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/server.js CHANGED
@@ -19,11 +19,25 @@ import {
19
19
  } from "./store.js";
20
20
  import { embeddingPublicStatus } from "./embed.js";
21
21
  import { chipsEnabledOf, configPanelOpenOf, gitSidecarEnabledOf, listedWorkspaces, pluginLocaleOf, recallModeOf, sessionLayerEnabledOf, writeUiSettings } from "./settings.js";
22
+ import { TURN_ADDITIONAL_BUDGET } from "./context-budget.js";
22
23
  import { discoverWorkbuddyFiles, discoverExternalFiles, exportMarkdown, importWorkbuddy, importExternal } from "./markdown.js";
23
24
  import { checkNpmUpdate, installedVersion } from "./update.js";
24
25
  import { writeGateMessage } from "./hygiene.js";
25
26
  import { backfillSessionMap, cutSessionMap, renameEpisode, sessionMapSnapshot } from "./episode.js";
26
27
  import { applyPinsUpdate, addChipsFromText, batchChipAction, listChips, readSessionPins } from "./pins.js";
28
+ import {
29
+ historyOf,
30
+ nodeDetail,
31
+ REVISION_CODES,
32
+ revisionMessage,
33
+ revokeSupersession,
34
+ supersede,
35
+ withWorkspaceQueue,
36
+ } from "./revisions.js";
37
+ import { provenanceOf } from "./provenance.js";
38
+ import { getTrace, listTraces, listTracesForCwd } from "./recall-trace.js";
39
+ import { recallPreview } from "./recall-preview.js";
40
+ import { sidecarHealth } from "./sidecar.js";
27
41
 
28
42
  export { readUiSettings, writeUiSettings } from "./settings.js";
29
43
 
@@ -141,6 +155,22 @@ async function handle(req, res, { liveOptions, status }) {
141
155
  lastInjectTokens: status.lastInjectTokens || 0,
142
156
  lastInjectAt: status.lastInjectAt || null,
143
157
  lastExtract: status.lastExtract || null,
158
+ // Estimates, not model tokens: the UI must label them as such.
159
+ mapTokenBudget: opts.mapTokenBudget || 400,
160
+ turnTokenBudget: TURN_ADDITIONAL_BUDGET,
161
+ lastContextPlan: status.lastContextPlan
162
+ ? {
163
+ at: status.lastContextPlan.at,
164
+ tokens: status.lastContextPlan.tokens,
165
+ budget: status.lastContextPlan.budget,
166
+ records: status.lastContextPlan.records,
167
+ deferred: status.lastContextPlan.deferred,
168
+ }
169
+ : null,
170
+ preStep: status.preStep || null,
171
+ lastTrace: status.lastTrace
172
+ ? { traceId: status.lastTrace.traceId, at: status.lastTrace.at, tokens: status.lastTrace.tokens }
173
+ : null,
144
174
  version: installedVersion(),
145
175
  recallMode: mode,
146
176
  autoRecall: mode === "auto",
@@ -152,8 +182,10 @@ async function handle(req, res, { liveOptions, status }) {
152
182
  pluginLocale: pluginLocaleOf(opts),
153
183
  configPanelOpen: configPanelOpenOf(opts),
154
184
  writeApproval: !!opts.writeApproval,
155
- mapTokenBudget: opts.mapTokenBudget || 400,
156
185
  workspaces: listOpenWorkspaces(),
186
+ // The graph is authoritative; the JSONL file is derived. Report its state
187
+ // instead of pretending the two files are one atomic unit.
188
+ derived: { sidecar: sidecarHealth() },
157
189
  ...embeddingPublicStatus(),
158
190
  });
159
191
  return;
@@ -294,6 +326,8 @@ async function handle(req, res, { liveOptions, status }) {
294
326
  const q = url.searchParams.get("q") || "";
295
327
  const includeArchived = url.searchParams.get("archived") === "1";
296
328
  const includeStale = url.searchParams.get("stale") === "1";
329
+ // "Show expired" and "show replaced" are separate switches.
330
+ const includeSuperseded = url.searchParams.get("superseded") === "1";
297
331
  const about = url.searchParams.get("about") || "";
298
332
  sendJson(res, 200, {
299
333
  ok: true,
@@ -303,12 +337,115 @@ async function handle(req, res, { liveOptions, status }) {
303
337
  q: q || undefined,
304
338
  includeArchived,
305
339
  includeStale,
340
+ includeSuperseded,
306
341
  aboutId: about || undefined,
307
342
  }),
308
343
  });
309
344
  return;
310
345
  }
311
346
 
347
+ /*
348
+ * Detail / provenance / history are read-on-demand surfaces. The detail
349
+ * response is a header only; full quotes and full history bodies stay behind
350
+ * their own endpoints so the list cannot accidentally pull all of it.
351
+ */
352
+ const detailMatch = path.match(/^\/api\/dsh-trivium\/nodes\/(\d+)\/detail$/);
353
+ if (detailMatch && method === "GET") {
354
+ const { cwd, db } = await dbForRequest(url);
355
+ if (!db) {
356
+ sendJson(res, 400, { ok: false, message: "no workspace" });
357
+ return;
358
+ }
359
+ const detail = nodeDetail(db, detailMatch[1]);
360
+ sendJson(res, detail ? 200 : 404, detail || { ok: false, message: "not found" });
361
+ return;
362
+ }
363
+
364
+ const provenanceMatch = path.match(/^\/api\/dsh-trivium\/nodes\/(\d+)\/provenance$/);
365
+ if (provenanceMatch && method === "GET") {
366
+ const { db } = await dbForRequest(url);
367
+ if (!db) {
368
+ sendJson(res, 400, { ok: false, message: "no workspace" });
369
+ return;
370
+ }
371
+ const prov = provenanceOf(db, provenanceMatch[1]);
372
+ sendJson(res, prov ? 200 : 404, prov || { ok: false, message: "not found" });
373
+ return;
374
+ }
375
+
376
+ const historyMatch = path.match(/^\/api\/dsh-trivium\/nodes\/(\d+)\/history$/);
377
+ if (historyMatch && method === "GET") {
378
+ const { cwd, db } = await dbForRequest(url);
379
+ if (!db) {
380
+ sendJson(res, 400, { ok: false, message: "no workspace" });
381
+ return;
382
+ }
383
+ const result = historyOf(db, historyMatch[1], {
384
+ limit: url.searchParams.get("limit") || 20,
385
+ cursor: url.searchParams.get("cursor") || "",
386
+ });
387
+ if (!result.ok) {
388
+ sendJson(res, 409, { ok: false, reason: result.reason, message: "version chain unresolved", ...result });
389
+ return;
390
+ }
391
+ sendJson(res, 200, { ok: true, cwd, ...result });
392
+ return;
393
+ }
394
+
395
+ if (path === "/api/dsh-trivium/recall-traces" && method === "GET") {
396
+ const cwd = url.searchParams.get("cwd") || getActiveCwd() || "";
397
+ if (url.searchParams.get("cwd") && !isKnownCwd(cwd)) {
398
+ sendJson(res, 400, { ok: false, message: "unknown workspace" });
399
+ return;
400
+ }
401
+ const sessionId = url.searchParams.get("session") || "";
402
+ const limit = url.searchParams.get("limit") || undefined;
403
+ sendJson(res, 200, {
404
+ ok: true,
405
+ cwd,
406
+ sessionId,
407
+ // No session filter means "every session of this workspace", which is
408
+ // still one workspace boundary. A session filter narrows it further.
409
+ traces: sessionId
410
+ ? listTraces({ cwd, sessionId, limit })
411
+ : listTracesForCwd({ cwd, limit }),
412
+ });
413
+ return;
414
+ }
415
+
416
+ const traceMatch = path.match(/^\/api\/dsh-trivium\/recall-traces\/([A-Za-z0-9-]+)$/);
417
+ if (traceMatch && method === "GET") {
418
+ const cwd = url.searchParams.get("cwd") || getActiveCwd() || "";
419
+ const found = getTrace(traceMatch[1], { cwd, sessionId: url.searchParams.get("session") || "" });
420
+ if (!found.ok) {
421
+ // 410 for an expired id, 404 for another workspace/session: the caller
422
+ // learns nothing about traces that are not theirs.
423
+ sendJson(res, found.status, { ok: false, message: found.message });
424
+ return;
425
+ }
426
+ sendJson(res, 200, { ok: true, cwd, trace: found.trace });
427
+ return;
428
+ }
429
+
430
+ if (path === "/api/dsh-trivium/recall-preview" && method === "POST") {
431
+ const { cwd, db } = await dbForRequest(url);
432
+ if (!db) {
433
+ sendJson(res, 400, { ok: false, message: "no workspace" });
434
+ return;
435
+ }
436
+ const body = JSON.parse((await readBody(req)) || "{}");
437
+ const sessionId = body.sessionId || url.searchParams.get("sessionId") || "";
438
+ const opts = liveOptions();
439
+ const result = recallPreview(db, {
440
+ cwd,
441
+ sessionId,
442
+ query: body.query,
443
+ mode: body.mode || recallModeOf(opts),
444
+ });
445
+ sendJson(res, result.ok ? 200 : result.status || 400, { ...result, cwd, sessionId });
446
+ return;
447
+ }
448
+
312
449
  const mergeMatch = path.match(/^\/api\/dsh-trivium\/nodes\/(\d+)\/merge$/);
313
450
  if (mergeMatch && method === "POST") {
314
451
  const { db } = await dbForRequest(url);
@@ -318,7 +455,61 @@ async function handle(req, res, { liveOptions, status }) {
318
455
  }
319
456
  const body = JSON.parse((await readBody(req)) || "{}");
320
457
  const result = mergeNodes(db, mergeMatch[1], body.dropId);
321
- sendJson(res, result.ok ? 200 : 400, result);
458
+ sendJson(res, result.status || (result.ok ? 200 : 400), result);
459
+ return;
460
+ }
461
+
462
+ /*
463
+ * Revision writes. Every one of them is validated server-side inside the
464
+ * workspace queue: the client's hidden buttons and the prototype's demo
465
+ * state are not trusted, and the second window always loses on a stale ETag.
466
+ */
467
+ const supersedeMatch = path.match(/^\/api\/dsh-trivium\/nodes\/(\d+)\/supersede$/);
468
+ if (supersedeMatch && method === "POST") {
469
+ const { cwd, db } = await dbForRequest(url);
470
+ if (!db) {
471
+ sendJson(res, 400, { ok: false, message: "no workspace" });
472
+ return;
473
+ }
474
+ const body = JSON.parse((await readBody(req)) || "{}");
475
+ const result = await withWorkspaceQueue(cwd, () =>
476
+ supersede(db, {
477
+ oldId: supersedeMatch[1],
478
+ successorId: body.successorId,
479
+ reason: body.reason,
480
+ expectedEtags: body.expectedEtags,
481
+ operationId: body.operationId,
482
+ }),
483
+ );
484
+ sendJson(res, result.ok ? 200 : result.status || 400, {
485
+ ...result,
486
+ cwd,
487
+ detail: result.ok ? null : revisionMessage(result.code),
488
+ });
489
+ return;
490
+ }
491
+
492
+ const revokeMatch = path.match(/^\/api\/dsh-trivium\/nodes\/(\d+)\/supersession\/revoke$/);
493
+ if (revokeMatch && method === "POST") {
494
+ const { cwd, db } = await dbForRequest(url);
495
+ if (!db) {
496
+ sendJson(res, 400, { ok: false, message: "no workspace" });
497
+ return;
498
+ }
499
+ const body = JSON.parse((await readBody(req)) || "{}");
500
+ const result = await withWorkspaceQueue(cwd, () =>
501
+ revokeSupersession(db, {
502
+ oldId: revokeMatch[1],
503
+ successorId: body.successorId,
504
+ expectedEtags: body.expectedEtags,
505
+ operationId: body.operationId,
506
+ }),
507
+ );
508
+ sendJson(res, result.ok ? 200 : result.status || 400, {
509
+ ...result,
510
+ cwd,
511
+ detail: result.ok ? null : revisionMessage(result.code),
512
+ });
322
513
  return;
323
514
  }
324
515
 
@@ -351,8 +542,17 @@ async function handle(req, res, { liveOptions, status }) {
351
542
  return;
352
543
  }
353
544
  const id = Number(nodeMatch[1]);
354
- const ok = nodeMatch[2] === "archive" ? archiveNode(db, id) : deleteNode(db, id);
355
- sendJson(res, ok ? 200 : 404, { ok, id });
545
+ try {
546
+ const ok = nodeMatch[2] === "archive" ? archiveNode(db, id) : deleteNode(db, id);
547
+ sendJson(res, ok ? 200 : 404, { ok, id });
548
+ } catch (err) {
549
+ // A node inside a revision chain needs its relation handled first.
550
+ if (err?.code === "REVISION_LINKED") {
551
+ sendJson(res, 409, { ok: false, id, code: err.code, message: revisionMessage(REVISION_CODES.revisionLinked) });
552
+ return;
553
+ }
554
+ throw err;
555
+ }
356
556
  return;
357
557
  }
358
558
 
package/lib/sidecar.js CHANGED
@@ -4,7 +4,7 @@ import { createHash } from "node:crypto";
4
4
  import { existsSync, readFileSync, unlinkSync } from "node:fs";
5
5
  import { join, resolve } from "node:path";
6
6
  import { atomicWriteFile } from "./atomic.js";
7
- import { BUSINESS_EDGES } from "./schema.js";
7
+ import { BUSINESS_EDGES, REVISION_EDGES, nowIso, weightOf } from "./schema.js";
8
8
  import {
9
9
  gitSidecarEnabledOf,
10
10
  listedWorkspaces,
@@ -14,13 +14,44 @@ import {
14
14
  } from "./settings.js";
15
15
 
16
16
  export const SIDECAR_FORMAT = "dsh-trivium-jsonl";
17
- export const SIDECAR_VERSION = 1;
17
+ /**
18
+ * v2 adds revision relations with their edge metadata. A v1 file is still read:
19
+ * it simply carries no supersedes rows. Downgrading is not supported in place —
20
+ * restore the pre-upgrade file group (see README) rather than letting an older
21
+ * build rewrite the chain away.
22
+ */
23
+ export const SIDECAR_VERSION = 2;
18
24
  export const SIDECAR_DEBOUNCE_MS = 1500;
19
25
  export const BUSINESS_NODE_TYPES = Object.freeze(["entity", "preference", "decision", "experience"]);
20
26
 
21
27
  const timers = new Map();
22
28
  const memoryHash = new Map();
23
29
 
30
+ /**
31
+ * Whether the derived JSONL file is currently in step with the graph.
32
+ *
33
+ * The graph is authoritative; this file is a projection written after the
34
+ * fact. A failure here is reported rather than hidden, and the next open
35
+ * (`syncSidecarOnOpen`) repairs it.
36
+ */
37
+ const health = { ok: true, error: "", at: "" };
38
+
39
+ function noteSidecarError(err) {
40
+ health.ok = false;
41
+ health.error = String(err?.message || err || "sidecar write failed");
42
+ health.at = new Date().toISOString();
43
+ }
44
+
45
+ function noteSidecarOk() {
46
+ health.ok = true;
47
+ health.error = "";
48
+ health.at = new Date().toISOString();
49
+ }
50
+
51
+ export function sidecarHealth() {
52
+ return { ...health, enabled: gitSidecarEnabledOf(readUiSettings()) };
53
+ }
54
+
24
55
  export function sidecarPath(cwd) {
25
56
  return join(resolve(cwd || ""), ".dsh", "trivium.jsonl");
26
57
  }
@@ -116,7 +147,9 @@ export function serializeGraph(db) {
116
147
  nodes.sort((a, b) => String(a.uri).localeCompare(String(b.uri)));
117
148
  const edges = [];
118
149
  const seen = new Set();
119
- const want = new Set(BUSINESS_EDGES);
150
+ // Business edges plus revision relations: a chain has to survive a clone, and
151
+ // the relation reason lives in the edge metadata.
152
+ const want = new Set([...BUSINESS_EDGES, ...REVISION_EDGES]);
120
153
  for (const id of db.allNodeIds?.() || []) {
121
154
  const fromUri = uriOf.get(Number(id));
122
155
  if (!fromUri) continue;
@@ -129,7 +162,9 @@ export function serializeGraph(db) {
129
162
  const key = `${fromUri}\0${toUri}\0${label}`;
130
163
  if (seen.has(key)) continue;
131
164
  seen.add(key);
132
- edges.push({ op: "edge", from: fromUri, to: toUri, type: label });
165
+ const row = { op: "edge", from: fromUri, to: toUri, type: label };
166
+ if (e.metadata && typeof e.metadata === "object") row.meta = e.metadata;
167
+ edges.push(row);
133
168
  }
134
169
  }
135
170
  edges.sort((a, b) => {
@@ -173,10 +208,15 @@ export function parseSidecar(text) {
173
208
  }
174
209
  if (op === "edge") {
175
210
  const label = row.type || row.label || "";
176
- if (!BUSINESS_EDGES.includes(label) || !row.from || !row.to) {
211
+ if ((!BUSINESS_EDGES.includes(label) && !REVISION_EDGES.includes(label)) || !row.from || !row.to) {
177
212
  return { ok: false, message: "invalid edge" };
178
213
  }
179
- edges.push({ from: row.from, to: row.to, type: label });
214
+ edges.push({
215
+ from: row.from,
216
+ to: row.to,
217
+ type: label,
218
+ meta: row.meta && typeof row.meta === "object" ? row.meta : null,
219
+ });
180
220
  continue;
181
221
  }
182
222
  return { ok: false, message: "unknown jsonl row" };
@@ -276,10 +316,12 @@ export function writeSidecarNow(cwd, db, { force = false } = {}) {
276
316
  const hash = hashText(text);
277
317
  const file = sidecarPath(cwd);
278
318
  if (!force && hash === knownHash(cwd) && existsSync(file)) {
319
+ noteSidecarOk();
279
320
  return { path: file, hash, wrote: false };
280
321
  }
281
322
  atomicWriteFile(file, text);
282
323
  rememberHash(cwd, hash);
324
+ noteSidecarOk();
283
325
  return { path: file, hash, wrote: true };
284
326
  }
285
327
 
@@ -293,8 +335,11 @@ export function scheduleSidecarWrite(cwd, db) {
293
335
  timers.delete(key);
294
336
  try {
295
337
  writeSidecarNow(key, db);
296
- } catch {
297
- // disk errors are non-fatal
338
+ noteSidecarOk();
339
+ } catch (err) {
340
+ // Disk errors are non-fatal, but they are visible: status reports them and
341
+ // reopening the workspace retries the sync.
342
+ noteSidecarError(err);
298
343
  }
299
344
  }, SIDECAR_DEBOUNCE_MS);
300
345
  timers.set(key, timer);
@@ -314,6 +359,7 @@ export function flushSidecarNow(cwd, db) {
314
359
  async function applySidecar(db, parsed) {
315
360
  const store = await import("./store.js");
316
361
  const uriSet = new Set();
362
+ const conflicts = [];
317
363
  store.runWithoutSidecar(() => {
318
364
  const idByUri = new Map();
319
365
  for (const row of parsed.nodes) {
@@ -333,27 +379,63 @@ async function applySidecar(db, parsed) {
333
379
  createdAt: row.createdAt || "",
334
380
  updatedAt: row.updatedAt || "",
335
381
  };
336
- if (!payload.until) delete payload.until;
382
+ // Keep the empty label as an explicit clear when reconciling a snapshot.
337
383
  if (!payload.untilAt) delete payload.untilAt;
338
384
  if (!payload.fail) delete payload.fail;
339
385
  if (!payload.fix) delete payload.fix;
340
386
  let dest = store.findByUri(db, uri);
341
- if (dest == null) dest = store.findMergeTarget(db, payload);
387
+ if (dest == null) dest = store.findMergeTarget(db, payload, { allowRevisionLinked: true });
342
388
  if (dest != null) {
343
- store.mergeInto(db, dest, payload);
344
- idByUri.set(uri, dest);
389
+ try {
390
+ store.mergeInto(db, dest, payload);
391
+ idByUri.set(uri, dest);
392
+ } catch (err) {
393
+ // The file wants to rewrite a body that is confirmed history. Keep the
394
+ // live text, keep the store usable, and report the conflict.
395
+ if (err?.code === "REVISION_PROTECTED") {
396
+ conflicts.push({ code: "chain-body", uri });
397
+ idByUri.set(uri, dest);
398
+ } else {
399
+ throw err;
400
+ }
401
+ }
345
402
  } else {
346
403
  idByUri.set(uri, store.insertNode(db, payload));
347
404
  }
348
405
  }
349
406
  const wantEdges = new Set();
407
+ const wantRevision = new Set();
408
+ const revisionOps = [];
350
409
  for (const e of parsed.edges) {
351
410
  const from = idByUri.get(e.from) ?? store.findByUri(db, e.from);
352
411
  const to = idByUri.get(e.to) ?? store.findByUri(db, e.to);
353
- if (from == null || to == null || from === to) continue;
412
+ const revision = REVISION_EDGES.includes(e.type);
413
+ if (revision) wantRevision.add(`${e.from}\0${e.to}\0${e.type}`);
414
+ if (from == null || to == null || from === to) {
415
+ if (revision) conflicts.push({ code: "unmapped-relation", from: e.from, to: e.to });
416
+ continue;
417
+ }
418
+ if (revision) {
419
+ revisionOps.push({
420
+ type: "upsertEdge",
421
+ src: from,
422
+ dst: to,
423
+ label: e.type,
424
+ weight: weightOf(e.type),
425
+ metadata: e.meta || { at: nowIso(), reason: "", source: "sidecar" },
426
+ });
427
+ continue;
428
+ }
354
429
  store.ensureLink(db, from, to, e.type);
355
430
  wantEdges.add(`${e.from}\0${e.to}\0${e.type}`);
356
431
  }
432
+ if (revisionOps.length) {
433
+ try {
434
+ db.commitTransaction(revisionOps);
435
+ } catch (err) {
436
+ conflicts.push({ code: "relation-transaction-failed", message: String(err.message || err) });
437
+ }
438
+ }
357
439
  const uriById = new Map();
358
440
  for (const id of db.allNodeIds?.() || []) {
359
441
  const payload = payloadOf(db, id);
@@ -375,6 +457,22 @@ async function applySidecar(db, parsed) {
375
457
  }
376
458
  }
377
459
  }
460
+ /*
461
+ * A relation that exists locally but is absent from the file is NOT removed.
462
+ * Deleting a chain from a pull is a semantic change a snapshot cannot prove,
463
+ * so it is reported as a conflict and the user revokes it explicitly.
464
+ */
465
+ for (const id of db.allNodeIds?.() || []) {
466
+ const node = db.get(Number(id));
467
+ const fromUri = uriById.get(Number(id));
468
+ for (const edge of node?.edges || []) {
469
+ if (!REVISION_EDGES.includes(String(edge.label))) continue;
470
+ const toUri = uriById.get(edgeTarget(edge));
471
+ if (!fromUri || !toUri) continue;
472
+ if (wantRevision.has(`${fromUri}\0${toUri}\0${edge.label}`)) continue;
473
+ conflicts.push({ code: "relation-missing-in-file", from: fromUri, to: toUri });
474
+ }
475
+ }
378
476
  for (const id of db.allNodeIds?.() || []) {
379
477
  const payload = payloadOf(db, id);
380
478
  if (!isBusinessType(payload.type) || payload.status === "archived") continue;
@@ -382,6 +480,7 @@ async function applySidecar(db, parsed) {
382
480
  if (!uri || !uriSet.has(uri)) store.archiveNode(db, id);
383
481
  }
384
482
  });
483
+ return { conflicts };
385
484
  }
386
485
 
387
486
  /**
@@ -401,7 +500,15 @@ export async function syncSidecarOnOpen(cwd, db) {
401
500
  if (fileHash === knownHash(key)) return { action: "skip" };
402
501
  const parsed = parseSidecar(text);
403
502
  if (!parsed.ok) return { action: "skip-invalid", message: parsed.message };
404
- await applySidecar(db, parsed);
503
+ const applied = await applySidecar(db, parsed);
405
504
  rememberHash(key, fileHash);
406
- return { action: "import", hash: fileHash, nodes: parsed.nodes.length, edges: parsed.edges.length };
505
+ return {
506
+ action: "import",
507
+ hash: fileHash,
508
+ nodes: parsed.nodes.length,
509
+ edges: parsed.edges.length,
510
+ version: Number(parsed.header?.v) || 1,
511
+ // Conflicts are visible; the store stays usable and nothing is half-applied.
512
+ conflicts: applied?.conflicts || [],
513
+ };
407
514
  }