Skip to content

Commit 917114c

Browse files
committed
feat(extended-memory): config, loop, tool, and CLI wiring
1 parent c72fbd1 commit 917114c

7 files changed

Lines changed: 523 additions & 12 deletions

File tree

cmd/odek/main.go

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,12 @@ type runFlags struct {
272272
SandboxUser string // Container user (e.g. "1000:1000")
273273
SandboxReadonly *bool // nil = not set; true = read-only mount
274274

275+
// Extended memory subsystem CLI overrides.
276+
MemoryExtendedEnabled *bool // nil = not set
277+
MemoryExtendedMaxSizeMB int // 0 = not set
278+
MemoryExtendedAtomMaxChars int // 0 = not set
279+
MemoryExtendedMemoryBudgetChars int // 0 = not set
280+
275281
Deliver *bool // nil = not set; true = deliver result to default channel
276282
}
277283

@@ -403,6 +409,27 @@ func parseRunFlags(args []string) (runFlags, error) {
403409
}
404410
f.Ctx = strings.Split(args[i+1], ",")
405411
i += 2
412+
case "--memory-extended-enabled":
413+
f.MemoryExtendedEnabled = boolPtr(true)
414+
i++
415+
case "--memory-extended-max-size-mb":
416+
if i+1 >= len(args) {
417+
return f, fmt.Errorf("--memory-extended-max-size-mb requires a value")
418+
}
419+
fmt.Sscanf(args[i+1], "%d", &f.MemoryExtendedMaxSizeMB)
420+
i += 2
421+
case "--memory-extended-atom-max-chars":
422+
if i+1 >= len(args) {
423+
return f, fmt.Errorf("--memory-extended-atom-max-chars requires a value")
424+
}
425+
fmt.Sscanf(args[i+1], "%d", &f.MemoryExtendedAtomMaxChars)
426+
i += 2
427+
case "--memory-extended-memory-budget-chars":
428+
if i+1 >= len(args) {
429+
return f, fmt.Errorf("--memory-extended-memory-budget-chars requires a value")
430+
}
431+
fmt.Sscanf(args[i+1], "%d", &f.MemoryExtendedMemoryBudgetChars)
432+
i += 2
406433
case "--deliver":
407434
f.Deliver = boolPtr(true)
408435
i++
@@ -454,6 +481,10 @@ done:
454481
f.SandboxReadonly = boolPtr(true)
455482
taskArgs = append(taskArgs[:j], taskArgs[j+1:]...)
456483
j--
484+
case "--memory-extended-enabled":
485+
f.MemoryExtendedEnabled = boolPtr(true)
486+
taskArgs = append(taskArgs[:j], taskArgs[j+1:]...)
487+
j--
457488
}
458489
}
459490
f.Task = strings.Join(taskArgs, " ")
@@ -646,6 +677,12 @@ Sandbox flags:
646677
--sandbox-cpus <n> CPU limit (e.g. 0.5, 2, 4)
647678
--sandbox-user <s> Run as user (uid:gid or name)
648679
680+
Extended memory flags:
681+
--memory-extended-enabled Enable Extended Memory (opt-in)
682+
--memory-extended-max-size-mb <n> Max on-disk size in MiB (default: 100)
683+
--memory-extended-atom-max-chars <n> Max chars per atom (default: 300)
684+
--memory-extended-memory-budget-chars <n> Max chars injected into prompt (default: 2000)
685+
649686
Config sources (lowest to highest priority):
650687
~/.odek/config.json Global defaults (shared across projects)
651688
./odek.json Project-level overrides
@@ -669,7 +706,11 @@ Environment variables:
669706
ODEK_SANDBOX_READONLY true/false — mount read-only
670707
ODEK_SANDBOX_MEMORY Memory limit (e.g. 512m, 2g)
671708
ODEK_SANDBOX_CPUS CPU limit (e.g. 0.5, 2)
672-
ODEK_SANDBOX_USER Container user (uid:gid or name)`)
709+
ODEK_SANDBOX_USER Container user (uid:gid or name)
710+
ODEK_MEMORY_EXTENDED_ENABLED true/false — enable Extended Memory
711+
ODEK_MEMORY_EXTENDED_MAX_SIZE_MB Max on-disk size in MiB
712+
ODEK_MEMORY_EXTENDED_ATOM_MAX_CHARS Max chars per atom
713+
ODEK_MEMORY_EXTENDED_MEMORY_BUDGET_CHARS Max chars injected into prompt`)
673714
}
674715

675716
// ── Init ──────────────────────────────────────────────────────────────
@@ -867,6 +908,11 @@ func run(args []string) error {
867908
SandboxMemory: f.SandboxMemory,
868909
SandboxCPUs: f.SandboxCPUs,
869910
SandboxUser: f.SandboxUser,
911+
912+
MemoryExtendedEnabled: f.MemoryExtendedEnabled,
913+
MemoryExtendedMaxSizeMB: f.MemoryExtendedMaxSizeMB,
914+
MemoryExtendedAtomMaxChars: f.MemoryExtendedAtomMaxChars,
915+
MemoryExtendedMemoryBudgetChars: f.MemoryExtendedMemoryBudgetChars,
870916
})
871917

872918
// Resolve @references and --ctx file attachments in the task
@@ -1048,6 +1094,11 @@ func run(args []string) error {
10481094
}
10491095
store.Save(sess)
10501096
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.
1099+
if mm := agent.Memory(); mm != nil {
1100+
mm.SetSessionContext(sess.ID, "")
1101+
}
10511102
}
10521103
} else {
10531104
// Single-shot mode (default)
@@ -1932,6 +1983,12 @@ func continueCmd(args []string) error {
19321983
mm.RestoreBuffer(sess.Buffer)
19331984
}
19341985

1986+
// Propagate session context to Extended Memory so extracted atoms are
1987+
// tagged with the session they came from.
1988+
if mm := agent.Memory(); mm != nil {
1989+
mm.SetSessionContext(sess.ID, "")
1990+
}
1991+
19351992
// Build message history: session messages + new user message
19361993
// The system message is already in the session
19371994
messages := sess.GetMessages()

cmd/odek/memory_cmd.go

Lines changed: 59 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,61 @@ 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|quarantine|compact> [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+
em := extended.New(extDir, nil, cfg)
86+
87+
switch sub {
88+
case "forget":
89+
if len(subArgs) == 0 {
90+
return fmt.Errorf("usage: odek memory extended forget <atom_id>")
91+
}
92+
id := subArgs[0]
93+
if err := em.ForgetAtom(id); err != nil {
94+
return err
95+
}
96+
fmt.Printf("odek: forgot atom %q\n", id)
97+
return nil
98+
99+
case "quarantine":
100+
atoms, err := em.ListQuarantine()
101+
if err != nil {
102+
return err
103+
}
104+
if len(atoms) == 0 {
105+
fmt.Println("No atoms in quarantine.")
106+
return nil
107+
}
108+
fmt.Printf("%d atom(s) in quarantine (excluded from recall):\n\n", len(atoms))
109+
for _, a := range atoms {
110+
fmt.Printf("• %s [%s] %s\n", a.ID, a.SourceClass, truncate(a.Text, 120))
111+
}
112+
return nil
113+
114+
case "compact":
115+
em.Compact()
116+
fmt.Println("odek: Extended Memory vector index compaction triggered in the background")
117+
return nil
118+
63119
default:
64-
return fmt.Errorf("unknown memory subcommand %q (expected: list, promote)", sub)
120+
return fmt.Errorf("unknown extended memory subcommand %q (expected: forget, quarantine, compact)", sub)
65121
}
66122
}

internal/config/loader.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"github.com/BackendStack21/odek/internal/embedding"
2828
"github.com/BackendStack21/odek/internal/mcpclient"
2929
"github.com/BackendStack21/odek/internal/memory"
30+
"github.com/BackendStack21/odek/internal/memory/extended"
3031
"github.com/BackendStack21/odek/internal/redact"
3132
"github.com/BackendStack21/odek/internal/skills"
3233
"github.com/BackendStack21/odek/internal/telegram"
@@ -83,6 +84,12 @@ type CLIFlags struct {
8384
// "verbose" = raw tool names, args, and results.
8485
// "off" = no intermediate progress output, clean answer only.
8586
InteractionMode string
87+
88+
// Extended memory subsystem CLI overrides.
89+
MemoryExtendedEnabled *bool // nil = not set
90+
MemoryExtendedMaxSizeMB int // 0 = not set
91+
MemoryExtendedAtomMaxChars int // 0 = not set
92+
MemoryExtendedMemoryBudgetChars int // 0 = not set
8693
}
8794

8895
// SkillsConfig holds the skills configuration section from JSON files.
@@ -627,6 +634,14 @@ func envStringList(key string) []string {
627634
return out
628635
}
629636

637+
// ensureExtended returns a non-nil *extended.Config, allocating one if needed.
638+
func ensureExtended(cfg *extended.Config) *extended.Config {
639+
if cfg == nil {
640+
return &extended.Config{}
641+
}
642+
return cfg
643+
}
644+
630645
// envScheduleDangerousConfig parses ODEK_SCHEDULES_DANGEROUS_* env vars into a
631646
// DangerousConfig. Returns nil if none are set.
632647
func envScheduleDangerousConfig(prefix string) *danger.DangerousConfig {
@@ -872,6 +887,36 @@ func LoadConfig(cli CLIFlags) ResolvedConfig {
872887
cfg.InteractionMode = v
873888
}
874889

890+
// Extended memory env overrides
891+
if v := envBool("MEMORY_EXTENDED_ENABLED"); v != nil {
892+
if cfg.Memory == nil {
893+
cfg.Memory = &memory.MemoryConfig{}
894+
}
895+
cfg.Memory.Extended = ensureExtended(cfg.Memory.Extended)
896+
cfg.Memory.Extended.Enabled = v
897+
}
898+
if v := envInt("MEMORY_EXTENDED_MAX_SIZE_MB"); v > 0 {
899+
if cfg.Memory == nil {
900+
cfg.Memory = &memory.MemoryConfig{}
901+
}
902+
cfg.Memory.Extended = ensureExtended(cfg.Memory.Extended)
903+
cfg.Memory.Extended.MaxSizeMB = v
904+
}
905+
if v := envInt("MEMORY_EXTENDED_ATOM_MAX_CHARS"); v > 0 {
906+
if cfg.Memory == nil {
907+
cfg.Memory = &memory.MemoryConfig{}
908+
}
909+
cfg.Memory.Extended = ensureExtended(cfg.Memory.Extended)
910+
cfg.Memory.Extended.AtomMaxChars = v
911+
}
912+
if v := envInt("MEMORY_EXTENDED_MEMORY_BUDGET_CHARS"); v > 0 {
913+
if cfg.Memory == nil {
914+
cfg.Memory = &memory.MemoryConfig{}
915+
}
916+
cfg.Memory.Extended = ensureExtended(cfg.Memory.Extended)
917+
cfg.Memory.Extended.MemoryBudgetChars = v
918+
}
919+
875920
// Schedules env overrides (ODEK_SCHEDULES_*): lets the scheduler be tuned
876921
// from the environment, like everything else in a containerised deploy.
877922
// Allocate once — an all-zero SchedulesConfig resolves identically to nil.
@@ -983,6 +1028,34 @@ func LoadConfig(cli CLIFlags) ResolvedConfig {
9831028
if cli.InteractionMode != "" {
9841029
cfg.InteractionMode = cli.InteractionMode
9851030
}
1031+
if cli.MemoryExtendedEnabled != nil {
1032+
if cfg.Memory == nil {
1033+
cfg.Memory = &memory.MemoryConfig{}
1034+
}
1035+
cfg.Memory.Extended = ensureExtended(cfg.Memory.Extended)
1036+
cfg.Memory.Extended.Enabled = cli.MemoryExtendedEnabled
1037+
}
1038+
if cli.MemoryExtendedMaxSizeMB > 0 {
1039+
if cfg.Memory == nil {
1040+
cfg.Memory = &memory.MemoryConfig{}
1041+
}
1042+
cfg.Memory.Extended = ensureExtended(cfg.Memory.Extended)
1043+
cfg.Memory.Extended.MaxSizeMB = cli.MemoryExtendedMaxSizeMB
1044+
}
1045+
if cli.MemoryExtendedAtomMaxChars > 0 {
1046+
if cfg.Memory == nil {
1047+
cfg.Memory = &memory.MemoryConfig{}
1048+
}
1049+
cfg.Memory.Extended = ensureExtended(cfg.Memory.Extended)
1050+
cfg.Memory.Extended.AtomMaxChars = cli.MemoryExtendedAtomMaxChars
1051+
}
1052+
if cli.MemoryExtendedMemoryBudgetChars > 0 {
1053+
if cfg.Memory == nil {
1054+
cfg.Memory = &memory.MemoryConfig{}
1055+
}
1056+
cfg.Memory.Extended = ensureExtended(cfg.Memory.Extended)
1057+
cfg.Memory.Extended.MemoryBudgetChars = cli.MemoryExtendedMemoryBudgetChars
1058+
}
9861059
if len(cli.ToolsEnabled) > 0 {
9871060
if cfg.Tools == nil {
9881061
cfg.Tools = &ToolsConfig{}
@@ -1292,6 +1365,10 @@ func resolveMemory(cfg *memory.MemoryConfig) memory.MemoryConfig {
12921365
if cfg.Embedding != nil {
12931366
def.Embedding = cfg.Embedding
12941367
}
1368+
if cfg.Extended != nil {
1369+
resolved := extended.Resolve(*cfg.Extended)
1370+
def.Extended = &resolved
1371+
}
12951372
return def
12961373
}
12971374

internal/config/loader_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1414,3 +1414,74 @@ func TestLoadConfig_SecretsEnvPermissionCheck(t *testing.T) {
14141414
t.Errorf("owner-only secrets.env not loaded, got %q", os.Getenv("ODEK_TEST_SECRET"))
14151415
}
14161416
}
1417+
1418+
func TestLoadConfig_ExtendedMemoryEnv(t *testing.T) {
1419+
t.Setenv("HOME", t.TempDir())
1420+
t.Setenv("ODEK_MEMORY_EXTENDED_ENABLED", "true")
1421+
t.Setenv("ODEK_MEMORY_EXTENDED_MAX_SIZE_MB", "200")
1422+
t.Setenv("ODEK_MEMORY_EXTENDED_ATOM_MAX_CHARS", "500")
1423+
t.Setenv("ODEK_MEMORY_EXTENDED_MEMORY_BUDGET_CHARS", "4000")
1424+
cfg := LoadConfig(CLIFlags{})
1425+
if cfg.Memory.Extended == nil {
1426+
t.Fatal("Extended memory config not loaded from env")
1427+
}
1428+
if cfg.Memory.Extended.Enabled == nil || !*cfg.Memory.Extended.Enabled {
1429+
t.Error("Extended memory should be enabled")
1430+
}
1431+
if cfg.Memory.Extended.MaxSizeMB != 200 {
1432+
t.Errorf("MaxSizeMB = %d, want 200", cfg.Memory.Extended.MaxSizeMB)
1433+
}
1434+
if cfg.Memory.Extended.AtomMaxChars != 500 {
1435+
t.Errorf("AtomMaxChars = %d, want 500", cfg.Memory.Extended.AtomMaxChars)
1436+
}
1437+
if cfg.Memory.Extended.MemoryBudgetChars != 4000 {
1438+
t.Errorf("MemoryBudgetChars = %d, want 4000", cfg.Memory.Extended.MemoryBudgetChars)
1439+
}
1440+
}
1441+
1442+
func TestLoadConfig_ExtendedMemoryCLIOverridesEnv(t *testing.T) {
1443+
t.Setenv("HOME", t.TempDir())
1444+
t.Setenv("ODEK_MEMORY_EXTENDED_MAX_SIZE_MB", "200")
1445+
cfg := LoadConfig(CLIFlags{
1446+
MemoryExtendedEnabled: boolPtr(true),
1447+
MemoryExtendedMaxSizeMB: 300,
1448+
MemoryExtendedAtomMaxChars: 600,
1449+
MemoryExtendedMemoryBudgetChars: 5000,
1450+
})
1451+
if cfg.Memory.Extended == nil {
1452+
t.Fatal("Extended memory config not resolved")
1453+
}
1454+
if !*cfg.Memory.Extended.Enabled {
1455+
t.Error("Extended memory should be enabled")
1456+
}
1457+
if cfg.Memory.Extended.MaxSizeMB != 300 {
1458+
t.Errorf("MaxSizeMB = %d, want 300", cfg.Memory.Extended.MaxSizeMB)
1459+
}
1460+
if cfg.Memory.Extended.AtomMaxChars != 600 {
1461+
t.Errorf("AtomMaxChars = %d, want 600", cfg.Memory.Extended.AtomMaxChars)
1462+
}
1463+
if cfg.Memory.Extended.MemoryBudgetChars != 5000 {
1464+
t.Errorf("MemoryBudgetChars = %d, want 5000", cfg.Memory.Extended.MemoryBudgetChars)
1465+
}
1466+
}
1467+
1468+
func TestLoadConfig_ProjectMemoryRejected(t *testing.T) {
1469+
wd := t.TempDir()
1470+
t.Setenv("HOME", t.TempDir())
1471+
t.Setenv("ODEK_MEMORY_EXTENDED_ENABLED", "true")
1472+
if err := os.WriteFile(filepath.Join(wd, "odek.json"), []byte(`{"memory":{"extended":{"enabled":false,"max_size_mb":50}}}`), 0600); err != nil {
1473+
t.Fatal(err)
1474+
}
1475+
t.Setenv("PWD", wd)
1476+
origGetwd, _ := os.Getwd()
1477+
os.Chdir(wd)
1478+
defer os.Chdir(origGetwd)
1479+
cfg := LoadConfig(CLIFlags{})
1480+
// Project memory should be rejected, so env-true wins.
1481+
if cfg.Memory.Extended == nil || cfg.Memory.Extended.Enabled == nil || !*cfg.Memory.Extended.Enabled {
1482+
t.Error("project memory should be rejected; env enabled should win")
1483+
}
1484+
if cfg.Memory.Extended.MaxSizeMB != 100 {
1485+
t.Errorf("MaxSizeMB = %d, want default 100 (project rejected)", cfg.Memory.Extended.MaxSizeMB)
1486+
}
1487+
}

0 commit comments

Comments
 (0)