From 3b309ccbc8cf238b16a1f71d36da69140914fcad Mon Sep 17 00:00:00 2001 From: "ilblackdragon@gmail.com" Date: Mon, 23 Mar 2026 22:11:03 -0700 Subject: [PATCH] feat(bridge): persist reflection docs to workspace for cross-session learning Replaces InMemoryStore with HybridStore: - Ephemeral data (threads, steps, events, leases) stays in-memory - MemoryDocs (lessons, specs, playbooks from reflection) persist to the workspace at engine/docs/{type}/{id}.json On engine init, load_docs_from_workspace() reads existing docs back into the in-memory cache. This means: - Lessons learned in session 1 are available in session 2 - The RetrievalEngine injects relevant past lessons into new threads - The engine genuinely improves over time as reflection accumulates Workspace paths: engine/docs/lessons/{uuid}.json engine/docs/specs/{uuid}.json engine/docs/playbooks/{uuid}.json engine/docs/summaries/{uuid}.json engine/docs/issues/{uuid}.json No new database tables. Uses existing workspace write/read/list. workspace() accessor widened to pub(crate). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/agent/agent_loop.rs | 2 +- src/bridge/router.rs | 9 ++- src/bridge/store_adapter.rs | 132 +++++++++++++++++++++++++++++------- 3 files changed, 113 insertions(+), 30 deletions(-) diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index dcfd1bd2..ab1c0e13 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -300,7 +300,7 @@ impl Agent { &self.deps.tools } - pub(super) fn workspace(&self) -> Option<&Arc> { + pub(crate) fn workspace(&self) -> Option<&Arc> { self.deps.workspace.as_ref() } diff --git a/src/bridge/router.rs b/src/bridge/router.rs index c49bcac6..02fc3a57 100644 --- a/src/bridge/router.rs +++ b/src/bridge/router.rs @@ -13,7 +13,7 @@ use ironclaw_engine::{ use crate::agent::Agent; use crate::bridge::effect_adapter::EffectBridgeAdapter; use crate::bridge::llm_adapter::LlmBridgeAdapter; -use crate::bridge::store_adapter::InMemoryStore; +use crate::bridge::store_adapter::HybridStore; use crate::channels::{IncomingMessage, StatusUpdate}; use crate::error::Error; @@ -37,7 +37,7 @@ struct EngineState { conversation_manager: ConversationManager, effect_adapter: Arc, #[allow(dead_code)] - store: Arc, + store: Arc, default_project_id: ironclaw_engine::ProjectId, /// Currently pending approval (if any). pending_approval: RwLock>, @@ -74,7 +74,10 @@ async fn get_or_init_engine(agent: &Agent) -> Result<(), Error> { agent.hooks().clone(), )); - let store = Arc::new(InMemoryStore::new()); + let store = Arc::new(HybridStore::new(agent.workspace().cloned())); + + // Load existing reflection docs from workspace (lessons from prior sessions) + store.load_docs_from_workspace().await; // Build capability registry from available tools let mut capabilities = CapabilityRegistry::new(); diff --git a/src/bridge/store_adapter.rs b/src/bridge/store_adapter.rs index b3f7677a..5b551f20 100644 --- a/src/bridge/store_adapter.rs +++ b/src/bridge/store_adapter.rs @@ -1,55 +1,132 @@ -//! In-memory store adapter — implements `ironclaw_engine::Store` without database tables. +//! Hybrid store adapter — in-memory for ephemeral data, workspace for durable knowledge. //! -//! Phase 6: threads and state live in memory during execution. Persistent -//! storage comes in Phase 7 when we add database migrations. +//! Threads, steps, events, and leases are ephemeral (per-session). +//! MemoryDocs (lessons, specs, playbooks from reflection) persist to the +//! workspace so the engine learns across restarts. use std::collections::HashMap; +use std::sync::Arc; use tokio::sync::RwLock; +use tracing::debug; use ironclaw_engine::{ - CapabilityLease, DocId, EngineError, LeaseId, MemoryDoc, Project, ProjectId, Step, Store, - Thread, ThreadEvent, ThreadId, ThreadState, + CapabilityLease, DocId, DocType, EngineError, LeaseId, MemoryDoc, Project, ProjectId, Step, + Store, Thread, ThreadEvent, ThreadId, ThreadState, types::mission::{Mission, MissionId, MissionStatus}, }; -/// In-memory implementation of the engine's `Store` trait. -/// -/// All state is discarded when the agent process restarts. This is -/// sufficient for Phase 6 (proving the engine works end-to-end). -pub struct InMemoryStore { +use crate::workspace::Workspace; + +/// Workspace path prefix for engine memory docs. +const ENGINE_DOCS_PREFIX: &str = "engine/docs"; + +/// Hybrid store: in-memory for session data, workspace for durable knowledge. +pub struct HybridStore { + // ── Ephemeral (in-memory, per-session) ── threads: RwLock>, steps: RwLock>>, events: RwLock>>, projects: RwLock>, - docs: RwLock>, leases: RwLock>, missions: RwLock>, + + // ── Durable (workspace-backed, survives restarts) ── + /// In-memory cache of docs (always in sync with workspace). + docs: RwLock>, + /// Workspace for persistent storage. None if workspace unavailable. + workspace: Option>, } -impl InMemoryStore { - pub fn new() -> Self { +impl HybridStore { + pub fn new(workspace: Option>) -> Self { Self { threads: RwLock::new(HashMap::new()), steps: RwLock::new(HashMap::new()), events: RwLock::new(HashMap::new()), projects: RwLock::new(HashMap::new()), - docs: RwLock::new(HashMap::new()), leases: RwLock::new(HashMap::new()), missions: RwLock::new(HashMap::new()), + docs: RwLock::new(HashMap::new()), + workspace, + } + } + + /// Load existing docs from workspace on startup. + pub async fn load_docs_from_workspace(&self) { + let Some(ref ws) = self.workspace else { + return; + }; + + // List all engine doc files + let entries = match ws.list(ENGINE_DOCS_PREFIX).await { + Ok(entries) => entries, + Err(e) => { + debug!("no engine docs in workspace: {e}"); + return; + } + }; + + let mut loaded = 0; + for entry in &entries { + if entry.is_directory || !entry.path.ends_with(".json") { + continue; + } + match ws.read(&entry.path).await { + Ok(ws_doc) => { + if let Ok(doc) = serde_json::from_str::(&ws_doc.content) { + self.docs.write().await.insert(doc.id, doc); + loaded += 1; + } + } + Err(e) => { + debug!(path = %entry.path, "failed to read engine doc: {e}"); + } + } + } + + if loaded > 0 { + debug!(loaded, "loaded engine docs from workspace"); + } + } + + /// Persist a MemoryDoc to workspace. + async fn persist_doc(&self, doc: &MemoryDoc) { + let Some(ref ws) = self.workspace else { + return; + }; + + let path = doc_workspace_path(doc); + let json = match serde_json::to_string_pretty(doc) { + Ok(j) => j, + Err(e) => { + debug!("failed to serialize doc: {e}"); + return; + } + }; + + if let Err(e) = ws.write(&path, &json).await { + debug!(path = %path, "failed to persist engine doc: {e}"); } } } -impl Default for InMemoryStore { - fn default() -> Self { - Self::new() - } +/// Build workspace path for a MemoryDoc. +fn doc_workspace_path(doc: &MemoryDoc) -> String { + let type_dir = match doc.doc_type { + DocType::Summary => "summaries", + DocType::Lesson => "lessons", + DocType::Playbook => "playbooks", + DocType::Issue => "issues", + DocType::Spec => "specs", + DocType::Note => "notes", + }; + format!("{ENGINE_DOCS_PREFIX}/{type_dir}/{}.json", doc.id.0) } #[async_trait::async_trait] -impl Store for InMemoryStore { - // ── Thread ────────────────────────────────────────────── +impl Store for HybridStore { + // ── Thread (ephemeral) ────────────────────────────────── async fn save_thread(&self, thread: &Thread) -> Result<(), EngineError> { self.threads.write().await.insert(thread.id, thread.clone()); @@ -82,7 +159,7 @@ impl Store for InMemoryStore { Ok(()) } - // ── Step ──────────────────────────────────────────────── + // ── Step (ephemeral) ──────────────────────────────────── async fn save_step(&self, step: &Step) -> Result<(), EngineError> { self.steps @@ -104,7 +181,7 @@ impl Store for InMemoryStore { .unwrap_or_default()) } - // ── Event ─────────────────────────────────────────────── + // ── Event (ephemeral) ─────────────────────────────────── async fn append_events(&self, events: &[ThreadEvent]) -> Result<(), EngineError> { let mut store = self.events.write().await; @@ -127,7 +204,7 @@ impl Store for InMemoryStore { .unwrap_or_default()) } - // ── Project ───────────────────────────────────────────── + // ── Project (ephemeral) ───────────────────────────────── async fn save_project(&self, project: &Project) -> Result<(), EngineError> { self.projects @@ -141,10 +218,13 @@ impl Store for InMemoryStore { Ok(self.projects.read().await.get(&id).cloned()) } - // ── MemoryDoc ─────────────────────────────────────────── + // ── MemoryDoc (DURABLE — persisted to workspace) ──────── async fn save_memory_doc(&self, doc: &MemoryDoc) -> Result<(), EngineError> { + // Save to in-memory cache self.docs.write().await.insert(doc.id, doc.clone()); + // Persist to workspace + self.persist_doc(doc).await; Ok(()) } @@ -163,7 +243,7 @@ impl Store for InMemoryStore { .collect()) } - // ── Lease ─────────────────────────────────────────────── + // ── Lease (ephemeral) ─────────────────────────────────── async fn save_lease(&self, lease: &CapabilityLease) -> Result<(), EngineError> { self.leases.write().await.insert(lease.id, lease.clone()); @@ -191,7 +271,7 @@ impl Store for InMemoryStore { Ok(()) } - // ── Mission ────────────────────────────────────────────── + // ── Mission (ephemeral) ────────────────────────────────── async fn save_mission(&self, mission: &Mission) -> Result<(), EngineError> { self.missions