diff --git a/cli/VERSION b/cli/VERSION index fd2726c9..87a1cf59 100644 --- a/cli/VERSION +++ b/cli/VERSION @@ -1 +1 @@ -v0.11.0 +v0.12.0 diff --git a/cli/cmd_memory.go b/cli/cmd_memory.go index 7ae11ea0..129d07b2 100644 --- a/cli/cmd_memory.go +++ b/cli/cmd_memory.go @@ -30,11 +30,21 @@ func memoryCommands() []Command { } } -// printMemories renders a {memories:[…]} response as compact lines, or raw JSON. +// printMemories renders a {memories:[…]} response as one line per memory, or raw JSON. func printMemories(raw []byte, jsonOut bool) error { + fmt.Print(renderMemories(raw, jsonOut)) + return nil +} + +// renderMemories formats each memory as a single line with its FULL content +// (newlines flattened to spaces). Content is deliberately never truncated: the +// old 240-rune preview cut memories mid-sentence, misled agents into believing +// no full-content read-back existed, and made blind `update --content` from +// the preview silently destroy the stored tail. Full passthrough also can't +// produce invalid UTF-8 (the old mid-rune cut crashed the recall hook). +func renderMemories(raw []byte, jsonOut bool) string { if jsonOut { - fmt.Println(string(raw)) - return nil + return string(raw) + "\n" } var r struct { Memories []struct { @@ -46,36 +56,20 @@ func printMemories(raw []byte, jsonOut bool) error { } `json:"memories"` } if err := json.Unmarshal(raw, &r); err != nil { - fmt.Println(string(raw)) - return nil + return string(raw) + "\n" } if len(r.Memories) == 0 { - fmt.Println("(no memories)") - return nil + return "(no memories)\n" } + var b strings.Builder for _, m := range r.Memories { - c := truncatePreview(strings.ReplaceAll(m.Content, "\n", " "), 240) - fmt.Printf("#%d [%s] (%.2f) %s\n", m.ID, m.Category, m.Importance, c) + c := strings.ReplaceAll(m.Content, "\n", " ") + fmt.Fprintf(&b, "#%d [%s] (%.2f) %s\n", m.ID, m.Category, m.Importance, c) if m.Tags != "" { - fmt.Printf(" tags: %s\n", m.Tags) + fmt.Fprintf(&b, " tags: %s\n", m.Tags) } } - return nil -} - -// truncatePreview shortens s to at most maxRunes RUNES, appending "…" when it -// trims. Counting runes (not bytes) is load-bearing: a byte slice like s[:240] -// can cut through the middle of a multibyte UTF-8 character (e.g. 2-byte -// Cyrillic), leaving a dangling lead byte = invalid UTF-8. That crashed strict -// decoders downstream — notably the homelab-memory-recall.py UserPromptSubmit -// hook (subprocess text=True), which surfaced as a recurring "UserPromptSubmit -// hook error" for Cyrillic-language users. -func truncatePreview(s string, maxRunes int) string { - r := []rune(s) - if len(r) <= maxRunes { - return s - } - return string(r[:maxRunes]) + "…" + return b.String() } func memoryRecall(args []string) error { diff --git a/cli/memory_test.go b/cli/memory_test.go index 1c673c7b..ee21ad12 100644 --- a/cli/memory_test.go +++ b/cli/memory_test.go @@ -8,25 +8,53 @@ import ( "unicode/utf8" ) -func TestTruncatePreviewKeepsValidUTF8(t *testing.T) { - // Byte-slicing a long Cyrillic string at 240 splits a 2-byte rune and emits - // invalid UTF-8 — the bug that crashed the recall hook. truncatePreview must - // cut on a rune boundary and always stay valid UTF-8. - long := strings.Repeat("я", 300) // 300 runes / 600 bytes - got := truncatePreview(long, 240) +func TestRenderMemoriesFullContent(t *testing.T) { + // The pretty view must NOT truncate content: the old 240-rune preview cut + // memories mid-sentence, misled agents into thinking no full-content + // read-back existed, and made blind `update --content` from the preview + // destroy the stored tail. Full passthrough also removes the mid-rune-cut + // invalid-UTF-8 class by construction — nothing is ever sliced. + long := strings.Repeat("я", 300) + strings.Repeat("a", 300) + raw, _ := json.Marshal(map[string]interface{}{"memories": []map[string]interface{}{ + {"id": 7, "content": long, "category": "facts", "tags": "t1,t2", "importance": 0.7}, + }}) + got := renderMemories(raw, false) + if !strings.Contains(got, long) { + t.Fatalf("content was truncated: %q", got) + } + if strings.Contains(got, "…") { + t.Fatalf("ellipsis in output — truncation still active: %q", got) + } if !utf8.ValidString(got) { - t.Fatalf("truncatePreview produced invalid UTF-8: %q", got) + t.Fatalf("invalid UTF-8 in output: %q", got) } - if r := []rune(got); len(r) != 241 || string(r[:240]) != strings.Repeat("я", 240) || r[240] != '…' { - t.Fatalf("truncatePreview = %d runes, want 240 Cyrillic + ellipsis", len(r)) + if !strings.Contains(got, "#7 [facts] (0.70) ") || !strings.Contains(got, "tags: t1,t2") { + t.Fatalf("line format broken: %q", got) } - // Short multibyte strings pass through untouched (no ellipsis). - if got := truncatePreview("кратко", 240); got != "кратко" { - t.Fatalf("short string altered: %q", got) +} + +func TestRenderMemoriesFlattensNewlinesToOneLine(t *testing.T) { + // Consumers (the recall hook, terminal skims) rely on one memory per line; + // multi-line content is flattened, never split across lines. + raw, _ := json.Marshal(map[string]interface{}{"memories": []map[string]interface{}{ + {"id": 1, "content": "line one\nline two\nline three", "category": "facts", "importance": 0.5}, + }}) + got := renderMemories(raw, false) + if !strings.Contains(got, "line one line two line three") { + t.Fatalf("newlines not flattened: %q", got) } - // ASCII boundary still works. - if got := truncatePreview(strings.Repeat("a", 500), 240); got != strings.Repeat("a", 240)+"…" { - t.Fatalf("ascii truncation wrong: %q", got) +} + +func TestRenderMemoriesEdgeCases(t *testing.T) { + if got := renderMemories([]byte(`{"memories":[]}`), false); got != "(no memories)\n" { + t.Fatalf("empty list: %q", got) + } + // --json and unparseable responses pass through raw. + if got := renderMemories([]byte(`{"x":1}`), true); got != "{\"x\":1}\n" { + t.Fatalf("json passthrough: %q", got) + } + if got := renderMemories([]byte(`not json`), false); got != "not json\n" { + t.Fatalf("unparseable passthrough: %q", got) } }