@geravant/sinain 1.20.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geravant/sinain",
3
- "version": "1.20.0",
3
+ "version": "1.22.0",
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,11 @@ 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
+
384
416
  // ── Router ────────────────────────────────────────────────────────────────
385
417
  function navigate(path) {
386
418
  history.pushState({}, "", path);
@@ -390,6 +422,8 @@ window.addEventListener("popstate", render);
390
422
  window.addEventListener("DOMContentLoaded", () => {
391
423
  setupSearch();
392
424
  setupGlobalDrop();
425
+ ShareManager.resumePeerShares().catch(e => console.warn("share resume failed", e));
426
+ refreshShareBadge();
393
427
  render();
394
428
  });
395
429
 
@@ -397,6 +431,8 @@ function render() {
397
431
  const path = location.pathname;
398
432
  if (path === "/knowledge/ui" || path === "/knowledge/ui/") {
399
433
  renderHome();
434
+ } else if (path === "/knowledge/ui/shares" || path === "/knowledge/ui/shares/") {
435
+ renderSharesView();
400
436
  } else if (path.startsWith("/knowledge/ui/entity/")) {
401
437
  const entity = decodeURIComponent(path.slice("/knowledge/ui/entity/".length));
402
438
  renderEntityPage(entity);
@@ -408,6 +444,258 @@ function render() {
408
444
  }
409
445
  }
410
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
+
411
699
  // ── Home view ─────────────────────────────────────────────────────────────
412
700
  async function renderHome() {
413
701
  document.title = "Sinain Knowledge";
@@ -454,6 +742,111 @@ function timeAgo(ts) {
454
742
  return Math.round(diff / 86_400_000) + "d ago";
455
743
  }
456
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
+
457
850
  // ── Search ────────────────────────────────────────────────────────────────
458
851
  function setupSearch() {
459
852
  const input = $("#search");
@@ -491,6 +884,30 @@ async function renderEntityPage(entity) {
491
884
  const root = $("#root");
492
885
  root.innerHTML = \`<div class="loading-block"><span class="spinner"></span> Loading \${esc(entity)}…</div>\`;
493
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
+
494
911
  const page = await api("/knowledge/page?entity=" + encodeURIComponent(entity));
495
912
  if (!page.ok || page.fact_count === 0) {
496
913
  if (page.fact_count === 0) {
@@ -515,8 +932,9 @@ async function renderEntityPage(entity) {
515
932
  <button id="bmFavorite" class="icon" title="Favorite">★</button>
516
933
  <button id="bmArchive" class="icon" title="Archive">🗄</button>
517
934
  <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>
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>
520
938
  </div>
521
939
  </div>
522
940
  <div class="layout-3col">
@@ -549,6 +967,7 @@ async function renderEntityPage(entity) {
549
967
  $("#bmArchive").onclick = () => bookmarkAction(entity, "archive");
550
968
  $("#actRefresh").onclick = () => refreshPage(entity);
551
969
  $("#actCopyLink").onclick = () => copyLink(entity);
970
+ $("#actShare").onclick = () => shareEntity(entity);
552
971
  $("#actExport").onclick = () => exportConcept(entity);
553
972
 
554
973
  // Wire bullet retraction (event delegation)
@@ -673,6 +1092,17 @@ async function exportConcept(entity) {
673
1092
  showToast("✓ Exporting concept bundle…");
674
1093
  }
675
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
+
676
1106
  // ── Retraction modal + undo toast ─────────────────────────────────────────
677
1107
  function openRetractModal(factId, bulletEl, sourceEntity) {
678
1108
  const text = bulletEl.querySelector(".text").textContent;
@@ -836,6 +1266,22 @@ async function importFiles(files, redirectAfter) {
836
1266
  </script>
837
1267
  </body></html>`;
838
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
+
839
1285
  /** Server epoch — lets clients detect restarts. */
840
1286
  const serverEpoch = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
841
1287
 
@@ -1492,6 +1938,131 @@ export function createAppServer(deps: ServerDeps) {
1492
1938
  return;
1493
1939
  }
1494
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
+
1495
2066
  // Legacy fact-browser kept for fallback / quick raw access.
1496
2067
  if (req.method === "GET" && url.pathname === "/knowledge/ui-legacy") {
1497
2068
  res.setHeader("Content-Type", "text/html");
@@ -1503,7 +2074,7 @@ export function createAppServer(deps: ServerDeps) {
1503
2074
  // bookmarks, retraction, concept transfer.
1504
2075
  if (req.method === "GET" && url.pathname === "/knowledge/ui") {
1505
2076
  res.setHeader("Content-Type", "text/html");
1506
- res.end(KNOWLEDGE_UI_V2_HTML);
2077
+ res.end(renderKnowledgeUiV2());
1507
2078
  return;
1508
2079
  }
1509
2080
 
@@ -1511,7 +2082,7 @@ export function createAppServer(deps: ServerDeps) {
1511
2082
  // we just serve the same HTML; client-side router parses location.pathname.
1512
2083
  if (req.method === "GET" && url.pathname.startsWith("/knowledge/ui/")) {
1513
2084
  res.setHeader("Content-Type", "text/html");
1514
- res.end(KNOWLEDGE_UI_V2_HTML);
2085
+ res.end(renderKnowledgeUiV2());
1515
2086
  return;
1516
2087
  }
1517
2088
 
@@ -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)