use serde_json::json;
use vta_sdk::prelude::*;
use vta_sdk::protocols::memory::{MemoryItem, MemoryListResponse};
use vta_sdk::specs::vta::memory::{get::v0_2 as get_spec, list::v0_3 as list_spec};
const PAGE: u64 = 500;
#[derive(Debug)]
struct Item {
key: String,
value: String,
version: u64,
}
use crate::render::{BOLD, CYAN, DIM, GREEN, RED, RESET, is_json_output, print_json};
pub async fn cmd_memory_plant(
client: &VtaClient,
context: &str,
key: &str,
value: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let resp = client
.memory_put_versioned(context, key, value, None)
.await?;
if is_json_output() {
print_json(&resp)?;
return Ok(());
}
let version = resp.get("version").and_then(|v| v.as_u64()).unwrap_or(0);
println!(
"{GREEN}\u{2713}{RESET} Planted {BOLD}{key}{RESET} in context '{context}' (version {version})."
);
println!(" {DIM}{value}{RESET}");
Ok(())
}
pub async fn cmd_memory_recall(
client: &VtaClient,
context: &str,
key: Option<&str>,
purpose: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let items = match key {
Some(k) => get_item(client, context, k, purpose)
.await?
.into_iter()
.collect(),
None => list_items(client, context, purpose).await?,
};
if is_json_output() {
let items = items
.into_iter()
.map(|i| MemoryItem {
key: i.key,
value: i.value,
})
.collect();
print_json(&MemoryListResponse { items })?;
return Ok(());
}
if items.is_empty() {
match key {
Some(k) => println!("No memory under '{k}' in context '{context}'."),
None => println!("Context '{context}' has no memories."),
}
return Ok(());
}
println!();
println!("{BOLD}Memory for context '{context}'{RESET}");
println!();
for item in &items {
println!(" {CYAN}{}{RESET} {}", item.key, item.value);
}
println!();
Ok(())
}
pub async fn cmd_memory_forget(
client: &VtaClient,
context: &str,
key: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let resp = client.memory_delete_versioned(context, key, None).await?;
if resp.get("existed").and_then(|v| v.as_bool()) != Some(true) {
return Err(
format!("no memory under '{key}' in context '{context}' — nothing to delete").into(),
);
}
if is_json_output() {
print_json(&resp)?;
return Ok(());
}
println!("{GREEN}\u{2713}{RESET} Deleted memory {BOLD}{key}{RESET} in context '{context}'.");
Ok(())
}
pub async fn cmd_memory_wipe(
client: &VtaClient,
context: &str,
assume_yes: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let items = list_items(client, context, None).await?;
if items.is_empty() {
if is_json_output() {
print_json(&json!({ "wiped": Vec::<String>::new() }))?;
} else {
println!("{DIM}Context '{context}' is already empty — nothing to wipe.{RESET}");
}
return Ok(());
}
match wipe_guard(assume_yes, is_json_output()) {
WipeGuard::RefuseJson => {
return Err("`memory wipe` needs `--yes` in --json mode: there is no \
prompt to confirm to"
.into());
}
WipeGuard::Confirm => {
println!(
"{RED}This wipes all {} in context '{context}'.{RESET}",
count_memories(items.len())
);
let go = dialoguer::Confirm::new()
.with_prompt("Wipe every memory in this context?")
.default(false)
.interact()?;
if !go {
println!("Cancelled — nothing was wiped.");
return Ok(());
}
}
WipeGuard::Proceed => {}
}
let mut wiped: Vec<String> = Vec::with_capacity(items.len());
for item in &items {
if let Err(e) = client
.memory_delete_versioned(context, &item.key, Some(item.version))
.await
{
return Err(format!(
"wiped {} of {} before failing on '{}': {e}",
wiped.len(),
items.len(),
item.key,
)
.into());
}
wiped.push(item.key.clone());
}
if is_json_output() {
print_json(&json!({ "wiped": wiped }))?;
return Ok(());
}
println!(
"{RED}\u{2713}{RESET} Wiped {} from context '{context}'.",
count_memories(wiped.len())
);
Ok(())
}
async fn list_items(
client: &VtaClient,
context: &str,
purpose: Option<&str>,
) -> Result<Vec<Item>, Box<dyn std::error::Error>> {
let mut items = Vec::new();
let mut cursor: Option<String> = None;
loop {
let mut query = serde_json::Map::new();
query.insert("limit".into(), json!(PAGE));
if let Some(c) = &cursor {
query.insert("cursor".into(), json!(c));
}
let (page, next) =
decode_page(client.memory_list_page_for(context, query, purpose).await?)?;
items.extend(page);
match next {
Some(n) => cursor = Some(n),
None => break,
}
}
Ok(items)
}
async fn get_item(
client: &VtaClient,
context: &str,
key: &str,
purpose: Option<&str>,
) -> Result<Option<Item>, Box<dyn std::error::Error>> {
let resp: get_spec::Response =
serde_json::from_value(client.memory_get_for(context, &[key], purpose).await?)?;
Ok(resp.items.into_iter().find_map(live_item))
}
fn decode_page(resp: serde_json::Value) -> Result<(Vec<Item>, Option<String>), serde_json::Error> {
let page: list_spec::Response = serde_json::from_value(resp)?;
Ok((
page.items.into_iter().filter_map(live_item).collect(),
page.next_cursor,
))
}
fn live_item<R: serde::Serialize>(record: R) -> Option<Item> {
let v = serde_json::to_value(record).ok()?;
if v.get("deleted").and_then(|d| d.as_bool()) == Some(true) {
return None;
}
Some(Item {
key: v.get("key")?.as_str()?.to_string(),
value: v.get("value")?.as_str()?.to_string(),
version: v.get("version")?.as_u64()?,
})
}
#[derive(Debug, PartialEq, Eq)]
enum WipeGuard {
Proceed,
Confirm,
RefuseJson,
}
fn wipe_guard(assume_yes: bool, json: bool) -> WipeGuard {
match (assume_yes, json) {
(true, _) => WipeGuard::Proceed,
(false, true) => WipeGuard::RefuseJson,
(false, false) => WipeGuard::Confirm,
}
}
fn count_memories(n: usize) -> String {
if n == 1 {
"1 memory".to_string()
} else {
format!("{n} memories")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wipe_yes_always_proceeds() {
assert_eq!(wipe_guard(true, false), WipeGuard::Proceed);
assert_eq!(wipe_guard(true, true), WipeGuard::Proceed);
}
#[test]
fn wipe_json_without_yes_refuses() {
assert_eq!(wipe_guard(false, true), WipeGuard::RefuseJson);
}
#[test]
fn wipe_interactive_without_yes_confirms() {
assert_eq!(wipe_guard(false, false), WipeGuard::Confirm);
}
fn record(key: &str, value: &str, version: u64) -> serde_json::Value {
json!({
"contextId": "c", "key": key, "value": value, "version": version,
"deleted": false, "updatedAt": "2026-11-09T00:00:00Z"
})
}
#[test]
fn decode_reads_a_page_its_cursor_and_skips_tombstones() {
let v = json!({
"items": [
record("a", "1", 3),
{ "contextId": "c", "key": "gone", "version": 4, "deleted": true,
"updatedAt": "2026-11-09T00:00:00Z" },
record("b", "2", 5),
],
"nextCursor": "next",
"highWatermark": 5
});
let (items, next) = decode_page(v).expect("well-formed page decodes");
assert_eq!(items.len(), 2);
assert_eq!(items[0].key, "a");
assert_eq!(items[1].value, "2");
assert_eq!(items[1].version, 5, "wipe conditions on this");
assert_eq!(next.as_deref(), Some("next"));
}
#[test]
fn decode_of_a_malformed_body_errors_rather_than_shortening() {
let v = json!({ "items": [ { "contextId": "c", "key": "a", "value": 7, "version": 1,
"deleted": false, "updatedAt": "2026-11-09T00:00:00Z" } ],
"highWatermark": 1 });
assert!(decode_page(v).is_err());
}
#[test]
fn count_memories_agrees_the_noun() {
assert_eq!(count_memories(0), "0 memories");
assert_eq!(count_memories(1), "1 memory");
assert_eq!(count_memories(2), "2 memories");
}
}