mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-11 10:32:12 +08:00
feat: add ecc2 graph observations
This commit is contained in:
parent
727d9380cb
commit
77c9082deb
195
ecc2/src/main.rs
195
ecc2/src/main.rs
@ -457,6 +457,39 @@ enum GraphCommands {
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Record an observation against a context graph entity
|
||||
AddObservation {
|
||||
/// Optional source session ID or alias for provenance
|
||||
#[arg(long)]
|
||||
session_id: Option<String>,
|
||||
/// Entity ID
|
||||
#[arg(long)]
|
||||
entity_id: i64,
|
||||
/// Observation type such as completion_summary, incident_note, or reminder
|
||||
#[arg(long = "type")]
|
||||
observation_type: String,
|
||||
/// Observation summary
|
||||
#[arg(long)]
|
||||
summary: String,
|
||||
/// Details in key=value form
|
||||
#[arg(long = "detail")]
|
||||
details: Vec<String>,
|
||||
/// Emit machine-readable JSON instead of the human summary
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// List observations in the shared context graph
|
||||
Observations {
|
||||
/// Filter to observations for a specific entity ID
|
||||
#[arg(long)]
|
||||
entity_id: Option<i64>,
|
||||
/// Maximum observations to return
|
||||
#[arg(long, default_value_t = 20)]
|
||||
limit: usize,
|
||||
/// Emit machine-readable JSON instead of the human summary
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Recall relevant context graph entities for a query
|
||||
Recall {
|
||||
/// Filter by source session ID or alias
|
||||
@ -1243,6 +1276,44 @@ async fn main() -> Result<()> {
|
||||
println!("{}", format_graph_relations_human(&relations));
|
||||
}
|
||||
}
|
||||
GraphCommands::AddObservation {
|
||||
session_id,
|
||||
entity_id,
|
||||
observation_type,
|
||||
summary,
|
||||
details,
|
||||
json,
|
||||
} => {
|
||||
let resolved_session_id = session_id
|
||||
.as_deref()
|
||||
.map(|value| resolve_session_id(&db, value))
|
||||
.transpose()?;
|
||||
let details = parse_key_value_pairs(&details, "graph observation details")?;
|
||||
let observation = db.add_context_observation(
|
||||
resolved_session_id.as_deref(),
|
||||
entity_id,
|
||||
&observation_type,
|
||||
&summary,
|
||||
&details,
|
||||
)?;
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&observation)?);
|
||||
} else {
|
||||
println!("{}", format_graph_observation_human(&observation));
|
||||
}
|
||||
}
|
||||
GraphCommands::Observations {
|
||||
entity_id,
|
||||
limit,
|
||||
json,
|
||||
} => {
|
||||
let observations = db.list_context_observations(entity_id, limit)?;
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&observations)?);
|
||||
} else {
|
||||
println!("{}", format_graph_observations_human(&observations));
|
||||
}
|
||||
}
|
||||
GraphCommands::Recall {
|
||||
session_id,
|
||||
query,
|
||||
@ -2249,6 +2320,58 @@ fn format_graph_relations_human(relations: &[session::ContextGraphRelation]) ->
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn format_graph_observation_human(observation: &session::ContextGraphObservation) -> String {
|
||||
let mut lines = vec![
|
||||
format!("Context graph observation #{}", observation.id),
|
||||
format!(
|
||||
"Entity: #{} [{}] {}",
|
||||
observation.entity_id, observation.entity_type, observation.entity_name
|
||||
),
|
||||
format!("Type: {}", observation.observation_type),
|
||||
format!("Summary: {}", observation.summary),
|
||||
];
|
||||
if let Some(session_id) = observation.session_id.as_deref() {
|
||||
lines.push(format!("Session: {}", short_session(session_id)));
|
||||
}
|
||||
if observation.details.is_empty() {
|
||||
lines.push("Details: none recorded".to_string());
|
||||
} else {
|
||||
lines.push("Details:".to_string());
|
||||
for (key, value) in &observation.details {
|
||||
lines.push(format!("- {key}={value}"));
|
||||
}
|
||||
}
|
||||
lines.push(format!(
|
||||
"Created: {}",
|
||||
observation.created_at.format("%Y-%m-%d %H:%M:%S UTC")
|
||||
));
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn format_graph_observations_human(observations: &[session::ContextGraphObservation]) -> String {
|
||||
if observations.is_empty() {
|
||||
return "No context graph observations found.".to_string();
|
||||
}
|
||||
|
||||
let mut lines = vec![format!(
|
||||
"Context graph observations: {}",
|
||||
observations.len()
|
||||
)];
|
||||
for observation in observations {
|
||||
let mut line = format!(
|
||||
"- #{} [{}] {}",
|
||||
observation.id, observation.observation_type, observation.entity_name
|
||||
);
|
||||
if let Some(session_id) = observation.session_id.as_deref() {
|
||||
line.push_str(&format!(" | {}", short_session(session_id)));
|
||||
}
|
||||
lines.push(line);
|
||||
lines.push(format!(" summary {}", observation.summary));
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn format_graph_recall_human(
|
||||
entries: &[session::ContextGraphRecallEntry],
|
||||
session_id: Option<&str>,
|
||||
@ -2268,12 +2391,13 @@ fn format_graph_recall_human(
|
||||
)];
|
||||
for entry in entries {
|
||||
let mut line = format!(
|
||||
"- #{} [{}] {} | score {} | relations {}",
|
||||
"- #{} [{}] {} | score {} | relations {} | observations {}",
|
||||
entry.entity.id,
|
||||
entry.entity.entity_type,
|
||||
entry.entity.name,
|
||||
entry.score,
|
||||
entry.relation_count
|
||||
entry.relation_count,
|
||||
entry.observation_count
|
||||
);
|
||||
if let Some(session_id) = entry.entity.session_id.as_deref() {
|
||||
line.push_str(&format!(" | {}", short_session(session_id)));
|
||||
@ -4226,6 +4350,49 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_parses_graph_add_observation_command() {
|
||||
let cli = Cli::try_parse_from([
|
||||
"ecc",
|
||||
"graph",
|
||||
"add-observation",
|
||||
"--session-id",
|
||||
"latest",
|
||||
"--entity-id",
|
||||
"7",
|
||||
"--type",
|
||||
"completion_summary",
|
||||
"--summary",
|
||||
"Finished auth callback recovery",
|
||||
"--detail",
|
||||
"tests_run=2",
|
||||
"--json",
|
||||
])
|
||||
.expect("graph add-observation should parse");
|
||||
|
||||
match cli.command {
|
||||
Some(Commands::Graph {
|
||||
command:
|
||||
GraphCommands::AddObservation {
|
||||
session_id,
|
||||
entity_id,
|
||||
observation_type,
|
||||
summary,
|
||||
details,
|
||||
json,
|
||||
},
|
||||
}) => {
|
||||
assert_eq!(session_id.as_deref(), Some("latest"));
|
||||
assert_eq!(entity_id, 7);
|
||||
assert_eq!(observation_type, "completion_summary");
|
||||
assert_eq!(summary, "Finished auth callback recovery");
|
||||
assert_eq!(details, vec!["tests_run=2"]);
|
||||
assert!(json);
|
||||
}
|
||||
_ => panic!("expected graph add-observation subcommand"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_decisions_human_renders_details() {
|
||||
let text = format_decisions_human(
|
||||
@ -4334,17 +4501,39 @@ mod tests {
|
||||
"recovery".to_string(),
|
||||
],
|
||||
relation_count: 2,
|
||||
observation_count: 1,
|
||||
}],
|
||||
Some("sess-12345678"),
|
||||
"auth callback recovery",
|
||||
);
|
||||
|
||||
assert!(text.contains("Relevant memory: 1 entries"));
|
||||
assert!(text.contains("[file] callback.ts | score 319 | relations 2"));
|
||||
assert!(text.contains("[file] callback.ts | score 319 | relations 2 | observations 1"));
|
||||
assert!(text.contains("matches auth, callback, recovery"));
|
||||
assert!(text.contains("path src/routes/auth/callback.ts"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_graph_observations_human_renders_summaries() {
|
||||
let text = format_graph_observations_human(&[session::ContextGraphObservation {
|
||||
id: 5,
|
||||
session_id: Some("sess-12345678".to_string()),
|
||||
entity_id: 11,
|
||||
entity_type: "session".to_string(),
|
||||
entity_name: "sess-12345678".to_string(),
|
||||
observation_type: "completion_summary".to_string(),
|
||||
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")
|
||||
.unwrap()
|
||||
.with_timezone(&chrono::Utc),
|
||||
}]);
|
||||
|
||||
assert!(text.contains("Context graph observations: 1"));
|
||||
assert!(text.contains("[completion_summary] sess-12345678"));
|
||||
assert!(text.contains("summary Finished auth callback recovery with 2 tests"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_graph_sync_stats_human_renders_counts() {
|
||||
let text = format_graph_sync_stats_human(
|
||||
|
||||
@ -190,12 +190,26 @@ pub struct ContextGraphEntityDetail {
|
||||
pub incoming: Vec<ContextGraphRelation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ContextGraphObservation {
|
||||
pub id: i64,
|
||||
pub session_id: Option<String>,
|
||||
pub entity_id: i64,
|
||||
pub entity_type: String,
|
||||
pub entity_name: String,
|
||||
pub observation_type: String,
|
||||
pub summary: String,
|
||||
pub details: BTreeMap<String, String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ContextGraphRecallEntry {
|
||||
pub entity: ContextGraphEntity,
|
||||
pub score: u64,
|
||||
pub matched_terms: Vec<String>,
|
||||
pub relation_count: usize,
|
||||
pub observation_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
|
||||
@ -14,9 +14,10 @@ use crate::observability::{ToolCallEvent, ToolLogEntry, ToolLogPage};
|
||||
use super::output::{OutputLine, OutputStream, OUTPUT_BUFFER_LIMIT};
|
||||
use super::{
|
||||
default_project_label, default_task_group_label, normalize_group_label, ContextGraphEntity,
|
||||
ContextGraphEntityDetail, ContextGraphRecallEntry, ContextGraphRelation, ContextGraphSyncStats,
|
||||
DecisionLogEntry, FileActivityAction, FileActivityEntry, Session, SessionAgentProfile,
|
||||
SessionMessage, SessionMetrics, SessionState, WorktreeInfo,
|
||||
ContextGraphEntityDetail, ContextGraphObservation, ContextGraphRecallEntry,
|
||||
ContextGraphRelation, ContextGraphSyncStats, DecisionLogEntry, FileActivityAction,
|
||||
FileActivityEntry, Session, SessionAgentProfile, SessionMessage, SessionMetrics, SessionState,
|
||||
WorktreeInfo,
|
||||
};
|
||||
|
||||
pub struct StateStore {
|
||||
@ -259,6 +260,16 @@ impl StateStore {
|
||||
UNIQUE(from_entity_id, to_entity_id, relation_type)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS context_graph_observations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
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,
|
||||
summary TEXT NOT NULL,
|
||||
details_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pending_worktree_queue (
|
||||
session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
repo_root TEXT NOT NULL,
|
||||
@ -319,6 +330,8 @@ impl StateStore {
|
||||
ON context_graph_relations(from_entity_id, created_at, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_context_graph_relations_to
|
||||
ON context_graph_relations(to_entity_id, created_at, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_context_graph_observations_entity
|
||||
ON context_graph_observations(entity_id, created_at, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_conflict_incidents_sessions
|
||||
ON conflict_incidents(first_session_id, second_session_id, resolved_at, updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_pending_worktree_queue_requested_at
|
||||
@ -2047,7 +2060,22 @@ impl StateStore {
|
||||
SELECT COUNT(*)
|
||||
FROM context_graph_relations r
|
||||
WHERE r.from_entity_id = e.id OR r.to_entity_id = e.id
|
||||
) AS relation_count
|
||||
) AS relation_count,
|
||||
COALESCE((
|
||||
SELECT group_concat(summary, ' ')
|
||||
FROM (
|
||||
SELECT summary
|
||||
FROM context_graph_observations o
|
||||
WHERE o.entity_id = e.id
|
||||
ORDER BY o.created_at DESC, o.id DESC
|
||||
LIMIT 4
|
||||
)
|
||||
), '') AS observation_text,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM context_graph_observations o
|
||||
WHERE o.entity_id = e.id
|
||||
) AS observation_count
|
||||
FROM context_graph_entities e
|
||||
WHERE (?1 IS NULL OR e.session_id = ?1)
|
||||
ORDER BY e.updated_at DESC, e.id DESC
|
||||
@ -2060,7 +2088,9 @@ impl StateStore {
|
||||
|row| {
|
||||
let entity = map_context_graph_entity(row)?;
|
||||
let relation_count = row.get::<_, i64>(9)?.max(0) as usize;
|
||||
Ok((entity, relation_count))
|
||||
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))
|
||||
},
|
||||
)?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
@ -2068,24 +2098,29 @@ impl StateStore {
|
||||
let now = chrono::Utc::now();
|
||||
let mut entries = candidates
|
||||
.into_iter()
|
||||
.filter_map(|(entity, relation_count)| {
|
||||
let matched_terms = context_graph_matched_terms(&entity, &terms);
|
||||
if matched_terms.is_empty() {
|
||||
return None;
|
||||
}
|
||||
.filter_map(
|
||||
|(entity, relation_count, observation_text, observation_count)| {
|
||||
let matched_terms =
|
||||
context_graph_matched_terms(&entity, &observation_text, &terms);
|
||||
if matched_terms.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(ContextGraphRecallEntry {
|
||||
score: context_graph_recall_score(
|
||||
matched_terms.len(),
|
||||
Some(ContextGraphRecallEntry {
|
||||
score: context_graph_recall_score(
|
||||
matched_terms.len(),
|
||||
relation_count,
|
||||
observation_count,
|
||||
entity.updated_at,
|
||||
now,
|
||||
),
|
||||
entity,
|
||||
matched_terms,
|
||||
relation_count,
|
||||
entity.updated_at,
|
||||
now,
|
||||
),
|
||||
entity,
|
||||
matched_terms,
|
||||
relation_count,
|
||||
})
|
||||
})
|
||||
observation_count,
|
||||
})
|
||||
},
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
entries.sort_by(|left, right| {
|
||||
@ -2165,6 +2200,95 @@ impl StateStore {
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn add_context_observation(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
entity_id: i64,
|
||||
observation_type: &str,
|
||||
summary: &str,
|
||||
details: &BTreeMap<String, String>,
|
||||
) -> Result<ContextGraphObservation> {
|
||||
if observation_type.trim().is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Context graph observation type cannot be empty"
|
||||
));
|
||||
}
|
||||
if summary.trim().is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Context graph observation summary cannot be empty"
|
||||
));
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
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)",
|
||||
rusqlite::params![
|
||||
session_id,
|
||||
entity_id,
|
||||
observation_type.trim(),
|
||||
summary.trim(),
|
||||
details_json,
|
||||
now,
|
||||
],
|
||||
)?;
|
||||
let observation_id = self.conn.last_insert_rowid();
|
||||
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
|
||||
FROM context_graph_observations o
|
||||
JOIN context_graph_entities e ON e.id = o.entity_id
|
||||
WHERE o.id = ?1",
|
||||
rusqlite::params![observation_id],
|
||||
map_context_graph_observation,
|
||||
)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn add_session_observation(
|
||||
&self,
|
||||
session_id: &str,
|
||||
observation_type: &str,
|
||||
summary: &str,
|
||||
details: &BTreeMap<String, String>,
|
||||
) -> Result<ContextGraphObservation> {
|
||||
let session_entity = self.sync_context_graph_session(session_id)?;
|
||||
self.add_context_observation(
|
||||
Some(session_id),
|
||||
session_entity.id,
|
||||
observation_type,
|
||||
summary,
|
||||
details,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_context_observations(
|
||||
&self,
|
||||
entity_id: Option<i64>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<ContextGraphObservation>> {
|
||||
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
|
||||
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)
|
||||
ORDER BY o.created_at DESC, o.id DESC
|
||||
LIMIT ?2",
|
||||
)?;
|
||||
|
||||
let entries = stmt
|
||||
.query_map(
|
||||
rusqlite::params![entity_id, limit as i64],
|
||||
map_context_graph_observation,
|
||||
)?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub fn upsert_context_relation(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
@ -3147,6 +3271,30 @@ fn map_context_graph_relation(row: &rusqlite::Row<'_>) -> rusqlite::Result<Conte
|
||||
})
|
||||
}
|
||||
|
||||
fn map_context_graph_observation(
|
||||
row: &rusqlite::Row<'_>,
|
||||
) -> rusqlite::Result<ContextGraphObservation> {
|
||||
let details_json = row
|
||||
.get::<_, Option<String>>(7)?
|
||||
.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))
|
||||
})?;
|
||||
let created_at = parse_store_timestamp(row.get::<_, String>(8)?, 8)?;
|
||||
|
||||
Ok(ContextGraphObservation {
|
||||
id: row.get(0)?,
|
||||
session_id: row.get(1)?,
|
||||
entity_id: row.get(2)?,
|
||||
entity_type: row.get(3)?,
|
||||
entity_name: row.get(4)?,
|
||||
observation_type: row.get(5)?,
|
||||
summary: row.get(6)?,
|
||||
details,
|
||||
created_at,
|
||||
})
|
||||
}
|
||||
|
||||
fn context_graph_recall_terms(query: &str) -> Vec<String> {
|
||||
let mut terms = Vec::new();
|
||||
for raw_term in
|
||||
@ -3161,7 +3309,11 @@ fn context_graph_recall_terms(query: &str) -> Vec<String> {
|
||||
terms
|
||||
}
|
||||
|
||||
fn context_graph_matched_terms(entity: &ContextGraphEntity, terms: &[String]) -> Vec<String> {
|
||||
fn context_graph_matched_terms(
|
||||
entity: &ContextGraphEntity,
|
||||
observation_text: &str,
|
||||
terms: &[String],
|
||||
) -> Vec<String> {
|
||||
let mut haystacks = vec![
|
||||
entity.entity_type.to_ascii_lowercase(),
|
||||
entity.name.to_ascii_lowercase(),
|
||||
@ -3174,6 +3326,9 @@ fn context_graph_matched_terms(entity: &ContextGraphEntity, terms: &[String]) ->
|
||||
haystacks.push(key.to_ascii_lowercase());
|
||||
haystacks.push(value.to_ascii_lowercase());
|
||||
}
|
||||
if !observation_text.trim().is_empty() {
|
||||
haystacks.push(observation_text.to_ascii_lowercase());
|
||||
}
|
||||
|
||||
let mut matched = Vec::new();
|
||||
for term in terms {
|
||||
@ -3187,6 +3342,7 @@ fn context_graph_matched_terms(entity: &ContextGraphEntity, terms: &[String]) ->
|
||||
fn context_graph_recall_score(
|
||||
matched_term_count: usize,
|
||||
relation_count: usize,
|
||||
observation_count: usize,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
now: chrono::DateTime<chrono::Utc>,
|
||||
) -> u64 {
|
||||
@ -3203,7 +3359,10 @@ fn context_graph_recall_score(
|
||||
}
|
||||
};
|
||||
|
||||
(matched_term_count as u64 * 100) + (relation_count.min(9) as u64 * 10) + recency_bonus
|
||||
(matched_term_count as u64 * 100)
|
||||
+ (relation_count.min(9) as u64 * 10)
|
||||
+ (observation_count.min(6) as u64 * 8)
|
||||
+ recency_bonus
|
||||
}
|
||||
|
||||
fn parse_store_timestamp(
|
||||
@ -3990,6 +4149,57 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_and_list_context_observations() -> Result<()> {
|
||||
let tempdir = TestDir::new("store-context-observations")?;
|
||||
let db = StateStore::open(&tempdir.path().join("state.db"))?;
|
||||
let now = Utc::now();
|
||||
|
||||
db.insert_session(&Session {
|
||||
id: "session-1".to_string(),
|
||||
task: "deep memory".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "knowledge".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
state: SessionState::Running,
|
||||
pid: None,
|
||||
worktree: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
last_heartbeat_at: now,
|
||||
metrics: SessionMetrics::default(),
|
||||
})?;
|
||||
|
||||
let entity = db.upsert_context_entity(
|
||||
Some("session-1"),
|
||||
"decision",
|
||||
"Prefer recovery-first routing",
|
||||
None,
|
||||
"Recovered installs should go through the portal first",
|
||||
&BTreeMap::new(),
|
||||
)?;
|
||||
let observation = db.add_context_observation(
|
||||
Some("session-1"),
|
||||
entity.id,
|
||||
"note",
|
||||
"Customer wiped setup and got charged twice",
|
||||
&BTreeMap::from([("customer".to_string(), "viktor".to_string())]),
|
||||
)?;
|
||||
|
||||
let observations = db.list_context_observations(Some(entity.id), 10)?;
|
||||
assert_eq!(observations.len(), 1);
|
||||
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].details.get("customer"),
|
||||
Some(&"viktor".to_string())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recall_context_entities_ranks_matching_entities() -> Result<()> {
|
||||
let tempdir = TestDir::new("store-context-recall")?;
|
||||
@ -4051,6 +4261,13 @@ mod tests {
|
||||
"references",
|
||||
"Callback route references the dashboard summary",
|
||||
)?;
|
||||
db.add_context_observation(
|
||||
Some("session-1"),
|
||||
recovery.id,
|
||||
"incident_note",
|
||||
"Previous auth callback recovery incident affected Viktor after a wipe",
|
||||
&BTreeMap::new(),
|
||||
)?;
|
||||
|
||||
let results =
|
||||
db.recall_context_entities(Some("session-1"), "Investigate auth callback recovery", 3)?;
|
||||
@ -4068,6 +4285,7 @@ mod tests {
|
||||
.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!(!results.iter().any(|entry| entry.entity.id == unrelated.id));
|
||||
|
||||
Ok(())
|
||||
|
||||
@ -4153,6 +4153,11 @@ impl Dashboard {
|
||||
}
|
||||
SessionState::Completed => {
|
||||
let summary = self.build_completion_summary(session);
|
||||
self.persist_completion_summary_observation(
|
||||
session,
|
||||
&summary,
|
||||
"completion_summary",
|
||||
);
|
||||
if self.cfg.completion_summary_notifications.enabled {
|
||||
completion_summaries.push(summary.clone());
|
||||
} else if self.cfg.desktop_notifications.session_completed {
|
||||
@ -4174,6 +4179,11 @@ impl Dashboard {
|
||||
}
|
||||
SessionState::Failed => {
|
||||
let summary = self.build_completion_summary(session);
|
||||
self.persist_completion_summary_observation(
|
||||
session,
|
||||
&summary,
|
||||
"failure_summary",
|
||||
);
|
||||
failed_notifications.push((
|
||||
"ECC 2.0: Session failed".to_string(),
|
||||
format!(
|
||||
@ -4226,6 +4236,34 @@ impl Dashboard {
|
||||
self.last_session_states = next_states;
|
||||
}
|
||||
|
||||
fn persist_completion_summary_observation(
|
||||
&self,
|
||||
session: &Session,
|
||||
summary: &SessionCompletionSummary,
|
||||
observation_type: &str,
|
||||
) {
|
||||
let observation_summary = format!(
|
||||
"{} | files {} | tests {}/{} | warnings {}",
|
||||
truncate_for_dashboard(&summary.task, 72),
|
||||
summary.files_changed,
|
||||
summary.tests_passed,
|
||||
summary.tests_run,
|
||||
summary.warnings.len()
|
||||
);
|
||||
let details = completion_summary_observation_details(summary, session);
|
||||
if let Err(error) = self.db.add_session_observation(
|
||||
&session.id,
|
||||
observation_type,
|
||||
&observation_summary,
|
||||
&details,
|
||||
) {
|
||||
tracing::warn!(
|
||||
"Failed to persist completion observation for {}: {error}",
|
||||
session.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_approval_notifications(&mut self) {
|
||||
let latest_message = match self.db.latest_unread_approval_message() {
|
||||
Ok(message) => message,
|
||||
@ -5320,12 +5358,13 @@ impl Dashboard {
|
||||
let mut lines = vec!["Relevant memory".to_string()];
|
||||
for entry in entries {
|
||||
let mut line = format!(
|
||||
"- #{} [{}] {} | score {} | relations {}",
|
||||
"- #{} [{}] {} | score {} | relations {} | observations {}",
|
||||
entry.entity.id,
|
||||
entry.entity.entity_type,
|
||||
truncate_for_dashboard(&entry.entity.name, 60),
|
||||
entry.score,
|
||||
entry.relation_count
|
||||
entry.relation_count,
|
||||
entry.observation_count
|
||||
);
|
||||
if let Some(session_id) = entry.entity.session_id.as_deref() {
|
||||
if session_id != session.id {
|
||||
@ -5345,6 +5384,14 @@ impl Dashboard {
|
||||
truncate_for_dashboard(&entry.entity.summary, 72)
|
||||
));
|
||||
}
|
||||
if let Ok(observations) = self.db.list_context_observations(Some(entry.entity.id), 1) {
|
||||
if let Some(observation) = observations.first() {
|
||||
lines.push(format!(
|
||||
" memory {}",
|
||||
truncate_for_dashboard(&observation.summary, 72)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines
|
||||
@ -8517,6 +8564,39 @@ fn summarize_completion_warnings(
|
||||
warnings
|
||||
}
|
||||
|
||||
fn completion_summary_observation_details(
|
||||
summary: &SessionCompletionSummary,
|
||||
session: &Session,
|
||||
) -> BTreeMap<String, String> {
|
||||
let mut details = BTreeMap::new();
|
||||
details.insert("state".to_string(), session.state.to_string());
|
||||
details.insert(
|
||||
"files_changed".to_string(),
|
||||
summary.files_changed.to_string(),
|
||||
);
|
||||
details.insert("tokens_used".to_string(), summary.tokens_used.to_string());
|
||||
details.insert(
|
||||
"duration_secs".to_string(),
|
||||
summary.duration_secs.to_string(),
|
||||
);
|
||||
details.insert("cost_usd".to_string(), format!("{:.4}", summary.cost_usd));
|
||||
details.insert("tests_run".to_string(), summary.tests_run.to_string());
|
||||
details.insert("tests_passed".to_string(), summary.tests_passed.to_string());
|
||||
if !summary.recent_files.is_empty() {
|
||||
details.insert("recent_files".to_string(), summary.recent_files.join(" | "));
|
||||
}
|
||||
if !summary.key_decisions.is_empty() {
|
||||
details.insert(
|
||||
"key_decisions".to_string(),
|
||||
summary.key_decisions.join(" | "),
|
||||
);
|
||||
}
|
||||
if !summary.warnings.is_empty() {
|
||||
details.insert("warnings".to_string(), summary.warnings.join(" | "));
|
||||
}
|
||||
details
|
||||
}
|
||||
|
||||
fn session_started_webhook_body(session: &Session, compare_url: Option<&str>) -> String {
|
||||
let mut lines = vec![
|
||||
"*ECC 2.0: Session started*".to_string(),
|
||||
@ -10444,11 +10524,25 @@ diff --git a/src/lib.rs b/src/lib.rs\n\
|
||||
"Handles auth callback recovery and billing fallback",
|
||||
&BTreeMap::from([("area".to_string(), "auth".to_string())]),
|
||||
)?;
|
||||
let entity = dashboard
|
||||
.db
|
||||
.list_context_entities(Some(&memory.id), Some("file"), 10)?
|
||||
.into_iter()
|
||||
.find(|entry| entry.name == "callback.ts")
|
||||
.expect("callback entity");
|
||||
dashboard.db.add_context_observation(
|
||||
Some(&memory.id),
|
||||
entity.id,
|
||||
"completion_summary",
|
||||
"Recovered auth callback incident with billing fallback",
|
||||
&BTreeMap::new(),
|
||||
)?;
|
||||
|
||||
let text = dashboard.selected_session_metrics_text();
|
||||
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"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -11876,6 +11970,73 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_persists_completion_summary_observation() -> Result<()> {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("ecc2-completion-observation-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(root.join(".claude").join("metrics"))?;
|
||||
|
||||
let mut cfg = build_config(&root.join(".claude"));
|
||||
cfg.completion_summary_notifications.delivery =
|
||||
crate::notifications::CompletionSummaryDelivery::TuiPopup;
|
||||
cfg.desktop_notifications.session_completed = false;
|
||||
|
||||
let db = StateStore::open(&cfg.db_path)?;
|
||||
let mut session = sample_session(
|
||||
"done-observation",
|
||||
"claude",
|
||||
SessionState::Running,
|
||||
Some("ecc/observation"),
|
||||
144,
|
||||
42,
|
||||
);
|
||||
session.task = "Recover auth callback after wipe".to_string();
|
||||
db.insert_session(&session)?;
|
||||
|
||||
let metrics_path = cfg.tool_activity_metrics_path();
|
||||
fs::create_dir_all(metrics_path.parent().unwrap())?;
|
||||
fs::write(
|
||||
&metrics_path,
|
||||
concat!(
|
||||
"{\"id\":\"evt-1\",\"session_id\":\"done-observation\",\"tool_name\":\"Bash\",\"input_summary\":\"cargo test -q\",\"input_params_json\":\"{\\\"command\\\":\\\"cargo test -q\\\"}\",\"output_summary\":\"ok\",\"timestamp\":\"2026-04-09T00:00:00Z\"}\n",
|
||||
"{\"id\":\"evt-2\",\"session_id\":\"done-observation\",\"tool_name\":\"Write\",\"input_summary\":\"Write src/routes/auth/callback.ts\",\"output_summary\":\"updated callback\",\"file_events\":[{\"path\":\"src/routes/auth/callback.ts\",\"action\":\"modify\",\"diff_preview\":\"portal first\",\"patch_preview\":\"+ portal first\"}],\"timestamp\":\"2026-04-09T00:01:00Z\"}\n"
|
||||
),
|
||||
)?;
|
||||
|
||||
let mut dashboard = Dashboard::new(db, cfg);
|
||||
dashboard
|
||||
.db
|
||||
.update_state("done-observation", &SessionState::Completed)?;
|
||||
|
||||
dashboard.refresh();
|
||||
|
||||
let session_entity = dashboard
|
||||
.db
|
||||
.list_context_entities(Some("done-observation"), Some("session"), 10)?
|
||||
.into_iter()
|
||||
.find(|entity| entity.name == "done-observation")
|
||||
.expect("session entity");
|
||||
let observations = dashboard
|
||||
.db
|
||||
.list_context_observations(Some(session_entity.id), 10)?;
|
||||
assert!(!observations.is_empty());
|
||||
assert_eq!(observations[0].observation_type, "completion_summary");
|
||||
assert!(observations[0]
|
||||
.summary
|
||||
.contains("Recover auth callback after wipe"));
|
||||
assert_eq!(
|
||||
observations[0].details.get("tests_run"),
|
||||
Some(&"1".to_string())
|
||||
);
|
||||
assert!(observations[0]
|
||||
.details
|
||||
.get("recent_files")
|
||||
.is_some_and(|value| value.contains("modify src/routes/auth/callback.ts")));
|
||||
|
||||
let _ = fs::remove_dir_all(root);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dismiss_completion_popup_promotes_the_next_summary() {
|
||||
let mut dashboard = test_dashboard(Vec::new(), 0);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user