dsh-trivium 0.4.9 → 0.4.13

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/sidecar.js ADDED
@@ -0,0 +1,422 @@
1
+ /** Git-friendly JSONL sidecar next to trivium.tdb. No embeddings, episodes, or chip pins. */
2
+
3
+ import { createHash } from "node:crypto";
4
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
5
+ import { dirname, join, resolve } from "node:path";
6
+ import { BUSINESS_EDGES } from "./schema.js";
7
+ import {
8
+ gitSidecarEnabledOf,
9
+ listedWorkspaces,
10
+ readUiSettings,
11
+ sidecarHashOf,
12
+ writeSidecarHash,
13
+ } from "./settings.js";
14
+
15
+ export const SIDECAR_FORMAT = "dsh-trivium-jsonl";
16
+ export const SIDECAR_VERSION = 1;
17
+ export const SIDECAR_DEBOUNCE_MS = 1500;
18
+ export const BUSINESS_NODE_TYPES = Object.freeze(["entity", "preference", "decision", "experience"]);
19
+
20
+ const timers = new Map();
21
+ const memoryHash = new Map();
22
+
23
+ export function sidecarPath(cwd) {
24
+ return join(resolve(cwd || ""), ".dsh", "trivium.jsonl");
25
+ }
26
+
27
+ export function hashText(text) {
28
+ return createHash("sha256").update(String(text || ""), "utf8").digest("hex");
29
+ }
30
+
31
+ function isBusinessType(type) {
32
+ return BUSINESS_NODE_TYPES.includes(String(type || ""));
33
+ }
34
+
35
+ function payloadOf(db, id) {
36
+ return db.get(Number(id))?.payload || {};
37
+ }
38
+
39
+ function edgeTarget(e) {
40
+ return Number(e?.targetId ?? e?.target_id);
41
+ }
42
+
43
+ function mintUri(payload) {
44
+ const h = createHash("sha256")
45
+ .update(
46
+ [payload.type || "", payload.name || "", payload.text || "", payload.createdAt || ""].join("\0"),
47
+ "utf8",
48
+ )
49
+ .digest("hex")
50
+ .slice(0, 16);
51
+ return `ctx://${payload.type}/${h}`;
52
+ }
53
+
54
+ function nodeRecord(payload) {
55
+ const rec = {
56
+ op: "node",
57
+ uri: payload.uri,
58
+ type: payload.type,
59
+ name: payload.name || "",
60
+ text: payload.text || "",
61
+ status: payload.status || "active",
62
+ };
63
+ const aliases = Array.isArray(payload.aliases)
64
+ ? payload.aliases.map((a) => String(a || "").trim()).filter(Boolean)
65
+ : [];
66
+ if (aliases.length) rec.aliases = aliases;
67
+ if (payload.until) rec.until = payload.until;
68
+ if (payload.untilAt) rec.untilAt = payload.untilAt;
69
+ if (payload.fail) rec.fail = payload.fail;
70
+ if (payload.fix) rec.fix = payload.fix;
71
+ if (payload.createdAt) rec.createdAt = payload.createdAt;
72
+ if (payload.updatedAt) rec.updatedAt = payload.updatedAt;
73
+ return rec;
74
+ }
75
+
76
+ /** Assign stable uris on business nodes that still lack one. Flushes the db; does not schedule a sidecar write. */
77
+ export function ensureBusinessUris(db) {
78
+ let changed = 0;
79
+ const used = new Set();
80
+ for (const id of db.allNodeIds?.() || []) {
81
+ const payload = payloadOf(db, id);
82
+ if (!isBusinessType(payload.type)) continue;
83
+ const uri = String(payload.uri || "").trim();
84
+ if (uri) used.add(uri);
85
+ }
86
+ for (const id of db.allNodeIds?.() || []) {
87
+ const payload = payloadOf(db, id);
88
+ if (!isBusinessType(payload.type) || payload.status === "archived") continue;
89
+ if (String(payload.uri || "").trim()) continue;
90
+ let uri = mintUri(payload);
91
+ let n = 0;
92
+ while (used.has(uri)) {
93
+ n += 1;
94
+ uri = `${mintUri(payload)}-${n}`;
95
+ }
96
+ used.add(uri);
97
+ db.updatePayload(Number(id), { ...payload, uri });
98
+ changed += 1;
99
+ }
100
+ if (changed) db.flush();
101
+ return changed;
102
+ }
103
+
104
+ export function serializeGraph(db) {
105
+ const nodes = [];
106
+ const uriOf = new Map();
107
+ for (const id of db.allNodeIds?.() || []) {
108
+ const payload = payloadOf(db, id);
109
+ if (!isBusinessType(payload.type) || payload.status === "archived") continue;
110
+ const uri = String(payload.uri || "").trim();
111
+ if (!uri) continue;
112
+ uriOf.set(Number(id), uri);
113
+ nodes.push(nodeRecord(payload));
114
+ }
115
+ nodes.sort((a, b) => String(a.uri).localeCompare(String(b.uri)));
116
+ const edges = [];
117
+ const seen = new Set();
118
+ const want = new Set(BUSINESS_EDGES);
119
+ for (const id of db.allNodeIds?.() || []) {
120
+ const fromUri = uriOf.get(Number(id));
121
+ if (!fromUri) continue;
122
+ const node = db.get(Number(id));
123
+ for (const e of node?.edges || []) {
124
+ const label = e.label || "";
125
+ if (!want.has(label)) continue;
126
+ const toUri = uriOf.get(edgeTarget(e));
127
+ if (!toUri || toUri === fromUri) continue;
128
+ const key = `${fromUri}\0${toUri}\0${label}`;
129
+ if (seen.has(key)) continue;
130
+ seen.add(key);
131
+ edges.push({ op: "edge", from: fromUri, to: toUri, type: label });
132
+ }
133
+ }
134
+ edges.sort((a, b) => {
135
+ const x = String(a.from).localeCompare(String(b.from));
136
+ if (x) return x;
137
+ const y = String(a.to).localeCompare(String(b.to));
138
+ if (y) return y;
139
+ return String(a.type).localeCompare(String(b.type));
140
+ });
141
+ const header = { v: SIDECAR_VERSION, format: SIDECAR_FORMAT };
142
+ return [header, ...nodes, ...edges].map((row) => JSON.stringify(row)).join("\n") + "\n";
143
+ }
144
+
145
+ export function parseSidecar(text) {
146
+ const lines = String(text || "").split(/\r?\n/);
147
+ const nodes = [];
148
+ const edges = [];
149
+ let header = null;
150
+ for (const raw of lines) {
151
+ const line = raw.trim();
152
+ if (!line) continue;
153
+ let row;
154
+ try {
155
+ row = JSON.parse(line);
156
+ } catch {
157
+ return { ok: false, message: "invalid jsonl" };
158
+ }
159
+ if (!row || typeof row !== "object") return { ok: false, message: "invalid jsonl" };
160
+ if (row.format === SIDECAR_FORMAT || (row.v && !row.op && !row.uri)) {
161
+ if (header) return { ok: false, message: "duplicate header" };
162
+ header = row;
163
+ continue;
164
+ }
165
+ const op = row.op || (row.uri && row.type ? "node" : row.from && row.to ? "edge" : "");
166
+ if (op === "node") {
167
+ if (!isBusinessType(row.type) || !String(row.uri || "").trim()) {
168
+ return { ok: false, message: "invalid node" };
169
+ }
170
+ nodes.push(row);
171
+ continue;
172
+ }
173
+ if (op === "edge") {
174
+ const label = row.type || row.label || "";
175
+ if (!BUSINESS_EDGES.includes(label) || !row.from || !row.to) {
176
+ return { ok: false, message: "invalid edge" };
177
+ }
178
+ edges.push({ from: row.from, to: row.to, type: label });
179
+ continue;
180
+ }
181
+ return { ok: false, message: "unknown jsonl row" };
182
+ }
183
+ if (!header || header.format !== SIDECAR_FORMAT) {
184
+ return { ok: false, message: "missing sidecar header" };
185
+ }
186
+ return { ok: true, header, nodes, edges };
187
+ }
188
+
189
+ function readSidecarFile(cwd) {
190
+ const file = sidecarPath(cwd);
191
+ if (!existsSync(file)) return null;
192
+ try {
193
+ return readFileSync(file, "utf8");
194
+ } catch {
195
+ return null;
196
+ }
197
+ }
198
+
199
+ function atomicWrite(file, text) {
200
+ mkdirSync(dirname(file), { recursive: true });
201
+ const tmp = `${file}.tmp`;
202
+ writeFileSync(tmp, text, "utf8");
203
+ try {
204
+ renameSync(tmp, file);
205
+ } catch {
206
+ try {
207
+ unlinkSync(file);
208
+ } catch {
209
+ // dest may not exist
210
+ }
211
+ renameSync(tmp, file);
212
+ }
213
+ }
214
+
215
+ function rememberHash(cwd, hash) {
216
+ const key = resolve(cwd);
217
+ memoryHash.set(key, hash);
218
+ try {
219
+ writeSidecarHash(key, hash);
220
+ } catch {
221
+ // home settings write is best-effort
222
+ }
223
+ }
224
+
225
+ function knownHash(cwd) {
226
+ const key = resolve(cwd);
227
+ return memoryHash.get(key) || sidecarHashOf(key) || "";
228
+ }
229
+
230
+ function unlinkIfExists(path) {
231
+ try {
232
+ unlinkSync(path);
233
+ return true;
234
+ } catch (err) {
235
+ if (err && err.code === "ENOENT") return false;
236
+ throw err;
237
+ }
238
+ }
239
+
240
+ function clearSidecarState(cwd) {
241
+ const key = resolve(cwd || "");
242
+ const prev = timers.get(key);
243
+ if (prev) {
244
+ clearTimeout(prev);
245
+ timers.delete(key);
246
+ }
247
+ memoryHash.delete(key);
248
+ try {
249
+ writeSidecarHash(key, "");
250
+ } catch {
251
+ // home settings write is best-effort
252
+ }
253
+ }
254
+
255
+ export function deleteSidecarAt(cwd) {
256
+ const key = resolve(cwd || "");
257
+ if (!key) return { path: "", deleted: false };
258
+ clearSidecarState(key);
259
+ const file = sidecarPath(key);
260
+ const deletedFile = unlinkIfExists(file);
261
+ const deletedTmp = unlinkIfExists(`${file}.tmp`);
262
+ return { path: file, deleted: deletedFile || deletedTmp };
263
+ }
264
+
265
+ export function deleteKnownSidecars(extraCwds = []) {
266
+ const seen = new Set();
267
+ const files = [];
268
+ const add = (cwd) => {
269
+ const key = String(cwd || "").trim();
270
+ if (!key) return;
271
+ const resolved = resolve(key);
272
+ if (seen.has(resolved)) return;
273
+ seen.add(resolved);
274
+ const result = deleteSidecarAt(resolved);
275
+ if (result.deleted) files.push(result.path);
276
+ };
277
+ try {
278
+ for (const cwd of listedWorkspaces()) add(cwd);
279
+ } catch {
280
+ // listing is best-effort
281
+ }
282
+ for (const cwd of extraCwds) add(cwd);
283
+ return files;
284
+ }
285
+
286
+ export function writeSidecarNow(cwd, db, { force = false } = {}) {
287
+ if (!cwd || !db) return null;
288
+ if (!gitSidecarEnabledOf(readUiSettings())) return null;
289
+ ensureBusinessUris(db);
290
+ const text = serializeGraph(db);
291
+ const hash = hashText(text);
292
+ const file = sidecarPath(cwd);
293
+ if (!force && hash === knownHash(cwd) && existsSync(file)) {
294
+ return { path: file, hash, wrote: false };
295
+ }
296
+ atomicWrite(file, text);
297
+ rememberHash(cwd, hash);
298
+ return { path: file, hash, wrote: true };
299
+ }
300
+
301
+ export function scheduleSidecarWrite(cwd, db) {
302
+ if (!cwd || !db) return;
303
+ if (!gitSidecarEnabledOf(readUiSettings())) return;
304
+ const key = resolve(cwd);
305
+ const prev = timers.get(key);
306
+ if (prev) clearTimeout(prev);
307
+ const timer = setTimeout(() => {
308
+ timers.delete(key);
309
+ try {
310
+ writeSidecarNow(key, db);
311
+ } catch {
312
+ // disk errors are non-fatal
313
+ }
314
+ }, SIDECAR_DEBOUNCE_MS);
315
+ timers.set(key, timer);
316
+ }
317
+
318
+ export function flushSidecarNow(cwd, db) {
319
+ const key = resolve(cwd || "");
320
+ const prev = timers.get(key);
321
+ if (prev) {
322
+ clearTimeout(prev);
323
+ timers.delete(key);
324
+ }
325
+ if (!cwd || !db) return null;
326
+ return writeSidecarNow(key, db);
327
+ }
328
+
329
+ async function applySidecar(db, parsed) {
330
+ const store = await import("./store.js");
331
+ const uriSet = new Set();
332
+ store.runWithoutSidecar(() => {
333
+ const idByUri = new Map();
334
+ for (const row of parsed.nodes) {
335
+ const uri = String(row.uri || "").trim();
336
+ uriSet.add(uri);
337
+ const payload = {
338
+ type: row.type,
339
+ name: row.name || "",
340
+ text: row.text || "",
341
+ uri,
342
+ status: row.status && row.status !== "archived" ? row.status : "active",
343
+ aliases: Array.isArray(row.aliases) ? row.aliases : [],
344
+ until: row.until || "",
345
+ untilAt: row.untilAt || "",
346
+ fail: row.fail || "",
347
+ fix: row.fix || "",
348
+ createdAt: row.createdAt || "",
349
+ updatedAt: row.updatedAt || "",
350
+ };
351
+ if (!payload.until) delete payload.until;
352
+ if (!payload.untilAt) delete payload.untilAt;
353
+ if (!payload.fail) delete payload.fail;
354
+ if (!payload.fix) delete payload.fix;
355
+ let dest = store.findByUri(db, uri);
356
+ if (dest == null) dest = store.findMergeTarget(db, payload);
357
+ if (dest != null) {
358
+ store.mergeInto(db, dest, payload);
359
+ idByUri.set(uri, dest);
360
+ } else {
361
+ idByUri.set(uri, store.insertNode(db, payload));
362
+ }
363
+ }
364
+ const wantEdges = new Set();
365
+ for (const e of parsed.edges) {
366
+ const from = idByUri.get(e.from) ?? store.findByUri(db, e.from);
367
+ const to = idByUri.get(e.to) ?? store.findByUri(db, e.to);
368
+ if (from == null || to == null || from === to) continue;
369
+ store.ensureLink(db, from, to, e.type);
370
+ wantEdges.add(`${e.from}\0${e.to}\0${e.type}`);
371
+ }
372
+ const uriById = new Map();
373
+ for (const id of db.allNodeIds?.() || []) {
374
+ const payload = payloadOf(db, id);
375
+ if (!isBusinessType(payload.type)) continue;
376
+ const uri = String(payload.uri || "").trim();
377
+ if (uri) uriById.set(Number(id), uri);
378
+ }
379
+ for (const id of db.allNodeIds?.() || []) {
380
+ const fromUri = uriById.get(Number(id));
381
+ if (!fromUri) continue;
382
+ const node = db.get(Number(id));
383
+ for (const e of node?.edges || []) {
384
+ const label = e.label || "";
385
+ if (!BUSINESS_EDGES.includes(label)) continue;
386
+ const toUri = uriById.get(edgeTarget(e));
387
+ if (!toUri) continue;
388
+ if (!wantEdges.has(`${fromUri}\0${toUri}\0${label}`)) {
389
+ store.dropLink(db, id, edgeTarget(e), label);
390
+ }
391
+ }
392
+ }
393
+ for (const id of db.allNodeIds?.() || []) {
394
+ const payload = payloadOf(db, id);
395
+ if (!isBusinessType(payload.type) || payload.status === "archived") continue;
396
+ const uri = String(payload.uri || "").trim();
397
+ if (!uri || !uriSet.has(uri)) store.archiveNode(db, id);
398
+ }
399
+ });
400
+ }
401
+
402
+ /**
403
+ * Import when the sidecar changed outside this process (clone / git pull).
404
+ * Write is skipped when the switch is off; import still runs.
405
+ */
406
+ export async function syncSidecarOnOpen(cwd, db) {
407
+ if (!cwd || !db) return { action: "skip" };
408
+ const key = resolve(cwd);
409
+ const text = readSidecarFile(key);
410
+ if (text == null) {
411
+ if (!gitSidecarEnabledOf(readUiSettings())) return { action: "skip" };
412
+ const wrote = writeSidecarNow(key, db);
413
+ return { action: "bootstrap-write", hash: wrote?.hash || "" };
414
+ }
415
+ const fileHash = hashText(text);
416
+ if (fileHash === knownHash(key)) return { action: "skip" };
417
+ const parsed = parseSidecar(text);
418
+ if (!parsed.ok) return { action: "skip-invalid", message: parsed.message };
419
+ await applySidecar(db, parsed);
420
+ rememberHash(key, fileHash);
421
+ return { action: "import", hash: fileHash, nodes: parsed.nodes.length, edges: parsed.edges.length };
422
+ }
package/lib/store.js CHANGED
@@ -2,6 +2,14 @@ import { mkdirSync } from "node:fs";
2
2
  import { createRequire } from "node:module";
3
3
  import { join, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { rememberWorkspace } from "./settings.js";
6
+ import {
7
+ deleteKnownSidecars,
8
+ flushSidecarNow,
9
+ scheduleSidecarWrite,
10
+ syncSidecarOnOpen,
11
+ writeSidecarNow,
12
+ } from "./sidecar.js";
5
13
  import { DIM, EDGE_LABELS, BUSINESS_EDGES, NODE_TYPES, indexTextFor, nowIso, weightOf, zeroVector } from "./schema.js";
6
14
  import { clipToTokenBudget, estimateTokens } from "./tokens.js";
7
15
  import { isStalePayload, parseUntilFromText, queryMentionsUntil, resolveUntilAt } from "./until.js";
@@ -31,6 +39,31 @@ function loadEngine() {
31
39
 
32
40
  const dbs = new Map();
33
41
  let activeCwd = null;
42
+ let sidecarPaused = false;
43
+
44
+ export function cwdForDb(db) {
45
+ for (const [cwd, inst] of dbs) {
46
+ if (inst === db) return cwd;
47
+ }
48
+ return "";
49
+ }
50
+
51
+ export function runWithoutSidecar(fn) {
52
+ const prev = sidecarPaused;
53
+ sidecarPaused = true;
54
+ try {
55
+ return fn();
56
+ } finally {
57
+ sidecarPaused = prev;
58
+ }
59
+ }
60
+
61
+ function persist(db) {
62
+ db.flush();
63
+ if (sidecarPaused) return;
64
+ const cwd = cwdForDb(db);
65
+ if (cwd) scheduleSidecarWrite(cwd, db);
66
+ }
34
67
 
35
68
  export function dbPathFor(cwd) {
36
69
  return join(resolve(cwd), ".dsh", "trivium.tdb");
@@ -53,6 +86,19 @@ export function listOpenWorkspaces() {
53
86
  }));
54
87
  }
55
88
 
89
+ /** Replace jsonl from the current .tdb. Overwrite in place (same path) so git diffs lines. */
90
+ export function generateSidecarFromDb(cwd, db) {
91
+ const wrote = writeSidecarNow(cwd, db, { force: true });
92
+ if (!wrote) return { ok: false, message: "git sidecar off" };
93
+ return { ok: true, action: "generate", cwd, path: wrote.path, wrote: wrote.wrote };
94
+ }
95
+
96
+ /** Delete known workspace trivium.jsonl files. .tdb stays. */
97
+ export function deleteGitSidecars() {
98
+ const files = deleteKnownSidecars([...dbs.keys(), activeCwd].filter(Boolean));
99
+ return { ok: true, action: "delete", files };
100
+ }
101
+
56
102
  export function nodeCountOf(db) {
57
103
  try {
58
104
  if (typeof db.nodeCount === "function") return db.nodeCount();
@@ -78,11 +124,26 @@ export async function openWorkspaceDb(cwd) {
78
124
  ensureWorkspaceRoot(db, cwd);
79
125
  rebuildTextIndex(db);
80
126
  dbs.set(key, db);
127
+ try {
128
+ rememberWorkspace(key);
129
+ } catch {
130
+ // listing is best-effort; opening the store still succeeds
131
+ }
132
+ try {
133
+ await syncSidecarOnOpen(key, db);
134
+ } catch {
135
+ // sidecar is best-effort; the .tdb still opens
136
+ }
81
137
  return db;
82
138
  }
83
139
 
84
140
  export function closeAll() {
85
- for (const db of dbs.values()) {
141
+ for (const [cwd, db] of dbs) {
142
+ try {
143
+ flushSidecarNow(cwd, db);
144
+ } catch {
145
+ // ignore
146
+ }
86
147
  try {
87
148
  db.flush();
88
149
  db.close();
@@ -257,7 +318,7 @@ export function mergeInto(db, id, incoming) {
257
318
  });
258
319
  db.updatePayload(Number(id), next);
259
320
  indexNode(db, Number(id), next);
260
- db.flush();
321
+ persist(db);
261
322
  queueEmbed(db, Number(id), next);
262
323
  return Number(id);
263
324
  }
@@ -333,7 +394,7 @@ export function archiveNode(db, id) {
333
394
  };
334
395
  db.updatePayload(Number(id), payload);
335
396
  rebuildTextIndex(db);
336
- db.flush();
397
+ persist(db);
337
398
  return true;
338
399
  }
339
400
 
@@ -342,7 +403,7 @@ export function deleteNode(db, id) {
342
403
  if (!db.contains?.(n) && !db.get(n)) return false;
343
404
  db.delete(n);
344
405
  rebuildTextIndex(db);
345
- db.flush();
406
+ persist(db);
346
407
  return true;
347
408
  }
348
409
 
@@ -384,7 +445,7 @@ export function updateNode(db, id, patch = {}) {
384
445
  }
385
446
  db.updatePayload(nid, body);
386
447
  indexNode(db, nid, body);
387
- db.flush();
448
+ persist(db);
388
449
  queueEmbed(db, nid, body);
389
450
  return body;
390
451
  }
@@ -535,7 +596,7 @@ export function ensureLink(db, from, to, label) {
535
596
  if (!Number.isFinite(src) || !Number.isFinite(dst) || src === dst) return false;
536
597
  if (hasEdge(db, src, dst, label)) return false;
537
598
  db.link(src, dst, label, weightOf(label));
538
- db.flush();
599
+ persist(db);
539
600
  return true;
540
601
  }
541
602
 
@@ -548,7 +609,7 @@ export function dropLink(db, from, to, label) {
548
609
  try {
549
610
  if (label) db.unlink(src, dst, label);
550
611
  else db.unlink(src, dst);
551
- db.flush();
612
+ persist(db);
552
613
  return true;
553
614
  } catch {
554
615
  return false;
@@ -591,7 +652,7 @@ export function insertNode(db, payload, { linkToWorkspace = true } = {}) {
591
652
  const root = workspaceId(db);
592
653
  if (root != null) ensureLink(db, id, root, EDGE_LABELS.inWorkspace);
593
654
  }
594
- db.flush();
655
+ persist(db);
595
656
  queueEmbed(db, id, body);
596
657
  return id;
597
658
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-trivium",
3
- "version": "0.4.9",
3
+ "version": "0.4.13",
4
4
  "description": "In-process graph memory for DeepSeek Harness, backed by TriviumDB.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -42,6 +42,9 @@
42
42
  "smoke-p7": "node scripts/smoke-p7.mjs",
43
43
  "smoke-p8": "node scripts/smoke-p8.mjs",
44
44
  "smoke-p10": "node scripts/smoke-p10.mjs",
45
+ "smoke-p11": "node scripts/smoke-p11.mjs",
46
+ "smoke-p12": "node scripts/smoke-p12.mjs",
47
+ "preuninstall": "node ./lib/purge.js",
45
48
  "verify-live-p2": "node scripts/verify-live-p2.mjs"
46
49
  },
47
50
  "engines": {