Skip to content

Commit 41d6301

Browse files
fix+feat(memory): roadmap phases 1-4 — tails, smartness, proof, operability (#90)
* fix(memory): phase 1 tails - tool honesty, session save lock, combined extraction, curator provenance - memory add_atom now reports 'quarantined for human review (reason: ...)' instead of 'added atom' when the guard scan routed the atom to quarantine, so the agent (and user) are not misled about what landed in the live store - session Store.Save no longer holds the store mutex during embedding (a potential 10s HTTP call); vector-index add happens after the lock is released, persistence semantics unchanged - session-end extraction uses ONE combined LLM call (summary + facts in a single JSON response) when both episode and fact extraction are enabled, with automatic fallback to the two single-purpose calls on parse failure; all existing safety filters preserved - curator MergeSkills keeps the WORSE provenance of the two inputs (Untrusted/NeedsReview = OR, Sources = deduped union) instead of inheriting the keeper's trust while concatenating a tainted body * feat(memory): phase 2 smartness - semantic dedup, intent confidence, consolidation, index fingerprint, stats API - semantic dedup on add: after the exact-match tier, atoms whose top-1 cosine similarity to a live atom exceeds semantic_dedup_threshold (default 0.92, 0 disables) refresh the existing atom instead of duplicating; in-batch duplicates handled via direct embedding - predicted-intent confidence now weights recall scores; intents below 0.3 confidence are skipped entirely - extractor confidence default is now 0.7 (was 1.0) so unconfident extractions no longer get maximal retention and eviction resistance - new ConsolidateAtoms: greedy similarity clustering (default 0.9) + one LLM merge call per group; originals kept on any failure; merged atoms keep highest confidence and union of associations; live store only, quarantine untouched - vector index persists a corpus fingerprint (count + FNV-1a of sorted atom IDs) in vectors_meta.json; a mismatch on load forces a rebuild instead of silently serving a stale corpus (legacy files rebuild once) - new Stats() API: live/quarantined atoms, quarantine reason breakdown, index vectors/dirty, store size, and recall timeout/failure counters for degradation observability * test: phase 3 proof - PiGuard CI E2E + fuzz soaks (and a fuzz-found panic fix) - new guard E2E test (env-gated ODEK_E2E_GUARD=1) exercises the real sidecar through guard.New over HTTP and socket_path: confident-BENIGN regression, injection detection, batch classification - new piguard-e2e CI workflow: builds the vendored daemon + gateway, caches the ONNX model by pinned revision, runs the E2E on ubuntu-latest (socket mode included), always tears down - fuzz suites for extractJSON, SKILL.md parsing, and session loading - fix a REAL panic found by the fuzzer: parseYAMLValue sliced s[1:len(s)-1] on a 1-char quoted string (' or "), crashing on any SKILL.md frontmatter line like 'key: "' * feat(memory): phase 4 operability - 'odek memory extended stats' and 'consolidate' - stats: store/index sizes, quarantine reason breakdown, recall timeout/failure counters with a degradation warning - makes guard false-positive rates and recall health visible to operators - consolidate: merges near-duplicate live atoms via the operator's configured LLM backend (10-minute bounded run) * Potential fix for pull request finding 'CodeQL / Workflow does not contain permissions' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * docs: sync CONFIG/MEMORY/EXTENDED_MEMORY/AGENTS with roadmap changes - CONFIG.md: add semantic_dedup_threshold and consolidate_similarity_threshold to the extended config example and field reference - MEMORY.md: combined session-end extraction call, bounded drain at close (episode extraction no longer fire-and-forget), quarantine-not-reject guard behavior with the honest add_atom report, stats in the CLI surface and observability sections, complete memory tool action enum - EXTENDED_MEMORY.md: consolidate/stats now have CLI commands - AGENTS.md: PIGuard E2E and fuzz soak commands in Testing * test: move guard E2E from CI to a local runner script The GitHub Actions job was too slow to keep (a ~735 MB ONNX model download plus two image builds per run). docker/piguard-e2e.sh now provisions the same stack locally (build-if-missing, health wait, env-gated test, always-tear-down) with an optional --linux flag that runs the test binary inside a container for full socket-mode coverage (the host socket subtest skips on macOS because unix sockets do not cross the Docker Desktop VM boundary). Verified locally: host run passes, container run passes all 6 subtests including socket mode. --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
1 parent c3c3e42 commit 41d6301

33 files changed

Lines changed: 2464 additions & 112 deletions

AGENTS.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,16 @@ ODEK_E2E=true go test -v -count=1 ./cmd/odek/ -run "TestMCPE2E_"
235235

236236
# Sandbox integration tests (requires Docker)
237237
go test -v -count=1 ./cmd/odek/ -run "TestSandbox"
238+
239+
# PIGuard sidecar E2E (local only — too heavy for CI; provisions the
240+
# docker stack, runs the env-gated test, tears down. Use --linux on macOS
241+
# for full socket-mode coverage.)
242+
docker/piguard-e2e.sh
243+
244+
# Fuzz soaks (extractJSON, SKILL.md parsing, session loading)
245+
go test -fuzz=FuzzExtractJSON -fuzztime=30s ./internal/memory/extended/
246+
go test -fuzz=FuzzParseSkillContent -fuzztime=30s ./internal/skills/
247+
go test -fuzz=FuzzSessionLoad -fuzztime=30s ./internal/session/
238248
```
239249

240250
Note: MCP client E2E tests build the fakeserver from `internal/mcpclient/testdata/main.go` at test time (no pre-compiled binary). macOS temp dirs are classified as `LocalWrite` (not `SystemWrite`), and the Docker availability check verifies daemon reachability before running sandbox tests.

cmd/odek/memory_cmd.go

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
package main
22

33
import (
4+
"context"
45
"fmt"
56
"os"
67
"path/filepath"
8+
"sort"
79
"strings"
10+
"time"
811

12+
"github.com/BackendStack21/odek/internal/config"
13+
"github.com/BackendStack21/odek/internal/llm"
914
"github.com/BackendStack21/odek/internal/memory"
1015
"github.com/BackendStack21/odek/internal/memory/extended"
1116
)
@@ -70,10 +75,10 @@ func memoryCmd(args []string) error {
7075
}
7176
}
7277

73-
// extendedMemoryCmd handles `odek memory extended forget|quarantine|compact`.
78+
// extendedMemoryCmd handles `odek memory extended <subcommand>`.
7479
func extendedMemoryCmd(dir string, args []string) error {
7580
if len(args) == 0 {
76-
fmt.Fprintf(os.Stderr, "Usage: odek memory extended <forget|promote|pin|quarantine|compact|pending|confirm|reject> [args]\n")
81+
fmt.Fprintf(os.Stderr, "Usage: odek memory extended <forget|promote|pin|quarantine|compact|stats|consolidate|pending|confirm|reject> [args]\n")
7782
return nil
7883
}
7984

@@ -144,6 +149,48 @@ func extendedMemoryCmd(dir string, args []string) error {
144149
fmt.Println("odek: Extended Memory vector index compaction triggered in the background")
145150
return nil
146151

152+
case "stats":
153+
st := em.Stats()
154+
fmt.Println("Extended Memory stats:")
155+
fmt.Printf(" live atoms: %d\n", st.LiveAtoms)
156+
fmt.Printf(" quarantined atoms: %d\n", st.QuarantinedAtoms)
157+
if len(st.QuarantineReasons) > 0 {
158+
reasons := make([]string, 0, len(st.QuarantineReasons))
159+
for r := range st.QuarantineReasons {
160+
reasons = append(reasons, r)
161+
}
162+
sort.Strings(reasons)
163+
for _, r := range reasons {
164+
fmt.Printf(" %-16s %d\n", r+":", st.QuarantineReasons[r])
165+
}
166+
}
167+
fmt.Printf(" index vectors: %d (dirty: %v)\n", st.IndexVectors, st.IndexDirty)
168+
fmt.Printf(" store size: %.1f MiB\n", float64(st.StoreSizeBytes)/(1<<20))
169+
fmt.Printf(" recall timeouts: %d\n", st.RecallTimeouts)
170+
fmt.Printf(" recall failures: %d\n", st.RecallFailures)
171+
if st.RecallTimeouts+st.RecallFailures > 0 {
172+
fmt.Println(" warning: recall degraded this process — check LLM/embedding backend latency")
173+
}
174+
return nil
175+
176+
case "consolidate":
177+
// Merging near-duplicate atoms needs an LLM; resolve the operator
178+
// backend the same way the agent does.
179+
resolved := config.LoadConfig(config.CLIFlags{})
180+
if resolved.APIKey == "" {
181+
return fmt.Errorf("memory extended consolidate requires an LLM backend (no API key resolved)")
182+
}
183+
llmClient := llm.New(resolved.BaseURL, resolved.APIKey, resolved.Model, "", 0, 120*time.Second)
184+
emLLM := extended.New(extDir, llmClient, cfg)
185+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
186+
defer cancel()
187+
merged, err := emLLM.ConsolidateAtoms(ctx)
188+
if err != nil {
189+
return err
190+
}
191+
fmt.Printf("odek: consolidation complete — %d atom(s) merged into existing or new entries\n", merged)
192+
return nil
193+
147194
case "pending":
148195
pending, err := em.ListPendingReview()
149196
if err != nil {
@@ -186,6 +233,6 @@ func extendedMemoryCmd(dir string, args []string) error {
186233
return nil
187234

188235
default:
189-
return fmt.Errorf("unknown extended memory subcommand %q (expected: forget, promote, pin, quarantine, compact, pending, confirm, reject)", sub)
236+
return fmt.Errorf("unknown extended memory subcommand %q (expected: forget, promote, pin, quarantine, compact, stats, consolidate, pending, confirm, reject)", sub)
190237
}
191238
}

docker/piguard-e2e.sh

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
#!/usr/bin/env bash
2+
# Local E2E runner for the PIGuard prompt-injection sidecar.
3+
#
4+
# Builds (if needed) and starts the daemon + gateway exactly like the
5+
# docker-compose stack, then runs the env-gated E2E test in
6+
# internal/guard/piguard_e2e_test.go against them, and tears everything
7+
# down afterwards.
8+
#
9+
# Why a script and not CI: the stack is heavy (a ~735 MB ONNX model plus
10+
# two image builds), which made the GitHub Actions job too slow to keep.
11+
# Run this before merging changes that touch internal/guard or the
12+
# docker piguard stack.
13+
#
14+
# Requirements: Docker, Go. First run needs the model exported once:
15+
# docker/piguard/download-model.sh
16+
#
17+
# Usage:
18+
# docker/piguard-e2e.sh build images if missing, run E2E, tear down
19+
# docker/piguard-e2e.sh --build force image rebuild
20+
# docker/piguard-e2e.sh --linux also run the test binary inside a Linux
21+
# container (full socket-mode coverage;
22+
# on macOS the host socket subtest skips
23+
# because unix sockets do not cross the
24+
# Docker Desktop VM boundary)
25+
set -euo pipefail
26+
27+
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
28+
DOCKER_DIR="${REPO_ROOT}/docker"
29+
MODEL_DIR="${DOCKER_DIR}/piguard/models"
30+
SOCK_DIR="/tmp/piguard-e2e"
31+
NETWORK="piguard-e2e"
32+
BUILD=0
33+
LINUX=0
34+
for arg in "$@"; do
35+
case "$arg" in
36+
--build) BUILD=1 ;;
37+
--linux) LINUX=1 ;;
38+
*) echo "unknown flag: $arg" >&2; exit 2 ;;
39+
esac
40+
done
41+
42+
cleanup() {
43+
docker rm -f piguard-e2e-gateway piguard-e2e-daemon >/dev/null 2>&1 || true
44+
docker network rm "${NETWORK}" >/dev/null 2>&1 || true
45+
rm -rf "${SOCK_DIR}"
46+
}
47+
trap cleanup EXIT
48+
49+
docker info >/dev/null 2>&1 || { echo "docker daemon is not running" >&2; exit 1; }
50+
51+
if [ ! -f "${MODEL_DIR}/model.onnx" ]; then
52+
echo "PIGuard model not found in ${MODEL_DIR}." >&2
53+
echo "Run ${DOCKER_DIR}/piguard/download-model.sh first (one-time, ~735 MB)." >&2
54+
exit 1
55+
fi
56+
57+
if [ "${BUILD}" = 1 ] || ! docker image inspect piguard:local >/dev/null 2>&1; then
58+
(cd "${DOCKER_DIR}" && docker compose --profile restricted build piguard)
59+
fi
60+
if [ "${BUILD}" = 1 ] || ! docker image inspect piguard-gateway:local >/dev/null 2>&1; then
61+
(cd "${DOCKER_DIR}" && docker compose --profile restricted build piguard-gateway)
62+
fi
63+
64+
cleanup # remove leftovers from a previous run
65+
mkdir -p "${SOCK_DIR}"
66+
docker network create "${NETWORK}" >/dev/null
67+
docker run -d --name piguard-e2e-daemon --network "${NETWORK}" \
68+
-v "${MODEL_DIR}:/models:ro" \
69+
-v "${SOCK_DIR}:/run/piguard" \
70+
piguard:local \
71+
--socket=/run/piguard/piguard.sock --model-dir=/models \
72+
--max-batch=32 --batch-wait=5ms >/dev/null
73+
docker run -d --name piguard-e2e-gateway --network "${NETWORK}" \
74+
-p 127.0.0.1:18080:8080 \
75+
-v "${SOCK_DIR}:/run/piguard" \
76+
piguard-gateway:local \
77+
--addr=:8080 --socket=/run/piguard/piguard.sock >/dev/null
78+
79+
echo "Waiting for gateway health..."
80+
healthy=0
81+
for _ in $(seq 1 90); do
82+
if curl -fsS http://127.0.0.1:18080/healthz >/dev/null 2>&1; then
83+
healthy=1
84+
break
85+
fi
86+
sleep 2
87+
done
88+
if [ "${healthy}" != 1 ]; then
89+
echo "=== daemon logs ===" >&2; docker logs piguard-e2e-daemon >&2 || true
90+
echo "=== gateway logs ===" >&2; docker logs piguard-e2e-gateway >&2 || true
91+
echo "gateway never became healthy" >&2
92+
exit 1
93+
fi
94+
95+
export ODEK_E2E_GUARD=1
96+
export PIGUARD_URL="http://127.0.0.1:18080/detect"
97+
export PIGUARD_SOCKET="${SOCK_DIR}/piguard.sock"
98+
99+
echo "Running guard E2E (host)..."
100+
(cd "${REPO_ROOT}" && go test ./internal/guard -run E2E -count=1 -v)
101+
102+
if [ "${LINUX}" = 1 ]; then
103+
echo "Running guard E2E inside a Linux container (socket mode covered)..."
104+
(cd "${REPO_ROOT}" && CGO_ENABLED=0 GOOS=linux go test -c -o /tmp/piguard-e2e/guard.test ./internal/guard)
105+
docker run --rm --network "${NETWORK}" \
106+
-e ODEK_E2E_GUARD=1 \
107+
-e PIGUARD_URL="http://piguard-e2e-gateway:8080/detect" \
108+
-e PIGUARD_SOCKET=/run/piguard/piguard.sock \
109+
-v "${SOCK_DIR}:/run/piguard" \
110+
-v /tmp/piguard-e2e:/out \
111+
debian:bookworm-slim /out/guard.test -test.run E2E -test.v
112+
fi
113+
114+
echo "PIGuard E2E passed."

docs/CONFIG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,8 @@ The `memory` section controls the persistent memory system (see [docs/MEMORY.md]
375375
"semantic_search_overfetch": 4,
376376
"semantic_search_min_score": 0.55,
377377
"semantic_search_rerank": true,
378+
"semantic_dedup_threshold": 0.92,
379+
"consolidate_similarity_threshold": 0.9,
378380
"atom_max_chars": 300,
379381
"memory_budget_chars": 2000,
380382
"decay_half_life_days": 30,
@@ -409,6 +411,8 @@ The `memory` section controls the persistent memory system (see [docs/MEMORY.md]
409411
| `semantic_search_overfetch` | `4` ||| Candidate multiplier before filtering and reranking. |
410412
| `semantic_search_min_score` | `0.55` ||| Minimum cosine similarity for a candidate to be considered. |
411413
| `semantic_search_rerank` | `true` ||| Use the memory LLM to rerank candidates. |
414+
| `semantic_dedup_threshold` | `0.92` ||| Cosine similarity at or above which an incoming atom is treated as a paraphrase of an existing live atom and refreshes it instead of appending. `0` disables the semantic tier (exact-match dedup always runs). |
415+
| `consolidate_similarity_threshold` | `0.9` ||| Pairwise cosine similarity at or above which live atoms are grouped for LLM merging by `odek memory extended consolidate` / `ConsolidateAtoms`. |
412416
| `atom_max_chars` | `300` | `ODEK_MEMORY_EXTENDED_ATOM_MAX_CHARS` | `--memory-extended-atom-max-chars` | Maximum stored text length per atom. |
413417
| `memory_budget_chars` | `2000` | `ODEK_MEMORY_EXTENDED_MEMORY_BUDGET_CHARS` | `--memory-extended-memory-budget-chars` | Maximum injected Extended Memory context per turn. |
414418
| `decay_half_life_days` | `30` ||| Days until an atom's recall/eviction weight halves. |

docs/EXTENDED_MEMORY.md

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,13 +99,13 @@ The current extraction prompt and tool schema accept all nine primary types. Unk
9999
│ └── <atom-id>.md
100100
├── vectors.gob # persisted go-vector store
101101
├── vectors.gob.emb # persisted embedder state (RP vocabulary / HTTP fingerprint)
102-
├── vectors_meta.json # embedding-space fingerprint for invalidation
102+
├── vectors_meta.json # embedding-space + corpus fingerprints for invalidation/staleness
103103
├── quarantine.json # tainted atoms awaiting promotion
104104
├── user_model.json # persisted inferred user state + pending review queue
105105
└── associations.json # bidirectional atom association links
106106
```
107107

108-
All files are written atomically and use `0600` permissions. The vector store and metadata are rebuilt from the chunk files if they are ever corrupted. The user model and associations are loaded at startup and persisted on every change.
108+
All files are written atomically and use `0600` permissions. The vector store and metadata are rebuilt from the chunk files if they are ever corrupted. `vectors_meta.json` records both the embedding-space fingerprint (which embedding backend produced the vectors) and a corpus fingerprint (atom count plus a hash of the sorted atom IDs); a mismatch on either — including a missing corpus fingerprint in files written before it was tracked — marks the persisted index stale so it is rebuilt once instead of silently serving an outdated corpus. The user model and associations are loaded at startup and persisted on every change.
109109

110110
## Dedicated Memory LLM
111111

@@ -163,6 +163,23 @@ Extracted atoms are immediately:
163163
4. Embedded and written to the atom store.
164164
5. Checked against the 100 MB size cap; low-retention atoms are evicted if needed.
165165

166+
Explicit LLM-provided `confidence` values in (0, 1] are kept. A missing or out-of-range confidence defaults to **0.7** for extraction-origin atoms, so unqualified extracted memories do not inflate their retention score.
167+
168+
### Write-Path Deduplication
169+
170+
Atoms are deduplicated in two tiers at persistence time:
171+
172+
1. **Exact match**: an atom whose normalized text (lowercased, whitespace-collapsed) equals an existing live atom refreshes it — new `CreatedAt`, the higher confidence wins, the original ID is kept — instead of appending a duplicate.
173+
2. **Semantic match**: if no exact match exists and `semantic_dedup_threshold` is non-zero, the incoming atom is compared against the live corpus via the vector index (top-1 similarity); atoms stored earlier in the same batch, which the index does not cover yet, are compared by embedding both texts directly. A match at or above the threshold is refreshed the same way. The dedup search uses the pre-batch index state and never triggers extra index rebuilds: the single index invalidation after the batch is preserved.
174+
175+
### Atom Consolidation
176+
177+
`ExtendedMemory.ConsolidateAtoms(ctx)` — exposed as `odek memory extended consolidate` — merges groups of live atoms that are near-duplicates (pairwise cosine similarity at or above `consolidate_similarity_threshold`). For each group it asks the memory LLM — one call per group — to merge the texts into a single concise atom, stores the merged atom through the normal add path (scan and size cap apply) keeping the group's highest confidence, a refreshed `CreatedAt`, and the union of the group's outward associations, then removes the originals. Quarantined atoms are never consolidated. On any failure for a group (LLM error, empty response, scan rejection) the originals are kept untouched.
178+
179+
### Observability
180+
181+
`ExtendedMemory.Stats()` returns a `Stats` snapshot — surfaced as `odek memory extended stats` — for operators and monitoring: live/quarantined atom counts, quarantine entries grouped by reason prefix (`tainted`, `scan_rejected`), index vector count and dirty flag, on-disk store size, and recall error counters (`RecallTimeouts` for context-deadline-exceeded errors, `RecallFailures` for all other recall errors). The CLI prints a degradation warning when either counter is non-zero.
182+
166183
## Semantic Search
167184

168185
Extended Memory replaces episode-based recall with semantic search over atom vectors.
@@ -200,6 +217,8 @@ The final recall result is also bounded by `memory_budget_chars`. Tainted atoms
200217

201218
When `follow_up_anticipation_enabled` is true (default), the memory LLM receives the current user message, the last several user messages, and the current user-state model. It returns a JSON array of up to `predictive_intents` likely follow-up intents. Each intent is embedded and searched, and the union of literal-query matches and predicted-intent matches is injected into the main agent's context. For each predicted intent, the system also performs a type-targeted recall for `convention`, `file`, and `error` atoms so the agent can pre-load relevant conventions, references, and known failure modes.
202219

220+
Intents carry a `confidence` (0.0-1.0). Intents below 0.3 are skipped entirely; otherwise the composite score of every atom found via a predicted intent is multiplied by that intent's confidence, so a speculative intent cannot outrank literal-query matches.
221+
203222
Example:
204223

205224
- User: "Refactor the auth package to remove JWT."
@@ -355,6 +374,8 @@ Extended Memory is configured under the `memory.extended` section.
355374
"user_state_max_pending": 20,
356375
"associations_enabled": true,
357376
"association_semantic_top_k": 3,
377+
"semantic_dedup_threshold": 0.92,
378+
"consolidate_similarity_threshold": 0.9,
358379
"proactive_return_after_break": true,
359380
"style_mirroring_enabled": true,
360381
"anaphora_resolution_enabled": true,
@@ -401,6 +422,8 @@ Extended Memory is configured under the `memory.extended` section.
401422
| `user_state_max_pending` | `20` | Maximum pending-review entries kept in the user model. |
402423
| `associations_enabled` | `true` | Enable bidirectional atom associations (temporal, task, semantic). |
403424
| `association_semantic_top_k` | `3` | Number of semantic neighbours linked when building associations. |
425+
| `semantic_dedup_threshold` | `0.92` | Cosine similarity at or above which an incoming atom is treated as a paraphrase of an existing live atom and refreshes it instead of appending. `0` disables the semantic tier (exact-match dedup always runs). |
426+
| `consolidate_similarity_threshold` | `0.9` | Cosine similarity at or above which live atoms are grouped as near-duplicates for `ConsolidateAtoms` merging. |
404427
| `proactive_return_after_break` | `true` | On session resume, inject a "where you left off" summary. |
405428
| `style_mirroring_enabled` | `true` | Inject a style-guidance directive based on the inferred user model. |
406429
| `anaphora_resolution_enabled` | `true` | Resolve the first pronoun in a user message against recent trusted atoms when the top atom's score is high enough. |
@@ -455,6 +478,8 @@ Additional CLI commands:
455478
odek memory extended forget <atom-id>
456479
odek memory extended quarantine
457480
odek memory extended compact
481+
odek memory extended stats
482+
odek memory extended consolidate
458483
odek memory extended promote <atom-id>
459484
odek memory extended pin <atom-id>
460485
odek memory extended pending
@@ -467,6 +492,8 @@ odek memory extended reject <pending-id>
467492
| `forget` | Removes an atom from the live store or quarantine. |
468493
| `quarantine` | Lists tainted atoms awaiting promotion. |
469494
| `compact` | Triggers a background rebuild of the vector index to reclaim space. |
495+
| `stats` | Shows store/index sizes, quarantine reason breakdown, and recall degradation counters (timeouts/failures). |
496+
| `consolidate` | Merges near-duplicate live atoms via the LLM (requires a configured backend). |
470497
| `promote` | Moves a quarantined atom to the live store as `user_approved`. |
471498
| `pin` | Pins a live atom so it is never evicted. |
472499
| `pending` | Lists pending user-model inferences. |

0 commit comments

Comments
 (0)