@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,178 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>amem</title>
7
+ <link rel="stylesheet" href="/styles.css" />
8
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
+ <link
11
+ href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap"
12
+ rel="stylesheet"
13
+ />
14
+ </head>
15
+ <body>
16
+ <div class="app">
17
+ <aside class="side-nav" id="sideNav">
18
+ <button class="brand" id="brandBtn" type="button">amem</button>
19
+ <nav class="side-tabs" id="tabs" aria-label="Primary">
20
+ <button type="button" data-tab="welcome">
21
+ <span class="nav-ico" aria-hidden="true"></span>
22
+ Plans
23
+ </button>
24
+ <button type="button" data-tab="setup">
25
+ <span class="nav-ico" aria-hidden="true"></span>
26
+ Setup
27
+ </button>
28
+ <button type="button" data-tab="dashboard">
29
+ <span class="nav-ico" aria-hidden="true"></span>
30
+ Dashboard
31
+ </button>
32
+ <button type="button" data-tab="brain">
33
+ <span class="nav-ico" aria-hidden="true"></span>
34
+ Memory
35
+ </button>
36
+ <button type="button" data-tab="analytics">
37
+ <span class="nav-ico" aria-hidden="true"></span>
38
+ Analytics
39
+ </button>
40
+ </nav>
41
+ <div class="side-footer">
42
+ <button class="side-foot-btn" id="personalBtn" type="button">Personal</button>
43
+ <button class="side-foot-btn" id="vaultToggle" type="button">Lock / backup</button>
44
+ </div>
45
+ </aside>
46
+
47
+ <div class="app-main">
48
+ <header class="top-bar" id="topBar">
49
+ <div class="repo-switcher">
50
+ <select id="repoSelect" aria-label="Git repo or workspace"></select>
51
+ <button class="btn secondary small hidden" id="renameWsBtn" type="button">Rename</button>
52
+ <button class="btn secondary small" id="addRepoBtn" type="button">Add repo</button>
53
+ </div>
54
+ <div class="top-search-wrap" id="topSearchWrap">
55
+ <input
56
+ id="brainSearch"
57
+ class="top-search"
58
+ type="search"
59
+ placeholder="Filter facts or try retrieval…"
60
+ autocomplete="off"
61
+ />
62
+ <button class="btn secondary small top-mem-only hidden" type="button" id="tryRetrievalBtn" title="Run the same lookup MCP uses">
63
+ Try retrieval
64
+ </button>
65
+ </div>
66
+ <div class="top-actions" id="topActions">
67
+ <label class="autostart brain-auto top-mem-only hidden" id="autoApplyLabel">
68
+ <input type="checkbox" id="autoApplyAll" /> Auto-approve new
69
+ </label>
70
+ <button class="btn secondary small top-mem-only hidden" type="button" id="showdownToggle">
71
+ Compare retrieval
72
+ </button>
73
+ <button class="btn secondary small top-mem-only hidden" type="button" id="rulesSyncBtn">
74
+ Sync pinned → rules
75
+ </button>
76
+ </div>
77
+ <p class="vault-summary" id="vaultChips"></p>
78
+ </header>
79
+
80
+ <div id="vaultPanel" class="vault-panel hidden">
81
+ <div class="vault-panel-inner">
82
+ <div>
83
+ <strong>Lock &amp; local backup</strong>
84
+ <p class="note" style="margin: 0.35rem 0 0">
85
+ AES-256-GCM on this machine. Passphrase never leaves localhost. No cloud.
86
+ </p>
87
+ </div>
88
+ <div class="add-repo-row">
89
+ <input id="vaultPass" type="password" autocomplete="new-password" placeholder="Passphrase" />
90
+ <button class="btn" id="vaultLockBtn" type="button">Lock</button>
91
+ <button class="btn secondary" id="vaultUnlockBtn" type="button">Unlock</button>
92
+ </div>
93
+ <div class="add-repo-row">
94
+ <button class="btn secondary" id="vaultBackupBtn" type="button">Backup now</button>
95
+ <label class="note" for="vaultHour">Hour</label>
96
+ <input id="vaultHour" type="number" min="0" max="23" value="3" />
97
+ <button class="btn secondary" id="vaultScheduleBtn" type="button">Schedule daily</button>
98
+ <button class="btn secondary" id="vaultUnscheduleBtn" type="button">Unschedule</button>
99
+ </div>
100
+ <div class="add-repo-row">
101
+ <input
102
+ id="vaultRestorePath"
103
+ type="text"
104
+ placeholder="Path to backup .db or .db.enc on this machine"
105
+ />
106
+ <button class="btn secondary" id="vaultRestoreBtn" type="button">Restore</button>
107
+ </div>
108
+ <div class="add-repo-row" id="shopRow">
109
+ <a class="btn" id="buyPro" href="https://getamem.com/buy/pro" target="_blank" rel="noopener"
110
+ >Buy Pro · $12</a
111
+ >
112
+ <a class="btn secondary" id="buyIt" href="https://getamem.com/buy/it" target="_blank" rel="noopener"
113
+ >Buy IT · $49</a
114
+ >
115
+ <span class="note"
116
+ >After pay, download <code>amem-license.json</code> on the thank-you page, then apply it in Plans/Setup or
117
+ <code>amem license apply --file</code>.</span
118
+ >
119
+ </div>
120
+ <p class="note" id="vaultDetail"></p>
121
+ </div>
122
+ </div>
123
+
124
+ <div id="embedIndexBanner" class="embed-index-banner hidden" role="status"></div>
125
+
126
+ <div id="addRepoPanel" class="add-repo-panel hidden">
127
+ <label for="addRepoPath">Path to another git repo on this machine</label>
128
+ <div class="add-repo-row">
129
+ <input id="addRepoPath" type="text" placeholder="/Users/you/projects/other-repo" />
130
+ <button class="btn" id="addRepoGo" type="button">Open setup</button>
131
+ <button class="btn secondary" id="addRepoCancel" type="button">Cancel</button>
132
+ </div>
133
+ </div>
134
+
135
+ <div class="workspace" id="workspace">
136
+ <main id="main"></main>
137
+ <aside class="right-panel" id="rightPanel" aria-label="Insights"></aside>
138
+ </div>
139
+ </div>
140
+ </div>
141
+ <script>
142
+ (function () {
143
+ var TABS = ["welcome", "setup", "dashboard", "brain", "analytics"];
144
+ var params = new URLSearchParams(location.search);
145
+ var fromUrl = params.get("tab");
146
+ var welcomeDismissed = false;
147
+ var paid = false;
148
+ try {
149
+ welcomeDismissed = localStorage.getItem("amem.welcome.dismissed") === "1";
150
+ var tier = localStorage.getItem("amem.license.tier");
151
+ paid = tier === "pro" || tier === "it";
152
+ } catch (e) {}
153
+ var tab = "setup";
154
+ if (paid) {
155
+ if (fromUrl === "stats") tab = "analytics";
156
+ else if (TABS.indexOf(fromUrl) !== -1) tab = fromUrl;
157
+ else tab = "dashboard";
158
+ } else if (
159
+ !welcomeDismissed &&
160
+ fromUrl !== "brain" &&
161
+ fromUrl !== "stats" &&
162
+ fromUrl !== "analytics" &&
163
+ fromUrl !== "dashboard"
164
+ ) {
165
+ tab = "welcome";
166
+ } else if (fromUrl === "stats") {
167
+ tab = "analytics";
168
+ } else if (TABS.indexOf(fromUrl) !== -1) {
169
+ tab = fromUrl;
170
+ }
171
+ document.querySelectorAll("#tabs button[data-tab]").forEach(function (b) {
172
+ b.classList.toggle("active", b.getAttribute("data-tab") === tab);
173
+ });
174
+ })();
175
+ </script>
176
+ <script type="module" src="/app.js"></script>
177
+ </body>
178
+ </html>
@@ -0,0 +1,389 @@
1
+ /**
2
+ * Orbit map — readable hub-and-spoke view of local memory.
3
+ * Center = scope, inner ring = groups, outer dots = what sits inside the focused group.
4
+ *
5
+ * The caller decides what a ring node means by tagging each claim with `_group`
6
+ * (inner ring) and optionally `_sub` (outer ring). Across all memories the ring is
7
+ * one node per memory, because every repo anchors most facts to README.md and
8
+ * grouping on the raw path would merge them into a single meaningless blob.
9
+ */
10
+
11
+ function shortFile(path) {
12
+ const parts = String(path || "").split(/[/\\]/).filter(Boolean);
13
+ if (parts.length <= 2) return path || "?";
14
+ return parts.slice(-2).join("/");
15
+ }
16
+
17
+ // `amem remember` writes topic tags into code_anchors alongside real paths, so a
18
+ // node like "hollywood" is a tag, not a file. Mark it as one instead of lying.
19
+ function isPathish(name) {
20
+ return /[/\\]/.test(String(name)) || /\.[a-z0-9]{1,6}$/i.test(String(name));
21
+ }
22
+
23
+ function nodeLabel(name) {
24
+ const raw = String(name || "?");
25
+ if (raw.startsWith("(")) return raw;
26
+ return isPathish(raw) ? shortFile(raw) : `#${raw}`;
27
+ }
28
+
29
+ function groupClaims(claims) {
30
+ const map = new Map();
31
+ for (const c of claims || []) {
32
+ if ((c.status || "active") !== "active") continue;
33
+ const key = c._group || "(unsorted)";
34
+ if (!map.has(key)) map.set(key, []);
35
+ map.get(key).push(c);
36
+ }
37
+ return [...map.entries()]
38
+ .map(([id, items]) => ({
39
+ id,
40
+ // A caller-supplied label is shown verbatim; otherwise the id is a path or tag.
41
+ label: items[0]?._groupLabel || null,
42
+ items,
43
+ used: items.some((c) => Number(c._hot || 0) > 0),
44
+ pinned: items.some((c) => Number(c.pinned || 0) > 0),
45
+ }))
46
+ .sort((a, b) => b.items.length - a.items.length);
47
+ }
48
+
49
+ function subGroups(items) {
50
+ const map = new Map();
51
+ for (const c of items) {
52
+ const key = c._sub || "(unanchored)";
53
+ if (!map.has(key)) map.set(key, []);
54
+ map.get(key).push(c);
55
+ }
56
+ return [...map.entries()]
57
+ .map(([id, list]) => ({
58
+ id,
59
+ label: list[0]?._subLabel || null,
60
+ count: list.length,
61
+ pinned: list.some((c) => Number(c.pinned || 0) > 0),
62
+ hot: list.some((c) => Number(c._hot || 0) > 0),
63
+ }))
64
+ .sort((a, b) => b.count - a.count);
65
+ }
66
+
67
+ const MAX_GROUPS = 18;
68
+ const MAX_SPOKES = 12;
69
+ const MAX_CANVAS = 2400;
70
+
71
+ export function createOrbitViz(canvas, opts = {}) {
72
+ const onSelectFile = opts.onSelectFile || (() => {});
73
+ const onSelectClaim = opts.onSelectClaim || (() => {});
74
+ const onSelectGroup = opts.onSelectGroup || null;
75
+ let files = [];
76
+ let totalGroups = 0;
77
+ let totalFacts = 0;
78
+ let ringNoun = "files";
79
+ let focusFile = null;
80
+ let selectedClaimId = null;
81
+ let recentIds = new Set();
82
+ let centerLabel = "memory";
83
+ let running = false;
84
+ let raf = 0;
85
+ let hover = null; // { type, id }
86
+ let pointer = { x: -9999, y: -9999 };
87
+ let t0 = performance.now();
88
+
89
+ function setData({ claims, recentClaimIds, selectedFile, selectedClaim, label, ringLabel }) {
90
+ recentIds = new Set(recentClaimIds || []);
91
+ const tagged = (claims || []).map((c) => ({
92
+ ...c,
93
+ _hot: recentIds.has(c.id) ? 1 : 0,
94
+ }));
95
+ const all = groupClaims(tagged);
96
+ totalGroups = all.length;
97
+ totalFacts = tagged.length;
98
+ ringNoun = ringLabel || "files";
99
+ files = all.slice(0, MAX_GROUPS);
100
+ // A node picked outside Orbit may sit past the ring cutoff — pull it in.
101
+ if (selectedFile && !files.some((f) => f.id === selectedFile)) {
102
+ const extra = all.find((f) => f.id === selectedFile);
103
+ if (extra) files = [extra, ...files.slice(0, MAX_GROUPS - 1)];
104
+ }
105
+ focusFile = selectedFile && files.some((f) => f.id === selectedFile) ? selectedFile : null;
106
+ selectedClaimId = selectedClaim || null;
107
+ centerLabel = label || "memory";
108
+ }
109
+
110
+ function layout(w, h) {
111
+ const cx = w / 2;
112
+ const cy = h / 2 + 6;
113
+ const R = Math.min(w, h) * 0.32;
114
+ const n = Math.max(files.length, 1);
115
+ return files.map((f, i) => {
116
+ const a = -Math.PI / 2 + (i / n) * Math.PI * 2;
117
+ return {
118
+ ...f,
119
+ a,
120
+ x: cx + Math.cos(a) * R,
121
+ y: cy + Math.sin(a) * R,
122
+ r: Math.min(28, 10 + Math.sqrt(f.items.length) * 5),
123
+ };
124
+ });
125
+ }
126
+
127
+ function factOrbit(focus, cx, cy, w, h) {
128
+ if (!focus) return [];
129
+ const hasSubs = focus.items.some((c) => c._sub);
130
+ const spokes = hasSubs
131
+ ? subGroups(focus.items).slice(0, MAX_SPOKES)
132
+ : focus.items.slice(0, MAX_SPOKES).map((c) => ({ claim: c, pinned: Number(c.pinned || 0) > 0 }));
133
+ const R = Math.min(w, h) * 0.46;
134
+ return spokes.map((s, i) => {
135
+ const a = focus.a - 0.55 + (spokes.length <= 1 ? 0 : (i / (spokes.length - 1 || 1)) * 1.1);
136
+ return {
137
+ ...s,
138
+ x: cx + Math.cos(a) * R,
139
+ y: cy + Math.sin(a) * R,
140
+ r: s.claim ? (s.pinned ? 7 : 5.5) : Math.min(11, 5 + Math.sqrt(s.count) * 2),
141
+ };
142
+ });
143
+ }
144
+
145
+ function paint() {
146
+ const ctx = canvas.getContext("2d");
147
+ if (!ctx) return;
148
+ const dpr = window.devicePixelRatio || 1;
149
+ // A canvas past the browser's max dimension silently renders nothing, so never
150
+ // trust the layout to hand back a sane box.
151
+ const w = Math.min(canvas.clientWidth || 640, MAX_CANVAS);
152
+ const h = Math.min(canvas.clientHeight || 360, MAX_CANVAS);
153
+ if (canvas.width !== Math.floor(w * dpr) || canvas.height !== Math.floor(h * dpr)) {
154
+ canvas.width = Math.floor(w * dpr);
155
+ canvas.height = Math.floor(h * dpr);
156
+ }
157
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
158
+ ctx.clearRect(0, 0, w, h);
159
+
160
+ const cx = w / 2;
161
+ const cy = h / 2 + 6;
162
+ const pulse = 0.5 + 0.5 * Math.sin((performance.now() - t0) / 900);
163
+
164
+ // soft field
165
+ const g = ctx.createRadialGradient(cx, cy, 8, cx, cy, Math.max(w, h) * 0.48);
166
+ g.addColorStop(0, "rgba(46,196,182,0.1)");
167
+ g.addColorStop(0.55, "rgba(10,16,22,0.05)");
168
+ g.addColorStop(1, "rgba(6,10,14,0)");
169
+ ctx.fillStyle = g;
170
+ ctx.fillRect(0, 0, w, h);
171
+
172
+ if (!files.length) {
173
+ ctx.fillStyle = "#8fa3b0";
174
+ ctx.font = "500 14px 'IBM Plex Sans', system-ui, sans-serif";
175
+ ctx.textAlign = "center";
176
+ ctx.fillText("No facts to orbit yet — approve a capture or bootstrap on Setup.", cx, cy);
177
+ return;
178
+ }
179
+
180
+ const nodes = layout(w, h);
181
+ const focus = nodes.find((n) => n.id === focusFile) || null;
182
+ const facts = factOrbit(focus, cx, cy, w, h);
183
+
184
+ // spokes
185
+ for (const n of nodes) {
186
+ const active = focus && n.id === focus.id;
187
+ ctx.beginPath();
188
+ ctx.moveTo(cx, cy);
189
+ ctx.lineTo(n.x, n.y);
190
+ ctx.strokeStyle = active
191
+ ? "rgba(94,224,212,0.55)"
192
+ : n.used
193
+ ? "rgba(46,196,182,0.28)"
194
+ : "rgba(140,180,200,0.14)";
195
+ ctx.lineWidth = active ? 2 : 1;
196
+ ctx.stroke();
197
+ }
198
+
199
+ // fact links from focused file
200
+ if (focus) {
201
+ for (const f of facts) {
202
+ ctx.beginPath();
203
+ ctx.moveTo(focus.x, focus.y);
204
+ ctx.lineTo(f.x, f.y);
205
+ ctx.strokeStyle = "rgba(230,180,80,0.28)";
206
+ ctx.lineWidth = 1;
207
+ ctx.stroke();
208
+ }
209
+ }
210
+
211
+ // center hub
212
+ ctx.beginPath();
213
+ ctx.fillStyle = "rgba(20, 28, 34, 0.95)";
214
+ ctx.strokeStyle = `rgba(94,224,212,${0.45 + pulse * 0.25})`;
215
+ ctx.lineWidth = 2;
216
+ ctx.arc(cx, cy, 36, 0, Math.PI * 2);
217
+ ctx.fill();
218
+ ctx.stroke();
219
+ ctx.fillStyle = "#e8eef2";
220
+ ctx.font = "600 12px 'IBM Plex Sans', system-ui, sans-serif";
221
+ ctx.textAlign = "center";
222
+ ctx.textBaseline = "middle";
223
+ const hub = String(centerLabel).slice(0, 16);
224
+ ctx.fillText(hub, cx, cy - 8);
225
+ ctx.fillStyle = "#8fa3b0";
226
+ ctx.font = "500 10px 'IBM Plex Sans', system-ui, sans-serif";
227
+ ctx.fillText(`${totalFacts} facts`, cx, cy + 8);
228
+ ctx.fillText(
229
+ totalGroups > files.length
230
+ ? `${files.length}/${totalGroups} ${ringNoun}`
231
+ : `${totalGroups} ${ringNoun}`,
232
+ cx,
233
+ cy + 20,
234
+ );
235
+
236
+ hover = null;
237
+
238
+ // file nodes
239
+ for (const n of nodes) {
240
+ const dx = pointer.x - n.x;
241
+ const dy = pointer.y - n.y;
242
+ const over = dx * dx + dy * dy <= (n.r + 6) ** 2;
243
+ if (over) hover = { type: "group", id: n.id, node: n };
244
+ const active = focus && n.id === focus.id;
245
+ ctx.beginPath();
246
+ ctx.fillStyle = n.pinned
247
+ ? "rgba(230,180,80,0.92)"
248
+ : n.used
249
+ ? "rgba(46,196,182,0.9)"
250
+ : "rgba(120,150,170,0.75)";
251
+ ctx.arc(n.x, n.y, n.r + (active || over ? 2 : 0), 0, Math.PI * 2);
252
+ ctx.fill();
253
+ if (active || over) {
254
+ ctx.strokeStyle = "rgba(232,238,242,0.85)";
255
+ ctx.lineWidth = 2;
256
+ ctx.stroke();
257
+ }
258
+ ctx.fillStyle = over || active ? "#e8eef2" : "#8fa3b0";
259
+ ctx.font = `${active || over ? 600 : 500} 11px 'IBM Plex Sans', system-ui, sans-serif`;
260
+ ctx.textAlign = "center";
261
+ ctx.textBaseline = "top";
262
+ ctx.fillText(n.label || nodeLabel(n.id), n.x, n.y + n.r + 6);
263
+ ctx.fillStyle = "#8fa3b0";
264
+ ctx.font = "500 10px 'IBM Plex Sans', system-ui, sans-serif";
265
+ ctx.fillText(`${n.items.length}`, n.x, n.y + n.r + 20);
266
+ }
267
+
268
+ // outer ring: individual facts inside a file, or the files inside a memory
269
+ for (const f of facts) {
270
+ const dx = pointer.x - f.x;
271
+ const dy = pointer.y - f.y;
272
+ const over = dx * dx + dy * dy <= (f.r + 5) ** 2;
273
+ if (over) hover = { type: f.claim ? "claim" : "sub", id: f.claim ? f.claim.id : f.id, node: f };
274
+ const sel = f.claim ? f.claim.id === selectedClaimId : f.id === selectedClaimId;
275
+ ctx.beginPath();
276
+ ctx.fillStyle = f.pinned
277
+ ? "rgba(230,180,80,0.95)"
278
+ : (f.claim ? recentIds.has(f.claim.id) : f.hot)
279
+ ? "rgba(94,224,212,0.95)"
280
+ : "rgba(180,200,210,0.85)";
281
+ ctx.arc(f.x, f.y, f.r + (over || sel ? 1.5 : 0), 0, Math.PI * 2);
282
+ ctx.fill();
283
+ if (over || sel) {
284
+ ctx.strokeStyle = "#e8eef2";
285
+ ctx.lineWidth = 1.5;
286
+ ctx.stroke();
287
+ }
288
+ if (!f.claim) {
289
+ ctx.fillStyle = over ? "#e8eef2" : "#8fa3b0";
290
+ ctx.font = `${over ? 600 : 500} 10px 'IBM Plex Sans', system-ui, sans-serif`;
291
+ ctx.textAlign = "center";
292
+ ctx.textBaseline = "top";
293
+ ctx.fillText(`${f.label || nodeLabel(f.id)} · ${f.count}`, f.x, f.y + f.r + 5);
294
+ }
295
+ }
296
+
297
+ if (hover?.type === "claim" || hover?.type === "sub") {
298
+ const text =
299
+ hover.type === "claim"
300
+ ? String(hover.node.claim.text || hover.id).slice(0, 64)
301
+ : `${hover.node.label || hover.id} · ${hover.node.count} fact${hover.node.count === 1 ? "" : "s"}`;
302
+ ctx.font = "600 12px 'IBM Plex Sans', system-ui, sans-serif";
303
+ const tw = ctx.measureText(text).width;
304
+ const pad = 8;
305
+ const bx = Math.min(Math.max(8, hover.node.x - tw / 2 - pad), w - tw - pad * 2 - 8);
306
+ const by = Math.max(8, hover.node.y - 28);
307
+ ctx.fillStyle = "rgba(8,12,16,0.9)";
308
+ ctx.strokeStyle = "rgba(120,200,180,0.35)";
309
+ ctx.lineWidth = 1;
310
+ roundRect(ctx, bx, by, tw + pad * 2, 22, 6);
311
+ ctx.fill();
312
+ ctx.stroke();
313
+ ctx.fillStyle = "#e8eef2";
314
+ ctx.textAlign = "left";
315
+ ctx.textBaseline = "alphabetic";
316
+ ctx.fillText(text, bx + pad, by + 15);
317
+ }
318
+ }
319
+
320
+ function roundRect(ctx, x, y, w, h, r) {
321
+ ctx.beginPath();
322
+ ctx.moveTo(x + r, y);
323
+ ctx.arcTo(x + w, y, x + w, y + h, r);
324
+ ctx.arcTo(x + w, y + h, x, y + h, r);
325
+ ctx.arcTo(x, y + h, x, y, r);
326
+ ctx.arcTo(x, y, x + w, y, r);
327
+ ctx.closePath();
328
+ }
329
+
330
+ function frame() {
331
+ if (!running) return;
332
+ paint();
333
+ raf = requestAnimationFrame(frame);
334
+ }
335
+
336
+ function start() {
337
+ if (running) return;
338
+ // Surface a dead canvas as an error instead of leaving an empty dark panel.
339
+ if (typeof canvas.getContext !== "function" || !canvas.getContext("2d")) {
340
+ throw new Error("this browser did not give Orbit a 2D canvas");
341
+ }
342
+ running = true;
343
+ t0 = performance.now();
344
+ raf = requestAnimationFrame(frame);
345
+ }
346
+
347
+ function stop() {
348
+ running = false;
349
+ if (raf) cancelAnimationFrame(raf);
350
+ raf = 0;
351
+ }
352
+
353
+ canvas.addEventListener("pointermove", (e) => {
354
+ const rect = canvas.getBoundingClientRect();
355
+ pointer = { x: e.clientX - rect.left, y: e.clientY - rect.top };
356
+ });
357
+ canvas.addEventListener("pointerleave", () => {
358
+ pointer = { x: -9999, y: -9999 };
359
+ hover = null;
360
+ });
361
+ canvas.addEventListener("pointerup", (e) => {
362
+ const rect = canvas.getBoundingClientRect();
363
+ pointer = { x: e.clientX - rect.left, y: e.clientY - rect.top };
364
+ paint();
365
+ if (!hover) return;
366
+ if (hover.type === "group") {
367
+ focusFile = hover.id;
368
+ if (onSelectGroup) onSelectGroup(hover.node);
369
+ else onSelectFile(hover.id);
370
+ } else if (hover.type === "sub") {
371
+ onSelectFile(hover.id);
372
+ } else if (hover.type === "claim") {
373
+ selectedClaimId = hover.id;
374
+ onSelectClaim(hover.node.claim);
375
+ }
376
+ });
377
+
378
+ return {
379
+ setData,
380
+ start,
381
+ stop,
382
+ setFocus(file) {
383
+ focusFile = file;
384
+ },
385
+ setSelectedClaim(id) {
386
+ selectedClaimId = id;
387
+ },
388
+ };
389
+ }