@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 +1 -1
- package/sinain-core/src/learning/local-curation.ts +3 -0
- package/sinain-core/src/server.ts +591 -4
- package/sinain-core/src/web-db/schema.ts +23 -1
- package/sinain-core/src/web-db/store.ts +127 -0
- package/sinain-memory/graph_query.py +6 -4
- package/sinain-memory/knowledge_integrator.py +87 -10
package/package.json
CHANGED
|
@@ -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
|
|
519
|
-
<button id="
|
|
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(
|
|
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(
|
|
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 =
|
|
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 = {"
|
|
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 = {"
|
|
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.
|
|
433
|
+
rrf_scores[eid] += 0.05 # direct graph-linked facts
|
|
432
434
|
elif eid in community_fact_ids:
|
|
433
|
-
rrf_scores[eid] += 0.
|
|
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
|
|
421
|
+
"""Convert ALL distiller output + raw feed items to graph ops.
|
|
422
422
|
|
|
423
|
-
DETERMINISTIC — no LLM needed.
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
#
|
|
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": "
|
|
455
|
+
"attribute": "value",
|
|
439
456
|
"value": fact_text,
|
|
440
457
|
"confidence": 0.9,
|
|
441
|
-
"
|
|
458
|
+
"kind": "distilled",
|
|
459
|
+
"session_ref": session_eid,
|
|
442
460
|
})
|
|
443
461
|
|
|
444
|
-
#
|
|
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": "
|
|
470
|
+
"attribute": "value",
|
|
453
471
|
"value": decision_text,
|
|
454
472
|
"confidence": 0.7,
|
|
455
|
-
"
|
|
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)
|