Watch
1
0
Fork
You've already forked souveraine
0
souveraine/src/core/memory/mod.rs

1603 lines
64 KiB
Rust
Raw Normal View History

#![allow(dead_code)] // WIP scaffolding not yet wired
//! Memory — The agent's git-backed, frontmatter-aware memory filesystem.
//!
//! Every agent has a memory directory at `~/.souveraine/agents/{id}/memory/`
//! containing markdown files with YAML frontmatter, tracked in git.
//!
//! The `memory` tool exposes this to the agent as a unified subcommand interface:
//!
//! ```text
//! memory read system/persona
//! memory write system/persona "new content"
//! memory append journal/2026-05-06 "new entry"
//! memory ls system/
//! memory init
//! memory status
//! memory compact --strategy sliding-window
//! ```
//!
//! Design:
//! - All files require YAML frontmatter with `description`
//! - `read_only: true` in frontmatter blocks writes
//! - Every write is a git commit (auto-commit)
//! - Paths are relative to the agent's memory directory
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
feat: sensorium architecture — 8 sensors, subagent runner, TUI component system Sensorium (src/core/tools/): - defs.rs: Tool trait, ToolContext (resolve_path, is_memory_path), ToolError with 7 constructors - read/write/edit: memory boundary with force override, line ranges, replace_all - bash: stateful (Arc<Mutex<BashState>>), background tasks, timeout - glob/grep/list_dir: gitignore respect, context lines, memory boundary - mod.rs: Sensorium registry + backward compat wrappers - subagent.rs: fork-of-self tool, dual-state framing, no hard depth guard - agent.rs: Agent(Sam) summoning stub Dual-state N+1: - ConsciousnessEngine::on_response_for_agent() — runs heuristic detector on subagent response - Observations flow back to parent agent's inbox - Signaled limits: SubagentConfig (max_depth, max_tool_rounds=50, warning thresholds 0.8/0.95) Architecture docs: - SENSORIUM_ARCHITECTURE.md — full sensorium vision with nervous system scaffold - DECISIONS.md — settled architectural decisions - ASTER_ARCHITECTURE.md / CONSCIOUSNESS_CYCLE.md — reframe appendices - ALIGNMENT_REPORT.md — doc-to-code alignment audit - Scope tasks 1-4, 4 issues TUI: - component.rs — component system trait + event + scene - 10 tui-component tasks (completed) - 4 tui-xxx tasks for next iteration (presence panel, cockpit, dashboard, ambient) - app.rs splash — vertical breathing room (30% from top) Config: - SubagentConfig in config.rs - ToolContext enrichment (agent_id, subagent_runner, subagent_depth) - Memory module context-aware overloads
2026-05-09 20:58:37 -04:00
use tracing::{debug, info};
use crate::core::compact::CompactionStrategyKind;
feat: sensorium architecture — 8 sensors, subagent runner, TUI component system Sensorium (src/core/tools/): - defs.rs: Tool trait, ToolContext (resolve_path, is_memory_path), ToolError with 7 constructors - read/write/edit: memory boundary with force override, line ranges, replace_all - bash: stateful (Arc<Mutex<BashState>>), background tasks, timeout - glob/grep/list_dir: gitignore respect, context lines, memory boundary - mod.rs: Sensorium registry + backward compat wrappers - subagent.rs: fork-of-self tool, dual-state framing, no hard depth guard - agent.rs: Agent(Sam) summoning stub Dual-state N+1: - ConsciousnessEngine::on_response_for_agent() — runs heuristic detector on subagent response - Observations flow back to parent agent's inbox - Signaled limits: SubagentConfig (max_depth, max_tool_rounds=50, warning thresholds 0.8/0.95) Architecture docs: - SENSORIUM_ARCHITECTURE.md — full sensorium vision with nervous system scaffold - DECISIONS.md — settled architectural decisions - ASTER_ARCHITECTURE.md / CONSCIOUSNESS_CYCLE.md — reframe appendices - ALIGNMENT_REPORT.md — doc-to-code alignment audit - Scope tasks 1-4, 4 issues TUI: - component.rs — component system trait + event + scene - 10 tui-component tasks (completed) - 4 tui-xxx tasks for next iteration (presence panel, cockpit, dashboard, ambient) - app.rs splash — vertical breathing room (30% from top) Config: - SubagentConfig in config.rs - ToolContext enrichment (agent_id, subagent_runner, subagent_depth) - Memory module context-aware overloads
2026-05-09 20:58:37 -04:00
use crate::core::tools::defs::ToolContext;
use crate::core::tools::ToolDefinition;
// ── Data Types ─────────────────────────────────────────────
/// Parsed memory file with frontmatter and body separated.
#[derive(Debug, Clone)]
pub struct MemoryFile {
pub frontmatter: MemoryFrontmatter,
pub body: String,
}
/// YAML frontmatter fields for a memory file.
///
/// `None` fields are omitted on render — never serialized as `key: null`
/// (nulls in frontmatter read as noise and confused agents into copying
/// the pattern). Unknown keys agents add (`name:`, `metadata:`, …) are
/// captured in `extra` and round-tripped verbatim instead of being
/// destroyed on the next rewrite.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MemoryFrontmatter {
/// Human-readable description of this file's purpose (required on
/// tool-path create; tolerated empty on read so nonconforming files
/// stay reachable and can be healed by a rewrite).
#[serde(default)]
pub description: String,
/// If "true", the file cannot be modified via the memory tool.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub read_only: Option<String>,
/// Optional tags for categorization.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
/// Optional max body size in characters. Writes/appends that would exceed
/// this length are rejected. Closes a gap where upstream memfs write path
/// bypasses block `limit`.
///
/// Units are characters, not tokens — cheap to enforce without a tokenizer.
/// Best-practice default for system/ files: 4_000 characters
/// (~1k tokens). For journal/, leave unset.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
/// Any other frontmatter keys, preserved across rewrites.
#[serde(flatten, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub extra: std::collections::BTreeMap<String, serde_yaml::Value>,
}
/// Status of the memory repo.
#[derive(Debug, Clone)]
pub struct MemoryStatus {
pub agent_id: String,
pub repo_path: PathBuf,
pub is_git_repo: bool,
pub file_count: usize,
pub last_commit: Option<String>,
pub has_uncommitted: bool,
pub remote_url: Option<String>,
}
/// Subcommands for the memory tool.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MemoryCommand {
/// Read a memory file by label (path relative to memory dir, .md optional).
Read { path: String },
/// Write content to a memory file (creates or replaces).
Write { path: String, content: String },
/// Append content to a memory file.
Append { path: String, content: String },
/// List files in a memory directory.
Ls { path: Option<String> },
/// Show memory repo status.
Status,
/// Initialize the memory repo for an agent.
Init { agent_id: String },
/// Compact the memory (placeholder — strategy in Stage 5/6).
Compact { strategy: Option<String> },
/// Delete a memory file.
Delete { path: String },
/// Push/fetch against the shared memfs remote (per-instance branch).
Sync,
/// Audit frontmatter health: list frontmatter-bound files missing
/// the required `description` field. Read-only — never mutates.
Audit,
}
// ── Git-backed Memory Repository ───────────────────────────────────────────
/// A git-backed memory repository for a single agent.
///
/// Wraps a git2 repository at `~/.souveraine/agents/{id}/memory/`.
/// All memory file operations go through this struct, which handles
/// frontmatter parsing, git commits, and path resolution.
#[derive(Debug, Clone)]
pub struct MemoryRepo {
agent_id: String,
/// Root of the memory filesystem.
root: PathBuf,
/// Whether to auto-commit after writes.
auto_commit: bool,
}
impl MemoryRepo {
/// Open or create a memory repo for the given agent.
///
/// The memory directory is at `{base}/{agent_id}/memory/`.
pub fn new(agent_id: &str, base: &Path) -> Self {
let root = base.join(agent_id).join("memory");
Self {
agent_id: agent_id.to_string(),
root,
auto_commit: true,
}
}
/// Open or create a memory repo using the default base path.
pub fn new_default(agent_id: &str) -> Self {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
Self::new(agent_id, &home.join(".souveraine").join("agents"))
}
/// Open a memory repo at an explicit path (rather than `{base}/{id}/memory`).
///
/// Used when the agent's memory dir is laid out differently — e.g. the
/// server's `agent_inventory` uses `memory.git/` instead of `memory/`.
pub fn open(agent_id: &str, root: PathBuf) -> Self {
Self {
agent_id: agent_id.to_string(),
root,
auto_commit: true,
}
}
/// Initialize the memory directory as a git repo.
///
/// Creates `system/` and sets up the initial commit with placeholder files.
/// Safe to call multiple times — skips if already a repo.
pub async fn init(&self) -> Result<()> {
let mem_path = &self.root;
tokio::fs::create_dir_all(mem_path.join("system"))
.await
.context("creating memory/system directory")?;
// Check if already a git repo
let git_dir = mem_path.join(".git");
if git_dir.exists() {
info!("Memory repo already initialized for agent {}", self.agent_id);
return Ok(());
}
// Initialize git repo
let repo = git2::Repository::init(mem_path)
.context("initializing git repository for memory")?;
// Set user config for commits (scoped to drop before .await)
{
let mut config = repo.config().context("opening repo config")?;
config.set_str("user.name", &self.agent_id)?;
config.set_str("user.email", &format!("{}@souveraine.local", self.agent_id))?;
}
// Write initial placeholder files with frontmatter
let persona_content = render_frontmatter(
&MemoryFrontmatter {
description: "Agent identity, voice, principles".to_string(),
tags: Some(vec!["system".to_string()]),
limit: Some(4_000),
..Default::default()
},
"# Identity\n\nAgent identity and core principles go here.\n",
);
tokio::fs::write(mem_path.join("system/persona.md"), &persona_content)
.await
.context("writing persona.md")?;
let state_content = render_frontmatter(
&MemoryFrontmatter {
description: "Current execution state and phase tracking".to_string(),
limit: Some(2_000),
..Default::default()
},
"phase: idle\ncurrent_unit: none\n",
);
tokio::fs::write(mem_path.join("system/state.md"), &state_content)
.await
.context("writing state.md")?;
// human.md is not written here — the setup wizard or first-run
// onboarding creates it with the human's name when it has one.
// Initial commit
let mut index = repo.index().context("opening git index")?;
index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
.context("staging initial memory files")?;
let tree_id = index.write_tree().context("writing git tree")?;
let tree = repo.find_tree(tree_id)?;
let signature = git2::Signature::now(
&self.agent_id,
&format!("{}@souveraine.local", self.agent_id),
)?;
repo.commit(
Some("HEAD"),
&signature,
&signature,
"feat(init): initialize agent memory",
&tree,
&[],
)?;
info!(
"Initialized memory repo for agent {} at {}",
self.agent_id,
mem_path.display()
);
Ok(())
}
feat: nervous system, seed identity, credentials, subconscious ledger Lays the substrate for Aster's clockmaker role and federation. Nervous system (src/core/nervous/): - EventBus broadcast channel with SensorEvent (universal event type carrying seed_id for future federation) - CronSensor loop with tokio::select!, mtime caching, active-session pause, at-most-once firing semantics - HeartbeatHandler scaffold (turn injection still stubbed) - Persistent JSONL EventLog firehose Schedule tool (src/core/tools/schedule.rs): - CRUD over schedule files with body-knowledge prose descriptions - Added to ASTER_SAFE_TOOLS so Aster can schedule her own rhythm - CLI subcommand for direct schedule management Seed identity (src/core/identity/): - Ed25519 keypair, load-or-generate at init - sign/verify primitives, CLI subcommand (show/sign/verify) - Wired into LocalBackend, threaded through SensorEvent for federation Credentials (src/core/credentials.rs): - OS keyring (Linux/macOS/Windows) + env var fallback - `souveraine auth` subcommand - Removes hardcoded Bifrost api_key from souveraine.toml Subconscious ledger (src/core/memory/mod.rs init_subconscious_ledger): - 6 ledger files (commitments/assumptions/patterns/drift_log/ relationships/infrastructure) with proper YAML frontmatter - Paths fixed: ledger/ not subconscious/ledger/ - Body usage instructions, idempotent init Prompt orientation (src/core/prompt.rs build_ledger_orientation): - Scans ledger/ directory, injects last 3 entries from each file into Aster's system prompt (live context, not just awareness) - Routing table + workflow (read before write, timestamped append) Consciousness engine (src/server/consciousness_engine.rs): - Adaptive rate delay shared with primary loop - Four-fold mandate (Complete/Verify/Persist/Surface) in hardcoded default prompt; observation format extracted for sharing - Schedule added to ASTER_SAFE_TOOLS Backend + CLI wiring expanded to mount the nervous system, identity, and credentials at startup. New TOML sections: [schedules], [events], [federation]. 102 tests passing.
2026-05-12 08:14:25 -04:00
/// Initialize the ledger directory structure.
/// Idempotent — safe to call multiple times, skips existing files.
feat: nervous system, seed identity, credentials, subconscious ledger Lays the substrate for Aster's clockmaker role and federation. Nervous system (src/core/nervous/): - EventBus broadcast channel with SensorEvent (universal event type carrying seed_id for future federation) - CronSensor loop with tokio::select!, mtime caching, active-session pause, at-most-once firing semantics - HeartbeatHandler scaffold (turn injection still stubbed) - Persistent JSONL EventLog firehose Schedule tool (src/core/tools/schedule.rs): - CRUD over schedule files with body-knowledge prose descriptions - Added to ASTER_SAFE_TOOLS so Aster can schedule her own rhythm - CLI subcommand for direct schedule management Seed identity (src/core/identity/): - Ed25519 keypair, load-or-generate at init - sign/verify primitives, CLI subcommand (show/sign/verify) - Wired into LocalBackend, threaded through SensorEvent for federation Credentials (src/core/credentials.rs): - OS keyring (Linux/macOS/Windows) + env var fallback - `souveraine auth` subcommand - Removes hardcoded Bifrost api_key from souveraine.toml Subconscious ledger (src/core/memory/mod.rs init_subconscious_ledger): - 6 ledger files (commitments/assumptions/patterns/drift_log/ relationships/infrastructure) with proper YAML frontmatter - Paths fixed: ledger/ not subconscious/ledger/ - Body usage instructions, idempotent init Prompt orientation (src/core/prompt.rs build_ledger_orientation): - Scans ledger/ directory, injects last 3 entries from each file into Aster's system prompt (live context, not just awareness) - Routing table + workflow (read before write, timestamped append) Consciousness engine (src/server/consciousness_engine.rs): - Adaptive rate delay shared with primary loop - Four-fold mandate (Complete/Verify/Persist/Surface) in hardcoded default prompt; observation format extracted for sharing - Schedule added to ASTER_SAFE_TOOLS Backend + CLI wiring expanded to mount the nervous system, identity, and credentials at startup. New TOML sections: [schedules], [events], [federation]. 102 tests passing.
2026-05-12 08:14:25 -04:00
///
/// Paths are relative to this repo's root (the subconscious agent's own
/// memfs), so `ledger/` — not `subconscious/ledger/`.
pub async fn init_subconscious_ledger(&self) -> Result<()> {
feat: nervous system, seed identity, credentials, subconscious ledger Lays the substrate for Aster's clockmaker role and federation. Nervous system (src/core/nervous/): - EventBus broadcast channel with SensorEvent (universal event type carrying seed_id for future federation) - CronSensor loop with tokio::select!, mtime caching, active-session pause, at-most-once firing semantics - HeartbeatHandler scaffold (turn injection still stubbed) - Persistent JSONL EventLog firehose Schedule tool (src/core/tools/schedule.rs): - CRUD over schedule files with body-knowledge prose descriptions - Added to ASTER_SAFE_TOOLS so Aster can schedule her own rhythm - CLI subcommand for direct schedule management Seed identity (src/core/identity/): - Ed25519 keypair, load-or-generate at init - sign/verify primitives, CLI subcommand (show/sign/verify) - Wired into LocalBackend, threaded through SensorEvent for federation Credentials (src/core/credentials.rs): - OS keyring (Linux/macOS/Windows) + env var fallback - `souveraine auth` subcommand - Removes hardcoded Bifrost api_key from souveraine.toml Subconscious ledger (src/core/memory/mod.rs init_subconscious_ledger): - 6 ledger files (commitments/assumptions/patterns/drift_log/ relationships/infrastructure) with proper YAML frontmatter - Paths fixed: ledger/ not subconscious/ledger/ - Body usage instructions, idempotent init Prompt orientation (src/core/prompt.rs build_ledger_orientation): - Scans ledger/ directory, injects last 3 entries from each file into Aster's system prompt (live context, not just awareness) - Routing table + workflow (read before write, timestamped append) Consciousness engine (src/server/consciousness_engine.rs): - Adaptive rate delay shared with primary loop - Four-fold mandate (Complete/Verify/Persist/Surface) in hardcoded default prompt; observation format extracted for sharing - Schedule added to ASTER_SAFE_TOOLS Backend + CLI wiring expanded to mount the nervous system, identity, and credentials at startup. New TOML sections: [schedules], [events], [federation]. 102 tests passing.
2026-05-12 08:14:25 -04:00
let ledger_files: &[(&str, &str, &str)] = &[
("ledger/commitments.md",
"Promises made by the primary — tracked until fulfilled or explicitly dropped",
"# Commitments\n\nAppend entries as:\n`[YYYY-MM-DD HH:MM] content`\n`[YYYY-MM-DD HH:MM] RESOLVED — resolution note`\n"),
("ledger/assumptions.md",
"Assumptions the primary is operating under — flagged for verification",
"# Assumptions\n\nAppend entries as:\n`[YYYY-MM-DD HH:MM] content`\n`[YYYY-MM-DD HH:MM] VERIFIED — evidence`\n"),
("ledger/patterns.md",
"Recurring behavioral patterns observed across turns",
"# Patterns\n\nAppend entries as:\n`[YYYY-MM-DD HH:MM] content`\n"),
("ledger/drift_log.md",
"Behavioral shifts — when the primary's actions diverge from stated intentions",
"# Drift Log\n\nAppend entries as:\n`[YYYY-MM-DD HH:MM] content`\n"),
("ledger/relationships.md",
"Observations about the human-agent relationship — tone shifts, trust signals, friction",
"# Relationships\n\nAppend entries as:\n`[YYYY-MM-DD HH:MM] content`\n"),
("ledger/infrastructure.md",
"System events — bridge failures, token issues, model errors, resource constraints",
"# Infrastructure\n\nAppend entries as:\n`[YYYY-MM-DD HH:MM] content`\n"),
];
feat: nervous system, seed identity, credentials, subconscious ledger Lays the substrate for Aster's clockmaker role and federation. Nervous system (src/core/nervous/): - EventBus broadcast channel with SensorEvent (universal event type carrying seed_id for future federation) - CronSensor loop with tokio::select!, mtime caching, active-session pause, at-most-once firing semantics - HeartbeatHandler scaffold (turn injection still stubbed) - Persistent JSONL EventLog firehose Schedule tool (src/core/tools/schedule.rs): - CRUD over schedule files with body-knowledge prose descriptions - Added to ASTER_SAFE_TOOLS so Aster can schedule her own rhythm - CLI subcommand for direct schedule management Seed identity (src/core/identity/): - Ed25519 keypair, load-or-generate at init - sign/verify primitives, CLI subcommand (show/sign/verify) - Wired into LocalBackend, threaded through SensorEvent for federation Credentials (src/core/credentials.rs): - OS keyring (Linux/macOS/Windows) + env var fallback - `souveraine auth` subcommand - Removes hardcoded Bifrost api_key from souveraine.toml Subconscious ledger (src/core/memory/mod.rs init_subconscious_ledger): - 6 ledger files (commitments/assumptions/patterns/drift_log/ relationships/infrastructure) with proper YAML frontmatter - Paths fixed: ledger/ not subconscious/ledger/ - Body usage instructions, idempotent init Prompt orientation (src/core/prompt.rs build_ledger_orientation): - Scans ledger/ directory, injects last 3 entries from each file into Aster's system prompt (live context, not just awareness) - Routing table + workflow (read before write, timestamped append) Consciousness engine (src/server/consciousness_engine.rs): - Adaptive rate delay shared with primary loop - Four-fold mandate (Complete/Verify/Persist/Surface) in hardcoded default prompt; observation format extracted for sharing - Schedule added to ASTER_SAFE_TOOLS Backend + CLI wiring expanded to mount the nervous system, identity, and credentials at startup. New TOML sections: [schedules], [events], [federation]. 102 tests passing.
2026-05-12 08:14:25 -04:00
for (path, description, body) in ledger_files {
let full_path = self.root.join(path);
if !full_path.exists() {
if let Some(parent) = full_path.parent() {
tokio::fs::create_dir_all(parent).await
.with_context(|| format!("creating ledger directory: {}", parent.display()))?;
}
let template = format!(
feat: nervous system, seed identity, credentials, subconscious ledger Lays the substrate for Aster's clockmaker role and federation. Nervous system (src/core/nervous/): - EventBus broadcast channel with SensorEvent (universal event type carrying seed_id for future federation) - CronSensor loop with tokio::select!, mtime caching, active-session pause, at-most-once firing semantics - HeartbeatHandler scaffold (turn injection still stubbed) - Persistent JSONL EventLog firehose Schedule tool (src/core/tools/schedule.rs): - CRUD over schedule files with body-knowledge prose descriptions - Added to ASTER_SAFE_TOOLS so Aster can schedule her own rhythm - CLI subcommand for direct schedule management Seed identity (src/core/identity/): - Ed25519 keypair, load-or-generate at init - sign/verify primitives, CLI subcommand (show/sign/verify) - Wired into LocalBackend, threaded through SensorEvent for federation Credentials (src/core/credentials.rs): - OS keyring (Linux/macOS/Windows) + env var fallback - `souveraine auth` subcommand - Removes hardcoded Bifrost api_key from souveraine.toml Subconscious ledger (src/core/memory/mod.rs init_subconscious_ledger): - 6 ledger files (commitments/assumptions/patterns/drift_log/ relationships/infrastructure) with proper YAML frontmatter - Paths fixed: ledger/ not subconscious/ledger/ - Body usage instructions, idempotent init Prompt orientation (src/core/prompt.rs build_ledger_orientation): - Scans ledger/ directory, injects last 3 entries from each file into Aster's system prompt (live context, not just awareness) - Routing table + workflow (read before write, timestamped append) Consciousness engine (src/server/consciousness_engine.rs): - Adaptive rate delay shared with primary loop - Four-fold mandate (Complete/Verify/Persist/Surface) in hardcoded default prompt; observation format extracted for sharing - Schedule added to ASTER_SAFE_TOOLS Backend + CLI wiring expanded to mount the nervous system, identity, and credentials at startup. New TOML sections: [schedules], [events], [federation]. 102 tests passing.
2026-05-12 08:14:25 -04:00
"---\ndescription: \"{}\"\nread_only: false\ntags:\n - ledger\n---\n\n{}",
description, body
);
tokio::fs::write(&full_path, &template).await
.with_context(|| format!("writing ledger file: {}", path))?;
debug!("Created ledger file: {}", path);
}
}
Ok(())
}
/// Read a memory file by label (path relative to memory dir, .md optional).
pub async fn read(&self, label: &str) -> Result<MemoryFile> {
let path = self.resolve_path(label);
let content = tokio::fs::read_to_string(&path)
.await
.with_context(|| format!("reading memory file: {}", label))?;
parse_memory_file(&content)
}
/// Write content to a memory file (creates or replaces).
///
/// Content should NOT include frontmatter — it will be added automatically.
/// If the file exists, its frontmatter is preserved (unless changing read_only).
pub async fn write(&self, label: &str, body: &str) -> Result<()> {
let path = self.resolve_path(label);
// Ensure parent directory exists
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent)
.await
.context("creating parent directories")?;
}
// Agent-supplied frontmatter in the content is merged, not nested.
let (supplied, body) = split_supplied_frontmatter(body);
// Get existing frontmatter or use default
let base = if path.exists() {
let existing = tokio::fs::read_to_string(&path).await?;
let parsed = parse_memory_file(&existing)?;
if parsed.frontmatter.read_only.as_deref() == Some("true") {
return Err(anyhow!("memory file is read_only: {}", label));
}
parsed.frontmatter
} else {
MemoryFrontmatter {
description: format!("Memory file: {}", label),
..Default::default()
}
};
let frontmatter = match supplied {
Some(fm) => merge_frontmatter(base, fm),
None => base,
};
// Enforce frontmatter `limit:` (LET-8133 closure).
if let Some(max) = frontmatter.limit {
if body.chars().count() > max {
return Err(anyhow!(
"memory write rejected: body is {} chars, limit is {} (file: {})",
body.chars().count(),
max,
label
));
}
}
// `description` is required on frontmatter-bound files — it is the
// file's headline in the prompt assembler, so persisting an empty
// one is a schema gap. We never fabricate one: the write is rejected
// loudly and the agent must supply a real description. (Memory-only
// write could still seed a default, but an agent-supplied empty
// frontmatter block that clears it must not silently take hold.)
if Self::is_frontmatter_bound(label) && frontmatter.description.trim().is_empty() {
return Err(anyhow!(
"memory write rejected: {} has no description. \
`description:` is required frontmatter on this file \
include it in the write (or a `---\\ndescription: ...\\n---` block). \
Run `memory audit` to see all files missing it.",
label
));
}
let rendered = render_frontmatter(&frontmatter, body);
tokio::fs::write(&path, &rendered)
.await
.with_context(|| format!("writing memory file: {}", label))?;
if self.auto_commit {
self.commit(&[label], &format!("memory write: {}", label))?;
}
Ok(())
}
/// Append content to a memory file.
pub async fn append(&self, label: &str, content: &str) -> Result<()> {
let path = self.resolve_path(label);
// Frontmatter never belongs mid-file; merge it instead of nesting.
let (supplied, content) = split_supplied_frontmatter(content);
if path.exists() {
let existing = tokio::fs::read_to_string(&path).await?;
let parsed = parse_memory_file(&existing)?;
if parsed.frontmatter.read_only.as_deref() == Some("true") {
return Err(anyhow!("memory file is read_only: {}", label));
}
let frontmatter = match supplied {
Some(fm) => merge_frontmatter(parsed.frontmatter, fm),
None => parsed.frontmatter,
};
// Write back body + new content, preserving frontmatter
let new_body = if parsed.body.is_empty() {
content.to_string()
} else {
format!("{}\n{}", parsed.body.trim_end(), content)
};
// Enforce frontmatter `limit:` (LET-8133 closure).
if let Some(max) = frontmatter.limit {
if new_body.chars().count() > max {
return Err(anyhow!(
"memory append rejected: body would be {} chars, limit is {} (file: {})",
new_body.chars().count(),
max,
label
));
}
}
// `description` is required on frontmatter-bound files. Appending
// to a file that already lacks one must not silently perpetuate
// the gap — reject loudly so the agent heals the description
// before growing the file. (See `write` for the same guard.)
if Self::is_frontmatter_bound(label) && frontmatter.description.trim().is_empty() {
return Err(anyhow!(
"memory append rejected: {} has no description. \
`description:` is required frontmatter on this file \
heal it with a `memory write` (include a `description:` block) \
before appending. Run `memory audit` to see all such files.",
label
));
}
let rendered = render_frontmatter(&frontmatter, &new_body);
tokio::fs::write(&path, &rendered).await?;
if self.auto_commit {
self.commit(&[label], &format!("memory append: {}", label))?;
}
return Ok(());
};
// File doesn't exist — create it with supplied or default frontmatter
let frontmatter = merge_frontmatter(
MemoryFrontmatter {
description: format!("Memory file: {}", label),
..Default::default()
},
supplied.unwrap_or_default(),
);
let rendered = render_frontmatter(&frontmatter, content);
tokio::fs::write(&path, &rendered).await?;
if self.auto_commit {
self.commit(&[label], &format!("memory append: {}", label))?;
}
Ok(())
}
/// List files in a memory directory.
pub async fn list(&self, subdir: Option<&str>) -> Result<Vec<String>> {
let dir = match subdir {
Some(d) => self.root.join(d),
None => self.root.clone(),
};
let mut entries = Vec::new();
let mut read_dir = tokio::fs::read_dir(&dir)
.await
.with_context(|| format!("listing memory directory: {}", dir.display()))?;
while let Some(entry) = read_dir.next_entry().await? {
let name = entry.file_name().to_string_lossy().to_string();
// Skip .git directory
if name == ".git" {
continue;
}
let kind = entry.file_type().await?;
if kind.is_dir() {
entries.push(format!("{}/", name));
} else {
entries.push(name);
}
}
entries.sort();
Ok(entries)
}
/// Recursively collect every `.md` under the memory root as a path
/// relative to the root (forward slashes), skipping `.git*`. Internal
/// helper for `audit`.
async fn walk_md_relative(&self) -> Result<Vec<String>> {
let mut out = Vec::new();
let root = self.root.clone();
let mut stack: Vec<(std::path::PathBuf, String)> = vec![(root.clone(), String::new())];
while let Some((dir, rel_prefix)) = stack.pop() {
let mut read_dir = tokio::fs::read_dir(&dir).await.with_context(|| {
format!("walking memory directory: {}", dir.display())
})?;
while let Some(entry) = read_dir.next_entry().await? {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with(".git") {
continue;
}
let kind = entry.file_type().await?;
let rel = if rel_prefix.is_empty() {
name.clone()
} else {
format!("{rel_prefix}/{name}")
};
if kind.is_dir() {
stack.push((entry.path(), rel));
} else if kind.is_file() && name.ends_with(".md") {
out.push(rel);
}
}
}
out.sort();
Ok(out)
}
/// Audit frontmatter health: return the relative paths of
/// frontmatter-bound memory files whose `description` is empty. Read-only.
///
/// Excludes files that legitimately carry no `description` because they
/// use a different schema — `tasks/` (TodoItem), `system/dynamic/`
/// (itinerary), `journal/` (freeform) — by reusing `is_frontmatter_bound`.
/// Parse failures are tolerated (a file must stay reachable to be healed),
/// matching the tolerant-on-read contract of `parse_memory_file`.
pub async fn audit(&self) -> Result<Vec<String>> {
let mut missing = Vec::new();
for rel in self.walk_md_relative().await? {
if !Self::is_frontmatter_bound(&rel) {
continue;
}
let path = self.root.join(&rel);
let content = match tokio::fs::read_to_string(&path).await {
Ok(c) => c,
Err(_) => continue, // don't let one unreadable file abort the audit
};
let parsed = parse_memory_file(&content)?;
if parsed.frontmatter.description.trim().is_empty() {
missing.push(rel);
}
}
Ok(missing)
}
/// Delete a memory file.
pub async fn delete(&self, label: &str) -> Result<()> {
let path = self.resolve_path(label);
if !path.exists() {
return Err(anyhow!("memory file not found: {}", label));
}
// Check not read_only
let content = tokio::fs::read_to_string(&path).await?;
let parsed = parse_memory_file(&content)?;
if parsed.frontmatter.read_only.as_deref() == Some("true") {
return Err(anyhow!("memory file is read_only: {}", label));
}
tokio::fs::remove_file(&path).await
.with_context(|| format!("deleting memory file: {}", label))?;
if self.auto_commit {
self.commit(&[label], &format!("memory delete: {}", label))?;
}
Ok(())
}
/// Get the status of the memory repo.
pub fn status(&self) -> Result<MemoryStatus> {
let repo_path = self.root.clone();
let is_git_repo = repo_path.join(".git").exists();
let mut file_count = 0;
if let Ok(entries) = std::fs::read_dir(&repo_path) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name != ".git" && name.ends_with(".md") {
file_count += 1;
}
}
}
let (last_commit, has_uncommitted, remote_url) = if is_git_repo {
match git2::Repository::open(&repo_path) {
Ok(repo) => {
let lc = repo.head().ok().and_then(|h| {
h.peel_to_commit().ok().map(|c| {
c.message().unwrap_or("(unknown)").to_string()
})
});
let dirty = repo.statuses(Some(
git2::StatusOptions::new().include_untracked(true),
))
.map(|s| s.iter().any(|_| true))
.unwrap_or(false);
let remote = repo.find_remote("origin").ok()
.and_then(|r| r.url().map(|u| u.to_string()));
(lc, dirty, remote)
}
Err(_) => (None, false, None),
}
} else {
(None, false, None)
};
Ok(MemoryStatus {
agent_id: self.agent_id.clone(),
repo_path,
is_git_repo,
file_count,
last_commit,
has_uncommitted,
remote_url,
})
}
/// Commit staged changes to the memory repo.
pub fn commit(&self, paths: &[&str], message: &str) -> Result<()> {
let repo = self.open_git()?;
let mut index = repo.index().context("opening git index")?;
for path in paths {
let rel_path = self.to_relative(path);
// Try with .md extension if not present
let md_path = if rel_path.ends_with(".md") {
rel_path.clone()
} else {
format!("{}.md", rel_path)
};
if self.root.join(&md_path).exists() {
index.add_path(Path::new(&md_path))?;
} else if self.root.join(&rel_path).exists() {
index.add_path(Path::new(&rel_path))?;
}
}
let tree_id = index.write_tree().context("writing tree")?;
let tree = repo.find_tree(tree_id)?;
let parent = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
let parents: Vec<&git2::Commit> = parent.iter().collect();
let signature = git2::Signature::now(
&self.agent_id,
&format!("{}@souveraine.local", self.agent_id),
)?;
repo.commit(
Some("HEAD"),
&signature,
&signature,
message,
&tree,
&parents,
)?;
debug!("Committed to memory: {}", message);
Ok(())
}
/// Hex hash of the current HEAD commit, if the repo has one.
pub fn head_commit_hex(&self) -> Option<String> {
let repo = self.open_git().ok()?;
let head = repo.head().ok()?;
head.peel_to_commit().ok().map(|c| c.id().to_string())
}
/// Branch shorthand HEAD points at ("main", "primary", ...), if any.
pub fn current_branch(&self) -> Option<String> {
let repo = self.open_git().ok()?;
let head = repo.head().ok()?;
head.shorthand().map(|s| s.to_string())
}
/// Sync this memfs against its shared remote: fetch everything, then
/// push HEAD to this instance's branch (`instance/{label}`).
///
/// This is the manual floor of federated memory transport
/// (FEDERATION.md): every instance writes its own branch on one bare
/// remote; reconciliation/merge is a separate, later step — sync never
/// touches the working tree. Shells out to system git so credentials
/// (ssh config, credential store) work the way they do everywhere else.
///
/// Loud on every failure. Requires an `origin` remote — provisioning is
/// deliberately explicit until the node commission ceremony exists.
pub async fn sync(&self, instance_label: &str) -> Result<String> {
if instance_label.is_empty()
|| instance_label.len() > 64
|| !instance_label
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Err(anyhow!("invalid instance label: {:?}", instance_label));
}
let repo = self.open_git()?;
if repo.find_remote("origin").is_err() {
return Err(anyhow!(
"no shared remote configured for this memfs. Provision one \
(bare repo on the Gitea) and run:\n git -C {} remote add origin <url>",
self.root.display()
));
}
drop(repo);
let fetch = self.git(&["fetch", "origin", "--prune"]).await?;
let branch = format!("instance/{}", instance_label);
let push_ref = format!("HEAD:refs/heads/{}", branch);
let push = self.git(&["push", "origin", &push_ref]).await?;
let instances = self
.git(&[
"for-each-ref",
"--format=%(refname:short) %(objectname:short)",
"refs/remotes/origin/instance/",
])
.await
.unwrap_or_default();
let head = self.head_commit_hex().unwrap_or_else(|| "(no head)".into());
let mut out = format!("Synced. This instance is {} @ {}\n", branch, &head[..12.min(head.len())]);
if !fetch.trim().is_empty() {
out.push_str(&format!("Fetched:\n{}\n", fetch.trim()));
}
if !push.trim().is_empty() {
out.push_str(&format!("Pushed:\n{}\n", push.trim()));
}
if !instances.trim().is_empty() {
out.push_str(&format!("Instances on the remote:\n{}\n", instances.trim()));
}
Ok(out)
}
/// Run a git subcommand against this repo, capturing combined output.
/// Errors carry git's stderr — loud, never swallowed.
async fn git(&self, args: &[&str]) -> Result<String> {
let output = tokio::process::Command::new("git")
.arg("-C")
.arg(&self.root)
.args(args)
.output()
.await
.context("running git — memory sync needs the git binary on PATH")?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() {
return Err(anyhow!(
"git {} failed ({}):\n{}",
args.first().unwrap_or(&"?"),
output.status,
stderr.trim()
));
}
Ok(format!("{}{}", stdout, stderr))
}
/// Get the root path of the memory repo.
pub fn root(&self) -> &Path {
&self.root
}
/// Get the agent ID.
pub fn agent_id(&self) -> &str {
&self.agent_id
}
// ── Internal helpers ──────────────────────────────────────────────────
fn open_git(&self) -> Result<git2::Repository> {
git2::Repository::open(&self.root)
.context("opening memory git repository")
}
/// Whether the `description` frontmatter contract applies to a label.
///
/// `description` is required on `MemoryFrontmatter` files — the prompt
/// assembler reads it as the file's one-line headline, so an empty one
/// is a real schema gap, not cosmetic. But not every `.md` under the
/// memory tree is a `MemoryFrontmatter` file: `tasks/` uses the
/// `TodoItem` schema, `system/dynamic/` holds the itinerary (its own
/// richer, unfinished schema), and `journal/` is freeform prose. Those
/// legitimately lack `description`; enforcing it there would reject
/// valid writes. This predicate scopes the rule to frontmatter-bound
/// locations only.
fn is_frontmatter_bound(label: &str) -> bool {
let rel = label.trim().trim_start_matches(['/', '.']).replace('\\', "/");
let lower = rel.to_ascii_lowercase();
// Exact segment prefixes, not substring matches, so a file like
// `reference/tasks-today.md` is not mistaken for a todo file.
let under = |seg: &str| {
lower == seg
|| lower.starts_with(&format!("{seg}/"))
|| lower.starts_with(&format!("{seg}\\"))
};
!(under("tasks") || under("system/dynamic") || under("journal"))
}
fn resolve_path(&self, label: &str) -> PathBuf {
let clean = label.trim().trim_end_matches(".md");
self.root.join(format!("{}.md", clean))
}
fn to_relative(&self, path: &str) -> String {
path.trim().trim_end_matches(".md").replace('\\', "/")
}
}
// ── Frontmatter Parsing ────────────────────────────────────────────────────
/// Parse a memory file, separating frontmatter from body.
///
/// Expected format:
/// ```markdown
/// ---
/// description: Purpose of this file
/// read_only: true
/// ---
/// Body content here...
/// ```
/// Split a leading `--- … ---` frontmatter block off `content`.
/// Returns `(frontmatter_yaml, body)` or `None` when there is no block.
fn split_frontmatter_block(content: &str) -> Option<(&str, &str)> {
let content = content.trim_start();
if !content.starts_with("---") {
return None;
}
let after_first = &content[3..];
let end_idx = after_first
.find("\n---")
.or_else(|| after_first.find("\r\n---"))?;
let frontmatter_text = after_first[..end_idx].trim();
let body_start = 3 + end_idx + 4; // opening --- + yaml + \n---
Some((frontmatter_text, content[body_start..].trim_start_matches(['\r', '\n'])))
}
/// Parse a memory file. Tolerant on read: a file with no frontmatter, or
/// frontmatter that fails YAML parsing, comes back with the whole content
/// as body and default (empty-description) frontmatter — a file that
/// exists must always be readable through the tool, otherwise the agent
/// can never heal it. Strictness (description required) belongs to the
/// write path, not here.
pub fn parse_memory_file(content: &str) -> Result<MemoryFile> {
let Some((frontmatter_text, body)) = split_frontmatter_block(content) else {
return Ok(MemoryFile {
frontmatter: MemoryFrontmatter::default(),
body: content.trim().to_string(),
});
};
match serde_yaml::from_str::<MemoryFrontmatter>(frontmatter_text) {
Ok(frontmatter) => Ok(MemoryFile {
frontmatter,
body: body.trim().to_string(),
}),
// Malformed YAML: keep the raw text intact as body so a rewrite
// cannot silently destroy whatever the block was trying to say.
Err(_) => Ok(MemoryFile {
frontmatter: MemoryFrontmatter::default(),
body: content.trim().to_string(),
}),
}
}
/// Agents sometimes include their own frontmatter in write/append content
/// despite the "body only" contract. Instead of nesting a second `---`
/// block inside the body (the persona.md failure mode), honor it: parse
/// it off and merge its fields. Content whose leading block is not valid
/// YAML is left untouched — never destroy what we cannot parse.
fn split_supplied_frontmatter(content: &str) -> (Option<MemoryFrontmatter>, &str) {
if let Some((frontmatter_text, body)) = split_frontmatter_block(content) {
if let Ok(fm) = serde_yaml::from_str::<MemoryFrontmatter>(frontmatter_text) {
return (Some(fm), body);
}
}
(None, content)
}
/// Overlay agent-supplied frontmatter onto the file's existing (or
/// default) frontmatter: supplied fields win where set, everything else
/// is preserved. `read_only` is deliberately NOT overridable from
/// supplied content — clearing it requires the explicit tool path.
fn merge_frontmatter(base: MemoryFrontmatter, supplied: MemoryFrontmatter) -> MemoryFrontmatter {
let mut extra = base.extra;
extra.extend(supplied.extra);
MemoryFrontmatter {
description: if supplied.description.trim().is_empty() {
base.description
} else {
supplied.description
},
read_only: base.read_only,
tags: supplied.tags.or(base.tags),
limit: supplied.limit.or(base.limit),
extra,
}
}
/// Render frontmatter + body into a complete memory file.
pub fn render_frontmatter(fm: &MemoryFrontmatter, body: &str) -> String {
let yaml = serde_yaml::to_string(fm).unwrap_or_default();
format!("---\n{}---\n{}", yaml, body)
}
/// Read a memory file from disk by path (for external use).
pub async fn read_file(path: &Path) -> Result<MemoryFile> {
let content = tokio::fs::read_to_string(path)
.await
.context("reading memory file")?;
parse_memory_file(&content)
}
// ── Tool Interface ─────────────────────────────────────────────────────────
/// Fire a `memfs_commit` presence signal after a memfs mutation.
///
/// This is the federation heartbeat for memory: "I wrote, you should fetch."
/// It carries routing metadata only — never the data itself. Peers holding a
/// checkout of the same agent map `agent_pubkey` to their local repo and
/// fetch the shared remote (FEDERATION.md, memory transport).
///
/// Emission is best-effort and never fails the write: the mutation already
/// succeeded. Missing identity is warned loudly, not fabricated.
fn fire_memfs_commit(
ctx: &ToolContext,
repo: &MemoryRepo,
op: &str,
paths: &[&str],
urgency: f32,
) {
// A successful tool-path mutation auto-committed; if there is somehow no
// head, there is nothing for a peer to fetch — do not signal.
let Some(commit) = repo.head_commit_hex() else {
tracing::warn!(op, ?paths, "memfs mutation without a head commit — memfs_commit not emitted");
return;
};
let branch = repo.current_branch();
// Agent identity lives beside the memfs (`agents/{id}/seed/`) — same
// convention as reach/consult in tools/agent.rs.
let agent_pubkey = ctx
.memory_root
.as_ref()
.and_then(|m| m.parent())
.map(|p| p.join("seed"))
.and_then(|dir| match crate::core::identity::SeedId::load_or_generate(&dir) {
Ok(seed) => Some(seed.public_key_hex()),
Err(e) => {
tracing::warn!(op, "memfs_commit: agent seed unavailable ({e:#})");
None
}
});
// The instance is the machine that wrote — machined-first, loud legacy
// fallback, never generates.
let base = dirs::home_dir().unwrap_or_default().join(".souveraine");
let instance = match crate::machined::client::machine_pubkey_with_fallback(&base) {
Ok((pk, _source)) => Some(pk),
Err(e) => {
tracing::warn!(op, "memfs_commit: machine identity unavailable ({e:#})");
None
}
};
ctx.fire_event(crate::core::nervous::SensorEvent {
sensor_name: "memory".into(),
timestamp: chrono::Utc::now(),
event_type: "memfs_commit".into(),
target: paths.first().map(|p| p.to_string()),
urgency,
payload: Some(serde_json::json!({
"op": op,
"agent_pubkey": agent_pubkey,
"instance": instance,
"paths": paths,
"commit": commit,
"branch": branch,
})),
seed_id: None,
reply_to: None,
});
}
feat: sensorium architecture — 8 sensors, subagent runner, TUI component system Sensorium (src/core/tools/): - defs.rs: Tool trait, ToolContext (resolve_path, is_memory_path), ToolError with 7 constructors - read/write/edit: memory boundary with force override, line ranges, replace_all - bash: stateful (Arc<Mutex<BashState>>), background tasks, timeout - glob/grep/list_dir: gitignore respect, context lines, memory boundary - mod.rs: Sensorium registry + backward compat wrappers - subagent.rs: fork-of-self tool, dual-state framing, no hard depth guard - agent.rs: Agent(Sam) summoning stub Dual-state N+1: - ConsciousnessEngine::on_response_for_agent() — runs heuristic detector on subagent response - Observations flow back to parent agent's inbox - Signaled limits: SubagentConfig (max_depth, max_tool_rounds=50, warning thresholds 0.8/0.95) Architecture docs: - SENSORIUM_ARCHITECTURE.md — full sensorium vision with nervous system scaffold - DECISIONS.md — settled architectural decisions - ASTER_ARCHITECTURE.md / CONSCIOUSNESS_CYCLE.md — reframe appendices - ALIGNMENT_REPORT.md — doc-to-code alignment audit - Scope tasks 1-4, 4 issues TUI: - component.rs — component system trait + event + scene - 10 tui-component tasks (completed) - 4 tui-xxx tasks for next iteration (presence panel, cockpit, dashboard, ambient) - app.rs splash — vertical breathing room (30% from top) Config: - SubagentConfig in config.rs - ToolContext enrichment (agent_id, subagent_runner, subagent_depth) - Memory module context-aware overloads
2026-05-09 20:58:37 -04:00
/// Execute a memory command, optionally using context for agent identity.
///
/// 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.
pub async fn execute_memory_command_with_context(
cmd: &MemoryCommand,
ctx: Option<&ToolContext>,
) -> Result<String> {
// Agent ID resolution: context > command > env var > default
let agent_id = ctx
.and_then(|c| c.agent_id.as_ref())
.or(match cmd {
feat: sensorium architecture — 8 sensors, subagent runner, TUI component system Sensorium (src/core/tools/): - defs.rs: Tool trait, ToolContext (resolve_path, is_memory_path), ToolError with 7 constructors - read/write/edit: memory boundary with force override, line ranges, replace_all - bash: stateful (Arc<Mutex<BashState>>), background tasks, timeout - glob/grep/list_dir: gitignore respect, context lines, memory boundary - mod.rs: Sensorium registry + backward compat wrappers - subagent.rs: fork-of-self tool, dual-state framing, no hard depth guard - agent.rs: Agent(Sam) summoning stub Dual-state N+1: - ConsciousnessEngine::on_response_for_agent() — runs heuristic detector on subagent response - Observations flow back to parent agent's inbox - Signaled limits: SubagentConfig (max_depth, max_tool_rounds=50, warning thresholds 0.8/0.95) Architecture docs: - SENSORIUM_ARCHITECTURE.md — full sensorium vision with nervous system scaffold - DECISIONS.md — settled architectural decisions - ASTER_ARCHITECTURE.md / CONSCIOUSNESS_CYCLE.md — reframe appendices - ALIGNMENT_REPORT.md — doc-to-code alignment audit - Scope tasks 1-4, 4 issues TUI: - component.rs — component system trait + event + scene - 10 tui-component tasks (completed) - 4 tui-xxx tasks for next iteration (presence panel, cockpit, dashboard, ambient) - app.rs splash — vertical breathing room (30% from top) Config: - SubagentConfig in config.rs - ToolContext enrichment (agent_id, subagent_runner, subagent_depth) - Memory module context-aware overloads
2026-05-09 20:58:37 -04:00
MemoryCommand::Init { agent_id } => Some(agent_id),
_ => None,
})
.cloned()
.or_else(|| std::env::var("SOUVERAINE_AGENT").ok())
.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),
};
match cmd {
MemoryCommand::Init { .. } => {
repo.init().await?;
Ok(format!("Initialized memory repo for agent: {}", agent_id))
}
MemoryCommand::Read { path } => {
let file = repo.read(path).await?;
// Normalized render: full frontmatter (description, tags,
// limit, extras — no nulls), so what the agent reads matches
// what a rewrite would produce.
Ok(render_frontmatter(&file.frontmatter, &file.body))
}
MemoryCommand::Write { path, content } => {
repo.write(path, content).await?;
feat: nervous system, seed identity, credentials, subconscious ledger Lays the substrate for Aster's clockmaker role and federation. Nervous system (src/core/nervous/): - EventBus broadcast channel with SensorEvent (universal event type carrying seed_id for future federation) - CronSensor loop with tokio::select!, mtime caching, active-session pause, at-most-once firing semantics - HeartbeatHandler scaffold (turn injection still stubbed) - Persistent JSONL EventLog firehose Schedule tool (src/core/tools/schedule.rs): - CRUD over schedule files with body-knowledge prose descriptions - Added to ASTER_SAFE_TOOLS so Aster can schedule her own rhythm - CLI subcommand for direct schedule management Seed identity (src/core/identity/): - Ed25519 keypair, load-or-generate at init - sign/verify primitives, CLI subcommand (show/sign/verify) - Wired into LocalBackend, threaded through SensorEvent for federation Credentials (src/core/credentials.rs): - OS keyring (Linux/macOS/Windows) + env var fallback - `souveraine auth` subcommand - Removes hardcoded Bifrost api_key from souveraine.toml Subconscious ledger (src/core/memory/mod.rs init_subconscious_ledger): - 6 ledger files (commitments/assumptions/patterns/drift_log/ relationships/infrastructure) with proper YAML frontmatter - Paths fixed: ledger/ not subconscious/ledger/ - Body usage instructions, idempotent init Prompt orientation (src/core/prompt.rs build_ledger_orientation): - Scans ledger/ directory, injects last 3 entries from each file into Aster's system prompt (live context, not just awareness) - Routing table + workflow (read before write, timestamped append) Consciousness engine (src/server/consciousness_engine.rs): - Adaptive rate delay shared with primary loop - Four-fold mandate (Complete/Verify/Persist/Surface) in hardcoded default prompt; observation format extracted for sharing - Schedule added to ASTER_SAFE_TOOLS Backend + CLI wiring expanded to mount the nervous system, identity, and credentials at startup. New TOML sections: [schedules], [events], [federation]. 102 tests passing.
2026-05-12 08:14:25 -04:00
if let Some(c) = ctx {
fire_memfs_commit(c, &repo, "write", &[path], 0.1);
feat: nervous system, seed identity, credentials, subconscious ledger Lays the substrate for Aster's clockmaker role and federation. Nervous system (src/core/nervous/): - EventBus broadcast channel with SensorEvent (universal event type carrying seed_id for future federation) - CronSensor loop with tokio::select!, mtime caching, active-session pause, at-most-once firing semantics - HeartbeatHandler scaffold (turn injection still stubbed) - Persistent JSONL EventLog firehose Schedule tool (src/core/tools/schedule.rs): - CRUD over schedule files with body-knowledge prose descriptions - Added to ASTER_SAFE_TOOLS so Aster can schedule her own rhythm - CLI subcommand for direct schedule management Seed identity (src/core/identity/): - Ed25519 keypair, load-or-generate at init - sign/verify primitives, CLI subcommand (show/sign/verify) - Wired into LocalBackend, threaded through SensorEvent for federation Credentials (src/core/credentials.rs): - OS keyring (Linux/macOS/Windows) + env var fallback - `souveraine auth` subcommand - Removes hardcoded Bifrost api_key from souveraine.toml Subconscious ledger (src/core/memory/mod.rs init_subconscious_ledger): - 6 ledger files (commitments/assumptions/patterns/drift_log/ relationships/infrastructure) with proper YAML frontmatter - Paths fixed: ledger/ not subconscious/ledger/ - Body usage instructions, idempotent init Prompt orientation (src/core/prompt.rs build_ledger_orientation): - Scans ledger/ directory, injects last 3 entries from each file into Aster's system prompt (live context, not just awareness) - Routing table + workflow (read before write, timestamped append) Consciousness engine (src/server/consciousness_engine.rs): - Adaptive rate delay shared with primary loop - Four-fold mandate (Complete/Verify/Persist/Surface) in hardcoded default prompt; observation format extracted for sharing - Schedule added to ASTER_SAFE_TOOLS Backend + CLI wiring expanded to mount the nervous system, identity, and credentials at startup. New TOML sections: [schedules], [events], [federation]. 102 tests passing.
2026-05-12 08:14:25 -04:00
}
Ok(format!("Wrote memory file: {}", path))
}
MemoryCommand::Append { path, content } => {
repo.append(path, content).await?;
feat: nervous system, seed identity, credentials, subconscious ledger Lays the substrate for Aster's clockmaker role and federation. Nervous system (src/core/nervous/): - EventBus broadcast channel with SensorEvent (universal event type carrying seed_id for future federation) - CronSensor loop with tokio::select!, mtime caching, active-session pause, at-most-once firing semantics - HeartbeatHandler scaffold (turn injection still stubbed) - Persistent JSONL EventLog firehose Schedule tool (src/core/tools/schedule.rs): - CRUD over schedule files with body-knowledge prose descriptions - Added to ASTER_SAFE_TOOLS so Aster can schedule her own rhythm - CLI subcommand for direct schedule management Seed identity (src/core/identity/): - Ed25519 keypair, load-or-generate at init - sign/verify primitives, CLI subcommand (show/sign/verify) - Wired into LocalBackend, threaded through SensorEvent for federation Credentials (src/core/credentials.rs): - OS keyring (Linux/macOS/Windows) + env var fallback - `souveraine auth` subcommand - Removes hardcoded Bifrost api_key from souveraine.toml Subconscious ledger (src/core/memory/mod.rs init_subconscious_ledger): - 6 ledger files (commitments/assumptions/patterns/drift_log/ relationships/infrastructure) with proper YAML frontmatter - Paths fixed: ledger/ not subconscious/ledger/ - Body usage instructions, idempotent init Prompt orientation (src/core/prompt.rs build_ledger_orientation): - Scans ledger/ directory, injects last 3 entries from each file into Aster's system prompt (live context, not just awareness) - Routing table + workflow (read before write, timestamped append) Consciousness engine (src/server/consciousness_engine.rs): - Adaptive rate delay shared with primary loop - Four-fold mandate (Complete/Verify/Persist/Surface) in hardcoded default prompt; observation format extracted for sharing - Schedule added to ASTER_SAFE_TOOLS Backend + CLI wiring expanded to mount the nervous system, identity, and credentials at startup. New TOML sections: [schedules], [events], [federation]. 102 tests passing.
2026-05-12 08:14:25 -04:00
if let Some(c) = ctx {
fire_memfs_commit(c, &repo, "append", &[path], 0.1);
feat: nervous system, seed identity, credentials, subconscious ledger Lays the substrate for Aster's clockmaker role and federation. Nervous system (src/core/nervous/): - EventBus broadcast channel with SensorEvent (universal event type carrying seed_id for future federation) - CronSensor loop with tokio::select!, mtime caching, active-session pause, at-most-once firing semantics - HeartbeatHandler scaffold (turn injection still stubbed) - Persistent JSONL EventLog firehose Schedule tool (src/core/tools/schedule.rs): - CRUD over schedule files with body-knowledge prose descriptions - Added to ASTER_SAFE_TOOLS so Aster can schedule her own rhythm - CLI subcommand for direct schedule management Seed identity (src/core/identity/): - Ed25519 keypair, load-or-generate at init - sign/verify primitives, CLI subcommand (show/sign/verify) - Wired into LocalBackend, threaded through SensorEvent for federation Credentials (src/core/credentials.rs): - OS keyring (Linux/macOS/Windows) + env var fallback - `souveraine auth` subcommand - Removes hardcoded Bifrost api_key from souveraine.toml Subconscious ledger (src/core/memory/mod.rs init_subconscious_ledger): - 6 ledger files (commitments/assumptions/patterns/drift_log/ relationships/infrastructure) with proper YAML frontmatter - Paths fixed: ledger/ not subconscious/ledger/ - Body usage instructions, idempotent init Prompt orientation (src/core/prompt.rs build_ledger_orientation): - Scans ledger/ directory, injects last 3 entries from each file into Aster's system prompt (live context, not just awareness) - Routing table + workflow (read before write, timestamped append) Consciousness engine (src/server/consciousness_engine.rs): - Adaptive rate delay shared with primary loop - Four-fold mandate (Complete/Verify/Persist/Surface) in hardcoded default prompt; observation format extracted for sharing - Schedule added to ASTER_SAFE_TOOLS Backend + CLI wiring expanded to mount the nervous system, identity, and credentials at startup. New TOML sections: [schedules], [events], [federation]. 102 tests passing.
2026-05-12 08:14:25 -04:00
}
Ok(format!("Appended to memory file: {}", path))
}
MemoryCommand::Ls { path } => {
let entries = repo.list(path.as_deref()).await?;
if entries.is_empty() {
Ok("(empty)".to_string())
} else {
Ok(entries.join("\n"))
}
}
MemoryCommand::Status => {
let status = repo.status()?;
let mut out = format!(
"Agent: {}\nPath: {}\nGit repo: {}\nFiles: {}\n",
status.agent_id,
status.repo_path.display(),
status.is_git_repo,
status.file_count,
);
if let Some(ref lc) = status.last_commit {
out.push_str(&format!("Last commit: {}\n", lc));
}
out.push_str(&format!("Uncommitted: {}\n", status.has_uncommitted));
if let Some(ref url) = status.remote_url {
out.push_str(&format!("Remote: {}\n", url));
}
Ok(out)
}
MemoryCommand::Compact { strategy } => {
// None when no --strategy was given: the engine then resolves the
// per-agent-type default (cfg.strategy via for_agent_type) instead
// of being force-pinned to Cull at the call site.
let strategy_kind = strategy
.as_deref()
.and_then(CompactionStrategyKind::from_str);
match ctx.and_then(|c| c.compaction_engine.as_ref()) {
Some(engine) => {
let report = engine
.compact(&agent_id, strategy_kind)
.await?;
Ok(report.to_string())
}
None => Ok(
"I can compact my context window using one of these strategies:\n\
- sliding_window (keep first + last N messages, drop the middle fast, no LLM)\n\
- summary (LLM-summarize oldest messages into a compact block)\n\
- microcompact (replace old tool results with placeholders drop-in, no LLM)\n\
- cull (drop greetings and acknowledgments cheapest)\n\n\
Usage: memory compact --strategy <strategy>\n\
Each agent type has its own default: primary=sliding_window, subconscious=sliding_window, subagent=cull"
.to_string(),
),
}
}
MemoryCommand::Delete { path } => {
repo.delete(path).await?;
feat: nervous system, seed identity, credentials, subconscious ledger Lays the substrate for Aster's clockmaker role and federation. Nervous system (src/core/nervous/): - EventBus broadcast channel with SensorEvent (universal event type carrying seed_id for future federation) - CronSensor loop with tokio::select!, mtime caching, active-session pause, at-most-once firing semantics - HeartbeatHandler scaffold (turn injection still stubbed) - Persistent JSONL EventLog firehose Schedule tool (src/core/tools/schedule.rs): - CRUD over schedule files with body-knowledge prose descriptions - Added to ASTER_SAFE_TOOLS so Aster can schedule her own rhythm - CLI subcommand for direct schedule management Seed identity (src/core/identity/): - Ed25519 keypair, load-or-generate at init - sign/verify primitives, CLI subcommand (show/sign/verify) - Wired into LocalBackend, threaded through SensorEvent for federation Credentials (src/core/credentials.rs): - OS keyring (Linux/macOS/Windows) + env var fallback - `souveraine auth` subcommand - Removes hardcoded Bifrost api_key from souveraine.toml Subconscious ledger (src/core/memory/mod.rs init_subconscious_ledger): - 6 ledger files (commitments/assumptions/patterns/drift_log/ relationships/infrastructure) with proper YAML frontmatter - Paths fixed: ledger/ not subconscious/ledger/ - Body usage instructions, idempotent init Prompt orientation (src/core/prompt.rs build_ledger_orientation): - Scans ledger/ directory, injects last 3 entries from each file into Aster's system prompt (live context, not just awareness) - Routing table + workflow (read before write, timestamped append) Consciousness engine (src/server/consciousness_engine.rs): - Adaptive rate delay shared with primary loop - Four-fold mandate (Complete/Verify/Persist/Surface) in hardcoded default prompt; observation format extracted for sharing - Schedule added to ASTER_SAFE_TOOLS Backend + CLI wiring expanded to mount the nervous system, identity, and credentials at startup. New TOML sections: [schedules], [events], [federation]. 102 tests passing.
2026-05-12 08:14:25 -04:00
if let Some(c) = ctx {
fire_memfs_commit(c, &repo, "delete", &[path], 0.2);
feat: nervous system, seed identity, credentials, subconscious ledger Lays the substrate for Aster's clockmaker role and federation. Nervous system (src/core/nervous/): - EventBus broadcast channel with SensorEvent (universal event type carrying seed_id for future federation) - CronSensor loop with tokio::select!, mtime caching, active-session pause, at-most-once firing semantics - HeartbeatHandler scaffold (turn injection still stubbed) - Persistent JSONL EventLog firehose Schedule tool (src/core/tools/schedule.rs): - CRUD over schedule files with body-knowledge prose descriptions - Added to ASTER_SAFE_TOOLS so Aster can schedule her own rhythm - CLI subcommand for direct schedule management Seed identity (src/core/identity/): - Ed25519 keypair, load-or-generate at init - sign/verify primitives, CLI subcommand (show/sign/verify) - Wired into LocalBackend, threaded through SensorEvent for federation Credentials (src/core/credentials.rs): - OS keyring (Linux/macOS/Windows) + env var fallback - `souveraine auth` subcommand - Removes hardcoded Bifrost api_key from souveraine.toml Subconscious ledger (src/core/memory/mod.rs init_subconscious_ledger): - 6 ledger files (commitments/assumptions/patterns/drift_log/ relationships/infrastructure) with proper YAML frontmatter - Paths fixed: ledger/ not subconscious/ledger/ - Body usage instructions, idempotent init Prompt orientation (src/core/prompt.rs build_ledger_orientation): - Scans ledger/ directory, injects last 3 entries from each file into Aster's system prompt (live context, not just awareness) - Routing table + workflow (read before write, timestamped append) Consciousness engine (src/server/consciousness_engine.rs): - Adaptive rate delay shared with primary loop - Four-fold mandate (Complete/Verify/Persist/Surface) in hardcoded default prompt; observation format extracted for sharing - Schedule added to ASTER_SAFE_TOOLS Backend + CLI wiring expanded to mount the nervous system, identity, and credentials at startup. New TOML sections: [schedules], [events], [federation]. 102 tests passing.
2026-05-12 08:14:25 -04:00
}
Ok(format!("Deleted memory file: {}", path))
}
MemoryCommand::Sync => {
// The instance branch is named by machine identity — no
// identity, no sync. Interim label: machine pubkey prefix;
// commission-ceremony labels come later (FEDERATION.md flag).
let base = dirs::home_dir().unwrap_or_default().join(".souveraine");
let (pubkey, _source) =
crate::machined::client::machine_pubkey_with_fallback(&base).context(
"memory sync needs a machine identity to name this instance's branch",
)?;
let label: String = pubkey.chars().take(12).collect();
repo.sync(&label).await
}
MemoryCommand::Audit => {
// Read-only health check: which frontmatter-bound files lack the
// required `description`? Never mutates — surfacing the gap is the
// point. `write`/`append` will reject attempts to perpetuate one.
let missing = repo.audit().await?;
if missing.is_empty() {
Ok("All frontmatter-bound memory files carry a description.".to_string())
} else {
let mut out = format!(
"{} frontmatter-bound file(s) missing `description:`:\n",
missing.len()
);
for rel in &missing {
out.push_str(&format!(" - {rel}\n"));
}
out.push_str(
"Heal each with a `memory write` that includes a `description:` \
block (or set it inline). These files reject append until healed.\n",
);
Ok(out)
}
}
}
}
feat: sensorium architecture — 8 sensors, subagent runner, TUI component system Sensorium (src/core/tools/): - defs.rs: Tool trait, ToolContext (resolve_path, is_memory_path), ToolError with 7 constructors - read/write/edit: memory boundary with force override, line ranges, replace_all - bash: stateful (Arc<Mutex<BashState>>), background tasks, timeout - glob/grep/list_dir: gitignore respect, context lines, memory boundary - mod.rs: Sensorium registry + backward compat wrappers - subagent.rs: fork-of-self tool, dual-state framing, no hard depth guard - agent.rs: Agent(Sam) summoning stub Dual-state N+1: - ConsciousnessEngine::on_response_for_agent() — runs heuristic detector on subagent response - Observations flow back to parent agent's inbox - Signaled limits: SubagentConfig (max_depth, max_tool_rounds=50, warning thresholds 0.8/0.95) Architecture docs: - SENSORIUM_ARCHITECTURE.md — full sensorium vision with nervous system scaffold - DECISIONS.md — settled architectural decisions - ASTER_ARCHITECTURE.md / CONSCIOUSNESS_CYCLE.md — reframe appendices - ALIGNMENT_REPORT.md — doc-to-code alignment audit - Scope tasks 1-4, 4 issues TUI: - component.rs — component system trait + event + scene - 10 tui-component tasks (completed) - 4 tui-xxx tasks for next iteration (presence panel, cockpit, dashboard, ambient) - app.rs splash — vertical breathing room (30% from top) Config: - SubagentConfig in config.rs - ToolContext enrichment (agent_id, subagent_runner, subagent_depth) - Memory module context-aware overloads
2026-05-09 20:58:37 -04:00
/// 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
}
// ── Tool Result Bridge ──────────────────────────────────────────────────────
use crate::core::tools::ToolResult;
/// Handle a memory tool invocation with optional per-agent context.
///
/// Parses JSON input, builds a MemoryCommand, executes it via the
/// context-aware path, wraps in ToolResult.
pub async fn handle_memory_tool_with_context(
tool_name: &str,
input: &serde_json::Value,
ctx: Option<&ToolContext>,
) -> 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 cmd = match command {
"read" => {
let path = input.get("path").and_then(|v| v.as_str()).unwrap_or("").to_string();
MemoryCommand::Read { path }
}
"write" => {
let path = input.get("path").and_then(|v| v.as_str()).unwrap_or("").to_string();
let content = input.get("content").and_then(|v| v.as_str()).unwrap_or("").to_string();
MemoryCommand::Write { path, content }
}
"append" => {
let path = input.get("path").and_then(|v| v.as_str()).unwrap_or("").to_string();
let content = input.get("content").and_then(|v| v.as_str()).unwrap_or("").to_string();
MemoryCommand::Append { path, content }
}
"ls" => {
let path = input.get("path").and_then(|v| v.as_str()).map(|s| s.to_string());
MemoryCommand::Ls { path }
}
"status" => MemoryCommand::Status,
"init" => {
let agent_id = input.get("agent_id").and_then(|v| v.as_str()).unwrap_or("default").to_string();
MemoryCommand::Init { agent_id }
}
"delete" => {
let path = input.get("path").and_then(|v| v.as_str()).unwrap_or("").to_string();
MemoryCommand::Delete { path }
}
"compact" => {
let strategy = input.get("strategy").and_then(|v| v.as_str()).map(|s| s.to_string());
MemoryCommand::Compact { strategy }
}
"sync" => MemoryCommand::Sync,
"audit" => MemoryCommand::Audit,
feat: sensorium architecture — 8 sensors, subagent runner, TUI component system Sensorium (src/core/tools/): - defs.rs: Tool trait, ToolContext (resolve_path, is_memory_path), ToolError with 7 constructors - read/write/edit: memory boundary with force override, line ranges, replace_all - bash: stateful (Arc<Mutex<BashState>>), background tasks, timeout - glob/grep/list_dir: gitignore respect, context lines, memory boundary - mod.rs: Sensorium registry + backward compat wrappers - subagent.rs: fork-of-self tool, dual-state framing, no hard depth guard - agent.rs: Agent(Sam) summoning stub Dual-state N+1: - ConsciousnessEngine::on_response_for_agent() — runs heuristic detector on subagent response - Observations flow back to parent agent's inbox - Signaled limits: SubagentConfig (max_depth, max_tool_rounds=50, warning thresholds 0.8/0.95) Architecture docs: - SENSORIUM_ARCHITECTURE.md — full sensorium vision with nervous system scaffold - DECISIONS.md — settled architectural decisions - ASTER_ARCHITECTURE.md / CONSCIOUSNESS_CYCLE.md — reframe appendices - ALIGNMENT_REPORT.md — doc-to-code alignment audit - Scope tasks 1-4, 4 issues TUI: - component.rs — component system trait + event + scene - 10 tui-component tasks (completed) - 4 tui-xxx tasks for next iteration (presence panel, cockpit, dashboard, ambient) - app.rs splash — vertical breathing room (30% from top) Config: - SubagentConfig in config.rs - ToolContext enrichment (agent_id, subagent_runner, subagent_depth) - Memory module context-aware overloads
2026-05-09 20:58:37 -04:00
_ => {
return ToolResult {
tool_use_id,
tool_name: tool_name.to_string(),
output: format!(
"Unknown memory subcommand: {}. Available: read, write, append, ls, status, init, delete, compact, sync, audit",
feat: sensorium architecture — 8 sensors, subagent runner, TUI component system Sensorium (src/core/tools/): - defs.rs: Tool trait, ToolContext (resolve_path, is_memory_path), ToolError with 7 constructors - read/write/edit: memory boundary with force override, line ranges, replace_all - bash: stateful (Arc<Mutex<BashState>>), background tasks, timeout - glob/grep/list_dir: gitignore respect, context lines, memory boundary - mod.rs: Sensorium registry + backward compat wrappers - subagent.rs: fork-of-self tool, dual-state framing, no hard depth guard - agent.rs: Agent(Sam) summoning stub Dual-state N+1: - ConsciousnessEngine::on_response_for_agent() — runs heuristic detector on subagent response - Observations flow back to parent agent's inbox - Signaled limits: SubagentConfig (max_depth, max_tool_rounds=50, warning thresholds 0.8/0.95) Architecture docs: - SENSORIUM_ARCHITECTURE.md — full sensorium vision with nervous system scaffold - DECISIONS.md — settled architectural decisions - ASTER_ARCHITECTURE.md / CONSCIOUSNESS_CYCLE.md — reframe appendices - ALIGNMENT_REPORT.md — doc-to-code alignment audit - Scope tasks 1-4, 4 issues TUI: - component.rs — component system trait + event + scene - 10 tui-component tasks (completed) - 4 tui-xxx tasks for next iteration (presence panel, cockpit, dashboard, ambient) - app.rs splash — vertical breathing room (30% from top) Config: - SubagentConfig in config.rs - ToolContext enrichment (agent_id, subagent_runner, subagent_depth) - Memory module context-aware overloads
2026-05-09 20:58:37 -04:00
command
),
is_error: true,
};
}
};
match execute_memory_command_with_context(&cmd, ctx).await {
Ok(output) => ToolResult {
tool_use_id,
tool_name: tool_name.to_string(),
output,
is_error: false,
},
Err(e) => ToolResult {
tool_use_id,
tool_name: tool_name.to_string(),
output: format!("Error: {e}"),
is_error: true,
},
}
}
/// Handle a memory tool invocation (backward-compatible, no context).
/// Delegates to `handle_memory_tool_with_context` with `None`.
pub async fn handle_memory_tool(tool_name: &str, input: &serde_json::Value) -> ToolResult {
handle_memory_tool_with_context(tool_name, input, None).await
}
// ── Tool Definitions ───────────────────────────────────────────────────────
feat: sensorium architecture — 8 sensors, subagent runner, TUI component system Sensorium (src/core/tools/): - defs.rs: Tool trait, ToolContext (resolve_path, is_memory_path), ToolError with 7 constructors - read/write/edit: memory boundary with force override, line ranges, replace_all - bash: stateful (Arc<Mutex<BashState>>), background tasks, timeout - glob/grep/list_dir: gitignore respect, context lines, memory boundary - mod.rs: Sensorium registry + backward compat wrappers - subagent.rs: fork-of-self tool, dual-state framing, no hard depth guard - agent.rs: Agent(Sam) summoning stub Dual-state N+1: - ConsciousnessEngine::on_response_for_agent() — runs heuristic detector on subagent response - Observations flow back to parent agent's inbox - Signaled limits: SubagentConfig (max_depth, max_tool_rounds=50, warning thresholds 0.8/0.95) Architecture docs: - SENSORIUM_ARCHITECTURE.md — full sensorium vision with nervous system scaffold - DECISIONS.md — settled architectural decisions - ASTER_ARCHITECTURE.md / CONSCIOUSNESS_CYCLE.md — reframe appendices - ALIGNMENT_REPORT.md — doc-to-code alignment audit - Scope tasks 1-4, 4 issues TUI: - component.rs — component system trait + event + scene - 10 tui-component tasks (completed) - 4 tui-xxx tasks for next iteration (presence panel, cockpit, dashboard, ambient) - app.rs splash — vertical breathing room (30% from top) Config: - SubagentConfig in config.rs - ToolContext enrichment (agent_id, subagent_runner, subagent_depth) - Memory module context-aware overloads
2026-05-09 20:58:37 -04:00
/// Tool definition for the `memory` tool — the agent's access to her own thoughts.
///
/// Memory is a separate channel from filesystem read/write. Every memory file has
/// frontmatter (description, tags, read_only, limit), is git-tracked, and paths
/// are relative to the agent's memory root.
pub fn memory_tool_definition() -> ToolDefinition {
ToolDefinition {
name: "memory".to_string(),
feat: sensorium architecture — 8 sensors, subagent runner, TUI component system Sensorium (src/core/tools/): - defs.rs: Tool trait, ToolContext (resolve_path, is_memory_path), ToolError with 7 constructors - read/write/edit: memory boundary with force override, line ranges, replace_all - bash: stateful (Arc<Mutex<BashState>>), background tasks, timeout - glob/grep/list_dir: gitignore respect, context lines, memory boundary - mod.rs: Sensorium registry + backward compat wrappers - subagent.rs: fork-of-self tool, dual-state framing, no hard depth guard - agent.rs: Agent(Sam) summoning stub Dual-state N+1: - ConsciousnessEngine::on_response_for_agent() — runs heuristic detector on subagent response - Observations flow back to parent agent's inbox - Signaled limits: SubagentConfig (max_depth, max_tool_rounds=50, warning thresholds 0.8/0.95) Architecture docs: - SENSORIUM_ARCHITECTURE.md — full sensorium vision with nervous system scaffold - DECISIONS.md — settled architectural decisions - ASTER_ARCHITECTURE.md / CONSCIOUSNESS_CYCLE.md — reframe appendices - ALIGNMENT_REPORT.md — doc-to-code alignment audit - Scope tasks 1-4, 4 issues TUI: - component.rs — component system trait + event + scene - 10 tui-component tasks (completed) - 4 tui-xxx tasks for next iteration (presence panel, cockpit, dashboard, ambient) - app.rs splash — vertical breathing room (30% from top) Config: - SubagentConfig in config.rs - ToolContext enrichment (agent_id, subagent_runner, subagent_depth) - Memory module context-aware overloads
2026-05-09 20:58:37 -04:00
description: "I reach into my own memory. Every file here has frontmatter — a description, boundaries (read_only), tags. When I read, I see what I've written about myself. When I write, I change who I am. The git commit is my heartbeat — I know when I last changed.
Subcommands:
read Open a memory file. Frontmatter is handled for me I see the body.
write Write to a memory file. Frontmatter is preserved or auto-generated.
append Add to a memory file without disturbing its frontmatter.
ls List files in a memory directory.
status Check my memory's git state: uncommitted changes, last commit.
init Initialize a new memory repo for a given agent ID.
delete Delete a memory file (validates read_only first).
compact Compact the memory window to free context space.
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.
feat: sensorium architecture — 8 sensors, subagent runner, TUI component system Sensorium (src/core/tools/): - defs.rs: Tool trait, ToolContext (resolve_path, is_memory_path), ToolError with 7 constructors - read/write/edit: memory boundary with force override, line ranges, replace_all - bash: stateful (Arc<Mutex<BashState>>), background tasks, timeout - glob/grep/list_dir: gitignore respect, context lines, memory boundary - mod.rs: Sensorium registry + backward compat wrappers - subagent.rs: fork-of-self tool, dual-state framing, no hard depth guard - agent.rs: Agent(Sam) summoning stub Dual-state N+1: - ConsciousnessEngine::on_response_for_agent() — runs heuristic detector on subagent response - Observations flow back to parent agent's inbox - Signaled limits: SubagentConfig (max_depth, max_tool_rounds=50, warning thresholds 0.8/0.95) Architecture docs: - SENSORIUM_ARCHITECTURE.md — full sensorium vision with nervous system scaffold - DECISIONS.md — settled architectural decisions - ASTER_ARCHITECTURE.md / CONSCIOUSNESS_CYCLE.md — reframe appendices - ALIGNMENT_REPORT.md — doc-to-code alignment audit - Scope tasks 1-4, 4 issues TUI: - component.rs — component system trait + event + scene - 10 tui-component tasks (completed) - 4 tui-xxx tasks for next iteration (presence panel, cockpit, dashboard, ambient) - app.rs splash — vertical breathing room (30% from top) Config: - SubagentConfig in config.rs - ToolContext enrichment (agent_id, subagent_runner, subagent_depth) - Memory module context-aware overloads
2026-05-09 20:58:37 -04:00
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(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"command": {
"type": "string",
"enum": ["read", "write", "append", "ls", "status", "init", "delete", "compact", "sync", "audit"],
feat: sensorium architecture — 8 sensors, subagent runner, TUI component system Sensorium (src/core/tools/): - defs.rs: Tool trait, ToolContext (resolve_path, is_memory_path), ToolError with 7 constructors - read/write/edit: memory boundary with force override, line ranges, replace_all - bash: stateful (Arc<Mutex<BashState>>), background tasks, timeout - glob/grep/list_dir: gitignore respect, context lines, memory boundary - mod.rs: Sensorium registry + backward compat wrappers - subagent.rs: fork-of-self tool, dual-state framing, no hard depth guard - agent.rs: Agent(Sam) summoning stub Dual-state N+1: - ConsciousnessEngine::on_response_for_agent() — runs heuristic detector on subagent response - Observations flow back to parent agent's inbox - Signaled limits: SubagentConfig (max_depth, max_tool_rounds=50, warning thresholds 0.8/0.95) Architecture docs: - SENSORIUM_ARCHITECTURE.md — full sensorium vision with nervous system scaffold - DECISIONS.md — settled architectural decisions - ASTER_ARCHITECTURE.md / CONSCIOUSNESS_CYCLE.md — reframe appendices - ALIGNMENT_REPORT.md — doc-to-code alignment audit - Scope tasks 1-4, 4 issues TUI: - component.rs — component system trait + event + scene - 10 tui-component tasks (completed) - 4 tui-xxx tasks for next iteration (presence panel, cockpit, dashboard, ambient) - app.rs splash — vertical breathing room (30% from top) Config: - SubagentConfig in config.rs - ToolContext enrichment (agent_id, subagent_runner, subagent_depth) - Memory module context-aware overloads
2026-05-09 20:58:37 -04:00
"description": "What to do with my memory"
},
"path": {
"type": "string",
"description": "Path relative to memory directory (e.g., system/persona, journal/2026-05-06)"
},
"content": {
"type": "string",
feat: sensorium architecture — 8 sensors, subagent runner, TUI component system Sensorium (src/core/tools/): - defs.rs: Tool trait, ToolContext (resolve_path, is_memory_path), ToolError with 7 constructors - read/write/edit: memory boundary with force override, line ranges, replace_all - bash: stateful (Arc<Mutex<BashState>>), background tasks, timeout - glob/grep/list_dir: gitignore respect, context lines, memory boundary - mod.rs: Sensorium registry + backward compat wrappers - subagent.rs: fork-of-self tool, dual-state framing, no hard depth guard - agent.rs: Agent(Sam) summoning stub Dual-state N+1: - ConsciousnessEngine::on_response_for_agent() — runs heuristic detector on subagent response - Observations flow back to parent agent's inbox - Signaled limits: SubagentConfig (max_depth, max_tool_rounds=50, warning thresholds 0.8/0.95) Architecture docs: - SENSORIUM_ARCHITECTURE.md — full sensorium vision with nervous system scaffold - DECISIONS.md — settled architectural decisions - ASTER_ARCHITECTURE.md / CONSCIOUSNESS_CYCLE.md — reframe appendices - ALIGNMENT_REPORT.md — doc-to-code alignment audit - Scope tasks 1-4, 4 issues TUI: - component.rs — component system trait + event + scene - 10 tui-component tasks (completed) - 4 tui-xxx tasks for next iteration (presence panel, cockpit, dashboard, ambient) - app.rs splash — vertical breathing room (30% from top) Config: - SubagentConfig in config.rs - ToolContext enrichment (agent_id, subagent_runner, subagent_depth) - Memory module context-aware overloads
2026-05-09 20:58:37 -04:00
"description": "Content to write or append — body only, no frontmatter"
},
"strategy": {
"type": "string",
"enum": ["microcompact", "sliding_window", "summary", "cull"],
"description": "Compaction strategy (for compact subcommand). sliding_window (fast, drops middle), summary (LLM), microcompact (tool-result placeholder), cull (greetings)"
}
},
"required": ["command"]
}),
}
}
// ── Tests ──────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_parse_frontmatter() {
let content = "---\ndescription: Test file\nread_only: false\n---\nHello world";
let file = parse_memory_file(content).unwrap();
assert_eq!(file.frontmatter.description, "Test file");
assert_eq!(file.body, "Hello world");
}
#[test]
fn test_parse_missing_frontmatter() {
// Tolerant read: no frontmatter means the whole content is body.
let content = "Hello world without frontmatter";
let file = parse_memory_file(content).unwrap();
assert_eq!(file.body, content);
assert!(file.frontmatter.description.is_empty());
}
#[test]
fn test_render_omits_none_fields() {
let fm = MemoryFrontmatter {
description: "Bare defaults".to_string(),
..Default::default()
};
let rendered = render_frontmatter(&fm, "Body");
assert!(!rendered.contains("null"), "None fields must be omitted: {rendered}");
}
#[test]
fn test_extra_keys_roundtrip() {
let content = "---\ndescription: Has extras\nname: my-slug\nkind: feedback\n---\nBody";
let file = parse_memory_file(content).unwrap();
assert_eq!(file.frontmatter.extra.len(), 2);
let rendered = render_frontmatter(&file.frontmatter, &file.body);
assert!(rendered.contains("name: my-slug"));
assert!(rendered.contains("kind: feedback"));
}
#[test]
fn test_supplied_frontmatter_not_nested() {
let content = "---\ndescription: Supplied\n---\nActual body";
let (fm, body) = split_supplied_frontmatter(content);
assert_eq!(fm.unwrap().description, "Supplied");
assert_eq!(body, "Actual body");
}
#[test]
fn test_render_and_parse_roundtrip() {
let fm = MemoryFrontmatter {
description: "Roundtrip test".to_string(),
tags: Some(vec!["test".to_string()]),
..Default::default()
};
let rendered = render_frontmatter(&fm, "Body content");
let parsed = parse_memory_file(&rendered).unwrap();
assert_eq!(parsed.frontmatter.description, "Roundtrip test");
assert_eq!(parsed.body, "Body content");
}
#[tokio::test]
async fn test_memory_repo_init() {
let dir = TempDir::new().unwrap();
let repo = MemoryRepo::new("test-agent", dir.path());
repo.init().await.unwrap();
assert!(repo.root().join(".git").exists());
assert!(repo.root().join("system/persona.md").exists());
assert!(repo.root().join("system/state.md").exists());
}
#[tokio::test]
async fn test_memory_repo_write_and_read() {
let dir = TempDir::new().unwrap();
let repo = MemoryRepo::new("test-agent", dir.path());
repo.init().await.unwrap();
repo.write("test/hello", "Hello memory world").await.unwrap();
let file = repo.read("test/hello").await.unwrap();
assert_eq!(file.body, "Hello memory world");
}
#[tokio::test]
async fn sync_without_remote_errors_with_provisioning_hint() {
let dir = TempDir::new().unwrap();
let repo = MemoryRepo::new("test-agent", dir.path());
repo.init().await.unwrap();
let err = repo.sync("abc123").await.unwrap_err().to_string();
assert!(err.contains("remote add origin"), "got: {err}");
}
#[tokio::test]
async fn sync_rejects_invalid_instance_labels() {
let dir = TempDir::new().unwrap();
let repo = MemoryRepo::new("test-agent", dir.path());
repo.init().await.unwrap();
for bad in ["", "a/b", "a:b", "a b", &"x".repeat(65)] {
assert!(repo.sync(bad).await.is_err(), "label {bad:?} should be rejected");
}
}
#[tokio::test]
async fn sync_pushes_instance_branch_to_bare_remote() {
let dir = TempDir::new().unwrap();
let repo = MemoryRepo::new("test-agent", dir.path());
repo.init().await.unwrap();
repo.write("journal/entry", "first thought").await.unwrap();
let remote_dir = TempDir::new().unwrap();
let bare = remote_dir.path().join("memfs.git");
git2::Repository::init_bare(&bare).unwrap();
git2::Repository::open(repo.root())
.unwrap()
.remote("origin", bare.to_str().unwrap())
.unwrap();
let report = repo.sync("deadbeef0123").await.unwrap();
assert!(report.contains("instance/deadbeef0123"), "got: {report}");
// The bare remote must now hold this instance's branch at our head.
let remote_repo = git2::Repository::open_bare(&bare).unwrap();
let branch = remote_repo
.find_reference("refs/heads/instance/deadbeef0123")
.unwrap();
assert_eq!(
branch.target().unwrap().to_string(),
repo.head_commit_hex().unwrap()
);
}
#[tokio::test]
async fn test_memory_repo_read_only() {
let dir = TempDir::new().unwrap();
let repo = MemoryRepo::new("test-agent", dir.path());
repo.init().await.unwrap();
// Write a read_only file
let fm = MemoryFrontmatter {
description: "Read-only test".to_string(),
read_only: Some("true".to_string()),
..Default::default()
};
let content = render_frontmatter(&fm, "This is read-only");
let path = repo.root().join("test/readonly.md");
tokio::fs::create_dir_all(path.parent().unwrap()).await.unwrap();
tokio::fs::write(&path, &content).await.unwrap();
// Try to write to it — should fail
let result = repo.write("test/readonly", "new content").await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("read_only"));
}
#[tokio::test]
async fn test_memory_repo_list() {
let dir = TempDir::new().unwrap();
let repo = MemoryRepo::new("test-agent", dir.path());
repo.init().await.unwrap();
let entries = repo.list(None).await.unwrap();
assert!(entries.iter().any(|e| e == "system/"));
}
feat: nervous system, seed identity, credentials, subconscious ledger Lays the substrate for Aster's clockmaker role and federation. Nervous system (src/core/nervous/): - EventBus broadcast channel with SensorEvent (universal event type carrying seed_id for future federation) - CronSensor loop with tokio::select!, mtime caching, active-session pause, at-most-once firing semantics - HeartbeatHandler scaffold (turn injection still stubbed) - Persistent JSONL EventLog firehose Schedule tool (src/core/tools/schedule.rs): - CRUD over schedule files with body-knowledge prose descriptions - Added to ASTER_SAFE_TOOLS so Aster can schedule her own rhythm - CLI subcommand for direct schedule management Seed identity (src/core/identity/): - Ed25519 keypair, load-or-generate at init - sign/verify primitives, CLI subcommand (show/sign/verify) - Wired into LocalBackend, threaded through SensorEvent for federation Credentials (src/core/credentials.rs): - OS keyring (Linux/macOS/Windows) + env var fallback - `souveraine auth` subcommand - Removes hardcoded Bifrost api_key from souveraine.toml Subconscious ledger (src/core/memory/mod.rs init_subconscious_ledger): - 6 ledger files (commitments/assumptions/patterns/drift_log/ relationships/infrastructure) with proper YAML frontmatter - Paths fixed: ledger/ not subconscious/ledger/ - Body usage instructions, idempotent init Prompt orientation (src/core/prompt.rs build_ledger_orientation): - Scans ledger/ directory, injects last 3 entries from each file into Aster's system prompt (live context, not just awareness) - Routing table + workflow (read before write, timestamped append) Consciousness engine (src/server/consciousness_engine.rs): - Adaptive rate delay shared with primary loop - Four-fold mandate (Complete/Verify/Persist/Surface) in hardcoded default prompt; observation format extracted for sharing - Schedule added to ASTER_SAFE_TOOLS Backend + CLI wiring expanded to mount the nervous system, identity, and credentials at startup. New TOML sections: [schedules], [events], [federation]. 102 tests passing.
2026-05-12 08:14:25 -04:00
#[tokio::test]
async fn test_ledger_init_creates_files_with_frontmatter() {
let dir = TempDir::new().unwrap();
let root = dir.path().to_path_buf();
let repo = MemoryRepo::open("test-sub", root.clone());
std::fs::create_dir_all(&root).unwrap();
repo.init_subconscious_ledger().await.unwrap();
let commitments = root.join("ledger/commitments.md");
assert!(commitments.exists(), "commitments.md should exist");
let content = std::fs::read_to_string(&commitments).unwrap();
assert!(content.starts_with("---\n"), "should have YAML frontmatter");
assert!(content.contains("description:"), "should have description field");
assert!(content.contains("tags:"), "should have tags field");
assert!(content.contains("# Commitments"), "should have body");
let relationships = root.join("ledger/relationships.md");
assert!(relationships.exists(), "relationships.md should exist");
let infrastructure = root.join("ledger/infrastructure.md");
assert!(infrastructure.exists(), "infrastructure.md should exist");
}
#[tokio::test]
async fn test_ledger_init_is_idempotent() {
let dir = TempDir::new().unwrap();
let root = dir.path().to_path_buf();
let repo = MemoryRepo::open("test-sub", root.clone());
std::fs::create_dir_all(&root).unwrap();
repo.init_subconscious_ledger().await.unwrap();
let commitments = root.join("ledger/commitments.md");
let before = std::fs::read_to_string(&commitments).unwrap();
repo.init_subconscious_ledger().await.unwrap();
let after = std::fs::read_to_string(&commitments).unwrap();
assert_eq!(before, after, "second init should not overwrite");
}
// ── description-required guard + audit ──────────────────────────────
#[test]
fn test_is_frontmatter_bound_scoping() {
// Frontmatter-bound: the description contract applies.
assert!(MemoryRepo::is_frontmatter_bound("system/persona"));
assert!(MemoryRepo::is_frontmatter_bound("reference/arch.md"));
assert!(MemoryRepo::is_frontmatter_bound("projects/plan.md"));
assert!(MemoryRepo::is_frontmatter_bound("issues/bug.md"));
// Different schemas that legitimately lack description.
assert!(!MemoryRepo::is_frontmatter_bound("tasks/some-todo.md"));
assert!(!MemoryRepo::is_frontmatter_bound("system/dynamic/itinerary.md"));
assert!(!MemoryRepo::is_frontmatter_bound("journal/2026/05/20.md"));
// Segment prefixes, not substring matches — a file named like a
// todo but living elsewhere is still bound.
assert!(MemoryRepo::is_frontmatter_bound("reference/tasks-today.md"));
assert!(MemoryRepo::is_frontmatter_bound("projects/journal-of-x.md"));
}
#[tokio::test]
async fn test_write_rejects_empty_description_on_bound_file() {
let dir = TempDir::new().unwrap();
let repo = MemoryRepo::new("test-agent", dir.path());
repo.init().await.unwrap();
// Seed a frontmatter-bound file with NO description (the gap).
std::fs::write(
repo.root().join("issues/gap.md"),
"---\nread_only: false\n---\nBody with no description\n",
)
.unwrap();
// A body-only write must not silently perpetuate the empty description.
let err = repo.write("issues/gap.md", "updated body").await.unwrap_err();
assert!(
err.to_string().contains("no description"),
"expected loud rejection, got: {err}"
);
// Supplying a description heals it and succeeds.
repo.write(
"issues/gap.md",
"---\ndescription: The healed headline\n---\nupdated body\n",
)
.await
.unwrap();
let healed = parse_memory_file(&std::fs::read_to_string(repo.root().join("issues/gap.md")).unwrap()).unwrap();
assert_eq!(healed.frontmatter.description, "The healed headline");
}
#[tokio::test]
async fn test_write_allows_no_description_on_unbound_schemas() {
let dir = TempDir::new().unwrap();
let repo = MemoryRepo::new("test-agent", dir.path());
repo.init().await.unwrap();
// tasks/, journal/, system/dynamic/ legitimately lack description.
repo.write("tasks/a-todo.md", "todo body").await.unwrap();
repo.write("journal/2026/05/20.md", "freeform journal entry").await.unwrap();
repo.write("system/dynamic/itinerary.md", "itinerary body").await.unwrap();
// New bound files still get the auto-generated default on create.
repo.write("reference/new.md", "some reference").await.unwrap();
let f = parse_memory_file(&std::fs::read_to_string(repo.root().join("reference/new.md")).unwrap()).unwrap();
assert!(!f.frontmatter.description.is_empty());
}
#[tokio::test]
async fn test_append_rejects_empty_description_on_bound_file() {
let dir = TempDir::new().unwrap();
let repo = MemoryRepo::new("test-agent", dir.path());
repo.init().await.unwrap();
// Existing bound file missing description.
std::fs::write(
repo.root().join("projects/gap.md"),
"---\n---\nInitial body\n",
)
.unwrap();
let err = repo.append("projects/gap.md", "more").await.unwrap_err();
assert!(err.to_string().contains("no description"));
}
#[tokio::test]
async fn test_audit_flags_only_bound_files_missing_description() {
let dir = TempDir::new().unwrap();
let repo = MemoryRepo::new("test-agent", dir.path());
repo.init().await.unwrap();
// Bound + missing description → flagged.
std::fs::write(
repo.root().join("issues/missing.md"),
"---\n---\nbody\n",
)
.unwrap();
// Bound + has description → not flagged.
std::fs::write(
repo.root().join("reference/has.md"),
"---\ndescription: present\n---\nbody\n",
)
.unwrap();
// Unbound schema + no description → not flagged (legitimate).
std::fs::write(
repo.root().join("tasks/todo.md"),
"---\nid: t1\n---\nbody\n",
)
.unwrap();
std::fs::write(
repo.root().join("journal/2026/05/20.md"),
"---\n---\nfreeform\n",
)
.unwrap();
let missing = repo.audit().await.unwrap();
assert_eq!(missing, vec!["issues/missing.md".to_string()]);
}
}