eleven / messenger Public

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>
Paul Campbell b770f85c28c51fef96d2b2a22ea9173a03fbc252 parent df2872f
15 files changed, +1080 −8
  • bot_memory_test.go +194 −0
  • changelog/2026-07-13-eleven-memory.md +8 −0
  • docs/eleven-memory.md +72 −0
  • internal/bot/brain.go +27 −5
  • internal/bot/knowledge.md +10 −0
  • internal/bot/memory.go +233 −0
  • internal/bot/memory_test.go +63 −0
  • internal/bot/view_test.go +1 −1
  • package.json +1 −1
  • test/memory.test.mjs +42 −0
  • web/static/apps.js +12 −0
  • web/static/apps/chat.js +15 −1
  • web/static/apps/memory.js +355 −0
  • web/static/shell.js +42 −0
  • web/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