Skip to content

Commit f2a1dd6

Browse files
committed
feat: persistent memory system with go-vector merge-on-write
Three-tier memory: - Tier 1: Facts (user.md/env.md) with caps, security scan, agent-managed - Tier 2: Buffer ring per session for turn-level context - Tier 3: Episode summaries with search via SimpleCall Merge-on-write uses go-vector RandomProjections for fast similarity: cos>0.7 auto-merge, cos<0.3 auto-add, 0.3-0.7 LLM judgment Saves ~80% LLM calls on memory writes. 57 tests, race clean, zero new dependencies beyond go-vector (also zero-dep).
1 parent d740ed7 commit f2a1dd6

24 files changed

Lines changed: 2890 additions & 13 deletions

cmd/kode/repl.go

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,12 +123,20 @@ func replCmd(args []string) error {
123123
Renderer: rend,
124124
Skills: skillsCfg,
125125
SkillManager: sm,
126+
MemoryConfig: resolved.Memory,
126127
})
127128
if err != nil {
128129
return err
129130
}
130131
defer agent.Close()
131132

133+
// Restore buffer from session if resuming
134+
if sess != nil && len(sess.Buffer) > 0 {
135+
if mm := agent.Memory(); mm != nil {
136+
mm.RestoreBuffer(sess.Buffer)
137+
}
138+
}
139+
132140
// Create session if not resuming one
133141
if sess == nil {
134142
sess, err = store.Create(
@@ -176,6 +184,15 @@ func replCmd(args []string) error {
176184
origLen := len(messages)
177185
messages = append(messages, llm.Message{Role: "user", Content: input})
178186

187+
// Append user input to buffer
188+
if mm := agent.Memory(); mm != nil {
189+
userSummary := input
190+
if len(userSummary) > 100 {
191+
userSummary = userSummary[:97] + "..."
192+
}
193+
mm.AppendBuffer("user", userSummary)
194+
}
195+
179196
// Run agent with full history
180197
rend.Start(input)
181198
_, allMessages, err := agent.RunWithMessages(ctx, messages)
@@ -184,18 +201,46 @@ func replCmd(args []string) error {
184201
continue
185202
}
186203

204+
// Append agent response to buffer
205+
if mm := agent.Memory(); mm != nil && len(allMessages) > 0 {
206+
if last := allMessages[len(allMessages)-1]; last.Role == "assistant" {
207+
summary := last.Content
208+
if len(summary) > 100 {
209+
summary = summary[:97] + "..."
210+
}
211+
mm.AppendBuffer("agent", summary)
212+
}
213+
}
214+
187215
// Save new messages to session
188216
newMsgs := allMessages[origLen:]
189217
if err := store.Append(sess.ID, newMsgs); err != nil {
190218
fmt.Fprintf(os.Stderr, "kode: save error: %v\n", err)
191219
}
192220

193-
// Reload session to get updated turn count
221+
// Reload session to get updated turn count + persist buffer
194222
sess, _ = store.Load(sess.ID)
223+
if sess != nil {
224+
if mm := agent.Memory(); mm != nil {
225+
sess.Buffer = mm.GetBuffer()
226+
store.Save(sess)
227+
}
228+
}
195229
turn++
196230

197231
fmt.Fprintln(os.Stderr)
198232
}
233+
234+
// Session end — extract episode if enough turns
235+
if mm := agent.Memory(); mm != nil {
236+
messages := sess.GetMessages()
237+
msgStrs := make([]string, 0, len(messages))
238+
for _, m := range messages {
239+
msgStrs = append(msgStrs, m.Role+": "+m.Content)
240+
}
241+
mm.OnSessionEnd(sess.ID, sess.Turns, msgStrs)
242+
}
243+
199244
return nil
200245
}
201246

cmd/kode/serve.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ func newServeAgent(resolved config.ResolvedConfig, system string) (*kode.Agent,
142142
Renderer: nil, // silent — we stream via WebSocket
143143
Skills: &resolved.Skills,
144144
SkillManager: sm,
145+
MemoryConfig: resolved.Memory,
145146
})
146147
if err != nil {
147148
return nil, nil, err

cmd/kode/subagent.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,7 @@ func subagentCmd(args []string) error {
335335
Renderer: rend,
336336
Skills: &resolved.Skills,
337337
SkillManager: sm,
338+
MemoryConfig: resolved.Memory,
338339
})
339340
if err != nil {
340341
return fmt.Errorf("create agent: %w", err)

docs/MEMORY.md

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# Memory System
2+
3+
kode has a **three-tier file-based memory** system. Zero external dependencies beyond go-vector (which is also zero-dep).
4+
5+
## Three Tiers
6+
7+
```
8+
~/.kode/memory/
9+
├── facts/
10+
│ ├── user.md ← Global user profile (1,500 chars)
11+
│ └── env.md ← Global environment facts (2,500 chars)
12+
├── project-facts/ ← Optional per-project overrides (auto-layered)
13+
│ └── <path-hash>/
14+
│ ├── user.md
15+
│ └── env.md
16+
└── episodes/
17+
├── <session-id>.md ← LLM-extracted summaries
18+
└── index.json ← Metadata for search
19+
```
20+
21+
### Tier 1 — Facts (in system prompt)
22+
23+
Two typed files, injected as frozen snapshot at session start. Managed by the agent via the `memory` tool.
24+
25+
| Target | File | Cap | Purpose |
26+
|--------|------|-----|---------|
27+
| `user` | `facts/user.md` | 1,500 | User preferences, style, pet peeves |
28+
| `env` | `facts/env.md` | 2,500 | OS, tools, conventions, architecture |
29+
30+
**Frozen snapshot:** Loaded once at agent start into the system prompt. Live writes via the `memory` tool persist to disk immediately but appear in the prompt next session. This preserves LLM prefix caching.
31+
32+
### Tier 2 — Buffer (in session)
33+
34+
Not a file. Lives in `Session.Buffer []string` — a ring buffer capped at 20 lines. The loop engine appends a one-line summary after each turn:
35+
36+
```
37+
HH:MM user "fix TOCTOU race"
38+
HH:MM agent read file_tool.go, wrote security_e2e_test.go
39+
HH:MM agent pushed 19 tests, tagged v0.8.19
40+
```
41+
42+
- Injected into system prompt only when non-empty.
43+
- Preserved across `kode continue` (serialized in session JSON).
44+
- Oldest evicted when cap reached.
45+
46+
### Tier 3 — Episodes (on-disk, searchable)
47+
48+
After sessions with ≥3 turns, the MemoryManager runs SimpleCall to extract 1-3 durable facts. Written to `episodes/<session-id>.md`. Searchable via `memory(search=...)` which uses SimpleCall to rank episodes by relevance to the query.
49+
50+
## Memory Tool — Unified API
51+
52+
```json
53+
{
54+
"name": "memory",
55+
"description": "Manage persistent memory across sessions.",
56+
"parameters": {
57+
"action": { "enum": ["add", "replace", "remove", "consolidate", "read", "search"] },
58+
"target": { "enum": ["user", "env"], "description": "For add/replace/remove/consolidate" },
59+
"content": { "type": "string", "description": "For add/replace" },
60+
"old_text": { "type": "string", "description": "Unique substring for replace/remove" },
61+
"query": { "type": "string", "description": "For search — facts + episodes" }
62+
}
63+
}
64+
```
65+
66+
### Actions
67+
68+
| Action | Target | Content | old_text | Effect |
69+
|--------|--------|---------|----------|--------|
70+
| `add` | user/env | ✅ new entry || Appends to file. Check: dedup + cap + merge |
71+
| `replace` | user/env | ✅ replacement | ✅ substring | Finds entry by substring, replaces it |
72+
| `remove` | user/env || ✅ substring | Finds entry by substring, removes it |
73+
| `consolidate` | user/env ||| SimpleCall: merge related entries for density |
74+
| `read` |||| Returns full content of both user.md + env.md |
75+
| `search` ||| ✅ query | SimpleCall: rank episodes + facts by relevance |
76+
77+
## Merge-on-Write (go-vector Integration)
78+
79+
When adding a fact, a **two-tier merge detector** classifies the new entry:
80+
81+
```
82+
RP.embed(newEntry) → cos similarity vs each existing entry
83+
84+
cos > 0.7 ──────────────────→ auto-merge (replace old + new)
85+
cos < 0.3 ──────────────────→ auto-add (no conflict)
86+
0.3 ≤ cos ≤ 0.7 ──→ SimpleCall judgment → merge or add
87+
```
88+
89+
This saves ~80% of LLM calls on memory writes.
90+
91+
**Implementation:** `internal/memory/merge.go` imports `github.com/BackendStack21/go-vector/pkg/vector` for `RandomProjections` and `Cosine`. The RP embedder is fit on existing facts when the detector is created, and re-fit whenever facts change.
92+
93+
### Cold Start
94+
95+
When `facts/user.md` and `facts/env.md` are empty (fresh install), no RP vocabulary exists. The merge detector gracefully returns "no corpus" and all adds pass through to SimpleCall. After the first few facts are written, the detector self-trains on re-fit.
96+
97+
## Subagent Memory
98+
99+
Subagents (separate OS processes via `kode subagent`) inherit a **read-only snapshot** of facts:
100+
101+
```
102+
kode subagent --memory-snapshot /tmp/kode-mem-<rand>.json
103+
```
104+
105+
The subagent's system prompt includes:
106+
```
107+
# Memory Context (read-only)
108+
── User Profile ──
109+
... (facts/user.md)
110+
── Environment ──
111+
... (facts/env.md)
112+
```
113+
114+
Subagents do NOT get a `memory` tool — they cannot modify parent memory.
115+
116+
## Config
117+
118+
```json
119+
{
120+
"memory": {
121+
"enabled": true,
122+
"facts_limit_user": 1500,
123+
"facts_limit_env": 2500,
124+
"buffer_lines": 20,
125+
"buffer_enabled": true,
126+
"merge_on_write": true,
127+
"extract_threshold": 3,
128+
"project_facts": false
129+
}
130+
}
131+
```
132+
133+
## Security
134+
135+
All memory content is scanned on write for:
136+
- **Invisible Unicode** (zero-width spaces, RTL override, etc.)
137+
- **Injection patterns** (prompt injection markers)
138+
- **Credential patterns** (`sk-...`, `-----BEGIN`, bearer tokens)
139+
140+
Rejected content returns an error to the agent.

go.mod

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
module github.com/BackendStack21/kode
22

3-
go 1.22
3+
go 1.24.3
4+
5+
require github.com/BackendStack21/go-vector v1.1.1 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
github.com/BackendStack21/go-vector v1.1.1 h1:sycI+a/ifT2DD3kdH0HleWtjKmIVNutvL/pgOL9qTA8=
2+
github.com/BackendStack21/go-vector v1.1.1/go.mod h1:+IzfAFO4m6xrjsOhZsiTAbMbm8+hX0d9C1uD0SGPzHc=

internal/config/loader.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"strconv"
2020

2121
"github.com/BackendStack21/kode/internal/danger"
22+
"github.com/BackendStack21/kode/internal/memory"
2223
"github.com/BackendStack21/kode/internal/skills"
2324
)
2425

@@ -96,6 +97,9 @@ type FileConfig struct {
9697

9798
// Skills section (see internal/skills package).
9899
Skills *SkillsConfig `json:"skills,omitempty"`
100+
101+
// Memory section controls the persistent memory system.
102+
Memory *memory.MemoryConfig `json:"memory,omitempty"`
99103
}
100104

101105
// ResolvedConfig is the fully merged result. Every field has a concrete
@@ -158,6 +162,9 @@ type ResolvedConfig struct {
158162

159163
// Skills is the resolved skills config with default values.
160164
Skills skills.SkillsConfig
165+
166+
// Memory is the resolved memory config with default values.
167+
Memory memory.MemoryConfig
161168
}
162169

163170
// ── Defaults ───────────────────────────────────────────────────────────
@@ -406,6 +413,7 @@ func LoadConfig(cli CLIFlags) ResolvedConfig {
406413
SandboxVolumes: cfg.SandboxVolumes,
407414
Skills: resolveSkills(cfg.Skills),
408415
Dangerous: resolveDangerous(cfg.Dangerous),
416+
Memory: resolveMemory(cfg.Memory),
409417
}
410418

411419
// Booleans: default to false if not set
@@ -486,6 +494,14 @@ func resolveDangerous(cfg *danger.DangerousConfig) danger.DangerousConfig {
486494
return danger.DangerousConfig{}
487495
}
488496

497+
// resolveMemory merges file-level memory config with defaults.
498+
func resolveMemory(cfg *memory.MemoryConfig) memory.MemoryConfig {
499+
if cfg != nil {
500+
return *cfg
501+
}
502+
return memory.DefaultMemoryConfig()
503+
}
504+
489505
// overlayFile overlays a higher-priority FileConfig onto a lower-priority one.
490506
// Only fields that are explicitly set (non-zero for scalars, non-nil for
491507
// pointers) override the base value.

internal/memory/buffer.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package memory
2+
3+
import (
4+
"fmt"
5+
"time"
6+
)
7+
8+
// Buffer is a simple ring buffer for session-level turn summaries.
9+
// Thread-safe only if accessed from a single goroutine (the loop engine
10+
// owns the buffer and serializes all access).
11+
type Buffer struct {
12+
lines []string
13+
cap int
14+
}
15+
16+
// NewBuffer creates a ring buffer with the given capacity. cap=0 means
17+
// disabled (all appends are silently discarded).
18+
func NewBuffer(cap int) *Buffer {
19+
if cap < 0 {
20+
cap = 0
21+
}
22+
return &Buffer{
23+
lines: make([]string, 0, cap),
24+
cap: cap,
25+
}
26+
}
27+
28+
// Append adds a line to the buffer. If the buffer is at capacity, the
29+
// oldest line is evicted first.
30+
func (b *Buffer) Append(line string) {
31+
if b.cap <= 0 {
32+
return
33+
}
34+
if len(b.lines) >= b.cap {
35+
// Evict oldest
36+
b.lines = b.lines[1:]
37+
}
38+
// Sanitize: strip newlines from the line
39+
line = sanitizeLine(line)
40+
b.lines = append(b.lines, line)
41+
}
42+
43+
// Lines returns a copy of the current buffer contents (oldest first).
44+
func (b *Buffer) Lines() []string {
45+
out := make([]string, len(b.lines))
46+
copy(out, b.lines)
47+
return out
48+
}
49+
50+
// Clear removes all lines from the buffer.
51+
func (b *Buffer) Clear() {
52+
b.lines = make([]string, 0, b.cap)
53+
}
54+
55+
// Cap returns the maximum number of lines.
56+
func (b *Buffer) Cap() int { return b.cap }
57+
58+
// Len returns the current number of lines.
59+
func (b *Buffer) Len() int { return len(b.lines) }
60+
61+
// sanitizeLine removes newlines, tabs, and trims whitespace.
62+
func sanitizeLine(line string) string {
63+
// Remove newlines and tabs
64+
result := make([]byte, 0, len(line))
65+
for i := 0; i < len(line); i++ {
66+
c := line[i]
67+
if c == '\n' || c == '\r' || c == '\t' {
68+
result = append(result, ' ')
69+
} else {
70+
result = append(result, c)
71+
}
72+
}
73+
return fmt.Sprintf("%s", string(result))
74+
}
75+
76+
// FormatBufferLine creates a timestamped buffer line.
77+
// Format: "HH:MM role message"
78+
func FormatBufferLine(role, message string) string {
79+
now := time.Now().UTC().Format("15:04")
80+
return fmt.Sprintf("%s %s %s", now, role, message)
81+
}

0 commit comments

Comments
 (0)