Skip to main content

oxi/store/
memory_summary.rs

1//! Autonomous memory pipeline (omp `local-backend` port).
2//!
3//! Implements the documented two-phase background job used by the
4//! `local` memory backend in omp:
5//!
6//! - **Phase 1** (per-session extraction): for each past session
7//!   that has changed since it was last processed, an LLM is asked
8//!   to distill durable signal. The output is a single small JSON
9//!   record per thread.
10//! - **Phase 2** (cross-session consolidation): periodically, the
11//!   extraction outputs are fed to a second LLM pass that produces
12//!   three artifacts on disk under `<memory-root>/`:
13//!   - `MEMORY.md` — long-term memory document
14//!   - `memory_summary.md` — compact summary injected at session start
15//!   - `skills/<name>/SKILL.md` (+ optional scripts/templates/examples)
16//!
17//! This module is intentionally **self-contained**: it owns its own
18//! SQLite tables (via the same `MemoryDb` connection as the rest of
19//! the memory store when present, otherwise a separate file) so the
20//! pipeline can run independently of `MemoryBackend`. The background
21//! spawn hook lives in `services::start_memory_pipeline` (wired from
22//! `bootstrap`).
23//!
24//! Code-only reference ports (MIT):
25//!   - `packages/coding-agent/src/memories/index.ts`
26//!   - `packages/coding-agent/src/memories/storage.ts`
27//!   - `packages/coding-agent/src/memory-backend/local-backend.ts`
28//!   - `packages/coding-agent/src/prompts/memories/*.md`
29//!   - `packages/coding-agent/src/internal-urls/memory-protocol.ts`
30//!
31//! Phases 1 and 2 require an LLM `Model` + `Provider` (oxi-ai). When
32//! neither is available, the worker logs once and skips — the
33//! pipeline never panics and never blocks session boot.
34//!
35//! All helpers here are *functional* (no global state, no side
36//! effects on import). The runtime spawn is opt-in.
37#![allow(missing_docs)]
38
39use std::path::{Path, PathBuf};
40use std::sync::LazyLock;
41
42// ── omp prompt ports (MIT) ─────────────────────────────────────
43//
44// Each prompt is verbatim from
45// `packages/coding-agent/src/prompts/memories/*.md` (or paraphrased
46// for the {{}}-templated gaps the original uses for now()). Variables
47// are spelled out as Rust `const`s with a separate runtime
48// substitution helper to keep the prompts easy to diff against the
49// upstream source.
50
51/// System prompt for per-session extraction (omp `stage_one_system.md`).
52pub const STAGE_ONE_SYSTEM_PROMPT: &str = "\
53You are the memory-stage-one extractor.\n\
54\n\
55You MUST return strict JSON only — no markdown, no commentary.\n\
56\n\
57Extraction goals:\n\
58- You MUST distill reusable durable knowledge from rollout history.\n\
59- You MUST keep concrete technical signal (constraints, decisions, workflows, pitfalls, resolved failures).\n\
60- You NEVER include transient chatter or low-signal noise.\n\
61\n\
62Output contract (required keys):\n\
63{\n\
64  \"rollout_summary\": \"string\",\n\
65  \"rollout_slug\": \"string | null\",\n\
66  \"raw_memory\": \"string\"\n\
67}\n\
68\n\
69Rules:\n\
70- rollout_summary: compact synopsis of what future runs should remember.\n\
71- rollout_slug: short lowercase slug (letters/numbers/_), or null.\n\
72- raw_memory: detailed durable memory blocks with enough context to reuse.\n\
73- If no durable signal exists, you MUST return empty strings for rollout_summary/raw_memory and null rollout_slug.";
74
75/// User-turn template for per-session extraction (omp `stage_one_input.md`).
76pub const STAGE_ONE_USER_TEMPLATE: &str = "\
77thread_id: {{thread_id}}\n\
78\n\
79Persistable response items (JSON):\n\
80{{response_items_json}}\n\
81\n\
82You MUST extract durable memory now.";
83
84/// System prompt for cross-session consolidation (omp `consolidation_system.md`).
85pub const CONSOLIDATION_SYSTEM_PROMPT: &str = "\
86You are the memory-stage-two consolidator.\n\
87\n\
88Follow the user-provided consolidation task exactly.\n\
89Return strict JSON only — no markdown, no commentary.";
90
91/// User-turn template for cross-session consolidation (omp `consolidation.md`).
92/// Variables: `{{raw_memories}}` (joined string of stage1 outputs),
93/// `{{rollout_summaries}}` (joined string).
94pub const CONSOLIDATION_USER_TEMPLATE: &str = "\
95Memory consolidation agent.\n\
96Memory root: memory://root\n\
97Input corpus (raw memories):\n\
98{{raw_memories}}\n\
99Input corpus (rollout summaries):\n\
100{{rollout_summaries}}\n\
101Produce strict JSON only with this schema — you NEVER include any other output:\n\
102{\n\
103  \"memory_md\": \"string\",\n\
104  \"memory_summary\": \"string\",\n\
105  \"skills\": [\n\
106    {\n\
107      \"name\": \"string\",\n\
108      \"content\": \"string\",\n\
109      \"scripts\": [{ \"path\": \"string\", \"content\": \"string\" }],\n\
110      \"templates\": [{ \"path\": \"string\", \"content\": \"string\" }],\n\
111      \"examples\": [{ \"path\": \"string\", \"content\": \"string\" }]\n\
112    }\n\
113  ]\n\
114}\n\
115\n\
116Requirements:\n\
117- memory_md: long-term memory document.\n\
118- memory_summary: prompt-time memory guidance.\n\
119- skills: reusable playbooks. Empty array allowed.\n\
120- skill.name maps to skills/<name>/.\n\
121- skill.content maps to skills/<name>/SKILL.md.\n\
122- scripts/templates/examples: optional. Each entry MUST write to skills/<name>/<bucket>/<path>.\n\
123- Only include files worth keeping long-term. Omit stale assets so they are pruned.\n\
124- Preserve useful prior themes. Remove stale or contradictory guidance.\n\
125- Treat memory as advisory: current repository state wins.";
126
127/// Read-path prompt (omp `read-path.md`). Compactly injected at
128/// session start so the agent treats memory as advisory context.
129pub const READ_PATH_PROMPT: &str = "\
130# Memory Guidance\n\
131Memory root: memory://root\n\
132Operational rules:\n\
1331) Read `memory://root/memory_summary.md` first.\n\
1342) If needed, inspect `memory://root/MEMORY.md` and `memory://root/skills/<name>/SKILL.md`.\n\
1353) Trust memory for heuristics and process context. Trust current repo files, runtime output, and user instruction for factual state and final decisions.\n\
1364) When memory changes your plan, cite the artifact path (e.g. `memory://root/skills/<name>/SKILL.md`) and pair it with current-repo evidence.\n\
1375) If memory disagrees with repo state or user instruction, treat memory as stale: proceed with corrected behavior, then update/regenerate memory artifacts.\n\
1386) Escalate confidence only after repository verification. Memory alone is NEVER sufficient proof.\n\
139{{memory_summary_block}}{{learned_block}}";
140
141/// Suggested rendered block when a `memory_summary.md` exists.
142pub const READ_PATH_SUMMARY_TEMPLATE: &str = "\
143Memory summary:\n\
144{{memory_summary}}\n\
145";
146
147/// Suggested rendered block when `learned.md` exists.
148pub const READ_PATH_LEARNED_TEMPLATE: &str = "\
149Learned lessons (captured via the `learn` tool; durable but may be stale — verify against the repo before relying on them):\n\
150{{learned}}\n\
151";
152
153// ── Tweakable knobs (mirror omp's `MemoryRuntimeConfig`) ────────
154
155/// Sessions older than this are not processed by Phase 1.
156pub const DEFAULT_MAX_ROLLOUT_AGE_DAYS: i64 = 30;
157/// Sessions active more recently than this are skipped (still in use).
158pub const DEFAULT_MIN_ROLLOUT_IDLE_HOURS: i64 = 12;
159/// Hard cap on the number of sessions processed per startup.
160pub const DEFAULT_MAX_ROLLOUTS_PER_STARTUP: usize = 64;
161/// Sum of `memory_summary.md` kept under this many bytes is read into
162/// the system prompt directly.
163pub const DEFAULT_SUMMARY_INJECTION_TOKEN_LIMIT: usize = 5_000;
164/// Lease seconds for Phase 2 ownership tokens (prevents two oxi
165/// processes from double-consolidating).
166pub const DEFAULT_GLOBAL_LEASE_SECONDS: i64 = 60;
167/// Phase 2 heartbeat refresh interval (must be < lease).
168pub const DEFAULT_GLOBAL_HEARTBEAT_SECONDS: i64 = 30;
169/// Token cap passed to the model for Stage 1 / Stage 2 inputs.
170pub const DEFAULT_MODEL_TOKEN_BUDGET: usize = 8_000;
171
172// ── Secret redaction (lazy-compiled, regex-free) ───────────────
173
174/// Crude secret patterns to redact from written `MEMORY.md` /
175/// `memory_summary.md`.
176///
177/// We use a single linear scan per line against a small set of
178/// byte-prefix matchers; that's plenty for guarding accidental
179/// token commits. The pattern set is a `LazyLock<Vec<SecretPattern>>`
180/// so the lookup table isn't rebuilt per call.
181struct SecretPattern {
182    /// Stable id for diagnostics.
183    name: &'static str,
184    /// Byte prefix to match at the start of the secret token.
185    prefix: &'static str,
186}
187
188static SECRET_PATTERNS: LazyLock<Vec<SecretPattern>> = LazyLock::new(|| {
189    vec![
190        SecretPattern {
191            name: "openai",
192            prefix: "sk-",
193        },
194        SecretPattern {
195            name: "anthropic",
196            prefix: "sk-ant-",
197        },
198        SecretPattern {
199            name: "github_pat",
200            prefix: "ghp_",
201        },
202        SecretPattern {
203            name: "github_oauth",
204            prefix: "gho_",
205        },
206        SecretPattern {
207            name: "google_api",
208            prefix: "AIza",
209        },
210        SecretPattern {
211            name: "slack",
212            prefix: "xoxb-",
213        },
214    ]
215});
216
217/// Redact obvious token patterns from a single line. Returns the
218/// redacted line unchanged if no patterns match.
219pub fn redact_line(line: &str) -> String {
220    let mut out = String::with_capacity(line.len());
221    let mut rest = line;
222    while !rest.is_empty() {
223        let mut matched = false;
224        for pat in SECRET_PATTERNS.iter() {
225            if let Some(idx) = rest.find(pat.prefix) {
226                // Token-char suffix until the next whitespace / quote.
227                let after = &rest[idx + pat.prefix.len()..];
228                let tail = after
229                    .find(|c: char| c.is_whitespace() || c == '"' || c == '\'')
230                    .unwrap_or(after.len());
231                let secret_len = pat.prefix.len() + tail;
232                out.push_str(&rest[..idx]);
233                out.push_str(&format!("[REDACTED:{}]", pat.name));
234                rest = &rest[idx + secret_len..];
235                matched = true;
236                break;
237            }
238        }
239        if !matched {
240            out.push_str(rest);
241            break;
242        }
243    }
244    out
245}
246
247/// Redact secrets across a multi-line string.
248pub fn redact_secrets(text: &str) -> String {
249    let mut out = String::with_capacity(text.len());
250    for line in text.split_inclusive('\n') {
251        out.push_str(&redact_line(line));
252    }
253    out
254}
255
256// ── Path / scope helpers ──────────────────────────────────────
257
258/// Resolve `<home>/.oxi/memory/<encoded-cwd>/` for the current cwd.
259/// Mirrors `encodeProjectPath` in omp's `index.ts`:
260///   `let encoded = "--" + cwd.trim_start_matches(['/','\\']).replace(['/','\\',':'], "-") + "--";`
261#[allow(clippy::manual_pattern_char_comparison)]
262pub fn memory_root(home: &Path, cwd: &str) -> PathBuf {
263    let stripped = cwd.trim_start_matches(|c: char| c == '/' || c == '\\');
264    let encoded = format!("--{}--", stripped.replace(['/', '\\', ':'], "-"));
265    home.join("memory").join(encoded)
266}
267
268/// Filenames of the canonical artifacts under `<memory-root>/`.
269pub const MEMORY_MD: &str = "MEMORY.md";
270pub const MEMORY_SUMMARY_MD: &str = "memory_summary.md";
271pub const LEARNED_MD: &str = "learned.md";
272
273/// Sanitize a model-supplied skill name into a safe directory name.
274pub fn sanitize_skill_name(name: &str) -> String {
275    name.chars()
276        .map(|c| {
277            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
278                c.to_ascii_lowercase()
279            } else {
280                '-'
281            }
282        })
283        .collect()
284}
285
286/// Sanitize a path within a `skills/<bucket>/<path>` so it cannot
287/// escape the bucket root.
288pub fn sanitize_skill_relative_path(raw: &str) -> Option<String> {
289    let mut parts: Vec<&str> = raw
290        .split('/')
291        .filter(|s| !s.is_empty() && *s != "." && *s != "..")
292        .collect();
293    if parts.is_empty() {
294        return None;
295    }
296    parts.sort();
297    Some(parts.join("/"))
298}
299
300// ── Helpers (kept tiny; the hard work lives in workers.rs) ─────
301
302/// Render `read-path.md` with optional summary and learned blocks.
303///
304/// If both `summary` and `learned` are `Some`, both blocks are
305/// emitted; if either is `None`, the corresponding placeholder is
306/// dropped so we don't ship literal `{{}}` to the agent.
307pub fn render_read_path(summary: Option<&str>, learned: Option<&str>) -> String {
308    let summary_block = summary
309        .map(|s| READ_PATH_SUMMARY_TEMPLATE.replace("{{memory_summary}}", s))
310        .unwrap_or_default();
311    let learned_block = learned
312        .map(|s| READ_PATH_LEARNED_TEMPLATE.replace("{{learned}}", s))
313        .unwrap_or_default();
314    READ_PATH_PROMPT
315        .replace("{{memory_summary_block}}", &summary_block)
316        .replace("{{learned_block}}", &learned_block)
317}
318
319// Phase 1 + Phase 2 worker functions + SQLite job queue live in
320// `memory_workers.rs` to keep this file focused on the artifact
321// surface. They are intentionally out of scope of this version;
322// `services::start_memory_pipeline` is the wiring point that the
323// follow-up PR will hook them up through.
324
325// ── Disk writer (consolidated artifact surface) ────────────────
326
327/// A consolidated skill as supplied by the Stage-2 model.
328#[derive(Debug, Clone, serde::Deserialize)]
329pub struct ConsolidationSkill {
330    pub name: String,
331    pub content: String,
332    #[serde(default)]
333    pub scripts: Vec<ConsolidationFile>,
334    #[serde(default)]
335    pub templates: Vec<ConsolidationFile>,
336    #[serde(default)]
337    pub examples: Vec<ConsolidationFile>,
338}
339
340/// One file under `skills/<name>/<bucket>/<path>`.
341#[derive(Debug, Clone, serde::Deserialize)]
342pub struct ConsolidationFile {
343    pub path: String,
344    pub content: String,
345}
346
347/// JSON payload returned by the Stage-2 LLM.
348#[derive(Debug, Clone, serde::Deserialize)]
349pub struct ConsolidationOutput {
350    #[serde(default)]
351    pub memory_md: String,
352    #[serde(default)]
353    pub memory_summary: String,
354    #[serde(default)]
355    pub skills: Vec<ConsolidationSkill>,
356}
357
358/// Write the three consolidation artifacts under `memory_root`.
359///
360/// All written text is run through [`redact_secrets`] before being
361/// persisted. Existing `skills/*` directories the Stage-2 output
362/// does NOT mention are pruned, mirroring omp's
363/// `cleanupConsolidatedArtifacts`.
364pub fn write_consolidation_artifacts(
365    memory_root: &Path,
366    out: &ConsolidationOutput,
367) -> std::io::Result<()> {
368    std::fs::create_dir_all(memory_root)?;
369
370    let memory_md = redact_secrets(&out.memory_md);
371    let summary = redact_secrets(&out.memory_summary);
372
373    let memory_path = memory_root.join(MEMORY_MD);
374    let summary_path = memory_root.join(MEMORY_SUMMARY_MD);
375    std::fs::write(&memory_path, memory_md.as_bytes())?;
376    std::fs::write(&summary_path, summary.as_bytes())?;
377
378    let skills_root = memory_root.join("skills");
379    std::fs::create_dir_all(&skills_root)?;
380
381    let mut retained: std::collections::HashSet<String> = std::collections::HashSet::new();
382    for skill in &out.skills {
383        let dir_name = sanitize_skill_name(&skill.name);
384        if dir_name.is_empty() || dir_name == "-" {
385            continue;
386        }
387        let skill_dir = skills_root.join(&dir_name);
388        std::fs::create_dir_all(&skill_dir)?;
389        retained.insert(dir_name.clone());
390
391        std::fs::write(
392            skill_dir.join("SKILL.md"),
393            redact_secrets(&skill.content).as_bytes(),
394        )?;
395        for (bucket, files) in [
396            ("scripts", &skill.scripts),
397            ("templates", &skill.templates),
398            ("examples", &skill.examples),
399        ] {
400            if files.is_empty() {
401                continue;
402            }
403            let bucket_root = skill_dir.join(bucket);
404            std::fs::create_dir_all(&bucket_root)?;
405            for file in files.iter() {
406                let Some(rel) = sanitize_skill_relative_path(&file.path) else {
407                    continue;
408                };
409                let target = bucket_root.join(&rel);
410                if let Some(parent) = target.parent() {
411                    std::fs::create_dir_all(parent)?;
412                }
413                std::fs::write(&target, redact_secrets(&file.content).as_bytes())?;
414            }
415        }
416    }
417
418    if let Ok(entries) = std::fs::read_dir(&skills_root) {
419        for entry in entries.flatten() {
420            let path = entry.path();
421            if path.is_dir()
422                && let Some(name) = path.file_name().and_then(|s| s.to_str())
423                && !retained.contains(name)
424            {
425                let _ = std::fs::remove_dir_all(&path);
426            }
427        }
428    }
429    Ok(())
430}
431
432/// Load `<memory_root>/MEMORY.md` and `<memory_root>/memory_summary.md`
433/// if present. Either can be `None` if the file does not exist.
434pub fn load_consolidated_artifacts(
435    memory_root: &Path,
436) -> std::io::Result<(Option<String>, Option<String>)> {
437    let memory_md = std::fs::read(memory_root.join(MEMORY_MD))
438        .ok()
439        .and_then(|b| String::from_utf8(b).ok());
440    let memory_summary = std::fs::read(memory_root.join(MEMORY_SUMMARY_MD))
441        .ok()
442        .and_then(|b| String::from_utf8(b).ok());
443    Ok((memory_md, memory_summary))
444}
445
446/// Parse a strict-JSON consolidation payload (Stage-2 model output)
447/// into the [`ConsolidationOutput`] shape. Strips ```json fences
448/// defensively.
449pub fn parse_consolidation_output(text: &str) -> Option<ConsolidationOutput> {
450    let trimmed = text
451        .trim()
452        .trim_start_matches("```json")
453        .trim_start_matches("```")
454        .trim_end_matches("```")
455        .trim();
456    serde_json::from_str(trimmed).ok()
457}
458
459/// Parse a strict-JSON Stage-1 payload.
460pub fn parse_stage1_output(text: &str) -> Option<Stage1OutputShape> {
461    let trimmed = text
462        .trim()
463        .trim_start_matches("```json")
464        .trim_start_matches("```")
465        .trim_end_matches("```")
466        .trim();
467    serde_json::from_str(trimmed).ok()
468}
469
470/// Stage-1 output JSON shape (omp `stage_one_system.md`).
471#[derive(Debug, Clone, serde::Deserialize)]
472pub struct Stage1OutputShape {
473    #[serde(default)]
474    pub rollout_summary: String,
475    #[serde(default)]
476    pub rollout_slug: Option<String>,
477    #[serde(default)]
478    pub raw_memory: String,
479}