diff --git a/src/core/memory/mod.rs b/src/core/memory/mod.rs index 1f5db89..fd29e4f 100644 --- a/src/core/memory/mod.rs +++ b/src/core/memory/mod.rs @@ -1188,9 +1188,14 @@ fn fire_memfs_commit(ctx: &ToolContext, repo: &MemoryRepo, op: &str, paths: &[&s /// When `ctx` is `Some` and carries an `agent_id`, that takes precedence over /// environment variables. Falls back to env vars when no context is provided, /// preserving backward compatibility with the HTTP server path. +/// +/// `tree` names one of the context's memory trees (primary, subconscious, +/// reflection, archivist). `None` keeps the historical behaviour: the +/// context's single `memory_root`. pub async fn execute_memory_command_with_context( cmd: &MemoryCommand, ctx: Option<&ToolContext>, + tree: Option<&str>, ) -> Result { // Agent ID resolution: context > command > env var > default let agent_id = ctx @@ -1204,10 +1209,36 @@ pub async fn execute_memory_command_with_context( .or_else(|| std::env::var("AGENT_ID").ok()) .unwrap_or_else(|| "default".to_string()); - // Memory root resolution: use memory_root from context when available - let repo = match ctx.and_then(|c| c.memory_root.as_ref()) { - Some(root) => MemoryRepo::open(&agent_id, root.clone()), - None => MemoryRepo::new_default(&agent_id), + // Memory root resolution: a named tree wins, then the context's + // memory_root, then the default. A name this context does not know is a + // refusal that names the doors, so a mistyped `tree` costs one error + // instead of a wrong-tree write. + let repo = match tree { + Some(name) => { + let root = ctx + .and_then(|c| c.memory_tree(name).cloned()) + .ok_or_else(|| { + let known = ctx + .map(|c| { + c.memory_trees + .iter() + .map(|(n, _)| n.as_str()) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + if known.is_empty() { + anyhow!("Unknown memory tree `{name}` — no named trees here") + } else { + anyhow!("Unknown memory tree `{name}` — known: {known}") + } + })?; + MemoryRepo::open(&agent_id, root) + } + None => match ctx.and_then(|c| c.memory_root.as_ref()) { + Some(root) => MemoryRepo::open(&agent_id, root.clone()), + None => MemoryRepo::new_default(&agent_id), + }, }; match cmd { @@ -1373,7 +1404,7 @@ pub async fn execute_memory_command_with_context( /// Execute a memory command, reading agent identity from env vars. /// Delegates to `execute_memory_command_with_context` with `None`. pub async fn execute_memory_command(cmd: &MemoryCommand) -> Result { - execute_memory_command_with_context(cmd, None).await + execute_memory_command_with_context(cmd, None, None).await } // ── Tool Result Bridge ────────────────────────────────────────────────────── @@ -1391,6 +1422,7 @@ pub async fn handle_memory_tool_with_context( ) -> ToolResult { let tool_use_id = format!("tool-u-{}", chrono::Utc::now().timestamp_millis()); let command = input.get("command").and_then(|v| v.as_str()).unwrap_or(""); + let tree = input.get("tree").and_then(|v| v.as_str()); let cmd = match command { "read" => { @@ -1481,7 +1513,7 @@ pub async fn handle_memory_tool_with_context( } }; - match execute_memory_command_with_context(&cmd, ctx).await { + match execute_memory_command_with_context(&cmd, ctx, tree).await { Ok(output) => ToolResult { tool_use_id, tool_name: tool_name.to_string(), @@ -1529,7 +1561,9 @@ Subcommands: sync — Push my instance branch to the shared memfs remote and fetch what my other instances wrote. My memory travels; this is how. audit — List frontmatter-bound files missing the required `description` (read-only). `write`/`append` reject a missing description, so this is how I find what to heal. -Paths are relative to my memory directory. Frontmatter description is required on create. Read-only files protect themselves. Every write is a git commit.".to_string(), +Paths are relative to my memory directory. Frontmatter description is required on create. Read-only files protect themselves. Every write is a git commit. + +`tree` names which memory tree I am reaching into. Default is my own. When I am a cadence I may name `primary`, `subconscious`, `reflection`, or `archivist` — each is a real tree with its own git history, and a write there is committed under my own name.".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { @@ -1554,6 +1588,10 @@ Paths are relative to my memory directory. Frontmatter description is required o "type": "string", "enum": ["microcompact", "sliding_window", "sliding_reflect", "summary", "cull"], "description": "Compaction strategy (for compact subcommand). microcompact (clears old tool output, drops nothing), cull (drops throwaways), sliding_window (fast, drops the middle blind), sliding_reflect (the same slide, but a pass reads the middle first and carries its threads forward), summary (LLM, highest fidelity)" + }, + "tree": { + "type": "string", + "description": "Which memory tree to reach into. Optional — defaults to my own tree. Cadences may name primary, subconscious, reflection, or archivist." } }, "required": ["command"] @@ -2168,4 +2206,110 @@ mod tests { let missing = repo.audit().await.unwrap(); assert_eq!(missing, vec!["issues/missing.md".to_string()]); } + + /// The `tree` param is the fence opening: a write aimed at a named tree + /// lands in that tree's own git history, and reads without a tree stay in + /// the default root — the two never share a file. + #[tokio::test] + async fn memory_tool_tree_names_the_target_root() { + let default_dir = TempDir::new().unwrap(); + let primary_dir = TempDir::new().unwrap(); + MemoryRepo::new("test-agent", default_dir.path()) + .init() + .await + .unwrap(); + MemoryRepo::new("test-agent", primary_dir.path()) + .init() + .await + .unwrap(); + + let mut ctx = ToolContext::new(); + ctx.agent_id = Some("test-agent".to_string()); + ctx.memory_root = Some(default_dir.path().to_path_buf()); + ctx.memory_trees = vec![("primary".to_string(), primary_dir.path().to_path_buf())]; + + let write = handle_memory_tool_with_context( + "memory", + &serde_json::json!({ + "command": "write", + "path": "journal/2026/05/20.md", + "content": "default root note" + }), + Some(&ctx), + ) + .await; + assert!(!write.is_error, "{}", write.output); + + let write_primary = handle_memory_tool_with_context( + "memory", + &serde_json::json!({ + "command": "write", + "path": "journal/2026/05/20.md", + "content": "primary root note", + "tree": "primary" + }), + Some(&ctx), + ) + .await; + assert!(!write_primary.is_error, "{}", write_primary.output); + + let read_default = handle_memory_tool_with_context( + "memory", + &serde_json::json!({ "command": "read", "path": "journal/2026/05/20.md" }), + Some(&ctx), + ) + .await; + assert!( + read_default.output.contains("default root note"), + "{}", + read_default.output + ); + + let read_primary = handle_memory_tool_with_context( + "memory", + &serde_json::json!({ + "command": "read", + "path": "journal/2026/05/20.md", + "tree": "primary" + }), + Some(&ctx), + ) + .await; + assert!( + read_primary.output.contains("primary root note"), + "{}", + read_primary.output + ); + assert!(!read_primary.output.contains("default root note")); + } + + /// A mistyped door refuses and names the doors that exist — a wrong-tree + /// write is worse than an error. + #[tokio::test] + async fn memory_tool_unknown_tree_refuses_with_the_known_names() { + let dir = TempDir::new().unwrap(); + MemoryRepo::new("test-agent", dir.path()) + .init() + .await + .unwrap(); + + let mut ctx = ToolContext::new(); + ctx.agent_id = Some("test-agent".to_string()); + ctx.memory_root = Some(dir.path().to_path_buf()); + ctx.memory_trees = vec![("primary".to_string(), dir.path().to_path_buf())]; + + let result = handle_memory_tool_with_context( + "memory", + &serde_json::json!({ + "command": "read", + "path": "system/persona.md", + "tree": "primry" + }), + Some(&ctx), + ) + .await; + assert!(result.is_error, "{}", result.output); + assert!(result.output.contains("primry"), "{}", result.output); + assert!(result.output.contains("primary"), "{}", result.output); + } } diff --git a/src/core/reflection/mod.rs b/src/core/reflection/mod.rs index f5720a5..039cf82 100644 --- a/src/core/reflection/mod.rs +++ b/src/core/reflection/mod.rs @@ -163,31 +163,39 @@ impl ReflectionEngine { }) .collect(); - // ── ToolContext: her own name, the ledgers' tree ── + // ── ToolContext: her own name, the ledgers' tree, every door ── // `MemoryRepo` signs commits with the context's agent id, so opening // the subconscious's root under the reflection id is what makes an // N+25 conclusion legible as one in `git log` instead of arriving as // something the subconscious noticed a moment after the turn. // - // One root, and it is this one. The prompt used to promise the primary - // memfs was reachable "through the `memory` tool" — `ToolContext` - // carries a single `memory_root` and there is no verb that names - // another, so every attempt landed in the wrong tree and spent a round. + // Her default root is the ledger. The `memory` tool's `tree:` names + // the other three, so the primary memfs is a deliberate choice rather + // than the broken promise it used to be (one `memory_root`, no verb + // that named another, every attempt landed in the wrong tree). let ledger_root = self .agents .cadence_memory_root(primary_id, Cadence::Subconscious); - let cwd = std::env::current_dir().ok(); - let env: Vec<(String, String)> = std::env::vars().collect(); - - let tool_ctx = - ToolContext::for_agent(own_id.clone(), cwd, Some(ledger_root.clone()), env, None); - // She wakes as herself: her pinned `system/` whole and in order, the // ledger window, then this cadence's persona and mandate. The seeded // floor below only covers the mandate she has not written yet. let cadence_root = self .agents .cadence_memory_root(primary_id, Cadence::Reflection); + let cwd = std::env::current_dir().ok(); + let env: Vec<(String, String)> = std::env::vars().collect(); + + let mut tool_ctx = + ToolContext::for_agent(own_id.clone(), cwd, Some(ledger_root.clone()), env, None); + tool_ctx.memory_trees = vec![ + ("primary".to_string(), self.agents.memory_root(primary_id)), + ("subconscious".to_string(), ledger_root.clone()), + ("reflection".to_string(), cadence_root.clone()), + ( + "archivist".to_string(), + self.agents.cadence_memory_root(primary_id, Cadence::Archivist), + ), + ]; let identity = crate::core::prompt::build_cadence_prompt( &self.agents.memory_root(primary_id), &cadence_root, @@ -203,9 +211,12 @@ impl ReflectionEngine { }; let user_content = format!( "The last {turns_reviewed} turns. `[user]` is the human; `[assistant]` \ - is me, in the moment, before I had the distance I have now.\n\n\ - My ledgers are at `{ledger_root}` and the `memory` tool reaches them.\n\n\ - \n{transcript}\n", + is me, in the moment, before I had the distance I have now.\n\n\ + My ledgers are at `{ledger_root}` and the `memory` tool reaches them \ + by default. Name a door when I need one: `tree: \"primary\"` for the \ + primary memfs, `tree: \"reflection\"` for my own tree, \ + `tree: \"archivist\"` for the memoir register.\n\n\ + \n{transcript}\n", ledger_root = ledger_root.display(), ); diff --git a/src/core/tools/body.rs b/src/core/tools/body.rs index 70907a5..0c75d97 100644 --- a/src/core/tools/body.rs +++ b/src/core/tools/body.rs @@ -465,6 +465,7 @@ mod tests { ToolContext { cwd: None, memory_root: None, + memory_trees: Vec::new(), env: Vec::new(), agent_id: None, subagent_runner: None, diff --git a/src/core/tools/defs.rs b/src/core/tools/defs.rs index baef3ed..d09e311 100644 --- a/src/core/tools/defs.rs +++ b/src/core/tools/defs.rs @@ -39,6 +39,10 @@ pub struct ToolOutput { pub struct ToolContext { /// The agent's memory directory root. pub memory_root: Option, + /// Named memory trees beyond the default root — the doors a cadence can + /// deliberately open. `memory` tool accepts a `tree` name; everything + /// else keeps using `memory_root`. + pub memory_trees: Vec<(String, PathBuf)>, /// Current working directory (bash tracks this). pub cwd: Option, /// Current environment variables. @@ -59,6 +63,7 @@ impl Clone for ToolContext { fn clone(&self) -> Self { Self { memory_root: self.memory_root.clone(), + memory_trees: self.memory_trees.clone(), cwd: self.cwd.clone(), env: self.env.clone(), agent_id: self.agent_id.clone(), @@ -74,6 +79,7 @@ impl std::fmt::Debug for ToolContext { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ToolContext") .field("memory_root", &self.memory_root) + .field("memory_trees", &self.memory_trees) .field("cwd", &self.cwd) .field("env_len", &self.env.len()) .field("agent_id", &self.agent_id) @@ -95,6 +101,7 @@ impl ToolContext { pub fn new() -> Self { Self { memory_root: None, + memory_trees: Vec::new(), cwd: None, env: Vec::new(), agent_id: None, @@ -160,6 +167,7 @@ impl ToolContext { Self { memory_root, + memory_trees: Vec::new(), cwd, env, agent_id: Some(agent_id_str), @@ -184,6 +192,14 @@ impl ToolContext { .is_some_and(|root| path.starts_with(root)) } + /// Root of one named memory tree, if this context knows that door. + pub fn memory_tree(&self, name: &str) -> Option<&PathBuf> { + self.memory_trees + .iter() + .find(|(n, _)| n == name) + .map(|(_, root)| root) + } + /// Resolve a path relative to cwd if it's relative. pub fn resolve_path(&self, path: &std::path::Path) -> PathBuf { if path.is_relative() { diff --git a/src/core/tools/mod.rs b/src/core/tools/mod.rs index 286b2d8..0e5118d 100644 --- a/src/core/tools/mod.rs +++ b/src/core/tools/mod.rs @@ -162,6 +162,7 @@ impl Sensorium { context: ToolContext { cwd, memory_root, + memory_trees: Vec::new(), env, agent_id: None, subagent_runner: None,