Skip to main content

vta_cli_common/commands/
memory.rs

1//! `pnm memory …` (and any CLI that adopts it) — CRUD over the VTA's
2//! per-context agent memory (`spec/vta/memory/{put,list,delete}/0.1`).
3//!
4//! A per-context key/value store the hosted agent reads before it answers:
5//! `plant` upserts an entry, `recall` lists (optionally one key), `forget`
6//! deletes one, and `wipe` clears the whole context. Each maps onto one of
7//! the SDK memory methods.
8//!
9//! Two server-side gates gate every operation, and both matter to what an
10//! operator sees here:
11//!
12//! - **Capability** — `MemoryRead` for `list`, `MemoryWrite` for `put` and
13//!   `delete` (`vta-service/src/trust_tasks/memory.rs`). `wipe` therefore
14//!   needs both: it lists before it deletes. A `reader` holds only
15//!   `MemoryRead`, so `plant` / `forget` / `wipe` are refused for that role.
16//! - **Context access** — the isolation boundary. A caller can only touch
17//!   memory in a context it is permitted to act in, so a context-A agent
18//!   never reaches context-B memory.
19//!
20//! The context is never defaulted at this layer. A super-admin's access check
21//! passes for *any* context id and the memory tasks do not require the context
22//! to exist, so a guessed or mistyped id would read and write a context that
23//! is not there — silently, and looking exactly like an empty one. The caller
24//! names the context or there is no call.
25
26use serde_json::json;
27use vta_sdk::prelude::*;
28use vta_sdk::protocols::memory::{MemoryItem, MemoryListResponse};
29
30use crate::render::{BOLD, CYAN, DIM, GREEN, RED, RESET, is_json_output, print_json};
31
32/// `plant` → `memory_put`. Upserts `value` under `(context, key)`; re-planting
33/// the same key overwrites the stored value.
34pub async fn cmd_memory_plant(
35    client: &VtaClient,
36    context: &str,
37    key: &str,
38    value: &str,
39) -> Result<(), Box<dyn std::error::Error>> {
40    let resp = client.memory_put(context, key, value).await?;
41    if is_json_output() {
42        print_json(&resp)?;
43        return Ok(());
44    }
45    println!("{GREEN}\u{2713}{RESET} Planted {BOLD}{key}{RESET} in context '{context}'.");
46    println!("  {DIM}{value}{RESET}");
47    Ok(())
48}
49
50/// `recall` → `memory_list`, optionally filtered to a single key.
51pub async fn cmd_memory_recall(
52    client: &VtaClient,
53    context: &str,
54    key: Option<&str>,
55) -> Result<(), Box<dyn std::error::Error>> {
56    let mut items = list_items(client, context).await?;
57    if let Some(k) = key {
58        items.retain(|item| item.key == k);
59    }
60
61    if is_json_output() {
62        // The SDK's own response shape *is* the stable `--json` contract here,
63        // so re-emit it rather than hand-building an equivalent that could
64        // drift from it.
65        print_json(&MemoryListResponse { items })?;
66        return Ok(());
67    }
68
69    if items.is_empty() {
70        match key {
71            Some(k) => println!("No memory under '{k}' in context '{context}'."),
72            None => println!("Context '{context}' has no memories."),
73        }
74        return Ok(());
75    }
76
77    println!();
78    println!("{BOLD}Memory for context '{context}'{RESET}");
79    println!();
80    for item in &items {
81        println!("  {CYAN}{}{RESET}  {}", item.key, item.value);
82    }
83    println!();
84    Ok(())
85}
86
87/// `forget` → `memory_delete` for one key.
88pub async fn cmd_memory_forget(
89    client: &VtaClient,
90    context: &str,
91    key: &str,
92) -> Result<(), Box<dyn std::error::Error>> {
93    let resp = client.memory_delete(context, key).await?;
94    if is_json_output() {
95        print_json(&resp)?;
96        return Ok(());
97    }
98    println!("{GREEN}\u{2713}{RESET} Forgot {BOLD}{key}{RESET} in context '{context}'.");
99    Ok(())
100}
101
102/// `wipe` → list, then `memory_delete` every key. There is no bulk-delete
103/// Trust Task, so this is N round-trips and *not* atomic. Needs both memory
104/// capabilities — `MemoryRead` for the list, `MemoryWrite` for the deletes.
105///
106/// The confirmation is where the operator consents to a destructive op;
107/// `--yes` is the only thing allowed to stand in for it. `--json` selects an
108/// output format, not consent — so with neither a prompt (JSON mode) nor
109/// `--yes`, this refuses rather than proceeds. See [`wipe_guard`].
110pub async fn cmd_memory_wipe(
111    client: &VtaClient,
112    context: &str,
113    assume_yes: bool,
114) -> Result<(), Box<dyn std::error::Error>> {
115    let items = list_items(client, context).await?;
116
117    if items.is_empty() {
118        if is_json_output() {
119            print_json(&json!({ "wiped": Vec::<String>::new() }))?;
120        } else {
121            println!("{DIM}Context '{context}' is already empty — nothing to wipe.{RESET}");
122        }
123        return Ok(());
124    }
125
126    match wipe_guard(assume_yes, is_json_output()) {
127        WipeGuard::RefuseJson => {
128            return Err("`memory wipe` needs `--yes` in --json mode: there is no \
129                        prompt to confirm to"
130                .into());
131        }
132        WipeGuard::Confirm => {
133            println!(
134                "{RED}This wipes all {} in context '{context}'.{RESET}",
135                count_memories(items.len())
136            );
137            let go = dialoguer::Confirm::new()
138                .with_prompt("Wipe every memory in this context?")
139                .default(false)
140                .interact()?;
141            if !go {
142                println!("Cancelled — nothing was wiped.");
143                return Ok(());
144            }
145        }
146        WipeGuard::Proceed => {}
147    }
148
149    // Not atomic: on a mid-loop failure, name what did delete before surfacing
150    // the error. Re-running `wipe` is safe and finishes the job — but only if
151    // the operator can see where it stopped.
152    let mut wiped: Vec<String> = Vec::with_capacity(items.len());
153    for item in &items {
154        if let Err(e) = client.memory_delete(context, &item.key).await {
155            return Err(format!(
156                "wiped {} of {} before failing on '{}': {e}",
157                wiped.len(),
158                items.len(),
159                item.key,
160            )
161            .into());
162        }
163        wiped.push(item.key.clone());
164    }
165
166    if is_json_output() {
167        print_json(&json!({ "wiped": wiped }))?;
168        return Ok(());
169    }
170    println!(
171        "{RED}\u{2713}{RESET} Wiped {} from context '{context}'.",
172        count_memories(wiped.len())
173    );
174    Ok(())
175}
176
177/// Fetch and typed-decode a context's memory. Decoding into the SDK body means
178/// a malformed response is an error, not a silently-shortened list — which
179/// matters most for `wipe`, which derives *what to delete* from it.
180async fn list_items(
181    client: &VtaClient,
182    context: &str,
183) -> Result<Vec<MemoryItem>, Box<dyn std::error::Error>> {
184    let resp = client.memory_list(context).await?;
185    Ok(decode_items(resp)?)
186}
187
188/// Typed decode of a `vta/memory/list` response body into its entries.
189/// Factored out so the "malformed → error" contract is unit-testable without a
190/// live VTA.
191fn decode_items(resp: serde_json::Value) -> Result<Vec<MemoryItem>, serde_json::Error> {
192    let parsed: MemoryListResponse = serde_json::from_value(resp)?;
193    Ok(parsed.items)
194}
195
196/// What `wipe` should do about the confirmation, given the two inputs that
197/// decide it. Pulled out as a pure function because getting it wrong is
198/// destructive (see the `--json` finding): `--yes` always proceeds; without
199/// it, JSON mode has no one to prompt so it refuses, and otherwise we confirm.
200#[derive(Debug, PartialEq, Eq)]
201enum WipeGuard {
202    Proceed,
203    Confirm,
204    RefuseJson,
205}
206
207fn wipe_guard(assume_yes: bool, json: bool) -> WipeGuard {
208    match (assume_yes, json) {
209        (true, _) => WipeGuard::Proceed,
210        (false, true) => WipeGuard::RefuseJson,
211        (false, false) => WipeGuard::Confirm,
212    }
213}
214
215/// "1 memory" / "N memories" — count with the noun agreed.
216fn count_memories(n: usize) -> String {
217    if n == 1 {
218        "1 memory".to_string()
219    } else {
220        format!("{n} memories")
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn wipe_yes_always_proceeds() {
230        // --yes is the operator saying "yes"; it overrides both other inputs.
231        assert_eq!(wipe_guard(true, false), WipeGuard::Proceed);
232        assert_eq!(wipe_guard(true, true), WipeGuard::Proceed);
233    }
234
235    #[test]
236    fn wipe_json_without_yes_refuses() {
237        // The core finding: an output-format flag is not consent, and there is
238        // no prompt to answer — so refuse rather than delete unguarded.
239        assert_eq!(wipe_guard(false, true), WipeGuard::RefuseJson);
240    }
241
242    #[test]
243    fn wipe_interactive_without_yes_confirms() {
244        assert_eq!(wipe_guard(false, false), WipeGuard::Confirm);
245    }
246
247    #[test]
248    fn decode_reads_camelcase_items() {
249        let v = json!({ "items": [
250            { "key": "a", "value": "1" },
251            { "key": "b", "value": "2" },
252        ] });
253        let items = decode_items(v).expect("well-formed body decodes");
254        assert_eq!(items.len(), 2);
255        assert_eq!(items[0].key, "a");
256        assert_eq!(items[1].value, "2");
257    }
258
259    #[test]
260    fn decode_of_a_malformed_body_errors_rather_than_shortening() {
261        // A non-string value must fail the whole decode — never be dropped,
262        // which would make `wipe` under-count what it leaves behind.
263        let v = json!({ "items": [ { "key": "a", "value": 7 } ] });
264        assert!(decode_items(v).is_err());
265    }
266
267    #[test]
268    fn count_memories_agrees_the_noun() {
269        assert_eq!(count_memories(0), "0 memories");
270        assert_eq!(count_memories(1), "1 memory");
271        assert_eq!(count_memories(2), "2 memories");
272    }
273}