eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

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.
11import { defineApp, getMemoryStore, openCommandPanel, motionOK } from "../apps.js";
12import { ICON_BRAIN } from "../icons.js";
13
14export const MAX_FACTS = 24;
15export 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.
20export 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
34defineApp({
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.
56export 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).
78async 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
317function 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