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
334// ── Worker entry points (skeletons; LLM call sits in next PR) ──
335
336/// Run one Stage 1 iteration: claim a job, call the LLM, persist the
337/// output. Returns `Ok(true)` if a job was processed; `Ok(false)`
338/// when there is nothing to do.
339///
340/// The actual `Oxi` call is out of scope for this version: pass
341/// `None` for the LLM client and the worker becomes a no-op that
342/// logs once and skips, so the rest of the integration can be wired
343/// and tested.
344pub async fn run_stage1_iteration(
345    conn: &Connection,
346    sessions_dir: &Path,
347    cwd: &str,
348    now: i64,
349    llm_provider: Option<&Arc<dyn oxi_ai::Provider>>,
350    llm_model: Option<&oxi_ai::Model>,
351) -> rusqlite::Result<bool> {
352    let (Some(_provider), Some(_model)) = (llm_provider, llm_model) else {
353        tracing::debug!("memory_summary: stage 1 skipped (no LLM wired)");
354        let _ = init_schema(conn);
355        return Ok(false);
356    };
357    init_schema(conn)?;
358    let Some((thread_id, _cwd2, _token)) = claim_stage1_job(conn, now)? else {
359        return Ok(false);
360    };
361    let _ = (sessions_dir, cwd);
362    tracing::debug!(thread_id, "stage 1: claimed job (LLM call pending wiring)");
363    Ok(false)
364}
365
366pub async fn run_stage2_iteration(
367    conn: &Connection,
368    memory_root: &Path,
369    cwd: &str,
370    now: i64,
371    llm_provider: Option<&Arc<dyn oxi_ai::Provider>>,
372    llm_model: Option<&oxi_ai::Model>,
373) -> rusqlite::Result<bool> {
374    let (Some(_provider), Some(_model)) = (llm_provider, llm_model) else {
375        tracing::debug!("memory_summary: stage 2 skipped (no LLM wired)");
376        return Ok(false);
377    };
378    let Some((_token, _lease)) = try_claim_phase2(conn, cwd, now, DEFAULT_GLOBAL_LEASE_SECONDS)?
379    else {
380        return Ok(false);
381    };
382    let _ = memory_root;
383    tracing::debug!(cwd, "stage 2: claimed lease (LLM call pending wiring)");
384    Ok(false)
385}
386
387/// Owned `MemoryDb` connection helper. Used by `services` to spawn
388/// the worker without exposing the raw `Connection`.
389pub fn open_db(path: &Path) -> rusqlite::Result<Connection> {
390    let conn = Connection::open(path)?;
391    conn.execute_batch(
392        "PRAGMA journal_mode = WAL;
393         PRAGMA busy_timeout = 5000;
394         PRAGMA foreign_keys = ON;",
395    )?;
396    init_schema(&conn)?;
397    Ok(conn)
398}
399
400/// A lazily-evaluated path for the pipeline's working DB.
401pub static PIPELINE_DB_PATH: LazyLock<Option<PathBuf>> = LazyLock::new(|| None);
402
403/// Resolve where the pipeline DB should live. Mirrors
404/// `MemoryBackend` placement: `<home>/memory/pipeline.db`.
405pub fn pipeline_db_path(home: &Path) -> PathBuf {
406    home.join("memory").join("pipeline.db")
407}