Skip to content

Commit 080dbc9

Browse files
committed
feat(extended-memory): P0-P2 implementation with security hardening and 80.5% coverage
- Add opt-in Extended Memory atom store, vector recall, and retention-decay eviction - Wire dedicated/fallback memory LLM and forward MemoryConfig in run/continue - Fix loop dedup for extended context injection - Implement quarantine store with TTL and scan tainted atoms before persistence - Add promote/pin/list_quarantine tool actions and CLI commands - Harden size cap accounting, vector file permissions, and anti-injection framing - Update MEMORY.md, EXTENDED_MEMORY.md, and CONFIG.md docs Delete docs/EXTENDED_MEMORY_IMPLEMENTATION_PLAN.md
1 parent 917114c commit 080dbc9

27 files changed

Lines changed: 1507 additions & 1033 deletions

cmd/odek/main.go

Lines changed: 76 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1032,6 +1032,8 @@ func run(args []string) error {
10321032
Skills: skillsCfg,
10331033
SkillManager: sm,
10341034
PromptCaching: resolved.PromptCaching,
1035+
MemoryDir: expandHome("~/.odek/memory"),
1036+
MemoryConfig: resolved.Memory,
10351037
})
10361038
if err != nil {
10371039
return err
@@ -1049,6 +1051,39 @@ func run(args []string) error {
10491051
var allMessages []llm.Message
10501052
var runErr error
10511053
var result string
1054+
var sessionID string
1055+
1056+
cwd, _ = os.Getwd()
1057+
if mm := agent.Memory(); mm != nil {
1058+
if f.Session != nil && *f.Session {
1059+
// Pre-create the session so extracted atoms can be tagged with the
1060+
// session ID before the agent run starts.
1061+
store, err := session.NewStore()
1062+
if err != nil {
1063+
return fmt.Errorf("session store: %w", err)
1064+
}
1065+
messages := []llm.Message{
1066+
{Role: "user", Content: f.Task},
1067+
}
1068+
if systemMessage != "" {
1069+
messages = append([]llm.Message{{Role: "system", Content: systemMessage}}, messages...)
1070+
}
1071+
sess, err := store.Create(messages, resolved.Model, f.Task)
1072+
if err != nil {
1073+
return fmt.Errorf("save session: %w", err)
1074+
}
1075+
sess.Sandbox = resolved.Sandbox
1076+
store.Save(sess)
1077+
sessionID = sess.ID
1078+
mm.SetSessionContext(sessionID, cwd)
1079+
fmt.Fprintf(os.Stderr, "odek: session %s created\n", sessionID)
1080+
} else {
1081+
// Non-session mode still needs a transient ID so extracted atoms can
1082+
// be traced back to this run for review.
1083+
sessionID = session.GenerateID()
1084+
mm.SetSessionContext(sessionID, cwd)
1085+
}
1086+
}
10521087

10531088
if f.Session != nil && *f.Session {
10541089
// Multi-turn session mode: save conversation history
@@ -1083,22 +1118,27 @@ func run(args []string) error {
10831118
if err != nil {
10841119
return fmt.Errorf("session store: %w", err)
10851120
}
1086-
sess, err := store.Create(allMessages, resolved.Model, f.Task)
1121+
// Re-load the pre-created session and append the messages produced
1122+
// by the run. The pre-created session contains the system + user task;
1123+
// append only the assistant/tool turns that follow.
1124+
latest, err := store.Load(sessionID)
10871125
if err != nil {
1126+
return fmt.Errorf("load session: %w", err)
1127+
}
1128+
newMsgs := allMessages[len(latest.GetMessages()):]
1129+
if err := store.Append(sessionID, newMsgs); err != nil {
10881130
return fmt.Errorf("save session: %w", err)
10891131
}
1090-
sess.Sandbox = resolved.Sandbox
1091-
// Persist buffer to session
1092-
if mm := agent.Memory(); mm != nil {
1093-
sess.Buffer = mm.GetBuffer()
1132+
updated, err := store.Load(sessionID)
1133+
if err != nil {
1134+
return fmt.Errorf("reload session: %w", err)
10941135
}
1095-
store.Save(sess)
1096-
fmt.Fprintf(os.Stderr, "odek: session %s saved — continue with: odek continue \"...\"\n", sess.ID)
1097-
// Tag any atoms extracted during this run with the session ID so
1098-
// future review can trace their origin.
1136+
updated.Sandbox = resolved.Sandbox
10991137
if mm := agent.Memory(); mm != nil {
1100-
mm.SetSessionContext(sess.ID, "")
1138+
updated.Buffer = mm.GetBuffer()
11011139
}
1140+
store.Save(updated)
1141+
fmt.Fprintf(os.Stderr, "odek: session %s saved — continue with: odek continue \"...\"\n", updated.ID)
11021142
}
11031143
} else {
11041144
// Single-shot mode (default)
@@ -1128,11 +1168,11 @@ func run(args []string) error {
11281168

11291169
// ── Session end — extract episode if enough turns ──
11301170
// Run asynchronously so episode extraction does not delay process exit.
1131-
if mm := agent.Memory(); mm != nil && f.Session != nil && *f.Session {
1171+
if mm := agent.Memory(); mm != nil && f.Session != nil && *f.Session && sessionID != "" {
11321172
go func() {
1133-
sess, err := session.NewStore()
1173+
store, err := session.NewStore()
11341174
if err == nil {
1135-
latest, err := sess.Latest()
1175+
latest, err := store.Load(sessionID)
11361176
if err == nil {
11371177
msgStrs := makeSessionMessageStrings(latest)
11381178
prov := memory.DeriveProvenance(latest.Messages)
@@ -1954,25 +1994,27 @@ func continueCmd(args []string) error {
19541994
skillsCfg = &resolved.Skills
19551995
}
19561996

1957-
agent, err := odek.New(odek.Config{
1958-
Model: resolved.Model,
1959-
BaseURL: resolved.BaseURL,
1960-
APIKey: resolved.APIKey,
1961-
MaxIterations: resolved.MaxIter,
1962-
MaxToolParallel: resolved.MaxToolParallel,
1963-
SystemMessage: systemMessage,
1964-
UntrustedWrapper: func(source, content string) string { return wrapUntrusted(context.Background(), source, content) },
1965-
NoProjectFile: resolved.NoAgents,
1966-
Thinking: resolved.Thinking,
1967-
Temperature: 0, // deterministic by default; override with --temperature
1968-
Tools: tools,
1969-
ToolFilter: odek.ToolFilterConfig{Enabled: resolved.Tools.Enabled, Disabled: resolved.Tools.Disabled},
1970-
SandboxCleanup: sandboxCleanup,
1971-
Renderer: rend,
1972-
Skills: skillsCfg,
1973-
SkillManager: sm,
1974-
PromptCaching: resolved.PromptCaching,
1975-
})
1997+
agent, err := odek.New(odek.Config{
1998+
Model: resolved.Model,
1999+
BaseURL: resolved.BaseURL,
2000+
APIKey: resolved.APIKey,
2001+
MaxIterations: resolved.MaxIter,
2002+
MaxToolParallel: resolved.MaxToolParallel,
2003+
SystemMessage: systemMessage,
2004+
UntrustedWrapper: func(source, content string) string { return wrapUntrusted(context.Background(), source, content) },
2005+
NoProjectFile: resolved.NoAgents,
2006+
Thinking: resolved.Thinking,
2007+
Temperature: 0, // deterministic by default; override with --temperature
2008+
Tools: tools,
2009+
ToolFilter: odek.ToolFilterConfig{Enabled: resolved.Tools.Enabled, Disabled: resolved.Tools.Disabled},
2010+
SandboxCleanup: sandboxCleanup,
2011+
Renderer: rend,
2012+
Skills: skillsCfg,
2013+
SkillManager: sm,
2014+
PromptCaching: resolved.PromptCaching,
2015+
MemoryDir: expandHome("~/.odek/memory"),
2016+
MemoryConfig: resolved.Memory,
2017+
})
19762018
if err != nil {
19772019
return err
19782020
}
@@ -1985,8 +2027,9 @@ func continueCmd(args []string) error {
19852027

19862028
// Propagate session context to Extended Memory so extracted atoms are
19872029
// tagged with the session they came from.
2030+
cwd, _ = os.Getwd()
19882031
if mm := agent.Memory(); mm != nil {
1989-
mm.SetSessionContext(sess.ID, "")
2032+
mm.SetSessionContext(sess.ID, cwd)
19902033
}
19912034

19922035
// Build message history: session messages + new user message

cmd/odek/memory_cmd.go

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ func memoryCmd(args []string) error {
7373
// extendedMemoryCmd handles `odek memory extended forget|quarantine|compact`.
7474
func extendedMemoryCmd(dir string, args []string) error {
7575
if len(args) == 0 {
76-
fmt.Fprintf(os.Stderr, "Usage: odek memory extended <forget|quarantine|compact> [args]\n")
76+
fmt.Fprintf(os.Stderr, "Usage: odek memory extended <forget|promote|pin|quarantine|compact> [args]\n")
7777
return nil
7878
}
7979

@@ -96,6 +96,28 @@ func extendedMemoryCmd(dir string, args []string) error {
9696
fmt.Printf("odek: forgot atom %q\n", id)
9797
return nil
9898

99+
case "promote":
100+
if len(subArgs) == 0 {
101+
return fmt.Errorf("usage: odek memory extended promote <atom_id>")
102+
}
103+
id := subArgs[0]
104+
if err := em.PromoteAtom(id); err != nil {
105+
return err
106+
}
107+
fmt.Printf("odek: promoted atom %q — it can now be recalled into future sessions\n", id)
108+
return nil
109+
110+
case "pin":
111+
if len(subArgs) == 0 {
112+
return fmt.Errorf("usage: odek memory extended pin <atom_id>")
113+
}
114+
id := subArgs[0]
115+
if err := em.PinAtom(id); err != nil {
116+
return err
117+
}
118+
fmt.Printf("odek: pinned atom %q\n", id)
119+
return nil
120+
99121
case "quarantine":
100122
atoms, err := em.ListQuarantine()
101123
if err != nil {
@@ -117,6 +139,6 @@ func extendedMemoryCmd(dir string, args []string) error {
117139
return nil
118140

119141
default:
120-
return fmt.Errorf("unknown extended memory subcommand %q (expected: forget, quarantine, compact)", sub)
142+
return fmt.Errorf("unknown extended memory subcommand %q (expected: forget, promote, pin, quarantine, compact)", sub)
121143
}
122144
}

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)