@anmol-srv/sigil 0.10.3 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1118 @@
1
+ // Sigil GUI — vanilla JS. Onboarding wizard + dashboard.
2
+ const $ = (sel, root = document) => root.querySelector(sel);
3
+ const $$ = (sel, root = document) => root.querySelectorAll(sel);
4
+
5
+ // ── RPC ──────────────────────────────────────────────────────────────
6
+ async function rpc(method, params = {}) {
7
+ const res = await fetch('/api/v1/rpc', {
8
+ method: 'POST',
9
+ headers: { 'Content-Type': 'application/json' },
10
+ credentials: 'same-origin',
11
+ body: JSON.stringify({ method, params }),
12
+ });
13
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
14
+ const body = await res.json();
15
+ if (!body.ok) throw new Error(body.error?.message || 'rpc error');
16
+ return body.data;
17
+ }
18
+
19
+ // ── Helpers ──────────────────────────────────────────────────────────
20
+ const escape = (v) => {
21
+ if (v === null || v === undefined) return '—';
22
+ return String(v).replace(/[&<>"']/g, (c) => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c]));
23
+ };
24
+ const formatUptime = (ms) => {
25
+ const s = Math.floor(ms / 1000), h = Math.floor(s/3600), m = Math.floor((s%3600)/60), sec = s%60;
26
+ return h ? `${h}h ${m}m ${sec}s` : m ? `${m}m ${sec}s` : `${sec}s`;
27
+ };
28
+ const formatTime = (iso) => {
29
+ if (!iso) return '—';
30
+ try { return new Date(iso).toISOString().slice(0, 16).replace('T', ' '); }
31
+ catch { return iso; }
32
+ };
33
+ async function copyToClipboard(text) {
34
+ try { await navigator.clipboard.writeText(text); return true; }
35
+ catch {
36
+ const ta = document.createElement('textarea');
37
+ ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
38
+ document.body.appendChild(ta); ta.select();
39
+ try { document.execCommand('copy'); return true; }
40
+ catch { return false; }
41
+ finally { document.body.removeChild(ta); }
42
+ }
43
+ }
44
+
45
+ // ════════════════════════════════════════════════════════════════════
46
+ // ONBOARDING WIZARD
47
+ // ════════════════════════════════════════════════════════════════════
48
+ const wizardState = { step: 'welcome', llmProvider: null, embProvider: null, llmProviders: [], embProviders: [] };
49
+
50
+ function setOnbStep(stepId) {
51
+ wizardState.step = stepId;
52
+ // Update step list state — done/active/future
53
+ const order = ['welcome', 'database', 'llm', 'embedding', 'finish'];
54
+ const idx = order.indexOf(stepId);
55
+ $$('.onboarding-step').forEach((el) => {
56
+ const i = order.indexOf(el.dataset.obStep);
57
+ el.classList.remove('active', 'done', 'future');
58
+ if (i < idx) el.classList.add('done');
59
+ else if (i === idx) el.classList.add('active');
60
+ else el.classList.add('future');
61
+ });
62
+ // Show only the active step
63
+ $$('.wizard-step').forEach((el) => el.classList.toggle('active', el.dataset.step === stepId));
64
+ // Lazy-fetch provider lists when entering those steps
65
+ if (stepId === 'llm' && !wizardState.llmProviders.length) loadLlmProviders();
66
+ if (stepId === 'embedding' && !wizardState.embProviders.length) loadEmbeddingProviders();
67
+ if (stepId === 'finish') renderFinish();
68
+ // Scroll content to top
69
+ document.querySelector('.onboarding-content')?.scrollTo(0, 0);
70
+ }
71
+
72
+ async function loadOnboardingState() {
73
+ try {
74
+ const state = await rpc('onboardingState');
75
+ if (state.setupComplete) {
76
+ $('#onboarding').hidden = true;
77
+ return;
78
+ }
79
+ $('#onboarding').hidden = false;
80
+ // Pre-fill DB step's "next" enabled if already done
81
+ if (state.steps.database.done) {
82
+ $('#ob-db-next').disabled = false;
83
+ }
84
+ if (state.steps.llm.done) {
85
+ $('#ob-llm-next').disabled = false;
86
+ }
87
+ if (state.steps.embedding.done) {
88
+ $('#ob-emb-next').disabled = false;
89
+ }
90
+ } catch (err) {
91
+ // Could not reach daemon — show welcome anyway
92
+ $('#onboarding').hidden = false;
93
+ }
94
+ }
95
+
96
+ // ── DB step ──────────────────────────────────────────────────────────
97
+ $('#db-mode-cards')?.addEventListener('click', (e) => {
98
+ const card = e.target.closest('[data-db-mode]');
99
+ if (!card) return;
100
+ $$('#db-mode-cards .provider-card').forEach((c) => c.classList.remove('selected'));
101
+ card.classList.add('selected');
102
+ card.querySelector('input').checked = true;
103
+ $('#ob-db-url').style.display = card.dataset.dbMode === 'url' ? '' : 'none';
104
+ $('#ob-db-fields').style.display = card.dataset.dbMode === 'fields' ? '' : 'none';
105
+ });
106
+ $('#db-mode-cards .provider-card')?.classList.add('selected');
107
+
108
+ function obDbParams() {
109
+ const isUrl = $('input[name="db-mode"]:checked').value === 'url';
110
+ if (isUrl) return { url: $('#ob-db-url-input').value.trim() };
111
+ return {
112
+ host: $('#ob-db-host').value.trim(),
113
+ port: Number($('#ob-db-port').value),
114
+ database: $('#ob-db-db').value.trim(),
115
+ user: $('#ob-db-user').value.trim(),
116
+ password: $('#ob-db-pass').value,
117
+ };
118
+ }
119
+
120
+ $('#ob-db-test')?.addEventListener('click', async () => {
121
+ const out = $('#ob-db-result');
122
+ out.hidden = false;
123
+ out.className = 'result';
124
+ out.textContent = 'testing…';
125
+ try {
126
+ const data = await rpc('testDbConnection', obDbParams());
127
+ out.textContent = JSON.stringify(data, null, 2);
128
+ out.classList.add(data.ok ? 'ok' : 'err');
129
+ if (data.ok && !data.pgvector) {
130
+ $('#ob-db-install-pgv').hidden = false;
131
+ $('#ob-db-migrate').hidden = true;
132
+ $('#ob-db-next').disabled = true;
133
+ out.textContent += '\n\npgvector is not installed yet. Click "Install pgvector".';
134
+ } else if (data.ok) {
135
+ $('#ob-db-install-pgv').hidden = true;
136
+ $('#ob-db-migrate').hidden = false;
137
+ out.textContent += '\n\npgvector is installed. Click "Run migrations" to finish.';
138
+ } else {
139
+ $('#ob-db-install-pgv').hidden = true;
140
+ $('#ob-db-migrate').hidden = true;
141
+ $('#ob-db-next').disabled = true;
142
+ }
143
+ } catch (err) {
144
+ out.textContent = `ERROR: ${err.message}`;
145
+ out.classList.add('err');
146
+ }
147
+ });
148
+
149
+ $('#ob-db-install-pgv')?.addEventListener('click', async () => {
150
+ const out = $('#ob-db-result');
151
+ out.textContent += '\n\nInstalling pgvector…';
152
+ try {
153
+ const data = await rpc('ensurePgvector', obDbParams());
154
+ if (data.ok && data.installed) {
155
+ out.textContent += `\n✓ pgvector ${data.version} installed`;
156
+ $('#ob-db-install-pgv').hidden = true;
157
+ $('#ob-db-migrate').hidden = false;
158
+ } else {
159
+ out.textContent += `\n✗ ${data.error || 'unknown'} (${data.stage})`;
160
+ }
161
+ } catch (err) { out.textContent += `\nERROR: ${err.message}`; }
162
+ });
163
+
164
+ $('#ob-db-migrate')?.addEventListener('click', async () => {
165
+ const out = $('#ob-db-result');
166
+ out.textContent += '\n\nPersisting connection to ~/.sigil/.env…';
167
+ try {
168
+ const params = obDbParams();
169
+ if (params.url) {
170
+ await rpc('writeEnv', { patch: {
171
+ SIGIL_DATABASE_URL: params.url,
172
+ SIGIL_DB_HOST: null, SIGIL_DB_PORT: null, SIGIL_DB_NAME: null, SIGIL_DB_USER: null, SIGIL_DB_PASSWORD: null,
173
+ } });
174
+ } else {
175
+ await rpc('writeEnv', { patch: {
176
+ SIGIL_DB_HOST: params.host, SIGIL_DB_PORT: String(params.port),
177
+ SIGIL_DB_NAME: params.database, SIGIL_DB_USER: params.user, SIGIL_DB_PASSWORD: params.password,
178
+ SIGIL_DATABASE_URL: null,
179
+ } });
180
+ }
181
+ out.textContent += '\n✓ env written. Running migrations…';
182
+ // Pass connection params so runMigrations uses a one-shot pool against
183
+ // the new URL — the daemon's existing pool is still bound to the
184
+ // pre-onboarding env (localhost:5432 by default).
185
+ const data = await rpc('runMigrations', params);
186
+ out.textContent += `\n✓ batch ${data.batchNo}: ${data.ran.length} migrations applied (${data.against})`;
187
+ $('#ob-db-next').disabled = false;
188
+ } catch (err) {
189
+ out.textContent += `\n✗ ${err.message}`;
190
+ }
191
+ });
192
+
193
+ // ── LLM provider step ───────────────────────────────────────────────
194
+ async function loadLlmProviders() {
195
+ try {
196
+ const { providers } = await rpc('listLlmProviders');
197
+ wizardState.llmProviders = providers;
198
+ $('#ob-llm-cards').innerHTML = providers.map((p) => `
199
+ <label class="provider-card" data-llm-id="${escape(p.id)}">
200
+ <span class="check"></span>
201
+ <span class="name">${escape(p.label)}${p.recommended ? ' <span class="badge info" style="margin-left:8px;">RECOMMENDED</span>' : ''}</span>
202
+ <span class="hint">${escape(p.hint)}</span>
203
+ </label>
204
+ `).join('');
205
+ // Auto-select recommended
206
+ const recommended = providers.find((p) => p.recommended);
207
+ if (recommended) selectLlmProvider(recommended.id);
208
+ } catch (err) {
209
+ $('#ob-llm-cards').innerHTML = `<div class="muted">failed: ${escape(err.message)}</div>`;
210
+ }
211
+ }
212
+ function selectLlmProvider(id) {
213
+ wizardState.llmProvider = id;
214
+ $$('#ob-llm-cards .provider-card').forEach((c) => c.classList.toggle('selected', c.dataset.llmId === id));
215
+ const p = wizardState.llmProviders.find((x) => x.id === id);
216
+ if (!p) return;
217
+ if (!p.fields.length) {
218
+ $('#ob-llm-fields').innerHTML = `<p class="muted text-sm">No additional configuration needed — Sigil will use your local Claude Code subscription.</p>`;
219
+ } else {
220
+ $('#ob-llm-fields').innerHTML = p.fields.map((f) => `
221
+ <label class="field">
222
+ <span class="label">${escape(f.label)}${f.optional ? ' <span class="muted text-xs">(optional)</span>' : ''}</span>
223
+ <input type="${f.type}" data-llm-field="${escape(f.name)}" placeholder="${escape(f.placeholder || '')}" autocomplete="off">
224
+ </label>
225
+ `).join('');
226
+ }
227
+ }
228
+ $('#ob-llm-cards')?.addEventListener('click', (e) => {
229
+ const card = e.target.closest('[data-llm-id]');
230
+ if (card) selectLlmProvider(card.dataset.llmId);
231
+ });
232
+ $('#ob-llm-save')?.addEventListener('click', async () => {
233
+ if (!wizardState.llmProvider) return;
234
+ const fields = {};
235
+ $$('#ob-llm-fields [data-llm-field]').forEach((i) => { if (i.value) fields[i.dataset.llmField] = i.value; });
236
+ const out = $('#ob-llm-result');
237
+ out.hidden = false; out.className = 'result'; out.textContent = 'saving…';
238
+ try {
239
+ await rpc('configureLlm', { id: wizardState.llmProvider, ...fields });
240
+ out.textContent = 'env written. Testing live LLM call…';
241
+ const test = await rpc('testLlm', {});
242
+ if (test.ok) {
243
+ out.classList.add('ok');
244
+ out.textContent += `\n✓ provider responded: "${test.response}"`;
245
+ $('#ob-llm-next').disabled = false;
246
+ } else {
247
+ out.classList.add('err');
248
+ out.textContent += `\n✗ test failed: ${test.error}`;
249
+ }
250
+ } catch (err) {
251
+ out.classList.add('err');
252
+ out.textContent = `✗ ${err.message}`;
253
+ }
254
+ });
255
+
256
+ // ── Embedding step ──────────────────────────────────────────────────
257
+ async function loadEmbeddingProviders() {
258
+ try {
259
+ const { providers } = await rpc('listEmbeddingProviders');
260
+ wizardState.embProviders = providers;
261
+ $('#ob-emb-cards').innerHTML = providers.map((p) => `
262
+ <label class="provider-card" data-emb-id="${escape(p.id)}">
263
+ <span class="check"></span>
264
+ <span class="name">${escape(p.label)}${p.recommended ? ' <span class="badge info" style="margin-left:8px;">RECOMMENDED</span>' : ''}</span>
265
+ <span class="hint">${escape(p.hint)}</span>
266
+ </label>
267
+ `).join('');
268
+ const r = providers.find((p) => p.recommended);
269
+ if (r) selectEmbProvider(r.id);
270
+ } catch (err) {
271
+ $('#ob-emb-cards').innerHTML = `<div class="muted">failed: ${escape(err.message)}</div>`;
272
+ }
273
+ }
274
+ function selectEmbProvider(id) {
275
+ wizardState.embProvider = id;
276
+ $$('#ob-emb-cards .provider-card').forEach((c) => c.classList.toggle('selected', c.dataset.embId === id));
277
+ const p = wizardState.embProviders.find((x) => x.id === id);
278
+ if (!p) return;
279
+ const visibleFields = p.fields.filter((f) => !f.sharedWith);
280
+ if (!visibleFields.length) {
281
+ const sharedNote = p.fields.find((f) => f.sharedWith === 'llm')
282
+ ? '<p class="muted text-sm">Reuses the API key from your LLM step.</p>'
283
+ : '<p class="muted text-sm">No configuration needed.</p>';
284
+ $('#ob-emb-fields').innerHTML = sharedNote;
285
+ } else {
286
+ $('#ob-emb-fields').innerHTML = visibleFields.map((f) => `
287
+ <label class="field">
288
+ <span class="label">${escape(f.label)}</span>
289
+ <input type="${f.type}" data-emb-field="${escape(f.name)}" placeholder="${escape(f.placeholder || '')}" autocomplete="off">
290
+ </label>
291
+ `).join('');
292
+ }
293
+ }
294
+ $('#ob-emb-cards')?.addEventListener('click', (e) => {
295
+ const card = e.target.closest('[data-emb-id]');
296
+ if (card) selectEmbProvider(card.dataset.embId);
297
+ });
298
+ // Shared embedding apply+gate, used by both the wizard and Settings.
299
+ // 1. Check dim-compat against the target DB BEFORE writing config.
300
+ // 2. If a conflict (DB has data at a different dim) → render Wipe/Cancel,
301
+ // do NOT write config. Caller stays put until the user resolves it.
302
+ // 3. Otherwise configure + live-test, surfacing honest errors.
303
+ // Returns true on success (embedder healthy), false otherwise.
304
+ async function applyEmbeddingProvider({ providerId, fields, out, conflictHost, onSuccess }) {
305
+ const prov = (wizardState.embProviders || []).find((p) => p.id === providerId);
306
+ out.hidden = false; out.className = 'result';
307
+ out.textContent = 'checking compatibility with your database…';
308
+
309
+ // Dim-conflict gate (skip silently if the DB isn't reachable yet — the
310
+ // embedder test below will surface that honestly).
311
+ try {
312
+ const compat = await rpc('inspectEmbeddingCompat', { id: providerId });
313
+ if (compat.ok && compat.conflict) {
314
+ renderConflictCard({
315
+ host: conflictHost, compat, providerId, fields,
316
+ out, onResolved: () => applyEmbeddingProvider({ providerId, fields, out, conflictHost, onSuccess }),
317
+ });
318
+ return false;
319
+ }
320
+ } catch { /* DB unreachable — let the embed test report the real cause */ }
321
+
322
+ out.textContent = 'saving…';
323
+ try {
324
+ await rpc('configureEmbedding', { id: providerId, ...fields });
325
+ out.textContent = 'env written. Testing embed call…';
326
+ const test = await rpc('testEmbedding', {});
327
+ if (test.ok) {
328
+ out.classList.add('ok');
329
+ out.textContent = `✓ embedder healthy — returned ${test.dim}-dim vector (${escape(prov?.label || providerId)})`;
330
+ if (onSuccess) onSuccess(test);
331
+ return true;
332
+ }
333
+ out.classList.add('err');
334
+ out.textContent = `✗ ${test.error || 'embed test failed'}`;
335
+ if (test.fixHint) out.textContent += `\n → ${test.fixHint}`;
336
+ return false;
337
+ } catch (err) {
338
+ out.classList.add('err');
339
+ out.textContent = `✗ ${err.message}`;
340
+ return false;
341
+ }
342
+ }
343
+
344
+ // Render the dimension-conflict resolution card: Wipe (destructive, confirmed)
345
+ // or Cancel. Never auto-destroys. `host` (the conflict container element) gets
346
+ // the card; `onResolved` re-runs the apply after a successful wipe.
347
+ function renderConflictCard({ host, compat, out, onResolved }) {
348
+ const target = host || out.parentElement;
349
+ const rows = Object.entries(compat.rowsAtRisk || {})
350
+ .map(([t, n]) => `${n.toLocaleString()} ${t}`).join(', ');
351
+ const card = document.createElement('div');
352
+ card.className = 'result err conflict-card';
353
+ card.innerHTML = `
354
+ <strong>Embedding size mismatch.</strong>
355
+ Your database stores <b>${compat.currentDim}-dim</b> vectors, but this provider produces
356
+ <b>${compat.targetDim}-dim</b>. They can't coexist — every save would fail.
357
+ <div class="muted" style="margin:6px 0;">At risk: ${escape(rows)} (${compat.totalAtRisk.toLocaleString()} rows).</div>
358
+ <div class="flex-row" style="margin-top:8px;">
359
+ <button type="button" class="btn danger" data-conflict-wipe>Wipe data & switch to ${compat.targetDim}-dim</button>
360
+ <button type="button" class="btn" data-conflict-cancel>Cancel</button>
361
+ </div>
362
+ <div class="muted text-sm" style="margin-top:6px;">Wipe deletes all ${compat.totalAtRisk.toLocaleString()} stored vectors. Pods/structure are kept; re-ingest to repopulate.</div>
363
+ `;
364
+ out.hidden = true;
365
+ // Remove any prior card before adding a fresh one.
366
+ target.querySelectorAll('.conflict-card').forEach((c) => c.remove());
367
+ target.appendChild(card);
368
+
369
+ card.querySelector('[data-conflict-cancel]').addEventListener('click', () => {
370
+ card.remove();
371
+ out.hidden = false; out.className = 'result';
372
+ out.textContent = 'Cancelled — no changes made. Pick a provider matching your data, or wipe to switch.';
373
+ });
374
+ card.querySelector('[data-conflict-wipe]').addEventListener('click', async (e) => {
375
+ const btn = e.target;
376
+ btn.disabled = true; btn.textContent = 'Wiping…';
377
+ try {
378
+ const r = await rpc('wipeEmbeddingData', { confirm: true });
379
+ if (!r.ok) { btn.textContent = `Wipe failed: ${r.error}`; btn.disabled = false; return; }
380
+ card.remove();
381
+ // Re-migrate the now-empty schema to the new dim happens on next
382
+ // migration/restart; re-run the apply which will now find no conflict.
383
+ if (onResolved) await onResolved();
384
+ } catch (err) {
385
+ btn.textContent = `Wipe failed: ${err.message}`; btn.disabled = false;
386
+ }
387
+ });
388
+ }
389
+
390
+ $('#ob-emb-save')?.addEventListener('click', async () => {
391
+ if (!wizardState.embProvider) return;
392
+ const fields = {};
393
+ $$('#ob-emb-fields [data-emb-field]').forEach((i) => { if (i.value) fields[i.dataset.embField] = i.value; });
394
+ const ok = await applyEmbeddingProvider({
395
+ providerId: wizardState.embProvider,
396
+ fields,
397
+ out: $('#ob-emb-result'),
398
+ conflictHost: $('#ob-emb-fields')?.parentElement,
399
+ onSuccess: () => { $('#ob-emb-next').disabled = false; },
400
+ });
401
+ if (!ok) $('#ob-emb-next').disabled = true;
402
+ });
403
+
404
+ // ── Finish step ─────────────────────────────────────────────────────
405
+ async function renderFinish() {
406
+ try {
407
+ const [ping, state] = await Promise.all([rpc('ping'), rpc('onboardingState')]);
408
+ $('#ob-finish-daemon').textContent = `pid ${ping.pid} · up ${formatUptime(ping.uptimeMs)}`;
409
+ $('#ob-finish-db').textContent = state.steps.database.done
410
+ ? `${state.steps.database.migrationsRan} migrations · pgvector ${state.steps.database.pgvector ? '✓' : '✗'}`
411
+ : 'not configured';
412
+ $('#ob-finish-llm').textContent = state.env.llmProvider || 'not configured';
413
+ $('#ob-finish-emb').textContent = state.env.embeddingProvider
414
+ ? `${state.env.embeddingProvider} · ${state.env.embeddingModel} · ${state.env.embeddingDim}d`
415
+ : 'not configured';
416
+ } catch { /* ignore */ }
417
+ }
418
+ $('#ob-complete')?.addEventListener('click', async () => {
419
+ try { await rpc('markOnboardingComplete'); }
420
+ catch { /* ignore */ }
421
+ $('#onboarding').hidden = true;
422
+ refreshHealth();
423
+ });
424
+
425
+ // ── Navigation between wizard steps ──────────────────────────────────
426
+ document.addEventListener('click', (e) => {
427
+ const n = e.target.closest('[data-ob-next]');
428
+ if (n) { e.preventDefault(); setOnbStep(n.dataset.obNext); return; }
429
+ const b = e.target.closest('[data-ob-back]');
430
+ if (b) { e.preventDefault(); setOnbStep(b.dataset.obBack); return; }
431
+ });
432
+
433
+ // ════════════════════════════════════════════════════════════════════
434
+ // DASHBOARD (unchanged behavior)
435
+ // ════════════════════════════════════════════════════════════════════
436
+ function setConn(state, label) {
437
+ const el = $('#conn');
438
+ el.className = `conn-status ${state}`;
439
+ el.textContent = label;
440
+ }
441
+ function renderKv(node, entries) {
442
+ node.innerHTML = entries.map(([k, v]) => `<div class="row"><div class="k">${escape(k)}</div><div class="v">${escape(v)}</div></div>`).join('');
443
+ }
444
+
445
+ const validRoutes = ['health', 'kb', 'devices', 'activity', 'setup', 'settings', 'methods'];
446
+ function setRoute(name) {
447
+ $$('.view').forEach((v) => v.classList.toggle('active', v.id === `view-${name}`));
448
+ $$('nav a').forEach((a) => a.classList.toggle('active', a.dataset.route === name));
449
+ window.location.hash = name;
450
+ if (name === 'health') refreshHealth();
451
+ if (name === 'kb') refreshKb();
452
+ if (name === 'methods') refreshMethods();
453
+ if (name === 'settings') refreshEnv();
454
+ if (name === 'devices') refreshDevices();
455
+ if (name === 'activity') { ensureActivityWs(); loadTraces(); }
456
+ }
457
+ function routeFromHash() {
458
+ const r = (window.location.hash || '#health').slice(1);
459
+ return validRoutes.includes(r) ? r : 'health';
460
+ }
461
+ window.addEventListener('hashchange', () => setRoute(routeFromHash()));
462
+ $$('nav a').forEach((a) => {
463
+ a.addEventListener('click', (e) => { e.preventDefault(); setRoute(a.dataset.route); });
464
+ });
465
+
466
+ async function refreshHealth() {
467
+ try {
468
+ const [ping, nodeInfo, mode] = await Promise.all([
469
+ rpc('ping'),
470
+ rpc('nodeInfo').catch(() => ({ enabled: false })),
471
+ rpc('mode').catch(() => ({})),
472
+ ]);
473
+ $('#hc-pid').textContent = `pid ${ping.pid}`;
474
+ $('#hc-uptime').textContent = `up ${formatUptime(ping.uptimeMs)} · ${ping.node}`;
475
+ $('#hc-mode').textContent = mode.mode || '—';
476
+ $('#hc-driver').textContent = mode.memoryClient ? `memory client: ${mode.memoryClient}` : '—';
477
+ if (nodeInfo.enabled && nodeInfo.nodeId) {
478
+ $('#hc-nodeid').textContent = nodeInfo.nodeId.slice(0, 12) + '…';
479
+ $('#hc-nodeid').title = nodeInfo.nodeId;
480
+ $('#hc-relay').textContent = nodeInfo.relayUrl ? new URL(nodeInfo.relayUrl).hostname : 'no relay';
481
+ } else {
482
+ $('#hc-nodeid').textContent = '—';
483
+ $('#hc-relay').textContent = 'Iroh disabled';
484
+ }
485
+ $('#brand-badge').textContent = mode.mode || 'solo';
486
+
487
+ const rows = [
488
+ ['daemon pid', ping.pid], ['version', ping.version], ['node.js', ping.node],
489
+ ['uptime', formatUptime(ping.uptimeMs)], ['mode', mode.mode || '—'],
490
+ ['memory client', mode.memoryClient || '—'],
491
+ ];
492
+ if (mode.masterNodeId) rows.push(['master nodeId', mode.masterNodeId]);
493
+ if (nodeInfo.enabled) {
494
+ rows.push(['this nodeId', nodeInfo.nodeId || nodeInfo.error || '—']);
495
+ if (nodeInfo.relayUrl) rows.push(['relay', nodeInfo.relayUrl]);
496
+ if (nodeInfo.addresses?.length) rows.push(['addresses', nodeInfo.addresses.join(', ')]);
497
+ }
498
+ renderKv($('#health-pane'), rows);
499
+
500
+ $('#footer-version').textContent = `v${ping.version}`;
501
+ $('#footer-pid').textContent = ping.pid;
502
+
503
+ setConn('ok', 'connected');
504
+ } catch (err) { setConn('err', err.message); }
505
+ }
506
+
507
+ async function refreshKb() {
508
+ try {
509
+ const data = await rpc('status', {});
510
+ renderKv($('#kb-pane'), [
511
+ ['documents', data.documents], ['chunks', data.chunks], ['facts', data.facts],
512
+ ['entities (docs)', data.entities.documents],
513
+ ['entities (people)', data.entities.people],
514
+ ['entities (topics)', data.entities.topics],
515
+ ['relations', data.relations],
516
+ ['hebbian edges', data.hebbian?.edgeCount ?? '—'],
517
+ ]);
518
+ const hot = data.hotFacts || [];
519
+ $('#hot-facts').innerHTML = hot.length
520
+ ? hot.map((f) => `<li>${escape(f.content.slice(0, 140))}<span class="muted" style="margin-left:8px;">${f.accessCount}×</span></li>`).join('')
521
+ : '<li class="muted">no hot facts yet</li>';
522
+ } catch (err) {
523
+ $('#kb-pane').innerHTML = `<div class="row"><div class="k">error</div><div class="v">${escape(err.message)}</div></div>`;
524
+ }
525
+ }
526
+ $('#kb-refresh')?.addEventListener('click', refreshKb);
527
+
528
+ async function refreshMethods() {
529
+ try {
530
+ const res = await fetch('/api/v1/methods', { credentials: 'same-origin' });
531
+ const body = await res.json();
532
+ $('#methods-list').innerHTML = body.data.methods.map((m) => `<li><span class="badge info">RPC</span>${escape(m)}</li>`).join('');
533
+ } catch (err) {
534
+ $('#methods-list').innerHTML = `<li class="muted">${escape(err.message)}</li>`;
535
+ }
536
+ }
537
+
538
+ async function refreshEnv() {
539
+ // Config summary (current providers) + raw env table.
540
+ try {
541
+ const state = await rpc('onboardingState', {});
542
+ const e = state.env || {};
543
+ const dbDesc = e.hasDatabaseUrl ? 'connection URL'
544
+ : e.hasDiscreteDb ? 'local Postgres (host/port)'
545
+ : 'not configured';
546
+ $('#cfg-db').textContent = `${dbDesc}${state.steps?.database?.done ? ' · ready' : ''}`;
547
+ $('#cfg-llm').textContent = e.llmProvider || 'not configured';
548
+ $('#cfg-emb').textContent = e.embeddingProvider
549
+ ? `${e.embeddingProvider} · ${e.embeddingModel} · ${e.embeddingDim}d`
550
+ : 'not configured';
551
+ } catch { /* summary best-effort */ }
552
+
553
+ try {
554
+ const data = await rpc('readEnv', {});
555
+ const tbody = $('#env-table tbody');
556
+ const rows = Object.entries(data.entries).sort(([a], [b]) => a.localeCompare(b));
557
+ if (!rows.length) { tbody.innerHTML = '<tr><td colspan="2" class="empty">no entries</td></tr>'; return; }
558
+ tbody.innerHTML = rows.map(([k, v]) => v.masked
559
+ ? `<tr><td class="mono">${escape(k)}</td><td>${v.hasValue ? '<span class="badge ok">configured</span>' : '<span class="badge">empty</span>'}</td></tr>`
560
+ : `<tr><td class="mono">${escape(k)}</td><td class="mono">${escape(v.value)}</td></tr>`
561
+ ).join('');
562
+ } catch (err) {
563
+ $('#env-table tbody').innerHTML = `<tr><td colspan="2" class="empty">${escape(err.message)}</td></tr>`;
564
+ }
565
+ }
566
+
567
+ // ── Settings: live provider switcher (LLM + embedding) ───────────────
568
+ // Reuses the wizard's provider catalogs + the shared applyEmbeddingProvider
569
+ // gate. "Apply" persists config and restarts the daemon so the new pool /
570
+ // embedder take effect; the 5s health poll recovers from the restart gap.
571
+ const cfgSwitch = { kind: null, providerId: null, providers: [] };
572
+
573
+ async function openSwitcher(kind) {
574
+ cfgSwitch.kind = kind;
575
+ cfgSwitch.providerId = null;
576
+ $('#cfg-switch-title').textContent = kind === 'llm' ? 'Change LLM provider' : 'Change embedding provider';
577
+ $('#cfg-switch-conflict').innerHTML = '';
578
+ $('#cfg-switch-fields').innerHTML = '';
579
+ const res = $('#cfg-switch-result'); res.style.display = 'none'; res.textContent = '';
580
+ $('#cfg-switch').style.display = '';
581
+ try {
582
+ const { providers } = await rpc(kind === 'llm' ? 'listLlmProviders' : 'listEmbeddingProviders');
583
+ cfgSwitch.providers = providers;
584
+ // Keep the wizard's catalog in sync so applyEmbeddingProvider can label.
585
+ if (kind === 'embedding') wizardState.embProviders = providers;
586
+ $('#cfg-switch-cards').innerHTML = providers.map((p) => `
587
+ <label class="provider-card" data-cfg-id="${escape(p.id)}">
588
+ <span class="check"></span>
589
+ <span class="name">${escape(p.label)}${p.recommended ? ' <span class="badge info" style="margin-left:8px;">RECOMMENDED</span>' : ''}</span>
590
+ <span class="hint">${escape(p.hint)}</span>
591
+ </label>`).join('');
592
+ } catch (err) {
593
+ $('#cfg-switch-cards').innerHTML = `<div class="muted">failed: ${escape(err.message)}</div>`;
594
+ }
595
+ }
596
+
597
+ function selectSwitchProvider(id) {
598
+ cfgSwitch.providerId = id;
599
+ $$('#cfg-switch-cards .provider-card').forEach((c) => c.classList.toggle('selected', c.dataset.cfgId === id));
600
+ const p = cfgSwitch.providers.find((x) => x.id === id);
601
+ if (!p) return;
602
+ const visible = (p.fields || []).filter((f) => !f.sharedWith);
603
+ $('#cfg-switch-fields').innerHTML = visible.length
604
+ ? visible.map((f) => `
605
+ <label class="field"><span class="label">${escape(f.label)}</span>
606
+ <input type="${f.type}" data-cfg-field="${escape(f.name)}" placeholder="${escape(f.placeholder || '')}" autocomplete="off"></label>`).join('')
607
+ : '<p class="muted text-sm">No additional configuration needed.</p>';
608
+ $('#cfg-switch-conflict').innerHTML = '';
609
+ }
610
+
611
+ $('#cfg-change-llm')?.addEventListener('click', () => openSwitcher('llm'));
612
+ $('#cfg-change-emb')?.addEventListener('click', () => openSwitcher('embedding'));
613
+ $('#cfg-switch-cancel')?.addEventListener('click', () => { $('#cfg-switch').style.display = 'none'; });
614
+ $('#cfg-switch-cards')?.addEventListener('click', (e) => {
615
+ const card = e.target.closest('[data-cfg-id]');
616
+ if (card) selectSwitchProvider(card.dataset.cfgId);
617
+ });
618
+
619
+ $('#cfg-switch-apply')?.addEventListener('click', async () => {
620
+ if (!cfgSwitch.providerId) return;
621
+ const fields = {};
622
+ $$('#cfg-switch-fields [data-cfg-field]').forEach((i) => { if (i.value) fields[i.dataset.cfgField] = i.value; });
623
+ const out = $('#cfg-switch-result');
624
+
625
+ if (cfgSwitch.kind === 'embedding') {
626
+ // Route through the shared dim-conflict gate. On success, restart.
627
+ const ok = await applyEmbeddingProvider({
628
+ providerId: cfgSwitch.providerId,
629
+ fields,
630
+ out,
631
+ conflictHost: $('#cfg-switch-conflict'),
632
+ onSuccess: () => restartAndClose(out),
633
+ });
634
+ if (!ok) return;
635
+ } else {
636
+ out.style.display = 'block'; out.className = 'result'; out.textContent = 'saving…';
637
+ try {
638
+ await rpc('configureLlm', { id: cfgSwitch.providerId, ...fields });
639
+ const test = await rpc('testLlm', {});
640
+ if (!test.ok) {
641
+ out.classList.add('err');
642
+ out.textContent = `✗ ${test.error || 'LLM test failed'}${test.fixHint ? '\n → ' + test.fixHint : ''}`;
643
+ return;
644
+ }
645
+ out.classList.add('ok'); out.textContent = `✓ LLM responded: "${test.response}"`;
646
+ restartAndClose(out);
647
+ } catch (err) {
648
+ out.classList.add('err'); out.textContent = `✗ ${err.message}`;
649
+ }
650
+ }
651
+ });
652
+
653
+ async function restartAndClose(out) {
654
+ out.textContent += '\nApplying — restarting daemon…';
655
+ try { await rpc('restartDaemon', {}); } catch { /* expected: connection drops on exit */ }
656
+ setTimeout(() => { $('#cfg-switch').style.display = 'none'; refreshEnv(); refreshHealth(); }, 1500);
657
+ }
658
+
659
+ // ── Activity / causal trace log ──────────────────────────────────────
660
+ let ws = null;
661
+ let traceFilter = '';
662
+ const seenTraceUids = new Set();
663
+
664
+ function ensureActivityWs() {
665
+ if (ws && (ws.readyState === ws.OPEN || ws.readyState === ws.CONNECTING)) return;
666
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
667
+ ws = new WebSocket(`${proto}//${location.host}/api/v1/events`);
668
+ ws.addEventListener('open', () => setActivityStatus('ok', 'live'));
669
+ ws.addEventListener('close', () => {
670
+ setActivityStatus('err', 'disconnected');
671
+ setTimeout(() => { if (location.hash === '#activity') ensureActivityWs(); }, 1500);
672
+ });
673
+ ws.addEventListener('error', () => setActivityStatus('err', 'error'));
674
+ ws.addEventListener('message', (e) => { try { onLiveEvent(JSON.parse(e.data)); } catch {} });
675
+ }
676
+ function setActivityStatus(state, label) { const el = $('#activity-status'); if (!el) return; el.className = `conn-status ${state}`; el.textContent = label; }
677
+
678
+ function onLiveEvent(evt) {
679
+ if (evt.type === 'trace') {
680
+ if (traceFilter && evt.kind !== traceFilter) return;
681
+ prependTrace(evt, true);
682
+ } else if (!traceFilter) {
683
+ // operational events (rpc/pair/device) only shown in the unfiltered view
684
+ prependOpEvent(evt);
685
+ }
686
+ }
687
+
688
+ async function loadTraces() {
689
+ const list = $('#trace-list');
690
+ if (!list) return;
691
+ try {
692
+ const { traces } = await rpc('trace.list', { kind: traceFilter || undefined, limit: 50 });
693
+ seenTraceUids.clear();
694
+ list.innerHTML = '';
695
+ if (!traces.length) { $('#activity-empty').style.display = 'block'; return; }
696
+ $('#activity-empty').style.display = 'none';
697
+ for (const t of traces) { list.appendChild(traceCard(t)); seenTraceUids.add(t.uid); }
698
+ } catch (err) {
699
+ list.innerHTML = `<li class="empty">failed to load history: ${escape(err.message)}</li>`;
700
+ }
701
+ }
702
+
703
+ function prependTrace(t, isLive) {
704
+ if (t.uid && seenTraceUids.has(t.uid)) return;
705
+ if (t.uid) seenTraceUids.add(t.uid);
706
+ $('#activity-empty').style.display = 'none';
707
+ const card = traceCard(t);
708
+ if (isLive) card.classList.add('flash');
709
+ $('#trace-list').prepend(card);
710
+ trimList();
711
+ }
712
+ function prependOpEvent(evt) {
713
+ $('#activity-empty').style.display = 'none';
714
+ const li = document.createElement('li');
715
+ li.className = 'trace-card op';
716
+ const ts = clock(evt.ts);
717
+ li.innerHTML = `<div class="trace-head static">
718
+ <span class="trace-ts">${escape(ts)}</span>
719
+ <span class="badge ${opBadge(evt.type)}">${escape(evt.type)}</span>
720
+ <span class="trace-summary">${opSummary(evt)}</span></div>`;
721
+ $('#trace-list').prepend(li);
722
+ trimList();
723
+ }
724
+ function trimList() { const ul = $('#trace-list'); while (ul.childNodes.length > 200) ul.removeChild(ul.lastChild); }
725
+
726
+ function traceCard(t) {
727
+ const li = document.createElement('li');
728
+ li.className = 'trace-card';
729
+ const dur = t.durationMs != null ? `${t.durationMs}ms` : '';
730
+ const ns = t.namespace ? `<span class="trace-ns">${escape(t.namespace)}</span>` : '';
731
+ li.innerHTML = `
732
+ <button class="trace-head" type="button" aria-expanded="false">
733
+ <span class="trace-caret">▸</span>
734
+ <span class="trace-ts">${escape(clock(t.ts))}</span>
735
+ <span class="badge ${traceBadge(t.kind)}">${escape(t.kind)}</span>
736
+ <span class="trace-summary">${escape(t.summary)}</span>
737
+ ${ns}
738
+ <span class="trace-dur">${escape(dur)}</span>
739
+ </button>
740
+ <div class="trace-detail" hidden></div>`;
741
+ const head = li.querySelector('.trace-head');
742
+ const body = li.querySelector('.trace-detail');
743
+ head.addEventListener('click', () => {
744
+ const isOpen = !body.hasAttribute('hidden');
745
+ if (isOpen) { body.setAttribute('hidden', ''); head.setAttribute('aria-expanded', 'false'); li.classList.remove('open'); return; }
746
+ if (!body.dataset.rendered) { body.innerHTML = renderTraceDetail(t); body.dataset.rendered = '1'; }
747
+ body.removeAttribute('hidden'); head.setAttribute('aria-expanded', 'true'); li.classList.add('open');
748
+ });
749
+ return li;
750
+ }
751
+
752
+ // ── Detail renderers ─────────────────────────────────────────────────
753
+ function renderTraceDetail(t) {
754
+ const d = t.detail || {};
755
+ if (t.kind === 'search') return renderSearchTrace(d);
756
+ if (t.kind === 'ingest') return renderIngestTrace(d);
757
+ return `<pre class="trace-json">${escape(JSON.stringify(d, null, 2))}</pre>`;
758
+ }
759
+
760
+ const sc = (v) => (v === null || v === undefined ? '—' : String(v));
761
+
762
+ function renderSearchTrace(d) {
763
+ const parts = [];
764
+
765
+ if (d.routing) {
766
+ const r = d.routing;
767
+ parts.push(traceBlock('Routing', `
768
+ ${kvline('intent', r.intent)}
769
+ ${kvline('reasoning', r.reasoning)}
770
+ ${kvline('useGraph', r.useGraph)} ${kvline('expand', r.expand)} ${kvline('limit', r.limit)}
771
+ ${r.categories && r.categories.length ? kvline('categories', r.categories.join(', ')) : ''}
772
+ ${r.pointInTime ? kvline('pointInTime', r.pointInTime) : ''}`));
773
+ } else {
774
+ parts.push(traceBlock('Routing', `<span class="muted">cognitive routing disabled for this query</span>`));
775
+ }
776
+
777
+ parts.push(traceBlock('Strategy', `${kvline('mode', d.strategy)} ${d.matchedEntity
778
+ ? `· matched entity <strong>${escape(d.matchedEntity.name)}</strong> <span class="muted">(${escape(d.matchedEntity.type)}${d.matchedEntity.aliases?.length ? ', aliases: ' + escape(d.matchedEntity.aliases.join(', ')) : ''})</span>`
779
+ : ''}`));
780
+
781
+ const facts = (d.ranking && d.ranking.facts) || [];
782
+ if (facts.length) {
783
+ const rows = facts.map((f) => `<tr>
784
+ <td class="num">${f.rank}</td>
785
+ <td class="fact-cell">${escape(f.content)}${f.source ? ` <span class="tag">${escape(f.source)}</span>` : ''}${f.importance === 'vital' ? ' <span class="tag vital">vital</span>' : ''}</td>
786
+ <td class="num" title="cosine similarity">${sc(f.similarity)}</td>
787
+ <td class="num" title="RRF fusion (vector+keyword)">${sc(f.rrfRaw)}</td>
788
+ <td class="num" title="ACT-R activation = ln(uses+1) − 0.5·ln(age_days); recency + frequency decay">${sc(f.activation)}</td>
789
+ <td class="num" title="access count (reinforcement)">${sc(f.accessCount)}</td>
790
+ <td class="num" title="rrf × activation × importance × confidence">${sc(f.finalScore)}</td>
791
+ <td class="num strong" title="normalized score the ranker sorted on">${sc(f.rrfScore)}</td>
792
+ </tr>`).join('');
793
+ parts.push(`<div class="trace-block"><div class="trace-block-h">Ranking <span class="muted">— ${escape(d.ranking.model)}</span></div>
794
+ <div class="trace-table-wrap"><table class="trace-table">
795
+ <thead><tr><th>#</th><th>fact</th><th>sim</th><th>rrf</th><th>act↓</th><th>uses</th><th>final</th><th>score</th></tr></thead>
796
+ <tbody>${rows}</tbody></table></div></div>`);
797
+ } else {
798
+ parts.push(traceBlock('Ranking', `<span class="muted">no facts matched</span>`));
799
+ }
800
+
801
+ const chunks = (d.ranking && d.ranking.chunks) || [];
802
+ if (chunks.length) {
803
+ const rows = chunks.map((c) => `<tr>
804
+ <td class="num">${c.rank}</td>
805
+ <td class="fact-cell">${c.sectionHeading ? `<span class="muted">${escape(c.sectionHeading)} · </span>` : ''}${escape(c.content)}</td>
806
+ <td class="num">${sc(c.similarity)}</td>
807
+ <td class="num strong">${sc(c.rrfScore)}</td>
808
+ </tr>`).join('');
809
+ parts.push(`<div class="trace-block"><div class="trace-block-h">Chunks</div>
810
+ <div class="trace-table-wrap"><table class="trace-table">
811
+ <thead><tr><th>#</th><th>chunk</th><th>sim</th><th>score</th></tr></thead>
812
+ <tbody>${rows}</tbody></table></div></div>`);
813
+ }
814
+
815
+ if (d.synthesized) parts.push(traceBlock('Synthesized answer', `<div class="synth">${escape(d.synthesized)}</div>`));
816
+
817
+ if (d.reinforced && d.reinforced.factIds && d.reinforced.factIds.length) {
818
+ parts.push(traceBlock('Reinforcement (decay update)', `<span class="muted">${escape(d.reinforced.note)}</span><br>fact ids: <code class="mono">${escape(d.reinforced.factIds.join(', '))}</code>`));
819
+ }
820
+
821
+ return parts.join('');
822
+ }
823
+
824
+ function renderIngestTrace(d) {
825
+ const parts = [];
826
+ const inputs = d.inputs || (d.verdicts ? [{ input: d.title, route: d.route, counts: d.counts, verdicts: d.verdicts, entities: d.entities }] : []);
827
+
828
+ if (d.totals) parts.push(traceBlock('Totals', `${kvline('added', d.totals.added)} ${kvline('updated', d.totals.updated)} ${kvline('alreadyKnown', d.totals.alreadyKnown)} ${kvline('inputs', d.totals.inputCount)}`));
829
+
830
+ inputs.forEach((inp, i) => {
831
+ const verdictRows = (inp.verdicts || []).map((v) => {
832
+ const a = v.audm || {};
833
+ const simTxt = a.topSimilarity != null
834
+ ? `sim <strong>${a.topSimilarity.toFixed(3)}</strong> ${audmExplain(a)}`
835
+ : `<span class="muted">${escape(a.decision || 'no match — new fact')}</span>`;
836
+ const link = v.supersededId ? ` → superseded #${v.supersededId}` : v.contradictedId ? ` → contradicted #${v.contradictedId}` : '';
837
+ return `<tr>
838
+ <td><span class="badge ${audmBadge(v.action)}">${escape(v.action)}</span></td>
839
+ <td class="fact-cell">${escape(v.content)}${link ? `<span class="muted">${escape(link)}</span>` : ''}</td>
840
+ <td class="audm-cell">${simTxt}</td>
841
+ </tr>`;
842
+ }).join('');
843
+
844
+ const head = `${inp.route ? `<span class="badge info">route: ${escape(inp.route)}</span> ` : ''}${inp.skipped ? '<span class="badge warn">skipped</span> ' : ''}<span class="muted">${escape(String(inp.input || '').slice(0, 160))}</span>`;
845
+ const counts = inp.counts ? `<div class="muted text-xs" style="margin:6px 0">+${inp.counts.added} added · ~${inp.counts.updated} updated · ${inp.counts.skipped} skipped · ${inp.counts.contradicted} contradicted</div>` : '';
846
+ const ents = inp.entities ? `<div class="text-xs muted" style="margin-top:6px">entities: ${inp.entities.entityCount}, relations: ${inp.entities.relationCount}${inp.entities.topics?.length ? ' · topics: ' + escape(inp.entities.topics.join(', ')) : ''}</div>` : '';
847
+
848
+ parts.push(`<div class="trace-block">
849
+ <div class="trace-block-h">Input ${inputs.length > 1 ? i + 1 : ''}</div>
850
+ <div style="margin-bottom:6px">${head}</div>
851
+ ${counts}
852
+ ${verdictRows ? `<div class="trace-table-wrap"><table class="trace-table"><thead><tr><th>AUDM</th><th>fact</th><th>decision</th></tr></thead><tbody>${verdictRows}</tbody></table></div>` : '<span class="muted text-xs">no facts extracted</span>'}
853
+ ${ents}
854
+ </div>`);
855
+ });
856
+
857
+ return parts.join('') || `<pre class="trace-json">${escape(JSON.stringify(d, null, 2))}</pre>`;
858
+ }
859
+
860
+ function audmExplain(a) {
861
+ const th = a.thresholds || {};
862
+ if (a.decision === 'skip-duplicate') return `≥ skip ${th.skip} → near-duplicate, deduped`;
863
+ if (a.decision === 'llm:UPDATE') return `in [${th.ambiguous}, ${th.skip}) → LLM judged UPDATE`;
864
+ if (a.decision === 'llm:CONTRADICT') return `in [${th.ambiguous}, ${th.skip}) → LLM judged CONTRADICT`;
865
+ if (a.decision === 'llm:ADD') return `in [${th.ambiguous}, ${th.skip}) → LLM judged distinct`;
866
+ if (a.decision === 'below-ambiguous') return `< ambiguous ${th.ambiguous} → distinct, added`;
867
+ return escape(a.decision || '');
868
+ }
869
+
870
+ function traceBlock(title, html) { return `<div class="trace-block"><div class="trace-block-h">${escape(title)}</div><div>${html}</div></div>`; }
871
+ function kvline(k, v) { return `<span class="kvline"><span class="muted">${escape(k)}</span> ${escape(sc(v))}</span>`; }
872
+ function clock(iso) { return (iso || '').slice(11, 19) || (iso || '').slice(0, 10); }
873
+
874
+ function traceBadge(kind) {
875
+ if (kind === 'search') return 'info';
876
+ if (kind === 'ingest') return 'ok';
877
+ if (kind === 'lifecycle') return 'warn';
878
+ return 'info';
879
+ }
880
+ function audmBadge(action) {
881
+ const a = String(action || '').toUpperCase();
882
+ if (a === 'ADD') return 'ok';
883
+ if (a === 'SKIP') return '';
884
+ if (a === 'UPDATE') return 'info';
885
+ if (a === 'CONTRADICT') return 'err';
886
+ return 'info';
887
+ }
888
+ function opBadge(type) {
889
+ if (type.startsWith('write.')) return 'ok';
890
+ if (type.startsWith('error') || type.startsWith('pair.rej')) return 'err';
891
+ if (type.startsWith('device.rev') || type === 'meta.dropped') return 'warn';
892
+ return 'info';
893
+ }
894
+ function opSummary(evt) {
895
+ if (evt.type === 'rpc.connected') return `device ${escape(evt.name || evt.deviceId)} connected`;
896
+ if (evt.type === 'rpc.disconnected') return `device ${escape(evt.deviceId)} disconnected`;
897
+ if (evt.type === 'rpc.denied') return `denied ${escape(evt.method)} (${escape(evt.code)})`;
898
+ if (evt.type === 'pair.consumed') return `paired ${escape(evt.deviceName)}`;
899
+ if (evt.type === 'pair.rejected') return `pairing rejected (${escape(evt.code)})`;
900
+ if (evt.type === 'device.revoked') return `device ${escape(evt.deviceId)} revoked (${escape(evt.reason)})`;
901
+ if (evt.type === 'meta.dropped') return `${evt.count} live events dropped (backpressure)`;
902
+ return `<code class="mono">${escape(JSON.stringify(evt))}</code>`;
903
+ }
904
+
905
+ // Filter chips + actions
906
+ $('#trace-filters')?.addEventListener('click', (e) => {
907
+ const chip = e.target.closest('[data-trace-filter]');
908
+ if (!chip) return;
909
+ traceFilter = chip.dataset.traceFilter || '';
910
+ $$('#trace-filters .chip').forEach((c) => c.classList.toggle('active', c === chip));
911
+ loadTraces();
912
+ });
913
+ $('#trace-refresh')?.addEventListener('click', loadTraces);
914
+ $('#trace-clear')?.addEventListener('click', async () => {
915
+ if (!confirm('Clear the entire trace log? This deletes persisted history.')) return;
916
+ try { await rpc('trace.clear'); } catch {}
917
+ loadTraces();
918
+ });
919
+
920
+ // ── Setup tab (legacy DB form) ──────────────────────────────────────
921
+ $('#db-mode')?.addEventListener('change', (e) => {
922
+ $('#db-url-pane').style.display = e.target.value === 'url' ? '' : 'none';
923
+ $('#db-fields-pane').style.display = e.target.value === 'fields' ? '' : 'none';
924
+ });
925
+ $('#db-test')?.addEventListener('click', async () => {
926
+ const out = $('#db-result');
927
+ out.style.display = 'block'; out.className = 'result'; out.textContent = 'testing…';
928
+ try {
929
+ const params = $('#db-mode').value === 'url' ? { url: $('#db-url').value.trim() }
930
+ : { host: $('#db-host').value.trim(), port: Number($('#db-port').value),
931
+ database: $('#db-database').value.trim(), user: $('#db-user').value.trim(), password: $('#db-password').value };
932
+ const data = await rpc('testDbConnection', params);
933
+ out.textContent = JSON.stringify(data, null, 2);
934
+ out.classList.add(data.ok ? 'ok' : 'err');
935
+ $('#db-migrate').disabled = !data.ok || !data.pgvector;
936
+ if (data.ok && !data.pgvector) {
937
+ $('#db-pgvector').hidden = false; $('#db-pgvector').disabled = false;
938
+ out.textContent += '\n\n⚠ pgvector not installed.';
939
+ } else { $('#db-pgvector').hidden = true; }
940
+ } catch (err) { out.textContent = `ERROR: ${err.message}`; out.classList.add('err'); $('#db-migrate').disabled = true; }
941
+ });
942
+ $('#db-pgvector')?.addEventListener('click', async () => {
943
+ const out = $('#db-result');
944
+ const params = $('#db-mode').value === 'url' ? { url: $('#db-url').value.trim() }
945
+ : { host: $('#db-host').value.trim(), port: Number($('#db-port').value),
946
+ database: $('#db-database').value.trim(), user: $('#db-user').value.trim(), password: $('#db-password').value };
947
+ out.textContent += '\n\nInstalling pgvector…';
948
+ try {
949
+ const data = await rpc('ensurePgvector', params);
950
+ if (data.ok && data.installed) { out.textContent += `\n✓ pgvector ${data.version} installed`; $('#db-pgvector').hidden = true; $('#db-migrate').disabled = false; }
951
+ else { out.textContent += `\n✗ ${data.error || 'unknown'} (${data.stage})`; }
952
+ } catch (err) { out.textContent += `\nERROR: ${err.message}`; }
953
+ });
954
+ $('#db-migrate')?.addEventListener('click', async () => {
955
+ const out = $('#db-result');
956
+ out.textContent += '\n\nRunning migrations…';
957
+ try {
958
+ const data = await rpc('runMigrations', {});
959
+ out.textContent += `\nbatch ${data.batchNo}: ${data.ran.length} migrations applied`;
960
+ } catch (err) { out.textContent += `\nERROR: ${err.message}`; }
961
+ });
962
+
963
+ // ── Modal infrastructure ────────────────────────────────────────────
964
+ function closeModal(id) { const m = document.getElementById(id); if (m) m.hidden = true; }
965
+ function openModal(id) {
966
+ const m = document.getElementById(id); if (!m) return;
967
+ m.hidden = false;
968
+ setTimeout(() => { const f = m.querySelector('input, select, textarea, button'); if (f) f.focus(); }, 30);
969
+ }
970
+ document.addEventListener('click', (e) => {
971
+ const closer = e.target.closest('[data-close-modal]');
972
+ if (closer) { e.preventDefault(); closeModal(closer.dataset.closeModal); return; }
973
+ if (e.target.classList && e.target.classList.contains('modal') && !e.target.hidden) closeModal(e.target.id);
974
+ });
975
+ document.addEventListener('keydown', (e) => {
976
+ if (e.key !== 'Escape') return;
977
+ for (const m of $$('.modal')) if (!m.hidden) { closeModal(m.id); return; }
978
+ });
979
+ document.addEventListener('click', async (e) => {
980
+ const t = e.target.closest('[data-copy]');
981
+ if (!t) return;
982
+ const node = document.getElementById(t.dataset.copy);
983
+ if (!node) return;
984
+ const text = Array.from(node.childNodes).filter((n) => n.nodeType === Node.TEXT_NODE || (n.nodeType === Node.ELEMENT_NODE && n.tagName !== 'BUTTON')).map((n) => n.textContent).join('').trim();
985
+ const ok = await copyToClipboard(text);
986
+ const orig = t.textContent;
987
+ t.textContent = ok ? 'copied!' : 'failed';
988
+ setTimeout(() => { t.textContent = orig; }, 1200);
989
+ });
990
+
991
+ // ── Devices ─────────────────────────────────────────────────────────
992
+ let revokeTargetId = null;
993
+ async function refreshDevices() {
994
+ try {
995
+ const { devices } = await rpc('device.list', {});
996
+ const tbody = $('#dev-table tbody');
997
+ $('#dev-count').textContent = `${devices.length} device${devices.length === 1 ? '' : 's'}`;
998
+ if (!devices.length) {
999
+ tbody.innerHTML = '<tr><td colspan="7" class="empty">no devices paired yet — click <strong>+ Add device</strong></td></tr>';
1000
+ } else {
1001
+ tbody.innerHTML = devices.map((d) => {
1002
+ const statusLabel = d.active ? 'connected' : d.revokedReason === 'compromised' ? 'compromised' : 'paused';
1003
+ const statusClass = d.active ? 'ok' : d.revokedReason === 'compromised' ? 'err' : 'warn';
1004
+ const actions = d.active
1005
+ ? `<button class="btn small danger" data-revoke="${d.id}" data-name="${escape(d.name)}">Revoke</button>`
1006
+ : d.reactivatable
1007
+ ? `<button class="btn small" data-activate="${d.id}">Re-activate</button>`
1008
+ : `<span class="muted text-xs" title="revoked as compromised">re-pair only</span>`;
1009
+ return `<tr>
1010
+ <td><div class="device-name">${escape(d.name)}</div><div class="device-sub">device #${d.id}${d.meta?.hostname ? ' · ' + escape(d.meta.hostname) : ''}</div></td>
1011
+ <td class="mono" title="${escape(d.nodeId)}">${escape(d.nodeId.slice(0, 16))}…</td>
1012
+ <td><span class="badge ${d.role === 'admin' ? 'err' : d.role === 'writer' ? 'info' : ''}">${escape(d.role)}</span></td>
1013
+ <td>${escape((d.namespaces && d.namespaces.length) ? d.namespaces.join(', ') : '(all)')}</td>
1014
+ <td class="muted">${escape(formatTime(d.lastSeenAt))}</td>
1015
+ <td><span class="pill ${statusClass}">${statusLabel}</span></td>
1016
+ <td class="actions-cell">${actions}</td>
1017
+ </tr>`;
1018
+ }).join('');
1019
+ }
1020
+ } catch (err) { $('#dev-table tbody').innerHTML = `<tr><td colspan="7" class="empty">${escape(err.message)}</td></tr>`; }
1021
+
1022
+ try {
1023
+ const { codes } = await rpc('pair.list', {});
1024
+ const tbody = $('#dev-codes tbody');
1025
+ if (!codes.length) {
1026
+ tbody.innerHTML = '<tr><td colspan="6" class="empty">no codes outstanding</td></tr>';
1027
+ } else {
1028
+ tbody.innerHTML = codes.map((c) => {
1029
+ let status, badgeCls = '';
1030
+ if (c.consumedBy) { status = `consumed by ${escape(c.consumedBy.name)}`; badgeCls = 'ok'; }
1031
+ else if (c.expired) { status = 'expired'; badgeCls = 'err'; }
1032
+ else { status = 'pending'; badgeCls = 'warn'; }
1033
+ return `<tr>
1034
+ <td class="mono">#${c.id}</td><td>${escape(c.name)}</td>
1035
+ <td><span class="badge">${escape(c.role)}</span></td>
1036
+ <td class="muted">${escape(formatTime(c.expiresAt))}</td>
1037
+ <td><span class="badge ${badgeCls}">${status}</span></td>
1038
+ <td class="actions-cell">${!c.consumedBy ? `<button class="btn small danger" data-revoke-code="${c.id}">Revoke</button>` : ''}</td>
1039
+ </tr>`;
1040
+ }).join('');
1041
+ }
1042
+ } catch (err) { $('#dev-codes tbody').innerHTML = `<tr><td colspan="6" class="empty">${escape(err.message)}</td></tr>`; }
1043
+ }
1044
+ $('#dev-refresh')?.addEventListener('click', refreshDevices);
1045
+
1046
+ document.addEventListener('click', (e) => {
1047
+ const r = e.target.closest('[data-revoke]');
1048
+ if (r) {
1049
+ revokeTargetId = Number(r.dataset.revoke);
1050
+ $('#revoke-target-name').textContent = r.dataset.name || `device #${revokeTargetId}`;
1051
+ const def = $('input[name="revoke-reason"][value="paused"]'); if (def) def.checked = true;
1052
+ openModal('revoke-modal');
1053
+ return;
1054
+ }
1055
+ const a = e.target.closest('[data-activate]');
1056
+ if (a) rpc('device.activate', { id: Number(a.dataset.activate) }).then(refreshDevices).catch((err) => alert(err.message));
1057
+ const cb = e.target.closest('[data-revoke-code]');
1058
+ if (cb) rpc('pair.revoke', { id: Number(cb.dataset.revokeCode) }).then(refreshDevices).catch((err) => alert(err.message));
1059
+ });
1060
+
1061
+ $('#revoke-confirm')?.addEventListener('click', async () => {
1062
+ if (revokeTargetId == null) return;
1063
+ const reason = $('input[name="revoke-reason"]:checked').value;
1064
+ try { await rpc('device.revoke', { id: revokeTargetId, reason }); closeModal('revoke-modal'); refreshDevices(); }
1065
+ catch (err) { alert(err.message); }
1066
+ });
1067
+
1068
+ // Highlight selected radio card in revoke modal
1069
+ $$('.radio-card').forEach((card) => {
1070
+ card.addEventListener('click', () => {
1071
+ const group = card.closest('.radio-card-group') || document;
1072
+ group.querySelectorAll('.radio-card').forEach((c) => c.classList.remove('selected'));
1073
+ card.classList.add('selected');
1074
+ });
1075
+ });
1076
+
1077
+ // Add-device modal
1078
+ function resetDevModal() {
1079
+ $('#dev-form').style.display = '';
1080
+ $('#dev-result-view').hidden = true;
1081
+ $('#dev-create').hidden = false;
1082
+ $('#dev-done').hidden = true;
1083
+ $('#dev-cancel').hidden = false;
1084
+ $('#dev-name').value = ''; $('#dev-ns').value = '';
1085
+ $('#dev-ttl').value = '600'; $('#dev-role').value = 'writer';
1086
+ }
1087
+ $('#dev-new')?.addEventListener('click', () => { resetDevModal(); openModal('dev-modal'); });
1088
+ new MutationObserver(() => { if ($('#dev-modal').hidden) { setTimeout(resetDevModal, 200); refreshDevices(); } })
1089
+ .observe($('#dev-modal'), { attributes: true, attributeFilter: ['hidden'] });
1090
+
1091
+ $('#dev-create')?.addEventListener('click', async () => {
1092
+ const name = $('#dev-name').value.trim(); if (!name) return alert('Device name required');
1093
+ const role = $('#dev-role').value;
1094
+ const ttl = Number($('#dev-ttl').value) || 600;
1095
+ const ns = $('#dev-ns').value.trim();
1096
+ try {
1097
+ const data = await rpc('pair.create', {
1098
+ name, role, ttlSeconds: ttl,
1099
+ namespaces: ns ? ns.split(',').map((s) => s.trim()).filter(Boolean) : [],
1100
+ });
1101
+ const cmd = `sigil join ${data.masterNodeId || '<master-node-id>'} ${data.code} --name ${data.name}`;
1102
+ $('#dev-form').style.display = 'none';
1103
+ $('#dev-result-view').hidden = false;
1104
+ $('#dev-create').hidden = true; $('#dev-cancel').hidden = true; $('#dev-done').hidden = false;
1105
+ $('#dev-result-code').firstChild.textContent = data.code + ' ';
1106
+ $('#dev-result-master').firstChild.textContent = (data.masterNodeId || '(iroh not running)') + ' ';
1107
+ $('#dev-result-cmd').textContent = cmd;
1108
+ $('#dev-result-expiry').textContent = data.expiresAt;
1109
+ } catch (err) { alert(`Create failed: ${err.message}`); }
1110
+ });
1111
+
1112
+ // ════════════════════════════════════════════════════════════════════
1113
+ // BOOT
1114
+ // ════════════════════════════════════════════════════════════════════
1115
+ const initial = (window.location.hash || '#health').slice(1);
1116
+ setRoute(validRoutes.includes(initial) ? initial : 'health');
1117
+ loadOnboardingState();
1118
+ setInterval(refreshHealth, 5000);