1use std::path::Path;
5
6use kimetsu_core::KimetsuResult;
7use rusqlite::{Connection, OptionalExtension, params};
8
9use crate::project::*;
10use crate::user_brain;
11
12#[derive(Debug, Clone, serde::Serialize)]
17pub struct BlameReport {
18 pub run_id: String,
19 pub outcome: String,
23 pub failure_category: Option<String>,
26 pub cited: Vec<CitedMemory>,
29 pub silent_passengers: Vec<SilentMemory>,
32}
33
34#[derive(Debug, Clone, serde::Serialize)]
35pub struct CitedMemory {
36 pub memory_id: String,
37 pub turn: i64,
38 pub rationale: Option<String>,
39 pub cited_at: String,
40 pub text_preview: String,
42 pub scope: String,
43 pub kind: String,
44}
45
46#[derive(Debug, Clone, serde::Serialize)]
47pub struct SilentMemory {
48 pub memory_id: String,
49 pub text_preview: String,
50 pub scope: String,
51 pub kind: String,
52}
53
54pub fn blame_run(start: &Path, run_id: &str) -> KimetsuResult<BlameReport> {
61 let (_paths, config, conn) = load_project(start)?;
62 let user_conn = user_brain::open_user_brain_readonly_for_config(config.kimetsu.use_user_brain)?;
65
66 let (outcome, failure_category) = run_outcome(&conn, run_id)?;
68
69 let cited_rows: Vec<(String, i64, Option<String>, String)> = {
71 let mut stmt = conn.prepare(
72 "
73 SELECT memory_id, turn, rationale, cited_at
74 FROM memory_citations
75 WHERE run_id = ?1
76 ORDER BY turn ASC, cited_at ASC
77 ",
78 )?;
79 let rows = stmt.query_map(rusqlite::params![run_id], |row| {
80 Ok((
81 row.get::<_, String>(0)?,
82 row.get::<_, i64>(1)?,
83 row.get::<_, Option<String>>(2)?,
84 row.get::<_, String>(3)?,
85 ))
86 })?;
87 let mut out = Vec::new();
88 for row in rows {
89 out.push(row?);
90 }
91 out
92 };
93
94 let mut cited: Vec<CitedMemory> = Vec::with_capacity(cited_rows.len());
95 let mut cited_set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
96 for (memory_id, turn, rationale, cited_at) in cited_rows {
97 cited_set.insert(memory_id.clone());
98 let (text, scope, kind) = resolve_memory(&conn, user_conn.as_ref(), &memory_id);
99 cited.push(CitedMemory {
100 memory_id,
101 turn,
102 rationale,
103 cited_at,
104 text_preview: text_preview(&text, 120),
105 scope,
106 kind,
107 });
108 }
109
110 let retrieved_ids = collect_injected_memory_ids_for_blame(&conn, run_id)?;
112 let mut silent: Vec<SilentMemory> = Vec::new();
113 for memory_id in retrieved_ids {
114 if cited_set.contains(&memory_id) {
115 continue;
116 }
117 let (text, scope, kind) = resolve_memory(&conn, user_conn.as_ref(), &memory_id);
118 silent.push(SilentMemory {
119 memory_id,
120 text_preview: text_preview(&text, 120),
121 scope,
122 kind,
123 });
124 }
125
126 Ok(BlameReport {
127 run_id: run_id.to_string(),
128 outcome,
129 failure_category,
130 cited,
131 silent_passengers: silent,
132 })
133}
134
135fn run_outcome(conn: &Connection, run_id: &str) -> KimetsuResult<(String, Option<String>)> {
136 let row: Option<(String, String)> = conn
138 .query_row(
139 "
140 SELECT kind, payload_json
141 FROM events
142 WHERE run_id = ?1
143 AND kind IN ('run.finished', 'run.failed', 'run.aborted')
144 ORDER BY ts DESC
145 LIMIT 1
146 ",
147 rusqlite::params![run_id],
148 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
149 )
150 .optional()?;
151 Ok(match row {
152 Some((kind, payload_json)) => {
153 let outcome = match kind.as_str() {
154 "run.finished" => "success".to_string(),
155 "run.failed" => "failed".to_string(),
156 "run.aborted" => "aborted".to_string(),
157 other => other.to_string(),
158 };
159 let category = if kind == "run.failed" {
160 serde_json::from_str::<serde_json::Value>(&payload_json)
161 .ok()
162 .and_then(|v| {
163 v.get("category")
164 .and_then(|c| c.as_str())
165 .map(str::to_string)
166 })
167 } else {
168 None
169 };
170 (outcome, category)
171 }
172 None => ("unknown".to_string(), None),
173 })
174}
175
176fn collect_injected_memory_ids_for_blame(
177 conn: &Connection,
178 run_id: &str,
179) -> KimetsuResult<Vec<String>> {
180 let mut stmt = conn.prepare(
181 "
182 SELECT payload_json
183 FROM events
184 WHERE run_id = ?1 AND kind = 'context.injected'
185 ",
186 )?;
187 let rows = stmt.query_map(rusqlite::params![run_id], |row| row.get::<_, String>(0))?;
188 let mut seen = std::collections::BTreeSet::new();
189 for row in rows {
190 let payload_json = row?;
191 let payload: serde_json::Value = serde_json::from_str(&payload_json)?;
192 if let Some(ids) = payload.get("memory_ids").and_then(|v| v.as_array()) {
193 for id in ids {
194 if let Some(s) = id.as_str()
195 && !s.is_empty()
196 {
197 seen.insert(s.to_string());
198 }
199 }
200 }
201 }
202 Ok(seen.into_iter().collect())
203}
204
205fn resolve_memory(
211 project_conn: &Connection,
212 user_conn: Option<&Connection>,
213 memory_id: &str,
214) -> (String, String, String) {
215 let q = "SELECT text, scope, kind FROM memories WHERE memory_id = ?1";
216 let try_conn = |conn: &Connection| -> Option<(String, String, String)> {
217 conn.query_row(q, rusqlite::params![memory_id], |row| {
218 Ok((
219 row.get::<_, String>(0)?,
220 row.get::<_, String>(1)?,
221 row.get::<_, String>(2)?,
222 ))
223 })
224 .optional()
225 .ok()
226 .flatten()
227 };
228 try_conn(project_conn)
229 .or_else(|| user_conn.and_then(try_conn))
230 .unwrap_or_else(|| {
231 (
232 "<unknown — deleted or invalid memory_id>".to_string(),
233 String::new(),
234 String::new(),
235 )
236 })
237}
238
239fn text_preview(text: &str, max_chars: usize) -> String {
240 let trimmed = text.trim();
241 if trimmed.chars().count() <= max_chars {
242 trimmed.to_string()
243 } else {
244 let head: String = trimmed.chars().take(max_chars).collect();
245 format!("{head}…")
246 }
247}
248
249#[derive(Debug, Clone, Default)]
257pub struct TopOptions {
258 pub scope: Option<String>,
259 pub min_uses: u32,
260 pub limit: u32,
261}
262
263pub fn list_memories_top(start: &Path, opts: TopOptions) -> KimetsuResult<Vec<MemoryRow>> {
264 let (_paths, _config, conn) = load_project(start)?;
265 let min_uses = opts.min_uses.max(1) as i64;
266 let limit = if opts.limit == 0 { 20 } else { opts.limit } as i64;
267
268 let (sql, scope_param): (&str, Option<String>) = if let Some(scope) = opts.scope.as_deref() {
269 (
270 "
271 SELECT memory_id, scope, kind, text, confidence, use_count, usefulness_score
272 FROM memories
273 WHERE invalidated_at IS NULL
274 AND superseded_by IS NULL
275 AND use_count >= ?1
276 AND lower(scope) = lower(?2)
277 ORDER BY (usefulness_score / CAST(use_count AS REAL)) DESC, use_count DESC
278 LIMIT ?3
279 ",
280 Some(scope.to_string()),
281 )
282 } else {
283 (
284 "
285 SELECT memory_id, scope, kind, text, confidence, use_count, usefulness_score
286 FROM memories
287 WHERE invalidated_at IS NULL
288 AND superseded_by IS NULL
289 AND use_count >= ?1
290 ORDER BY (usefulness_score / CAST(use_count AS REAL)) DESC, use_count DESC
291 LIMIT ?2
292 ",
293 None,
294 )
295 };
296
297 let mut stmt = conn.prepare(sql)?;
298 let mut rows = if let Some(scope) = scope_param {
299 stmt.query_map(params![min_uses, scope, limit], map_memory_row)?
300 .collect::<Result<Vec<_>, _>>()?
301 } else {
302 stmt.query_map(params![min_uses, limit], map_memory_row)?
303 .collect::<Result<Vec<_>, _>>()?
304 };
305
306 rows.sort_by(|a, b| {
311 let ra = a.usefulness_score as f64 / a.use_count.max(1) as f64;
312 let rb = b.usefulness_score as f64 / b.use_count.max(1) as f64;
313 rb.partial_cmp(&ra).unwrap_or(std::cmp::Ordering::Equal)
314 });
315 Ok(rows)
316}
317
318pub(crate) fn map_memory_row(row: &rusqlite::Row) -> rusqlite::Result<MemoryRow> {
319 Ok(MemoryRow {
320 memory_id: row.get(0)?,
321 scope: row.get(1)?,
322 kind: row.get(2)?,
323 text: row.get(3)?,
324 confidence: row.get(4)?,
325 use_count: row.get(5)?,
326 usefulness_score: row.get::<_, f64>(6)? as f32,
327 })
328}