1use 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: ¤t_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: ¤t_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 ¤t_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 ¤t_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}