eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package bot
2
3// Eleven's memory — the small set of durable facts a member has let their AI
4// keep about them, with the member holding every key. Design: docs/eleven-memory.md.
5//
6// The trust model does the heavy lifting: memory lives in the "memory" slot
7// of the DM thread's state store (miniapps.go), encrypted under the DM's
8// thread key — so it is E2EE to exactly the two participants, per
9// relationship (your memory with Eleven is not your memory with Claude), and
10// the server holds only ciphertext. And it is the MEMBER's data: the web
11// scratchpad (/memory, web/static/apps/memory.js) reads and writes the same
12// slot, so anything the bot notes, the member can see, edit, or erase.
13
14import (
15 "crypto/rand"
16 "encoding/hex"
17 "fmt"
18 "net/url"
19 "strings"
20 "time"
21
22 "lchat/internal/e2ee"
23)
24
25const memorySlot = "memory"
26const maxMemFacts = 24
27const maxMemFactChars = 280
28
29// memFact is one remembered fact. IDs are short + stable so the model can
30// name a fact to forget, and the scratchpad can key its rows.
31type memFact struct {
32 ID string `json:"id"`
33 Text string `json:"text"`
34 TS int64 `json:"ts"`
35}
36
37type memoryState struct {
38 V int `json:"v"`
39 Facts []memFact `json:"facts"`
40}
41
42// clampMemory bounds a decrypted state whatever wrote it (the scratchpad and
43// this file share the slot; payloads aren't signed, so read-side clamping is
44// the invariant): fact count and length capped, blanks dropped, ids repaired.
45func clampMemory(m *memoryState) {
46 kept := m.Facts[:0]
47 for _, f := range m.Facts {
48 f.Text = strings.TrimSpace(f.Text)
49 if f.Text == "" {
50 continue
51 }
52 if len(f.Text) > maxMemFactChars {
53 f.Text = f.Text[:maxMemFactChars]
54 }
55 if f.ID == "" {
56 f.ID = newMemID()
57 }
58 kept = append(kept, f)
59 if len(kept) == maxMemFacts {
60 break
61 }
62 }
63 m.V = 1
64 m.Facts = kept
65}
66
67func newMemID() string {
68 var b [4]byte
69 if _, err := rand.Read(b[:]); err != nil {
70 return fmt.Sprintf("m%d", time.Now().UnixNano()%1_000_000)
71 }
72 return "m" + hex.EncodeToString(b[:])
73}
74
75// loadMemory reads and decrypts a thread's memory slot. An empty slot is an
76// empty memory, not an error; an undecryptable blob is treated the same way
77// (fail open to empty — memory must never break the reply path).
78func (r *Runtime) loadMemory(threadID string, key []byte) (memoryState, int64) {
79 var out struct {
80 Blob string `json:"blob"`
81 Version int64 `json:"version"`
82 }
83 var mem memoryState
84 if err := r.get("/api/threads/"+url.PathEscape(threadID)+"/state?slot="+memorySlot, &out); err != nil {
85 r.logf("memory: load %s: %v", threadID, err)
86 return mem, 0
87 }
88 if out.Blob != "" {
89 if err := e2ee.DecryptJSON(key, out.Blob, &mem); err != nil {
90 r.logf("memory: %s: undecryptable — starting empty", threadID)
91 mem = memoryState{}
92 }
93 }
94 clampMemory(&mem)
95 return mem, out.Version
96}
97
98// saveMemory encrypts and writes the slot at the version read. false = a
99// concurrent write won (the scratchpad saved first) — reload and retry.
100func (r *Runtime) saveMemory(threadID string, key []byte, mem memoryState, version int64) bool {
101 clampMemory(&mem)
102 blob, err := e2ee.EncryptJSON(key, mem)
103 if err != nil {
104 r.logf("memory: seal %s: %v", threadID, err)
105 return false
106 }
107 err = r.put("/api/threads/"+url.PathEscape(threadID)+"/state", map[string]any{
108 "token": r.Session.Token, "slot": memorySlot, "blob": blob, "version": version,
109 })
110 if err != nil {
111 r.logf("memory: save %s: %v", threadID, err)
112 return false
113 }
114 return true
115}
116
117// ---- the tools -------------------------------------------------------------
118
119func rememberTool() anthTool {
120 return anthTool{
121 Name: "remember",
122 Description: "Save ONE short durable fact about this member to your memory of them — something they " +
123 "told you about themselves, a standing preference, or anything they explicitly asked you to " +
124 "remember. Third person, plain words (\"Prefers short answers\", \"Runs the Eleven Messenger " +
125 "project\"). Never store secrets (passwords, keys, codes) or sensitive details (health, finances, " +
126 "other people's private business) unless the member explicitly asks. Don't re-save what your " +
127 "memory already holds. Max 280 characters.",
128 InputSchema: map[string]any{
129 "type": "object",
130 "properties": map[string]any{"fact": map[string]any{"type": "string"}},
131 "required": []any{"fact"},
132 },
133 }
134}
135
136func forgetTool() anthTool {
137 return anthTool{
138 Name: "forget",
139 Description: "Remove something from your memory of this member — when they ask you to forget it, or " +
140 "it's clearly stale or superseded. Pass the exact id from your memory list, or all:true to erase " +
141 "everything (only when they clearly asked for a full wipe).",
142 InputSchema: map[string]any{
143 "type": "object",
144 "properties": map[string]any{
145 "id": map[string]any{"type": "string"},
146 "all": map[string]any{"type": "boolean"},
147 },
148 },
149 }
150}
151
152// runMemoryOp executes one remember/forget tool_use inline (frame goroutine —
153// two small REST calls). Returns (chip label, ok): the label rides the
154// sidecar-attached open_memory chip, so every write announces itself and
155// hands the member the door to the scratchpad in the same breath.
156func (r *Runtime) runMemoryOp(tc *ThreadCtx, u anthBlock) (string, bool) {
157 mem, version := r.loadMemory(tc.ID, tc.key)
158 switch u.Name {
159 case "remember":
160 fact, _ := u.Input["fact"].(string)
161 fact = strings.TrimSpace(fact)
162 if fact == "" {
163 return "", false
164 }
165 if len(fact) > maxMemFactChars {
166 fact = fact[:maxMemFactChars]
167 }
168 for _, f := range mem.Facts {
169 if strings.EqualFold(f.Text, fact) {
170 return "", false // already known — no write, no chip
171 }
172 }
173 if len(mem.Facts) >= maxMemFacts {
174 r.logf("memory: %s: full (%d facts) — remember dropped", tc.ID, maxMemFacts)
175 return "", false
176 }
177 // Newest first — the scratchpad reads top-down, and a note just made
178 // belongs where the eye already is (the panel prepends the same way).
179 mem.Facts = append([]memFact{{ID: newMemID(), Text: fact, TS: time.Now().UnixMilli()}}, mem.Facts...)
180 if !r.saveMemory(tc.ID, tc.key, mem, version) {
181 return "", false
182 }
183 return "✍️ Noted — see what I remember", true
184 case "forget":
185 if all, _ := u.Input["all"].(bool); all {
186 if !r.saveMemory(tc.ID, tc.key, memoryState{V: 1}, version) {
187 return "", false
188 }
189 return "Memory erased — take a look", true
190 }
191 id, _ := u.Input["id"].(string)
192 kept := mem.Facts[:0]
193 for _, f := range mem.Facts {
194 if f.ID != id {
195 kept = append(kept, f)
196 }
197 }
198 if len(kept) == len(mem.Facts) {
199 return "", false // unknown id — nothing changed
200 }
201 mem.Facts = kept
202 if !r.saveMemory(tc.ID, tc.key, mem, version) {
203 return "", false
204 }
205 return "Forgotten — see what I remember", true
206 }
207 return "", false
208}
209
210// memoryNote is the DM system addendum: what the bot remembers about this
211// member (ids included, so forget can name a fact) plus the ground rules.
212// Rides after the cached system block, like charterHomeNote.
213func (r *Runtime) memoryNote(tc *ThreadCtx) string {
214 mem, _ := r.loadMemory(tc.ID, tc.key)
215 var b strings.Builder
216 b.WriteString("You keep a small MEMORY about this member — durable facts and preferences, stored " +
217 "encrypted in this chat where only the two of you can read it. The member can open, edit, or " +
218 "erase it any time with /memory (mention that when you save something, or when they ask what " +
219 "you know). Use the remember tool when they share something durable or ask you to keep it — " +
220 "and ESPECIALLY when they answer a question you asked about them (their role, their situation, " +
221 "a preference): if you had to ask once, you must not need to ask twice. Use forget (with the " +
222 "id) when they ask you to drop something. Never claim to remember anything that isn't listed " +
223 "below.\n\n")
224 if len(mem.Facts) == 0 {
225 b.WriteString("Your memory of this member is empty so far.")
226 return b.String()
227 }
228 b.WriteString("What you currently remember (id · fact):\n")
229 for _, f := range mem.Facts {
230 fmt.Fprintf(&b, "- %s · %s\n", f.ID, f.Text)
231 }
232 return b.String()
233}
234