mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
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) <[email protected]>
This commit is contained in:
@@ -300,7 +300,7 @@ impl Agent {
|
||||
&self.deps.tools
|
||||
}
|
||||
|
||||
pub(super) fn workspace(&self) -> Option<&Arc<Workspace>> {
|
||||
pub(crate) fn workspace(&self) -> Option<&Arc<Workspace>> {
|
||||
self.deps.workspace.as_ref()
|
||||
}
|
||||
|
||||
|
||||
@@ -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<EffectBridgeAdapter>,
|
||||
#[allow(dead_code)]
|
||||
store: Arc<InMemoryStore>,
|
||||
store: Arc<HybridStore>,
|
||||
default_project_id: ironclaw_engine::ProjectId,
|
||||
/// Currently pending approval (if any).
|
||||
pending_approval: RwLock<Option<PendingApproval>>,
|
||||
@@ -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();
|
||||
|
||||
+106
-26
@@ -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<HashMap<ThreadId, Thread>>,
|
||||
steps: RwLock<HashMap<ThreadId, Vec<Step>>>,
|
||||
events: RwLock<HashMap<ThreadId, Vec<ThreadEvent>>>,
|
||||
projects: RwLock<HashMap<ProjectId, Project>>,
|
||||
docs: RwLock<HashMap<DocId, MemoryDoc>>,
|
||||
leases: RwLock<HashMap<LeaseId, CapabilityLease>>,
|
||||
missions: RwLock<HashMap<MissionId, Mission>>,
|
||||
|
||||
// ── Durable (workspace-backed, survives restarts) ──
|
||||
/// In-memory cache of docs (always in sync with workspace).
|
||||
docs: RwLock<HashMap<DocId, MemoryDoc>>,
|
||||
/// Workspace for persistent storage. None if workspace unavailable.
|
||||
workspace: Option<Arc<Workspace>>,
|
||||
}
|
||||
|
||||
impl InMemoryStore {
|
||||
pub fn new() -> Self {
|
||||
impl HybridStore {
|
||||
pub fn new(workspace: Option<Arc<Workspace>>) -> 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::<MemoryDoc>(&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
|
||||
|
||||
Reference in New Issue
Block a user