Skip to content

Commit 3cae005

Browse files
authored
feat(memory): implement Extended Memory P0-P5 with trust-preserving anticipatory recall
feat: Extended Memory module (P0-P2)
2 parents ff62bd6 + 93ba487 commit 3cae005

40 files changed

Lines changed: 8220 additions & 332 deletions

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,7 @@ docker/.odek/*
1919
# Claude Code local artifacts
2020
.claude/
2121

22-
sec_findings.md
22+
sec_findings.md
23+
24+
# opencode agent local memory
25+
.kai/

cmd/odek/main.go

Lines changed: 249 additions & 38 deletions
Large diffs are not rendered by default.

cmd/odek/memory_cmd.go

Lines changed: 124 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ package main
33
import (
44
"fmt"
55
"os"
6+
"path/filepath"
67
"strings"
78

89
"github.com/BackendStack21/odek/internal/memory"
10+
"github.com/BackendStack21/odek/internal/memory/extended"
911
)
1012

11-
// memoryCmd handles `odek memory <list|promote> [args]`.
13+
// memoryCmd handles `odek memory <list|promote|extended> [args]`.
1214
//
1315
// This is the human-gated surface for the episode-memory trust control.
1416
// Episodes whose originating session touched external content (web/http/MCP/
@@ -18,7 +20,7 @@ import (
1820
// approve its own poisoned memory.
1921
func memoryCmd(args []string) error {
2022
if len(args) == 0 {
21-
fmt.Fprintf(os.Stderr, "Usage: odek memory <list|promote> [args]\n")
23+
fmt.Fprintf(os.Stderr, "Usage: odek memory <list|promote|extended> [args]\n")
2224
return nil
2325
}
2426

@@ -60,7 +62,126 @@ func memoryCmd(args []string) error {
6062
fmt.Printf("odek: promoted episode %q — it can now be recalled into future sessions\n", id)
6163
return nil
6264

65+
case "extended":
66+
return extendedMemoryCmd(dir, subArgs)
67+
68+
default:
69+
return fmt.Errorf("unknown memory subcommand %q (expected: list, promote, extended)", sub)
70+
}
71+
}
72+
73+
// extendedMemoryCmd handles `odek memory extended forget|quarantine|compact`.
74+
func extendedMemoryCmd(dir string, args []string) error {
75+
if len(args) == 0 {
76+
fmt.Fprintf(os.Stderr, "Usage: odek memory extended <forget|promote|pin|quarantine|compact|pending|confirm|reject> [args]\n")
77+
return nil
78+
}
79+
80+
sub := args[0]
81+
subArgs := args[1:]
82+
83+
extDir := filepath.Join(dir, "extended")
84+
cfg := extended.DefaultConfig()
85+
enabled := true
86+
cfg.Enabled = &enabled
87+
em := extended.New(extDir, nil, cfg)
88+
89+
switch sub {
90+
case "forget":
91+
if len(subArgs) == 0 {
92+
return fmt.Errorf("usage: odek memory extended forget <atom_id>")
93+
}
94+
id := subArgs[0]
95+
if err := em.ForgetAtom(id); err != nil {
96+
return err
97+
}
98+
fmt.Printf("odek: forgot atom %q\n", id)
99+
return nil
100+
101+
case "promote":
102+
if len(subArgs) == 0 {
103+
return fmt.Errorf("usage: odek memory extended promote <atom_id>")
104+
}
105+
id := subArgs[0]
106+
if err := em.PromoteAtom(id); err != nil {
107+
return err
108+
}
109+
fmt.Printf("odek: promoted atom %q — it can now be recalled into future sessions\n", id)
110+
return nil
111+
112+
case "pin":
113+
if len(subArgs) == 0 {
114+
return fmt.Errorf("usage: odek memory extended pin <atom_id>")
115+
}
116+
id := subArgs[0]
117+
if err := em.PinAtom(id); err != nil {
118+
return err
119+
}
120+
fmt.Printf("odek: pinned atom %q\n", id)
121+
return nil
122+
123+
case "quarantine":
124+
atoms, err := em.ListQuarantine()
125+
if err != nil {
126+
return err
127+
}
128+
if len(atoms) == 0 {
129+
fmt.Println("No atoms in quarantine.")
130+
return nil
131+
}
132+
fmt.Printf("%d atom(s) in quarantine (excluded from recall):\n\n", len(atoms))
133+
for _, a := range atoms {
134+
fmt.Printf("• %s [%s] %s\n", a.ID, a.SourceClass, truncate(a.Text, 120))
135+
}
136+
return nil
137+
138+
case "compact":
139+
em.Compact()
140+
fmt.Println("odek: Extended Memory vector index compaction triggered in the background")
141+
return nil
142+
143+
case "pending":
144+
pending, err := em.ListPendingReview()
145+
if err != nil {
146+
return err
147+
}
148+
if len(pending) == 0 {
149+
fmt.Println("No pending user-model reviews.")
150+
return nil
151+
}
152+
fmt.Printf("%d pending review(s):\n\n", len(pending))
153+
for _, p := range pending {
154+
fmt.Printf("• %s | %s = %q (confidence %.2f)\n", p.ID, p.Field, truncate(p.Value, 120), p.Confidence)
155+
if p.Evidence != "" {
156+
fmt.Printf(" evidence: %s\n", truncate(p.Evidence, 120))
157+
}
158+
}
159+
fmt.Println("\nConfirm with: odek memory extended confirm <id>")
160+
return nil
161+
162+
case "confirm":
163+
if len(subArgs) == 0 {
164+
return fmt.Errorf("usage: odek memory extended confirm <pending_id>")
165+
}
166+
id := subArgs[0]
167+
if err := em.ConfirmPendingReview(id); err != nil {
168+
return err
169+
}
170+
fmt.Printf("odek: confirmed pending review %q\n", id)
171+
return nil
172+
173+
case "reject":
174+
if len(subArgs) == 0 {
175+
return fmt.Errorf("usage: odek memory extended reject <pending_id>")
176+
}
177+
id := subArgs[0]
178+
if err := em.RejectPendingReview(id); err != nil {
179+
return err
180+
}
181+
fmt.Printf("odek: rejected pending review %q\n", id)
182+
return nil
183+
63184
default:
64-
return fmt.Errorf("unknown memory subcommand %q (expected: list, promote)", sub)
185+
return fmt.Errorf("unknown extended memory subcommand %q (expected: forget, promote, pin, quarantine, compact, pending, confirm, reject)", sub)
65186
}
66187
}

cmd/odek/repl.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ func replCmd(args []string) error {
150150
Skills: skillsCfg,
151151
SkillManager: sm,
152152
MemoryConfig: resolved.Memory,
153+
MemoryDir: expandHome("~/.odek/memory"),
153154
PromptCaching: resolved.PromptCaching,
154155
})
155156
if err != nil {
@@ -177,6 +178,10 @@ func replCmd(args []string) error {
177178
sess.Sandbox = resolved.Sandbox
178179
store.Save(sess)
179180
}
181+
cwd, _ := os.Getwd()
182+
if mm := agent.Memory(); mm != nil {
183+
mm.SetSessionContext(sess.ID, cwd)
184+
}
180185

181186
fmt.Fprintf(os.Stderr, "\nodek ⚡ %s · session %s\n\n", modelLabel, sess.ID)
182187
fmt.Fprintf(os.Stderr, " Type /help for commands, /exit to quit.\n\n")

cmd/odek/serve.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,7 @@ func newServeAgent(resolved config.ResolvedConfig, system string, sendFn func(v
534534
Skills: &resolved.Skills,
535535
SkillManager: sm,
536536
MemoryConfig: resolved.Memory,
537+
MemoryDir: expandHome("~/.odek/memory"),
537538
ToolEventHandler: func(event, name, data string) {
538539
sendFn(map[string]any{
539540
"type": event,
@@ -967,6 +968,11 @@ func handlePrompt(
967968
}
968969
}
969970

971+
cwd, _ := os.Getwd()
972+
if agent.Memory() != nil && sess != nil {
973+
agent.Memory().SetSessionContext(sess.ID, cwd)
974+
}
975+
970976
// Send session info
971977
sid := ""
972978
authToken := ""

docs/CONFIG.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,10 @@ Every config knob has a `ODEK_*` counterpart:
122122
| `ODEK_SANDBOX_CPUS` | `--sandbox-cpus` | string |
123123
| `ODEK_SANDBOX_USER` | `--sandbox-user` | string |
124124
| `ODEK_MAX_TOOL_PARALLEL` | `max_tool_parallel` | int |
125+
| `ODEK_MEMORY_EXTENDED_ENABLED` | `--memory-extended-enabled` | bool |
126+
| `ODEK_MEMORY_EXTENDED_MAX_SIZE_MB` | `--memory-extended-max-size-mb` | int |
127+
| `ODEK_MEMORY_EXTENDED_ATOM_MAX_CHARS` | `--memory-extended-atom-max-chars` | int |
128+
| `ODEK_MEMORY_EXTENDED_MEMORY_BUDGET_CHARS` | `--memory-extended-memory-budget-chars` | int |
125129

126130
## API key fallback order
127131

@@ -252,6 +256,67 @@ The `memory` section controls the persistent memory system (see [docs/MEMORY.md]
252256
| `episode_ttl_days` | 0 | Evict episodes older than this many days. `0` (default) disables TTL-based eviction. |
253257
| `embedding` | *(inherits top-level `embedding`)* | Optional override of the embedding backend for episode recall, dedup, the non-LLM episode ranker, and fact merge-on-write. When unset, memory inherits the shared top-level [`embedding`](#shared-embedding-backend-embedding--memory-sessions--skills) default; if neither is set, local RandomProjections (lexical bag-of-words — fast, zero-cost, but no real semantics). See below. |
254258

259+
### Extended Memory (`memory.extended`)
260+
261+
`memory.extended` is an **opt-in** atomic memory layer. It extracts small, typed memory atoms from user messages and recalls them via semantic search over the atom corpus. It does not replace facts, the buffer, or episodes; it adds a fourth source of context that is injected after episodes on each turn. See [docs/EXTENDED_MEMORY.md](EXTENDED_MEMORY.md) for the full design.
262+
263+
> **Security note:** Project-level `./odek.json` cannot set the `memory` or `embedding` sections. Configure `memory.extended` in `~/.odek/config.json`, via the `ODEK_MEMORY_EXTENDED_*` environment variables, or with the CLI flags listed below.
264+
265+
```json
266+
{
267+
"memory": {
268+
"extended": {
269+
"enabled": true,
270+
"max_size_mb": 100,
271+
"semantic_search_top_k": 10,
272+
"semantic_search_overfetch": 4,
273+
"semantic_search_min_score": 0.55,
274+
"semantic_search_rerank": true,
275+
"atom_max_chars": 300,
276+
"memory_budget_chars": 2000,
277+
"decay_half_life_days": 30,
278+
"quarantine_ttl_days": 7,
279+
"eviction_policy": "retention_decay",
280+
"predictive_intents": 3,
281+
"auto_extract_per_turn": true,
282+
"infer_user_state": true,
283+
"llm": {
284+
"base_url": "http://localhost:11434/v1",
285+
"api_key": "",
286+
"model": "qwen2.5:7b",
287+
"max_tokens": 1024,
288+
"temperature": 0.2,
289+
"timeout_seconds": 30
290+
},
291+
"embedding": {
292+
"provider": "http",
293+
"base_url": "http://localhost:11434/v1",
294+
"model": "nomic-embed-text"
295+
}
296+
}
297+
}
298+
}
299+
```
300+
301+
| Field | Default | Env var | CLI flag | Description |
302+
|-------|---------|---------|----------|-------------|
303+
| `enabled` | `false` | `ODEK_MEMORY_EXTENDED_ENABLED` | `--memory-extended-enabled` | Master switch for Extended Memory. |
304+
| `max_size_mb` | `100` | `ODEK_MEMORY_EXTENDED_MAX_SIZE_MB` | `--memory-extended-max-size-mb` | Hard disk budget for the `extended/` directory. |
305+
| `semantic_search_top_k` | `10` ||| Number of atoms returned to the system prompt. |
306+
| `semantic_search_overfetch` | `4` ||| Candidate multiplier before filtering and reranking. |
307+
| `semantic_search_min_score` | `0.55` ||| Minimum cosine similarity for a candidate to be considered. |
308+
| `semantic_search_rerank` | `true` ||| Use the memory LLM to rerank candidates. |
309+
| `atom_max_chars` | `300` | `ODEK_MEMORY_EXTENDED_ATOM_MAX_CHARS` | `--memory-extended-atom-max-chars` | Maximum stored text length per atom. |
310+
| `memory_budget_chars` | `2000` | `ODEK_MEMORY_EXTENDED_MEMORY_BUDGET_CHARS` | `--memory-extended-memory-budget-chars` | Maximum injected Extended Memory context per turn. |
311+
| `decay_half_life_days` | `30` ||| Days until an atom's recall/eviction weight halves. |
312+
| `quarantine_ttl_days` | `7` ||| Days before a tainted atom is auto-deleted from quarantine. |
313+
| `eviction_policy` | `"retention_decay"` ||| Eviction algorithm. `"retention_decay"` is the only supported value. |
314+
| `predictive_intents` | `3` ||| Reserved for future predictive-intent recall (P5). Currently accepted but ignored. |
315+
| `auto_extract_per_turn` | `true` ||| Extract atoms after every user message. |
316+
| `infer_user_state` | `true` ||| Reserved for future user-state model inference (P3). Currently accepted but ignored. |
317+
| `llm` | omitted ||| Dedicated memory LLM. If omitted, the main agent LLM is reused. A warning is emitted if that model has thinking enabled. |
318+
| `embedding` | omitted ||| Dedicated embedding backend for atoms. If omitted, inherits `memory.embedding` or the shared top-level `embedding`. |
319+
255320
### `embedding` — real semantic embeddings (optional)
256321

257322
By default every similarity computation in memory uses go-vector
@@ -681,6 +746,12 @@ ODEK_SANDBOX=true odek run "run untrusted script"
681746
# Enable skill learning via env var
682747
ODEK_SKILLS_LEARN=true odek run "set up CI"
683748

749+
# Enable Extended Memory via CLI flag
750+
odek run --memory-extended-enabled "remember that I prefer Go over Python"
751+
752+
# Or configure it globally in ~/.odek/config.json (memory cannot be set in ./odek.json)
753+
# { "memory": { "extended": { "enabled": true } } }
754+
684755
# Sub-agent config (project-level)
685756
echo '{"subagent": {"max_concurrency": 5, "timeout_seconds": 300}}' > ./odek.json
686757

0 commit comments

Comments
 (0)