1use 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
44pub 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 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
91pub 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 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
175pub 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
251pub 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}