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.
946 lines
36 KiB
Rust
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());
|
|
}
|
|
}
|