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
334const DEFAULT_STAGE1_RETRY_SECS: i64 = 120;
335const DEFAULT_PHASE2_RETRY_SECS: i64 = 180;
336
337fn 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
358fn 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
378fn 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
400fn 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
410pub 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 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 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 let llm_result = call_llm(provider, model, system_prompt, &user_prompt).await;
454
455 match llm_result {
456 Ok(response) => {
457 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
498pub 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 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 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
586async 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
622fn parse_stage1_output(text: &str) -> Option<(String, String, Option<String>)> {
626 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
654fn 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
688fn 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
697pub 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
720pub static PIPELINE_DB_PATH: LazyLock<Option<PathBuf>> = LazyLock::new(|| None);
722
723pub fn pipeline_db_path(home: &Path) -> PathBuf {
726 home.join("memory").join("pipeline.db")
727}