package bot // Eleven's memory — the small set of durable facts a member has let their AI // keep about them, with the member holding every key. Design: docs/eleven-memory.md. // // The trust model does the heavy lifting: memory lives in the "memory" slot // of the DM thread's state store (miniapps.go), encrypted under the DM's // thread key — so it is E2EE to exactly the two participants, per // relationship (your memory with Eleven is not your memory with Claude), and // the server holds only ciphertext. And it is the MEMBER's data: the web // scratchpad (/memory, web/static/apps/memory.js) reads and writes the same // slot, so anything the bot notes, the member can see, edit, or erase. import ( "crypto/rand" "encoding/hex" "fmt" "net/url" "strings" "time" "lchat/internal/e2ee" ) const memorySlot = "memory" const maxMemFacts = 24 const maxMemFactChars = 280 // memFact is one remembered fact. IDs are short + stable so the model can // name a fact to forget, and the scratchpad can key its rows. type memFact struct { ID string `json:"id"` Text string `json:"text"` TS int64 `json:"ts"` } type memoryState struct { V int `json:"v"` Facts []memFact `json:"facts"` } // clampMemory bounds a decrypted state whatever wrote it (the scratchpad and // this file share the slot; payloads aren't signed, so read-side clamping is // the invariant): fact count and length capped, blanks dropped, ids repaired. func clampMemory(m *memoryState) { kept := m.Facts[:0] for _, f := range m.Facts { f.Text = strings.TrimSpace(f.Text) if f.Text == "" { continue } if len(f.Text) > maxMemFactChars { f.Text = f.Text[:maxMemFactChars] } if f.ID == "" { f.ID = newMemID() } kept = append(kept, f) if len(kept) == maxMemFacts { break } } m.V = 1 m.Facts = kept } func newMemID() string { var b [4]byte if _, err := rand.Read(b[:]); err != nil { return fmt.Sprintf("m%d", time.Now().UnixNano()%1_000_000) } return "m" + hex.EncodeToString(b[:]) } // loadMemory reads and decrypts a thread's memory slot. An empty slot is an // empty memory, not an error; an undecryptable blob is treated the same way // (fail open to empty — memory must never break the reply path). func (r *Runtime) loadMemory(threadID string, key []byte) (memoryState, int64) { var out struct { Blob string `json:"blob"` Version int64 `json:"version"` } var mem memoryState if err := r.get("/api/threads/"+url.PathEscape(threadID)+"/state?slot="+memorySlot, &out); err != nil { r.logf("memory: load %s: %v", threadID, err) return mem, 0 } if out.Blob != "" { if err := e2ee.DecryptJSON(key, out.Blob, &mem); err != nil { r.logf("memory: %s: undecryptable — starting empty", threadID) mem = memoryState{} } } clampMemory(&mem) return mem, out.Version } // saveMemory encrypts and writes the slot at the version read. false = a // concurrent write won (the scratchpad saved first) — reload and retry. func (r *Runtime) saveMemory(threadID string, key []byte, mem memoryState, version int64) bool { clampMemory(&mem) blob, err := e2ee.EncryptJSON(key, mem) if err != nil { r.logf("memory: seal %s: %v", threadID, err) return false } err = r.put("/api/threads/"+url.PathEscape(threadID)+"/state", map[string]any{ "token": r.Session.Token, "slot": memorySlot, "blob": blob, "version": version, }) if err != nil { r.logf("memory: save %s: %v", threadID, err) return false } return true } // ---- the tools ------------------------------------------------------------- func rememberTool() anthTool { return anthTool{ Name: "remember", Description: "Save ONE short durable fact about this member to your memory of them — something they " + "told you about themselves, a standing preference, or anything they explicitly asked you to " + "remember. Third person, plain words (\"Prefers short answers\", \"Runs the Eleven Messenger " + "project\"). Never store secrets (passwords, keys, codes) or sensitive details (health, finances, " + "other people's private business) unless the member explicitly asks. Don't re-save what your " + "memory already holds. Max 280 characters.", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{"fact": map[string]any{"type": "string"}}, "required": []any{"fact"}, }, } } func forgetTool() anthTool { return anthTool{ Name: "forget", Description: "Remove something from your memory of this member — when they ask you to forget it, or " + "it's clearly stale or superseded. Pass the exact id from your memory list, or all:true to erase " + "everything (only when they clearly asked for a full wipe).", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "id": map[string]any{"type": "string"}, "all": map[string]any{"type": "boolean"}, }, }, } } // runMemoryOp executes one remember/forget tool_use inline (frame goroutine — // two small REST calls). Returns (chip label, ok): the label rides the // sidecar-attached open_memory chip, so every write announces itself and // hands the member the door to the scratchpad in the same breath. func (r *Runtime) runMemoryOp(tc *ThreadCtx, u anthBlock) (string, bool) { mem, version := r.loadMemory(tc.ID, tc.key) switch u.Name { case "remember": fact, _ := u.Input["fact"].(string) fact = strings.TrimSpace(fact) if fact == "" { return "", false } if len(fact) > maxMemFactChars { fact = fact[:maxMemFactChars] } for _, f := range mem.Facts { if strings.EqualFold(f.Text, fact) { return "", false // already known — no write, no chip } } if len(mem.Facts) >= maxMemFacts { r.logf("memory: %s: full (%d facts) — remember dropped", tc.ID, maxMemFacts) return "", false } // Newest first — the scratchpad reads top-down, and a note just made // belongs where the eye already is (the panel prepends the same way). mem.Facts = append([]memFact{{ID: newMemID(), Text: fact, TS: time.Now().UnixMilli()}}, mem.Facts...) if !r.saveMemory(tc.ID, tc.key, mem, version) { return "", false } return "✍️ Noted — see what I remember", true case "forget": if all, _ := u.Input["all"].(bool); all { if !r.saveMemory(tc.ID, tc.key, memoryState{V: 1}, version) { return "", false } return "Memory erased — take a look", true } id, _ := u.Input["id"].(string) kept := mem.Facts[:0] for _, f := range mem.Facts { if f.ID != id { kept = append(kept, f) } } if len(kept) == len(mem.Facts) { return "", false // unknown id — nothing changed } mem.Facts = kept if !r.saveMemory(tc.ID, tc.key, mem, version) { return "", false } return "Forgotten — see what I remember", true } return "", false } // memoryNote is the DM system addendum: what the bot remembers about this // member (ids included, so forget can name a fact) plus the ground rules. // Rides after the cached system block, like charterHomeNote. func (r *Runtime) memoryNote(tc *ThreadCtx) string { mem, _ := r.loadMemory(tc.ID, tc.key) var b strings.Builder b.WriteString("You keep a small MEMORY about this member — durable facts and preferences, stored " + "encrypted in this chat where only the two of you can read it. The member can open, edit, or " + "erase it any time with /memory (mention that when you save something, or when they ask what " + "you know). Use the remember tool when they share something durable or ask you to keep it — " + "and ESPECIALLY when they answer a question you asked about them (their role, their situation, " + "a preference): if you had to ask once, you must not need to ask twice. Use forget (with the " + "id) when they ask you to drop something. Never claim to remember anything that isn't listed " + "below.\n\n") if len(mem.Facts) == 0 { b.WriteString("Your memory of this member is empty so far.") return b.String() } b.WriteString("What you currently remember (id · fact):\n") for _, f := range mem.Facts { fmt.Fprintf(&b, "- %s · %s\n", f.ID, f.Text) } return b.String() }