Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
72 changes: 72 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <text>`
forgets the memories that say it (scored by the server's relevance model, `--min-score`,
`--limit`); `--request <message>` 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 <message>...`** (`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 <agent>`** and **`dk memory restore <agent> <id>`**: 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
Expand Down
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"] }
Expand Down
43 changes: 39 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`.
Expand Down
136 changes: 135 additions & 1 deletion src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -80,11 +85,30 @@ impl ApiHttpError {
pub fn hint(&self) -> Option<String> {
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 {
Expand All @@ -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,
Expand All @@ -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.";
Expand Down Expand Up @@ -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<String> {
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,
Expand Down Expand Up @@ -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<T: serde::Serialize>(request: &T) -> Result<Value> {
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())
Expand Down Expand Up @@ -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");
Expand Down
Loading
Loading