Skip to main content

agent_core/memory/
index.rs

1//! Task 33 — staged in-repo lexical memory index (spec §9.6 bounds; SQLite
2//! declined, see docs/decisions/T33-memory-index-no-sqlite.md).
3//!
4//! The index is fully DERIVED state over the append-only JSONL store:
5//!
6//! - immutable ts-desc-sorted segment files of doc summaries
7//!   (`{"id","ts","tags","terms"}`). Lexical terms are CONTENT-DERIVED —
8//!   they reveal the vocabulary of record bodies, so index files and
9//!   directories are private-moded (0600/0700) like the store itself, and
10//!   `secret`-sensitivity bodies contribute NO terms at all (metadata-only
11//!   indexing: id, timestamp, tags);
12//! - an atomically renamed `manifest.json` recording the consumed store
13//!   offset, segment metadata (with min/max timestamps for range skipping),
14//!   and the tombstone set;
15//! - queries stream segment lines through a k-way ts-desc merge with a
16//!   result-bounded collector — resident memory is proportional to the
17//!   requested limit plus at most [`MAX_SEGMENTS`] buffered docs (staging
18//!   compacts segments whenever they exceed that bound), never to the
19//!   total match count, and streaming early-terminates once a page is
20//!   full;
21//! - crash safety + integrity: torn store tails are healed by the store's
22//!   append path and skipped by the lenient reader; orphaned `*.tmp` files
23//!   are ignored; the manifest records byte length AND sha256 per segment,
24//!   and any unparseable manifest, offset mismatch, or missing/truncated/
25//!   corrupted segment triggers a VERIFIED full rebuild from the store
26//!   (the index can always be deleted safely).
27//!
28//! No network: this module performs filesystem I/O only. Embeddings are
29//! DISABLED — no embedding hook exists here, so no implicit remote call is
30//! possible (spec §9.6; local embeddings would be a separate ask-first
31//! opt-in).
32
33use std::collections::{BTreeSet, BinaryHeap, HashSet};
34use std::fs;
35use std::io::{BufRead, BufReader};
36use std::path::{Path, PathBuf};
37
38use serde::{Deserialize, Serialize};
39
40use super::store::{namespace_file, MemoryError, MemoryRecord, ProjectScope};
41use super::store::{DEFAULT_SEARCH_LIMIT, MAX_SEARCH_LIMIT};
42
43/// Docs per immutable segment file.
44pub const SEGMENT_MAX_DOCS: usize = 1024;
45/// Hard bound on staged segments (and therefore on open readers per
46/// query). Staging compacts everything above this into fresh globally
47/// ts-ordered segments.
48pub const MAX_SEGMENTS: usize = 32;
49/// Lexical terms retained per document (deduped, sorted).
50pub const MAX_TERMS_PER_DOC: usize = 512;
51
52const MANIFEST_VERSION: u32 = 2;
53
54/// Per-project index directory: `<base>/memory/index/<namespace>/`.
55pub fn index_dir_in(base: &Path, scope: &ProjectScope) -> PathBuf {
56    super::store::memory_dir_in(base)
57        .join("index")
58        .join(scope.namespace())
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62struct SegmentMeta {
63    file: String,
64    docs: usize,
65    min_ts: u64,
66    max_ts: u64,
67    /// Exact byte length of the segment file (truncation detection).
68    bytes: u64,
69    /// SHA-256 of the segment file (corruption detection).
70    sha256: String,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74struct Manifest {
75    version: u32,
76    /// Bytes of the store file consumed into segments.
77    indexed_bytes: u64,
78    segments: Vec<SegmentMeta>,
79    tombstones: Vec<String>,
80}
81
82impl Manifest {
83    fn empty() -> Self {
84        Self {
85            version: MANIFEST_VERSION,
86            indexed_bytes: 0,
87            segments: Vec::new(),
88            tombstones: Vec::new(),
89        }
90    }
91}
92
93/// One indexed document summary — content-free (terms, not bodies).
94#[derive(Debug, Clone, Serialize, Deserialize)]
95struct SegmentDoc {
96    id: String,
97    ts: u64,
98    #[serde(default)]
99    tags: Vec<String>,
100    #[serde(default)]
101    terms: Vec<String>,
102}
103
104/// Exclusive pagination cursor: results strictly AFTER this position in
105/// `(timestamp desc, id desc)` order.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct PageCursor {
108    pub before_ts: u64,
109    pub before_id: String,
110}
111
112/// Index query. Terms use token-exact AND semantics over the lexical term
113/// sets (see the T33 decision record for the trade-off vs FTS).
114#[derive(Debug, Clone, Default)]
115pub struct IndexQuery {
116    pub terms: Vec<String>,
117    pub tag_prefix: Option<String>,
118    pub since_ms: Option<u64>,
119    pub until_ms: Option<u64>,
120    /// Clamped to [`MAX_SEARCH_LIMIT`].
121    pub limit: Option<usize>,
122    pub cursor: Option<PageCursor>,
123}
124
125/// A matching document reference (bodies stay in the store; fetch by id).
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct IndexHit {
128    pub id: String,
129    pub timestamp_ms: u64,
130    pub tags: Vec<String>,
131}
132
133/// Observability for the proportional-memory bound.
134#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
135pub struct IndexSearchStats {
136    /// Documents popped from the merge (early termination keeps this far
137    /// below the corpus size for full pages).
138    pub docs_scanned: usize,
139    /// Peak retained hits — bounded by the requested limit by construction.
140    pub max_resident_hits: usize,
141    /// Segment readers opened for this query — bounded by [`MAX_SEGMENTS`].
142    pub segments_open: usize,
143}
144
145/// One bounded result page.
146#[derive(Debug, Clone)]
147pub struct IndexPage {
148    pub hits: Vec<IndexHit>,
149    pub next: Option<PageCursor>,
150    pub stats: IndexSearchStats,
151}
152
153/// Status returned by [`ensure_index_in`].
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub struct IndexStatus {
156    pub segments: usize,
157    pub indexed_docs: usize,
158    pub tombstones: usize,
159}
160
161/// Normalize content into deduped lexical terms.
162fn terms_of(content: &str) -> Vec<String> {
163    let mut set = BTreeSet::new();
164    for token in content
165        .split(|c: char| !c.is_alphanumeric())
166        .filter(|t| t.len() >= 2)
167    {
168        set.insert(token.to_lowercase());
169        if set.len() >= MAX_TERMS_PER_DOC {
170            break;
171        }
172    }
173    set.into_iter().collect()
174}
175
176fn manifest_path(dir: &Path) -> PathBuf {
177    dir.join("manifest.json")
178}
179
180fn load_manifest(dir: &Path) -> Option<Manifest> {
181    let raw = fs::read_to_string(manifest_path(dir)).ok()?;
182    let manifest: Manifest = serde_json::from_str(&raw).ok()?;
183    if manifest.version != MANIFEST_VERSION {
184        return None;
185    }
186    Some(manifest)
187}
188
189fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), MemoryError> {
190    crate::core::private_fs::write_atomic_private(path, bytes)
191        .map_err(std::io::Error::from)
192        .map_err(MemoryError::from)
193}
194
195fn sha256_hex(bytes: &[u8]) -> String {
196    use sha2::{Digest, Sha256};
197    let digest = Sha256::digest(bytes);
198    let mut out = String::with_capacity(64);
199    for byte in digest {
200        use std::fmt::Write;
201        let _ = write!(out, "{byte:02x}");
202    }
203    out
204}
205
206/// Verify every manifest-referenced segment: present, exact byte length,
207/// exact sha256 (CP-13 fix1 I8). Any deviation invalidates the manifest.
208fn segments_verified(dir: &Path, manifest: &Manifest) -> bool {
209    for seg in &manifest.segments {
210        let path = dir.join(&seg.file);
211        let Ok(bytes) = fs::read(&path) else {
212            return false; // missing/unreadable
213        };
214        if bytes.len() as u64 != seg.bytes {
215            return false; // truncated/grown
216        }
217        if sha256_hex(&bytes) != seg.sha256 {
218            return false; // corrupted
219        }
220    }
221    true
222}
223
224/// Wipe every index artifact (derived state — always safe) for a rebuild.
225fn reset_index_dir(dir: &Path) -> Result<(), MemoryError> {
226    if dir.exists() {
227        fs::remove_dir_all(dir)?;
228    }
229    Ok(())
230}
231
232/// Stage the index up to the current end of the store: consume unindexed
233/// store bytes into new immutable segments and an atomically replaced
234/// manifest. Crash-safe at every step; rebuilds from scratch when the
235/// manifest is unparseable or the store shrank (rewritten by retention).
236pub fn ensure_index_in(base: &Path, scope: &ProjectScope) -> Result<IndexStatus, MemoryError> {
237    let dir = index_dir_in(base, scope);
238    let store_path = namespace_file(base, &scope.namespace());
239    let store_len = fs::metadata(&store_path).map(|m| m.len()).unwrap_or(0);
240
241    let mut manifest = match load_manifest(&dir) {
242        Some(m) if m.indexed_bytes <= store_len && segments_verified(&dir, &m) => m,
243        Some(_) | None => {
244            // Corrupt/mismatched manifest, rewritten store, or a
245            // missing/truncated/corrupted segment: verified full rebuild.
246            reset_index_dir(&dir)?;
247            Manifest::empty()
248        }
249    };
250
251    if store_len > manifest.indexed_bytes {
252        crate::core::private_fs::ensure_private_dir(&dir).map_err(std::io::Error::from)?;
253        let mut tombstones: HashSet<String> = manifest.tombstones.iter().cloned().collect();
254        let mut pending: Vec<SegmentDoc> = Vec::new();
255
256        let file = fs::File::open(&store_path)?;
257        let mut reader = BufReader::new(file);
258        // Skip already-consumed bytes.
259        {
260            use std::io::Read;
261            std::io::copy(
262                &mut Read::take(&mut reader, manifest.indexed_bytes),
263                &mut std::io::sink(),
264            )?;
265        }
266        let mut consumed = manifest.indexed_bytes;
267        let mut line = String::new();
268        loop {
269            line.clear();
270            let n = reader.read_line(&mut line)?;
271            if n == 0 {
272                break;
273            }
274            if !line.ends_with('\n') {
275                // Torn tail: not consumed; the store's append path heals it
276                // and a later pass picks up everything after the heal.
277                break;
278            }
279            consumed += n as u64;
280            let trimmed = line.trim();
281            if trimmed.is_empty() {
282                continue;
283            }
284            if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) {
285                if let Some(id) = value.get("tombstone").and_then(|v| v.as_str()) {
286                    tombstones.insert(id.to_string());
287                    pending.retain(|doc| doc.id != id);
288                    continue;
289                }
290            }
291            let record: MemoryRecord = match serde_json::from_str(trimmed) {
292                Ok(r) => r,
293                Err(_) => continue,
294            };
295            if record.project.as_deref() != Some(scope.key()) {
296                continue;
297            }
298            let Some(id) = record.id.clone() else {
299                continue;
300            };
301            if tombstones.contains(&id) {
302                continue;
303            }
304            // CP-13 fix1 I7: secret bodies contribute NO terms — the
305            // persisted index carries only metadata for them.
306            let terms = if record.sensitivity == Some(super::store::MemorySensitivity::Secret) {
307                Vec::new()
308            } else {
309                terms_of(&record.content)
310            };
311            pending.push(SegmentDoc {
312                id,
313                ts: record.timestamp_ms,
314                tags: record.tags,
315                terms,
316            });
317        }
318
319        let mut next_seg = manifest
320            .segments
321            .iter()
322            .filter_map(|s| {
323                s.file
324                    .strip_prefix("seg-")
325                    .and_then(|rest| rest.strip_suffix(".jsonl"))
326                    .and_then(|n| n.parse::<u64>().ok())
327            })
328            .max()
329            .map(|n| n + 1)
330            .unwrap_or(0);
331        for chunk in pending.chunks(SEGMENT_MAX_DOCS) {
332            let mut docs = chunk.to_vec();
333            docs.sort_by(|a, b| b.ts.cmp(&a.ts).then_with(|| b.id.cmp(&a.id)));
334            manifest
335                .segments
336                .push(write_segment(&dir, next_seg, &docs)?);
337            next_seg += 1;
338        }
339
340        manifest.indexed_bytes = consumed;
341        manifest.tombstones = tombstones.into_iter().collect();
342        manifest.tombstones.sort();
343
344        // CP-13 fix1 M1: bound the staged segment count. Above
345        // MAX_SEGMENTS, merge every live doc into fresh globally
346        // ts-ordered segments (tombstones applied and cleared). This is
347        // maintenance-time memory (proportional to indexed docs), traded
348        // for a hard bound on QUERY-time open readers and buffered docs.
349        if manifest.segments.len() > MAX_SEGMENTS {
350            let tombstoned: HashSet<String> = manifest.tombstones.iter().cloned().collect();
351            let mut all_docs: Vec<SegmentDoc> = Vec::new();
352            for seg in &manifest.segments {
353                let mut stream = SegmentStream::open(&dir.join(&seg.file))?;
354                while let Some(doc) = stream.current.take() {
355                    if !tombstoned.contains(&doc.id) {
356                        all_docs.push(doc);
357                    }
358                    stream.advance()?;
359                }
360            }
361            all_docs.sort_by(|a, b| b.ts.cmp(&a.ts).then_with(|| b.id.cmp(&a.id)));
362
363            let old_files: Vec<String> = manifest.segments.iter().map(|s| s.file.clone()).collect();
364            let target_docs = all_docs
365                .len()
366                .div_ceil(MAX_SEGMENTS / 2)
367                .max(SEGMENT_MAX_DOCS);
368            let mut compacted: Vec<SegmentMeta> = Vec::new();
369            for chunk in all_docs.chunks(target_docs) {
370                compacted.push(write_segment(&dir, next_seg, chunk)?);
371                next_seg += 1;
372            }
373            manifest.segments = compacted;
374            manifest.tombstones.clear();
375            // New manifest FIRST (atomic), then the orphaned old segment
376            // files can be removed; a crash in between leaves ignorable
377            // orphans, never a broken index.
378            write_atomic(
379                &manifest_path(&dir),
380                serde_json::to_string_pretty(&manifest)?.as_bytes(),
381            )?;
382            for file in old_files {
383                let _ = fs::remove_file(dir.join(file));
384            }
385        } else {
386            write_atomic(
387                &manifest_path(&dir),
388                serde_json::to_string_pretty(&manifest)?.as_bytes(),
389            )?;
390        }
391    }
392
393    Ok(IndexStatus {
394        segments: manifest.segments.len(),
395        indexed_docs: manifest.segments.iter().map(|s| s.docs).sum(),
396        tombstones: manifest.tombstones.len(),
397    })
398}
399
400/// Serialize one ts-desc-sorted doc chunk into an immutable segment file
401/// and return its verified metadata (length + sha256).
402fn write_segment(dir: &Path, seq: u64, docs: &[SegmentDoc]) -> Result<SegmentMeta, MemoryError> {
403    let mut body = String::new();
404    for doc in docs {
405        body.push_str(&serde_json::to_string(doc)?);
406        body.push('\n');
407    }
408    let file = format!("seg-{seq:06}.jsonl");
409    write_atomic(&dir.join(&file), body.as_bytes())?;
410    Ok(SegmentMeta {
411        file,
412        docs: docs.len(),
413        min_ts: docs.iter().map(|d| d.ts).min().unwrap_or(0),
414        max_ts: docs.iter().map(|d| d.ts).max().unwrap_or(0),
415        bytes: body.len() as u64,
416        sha256: sha256_hex(body.as_bytes()),
417    })
418}
419
420/// Streaming ts-desc segment reader: one buffered doc at a time.
421struct SegmentStream {
422    reader: BufReader<fs::File>,
423    current: Option<SegmentDoc>,
424}
425
426impl SegmentStream {
427    fn open(path: &Path) -> Result<Self, MemoryError> {
428        let reader = BufReader::new(fs::File::open(path)?);
429        let mut stream = Self {
430            reader,
431            current: None,
432        };
433        stream.advance()?;
434        Ok(stream)
435    }
436
437    fn advance(&mut self) -> Result<(), MemoryError> {
438        let mut line = String::new();
439        loop {
440            line.clear();
441            if self.reader.read_line(&mut line)? == 0 {
442                self.current = None;
443                return Ok(());
444            }
445            if line.trim().is_empty() {
446                continue;
447            }
448            match serde_json::from_str::<SegmentDoc>(line.trim()) {
449                Ok(doc) => {
450                    self.current = Some(doc);
451                    return Ok(());
452                }
453                Err(_) => continue, // torn/foreign line — skip
454            }
455        }
456    }
457}
458
459/// Search the staged index (auto-staging the tail first). Bounded page,
460/// newest-first, resident memory proportional to the limit.
461pub fn search_index_in(
462    base: &Path,
463    scope: &ProjectScope,
464    query: &IndexQuery,
465) -> Result<IndexPage, MemoryError> {
466    ensure_index_in(base, scope)?;
467    let dir = index_dir_in(base, scope);
468    let manifest = load_manifest(&dir).unwrap_or_else(Manifest::empty);
469    let tombstones: HashSet<&String> = manifest.tombstones.iter().collect();
470    let limit = query
471        .limit
472        .unwrap_or(DEFAULT_SEARCH_LIMIT)
473        .clamp(1, MAX_SEARCH_LIMIT);
474    let wanted_terms: Vec<String> = query.terms.iter().flat_map(|t| terms_of(t)).collect();
475
476    // Open streams for segments that can overlap the time range.
477    let mut streams: Vec<SegmentStream> = Vec::new();
478    for seg in &manifest.segments {
479        if let Some(since) = query.since_ms {
480            if seg.max_ts < since {
481                continue;
482            }
483        }
484        if let Some(until) = query.until_ms {
485            if seg.min_ts > until {
486                continue;
487            }
488        }
489        if let Some(cursor) = &query.cursor {
490            if seg.min_ts > cursor.before_ts {
491                continue; // entirely newer than the cursor position
492            }
493        }
494        streams.push(SegmentStream::open(&dir.join(&seg.file))?);
495    }
496
497    // K-way merge on (ts desc, id desc).
498    #[derive(PartialEq, Eq)]
499    struct HeapKey {
500        ts: u64,
501        id: String,
502        stream: usize,
503    }
504    impl Ord for HeapKey {
505        fn cmp(&self, other: &Self) -> std::cmp::Ordering {
506            self.ts.cmp(&other.ts).then_with(|| self.id.cmp(&other.id))
507        }
508    }
509    impl PartialOrd for HeapKey {
510        fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
511            Some(self.cmp(other))
512        }
513    }
514    let mut heap: BinaryHeap<HeapKey> = BinaryHeap::new();
515    for (i, stream) in streams.iter().enumerate() {
516        if let Some(doc) = &stream.current {
517            heap.push(HeapKey {
518                ts: doc.ts,
519                id: doc.id.clone(),
520                stream: i,
521            });
522        }
523    }
524
525    let mut hits: Vec<IndexHit> = Vec::new();
526    let mut stats = IndexSearchStats {
527        segments_open: streams.len(),
528        ..Default::default()
529    };
530    let mut next: Option<PageCursor> = None;
531
532    while let Some(key) = heap.pop() {
533        let stream = &mut streams[key.stream];
534        let doc = stream
535            .current
536            .take()
537            .expect("heap keys always reference a buffered doc");
538        stream.advance()?;
539        if let Some(following) = &stream.current {
540            heap.push(HeapKey {
541                ts: following.ts,
542                id: following.id.clone(),
543                stream: key.stream,
544            });
545        }
546        stats.docs_scanned += 1;
547
548        // Cursor: only positions strictly after (older than) the cursor.
549        if let Some(cursor) = &query.cursor {
550            let after = doc.ts < cursor.before_ts
551                || (doc.ts == cursor.before_ts && doc.id < cursor.before_id);
552            if !after {
553                continue;
554            }
555        }
556        if tombstones.contains(&doc.id) {
557            continue;
558        }
559        if let Some(since) = query.since_ms {
560            if doc.ts < since {
561                continue;
562            }
563        }
564        if let Some(until) = query.until_ms {
565            if doc.ts > until {
566                continue;
567            }
568        }
569        if let Some(prefix) = &query.tag_prefix {
570            if !doc.tags.iter().any(|t| t.starts_with(prefix)) {
571                continue;
572            }
573        }
574        if !wanted_terms.is_empty() {
575            let have: HashSet<&String> = doc.terms.iter().collect();
576            if !wanted_terms.iter().all(|t| have.contains(t)) {
577                continue;
578            }
579        }
580
581        if hits.len() == limit {
582            // One more qualifying doc exists → expose the cursor and stop.
583            let last = hits.last().expect("limit >= 1");
584            next = Some(PageCursor {
585                before_ts: last.timestamp_ms,
586                before_id: last.id.clone(),
587            });
588            break;
589        }
590        hits.push(IndexHit {
591            id: doc.id,
592            timestamp_ms: doc.ts,
593            tags: doc.tags,
594        });
595        stats.max_resident_hits = stats.max_resident_hits.max(hits.len());
596    }
597
598    Ok(IndexPage { hits, next, stats })
599}