diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ffe16c5..3aa3996 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,7 +121,7 @@ jobs: runs-on: [self-hosted, linux, arm64] services: dakera: - image: ghcr.io/dakera-ai/dakera:0.12.0 + image: ghcr.io/dakera-ai/dakera:0.12.3 ports: - 13300:3000 env: diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e3e4cb..18e631a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,78 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.3] - Unreleased + +Support for Dakera server **v0.12.4**, with `dakera-client` 0.12.3. Works against v0.11.108 and +v0.12.0 – v0.12.3 servers as before; the forget options, `forget-detect` and `--projection-days` +need v0.12.4, and `dk` refuses the forget options on an older server before sending anything +(an older server would ignore `--dry-run` or `--restorable` and delete). + +### Added + +- **`dk memory forget` forgets by description and by request** (server v0.12.4). `--about ` + forgets the memories that say it (scored by the server's relevance model, `--min-score`, + `--limit`); `--request ` takes a user's request to forget as said, in any language, + and forgets what it means, or nothing when the message is not one (the server's reading is + shown: probability, threshold, the sentence that decided). `--until` keeps what was said after + the request, `--restorable` keeps a copy in the agent's trash, `--dry-run` lists what would be + forgotten. The memory id is now optional and several may be given; with `--about` / + `--request` only the listed ids that match are forgotten. The table lists the forgotten ids; + `--format json` prints the server's answer. +- **`dk memory forget-detect ...`** (`POST /v1/memory/forget/detect`, v0.12.4): which + messages ask to forget something said, with the probability, in any language; `--file` reads + one message per line (`-` for stdin). Reads no memory. +- **`dk memory trash `** and **`dk memory restore `**: the agent's restorable + deletions (forgotten with `--restorable`, deduplicated or consolidated) with their content, and + putting one back exactly as it was stored (server v0.12.3; forgotten ones v0.12.4). +- `dk memory store` says when the stored text reads as a request to forget (the store answer's + `forget_request`, v0.12.4) and how to act on it, and when the memory's session has ended. + `dk memory batch-store` says the same for each such memory (`forget_requests`), and lists the + items the server did not store as near-duplicates (`duplicates`, under the namespace's + `dedup_on_store`). +- `dk memory recall` shows the agent's **standing instructions** that fit the query + (`instructions`, v0.12.4) and the **associated memories** (knowledge-graph neighbours of the + results, on by default since v0.12.4) in their own sections, and warns about parts of the + recall the server could not serve (`degraded`). `--include-associated[=false]` turns the + associated memories on or off (sent only when given). `--format json` carries + `instructions` and `associated_memories` too. +- `dk memory get --projection-days N`: the memory's projected importance for the next N days + (v0.12.4). +- `dk session end --auto-summarize[=false]`: store the session's summary memory for any session + with memories, or never (honoured since v0.12.4); a summary the server could not store yet + (`summary_pending`) is reported. +- `dk memory extract` says which extractor ran (GLiNER and rules, or rules only) and shows the + server's warnings (a failed or partial model pass, v0.12.4). +- `dk namespace policy get` says when the namespace has no stored policy (`stored: false`, + v0.12.4): what is shown is what its memories get. +- Hints for the new answers of v0.12.4: a forget by `--about` / `--request` refused (`503`) while + the relevance model is not loaded; a name refused (`503` + `Retry-After`) while a deleted + namespace's state is removed; a change answered `409` because a later change of the same + record won; a change that could not be stored or logged (`503`); and the refusals of a Dakera + Cloud key. + +### Fixed + +- **`dk memory consolidate --dry-run` merged the memories.** The server has no consolidation + preview and never read `dry_run` (nor `memory_type`), so a "preview" deleted the sources. Both + options are now refused (exit 5) before anything is sent. The command takes `--ids` (the + memories to merge; without it the server picks similar ones), `--threshold` (sent only when + given: the server's default is 0.85, `dk` sent 0.8) and `--target-type`, and reports the merge + from the server's answer (`consolidated_memory`, `source_memory_ids`, `memories_removed`; it + printed zeros). +- `dk keys delete` printed "deleted" before the server answered, also when it refused. Since + v0.12.4 a key revocation or deactivation, a quota and a backup deletion that the server applied + but could not store yet are answered `202`: `dk keys delete|deactivate`, `dk admin quotas-set` + and `dk admin backup-delete` say so instead of reporting a plain success. +- `dk namespace policy set`: the TTL options' help gave the old per-type defaults (4 h, 30 d, + 365 d, 730 d); since v0.12.4 a policy has no TTL by type unless one is set. +- `dk --format json|compact memory store` and `memory batch-store` print one JSON document (the + store answer; the stored rows) and their notes on stderr; a success line came first on stdout. + +### Changed + +- `dakera-client` 0.12.2 → 0.12.3. + ## [0.8.2] - 2026-10-22 Support for Dakera server **v0.12.3**. Follows the engine's recall API correctness and security diff --git a/Cargo.lock b/Cargo.lock index e316307..23d4ea3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -345,7 +345,7 @@ dependencies = [ [[package]] name = "dakera-cli" -version = "0.8.2" +version = "0.8.3" dependencies = [ "anyhow", "assert_cmd", @@ -368,9 +368,9 @@ dependencies = [ [[package]] name = "dakera-client" -version = "0.12.2" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e8f082a3d37049a29bf8e89eb548dca7d52fde6b68c3bb12cb07c2332420a3d" +checksum = "9f30b3a7aac6ecefed95587db715b58a31398bc74c1344d93244c81ec1d9c4ed" dependencies = [ "futures-util", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index 96f8319..c9f69e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dakera-cli" -version = "0.8.2" +version = "0.8.3" edition = "2021" license = "MIT" description = "Command-line interface for Dakera AI Agent Memory Platform" @@ -19,7 +19,7 @@ path = "src/main.rs" clap = { version = "4.6", default-features = false, features = ["std", "help", "usage", "error-context", "env"] } # Dakera client SDK -dakera-client = "0.12.2" +dakera-client = "0.12.3" # Async runtime tokio = { version = "1.52", features = ["full"] } diff --git a/README.md b/README.md index 45a8deb..bad628d 100644 --- a/README.md +++ b/README.md @@ -317,8 +317,17 @@ dk memory batch-store my-agent --file memories.json --lang de # Extract entities from text without storing it dk memory extract "Anna met Bob in Paris" --entity-types person,location -# Delete a single memory -dk memory forget my-agent mem-abc123 +# Forget memories by ID +dk memory forget my-agent mem-abc123 mem-def456 + +# Forget by what was said, or by a user's request to forget, in any language (server v0.12.4) +dk memory forget my-agent --about "my home address" --dry-run +dk memory forget my-agent --request "Please forget what I told you about my ex" --restorable +dk memory forget-detect "Oublie mon adresse" "I always forget my keys" + +# Restorable deletions: list, and put one back +dk memory trash my-agent +dk memory restore my-agent mem-abc123 # Batch delete by filters (dry-run first!) dk memory batch-forget my-agent --min-importance 0.3 --dry-run @@ -327,8 +336,12 @@ dk memory batch-forget my-agent --min-importance 0.3 --max-age-days 90 # Update importance scores dk memory importance my-agent --ids mem-1,mem-2 --value 0.9 -# Consolidate similar memories into summaries -dk memory consolidate my-agent --dry-run +# Merge memories into one (without --ids the server picks similar ones; no preview) +dk memory consolidate my-agent --ids mem-1,mem-2,mem-3 +dk memory consolidate my-agent --threshold 0.9 + +# A memory's projected importance for the next 30 days (server v0.12.4) +dk memory get my-agent mem-abc123 --projection-days 30 # Submit recall quality feedback (upvote, downvote, flag) dk memory feedback my-agent mem-abc123 upvote @@ -359,6 +372,7 @@ dk session start my-agent dk session start my-agent --idle-timeout 8h # v0.12.2: own inactivity timeout (0 = never) dk session touch sess-abc123 # v0.12.2: keep a quiet session open dk session end sess-abc123 +dk session end sess-abc123 --auto-summarize # v0.12.4: summary memory for any session with memories dk session list --agent-id my-agent --active-only dk session get sess-abc123 dk session memories sess-abc123 --preview 200 --limit 20 @@ -545,6 +559,27 @@ dk completion powershell ## Dakera v0.12 and compatibility +`dk` 0.8.3 supports Dakera **v0.12.4** and works against v0.11.108 and v0.12.0 – v0.12.3 as +before. What 0.8.3 adds needs a v0.12.4 server, as noted: + +| Command (0.8.3) | v0.12.3 and older | v0.12.4 | +|---|---|---| +| `dk memory forget --about / --request / --dry-run / --restorable` | refused by `dk` before sending (the server would ignore them and delete) | yes; `--about` / `--request` answer `503` while the server's relevance model is not loaded | +| `dk memory forget-detect` | no route | yes | +| `dk memory trash`, `dk memory restore` | v0.12.3 (deduplicated and consolidated memories) | also memories forgotten with `--restorable` | +| `dk memory recall`: Standing instructions, Associated, `degraded` warnings | not sent (associated memories only with `--include-associated`) | instructions when the agent has some; associated memories by default | +| `dk memory get --projection-days` | ignored | yes | +| `dk session end --auto-summarize` | ignored | yes; a summary not stored yet is reported | +| `dk memory store` / `batch-store` notes (requests to forget, near-duplicates) | never shown | when the server reports them | +| `dk keys delete|deactivate`, `dk admin quotas-set|backup-delete` | `200` | `202` when the change applies but is not stored yet: `dk` says so | + +Since v0.12.4 the server tags stored memories with people, organisations and locations by +default (`DAKERA_DEFAULT_ENTITY_TYPES`), with a GLiNER model chosen by the embedding model's +languages (`gliner-bi-small` for English, `gliner-x-small` for about 100 languages; an operator +can set `DAKERA_GLINER_MODEL` to another model of the same languages: `gliner-bi-edge` or +`gliner-medium` for English, `gliner-multi` multilingual). The tags are added in the background after the +store answers. `dk memory extract` says whether the model or only the rules ran. + > **Breaking in dk 0.8.2:** `dk --format json|compact memory recall` prints one object, > `{"memories": [...], "context_memories": [...]}`, instead of an array of rows: read the ranked > results from `.memories`. diff --git a/src/api.rs b/src/api.rs index 86f4246..bc79a9e 100644 --- a/src/api.rs +++ b/src/api.rs @@ -68,6 +68,11 @@ impl ApiHttpError { } } + /// Whether the message or the details say `needle`. + fn mentions(&self, needle: &str) -> bool { + self.message.contains(needle) || self.details_contain(needle) + } + fn details_contain(&self, needle: &str) -> bool { match &self.details { Some(d) => d.contains(needle), @@ -80,11 +85,30 @@ impl ApiHttpError { pub fn hint(&self) -> Option { let code = self.code.as_deref().unwrap_or(""); if self.status == 503 || self.status == 429 { - return Some(match self.retry_after_secs { + let retry = match self.retry_after_secs { Some(secs) => format!("the server asks to retry in {secs}s (Retry-After)"), None => HINT_BUSY.to_string(), + }; + // Dakera v0.12.4: forgetting by `--about` / `--request` needs the + // relevance model (and the request detector); a name whose deleted + // namespace is still being cleaned up refuses writes for a while. + let specific = if self.mentions("relevance model") || self.mentions("detector") { + Some(HINT_FORGET_MODEL) + } else if self.mentions("not accepting writes") { + Some(HINT_FENCED) + } else if self.mentions("could not be stored or logged") { + Some(HINT_NOT_DURABLE) + } else { + None + }; + return Some(match specific { + Some(why) => format!("{why}; {retry}"), + None => retry, }); } + if self.status == 409 && self.mentions("a later change of the same record") { + return Some(HINT_SUPERSEDED.to_string()); + } // Since v0.12.3 the admin key, backup and rotate-key requests refuse a // field they do not know instead of ignoring it. if self.status == 422 || self.status == 400 { @@ -105,6 +129,10 @@ impl ApiHttpError { (403, "INSUFFICIENT_SCOPE") if super_admin => HINT_SUPER_ADMIN, (403, "INSUFFICIENT_SCOPE") => HINT_SCOPE, (403, "CROSS_ORIGIN_REQUEST_REFUSED") => HINT_CROSS_ORIGIN, + (403, "TENANT_ROUTE") => HINT_TENANT_ROUTE, + (403, "TENANT_SUSPENDED") => HINT_TENANT_SUSPENDED, + (403, "TENANT_POLICY") => HINT_TENANT_POLICY, + (403, "TENANT_MODE_OFF") => HINT_TENANT_MODE_OFF, (413, "QUOTA_EXCEEDED") => HINT_QUOTA, (413, _) => HINT_TOO_LARGE, (501, "FEATURE_DISABLED") => HINT_DISABLED, @@ -124,6 +152,21 @@ pub fn unknown_field(message: &str) -> Option<&str> { const HINT_AUTH: &str = "authentication failed: set DAKERA_API_KEY to a valid, unexpired API key"; const HINT_BUSY: &str = "the server is busy or starting; retry shortly"; +const HINT_FORGET_MODEL: &str = "forgetting by --about or --request needs the server's relevance \ + model (and, for --request, its request-to-forget detector), which is not loaded yet or failed \ + to load; forget by memory id meanwhile"; +const HINT_FENCED: &str = "this name belonged to a deleted namespace whose stored state is still \ + being removed; writes to it are refused until that is done"; +const HINT_NOT_DURABLE: &str = "the change applies now but the server could not store it or log \ + it, so a restart could undo it; run the command again once the server's storage recovers"; +const HINT_SUPERSEDED: &str = "another change of the same record was made at the same time and \ + won; nothing of this one was applied. Check the current state and repeat it if still needed"; +const HINT_TENANT_ROUTE: &str = "this command is not available with a Dakera Cloud key"; +const HINT_TENANT_SUSPENDED: &str = "this Dakera Cloud account is suspended; the message says why"; +const HINT_TENANT_POLICY: &str = "the limits of this Dakera Cloud account do not allow this \ + request; the message names what they refuse"; +const HINT_TENANT_MODE_OFF: &str = "this key belongs to a Dakera Cloud account and is not valid \ + on this server; use a key this server issued"; const HINT_PINNED: &str = "this API key is pinned to namespaces; since v0.12 such a key is \ refused on node-wide routes (/admin/*: backups, encryption, quotas, config). Use a key that \ is not pinned to a namespace."; @@ -270,6 +313,45 @@ pub async fn request_json( json_of(resp).await } +/// [`request_json`] that also returns the status of the successful answer. +/// Since Dakera v0.12.4 a change the server applied but could not store yet +/// (an API key revocation or deactivation, a quota, a backup deletion) is +/// answered `202`: see [`pending_write_note`]. +pub async fn request_json_status( + url: &str, + method: Method, + path: &str, + body: Option<&Value>, +) -> Result<(u16, Value)> { + let client = crate::commands::authed_client(); + let mut req = client.request(method.clone(), format!("{url}{path}")); + if let Some(b) = body { + req = req.json(b); + } + let resp = send(req, &method, path).await?; + let resp = ensure_success(resp).await?; + let status = resp.status().as_u16(); + Ok((status, json_of(resp).await?)) +} + +/// What to tell the user about a `202` answer to a change (Dakera v0.12.4): +/// it applies now, but the server could not store it yet; it is logged and +/// retried until it is stored, after a restart too. `None` for any other +/// status. +pub fn pending_write_note(status: u16, answer: &Value) -> Option { + if status != 202 { + return None; + } + let said = str_of(answer, "message") + .or_else(|| str_of(answer, "warning")) + .map(str::to_string); + Some(said.unwrap_or_else(|| { + "the change applies now but is not stored yet: the server logged it and retries until \ + it is stored, after a restart too (`dk health` lists pending writes)" + .to_string() + })) +} + /// [`request_json`] with the verbose request/response log lines. pub async fn request_json_logged( ctx: &Context, @@ -320,6 +402,12 @@ pub async fn post_bytes( json_of(result?).await } +/// A request type as a JSON body, its `f32` fields written as the SDK sends +/// them (`0.9`, not the `0.8999999761581421` of `serde_json::to_value`). +pub fn body_of(request: &T) -> Result { + Ok(serde_json::from_slice(&serde_json::to_vec(request)?)?) +} + /// A string field of a JSON object. pub fn str_of<'a>(v: &'a Value, key: &str) -> Option<&'a str> { v.get(key).and_then(|x| x.as_str()) @@ -497,6 +585,52 @@ mod tests { assert!(e.hint().is_none()); } + #[test] + fn forget_model_and_fenced_namespace_503s_say_why() { + let body = json!({ + "error": "Service unavailable", + "code": "SERVICE_UNAVAILABLE", + "details": "forget by description needs the relevance model (not loaded); retry" + }); + let e = raw(503, Some(5), &body.to_string()); + let hint = e.hint().unwrap(); + assert!(hint.contains("--about or --request"), "{hint}"); + assert!(hint.contains("retry in 5s"), "{hint}"); + let body = json!({ + "error": "Namespace 'a' is not accepting writes right now (teardown); retry in 5 s", + "code": "SERVICE_UNAVAILABLE", + }); + let e = raw(503, Some(5), &body.to_string()); + assert!(e.hint().unwrap().contains("deleted namespace")); + } + + #[test] + fn superseded_change_409_and_tenant_refusals_have_hints() { + let body = json!({ + "success": false, + "message": "a later change of the same record took over before this one was stored; \ + nothing of this one was applied" + }); + let e = raw(409, None, &body.to_string()); + assert!(e.hint().unwrap().contains("won")); + let e = answer(403, "TENANT_ROUTE", "not allowed", None); + assert!(e.hint().unwrap().contains("Dakera Cloud")); + let e = answer(409, "CONFLICT", "exists", None); + assert!(e.hint().is_none()); + } + + #[test] + fn pending_write_note_only_for_202() { + let said = json!({ "success": true, "message": "API key 'k' has been deleted on every node now; logged" }); + assert!(pending_write_note(202, &said) + .unwrap() + .contains("every node")); + assert!(pending_write_note(202, &json!({})) + .unwrap() + .contains("not stored yet")); + assert!(pending_write_note(200, &said).is_none()); + } + #[test] fn segment_encodes_reserved_characters() { assert_eq!(segment("sha256:ab"), "sha256:ab"); diff --git a/src/cli.rs b/src/cli.rs index b05f6bd..36d6585 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -557,10 +557,10 @@ pub fn build_namespace_command() -> Command { Command::new("set") .about("Update memory policy fields for a namespace (only supplied flags are changed)") .arg(Arg::new("namespace").required(true).help("Namespace name")) - .arg(Arg::new("working-ttl").long("working-ttl").value_parser(value_parser!(u64)).help("TTL for working memories in seconds (default: 14400 = 4h)")) - .arg(Arg::new("episodic-ttl").long("episodic-ttl").value_parser(value_parser!(u64)).help("TTL for episodic memories in seconds (default: 2592000 = 30d)")) - .arg(Arg::new("semantic-ttl").long("semantic-ttl").value_parser(value_parser!(u64)).help("TTL for semantic memories in seconds (default: 31536000 = 365d)")) - .arg(Arg::new("procedural-ttl").long("procedural-ttl").value_parser(value_parser!(u64)).help("TTL for procedural memories in seconds (default: 63072000 = 730d)")) + .arg(Arg::new("working-ttl").long("working-ttl").value_parser(value_parser!(u64)).help("TTL in seconds for working memories stored without one (default: none, they never expire by type)")) + .arg(Arg::new("episodic-ttl").long("episodic-ttl").value_parser(value_parser!(u64)).help("TTL in seconds for episodic memories stored without one (default: none, they never expire by type)")) + .arg(Arg::new("semantic-ttl").long("semantic-ttl").value_parser(value_parser!(u64)).help("TTL in seconds for semantic memories stored without one (default: none, they never expire by type)")) + .arg(Arg::new("procedural-ttl").long("procedural-ttl").value_parser(value_parser!(u64)).help("TTL in seconds for procedural memories stored without one (default: none, they never expire by type)")) .arg(Arg::new("working-decay").long("working-decay").value_parser(["exponential", "power_law", "logarithmic", "flat"]).help("Decay curve for working memories")) .arg(Arg::new("episodic-decay").long("episodic-decay").value_parser(["exponential", "power_law", "logarithmic", "flat"]).help("Decay curve for episodic memories")) .arg(Arg::new("semantic-decay").long("semantic-decay").value_parser(["exponential", "power_law", "logarithmic", "flat"]).help("Decay curve for semantic memories")) @@ -727,13 +727,34 @@ pub fn build_memory_command() -> Command { "Let session summaries be recalled (=false keeps them out); \ default: the server's choice", ), + ) + .arg( + Arg::new("include-associated") + .long("include-associated") + .value_name("BOOL") + .num_args(0..=1) + .require_equals(true) + .default_missing_value("true") + .value_parser(value_parser!(bool)) + .help( + "List the knowledge-graph neighbours of the results in their own \ + Associated section (=false leaves them out); default: the server's \ + choice, on since v0.12.4", + ), ), ) .subcommand( Command::new("get") .about("Get a specific memory by ID") .arg(Arg::new("agent_id").required(true).help("Agent ID")) - .arg(Arg::new("memory_id").required(true).help("Memory ID")), + .arg(Arg::new("memory_id").required(true).help("Memory ID")) + .arg( + Arg::new("projection-days") + .long("projection-days") + .value_name("DAYS") + .value_parser(value_parser!(u32).range(1..=365)) + .help("Also show the memory's importance for each of the next DAYS days if nothing touches it (1-365; server v0.12.4)"), + ), ) .subcommand( Command::new("update") @@ -757,15 +778,18 @@ pub fn build_memory_command() -> Command { "Language of the memory's content (a change re-derives the text-based data)", )), ) + .subcommand(build_memory_forget_command()) + .subcommand(build_memory_forget_detect_command()) .subcommand( - Command::new("forget") - .about("Delete a memory") + Command::new("trash") + .about("List an agent's restorable deletions: memories forgotten with --restorable, deduplicated or consolidated (server v0.12.3; forgotten ones v0.12.4)") + .arg(Arg::new("agent_id").required(true).help("Agent ID")), + ) + .subcommand( + Command::new("restore") + .about("Put back a memory from the agent's trash, exactly as it was stored (server v0.12.3)") .arg(Arg::new("agent_id").required(true).help("Agent ID")) - .arg( - Arg::new("memory_id") - .required(true) - .help("Memory ID to delete"), - ), + .arg(Arg::new("memory_id").required(true).help("Memory ID (from dk memory trash)")), ) .subcommand( Command::new("search") @@ -834,27 +858,47 @@ pub fn build_memory_command() -> Command { ) .subcommand( Command::new("consolidate") - .about("Consolidate similar memories") + .about("Merge memories into one consolidated memory (the sources are deleted, restorable for a while: dk memory trash)") + .after_help( + "Without --ids the server picks the most important memory that has \ + neighbours at cosine similarity >= --threshold (same type; same session \ + unless cross-session consolidation is configured) and merges it with up to 4 \ + of them; it answers 400 when no memories are that similar.\n\nThe server has \ + no preview of a consolidation: there is no --dry-run.\n\nExamples:\n dk memory consolidate my-agent --ids m1,m2,m3\n dk memory consolidate my-agent --threshold 0.9", + ) .arg(Arg::new("agent_id").required(true).help("Agent ID")) .arg( - Arg::new("type") - .short('t') - .long("type") - .value_parser(["episodic", "semantic", "procedural", "working"]) - .help("Filter by memory type"), + Arg::new("ids") + .long("ids") + .value_delimiter(',') + .help("Comma-separated memory IDs to merge (default: the server picks similar memories)"), ) .arg( Arg::new("threshold") .long("threshold") - .default_value("0.8") - .value_parser(value_parser!(f32)) - .help("Similarity threshold for consolidation"), + .value_parser(value_parser!(f64)) + .conflicts_with("ids") + .help("Cosine similarity a neighbour needs when the server picks the memories, in (0, 1] (server default 0.85)"), + ) + .arg( + Arg::new("target-type") + .long("target-type") + .value_parser(["episodic", "semantic", "procedural", "working"]) + .help("Memory type of the consolidated memory"), + ) + .arg( + Arg::new("type") + .short('t') + .long("type") + .hide(true) + .help("Removed: the server never filtered by it"), ) .arg( Arg::new("dry-run") .long("dry-run") + .hide(true) .action(ArgAction::SetTrue) - .help("Preview consolidation without applying changes"), + .help("Removed: the server has no consolidation preview"), ), ) .subcommand( @@ -974,6 +1018,92 @@ fn memory_lang_arg(help: &'static str) -> Arg { )) } +fn build_memory_forget_command() -> Command { + Command::new("forget") + .about("Forget memories: by ID, by what they are about (--about) or by a user's request to forget (--request)") + .after_help( + "--about takes what was said, not the request to forget it (\"I love sushi\", not \ + \"Please forget that I love sushi\"). --request takes the user's message as said, in \ + any language; the server checks that it is a request to forget and forgets what it \ + means (nothing when it is not one). Both need server v0.12.4, and are refused (503) \ + while its relevance model is not loaded.\n\nExamples:\n dk memory forget my-agent 6f1c2a\n dk memory forget my-agent --about 'my home address' --dry-run\n dk memory forget my-agent --request 'Please forget what I told you about my ex' --restorable\n dk memory forget my-agent --request \"Oublie ce que je t'ai dit sur mon travail\" --until 1767225600", + ) + .arg(Arg::new("agent_id").required(true).help("Agent ID")) + .arg( + Arg::new("memory_id") + .num_args(0..) + .help("Memory IDs to forget (with --about / --request: only those of them that match)"), + ) + .arg( + Arg::new("about") + .long("about") + .value_name("TEXT") + .conflicts_with("request") + .help("Forget the memories that say this (scored by the server's relevance model; server v0.12.4)"), + ) + .arg( + Arg::new("request") + .long("request") + .value_name("MESSAGE") + .help("A user's request to forget, as said, in any language: forget what it means (server v0.12.4)"), + ) + .group(clap::ArgGroup::new("described").args(["about", "request"])) + .arg( + Arg::new("until") + .long("until") + .value_name("UNIX_SECS") + .value_parser(value_parser!(u64)) + .requires("described") + .help("With --about / --request: only memories stored at or before this time (Unix seconds), so what was said after the request is kept"), + ) + .arg( + Arg::new("min-score") + .long("min-score") + .value_name("SCORE") + .value_parser(value_parser!(f64)) + .requires("about") + .help("With --about: the least relevance (0-1) a memory needs to be forgotten (server default 0.5)"), + ) + .arg( + Arg::new("limit") + .long("limit") + .value_parser(value_parser!(usize)) + .requires("described") + .help("With --about / --request: the most memories forgotten (server default 10, at most 50)"), + ) + .arg( + Arg::new("restorable") + .long("restorable") + .action(ArgAction::SetTrue) + .help("Keep a copy in the agent's trash for the trash retention (dk memory trash / restore; server v0.12.4)"), + ) + .arg( + Arg::new("dry-run") + .long("dry-run") + .action(ArgAction::SetTrue) + .help("List what would be forgotten and forget nothing (server v0.12.4)"), + ) +} + +fn build_memory_forget_detect_command() -> Command { + Command::new("forget-detect") + .about("Check which messages ask to forget something said, in any language (POST /v1/memory/forget/detect, server v0.12.4); reads no memory") + .after_help( + "Forget what a detected request means with dk memory forget --request \ + ''.\n\nExamples:\n dk memory forget-detect 'Please forget my address' 'I always forget my keys'\n dk memory forget-detect --file messages.txt", + ) + .arg( + Arg::new("text") + .num_args(0..) + .help("Messages to read (at most 64, each at most 8,000 characters)"), + ) + .arg( + Arg::new("file") + .long("file") + .help("Read one message per line from a file ('-' reads stdin)"), + ) +} + fn build_memory_batch_store_command() -> Command { Command::new("batch-store") .about("Store many memories in one request (POST /v1/memories/store/batch, up to 1000)") @@ -1075,6 +1205,20 @@ pub fn build_session_command() -> Command { .short('s') .long("summary") .help("Session summary text"), + ) + .arg( + Arg::new("auto-summarize") + .long("auto-summarize") + .value_name("BOOL") + .num_args(0..=1) + .require_equals(true) + .default_missing_value("true") + .value_parser(value_parser!(bool)) + .help( + "Store the session's summary memory (an extract of its memories) for \ + any session with memories (=false: never); default: the server's \ + choice, for a session with more than 3 memories", + ), ), ) .subcommand( diff --git a/src/commands/admin.rs b/src/commands/admin.rs index 2e175ab..a1b2367 100644 --- a/src/commands/admin.rs +++ b/src/commands/admin.rs @@ -17,10 +17,6 @@ async fn admin_post(url: &str, path: &str, body: Option<&Value>) -> Result Result { - crate::api::request_json(url, Method::DELETE, path, None).await -} - async fn admin_put(url: &str, path: &str, body: &Value) -> Result { crate::api::request_json(url, Method::PUT, path, Some(body)).await } @@ -173,11 +169,11 @@ pub async fn execute(ctx: &Ctx, matches: &ArgMatches) -> Result<()> { let body = quota_request_body(parsed); let path = quota_path(sub.get_one::("namespace")); let t = ctx.log_request("PUT", &path); - let result = admin_put(&ctx.url, &path, &body).await; - ctx.log_response(t, if result.is_ok() { "200 OK" } else { "ERR" }); - let result = result?; - output::success("Quotas updated"); - output::print_item(&result, ctx.format); + let result = + crate::api::request_json_status(&ctx.url, Method::PUT, &path, Some(&body)).await; + ctx.log_response(t, if result.is_ok() { "OK" } else { "ERR" }); + let (status, answer) = result?; + super::keys::report_change(ctx, status, &answer, "Quotas updated"); } Some(("slow-queries", sub)) => { @@ -225,11 +221,16 @@ pub async fn execute(ctx: &Ctx, matches: &ArgMatches) -> Result<()> { let backup_id = sub.get_one::("backup_id").unwrap(); let path = format!("/admin/backups/{}", crate::api::segment(backup_id)); let t = ctx.log_request("DELETE", &path); - let result = admin_delete(&ctx.url, &path).await; - ctx.log_response(t, if result.is_ok() { "200 OK" } else { "ERR" }); - let result = result?; - output::success(&format!("Backup '{}' deleted", backup_id)); - output::print_item(&result, ctx.format); + let result = + crate::api::request_json_status(&ctx.url, Method::DELETE, &path, None).await; + ctx.log_response(t, if result.is_ok() { "OK" } else { "ERR" }); + let (status, answer) = result?; + super::keys::report_change( + ctx, + status, + &answer, + &format!("Backup '{backup_id}' deleted"), + ); } _ => { diff --git a/src/commands/completion.rs b/src/commands/completion.rs index 51d1b9a..77718c7 100644 --- a/src/commands/completion.rs +++ b/src/commands/completion.rs @@ -99,7 +99,7 @@ _dk() {{ ;; memory) [[ -z "$sub" ]] && COMPREPLY=($(compgen -W \ - "store batch-store recall get update forget search extract importance consolidate feedback" -- "$cur")) + "store batch-store recall get update forget forget-detect trash restore search extract importance consolidate feedback" -- "$cur")) ;; session) [[ -z "$sub" ]] && COMPREPLY=($(compgen -W "start touch end get list memories" -- "$cur")) @@ -251,7 +251,10 @@ _dk() { 'recall:Recall memories by semantic query' 'get:Get a memory by ID' 'update:Update an existing memory' - 'forget:Delete a memory' + 'forget:Forget memories by ID, description or request' + 'forget-detect:Check messages for requests to forget' + 'trash:List restorable deletions' + 'restore:Restore a memory from the trash' 'search:Search memories with filters' 'extract:Extract entities from text' 'importance:Update importance score' @@ -469,7 +472,10 @@ complete -c dk -f -n '__dk_using_subcommand memory' -a 'batch-store' -d 'Store m complete -c dk -f -n '__dk_using_subcommand memory' -a 'recall' -d 'Recall memories by semantic query' complete -c dk -f -n '__dk_using_subcommand memory' -a 'get' -d 'Get a memory by ID' complete -c dk -f -n '__dk_using_subcommand memory' -a 'update' -d 'Update an existing memory' -complete -c dk -f -n '__dk_using_subcommand memory' -a 'forget' -d 'Delete a memory' +complete -c dk -f -n '__dk_using_subcommand memory' -a 'forget' -d 'Forget memories by ID, description or request' +complete -c dk -f -n '__dk_using_subcommand memory' -a 'forget-detect' -d 'Check messages for requests to forget' +complete -c dk -f -n '__dk_using_subcommand memory' -a 'trash' -d 'List restorable deletions' +complete -c dk -f -n '__dk_using_subcommand memory' -a 'restore' -d 'Restore a memory from the trash' complete -c dk -f -n '__dk_using_subcommand memory' -a 'search' -d 'Search memories with filters' complete -c dk -f -n '__dk_using_subcommand memory' -a 'extract' -d 'Extract entities from text' complete -c dk -f -n '__dk_using_subcommand memory' -a 'importance' -d 'Update importance score' diff --git a/src/commands/forget.rs b/src/commands/forget.rs new file mode 100644 index 0000000..8c097e4 --- /dev/null +++ b/src/commands/forget.rs @@ -0,0 +1,653 @@ +//! Forgetting memories (Dakera v0.12.4): by id, by description (`--about`), +//! by a user's request to forget (`--request`); reading messages for +//! requests to forget (`forget-detect`); and the agent's trash of restorable +//! deletions (`trash`, `restore`). +//! +//! * `forget` `POST /v1/memory/forget` +//! * `forget-detect` `POST /v1/memory/forget/detect` +//! * `trash` `GET /v1/memory/deduplicated?agent_id=` +//! * `restore` `POST /v1/memory/deduplicated/{id}/restore?agent_id=` + +use anyhow::Result; +use clap::ArgMatches; +use dakera_client::reqwest::Method; +use serde::Serialize; +use serde_json::{json, Value}; + +use crate::api; +use crate::context::Context; +use crate::error::input_error; +use crate::output; +use crate::OutputFormat; + +/// The most messages one `forget-detect` request reads. +pub const MAX_DETECT_TEXTS: usize = 64; + +/// The longest message (`--about`, `--request`, a `forget-detect` text), in +/// characters. +pub const MAX_TEXT_CHARS: usize = 8_000; + +/// The most memories one `--about` / `--request` forget removes. +pub const MAX_FORGET_LIMIT: usize = 50; + +/// The body of `POST /v1/memory/forget` from the command's arguments. +pub fn forget_body(agent_id: &str, sub: &ArgMatches) -> Result { + let ids: Vec = sub + .get_many::("memory_id") + .map(|v| v.cloned().collect()) + .unwrap_or_default(); + let about = sub.get_one::("about"); + let request = sub.get_one::("request"); + if ids.is_empty() && about.is_none() && request.is_none() { + return Err(input_error( + "say what to forget: one or more memory ids, --about or \ + --request ", + )); + } + for (flag, text) in [("--about", about), ("--request", request)] { + if let Some(text) = text { + if text.trim().is_empty() { + return Err(input_error(format!("{flag} is empty"))); + } + if text.chars().count() > MAX_TEXT_CHARS { + return Err(input_error(format!( + "{flag} is longer than {MAX_TEXT_CHARS} characters" + ))); + } + } + } + let mut body = json!({ "agent_id": agent_id }); + if !ids.is_empty() { + body["memory_ids"] = json!(ids); + } + if let Some(about) = about { + body["about"] = json!(about); + } + if let Some(request) = request { + body["request"] = json!(request); + } + if let Some(until) = sub.get_one::("until") { + body["until"] = json!(until); + } + if let Some(score) = sub.get_one::("min-score") { + if about.is_none() { + return Err(input_error("--min-score goes with --about")); + } + if !(0.0..=1.0).contains(score) { + return Err(input_error(format!( + "--min-score is a relevance between 0 and 1, got {score}" + ))); + } + body["min_score"] = json!(score); + } + if let Some(limit) = sub.get_one::("limit") { + if *limit == 0 || *limit > MAX_FORGET_LIMIT { + return Err(input_error(format!( + "--limit is between 1 and {MAX_FORGET_LIMIT}, got {limit}" + ))); + } + body["limit"] = json!(limit); + } + if sub.get_flag("restorable") { + body["restorable"] = json!(true); + } + if sub.get_flag("dry-run") { + body["dry_run"] = json!(true); + } + Ok(body) +} + +/// "1 memory", "3 memories". +fn memories(n: u64) -> String { + if n == 1 { + "1 memory".to_string() + } else { + format!("{n} memories") + } +} + +/// One forgotten memory id. +#[derive(Debug, Serialize)] +struct IdRow { + id: String, +} + +/// The ids in `answer[key]`. +fn ids_of(answer: &Value, key: &str) -> Vec { + answer + .get(key) + .and_then(Value::as_array) + .map(|ids| { + ids.iter() + .filter_map(|id| id.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +/// How the server read a `--request` (`intent`), for the table output. +pub fn intent_line(intent: &Value) -> Option { + let probability = intent.get("probability")?.as_f64()?; + let threshold = intent + .get("threshold") + .and_then(Value::as_f64) + .unwrap_or(f64::NAN); + let is_request = api::bool_of(intent, "is_request").unwrap_or(false); + let verdict = if is_request { + "reads as a request to forget" + } else { + "does not read as a request to forget" + }; + let mut line = + format!("The message {verdict} (probability {probability:.2}, threshold {threshold:.2})"); + if let Some(sentence) = api::str_of(intent, "sentence") { + line.push_str(&format!("; the request is the sentence \"{sentence}\"")); + } + Some(line) +} + +/// `(major, minor, patch)` of a server version string (`0.12.4`, +/// `0.12.4-rc1`, `v0.12.4`). +pub fn parse_version(version: &str) -> Option<(u64, u64, u64)> { + let mut parts = version + .trim() + .trim_start_matches('v') + .split(['.', '-', '+']); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + let patch = parts.next()?.parse().ok()?; + Some((major, minor, patch)) +} + +/// The forget options an older server does not read. A v0.12.3 server +/// ignores them, and would delete the listed ids although `--dry-run` was +/// asked, or delete them for good although `--restorable` was. +fn new_forget_options(sub: &ArgMatches) -> Vec<&'static str> { + let mut used = Vec::new(); + for (id, flag) in [("about", "--about"), ("request", "--request")] { + if sub.get_one::(id).is_some() { + used.push(flag); + } + } + for (id, flag) in [("dry-run", "--dry-run"), ("restorable", "--restorable")] { + if sub.get_flag(id) { + used.push(flag); + } + } + used +} + +/// Refuse options a server before v0.12.4 would ignore (see +/// [`new_forget_options`]). The version comes from `/health`; when it cannot +/// be read the request goes ahead. +async fn require_v0124(ctx: &Context, options: &[&str]) -> Result<()> { + if options.is_empty() { + return Ok(()); + } + let Ok(probe) = api::probe(ctx, "/health").await else { + return Ok(()); + }; + let Some(version) = api::str_of(&probe.body, "version") else { + return Ok(()); + }; + match parse_version(version) { + Some(v) if v < (0, 12, 4) => Err(input_error(format!( + "{} need(s) Dakera server v0.12.4 or later; this server is {version}, which would \ + ignore {} and act on the rest of the request. Nothing was sent", + options.join(", "), + if options.len() == 1 { "it" } else { "them" } + ))), + _ => Ok(()), + } +} + +async fn forget(ctx: &Context, sub: &ArgMatches) -> Result<()> { + let agent_id = sub.get_one::("agent_id").unwrap(); + let body = forget_body(agent_id, sub)?; + require_v0124(ctx, &new_forget_options(sub)).await?; + let dry_run = sub.get_flag("dry-run"); + let restorable = sub.get_flag("restorable"); + let answer = + api::request_json_logged(ctx, Method::POST, "/v1/memory/forget", Some(&body)).await?; + if !matches!(ctx.format, OutputFormat::Table) { + output::print_item(&answer, ctx.format); + return Ok(()); + } + let intent = answer.get("intent"); + if let Some(line) = intent.and_then(intent_line) { + output::info(&line); + } + let not_a_request = intent + .and_then(|i| api::bool_of(i, "is_request")) + .is_some_and(|is| !is); + if not_a_request { + output::warning("Nothing was forgotten."); + return Ok(()); + } + let ids = ids_of(&answer, "forgotten_ids"); + let deleted = api::u64_of(&answer, "deleted_count").unwrap_or(0); + if dry_run { + output::info(&format!( + "[dry-run] Would forget {}; nothing was deleted", + memories(ids.len() as u64) + )); + } else if deleted == 0 { + output::info("Nothing matched; nothing was forgotten"); + } else if restorable { + output::success(&format!( + "Forgot {} of '{agent_id}' (restorable: `dk memory trash {agent_id}` lists them, \ + `dk memory restore {agent_id} ` puts one back)", + memories(deleted) + )); + } else { + output::success(&format!("Forgot {} of '{agent_id}'", memories(deleted))); + } + if !ids.is_empty() { + let rows: Vec = ids.into_iter().map(|id| IdRow { id }).collect(); + output::print_data(&rows, ctx.format); + } + Ok(()) +} + +/// The messages of `forget-detect`: the arguments, or one per non-empty line +/// of `--file` (`-` reads stdin). +pub fn detect_texts(args: Vec, file: Option<&str>) -> Result> { + let mut texts = args; + if let Some(text) = file { + texts.extend( + text.lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .map(str::to_string), + ); + } + if texts.is_empty() { + return Err(input_error( + "give at least one message, as an argument or with --file", + )); + } + if texts.len() > MAX_DETECT_TEXTS { + return Err(input_error(format!( + "at most {MAX_DETECT_TEXTS} messages per request, got {}", + texts.len() + ))); + } + if let Some(i) = texts + .iter() + .position(|t| t.chars().count() > MAX_TEXT_CHARS) + { + return Err(input_error(format!( + "message {} is longer than {MAX_TEXT_CHARS} characters", + i + 1 + ))); + } + Ok(texts) +} + +/// One message of `forget-detect`, as a table row. +#[derive(Debug, Serialize)] +pub struct DetectRow { + pub message: String, + pub request: String, + pub probability: String, + pub sentence: String, +} + +/// The rows of a `POST /v1/memory/forget/detect` answer, one per message. +pub fn detect_rows(texts: &[String], answer: &Value) -> Vec { + let empty = Vec::new(); + let results = answer + .get("results") + .and_then(Value::as_array) + .unwrap_or(&empty); + texts + .iter() + .zip(results) + .map(|(text, r)| DetectRow { + message: text.clone(), + request: if api::bool_of(r, "is_request").unwrap_or(false) { + "yes".to_string() + } else { + "no".to_string() + }, + probability: r + .get("probability") + .and_then(Value::as_f64) + .map(|p| format!("{p:.3}")) + .unwrap_or_else(|| "-".to_string()), + sentence: api::str_of(r, "sentence").unwrap_or("-").to_string(), + }) + .collect() +} + +async fn forget_detect(ctx: &Context, sub: &ArgMatches) -> Result<()> { + let args: Vec = sub + .get_many::("text") + .map(|v| v.cloned().collect()) + .unwrap_or_default(); + let file = match sub.get_one::("file").map(String::as_str) { + Some("-") => { + let mut text = String::new(); + std::io::Read::read_to_string(&mut std::io::stdin(), &mut text) + .map_err(|e| input_error(format!("failed to read stdin: {e}")))?; + Some(text) + } + Some(path) => Some( + std::fs::read_to_string(path) + .map_err(|e| input_error(format!("failed to read file {path}: {e}")))?, + ), + None => None, + }; + let texts = detect_texts(args, file.as_deref())?; + let body = json!({ "texts": texts }); + let answer = + api::request_json_logged(ctx, Method::POST, "/v1/memory/forget/detect", Some(&body)) + .await?; + if !matches!(ctx.format, OutputFormat::Table) { + output::print_item(&answer, ctx.format); + return Ok(()); + } + let rows = detect_rows(&texts, &answer); + let requests = rows.iter().filter(|r| r.request == "yes").count(); + output::info(&format!( + "{requests} of {} message(s) read as a request to forget (model {}, threshold {})", + rows.len(), + api::str_of(&answer, "model").unwrap_or("?"), + answer + .get("threshold") + .and_then(Value::as_f64) + .map(|t| format!("{t:.2}")) + .unwrap_or_else(|| "?".to_string()), + )); + output::print_data(&rows, ctx.format); + if requests > 0 { + output::info("Forget what one means: dk memory forget --request ''"); + } + Ok(()) +} + +/// `?agent_id=` of the trash routes. +fn agent_query(agent_id: &str) -> String { + format!("agent_id={}", api::segment(agent_id)) +} + +/// One restorable memory of the agent's trash. +#[derive(Debug, Serialize)] +struct TrashRow { + id: String, + kind: String, + survivor_id: String, + deleted_at: String, + restorable_until: String, + content: String, +} + +fn trash_rows(answer: &Value) -> Vec { + let empty = Vec::new(); + let text = |v: &Value, key: &str| api::str_of(v, key).unwrap_or("-").to_string(); + let time = |v: &Value, key: &str| { + api::u64_of(v, key) + .map(|t| t.to_string()) + .unwrap_or_else(|| "-".to_string()) + }; + answer + .get("memories") + .and_then(Value::as_array) + .unwrap_or(&empty) + .iter() + .map(|m| TrashRow { + id: text(m, "id"), + kind: text(m, "kind"), + survivor_id: text(m, "survivor_id"), + deleted_at: time(m, "deleted_at"), + restorable_until: time(m, "restorable_until"), + content: text(m, "content"), + }) + .collect() +} + +async fn trash(ctx: &Context, sub: &ArgMatches) -> Result<()> { + let agent_id = sub.get_one::("agent_id").unwrap(); + let path = format!("/v1/memory/deduplicated?{}", agent_query(agent_id)); + let answer = api::request_json_logged(ctx, Method::GET, &path, None).await?; + if !matches!(ctx.format, OutputFormat::Table) { + output::print_item(&answer, ctx.format); + return Ok(()); + } + let rows = trash_rows(&answer); + if rows.is_empty() { + output::info(&format!("No restorable memories for '{agent_id}'")); + return Ok(()); + } + output::info(&format!( + "{} restorable memories of '{agent_id}' (kind: deletion = forgotten with --restorable; \ + deduplication / consolidation = merged into survivor_id). Times are Unix seconds.", + rows.len() + )); + output::print_data(&rows, ctx.format); + Ok(()) +} + +async fn restore(ctx: &Context, sub: &ArgMatches) -> Result<()> { + let agent_id = sub.get_one::("agent_id").unwrap(); + let memory_id = sub.get_one::("memory_id").unwrap(); + let path = format!( + "/v1/memory/deduplicated/{}/restore?{}", + api::segment(memory_id), + agent_query(agent_id) + ); + let memory = api::request_json_logged(ctx, Method::POST, &path, None).await?; + if matches!(ctx.format, OutputFormat::Table) { + output::success(&format!("Memory '{memory_id}' restored for '{agent_id}'")); + } + output::print_item(&memory, ctx.format); + Ok(()) +} + +/// Run `dk memory forget | forget-detect | trash | restore`. +pub async fn execute(ctx: &Context, name: &str, sub: &ArgMatches) -> Result<()> { + match name { + "forget" => forget(ctx, sub).await, + "forget-detect" => forget_detect(ctx, sub).await, + "trash" => trash(ctx, sub).await, + "restore" => restore(ctx, sub).await, + other => anyhow::bail!("not a forget command: {other}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn forget_matches(args: &[&str]) -> ArgMatches { + let m = crate::cli::build_memory_command() + .try_get_matches_from(args) + .expect("parse"); + m.subcommand_matches("forget").unwrap().clone() + } + + #[test] + fn forget_by_id_keeps_the_old_body() { + let sub = forget_matches(&["memory", "forget", "bot", "m1"]); + let body = forget_body("bot", &sub).unwrap(); + assert_eq!(body, json!({"agent_id": "bot", "memory_ids": ["m1"]})); + } + + #[test] + fn forget_by_request_sends_every_option() { + let sub = forget_matches(&[ + "memory", + "forget", + "bot", + "--request", + "Oublie mon adresse", + "--until", + "1700000000", + "--limit", + "5", + "--restorable", + "--dry-run", + ]); + let body = forget_body("bot", &sub).unwrap(); + assert_eq!( + body, + json!({ + "agent_id": "bot", + "request": "Oublie mon adresse", + "until": 1_700_000_000u64, + "limit": 5, + "restorable": true, + "dry_run": true + }) + ); + } + + #[test] + fn forget_by_description_takes_min_score() { + let sub = forget_matches(&[ + "memory", + "forget", + "bot", + "--about", + "I love sushi", + "--min-score", + "0.7", + ]); + let body = forget_body("bot", &sub).unwrap(); + assert_eq!(body["about"], "I love sushi"); + assert!((body["min_score"].as_f64().unwrap() - 0.7).abs() < 1e-6); + } + + #[test] + fn forget_needs_something_to_forget() { + let sub = forget_matches(&["memory", "forget", "bot"]); + assert!(forget_body("bot", &sub).is_err()); + } + + #[test] + fn forget_refuses_out_of_range_options() { + let sub = forget_matches(&["memory", "forget", "bot", "--about", "x", "--limit", "51"]); + assert!(forget_body("bot", &sub).is_err()); + let sub = forget_matches(&[ + "memory", + "forget", + "bot", + "--about", + "x", + "--min-score", + "2", + ]); + assert!(forget_body("bot", &sub).is_err()); + let sub = forget_matches(&["memory", "forget", "bot", "--about", " "]); + assert!(forget_body("bot", &sub).is_err()); + } + + #[test] + fn about_and_request_are_exclusive_and_their_options_need_one() { + let cmd = crate::cli::build_memory_command; + assert!(cmd() + .try_get_matches_from(["memory", "forget", "bot", "--about", "a", "--request", "b"]) + .is_err()); + assert!(cmd() + .try_get_matches_from(["memory", "forget", "bot", "m1", "--until", "5"]) + .is_err()); + // clap counts --request as the group --about belongs to: forget_body refuses it. + let sub = forget_matches(&[ + "memory", + "forget", + "bot", + "--request", + "r", + "--min-score", + "0.5", + ]); + assert!(forget_body("bot", &sub).is_err()); + assert!(cmd() + .try_get_matches_from(["memory", "forget", "bot", "m1", "--restorable", "--dry-run"]) + .is_ok()); + } + + #[test] + fn versions_parse_and_compare() { + assert_eq!(parse_version("0.12.4"), Some((0, 12, 4))); + assert_eq!(parse_version("v0.12.10-rc1"), Some((0, 12, 10))); + assert!(parse_version("0.12.3").unwrap() < (0, 12, 4)); + assert!(parse_version("0.13.0").unwrap() >= (0, 12, 4)); + assert_eq!(parse_version("dev"), None); + } + + #[test] + fn new_forget_options_names_what_an_older_server_ignores() { + let sub = forget_matches(&["memory", "forget", "bot", "m1"]); + assert!(new_forget_options(&sub).is_empty()); + let sub = forget_matches(&["memory", "forget", "bot", "m1", "--dry-run", "--restorable"]); + assert_eq!(new_forget_options(&sub), vec!["--dry-run", "--restorable"]); + } + + #[test] + fn intent_line_says_how_the_request_was_read() { + let yes = json!({"probability": 0.97, "threshold": 0.5, "is_request": true}); + assert!(intent_line(&yes).unwrap().contains("reads as a request")); + let no = json!({"probability": 0.1, "threshold": 0.5, "is_request": false}); + assert!(intent_line(&no).unwrap().contains("does not read")); + let sentence = json!({ + "probability": 0.9, "threshold": 0.6, "is_request": true, + "sentence": "Please forget my address." + }); + assert!(intent_line(&sentence) + .unwrap() + .contains("Please forget my address.")); + } + + #[test] + fn detect_texts_reads_arguments_and_lines() { + let texts = detect_texts(vec!["a".into()], Some("b\n\n c \n")).unwrap(); + assert_eq!(texts, vec!["a", "b", "c"]); + assert!(detect_texts(vec![], None).is_err()); + let many: Vec = (0..65).map(|i| i.to_string()).collect(); + assert!(detect_texts(many, None).is_err()); + assert!(detect_texts(vec!["x".repeat(8_001)], None).is_err()); + } + + #[test] + fn detect_rows_pair_messages_with_results() { + let answer = json!({ + "model": "multilingual-minilm", + "threshold": 0.5, + "sentence_threshold": 0.7, + "results": [ + {"probability": 0.98, "is_request": true}, + {"probability": 0.02, "is_request": false}, + {"probability": 0.4, "is_request": true, "sentence": "Forget my job."} + ] + }); + let texts = vec![ + "forget it".to_string(), + "hi".to_string(), + "long".to_string(), + ]; + let rows = detect_rows(&texts, &answer); + assert_eq!(rows.len(), 3); + assert_eq!(rows[0].request, "yes"); + assert_eq!(rows[0].probability, "0.980"); + assert_eq!(rows[1].request, "no"); + assert_eq!(rows[2].sentence, "Forget my job."); + } + + #[test] + fn trash_rows_read_the_list() { + let answer = json!({ + "agent_id": "bot", + "count": 1, + "memories": [{ + "id": "m1", "kind": "deletion", "survivor_id": "m1", + "deleted_at": 10, "restorable_until": 20, "content": "I love sushi" + }] + }); + let rows = trash_rows(&answer); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].kind, "deletion"); + assert_eq!(rows[0].restorable_until, "20"); + assert_eq!(rows[0].content, "I love sushi"); + } +} diff --git a/src/commands/keys.rs b/src/commands/keys.rs index 013b30f..354d59f 100644 --- a/src/commands/keys.rs +++ b/src/commands/keys.rs @@ -129,10 +129,6 @@ async fn keys_post(url: &str, path: &str, body: Option<&Value>) -> Result crate::api::request_json(url, Method::POST, path, body).await } -async fn keys_delete(url: &str, path: &str) -> Result { - crate::api::request_json(url, Method::DELETE, path, None).await -} - /// `/admin/keys/{key_id}{suffix}` with the id percent-encoded. fn key_path(key_id: &str, suffix: &str) -> String { let mut path = format!("/admin/keys/{}", crate::api::segment(key_id)); @@ -140,6 +136,20 @@ fn key_path(key_id: &str, suffix: &str) -> String { path } +/// Report a change the server answered: `done` once it is stored (`200`), +/// or a warning that it applies but is not stored yet (`202`, Dakera v0.12.4: +/// logged and retried by the server), then the answer. +pub fn report_change(ctx: &Ctx, status: u16, answer: &Value, done: &str) { + match crate::api::pending_write_note(status, answer) { + Some(note) => { + crate::commands::memory::note(ctx.format, &format!("{done}, not stored yet: {note}")) + } + None if matches!(ctx.format, OutputFormat::Table) => output::success(done), + None => {} + } + output::print_item(answer, ctx.format); +} + pub async fn execute(ctx: &Ctx, matches: &ArgMatches) -> Result<()> { match matches.subcommand() { Some(("create", sub)) => { @@ -205,20 +215,26 @@ pub async fn execute(ctx: &Ctx, matches: &ArgMatches) -> Result<()> { let key_id = sub.get_one::("key_id").unwrap(); let path = key_path(key_id, ""); let t = ctx.log_request("DELETE", &path); - let result = keys_delete(&ctx.url, &path).await; - ctx.log_response(t, if result.is_ok() { "200 OK" } else { "ERR" }); - output::success(&format!("API key '{}' deleted", key_id)); - output::print_item(&result?, ctx.format); + let result = + crate::api::request_json_status(&ctx.url, Method::DELETE, &path, None).await; + ctx.log_response(t, if result.is_ok() { "OK" } else { "ERR" }); + let (status, answer) = result?; + report_change(ctx, status, &answer, &format!("API key '{key_id}' deleted")); } Some(("deactivate", sub)) => { let key_id = sub.get_one::("key_id").unwrap(); let path = key_path(key_id, "/deactivate"); let t = ctx.log_request("POST", &path); - let result = keys_post(&ctx.url, &path, None).await; - ctx.log_response(t, if result.is_ok() { "200 OK" } else { "ERR" }); - output::success(&format!("API key '{}' deactivated", key_id)); - output::print_item(&result?, ctx.format); + let result = crate::api::request_json_status(&ctx.url, Method::POST, &path, None).await; + ctx.log_response(t, if result.is_ok() { "OK" } else { "ERR" }); + let (status, answer) = result?; + report_change( + ctx, + status, + &answer, + &format!("API key '{key_id}' deactivated"), + ); } Some(("rotate", sub)) => { diff --git a/src/commands/memory.rs b/src/commands/memory.rs index 42495d7..b9c4262 100644 --- a/src/commands/memory.rs +++ b/src/commands/memory.rs @@ -4,7 +4,7 @@ use anyhow::Result; use clap::ArgMatches; use dakera_client::memory::{ BatchMemoryFilter, BatchRecallRequest, BatchStoreMemoryItem, BatchStoreMemoryRequest, - ConsolidateRequest, MemoryType, RecallRequest, StoreMemoryRequest, UpdateImportanceRequest, + MemoryType, RecallRequest, StoreMemoryRequest, UpdateImportanceRequest, }; use dakera_client::reqwest::Method; use dakera_client::{DakeraClient, HybridSearchRequest}; @@ -115,6 +115,19 @@ pub struct ContextRow { pub document: Option, } +/// A memory of a recall answer's `associated_memories` (knowledge-graph +/// neighbours of the results) or `instructions` (the agent's standing +/// instructions that fit the query), Dakera v0.12.4. +#[derive(Debug, Serialize)] +pub struct ExtraRow { + /// Memory id. + pub id: String, + /// Memory content (stored data, shown as is). + pub content: String, + /// The score the server gave it (not comparable with the ranked scores). + pub score: f32, +} + /// What `dk memory recall` shows: at most `--top-k` ranked results, and the /// neighbourhood context apart from them. #[derive(Debug, Serialize)] @@ -123,6 +136,19 @@ pub struct RecallView { pub memories: Vec, /// The neighbourhood context of those results. pub context_memories: Vec, + /// Knowledge-graph neighbours of the results (shared entities, + /// similarity, explicit links), not ranked: on by default since Dakera + /// v0.12.4 (`--include-associated=false` turns them off). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub associated_memories: Vec, + /// The agent's standing instructions that best fit the query (memories + /// tagged `dakera-instruction`, at most 3; Dakera v0.12.4). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub instructions: Vec, + /// Parts of the recall the server could not serve, each with why + /// (Dakera v0.12.4). Printed as warnings; empty when it is complete. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub degraded: Vec, /// Context items an older server appended to `memories` that were left /// out (under `--neighborhood separate` / `off`). Not printed in JSON. #[serde(skip)] @@ -137,7 +163,7 @@ pub fn recall_body( mode: &str, include_summaries: Option, ) -> Result { - let mut body = serde_json::to_value(request)?; + let mut body = api::body_of(request)?; body["neighborhood_mode"] = serde_json::json!(mode); if mode == "off" { body["neighborhood"] = serde_json::json!(false); @@ -148,6 +174,33 @@ pub fn recall_body( Ok(body) } +/// `--include-associated[=BOOL]`, sent only when given: the server's default +/// (on since Dakera v0.12.4) applies otherwise. +pub fn set_include_associated(body: &mut serde_json::Value, include: Option) { + if let Some(include) = include { + body["include_associated"] = serde_json::json!(include); + } +} + +/// The memories of `answer[key]` (`associated_memories`, `instructions`). +fn extra_rows(answer: &serde_json::Value, key: &str) -> Result> { + let empty = Vec::new(); + answer + .get(key) + .and_then(|v| v.as_array()) + .unwrap_or(&empty) + .iter() + .map(|item| { + let m = recalled(item)?; + Ok(ExtraRow { + id: m.id, + content: m.content, + score: m.score, + }) + }) + .collect() +} + /// One recall result as a row, read with the SDK's result parser (which /// takes the server's `{memory, score, ...}` items and flat ones). fn recalled(item: &serde_json::Value) -> Result { @@ -239,9 +292,22 @@ pub fn shape_recall(answer: &serde_json::Value, top_k: usize, mode: &str) -> Res document: super::document::citation(item), }); } + let degraded = answer + .get("degraded") + .and_then(|v| v.as_array()) + .map(|items| { + items + .iter() + .filter_map(|d| d.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); Ok(RecallView { memories, context_memories, + associated_memories: extra_rows(answer, "associated_memories")?, + instructions: extra_rows(answer, "instructions")?, + degraded, omitted, }) } @@ -253,6 +319,14 @@ pub fn shape_recall(answer: &serde_json::Value, top_k: usize, mode: &str) -> Res /// stdout; the notes go to stderr there. pub fn print_recall(view: &RecallView, format: crate::OutputFormat) { let table = matches!(format, crate::OutputFormat::Table); + for part in &view.degraded { + let note = format!("the server could not serve part of this recall: {part}"); + if table { + output::warning(¬e); + } else { + eprintln!("warning: {note}"); + } + } if view.omitted > 0 { let note = format!( "{} neighbourhood context item(s) left out: this server predates neighborhood_mode \ @@ -297,6 +371,18 @@ pub fn print_recall(view: &RecallView, format: crate::OutputFormat) { .collect(); output::print_data(&rows, format); } + print_extras( + &view.instructions, + "Standing instructions: rules the user set earlier that fit this query (memories tagged \ + dakera-instruction)", + format, + ); + print_extras( + &view.associated_memories, + "Associated: knowledge-graph neighbours of the results (not ranked, not counted in \ + --top-k)", + format, + ); if view.context_memories.is_empty() { return; } @@ -331,6 +417,83 @@ pub fn print_recall(view: &RecallView, format: crate::OutputFormat) { output::print_data(&rows, format); } +/// A titled section of extra recall memories (table output only). +fn print_extras(rows: &[ExtraRow], title: &str, format: crate::OutputFormat) { + if rows.is_empty() { + return; + } + println!(); + output::info(&format!("{title}: {}", rows.len())); + output::print_data(rows, format); +} + +/// Tell the user something about a successful answer without breaking JSON +/// output: a warning line in the table output, `warning: ...` on stderr +/// otherwise. +pub fn note(format: crate::OutputFormat, message: &str) { + if matches!(format, crate::OutputFormat::Table) { + output::warning(message); + } else { + eprintln!("warning: {message}"); + } +} + +/// What to say about a stored text the server read as a request to forget +/// (`forget_request`, Dakera v0.12.4): how to act on it. +pub fn forget_request_note(agent_id: &str, intent: &serde_json::Value) -> Option { + if !api::bool_of(intent, "is_request").unwrap_or(false) { + return None; + } + let probability = intent + .get("probability") + .and_then(|v| v.as_f64()) + .unwrap_or(f64::NAN); + Some(format!( + "this text reads as a request to forget something said (probability {probability:.2}). \ + To forget what it means: dk memory forget {agent_id} --request '' (unless the \ + server runs DAKERA_FORGET_REQUESTS=apply, which does it itself, restorably)" + )) +} + +/// The body of `POST /v1/memory/consolidate` from `dk memory consolidate`'s +/// arguments: the server reads `memory_ids`, `threshold` and `target_type` +/// only. `--type` and `--dry-run` were sent before 0.8.3 and the server +/// ignored them: a "dry run" merged. +pub fn consolidate_body(agent_id: &str, sub: &ArgMatches) -> Result { + if sub.get_flag("dry-run") { + return Err(input_error( + "the server has no consolidation preview, so --dry-run is refused (before dk 0.8.3 \ + it was sent, ignored by the server, and the memories were merged). Use --ids to \ + choose the memories to merge", + )); + } + if sub.get_one::("type").is_some() { + return Err(input_error( + "--type is refused: the server never filtered consolidation by type (it merges \ + memories of one type); --target-type sets the consolidated memory's type", + )); + } + let mut body = serde_json::json!({ "agent_id": agent_id }); + if let Some(ids) = sub.get_many::("ids") { + let ids: Vec<&str> = ids.map(|s| s.trim()).filter(|s| !s.is_empty()).collect(); + if !ids.is_empty() { + body["memory_ids"] = serde_json::json!(ids); + } + } + if let Some(threshold) = sub.get_one::("threshold") { + if !(*threshold > 0.0 && *threshold <= 1.0) { + return Err(input_error(format!( + "--threshold is a cosine similarity in (0, 1], got {threshold}" + ))); + } + body["threshold"] = serde_json::json!(threshold); + } + if let Some(t) = sub.get_one::("target-type") { + body["target_type"] = serde_json::json!(t); + } + Ok(body) +} + fn memory_type_to_string(mt: &MemoryType) -> String { match mt { MemoryType::Episodic => "episodic".to_string(), @@ -443,6 +606,117 @@ fn batch_forget_filter(sub: &ArgMatches) -> serde_json::Value { filter } +/// The stored memories of a batch store answer. +fn stored_rows(answer: &serde_json::Value) -> Vec { + answer + .get("stored") + .and_then(|v| v.as_array()) + .into_iter() + .flatten() + .map(|m| StoredRow { + id: api::str_of(m, "id").unwrap_or("").to_string(), + content: api::str_of(m, "content").unwrap_or("").to_string(), + importance: m.get("importance").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32, + }) + .collect() +} + +/// What to say about a batch store answer (Dakera v0.12.4): items not stored +/// as near-duplicates (`duplicates`, under the namespace's `dedup_on_store`) +/// and stored texts that read as requests to forget (`forget_requests`). +pub fn batch_store_notes(agent_id: &str, answer: &serde_json::Value) -> Vec { + let mut notes = Vec::new(); + let empty = Vec::new(); + let list = |key: &str| { + answer + .get(key) + .and_then(|v| v.as_array()) + .unwrap_or(&empty) + .clone() + }; + let duplicates = list("duplicates"); + if !duplicates.is_empty() { + let items: Vec = duplicates + .iter() + .map(|d| { + format!( + "#{} = {}", + api::u64_of(d, "index") + .map(|i| i.to_string()) + .unwrap_or_default(), + api::str_of(d, "existing_id").unwrap_or("?") + ) + }) + .collect(); + notes.push(format!( + "{} item(s) not stored: the namespace deduplicates on store and each is a \ + near-duplicate of a stored memory (item = existing id): {}", + duplicates.len(), + items.join(", ") + )); + } + let requests = list("forget_requests"); + let ids: Vec<&str> = requests + .iter() + .filter(|r| api::bool_of(r, "is_request").unwrap_or(true)) + .filter_map(|r| api::str_of(r, "id")) + .collect(); + if !ids.is_empty() { + notes.push(format!( + "{} stored memory(ies) read as a request to forget something said ({}). To forget \ + what one means: dk memory forget {agent_id} --request '' (unless the \ + server runs DAKERA_FORGET_REQUESTS=apply, which does it itself)", + ids.len(), + ids.join(", ") + )); + } + notes +} + +/// The entities of a `POST /v1/memories/extract` answer. +fn entity_rows(answer: &serde_json::Value) -> Vec { + answer + .get("entities") + .and_then(|v| v.as_array()) + .into_iter() + .flatten() + .map(|e| EntityRow { + entity_type: api::str_of(e, "entity_type").unwrap_or("").to_string(), + value: api::str_of(e, "value").unwrap_or("").to_string(), + score: e.get("score").and_then(|v| v.as_f64()).unwrap_or(0.0), + }) + .collect() +} + +/// `dk memory get --projection-days`: the memory, then its projected +/// importance per day (table), or the whole answer (JSON). +fn print_projection(answer: &serde_json::Value, format: crate::OutputFormat) { + if !matches!(format, crate::OutputFormat::Table) { + output::print_item(answer, format); + return; + } + let mut memory = answer.clone(); + let projection = memory + .as_object_mut() + .and_then(|m| m.remove("strength_projection")) + .unwrap_or(serde_json::Value::Null); + output::print_item(&memory, format); + let points: Vec = projection + .as_array() + .cloned() + .unwrap_or_default() + .into_iter() + .map(|p| serde_json::json!({ "day": p.get("day"), "importance": p.get("importance") })) + .collect(); + if points.is_empty() { + output::info("The server sent no strength projection (it needs v0.12.4)"); + return; + } + println!(); + output::info("Projected importance if nothing touches the memory (day 0 = today)"); + output::print_data(&points, format); +} + pub async fn execute(ctx: &Context, matches: &ArgMatches) -> Result<()> { let client = DakeraClient::new(&ctx.url)?; @@ -471,20 +745,38 @@ pub async fn execute(ctx: &Context, matches: &ArgMatches) -> Result<()> { request = request.with_attachment_ref(reference); } - let t = ctx.log_request("POST", "/v1/memory/store"); - let response = client.store_memory(request).await; - match &response { - Ok(_) => ctx.log_response(t, "200 OK"), - Err(_) => ctx.log_response(t, "ERR"), + // Raw request: the answer's `forget_request` (v0.12.4) and + // `session_state` are newer than the SDK's store response. + let body = api::body_of(&request)?; + let answer = + api::request_json_logged(ctx, Method::POST, "/v1/memory/store", Some(&body)) + .await?; + let id = answer + .pointer("/memory/id") + .or_else(|| answer.get("memory_id")) + .and_then(|v| v.as_str()) + .unwrap_or("?"); + + // The store answer carries no namespace; the memory lives in the + // agent's memory namespace. + if matches!(ctx.format, crate::OutputFormat::Table) { + output::success(&format!("Memory stored (id: {id}, agent: {agent_id})")); + } else { + output::print_item(&answer, ctx.format); + } + if api::str_of(&answer, "session_state") == Some("ended") { + note( + ctx.format, + "the session has ended (the memory was stored all the same); start a new \ + session for what follows", + ); + } + if let Some(text) = answer + .get("forget_request") + .and_then(|i| forget_request_note(agent_id, i)) + { + note(ctx.format, &text); } - let response = response?; - - // The store answer carries no namespace (the SDK's `namespace` is a - // placeholder); the memory lives in the agent's memory namespace. - output::success(&format!( - "Memory stored (id: {}, agent: {agent_id})", - response.memory_id - )); } Some(("recall", sub_matches)) => { @@ -504,7 +796,11 @@ pub async fn execute(ctx: &Context, matches: &ArgMatches) -> Result<()> { .map(String::as_str) .unwrap_or("separate"); let include_summaries = sub_matches.get_one::("include-summaries").copied(); - let body = recall_body(&request, mode, include_summaries)?; + let mut body = recall_body(&request, mode, include_summaries)?; + set_include_associated( + &mut body, + sub_matches.get_one::("include-associated").copied(), + ); // Raw request: the `neighborhood_mode` / `include_summaries` fields // and the `context_memories` / `matched_text` answer fields are newer @@ -520,6 +816,16 @@ pub async fn execute(ctx: &Context, matches: &ArgMatches) -> Result<()> { let agent_id = sub_matches.get_one::("agent_id").unwrap(); let memory_id = sub_matches.get_one::("memory_id").unwrap(); + if let Some(days) = sub_matches.get_one::("projection-days") { + let path = format!( + "/v1/memory/get/{}?agent_id={}&projection_days={days}", + api::segment(memory_id), + api::segment(agent_id) + ); + let answer = api::request_json_logged(ctx, Method::GET, &path, None).await?; + print_projection(&answer, ctx.format); + return Ok(()); + } let t = ctx.log_request("GET", &format!("/v1/memory/get/{}", memory_id)); let memory = client.get_memory(agent_id, memory_id).await; match &memory { @@ -549,26 +855,8 @@ pub async fn execute(ctx: &Context, matches: &ArgMatches) -> Result<()> { output::success(&format!("Memory '{id}' updated")); } - Some(("forget", sub_matches)) => { - let agent_id = sub_matches.get_one::("agent_id").unwrap(); - let memory_id = sub_matches.get_one::("memory_id").unwrap(); - - let request = dakera_client::memory::ForgetRequest::by_ids( - agent_id.clone(), - vec![memory_id.clone()], - ); - let t = ctx.log_request("POST", "/v1/memory/forget"); - let response = client.forget(request).await; - match &response { - Ok(_) => ctx.log_response(t, "200 OK"), - Err(_) => ctx.log_response(t, "ERR"), - } - let response = response?; - - output::success(&format!( - "Deleted {} memory (id: {})", - response.deleted_count, memory_id - )); + Some((name @ ("forget" | "forget-detect" | "trash" | "restore"), sub_matches)) => { + super::forget::execute(ctx, name, sub_matches).await?; } Some(("search", sub_matches)) => { @@ -633,28 +921,24 @@ pub async fn execute(ctx: &Context, matches: &ArgMatches) -> Result<()> { let mut request = BatchStoreMemoryRequest::new(agent_id.clone(), items); request.lang = sub_matches.get_one::("lang").cloned(); - let t = ctx.log_request("POST", "/v1/memories/store/batch"); - let response = client.store_memories_batch(request).await; - match &response { - Ok(_) => ctx.log_response(t, "200 OK"), - Err(_) => ctx.log_response(t, "ERR"), + // Raw request: `duplicates` and `forget_requests` (v0.12.4) are + // newer than the SDK's batch response. + let body = api::body_of(&request)?; + let path = "/v1/memories/store/batch"; + let answer = api::request_json_logged(ctx, Method::POST, path, Some(&body)).await?; + let table = matches!(ctx.format, crate::OutputFormat::Table); + if table { + output::success(&format!( + "Stored {} memories (embedding: {} ms)", + api::u64_of(&answer, "stored_count").unwrap_or(0), + api::u64_of(&answer, "total_embedding_time_ms").unwrap_or(0) + )); } - let response = response?; - - output::success(&format!( - "Stored {} memories (embedding: {} ms)", - response.stored_count, response.total_embedding_time_ms - )); - let rows: Vec = response - .stored - .into_iter() - .map(|m| StoredRow { - id: m.id, - content: m.content, - importance: m.importance, - }) - .collect(); + let rows = stored_rows(&answer); output::print_data(&rows, ctx.format); + for text in batch_store_notes(agent_id, &answer) { + note(ctx.format, &text); + } } Some(("extract", sub_matches)) => { @@ -667,29 +951,38 @@ pub async fn execute(ctx: &Context, matches: &ArgMatches) -> Result<()> { }); let lang = sub_matches.get_one::("lang").map(String::as_str); - let t = ctx.log_request("POST", "/v1/memories/extract"); - let response = client - .extract_entities_with_lang(text, entity_types, lang) - .await; - match &response { - Ok(_) => ctx.log_response(t, "200 OK"), - Err(_) => ctx.log_response(t, "ERR"), + // Raw request: the answer's `extractor` and `warnings` (v0.12.4) + // are newer than the SDK's extract response. + let mut body = serde_json::json!({ "content": text }); + if let Some(types) = entity_types { + body["entity_types"] = serde_json::json!(types); } - let response = response?; - - if response.entities.is_empty() { + if let Some(lang) = lang { + body["lang"] = serde_json::json!(lang); + } + let path = "/v1/memories/extract"; + let answer = api::request_json_logged(ctx, Method::POST, path, Some(&body)).await?; + for warning in answer + .get("warnings") + .and_then(|v| v.as_array()) + .into_iter() + .flatten() + .filter_map(|w| w.as_str()) + { + note(ctx.format, warning); + } + let rows = entity_rows(&answer); + if rows.is_empty() { output::info("No entities found"); } else { - output::info(&format!("Found {} entities", response.entities.len())); - let rows: Vec = response - .entities - .into_iter() - .map(|e| EntityRow { - entity_type: e.entity_type, - value: e.value, - score: e.score, - }) - .collect(); + if matches!(ctx.format, crate::OutputFormat::Table) { + let by = match api::str_of(&answer, "extractor") { + Some("gliner") => " (GLiNER model and rules)", + Some("rule_based") => " (rules only: dates, URLs, emails, ...)", + _ => "", + }; + output::info(&format!("Found {} entities{by}", rows.len())); + } output::print_data(&rows, ctx.format); } } @@ -721,38 +1014,26 @@ pub async fn execute(ctx: &Context, matches: &ArgMatches) -> Result<()> { Some(("consolidate", sub_matches)) => { let agent_id = sub_matches.get_one::("agent_id").unwrap(); - let memory_type = sub_matches.get_one::("type").cloned(); - let threshold = sub_matches.get_one::("threshold").copied(); - let dry_run = sub_matches.get_flag("dry-run"); - - let request = ConsolidateRequest { - memory_type, - threshold, - dry_run, - ..Default::default() - }; - - let t = ctx.log_request("POST", "/v1/memory/consolidate"); - let response = client.consolidate(agent_id, request).await; - match &response { - Ok(_) => ctx.log_response(t, "200 OK"), - Err(_) => ctx.log_response(t, "ERR"), - } - let response = response?; - - if dry_run { - output::info(&format!( - "[dry-run] Would consolidate {} memories, removing {}", - response.consolidated_count, response.removed_count - )); - } else { + let body = consolidate_body(agent_id, sub_matches)?; + let path = "/v1/memory/consolidate"; + let answer = api::request_json_logged(ctx, Method::POST, path, Some(&body)).await?; + if matches!(ctx.format, crate::OutputFormat::Table) { + let sources = answer + .get("source_memory_ids") + .and_then(|v| v.as_array()) + .map(|a| a.len()) + .unwrap_or(0); + let merged_into = answer + .pointer("/consolidated_memory/id") + .and_then(|v| v.as_str()) + .unwrap_or("?"); output::success(&format!( - "Consolidated {} memories, removed {} duplicates, created {} new memories", - response.consolidated_count, - response.removed_count, - response.new_memories.len() + "Merged {sources} memories into '{merged_into}' ({} removed; `dk memory trash \ + {agent_id}` lists them while they can be restored)", + api::u64_of(&answer, "memories_removed").unwrap_or(0) )); } + output::print_item(&answer, ctx.format); } Some(("feedback", sub_matches)) => { diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 691af06..5250d89 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -8,6 +8,7 @@ pub mod capabilities; pub mod completion; pub mod config; pub mod document; +pub mod forget; pub mod health; pub mod index; pub mod init; diff --git a/src/commands/namespace.rs b/src/commands/namespace.rs index 46e538d..65224af 100644 --- a/src/commands/namespace.rs +++ b/src/commands/namespace.rs @@ -2,6 +2,7 @@ use anyhow::Result; use clap::ArgMatches; +use dakera_client::reqwest::Method; use dakera_client::DakeraClient; use serde::Serialize; @@ -137,14 +138,21 @@ pub async fn execute(ctx: &Context, matches: &ArgMatches) -> Result<()> { match sub_matches.subcommand() { Some(("get", get_matches)) => { let ns = get_matches.get_one::("namespace").unwrap(); - let path = format!("/v1/namespaces/{}/memory_policy", ns); - let t = ctx.log_request("GET", &path); - let policy = client.get_memory_policy(ns).await; - match &policy { - Ok(_) => ctx.log_response(t, "200 OK"), - Err(_) => ctx.log_response(t, "ERR"), + // Raw request: `stored` (v0.12.4) is newer than the SDK's + // policy type. + let path = format!("/v1/namespaces/{}/memory_policy", api::segment(ns)); + let policy = api::request_json_logged(ctx, Method::GET, &path, None).await?; + if api::bool_of(&policy, "stored") == Some(false) { + crate::commands::memory::note( + ctx.format, + &format!( + "namespace '{ns}' has no stored policy: this is what its memories \ + get (no TTL by type, the server's decay curve). `dk namespace \ + policy set` stores one, starting from these values" + ), + ); } - output::print_item(&policy?, ctx.format); + output::print_item(&policy, ctx.format); } Some(("set", set_matches)) => { diff --git a/src/commands/session.rs b/src/commands/session.rs index bf5f6e3..1353e8d 100644 --- a/src/commands/session.rs +++ b/src/commands/session.rs @@ -37,6 +37,19 @@ pub struct SessionMemoryRow { pub truncated: Option, } +/// The body of `POST /v1/sessions/{id}/end`: the summary and +/// `auto_summarize` (honoured since Dakera v0.12.4), each sent only when given. +pub fn end_body(summary: Option<&str>, auto_summarize: Option) -> serde_json::Value { + let mut body = serde_json::json!({}); + if let Some(summary) = summary { + body["summary"] = serde_json::json!(summary); + } + if let Some(auto) = auto_summarize { + body["auto_summarize"] = serde_json::json!(auto); + } + body +} + pub async fn execute(ctx: &Ctx, matches: &ArgMatches) -> Result<()> { let client = DakeraClient::new(&ctx.url)?; @@ -108,18 +121,34 @@ pub async fn execute(ctx: &Ctx, matches: &ArgMatches) -> Result<()> { Some(("end", sub_matches)) => { let session_id = sub_matches.get_one::("session_id").unwrap(); - let summary = sub_matches.get_one::("summary").cloned(); - - let t = ctx.log_request("POST", &format!("/v1/sessions/{}/end", session_id)); - let response = client.end_session(session_id, summary).await; - match &response { - Ok(_) => ctx.log_response(t, "200 OK"), - Err(_) => ctx.log_response(t, "ERR"), + let body = end_body( + sub_matches.get_one::("summary").map(String::as_str), + sub_matches.get_one::("auto-summarize").copied(), + ); + // Raw request: `auto_summarize` and the answer's `summary_pending` + // (v0.12.4) are newer than the SDK's session types. + let path = format!("/v1/sessions/{}/end", crate::api::segment(session_id)); + let answer = crate::api::request_json_logged( + ctx, + dakera_client::reqwest::Method::POST, + &path, + Some(&body), + ) + .await?; + let ended = crate::api::ptr_str(&answer, "/session/id").unwrap_or(session_id); + if matches!(ctx.format, crate::OutputFormat::Table) { + output::success(&format!("Session '{ended}' ended")); + } + output::print_item(&answer, ctx.format); + if let Some(why) = crate::api::str_of(&answer, "summary_pending") { + super::memory::note( + ctx.format, + &format!( + "the session's summary memory is not stored yet ({why}); the server \ + retries until it is, after a restart too" + ), + ); } - let response = response?; - - output::success(&format!("Session '{}' ended", response.session.id)); - output::print_item(&response, ctx.format); } Some(("get", sub_matches)) => { @@ -266,6 +295,7 @@ pub async fn execute(ctx: &Ctx, matches: &ArgMatches) -> Result<()> { #[cfg(test)] mod tests { + use super::end_body; use crate::cli::build_session_command; #[test] @@ -288,6 +318,25 @@ mod tests { ); } + #[test] + fn session_end_sends_auto_summarize_only_when_given() { + let m = build_session_command() + .try_get_matches_from(["session", "end", "s1", "--auto-summarize=false"]) + .unwrap(); + let sub = m.subcommand_matches("end").unwrap(); + assert_eq!(sub.get_one::("auto-summarize"), Some(&false)); + assert_eq!( + end_body(Some("done"), Some(false)), + serde_json::json!({"summary": "done", "auto_summarize": false}) + ); + assert_eq!(end_body(None, None), serde_json::json!({})); + let m = build_session_command() + .try_get_matches_from(["session", "end", "s1", "--auto-summarize"]) + .unwrap(); + let sub = m.subcommand_matches("end").unwrap(); + assert_eq!(sub.get_one::("auto-summarize"), Some(&true)); + } + #[test] fn session_list_limit_defaults_to_50() { let m = build_session_command() diff --git a/tests/integration.rs b/tests/integration.rs index 9e11a45..ed63079 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -404,7 +404,7 @@ fn memory_forget_success_reports_deleted_count() { ]) .assert() .success() - .stdout(predicate::str::contains("Deleted 1 memory")); + .stdout(predicate::str::contains("Forgot 1 memory of 'test-agent'")); } // --------------------------------------------------------------------------- @@ -506,17 +506,11 @@ fn memory_update_success_reports_memory_id() { // --------------------------------------------------------------------------- #[test] -fn memory_consolidate_dry_run_shows_preview() { +fn memory_consolidate_refuses_dry_run_the_server_never_had() { let server = MockServer::start(); - server.mock(|when, then| { + let m = server.mock(|when, then| { when.method(POST).path("/v1/memory/consolidate"); - then.status(200) - .header("Content-Type", "application/json") - .json_body(json!({ - "consolidated_count": 5, - "removed_count": 3, - "new_memories": [] - })); + then.status(200).json_body(json!({})); }); dk().args([ @@ -528,8 +522,38 @@ fn memory_consolidate_dry_run_shows_preview() { "--dry-run", ]) .assert() + .code(5) + .stderr(predicate::str::contains("no consolidation preview")); + m.assert_calls(0); +} + +#[test] +fn memory_consolidate_sends_the_ids_and_reports_the_merge() { + let server = MockServer::start(); + let m = server.mock(|when, then| { + when.method(POST) + .path("/v1/memory/consolidate") + .json_body(json!({"agent_id": "test-agent", "memory_ids": ["m1", "m2"]})); + then.status(200).json_body(json!({ + "consolidated_memory": {"id": "m9", "content": "both"}, + "source_memory_ids": ["m1", "m2"], + "memories_removed": 2 + })); + }); + + dk().args([ + "--url", + &server.base_url(), + "memory", + "consolidate", + "test-agent", + "--ids", + "m1,m2", + ]) + .assert() .success() - .stdout(predicate::str::contains("[dry-run]")); + .stdout(predicate::str::contains("Merged 2 memories into 'm9'")); + m.assert(); } // --------------------------------------------------------------------------- @@ -1334,7 +1358,7 @@ fn container_memory_forget() { .args(["memory", "forget", "integration-agent", id.trim()]) .assert() .success() - .stdout(predicate::str::contains("Deleted")); + .stdout(predicate::str::contains("Forgot 1 memory")); } #[test] @@ -1432,6 +1456,8 @@ fn container_memory_consolidate() { .success(); } + // The server has no consolidation preview: `--dry-run` is refused before + // anything is sent (it used to be sent, ignored, and the memories merged). container_dk(&url, &key) .args([ "memory", @@ -1440,7 +1466,7 @@ fn container_memory_consolidate() { "--dry-run", ]) .assert() - .success(); + .code(5); } #[test] diff --git a/tests/route_audit.rs b/tests/route_audit.rs index 4a0c43b..66b44e6 100644 --- a/tests/route_audit.rs +++ b/tests/route_audit.rs @@ -1,5 +1,5 @@ //! Every server path `dk` calls must be a route of the Dakera v0.12 server -//! (v0.12.0 plus the routes v0.12.2 and v0.12.3 add). +//! (v0.12.0 plus the routes v0.12.2, v0.12.3 and v0.12.4 add). //! //! The route list (`tests/server_routes_v0.12.txt`) comes from the server's //! router (`crates/api/src/lib.rs`); the paths `dk` calls are the string @@ -98,6 +98,20 @@ fn every_route_dk_calls_exists_on_the_v012_server() { ); } +/// The tenant routes of Dakera Cloud (`/v1/tenants/*`, v0.12.4) are not +/// `dk`'s to call: they are left out of the route list, and no command may +/// name them. +#[test] +fn no_command_calls_a_tenant_route() { + for (file, text) in sources() { + let production = text.split("#[cfg(test)]").next().unwrap_or(""); + assert!( + !production.contains("/v1/tenants"), + "{file} names a Dakera Cloud tenant route" + ); + } +} + #[test] fn normalize_turns_parameters_into_stars() { assert_eq!( diff --git a/tests/server_routes_v0.12.txt b/tests/server_routes_v0.12.txt index c2f118f..ecf8bee 100644 --- a/tests/server_routes_v0.12.txt +++ b/tests/server_routes_v0.12.txt @@ -1,4 +1,4 @@ -# Routes of the Dakera v0.12.3 server (v0.12.0 plus the routes v0.12.2 and v0.12.3 add) (crates/api/src/lib.rs + keys_router + attachment routes), params as *. +# Routes of the Dakera v0.12.4 server (v0.12.0 plus the routes v0.12.2, v0.12.3 and v0.12.4 add; the Dakera Cloud tenant routes /v1/tenants/* are left out) (crates/api/src/lib.rs + keys_router + attachment routes), params as *. # Admin routes are served under /admin and /v1/admin. Regenerate when the server's router changes. DELETE /admin/backups/* DELETE /admin/keys/* @@ -236,6 +236,7 @@ POST /v1/memories/store/batch POST /v1/memory/consolidate POST /v1/memory/feedback POST /v1/memory/forget +POST /v1/memory/forget/detect POST /v1/memory/importance POST /v1/memory/recall POST /v1/memory/search diff --git a/tests/v0124.rs b/tests/v0124.rs new file mode 100644 index 0000000..b9097eb --- /dev/null +++ b/tests/v0124.rs @@ -0,0 +1,665 @@ +//! Integration tests for Dakera server v0.12.4 support. +//! +//! Like `v0123.rs`, each test starts a local [`httpmock`] server and runs the +//! compiled `dk` binary against it; no Dakera server is needed. The request +//! bodies and answers follow the engine's request and response types. +//! +//! Covered: `memory forget` by description (`--about`) and by request +//! (`--request`, with the server's reading of it), `--restorable`, +//! `--dry-run`; `memory forget-detect`; `memory trash` / `restore`; the +//! `forget_request` of a store and the `duplicates` / `forget_requests` of a +//! batch store; recall's `instructions`, `associated_memories`, `degraded` and +//! `--include-associated`; `memory get --projection-days`; `session end +//! --auto-summarize` and `summary_pending`; `memory extract`'s `warnings`; a +//! `202` key revocation; a `503` forget refused for want of the relevance +//! model; `namespace policy get` of a namespace without a stored policy. + +use assert_cmd::Command; +use httpmock::prelude::*; +use predicates::prelude::*; +use serde_json::{json, Value}; + +fn dk() -> Command { + let mut cmd = Command::cargo_bin("dk").expect("dk binary not found — run `cargo build` first"); + cmd.env_remove("DAKERA_API_KEY"); + cmd +} + +fn item(id: &str, content: &str, score: f64) -> Value { + json!({ + "memory": {"id": id, "content": content, "memory_type": "episodic", "importance": 0.5}, + "score": score + }) +} + +// --------------------------------------------------------------------------- +// memory forget +// --------------------------------------------------------------------------- + +#[test] +fn forget_by_request_sends_it_and_lists_what_was_forgotten() { + let server = MockServer::start(); + let m = server.mock(|when, then| { + when.method(POST) + .path("/v1/memory/forget") + .json_body(json!({ + "agent_id": "bot", + "request": "Please forget what I told you about my ex", + "restorable": true + })); + then.status(200).json_body(json!({ + "deleted_count": 2, + "forgotten_ids": ["m1", "m2"], + "intent": {"probability": 0.97, "threshold": 0.5, "is_request": true} + })); + }); + dk().args([ + "--url", + &server.base_url(), + "memory", + "forget", + "bot", + "--request", + "Please forget what I told you about my ex", + "--restorable", + ]) + .assert() + .success() + .stdout(predicate::str::contains("reads as a request to forget")) + .stdout(predicate::str::contains("Forgot 2 memories of 'bot'")) + .stdout(predicate::str::contains("dk memory trash bot")) + .stdout(predicate::str::contains("m2")); + m.assert(); +} + +#[test] +fn forget_by_request_that_is_not_one_forgets_nothing() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/v1/memory/forget"); + then.status(200).json_body(json!({ + "deleted_count": 0, + "forgotten_ids": [], + "intent": {"probability": 0.03, "threshold": 0.5, "is_request": false} + })); + }); + dk().args([ + "--url", + &server.base_url(), + "memory", + "forget", + "bot", + "--request", + "I always forget my keys", + ]) + .assert() + .success() + .stdout(predicate::str::contains("does not read as a request")) + .stdout(predicate::str::contains("Nothing was forgotten")); +} + +#[test] +fn forget_by_description_dry_run_lists_without_deleting() { + let server = MockServer::start(); + let m = server.mock(|when, then| { + when.method(POST) + .path("/v1/memory/forget") + .json_body(json!({ + "agent_id": "bot", + "about": "my home address", + "min_score": 0.6, + "limit": 3, + "dry_run": true + })); + then.status(200) + .json_body(json!({"deleted_count": 0, "forgotten_ids": ["a1"]})); + }); + dk().args([ + "--url", + &server.base_url(), + "memory", + "forget", + "bot", + "--about", + "my home address", + "--min-score", + "0.6", + "--limit", + "3", + "--dry-run", + ]) + .assert() + .success() + .stdout(predicate::str::contains("[dry-run] Would forget 1 memory")) + .stdout(predicate::str::contains("a1")); + m.assert(); +} + +#[test] +fn forget_json_prints_the_answer() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/v1/memory/forget"); + then.status(200) + .json_body(json!({"deleted_count": 1, "forgotten_ids": ["a1"]})); + }); + let out = dk() + .args([ + "--url", + &server.base_url(), + "--format", + "json", + "memory", + "forget", + "bot", + "--about", + "sushi", + ]) + .assert() + .success() + .get_output() + .stdout + .clone(); + let parsed: Value = serde_json::from_slice(&out).expect("stdout is one JSON document"); + assert_eq!(parsed["forgotten_ids"], json!(["a1"])); +} + +#[test] +fn forget_without_the_relevance_model_explains_the_503() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/v1/memory/forget"); + then.status(503) + .header("Retry-After", "5") + .json_body(json!({ + "error": "Service unavailable", + "code": "SERVICE_UNAVAILABLE", + "status": 503, + "details": "forget by description needs the relevance model (not loaded); retry" + })); + }); + dk().args([ + "--url", + &server.base_url(), + "memory", + "forget", + "bot", + "--about", + "sushi", + ]) + .assert() + .code(6) + .stderr(predicate::str::contains("forget by memory id meanwhile")) + .stderr(predicate::str::contains("retry in 5s")); +} + +#[test] +fn forget_with_nothing_to_forget_is_refused_before_sending() { + let server = MockServer::start(); + let m = server.mock(|when, then| { + when.method(POST).path("/v1/memory/forget"); + then.status(200).json_body(json!({"deleted_count": 0})); + }); + dk().args(["--url", &server.base_url(), "memory", "forget", "bot"]) + .assert() + .code(5); + m.assert_calls(0); +} + +// --------------------------------------------------------------------------- +// memory forget-detect / trash / restore +// --------------------------------------------------------------------------- + +#[test] +fn forget_detect_reads_each_message() { + let server = MockServer::start(); + let m = server.mock(|when, then| { + when.method(POST) + .path("/v1/memory/forget/detect") + .json_body(json!({"texts": ["Oublie mon adresse", "I always forget my keys"]})); + then.status(200).json_body(json!({ + "model": "multilingual-minilm", + "threshold": 0.5, + "sentence_threshold": 0.7, + "results": [ + {"probability": 0.96, "is_request": true}, + {"probability": 0.04, "is_request": false} + ] + })); + }); + dk().args([ + "--url", + &server.base_url(), + "memory", + "forget-detect", + "Oublie mon adresse", + "I always forget my keys", + ]) + .assert() + .success() + .stdout(predicate::str::contains("1 of 2 message(s)")) + .stdout(predicate::str::contains("0.960")) + .stdout(predicate::str::contains("--request")); + m.assert(); +} + +#[test] +fn trash_lists_and_restore_puts_back() { + let server = MockServer::start(); + let list = server.mock(|when, then| { + when.method(GET) + .path("/v1/memory/deduplicated") + .query_param("agent_id", "bot"); + then.status(200).json_body(json!({ + "agent_id": "bot", + "count": 1, + "memories": [{ + "id": "m1", "kind": "deletion", "survivor_id": "m1", + "deleted_at": 1700000000u64, "restorable_until": 1700600000u64, + "content": "I love sushi" + }] + })); + }); + let restore = server.mock(|when, then| { + when.method(POST) + .path("/v1/memory/deduplicated/m1/restore") + .query_param("agent_id", "bot"); + then.status(200) + .json_body(json!({"id": "m1", "content": "I love sushi"})); + }); + dk().args(["--url", &server.base_url(), "memory", "trash", "bot"]) + .assert() + .success() + .stdout(predicate::str::contains("I love sushi")) + .stdout(predicate::str::contains("deletion")); + dk().args([ + "--url", + &server.base_url(), + "memory", + "restore", + "bot", + "m1", + ]) + .assert() + .success() + .stdout(predicate::str::contains("Memory 'm1' restored")); + list.assert(); + restore.assert(); +} + +// --------------------------------------------------------------------------- +// store and batch store +// --------------------------------------------------------------------------- + +#[test] +fn store_says_when_the_text_is_a_request_to_forget() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/v1/memory/store"); + then.status(200).json_body(json!({ + "memory": {"id": "s1", "content": "Please forget my address"}, + "embedding_time_ms": 3, + "forget_request": {"probability": 0.95, "threshold": 0.5, "is_request": true} + })); + }); + dk().args([ + "--url", + &server.base_url(), + "memory", + "store", + "bot", + "Please forget my address", + ]) + .assert() + .success() + .stdout(predicate::str::contains("Memory stored (id: s1")) + .stdout(predicate::str::contains("dk memory forget bot --request")); +} + +#[test] +fn store_json_prints_the_answer_and_notes_on_stderr() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/v1/memory/store"); + then.status(200).json_body(json!({ + "memory": {"id": "s1", "content": "x"}, + "embedding_time_ms": 3, + "forget_request": {"probability": 0.95, "threshold": 0.5, "is_request": true} + })); + }); + let out = dk() + .args([ + "--url", + &server.base_url(), + "-f", + "json", + "memory", + "store", + "bot", + "x", + ]) + .assert() + .success() + .stderr(predicate::str::contains("request to forget")) + .get_output() + .stdout + .clone(); + let parsed: Value = serde_json::from_slice(&out).expect("stdout is one JSON document"); + assert_eq!(parsed["memory"]["id"], "s1"); +} + +#[test] +fn batch_store_reports_duplicates_and_requests_to_forget() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/v1/memories/store/batch"); + then.status(200).json_body(json!({ + "stored": [{"id": "b1", "content": "Forget my job", "importance": 0.5}], + "stored_count": 1, + "total_embedding_time_ms": 4, + "duplicates": [{"index": 1, "existing_id": "old-7"}], + "forget_requests": [ + {"id": "b1", "probability": 0.9, "threshold": 0.5, "is_request": true} + ] + })); + }); + dk().args([ + "--url", + &server.base_url(), + "memory", + "batch-store", + "bot", + "-c", + "Forget my job", + "-c", + "Lives in Berlin", + ]) + .assert() + .success() + .stdout(predicate::str::contains("Stored 1 memories")) + .stdout(predicate::str::contains("#1 = old-7")) + .stdout(predicate::str::contains("(b1)")); +} + +// --------------------------------------------------------------------------- +// recall +// --------------------------------------------------------------------------- + +#[test] +fn recall_shows_instructions_associated_and_degraded() { + let server = MockServer::start(); + let m = server.mock(|when, then| { + when.method(POST) + .path("/v1/memory/recall") + .json_body(json!({ + "agent_id": "bot", + "query": "library", + "top_k": 5, + "min_importance": 0.0, + "tags": [], + "neighborhood_mode": "separate", + "include_associated": true + })); + then.status(200).json_body(json!({ + "memories": [item("r1", "uses tokio", 0.9)], + "instructions": [item("i1", "Always include the version", 0.8)], + "associated_memories": [item("a1", "tokio is async", 0.4)], + "degraded": ["graph: read failed"] + })); + }); + dk().args([ + "--url", + &server.base_url(), + "memory", + "recall", + "bot", + "library", + "--include-associated", + ]) + .assert() + .success() + .stdout(predicate::str::contains("Standing instructions")) + .stdout(predicate::str::contains("Always include the version")) + .stdout(predicate::str::contains("Associated")) + .stdout(predicate::str::contains("tokio is async")) + .stdout(predicate::str::contains("graph: read failed")); + m.assert(); +} + +#[test] +fn recall_can_turn_associated_memories_off() { + let server = MockServer::start(); + let m = server.mock(|when, then| { + when.method(POST) + .path("/v1/memory/recall") + .json_body_includes(r#"{"include_associated": false}"#); + then.status(200).json_body(json!({"memories": []})); + }); + dk().args([ + "--url", + &server.base_url(), + "memory", + "recall", + "bot", + "q", + "--include-associated=false", + ]) + .assert() + .success(); + m.assert(); +} + +#[test] +fn recall_json_carries_instructions_and_associated() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/v1/memory/recall"); + then.status(200).json_body(json!({ + "memories": [item("r1", "a", 0.9)], + "instructions": [item("i1", "Answer in French", 0.8)], + "associated_memories": [item("a1", "b", 0.4)] + })); + }); + let out = dk() + .args([ + "--url", + &server.base_url(), + "-f", + "json", + "memory", + "recall", + "bot", + "q", + ]) + .assert() + .success() + .get_output() + .stdout + .clone(); + let parsed: Value = serde_json::from_slice(&out).unwrap(); + assert_eq!(parsed["instructions"][0]["id"], "i1"); + assert_eq!(parsed["associated_memories"][0]["id"], "a1"); +} + +// --------------------------------------------------------------------------- +// get, session end, extract +// --------------------------------------------------------------------------- + +#[test] +fn get_with_projection_days_shows_the_projection() { + let server = MockServer::start(); + let m = server.mock(|when, then| { + when.method(GET) + .path("/v1/memory/get/m1") + .query_param("agent_id", "bot") + .query_param("projection_days", "2"); + then.status(200).json_body(json!({ + "id": "m1", "content": "x", "importance": 0.8, + "strength_projection": [ + {"day": 0, "importance": 0.8}, + {"day": 1, "importance": 0.75}, + {"day": 2, "importance": 0.7} + ] + })); + }); + dk().args([ + "--url", + &server.base_url(), + "memory", + "get", + "bot", + "m1", + "--projection-days", + "2", + ]) + .assert() + .success() + .stdout(predicate::str::contains("Projected importance")) + .stdout(predicate::str::contains("0.75")); + m.assert(); +} + +#[test] +fn session_end_sends_auto_summarize_and_warns_of_a_pending_summary() { + let server = MockServer::start(); + let m = server.mock(|when, then| { + when.method(POST) + .path("/v1/sessions/s1/end") + .json_body(json!({"auto_summarize": true})); + then.status(200).json_body(json!({ + "session": {"id": "s1", "agent_id": "bot"}, + "memory_count": 5, + "summary_pending": "the store refused the write" + })); + }); + dk().args([ + "--url", + &server.base_url(), + "session", + "end", + "s1", + "--auto-summarize", + ]) + .assert() + .success() + .stdout(predicate::str::contains("Session 's1' ended")) + .stdout(predicate::str::contains("summary memory is not stored yet")); + m.assert(); +} + +#[test] +fn extract_shows_the_extractor_and_its_warnings() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST) + .path("/v1/memories/extract") + .json_body(json!({"content": "Mail ana@example.com"})); + then.status(200).json_body(json!({ + "entities": [{"entity_type": "email", "value": "ana@example.com", "score": 1.0, + "start": 5, "end": 20, "tag": "entity:email:ana@example.com"}], + "count": 1, + "extractor": "rule_based", + "warnings": ["the neural pass failed: model not loaded"] + })); + }); + dk().args([ + "--url", + &server.base_url(), + "memory", + "extract", + "Mail ana@example.com", + ]) + .assert() + .success() + .stdout(predicate::str::contains("rules only")) + .stdout(predicate::str::contains("the neural pass failed")) + .stdout(predicate::str::contains("ana@example.com")); +} + +// --------------------------------------------------------------------------- +// changes the server applied but could not store yet; memory policy +// --------------------------------------------------------------------------- + +#[test] +fn key_delete_answered_202_says_it_is_not_stored_yet() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(DELETE).path("/admin/keys/k1"); + then.status(202).json_body(json!({ + "success": true, + "message": "API key 'k1' has been deleted on every node now; it could not be stored \ + yet (timeout), so it is logged on this node and retried until it is" + })); + }); + dk().args(["--url", &server.base_url(), "keys", "delete", "k1"]) + .assert() + .success() + .stdout(predicate::str::contains("not stored yet")) + .stdout(predicate::str::contains("retried until it is")); +} + +#[test] +fn key_delete_refused_prints_no_success() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(DELETE).path("/admin/keys/k1"); + then.status(404) + .json_body(json!({"error": "API key not found", "code": "NOT_FOUND"})); + }); + dk().args(["--url", &server.base_url(), "keys", "delete", "k1"]) + .assert() + .code(3) + .stdout(predicate::str::contains("deleted").not()); +} + +#[test] +fn policy_get_says_when_nothing_is_stored() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/v1/namespaces/ns1/memory_policy"); + then.status(200).json_body(json!({ + "working_ttl_seconds": null, + "episodic_ttl_seconds": null, + "stored": false + })); + }); + dk().args([ + "--url", + &server.base_url(), + "namespace", + "policy", + "get", + "ns1", + ]) + .assert() + .success() + .stdout(predicate::str::contains("has no stored policy")); +} + +#[test] +fn forget_dry_run_is_refused_by_an_older_server_before_anything_is_sent() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/health"); + then.status(200) + .json_body(json!({"status": "healthy", "version": "0.12.3"})); + }); + let m = server.mock(|when, then| { + when.method(POST).path("/v1/memory/forget"); + then.status(200).json_body(json!({"deleted_count": 1})); + }); + dk().args([ + "--url", + &server.base_url(), + "memory", + "forget", + "bot", + "m1", + "--dry-run", + ]) + .assert() + .code(5) + .stderr(predicate::str::contains("v0.12.4")) + .stderr(predicate::str::contains("0.12.3")); + m.assert_calls(0); +}