From 766bf31737c417f2465cab177c8579f50c4c8069 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 06:56:26 -0700 Subject: [PATCH] feat: add ecc2 memory observation priorities --- ecc2/src/main.rs | 45 ++++++++++++++++--- ecc2/src/session/mod.rs | 48 ++++++++++++++++++++ ecc2/src/session/store.rs | 95 +++++++++++++++++++++++++++++++-------- ecc2/src/tui/dashboard.rs | 22 ++++++--- 4 files changed, 181 insertions(+), 29 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 982b5ef2..ce18d5fb 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -470,6 +470,9 @@ enum GraphCommands { /// Observation type such as completion_summary, incident_note, or reminder #[arg(long = "type")] observation_type: String, + /// Observation priority + #[arg(long, value_enum, default_value_t = ObservationPriorityArg::Normal)] + priority: ObservationPriorityArg, /// Observation summary #[arg(long)] summary: String, @@ -569,6 +572,25 @@ enum MessageKindArg { Conflict, } +#[derive(clap::ValueEnum, Clone, Debug)] +enum ObservationPriorityArg { + Low, + Normal, + High, + Critical, +} + +impl From for session::ContextObservationPriority { + fn from(value: ObservationPriorityArg) -> Self { + match value { + ObservationPriorityArg::Low => Self::Low, + ObservationPriorityArg::Normal => Self::Normal, + ObservationPriorityArg::High => Self::High, + ObservationPriorityArg::Critical => Self::Critical, + } + } +} + #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] struct GraphConnectorSyncStats { connector_name: String, @@ -1365,6 +1387,7 @@ async fn main() -> Result<()> { session_id, entity_id, observation_type, + priority, summary, details, json, @@ -1378,6 +1401,7 @@ async fn main() -> Result<()> { resolved_session_id.as_deref(), entity_id, &observation_type, + priority.into(), &summary, &details, )?; @@ -2119,6 +2143,7 @@ fn import_memory_connector_record( session_id.as_deref(), entity.id, observation_type, + session::ContextObservationPriority::Normal, summary, &record.details, )?; @@ -3323,6 +3348,7 @@ fn format_graph_observation_human(observation: &session::ContextGraphObservation observation.entity_id, observation.entity_type, observation.entity_name ), format!("Type: {}", observation.observation_type), + format!("Priority: {}", observation.priority), format!("Summary: {}", observation.summary), ]; if let Some(session_id) = observation.session_id.as_deref() { @@ -3354,8 +3380,11 @@ fn format_graph_observations_human(observations: &[session::ContextGraphObservat )]; for observation in observations { let mut line = format!( - "- #{} [{}] {}", - observation.id, observation.observation_type, observation.entity_name + "- #{} [{}/{}] {}", + observation.id, + observation.observation_type, + observation.priority, + observation.entity_name ); if let Some(session_id) = observation.session_id.as_deref() { line.push_str(&format!(" | {}", short_session(session_id))); @@ -3386,13 +3415,14 @@ fn format_graph_recall_human( )]; for entry in entries { let mut line = format!( - "- #{} [{}] {} | score {} | relations {} | observations {}", + "- #{} [{}] {} | score {} | relations {} | observations {} | priority {}", entry.entity.id, entry.entity.entity_type, entry.entity.name, entry.score, entry.relation_count, - entry.observation_count + entry.observation_count, + entry.max_observation_priority ); if let Some(session_id) = entry.entity.session_id.as_deref() { line.push_str(&format!(" | {}", short_session(session_id))); @@ -5448,6 +5478,7 @@ mod tests { session_id, entity_id, observation_type, + priority, summary, details, json, @@ -5456,6 +5487,7 @@ mod tests { assert_eq!(session_id.as_deref(), Some("latest")); assert_eq!(entity_id, 7); assert_eq!(observation_type, "completion_summary"); + assert!(matches!(priority, ObservationPriorityArg::Normal)); assert_eq!(summary, "Finished auth callback recovery"); assert_eq!(details, vec!["tests_run=2"]); assert!(json); @@ -5668,6 +5700,7 @@ mod tests { ], relation_count: 2, observation_count: 1, + max_observation_priority: session::ContextObservationPriority::High, }], Some("sess-12345678"), "auth callback recovery", @@ -5675,6 +5708,7 @@ mod tests { assert!(text.contains("Relevant memory: 1 entries")); assert!(text.contains("[file] callback.ts | score 319 | relations 2 | observations 1")); + assert!(text.contains("priority high")); assert!(text.contains("matches auth, callback, recovery")); assert!(text.contains("path src/routes/auth/callback.ts")); } @@ -5688,6 +5722,7 @@ mod tests { entity_type: "session".to_string(), entity_name: "sess-12345678".to_string(), observation_type: "completion_summary".to_string(), + priority: session::ContextObservationPriority::High, summary: "Finished auth callback recovery with 2 tests".to_string(), details: BTreeMap::from([("tests_run".to_string(), "2".to_string())]), created_at: chrono::DateTime::parse_from_rfc3339("2026-04-10T01:02:03Z") @@ -5696,7 +5731,7 @@ mod tests { }]); assert!(text.contains("Context graph observations: 1")); - assert!(text.contains("[completion_summary] sess-12345678")); + assert!(text.contains("[completion_summary/high] sess-12345678")); assert!(text.contains("summary Finished auth callback recovery with 2 tests")); } diff --git a/ecc2/src/session/mod.rs b/ecc2/src/session/mod.rs index 727a7c9f..878d88bc 100644 --- a/ecc2/src/session/mod.rs +++ b/ecc2/src/session/mod.rs @@ -198,6 +198,7 @@ pub struct ContextGraphObservation { pub entity_type: String, pub entity_name: String, pub observation_type: String, + pub priority: ContextObservationPriority, pub summary: String, pub details: BTreeMap, pub created_at: DateTime, @@ -210,6 +211,53 @@ pub struct ContextGraphRecallEntry { pub matched_terms: Vec, pub relation_count: usize, pub observation_count: usize, + pub max_observation_priority: ContextObservationPriority, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +pub enum ContextObservationPriority { + Low, + Normal, + High, + Critical, +} + +impl Default for ContextObservationPriority { + fn default() -> Self { + Self::Normal + } +} + +impl fmt::Display for ContextObservationPriority { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Low => write!(f, "low"), + Self::Normal => write!(f, "normal"), + Self::High => write!(f, "high"), + Self::Critical => write!(f, "critical"), + } + } +} + +impl ContextObservationPriority { + pub fn from_db_value(value: i64) -> Self { + match value { + 0 => Self::Low, + 2 => Self::High, + 3 => Self::Critical, + _ => Self::Normal, + } + } + + pub fn as_db_value(self) -> i64 { + match self { + Self::Low => 0, + Self::Normal => 1, + Self::High => 2, + Self::Critical => 3, + } + } } #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 356131d9..46025b0c 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -16,8 +16,8 @@ use super::{ default_project_label, default_task_group_label, normalize_group_label, ContextGraphCompactionStats, ContextGraphEntity, ContextGraphEntityDetail, ContextGraphObservation, ContextGraphRecallEntry, ContextGraphRelation, ContextGraphSyncStats, - DecisionLogEntry, FileActivityAction, FileActivityEntry, Session, SessionAgentProfile, - SessionMessage, SessionMetrics, SessionState, WorktreeInfo, + ContextObservationPriority, DecisionLogEntry, FileActivityAction, FileActivityEntry, Session, + SessionAgentProfile, SessionMessage, SessionMetrics, SessionState, WorktreeInfo, }; pub struct StateStore { @@ -267,6 +267,7 @@ impl StateStore { session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL, entity_id INTEGER NOT NULL REFERENCES context_graph_entities(id) ON DELETE CASCADE, observation_type TEXT NOT NULL, + priority INTEGER NOT NULL DEFAULT 1, summary TEXT NOT NULL, details_json TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL @@ -464,6 +465,15 @@ impl StateStore { .context("Failed to add trigger_summary column to tool_log table")?; } + if !self.has_column("context_graph_observations", "priority")? { + self.conn + .execute( + "ALTER TABLE context_graph_observations ADD COLUMN priority INTEGER NOT NULL DEFAULT 1", + [], + ) + .context("Failed to add priority column to context_graph_observations table")?; + } + if !self.has_column("daemon_activity", "last_dispatch_deferred")? { self.conn .execute( @@ -2088,6 +2098,12 @@ impl StateStore { FROM context_graph_observations o WHERE o.entity_id = e.id ) AS observation_count + , + COALESCE(( + SELECT MAX(priority) + FROM context_graph_observations o + WHERE o.entity_id = e.id + ), 1) AS max_observation_priority FROM context_graph_entities e WHERE (?1 IS NULL OR e.session_id = ?1) ORDER BY e.updated_at DESC, e.id DESC @@ -2102,7 +2118,15 @@ impl StateStore { let relation_count = row.get::<_, i64>(9)?.max(0) as usize; let observation_text = row.get::<_, String>(10)?; let observation_count = row.get::<_, i64>(11)?.max(0) as usize; - Ok((entity, relation_count, observation_text, observation_count)) + let max_observation_priority = + ContextObservationPriority::from_db_value(row.get::<_, i64>(12)?); + Ok(( + entity, + relation_count, + observation_text, + observation_count, + max_observation_priority, + )) }, )? .collect::, _>>()?; @@ -2111,7 +2135,13 @@ impl StateStore { let mut entries = candidates .into_iter() .filter_map( - |(entity, relation_count, observation_text, observation_count)| { + |( + entity, + relation_count, + observation_text, + observation_count, + max_observation_priority, + )| { let matched_terms = context_graph_matched_terms(&entity, &observation_text, &terms); if matched_terms.is_empty() { @@ -2123,6 +2153,7 @@ impl StateStore { matched_terms.len(), relation_count, observation_count, + max_observation_priority, entity.updated_at, now, ), @@ -2130,6 +2161,7 @@ impl StateStore { matched_terms, relation_count, observation_count, + max_observation_priority, }) }, ) @@ -2217,6 +2249,7 @@ impl StateStore { session_id: Option<&str>, entity_id: i64, observation_type: &str, + priority: ContextObservationPriority, summary: &str, details: &BTreeMap, ) -> Result { @@ -2235,12 +2268,13 @@ impl StateStore { let details_json = serde_json::to_string(details)?; self.conn.execute( "INSERT INTO context_graph_observations ( - session_id, entity_id, observation_type, summary, details_json, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + session_id, entity_id, observation_type, priority, summary, details_json, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", rusqlite::params![ session_id, entity_id, observation_type.trim(), + priority.as_db_value(), summary.trim(), details_json, now, @@ -2255,7 +2289,7 @@ impl StateStore { self.conn .query_row( "SELECT o.id, o.session_id, o.entity_id, e.entity_type, e.name, - o.observation_type, o.summary, o.details_json, o.created_at + o.observation_type, o.priority, o.summary, o.details_json, o.created_at FROM context_graph_observations o JOIN context_graph_entities e ON e.id = o.entity_id WHERE o.id = ?1", @@ -2277,6 +2311,7 @@ impl StateStore { &self, session_id: &str, observation_type: &str, + priority: ContextObservationPriority, summary: &str, details: &BTreeMap, ) -> Result { @@ -2285,6 +2320,7 @@ impl StateStore { Some(session_id), session_entity.id, observation_type, + priority, summary, details, ) @@ -2297,7 +2333,7 @@ impl StateStore { ) -> Result> { let mut stmt = self.conn.prepare( "SELECT o.id, o.session_id, o.entity_id, e.entity_type, e.name, - o.observation_type, o.summary, o.details_json, o.created_at + o.observation_type, o.priority, o.summary, o.details_json, o.created_at FROM context_graph_observations o JOIN context_graph_entities e ON e.id = o.entity_id WHERE (?1 IS NULL OR o.entity_id = ?1) @@ -3428,12 +3464,12 @@ fn map_context_graph_observation( row: &rusqlite::Row<'_>, ) -> rusqlite::Result { let details_json = row - .get::<_, Option>(7)? + .get::<_, Option>(8)? .unwrap_or_else(|| "{}".to_string()); let details = serde_json::from_str(&details_json).map_err(|error| { - rusqlite::Error::FromSqlConversionFailure(7, rusqlite::types::Type::Text, Box::new(error)) + rusqlite::Error::FromSqlConversionFailure(8, rusqlite::types::Type::Text, Box::new(error)) })?; - let created_at = parse_store_timestamp(row.get::<_, String>(8)?, 8)?; + let created_at = parse_store_timestamp(row.get::<_, String>(9)?, 9)?; Ok(ContextGraphObservation { id: row.get(0)?, @@ -3442,7 +3478,8 @@ fn map_context_graph_observation( entity_type: row.get(3)?, entity_name: row.get(4)?, observation_type: row.get(5)?, - summary: row.get(6)?, + priority: ContextObservationPriority::from_db_value(row.get::<_, i64>(6)?), + summary: row.get(7)?, details, created_at, }) @@ -3496,6 +3533,7 @@ fn context_graph_recall_score( matched_term_count: usize, relation_count: usize, observation_count: usize, + max_observation_priority: ContextObservationPriority, updated_at: chrono::DateTime, now: chrono::DateTime, ) -> u64 { @@ -3515,6 +3553,7 @@ fn context_graph_recall_score( (matched_term_count as u64 * 100) + (relation_count.min(9) as u64 * 10) + (observation_count.min(6) as u64 * 8) + + (max_observation_priority.as_db_value() as u64 * 18) + recency_bonus } @@ -4336,6 +4375,7 @@ mod tests { Some("session-1"), entity.id, "note", + ContextObservationPriority::Normal, "Customer wiped setup and got charged twice", &BTreeMap::from([("customer".to_string(), "viktor".to_string())]), )?; @@ -4345,6 +4385,7 @@ mod tests { assert_eq!(observations[0].id, observation.id); assert_eq!(observations[0].entity_name, "Prefer recovery-first routing"); assert_eq!(observations[0].observation_type, "note"); + assert_eq!(observations[0].priority, ContextObservationPriority::Normal); assert_eq!( observations[0].details.get("customer"), Some(&"viktor".to_string()) @@ -4393,12 +4434,13 @@ mod tests { ] { db.conn.execute( "INSERT INTO context_graph_observations ( - session_id, entity_id, observation_type, summary, details_json, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + session_id, entity_id, observation_type, priority, summary, details_json, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", rusqlite::params![ "session-1", entity.id, "note", + ContextObservationPriority::Normal.as_db_value(), summary, "{}", chrono::Utc::now().to_rfc3339(), @@ -4460,6 +4502,7 @@ mod tests { Some("session-1"), entity.id, "completion_summary", + ContextObservationPriority::Normal, &summary, &BTreeMap::new(), )?; @@ -4542,6 +4585,7 @@ mod tests { Some("session-1"), recovery.id, "incident_note", + ContextObservationPriority::High, "Previous auth callback recovery incident affected Viktor after a wipe", &BTreeMap::new(), )?; @@ -4550,19 +4594,32 @@ mod tests { db.recall_context_entities(Some("session-1"), "Investigate auth callback recovery", 3)?; assert_eq!(results.len(), 2); - assert_eq!(results[0].entity.id, callback.id); + assert_eq!(results[0].entity.id, recovery.id); assert!(results[0].matched_terms.iter().any(|term| term == "auth")); assert!(results[0] + .matched_terms + .iter() + .any(|term| term == "recovery")); + assert_eq!(results[0].observation_count, 1); + assert_eq!( + results[0].max_observation_priority, + ContextObservationPriority::High + ); + assert_eq!(results[1].entity.id, callback.id); + assert!(results[1] .matched_terms .iter() .any(|term| term == "callback")); - assert!(results[0] + assert!(results[1] .matched_terms .iter() .any(|term| term == "recovery")); - assert_eq!(results[0].relation_count, 2); - assert_eq!(results[1].entity.id, recovery.id); - assert_eq!(results[1].observation_count, 1); + assert_eq!(results[1].relation_count, 2); + assert_eq!(results[1].observation_count, 0); + assert_eq!( + results[1].max_observation_priority, + ContextObservationPriority::Normal + ); assert!(!results.iter().any(|entry| entry.entity.id == unrelated.id)); Ok(()) diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index c0a013fa..a9e7464b 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -23,7 +23,8 @@ use crate::session::output::{ }; use crate::session::store::{DaemonActivity, FileActivityOverlap, StateStore}; use crate::session::{ - DecisionLogEntry, FileActivityEntry, Session, SessionGrouping, SessionMessage, SessionState, + ContextObservationPriority, DecisionLogEntry, FileActivityEntry, Session, SessionGrouping, + SessionMessage, SessionState, }; use crate::worktree; @@ -4251,9 +4252,15 @@ impl Dashboard { summary.warnings.len() ); let details = completion_summary_observation_details(summary, session); + let priority = if observation_type == "failure_summary" { + ContextObservationPriority::High + } else { + ContextObservationPriority::Normal + }; if let Err(error) = self.db.add_session_observation( &session.id, observation_type, + priority, &observation_summary, &details, ) { @@ -5358,13 +5365,14 @@ impl Dashboard { let mut lines = vec!["Relevant memory".to_string()]; for entry in entries { let mut line = format!( - "- #{} [{}] {} | score {} | relations {} | observations {}", + "- #{} [{}] {} | score {} | relations {} | observations {} | priority {}", entry.entity.id, entry.entity.entity_type, truncate_for_dashboard(&entry.entity.name, 60), entry.score, entry.relation_count, - entry.observation_count + entry.observation_count, + entry.max_observation_priority ); if let Some(session_id) = entry.entity.session_id.as_deref() { if session_id != session.id { @@ -5387,7 +5395,8 @@ impl Dashboard { if let Ok(observations) = self.db.list_context_observations(Some(entry.entity.id), 1) { if let Some(observation) = observations.first() { lines.push(format!( - " memory {}", + " memory [{}] {}", + observation.priority, truncate_for_dashboard(&observation.summary, 72) )); } @@ -10534,6 +10543,7 @@ diff --git a/src/lib.rs b/src/lib.rs\n\ Some(&memory.id), entity.id, "completion_summary", + ContextObservationPriority::Normal, "Recovered auth callback incident with billing fallback", &BTreeMap::new(), )?; @@ -10542,7 +10552,9 @@ diff --git a/src/lib.rs b/src/lib.rs\n\ assert!(text.contains("Relevant memory")); assert!(text.contains("[file] callback.ts")); assert!(text.contains("matches auth, callback, recovery")); - assert!(text.contains("memory Recovered auth callback incident with billing fallback")); + assert!( + text.contains("memory [normal] Recovered auth callback incident with billing fallback") + ); Ok(()) }