dsh-trivium 0.2.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 ADDED
@@ -0,0 +1,1042 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { DIM, EDGE_LABELS, BUSINESS_EDGES, NODE_TYPES, indexTextFor, nowIso, weightOf, zeroVector } from "./schema.js";
6
+ import { clipToTokenBudget, estimateTokens } from "./tokens.js";
7
+ import { isStalePayload, parseUntilFromText, queryMentionsUntil, resolveUntilAt } from "./until.js";
8
+ import { embedText, embeddingEnabled } from "./embed.js";
9
+
10
+ const require = createRequire(fileURLToPath(import.meta.url));
11
+
12
+ let TriviumDB = null;
13
+ let loadError = null;
14
+
15
+ function loadEngine() {
16
+ if (TriviumDB) return TriviumDB;
17
+ if (loadError) throw loadError;
18
+ try {
19
+ const mod = require("triviumdb");
20
+ TriviumDB = mod.TriviumDB || mod.default?.TriviumDB || mod.default;
21
+ if (typeof TriviumDB !== "function") {
22
+ throw new Error("triviumdb export TriviumDB not found");
23
+ }
24
+ return TriviumDB;
25
+ } catch (err) {
26
+ loadError = err;
27
+ throw err;
28
+ }
29
+ }
30
+
31
+ const dbs = new Map();
32
+ let activeCwd = null;
33
+
34
+ export function dbPathFor(cwd) {
35
+ return join(resolve(cwd), ".dsh", "trivium.tdb");
36
+ }
37
+
38
+ export function setActiveCwd(cwd) {
39
+ if (cwd) activeCwd = cwd;
40
+ return activeCwd;
41
+ }
42
+
43
+ export function getActiveCwd() {
44
+ return activeCwd;
45
+ }
46
+
47
+ export function listOpenWorkspaces() {
48
+ return [...dbs.entries()].map(([cwd, db]) => ({
49
+ cwd,
50
+ path: dbPathFor(cwd),
51
+ nodeCount: nodeCountOf(db),
52
+ }));
53
+ }
54
+
55
+ export function nodeCountOf(db) {
56
+ try {
57
+ if (typeof db.nodeCount === "function") return db.nodeCount();
58
+ return (db.allNodeIds?.() || []).length;
59
+ } catch {
60
+ return 0;
61
+ }
62
+ }
63
+
64
+ export async function openWorkspaceDb(cwd) {
65
+ const key = resolve(cwd);
66
+ if (cwd) activeCwd = key;
67
+ if (dbs.has(key)) return dbs.get(key);
68
+ mkdirSync(join(key, ".dsh"), { recursive: true });
69
+ const Engine = loadEngine();
70
+ const db = new Engine(dbPathFor(key), DIM);
71
+ try {
72
+ db.createIndex("type");
73
+ db.createIndex("status");
74
+ } catch {
75
+ // indexes may already exist
76
+ }
77
+ ensureWorkspaceRoot(db, cwd);
78
+ rebuildTextIndex(db);
79
+ dbs.set(key, db);
80
+ return db;
81
+ }
82
+
83
+ export function closeAll() {
84
+ for (const db of dbs.values()) {
85
+ try {
86
+ db.flush();
87
+ db.close();
88
+ } catch {
89
+ // ignore
90
+ }
91
+ }
92
+ dbs.clear();
93
+ }
94
+
95
+ function ensureWorkspaceRoot(db, cwd) {
96
+ const existing = findByType(db, "workspace")[0];
97
+ if (existing) return existing.id;
98
+ const payload = {
99
+ type: "workspace",
100
+ name: cwd,
101
+ text: `workspace ${cwd}`,
102
+ uri: "ctx://workspace",
103
+ status: "active",
104
+ createdAt: nowIso(),
105
+ updatedAt: nowIso(),
106
+ };
107
+ const id = db.insert(zeroVector(), payload);
108
+ indexNode(db, id, payload);
109
+ return id;
110
+ }
111
+
112
+ export function workspaceId(db) {
113
+ return findByType(db, "workspace")[0]?.id ?? null;
114
+ }
115
+
116
+ export function findByType(db, type) {
117
+ try {
118
+ const rows = db.tql(
119
+ `FIND {type: ${JSON.stringify(type)}, status: "active"} RETURN *`,
120
+ );
121
+ return normalizeTql(rows);
122
+ } catch {
123
+ return [];
124
+ }
125
+ }
126
+
127
+ function normalizeTql(rows) {
128
+ if (!Array.isArray(rows)) return [];
129
+ return rows
130
+ .map((row) => {
131
+ const node = row?._ || row?.["*"] || row?.a || Object.values(row || {})[0];
132
+ if (!node || typeof node.id !== "number") return null;
133
+ return { id: node.id, payload: node.payload || {} };
134
+ })
135
+ .filter(Boolean);
136
+ }
137
+
138
+ function indexNode(db, id, payload) {
139
+ const text = indexTextFor(payload);
140
+ try {
141
+ if (text) db.indexText(id, text);
142
+ if (payload?.name) db.indexKeyword(id, String(payload.name));
143
+ db.buildTextIndex();
144
+ } catch {
145
+ // text index is optional if the native binding rejects a token
146
+ }
147
+ }
148
+
149
+ function rebuildTextIndex(db) {
150
+ try {
151
+ for (const id of db.allNodeIds()) {
152
+ const payload = db.getPayload(id);
153
+ if (!payload) continue;
154
+ const text = indexTextFor(payload);
155
+ if (text) db.indexText(id, text);
156
+ if (payload.name) db.indexKeyword(id, String(payload.name));
157
+ }
158
+ db.buildTextIndex();
159
+ } catch {
160
+ // keep the file usable even if sparse index rebuild fails
161
+ }
162
+ }
163
+
164
+ export function findMergeTarget(db, candidate) {
165
+ const type = candidate?.type;
166
+ if (!type) return null;
167
+ const name = String(candidate.name || "").trim().toLowerCase();
168
+ const text = String(candidate.text || "").trim().toLowerCase();
169
+ for (const n of findByType(db, type)) {
170
+ const payload = n.payload || {};
171
+ const nName = String(payload.name || "").trim().toLowerCase();
172
+ const nText = String(payload.text || "").trim().toLowerCase();
173
+ const aliases = (payload.aliases || []).map((a) => String(a).trim().toLowerCase());
174
+ if (type === "entity") {
175
+ if (name && (nName === name || aliases.includes(name))) return n.id;
176
+ if (text && nName && nName === text) return n.id;
177
+ continue;
178
+ }
179
+ if (text.length >= 8 && nText && (nText === text || nText.includes(text) || text.includes(nText))) {
180
+ return n.id;
181
+ }
182
+ }
183
+ return null;
184
+ }
185
+
186
+ function uniqueStrings(values) {
187
+ const out = [];
188
+ const seen = new Set();
189
+ for (const value of values || []) {
190
+ const s = String(value || "").trim();
191
+ if (!s) continue;
192
+ const key = s.toLowerCase();
193
+ if (seen.has(key)) continue;
194
+ seen.add(key);
195
+ out.push(s);
196
+ }
197
+ return out;
198
+ }
199
+
200
+ export function mergeInto(db, id, incoming) {
201
+ const node = db.get(Number(id));
202
+ if (!node) return insertNode(db, incoming);
203
+ const old = node.payload || {};
204
+ const aliases = uniqueStrings([
205
+ ...(old.aliases || []),
206
+ ...(incoming.aliases || []),
207
+ incoming.name,
208
+ old.name,
209
+ ]).filter((a) => a !== (incoming.name || old.name));
210
+ const next = decorateUntil({
211
+ ...old,
212
+ ...incoming,
213
+ name: incoming.name || old.name,
214
+ text: pickMergedText(old.text, incoming.text),
215
+ aliases,
216
+ createdAt: old.createdAt || incoming.createdAt || nowIso(),
217
+ updatedAt: nowIso(),
218
+ status: old.status || "active",
219
+ source: incoming.source || old.source,
220
+ });
221
+ db.updatePayload(Number(id), next);
222
+ indexNode(db, Number(id), next);
223
+ db.flush();
224
+ queueEmbed(db, Number(id), next);
225
+ return Number(id);
226
+ }
227
+
228
+ export function listNodes(
229
+ db,
230
+ { type, q, includeArchived = false, includeStale = false, aboutId } = {},
231
+ ) {
232
+ const rows = [];
233
+ const needle = String(q || "").trim().toLowerCase();
234
+ const about = aboutId == null || aboutId === "" ? null : Number(aboutId);
235
+ const around =
236
+ about != null && Number.isFinite(about)
237
+ ? new Set([about, ...businessNeighborIds(db, about)])
238
+ : null;
239
+ for (const id of db.allNodeIds?.() || []) {
240
+ const node = db.get(id);
241
+ if (!node) continue;
242
+ const payload = node.payload || {};
243
+ if (!includeArchived && payload.status === "archived") continue;
244
+ if (type && payload.type !== type) continue;
245
+ if (around && !around.has(Number(id))) continue;
246
+ const stale = isStalePayload(payload);
247
+ if (
248
+ !includeStale &&
249
+ payload.type === "decision" &&
250
+ stale &&
251
+ !queryMentionsUntil(needle, payload)
252
+ ) {
253
+ continue;
254
+ }
255
+ const path = edgePath(db, id, node.edges || [], {
256
+ query: needle,
257
+ hideStale: !includeStale,
258
+ });
259
+ if (needle) {
260
+ const hay = [payload.type, payload.name, payload.text, payload.until, ...path]
261
+ .filter(Boolean)
262
+ .join(" ")
263
+ .toLowerCase();
264
+ if (!hay.includes(needle)) continue;
265
+ }
266
+ rows.push({
267
+ id,
268
+ type: payload.type || "unknown",
269
+ name: payload.name || "",
270
+ text: payload.text || "",
271
+ aliases: payload.aliases || [],
272
+ until: payload.until || "",
273
+ stale,
274
+ status: payload.status || "active",
275
+ edges: (node.edges || []).length,
276
+ path,
277
+ incoming: listIncomingBusiness(db, id, { query: needle, hideStale: !includeStale }),
278
+ outgoing: listOutgoingBusiness(db, id),
279
+ sourceSession: payload.source?.sessionId || "",
280
+ createdAt: payload.createdAt || "",
281
+ updatedAt: payload.updatedAt || "",
282
+ });
283
+ }
284
+ return rows.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
285
+ }
286
+
287
+ export function archiveNode(db, id) {
288
+ const node = db.get(Number(id));
289
+ if (!node) return false;
290
+ const payload = {
291
+ ...(node.payload || {}),
292
+ status: "archived",
293
+ updatedAt: nowIso(),
294
+ };
295
+ db.updatePayload(Number(id), payload);
296
+ rebuildTextIndex(db);
297
+ db.flush();
298
+ return true;
299
+ }
300
+
301
+ export function deleteNode(db, id) {
302
+ const n = Number(id);
303
+ if (!db.contains?.(n) && !db.get(n)) return false;
304
+ db.delete(n);
305
+ rebuildTextIndex(db);
306
+ db.flush();
307
+ return true;
308
+ }
309
+
310
+ export function updateNode(db, id, patch = {}) {
311
+ const nid = Number(id);
312
+ const node = db.get(nid);
313
+ if (!node) return null;
314
+ const old = node.payload || {};
315
+ if (old.type === "workspace") return null;
316
+ const next = { ...old };
317
+ if (patch.name !== undefined) next.name = String(patch.name || "").trim();
318
+ if (patch.text !== undefined) next.text = String(patch.text || "").trim();
319
+ if (Array.isArray(patch.aliases)) {
320
+ next.aliases = uniqueStrings(patch.aliases).filter((a) => a !== next.name);
321
+ }
322
+ if (patch.until !== undefined) {
323
+ const until = String(patch.until || "").trim();
324
+ if (until) {
325
+ next.until = until;
326
+ const untilAt = resolveUntilAt(until, next.createdAt);
327
+ if (untilAt) next.untilAt = untilAt;
328
+ } else {
329
+ delete next.until;
330
+ delete next.untilAt;
331
+ }
332
+ }
333
+ const body = decorateUntil({
334
+ ...next,
335
+ type: old.type,
336
+ createdAt: old.createdAt,
337
+ status: old.status || "active",
338
+ updatedAt: nowIso(),
339
+ });
340
+ db.updatePayload(nid, body);
341
+ indexNode(db, nid, body);
342
+ db.flush();
343
+ queueEmbed(db, nid, body);
344
+ return body;
345
+ }
346
+
347
+ /** Keep `keepId`, archive `dropId`, rewire edges, `same_as` drop → keep. Same type only. */
348
+ export function mergeNodes(db, keepId, dropId) {
349
+ const keep = Number(keepId);
350
+ const drop = Number(dropId);
351
+ if (!Number.isFinite(keep) || !Number.isFinite(drop) || keep === drop) {
352
+ return { ok: false, message: "need two different ids" };
353
+ }
354
+ const a = db.get(keep);
355
+ const b = db.get(drop);
356
+ if (!a || !b) return { ok: false, message: "not found" };
357
+ const pa = a.payload || {};
358
+ const pb = b.payload || {};
359
+ if (pa.type === "workspace" || pb.type === "workspace") {
360
+ return { ok: false, message: "cannot merge workspace" };
361
+ }
362
+ if (pa.type !== pb.type) return { ok: false, message: "type mismatch" };
363
+ if (pa.status === "archived" || pb.status === "archived") {
364
+ return { ok: false, message: "archived" };
365
+ }
366
+ for (const other of db.allNodeIds?.() || []) {
367
+ if (Number(other) === drop) continue;
368
+ const n = db.get(other);
369
+ for (const e of n?.edges || []) {
370
+ if (Number(e.targetId ?? e.target_id) === drop) {
371
+ ensureLink(db, other, keep, e.label);
372
+ }
373
+ }
374
+ }
375
+ for (const e of b.edges || []) {
376
+ const tid = Number(e.targetId ?? e.target_id);
377
+ if (!Number.isFinite(tid) || tid === keep) continue;
378
+ ensureLink(db, keep, tid, e.label);
379
+ }
380
+ mergeInto(db, keep, {
381
+ type: pa.type,
382
+ name: pa.name || pb.name,
383
+ text: pa.text,
384
+ aliases: [...(pa.aliases || []), ...(pb.aliases || []), pb.name],
385
+ until: pa.until || pb.until,
386
+ untilAt: pa.untilAt || pb.untilAt,
387
+ source: pa.source || pb.source,
388
+ });
389
+ ensureLink(db, drop, keep, EDGE_LABELS.sameAs);
390
+ archiveNode(db, drop);
391
+ return { ok: true, keep, drop, type: pa.type };
392
+ }
393
+
394
+ export function exportGraph(db, cwd = "") {
395
+ const nodes = [];
396
+ const edges = [];
397
+ for (const id of db.allNodeIds?.() || []) {
398
+ const node = db.get(id);
399
+ if (!node) continue;
400
+ nodes.push({ id: Number(id), payload: node.payload || {} });
401
+ for (const e of node.edges || []) {
402
+ edges.push({
403
+ from: Number(id),
404
+ to: Number(e.targetId ?? e.target_id),
405
+ label: e.label || "",
406
+ weight: e.weight ?? null,
407
+ });
408
+ }
409
+ }
410
+ return {
411
+ format: "dsh-trivium-graph",
412
+ version: 1,
413
+ exportedAt: nowIso(),
414
+ cwd: cwd || "",
415
+ dim: DIM,
416
+ nodes,
417
+ edges,
418
+ };
419
+ }
420
+
421
+ function findByUri(db, uri) {
422
+ const needle = String(uri || "").trim();
423
+ if (!needle) return null;
424
+ for (const id of db.allNodeIds?.() || []) {
425
+ const payload = db.get(id)?.payload;
426
+ if (payload?.uri === needle) return Number(id);
427
+ }
428
+ return null;
429
+ }
430
+
431
+ /** Merge an exported graph into the open workspace. Remaps ids; skips extra workspace roots. */
432
+ export function importGraph(db, graph) {
433
+ const body = graph?.graph && Array.isArray(graph.graph.nodes) ? graph.graph : graph;
434
+ const nodes = Array.isArray(body?.nodes) ? body.nodes : null;
435
+ if (!nodes) return { ok: false, message: "不是本插件导出的 JSON" };
436
+ const edges = Array.isArray(body.edges) ? body.edges : [];
437
+ const idMap = new Map();
438
+ const root = workspaceId(db);
439
+ let created = 0;
440
+ let merged = 0;
441
+ let linked = 0;
442
+ for (const row of nodes) {
443
+ const oldId = Number(row?.id);
444
+ const payload = row?.payload && typeof row.payload === "object" ? { ...row.payload } : null;
445
+ if (!payload || !Number.isFinite(oldId)) continue;
446
+ if (payload.type === "workspace") {
447
+ if (root != null) idMap.set(oldId, root);
448
+ continue;
449
+ }
450
+ if (!NODE_TYPES.includes(payload.type)) continue;
451
+ let dest = findByUri(db, payload.uri);
452
+ if (dest == null) dest = findMergeTarget(db, payload);
453
+ if (dest != null) {
454
+ mergeInto(db, dest, payload);
455
+ idMap.set(oldId, dest);
456
+ merged += 1;
457
+ } else {
458
+ const next = insertNode(db, payload);
459
+ idMap.set(oldId, next);
460
+ created += 1;
461
+ }
462
+ }
463
+ for (const e of edges) {
464
+ const from = idMap.get(Number(e.from));
465
+ const to = idMap.get(Number(e.to));
466
+ const label = e.label || EDGE_LABELS.about;
467
+ if (from == null || to == null || from === to) continue;
468
+ if (ensureLink(db, from, to, label)) linked += 1;
469
+ }
470
+ return { ok: true, created, merged, linked, nodes: idMap.size };
471
+ }
472
+
473
+ export function hasEdge(db, from, to, label) {
474
+ const node = db.get(Number(from));
475
+ return (node?.edges || []).some(
476
+ (e) => Number(e.targetId ?? e.target_id) === Number(to) && (!label || e.label === label),
477
+ );
478
+ }
479
+
480
+ /** Link once for a (from, to, label) triple. Engine unlink cannot drop a single label. */
481
+ export function ensureLink(db, from, to, label) {
482
+ const src = Number(from);
483
+ const dst = Number(to);
484
+ if (!Number.isFinite(src) || !Number.isFinite(dst) || src === dst) return false;
485
+ if (hasEdge(db, src, dst, label)) return false;
486
+ db.link(src, dst, label, weightOf(label));
487
+ db.flush();
488
+ return true;
489
+ }
490
+
491
+ function pickMergedText(oldText, incomingText) {
492
+ const a = String(oldText || "").trim();
493
+ const b = String(incomingText || "").trim();
494
+ if (!a) return b;
495
+ if (!b) return a;
496
+ if (b.length >= a.length && b.includes(a) && b.length > Math.max(80, a.length * 2)) return a;
497
+ if (a.length >= b.length && a.includes(b) && a.length > Math.max(80, b.length * 2)) return b;
498
+ return b.length >= a.length ? b : a;
499
+ }
500
+
501
+ function decorateUntil(payload) {
502
+ const body = { ...payload };
503
+ if (body.type === "decision" && !body.until) {
504
+ const until = parseUntilFromText(body.text);
505
+ if (until) body.until = until;
506
+ }
507
+ if (body.until && !body.untilAt) {
508
+ const untilAt = resolveUntilAt(body.until, body.createdAt);
509
+ if (untilAt) body.untilAt = untilAt;
510
+ }
511
+ return body;
512
+ }
513
+
514
+ export function insertNode(db, payload, { linkToWorkspace = true } = {}) {
515
+ const body = decorateUntil({
516
+ status: "active",
517
+ createdAt: nowIso(),
518
+ updatedAt: nowIso(),
519
+ ...payload,
520
+ });
521
+ const id = db.insert(zeroVector(), body);
522
+ indexNode(db, id, body);
523
+ if (linkToWorkspace && body.type !== "workspace") {
524
+ const root = workspaceId(db);
525
+ if (root != null) ensureLink(db, id, root, EDGE_LABELS.inWorkspace);
526
+ }
527
+ db.flush();
528
+ queueEmbed(db, id, body);
529
+ return id;
530
+ }
531
+
532
+ function persistVector(db, id, vec) {
533
+ const fn = db.updateVector || db.update_vector;
534
+ if (typeof fn !== "function" || !Array.isArray(vec)) return false;
535
+ try {
536
+ fn.call(db, Number(id), vec);
537
+ db.flush();
538
+ return true;
539
+ } catch {
540
+ return false;
541
+ }
542
+ }
543
+
544
+ function queueEmbed(db, id, payload) {
545
+ if (!embeddingEnabled()) return;
546
+ const text = indexTextFor(payload);
547
+ if (!text) return;
548
+ embedText(text)
549
+ .then((vec) => {
550
+ if (vec) persistVector(db, id, vec);
551
+ })
552
+ .catch(() => {});
553
+ }
554
+
555
+ export async function embedQueryVector(text) {
556
+ if (!embeddingEnabled()) return null;
557
+ return embedText(text);
558
+ }
559
+
560
+ export async function backfillEmbeddings(db, { log } = {}) {
561
+ if (!embeddingEnabled()) return { ok: false, message: "embedding off", embedded: 0, failed: 0 };
562
+ let embedded = 0;
563
+ let failed = 0;
564
+ for (const id of db.allNodeIds?.() || []) {
565
+ const node = db.get(id);
566
+ const payload = node?.payload;
567
+ if (!payload || payload.status === "archived") continue;
568
+ const text = indexTextFor(payload);
569
+ if (!text) continue;
570
+ const vec = await embedText(text);
571
+ if (!vec || !persistVector(db, id, vec)) {
572
+ failed += 1;
573
+ log?.warn?.(`[dsh-trivium] embed backfill failed id=${id}`);
574
+ } else {
575
+ embedded += 1;
576
+ }
577
+ }
578
+ return { ok: true, embedded, failed };
579
+ }
580
+
581
+ export function searchNodes(db, query, { topK = 8, expandDepth = 1, now, queryVector } = {}) {
582
+ const text = String(query || "").trim();
583
+ if (!text) return [];
584
+ const at = now instanceof Date ? now : new Date();
585
+ const primary = new Map();
586
+ const consider = (id) => {
587
+ const rec = hitRecord(db, id);
588
+ if (!rec) return;
589
+ const score = primaryScore(rec.payload, text, at);
590
+ if (score <= 0) return;
591
+ rec.score = score;
592
+ rec.query = text;
593
+ rec.now = at;
594
+ const prev = primary.get(rec.id);
595
+ if (!prev || rec.score > prev.score) primary.set(rec.id, rec);
596
+ };
597
+ try {
598
+ const vec =
599
+ Array.isArray(queryVector) && queryVector.length === DIM ? queryVector : zeroVector();
600
+ const hits = db.searchHybrid(vec, text, Math.max(topK, 16), 0, 0.01, 0.2);
601
+ for (const hit of hits || []) consider(hit.id);
602
+ } catch {
603
+ // fall through to keyword scan
604
+ }
605
+ for (const hit of keywordFallback(db, text, 64)) consider(hit.id);
606
+ for (const id of entityAnchorIds(db, text)) consider(id);
607
+ if (queryMentionsUntil(text)) {
608
+ for (const id of db.allNodeIds?.() || []) consider(id);
609
+ }
610
+
611
+ for (const hit of [...primary.values()]) {
612
+ const dest = sameAsTarget(db, hit.id);
613
+ if (dest == null || dest === hit.id || primary.has(dest)) continue;
614
+ const rec = hitRecord(db, dest);
615
+ if (!rec) continue;
616
+ rec.score = Math.max(hit.score || 0, 1);
617
+ rec.fromSameAs = true;
618
+ rec.query = text;
619
+ rec.now = at;
620
+ primary.set(dest, rec);
621
+ }
622
+
623
+ const out = new Map(primary);
624
+ if (expandDepth > 0) {
625
+ for (const hit of primary.values()) {
626
+ for (const nid of businessNeighborIds(db, hit.id)) {
627
+ if (out.has(nid)) continue;
628
+ const rec = hitRecord(db, nid);
629
+ if (rec) {
630
+ rec.score = 1;
631
+ rec.fromExpand = true;
632
+ rec.query = text;
633
+ rec.now = at;
634
+ out.set(nid, rec);
635
+ }
636
+ }
637
+ }
638
+ }
639
+ const typeRank = { entity: 0, decision: 1, preference: 2, experience: 3 };
640
+ return [...out.values()]
641
+ .filter((hit) => !shouldHideStale(hit, text, at))
642
+ .sort((a, b) => {
643
+ const rb = rankBoost(db, b, text, at) - rankBoost(db, a, text, at);
644
+ if (rb) return rb;
645
+ const td = (typeRank[a.payload?.type] ?? 9) - (typeRank[b.payload?.type] ?? 9);
646
+ if (td) return td;
647
+ return (b.score || 0) - (a.score || 0);
648
+ })
649
+ .slice(0, topK);
650
+ }
651
+
652
+ /** Entity-name hit → that entity plus unexpired business-edge neighbors. Empty if query names none. */
653
+ export function namedRecallHits(db, query, { topK = 8, now } = {}) {
654
+ const text = String(query || "").trim();
655
+ const ids = entityAnchorIds(db, text);
656
+ if (!ids.length) return [];
657
+ const at = now instanceof Date ? now : new Date();
658
+ const out = new Map();
659
+ for (const id of ids) {
660
+ const rec = hitRecord(db, id);
661
+ if (rec) {
662
+ rec.score = 4;
663
+ rec.query = text;
664
+ rec.now = at;
665
+ rec.fromAnchor = true;
666
+ out.set(rec.id, rec);
667
+ }
668
+ for (const nid of businessNeighborIds(db, id)) {
669
+ if (out.has(nid)) continue;
670
+ const nrec = hitRecord(db, nid);
671
+ if (!nrec) continue;
672
+ nrec.score = 1;
673
+ nrec.fromExpand = true;
674
+ nrec.query = text;
675
+ nrec.now = at;
676
+ out.set(nid, nrec);
677
+ }
678
+ }
679
+ return [...out.values()]
680
+ .filter((hit) => !shouldHideStale(hit, text, at))
681
+ .slice(0, topK);
682
+ }
683
+
684
+ function hasBusinessEdge(db, hit) {
685
+ const want = new Set(BUSINESS_EDGES);
686
+ if ((hit.edges || []).some((e) => want.has(e.label))) return true;
687
+ return incomingBusinessEdges(db, hit.id).length > 0;
688
+ }
689
+
690
+ function shouldHideStale(hit, query, now) {
691
+ if (hit.payload?.type !== "decision") return false;
692
+ if (!isStalePayload(hit.payload, now)) return false;
693
+ return !queryMentionsUntil(query, hit.payload);
694
+ }
695
+
696
+ function rankBoost(db, hit, query, now) {
697
+ let n = 0;
698
+ if (hasBusinessEdge(db, hit)) n += 4;
699
+ const stale = isStalePayload(hit.payload, now);
700
+ if (stale) n -= 6;
701
+ else if (hit.payload?.until) n += 1;
702
+ if (stale && queryMentionsUntil(query, hit.payload)) n += 3;
703
+ return n;
704
+ }
705
+
706
+ function hitRecord(db, id) {
707
+ const node = db.get(Number(id));
708
+ if (!node || node.payload?.status === "archived" || node.payload?.type === "workspace") return null;
709
+ return {
710
+ id: Number(id),
711
+ score: 0,
712
+ payload: node.payload,
713
+ edges: node.edges || [],
714
+ };
715
+ }
716
+
717
+ const MIN_ENTITY_ANCHOR = 3;
718
+
719
+ function entityAnchorLabels(payload) {
720
+ return uniqueStrings([payload?.name, ...(payload?.aliases || [])]).filter(
721
+ (s) => s.length >= MIN_ENTITY_ANCHOR,
722
+ );
723
+ }
724
+
725
+ function queryMentionsEntityName(queryLower, nameLower) {
726
+ if (!queryLower || !nameLower || nameLower.length < MIN_ENTITY_ANCHOR) return false;
727
+ if (queryLower === nameLower) return true;
728
+ if (/^[\x00-\x7f]+$/.test(nameLower)) {
729
+ const escaped = nameLower.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
730
+ return new RegExp(`(?:^|[^a-z0-9_])${escaped}(?:$|[^a-z0-9_])`, "i").test(queryLower);
731
+ }
732
+ return queryLower.includes(nameLower);
733
+ }
734
+
735
+ function entityAnchorScore(payload, queryLower) {
736
+ if (payload?.type !== "entity") return 0;
737
+ let best = 0;
738
+ for (const label of entityAnchorLabels(payload)) {
739
+ const name = label.toLowerCase();
740
+ if (name === queryLower) best = Math.max(best, 4);
741
+ else if (queryMentionsEntityName(queryLower, name)) best = Math.max(best, 3);
742
+ }
743
+ return best;
744
+ }
745
+
746
+ function entityAnchorIds(db, query) {
747
+ const q = String(query || "").trim().toLowerCase();
748
+ if (!q) return [];
749
+ const ids = [];
750
+ for (const id of db.allNodeIds?.() || []) {
751
+ const node = db.get(id);
752
+ if (node?.payload?.status === "archived") continue;
753
+ if (entityAnchorScore(node?.payload, q) > 0) ids.push(Number(id));
754
+ }
755
+ return ids;
756
+ }
757
+
758
+ function primaryScore(payload, query, now) {
759
+ const q = String(query || "").trim().toLowerCase();
760
+ if (!q) return 0;
761
+ const name = String(payload?.name || "").toLowerCase();
762
+ const text = String(payload?.text || "").toLowerCase();
763
+ const until = String(payload?.until || "").toLowerCase();
764
+ let score = 0;
765
+ if (name === q) score = 4;
766
+ else if (name.includes(q)) score = 3;
767
+ else if (text.includes(q) || until.includes(q)) score = 2;
768
+ else if (indexTextFor(payload || {}).toLowerCase().includes(q)) score = 2;
769
+ if (
770
+ payload?.type === "decision" &&
771
+ isStalePayload(payload, now) &&
772
+ queryMentionsUntil(query, payload)
773
+ ) {
774
+ score = Math.max(score, 2);
775
+ }
776
+ return Math.max(score, entityAnchorScore(payload, q));
777
+ }
778
+
779
+ function scanBusinessNeighbors(db, id) {
780
+ const ids = new Set();
781
+ const want = new Set(BUSINESS_EDGES);
782
+ const node = db.get(Number(id));
783
+ for (const e of node?.edges || []) {
784
+ if (want.has(e.label)) ids.add(Number(e.targetId ?? e.target_id));
785
+ }
786
+ for (const other of db.allNodeIds?.() || []) {
787
+ if (Number(other) === Number(id)) continue;
788
+ const n = db.get(other);
789
+ for (const e of n?.edges || []) {
790
+ if (want.has(e.label) && Number(e.targetId ?? e.target_id) === Number(id)) {
791
+ ids.add(Number(other));
792
+ }
793
+ }
794
+ }
795
+ return ids;
796
+ }
797
+
798
+ function businessNeighborIds(db, id) {
799
+ const ids = scanBusinessNeighbors(db, id);
800
+ const viaTql = tqlBusinessNeighbors(db, id);
801
+ if (viaTql) {
802
+ for (const nid of viaTql) ids.add(Number(nid));
803
+ }
804
+ return [...ids].filter((n) => Number.isFinite(n));
805
+ }
806
+
807
+ function tqlBusinessNeighbors(db, id) {
808
+ if (typeof db.tql !== "function") return null;
809
+ const nid = Number(id);
810
+ const found = new Set();
811
+ try {
812
+ for (const label of BUSINESS_EDGES) {
813
+ const outgoing = db.tql(`MATCH (a)-[:${label}]->(b) WHERE a.id == ${nid} RETURN b`);
814
+ const incoming = db.tql(`MATCH (a)-[:${label}]->(b) WHERE b.id == ${nid} RETURN a`);
815
+ for (const row of [...normalizeTql(outgoing), ...normalizeTql(incoming)]) {
816
+ if (row.id !== nid) found.add(row.id);
817
+ }
818
+ }
819
+ return [...found];
820
+ } catch {
821
+ return null;
822
+ }
823
+ }
824
+
825
+ function keywordFallback(db, text, topK) {
826
+ const q = text.toLowerCase();
827
+ const ids = db.allNodeIds?.() || [];
828
+ const hits = [];
829
+ for (const id of ids) {
830
+ const node = db.get(id);
831
+ if (!node || node.payload?.status === "archived" || node.payload?.type === "workspace") continue;
832
+ const hay = indexTextFor(node.payload || {}).toLowerCase();
833
+ if (!hay.includes(q)) continue;
834
+ hits.push({
835
+ id,
836
+ score: 1,
837
+ payload: node.payload,
838
+ edges: node.edges || [],
839
+ });
840
+ if (hits.length >= topK) break;
841
+ }
842
+ return hits;
843
+ }
844
+
845
+ function nodeLabel(db, id) {
846
+ const nid = Number(id);
847
+ let label = String(nid);
848
+ try {
849
+ const payload = db.getPayload(nid) || db.get(nid)?.payload;
850
+ const name = payload?.name || payload?.text;
851
+ if (name) label = `${nid}(${String(name).slice(0, 24)})`;
852
+ } catch {
853
+ // keep numeric id
854
+ }
855
+ return label;
856
+ }
857
+
858
+ function sameAsTarget(db, id) {
859
+ const node = db.get(Number(id));
860
+ for (const e of node?.edges || []) {
861
+ if (e.label === EDGE_LABELS.sameAs) return Number(e.targetId ?? e.target_id);
862
+ }
863
+ return null;
864
+ }
865
+
866
+ function incomingBusinessEdges(db, id, { query = "", now, hideStale = false } = {}) {
867
+ const nid = Number(id);
868
+ const want = new Set(BUSINESS_EDGES);
869
+ const found = [];
870
+ const seen = new Set();
871
+ const at = now instanceof Date ? now : now ? new Date(now) : new Date();
872
+ const add = (fromId, label) => {
873
+ const src = Number(fromId);
874
+ if (!Number.isFinite(src) || src === nid || !want.has(label)) return;
875
+ const srcNode = db.get(src);
876
+ if (!srcNode || srcNode.payload?.status === "archived") return;
877
+ if (
878
+ hideStale &&
879
+ isStalePayload(srcNode.payload, at) &&
880
+ !queryMentionsUntil(query, srcNode.payload)
881
+ ) {
882
+ return;
883
+ }
884
+ const key = `${src}|${label}`;
885
+ if (seen.has(key)) return;
886
+ seen.add(key);
887
+ found.push({ fromId: src, label, payload: srcNode.payload });
888
+ };
889
+ if (typeof db.tql === "function") {
890
+ try {
891
+ for (const label of BUSINESS_EDGES) {
892
+ const incoming = db.tql(`MATCH (a)-[:${label}]->(b) WHERE b.id == ${nid} RETURN a`);
893
+ for (const row of normalizeTql(incoming)) add(row.id, label);
894
+ }
895
+ } catch {
896
+ // scan below
897
+ }
898
+ }
899
+ for (const other of db.allNodeIds?.() || []) {
900
+ if (Number(other) === nid) continue;
901
+ const n = db.get(other);
902
+ if (!n || n.payload?.status === "archived") continue;
903
+ for (const e of n.edges || []) {
904
+ if (want.has(e.label) && Number(e.targetId ?? e.target_id) === nid) {
905
+ add(other, e.label);
906
+ }
907
+ }
908
+ }
909
+ return found;
910
+ }
911
+
912
+ function edgePath(db, nodeId, edges, { query = "", now, hideStale = true } = {}) {
913
+ const priority = {
914
+ about: 0,
915
+ decided: 1,
916
+ broke: 2,
917
+ fixed: 3,
918
+ same_as: 4,
919
+ in_workspace: 9,
920
+ };
921
+ const parts = [];
922
+ for (const e of incomingBusinessEdges(db, nodeId, { query, now, hideStale })) {
923
+ parts.push({
924
+ sort: priority[e.label] ?? 5,
925
+ text: `<-${e.label}-${nodeLabel(db, e.fromId)}`,
926
+ });
927
+ }
928
+ const seenOut = new Set();
929
+ for (const e of edges || []) {
930
+ const targetId = e.targetId ?? e.target_id;
931
+ const key = `${e.label}->${targetId}`;
932
+ if (seenOut.has(key)) continue;
933
+ seenOut.add(key);
934
+ parts.push({
935
+ sort: priority[e.label] ?? 5,
936
+ text: `${e.label}->${nodeLabel(db, targetId)}`,
937
+ });
938
+ }
939
+ return parts
940
+ .sort((a, b) => a.sort - b.sort)
941
+ .slice(0, 8)
942
+ .map((p) => p.text);
943
+ }
944
+
945
+ export function listOutgoingBusiness(db, id) {
946
+ const want = new Set(BUSINESS_EDGES);
947
+ const node = db.get(Number(id));
948
+ const out = [];
949
+ const seen = new Set();
950
+ for (const e of node?.edges || []) {
951
+ if (!want.has(e.label)) continue;
952
+ const tid = Number(e.targetId ?? e.target_id);
953
+ if (!Number.isFinite(tid)) continue;
954
+ const key = `${e.label}:${tid}`;
955
+ if (seen.has(key)) continue;
956
+ seen.add(key);
957
+ const payload = db.get(tid)?.payload;
958
+ if (!payload || payload.status === "archived") continue;
959
+ out.push({
960
+ to: tid,
961
+ label: e.label,
962
+ type: payload.type || "unknown",
963
+ l0: String(payload.text || payload.name || "").slice(0, 200),
964
+ });
965
+ }
966
+ return out;
967
+ }
968
+
969
+ export function listIncomingBusiness(db, id, opts = {}) {
970
+ return incomingBusinessEdges(db, id, opts).map((e) => ({
971
+ from: e.fromId,
972
+ label: e.label,
973
+ type: e.payload?.type || "unknown",
974
+ l0: String(e.payload?.text || e.payload?.name || "").slice(0, 200),
975
+ }));
976
+ }
977
+
978
+ export function formatHit(db, hit, opts = {}) {
979
+ const node = db.get(hit.id) || { payload: hit.payload, edges: [] };
980
+ const payload = node.payload || {};
981
+ const l0 = payload.text || payload.name || "";
982
+ const query = opts.query ?? hit.query ?? "";
983
+ const now = opts.now ?? hit.now;
984
+ const stale = isStalePayload(payload, now instanceof Date ? now : undefined);
985
+ return {
986
+ id: hit.id,
987
+ type: payload.type || "unknown",
988
+ score: Number(hit.score || 0),
989
+ l0: l0.slice(0, 200),
990
+ until: payload.until || "",
991
+ stale,
992
+ path: edgePath(db, hit.id, node.edges || [], { query, now }),
993
+ };
994
+ }
995
+
996
+ export function buildShortMapReport(db, budgetTokens = 400) {
997
+ const counts = { entity: 0, preference: 0, decision: 0, experience: 0 };
998
+ const buckets = { preference: [], decisionUntil: [], decision: [], entity: [], experience: [] };
999
+ for (const type of ["preference", "decision", "entity", "experience"]) {
1000
+ const nodes = findByType(db, type);
1001
+ counts[type] = nodes.length;
1002
+ for (const n of nodes) {
1003
+ if (type === "decision" && isStalePayload(n.payload)) continue;
1004
+ let label = n.payload?.name || n.payload?.text;
1005
+ if (!label) continue;
1006
+ if (type === "decision" && n.payload?.until) {
1007
+ const core = String(label).replace(/\s+/g, " ").slice(0, 28);
1008
+ label = `${core} until ${n.payload.until}`;
1009
+ buckets.decisionUntil.push(`decision:${label}`);
1010
+ } else {
1011
+ buckets[type].push(`${type}:${String(label).slice(0, 40)}`);
1012
+ }
1013
+ }
1014
+ }
1015
+ const names = [];
1016
+ const take = (list, max) => {
1017
+ let n = max;
1018
+ while (list.length && n > 0 && names.length < 8) {
1019
+ names.push(list.shift());
1020
+ n -= 1;
1021
+ }
1022
+ };
1023
+ take(buckets.preference, 4);
1024
+ take(buckets.decisionUntil, 2);
1025
+ take(buckets.entity, 8);
1026
+ take(buckets.decision, 8);
1027
+ take(buckets.experience, 8);
1028
+ take(buckets.preference, 8);
1029
+ take(buckets.decisionUntil, 8);
1030
+ const lines = [
1031
+ "dsh-trivium memory map (use ctx_find naming an entity for unexpired neighbors; ctx_read for full text).",
1032
+ `entities=${counts.entity} preferences=${counts.preference} decisions=${counts.decision} experiences=${counts.experience}`,
1033
+ ];
1034
+ if (names.length) lines.push(`named: ${names.join("; ")}`);
1035
+ const text = clipToTokenBudget(lines.join("\n"), budgetTokens);
1036
+ return { text, tokens: estimateTokens(text), counts };
1037
+ }
1038
+
1039
+ export function buildShortMap(db, budgetChars = 1600) {
1040
+ const tokens = Math.max(1, Math.floor(Number(budgetChars) / 4) || 400);
1041
+ return buildShortMapReport(db, tokens).text;
1042
+ }