// 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"; import { ICON_BRAIN } from "../icons.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: "🧠", symbol: "brain", // native chrome: the SF Symbols name iOS/Mac render (docs/icons.md) logo: ICON_BRAIN, places: [], // 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; } `; }