bot+web: Eleven remembers — with a member-owned memory scratchpad (#829)
* bot+web: Eleven remembers — with a member-owned memory scratchpad
An AI helper's entire knowledge of a member was the last ~30 messages
of the current thread ("What's your role?", asked weekly). Now a DM's
helper keeps a small memory — and the member owns it outright.
Storage is the design: the "memory" slot of the DM thread's state store
(one more slot on machinery that already existed), encrypted under the
DM's thread key — E2EE to exactly the two participants, per
relationship, never the instance key. The sidecar gets remember/forget
tools (DM-only) and the memory rides the DM's system context; the web
gets the scratchpad (/memory, the chat's menu -> Memory, or the chip):
see every note, edit, add your own, delete, or erase everything. Every
write announces itself — the sidecar, never the model, attaches an
open_memory chip. Both readers re-clamp the unsigned shared slot
(clampMemory / sanitizeMemory, kept in step).
Design + trust model: docs/eleven-memory.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* bot: memory guidance — an answer to your own question is the thing to remember
* web: drop the kebab Memory item — the Views dropdown is memory's home (follow-up UX call)
* bot+web: scratchpad polish — Forget… with a cancellable countdown, newest-first
Paul's pass on the live scratchpad: "Forget everything" becomes a small
"Forget…" at the panel's top right, and erasing is three deliberate
beats — arm ("Erase it all?"), a 5-second visible countdown that a tap
cancels, then a little drift-out flourish (skipped under reduced
motion) before the erase commits. The add box moves above the list, and
new notes prepend — in the panel and in the bot's remember alike — so
the newest note lands where the eye already is. The panel head also
drops its glyph (the dropdown row and header button carry the identity).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: Forget… sits inline with the Memory panel title (headExtra slot on command panels)
* web: the forget countdown is just the number, inline beside Cancel
* web: All forgotten lands where the button was; Forget… returns with the next note
* web: the add-a-memory input refocuses after each add
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
15 files changed,
+1080
−8
bot_memory_test.go+194 −0changelog/2026-07-13-eleven-memory.md+8 −0docs/eleven-memory.md+72 −0internal/bot/brain.go+27 −5internal/bot/knowledge.md+10 −0internal/bot/memory.go+233 −0internal/bot/memory_test.go+63 −0internal/bot/view_test.go+1 −1package.json+1 −1test/memory.test.mjs+42 −0web/static/apps.js+12 −0web/static/apps/chat.js+15 −1web/static/apps/memory.js+355 −0web/static/shell.js+42 −0web/static/ui.js+5 −0
diff --git a/bot_memory_test.go b/bot_memory_test.go| new file mode 100644 |
| index 0000000..8c2ffd7 |
| --- /dev/null |
| +++ b/bot_memory_test.go |
| @@ -0,0 +1,194 @@ |
| +package main |
| + |
| +// End-to-end test of Eleven's memory (internal/bot/memory.go + |
| +// docs/eleven-memory.md) against the real router: a DM remember lands in the |
| +// thread's "memory" state slot (encrypted under the DM key — decrypted here |
| +// as the member's client would), the fact rides the NEXT call's system |
| +// context with its id, the reply carries the sidecar-attached open_memory |
| +// chip, and forget(all) wipes the slot. |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "lchat/internal/bot" |
| + "lchat/internal/e2ee" |
| +) |
| + |
| +func TestMemoryRememberRecallForget(t *testing.T) { |
| + var mu sync.Mutex |
| + var bodies []string |
| + calls := 0 |
| + fakeAI := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + b, _ := io.ReadAll(r.Body) |
| + mu.Lock() |
| + bodies = append(bodies, string(b)) |
| + i := calls |
| + calls++ |
| + mu.Unlock() |
| + responses := [][]map[string]any{ |
| + // Turn 1: remember + a conversational ack. |
| + {{"type": "text", "text": "Noted!"}, |
| + {"type": "tool_use", "name": "remember", "input": map[string]any{"fact": "Runs the Eleven Messenger project"}}}, |
| + // Turn 2: a plain reply (the test only wants this call's request). |
| + {{"type": "text", "text": "You run the Eleven Messenger project."}}, |
| + // Turn 3: the member asked for a wipe. |
| + {{"type": "text", "text": "All forgotten."}, |
| + {"type": "tool_use", "name": "forget", "input": map[string]any{"all": true}}}, |
| + } |
| + if i >= len(responses) { |
| + i = len(responses) - 1 |
| + } |
| + json.NewEncoder(w).Encode(map[string]any{"content": responses[i], "stop_reason": "end_turn"}) |
| + })) |
| + defer fakeAI.Close() |
| + t.Setenv("ANTHROPIC_BASE_URL", fakeAI.URL) |
| + |
| + ts, s := newTestServer(t) |
| + owner := provisionNamed(t, ts.URL, "Owner", false) |
| + eleven := provisionNamed(t, ts.URL, "Eleven", true) |
| + ownerUID, _ := s.db.deviceTokenUser(owner.Token) |
| + dmID, dmKey := createThreadKind(t, ts.URL, s, "dm", owner, eleven) |
| + |
| + dir := t.TempDir() |
| + sessionPath := dir + "/eleven.session.json" |
| + if err := eleven.Save(sessionPath); err != nil { |
| + t.Fatal(err) |
| + } |
| + ready := make(chan struct{}) |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + defer cancel() |
| + eng := &bot.Engine{Base: ts.URL, Session: eleven, SessionPath: sessionPath, |
| + Recipe: bot.DefaultRecipe(), AIKey: "test-key", |
| + Ready: func() { close(ready) }, Logf: t.Logf} |
| + go eng.Run(ctx) |
| + awaitReady(t, ready) |
| + |
| + // memorySlot reads the slot the way the member's scratchpad would. |
| + memorySlot := func() (map[string]any, bool) { |
| + resp, err := http.Get(ts.URL + "/api/threads/" + dmID + "/state?token=" + owner.Token + "&slot=memory") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer resp.Body.Close() |
| + var out struct { |
| + Blob string `json:"blob"` |
| + } |
| + if json.NewDecoder(resp.Body).Decode(&out) != nil || out.Blob == "" { |
| + return nil, false |
| + } |
| + var mem map[string]any |
| + if err := e2ee.DecryptJSON(dmKey, out.Blob, &mem); err != nil { |
| + t.Fatalf("memory slot undecryptable with the DM key: %v", err) |
| + } |
| + return mem, true |
| + } |
| + |
| + // Turn 1 — remember: the fact lands in the slot, and the reply carries the |
| + // sidecar-attached open_memory chip labeled as a note. |
| + postAsMember(t, ts.URL, dmID, dmKey, owner, ownerUID, "Please remember that I run the Eleven Messenger project.") |
| + waitFor(t, "memory slot written", func() bool { |
| + mem, ok := memorySlot() |
| + if !ok { |
| + return false |
| + } |
| + facts, _ := mem["facts"].([]any) |
| + return len(facts) == 1 |
| + }) |
| + mem, _ := memorySlot() |
| + fact := mem["facts"].([]any)[0].(map[string]any) |
| + if fact["text"] != "Runs the Eleven Messenger project" || fact["id"] == "" { |
| + t.Fatalf("stored fact: %+v", fact) |
| + } |
| + waitFor(t, "the open_memory chip on the reply", func() bool { |
| + for _, rp := range botReplies(t, s, dmID, dmKey, "Eleven") { |
| + for _, a := range rp.Actions { |
| + if a["type"] == "open_memory" { |
| + return true |
| + } |
| + } |
| + } |
| + return false |
| + }) |
| + |
| + // Turn 2 — recall: the next call's system context carries the fact + id. |
| + postAsMember(t, ts.URL, dmID, dmKey, owner, ownerUID, "What do I do?") |
| + waitFor(t, "the recall reply", func() bool { |
| + return len(botReplies(t, s, dmID, dmKey, "Eleven")) >= 2 |
| + }) |
| + mu.Lock() |
| + secondReq := bodies[1] |
| + mu.Unlock() |
| + var call struct { |
| + System []struct { |
| + Text string `json:"text"` |
| + } `json:"system"` |
| + } |
| + if err := json.Unmarshal([]byte(secondReq), &call); err != nil { |
| + t.Fatal(err) |
| + } |
| + var note string |
| + for _, b := range call.System[1:] { // after the cached block |
| + if strings.Contains(b.Text, "MEMORY") { |
| + note = b.Text |
| + } |
| + } |
| + if !strings.Contains(note, "Runs the Eleven Messenger project") || !strings.Contains(note, fact["id"].(string)) { |
| + t.Fatalf("memory note missing the fact or its id:\n%s", note) |
| + } |
| + |
| + // Turn 3 — forget everything: the slot empties (the blob stays, sealed, |
| + // holding zero facts) and the wipe announces itself with the chip. |
| + postAsMember(t, ts.URL, dmID, dmKey, owner, ownerUID, "Forget everything about me please.") |
| + waitFor(t, "memory wiped", func() bool { |
| + mem, ok := memorySlot() |
| + if !ok { |
| + return false |
| + } |
| + facts, _ := mem["facts"].([]any) |
| + return len(facts) == 0 |
| + }) |
| +} |
| + |
| +// TestMemberScratchpadWriteWinsConflict pins the scratchpad's side of the |
| +// shared slot: a member's own write (the panel saving at the version it read) |
| +// bumps the version, and a stale second write 409s instead of clobbering. |
| +func TestMemberScratchpadWriteWinsConflict(t *testing.T) { |
| + ts, s := newTestServer(t) |
| + owner := provisionNamed(t, ts.URL, "Owner", false) |
| + eleven := provisionNamed(t, ts.URL, "Eleven", true) |
| + _ = s |
| + dmID, dmKey := createThreadKind(t, ts.URL, s, "dm", owner, eleven) |
| + |
| + put := func(version int64) int { |
| + blob, err := e2ee.EncryptJSON(dmKey, map[string]any{"v": 1, "facts": []map[string]any{ |
| + {"id": "m1", "text": "Prefers short answers", "ts": time.Now().UnixMilli()}, |
| + }}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + body, _ := json.Marshal(map[string]any{"token": owner.Token, "slot": "memory", "blob": blob, "version": version}) |
| + req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/threads/"+dmID+"/state", bytes.NewReader(body)) |
| + req.Header.Set("Content-Type", "application/json") |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + resp.Body.Close() |
| + return resp.StatusCode |
| + } |
| + if code := put(0); code != http.StatusOK { |
| + t.Fatalf("first write: %d", code) |
| + } |
| + if code := put(0); code != http.StatusConflict { |
| + t.Fatalf("stale write should 409, got %d", code) |
| + } |
| +} |
diff --git a/changelog/2026-07-13-eleven-memory.md b/changelog/2026-07-13-eleven-memory.md| new file mode 100644 |
| index 0000000..c9523a3 |
| --- /dev/null |
| +++ b/changelog/2026-07-13-eleven-memory.md |
| @@ -0,0 +1,8 @@ |
| +🤖 Eleven can remember things about you now — and the memory is yours. In a |
| +DM, tell it something worth keeping ("remember I prefer short answers") and |
| +it saves a note; from then on it just knows, instead of asking again. Every |
| +note it saves announces itself with a button, and /memory (or the chat's ⋮ |
| +menu → Memory) opens the scratchpad: see every note, edit or remove any of |
| +them, add your own, or erase the lot. It's stored end-to-end encrypted in |
| +that chat — only you and Eleven can read it — and each helper keeps its own, |
| +so nothing you say in one chat follows you anywhere else. |
diff --git a/docs/eleven-memory.md b/docs/eleven-memory.md| new file mode 100644 |
| index 0000000..f43dfeb |
| --- /dev/null |
| +++ b/docs/eleven-memory.md |
| @@ -0,0 +1,72 @@ |
| +# 🤖 Eleven's memory — remembered by the bot, owned by the member |
| + |
| +*"What's your role?" — asked for the third time this week.* Without memory, |
| +an AI helper's entire knowledge of a member is the last ~30 messages of the |
| +current thread: every DM is groundhog day. This document describes the |
| +memory that fixes it, and the property that makes it acceptable: **nothing |
| +the bot remembers is ever invisible, uneditable, or unerasable.** |
| + |
| +## What it is |
| + |
| +A small set of durable facts (max 24, ≤ 280 chars each) an AI helper keeps |
| +about a member — role, standing preferences, anything the member asked it to |
| +keep. Two halves, one store: |
| + |
| +- **The bot's half** (`internal/bot/memory.go`): `remember` / `forget` tools |
| + offered to the conversational brain in DMs only. What's remembered rides the |
| + DM's system context (after the cache breakpoint, like a bot-home thread's |
| + charter note), so the helper simply *knows* — and it's told to never claim a |
| + memory that isn't in the list. |
| +- **The member's half** (`web/static/apps/memory.js`): the **scratchpad** — a |
| + command panel opened by `/memory`, the chat's ⋮ menu → Memory, or the chip |
| + every write attaches. It shows every fact and lets the member edit any of |
| + them, add their own, delete one, or erase everything. Memory the member |
| + can't inspect is creepy; memory they can is a feature. |
| + |
| +## Where it lives (the trust model) |
| + |
| +The `"memory"` slot of the **DM thread's state store** (`miniapps.go` — |
| +`GET/PUT /api/threads/{id}/state`, optimistic versioning), encrypted under |
| +the **DM's thread key**. That one choice does the heavy lifting: |
| + |
| +- **E2EE to exactly two parties.** Only the member and that bot hold the DM |
| + key. Not the instance key (which every member holds) — things learned in a |
| + private conversation stay in that conversation's trust class. The server |
| + stores ciphertext, as ever. |
| +- **Per relationship.** Your memory with Eleven is not your memory with |
| + Claude — each DM has its own slot under its own key. Retiring a helper (or |
| + ending the DM) strands nothing anywhere else. |
| +- **No new server surface.** The slot machinery, versioning, and member |
| + gating already existed for app-state snapshots; memory is one more slot |
| + (`"memory"`, distinct from `"apps"`, so the two never contend). |
| + |
| +Writes are whole-blob at the version read; a concurrent write 409s and the |
| +loser reloads (the sidecar retries its op against the fresh copy; the |
| +scratchpad shows the latest and the member redoes the edit). Both readers |
| +re-clamp whatever they read (`clampMemory` in Go, `sanitizeMemory` on the |
| +web — unsigned shared slot, so read-side clamping is the invariant). |
| + |
| +## Transparency rules |
| + |
| +- **Every write announces itself.** The sidecar — never the model — appends |
| + an `open_memory` chip to any reply whose turn performed a remember/forget |
| + ("✍️ Noted — see what I remember"). No silent writes. |
| +- **DM-only.** The tools and the recall exist only in the DM where the memory |
| + lives. Group-Eleven never reads DM-derived memory — whether it ever should |
| + is a deliberate future decision, not a default. |
| +- **Hygiene in the tool contract.** The `remember` description forbids |
| + secrets (passwords, keys, codes) and sensitive details (health, finances, |
| + other people's private business) unless the member explicitly asks, and the |
| + model is told to store one short third-person fact per call. |
| +- **Bounded.** 24 facts × 280 chars keeps the whole memory a few hundred |
| + tokens of context and one small ciphertext blob. |
| + |
| +## Scope |
| + |
| +Web-first (stated): the scratchpad is a web command panel; native renders AI |
| +DMs through the webview surfaces today. The `open_memory` chip type is |
| +whitelisted on web (`ui.js CHIP_TYPES`) and unknown to native clients, which |
| +drop unrecognised chips — the reply text still reads fine. Natural next |
| +rungs: the panel's `side` placement everywhere (#816 ships it), surfacing |
| +memory in the member's bot-home threads, and a deliberate design for whether |
| +any of it may inform group replies. |
diff --git a/internal/bot/brain.go b/internal/bot/brain.go| index c298f23..6294dc5 100644 |
| --- a/internal/bot/brain.go |
| +++ b/internal/bot/brain.go |
| @@ -197,7 +197,7 @@ func (r *Runtime) systemPrompt() string { |
| // the member to connect first if they haven't), plus the app-builder when the |
| // instance serves the authoring guide. Everything except create_app becomes a |
| // CONSENT CHIP — the client whitelists, and nothing runs until a tap. |
| -func (r *Runtime) replyTools() []anthTool { |
| +func (r *Runtime) replyTools(tc *ThreadCtx) []anthTool { |
| // create_view is an EXECUTOR like the builders (the sidecar posts the view |
| // itself, no tap needed — a view is inert data, docs/views.md), so it lives |
| // here rather than in the forced-retry chip palette. |
| @@ -208,6 +208,11 @@ func (r *Runtime) replyTools() []anthTool { |
| if r.Charters { |
| tools = append(tools, createBotTool(), removeBotTool()) |
| } |
| + // Memory is per-relationship and DM-only (docs/eleven-memory.md): the |
| + // tools exist exactly where the memory lives — this DM's state slot. |
| + if tc != nil && tc.Kind == "dm" { |
| + tools = append(tools, rememberTool(), forgetTool()) |
| + } |
| return tools |
| } |
| @@ -483,13 +488,16 @@ func (r *Runtime) modelReply(tc *ThreadCtx) (Reply, bool) { |
| if len(context) == 0 { |
| context = []anthMessage{{Role: "user", Content: "Say hello."}} |
| } |
| - // In a member bot's home thread the brain also gets the charter — appended |
| - // AFTER the cached block, so the big stable prompt still caches and the |
| - // per-thread addendum rides uncached. |
| + // Per-thread system addenda ride AFTER the cached block, so the big stable |
| + // prompt still caches: a member bot's home thread gets its charter; a DM |
| + // gets the memory of this member (docs/eleven-memory.md). |
| system := r.systemBlocks() |
| if note := r.charterHomeNote(tc); note != "" { |
| system = append(system, anthBlock{Type: "text", Text: note}) |
| } |
| + if tc.Kind == "dm" { |
| + system = append(system, anthBlock{Type: "text", Text: r.memoryNote(tc)}) |
| + } |
| resp, err := r.ai.create(anthRequest{ |
| Model: r.model, |
| // Thinking, text and any tool call share this budget — a tight cap |
| @@ -497,7 +505,7 @@ func (r *Runtime) modelReply(tc *ThreadCtx) (Reply, bool) { |
| MaxTokens: 4096, |
| System: system, |
| Thinking: adaptiveThinking(r.model), |
| - Tools: r.replyTools(), |
| + Tools: r.replyTools(tc), |
| Messages: context, |
| }) |
| if err != nil { |
| @@ -572,6 +580,20 @@ func (r *Runtime) modelReply(tc *ThreadCtx) (Reply, bool) { |
| } |
| } |
| actions := actionsFromToolUse(resp.Content) |
| + // Memory writes (remember/forget, memory.go) run inline — two small REST |
| + // calls — and every successful write announces itself: the sidecar (never |
| + // the model) appends an open_memory chip, so the note and the door to the |
| + // scratchpad arrive in the same breath. DM-only, like the tools. |
| + if tc.Kind == "dm" { |
| + for _, b := range resp.Content { |
| + if b.Type != "tool_use" || (b.Name != "remember" && b.Name != "forget") { |
| + continue |
| + } |
| + if label, ok := r.runMemoryOp(tc, b); ok { |
| + actions = append(actions, map[string]any{"type": "open_memory", "label": label}) |
| + } |
| + } |
| + } |
| // Self-heal the promised-button case: threads full of pre-annotation chip |
| // texts bait the model into promising a button WITHOUT the tool call. The |
| // promise is detectable, so make it deterministic: ask once more with a |
diff --git a/internal/bot/knowledge.md b/internal/bot/knowledge.md| index 3ec1f09..68f992f 100644 |
| --- a/internal/bot/knowledge.md |
| +++ b/internal/bot/knowledge.md |
| @@ -378,6 +378,16 @@ The **mini-app commands** (each app owns its own; see §12): `/todo`, `/remind`, |
| a typeahead listing what's available in the current thread — bot commands only |
| appear where a bot can actually answer them. |
| +`/memory` (in a DM with an AI helper) opens the **memory scratchpad**: the |
| +small set of facts that helper remembers about you. Memory is per-chat and |
| +end-to-end encrypted under that DM's own key — only you and that helper can |
| +read it, and it's fully yours: see it, edit any note, add your own, or erase |
| +everything, any time (also in the chat's ⋮ menu → Memory). The helper saves a |
| +note only for durable things — something you tell it about yourself, a |
| +standing preference, or anything you ask it to remember — and every save |
| +announces itself with a "See what I remember" button. Ask "what do you |
| +remember about me?" or say "forget that" and it answers from the same list. |
| + |
| If you type a `/word` that isn't a real command, you get a private "that isn't a |
| command — type `/` to see what's available" reply; it's never broadcast to the |
| thread. |
diff --git a/internal/bot/memory.go b/internal/bot/memory.go| new file mode 100644 |
| index 0000000..4a467ef |
| --- /dev/null |
| +++ b/internal/bot/memory.go |
| @@ -0,0 +1,233 @@ |
| +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() |
| +} |
diff --git a/internal/bot/memory_test.go b/internal/bot/memory_test.go| new file mode 100644 |
| index 0000000..9ffe321 |
| --- /dev/null |
| +++ b/internal/bot/memory_test.go |
| @@ -0,0 +1,63 @@ |
| +package bot |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// TestClampMemory pins the read-side clamp (the scratchpad and the sidecar |
| +// share the slot unsigned, so both sides re-clamp whatever they read — keep |
| +// this in step with web/static/apps/memory.js sanitizeMemory). |
| +func TestClampMemory(t *testing.T) { |
| + long := strings.Repeat("x", maxMemFactChars+50) |
| + m := memoryState{Facts: []memFact{ |
| + {ID: "m1", Text: " keeps replies short "}, |
| + {Text: "no id — repaired"}, |
| + {ID: "m3", Text: " "}, // blank — dropped |
| + {ID: "m4", Text: long}, |
| + }} |
| + clampMemory(&m) |
| + if m.V != 1 || len(m.Facts) != 3 { |
| + t.Fatalf("clamped shape: v=%d facts=%d", m.V, len(m.Facts)) |
| + } |
| + if m.Facts[0].Text != "keeps replies short" { |
| + t.Errorf("trim: %q", m.Facts[0].Text) |
| + } |
| + if m.Facts[1].ID == "" { |
| + t.Error("missing id not repaired") |
| + } |
| + if len(m.Facts[2].Text) != maxMemFactChars { |
| + t.Errorf("overlong fact not clamped: %d", len(m.Facts[2].Text)) |
| + } |
| + |
| + over := memoryState{} |
| + for i := 0; i < maxMemFacts+10; i++ { |
| + over.Facts = append(over.Facts, memFact{ID: newMemID(), Text: "fact"}) |
| + } |
| + clampMemory(&over) |
| + if len(over.Facts) != maxMemFacts { |
| + t.Fatalf("cap: %d facts survived", len(over.Facts)) |
| + } |
| +} |
| + |
| +// TestMemoryToolsDMOnly pins the gate: remember/forget exist exactly where |
| +// the memory lives — a DM — and never in groups. |
| +func TestMemoryToolsDMOnly(t *testing.T) { |
| + r := &Runtime{Session: &Session{Name: "Eleven"}} |
| + has := func(tools []anthTool, name string) bool { |
| + for _, tl := range tools { |
| + if tl.Name == name { |
| + return true |
| + } |
| + } |
| + return false |
| + } |
| + dm := r.replyTools(&ThreadCtx{Kind: "dm"}) |
| + if !has(dm, "remember") || !has(dm, "forget") { |
| + t.Error("DM palette missing the memory tools") |
| + } |
| + group := r.replyTools(&ThreadCtx{Kind: "group"}) |
| + if has(group, "remember") || has(group, "forget") { |
| + t.Error("group palette must not carry the memory tools") |
| + } |
| +} |
diff --git a/internal/bot/view_test.go b/internal/bot/view_test.go| index 0055779..a3d267a 100644 |
| --- a/internal/bot/view_test.go |
| +++ b/internal/bot/view_test.go |
| @@ -98,7 +98,7 @@ func TestCreateViewToolDeclared(t *testing.T) { |
| return false |
| } |
| r := &Runtime{} |
| - if !has(r.replyTools()) { |
| + if !has(r.replyTools(&ThreadCtx{Kind: "dm"})) { |
| t.Error("create_view missing from the conversational palette") |
| } |
| if !has(researchTools([]string{"github"})) { |
diff --git a/package.json b/package.json| index 158a4cb..915250e 100644 |
| --- a/package.json |
| +++ b/package.json |
| @@ -3,6 +3,6 @@ |
| "private": true, |
| "type": "module", |
| "scripts": { |
| - "test": "node test/crypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/views.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/status-contract.test.mjs && node test/store.test.mjs && node test/search.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs" |
| + "test": "node test/crypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/views.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/status-contract.test.mjs && node test/store.test.mjs && node test/memory.test.mjs && node test/search.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs" |
| } |
| } |
diff --git a/test/memory.test.mjs b/test/memory.test.mjs| new file mode 100644 |
| index 0000000..895a281 |
| --- /dev/null |
| +++ b/test/memory.test.mjs |
| @@ -0,0 +1,42 @@ |
| +// Tests for the memory scratchpad's pure half (web/static/apps/memory.js — |
| +// docs/eleven-memory.md). node test/memory.test.mjs |
| +import assert from "node:assert/strict"; |
| +import { sanitizeMemory, MAX_FACTS, MAX_FACT_CHARS } from "../web/static/apps/memory.js"; |
| + |
| +// sanitizeMemory clamps whatever the slot held — the sidecar and the panel |
| +// share it unsigned, so read-side clamping is the invariant (mirror of the Go |
| +// side's clampMemory; keep the two in step). |
| +{ |
| + const raw = { |
| + facts: [ |
| + { id: "m1", text: " keeps replies short " }, |
| + { text: "no id — repaired" }, |
| + { id: "m3", text: " " }, // blank — dropped |
| + { id: "bad id!", text: "hostile id — replaced" }, |
| + { id: "m5", text: "x".repeat(MAX_FACT_CHARS + 50) }, |
| + "not an object", |
| + ], |
| + }; |
| + const m = sanitizeMemory(raw); |
| + assert.equal(m.v, 1); |
| + assert.equal(m.facts.length, 4); |
| + assert.equal(m.facts[0].text, "keeps replies short"); |
| + assert.ok(m.facts[1].id, "missing id repaired"); |
| + assert.match(m.facts[2].id, /^[A-Za-z0-9_-]+$/, "hostile id replaced"); |
| + assert.equal(m.facts[3].text.length, MAX_FACT_CHARS, "overlong fact clamped"); |
| +} |
| + |
| +// The fact cap holds however many the blob carried. |
| +{ |
| + const raw = { facts: Array.from({ length: MAX_FACTS + 10 }, (_, i) => ({ id: "m" + i, text: "fact " + i })) }; |
| + assert.equal(sanitizeMemory(raw).facts.length, MAX_FACTS); |
| +} |
| + |
| +// Hopeless shapes fold to empty, never throw. |
| +for (const raw of [null, undefined, {}, { facts: "nope" }, { facts: 42 }]) { |
| + const m = sanitizeMemory(raw); |
| + assert.equal(m.v, 1); |
| + assert.deepEqual(m.facts, []); |
| +} |
| + |
| +console.log("memory.test.mjs: ALL PASSED"); |
diff --git a/web/static/apps.js b/web/static/apps.js| index ac16808..69b0370 100644 |
| --- a/web/static/apps.js |
| +++ b/web/static/apps.js |
| @@ -90,6 +90,7 @@ injectStyles( |
| font-size: 12px; color: var(--muted); |
| } |
| .cmd-panel-title { font-weight: 600; flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| +.cmd-panel-extra { flex: 0 0 auto; display: flex; align-items: center; } |
| .cmd-panel-x { |
| flex: 0 0 auto; border: none; background: none; color: var(--muted); |
| font-size: 16px; line-height: 1; padding: 2px 4px; cursor: pointer; border-radius: 6px; |
| @@ -849,6 +850,17 @@ let threadIsGroupFn = null; |
| export function setThreadIsGroup(fn) { |
| threadIsGroupFn = fn; |
| } |
| +// The memory scratchpad's store (web/static/apps/memory.js): shell.js wires |
| +// load/save closures that hold the thread keys and the session token, so the |
| +// app file itself never touches crypto. load(threadId) → { memory, version }; |
| +// save(threadId, memory, version) → new version (throws on a 409 conflict). |
| +let memoryStoreFns = null; |
| +export function setMemoryStore(fns) { |
| + memoryStoreFns = fns; |
| +} |
| +export function getMemoryStore() { |
| + return memoryStoreFns; |
| +} |
| // Which app OWNS a thread (a kind=="self" app-thread's app name, else null) — |
| // shell.js injects its threadApp lookup. This is what scopes local commands and |
| // the default command to the app's own thread. Absent → no thread is app-owned. |
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js| index 27784cc..7dd18ad 100644 |
| --- a/web/static/apps/chat.js |
| +++ b/web/static/apps/chat.js |
| @@ -3149,7 +3149,21 @@ function buildSkeleton(el, ctx) { |
| x.setAttribute("aria-label", "Dismiss"); |
| x.innerHTML = icon("x"); |
| x.onclick = () => hidePanel(); |
| - head.append(title, x); |
| + // headExtra: a spec-provided slot in the title row, between the title and |
| + // the dismiss — for a panel's one standing control (Memory's "Forget…"), |
| + // so it sits inline with the name instead of eating body space. |
| + if (typeof panelSpec.headExtra === "function") { |
| + const extra = document.createElement("span"); |
| + extra.className = "cmd-panel-extra"; |
| + try { |
| + panelSpec.headExtra(extra); |
| + } catch (e) { |
| + console.error(e); |
| + } |
| + head.append(title, extra, x); |
| + } else { |
| + head.append(title, x); |
| + } |
| const body = document.createElement("div"); |
| body.className = "cmd-panel-body"; |
| panelBox.append(head, body); |
diff --git a/web/static/apps/memory.js b/web/static/apps/memory.js| new file mode 100644 |
| index 0000000..6d5f8cc |
| --- /dev/null |
| +++ b/web/static/apps/memory.js |
| @@ -0,0 +1,355 @@ |
| +// Memory — the scratchpad of what an AI helper remembers about YOU, yours to |
| +// read, edit, and erase (docs/eleven-memory.md). The data is a small set of |
| +// facts stored in the "memory" slot of your DM thread's state store, |
| +// encrypted under the DM's thread key — E2EE to exactly you and the bot, per |
| +// relationship. The sidecar reads the same slot into the bot's context and |
| +// writes it via the remember/forget tools; this panel is the member's half: |
| +// nothing the bot remembers is ever invisible or uneditable. |
| +// |
| +// The store rides a host bridge (setMemoryStore in apps.js, wired by |
| +// shell.js, which holds the thread keys) — this file never touches crypto. |
| +import { defineApp, getMemoryStore, openCommandPanel, motionOK } from "../apps.js"; |
| + |
| +export const MAX_FACTS = 24; |
| +export const MAX_FACT_CHARS = 280; |
| + |
| +// sanitizeMemory clamps a decrypted memory blob whoever wrote it (the sidecar |
| +// and this panel share the slot; read-side clamping is the invariant, exactly |
| +// like the Go side's clampMemory — keep the two in step). Pure; Node-tested. |
| +export function sanitizeMemory(raw) { |
| + const facts = []; |
| + const list = raw && Array.isArray(raw.facts) ? raw.facts : []; |
| + for (const f of list) { |
| + if (facts.length === MAX_FACTS) break; |
| + if (!f || typeof f !== "object") continue; |
| + const text = (typeof f.text === "string" ? f.text : "").trim().slice(0, MAX_FACT_CHARS); |
| + if (!text) continue; |
| + const id = typeof f.id === "string" && /^[A-Za-z0-9_-]{1,64}$/.test(f.id) ? f.id : "m" + facts.length + Date.now().toString(36); |
| + facts.push({ id, text, ts: typeof f.ts === "number" ? f.ts : 0 }); |
| + } |
| + return { v: 1, facts }; |
| +} |
| + |
| +defineApp({ |
| + name: "memory", |
| + title: "Memory", |
| + glyph: "🧠", |
| + scope: "system", // a scratchpad panel over the current DM — never a listed app |
| + commands: { |
| + "/memory": { |
| + desc: "See and edit what this AI remembers about you", |
| + run: (args, ctx) => { |
| + const r = openMemoryPanel(ctx.threadId); |
| + if (r) ctx.reply({ text: r }); |
| + }, |
| + }, |
| + }, |
| + styles: memoryStyles(), |
| +}); |
| + |
| +// openMemoryPanel opens the scratchpad for a thread (the /memory command, the |
| +// open_memory chip, and the thread menu all land here). Returns "" on |
| +// success, or the in-place explanation when the surface can't open. |
| +export function openMemoryPanel(threadId) { |
| + const store = getMemoryStore(); |
| + if (!store) return "Memory isn't available here."; |
| + // headSlot hands mountScratchpad the title-row slot where its Forget… |
| + // control lives (chat.js renders the head before the body mounts). |
| + const headSlot = {}; |
| + const opened = openCommandPanel(threadId, { |
| + // No sender glyph: the Views dropdown row and the header button already |
| + // carry the identity — the panel head repeating it read as clutter. |
| + title: "Memory", |
| + position: "side", |
| + headExtra: (el) => { |
| + headSlot.el = el; |
| + }, |
| + render: (el, panel) => mountScratchpad(el, panel, threadId, store, headSlot), |
| + }); |
| + return opened ? "" : "Open a chat with your AI helper to see its memory."; |
| +} |
| + |
| +// mountScratchpad loads then renders — every mutation writes the WHOLE blob |
| +// at the version read (the store 409s a concurrent write; we reload and the |
| +// member redoes the edit — memory edits are seconds apart in practice). |
| +async function mountScratchpad(el, panel, threadId, store, headSlot = {}) { |
| + el.replaceChildren(); |
| + const note = document.createElement("p"); |
| + note.className = "memory-note"; |
| + note.textContent = "Loading…"; |
| + el.appendChild(note); |
| + let loaded; |
| + try { |
| + loaded = await store.load(threadId); |
| + } catch { |
| + loaded = null; |
| + } |
| + if (!el.isConnected) return; |
| + if (!loaded) { |
| + note.textContent = "Memory isn't available in this chat."; |
| + return; |
| + } |
| + const state = { mem: sanitizeMemory(loaded.memory), version: loaded.version, notice: "", flash: "" }; |
| + // The forget flow: idle → armed (tap again) → counting (cancellable) → gone. |
| + let wipeTimer = null; |
| + const cancelWipe = () => { |
| + if (wipeTimer) clearInterval(wipeTimer); |
| + wipeTimer = null; |
| + state.phase = "idle"; |
| + }; |
| + |
| + const save = async () => { |
| + try { |
| + state.version = await store.save(threadId, state.mem, state.version); |
| + state.notice = ""; |
| + } catch (e) { |
| + // A concurrent write (another device, or the bot mid-conversation) won: |
| + // reload and show the latest — the member redoes their edit against it. |
| + try { |
| + const fresh = await store.load(threadId); |
| + state.mem = sanitizeMemory(fresh.memory); |
| + state.version = fresh.version; |
| + state.notice = "Updated somewhere else just now — showing the latest."; |
| + } catch { |
| + state.notice = "That didn't save — try again."; |
| + } |
| + } |
| + draw(); |
| + }; |
| + |
| + // Forget… lives INLINE with the panel title (the headExtra slot) — |
| + // forgetting is three deliberate beats: arm, confirm, a cancellable |
| + // countdown — so an erase is always an intention, never a slip. One |
| + // persistent button, repainted per phase (the head renders once). |
| + const wipe = document.createElement("button"); |
| + wipe.type = "button"; |
| + wipe.className = "memory-wipe"; |
| + // The countdown is just the number, beside Cancel in the title row — the |
| + // flipped button already says what's happening. |
| + const count = document.createElement("span"); |
| + count.className = "memory-count"; |
| + count.setAttribute("role", "status"); |
| + if (headSlot.el) headSlot.el.append(count, wipe); |
| + const paintWipe = () => { |
| + wipe.style.display = state.mem.facts.length ? "" : "none"; |
| + wipe.classList.toggle("armed", state.phase === "armed"); |
| + // The head status span pulls double duty: the ticking number while |
| + // counting, and "All forgotten ✨" where the button was — until a new |
| + // note brings Forget… back and clears it. |
| + if (state.mem.facts.length) state.flash = ""; |
| + count.classList.toggle("flash", !!state.flash && state.phase !== "counting"); |
| + count.textContent = state.phase === "counting" ? `${state.count}…` : state.flash; |
| + if (state.phase === "counting") { |
| + wipe.textContent = "Cancel"; |
| + wipe.onclick = () => { |
| + cancelWipe(); |
| + draw(); |
| + }; |
| + } else if (state.phase === "armed") { |
| + wipe.textContent = "Erase it all?"; |
| + wipe.onclick = () => { |
| + state.phase = "counting"; |
| + state.count = 5; |
| + wipeTimer = setInterval(() => { |
| + if (!el.isConnected) return cancelWipe(); |
| + state.count--; |
| + if (state.count > 0) return draw(); |
| + cancelWipe(); |
| + // The flourish: rows drift out together, then the erase commits. |
| + // Reduced motion skips straight to the result. |
| + const list = el.querySelector(".memory-list"); |
| + if (list && motionOK()) { |
| + list.classList.add("memory-vanish"); |
| + setTimeout(finishWipe, 650); |
| + } else { |
| + finishWipe(); |
| + } |
| + }, 1000); |
| + draw(); |
| + }; |
| + } else { |
| + wipe.textContent = "Forget…"; |
| + wipe.onclick = () => { |
| + state.phase = "armed"; |
| + draw(); |
| + setTimeout(() => { |
| + if (state.phase === "armed") { |
| + state.phase = "idle"; |
| + draw(); |
| + } |
| + }, 4000); |
| + }; |
| + } |
| + }; |
| + |
| + const draw = () => { |
| + if (!el.isConnected) return cancelWipe(); |
| + paintWipe(); |
| + el.replaceChildren(); |
| + |
| + const intro = document.createElement("p"); |
| + intro.className = "memory-note"; |
| + intro.textContent = |
| + "What your AI helper remembers about you in this chat. It's yours: edit or remove anything, " + |
| + "and it's stored encrypted — only the two of you can read it."; |
| + el.appendChild(intro); |
| + |
| + if (state.notice) { |
| + const n = document.createElement("p"); |
| + n.className = "memory-notice"; |
| + n.setAttribute("role", "status"); |
| + n.textContent = state.notice; |
| + el.appendChild(n); |
| + } |
| + |
| + // Add a fact by hand, right at the top — memory is the member's as much |
| + // as the bot's, and the newest note belongs where the eye already is. |
| + const addRow = document.createElement("form"); |
| + addRow.className = "memory-add"; |
| + const input = document.createElement("input"); |
| + input.type = "text"; |
| + input.maxLength = MAX_FACT_CHARS; |
| + input.placeholder = "Add something it should remember…"; |
| + input.setAttribute("aria-label", "Add a memory"); |
| + const add = document.createElement("button"); |
| + add.type = "submit"; |
| + add.textContent = "Add"; |
| + addRow.append(input, add); |
| + addRow.onsubmit = (e) => { |
| + e.preventDefault(); |
| + const text = input.value.trim().slice(0, MAX_FACT_CHARS); |
| + if (!text) return; |
| + if (state.mem.facts.length >= MAX_FACTS) { |
| + state.notice = `Memory holds ${MAX_FACTS} notes — remove one first.`; |
| + state.refocusAdd = true; |
| + draw(); |
| + return; |
| + } |
| + state.mem.facts.unshift({ id: "m" + Date.now().toString(36), text, ts: Date.now() }); |
| + state.refocusAdd = true; |
| + save(); |
| + }; |
| + el.appendChild(addRow); |
| + if (state.refocusAdd) { |
| + // Adding rebuilds the panel — hand focus back to the input so a run of |
| + // notes types straight through (draw() is synchronous, but save()'s |
| + // round-trip redraw isn't; consume the flag whenever the redraw lands). |
| + state.refocusAdd = false; |
| + input.focus(); |
| + } |
| + |
| + const ul = document.createElement("ul"); |
| + ul.className = "memory-list"; |
| + for (const f of state.mem.facts) { |
| + ul.appendChild(factRow(f)); |
| + } |
| + if (!state.mem.facts.length) { |
| + const empty = document.createElement("p"); |
| + empty.className = "memory-empty"; |
| + empty.textContent = "Nothing here yet. Ask it to remember something — or add a note yourself above."; |
| + el.appendChild(empty); |
| + } else { |
| + el.appendChild(ul); |
| + } |
| + }; |
| + |
| + const finishWipe = () => { |
| + state.mem.facts = []; |
| + state.flash = "All forgotten ✨"; |
| + save(); |
| + }; |
| + |
| + const factRow = (f) => { |
| + const li = document.createElement("li"); |
| + li.className = "memory-fact"; |
| + const text = document.createElement("span"); |
| + text.className = "memory-fact-text"; |
| + text.textContent = f.text; |
| + const edit = document.createElement("button"); |
| + edit.type = "button"; |
| + edit.className = "memory-fact-btn"; |
| + edit.setAttribute("aria-label", `Edit "${f.text.slice(0, 40)}"`); |
| + edit.textContent = "✎"; |
| + edit.onclick = () => { |
| + const editor = document.createElement("form"); |
| + editor.className = "memory-edit"; |
| + const ta = document.createElement("textarea"); |
| + ta.maxLength = MAX_FACT_CHARS; |
| + ta.value = f.text; |
| + ta.setAttribute("aria-label", "Edit this memory"); |
| + const saveBtn = document.createElement("button"); |
| + saveBtn.type = "submit"; |
| + saveBtn.textContent = "Save"; |
| + const cancel = document.createElement("button"); |
| + cancel.type = "button"; |
| + cancel.textContent = "Cancel"; |
| + cancel.onclick = () => draw(); |
| + editor.append(ta, saveBtn, cancel); |
| + editor.onsubmit = (e) => { |
| + e.preventDefault(); |
| + const t = ta.value.trim().slice(0, MAX_FACT_CHARS); |
| + if (!t) return; |
| + f.text = t; |
| + save(); |
| + }; |
| + li.replaceChildren(editor); |
| + ta.focus(); |
| + }; |
| + const del = document.createElement("button"); |
| + del.type = "button"; |
| + del.className = "memory-fact-btn"; |
| + del.setAttribute("aria-label", `Remove "${f.text.slice(0, 40)}"`); |
| + del.textContent = "✕"; |
| + del.onclick = () => { |
| + state.mem.facts = state.mem.facts.filter((x) => x.id !== f.id); |
| + save(); |
| + }; |
| + li.append(text, edit, del); |
| + return li; |
| + }; |
| + |
| + draw(); |
| +} |
| + |
| +function memoryStyles() { |
| + return ` |
| + .memory-note { margin: 4px 0 10px; font-size: 12px; color: var(--muted); } |
| + .memory-notice { margin: 0 0 8px; font-size: 12px; color: var(--muted); font-style: italic; } |
| + .memory-empty { margin: 12px 0; font-size: 13px; color: var(--muted); } |
| + .memory-list { list-style: none; margin: 0 0 10px; padding: 0; display: flex; flex-direction: column; gap: 4px; } |
| + .memory-fact { display: flex; align-items: flex-start; gap: 6px; padding: 8px 10px; border-radius: 10px; background: var(--bubble-them); } |
| + .memory-fact-text { flex: 1 1 auto; min-width: 0; font-size: 13px; line-height: 1.4; overflow-wrap: anywhere; } |
| + .memory-fact-btn { |
| + flex: 0 0 auto; border: none; background: none; color: var(--muted); cursor: pointer; |
| + font-size: 13px; line-height: 1; padding: 4px 6px; border-radius: 6px; min-width: 24px; min-height: 24px; |
| + } |
| + .memory-fact-btn:hover, .memory-fact-btn:focus-visible { color: var(--ink); background: var(--hover, rgba(127,127,127,.12)); } |
| + .memory-add { display: flex; gap: 6px; margin: 4px 0 10px; } |
| + .memory-add input { |
| + flex: 1 1 auto; min-width: 0; font: inherit; font-size: 13px; padding: 8px 10px; |
| + border: 1px solid var(--border); border-radius: 10px; background: var(--panel); color: var(--ink); |
| + } |
| + .memory-add button, .memory-edit button { |
| + font: inherit; font-size: 13px; padding: 8px 12px; border: 1px solid var(--border); |
| + border-radius: 10px; background: var(--panel); color: var(--ink); cursor: pointer; min-height: 32px; |
| + } |
| + .memory-add button:hover, .memory-edit button:hover { background: var(--hover, rgba(127,127,127,.12)); } |
| + .memory-edit { display: flex; flex-direction: column; gap: 6px; width: 100%; } |
| + .memory-edit textarea { |
| + font: inherit; font-size: 13px; padding: 8px 10px; min-height: 60px; resize: vertical; |
| + border: 1px solid var(--border); border-radius: 10px; background: var(--panel); color: var(--ink); |
| + } |
| + .memory-wipe { |
| + flex: 0 0 auto; font: inherit; font-size: 12px; padding: 4px 8px; border: none; border-radius: 8px; |
| + background: none; color: var(--danger, #c0392b); cursor: pointer; min-height: 24px; white-space: nowrap; |
| + } |
| + .memory-wipe:hover, .memory-wipe:focus-visible { background: var(--hover, rgba(192,57,43,.1)); } |
| + .memory-wipe.armed { background: var(--danger, #c0392b); color: #fff; } |
| + .memory-count { font-size: 12px; font-weight: 600; color: var(--danger, #c0392b); margin-right: 2px; font-variant-numeric: tabular-nums; white-space: nowrap; } |
| + .memory-count.flash { color: var(--muted); font-weight: 500; } |
| + .memory-list.memory-vanish .memory-fact { |
| + opacity: 0; transform: translateY(-6px) scale(0.98); |
| + transition: opacity 0.5s ease, transform 0.5s ease; |
| + } |
| + `; |
| +} |
diff --git a/web/static/shell.js b/web/static/shell.js| index c5335a2..48bb89b 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -109,6 +109,7 @@ import { |
| renderInline as renderAppLine, |
| setCommandPanelObserver, |
| closeCommandPanel, |
| + setMemoryStore, |
| } from "/static/apps.js"; |
| // Register the mini-apps (side-effect imports). Each is a single self-contained |
| // file (JS + its own CSS). Personal apps get their own sidebar thread; shared |
| @@ -133,6 +134,7 @@ import { startWelcome } from "/static/apps/welcome.js"; // the joiner welcome co |
| import { planQuickForm } from "/static/apps/quickform.js"; // "Fill in a form instead" — values → verbs+records |
| import "/static/apps/admin.js"; // the admin panel (admin-only; opened via /admin or setup's chip) |
| import "/static/apps/appstudio.js"; // make + manage your own member apps (docs/member-apps.md) |
| +import { openMemoryPanel } from "/static/apps/memory.js"; // /memory — the AI-memory scratchpad (docs/eleven-memory.md) |
| import { setHost as setChatAppHost, setConnected as setChatAppConnected, appendNotice, setNoticeTyping, feedGlideToBottom, scrollToMessage } from "/static/apps/chat.js"; |
| import { SearchIndex, snippetFor } from "/static/search.js"; // message search over the local store (#586) |
| import { loadMemberApps, setMemberSeeder, setSurfaceRefresh, setMemberAdmin, recentLocalDeploy, pushTheme } from "/static/sandbox.js"; // member-built apps, run sandboxed (docs/member-apps.md) |
| @@ -340,6 +342,10 @@ const CHIP_RUNNERS = { |
| // panel above the feed from state already folded here. Returns the in-place |
| // line when the view isn't in the fold (yet) — never throws. |
| open_view: (a) => openThreadView(activeId, a.view) || null, |
| + // The memory scratchpad, for the thread the chip was tapped in (the chip is |
| + // sidecar-attached on every remember/forget write — transparency + the door |
| + // in one tap). Returns null: the chip keeps its label for re-taps. |
| + open_memory: () => (openMemoryPanel(activeId), null), |
| // Connected-service op: the tap IS the consent (a write chip's label carries |
| // the manifest's confirm line). Lock the chip immediately so a double-tap |
| // can't run the op twice; the run is async and its outcome lands as a private |
| @@ -407,6 +413,7 @@ const chipLabel = (a) => |
| open_tutorial: "Take the tour", |
| open_thread: "Open the chat", |
| open_view: "Open the view", |
| + open_memory: "See what I remember", |
| service_op: `Run ${a.op} on ${a.service}`, |
| service_research: `Look at ${(a.services || []).join(" & ")} (read-only)`, |
| // The command IS the label (ui.js strips any free label) — a run_command |
| @@ -5463,6 +5470,41 @@ async function seedAppState(threadId) { |
| } |
| // Persist a fresh snapshot when folds made new state (debounced by apps.js). |
| +// The memory scratchpad's store (apps/memory.js): the "memory" slot of the |
| +// thread's state store, encrypted under the thread key — E2EE to exactly this |
| +// DM's two participants. Same endpoint + versioning as the app snapshots |
| +// above; a different slot, so the two never contend. |
| +setMemoryStore({ |
| + load: async (threadId) => { |
| + const key = threadKeys[threadId]; |
| + if (!key) return null; |
| + const r = await fetch( |
| + `/api/threads/${encodeURIComponent(threadId)}/state?token=${encodeURIComponent(session.token)}&slot=memory`, |
| + ); |
| + if (!r.ok) return null; |
| + const { blob, version } = await r.json(); |
| + let memory = null; |
| + if (blob) { |
| + try { |
| + memory = await decrypt(key, blob); |
| + } catch {} // undecryptable → treat as empty (the panel starts fresh) |
| + } |
| + return { memory, version: version || 0 }; |
| + }, |
| + save: async (threadId, memory, version) => { |
| + const key = threadKeys[threadId]; |
| + if (!key) throw new Error("no key"); |
| + const blob = await encrypt(key, memory); |
| + const r = await fetch(`/api/threads/${encodeURIComponent(threadId)}/state`, { |
| + method: "PUT", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ token: session.token, slot: "memory", blob, version: version || 0 }), |
| + }); |
| + if (!r.ok) throw new Error(`save ${r.status}`); |
| + return (await r.json()).version || 0; |
| + }, |
| +}); |
| + |
| setStateSaver(async (threadId) => { |
| const key = threadKeys[threadId]; |
| if (!key) return; |
diff --git a/web/static/ui.js b/web/static/ui.js| index 359e5f1..f647ac9 100644 |
| --- a/web/static/ui.js |
| +++ b/web/static/ui.js |
| @@ -293,6 +293,11 @@ export const CHIP_TYPES = { |
| // re-clamped) from the thread's own log, so a crafted chip can at worst |
| // open a view the member could open anyway with /views. |
| open_view: { param: "view", valid: (v) => /^[A-Za-z0-9_-]{1,64}$/.test(v) }, |
| + // Open the memory scratchpad for the thread the chip sits in — how a |
| + // remember/forget write announces itself (the sidecar attaches this, never |
| + // the model). Navigation-free and read-only at the tap: it opens a panel |
| + // over state already synced to this device. |
| + open_memory: {}, |
| // Connected-service op (the integrations registry, docs/integrations.md): a |
| // bot proposes running one manifest operation and the tap is the member's |
| // consent — for writes, the chip's label carries the manifest's confirm line |