| 1 | // Memory — the scratchpad of what an AI helper remembers about YOU, yours to |
| 2 | // read, edit, and erase (docs/eleven-memory.md). The data is a small set of |
| 3 | // facts stored in the "memory" slot of your DM thread's state store, |
| 4 | // encrypted under the DM's thread key — E2EE to exactly you and the bot, per |
| 5 | // relationship. The sidecar reads the same slot into the bot's context and |
| 6 | // writes it via the remember/forget tools; this panel is the member's half: |
| 7 | // nothing the bot remembers is ever invisible or uneditable. |
| 8 | // |
| 9 | // The store rides a host bridge (setMemoryStore in apps.js, wired by |
| 10 | // shell.js, which holds the thread keys) — this file never touches crypto. |
| 11 | import { defineApp, getMemoryStore, openCommandPanel, motionOK } from "../apps.js"; |
| 12 | import { ICON_BRAIN } from "../icons.js"; |
| 13 | |
| 14 | export const MAX_FACTS = 24; |
| 15 | export const MAX_FACT_CHARS = 280; |
| 16 | |
| 17 | // sanitizeMemory clamps a decrypted memory blob whoever wrote it (the sidecar |
| 18 | // and this panel share the slot; read-side clamping is the invariant, exactly |
| 19 | // like the Go side's clampMemory — keep the two in step). Pure; Node-tested. |
| 20 | export function sanitizeMemory(raw) { |
| 21 | const facts = []; |
| 22 | const list = raw && Array.isArray(raw.facts) ? raw.facts : []; |
| 23 | for (const f of list) { |
| 24 | if (facts.length === MAX_FACTS) break; |
| 25 | if (!f || typeof f !== "object") continue; |
| 26 | const text = (typeof f.text === "string" ? f.text : "").trim().slice(0, MAX_FACT_CHARS); |
| 27 | if (!text) continue; |
| 28 | const id = typeof f.id === "string" && /^[A-Za-z0-9_-]{1,64}$/.test(f.id) ? f.id : "m" + facts.length + Date.now().toString(36); |
| 29 | facts.push({ id, text, ts: typeof f.ts === "number" ? f.ts : 0 }); |
| 30 | } |
| 31 | return { v: 1, facts }; |
| 32 | } |
| 33 | |
| 34 | defineApp({ |
| 35 | name: "memory", |
| 36 | title: "Memory", |
| 37 | glyph: "🧠", |
| 38 | symbol: "brain", // native chrome: the SF Symbols name iOS/Mac render (docs/icons.md) |
| 39 | logo: ICON_BRAIN, |
| 40 | places: [], // a scratchpad panel over the current DM — never a listed app |
| 41 | commands: { |
| 42 | "/memory": { |
| 43 | desc: "See and edit what this AI remembers about you", |
| 44 | run: (args, ctx) => { |
| 45 | const r = openMemoryPanel(ctx.threadId); |
| 46 | if (r) ctx.reply({ text: r }); |
| 47 | }, |
| 48 | }, |
| 49 | }, |
| 50 | styles: memoryStyles(), |
| 51 | }); |
| 52 | |
| 53 | // openMemoryPanel opens the scratchpad for a thread (the /memory command, the |
| 54 | // open_memory chip, and the thread menu all land here). Returns "" on |
| 55 | // success, or the in-place explanation when the surface can't open. |
| 56 | export function openMemoryPanel(threadId) { |
| 57 | const store = getMemoryStore(); |
| 58 | if (!store) return "Memory isn't available here."; |
| 59 | // headSlot hands mountScratchpad the title-row slot where its Forget… |
| 60 | // control lives (chat.js renders the head before the body mounts). |
| 61 | const headSlot = {}; |
| 62 | const opened = openCommandPanel(threadId, { |
| 63 | // No sender glyph: the Views dropdown row and the header button already |
| 64 | // carry the identity — the panel head repeating it read as clutter. |
| 65 | title: "Memory", |
| 66 | position: "side", |
| 67 | headExtra: (el) => { |
| 68 | headSlot.el = el; |
| 69 | }, |
| 70 | render: (el, panel) => mountScratchpad(el, panel, threadId, store, headSlot), |
| 71 | }); |
| 72 | return opened ? "" : "Open a chat with your AI helper to see its memory."; |
| 73 | } |
| 74 | |
| 75 | // mountScratchpad loads then renders — every mutation writes the WHOLE blob |
| 76 | // at the version read (the store 409s a concurrent write; we reload and the |
| 77 | // member redoes the edit — memory edits are seconds apart in practice). |
| 78 | async function mountScratchpad(el, panel, threadId, store, headSlot = {}) { |
| 79 | el.replaceChildren(); |
| 80 | const note = document.createElement("p"); |
| 81 | note.className = "memory-note"; |
| 82 | note.textContent = "Loading…"; |
| 83 | el.appendChild(note); |
| 84 | let loaded; |
| 85 | try { |
| 86 | loaded = await store.load(threadId); |
| 87 | } catch { |
| 88 | loaded = null; |
| 89 | } |
| 90 | if (!el.isConnected) return; |
| 91 | if (!loaded) { |
| 92 | note.textContent = "Memory isn't available in this chat."; |
| 93 | return; |
| 94 | } |
| 95 | const state = { mem: sanitizeMemory(loaded.memory), version: loaded.version, notice: "", flash: "" }; |
| 96 | // The forget flow: idle → armed (tap again) → counting (cancellable) → gone. |
| 97 | let wipeTimer = null; |
| 98 | const cancelWipe = () => { |
| 99 | if (wipeTimer) clearInterval(wipeTimer); |
| 100 | wipeTimer = null; |
| 101 | state.phase = "idle"; |
| 102 | }; |
| 103 | |
| 104 | const save = async () => { |
| 105 | try { |
| 106 | state.version = await store.save(threadId, state.mem, state.version); |
| 107 | state.notice = ""; |
| 108 | } catch (e) { |
| 109 | // A concurrent write (another device, or the bot mid-conversation) won: |
| 110 | // reload and show the latest — the member redoes their edit against it. |
| 111 | try { |
| 112 | const fresh = await store.load(threadId); |
| 113 | state.mem = sanitizeMemory(fresh.memory); |
| 114 | state.version = fresh.version; |
| 115 | state.notice = "Updated somewhere else just now — showing the latest."; |
| 116 | } catch { |
| 117 | state.notice = "That didn't save — try again."; |
| 118 | } |
| 119 | } |
| 120 | draw(); |
| 121 | }; |
| 122 | |
| 123 | // Forget… lives INLINE with the panel title (the headExtra slot) — |
| 124 | // forgetting is three deliberate beats: arm, confirm, a cancellable |
| 125 | // countdown — so an erase is always an intention, never a slip. One |
| 126 | // persistent button, repainted per phase (the head renders once). |
| 127 | const wipe = document.createElement("button"); |
| 128 | wipe.type = "button"; |
| 129 | wipe.className = "memory-wipe"; |
| 130 | // The countdown is just the number, beside Cancel in the title row — the |
| 131 | // flipped button already says what's happening. |
| 132 | const count = document.createElement("span"); |
| 133 | count.className = "memory-count"; |
| 134 | count.setAttribute("role", "status"); |
| 135 | if (headSlot.el) headSlot.el.append(count, wipe); |
| 136 | const paintWipe = () => { |
| 137 | wipe.style.display = state.mem.facts.length ? "" : "none"; |
| 138 | wipe.classList.toggle("armed", state.phase === "armed"); |
| 139 | // The head status span pulls double duty: the ticking number while |
| 140 | // counting, and "All forgotten ✨" where the button was — until a new |
| 141 | // note brings Forget… back and clears it. |
| 142 | if (state.mem.facts.length) state.flash = ""; |
| 143 | count.classList.toggle("flash", !!state.flash && state.phase !== "counting"); |
| 144 | count.textContent = state.phase === "counting" ? `${state.count}…` : state.flash; |
| 145 | if (state.phase === "counting") { |
| 146 | wipe.textContent = "Cancel"; |
| 147 | wipe.onclick = () => { |
| 148 | cancelWipe(); |
| 149 | draw(); |
| 150 | }; |
| 151 | } else if (state.phase === "armed") { |
| 152 | wipe.textContent = "Erase it all?"; |
| 153 | wipe.onclick = () => { |
| 154 | state.phase = "counting"; |
| 155 | state.count = 5; |
| 156 | wipeTimer = setInterval(() => { |
| 157 | if (!el.isConnected) return cancelWipe(); |
| 158 | state.count--; |
| 159 | if (state.count > 0) return draw(); |
| 160 | cancelWipe(); |
| 161 | // The flourish: rows drift out together, then the erase commits. |
| 162 | // Reduced motion skips straight to the result. |
| 163 | const list = el.querySelector(".memory-list"); |
| 164 | if (list && motionOK()) { |
| 165 | list.classList.add("memory-vanish"); |
| 166 | setTimeout(finishWipe, 650); |
| 167 | } else { |
| 168 | finishWipe(); |
| 169 | } |
| 170 | }, 1000); |
| 171 | draw(); |
| 172 | }; |
| 173 | } else { |
| 174 | wipe.textContent = "Forget…"; |
| 175 | wipe.onclick = () => { |
| 176 | state.phase = "armed"; |
| 177 | draw(); |
| 178 | setTimeout(() => { |
| 179 | if (state.phase === "armed") { |
| 180 | state.phase = "idle"; |
| 181 | draw(); |
| 182 | } |
| 183 | }, 4000); |
| 184 | }; |
| 185 | } |
| 186 | }; |
| 187 | |
| 188 | const draw = () => { |
| 189 | if (!el.isConnected) return cancelWipe(); |
| 190 | paintWipe(); |
| 191 | el.replaceChildren(); |
| 192 | |
| 193 | const intro = document.createElement("p"); |
| 194 | intro.className = "memory-note"; |
| 195 | intro.textContent = |
| 196 | "What your AI helper remembers about you in this chat. It's yours: edit or remove anything, " + |
| 197 | "and it's stored encrypted — only the two of you can read it."; |
| 198 | el.appendChild(intro); |
| 199 | |
| 200 | if (state.notice) { |
| 201 | const n = document.createElement("p"); |
| 202 | n.className = "memory-notice"; |
| 203 | n.setAttribute("role", "status"); |
| 204 | n.textContent = state.notice; |
| 205 | el.appendChild(n); |
| 206 | } |
| 207 | |
| 208 | // Add a fact by hand, right at the top — memory is the member's as much |
| 209 | // as the bot's, and the newest note belongs where the eye already is. |
| 210 | const addRow = document.createElement("form"); |
| 211 | addRow.className = "memory-add"; |
| 212 | const input = document.createElement("input"); |
| 213 | input.type = "text"; |
| 214 | input.maxLength = MAX_FACT_CHARS; |
| 215 | input.placeholder = "Add something it should remember…"; |
| 216 | input.setAttribute("aria-label", "Add a memory"); |
| 217 | const add = document.createElement("button"); |
| 218 | add.type = "submit"; |
| 219 | add.textContent = "Add"; |
| 220 | addRow.append(input, add); |
| 221 | addRow.onsubmit = (e) => { |
| 222 | e.preventDefault(); |
| 223 | const text = input.value.trim().slice(0, MAX_FACT_CHARS); |
| 224 | if (!text) return; |
| 225 | if (state.mem.facts.length >= MAX_FACTS) { |
| 226 | state.notice = `Memory holds ${MAX_FACTS} notes — remove one first.`; |
| 227 | state.refocusAdd = true; |
| 228 | draw(); |
| 229 | return; |
| 230 | } |
| 231 | state.mem.facts.unshift({ id: "m" + Date.now().toString(36), text, ts: Date.now() }); |
| 232 | state.refocusAdd = true; |
| 233 | save(); |
| 234 | }; |
| 235 | el.appendChild(addRow); |
| 236 | if (state.refocusAdd) { |
| 237 | // Adding rebuilds the panel — hand focus back to the input so a run of |
| 238 | // notes types straight through (draw() is synchronous, but save()'s |
| 239 | // round-trip redraw isn't; consume the flag whenever the redraw lands). |
| 240 | state.refocusAdd = false; |
| 241 | input.focus(); |
| 242 | } |
| 243 | |
| 244 | const ul = document.createElement("ul"); |
| 245 | ul.className = "memory-list"; |
| 246 | for (const f of state.mem.facts) { |
| 247 | ul.appendChild(factRow(f)); |
| 248 | } |
| 249 | if (!state.mem.facts.length) { |
| 250 | const empty = document.createElement("p"); |
| 251 | empty.className = "memory-empty"; |
| 252 | empty.textContent = "Nothing here yet. Ask it to remember something — or add a note yourself above."; |
| 253 | el.appendChild(empty); |
| 254 | } else { |
| 255 | el.appendChild(ul); |
| 256 | } |
| 257 | }; |
| 258 | |
| 259 | const finishWipe = () => { |
| 260 | state.mem.facts = []; |
| 261 | state.flash = "All forgotten ✨"; |
| 262 | save(); |
| 263 | }; |
| 264 | |
| 265 | const factRow = (f) => { |
| 266 | const li = document.createElement("li"); |
| 267 | li.className = "memory-fact"; |
| 268 | const text = document.createElement("span"); |
| 269 | text.className = "memory-fact-text"; |
| 270 | text.textContent = f.text; |
| 271 | const edit = document.createElement("button"); |
| 272 | edit.type = "button"; |
| 273 | edit.className = "memory-fact-btn"; |
| 274 | edit.setAttribute("aria-label", `Edit "${f.text.slice(0, 40)}"`); |
| 275 | edit.textContent = "✎"; |
| 276 | edit.onclick = () => { |
| 277 | const editor = document.createElement("form"); |
| 278 | editor.className = "memory-edit"; |
| 279 | const ta = document.createElement("textarea"); |
| 280 | ta.maxLength = MAX_FACT_CHARS; |
| 281 | ta.value = f.text; |
| 282 | ta.setAttribute("aria-label", "Edit this memory"); |
| 283 | const saveBtn = document.createElement("button"); |
| 284 | saveBtn.type = "submit"; |
| 285 | saveBtn.textContent = "Save"; |
| 286 | const cancel = document.createElement("button"); |
| 287 | cancel.type = "button"; |
| 288 | cancel.textContent = "Cancel"; |
| 289 | cancel.onclick = () => draw(); |
| 290 | editor.append(ta, saveBtn, cancel); |
| 291 | editor.onsubmit = (e) => { |
| 292 | e.preventDefault(); |
| 293 | const t = ta.value.trim().slice(0, MAX_FACT_CHARS); |
| 294 | if (!t) return; |
| 295 | f.text = t; |
| 296 | save(); |
| 297 | }; |
| 298 | li.replaceChildren(editor); |
| 299 | ta.focus(); |
| 300 | }; |
| 301 | const del = document.createElement("button"); |
| 302 | del.type = "button"; |
| 303 | del.className = "memory-fact-btn"; |
| 304 | del.setAttribute("aria-label", `Remove "${f.text.slice(0, 40)}"`); |
| 305 | del.textContent = "✕"; |
| 306 | del.onclick = () => { |
| 307 | state.mem.facts = state.mem.facts.filter((x) => x.id !== f.id); |
| 308 | save(); |
| 309 | }; |
| 310 | li.append(text, edit, del); |
| 311 | return li; |
| 312 | }; |
| 313 | |
| 314 | draw(); |
| 315 | } |
| 316 | |
| 317 | function memoryStyles() { |
| 318 | return ` |
| 319 | .memory-note { margin: 4px 0 10px; font-size: 12px; color: var(--muted); } |
| 320 | .memory-notice { margin: 0 0 8px; font-size: 12px; color: var(--muted); font-style: italic; } |
| 321 | .memory-empty { margin: 12px 0; font-size: 13px; color: var(--muted); } |
| 322 | .memory-list { list-style: none; margin: 0 0 10px; padding: 0; display: flex; flex-direction: column; gap: 4px; } |
| 323 | .memory-fact { display: flex; align-items: flex-start; gap: 6px; padding: 8px 10px; border-radius: 10px; background: var(--bubble-them); } |
| 324 | .memory-fact-text { flex: 1 1 auto; min-width: 0; font-size: 13px; line-height: 1.4; overflow-wrap: anywhere; } |
| 325 | .memory-fact-btn { |
| 326 | flex: 0 0 auto; border: none; background: none; color: var(--muted); cursor: pointer; |
| 327 | font-size: 13px; line-height: 1; padding: 4px 6px; border-radius: 6px; min-width: 24px; min-height: 24px; |
| 328 | } |
| 329 | .memory-fact-btn:hover, .memory-fact-btn:focus-visible { color: var(--ink); background: var(--hover, rgba(127,127,127,.12)); } |
| 330 | .memory-add { display: flex; gap: 6px; margin: 4px 0 10px; } |
| 331 | .memory-add input { |
| 332 | flex: 1 1 auto; min-width: 0; font: inherit; font-size: 13px; padding: 8px 10px; |
| 333 | border: 1px solid var(--border); border-radius: 10px; background: var(--panel); color: var(--ink); |
| 334 | } |
| 335 | .memory-add button, .memory-edit button { |
| 336 | font: inherit; font-size: 13px; padding: 8px 12px; border: 1px solid var(--border); |
| 337 | border-radius: 10px; background: var(--panel); color: var(--ink); cursor: pointer; min-height: 32px; |
| 338 | } |
| 339 | .memory-add button:hover, .memory-edit button:hover { background: var(--hover, rgba(127,127,127,.12)); } |
| 340 | .memory-edit { display: flex; flex-direction: column; gap: 6px; width: 100%; } |
| 341 | .memory-edit textarea { |
| 342 | font: inherit; font-size: 13px; padding: 8px 10px; min-height: 60px; resize: vertical; |
| 343 | border: 1px solid var(--border); border-radius: 10px; background: var(--panel); color: var(--ink); |
| 344 | } |
| 345 | .memory-wipe { |
| 346 | flex: 0 0 auto; font: inherit; font-size: 12px; padding: 4px 8px; border: none; border-radius: 8px; |
| 347 | background: none; color: var(--danger, #c0392b); cursor: pointer; min-height: 24px; white-space: nowrap; |
| 348 | } |
| 349 | .memory-wipe:hover, .memory-wipe:focus-visible { background: var(--hover, rgba(192,57,43,.1)); } |
| 350 | .memory-wipe.armed { background: var(--danger, #c0392b); color: #fff; } |
| 351 | .memory-count { font-size: 12px; font-weight: 600; color: var(--danger, #c0392b); margin-right: 2px; font-variant-numeric: tabular-nums; white-space: nowrap; } |
| 352 | .memory-count.flash { color: var(--muted); font-weight: 500; } |
| 353 | .memory-list.memory-vanish .memory-fact { |
| 354 | opacity: 0; transform: translateY(-6px) scale(0.98); |
| 355 | transition: opacity 0.5s ease, transform 0.5s ease; |
| 356 | } |
| 357 | `; |
| 358 | } |
| 359 | |