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    #[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    // Internal anchoring/dedup mechanics — never actionable for a reader, so kept off the wire.
21    #[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}