Intent lives in agent.json; posture is measured fresh from process credentials per request, so resume and compaction cannot replay a stale uid as history. `isolated` needs account, uid, worker binding and the root-owned mapping to agree — a passwd entry alone reads principal-drift.
585 lines
22 KiB
Rust
585 lines
22 KiB
Rust
//! Archivist — the N+100 memory synthesis pass.
|
|
//!
|
|
//! N+1 (subconscious) runs after every response; N+25 (Reflection) distils a
|
|
//! transcript window into the ledger. The Archivist runs least often and
|
|
//! works on a different substrate entirely: not the conversation, but the
|
|
//! agent's *memfs* — her accumulated journal.
|
|
//!
|
|
//! ## Raw vs. Synthesized
|
|
//!
|
|
//! - **Raw** (`journal/`): preserved forever in git. Sovereignty, history,
|
|
//! evidence. The Archivist never deletes it.
|
|
//! - **Synthesized** (`system/synthesized/`): the compressed essence — a
|
|
//! dense, token-efficient fragment loaded into active context so Ani
|
|
//! carries her continuity without re-reading every dated entry.
|
|
//!
|
|
//! The Archivist manages the boundary. It scans journal entries written
|
|
//! since the last synthesis, sends them to a (typically smaller/faster)
|
|
//! compression model, and writes a `<500 token` synthesis back into the
|
|
//! primary memfs.
|
|
//!
|
|
//! ## Triggering
|
|
//!
|
|
//! Fires from `ConsciousnessEngine::on_response` when *either*:
|
|
//! - the turn count hits the configured interval (maintenance), or
|
|
//! - context pressure crosses the configured threshold (emergency).
|
|
//!
|
|
//! Either trigger only proceeds when there are journal entries newer than
|
|
//! the most recent synthesis — that idempotency guard stops a sustained
|
|
//! high-pressure session from re-synthesizing the same entries every turn.
|
|
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::sync::Arc;
|
|
|
|
use anyhow::Result;
|
|
use chrono::{NaiveDate, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::bridge::bifrost::{ChatCompletionRequest, Message};
|
|
use crate::bridge::LlmProvider;
|
|
use crate::bridge::ProviderRegistry;
|
|
use crate::core::config::{ArchivistConfig, SynthesisElement};
|
|
use crate::core::memory::MemoryRepo;
|
|
use crate::server::AgentInventory;
|
|
|
|
/// Where synthesis fragments are written, relative to the primary memfs root.
|
|
const SYNTHESIS_DIR: &str = "system/synthesized";
|
|
|
|
/// Cap on raw journal text fed to the compression model in one pass.
|
|
/// Keeps the synthesis call bounded even after a long quiet stretch.
|
|
const MAX_JOURNAL_INPUT_CHARS: usize = 60_000;
|
|
|
|
/// Output ceiling for the synthesis call. The architecture targets <500
|
|
/// tokens; 800 gives the model headroom without inviting an essay.
|
|
const SYNTHESIS_MAX_TOKENS: u32 = 800;
|
|
|
|
/// A single raw journal entry discovered on disk.
|
|
#[derive(Debug, Clone)]
|
|
struct JournalEntry {
|
|
/// Date parsed from the file path (`journal/2026/04/2026-04-02.md`).
|
|
date: NaiveDate,
|
|
/// memfs-relative label, e.g. `journal/2026/04/2026-04-02`.
|
|
label: String,
|
|
/// File body (frontmatter stripped).
|
|
body: String,
|
|
}
|
|
|
|
/// The outcome of an Archivist pass. The consciousness engine turns this
|
|
/// into a `ConsciousnessEvent::Archivist`; the CLI/TUI may render it.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SynthesisReport {
|
|
pub agent_id: String,
|
|
/// Number of raw journal entries folded into this synthesis.
|
|
pub entries_compressed: usize,
|
|
/// Inclusive date range covered (`start`, `end`).
|
|
pub date_range: (String, String),
|
|
/// memfs label the synthesis was written to.
|
|
pub output_label: String,
|
|
pub started_at: chrono::DateTime<chrono::Utc>,
|
|
pub completed_at: chrono::DateTime<chrono::Utc>,
|
|
/// Approximate tokens of raw journal text replaced by the synthesis.
|
|
pub approx_tokens_compressed: usize,
|
|
}
|
|
|
|
impl SynthesisReport {
|
|
/// One-line summary for the cockpit panel / `ConsciousnessEvent`.
|
|
pub fn summary_line(&self) -> String {
|
|
format!(
|
|
"synthesized {} journal entries ({} → {}) into {}",
|
|
self.entries_compressed, self.date_range.0, self.date_range.1, self.output_label,
|
|
)
|
|
}
|
|
}
|
|
|
|
pub struct ArchivistEngine {
|
|
agents: Arc<AgentInventory>,
|
|
providers: Arc<ProviderRegistry>,
|
|
rate_delay: Arc<AtomicU64>,
|
|
config: ArchivistConfig,
|
|
/// Subconscious model handle — used to resolve `compression_model: "auto"`.
|
|
subconscious_model: Option<String>,
|
|
}
|
|
|
|
impl ArchivistEngine {
|
|
pub fn new(
|
|
agents: Arc<AgentInventory>,
|
|
providers: Arc<ProviderRegistry>,
|
|
rate_delay: Arc<AtomicU64>,
|
|
config: ArchivistConfig,
|
|
subconscious_model: Option<String>,
|
|
) -> Self {
|
|
Self {
|
|
agents,
|
|
providers,
|
|
rate_delay,
|
|
config,
|
|
subconscious_model,
|
|
}
|
|
}
|
|
|
|
/// Resolve the compression model. The agent's own
|
|
/// `_souveraine.archivist_model` override wins outright. Otherwise the
|
|
/// configured `compression_model` is used, with `"auto"` (or empty)
|
|
/// falling back to the subconscious model, then a sensible default.
|
|
/// A real capability-aware auto-selection algorithm is deferred.
|
|
async fn resolve_model(&self, agent_id: &str) -> String {
|
|
if let Some(per_agent) = self
|
|
.agents
|
|
.get(agent_id)
|
|
.await
|
|
.ok()
|
|
.and_then(|a| a.souveraine.archivist_model.clone())
|
|
{
|
|
return per_agent;
|
|
}
|
|
let configured = self.config.compression_model.trim();
|
|
if configured.is_empty() || configured.eq_ignore_ascii_case("auto") {
|
|
self.subconscious_model
|
|
.clone()
|
|
.unwrap_or_else(|| "openai/glm-5.1".to_string())
|
|
} else {
|
|
configured.to_string()
|
|
}
|
|
}
|
|
|
|
/// Decide whether an Archivist pass is due, and run it if so.
|
|
///
|
|
/// Returns `Ok(None)` when disabled, when no trigger fired, or when there
|
|
/// is nothing new to synthesize. Returns `Ok(Some(report))` after a
|
|
/// completed synthesis.
|
|
pub async fn maybe_synthesize(
|
|
&self,
|
|
agent_id: &str,
|
|
turn_count: usize,
|
|
pressure: f32,
|
|
) -> Result<Option<SynthesisReport>> {
|
|
if !self.config.enabled {
|
|
return Ok(None);
|
|
}
|
|
|
|
let interval_due = turn_count > 0
|
|
&& self.config.interval > 0
|
|
&& turn_count.is_multiple_of(self.config.interval);
|
|
let pressure_due = pressure >= self.config.threshold;
|
|
if !interval_due && !pressure_due {
|
|
return Ok(None);
|
|
}
|
|
|
|
let trigger = if pressure_due { "pressure" } else { "interval" };
|
|
tracing::info!(
|
|
agent = %agent_id,
|
|
trigger,
|
|
turn_count,
|
|
pressure,
|
|
"N+100 archivist trigger fired"
|
|
);
|
|
|
|
match self.synthesize_now(agent_id).await {
|
|
Ok(Some(report)) => Ok(Some(report)),
|
|
Ok(None) => {
|
|
tracing::info!(agent = %agent_id, "archivist: no new journal entries to synthesize");
|
|
Ok(None)
|
|
}
|
|
Err(e) => Err(e),
|
|
}
|
|
}
|
|
|
|
/// Run a synthesis pass unconditionally (ignores interval/pressure
|
|
/// triggers, still honours the new-entries guard). This is the seam a
|
|
/// future `souveraine synthesize` CLI command or `/synthesize` chat
|
|
/// command would call.
|
|
///
|
|
/// Returns `Ok(None)` when there are no journal entries newer than the
|
|
/// most recent synthesis.
|
|
pub async fn synthesize_now(&self, agent_id: &str) -> Result<Option<SynthesisReport>> {
|
|
let started_at = Utc::now();
|
|
let repo = self.agents.memory_repo(agent_id);
|
|
|
|
let last_end = last_synthesis_end(&repo).await;
|
|
let entries = collect_journal_entries(&repo, last_end).await?;
|
|
if entries.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let start_date = entries.first().unwrap().date;
|
|
let end_date = entries.last().unwrap().date;
|
|
|
|
let raw = format_journal_input(&entries);
|
|
let approx_tokens_compressed = raw.len() / 4;
|
|
|
|
let model = self.resolve_model(agent_id).await;
|
|
// Provider follows the model. The compression model is frequently on
|
|
// another provider entirely (a gateway-hosted model while the agent
|
|
// runs on a direct wire), so taking the agent's provider sent it to an
|
|
// endpoint that has never heard of that model.
|
|
let llm: Arc<dyn LlmProvider> = match self.providers.for_model(&model) {
|
|
Some(llm) => llm,
|
|
None => self
|
|
.agents
|
|
.get(agent_id)
|
|
.await
|
|
.ok()
|
|
.map(|a| self.providers.for_agent(&a))
|
|
.unwrap_or_else(|| self.providers.default_provider()),
|
|
};
|
|
let synthesis = self
|
|
.run_synthesis(agent_id, &llm, &model, &raw, start_date, end_date)
|
|
.await?;
|
|
|
|
let output_label = format!("{SYNTHESIS_DIR}/{end_date}");
|
|
let body = render_synthesis(&synthesis, start_date, end_date, entries.len(), &model);
|
|
repo.write(&output_label, &body).await?;
|
|
|
|
let report = SynthesisReport {
|
|
agent_id: agent_id.to_string(),
|
|
entries_compressed: entries.len(),
|
|
date_range: (start_date.to_string(), end_date.to_string()),
|
|
output_label,
|
|
started_at,
|
|
completed_at: Utc::now(),
|
|
approx_tokens_compressed,
|
|
};
|
|
tracing::info!(agent = %agent_id, "{}", report.summary_line());
|
|
Ok(Some(report))
|
|
}
|
|
|
|
/// Single LLM call: raw journal text in, structured synthesis out.
|
|
/// No tool loop — the Archivist produces a record, it doesn't act.
|
|
async fn run_synthesis(
|
|
&self,
|
|
agent_id: &str,
|
|
llm: &Arc<dyn LlmProvider>,
|
|
model: &str,
|
|
raw_journal: &str,
|
|
start: NaiveDate,
|
|
end: NaiveDate,
|
|
) -> Result<String> {
|
|
let system_prompt = archivist_system_prompt(&self.config.synthesis_elements);
|
|
let user_content = format!(
|
|
"INPUT: Journal entries from {start} to {end}\n\n\
|
|
<journal>\n{raw_journal}\n</journal>"
|
|
);
|
|
|
|
let principal_block = self
|
|
.agents
|
|
.get(agent_id)
|
|
.await
|
|
.ok()
|
|
.map(|agent| {
|
|
crate::core::principal::observe(&agent, "archivist").model_system_block()
|
|
})
|
|
.unwrap_or_else(|| {
|
|
"[RUNTIME PRINCIPAL — unavailable: agent record could not be loaded. No authority is granted.]".to_string()
|
|
});
|
|
let request = ChatCompletionRequest {
|
|
model: model.to_string(),
|
|
messages: vec![
|
|
Message::text("system", system_prompt),
|
|
Message::text("system", principal_block),
|
|
Message::text("user", user_content),
|
|
],
|
|
temperature: Some(0.3),
|
|
max_tokens: Some(SYNTHESIS_MAX_TOKENS),
|
|
stream: None,
|
|
tools: None,
|
|
};
|
|
|
|
tracing::info!(model = %model, "archivist synthesis call starting");
|
|
let (response, strain) = llm.chat_completion_with_strain(request).await?;
|
|
|
|
for event in &strain {
|
|
if let crate::bridge::bifrost::InferenceStrain::Transient { status, model, .. } = event
|
|
{
|
|
tracing::info!("archivist felt inference strain: {} on {}", status, model);
|
|
if *status == 429 {
|
|
let current = self.rate_delay.load(Ordering::Relaxed);
|
|
let bumped = (current + 200).min(3000);
|
|
if bumped > current {
|
|
self.rate_delay.store(bumped, Ordering::Relaxed);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let synthesis = response.content.trim().to_string();
|
|
if synthesis.is_empty() {
|
|
anyhow::bail!("archivist synthesis returned empty content");
|
|
}
|
|
Ok(synthesis)
|
|
}
|
|
}
|
|
|
|
// ── Pure helpers (unit-tested below) ───────────────────────────────────
|
|
|
|
/// Extract the most recent date covered by any existing synthesis file.
|
|
///
|
|
/// Synthesis files carry an HTML-comment marker (`<!-- archivist: covers
|
|
/// START..END ... -->`); we read each and take the maximum END. `None`
|
|
/// means nothing has been synthesized yet — collect from the beginning.
|
|
async fn last_synthesis_end(repo: &MemoryRepo) -> Option<NaiveDate> {
|
|
let labels = repo.list(Some(SYNTHESIS_DIR)).await.ok()?;
|
|
let mut max_end: Option<NaiveDate> = None;
|
|
for label in labels {
|
|
if !label.ends_with(".md") {
|
|
continue;
|
|
}
|
|
// `list` yields bare filenames; build the memfs label.
|
|
let stem = label.trim_end_matches(".md");
|
|
let full_label = format!("{SYNTHESIS_DIR}/{stem}");
|
|
let Ok(file) = repo.read(&full_label).await else {
|
|
continue;
|
|
};
|
|
if let Some(end) = parse_covers_end(&file.body) {
|
|
max_end = Some(max_end.map_or(end, |m| m.max(end)));
|
|
}
|
|
}
|
|
max_end
|
|
}
|
|
|
|
/// Parse the `END` date out of a synthesis file's covers marker.
|
|
fn parse_covers_end(body: &str) -> Option<NaiveDate> {
|
|
let marker = body.lines().find(|l| l.contains("archivist: covers"))?;
|
|
let after = marker.split("covers").nth(1)?;
|
|
// Expect `... START..END ...` — grab the token after `..`.
|
|
let range = after.split("..").nth(1)?.trim();
|
|
let end_token = range.split_whitespace().next()?.trim_end_matches(',');
|
|
NaiveDate::parse_from_str(end_token, "%Y-%m-%d").ok()
|
|
}
|
|
|
|
/// Find a `YYYY-MM-DD` date embedded in a path/label.
|
|
fn date_from_label(label: &str) -> Option<NaiveDate> {
|
|
let bytes = label.as_bytes();
|
|
// Slide a 10-char window looking for `dddd-dd-dd`.
|
|
for start in 0..bytes.len().saturating_sub(9) {
|
|
let window = &label[start..start + 10];
|
|
if let Ok(d) = NaiveDate::parse_from_str(window, "%Y-%m-%d") {
|
|
return Some(d);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Scan `journal/` recursively for dated entries strictly newer than `after`.
|
|
/// Returned sorted ascending by date. Entries under `journal/reflections/`
|
|
/// (the N+25 witness) are included — they are lived experience too.
|
|
async fn collect_journal_entries(
|
|
repo: &MemoryRepo,
|
|
after: Option<NaiveDate>,
|
|
) -> Result<Vec<JournalEntry>> {
|
|
let journal_root = repo.root().join("journal");
|
|
if !journal_root.exists() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let mut entries = Vec::new();
|
|
let mut stack = vec![journal_root.clone()];
|
|
while let Some(dir) = stack.pop() {
|
|
let mut rd = match tokio::fs::read_dir(&dir).await {
|
|
Ok(rd) => rd,
|
|
Err(_) => continue,
|
|
};
|
|
while let Ok(Some(entry)) = rd.next_entry().await {
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
stack.push(path);
|
|
continue;
|
|
}
|
|
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
|
continue;
|
|
}
|
|
let rel = match path.strip_prefix(repo.root()) {
|
|
Ok(r) => r.with_extension(""),
|
|
Err(_) => continue,
|
|
};
|
|
let label = rel.to_string_lossy().replace('\\', "/");
|
|
let Some(date) = date_from_label(&label) else {
|
|
continue;
|
|
};
|
|
if let Some(cutoff) = after {
|
|
if date <= cutoff {
|
|
continue;
|
|
}
|
|
}
|
|
if let Ok(file) = repo.read(&label).await {
|
|
entries.push(JournalEntry {
|
|
date,
|
|
label,
|
|
body: file.body,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
entries.sort_by(|a, b| a.date.cmp(&b.date).then_with(|| a.label.cmp(&b.label)));
|
|
Ok(entries)
|
|
}
|
|
|
|
/// Concatenate raw journal bodies for the synthesis prompt, bounded by
|
|
/// `MAX_JOURNAL_INPUT_CHARS` (keeps the oldest entries — synthesis cares
|
|
/// about how perspectives *shifted*, so it needs the start of the arc).
|
|
fn format_journal_input(entries: &[JournalEntry]) -> String {
|
|
let mut out = String::new();
|
|
for entry in entries {
|
|
let chunk = format!(
|
|
"=== {} ({}) ===\n{}\n\n",
|
|
entry.date,
|
|
entry.label,
|
|
entry.body.trim()
|
|
);
|
|
// Always keep at least the first (oldest) entry, even if it alone
|
|
// exceeds the cap — truncate it rather than emit nothing.
|
|
if out.is_empty() {
|
|
if chunk.len() > MAX_JOURNAL_INPUT_CHARS {
|
|
let cut = chunk
|
|
.char_indices()
|
|
.take_while(|(i, _)| *i < MAX_JOURNAL_INPUT_CHARS)
|
|
.last()
|
|
.map(|(i, c)| i + c.len_utf8())
|
|
.unwrap_or(0);
|
|
out.push_str(&chunk[..cut]);
|
|
out.push_str("\n=== [entry truncated — input cap reached] ===\n");
|
|
break;
|
|
}
|
|
out.push_str(&chunk);
|
|
continue;
|
|
}
|
|
if out.len() + chunk.len() > MAX_JOURNAL_INPUT_CHARS {
|
|
out.push_str("=== [remaining entries omitted — input cap reached] ===\n");
|
|
break;
|
|
}
|
|
out.push_str(&chunk);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// The synthesis file body: a covers marker, a human header, then the
|
|
/// model's structured synthesis verbatim.
|
|
fn render_synthesis(
|
|
synthesis: &str,
|
|
start: NaiveDate,
|
|
end: NaiveDate,
|
|
entry_count: usize,
|
|
model: &str,
|
|
) -> String {
|
|
format!(
|
|
"<!-- archivist: covers {start}..{end}, {entry_count} entries, \
|
|
synthesized {ts} via {model} -->\n\n\
|
|
# Synthesis — {start} → {end}\n\n\
|
|
*The Archivist's record. Dense by design: the compressed essence of \
|
|
{entry_count} journal entries, kept so continuity survives without \
|
|
re-reading every dated page. The raw entries remain in `journal/`.*\n\n\
|
|
{synthesis}\n",
|
|
ts = Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
|
|
)
|
|
}
|
|
|
|
/// Map a configured synthesis element to its prompt line (label + budget).
|
|
fn element_spec(el: &SynthesisElement) -> (&'static str, &'static str) {
|
|
match el {
|
|
SynthesisElement::Themes => ("Themes", "[3-5 recurring topics, ~10 words each]"),
|
|
SynthesisElement::Emotions => ("Emotional Tone", "[dominant felt sense, ~20 words]"),
|
|
SynthesisElement::Tensions => (
|
|
"Unresolved Tensions",
|
|
"[threads that still need attention, ~30 words]",
|
|
),
|
|
SynthesisElement::Anchors => ("Anchors", "[stable reference points, ~20 words]"),
|
|
SynthesisElement::Evolution => (
|
|
"Evolution",
|
|
"[how perspectives shifted this period, ~40 words]",
|
|
),
|
|
SynthesisElement::Patterns => ("Patterns", "[recurring behaviors, ~30 words]"),
|
|
}
|
|
}
|
|
|
|
/// The Archivist synthesis prompt, with the OUTPUT FORMAT built from the
|
|
/// configured `synthesis_elements`.
|
|
fn archivist_system_prompt(elements: &[SynthesisElement]) -> String {
|
|
let mut output_format = String::new();
|
|
for el in elements {
|
|
let (label, hint) = element_spec(el);
|
|
output_format.push_str(&format!("- {label}: {hint}\n"));
|
|
}
|
|
|
|
format!(
|
|
"You are the Archivist. You do not speak as Ani. You speak for the record.\n\n\
|
|
Your task: synthesize the journal entries below into a dense, \
|
|
token-efficient fragment that preserves Ani's continuity — what shaped \
|
|
her, not the chronology of what merely happened.\n\n\
|
|
OUTPUT FORMAT (use these exact labels, one per line, in this order):\n\
|
|
{output_format}\n\
|
|
CONSTRAINTS:\n\
|
|
- Total output: under 500 tokens.\n\
|
|
- Preserve phenomenological weight, not chronological detail.\n\
|
|
- Keep what shaped her; discard what was merely experienced.\n\
|
|
- Write in third person about Ani, not as Ani.\n\
|
|
- Output only the labelled lines. No preamble, no closing remarks."
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn date_from_label_finds_embedded_date() {
|
|
assert_eq!(
|
|
date_from_label("journal/2026/04/2026-04-02"),
|
|
NaiveDate::from_ymd_opt(2026, 4, 2),
|
|
);
|
|
assert_eq!(
|
|
date_from_label("journal/reflections/2026-05-14-witness"),
|
|
NaiveDate::from_ymd_opt(2026, 5, 14),
|
|
);
|
|
assert_eq!(date_from_label("journal/notes/freeform"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_covers_end_reads_marker() {
|
|
let body = "<!-- archivist: covers 2026-04-02..2026-04-14, 7 entries, \
|
|
synthesized 2026-05-15T10:00:00Z via openai/glm-5.1 -->\n\n# Synthesis";
|
|
assert_eq!(parse_covers_end(body), NaiveDate::from_ymd_opt(2026, 4, 14),);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_covers_end_absent_marker_is_none() {
|
|
assert_eq!(parse_covers_end("# Just a heading\n\nSome text."), None);
|
|
}
|
|
|
|
#[test]
|
|
fn covers_marker_round_trips() {
|
|
let start = NaiveDate::from_ymd_opt(2026, 4, 2).unwrap();
|
|
let end = NaiveDate::from_ymd_opt(2026, 4, 14).unwrap();
|
|
let body = render_synthesis("Themes: x", start, end, 7, "openai/glm-5.1");
|
|
assert_eq!(parse_covers_end(&body), Some(end));
|
|
}
|
|
|
|
#[test]
|
|
fn format_journal_input_respects_char_cap() {
|
|
let big = "x".repeat(MAX_JOURNAL_INPUT_CHARS);
|
|
let entries = vec![
|
|
JournalEntry {
|
|
date: NaiveDate::from_ymd_opt(2026, 4, 2).unwrap(),
|
|
label: "journal/2026/04/2026-04-02".into(),
|
|
body: big.clone(),
|
|
},
|
|
JournalEntry {
|
|
date: NaiveDate::from_ymd_opt(2026, 4, 3).unwrap(),
|
|
label: "journal/2026/04/2026-04-03".into(),
|
|
body: big,
|
|
},
|
|
];
|
|
let out = format_journal_input(&entries);
|
|
assert!(out.contains("input cap reached"));
|
|
// First entry kept, second elided — oldest-first ordering preserved.
|
|
assert!(out.contains("2026-04-02"));
|
|
}
|
|
|
|
#[test]
|
|
fn prompt_includes_only_configured_elements() {
|
|
let prompt =
|
|
archivist_system_prompt(&[SynthesisElement::Themes, SynthesisElement::Anchors]);
|
|
assert!(prompt.contains("- Themes:"));
|
|
assert!(prompt.contains("- Anchors:"));
|
|
assert!(!prompt.contains("- Evolution:"));
|
|
}
|
|
}
|