Skip to main content

rag_rat_core/query/
memory.rs

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}