Watch
1
0
Fork
You've already forked souveraine
0

memory tool: tree names the door past the fence

reflection's context now registers all four trees, and a
tree param on the memory verb lands reads and writes in the
named memfs, committed under her own name. an unknown tree
refuses and lists the doors, so a typo costs one error
instead of a wrong-tree write
This commit is contained in:
Fimeg 2026-08-17 22:27:55 -04:00
commit 04ceac79bf
5 changed files with 194 additions and 21 deletions

View file

@ -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<String> {
// 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::<Vec<_>>()
.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<String> {
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);
}
}

View file

@ -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\
<transcript>\n{transcript}\n</transcript>",
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\
<transcript>\n{transcript}\n</transcript>",
ledger_root = ledger_root.display(),
);

View file

@ -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,

View file

@ -39,6 +39,10 @@ pub struct ToolOutput {
pub struct ToolContext {
/// The agent's memory directory root.
pub memory_root: Option<PathBuf>,
/// 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<PathBuf>,
/// 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() {

View file

@ -162,6 +162,7 @@ impl Sensorium {
context: ToolContext {
cwd,
memory_root,
memory_trees: Vec::new(),
env,
agent_id: None,
subagent_runner: None,