@geravant/sinain 1.20.0 → 1.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geravant/sinain",
3
- "version": "1.20.0",
3
+ "version": "1.22.1",
4
4
  "description": "Ambient intelligence that sees what you see, hears what you hear, and acts on your behalf",
5
5
  "type": "module",
6
6
  "bin": {
@@ -366,6 +366,9 @@ export class LocalCurationService {
366
366
  this.writeDailyNotes(digest, transcript as any);
367
367
 
368
368
  // Step 2: Integrate into playbook + knowledge graph
369
+ // Inject raw feed items so integrator stores verbatim quotes + agent analysis
370
+ digest._rawItems = transcript;
371
+ digest._feedItemCount = transcript.length;
369
372
  try {
370
373
  const integratorOutput = execFileSync("python3", [
371
374
  resolve(this.scriptsDir, "knowledge_integrator.py"),
@@ -331,6 +331,32 @@ const KNOWLEDGE_UI_V2_HTML = `<!DOCTYPE html>
331
331
  .toast .timer { height: 2px; background: var(--accent); position: absolute;
332
332
  bottom: 0; left: 0; transition: width linear; }
333
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; } }
334
360
  /* Loading */
335
361
  .spinner { display: inline-block; width: 14px; height: 14px;
336
362
  border: 2px solid var(--border); border-top-color: var(--accent);
@@ -351,6 +377,7 @@ const KNOWLEDGE_UI_V2_HTML = `<!DOCTYPE html>
351
377
  <div id="searchResults" class="search-results"></div>
352
378
  </div>
353
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>
354
381
  <a href="/knowledge/ui-legacy"><button>Legacy view</button></a>
355
382
  </div>
356
383
  </header>
@@ -381,6 +408,12 @@ async function api(path, opts = {}) {
381
408
  }
382
409
  }
383
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
+ const SHARE_BASE_URL = __SHARE_BASE_URL__; // public redirector that points to localhost
416
+
384
417
  // ── Router ────────────────────────────────────────────────────────────────
385
418
  function navigate(path) {
386
419
  history.pushState({}, "", path);
@@ -390,6 +423,8 @@ window.addEventListener("popstate", render);
390
423
  window.addEventListener("DOMContentLoaded", () => {
391
424
  setupSearch();
392
425
  setupGlobalDrop();
426
+ ShareManager.resumePeerShares().catch(e => console.warn("share resume failed", e));
427
+ refreshShareBadge();
393
428
  render();
394
429
  });
395
430
 
@@ -397,6 +432,8 @@ function render() {
397
432
  const path = location.pathname;
398
433
  if (path === "/knowledge/ui" || path === "/knowledge/ui/") {
399
434
  renderHome();
435
+ } else if (path === "/knowledge/ui/shares" || path === "/knowledge/ui/shares/") {
436
+ renderSharesView();
400
437
  } else if (path.startsWith("/knowledge/ui/entity/")) {
401
438
  const entity = decodeURIComponent(path.slice("/knowledge/ui/entity/".length));
402
439
  renderEntityPage(entity);
@@ -408,6 +445,267 @@ function render() {
408
445
  }
409
446
  }
410
447
 
448
+ // ── Share infrastructure (gzip helpers, peerjs loader, ShareManager) ─────
449
+
450
+ function randomHex(byteCount) {
451
+ const buf = new Uint8Array(byteCount);
452
+ crypto.getRandomValues(buf);
453
+ return Array.from(buf, b => b.toString(16).padStart(2, "0")).join("");
454
+ }
455
+
456
+ async function gzipBase64(text) {
457
+ // CompressionStream("gzip") is in all modern browsers (Chrome 80+, Safari
458
+ // 16.4+, Firefox 113+). No external library needed.
459
+ const cs = new Blob([text]).stream().pipeThrough(new CompressionStream("gzip"));
460
+ const buf = new Uint8Array(await new Response(cs).arrayBuffer());
461
+ // base64url so the output is URL-safe (no +, /, =).
462
+ let bin = "";
463
+ for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i]);
464
+ return btoa(bin).replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/, "");
465
+ }
466
+
467
+ async function ungzipBase64(encoded) {
468
+ const padded = encoded.replace(/-/g, "+").replace(/_/g, "/")
469
+ + "===".slice((encoded.length + 3) % 4);
470
+ const bin = atob(padded);
471
+ const bytes = new Uint8Array(bin.length);
472
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
473
+ const ds = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"));
474
+ return await new Response(ds).text();
475
+ }
476
+
477
+ let _peerjsLoading = null;
478
+ function ensurePeerJsLoaded() {
479
+ if (window.Peer) return Promise.resolve();
480
+ if (_peerjsLoading) return _peerjsLoading;
481
+ _peerjsLoading = new Promise((res, rej) => {
482
+ const s = document.createElement("script");
483
+ // Pinned version + SRI hash. If you bump version, regenerate hash via:
484
+ // curl -sL https://cdn.jsdelivr.net/npm/peerjs@1.5.4/dist/peerjs.min.js | openssl dgst -sha384 -binary | openssl base64 -A
485
+ s.src = "https://cdn.jsdelivr.net/npm/peerjs@1.5.4/dist/peerjs.min.js";
486
+ s.crossOrigin = "anonymous";
487
+ s.onload = () => res();
488
+ s.onerror = (e) => rej(new Error("peerjs failed to load — network or CDN issue"));
489
+ document.head.appendChild(s);
490
+ });
491
+ return _peerjsLoading;
492
+ }
493
+
494
+ function newPeer(idOrUndef) {
495
+ // Honor SHARE_PEERJS_HOST env-injected override. Empty string = peerjs.com default.
496
+ const opts = SHARE_PEERJS_HOST ? { host: SHARE_PEERJS_HOST } : {};
497
+ return idOrUndef ? new window.Peer(idOrUndef, opts) : new window.Peer(opts);
498
+ }
499
+
500
+ const ShareManager = (() => {
501
+ // share_token → live Peer instance (sender side only). Bundles are re-fetched
502
+ // on demand rather than kept in JS memory across resume.
503
+ const peers = new Map();
504
+
505
+ async function buildBundle(entity) {
506
+ const r = await fetch(\`/knowledge/concepts/export?entity=\${encodeURIComponent(entity)}\` +
507
+ \`&depth=1&include_page=1\`);
508
+ if (!r.ok) throw new Error("export failed: " + r.status);
509
+ return await r.text();
510
+ }
511
+
512
+ // Build the public-shareable URL. Recipient pastes this anywhere; the
513
+ // redirector at SHARE_BASE_URL does a client-side rewrite to their local
514
+ // sinain-core (location.href = "http://localhost:<port>/...#hash") with
515
+ // the fragment preserved (browsers don't send fragments to the server,
516
+ // so bundle bytes never touch the CDN).
517
+ function buildShareUrl(entity, hash) {
518
+ const port = location.port || (location.protocol === "https:" ? "443" : "80");
519
+ const params = new URLSearchParams({ entity, port });
520
+ return SHARE_BASE_URL + "?" + params.toString() + hash;
521
+ }
522
+
523
+ async function createShare(entity) {
524
+ const bundle = await buildBundle(entity);
525
+ const sizeBytes = new TextEncoder().encode(bundle).length;
526
+ const token = randomHex(8); // 16 hex chars
527
+
528
+ if (sizeBytes <= SHARE_INLINE_MAX_BYTES) {
529
+ const compressed = await gzipBase64(bundle);
530
+ const url = buildShareUrl(entity, "#bundle=" + compressed);
531
+ await api("/knowledge/shares", { method: "POST",
532
+ headers: {"Content-Type": "application/json"},
533
+ body: JSON.stringify({
534
+ entity_id: entity, mode: "fragment", share_token: token, url, bundle_size: sizeBytes
535
+ })
536
+ });
537
+ try { await navigator.clipboard.writeText(url); } catch { /* clipboard denied */ }
538
+ showToast("✓ Link copied · self-contained, can't be revoked", 6000);
539
+ refreshShareBadge();
540
+ return { mode: "fragment", url };
541
+ }
542
+
543
+ // Peer mode
544
+ await ensurePeerJsLoaded();
545
+ const peer = newPeer(token);
546
+ await new Promise((res, rej) => {
547
+ peer.on("open", () => res());
548
+ peer.on("error", e => rej(e));
549
+ setTimeout(() => rej(new Error("peerjs broker timeout")), 8000);
550
+ });
551
+ const url = buildShareUrl(entity, "#peer=" + token);
552
+ await api("/knowledge/shares", { method: "POST",
553
+ headers: {"Content-Type": "application/json"},
554
+ body: JSON.stringify({
555
+ entity_id: entity, mode: "peer", share_token: token, url, bundle_size: sizeBytes
556
+ })
557
+ });
558
+ peers.set(token, peer);
559
+ attachSenderHandlers(peer, token, entity);
560
+ try { await navigator.clipboard.writeText(url); } catch {}
561
+ showToast("✓ Link copied · live until you revoke (see Shares)", 6000);
562
+ refreshShareBadge();
563
+ return { mode: "peer", url };
564
+ }
565
+
566
+ function attachSenderHandlers(peer, token, entity) {
567
+ peer.on("connection", (conn) => {
568
+ patchStatus(token, "connecting");
569
+ conn.on("open", async () => {
570
+ try {
571
+ // Re-fetch bundle each time — keeps memory low and reflects latest state.
572
+ const bundle = await buildBundle(entity);
573
+ conn.send({ type: "bundle", payload: bundle });
574
+ } catch (e) {
575
+ conn.send({ type: "error", message: String(e).slice(0, 200) });
576
+ conn.close();
577
+ }
578
+ });
579
+ conn.on("data", (msg) => {
580
+ if (msg && msg.type === "ack") {
581
+ patchStatus(token, "delivered", { delivered_at: Date.now() });
582
+ // Keep peer alive briefly for retries, then release.
583
+ setTimeout(() => destroyPeer(token), 5000);
584
+ }
585
+ });
586
+ conn.on("close", () => { /* normal */ });
587
+ });
588
+ peer.on("disconnected", () => patchStatus(token, "disconnected"));
589
+ peer.on("close", () => patchStatus(token, "disconnected"));
590
+ peer.on("error", (err) => {
591
+ console.warn("share peer error", token, err && err.type, err && err.message);
592
+ });
593
+ }
594
+
595
+ async function patchStatus(token, status, extra) {
596
+ const body = Object.assign({ status }, extra || {});
597
+ await api("/knowledge/shares/" + encodeURIComponent(token), {
598
+ method: "PATCH", headers: {"Content-Type": "application/json"},
599
+ body: JSON.stringify(body),
600
+ });
601
+ refreshShareBadge();
602
+ }
603
+
604
+ async function resumePeerShares() {
605
+ const r = await api("/knowledge/shares?status=waiting&status=connecting&status=disconnected");
606
+ if (!r || !r.ok) return;
607
+ for (const share of r.shares || []) {
608
+ if (share.mode !== "peer") continue;
609
+ try {
610
+ await ensurePeerJsLoaded();
611
+ const peer = newPeer(share.share_token);
612
+ await new Promise((res, rej) => {
613
+ peer.on("open", () => res());
614
+ peer.on("error", e => rej(e));
615
+ setTimeout(() => rej(new Error("peerjs open timeout")), 8000);
616
+ });
617
+ peers.set(share.share_token, peer);
618
+ attachSenderHandlers(peer, share.share_token, share.entity_id);
619
+ if (share.status !== "waiting") {
620
+ await patchStatus(share.share_token, "waiting");
621
+ }
622
+ } catch (e) {
623
+ console.warn("resume failed for", share.share_token, e && e.message);
624
+ // Mark as disconnected so the user sees it failed; they can manually revoke.
625
+ await patchStatus(share.share_token, "disconnected").catch(() => {});
626
+ }
627
+ }
628
+ }
629
+
630
+ function destroyPeer(token) {
631
+ const peer = peers.get(token);
632
+ if (peer) {
633
+ try { peer.destroy(); } catch {}
634
+ peers.delete(token);
635
+ }
636
+ }
637
+
638
+ async function revoke(token) {
639
+ destroyPeer(token);
640
+ await api("/knowledge/shares/" + encodeURIComponent(token), {
641
+ method: "PATCH", headers: {"Content-Type": "application/json"},
642
+ body: JSON.stringify({ status: "revoked", revoked_at: Date.now() })
643
+ });
644
+ refreshShareBadge();
645
+ }
646
+
647
+ async function forget(token) {
648
+ destroyPeer(token);
649
+ await api("/knowledge/shares/" + encodeURIComponent(token), { method: "DELETE" });
650
+ refreshShareBadge();
651
+ }
652
+
653
+ async function connectAsRecipient(token) {
654
+ showToast('<span class="spinner"></span> Connecting peer-to-peer…', 30_000);
655
+ await ensurePeerJsLoaded();
656
+ const me = newPeer();
657
+ await new Promise((res, rej) => {
658
+ me.on("open", () => res());
659
+ me.on("error", e => rej(e));
660
+ setTimeout(() => rej(new Error("peerjs broker timeout")), 8000);
661
+ });
662
+ return new Promise((resolve, reject) => {
663
+ const conn = me.connect(token, { reliable: true });
664
+ const cleanup = () => { try { conn.close(); } catch {} try { me.destroy(); } catch {} };
665
+ const openTimeout = setTimeout(() => {
666
+ cleanup();
667
+ reject(new Error("source offline or unreachable"));
668
+ }, 15_000);
669
+ conn.on("open", () => clearTimeout(openTimeout));
670
+ conn.on("error", (e) => { cleanup(); reject(e); });
671
+ conn.on("data", async (msg) => {
672
+ if (!msg) return;
673
+ if (msg.type === "error") {
674
+ cleanup();
675
+ reject(new Error("source error: " + msg.message));
676
+ return;
677
+ }
678
+ if (msg.type === "bundle") {
679
+ try {
680
+ const importR = await api("/knowledge/concepts/import?conflict=merge", {
681
+ method: "POST",
682
+ headers: {"Content-Type": "application/json"},
683
+ body: msg.payload,
684
+ });
685
+ conn.send({ type: "ack" });
686
+ setTimeout(cleanup, 500);
687
+ resolve(importR);
688
+ } catch (e) {
689
+ cleanup();
690
+ reject(e);
691
+ }
692
+ }
693
+ });
694
+ });
695
+ }
696
+
697
+ return { createShare, resumePeerShares, revoke, forget, connectAsRecipient };
698
+ })();
699
+
700
+ async function refreshShareBadge() {
701
+ try {
702
+ const r = await api("/knowledge/shares?status=waiting&status=connecting");
703
+ const count = (r && r.shares) ? r.shares.length : 0;
704
+ const badge = document.getElementById("shareBadge");
705
+ if (badge) badge.textContent = count > 0 ? "(" + count + ")" : "";
706
+ } catch {}
707
+ }
708
+
411
709
  // ── Home view ─────────────────────────────────────────────────────────────
412
710
  async function renderHome() {
413
711
  document.title = "Sinain Knowledge";
@@ -454,6 +752,111 @@ function timeAgo(ts) {
454
752
  return Math.round(diff / 86_400_000) + "d ago";
455
753
  }
456
754
 
755
+ function fmtBytes(n) {
756
+ if (n == null) return "?";
757
+ if (n < 1024) return n + " B";
758
+ if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
759
+ return (n / 1024 / 1024).toFixed(1) + " MB";
760
+ }
761
+
762
+ // ── Shares view ───────────────────────────────────────────────────────────
763
+ async function renderSharesView() {
764
+ document.title = "Shares · Sinain";
765
+ const root = $("#root");
766
+ root.innerHTML = '<div class="loading-block"><span class="spinner"></span> Loading shares…</div>';
767
+
768
+ const r = await api("/knowledge/shares?include_archived=1");
769
+ const shares = (r && r.shares) || [];
770
+ refreshShareBadge();
771
+
772
+ if (shares.length === 0) {
773
+ root.innerHTML = \`
774
+ <h1>Shares</h1>
775
+ <div class="empty-row" style="padding:24px;">
776
+ No shares yet. Open an entity page and click 📤 Share to create one.
777
+ </div>\`;
778
+ return;
779
+ }
780
+
781
+ root.innerHTML = '<h1>Shares</h1><div class="shares-list" id="sharesList"></div>';
782
+ const list = $("#sharesList");
783
+ list.innerHTML = shares.map(renderShareRow).join("");
784
+
785
+ // Wire per-row actions via event delegation
786
+ list.addEventListener("click", async (e) => {
787
+ const btn = e.target.closest("button[data-action]");
788
+ if (!btn) return;
789
+ const token = btn.dataset.token;
790
+ const action = btn.dataset.action;
791
+ const share = shares.find(s => s.share_token === token);
792
+ if (!share) return;
793
+ if (action === "copy") {
794
+ try {
795
+ await navigator.clipboard.writeText(share.url);
796
+ showToast("✓ URL copied");
797
+ } catch {
798
+ showToast("Copy failed — your browser may block clipboard access");
799
+ }
800
+ } else if (action === "revoke") {
801
+ if (share.mode === "fragment") {
802
+ const ok = confirm(
803
+ "Mark this share as revoked?\\n\\n" +
804
+ "Note: the URL is self-contained — anyone who already has it can still import. " +
805
+ "This only removes it from your active list.");
806
+ if (!ok) return;
807
+ }
808
+ await ShareManager.revoke(token);
809
+ renderSharesView();
810
+ } else if (action === "forget") {
811
+ const ok = confirm("Remove this share from your list permanently?");
812
+ if (!ok) return;
813
+ await ShareManager.forget(token);
814
+ renderSharesView();
815
+ } else if (action === "open") {
816
+ navigate("/knowledge/ui/entity/" + encodeURIComponent(share.entity_id));
817
+ }
818
+ });
819
+ }
820
+
821
+ function renderShareRow(s) {
822
+ const isPeer = s.mode === "peer";
823
+ const statusClass = "pill-" + (s.mode === "fragment" && s.status === "delivered" ? "permanent" : s.status);
824
+ const statusLabel = s.mode === "fragment" && s.status === "delivered" ? "permanent" : s.status;
825
+ const pulsing = (s.status === "waiting" || s.status === "connecting") ? " pulse" : "";
826
+ const icon = ({
827
+ waiting: "⏳", connecting: "⚡", delivered: isPeer ? "✓" : "📎",
828
+ disconnected: "⚠", revoked: "✕", expired: "⌛"
829
+ })[s.status] || "•";
830
+
831
+ const sub = [];
832
+ sub.push(timeAgo(s.created_at));
833
+ if (s.bundle_size != null) sub.push(fmtBytes(s.bundle_size));
834
+ if (isPeer) sub.push("PEER"); else sub.push("LINK");
835
+ if (s.delivered_at) sub.push("delivered " + timeAgo(s.delivered_at));
836
+ if (s.recipient_hint) sub.push(s.recipient_hint);
837
+
838
+ // Per-mode actions: peer has Revoke (real); fragment has Revoke (best-effort)
839
+ // and both have Copy + Forget.
840
+ const showRevoke = (s.status === "waiting" || s.status === "connecting" || s.status === "disconnected"
841
+ || (s.mode === "fragment" && s.status === "delivered"));
842
+ return \`
843
+ <div class="share-row\${pulsing}">
844
+ <div class="icon">\${icon}</div>
845
+ <div class="body">
846
+ <div class="title" onclick="navigate('/knowledge/ui/entity/' + encodeURIComponent('\${esc(s.entity_id)}'))" style="cursor:pointer">
847
+ \${esc(s.entity_id)}
848
+ </div>
849
+ <div class="meta">\${sub.map(esc).join(" · ")}</div>
850
+ </div>
851
+ <div class="actions">
852
+ <span class="pill \${statusClass}">\${esc(statusLabel)}</span>
853
+ <button data-action="copy" data-token="\${esc(s.share_token)}" title="Copy share URL">📋</button>
854
+ \${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>\` : ""}
855
+ <button data-action="forget" data-token="\${esc(s.share_token)}" title="Remove from list">🗑</button>
856
+ </div>
857
+ </div>\`;
858
+ }
859
+
457
860
  // ── Search ────────────────────────────────────────────────────────────────
458
861
  function setupSearch() {
459
862
  const input = $("#search");
@@ -491,6 +894,30 @@ async function renderEntityPage(entity) {
491
894
  const root = $("#root");
492
895
  root.innerHTML = \`<div class="loading-block"><span class="spinner"></span> Loading \${esc(entity)}…</div>\`;
493
896
 
897
+ // Auto-import path for share links — runs BEFORE the local existence check
898
+ // so a recipient with no prior data on this entity gets the page populated.
899
+ if (location.hash.startsWith("#bundle=")) {
900
+ try {
901
+ const json = await ungzipBase64(location.hash.slice("#bundle=".length));
902
+ await api("/knowledge/concepts/import?conflict=merge", {
903
+ method: "POST", headers: {"Content-Type": "application/json"}, body: json
904
+ });
905
+ showToast("✓ Concept imported");
906
+ } catch (e) {
907
+ showToast("Import failed: " + (e.message || "decode error"));
908
+ }
909
+ history.replaceState({}, "", location.pathname); // strip hash
910
+ } else if (location.hash.startsWith("#peer=")) {
911
+ const token = location.hash.slice("#peer=".length);
912
+ history.replaceState({}, "", location.pathname); // strip early — keeps refresh sane
913
+ try {
914
+ await ShareManager.connectAsRecipient(token);
915
+ showToast("✓ Concept imported via peer");
916
+ } catch (e) {
917
+ showToast("Peer share failed: " + (e.message || "unreachable"));
918
+ }
919
+ }
920
+
494
921
  const page = await api("/knowledge/page?entity=" + encodeURIComponent(entity));
495
922
  if (!page.ok || page.fact_count === 0) {
496
923
  if (page.fact_count === 0) {
@@ -515,8 +942,9 @@ async function renderEntityPage(entity) {
515
942
  <button id="bmFavorite" class="icon" title="Favorite">★</button>
516
943
  <button id="bmArchive" class="icon" title="Archive">🗄</button>
517
944
  <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>
945
+ <button id="actCopyLink" class="icon" title="Copy entity URL (recipient needs same data)">🔗</button>
946
+ <button id="actShare" class="icon" title="Share concept (auto-imports for recipient)">📤</button>
947
+ <button id="actExport" class="icon" title="Download bundle file (manual transfer)">⬇</button>
520
948
  </div>
521
949
  </div>
522
950
  <div class="layout-3col">
@@ -549,6 +977,7 @@ async function renderEntityPage(entity) {
549
977
  $("#bmArchive").onclick = () => bookmarkAction(entity, "archive");
550
978
  $("#actRefresh").onclick = () => refreshPage(entity);
551
979
  $("#actCopyLink").onclick = () => copyLink(entity);
980
+ $("#actShare").onclick = () => shareEntity(entity);
552
981
  $("#actExport").onclick = () => exportConcept(entity);
553
982
 
554
983
  // Wire bullet retraction (event delegation)
@@ -673,6 +1102,17 @@ async function exportConcept(entity) {
673
1102
  showToast("✓ Exporting concept bundle…");
674
1103
  }
675
1104
 
1105
+ async function shareEntity(entity) {
1106
+ showToast('<span class="spinner"></span> Preparing share…', 30_000);
1107
+ try {
1108
+ await ShareManager.createShare(entity);
1109
+ // Toast + clipboard already handled by ShareManager. User can navigate
1110
+ // freely; status is visible in the Shares view.
1111
+ } catch (e) {
1112
+ showToast("Share failed: " + (e && e.message ? e.message : String(e)));
1113
+ }
1114
+ }
1115
+
676
1116
  // ── Retraction modal + undo toast ─────────────────────────────────────────
677
1117
  function openRetractModal(factId, bulletEl, sourceEntity) {
678
1118
  const text = bulletEl.querySelector(".text").textContent;
@@ -836,6 +1276,28 @@ async function importFiles(files, redirectAfter) {
836
1276
  </script>
837
1277
  </body></html>`;
838
1278
 
1279
+ /**
1280
+ * Render the V2 SPA HTML with env-var-driven config substituted in.
1281
+ * The placeholders `__SHARE_PEERJS_HOST__` etc. are inert in the source
1282
+ * template; we replace them at serve time so the SPA can read the values
1283
+ * without an extra `/knowledge/share/config` round-trip on load.
1284
+ */
1285
+ function renderKnowledgeUiV2(): string {
1286
+ const peerHost = process.env.SINAIN_PEERJS_HOST || ""; // empty = peerjs.com cloud default
1287
+ const inlineMax = parseInt(process.env.SINAIN_SHARE_INLINE_MAX_BYTES || "6000");
1288
+ const ttlHours = parseInt(process.env.SINAIN_SHARE_TTL_HOURS || "24");
1289
+ // Public URL of the share-redirector (docs/share.html in the repo). Browsers
1290
+ // preserve URL fragments through redirects without sending them to the
1291
+ // server, so the bundle bytes never touch this CDN.
1292
+ const shareBaseUrl = process.env.SINAIN_SHARE_BASE_URL
1293
+ || "https://cdn.jsdelivr.net/gh/anthillnet/sinain-hud@main/docs/share.html";
1294
+ return KNOWLEDGE_UI_V2_HTML
1295
+ .replace(/__SHARE_PEERJS_HOST__/g, JSON.stringify(peerHost))
1296
+ .replace(/__SHARE_INLINE_MAX_BYTES__/g, String(inlineMax))
1297
+ .replace(/__SHARE_TTL_HOURS__/g, String(ttlHours))
1298
+ .replace(/__SHARE_BASE_URL__/g, JSON.stringify(shareBaseUrl));
1299
+ }
1300
+
839
1301
  /** Server epoch — lets clients detect restarts. */
840
1302
  const serverEpoch = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
841
1303
 
@@ -1492,6 +1954,131 @@ export function createAppServer(deps: ServerDeps) {
1492
1954
  return;
1493
1955
  }
1494
1956
 
1957
+ // ── /knowledge/shares ── (cross-machine concept share metadata) ──
1958
+ if (req.method === "POST" && url.pathname === "/knowledge/shares") {
1959
+ if (!deps.webDb) {
1960
+ res.writeHead(503);
1961
+ res.end(JSON.stringify({ ok: false, error: "web.db not initialized" }));
1962
+ return;
1963
+ }
1964
+ let body: any;
1965
+ try { body = JSON.parse(await readBody(req, 16_384)); } catch {
1966
+ res.writeHead(400);
1967
+ res.end(JSON.stringify({ ok: false, error: "invalid JSON" }));
1968
+ return;
1969
+ }
1970
+ const required = ["entity_id", "mode", "share_token", "url"];
1971
+ for (const k of required) {
1972
+ if (!body[k] || typeof body[k] !== "string") {
1973
+ res.writeHead(400);
1974
+ res.end(JSON.stringify({ ok: false, error: `${k} required` }));
1975
+ return;
1976
+ }
1977
+ }
1978
+ if (!["fragment", "peer"].includes(body.mode)) {
1979
+ res.writeHead(400);
1980
+ res.end(JSON.stringify({ ok: false, error: "mode must be fragment|peer" }));
1981
+ return;
1982
+ }
1983
+ try {
1984
+ const row = deps.webDb.createSharedDoc({
1985
+ share_token: body.share_token,
1986
+ entity_id: body.entity_id,
1987
+ mode: body.mode,
1988
+ // Fragment shares are 'delivered' the moment the link is created
1989
+ // (the bundle is in the URL); peer shares start as 'waiting'.
1990
+ status: body.mode === "fragment" ? "delivered" : "waiting",
1991
+ bundle_size: typeof body.bundle_size === "number" ? body.bundle_size : null,
1992
+ url: body.url,
1993
+ delivered_at: body.mode === "fragment" ? Date.now() : null,
1994
+ revoked_at: null,
1995
+ recipient_hint: null,
1996
+ notes: body.notes || null,
1997
+ });
1998
+ res.end(JSON.stringify({ ok: true, share: row }));
1999
+ } catch (err: any) {
2000
+ // Most likely UNIQUE constraint on share_token
2001
+ res.writeHead(409);
2002
+ res.end(JSON.stringify({ ok: false, error: err.message?.slice(0, 200) }));
2003
+ }
2004
+ return;
2005
+ }
2006
+
2007
+ if (req.method === "GET" && url.pathname === "/knowledge/shares") {
2008
+ if (!deps.webDb) {
2009
+ res.writeHead(503);
2010
+ res.end(JSON.stringify({ ok: false, error: "web.db not initialized" }));
2011
+ return;
2012
+ }
2013
+ // Auto-expire stale shares opportunistically on each list call.
2014
+ const ttlHours = parseInt(process.env.SINAIN_SHARE_TTL_HOURS || "24");
2015
+ if (ttlHours > 0) {
2016
+ deps.webDb.expireStaleShares(ttlHours * 60 * 60 * 1000);
2017
+ }
2018
+ const statusParams = url.searchParams.getAll("status").filter(Boolean);
2019
+ const limit = Math.min(parseInt(url.searchParams.get("limit") || "200"), 500);
2020
+ const includeArchived = url.searchParams.get("include_archived") === "1";
2021
+ const shares = deps.webDb.listSharedDocs({
2022
+ statuses: statusParams.length > 0 ? statusParams as any : undefined,
2023
+ limit,
2024
+ includeArchived,
2025
+ });
2026
+ const activeCount = deps.webDb.countActiveShares();
2027
+ res.end(JSON.stringify({ ok: true, shares, active_count: activeCount }));
2028
+ return;
2029
+ }
2030
+
2031
+ if (req.method === "PATCH" && url.pathname.startsWith("/knowledge/shares/")) {
2032
+ if (!deps.webDb) {
2033
+ res.writeHead(503);
2034
+ res.end(JSON.stringify({ ok: false, error: "web.db not initialized" }));
2035
+ return;
2036
+ }
2037
+ const token = decodeURIComponent(url.pathname.slice("/knowledge/shares/".length));
2038
+ if (!token) {
2039
+ res.writeHead(400);
2040
+ res.end(JSON.stringify({ ok: false, error: "share_token required" }));
2041
+ return;
2042
+ }
2043
+ let body: any;
2044
+ try { body = JSON.parse(await readBody(req, 4096)); } catch {
2045
+ res.writeHead(400);
2046
+ res.end(JSON.stringify({ ok: false, error: "invalid JSON" }));
2047
+ return;
2048
+ }
2049
+ const status = body.status;
2050
+ const valid = ["waiting","connecting","delivered","disconnected","revoked","expired"];
2051
+ if (!status || !valid.includes(status)) {
2052
+ res.writeHead(400);
2053
+ res.end(JSON.stringify({ ok: false, error: `status must be one of ${valid.join("|")}` }));
2054
+ return;
2055
+ }
2056
+ const ok = deps.webDb.updateSharedDocStatus(token, status, {
2057
+ delivered_at: typeof body.delivered_at === "number" ? body.delivered_at : undefined,
2058
+ revoked_at: typeof body.revoked_at === "number" ? body.revoked_at : undefined,
2059
+ recipient_hint: typeof body.recipient_hint === "string" ? body.recipient_hint.slice(0, 200) : undefined,
2060
+ });
2061
+ if (!ok) {
2062
+ res.writeHead(404);
2063
+ res.end(JSON.stringify({ ok: false, error: "share not found" }));
2064
+ return;
2065
+ }
2066
+ res.end(JSON.stringify({ ok: true, share: deps.webDb.getSharedDoc(token) }));
2067
+ return;
2068
+ }
2069
+
2070
+ if (req.method === "DELETE" && url.pathname.startsWith("/knowledge/shares/")) {
2071
+ if (!deps.webDb) {
2072
+ res.writeHead(503);
2073
+ res.end(JSON.stringify({ ok: false, error: "web.db not initialized" }));
2074
+ return;
2075
+ }
2076
+ const token = decodeURIComponent(url.pathname.slice("/knowledge/shares/".length));
2077
+ const removed = deps.webDb.deleteSharedDoc(token);
2078
+ res.end(JSON.stringify({ ok: true, removed }));
2079
+ return;
2080
+ }
2081
+
1495
2082
  // Legacy fact-browser kept for fallback / quick raw access.
1496
2083
  if (req.method === "GET" && url.pathname === "/knowledge/ui-legacy") {
1497
2084
  res.setHeader("Content-Type", "text/html");
@@ -1503,7 +2090,7 @@ export function createAppServer(deps: ServerDeps) {
1503
2090
  // bookmarks, retraction, concept transfer.
1504
2091
  if (req.method === "GET" && url.pathname === "/knowledge/ui") {
1505
2092
  res.setHeader("Content-Type", "text/html");
1506
- res.end(KNOWLEDGE_UI_V2_HTML);
2093
+ res.end(renderKnowledgeUiV2());
1507
2094
  return;
1508
2095
  }
1509
2096
 
@@ -1511,7 +2098,7 @@ export function createAppServer(deps: ServerDeps) {
1511
2098
  // we just serve the same HTML; client-side router parses location.pathname.
1512
2099
  if (req.method === "GET" && url.pathname.startsWith("/knowledge/ui/")) {
1513
2100
  res.setHeader("Content-Type", "text/html");
1514
- res.end(KNOWLEDGE_UI_V2_HTML);
2101
+ res.end(renderKnowledgeUiV2());
1515
2102
  return;
1516
2103
  }
1517
2104
 
@@ -7,7 +7,7 @@
7
7
  * that should not be visible to the curator/distiller.
8
8
  */
9
9
 
10
- export const SCHEMA_VERSION = 1;
10
+ export const SCHEMA_VERSION = 2;
11
11
 
12
12
  export const SCHEMA_SQL = `
13
13
  -- Schema version tracking (for future migrations)
@@ -97,4 +97,26 @@ CREATE TABLE IF NOT EXISTS search_log (
97
97
  result_count INTEGER
98
98
  );
99
99
  CREATE INDEX IF NOT EXISTS idx_search_log_ts ON search_log(ts DESC);
100
+
101
+ -- Cross-machine concept share tracking (URL-fragment + WebRTC peer modes).
102
+ -- Persistent across SPA refresh — ShareManager reads this on load and
103
+ -- re-binds peer connections using the stored share_token as the peerjs ID.
104
+ CREATE TABLE IF NOT EXISTS shared_docs (
105
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
106
+ share_token TEXT NOT NULL UNIQUE,
107
+ entity_id TEXT NOT NULL,
108
+ mode TEXT NOT NULL CHECK (mode IN ('fragment','peer')),
109
+ status TEXT NOT NULL CHECK (status IN ('waiting','connecting','delivered','disconnected','revoked','expired')),
110
+ bundle_size INTEGER,
111
+ url TEXT NOT NULL,
112
+ created_at INTEGER NOT NULL,
113
+ delivered_at INTEGER,
114
+ revoked_at INTEGER,
115
+ recipient_hint TEXT,
116
+ notes TEXT
117
+ );
118
+ CREATE INDEX IF NOT EXISTS idx_shared_docs_status
119
+ ON shared_docs(status, created_at DESC);
120
+ CREATE INDEX IF NOT EXISTS idx_shared_docs_entity
121
+ ON shared_docs(entity_id);
100
122
  `;
@@ -58,6 +58,26 @@ export interface ConceptImportRow {
58
58
  notes: string | null;
59
59
  }
60
60
 
61
+ export type SharedDocMode = "fragment" | "peer";
62
+ export type SharedDocStatus =
63
+ | "waiting" | "connecting" | "delivered"
64
+ | "disconnected" | "revoked" | "expired";
65
+
66
+ export interface SharedDocRow {
67
+ id?: number;
68
+ share_token: string;
69
+ entity_id: string;
70
+ mode: SharedDocMode;
71
+ status: SharedDocStatus;
72
+ bundle_size: number | null;
73
+ url: string;
74
+ created_at: number;
75
+ delivered_at: number | null;
76
+ revoked_at: number | null;
77
+ recipient_hint: string | null;
78
+ notes: string | null;
79
+ }
80
+
61
81
  const PAGE_CACHE_LRU_CAP = 500;
62
82
 
63
83
  export class WebDb {
@@ -276,4 +296,111 @@ export class WebDb {
276
296
  )
277
297
  .run(Date.now(), query, resolved_to, result_count);
278
298
  }
299
+
300
+ // ── Shared docs ─────────────────────────────────────────
301
+ // Cross-machine sharing: persistent records of share links the user
302
+ // produced. ShareManager (browser) reads these on SPA load to resume
303
+ // peer connections.
304
+
305
+ createSharedDoc(row: Omit<SharedDocRow, "id" | "created_at"> & { created_at?: number }): SharedDocRow {
306
+ const created_at = row.created_at ?? Date.now();
307
+ const r = this.db
308
+ .prepare(
309
+ `INSERT INTO shared_docs
310
+ (share_token, entity_id, mode, status, bundle_size, url,
311
+ created_at, delivered_at, revoked_at, recipient_hint, notes)
312
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
313
+ )
314
+ .run(
315
+ row.share_token,
316
+ row.entity_id,
317
+ row.mode,
318
+ row.status,
319
+ row.bundle_size,
320
+ row.url,
321
+ created_at,
322
+ row.delivered_at,
323
+ row.revoked_at,
324
+ row.recipient_hint,
325
+ row.notes,
326
+ );
327
+ return this.db
328
+ .prepare("SELECT * FROM shared_docs WHERE id = ?")
329
+ .get(Number(r.lastInsertRowid)) as SharedDocRow;
330
+ }
331
+
332
+ /** List shares; default returns all non-revoked + non-expired, recent first. */
333
+ listSharedDocs(opts?: { statuses?: SharedDocStatus[]; limit?: number; includeArchived?: boolean }): SharedDocRow[] {
334
+ const limit = opts?.limit ?? 200;
335
+ const statuses = opts?.statuses;
336
+ if (statuses && statuses.length > 0) {
337
+ const placeholders = statuses.map(() => "?").join(",");
338
+ return this.db
339
+ .prepare(
340
+ `SELECT * FROM shared_docs WHERE status IN (${placeholders})
341
+ ORDER BY created_at DESC LIMIT ?`,
342
+ )
343
+ .all(...statuses, limit) as SharedDocRow[];
344
+ }
345
+ if (opts?.includeArchived) {
346
+ return this.db
347
+ .prepare("SELECT * FROM shared_docs ORDER BY created_at DESC LIMIT ?")
348
+ .all(limit) as SharedDocRow[];
349
+ }
350
+ return this.db
351
+ .prepare(
352
+ `SELECT * FROM shared_docs WHERE status NOT IN ('revoked','expired')
353
+ ORDER BY created_at DESC LIMIT ?`,
354
+ )
355
+ .all(limit) as SharedDocRow[];
356
+ }
357
+
358
+ getSharedDoc(share_token: string): SharedDocRow | null {
359
+ const row = this.db
360
+ .prepare("SELECT * FROM shared_docs WHERE share_token = ?")
361
+ .get(share_token) as SharedDocRow | undefined;
362
+ return row ?? null;
363
+ }
364
+
365
+ updateSharedDocStatus(share_token: string, status: SharedDocStatus,
366
+ extra?: { delivered_at?: number; revoked_at?: number; recipient_hint?: string }): boolean {
367
+ // Compose dynamic SET clause based on which extras are present.
368
+ const sets: string[] = ["status = ?"];
369
+ const params: any[] = [status];
370
+ if (extra?.delivered_at != null) { sets.push("delivered_at = ?"); params.push(extra.delivered_at); }
371
+ if (extra?.revoked_at != null) { sets.push("revoked_at = ?"); params.push(extra.revoked_at); }
372
+ if (extra?.recipient_hint != null) { sets.push("recipient_hint = ?"); params.push(extra.recipient_hint); }
373
+ params.push(share_token);
374
+ const r = this.db
375
+ .prepare(`UPDATE shared_docs SET ${sets.join(", ")} WHERE share_token = ?`)
376
+ .run(...params);
377
+ return r.changes > 0;
378
+ }
379
+
380
+ deleteSharedDoc(share_token: string): boolean {
381
+ const r = this.db.prepare("DELETE FROM shared_docs WHERE share_token = ?").run(share_token);
382
+ return r.changes > 0;
383
+ }
384
+
385
+ /** Auto-expire stale shares: waiting/disconnected older than ttl_ms. */
386
+ expireStaleShares(ttl_ms: number): number {
387
+ const cutoff = Date.now() - ttl_ms;
388
+ const r = this.db
389
+ .prepare(
390
+ `UPDATE shared_docs SET status = 'expired'
391
+ WHERE status IN ('waiting','disconnected','connecting') AND created_at < ?`,
392
+ )
393
+ .run(cutoff);
394
+ return r.changes;
395
+ }
396
+
397
+ countActiveShares(): number {
398
+ const row = this.db
399
+ .prepare(
400
+ `SELECT COUNT(*) AS n FROM shared_docs
401
+ WHERE status IN ('waiting','connecting','disconnected')`,
402
+ )
403
+ .get() as { n: number };
404
+ return row.n;
405
+ }
279
406
  }
@@ -76,7 +76,7 @@ def query_facts_by_entities(
76
76
  attrs = store.entity(fid)
77
77
  if not attrs:
78
78
  continue
79
- fact = {"entityId": fid}
79
+ fact = {"entity_id": fid}
80
80
  for attr_name, values in attrs.items():
81
81
  if attr_name == "tag":
82
82
  continue # Don't include tags in output (noise)
@@ -117,7 +117,7 @@ def query_top_facts(db_path: str, limit: int = 30) -> list[dict]:
117
117
  attrs = store.entity(fid)
118
118
  if not attrs:
119
119
  continue
120
- fact = {"entityId": fid}
120
+ fact = {"entity_id": fid}
121
121
  for attr_name, values in attrs.items():
122
122
  fact[attr_name] = values[0] if len(values) == 1 else values
123
123
  facts.append(fact)
@@ -425,12 +425,14 @@ def query_facts_hybrid(
425
425
  pass
426
426
 
427
427
  # Graph boost: facts linked to mentioned entities via backrefs get priority
428
+ # +0.05 is significant vs RRF scores of ~0.015-0.033 — ensures entity-linked facts
429
+ # rank above FTS noise in large graphs (100K+ triples)
428
430
  if graph_fact_ids or community_fact_ids:
429
431
  for eid in rrf_scores:
430
432
  if eid in graph_fact_ids:
431
- rrf_scores[eid] += 0.02 # direct graph-linked facts
433
+ rrf_scores[eid] += 0.05 # direct graph-linked facts
432
434
  elif eid in community_fact_ids:
433
- rrf_scores[eid] += 0.01 # community-expanded facts (half weight)
435
+ rrf_scores[eid] += 0.025 # community-expanded facts (half weight)
434
436
 
435
437
  # Apply confidence decay as secondary signal (fresh facts rank above stale ones)
436
438
  from triplestore import decayed_confidence
@@ -418,16 +418,33 @@ def _extract_entity_from_fact(fact_text: str, known_entities: list) -> str:
418
418
 
419
419
 
420
420
  def _facts_to_graph_ops(digest: dict) -> list[dict]:
421
- """Convert distiller facts/entities/decisions directly to graph ops.
421
+ """Convert ALL distiller output + raw feed items to graph ops.
422
422
 
423
- DETERMINISTIC — no LLM needed. The distiller already extracted structured
424
- facts with entity names. This function mechanically converts them to
425
- assert operations for the triplestore.
423
+ DETERMINISTIC — no LLM needed. Stores distilled knowledge (facts,
424
+ decisions, patterns, preferences, summary) AND verbatim raw captures
425
+ (audio quotes, agent analysis) so the triplestore is the single
426
+ source of truth for session recall.
426
427
  """
427
428
  ops = []
428
429
  known_entities = digest.get("entities", [])
430
+ raw_items = digest.pop("_rawItems", None) or []
429
431
 
430
- # Each fact becomes an assert op
432
+ # Session anchor from whatHappened
433
+ session_ts = digest.get("ts", "")[:16] # "2026-05-07T10:08"
434
+ session_eid = f"session:{session_ts}" if session_ts else None
435
+ if session_eid and digest.get("whatHappened"):
436
+ ops.append({
437
+ "op": "assert",
438
+ "entity": session_ts,
439
+ "attribute": "value",
440
+ "value": digest["whatHappened"],
441
+ "confidence": 0.9,
442
+ "domain": "session",
443
+ "kind": "distilled",
444
+ "session_ref": session_eid,
445
+ })
446
+
447
+ # Facts (distilled)
431
448
  for fact_text in digest.get("facts", []):
432
449
  if not fact_text or len(fact_text) < 5:
433
450
  continue
@@ -435,13 +452,14 @@ def _facts_to_graph_ops(digest: dict) -> list[dict]:
435
452
  ops.append({
436
453
  "op": "assert",
437
454
  "entity": entity,
438
- "attribute": "fact",
455
+ "attribute": "value",
439
456
  "value": fact_text,
440
457
  "confidence": 0.9,
441
- "domain": "",
458
+ "kind": "distilled",
459
+ "session_ref": session_eid,
442
460
  })
443
461
 
444
- # Each decision becomes an assert with lower confidence (time-bound)
462
+ # Decisions (distilled, lower confidence time-bound)
445
463
  for decision_text in digest.get("decisions", []):
446
464
  if not decision_text or len(decision_text) < 5:
447
465
  continue
@@ -449,10 +467,63 @@ def _facts_to_graph_ops(digest: dict) -> list[dict]:
449
467
  ops.append({
450
468
  "op": "assert",
451
469
  "entity": entity,
452
- "attribute": "decision",
470
+ "attribute": "value",
453
471
  "value": decision_text,
454
472
  "confidence": 0.7,
455
- "domain": "",
473
+ "kind": "distilled",
474
+ "session_ref": session_eid,
475
+ })
476
+
477
+ # Patterns + Preferences (distilled)
478
+ for text in digest.get("patterns", []) + digest.get("preferences", []):
479
+ if not text or not isinstance(text, str) or len(text) < 5:
480
+ continue
481
+ entity = _extract_entity_from_fact(text, known_entities)
482
+ ops.append({
483
+ "op": "assert",
484
+ "entity": entity,
485
+ "attribute": "value",
486
+ "value": text,
487
+ "confidence": 0.7,
488
+ "kind": "distilled",
489
+ "session_ref": session_eid,
490
+ })
491
+
492
+ # Verbatim audio quotes (top 20 by length, > 30 chars)
493
+ audio = [i for i in raw_items
494
+ if i.get("source") == "audio" and len(i.get("text", "")) > 30]
495
+ for item in sorted(audio, key=lambda x: -len(x.get("text", "")))[:20]:
496
+ text = re.sub(r"^\[.*?\]\s*", "", item["text"]) # strip emoji prefixes
497
+ if len(text) < 20:
498
+ continue
499
+ entity = _extract_entity_from_fact(text, known_entities)
500
+ ops.append({
501
+ "op": "assert",
502
+ "entity": entity,
503
+ "attribute": "value",
504
+ "value": text,
505
+ "confidence": 0.95,
506
+ "kind": "verbatim",
507
+ "session_ref": session_eid,
508
+ })
509
+
510
+ # Agent analysis responses (last 10, > 50 chars — verbatim)
511
+ agents = [i for i in raw_items
512
+ if i.get("source") in ("agent", "openclaw")
513
+ and len(i.get("text", "")) > 50]
514
+ for item in agents[-10:]:
515
+ text = re.sub(r"^\[.*?\]\s*", "", item["text"]) # strip emoji prefixes
516
+ if len(text) < 30:
517
+ continue
518
+ entity = _extract_entity_from_fact(text, known_entities)
519
+ ops.append({
520
+ "op": "assert",
521
+ "entity": entity,
522
+ "attribute": "value",
523
+ "value": text,
524
+ "confidence": 0.8,
525
+ "kind": "verbatim",
526
+ "session_ref": session_eid,
456
527
  })
457
528
 
458
529
  return ops
@@ -506,6 +577,12 @@ def _execute_graph_ops(db_path: str, ops: list[dict], digest_ts: str, digest_ent
506
577
  store.assert_triple(tx, entity_id, "reinforce_count", "1")
507
578
  if domain:
508
579
  store.assert_triple(tx, entity_id, "domain", domain)
580
+ kind = op_data.get("kind", "distilled")
581
+ store.assert_triple(tx, entity_id, "kind", kind)
582
+ # Link to session anchor via ref edge
583
+ session_ref = op_data.get("session_ref")
584
+ if session_ref:
585
+ store.assert_triple(tx, entity_id, "session", session_ref, value_type="ref")
509
586
  # Auto-tag for keyword-based discovery
510
587
  for tag in _extract_tags(value):
511
588
  store.assert_triple(tx, entity_id, "tag", tag)