1#![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
25pub 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#[derive(Debug, Clone)]
81pub struct ThreadInfo {
82 pub thread_id: String,
83 pub cwd: String,
84 pub source_updated_at: i64,
85}
86
87pub 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 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; Ok(rows)
122}
123
124pub 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
137pub fn claim_stage1_job(
140 conn: &Connection,
141 now: i64,
142) -> rusqlite::Result<Option<(String, String, String)>> {
143 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#[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
213pub 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
243pub 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
262pub 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; Ok(())
273}
274
275pub 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#[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
312pub 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
321pub 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
328pub 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
334pub 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
387pub 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
400pub static PIPELINE_DB_PATH: LazyLock<Option<PathBuf>> = LazyLock::new(|| None);
402
403pub fn pipeline_db_path(home: &Path) -> PathBuf {
406 home.join("memory").join("pipeline.db")
407}