aidimag 1.0.7 → 1.0.9
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/README.md +1 -1
- package/dist/cli/commands/sync.js +5 -0
- package/dist/sync/client.d.ts +4 -0
- package/dist/sync/client.js +20 -3
- package/dist/ui/page.d.ts +1 -1
- package/dist/ui/page.js +13 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -151,7 +151,7 @@ For larger teams or commercial use beyond this limit, a commercial license is re
|
|
|
151
151
|
|
|
152
152
|
<div align="center">
|
|
153
153
|
|
|
154
|
-
**Built by [Anup Khanal](https://github.com/
|
|
154
|
+
**Built by [Anup Khanal](https://github.com/anup-khanal)**
|
|
155
155
|
|
|
156
156
|
[Website](https://aidimag.com) • [Documentation](https://aidimag.com) • [Cloud](https://cloud.aidimag.com) • [npm](https://www.npmjs.com/package/aidimag) • [License](./LICENSE)
|
|
157
157
|
|
|
@@ -174,6 +174,7 @@ export function registerSyncCommands(program) {
|
|
|
174
174
|
.command("sync")
|
|
175
175
|
.description("Sync this repo's memory with the linked team server (push + pull)")
|
|
176
176
|
.option("--full", "Upload all local memories to this brain (not just changes since last push)")
|
|
177
|
+
.option("--force-pull", "Reset pull cursor and fetch all remote data (recovery after local DB corruption)")
|
|
177
178
|
.option("-y, --yes", "Skip confirmation when uploading to an empty remote brain")
|
|
178
179
|
.action(async (opts) => {
|
|
179
180
|
const root = findRepoRoot() ?? fail("not inside a repo");
|
|
@@ -182,6 +183,7 @@ export function registerSyncCommands(program) {
|
|
|
182
183
|
try {
|
|
183
184
|
const r = await sync(store, root, {
|
|
184
185
|
full: Boolean(opts.full),
|
|
186
|
+
forcePull: Boolean(opts.forcePull),
|
|
185
187
|
confirmFullUpload: opts.full
|
|
186
188
|
? undefined
|
|
187
189
|
: async (localCount, remoteCount) => {
|
|
@@ -219,6 +221,9 @@ export function registerSyncCommands(program) {
|
|
|
219
221
|
? `already on server (${mem(r.memoriesQueued)} unchanged)`
|
|
220
222
|
: "nothing to send";
|
|
221
223
|
console.log(`☁ Synced — ${sent}, ${recv}${r.eventsPushed ? ` (+${r.eventsPushed} verification events)` : ""}.`);
|
|
224
|
+
if (r.autoRecovered) {
|
|
225
|
+
console.log("✓ Auto-recovery: detected local data loss and restored memories from the cloud.");
|
|
226
|
+
}
|
|
222
227
|
const localMemories = store.statusSummary().total;
|
|
223
228
|
if (opts.full && r.memoriesQueued > 0 && r.memoriesPushed === 0 && localMemories > 0) {
|
|
224
229
|
console.log("✓ Full sync checked all local memories — cloud already has them (no updates needed).");
|
package/dist/sync/client.d.ts
CHANGED
|
@@ -54,12 +54,16 @@ export interface SyncResult {
|
|
|
54
54
|
pushSkipped: boolean;
|
|
55
55
|
/** Remote brain is empty while local has memories — user was not asked or declined upload. */
|
|
56
56
|
needsFullUploadConfirm?: boolean;
|
|
57
|
+
/** Auto-recovery detected local data loss and reset cursor to pull all remote data. */
|
|
58
|
+
autoRecovered?: boolean;
|
|
57
59
|
}
|
|
58
60
|
export interface SyncOptions {
|
|
59
61
|
/** Push every local memory/proposal, not just rows changed since the last push. */
|
|
60
62
|
full?: boolean;
|
|
61
63
|
/** Called when local has memories missing on the remote; return true to run a full upload. */
|
|
62
64
|
confirmFullUpload?: (localMemoryCount: number, remoteMemoryCount: number | null) => Promise<boolean>;
|
|
65
|
+
/** Reset pull cursor to 0 and fetch all remote data (useful for recovery after local DB corruption). */
|
|
66
|
+
forcePull?: boolean;
|
|
63
67
|
}
|
|
64
68
|
export type { RemoteSnapshot, RemoteSnapshotItem } from "./server.js";
|
|
65
69
|
export interface FetchRemoteSnapshotOpts {
|
package/dist/sync/client.js
CHANGED
|
@@ -29,7 +29,9 @@ export function readCloudConfig(repoRoot) {
|
|
|
29
29
|
return null;
|
|
30
30
|
try {
|
|
31
31
|
const cfg = JSON.parse(readFileSync(p, "utf8"));
|
|
32
|
-
|
|
32
|
+
const server = (cfg.server ?? "").trim();
|
|
33
|
+
const brain = (cfg.brain ?? "").trim();
|
|
34
|
+
return server && brain ? { server, brain } : null;
|
|
33
35
|
}
|
|
34
36
|
catch {
|
|
35
37
|
return null;
|
|
@@ -46,7 +48,9 @@ export function writeCloudConfig(repoRoot, cfg) {
|
|
|
46
48
|
catch {
|
|
47
49
|
// fresh file
|
|
48
50
|
}
|
|
49
|
-
|
|
51
|
+
const server = cfg.server.trim();
|
|
52
|
+
const brain = cfg.brain.trim();
|
|
53
|
+
writeFileSync(p, JSON.stringify({ ...existing, server, brain }, null, 2) + "\n");
|
|
50
54
|
}
|
|
51
55
|
function credentialsPath() {
|
|
52
56
|
return path.join(homedir(), ".aidimag", "credentials.json");
|
|
@@ -305,7 +309,19 @@ export async function sync(store, repoRoot, opts = {}) {
|
|
|
305
309
|
throw err;
|
|
306
310
|
}
|
|
307
311
|
// ---- pull
|
|
308
|
-
|
|
312
|
+
let cursor = parseInt(store.getMeta(cursorKey) ?? "0", 10);
|
|
313
|
+
let autoRecovered = false;
|
|
314
|
+
// Auto-recovery: if local is empty but cursor suggests we've synced before,
|
|
315
|
+
// and remote has data, reset cursor to pull everything (likely DB corruption/loss)
|
|
316
|
+
if (opts.forcePull || (localMemoryCount === 0 && cursor > 0)) {
|
|
317
|
+
const remoteCount = remoteMemoryCount ?? await getRemoteMemoryCount(cfg, token);
|
|
318
|
+
if (remoteCount && remoteCount > 0) {
|
|
319
|
+
cursor = 0;
|
|
320
|
+
store.setMeta(cursorKey, "0");
|
|
321
|
+
autoRecovered = true;
|
|
322
|
+
debugLog("sync", `Auto-recovery: local empty but remote has ${remoteCount} memories, resetting cursor to pull all`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
309
325
|
const pullRes = await api(cfg, token, `/v1/pull?brain=${encodeURIComponent(cfg.brain)}&since=${cursor}`);
|
|
310
326
|
let applied = 0;
|
|
311
327
|
let skippedOlder = 0;
|
|
@@ -356,6 +372,7 @@ export async function sync(store, repoRoot, opts = {}) {
|
|
|
356
372
|
pushQueued,
|
|
357
373
|
pushSkipped,
|
|
358
374
|
...(needsFullUploadConfirm ? { needsFullUploadConfirm: true } : {}),
|
|
375
|
+
...(autoRecovered ? { autoRecovered: true } : {}),
|
|
359
376
|
};
|
|
360
377
|
}
|
|
361
378
|
// ---------------------------------------------------------------- auto-sync (debounced)
|
package/dist/ui/page.d.ts
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
* Dashboard HTML — embedded as a template string so `tsc` is the whole build
|
|
3
3
|
* (no asset pipeline). D3 v7 from CDN renders the memory graph.
|
|
4
4
|
*/
|
|
5
|
-
export declare const PAGE_HTML = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>aiDimag \u2014 repo brain</title>\n<script src=\"https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js\"></script>\n<meta name=\"color-scheme\" content=\"light dark\">\n<style>\n :root {\n --background: 210 40% 98%;\n --foreground: 222 47% 11%;\n --card: 0 0% 100%;\n --muted: 214 32% 94%;\n --muted-foreground: 215 16% 47%;\n --primary: 217 91% 53%;\n --primary-foreground: 0 0% 100%;\n --secondary: 214 32% 94%;\n --border: 214 32% 91%;\n --ring: 199 89% 48%;\n --radius: 0.75rem;\n --surface-glow: 0 0 0 1px rgba(37, 99, 235, 0.08), 0 8px 32px rgba(37, 99, 235, 0.08);\n --verified: #22c55e;\n --unverified: #64748b;\n --stale: #eab308;\n --refuted: #ef4444;\n --path: #2563eb;\n }\n .dark {\n --background: 222 47% 6%;\n --foreground: 210 40% 98%;\n --card: 222 47% 8%;\n --muted: 217 33% 14%;\n --muted-foreground: 215 20% 65%;\n --primary: 213 94% 68%;\n --primary-foreground: 222 47% 6%;\n --secondary: 217 33% 14%;\n --border: 217 33% 16%;\n --surface-glow: 0 0 0 1px rgba(96, 165, 250, 0.12), 0 8px 32px rgba(0, 0, 0, 0.35);\n --path: #60a5fa;\n --unverified: #94a3b8;\n }\n * { box-sizing: border-box; margin: 0; }\n html { color-scheme: light dark; }\n body {\n background-color: hsl(var(--background));\n background-image:\n radial-gradient(at 0% 0%, rgba(37, 99, 235, 0.12) 0, transparent 50%),\n radial-gradient(at 100% 0%, rgba(14, 165, 233, 0.1) 0, transparent 50%),\n radial-gradient(at 50% 100%, rgba(6, 182, 212, 0.08) 0, transparent 50%);\n color: hsl(var(--foreground));\n font: 14px/1.5 \"Inter\", ui-sans-serif, system-ui, sans-serif;\n font-feature-settings: \"cv02\", \"cv03\", \"cv04\", \"cv11\";\n height: 100vh;\n display: flex;\n flex-direction: column;\n -webkit-font-smoothing: antialiased;\n }\n html.dark body {\n background-image:\n radial-gradient(at 0% 0%, rgba(37, 99, 235, 0.18) 0, transparent 50%),\n radial-gradient(at 100% 0%, rgba(14, 165, 233, 0.12) 0, transparent 50%),\n radial-gradient(at 50% 100%, rgba(6, 182, 212, 0.1) 0, transparent 50%);\n }\n header {\n display: flex; align-items: center; gap: 8px 12px; flex-wrap: wrap;\n padding: 10px 16px;\n border-bottom: 1px solid hsl(var(--border) / 0.6);\n background: hsl(var(--card) / 0.82);\n backdrop-filter: blur(16px);\n }\n .brand { display: flex; align-items: center; gap: 10px; min-width: 0; }\n .logo { width: 32px; height: 32px; flex-shrink: 0; border-radius: 10px; }\n .brand-text { min-width: 0; }\n header h1 { font-size: 15px; font-weight: 700; letter-spacing: -0.02em; line-height: 1.2; }\n header .subtitle {\n display: block; font-size: 11px; font-weight: 500; color: hsl(var(--muted-foreground));\n white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 280px;\n }\n .pill {\n padding: 3px 10px; border-radius: 999px; font-size: 12px;\n border: 1px solid hsl(var(--border)); white-space: nowrap;\n background: hsl(var(--muted) / 0.6); color: hsl(var(--muted-foreground));\n }\n .pill b { font-weight: 600; color: hsl(var(--foreground)); }\n .spacer { flex: 1; }\n .toolbar { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }\n button {\n display: inline-flex; align-items: center; justify-content: center; gap: 6px; white-space: nowrap;\n background: hsl(var(--secondary)); color: hsl(var(--foreground));\n border: 1px solid hsl(var(--border)); border-radius: calc(var(--radius) - 2px);\n padding: 6px 12px; font-size: 12px; font-weight: 500; cursor: pointer;\n transition: background 0.15s, border-color 0.15s, opacity 0.15s;\n }\n button svg { width: 14px; height: 14px; flex: 0 0 auto; }\n button:hover:not(.primary) { background: hsl(var(--muted)); border-color: hsl(var(--primary) / 0.35); }\n button:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 2px; }\n button.primary {\n background: hsl(var(--primary)); border-color: transparent;\n color: hsl(var(--primary-foreground));\n }\n button.primary:hover { opacity: 0.9; background: hsl(var(--primary)); border-color: transparent; }\n button.icon { padding: 8px; width: 36px; height: 36px; }\n button.danger:hover { background: color-mix(in srgb, var(--refuted) 12%, transparent); border-color: var(--refuted); }\n main { flex: 1; display: flex; min-height: 0; }\n #graph { flex: 1; min-width: 0; background: hsl(var(--background) / 0.35); }\n aside {\n width: 460px; border-left: 1px solid hsl(var(--border) / 0.6);\n overflow-y: auto; padding: 16px;\n background: hsl(var(--card) / 0.55); backdrop-filter: blur(12px);\n }\n h2 { font-size: 14px; font-weight: 600; color: hsl(var(--foreground)); margin: 16px 0 10px; letter-spacing: -0.01em; }\n .card {\n background: hsl(var(--card) / 0.9);\n border: 1px solid hsl(var(--border) / 0.6);\n border-radius: var(--radius); padding: 12px 14px; margin-bottom: 10px;\n box-shadow: var(--surface-glow);\n transition: border-color 0.15s, transform 0.15s;\n }\n .card:hover { border-color: hsl(var(--primary) / 0.3); }\n .card .claim { font-size: 13px; margin-bottom: 6px; line-height: 1.5; }\n .card .meta { font-size: 11px; color: hsl(var(--muted-foreground)); display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }\n .badge { padding: 2px 8px; border-radius: 999px; font-size: 10px; font-weight: 600; border: 1px solid transparent; }\n .badge.VERIFIED { background: color-mix(in srgb, var(--verified) 15%, transparent); color: var(--verified); }\n .badge.UNVERIFIED { background: color-mix(in srgb, var(--unverified) 15%, transparent); color: var(--unverified); }\n .badge.STALE { background: color-mix(in srgb, var(--stale) 15%, transparent); color: var(--stale); }\n .badge.REFUTED { background: color-mix(in srgb, var(--refuted) 15%, transparent); color: var(--refuted); }\n .kind { color: hsl(var(--primary)); font-weight: 500; }\n .actions { margin-top: 10px; display: flex; gap: 6px; flex-wrap: wrap; }\n .evidence { font-size: 11px; color: hsl(var(--muted-foreground)); font-family: ui-monospace, monospace; margin-top: 4px; word-break: break-all; }\n .legend {\n display: flex; gap: 14px; flex-wrap: wrap; padding: 8px 16px; font-size: 11px;\n color: hsl(var(--muted-foreground)); border-top: 1px solid hsl(var(--border) / 0.6);\n background: hsl(var(--card) / 0.65); backdrop-filter: blur(12px);\n }\n .dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 5px; vertical-align: -1px; }\n #toast {\n position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);\n background: hsl(var(--card)); color: hsl(var(--foreground));\n border: 1px solid hsl(var(--border) / 0.6);\n padding: 10px 18px;\n border-radius: var(--radius); display: none; font-size: 13px; font-weight: 500; z-index: 50;\n box-shadow: var(--surface-glow);\n }\n .empty { color: hsl(var(--muted-foreground)); font-size: 12px; padding: 8px 0; }\n svg text { fill: hsl(var(--muted-foreground)); font-size: 10px; pointer-events: none; }\n dialog {\n background: hsl(var(--card)); color: hsl(var(--foreground));\n border: 1px solid hsl(var(--border) / 0.6);\n border-radius: calc(var(--radius) + 2px); padding: 20px; width: 480px; max-width: 92vw;\n box-shadow: var(--surface-glow);\n }\n dialog::backdrop { background: rgba(0,0,0,.55); backdrop-filter: blur(4px); }\n dialog h3 { font-size: 15px; font-weight: 700; margin-bottom: 12px; letter-spacing: -0.02em; }\n dialog label {\n display: block; font-size: 12px; color: hsl(var(--muted-foreground));\n margin: 12px 0 4px; font-weight: 500;\n }\n dialog input, dialog select, dialog textarea {\n width: 100%; background: hsl(var(--background)); color: hsl(var(--foreground));\n border: 1px solid hsl(var(--border)); border-radius: calc(var(--radius) - 2px);\n padding: 8px 10px; font-size: 13px; font-family: inherit;\n }\n dialog input:focus, dialog select:focus, dialog textarea:focus {\n outline: 2px solid hsl(var(--ring)); outline-offset: 1px; border-color: transparent;\n }\n dialog textarea { min-height: 64px; resize: vertical; }\n .dialog-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }\n .ev-row { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; }\n .ev-row select { width: 160px; }\n .searchbar { display: flex; gap: 6px; margin-bottom: 10px; }\n .searchbar input {\n flex: 1; background: hsl(var(--background)); color: hsl(var(--foreground));\n border: 1px solid hsl(var(--border)); border-radius: calc(var(--radius) - 2px);\n padding: 8px 10px; font-size: 13px;\n }\n .searchbar input:focus { outline: 2px solid hsl(var(--ring)); outline-offset: 1px; border-color: transparent; }\n .searchbar select {\n background: hsl(var(--background)); color: hsl(var(--foreground));\n border: 1px solid hsl(var(--border)); border-radius: calc(var(--radius) - 2px);\n font-size: 12px; padding: 6px 8px;\n }\n .keyrow {\n font-size: 11px; font-family: ui-monospace, monospace;\n display: flex; justify-content: space-between; align-items: center;\n padding: 4px 0; border-bottom: 1px solid hsl(var(--border));\n }\n .hint { font-size: 11px; color: hsl(var(--muted-foreground)); margin-top: 6px; line-height: 1.5; }\n .theme-icon-sun { display: none; }\n html:not(.dark) .theme-icon-sun { display: block; }\n html:not(.dark) .theme-icon-moon { display: none; }\n html.dark .theme-icon-sun { display: none; }\n html.dark .theme-icon-moon { display: block; }\n</style>\n<script>\n(function () {\n var k = \"aidimag-ui-theme\";\n var saved = localStorage.getItem(k);\n // Default dark; only use light when explicitly chosen.\n if (saved !== \"light\") document.documentElement.classList.add(\"dark\");\n})();\n</script>\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap\" rel=\"stylesheet\">\n</head>\n<body>\n<header>\n <div class=\"brand\">\n <svg class=\"logo\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 64 64\" fill=\"none\" aria-hidden=\"true\">\n <defs>\n <linearGradient id=\"dimGrad\" x1=\"8\" y1=\"8\" x2=\"56\" y2=\"56\" gradientUnits=\"userSpaceOnUse\">\n <stop offset=\"0%\" stop-color=\"#2563eb\"/><stop offset=\"55%\" stop-color=\"#0ea5e9\"/><stop offset=\"100%\" stop-color=\"#06b6d4\"/>\n </linearGradient>\n <linearGradient id=\"dimGradSoft\" x1=\"8\" y1=\"8\" x2=\"56\" y2=\"56\" gradientUnits=\"userSpaceOnUse\">\n <stop offset=\"0%\" stop-color=\"#2563eb\" stop-opacity=\"0.18\"/><stop offset=\"100%\" stop-color=\"#06b6d4\" stop-opacity=\"0.18\"/>\n </linearGradient>\n </defs>\n <rect x=\"2\" y=\"2\" width=\"60\" height=\"60\" rx=\"16\" fill=\"url(#dimGradSoft)\"/>\n <g transform=\"translate(8 8) scale(2)\" stroke=\"url(#dimGrad)\" stroke-width=\"1.9\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <path d=\"M12 18V5\"/><path d=\"M15 13a4.17 4.17 0 0 1-3-4 4.17 4.17 0 0 1-3 4\"/>\n <path d=\"M17.598 6.5A3 3 0 1 0 12 5a3 3 0 1 0-5.598 1.5\"/>\n <path d=\"M17.997 5.125a4 4 0 0 1 2.526 5.77\"/><path d=\"M18 18a4 4 0 0 0 2-7.464\"/>\n <path d=\"M19.967 17.483A4 4 0 1 1 12 18a4 4 0 1 1-7.967-.517\"/>\n <path d=\"M6 18a4 4 0 0 1-2-7.464\"/><path d=\"M6.003 5.125a4 4 0 0 0-2.526 5.77\"/>\n </g>\n <circle cx=\"49\" cy=\"49\" r=\"11\" fill=\"#10b981\"/>\n <path d=\"M44 49.2l3.4 3.4L54.5 45\" stroke=\"#ffffff\" stroke-width=\"2.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" fill=\"none\"/>\n </svg>\n <div class=\"brand-text\">\n <h1>aiDimag</h1>\n <span class=\"subtitle\" id=\"repo\">Repo brain</span>\n </div>\n </div>\n <span class=\"pill\" id=\"counts\"></span>\n <div class=\"spacer\"></div>\n <div class=\"toolbar\">\n <button class=\"primary\" onclick=\"document.getElementById('dlg-new').showModal()\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M5 12h14\"/><path d=\"M12 5v14\"/></svg>New memory</button>\n <button onclick=\"runMine()\" title=\"Mine new commits since the last run (Shift+click: rescan all history)\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M14.5 5.5 18 9\"/><path d=\"M2 22l8-8\"/><path d=\"M20.5 7.5 22 6a2.83 2.83 0 0 0-4-4l-1.5 1.5\"/><path d=\"m9 11 4 4\"/><path d=\"M16 2 8.5 9.5\"/></svg>Mine commits</button>\n <button class=\"primary\" onclick=\"runVerify(false)\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21.801 10A10 10 0 1 1 17 3.335\"/><path d=\"m9 11 3 3L22 4\"/></svg>Verify</button>\n <button onclick=\"runVerify(true)\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"11\" cy=\"11\" r=\"8\"/><path d=\"m21 21-4.3-4.3\"/><path d=\"m8 11 2 2 4-4\"/></svg>Verify --deep</button>\n <button onclick=\"runSync()\" id=\"btn-sync\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8\"/><path d=\"M3 3v5h5\"/><path d=\"M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16\"/><path d=\"M16 16h5v5\"/></svg>Sync</button>\n <button onclick=\"runReindex()\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><ellipse cx=\"12\" cy=\"5\" rx=\"9\" ry=\"3\"/><path d=\"M3 5v14a9 3 0 0 0 18 0V5\"/><path d=\"M3 12a9 3 0 0 0 18 0\"/></svg>Reindex</button>\n <button onclick=\"openCloud()\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z\"/></svg>Cloud</button>\n <button onclick=\"openTickets()\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z\"/><path d=\"M13 5v2\"/><path d=\"M13 17v2\"/><path d=\"M13 11v2\"/></svg>Tickets</button>\n <button onclick=\"load()\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8\"/><path d=\"M21 3v5h-5\"/><path d=\"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16\"/><path d=\"M8 16H3v5\"/></svg>Refresh</button>\n <button class=\"icon\" type=\"button\" onclick=\"toggleTheme()\" id=\"btn-theme\" aria-label=\"Toggle light/dark theme\">\n <svg class=\"theme-icon-sun\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"4\"/><path d=\"M12 2v2\"/><path d=\"M12 20v2\"/><path d=\"m4.93 4.93 1.41 1.41\"/><path d=\"m17.66 17.66 1.41 1.41\"/><path d=\"M2 12h2\"/><path d=\"M20 12h2\"/><path d=\"m6.34 17.66-1.41 1.41\"/><path d=\"m19.07 4.93-1.41 1.41\"/></svg>\n <svg class=\"theme-icon-moon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z\"/></svg>\n </button>\n </div>\n</header>\n<main>\n <div id=\"graph\"></div>\n <aside>\n <h2 id=\"proposals-h\">Pending proposals</h2>\n <div id=\"proposals\"></div>\n <h2>Memories</h2>\n <div class=\"searchbar\">\n <input id=\"q\" placeholder=\"Search memories\u2026 (semantic when embeddings configured)\" oninput=\"debouncedSearch()\">\n <select id=\"q-kind\" onchange=\"doSearch()\">\n <option value=\"\">all kinds</option>\n <option>DECISION</option><option>CONVENTION</option><option>GOTCHA</option>\n <option>FAILED_APPROACH</option><option>ARCHITECTURE</option><option>INVARIANT</option>\n <option>TODO_CONTEXT</option><option>GUARDRAIL</option><option>SKILL</option>\n </select>\n </div>\n <div id=\"memories\"></div>\n </aside>\n</main>\n\n<!-- New memory dialog (dim remember) -->\n<dialog id=\"dlg-new\">\n <h3>\uFF0B New memory</h3>\n <label>Claim (write it falsifiable \u2014 something a check could verify)</label>\n <textarea id=\"nm-claim\" placeholder=\"All DB access goes through src/db/store.ts; nothing else imports better-sqlite3\"></textarea>\n <label>Kind</label>\n <select id=\"nm-kind\" onchange=\"toggleGuardrailLevel()\">\n <option>DECISION</option><option>CONVENTION</option><option>GOTCHA</option>\n <option>FAILED_APPROACH</option><option>ARCHITECTURE</option><option>INVARIANT</option>\n <option>TODO_CONTEXT</option><option>GUARDRAIL</option><option>SKILL</option>\n </select>\n <div id=\"guardrail-section\" style=\"display:none;\">\n <label>Guardrail Level</label>\n <select id=\"nm-guardrail-level\">\n <option value=\"ask-first\">\uD83E\uDD1A Ask First - Confirm before doing it</option>\n <option value=\"always\">\u2705 Always - Block completely, refuse to proceed</option>\n <option value=\"never\">\uD83D\uDEAB Never - Just a suggestion</option>\n </select>\n </div>\n <label>Scope paths (comma-separated, empty = repo-wide)</label>\n <input id=\"nm-paths\" placeholder=\"src/db, src/api/auth.ts\">\n <label>Symbols (comma-separated, optional)</label>\n <input id=\"nm-symbols\" placeholder=\"UserService, authenticate()\">\n <label>\n <input type=\"checkbox\" id=\"nm-pinned\">\n \uD83D\uDCCC Pin this memory (exempt from time decay)\n </label>\n <label>Evidence (optional but recommended)</label>\n <div id=\"nm-evidence\"></div>\n <button style=\"margin-top:6px\" onclick=\"addEvidenceRow()\">\uFF0B add evidence</button>\n <div class=\"hint\">STATIC_CHECK: shell command, exit 0 = claim holds \u00B7 COMMIT_REF: sha \u00B7 EXEC_TRACE: cmd :: regex \u00B7 TEST_RESULT: test cmd</div>\n <div class=\"dialog-actions\">\n <button onclick=\"document.getElementById('dlg-new').close()\">Cancel</button>\n <button class=\"primary\" onclick=\"saveMemory()\">Save memory</button>\n </div>\n</dialog>\n\n<!-- Cloud settings dialog (dim cloud link / dim keys) -->\n<dialog id=\"dlg-cloud\">\n <h3>\u2601 Team sync</h3>\n <div id=\"cloud-status\" class=\"hint\"></div>\n <label>Server URL</label>\n <input id=\"cl-server\" placeholder=\"http://localhost:3000\">\n <label>Brain (team memory name)</label>\n <input id=\"cl-brain\" placeholder=\"myrepo\">\n <label>Access token (stored on this machine only, never in the repo)</label>\n <input id=\"cl-token\" type=\"password\" placeholder=\"aidimag_sk_\u2026\">\n <div class=\"dialog-actions\">\n <button onclick=\"cloudUnlink()\">Unlink</button>\n <button class=\"primary\" onclick=\"cloudLink()\">Link</button>\n </div>\n <h3 style=\"margin-top:18px\">\uD83D\uDD11 API keys (admin)</h3>\n <label>Admin token (used for this request only \u2014 not stored)</label>\n <input id=\"k-admin\" type=\"password\" placeholder=\"server admin token\">\n <div class=\"ev-row\">\n <input id=\"k-brain\" placeholder=\"brain\">\n <input id=\"k-label\" placeholder=\"label (alice-laptop)\">\n <button class=\"primary\" onclick=\"keyCreate()\">Create</button>\n <button onclick=\"keyList()\">List</button>\n </div>\n <div id=\"keys-out\"></div>\n <div class=\"dialog-actions\">\n <button onclick=\"document.getElementById('dlg-cloud').close()\">Close</button>\n </div>\n</dialog>\n\n<!-- Tickets dialog (dim ticket connect / share) -->\n<dialog id=\"dlg-tickets\">\n <h3>\uD83C\uDFAB Tickets</h3>\n <div id=\"tk-status\" class=\"hint\"></div>\n <label>Provider</label>\n <select id=\"tk-provider\" onchange=\"ticketsProviderHint()\">\n <option value=\"jira\">Jira</option>\n <option value=\"github\">GitHub Issues</option>\n <option value=\"linear\">Linear</option>\n <option value=\"http\">HTTP middleware (your own)</option>\n <option value=\"remote\">Remote (team sync server \u2014 zero local credentials)</option>\n </select>\n <div id=\"tk-url-row\">\n <label id=\"tk-url-label\">Base URL</label>\n <input id=\"tk-url\" placeholder=\"https://acme.atlassian.net\">\n </div>\n <div id=\"tk-token-row\">\n <label id=\"tk-token-label\">Credential (stored on this machine only, never in the repo)</label>\n <input id=\"tk-token\" type=\"password\" placeholder=\"email:apiToken\">\n </div>\n <label>Ticket-id pattern (extracted from branch names & commit messages)</label>\n <input id=\"tk-pattern\" placeholder=\"[A-Z][A-Z0-9]+-\\\\d+\">\n <label>Validate with a real ticket id (optional)</label>\n <input id=\"tk-test\" placeholder=\"XXX-2100\">\n <div class=\"dialog-actions\">\n <button onclick=\"ticketsDisconnect()\">Disconnect</button>\n <button class=\"primary\" onclick=\"ticketsConnect()\">Connect</button>\n </div>\n <h3 style=\"margin-top:18px\">\uD83D\uDC65 Team credentials (admin)</h3>\n <div class=\"hint\">Stores the provider + token on the linked sync server \u2014 teammates connect with provider \u201Cremote\u201D and never hold a ticket credential.</div>\n <label>Admin token (used for this request only \u2014 not stored)</label>\n <input id=\"tk-admin\" type=\"password\" placeholder=\"server admin token\">\n <div class=\"ev-row\">\n <button class=\"primary\" onclick=\"ticketsShare()\">Share current config</button>\n <button class=\"danger\" onclick=\"ticketsShare(true)\">Remove from server</button>\n </div>\n <div class=\"dialog-actions\">\n <button onclick=\"document.getElementById('dlg-tickets').close()\">Close</button>\n </div>\n</dialog>\n\n<div class=\"legend\">\n <span><span class=\"dot\" style=\"background:var(--verified)\"></span>VERIFIED</span>\n <span><span class=\"dot\" style=\"background:var(--unverified)\"></span>UNVERIFIED</span>\n <span><span class=\"dot\" style=\"background:var(--stale)\"></span>STALE</span>\n <span><span class=\"dot\" style=\"background:var(--refuted)\"></span>REFUTED</span>\n <span><span class=\"dot\" style=\"background:var(--path); border-radius:2px\"></span>scope path</span>\n <span style=\"margin-left:auto\">node size = confidence \u00B7 drag to rearrange \u00B7 scroll to zoom</span>\n</div>\n<div id=\"toast\"></div>\n\n<script>\nconst COLORS = { VERIFIED: \"#22c55e\", UNVERIFIED: \"#94a3b8\", STALE: \"#eab308\", REFUTED: \"#ef4444\" };\nlet state = null;\nlet csrfToken = null;\n\nfunction cssVar(name, fallback) {\n const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();\n return v || fallback;\n}\n\nfunction graphPalette() {\n return {\n VERIFIED: cssVar(\"--verified\", COLORS.VERIFIED),\n UNVERIFIED: cssVar(\"--unverified\", COLORS.UNVERIFIED),\n STALE: cssVar(\"--stale\", COLORS.STALE),\n REFUTED: cssVar(\"--refuted\", COLORS.REFUTED),\n path: cssVar(\"--path\", \"#60a5fa\"),\n link: \"hsl(\" + cssVar(\"--border\", \"217 33% 16%\") + \")\",\n primary: \"hsl(\" + cssVar(\"--primary\", \"217 91% 53%\") + \")\",\n };\n}\n\nfunction toggleTheme() {\n const root = document.documentElement;\n const dark = !root.classList.contains(\"dark\");\n root.classList.toggle(\"dark\", dark);\n localStorage.setItem(\"aidimag-ui-theme\", dark ? \"dark\" : \"light\");\n if (state) renderGraph();\n}\n\nfunction toast(msg) {\n const t = document.getElementById(\"toast\");\n t.textContent = msg; t.style.display = \"block\";\n setTimeout(() => t.style.display = \"none\", 2500);\n}\n\nasync function api(path, opts) {\n opts = opts || {};\n const method = (opts.method || \"GET\").toUpperCase();\n if (method !== \"GET\" && method !== \"HEAD\") {\n if (!csrfToken) throw new Error(\"missing CSRF token \u2014 reload the page\");\n opts.headers = { ...(opts.headers || {}), \"X-Aidimag-Csrf-Token\": csrfToken };\n }\n const r = await fetch(path, opts);\n const body = await r.json();\n if (!r.ok) throw new Error(body.error || r.status);\n return body;\n}\n\nasync function load() {\n state = await api(\"/api/state\");\n csrfToken = state.csrfToken;\n document.getElementById(\"repo\").textContent = state.repoRoot;\n const s = state.summary.byStatus;\n document.getElementById(\"counts\").innerHTML =\n `<b>${state.summary.total}</b> memories \u00B7 \u2713${s.VERIFIED} ?${s.UNVERIFIED} ~${s.STALE} \u2717${s.REFUTED}`;\n renderProposals(); renderMemories(); renderGraph();\n}\n\nfunction esc(s) { return s.replace(/[&<>\"']/g, c => ({ \"&\":\"&\",\"<\":\"<\",\">\":\">\",'\"':\""\",\"'\":\"'\" }[c])); }\n\nfunction renderProposals() {\n const el = document.getElementById(\"proposals\");\n document.getElementById(\"proposals-h\").textContent = `Pending proposals (${state.proposals.length})`;\n if (!state.proposals.length) { el.innerHTML = '<div class=\"empty\">Queue is empty.</div>'; return; }\n el.innerHTML = state.proposals.map(p => `\n <div class=\"card\">\n <div class=\"claim\">${esc(p.claim)}</div>\n <div class=\"meta\"><span class=\"kind\">${p.kind}</span><span>via ${esc(p.source)}</span>${p.ticketRef ? `<span>\uD83C\uDFAB ${esc(p.ticketRef)}</span>` : \"\"}</div>\n <div class=\"actions\">\n <button class=\"primary\" onclick=\"act('/api/proposals/${p.id}/approve','approved')\">Approve</button>\n <button class=\"danger\" onclick=\"act('/api/proposals/${p.id}/reject','rejected')\">Reject</button>\n </div>\n </div>`).join(\"\");\n}\n\nfunction renderMemories(list) {\n const el = document.getElementById(\"memories\");\n const items = list ?? state.memories;\n if (!items.length) { el.innerHTML = '<div class=\"empty\">No matching memories.</div>'; return; }\n el.innerHTML = items.map(m => `\n <div class=\"card\" id=\"mem-${m.id}\">\n <div class=\"claim\">${esc(m.claim)}</div>\n <div class=\"meta\">\n <span class=\"badge ${m.status}\">${m.status}</span>\n ${m.pinned ? '<span class=\"badge\" title=\"Pinned: never decays with age (evidence failure can still mark it stale)\">\uD83D\uDCCC PINNED</span>' : \"\"}\n <span class=\"kind\">${m.kind}</span>\n <span>conf ${m.confidence.toFixed(2)}</span>\n ${m.scope.paths.length ? \"<span>\uD83D\uDCC1 \" + esc(m.scope.paths.join(\", \")) + \"</span>\" : \"<span>repo-wide</span>\"}\n </div>\n ${m.grounding.map(e => `<div class=\"evidence\">${e.type}(${e.result}) ${esc(e.payload)}</div>`).join(\"\")}\n <div class=\"actions\">\n ${m.pinned\n ? `<button onclick=\"act('/api/memories/${m.id}/unpin','unpinned')\">Unpin</button>`\n : `<button onclick=\"act('/api/memories/${m.id}/pin','pinned \uD83D\uDCCC')\">Pin</button>`}\n ${m.status !== \"REFUTED\" ? `<button class=\"danger\" onclick=\"act('/api/memories/${m.id}/refute','refuted')\">Refute</button>` : \"\"}\n <button class=\"danger\" onclick=\"if(confirm('Delete permanently?'))act('/api/memories/${m.id}/forget','forgotten')\">Forget</button>\n </div>\n </div>`).join(\"\");\n}\n\nasync function act(path, verb) {\n try { await api(path, { method: \"POST\" }); toast(\"Memory \" + verb); load(); }\n catch (e) { toast(\"Error: \" + e.message); }\n}\n\nasync function runVerify(deep) {\n toast(deep ? \"Running deep verification\u2026\" : \"Verifying\u2026\");\n try {\n const r = await api(\"/api/verify\" + (deep ? \"?deep=1\" : \"\"), { method: \"POST\" });\n toast(`Checked ${r.checked}: ${r.verified} verified, ${r.stale} stale, ${r.decayed} decayed`);\n load();\n } catch (e) { toast(\"Error: \" + e.message); }\n}\n\n// ---------------------------------------------------------------- new memory\n\nfunction addEvidenceRow() {\n const row = document.createElement(\"div\");\n row.className = \"ev-row\";\n row.innerHTML = `\n <select>\n <option>STATIC_CHECK</option><option>COMMIT_REF</option>\n <option>TEST_RESULT</option><option>EXEC_TRACE</option><option>HUMAN_ATTESTED</option>\n </select>\n <input placeholder=\"payload\">\n <button onclick=\"this.parentElement.remove()\">\u2715</button>`;\n document.getElementById(\"nm-evidence\").appendChild(row);\n}\n\nfunction toggleGuardrailLevel() {\n const kind = document.getElementById(\"nm-kind\").value;\n const section = document.getElementById(\"guardrail-section\");\n section.style.display = kind === \"GUARDRAIL\" ? \"block\" : \"none\";\n}\n\nasync function saveMemory() {\n const claim = document.getElementById(\"nm-claim\").value.trim();\n if (claim.length < 10) { toast(\"Claim is too short\"); return; }\n const kind = document.getElementById(\"nm-kind\").value;\n const evidence = [...document.querySelectorAll(\"#nm-evidence .ev-row\")]\n .map(r => ({ type: r.querySelector(\"select\").value, payload: r.querySelector(\"input\").value.trim() }))\n .filter(e => e.payload);\n const paths = document.getElementById(\"nm-paths\").value.split(\",\").map(s => s.trim()).filter(Boolean);\n const symbols = document.getElementById(\"nm-symbols\").value.split(\",\").map(s => s.trim()).filter(Boolean);\n const pinned = document.getElementById(\"nm-pinned\").checked;\n const guardrailLevel = kind === \"GUARDRAIL\" ? document.getElementById(\"nm-guardrail-level\").value : undefined;\n \n try {\n await api(\"/api/memories\", {\n method: \"POST\",\n body: JSON.stringify({ kind, claim, paths, symbols, evidence, pinned, guardrailLevel }),\n });\n document.getElementById(\"dlg-new\").close();\n document.getElementById(\"nm-claim\").value = \"\";\n document.getElementById(\"nm-paths\").value = \"\";\n document.getElementById(\"nm-symbols\").value = \"\";\n document.getElementById(\"nm-pinned\").checked = false;\n document.getElementById(\"nm-evidence\").innerHTML = \"\";\n toast(\"Memory saved\");\n load();\n } catch (e) { toast(\"Error: \" + e.message); }\n}\n\n// ---------------------------------------------------------------- search\n\nlet searchTimer = null;\nfunction debouncedSearch() {\n clearTimeout(searchTimer);\n searchTimer = setTimeout(doSearch, 300);\n}\n\nasync function doSearch() {\n const q = document.getElementById(\"q\").value.trim();\n const kind = document.getElementById(\"q-kind\").value;\n if (!q && !kind) { renderMemories(); return; }\n try {\n const r = await api(`/api/search?q=${encodeURIComponent(q)}&kind=${encodeURIComponent(kind)}`);\n renderMemories(r.results);\n if (q && !r.semantic) toast(\"Keyword match only \u2014 configure embeddings for semantic search\");\n } catch (e) { toast(\"Error: \" + e.message); }\n}\n\n// ---------------------------------------------------------------- mine / sync / reindex\n\nasync function runMine(ev) {\n const full = ev && ev.shiftKey;\n toast(full ? \"Rescanning full git history\u2026\" : \"Mining git history\u2026\");\n try {\n const r = await api(\"/api/mine\" + (full ? \"?full=1\" : \"\"), { method: \"POST\" });\n if (r.noCommits) {\n toast(\"No git commits yet \u2014 make an initial commit, then try again.\");\n return;\n }\n if (r.noNewCommits) {\n toast(\"No new commits since the last mine \u2014 Shift+click Mine commits to rescan all history.\");\n return;\n }\n if (r.scanned > 0 && r.proposed === 0) {\n toast(\"Scanned \" + r.scanned + \" commit(s): none matched memory-worthy signals (try descriptive messages or dim mine --llm)\");\n return;\n }\n toast(`Scanned ${r.scanned} commit(s): ${r.proposed} proposal(s) queued`);\n load();\n } catch (e) { toast(\"Error: \" + e.message); }\n}\n\nasync function runSync() {\n toast(\"Syncing\u2026\");\n try {\n const r = await api(\"/api/sync\", { method: \"POST\" });\n const mem = (n) => n + (n === 1 ? \" memory\" : \" memories\");\n let msg;\n if (r.memoriesPushed) msg = \"Sent \" + mem(r.memoriesPushed);\n else if (r.memoriesQueued) msg = \"Already on server (\" + mem(r.memoriesQueued) + \" unchanged)\";\n else msg = \"Nothing to send\";\n if (r.applied) msg += \", received \" + r.applied + \" update\" + (r.applied === 1 ? \"\" : \"s\");\n else if (r.pulled) msg += \", pulled \" + r.pulled + \" (already up to date locally)\";\n else msg += \", nothing new from team\";\n if (r.needsFullUploadConfirm) {\n msg += \" \u2014 run dim sync in terminal to confirm upload\";\n }\n toast(msg);\n load();\n } catch (e) { toast(\"Sync: \" + e.message); }\n}\n\nasync function runReindex() {\n toast(\"Reindexing embeddings\u2026\");\n try {\n const r = await api(\"/api/reindex\", { method: \"POST\" });\n toast(r.provider ? `Indexed ${r.indexed} with ${r.provider}` : \"No embedding provider \u2014 run Ollama or set OPENAI_API_KEY\");\n } catch (e) { toast(\"Error: \" + e.message); }\n}\n\n// ---------------------------------------------------------------- cloud + keys\n\nfunction openCloud() {\n const c = state && state.cloud;\n document.getElementById(\"cloud-status\").textContent = c\n ? `Linked: ${c.server} \u2192 brain '${c.brain}' (${c.hasToken ? \"token stored\" : \"\u26A0 NO TOKEN \u2014 paste API key and Link\"})`\n : \"Not linked to a team server yet. For local aidimag-cloud dev use http://localhost:3000\";\n if (c) {\n document.getElementById(\"cl-server\").value = c.server;\n document.getElementById(\"cl-brain\").value = c.brain;\n }\n document.getElementById(\"dlg-cloud\").showModal();\n}\n\nasync function cloudLink() {\n try {\n await api(\"/api/cloud/link\", {\n method: \"POST\",\n body: JSON.stringify({\n server: document.getElementById(\"cl-server\").value.trim(),\n brain: document.getElementById(\"cl-brain\").value.trim(),\n token: document.getElementById(\"cl-token\").value.trim() || undefined,\n }),\n });\n document.getElementById(\"cl-token\").value = \"\";\n toast(\"Linked \u2014 use Sync to exchange memory\");\n load(); openCloud();\n } catch (e) { toast(\"Error: \" + e.message); }\n}\n\nasync function cloudUnlink() {\n try { await api(\"/api/cloud/unlink\", { method: \"POST\" }); toast(\"Unlinked\"); load(); openCloud(); }\n catch (e) { toast(\"Error: \" + e.message); }\n}\n\nfunction keyParams() {\n return {\n server: document.getElementById(\"cl-server\").value.trim(),\n adminToken: document.getElementById(\"k-admin\").value.trim(),\n };\n}\n\nasync function keyCreate() {\n const p = keyParams();\n if (!p.adminToken) { toast(\"Admin token required\"); return; }\n try {\n const r = await api(\"/api/keys\", {\n method: \"POST\",\n body: JSON.stringify({ ...p, brain: document.getElementById(\"k-brain\").value.trim(), label: document.getElementById(\"k-label\").value.trim() || undefined }),\n });\n document.getElementById(\"keys-out\").innerHTML =\n `<div class=\"hint\">New key (shown once \u2014 copy now):</div><div class=\"keyrow\"><span>${esc(r.key)}</span><button onclick=\"navigator.clipboard.writeText('${esc(r.key)}').then(()=>toast('Copied'))\">Copy</button></div>`;\n } catch (e) { toast(\"Error: \" + e.message); }\n}\n\nasync function keyList() {\n const p = keyParams();\n if (!p.adminToken) { toast(\"Admin token required\"); return; }\n try {\n const r = await api(`/api/keys?server=${encodeURIComponent(p.server)}`, {\n headers: { \"X-Aidimag-Admin-Token\": p.adminToken },\n });\n document.getElementById(\"keys-out\").innerHTML = r.keys.length\n ? r.keys.map(k => `<div class=\"keyrow\"><span>${k.revoked_at ? \"\u2717\" : \"\u2713\"} ${esc(k.key)} \u2192 ${esc(k.brain)}${k.label ? \" (\" + esc(k.label) + \")\" : \"\"}</span></div>`).join(\"\")\n : '<div class=\"hint\">No keys yet.</div>';\n } catch (e) { toast(\"Error: \" + e.message); }\n}\n\n// ---------------------------------------------------------------- tickets\n\nconst TK_HINTS = {\n jira: { url: \"Jira site URL\", urlPh: \"https://acme.atlassian.net\", token: \"email:apiToken (or a PAT)\", needUrl: true, needToken: true },\n github: { url: \"Repo URL\", urlPh: \"https://github.com/acme/api\", token: \"GitHub token (repo read)\", needUrl: true, needToken: true },\n linear: { url: \"\", urlPh: \"\", token: \"Linear API key\", needUrl: false, needToken: true },\n http: { url: \"Middleware endpoint (GET /ticket/:id)\", urlPh: \"https://tickets.internal.acme.com\", token: \"Bearer token (optional)\", needUrl: true, needToken: true },\n remote: { url: \"\", urlPh: \"\", token: \"\", needUrl: false, needToken: false },\n};\n\nfunction ticketsProviderHint() {\n const h = TK_HINTS[document.getElementById(\"tk-provider\").value];\n document.getElementById(\"tk-url-row\").style.display = h.needUrl ? \"\" : \"none\";\n document.getElementById(\"tk-token-row\").style.display = h.needToken ? \"\" : \"none\";\n if (h.needUrl) {\n document.getElementById(\"tk-url-label\").textContent = h.url;\n document.getElementById(\"tk-url\").placeholder = h.urlPh;\n }\n if (h.needToken) document.getElementById(\"tk-token-label\").textContent = h.token + \" \u2014 stored on this machine only, never in the repo\";\n}\n\nfunction openTickets() {\n const t = state && state.tickets;\n document.getElementById(\"tk-status\").textContent = t\n ? `Connected: ${t.provider}${t.baseUrl ? \" at \" + t.baseUrl : \"\"} (${t.hasCredential ? \"credential stored\" : \"\u26A0 NO CREDENTIAL\"})`\n : \"No ticketing app connected \u2014 proposals will miss the why from your tickets.\";\n if (t) {\n document.getElementById(\"tk-provider\").value = t.provider;\n if (t.baseUrl) document.getElementById(\"tk-url\").value = t.baseUrl;\n document.getElementById(\"tk-pattern\").value = t.pattern || \"\";\n }\n ticketsProviderHint();\n document.getElementById(\"dlg-tickets\").showModal();\n}\n\nasync function ticketsConnect() {\n try {\n const r = await api(\"/api/tickets/connect\", {\n method: \"POST\",\n body: JSON.stringify({\n provider: document.getElementById(\"tk-provider\").value,\n baseUrl: document.getElementById(\"tk-url\").value.trim() || undefined,\n token: document.getElementById(\"tk-token\").value.trim() || undefined,\n pattern: document.getElementById(\"tk-pattern\").value.trim() || undefined,\n testId: document.getElementById(\"tk-test\").value.trim() || undefined,\n }),\n });\n document.getElementById(\"tk-token\").value = \"\";\n toast(r.validated ? `Connected \u2713 validated with ${r.validated.id}: ${r.validated.title}` : \"Tickets connected\");\n load(); openTickets();\n } catch (e) { toast(\"Error: \" + e.message); }\n}\n\nasync function ticketsDisconnect() {\n try { await api(\"/api/tickets/disconnect\", { method: \"POST\" }); toast(\"Tickets disconnected\"); load(); openTickets(); }\n catch (e) { toast(\"Error: \" + e.message); }\n}\n\nasync function ticketsShare(remove) {\n const adminToken = document.getElementById(\"tk-admin\").value.trim();\n if (!adminToken) { toast(\"Admin token required\"); return; }\n try {\n await api(\"/api/tickets/share\", {\n method: \"POST\",\n body: JSON.stringify(remove ? { adminToken, remove: true } : {\n adminToken,\n provider: document.getElementById(\"tk-provider\").value,\n baseUrl: document.getElementById(\"tk-url\").value.trim() || undefined,\n credential: document.getElementById(\"tk-token\").value.trim() || undefined,\n }),\n });\n document.getElementById(\"tk-token\").value = \"\";\n toast(remove ? \"Team ticket config removed\" : \"Team credentials stored on the server \u2014 teammates use provider 'remote'\");\n } catch (e) { toast(\"Error: \" + e.message); }\n}\n\nlet sim = null;\nfunction renderGraph() {\n const container = document.getElementById(\"graph\");\n container.innerHTML = \"\";\n const W = container.clientWidth, H = container.clientHeight;\n\n const nodes = [], links = [], pathNodes = new Map();\n for (const m of state.memories) {\n nodes.push({ id: m.id, type: \"memory\", label: m.claim.slice(0, 36) + (m.claim.length > 36 ? \"\u2026\" : \"\"), status: m.status, conf: m.confidence });\n for (const p of m.scope.paths) {\n if (!pathNodes.has(p)) { pathNodes.set(p, { id: \"path:\" + p, type: \"path\", label: p }); }\n links.push({ source: m.id, target: \"path:\" + p, kind: \"scope\" });\n }\n for (const l of m.links) {\n if (l.fromId === m.id) links.push({ source: l.fromId, target: l.toId, kind: l.relation });\n }\n }\n nodes.push(...pathNodes.values());\n\n const svg = d3.select(container).append(\"svg\").attr(\"width\", W).attr(\"height\", H);\n const g = svg.append(\"g\");\n svg.call(d3.zoom().scaleExtent([0.3, 4]).on(\"zoom\", e => g.attr(\"transform\", e.transform)));\n\n if (sim) sim.stop();\n sim = d3.forceSimulation(nodes)\n .force(\"link\", d3.forceLink(links).id(d => d.id).distance(90))\n .force(\"charge\", d3.forceManyBody().strength(-220))\n .force(\"center\", d3.forceCenter(W / 2, H / 2))\n .force(\"collide\", d3.forceCollide(28));\n\n const palette = graphPalette();\n\n const link = g.append(\"g\").selectAll(\"line\").data(links).join(\"line\")\n .attr(\"stroke\", d => d.kind === \"contradicts\" ? palette.REFUTED : palette.link)\n .attr(\"stroke-width\", 1.2);\n\n const node = g.append(\"g\").selectAll(\"g\").data(nodes).join(\"g\")\n .style(\"cursor\", \"pointer\")\n .call(d3.drag()\n .on(\"start\", (e, d) => { if (!e.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })\n .on(\"drag\", (e, d) => { d.fx = e.x; d.fy = e.y; })\n .on(\"end\", (e, d) => { if (!e.active) sim.alphaTarget(0); d.fx = null; d.fy = null; }));\n\n node.filter(d => d.type === \"memory\").append(\"circle\")\n .attr(\"r\", d => 7 + d.conf * 10)\n .attr(\"fill\", d => palette[d.status] || palette.UNVERIFIED)\n .attr(\"fill-opacity\", 0.85);\n node.filter(d => d.type === \"path\").append(\"rect\")\n .attr(\"x\", -7).attr(\"y\", -7).attr(\"width\", 14).attr(\"height\", 14).attr(\"rx\", 3)\n .attr(\"fill\", palette.path).attr(\"fill-opacity\", 0.85);\n\n node.append(\"text\").attr(\"dy\", d => d.type === \"memory\" ? 7 + d.conf * 10 + 12 : 22).attr(\"text-anchor\", \"middle\").text(d => d.label);\n\n node.on(\"click\", (e, d) => {\n if (d.type !== \"memory\") return;\n const card = document.getElementById(\"mem-\" + d.id);\n if (card) { card.scrollIntoView({ behavior: \"smooth\", block: \"center\" }); card.style.outline = \"2px solid \" + palette.primary; setTimeout(() => card.style.outline = \"\", 1500); }\n });\n\n sim.on(\"tick\", () => {\n link.attr(\"x1\", d => d.source.x).attr(\"y1\", d => d.source.y).attr(\"x2\", d => d.target.x).attr(\"y2\", d => d.target.y);\n node.attr(\"transform\", d => `translate(${d.x},${d.y})`);\n });\n}\n\nwindow.addEventListener(\"resize\", () => state && renderGraph());\nload();\n</script>\n</body>\n</html>";
|
|
5
|
+
export declare const PAGE_HTML = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>aiDimag \u2014 repo brain</title>\n<script src=\"https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js\"></script>\n<meta name=\"color-scheme\" content=\"light dark\">\n<style>\n :root {\n --background: 210 40% 98%;\n --foreground: 222 47% 11%;\n --card: 0 0% 100%;\n --muted: 214 32% 94%;\n --muted-foreground: 215 16% 47%;\n --primary: 217 91% 53%;\n --primary-foreground: 0 0% 100%;\n --secondary: 214 32% 94%;\n --border: 214 32% 91%;\n --ring: 199 89% 48%;\n --radius: 0.75rem;\n --surface-glow: 0 0 0 1px rgba(37, 99, 235, 0.08), 0 8px 32px rgba(37, 99, 235, 0.08);\n --verified: #22c55e;\n --unverified: #64748b;\n --stale: #eab308;\n --refuted: #ef4444;\n --path: #2563eb;\n }\n .dark {\n --background: 222 47% 6%;\n --foreground: 210 40% 98%;\n --card: 222 47% 8%;\n --muted: 217 33% 14%;\n --muted-foreground: 215 20% 65%;\n --primary: 213 94% 68%;\n --primary-foreground: 222 47% 6%;\n --secondary: 217 33% 14%;\n --border: 217 33% 16%;\n --surface-glow: 0 0 0 1px rgba(96, 165, 250, 0.12), 0 8px 32px rgba(0, 0, 0, 0.35);\n --path: #60a5fa;\n --unverified: #94a3b8;\n }\n * { box-sizing: border-box; margin: 0; }\n html { color-scheme: light dark; }\n body {\n background-color: hsl(var(--background));\n background-image:\n radial-gradient(at 0% 0%, rgba(37, 99, 235, 0.12) 0, transparent 50%),\n radial-gradient(at 100% 0%, rgba(14, 165, 233, 0.1) 0, transparent 50%),\n radial-gradient(at 50% 100%, rgba(6, 182, 212, 0.08) 0, transparent 50%);\n color: hsl(var(--foreground));\n font: 14px/1.5 \"Inter\", ui-sans-serif, system-ui, sans-serif;\n font-feature-settings: \"cv02\", \"cv03\", \"cv04\", \"cv11\";\n height: 100vh;\n display: flex;\n flex-direction: column;\n -webkit-font-smoothing: antialiased;\n }\n html.dark body {\n background-image:\n radial-gradient(at 0% 0%, rgba(37, 99, 235, 0.18) 0, transparent 50%),\n radial-gradient(at 100% 0%, rgba(14, 165, 233, 0.12) 0, transparent 50%),\n radial-gradient(at 50% 100%, rgba(6, 182, 212, 0.1) 0, transparent 50%);\n }\n header {\n display: flex; align-items: center; gap: 8px 12px; flex-wrap: wrap;\n padding: 10px 16px;\n border-bottom: 1px solid hsl(var(--border) / 0.6);\n background: hsl(var(--card) / 0.82);\n backdrop-filter: blur(16px);\n }\n .brand { display: flex; align-items: center; gap: 10px; min-width: 0; }\n .logo { width: 32px; height: 32px; flex-shrink: 0; border-radius: 10px; }\n .brand-text { min-width: 0; }\n header h1 { font-size: 15px; font-weight: 700; letter-spacing: -0.02em; line-height: 1.2; }\n header .subtitle {\n display: block; font-size: 11px; font-weight: 500; color: hsl(var(--muted-foreground));\n white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 280px;\n }\n .pill {\n padding: 3px 10px; border-radius: 999px; font-size: 12px;\n border: 1px solid hsl(var(--border)); white-space: nowrap;\n background: hsl(var(--muted) / 0.6); color: hsl(var(--muted-foreground));\n }\n .pill b { font-weight: 600; color: hsl(var(--foreground)); }\n .spacer { flex: 1; }\n .toolbar { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }\n button {\n display: inline-flex; align-items: center; justify-content: center; gap: 6px; white-space: nowrap;\n background: hsl(var(--secondary)); color: hsl(var(--foreground));\n border: 1px solid hsl(var(--border)); border-radius: calc(var(--radius) - 2px);\n padding: 6px 12px; font-size: 12px; font-weight: 500; cursor: pointer;\n transition: background 0.15s, border-color 0.15s, opacity 0.15s;\n }\n button svg { width: 14px; height: 14px; flex: 0 0 auto; }\n button:hover:not(.primary) { background: hsl(var(--muted)); border-color: hsl(var(--primary) / 0.35); }\n button:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 2px; }\n button.primary {\n background: hsl(var(--primary)); border-color: transparent;\n color: hsl(var(--primary-foreground));\n }\n button.primary:hover { opacity: 0.9; background: hsl(var(--primary)); border-color: transparent; }\n button.icon { padding: 8px; width: 36px; height: 36px; }\n button.danger:hover { background: color-mix(in srgb, var(--refuted) 12%, transparent); border-color: var(--refuted); }\n main { flex: 1; display: flex; min-height: 0; }\n #graph { flex: 1; min-width: 0; background: hsl(var(--background) / 0.35); }\n aside {\n width: 460px; border-left: 1px solid hsl(var(--border) / 0.6);\n overflow-y: auto; padding: 16px;\n background: hsl(var(--card) / 0.55); backdrop-filter: blur(12px);\n }\n h2 { font-size: 14px; font-weight: 600; color: hsl(var(--foreground)); margin: 16px 0 10px; letter-spacing: -0.01em; }\n .card {\n background: hsl(var(--card) / 0.9);\n border: 1px solid hsl(var(--border) / 0.6);\n border-radius: var(--radius); padding: 12px 14px; margin-bottom: 10px;\n box-shadow: var(--surface-glow);\n transition: border-color 0.15s, transform 0.15s;\n }\n .card:hover { border-color: hsl(var(--primary) / 0.3); }\n .card .claim { font-size: 13px; margin-bottom: 6px; line-height: 1.5; }\n .card .meta { font-size: 11px; color: hsl(var(--muted-foreground)); display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }\n .badge { padding: 2px 8px; border-radius: 999px; font-size: 10px; font-weight: 600; border: 1px solid transparent; }\n .badge.VERIFIED { background: color-mix(in srgb, var(--verified) 15%, transparent); color: var(--verified); }\n .badge.UNVERIFIED { background: color-mix(in srgb, var(--unverified) 15%, transparent); color: var(--unverified); }\n .badge.STALE { background: color-mix(in srgb, var(--stale) 15%, transparent); color: var(--stale); }\n .badge.REFUTED { background: color-mix(in srgb, var(--refuted) 15%, transparent); color: var(--refuted); }\n .kind { color: hsl(var(--primary)); font-weight: 500; }\n .actions { margin-top: 10px; display: flex; gap: 6px; flex-wrap: wrap; }\n .evidence { font-size: 11px; color: hsl(var(--muted-foreground)); font-family: ui-monospace, monospace; margin-top: 4px; word-break: break-all; }\n .legend {\n display: flex; gap: 14px; flex-wrap: wrap; padding: 8px 16px; font-size: 11px;\n color: hsl(var(--muted-foreground)); border-top: 1px solid hsl(var(--border) / 0.6);\n background: hsl(var(--card) / 0.65); backdrop-filter: blur(12px);\n }\n .dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 5px; vertical-align: -1px; }\n #toast {\n position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);\n background: hsl(var(--card)); color: hsl(var(--foreground));\n border: 1px solid hsl(var(--border) / 0.6);\n padding: 10px 18px;\n border-radius: var(--radius); display: none; font-size: 13px; font-weight: 500; z-index: 50;\n box-shadow: var(--surface-glow);\n }\n .empty { color: hsl(var(--muted-foreground)); font-size: 12px; padding: 8px 0; }\n svg text { fill: hsl(var(--muted-foreground)); font-size: 10px; pointer-events: none; }\n dialog {\n background: hsl(var(--card)); color: hsl(var(--foreground));\n border: 1px solid hsl(var(--border) / 0.6);\n border-radius: calc(var(--radius) + 2px); padding: 20px; width: 480px; max-width: 92vw;\n box-shadow: var(--surface-glow);\n }\n dialog::backdrop { background: rgba(0,0,0,.55); backdrop-filter: blur(4px); }\n dialog h3 { font-size: 15px; font-weight: 700; margin-bottom: 12px; letter-spacing: -0.02em; }\n dialog label {\n display: block; font-size: 12px; color: hsl(var(--muted-foreground));\n margin: 12px 0 4px; font-weight: 500;\n }\n dialog input, dialog select, dialog textarea {\n width: 100%; background: hsl(var(--background)); color: hsl(var(--foreground));\n border: 1px solid hsl(var(--border)); border-radius: calc(var(--radius) - 2px);\n padding: 8px 10px; font-size: 13px; font-family: inherit;\n }\n dialog input:focus, dialog select:focus, dialog textarea:focus {\n outline: 2px solid hsl(var(--ring)); outline-offset: 1px; border-color: transparent;\n }\n dialog textarea { min-height: 64px; resize: vertical; }\n .dialog-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }\n .ev-row { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; }\n .ev-row select { width: 160px; }\n .searchbar { display: flex; gap: 6px; margin-bottom: 10px; }\n .searchbar input {\n flex: 1; background: hsl(var(--background)); color: hsl(var(--foreground));\n border: 1px solid hsl(var(--border)); border-radius: calc(var(--radius) - 2px);\n padding: 8px 10px; font-size: 13px;\n }\n .searchbar input:focus { outline: 2px solid hsl(var(--ring)); outline-offset: 1px; border-color: transparent; }\n .searchbar select {\n background: hsl(var(--background)); color: hsl(var(--foreground));\n border: 1px solid hsl(var(--border)); border-radius: calc(var(--radius) - 2px);\n font-size: 12px; padding: 6px 8px;\n }\n .keyrow {\n font-size: 11px; font-family: ui-monospace, monospace;\n display: flex; justify-content: space-between; align-items: center;\n padding: 4px 0; border-bottom: 1px solid hsl(var(--border));\n }\n .hint { font-size: 11px; color: hsl(var(--muted-foreground)); margin-top: 6px; line-height: 1.5; }\n .theme-icon-sun { display: none; }\n html:not(.dark) .theme-icon-sun { display: block; }\n html:not(.dark) .theme-icon-moon { display: none; }\n html.dark .theme-icon-sun { display: none; }\n html.dark .theme-icon-moon { display: block; }\n</style>\n<script>\n(function () {\n var k = \"aidimag-ui-theme\";\n var saved = localStorage.getItem(k);\n // Default dark; only use light when explicitly chosen.\n if (saved !== \"light\") document.documentElement.classList.add(\"dark\");\n})();\n</script>\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap\" rel=\"stylesheet\">\n</head>\n<body>\n<header>\n <div class=\"brand\">\n <svg class=\"logo\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 64 64\" fill=\"none\" aria-hidden=\"true\">\n <defs>\n <linearGradient id=\"dimGrad\" x1=\"8\" y1=\"8\" x2=\"56\" y2=\"56\" gradientUnits=\"userSpaceOnUse\">\n <stop offset=\"0%\" stop-color=\"#2563eb\"/><stop offset=\"55%\" stop-color=\"#0ea5e9\"/><stop offset=\"100%\" stop-color=\"#06b6d4\"/>\n </linearGradient>\n <linearGradient id=\"dimGradSoft\" x1=\"8\" y1=\"8\" x2=\"56\" y2=\"56\" gradientUnits=\"userSpaceOnUse\">\n <stop offset=\"0%\" stop-color=\"#2563eb\" stop-opacity=\"0.18\"/><stop offset=\"100%\" stop-color=\"#06b6d4\" stop-opacity=\"0.18\"/>\n </linearGradient>\n </defs>\n <rect x=\"2\" y=\"2\" width=\"60\" height=\"60\" rx=\"16\" fill=\"url(#dimGradSoft)\"/>\n <g transform=\"translate(8 8) scale(2)\" stroke=\"url(#dimGrad)\" stroke-width=\"1.9\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <path d=\"M12 18V5\"/><path d=\"M15 13a4.17 4.17 0 0 1-3-4 4.17 4.17 0 0 1-3 4\"/>\n <path d=\"M17.598 6.5A3 3 0 1 0 12 5a3 3 0 1 0-5.598 1.5\"/>\n <path d=\"M17.997 5.125a4 4 0 0 1 2.526 5.77\"/><path d=\"M18 18a4 4 0 0 0 2-7.464\"/>\n <path d=\"M19.967 17.483A4 4 0 1 1 12 18a4 4 0 1 1-7.967-.517\"/>\n <path d=\"M6 18a4 4 0 0 1-2-7.464\"/><path d=\"M6.003 5.125a4 4 0 0 0-2.526 5.77\"/>\n </g>\n <circle cx=\"49\" cy=\"49\" r=\"11\" fill=\"#10b981\"/>\n <path d=\"M44 49.2l3.4 3.4L54.5 45\" stroke=\"#ffffff\" stroke-width=\"2.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" fill=\"none\"/>\n </svg>\n <div class=\"brand-text\">\n <h1>aiDimag</h1>\n <span class=\"subtitle\" id=\"repo\">Repo brain</span>\n </div>\n </div>\n <span class=\"pill\" id=\"counts\"></span>\n <div class=\"spacer\"></div>\n <div class=\"toolbar\">\n <button class=\"primary\" onclick=\"document.getElementById('dlg-new').showModal()\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M5 12h14\"/><path d=\"M12 5v14\"/></svg>New memory</button>\n <button onclick=\"runMine()\" title=\"Mine new commits since the last run (Shift+click: rescan all history)\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M14.5 5.5 18 9\"/><path d=\"M2 22l8-8\"/><path d=\"M20.5 7.5 22 6a2.83 2.83 0 0 0-4-4l-1.5 1.5\"/><path d=\"m9 11 4 4\"/><path d=\"M16 2 8.5 9.5\"/></svg>Mine commits</button>\n <button class=\"primary\" onclick=\"runVerify(false)\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21.801 10A10 10 0 1 1 17 3.335\"/><path d=\"m9 11 3 3L22 4\"/></svg>Verify</button>\n <button onclick=\"runVerify(true)\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"11\" cy=\"11\" r=\"8\"/><path d=\"m21 21-4.3-4.3\"/><path d=\"m8 11 2 2 4-4\"/></svg>Verify --deep</button>\n <button onclick=\"runSync()\" id=\"btn-sync\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8\"/><path d=\"M3 3v5h5\"/><path d=\"M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16\"/><path d=\"M16 16h5v5\"/></svg>Sync</button>\n <button onclick=\"runReindex()\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><ellipse cx=\"12\" cy=\"5\" rx=\"9\" ry=\"3\"/><path d=\"M3 5v14a9 3 0 0 0 18 0V5\"/><path d=\"M3 12a9 3 0 0 0 18 0\"/></svg>Reindex</button>\n <button onclick=\"openCloud()\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z\"/></svg>Cloud</button>\n <button onclick=\"openTickets()\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z\"/><path d=\"M13 5v2\"/><path d=\"M13 17v2\"/><path d=\"M13 11v2\"/></svg>Tickets</button>\n <button onclick=\"load()\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8\"/><path d=\"M21 3v5h-5\"/><path d=\"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16\"/><path d=\"M8 16H3v5\"/></svg>Refresh</button>\n <button class=\"icon\" type=\"button\" onclick=\"toggleTheme()\" id=\"btn-theme\" aria-label=\"Toggle light/dark theme\">\n <svg class=\"theme-icon-sun\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"4\"/><path d=\"M12 2v2\"/><path d=\"M12 20v2\"/><path d=\"m4.93 4.93 1.41 1.41\"/><path d=\"m17.66 17.66 1.41 1.41\"/><path d=\"M2 12h2\"/><path d=\"M20 12h2\"/><path d=\"m6.34 17.66-1.41 1.41\"/><path d=\"m19.07 4.93-1.41 1.41\"/></svg>\n <svg class=\"theme-icon-moon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z\"/></svg>\n </button>\n </div>\n</header>\n<main>\n <div id=\"graph\"></div>\n <aside>\n <h2 id=\"proposals-h\">Pending proposals</h2>\n <div id=\"proposals\"></div>\n <h2>Memories</h2>\n <div class=\"searchbar\">\n <input id=\"q\" placeholder=\"Search memories\u2026 (semantic when embeddings configured)\" oninput=\"debouncedSearch()\">\n <select id=\"q-kind\" onchange=\"doSearch()\">\n <option value=\"\">all kinds</option>\n <option>DECISION</option><option>CONVENTION</option><option>GOTCHA</option>\n <option>FAILED_APPROACH</option><option>ARCHITECTURE</option><option>INVARIANT</option>\n <option>TODO_CONTEXT</option><option>GUARDRAIL</option><option>SKILL</option>\n </select>\n </div>\n <div id=\"memories\"></div>\n </aside>\n</main>\n\n<!-- New memory dialog (dim remember) -->\n<dialog id=\"dlg-new\">\n <h3>\uFF0B New memory</h3>\n <label>Claim (write it falsifiable \u2014 something a check could verify)</label>\n <textarea id=\"nm-claim\" placeholder=\"All DB access goes through src/db/store.ts; nothing else imports better-sqlite3\"></textarea>\n <label>Kind</label>\n <select id=\"nm-kind\" onchange=\"toggleGuardrailLevel()\">\n <option>DECISION</option><option>CONVENTION</option><option>GOTCHA</option>\n <option>FAILED_APPROACH</option><option>ARCHITECTURE</option><option>INVARIANT</option>\n <option>TODO_CONTEXT</option><option>GUARDRAIL</option><option>SKILL</option>\n </select>\n <div id=\"guardrail-section\" style=\"display:none;\">\n <label>Guardrail Level</label>\n <select id=\"nm-guardrail-level\">\n <option value=\"ask-first\">\uD83E\uDD1A Ask First - Confirm before doing it</option>\n <option value=\"always\">\u2705 Always - Block completely, refuse to proceed</option>\n <option value=\"never\">\uD83D\uDEAB Never - Just a suggestion</option>\n </select>\n </div>\n <label>Scope paths (comma-separated, empty = repo-wide)</label>\n <input id=\"nm-paths\" placeholder=\"src/db, src/api/auth.ts\">\n <label>Symbols (comma-separated, optional)</label>\n <input id=\"nm-symbols\" placeholder=\"UserService, authenticate()\">\n <label>\n <input type=\"checkbox\" id=\"nm-pinned\">\n \uD83D\uDCCC Pin this memory (exempt from time decay)\n </label>\n <label>Evidence (optional but recommended)</label>\n <div id=\"nm-evidence\"></div>\n <button style=\"margin-top:6px\" onclick=\"addEvidenceRow()\">\uFF0B add evidence</button>\n <div class=\"hint\">STATIC_CHECK: shell command, exit 0 = claim holds \u00B7 COMMIT_REF: sha \u00B7 EXEC_TRACE: cmd :: regex \u00B7 TEST_RESULT: test cmd</div>\n <div class=\"dialog-actions\">\n <button onclick=\"document.getElementById('dlg-new').close()\">Cancel</button>\n <button class=\"primary\" onclick=\"saveMemory()\">Save memory</button>\n </div>\n</dialog>\n\n<!-- Cloud settings dialog (dim cloud link / dim keys) -->\n<dialog id=\"dlg-cloud\">\n <h3>\u2601 Team sync</h3>\n <div id=\"cloud-status\" class=\"hint\"></div>\n <label>Server URL</label>\n <input id=\"cl-server\" placeholder=\"http://localhost:3000\">\n <label>Brain (team memory name)</label>\n <input id=\"cl-brain\" placeholder=\"myrepo\">\n <label>Access token (stored on this machine only, never in the repo)</label>\n <input id=\"cl-token\" type=\"password\" placeholder=\"aidimag_sk_\u2026\">\n <div class=\"dialog-actions\">\n <button onclick=\"cloudUnlink()\">Unlink</button>\n <button class=\"primary\" onclick=\"cloudLink()\">Link</button>\n </div>\n <h3 style=\"margin-top:18px\">\uD83D\uDD11 API keys (admin)</h3>\n <label>Admin token (used for this request only \u2014 not stored)</label>\n <input id=\"k-admin\" type=\"password\" placeholder=\"server admin token\">\n <div class=\"ev-row\">\n <input id=\"k-brain\" placeholder=\"brain\">\n <input id=\"k-label\" placeholder=\"label (alice-laptop)\">\n <button class=\"primary\" onclick=\"keyCreate()\">Create</button>\n <button onclick=\"keyList()\">List</button>\n </div>\n <div id=\"keys-out\"></div>\n <div class=\"dialog-actions\">\n <button onclick=\"document.getElementById('dlg-cloud').close()\">Close</button>\n </div>\n</dialog>\n\n<!-- Tickets dialog (dim ticket connect / share) -->\n<dialog id=\"dlg-tickets\">\n <h3>\uD83C\uDFAB Tickets</h3>\n <div id=\"tk-status\" class=\"hint\"></div>\n <label>Provider</label>\n <select id=\"tk-provider\" onchange=\"ticketsProviderHint()\">\n <option value=\"jira\">Jira</option>\n <option value=\"github\">GitHub Issues</option>\n <option value=\"linear\">Linear</option>\n <option value=\"http\">HTTP middleware (your own)</option>\n <option value=\"remote\">Remote (team sync server \u2014 zero local credentials)</option>\n </select>\n <div id=\"tk-url-row\">\n <label id=\"tk-url-label\">Base URL</label>\n <input id=\"tk-url\" placeholder=\"https://acme.atlassian.net\">\n </div>\n <div id=\"tk-token-row\">\n <label id=\"tk-token-label\">Credential (stored on this machine only, never in the repo)</label>\n <input id=\"tk-token\" type=\"password\" placeholder=\"email:apiToken\">\n </div>\n <label>Ticket-id pattern (extracted from branch names & commit messages)</label>\n <input id=\"tk-pattern\" placeholder=\"[A-Z][A-Z0-9]+-\\\\d+\">\n <label>Validate with a real ticket id (optional)</label>\n <input id=\"tk-test\" placeholder=\"XXX-2100\">\n <div class=\"dialog-actions\">\n <button onclick=\"ticketsDisconnect()\">Disconnect</button>\n <button class=\"primary\" onclick=\"ticketsConnect()\">Connect</button>\n </div>\n <h3 style=\"margin-top:18px\">\uD83D\uDC65 Team credentials (admin)</h3>\n <div class=\"hint\">Stores the provider + token on the linked sync server \u2014 teammates connect with provider \u201Cremote\u201D and never hold a ticket credential.</div>\n <label>Admin token (used for this request only \u2014 not stored)</label>\n <input id=\"tk-admin\" type=\"password\" placeholder=\"server admin token\">\n <div class=\"ev-row\">\n <button class=\"primary\" onclick=\"ticketsShare()\">Share current config</button>\n <button class=\"danger\" onclick=\"ticketsShare(true)\">Remove from server</button>\n </div>\n <div class=\"dialog-actions\">\n <button onclick=\"document.getElementById('dlg-tickets').close()\">Close</button>\n </div>\n</dialog>\n\n<div class=\"legend\">\n <span><span class=\"dot\" style=\"background:var(--verified)\"></span>VERIFIED</span>\n <span><span class=\"dot\" style=\"background:var(--unverified)\"></span>UNVERIFIED</span>\n <span><span class=\"dot\" style=\"background:var(--stale)\"></span>STALE</span>\n <span><span class=\"dot\" style=\"background:var(--refuted)\"></span>REFUTED</span>\n <span><span class=\"dot\" style=\"background:var(--path); border-radius:2px\"></span>scope path</span>\n <span style=\"margin-left:auto\">node size = confidence \u00B7 drag to rearrange \u00B7 scroll to zoom</span>\n</div>\n<div id=\"toast\"></div>\n\n<script>\nconst COLORS = { VERIFIED: \"#22c55e\", UNVERIFIED: \"#94a3b8\", STALE: \"#eab308\", REFUTED: \"#ef4444\" };\nlet state = null;\nlet csrfToken = null;\n\nfunction cssVar(name, fallback) {\n const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();\n return v || fallback;\n}\n\nfunction graphPalette() {\n return {\n VERIFIED: cssVar(\"--verified\", COLORS.VERIFIED),\n UNVERIFIED: cssVar(\"--unverified\", COLORS.UNVERIFIED),\n STALE: cssVar(\"--stale\", COLORS.STALE),\n REFUTED: cssVar(\"--refuted\", COLORS.REFUTED),\n path: cssVar(\"--path\", \"#60a5fa\"),\n link: \"hsl(\" + cssVar(\"--border\", \"217 33% 16%\") + \")\",\n primary: \"hsl(\" + cssVar(\"--primary\", \"217 91% 53%\") + \")\",\n };\n}\n\nfunction toggleTheme() {\n const root = document.documentElement;\n const dark = !root.classList.contains(\"dark\");\n root.classList.toggle(\"dark\", dark);\n localStorage.setItem(\"aidimag-ui-theme\", dark ? \"dark\" : \"light\");\n if (state) renderGraph();\n}\n\nfunction toast(msg) {\n const t = document.getElementById(\"toast\");\n t.textContent = msg; t.style.display = \"block\";\n setTimeout(() => t.style.display = \"none\", 2500);\n}\n\nasync function api(path, opts) {\n opts = opts || {};\n const method = (opts.method || \"GET\").toUpperCase();\n if (method !== \"GET\" && method !== \"HEAD\") {\n if (!csrfToken) throw new Error(\"missing CSRF token \u2014 reload the page\");\n opts.headers = { ...(opts.headers || {}), \"X-Aidimag-Csrf-Token\": csrfToken };\n }\n const r = await fetch(path, opts);\n const body = await r.json();\n if (!r.ok) throw new Error(body.error || r.status);\n return body;\n}\n\nasync function load() {\n state = await api(\"/api/state\");\n csrfToken = state.csrfToken;\n document.getElementById(\"repo\").textContent = state.repoRoot;\n const s = state.summary.byStatus;\n document.getElementById(\"counts\").innerHTML =\n `<b>${state.summary.total}</b> memories \u00B7 \u2713${s.VERIFIED} ?${s.UNVERIFIED} ~${s.STALE} \u2717${s.REFUTED}`;\n renderProposals(); renderMemories(); renderGraph();\n}\n\nfunction esc(s) { return s.replace(/[&<>\"']/g, c => ({ \"&\":\"&\",\"<\":\"<\",\">\":\">\",'\"':\""\",\"'\":\"'\" }[c])); }\n\nfunction renderProposals() {\n const el = document.getElementById(\"proposals\");\n document.getElementById(\"proposals-h\").textContent = `Pending proposals (${state.proposals.length})`;\n if (!state.proposals.length) { el.innerHTML = '<div class=\"empty\">Queue is empty.</div>'; return; }\n el.innerHTML = state.proposals.map(p => `\n <div class=\"card\">\n <div class=\"claim\">${esc(p.claim)}</div>\n <div class=\"meta\"><span class=\"kind\">${p.kind}</span><span>via ${esc(p.source)}</span>${p.ticketRef ? `<span>\uD83C\uDFAB ${esc(p.ticketRef)}</span>` : \"\"}</div>\n <div class=\"actions\">\n <button class=\"primary\" onclick=\"act('/api/proposals/${p.id}/approve','approved')\">Approve</button>\n <button class=\"danger\" onclick=\"act('/api/proposals/${p.id}/reject','rejected')\">Reject</button>\n </div>\n </div>`).join(\"\");\n}\n\nfunction renderMemories(list) {\n const el = document.getElementById(\"memories\");\n const items = list ?? state.memories;\n if (!items.length) { el.innerHTML = '<div class=\"empty\">No matching memories.</div>'; return; }\n el.innerHTML = items.map(m => `\n <div class=\"card\" id=\"mem-${m.id}\">\n <div class=\"claim\">${esc(m.claim)}</div>\n <div class=\"meta\">\n <span class=\"badge ${m.status}\">${m.status}</span>\n ${m.pinned ? '<span class=\"badge\" title=\"Pinned: never decays with age (evidence failure can still mark it stale)\">\uD83D\uDCCC PINNED</span>' : \"\"}\n <span class=\"kind\">${m.kind}</span>\n <span>conf ${m.confidence.toFixed(2)}</span>\n ${m.scope.paths.length ? \"<span>\uD83D\uDCC1 \" + esc(m.scope.paths.join(\", \")) + \"</span>\" : \"<span>repo-wide</span>\"}\n </div>\n ${m.grounding.map(e => `<div class=\"evidence\">${e.type}(${e.result}) ${esc(e.payload)}</div>`).join(\"\")}\n <div class=\"actions\">\n ${m.pinned\n ? `<button onclick=\"act('/api/memories/${m.id}/unpin','unpinned')\">Unpin</button>`\n : `<button onclick=\"act('/api/memories/${m.id}/pin','pinned \uD83D\uDCCC')\">Pin</button>`}\n ${m.status !== \"REFUTED\" ? `<button class=\"danger\" onclick=\"act('/api/memories/${m.id}/refute','refuted')\">Refute</button>` : \"\"}\n <button class=\"danger\" onclick=\"if(confirm('Delete permanently?'))act('/api/memories/${m.id}/forget','forgotten')\">Forget</button>\n </div>\n </div>`).join(\"\");\n}\n\nasync function act(path, verb) {\n try { await api(path, { method: \"POST\" }); toast(\"Memory \" + verb); load(); }\n catch (e) { toast(\"Error: \" + e.message); }\n}\n\nasync function runVerify(deep) {\n toast(deep ? \"Running deep verification\u2026\" : \"Verifying\u2026\");\n try {\n const r = await api(\"/api/verify\" + (deep ? \"?deep=1\" : \"\"), { method: \"POST\" });\n toast(`Checked ${r.checked}: ${r.verified} verified, ${r.stale} stale, ${r.decayed} decayed`);\n load();\n } catch (e) { toast(\"Error: \" + e.message); }\n}\n\n// ---------------------------------------------------------------- new memory\n\nfunction addEvidenceRow() {\n const row = document.createElement(\"div\");\n row.className = \"ev-row\";\n row.innerHTML = `\n <select>\n <option>STATIC_CHECK</option><option>COMMIT_REF</option>\n <option>TEST_RESULT</option><option>EXEC_TRACE</option><option>HUMAN_ATTESTED</option>\n </select>\n <input placeholder=\"payload\">\n <button onclick=\"this.parentElement.remove()\">\u2715</button>`;\n document.getElementById(\"nm-evidence\").appendChild(row);\n}\n\nfunction toggleGuardrailLevel() {\n const kind = document.getElementById(\"nm-kind\").value;\n const section = document.getElementById(\"guardrail-section\");\n section.style.display = kind === \"GUARDRAIL\" ? \"block\" : \"none\";\n}\n\nasync function saveMemory() {\n const claim = document.getElementById(\"nm-claim\").value.trim();\n if (claim.length < 10) { toast(\"Claim is too short\"); return; }\n const kind = document.getElementById(\"nm-kind\").value;\n const evidence = [...document.querySelectorAll(\"#nm-evidence .ev-row\")]\n .map(r => ({ type: r.querySelector(\"select\").value, payload: r.querySelector(\"input\").value.trim() }))\n .filter(e => e.payload);\n const paths = document.getElementById(\"nm-paths\").value.split(\",\").map(s => s.trim()).filter(Boolean);\n const symbols = document.getElementById(\"nm-symbols\").value.split(\",\").map(s => s.trim()).filter(Boolean);\n const pinned = document.getElementById(\"nm-pinned\").checked;\n const guardrailLevel = kind === \"GUARDRAIL\" ? document.getElementById(\"nm-guardrail-level\").value : undefined;\n \n try {\n await api(\"/api/memories\", {\n method: \"POST\",\n body: JSON.stringify({ kind, claim, paths, symbols, evidence, pinned, guardrailLevel }),\n });\n document.getElementById(\"dlg-new\").close();\n document.getElementById(\"nm-claim\").value = \"\";\n document.getElementById(\"nm-paths\").value = \"\";\n document.getElementById(\"nm-symbols\").value = \"\";\n document.getElementById(\"nm-pinned\").checked = false;\n document.getElementById(\"nm-evidence\").innerHTML = \"\";\n toast(\"Memory saved\");\n load();\n } catch (e) { toast(\"Error: \" + e.message); }\n}\n\n// ---------------------------------------------------------------- search\n\nlet searchTimer = null;\nfunction debouncedSearch() {\n clearTimeout(searchTimer);\n searchTimer = setTimeout(doSearch, 300);\n}\n\nasync function doSearch() {\n const q = document.getElementById(\"q\").value.trim();\n const kind = document.getElementById(\"q-kind\").value;\n if (!q && !kind) { renderMemories(); return; }\n try {\n const r = await api(`/api/search?q=${encodeURIComponent(q)}&kind=${encodeURIComponent(kind)}`);\n renderMemories(r.results);\n if (q && !r.semantic) toast(\"Keyword match only \u2014 configure embeddings for semantic search\");\n } catch (e) { toast(\"Error: \" + e.message); }\n}\n\n// ---------------------------------------------------------------- mine / sync / reindex\n\nasync function runMine(ev) {\n const full = ev && ev.shiftKey;\n toast(full ? \"Rescanning full git history\u2026\" : \"Mining git history\u2026\");\n try {\n const r = await api(\"/api/mine\" + (full ? \"?full=1\" : \"\"), { method: \"POST\" });\n if (r.noCommits) {\n toast(\"No git commits yet \u2014 make an initial commit, then try again.\");\n return;\n }\n if (r.noNewCommits) {\n toast(\"No new commits since the last mine \u2014 Shift+click Mine commits to rescan all history.\");\n return;\n }\n if (r.scanned > 0 && r.proposed === 0) {\n toast(\"Scanned \" + r.scanned + \" commit(s): none matched memory-worthy signals (try descriptive messages or dim mine --llm)\");\n return;\n }\n toast(`Scanned ${r.scanned} commit(s): ${r.proposed} proposal(s) queued`);\n load();\n } catch (e) { toast(\"Error: \" + e.message); }\n}\n\nasync function runSync() {\n toast(\"Syncing\u2026\");\n try {\n const r = await api(\"/api/sync\", { method: \"POST\" });\n const mem = (n) => n + (n === 1 ? \" memory\" : \" memories\");\n let msg;\n if (r.autoRecovered) {\n msg = \"\u2713 Auto-recovery: restored memories from cloud\";\n if (r.applied) msg += \" (\" + r.applied + \" recovered)\";\n } else if (r.memoriesPushed) msg = \"Sent \" + mem(r.memoriesPushed);\n else if (r.memoriesQueued) msg = \"Already on server (\" + mem(r.memoriesQueued) + \" unchanged)\";\n else msg = \"Nothing to send\";\n if (!r.autoRecovered) {\n if (r.applied) msg += \", received \" + r.applied + \" update\" + (r.applied === 1 ? \"\" : \"s\");\n else if (r.pulled) msg += \", pulled \" + r.pulled + \" (already up to date locally)\";\n else msg += \", nothing new from team\";\n }\n if (r.needsFullUploadConfirm) {\n msg += \" \u2014 run dim sync in terminal to confirm upload\";\n }\n toast(msg);\n load();\n } catch (e) { toast(\"Sync: \" + e.message); }\n}\n\nasync function runReindex() {\n toast(\"Reindexing embeddings\u2026\");\n try {\n const r = await api(\"/api/reindex\", { method: \"POST\" });\n toast(r.provider ? `Indexed ${r.indexed} with ${r.provider}` : \"No embedding provider \u2014 run Ollama or set OPENAI_API_KEY\");\n } catch (e) { toast(\"Error: \" + e.message); }\n}\n\n// ---------------------------------------------------------------- cloud + keys\n\nfunction openCloud() {\n const c = state && state.cloud;\n document.getElementById(\"cloud-status\").textContent = c\n ? `Linked: ${c.server} \u2192 brain '${c.brain}' (${c.hasToken ? \"token stored\" : \"\u26A0 NO TOKEN \u2014 paste API key and Link\"})`\n : \"Not linked to a team server yet. For local aidimag-cloud dev use http://localhost:3000\";\n if (c) {\n document.getElementById(\"cl-server\").value = c.server;\n document.getElementById(\"cl-brain\").value = c.brain;\n } else {\n document.getElementById(\"cl-server\").value = \"\";\n document.getElementById(\"cl-brain\").value = \"\";\n }\n document.getElementById(\"cl-token\").value = \"\";\n document.getElementById(\"dlg-cloud\").showModal();\n}\n\nasync function cloudLink() {\n try {\n await api(\"/api/cloud/link\", {\n method: \"POST\",\n body: JSON.stringify({\n server: document.getElementById(\"cl-server\").value.trim(),\n brain: document.getElementById(\"cl-brain\").value.trim(),\n token: document.getElementById(\"cl-token\").value.trim() || undefined,\n }),\n });\n document.getElementById(\"cl-token\").value = \"\";\n toast(\"Linked \u2014 use Sync to exchange memory\");\n load(); openCloud();\n } catch (e) { toast(\"Error: \" + e.message); }\n}\n\nasync function cloudUnlink() {\n try { await api(\"/api/cloud/unlink\", { method: \"POST\" }); toast(\"Unlinked\"); load(); openCloud(); }\n catch (e) { toast(\"Error: \" + e.message); }\n}\n\nfunction keyParams() {\n return {\n server: document.getElementById(\"cl-server\").value.trim(),\n adminToken: document.getElementById(\"k-admin\").value.trim(),\n };\n}\n\nasync function keyCreate() {\n const p = keyParams();\n if (!p.adminToken) { toast(\"Admin token required\"); return; }\n try {\n const r = await api(\"/api/keys\", {\n method: \"POST\",\n body: JSON.stringify({ ...p, brain: document.getElementById(\"k-brain\").value.trim(), label: document.getElementById(\"k-label\").value.trim() || undefined }),\n });\n document.getElementById(\"keys-out\").innerHTML =\n `<div class=\"hint\">New key (shown once \u2014 copy now):</div><div class=\"keyrow\"><span>${esc(r.key)}</span><button onclick=\"navigator.clipboard.writeText('${esc(r.key)}').then(()=>toast('Copied'))\">Copy</button></div>`;\n } catch (e) { toast(\"Error: \" + e.message); }\n}\n\nasync function keyList() {\n const p = keyParams();\n if (!p.adminToken) { toast(\"Admin token required\"); return; }\n try {\n const r = await api(`/api/keys?server=${encodeURIComponent(p.server)}`, {\n headers: { \"X-Aidimag-Admin-Token\": p.adminToken },\n });\n document.getElementById(\"keys-out\").innerHTML = r.keys.length\n ? r.keys.map(k => `<div class=\"keyrow\"><span>${k.revoked_at ? \"\u2717\" : \"\u2713\"} ${esc(k.key)} \u2192 ${esc(k.brain)}${k.label ? \" (\" + esc(k.label) + \")\" : \"\"}</span></div>`).join(\"\")\n : '<div class=\"hint\">No keys yet.</div>';\n } catch (e) { toast(\"Error: \" + e.message); }\n}\n\n// ---------------------------------------------------------------- tickets\n\nconst TK_HINTS = {\n jira: { url: \"Jira site URL\", urlPh: \"https://acme.atlassian.net\", token: \"email:apiToken (or a PAT)\", needUrl: true, needToken: true },\n github: { url: \"Repo URL\", urlPh: \"https://github.com/acme/api\", token: \"GitHub token (repo read)\", needUrl: true, needToken: true },\n linear: { url: \"\", urlPh: \"\", token: \"Linear API key\", needUrl: false, needToken: true },\n http: { url: \"Middleware endpoint (GET /ticket/:id)\", urlPh: \"https://tickets.internal.acme.com\", token: \"Bearer token (optional)\", needUrl: true, needToken: true },\n remote: { url: \"\", urlPh: \"\", token: \"\", needUrl: false, needToken: false },\n};\n\nfunction ticketsProviderHint() {\n const h = TK_HINTS[document.getElementById(\"tk-provider\").value];\n document.getElementById(\"tk-url-row\").style.display = h.needUrl ? \"\" : \"none\";\n document.getElementById(\"tk-token-row\").style.display = h.needToken ? \"\" : \"none\";\n if (h.needUrl) {\n document.getElementById(\"tk-url-label\").textContent = h.url;\n document.getElementById(\"tk-url\").placeholder = h.urlPh;\n }\n if (h.needToken) document.getElementById(\"tk-token-label\").textContent = h.token + \" \u2014 stored on this machine only, never in the repo\";\n}\n\nfunction openTickets() {\n const t = state && state.tickets;\n document.getElementById(\"tk-status\").textContent = t\n ? `Connected: ${t.provider}${t.baseUrl ? \" at \" + t.baseUrl : \"\"} (${t.hasCredential ? \"credential stored\" : \"\u26A0 NO CREDENTIAL\"})`\n : \"No ticketing app connected \u2014 proposals will miss the why from your tickets.\";\n if (t) {\n document.getElementById(\"tk-provider\").value = t.provider;\n if (t.baseUrl) document.getElementById(\"tk-url\").value = t.baseUrl;\n document.getElementById(\"tk-pattern\").value = t.pattern || \"\";\n }\n ticketsProviderHint();\n document.getElementById(\"dlg-tickets\").showModal();\n}\n\nasync function ticketsConnect() {\n try {\n const r = await api(\"/api/tickets/connect\", {\n method: \"POST\",\n body: JSON.stringify({\n provider: document.getElementById(\"tk-provider\").value,\n baseUrl: document.getElementById(\"tk-url\").value.trim() || undefined,\n token: document.getElementById(\"tk-token\").value.trim() || undefined,\n pattern: document.getElementById(\"tk-pattern\").value.trim() || undefined,\n testId: document.getElementById(\"tk-test\").value.trim() || undefined,\n }),\n });\n document.getElementById(\"tk-token\").value = \"\";\n toast(r.validated ? `Connected \u2713 validated with ${r.validated.id}: ${r.validated.title}` : \"Tickets connected\");\n load(); openTickets();\n } catch (e) { toast(\"Error: \" + e.message); }\n}\n\nasync function ticketsDisconnect() {\n try { await api(\"/api/tickets/disconnect\", { method: \"POST\" }); toast(\"Tickets disconnected\"); load(); openTickets(); }\n catch (e) { toast(\"Error: \" + e.message); }\n}\n\nasync function ticketsShare(remove) {\n const adminToken = document.getElementById(\"tk-admin\").value.trim();\n if (!adminToken) { toast(\"Admin token required\"); return; }\n try {\n await api(\"/api/tickets/share\", {\n method: \"POST\",\n body: JSON.stringify(remove ? { adminToken, remove: true } : {\n adminToken,\n provider: document.getElementById(\"tk-provider\").value,\n baseUrl: document.getElementById(\"tk-url\").value.trim() || undefined,\n credential: document.getElementById(\"tk-token\").value.trim() || undefined,\n }),\n });\n document.getElementById(\"tk-token\").value = \"\";\n toast(remove ? \"Team ticket config removed\" : \"Team credentials stored on the server \u2014 teammates use provider 'remote'\");\n } catch (e) { toast(\"Error: \" + e.message); }\n}\n\nlet sim = null;\nfunction renderGraph() {\n const container = document.getElementById(\"graph\");\n container.innerHTML = \"\";\n const W = container.clientWidth, H = container.clientHeight;\n\n const nodes = [], links = [], pathNodes = new Map();\n for (const m of state.memories) {\n nodes.push({ id: m.id, type: \"memory\", label: m.claim.slice(0, 36) + (m.claim.length > 36 ? \"\u2026\" : \"\"), status: m.status, conf: m.confidence });\n for (const p of m.scope.paths) {\n if (!pathNodes.has(p)) { pathNodes.set(p, { id: \"path:\" + p, type: \"path\", label: p }); }\n links.push({ source: m.id, target: \"path:\" + p, kind: \"scope\" });\n }\n for (const l of m.links) {\n if (l.fromId === m.id) links.push({ source: l.fromId, target: l.toId, kind: l.relation });\n }\n }\n nodes.push(...pathNodes.values());\n\n const svg = d3.select(container).append(\"svg\").attr(\"width\", W).attr(\"height\", H);\n const g = svg.append(\"g\");\n svg.call(d3.zoom().scaleExtent([0.3, 4]).on(\"zoom\", e => g.attr(\"transform\", e.transform)));\n\n if (sim) sim.stop();\n sim = d3.forceSimulation(nodes)\n .force(\"link\", d3.forceLink(links).id(d => d.id).distance(90))\n .force(\"charge\", d3.forceManyBody().strength(-220))\n .force(\"center\", d3.forceCenter(W / 2, H / 2))\n .force(\"collide\", d3.forceCollide(28));\n\n const palette = graphPalette();\n\n const link = g.append(\"g\").selectAll(\"line\").data(links).join(\"line\")\n .attr(\"stroke\", d => d.kind === \"contradicts\" ? palette.REFUTED : palette.link)\n .attr(\"stroke-width\", 1.2);\n\n const node = g.append(\"g\").selectAll(\"g\").data(nodes).join(\"g\")\n .style(\"cursor\", \"pointer\")\n .call(d3.drag()\n .on(\"start\", (e, d) => { if (!e.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })\n .on(\"drag\", (e, d) => { d.fx = e.x; d.fy = e.y; })\n .on(\"end\", (e, d) => { if (!e.active) sim.alphaTarget(0); d.fx = null; d.fy = null; }));\n\n node.filter(d => d.type === \"memory\").append(\"circle\")\n .attr(\"r\", d => 7 + d.conf * 10)\n .attr(\"fill\", d => palette[d.status] || palette.UNVERIFIED)\n .attr(\"fill-opacity\", 0.85);\n node.filter(d => d.type === \"path\").append(\"rect\")\n .attr(\"x\", -7).attr(\"y\", -7).attr(\"width\", 14).attr(\"height\", 14).attr(\"rx\", 3)\n .attr(\"fill\", palette.path).attr(\"fill-opacity\", 0.85);\n\n node.append(\"text\").attr(\"dy\", d => d.type === \"memory\" ? 7 + d.conf * 10 + 12 : 22).attr(\"text-anchor\", \"middle\").text(d => d.label);\n\n node.on(\"click\", (e, d) => {\n if (d.type !== \"memory\") return;\n const card = document.getElementById(\"mem-\" + d.id);\n if (card) { card.scrollIntoView({ behavior: \"smooth\", block: \"center\" }); card.style.outline = \"2px solid \" + palette.primary; setTimeout(() => card.style.outline = \"\", 1500); }\n });\n\n sim.on(\"tick\", () => {\n link.attr(\"x1\", d => d.source.x).attr(\"y1\", d => d.source.y).attr(\"x2\", d => d.target.x).attr(\"y2\", d => d.target.y);\n node.attr(\"transform\", d => `translate(${d.x},${d.y})`);\n });\n}\n\nwindow.addEventListener(\"resize\", () => state && renderGraph());\nload();\n</script>\n</body>\n</html>";
|
package/dist/ui/page.js
CHANGED
|
@@ -602,12 +602,17 @@ async function runSync() {
|
|
|
602
602
|
const r = await api("/api/sync", { method: "POST" });
|
|
603
603
|
const mem = (n) => n + (n === 1 ? " memory" : " memories");
|
|
604
604
|
let msg;
|
|
605
|
-
if (r.
|
|
605
|
+
if (r.autoRecovered) {
|
|
606
|
+
msg = "✓ Auto-recovery: restored memories from cloud";
|
|
607
|
+
if (r.applied) msg += " (" + r.applied + " recovered)";
|
|
608
|
+
} else if (r.memoriesPushed) msg = "Sent " + mem(r.memoriesPushed);
|
|
606
609
|
else if (r.memoriesQueued) msg = "Already on server (" + mem(r.memoriesQueued) + " unchanged)";
|
|
607
610
|
else msg = "Nothing to send";
|
|
608
|
-
if (r.
|
|
609
|
-
|
|
610
|
-
|
|
611
|
+
if (!r.autoRecovered) {
|
|
612
|
+
if (r.applied) msg += ", received " + r.applied + " update" + (r.applied === 1 ? "" : "s");
|
|
613
|
+
else if (r.pulled) msg += ", pulled " + r.pulled + " (already up to date locally)";
|
|
614
|
+
else msg += ", nothing new from team";
|
|
615
|
+
}
|
|
611
616
|
if (r.needsFullUploadConfirm) {
|
|
612
617
|
msg += " — run dim sync in terminal to confirm upload";
|
|
613
618
|
}
|
|
@@ -634,7 +639,11 @@ function openCloud() {
|
|
|
634
639
|
if (c) {
|
|
635
640
|
document.getElementById("cl-server").value = c.server;
|
|
636
641
|
document.getElementById("cl-brain").value = c.brain;
|
|
642
|
+
} else {
|
|
643
|
+
document.getElementById("cl-server").value = "";
|
|
644
|
+
document.getElementById("cl-brain").value = "";
|
|
637
645
|
}
|
|
646
|
+
document.getElementById("cl-token").value = "";
|
|
638
647
|
document.getElementById("dlg-cloud").showModal();
|
|
639
648
|
}
|
|
640
649
|
|