@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.
- package/LICENSE +21 -0
- package/README.md +534 -0
- package/dist/activity.d.ts +27 -0
- package/dist/activity.js +202 -0
- package/dist/api/routes.d.ts +31 -0
- package/dist/api/routes.js +1345 -0
- package/dist/attest.d.ts +52 -0
- package/dist/attest.js +192 -0
- package/dist/backup-schedule.d.ts +25 -0
- package/dist/backup-schedule.js +216 -0
- package/dist/capture.d.ts +34 -0
- package/dist/capture.js +257 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +1007 -0
- package/dist/context.d.ts +46 -0
- package/dist/context.js +334 -0
- package/dist/crypto.d.ts +39 -0
- package/dist/crypto.js +166 -0
- package/dist/db.d.ts +192 -0
- package/dist/db.js +666 -0
- package/dist/draft-quality.d.ts +19 -0
- package/dist/draft-quality.js +85 -0
- package/dist/embed.d.ts +76 -0
- package/dist/embed.js +331 -0
- package/dist/estimate.d.ts +32 -0
- package/dist/estimate.js +69 -0
- package/dist/freshness.d.ts +15 -0
- package/dist/freshness.js +93 -0
- package/dist/hook.d.ts +16 -0
- package/dist/hook.js +177 -0
- package/dist/hygiene-schedule.d.ts +28 -0
- package/dist/hygiene-schedule.js +221 -0
- package/dist/hygiene.d.ts +61 -0
- package/dist/hygiene.js +196 -0
- package/dist/install/claude.d.ts +6 -0
- package/dist/install/claude.js +69 -0
- package/dist/install/cursor.d.ts +7 -0
- package/dist/install/cursor.js +80 -0
- package/dist/install/hosts.d.ts +21 -0
- package/dist/install/hosts.js +186 -0
- package/dist/install/skills.d.ts +8 -0
- package/dist/install/skills.js +67 -0
- package/dist/it-pack.d.ts +20 -0
- package/dist/it-pack.js +84 -0
- package/dist/kinds.d.ts +18 -0
- package/dist/kinds.js +106 -0
- package/dist/license.d.ts +48 -0
- package/dist/license.js +172 -0
- package/dist/mcp.d.ts +40 -0
- package/dist/mcp.js +435 -0
- package/dist/paths.d.ts +11 -0
- package/dist/paths.js +55 -0
- package/dist/personal.d.ts +7 -0
- package/dist/personal.js +44 -0
- package/dist/platforms.d.ts +11 -0
- package/dist/platforms.js +32 -0
- package/dist/policy.d.ts +46 -0
- package/dist/policy.js +254 -0
- package/dist/prefs.d.ts +6 -0
- package/dist/prefs.js +11 -0
- package/dist/proposal.d.ts +90 -0
- package/dist/proposal.js +376 -0
- package/dist/publish.d.ts +28 -0
- package/dist/publish.js +57 -0
- package/dist/remember-contract.d.ts +23 -0
- package/dist/remember-contract.js +117 -0
- package/dist/repo-identity.d.ts +15 -0
- package/dist/repo-identity.js +82 -0
- package/dist/rules-sync.d.ts +7 -0
- package/dist/rules-sync.js +47 -0
- package/dist/savings-export.d.ts +49 -0
- package/dist/savings-export.js +141 -0
- package/dist/scan.d.ts +16 -0
- package/dist/scan.js +109 -0
- package/dist/search.d.ts +25 -0
- package/dist/search.js +150 -0
- package/dist/service.d.ts +20 -0
- package/dist/service.js +254 -0
- package/dist/shop.d.ts +9 -0
- package/dist/shop.js +15 -0
- package/dist/ui/server.d.ts +21 -0
- package/dist/ui/server.js +268 -0
- package/dist/vault.d.ts +25 -0
- package/dist/vault.js +42 -0
- package/dist/workspace-setup.d.ts +8 -0
- package/dist/workspace-setup.js +55 -0
- package/docs/agent-install-prompt.md +41 -0
- package/docs/backlog.md +59 -0
- package/docs/enterprise-endpoint.md +98 -0
- package/docs/license.md +54 -0
- package/docs/npm-release.md +38 -0
- package/docs/remember-contract.md +42 -0
- package/package.json +64 -0
- package/scripts/mdm-offboard.sh +14 -0
- package/skills/amem-bootstrap/SKILL.md +83 -0
- package/skills/amem-update-working-memory/SKILL.md +54 -0
- package/templates/cursor-rule.mdc +23 -0
- package/templates/mdm/co.amem.managed.plist +33 -0
- package/templates/policy.deny-default.toml +20 -0
- package/templates/policy.example.toml +22 -0
- package/ui-static/app.js +3966 -0
- package/ui-static/index.html +178 -0
- package/ui-static/orbit.js +389 -0
- 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 & 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
|
+
}
|