Merge branch 'master' of https://forgejo.viktorbarzin.me/viktor/infra
All checks were successful
ci/woodpecker/push/default Pipeline was successful
All checks were successful
ci/woodpecker/push/default Pipeline was successful
This commit is contained in:
commit
37bdb3cb1e
3 changed files with 64 additions and 42 deletions
|
|
@ -1 +1 @@
|
||||||
v0.11.0
|
v0.12.0
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
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 {
|
if jsonOut {
|
||||||
fmt.Println(string(raw))
|
return string(raw) + "\n"
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
var r struct {
|
var r struct {
|
||||||
Memories []struct {
|
Memories []struct {
|
||||||
|
|
@ -46,36 +56,20 @@ func printMemories(raw []byte, jsonOut bool) error {
|
||||||
} `json:"memories"`
|
} `json:"memories"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(raw, &r); err != nil {
|
if err := json.Unmarshal(raw, &r); err != nil {
|
||||||
fmt.Println(string(raw))
|
return string(raw) + "\n"
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
if len(r.Memories) == 0 {
|
if len(r.Memories) == 0 {
|
||||||
fmt.Println("(no memories)")
|
return "(no memories)\n"
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
var b strings.Builder
|
||||||
for _, m := range r.Memories {
|
for _, m := range r.Memories {
|
||||||
c := truncatePreview(strings.ReplaceAll(m.Content, "\n", " "), 240)
|
c := strings.ReplaceAll(m.Content, "\n", " ")
|
||||||
fmt.Printf("#%d [%s] (%.2f) %s\n", m.ID, m.Category, m.Importance, c)
|
fmt.Fprintf(&b, "#%d [%s] (%.2f) %s\n", m.ID, m.Category, m.Importance, c)
|
||||||
if m.Tags != "" {
|
if m.Tags != "" {
|
||||||
fmt.Printf(" tags: %s\n", m.Tags)
|
fmt.Fprintf(&b, " tags: %s\n", m.Tags)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return b.String()
|
||||||
}
|
|
||||||
|
|
||||||
// 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]) + "…"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func memoryRecall(args []string) error {
|
func memoryRecall(args []string) error {
|
||||||
|
|
|
||||||
|
|
@ -8,25 +8,53 @@ import (
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTruncatePreviewKeepsValidUTF8(t *testing.T) {
|
func TestRenderMemoriesFullContent(t *testing.T) {
|
||||||
// Byte-slicing a long Cyrillic string at 240 splits a 2-byte rune and emits
|
// The pretty view must NOT truncate content: the old 240-rune preview cut
|
||||||
// invalid UTF-8 — the bug that crashed the recall hook. truncatePreview must
|
// memories mid-sentence, misled agents into thinking no full-content
|
||||||
// cut on a rune boundary and always stay valid UTF-8.
|
// read-back existed, and made blind `update --content` from the preview
|
||||||
long := strings.Repeat("я", 300) // 300 runes / 600 bytes
|
// destroy the stored tail. Full passthrough also removes the mid-rune-cut
|
||||||
got := truncatePreview(long, 240)
|
// 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) {
|
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] != '…' {
|
if !strings.Contains(got, "#7 [facts] (0.70) ") || !strings.Contains(got, "tags: t1,t2") {
|
||||||
t.Fatalf("truncatePreview = %d runes, want 240 Cyrillic + ellipsis", len(r))
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue