Repository navigation
Expand file tree
/
Copy pathmemory.go
More file actions
128 lines (112 loc) · 3.4 KB
/
Copy pathmemory.go
File metadata and controls
128 lines (112 loc) · 3.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"unicode/utf8"
)
// Persistence, all plain files on the 128 GB microSD: per-channel
// conversation history (JSON files) and one shared MEMORY.md of durable
// notes, loaded into every system prompt.
var memMu sync.Mutex
func memoryPath(cfg *Config) string { return filepath.Join(cfg.DataDir, "MEMORY.md") }
func readMemory(cfg *Config) string {
memMu.Lock()
b, err := os.ReadFile(memoryPath(cfg))
memMu.Unlock()
if err != nil {
return ""
}
s := string(b)
if len(s) > 4000 { // cap what rides the prompt; the full file stays on SD
n := len(s) - 4000
for n < len(s) && !utf8.RuneStart(s[n]) { // don't start mid-rune
n++
}
s = "…" + s[n:]
}
return s
}
// memFence matches the memory delimiter in any case, so a note can't contain a
// literal </memory> that forges the block boundary the system prompt relies on.
var memFence = regexp.MustCompile(`(?i)</?memory>`)
// selfDated strips a leading "- [YYYY-MM-DD] " (and any bullet) the model
// sometimes bakes into the note itself, so appendMemory's own stamp doesn't
// double it ("- [2026-08-09] - [2026-08-09] …").
var selfDated = regexp.MustCompile(`^\s*-?\s*\[\d{4}-\d{2}-\d{2}\]\s*`)
func appendMemory(cfg *Config, note string) string {
note = memFence.ReplaceAllString(strings.TrimSpace(note), "[mem]")
note = strings.TrimSpace(selfDated.ReplaceAllString(note, ""))
if note == "" {
return "memory error: empty note"
}
memMu.Lock()
defer memMu.Unlock()
f, err := os.OpenFile(memoryPath(cfg), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return "memory error: " + err.Error()
}
defer f.Close()
fmt.Fprintf(f, "- [%s] %s\n", time.Now().UTC().Format("2006-01-02"), note)
return "remembered"
}
// fullMemory returns the whole MEMORY.md (used by tests; the runtime path is
// readMemory).
func fullMemory(cfg *Config) string {
memMu.Lock()
defer memMu.Unlock()
b, _ := os.ReadFile(memoryPath(cfg))
return string(b)
}
// ── per-channel history ────────────────────────────────────────────────
type History struct {
mu sync.Mutex
cfg *Config
byCh map[string][]Msg
}
func NewHistory(cfg *Config) *History {
return &History{cfg: cfg, byCh: map[string][]Msg{}}
}
func (h *History) path(ch string) string {
return filepath.Join(h.cfg.DataDir, "history", ch+".json")
}
func (h *History) Get(ch string) []Msg {
h.mu.Lock()
defer h.mu.Unlock()
if m, ok := h.byCh[ch]; ok {
return append([]Msg(nil), m...)
}
var m []Msg
if b, err := os.ReadFile(h.path(ch)); err == nil {
_ = json.Unmarshal(b, &m)
}
h.byCh[ch] = m
return append([]Msg(nil), m...)
}
// Clear wipes one channel's conversation history (memory + disk) — the /reset
// command. Long-term MEMORY.md is untouched.
func (h *History) Clear(ch string) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.byCh, ch)
_ = os.Remove(h.path(ch))
}
// Append stores a user/assistant exchange (tool traffic is not persisted —
// it can be huge and the model re-derives it cheaply).
func (h *History) Append(ch string, exchange ...Msg) {
h.mu.Lock()
defer h.mu.Unlock()
m := append(h.byCh[ch], exchange...)
if max := h.cfg.HistoryTurns * 2; len(m) > max {
m = m[len(m)-max:]
}
h.byCh[ch] = m
if b, err := json.Marshal(m); err == nil {
_ = os.WriteFile(h.path(ch), b, 0o600)
}
}