Skip to main content

caboodle/
agent_memory.rs

1//! Opt-in memory setup through the existing settings owner. Configuration is
2//! not firing evidence, and firing is not promotion/migration acceptance.
3use std::{collections::BTreeSet, path::Path};
4
5use anyhow::{bail, Context, Result};
6use serde_json::{json, Value};
7use toml_edit::{value, DocumentMut};
8
9pub const BUNDLE_NAME: &str = "camayoc-memory";
10
11pub mod hooks;
12pub mod lifecycle;
13#[cfg(test)]
14mod tests;
15
16#[derive(
17    Clone,
18    Copy,
19    Default,
20    Debug,
21    PartialEq,
22    Eq,
23    serde::Serialize,
24    serde::Deserialize,
25    clap::ValueEnum,
26)]
27#[serde(rename_all = "lowercase")]
28pub enum RedirectMode {
29    #[default]
30    Copy,
31    Block,
32    Off,
33}
34impl RedirectMode {
35    pub fn as_str(self) -> &'static str {
36        match self {
37            Self::Copy => "copy",
38            Self::Block => "block",
39            Self::Off => "off",
40        }
41    }
42}
43
44/// Build the exact reviewable edits without changing a host. Bobbin continues
45/// to own recall; no second recall hook or MCP server is invented here.
46pub fn render(root_config: &str, bobbin_config: &str, person: &str) -> Result<(String, String)> {
47    render_with_mode(root_config, bobbin_config, person, RedirectMode::Copy)
48}
49pub fn render_with_mode(
50    root_config: &str,
51    bobbin_config: &str,
52    person: &str,
53    mode: RedirectMode,
54) -> Result<(String, String)> {
55    if person.is_empty() || person.chars().any(|c| c.is_whitespace() || c.is_control()) {
56        bail!("person must be a nonempty IRI or local entity name");
57    }
58    let mut root: DocumentMut = root_config.parse().context("parse shantytown config")?;
59    let mut bobbin: DocumentMut = bobbin_config.parse().context("parse bobbin config")?;
60    for (key, val) in [
61        ("CAMAYOC_CAPTURE", "1"),
62        ("CAMAYOC_MEMORY_REDIRECT", mode.as_str()),
63        ("CAMAYOC_PERSON", person),
64        ("SHANTY_STOP_RULING_MEMORY", "1"),
65        ("BOBBIN_MEMORY_RECALL", "1"),
66    ] {
67        if root.get("env").is_some_and(|v| !v.is_table_like()) {
68            bail!("shantytown [env] is not a table");
69        }
70        root["env"][key] = value(val);
71    }
72    if bobbin.get("context").is_some_and(|v| !v.is_table_like()) {
73        bail!("bobbin [context] is not a table");
74    }
75    // Preserve a deliberate nondefault budget. Zero cannot meet this plan.
76    match bobbin
77        .get("context")
78        .and_then(|v| v.get("knowledge_budget_pct"))
79    {
80        Some(v) => {
81            let pct = v.as_float().or_else(|| v.as_integer().map(|n| n as f64));
82            if !pct.is_some_and(|n| n.is_finite() && n > 0.0 && n <= 100.0) {
83                bail!("memory recall requires knowledge_budget_pct in (0,100]");
84            }
85        }
86        None => bobbin["context"]["knowledge_budget_pct"] = value(15.0),
87    }
88    Ok((root.to_string(), bobbin.to_string()))
89}
90
91/// Strict view of the settings owner's real report. Every required item must
92/// be configured, carried by a live harness, AND observed firing. We never
93/// translate a not-checked/unknown report to success or fabricate timestamps.
94pub fn assess(report: &Value) -> Result<Value> {
95    assess_with_mode(report, RedirectMode::Copy)
96}
97pub fn assess_with_mode(report: &Value, mode: RedirectMode) -> Result<Value> {
98    if report["schema"] != "st.hook-check/1" {
99        bail!("unsupported settings-owner hook report");
100    }
101    if !report
102        .get("registry_errors")
103        .and_then(Value::as_array)
104        .is_some_and(Vec::is_empty)
105    {
106        bail!("settings owner cannot establish an error-free hook registry");
107    }
108    let items = report["items"]
109        .as_array()
110        .context("owner report has no items")?;
111    let mut pending = BTreeSet::new();
112    for harness in ["claude", "codex"] {
113        for (name, event) in [
114            ("bobbin", "UserPromptSubmit"),
115            (BUNDLE_NAME, "SessionStart"),
116            (BUNDLE_NAME, "UserPromptSubmit"),
117            (BUNDLE_NAME, "PreToolUse"),
118            (BUNDLE_NAME, "Stop"),
119        ] {
120            if mode == RedirectMode::Off && event == "PreToolUse" {
121                continue;
122            }
123            let rows: Vec<_> = items
124                .iter()
125                .filter(|r| r["harness"] == harness && r["bundle"] == name && r["event"] == event)
126                .collect();
127            if rows.is_empty()
128                || rows.iter().any(|r| r["configured"] != "ok")
129                || !rows
130                    .iter()
131                    .any(|r| r["live"] == "ok" && r["firing"] == "ok")
132            {
133                pending.insert(format!("{harness}: {name} {event} firing not proven"));
134            }
135        }
136        if harness == "claude"
137            && !items.iter().any(|r| {
138                r["harness"] == harness
139                    && r["bundle"] == BUNDLE_NAME
140                    && r["event"] == "SessionEnd"
141                    && r["configured"] == "ok"
142                    && r["live"] == "ok"
143                    && r["firing"] == "ok"
144            })
145        {
146            pending.insert("claude: camayoc-memory SessionEnd firing not proven".to_string());
147        }
148    }
149    let hook_setup_status = if pending.is_empty() {
150        "verified"
151    } else {
152        "pending"
153    };
154    let hook_pending = pending;
155    let mut pending = BTreeSet::new();
156    // These proofs have separate producers. A recent hook timestamp alone
157    // cannot establish any of them; never flip auto-memory from this report.
158    for gate in [
159        "migration and conflict clearance",
160        "18 of 20 recall prompts without superseded hits",
161        "native Stop ruling reminder",
162        "typed promotion within ten minutes for both harnesses",
163        "SessionStart Handoff and Directive pins",
164    ] {
165        pending.insert(format!("acceptance pending: {gate}"));
166    }
167    Ok(
168        json!({"schema":"tambo.agent-memory-verification/1", "status":"pending",
169        "hook_setup_status":hook_setup_status,"hook_pending":hook_pending,
170        "unproven_carriers":items.iter().filter(|r| (r["bundle"]==BUNDLE_NAME || r["bundle"]=="bobbin") && (r["live"]!="ok" || r["firing"]!="ok")).map(|r|json!({"harness":r["harness"],"role":r["role"],"event":r["event"],"live":r["live"],"firing":r["firing"]})).collect::<Vec<_>>(),
171        "auto_memory_changed":false, "auto_memory_eligible":false,"auto_memory_blockers":pending}),
172    )
173}
174
175/// Verify the exact plan's current files and the owner's live firing report.
176/// No functional writes, hook invocation, or auto-memory mutation occurs here.
177pub fn verify(directory: &Path, runner: &mut impl lifecycle::Runner) -> Result<Value> {
178    let plan = lifecycle::load(directory)?;
179    let owner = runner.report(
180        "st",
181        &[
182            "--root".into(),
183            plan.root.display().to_string(),
184            "ops".into(),
185            "hooks".into(),
186            "check".into(),
187            "--json".into(),
188        ],
189    )?;
190    let owner: Value = serde_json::from_str(&owner).context("no readable owner hook report")?;
191    let mut result = assess_with_mode(&owner, plan.redirect_mode)?;
192    result["redirect_mode"] = json!(plan.redirect_mode);
193    let mut drift = Vec::new();
194    if lifecycle::check_existing_redirect(&plan.harness_configs).is_err() {
195        drift.push("existing standalone redirect hook conflicts with this plan".to_string());
196    }
197    if owner["root"] != plan.root.display().to_string() {
198        drift.push("owner report is for another or unknown store".to_string());
199    }
200    for bundle in &plan.bundles {
201        if owner["items"]
202            .as_array()
203            .into_iter()
204            .flatten()
205            .any(|r| r["bundle"] == bundle["name"] && r["version"] != bundle["version"])
206        {
207            drift.push("registered bundle version differs from plan".to_string());
208        }
209    }
210    let state = std::fs::read(directory.join("state.json"))
211        .ok()
212        .and_then(|b| serde_json::from_slice::<Value>(&b).ok());
213    if state.as_ref().map(|s| &s["phase"]) != Some(&json!("applied")) {
214        drift.push("memory plan is not applied".to_string());
215    }
216    for change in &plan.changes {
217        if std::fs::read_to_string(&change.path).ok().as_deref() != Some(change.after.as_str()) {
218            drift.push(format!(
219                "configured file differs: {}",
220                change.path.display()
221            ));
222        }
223    }
224    for args in [
225        vec!["--version"],
226        vec!["record", "decision", "--help"],
227        vec!["recall", "decision", "--help"],
228    ] {
229        if runner
230            .run(
231                "cy",
232                &args.iter().map(|s| (*s).to_string()).collect::<Vec<_>>(),
233            )
234            .is_err()
235        {
236            drift.push("generated Camayoc CLI tools unavailable".to_string());
237        }
238    }
239    if lifecycle::check_tools(&plan, runner).is_err() {
240        drift.push("Camayoc generated tool schemas differ or cannot be read".to_string());
241    }
242    if !drift.is_empty() {
243        result["hook_setup_status"] = json!("pending");
244    }
245    result["configuration_drift"] = json!(drift);
246    result["plan_id"] = json!(plan.id);
247    result["source_revision"] = json!(plan.source_revision);
248    Ok(result)
249}
250
251/// The rollout decision is separate from installing the setup. A future
252/// acceptance producer must supply migration/recall/promotion/pin proof; no
253/// caller-supplied bool or a recent hook timestamp can bypass that gate.
254pub fn disable_auto_memory(directory: &Path, runner: &mut impl lifecycle::Runner) -> Result<()> {
255    let report = verify(directory, runner)?;
256    bail!(
257        "auto-memory remains unchanged: {}",
258        report["auto_memory_blockers"]
259    );
260}