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