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/lib/store.js CHANGED
@@ -10,9 +10,21 @@ import {
10
10
  syncSidecarOnOpen,
11
11
  writeSidecarNow,
12
12
  } from "./sidecar.js";
13
- import { DIM, EDGE_LABELS, BUSINESS_EDGES, NODE_TYPES, indexTextFor, nowIso, weightOf, zeroVector } from "./schema.js";
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, parseUntilFromText, queryMentionsUntil, resolveUntilAt } from "./until.js";
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
- { type, q, includeArchived = false, includeStale = false, aboutId, includeEpisodes = false } = {},
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) next.text = String(patch.text || "").trim();
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
- if (until) {
436
- next.until = until;
437
- const untilAt = resolveUntilAt(until, next.createdAt);
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 || pb.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
- edges.push({
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: "dsh-trivium-graph",
535
- version: 1,
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
- /** Merge an exported graph into the open workspace. Remaps ids; skips extra workspace roots. */
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
- mergeInto(db, dest, payload);
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
- return { ok: true, created, merged, linked, nodes: idMap.size };
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
- if (body.type === "decision" && !body.until) {
643
- const until = parseUntilFromText(body.text);
644
- if (until) body.until = until;
944
+ const until = String(body.until || "").trim();
945
+ if (!until) {
946
+ delete body.until;
947
+ delete body.untilAt;
948
+ return body;
645
949
  }
646
- if (body.until && !body.untilAt) {
647
- const untilAt = resolveUntilAt(body.until, body.createdAt);
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
- export function searchNodes(db, query, { topK = 8, expandDepth = 1, now, queryVector } = {}) {
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 consider = (id) => {
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
- const score = primaryScore(rec.payload, text, at);
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
- Array.isArray(queryVector) && queryVector.length === DIM ? queryVector : zeroVector();
740
- const hits = db.searchHybrid(vec, text, Math.max(topK, 16), 0, 0.01, 0.2);
741
- for (const hit of hits || []) consider(hit.id);
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
- for (const hit of keywordFallback(db, text, 64)) consider(hit.id);
746
- for (const id of entityAnchorIds(db, text)) consider(id);
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) => !shouldHideStale(hit, text, at))
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 td = (typeRank[a.payload?.type] ?? 9) - (typeRank[b.payload?.type] ?? 9);
786
- if (td) return td;
787
- return (b.score || 0) - (a.score || 0);
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) => !shouldHideStale(hit, text, at))
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
- (s) => s.length >= MIN_ENTITY_ANCHOR,
1229
+ longEnoughAnchor,
870
1230
  );
871
1231
  }
872
1232
 
873
1233
  function queryMentionsEntityName(queryLower, nameLower) {
874
- if (!queryLower || !nameLower || nameLower.length < MIN_ENTITY_ANCHOR) return false;
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
- function primaryScore(payload, query, now) {
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 if (indexTextFor(payload || {}).toLowerCase().includes(q)) score = 2;
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 hay = indexTextFor(node.payload || {}).toLowerCase();
1011
- if (!hay.includes(q)) continue;
1394
+ const score = lexicalScore(node.payload || {}, q, terms);
1395
+ if (!score) continue;
1012
1396
  hits.push({
1013
1397
  id,
1014
- score: 1,
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