Skip to main content

tsift_memory/
lib.rs

1use anyhow::{Context, Result, bail};
2use rusqlite::{Connection, OpenFlags, OptionalExtension, params, params_from_iter};
3use serde::{Deserialize, Serialize};
4use std::collections::{BTreeMap, BTreeSet};
5use std::path::{Path, PathBuf};
6use tsift_core::{GraphEdge, GraphFreshness, GraphNode, GraphProjection, GraphProvenance};
7
8pub const MEMORY_CONTRACT_VERSION: &str = "tsift-memory-v1";
9pub const MEMORY_SCHEMA_VERSION: i64 = 1;
10pub const DEFAULT_MAX_PROMPT_TOKENS: usize = 4096;
11pub const DEFAULT_RESERVE_TOKENS: usize = 512;
12pub const DEFAULT_MAX_EVENT_TOKENS: usize = 1536;
13pub const MEMORY_BUDGET_GUARD_CONTRACT_VERSION: &str = "tsift-memory-budget-guard-v1";
14
15const MAX_IMPORT_EVENT_IDS: usize = 100;
16
17const MEMORY_SCHEMA_SQL: &str = r#"
18PRAGMA foreign_keys = ON;
19
20CREATE TABLE IF NOT EXISTS memory_schema_versions (
21  version INTEGER PRIMARY KEY,
22  applied_at_unix INTEGER NOT NULL
23);
24
25INSERT OR IGNORE INTO memory_schema_versions(version, applied_at_unix)
26VALUES (1, strftime('%s','now'));
27
28CREATE TABLE IF NOT EXISTS memory_events (
29  id TEXT PRIMARY KEY,
30  kind TEXT NOT NULL,
31  session_id TEXT,
32  source_ref TEXT NOT NULL,
33  text TEXT NOT NULL,
34  metadata_json TEXT NOT NULL DEFAULT '{}',
35  observed_at_unix INTEGER,
36  token_estimate INTEGER NOT NULL,
37  imported_from TEXT,
38  imported_id TEXT,
39  created_at_unix INTEGER NOT NULL DEFAULT (strftime('%s','now'))
40);
41
42CREATE INDEX IF NOT EXISTS idx_memory_events_kind
43ON memory_events(kind);
44
45CREATE INDEX IF NOT EXISTS idx_memory_events_session
46ON memory_events(session_id);
47
48CREATE UNIQUE INDEX IF NOT EXISTS idx_memory_events_import_source
49ON memory_events(imported_from, imported_id)
50WHERE imported_from IS NOT NULL AND imported_id IS NOT NULL;
51
52CREATE TABLE IF NOT EXISTS memory_session_summaries (
53  id TEXT PRIMARY KEY,
54  session_id TEXT NOT NULL,
55  summary TEXT NOT NULL,
56  metadata_json TEXT NOT NULL DEFAULT '{}',
57  observed_at_unix INTEGER,
58  token_estimate INTEGER NOT NULL,
59  created_at_unix INTEGER NOT NULL DEFAULT (strftime('%s','now'))
60);
61
62CREATE TABLE IF NOT EXISTS memory_artifacts (
63  id TEXT PRIMARY KEY,
64  session_id TEXT,
65  source_ref TEXT NOT NULL,
66  artifact_kind TEXT NOT NULL,
67  path TEXT,
68  handle TEXT,
69  metadata_json TEXT NOT NULL DEFAULT '{}',
70  token_estimate INTEGER NOT NULL DEFAULT 0,
71  created_at_unix INTEGER NOT NULL DEFAULT (strftime('%s','now'))
72);
73
74CREATE TABLE IF NOT EXISTS memory_tool_spans (
75  id TEXT PRIMARY KEY,
76  session_id TEXT,
77  tool_name TEXT NOT NULL,
78  input_artifact_id TEXT,
79  output_artifact_id TEXT,
80  status TEXT NOT NULL,
81  metadata_json TEXT NOT NULL DEFAULT '{}',
82  started_at_unix INTEGER,
83  completed_at_unix INTEGER,
84  FOREIGN KEY(input_artifact_id) REFERENCES memory_artifacts(id),
85  FOREIGN KEY(output_artifact_id) REFERENCES memory_artifacts(id)
86);
87
88CREATE TABLE IF NOT EXISTS memory_embeddings (
89  id TEXT PRIMARY KEY,
90  owner_kind TEXT NOT NULL,
91  owner_id TEXT NOT NULL,
92  provider TEXT NOT NULL,
93  model TEXT NOT NULL,
94  vector_ref TEXT NOT NULL,
95  dimensions INTEGER,
96  metadata_json TEXT NOT NULL DEFAULT '{}',
97  created_at_unix INTEGER NOT NULL DEFAULT (strftime('%s','now'))
98);
99
100CREATE TABLE IF NOT EXISTS memory_graph_links (
101  id TEXT PRIMARY KEY,
102  memory_event_id TEXT NOT NULL,
103  graph_node_id TEXT NOT NULL,
104  graph_edge_id TEXT,
105  link_kind TEXT NOT NULL,
106  metadata_json TEXT NOT NULL DEFAULT '{}',
107  created_at_unix INTEGER NOT NULL DEFAULT (strftime('%s','now')),
108  FOREIGN KEY(memory_event_id) REFERENCES memory_events(id)
109);
110
111CREATE TABLE IF NOT EXISTS memory_import_runs (
112  id TEXT PRIMARY KEY,
113  source TEXT NOT NULL,
114  source_ref TEXT NOT NULL,
115  status TEXT NOT NULL,
116  imported_events INTEGER NOT NULL DEFAULT 0,
117  warnings_json TEXT NOT NULL DEFAULT '[]',
118  started_at_unix INTEGER NOT NULL DEFAULT (strftime('%s','now')),
119  completed_at_unix INTEGER
120);
121"#;
122
123pub fn memory_schema_sql() -> &'static str {
124    MEMORY_SCHEMA_SQL
125}
126
127pub fn default_memory_db_path(project_root: &Path) -> PathBuf {
128    project_root.join(".tsift").join("memory.db")
129}
130
131pub fn default_claude_mem_db_path() -> Option<PathBuf> {
132    std::env::var_os("HOME")
133        .map(PathBuf::from)
134        .map(|home| home.join(".claude-mem").join("claude-mem.db"))
135}
136
137pub fn estimate_tokens(text: &str) -> usize {
138    let bytes = text.trim().len();
139    if bytes == 0 { 0 } else { bytes.div_ceil(4) }
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(rename_all = "snake_case")]
144pub enum MemoryEventKind {
145    PromptTarget,
146    ToolCall,
147    ToolResultArtifact,
148    ResponseSummary,
149    CloseoutProof,
150    SessionCheck,
151    ImportedObservation,
152    ImportedSessionSummary,
153    ImportedUserPrompt,
154}
155
156impl MemoryEventKind {
157    pub fn as_str(self) -> &'static str {
158        match self {
159            Self::PromptTarget => "prompt_target",
160            Self::ToolCall => "tool_call",
161            Self::ToolResultArtifact => "tool_result_artifact",
162            Self::ResponseSummary => "response_summary",
163            Self::CloseoutProof => "closeout_proof",
164            Self::SessionCheck => "session_check",
165            Self::ImportedObservation => "imported_observation",
166            Self::ImportedSessionSummary => "imported_session_summary",
167            Self::ImportedUserPrompt => "imported_user_prompt",
168        }
169    }
170
171    pub fn parse(raw: &str) -> Result<Self> {
172        match raw {
173            "prompt_target" => Ok(Self::PromptTarget),
174            "tool_call" => Ok(Self::ToolCall),
175            "tool_result_artifact" => Ok(Self::ToolResultArtifact),
176            "response_summary" => Ok(Self::ResponseSummary),
177            "closeout_proof" => Ok(Self::CloseoutProof),
178            "session_check" => Ok(Self::SessionCheck),
179            "imported_observation" => Ok(Self::ImportedObservation),
180            "imported_session_summary" => Ok(Self::ImportedSessionSummary),
181            "imported_user_prompt" => Ok(Self::ImportedUserPrompt),
182            other => bail!("unsupported memory event kind `{other}`"),
183        }
184    }
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188pub struct MemoryEvent {
189    pub kind: MemoryEventKind,
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub session_id: Option<String>,
192    pub source_ref: String,
193    pub text: String,
194    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
195    pub metadata: BTreeMap<String, String>,
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub observed_at_unix: Option<i64>,
198    pub token_estimate: usize,
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub imported_from: Option<String>,
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub imported_id: Option<String>,
203}
204
205impl MemoryEvent {
206    pub fn new(
207        kind: MemoryEventKind,
208        source_ref: impl Into<String>,
209        text: impl Into<String>,
210    ) -> Self {
211        let text = text.into();
212        Self {
213            kind,
214            session_id: None,
215            source_ref: source_ref.into(),
216            token_estimate: estimate_tokens(&text),
217            text,
218            metadata: BTreeMap::new(),
219            observed_at_unix: None,
220            imported_from: None,
221            imported_id: None,
222        }
223    }
224
225    pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
226        self.session_id = Some(session_id.into());
227        self
228    }
229
230    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
231        self.metadata.insert(key.into(), value.into());
232        self
233    }
234
235    pub fn with_observed_at_unix(mut self, observed_at_unix: i64) -> Self {
236        self.observed_at_unix = Some(observed_at_unix);
237        self
238    }
239
240    pub fn with_import(mut self, source: impl Into<String>, id: impl Into<String>) -> Self {
241        self.imported_from = Some(source.into());
242        self.imported_id = Some(id.into());
243        self
244    }
245
246    pub fn stable_id(&self) -> String {
247        let raw = serde_json::json!([
248            self.kind.as_str(),
249            self.session_id,
250            self.source_ref,
251            self.text,
252            self.observed_at_unix,
253            self.imported_from,
254            self.imported_id
255        ])
256        .to_string();
257        format!("memevt:{}", blake3::hash(raw.as_bytes()).to_hex())
258    }
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
262pub struct MemoryBudget {
263    pub max_prompt_tokens: usize,
264    pub reserve_tokens: usize,
265    pub max_event_tokens: usize,
266}
267
268impl Default for MemoryBudget {
269    fn default() -> Self {
270        Self {
271            max_prompt_tokens: DEFAULT_MAX_PROMPT_TOKENS,
272            reserve_tokens: DEFAULT_RESERVE_TOKENS,
273            max_event_tokens: DEFAULT_MAX_EVENT_TOKENS,
274        }
275    }
276}
277
278impl MemoryBudget {
279    pub fn new(max_prompt_tokens: usize) -> Self {
280        Self {
281            max_prompt_tokens,
282            ..Self::default()
283        }
284    }
285
286    pub fn available_tokens(self) -> usize {
287        self.max_prompt_tokens.saturating_sub(self.reserve_tokens)
288    }
289}
290
291#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
292pub struct BudgetedMemoryEvent {
293    pub id: String,
294    pub kind: String,
295    pub source_ref: String,
296    pub token_estimate: usize,
297}
298
299#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
300pub struct DeferredMemoryEvent {
301    pub id: String,
302    pub kind: String,
303    pub source_ref: String,
304    pub token_estimate: usize,
305    pub reason: String,
306    pub recommended_chunks: usize,
307}
308
309#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
310pub struct MemoryHandoffPlan {
311    pub contract_version: String,
312    pub status: String,
313    pub max_prompt_tokens: usize,
314    pub reserve_tokens: usize,
315    pub available_tokens: usize,
316    pub estimated_included_tokens: usize,
317    pub included_events: Vec<BudgetedMemoryEvent>,
318    pub deferred_events: Vec<DeferredMemoryEvent>,
319    pub next_commands: Vec<String>,
320}
321
322pub fn plan_capture_handoff(events: &[MemoryEvent], budget: MemoryBudget) -> MemoryHandoffPlan {
323    let mut used = 0usize;
324    let mut included_events = Vec::new();
325    let mut deferred_events = Vec::new();
326    let available = budget.available_tokens();
327
328    for event in events {
329        let id = event.stable_id();
330        if event.token_estimate > budget.max_event_tokens {
331            deferred_events.push(DeferredMemoryEvent {
332                id,
333                kind: event.kind.as_str().to_string(),
334                source_ref: event.source_ref.clone(),
335                token_estimate: event.token_estimate,
336                reason: "event_exceeds_max_event_tokens".to_string(),
337                recommended_chunks: event
338                    .token_estimate
339                    .div_ceil(budget.max_event_tokens.max(1)),
340            });
341            continue;
342        }
343
344        if used + event.token_estimate <= available {
345            used += event.token_estimate;
346            included_events.push(BudgetedMemoryEvent {
347                id,
348                kind: event.kind.as_str().to_string(),
349                source_ref: event.source_ref.clone(),
350                token_estimate: event.token_estimate,
351            });
352        } else {
353            deferred_events.push(DeferredMemoryEvent {
354                id,
355                kind: event.kind.as_str().to_string(),
356                source_ref: event.source_ref.clone(),
357                token_estimate: event.token_estimate,
358                reason: "handoff_budget_exhausted".to_string(),
359                recommended_chunks: 1,
360            });
361        }
362    }
363
364    MemoryHandoffPlan {
365        contract_version: MEMORY_CONTRACT_VERSION.to_string(),
366        status: if deferred_events.is_empty() {
367            "ready".to_string()
368        } else {
369            "split_required".to_string()
370        },
371        max_prompt_tokens: budget.max_prompt_tokens,
372        reserve_tokens: budget.reserve_tokens,
373        available_tokens: available,
374        estimated_included_tokens: used,
375        included_events,
376        deferred_events,
377        next_commands: vec![
378            "tsift memory handoff-plan '<event text>' --budget-tokens <n> --json".to_string(),
379            "tsift --envelope context-pack <session.md> --budget normal".to_string(),
380        ],
381    }
382}
383
384#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
385pub struct MemoryBudgetGuardInput {
386    pub source_ref: String,
387    pub payload_kind: String,
388    pub text: String,
389}
390
391impl MemoryBudgetGuardInput {
392    pub fn new(
393        source_ref: impl Into<String>,
394        payload_kind: impl Into<String>,
395        text: impl Into<String>,
396    ) -> Self {
397        Self {
398            source_ref: source_ref.into(),
399            payload_kind: payload_kind.into(),
400            text: text.into(),
401        }
402    }
403}
404
405#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
406pub struct MemoryBudgetReplacement {
407    pub strategy: String,
408    pub artifact_ref: String,
409    pub digest_command: String,
410    pub context_command: String,
411    pub session_review_command: String,
412}
413
414#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
415pub struct MemoryRetryChunk {
416    pub index: usize,
417    pub source_ref: String,
418    pub byte_start: usize,
419    pub byte_end: usize,
420    pub token_estimate: usize,
421    pub retry_command: String,
422}
423
424#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
425pub struct MemoryBudgetGuardReport {
426    pub contract_version: String,
427    pub status: String,
428    pub allowed: bool,
429    pub source_ref: String,
430    pub payload_kind: String,
431    pub byte_count: usize,
432    pub estimated_tokens: usize,
433    pub max_prompt_tokens: usize,
434    pub reserve_tokens: usize,
435    pub available_tokens: usize,
436    pub max_chunk_tokens: usize,
437    #[serde(skip_serializing_if = "Option::is_none")]
438    pub replacement: Option<MemoryBudgetReplacement>,
439    pub retryable_chunk_plan: Vec<MemoryRetryChunk>,
440    pub warnings: Vec<String>,
441    pub next_commands: Vec<String>,
442}
443
444pub fn guard_memory_handoff(
445    input: MemoryBudgetGuardInput,
446    budget: MemoryBudget,
447) -> MemoryBudgetGuardReport {
448    let estimated_tokens = estimate_tokens(&input.text);
449    let available_tokens = budget.available_tokens();
450    let allowed =
451        estimated_tokens <= available_tokens && estimated_tokens <= budget.max_event_tokens;
452    let replacement = (!allowed).then(|| replacement_for_budget_guard(&input));
453    let retryable_chunk_plan = if allowed {
454        Vec::new()
455    } else {
456        retry_chunks_for_budget_guard(&input, budget.max_event_tokens.min(available_tokens).max(1))
457    };
458    let mut warnings = Vec::new();
459    if estimated_tokens > available_tokens {
460        warnings.push("payload_exceeds_available_prompt_budget".to_string());
461    }
462    if estimated_tokens > budget.max_event_tokens {
463        warnings.push("payload_exceeds_max_chunk_tokens".to_string());
464    }
465
466    MemoryBudgetGuardReport {
467        contract_version: MEMORY_BUDGET_GUARD_CONTRACT_VERSION.to_string(),
468        status: if allowed {
469            "ready".to_string()
470        } else {
471            "blocked_split_required".to_string()
472        },
473        allowed,
474        source_ref: input.source_ref.clone(),
475        payload_kind: input.payload_kind.clone(),
476        byte_count: input.text.len(),
477        estimated_tokens,
478        max_prompt_tokens: budget.max_prompt_tokens,
479        reserve_tokens: budget.reserve_tokens,
480        available_tokens,
481        max_chunk_tokens: budget.max_event_tokens,
482        replacement,
483        retryable_chunk_plan,
484        warnings,
485        next_commands: vec![
486            format!(
487                "tsift memory budget-guard --file {} --json",
488                shell_quote(&input.source_ref)
489            ),
490            "tsift --envelope context-pack <session.md> --budget normal".to_string(),
491            "tsift --envelope session-review <session.md> --next-context --budget normal"
492                .to_string(),
493        ],
494    }
495}
496
497fn replacement_for_budget_guard(input: &MemoryBudgetGuardInput) -> MemoryBudgetReplacement {
498    let quoted_ref = shell_quote(&input.source_ref);
499    let is_transcript = matches!(
500        input.payload_kind.as_str(),
501        "transcript" | "session" | "session_transcript" | "agent_doc"
502    ) || input.source_ref.ends_with(".jsonl")
503        || input.source_ref.ends_with(".md");
504    let strategy = if is_transcript {
505        "replace_raw_transcript_with_session_review_or_context_pack_handle"
506    } else {
507        "replace_raw_tool_or_log_payload_with_digest_artifact_handle"
508    };
509    MemoryBudgetReplacement {
510        strategy: strategy.to_string(),
511        artifact_ref: format!(
512            "artifact:{}",
513            blake3::hash(input.source_ref.as_bytes()).to_hex()
514        ),
515        digest_command: if is_transcript {
516            format!("tsift --envelope session-review {quoted_ref} --next-context --budget normal")
517        } else {
518            format!("tsift log-digest --path . --input {quoted_ref} --json")
519        },
520        context_command: format!("tsift --envelope context-pack {quoted_ref} --budget normal"),
521        session_review_command: format!(
522            "tsift --envelope session-review {quoted_ref} --next-context --budget normal"
523        ),
524    }
525}
526
527fn retry_chunks_for_budget_guard(
528    input: &MemoryBudgetGuardInput,
529    max_chunk_tokens: usize,
530) -> Vec<MemoryRetryChunk> {
531    let byte_budget = max_chunk_tokens.saturating_mul(4).max(1);
532    let mut chunks = Vec::new();
533    let mut start = 0usize;
534    while start < input.text.len() {
535        let mut end = (start + byte_budget).min(input.text.len());
536        while end > start && !input.text.is_char_boundary(end) {
537            end -= 1;
538        }
539        if end == start {
540            if let Some((offset, ch)) = input.text[start..].char_indices().next() {
541                end = start + offset + ch.len_utf8();
542            } else {
543                break;
544            }
545        }
546        let token_estimate = estimate_tokens(&input.text[start..end]);
547        let index = chunks.len() + 1;
548        chunks.push(MemoryRetryChunk {
549            index,
550            source_ref: format!("{}#chunk-{index}", input.source_ref),
551            byte_start: start,
552            byte_end: end,
553            token_estimate,
554            retry_command: retry_chunk_command(input, index, start, end),
555        });
556        start = end;
557    }
558    chunks
559}
560
561fn retry_chunk_command(
562    input: &MemoryBudgetGuardInput,
563    index: usize,
564    byte_start: usize,
565    byte_end: usize,
566) -> String {
567    let chunk_ref = format!("{}#chunk-{index}", input.source_ref);
568    if input.source_ref == "inline" {
569        format!(
570            "tsift memory budget-guard --text '<chunk {index} payload>' --source-ref {} --budget-tokens <n> --json",
571            shell_quote(&chunk_ref)
572        )
573    } else {
574        format!(
575            "tsift memory budget-guard --file {} --byte-start {byte_start} --byte-end {byte_end} --source-ref {} --budget-tokens <n> --json",
576            shell_quote(&input.source_ref),
577            shell_quote(&chunk_ref)
578        )
579    }
580}
581
582fn shell_quote(s: &str) -> String {
583    if s.chars()
584        .all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | '/'))
585    {
586        s.to_string()
587    } else {
588        format!("'{}'", s.replace('\'', "'\\''"))
589    }
590}
591
592#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
593pub struct MemoryHookSpec {
594    pub name: String,
595    pub event_kind: String,
596    pub required_fields: Vec<String>,
597    pub budget_behavior: String,
598}
599
600pub fn agent_doc_hook_contract() -> Vec<MemoryHookSpec> {
601    vec![
602        MemoryHookSpec {
603            name: "prompt_target".to_string(),
604            event_kind: MemoryEventKind::PromptTarget.as_str().to_string(),
605            required_fields: vec!["session_path".to_string(), "prompt_target".to_string()],
606            budget_behavior:
607                "capture text plus graph/source handles; never inline raw transcript blobs"
608                    .to_string(),
609        },
610        MemoryHookSpec {
611            name: "tool_result_artifact".to_string(),
612            event_kind: MemoryEventKind::ToolResultArtifact.as_str().to_string(),
613            required_fields: vec!["tool_name".to_string(), "artifact_handle".to_string()],
614            budget_behavior: "store artifact handle and digest, not full raw output".to_string(),
615        },
616        MemoryHookSpec {
617            name: "response_summary".to_string(),
618            event_kind: MemoryEventKind::ResponseSummary.as_str().to_string(),
619            required_fields: vec!["response_topic".to_string(), "summary".to_string()],
620            budget_behavior: "summaries are capped before write and linked to source handles"
621                .to_string(),
622        },
623        MemoryHookSpec {
624            name: "closeout_proof".to_string(),
625            event_kind: MemoryEventKind::CloseoutProof.as_str().to_string(),
626            required_fields: vec!["commit_hash".to_string(), "changed_paths".to_string()],
627            budget_behavior: "proof records are structured metadata with compact prose".to_string(),
628        },
629        MemoryHookSpec {
630            name: "session_check".to_string(),
631            event_kind: MemoryEventKind::SessionCheck.as_str().to_string(),
632            required_fields: vec!["status".to_string(), "session_path".to_string()],
633            budget_behavior: "persist pass/fail state and recovery commands".to_string(),
634        },
635    ]
636}
637
638pub fn agent_doc_closeout_events(
639    session_path: &Path,
640    prompt_target: &str,
641    response_summary: &str,
642    commit_hash: Option<&str>,
643    session_check_status: &str,
644) -> Vec<MemoryEvent> {
645    let session_id = session_path.display().to_string();
646    let mut events = vec![
647        MemoryEvent::new(
648            MemoryEventKind::PromptTarget,
649            session_path.display().to_string(),
650            prompt_target,
651        )
652        .with_session_id(session_id.clone()),
653        MemoryEvent::new(
654            MemoryEventKind::ResponseSummary,
655            session_path.display().to_string(),
656            response_summary,
657        )
658        .with_session_id(session_id.clone()),
659        MemoryEvent::new(
660            MemoryEventKind::SessionCheck,
661            session_path.display().to_string(),
662            session_check_status,
663        )
664        .with_session_id(session_id.clone())
665        .with_metadata("status", session_check_status),
666    ];
667
668    if let Some(commit_hash) = commit_hash {
669        events.push(
670            MemoryEvent::new(
671                MemoryEventKind::CloseoutProof,
672                session_path.display().to_string(),
673                commit_hash,
674            )
675            .with_session_id(session_id)
676            .with_metadata("commit_hash", commit_hash),
677        );
678    }
679
680    events
681}
682
683#[derive(Debug, Clone, PartialEq, Eq)]
684pub struct MemoryInsertResult {
685    pub id: String,
686    pub inserted: bool,
687}
688
689pub struct MemoryStore {
690    conn: Connection,
691}
692
693impl MemoryStore {
694    pub fn open_or_create(path: &Path) -> Result<Self> {
695        if let Some(parent) = path.parent() {
696            std::fs::create_dir_all(parent)
697                .with_context(|| format!("create {}", parent.display()))?;
698        }
699        let conn = Connection::open(path).with_context(|| format!("open {}", path.display()))?;
700        conn.execute_batch(MEMORY_SCHEMA_SQL)
701            .with_context(|| format!("initialize {}", path.display()))?;
702        Ok(Self { conn })
703    }
704
705    pub fn insert_event(&self, event: &MemoryEvent) -> Result<String> {
706        Ok(self.insert_event_result(event)?.id)
707    }
708
709    pub fn insert_event_result(&self, event: &MemoryEvent) -> Result<MemoryInsertResult> {
710        insert_event_on(&self.conn, event)
711    }
712
713    pub fn insert_events(&mut self, events: &[MemoryEvent]) -> Result<Vec<MemoryInsertResult>> {
714        let tx = self.conn.transaction()?;
715        let mut results = Vec::with_capacity(events.len());
716        for event in events {
717            results.push(insert_event_on(&tx, event)?);
718        }
719        tx.commit()?;
720        Ok(results)
721    }
722
723    pub fn event_count(&self) -> Result<usize> {
724        let count: i64 = self
725            .conn
726            .query_row("SELECT COUNT(*) FROM memory_events", [], |row| row.get(0))?;
727        Ok(count as usize)
728    }
729}
730
731fn insert_event_on(conn: &Connection, event: &MemoryEvent) -> Result<MemoryInsertResult> {
732    let id = event.stable_id();
733    let metadata_json = serde_json::to_string(&event.metadata)?;
734    let changed = conn.execute(
735        r#"
736            INSERT OR IGNORE INTO memory_events(
737              id, kind, session_id, source_ref, text, metadata_json,
738              observed_at_unix, token_estimate, imported_from, imported_id
739            )
740            VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
741            "#,
742        params![
743            &id,
744            event.kind.as_str(),
745            event.session_id.as_deref(),
746            &event.source_ref,
747            &event.text,
748            &metadata_json,
749            event.observed_at_unix,
750            event.token_estimate as i64,
751            event.imported_from.as_deref(),
752            event.imported_id.as_deref()
753        ],
754    )?;
755    Ok(MemoryInsertResult {
756        id,
757        inserted: changed > 0,
758    })
759}
760
761pub fn read_memory_events(memory_db_path: &Path, limit: usize) -> Result<Vec<MemoryEvent>> {
762    if !memory_db_path.exists() {
763        return Ok(Vec::new());
764    }
765    let conn = Connection::open_with_flags(
766        memory_db_path,
767        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
768    )
769    .with_context(|| format!("open memory db {}", memory_db_path.display()))?;
770    let mut stmt = conn.prepare(
771        r#"
772        SELECT kind, session_id, source_ref, text, metadata_json,
773               observed_at_unix, token_estimate, imported_from, imported_id
774        FROM memory_events
775        ORDER BY COALESCE(observed_at_unix, created_at_unix), id
776        LIMIT ?1
777        "#,
778    )?;
779    let mut rows = stmt.query([limit as i64])?;
780    let mut events = Vec::new();
781    while let Some(row) = rows.next()? {
782        let kind_raw: String = row.get(0)?;
783        let session_id: Option<String> = row.get(1)?;
784        let source_ref: String = row.get(2)?;
785        let text: String = row.get(3)?;
786        let metadata_json: String = row.get(4)?;
787        let observed_at_unix: Option<i64> = row.get(5)?;
788        let token_estimate: i64 = row.get(6)?;
789        let imported_from: Option<String> = row.get(7)?;
790        let imported_id: Option<String> = row.get(8)?;
791        let metadata = serde_json::from_str::<BTreeMap<String, String>>(&metadata_json)
792            .with_context(|| format!("parse memory metadata for {source_ref}"))?;
793        let mut event = MemoryEvent::new(MemoryEventKind::parse(&kind_raw)?, source_ref, text);
794        event.session_id = session_id;
795        event.metadata = metadata;
796        event.observed_at_unix = observed_at_unix;
797        event.token_estimate = token_estimate.max(0) as usize;
798        event.imported_from = imported_from;
799        event.imported_id = imported_id;
800        events.push(event);
801    }
802    Ok(events)
803}
804
805#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
806pub struct ClaudeMemTablePlan {
807    pub table: String,
808    pub supported: bool,
809    pub rows: usize,
810    pub columns: Vec<String>,
811    #[serde(skip_serializing_if = "Option::is_none")]
812    pub unsupported_reason: Option<String>,
813}
814
815#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
816pub struct ClaudeMemImportPlan {
817    pub db_path: String,
818    pub exists: bool,
819    pub readable: bool,
820    #[serde(skip_serializing_if = "Option::is_none")]
821    pub chroma_path: Option<String>,
822    pub chroma_present: bool,
823    pub observations: ClaudeMemTablePlan,
824    pub session_summaries: ClaudeMemTablePlan,
825    pub user_prompts: ClaudeMemTablePlan,
826    pub pending_messages: ClaudeMemTablePlan,
827    pub warnings: Vec<String>,
828    pub next_commands: Vec<String>,
829}
830
831#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
832pub struct ClaudeMemTableReconciliation {
833    pub table: String,
834    pub source_rows: usize,
835    pub read_events: usize,
836    pub planned_events: usize,
837    pub imported_events: usize,
838    pub already_present_events: usize,
839    pub skipped_source_rows: usize,
840    #[serde(skip_serializing_if = "Option::is_none")]
841    pub limit_per_table: Option<usize>,
842    pub complete: bool,
843}
844
845#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
846pub struct ClaudeMemImportReconciliation {
847    pub observations: ClaudeMemTableReconciliation,
848    pub session_summaries: ClaudeMemTableReconciliation,
849    pub user_prompts: ClaudeMemTableReconciliation,
850    pub total_source_rows: usize,
851    pub total_read_events: usize,
852    pub total_planned_events: usize,
853    pub total_imported_events: usize,
854    pub total_already_present_events: usize,
855    pub total_skipped_source_rows: usize,
856    pub complete: bool,
857}
858
859fn empty_table_plan(table: &str) -> ClaudeMemTablePlan {
860    ClaudeMemTablePlan {
861        table: table.to_string(),
862        supported: false,
863        rows: 0,
864        columns: Vec::new(),
865        unsupported_reason: None,
866    }
867}
868
869const PENDING_MESSAGES_EXCLUSION_REASON: &str = "pending_messages is intentionally excluded from claude-mem import because it contains transient queue state and raw tool payload fields, not durable replacement memory";
870
871pub fn inspect_claude_mem(db_path: &Path) -> Result<ClaudeMemImportPlan> {
872    let chroma_path = db_path.parent().map(|parent| parent.join("chroma"));
873    let chroma_present = chroma_path.as_ref().is_some_and(|path| path.exists());
874    let mut plan = ClaudeMemImportPlan {
875        db_path: db_path.display().to_string(),
876        exists: db_path.exists(),
877        readable: false,
878        chroma_path: chroma_path.as_ref().map(|path| path.display().to_string()),
879        chroma_present,
880        observations: empty_table_plan("observations"),
881        session_summaries: empty_table_plan("session_summaries"),
882        user_prompts: empty_table_plan("user_prompts"),
883        pending_messages: empty_table_plan("pending_messages"),
884        warnings: Vec::new(),
885        next_commands: vec![
886            "tsift memory import-claude-mem . --all --apply --json".to_string(),
887            "tsift graph-db --path . --json refresh".to_string(),
888        ],
889    };
890
891    if !plan.exists {
892        plan.warnings
893            .push("claude-mem database not found; pass --db to inspect another path".to_string());
894        return Ok(plan);
895    }
896
897    let conn = Connection::open_with_flags(
898        db_path,
899        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
900    )
901    .with_context(|| format!("open claude-mem db {}", db_path.display()))?;
902    plan.readable = true;
903    plan.observations = inspect_table(&conn, "observations", true, None)?;
904    plan.session_summaries = inspect_table(&conn, "session_summaries", true, None)?;
905    plan.user_prompts = inspect_table(&conn, "user_prompts", true, None)?;
906    plan.pending_messages = inspect_table(
907        &conn,
908        "pending_messages",
909        false,
910        Some(PENDING_MESSAGES_EXCLUSION_REASON),
911    )?;
912    if plan.pending_messages.rows > 0 {
913        plan.warnings
914            .push(PENDING_MESSAGES_EXCLUSION_REASON.to_string());
915    }
916
917    if !plan.chroma_present {
918        plan.warnings
919            .push("chroma directory was not found next to the claude-mem SQLite DB".to_string());
920    }
921
922    Ok(plan)
923}
924
925fn inspect_table(
926    conn: &Connection,
927    table: &str,
928    supported: bool,
929    unsupported_reason: Option<&str>,
930) -> Result<ClaudeMemTablePlan> {
931    if !table_exists(conn, table)? {
932        return Ok(empty_table_plan(table));
933    }
934    let rows: i64 = conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| {
935        row.get(0)
936    })?;
937    let columns = table_columns(conn, table)?;
938    Ok(ClaudeMemTablePlan {
939        table: table.to_string(),
940        supported,
941        rows: rows as usize,
942        columns,
943        unsupported_reason: unsupported_reason.map(str::to_string),
944    })
945}
946
947fn table_exists(conn: &Connection, table: &str) -> Result<bool> {
948    let exists: Option<i64> = conn
949        .query_row(
950            "SELECT 1 FROM sqlite_master WHERE type IN ('table','view') AND name = ?1",
951            [table],
952            |row| row.get(0),
953        )
954        .optional()?;
955    Ok(exists.is_some())
956}
957
958fn table_columns(conn: &Connection, table: &str) -> Result<Vec<String>> {
959    let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
960    let mut rows = stmt.query([])?;
961    let mut columns = Vec::new();
962    while let Some(row) = rows.next()? {
963        columns.push(row.get::<_, String>(1)?);
964    }
965    Ok(columns)
966}
967
968#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
969pub struct ClaudeMemImportReport {
970    pub contract_version: String,
971    pub source: String,
972    pub target: String,
973    pub dry_run: bool,
974    #[serde(skip_serializing_if = "Option::is_none")]
975    pub limit_per_table: Option<usize>,
976    pub import_all: bool,
977    pub imported_events: usize,
978    pub already_present_events: usize,
979    pub planned_events: usize,
980    pub event_ids: Vec<String>,
981    pub event_ids_total: usize,
982    pub event_ids_truncated: bool,
983    pub reconciliation: ClaudeMemImportReconciliation,
984    pub plan: ClaudeMemImportPlan,
985}
986
987#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
988pub struct ClaudeMemReadReport {
989    pub contract_version: String,
990    pub source: String,
991    #[serde(skip_serializing_if = "Option::is_none")]
992    pub limit_per_table: Option<usize>,
993    pub import_all: bool,
994    pub events: Vec<MemoryEvent>,
995    pub reconciliation: ClaudeMemImportReconciliation,
996    pub plan: ClaudeMemImportPlan,
997}
998
999pub fn read_claude_mem_events(
1000    source_db_path: &Path,
1001    limit_per_table: Option<usize>,
1002) -> Result<ClaudeMemReadReport> {
1003    let plan = inspect_claude_mem(source_db_path)?;
1004    if !plan.exists || !plan.readable {
1005        let reconciliation =
1006            reconcile_claude_mem_import(&plan, limit_per_table, &[], &BTreeMap::new());
1007        return Ok(ClaudeMemReadReport {
1008            contract_version: MEMORY_CONTRACT_VERSION.to_string(),
1009            source: source_db_path.display().to_string(),
1010            limit_per_table,
1011            import_all: limit_per_table.is_none(),
1012            events: Vec::new(),
1013            reconciliation,
1014            plan,
1015        });
1016    }
1017
1018    let conn = Connection::open_with_flags(
1019        source_db_path,
1020        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
1021    )?;
1022    let mut events = Vec::new();
1023    if plan.observations.supported {
1024        events.extend(read_claude_mem_observations(&conn, limit_per_table)?);
1025    }
1026    if plan.session_summaries.supported {
1027        events.extend(read_claude_mem_summaries(&conn, limit_per_table)?);
1028    }
1029    if plan.user_prompts.supported {
1030        events.extend(read_claude_mem_user_prompts(&conn, limit_per_table)?);
1031    }
1032    let reconciliation =
1033        reconcile_claude_mem_import(&plan, limit_per_table, &events, &BTreeMap::new());
1034
1035    Ok(ClaudeMemReadReport {
1036        contract_version: MEMORY_CONTRACT_VERSION.to_string(),
1037        source: source_db_path.display().to_string(),
1038        limit_per_table,
1039        import_all: limit_per_table.is_none(),
1040        events,
1041        reconciliation,
1042        plan,
1043    })
1044}
1045
1046pub fn import_claude_mem(
1047    source_db_path: &Path,
1048    target_memory_db_path: &Path,
1049    limit_per_table: Option<usize>,
1050    dry_run: bool,
1051) -> Result<ClaudeMemImportReport> {
1052    let read_report = read_claude_mem_events(source_db_path, limit_per_table)?;
1053    let plan = read_report.plan;
1054    if !plan.exists || !plan.readable {
1055        let reconciliation =
1056            reconcile_claude_mem_import(&plan, limit_per_table, &[], &BTreeMap::new());
1057        return Ok(ClaudeMemImportReport {
1058            contract_version: MEMORY_CONTRACT_VERSION.to_string(),
1059            source: source_db_path.display().to_string(),
1060            target: target_memory_db_path.display().to_string(),
1061            dry_run,
1062            limit_per_table,
1063            import_all: limit_per_table.is_none(),
1064            imported_events: 0,
1065            already_present_events: 0,
1066            planned_events: 0,
1067            event_ids: Vec::new(),
1068            event_ids_total: 0,
1069            event_ids_truncated: false,
1070            reconciliation,
1071            plan,
1072        });
1073    }
1074
1075    let events = read_report.events;
1076    let planned_events = events.len();
1077    let mut event_ids = Vec::new();
1078    let mut event_ids_total = 0;
1079    let mut write_results = BTreeMap::new();
1080    if !dry_run {
1081        let mut store = MemoryStore::open_or_create(target_memory_db_path)?;
1082        let results = store.insert_events(&events)?;
1083        for (event, result) in events.iter().zip(results) {
1084            record_claude_mem_write(&mut write_results, event, result.inserted);
1085            event_ids_total += 1;
1086            if event_ids.len() < MAX_IMPORT_EVENT_IDS {
1087                event_ids.push(result.id);
1088            }
1089        }
1090    }
1091    let reconciliation =
1092        reconcile_claude_mem_import(&plan, limit_per_table, &events, &write_results);
1093
1094    Ok(ClaudeMemImportReport {
1095        contract_version: MEMORY_CONTRACT_VERSION.to_string(),
1096        source: source_db_path.display().to_string(),
1097        target: target_memory_db_path.display().to_string(),
1098        dry_run,
1099        limit_per_table,
1100        import_all: limit_per_table.is_none(),
1101        imported_events: reconciliation.total_imported_events,
1102        already_present_events: reconciliation.total_already_present_events,
1103        planned_events,
1104        event_ids,
1105        event_ids_total,
1106        event_ids_truncated: event_ids_total > MAX_IMPORT_EVENT_IDS,
1107        reconciliation,
1108        plan,
1109    })
1110}
1111
1112fn read_claude_mem_observations(
1113    conn: &Connection,
1114    limit: Option<usize>,
1115) -> Result<Vec<MemoryEvent>> {
1116    let sql = format!(
1117        r#"
1118        SELECT id, memory_session_id, project, type, title, subtitle, text, facts,
1119               narrative, concepts, prompt_number, discovery_tokens, created_at_epoch,
1120               content_hash
1121        FROM observations
1122        ORDER BY created_at_epoch ASC, id ASC{}
1123        "#,
1124        claude_mem_limit_clause(limit)
1125    );
1126    let mut stmt = conn.prepare(&sql)?;
1127    let rows = stmt.query_map(params_from_iter(limit.map(|value| value as i64)), |row| {
1128        let id: i64 = row.get(0)?;
1129        let session_id: String = row.get(1)?;
1130        let project: String = row.get(2)?;
1131        let observation_type: String = row.get(3)?;
1132        let title: Option<String> = row.get(4)?;
1133        let subtitle: Option<String> = row.get(5)?;
1134        let text: Option<String> = row.get(6)?;
1135        let facts: Option<String> = row.get(7)?;
1136        let narrative: Option<String> = row.get(8)?;
1137        let concepts: Option<String> = row.get(9)?;
1138        let prompt_number: Option<i64> = row.get(10)?;
1139        let discovery_tokens: i64 = row.get(11)?;
1140        let created_at_epoch: i64 = row.get(12)?;
1141        let content_hash: Option<String> = row.get(13)?;
1142        let body = join_non_empty([title, subtitle, text, facts, narrative, concepts]);
1143        let mut event = MemoryEvent::new(
1144            MemoryEventKind::ImportedObservation,
1145            format!("claude-mem:observations:{id}"),
1146            body,
1147        )
1148        .with_session_id(session_id)
1149        .with_observed_at_unix(created_at_epoch)
1150        .with_import("claude-mem", format!("observations:{id}"))
1151        .with_metadata("project", project)
1152        .with_metadata("observation_type", observation_type)
1153        .with_metadata("discovery_tokens", discovery_tokens.to_string());
1154        if let Some(prompt_number) = prompt_number {
1155            event = event.with_metadata("prompt_number", prompt_number.to_string());
1156        }
1157        if let Some(content_hash) = content_hash {
1158            event = event.with_metadata("content_hash", content_hash);
1159        }
1160        Ok(event)
1161    })?;
1162    collect_rows(rows)
1163}
1164
1165fn read_claude_mem_summaries(conn: &Connection, limit: Option<usize>) -> Result<Vec<MemoryEvent>> {
1166    let sql = format!(
1167        r#"
1168        SELECT id, memory_session_id, project, request, investigated, learned,
1169               completed, next_steps, notes, prompt_number, discovery_tokens,
1170               created_at_epoch
1171        FROM session_summaries
1172        ORDER BY created_at_epoch ASC, id ASC{}
1173        "#,
1174        claude_mem_limit_clause(limit)
1175    );
1176    let mut stmt = conn.prepare(&sql)?;
1177    let rows = stmt.query_map(params_from_iter(limit.map(|value| value as i64)), |row| {
1178        let id: i64 = row.get(0)?;
1179        let session_id: String = row.get(1)?;
1180        let project: String = row.get(2)?;
1181        let request: Option<String> = row.get(3)?;
1182        let investigated: Option<String> = row.get(4)?;
1183        let learned: Option<String> = row.get(5)?;
1184        let completed: Option<String> = row.get(6)?;
1185        let next_steps: Option<String> = row.get(7)?;
1186        let notes: Option<String> = row.get(8)?;
1187        let prompt_number: Option<i64> = row.get(9)?;
1188        let discovery_tokens: i64 = row.get(10)?;
1189        let created_at_epoch: i64 = row.get(11)?;
1190        let body = join_non_empty([request, investigated, learned, completed, next_steps, notes]);
1191        let mut event = MemoryEvent::new(
1192            MemoryEventKind::ImportedSessionSummary,
1193            format!("claude-mem:session_summaries:{id}"),
1194            body,
1195        )
1196        .with_session_id(session_id)
1197        .with_observed_at_unix(created_at_epoch)
1198        .with_import("claude-mem", format!("session_summaries:{id}"))
1199        .with_metadata("project", project)
1200        .with_metadata("discovery_tokens", discovery_tokens.to_string());
1201        if let Some(prompt_number) = prompt_number {
1202            event = event.with_metadata("prompt_number", prompt_number.to_string());
1203        }
1204        Ok(event)
1205    })?;
1206    collect_rows(rows)
1207}
1208
1209fn read_claude_mem_user_prompts(
1210    conn: &Connection,
1211    limit: Option<usize>,
1212) -> Result<Vec<MemoryEvent>> {
1213    let sql = format!(
1214        r#"
1215        SELECT id, content_session_id, prompt_number, prompt_text, created_at_epoch
1216        FROM user_prompts
1217        ORDER BY created_at_epoch ASC, id ASC{}
1218        "#,
1219        claude_mem_limit_clause(limit)
1220    );
1221    let mut stmt = conn.prepare(&sql)?;
1222    let rows = stmt.query_map(params_from_iter(limit.map(|value| value as i64)), |row| {
1223        let id: i64 = row.get(0)?;
1224        let session_id: String = row.get(1)?;
1225        let prompt_number: i64 = row.get(2)?;
1226        let prompt_text: String = row.get(3)?;
1227        let created_at_epoch: i64 = row.get(4)?;
1228        Ok(MemoryEvent::new(
1229            MemoryEventKind::ImportedUserPrompt,
1230            format!("claude-mem:user_prompts:{id}"),
1231            prompt_text,
1232        )
1233        .with_session_id(session_id)
1234        .with_observed_at_unix(created_at_epoch)
1235        .with_import("claude-mem", format!("user_prompts:{id}"))
1236        .with_metadata("prompt_number", prompt_number.to_string()))
1237    })?;
1238    collect_rows(rows)
1239}
1240
1241fn claude_mem_limit_clause(limit: Option<usize>) -> &'static str {
1242    if limit.is_some() {
1243        "\n        LIMIT ?1"
1244    } else {
1245        ""
1246    }
1247}
1248
1249fn record_claude_mem_write(
1250    write_results: &mut BTreeMap<String, ClaudeMemTableWriteCounts>,
1251    event: &MemoryEvent,
1252    inserted: bool,
1253) {
1254    let Some(table) = claude_mem_event_table(event) else {
1255        return;
1256    };
1257    let counts = write_results.entry(table.to_string()).or_default();
1258    if inserted {
1259        counts.imported_events += 1;
1260    } else {
1261        counts.already_present_events += 1;
1262    }
1263}
1264
1265#[derive(Debug, Clone, Copy, Default)]
1266struct ClaudeMemTableWriteCounts {
1267    imported_events: usize,
1268    already_present_events: usize,
1269}
1270
1271fn reconcile_claude_mem_import(
1272    plan: &ClaudeMemImportPlan,
1273    limit_per_table: Option<usize>,
1274    events: &[MemoryEvent],
1275    write_results: &BTreeMap<String, ClaudeMemTableWriteCounts>,
1276) -> ClaudeMemImportReconciliation {
1277    let event_counts = claude_mem_event_counts(events);
1278    let observations = reconcile_claude_mem_table(
1279        &plan.observations,
1280        limit_per_table,
1281        event_counts
1282            .get("observations")
1283            .copied()
1284            .unwrap_or_default(),
1285        write_results
1286            .get("observations")
1287            .copied()
1288            .unwrap_or_default(),
1289    );
1290    let session_summaries = reconcile_claude_mem_table(
1291        &plan.session_summaries,
1292        limit_per_table,
1293        event_counts
1294            .get("session_summaries")
1295            .copied()
1296            .unwrap_or_default(),
1297        write_results
1298            .get("session_summaries")
1299            .copied()
1300            .unwrap_or_default(),
1301    );
1302    let user_prompts = reconcile_claude_mem_table(
1303        &plan.user_prompts,
1304        limit_per_table,
1305        event_counts
1306            .get("user_prompts")
1307            .copied()
1308            .unwrap_or_default(),
1309        write_results
1310            .get("user_prompts")
1311            .copied()
1312            .unwrap_or_default(),
1313    );
1314    let total_source_rows =
1315        observations.source_rows + session_summaries.source_rows + user_prompts.source_rows;
1316    let total_read_events =
1317        observations.read_events + session_summaries.read_events + user_prompts.read_events;
1318    let total_planned_events = observations.planned_events
1319        + session_summaries.planned_events
1320        + user_prompts.planned_events;
1321    let total_imported_events = observations.imported_events
1322        + session_summaries.imported_events
1323        + user_prompts.imported_events;
1324    let total_already_present_events = observations.already_present_events
1325        + session_summaries.already_present_events
1326        + user_prompts.already_present_events;
1327    let total_skipped_source_rows = observations.skipped_source_rows
1328        + session_summaries.skipped_source_rows
1329        + user_prompts.skipped_source_rows;
1330    let complete = observations.complete && session_summaries.complete && user_prompts.complete;
1331    ClaudeMemImportReconciliation {
1332        observations,
1333        session_summaries,
1334        user_prompts,
1335        total_source_rows,
1336        total_read_events,
1337        total_planned_events,
1338        total_imported_events,
1339        total_already_present_events,
1340        total_skipped_source_rows,
1341        complete,
1342    }
1343}
1344
1345fn reconcile_claude_mem_table(
1346    table_plan: &ClaudeMemTablePlan,
1347    limit_per_table: Option<usize>,
1348    read_events: usize,
1349    write_counts: ClaudeMemTableWriteCounts,
1350) -> ClaudeMemTableReconciliation {
1351    let source_rows = table_plan.rows;
1352    let skipped_source_rows = source_rows.saturating_sub(read_events);
1353    ClaudeMemTableReconciliation {
1354        table: table_plan.table.clone(),
1355        source_rows,
1356        read_events,
1357        planned_events: read_events,
1358        imported_events: write_counts.imported_events,
1359        already_present_events: write_counts.already_present_events,
1360        skipped_source_rows,
1361        limit_per_table,
1362        complete: skipped_source_rows == 0,
1363    }
1364}
1365
1366fn claude_mem_event_counts(events: &[MemoryEvent]) -> BTreeMap<String, usize> {
1367    let mut counts = BTreeMap::new();
1368    for event in events {
1369        if let Some(table) = claude_mem_event_table(event) {
1370            *counts.entry(table.to_string()).or_insert(0) += 1;
1371        }
1372    }
1373    counts
1374}
1375
1376fn claude_mem_event_table(event: &MemoryEvent) -> Option<&str> {
1377    if event.imported_from.as_deref() != Some("claude-mem") {
1378        return None;
1379    }
1380    event
1381        .imported_id
1382        .as_deref()?
1383        .split_once(':')
1384        .map(|(table, _)| table)
1385}
1386
1387fn collect_rows<T>(rows: impl Iterator<Item = rusqlite::Result<T>>) -> Result<Vec<T>> {
1388    let mut values = Vec::new();
1389    for row in rows {
1390        values.push(row?);
1391    }
1392    Ok(values)
1393}
1394
1395fn join_non_empty(values: impl IntoIterator<Item = Option<String>>) -> String {
1396    let parts: Vec<String> = values
1397        .into_iter()
1398        .flatten()
1399        .map(|value| value.trim().to_string())
1400        .filter(|value| !value.is_empty())
1401        .collect();
1402    parts.join("\n\n")
1403}
1404
1405pub fn memory_graph_node_kinds() -> Vec<&'static str> {
1406    vec![
1407        "memory_session",
1408        "memory_event",
1409        "session",
1410        "source_handle",
1411        "semantic_concept",
1412        "semantic_vector_handle",
1413    ]
1414}
1415
1416/// Authored finding-graph node kinds (`#trt1`): human/agent-authored knowledge
1417/// anchored to code, distinct from passively-captured `MemoryEvent`s.
1418#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1419pub enum AuthoredNodeKind {
1420    Finding,
1421    Decision,
1422    Note,
1423}
1424
1425impl AuthoredNodeKind {
1426    pub fn as_str(self) -> &'static str {
1427        match self {
1428            Self::Finding => "finding",
1429            Self::Decision => "decision",
1430            Self::Note => "note",
1431        }
1432    }
1433
1434    pub fn parse(raw: &str) -> Result<Self> {
1435        match raw {
1436            "finding" => Ok(Self::Finding),
1437            "decision" => Ok(Self::Decision),
1438            "note" => Ok(Self::Note),
1439            other => {
1440                bail!("unsupported authored node kind `{other}` (expected finding|decision|note)")
1441            }
1442        }
1443    }
1444}
1445
1446/// Build a `GraphProjection` for an authored finding/decision/note node
1447/// (`#trt1`), anchored to a stable symbol handle (graph node id / tagpath — NOT
1448/// a line number) via an `annotates` edge. `confidence` is clamped to `0..=1`;
1449/// `observed_at_unix` is the freshness stamp. The node id is content-stable, so
1450/// re-authoring the same text on the same anchor dedupes instead of duplicating.
1451pub fn authored_node_projection(
1452    kind: AuthoredNodeKind,
1453    text: &str,
1454    anchor_handle: &str,
1455    confidence: f64,
1456    observed_at_unix: i64,
1457    session_id: Option<&str>,
1458) -> GraphProjection {
1459    let mut projection = GraphProjection::default();
1460    let confidence = confidence.clamp(0.0, 1.0);
1461    let node_id = format!(
1462        "{}:{}",
1463        kind.as_str(),
1464        blake3::hash(format!("{}|{}|{}", kind.as_str(), anchor_handle, text).as_bytes()).to_hex()
1465    );
1466    let label: String = text.chars().take(80).collect();
1467    let mut node = GraphNode::new(&node_id, kind.as_str(), label)
1468        .with_property("text", text)
1469        .with_property("anchor_handle", anchor_handle)
1470        .with_property("confidence", format!("{confidence:.3}"))
1471        .with_property("observed_at_unix", observed_at_unix.to_string())
1472        .with_provenance(GraphProvenance::new("tsift-findings", anchor_handle))
1473        .with_freshness(GraphFreshness {
1474            content_hash: None,
1475            observed_at_unix: Some(observed_at_unix),
1476        });
1477    if let Some(session_id) = session_id {
1478        node = node.with_property("session_id", session_id);
1479    }
1480    projection.nodes.push(node);
1481    projection.edges.push(
1482        GraphEdge::new(node_id, anchor_handle, "annotates")
1483            .with_property("authored_kind", kind.as_str())
1484            .with_provenance(GraphProvenance::new("tsift-findings", anchor_handle)),
1485    );
1486    projection
1487}
1488
1489pub fn project_memory_events(events: &[MemoryEvent]) -> GraphProjection {
1490    let mut projection = GraphProjection::default();
1491    let mut sessions = BTreeSet::new();
1492
1493    for event in events {
1494        let event_id = event.stable_id();
1495        if let Some(session_id) = &event.session_id
1496            && sessions.insert(session_id.clone())
1497        {
1498            projection.nodes.push(
1499                GraphNode::new(
1500                    format!("memsess:{}", blake3::hash(session_id.as_bytes()).to_hex()),
1501                    "memory_session",
1502                    session_id,
1503                )
1504                .with_property("session_id", session_id)
1505                .with_provenance(GraphProvenance::new("tsift-memory", session_id)),
1506            );
1507        }
1508
1509        let mut node = GraphNode::new(&event_id, "memory_event", event.kind.as_str())
1510            .with_property("event_kind", event.kind.as_str())
1511            .with_property("source_ref", &event.source_ref)
1512            .with_property("token_estimate", event.token_estimate.to_string())
1513            .with_provenance(GraphProvenance::new("tsift-memory", &event.source_ref));
1514        if let Some(imported_from) = &event.imported_from {
1515            node = node.with_property("imported_from", imported_from);
1516        }
1517        if let Some(imported_id) = &event.imported_id {
1518            node = node.with_property("imported_id", imported_id);
1519        }
1520        projection.nodes.push(node);
1521
1522        if let Some(session_id) = &event.session_id {
1523            let session_node_id =
1524                format!("memsess:{}", blake3::hash(session_id.as_bytes()).to_hex());
1525            projection.edges.push(
1526                GraphEdge::new(session_node_id, event_id, "records_memory_event")
1527                    .with_provenance(GraphProvenance::new("tsift-memory", &event.source_ref)),
1528            );
1529        }
1530    }
1531
1532    projection
1533}
1534
1535/// Decay-weighted memory retrieval configuration (`#memgraphrag1`).
1536///
1537/// Retrieval combines a lexical relevance component with a recency component
1538/// that decays exponentially in the age of each event. `half_life_secs` is the
1539/// age at which an event's recency contribution halves; the two weights blend
1540/// the lexical and recency components into the final score.
1541#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1542pub struct MemoryDecayConfig {
1543    pub half_life_secs: f64,
1544    pub lexical_weight: f64,
1545    pub recency_weight: f64,
1546}
1547
1548impl Default for MemoryDecayConfig {
1549    fn default() -> Self {
1550        // One-week half-life with a slight lexical bias: recent context matters
1551        // but a strong term match should still surface older events.
1552        Self {
1553            half_life_secs: 7.0 * 24.0 * 3600.0,
1554            lexical_weight: 0.6,
1555            recency_weight: 0.4,
1556        }
1557    }
1558}
1559
1560/// A memory event scored by [`rank_memory_events`], with its component scores
1561/// exposed for explainability.
1562#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1563pub struct ScoredMemoryEvent {
1564    pub event: MemoryEvent,
1565    pub lexical_score: f64,
1566    pub recency_score: f64,
1567    pub score: f64,
1568}
1569
1570fn memory_query_terms(query: &str) -> Vec<String> {
1571    query
1572        .split(|c: char| !c.is_alphanumeric())
1573        .filter(|term| !term.is_empty())
1574        .map(|term| term.to_lowercase())
1575        .collect()
1576}
1577
1578fn memory_lexical_overlap(terms: &[String], text: &str) -> f64 {
1579    if terms.is_empty() {
1580        return 0.0;
1581    }
1582    let haystack = text.to_lowercase();
1583    let hits = terms
1584        .iter()
1585        .filter(|term| haystack.contains(term.as_str()))
1586        .count();
1587    hits as f64 / terms.len() as f64
1588}
1589
1590/// Exponential recency decay: `0.5 ^ (age / half_life)`. Events without an
1591/// `observed_at_unix` timestamp receive no recency credit (they still rank on
1592/// lexical relevance).
1593fn memory_recency_decay(observed_at_unix: Option<i64>, now_unix: i64, half_life_secs: f64) -> f64 {
1594    match observed_at_unix {
1595        Some(observed) => {
1596            let age = (now_unix - observed).max(0) as f64;
1597            0.5f64.powf(age / half_life_secs.max(1.0))
1598        }
1599        None => 0.0,
1600    }
1601}
1602
1603/// Rank memory events for a query, blending lexical relevance with exponential
1604/// recency decay (`#memgraphrag1`). Returns at most `limit` events, highest
1605/// score first; ties break toward the more recent event.
1606pub fn rank_memory_events(
1607    events: &[MemoryEvent],
1608    query: &str,
1609    now_unix: i64,
1610    config: MemoryDecayConfig,
1611    limit: usize,
1612) -> Vec<ScoredMemoryEvent> {
1613    let terms = memory_query_terms(query);
1614    let mut scored: Vec<ScoredMemoryEvent> = events
1615        .iter()
1616        .map(|event| {
1617            let lexical_score = memory_lexical_overlap(&terms, &event.text);
1618            let recency_score =
1619                memory_recency_decay(event.observed_at_unix, now_unix, config.half_life_secs);
1620            let score = config.lexical_weight * lexical_score + config.recency_weight * recency_score;
1621            ScoredMemoryEvent {
1622                event: event.clone(),
1623                lexical_score,
1624                recency_score,
1625                score,
1626            }
1627        })
1628        .collect();
1629    scored.sort_by(|a, b| {
1630        b.score
1631            .partial_cmp(&a.score)
1632            .unwrap_or(std::cmp::Ordering::Equal)
1633            .then_with(|| {
1634                b.recency_score
1635                    .partial_cmp(&a.recency_score)
1636                    .unwrap_or(std::cmp::Ordering::Equal)
1637            })
1638    });
1639    scored.truncate(limit);
1640    scored
1641}
1642
1643#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1644pub struct MemoryQueryPlan {
1645    pub contract_version: String,
1646    pub query: String,
1647    pub limit: usize,
1648    pub max_tokens: usize,
1649    pub estimated_query_tokens: usize,
1650    pub decay: MemoryDecayConfig,
1651    pub output_contract: Vec<String>,
1652    pub next_commands: Vec<String>,
1653}
1654
1655pub fn plan_memory_query(query: &str, limit: usize, max_tokens: usize) -> Result<MemoryQueryPlan> {
1656    if query.trim().is_empty() {
1657        bail!("memory query must not be empty");
1658    }
1659    Ok(MemoryQueryPlan {
1660        contract_version: MEMORY_CONTRACT_VERSION.to_string(),
1661        query: query.to_string(),
1662        limit,
1663        max_tokens,
1664        estimated_query_tokens: estimate_tokens(query),
1665        decay: MemoryDecayConfig::default(),
1666        output_contract: vec![
1667            "decay-weighted ranked memory_event ids (lexical + recency)".to_string(),
1668            "per-event lexical_score, recency_score, and blended score".to_string(),
1669            "source_ref handles for expansion".to_string(),
1670            "graph node ids for neighborhood projection".to_string(),
1671            "token estimates for every returned packet".to_string(),
1672        ],
1673        next_commands: vec![
1674            "tsift memory status . --json".to_string(),
1675            "tsift memory project-graph . --json".to_string(),
1676            "tsift graph-db --path . --json related '<query>'".to_string(),
1677        ],
1678    })
1679}
1680
1681#[cfg(test)]
1682mod tests {
1683    use super::*;
1684    use tempfile::TempDir;
1685
1686    #[test]
1687    fn handoff_plan_defers_oversized_events_before_model_call() {
1688        let small = MemoryEvent::new(MemoryEventKind::PromptTarget, "session.md", "short");
1689        let large = MemoryEvent::new(
1690            MemoryEventKind::ToolResultArtifact,
1691            "artifact:log",
1692            "x".repeat(10_000),
1693        );
1694        let plan = plan_capture_handoff(
1695            &[small, large],
1696            MemoryBudget {
1697                max_prompt_tokens: 1000,
1698                reserve_tokens: 100,
1699                max_event_tokens: 500,
1700            },
1701        );
1702        assert_eq!(plan.status, "split_required");
1703        assert_eq!(plan.included_events.len(), 1);
1704        assert_eq!(
1705            plan.deferred_events[0].reason,
1706            "event_exceeds_max_event_tokens"
1707        );
1708    }
1709
1710    fn event_at(text: &str, observed_at_unix: i64) -> MemoryEvent {
1711        let mut event = MemoryEvent::new(MemoryEventKind::ResponseSummary, "session.md", text);
1712        event.observed_at_unix = Some(observed_at_unix);
1713        event
1714    }
1715
1716    #[test]
1717    fn recency_decay_halves_at_half_life() {
1718        let now = 1_000_000;
1719        let half_life = 1000.0;
1720        let fresh = memory_recency_decay(Some(now), now, half_life);
1721        let one_half_life = memory_recency_decay(Some(now - 1000), now, half_life);
1722        let two_half_lives = memory_recency_decay(Some(now - 2000), now, half_life);
1723        assert!((fresh - 1.0).abs() < 1e-9);
1724        assert!((one_half_life - 0.5).abs() < 1e-9);
1725        assert!((two_half_lives - 0.25).abs() < 1e-9);
1726        // Missing timestamps get no recency credit.
1727        assert_eq!(memory_recency_decay(None, now, half_life), 0.0);
1728    }
1729
1730    #[test]
1731    fn rank_recent_event_outranks_old_with_equal_lexical() {
1732        let now = 10_000_000;
1733        let config = MemoryDecayConfig {
1734            half_life_secs: 1000.0,
1735            lexical_weight: 0.5,
1736            recency_weight: 0.5,
1737        };
1738        let recent = event_at("graph retrieval decay", now - 100);
1739        let old = event_at("graph retrieval decay", now - 100_000);
1740        let ranked = rank_memory_events(&[old, recent], "graph retrieval", now, config, 10);
1741        assert_eq!(ranked.len(), 2);
1742        assert!(ranked[0].event.observed_at_unix.unwrap() > ranked[1].event.observed_at_unix.unwrap());
1743        assert!(ranked[0].score > ranked[1].score);
1744    }
1745
1746    #[test]
1747    fn rank_strong_lexical_can_beat_recency_and_respects_limit() {
1748        let now = 10_000_000;
1749        let config = MemoryDecayConfig::default();
1750        let on_topic_old = event_at("decay weighted memory retrieval ranking", now - 60 * 3600);
1751        let off_topic_fresh = event_at("unrelated build log output", now - 10);
1752        let ranked = rank_memory_events(
1753            &[off_topic_fresh, on_topic_old],
1754            "decay weighted retrieval",
1755            now,
1756            config,
1757            1,
1758        );
1759        assert_eq!(ranked.len(), 1, "limit must be respected");
1760        assert!(ranked[0].event.text.contains("decay weighted memory retrieval"));
1761        assert!(ranked[0].lexical_score > 0.0);
1762    }
1763
1764    #[test]
1765    fn authored_node_projection_anchors_and_dedupes() {
1766        let p = authored_node_projection(
1767            AuthoredNodeKind::Finding,
1768            "decay ranking ships in tsift-memory",
1769            "symbol:rank_memory_events",
1770            1.7, // out of range -> clamps to 1.0
1771            1_700_000_000,
1772            Some("sess-1"),
1773        );
1774        assert_eq!(p.nodes.len(), 1);
1775        assert_eq!(p.edges.len(), 1);
1776        let node = &p.nodes[0];
1777        assert_eq!(node.kind, "finding");
1778        assert_eq!(node.properties.get("confidence").unwrap(), "1.000");
1779        assert_eq!(
1780            node.properties.get("anchor_handle").unwrap(),
1781            "symbol:rank_memory_events"
1782        );
1783        assert_eq!(p.edges[0].kind, "annotates");
1784        assert_eq!(p.edges[0].to_id, "symbol:rank_memory_events");
1785        assert_eq!(node.freshness.as_ref().unwrap().observed_at_unix, Some(1_700_000_000));
1786
1787        // Same kind+anchor+text -> identical (deduping) node id.
1788        let again = authored_node_projection(
1789            AuthoredNodeKind::Finding,
1790            "decay ranking ships in tsift-memory",
1791            "symbol:rank_memory_events",
1792            0.5,
1793            1_700_000_999,
1794            None,
1795        );
1796        assert_eq!(again.nodes[0].id, node.id);
1797        assert!(AuthoredNodeKind::parse("note").is_ok());
1798        assert!(AuthoredNodeKind::parse("bogus").is_err());
1799    }
1800
1801    #[test]
1802    fn plan_memory_query_carries_default_decay_config() {
1803        let plan = plan_memory_query("graph rag", 5, 1500).unwrap();
1804        assert_eq!(plan.decay, MemoryDecayConfig::default());
1805        assert!(plan.output_contract.iter().any(|line| line.contains("decay")));
1806    }
1807
1808    #[test]
1809    fn memory_store_initializes_schema_and_dedupes_imported_events() {
1810        let dir = TempDir::new().unwrap();
1811        let db = dir.path().join("memory.db");
1812        let store = MemoryStore::open_or_create(&db).unwrap();
1813        let event = MemoryEvent::new(
1814            MemoryEventKind::ImportedObservation,
1815            "claude-mem:observations:1",
1816            "fact",
1817        )
1818        .with_session_id("session-a")
1819        .with_import("claude-mem", "observations:1");
1820
1821        let first = store.insert_event(&event).unwrap();
1822        let second = store.insert_event(&event).unwrap();
1823        assert_eq!(first, second);
1824        assert_eq!(store.event_count().unwrap(), 1);
1825    }
1826
1827    #[test]
1828    fn claude_mem_pending_messages_are_reported_but_not_imported() {
1829        let dir = TempDir::new().unwrap();
1830        let db = dir.path().join("claude-mem.db");
1831        let conn = Connection::open(&db).unwrap();
1832        conn.execute_batch(
1833            r#"
1834            CREATE TABLE pending_messages (
1835              id INTEGER PRIMARY KEY,
1836              session_db_id INTEGER NOT NULL,
1837              content_session_id TEXT NOT NULL,
1838              message_type TEXT NOT NULL CHECK(message_type IN ('observation', 'summarize')),
1839              tool_name TEXT,
1840              tool_input TEXT,
1841              tool_response TEXT,
1842              cwd TEXT,
1843              last_user_message TEXT,
1844              last_assistant_message TEXT,
1845              prompt_number INTEGER,
1846              status TEXT NOT NULL DEFAULT 'pending',
1847              retry_count INTEGER NOT NULL DEFAULT 0,
1848              created_at_epoch INTEGER NOT NULL,
1849              started_processing_at_epoch INTEGER,
1850              completed_at_epoch INTEGER,
1851              failed_at_epoch INTEGER
1852            );
1853            INSERT INTO pending_messages (
1854              id, session_db_id, content_session_id, message_type, tool_name,
1855              tool_input, tool_response, cwd, last_user_message,
1856              last_assistant_message, prompt_number, status, retry_count,
1857              created_at_epoch
1858            ) VALUES (
1859              1, 10, 'session-a', 'observation', 'bash',
1860              '{"cmd":"cat large.log"}', 'raw tool response', '/repo',
1861              'run tests', 'done', 7, 'pending', 0, 1700000000
1862            );
1863            "#,
1864        )
1865        .unwrap();
1866
1867        let plan = inspect_claude_mem(&db).unwrap();
1868        assert_eq!(plan.pending_messages.rows, 1);
1869        assert!(!plan.pending_messages.supported);
1870        assert_eq!(
1871            plan.pending_messages.unsupported_reason.as_deref(),
1872            Some(PENDING_MESSAGES_EXCLUSION_REASON)
1873        );
1874        assert!(
1875            plan.warnings
1876                .iter()
1877                .any(|warning| warning.contains("pending_messages"))
1878        );
1879
1880        let read_report = read_claude_mem_events(&db, Some(100)).unwrap();
1881        assert!(read_report.events.is_empty());
1882    }
1883
1884    #[test]
1885    fn claude_mem_import_all_reconciles_supported_table_counts() {
1886        let dir = TempDir::new().unwrap();
1887        let source = dir.path().join("claude-mem.db");
1888        let target = dir.path().join("memory.db");
1889        let conn = Connection::open(&source).unwrap();
1890        create_supported_claude_mem_fixture(&conn);
1891
1892        let dry_run = import_claude_mem(&source, &target, None, true).unwrap();
1893        assert!(dry_run.import_all);
1894        assert!(dry_run.reconciliation.complete);
1895        assert_eq!(dry_run.reconciliation.total_source_rows, 6);
1896        assert_eq!(dry_run.reconciliation.total_read_events, 6);
1897        assert_eq!(dry_run.reconciliation.total_skipped_source_rows, 0);
1898        assert_eq!(dry_run.reconciliation.observations.source_rows, 2);
1899        assert_eq!(dry_run.reconciliation.session_summaries.source_rows, 1);
1900        assert_eq!(dry_run.reconciliation.user_prompts.source_rows, 3);
1901
1902        let applied = import_claude_mem(&source, &target, None, false).unwrap();
1903        assert_eq!(applied.planned_events, 6);
1904        assert_eq!(applied.imported_events, 6);
1905        assert_eq!(applied.already_present_events, 0);
1906        assert_eq!(applied.event_ids_total, 6);
1907        assert_eq!(applied.event_ids.len(), 6);
1908        assert!(!applied.event_ids_truncated);
1909        assert_eq!(
1910            MemoryStore::open_or_create(&target)
1911                .unwrap()
1912                .event_count()
1913                .unwrap(),
1914            6
1915        );
1916
1917        let second_apply = import_claude_mem(&source, &target, None, false).unwrap();
1918        assert_eq!(second_apply.imported_events, 0);
1919        assert_eq!(second_apply.already_present_events, 6);
1920        assert_eq!(second_apply.reconciliation.total_already_present_events, 6);
1921    }
1922
1923    #[test]
1924    fn claude_mem_limited_import_reports_skipped_source_rows() {
1925        let dir = TempDir::new().unwrap();
1926        let source = dir.path().join("claude-mem.db");
1927        let conn = Connection::open(&source).unwrap();
1928        create_supported_claude_mem_fixture(&conn);
1929
1930        let read_report = read_claude_mem_events(&source, Some(1)).unwrap();
1931        assert!(!read_report.import_all);
1932        assert!(!read_report.reconciliation.complete);
1933        assert_eq!(read_report.reconciliation.total_source_rows, 6);
1934        assert_eq!(read_report.reconciliation.total_read_events, 3);
1935        assert_eq!(read_report.reconciliation.total_skipped_source_rows, 3);
1936        assert_eq!(
1937            read_report.reconciliation.observations.skipped_source_rows,
1938            1
1939        );
1940        assert_eq!(
1941            read_report
1942                .reconciliation
1943                .session_summaries
1944                .skipped_source_rows,
1945            0
1946        );
1947        assert_eq!(
1948            read_report.reconciliation.user_prompts.skipped_source_rows,
1949            2
1950        );
1951    }
1952
1953    #[test]
1954    fn claude_mem_import_caps_reported_event_ids() {
1955        let dir = TempDir::new().unwrap();
1956        let source = dir.path().join("claude-mem.db");
1957        let target = dir.path().join("memory.db");
1958        let conn = Connection::open(&source).unwrap();
1959        conn.execute_batch(
1960            r#"
1961            CREATE TABLE user_prompts (
1962              id INTEGER PRIMARY KEY,
1963              content_session_id TEXT NOT NULL,
1964              prompt_number INTEGER NOT NULL,
1965              prompt_text TEXT NOT NULL,
1966              created_at_epoch INTEGER NOT NULL
1967            );
1968            "#,
1969        )
1970        .unwrap();
1971        for id in 1..=(MAX_IMPORT_EVENT_IDS + 1) {
1972            conn.execute(
1973                "INSERT INTO user_prompts (id, content_session_id, prompt_number, prompt_text, created_at_epoch) VALUES (?1, 'session-a', ?2, ?3, ?4)",
1974                params![id as i64, id as i64, format!("prompt {id}"), 1700000000_i64 + id as i64],
1975            )
1976            .unwrap();
1977        }
1978
1979        let applied = import_claude_mem(&source, &target, None, false).unwrap();
1980        assert_eq!(applied.planned_events, MAX_IMPORT_EVENT_IDS + 1);
1981        assert_eq!(applied.event_ids_total, MAX_IMPORT_EVENT_IDS + 1);
1982        assert_eq!(applied.event_ids.len(), MAX_IMPORT_EVENT_IDS);
1983        assert!(applied.event_ids_truncated);
1984        assert!(applied.reconciliation.complete);
1985    }
1986
1987    fn create_supported_claude_mem_fixture(conn: &Connection) {
1988        conn.execute_batch(
1989            r#"
1990            CREATE TABLE observations (
1991              id INTEGER PRIMARY KEY,
1992              memory_session_id TEXT NOT NULL,
1993              project TEXT NOT NULL,
1994              type TEXT NOT NULL,
1995              title TEXT,
1996              subtitle TEXT,
1997              text TEXT,
1998              facts TEXT,
1999              narrative TEXT,
2000              concepts TEXT,
2001              prompt_number INTEGER,
2002              discovery_tokens INTEGER NOT NULL,
2003              created_at_epoch INTEGER NOT NULL,
2004              content_hash TEXT
2005            );
2006            CREATE TABLE session_summaries (
2007              id INTEGER PRIMARY KEY,
2008              memory_session_id TEXT NOT NULL,
2009              project TEXT NOT NULL,
2010              request TEXT,
2011              investigated TEXT,
2012              learned TEXT,
2013              completed TEXT,
2014              next_steps TEXT,
2015              notes TEXT,
2016              prompt_number INTEGER,
2017              discovery_tokens INTEGER NOT NULL,
2018              created_at_epoch INTEGER NOT NULL
2019            );
2020            CREATE TABLE user_prompts (
2021              id INTEGER PRIMARY KEY,
2022              content_session_id TEXT NOT NULL,
2023              prompt_number INTEGER NOT NULL,
2024              prompt_text TEXT NOT NULL,
2025              created_at_epoch INTEGER NOT NULL
2026            );
2027            INSERT INTO observations (
2028              id, memory_session_id, project, type, title, subtitle, text, facts,
2029              narrative, concepts, prompt_number, discovery_tokens, created_at_epoch,
2030              content_hash
2031            ) VALUES
2032              (1, 'session-a', 'agent-loop', 'fact', 'Title A', NULL, 'Text A', NULL, NULL, 'tsift', 1, 42, 1700000001, 'hash-a'),
2033              (2, 'session-b', 'agent-loop', 'fact', 'Title B', NULL, 'Text B', NULL, NULL, 'memory', 2, 43, 1700000002, 'hash-b');
2034            INSERT INTO session_summaries (
2035              id, memory_session_id, project, request, investigated, learned,
2036              completed, next_steps, notes, prompt_number, discovery_tokens,
2037              created_at_epoch
2038            ) VALUES (
2039              1, 'session-a', 'agent-loop', 'replace claude-mem', 'tables',
2040              'all rows', 'imported', 'refresh graph', NULL, 3, 44, 1700000003
2041            );
2042            INSERT INTO user_prompts (
2043              id, content_session_id, prompt_number, prompt_text, created_at_epoch
2044            ) VALUES
2045              (1, 'session-a', 1, 'first prompt', 1700000004),
2046              (2, 'session-a', 2, 'second prompt', 1700000005),
2047              (3, 'session-b', 1, 'third prompt', 1700000006);
2048            "#,
2049        )
2050        .unwrap();
2051    }
2052
2053    #[test]
2054    fn read_memory_events_round_trips_closeout_events() {
2055        let dir = TempDir::new().unwrap();
2056        let db = dir.path().join("memory.db");
2057        let store = MemoryStore::open_or_create(&db).unwrap();
2058        for event in agent_doc_closeout_events(
2059            Path::new("tasks/software/tsift.md"),
2060            "do [#tsiftmemhooks]",
2061            "wired closeout capture",
2062            Some("abc123"),
2063            "ok",
2064        ) {
2065            store.insert_event(&event).unwrap();
2066        }
2067
2068        let events = read_memory_events(&db, 10).unwrap();
2069        assert!(events.iter().any(|event| {
2070            event.kind == MemoryEventKind::PromptTarget
2071                && event.text == "do [#tsiftmemhooks]"
2072                && event.session_id.as_deref() == Some("tasks/software/tsift.md")
2073        }));
2074        assert!(events.iter().any(|event| {
2075            event.kind == MemoryEventKind::CloseoutProof
2076                && event.metadata.get("commit_hash") == Some(&"abc123".to_string())
2077        }));
2078    }
2079
2080    #[test]
2081    fn graph_projection_links_events_to_sessions() {
2082        let event = MemoryEvent::new(MemoryEventKind::ResponseSummary, "session.md", "done")
2083            .with_session_id("session-a");
2084        let projection = project_memory_events(&[event]);
2085        assert_eq!(projection.nodes.len(), 2);
2086        assert_eq!(projection.edges.len(), 1);
2087        assert!(
2088            projection
2089                .nodes
2090                .iter()
2091                .any(|node| node.kind == "memory_session")
2092        );
2093        assert!(
2094            projection
2095                .nodes
2096                .iter()
2097                .any(|node| node.kind == "memory_event")
2098        );
2099    }
2100
2101    #[test]
2102    fn budget_guard_fails_closed_with_retryable_chunks() {
2103        let report = guard_memory_handoff(
2104            MemoryBudgetGuardInput::new("tool.log", "tool_result", "x".repeat(5_000)),
2105            MemoryBudget {
2106                max_prompt_tokens: 1000,
2107                reserve_tokens: 100,
2108                max_event_tokens: 400,
2109            },
2110        );
2111
2112        assert!(!report.allowed);
2113        assert_eq!(report.status, "blocked_split_required");
2114        assert!(report.replacement.is_some());
2115        assert!(report.retryable_chunk_plan.len() > 1);
2116        assert!(
2117            report
2118                .retryable_chunk_plan
2119                .iter()
2120                .all(|chunk| chunk.token_estimate <= 400)
2121        );
2122    }
2123
2124    #[test]
2125    fn budget_guard_replaces_transcripts_with_session_review_commands() {
2126        let report = guard_memory_handoff(
2127            MemoryBudgetGuardInput::new("session.jsonl", "transcript", "x".repeat(5_000)),
2128            MemoryBudget {
2129                max_prompt_tokens: 1000,
2130                reserve_tokens: 100,
2131                max_event_tokens: 400,
2132            },
2133        );
2134        let replacement = report.replacement.unwrap();
2135        assert_eq!(
2136            replacement.strategy,
2137            "replace_raw_transcript_with_session_review_or_context_pack_handle"
2138        );
2139        assert!(
2140            replacement
2141                .session_review_command
2142                .contains("session-review")
2143        );
2144        assert!(replacement.context_command.contains("context-pack"));
2145    }
2146}