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