@geravant/sinain 1.19.0 → 1.22.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,1139 @@ 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
+ /* Share badge in header */
335
+ .share-badge { color: var(--accent); font-weight: 600; font-variant-numeric: tabular-nums; }
336
+ /* Shares view */
337
+ .shares-list { display: flex; flex-direction: column; gap: 12px; }
338
+ .share-row { background: var(--bg-elev); border: 1px solid var(--border);
339
+ border-radius: 6px; padding: 14px 16px;
340
+ display: grid; grid-template-columns: auto 1fr auto; gap: 12px;
341
+ align-items: center; }
342
+ .share-row .icon { font-size: 18px; line-height: 1; }
343
+ .share-row .body { min-width: 0; }
344
+ .share-row .title { font-weight: 600; color: var(--accent); white-space: nowrap;
345
+ overflow: hidden; text-overflow: ellipsis; }
346
+ .share-row .meta { color: var(--fg-dim); font-size: 12px; margin-top: 2px;
347
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
348
+ .share-row .actions { display: flex; gap: 6px; flex-wrap: wrap; }
349
+ .share-row .pill { padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600;
350
+ letter-spacing: 0.02em; text-transform: uppercase; }
351
+ .pill-waiting { background: rgba(180,83,9,0.10); color: var(--warn); }
352
+ .pill-connecting { background: rgba(37,99,235,0.10); color: var(--accent); }
353
+ .pill-delivered { background: rgba(21,128,61,0.10); color: #15803d; }
354
+ .pill-disconnected { background: var(--chip); color: var(--fg-faint); }
355
+ .pill-revoked { background: rgba(185,28,28,0.08); color: var(--danger); }
356
+ .pill-expired { background: var(--chip); color: var(--fg-faint); }
357
+ .pill-permanent { background: var(--chip); color: var(--fg-dim); }
358
+ .pulse { animation: pulse 1.6s ease-in-out infinite; }
359
+ @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.55; } }
360
+ /* Loading */
361
+ .spinner { display: inline-block; width: 14px; height: 14px;
362
+ border: 2px solid var(--border); border-top-color: var(--accent);
363
+ border-radius: 50%; animation: spin 0.8s linear infinite;
364
+ vertical-align: middle; }
365
+ @keyframes spin { to { transform: rotate(360deg); } }
366
+ .loading-block { padding: 32px; text-align: center; color: var(--fg-dim); }
367
+ .error-block { padding: 24px; background: rgba(185, 28, 28, 0.05);
368
+ border-left: 3px solid var(--danger); border-radius: 0 6px 6px 0;
369
+ color: var(--fg); }
370
+ </style>
371
+ </head>
372
+ <body>
373
+ <header>
374
+ <div class="logo" onclick="navigate('/knowledge/ui')">SINAIN</div>
375
+ <div class="search-wrap">
376
+ <input id="search" type="text" placeholder="Search entities, topics, people…" autocomplete="off" />
377
+ <div id="searchResults" class="search-results"></div>
378
+ </div>
379
+ <div class="header-actions">
380
+ <button onclick="navigate('/knowledge/ui/shares')" title="My share links">📤 Shares <span id="shareBadge" class="share-badge"></span></button>
381
+ <a href="/knowledge/ui-legacy"><button>Legacy view</button></a>
382
+ </div>
383
+ </header>
384
+ <main id="root"></main>
385
+ <div id="modalRoot"></div>
386
+ <div id="toastRoot"></div>
387
+
388
+ <script>
389
+ // ── Util ──────────────────────────────────────────────────────────────────
390
+ const esc = (s) => String(s ?? "").replace(/[&<>"']/g, c =>
391
+ ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
392
+ const $ = (sel) => document.querySelector(sel);
393
+ const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; };
394
+
395
+ // ── API ───────────────────────────────────────────────────────────────────
396
+ async function api(path, opts = {}) {
397
+ try {
398
+ const res = await fetch(path, opts);
399
+ if (!res.ok && res.status !== 404) {
400
+ // Some endpoints (e.g. retract on missing fact) return error JSON with non-200.
401
+ // We still try to parse — ok=false in body is the contract.
402
+ }
403
+ const contentType = res.headers.get("content-type") || "";
404
+ if (contentType.includes("json")) return await res.json();
405
+ return await res.text();
406
+ } catch (e) {
407
+ return { ok: false, error: String(e) };
408
+ }
409
+ }
410
+
411
+ // ── Cross-machine sharing config (env-injected at serve time) ────────────
412
+ const SHARE_PEERJS_HOST = __SHARE_PEERJS_HOST__; // empty string → peerjs.com cloud
413
+ const SHARE_INLINE_MAX_BYTES = __SHARE_INLINE_MAX_BYTES__;
414
+ const SHARE_TTL_HOURS = __SHARE_TTL_HOURS__;
415
+
416
+ // ── Router ────────────────────────────────────────────────────────────────
417
+ function navigate(path) {
418
+ history.pushState({}, "", path);
419
+ render();
420
+ }
421
+ window.addEventListener("popstate", render);
422
+ window.addEventListener("DOMContentLoaded", () => {
423
+ setupSearch();
424
+ setupGlobalDrop();
425
+ ShareManager.resumePeerShares().catch(e => console.warn("share resume failed", e));
426
+ refreshShareBadge();
427
+ render();
428
+ });
429
+
430
+ function render() {
431
+ const path = location.pathname;
432
+ if (path === "/knowledge/ui" || path === "/knowledge/ui/") {
433
+ renderHome();
434
+ } else if (path === "/knowledge/ui/shares" || path === "/knowledge/ui/shares/") {
435
+ renderSharesView();
436
+ } else if (path.startsWith("/knowledge/ui/entity/")) {
437
+ const entity = decodeURIComponent(path.slice("/knowledge/ui/entity/".length));
438
+ renderEntityPage(entity);
439
+ } else if (path.startsWith("/knowledge/ui/topic/")) {
440
+ const q = decodeURIComponent(path.slice("/knowledge/ui/topic/".length));
441
+ renderTopicPage(q);
442
+ } else {
443
+ renderHome();
444
+ }
445
+ }
446
+
447
+ // ── Share infrastructure (gzip helpers, peerjs loader, ShareManager) ─────
448
+
449
+ function randomHex(byteCount) {
450
+ const buf = new Uint8Array(byteCount);
451
+ crypto.getRandomValues(buf);
452
+ return Array.from(buf, b => b.toString(16).padStart(2, "0")).join("");
453
+ }
454
+
455
+ async function gzipBase64(text) {
456
+ // CompressionStream("gzip") is in all modern browsers (Chrome 80+, Safari
457
+ // 16.4+, Firefox 113+). No external library needed.
458
+ const cs = new Blob([text]).stream().pipeThrough(new CompressionStream("gzip"));
459
+ const buf = new Uint8Array(await new Response(cs).arrayBuffer());
460
+ // base64url so the output is URL-safe (no +, /, =).
461
+ let bin = "";
462
+ for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i]);
463
+ return btoa(bin).replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/, "");
464
+ }
465
+
466
+ async function ungzipBase64(encoded) {
467
+ const padded = encoded.replace(/-/g, "+").replace(/_/g, "/")
468
+ + "===".slice((encoded.length + 3) % 4);
469
+ const bin = atob(padded);
470
+ const bytes = new Uint8Array(bin.length);
471
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
472
+ const ds = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"));
473
+ return await new Response(ds).text();
474
+ }
475
+
476
+ let _peerjsLoading = null;
477
+ function ensurePeerJsLoaded() {
478
+ if (window.Peer) return Promise.resolve();
479
+ if (_peerjsLoading) return _peerjsLoading;
480
+ _peerjsLoading = new Promise((res, rej) => {
481
+ const s = document.createElement("script");
482
+ // Pinned version + SRI hash. If you bump version, regenerate hash via:
483
+ // curl -sL https://cdn.jsdelivr.net/npm/peerjs@1.5.4/dist/peerjs.min.js | openssl dgst -sha384 -binary | openssl base64 -A
484
+ s.src = "https://cdn.jsdelivr.net/npm/peerjs@1.5.4/dist/peerjs.min.js";
485
+ s.crossOrigin = "anonymous";
486
+ s.onload = () => res();
487
+ s.onerror = (e) => rej(new Error("peerjs failed to load — network or CDN issue"));
488
+ document.head.appendChild(s);
489
+ });
490
+ return _peerjsLoading;
491
+ }
492
+
493
+ function newPeer(idOrUndef) {
494
+ // Honor SHARE_PEERJS_HOST env-injected override. Empty string = peerjs.com default.
495
+ const opts = SHARE_PEERJS_HOST ? { host: SHARE_PEERJS_HOST } : {};
496
+ return idOrUndef ? new window.Peer(idOrUndef, opts) : new window.Peer(opts);
497
+ }
498
+
499
+ const ShareManager = (() => {
500
+ // share_token → live Peer instance (sender side only). Bundles are re-fetched
501
+ // on demand rather than kept in JS memory across resume.
502
+ const peers = new Map();
503
+
504
+ async function buildBundle(entity) {
505
+ const r = await fetch(\`/knowledge/concepts/export?entity=\${encodeURIComponent(entity)}\` +
506
+ \`&depth=1&include_page=1\`);
507
+ if (!r.ok) throw new Error("export failed: " + r.status);
508
+ return await r.text();
509
+ }
510
+
511
+ async function createShare(entity) {
512
+ const bundle = await buildBundle(entity);
513
+ const sizeBytes = new TextEncoder().encode(bundle).length;
514
+ const token = randomHex(8); // 16 hex chars
515
+
516
+ if (sizeBytes <= SHARE_INLINE_MAX_BYTES) {
517
+ const compressed = await gzipBase64(bundle);
518
+ const url = location.origin + "/knowledge/ui/entity/" +
519
+ encodeURIComponent(entity) + "#bundle=" + compressed;
520
+ await api("/knowledge/shares", { method: "POST",
521
+ headers: {"Content-Type": "application/json"},
522
+ body: JSON.stringify({
523
+ entity_id: entity, mode: "fragment", share_token: token, url, bundle_size: sizeBytes
524
+ })
525
+ });
526
+ try { await navigator.clipboard.writeText(url); } catch { /* clipboard denied */ }
527
+ showToast("✓ Link copied · self-contained, can't be revoked", 6000);
528
+ refreshShareBadge();
529
+ return { mode: "fragment", url };
530
+ }
531
+
532
+ // Peer mode
533
+ await ensurePeerJsLoaded();
534
+ const peer = newPeer(token);
535
+ await new Promise((res, rej) => {
536
+ peer.on("open", () => res());
537
+ peer.on("error", e => rej(e));
538
+ setTimeout(() => rej(new Error("peerjs broker timeout")), 8000);
539
+ });
540
+ const url = location.origin + "/knowledge/ui/entity/" +
541
+ encodeURIComponent(entity) + "#peer=" + token;
542
+ await api("/knowledge/shares", { method: "POST",
543
+ headers: {"Content-Type": "application/json"},
544
+ body: JSON.stringify({
545
+ entity_id: entity, mode: "peer", share_token: token, url, bundle_size: sizeBytes
546
+ })
547
+ });
548
+ peers.set(token, peer);
549
+ attachSenderHandlers(peer, token, entity);
550
+ try { await navigator.clipboard.writeText(url); } catch {}
551
+ showToast("✓ Link copied · live until you revoke (see Shares)", 6000);
552
+ refreshShareBadge();
553
+ return { mode: "peer", url };
554
+ }
555
+
556
+ function attachSenderHandlers(peer, token, entity) {
557
+ peer.on("connection", (conn) => {
558
+ patchStatus(token, "connecting");
559
+ conn.on("open", async () => {
560
+ try {
561
+ // Re-fetch bundle each time — keeps memory low and reflects latest state.
562
+ const bundle = await buildBundle(entity);
563
+ conn.send({ type: "bundle", payload: bundle });
564
+ } catch (e) {
565
+ conn.send({ type: "error", message: String(e).slice(0, 200) });
566
+ conn.close();
567
+ }
568
+ });
569
+ conn.on("data", (msg) => {
570
+ if (msg && msg.type === "ack") {
571
+ patchStatus(token, "delivered", { delivered_at: Date.now() });
572
+ // Keep peer alive briefly for retries, then release.
573
+ setTimeout(() => destroyPeer(token), 5000);
574
+ }
575
+ });
576
+ conn.on("close", () => { /* normal */ });
577
+ });
578
+ peer.on("disconnected", () => patchStatus(token, "disconnected"));
579
+ peer.on("close", () => patchStatus(token, "disconnected"));
580
+ peer.on("error", (err) => {
581
+ console.warn("share peer error", token, err && err.type, err && err.message);
582
+ });
583
+ }
584
+
585
+ async function patchStatus(token, status, extra) {
586
+ const body = Object.assign({ status }, extra || {});
587
+ await api("/knowledge/shares/" + encodeURIComponent(token), {
588
+ method: "PATCH", headers: {"Content-Type": "application/json"},
589
+ body: JSON.stringify(body),
590
+ });
591
+ refreshShareBadge();
592
+ }
593
+
594
+ async function resumePeerShares() {
595
+ const r = await api("/knowledge/shares?status=waiting&status=connecting&status=disconnected");
596
+ if (!r || !r.ok) return;
597
+ for (const share of r.shares || []) {
598
+ if (share.mode !== "peer") continue;
599
+ try {
600
+ await ensurePeerJsLoaded();
601
+ const peer = newPeer(share.share_token);
602
+ await new Promise((res, rej) => {
603
+ peer.on("open", () => res());
604
+ peer.on("error", e => rej(e));
605
+ setTimeout(() => rej(new Error("peerjs open timeout")), 8000);
606
+ });
607
+ peers.set(share.share_token, peer);
608
+ attachSenderHandlers(peer, share.share_token, share.entity_id);
609
+ if (share.status !== "waiting") {
610
+ await patchStatus(share.share_token, "waiting");
611
+ }
612
+ } catch (e) {
613
+ console.warn("resume failed for", share.share_token, e && e.message);
614
+ // Mark as disconnected so the user sees it failed; they can manually revoke.
615
+ await patchStatus(share.share_token, "disconnected").catch(() => {});
616
+ }
617
+ }
618
+ }
619
+
620
+ function destroyPeer(token) {
621
+ const peer = peers.get(token);
622
+ if (peer) {
623
+ try { peer.destroy(); } catch {}
624
+ peers.delete(token);
625
+ }
626
+ }
627
+
628
+ async function revoke(token) {
629
+ destroyPeer(token);
630
+ await api("/knowledge/shares/" + encodeURIComponent(token), {
631
+ method: "PATCH", headers: {"Content-Type": "application/json"},
632
+ body: JSON.stringify({ status: "revoked", revoked_at: Date.now() })
633
+ });
634
+ refreshShareBadge();
635
+ }
636
+
637
+ async function forget(token) {
638
+ destroyPeer(token);
639
+ await api("/knowledge/shares/" + encodeURIComponent(token), { method: "DELETE" });
640
+ refreshShareBadge();
641
+ }
642
+
643
+ async function connectAsRecipient(token) {
644
+ showToast('<span class="spinner"></span> Connecting peer-to-peer…', 30_000);
645
+ await ensurePeerJsLoaded();
646
+ const me = newPeer();
647
+ await new Promise((res, rej) => {
648
+ me.on("open", () => res());
649
+ me.on("error", e => rej(e));
650
+ setTimeout(() => rej(new Error("peerjs broker timeout")), 8000);
651
+ });
652
+ return new Promise((resolve, reject) => {
653
+ const conn = me.connect(token, { reliable: true });
654
+ const cleanup = () => { try { conn.close(); } catch {} try { me.destroy(); } catch {} };
655
+ const openTimeout = setTimeout(() => {
656
+ cleanup();
657
+ reject(new Error("source offline or unreachable"));
658
+ }, 15_000);
659
+ conn.on("open", () => clearTimeout(openTimeout));
660
+ conn.on("error", (e) => { cleanup(); reject(e); });
661
+ conn.on("data", async (msg) => {
662
+ if (!msg) return;
663
+ if (msg.type === "error") {
664
+ cleanup();
665
+ reject(new Error("source error: " + msg.message));
666
+ return;
667
+ }
668
+ if (msg.type === "bundle") {
669
+ try {
670
+ const importR = await api("/knowledge/concepts/import?conflict=merge", {
671
+ method: "POST",
672
+ headers: {"Content-Type": "application/json"},
673
+ body: msg.payload,
674
+ });
675
+ conn.send({ type: "ack" });
676
+ setTimeout(cleanup, 500);
677
+ resolve(importR);
678
+ } catch (e) {
679
+ cleanup();
680
+ reject(e);
681
+ }
682
+ }
683
+ });
684
+ });
685
+ }
686
+
687
+ return { createShare, resumePeerShares, revoke, forget, connectAsRecipient };
688
+ })();
689
+
690
+ async function refreshShareBadge() {
691
+ try {
692
+ const r = await api("/knowledge/shares?status=waiting&status=connecting");
693
+ const count = (r && r.shares) ? r.shares.length : 0;
694
+ const badge = document.getElementById("shareBadge");
695
+ if (badge) badge.textContent = count > 0 ? "(" + count + ")" : "";
696
+ } catch {}
697
+ }
698
+
699
+ // ── Home view ─────────────────────────────────────────────────────────────
700
+ async function renderHome() {
701
+ document.title = "Sinain Knowledge";
702
+ const root = $("#root");
703
+ root.innerHTML = '<div class="loading-block"><span class="spinner"></span> Loading bookmarks…</div>';
704
+
705
+ const [favs, recents, archives] = await Promise.all([
706
+ api("/knowledge/bookmarks?status=favorite&limit=50"),
707
+ api("/knowledge/bookmarks?status=recent&limit=20"),
708
+ api("/knowledge/bookmarks?status=archive&limit=50"),
709
+ ]);
710
+
711
+ root.innerHTML = \`
712
+ <h1>Knowledge</h1>
713
+ \${renderBookmarkRow("★ Favorites", favs.bookmarks ?? [])}
714
+ \${renderBookmarkRow("📚 Recent", recents.bookmarks ?? [])}
715
+ \${renderBookmarkRow("🗄 Archive", archives.bookmarks ?? [])}
716
+ <h2>Import a concept</h2>
717
+ <div class="dropzone" id="homeDropzone">
718
+ Drop a <code>.sinain-concept.json</code> file here, or click to choose.
719
+ <input type="file" id="homeFileInput" accept=".json,.sinain-concept.json"
720
+ style="display:none" />
721
+ </div>
722
+ \`;
723
+ bindDropzone($("#homeDropzone"), $("#homeFileInput"));
724
+ }
725
+
726
+ function renderBookmarkRow(label, items) {
727
+ const cards = items.length === 0
728
+ ? '<div class="empty-row">— none yet —</div>'
729
+ : items.map(b => \`
730
+ <div class="bookmark-card" onclick="navigate('/knowledge/ui/entity/' + encodeURIComponent('\${esc(b.entity_id)}'))">
731
+ <div class="entity">\${esc(b.entity_id)}</div>
732
+ <div class="meta">\${b.note ? esc(b.note) + ' · ' : ''}visited \${timeAgo(b.last_visited)}</div>
733
+ </div>\`).join("");
734
+ return \`<div class="bookmark-row"><h2>\${label}</h2><div class="bookmark-list">\${cards}</div></div>\`;
735
+ }
736
+
737
+ function timeAgo(ts) {
738
+ const diff = Date.now() - ts;
739
+ if (diff < 60_000) return "just now";
740
+ if (diff < 3_600_000) return Math.round(diff / 60_000) + "m ago";
741
+ if (diff < 86_400_000) return Math.round(diff / 3_600_000) + "h ago";
742
+ return Math.round(diff / 86_400_000) + "d ago";
743
+ }
744
+
745
+ function fmtBytes(n) {
746
+ if (n == null) return "?";
747
+ if (n < 1024) return n + " B";
748
+ if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
749
+ return (n / 1024 / 1024).toFixed(1) + " MB";
750
+ }
751
+
752
+ // ── Shares view ───────────────────────────────────────────────────────────
753
+ async function renderSharesView() {
754
+ document.title = "Shares · Sinain";
755
+ const root = $("#root");
756
+ root.innerHTML = '<div class="loading-block"><span class="spinner"></span> Loading shares…</div>';
757
+
758
+ const r = await api("/knowledge/shares?include_archived=1");
759
+ const shares = (r && r.shares) || [];
760
+ refreshShareBadge();
761
+
762
+ if (shares.length === 0) {
763
+ root.innerHTML = \`
764
+ <h1>Shares</h1>
765
+ <div class="empty-row" style="padding:24px;">
766
+ No shares yet. Open an entity page and click 📤 Share to create one.
767
+ </div>\`;
768
+ return;
769
+ }
770
+
771
+ root.innerHTML = '<h1>Shares</h1><div class="shares-list" id="sharesList"></div>';
772
+ const list = $("#sharesList");
773
+ list.innerHTML = shares.map(renderShareRow).join("");
774
+
775
+ // Wire per-row actions via event delegation
776
+ list.addEventListener("click", async (e) => {
777
+ const btn = e.target.closest("button[data-action]");
778
+ if (!btn) return;
779
+ const token = btn.dataset.token;
780
+ const action = btn.dataset.action;
781
+ const share = shares.find(s => s.share_token === token);
782
+ if (!share) return;
783
+ if (action === "copy") {
784
+ try {
785
+ await navigator.clipboard.writeText(share.url);
786
+ showToast("✓ URL copied");
787
+ } catch {
788
+ showToast("Copy failed — your browser may block clipboard access");
789
+ }
790
+ } else if (action === "revoke") {
791
+ if (share.mode === "fragment") {
792
+ const ok = confirm(
793
+ "Mark this share as revoked?\\n\\n" +
794
+ "Note: the URL is self-contained — anyone who already has it can still import. " +
795
+ "This only removes it from your active list.");
796
+ if (!ok) return;
797
+ }
798
+ await ShareManager.revoke(token);
799
+ renderSharesView();
800
+ } else if (action === "forget") {
801
+ const ok = confirm("Remove this share from your list permanently?");
802
+ if (!ok) return;
803
+ await ShareManager.forget(token);
804
+ renderSharesView();
805
+ } else if (action === "open") {
806
+ navigate("/knowledge/ui/entity/" + encodeURIComponent(share.entity_id));
807
+ }
808
+ });
809
+ }
810
+
811
+ function renderShareRow(s) {
812
+ const isPeer = s.mode === "peer";
813
+ const statusClass = "pill-" + (s.mode === "fragment" && s.status === "delivered" ? "permanent" : s.status);
814
+ const statusLabel = s.mode === "fragment" && s.status === "delivered" ? "permanent" : s.status;
815
+ const pulsing = (s.status === "waiting" || s.status === "connecting") ? " pulse" : "";
816
+ const icon = ({
817
+ waiting: "⏳", connecting: "⚡", delivered: isPeer ? "✓" : "📎",
818
+ disconnected: "⚠", revoked: "✕", expired: "⌛"
819
+ })[s.status] || "•";
820
+
821
+ const sub = [];
822
+ sub.push(timeAgo(s.created_at));
823
+ if (s.bundle_size != null) sub.push(fmtBytes(s.bundle_size));
824
+ if (isPeer) sub.push("PEER"); else sub.push("LINK");
825
+ if (s.delivered_at) sub.push("delivered " + timeAgo(s.delivered_at));
826
+ if (s.recipient_hint) sub.push(s.recipient_hint);
827
+
828
+ // Per-mode actions: peer has Revoke (real); fragment has Revoke (best-effort)
829
+ // and both have Copy + Forget.
830
+ const showRevoke = (s.status === "waiting" || s.status === "connecting" || s.status === "disconnected"
831
+ || (s.mode === "fragment" && s.status === "delivered"));
832
+ return \`
833
+ <div class="share-row\${pulsing}">
834
+ <div class="icon">\${icon}</div>
835
+ <div class="body">
836
+ <div class="title" onclick="navigate('/knowledge/ui/entity/' + encodeURIComponent('\${esc(s.entity_id)}'))" style="cursor:pointer">
837
+ \${esc(s.entity_id)}
838
+ </div>
839
+ <div class="meta">\${sub.map(esc).join(" · ")}</div>
840
+ </div>
841
+ <div class="actions">
842
+ <span class="pill \${statusClass}">\${esc(statusLabel)}</span>
843
+ <button data-action="copy" data-token="\${esc(s.share_token)}" title="Copy share URL">📋</button>
844
+ \${showRevoke ? \`<button data-action="revoke" data-token="\${esc(s.share_token)}" title="\${isPeer ? 'Revoke this share (recipient will see source offline)' : 'Mark revoked (URL still works for anyone who has it)'}">✕</button>\` : ""}
845
+ <button data-action="forget" data-token="\${esc(s.share_token)}" title="Remove from list">🗑</button>
846
+ </div>
847
+ </div>\`;
848
+ }
849
+
850
+ // ── Search ────────────────────────────────────────────────────────────────
851
+ function setupSearch() {
852
+ const input = $("#search");
853
+ const dropdown = $("#searchResults");
854
+ const handleQuery = debounce(async () => {
855
+ const q = input.value.trim();
856
+ if (!q) { dropdown.classList.remove("open"); dropdown.innerHTML = ""; return; }
857
+ const result = await api("/knowledge/search?q=" + encodeURIComponent(q) + "&limit=15");
858
+ if (!result.results || result.results.length === 0) {
859
+ dropdown.innerHTML = \`
860
+ <div class="search-result" onclick="navigate('/knowledge/ui/topic/' + encodeURIComponent('\${esc(q)}'))">
861
+ <div class="entity">View as topic page</div>
862
+ <div class="snippet">No matching entities — synthesize from search hits.</div>
863
+ </div>\`;
864
+ } else {
865
+ dropdown.innerHTML = result.results.map(r => \`
866
+ <div class="search-result" onclick="navigate('/knowledge/ui/entity/' + encodeURIComponent('\${esc(r.entity)}'))">
867
+ <div class="entity">\${esc(r.entity)}</div>
868
+ <div class="meta">\${esc(r.type)} · \${r.fact_count} fact\${r.fact_count === 1 ? "" : "s"}</div>
869
+ <div class="snippet">\${esc(r.snippet || "")}</div>
870
+ </div>\`).join("");
871
+ }
872
+ dropdown.classList.add("open");
873
+ }, 220);
874
+ input.addEventListener("input", handleQuery);
875
+ input.addEventListener("focus", () => { if (input.value) handleQuery(); });
876
+ document.addEventListener("click", (e) => {
877
+ if (!e.target.closest(".search-wrap")) dropdown.classList.remove("open");
878
+ });
879
+ }
880
+
881
+ // ── Entity page ───────────────────────────────────────────────────────────
882
+ async function renderEntityPage(entity) {
883
+ document.title = entity + " · Sinain";
884
+ const root = $("#root");
885
+ root.innerHTML = \`<div class="loading-block"><span class="spinner"></span> Loading \${esc(entity)}…</div>\`;
886
+
887
+ // Auto-import path for share links — runs BEFORE the local existence check
888
+ // so a recipient with no prior data on this entity gets the page populated.
889
+ if (location.hash.startsWith("#bundle=")) {
890
+ try {
891
+ const json = await ungzipBase64(location.hash.slice("#bundle=".length));
892
+ await api("/knowledge/concepts/import?conflict=merge", {
893
+ method: "POST", headers: {"Content-Type": "application/json"}, body: json
894
+ });
895
+ showToast("✓ Concept imported");
896
+ } catch (e) {
897
+ showToast("Import failed: " + (e.message || "decode error"));
898
+ }
899
+ history.replaceState({}, "", location.pathname); // strip hash
900
+ } else if (location.hash.startsWith("#peer=")) {
901
+ const token = location.hash.slice("#peer=".length);
902
+ history.replaceState({}, "", location.pathname); // strip early — keeps refresh sane
903
+ try {
904
+ await ShareManager.connectAsRecipient(token);
905
+ showToast("✓ Concept imported via peer");
906
+ } catch (e) {
907
+ showToast("Peer share failed: " + (e.message || "unreachable"));
908
+ }
909
+ }
910
+
911
+ const page = await api("/knowledge/page?entity=" + encodeURIComponent(entity));
912
+ if (!page.ok || page.fact_count === 0) {
913
+ if (page.fact_count === 0) {
914
+ renderMissingConcept(entity, root);
915
+ return;
916
+ }
917
+ root.innerHTML = \`<div class="error-block">Failed to load: \${esc(page.error || "unknown")}</div>\`;
918
+ return;
919
+ }
920
+
921
+ const factCount = page.fact_count;
922
+ const facts = collectFactsFromSections(page.sections || []);
923
+
924
+ root.innerHTML = \`
925
+ <div class="page-header">
926
+ <div class="title">\${esc(entity)}</div>
927
+ <div class="badges">
928
+ <span class="badge">\${factCount} fact\${factCount === 1 ? "" : "s"}</span>
929
+ \${page.stats?.from_cache ? '<span class="badge">cached</span>' : '<span class="badge">fresh</span>'}
930
+ </div>
931
+ <div class="page-actions">
932
+ <button id="bmFavorite" class="icon" title="Favorite">★</button>
933
+ <button id="bmArchive" class="icon" title="Archive">🗄</button>
934
+ <button id="actRefresh" class="icon" title="Re-render">↻</button>
935
+ <button id="actCopyLink" class="icon" title="Copy entity URL (recipient needs same data)">🔗</button>
936
+ <button id="actShare" class="icon" title="Share concept (auto-imports for recipient)">📤</button>
937
+ <button id="actExport" class="icon" title="Download bundle file (manual transfer)">⬇</button>
938
+ </div>
939
+ </div>
940
+ <div class="layout-3col">
941
+ <aside><div id="treeRoot" class="tree"></div></aside>
942
+ <div>
943
+ \${page.summary ? \`<div class="summary">\${esc(page.summary)}</div>\` : ""}
944
+ <div id="sectionsRoot">\${(page.sections || []).map((s, i) => renderSection(s, i)).join("")}</div>
945
+ <div class="raw-accordion">
946
+ <div class="raw-toggle" onclick="this.nextElementSibling.classList.toggle('open')">
947
+ ▸ Show all \${factCount} raw fact\${factCount === 1 ? "" : "s"}
948
+ </div>
949
+ <div class="raw-list">\${facts.map(f => \`
950
+ <div class="raw-item">[\${esc(f.fact_id)}] (conf=\${f.confidence}, \${esc(f.domain || "")}): \${esc(f.text || "")}</div>
951
+ \`).join("")}</div>
952
+ </div>
953
+ </div>
954
+ <aside class="meta-panel">
955
+ <h3>Stats</h3>
956
+ <div class="stat-row"><span class="stat-label">Facts</span><span class="stat-value">\${factCount}</span></div>
957
+ <div class="stat-row"><span class="stat-label">Used</span><span class="stat-value">\${page.facts_used ?? factCount}</span></div>
958
+ <div class="stat-row"><span class="stat-label">Tx watermark</span><span class="stat-value">\${page.tx_watermark}</span></div>
959
+ \${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>\` : ""}
960
+ \${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>\` : ""}
961
+ \${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>\` : ""}
962
+ </aside>
963
+ </div>\`;
964
+
965
+ // Wire actions
966
+ $("#bmFavorite").onclick = () => bookmarkAction(entity, "favorite");
967
+ $("#bmArchive").onclick = () => bookmarkAction(entity, "archive");
968
+ $("#actRefresh").onclick = () => refreshPage(entity);
969
+ $("#actCopyLink").onclick = () => copyLink(entity);
970
+ $("#actShare").onclick = () => shareEntity(entity);
971
+ $("#actExport").onclick = () => exportConcept(entity);
972
+
973
+ // Wire bullet retraction (event delegation)
974
+ $("#sectionsRoot").addEventListener("click", (e) => {
975
+ const more = e.target.closest(".more");
976
+ if (more) {
977
+ const factId = more.dataset.factId;
978
+ openRetractModal(factId, more.closest(".bullet"), entity);
979
+ }
980
+ });
981
+
982
+ // Tree
983
+ loadTreeChildren(entity, $("#treeRoot"), 0);
984
+ }
985
+
986
+ function collectFactsFromSections(sections) {
987
+ const out = [];
988
+ for (const s of sections) {
989
+ for (const b of s.bullets || []) out.push(b);
990
+ }
991
+ return out;
992
+ }
993
+
994
+ function renderSection(s, idx) {
995
+ return \`
996
+ <div class="section" id="sec-\${idx}">
997
+ <div class="section-heading" onclick="this.parentElement.classList.toggle('collapsed')">
998
+ \${esc(s.heading || "Untitled")}
999
+ </div>
1000
+ \${s.notes ? \`<div class="notes">⚠ \${esc(s.notes)}</div>\` : ""}
1001
+ <ul class="bullets">\${(s.bullets || []).map(b => \`
1002
+ <li class="bullet" data-fact-id="\${esc(b.fact_id)}">
1003
+ <span class="text">\${esc(b.text || "")}</span>
1004
+ <span class="conf">\${b.confidence != null ? Number(b.confidence).toFixed(2) : ""}</span>
1005
+ <span class="fid" title="\${esc(b.fact_id)}">[\${esc((b.fact_id || "").slice(0, 16))}…]</span>
1006
+ <span class="more" data-fact-id="\${esc(b.fact_id)}" title="More">⋯</span>
1007
+ </li>\`).join("")}</ul>
1008
+ </div>\`;
1009
+ }
1010
+
1011
+ // ── Tree (graph children) ─────────────────────────────────────────────────
1012
+ async function loadTreeChildren(entity, container, depth) {
1013
+ if (depth > 3) {
1014
+ container.innerHTML = '<div class="empty-row">depth limit</div>';
1015
+ return;
1016
+ }
1017
+ const result = await api("/knowledge/graph/children?entity=" + encodeURIComponent(entity));
1018
+ if (!result.groups || result.groups.length === 0) {
1019
+ container.innerHTML = '<div class="empty-row">no children</div>';
1020
+ return;
1021
+ }
1022
+ container.innerHTML = result.groups.map(g => \`
1023
+ <div class="tree-group">
1024
+ <div class="tree-group-label">\${esc(g.label)} (\${g.children.length})</div>
1025
+ \${g.children.map(c => {
1026
+ // Prefer the fact's own value text as the visible label; entity_id
1027
+ // slugs are opaque to humans. Show the slug only when no snippet.
1028
+ const label = c.snippet || c.entity.split(":").pop() || c.entity;
1029
+ return \`
1030
+ <div class="tree-node \${c.expandable ? 'expandable' : ''}" data-entity="\${esc(c.entity)}" title="\${esc(c.entity)}">
1031
+ \${esc(label.length > 36 ? label.slice(0, 36) + "…" : label)}
1032
+ </div>
1033
+ <div class="tree-children" data-parent="\${esc(c.entity)}" style="display:none"></div>\`;
1034
+ }).join("")}
1035
+ </div>\`).join("");
1036
+ // Wire expand
1037
+ container.querySelectorAll(".tree-node").forEach(node => {
1038
+ node.addEventListener("click", async () => {
1039
+ const eid = node.dataset.entity;
1040
+ const child = container.querySelector('[data-parent="' + CSS.escape(eid) + '"]');
1041
+ if (node.classList.contains("expanded")) {
1042
+ node.classList.remove("expanded");
1043
+ child.style.display = "none";
1044
+ } else if (node.classList.contains("expandable")) {
1045
+ node.classList.add("expanded");
1046
+ child.style.display = "block";
1047
+ if (!child.dataset.loaded) {
1048
+ child.dataset.loaded = "1";
1049
+ await loadTreeChildren(eid, child, depth + 1);
1050
+ }
1051
+ } else {
1052
+ navigate("/knowledge/ui/entity/" + encodeURIComponent(eid));
1053
+ }
1054
+ });
1055
+ });
1056
+ }
1057
+
1058
+ // ── Bookmark + actions ────────────────────────────────────────────────────
1059
+ async function bookmarkAction(entity, status) {
1060
+ const r = await api("/knowledge/bookmarks", {
1061
+ method: "POST", headers: { "Content-Type": "application/json" },
1062
+ body: JSON.stringify({ entity, status }),
1063
+ });
1064
+ if (r.ok) showToast(\`✓ \${status === "favorite" ? "Favorited" : "Archived"}\`);
1065
+ else showToast("Failed: " + (r.error || "unknown"));
1066
+ }
1067
+
1068
+ async function refreshPage(entity) {
1069
+ showToast(\`<span class="spinner"></span> Re-rendering…\`);
1070
+ await api("/knowledge/page?refresh=1&entity=" + encodeURIComponent(entity));
1071
+ render();
1072
+ }
1073
+
1074
+ function copyLink(entity) {
1075
+ const url = location.origin + "/knowledge/ui/entity/" + encodeURIComponent(entity);
1076
+ navigator.clipboard.writeText(url).then(
1077
+ () => showToast("✓ Link copied. Recipient needs the concept imported."),
1078
+ () => showToast("Copy failed — your browser may block clipboard access"),
1079
+ );
1080
+ }
1081
+
1082
+ async function exportConcept(entity) {
1083
+ // Simple export — no preflight dialog in v1, sensible defaults.
1084
+ const url = "/knowledge/concepts/export?entity=" + encodeURIComponent(entity)
1085
+ + "&depth=1&include_page=1";
1086
+ const a = document.createElement("a");
1087
+ a.href = url;
1088
+ a.download = entity.replace(/[^a-z0-9-]/gi, "-") + ".sinain-concept.json";
1089
+ document.body.appendChild(a);
1090
+ a.click();
1091
+ a.remove();
1092
+ showToast("✓ Exporting concept bundle…");
1093
+ }
1094
+
1095
+ async function shareEntity(entity) {
1096
+ showToast('<span class="spinner"></span> Preparing share…', 30_000);
1097
+ try {
1098
+ await ShareManager.createShare(entity);
1099
+ // Toast + clipboard already handled by ShareManager. User can navigate
1100
+ // freely; status is visible in the Shares view.
1101
+ } catch (e) {
1102
+ showToast("Share failed: " + (e && e.message ? e.message : String(e)));
1103
+ }
1104
+ }
1105
+
1106
+ // ── Retraction modal + undo toast ─────────────────────────────────────────
1107
+ function openRetractModal(factId, bulletEl, sourceEntity) {
1108
+ const text = bulletEl.querySelector(".text").textContent;
1109
+ const conf = bulletEl.querySelector(".conf").textContent;
1110
+ const root = $("#modalRoot");
1111
+ root.innerHTML = \`
1112
+ <div class="modal-backdrop open" id="retractModal">
1113
+ <div class="modal">
1114
+ <h2>Retract this fact?</h2>
1115
+ <div class="quote">\${esc(text)}</div>
1116
+ <div class="body">Confidence \${esc(conf)} · Fact id <code>\${esc(factId)}</code></div>
1117
+ <label>Reason (optional)</label>
1118
+ <textarea id="retractReason" placeholder="Why are you retracting this?"></textarea>
1119
+ <div class="modal-actions">
1120
+ <button onclick="closeModal()">Cancel</button>
1121
+ <button class="primary danger" id="retractGo">Retract</button>
1122
+ </div>
1123
+ </div>
1124
+ </div>\`;
1125
+ $("#retractGo").onclick = async () => {
1126
+ const reason = $("#retractReason").value.trim() || null;
1127
+ closeModal();
1128
+ bulletEl.classList.add("retracting");
1129
+ const r = await api("/knowledge/facts/" + encodeURIComponent(factId), {
1130
+ method: "DELETE", headers: { "Content-Type": "application/json" },
1131
+ body: JSON.stringify({ reason, actor: "web-ui", source_entity: sourceEntity }),
1132
+ });
1133
+ if (r.ok) {
1134
+ setTimeout(() => bulletEl.remove(), 300);
1135
+ showUndoToast(factId, r.undo_token, sourceEntity);
1136
+ } else {
1137
+ bulletEl.classList.remove("retracting");
1138
+ showToast("Retract failed: " + (r.error || "unknown"));
1139
+ }
1140
+ };
1141
+ }
1142
+ function closeModal() { $("#modalRoot").innerHTML = ""; }
1143
+
1144
+ function showToast(html, ms = 4000) {
1145
+ const root = $("#toastRoot");
1146
+ root.innerHTML = \`<div class="toast"><div class="text">\${html}</div></div>\`;
1147
+ setTimeout(() => { if (root.innerHTML.includes(html)) root.innerHTML = ""; }, ms);
1148
+ }
1149
+
1150
+ function showUndoToast(factId, undoToken, sourceEntity) {
1151
+ const root = $("#toastRoot");
1152
+ const ms = 10_000;
1153
+ root.innerHTML = \`
1154
+ <div class="toast" id="undoToast">
1155
+ <span class="icon">✓</span>
1156
+ <span class="text">Retracted</span>
1157
+ <button id="undoBtn">Undo</button>
1158
+ <div class="timer" style="width:100%; transition: width \${ms}ms linear;"></div>
1159
+ </div>\`;
1160
+ // Animate timer
1161
+ requestAnimationFrame(() => { $("#undoToast .timer").style.width = "0%"; });
1162
+ $("#undoBtn").onclick = async () => {
1163
+ root.innerHTML = "";
1164
+ const r = await api("/knowledge/facts/" + encodeURIComponent(factId) + "/restore", {
1165
+ method: "POST", headers: { "Content-Type": "application/json" },
1166
+ body: JSON.stringify({ undo_token: undoToken }),
1167
+ });
1168
+ if (r.ok) {
1169
+ showToast("✓ Restored — reload to see");
1170
+ } else {
1171
+ showToast("Restore failed: " + (r.error || "unknown"));
1172
+ }
1173
+ };
1174
+ setTimeout(() => { if ($("#undoToast")) root.innerHTML = ""; }, ms);
1175
+ }
1176
+
1177
+ // ── Missing concept landing ───────────────────────────────────────────────
1178
+ function renderMissingConcept(entity, root) {
1179
+ document.title = "Missing · " + entity;
1180
+ root.innerHTML = \`
1181
+ <h1>Concept not found</h1>
1182
+ <div class="error-block" style="margin-bottom: 24px;">
1183
+ <code>\${esc(entity)}</code> is not in this machine's knowledge graph yet.
1184
+ </div>
1185
+ <p>If someone shared a <code>.sinain-concept.json</code> bundle with you, drop it here:</p>
1186
+ <div class="dropzone" id="missingDropzone">
1187
+ 📥 Drop concept bundle
1188
+ <input type="file" id="missingFileInput" accept=".json,.sinain-concept.json"
1189
+ style="display:none" />
1190
+ </div>
1191
+ <p style="color: var(--fg-dim); margin-top:24px">After import, this page will load automatically.</p>
1192
+ \`;
1193
+ bindDropzone($("#missingDropzone"), $("#missingFileInput"), entity);
1194
+ }
1195
+
1196
+ // ── Topic page (simple, v1) ───────────────────────────────────────────────
1197
+ async function renderTopicPage(q) {
1198
+ document.title = "Topic: " + q;
1199
+ const root = $("#root");
1200
+ root.innerHTML = \`
1201
+ <h1>Topic: \${esc(q)}</h1>
1202
+ <div class="loading-block"><span class="spinner"></span> Searching…</div>\`;
1203
+ const r = await api("/knowledge/search?q=" + encodeURIComponent(q) + "&limit=50");
1204
+ if (!r.results || r.results.length === 0) {
1205
+ root.innerHTML = \`<h1>Topic: \${esc(q)}</h1>
1206
+ <div class="error-block">No matching facts.</div>\`;
1207
+ return;
1208
+ }
1209
+ root.innerHTML = \`
1210
+ <h1>Topic: \${esc(q)}</h1>
1211
+ <div class="summary">Top \${r.results.length} matches across the knowledge graph.</div>
1212
+ \${r.results.map(rr => \`
1213
+ <div class="bullet" onclick="navigate('/knowledge/ui/entity/' + encodeURIComponent('\${esc(rr.entity)}'))" style="cursor:pointer">
1214
+ <span class="text"><strong>\${esc(rr.entity)}</strong> — \${esc(rr.snippet || "")}</span>
1215
+ <span class="conf">\${rr.fact_count} fact\${rr.fact_count === 1 ? "" : "s"}</span>
1216
+ </div>\`).join("")}\`;
1217
+ }
1218
+
1219
+ // ── Dropzone wiring (shared) ──────────────────────────────────────────────
1220
+ function bindDropzone(zone, input, redirectAfter) {
1221
+ zone.addEventListener("click", () => input.click());
1222
+ input.addEventListener("change", (e) => importFiles(e.target.files, redirectAfter));
1223
+ zone.addEventListener("dragover", (e) => { e.preventDefault(); zone.classList.add("drag-over"); });
1224
+ zone.addEventListener("dragleave", () => zone.classList.remove("drag-over"));
1225
+ zone.addEventListener("drop", (e) => {
1226
+ e.preventDefault();
1227
+ zone.classList.remove("drag-over");
1228
+ importFiles(e.dataTransfer.files, redirectAfter);
1229
+ });
1230
+ }
1231
+
1232
+ function setupGlobalDrop() {
1233
+ // Prevent navigation when files dropped outside the dropzone.
1234
+ window.addEventListener("dragover", (e) => e.preventDefault());
1235
+ window.addEventListener("drop", (e) => {
1236
+ if (e.target.closest(".dropzone")) return;
1237
+ e.preventDefault();
1238
+ });
1239
+ }
1240
+
1241
+ async function importFiles(files, redirectAfter) {
1242
+ if (!files || files.length === 0) return;
1243
+ const file = files[0];
1244
+ showToast(\`<span class="spinner"></span> Importing \${esc(file.name)}…\`, 30_000);
1245
+ const text = await file.text();
1246
+ let envelope;
1247
+ try { envelope = JSON.parse(text); } catch (e) {
1248
+ showToast("Import failed: not valid JSON");
1249
+ return;
1250
+ }
1251
+ const r = await api("/knowledge/concepts/import?conflict=merge", {
1252
+ method: "POST", headers: { "Content-Type": "application/json" },
1253
+ body: JSON.stringify(envelope),
1254
+ });
1255
+ if (r.ok) {
1256
+ const stats = r.stats || {};
1257
+ showToast(\`✓ Imported \${stats.triples_inserted || 0} triple\${stats.triples_inserted === 1 ? "" : "s"}\` +
1258
+ (stats.triples_skipped_duplicate ? \` (\${stats.triples_skipped_duplicate} dupes skipped)\` : ""));
1259
+ const target = r.root_entity || redirectAfter;
1260
+ if (target) navigate("/knowledge/ui/entity/" + encodeURIComponent(target));
1261
+ else render();
1262
+ } else {
1263
+ showToast("Import failed: " + (r.error || "unknown"));
1264
+ }
1265
+ }
1266
+ </script>
1267
+ </body></html>`;
1268
+
1269
+ /**
1270
+ * Render the V2 SPA HTML with env-var-driven config substituted in.
1271
+ * The placeholders `__SHARE_PEERJS_HOST__` etc. are inert in the source
1272
+ * template; we replace them at serve time so the SPA can read the values
1273
+ * without an extra `/knowledge/share/config` round-trip on load.
1274
+ */
1275
+ function renderKnowledgeUiV2(): string {
1276
+ const peerHost = process.env.SINAIN_PEERJS_HOST || ""; // empty = peerjs.com cloud default
1277
+ const inlineMax = parseInt(process.env.SINAIN_SHARE_INLINE_MAX_BYTES || "6000");
1278
+ const ttlHours = parseInt(process.env.SINAIN_SHARE_TTL_HOURS || "24");
1279
+ return KNOWLEDGE_UI_V2_HTML
1280
+ .replace(/__SHARE_PEERJS_HOST__/g, JSON.stringify(peerHost))
1281
+ .replace(/__SHARE_INLINE_MAX_BYTES__/g, String(inlineMax))
1282
+ .replace(/__SHARE_TTL_HOURS__/g, String(ttlHours));
1283
+ }
1284
+
151
1285
  /** Server epoch — lets clients detect restarts. */
152
1286
  const serverEpoch = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
153
1287
 
@@ -188,6 +1322,23 @@ export interface ServerDeps {
188
1322
  embedTexts?: (texts: string[]) => Promise<Float32Array[]>;
189
1323
  isEmbeddingReady?: () => boolean;
190
1324
 
1325
+ /** Web UI metadata DB (bookmarks, page cache, retraction undo). */
1326
+ webDb?: WebDb;
1327
+ /** Search entities by query (FTS5 + entity ref grouping). */
1328
+ searchEntities?: (q: string, limit: number) => Promise<unknown>;
1329
+ /** Lazy-load entity graph children (one level via VAET backref). */
1330
+ graphChildren?: (entity: string) => Promise<unknown>;
1331
+ /** Render a Confluence-style page for an entity (cached via web.db). */
1332
+ renderEntityPage?: (entity: string, opts: { refresh: boolean; maxFacts: number }) => Promise<unknown>;
1333
+ /** Retract a fact entity (soft-delete in triplestore + audit triples + undo snapshot). */
1334
+ retractFact?: (factId: string, reason: string | null, actor: string | null, sourceEntity: string | null) => Promise<unknown>;
1335
+ /** Restore a previously retracted fact via undo token. */
1336
+ restoreFact?: (factId: string, undoToken: string) => Promise<unknown>;
1337
+ /** Export a concept bundle (entity + neighborhood) for transfer between machines. */
1338
+ exportConcept?: (entity: string, depth: number, opts: { includeRetracted: boolean; includePage: boolean; redactRules: string[] }) => Promise<unknown>;
1339
+ /** Import a concept bundle into the local knowledge graph. */
1340
+ importConcept?: (envelope: unknown, conflict: "skip" | "merge" | "overwrite") => Promise<unknown>;
1341
+
191
1342
  /** Bare-agent announced its roster on startup. */
192
1343
  registerBareAgent?: (available: string[], current: string) => void;
193
1344
  /** Current per-lane agent choice; read by run.sh via the piggyback field
@@ -511,13 +1662,430 @@ export function createAppServer(deps: ServerDeps) {
511
1662
  return;
512
1663
  }
513
1664
 
514
- if (req.method === "GET" && url.pathname === "/knowledge/ui") {
515
- // Simple web UI for browsing and transferring knowledge
1665
+ // ── /knowledge/search ── (entity-prioritized) ──
1666
+ if (req.method === "GET" && url.pathname === "/knowledge/search") {
1667
+ const q = url.searchParams.get("q") || "";
1668
+ const limit = Math.min(parseInt(url.searchParams.get("limit") || "20"), 100);
1669
+ if (!q.trim()) {
1670
+ res.writeHead(400);
1671
+ res.end(JSON.stringify({ ok: false, error: "q parameter required" }));
1672
+ return;
1673
+ }
1674
+ if (!deps.searchEntities) {
1675
+ res.end(JSON.stringify({ ok: true, results: [], topic_fallback: true }));
1676
+ return;
1677
+ }
1678
+ try {
1679
+ const result = await deps.searchEntities(q, limit) as any;
1680
+ // Telemetry
1681
+ if (deps.webDb) {
1682
+ const top = result.results?.[0]?.entity ?? null;
1683
+ deps.webDb.logSearch(q, top, result.results?.length ?? 0);
1684
+ }
1685
+ res.end(JSON.stringify({ ok: true, ...result }));
1686
+ } catch (err) {
1687
+ res.end(JSON.stringify({ ok: false, error: String(err) }));
1688
+ }
1689
+ return;
1690
+ }
1691
+
1692
+ // ── /knowledge/page ── (LLM-rendered Confluence-style page) ──
1693
+ if (req.method === "GET" && url.pathname === "/knowledge/page") {
1694
+ const entity = url.searchParams.get("entity") || "";
1695
+ const refresh = url.searchParams.get("refresh") === "1";
1696
+ const maxFacts = Math.min(parseInt(url.searchParams.get("max_facts") || "1000"), 5000);
1697
+ if (!entity) {
1698
+ res.writeHead(400);
1699
+ res.end(JSON.stringify({ ok: false, error: "entity parameter required" }));
1700
+ return;
1701
+ }
1702
+ if (!deps.renderEntityPage) {
1703
+ res.writeHead(503);
1704
+ res.end(JSON.stringify({ ok: false, error: "page renderer not available" }));
1705
+ return;
1706
+ }
1707
+ try {
1708
+ const page = await deps.renderEntityPage(entity, { refresh, maxFacts });
1709
+ // Touch bookmark visit (auto-populates 'recent')
1710
+ if (deps.webDb) deps.webDb.touchVisit(entity);
1711
+ res.end(JSON.stringify({ ok: true, ...(page as object) }));
1712
+ } catch (err) {
1713
+ res.writeHead(500);
1714
+ res.end(JSON.stringify({ ok: false, error: String(err) }));
1715
+ }
1716
+ return;
1717
+ }
1718
+
1719
+ // ── /knowledge/graph/children ── (lazy tree expansion) ──
1720
+ if (req.method === "GET" && url.pathname === "/knowledge/graph/children") {
1721
+ const entity = url.searchParams.get("entity") || "";
1722
+ if (!entity) {
1723
+ res.writeHead(400);
1724
+ res.end(JSON.stringify({ ok: false, error: "entity parameter required" }));
1725
+ return;
1726
+ }
1727
+ if (!deps.graphChildren) {
1728
+ res.end(JSON.stringify({ ok: true, entity, groups: [] }));
1729
+ return;
1730
+ }
1731
+ try {
1732
+ const result = await deps.graphChildren(entity);
1733
+ res.end(JSON.stringify({ ok: true, ...(result as object) }));
1734
+ } catch (err) {
1735
+ res.end(JSON.stringify({ ok: false, error: String(err) }));
1736
+ }
1737
+ return;
1738
+ }
1739
+
1740
+ // ── /knowledge/bookmarks ──
1741
+ if (req.method === "GET" && url.pathname === "/knowledge/bookmarks") {
1742
+ if (!deps.webDb) {
1743
+ res.writeHead(503);
1744
+ res.end(JSON.stringify({ ok: false, error: "web.db not initialized" }));
1745
+ return;
1746
+ }
1747
+ const status = url.searchParams.get("status") as BookmarkStatus | null;
1748
+ const limit = Math.min(parseInt(url.searchParams.get("limit") || "100"), 500);
1749
+ if (status && !["favorite","archive","recent"].includes(status)) {
1750
+ res.writeHead(400);
1751
+ res.end(JSON.stringify({ ok: false, error: "status must be favorite|archive|recent" }));
1752
+ return;
1753
+ }
1754
+ const bookmarks = deps.webDb.listBookmarks(status ?? undefined, limit);
1755
+ res.end(JSON.stringify({ ok: true, bookmarks }));
1756
+ return;
1757
+ }
1758
+
1759
+ if (req.method === "POST" && url.pathname === "/knowledge/bookmarks") {
1760
+ if (!deps.webDb) {
1761
+ res.writeHead(503);
1762
+ res.end(JSON.stringify({ ok: false, error: "web.db not initialized" }));
1763
+ return;
1764
+ }
1765
+ const body = await readBody(req, 16_384);
1766
+ let payload: { entity?: string; status?: string; note?: string };
1767
+ try { payload = JSON.parse(body); } catch {
1768
+ res.writeHead(400);
1769
+ res.end(JSON.stringify({ ok: false, error: "invalid JSON body" }));
1770
+ return;
1771
+ }
1772
+ const entity = (payload.entity || "").trim();
1773
+ const status = payload.status as BookmarkStatus | undefined;
1774
+ if (!entity) {
1775
+ res.writeHead(400);
1776
+ res.end(JSON.stringify({ ok: false, error: "entity required" }));
1777
+ return;
1778
+ }
1779
+ if (!status || !["favorite","archive","recent"].includes(status)) {
1780
+ res.writeHead(400);
1781
+ res.end(JSON.stringify({ ok: false, error: "status must be favorite|archive|recent" }));
1782
+ return;
1783
+ }
1784
+ const bookmark = deps.webDb.upsertBookmark(entity, status, payload.note);
1785
+ res.end(JSON.stringify({ ok: true, bookmark }));
1786
+ return;
1787
+ }
1788
+
1789
+ if (req.method === "DELETE" && url.pathname.startsWith("/knowledge/bookmarks/")) {
1790
+ if (!deps.webDb) {
1791
+ res.writeHead(503);
1792
+ res.end(JSON.stringify({ ok: false, error: "web.db not initialized" }));
1793
+ return;
1794
+ }
1795
+ const entity = decodeURIComponent(url.pathname.slice("/knowledge/bookmarks/".length));
1796
+ if (!entity) {
1797
+ res.writeHead(400);
1798
+ res.end(JSON.stringify({ ok: false, error: "entity required in path" }));
1799
+ return;
1800
+ }
1801
+ const removed = deps.webDb.deleteBookmark(entity);
1802
+ res.end(JSON.stringify({ ok: true, removed }));
1803
+ return;
1804
+ }
1805
+
1806
+ // ── /knowledge/concepts/export ──
1807
+ if (req.method === "GET" && url.pathname === "/knowledge/concepts/export") {
1808
+ const entity = url.searchParams.get("entity") || "";
1809
+ const depth = Math.min(parseInt(url.searchParams.get("depth") || "1"), 3);
1810
+ const includeRetracted = url.searchParams.get("include_retracted") === "1";
1811
+ const includePage = url.searchParams.get("include_page") !== "0";
1812
+ const redactRules = (url.searchParams.get("redact")
1813
+ || "private,creditcard,apikey,bearer,awskey,password,secret")
1814
+ .split(",").map(s => s.trim()).filter(Boolean);
1815
+ if (!entity) {
1816
+ res.writeHead(400);
1817
+ res.end(JSON.stringify({ ok: false, error: "entity parameter required" }));
1818
+ return;
1819
+ }
1820
+ if (!deps.exportConcept) {
1821
+ res.writeHead(503);
1822
+ res.end(JSON.stringify({ ok: false, error: "concept export not available" }));
1823
+ return;
1824
+ }
1825
+ try {
1826
+ const bundle = await deps.exportConcept(entity, depth, {
1827
+ includeRetracted, includePage, redactRules,
1828
+ });
1829
+ // Sanitize entity for filename
1830
+ const slug = entity.replace(/[^a-z0-9-]/gi, "-").replace(/-+/g, "-").slice(0, 60);
1831
+ res.setHeader("Content-Type", "application/json");
1832
+ res.setHeader("Content-Disposition", `attachment; filename="${slug}.sinain-concept.json"`);
1833
+ res.end(JSON.stringify(bundle, null, 2));
1834
+ } catch (err) {
1835
+ res.writeHead(500);
1836
+ res.end(JSON.stringify({ ok: false, error: String(err) }));
1837
+ }
1838
+ return;
1839
+ }
1840
+
1841
+ // ── /knowledge/concepts/import ──
1842
+ if (req.method === "POST" && url.pathname === "/knowledge/concepts/import") {
1843
+ const conflict = (url.searchParams.get("conflict") || "merge") as "skip"|"merge"|"overwrite";
1844
+ if (!["skip","merge","overwrite"].includes(conflict)) {
1845
+ res.writeHead(400);
1846
+ res.end(JSON.stringify({ ok: false, error: "conflict must be skip|merge|overwrite" }));
1847
+ return;
1848
+ }
1849
+ if (!deps.importConcept) {
1850
+ res.writeHead(503);
1851
+ res.end(JSON.stringify({ ok: false, error: "concept import not available" }));
1852
+ return;
1853
+ }
1854
+ // Allow large bundles (up to ~50MB).
1855
+ const body = await readBody(req, 50 * 1024 * 1024);
1856
+ let envelope: unknown;
1857
+ try { envelope = JSON.parse(body); } catch {
1858
+ res.writeHead(400);
1859
+ res.end(JSON.stringify({ ok: false, error: "invalid JSON body" }));
1860
+ return;
1861
+ }
1862
+ try {
1863
+ const result = await deps.importConcept(envelope, conflict);
1864
+ res.end(JSON.stringify(result));
1865
+ } catch (err) {
1866
+ res.writeHead(500);
1867
+ res.end(JSON.stringify({ ok: false, error: String(err) }));
1868
+ }
1869
+ return;
1870
+ }
1871
+
1872
+ // ── /knowledge/facts/:id (DELETE = retract, POST .../restore = restore) ──
1873
+ if (req.method === "DELETE" && url.pathname.startsWith("/knowledge/facts/")
1874
+ && !url.pathname.endsWith("/restore")) {
1875
+ const factId = decodeURIComponent(url.pathname.slice("/knowledge/facts/".length));
1876
+ if (!factId) {
1877
+ res.writeHead(400);
1878
+ res.end(JSON.stringify({ ok: false, error: "fact id required in path" }));
1879
+ return;
1880
+ }
1881
+ if (!deps.retractFact) {
1882
+ res.writeHead(503);
1883
+ res.end(JSON.stringify({ ok: false, error: "retraction not available" }));
1884
+ return;
1885
+ }
1886
+ let body: any = {};
1887
+ if (parseInt(req.headers["content-length"] || "0") > 0) {
1888
+ try { body = JSON.parse(await readBody(req, 4096)); } catch {}
1889
+ }
1890
+ const reason = (body.reason || "").toString().slice(0, 500) || null;
1891
+ const actor = (body.actor || "web-ui").toString().slice(0, 80) || null;
1892
+ const sourceEntity = (body.source_entity || "").toString().slice(0, 200) || null;
1893
+ try {
1894
+ const result = await deps.retractFact(factId, reason, actor, sourceEntity);
1895
+ res.end(JSON.stringify(result));
1896
+ } catch (err) {
1897
+ res.writeHead(500);
1898
+ res.end(JSON.stringify({ ok: false, error: String(err) }));
1899
+ }
1900
+ return;
1901
+ }
1902
+
1903
+ if (req.method === "POST" && url.pathname.startsWith("/knowledge/facts/")
1904
+ && url.pathname.endsWith("/restore")) {
1905
+ const factId = decodeURIComponent(
1906
+ url.pathname.slice("/knowledge/facts/".length, -"/restore".length),
1907
+ );
1908
+ if (!factId) {
1909
+ res.writeHead(400);
1910
+ res.end(JSON.stringify({ ok: false, error: "fact id required in path" }));
1911
+ return;
1912
+ }
1913
+ if (!deps.restoreFact) {
1914
+ res.writeHead(503);
1915
+ res.end(JSON.stringify({ ok: false, error: "restore not available" }));
1916
+ return;
1917
+ }
1918
+ let body: any = {};
1919
+ try { body = JSON.parse(await readBody(req, 4096)); } catch {
1920
+ res.writeHead(400);
1921
+ res.end(JSON.stringify({ ok: false, error: "invalid JSON body" }));
1922
+ return;
1923
+ }
1924
+ const undoToken = (body.undo_token || "").toString();
1925
+ if (!undoToken) {
1926
+ res.writeHead(400);
1927
+ res.end(JSON.stringify({ ok: false, error: "undo_token required" }));
1928
+ return;
1929
+ }
1930
+ try {
1931
+ // Python owns the retraction tx + audit log update — TS just relays.
1932
+ const result = await deps.restoreFact(factId, undoToken);
1933
+ res.end(JSON.stringify(result));
1934
+ } catch (err) {
1935
+ res.writeHead(500);
1936
+ res.end(JSON.stringify({ ok: false, error: String(err) }));
1937
+ }
1938
+ return;
1939
+ }
1940
+
1941
+ // ── /knowledge/shares ── (cross-machine concept share metadata) ──
1942
+ if (req.method === "POST" && url.pathname === "/knowledge/shares") {
1943
+ if (!deps.webDb) {
1944
+ res.writeHead(503);
1945
+ res.end(JSON.stringify({ ok: false, error: "web.db not initialized" }));
1946
+ return;
1947
+ }
1948
+ let body: any;
1949
+ try { body = JSON.parse(await readBody(req, 16_384)); } catch {
1950
+ res.writeHead(400);
1951
+ res.end(JSON.stringify({ ok: false, error: "invalid JSON" }));
1952
+ return;
1953
+ }
1954
+ const required = ["entity_id", "mode", "share_token", "url"];
1955
+ for (const k of required) {
1956
+ if (!body[k] || typeof body[k] !== "string") {
1957
+ res.writeHead(400);
1958
+ res.end(JSON.stringify({ ok: false, error: `${k} required` }));
1959
+ return;
1960
+ }
1961
+ }
1962
+ if (!["fragment", "peer"].includes(body.mode)) {
1963
+ res.writeHead(400);
1964
+ res.end(JSON.stringify({ ok: false, error: "mode must be fragment|peer" }));
1965
+ return;
1966
+ }
1967
+ try {
1968
+ const row = deps.webDb.createSharedDoc({
1969
+ share_token: body.share_token,
1970
+ entity_id: body.entity_id,
1971
+ mode: body.mode,
1972
+ // Fragment shares are 'delivered' the moment the link is created
1973
+ // (the bundle is in the URL); peer shares start as 'waiting'.
1974
+ status: body.mode === "fragment" ? "delivered" : "waiting",
1975
+ bundle_size: typeof body.bundle_size === "number" ? body.bundle_size : null,
1976
+ url: body.url,
1977
+ delivered_at: body.mode === "fragment" ? Date.now() : null,
1978
+ revoked_at: null,
1979
+ recipient_hint: null,
1980
+ notes: body.notes || null,
1981
+ });
1982
+ res.end(JSON.stringify({ ok: true, share: row }));
1983
+ } catch (err: any) {
1984
+ // Most likely UNIQUE constraint on share_token
1985
+ res.writeHead(409);
1986
+ res.end(JSON.stringify({ ok: false, error: err.message?.slice(0, 200) }));
1987
+ }
1988
+ return;
1989
+ }
1990
+
1991
+ if (req.method === "GET" && url.pathname === "/knowledge/shares") {
1992
+ if (!deps.webDb) {
1993
+ res.writeHead(503);
1994
+ res.end(JSON.stringify({ ok: false, error: "web.db not initialized" }));
1995
+ return;
1996
+ }
1997
+ // Auto-expire stale shares opportunistically on each list call.
1998
+ const ttlHours = parseInt(process.env.SINAIN_SHARE_TTL_HOURS || "24");
1999
+ if (ttlHours > 0) {
2000
+ deps.webDb.expireStaleShares(ttlHours * 60 * 60 * 1000);
2001
+ }
2002
+ const statusParams = url.searchParams.getAll("status").filter(Boolean);
2003
+ const limit = Math.min(parseInt(url.searchParams.get("limit") || "200"), 500);
2004
+ const includeArchived = url.searchParams.get("include_archived") === "1";
2005
+ const shares = deps.webDb.listSharedDocs({
2006
+ statuses: statusParams.length > 0 ? statusParams as any : undefined,
2007
+ limit,
2008
+ includeArchived,
2009
+ });
2010
+ const activeCount = deps.webDb.countActiveShares();
2011
+ res.end(JSON.stringify({ ok: true, shares, active_count: activeCount }));
2012
+ return;
2013
+ }
2014
+
2015
+ if (req.method === "PATCH" && url.pathname.startsWith("/knowledge/shares/")) {
2016
+ if (!deps.webDb) {
2017
+ res.writeHead(503);
2018
+ res.end(JSON.stringify({ ok: false, error: "web.db not initialized" }));
2019
+ return;
2020
+ }
2021
+ const token = decodeURIComponent(url.pathname.slice("/knowledge/shares/".length));
2022
+ if (!token) {
2023
+ res.writeHead(400);
2024
+ res.end(JSON.stringify({ ok: false, error: "share_token required" }));
2025
+ return;
2026
+ }
2027
+ let body: any;
2028
+ try { body = JSON.parse(await readBody(req, 4096)); } catch {
2029
+ res.writeHead(400);
2030
+ res.end(JSON.stringify({ ok: false, error: "invalid JSON" }));
2031
+ return;
2032
+ }
2033
+ const status = body.status;
2034
+ const valid = ["waiting","connecting","delivered","disconnected","revoked","expired"];
2035
+ if (!status || !valid.includes(status)) {
2036
+ res.writeHead(400);
2037
+ res.end(JSON.stringify({ ok: false, error: `status must be one of ${valid.join("|")}` }));
2038
+ return;
2039
+ }
2040
+ const ok = deps.webDb.updateSharedDocStatus(token, status, {
2041
+ delivered_at: typeof body.delivered_at === "number" ? body.delivered_at : undefined,
2042
+ revoked_at: typeof body.revoked_at === "number" ? body.revoked_at : undefined,
2043
+ recipient_hint: typeof body.recipient_hint === "string" ? body.recipient_hint.slice(0, 200) : undefined,
2044
+ });
2045
+ if (!ok) {
2046
+ res.writeHead(404);
2047
+ res.end(JSON.stringify({ ok: false, error: "share not found" }));
2048
+ return;
2049
+ }
2050
+ res.end(JSON.stringify({ ok: true, share: deps.webDb.getSharedDoc(token) }));
2051
+ return;
2052
+ }
2053
+
2054
+ if (req.method === "DELETE" && url.pathname.startsWith("/knowledge/shares/")) {
2055
+ if (!deps.webDb) {
2056
+ res.writeHead(503);
2057
+ res.end(JSON.stringify({ ok: false, error: "web.db not initialized" }));
2058
+ return;
2059
+ }
2060
+ const token = decodeURIComponent(url.pathname.slice("/knowledge/shares/".length));
2061
+ const removed = deps.webDb.deleteSharedDoc(token);
2062
+ res.end(JSON.stringify({ ok: true, removed }));
2063
+ return;
2064
+ }
2065
+
2066
+ // Legacy fact-browser kept for fallback / quick raw access.
2067
+ if (req.method === "GET" && url.pathname === "/knowledge/ui-legacy") {
516
2068
  res.setHeader("Content-Type", "text/html");
517
2069
  res.end(KNOWLEDGE_UI_HTML);
518
2070
  return;
519
2071
  }
520
2072
 
2073
+ // New "living Confluence" SPA — search-driven, LLM-rendered pages,
2074
+ // bookmarks, retraction, concept transfer.
2075
+ if (req.method === "GET" && url.pathname === "/knowledge/ui") {
2076
+ res.setHeader("Content-Type", "text/html");
2077
+ res.end(renderKnowledgeUiV2());
2078
+ return;
2079
+ }
2080
+
2081
+ // SPA route handling — the SPA uses path-style entity URLs. Server-side
2082
+ // we just serve the same HTML; client-side router parses location.pathname.
2083
+ if (req.method === "GET" && url.pathname.startsWith("/knowledge/ui/")) {
2084
+ res.setHeader("Content-Type", "text/html");
2085
+ res.end(renderKnowledgeUiV2());
2086
+ return;
2087
+ }
2088
+
521
2089
  // ── /traces ──
522
2090
  if (req.method === "GET" && url.pathname === "/traces") {
523
2091
  const after = parseInt(url.searchParams.get("after") || "0");