oxi-cli 0.54.0

Terminal-based AI coding assistant — multi-provider, streaming-first, extensible
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
//! Autonomous memory pipeline (omp `local-backend` port).
//!
//! Implements the documented two-phase background job used by the
//! `local` memory backend in omp:
//!
//! - **Phase 1** (per-session extraction): for each past session
//!   that has changed since it was last processed, an LLM is asked
//!   to distill durable signal. The output is a single small JSON
//!   record per thread.
//! - **Phase 2** (cross-session consolidation): periodically, the
//!   extraction outputs are fed to a second LLM pass that produces
//!   three artifacts on disk under `<memory-root>/`:
//!   - `MEMORY.md` — long-term memory document
//!   - `memory_summary.md` — compact summary injected at session start
//!   - `skills/<name>/SKILL.md` (+ optional scripts/templates/examples)
//!
//! This module is intentionally **self-contained**: it owns its own
//! SQLite tables (via the same `MemoryDb` connection as the rest of
//! the memory store when present, otherwise a separate file) so the
//! pipeline can run independently of `MemoryBackend`. The background
//! spawn hook lives in `services::start_memory_pipeline` (wired from
//! `bootstrap`).
//!
//! Code-only reference ports (MIT):
//!   - `packages/coding-agent/src/memories/index.ts`
//!   - `packages/coding-agent/src/memories/storage.ts`
//!   - `packages/coding-agent/src/memory-backend/local-backend.ts`
//!   - `packages/coding-agent/src/prompts/memories/*.md`
//!   - `packages/coding-agent/src/internal-urls/memory-protocol.ts`
//!
//! Phases 1 and 2 require an LLM `Model` + `Provider` (oxi-ai). When
//! neither is available, the worker logs once and skips — the
//! pipeline never panics and never blocks session boot.
//!
//! All helpers here are *functional* (no global state, no side
//! effects on import). The runtime spawn is opt-in.
#![allow(missing_docs)]

use std::path::{Path, PathBuf};
use std::sync::LazyLock;

// ── omp prompt ports (MIT) ─────────────────────────────────────
//
// Each prompt is verbatim from
// `packages/coding-agent/src/prompts/memories/*.md` (or paraphrased
// for the {{}}-templated gaps the original uses for now()). Variables
// are spelled out as Rust `const`s with a separate runtime
// substitution helper to keep the prompts easy to diff against the
// upstream source.

/// System prompt for per-session extraction (omp `stage_one_system.md`).
pub const STAGE_ONE_SYSTEM_PROMPT: &str = "\
You are the memory-stage-one extractor.\n\
\n\
You MUST return strict JSON only — no markdown, no commentary.\n\
\n\
Extraction goals:\n\
- You MUST distill reusable durable knowledge from rollout history.\n\
- You MUST keep concrete technical signal (constraints, decisions, workflows, pitfalls, resolved failures).\n\
- You NEVER include transient chatter or low-signal noise.\n\
\n\
Output contract (required keys):\n\
{\n\
  \"rollout_summary\": \"string\",\n\
  \"rollout_slug\": \"string | null\",\n\
  \"raw_memory\": \"string\"\n\
}\n\
\n\
Rules:\n\
- rollout_summary: compact synopsis of what future runs should remember.\n\
- rollout_slug: short lowercase slug (letters/numbers/_), or null.\n\
- raw_memory: detailed durable memory blocks with enough context to reuse.\n\
- If no durable signal exists, you MUST return empty strings for rollout_summary/raw_memory and null rollout_slug.";

/// User-turn template for per-session extraction (omp `stage_one_input.md`).
pub const STAGE_ONE_USER_TEMPLATE: &str = "\
thread_id: {{thread_id}}\n\
\n\
Persistable response items (JSON):\n\
{{response_items_json}}\n\
\n\
You MUST extract durable memory now.";

/// System prompt for cross-session consolidation (omp `consolidation_system.md`).
pub const CONSOLIDATION_SYSTEM_PROMPT: &str = "\
You are the memory-stage-two consolidator.\n\
\n\
Follow the user-provided consolidation task exactly.\n\
Return strict JSON only — no markdown, no commentary.";

/// User-turn template for cross-session consolidation (omp `consolidation.md`).
/// Variables: `{{raw_memories}}` (joined string of stage1 outputs),
/// `{{rollout_summaries}}` (joined string).
pub const CONSOLIDATION_USER_TEMPLATE: &str = "\
Memory consolidation agent.\n\
Memory root: memory://root\n\
Input corpus (raw memories):\n\
{{raw_memories}}\n\
Input corpus (rollout summaries):\n\
{{rollout_summaries}}\n\
Produce strict JSON only with this schema — you NEVER include any other output:\n\
{\n\
  \"memory_md\": \"string\",\n\
  \"memory_summary\": \"string\",\n\
  \"skills\": [\n\
    {\n\
      \"name\": \"string\",\n\
      \"content\": \"string\",\n\
      \"scripts\": [{ \"path\": \"string\", \"content\": \"string\" }],\n\
      \"templates\": [{ \"path\": \"string\", \"content\": \"string\" }],\n\
      \"examples\": [{ \"path\": \"string\", \"content\": \"string\" }]\n\
    }\n\
  ]\n\
}\n\
\n\
Requirements:\n\
- memory_md: long-term memory document.\n\
- memory_summary: prompt-time memory guidance.\n\
- skills: reusable playbooks. Empty array allowed.\n\
- skill.name maps to skills/<name>/.\n\
- skill.content maps to skills/<name>/SKILL.md.\n\
- scripts/templates/examples: optional. Each entry MUST write to skills/<name>/<bucket>/<path>.\n\
- Only include files worth keeping long-term. Omit stale assets so they are pruned.\n\
- Preserve useful prior themes. Remove stale or contradictory guidance.\n\
- Treat memory as advisory: current repository state wins.";

/// Read-path prompt (omp `read-path.md`). Compactly injected at
/// session start so the agent treats memory as advisory context.
pub const READ_PATH_PROMPT: &str = "\
# Memory Guidance\n\
Memory root: memory://root\n\
Operational rules:\n\
1) Read `memory://root/memory_summary.md` first.\n\
2) If needed, inspect `memory://root/MEMORY.md` and `memory://root/skills/<name>/SKILL.md`.\n\
3) Trust memory for heuristics and process context. Trust current repo files, runtime output, and user instruction for factual state and final decisions.\n\
4) 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\
5) If memory disagrees with repo state or user instruction, treat memory as stale: proceed with corrected behavior, then update/regenerate memory artifacts.\n\
6) Escalate confidence only after repository verification. Memory alone is NEVER sufficient proof.\n\
{{memory_summary_block}}{{learned_block}}";

/// Suggested rendered block when a `memory_summary.md` exists.
pub const READ_PATH_SUMMARY_TEMPLATE: &str = "\
Memory summary:\n\
{{memory_summary}}\n\
";

/// Suggested rendered block when `learned.md` exists.
pub const READ_PATH_LEARNED_TEMPLATE: &str = "\
Learned lessons (captured via the `learn` tool; durable but may be stale — verify against the repo before relying on them):\n\
{{learned}}\n\
";

// ── Tweakable knobs (mirror omp's `MemoryRuntimeConfig`) ────────

/// Sessions older than this are not processed by Phase 1.
pub const DEFAULT_MAX_ROLLOUT_AGE_DAYS: i64 = 30;
/// Sessions active more recently than this are skipped (still in use).
pub const DEFAULT_MIN_ROLLOUT_IDLE_HOURS: i64 = 12;
/// Hard cap on the number of sessions processed per startup.
pub const DEFAULT_MAX_ROLLOUTS_PER_STARTUP: usize = 64;
/// Sum of `memory_summary.md` kept under this many bytes is read into
/// the system prompt directly.
pub const DEFAULT_SUMMARY_INJECTION_TOKEN_LIMIT: usize = 5_000;
/// Lease seconds for Phase 2 ownership tokens (prevents two oxi
/// processes from double-consolidating).
pub const DEFAULT_GLOBAL_LEASE_SECONDS: i64 = 60;
/// Phase 2 heartbeat refresh interval (must be < lease).
pub const DEFAULT_GLOBAL_HEARTBEAT_SECONDS: i64 = 30;
/// Token cap passed to the model for Stage 1 / Stage 2 inputs.
pub const DEFAULT_MODEL_TOKEN_BUDGET: usize = 8_000;

// ── Secret redaction (lazy-compiled, regex-free) ───────────────

/// Crude secret patterns to redact from written `MEMORY.md` /
/// `memory_summary.md`.
///
/// We use a single linear scan per line against a small set of
/// byte-prefix matchers; that's plenty for guarding accidental
/// token commits. The pattern set is a `LazyLock<Vec<SecretPattern>>`
/// so the lookup table isn't rebuilt per call.
struct SecretPattern {
    /// Stable id for diagnostics.
    name: &'static str,
    /// Byte prefix to match at the start of the secret token.
    prefix: &'static str,
}

static SECRET_PATTERNS: LazyLock<Vec<SecretPattern>> = LazyLock::new(|| {
    vec![
        SecretPattern {
            name: "openai",
            prefix: "sk-",
        },
        SecretPattern {
            name: "anthropic",
            prefix: "sk-ant-",
        },
        SecretPattern {
            name: "github_pat",
            prefix: "ghp_",
        },
        SecretPattern {
            name: "github_oauth",
            prefix: "gho_",
        },
        SecretPattern {
            name: "google_api",
            prefix: "AIza",
        },
        SecretPattern {
            name: "slack",
            prefix: "xoxb-",
        },
    ]
});

/// Redact obvious token patterns from a single line. Returns the
/// redacted line unchanged if no patterns match.
pub fn redact_line(line: &str) -> String {
    let mut out = String::with_capacity(line.len());
    let mut rest = line;
    while !rest.is_empty() {
        let mut matched = false;
        for pat in SECRET_PATTERNS.iter() {
            if let Some(idx) = rest.find(pat.prefix) {
                // Token-char suffix until the next whitespace / quote.
                let after = &rest[idx + pat.prefix.len()..];
                let tail = after
                    .find(|c: char| c.is_whitespace() || c == '"' || c == '\'')
                    .unwrap_or(after.len());
                let secret_len = pat.prefix.len() + tail;
                out.push_str(&rest[..idx]);
                out.push_str(&format!("[REDACTED:{}]", pat.name));
                rest = &rest[idx + secret_len..];
                matched = true;
                break;
            }
        }
        if !matched {
            out.push_str(rest);
            break;
        }
    }
    out
}

/// Redact secrets across a multi-line string.
pub fn redact_secrets(text: &str) -> String {
    let mut out = String::with_capacity(text.len());
    for line in text.split_inclusive('\n') {
        out.push_str(&redact_line(line));
    }
    out
}

// ── Path / scope helpers ──────────────────────────────────────

/// Resolve `<home>/.oxi/memory/<encoded-cwd>/` for the current cwd.
/// Mirrors `encodeProjectPath` in omp's `index.ts`:
///   `let encoded = "--" + cwd.trim_start_matches(['/','\\']).replace(['/','\\',':'], "-") + "--";`
#[allow(clippy::manual_pattern_char_comparison)]
pub fn memory_root(home: &Path, cwd: &str) -> PathBuf {
    let stripped = cwd.trim_start_matches(|c: char| c == '/' || c == '\\');
    let encoded = format!("--{}--", stripped.replace(['/', '\\', ':'], "-"));
    home.join("memory").join(encoded)
}

/// Filenames of the canonical artifacts under `<memory-root>/`.
pub const MEMORY_MD: &str = "MEMORY.md";
pub const MEMORY_SUMMARY_MD: &str = "memory_summary.md";
pub const LEARNED_MD: &str = "learned.md";

/// Sanitize a model-supplied skill name into a safe directory name.
pub fn sanitize_skill_name(name: &str) -> String {
    name.chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
                c.to_ascii_lowercase()
            } else {
                '-'
            }
        })
        .collect()
}

/// Sanitize a path within a `skills/<bucket>/<path>` so it cannot
/// escape the bucket root.
pub fn sanitize_skill_relative_path(raw: &str) -> Option<String> {
    let mut parts: Vec<&str> = raw
        .split('/')
        .filter(|s| !s.is_empty() && *s != "." && *s != "..")
        .collect();
    if parts.is_empty() {
        return None;
    }
    parts.sort();
    Some(parts.join("/"))
}

// ── Helpers (kept tiny; the hard work lives in workers.rs) ─────

/// Render `read-path.md` with optional summary and learned blocks.
///
/// If both `summary` and `learned` are `Some`, both blocks are
/// emitted; if either is `None`, the corresponding placeholder is
/// dropped so we don't ship literal `{{}}` to the agent.
pub fn render_read_path(summary: Option<&str>, learned: Option<&str>) -> String {
    let summary_block = summary
        .map(|s| READ_PATH_SUMMARY_TEMPLATE.replace("{{memory_summary}}", s))
        .unwrap_or_default();
    let learned_block = learned
        .map(|s| READ_PATH_LEARNED_TEMPLATE.replace("{{learned}}", s))
        .unwrap_or_default();
    READ_PATH_PROMPT
        .replace("{{memory_summary_block}}", &summary_block)
        .replace("{{learned_block}}", &learned_block)
}

// Phase 1 + Phase 2 worker functions + SQLite job queue live in
// `memory_workers.rs` to keep this file focused on the artifact
// surface. They are intentionally out of scope of this version;
// `services::start_memory_pipeline` is the wiring point that the
// follow-up PR will hook them up through.

// ── Disk writer (consolidated artifact surface) ────────────────

/// A consolidated skill as supplied by the Stage-2 model.
#[derive(Debug, Clone, serde::Deserialize)]
pub struct ConsolidationSkill {
    pub name: String,
    pub content: String,
    #[serde(default)]
    pub scripts: Vec<ConsolidationFile>,
    #[serde(default)]
    pub templates: Vec<ConsolidationFile>,
    #[serde(default)]
    pub examples: Vec<ConsolidationFile>,
}

/// One file under `skills/<name>/<bucket>/<path>`.
#[derive(Debug, Clone, serde::Deserialize)]
pub struct ConsolidationFile {
    pub path: String,
    pub content: String,
}

/// JSON payload returned by the Stage-2 LLM.
#[derive(Debug, Clone, serde::Deserialize)]
pub struct ConsolidationOutput {
    #[serde(default)]
    pub memory_md: String,
    #[serde(default)]
    pub memory_summary: String,
    #[serde(default)]
    pub skills: Vec<ConsolidationSkill>,
}

/// Write the three consolidation artifacts under `memory_root`.
///
/// All written text is run through [`redact_secrets`] before being
/// persisted. Existing `skills/*` directories the Stage-2 output
/// does NOT mention are pruned, mirroring omp's
/// `cleanupConsolidatedArtifacts`.
pub fn write_consolidation_artifacts(
    memory_root: &Path,
    out: &ConsolidationOutput,
) -> std::io::Result<()> {
    std::fs::create_dir_all(memory_root)?;

    let memory_md = redact_secrets(&out.memory_md);
    let summary = redact_secrets(&out.memory_summary);

    let memory_path = memory_root.join(MEMORY_MD);
    let summary_path = memory_root.join(MEMORY_SUMMARY_MD);
    std::fs::write(&memory_path, memory_md.as_bytes())?;
    std::fs::write(&summary_path, summary.as_bytes())?;

    let skills_root = memory_root.join("skills");
    std::fs::create_dir_all(&skills_root)?;

    let mut retained: std::collections::HashSet<String> = std::collections::HashSet::new();
    for skill in &out.skills {
        let dir_name = sanitize_skill_name(&skill.name);
        if dir_name.is_empty() || dir_name == "-" {
            continue;
        }
        let skill_dir = skills_root.join(&dir_name);
        std::fs::create_dir_all(&skill_dir)?;
        retained.insert(dir_name.clone());

        std::fs::write(
            skill_dir.join("SKILL.md"),
            redact_secrets(&skill.content).as_bytes(),
        )?;
        for (bucket, files) in [
            ("scripts", &skill.scripts),
            ("templates", &skill.templates),
            ("examples", &skill.examples),
        ] {
            if files.is_empty() {
                continue;
            }
            let bucket_root = skill_dir.join(bucket);
            std::fs::create_dir_all(&bucket_root)?;
            for file in files.iter() {
                let Some(rel) = sanitize_skill_relative_path(&file.path) else {
                    continue;
                };
                let target = bucket_root.join(&rel);
                if let Some(parent) = target.parent() {
                    std::fs::create_dir_all(parent)?;
                }
                std::fs::write(&target, redact_secrets(&file.content).as_bytes())?;
            }
        }
    }

    if let Ok(entries) = std::fs::read_dir(&skills_root) {
        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir()
                && let Some(name) = path.file_name().and_then(|s| s.to_str())
                && !retained.contains(name)
            {
                let _ = std::fs::remove_dir_all(&path);
            }
        }
    }
    Ok(())
}

/// Load `<memory_root>/MEMORY.md` and `<memory_root>/memory_summary.md`
/// if present. Either can be `None` if the file does not exist.
pub fn load_consolidated_artifacts(
    memory_root: &Path,
) -> std::io::Result<(Option<String>, Option<String>)> {
    let memory_md = std::fs::read(memory_root.join(MEMORY_MD))
        .ok()
        .and_then(|b| String::from_utf8(b).ok());
    let memory_summary = std::fs::read(memory_root.join(MEMORY_SUMMARY_MD))
        .ok()
        .and_then(|b| String::from_utf8(b).ok());
    Ok((memory_md, memory_summary))
}

/// Parse a strict-JSON consolidation payload (Stage-2 model output)
/// into the [`ConsolidationOutput`] shape. Strips ```json fences
/// defensively.
pub fn parse_consolidation_output(text: &str) -> Option<ConsolidationOutput> {
    let trimmed = text
        .trim()
        .trim_start_matches("```json")
        .trim_start_matches("```")
        .trim_end_matches("```")
        .trim();
    serde_json::from_str(trimmed).ok()
}

/// Parse a strict-JSON Stage-1 payload.
pub fn parse_stage1_output(text: &str) -> Option<Stage1OutputShape> {
    let trimmed = text
        .trim()
        .trim_start_matches("```json")
        .trim_start_matches("```")
        .trim_end_matches("```")
        .trim();
    serde_json::from_str(trimmed).ok()
}

/// Stage-1 output JSON shape (omp `stage_one_system.md`).
#[derive(Debug, Clone, serde::Deserialize)]
pub struct Stage1OutputShape {
    #[serde(default)]
    pub rollout_summary: String,
    #[serde(default)]
    pub rollout_slug: Option<String>,
    #[serde(default)]
    pub raw_memory: String,
}