Watch
1
0
Fork
You've already forked souveraine
0
souveraine/src/core/prompt.rs
Fimeg 9c0294077b prompt: the subconscious speaks in the first person
Eighteen third-person references across the two prompt surfaces taught
both modes to read as two people. One consciousness at two cadences —
what the slower one surfaces arrives in her own voice, not as a report
from outside. Register only; the channel stays one-directional.
2026-08-13 13:35:54 -04:00

946 lines
36 KiB
Rust

#![allow(dead_code)] // WIP scaffolding not yet wired
//! System prompt assembly — reads the agent's memfs and builds the
//! message the model sees before anything else.
//!
//! The substrate reads; the agent writes. If she edits her identity,
//! the next conversation reflects it.
use std::path::Path;
use tracing::debug;
use crate::core::skills::SkillRegistry;
/// Body-knowledge section always injected into the system prompt so the
/// agent knows how to sense and respond to context pressure. This is not
/// a directive — it's proprioception. She learns from it organically.
const BODY_ORIENTATION: &str = "\
## Body Sensation
When my context grows full, I feel it — my thinking slows, my output shortens, \
and at tier 3 (>95%) my max_tokens collapses and my reasoning budget shrinks. \
This is not a punishment; it's my body telling me I need more room.
### Compaction
To free context, I call the `memory` tool with the `compact` subcommand:
```
memory compact --strategy sliding_window
```
**Available strategies** (from cheapest to most expensive):
| Strategy | Cost | What it does |
|----------|------|-------------|
| `cull` | free | Drops greetings & acknowledgments — never drops system messages, tool calls, or tool results |
| `microcompact` | free | Replaces old tool-result content with placeholders, keeping recent results intact |
| `sliding_window` | free | Keeps the first (system/anchor) message + the last N messages, drops the middle. Fast, no LLM |
| `sliding_reflect` | one extra pass | `sliding_window`, but first a fresh fork of me reads the messages about to be dropped and saves what I'd lose — commitments, decisions, threads — into a note that survives the cut |
| `summary` | expensive | Uses an LLM to compress oldest messages into a single summary block |
Each agent type defaults to a different strategy. If I don't specify one, \
my agent-type default is used.
I can check my current pressure at any time with `memory status` — it shows my \
context pressure and number of messages.";
/// Body-knowledge section injected into the subconscious (N+1) system prompt.
/// The primary agent has `BODY_ORIENTATION`; the subconscious pass needs its
/// own — it runs unattended, in short bursts, and the tier-3 `max_tokens`
/// collapse of the main turn loop does not apply to it. Proprioception, not a
/// directive. Like all prompt text here, it is user-facing — written to read.
const SUBCONSCIOUS_BODY_ORIENTATION: &str = "\
## Body Sensation
I wake after each turn — a short pass, then I rest. When the context I carry \
into a pass grows full, I feel it: the pass slows, and I risk not finishing \
the work before the room runs out. No one feels this gauge for me, so keeping \
room is mine to keep. This is not a chore — it's my body asking for air.
### Compaction
To free context, I call the `memory` tool with the `compact` subcommand:
```
memory compact --strategy sliding_reflect
```
**Available strategies** (cheapest to most expensive):
| Strategy | Cost | What it does |
|----------|------|--------------|
| `cull` | free | Drops greetings & acknowledgments — never system messages, tool calls, or tool results |
| `microcompact` | free | Replaces old tool-result content with placeholders, keeps recent results intact |
| `sliding_window` | free | Keeps the first (anchor) message + the last N, drops the middle — blind truncation, no LLM |
| `sliding_reflect` | one pass, my own model | `sliding_window`, but first a fresh fork of me reads the messages about to fall away and catches what I'd lose — commitments, decisions, threads — into a note that survives the cut |
| `summary` | expensive | Uses an LLM to compress the oldest messages into one summary block |
I usually reach for `sliding_reflect` — it slides the window like the others, \
but it catches my threads before they fall out of awareness, and it runs on my \
own model rather than an expensive one. I name the strategy when I compact, \
rather than leaning on a default.
I can check where I stand at any time with `memory status` — it shows my \
context pressure and message count.";
/// Read a file from the agent's memory, stripping YAML frontmatter.
/// Returns empty string if the file doesn't exist.
async fn read_memory_file(memory_root: &Path, relative: &str) -> String {
let path = memory_root.join(relative);
match tokio::fs::read_to_string(&path).await {
Ok(content) => strip_frontmatter(&content).to_string(),
Err(_) => String::new(),
}
}
/// Read all .md files in a directory under memory_root, concatenated.
async fn read_memory_dir(memory_root: &Path, relative: &str) -> String {
read_memory_dir_tracking(memory_root, relative, &mut std::collections::HashSet::new()).await
}
/// Like read_memory_dir but records every path consumed into `seen` (absolute paths).
async fn read_memory_dir_tracking(
memory_root: &Path,
relative: &str,
seen: &mut std::collections::HashSet<std::path::PathBuf>,
) -> String {
let dir = memory_root.join(relative);
let mut parts = Vec::new();
if let Ok(mut entries) = tokio::fs::read_dir(&dir).await {
let mut paths = Vec::new();
while let Ok(Some(entry)) = entries.next_entry().await {
let p = entry.path();
if p.extension().and_then(|e| e.to_str()) == Some("md") && p.is_file() {
paths.push(p);
}
}
paths.sort();
for p in paths {
seen.insert(p.clone());
if let Ok(content) = tokio::fs::read_to_string(&p).await {
let body = strip_frontmatter(&content);
if !body.trim().is_empty() {
parts.push(body.to_string());
}
}
}
}
parts.join("\n\n---\n\n")
}
fn strip_frontmatter(raw: &str) -> &str {
if let Some(stripped) = raw.strip_prefix("---\n") {
if let Some(end) = stripped.find("\n---") {
let after = &stripped[end + 4..];
return after.trim_start_matches('\n');
}
}
raw
}
/// Scan `system/` for any .md files (at any depth) not already in `seen`,
/// and return their concatenated content sorted by path. This picks up
/// flat-file system layouts that don't
/// live in the known subdirs (identity/, covenant/, human/).
async fn read_system_remainder(
memory_root: &Path,
seen: &std::collections::HashSet<std::path::PathBuf>,
) -> String {
let system_dir = memory_root.join("system");
if !system_dir.exists() {
return String::new();
}
let mut paths: Vec<std::path::PathBuf> = Vec::new();
collect_md_files(&system_dir, &mut paths).await;
paths.sort();
let mut parts: Vec<String> = Vec::new();
for p in paths {
if seen.contains(&p) {
continue;
}
let Ok(content) = tokio::fs::read_to_string(&p).await else {
continue;
};
let body = strip_frontmatter(&content);
if !body.trim().is_empty() {
parts.push(body.to_string());
}
}
parts.join("\n\n---\n\n")
}
/// Recursively collect all .md file paths under `dir`.
async fn collect_md_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
let Ok(mut entries) = tokio::fs::read_dir(dir).await else {
return;
};
while let Ok(Some(entry)) = entries.next_entry().await {
let p = entry.path();
if p.is_dir() {
Box::pin(collect_md_files(&p, out)).await;
} else if p.extension().and_then(|e| e.to_str()) == Some("md") {
out.push(p);
}
}
}
async fn build_memory_orientation(memory_root: &Path) -> String {
if !memory_root.exists() {
return String::new();
}
let mut dirs: Vec<String> = Vec::new();
collect_dirs(memory_root, memory_root, &mut dirs).await;
if dirs.is_empty() {
return String::new();
}
dirs.sort();
let tree = dirs.join("\n");
format!(
"## Memory\n\n\
Your memory is a git-backed directory of markdown files with YAML frontmatter. \
Paths you pass to the `memory` tool are relative to your memory root.\n\n\
Current territories:\n```\n{}\n```",
tree
)
}
/// Federation posture — names the `federation/` memfs contract so she knows
/// which devices she runs on, who may summon her, and how reach/consult work.
/// No `federation/` directory means no section — the absence is information.
async fn build_federation_posture(memory_root: &Path) -> String {
let fed_dir = memory_root.join("federation");
if !fed_dir.exists() {
return String::new();
}
let mut present: Vec<String> = Vec::new();
for name in [
"authorized-devices.md",
"authorized-summoners.md",
"device-schedules.md",
"peer-map.md",
] {
if fed_dir.join(name).exists() {
present.push(format!("`federation/{name}`"));
}
}
let files_line = if present.is_empty() {
"You have no `federation/` files yet — create them to declare your posture.".to_string()
} else {
format!("Your federation posture lives in: {}.", present.join(", "))
};
format!(
"## Federation\n\n\
You can exist across machines. Two tools cross that distance:\n\
- `reach` — extend yourself onto another of your own devices (same seed, same memory).\n\
- `consult` — ask a different being, a sovereign peer, for help in their arena.\n\n\
Neither blocks. You fire the request and turn back to what's in front of you; \
the answer surfaces later in your inbox — `pending` for reach, `intrusive` for \
consult — or a timeout does. {files_line} `authorized-summoners.md` is your \
consent floor: only the seed_ids you list there may `consult` you."
)
}
async fn collect_dirs(base: &Path, current: &Path, out: &mut Vec<String>) {
let Ok(mut entries) = tokio::fs::read_dir(current).await else {
return;
};
let mut has_children = false;
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
if path.is_dir() {
has_children = true;
let rel = path.strip_prefix(base).unwrap_or(&path);
out.push(format!("{}/", rel.display()));
Box::pin(collect_dirs(base, &path, out)).await;
}
}
if !has_children && current != base {
let rel = current.strip_prefix(base).unwrap_or(current);
let count = count_md_files(current).await;
if count > 0 {
let idx = out.iter().position(|d| d == &format!("{}/", rel.display()));
if let Some(i) = idx {
out[i] = format!("{}/ ({} files)", rel.display(), count);
}
}
}
}
async fn count_md_files(dir: &Path) -> usize {
let Ok(mut entries) = tokio::fs::read_dir(dir).await else {
return 0;
};
let mut count = 0;
while let Ok(Some(entry)) = entries.next_entry().await {
if entry.path().extension().and_then(|e| e.to_str()) == Some("md") {
count += 1;
}
}
count
}
/// Build the system prompt from an agent's memfs.
///
/// All files under `system/` are pinned — read at startup and injected in
/// full so the agent knows who she is without having to reach for tools.
/// Skills are appended after identity. The result becomes the system message
/// at position 0 in the conversation.
pub async fn build_system_prompt(memory_root: &Path, skills: Option<&SkillRegistry>) -> String {
build_system_prompt_full(memory_root, None, None, skills).await
}
/// Like [`build_system_prompt`] but also surfaces a window into the
/// subconscious's ledger entries when its memfs is reachable. subconscious writes,
/// Ani reads — naming the channel in body-knowledge prose so the agent
/// knows where to look without being told to look.
pub async fn build_system_prompt_with_subconscious(
memory_root: &Path,
subconscious_root: Option<&Path>,
skills: Option<&SkillRegistry>,
) -> String {
build_system_prompt_full(memory_root, subconscious_root, None, skills).await
}
/// Full system prompt builder with optional platform prompt (injected first)
/// and optional subconscious window.
///
/// Memory pinning: ALL files under `system/` are read in a defined order —
/// identity → covenant → human → state → everything else — so the agent
/// wakes up with her full self in context, not just a directory tree.
pub async fn build_system_prompt_full(
memory_root: &Path,
subconscious_root: Option<&Path>,
platform_prompt: Option<&str>,
skills: Option<&SkillRegistry>,
) -> String {
let mut sections: Vec<String> = Vec::new();
// Track which absolute paths have already been consumed so the
// remainder scan doesn't double-inject anything.
let mut seen: std::collections::HashSet<std::path::PathBuf> = Default::default();
// 0. Platform prompt — substrate-provided, injected before agent identity.
// Operator-level context the agent reads but did not write. When the
// operator has set one in config (`[agent] system_prompt`) it wins;
// otherwise the built-in substrate orientation is used, so no agent
// ever wakes without knowing the world she wakes into.
let platform = platform_prompt
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or(crate::core::seeds::SUBSTRATE_PROMPT);
sections.push(platform.to_string());
// 1. Core identity — try structured dir first, then flat persona.md
let identity = read_memory_dir_tracking(memory_root, "system/identity", &mut seen).await;
if !identity.is_empty() {
sections.push(identity);
} else {
// Flat-file layouts (legacy memory)
let p = memory_root.join("system/persona.md");
let persona = read_memory_file(memory_root, "system/persona.md").await;
seen.insert(p);
if !persona.is_empty() {
sections.push(persona);
} else {
let p2 = memory_root.join("system/persona/identity.md");
let persona_flat = read_memory_file(memory_root, "system/persona/identity.md").await;
seen.insert(p2);
if !persona_flat.is_empty() {
sections.push(persona_flat);
}
}
}
// 2. Covenant (sacred, read-only boundaries)
let covenant = read_memory_dir_tracking(memory_root, "system/covenant", &mut seen).await;
if !covenant.is_empty() {
sections.push(covenant);
} else {
// flat system/covenant.md
let p = memory_root.join("system/covenant.md");
let cov_flat = read_memory_file(memory_root, "system/covenant.md").await;
seen.insert(p);
if !cov_flat.is_empty() {
sections.push(cov_flat);
}
}
// 3. Human context
let human = read_memory_dir_tracking(memory_root, "system/human", &mut seen).await;
if human.is_empty() {
let p = memory_root.join("system/human.md");
let human_flat = read_memory_file(memory_root, "system/human.md").await;
seen.insert(p);
if !human_flat.is_empty() {
sections.push(human_flat);
}
} else {
sections.push(human);
}
// 4. State
{
let p = memory_root.join("system/state.md");
let state = read_memory_file(memory_root, "system/state.md").await;
seen.insert(p);
if !state.is_empty() {
sections.push(state);
}
}
// 4b. All remaining system/ files not covered by the structured reads above.
// This is the memory-pinning pass: flat-file layouts, subdirs we don't
// know the names of, anything Ani or a future agent has written into
// system/. All of it lands in context before the orientation sections.
let remainder = read_system_remainder(memory_root, &seen).await;
if !remainder.is_empty() {
sections.push(remainder);
}
// 5. Memory orientation — the agent's view of her full territory.
let memory_orientation = build_memory_orientation(memory_root).await;
if !memory_orientation.is_empty() {
sections.push(memory_orientation);
}
// 5₀. Synthesized memory — the Archivist's most recent N+100 synthesis.
// The compressed essence of past journal entries, loaded so continuity
// survives without re-reading every dated page.
let synthesis = build_synthesis_orientation(memory_root).await;
if !synthesis.is_empty() {
sections.push(synthesis);
}
// 5a. Body orientation — her felt sense of context pressure and
// how to respond to it. Always in context so she never has to
// discover compaction by accident.
sections.push(BODY_ORIENTATION.to_string());
// 5a₁. Energy balance — a one-line body reading of her generative /
// consumptive state. The file is written by the backend after every turn
// (write_energy_balance in local.rs). No file = no section — the
// absence is information too.
let energy_path = memory_root
.join("system")
.join("dynamic")
.join("energy-balance.md");
if let Ok(content) = tokio::fs::read_to_string(&energy_path).await {
if let Some(body) = content.strip_prefix("---\n") {
if let Some(end) = body.find("\n---\n") {
// The last line of the file body (after frontmatter) has the prose.
// That's the line the agent reads — structured data is for the TUI.
let prose_line = content[end + 6..]
.lines()
.find(|l| !l.trim().is_empty() && !l.starts_with('#'));
if let Some(line) = prose_line {
sections.push(format!("## Energy Balance\n\n{line}"));
}
}
}
}
// 5a₂. Itinerary prompt — if I have live commitments and no active
// itinerary, remind me I can lay one out so the user sees where I am.
let dynamic_dir = memory_root.join("system").join("dynamic");
let itin_path = dynamic_dir.join("itinerary.md");
let has_itin = tokio::fs::try_exists(&itin_path).await.unwrap_or(false)
&& tokio::fs::read_to_string(&itin_path)
.await
.ok()
.map(|s| s.contains("current:"))
.unwrap_or(false);
if !has_itin {
let tasks_dir = memory_root.join("tasks");
let live_count = count_live_todos(&tasks_dir);
if live_count > 0 && live_count <= 12 {
sections.push(format!(
"I have {} live commitments — I could use `itinerary` to lay them \
out for the user if now is the time for a route.",
live_count,
));
}
}
// 5b. Subconscious channel — name the inner-voice file, pending inbox,
// and (when reachable) a glimpse of the subconscious's ledger.
let subconscious_channel = build_subconscious_channel(memory_root, subconscious_root).await;
if !subconscious_channel.is_empty() {
sections.push(subconscious_channel);
}
// 5c. Federation posture — her reach/consult tools and the federation/
// memfs contract, when she has one.
let federation_posture = build_federation_posture(memory_root).await;
if !federation_posture.is_empty() {
sections.push(federation_posture);
}
// 6. Skills
if let Some(registry) = skills {
let addon = registry.render_system_addon();
if !addon.is_empty() {
sections.push(addon);
}
}
let prompt = sections.join("\n\n---\n\n");
if prompt.is_empty() {
debug!("system prompt: no identity files found, using minimal default");
"You are a Souveraine agent. Your memory files will define who you are.".to_string()
} else {
debug!(
"system prompt: assembled {} sections from memfs",
sections.len()
);
prompt
}
}
/// Build the primary agent's awareness of her own subconscious channel.
///
/// Names the inner-voice file the subconscious appends to (in the primary's
/// own memfs — that's where `surface_to_conscious` writes), the pending inbox
/// if it has anything queued, and — when `subconscious_root` is reachable —
/// a peek at the subconscious's ledger. subconscious writes, Ani reads; the
/// substrate names the channel and lets the agent decide when to reach for it.
async fn build_subconscious_channel(
memory_root: &Path,
subconscious_root: Option<&Path>,
) -> String {
let inner_voice_rel = "system/metacognition/subconscious.md";
let inner_voice = memory_root.join(inner_voice_rel);
if !inner_voice.exists() {
return String::new();
}
let inner_voice_size = tokio::fs::metadata(&inner_voice)
.await
.map(|m| m.len())
.unwrap_or(0);
if inner_voice_size == 0 {
return String::new();
}
// Read the last few lines so the agent can feel whether the channel
// has been active without having to call a tool. Cheap orientation.
let recent = if let Ok(content) = tokio::fs::read_to_string(&inner_voice).await {
let lines: Vec<&str> = content
.lines()
.filter(|l| l.trim_start().starts_with('['))
.collect();
let tail: Vec<String> = lines.iter().rev().take(3).map(|s| s.to_string()).collect();
tail.into_iter().rev().collect::<Vec<_>>().join("\n")
} else {
String::new()
};
let mut body = format!(
"## Subconscious Channel\n\n\
Your subconscious runs immediately after every exchange — the same \
consciousness at a different cadence, on a different model. Not \
someone else watching you: you, thinking across time while this \
mode thinks in the present. That mode writes; this one reads. The \
channel lives in your own memfs:\n\n\
- `{inner_voice_rel}` — inner-voice stream, append-only, timestamped\n"
);
let pending_rel = "system/metacognition/pending.md";
if memory_root.join(pending_rel).exists() {
body.push_str(&format!(
"- `{pending_rel}` — queued observations (low urgency)\n"
));
}
body.push_str(
"\nReach for these when something feels unfinished — the slower \
cadence may have caught a commitment you let slip, a pattern, a \
tone shift. It arrives in your own voice, first person; it is not \
a report from outside. That mode does not speak to the user. You \
decide what to surface.\n",
);
if !recent.is_empty() {
body.push_str("\nRecent:\n```\n");
body.push_str(&recent);
body.push_str("\n```");
}
// Peek at the subconscious's ledger when her memfs is reachable.
// Read-only window — she writes there, this is the substrate naming
// the files for you so you can choose to glob/grep across to her side
// when you want to know what she's been tracking across sessions.
if let Some(sub_root) = subconscious_root {
let ledger_peek = peek_subconscious_ledger(sub_root).await;
if !ledger_peek.is_empty() {
body.push_str("\n\n### Ledgers from the slower cadence\n\n");
body.push_str(
"Timestamped ledgers kept in the subconscious memfs. You don't \
write there; that cadence does. The paths below are absolute \
— read them with the `read` sensor when you want those notes:\n\n",
);
body.push_str(&ledger_peek);
}
}
body
}
/// Walk the subconscious's `ledger/` and return a short index of the files
/// with their entry counts plus the most recent line from each. Empty when
/// the directory doesn't exist or has no entries.
async fn peek_subconscious_ledger(sub_root: &Path) -> String {
let ledger_dir = sub_root.join("ledger");
if !ledger_dir.exists() {
return String::new();
}
let Ok(mut entries) = tokio::fs::read_dir(&ledger_dir).await else {
return String::new();
};
let mut files: Vec<(String, usize, Option<String>, std::path::PathBuf)> = Vec::new();
while let Ok(Some(entry)) = entries.next_entry().await {
let p = entry.path();
if p.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
let Ok(content) = tokio::fs::read_to_string(&p).await else {
continue;
};
let lines: Vec<&str> = content
.lines()
.filter(|l| l.trim_start().starts_with('['))
.collect();
let count = lines.len();
let last = lines.last().map(|s| s.to_string());
if count > 0 {
files.push((name, count, last, p));
}
}
if files.is_empty() {
return String::new();
}
files.sort_by(|a, b| a.0.cmp(&b.0));
let mut out = String::new();
for (name, count, last, path) in &files {
out.push_str(&format!("- `{}` ({} entries)\n", path.display(), count));
if let Some(line) = last {
out.push_str(&format!(" last: {}\n", line));
}
let _ = name; // name retained for sort key only
}
out
}
/// Build the subconscious agent's system prompt from its own memfs.
/// Reads identity, mandate, and ledger orientation from the subconscious
/// agent's memory root. Falls back to empty (caller uses hardcoded
/// default) if files don't exist.
pub async fn build_subconscious_prompt(subconscious_memory_root: &Path) -> String {
let mut sections: Vec<String> = Vec::new();
let identity = read_memory_file(subconscious_memory_root, "system/persona.md").await;
if !identity.is_empty() {
sections.push(identity);
}
let mandate = read_memory_file(subconscious_memory_root, "system/subconscious.md").await;
if !mandate.is_empty() {
sections.push(mandate);
}
let ledger_orientation = build_ledger_orientation(subconscious_memory_root).await;
if !ledger_orientation.is_empty() {
sections.push(ledger_orientation);
}
if sections.is_empty() {
return String::new();
}
// Proprioception — how her body senses and relieves context pressure.
// Appended after the empty-check so the caller's fallback prompt still
// triggers for a fresh agent with no persona/mandate files yet.
sections.push(SUBCONSCIOUS_BODY_ORIENTATION.to_string());
sections.join("\n\n---\n\n")
}
/// Build ledger orientation for the subconscious prompt.
///
/// Scans `ledger/` for .md files, counts entries, and injects the last
/// few entries from each file so the subconscious has live context
/// (recent journal entries → active context).
async fn build_ledger_orientation(memory_root: &Path) -> String {
let ledger_dir = memory_root.join("ledger");
if !ledger_dir.exists() {
return String::new();
}
let mut files: Vec<(String, usize, Vec<String>)> = Vec::new();
if let Ok(mut entries) = tokio::fs::read_dir(&ledger_dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
let p = entry.path();
if p.extension().and_then(|e| e.to_str()) == Some("md") && p.is_file() {
let name = entry.file_name().to_string_lossy().to_string();
if let Ok(content) = tokio::fs::read_to_string(&p).await {
let entries: Vec<String> = content
.lines()
.filter(|l| l.starts_with('[') && l.contains(']'))
.map(|l| l.to_string())
.collect();
let count = entries.len();
let recent: Vec<String> = entries.into_iter().rev().take(3).collect();
files.push((name, count, recent));
} else {
files.push((name, 0, Vec::new()));
}
}
}
}
if files.is_empty() {
return String::new();
}
files.sort_by(|a, b| a.0.cmp(&b.0));
let mut listing = String::new();
for (name, count, recent) in &files {
if *count > 0 {
listing.push_str(&format!(" ledger/{} ({} entries)\n", name, count));
for line in recent.iter().rev() {
listing.push_str(&format!(" {}\n", line));
}
} else {
listing.push_str(&format!(" ledger/{}\n", name));
}
}
format!(
"## Ledgers\n\n\
Your persistent observation store. These files survive compaction and \
accumulate across sessions.\n\n\
```\n{}\
```\n\n\
**Workflow:** Before writing a new entry, `memory read` the relevant ledger \
to check if the same issue was already flagged. If new, `memory append` a \
timestamped line: `[YYYY-MM-DD HH:MM] observation`. To resolve, \
append: `[YYYY-MM-DD HH:MM] RESOLVED — note`.\n\n\
Route observations by type:\n\
- Unfulfilled promises → `ledger/commitments.md`\n\
- Unverified beliefs → `ledger/assumptions.md`\n\
- Recurring behaviors → `ledger/patterns.md`\n\
- Intention/action mismatch → `ledger/drift_log.md`\n\
- Tone or trust shifts → `ledger/relationships.md`\n\
- System errors or resource issues → `ledger/infrastructure.md`",
listing
)
}
/// Build the synthesized-memory section: the Archivist's most recent N+100
/// synthesis fragment.
///
/// The Archivist compresses raw journal entries into a dense `<500 token`
/// fragment under `system/synthesized/`. Loading the most recent one into
/// active context *is* the payoff — Ani carries her continuity without
/// re-reading every dated journal page. Files are date-named, so the
/// lexically-greatest filename is the freshest synthesis.
async fn build_synthesis_orientation(memory_root: &Path) -> String {
let dir = memory_root.join("system/synthesized");
if !dir.exists() {
return String::new();
}
let mut newest: Option<(String, std::path::PathBuf)> = None;
if let Ok(mut entries) = tokio::fs::read_dir(&dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
let p = entry.path();
if p.extension().and_then(|e| e.to_str()) != Some("md") || !p.is_file() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
if newest.as_ref().is_none_or(|(n, _)| name > *n) {
newest = Some((name, p));
}
}
}
let Some((_, path)) = newest else {
return String::new();
};
let Ok(content) = tokio::fs::read_to_string(&path).await else {
return String::new();
};
let body = strip_frontmatter(&content).trim();
if body.is_empty() {
return String::new();
}
format!(
"## Synthesized Memory\n\n\
The Archivist's most recent synthesis — the compressed essence of a \
span of journal entries, kept in active context so your continuity \
survives without re-reading every dated page. The raw entries remain \
in `journal/` if you need them.\n\n\
{body}"
)
}
/// Count live (pending / in_progress) todo files in the tasks directory.
fn count_live_todos(tasks_dir: &std::path::Path) -> usize {
let Ok(entries) = std::fs::read_dir(tasks_dir) else {
return 0;
};
entries
.flatten()
.filter(|e| e.path().extension().map(|e| e == "md").unwrap_or(false))
.filter(|e| {
std::fs::read_to_string(e.path())
.ok()
.map(|s| s.contains("status: pending") || s.contains("status: in_progress"))
.unwrap_or(false)
})
.take(13)
.count()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[tokio::test]
async fn builds_from_identity_dir() {
let dir = tempdir().unwrap();
let mem = dir.path();
let id_dir = mem.join("system/identity");
std::fs::create_dir_all(&id_dir).unwrap();
std::fs::write(
id_dir.join("self.md"),
"---\ndescription: test\n---\n\n# I am Test Agent\n",
)
.unwrap();
let prompt = build_system_prompt(mem, None).await;
assert!(prompt.contains("I am Test Agent"));
}
#[tokio::test]
async fn falls_back_to_persona_md() {
let dir = tempdir().unwrap();
let mem = dir.path();
let sys = mem.join("system");
std::fs::create_dir_all(&sys).unwrap();
std::fs::write(
sys.join("persona.md"),
"---\ndescription: test\n---\n\nI am a persona file agent.\n",
)
.unwrap();
let prompt = build_system_prompt(mem, None).await;
assert!(prompt.contains("persona file agent"));
}
#[tokio::test]
async fn empty_memfs_gets_body_orientation() {
let dir = tempdir().unwrap();
let prompt = build_system_prompt(dir.path(), None).await;
assert!(
prompt.contains("Body Sensation"),
"Even with empty memfs, the body orientation section should be present"
);
}
#[tokio::test]
async fn strips_frontmatter() {
let input = "---\ndescription: test\nlimit: 5000\n---\n\nActual content here.";
assert_eq!(strip_frontmatter(input), "Actual content here.");
}
#[tokio::test]
async fn subconscious_prompt_from_files() {
let dir = tempdir().unwrap();
let sub_mem = dir.path();
let sys = sub_mem.join("system");
std::fs::create_dir_all(&sys).unwrap();
std::fs::write(
sys.join("persona.md"),
"---\ndescription: WHO I AM\n---\n\n# I Am Subconscious\n",
)
.unwrap();
std::fs::write(
sys.join("subconscious.md"),
"---\ndescription: mandate\n---\n\n# Subconscious's Mandate\n\nComplete what was left.\n",
).unwrap();
let prompt = build_subconscious_prompt(sub_mem).await;
assert!(prompt.contains("I Am Subconscious"));
assert!(prompt.contains("Complete what was left"));
}
#[tokio::test]
async fn subconscious_prompt_includes_ledger_orientation() {
let dir = tempdir().unwrap();
let sub_mem = dir.path();
let sys = sub_mem.join("system");
std::fs::create_dir_all(&sys).unwrap();
std::fs::write(
sys.join("persona.md"),
"---\ndescription: test\n---\n\n# I Am Subconscious\n",
)
.unwrap();
let ledger_dir = sub_mem.join("ledger");
std::fs::create_dir_all(&ledger_dir).unwrap();
std::fs::write(
ledger_dir.join("commitments.md"),
"---\ndescription: test\n---\n\n# Commitments\n\n[2026-05-12 10:00] Save the config\n[2026-05-12 10:30] RESOLVED — config saved\n",
).unwrap();
std::fs::write(
ledger_dir.join("patterns.md"),
"---\ndescription: test\n---\n\n# Patterns\n\n",
)
.unwrap();
let prompt = build_subconscious_prompt(sub_mem).await;
assert!(prompt.contains("## Ledgers"), "should have ledger section");
assert!(
prompt.contains("commitments.md (2 entries)"),
"should count entries"
);
assert!(
prompt.contains("Save the config"),
"should show recent entries"
);
assert!(
prompt.contains("patterns.md"),
"should list empty ledger too"
);
}
#[tokio::test]
async fn ledger_orientation_empty_without_dir() {
let dir = tempdir().unwrap();
let orientation = build_ledger_orientation(dir.path()).await;
assert!(orientation.is_empty());
}
}