| 1 | package 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 | |
| 14 | import ( |
| 15 | "crypto/rand" |
| 16 | "encoding/hex" |
| 17 | "fmt" |
| 18 | "net/url" |
| 19 | "strings" |
| 20 | "time" |
| 21 | |
| 22 | "lchat/internal/e2ee" |
| 23 | ) |
| 24 | |
| 25 | const memorySlot = "memory" |
| 26 | const maxMemFacts = 24 |
| 27 | const 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. |
| 31 | type memFact struct { |
| 32 | ID string `json:"id"` |
| 33 | Text string `json:"text"` |
| 34 | TS int64 `json:"ts"` |
| 35 | } |
| 36 | |
| 37 | type 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. |
| 45 | func 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 | |
| 67 | func 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). |
| 78 | func (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. |
| 100 | func (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 | |
| 119 | func 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 | |
| 136 | func 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. |
| 156 | func (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. |
| 213 | func (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 | |