Skip to main content

agent_doc_sqlite/
archive_index.rs

1//! # Module: archive_index
2//!
3//! ## Spec
4//! - Maintains a derived sqlite index at `.agent-doc/archive-index.db` for compacted-turn lookup.
5//! - Markdown archives under `.agent-doc/archives/*.md` remain canonical history; this DB is
6//!   rebuildable and may be dropped/recreated from those files.
7//! - `run_index(file, rebuild)` creates or refreshes the index for the target project.
8//! - `run_search(file, query, backlog_id, session, limit, json, rebuild)` queries indexed archive
9//!   chunks, biasing results toward the current document and recent archives.
10//! - Search results operate on extracted turn/section chunks rather than full-file blobs.
11//! - Compact-time indexing is best effort only; archive creation must still succeed when the DB
12//!   update fails.
13//!
14//! ## Agentic Contracts
15//! - The DB lives under the same project root that owns `.agent-doc/archives`.
16//! - `rebuild: true` drops all derived rows and reindexes every archive markdown file on disk.
17//! - Search requires at least one of `query`, `backlog_id`, or `session`.
18//! - Backlog-id filters match exact `#id` references extracted from archive text.
19//!
20//! ## Evals
21//! - rebuild_indexes_archives: archive markdown corpus produces sqlite rows for archives/turns/refs
22//! - search_prefers_current_document_hits: same-query hits in the current doc sort above other docs
23//! - compact_section_archives_are_chunked: `### Re:` archives index per-response sections
24//! - bare_id_filter_accepts_missing_hash_prefix: `sqlarcidx` and `#sqlarcidx` behave the same
25
26use anyhow::{Context, Result};
27use rusqlite::{Connection, params};
28use serde::Serialize;
29use std::collections::{BTreeMap, BTreeSet};
30use std::fs;
31use std::path::{Path, PathBuf};
32
33#[derive(Debug)]
34struct ArchiveRecord {
35    archive_path: String,
36    document_path: String,
37    session_id: Option<String>,
38    component: Option<String>,
39    archived_at: String,
40    source_snapshot_hash: Option<String>,
41    archive_byte_size: i64,
42    turns: Vec<ArchiveTurn>,
43    refs: Vec<ArchiveRef>,
44}
45
46#[derive(Debug)]
47struct ArchiveTurn {
48    turn_ordinal: i64,
49    speaker: String,
50    text: String,
51    normalized_text: String,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
55struct ArchiveRef {
56    kind: String,
57    value: String,
58}
59
60#[derive(Debug, Serialize)]
61struct SearchResult {
62    archive_path: String,
63    document_path: String,
64    session_id: Option<String>,
65    archived_at: String,
66    speaker: String,
67    turn_ordinal: i64,
68    score: i64,
69    ref_hits: Vec<String>,
70    preview: String,
71}
72
73#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
74pub struct IndexedArchiveTurn {
75    pub archive_path: String,
76    pub document_path: String,
77    pub session_id: Option<String>,
78    pub archived_at: String,
79    pub speaker: String,
80    pub turn_ordinal: i64,
81    pub heading: String,
82    pub preview: String,
83    pub refs: Vec<String>,
84    pub text: String,
85}
86
87#[derive(Debug)]
88struct SearchOptions<'a> {
89    query: Option<&'a str>,
90    backlog_id: Option<&'a str>,
91    session: Option<&'a str>,
92    limit: usize,
93}
94
95pub fn run_index(file: &Path, rebuild: bool) -> Result<()> {
96    let project_root = find_project_root(file)?;
97    let db_path = db_path(&project_root);
98    let indexed = if rebuild {
99        rebuild_project_index(&project_root)?
100    } else {
101        sync_project_index(&project_root)?
102    };
103    println!("{} archive(s) indexed into {}", indexed, db_path.display());
104    Ok(())
105}
106
107pub fn run_search(
108    file: &Path,
109    query: Option<&str>,
110    backlog_id: Option<&str>,
111    session: Option<&str>,
112    limit: usize,
113    json: bool,
114    rebuild: bool,
115) -> Result<()> {
116    if query.is_none() && backlog_id.is_none() && session.is_none() {
117        anyhow::bail!("archive-search requires --query, --id, or --session");
118    }
119    let project_root = find_project_root(file)?;
120    if rebuild {
121        rebuild_project_index(&project_root)?;
122    } else {
123        sync_project_index(&project_root)?;
124    }
125    let results = search_results(
126        file,
127        &SearchOptions {
128            query,
129            backlog_id,
130            session,
131            limit,
132        },
133    )?;
134    if json {
135        println!("{}", serde_json::to_string_pretty(&results)?);
136    } else if results.is_empty() {
137        println!("No archive hits.");
138    } else {
139        for result in results {
140            let refs = if result.ref_hits.is_empty() {
141                String::new()
142            } else {
143                format!(" refs={}", result.ref_hits.join(","))
144            };
145            println!(
146                "{} score={} [{} #{}] {}{}",
147                result.archived_at,
148                result.score,
149                result.speaker,
150                result.turn_ordinal,
151                result.archive_path,
152                refs
153            );
154            println!("  {}", result.preview);
155        }
156    }
157    Ok(())
158}
159
160pub fn index_archive(doc: &Path, archive_path: &Path) -> Result<()> {
161    let project_root = find_project_root(doc)?;
162    let db_path = db_path(&project_root);
163    let conn = Connection::open(&db_path)
164        .with_context(|| format!("failed to open {}", db_path.display()))?;
165    ensure_schema(&conn)?;
166    let record = parse_archive(doc, archive_path, &project_root)?;
167    upsert_archive(&conn, &record)?;
168    Ok(())
169}
170
171pub fn list_recent_turns(
172    file: &Path,
173    query: Option<&str>,
174    backlog_id: Option<&str>,
175    limit: usize,
176) -> Result<Vec<IndexedArchiveTurn>> {
177    let project_root = find_project_root(file)?;
178    sync_project_index(&project_root)?;
179    let current_doc = canonical_document_key(file, &project_root)?;
180    let current_session = current_session_id(file)?;
181    let db_path = db_path(&project_root);
182    let conn = Connection::open(&db_path)
183        .with_context(|| format!("failed to open {}", db_path.display()))?;
184    ensure_schema(&conn)?;
185
186    let query_norm = query.map(normalize_text);
187    let backlog_id = backlog_id.map(normalize_backlog_id).transpose()?;
188
189    let mut stmt = conn.prepare(
190        "SELECT a.id,
191                a.archive_path,
192                a.document_path,
193                a.session_id,
194                a.archived_at,
195                t.turn_ordinal,
196                t.speaker,
197                t.text,
198                t.normalized_text
199         FROM archive_turns t
200         JOIN archives a ON a.id = t.archive_id
201         WHERE a.document_path = ?1
202           AND t.speaker = 'assistant'
203           AND (?2 IS NULL OR instr(t.normalized_text, ?2) > 0)
204           AND (?3 IS NULL OR EXISTS (
205                SELECT 1 FROM archive_refs r
206                WHERE r.archive_id = a.id
207                  AND r.ref_kind = 'backlog_id'
208                  AND r.ref_value = ?3
209           ))
210         ORDER BY a.archived_at DESC, t.turn_ordinal DESC",
211    )?;
212
213    let rows = stmt.query_map(
214        params![current_doc, query_norm.as_deref(), backlog_id.as_deref()],
215        |row| {
216            Ok((
217                row.get::<_, i64>(0)?,
218                row.get::<_, String>(1)?,
219                row.get::<_, String>(2)?,
220                row.get::<_, Option<String>>(3)?,
221                row.get::<_, String>(4)?,
222                row.get::<_, i64>(5)?,
223                row.get::<_, String>(6)?,
224                row.get::<_, String>(7)?,
225                row.get::<_, String>(8)?,
226            ))
227        },
228    )?;
229
230    let mut results = Vec::new();
231    for row in rows {
232        let (
233            archive_id,
234            archive_path,
235            document_path,
236            session_id,
237            archived_at,
238            turn_ordinal,
239            speaker,
240            text,
241            normalized_text,
242        ) = row?;
243        let refs = archive_backlog_refs(&conn, archive_id)?;
244        let score = score_result(
245            &document_path,
246            session_id.as_deref(),
247            &normalized_text,
248            query_norm.as_deref(),
249            SearchScoreContext {
250                has_backlog_filter: backlog_id.is_some(),
251                has_ref_hit: backlog_id
252                    .as_deref()
253                    .map(|needle| refs.iter().any(|candidate| candidate == needle))
254                    .unwrap_or(false),
255                current_doc: &current_doc,
256                current_session: current_session.as_deref(),
257            },
258        );
259        results.push((
260            score,
261            IndexedArchiveTurn {
262                heading: turn_heading(&text, &speaker, turn_ordinal),
263                preview: preview_text(&text),
264                archive_path,
265                document_path,
266                session_id,
267                archived_at,
268                speaker,
269                turn_ordinal,
270                refs,
271                text,
272            },
273        ));
274    }
275
276    results.sort_by(|(score_a, a), (score_b, b)| {
277        score_b
278            .cmp(score_a)
279            .then_with(|| b.archived_at.cmp(&a.archived_at))
280            .then_with(|| b.turn_ordinal.cmp(&a.turn_ordinal))
281    });
282    results.truncate(limit);
283    Ok(results.into_iter().map(|(_, turn)| turn).collect())
284}
285
286pub fn fetch_turn_window(
287    file: &Path,
288    archive_path: &str,
289    turn_ordinal: i64,
290    before: usize,
291    after: usize,
292) -> Result<Vec<IndexedArchiveTurn>> {
293    let project_root = find_project_root(file)?;
294    sync_project_index(&project_root)?;
295    let db_path = db_path(&project_root);
296    let conn = Connection::open(&db_path)
297        .with_context(|| format!("failed to open {}", db_path.display()))?;
298    ensure_schema(&conn)?;
299
300    let archive_path = normalize_archive_path(&project_root, archive_path);
301    let start = turn_ordinal.saturating_sub(before as i64);
302    let end = turn_ordinal.saturating_add(after as i64);
303    let mut stmt = conn.prepare(
304        "SELECT a.id,
305                a.archive_path,
306                a.document_path,
307                a.session_id,
308                a.archived_at,
309                t.turn_ordinal,
310                t.speaker,
311                t.text
312         FROM archive_turns t
313         JOIN archives a ON a.id = t.archive_id
314         WHERE a.archive_path = ?1
315           AND t.turn_ordinal BETWEEN ?2 AND ?3
316         ORDER BY t.turn_ordinal ASC",
317    )?;
318
319    let turns = stmt
320        .query_map(params![archive_path, start, end], |row| {
321            Ok((
322                row.get::<_, i64>(0)?,
323                row.get::<_, String>(1)?,
324                row.get::<_, String>(2)?,
325                row.get::<_, Option<String>>(3)?,
326                row.get::<_, String>(4)?,
327                row.get::<_, i64>(5)?,
328                row.get::<_, String>(6)?,
329                row.get::<_, String>(7)?,
330            ))
331        })?
332        .collect::<std::result::Result<Vec<_>, _>>()?;
333
334    if turns.is_empty() {
335        anyhow::bail!(
336            "archive turn not found for {}#{}",
337            archive_path,
338            turn_ordinal
339        );
340    }
341
342    let mut results = Vec::new();
343    for (
344        archive_id,
345        archive_path,
346        document_path,
347        session_id,
348        archived_at,
349        turn_ordinal,
350        speaker,
351        text,
352    ) in turns
353    {
354        results.push(IndexedArchiveTurn {
355            heading: turn_heading(&text, &speaker, turn_ordinal),
356            preview: preview_text(&text),
357            refs: archive_backlog_refs(&conn, archive_id)?,
358            archive_path,
359            document_path,
360            session_id,
361            archived_at,
362            speaker,
363            turn_ordinal,
364            text,
365        });
366    }
367    Ok(results)
368}
369
370fn rebuild_project_index(project_root: &Path) -> Result<usize> {
371    let db_path = db_path(project_root);
372    let conn = Connection::open(&db_path)
373        .with_context(|| format!("failed to open {}", db_path.display()))?;
374    reset_schema(&conn)?;
375    let archives = list_archive_files(project_root)?;
376    for archive_path in &archives {
377        let record = parse_archive_from_project_root(archive_path, project_root)?;
378        upsert_archive(&conn, &record)?;
379    }
380    Ok(archives.len())
381}
382
383fn sync_project_index(project_root: &Path) -> Result<usize> {
384    let db_path = db_path(project_root);
385    let conn = Connection::open(&db_path)
386        .with_context(|| format!("failed to open {}", db_path.display()))?;
387    ensure_schema(&conn)?;
388    let archives = list_archive_files(project_root)?;
389    for archive_path in &archives {
390        let record = parse_archive_from_project_root(archive_path, project_root)?;
391        upsert_archive(&conn, &record)?;
392    }
393    Ok(archives.len())
394}
395
396fn search_results(file: &Path, options: &SearchOptions<'_>) -> Result<Vec<SearchResult>> {
397    let project_root = find_project_root(file)?;
398    let current_doc = canonical_document_key(file, &project_root)?;
399    let current_session = current_session_id(file)?;
400    let db_path = db_path(&project_root);
401    let conn = Connection::open(&db_path)
402        .with_context(|| format!("failed to open {}", db_path.display()))?;
403    ensure_schema(&conn)?;
404
405    let query_norm = options.query.map(normalize_text);
406    let backlog_id = options.backlog_id.map(normalize_backlog_id).transpose()?;
407
408    let mut stmt = conn.prepare(
409        "SELECT a.id,
410                a.archive_path,
411                a.document_path,
412                a.session_id,
413                a.archived_at,
414                t.turn_ordinal,
415                t.speaker,
416                t.text,
417                t.normalized_text
418         FROM archive_turns t
419         JOIN archives a ON a.id = t.archive_id
420         WHERE (?1 IS NULL OR instr(t.normalized_text, ?1) > 0)
421           AND (?2 IS NULL OR a.session_id = ?2)
422           AND (?3 IS NULL OR EXISTS (
423                SELECT 1 FROM archive_refs r
424                WHERE r.archive_id = a.id
425                  AND r.ref_kind = 'backlog_id'
426                  AND r.ref_value = ?3
427           ))
428         ORDER BY a.archived_at DESC, t.turn_ordinal DESC",
429    )?;
430
431    let rows = stmt.query_map(
432        params![
433            query_norm.as_deref(),
434            options.session,
435            backlog_id.as_deref()
436        ],
437        |row| {
438            Ok((
439                row.get::<_, i64>(0)?,
440                row.get::<_, String>(1)?,
441                row.get::<_, String>(2)?,
442                row.get::<_, Option<String>>(3)?,
443                row.get::<_, String>(4)?,
444                row.get::<_, i64>(5)?,
445                row.get::<_, String>(6)?,
446                row.get::<_, String>(7)?,
447                row.get::<_, String>(8)?,
448            ))
449        },
450    )?;
451
452    let mut results = Vec::new();
453    for row in rows {
454        let (
455            archive_id,
456            archive_path,
457            document_path,
458            session_id,
459            archived_at,
460            turn_ordinal,
461            speaker,
462            text,
463            normalized_text,
464        ) = row?;
465        let ref_hits = ref_hits(&conn, archive_id, backlog_id.as_deref())?;
466        let score = score_result(
467            &document_path,
468            session_id.as_deref(),
469            &normalized_text,
470            query_norm.as_deref(),
471            SearchScoreContext {
472                has_backlog_filter: backlog_id.is_some(),
473                has_ref_hit: !ref_hits.is_empty(),
474                current_doc: &current_doc,
475                current_session: current_session.as_deref(),
476            },
477        );
478        results.push(SearchResult {
479            archive_path,
480            document_path,
481            session_id,
482            archived_at,
483            speaker,
484            turn_ordinal,
485            score,
486            ref_hits,
487            preview: preview_text(&text),
488        });
489    }
490
491    results.sort_by(|a, b| {
492        b.score
493            .cmp(&a.score)
494            .then_with(|| b.archived_at.cmp(&a.archived_at))
495            .then_with(|| a.turn_ordinal.cmp(&b.turn_ordinal))
496    });
497    results.truncate(options.limit);
498    Ok(results)
499}
500
501struct SearchScoreContext<'a> {
502    has_backlog_filter: bool,
503    has_ref_hit: bool,
504    current_doc: &'a str,
505    current_session: Option<&'a str>,
506}
507
508fn score_result(
509    document_path: &str,
510    session_id: Option<&str>,
511    normalized_text: &str,
512    query_norm: Option<&str>,
513    context: SearchScoreContext<'_>,
514) -> i64 {
515    let mut score = 0;
516    if let Some(query) = query_norm
517        && normalized_text.contains(query)
518    {
519        score += 40;
520    }
521    if context.has_backlog_filter && context.has_ref_hit {
522        score += 100;
523    }
524    if document_path == context.current_doc {
525        score += 30;
526    }
527    if session_id.is_some() && session_id == context.current_session {
528        score += 15;
529    }
530    score
531}
532
533fn ref_hits(conn: &Connection, archive_id: i64, backlog_id: Option<&str>) -> Result<Vec<String>> {
534    let Some(backlog_id) = backlog_id else {
535        return Ok(Vec::new());
536    };
537    let mut stmt = conn.prepare(
538        "SELECT ref_value FROM archive_refs
539         WHERE archive_id = ?1 AND ref_kind = 'backlog_id' AND ref_value = ?2
540         ORDER BY ref_value",
541    )?;
542    let hits = stmt
543        .query_map(params![archive_id, backlog_id], |row| {
544            row.get::<_, String>(0)
545        })?
546        .collect::<std::result::Result<Vec<_>, _>>()?;
547    Ok(hits)
548}
549
550fn archive_backlog_refs(conn: &Connection, archive_id: i64) -> Result<Vec<String>> {
551    let mut stmt = conn.prepare(
552        "SELECT ref_value FROM archive_refs
553         WHERE archive_id = ?1 AND ref_kind = 'backlog_id'
554         ORDER BY ref_value",
555    )?;
556    let hits = stmt
557        .query_map(params![archive_id], |row| row.get::<_, String>(0))?
558        .collect::<std::result::Result<Vec<_>, _>>()?;
559    Ok(hits)
560}
561
562fn ensure_schema(conn: &Connection) -> Result<()> {
563    conn.execute_batch(
564        "PRAGMA foreign_keys = ON;
565         CREATE TABLE IF NOT EXISTS archives (
566             id INTEGER PRIMARY KEY,
567             archive_path TEXT NOT NULL UNIQUE,
568             document_path TEXT NOT NULL,
569             session_id TEXT,
570             component TEXT,
571             archived_at TEXT NOT NULL,
572             source_snapshot_hash TEXT,
573             archive_byte_size INTEGER NOT NULL,
574             indexed_at TEXT NOT NULL
575         );
576         CREATE TABLE IF NOT EXISTS archive_turns (
577             id INTEGER PRIMARY KEY,
578             archive_id INTEGER NOT NULL REFERENCES archives(id) ON DELETE CASCADE,
579             turn_ordinal INTEGER NOT NULL,
580             speaker TEXT NOT NULL,
581             text TEXT NOT NULL,
582             normalized_text TEXT NOT NULL,
583             UNIQUE(archive_id, turn_ordinal, speaker)
584         );
585         CREATE TABLE IF NOT EXISTS archive_refs (
586             archive_id INTEGER NOT NULL REFERENCES archives(id) ON DELETE CASCADE,
587             ref_kind TEXT NOT NULL,
588             ref_value TEXT NOT NULL,
589             UNIQUE(archive_id, ref_kind, ref_value)
590         );
591         CREATE INDEX IF NOT EXISTS idx_archive_turns_normalized_text
592             ON archive_turns(normalized_text);
593         CREATE INDEX IF NOT EXISTS idx_archive_refs_lookup
594             ON archive_refs(ref_kind, ref_value);
595         CREATE INDEX IF NOT EXISTS idx_archives_document_archived_at
596             ON archives(document_path, archived_at DESC);",
597    )?;
598    Ok(())
599}
600
601fn reset_schema(conn: &Connection) -> Result<()> {
602    conn.execute_batch(
603        "DROP TABLE IF EXISTS archive_refs;
604         DROP TABLE IF EXISTS archive_turns;
605         DROP TABLE IF EXISTS archives;",
606    )?;
607    ensure_schema(conn)
608}
609
610fn upsert_archive(conn: &Connection, record: &ArchiveRecord) -> Result<()> {
611    conn.execute(
612        "INSERT INTO archives (
613             archive_path, document_path, session_id, component, archived_at,
614             source_snapshot_hash, archive_byte_size, indexed_at
615         ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
616         ON CONFLICT(archive_path) DO UPDATE SET
617             document_path = excluded.document_path,
618             session_id = excluded.session_id,
619             component = excluded.component,
620             archived_at = excluded.archived_at,
621             source_snapshot_hash = excluded.source_snapshot_hash,
622             archive_byte_size = excluded.archive_byte_size,
623             indexed_at = excluded.indexed_at",
624        params![
625            record.archive_path,
626            record.document_path,
627            record.session_id,
628            record.component,
629            record.archived_at,
630            record.source_snapshot_hash,
631            record.archive_byte_size,
632            timestamp_now(),
633        ],
634    )?;
635    let archive_id: i64 = conn.query_row(
636        "SELECT id FROM archives WHERE archive_path = ?1",
637        params![record.archive_path],
638        |row| row.get(0),
639    )?;
640
641    conn.execute(
642        "DELETE FROM archive_turns WHERE archive_id = ?1",
643        params![archive_id],
644    )?;
645    conn.execute(
646        "DELETE FROM archive_refs WHERE archive_id = ?1",
647        params![archive_id],
648    )?;
649
650    for turn in &record.turns {
651        conn.execute(
652            "INSERT INTO archive_turns (
653                 archive_id, turn_ordinal, speaker, text, normalized_text
654             ) VALUES (?1, ?2, ?3, ?4, ?5)",
655            params![
656                archive_id,
657                turn.turn_ordinal,
658                turn.speaker,
659                turn.text,
660                turn.normalized_text
661            ],
662        )?;
663    }
664
665    for archive_ref in &record.refs {
666        conn.execute(
667            "INSERT OR IGNORE INTO archive_refs (archive_id, ref_kind, ref_value)
668             VALUES (?1, ?2, ?3)",
669            params![archive_id, archive_ref.kind, archive_ref.value],
670        )?;
671    }
672    Ok(())
673}
674
675fn list_archive_files(project_root: &Path) -> Result<Vec<PathBuf>> {
676    let archive_dir = project_root.join(".agent-doc/archives");
677    if !archive_dir.is_dir() {
678        return Ok(Vec::new());
679    }
680    let mut files = Vec::new();
681    for entry in fs::read_dir(&archive_dir)
682        .with_context(|| format!("failed to read {}", archive_dir.display()))?
683    {
684        let entry = entry?;
685        let path = entry.path();
686        if path.extension().and_then(|ext| ext.to_str()) == Some("md") {
687            files.push(path);
688        }
689    }
690    files.sort();
691    Ok(files)
692}
693
694fn parse_archive(doc: &Path, archive_path: &Path, project_root: &Path) -> Result<ArchiveRecord> {
695    let raw = fs::read_to_string(archive_path)
696        .with_context(|| format!("failed to read {}", archive_path.display()))?;
697    let (frontmatter, body) = split_archive_frontmatter(&raw)?;
698    let document_path = frontmatter
699        .get("document")
700        .cloned()
701        .unwrap_or(canonical_document_key(doc, project_root)?);
702    Ok(ArchiveRecord {
703        archive_path: relative_to_root(archive_path, project_root),
704        document_path,
705        session_id: frontmatter.get("session").cloned(),
706        component: frontmatter.get("component").cloned(),
707        archived_at: frontmatter
708            .get("archived_at")
709            .cloned()
710            .unwrap_or_else(timestamp_now),
711        source_snapshot_hash: archive_snapshot_hash(archive_path),
712        archive_byte_size: raw.len() as i64,
713        turns: parse_turns(&body),
714        refs: extract_refs(&body),
715    })
716}
717
718fn parse_archive_from_project_root(
719    archive_path: &Path,
720    project_root: &Path,
721) -> Result<ArchiveRecord> {
722    let raw = fs::read_to_string(archive_path)
723        .with_context(|| format!("failed to read {}", archive_path.display()))?;
724    let (frontmatter, body) = split_archive_frontmatter(&raw)?;
725    let document_path = frontmatter
726        .get("document")
727        .cloned()
728        .unwrap_or_else(|| "<unknown>".to_string());
729    Ok(ArchiveRecord {
730        archive_path: relative_to_root(archive_path, project_root),
731        document_path,
732        session_id: frontmatter.get("session").cloned(),
733        component: frontmatter.get("component").cloned(),
734        archived_at: frontmatter
735            .get("archived_at")
736            .cloned()
737            .unwrap_or_else(timestamp_now),
738        source_snapshot_hash: archive_snapshot_hash(archive_path),
739        archive_byte_size: raw.len() as i64,
740        turns: parse_turns(&body),
741        refs: extract_refs(&body),
742    })
743}
744
745fn split_archive_frontmatter(raw: &str) -> Result<(BTreeMap<String, String>, String)> {
746    let Some(rest) = raw.strip_prefix("---\n") else {
747        anyhow::bail!("archive missing frontmatter header");
748    };
749    let Some((frontmatter, body)) = rest.split_once("\n---\n") else {
750        anyhow::bail!("archive missing closing frontmatter delimiter");
751    };
752    let mut map = BTreeMap::new();
753    for line in frontmatter.lines() {
754        let Some((key, value)) = line.split_once(':') else {
755            continue;
756        };
757        map.insert(key.trim().to_string(), value.trim().to_string());
758    }
759    Ok((map, body.trim().to_string()))
760}
761
762fn parse_turns(body: &str) -> Vec<ArchiveTurn> {
763    let exchange_turns = parse_exchange_turns(body);
764    if !exchange_turns.is_empty() {
765        return exchange_turns;
766    }
767
768    let (preamble, sections) = agent_doc_core::topic::parse_topic_sections(body);
769    if !sections.is_empty() {
770        let mut turns = Vec::new();
771        let mut ordinal = 1;
772        if !preamble.trim().is_empty() {
773            turns.push(ArchiveTurn {
774                turn_ordinal: ordinal,
775                speaker: "assistant".to_string(),
776                text: preamble.trim().to_string(),
777                normalized_text: normalize_text(preamble.trim()),
778            });
779            ordinal += 1;
780        }
781        for section in sections {
782            let trimmed = section.trim();
783            if trimmed.is_empty() {
784                continue;
785            }
786            turns.push(ArchiveTurn {
787                turn_ordinal: ordinal,
788                speaker: "assistant".to_string(),
789                text: trimmed.to_string(),
790                normalized_text: normalize_text(trimmed),
791            });
792            ordinal += 1;
793        }
794        return turns;
795    }
796
797    let trimmed = body.trim();
798    if trimmed.is_empty() {
799        Vec::new()
800    } else {
801        vec![ArchiveTurn {
802            turn_ordinal: 1,
803            speaker: "assistant".to_string(),
804            text: trimmed.to_string(),
805            normalized_text: normalize_text(trimmed),
806        }]
807    }
808}
809
810fn parse_exchange_turns(body: &str) -> Vec<ArchiveTurn> {
811    let mut turns = Vec::new();
812    let mut current_speaker: Option<&str> = None;
813    let mut current = String::new();
814    let mut ordinal = 1;
815    let mut in_code_block = false;
816
817    for line in body.lines() {
818        if line.starts_with("```") {
819            in_code_block = !in_code_block;
820        }
821        if !in_code_block && (line == "## User" || line == "## Assistant") {
822            flush_turn(&mut turns, &mut current_speaker, &mut current, &mut ordinal);
823            current_speaker = Some(if line == "## User" {
824                "user"
825            } else {
826                "assistant"
827            });
828            continue;
829        }
830        if current_speaker.is_some() {
831            current.push_str(line);
832            current.push('\n');
833        }
834    }
835    flush_turn(&mut turns, &mut current_speaker, &mut current, &mut ordinal);
836    turns
837}
838
839fn flush_turn(
840    turns: &mut Vec<ArchiveTurn>,
841    current_speaker: &mut Option<&str>,
842    current: &mut String,
843    ordinal: &mut i64,
844) {
845    let Some(speaker) = current_speaker.take() else {
846        return;
847    };
848    let trimmed = current.trim();
849    if trimmed.is_empty() {
850        current.clear();
851        return;
852    }
853    turns.push(ArchiveTurn {
854        turn_ordinal: *ordinal,
855        speaker: speaker.to_string(),
856        text: trimmed.to_string(),
857        normalized_text: normalize_text(trimmed),
858    });
859    *ordinal += 1;
860    current.clear();
861}
862
863fn extract_refs(body: &str) -> Vec<ArchiveRef> {
864    let mut refs = BTreeSet::new();
865    for backlog_id in extract_backlog_ids(body) {
866        refs.insert(ArchiveRef {
867            kind: "backlog_id".to_string(),
868            value: backlog_id,
869        });
870    }
871    for plan_path in extract_plan_paths(body) {
872        refs.insert(ArchiveRef {
873            kind: "plan_path".to_string(),
874            value: plan_path,
875        });
876    }
877    refs.into_iter().collect()
878}
879
880fn extract_backlog_ids(text: &str) -> BTreeSet<String> {
881    let chars: Vec<char> = text.chars().collect();
882    let mut found = BTreeSet::new();
883    let mut i = 0;
884    while i < chars.len() {
885        if chars[i] == '#'
886            && (i == 0 || chars[i - 1] != '#')
887            && i + 1 < chars.len()
888            && is_backlog_id_char(chars[i + 1])
889        {
890            let mut j = i + 1;
891            while j < chars.len() && is_backlog_id_char(chars[j]) {
892                j += 1;
893            }
894            found.insert(chars[i..j].iter().collect());
895            i = j;
896            continue;
897        }
898        i += 1;
899    }
900    found
901}
902
903fn extract_plan_paths(text: &str) -> BTreeSet<String> {
904    text.split_whitespace()
905        .filter_map(|token| {
906            let cleaned =
907                token.trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | '(' | ')' | ',' | '.'));
908            if cleaned.contains('/') && cleaned.ends_with(".md") {
909                Some(cleaned.to_string())
910            } else {
911                None
912            }
913        })
914        .collect()
915}
916
917fn preview_text(text: &str) -> String {
918    let collapsed = normalize_text(text);
919    let mut chars = collapsed.chars();
920    let preview: String = chars.by_ref().take(120).collect();
921    if chars.next().is_some() {
922        format!("{}...", preview.chars().take(117).collect::<String>())
923    } else {
924        preview
925    }
926}
927
928fn turn_heading(text: &str, speaker: &str, turn_ordinal: i64) -> String {
929    text.lines()
930        .map(str::trim)
931        .find(|line| !line.is_empty())
932        .map(str::to_string)
933        .unwrap_or_else(|| format!("{} turn #{}", speaker, turn_ordinal))
934}
935
936fn normalize_text(text: &str) -> String {
937    text.split_whitespace()
938        .map(|segment| segment.to_ascii_lowercase())
939        .collect::<Vec<_>>()
940        .join(" ")
941}
942
943fn normalize_backlog_id(raw: &str) -> Result<String> {
944    let trimmed = raw.trim();
945    if trimmed.is_empty() {
946        anyhow::bail!("backlog id cannot be empty");
947    }
948    Ok(if trimmed.starts_with('#') {
949        trimmed.to_string()
950    } else {
951        format!("#{trimmed}")
952    })
953}
954
955fn is_backlog_id_char(ch: char) -> bool {
956    ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'
957}
958
959fn current_session_id(file: &Path) -> Result<Option<String>> {
960    let content =
961        fs::read_to_string(file).with_context(|| format!("failed to read {}", file.display()))?;
962    let Ok((frontmatter, _)) = agent_doc_core::frontmatter::parse(&content) else {
963        return Ok(None);
964    };
965    Ok(frontmatter.session)
966}
967
968fn archive_snapshot_hash(path: &Path) -> Option<String> {
969    let stem = path.file_stem()?.to_str()?;
970    let hash: String = stem
971        .chars()
972        .take_while(|ch| ch.is_ascii_hexdigit())
973        .collect();
974    if hash.is_empty() { None } else { Some(hash) }
975}
976
977fn canonical_document_key(file: &Path, project_root: &Path) -> Result<String> {
978    let canonical = file
979        .canonicalize()
980        .with_context(|| format!("failed to canonicalize {}", file.display()))?;
981    Ok(relative_to_root(&canonical, project_root))
982}
983
984fn relative_to_root(path: &Path, project_root: &Path) -> String {
985    path.strip_prefix(project_root)
986        .map(|rel| rel.to_string_lossy().replace('\\', "/"))
987        .unwrap_or_else(|_| path.to_string_lossy().replace('\\', "/"))
988}
989
990fn normalize_archive_path(project_root: &Path, archive_path: &str) -> String {
991    let path = Path::new(archive_path);
992    if path.is_absolute() {
993        relative_to_root(path, project_root)
994    } else {
995        archive_path.replace('\\', "/")
996    }
997}
998
999fn db_path(project_root: &Path) -> PathBuf {
1000    project_root.join(".agent-doc/archive-index.db")
1001}
1002
1003fn find_project_root(file: &Path) -> Result<PathBuf> {
1004    let canonical = file
1005        .canonicalize()
1006        .with_context(|| format!("failed to canonicalize {}", file.display()))?;
1007    let mut dir = canonical.parent();
1008    while let Some(candidate) = dir {
1009        if candidate.join(".agent-doc").is_dir() {
1010            return Ok(candidate.to_path_buf());
1011        }
1012        dir = candidate.parent();
1013    }
1014    anyhow::bail!("failed to find project root for {}", file.display());
1015}
1016
1017fn timestamp_now() -> String {
1018    use std::time::{SystemTime, UNIX_EPOCH};
1019    let now = SystemTime::now()
1020        .duration_since(UNIX_EPOCH)
1021        .unwrap_or_default();
1022    let secs = now.as_secs();
1023    let days = secs / 86_400;
1024    let time_of_day = secs % 86_400;
1025    let hours = time_of_day / 3_600;
1026    let minutes = (time_of_day % 3_600) / 60;
1027    let seconds = time_of_day % 60;
1028
1029    let mut year = 1970_i64;
1030    let mut remaining_days = days as i64;
1031    loop {
1032        let days_in_year = if is_leap_year(year) { 366 } else { 365 };
1033        if remaining_days < days_in_year {
1034            break;
1035        }
1036        remaining_days -= days_in_year;
1037        year += 1;
1038    }
1039    let month_days: &[i64] = if is_leap_year(year) {
1040        &[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
1041    } else {
1042        &[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
1043    };
1044    let mut month = 0;
1045    for &month_days in month_days {
1046        if remaining_days < month_days {
1047            break;
1048        }
1049        remaining_days -= month_days;
1050        month += 1;
1051    }
1052    format!(
1053        "{:04}{:02}{:02}-{:02}{:02}{:02}",
1054        year,
1055        month + 1,
1056        remaining_days + 1,
1057        hours,
1058        minutes,
1059        seconds
1060    )
1061}
1062
1063fn is_leap_year(year: i64) -> bool {
1064    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
1065}
1066
1067#[cfg(test)]
1068mod tests {
1069    use super::*;
1070    use rusqlite::Connection;
1071
1072    #[test]
1073    fn bare_id_filter_accepts_missing_hash_prefix() {
1074        assert_eq!(normalize_backlog_id("sqlarcidx").unwrap(), "#sqlarcidx");
1075        assert_eq!(normalize_backlog_id("#sqlarcidx").unwrap(), "#sqlarcidx");
1076    }
1077
1078    #[test]
1079    fn compact_section_archives_are_chunked() {
1080        let body = "### Session Summary\n\nArchived context.\n\n### Re: one — gpt-5\n\nFirst body.\n\n### Re: two — gpt-5\n\nSecond body.\n";
1081        let turns = parse_turns(body);
1082        assert_eq!(turns.len(), 3);
1083        assert!(turns[0].text.contains("Session Summary"));
1084        assert!(turns[1].text.contains("### Re: one"));
1085        assert!(turns[2].text.contains("### Re: two"));
1086    }
1087
1088    #[test]
1089    fn rebuild_indexes_archives() {
1090        let tmp = tempfile::tempdir().unwrap();
1091        let root = tmp.path();
1092        fs::create_dir_all(root.join(".agent-doc/archives")).unwrap();
1093        let doc = root.join("tasks/session.md");
1094        fs::create_dir_all(doc.parent().unwrap()).unwrap();
1095        fs::write(
1096            &doc,
1097            "---\nagent_doc_session: current-session\n---\n\nbody\n",
1098        )
1099        .unwrap();
1100        let archive = root.join(".agent-doc/archives/hash-20260506-000000.md");
1101        fs::write(
1102            &archive,
1103            concat!(
1104                "---\n",
1105                "archived_from: compact\n",
1106                "archived_at: 20260506-000000\n",
1107                "component: exchange\n",
1108                "document: tasks/session.md\n",
1109                "session: current-session\n",
1110                "---\n\n",
1111                "## User\n\nDo #sqlarcidx.\n\n",
1112                "## Assistant\n\nPlan: tasks/agent-doc/plan-sqlite-compacted-turn-archive.md\n"
1113            ),
1114        )
1115        .unwrap();
1116
1117        let indexed = rebuild_project_index(root).unwrap();
1118        assert_eq!(indexed, 1);
1119
1120        let conn = Connection::open(root.join(".agent-doc/archive-index.db")).unwrap();
1121        let archive_count: i64 = conn
1122            .query_row("SELECT COUNT(*) FROM archives", [], |row| row.get(0))
1123            .unwrap();
1124        let turn_count: i64 = conn
1125            .query_row("SELECT COUNT(*) FROM archive_turns", [], |row| row.get(0))
1126            .unwrap();
1127        let ref_count: i64 = conn
1128            .query_row("SELECT COUNT(*) FROM archive_refs", [], |row| row.get(0))
1129            .unwrap();
1130        assert_eq!(archive_count, 1);
1131        assert_eq!(turn_count, 2);
1132        assert!(ref_count >= 2);
1133    }
1134
1135    #[test]
1136    fn search_prefers_current_document_hits() {
1137        let tmp = tempfile::tempdir().unwrap();
1138        let root = tmp.path();
1139        fs::create_dir_all(root.join(".agent-doc/archives")).unwrap();
1140        let current_doc = root.join("tasks/current.md");
1141        let other_doc = root.join("tasks/other.md");
1142        fs::create_dir_all(current_doc.parent().unwrap()).unwrap();
1143        fs::write(
1144            &current_doc,
1145            "---\nagent_doc_session: current-session\n---\n\nbody\n",
1146        )
1147        .unwrap();
1148        fs::write(
1149            &other_doc,
1150            "---\nagent_doc_session: other-session\n---\n\nbody\n",
1151        )
1152        .unwrap();
1153
1154        fs::write(
1155            root.join(".agent-doc/archives/a-20260506-000000.md"),
1156            concat!(
1157                "---\n",
1158                "archived_from: compact\n",
1159                "archived_at: 20260506-000000\n",
1160                "component: exchange\n",
1161                "document: tasks/current.md\n",
1162                "session: current-session\n",
1163                "---\n\n",
1164                "## Assistant\n\nNeed #sqlarcidx and sqlite lookup.\n"
1165            ),
1166        )
1167        .unwrap();
1168        fs::write(
1169            root.join(".agent-doc/archives/b-20260506-000100.md"),
1170            concat!(
1171                "---\n",
1172                "archived_from: compact\n",
1173                "archived_at: 20260506-000100\n",
1174                "component: exchange\n",
1175                "document: tasks/other.md\n",
1176                "session: other-session\n",
1177                "---\n\n",
1178                "## Assistant\n\nNeed #sqlarcidx and sqlite lookup.\n"
1179            ),
1180        )
1181        .unwrap();
1182
1183        rebuild_project_index(root).unwrap();
1184        let results = search_results(
1185            &current_doc,
1186            &SearchOptions {
1187                query: Some("sqlite lookup"),
1188                backlog_id: Some("#sqlarcidx"),
1189                session: None,
1190                limit: 10,
1191            },
1192        )
1193        .unwrap();
1194        assert_eq!(results.len(), 2);
1195        assert_eq!(results[0].document_path, "tasks/current.md");
1196        assert!(results[0].score > results[1].score);
1197    }
1198}