@geravant/sinain 1.19.0 → 1.20.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.
@@ -4,6 +4,7 @@ import type { CoreConfig, SenseEvent } from "./types.js";
4
4
  import type { Profiler } from "./profiler.js";
5
5
  import type { CostTracker } from "./cost/tracker.js";
6
6
  import type { FeedbackStore } from "./learning/feedback-store.js";
7
+ import type { WebDb, BookmarkStatus } from "./web-db/store.js";
7
8
  import { FeedBuffer } from "./buffers/feed-buffer.js";
8
9
  import { SenseBuffer, type SemanticSenseEvent, type TextDelta } from "./buffers/sense-buffer.js";
9
10
  import { WsHandler } from "./overlay/ws-handler.js";
@@ -148,6 +149,693 @@ loadFacts();
148
149
  </script>
149
150
  </body></html>`;
150
151
 
152
+ // ──────────────────────────────────────────────────────────────────────────
153
+ // Knowledge UI V2 — "Living Confluence" SPA. Replaces the legacy fact-browser
154
+ // (still served at /knowledge/ui-legacy). Single inline file to preserve
155
+ // the zero-build single-file deploy story.
156
+ // ──────────────────────────────────────────────────────────────────────────
157
+ const KNOWLEDGE_UI_V2_HTML = `<!DOCTYPE html>
158
+ <html lang="en">
159
+ <head>
160
+ <meta charset="utf-8">
161
+ <meta name="viewport" content="width=device-width, initial-scale=1">
162
+ <title>Sinain Knowledge</title>
163
+ <style>
164
+ :root {
165
+ /* Day theme: neutral white/black/blue. Surfaces use light slate
166
+ grays for layered elevation (no tint), text is near-black, accent
167
+ is a single blue used for entity links, primary buttons, and the
168
+ summary border. Avoids any saturated greens/reds outside semantic
169
+ warn/danger usage. */
170
+ --bg: #ffffff; --bg-elev: #f8fafc; --bg-hover: #f1f5f9;
171
+ --fg: #0f172a; --fg-dim: #475569; --fg-faint: #94a3b8;
172
+ --accent: #2563eb; --accent-dim: #1d4ed8;
173
+ --warn: #b45309; --danger: #b91c1c;
174
+ --border: #e2e8f0; --border-strong: #cbd5e1;
175
+ --chip: #f1f5f9;
176
+ --shadow: 0 4px 18px rgba(15, 23, 42, 0.08);
177
+ }
178
+ * { box-sizing: border-box; }
179
+ body { margin: 0; background: var(--bg); color: var(--fg);
180
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
181
+ font-size: 14px; line-height: 1.5; }
182
+ a { color: var(--accent); text-decoration: none; }
183
+ a:hover { text-decoration: underline; }
184
+ button { background: var(--bg-elev); color: var(--fg); border: 1px solid var(--border);
185
+ padding: 6px 12px; border-radius: 6px; cursor: pointer; font: inherit; }
186
+ button:hover { background: var(--bg-hover); border-color: var(--border-strong); }
187
+ button.primary { background: var(--accent); color: #ffffff; border-color: var(--accent); font-weight: 600; }
188
+ button.primary:hover { background: var(--accent-dim); border-color: var(--accent-dim); }
189
+ button.danger { color: var(--danger); }
190
+ /* Destructive-primary (e.g. modal "Retract"): solid red, not the awkward
191
+ blue-bg + red-text combination of stacking the two classes. */
192
+ button.primary.danger { background: var(--danger); border-color: var(--danger); color: #ffffff; }
193
+ button.primary.danger:hover { background: #991b1b; border-color: #991b1b; color: #ffffff; }
194
+ button.icon { padding: 4px 8px; }
195
+ input, textarea, select { background: var(--bg-elev); color: var(--fg);
196
+ border: 1px solid var(--border); padding: 8px 12px; border-radius: 6px;
197
+ font: inherit; }
198
+ input:focus, textarea:focus { outline: none; border-color: var(--accent); }
199
+ /* Header */
200
+ header { position: sticky; top: 0; z-index: 100; background: var(--bg);
201
+ border-bottom: 1px solid var(--border); padding: 12px 24px;
202
+ display: flex; gap: 16px; align-items: center; }
203
+ .logo { font-weight: 700; color: var(--accent); font-size: 16px;
204
+ cursor: pointer; flex-shrink: 0; }
205
+ .search-wrap { position: relative; flex: 1; max-width: 600px; }
206
+ #search { width: 100%; padding: 10px 14px; font-size: 15px; }
207
+ .search-results { position: absolute; top: 100%; left: 0; right: 0;
208
+ background: var(--bg-elev); border: 1px solid var(--border);
209
+ border-radius: 6px; margin-top: 4px; max-height: 400px; overflow-y: auto;
210
+ box-shadow: var(--shadow); display: none; }
211
+ .search-results.open { display: block; }
212
+ .search-result { padding: 10px 14px; cursor: pointer; border-bottom: 1px solid var(--border); }
213
+ .search-result:hover { background: var(--bg-hover); }
214
+ .search-result:last-child { border-bottom: none; }
215
+ .search-result .entity { color: var(--accent); font-weight: 600; font-size: 13px; }
216
+ .search-result .meta { color: var(--fg-dim); font-size: 11px; margin-top: 2px; }
217
+ .search-result .snippet { color: var(--fg); font-size: 13px; margin-top: 4px; }
218
+ .header-actions { margin-left: auto; display: flex; gap: 8px; }
219
+ /* Main */
220
+ main { padding: 24px; max-width: 1400px; margin: 0 auto; }
221
+ h1 { color: var(--fg); font-size: 20px; margin: 0 0 16px; }
222
+ h2 { color: var(--fg); font-size: 16px; margin: 24px 0 12px; }
223
+ h3 { color: var(--fg); font-size: 14px; margin: 16px 0 8px; font-weight: 600; }
224
+ /* Home */
225
+ .bookmark-row { margin-bottom: 24px; }
226
+ .bookmark-row h2 { color: var(--accent); margin-bottom: 8px; }
227
+ .bookmark-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
228
+ gap: 10px; }
229
+ .bookmark-card { background: var(--bg-elev); border: 1px solid var(--border);
230
+ border-radius: 6px; padding: 12px; cursor: pointer;
231
+ transition: border-color 0.15s; }
232
+ .bookmark-card:hover { border-color: var(--accent-dim); }
233
+ .bookmark-card .entity { font-weight: 600; color: var(--accent); font-size: 13px;
234
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
235
+ .bookmark-card .meta { color: var(--fg-dim); font-size: 11px; margin-top: 4px; }
236
+ .empty-row { color: var(--fg-faint); font-style: italic; padding: 8px; }
237
+ /* Import dropzone */
238
+ .dropzone { border: 2px dashed var(--border-strong); border-radius: 8px;
239
+ padding: 32px; text-align: center; color: var(--fg-dim); cursor: pointer;
240
+ margin-top: 24px; transition: border-color 0.15s; }
241
+ .dropzone:hover, .dropzone.drag-over { border-color: var(--accent); color: var(--fg); }
242
+ /* Entity page */
243
+ .page-header { padding-bottom: 16px; border-bottom: 1px solid var(--border);
244
+ margin-bottom: 24px; display: flex; flex-wrap: wrap; gap: 12px;
245
+ align-items: center; }
246
+ .page-header .title { font-size: 22px; font-weight: 700; flex: 1; }
247
+ .page-header .badges { color: var(--fg-dim); font-size: 12px; }
248
+ .page-header .badge { background: var(--chip); padding: 3px 8px; border-radius: 4px;
249
+ margin-right: 6px; }
250
+ .page-actions { display: flex; gap: 6px; flex-wrap: wrap; }
251
+ .layout-3col { display: grid; grid-template-columns: 220px 1fr 240px; gap: 24px; }
252
+ @media (max-width: 1100px) { .layout-3col { grid-template-columns: 1fr; } }
253
+ /* Tree */
254
+ .tree { font-size: 12px; }
255
+ .tree-group { margin-bottom: 12px; }
256
+ .tree-group-label { color: var(--accent); font-weight: 600; font-size: 11px;
257
+ text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px; }
258
+ .tree-node { padding: 4px 6px; border-radius: 4px; cursor: pointer;
259
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
260
+ .tree-node:hover { background: var(--bg-hover); }
261
+ .tree-node.expandable::before { content: "▸ "; color: var(--fg-dim); }
262
+ .tree-node.expanded::before { content: "▾ "; color: var(--accent); }
263
+ .tree-children { padding-left: 12px; border-left: 1px solid var(--border); margin-left: 6px; }
264
+ /* Page body */
265
+ .summary { background: var(--bg-elev); border-left: 3px solid var(--accent);
266
+ padding: 16px; border-radius: 0 6px 6px 0; margin-bottom: 24px;
267
+ font-size: 15px; }
268
+ .section { margin-bottom: 24px; }
269
+ .section-heading { color: var(--fg); font-size: 17px; font-weight: 600;
270
+ border-bottom: 1px solid var(--border); padding-bottom: 6px;
271
+ margin-bottom: 12px; cursor: pointer; user-select: none; }
272
+ .section-heading::before { content: "▾ "; color: var(--fg-dim); }
273
+ .section.collapsed .section-heading::before { content: "▸ "; }
274
+ .section.collapsed .bullets { display: none; }
275
+ .bullets { list-style: none; padding: 0; margin: 0; }
276
+ .bullet { padding: 8px 12px; border-radius: 6px; margin-bottom: 4px;
277
+ display: flex; gap: 12px; align-items: flex-start;
278
+ transition: background 0.15s; position: relative; }
279
+ .bullet:hover { background: var(--bg-elev); }
280
+ .bullet.retracting { opacity: 0.4; text-decoration: line-through;
281
+ transition: opacity 0.3s, transform 0.3s; }
282
+ .bullet .text { flex: 1; }
283
+ .bullet .conf { color: var(--fg-faint); font-size: 11px; flex-shrink: 0;
284
+ font-variant-numeric: tabular-nums; }
285
+ .bullet .fid { color: var(--fg-faint); font-size: 10px; font-family: ui-monospace, monospace;
286
+ cursor: pointer; }
287
+ .bullet .fid:hover { color: var(--accent); }
288
+ .bullet .more { color: var(--fg-faint); cursor: pointer; padding: 0 4px;
289
+ visibility: hidden; }
290
+ .bullet:hover .more { visibility: visible; }
291
+ .bullet .more:hover { color: var(--accent); }
292
+ .section .notes { color: var(--warn); font-size: 12px; font-style: italic;
293
+ padding: 4px 12px; }
294
+ /* Raw facts accordion */
295
+ .raw-accordion { margin-top: 32px; border-top: 1px solid var(--border); padding-top: 16px; }
296
+ .raw-toggle { color: var(--accent); cursor: pointer; user-select: none; font-size: 13px; }
297
+ .raw-list { display: none; margin-top: 12px; }
298
+ .raw-list.open { display: block; }
299
+ .raw-item { padding: 6px 12px; font-family: ui-monospace, monospace; font-size: 11px;
300
+ color: var(--fg-dim); border-left: 2px solid var(--border); margin-bottom: 4px; }
301
+ /* Meta panel */
302
+ .meta-panel { font-size: 12px; color: var(--fg-dim); }
303
+ .meta-panel .stat-row { display: flex; justify-content: space-between;
304
+ padding: 4px 0; border-bottom: 1px solid var(--border); }
305
+ .meta-panel .stat-row:last-child { border-bottom: none; }
306
+ .meta-panel .stat-label { color: var(--fg-faint); }
307
+ .meta-panel .stat-value { color: var(--fg); font-variant-numeric: tabular-nums; }
308
+ /* Modal */
309
+ .modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6);
310
+ display: none; align-items: center; justify-content: center;
311
+ z-index: 1000; }
312
+ .modal-backdrop.open { display: flex; }
313
+ .modal { background: var(--bg-elev); border-radius: 8px; padding: 24px;
314
+ max-width: 480px; width: 90%; box-shadow: var(--shadow); }
315
+ .modal h2 { margin-top: 0; }
316
+ .modal .body { color: var(--fg); margin-bottom: 16px; }
317
+ .modal .quote { color: var(--fg); padding: 8px 12px; background: var(--bg);
318
+ border-left: 3px solid var(--warn); border-radius: 0 4px 4px 0;
319
+ margin: 8px 0; }
320
+ .modal label { display: block; margin: 12px 0 4px; color: var(--fg-dim); font-size: 12px; }
321
+ .modal textarea { width: 100%; min-height: 60px; resize: vertical; }
322
+ .modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
323
+ /* Toast */
324
+ .toast { position: fixed; bottom: 24px; right: 24px; background: var(--bg-elev);
325
+ border: 1px solid var(--border); border-radius: 8px; padding: 12px 16px;
326
+ box-shadow: var(--shadow); display: flex; gap: 12px; align-items: center;
327
+ min-width: 320px; max-width: 480px; z-index: 1000; animation: slidein 0.2s; }
328
+ @keyframes slidein { from { transform: translateY(100%); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
329
+ .toast .icon { color: var(--accent); }
330
+ .toast .text { flex: 1; font-size: 13px; }
331
+ .toast .timer { height: 2px; background: var(--accent); position: absolute;
332
+ bottom: 0; left: 0; transition: width linear; }
333
+ .toast button { padding: 4px 10px; font-size: 12px; }
334
+ /* Loading */
335
+ .spinner { display: inline-block; width: 14px; height: 14px;
336
+ border: 2px solid var(--border); border-top-color: var(--accent);
337
+ border-radius: 50%; animation: spin 0.8s linear infinite;
338
+ vertical-align: middle; }
339
+ @keyframes spin { to { transform: rotate(360deg); } }
340
+ .loading-block { padding: 32px; text-align: center; color: var(--fg-dim); }
341
+ .error-block { padding: 24px; background: rgba(185, 28, 28, 0.05);
342
+ border-left: 3px solid var(--danger); border-radius: 0 6px 6px 0;
343
+ color: var(--fg); }
344
+ </style>
345
+ </head>
346
+ <body>
347
+ <header>
348
+ <div class="logo" onclick="navigate('/knowledge/ui')">SINAIN</div>
349
+ <div class="search-wrap">
350
+ <input id="search" type="text" placeholder="Search entities, topics, people…" autocomplete="off" />
351
+ <div id="searchResults" class="search-results"></div>
352
+ </div>
353
+ <div class="header-actions">
354
+ <a href="/knowledge/ui-legacy"><button>Legacy view</button></a>
355
+ </div>
356
+ </header>
357
+ <main id="root"></main>
358
+ <div id="modalRoot"></div>
359
+ <div id="toastRoot"></div>
360
+
361
+ <script>
362
+ // ── Util ──────────────────────────────────────────────────────────────────
363
+ const esc = (s) => String(s ?? "").replace(/[&<>"']/g, c =>
364
+ ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
365
+ const $ = (sel) => document.querySelector(sel);
366
+ const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; };
367
+
368
+ // ── API ───────────────────────────────────────────────────────────────────
369
+ async function api(path, opts = {}) {
370
+ try {
371
+ const res = await fetch(path, opts);
372
+ if (!res.ok && res.status !== 404) {
373
+ // Some endpoints (e.g. retract on missing fact) return error JSON with non-200.
374
+ // We still try to parse — ok=false in body is the contract.
375
+ }
376
+ const contentType = res.headers.get("content-type") || "";
377
+ if (contentType.includes("json")) return await res.json();
378
+ return await res.text();
379
+ } catch (e) {
380
+ return { ok: false, error: String(e) };
381
+ }
382
+ }
383
+
384
+ // ── Router ────────────────────────────────────────────────────────────────
385
+ function navigate(path) {
386
+ history.pushState({}, "", path);
387
+ render();
388
+ }
389
+ window.addEventListener("popstate", render);
390
+ window.addEventListener("DOMContentLoaded", () => {
391
+ setupSearch();
392
+ setupGlobalDrop();
393
+ render();
394
+ });
395
+
396
+ function render() {
397
+ const path = location.pathname;
398
+ if (path === "/knowledge/ui" || path === "/knowledge/ui/") {
399
+ renderHome();
400
+ } else if (path.startsWith("/knowledge/ui/entity/")) {
401
+ const entity = decodeURIComponent(path.slice("/knowledge/ui/entity/".length));
402
+ renderEntityPage(entity);
403
+ } else if (path.startsWith("/knowledge/ui/topic/")) {
404
+ const q = decodeURIComponent(path.slice("/knowledge/ui/topic/".length));
405
+ renderTopicPage(q);
406
+ } else {
407
+ renderHome();
408
+ }
409
+ }
410
+
411
+ // ── Home view ─────────────────────────────────────────────────────────────
412
+ async function renderHome() {
413
+ document.title = "Sinain Knowledge";
414
+ const root = $("#root");
415
+ root.innerHTML = '<div class="loading-block"><span class="spinner"></span> Loading bookmarks…</div>';
416
+
417
+ const [favs, recents, archives] = await Promise.all([
418
+ api("/knowledge/bookmarks?status=favorite&limit=50"),
419
+ api("/knowledge/bookmarks?status=recent&limit=20"),
420
+ api("/knowledge/bookmarks?status=archive&limit=50"),
421
+ ]);
422
+
423
+ root.innerHTML = \`
424
+ <h1>Knowledge</h1>
425
+ \${renderBookmarkRow("★ Favorites", favs.bookmarks ?? [])}
426
+ \${renderBookmarkRow("📚 Recent", recents.bookmarks ?? [])}
427
+ \${renderBookmarkRow("🗄 Archive", archives.bookmarks ?? [])}
428
+ <h2>Import a concept</h2>
429
+ <div class="dropzone" id="homeDropzone">
430
+ Drop a <code>.sinain-concept.json</code> file here, or click to choose.
431
+ <input type="file" id="homeFileInput" accept=".json,.sinain-concept.json"
432
+ style="display:none" />
433
+ </div>
434
+ \`;
435
+ bindDropzone($("#homeDropzone"), $("#homeFileInput"));
436
+ }
437
+
438
+ function renderBookmarkRow(label, items) {
439
+ const cards = items.length === 0
440
+ ? '<div class="empty-row">— none yet —</div>'
441
+ : items.map(b => \`
442
+ <div class="bookmark-card" onclick="navigate('/knowledge/ui/entity/' + encodeURIComponent('\${esc(b.entity_id)}'))">
443
+ <div class="entity">\${esc(b.entity_id)}</div>
444
+ <div class="meta">\${b.note ? esc(b.note) + ' · ' : ''}visited \${timeAgo(b.last_visited)}</div>
445
+ </div>\`).join("");
446
+ return \`<div class="bookmark-row"><h2>\${label}</h2><div class="bookmark-list">\${cards}</div></div>\`;
447
+ }
448
+
449
+ function timeAgo(ts) {
450
+ const diff = Date.now() - ts;
451
+ if (diff < 60_000) return "just now";
452
+ if (diff < 3_600_000) return Math.round(diff / 60_000) + "m ago";
453
+ if (diff < 86_400_000) return Math.round(diff / 3_600_000) + "h ago";
454
+ return Math.round(diff / 86_400_000) + "d ago";
455
+ }
456
+
457
+ // ── Search ────────────────────────────────────────────────────────────────
458
+ function setupSearch() {
459
+ const input = $("#search");
460
+ const dropdown = $("#searchResults");
461
+ const handleQuery = debounce(async () => {
462
+ const q = input.value.trim();
463
+ if (!q) { dropdown.classList.remove("open"); dropdown.innerHTML = ""; return; }
464
+ const result = await api("/knowledge/search?q=" + encodeURIComponent(q) + "&limit=15");
465
+ if (!result.results || result.results.length === 0) {
466
+ dropdown.innerHTML = \`
467
+ <div class="search-result" onclick="navigate('/knowledge/ui/topic/' + encodeURIComponent('\${esc(q)}'))">
468
+ <div class="entity">View as topic page</div>
469
+ <div class="snippet">No matching entities — synthesize from search hits.</div>
470
+ </div>\`;
471
+ } else {
472
+ dropdown.innerHTML = result.results.map(r => \`
473
+ <div class="search-result" onclick="navigate('/knowledge/ui/entity/' + encodeURIComponent('\${esc(r.entity)}'))">
474
+ <div class="entity">\${esc(r.entity)}</div>
475
+ <div class="meta">\${esc(r.type)} · \${r.fact_count} fact\${r.fact_count === 1 ? "" : "s"}</div>
476
+ <div class="snippet">\${esc(r.snippet || "")}</div>
477
+ </div>\`).join("");
478
+ }
479
+ dropdown.classList.add("open");
480
+ }, 220);
481
+ input.addEventListener("input", handleQuery);
482
+ input.addEventListener("focus", () => { if (input.value) handleQuery(); });
483
+ document.addEventListener("click", (e) => {
484
+ if (!e.target.closest(".search-wrap")) dropdown.classList.remove("open");
485
+ });
486
+ }
487
+
488
+ // ── Entity page ───────────────────────────────────────────────────────────
489
+ async function renderEntityPage(entity) {
490
+ document.title = entity + " · Sinain";
491
+ const root = $("#root");
492
+ root.innerHTML = \`<div class="loading-block"><span class="spinner"></span> Loading \${esc(entity)}…</div>\`;
493
+
494
+ const page = await api("/knowledge/page?entity=" + encodeURIComponent(entity));
495
+ if (!page.ok || page.fact_count === 0) {
496
+ if (page.fact_count === 0) {
497
+ renderMissingConcept(entity, root);
498
+ return;
499
+ }
500
+ root.innerHTML = \`<div class="error-block">Failed to load: \${esc(page.error || "unknown")}</div>\`;
501
+ return;
502
+ }
503
+
504
+ const factCount = page.fact_count;
505
+ const facts = collectFactsFromSections(page.sections || []);
506
+
507
+ root.innerHTML = \`
508
+ <div class="page-header">
509
+ <div class="title">\${esc(entity)}</div>
510
+ <div class="badges">
511
+ <span class="badge">\${factCount} fact\${factCount === 1 ? "" : "s"}</span>
512
+ \${page.stats?.from_cache ? '<span class="badge">cached</span>' : '<span class="badge">fresh</span>'}
513
+ </div>
514
+ <div class="page-actions">
515
+ <button id="bmFavorite" class="icon" title="Favorite">★</button>
516
+ <button id="bmArchive" class="icon" title="Archive">🗄</button>
517
+ <button id="actRefresh" class="icon" title="Re-render">↻</button>
518
+ <button id="actCopyLink" class="icon" title="Copy link">🔗</button>
519
+ <button id="actExport" class="icon" title="Export concept">⬇</button>
520
+ </div>
521
+ </div>
522
+ <div class="layout-3col">
523
+ <aside><div id="treeRoot" class="tree"></div></aside>
524
+ <div>
525
+ \${page.summary ? \`<div class="summary">\${esc(page.summary)}</div>\` : ""}
526
+ <div id="sectionsRoot">\${(page.sections || []).map((s, i) => renderSection(s, i)).join("")}</div>
527
+ <div class="raw-accordion">
528
+ <div class="raw-toggle" onclick="this.nextElementSibling.classList.toggle('open')">
529
+ ▸ Show all \${factCount} raw fact\${factCount === 1 ? "" : "s"}
530
+ </div>
531
+ <div class="raw-list">\${facts.map(f => \`
532
+ <div class="raw-item">[\${esc(f.fact_id)}] (conf=\${f.confidence}, \${esc(f.domain || "")}): \${esc(f.text || "")}</div>
533
+ \`).join("")}</div>
534
+ </div>
535
+ </div>
536
+ <aside class="meta-panel">
537
+ <h3>Stats</h3>
538
+ <div class="stat-row"><span class="stat-label">Facts</span><span class="stat-value">\${factCount}</span></div>
539
+ <div class="stat-row"><span class="stat-label">Used</span><span class="stat-value">\${page.facts_used ?? factCount}</span></div>
540
+ <div class="stat-row"><span class="stat-label">Tx watermark</span><span class="stat-value">\${page.tx_watermark}</span></div>
541
+ \${page.stats?.tokens_in ? \`<div class="stat-row"><span class="stat-label">Tokens in</span><span class="stat-value">\${page.stats.tokens_in}</span></div>\` : ""}
542
+ \${page.stats?.tokens_out ? \`<div class="stat-row"><span class="stat-label">Tokens out</span><span class="stat-value">\${page.stats.tokens_out}</span></div>\` : ""}
543
+ \${page.stats?.dropped_bullets ? \`<div class="stat-row"><span class="stat-label">Dropped (LLM)</span><span class="stat-value">\${page.stats.dropped_bullets}</span></div>\` : ""}
544
+ </aside>
545
+ </div>\`;
546
+
547
+ // Wire actions
548
+ $("#bmFavorite").onclick = () => bookmarkAction(entity, "favorite");
549
+ $("#bmArchive").onclick = () => bookmarkAction(entity, "archive");
550
+ $("#actRefresh").onclick = () => refreshPage(entity);
551
+ $("#actCopyLink").onclick = () => copyLink(entity);
552
+ $("#actExport").onclick = () => exportConcept(entity);
553
+
554
+ // Wire bullet retraction (event delegation)
555
+ $("#sectionsRoot").addEventListener("click", (e) => {
556
+ const more = e.target.closest(".more");
557
+ if (more) {
558
+ const factId = more.dataset.factId;
559
+ openRetractModal(factId, more.closest(".bullet"), entity);
560
+ }
561
+ });
562
+
563
+ // Tree
564
+ loadTreeChildren(entity, $("#treeRoot"), 0);
565
+ }
566
+
567
+ function collectFactsFromSections(sections) {
568
+ const out = [];
569
+ for (const s of sections) {
570
+ for (const b of s.bullets || []) out.push(b);
571
+ }
572
+ return out;
573
+ }
574
+
575
+ function renderSection(s, idx) {
576
+ return \`
577
+ <div class="section" id="sec-\${idx}">
578
+ <div class="section-heading" onclick="this.parentElement.classList.toggle('collapsed')">
579
+ \${esc(s.heading || "Untitled")}
580
+ </div>
581
+ \${s.notes ? \`<div class="notes">⚠ \${esc(s.notes)}</div>\` : ""}
582
+ <ul class="bullets">\${(s.bullets || []).map(b => \`
583
+ <li class="bullet" data-fact-id="\${esc(b.fact_id)}">
584
+ <span class="text">\${esc(b.text || "")}</span>
585
+ <span class="conf">\${b.confidence != null ? Number(b.confidence).toFixed(2) : ""}</span>
586
+ <span class="fid" title="\${esc(b.fact_id)}">[\${esc((b.fact_id || "").slice(0, 16))}…]</span>
587
+ <span class="more" data-fact-id="\${esc(b.fact_id)}" title="More">⋯</span>
588
+ </li>\`).join("")}</ul>
589
+ </div>\`;
590
+ }
591
+
592
+ // ── Tree (graph children) ─────────────────────────────────────────────────
593
+ async function loadTreeChildren(entity, container, depth) {
594
+ if (depth > 3) {
595
+ container.innerHTML = '<div class="empty-row">depth limit</div>';
596
+ return;
597
+ }
598
+ const result = await api("/knowledge/graph/children?entity=" + encodeURIComponent(entity));
599
+ if (!result.groups || result.groups.length === 0) {
600
+ container.innerHTML = '<div class="empty-row">no children</div>';
601
+ return;
602
+ }
603
+ container.innerHTML = result.groups.map(g => \`
604
+ <div class="tree-group">
605
+ <div class="tree-group-label">\${esc(g.label)} (\${g.children.length})</div>
606
+ \${g.children.map(c => {
607
+ // Prefer the fact's own value text as the visible label; entity_id
608
+ // slugs are opaque to humans. Show the slug only when no snippet.
609
+ const label = c.snippet || c.entity.split(":").pop() || c.entity;
610
+ return \`
611
+ <div class="tree-node \${c.expandable ? 'expandable' : ''}" data-entity="\${esc(c.entity)}" title="\${esc(c.entity)}">
612
+ \${esc(label.length > 36 ? label.slice(0, 36) + "…" : label)}
613
+ </div>
614
+ <div class="tree-children" data-parent="\${esc(c.entity)}" style="display:none"></div>\`;
615
+ }).join("")}
616
+ </div>\`).join("");
617
+ // Wire expand
618
+ container.querySelectorAll(".tree-node").forEach(node => {
619
+ node.addEventListener("click", async () => {
620
+ const eid = node.dataset.entity;
621
+ const child = container.querySelector('[data-parent="' + CSS.escape(eid) + '"]');
622
+ if (node.classList.contains("expanded")) {
623
+ node.classList.remove("expanded");
624
+ child.style.display = "none";
625
+ } else if (node.classList.contains("expandable")) {
626
+ node.classList.add("expanded");
627
+ child.style.display = "block";
628
+ if (!child.dataset.loaded) {
629
+ child.dataset.loaded = "1";
630
+ await loadTreeChildren(eid, child, depth + 1);
631
+ }
632
+ } else {
633
+ navigate("/knowledge/ui/entity/" + encodeURIComponent(eid));
634
+ }
635
+ });
636
+ });
637
+ }
638
+
639
+ // ── Bookmark + actions ────────────────────────────────────────────────────
640
+ async function bookmarkAction(entity, status) {
641
+ const r = await api("/knowledge/bookmarks", {
642
+ method: "POST", headers: { "Content-Type": "application/json" },
643
+ body: JSON.stringify({ entity, status }),
644
+ });
645
+ if (r.ok) showToast(\`✓ \${status === "favorite" ? "Favorited" : "Archived"}\`);
646
+ else showToast("Failed: " + (r.error || "unknown"));
647
+ }
648
+
649
+ async function refreshPage(entity) {
650
+ showToast(\`<span class="spinner"></span> Re-rendering…\`);
651
+ await api("/knowledge/page?refresh=1&entity=" + encodeURIComponent(entity));
652
+ render();
653
+ }
654
+
655
+ function copyLink(entity) {
656
+ const url = location.origin + "/knowledge/ui/entity/" + encodeURIComponent(entity);
657
+ navigator.clipboard.writeText(url).then(
658
+ () => showToast("✓ Link copied. Recipient needs the concept imported."),
659
+ () => showToast("Copy failed — your browser may block clipboard access"),
660
+ );
661
+ }
662
+
663
+ async function exportConcept(entity) {
664
+ // Simple export — no preflight dialog in v1, sensible defaults.
665
+ const url = "/knowledge/concepts/export?entity=" + encodeURIComponent(entity)
666
+ + "&depth=1&include_page=1";
667
+ const a = document.createElement("a");
668
+ a.href = url;
669
+ a.download = entity.replace(/[^a-z0-9-]/gi, "-") + ".sinain-concept.json";
670
+ document.body.appendChild(a);
671
+ a.click();
672
+ a.remove();
673
+ showToast("✓ Exporting concept bundle…");
674
+ }
675
+
676
+ // ── Retraction modal + undo toast ─────────────────────────────────────────
677
+ function openRetractModal(factId, bulletEl, sourceEntity) {
678
+ const text = bulletEl.querySelector(".text").textContent;
679
+ const conf = bulletEl.querySelector(".conf").textContent;
680
+ const root = $("#modalRoot");
681
+ root.innerHTML = \`
682
+ <div class="modal-backdrop open" id="retractModal">
683
+ <div class="modal">
684
+ <h2>Retract this fact?</h2>
685
+ <div class="quote">\${esc(text)}</div>
686
+ <div class="body">Confidence \${esc(conf)} · Fact id <code>\${esc(factId)}</code></div>
687
+ <label>Reason (optional)</label>
688
+ <textarea id="retractReason" placeholder="Why are you retracting this?"></textarea>
689
+ <div class="modal-actions">
690
+ <button onclick="closeModal()">Cancel</button>
691
+ <button class="primary danger" id="retractGo">Retract</button>
692
+ </div>
693
+ </div>
694
+ </div>\`;
695
+ $("#retractGo").onclick = async () => {
696
+ const reason = $("#retractReason").value.trim() || null;
697
+ closeModal();
698
+ bulletEl.classList.add("retracting");
699
+ const r = await api("/knowledge/facts/" + encodeURIComponent(factId), {
700
+ method: "DELETE", headers: { "Content-Type": "application/json" },
701
+ body: JSON.stringify({ reason, actor: "web-ui", source_entity: sourceEntity }),
702
+ });
703
+ if (r.ok) {
704
+ setTimeout(() => bulletEl.remove(), 300);
705
+ showUndoToast(factId, r.undo_token, sourceEntity);
706
+ } else {
707
+ bulletEl.classList.remove("retracting");
708
+ showToast("Retract failed: " + (r.error || "unknown"));
709
+ }
710
+ };
711
+ }
712
+ function closeModal() { $("#modalRoot").innerHTML = ""; }
713
+
714
+ function showToast(html, ms = 4000) {
715
+ const root = $("#toastRoot");
716
+ root.innerHTML = \`<div class="toast"><div class="text">\${html}</div></div>\`;
717
+ setTimeout(() => { if (root.innerHTML.includes(html)) root.innerHTML = ""; }, ms);
718
+ }
719
+
720
+ function showUndoToast(factId, undoToken, sourceEntity) {
721
+ const root = $("#toastRoot");
722
+ const ms = 10_000;
723
+ root.innerHTML = \`
724
+ <div class="toast" id="undoToast">
725
+ <span class="icon">✓</span>
726
+ <span class="text">Retracted</span>
727
+ <button id="undoBtn">Undo</button>
728
+ <div class="timer" style="width:100%; transition: width \${ms}ms linear;"></div>
729
+ </div>\`;
730
+ // Animate timer
731
+ requestAnimationFrame(() => { $("#undoToast .timer").style.width = "0%"; });
732
+ $("#undoBtn").onclick = async () => {
733
+ root.innerHTML = "";
734
+ const r = await api("/knowledge/facts/" + encodeURIComponent(factId) + "/restore", {
735
+ method: "POST", headers: { "Content-Type": "application/json" },
736
+ body: JSON.stringify({ undo_token: undoToken }),
737
+ });
738
+ if (r.ok) {
739
+ showToast("✓ Restored — reload to see");
740
+ } else {
741
+ showToast("Restore failed: " + (r.error || "unknown"));
742
+ }
743
+ };
744
+ setTimeout(() => { if ($("#undoToast")) root.innerHTML = ""; }, ms);
745
+ }
746
+
747
+ // ── Missing concept landing ───────────────────────────────────────────────
748
+ function renderMissingConcept(entity, root) {
749
+ document.title = "Missing · " + entity;
750
+ root.innerHTML = \`
751
+ <h1>Concept not found</h1>
752
+ <div class="error-block" style="margin-bottom: 24px;">
753
+ <code>\${esc(entity)}</code> is not in this machine's knowledge graph yet.
754
+ </div>
755
+ <p>If someone shared a <code>.sinain-concept.json</code> bundle with you, drop it here:</p>
756
+ <div class="dropzone" id="missingDropzone">
757
+ 📥 Drop concept bundle
758
+ <input type="file" id="missingFileInput" accept=".json,.sinain-concept.json"
759
+ style="display:none" />
760
+ </div>
761
+ <p style="color: var(--fg-dim); margin-top:24px">After import, this page will load automatically.</p>
762
+ \`;
763
+ bindDropzone($("#missingDropzone"), $("#missingFileInput"), entity);
764
+ }
765
+
766
+ // ── Topic page (simple, v1) ───────────────────────────────────────────────
767
+ async function renderTopicPage(q) {
768
+ document.title = "Topic: " + q;
769
+ const root = $("#root");
770
+ root.innerHTML = \`
771
+ <h1>Topic: \${esc(q)}</h1>
772
+ <div class="loading-block"><span class="spinner"></span> Searching…</div>\`;
773
+ const r = await api("/knowledge/search?q=" + encodeURIComponent(q) + "&limit=50");
774
+ if (!r.results || r.results.length === 0) {
775
+ root.innerHTML = \`<h1>Topic: \${esc(q)}</h1>
776
+ <div class="error-block">No matching facts.</div>\`;
777
+ return;
778
+ }
779
+ root.innerHTML = \`
780
+ <h1>Topic: \${esc(q)}</h1>
781
+ <div class="summary">Top \${r.results.length} matches across the knowledge graph.</div>
782
+ \${r.results.map(rr => \`
783
+ <div class="bullet" onclick="navigate('/knowledge/ui/entity/' + encodeURIComponent('\${esc(rr.entity)}'))" style="cursor:pointer">
784
+ <span class="text"><strong>\${esc(rr.entity)}</strong> — \${esc(rr.snippet || "")}</span>
785
+ <span class="conf">\${rr.fact_count} fact\${rr.fact_count === 1 ? "" : "s"}</span>
786
+ </div>\`).join("")}\`;
787
+ }
788
+
789
+ // ── Dropzone wiring (shared) ──────────────────────────────────────────────
790
+ function bindDropzone(zone, input, redirectAfter) {
791
+ zone.addEventListener("click", () => input.click());
792
+ input.addEventListener("change", (e) => importFiles(e.target.files, redirectAfter));
793
+ zone.addEventListener("dragover", (e) => { e.preventDefault(); zone.classList.add("drag-over"); });
794
+ zone.addEventListener("dragleave", () => zone.classList.remove("drag-over"));
795
+ zone.addEventListener("drop", (e) => {
796
+ e.preventDefault();
797
+ zone.classList.remove("drag-over");
798
+ importFiles(e.dataTransfer.files, redirectAfter);
799
+ });
800
+ }
801
+
802
+ function setupGlobalDrop() {
803
+ // Prevent navigation when files dropped outside the dropzone.
804
+ window.addEventListener("dragover", (e) => e.preventDefault());
805
+ window.addEventListener("drop", (e) => {
806
+ if (e.target.closest(".dropzone")) return;
807
+ e.preventDefault();
808
+ });
809
+ }
810
+
811
+ async function importFiles(files, redirectAfter) {
812
+ if (!files || files.length === 0) return;
813
+ const file = files[0];
814
+ showToast(\`<span class="spinner"></span> Importing \${esc(file.name)}…\`, 30_000);
815
+ const text = await file.text();
816
+ let envelope;
817
+ try { envelope = JSON.parse(text); } catch (e) {
818
+ showToast("Import failed: not valid JSON");
819
+ return;
820
+ }
821
+ const r = await api("/knowledge/concepts/import?conflict=merge", {
822
+ method: "POST", headers: { "Content-Type": "application/json" },
823
+ body: JSON.stringify(envelope),
824
+ });
825
+ if (r.ok) {
826
+ const stats = r.stats || {};
827
+ showToast(\`✓ Imported \${stats.triples_inserted || 0} triple\${stats.triples_inserted === 1 ? "" : "s"}\` +
828
+ (stats.triples_skipped_duplicate ? \` (\${stats.triples_skipped_duplicate} dupes skipped)\` : ""));
829
+ const target = r.root_entity || redirectAfter;
830
+ if (target) navigate("/knowledge/ui/entity/" + encodeURIComponent(target));
831
+ else render();
832
+ } else {
833
+ showToast("Import failed: " + (r.error || "unknown"));
834
+ }
835
+ }
836
+ </script>
837
+ </body></html>`;
838
+
151
839
  /** Server epoch — lets clients detect restarts. */
152
840
  const serverEpoch = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
153
841
 
@@ -188,6 +876,23 @@ export interface ServerDeps {
188
876
  embedTexts?: (texts: string[]) => Promise<Float32Array[]>;
189
877
  isEmbeddingReady?: () => boolean;
190
878
 
879
+ /** Web UI metadata DB (bookmarks, page cache, retraction undo). */
880
+ webDb?: WebDb;
881
+ /** Search entities by query (FTS5 + entity ref grouping). */
882
+ searchEntities?: (q: string, limit: number) => Promise<unknown>;
883
+ /** Lazy-load entity graph children (one level via VAET backref). */
884
+ graphChildren?: (entity: string) => Promise<unknown>;
885
+ /** Render a Confluence-style page for an entity (cached via web.db). */
886
+ renderEntityPage?: (entity: string, opts: { refresh: boolean; maxFacts: number }) => Promise<unknown>;
887
+ /** Retract a fact entity (soft-delete in triplestore + audit triples + undo snapshot). */
888
+ retractFact?: (factId: string, reason: string | null, actor: string | null, sourceEntity: string | null) => Promise<unknown>;
889
+ /** Restore a previously retracted fact via undo token. */
890
+ restoreFact?: (factId: string, undoToken: string) => Promise<unknown>;
891
+ /** Export a concept bundle (entity + neighborhood) for transfer between machines. */
892
+ exportConcept?: (entity: string, depth: number, opts: { includeRetracted: boolean; includePage: boolean; redactRules: string[] }) => Promise<unknown>;
893
+ /** Import a concept bundle into the local knowledge graph. */
894
+ importConcept?: (envelope: unknown, conflict: "skip" | "merge" | "overwrite") => Promise<unknown>;
895
+
191
896
  /** Bare-agent announced its roster on startup. */
192
897
  registerBareAgent?: (available: string[], current: string) => void;
193
898
  /** Current per-lane agent choice; read by run.sh via the piggyback field
@@ -511,13 +1216,305 @@ export function createAppServer(deps: ServerDeps) {
511
1216
  return;
512
1217
  }
513
1218
 
514
- if (req.method === "GET" && url.pathname === "/knowledge/ui") {
515
- // Simple web UI for browsing and transferring knowledge
1219
+ // ── /knowledge/search ── (entity-prioritized) ──
1220
+ if (req.method === "GET" && url.pathname === "/knowledge/search") {
1221
+ const q = url.searchParams.get("q") || "";
1222
+ const limit = Math.min(parseInt(url.searchParams.get("limit") || "20"), 100);
1223
+ if (!q.trim()) {
1224
+ res.writeHead(400);
1225
+ res.end(JSON.stringify({ ok: false, error: "q parameter required" }));
1226
+ return;
1227
+ }
1228
+ if (!deps.searchEntities) {
1229
+ res.end(JSON.stringify({ ok: true, results: [], topic_fallback: true }));
1230
+ return;
1231
+ }
1232
+ try {
1233
+ const result = await deps.searchEntities(q, limit) as any;
1234
+ // Telemetry
1235
+ if (deps.webDb) {
1236
+ const top = result.results?.[0]?.entity ?? null;
1237
+ deps.webDb.logSearch(q, top, result.results?.length ?? 0);
1238
+ }
1239
+ res.end(JSON.stringify({ ok: true, ...result }));
1240
+ } catch (err) {
1241
+ res.end(JSON.stringify({ ok: false, error: String(err) }));
1242
+ }
1243
+ return;
1244
+ }
1245
+
1246
+ // ── /knowledge/page ── (LLM-rendered Confluence-style page) ──
1247
+ if (req.method === "GET" && url.pathname === "/knowledge/page") {
1248
+ const entity = url.searchParams.get("entity") || "";
1249
+ const refresh = url.searchParams.get("refresh") === "1";
1250
+ const maxFacts = Math.min(parseInt(url.searchParams.get("max_facts") || "1000"), 5000);
1251
+ if (!entity) {
1252
+ res.writeHead(400);
1253
+ res.end(JSON.stringify({ ok: false, error: "entity parameter required" }));
1254
+ return;
1255
+ }
1256
+ if (!deps.renderEntityPage) {
1257
+ res.writeHead(503);
1258
+ res.end(JSON.stringify({ ok: false, error: "page renderer not available" }));
1259
+ return;
1260
+ }
1261
+ try {
1262
+ const page = await deps.renderEntityPage(entity, { refresh, maxFacts });
1263
+ // Touch bookmark visit (auto-populates 'recent')
1264
+ if (deps.webDb) deps.webDb.touchVisit(entity);
1265
+ res.end(JSON.stringify({ ok: true, ...(page as object) }));
1266
+ } catch (err) {
1267
+ res.writeHead(500);
1268
+ res.end(JSON.stringify({ ok: false, error: String(err) }));
1269
+ }
1270
+ return;
1271
+ }
1272
+
1273
+ // ── /knowledge/graph/children ── (lazy tree expansion) ──
1274
+ if (req.method === "GET" && url.pathname === "/knowledge/graph/children") {
1275
+ const entity = url.searchParams.get("entity") || "";
1276
+ if (!entity) {
1277
+ res.writeHead(400);
1278
+ res.end(JSON.stringify({ ok: false, error: "entity parameter required" }));
1279
+ return;
1280
+ }
1281
+ if (!deps.graphChildren) {
1282
+ res.end(JSON.stringify({ ok: true, entity, groups: [] }));
1283
+ return;
1284
+ }
1285
+ try {
1286
+ const result = await deps.graphChildren(entity);
1287
+ res.end(JSON.stringify({ ok: true, ...(result as object) }));
1288
+ } catch (err) {
1289
+ res.end(JSON.stringify({ ok: false, error: String(err) }));
1290
+ }
1291
+ return;
1292
+ }
1293
+
1294
+ // ── /knowledge/bookmarks ──
1295
+ if (req.method === "GET" && url.pathname === "/knowledge/bookmarks") {
1296
+ if (!deps.webDb) {
1297
+ res.writeHead(503);
1298
+ res.end(JSON.stringify({ ok: false, error: "web.db not initialized" }));
1299
+ return;
1300
+ }
1301
+ const status = url.searchParams.get("status") as BookmarkStatus | null;
1302
+ const limit = Math.min(parseInt(url.searchParams.get("limit") || "100"), 500);
1303
+ if (status && !["favorite","archive","recent"].includes(status)) {
1304
+ res.writeHead(400);
1305
+ res.end(JSON.stringify({ ok: false, error: "status must be favorite|archive|recent" }));
1306
+ return;
1307
+ }
1308
+ const bookmarks = deps.webDb.listBookmarks(status ?? undefined, limit);
1309
+ res.end(JSON.stringify({ ok: true, bookmarks }));
1310
+ return;
1311
+ }
1312
+
1313
+ if (req.method === "POST" && url.pathname === "/knowledge/bookmarks") {
1314
+ if (!deps.webDb) {
1315
+ res.writeHead(503);
1316
+ res.end(JSON.stringify({ ok: false, error: "web.db not initialized" }));
1317
+ return;
1318
+ }
1319
+ const body = await readBody(req, 16_384);
1320
+ let payload: { entity?: string; status?: string; note?: string };
1321
+ try { payload = JSON.parse(body); } catch {
1322
+ res.writeHead(400);
1323
+ res.end(JSON.stringify({ ok: false, error: "invalid JSON body" }));
1324
+ return;
1325
+ }
1326
+ const entity = (payload.entity || "").trim();
1327
+ const status = payload.status as BookmarkStatus | undefined;
1328
+ if (!entity) {
1329
+ res.writeHead(400);
1330
+ res.end(JSON.stringify({ ok: false, error: "entity required" }));
1331
+ return;
1332
+ }
1333
+ if (!status || !["favorite","archive","recent"].includes(status)) {
1334
+ res.writeHead(400);
1335
+ res.end(JSON.stringify({ ok: false, error: "status must be favorite|archive|recent" }));
1336
+ return;
1337
+ }
1338
+ const bookmark = deps.webDb.upsertBookmark(entity, status, payload.note);
1339
+ res.end(JSON.stringify({ ok: true, bookmark }));
1340
+ return;
1341
+ }
1342
+
1343
+ if (req.method === "DELETE" && url.pathname.startsWith("/knowledge/bookmarks/")) {
1344
+ if (!deps.webDb) {
1345
+ res.writeHead(503);
1346
+ res.end(JSON.stringify({ ok: false, error: "web.db not initialized" }));
1347
+ return;
1348
+ }
1349
+ const entity = decodeURIComponent(url.pathname.slice("/knowledge/bookmarks/".length));
1350
+ if (!entity) {
1351
+ res.writeHead(400);
1352
+ res.end(JSON.stringify({ ok: false, error: "entity required in path" }));
1353
+ return;
1354
+ }
1355
+ const removed = deps.webDb.deleteBookmark(entity);
1356
+ res.end(JSON.stringify({ ok: true, removed }));
1357
+ return;
1358
+ }
1359
+
1360
+ // ── /knowledge/concepts/export ──
1361
+ if (req.method === "GET" && url.pathname === "/knowledge/concepts/export") {
1362
+ const entity = url.searchParams.get("entity") || "";
1363
+ const depth = Math.min(parseInt(url.searchParams.get("depth") || "1"), 3);
1364
+ const includeRetracted = url.searchParams.get("include_retracted") === "1";
1365
+ const includePage = url.searchParams.get("include_page") !== "0";
1366
+ const redactRules = (url.searchParams.get("redact")
1367
+ || "private,creditcard,apikey,bearer,awskey,password,secret")
1368
+ .split(",").map(s => s.trim()).filter(Boolean);
1369
+ if (!entity) {
1370
+ res.writeHead(400);
1371
+ res.end(JSON.stringify({ ok: false, error: "entity parameter required" }));
1372
+ return;
1373
+ }
1374
+ if (!deps.exportConcept) {
1375
+ res.writeHead(503);
1376
+ res.end(JSON.stringify({ ok: false, error: "concept export not available" }));
1377
+ return;
1378
+ }
1379
+ try {
1380
+ const bundle = await deps.exportConcept(entity, depth, {
1381
+ includeRetracted, includePage, redactRules,
1382
+ });
1383
+ // Sanitize entity for filename
1384
+ const slug = entity.replace(/[^a-z0-9-]/gi, "-").replace(/-+/g, "-").slice(0, 60);
1385
+ res.setHeader("Content-Type", "application/json");
1386
+ res.setHeader("Content-Disposition", `attachment; filename="${slug}.sinain-concept.json"`);
1387
+ res.end(JSON.stringify(bundle, null, 2));
1388
+ } catch (err) {
1389
+ res.writeHead(500);
1390
+ res.end(JSON.stringify({ ok: false, error: String(err) }));
1391
+ }
1392
+ return;
1393
+ }
1394
+
1395
+ // ── /knowledge/concepts/import ──
1396
+ if (req.method === "POST" && url.pathname === "/knowledge/concepts/import") {
1397
+ const conflict = (url.searchParams.get("conflict") || "merge") as "skip"|"merge"|"overwrite";
1398
+ if (!["skip","merge","overwrite"].includes(conflict)) {
1399
+ res.writeHead(400);
1400
+ res.end(JSON.stringify({ ok: false, error: "conflict must be skip|merge|overwrite" }));
1401
+ return;
1402
+ }
1403
+ if (!deps.importConcept) {
1404
+ res.writeHead(503);
1405
+ res.end(JSON.stringify({ ok: false, error: "concept import not available" }));
1406
+ return;
1407
+ }
1408
+ // Allow large bundles (up to ~50MB).
1409
+ const body = await readBody(req, 50 * 1024 * 1024);
1410
+ let envelope: unknown;
1411
+ try { envelope = JSON.parse(body); } catch {
1412
+ res.writeHead(400);
1413
+ res.end(JSON.stringify({ ok: false, error: "invalid JSON body" }));
1414
+ return;
1415
+ }
1416
+ try {
1417
+ const result = await deps.importConcept(envelope, conflict);
1418
+ res.end(JSON.stringify(result));
1419
+ } catch (err) {
1420
+ res.writeHead(500);
1421
+ res.end(JSON.stringify({ ok: false, error: String(err) }));
1422
+ }
1423
+ return;
1424
+ }
1425
+
1426
+ // ── /knowledge/facts/:id (DELETE = retract, POST .../restore = restore) ──
1427
+ if (req.method === "DELETE" && url.pathname.startsWith("/knowledge/facts/")
1428
+ && !url.pathname.endsWith("/restore")) {
1429
+ const factId = decodeURIComponent(url.pathname.slice("/knowledge/facts/".length));
1430
+ if (!factId) {
1431
+ res.writeHead(400);
1432
+ res.end(JSON.stringify({ ok: false, error: "fact id required in path" }));
1433
+ return;
1434
+ }
1435
+ if (!deps.retractFact) {
1436
+ res.writeHead(503);
1437
+ res.end(JSON.stringify({ ok: false, error: "retraction not available" }));
1438
+ return;
1439
+ }
1440
+ let body: any = {};
1441
+ if (parseInt(req.headers["content-length"] || "0") > 0) {
1442
+ try { body = JSON.parse(await readBody(req, 4096)); } catch {}
1443
+ }
1444
+ const reason = (body.reason || "").toString().slice(0, 500) || null;
1445
+ const actor = (body.actor || "web-ui").toString().slice(0, 80) || null;
1446
+ const sourceEntity = (body.source_entity || "").toString().slice(0, 200) || null;
1447
+ try {
1448
+ const result = await deps.retractFact(factId, reason, actor, sourceEntity);
1449
+ res.end(JSON.stringify(result));
1450
+ } catch (err) {
1451
+ res.writeHead(500);
1452
+ res.end(JSON.stringify({ ok: false, error: String(err) }));
1453
+ }
1454
+ return;
1455
+ }
1456
+
1457
+ if (req.method === "POST" && url.pathname.startsWith("/knowledge/facts/")
1458
+ && url.pathname.endsWith("/restore")) {
1459
+ const factId = decodeURIComponent(
1460
+ url.pathname.slice("/knowledge/facts/".length, -"/restore".length),
1461
+ );
1462
+ if (!factId) {
1463
+ res.writeHead(400);
1464
+ res.end(JSON.stringify({ ok: false, error: "fact id required in path" }));
1465
+ return;
1466
+ }
1467
+ if (!deps.restoreFact) {
1468
+ res.writeHead(503);
1469
+ res.end(JSON.stringify({ ok: false, error: "restore not available" }));
1470
+ return;
1471
+ }
1472
+ let body: any = {};
1473
+ try { body = JSON.parse(await readBody(req, 4096)); } catch {
1474
+ res.writeHead(400);
1475
+ res.end(JSON.stringify({ ok: false, error: "invalid JSON body" }));
1476
+ return;
1477
+ }
1478
+ const undoToken = (body.undo_token || "").toString();
1479
+ if (!undoToken) {
1480
+ res.writeHead(400);
1481
+ res.end(JSON.stringify({ ok: false, error: "undo_token required" }));
1482
+ return;
1483
+ }
1484
+ try {
1485
+ // Python owns the retraction tx + audit log update — TS just relays.
1486
+ const result = await deps.restoreFact(factId, undoToken);
1487
+ res.end(JSON.stringify(result));
1488
+ } catch (err) {
1489
+ res.writeHead(500);
1490
+ res.end(JSON.stringify({ ok: false, error: String(err) }));
1491
+ }
1492
+ return;
1493
+ }
1494
+
1495
+ // Legacy fact-browser kept for fallback / quick raw access.
1496
+ if (req.method === "GET" && url.pathname === "/knowledge/ui-legacy") {
516
1497
  res.setHeader("Content-Type", "text/html");
517
1498
  res.end(KNOWLEDGE_UI_HTML);
518
1499
  return;
519
1500
  }
520
1501
 
1502
+ // New "living Confluence" SPA — search-driven, LLM-rendered pages,
1503
+ // bookmarks, retraction, concept transfer.
1504
+ if (req.method === "GET" && url.pathname === "/knowledge/ui") {
1505
+ res.setHeader("Content-Type", "text/html");
1506
+ res.end(KNOWLEDGE_UI_V2_HTML);
1507
+ return;
1508
+ }
1509
+
1510
+ // SPA route handling — the SPA uses path-style entity URLs. Server-side
1511
+ // we just serve the same HTML; client-side router parses location.pathname.
1512
+ if (req.method === "GET" && url.pathname.startsWith("/knowledge/ui/")) {
1513
+ res.setHeader("Content-Type", "text/html");
1514
+ res.end(KNOWLEDGE_UI_V2_HTML);
1515
+ return;
1516
+ }
1517
+
521
1518
  // ── /traces ──
522
1519
  if (req.method === "GET" && url.pathname === "/traces") {
523
1520
  const after = parseInt(url.searchParams.get("after") || "0");