@iamem/amem 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +534 -0
  3. package/dist/activity.d.ts +27 -0
  4. package/dist/activity.js +202 -0
  5. package/dist/api/routes.d.ts +31 -0
  6. package/dist/api/routes.js +1345 -0
  7. package/dist/attest.d.ts +52 -0
  8. package/dist/attest.js +192 -0
  9. package/dist/backup-schedule.d.ts +25 -0
  10. package/dist/backup-schedule.js +216 -0
  11. package/dist/capture.d.ts +34 -0
  12. package/dist/capture.js +257 -0
  13. package/dist/cli.d.ts +2 -0
  14. package/dist/cli.js +1007 -0
  15. package/dist/context.d.ts +46 -0
  16. package/dist/context.js +334 -0
  17. package/dist/crypto.d.ts +39 -0
  18. package/dist/crypto.js +166 -0
  19. package/dist/db.d.ts +192 -0
  20. package/dist/db.js +666 -0
  21. package/dist/draft-quality.d.ts +19 -0
  22. package/dist/draft-quality.js +85 -0
  23. package/dist/embed.d.ts +76 -0
  24. package/dist/embed.js +331 -0
  25. package/dist/estimate.d.ts +32 -0
  26. package/dist/estimate.js +69 -0
  27. package/dist/freshness.d.ts +15 -0
  28. package/dist/freshness.js +93 -0
  29. package/dist/hook.d.ts +16 -0
  30. package/dist/hook.js +177 -0
  31. package/dist/hygiene-schedule.d.ts +28 -0
  32. package/dist/hygiene-schedule.js +221 -0
  33. package/dist/hygiene.d.ts +61 -0
  34. package/dist/hygiene.js +196 -0
  35. package/dist/install/claude.d.ts +6 -0
  36. package/dist/install/claude.js +69 -0
  37. package/dist/install/cursor.d.ts +7 -0
  38. package/dist/install/cursor.js +80 -0
  39. package/dist/install/hosts.d.ts +21 -0
  40. package/dist/install/hosts.js +186 -0
  41. package/dist/install/skills.d.ts +8 -0
  42. package/dist/install/skills.js +67 -0
  43. package/dist/it-pack.d.ts +20 -0
  44. package/dist/it-pack.js +84 -0
  45. package/dist/kinds.d.ts +18 -0
  46. package/dist/kinds.js +106 -0
  47. package/dist/license.d.ts +48 -0
  48. package/dist/license.js +172 -0
  49. package/dist/mcp.d.ts +40 -0
  50. package/dist/mcp.js +435 -0
  51. package/dist/paths.d.ts +11 -0
  52. package/dist/paths.js +55 -0
  53. package/dist/personal.d.ts +7 -0
  54. package/dist/personal.js +44 -0
  55. package/dist/platforms.d.ts +11 -0
  56. package/dist/platforms.js +32 -0
  57. package/dist/policy.d.ts +46 -0
  58. package/dist/policy.js +254 -0
  59. package/dist/prefs.d.ts +6 -0
  60. package/dist/prefs.js +11 -0
  61. package/dist/proposal.d.ts +90 -0
  62. package/dist/proposal.js +376 -0
  63. package/dist/publish.d.ts +28 -0
  64. package/dist/publish.js +57 -0
  65. package/dist/remember-contract.d.ts +23 -0
  66. package/dist/remember-contract.js +117 -0
  67. package/dist/repo-identity.d.ts +15 -0
  68. package/dist/repo-identity.js +82 -0
  69. package/dist/rules-sync.d.ts +7 -0
  70. package/dist/rules-sync.js +47 -0
  71. package/dist/savings-export.d.ts +49 -0
  72. package/dist/savings-export.js +141 -0
  73. package/dist/scan.d.ts +16 -0
  74. package/dist/scan.js +109 -0
  75. package/dist/search.d.ts +25 -0
  76. package/dist/search.js +150 -0
  77. package/dist/service.d.ts +20 -0
  78. package/dist/service.js +254 -0
  79. package/dist/shop.d.ts +9 -0
  80. package/dist/shop.js +15 -0
  81. package/dist/ui/server.d.ts +21 -0
  82. package/dist/ui/server.js +268 -0
  83. package/dist/vault.d.ts +25 -0
  84. package/dist/vault.js +42 -0
  85. package/dist/workspace-setup.d.ts +8 -0
  86. package/dist/workspace-setup.js +55 -0
  87. package/docs/agent-install-prompt.md +41 -0
  88. package/docs/backlog.md +59 -0
  89. package/docs/enterprise-endpoint.md +98 -0
  90. package/docs/license.md +54 -0
  91. package/docs/npm-release.md +38 -0
  92. package/docs/remember-contract.md +42 -0
  93. package/package.json +64 -0
  94. package/scripts/mdm-offboard.sh +14 -0
  95. package/skills/amem-bootstrap/SKILL.md +83 -0
  96. package/skills/amem-update-working-memory/SKILL.md +54 -0
  97. package/templates/cursor-rule.mdc +23 -0
  98. package/templates/mdm/co.amem.managed.plist +33 -0
  99. package/templates/policy.deny-default.toml +20 -0
  100. package/templates/policy.example.toml +22 -0
  101. package/ui-static/app.js +3966 -0
  102. package/ui-static/index.html +178 -0
  103. package/ui-static/orbit.js +389 -0
  104. package/ui-static/styles.css +2411 -0
@@ -0,0 +1,3966 @@
1
+ import { createOrbitViz } from "./orbit.js";
2
+
3
+ const PLATFORM_STORAGE = "amem.selectedPlatforms";
4
+ const WELCOME_STORAGE = "amem.welcome.dismissed";
5
+ const BRAIN_SCOPE_STORAGE = "amem.brain.scope";
6
+ const MEMORY_VIZ_STORAGE = "amem.memory.viz";
7
+ const TABS = ["welcome", "setup", "dashboard", "brain", "analytics", "stats"];
8
+ const VIZ_MODES = ["map", "blocks", "orbit"];
9
+ const FALLBACK_CLIENTS = [
10
+ { id: "cursor", label: "Cursor", hint: "Rules, skills, hooks" },
11
+ { id: "claude", label: "Claude Code", hint: "Skills and settings hooks" },
12
+ { id: "copilot", label: "GitHub Copilot", hint: "MCP / HTTP API" },
13
+ { id: "codex", label: "ChatGPT / Codex", hint: "MCP / HTTP API" },
14
+ { id: "gemini", label: "Gemini", hint: "MCP / HTTP API" },
15
+ { id: "windsurf", label: "Windsurf", hint: "MCP / HTTP API" },
16
+ { id: "grok", label: "Grok", hint: "MCP / HTTP API" },
17
+ ];
18
+
19
+ function parsePlatformList(raw) {
20
+ if (Array.isArray(raw)) return raw.filter((p) => typeof p === "string" && p.trim());
21
+ if (typeof raw !== "string" || !raw.trim()) return [];
22
+ try {
23
+ const parsed = JSON.parse(raw);
24
+ return Array.isArray(parsed) ? parsed.filter((p) => typeof p === "string" && p.trim()) : [];
25
+ } catch {
26
+ return [];
27
+ }
28
+ }
29
+
30
+ function loadStoredPlatforms() {
31
+ try {
32
+ return parsePlatformList(localStorage.getItem(PLATFORM_STORAGE));
33
+ } catch {
34
+ return [];
35
+ }
36
+ }
37
+
38
+ function persistPlatforms() {
39
+ try {
40
+ localStorage.setItem(PLATFORM_STORAGE, JSON.stringify([...state.selected]));
41
+ } catch {
42
+ /* ignore quota / private mode */
43
+ }
44
+ }
45
+
46
+ function hydratePlatforms(status) {
47
+ const stored = loadStoredPlatforms();
48
+ const fromSetup = parsePlatformList(status?.setup?.platforms);
49
+ if (stored.length) {
50
+ state.selected = new Set(stored);
51
+ return;
52
+ }
53
+ if (fromSetup.length) {
54
+ state.selected = new Set(fromSetup);
55
+ persistPlatforms();
56
+ }
57
+ }
58
+
59
+ function knownClients() {
60
+ const fromStatus = state.status?.clients;
61
+ if (Array.isArray(fromStatus) && fromStatus.length) return fromStatus;
62
+ return FALLBACK_CLIENTS;
63
+ }
64
+
65
+ const initialUrl = new URLSearchParams(location.search);
66
+ const storedPlatforms = loadStoredPlatforms();
67
+
68
+ function welcomeDismissed() {
69
+ try {
70
+ return localStorage.getItem(WELCOME_STORAGE) === "1";
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ function dismissWelcome() {
77
+ try {
78
+ localStorage.setItem(WELCOME_STORAGE, "1");
79
+ } catch {
80
+ /* ignore */
81
+ }
82
+ }
83
+
84
+ function initialTab() {
85
+ const fromUrl = initialUrl.get("tab");
86
+ // Paid users: never force the sell card on every launch.
87
+ if (isPaidLicenseCached()) {
88
+ if (fromUrl === "stats") return "analytics";
89
+ if (TABS.includes(fromUrl)) return fromUrl;
90
+ return "dashboard";
91
+ }
92
+ if (!welcomeDismissed() && fromUrl !== "brain" && fromUrl !== "stats" && fromUrl !== "analytics" && fromUrl !== "dashboard") return "welcome";
93
+ if (fromUrl === "stats") return "analytics";
94
+ if (TABS.includes(fromUrl)) return fromUrl;
95
+ return "setup";
96
+ }
97
+
98
+ /** Best-effort sync read of last known license for first paint / initialTab (may be stale). */
99
+ function isPaidLicenseCached() {
100
+ try {
101
+ const raw = localStorage.getItem("amem.license.tier");
102
+ return raw === "pro" || raw === "it";
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ function persistLicenseTier(tier) {
109
+ try {
110
+ if (tier) localStorage.setItem("amem.license.tier", String(tier).toLowerCase());
111
+ } catch {
112
+ /* ignore */
113
+ }
114
+ }
115
+
116
+ function loadBrainAll() {
117
+ const fromUrl = initialUrl.get("scope");
118
+ const hasTrackedRepo = Boolean(initialUrl.get("repo"));
119
+ if (fromUrl === "all" || !hasTrackedRepo) return true;
120
+ if (fromUrl === "current") return false;
121
+ try {
122
+ return localStorage.getItem(BRAIN_SCOPE_STORAGE) !== "current";
123
+ } catch {
124
+ return true;
125
+ }
126
+ }
127
+
128
+ function persistBrainAll(all) {
129
+ try {
130
+ localStorage.setItem(BRAIN_SCOPE_STORAGE, all ? "all" : "current");
131
+ } catch {
132
+ /* ignore */
133
+ }
134
+ }
135
+
136
+ function loadMemoryViz() {
137
+ try {
138
+ const v = localStorage.getItem(MEMORY_VIZ_STORAGE);
139
+ if (v === "neural") return "orbit";
140
+ if (VIZ_MODES.includes(v)) return v;
141
+ } catch {
142
+ /* ignore */
143
+ }
144
+ return "map";
145
+ }
146
+
147
+ function persistMemoryViz(mode) {
148
+ try {
149
+ localStorage.setItem(MEMORY_VIZ_STORAGE, VIZ_MODES.includes(mode) ? mode : "map");
150
+ } catch {
151
+ /* ignore */
152
+ }
153
+ }
154
+
155
+ let orbitViz = null;
156
+
157
+ function stopOrbitViz() {
158
+ if (orbitViz) {
159
+ orbitViz.stop();
160
+ orbitViz = null;
161
+ }
162
+ }
163
+
164
+ function stopAllViz() {
165
+ stopOrbitViz();
166
+ }
167
+
168
+ function setMemoryViz(mode) {
169
+ const next = VIZ_MODES.includes(mode) ? mode : "map";
170
+ state.memoryViz = next;
171
+ persistMemoryViz(next);
172
+ if (next !== "orbit") stopOrbitViz();
173
+ if (next !== "map" && (state.brainFilter === "drafts" || state.brainFilter === "review")) {
174
+ state.brainFilter = "files";
175
+ state.selectedNode = null;
176
+ }
177
+ }
178
+
179
+ function vizToggleHtml() {
180
+ const mode = state.memoryViz || "map";
181
+ return `<div class="viz-toggle" role="group" aria-label="Memory visualization">
182
+ <button type="button" data-viz="map" class="${mode === "map" ? "active" : ""}" title="Plain list of facts by file">Files</button>
183
+ <button type="button" data-viz="blocks" class="${mode === "blocks" ? "active" : ""}" title="Treemap: each block is a file, sized by facts">Blocks</button>
184
+ <button type="button" data-viz="orbit" class="${mode === "orbit" ? "active" : ""}" title="Hub and spoke by memory">Orbit</button>
185
+ </div>`;
186
+ }
187
+
188
+ function bindVizToggle(root = document) {
189
+ root.querySelectorAll(".viz-toggle [data-viz]").forEach((btn) => {
190
+ btn.addEventListener("click", (e) => {
191
+ e.preventDefault();
192
+ e.stopPropagation();
193
+ const mode = btn.dataset.viz;
194
+ if (!VIZ_MODES.includes(mode)) return;
195
+ setMemoryViz(mode);
196
+ renderBrain();
197
+ });
198
+ });
199
+ }
200
+
201
+ const state = {
202
+ tab: (() => {
203
+ const t = initialTab();
204
+ return t === "stats" ? "analytics" : t;
205
+ })(),
206
+ repoId: initialUrl.get("repo") || null,
207
+ path: initialUrl.get("path") || null,
208
+ status: null,
209
+ repos: [],
210
+ graph: null,
211
+ usage: null,
212
+ selected: new Set(storedPlatforms.length ? storedPlatforms : ["cursor"]),
213
+ picked: new Set(),
214
+ scan: null,
215
+ scanFilter: "",
216
+ scanLoading: false,
217
+ service: null,
218
+ selectedNode: null,
219
+ activeEventId: null,
220
+ brainFilter: "files",
221
+ draftSelected: {},
222
+ selectedFile: null,
223
+ brainSearch: "",
224
+ memoryViz: loadMemoryViz(),
225
+ anim: 0,
226
+ graphTick: 0,
227
+ vault: null,
228
+ vaultError: null,
229
+ recipe: null,
230
+ license: null,
231
+ shop: null,
232
+ embed: null,
233
+ prefs: null,
234
+ brainAll: loadBrainAll(),
235
+ brainError: null,
236
+ setupStep: 1,
237
+ setupEdit: false,
238
+ statsDays: 30,
239
+ showdown: null,
240
+ showdownQuery: "",
241
+ showdownOpen: false,
242
+ };
243
+
244
+ function scopedPath(path, repoId = state.repoId) {
245
+ const url = new URL(path, location.origin);
246
+ if (repoId) url.searchParams.set("repo", repoId);
247
+ else if (state.path && !state.brainAll) url.searchParams.set("path", state.path);
248
+ return `${url.pathname}?${url.searchParams.toString()}`;
249
+ }
250
+
251
+ async function api(path, options = {}) {
252
+ const { repoId, ...rest } = options;
253
+ const res = await fetch(scopedPath(path, repoId), {
254
+ headers: { "Content-Type": "application/json", ...(rest.headers || {}) },
255
+ ...rest,
256
+ });
257
+ const data = await res.json();
258
+ if (!res.ok) throw new Error(data.error || res.statusText);
259
+ return data;
260
+ }
261
+
262
+ async function apiUnscoped(path, options = {}) {
263
+ const res = await fetch(path, {
264
+ headers: { "Content-Type": "application/json", ...(options.headers || {}) },
265
+ ...options,
266
+ });
267
+ const data = await res.json();
268
+ if (!res.ok) throw new Error(data.error || res.statusText);
269
+ return data;
270
+ }
271
+
272
+ function $(sel) {
273
+ return document.querySelector(sel);
274
+ }
275
+
276
+ function samePath(a, b) {
277
+ if (!a || !b) return false;
278
+ const norm = (p) => String(p).replace(/[/\\]+$/, "").toLowerCase();
279
+ return norm(a) === norm(b);
280
+ }
281
+
282
+ function listedRepos() {
283
+ const fromList = Array.isArray(state.repos) ? state.repos : [];
284
+ const fromStatus = Array.isArray(state.status?.repos) ? state.status.repos : [];
285
+ const byId = new Map();
286
+ for (const r of [...fromStatus, ...fromList]) {
287
+ if (r?.id) byId.set(r.id, r);
288
+ }
289
+ if (state.status?.repo?.id) byId.set(state.status.repo.id, { ...state.status.repo, ...byId.get(state.status.repo.id) });
290
+ return [...byId.values()];
291
+ }
292
+
293
+ function matchBoundRepo() {
294
+ const repos = listedRepos();
295
+ if (state.repoId) {
296
+ const byId = repos.find((r) => r.id === state.repoId);
297
+ if (byId) return byId;
298
+ }
299
+ if (state.status?.repo?.id) {
300
+ const byStatus = repos.find((r) => r.id === state.status.repo.id);
301
+ if (byStatus) return byStatus;
302
+ return state.status.repo;
303
+ }
304
+ const path = state.status?.identity?.rootPath || state.path;
305
+ if (path) {
306
+ const byPath = repos.find((r) => samePath(r.root_path, path));
307
+ if (byPath) return byPath;
308
+ }
309
+ return null;
310
+ }
311
+
312
+ function writeUrlState() {
313
+ const q = new URLSearchParams();
314
+ q.set("tab", state.tab);
315
+ q.set("scope", state.brainAll ? "all" : "current");
316
+ if (state.repoId && !state.brainAll) q.set("repo", state.repoId);
317
+ if (state.path && !state.brainAll) q.set("path", state.path);
318
+ history.replaceState(null, "", `?${q.toString()}`);
319
+ }
320
+
321
+ function setTab(tab) {
322
+ if (tab === "stats") tab = "analytics";
323
+ if (tab === "brain") {
324
+ state.brainAll = true;
325
+ persistBrainAll(true);
326
+ state.brainError = null;
327
+ resetBrainView();
328
+ } else {
329
+ stopAllViz();
330
+ }
331
+ state.tab = tab;
332
+ document.querySelectorAll("#tabs button[data-tab]").forEach((b) => {
333
+ b.classList.toggle("active", b.dataset.tab === tab);
334
+ });
335
+ writeUrlState();
336
+ render();
337
+ }
338
+
339
+ // Memory is scoped by a sticky file/search/selection. Carrying those across a tab
340
+ // switch makes Memory look like it only loaded part of the store.
341
+ function resetBrainView() {
342
+ state.selectedFile = null;
343
+ state.selectedNode = null;
344
+ state.activeEventId = null;
345
+ state.draftSelected = {};
346
+ state.brainSearch = "";
347
+ const search = $("#brainSearch");
348
+ if (search) search.value = "";
349
+ }
350
+
351
+ function syncFocusFromStatus() {
352
+ const identity = state.status?.identity;
353
+ const repo = state.status?.repo || matchBoundRepo();
354
+ if (repo) {
355
+ state.repoId = repo.id;
356
+ state.path = repo.root_path;
357
+ } else if (identity?.rootPath) {
358
+ state.path = identity.rootPath;
359
+ state.repoId = null;
360
+ }
361
+ writeUrlState();
362
+ }
363
+
364
+ async function refreshRepos() {
365
+ try {
366
+ const data = await apiUnscoped("/api/repos");
367
+ state.repos = Array.isArray(data.repos) ? data.repos : [];
368
+ } catch {
369
+ if (Array.isArray(state.status?.repos)) state.repos = state.status.repos;
370
+ }
371
+ }
372
+
373
+ async function refreshStatus() {
374
+ state.status = await api("/api/status");
375
+ await refreshRepos();
376
+ const match = matchBoundRepo();
377
+ if (!state.status?.repo && match && state.repoId !== match.id) {
378
+ state.repoId = match.id;
379
+ state.path = match.root_path;
380
+ state.status = await api("/api/status");
381
+ }
382
+ syncFocusFromStatus();
383
+ hydratePlatforms(state.status);
384
+ state.prefs = state.status?.prefs || state.prefs;
385
+ await refreshVault();
386
+ }
387
+
388
+ async function refreshVault() {
389
+ try {
390
+ state.vault = await apiUnscoped("/api/vault");
391
+ state.vaultError = null;
392
+ } catch (e) {
393
+ state.vault = state.status?.vault || null;
394
+ state.vaultError = e?.message || String(e);
395
+ }
396
+ if (!state.recipe) {
397
+ try {
398
+ state.recipe = await apiUnscoped("/api/recipe");
399
+ } catch {
400
+ state.recipe = null;
401
+ }
402
+ }
403
+ try {
404
+ state.license = await apiUnscoped("/api/license");
405
+ } catch {
406
+ state.license = state.status?.license || null;
407
+ }
408
+ try {
409
+ state.embed = await apiUnscoped("/api/embed");
410
+ } catch {
411
+ state.embed = state.status?.embed || null;
412
+ }
413
+ paintEmbedIndexBanner();
414
+ try {
415
+ state.shop = await apiUnscoped("/api/shop");
416
+ } catch {
417
+ state.shop = { url: "https://getamem.com", enabled: true, proUrl: "https://getamem.com/buy/pro", itUrl: "https://getamem.com/buy/it" };
418
+ }
419
+ paintVault();
420
+ }
421
+
422
+ function vaultSummaryText() {
423
+ if (state.vaultError && /unknown route/i.test(state.vaultError)) {
424
+ return "This amem UI is outdated — stop the process on port 7843 and run amem ui again";
425
+ }
426
+ const v = state.vault;
427
+ if (!v) return "Vault…";
428
+ const lock = v.encryptedAtRest ? "Locked" : v.encCopyPresent ? "Unlocked" : "Plaintext";
429
+ const backup = v.backup?.last
430
+ ? `Backup ${String(v.backup.last.mtime).slice(0, 10)}`
431
+ : v.backup?.scheduled
432
+ ? "Backup scheduled"
433
+ : "Backup off";
434
+ const bits = [lock, backup];
435
+ if (state.license?.tier) bits.push(`License ${state.license.tier}`);
436
+ if (state.embed?.backend) bits.push(`Embed ${state.embed.backend}`);
437
+ bits.push("Local only");
438
+ return bits.join(" · ");
439
+ }
440
+
441
+ function paintVault() {
442
+ const chips = $("#vaultChips");
443
+ if (chips) chips.textContent = vaultSummaryText();
444
+ const detail = $("#vaultDetail");
445
+ if (detail && state.vault) {
446
+ const last = state.vault.backup?.last;
447
+ detail.textContent = last
448
+ ? `Last backup: ${last.name} · ${last.encrypted ? "encrypted" : "plaintext"} · stays in ${state.vault.backup.dir}`
449
+ : `Backups go to ${state.vault.backup?.dir || "~/.amem/backups"} on this machine.`;
450
+ }
451
+ const shop = state.shop;
452
+ const row = $("#shopRow");
453
+ if (row) row.classList.toggle("hidden", shop && shop.enabled === false);
454
+ const pro = $("#buyPro");
455
+ const it = $("#buyIt");
456
+ if (pro && shop?.proUrl) {
457
+ pro.setAttribute("href", shop.proUrl);
458
+ pro.textContent = `Buy Pro · ${shop.proPrice || "$12"}`;
459
+ }
460
+ if (it && shop?.itUrl) {
461
+ it.setAttribute("href", shop.itUrl);
462
+ it.textContent = `Buy IT · ${shop.itPrice || "$49"}`;
463
+ }
464
+ }
465
+
466
+ async function focusPersonal() {
467
+ const existing = listedRepos().find(isPersonal);
468
+ if (existing) {
469
+ await focusRepo({ repoId: existing.id, tab: state.tab === "setup" ? "setup" : state.tab });
470
+ return;
471
+ }
472
+ try {
473
+ const created = await apiUnscoped("/api/workspaces/personal", { method: "POST", body: "{}" });
474
+ state.repos = (await apiUnscoped("/api/repos")).repos || [];
475
+ await focusRepo({ repoId: created.repo?.id, tab: "brain" });
476
+ } catch (e) {
477
+ alert(e.message);
478
+ }
479
+ }
480
+
481
+ function vaultPassphrase() {
482
+ return $("#vaultPass")?.value || "";
483
+ }
484
+
485
+ function clearVaultPass() {
486
+ const input = $("#vaultPass");
487
+ if (input) input.value = "";
488
+ }
489
+
490
+ async function vaultAction(path, extra = {}) {
491
+ const passphrase = vaultPassphrase();
492
+ try {
493
+ const body = { ...extra };
494
+ if (passphrase) body.passphrase = passphrase;
495
+ const result = await apiUnscoped(path, { method: "POST", body: JSON.stringify(body) });
496
+ state.vault = result.vault || result;
497
+ paintVault();
498
+ clearVaultPass();
499
+ if (path.includes("/lock") || path.includes("/unlock")) {
500
+ await refreshStatus().catch(() => {});
501
+ await render();
502
+ }
503
+ } catch (e) {
504
+ alert(e.message);
505
+ }
506
+ }
507
+
508
+ function emptyGraph(scope = "all") {
509
+ return {
510
+ claims: [],
511
+ components: [],
512
+ flows: [],
513
+ edges: [],
514
+ drafts: [],
515
+ pendingDraftTotal: 0,
516
+ recentClaimIds: [],
517
+ recentEvents: [],
518
+ activity: null,
519
+ scope,
520
+ };
521
+ }
522
+
523
+ async function refreshGraph() {
524
+ state.brainError = null;
525
+ try {
526
+ if (state.brainAll) {
527
+ state.graph = await apiUnscoped("/api/graph?scope=all&days=30");
528
+ return;
529
+ }
530
+ if (!state.status?.repo) {
531
+ state.graph = emptyGraph("current");
532
+ return;
533
+ }
534
+ state.graph = await api("/api/graph?days=30");
535
+ } catch (e) {
536
+ const message = e?.message || String(e);
537
+ if (state.brainAll && state.status?.repo) {
538
+ try {
539
+ state.graph = await api("/api/graph?days=30");
540
+ return;
541
+ } catch {
542
+ /* keep the original error */
543
+ }
544
+ }
545
+ state.brainError = message;
546
+ state.graph = emptyGraph(state.brainAll ? "all" : "current");
547
+ }
548
+ }
549
+
550
+ function statsScope() {
551
+ return state.brainAll ? "all" : "current";
552
+ }
553
+
554
+ function statsFocusLabel() {
555
+ if (state.brainAll) return "All memory";
556
+ const repo = state.status?.repo || matchBoundRepo();
557
+ if (!repo) return "this folder (not tracking yet)";
558
+ if (isPersonal(repo)) return "Personal";
559
+ if (isWorkspace(repo)) return `${repo.repo_name} (workspace)`;
560
+ return repo.repo_name;
561
+ }
562
+
563
+ async function refreshUsage(scope = statsScope(), days = state.statsDays || 30) {
564
+ state.statsDays = days;
565
+ if (scope === "all") {
566
+ state.usage = await apiUnscoped(`/api/usage?scope=all&days=${days}`);
567
+ return;
568
+ }
569
+ if (!state.status?.repo) {
570
+ state.usage = null;
571
+ return;
572
+ }
573
+ state.usage = await api(`/api/usage?days=${days}`);
574
+ }
575
+
576
+ function isTrackingPath(path) {
577
+ return listedRepos().some((r) => samePath(r.root_path, path));
578
+ }
579
+
580
+ async function loadScan({ force = false } = {}) {
581
+ if (state.scan && !force) return;
582
+ state.scanLoading = true;
583
+ if (state.tab === "setup") renderSetup();
584
+ try {
585
+ const [scan, service] = await Promise.all([
586
+ apiUnscoped("/api/scan"),
587
+ apiUnscoped("/api/service"),
588
+ ]);
589
+ state.scan = scan;
590
+ state.service = service;
591
+ if (state.picked.size === 0) {
592
+ for (const r of scan.repos || []) {
593
+ if (r.tracking || samePath(r.path, state.path)) state.picked.add(r.path);
594
+ }
595
+ }
596
+ } catch (e) {
597
+ state.scan = { repos: [], truncated: false, scannedRoots: [] };
598
+ throw e;
599
+ } finally {
600
+ state.scanLoading = false;
601
+ }
602
+ }
603
+
604
+ function isWorkspace(r) {
605
+ if (!r) return false;
606
+ return r.kind === "workspace" || Boolean(r.slug) || String(r.remote_url || "").startsWith("amem://workspace/");
607
+ }
608
+
609
+ function isPersonal(r) {
610
+ if (!r) return false;
611
+ return Boolean(r.personal) || r.slug === "personal" || String(r.remote_url || "").includes("amem://workspace/personal");
612
+ }
613
+
614
+ function shortPath(p) {
615
+ if (!p) return "";
616
+ const home = String(p).replace(/^\/Users\/[^/]+/, "~");
617
+ const parts = home.split(/[/\\]/).filter(Boolean);
618
+ if (parts.length <= 3) return home;
619
+ return `…/${parts.slice(-3).join("/")}`;
620
+ }
621
+
622
+ function memoryLabel(repoId) {
623
+ const r = listedRepos().find((x) => x.id === repoId);
624
+ if (!r) return "unknown";
625
+ if (isPersonal(r)) return "Personal";
626
+ if (isWorkspace(r)) return `${r.repo_name} · workspace`;
627
+ return `${r.repo_name} · repo`;
628
+ }
629
+
630
+ function switcherLabel(r) {
631
+ const n = r.counts?.claims ?? 0;
632
+ const facts = `${n} fact${n === 1 ? "" : "s"}`;
633
+ if (isWorkspace(r)) {
634
+ const slug = r.slug || r.repo_name;
635
+ if (String(r.repo_name).toLowerCase() !== String(slug).toLowerCase()) {
636
+ return `${r.repo_name} · ${slug} · ${facts}`;
637
+ }
638
+ return `${r.repo_name} · ${facts}`;
639
+ }
640
+ return `${r.repo_name} · ${shortPath(r.root_path)}`;
641
+ }
642
+
643
+ function fillRepoSelect() {
644
+ const select = $("#repoSelect");
645
+ if (!select) return;
646
+ const previous = select.value;
647
+ select.replaceChildren();
648
+
649
+ const repos = listedRepos();
650
+ const bound = matchBoundRepo();
651
+ const currentId = bound?.id || state.status?.repo?.id || state.repoId;
652
+ const currentPath = state.status?.identity?.rootPath || state.path;
653
+ const inList = Boolean(bound || (currentId && repos.some((r) => r.id === currentId)));
654
+
655
+ const addGroup = (label, items) => {
656
+ if (!items.length) return;
657
+ const group = document.createElement("optgroup");
658
+ group.label = label;
659
+ for (const r of items) {
660
+ const opt = document.createElement("option");
661
+ opt.value = `repo:${r.id}`;
662
+ opt.textContent = switcherLabel(r);
663
+ if (!state.brainAll && (r.id === currentId || samePath(r.root_path, currentPath))) opt.selected = true;
664
+ group.appendChild(opt);
665
+ }
666
+ select.appendChild(group);
667
+ };
668
+
669
+ const allOpt = document.createElement("option");
670
+ allOpt.value = "__all__";
671
+ allOpt.textContent = "All memory";
672
+ if (state.brainAll) allOpt.selected = true;
673
+ select.appendChild(allOpt);
674
+
675
+ addGroup(
676
+ "Personal",
677
+ repos.filter(isPersonal),
678
+ );
679
+ addGroup(
680
+ "Git repos",
681
+ repos.filter((r) => !isWorkspace(r)),
682
+ );
683
+ addGroup(
684
+ "Workspaces",
685
+ repos.filter((r) => isWorkspace(r) && !isPersonal(r)),
686
+ );
687
+
688
+ if (!inList && currentPath) {
689
+ const opt = document.createElement("option");
690
+ opt.value = `path:${currentPath}`;
691
+ opt.textContent = `${state.status?.identity?.repoName || currentPath} · not initialized`;
692
+ if (!state.brainAll) opt.selected = true;
693
+ select.appendChild(opt);
694
+ }
695
+
696
+ const add = document.createElement("option");
697
+ add.value = "__add__";
698
+ add.textContent = "Add git repo…";
699
+ select.appendChild(add);
700
+
701
+ if (!select.value && previous && previous !== "__add__") {
702
+ select.value = previous;
703
+ }
704
+
705
+ const renameBtn = $("#renameWsBtn");
706
+ if (renameBtn) {
707
+ const current = repos.find((r) => `repo:${r.id}` === select.value) || matchBoundRepo() || state.status?.repo;
708
+ renameBtn.classList.toggle("hidden", !isWorkspace(current));
709
+ }
710
+ }
711
+
712
+ async function renameWorkspaceUi(repo) {
713
+ if (!repo?.id || !isWorkspace(repo)) return;
714
+ const slug = repo.slug || "";
715
+ const next = prompt(
716
+ slug
717
+ ? `Display name for this workspace.\nMCP still uses workspace=${slug} — memory is unchanged.`
718
+ : "Display name for this workspace",
719
+ repo.repo_name || "",
720
+ );
721
+ if (next == null) return;
722
+ const name = next.trim();
723
+ if (!name || name === repo.repo_name) return;
724
+ try {
725
+ await apiUnscoped("/api/workspaces/rename", {
726
+ method: "POST",
727
+ body: JSON.stringify({ repoId: repo.id, name }),
728
+ });
729
+ state.repos = (await apiUnscoped("/api/repos")).repos || [];
730
+ await refreshStatus();
731
+ await render();
732
+ } catch (e) {
733
+ alert(e.message);
734
+ }
735
+ }
736
+
737
+ function setAddPanel(open) {
738
+ $("#addRepoPanel")?.classList.toggle("hidden", !open);
739
+ if (open) {
740
+ const input = $("#addRepoPath");
741
+ if (input) {
742
+ input.value = "";
743
+ input.focus();
744
+ }
745
+ }
746
+ }
747
+
748
+ async function focusRepo({ repoId = null, path = null, tab = "setup" } = {}) {
749
+ state.repoId = repoId;
750
+ state.path = path;
751
+ state.tab = tab;
752
+ state.graph = null;
753
+ state.usage = null;
754
+ state.activeEventId = null;
755
+ document.querySelectorAll(".tabs button").forEach((b) => {
756
+ b.classList.toggle("active", b.dataset.tab === tab);
757
+ });
758
+ writeUrlState();
759
+ await refreshStatus();
760
+ await render();
761
+ }
762
+
763
+ async function openAddedPath() {
764
+ const path = $("#addRepoPath")?.value.trim();
765
+ if (!path) return;
766
+ try {
767
+ await focusRepo({ path, tab: "setup" });
768
+ setAddPanel(false);
769
+ } catch (e) {
770
+ alert(e.message);
771
+ }
772
+ }
773
+
774
+ function defaultProposal() {
775
+ const name = state.status?.identity?.repoName || "app";
776
+ return JSON.stringify(
777
+ {
778
+ components: [
779
+ {
780
+ id: `component.${name.replace(/[^a-z0-9]+/gi, "_").toLowerCase()}`,
781
+ name: `${name} root`,
782
+ code_anchor: "README.md",
783
+ },
784
+ ],
785
+ flows: [{ id: "flow.local_dev", name: "Local development loop" }],
786
+ claims: [
787
+ {
788
+ id: "claim.readme_entry",
789
+ kind: "structure",
790
+ text: "Repository entry documentation lives in README.md.",
791
+ code_anchors: ["README.md"],
792
+ },
793
+ ],
794
+ edges: [
795
+ {
796
+ from_id: "claim.readme_entry",
797
+ from_type: "claim",
798
+ to_id: "flow.local_dev",
799
+ to_type: "flow",
800
+ kind: "about",
801
+ },
802
+ ],
803
+ },
804
+ null,
805
+ 2,
806
+ );
807
+ }
808
+
809
+ function renderWelcome() {
810
+ const main = $("#main");
811
+ setMainMode("page");
812
+ renderPageInsight();
813
+ if (isPaidLicense()) {
814
+ dismissWelcome();
815
+ setTab("dashboard");
816
+ return;
817
+ }
818
+ const shop = state.shop || {};
819
+ const proUrl = shop.proUrl || "https://getamem.com/buy/pro";
820
+ const itUrl = shop.itUrl || "https://getamem.com/buy/it";
821
+ const proPrice = shop.proPrice || "$12";
822
+ const itPrice = shop.itPrice || "$49";
823
+ const tier = state.license?.tier || "free";
824
+ const shopOn = shop.enabled !== false;
825
+
826
+ main.innerHTML = `
827
+ <section class="hero welcome-hero">
828
+ <div class="hero-inner welcome-wide">
829
+ <div class="welcome-brand">
830
+ <div class="welcome-logo" aria-hidden="true">a</div>
831
+ <h1>amem</h1>
832
+ </div>
833
+ <p>Local agent memory for Cursor and any MCP host. Facts stay in <code>~/.amem</code> on this machine. Free already remembers; Pro = better local ranking + cleanup + rules sync. IT = richer attest for security/DevEx — not more memory.</p>
834
+ <div class="plan-grid">
835
+ <article class="plan-card ${tier === "free" ? "current" : ""}">
836
+ <h2>Free</h2>
837
+ <p class="plan-price">$0</p>
838
+ <ul>
839
+ <li>Memory, MCP, and Stats</li>
840
+ <li>Lock and local backup</li>
841
+ <li>Hashing embedder — no download</li>
842
+ </ul>
843
+ <button class="btn" type="button" id="continueFree">Continue with free</button>
844
+ </article>
845
+ <article class="plan-card ${tier === "pro" ? "current" : ""}">
846
+ <h2>Pro</h2>
847
+ <p class="plan-price">${esc(proPrice)} <span>once</span></p>
848
+ <ul>
849
+ <li>Everything in Free</li>
850
+ <li>Local n-gram or external embedder</li>
851
+ <li>Hygiene inbox — decay and merge</li>
852
+ <li>Pin facts → Cursor rules</li>
853
+ </ul>
854
+ ${
855
+ shopOn
856
+ ? `<a class="btn" href="${esc(proUrl)}" target="_blank" rel="noopener">Buy Pro</a>`
857
+ : `<p class="note">Shop is off on this machine.</p>`
858
+ }
859
+ </article>
860
+ <article class="plan-card ${tier === "it" ? "current" : ""}">
861
+ <h2>IT</h2>
862
+ <p class="plan-price">${esc(itPrice)} <span>once</span></p>
863
+ <ul>
864
+ <li>Everything in Pro</li>
865
+ <li>Richer <code>amem doctor --attest</code> (the exclusive)</li>
866
+ <li>Solo agents usually stop at Pro</li>
867
+ </ul>
868
+ ${
869
+ shopOn
870
+ ? `<a class="btn secondary" href="${esc(itUrl)}" target="_blank" rel="noopener">Buy IT</a>`
871
+ : `<p class="note">Shop is off on this machine.</p>`
872
+ }
873
+ </article>
874
+ </div>
875
+ ${licenseApplyHtml("welcome")}
876
+ <p class="note welcome-note">After pay, apply <code>amem-license.json</code> above (or in Setup). Memory never uploads.</p>
877
+ </div>
878
+ </section>`;
879
+
880
+ $("#continueFree")?.addEventListener("click", () => {
881
+ dismissWelcome();
882
+ setTab("setup");
883
+ });
884
+ wireLicenseApply("welcome", async () => {
885
+ await refreshVault();
886
+ if (isPaidLicense()) {
887
+ dismissWelcome();
888
+ setTab("setup");
889
+ return;
890
+ }
891
+ renderWelcome();
892
+ });
893
+ }
894
+
895
+ const SETUP_STEPS = [
896
+ { id: 1, title: "Clients", hint: "Which apps use local memory" },
897
+ { id: 2, title: "Git repos", hint: "Folders amem should watch" },
898
+ { id: 3, title: "Workspaces", hint: "Optional named spaces" },
899
+ { id: 4, title: "Connect", hint: "Paste the recipe into any host" },
900
+ ];
901
+
902
+ function setupIsComplete() {
903
+ return listedRepos().length > 0 || Boolean(state.status?.setup?.setup_completed_at);
904
+ }
905
+
906
+ function selectedClientLabels() {
907
+ return knownClients()
908
+ .filter((c) => state.selected.has(c.id))
909
+ .map((c) => c.label);
910
+ }
911
+
912
+ function setupContext() {
913
+ const s = state.status;
914
+ const bound = matchBoundRepo();
915
+ const repos = listedRepos();
916
+ const gitBound = repos.filter((r) => !isWorkspace(r));
917
+ const workspaces = repos.filter(isWorkspace);
918
+ const scanned = state.scan?.repos || [];
919
+ const filter = state.scanFilter.trim().toLowerCase();
920
+ const visible = scanned.filter((r) => {
921
+ if (!filter) return true;
922
+ return `${r.name} ${r.path} ${r.remote || ""}`.toLowerCase().includes(filter);
923
+ });
924
+ return {
925
+ s,
926
+ bound,
927
+ repos,
928
+ gitBound,
929
+ workspaces,
930
+ scanned,
931
+ visible,
932
+ pickedCount: [...state.picked].length,
933
+ serviceOn: Boolean(state.service?.installed),
934
+ focused: bound || s?.repo,
935
+ issues: (s?.doctor || []).filter((i) => !(bound && i === "Repo not initialized")),
936
+ };
937
+ }
938
+
939
+ function clientsBlockHtml() {
940
+ return `<div class="platform-row">
941
+ ${knownClients()
942
+ .map(
943
+ (p) => `<button class="chip ${state.selected.has(p.id) ? "selected" : ""}" data-platform="${esc(p.id)}" type="button">
944
+ <strong>${esc(p.label)}</strong>
945
+ <span>${esc(p.hint)}</span>
946
+ </button>`,
947
+ )
948
+ .join("")}
949
+ </div>`;
950
+ }
951
+
952
+ function autostartBlockHtml() {
953
+ const serviceOn = Boolean(state.service?.installed);
954
+ if (state.service?.supported === false) {
955
+ return `<p class="note">Login auto-start is not supported on this OS.</p>`;
956
+ }
957
+ const extra =
958
+ state.service?.servicePlatform === "linux"
959
+ ? " (systemd user unit)"
960
+ : state.service?.servicePlatform === "win32"
961
+ ? " (Startup folder)"
962
+ : "";
963
+ return `<label class="autostart"><input type="checkbox" id="autostart" ${serviceOn ? "checked" : ""}/> Start amem ui when this computer logs in${extra}</label>`;
964
+ }
965
+
966
+ function gitScanBlockHtml(ctx) {
967
+ const { scanned, visible, gitBound, pickedCount } = ctx;
968
+ return `
969
+ <div class="scan-head">
970
+ <div>
971
+ <strong>Git repos on this Mac</strong>
972
+ <div class="note" style="margin:0.25rem 0 0">${
973
+ state.scanLoading
974
+ ? "Scanning your home folder…"
975
+ : `${scanned.length} found · ${gitBound.length} git repo${gitBound.length === 1 ? "" : "s"} tracking${state.scan?.truncated ? " · scan capped" : ""}`
976
+ }</div>
977
+ </div>
978
+ <div class="scan-actions">
979
+ <input id="scanFilter" type="search" placeholder="Filter repos" value="${state.scanFilter.replaceAll('"', "&quot;")}" />
980
+ <button class="btn secondary small" id="rescanBtn" type="button">Rescan</button>
981
+ </div>
982
+ </div>
983
+ <div class="repo-list" id="repoList">
984
+ ${
985
+ state.scanLoading && scanned.length === 0
986
+ ? `<div class="note">Scanning…</div>`
987
+ : visible.length === 0
988
+ ? `<div class="note">No git repos found in your home folder.</div>`
989
+ : visible
990
+ .map((r) => {
991
+ const tracking = r.tracking || isTrackingPath(r.path);
992
+ const checked = state.picked.has(r.path);
993
+ return `<label class="repo-row ${tracking ? "tracking" : ""}">
994
+ <input type="checkbox" data-path="${r.path.replaceAll('"', "&quot;")}" ${checked ? "checked" : ""} />
995
+ <span>
996
+ <b>${r.name}</b>
997
+ ${tracking ? `<em>tracking</em>` : ""}
998
+ <small>${r.path}</small>
999
+ </span>
1000
+ </label>`;
1001
+ })
1002
+ .join("")
1003
+ }
1004
+ </div>
1005
+ <div class="actions">
1006
+ <button class="btn" id="trackBtn">Start tracking selected (${pickedCount || visible.filter((r) => r.tracking).length})</button>
1007
+ </div>`;
1008
+ }
1009
+
1010
+ function workspaceBlockHtml(ctx) {
1011
+ const { workspaces, focused } = ctx;
1012
+ return `
1013
+ <div class="scan-head">
1014
+ <div>
1015
+ <strong>Named workspaces</strong>
1016
+ <div class="note" style="margin:0.25rem 0 0">Not git checkouts. Rename the label anytime — the MCP id and memory stay put.</div>
1017
+ </div>
1018
+ </div>
1019
+ <div class="repo-list" id="workspaceList">
1020
+ ${
1021
+ workspaces.length === 0
1022
+ ? `<div class="note">None yet. Create one below for Luna or any MCP host.</div>`
1023
+ : workspaces
1024
+ .map((w) => {
1025
+ const slug = w.slug || "";
1026
+ const n = w.counts?.claims ?? 0;
1027
+ const active = focused?.id === w.id;
1028
+ return `<div class="repo-row workspace-row ${active ? "tracking" : ""}" data-focus-ws="${esc(w.id)}">
1029
+ <span>
1030
+ <b>${esc(w.repo_name)}</b>
1031
+ <em>workspace</em>
1032
+ <small>MCP id ${esc(slug)} · ${n} fact${n === 1 ? "" : "s"}</small>
1033
+ </span>
1034
+ <button class="btn secondary small" type="button" data-rename-ws="${esc(w.id)}">Rename</button>
1035
+ </div>`;
1036
+ })
1037
+ .join("")
1038
+ }
1039
+ </div>
1040
+ <div class="workspace-add">
1041
+ <label class="note">Create a workspace (no git repo required)</label>
1042
+ <div class="add-repo-row">
1043
+ <input id="wsName" type="text" placeholder="Luna Client" />
1044
+ <button class="btn secondary" id="wsBtn" type="button">Create workspace</button>
1045
+ </div>
1046
+ <p class="note">Any MCP host: keep this UI running, then connect to <code>http://127.0.0.1:7843/mcp?workspace=SLUG</code> (not a bare <code>amem</code> command — GUI apps often cannot see Homebrew on PATH).</p>
1047
+ </div>`;
1048
+ }
1049
+
1050
+ function isPaidLicense() {
1051
+ const tier = String(state.license?.tier || state.status?.license?.tier || "free").toLowerCase();
1052
+ const paid = state.license?.valid !== false && (tier === "pro" || tier === "it");
1053
+ if (paid) persistLicenseTier(tier);
1054
+ return paid;
1055
+ }
1056
+
1057
+ function licenseApplyHtml(idPrefix = "lic") {
1058
+ return `
1059
+ <div class="license-apply" id="${idPrefix}ApplyBox">
1060
+ <h2>Apply license</h2>
1061
+ <p class="note">After checkout, paste <code>amem-license.json</code> or choose the downloaded file. Memory never uploads.</p>
1062
+ <textarea id="${idPrefix}LicenseText" rows="5" placeholder='{"kind":"signed","payload":{…},"signature":"…"}'></textarea>
1063
+ <div class="license-apply-actions">
1064
+ <label class="btn secondary small license-file-label">Choose file
1065
+ <input type="file" id="${idPrefix}LicenseFile" accept="application/json,.json" hidden />
1066
+ </label>
1067
+ <button class="btn" type="button" id="${idPrefix}LicenseApply">Apply license</button>
1068
+ </div>
1069
+ <p class="note" id="${idPrefix}LicenseMsg"></p>
1070
+ </div>`;
1071
+ }
1072
+
1073
+ function proOnboardHtml(idPrefix = "onboard") {
1074
+ if (!isPaidLicense()) return "";
1075
+ const backend = state.embed?.backend || "hash";
1076
+ const ngramOn = backend === "ngram" || backend === "external";
1077
+ const done = loadProOnboardDone();
1078
+ const hygieneDone = Boolean(done.hygiene) || Boolean(state.hygieneSchedule?.installed);
1079
+ const rulesDone = Boolean(done.rules);
1080
+ const steps = [
1081
+ { id: "embed", done: ngramOn || Boolean(done.embed), label: "Pro retrieval (n-gram)" },
1082
+ { id: "reindex", done: ngramOn || Boolean(done.reindex), label: "Reindex embeddings" },
1083
+ { id: "hygiene", done: hygieneDone, label: "Weekly hygiene schedule" },
1084
+ { id: "rules", done: rulesDone, label: "Sync pinned → Cursor rules" },
1085
+ ];
1086
+ const allDone = steps.every((s) => s.done);
1087
+ return `
1088
+ <div class="pro-onboard" id="${idPrefix}Onboard">
1089
+ <h2>${allDone ? "Pro is on" : "Turn on Pro"}</h2>
1090
+ <p class="note">${
1091
+ allDone
1092
+ ? "Checklist complete on this machine. Open Memory anytime for Compare retrieval or Cleanup."
1093
+ : "Paying should change defaults immediately — enable richer retrieval, then clean and sync."
1094
+ }</p>
1095
+ <ol class="pro-onboard-steps">
1096
+ ${steps
1097
+ .map(
1098
+ (s) =>
1099
+ `<li class="${s.done ? "done" : ""}"><span class="setup-check" aria-hidden="true">${s.done ? "✓" : "·"}</span> ${esc(s.label)}</li>`,
1100
+ )
1101
+ .join("")}
1102
+ </ol>
1103
+ ${
1104
+ allDone
1105
+ ? ""
1106
+ : `<label class="autostart brain-auto" style="margin:0.5rem 0 0.75rem;display:flex">
1107
+ <input type="checkbox" id="${idPrefix}HygieneSched" checked /> Also schedule weekly hygiene (Sunday)
1108
+ </label>
1109
+ <div class="actions">
1110
+ <button class="btn" type="button" id="${idPrefix}EnablePro">Turn on Pro retrieval</button>
1111
+ <button class="btn secondary" type="button" id="${idPrefix}OpenReview">Open Cleanup</button>
1112
+ ${
1113
+ state.license?.features?.includes("rules_sync") && !state.brainAll
1114
+ ? `<button class="btn secondary" type="button" id="${idPrefix}RulesSync">Sync pinned rules</button>`
1115
+ : ""
1116
+ }
1117
+ </div>`
1118
+ }
1119
+ <p class="note" id="${idPrefix}OnboardMsg"></p>
1120
+ </div>`;
1121
+ }
1122
+
1123
+ const PRO_ONBOARD_STORAGE = "amem.proOnboard.done";
1124
+
1125
+ function loadProOnboardDone() {
1126
+ try {
1127
+ return JSON.parse(localStorage.getItem(PRO_ONBOARD_STORAGE) || "{}") || {};
1128
+ } catch {
1129
+ return {};
1130
+ }
1131
+ }
1132
+
1133
+ function markProOnboardDone(keys) {
1134
+ const cur = loadProOnboardDone();
1135
+ for (const k of keys) cur[k] = true;
1136
+ try {
1137
+ localStorage.setItem(PRO_ONBOARD_STORAGE, JSON.stringify(cur));
1138
+ } catch {
1139
+ /* ignore */
1140
+ }
1141
+ }
1142
+
1143
+ async function applyLicenseFromUi(text, msgEl) {
1144
+ const raw = String(text || "").trim();
1145
+ if (!raw) {
1146
+ if (msgEl) msgEl.textContent = "Paste license JSON or choose a file first.";
1147
+ return false;
1148
+ }
1149
+ let parsed;
1150
+ try {
1151
+ parsed = JSON.parse(raw);
1152
+ } catch {
1153
+ if (msgEl) msgEl.textContent = "That is not valid JSON.";
1154
+ return false;
1155
+ }
1156
+ try {
1157
+ const status = await apiUnscoped("/api/license/apply", {
1158
+ method: "POST",
1159
+ body: JSON.stringify({ json: parsed }),
1160
+ });
1161
+ state.license = status;
1162
+ try {
1163
+ state.embed = await apiUnscoped("/api/embed");
1164
+ } catch {
1165
+ /* keep */
1166
+ }
1167
+ if (msgEl) msgEl.textContent = `Applied ${status.tier} license.`;
1168
+ return true;
1169
+ } catch (e) {
1170
+ if (msgEl) msgEl.textContent = e?.message || String(e);
1171
+ return false;
1172
+ }
1173
+ }
1174
+
1175
+ function wireLicenseApply(idPrefix, onApplied) {
1176
+ const msg = $(`#${idPrefix}LicenseMsg`);
1177
+ const area = $(`#${idPrefix}LicenseText`);
1178
+ $(`#${idPrefix}LicenseFile`)?.addEventListener("change", async (e) => {
1179
+ const file = e.target.files?.[0];
1180
+ if (!file) return;
1181
+ const text = await file.text();
1182
+ if (area) area.value = text;
1183
+ });
1184
+ $(`#${idPrefix}LicenseApply`)?.addEventListener("click", async () => {
1185
+ const ok = await applyLicenseFromUi(area?.value || "", msg);
1186
+ if (ok && onApplied) await onApplied();
1187
+ });
1188
+ }
1189
+
1190
+ function wireProOnboard(idPrefix) {
1191
+ const msg = $(`#${idPrefix}OnboardMsg`);
1192
+ $(`#${idPrefix}EnablePro`)?.addEventListener("click", async () => {
1193
+ try {
1194
+ if (msg) msg.textContent = "Enabling n-gram…";
1195
+ state.embed = await apiUnscoped("/api/embed", {
1196
+ method: "POST",
1197
+ body: JSON.stringify({ backend: "ngram" }),
1198
+ });
1199
+ if (msg) msg.textContent = "Reindexing…";
1200
+ await apiUnscoped("/api/embed/reindex", { method: "POST", body: "{}" });
1201
+ markProOnboardDone(["embed", "reindex"]);
1202
+ const schedOn = Boolean($(`#${idPrefix}HygieneSched`)?.checked);
1203
+ if (schedOn) {
1204
+ if (msg) msg.textContent = "Scheduling weekly hygiene…";
1205
+ try {
1206
+ await apiUnscoped("/api/hygiene/schedule", {
1207
+ method: "POST",
1208
+ body: JSON.stringify({ hour: 4 }),
1209
+ });
1210
+ markProOnboardDone(["hygiene"]);
1211
+ } catch (schedErr) {
1212
+ if (msg) msg.textContent = `Pro retrieval is on. Hygiene schedule failed: ${schedErr?.message || schedErr}`;
1213
+ if (state.tab === "welcome") renderWelcome();
1214
+ else if (state.tab === "setup") renderSetup();
1215
+ return;
1216
+ }
1217
+ }
1218
+ if (msg) {
1219
+ msg.textContent = schedOn
1220
+ ? "Pro retrieval is on and weekly hygiene is scheduled. Run a showdown in Memory anytime."
1221
+ : "Pro retrieval is on. Run a showdown in Memory anytime.";
1222
+ }
1223
+ if (state.tab === "welcome") renderWelcome();
1224
+ else if (state.tab === "setup") renderSetup();
1225
+ } catch (e) {
1226
+ if (msg) msg.textContent = e?.message || String(e);
1227
+ }
1228
+ });
1229
+ $(`#${idPrefix}OpenReview`)?.addEventListener("click", () => {
1230
+ state.brainFilter = "review";
1231
+ setTab("brain");
1232
+ });
1233
+ $(`#${idPrefix}RulesSync`)?.addEventListener("click", async () => {
1234
+ try {
1235
+ const result = await api("/api/rules/sync", { method: "POST", body: "{}" });
1236
+ markProOnboardDone(["rules"]);
1237
+ if (msg) msg.textContent = result?.path ? `Wrote ${result.path}` : "Rules synced.";
1238
+ if (state.tab === "setup") renderSetup();
1239
+ } catch (e) {
1240
+ if (msg) msg.textContent = e?.message || String(e);
1241
+ }
1242
+ });
1243
+ }
1244
+
1245
+ function shopBuyCardHtml() {
1246
+ if (state.shop?.enabled === false) return "";
1247
+ const shop = state.shop || {};
1248
+ const proUrl = shop.proUrl || "https://getamem.com/buy/pro";
1249
+ const itUrl = shop.itUrl || "https://getamem.com/buy/it";
1250
+ const proPrice = shop.proPrice || "$12";
1251
+ const itPrice = shop.itPrice || "$49";
1252
+ const tier = String(state.license?.tier || state.status?.license?.tier || "free").toLowerCase();
1253
+ const proOwned = tier === "pro" || tier === "it";
1254
+ const itOwned = tier === "it";
1255
+ if (proOwned) {
1256
+ return `
1257
+ <div class="setup-shop">
1258
+ <div class="setup-shop-head">
1259
+ <div>
1260
+ <h2>${itOwned ? "IT license" : "Pro license"}</h2>
1261
+ <p class="note">Extras unlocked on this machine. ${
1262
+ itOwned
1263
+ ? "IT exclusive: richer doctor --attest."
1264
+ : "Upgrade to IT only if you need richer host attestation."
1265
+ }</p>
1266
+ </div>
1267
+ ${itOwned ? "" : `<a class="btn secondary small" href="${esc(itUrl)}" target="_blank" rel="noopener">IT · ${esc(itPrice)}</a>`}
1268
+ </div>
1269
+ ${proOnboardHtml("setup")}
1270
+ ${itPackCardHtml()}
1271
+ </div>`;
1272
+ }
1273
+ return `
1274
+ <div class="setup-shop">
1275
+ <div class="setup-shop-head">
1276
+ <div>
1277
+ <h2>Signed license</h2>
1278
+ <p class="note">Pay on getamem.com, then apply the file here — no CLI required. Memory never uploads. <a href="https://getamem.com/pricing" target="_blank" rel="noopener">Full matrix</a></p>
1279
+ </div>
1280
+ <button class="btn secondary small" id="seePlans" type="button">What's included</button>
1281
+ </div>
1282
+ <div class="setup-shop-grid">
1283
+ <article class="setup-buy">
1284
+ <div class="setup-buy-top"><strong>Pro</strong></div>
1285
+ <p class="setup-buy-price">${esc(proPrice)} <span>once</span></p>
1286
+ <p class="note">Better local ranking + cleanup + rules sync.</p>
1287
+ <a class="btn" href="${esc(proUrl)}" target="_blank" rel="noopener">Buy Pro</a>
1288
+ </article>
1289
+ <article class="setup-buy">
1290
+ <div class="setup-buy-top"><strong>IT</strong></div>
1291
+ <p class="setup-buy-price">${esc(itPrice)} <span>once</span></p>
1292
+ <p class="note">Pro + richer attest packet for security tickets.</p>
1293
+ <a class="btn secondary" href="${esc(itUrl)}" target="_blank" rel="noopener">Buy IT</a>
1294
+ </article>
1295
+ </div>
1296
+ ${licenseApplyHtml("setup")}
1297
+ ${itPackCardHtml()}
1298
+ </div>`;
1299
+ }
1300
+
1301
+ function itPackCardHtml() {
1302
+ const itOwned = String(state.license?.tier || "").toLowerCase() === "it";
1303
+ return `
1304
+ <div class="it-pack-card" id="itPackCard">
1305
+ <h2>Security pack (local)</h2>
1306
+ <p class="note">Writes policy, MDM plist, SBOM, and offboard script under <code>~/.amem/it-pack</code>. Available on Free — ${
1307
+ itOwned
1308
+ ? "your IT license stamps richer attest when you run doctor."
1309
+ : "IT’s exclusive is the richer <code>amem doctor --attest</code> packet, not this folder."
1310
+ }</p>
1311
+ <div class="actions">
1312
+ <button class="btn secondary" type="button" id="genItPack">Generate security pack</button>
1313
+ ${itOwned ? `<button class="btn secondary" type="button" id="runAttest">Show attest summary</button>` : ""}
1314
+ </div>
1315
+ <pre class="it-pack-out hidden" id="itPackOut"></pre>
1316
+ </div>`;
1317
+ }
1318
+
1319
+ function recipeBlockHtml() {
1320
+ return `
1321
+ <div class="recipe-card">
1322
+ <div class="scan-head">
1323
+ <div>
1324
+ <strong>Remember contract (any MCP host)</strong>
1325
+ <div class="note" style="margin:0.25rem 0 0">Read first, then write. Same tools for every client — not a per-app fork.</div>
1326
+ </div>
1327
+ <button class="btn secondary small" id="copyRecipe" type="button">Copy recipe</button>
1328
+ </div>
1329
+ <pre id="recipePaste">${esc(state.recipe?.paste || "amem recipe")}</pre>
1330
+ </div>`;
1331
+ }
1332
+
1333
+ function connectExtrasHtml(ctx) {
1334
+ const { s, focused, issues } = ctx;
1335
+ const focusedWs = isWorkspace(focused);
1336
+ const focusedSlug = focused?.slug || "";
1337
+ return `
1338
+ ${
1339
+ focused
1340
+ ? `<div class="status-grid" style="margin-top:1.5rem">
1341
+ <div class="stat-line"><span>Focused ${focusedWs ? "workspace" : "git repo"}</span><b>${esc(focused?.repo_name || s?.identity?.repoName || "—")}</b></div>
1342
+ ${focusedWs && focusedSlug && focusedSlug !== focused?.repo_name ? `<div class="stat-line"><span>MCP id</span><b>${esc(focusedSlug)}</b></div>` : ""}
1343
+ <div class="stat-line"><span>Claims</span><b>${s?.counts?.claims ?? 0}</b></div>
1344
+ <div class="stat-line"><span>License</span><b>${esc(s?.license?.tier || state.license?.tier || "free")}</b></div>
1345
+ </div>`
1346
+ : ""
1347
+ }
1348
+ ${issues.length ? `<div class="issues">${issues.map((i) => `• ${i}`).join("<br/>")}</div>` : ""}
1349
+ ${
1350
+ focused
1351
+ ? `<div class="bootstrap">
1352
+ <label class="note">Bootstrap proposal (applied only to the focused ${focusedWs ? "workspace" : "repo"})</label>
1353
+ <textarea id="proposal">${defaultProposal()}</textarea>
1354
+ <div class="actions">
1355
+ <button class="btn" id="applyBootstrap">Apply bootstrap</button>
1356
+ </div>
1357
+ </div>`
1358
+ : `<p class="note">Track a git repo or create a workspace first if you want to apply a bootstrap proposal.</p>`
1359
+ }`;
1360
+ }
1361
+
1362
+ function setupStepperHtml(current) {
1363
+ return `<ol class="setup-steps">
1364
+ ${SETUP_STEPS.map((step) => {
1365
+ const cls = ["setup-step", step.id === current ? "active" : "", step.id < current ? "done" : ""]
1366
+ .filter(Boolean)
1367
+ .join(" ");
1368
+ return `<li>
1369
+ <button type="button" class="${cls}" data-setup-step="${step.id}">
1370
+ <span class="setup-num">${step.id < current ? "✓" : step.id}</span>
1371
+ <span>
1372
+ <strong>${esc(step.title)}</strong>
1373
+ <em>${esc(step.hint)}</em>
1374
+ </span>
1375
+ </button>
1376
+ </li>`;
1377
+ }).join("")}
1378
+ </ol>`;
1379
+ }
1380
+
1381
+ function setupDoneHtml(ctx) {
1382
+ const { gitBound, workspaces, serviceOn, focused } = ctx;
1383
+ const clients = selectedClientLabels();
1384
+ const mcpHint = focused?.slug
1385
+ ? `http://127.0.0.1:7843/mcp?workspace=${focused.slug}`
1386
+ : "http://127.0.0.1:7843/mcp?workspace=personal";
1387
+ const rows = [
1388
+ {
1389
+ title: "Clients",
1390
+ detail: clients.length ? clients.join(", ") : "None selected",
1391
+ },
1392
+ {
1393
+ title: "Git repos",
1394
+ detail:
1395
+ gitBound.length === 0
1396
+ ? "None tracking"
1397
+ : `${gitBound.length} tracking · ${gitBound
1398
+ .slice(0, 3)
1399
+ .map((r) => r.repo_name)
1400
+ .join(", ")}${gitBound.length > 3 ? "…" : ""}`,
1401
+ },
1402
+ {
1403
+ title: "Workspaces",
1404
+ detail:
1405
+ workspaces.length === 0
1406
+ ? "Skipped — add one anytime"
1407
+ : `${workspaces.length} named · ${workspaces.map((w) => w.repo_name).join(", ")}`,
1408
+ },
1409
+ {
1410
+ title: "Connect",
1411
+ detail: serviceOn
1412
+ ? "Recipe ready · UI starts at login · MCP on 127.0.0.1:7843"
1413
+ : "Recipe ready · MCP on 127.0.0.1:7843",
1414
+ },
1415
+ ];
1416
+ return `
1417
+ <section class="hero">
1418
+ <div class="hero-inner setup-wide">
1419
+ <h1>You're set up</h1>
1420
+ <p>Memory stays in ~/.amem and never leaves this machine. Every step below is done — change any of them if you need to.</p>
1421
+ <ol class="setup-done">
1422
+ ${rows
1423
+ .map(
1424
+ (row, i) => `<li>
1425
+ <span class="setup-check" aria-hidden="true">✓</span>
1426
+ <div>
1427
+ <strong>${i + 1}. ${esc(row.title)}</strong>
1428
+ <p>${esc(row.detail)}</p>
1429
+ </div>
1430
+ </li>`,
1431
+ )
1432
+ .join("")}
1433
+ </ol>
1434
+ <div class="setup-verify" id="setupVerify">
1435
+ <strong>Verify MCP context</strong>
1436
+ <p class="note">Green when a sample query returns at least one claim from this machine.</p>
1437
+ <div class="add-repo-row">
1438
+ <button class="btn secondary small" type="button" id="probeContext">Test amem_context</button>
1439
+ <code class="mcp-url-chip">${esc(mcpHint)}</code>
1440
+ </div>
1441
+ <p class="note" id="probeContextMsg">Not tested yet.</p>
1442
+ </div>
1443
+ <div class="actions">
1444
+ <button class="btn" id="openBrain" type="button">Open memory</button>
1445
+ <button class="btn secondary" id="editSetup" type="button">Change setup</button>
1446
+ </div>
1447
+ ${recipeBlockHtml()}
1448
+ ${shopBuyCardHtml()}
1449
+ </div>
1450
+ </section>`;
1451
+ }
1452
+
1453
+ function setupWizardHtml(ctx) {
1454
+ const step = Math.min(4, Math.max(1, state.setupStep || 1));
1455
+ const titles = {
1456
+ 1: { h: "Which apps?", p: "Pick every agent that should read and write local memory. You can change this later." },
1457
+ 2: { h: "Which git repos?", p: "Select folders to track. Chats in those folders will use this machine's memory." },
1458
+ 3: { h: "Named workspaces?", p: "Optional. Use these when there is no git checkout — Luna, a GUI host, or a personal space." },
1459
+ 4: { h: "Connect a host", p: "Keep this UI running. Paste the recipe into any MCP client. Then open Memory to see what's stored." },
1460
+ };
1461
+ const copy = titles[step];
1462
+ const body =
1463
+ step === 1
1464
+ ? `${clientsBlockHtml()}${autostartBlockHtml()}`
1465
+ : step === 2
1466
+ ? gitScanBlockHtml(ctx)
1467
+ : step === 3
1468
+ ? workspaceBlockHtml(ctx)
1469
+ : `${recipeBlockHtml()}${connectExtrasHtml(ctx)}`;
1470
+ const back = step > 1 ? `<button class="btn secondary" id="setupBack" type="button">Back</button>` : "";
1471
+ const next =
1472
+ step === 4
1473
+ ? `<button class="btn" id="openBrain" type="button">Open memory</button>`
1474
+ : `<button class="btn${step === 1 ? "" : " secondary"}" id="setupNext" type="button">${step === 1 ? "Continue" : "Skip for now"}</button>`;
1475
+ return `
1476
+ <section class="hero">
1477
+ <div class="hero-inner setup-wide">
1478
+ <h1>Setup</h1>
1479
+ <p>Four short steps. Memory stays in ~/.amem and never leaves localhost.</p>
1480
+ ${setupStepperHtml(step)}
1481
+ <div class="setup-panel">
1482
+ <h2>${esc(copy.h)}</h2>
1483
+ <p class="note setup-lead">${esc(copy.p)}</p>
1484
+ ${body}
1485
+ </div>
1486
+ <div class="actions setup-nav">
1487
+ ${back}
1488
+ ${next}
1489
+ ${setupIsComplete() ? `<button class="btn secondary" id="setupFinish" type="button">Done</button>` : ""}
1490
+ </div>
1491
+ </div>
1492
+ </section>`;
1493
+ }
1494
+
1495
+ function renderSetup() {
1496
+ const ctx = setupContext();
1497
+ const main = $("#main");
1498
+ setMainMode("page");
1499
+ renderPageInsight();
1500
+ const showDone = setupIsComplete() && !state.setupEdit;
1501
+ main.innerHTML = showDone ? setupDoneHtml(ctx) : setupWizardHtml(ctx);
1502
+ bindSetupEvents(main, ctx);
1503
+ }
1504
+
1505
+ function goSetupStep(step) {
1506
+ state.setupStep = Math.min(4, Math.max(1, step));
1507
+ renderSetup();
1508
+ }
1509
+
1510
+ function bindSetupEvents(main, ctx) {
1511
+ const { workspaces, focused } = ctx;
1512
+ main.querySelectorAll("[data-setup-step]").forEach((el) => {
1513
+ el.addEventListener("click", () => {
1514
+ const next = Number(el.dataset.setupStep);
1515
+ if (!Number.isFinite(next)) return;
1516
+ if (next > (state.setupStep || 1) && !setupIsComplete()) return;
1517
+ goSetupStep(next);
1518
+ });
1519
+ });
1520
+ $("#setupNext")?.addEventListener("click", () => {
1521
+ if (state.setupStep === 1) persistPlatforms();
1522
+ goSetupStep((state.setupStep || 1) + 1);
1523
+ });
1524
+ $("#setupBack")?.addEventListener("click", () => goSetupStep((state.setupStep || 1) - 1));
1525
+ $("#editSetup")?.addEventListener("click", () => {
1526
+ state.setupEdit = true;
1527
+ state.setupStep = 1;
1528
+ renderSetup();
1529
+ });
1530
+ $("#setupFinish")?.addEventListener("click", () => {
1531
+ state.setupEdit = false;
1532
+ renderSetup();
1533
+ });
1534
+ main.querySelectorAll("[data-platform]").forEach((el) => {
1535
+ el.addEventListener("click", () => {
1536
+ const p = el.dataset.platform;
1537
+ if (!p) return;
1538
+ if (state.selected.has(p)) {
1539
+ if (state.selected.size === 1) return;
1540
+ state.selected.delete(p);
1541
+ } else {
1542
+ state.selected.add(p);
1543
+ }
1544
+ persistPlatforms();
1545
+ renderSetup();
1546
+ });
1547
+ });
1548
+ $("#autostart")?.addEventListener("change", async (e) => {
1549
+ try {
1550
+ const enabled = e.target.checked;
1551
+ const result = await apiUnscoped("/api/service", {
1552
+ method: "POST",
1553
+ body: JSON.stringify({ enabled }),
1554
+ });
1555
+ state.service = { ...(state.service || {}), ...result };
1556
+ } catch (err) {
1557
+ alert(err.message);
1558
+ e.target.checked = !e.target.checked;
1559
+ }
1560
+ });
1561
+ $("#scanFilter")?.addEventListener("input", (e) => {
1562
+ state.scanFilter = e.target.value;
1563
+ renderSetup();
1564
+ const input = $("#scanFilter");
1565
+ if (input) {
1566
+ input.focus();
1567
+ const len = input.value.length;
1568
+ input.setSelectionRange(len, len);
1569
+ }
1570
+ });
1571
+ $("#rescanBtn")?.addEventListener("click", async () => {
1572
+ try {
1573
+ await loadScan({ force: true });
1574
+ renderSetup();
1575
+ } catch (err) {
1576
+ alert(err.message);
1577
+ renderSetup();
1578
+ }
1579
+ });
1580
+ main.querySelectorAll("input[data-path]").forEach((el) => {
1581
+ el.addEventListener("change", () => {
1582
+ const path = el.dataset.path;
1583
+ if (el.checked) state.picked.add(path);
1584
+ else state.picked.delete(path);
1585
+ const btn = $("#trackBtn");
1586
+ if (btn) btn.textContent = `Start tracking selected (${state.picked.size})`;
1587
+ });
1588
+ });
1589
+ $("#trackBtn")?.addEventListener("click", async () => {
1590
+ const platforms = [...state.selected];
1591
+ if (!platforms.length) return alert("Pick at least one platform");
1592
+ const paths = [...state.picked];
1593
+ if (!paths.length) return alert("Select at least one git repo");
1594
+ const btn = $("#trackBtn");
1595
+ btn.disabled = true;
1596
+ btn.textContent = "Tracking…";
1597
+ try {
1598
+ const tracked = await apiUnscoped("/api/track", {
1599
+ method: "POST",
1600
+ body: JSON.stringify({ paths, platforms }),
1601
+ });
1602
+ persistPlatforms();
1603
+ state.repos = tracked.repos || [];
1604
+ if (tracked.tracked?.[0]?.id) {
1605
+ state.repoId = tracked.tracked[0].id;
1606
+ state.path = tracked.tracked[0].root_path;
1607
+ }
1608
+ await refreshStatus();
1609
+ await loadScan({ force: true });
1610
+ alert(`Tracking ${tracked.tracked?.length ?? paths.length} repo(s).`);
1611
+ state.setupStep = 3;
1612
+ state.setupEdit = true;
1613
+ render();
1614
+ } catch (err) {
1615
+ alert(err.message);
1616
+ btn.disabled = false;
1617
+ btn.textContent = "Start tracking selected";
1618
+ }
1619
+ });
1620
+ $("#wsBtn")?.addEventListener("click", async () => {
1621
+ const name = $("#wsName")?.value?.trim();
1622
+ if (!name) return alert("Name a workspace, e.g. luna");
1623
+ try {
1624
+ const created = await apiUnscoped("/api/workspaces", {
1625
+ method: "POST",
1626
+ body: JSON.stringify({ name, platform: "app" }),
1627
+ });
1628
+ state.repos = (await apiUnscoped("/api/repos")).repos || [];
1629
+ if (created.repo?.id) {
1630
+ state.repoId = created.repo.id;
1631
+ state.path = created.repo.root_path;
1632
+ }
1633
+ await refreshStatus();
1634
+ const checks = (created.ready?.checks || []).join("\n");
1635
+ const label = created.name || created.workspace;
1636
+ const mcpUrl = created.mcp?.url || `http://127.0.0.1:7843/mcp?workspace=${created.workspace}`;
1637
+ const slugNote =
1638
+ created.workspace && created.workspace !== label ? `\nMCP id stays: ${created.workspace}` : "";
1639
+ alert(
1640
+ `Workspace "${label}" is ready.${checks ? `\n${checks}` : ""}${slugNote}\n\nMCP URL:\n${mcpUrl}`,
1641
+ );
1642
+ state.setupStep = 4;
1643
+ state.setupEdit = true;
1644
+ render();
1645
+ } catch (err) {
1646
+ alert(err.message);
1647
+ }
1648
+ });
1649
+ $("#applyBootstrap")?.addEventListener("click", async () => {
1650
+ try {
1651
+ const proposal = JSON.parse($("#proposal").value);
1652
+ await api("/api/bootstrap", {
1653
+ method: "POST",
1654
+ body: JSON.stringify({ proposal }),
1655
+ });
1656
+ await refreshStatus();
1657
+ await refreshGraph();
1658
+ alert("Baseline memory applied locally.");
1659
+ render();
1660
+ } catch (e) {
1661
+ alert(e.message);
1662
+ }
1663
+ });
1664
+ $("#openBrain")?.addEventListener("click", () => setTab("brain"));
1665
+ $("#seePlans")?.addEventListener("click", () => setTab("welcome"));
1666
+ wireLicenseApply("setup", async () => {
1667
+ await refreshVault();
1668
+ renderSetup();
1669
+ });
1670
+ wireProOnboard("setup");
1671
+ wireItPackCard();
1672
+ $("#probeContext")?.addEventListener("click", () => probeSetupContext());
1673
+ $("#copyRecipe")?.addEventListener("click", async () => {
1674
+ const text = state.recipe?.paste || $("#recipePaste")?.textContent || "";
1675
+ try {
1676
+ await navigator.clipboard.writeText(text);
1677
+ const btn = $("#copyRecipe");
1678
+ if (btn) btn.textContent = "Copied";
1679
+ } catch {
1680
+ alert(text);
1681
+ }
1682
+ });
1683
+ main.querySelectorAll("[data-rename-ws]").forEach((el) => {
1684
+ el.addEventListener("click", (e) => {
1685
+ e.stopPropagation();
1686
+ const id = el.dataset.renameWs;
1687
+ const repo = workspaces.find((w) => w.id === id);
1688
+ if (repo) renameWorkspaceUi(repo);
1689
+ });
1690
+ });
1691
+ main.querySelectorAll("[data-focus-ws]").forEach((el) => {
1692
+ el.addEventListener("click", () => {
1693
+ const id = el.dataset.focusWs;
1694
+ if (id && id !== focused?.id) focusRepo({ repoId: id, tab: "setup" });
1695
+ });
1696
+ });
1697
+ }
1698
+
1699
+ async function probeSetupContext() {
1700
+ const msg = $("#probeContextMsg");
1701
+ const box = $("#setupVerify");
1702
+ if (msg) msg.textContent = "Calling /api/context…";
1703
+ try {
1704
+ const packet = await api("/api/context", {
1705
+ method: "POST",
1706
+ body: JSON.stringify({ query: "what should I know about this project" }),
1707
+ });
1708
+ const n = Array.isArray(packet?.claims) ? packet.claims.length : 0;
1709
+ if (n > 0) {
1710
+ if (box) box.classList.add("ok");
1711
+ if (msg) {
1712
+ msg.textContent = `OK — ${n} claim${n === 1 ? "" : "s"} returned. Paste the MCP URL into your host and call amem_context the same way.`;
1713
+ }
1714
+ noteContextHit();
1715
+ } else {
1716
+ if (box) box.classList.remove("ok");
1717
+ if (msg) {
1718
+ msg.textContent =
1719
+ "Reachable, but no claims yet. Add a fact in Memory or run bootstrap, then retry.";
1720
+ }
1721
+ }
1722
+ } catch (e) {
1723
+ if (box) box.classList.remove("ok");
1724
+ if (msg) msg.textContent = e?.message || String(e);
1725
+ }
1726
+ }
1727
+
1728
+ function wireItPackCard() {
1729
+ $("#genItPack")?.addEventListener("click", async () => {
1730
+ const out = $("#itPackOut");
1731
+ try {
1732
+ const result = await apiUnscoped("/api/it-pack", {
1733
+ method: "POST",
1734
+ body: JSON.stringify({}),
1735
+ });
1736
+ if (out) {
1737
+ out.classList.remove("hidden");
1738
+ out.textContent = `Wrote ${result.files?.length || 0} files → ${result.dir}\n\nChecklist:\n1. Deploy policy.toml as /etc/amem/policy.toml\n2. Attach sbom.json + amem doctor --attest --json\n3. Offboard with mdm-offboard.sh when needed`;
1739
+ }
1740
+ } catch (e) {
1741
+ if (out) {
1742
+ out.classList.remove("hidden");
1743
+ out.textContent = e?.message || String(e);
1744
+ }
1745
+ }
1746
+ });
1747
+ $("#runAttest")?.addEventListener("click", async () => {
1748
+ const out = $("#itPackOut");
1749
+ try {
1750
+ const report = await apiUnscoped("/api/attest");
1751
+ if (out) {
1752
+ out.classList.remove("hidden");
1753
+ const sku = report.sku ? JSON.stringify(report.sku, null, 2) : "(no IT sku — Pro/Free)";
1754
+ out.textContent = `license: ${report.license?.tier || "?"}\nissues: ${(report.issues || []).length}\n\nsku:\n${sku}`;
1755
+ }
1756
+ } catch (e) {
1757
+ if (out) {
1758
+ out.classList.remove("hidden");
1759
+ out.textContent = e?.message || String(e);
1760
+ }
1761
+ }
1762
+ });
1763
+ }
1764
+
1765
+ function esc(value) {
1766
+ return String(value ?? "")
1767
+ .replace(/&/g, "&amp;")
1768
+ .replace(/</g, "&lt;")
1769
+ .replace(/>/g, "&gt;")
1770
+ .replace(/"/g, "&quot;");
1771
+ }
1772
+
1773
+ function claimAnchors(claim) {
1774
+ try {
1775
+ const parsed = JSON.parse(claim.code_anchors || "[]");
1776
+ return Array.isArray(parsed) ? parsed.filter(Boolean) : [];
1777
+ } catch {
1778
+ return [];
1779
+ }
1780
+ }
1781
+
1782
+ function claimPreview(claim, max = 140) {
1783
+ const text = String(claim.text || claim.id || "").replace(/\s+/g, " ").trim();
1784
+ if (text.length <= max) return text;
1785
+ return `${text.slice(0, max).trim()}…`;
1786
+ }
1787
+
1788
+ function isSessionClaim(claim) {
1789
+ return claim.kind === "session" || String(claim.id || "").startsWith("claim.session_");
1790
+ }
1791
+
1792
+ function formatWhen(iso) {
1793
+ if (!iso) return "";
1794
+ const t = Date.parse(iso);
1795
+ if (!Number.isFinite(t)) return iso;
1796
+ const s = (Date.now() - t) / 1000;
1797
+ if (s < 45) return "just now";
1798
+ if (s < 3600) return `${Math.round(s / 60)}m ago`;
1799
+ if (s < 86400) return `${Math.round(s / 3600)}h ago`;
1800
+ if (s < 86400 * 7) return `${Math.round(s / 86400)}d ago`;
1801
+ return new Date(t).toLocaleDateString(undefined, { month: "short", day: "numeric" });
1802
+ }
1803
+
1804
+ function eventKindOf(ev) {
1805
+ if (ev.kind === "local_hit" || ev.kind === "server_trip") return ev.kind;
1806
+ return Number(ev.claims_count) > 0 ? "local_hit" : "server_trip";
1807
+ }
1808
+
1809
+ function eventClaimIds(ev) {
1810
+ try {
1811
+ const ids = JSON.parse(ev.claim_ids || "[]");
1812
+ return Array.isArray(ids) ? ids : [];
1813
+ } catch {
1814
+ return [];
1815
+ }
1816
+ }
1817
+
1818
+ function eventQueryLabel(ev) {
1819
+ const q = String(ev.query || "").trim();
1820
+ if (!q || q === "(session start)") return "Session start";
1821
+ return q;
1822
+ }
1823
+
1824
+ function relatedForClaim(claimId) {
1825
+ const g = state.graph || {};
1826
+ const flows = [];
1827
+ const components = [];
1828
+ for (const e of g.edges || []) {
1829
+ const other =
1830
+ e.from_id === claimId ? { id: e.to_id, type: e.to_type } : e.to_id === claimId ? { id: e.from_id, type: e.from_type } : null;
1831
+ if (!other) continue;
1832
+ if (other.type === "flow") {
1833
+ const f = (g.flows || []).find((x) => x.id === other.id);
1834
+ if (f && !flows.some((x) => x.id === f.id)) flows.push(f);
1835
+ }
1836
+ if (other.type === "component") {
1837
+ const c = (g.components || []).find((x) => x.id === other.id);
1838
+ if (c && !components.some((x) => x.id === c.id)) components.push(c);
1839
+ }
1840
+ }
1841
+ return { flows, components };
1842
+ }
1843
+
1844
+ function visibleClaims() {
1845
+ const g = state.graph || {};
1846
+ const claims = g.claims || [];
1847
+ const hot = new Set(g.recentClaimIds || []);
1848
+ const filter = state.brainFilter || "files";
1849
+ const q = (state.brainSearch || "").trim().toLowerCase();
1850
+ return claims.filter((c) => {
1851
+ if (q) {
1852
+ const hay = `${c.id} ${c.kind || ""} ${c.text || ""} ${claimAnchors(c).join(" ")} ${memoryLabel(c.repo_id)}`.toLowerCase();
1853
+ if (!hay.includes(q)) return false;
1854
+ }
1855
+ if (filter === "durable") return !isSessionClaim(c);
1856
+ if (filter === "files") return true;
1857
+ if (filter === "chats") return isSessionClaim(c);
1858
+ if (filter === "used") return hot.has(c.id);
1859
+ if (filter === "pinned") return Number(c.pinned || 0) > 0;
1860
+ if (filter === "drafts") return false; // drafts render separately
1861
+ return true;
1862
+ });
1863
+ }
1864
+
1865
+ // Across all memories the same path exists in several repos, so the group key
1866
+ // carries the memory. Anything that selects a file must build the key this way.
1867
+ function fileGroupKey(claim, file) {
1868
+ return state.brainAll ? `${memoryLabel(claim.repo_id)} · ${file}` : file;
1869
+ }
1870
+
1871
+ function fileGroups(claims) {
1872
+ const groups = new Map();
1873
+ for (const c of claims) {
1874
+ const anchors = claimAnchors(c);
1875
+ const keys = (anchors.length ? anchors : ["(no file yet)"]).map((file) => fileGroupKey(c, file));
1876
+ for (const key of keys) {
1877
+ if (!groups.has(key)) groups.set(key, []);
1878
+ groups.get(key).push(c);
1879
+ }
1880
+ }
1881
+ const hot = new Set(state.graph?.recentClaimIds || []);
1882
+ return [...groups.entries()]
1883
+ .map(([file, items]) => ({
1884
+ file,
1885
+ items,
1886
+ used: items.some((c) => hot.has(c.id)),
1887
+ }))
1888
+ .sort((a, b) => Number(b.used) - Number(a.used) || b.items.length - a.items.length || a.file.localeCompare(b.file));
1889
+ }
1890
+
1891
+ function setMainMode(mode) {
1892
+ const main = $("#main");
1893
+ if (!main) return;
1894
+ main.classList.toggle("tab-brain", mode === "brain");
1895
+ syncShellChrome(mode);
1896
+ }
1897
+
1898
+ function syncShellChrome(mode) {
1899
+ const isBrain = mode === "brain";
1900
+ $("#topSearchWrap")?.classList.toggle("hidden", !isBrain);
1901
+ document.querySelectorAll(".top-mem-only").forEach((el) => {
1902
+ if (el.id === "rulesSyncBtn") return;
1903
+ el.classList.toggle("hidden", !isBrain);
1904
+ });
1905
+ const rules = $("#rulesSyncBtn");
1906
+ if (rules) {
1907
+ const show = isBrain && state.license?.features?.includes("rules_sync") && !state.brainAll;
1908
+ rules.classList.toggle("hidden", !show);
1909
+ }
1910
+ const search = $("#brainSearch");
1911
+ if (search) {
1912
+ if (isBrain) search.value = state.brainSearch || "";
1913
+ else search.value = "";
1914
+ }
1915
+ const auto = $("#autoApplyAll");
1916
+ if (auto && isBrain) auto.checked = Boolean(state.prefs?.autoApplyAll);
1917
+ const sd = $("#showdownToggle");
1918
+ if (sd && isBrain) sd.textContent = state.showdownOpen ? "Hide compare" : "Compare retrieval";
1919
+ }
1920
+
1921
+ function renderPageInsight() {
1922
+ const el = $("#rightPanel");
1923
+ if (!el) return;
1924
+ const tier = state.license?.tier || "free";
1925
+ const locked = Boolean(state.vault?.locked);
1926
+ const embed = state.embed?.backend || "hashing";
1927
+ const repo = state.status?.repo?.repo_name || (state.brainAll ? "All memory" : "—");
1928
+ el.innerHTML = `
1929
+ <h2>Overview</h2>
1930
+ <div class="fact-kpis">
1931
+ <div class="fact-kpi"><b>${esc(String(tier))}</b><span>License</span></div>
1932
+ <div class="fact-kpi"><b>${locked ? "Yes" : "No"}</b><span>Vault locked</span></div>
1933
+ <div class="fact-kpi"><b>${esc(embed)}</b><span>Embedder</span></div>
1934
+ <div class="fact-kpi"><b>${(state.graph?.claims || []).length || "—"}</b><span>Facts loaded</span></div>
1935
+ </div>
1936
+ <div class="insight-card">
1937
+ <strong>Focus</strong>
1938
+ <p>${esc(repo)}</p>
1939
+ </div>
1940
+ <div class="insight-card">
1941
+ <strong>Local only</strong>
1942
+ <p>Memory stays in <code>~/.amem</code>. Use Lock / backup in the sidebar for passphrase protection.</p>
1943
+ </div>`;
1944
+ }
1945
+
1946
+ function renderBrainRight() {
1947
+ const el = $("#rightPanel");
1948
+ if (!el) return;
1949
+ const g = state.graph || emptyGraph(state.brainAll ? "all" : "current");
1950
+ const events = g.recentEvents || [];
1951
+ const hits = events.filter((e) => eventKindOf(e) === "local_hit").length;
1952
+ const files = new Set((g.claims || []).flatMap(claimAnchors)).size;
1953
+ const drafts = (g.drafts || []).filter((d) => d.status === "pending");
1954
+ const pendingTotal = Number(g.pendingDraftTotal ?? drafts.length);
1955
+ const pinned = (g.claims || []).filter((c) => Number(c.pinned || 0) > 0).length;
1956
+ const drawerOpen = Boolean(state.selectedNode);
1957
+ el.innerHTML = `
1958
+ <h2>What amem knows</h2>
1959
+ <div class="fact-kpis">
1960
+ <div class="fact-kpi"><b>${g.claims?.length ?? 0}</b><span>Facts</span></div>
1961
+ <div class="fact-kpi"><b>${pendingTotal}</b><span>Waiting</span></div>
1962
+ <div class="fact-kpi"><b>${pinned}</b><span>Pinned</span></div>
1963
+ <div class="fact-kpi"><b>${hits}</b><span>Hits</span></div>
1964
+ </div>
1965
+ <p class="note brain-guide"><b>Pin:</b> Memory → <b>Files</b> → click <em>☆ Pin</em> on a fact row (or in this panel after clicking a file). Pinned facts rank higher.</p>
1966
+ ${state.brainError ? `<p class="note">${esc(brainErrorNote())}</p>` : ""}
1967
+ <div class="right-section">
1968
+ <aside class="brain-feed" id="brainFeed"></aside>
1969
+ </div>
1970
+ <div class="right-section">
1971
+ <aside class="drawer ${drawerOpen ? "" : ""}" id="drawer"></aside>
1972
+ </div>`;
1973
+ }
1974
+
1975
+ function brainErrorNote() {
1976
+ const m = String(state.brainError || "");
1977
+ if (!m) return "";
1978
+ if (/unknown route|outdated/i.test(m)) {
1979
+ return "This amem UI process is outdated. Stop the process on port 7843 and run amem ui again.";
1980
+ }
1981
+ if (/not initialized/i.test(m)) {
1982
+ return "All-memory needs a current amem ui. Track a repo in Setup, or restart the UI on port 7843.";
1983
+ }
1984
+ return m;
1985
+ }
1986
+
1987
+ function showdownColumnHtml(title, claims, locked, shopUrl) {
1988
+ if (locked) {
1989
+ return `<div class="showdown-col">
1990
+ <h3>${esc(title)}</h3>
1991
+ <p class="note">Pro retrieval needs a license.</p>
1992
+ <a class="btn small" href="${esc(shopUrl || "https://getamem.com/buy/pro")}" target="_blank" rel="noopener">Buy Pro</a>
1993
+ </div>`;
1994
+ }
1995
+ if (!claims?.length) {
1996
+ return `<div class="showdown-col"><h3>${esc(title)}</h3><p class="note">No ranked hits.</p></div>`;
1997
+ }
1998
+ return `<div class="showdown-col">
1999
+ <h3>${esc(title)}</h3>
2000
+ <ol class="showdown-list">
2001
+ ${claims
2002
+ .map(
2003
+ (c) =>
2004
+ `<li><strong>${esc(c.id)}</strong> <span class="note">${esc(String(c.score))}</span>
2005
+ <div>${esc((c.text || "").slice(0, 160))}${(c.text || "").length > 160 ? "…" : ""}</div>
2006
+ ${c.reasons?.length ? `<div class="note">Why: ${esc(c.reasons.slice(0, 3).join(", "))}</div>` : ""}
2007
+ </li>`,
2008
+ )
2009
+ .join("")}
2010
+ </ol>
2011
+ </div>`;
2012
+ }
2013
+
2014
+ function paintShowdownResult(data) {
2015
+ const el = $("#showdownResult");
2016
+ if (!el) return;
2017
+ if (!data) {
2018
+ el.innerHTML = "";
2019
+ return;
2020
+ }
2021
+ const shop = state.shop || {};
2022
+ const proOnly = data.proOnlyIds?.length || 0;
2023
+ el.innerHTML = `
2024
+ <div class="showdown-result-bar">
2025
+ <p class="note">Pro-only hits in top results: <b>${proOnly}</b>${
2026
+ data.proLocked ? " · apply a Pro license to unlock the right column" : ""
2027
+ }</p>
2028
+ <button class="btn secondary small" type="button" id="showdownClear">Clear</button>
2029
+ </div>
2030
+ <div class="showdown-grid">
2031
+ ${showdownColumnHtml("Free (hash)", data.free, false)}
2032
+ ${showdownColumnHtml("Pro (n-gram)", data.pro, Boolean(data.proLocked), shop.proUrl)}
2033
+ </div>`;
2034
+ $("#showdownClear")?.addEventListener("click", () => clearRetrievalShowdown());
2035
+ }
2036
+
2037
+ function clearRetrievalShowdown() {
2038
+ state.showdown = null;
2039
+ state.showdownQuery = "";
2040
+ const input = $("#showdownQuery");
2041
+ if (input) input.value = "";
2042
+ paintShowdownResult(null);
2043
+ }
2044
+
2045
+ async function runRetrievalShowdown() {
2046
+ const input = $("#showdownQuery");
2047
+ const q = String(input?.value || state.showdownQuery || "").trim();
2048
+ state.showdownQuery = q;
2049
+ const el = $("#showdownResult");
2050
+ if (!q) {
2051
+ clearRetrievalShowdown();
2052
+ return;
2053
+ }
2054
+ if (el) el.innerHTML = `<p class="note">Running…</p>`;
2055
+ try {
2056
+ const data = await api("/api/retrieval/showdown", {
2057
+ method: "POST",
2058
+ body: JSON.stringify({ query: q, limit: 6 }),
2059
+ });
2060
+ state.showdown = data;
2061
+ paintShowdownResult(data);
2062
+ } catch (e) {
2063
+ if (el) el.innerHTML = `<p class="note">${esc(e?.message || String(e))}</p>`;
2064
+ }
2065
+ }
2066
+
2067
+ function softPaywallDismissed() {
2068
+ try {
2069
+ return sessionStorage.getItem("amem.softPaywall.dismissed") === "1";
2070
+ } catch {
2071
+ return false;
2072
+ }
2073
+ }
2074
+
2075
+ function dismissSoftPaywall() {
2076
+ try {
2077
+ sessionStorage.setItem("amem.softPaywall.dismissed", "1");
2078
+ } catch {
2079
+ /* ignore */
2080
+ }
2081
+ $("#softPaywall")?.classList.add("hidden");
2082
+ }
2083
+
2084
+ async function loadSoftPaywall() {
2085
+ const el = $("#softPaywall");
2086
+ if (!el) return;
2087
+ if (isPaidLicense() || softPaywallDismissed()) {
2088
+ el.classList.add("hidden");
2089
+ el.innerHTML = "";
2090
+ return;
2091
+ }
2092
+ try {
2093
+ const path = state.brainAll ? "/api/hygiene/preview?scope=all" : "/api/hygiene/preview";
2094
+ const preview = state.brainAll ? await apiUnscoped(path) : await api(path);
2095
+ if (!preview?.softPaywall) {
2096
+ el.classList.add("hidden");
2097
+ el.innerHTML = "";
2098
+ return;
2099
+ }
2100
+ const noise = (preview.staleCount || 0) + (preview.duplicateCount || 0);
2101
+ const shop = state.shop || {};
2102
+ const proUrl = shop.proUrl || "https://getamem.com/buy/pro";
2103
+ el.classList.remove("hidden");
2104
+ el.innerHTML = `
2105
+ <div class="soft-paywall-inner">
2106
+ <div>
2107
+ <strong>Pro can clean this</strong>
2108
+ <p class="note" style="margin:0.25rem 0 0">${preview.active} facts · ~${noise} unused/duplicates → about <b>${preview.afterCleanup}</b> after cleanup. Free still works — this is optional.</p>
2109
+ </div>
2110
+ <div class="soft-paywall-actions">
2111
+ <a class="btn small" href="${esc(proUrl)}" target="_blank" rel="noopener">Buy Pro · Apply cleanup</a>
2112
+ <button class="btn secondary small" type="button" id="softPaywallShowdown">Compare retrieval</button>
2113
+ <button class="btn secondary small" type="button" id="softPaywallDismiss">Dismiss</button>
2114
+ </div>
2115
+ </div>`;
2116
+ $("#softPaywallDismiss")?.addEventListener("click", () => dismissSoftPaywall());
2117
+ $("#softPaywallShowdown")?.addEventListener("click", () => {
2118
+ state.showdownOpen = true;
2119
+ renderBrain();
2120
+ });
2121
+ maybeNudgeShowdown();
2122
+ } catch {
2123
+ el.classList.add("hidden");
2124
+ }
2125
+ }
2126
+
2127
+ function maybeNudgeShowdown() {
2128
+ if (isPaidLicense() || state.showdownOpen) return;
2129
+ try {
2130
+ if (sessionStorage.getItem("amem.showdown.nudged") === "1") return;
2131
+ const hits = Number(sessionStorage.getItem("amem.context.hits") || "0");
2132
+ if (hits < 3) return;
2133
+ sessionStorage.setItem("amem.showdown.nudged", "1");
2134
+ state.showdownOpen = true;
2135
+ } catch {
2136
+ /* ignore */
2137
+ }
2138
+ }
2139
+
2140
+ function noteContextHit() {
2141
+ try {
2142
+ const n = Number(sessionStorage.getItem("amem.context.hits") || "0") + 1;
2143
+ sessionStorage.setItem("amem.context.hits", String(n));
2144
+ if (n === 3 || n === 10) maybeNudgeShowdown();
2145
+ } catch {
2146
+ /* ignore */
2147
+ }
2148
+ }
2149
+
2150
+ function paintEmbedIndexBanner() {
2151
+ const el = $("#embedIndexBanner");
2152
+ if (!el) return;
2153
+ const health = state.embed?.index;
2154
+ const stale = Number(health?.stale || 0);
2155
+ if (!health || stale <= 0) {
2156
+ el.classList.add("hidden");
2157
+ el.innerHTML = "";
2158
+ return;
2159
+ }
2160
+ const from = (health.strandedBy || [])
2161
+ .map((s) => `${s.backend}/${s.dim}`)
2162
+ .join(", ");
2163
+ el.classList.remove("hidden");
2164
+ el.innerHTML = `
2165
+ <div class="embed-index-banner-inner">
2166
+ <div>
2167
+ <strong>Embeddings need a reindex</strong>
2168
+ <p class="note" style="margin:0.25rem 0 0">${stale} of ${health.total} facts are indexed as ${esc(from || "another backend")} but active retrieval is <b>${esc(health.active)}/${health.dim}</b>. Semantic search skips them until you reindex.</p>
2169
+ </div>
2170
+ <div class="embed-index-banner-actions">
2171
+ <button class="btn small" type="button" id="embedIndexReindex">Reindex now</button>
2172
+ </div>
2173
+ </div>`;
2174
+ $("#embedIndexReindex")?.addEventListener("click", async () => {
2175
+ const btn = $("#embedIndexReindex");
2176
+ if (btn) {
2177
+ btn.disabled = true;
2178
+ btn.textContent = "Reindexing…";
2179
+ }
2180
+ try {
2181
+ await apiUnscoped("/api/embed/reindex", { method: "POST", body: "{}" });
2182
+ state.embed = await apiUnscoped("/api/embed");
2183
+ paintEmbedIndexBanner();
2184
+ } catch (e) {
2185
+ if (btn) {
2186
+ btn.disabled = false;
2187
+ btn.textContent = "Reindex now";
2188
+ }
2189
+ el.querySelector(".note") &&
2190
+ (el.querySelector(".note").textContent = e?.message || String(e));
2191
+ }
2192
+ });
2193
+ }
2194
+
2195
+ function renderBrain() {
2196
+ const main = $("#main");
2197
+ state.graphTick += 1;
2198
+ setMainMode("brain");
2199
+ const drafts = (state.graph?.drafts || []).filter((d) => d.status === "pending");
2200
+ const pendingTotal = Number(state.graph?.pendingDraftTotal ?? drafts.length);
2201
+ const totalClaims = (state.graph?.claims || []).length;
2202
+ const showdownOpen = Boolean(state.showdownOpen);
2203
+ const filterHelp = {
2204
+ files: "Everything stored, grouped by file — what amem can inject instead of searching.",
2205
+ drafts: "Session captures waiting for you. Approve good ones; dismiss junk in bulk.",
2206
+ chats: "Takeaways from chats (session facts).",
2207
+ used: "Facts that were actually injected into a recent Cursor/Claude query.",
2208
+ pinned: "Pinned facts rank higher in retrieval and can sync to Cursor rules (Pro).",
2209
+ review: "Pro cleanup: unused facts and near-duplicates.",
2210
+ }[state.brainFilter] || "";
2211
+
2212
+ main.innerHTML = `
2213
+ <div class="brain-v2">
2214
+ <div class="brain-chrome">
2215
+ <div class="brain-filters" id="brainFilters" role="tablist" aria-label="Memory views">
2216
+ <button type="button" data-filter="files" class="${state.brainFilter === "files" ? "active" : ""}" title="Every stored fact, grouped by file">Files${totalClaims ? ` (${totalClaims})` : ""}</button>
2217
+ <button type="button" data-filter="drafts" class="${state.brainFilter === "drafts" ? "active" : ""}" title="Approve session captures">Approve${pendingTotal ? ` (${pendingTotal})` : ""}</button>
2218
+ <button type="button" data-filter="chats" class="${state.brainFilter === "chats" ? "active" : ""}" title="Chat takeaways">Chats</button>
2219
+ <button type="button" data-filter="used" class="${state.brainFilter === "used" ? "active" : ""}" title="Recently injected">Injected</button>
2220
+ <button type="button" data-filter="pinned" class="${state.brainFilter === "pinned" ? "active" : ""}" title="Pinned / boosted facts">Pinned</button>
2221
+ <button type="button" data-filter="review" class="${state.brainFilter === "review" ? "active" : ""}" title="Unused & duplicates">Cleanup</button>
2222
+ </div>
2223
+ ${vizToggleHtml()}
2224
+ </div>
2225
+ <p class="brain-filter-help">${esc(filterHelp)}</p>
2226
+ <div id="softPaywall" class="soft-paywall hidden"></div>
2227
+ <div class="showdown-panel ${showdownOpen ? "open" : "closed"}" id="showdownPanel">
2228
+ <div class="showdown-run">
2229
+ <input id="showdownQuery" type="search" placeholder="Same query · free hash vs Pro n-gram…" value="${esc(state.showdownQuery || "")}" />
2230
+ <button class="btn secondary small" type="button" id="showdownBtn">Run</button>
2231
+ <button class="btn secondary small ${state.showdown ? "" : "hidden"}" type="button" id="showdownClearTop">Clear</button>
2232
+ </div>
2233
+ <div id="showdownResult" class="showdown-result"></div>
2234
+ </div>
2235
+ <div class="brain-body">
2236
+ <section class="brain-map" id="brainMap"></section>
2237
+ </div>
2238
+ </div>`;
2239
+
2240
+ renderBrainRight();
2241
+ loadSoftPaywall();
2242
+
2243
+ $("#showdownBtn")?.addEventListener("click", () => runRetrievalShowdown());
2244
+ $("#showdownClearTop")?.addEventListener("click", () => clearRetrievalShowdown());
2245
+ $("#showdownQuery")?.addEventListener("input", (e) => {
2246
+ if (!String(e.target.value || "").trim() && state.showdown) clearRetrievalShowdown();
2247
+ });
2248
+ $("#showdownQuery")?.addEventListener("keydown", (e) => {
2249
+ if (e.key === "Enter") runRetrievalShowdown();
2250
+ if (e.key === "Escape") clearRetrievalShowdown();
2251
+ });
2252
+ if (state.showdown) paintShowdownResult(state.showdown);
2253
+ $("#brainFilters")?.querySelectorAll("button").forEach((btn) => {
2254
+ btn.addEventListener("click", () => {
2255
+ state.brainFilter = btn.dataset.filter;
2256
+ state.selectedNode = null;
2257
+ state.selectedFile = null;
2258
+ if (state.brainFilter !== "drafts") state.draftSelected = {};
2259
+ renderBrain();
2260
+ });
2261
+ });
2262
+ bindVizToggle(main);
2263
+ try {
2264
+ paintBrain();
2265
+ } catch (e) {
2266
+ const map = $("#brainMap");
2267
+ if (map) map.innerHTML = `<p class="note">${esc(e.message || e)}</p>`;
2268
+ }
2269
+ }
2270
+ function paintBrain() {
2271
+ renderBrainFeed();
2272
+ renderBrainMap();
2273
+ bindCoverageResize();
2274
+ if (state.selectedNode) showBrainDetail(state.selectedNode);
2275
+ else showBrainOverview();
2276
+ }
2277
+
2278
+ function renderBrainFeed() {
2279
+ const el = $("#brainFeed");
2280
+ if (!el) return;
2281
+ const drafts = (state.graph?.drafts || []).filter((d) => d.status === "pending").slice(0, 12);
2282
+ const events = (state.graph?.recentEvents || []).slice(0, 16);
2283
+ const draftBlock =
2284
+ drafts.length === 0
2285
+ ? ""
2286
+ : `<h2>Waiting for approval</h2>${drafts
2287
+ .map((d) => {
2288
+ const active = state.selectedNode?.type === "draft" && state.selectedNode.id === d.id;
2289
+ return `<button type="button" class="feed-item draft ${active ? "active" : ""}" data-draft="${esc(d.id)}">
2290
+ <div class="feed-top"><span>${esc(d.platform || "agent")} · ${esc(formatWhen(d.created_at))}</span><span class="pill ${d.quality?.label === "high" ? "" : "warn"}">${esc(d.quality?.label || "approve?")}${d.conflicts?.length ? " · conflict" : ""}</span></div>
2291
+ <div class="feed-q">${esc(d.title || d.id)}</div>
2292
+ <div class="feed-meta">${state.brainAll ? `${esc(memoryLabel(d.repo_id))} · ` : ""}${d.quality ? `Confidence ${d.quality.score}` : "Session capture"} — open Approve tab for bulk actions</div>
2293
+ </button>`;
2294
+ })
2295
+ .join("")}`;
2296
+
2297
+ if (!events.length && !drafts.length) {
2298
+ el.innerHTML = `<h2>Recent uses</h2><p class="note" style="margin:0">No <code>amem context</code> hits yet. Ask Cursor something in this repo — local hits show up here. Session-end drafts for approval also land here.</p>`;
2299
+ return;
2300
+ }
2301
+ el.innerHTML = `${draftBlock}<h2>Recent uses</h2>${
2302
+ events.length
2303
+ ? events
2304
+ .map((e) => {
2305
+ const kind = eventKindOf(e);
2306
+ const ids = eventClaimIds(e);
2307
+ const active = state.activeEventId === e.id;
2308
+ return `<button type="button" class="feed-item ${kind} ${active ? "active" : ""}" data-event="${esc(e.id)}">
2309
+ <div class="feed-top"><span>${esc(e.platform || "agent")} · ${esc(formatWhen(e.created_at))}</span><span class="pill ${kind === "local_hit" ? "ok" : "warn"}">${kind === "local_hit" ? "local hit" : "had to explore"}</span></div>
2310
+ <div class="feed-q">${esc(eventQueryLabel(e))}</div>
2311
+ <div class="feed-meta">${kind === "local_hit" ? `${ids.length} fact${ids.length === 1 ? "" : "s"} · ~${formatTokens(e.estimated_tokens_saved)} tok` : "amem had nothing useful"}</div>
2312
+ </button>`;
2313
+ })
2314
+ .join("")
2315
+ : `<p class="note" style="margin:0">No context hits yet.</p>`
2316
+ }`;
2317
+ el.querySelectorAll("[data-draft]").forEach((btn) => {
2318
+ btn.addEventListener("click", () => {
2319
+ const draft = (state.graph.drafts || []).find((x) => x.id === btn.dataset.draft);
2320
+ if (!draft) return;
2321
+ state.activeEventId = null;
2322
+ state.selectedNode = { type: "draft", id: draft.id, detail: draft };
2323
+ paintBrain();
2324
+ });
2325
+ });
2326
+ el.querySelectorAll("[data-event]").forEach((btn) => {
2327
+ btn.addEventListener("click", () => {
2328
+ const ev = (state.graph.recentEvents || []).find((x) => x.id === btn.dataset.event);
2329
+ if (!ev) return;
2330
+ state.activeEventId = ev.id;
2331
+ try {
2332
+ state.graph.recentClaimIds = eventClaimIds(ev);
2333
+ } catch {
2334
+ state.graph.recentClaimIds = [];
2335
+ }
2336
+ state.selectedNode = { type: "event", id: ev.id, detail: ev };
2337
+ const files = [...eventFiles(ev)];
2338
+ state.selectedFile = files[0] || null;
2339
+ paintBrain();
2340
+ });
2341
+ });
2342
+ }
2343
+
2344
+ function fileLabel(path) {
2345
+ const parts = String(path || "").split(/[/\\]/).filter(Boolean);
2346
+ if (parts.length <= 2) return path;
2347
+ return parts.slice(-2).join("/");
2348
+ }
2349
+
2350
+ function coverageGroups() {
2351
+ return fileGroups(state.graph?.claims || []);
2352
+ }
2353
+
2354
+ function orbitMemoryLabel(repoId) {
2355
+ const r = listedRepos().find((x) => x.id === repoId);
2356
+ if (!r) return "unknown";
2357
+ if (isPersonal(r)) return "Personal";
2358
+ return isWorkspace(r) ? `${r.repo_name} (ws)` : r.repo_name;
2359
+ }
2360
+
2361
+ // Across all memories the ring must be one node per memory: every repo anchors most
2362
+ // of its facts to README.md, so grouping on the raw path merges five repos into one
2363
+ // node and hides the smaller ones entirely.
2364
+ function orbitClaims() {
2365
+ const base = visibleClaims().length ? visibleClaims() : state.graph?.claims || [];
2366
+ return base.map((c) => {
2367
+ const file = claimAnchors(c)[0] || "(no file yet)";
2368
+ return state.brainAll
2369
+ ? {
2370
+ ...c,
2371
+ _group: c.repo_id,
2372
+ _groupLabel: orbitMemoryLabel(c.repo_id),
2373
+ _sub: fileGroupKey(c, file),
2374
+ _subLabel: fileLabel(file),
2375
+ }
2376
+ : { ...c, _group: file, _sub: null };
2377
+ });
2378
+ }
2379
+
2380
+ function orbitFocusMemory() {
2381
+ if (state.selectedNode?.type === "memory") return state.selectedNode.id;
2382
+ if (state.selectedNode?.type === "claim") return state.selectedNode.detail?.repo_id || null;
2383
+ return null;
2384
+ }
2385
+
2386
+ function matchingFiles() {
2387
+ return new Set(fileGroups(visibleClaims()).map((g) => g.file));
2388
+ }
2389
+
2390
+ function eventFiles(ev) {
2391
+ const ids = eventClaimIds(ev);
2392
+ const files = new Set();
2393
+ for (const id of ids) {
2394
+ const claim = (state.graph?.claims || []).find((c) => c.id === id);
2395
+ if (!claim) continue;
2396
+ for (const a of claimAnchors(claim)) files.add(a);
2397
+ }
2398
+ return files;
2399
+ }
2400
+
2401
+ function layoutTreemap(items, x, y, w, h) {
2402
+ if (!items.length) return [];
2403
+ if (items.length === 1) return [{ ...items[0], x, y, w, h }];
2404
+ const total = items.reduce((s, i) => s + i.weight, 0) || 1;
2405
+ let acc = 0;
2406
+ let split = 1;
2407
+ for (let i = 0; i < items.length; i++) {
2408
+ acc += items[i].weight;
2409
+ split = i + 1;
2410
+ if (acc >= total / 2) break;
2411
+ }
2412
+ const left = items.slice(0, split);
2413
+ const right = items.slice(split);
2414
+ if (!right.length) return [{ ...left[0], x, y, w, h }];
2415
+ const leftW = left.reduce((s, i) => s + i.weight, 0);
2416
+ const frac = Math.min(0.85, Math.max(0.15, leftW / total));
2417
+ if (w >= h) {
2418
+ const lw = w * frac;
2419
+ return [...layoutTreemap(left, x, y, lw, h), ...layoutTreemap(right, x + lw, y, w - lw, h)];
2420
+ }
2421
+ const lh = h * frac;
2422
+ return [...layoutTreemap(left, x, y, w, lh), ...layoutTreemap(right, x, y + lh, w, h - lh)];
2423
+ }
2424
+
2425
+ let coverageRo = null;
2426
+ function bindCoverageResize() {
2427
+ const host = $("#coverageHost");
2428
+ if (coverageRo) {
2429
+ coverageRo.disconnect();
2430
+ coverageRo = null;
2431
+ }
2432
+ if (!host || typeof ResizeObserver === "undefined") return;
2433
+ coverageRo = new ResizeObserver(() => renderCoverageMap());
2434
+ coverageRo.observe(host);
2435
+ }
2436
+
2437
+ function renderCoverageMap() {
2438
+ const host = $("#coverageHost");
2439
+ if (!host) return;
2440
+ const legendH = 28;
2441
+ const pad = 2;
2442
+ // Clamp: an unbounded host makes the treemap taller than the viewport, so only
2443
+ // the single largest tile is ever on screen.
2444
+ const w = Math.min(2400, Math.max(160, Math.floor(host.clientWidth)));
2445
+ const h = Math.min(1400, Math.max(140, Math.floor(host.clientHeight - legendH)));
2446
+ const groups = coverageGroups();
2447
+ const matching = matchingFiles();
2448
+ const hot = new Set(state.graph?.recentClaimIds || []);
2449
+ const selectedFile = state.selectedFile;
2450
+ const eventHotFiles =
2451
+ state.selectedNode?.type === "event" ? eventFiles(state.selectedNode.detail) : new Set();
2452
+
2453
+ if (!groups.length) {
2454
+ host.innerHTML = `
2455
+ <div class="coverage-legend">No files in memory yet — bootstrap on Setup or keep working in this repo.</div>
2456
+ <svg class="coverage-svg" viewBox="0 0 ${w} ${h}" role="img" aria-label="Empty coverage map">
2457
+ <rect x="8" y="8" width="${w - 16}" height="${h - 16}" rx="10" fill="none" stroke="rgba(232,238,242,0.14)" stroke-dasharray="6 6"/>
2458
+ <text x="${w / 2}" y="${h / 2}" text-anchor="middle" fill="#8fa3b0" font-size="13">amem has no file coverage here</text>
2459
+ </svg>`;
2460
+ return;
2461
+ }
2462
+
2463
+ const items = groups.map((g) => ({
2464
+ file: g.file,
2465
+ weight: Math.max(1, g.items.length),
2466
+ count: g.items.length,
2467
+ used: g.used,
2468
+ session: g.items.every(isSessionClaim),
2469
+ }));
2470
+ const gap = 3;
2471
+ const rects = layoutTreemap(items, pad, pad, w - pad * 2, h - pad * 2).map((r) => ({
2472
+ ...r,
2473
+ x: r.x + gap / 2,
2474
+ y: r.y + gap / 2,
2475
+ w: Math.max(2, r.w - gap),
2476
+ h: Math.max(2, r.h - gap),
2477
+ }));
2478
+
2479
+ const tiles = rects
2480
+ .map((r, i) => {
2481
+ const match = matching.has(r.file);
2482
+ const selected = selectedFile === r.file;
2483
+ const fromEvent = eventHotFiles.has(r.file);
2484
+ const classes = [
2485
+ "cov-tile",
2486
+ r.used || fromEvent ? "used" : "",
2487
+ r.session ? "session" : "",
2488
+ match ? "" : "dim",
2489
+ selected ? "selected" : "",
2490
+ ]
2491
+ .filter(Boolean)
2492
+ .join(" ");
2493
+ const showLabel = r.w > 72 && r.h > 32;
2494
+ const showCount = r.w > 72 && r.h > 48;
2495
+ const label = fileLabel(r.file);
2496
+ const clipId = `cov-clip-${i}`;
2497
+ return `<g class="${classes}" data-file="${esc(r.file)}" role="button" tabindex="0">
2498
+ <title>${esc(r.file)} · ${r.count} fact${r.count === 1 ? "" : "s"}${r.used ? " · used recently" : ""}</title>
2499
+ <clipPath id="${clipId}"><rect x="${r.x.toFixed(1)}" y="${r.y.toFixed(1)}" width="${r.w.toFixed(1)}" height="${r.h.toFixed(1)}" rx="7"/></clipPath>
2500
+ <rect x="${r.x.toFixed(1)}" y="${r.y.toFixed(1)}" width="${r.w.toFixed(1)}" height="${r.h.toFixed(1)}" rx="7"/>
2501
+ <g clip-path="url(#${clipId})">
2502
+ ${
2503
+ showLabel
2504
+ ? `<text class="cov-name" x="${(r.x + 8).toFixed(1)}" y="${(r.y + 18).toFixed(1)}">${esc(label)}</text>`
2505
+ : ""
2506
+ }
2507
+ ${
2508
+ showCount
2509
+ ? `<text class="cov-count" x="${(r.x + 8).toFixed(1)}" y="${(r.y + 34).toFixed(1)}">${r.count} fact${r.count === 1 ? "" : "s"}</text>`
2510
+ : ""
2511
+ }
2512
+ </g>
2513
+ </g>`;
2514
+ })
2515
+ .join("");
2516
+
2517
+ host.innerHTML = `
2518
+ <div class="coverage-legend">
2519
+ <span class="swatch used"></span> used recently
2520
+ <span class="swatch file"></span> known file
2521
+ <span class="swatch dim"></span> hidden by this tab
2522
+ <span class="legend-note">tile size = facts</span>
2523
+ </div>
2524
+ <svg class="coverage-svg" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" role="img" aria-label="File coverage map">${tiles}</svg>`;
2525
+
2526
+ host.querySelectorAll("[data-file]").forEach((node) => {
2527
+ const pick = () => {
2528
+ const file = node.getAttribute("data-file");
2529
+ const group = coverageGroups().find((g) => g.file === file);
2530
+ if (!group) return;
2531
+ state.selectedFile = file;
2532
+ state.selectedNode = { type: "file", id: file, detail: group };
2533
+ paintBrain();
2534
+ };
2535
+ node.addEventListener("click", pick);
2536
+ node.addEventListener("keydown", (e) => {
2537
+ if (e.key === "Enter" || e.key === " ") {
2538
+ e.preventDefault();
2539
+ pick();
2540
+ }
2541
+ });
2542
+ });
2543
+ }
2544
+
2545
+ function renderBrainMap() {
2546
+ const el = $("#brainMap");
2547
+ if (!el) return;
2548
+
2549
+ if (state.brainFilter === "review" || state.brainFilter === "drafts") {
2550
+ stopAllViz();
2551
+ }
2552
+
2553
+ if (state.brainFilter === "review") {
2554
+ el.innerHTML = `<div class="brain-empty">Loading cleanup inbox…</div>`;
2555
+ api("/api/hygiene")
2556
+ .then((h) => {
2557
+ const stale = h.stale || [];
2558
+ const dups = h.duplicates || [];
2559
+ if (!stale.length && !dups.length) {
2560
+ el.innerHTML = `<div class="brain-empty">Nothing to clean up. Unused facts (90 days) and near-duplicates show up here on Pro/IT.${
2561
+ h.schedule?.installed ? " Weekly hygiene is scheduled." : ""
2562
+ }</div>`;
2563
+ return;
2564
+ }
2565
+ el.innerHTML = `
2566
+ <div class="draft-toolbar">
2567
+ <button class="btn small" type="button" id="acceptSafe">Accept safe cleanups</button>
2568
+ <button class="btn secondary small" type="button" id="decayStale">Decay ${stale.length} unused</button>
2569
+ ${
2570
+ h.schedule?.installed
2571
+ ? `<button class="btn secondary small" type="button" id="hygieneUnsched">Unschedule weekly</button>`
2572
+ : `<button class="btn secondary small" type="button" id="hygieneSched">Schedule weekly</button>`
2573
+ }
2574
+ </div>
2575
+ ${
2576
+ dups.length
2577
+ ? `<h2>Near-duplicates</h2>${dups
2578
+ .map(
2579
+ (d) =>
2580
+ `<div class="draft-card"><strong>${esc(d.keepId)}</strong> vs ${esc(d.dropId)} · ${Math.round((d.similarity || 0) * 100)}%<div class="drawer-actions"><button class="btn secondary small" type="button" data-merge-keep="${esc(d.keepId)}" data-merge-drop="${esc(d.dropId)}">Merge into keep</button></div></div>`,
2581
+ )
2582
+ .join("")}`
2583
+ : ""
2584
+ }
2585
+ ${
2586
+ stale.length
2587
+ ? `<h2>Unused ${stale.length}</h2>${stale
2588
+ .slice(0, 24)
2589
+ .map((c) => `<div class="draft-card"><strong>${esc(c.id)}</strong><span class="meta">${esc((c.text || "").slice(0, 160))}</span></div>`)
2590
+ .join("")}`
2591
+ : ""
2592
+ }`;
2593
+ $("#acceptSafe")?.addEventListener("click", async () => {
2594
+ try {
2595
+ const result = await api("/api/hygiene/accept-safe", { method: "POST", body: "{}" });
2596
+ await refreshGraph();
2597
+ renderBrain();
2598
+ alert(
2599
+ `Decayed ${result.decayed?.length || 0}; merged ${result.merged?.length || 0}.`,
2600
+ );
2601
+ } catch (err) {
2602
+ alert(err.message);
2603
+ }
2604
+ });
2605
+ $("#decayStale")?.addEventListener("click", async () => {
2606
+ try {
2607
+ const result = await api("/api/hygiene/decay", { method: "POST", body: "{}" });
2608
+ await refreshGraph();
2609
+ renderBrain();
2610
+ alert(`Decayed ${result.decayed?.length || 0} facts.`);
2611
+ } catch (err) {
2612
+ alert(err.message);
2613
+ }
2614
+ });
2615
+ $("#hygieneSched")?.addEventListener("click", async () => {
2616
+ try {
2617
+ await apiUnscoped("/api/hygiene/schedule", {
2618
+ method: "POST",
2619
+ body: JSON.stringify({ hour: 4 }),
2620
+ });
2621
+ renderBrain();
2622
+ } catch (err) {
2623
+ alert(err.message);
2624
+ }
2625
+ });
2626
+ $("#hygieneUnsched")?.addEventListener("click", async () => {
2627
+ try {
2628
+ await apiUnscoped("/api/hygiene/unschedule", { method: "POST", body: "{}" });
2629
+ renderBrain();
2630
+ } catch (err) {
2631
+ alert(err.message);
2632
+ }
2633
+ });
2634
+ el.querySelectorAll("[data-merge-keep]").forEach((btn) => {
2635
+ btn.addEventListener("click", async () => {
2636
+ try {
2637
+ await api("/api/hygiene/merge", {
2638
+ method: "POST",
2639
+ body: JSON.stringify({ keepId: btn.dataset.mergeKeep, dropId: btn.dataset.mergeDrop }),
2640
+ });
2641
+ await refreshGraph();
2642
+ renderBrain();
2643
+ } catch (err) {
2644
+ alert(err.message);
2645
+ }
2646
+ });
2647
+ });
2648
+ })
2649
+ .catch((err) => {
2650
+ el.innerHTML = `<div class="brain-empty">${esc(err.message)}</div>`;
2651
+ });
2652
+ return;
2653
+ }
2654
+
2655
+ if (state.brainFilter === "drafts") {
2656
+ const drafts = (state.graph?.drafts || []).filter((d) => d.status === "pending");
2657
+ const pendingTotal = Number(state.graph?.pendingDraftTotal ?? drafts.length);
2658
+ const selectedId = state.selectedNode?.type === "draft" ? state.selectedNode.id : null;
2659
+ const selectedIds = Object.keys(state.draftSelected || {}).filter((id) => state.draftSelected[id]);
2660
+ const counts = { high: 0, medium: 0, low: 0, reject: 0 };
2661
+ for (const d of drafts) {
2662
+ const label = d.quality?.label || "medium";
2663
+ if (counts[label] != null) counts[label] += 1;
2664
+ }
2665
+ const highN = counts.high;
2666
+ const junkN = counts.low + counts.reject;
2667
+
2668
+ if (drafts.length === 0) {
2669
+ el.innerHTML = `<div class="brain-empty">
2670
+ <strong>Nothing waiting</strong>
2671
+ <p>When a Cursor session ends, amem queues a short capture here. Approve to store it as a fact, or dismiss.</p>
2672
+ <p class="note">Tip: turn on <b>Auto-approve</b> in the top bar if you trust most captures.</p>
2673
+ </div>`;
2674
+ return;
2675
+ }
2676
+
2677
+ el.innerHTML = `
2678
+ <div class="draft-inbox">
2679
+ <div class="draft-inbox-head">
2680
+ <div>
2681
+ <h2>Approve captures <span>${pendingTotal} waiting${pendingTotal > drafts.length ? ` · showing ${drafts.length}` : ""}</span></h2>
2682
+ <p class="note">These are proposed facts from sessions — not stored until you approve. Use bulk actions so you don’t open 400 one-by-one.</p>
2683
+ </div>
2684
+ <div class="draft-quality-pills">
2685
+ <span class="pill ok">${highN} high</span>
2686
+ <span class="pill">${counts.medium} medium</span>
2687
+ <span class="pill warn">${junkN} low/junk</span>
2688
+ </div>
2689
+ </div>
2690
+ <div class="draft-toolbar">
2691
+ <button class="btn small" type="button" id="approveHigh" ${highN ? "" : "disabled"}>Approve all high (${highN})</button>
2692
+ <button class="btn secondary small" type="button" id="dismissJunk" ${junkN ? "" : "disabled"}>Dismiss junk (${junkN})</button>
2693
+ <button class="btn secondary small" type="button" id="dismissAllDrafts">Dismiss all ${pendingTotal}</button>
2694
+ <span class="draft-sel-meta">${selectedIds.length ? `${selectedIds.length} selected` : "or select rows"}</span>
2695
+ <button class="btn secondary small" type="button" id="applySelected" ${selectedIds.length ? "" : "disabled"}>Approve selected</button>
2696
+ <button class="btn secondary small" type="button" id="dismissSelected" ${selectedIds.length ? "" : "disabled"}>Dismiss selected</button>
2697
+ <button class="btn secondary small" type="button" id="selectAllDrafts">Select all shown</button>
2698
+ <button class="btn secondary small" type="button" id="clearDraftSel" ${selectedIds.length ? "" : "disabled"}>Clear</button>
2699
+ </div>
2700
+ <div class="draft-grid">${drafts
2701
+ .map((d) => {
2702
+ const checked = Boolean(state.draftSelected?.[d.id]);
2703
+ const q = d.quality?.label || "unscored";
2704
+ return `<div class="draft-card ${selectedId === d.id ? "selected" : ""}" data-draft-map="${esc(d.id)}">
2705
+ <label class="draft-check" title="Select for bulk">
2706
+ <input type="checkbox" data-draft-check="${esc(d.id)}" ${checked ? "checked" : ""} />
2707
+ </label>
2708
+ <button type="button" class="draft-card-main" data-draft-open="${esc(d.id)}">
2709
+ <strong>${esc(d.title || d.id)}</strong>
2710
+ <span class="meta">${esc(d.platform || "agent")} · ${esc(formatWhen(d.created_at))} · <em class="q-${esc(q)}">${esc(q)}${d.quality ? ` ${d.quality.score}` : ""}</em>${d.conflicts?.length ? " · conflict" : ""}</span>
2711
+ </button>
2712
+ </div>`;
2713
+ })
2714
+ .join("")}</div>
2715
+ </div>`;
2716
+
2717
+ const runBulk = async (action, extra = {}) => {
2718
+ try {
2719
+ const result = await api("/api/drafts/bulk", {
2720
+ method: "POST",
2721
+ body: JSON.stringify({
2722
+ action,
2723
+ scope: state.brainAll ? "all" : undefined,
2724
+ ...extra,
2725
+ }),
2726
+ });
2727
+ state.draftSelected = {};
2728
+ state.selectedNode = null;
2729
+ await refreshGraph();
2730
+ renderBrain();
2731
+ const parts = [];
2732
+ if (result.appliedCount) parts.push(`approved ${result.appliedCount}`);
2733
+ if (result.dismissedCount) parts.push(`dismissed ${result.dismissedCount}`);
2734
+ if (result.skipped?.length) parts.push(`skipped ${result.skipped.length}`);
2735
+ if (parts.length) alert(parts.join(" · "));
2736
+ } catch (err) {
2737
+ alert(err.message);
2738
+ }
2739
+ };
2740
+
2741
+ $("#approveHigh")?.addEventListener("click", () => runBulk("approve_high"));
2742
+ $("#dismissJunk")?.addEventListener("click", () => runBulk("dismiss_junk"));
2743
+ $("#dismissAllDrafts")?.addEventListener("click", () => {
2744
+ if (!confirm(`Dismiss all ${pendingTotal} waiting captures? This cannot be undone.`)) return;
2745
+ runBulk("dismiss_all", { confirm: true });
2746
+ });
2747
+ $("#applySelected")?.addEventListener("click", () => {
2748
+ const ids = Object.keys(state.draftSelected || {}).filter((id) => state.draftSelected[id]);
2749
+ if (!ids.length) return;
2750
+ runBulk("apply_ids", { ids, resolve: "keep" });
2751
+ });
2752
+ $("#dismissSelected")?.addEventListener("click", () => {
2753
+ const ids = Object.keys(state.draftSelected || {}).filter((id) => state.draftSelected[id]);
2754
+ if (!ids.length) return;
2755
+ runBulk("dismiss_ids", { ids });
2756
+ });
2757
+ $("#selectAllDrafts")?.addEventListener("click", () => {
2758
+ state.draftSelected = Object.fromEntries(drafts.map((d) => [d.id, true]));
2759
+ renderBrain();
2760
+ });
2761
+ $("#clearDraftSel")?.addEventListener("click", () => {
2762
+ state.draftSelected = {};
2763
+ renderBrain();
2764
+ });
2765
+ el.querySelectorAll("[data-draft-check]").forEach((box) => {
2766
+ box.addEventListener("click", (e) => e.stopPropagation());
2767
+ box.addEventListener("change", () => {
2768
+ const id = box.dataset.draftCheck;
2769
+ state.draftSelected = { ...(state.draftSelected || {}), [id]: box.checked };
2770
+ renderBrain();
2771
+ });
2772
+ });
2773
+ el.querySelectorAll("[data-draft-open]").forEach((btn) => {
2774
+ btn.addEventListener("click", () => {
2775
+ const draft = (state.graph.drafts || []).find((x) => x.id === btn.dataset.draftOpen);
2776
+ if (!draft) return;
2777
+ state.selectedNode = { type: "draft", id: draft.id, detail: draft };
2778
+ paintBrain();
2779
+ });
2780
+ });
2781
+ return;
2782
+ }
2783
+
2784
+ const claims = visibleClaims();
2785
+ const groups = fileGroups(claims);
2786
+ const selectedFile = state.selectedFile;
2787
+ const shown = selectedFile
2788
+ ? coverageGroups().filter((g) => g.file === selectedFile)
2789
+ : groups;
2790
+ const hot = new Set(state.graph?.recentClaimIds || []);
2791
+ const selectedId = state.selectedNode?.type === "claim" ? state.selectedNode.id : null;
2792
+ const filterHint =
2793
+ state.brainFilter === "used"
2794
+ ? "Only facts injected in a recent query."
2795
+ : state.brainFilter === "chats"
2796
+ ? "Chat takeaways. Other files stay on the map, dimmed."
2797
+ : state.brainFilter === "pinned"
2798
+ ? "Pinned facts only — click Pin on any row to boost retrieval."
2799
+ : "Each tile is a file amem can inject. Use ★ Pin on a row to boost it.";
2800
+
2801
+ const list =
2802
+ shown.length === 0
2803
+ ? `<div class="brain-empty">${
2804
+ state.brainFilter === "used"
2805
+ ? "Nothing in this repo has been injected yet — the map still shows what is stored."
2806
+ : state.brainFilter === "chats"
2807
+ ? "No session takeaways yet. Approve a capture or keep chatting."
2808
+ : state.brainFilter === "pinned"
2809
+ ? "No pinned facts yet. On any fact row, click <b>Pin</b> — pinned facts rank higher and fill this view."
2810
+ : state.brainSearch
2811
+ ? "No facts match that filter. Clear the search box to see everything again."
2812
+ : "No facts yet. Apply a bootstrap on Setup, or keep working in this repo."
2813
+ }</div>`
2814
+ : shown
2815
+ .map((group) => {
2816
+ const usedClass = group.used ? "used" : "";
2817
+ return `<article class="file-card ${usedClass}">
2818
+ <header>
2819
+ <code>${esc(group.file)}</code>
2820
+ <span>${group.items.length} fact${group.items.length === 1 ? "" : "s"}${group.used ? " · used recently" : ""}</span>
2821
+ </header>
2822
+ <ul>
2823
+ ${group.items
2824
+ .map((c) => {
2825
+ const rel = relatedForClaim(c.id);
2826
+ const isPinned = Number(c.pinned || 0) > 0;
2827
+ const tags = [
2828
+ isPinned ? "pinned" : null,
2829
+ c.kind,
2830
+ ...rel.flows.map((f) => f.name),
2831
+ ...rel.components.map((x) => x.name),
2832
+ ]
2833
+ .filter(Boolean)
2834
+ .slice(0, 4);
2835
+ return `<li class="claim-li">
2836
+ <button type="button" class="claim-row ${hot.has(c.id) ? "hot" : ""} ${selectedId === c.id ? "selected" : ""}" data-claim="${esc(c.id)}">
2837
+ <span class="claim-text">${esc(claimPreview(c))}</span>
2838
+ <span class="claim-tags">${tags.map((t) => `<em>${esc(t)}</em>`).join("")}</span>
2839
+ </button>
2840
+ ${pinButtonHtml(c)}
2841
+ </li>`;
2842
+ })
2843
+ .join("")}
2844
+ </ul>
2845
+ </article>`;
2846
+ })
2847
+ .join("");
2848
+
2849
+ const mode = state.memoryViz || "map";
2850
+ const scopeLabel = state.brainAll
2851
+ ? "All memory"
2852
+ : state.status?.repo?.repo_name || "Memory";
2853
+
2854
+ const vizBlock =
2855
+ mode === "orbit"
2856
+ ? `<div class="orbit-host viz-host" id="orbitHost">
2857
+ <div class="viz-banner">
2858
+ <span>${esc(scopeLabel)} · ${state.brainAll ? "click a memory, then a file" : "click a file, then a fact"}</span>
2859
+ </div>
2860
+ <canvas id="orbitCanvas" aria-label="Orbit memory map"></canvas>
2861
+ <div class="viz-legend">
2862
+ <span><i class="sw teal"></i> Used recently</span>
2863
+ <span><i class="sw gold"></i> Has pinned</span>
2864
+ <span><i class="sw blue"></i> ${state.brainAll ? "Other memories" : "Other files"}</span>
2865
+ </div>
2866
+ </div>`
2867
+ : mode === "blocks"
2868
+ ? `<div class="coverage-host viz-host" id="coverageHost"></div>`
2869
+ : "";
2870
+
2871
+ // Files is the plain list; Blocks and Orbit put their visual on top of it.
2872
+ el.innerHTML =
2873
+ mode === "map"
2874
+ ? `<div class="brain-facts">
2875
+ <div class="pin-howto">
2876
+ <strong>Pin a fact</strong>
2877
+ <span>Click <em>☆ Pin</em> on any row below — pinned facts rank higher in retrieval and fill the Pinned tab.</span>
2878
+ </div>
2879
+ <h2>${selectedFile ? esc(fileLabel(selectedFile)) : "Facts"} <span>${esc(filterHint)}</span></h2>
2880
+ ${list}
2881
+ </div>`
2882
+ : `${vizBlock}
2883
+ <div class="brain-facts">
2884
+ <h2>${selectedFile ? esc(fileLabel(selectedFile)) : "Facts"} <span>${esc(filterHint)}</span></h2>
2885
+ ${list}
2886
+ </div>`;
2887
+
2888
+ stopAllViz();
2889
+ if (mode === "orbit") {
2890
+ const canvas = $("#orbitCanvas");
2891
+ if (canvas) {
2892
+ try {
2893
+ orbitViz = createOrbitViz(canvas, {
2894
+ onSelectFile: (file) => {
2895
+ state.selectedFile = file;
2896
+ const group = coverageGroups().find((g) => g.file === file);
2897
+ state.selectedNode = group
2898
+ ? { type: "file", id: file, detail: group }
2899
+ : { type: "file", id: file, detail: { file, items: [] } };
2900
+ showBrainDetail(state.selectedNode);
2901
+ // Across memories the ring is focused on a memory, so keep it there
2902
+ // when the click came from a file on the outer ring.
2903
+ if (!state.brainAll) orbitViz?.setFocus(file);
2904
+ },
2905
+ onSelectClaim: (claim) => {
2906
+ if (!claim) return;
2907
+ const anchors = claimAnchors(claim);
2908
+ if (anchors[0]) state.selectedFile = anchors[0];
2909
+ state.selectedNode = { type: "claim", id: claim.id, detail: claim };
2910
+ showBrainDetail(state.selectedNode);
2911
+ orbitViz?.setSelectedClaim(claim.id);
2912
+ el.querySelectorAll(".claim-row").forEach((row) => {
2913
+ row.classList.toggle("selected", row.dataset.claim === claim.id);
2914
+ });
2915
+ },
2916
+ onSelectGroup: state.brainAll
2917
+ ? (group) => {
2918
+ state.selectedFile = null;
2919
+ state.selectedNode = { type: "memory", id: group.id, detail: group };
2920
+ showBrainDetail(state.selectedNode);
2921
+ orbitViz?.setFocus(group.id);
2922
+ }
2923
+ : null,
2924
+ });
2925
+ orbitViz.setData({
2926
+ claims: orbitClaims(),
2927
+ recentClaimIds: state.graph?.recentClaimIds || [],
2928
+ selectedFile: state.brainAll ? orbitFocusMemory() : state.selectedFile,
2929
+ selectedClaim: selectedId,
2930
+ label: scopeLabel,
2931
+ ringLabel: state.brainAll ? "memories" : "files",
2932
+ });
2933
+ orbitViz.start();
2934
+ } catch (err) {
2935
+ const host = $("#orbitHost");
2936
+ if (host) host.innerHTML = `<div class="brain-empty">Orbit view failed: ${esc(err.message || err)}</div>`;
2937
+ }
2938
+ }
2939
+ } else if (mode === "blocks") {
2940
+ renderCoverageMap();
2941
+ }
2942
+ bindClaimClicks(el);
2943
+ bindPinButtons(el);
2944
+ bindVizToggle(el);
2945
+ }
2946
+
2947
+ function bindClaimClicks(root) {
2948
+ root.querySelectorAll("[data-claim]").forEach((btn) => {
2949
+ btn.addEventListener("click", () => {
2950
+ const claim = (state.graph.claims || []).find((c) => c.id === btn.dataset.claim);
2951
+ if (!claim) return;
2952
+ const anchors = claimAnchors(claim);
2953
+ if (anchors[0]) state.selectedFile = anchors[0];
2954
+ state.selectedNode = { type: "claim", id: claim.id, detail: claim };
2955
+ renderBrain();
2956
+ });
2957
+ });
2958
+ }
2959
+
2960
+ async function toggleClaimPin(id, repoId) {
2961
+ const claim = (state.graph.claims || []).find((c) => c.id === id);
2962
+ if (!claim) return;
2963
+ const pinned = !(Number(claim.pinned || 0) > 0);
2964
+ await api("/api/claims/pin", {
2965
+ method: "POST",
2966
+ repoId: claim.repo_id || repoId,
2967
+ body: JSON.stringify({ id, pinned }),
2968
+ });
2969
+ await refreshGraph();
2970
+ if (state.selectedNode?.type === "claim" && state.selectedNode.id === id) {
2971
+ const updated = (state.graph.claims || []).find((c) => c.id === id);
2972
+ if (updated) state.selectedNode = { type: "claim", id, detail: updated };
2973
+ }
2974
+ paintBrain();
2975
+ }
2976
+
2977
+ function bindPinButtons(root = document) {
2978
+ root.querySelectorAll("[data-pin]").forEach((btn) => {
2979
+ btn.addEventListener("click", async (e) => {
2980
+ e.stopPropagation();
2981
+ try {
2982
+ await toggleClaimPin(btn.dataset.pin, btn.dataset.repo);
2983
+ } catch (err) {
2984
+ alert(err.message);
2985
+ }
2986
+ });
2987
+ });
2988
+ }
2989
+
2990
+ function pinButtonHtml(c) {
2991
+ const isPinned = Number(c.pinned || 0) > 0;
2992
+ return `<button type="button" class="claim-pin ${isPinned ? "on" : ""}" data-pin="${esc(c.id)}" data-repo="${esc(c.repo_id || "")}" title="${isPinned ? "Unpin — stop boosting this fact" : "Pin — boost this fact in retrieval"}" aria-label="${isPinned ? "Unpin fact" : "Pin fact"}">${isPinned ? "★ Pinned" : "☆ Pin"}</button>`;
2993
+ }
2994
+
2995
+ function showBrainOverview() {
2996
+ const drawer = $("#drawer");
2997
+ if (!drawer) return;
2998
+ const g = state.graph || {};
2999
+ const durable = (g.claims || []).filter((c) => !isSessionClaim(c)).length;
3000
+ const chats = (g.claims || []).filter(isSessionClaim).length;
3001
+ const title = state.brainAll
3002
+ ? "All memory"
3003
+ : state.status?.repo?.repo_name || "Memory";
3004
+ drawer.innerHTML = `
3005
+ <h2>${esc(title)}</h2>
3006
+ <div class="meta">Local memory map</div>
3007
+ <p><b>To pin:</b> stay on <b>Files</b>, find a fact in the list (top of the main panel), click <b>☆ Pin</b> on the right of that row.</p>
3008
+ <p>The map below shows files amem can inject. Bigger tiles have more facts. Teal = used in a recent query.</p>
3009
+ <div class="meta">${durable} durable facts · ${chats} chat takeaways · ${g.flows?.length ?? 0} flows · ${g.components?.length ?? 0} components</div>`;
3010
+ }
3011
+
3012
+ function showPinRulesCta(claim) {
3013
+ const drawer = $("#drawer");
3014
+ if (!drawer || drawer.querySelector("#pinRulesCta")) return;
3015
+ const preview = String(claim?.text || "").slice(0, 160);
3016
+ const box = document.createElement("div");
3017
+ box.id = "pinRulesCta";
3018
+ box.className = "pin-rules-cta";
3019
+ box.innerHTML = `
3020
+ <strong>Sync pinned → Cursor rules (Pro)</strong>
3021
+ <p class="note" style="margin:0.35rem 0">Would write a line like: <code>${esc(preview)}${preview.length >= 160 ? "…" : ""}</code> into <code>.cursor/rules/amem-pinned.mdc</code></p>
3022
+ <button class="btn small" type="button" id="pinRulesSyncNow">Sync pinned rules</button>`;
3023
+ drawer.appendChild(box);
3024
+ $("#pinRulesSyncNow")?.addEventListener("click", async () => {
3025
+ try {
3026
+ const result = await api("/api/rules/sync", { method: "POST", body: "{}" });
3027
+ markProOnboardDone(["rules"]);
3028
+ box.innerHTML = `<p class="note">Wrote ${esc(result.path || "rules")} (${result.pinned || 0} pinned).</p>`;
3029
+ } catch (e) {
3030
+ box.innerHTML = `<p class="note">${esc(e?.message || String(e))}</p>`;
3031
+ }
3032
+ });
3033
+ }
3034
+
3035
+ function showBrainDetail(node) {
3036
+ const drawer = $("#drawer");
3037
+ if (!drawer) return;
3038
+ const d = node.detail || {};
3039
+ if (node.type === "event") {
3040
+ const kind = eventKindOf(d);
3041
+ const ids = eventClaimIds(d);
3042
+ const claims = ids
3043
+ .map((id) => (state.graph.claims || []).find((c) => c.id === id))
3044
+ .filter(Boolean);
3045
+ drawer.innerHTML = `
3046
+ <h2>${kind === "local_hit" ? "Local hit" : "Had to explore"}</h2>
3047
+ <div class="meta">${esc(d.platform || "agent")} · ${esc(formatWhen(d.created_at))}</div>
3048
+ <p>${esc(eventQueryLabel(d))}</p>
3049
+ <div class="meta">${kind === "local_hit" ? `Injected ${claims.length} fact(s) · ~${formatTokens(d.estimated_tokens_saved)} tokens estimated avoided` : "amem returned nothing useful, so Cursor likely grepped or read files."}</div>
3050
+ ${
3051
+ claims.length
3052
+ ? `<ul class="detail-list detail-list-pin">${claims
3053
+ .map(
3054
+ (c) =>
3055
+ `<li><div class="detail-pin-row"><strong>${esc(claimPreview(c, 90))}</strong>${pinButtonHtml(c)}</div><div class="meta">${claimAnchors(c).map((a) => `<code>${esc(a)}</code>`).join(" ") || ""}</div></li>`,
3056
+ )
3057
+ .join("")}</ul>`
3058
+ : ""
3059
+ }`;
3060
+ bindPinButtons(drawer);
3061
+ return;
3062
+ }
3063
+ if (node.type === "memory") {
3064
+ const items = node.detail?.items || [];
3065
+ const byFile = new Map();
3066
+ for (const c of items) {
3067
+ const file = claimAnchors(c)[0] || "(no file yet)";
3068
+ const key = fileGroupKey(c, file);
3069
+ const entry = byFile.get(key) || { label: fileLabel(file), path: file, n: 0 };
3070
+ entry.n += 1;
3071
+ byFile.set(key, entry);
3072
+ }
3073
+ const files = [...byFile.entries()].sort((a, b) => b[1].n - a[1].n).slice(0, 12);
3074
+ const pinned = items.filter((c) => Number(c.pinned || 0) > 0).length;
3075
+ drawer.innerHTML = `
3076
+ <h2>${esc(orbitMemoryLabel(node.id))}</h2>
3077
+ <div class="meta">${items.length} fact${items.length === 1 ? "" : "s"} · ${pinned} pinned</div>
3078
+ <p>Click a dot on the outer ring to open one of these files. Switch the sidebar to this memory to work on it alone.</p>
3079
+ ${
3080
+ files.length
3081
+ ? `<ul class="detail-list">${files
3082
+ .map(
3083
+ ([key, f]) =>
3084
+ `<li><button type="button" class="link-row" data-orbit-file="${esc(key)}"><strong>${esc(f.label)}</strong></button><div class="meta">${f.n} fact${f.n === 1 ? "" : "s"} · <code>${esc(f.path)}</code></div></li>`,
3085
+ )
3086
+ .join("")}</ul>`
3087
+ : `<p class="note">No anchored files in this memory yet.</p>`
3088
+ }`;
3089
+ drawer.querySelectorAll("[data-orbit-file]").forEach((btn) => {
3090
+ btn.addEventListener("click", () => {
3091
+ const file = btn.dataset.orbitFile;
3092
+ state.selectedFile = file;
3093
+ const group = coverageGroups().find((g) => g.file === file);
3094
+ state.selectedNode = { type: "file", id: file, detail: group || { file, items: [] } };
3095
+ showBrainDetail(state.selectedNode);
3096
+ });
3097
+ });
3098
+ return;
3099
+ }
3100
+ if (node.type === "file") {
3101
+ const group = node.detail || coverageGroups().find((g) => g.file === node.id);
3102
+ const items = group?.items || [];
3103
+ drawer.innerHTML = `
3104
+ <h2>${esc(fileLabel(node.id))}</h2>
3105
+ <div class="meta"><code>${esc(node.id)}</code> · ${items.length} fact${items.length === 1 ? "" : "s"}</div>
3106
+ <p>Pin any fact here to boost it in retrieval. Teal on the map means a recent query already used one of them.</p>
3107
+ ${
3108
+ items.length
3109
+ ? `<ul class="detail-list detail-list-pin">${items
3110
+ .map(
3111
+ (c) =>
3112
+ `<li><div class="detail-pin-row"><strong>${esc(claimPreview(c, 90))}</strong>${pinButtonHtml(c)}</div><div class="meta">${esc(c.kind || "fact")}</div></li>`,
3113
+ )
3114
+ .join("")}</ul>`
3115
+ : ""
3116
+ }`;
3117
+ bindPinButtons(drawer);
3118
+ return;
3119
+ }
3120
+ if (node.type === "draft") {
3121
+ let proposal = {};
3122
+ try {
3123
+ proposal = JSON.parse(d.proposal_json || "{}");
3124
+ } catch {
3125
+ proposal = {};
3126
+ }
3127
+ const claims = Array.isArray(proposal.claims) ? proposal.claims : [];
3128
+ const conflicts = Array.isArray(d.conflicts) ? d.conflicts : [];
3129
+ const quality = d.quality;
3130
+ drawer.innerHTML = `
3131
+ <h2>Pending draft</h2>
3132
+ <div class="meta">${esc(memoryLabel(d.repo_id))} · ${esc(d.platform || "agent")} · ${esc(formatWhen(d.created_at))} · ${esc(d.source || "session-end")}${quality ? ` · ${esc(quality.label)} ${quality.score}` : ""}</div>
3133
+ <p>${esc(d.title || d.id)}</p>
3134
+ ${quality?.reasons?.length ? `<p class="note">${quality.reasons.map((r) => esc(r)).join(" · ")}</p>` : ""}
3135
+ ${
3136
+ claims.length
3137
+ ? `<ul class="detail-list">${claims
3138
+ .map(
3139
+ (c) =>
3140
+ `<li><strong>${esc((c.text || "").slice(0, 160))}</strong><div class="meta">${esc(c.kind || "fact")} · ${(c.code_anchors || []).map((a) => `<code>${esc(a)}</code>`).join(" ")}</div></li>`,
3141
+ )
3142
+ .join("")}</ul>`
3143
+ : `<p class="note">Empty proposal</p>`
3144
+ }
3145
+ ${
3146
+ conflicts.length
3147
+ ? `<div class="conflict-box"><strong>May replace older facts</strong>${conflicts
3148
+ .map(
3149
+ (c) =>
3150
+ `<p class="note"><code>${esc(c.otherId)}</code> (${Math.round((c.similarity || 0) * 100)}% similar)${c.otherText ? ` — ${esc(c.otherText.slice(0, 120))}` : ""}</p>`,
3151
+ )
3152
+ .join("")}</div>`
3153
+ : ""
3154
+ }
3155
+ <div class="drawer-actions">
3156
+ ${conflicts.length ? `<button class="btn" type="button" id="draftSupersede">Apply and replace older</button>` : ""}
3157
+ <button class="btn ${conflicts.length ? "secondary" : ""}" type="button" id="draftApply">Apply ${conflicts.length ? "anyway" : "to memory"}</button>
3158
+ <button class="btn secondary" type="button" id="draftDismiss">Dismiss</button>
3159
+ </div>`;
3160
+ const applyDraft = async (resolve) => {
3161
+ try {
3162
+ await api("/api/drafts/apply", {
3163
+ method: "POST",
3164
+ repoId: d.repo_id,
3165
+ body: JSON.stringify({ id: d.id, resolve, scope: state.brainAll ? "all" : undefined }),
3166
+ });
3167
+ state.selectedNode = null;
3168
+ await refreshGraph();
3169
+ renderBrain();
3170
+ } catch (err) {
3171
+ alert(err.message);
3172
+ }
3173
+ };
3174
+ $("#draftApply")?.addEventListener("click", () => applyDraft(conflicts.length ? "keep" : undefined));
3175
+ $("#draftSupersede")?.addEventListener("click", () => applyDraft("supersede"));
3176
+ $("#draftDismiss")?.addEventListener("click", async () => {
3177
+ try {
3178
+ await api("/api/drafts/dismiss", {
3179
+ method: "POST",
3180
+ repoId: d.repo_id,
3181
+ body: JSON.stringify({ id: d.id, scope: state.brainAll ? "all" : undefined }),
3182
+ });
3183
+ state.selectedNode = null;
3184
+ await refreshGraph();
3185
+ renderBrain();
3186
+ } catch (err) {
3187
+ alert(err.message);
3188
+ }
3189
+ });
3190
+ return;
3191
+ }
3192
+ if (node.type === "claim") {
3193
+ const anchors = claimAnchors(d);
3194
+ const rel = relatedForClaim(d.id);
3195
+ const pinned = Number(d.pinned || 0) > 0;
3196
+ drawer.innerHTML = `
3197
+ <h2>${esc(d.kind || "fact")}${pinned ? " · pinned" : ""}</h2>
3198
+ <div class="meta">${esc(memoryLabel(d.repo_id))} · ${esc(d.id)}</div>
3199
+ <label class="drawer-label">Text</label>
3200
+ <textarea id="claimText" rows="6">${esc(d.text || "")}</textarea>
3201
+ <label class="drawer-label">Kind</label>
3202
+ <input id="claimKind" type="text" value="${esc(d.kind || "")}" />
3203
+ <label class="drawer-label">Anchors (comma-separated)</label>
3204
+ <input id="claimAnchors" type="text" value="${esc(anchors.join(", "))}" />
3205
+ <div class="drawer-actions">
3206
+ <button class="btn" type="button" id="claimSave">Save</button>
3207
+ <button class="btn secondary" type="button" id="claimPin">${pinned ? "Unpin ★" : "Pin ★"}</button>
3208
+ <button class="btn secondary" type="button" id="claimClose">Close</button>
3209
+ <button class="btn secondary danger" type="button" id="claimDelete">Delete</button>
3210
+ </div>
3211
+ ${rel.flows.length ? `<div class="meta">Flows: ${rel.flows.map((f) => esc(f.name)).join(", ")}</div>` : ""}
3212
+ ${rel.components.length ? `<div class="meta">Components: ${rel.components.map((c) => esc(c.name)).join(", ")}</div>` : ""}
3213
+ <p class="note" style="margin:0"><b>Pin</b> boosts this fact in <code>amem context</code>. Use the Pin button on each row, or here. Pinned facts appear under the Pinned filter and can sync to Cursor rules (Pro).</p>`;
3214
+ $("#claimSave")?.addEventListener("click", async () => {
3215
+ try {
3216
+ const text = $("#claimText")?.value ?? "";
3217
+ const kind = $("#claimKind")?.value ?? "";
3218
+ const anchorsRaw = $("#claimAnchors")?.value ?? "";
3219
+ const code_anchors = anchorsRaw
3220
+ .split(",")
3221
+ .map((s) => s.trim())
3222
+ .filter(Boolean);
3223
+ const result = await api("/api/claims", {
3224
+ method: "PATCH",
3225
+ repoId: d.repo_id,
3226
+ body: JSON.stringify({ id: d.id, text, kind, code_anchors }),
3227
+ });
3228
+ state.selectedNode = { type: "claim", id: d.id, detail: result.claim };
3229
+ await refreshGraph();
3230
+ paintBrain();
3231
+ } catch (err) {
3232
+ alert(err.message);
3233
+ }
3234
+ });
3235
+ $("#claimPin")?.addEventListener("click", async () => {
3236
+ try {
3237
+ const nextPinned = !pinned;
3238
+ const result = await api("/api/claims/pin", {
3239
+ method: "POST",
3240
+ repoId: d.repo_id,
3241
+ body: JSON.stringify({ id: d.id, pinned: nextPinned }),
3242
+ });
3243
+ state.selectedNode = { type: "claim", id: d.id, detail: result.claim };
3244
+ await refreshGraph();
3245
+ paintBrain();
3246
+ if (nextPinned && state.license?.features?.includes("rules_sync") && !state.brainAll) {
3247
+ showPinRulesCta(result.claim);
3248
+ }
3249
+ } catch (err) {
3250
+ alert(err.message);
3251
+ }
3252
+ });
3253
+ $("#claimClose")?.addEventListener("click", () => {
3254
+ state.selectedNode = null;
3255
+ renderBrain();
3256
+ });
3257
+ $("#claimDelete")?.addEventListener("click", async () => {
3258
+ if (!confirm(`Delete ${d.id} from local memory?`)) return;
3259
+ try {
3260
+ await api(`/api/claims?id=${encodeURIComponent(d.id)}`, { method: "DELETE", repoId: d.repo_id });
3261
+ state.selectedNode = null;
3262
+ await refreshGraph();
3263
+ renderBrain();
3264
+ } catch (err) {
3265
+ alert(err.message);
3266
+ }
3267
+ });
3268
+ return;
3269
+ }
3270
+ showBrainOverview();
3271
+ }
3272
+
3273
+ function formatTokens(n) {
3274
+ return Number(n || 0).toLocaleString();
3275
+ }
3276
+
3277
+ function formatDuration(ms) {
3278
+ const n = Number(ms);
3279
+ if (!Number.isFinite(n) || n < 0) return "—";
3280
+ if (n < 1000) return `${Math.round(n)}ms`;
3281
+ if (n < 60000) return `${(n / 1000).toFixed(1)}s`;
3282
+ if (n < 3600000) return `${(n / 60000).toFixed(1)}m`;
3283
+ return `${(n / 3600000).toFixed(1)}h`;
3284
+ }
3285
+
3286
+ function formatPct(n) {
3287
+ if (!Number.isFinite(Number(n))) return "—";
3288
+ return `${Math.round(Number(n) * 100)}%`;
3289
+ }
3290
+
3291
+ function formatUsd(n) {
3292
+ const v = Number(n);
3293
+ if (!Number.isFinite(v) || v <= 0) return "$0";
3294
+ if (v < 0.01) return `~$${v.toFixed(3)}`;
3295
+ return `~$${v.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
3296
+ }
3297
+
3298
+ function formatChartDay(iso) {
3299
+ const d = new Date(`${iso}T00:00:00`);
3300
+ if (Number.isNaN(d.getTime())) return iso;
3301
+ return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
3302
+ }
3303
+
3304
+ function renderStats() {
3305
+ return renderAnalytics();
3306
+ }
3307
+
3308
+ function renderDashboard() {
3309
+ const main = $("#main");
3310
+ setMainMode("page");
3311
+ stopAllViz();
3312
+ const g = state.graph || emptyGraph(state.brainAll ? "all" : "current");
3313
+ const agg = state.usage?.aggregate;
3314
+ const totals = agg?.totals || {};
3315
+ const drafts = (g.drafts || []).filter((d) => d.status === "pending");
3316
+ const pendingTotal = Number(g.pendingDraftTotal ?? drafts.length);
3317
+ const claims = g.claims || [];
3318
+ const pinned = claims.filter((c) => Number(c.pinned || 0) > 0).length;
3319
+ const events = g.recentEvents || [];
3320
+ const hits = events.filter((e) => eventKindOf(e) === "local_hit");
3321
+ const misses = events.filter((e) => eventKindOf(e) !== "local_hit");
3322
+ const focus = statsFocusLabel();
3323
+ const tier = state.license?.tier || "free";
3324
+ const highDrafts = drafts.filter((d) => d.quality?.label === "high").length;
3325
+ const junkDrafts = drafts.filter((d) => d.quality?.reject || d.quality?.label === "low").length;
3326
+ const topFiles = fileGroups(claims)
3327
+ .slice()
3328
+ .sort((a, b) => b.items.length - a.items.length)
3329
+ .slice(0, 6);
3330
+
3331
+ renderPageInsight();
3332
+ main.innerHTML = `
3333
+ <section class="dash-page">
3334
+ <header class="dash-head">
3335
+ <div>
3336
+ <h1>Dashboard</h1>
3337
+ <p class="sub">What matters right now for <strong>${esc(focus)}</strong>. Memory stays on this machine.</p>
3338
+ </div>
3339
+ <div class="dash-actions">
3340
+ <button class="btn secondary small" type="button" data-go="brain">Open Memory</button>
3341
+ <button class="btn secondary small" type="button" data-go="analytics">Open Analytics</button>
3342
+ </div>
3343
+ </header>
3344
+
3345
+ <div class="dash-kpis">
3346
+ <button type="button" class="dash-kpi" data-go="brain" data-filter="files">
3347
+ <b>${claims.length}</b><span>Facts stored</span>
3348
+ </button>
3349
+ <button type="button" class="dash-kpi ${pendingTotal ? "warn" : ""}" data-go="brain" data-filter="drafts">
3350
+ <b>${pendingTotal}</b><span>Waiting to approve</span>
3351
+ </button>
3352
+ <button type="button" class="dash-kpi" data-go="brain" data-filter="pinned">
3353
+ <b>${pinned}</b><span>Pinned</span>
3354
+ </button>
3355
+ <button type="button" class="dash-kpi" data-go="analytics">
3356
+ <b>${formatPct(totals.hitRate)}</b><span>Hit rate · ${totals.localHits ?? 0} hits</span>
3357
+ </button>
3358
+ <div class="dash-kpi static">
3359
+ <b>${esc(String(tier))}</b><span>License</span>
3360
+ </div>
3361
+ <div class="dash-kpi static">
3362
+ <b>${formatUsd(totals.estimatedUsdSaved)}</b><span>Est. $ saved (proxy)</span>
3363
+ </div>
3364
+ </div>
3365
+
3366
+ <div class="dash-grid">
3367
+ <article class="dash-panel">
3368
+ <h2>Needs attention</h2>
3369
+ ${
3370
+ pendingTotal
3371
+ ? `<p>${pendingTotal} session capture${pendingTotal === 1 ? "" : "s"} waiting · ${highDrafts} high · ${junkDrafts} junk.</p>
3372
+ <div class="drawer-actions">
3373
+ <button class="btn small" type="button" data-go="brain" data-filter="drafts">Review Approve queue</button>
3374
+ </div>`
3375
+ : `<p class="note">No pending captures. New session-end drafts will show up here.</p>`
3376
+ }
3377
+ ${
3378
+ misses.length
3379
+ ? `<h3>Recent misses</h3><ul class="dash-list">${misses
3380
+ .slice(0, 5)
3381
+ .map(
3382
+ (e) =>
3383
+ `<li><strong>${esc(eventQueryLabel(e))}</strong><span class="meta">${esc(e.platform || "agent")} · ${esc(formatWhen(e.created_at))}</span></li>`,
3384
+ )
3385
+ .join("")}</ul>`
3386
+ : `<p class="note">No recent misses in the feed.</p>`
3387
+ }
3388
+ </article>
3389
+ <article class="dash-panel">
3390
+ <h2>Working well</h2>
3391
+ <p>${hits.length} local hit${hits.length === 1 ? "" : "s"} in recent uses · ~${formatTokens(totals.estimatedTokensSaved ?? 0)} tokens estimated avoided (${state.usage?.days ?? 30}d).</p>
3392
+ ${
3393
+ hits.length
3394
+ ? `<ul class="dash-list">${hits
3395
+ .slice(0, 5)
3396
+ .map(
3397
+ (e) =>
3398
+ `<li><strong>${esc(eventQueryLabel(e))}</strong><span class="meta">${eventClaimIds(e).length} facts · ${esc(formatWhen(e.created_at))}</span></li>`,
3399
+ )
3400
+ .join("")}</ul>`
3401
+ : `<p class="note">Ask Cursor something in a tracked repo — hits appear after <code>amem context</code> runs.</p>`
3402
+ }
3403
+ </article>
3404
+ <article class="dash-panel">
3405
+ <h2>Densest files</h2>
3406
+ ${
3407
+ topFiles.length
3408
+ ? `<ul class="dash-list">${topFiles
3409
+ .map(
3410
+ (f) =>
3411
+ `<li><button type="button" class="linkish" data-file="${esc(f.file)}"><code>${esc(fileLabel(f.file))}</code></button><span class="meta">${f.items.length} fact${f.items.length === 1 ? "" : "s"}</span></li>`,
3412
+ )
3413
+ .join("")}</ul>`
3414
+ : `<p class="note">No facts yet.</p>`
3415
+ }
3416
+ <div class="drawer-actions">
3417
+ <button class="btn secondary small" type="button" data-go="brain" data-viz="orbit">View as Orbit</button>
3418
+ <button class="btn secondary small" type="button" data-go="brain" data-filter="pinned">Pinned facts</button>
3419
+ </div>
3420
+ </article>
3421
+ </div>
3422
+ </section>`;
3423
+
3424
+ main.querySelectorAll("[data-go]").forEach((btn) => {
3425
+ btn.addEventListener("click", () => {
3426
+ const tab = btn.dataset.go;
3427
+ if (btn.dataset.filter) state.brainFilter = btn.dataset.filter;
3428
+ if (btn.dataset.viz) {
3429
+ setMemoryViz(btn.dataset.viz);
3430
+ }
3431
+ setTab(tab);
3432
+ });
3433
+ });
3434
+ main.querySelectorAll("[data-file]").forEach((btn) => {
3435
+ btn.addEventListener("click", () => {
3436
+ state.selectedFile = btn.dataset.file;
3437
+ state.brainFilter = "files";
3438
+ setTab("brain");
3439
+ });
3440
+ });
3441
+ }
3442
+
3443
+ function renderAnalytics() {
3444
+ const main = $("#main");
3445
+ setMainMode("page");
3446
+ stopAllViz();
3447
+ renderPageInsight();
3448
+ if (!state.brainAll && !state.status?.repo) {
3449
+ main.innerHTML = `<section class="hero"><div class="hero-inner"><h1>Analytics</h1><p>Pick <strong>All memory</strong> in the header, or a tracked repo, to see usage analytics.</p></div></section>`;
3450
+ return;
3451
+ }
3452
+ const agg = state.usage?.aggregate;
3453
+ const totals = agg?.totals || {};
3454
+ const currentDays = String(state.usage?.days ?? state.statsDays ?? 30);
3455
+ const focus = statsFocusLabel();
3456
+ const byDay = agg?.byDay || [];
3457
+ const g = state.graph || {};
3458
+ const claimUse = new Map();
3459
+ for (const e of state.usage?.events || g.recentEvents || []) {
3460
+ for (const id of eventClaimIds(e)) {
3461
+ claimUse.set(id, (claimUse.get(id) || 0) + 1);
3462
+ }
3463
+ }
3464
+ const topClaims = [...claimUse.entries()]
3465
+ .sort((a, b) => b[1] - a[1])
3466
+ .slice(0, 8)
3467
+ .map(([id, n]) => {
3468
+ const c = (g.claims || []).find((x) => x.id === id);
3469
+ return { id, n, text: c ? claimPreview(c, 100) : id };
3470
+ });
3471
+
3472
+ main.innerHTML = `
3473
+ <section class="stats-page analytics-page">
3474
+ <h1>Analytics</h1>
3475
+ <p class="sub">Deeper usage for <strong>${esc(focus)}</strong>. Hit rate is local memory success — not Cursor→model API trips. Time and $ are proxies.</p>
3476
+ <div class="filters">
3477
+ <select id="days" aria-label="Analytics time range">
3478
+ <option value="7" ${currentDays === "7" ? "selected" : ""}>7 days</option>
3479
+ <option value="30" ${currentDays === "30" ? "selected" : ""}>30 days</option>
3480
+ <option value="90" ${currentDays === "90" ? "selected" : ""}>90 days</option>
3481
+ </select>
3482
+ <button class="btn secondary small" type="button" data-export="json">Export JSON</button>
3483
+ <button class="btn secondary small" type="button" data-export="md">Export markdown</button>
3484
+ <button class="btn secondary small" type="button" data-export="pdf">Export PDF</button>
3485
+ </div>
3486
+
3487
+ <div class="analytics-split">
3488
+ <div class="chart-wrap">
3489
+ <h2>Hits vs misses by day</h2>
3490
+ <div class="chart-stage">
3491
+ <canvas id="hitMissChart"></canvas>
3492
+ </div>
3493
+ </div>
3494
+ <div class="chart-wrap">
3495
+ <h2>Estimated tokens saved by day</h2>
3496
+ <div class="chart-stage">
3497
+ <canvas id="savingsChart"></canvas>
3498
+ <div class="chart-tooltip hidden" id="chartTooltip"></div>
3499
+ </div>
3500
+ </div>
3501
+ </div>
3502
+
3503
+ <div class="platform-cards" id="speedCards"></div>
3504
+ <h2 class="stats-heading">Monthly projection</h2>
3505
+ <div class="platform-cards" id="monthlyCards"></div>
3506
+ <div class="platform-cards" id="cards"></div>
3507
+
3508
+ <div class="analytics-split">
3509
+ <article class="dash-panel">
3510
+ <h2>Top injected facts</h2>
3511
+ ${
3512
+ topClaims.length
3513
+ ? `<ul class="dash-list">${topClaims
3514
+ .map(
3515
+ (c) =>
3516
+ `<li><strong>${esc(c.text)}</strong><span class="meta">${c.n}× · <code>${esc(c.id)}</code></span></li>`,
3517
+ )
3518
+ .join("")}</ul>`
3519
+ : `<p class="note">No claim injections logged in this window.</p>`
3520
+ }
3521
+ </article>
3522
+ <article class="dash-panel">
3523
+ <h2>By platform</h2>
3524
+ <ul class="dash-list" id="platformList"></ul>
3525
+ </article>
3526
+ </div>
3527
+ <p class="note">Tokens: max(0, anchors×4000 + claims×200 − packet tokens). Time: anchors×1.2s + claims×80ms. Money: tokens × $${agg?.pricing?.usdPerMillionInputTokens ?? 3}/1M input tokens. “Server trips” = empty amem lookups, not model API calls.</p>
3528
+ </section>`;
3529
+
3530
+ const speedCards = $("#speedCards");
3531
+ speedCards.innerHTML = `
3532
+ <div class="platform-card"><div class="label">Estimated time saved</div><div class="value">~${formatDuration(totals.estimatedMsSaved)}</div><div class="meta">proxy vs tool round-trips · not model latency</div></div>
3533
+ <div class="platform-card"><div class="label">Estimated money saved</div><div class="value">${formatUsd(totals.estimatedUsdSaved)}</div><div class="meta">at $${agg?.pricing?.usdPerMillionInputTokens ?? 3}/1M input tokens · proxy, not a bill</div></div>
3534
+ <div class="platform-card"><div class="label">Local lookup</div><div class="value">${totals.avgLocalMs != null ? formatDuration(totals.avgLocalMs) : "—"}</div><div class="meta">measured SQLite / localhost avg</div></div>
3535
+ <div class="platform-card"><div class="label">Hit rate</div><div class="value">${formatPct(totals.hitRate)}</div><div class="meta">${totals.localHits ?? 0} keyword hits · ${totals.serverTrips ?? 0} misses · not model API calls</div></div>
3536
+ <div class="platform-card"><div class="label">Avoided file reads</div><div class="value">${formatTokens(totals.anchorsAvoided ?? 0)}</div><div class="meta">unique anchors returned in packets</div></div>`;
3537
+
3538
+ const monthly = agg?.monthly || {};
3539
+ const monthlyCards = $("#monthlyCards");
3540
+ const trend = monthly.trendDays || 0;
3541
+ monthlyCards.innerHTML = `
3542
+ <div class="platform-card accented"><div class="label">Est. tokens / month</div><div class="value">~${formatTokens(monthly.estimatedTokensSaved ?? 0)}</div><div class="meta">${trend ? `from ${monthly.sampleQueries} calls over ${trend} day${trend === 1 ? "" : "s"} × 30` : "no usage yet"} · proxy, not a bill</div></div>
3543
+ <div class="platform-card accented"><div class="label">Est. $ / month</div><div class="value">${formatUsd(monthly.estimatedUsdSaved)}</div><div class="meta">same token proxy at $${agg?.pricing?.usdPerMillionInputTokens ?? 3}/1M input</div></div>
3544
+ <div class="platform-card accented"><div class="label">Est. time / month</div><div class="value">~${formatDuration(monthly.estimatedMsSaved)}</div><div class="meta">avoided tool round-trips at current pace</div></div>
3545
+ <div class="platform-card accented"><div class="label">Est. calls / month</div><div class="value">${formatTokens(monthly.queries ?? 0)}</div><div class="meta">amem context hits if this rate holds</div></div>
3546
+ <div class="platform-card accented"><div class="label">Est. file reads / month</div><div class="value">${formatTokens(monthly.anchorsAvoided ?? 0)}</div><div class="meta">anchors that would be skipped</div></div>`;
3547
+
3548
+ const cards = $("#cards");
3549
+ const platforms = agg?.byPlatform?.length
3550
+ ? agg.byPlatform
3551
+ : [{ platform: "—", queries: 0, estimatedTokensSaved: 0, reportedTokensSaved: 0 }];
3552
+ cards.innerHTML = `
3553
+ <div class="platform-card"><div class="label">Total estimated</div><div class="value">~${formatTokens(agg?.totals?.estimatedTokensSaved ?? 0)}</div><div class="meta">${formatUsd(agg?.totals?.estimatedUsdSaved)} · ${agg?.totals?.queries ?? 0} context queries · proxy, not billed savings</div></div>
3554
+ ${platforms
3555
+ .map(
3556
+ (p) => `<div class="platform-card"><div class="label">${p.platform}</div><div class="value">~${formatTokens(p.estimatedTokensSaved)}</div><div class="meta">${p.queries} ${p.queries === 1 ? "query" : "queries"} · ~${formatDuration(p.estimatedMsSaved)}${p.avgLocalMs != null ? ` · ${formatDuration(p.avgLocalMs)} local` : ""}${p.reportedTokensSaved ? ` · reported ${formatTokens(p.reportedTokensSaved)}` : ""}</div></div>`,
3557
+ )
3558
+ .join("")}`;
3559
+
3560
+ const platformList = $("#platformList");
3561
+ if (platformList) {
3562
+ platformList.innerHTML = platforms
3563
+ .map(
3564
+ (p) =>
3565
+ `<li><strong>${esc(p.platform)}</strong><span class="meta">${p.queries} queries · hit-ish savings ~${formatTokens(p.estimatedTokensSaved)} · ${formatUsd(p.estimatedUsdSaved)}</span></li>`,
3566
+ )
3567
+ .join("");
3568
+ }
3569
+
3570
+ drawChart(byDay);
3571
+ drawHitMissChart(byDay);
3572
+
3573
+ $("#days").addEventListener("change", async (e) => {
3574
+ await refreshUsage(statsScope(), Number(e.target.value));
3575
+ renderAnalytics();
3576
+ });
3577
+ main.querySelectorAll("[data-export]").forEach((btn) => {
3578
+ btn.addEventListener("click", () => downloadSavings(btn.dataset.export));
3579
+ });
3580
+ }
3581
+
3582
+ function drawHitMissChart(days) {
3583
+ const canvas = $("#hitMissChart");
3584
+ if (!canvas) return;
3585
+ const ctx = canvas.getContext("2d");
3586
+ const dpr = devicePixelRatio || 1;
3587
+ const w = canvas.clientWidth;
3588
+ const h = canvas.clientHeight;
3589
+ canvas.width = w * dpr;
3590
+ canvas.height = h * dpr;
3591
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
3592
+ ctx.clearRect(0, 0, w, h);
3593
+ if (!days.length) {
3594
+ ctx.fillStyle = "#8fa3b0";
3595
+ ctx.fillText("No usage yet.", 16, 32);
3596
+ return;
3597
+ }
3598
+ const max = Math.max(...days.map((d) => (d.localHits || 0) + (d.serverTrips || 0)), 1);
3599
+ const pad = 36;
3600
+ const bw = (w - pad * 2) / days.length;
3601
+ days.forEach((d, i) => {
3602
+ const hits = d.localHits || 0;
3603
+ const misses = d.serverTrips || 0;
3604
+ const x = pad + i * bw + 4;
3605
+ const barW = Math.max(bw - 8, 2);
3606
+ const hitH = ((h - pad * 2) * hits) / max;
3607
+ const missH = ((h - pad * 2) * misses) / max;
3608
+ ctx.fillStyle = "#2ec4b6";
3609
+ ctx.fillRect(x, h - pad - hitH, barW, Math.max(hitH, hits ? 1 : 0));
3610
+ ctx.fillStyle = "rgba(232, 140, 90, 0.85)";
3611
+ ctx.fillRect(x, h - pad - hitH - missH, barW, Math.max(missH, misses ? 1 : 0));
3612
+ });
3613
+ ctx.fillStyle = "#8fa3b0";
3614
+ ctx.font = "12px 'IBM Plex Sans', system-ui, sans-serif";
3615
+ ctx.fillText("teal = hits · orange = misses", pad, 18);
3616
+ }
3617
+
3618
+ async function downloadSavings(format) {
3619
+ const scope = statsScope();
3620
+ const days = $("#days")?.value || "30";
3621
+ try {
3622
+ const data = await api(`/api/usage/export?format=${encodeURIComponent(format)}&scope=${encodeURIComponent(scope)}&days=${encodeURIComponent(days)}`);
3623
+ let blob;
3624
+ if (data.contentBase64) {
3625
+ const raw = atob(data.contentBase64);
3626
+ const bytes = new Uint8Array(raw.length);
3627
+ for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
3628
+ blob = new Blob([bytes], { type: data.mime || "application/pdf" });
3629
+ } else if (data.markdown) {
3630
+ blob = new Blob([data.markdown], { type: "text/markdown;charset=utf-8" });
3631
+ } else {
3632
+ blob = new Blob([JSON.stringify(data.report ?? data, null, 2)], {
3633
+ type: "application/json;charset=utf-8",
3634
+ });
3635
+ }
3636
+ const a = document.createElement("a");
3637
+ a.href = URL.createObjectURL(blob);
3638
+ a.download = data.filename || `amem-savings.${format}`;
3639
+ a.click();
3640
+ URL.revokeObjectURL(a.href);
3641
+ } catch (e) {
3642
+ alert(e.message);
3643
+ }
3644
+ }
3645
+
3646
+ function drawChart(days) {
3647
+ const canvas = $("#savingsChart");
3648
+ const tooltip = $("#chartTooltip");
3649
+ if (!canvas) return;
3650
+ const ctx = canvas.getContext("2d");
3651
+ const dpr = devicePixelRatio || 1;
3652
+ const w = canvas.clientWidth;
3653
+ const h = canvas.clientHeight;
3654
+ canvas.width = w * dpr;
3655
+ canvas.height = h * dpr;
3656
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
3657
+
3658
+ if (!days.length) {
3659
+ ctx.clearRect(0, 0, w, h);
3660
+ ctx.fillStyle = "#8fa3b0";
3661
+ ctx.fillText("No usage yet — run amem context from an agent.", 16, 32);
3662
+ canvas.onmousemove = null;
3663
+ canvas.onmouseleave = null;
3664
+ tooltip?.classList.add("hidden");
3665
+ return;
3666
+ }
3667
+
3668
+ const max = Math.max(...days.map((d) => d.estimatedTokensSaved), 1);
3669
+ const pad = 36;
3670
+ const bw = (w - pad * 2) / days.length;
3671
+ const bars = days.map((d, i) => {
3672
+ const barW = Math.max(bw - 8, 2);
3673
+ const bh = ((h - pad * 2) * d.estimatedTokensSaved) / max;
3674
+ return {
3675
+ ...d,
3676
+ x: pad + i * bw + 4,
3677
+ y: h - pad - bh,
3678
+ w: barW,
3679
+ h: bh,
3680
+ colX: pad + i * bw,
3681
+ colW: bw,
3682
+ };
3683
+ });
3684
+
3685
+ function paint(hoverIndex = -1) {
3686
+ ctx.clearRect(0, 0, w, h);
3687
+ ctx.strokeStyle = "rgba(232,238,242,0.12)";
3688
+ ctx.beginPath();
3689
+ ctx.moveTo(pad, pad);
3690
+ ctx.lineTo(pad, h - pad);
3691
+ ctx.lineTo(w - pad, h - pad);
3692
+ ctx.stroke();
3693
+ bars.forEach((b, i) => {
3694
+ ctx.fillStyle = i === hoverIndex ? "#5ee0d4" : "#2ec4b6";
3695
+ ctx.fillRect(b.x, b.y, b.w, Math.max(b.h, 1));
3696
+ if (i === hoverIndex) {
3697
+ ctx.fillStyle = "#e8eef2";
3698
+ ctx.font = "600 12px 'IBM Plex Sans', system-ui, sans-serif";
3699
+ ctx.textAlign = "center";
3700
+ const label = `~${formatTokens(b.estimatedTokensSaved)}`;
3701
+ ctx.fillText(label, b.x + b.w / 2, Math.max(14, b.y - 8));
3702
+ }
3703
+ });
3704
+ }
3705
+
3706
+ paint();
3707
+
3708
+ canvas.onmousemove = (e) => {
3709
+ const rect = canvas.getBoundingClientRect();
3710
+ const x = e.clientX - rect.left;
3711
+ const i = bars.findIndex((b) => x >= b.colX && x < b.colX + b.colW);
3712
+ if (i < 0) {
3713
+ tooltip?.classList.add("hidden");
3714
+ paint(-1);
3715
+ canvas.style.cursor = "default";
3716
+ return;
3717
+ }
3718
+ const b = bars[i];
3719
+ paint(i);
3720
+ canvas.style.cursor = "pointer";
3721
+ if (!tooltip) return;
3722
+ tooltip.classList.remove("hidden");
3723
+ tooltip.innerHTML = `<strong>${formatChartDay(b.day)}</strong><span>~${formatTokens(b.estimatedTokensSaved)} tokens</span><span>${formatUsd(b.estimatedUsdSaved)}</span><span>~${formatDuration(b.estimatedMsSaved)} estimated time</span><span>${b.queries} ${b.queries === 1 ? "query" : "queries"} · ${b.localHits ?? 0} hits · ${b.serverTrips ?? 0} misses</span>`;
3724
+ const tipW = tooltip.offsetWidth || 160;
3725
+ const left = Math.min(Math.max(8, x + 12), rect.width - tipW - 8);
3726
+ const top = Math.max(8, e.clientY - rect.top - (tooltip.offsetHeight || 56) - 10);
3727
+ tooltip.style.left = `${left}px`;
3728
+ tooltip.style.top = `${top}px`;
3729
+ };
3730
+ canvas.onmouseleave = () => {
3731
+ tooltip?.classList.add("hidden");
3732
+ paint(-1);
3733
+ canvas.style.cursor = "default";
3734
+ };
3735
+ }
3736
+
3737
+ let renderSeq = 0;
3738
+
3739
+ async function render() {
3740
+ const seq = ++renderSeq;
3741
+ const stale = () => seq !== renderSeq;
3742
+ fillRepoSelect();
3743
+ paintVault();
3744
+ try {
3745
+ await refreshStatus();
3746
+ } catch (e) {
3747
+ if (stale()) return;
3748
+ await refreshVault();
3749
+ if (/encrypted|unlock|Passphrase/i.test(String(e.message))) {
3750
+ setMainMode("page");
3751
+ $("#main").innerHTML = `<section class="hero"><div class="hero-inner"><h1>Locked</h1><p>Unlock from <strong>Lock / backup</strong> in the sidebar to open Memory, Dashboard, and Analytics. Memory never left this machine.</p></div></section>`;
3752
+ renderPageInsight();
3753
+ return;
3754
+ }
3755
+ throw e;
3756
+ }
3757
+ if (stale()) return;
3758
+ fillRepoSelect();
3759
+ if (state.tab === "welcome") {
3760
+ renderWelcome();
3761
+ } else if (state.tab === "setup") {
3762
+ try {
3763
+ await loadScan();
3764
+ } catch (e) {
3765
+ if (!stale()) alert(e.message);
3766
+ }
3767
+ if (stale()) return;
3768
+ fillRepoSelect();
3769
+ renderSetup();
3770
+ } else if (state.tab === "brain") {
3771
+ await refreshGraph();
3772
+ if (stale()) return;
3773
+ try {
3774
+ renderBrain();
3775
+ } catch (e) {
3776
+ setMainMode("page");
3777
+ $("#main").innerHTML = `<section class="hero"><div class="hero-inner"><h1>Memory</h1><p>${esc(e.message || e)}</p><button class="btn" id="retryBrain" type="button">Try again</button></div></section>`;
3778
+ $("#retryBrain")?.addEventListener("click", () => setTab("brain"));
3779
+ }
3780
+ } else if (state.tab === "dashboard") {
3781
+ stopAllViz();
3782
+ await Promise.all([
3783
+ refreshGraph().catch(() => {
3784
+ state.graph = emptyGraph(state.brainAll ? "all" : "current");
3785
+ }),
3786
+ refreshUsage(statsScope(), state.statsDays || 30).catch(() => {
3787
+ state.usage = null;
3788
+ }),
3789
+ ]);
3790
+ if (stale()) return;
3791
+ renderDashboard();
3792
+ } else {
3793
+ stopAllViz();
3794
+ if (state.tab === "stats") state.tab = "analytics";
3795
+ try {
3796
+ await refreshUsage(statsScope(), state.statsDays || 30);
3797
+ } catch {
3798
+ state.usage = null;
3799
+ }
3800
+ try {
3801
+ await refreshGraph();
3802
+ } catch {
3803
+ /* analytics can still show usage without graph */
3804
+ }
3805
+ if (stale()) return;
3806
+ renderAnalytics();
3807
+ }
3808
+ }
3809
+
3810
+ document.querySelectorAll("#tabs button[data-tab]").forEach((b) => {
3811
+ b.addEventListener("click", () => setTab(b.dataset.tab));
3812
+ });
3813
+ $("#brandBtn")?.addEventListener("click", () => setTab("welcome"));
3814
+
3815
+ $("#brainSearch")?.addEventListener("input", (e) => {
3816
+ if (state.tab !== "brain") return;
3817
+ state.brainSearch = e.target.value;
3818
+ paintBrain();
3819
+ });
3820
+ $("#tryRetrievalBtn")?.addEventListener("click", async () => {
3821
+ if (state.tab !== "brain") return;
3822
+ const q = String($("#brainSearch")?.value || state.brainSearch || "").trim();
3823
+ if (!q) {
3824
+ alert("Type a query in the search box first.");
3825
+ return;
3826
+ }
3827
+ state.brainSearch = q;
3828
+ state.showdownOpen = true;
3829
+ state.showdownQuery = q;
3830
+ renderBrain();
3831
+ const input = $("#showdownQuery");
3832
+ if (input) input.value = q;
3833
+ await runRetrievalShowdown();
3834
+ try {
3835
+ const packet = await api("/api/context", {
3836
+ method: "POST",
3837
+ body: JSON.stringify({ query: q }),
3838
+ });
3839
+ const n = Array.isArray(packet?.claims) ? packet.claims.length : 0;
3840
+ noteContextHit();
3841
+ const el = $("#showdownResult");
3842
+ if (el && n >= 0) {
3843
+ const note = document.createElement("p");
3844
+ note.className = "note";
3845
+ note.textContent = `Live amem_context would return ${n} claim${n === 1 ? "" : "s"} for this query (same path MCP uses).`;
3846
+ el.prepend(note);
3847
+ }
3848
+ } catch {
3849
+ /* showdown result is enough */
3850
+ }
3851
+ });
3852
+ $("#showdownToggle")?.addEventListener("click", () => {
3853
+ if (state.tab !== "brain") return;
3854
+ state.showdownOpen = !state.showdownOpen;
3855
+ if (!state.showdownOpen) clearRetrievalShowdown();
3856
+ renderBrain();
3857
+ });
3858
+ $("#autoApplyAll")?.addEventListener("change", async (e) => {
3859
+ if (state.tab !== "brain") return;
3860
+ const on = Boolean(e.target.checked);
3861
+ try {
3862
+ const result = await apiUnscoped("/api/prefs", {
3863
+ method: "POST",
3864
+ body: JSON.stringify({ autoApplyAll: on }),
3865
+ });
3866
+ state.prefs = { autoApplyAll: result.autoApplyAll };
3867
+ await refreshGraph();
3868
+ renderBrain();
3869
+ } catch (err) {
3870
+ e.target.checked = !on;
3871
+ alert(err.message);
3872
+ }
3873
+ });
3874
+ $("#rulesSyncBtn")?.addEventListener("click", async () => {
3875
+ if (state.tab !== "brain") return;
3876
+ try {
3877
+ const result = await api("/api/rules/sync", { method: "POST", body: "{}" });
3878
+ alert(`Wrote ${result.pinned} pinned facts to ${result.path}`);
3879
+ } catch (err) {
3880
+ alert(err.message);
3881
+ }
3882
+ });
3883
+
3884
+ $("#repoSelect")?.addEventListener("change", async (e) => {
3885
+ const value = e.target.value;
3886
+ if (value === "__add__") {
3887
+ fillRepoSelect();
3888
+ setAddPanel(true);
3889
+ return;
3890
+ }
3891
+ if (value === "__all__") {
3892
+ state.brainAll = true;
3893
+ persistBrainAll(true);
3894
+ writeUrlState();
3895
+ await render();
3896
+ return;
3897
+ }
3898
+ if (value.startsWith("repo:")) {
3899
+ state.brainAll = false;
3900
+ persistBrainAll(false);
3901
+ await focusRepo({ repoId: value.slice(5), tab: state.tab === "setup" ? "setup" : state.tab });
3902
+ return;
3903
+ }
3904
+ if (value.startsWith("path:")) {
3905
+ state.brainAll = false;
3906
+ persistBrainAll(false);
3907
+ await focusRepo({ path: value.slice(5), tab: "setup" });
3908
+ }
3909
+ });
3910
+
3911
+ $("#addRepoBtn")?.addEventListener("click", () => setAddPanel(true));
3912
+ $("#personalBtn")?.addEventListener("click", () => focusPersonal());
3913
+ $("#vaultToggle")?.addEventListener("click", () => {
3914
+ $("#vaultPanel")?.classList.toggle("hidden");
3915
+ });
3916
+ $("#vaultLockBtn")?.addEventListener("click", () => vaultAction("/api/vault/lock"));
3917
+ $("#vaultUnlockBtn")?.addEventListener("click", () => vaultAction("/api/vault/unlock"));
3918
+ $("#vaultBackupBtn")?.addEventListener("click", () => vaultAction("/api/vault/backup"));
3919
+ $("#vaultScheduleBtn")?.addEventListener("click", () => {
3920
+ const hour = Number($("#vaultHour")?.value);
3921
+ vaultAction("/api/vault/backup/schedule", { hour: Number.isFinite(hour) ? hour : 3 });
3922
+ });
3923
+ $("#vaultUnscheduleBtn")?.addEventListener("click", () => vaultAction("/api/vault/backup/unschedule"));
3924
+ $("#vaultRestoreBtn")?.addEventListener("click", () => {
3925
+ const file = $("#vaultRestorePath")?.value?.trim();
3926
+ if (!file) {
3927
+ alert("Path to a local backup .db or .db.enc is required");
3928
+ return;
3929
+ }
3930
+ vaultAction("/api/vault/restore", { file });
3931
+ });
3932
+ $("#renameWsBtn")?.addEventListener("click", () => {
3933
+ renameWorkspaceUi(matchBoundRepo() || state.status?.repo);
3934
+ });
3935
+ $("#addRepoCancel")?.addEventListener("click", () => setAddPanel(false));
3936
+ $("#addRepoGo")?.addEventListener("click", () => openAddedPath());
3937
+ $("#addRepoPath")?.addEventListener("keydown", (e) => {
3938
+ if (e.key === "Enter") openAddedPath();
3939
+ });
3940
+
3941
+ document.querySelectorAll("#tabs button[data-tab]").forEach((b) => {
3942
+ b.classList.toggle("active", b.dataset.tab === state.tab);
3943
+ });
3944
+
3945
+ refreshStatus()
3946
+ .then(render)
3947
+ .catch((e) => {
3948
+ $("#main").innerHTML = `<section class="hero"><div class="hero-inner"><h1>amem</h1><p>${e.message}</p></div></section>`;
3949
+ });
3950
+
3951
+ setInterval(async () => {
3952
+ const before = listedRepos()
3953
+ .map((r) => r.id)
3954
+ .sort()
3955
+ .join(",");
3956
+ try {
3957
+ await refreshRepos();
3958
+ } catch {
3959
+ return;
3960
+ }
3961
+ const after = listedRepos()
3962
+ .map((r) => r.id)
3963
+ .sort()
3964
+ .join(",");
3965
+ if (after !== before) fillRepoSelect();
3966
+ }, 3000);