Skip to content

Commit 8afd32e

Browse files
jkyberneeesclaude
andcommitted
feat(memory): deterministic buffer turn-summaries (no LLM)
The tier-2 buffer is injected into the system prompt every turn but each entry was a naive truncation of raw text (message[:97]+"..." or shorten(s,100)) — a byte slice that can split a UTF-8 rune and usually captures only filler ("Sure, I'll help. Let me start by..."), leaving the buffer with almost no recall signal. Centralize summarization in MemoryManager.AppendBuffer via a new deterministic, no-LLM, rune-safe summarizeForBuffer: strip fenced/inline code, conservative markdown, collapse whitespace, drop a leading filler clause (only when substance remains), and excerpt to 200 runes at a sentence/word boundary. All 8 call sites now pass raw text. Placement is load-bearing: summarization lives in AppendBuffer, not Buffer.Append, so RestoreBuffer (which calls Buffer.Append directly with already-summarized lines) never re-processes persisted summaries. Old session buffers restore verbatim — no migration. shorten() is retained for its non-buffer callers (session labels, tool-result display). Tests: table-driven heuristic cases (code/markdown/filler/multibyte/ hard-cut), an AppendBuffer no-mid-word-cut regression, and a verbatim RestoreBuffer guard for the invariant. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ad0be26 commit 8afd32e

7 files changed

Lines changed: 317 additions & 24 deletions

File tree

cmd/odek/main.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -877,9 +877,9 @@ func run(args []string) error {
877877
messages = append([]llm.Message{{Role: "system", Content: systemMessage}}, messages...)
878878
}
879879

880-
// Append user input to buffer
880+
// Append user input to buffer (AppendBuffer summarizes raw text).
881881
if mm := agent.Memory(); mm != nil {
882-
mm.AppendBuffer("user", shorten(f.Task, 100))
882+
mm.AppendBuffer("user", f.Task)
883883
}
884884

885885
result, allMessages, runErr = agent.RunWithMessages(ctx, messages)
@@ -889,7 +889,7 @@ func run(args []string) error {
889889
if mm := agent.Memory(); mm != nil {
890890
for i := len(allMessages) - 1; i >= 0; i-- {
891891
if allMessages[i].Role == "assistant" && allMessages[i].Content != "" {
892-
mm.AppendBuffer("agent", shorten(allMessages[i].Content, 100))
892+
mm.AppendBuffer("agent", allMessages[i].Content)
893893
break
894894
}
895895
}
@@ -1675,9 +1675,9 @@ func continueCmd(args []string) error {
16751675
messages := sess.GetMessages()
16761676
messages = append(messages, llm.Message{Role: "user", Content: task})
16771677

1678-
// Append user input to buffer
1678+
// Append user input to buffer (AppendBuffer summarizes raw text).
16791679
if mm := agent.Memory(); mm != nil {
1680-
mm.AppendBuffer("user", shorten(task, 100))
1680+
mm.AppendBuffer("user", task)
16811681
}
16821682

16831683
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
@@ -1710,7 +1710,7 @@ func continueCmd(args []string) error {
17101710
if mm := agent.Memory(); mm != nil {
17111711
for i := len(allMessages) - 1; i >= 0; i-- {
17121712
if allMessages[i].Role == "assistant" && allMessages[i].Content != "" {
1713-
mm.AppendBuffer("agent", shorten(allMessages[i].Content, 100))
1713+
mm.AppendBuffer("agent", allMessages[i].Content)
17141714
break
17151715
}
17161716
}

cmd/odek/repl.go

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -224,13 +224,9 @@ func replCmd(args []string) error {
224224
origLen := len(messages)
225225
messages = append(messages, llm.Message{Role: "user", Content: input})
226226

227-
// Append user input to buffer
227+
// Append user input to buffer (AppendBuffer summarizes raw text).
228228
if mm := agent.Memory(); mm != nil {
229-
userSummary := input
230-
if len(userSummary) > 100 {
231-
userSummary = userSummary[:97] + "..."
232-
}
233-
mm.AppendBuffer("user", userSummary)
229+
mm.AppendBuffer("user", input)
234230
}
235231

236232
// Run agent with full history
@@ -241,14 +237,10 @@ func replCmd(args []string) error {
241237
continue
242238
}
243239

244-
// Append agent response to buffer
240+
// Append agent response to buffer (AppendBuffer summarizes raw text).
245241
if mm := agent.Memory(); mm != nil && len(allMessages) > 0 {
246242
if last := allMessages[len(allMessages)-1]; last.Role == "assistant" {
247-
summary := last.Content
248-
if len(summary) > 100 {
249-
summary = summary[:97] + "..."
250-
}
251-
mm.AppendBuffer("agent", summary)
243+
mm.AppendBuffer("agent", last.Content)
252244
}
253245
}
254246

cmd/odek/serve.go

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -652,10 +652,9 @@ func handlePrompt(
652652
}
653653
writeWSJSON(conn, map[string]any{"type": "session", "session_id": sid, "model": resolved.Model, "sandbox": resolved.Sandbox})
654654

655-
// Append user input to buffer
655+
// Append user input to buffer (AppendBuffer summarizes raw text).
656656
if mm := agent.Memory(); mm != nil {
657-
userSummary := shorten(prompt, 100)
658-
mm.AppendBuffer("user", userSummary)
657+
mm.AppendBuffer("user", prompt)
659658
}
660659

661660
// Run agent. Audit recorder wired around the loop so every
@@ -753,8 +752,7 @@ func handlePrompt(
753752
if mm := agent.Memory(); mm != nil {
754753
for _, msg := range newMsgs {
755754
if msg.Role == "assistant" && msg.Content != "" {
756-
agentSummary := shorten(msg.Content, 100)
757-
mm.AppendBuffer("agent", agentSummary)
755+
mm.AppendBuffer("agent", msg.Content)
758756
break
759757
}
760758
}

internal/memory/memory.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -449,11 +449,18 @@ Entries for %s:
449449

450450
// ── Buffer Operations ────────────────────────────────────────────────
451451

452-
// AppendBuffer adds a turn summary to the in-memory ring buffer.
452+
// AppendBuffer adds a turn summary to the in-memory ring buffer. Callers pass
453+
// the RAW turn text; summarizeForBuffer derives a clean, bounded excerpt here so
454+
// every entry point shares one policy.
455+
//
456+
// Invariant: summarization lives here, not in Buffer.Append, so that
457+
// RestoreBuffer (which calls Buffer.Append directly with already-formatted,
458+
// already-summarized lines) never re-processes persisted summaries.
453459
func (m *MemoryManager) AppendBuffer(role, message string) {
454460
if m.cfg.BufferEnabled == nil || !*m.cfg.BufferEnabled {
455461
return
456462
}
463+
message = summarizeForBuffer(message)
457464
line := FormatBufferLine(role, message)
458465
m.buffer.Append(line)
459466
m.markPromptDirty()
@@ -468,6 +475,10 @@ func (m *MemoryManager) GetBuffer() []string {
468475
}
469476

470477
// RestoreBuffer loads buffer lines from a saved slice (e.g., from session).
478+
//
479+
// Invariant: these lines are already-formatted, already-summarized buffer
480+
// entries. They go straight to Buffer.Append and MUST NOT be routed through
481+
// AppendBuffer/summarizeForBuffer, which would corrupt the persisted summaries.
471482
func (m *MemoryManager) RestoreBuffer(lines []string) {
472483
if m.cfg.BufferEnabled == nil || !*m.cfg.BufferEnabled {
473484
return

internal/memory/memory_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"os"
66
"strings"
77
"testing"
8+
"unicode/utf8"
89
)
910

1011
// mockLLM is a simple LLMClient mock for testing.
@@ -149,6 +150,72 @@ func TestMemoryManagerBufferRestore(t *testing.T) {
149150
}
150151
}
151152

153+
// bufferMessage extracts the message portion of a formatted buffer line
154+
// ("HH:MM role message").
155+
func bufferMessage(t *testing.T, line string) string {
156+
t.Helper()
157+
parts := strings.SplitN(line, " ", 3)
158+
if len(parts) != 3 {
159+
t.Fatalf("unexpected buffer line format: %q", line)
160+
}
161+
return parts[2]
162+
}
163+
164+
// TestAppendBufferCleansAndDoesNotMidWordCut verifies that AppendBuffer routes
165+
// raw text through summarizeForBuffer: code/markdown noise is stripped and the
166+
// excerpt is bounded and rune-safe (no mid-word/mid-rune chop).
167+
func TestAppendBufferCleansAndDoesNotMidWordCut(t *testing.T) {
168+
dir := t.TempDir()
169+
mm := NewMemoryManager(dir, nil, DefaultMemoryConfig())
170+
171+
raw := "Sure, I'll help with that.\n\n```go\nfunc main() {}\n```\n" +
172+
strings.Repeat("Then we verify the behavior carefully. ", 30)
173+
mm.AppendBuffer("agent", raw)
174+
175+
lines := mm.GetBuffer()
176+
if len(lines) != 1 {
177+
t.Fatalf("expected 1 buffer line, got %d", len(lines))
178+
}
179+
msg := bufferMessage(t, lines[0])
180+
181+
if strings.Contains(msg, "\n") {
182+
t.Errorf("buffer message contains a newline: %q", msg)
183+
}
184+
if strings.Contains(msg, "```") {
185+
t.Errorf("buffer message still contains a code fence: %q", msg)
186+
}
187+
if !utf8.ValidString(msg) {
188+
t.Errorf("buffer message is not valid UTF-8: %q", msg)
189+
}
190+
if n := utf8.RuneCountInString(msg); n > maxBufferSummaryRunes+1 {
191+
t.Errorf("buffer message rune count %d exceeds cap %d (+1)", n, maxBufferSummaryRunes)
192+
}
193+
}
194+
195+
// TestRestoreBufferPreservesLinesVerbatim guards the load-bearing invariant:
196+
// RestoreBuffer must NOT re-summarize. It includes a line whose content would be
197+
// mangled if it were routed through summarizeForBuffer.
198+
func TestRestoreBufferPreservesLinesVerbatim(t *testing.T) {
199+
dir := t.TempDir()
200+
mm := NewMemoryManager(dir, nil, DefaultMemoryConfig())
201+
202+
saved := []string{
203+
"14:00 user first turn",
204+
"14:01 agent Sure, I'll help. ```code``` # heading",
205+
}
206+
mm.RestoreBuffer(saved)
207+
208+
lines := mm.GetBuffer()
209+
if len(lines) != len(saved) {
210+
t.Fatalf("expected %d lines, got %d", len(saved), len(lines))
211+
}
212+
for i := range saved {
213+
if lines[i] != saved[i] {
214+
t.Errorf("line %d not verbatim:\n got %q\n want %q", i, lines[i], saved[i])
215+
}
216+
}
217+
}
218+
152219
func TestMemoryManagerBuildSystemPrompt(t *testing.T) {
153220
dir := t.TempDir()
154221
mm := NewMemoryManager(dir, nil, DefaultMemoryConfig())

internal/memory/summarize.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package memory
2+
3+
import (
4+
"regexp"
5+
"strings"
6+
"unicode/utf8"
7+
)
8+
9+
// maxBufferSummaryRunes bounds the length of a single buffer turn-summary.
10+
// Raised from the old ~100-byte cut so that, after filler-stripping, one or two
11+
// substantive sentences survive. Total injected ≈ BufferLines × this; at the
12+
// default 20 lines that is ~4 KB worst case — negligible against the prompt.
13+
const maxBufferSummaryRunes = 200
14+
15+
// codePlaceholder is substituted when a turn is nothing but a code block, so the
16+
// buffer line is never blank.
17+
const codePlaceholder = "[code]"
18+
19+
var (
20+
// Fenced code blocks: ```lang\n ... ``` (dot-all, non-greedy).
21+
reCodeFence = regexp.MustCompile("(?s)```.*?```")
22+
// Inline code: `text` -> text (keep inner content).
23+
reInlineCode = regexp.MustCompile("`([^`]*)`")
24+
// Markdown images ![alt](url) -> alt (run before links).
25+
reMdImage = regexp.MustCompile(`!\[([^\]]*)\]\([^)]*\)`)
26+
// Markdown links [text](url) -> text.
27+
reMdLink = regexp.MustCompile(`\[([^\]]*)\]\([^)]*\)`)
28+
// Line-leading markers: heading #, blockquote >, bullets -/*/+, numbered N.
29+
reLineMarker = regexp.MustCompile(`(?m)^[ \t]*(?:#{1,6}|>+|[-*+]|\d+\.)[ \t]+`)
30+
// Bold/italic emphasis pairs. Single * / _ are left alone to avoid harming
31+
// snake_case identifiers and lone math/glob asterisks.
32+
reBold = regexp.MustCompile(`(\*\*|__)([^*_]+)(\*\*|__)`)
33+
// Leading acknowledgement clause (pure filler), up to its sentence end.
34+
// Note: "let me ..." / "i'll start ..." are actions, deliberately NOT here.
35+
reFiller = regexp.MustCompile(`(?i)^(?:sure|okay|ok|got it|certainly|of course|alright|no problem|sounds good|absolutely|great|happy to help|glad to help|i'?ll help|i can help)\b[^.!?]*[.!?]\s+`)
36+
)
37+
38+
// summarizeForBuffer produces a deterministic, no-LLM, rune-safe single-line
39+
// excerpt of raw turn text for the tier-2 buffer. It strips code/markdown noise,
40+
// collapses whitespace, drops a leading filler clause when substance remains,
41+
// and cuts at a sentence/word boundary within maxBufferSummaryRunes.
42+
//
43+
// It is invoked only on WRITE (MemoryManager.AppendBuffer). It must never run on
44+
// already-stored buffer lines (RestoreBuffer bypasses it) — re-running it on a
45+
// formatted "HH:MM role msg" line would corrupt the persisted summary.
46+
func summarizeForBuffer(text string) string {
47+
// 1. Remove fenced code blocks; remember whether we saw one.
48+
hadCode := reCodeFence.MatchString(text)
49+
text = reCodeFence.ReplaceAllString(text, " ")
50+
51+
// 2. Inline code -> inner text.
52+
text = reInlineCode.ReplaceAllString(text, "$1")
53+
54+
// 3. Images/links -> their visible text.
55+
text = reMdImage.ReplaceAllString(text, "$1")
56+
text = reMdLink.ReplaceAllString(text, "$1")
57+
58+
// 4. Line-leading markers and bold/italic emphasis.
59+
text = reLineMarker.ReplaceAllString(text, "")
60+
text = reBold.ReplaceAllString(text, "$2")
61+
62+
// 5. Collapse every whitespace run (incl. newlines/tabs) to a single space.
63+
text = strings.Join(strings.Fields(text), " ")
64+
65+
if text == "" {
66+
if hadCode {
67+
return codePlaceholder
68+
}
69+
return ""
70+
}
71+
72+
// 6. Drop a single leading filler clause, but only if substance remains.
73+
if loc := reFiller.FindStringIndex(text); loc != nil {
74+
if rest := strings.TrimSpace(text[loc[1]:]); rest != "" {
75+
text = rest
76+
}
77+
}
78+
79+
// 7. Excerpt to the rune cap at a sentence/word boundary.
80+
return excerptRunes(text, maxBufferSummaryRunes)
81+
}
82+
83+
// excerptRunes returns s unchanged if it fits within max runes; otherwise it
84+
// cuts at the last sentence terminator (.!?) before the cap, else the last
85+
// space, else hard-cuts at the cap — always on a rune boundary — and appends an
86+
// ellipsis. Never splits a multibyte rune.
87+
func excerptRunes(s string, max int) string {
88+
if utf8.RuneCountInString(s) <= max {
89+
return s
90+
}
91+
runes := []rune(s)
92+
window := runes[:max]
93+
94+
cut := -1
95+
for i, r := range window {
96+
if r == '.' || r == '!' || r == '?' {
97+
cut = i + 1 // include the terminator
98+
}
99+
}
100+
if cut <= 0 {
101+
// No sentence end; fall back to the last word boundary.
102+
for i := len(window) - 1; i >= 0; i-- {
103+
if window[i] == ' ' {
104+
cut = i
105+
break
106+
}
107+
}
108+
}
109+
if cut <= 0 {
110+
// Single very long token: hard-cut at the cap.
111+
cut = max
112+
}
113+
114+
return strings.TrimSpace(string(window[:cut])) + "…"
115+
}

0 commit comments

Comments
 (0)