Skip to main content

oxi/store/
memory_workers.rs

1//! SQLite-backed job queue + Phase 1 / Phase 2 workers for the
2//! autonomous memory pipeline (`memory_summary.rs`).
3//!
4//! This module is split out so the memory artifact surface
5//! (prompts, paths, redaction) stays in `memory_summary.rs` while
6//! the runtime machinery (SQLite schema, lease/heartbeat,
7//! LLM-backed extraction + consolidation) lives here.
8//!
9//! **Status**: skeletons only. The runtime spawn hook
10//! (`services::start_memory_pipeline`) will instantiate and drive
11//! these in the follow-up PR. Each worker is a pure function over
12//! the SQLite connection so it's unit-testable in isolation.
13
14#![allow(missing_docs)]
15use rusqlite::{Connection, params};
16use std::path::{Path, PathBuf};
17use std::sync::{Arc, LazyLock};
18
19#[allow(unused_imports)]
20use super::memory_summary::{
21    CONSOLIDATION_SYSTEM_PROMPT, CONSOLIDATION_USER_TEMPLATE, DEFAULT_GLOBAL_LEASE_SECONDS,
22    STAGE_ONE_SYSTEM_PROMPT, STAGE_ONE_USER_TEMPLATE,
23};
24
25// ── Database schema ──────────────────────────────────────────
26
27/// Initialize the SQLite schema (idempotent). Safe to call on every
28/// open. Adds three tables:
29///
30/// - `memory_threads` — registry of threads we've ever observed
31/// - `memory_stage1_outputs` — one row per (thread, run) LLM extraction
32/// - `memory_jobs` — per-project consolidation queue (with lease)
33pub fn init_schema(conn: &Connection) -> rusqlite::Result<()> {
34    conn.execute_batch(
35        "
36        CREATE TABLE IF NOT EXISTS memory_threads (
37            thread_id TEXT PRIMARY KEY,
38            cwd TEXT NOT NULL,
39            source_updated_at INTEGER NOT NULL,
40            last_extracted_at INTEGER
41        );
42
43        CREATE TABLE IF NOT EXISTS memory_stage1_outputs (
44            id INTEGER PRIMARY KEY AUTOINCREMENT,
45            thread_id TEXT NOT NULL,
46            cwd TEXT NOT NULL,
47            rollout_summary TEXT NOT NULL,
48            rollout_slug TEXT,
49            raw_memory TEXT NOT NULL,
50            created_at INTEGER NOT NULL,
51            source_updated_at INTEGER NOT NULL
52        );
53
54        CREATE INDEX IF NOT EXISTS idx_stage1_cwd
55            ON memory_stage1_outputs(cwd, created_at DESC);
56
57        CREATE TABLE IF NOT EXISTS memory_jobs (
58            id INTEGER PRIMARY KEY AUTOINCREMENT,
59            cwd TEXT NOT NULL,
60            kind TEXT NOT NULL,                    -- 'stage1' | 'global'
61            thread_id TEXT,                        -- NULL for global
62            ownership_token TEXT,
63            claimed_at INTEGER,
64            lease_until INTEGER,
65            last_error TEXT,
66            attempts INTEGER NOT NULL DEFAULT 0,
67            created_at INTEGER NOT NULL
68        );
69
70        CREATE INDEX IF NOT EXISTS idx_jobs_kind
71            ON memory_jobs(kind, created_at ASC);
72        ",
73    )
74}
75
76// ── Phase 1: per-session extraction ──────────────────────────
77
78/// A single session (thread) that is eligible for Phase 1
79/// processing.
80#[derive(Debug, Clone)]
81pub struct ThreadInfo {
82    pub thread_id: String,
83    pub cwd: String,
84    pub source_updated_at: i64,
85}
86
87/// Collect eligible threads from `sessions_dir` for the given cwd.
88/// `now` is the current Unix timestamp in seconds.
89pub fn collect_threads(
90    conn: &Connection,
91    sessions_dir: &Path,
92    cwd: &str,
93    now: i64,
94    max_age_days: i64,
95    min_idle_hours: i64,
96) -> rusqlite::Result<Vec<ThreadInfo>> {
97    // omp's equivalent: walks `<cwd>/<session_id>.jsonl`, parses
98    // each session header, applies the age/idle/limit filters,
99    // upserts into `memory_threads`. We re-export the deterministic
100    // SQL surface only — the JSONL walker lives in the next PR.
101    let max_age = now - max_age_days * 24 * 3600;
102    let min_idle = now - min_idle_hours * 3600;
103    let mut stmt = conn.prepare(
104        "SELECT thread_id, cwd, source_updated_at
105         FROM memory_threads
106         WHERE cwd = ?1
107           AND source_updated_at >= ?2
108           AND (last_extracted_at IS NULL OR last_extracted_at <= ?3)
109         ORDER BY source_updated_at DESC",
110    )?;
111    let rows = stmt
112        .query_map(params![cwd, max_age, min_idle], |r| {
113            Ok(ThreadInfo {
114                thread_id: r.get(0)?,
115                cwd: r.get(1)?,
116                source_updated_at: r.get(2)?,
117            })
118        })?
119        .collect::<rusqlite::Result<Vec<_>>>()?;
120    let _ = sessions_dir; // consumed in the next PR
121    Ok(rows)
122}
123
124/// Insert / update the row for a single thread observation.
125pub fn upsert_thread(conn: &Connection, info: &ThreadInfo) -> rusqlite::Result<()> {
126    conn.execute(
127        "INSERT INTO memory_threads (thread_id, cwd, source_updated_at)
128         VALUES (?1, ?2, ?3)
129         ON CONFLICT(thread_id) DO UPDATE SET
130           cwd = excluded.cwd,
131           source_updated_at = MAX(source_updated_at, excluded.source_updated_at)",
132        params![info.thread_id, info.cwd, info.source_updated_at],
133    )?;
134    Ok(())
135}
136
137/// Atomically claim one Stage 1 job. Returns the (thread_id, cwd)
138/// pair plus the ownership token if a job was claimed.
139pub fn claim_stage1_job(
140    conn: &Connection,
141    now: i64,
142) -> rusqlite::Result<Option<(String, String, String)>> {
143    // Begin → claim → return. The lease prevents two oxi processes
144    // from double-extracting the same thread.
145    conn.execute_batch("BEGIN")?;
146    let candidate: Option<(i64, String, String)> = conn
147        .query_row(
148            "SELECT id, thread_id, cwd
149             FROM memory_jobs
150             WHERE kind = 'stage1'
151               AND (claimed_at IS NULL OR lease_until < ?1)
152             ORDER BY created_at ASC
153             LIMIT 1",
154            params![now],
155            |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
156        )
157        .ok();
158    let Some((id, thread_id, cwd)) = candidate else {
159        conn.execute_batch("ROLLBACK")?;
160        return Ok(None);
161    };
162    let token = format!("{:x}-{}", std::process::id(), uuid::Uuid::new_v4().simple());
163    conn.execute(
164        "UPDATE memory_jobs
165         SET claimed_at = ?1,
166             lease_until = ?2,
167             ownership_token = ?3,
168             attempts = attempts + 1
169         WHERE id = ?4",
170        params![now, now + 60, token, id],
171    )?;
172    conn.execute_batch("COMMIT")?;
173    Ok(Some((thread_id, cwd, token)))
174}
175
176/// Insert the Stage 1 output for a thread and mark it extracted.
177#[allow(clippy::too_many_arguments)]
178pub fn write_stage1_output(
179    conn: &Connection,
180    thread_id: &str,
181    cwd: &str,
182    rollout_summary: &str,
183    rollout_slug: Option<&str>,
184    raw_memory: &str,
185    now: i64,
186    source_updated_at: i64,
187) -> rusqlite::Result<i64> {
188    conn.execute(
189        "INSERT INTO memory_stage1_outputs
190            (thread_id, cwd, rollout_summary, rollout_slug, raw_memory,
191             created_at, source_updated_at)
192         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
193        params![
194            thread_id,
195            cwd,
196            rollout_summary,
197            rollout_slug,
198            raw_memory,
199            now,
200            source_updated_at,
201        ],
202    )?;
203    let id = conn.last_insert_rowid();
204    conn.execute(
205        "UPDATE memory_threads
206         SET last_extracted_at = ?1
207         WHERE thread_id = ?2",
208        params![now, thread_id],
209    )?;
210    Ok(id)
211}
212
213// ── Phase 2: cross-session consolidation ─────────────────────
214
215/// Try to claim the global Phase 2 job for `cwd`. Returns
216/// `Some((token, lease))` when claimed; `None` when another oxi
217/// process already owns it.
218pub fn try_claim_phase2(
219    conn: &Connection,
220    cwd: &str,
221    now: i64,
222    lease_seconds: i64,
223) -> rusqlite::Result<Option<(String, i64)>> {
224    let token = format!("{:x}-{}", std::process::id(), uuid::Uuid::new_v4().simple());
225    let lease = now + lease_seconds;
226    let updated = conn.execute(
227        "UPDATE memory_jobs
228         SET ownership_token = ?1,
229             claimed_at = ?2,
230             lease_until = ?3,
231             attempts = attempts + 1
232         WHERE kind = 'global'
233           AND cwd = ?4
234           AND (lease_until IS NULL OR lease_until < ?5)",
235        params![token, now, lease, cwd, now],
236    )?;
237    if updated == 0 {
238        return Ok(None);
239    }
240    Ok(Some((token, lease)))
241}
242
243/// Refresh the heart-beat on a held Phase 2 lease.
244pub fn heartbeat_phase2(
245    conn: &Connection,
246    cwd: &str,
247    token: &str,
248    lease_seconds: i64,
249    now: i64,
250) -> rusqlite::Result<bool> {
251    let updated = conn.execute(
252        "UPDATE memory_jobs
253         SET lease_until = ?1
254         WHERE kind = 'global'
255           AND cwd = ?2
256           AND ownership_token = ?3",
257        params![now + lease_seconds, cwd, token],
258    )?;
259    Ok(updated > 0)
260}
261
262/// Release the Phase 2 lease (success).
263pub fn finish_phase2(conn: &Connection, cwd: &str, token: &str, now: i64) -> rusqlite::Result<()> {
264    conn.execute(
265        "UPDATE memory_jobs
266         SET lease_until = NULL, claimed_at = NULL, ownership_token = NULL,
267             last_error = NULL
268         WHERE kind = 'global' AND cwd = ?1 AND ownership_token = ?2",
269        params![cwd, token],
270    )?;
271    let _ = now; // currently unused; reserved for future stamp
272    Ok(())
273}
274
275/// Load all Stage 1 outputs for `cwd`, newest first, capped at `limit`.
276pub fn list_stage1_outputs(
277    conn: &Connection,
278    cwd: &str,
279    limit: usize,
280) -> rusqlite::Result<Vec<Stage1Row>> {
281    let mut stmt = conn.prepare(
282        "SELECT id, thread_id, rollout_summary, raw_memory, created_at
283         FROM memory_stage1_outputs
284         WHERE cwd = ?1
285         ORDER BY created_at DESC
286         LIMIT ?2",
287    )?;
288    let rows = stmt
289        .query_map(params![cwd, limit as i64], |r| {
290            Ok(Stage1Row {
291                id: r.get(0)?,
292                thread_id: r.get(1)?,
293                rollout_summary: r.get(2)?,
294                raw_memory: r.get(3)?,
295                created_at: r.get(4)?,
296            })
297        })?
298        .collect::<rusqlite::Result<Vec<_>>>()?;
299    Ok(rows)
300}
301
302/// One row from `memory_stage1_outputs`.
303#[derive(Debug, Clone)]
304pub struct Stage1Row {
305    pub id: i64,
306    pub thread_id: String,
307    pub rollout_summary: String,
308    pub raw_memory: String,
309    pub created_at: i64,
310}
311
312/// Build the user-turn text for Stage 1 by templating
313/// `STAGE_ONE_USER_TEMPLATE` with the session metadata + persistable
314/// items JSON.
315pub fn render_stage1_user(thread_id: &str, response_items_json: &str) -> String {
316    STAGE_ONE_USER_TEMPLATE
317        .replace("{{thread_id}}", thread_id)
318        .replace("{{response_items_json}}", response_items_json)
319}
320
321/// Build the user-turn text for Stage 2 from accumulated Stage 1 rows.
322pub fn render_stage2_user(raw_memories: &str, rollout_summaries: &str) -> String {
323    CONSOLIDATION_USER_TEMPLATE
324        .replace("{{raw_memories}}", raw_memories)
325        .replace("{{rollout_summaries}}", rollout_summaries)
326}
327
328/// Re-exported prompt constants for callers that prefer using the
329/// raw const directly (e.g. custom user templates).
330pub use super::memory_summary::STAGE_ONE_SYSTEM_PROMPT as STAGE1_SYSTEM_PROMPT;
331
332pub use super::memory_summary::CONSOLIDATION_SYSTEM_PROMPT as CONSOLIDATION_SYSTEM_PROMPT_EXPORT;
333
334const DEFAULT_STAGE1_RETRY_SECS: i64 = 120;
335const DEFAULT_PHASE2_RETRY_SECS: i64 = 180;
336
337/// Mark a stage1 job as failed with a retry delay.
338fn mark_stage1_failed(
339    conn: &Connection,
340    token: &str,
341    retry_secs: i64,
342    reason: &str,
343    now: i64,
344) -> rusqlite::Result<()> {
345    conn.execute(
346        "UPDATE memory_jobs
347         SET claimed_at = NULL,
348             lease_until = NULL,
349             ownership_token = NULL,
350             retry_at = ?1,
351             last_error = ?2
352         WHERE ownership_token = ?3",
353        params![now + retry_secs, reason, token],
354    )?;
355    Ok(())
356}
357
358/// Mark stage1 as succeeded with no output (empty extraction).
359fn mark_stage1_succeeded_no_output(
360    conn: &Connection,
361    token: &str,
362    now: i64,
363    _cwd: &str,
364) -> rusqlite::Result<()> {
365    conn.execute(
366        "UPDATE memory_jobs
367         SET claimed_at = NULL,
368             lease_until = NULL,
369             ownership_token = NULL,
370             status = 'done',
371             last_success_watermark = ?1
372         WHERE ownership_token = ?2",
373        params![now, token],
374    )?;
375    Ok(())
376}
377
378/// Mark phase2 as failed with retry.
379fn mark_global_phase2_failed(
380    conn: &Connection,
381    token: &str,
382    retry_secs: i64,
383    reason: &str,
384    now: i64,
385    _cwd: &str,
386) -> rusqlite::Result<()> {
387    conn.execute(
388        "UPDATE memory_jobs
389         SET claimed_at = NULL,
390             lease_until = NULL,
391             ownership_token = NULL,
392             retry_at = ?1,
393             last_error = ?2
394         WHERE ownership_token = ?3",
395        params![now + retry_secs, reason, token],
396    )?;
397    Ok(())
398}
399
400/// Mark phase2 as succeeded using finish_phase2.
401fn mark_global_phase2_succeeded(
402    conn: &Connection,
403    token: &str,
404    now: i64,
405    cwd: &str,
406) -> rusqlite::Result<()> {
407    finish_phase2(conn, cwd, token, now)
408}
409
410// ── Worker entry points ──────────────────────────────────────────
411
412/// Run one Stage 1 iteration: claim a job, call the LLM, persist the
413/// output. Returns `Ok(true)` if a job was processed; `Ok(false)`
414/// when there is nothing to do.
415pub async fn run_stage1_iteration(
416    conn: &Connection,
417    sessions_dir: &Path,
418    cwd: &str,
419    now: i64,
420    llm_provider: Option<&Arc<dyn oxi_ai::Provider>>,
421    llm_model: Option<&oxi_ai::Model>,
422) -> rusqlite::Result<bool> {
423    let (Some(provider), Some(model)) = (llm_provider, llm_model) else {
424        return Ok(false);
425    };
426    init_schema(conn)?;
427    let Some((thread_id, _cwd2, token)) = claim_stage1_job(conn, now)? else {
428        return Ok(false);
429    };
430
431    // Read the session JSONL for this thread.
432    let session_path = sessions_dir.join(format!("{thread_id}.jsonl"));
433    let session_content = match std::fs::read_to_string(&session_path) {
434        Ok(c) => c,
435        Err(e) => {
436            tracing::warn!(thread_id, error = %e, "stage 1: failed to read session");
437            mark_stage1_failed(conn, &token, DEFAULT_STAGE1_RETRY_SECS, &e.to_string(), now)?;
438            return Ok(true);
439        }
440    };
441
442    // Truncate to a reasonable size to avoid blowing context.
443    let truncated = if session_content.len() > 50_000 {
444        &session_content[..50_000]
445    } else {
446        &session_content
447    };
448
449    let user_prompt = render_stage1_user(&thread_id, truncated);
450    let system_prompt = STAGE1_SYSTEM_PROMPT;
451
452    // Call the LLM.
453    let llm_result = call_llm(provider, model, system_prompt, &user_prompt).await;
454
455    match llm_result {
456        Ok(response) => {
457            // Parse the JSON response.
458            let parsed = parse_stage1_output(&response);
459            match parsed {
460                Some((raw_memory, rollout_summary, rollout_slug)) => {
461                    if raw_memory.is_empty() && rollout_summary.is_empty() {
462                        mark_stage1_succeeded_no_output(conn, &token, now, cwd)?;
463                    } else {
464                        write_stage1_output(
465                            conn,
466                            &thread_id,
467                            cwd,
468                            &rollout_summary,
469                            rollout_slug.as_deref(),
470                            &raw_memory,
471                            now,
472                            now,
473                        )?;
474                    }
475                    tracing::info!(thread_id, "stage 1: extraction complete");
476                }
477                None => {
478                    tracing::warn!(thread_id, "stage 1: failed to parse LLM output");
479                    mark_stage1_failed(
480                        conn,
481                        &token,
482                        DEFAULT_STAGE1_RETRY_SECS,
483                        "unparseable LLM output",
484                        now,
485                    )?;
486                }
487            }
488            Ok(true)
489        }
490        Err(e) => {
491            tracing::warn!(thread_id, error = %e, "stage 1: LLM call failed");
492            mark_stage1_failed(conn, &token, DEFAULT_STAGE1_RETRY_SECS, &e, now)?;
493            Ok(true)
494        }
495    }
496}
497
498/// Run one Stage 2 iteration: claim the global job, collect Stage 1
499/// outputs, call the consolidation LLM, write artifacts.
500pub async fn run_stage2_iteration(
501    conn: &Connection,
502    memory_root: &Path,
503    cwd: &str,
504    now: i64,
505    llm_provider: Option<&Arc<dyn oxi_ai::Provider>>,
506    llm_model: Option<&oxi_ai::Model>,
507) -> rusqlite::Result<bool> {
508    let (Some(provider), Some(model)) = (llm_provider, llm_model) else {
509        return Ok(false);
510    };
511    let Some((token, _lease)) = try_claim_phase2(conn, cwd, now, DEFAULT_GLOBAL_LEASE_SECONDS)?
512    else {
513        return Ok(false);
514    };
515
516    // Collect Stage 1 outputs for this cwd.
517    let outputs = list_stage1_outputs(conn, cwd, 200)?;
518    if outputs.is_empty() {
519        mark_global_phase2_failed(
520            conn,
521            &token,
522            DEFAULT_PHASE2_RETRY_SECS,
523            "no stage1 outputs",
524            now,
525            cwd,
526        )?;
527        return Ok(true);
528    }
529
530    let raw_memories: Vec<String> = outputs.iter().map(|o| o.raw_memory.clone()).collect();
531    let rollout_summaries: Vec<String> =
532        outputs.iter().map(|o| o.rollout_summary.clone()).collect();
533    let user_prompt = render_stage2_user(
534        &raw_memories.join("\n---\n"),
535        &rollout_summaries.join("\n---\n"),
536    );
537    let llm_result = call_llm(
538        provider,
539        model,
540        CONSOLIDATION_SYSTEM_PROMPT_EXPORT,
541        &user_prompt,
542    )
543    .await;
544
545    match llm_result {
546        Ok(response) => {
547            let parsed = parse_consolidation_output(&response);
548            match parsed {
549                Some(consolidated) => {
550                    // Write artifacts atomically.
551                    let _ = std::fs::create_dir_all(memory_root);
552                    write_artifact(memory_root, "MEMORY.md", &consolidated.memory_md);
553                    write_artifact(
554                        memory_root,
555                        "memory_summary.md",
556                        &consolidated.memory_summary,
557                    );
558                    for skill in &consolidated.skills {
559                        let skill_dir = memory_root.join("skills").join(&skill.name);
560                        let _ = std::fs::create_dir_all(&skill_dir);
561                        write_artifact(&skill_dir, "SKILL.md", &skill.content);
562                    }
563                    mark_global_phase2_succeeded(conn, &token, now, cwd)?;
564                    tracing::info!(cwd, "stage 2: consolidation complete");
565                }
566                None => {
567                    mark_global_phase2_failed(
568                        conn,
569                        &token,
570                        DEFAULT_PHASE2_RETRY_SECS,
571                        "unparseable consolidation output",
572                        now,
573                        cwd,
574                    )?;
575                }
576            }
577            Ok(true)
578        }
579        Err(e) => {
580            mark_global_phase2_failed(conn, &token, DEFAULT_PHASE2_RETRY_SECS, &e, now, cwd)?;
581            Ok(true)
582        }
583    }
584}
585
586// ── LLM call helper ──────────────────────────────────────────────
587
588async fn call_llm(
589    provider: &Arc<dyn oxi_ai::Provider>,
590    model: &oxi_ai::Model,
591    system_prompt: &str,
592    user_prompt: &str,
593) -> Result<String, String> {
594    use futures::StreamExt;
595    use oxi_ai::{Context, Message, UserMessage};
596
597    let mut context = Context::new();
598    context.set_system_prompt(system_prompt);
599    context.add_message(Message::User(UserMessage::new(user_prompt)));
600    let mut text = String::new();
601    let mut stream = provider
602        .stream(model, &context, None)
603        .await
604        .map_err(|e| format!("provider stream error: {e}"))?;
605    while let Some(event) = stream.next().await {
606        match event {
607            oxi_ai::ProviderEvent::TextDelta { delta, .. } => text.push_str(&delta),
608            oxi_ai::ProviderEvent::Done { message, .. } if text.is_empty() => {
609                for block in &message.content {
610                    if let oxi_ai::ContentBlock::Text(t) = block {
611                        text = t.text.clone();
612                        break;
613                    }
614                }
615            }
616            _ => {}
617        }
618    }
619    Ok(text.trim().to_string())
620}
621
622// ── Output parsers ──────────────────────────────────────────────
623
624/// Parse Stage 1 JSON output: { rollout_summary, rollout_slug, raw_memory }.
625fn parse_stage1_output(text: &str) -> Option<(String, String, Option<String>)> {
626    // Strip markdown code fence if present.
627    let json_str = text
628        .strip_prefix("```json\n")
629        .or_else(|| text.strip_prefix("```\n"))
630        .and_then(|s| s.strip_suffix("\n```"))
631        .unwrap_or(text);
632
633    let v: serde_json::Value = serde_json::from_str(json_str).ok()?;
634    let raw_memory = v.get("raw_memory")?.as_str()?.to_string();
635    let rollout_summary = v.get("rollout_summary")?.as_str()?.to_string();
636    let rollout_slug = v
637        .get("rollout_slug")
638        .and_then(|s| s.as_str())
639        .map(|s| s.to_string());
640    Some((raw_memory, rollout_summary, rollout_slug))
641}
642
643struct ConsolidatedOutput {
644    memory_md: String,
645    memory_summary: String,
646    skills: Vec<ConsolidationSkill>,
647}
648
649struct ConsolidationSkill {
650    name: String,
651    content: String,
652}
653
654/// Parse Stage 2 consolidation JSON output.
655fn parse_consolidation_output(text: &str) -> Option<ConsolidatedOutput> {
656    let json_str = text
657        .strip_prefix("```json\n")
658        .or_else(|| text.strip_prefix("```\n"))
659        .and_then(|s| s.strip_suffix("\n```"))
660        .unwrap_or(text);
661
662    let v: serde_json::Value = serde_json::from_str(json_str).ok()?;
663    let memory_md = v.get("memory_md")?.as_str()?.to_string();
664    let memory_summary = v.get("memory_summary")?.as_str()?.to_string();
665
666    let skills = v
667        .get("skills")
668        .and_then(|s| s.as_array())
669        .map(|arr| {
670            arr.iter()
671                .filter_map(|skill| {
672                    Some(ConsolidationSkill {
673                        name: skill.get("name")?.as_str()?.to_string(),
674                        content: skill.get("content")?.as_str()?.to_string(),
675                    })
676                })
677                .collect()
678        })
679        .unwrap_or_default();
680
681    Some(ConsolidatedOutput {
682        memory_md,
683        memory_summary,
684        skills,
685    })
686}
687
688/// Atomically write a file (temp + rename pattern).
689fn write_artifact(dir: &Path, name: &str, content: &str) {
690    let path = dir.join(name);
691    let tmp = dir.join(format!("{name}.tmp"));
692    if std::fs::write(&tmp, content).is_ok() {
693        let _ = std::fs::rename(&tmp, &path);
694    }
695}
696
697/// Owned `MemoryDb` connection helper. Used by `services` to spawn
698/// the worker without exposing the raw `Connection`.
699///
700/// `pipeline.db` is a rebuildable job-queue cache — on network filesystems
701/// we use the per-host sibling (`pipeline.h-<host>.db`) so old binaries
702/// that would flip a shared DB back to WAL cannot corrupt the no-WAL
703/// invariant. Each host starts fresh; the pipeline rebuilds state from
704/// durable stores on next run.
705pub fn open_db(path: &Path) -> rusqlite::Result<Connection> {
706    let mode = oxi_mnemopi::journal::JournalMode::for_db_path(path);
707    let effective = mode.per_host_db_path(path);
708    let conn = Connection::open(&effective)?;
709    conn.execute_batch(&format!(
710        "PRAGMA journal_mode = {};
711         PRAGMA busy_timeout = {};
712         PRAGMA foreign_keys = ON;",
713        mode.as_str(),
714        mode.busy_timeout_ms()
715    ))?;
716    init_schema(&conn)?;
717    Ok(conn)
718}
719
720/// A lazily-evaluated path for the pipeline's working DB.
721pub static PIPELINE_DB_PATH: LazyLock<Option<PathBuf>> = LazyLock::new(|| None);
722
723/// Resolve where the pipeline DB should live. Mirrors
724/// `MemoryBackend` placement: `<home>/memory/pipeline.db`.
725pub fn pipeline_db_path(home: &Path) -> PathBuf {
726    home.join("memory").join("pipeline.db")
727}