dsh-trivium 0.4.21 → 0.5.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/README.md +49 -6
- package/README.zh-CN.md +49 -6
- package/lib/client.js +948 -9
- package/lib/context-budget.js +348 -0
- package/lib/extract.js +25 -11
- package/lib/index.js +236 -63
- package/lib/markdown.js +0 -2
- package/lib/pins.js +66 -21
- package/lib/provenance.js +147 -0
- package/lib/recall-preview.js +128 -0
- package/lib/recall-trace.js +285 -0
- package/lib/revision-graph.js +103 -0
- package/lib/revisions.js +451 -0
- package/lib/schema.js +13 -1
- package/lib/server.js +204 -4
- package/lib/sidecar.js +122 -15
- package/lib/store.js +480 -62
- package/lib/tool-defs.js +93 -0
- package/lib/tool-output.js +88 -0
- package/lib/tools.js +69 -43
- package/lib/turn-ledger.js +184 -0
- package/lib/until.js +1 -13
- package/package.json +10 -4
package/lib/store.js
CHANGED
|
@@ -10,9 +10,21 @@ import {
|
|
|
10
10
|
syncSidecarOnOpen,
|
|
11
11
|
writeSidecarNow,
|
|
12
12
|
} from "./sidecar.js";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
DIM,
|
|
15
|
+
EDGE_LABELS,
|
|
16
|
+
BUSINESS_EDGES,
|
|
17
|
+
NODE_TYPES,
|
|
18
|
+
REVISION_EDGES,
|
|
19
|
+
REVISION_WALK_LIMIT,
|
|
20
|
+
indexTextFor,
|
|
21
|
+
nowIso,
|
|
22
|
+
weightOf,
|
|
23
|
+
zeroVector,
|
|
24
|
+
} from "./schema.js";
|
|
25
|
+
import { validateRevisionStructure } from "./revision-graph.js";
|
|
14
26
|
import { clipToTokenBudget, estimateTokens } from "./tokens.js";
|
|
15
|
-
import { isStalePayload,
|
|
27
|
+
import { isStalePayload, queryMentionsUntil, resolveUntilAt } from "./until.js";
|
|
16
28
|
import { embedText, embeddingEnabled } from "./embed.js";
|
|
17
29
|
import { payloadLooksDirty, sanitizeForWrite } from "./hygiene.js";
|
|
18
30
|
|
|
@@ -65,6 +77,17 @@ function persist(db) {
|
|
|
65
77
|
if (cwd) scheduleSidecarWrite(cwd, db);
|
|
66
78
|
}
|
|
67
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Post-write hook for the revision path.
|
|
82
|
+
*
|
|
83
|
+
* The graph write is authoritative and already committed; this flushes it and
|
|
84
|
+
* lets the JSONL sidecar catch up on its own timer. Cross-file atomicity is not
|
|
85
|
+
* claimed, and a sidecar failure is reported through sidecarHealth().
|
|
86
|
+
*/
|
|
87
|
+
export function persistAfterRevision(db) {
|
|
88
|
+
persist(db);
|
|
89
|
+
}
|
|
90
|
+
|
|
68
91
|
export function dbPathFor(cwd) {
|
|
69
92
|
return join(resolve(cwd), ".dsh", "trivium.tdb");
|
|
70
93
|
}
|
|
@@ -270,13 +293,16 @@ function rebuildTextIndex(db) {
|
|
|
270
293
|
}
|
|
271
294
|
}
|
|
272
295
|
|
|
273
|
-
export function findMergeTarget(db, candidate) {
|
|
296
|
+
export function findMergeTarget(db, candidate, { allowRevisionLinked = false } = {}) {
|
|
274
297
|
const type = candidate?.type;
|
|
275
298
|
if (!type || type === "episode") return null;
|
|
276
299
|
const name = String(candidate.name || "").trim().toLowerCase();
|
|
277
300
|
const text = String(candidate.text || "").trim().toLowerCase();
|
|
278
301
|
for (const n of findByType(db, type)) {
|
|
279
302
|
const payload = n.payload || {};
|
|
303
|
+
// A node inside a revision chain is confirmed history: auto-extraction must
|
|
304
|
+
// create a new decision instead of quietly folding new text into it.
|
|
305
|
+
if (!allowRevisionLinked && isRevisionLinked(db, n.id)) continue;
|
|
280
306
|
const nName = String(payload.name || "").trim().toLowerCase();
|
|
281
307
|
const nText = String(payload.text || "").trim().toLowerCase();
|
|
282
308
|
const aliases = (payload.aliases || []).map((a) => String(a).trim().toLowerCase());
|
|
@@ -310,12 +336,28 @@ export function mergeInto(db, id, incoming) {
|
|
|
310
336
|
const node = db.get(Number(id));
|
|
311
337
|
if (!node) return insertNode(db, incoming);
|
|
312
338
|
const old = node.payload || {};
|
|
339
|
+
/*
|
|
340
|
+
* A node in a revision chain holds confirmed history: new text becomes a new
|
|
341
|
+
* decision that then supersedes it. Non-factual fields (aliases, deadlines,
|
|
342
|
+
* source notes) may still be filled in.
|
|
343
|
+
*/
|
|
344
|
+
if (isRevisionLinked(db, Number(id))) {
|
|
345
|
+
const nextBody = pickMergedText(old.text, incoming.text);
|
|
346
|
+
if (String(nextBody || "").trim() !== String(old.text || "").trim()) {
|
|
347
|
+
const err = new Error("REVISION_PROTECTED");
|
|
348
|
+
err.code = "REVISION_PROTECTED";
|
|
349
|
+
err.status = 409;
|
|
350
|
+
err.nodeId = Number(id);
|
|
351
|
+
throw err;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
313
354
|
const aliases = uniqueStrings([
|
|
314
355
|
...(old.aliases || []),
|
|
315
356
|
...(incoming.aliases || []),
|
|
316
357
|
incoming.name,
|
|
317
358
|
old.name,
|
|
318
359
|
]).filter((a) => a !== (incoming.name || old.name));
|
|
360
|
+
const deadlineChanged = incoming.until !== undefined && String(incoming.until || "").trim() !== String(old.until || "").trim();
|
|
319
361
|
const next = decorateUntil({
|
|
320
362
|
...old,
|
|
321
363
|
...incoming,
|
|
@@ -326,7 +368,11 @@ export function mergeInto(db, id, incoming) {
|
|
|
326
368
|
updatedAt: nowIso(),
|
|
327
369
|
status: old.status || "active",
|
|
328
370
|
source: incoming.source || old.source,
|
|
329
|
-
|
|
371
|
+
// An omitted expiry must not erase an existing one or leave an orphaned
|
|
372
|
+
// untilAt. A changed label must not inherit the previous absolute instant.
|
|
373
|
+
until: incoming.until === undefined ? old.until : incoming.until,
|
|
374
|
+
untilAt: incoming.untilAt || (deadlineChanged ? undefined : old.untilAt),
|
|
375
|
+
}, deadlineChanged ? nowIso() : old.createdAt);
|
|
330
376
|
db.updatePayload(Number(id), next);
|
|
331
377
|
indexNode(db, Number(id), next);
|
|
332
378
|
persist(db);
|
|
@@ -336,7 +382,17 @@ export function mergeInto(db, id, incoming) {
|
|
|
336
382
|
|
|
337
383
|
export function listNodes(
|
|
338
384
|
db,
|
|
339
|
-
{
|
|
385
|
+
{
|
|
386
|
+
type,
|
|
387
|
+
q,
|
|
388
|
+
includeArchived = false,
|
|
389
|
+
includeStale = false,
|
|
390
|
+
// Superseded versions are hidden by default and revealed by their own
|
|
391
|
+
// switch: "show expired" and "show replaced" are different questions.
|
|
392
|
+
includeSuperseded = false,
|
|
393
|
+
aboutId,
|
|
394
|
+
includeEpisodes = false,
|
|
395
|
+
} = {},
|
|
340
396
|
) {
|
|
341
397
|
const rows = [];
|
|
342
398
|
const needle = String(q || "").trim().toLowerCase();
|
|
@@ -353,6 +409,8 @@ export function listNodes(
|
|
|
353
409
|
if (payload.type === "episode" && !includeEpisodes && type !== "episode") continue;
|
|
354
410
|
if (type && payload.type !== type) continue;
|
|
355
411
|
if (around && !around.has(Number(id))) continue;
|
|
412
|
+
const successor = successorOf(db, id);
|
|
413
|
+
if (!includeSuperseded && successor != null) continue;
|
|
356
414
|
const stale = isStalePayload(payload);
|
|
357
415
|
if (
|
|
358
416
|
!includeStale &&
|
|
@@ -381,6 +439,13 @@ export function listNodes(
|
|
|
381
439
|
aliases: payload.aliases || [],
|
|
382
440
|
until: payload.until || "",
|
|
383
441
|
stale,
|
|
442
|
+
// Independent axes, reported independently.
|
|
443
|
+
revision: successor != null ? "superseded" : "current",
|
|
444
|
+
currentId: successor != null ? currentHeadOf(db, id).id ?? null : Number(id),
|
|
445
|
+
// The two relation endpoints, so the panel can offer "view current" and
|
|
446
|
+
// "undo the replacement" without a second request.
|
|
447
|
+
supersededBy: successor != null ? Number(successor) : null,
|
|
448
|
+
replacesId: predecessorOf(db, id),
|
|
384
449
|
dirty: payloadLooksDirty(payload),
|
|
385
450
|
status: payload.status || "active",
|
|
386
451
|
edges: (node.edges || []).length,
|
|
@@ -409,9 +474,90 @@ export function archiveNode(db, id) {
|
|
|
409
474
|
return true;
|
|
410
475
|
}
|
|
411
476
|
|
|
477
|
+
/** Expiry is one axis; revision history is another. Kept apart on purpose. */
|
|
478
|
+
const SUPERSEDES = EDGE_LABELS.supersedes;
|
|
479
|
+
|
|
480
|
+
export { REVISION_WALK_LIMIT };
|
|
481
|
+
|
|
482
|
+
/** Outgoing supersedes edges with their metadata. */
|
|
483
|
+
export function supersedesOut(db, id) {
|
|
484
|
+
const node = db.get(Number(id));
|
|
485
|
+
return (node?.edges || [])
|
|
486
|
+
.filter((e) => String(e.label) === SUPERSEDES)
|
|
487
|
+
.map((e) => ({ to: edgeTarget(e), metadata: e.metadata ?? null }));
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Incoming supersedes edges: who claims to replace this node. */
|
|
491
|
+
export function supersedesIn(db, id) {
|
|
492
|
+
const node = db.get(Number(id));
|
|
493
|
+
const rows = incomingLabeled(db, id, [SUPERSEDES]);
|
|
494
|
+
return rows.map((row) => {
|
|
495
|
+
const from = db.get(Number(row.from));
|
|
496
|
+
const edge = (from?.edges || []).find(
|
|
497
|
+
(e) => String(e.label) === SUPERSEDES && edgeTarget(e) === Number(id),
|
|
498
|
+
);
|
|
499
|
+
return { from: Number(row.from), metadata: edge?.metadata ?? null };
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** The node that replaces this one (newer), or null. */
|
|
504
|
+
export function successorOf(db, id) {
|
|
505
|
+
const rows = supersedesIn(db, id);
|
|
506
|
+
return rows.length ? Number(rows[0].from) : null;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** The node this one replaces (older), or null. */
|
|
510
|
+
export function predecessorOf(db, id) {
|
|
511
|
+
const rows = supersedesOut(db, id);
|
|
512
|
+
return rows.length ? Number(rows[0].to) : null;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** The supersedes edge this node is the target of, metadata included. */
|
|
516
|
+
export function revisionEdgeOf(db, id) {
|
|
517
|
+
const rows = supersedesIn(db, id);
|
|
518
|
+
return rows.length ? rows[0] : null;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Any relation at all, in either direction. */
|
|
522
|
+
export function isRevisionLinked(db, id) {
|
|
523
|
+
return successorOf(db, id) != null || predecessorOf(db, id) != null;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Walk forward to the head of the chain, or report why we cannot. */
|
|
527
|
+
export function currentHeadOf(db, id, limit = REVISION_WALK_LIMIT) {
|
|
528
|
+
let current = Number(id);
|
|
529
|
+
const seen = new Set();
|
|
530
|
+
for (let hops = 0; hops < limit; hops += 1) {
|
|
531
|
+
if (seen.has(current)) return { ok: false, reason: "cycle", id: null, hops };
|
|
532
|
+
seen.add(current);
|
|
533
|
+
const next = successorOf(db, current);
|
|
534
|
+
if (next == null) return { ok: true, id: current, hops };
|
|
535
|
+
current = next;
|
|
536
|
+
}
|
|
537
|
+
return { ok: false, reason: "too-long", id: null, hops: limit };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Error the HTTP layer turns into 409 REVISION_LINKED / REVISION_PROTECTED. */
|
|
541
|
+
function revisionGuardError(code, id, action) {
|
|
542
|
+
const err = new Error(`#${id} is part of a decision revision chain; ${action} would break the recorded history.`);
|
|
543
|
+
err.code = code;
|
|
544
|
+
err.status = 409;
|
|
545
|
+
err.nodeId = Number(id);
|
|
546
|
+
return err;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Chain bodies are confirmed history: a new fact is a new decision that then
|
|
551
|
+
* supersedes the old one, never an in-place rewrite.
|
|
552
|
+
*/
|
|
553
|
+
export function assertBodyEditable(db, id) {
|
|
554
|
+
if (isRevisionLinked(db, id)) throw revisionGuardError("REVISION_PROTECTED", id, "rewriting its body");
|
|
555
|
+
}
|
|
556
|
+
|
|
412
557
|
export function deleteNode(db, id) {
|
|
413
558
|
const n = Number(id);
|
|
414
559
|
if (!db.contains?.(n) && !db.get(n)) return false;
|
|
560
|
+
if (isRevisionLinked(db, n)) throw revisionGuardError("REVISION_LINKED", n, "deleting it");
|
|
415
561
|
db.delete(n);
|
|
416
562
|
rebuildTextIndex(db);
|
|
417
563
|
persist(db);
|
|
@@ -426,18 +572,22 @@ export function updateNode(db, id, patch = {}) {
|
|
|
426
572
|
if (old.type === "workspace") return null;
|
|
427
573
|
const next = { ...old };
|
|
428
574
|
if (patch.name !== undefined) next.name = String(patch.name || "").trim();
|
|
429
|
-
if (patch.text !== undefined)
|
|
575
|
+
if (patch.text !== undefined) {
|
|
576
|
+
const body = String(patch.text || "").trim();
|
|
577
|
+
// Only the body is protected: a renamed alias, a corrected deadline or an
|
|
578
|
+
// extra source note does not change the fact being remembered.
|
|
579
|
+
if (body !== String(old.text || "").trim()) assertBodyEditable(db, nid);
|
|
580
|
+
next.text = body;
|
|
581
|
+
}
|
|
430
582
|
if (Array.isArray(patch.aliases)) {
|
|
431
583
|
next.aliases = uniqueStrings(patch.aliases).filter((a) => a !== next.name);
|
|
432
584
|
}
|
|
585
|
+
let deadlineChanged = false;
|
|
433
586
|
if (patch.until !== undefined) {
|
|
434
587
|
const until = String(patch.until || "").trim();
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
if (untilAt) next.untilAt = untilAt;
|
|
439
|
-
} else {
|
|
440
|
-
delete next.until;
|
|
588
|
+
deadlineChanged = until !== String(old.until || "").trim();
|
|
589
|
+
next.until = until;
|
|
590
|
+
if (deadlineChanged || !until) {
|
|
441
591
|
delete next.untilAt;
|
|
442
592
|
}
|
|
443
593
|
}
|
|
@@ -447,7 +597,7 @@ export function updateNode(db, id, patch = {}) {
|
|
|
447
597
|
createdAt: old.createdAt,
|
|
448
598
|
status: old.status || "active",
|
|
449
599
|
updatedAt: nowIso(),
|
|
450
|
-
});
|
|
600
|
+
}, deadlineChanged ? nowIso() : old.createdAt);
|
|
451
601
|
const gate = sanitizeForWrite([body.name, body.text, body.fail, body.fix].filter(Boolean).join("\n"));
|
|
452
602
|
if (!gate.ok && gate.reason !== "empty") {
|
|
453
603
|
const err = new Error(gate.reason);
|
|
@@ -480,6 +630,16 @@ export function mergeNodes(db, keepId, dropId) {
|
|
|
480
630
|
if (pa.status === "archived" || pb.status === "archived") {
|
|
481
631
|
return { ok: false, message: "archived" };
|
|
482
632
|
}
|
|
633
|
+
// Merging either end of a revision chain would leave a dangling history or a
|
|
634
|
+
// wrong "current version". The user has to undo the relation first.
|
|
635
|
+
if (isRevisionLinked(db, keep) || isRevisionLinked(db, drop)) {
|
|
636
|
+
return {
|
|
637
|
+
ok: false,
|
|
638
|
+
status: 409,
|
|
639
|
+
code: "REVISION_LINKED",
|
|
640
|
+
message: "节点在决策替代链上,请先处理替代关系再合并。",
|
|
641
|
+
};
|
|
642
|
+
}
|
|
483
643
|
const inbound = nativeIncoming(db, drop);
|
|
484
644
|
if (inbound) {
|
|
485
645
|
for (const e of inbound) {
|
|
@@ -506,7 +666,7 @@ export function mergeNodes(db, keepId, dropId) {
|
|
|
506
666
|
text: pa.text,
|
|
507
667
|
aliases: [...(pa.aliases || []), ...(pb.aliases || []), pb.name],
|
|
508
668
|
until: pa.until || pb.until,
|
|
509
|
-
untilAt: pa.untilAt
|
|
669
|
+
untilAt: pa.until ? pa.untilAt : pb.untilAt,
|
|
510
670
|
source: pa.source || pb.source,
|
|
511
671
|
});
|
|
512
672
|
ensureLink(db, drop, keep, EDGE_LABELS.sameAs);
|
|
@@ -514,6 +674,15 @@ export function mergeNodes(db, keepId, dropId) {
|
|
|
514
674
|
return { ok: true, keep, drop, type: pa.type };
|
|
515
675
|
}
|
|
516
676
|
|
|
677
|
+
export const GRAPH_FORMAT = "dsh-trivium-graph";
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* v2 keeps the v1 node/edge shape and adds edge metadata, which is where a
|
|
681
|
+
* revision relation stores its reason and confirmation time. A v1 reader that
|
|
682
|
+
* ignores `metadata` still imports the graph; it just loses the reason text.
|
|
683
|
+
*/
|
|
684
|
+
export const GRAPH_VERSION = 2;
|
|
685
|
+
|
|
517
686
|
export function exportGraph(db, cwd = "") {
|
|
518
687
|
const nodes = [];
|
|
519
688
|
const edges = [];
|
|
@@ -522,17 +691,21 @@ export function exportGraph(db, cwd = "") {
|
|
|
522
691
|
if (!node) continue;
|
|
523
692
|
nodes.push({ id: Number(id), payload: node.payload || {} });
|
|
524
693
|
for (const e of node.edges || []) {
|
|
525
|
-
|
|
694
|
+
const row = {
|
|
526
695
|
from: Number(id),
|
|
527
696
|
to: edgeTarget(e),
|
|
528
697
|
label: e.label || "",
|
|
529
698
|
weight: e.weight ?? null,
|
|
530
|
-
}
|
|
699
|
+
};
|
|
700
|
+
// Edge metadata is only written when present, so a graph without revision
|
|
701
|
+
// relations stays byte-comparable with a v1 export.
|
|
702
|
+
if (e.metadata && typeof e.metadata === "object") row.metadata = e.metadata;
|
|
703
|
+
edges.push(row);
|
|
531
704
|
}
|
|
532
705
|
}
|
|
533
706
|
return {
|
|
534
|
-
format:
|
|
535
|
-
version:
|
|
707
|
+
format: GRAPH_FORMAT,
|
|
708
|
+
version: GRAPH_VERSION,
|
|
536
709
|
exportedAt: nowIso(),
|
|
537
710
|
cwd: cwd || "",
|
|
538
711
|
dim: DIM,
|
|
@@ -551,12 +724,91 @@ export function findByUri(db, uri) {
|
|
|
551
724
|
return null;
|
|
552
725
|
}
|
|
553
726
|
|
|
554
|
-
/**
|
|
727
|
+
/**
|
|
728
|
+
* Merge an exported graph into the open workspace. Remaps ids; skips extra
|
|
729
|
+
* workspace roots.
|
|
730
|
+
*
|
|
731
|
+
* Revision edges are validated as a whole before anything is written, and
|
|
732
|
+
* conflicts with the live chain are reported instead of applied: an import must
|
|
733
|
+
* never resurrect an old version or half-build a chain.
|
|
734
|
+
*/
|
|
555
735
|
export function importGraph(db, graph) {
|
|
556
736
|
const body = graph?.graph && Array.isArray(graph.graph.nodes) ? graph.graph : graph;
|
|
557
737
|
const nodes = Array.isArray(body?.nodes) ? body.nodes : null;
|
|
558
738
|
if (!nodes) return { ok: false, message: "不是本插件导出的 JSON" };
|
|
559
739
|
const edges = Array.isArray(body.edges) ? body.edges : [];
|
|
740
|
+
const version = Number(body.version) || 1;
|
|
741
|
+
|
|
742
|
+
const payloadByOldId = new Map();
|
|
743
|
+
for (const row of nodes) {
|
|
744
|
+
const oldId = Number(row?.id);
|
|
745
|
+
if (!Number.isFinite(oldId)) continue;
|
|
746
|
+
payloadByOldId.set(oldId, row?.payload && typeof row.payload === "object" ? row.payload : null);
|
|
747
|
+
}
|
|
748
|
+
const revisionEdges = edges
|
|
749
|
+
.filter((e) => REVISION_EDGES.includes(String(e.label || "")))
|
|
750
|
+
.map((e) => ({ from: Number(e.from), to: Number(e.to), metadata: e.metadata || null }));
|
|
751
|
+
|
|
752
|
+
if (revisionEdges.length) {
|
|
753
|
+
// Pure structural pass over the imported graph, before any write.
|
|
754
|
+
const structure = validateRevisionStructure({
|
|
755
|
+
edges: revisionEdges,
|
|
756
|
+
payloadOf: (id) => payloadByOldId.get(Number(id)) || null,
|
|
757
|
+
});
|
|
758
|
+
if (!structure.ok) {
|
|
759
|
+
return {
|
|
760
|
+
ok: false,
|
|
761
|
+
status: 409,
|
|
762
|
+
code: "REVISION_LINKED",
|
|
763
|
+
message: "导入的版本关系未通过校验,未写入任何内容。",
|
|
764
|
+
conflicts: structure.conflicts,
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
const conflicts = [];
|
|
768
|
+
/*
|
|
769
|
+
* Resolve exactly like the apply pass does: stable URI first, then the text
|
|
770
|
+
* heuristic for legacy nodes that never got a URI. Anything else would let
|
|
771
|
+
* the pre-check miss a conflict the apply pass would then write into.
|
|
772
|
+
*/
|
|
773
|
+
const resolveLocal = (payload) => {
|
|
774
|
+
const uri = String(payload?.uri || "").trim();
|
|
775
|
+
if (uri) {
|
|
776
|
+
const byUri = findByUri(db, uri);
|
|
777
|
+
if (byUri != null) return byUri;
|
|
778
|
+
}
|
|
779
|
+
return findMergeTarget(db, payload, { allowRevisionLinked: true });
|
|
780
|
+
};
|
|
781
|
+
const localOf = (oldId) => resolveLocal(payloadByOldId.get(Number(oldId)));
|
|
782
|
+
for (const edge of revisionEdges) {
|
|
783
|
+
const oldLocal = localOf(edge.to);
|
|
784
|
+
if (oldLocal == null) continue;
|
|
785
|
+
const existing = successorOf(db, oldLocal);
|
|
786
|
+
if (existing == null) continue;
|
|
787
|
+
const successorLocal = localOf(edge.from);
|
|
788
|
+
if (successorLocal == null || Number(existing) !== Number(successorLocal)) {
|
|
789
|
+
conflicts.push({ code: "already-superseded", id: oldLocal });
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
for (const row of nodes) {
|
|
793
|
+
const payload = row?.payload;
|
|
794
|
+
if (!payload) continue;
|
|
795
|
+
const dest = resolveLocal(payload);
|
|
796
|
+
if (dest == null) continue;
|
|
797
|
+
const live = db.get(dest)?.payload || {};
|
|
798
|
+
if (String(payload.text || "").trim() === String(live.text || "").trim()) continue;
|
|
799
|
+
if (isRevisionLinked(db, dest)) conflicts.push({ code: "chain-body", id: dest });
|
|
800
|
+
}
|
|
801
|
+
if (conflicts.length) {
|
|
802
|
+
return {
|
|
803
|
+
ok: false,
|
|
804
|
+
status: 409,
|
|
805
|
+
code: "REVISION_LINKED",
|
|
806
|
+
message: "导入内容与现有的决策替代链冲突,未写入。",
|
|
807
|
+
conflicts,
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
560
812
|
const idMap = new Map();
|
|
561
813
|
const root = workspaceId(db);
|
|
562
814
|
let created = 0;
|
|
@@ -572,9 +824,10 @@ export function importGraph(db, graph) {
|
|
|
572
824
|
}
|
|
573
825
|
if (!NODE_TYPES.includes(payload.type)) continue;
|
|
574
826
|
let dest = findByUri(db, payload.uri);
|
|
575
|
-
if (dest == null) dest = findMergeTarget(db, payload);
|
|
827
|
+
if (dest == null) dest = findMergeTarget(db, payload, { allowRevisionLinked: true });
|
|
576
828
|
if (dest != null) {
|
|
577
|
-
|
|
829
|
+
// An exported snapshot is authoritative, including a cleared deadline.
|
|
830
|
+
mergeInto(db, dest, { ...payload, until: payload.until ?? "" });
|
|
578
831
|
idMap.set(oldId, dest);
|
|
579
832
|
merged += 1;
|
|
580
833
|
} else {
|
|
@@ -584,13 +837,62 @@ export function importGraph(db, graph) {
|
|
|
584
837
|
}
|
|
585
838
|
}
|
|
586
839
|
for (const e of edges) {
|
|
840
|
+
const label = e.label || EDGE_LABELS.about;
|
|
841
|
+
if (REVISION_EDGES.includes(String(label))) continue;
|
|
587
842
|
const from = idMap.get(Number(e.from));
|
|
588
843
|
const to = idMap.get(Number(e.to));
|
|
589
|
-
const label = e.label || EDGE_LABELS.about;
|
|
590
844
|
if (from == null || to == null || from === to) continue;
|
|
591
845
|
if (ensureLink(db, from, to, label)) linked += 1;
|
|
592
846
|
}
|
|
593
|
-
|
|
847
|
+
|
|
848
|
+
// Revision edges go in one native transaction after the nodes exist: the
|
|
849
|
+
// chain is either complete or absent, never half built.
|
|
850
|
+
let revisionLinked = 0;
|
|
851
|
+
const revisionConflicts = [];
|
|
852
|
+
if (revisionEdges.length) {
|
|
853
|
+
const ops = [];
|
|
854
|
+
const seenPairs = new Set();
|
|
855
|
+
for (const edge of revisionEdges) {
|
|
856
|
+
const from = idMap.get(Number(edge.from));
|
|
857
|
+
const to = idMap.get(Number(edge.to));
|
|
858
|
+
if (from == null || to == null) {
|
|
859
|
+
revisionConflicts.push({ code: "unmapped", id: Number(edge.from) });
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
const pair = `${from}\u0000${to}`;
|
|
863
|
+
if (seenPairs.has(pair)) continue;
|
|
864
|
+
seenPairs.add(pair);
|
|
865
|
+
if (hasEdge(db, from, to, EDGE_LABELS.supersedes)) continue;
|
|
866
|
+
ops.push({
|
|
867
|
+
type: "upsertEdge",
|
|
868
|
+
src: from,
|
|
869
|
+
dst: to,
|
|
870
|
+
label: EDGE_LABELS.supersedes,
|
|
871
|
+
weight: weightOf(EDGE_LABELS.supersedes),
|
|
872
|
+
metadata: edge.metadata || { at: nowIso() },
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
if (ops.length) {
|
|
876
|
+
try {
|
|
877
|
+
db.commitTransaction(ops);
|
|
878
|
+
revisionLinked = ops.length;
|
|
879
|
+
persistAfterRevision(db);
|
|
880
|
+
} catch (err) {
|
|
881
|
+
revisionConflicts.push({ code: "transaction-failed", message: String(err.message || err) });
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
return {
|
|
887
|
+
ok: true,
|
|
888
|
+
version,
|
|
889
|
+
created,
|
|
890
|
+
merged,
|
|
891
|
+
linked,
|
|
892
|
+
revisionLinked,
|
|
893
|
+
revisionConflicts,
|
|
894
|
+
nodes: idMap.size,
|
|
895
|
+
};
|
|
594
896
|
}
|
|
595
897
|
|
|
596
898
|
export function hasEdge(db, from, to, label) {
|
|
@@ -637,14 +939,18 @@ function pickMergedText(oldText, incomingText) {
|
|
|
637
939
|
return b.length >= a.length ? b : a;
|
|
638
940
|
}
|
|
639
941
|
|
|
640
|
-
function decorateUntil(payload) {
|
|
942
|
+
function decorateUntil(payload, from = payload.createdAt) {
|
|
641
943
|
const body = { ...payload };
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
944
|
+
const until = String(body.until || "").trim();
|
|
945
|
+
if (!until) {
|
|
946
|
+
delete body.until;
|
|
947
|
+
delete body.untilAt;
|
|
948
|
+
return body;
|
|
645
949
|
}
|
|
646
|
-
|
|
647
|
-
|
|
950
|
+
body.until = until;
|
|
951
|
+
if (!body.untilAt || !Number.isFinite(Date.parse(body.untilAt))) {
|
|
952
|
+
delete body.untilAt;
|
|
953
|
+
const untilAt = resolveUntilAt(until, from);
|
|
648
954
|
if (untilAt) body.untilAt = untilAt;
|
|
649
955
|
}
|
|
650
956
|
return body;
|
|
@@ -718,15 +1024,35 @@ export async function backfillEmbeddings(db, { log } = {}) {
|
|
|
718
1024
|
return { ok: true, embedded, failed };
|
|
719
1025
|
}
|
|
720
1026
|
|
|
721
|
-
|
|
1027
|
+
/**
|
|
1028
|
+
* @param onStage - optional observer `(stageName, ids)` called once per pipeline
|
|
1029
|
+
* branch that actually produced candidates. Used by the recall trace, which
|
|
1030
|
+
* must not claim a branch ran when it did not.
|
|
1031
|
+
*/
|
|
1032
|
+
export function searchNodes(db, query, { topK = 8, expandDepth = 1, now, queryVector, includeStale = true, onStage } = {}) {
|
|
722
1033
|
const text = String(query || "").trim();
|
|
723
1034
|
if (!text) return [];
|
|
724
1035
|
const at = now instanceof Date ? now : new Date();
|
|
725
1036
|
const primary = new Map();
|
|
726
|
-
const
|
|
1037
|
+
const terms = queryTerms(text);
|
|
1038
|
+
const hasQueryVector = Array.isArray(queryVector) && queryVector.length === DIM &&
|
|
1039
|
+
queryVector.every(Number.isFinite) && queryVector.some((value) => value !== 0);
|
|
1040
|
+
const identifierQuery = /^[a-z0-9_]+(?:[-./:][a-z0-9_]+)+$/i.test(text);
|
|
1041
|
+
const consider = (id, nativeScore = 0) => {
|
|
727
1042
|
const rec = hitRecord(db, id);
|
|
728
1043
|
if (!rec) return;
|
|
729
|
-
|
|
1044
|
+
// A native BM25/vector hit is already a retrieval candidate. Lexical
|
|
1045
|
+
// evidence may boost it, but must never veto it with an exact-phrase gate.
|
|
1046
|
+
const lexical = primaryScore(rec.payload, text, at, terms);
|
|
1047
|
+
// Explicit keywords are additive in 0.8.x. A renamed node may still have
|
|
1048
|
+
// an old native keyword: require some current token evidence for text-only
|
|
1049
|
+
// hits, never the whole query phrase. Vector-only hits need no such overlap.
|
|
1050
|
+
const currentText = indexTextFor(rec.payload).toLowerCase();
|
|
1051
|
+
if (!hasQueryVector && lexical <= 0 && !terms.some((term) => currentText.includes(term))) return;
|
|
1052
|
+
// Keep code identifiers atomic: a sparse hit for just "X" must not turn
|
|
1053
|
+
// an exact X-Request-Id lookup into a hit on an unrelated header X fact.
|
|
1054
|
+
if (identifierQuery && !hasQueryVector && lexical <= 0) return;
|
|
1055
|
+
const score = Math.max(lexical, Number.isFinite(nativeScore) ? nativeScore : 0);
|
|
730
1056
|
if (score <= 0) return;
|
|
731
1057
|
rec.score = score;
|
|
732
1058
|
rec.query = text;
|
|
@@ -734,20 +1060,38 @@ export function searchNodes(db, query, { topK = 8, expandDepth = 1, now, queryVe
|
|
|
734
1060
|
const prev = primary.get(rec.id);
|
|
735
1061
|
if (!prev || rec.score > prev.score) primary.set(rec.id, rec);
|
|
736
1062
|
};
|
|
1063
|
+
const report = (stage, ids) => {
|
|
1064
|
+
if (typeof onStage !== "function") return;
|
|
1065
|
+
try {
|
|
1066
|
+
onStage(stage, ids);
|
|
1067
|
+
} catch {
|
|
1068
|
+
// a broken observer must not break retrieval
|
|
1069
|
+
}
|
|
1070
|
+
};
|
|
737
1071
|
try {
|
|
738
1072
|
const vec =
|
|
739
|
-
|
|
740
|
-
const hits = db.searchHybrid(vec, text, Math.max(topK, 16), 0, 0.01, 0.2
|
|
741
|
-
|
|
1073
|
+
hasQueryVector ? queryVector : zeroVector();
|
|
1074
|
+
const hits = db.searchHybrid(vec, text, Math.max(topK, 16), 0, 0.01, 0.2, {
|
|
1075
|
+
type: { $in: ["entity", "decision", "preference", "experience"] },
|
|
1076
|
+
status: { $ne: "archived" },
|
|
1077
|
+
});
|
|
1078
|
+
report("native-hybrid", (hits || []).map((hit) => hit.id));
|
|
1079
|
+
for (const hit of hits || []) consider(hit.id, Number(hit.score));
|
|
742
1080
|
} catch {
|
|
743
1081
|
// fall through to keyword scan
|
|
744
1082
|
}
|
|
745
|
-
|
|
746
|
-
|
|
1083
|
+
const fallback = keywordFallback(db, text, 64, terms);
|
|
1084
|
+
report("keyword-fallback", fallback.map((hit) => hit.id));
|
|
1085
|
+
for (const hit of fallback) consider(hit.id);
|
|
1086
|
+
const anchors = entityAnchorIds(db, text);
|
|
1087
|
+
report("entity-anchor", anchors);
|
|
1088
|
+
for (const id of anchors) consider(id);
|
|
747
1089
|
if (queryMentionsUntil(text)) {
|
|
748
1090
|
for (const id of db.allNodeIds?.() || []) consider(id);
|
|
1091
|
+
report("deadline-scan", [...primary.keys()]);
|
|
749
1092
|
}
|
|
750
1093
|
|
|
1094
|
+
const normalized = [];
|
|
751
1095
|
for (const hit of [...primary.values()]) {
|
|
752
1096
|
const dest = sameAsTarget(db, hit.id);
|
|
753
1097
|
if (dest == null || dest === hit.id || primary.has(dest)) continue;
|
|
@@ -758,10 +1102,13 @@ export function searchNodes(db, query, { topK = 8, expandDepth = 1, now, queryVe
|
|
|
758
1102
|
rec.query = text;
|
|
759
1103
|
rec.now = at;
|
|
760
1104
|
primary.set(dest, rec);
|
|
1105
|
+
normalized.push(dest);
|
|
761
1106
|
}
|
|
1107
|
+
report("same-as-normalize", normalized);
|
|
762
1108
|
|
|
763
1109
|
const out = new Map(primary);
|
|
764
1110
|
if (expandDepth > 0) {
|
|
1111
|
+
const expanded = [];
|
|
765
1112
|
for (const hit of primary.values()) {
|
|
766
1113
|
for (const nid of businessNeighborIds(db, hit.id)) {
|
|
767
1114
|
if (out.has(nid)) continue;
|
|
@@ -772,30 +1119,43 @@ export function searchNodes(db, query, { topK = 8, expandDepth = 1, now, queryVe
|
|
|
772
1119
|
rec.query = text;
|
|
773
1120
|
rec.now = at;
|
|
774
1121
|
out.set(nid, rec);
|
|
1122
|
+
expanded.push(nid);
|
|
775
1123
|
}
|
|
776
1124
|
}
|
|
777
1125
|
}
|
|
1126
|
+
report("business-expand", expanded);
|
|
778
1127
|
}
|
|
779
1128
|
const typeRank = { entity: 0, decision: 1, preference: 2, experience: 3 };
|
|
780
1129
|
return [...out.values()]
|
|
781
|
-
.filter((hit) => !
|
|
1130
|
+
.filter((hit) => includeStale || !isStalePayload(hit.payload, at))
|
|
1131
|
+
.map((hit) => ({ ...hit, includeStale }))
|
|
782
1132
|
.sort((a, b) => {
|
|
783
1133
|
const rb = rankBoost(db, b, text, at) - rankBoost(db, a, text, at);
|
|
784
1134
|
if (rb) return rb;
|
|
785
|
-
const
|
|
786
|
-
if (
|
|
787
|
-
return (
|
|
1135
|
+
const score = (b.score || 0) - (a.score || 0);
|
|
1136
|
+
if (score) return score;
|
|
1137
|
+
return (typeRank[a.payload?.type] ?? 9) - (typeRank[b.payload?.type] ?? 9);
|
|
788
1138
|
})
|
|
789
1139
|
.slice(0, topK);
|
|
790
1140
|
}
|
|
791
1141
|
|
|
792
1142
|
/** Entity-name hit → that entity plus unexpired business-edge neighbors. Empty if query names none. */
|
|
793
|
-
export function namedRecallHits(db, query, { topK = 8, now } = {}) {
|
|
1143
|
+
export function namedRecallHits(db, query, { topK = 8, now, onStage } = {}) {
|
|
794
1144
|
const text = String(query || "").trim();
|
|
795
1145
|
const ids = entityAnchorIds(db, text);
|
|
796
1146
|
if (!ids.length) return [];
|
|
797
1147
|
const at = now instanceof Date ? now : new Date();
|
|
798
1148
|
const out = new Map();
|
|
1149
|
+
const report = (stage, list) => {
|
|
1150
|
+
if (typeof onStage !== "function") return;
|
|
1151
|
+
try {
|
|
1152
|
+
onStage(stage, list);
|
|
1153
|
+
} catch {
|
|
1154
|
+
// ignore a broken observer
|
|
1155
|
+
}
|
|
1156
|
+
};
|
|
1157
|
+
report("entity-anchor", ids);
|
|
1158
|
+
const expanded = [];
|
|
799
1159
|
for (const id of ids) {
|
|
800
1160
|
const rec = hitRecord(db, id);
|
|
801
1161
|
if (rec) {
|
|
@@ -814,10 +1174,12 @@ export function namedRecallHits(db, query, { topK = 8, now } = {}) {
|
|
|
814
1174
|
nrec.query = text;
|
|
815
1175
|
nrec.now = at;
|
|
816
1176
|
out.set(nid, nrec);
|
|
1177
|
+
expanded.push(nid);
|
|
817
1178
|
}
|
|
818
1179
|
}
|
|
1180
|
+
report("business-expand", expanded);
|
|
819
1181
|
return [...out.values()]
|
|
820
|
-
.filter((hit) => !
|
|
1182
|
+
.filter((hit) => !isStalePayload(hit.payload, at))
|
|
821
1183
|
.slice(0, topK);
|
|
822
1184
|
}
|
|
823
1185
|
|
|
@@ -827,12 +1189,6 @@ function hasBusinessEdge(db, hit) {
|
|
|
827
1189
|
return incomingBusinessEdges(db, hit.id).length > 0;
|
|
828
1190
|
}
|
|
829
1191
|
|
|
830
|
-
function shouldHideStale(hit, query, now) {
|
|
831
|
-
if (hit.payload?.type !== "decision") return false;
|
|
832
|
-
if (!isStalePayload(hit.payload, now)) return false;
|
|
833
|
-
return !queryMentionsUntil(query, hit.payload);
|
|
834
|
-
}
|
|
835
|
-
|
|
836
1192
|
function rankBoost(db, hit, query, now) {
|
|
837
1193
|
let n = 0;
|
|
838
1194
|
if (hasBusinessEdge(db, hit)) n += 4;
|
|
@@ -864,14 +1220,18 @@ function hitRecord(db, id) {
|
|
|
864
1220
|
|
|
865
1221
|
const MIN_ENTITY_ANCHOR = 3;
|
|
866
1222
|
|
|
1223
|
+
function longEnoughAnchor(label) {
|
|
1224
|
+
return label.length >= MIN_ENTITY_ANCHOR || /^[\p{Script=Han}]{2}$/u.test(label);
|
|
1225
|
+
}
|
|
1226
|
+
|
|
867
1227
|
function entityAnchorLabels(payload) {
|
|
868
1228
|
return uniqueStrings([payload?.name, ...(payload?.aliases || [])]).filter(
|
|
869
|
-
|
|
1229
|
+
longEnoughAnchor,
|
|
870
1230
|
);
|
|
871
1231
|
}
|
|
872
1232
|
|
|
873
1233
|
function queryMentionsEntityName(queryLower, nameLower) {
|
|
874
|
-
if (!queryLower || !nameLower || nameLower
|
|
1234
|
+
if (!queryLower || !nameLower || !longEnoughAnchor(nameLower)) return false;
|
|
875
1235
|
if (queryLower === nameLower) return true;
|
|
876
1236
|
if (/^[\x00-\x7f]+$/.test(nameLower)) {
|
|
877
1237
|
const escaped = nameLower.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
@@ -902,7 +1262,31 @@ function entityAnchorIds(db, query) {
|
|
|
902
1262
|
return ids;
|
|
903
1263
|
}
|
|
904
1264
|
|
|
905
|
-
|
|
1265
|
+
const wordSegmenter = new Intl.Segmenter("zh", { granularity: "word" });
|
|
1266
|
+
const QUERY_STOP_WORDS = new Set(["的", "了", "是", "在", "有", "和", "与", "请", "什么", "哪些", "怎么", "如何", "一下", "这个", "我们", "the", "a", "an", "is", "are", "to", "of", "for", "and", "what", "how"]);
|
|
1267
|
+
|
|
1268
|
+
function queryTerms(query) {
|
|
1269
|
+
const chunks = query.toLowerCase().match(/[\p{L}\p{N}_]+(?:[-./:][\p{L}\p{N}_]+)*/gu) || [];
|
|
1270
|
+
return [...new Set(chunks.flatMap((chunk) => /\p{Script=Han}/u.test(chunk)
|
|
1271
|
+
? [...wordSegmenter.segment(chunk)].filter((part) => part.isWordLike).map((part) => part.segment)
|
|
1272
|
+
: [chunk])
|
|
1273
|
+
.filter((term) => !QUERY_STOP_WORDS.has(term) && (!/\p{Script=Han}/u.test(term) || term.length >= 2)))];
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function lexicalScore(payload, query, terms = queryTerms(query)) {
|
|
1277
|
+
const hay = indexTextFor(payload).toLowerCase();
|
|
1278
|
+
if (hay.includes(query)) return 2;
|
|
1279
|
+
if (!terms.length) return 0;
|
|
1280
|
+
const matched = terms.filter((term) => {
|
|
1281
|
+
if (/\p{Script=Han}/u.test(term)) return hay.includes(term);
|
|
1282
|
+
const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1283
|
+
return new RegExp(`(?:^|[^a-z0-9_])${escaped}(?:$|[^a-z0-9_])`, "i").test(hay);
|
|
1284
|
+
}).length;
|
|
1285
|
+
const coverage = matched / terms.length;
|
|
1286
|
+
return coverage >= 0.5 ? coverage : 0;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
function primaryScore(payload, query, now, terms) {
|
|
906
1290
|
const q = String(query || "").trim().toLowerCase();
|
|
907
1291
|
if (!q) return 0;
|
|
908
1292
|
const name = String(payload?.name || "").toLowerCase();
|
|
@@ -912,7 +1296,7 @@ function primaryScore(payload, query, now) {
|
|
|
912
1296
|
if (name === q) score = 4;
|
|
913
1297
|
else if (name.includes(q)) score = 3;
|
|
914
1298
|
else if (text.includes(q) || until.includes(q)) score = 2;
|
|
915
|
-
else
|
|
1299
|
+
else score = lexicalScore(payload || {}, q, terms);
|
|
916
1300
|
if (
|
|
917
1301
|
payload?.type === "decision" &&
|
|
918
1302
|
isStalePayload(payload, now) &&
|
|
@@ -993,7 +1377,7 @@ function tqlBusinessNeighbors(db, id) {
|
|
|
993
1377
|
}
|
|
994
1378
|
}
|
|
995
1379
|
|
|
996
|
-
function keywordFallback(db, text, topK) {
|
|
1380
|
+
function keywordFallback(db, text, topK, terms) {
|
|
997
1381
|
const q = text.toLowerCase();
|
|
998
1382
|
const ids = db.allNodeIds?.() || [];
|
|
999
1383
|
const hits = [];
|
|
@@ -1007,17 +1391,16 @@ function keywordFallback(db, text, topK) {
|
|
|
1007
1391
|
) {
|
|
1008
1392
|
continue;
|
|
1009
1393
|
}
|
|
1010
|
-
const
|
|
1011
|
-
if (!
|
|
1394
|
+
const score = lexicalScore(node.payload || {}, q, terms);
|
|
1395
|
+
if (!score) continue;
|
|
1012
1396
|
hits.push({
|
|
1013
1397
|
id,
|
|
1014
|
-
score
|
|
1398
|
+
score,
|
|
1015
1399
|
payload: node.payload,
|
|
1016
1400
|
edges: node.edges || [],
|
|
1017
1401
|
});
|
|
1018
|
-
if (hits.length >= topK) break;
|
|
1019
1402
|
}
|
|
1020
|
-
return hits;
|
|
1403
|
+
return hits.sort((a, b) => b.score - a.score).slice(0, topK);
|
|
1021
1404
|
}
|
|
1022
1405
|
|
|
1023
1406
|
function nodeLabel(db, id) {
|
|
@@ -1054,8 +1437,7 @@ function incomingBusinessEdges(db, id, { query = "", now, hideStale = false } =
|
|
|
1054
1437
|
if (!srcNode || srcNode.payload?.status === "archived") return;
|
|
1055
1438
|
if (
|
|
1056
1439
|
hideStale &&
|
|
1057
|
-
isStalePayload(srcNode.payload, at)
|
|
1058
|
-
!queryMentionsUntil(query, srcNode.payload)
|
|
1440
|
+
isStalePayload(srcNode.payload, at)
|
|
1059
1441
|
) {
|
|
1060
1442
|
return;
|
|
1061
1443
|
}
|
|
@@ -1109,6 +1491,7 @@ function edgePath(db, nodeId, edges, { query = "", now, hideStale = true } = {})
|
|
|
1109
1491
|
const seenOut = new Set();
|
|
1110
1492
|
for (const e of edges || []) {
|
|
1111
1493
|
const targetId = e.targetId ?? e.target_id;
|
|
1494
|
+
if (hideStale && isStalePayload(db.get(Number(targetId))?.payload, now)) continue;
|
|
1112
1495
|
const key = `${e.label}->${targetId}`;
|
|
1113
1496
|
if (seenOut.has(key)) continue;
|
|
1114
1497
|
seenOut.add(key);
|
|
@@ -1156,6 +1539,41 @@ export function listIncomingBusiness(db, id, opts = {}) {
|
|
|
1156
1539
|
}));
|
|
1157
1540
|
}
|
|
1158
1541
|
|
|
1542
|
+
/**
|
|
1543
|
+
* Incoming edges for an arbitrary label set (revision edges are not business
|
|
1544
|
+
* edges, so they cannot go through incomingBusinessEdges).
|
|
1545
|
+
*
|
|
1546
|
+
* Uses the native incoming index when available and otherwise scans outgoing
|
|
1547
|
+
* edges. Only the management UI and the revision resolver call this, never the
|
|
1548
|
+
* hot retrieval path.
|
|
1549
|
+
*/
|
|
1550
|
+
export function incomingLabeled(db, id, labels) {
|
|
1551
|
+
const want = new Set([...(labels || [])].map(String));
|
|
1552
|
+
const target = Number(id);
|
|
1553
|
+
if (!want.size || !Number.isFinite(target)) return [];
|
|
1554
|
+
const found = [];
|
|
1555
|
+
const push = (from, label) => {
|
|
1556
|
+
if (!Number.isFinite(from) || !want.has(String(label))) return;
|
|
1557
|
+
if (found.some((row) => row.from === from && row.label === label)) return;
|
|
1558
|
+
found.push({ from, label: String(label) });
|
|
1559
|
+
};
|
|
1560
|
+
const native = nativeIncoming(db, target);
|
|
1561
|
+
if (Array.isArray(native)) {
|
|
1562
|
+
for (const e of native) push(edgeSource(e), e.label ?? e.edge_label);
|
|
1563
|
+
return found;
|
|
1564
|
+
}
|
|
1565
|
+
for (const raw of db.allNodeIds?.() || []) {
|
|
1566
|
+
const from = Number(raw);
|
|
1567
|
+
if (!Number.isFinite(from)) continue;
|
|
1568
|
+
const node = db.get(from);
|
|
1569
|
+
for (const e of node?.edges || []) {
|
|
1570
|
+
if (edgeTarget(e) !== target) continue;
|
|
1571
|
+
push(from, e.label);
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
return found;
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1159
1577
|
export function formatHit(db, hit, opts = {}) {
|
|
1160
1578
|
const node = db.get(hit.id) || { payload: hit.payload, edges: [] };
|
|
1161
1579
|
const payload = node.payload || {};
|
|
@@ -1170,7 +1588,7 @@ export function formatHit(db, hit, opts = {}) {
|
|
|
1170
1588
|
l0: l0.slice(0, 200),
|
|
1171
1589
|
until: payload.until || "",
|
|
1172
1590
|
stale,
|
|
1173
|
-
path: edgePath(db, hit.id, node.edges || [], { query, now }),
|
|
1591
|
+
path: edgePath(db, hit.id, node.edges || [], { query, now, hideStale: hit.includeStale !== true }),
|
|
1174
1592
|
};
|
|
1175
1593
|
}
|
|
1176
1594
|
|