1use std::collections::BTreeSet;
2
3use rusqlite::{Connection, OptionalExtension, params};
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6
7#[derive(Debug, Clone, Serialize)]
8pub struct RepoMemory {
9 pub memory_id: String,
10 pub kind: String,
11 pub title: String,
12 pub body: String,
13 pub confidence: String,
14 pub status: String,
15 pub created_by: Option<String>,
16 pub created_at_ms: i64,
17 pub updated_at_ms: i64,
18 pub source: String,
19 pub source_text_hash: Option<String>,
20 pub input_hash: Option<String>,
21 pub memory_version: String,
22 pub bindings: Vec<RepoMemoryBinding>,
23 pub call_paths: Vec<RepoMemoryCallPath>,
24 pub tags: Vec<String>,
25}
26
27#[derive(Debug, Clone, Serialize)]
28pub struct RepoMemoryBinding {
29 pub memory_id: String,
30 pub binding_kind: String,
31 pub binding_id: String,
32 pub path: Option<String>,
33 pub start_line: Option<i64>,
34 pub end_line: Option<i64>,
35 pub logical_symbol_id: Option<i64>,
36 pub symbol_id: Option<i64>,
37 pub chunk_id: Option<i64>,
38 pub edge_id: Option<i64>,
39 pub commit_hash: Option<String>,
40 pub github_owner: Option<String>,
41 pub github_repo: Option<String>,
42 pub github_number: Option<i64>,
43 pub anchor_status: String,
44 pub created_at_ms: i64,
45}
46
47#[derive(Debug, Clone, Serialize)]
48pub struct RepoMemoryCallPath {
49 pub memory_id: String,
50 pub start_logical_symbol_id: Option<i64>,
51 pub end_logical_symbol_id: Option<i64>,
52 pub edge_sequence_hash: String,
53 pub path_summary: String,
54 pub created_at_ms: i64,
55}
56
57#[derive(Debug, Clone, Serialize)]
58pub struct RepoMemoryCreateResult {
59 pub memory: RepoMemory,
60 pub duplicate: bool,
61}
62
63#[derive(Debug, Clone, Deserialize)]
64pub struct RepoMemoryCreate {
65 pub kind: String,
66 pub title: String,
67 pub body: String,
68 pub confidence: String,
69 pub created_by: Option<String>,
70 pub source: Option<String>,
71 #[serde(default)]
72 pub tags: Vec<String>,
73 pub bind: RepoMemoryBindTarget,
74}
75
76#[derive(Debug, Clone, Deserialize)]
77pub struct RepoMemoryBindTarget {
78 pub logical_symbol_id: Option<i64>,
79 pub symbol_id: Option<i64>,
80 pub chunk_id: Option<i64>,
81 pub edge_id: Option<i64>,
82 pub path: Option<String>,
83 pub start_line: Option<i64>,
84 pub end_line: Option<i64>,
85 pub commit_hash: Option<String>,
86 pub github_owner: Option<String>,
87 pub github_repo: Option<String>,
88 pub github_number: Option<i64>,
89 pub start_logical_symbol_id: Option<i64>,
90 pub end_logical_symbol_id: Option<i64>,
91 pub edge_sequence_hash: Option<String>,
92 pub path_summary: Option<String>,
93}
94
95#[derive(Debug, Clone, Deserialize)]
96pub struct RepoMemoryUpdate {
97 pub memory_id: String,
98 pub kind: Option<String>,
99 pub title: Option<String>,
100 pub body: Option<String>,
101 pub confidence: Option<String>,
102 pub status: Option<String>,
103 pub tags: Option<Vec<String>>,
104}
105
106#[derive(Debug, Clone, Serialize)]
107pub struct RepoMemoryValidationReport {
108 pub checked: u64,
109 pub current: u64,
110 pub relocated: u64,
111 pub stale: u64,
112 pub gone: u64,
113 pub unverified: u64,
114}
115
116#[derive(Debug, Clone, Serialize)]
117pub struct RepoMemoryEvidence {
118 pub direct: Vec<RepoMemory>,
119 pub path_crossed: Vec<RepoMemory>,
120 pub stale: Vec<RepoMemory>,
121}
122
123#[derive(Debug)]
124struct ResolvedBinding {
125 binding_kind: String,
126 binding_id: String,
127 path: Option<String>,
128 start_line: Option<i64>,
129 end_line: Option<i64>,
130 logical_symbol_id: Option<i64>,
131 symbol_id: Option<i64>,
132 chunk_id: Option<i64>,
133 edge_id: Option<i64>,
134 commit_hash: Option<String>,
135 github_owner: Option<String>,
136 github_repo: Option<String>,
137 github_number: Option<i64>,
138 call_path: Option<ResolvedCallPath>,
139 source_text_hash: Option<String>,
140 anchor_status: String,
141}
142
143#[derive(Debug)]
144struct ResolvedCallPath {
145 start_logical_symbol_id: Option<i64>,
146 end_logical_symbol_id: Option<i64>,
147 edge_sequence_hash: String,
148 path_summary: String,
149}
150
151pub fn create_memory(
152 conn: &Connection,
153 request: RepoMemoryCreate,
154) -> anyhow::Result<RepoMemoryCreateResult> {
155 validate_kind(&request.kind)?;
156 validate_confidence(&request.confidence)?;
157 validate_len("title", &request.title, 160)?;
158 validate_len("body", &request.body, 4000)?;
159 let source = request.source.clone().unwrap_or_else(|| "agent".to_string());
160 validate_source(&source)?;
161 let binding = resolve_binding(conn, &request.bind)?;
162 let input_hash = memory_input_hash(&request.kind, &request.title, &request.body, &request.tags);
163 if let Some(existing_id) = duplicate_memory_id(conn, &request.title, &request.body, &binding)? {
164 let memory = memory_by_id(conn, &existing_id)?
165 .ok_or_else(|| anyhow::anyhow!("duplicate memory `{existing_id}` disappeared"))?;
166 return Ok(RepoMemoryCreateResult { memory, duplicate: true });
167 }
168
169 let now = now_ms();
170 let id = memory_id(now, &input_hash);
171 conn.execute(
172 "
173 INSERT INTO repo_memories(
174 id, kind, title, body, confidence, status, created_by, created_at_ms, updated_at_ms,
175 source, source_text_hash, input_hash, memory_version
176 )
177 VALUES (?1, ?2, ?3, ?4, ?5, 'active', ?6, ?7, ?7, ?8, ?9, ?10, 'v1')
178 ",
179 params![
180 id,
181 request.kind,
182 request.title,
183 request.body,
184 request.confidence,
185 request.created_by,
186 now,
187 source,
188 binding.source_text_hash,
189 input_hash
190 ],
191 )?;
192 insert_binding(conn, &id, &binding, now)?;
193 replace_tags(conn, &id, &request.tags)?;
194 upsert_memory_fts(conn, &id)?;
195 let memory = memory_by_id(conn, &id)?
196 .ok_or_else(|| anyhow::anyhow!("created memory `{id}` could not be read back"))?;
197 Ok(RepoMemoryCreateResult { memory, duplicate: false })
198}
199
200pub fn update_memory(conn: &Connection, update: RepoMemoryUpdate) -> anyhow::Result<RepoMemory> {
201 let current = memory_by_id(conn, &update.memory_id)?
202 .ok_or_else(|| anyhow::anyhow!("memory `{}` not found", update.memory_id))?;
203 if let Some(kind) = update.kind.as_deref() {
204 validate_kind(kind)?;
205 }
206 if let Some(confidence) = update.confidence.as_deref() {
207 validate_confidence(confidence)?;
208 }
209 if let Some(status) = update.status.as_deref() {
210 validate_status(status)?;
211 }
212 if let Some(title) = update.title.as_deref() {
213 validate_len("title", title, 160)?;
214 }
215 if let Some(body) = update.body.as_deref() {
216 validate_len("body", body, 4000)?;
217 }
218 let now = now_ms();
219 conn.execute(
220 "
221 UPDATE repo_memories
222 SET kind = ?2,
223 title = ?3,
224 body = ?4,
225 confidence = ?5,
226 status = ?6,
227 updated_at_ms = ?7
228 WHERE id = ?1
229 ",
230 params![
231 update.memory_id,
232 update.kind.unwrap_or(current.kind),
233 update.title.unwrap_or(current.title),
234 update.body.unwrap_or(current.body),
235 update.confidence.unwrap_or(current.confidence),
236 update.status.unwrap_or(current.status),
237 now
238 ],
239 )?;
240 if let Some(tags) = update.tags {
241 replace_tags(conn, &update.memory_id, &tags)?;
242 }
243 upsert_memory_fts(conn, &update.memory_id)?;
244 memory_by_id(conn, &update.memory_id)?.ok_or_else(|| {
245 anyhow::anyhow!("updated memory `{}` could not be read back", update.memory_id)
246 })
247}
248
249pub fn mark_obsolete(conn: &Connection, memory_id: &str) -> anyhow::Result<RepoMemory> {
250 update_memory(
251 conn,
252 RepoMemoryUpdate {
253 memory_id: memory_id.to_string(),
254 kind: None,
255 title: None,
256 body: None,
257 confidence: None,
258 status: Some("obsolete".to_string()),
259 tags: None,
260 },
261 )
262}
263
264pub fn memory_by_id(conn: &Connection, memory_id: &str) -> anyhow::Result<Option<RepoMemory>> {
265 let Some(mut memory) = conn
266 .query_row(
267 "
268 SELECT id AS memory_id,
269 kind AS kind,
270 title AS title,
271 body AS body,
272 confidence AS confidence,
273 status AS status,
274 created_by AS created_by,
275 created_at_ms AS created_at_ms,
276 updated_at_ms AS updated_at_ms,
277 source AS source,
278 source_text_hash AS source_text_hash,
279 input_hash AS input_hash,
280 memory_version AS memory_version
281 FROM repo_memories
282 WHERE id = ?1
283 ",
284 [memory_id],
285 memory_row,
286 )
287 .optional()?
288 else {
289 return Ok(None);
290 };
291 attach_memory_children(conn, &mut memory)?;
292 Ok(Some(memory))
293}
294
295pub fn memories_for_chunk(
296 conn: &Connection,
297 chunk_id: i64,
298 limit: u32,
299) -> anyhow::Result<Vec<RepoMemory>> {
300 let mut stmt = conn.prepare(
301 "
302 SELECT DISTINCT repo_memories.id AS memory_id
303 FROM repo_memories
304 JOIN repo_memory_bindings ON repo_memory_bindings.memory_id = repo_memories.id
305 LEFT JOIN chunks ON chunks.id = ?1
306 LEFT JOIN files ON files.id = chunks.file_id
307 WHERE repo_memories.status IN ('active', 'stale')
308 AND (
309 repo_memory_bindings.chunk_id = ?1
310 OR (files.path IS NOT NULL AND repo_memory_bindings.path = files.path)
311 )
312 ORDER BY repo_memories.updated_at_ms DESC
313 LIMIT ?2
314 ",
315 )?;
316 ids_to_memories(
317 conn,
318 stmt.query_map(params![chunk_id, i64::from(limit)], |row| {
319 row.get::<_, String>("memory_id")
320 })?,
321 )
322}
323
324pub fn memories_for_path(
325 conn: &Connection,
326 path: &str,
327 limit: u32,
328) -> anyhow::Result<Vec<RepoMemory>> {
329 let mut stmt = conn.prepare(
330 "
331 SELECT DISTINCT repo_memories.id AS memory_id
332 FROM repo_memories
333 JOIN repo_memory_bindings ON repo_memory_bindings.memory_id = repo_memories.id
334 WHERE repo_memories.status IN ('active', 'stale')
335 AND repo_memory_bindings.path = ?1
336 ORDER BY repo_memories.updated_at_ms DESC
337 LIMIT ?2
338 ",
339 )?;
340 ids_to_memories(
341 conn,
342 stmt.query_map(params![path, i64::from(limit)], |row| row.get("memory_id"))?,
343 )
344}
345
346pub fn memories_for_symbol(
347 conn: &Connection,
348 symbol: &crate::query::symbol::SymbolHit,
349 limit: u32,
350) -> anyhow::Result<Vec<RepoMemory>> {
351 let chunk_ids = chunk_ids_for_symbol(conn, symbol)?;
352 let mut candidate_ids = BTreeSet::new();
353 let mut stmt = conn.prepare(
354 "
355 SELECT DISTINCT repo_memories.id AS memory_id
356 FROM repo_memories
357 JOIN repo_memory_bindings ON repo_memory_bindings.memory_id = repo_memories.id
358 WHERE repo_memories.status IN ('active', 'stale')
359 AND (
360 repo_memory_bindings.logical_symbol_id = ?1
361 OR repo_memory_bindings.symbol_id = ?2
362 OR repo_memory_bindings.binding_id = ?3
363 OR (
364 repo_memory_bindings.binding_kind = 'path'
365 AND repo_memory_bindings.path = ?4
366 )
367 )
368 ORDER BY repo_memories.updated_at_ms DESC
369 LIMIT ?5
370 ",
371 )?;
372 let rows = stmt.query_map(
373 params![
374 symbol.logical_symbol_id,
375 symbol.symbol_id,
376 symbol.qualified_name,
377 symbol.path,
378 i64::from(limit)
379 ],
380 |row| row.get::<_, String>("memory_id"),
381 )?;
382 for row in rows {
383 candidate_ids.insert(row?);
384 }
385 if !chunk_ids.is_empty() {
386 let placeholders = std::iter::repeat_n("?", chunk_ids.len()).collect::<Vec<_>>().join(",");
387 let sql = format!(
388 "
389 SELECT DISTINCT repo_memories.id AS memory_id
390 FROM repo_memories
391 JOIN repo_memory_bindings ON repo_memory_bindings.memory_id = repo_memories.id
392 WHERE repo_memories.status IN ('active', 'stale')
393 AND repo_memory_bindings.chunk_id IN ({placeholders})
394 ORDER BY repo_memories.updated_at_ms DESC
395 LIMIT ?
396 "
397 );
398 let mut stmt = conn.prepare(&sql)?;
399 let mut values =
400 chunk_ids.iter().map(|id| rusqlite::types::Value::Integer(*id)).collect::<Vec<_>>();
401 values.push(rusqlite::types::Value::Integer(i64::from(limit)));
402 let rows = stmt.query_map(rusqlite::params_from_iter(values), |row| {
403 row.get::<_, String>("memory_id")
404 })?;
405 for row in rows {
406 candidate_ids.insert(row?);
407 }
408 }
409 let mut memories = Vec::new();
410 for id in candidate_ids.into_iter().take(usize::try_from(limit).unwrap_or(usize::MAX)) {
411 if let Some(memory) = memory_by_id(conn, &id)? {
412 memories.push(memory);
413 }
414 }
415 memories.sort_by_key(|memory| std::cmp::Reverse(memory.updated_at_ms));
416 Ok(memories)
417}
418
419pub fn memory_evidence_for_symbol(
420 conn: &Connection,
421 symbol: &crate::query::symbol::SymbolHit,
422 limit: u32,
423) -> anyhow::Result<RepoMemoryEvidence> {
424 let (direct, stale) = split_active_stale(memories_for_symbol(conn, symbol, limit)?);
425 Ok(RepoMemoryEvidence { direct, path_crossed: Vec::new(), stale })
426}
427
428pub fn memory_evidence_for_symbol_and_edges(
429 conn: &Connection,
430 symbol: &crate::query::symbol::SymbolHit,
431 edge_ids: &[i64],
432 limit: u32,
433) -> anyhow::Result<RepoMemoryEvidence> {
434 let (direct, mut stale) = split_active_stale(memories_for_symbol(conn, symbol, limit)?);
435 let (path_crossed, crossed_stale) =
436 split_active_stale(memories_for_edges(conn, edge_ids, limit)?);
437 stale.extend(crossed_stale);
438 Ok(RepoMemoryEvidence { direct, path_crossed, stale })
439}
440
441pub fn memories_for_edges(
442 conn: &Connection,
443 edge_ids: &[i64],
444 limit: u32,
445) -> anyhow::Result<Vec<RepoMemory>> {
446 if edge_ids.is_empty() {
447 return Ok(Vec::new());
448 }
449 let mut unique_edge_ids = edge_ids.to_vec();
450 unique_edge_ids.sort_unstable();
451 unique_edge_ids.dedup();
452 let placeholders =
453 std::iter::repeat_n("?", unique_edge_ids.len()).collect::<Vec<_>>().join(",");
454 let sql = format!(
455 "
456 SELECT DISTINCT repo_memories.id AS memory_id
457 FROM repo_memories
458 JOIN repo_memory_bindings ON repo_memory_bindings.memory_id = repo_memories.id
459 WHERE repo_memories.status IN ('active', 'stale')
460 AND repo_memory_bindings.edge_id IN ({placeholders})
461 ORDER BY repo_memories.updated_at_ms DESC
462 LIMIT ?
463 "
464 );
465 let mut values =
466 unique_edge_ids.iter().map(|id| rusqlite::types::Value::Integer(*id)).collect::<Vec<_>>();
467 values.push(rusqlite::types::Value::Integer(i64::from(limit)));
468 let mut stmt = conn.prepare(&sql)?;
469 ids_to_memories(
470 conn,
471 stmt.query_map(rusqlite::params_from_iter(values), |row| row.get("memory_id"))?,
472 )
473}
474
475pub fn memories_for_call_path_hash(
476 conn: &Connection,
477 edge_sequence_hash: &str,
478 limit: u32,
479) -> anyhow::Result<Vec<RepoMemory>> {
480 let mut stmt = conn.prepare(
481 "
482 SELECT DISTINCT repo_memories.id AS memory_id
483 FROM repo_memories
484 JOIN repo_memory_call_paths ON repo_memory_call_paths.memory_id = repo_memories.id
485 WHERE repo_memories.status IN ('active', 'stale')
486 AND repo_memory_call_paths.edge_sequence_hash = ?1
487 ORDER BY repo_memories.updated_at_ms DESC
488 LIMIT ?2
489 ",
490 )?;
491 ids_to_memories(
492 conn,
493 stmt.query_map(params![edge_sequence_hash, i64::from(limit)], |row| row.get("memory_id"))?,
494 )
495}
496
497pub fn memory_search(
498 conn: &Connection,
499 query: &str,
500 limit: u32,
501) -> anyhow::Result<Vec<RepoMemory>> {
502 let query = fts_query(query);
503 if query.is_empty() {
504 return Ok(Vec::new());
505 }
506 let mut stmt = conn.prepare(
507 "
508 SELECT DISTINCT repo_memory_fts.memory_id
509 FROM repo_memory_fts
510 JOIN repo_memories ON repo_memories.id = repo_memory_fts.memory_id
511 WHERE repo_memory_fts MATCH ?1
512 AND repo_memories.status IN ('active', 'stale')
513 ORDER BY bm25(repo_memory_fts)
514 LIMIT ?2
515 ",
516 )?;
517 ids_to_memories(
518 conn,
519 stmt.query_map(params![query, i64::from(limit)], |row| row.get("memory_id"))?,
520 )
521}
522
523pub fn validate_memories(conn: &Connection) -> anyhow::Result<RepoMemoryValidationReport> {
524 let mut stmt = conn.prepare(
525 "
526 SELECT memory_id, binding_kind, binding_id, path, start_line, end_line,
527 logical_symbol_id, symbol_id, chunk_id, edge_id, commit_hash, github_owner,
528 github_repo, github_number, anchor_status, created_at_ms
529 FROM repo_memory_bindings
530 ",
531 )?;
532 let rows = stmt.query_map([], binding_row)?;
533 let mut report = RepoMemoryValidationReport {
534 checked: 0,
535 current: 0,
536 relocated: 0,
537 stale: 0,
538 gone: 0,
539 unverified: 0,
540 };
541 for row in rows {
542 let mut binding = row?;
543 report.checked += 1;
544 let status = validate_binding(conn, &mut binding)?;
545 conn.execute(
546 "
547 UPDATE repo_memory_bindings
548 SET anchor_status = ?3,
549 logical_symbol_id = ?4,
550 symbol_id = ?5,
551 chunk_id = ?6,
552 edge_id = ?7,
553 path = ?8,
554 start_line = ?9,
555 end_line = ?10
556 WHERE memory_id = ?1 AND binding_kind = ?2 AND binding_id = ?11
557 ",
558 params![
559 binding.memory_id,
560 binding.binding_kind,
561 status,
562 binding.logical_symbol_id,
563 binding.symbol_id,
564 binding.chunk_id,
565 binding.edge_id,
566 binding.path,
567 binding.start_line,
568 binding.end_line,
569 binding.binding_id
570 ],
571 )?;
572 match status.as_str() {
573 "current" => report.current += 1,
574 "relocated" => report.relocated += 1,
575 "stale" => report.stale += 1,
576 "gone" => report.gone += 1,
577 _ => report.unverified += 1,
578 }
579 }
580 Ok(report)
581}
582
583fn resolve_binding(
584 conn: &Connection,
585 bind: &RepoMemoryBindTarget,
586) -> anyhow::Result<ResolvedBinding> {
587 if let Some(logical_symbol_id) = bind.logical_symbol_id {
588 return resolve_logical_symbol_binding(conn, logical_symbol_id);
589 }
590 if let Some(symbol_id) = bind.symbol_id {
591 return resolve_symbol_binding(conn, symbol_id);
592 }
593 if let Some(chunk_id) = bind.chunk_id {
594 return resolve_chunk_binding(conn, chunk_id);
595 }
596 if let Some(edge_id) = bind.edge_id {
597 return resolve_edge_binding(conn, edge_id);
598 }
599 if let Some(edge_sequence_hash) = bind.edge_sequence_hash.as_deref() {
600 return resolve_call_path_binding(conn, bind, edge_sequence_hash);
601 }
602 if let Some(path) = bind.path.as_deref() {
603 return resolve_path_binding(conn, path, bind.start_line, bind.end_line);
604 }
605 if let Some(commit_hash) = bind.commit_hash.as_deref() {
606 return Ok(ResolvedBinding {
607 binding_kind: "commit".to_string(),
608 binding_id: commit_hash.to_string(),
609 path: None,
610 start_line: None,
611 end_line: None,
612 logical_symbol_id: None,
613 symbol_id: None,
614 chunk_id: None,
615 edge_id: None,
616 commit_hash: Some(commit_hash.to_string()),
617 github_owner: None,
618 github_repo: None,
619 github_number: None,
620 call_path: None,
621 source_text_hash: None,
622 anchor_status: "unverified".to_string(),
623 });
624 }
625 if let (Some(owner), Some(repo), Some(number)) =
626 (bind.github_owner.as_deref(), bind.github_repo.as_deref(), bind.github_number)
627 {
628 return Ok(ResolvedBinding {
629 binding_kind: "github".to_string(),
630 binding_id: format!("{owner}/{repo}#{number}"),
631 path: None,
632 start_line: None,
633 end_line: None,
634 logical_symbol_id: None,
635 symbol_id: None,
636 chunk_id: None,
637 edge_id: None,
638 commit_hash: None,
639 github_owner: Some(owner.to_string()),
640 github_repo: Some(repo.to_string()),
641 github_number: Some(number),
642 call_path: None,
643 source_text_hash: None,
644 anchor_status: "unverified".to_string(),
645 });
646 }
647 anyhow::bail!(
648 "memory_create requires logical_symbol_id, symbol_id, chunk_id, edge_id, call path, path/span, commit_hash, or github ref binding"
649 )
650}
651
652fn resolve_logical_symbol_binding(
653 conn: &Connection,
654 logical_symbol_id: i64,
655) -> anyhow::Result<ResolvedBinding> {
656 let logical = crate::query::symbol::lookup_logical_by_id(conn, logical_symbol_id)?
657 .ok_or_else(|| anyhow::anyhow!("logical_symbol_id {logical_symbol_id} not found"))?;
658 let chunk = chunk_for_logical_symbol(conn, logical_symbol_id)?;
659 Ok(ResolvedBinding {
660 binding_kind: "logical_symbol".to_string(),
661 binding_id: logical.qualified_name,
662 path: Some(logical.path),
663 start_line: chunk.as_ref().map(|chunk| chunk.start_line),
664 end_line: chunk.as_ref().map(|chunk| chunk.end_line),
665 logical_symbol_id: Some(logical_symbol_id),
666 symbol_id: chunk.as_ref().and_then(|chunk| chunk.symbol_id),
667 chunk_id: chunk.as_ref().map(|chunk| chunk.chunk_id),
668 edge_id: None,
669 commit_hash: None,
670 github_owner: None,
671 github_repo: None,
672 github_number: None,
673 call_path: None,
674 source_text_hash: chunk.map(|chunk| chunk.text_hash),
675 anchor_status: "current".to_string(),
676 })
677}
678
679fn resolve_symbol_binding(conn: &Connection, symbol_id: i64) -> anyhow::Result<ResolvedBinding> {
680 let symbol = crate::query::symbol::lookup_by_id(conn, symbol_id)?
681 .ok_or_else(|| anyhow::anyhow!("symbol_id {symbol_id} not found"))?;
682 let chunk = chunk_for_symbol(conn, symbol_id, &symbol.qualified_name)?;
683 Ok(ResolvedBinding {
684 binding_kind: "symbol".to_string(),
685 binding_id: symbol.qualified_name,
686 path: Some(symbol.path),
687 start_line: chunk.as_ref().map(|chunk| chunk.start_line),
688 end_line: chunk.as_ref().map(|chunk| chunk.end_line),
689 logical_symbol_id: symbol.logical_symbol_id,
690 symbol_id: Some(symbol_id),
691 chunk_id: chunk.as_ref().map(|chunk| chunk.chunk_id),
692 edge_id: None,
693 commit_hash: None,
694 github_owner: None,
695 github_repo: None,
696 github_number: None,
697 call_path: None,
698 source_text_hash: chunk.map(|chunk| chunk.text_hash),
699 anchor_status: "current".to_string(),
700 })
701}
702
703fn resolve_chunk_binding(conn: &Connection, chunk_id: i64) -> anyhow::Result<ResolvedBinding> {
704 let chunk = chunk_by_id(conn, chunk_id)?
705 .ok_or_else(|| anyhow::anyhow!("chunk_id {chunk_id} not found"))?;
706 let symbol_id = symbol_id_for_chunk(conn, &chunk)?;
707 Ok(ResolvedBinding {
708 binding_kind: "chunk".to_string(),
709 binding_id: chunk_id.to_string(),
710 path: Some(chunk.path),
711 start_line: Some(chunk.start_line),
712 end_line: Some(chunk.end_line),
713 logical_symbol_id: symbol_id
714 .and_then(|id| logical_symbol_id_for_symbol(conn, id).ok().flatten()),
715 symbol_id,
716 chunk_id: Some(chunk_id),
717 edge_id: None,
718 commit_hash: None,
719 github_owner: None,
720 github_repo: None,
721 github_number: None,
722 call_path: None,
723 source_text_hash: Some(chunk.text_hash),
724 anchor_status: "current".to_string(),
725 })
726}
727
728fn resolve_edge_binding(conn: &Connection, edge_id: i64) -> anyhow::Result<ResolvedBinding> {
729 let edge =
730 edge_by_id(conn, edge_id)?.ok_or_else(|| anyhow::anyhow!("edge_id {edge_id} not found"))?;
731 Ok(ResolvedBinding {
732 binding_kind: "edge".to_string(),
733 binding_id: edge.fingerprint,
734 path: Some(edge.path),
735 start_line: Some(edge.start_line),
736 end_line: Some(edge.end_line),
737 logical_symbol_id: None,
738 symbol_id: None,
739 chunk_id: None,
740 edge_id: Some(edge_id),
741 commit_hash: None,
742 github_owner: None,
743 github_repo: None,
744 github_number: None,
745 call_path: None,
746 source_text_hash: Some(edge.source_hash),
747 anchor_status: "current".to_string(),
748 })
749}
750
751fn resolve_call_path_binding(
752 conn: &Connection,
753 bind: &RepoMemoryBindTarget,
754 edge_sequence_hash: &str,
755) -> anyhow::Result<ResolvedBinding> {
756 validate_len("edge_sequence_hash", edge_sequence_hash, 128)?;
757 let path_summary = bind
758 .path_summary
759 .as_deref()
760 .map(str::trim)
761 .filter(|value| !value.is_empty())
762 .ok_or_else(|| anyhow::anyhow!("call-path memory requires path_summary"))?;
763 validate_len("path_summary", path_summary, 500)?;
764 if let Some(start_id) = bind.start_logical_symbol_id {
765 ensure_logical_symbol_exists(conn, start_id)?;
766 }
767 if let Some(end_id) = bind.end_logical_symbol_id {
768 ensure_logical_symbol_exists(conn, end_id)?;
769 }
770 Ok(ResolvedBinding {
771 binding_kind: "call_path".to_string(),
772 binding_id: edge_sequence_hash.to_string(),
773 path: None,
774 start_line: None,
775 end_line: None,
776 logical_symbol_id: bind.start_logical_symbol_id.or(bind.end_logical_symbol_id),
777 symbol_id: None,
778 chunk_id: None,
779 edge_id: None,
780 commit_hash: None,
781 github_owner: None,
782 github_repo: None,
783 github_number: None,
784 call_path: Some(ResolvedCallPath {
785 start_logical_symbol_id: bind.start_logical_symbol_id,
786 end_logical_symbol_id: bind.end_logical_symbol_id,
787 edge_sequence_hash: edge_sequence_hash.to_string(),
788 path_summary: path_summary.to_string(),
789 }),
790 source_text_hash: None,
791 anchor_status: "unverified".to_string(),
792 })
793}
794
795fn resolve_path_binding(
796 conn: &Connection,
797 path: &str,
798 start_line: Option<i64>,
799 end_line: Option<i64>,
800) -> anyhow::Result<ResolvedBinding> {
801 let file_hash = conn
802 .query_row(
803 "SELECT sha256 FROM files WHERE path = ?1 ORDER BY id DESC LIMIT 1",
804 [path],
805 |row| row.get::<_, String>(0),
806 )
807 .optional()?;
808 Ok(ResolvedBinding {
809 binding_kind: "path".to_string(),
810 binding_id: match (start_line, end_line) {
811 (Some(start), Some(end)) => format!("{path}:{start}-{end}"),
812 _ => path.to_string(),
813 },
814 path: Some(path.to_string()),
815 start_line,
816 end_line,
817 logical_symbol_id: None,
818 symbol_id: None,
819 chunk_id: None,
820 edge_id: None,
821 commit_hash: None,
822 github_owner: None,
823 github_repo: None,
824 github_number: None,
825 call_path: None,
826 source_text_hash: file_hash,
827 anchor_status: "current".to_string(),
828 })
829}
830
831#[derive(Debug)]
832struct ChunkAnchor {
833 chunk_id: i64,
834 path: String,
835 start_line: i64,
836 end_line: i64,
837 symbol_path: Option<String>,
838 text_hash: String,
839 symbol_id: Option<i64>,
840}
841
842#[derive(Debug)]
843struct EdgeAnchor {
844 edge_id: i64,
845 fingerprint: String,
846 path: String,
847 start_line: i64,
848 end_line: i64,
849 source_hash: String,
850}
851
852fn chunk_by_id(conn: &Connection, chunk_id: i64) -> anyhow::Result<Option<ChunkAnchor>> {
853 conn.query_row(
854 "
855 SELECT chunks.id AS chunk_id,
856 files.path AS path,
857 chunks.start_line AS start_line,
858 chunks.end_line AS end_line,
859 chunks.symbol_path AS symbol_path,
860 chunks.text_hash AS text_hash,
861 NULL AS symbol_id
862 FROM chunks
863 JOIN files ON files.id = chunks.file_id
864 WHERE chunks.id = ?1
865 ",
866 [chunk_id],
867 chunk_anchor_row,
868 )
869 .optional()
870 .map_err(Into::into)
871}
872
873fn chunk_for_symbol(
874 conn: &Connection,
875 symbol_id: i64,
876 qualified_name: &str,
877) -> anyhow::Result<Option<ChunkAnchor>> {
878 conn.query_row(
879 "
880 SELECT chunks.id AS chunk_id,
881 files.path AS path,
882 chunks.start_line AS start_line,
883 chunks.end_line AS end_line,
884 chunks.symbol_path AS symbol_path,
885 chunks.text_hash AS text_hash,
886 symbols.id AS symbol_id
887 FROM symbols
888 JOIN files ON files.id = symbols.file_id
889 LEFT JOIN chunks ON chunks.file_id = files.id
890 AND (chunks.symbol_path = symbols.qualified_name OR chunks.symbol_path = ?2)
891 WHERE symbols.id = ?1
892 ORDER BY CASE WHEN chunks.symbol_path = symbols.qualified_name THEN 0 ELSE 1 END,
893 chunks.start_line
894 LIMIT 1
895 ",
896 params![symbol_id, qualified_name],
897 chunk_anchor_row,
898 )
899 .optional()
900 .map_err(Into::into)
901}
902
903fn chunk_for_logical_symbol(
904 conn: &Connection,
905 logical_symbol_id: i64,
906) -> anyhow::Result<Option<ChunkAnchor>> {
907 conn.query_row(
908 "
909 SELECT chunks.id AS chunk_id,
910 files.path AS path,
911 chunks.start_line AS start_line,
912 chunks.end_line AS end_line,
913 chunks.symbol_path AS symbol_path,
914 chunks.text_hash AS text_hash,
915 symbols.id AS symbol_id
916 FROM logical_symbol_members
917 JOIN symbols ON symbols.id = logical_symbol_members.symbol_id
918 JOIN files ON files.id = symbols.file_id
919 LEFT JOIN chunks ON chunks.file_id = files.id
920 AND chunks.symbol_path = symbols.qualified_name
921 WHERE logical_symbol_members.logical_symbol_id = ?1
922 ORDER BY logical_symbol_members.start_line, chunks.start_line
923 LIMIT 1
924 ",
925 [logical_symbol_id],
926 chunk_anchor_row,
927 )
928 .optional()
929 .map_err(Into::into)
930}
931
932fn chunk_ids_for_symbol(
933 conn: &Connection,
934 symbol: &crate::query::symbol::SymbolHit,
935) -> anyhow::Result<Vec<i64>> {
936 let mut stmt = conn.prepare(
937 "
938 SELECT chunks.id AS chunk_id
939 FROM chunks
940 JOIN files ON files.id = chunks.file_id
941 WHERE files.path = ?1
942 AND (chunks.symbol_path = ?2 OR chunks.symbol_path = ?3)
943 ",
944 )?;
945 let rows = stmt
946 .query_map(params![symbol.path, symbol.qualified_name, symbol.symbol_path], |row| {
947 row.get::<_, i64>("chunk_id")
948 })?;
949 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
950}
951
952fn symbol_id_for_chunk(conn: &Connection, chunk: &ChunkAnchor) -> anyhow::Result<Option<i64>> {
953 let Some(symbol_path) = chunk.symbol_path.as_deref() else {
954 return Ok(None);
955 };
956 conn.query_row(
957 "
958 SELECT symbols.id AS symbol_id
959 FROM symbols
960 JOIN files ON files.id = symbols.file_id
961 WHERE files.path = ?1 AND symbols.qualified_name = ?2
962 LIMIT 1
963 ",
964 params![chunk.path, symbol_path],
965 |row| row.get("symbol_id"),
966 )
967 .optional()
968 .map_err(Into::into)
969}
970
971fn logical_symbol_id_for_symbol(conn: &Connection, symbol_id: i64) -> anyhow::Result<Option<i64>> {
972 conn.query_row(
973 "SELECT logical_symbol_id AS logical_symbol_id FROM logical_symbol_members WHERE symbol_id = ?1 LIMIT 1",
974 [symbol_id],
975 |row| row.get("logical_symbol_id"),
976 )
977 .optional()
978 .map_err(Into::into)
979}
980
981fn chunk_anchor_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ChunkAnchor> {
982 Ok(ChunkAnchor {
983 chunk_id: row.get("chunk_id")?,
984 path: row.get("path")?,
985 start_line: row.get("start_line")?,
986 end_line: row.get("end_line")?,
987 symbol_path: row.get("symbol_path")?,
988 text_hash: row.get("text_hash")?,
989 symbol_id: row.get("symbol_id")?,
990 })
991}
992
993fn edge_by_id(conn: &Connection, edge_id: i64) -> anyhow::Result<Option<EdgeAnchor>> {
994 conn.query_row(
995 "
996 SELECT edges.id AS edge_id,
997 files.path AS path,
998 COALESCE(NULLIF(edges.source_start_line, 0), 1) AS start_line,
999 COALESCE(NULLIF(edges.source_end_line, 0), NULLIF(edges.source_start_line, 0), 1) AS end_line,
1000 files.sha256 AS source_hash,
1001 edges.from_name AS from_name,
1002 edges.to_name AS to_name,
1003 edges.edge_kind AS edge_kind,
1004 edges.target_qualified_name AS target_qualified_name,
1005 edges.receiver_hint AS receiver_hint
1006 FROM edges
1007 JOIN files ON files.id = edges.source_file_id
1008 WHERE edges.id = ?1
1009 ",
1010 [edge_id],
1011 edge_anchor_row,
1012 )
1013 .optional()
1014 .map_err(Into::into)
1015}
1016
1017fn edge_by_fingerprint(conn: &Connection, fingerprint: &str) -> anyhow::Result<Option<EdgeAnchor>> {
1018 let mut stmt = conn.prepare(
1019 "
1020 SELECT edges.id AS edge_id,
1021 files.path AS path,
1022 COALESCE(NULLIF(edges.source_start_line, 0), 1) AS start_line,
1023 COALESCE(NULLIF(edges.source_end_line, 0), NULLIF(edges.source_start_line, 0), 1) AS end_line,
1024 files.sha256 AS source_hash,
1025 edges.from_name AS from_name,
1026 edges.to_name AS to_name,
1027 edges.edge_kind AS edge_kind,
1028 edges.target_qualified_name AS target_qualified_name,
1029 edges.receiver_hint AS receiver_hint
1030 FROM edges
1031 JOIN files ON files.id = edges.source_file_id
1032 ",
1033 )?;
1034 let rows = stmt.query_map([], edge_anchor_row)?;
1035 for row in rows {
1036 let edge = row?;
1037 if edge.fingerprint == fingerprint {
1038 return Ok(Some(edge));
1039 }
1040 }
1041 Ok(None)
1042}
1043
1044fn edge_anchor_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<EdgeAnchor> {
1045 let path: String = row.get("path")?;
1046 let start_line = row.get("start_line")?;
1047 let end_line = row.get("end_line")?;
1048 let from_name: Option<String> = row.get("from_name")?;
1049 let to_name: Option<String> = row.get("to_name")?;
1050 let edge_kind: String = row.get("edge_kind")?;
1051 let target_qualified_name: Option<String> = row.get("target_qualified_name")?;
1052 let receiver_hint: Option<String> = row.get("receiver_hint")?;
1053 Ok(EdgeAnchor {
1054 edge_id: row.get("edge_id")?,
1055 fingerprint: edge_fingerprint(EdgeFingerprintParts {
1056 path: &path,
1057 start_line,
1058 end_line,
1059 from_name: from_name.as_deref(),
1060 to_name: to_name.as_deref(),
1061 edge_kind: &edge_kind,
1062 target_qualified_name: target_qualified_name.as_deref(),
1063 receiver_hint: receiver_hint.as_deref(),
1064 }),
1065 path,
1066 start_line,
1067 end_line,
1068 source_hash: row.get("source_hash")?,
1069 })
1070}
1071
1072struct EdgeFingerprintParts<'a> {
1073 path: &'a str,
1074 start_line: i64,
1075 end_line: i64,
1076 from_name: Option<&'a str>,
1077 to_name: Option<&'a str>,
1078 edge_kind: &'a str,
1079 target_qualified_name: Option<&'a str>,
1080 receiver_hint: Option<&'a str>,
1081}
1082
1083fn edge_fingerprint(parts: EdgeFingerprintParts<'_>) -> String {
1084 hex_sha256(
1085 format!(
1086 "{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}",
1087 parts.path,
1088 parts.start_line,
1089 parts.end_line,
1090 parts.from_name.unwrap_or(""),
1091 parts.to_name.unwrap_or(""),
1092 parts.edge_kind,
1093 parts.target_qualified_name.unwrap_or(""),
1094 parts.receiver_hint.unwrap_or("")
1095 )
1096 .as_bytes(),
1097 )
1098}
1099
1100fn ensure_logical_symbol_exists(conn: &Connection, logical_symbol_id: i64) -> anyhow::Result<()> {
1101 if crate::query::symbol::lookup_logical_by_id(conn, logical_symbol_id)?.is_some() {
1102 return Ok(());
1103 }
1104 anyhow::bail!("logical_symbol_id {logical_symbol_id} not found")
1105}
1106
1107fn insert_binding(
1108 conn: &Connection,
1109 memory_id: &str,
1110 binding: &ResolvedBinding,
1111 now: i64,
1112) -> anyhow::Result<()> {
1113 conn.execute(
1114 "
1115 INSERT INTO repo_memory_bindings(
1116 memory_id, binding_kind, binding_id, path, start_line, end_line, logical_symbol_id,
1117 symbol_id, chunk_id, edge_id, commit_hash, github_owner, github_repo, github_number,
1118 anchor_status, created_at_ms
1119 )
1120 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)
1121 ",
1122 params![
1123 memory_id,
1124 binding.binding_kind,
1125 binding.binding_id,
1126 binding.path,
1127 binding.start_line,
1128 binding.end_line,
1129 binding.logical_symbol_id,
1130 binding.symbol_id,
1131 binding.chunk_id,
1132 binding.edge_id,
1133 binding.commit_hash,
1134 binding.github_owner,
1135 binding.github_repo,
1136 binding.github_number,
1137 binding.anchor_status,
1138 now
1139 ],
1140 )?;
1141 if let Some(call_path) = &binding.call_path {
1142 conn.execute(
1143 "
1144 INSERT INTO repo_memory_call_paths(
1145 memory_id, start_logical_symbol_id, end_logical_symbol_id, edge_sequence_hash,
1146 path_summary, created_at_ms
1147 )
1148 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
1149 ",
1150 params![
1151 memory_id,
1152 call_path.start_logical_symbol_id,
1153 call_path.end_logical_symbol_id,
1154 call_path.edge_sequence_hash,
1155 call_path.path_summary,
1156 now
1157 ],
1158 )?;
1159 }
1160 Ok(())
1161}
1162
1163fn duplicate_memory_id(
1164 conn: &Connection,
1165 title: &str,
1166 body: &str,
1167 binding: &ResolvedBinding,
1168) -> anyhow::Result<Option<String>> {
1169 conn.query_row(
1170 "
1171 SELECT repo_memories.id AS memory_id
1172 FROM repo_memories
1173 JOIN repo_memory_bindings ON repo_memory_bindings.memory_id = repo_memories.id
1174 WHERE lower(repo_memories.title) = lower(?1)
1175 AND lower(repo_memories.body) = lower(?2)
1176 AND repo_memory_bindings.binding_kind = ?3
1177 AND repo_memory_bindings.binding_id = ?4
1178 AND repo_memories.status != 'obsolete'
1179 LIMIT 1
1180 ",
1181 params![title.trim(), body.trim(), binding.binding_kind, binding.binding_id],
1182 |row| row.get("memory_id"),
1183 )
1184 .optional()
1185 .map_err(Into::into)
1186}
1187
1188fn replace_tags(conn: &Connection, memory_id: &str, tags: &[String]) -> anyhow::Result<()> {
1189 conn.execute("DELETE FROM repo_memory_tags WHERE memory_id = ?1", [memory_id])?;
1190 for tag in tags.iter().map(|tag| tag.trim()).filter(|tag| !tag.is_empty()) {
1191 validate_len("tag", tag, 64)?;
1192 conn.execute(
1193 "INSERT OR IGNORE INTO repo_memory_tags(memory_id, tag) VALUES (?1, ?2)",
1194 params![memory_id, tag],
1195 )?;
1196 }
1197 Ok(())
1198}
1199
1200fn upsert_memory_fts(conn: &Connection, memory_id: &str) -> anyhow::Result<()> {
1201 conn.execute("DELETE FROM repo_memory_fts WHERE memory_id = ?1", [memory_id])?;
1202 let tags = tags_for_memory(conn, memory_id)?.join(" ");
1203 conn.execute(
1204 "
1205 INSERT INTO repo_memory_fts(memory_id, title, body, kind, tags)
1206 SELECT id, title, body, kind, ?2
1207 FROM repo_memories
1208 WHERE id = ?1
1209 ",
1210 params![memory_id, tags],
1211 )?;
1212 Ok(())
1213}
1214
1215fn memory_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<RepoMemory> {
1216 Ok(RepoMemory {
1217 memory_id: row.get("memory_id")?,
1218 kind: row.get("kind")?,
1219 title: row.get("title")?,
1220 body: row.get("body")?,
1221 confidence: row.get("confidence")?,
1222 status: row.get("status")?,
1223 created_by: row.get("created_by")?,
1224 created_at_ms: row.get("created_at_ms")?,
1225 updated_at_ms: row.get("updated_at_ms")?,
1226 source: row.get("source")?,
1227 source_text_hash: row.get("source_text_hash")?,
1228 input_hash: row.get("input_hash")?,
1229 memory_version: row.get("memory_version")?,
1230 bindings: Vec::new(),
1231 call_paths: Vec::new(),
1232 tags: Vec::new(),
1233 })
1234}
1235
1236fn binding_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<RepoMemoryBinding> {
1237 Ok(RepoMemoryBinding {
1238 memory_id: row.get("memory_id")?,
1239 binding_kind: row.get("binding_kind")?,
1240 binding_id: row.get("binding_id")?,
1241 path: row.get("path")?,
1242 start_line: row.get("start_line")?,
1243 end_line: row.get("end_line")?,
1244 logical_symbol_id: row.get("logical_symbol_id")?,
1245 symbol_id: row.get("symbol_id")?,
1246 chunk_id: row.get("chunk_id")?,
1247 edge_id: row.get("edge_id")?,
1248 commit_hash: row.get("commit_hash")?,
1249 github_owner: row.get("github_owner")?,
1250 github_repo: row.get("github_repo")?,
1251 github_number: row.get("github_number")?,
1252 anchor_status: row.get("anchor_status")?,
1253 created_at_ms: row.get("created_at_ms")?,
1254 })
1255}
1256
1257fn attach_memory_children(conn: &Connection, memory: &mut RepoMemory) -> anyhow::Result<()> {
1258 let mut stmt = conn.prepare(
1259 "
1260 SELECT memory_id, binding_kind, binding_id, path, start_line, end_line, logical_symbol_id,
1261 symbol_id, chunk_id, edge_id, commit_hash, github_owner, github_repo,
1262 github_number, anchor_status, created_at_ms
1263 FROM repo_memory_bindings
1264 WHERE memory_id = ?1
1265 ORDER BY binding_kind, binding_id
1266 ",
1267 )?;
1268 memory.bindings =
1269 stmt.query_map([&memory.memory_id], binding_row)?.collect::<Result<Vec<_>, _>>()?;
1270 let mut stmt = conn.prepare(
1271 "
1272 SELECT memory_id, start_logical_symbol_id, end_logical_symbol_id, edge_sequence_hash,
1273 path_summary, created_at_ms
1274 FROM repo_memory_call_paths
1275 WHERE memory_id = ?1
1276 ORDER BY created_at_ms, edge_sequence_hash
1277 ",
1278 )?;
1279 memory.call_paths =
1280 stmt.query_map([&memory.memory_id], call_path_row)?.collect::<Result<Vec<_>, _>>()?;
1281 memory.tags = tags_for_memory(conn, &memory.memory_id)?;
1282 Ok(())
1283}
1284
1285fn call_path_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<RepoMemoryCallPath> {
1286 Ok(RepoMemoryCallPath {
1287 memory_id: row.get("memory_id")?,
1288 start_logical_symbol_id: row.get("start_logical_symbol_id")?,
1289 end_logical_symbol_id: row.get("end_logical_symbol_id")?,
1290 edge_sequence_hash: row.get("edge_sequence_hash")?,
1291 path_summary: row.get("path_summary")?,
1292 created_at_ms: row.get("created_at_ms")?,
1293 })
1294}
1295
1296fn tags_for_memory(conn: &Connection, memory_id: &str) -> anyhow::Result<Vec<String>> {
1297 let mut stmt =
1298 conn.prepare("SELECT tag FROM repo_memory_tags WHERE memory_id = ?1 ORDER BY tag")?;
1299 stmt.query_map([memory_id], |row| row.get::<_, String>(0))?
1300 .collect::<Result<Vec<_>, _>>()
1301 .map_err(Into::into)
1302}
1303
1304fn ids_to_memories(
1305 conn: &Connection,
1306 rows: rusqlite::MappedRows<'_, impl FnMut(&rusqlite::Row<'_>) -> rusqlite::Result<String>>,
1307) -> anyhow::Result<Vec<RepoMemory>> {
1308 let mut memories = Vec::new();
1309 for row in rows {
1310 if let Some(memory) = memory_by_id(conn, &row?)? {
1311 memories.push(memory);
1312 }
1313 }
1314 Ok(memories)
1315}
1316
1317fn split_active_stale(memories: Vec<RepoMemory>) -> (Vec<RepoMemory>, Vec<RepoMemory>) {
1318 let mut direct = Vec::new();
1319 let mut stale = Vec::new();
1320 for memory in memories {
1321 if memory.status == "stale"
1322 || memory.bindings.iter().any(|binding| {
1323 matches!(binding.anchor_status.as_str(), "stale" | "gone" | "unverified")
1324 })
1325 {
1326 stale.push(memory);
1327 } else {
1328 direct.push(memory);
1329 }
1330 }
1331 (direct, stale)
1332}
1333
1334fn validate_binding(conn: &Connection, binding: &mut RepoMemoryBinding) -> anyhow::Result<String> {
1335 match binding.binding_kind.as_str() {
1336 "logical_symbol" => validate_logical_symbol_binding(conn, binding),
1337 "symbol" => validate_symbol_binding(conn, binding),
1338 "chunk" => validate_chunk_binding(conn, binding),
1339 "edge" => validate_edge_binding(conn, binding),
1340 "call_path" => validate_call_path_binding(conn, binding),
1341 "path" => validate_path_binding(conn, binding),
1342 "commit" | "github" => Ok("unverified".to_string()),
1343 _ => Ok("unverified".to_string()),
1344 }
1345}
1346
1347fn validate_logical_symbol_binding(
1348 conn: &Connection,
1349 binding: &mut RepoMemoryBinding,
1350) -> anyhow::Result<String> {
1351 if let Some(id) = binding.logical_symbol_id
1352 && crate::query::symbol::lookup_logical_by_id(conn, id)?.is_some()
1353 {
1354 return validate_bound_chunk(conn, binding);
1355 }
1356 let relocated = conn
1357 .query_row(
1358 "
1359 SELECT id, path
1360 FROM logical_symbols
1361 WHERE qualified_name = ?1
1362 LIMIT 1
1363 ",
1364 [&binding.binding_id],
1365 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
1366 )
1367 .optional()?;
1368 let Some((id, path)) = relocated else {
1369 return Ok("gone".to_string());
1370 };
1371 binding.logical_symbol_id = Some(id);
1372 binding.path = Some(path);
1373 if let Some(chunk) = chunk_for_logical_symbol(conn, id)? {
1374 binding.symbol_id = chunk.symbol_id;
1375 binding.chunk_id = Some(chunk.chunk_id);
1376 binding.start_line = Some(chunk.start_line);
1377 binding.end_line = Some(chunk.end_line);
1378 }
1379 Ok("relocated".to_string())
1380}
1381
1382fn validate_symbol_binding(
1383 conn: &Connection,
1384 binding: &mut RepoMemoryBinding,
1385) -> anyhow::Result<String> {
1386 if let Some(id) = binding.symbol_id
1387 && crate::query::symbol::lookup_by_id(conn, id)?.is_some()
1388 {
1389 return validate_bound_chunk(conn, binding);
1390 }
1391 let relocated = conn
1392 .query_row(
1393 "
1394 SELECT symbols.id, files.path
1395 FROM symbols
1396 JOIN files ON files.id = symbols.file_id
1397 WHERE symbols.qualified_name = ?1
1398 LIMIT 1
1399 ",
1400 [&binding.binding_id],
1401 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
1402 )
1403 .optional()?;
1404 let Some((id, path)) = relocated else {
1405 return Ok("gone".to_string());
1406 };
1407 binding.symbol_id = Some(id);
1408 binding.logical_symbol_id = logical_symbol_id_for_symbol(conn, id)?;
1409 binding.path = Some(path);
1410 if let Some(chunk) = chunk_for_symbol(conn, id, &binding.binding_id)? {
1411 binding.chunk_id = Some(chunk.chunk_id);
1412 binding.start_line = Some(chunk.start_line);
1413 binding.end_line = Some(chunk.end_line);
1414 }
1415 Ok("relocated".to_string())
1416}
1417
1418fn validate_chunk_binding(
1419 conn: &Connection,
1420 binding: &mut RepoMemoryBinding,
1421) -> anyhow::Result<String> {
1422 validate_bound_chunk(conn, binding)
1423}
1424
1425fn validate_edge_binding(
1426 conn: &Connection,
1427 binding: &mut RepoMemoryBinding,
1428) -> anyhow::Result<String> {
1429 if let Some(edge_id) = binding.edge_id
1430 && let Some(edge) = edge_by_id(conn, edge_id)?
1431 {
1432 binding.path = Some(edge.path);
1433 binding.start_line = Some(edge.start_line);
1434 binding.end_line = Some(edge.end_line);
1435 binding.symbol_id = None;
1436 binding.logical_symbol_id = None;
1437 return validate_bound_edge_source_hash(conn, binding, &edge.source_hash);
1438 }
1439 let Some(edge) = edge_by_fingerprint(conn, &binding.binding_id)? else {
1440 return Ok("gone".to_string());
1441 };
1442 binding.edge_id = Some(edge.edge_id);
1443 binding.path = Some(edge.path);
1444 binding.start_line = Some(edge.start_line);
1445 binding.end_line = Some(edge.end_line);
1446 binding.symbol_id = None;
1447 binding.logical_symbol_id = None;
1448 Ok("relocated".to_string())
1449}
1450
1451fn validate_call_path_binding(
1452 conn: &Connection,
1453 binding: &mut RepoMemoryBinding,
1454) -> anyhow::Result<String> {
1455 let exists = conn.query_row(
1456 "
1457 SELECT COUNT(*)
1458 FROM repo_memory_call_paths
1459 WHERE memory_id = ?1 AND edge_sequence_hash = ?2
1460 ",
1461 params![binding.memory_id, binding.binding_id],
1462 |row| row.get::<_, i64>(0),
1463 )?;
1464 Ok(if exists > 0 { "unverified" } else { "gone" }.to_string())
1465}
1466
1467fn validate_bound_edge_source_hash(
1468 conn: &Connection,
1469 binding: &RepoMemoryBinding,
1470 current_source_hash: &str,
1471) -> anyhow::Result<String> {
1472 match source_hash_for_memory(conn, &binding.memory_id)? {
1473 Some(expected) if expected != current_source_hash => Ok("stale".to_string()),
1474 _ => Ok("current".to_string()),
1475 }
1476}
1477
1478fn validate_bound_chunk(
1479 conn: &Connection,
1480 binding: &mut RepoMemoryBinding,
1481) -> anyhow::Result<String> {
1482 let Some(chunk_id) = binding.chunk_id else {
1483 return Ok("unverified".to_string());
1484 };
1485 let Some(chunk) = chunk_by_id(conn, chunk_id)? else {
1486 return Ok("gone".to_string());
1487 };
1488 binding.path = Some(chunk.path);
1489 binding.start_line = Some(chunk.start_line);
1490 binding.end_line = Some(chunk.end_line);
1491 match source_hash_for_memory(conn, &binding.memory_id)? {
1492 Some(expected) if expected != chunk.text_hash => Ok("stale".to_string()),
1493 _ => Ok("current".to_string()),
1494 }
1495}
1496
1497fn validate_path_binding(
1498 conn: &Connection,
1499 binding: &mut RepoMemoryBinding,
1500) -> anyhow::Result<String> {
1501 let Some(path) = binding.path.as_deref() else {
1502 return Ok("unverified".to_string());
1503 };
1504 let current_hash = conn
1505 .query_row(
1506 "SELECT sha256 FROM files WHERE path = ?1 ORDER BY id DESC LIMIT 1",
1507 [path],
1508 |row| row.get::<_, String>(0),
1509 )
1510 .optional()?;
1511 let Some(current_hash) = current_hash else {
1512 return Ok("gone".to_string());
1513 };
1514 match source_hash_for_memory(conn, &binding.memory_id)? {
1515 Some(expected) if expected != current_hash => Ok("stale".to_string()),
1516 _ => Ok("current".to_string()),
1517 }
1518}
1519
1520fn source_hash_for_memory(conn: &Connection, memory_id: &str) -> anyhow::Result<Option<String>> {
1521 conn.query_row("SELECT source_text_hash FROM repo_memories WHERE id = ?1", [memory_id], |row| {
1522 row.get::<_, Option<String>>(0)
1523 })
1524 .optional()
1525 .map(|value| value.flatten())
1526 .map_err(Into::into)
1527}
1528
1529fn validate_kind(kind: &str) -> anyhow::Result<()> {
1530 match kind {
1531 "Invariant"
1532 | "Decision"
1533 | "RejectedAlternative"
1534 | "Risk"
1535 | "BugPattern"
1536 | "TestExpectation"
1537 | "PerformanceNote"
1538 | "SecurityNote"
1539 | "FFIBoundary"
1540 | "PlatformQuirk"
1541 | "FollowUp"
1542 | "OpenQuestion"
1543 | "Obsolete" => Ok(()),
1544 _ => anyhow::bail!("invalid memory kind `{kind}`"),
1545 }
1546}
1547
1548fn validate_confidence(confidence: &str) -> anyhow::Result<()> {
1549 match confidence {
1550 "high" | "medium" | "low" => Ok(()),
1551 _ => anyhow::bail!("invalid memory confidence `{confidence}`"),
1552 }
1553}
1554
1555fn validate_status(status: &str) -> anyhow::Result<()> {
1556 match status {
1557 "active" | "stale" | "obsolete" | "rejected" => Ok(()),
1558 _ => anyhow::bail!("invalid memory status `{status}`"),
1559 }
1560}
1561
1562fn validate_source(source: &str) -> anyhow::Result<()> {
1563 match source {
1564 "agent" | "human" | "imported" | "generated" => Ok(()),
1565 _ => anyhow::bail!("invalid memory source `{source}`"),
1566 }
1567}
1568
1569fn validate_len(field: &str, value: &str, max: usize) -> anyhow::Result<()> {
1570 let len = value.trim().chars().count();
1571 if len == 0 {
1572 anyhow::bail!("memory {field} must not be empty");
1573 }
1574 if len > max {
1575 anyhow::bail!("memory {field} exceeds {max} characters");
1576 }
1577 Ok(())
1578}
1579
1580fn memory_id(now: i64, input_hash: &str) -> String {
1581 let suffix = input_hash.chars().take(12).collect::<String>();
1582 format!("mem_{now:x}_{suffix}")
1583}
1584
1585fn memory_input_hash(kind: &str, title: &str, body: &str, tags: &[String]) -> String {
1586 let mut normalized_tags = tags.iter().map(|tag| tag.trim()).collect::<Vec<_>>();
1587 normalized_tags.sort_unstable();
1588 hex_sha256(
1589 format!("{kind}\n{}\n{}\n{}", title.trim(), body.trim(), normalized_tags.join(","))
1590 .as_bytes(),
1591 )
1592}
1593
1594fn hex_sha256(bytes: &[u8]) -> String {
1595 let hash = Sha256::digest(bytes);
1596 hash.iter().map(|byte| format!("{byte:02x}")).collect()
1597}
1598
1599fn now_ms() -> i64 {
1600 std::time::SystemTime::now()
1601 .duration_since(std::time::UNIX_EPOCH)
1602 .map(|duration| i64::try_from(duration.as_millis()).unwrap_or(i64::MAX))
1603 .unwrap_or(0)
1604}
1605
1606fn fts_query(query: &str) -> String {
1607 query
1608 .split_whitespace()
1609 .map(|term| term.trim_matches(|ch: char| !ch.is_alphanumeric() && ch != '_'))
1610 .filter(|term| !term.is_empty())
1611 .map(|term| format!("{term}*"))
1612 .collect::<Vec<_>>()
1613 .join(" OR ")
1614}