blob: ff4ebf2d6e3d30dd42647aab657fb47d2c97d09b [file]
//! T0: the small always-loaded index an agent starts with.
//!
//! Under 2k tokens by construction. It names where to look, not what is true: the latest
//! handoffs, the standing decisions, and pointers. Everything else is one `get` away.
use dm_core::approx_tokens;
use dm_index::Hit;
/// The token budget T0 is held under.
pub const BUDGET_TOKENS: usize = 2000;
/// What goes into a T0, before trimming.
pub struct Inputs<'a> {
/// The domain this T0 is for.
pub domain: &'a str,
/// Who generated it, for the header.
pub generated_by: &'a str,
/// Open handoffs, newest first.
pub handoffs: &'a [Hit],
/// Standing decisions, newest first.
pub decisions: &'a [Hit],
/// Conventions and methods, newest first.
pub conventions: &'a [Hit],
/// How many active entries the domain holds.
pub total_active: u64,
/// How many are past their review date.
pub due_for_review: usize,
/// How many admitted tasks are waiting.
pub tasks_waiting: usize,
/// The policy digest: the L0 core in full, the other layers' rules in force by id,
/// and the exceptions in force, one line each.
pub policy_digest: &'a [String],
}
fn line(h: &Hit) -> String {
let summary = if h.summary.is_empty() {
String::new()
} else {
format!(": {}", h.summary)
};
// the decay clock, where the reader sees it
let due = if h.review_due { ", review due" } else { "" };
format!(
"- `{}` {}{} ({}{due})\n",
h.id, h.title, summary, h.trust_tier
)
}
/// Render a T0 under [`BUDGET_TOKENS`]. Sections are trimmed from the bottom, oldest
/// decisions first, then conventions, then handoffs, so the newest handoff is the last
/// thing to go.
#[must_use]
pub fn render(inputs: &Inputs<'_>) -> String {
let mut handoffs = inputs.handoffs.len();
let mut decisions = inputs.decisions.len();
let mut conventions = inputs.conventions.len();
loop {
let text = render_with(inputs, handoffs, decisions, conventions);
if approx_tokens(&text) <= BUDGET_TOKENS {
return text;
}
if decisions > 0 {
decisions -= 1;
} else if conventions > 0 {
conventions -= 1;
} else if handoffs > 1 {
handoffs -= 1;
} else {
return text;
}
}
}
fn render_with(
inputs: &Inputs<'_>,
handoffs: usize,
decisions: usize,
conventions: usize,
) -> String {
let mut out = String::new();
out.push_str(&format!(
"# {} · T0 · generated by {}\n\n",
inputs.domain, inputs.generated_by
));
out.push_str("## Now\n\n");
if inputs.handoffs.is_empty() {
out.push_str("No open handoffs. Start from the ledger.\n");
} else {
for h in &inputs.handoffs[..handoffs] {
out.push_str(&line(h));
}
if handoffs < inputs.handoffs.len() {
out.push_str(&format!(
"- {} older handoffs, via search\n",
inputs.handoffs.len() - handoffs
));
}
}
out.push_str("\n## Standing decisions (full text via get)\n\n");
if inputs.decisions.is_empty() {
out.push_str("None recorded yet.\n");
} else {
for h in &inputs.decisions[..decisions] {
out.push_str(&line(h));
}
if decisions < inputs.decisions.len() {
out.push_str(&format!(
"- {} more, via search\n",
inputs.decisions.len() - decisions
));
}
}
if !inputs.conventions.is_empty() {
out.push_str("\n## Conventions and methods\n\n");
for h in &inputs.conventions[..conventions] {
out.push_str(&line(h));
}
if conventions < inputs.conventions.len() {
out.push_str(&format!(
"- {} more, via search\n",
inputs.conventions.len() - conventions
));
}
}
if !inputs.policy_digest.is_empty() {
out.push_str("\n## Policy digest\n\n");
for l in inputs.policy_digest {
out.push_str(&format!("- {l}\n"));
}
}
out.push_str("\n## Pointers\n\n");
out.push_str(&format!(
"- {} active entries in this domain; search before writing a fact that may exist\n",
inputs.total_active
));
if inputs.due_for_review > 0 {
out.push_str(&format!(
"- {} entries past their review date; treat their facts as unverified\n",
inputs.due_for_review
));
}
out.push_str(&format!(
"- {} tasks waiting on the ledger; `claim_next` takes the highest-ranked\n",
inputs.tasks_waiting
));
out.push_str("- when you stop, write a `handoff` entry; the next session starts from it, not from your transcript\n");
out
}
#[cfg(test)]
mod tests {
use super::*;
use dm_core::{EntryType, Label, TrustTier};
fn hit(id: &str, title: &str, summary: &str, kind: EntryType) -> Hit {
Hit {
id: id.into(),
domain: "dom-a".into(),
entry_type: kind,
trust_tier: TrustTier::Raw,
classification: Label::Public,
title: title.into(),
summary: summary.into(),
score: 0.0,
review_due: false,
}
}
fn inputs<'a>(handoffs: &'a [Hit], decisions: &'a [Hit], conventions: &'a [Hit]) -> Inputs<'a> {
Inputs {
domain: "dom-a",
generated_by: "memory 0.1",
handoffs,
decisions,
conventions,
total_active: 42,
due_for_review: 3,
tasks_waiting: 2,
policy_digest: &[],
}
}
#[test]
fn empty_domain_still_orients() {
let t = render(&inputs(&[], &[], &[]));
assert!(t.starts_with("# dom-a · T0"));
assert!(t.contains("No open handoffs"));
assert!(t.contains("None recorded yet"));
assert!(t.contains("42 active entries"));
assert!(t.contains("3 entries past their review date"));
assert!(t.contains("2 tasks waiting"));
assert!(t.contains("write a `handoff` entry"));
assert!(
!t.contains("Conventions and methods"),
"an empty section is left out"
);
}
#[test]
fn lists_handoffs_decisions_and_conventions() {
let h = [hit(
"mem-a-h1",
"Stopped at step 3",
"Resume there.",
EntryType::Handoff,
)];
let d = [hit(
"mem-a-d1",
"Retry with backoff",
"",
EntryType::Decision,
)];
let c = [hit(
"mem-a-c1",
"Commit prefixes",
"feat, fix, docs.",
EntryType::Convention,
)];
let t = render(&inputs(&h, &d, &c));
assert!(t.contains("- `mem-a-h1` Stopped at step 3: Resume there. (raw)"));
assert!(t.contains("- `mem-a-d1` Retry with backoff (raw)"));
assert!(t.contains("## Conventions and methods"));
assert!(t.contains("`mem-a-c1`"));
}
#[test]
fn stays_under_budget_and_drops_decisions_first() {
let long = "word ".repeat(60);
let handoffs: Vec<Hit> = (0..5)
.map(|i| hit(&format!("mem-a-h{i}"), "Handoff", &long, EntryType::Handoff))
.collect();
let decisions: Vec<Hit> = (0..40)
.map(|i| {
hit(
&format!("mem-a-d{i}"),
"Decision",
&long,
EntryType::Decision,
)
})
.collect();
let t = render(&inputs(&handoffs, &decisions, &[]));
assert!(approx_tokens(&t) <= BUDGET_TOKENS, "{}", approx_tokens(&t));
assert!(t.contains("more, via search"));
assert!(t.contains("mem-a-h0"), "the newest handoff survives");
assert!(!t.contains("mem-a-d39"), "the oldest decision goes first");
}
#[test]
fn a_single_oversized_handoff_is_kept_rather_than_producing_nothing() {
let huge = "word ".repeat(3000);
let handoffs = [hit("mem-a-h0", "Huge", &huge, EntryType::Handoff)];
let t = render(&inputs(&handoffs, &[], &[]));
assert!(t.contains("mem-a-h0"));
}
}