dsh-engram 0.3.4 → 0.3.6

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/tools.js CHANGED
@@ -1,24 +1,26 @@
1
1
  /**
2
2
  * dsh-engram: model-facing tools.
3
3
  *
4
- * Eight small, tight-schema tools (deliberately fewer than the market's 8-12 —
5
- * tool schemas cost tokens in every request):
4
+ * Fourteen small, tight-schema tools (schemas cost tokens in every request):
6
5
  * engram_store / engram_recall / engram_detail — the engram memory surface
7
- * esr_task / esr_node / esr_close / esr_link — the ESR-lite evidence surface
8
- * esr_ready / esr_status — the pull surface (live state + hints)
9
- * esr_gc — pi-esr memory garbage collection
6
+ * esr_task / esr_close / esr_link / esr_dep / esr_claim / esr_unclaim /
7
+ * esr_node / esr_ready / esr_status / esr_gc / esr_model — ESR-lite surface
10
8
  *
11
9
  * Every call is recorded into the per-(workspace, day) usage rollup so the
12
10
  * GUI can report real ESR-proactivity and recall rates (see usage.js).
13
11
  *
14
12
  * All reads are deterministic local scans; `engram_recall` may additionally
15
- * fall back to `ctx.sessionQuery` FTS over past sessions when mounted.
13
+ * fall back to `ctx.sessionQuery` FTS over past sessions when mounted, and to
14
+ * a bounded CJK substring scan over the workspace's own session logs when the
15
+ * query is Chinese (see session-cjk.js).
16
16
  */
17
17
 
18
18
  import { z } from "zod";
19
+ import { basename } from "node:path";
19
20
  import { EngramError, hasCode } from "./store.js";
20
21
  import { buildEsrStatusView } from "./index-block.js";
21
22
  import { truncate, shortId, slugId, fmtDate, escapeLt, workspaceKey as wk, uuid } from "./util.js";
23
+ import { hasCJK, searchSessionsCJK } from "./session-cjk.js";
22
24
 
23
25
  const KINDS = [
24
26
  "decision",
@@ -73,16 +75,27 @@ async function requireDomain(service) {
73
75
  }
74
76
  }
75
77
 
76
- function memoryLine(m, verbosity = "short") {
78
+ function memoryLine(m, verbosity = "short", rel, homeWs) {
79
+ // origin marker for cross-workspace hits: [W:<ws>] tells the model the memory
80
+ // came from a different workspace pool (issue #5).
81
+ const origin = homeWs !== void 0 && homeWs !== null && (m._origin ?? m.workspace) !== homeWs
82
+ ? ` [W:${basename(m._origin ?? m.workspace) || String(m._origin ?? m.workspace)}]`
83
+ : "";
77
84
  const tags = m.entity !== null ? ` · entity:${m.entity}` : "";
85
+ const relTail = rel
86
+ ? `${rel.supersededBy.has(m.id) ? ` · superseded by ${rel.supersededBy.get(m.id).id.slice(0, 8)}` : ""}${rel.contradictedBy.has(m.id) ? ` · contradicted by ${rel.contradictedBy.get(m.id).map((c) => c.id.slice(0, 8)).join(",")}` : ""}`
87
+ : "";
78
88
  if (verbosity === "full") {
79
89
  const access = m.lastAccessAt == null ? "never-accessed" : `lastAccess ${fmtDate(m.lastAccessAt)}`;
80
90
  const pathLine = m.filePath ? ` · path ${m.filePath}` : "";
81
- return [`# ${m.id} [${m.kind}]`, escapeLt(m.text), `tags: ${(m.tags ?? []).join(", ")}${tags}`, `signal ${m.signal} · hits ${m.hits ?? 0} · ${fmtDate(m.createdAt)} · session ${m.sessionId}#${m.seq} · ${access}${pathLine}`, ""].join("\n");
91
+ const relLine = relTail.length > 0 ? relTail.trim() : "";
92
+ const sourceLine = m.source ? ` · source ${m.source}` : "";
93
+ const condLine = m.conditions ? ` · conditions ${m.conditions}` : "";
94
+ return [`# ${m.id} [${m.kind}]${origin}`, escapeLt(m.text), `tags: ${(m.tags ?? []).join(", ")}${tags}`, `signal ${m.signal} · hits ${m.hits ?? 0} · ${fmtDate(m.createdAt)} · session ${m.sessionId}#${m.seq} · ${access}${pathLine}${relLine ? ` · ${relLine}` : ""}${sourceLine}${condLine}`, ""].join("\n");
82
95
  }
83
96
  const date = fmtDate(m.createdAt ?? m.updatedAt);
84
97
  const path = m.filePath ? ` · ${truncate(m.filePath, 48)}` : "";
85
- return `- ${m.id.slice(0, 8)} [${m.kind}] ${date} ${escapeLt(truncate(m.text, 200))}${tags}${m.hits > 0 ? ` · ×${m.hits}` : ""}${path}`;
98
+ return `- ${m.id.slice(0, 8)} [${m.kind}]${origin} ${date} ${escapeLt(truncate(m.text, 200))}${tags}${m.hits > 0 ? ` · ×${m.hits}` : ""}${path}${relTail}`;
86
99
  }
87
100
 
88
101
  /** Stable kind display order for grouped recall output (matches MEMORY_KINDS). */
@@ -95,7 +108,7 @@ const MEMORY_KIND_ORDER = ["decision", "error", "procedure", "fact", "insight",
95
108
  * Within a group the original (relevance) order is preserved; unknown kinds
96
109
  * (future-proof) trail the fixed order. Pure, deterministic.
97
110
  */
98
- function groupKindLines(items) {
111
+ function groupKindLines(items, rel, homeWs) {
99
112
  const buckets = new Map();
100
113
  for (const m of items) {
101
114
  const kind = typeof m.kind === "string" && m.kind.length > 0 ? m.kind : "unknown";
@@ -105,7 +118,7 @@ function groupKindLines(items) {
105
118
  const lines = [];
106
119
  const emit = (kind, group) => {
107
120
  lines.push(`## ${kind} (${group.length})`);
108
- for (const m of group) lines.push(memoryLine(m));
121
+ for (const m of group) lines.push(memoryLine(m, "short", rel, homeWs));
109
122
  };
110
123
  for (const kind of MEMORY_KIND_ORDER) if (buckets.has(kind)) emit(kind, buckets.get(kind));
111
124
  for (const [kind, group] of buckets) if (!MEMORY_KIND_ORDER.includes(kind)) emit(kind, group);
@@ -122,19 +135,23 @@ export function registerTools(ctx, service) {
122
135
  // ── engram_store ────────────────────────────────────────────────────
123
136
  registerTool({
124
137
  name: "engram_store",
125
- description: "Store one memory: kind=(decision|error|procedure|fact|insight|handoff|task), text, optional entity anchor. Exact-duplicate text refreshes instead of adding; auto-capture already handles git/file/error events — use this for anything it misses.",
138
+ description: "Store one memory (kind, text, optional entity anchor + supersedes/contradicts id). Duplicate text refreshes; auto-capture covers git/file/error — use this for anything missed.",
126
139
  parameters: {
127
140
  type: "object",
128
141
  properties: {
129
142
  text: { type: "string", description: "Compact, information-dense memory text (required)" },
130
143
  ...p,
131
144
  file_path: { type: "string", description: "Optional absolute path this memory anchors to (e.g. the failing file)" },
145
+ supersedes: { type: "string", description: "Optional memory id this new statement replaces (old row stays, demoted in recall)" },
146
+ contradicts: { type: "string", description: "Optional memory id this new statement directly contradicts (both stay, flagged)" },
147
+ source: { type: "string", description: "Optional provenance source class: manual | session | report | upstream | handoff" },
148
+ conditions: { type: "string", description: "Optional trigger/condition under which this memory should be recalled" },
132
149
  },
133
150
  required: ["text"],
134
151
  },
135
152
  output: TEXT_OUTPUT,
136
153
  execute: async (raw, exec) => {
137
- const args = parse(z.object({ text: z.string().min(1).max(2000), kind: z.enum(KINDS).optional(), tags: z.array(z.string()).optional(), entity: z.string().optional(), file_path: z.string().optional() }), raw, "engram_store");
154
+ const args = parse(z.object({ text: z.string().min(1).max(2000), kind: z.enum(KINDS).optional(), tags: z.array(z.string()).optional(), entity: z.string().optional(), file_path: z.string().optional(), supersedes: z.string().optional(), contradicts: z.string().optional(), source: z.string().optional(), conditions: z.string().optional() }), raw, "engram_store");
138
155
  const caller = callerOf(exec);
139
156
  const domain = await requireDomain(service);
140
157
  await domain.expireSweep(caller.cwd);
@@ -146,6 +163,10 @@ export function registerTools(ctx, service) {
146
163
  tags: args.tags ?? [],
147
164
  entity: args.entity ?? null,
148
165
  filePath: args.file_path ?? null,
166
+ supersedes: args.supersedes ?? null,
167
+ contradicts: args.contradicts ?? null,
168
+ source: args.source ?? null,
169
+ conditions: args.conditions ?? null,
149
170
  sessionId: caller.sessionId,
150
171
  seq: caller.seq,
151
172
  signal: 0.6,
@@ -161,22 +182,23 @@ export function registerTools(ctx, service) {
161
182
  // ── engram_recall ───────────────────────────────────────────────────
162
183
  registerTool({
163
184
  name: "engram_recall",
164
- description: "Deterministic recall of this workspace's memories (BM25: exact tag first, recency ties). Literal, not semantic — query in the memory's likely language. query=filter, entity=timeline, search_sessions=true adds past-session FTS. limit/max_per_session/group_by_kind tune output.",
185
+ description: "Deterministic recall (BM25: exact tag first, recency ties). Literal, not semantic. query=filter, entity=timeline, search_sessions=true adds past-session FTS; limit/max_per_session/group_by_kind tune.",
165
186
  parameters: {
166
187
  type: "object",
167
188
  properties: {
168
189
  query: { type: "string", description: "Words to match against memory text and tags" },
169
190
  entity: { type: "string", description: "Return that entity's memories chronologically" },
170
191
  limit: { type: "integer", minimum: 1, maximum: 50, description: "Max memories (default 12)" },
171
- max_per_session: { type: "integer", minimum: 1, maximum: 10, description: "Session-diversified cap: max memories kept per sessionId (default config.maxRecallPerSession=3; >1 sessions get to share the list)" },
192
+ max_per_session: { type: "integer", minimum: 1, maximum: 10, description: "Max memories kept per sessionId (default 3; sessions share the cap)" },
172
193
  group_by_kind: { type: "boolean", description: "Group recall hits by kind into sections (default true); false = one flat list" },
173
194
  search_sessions: { type: "boolean", description: "Also FTS search past sessions (requires the session-query index)" },
195
+ scope: { type: "string", enum: ["workspace", "global", "auto"], description: "Recall scope override: workspace=strict current-workspace only (default); global=include other workspaces' pools when the local pool is weak/empty (marks [W:<ws>] origin); auto=follow the recallScope config (recommended)" },
174
196
  },
175
197
  },
176
198
  output: TEXT_OUTPUT,
177
199
  timeoutMs: 30000,
178
200
  execute: async (raw, exec) => {
179
- const args = parse(z.object({ query: z.string().optional(), entity: z.string().optional(), limit: z.number().int().min(1).max(50).optional(), max_per_session: z.number().int().min(1).max(10).optional(), group_by_kind: z.boolean().optional(), search_sessions: z.boolean().optional() }), raw, "engram_recall");
201
+ const args = parse(z.object({ query: z.string().optional(), entity: z.string().optional(), limit: z.number().int().min(1).max(50).optional(), max_per_session: z.number().int().min(1).max(10).optional(), group_by_kind: z.boolean().optional(), search_sessions: z.boolean().optional(), scope: z.enum(["workspace", "global", "auto"]).optional() }), raw, "engram_recall");
180
202
  const caller = callerOf(exec);
181
203
  const domain = await requireDomain(service);
182
204
  let lines = [];
@@ -185,6 +207,11 @@ export function registerTools(ctx, service) {
185
207
  // reuses DSH's own cross-session index (ctx.sessionQuery) instead of
186
208
  // a self-built index — this keeps proactive coverage without new infra.
187
209
  let zeroHits = false;
210
+ // recallScope resolution: explicit scope wins; else the config knob;
211
+ // default "workspace" preserves the strict per-workspace isolation.
212
+ const cfgScope = service.config.recallScope ?? "workspace";
213
+ const effectiveScope = args.scope === void 0 ? cfgScope : args.scope === "auto" ? cfgScope : args.scope;
214
+ const globalEnabled = effectiveScope === "global";
188
215
  if (args.entity !== void 0 && args.entity.trim().length > 0) {
189
216
  items = domain.timeline(caller.cwd, args.entity, args.limit ?? 30);
190
217
  if (items.length === 0) zeroHits = true;
@@ -193,10 +220,25 @@ export function registerTools(ctx, service) {
193
220
  await Promise.all(items.map((m) => domain.touchMemory(caller.cwd, m.id).catch(() => null)));
194
221
  } else if (args.query !== void 0) {
195
222
  const maxPerSession = args.max_per_session ?? service.config.maxRecallPerSession ?? 3;
196
- items = domain.recall(caller.cwd, args.query, args.limit ?? 12, { maxPerSession });
223
+ const limit = args.limit ?? 12;
224
+ items = domain.recall(caller.cwd, args.query, limit, { maxPerSession });
225
+ let cross = false;
226
+ // Opt-in global fallback (issue #5): only when the local pool is
227
+ // weak/no-hit AND the user enabled recallScope=global or passed
228
+ // scope=global. Cross-workspace hits carry a [W:<ws>] origin marker.
229
+ if (globalEnabled && items.length === 0) {
230
+ const extra = domain.recallGlobal(args.query, limit, { maxPerSession, excludeWorkspace: caller.cwd });
231
+ if (extra.length > 0) {
232
+ items = extra;
233
+ cross = true;
234
+ }
235
+ }
197
236
  if (items.length === 0) zeroHits = true;
198
- lines.push(zeroHits ? `no active memories match ${JSON.stringify(args.query)} in this workspace` : `# recall: ${JSON.stringify(args.query)} (${items.length}) · ≤${maxPerSession}/session`);
199
- lines.push(...(args.group_by_kind === false ? items.map((m) => memoryLine(m)) : groupKindLines(items)));
237
+ const scopeTag = cross ? " · cross-workspace" : "";
238
+ lines.push(zeroHits ? `no active memories match ${JSON.stringify(args.query)} in this workspace` + (globalEnabled ? " (global fallback also empty)" : "") : `# recall: ${JSON.stringify(args.query)} (${items.length}) · ≤${maxPerSession}/session${scopeTag}`);
239
+ const rel = domain.memoryRelations(caller.cwd);
240
+ const homeWs = caller.cwd;
241
+ lines.push(...(args.group_by_kind === false ? items.map((m) => memoryLine(m, "short", rel, homeWs)) : groupKindLines(items, rel, homeWs)));
200
242
  await Promise.all(items.map((m) => domain.touchMemory(caller.cwd, m.id).catch(() => null)));
201
243
  } else {
202
244
  items = domain.listMemories(caller.cwd, args.limit ?? 12);
@@ -217,9 +259,23 @@ export function registerTools(ctx, service) {
217
259
  }
218
260
  const wantSessions = args.search_sessions === true || (zeroHits && service.config.sessionSearch !== false);
219
261
  if (wantSessions) {
220
- const extra = await searchSessions(service, caller, args.query ?? args.entity ?? "", exec.signal);
221
- if (extra.length > 0) lines.push("", "# past sessions (FTS fallback)", ...extra);
222
- else if (zeroHits && lines.length === 1) lines[0] += " (no past-session hits either)";
262
+ const q = args.query ?? args.entity ?? "";
263
+ const extra = await searchSessions(service, caller, q, exec.signal, globalEnabled);
264
+ if (extra.length > 0) {
265
+ lines.push("", "# past sessions (FTS fallback)", ...extra);
266
+ } else if (hasCJK(q)) {
267
+ // The host FTS index is tokenizer-blind to Chinese (unicode61
268
+ // keeps CJK runs as single tokens), so supplement it with a
269
+ // bounded substring scan over this workspace's own sessions.
270
+ const cjk = searchSessionsCJK({ cwd: caller.cwd, query: q });
271
+ if (cjk.length > 0) {
272
+ lines.push("", "# past sessions (CJK substring scan)", ...cjk.map((h, i) => `${i + 1}. session ${h.sessionId} · ${truncate(h.snippet, 160)}`));
273
+ } else if (zeroHits && lines.length === 1) {
274
+ lines[0] += " (no past-session hits either)";
275
+ }
276
+ } else if (zeroHits && lines.length === 1) {
277
+ lines[0] += " (no past-session hits either)";
278
+ }
223
279
  }
224
280
  return lines.join("\n");
225
281
  },
@@ -228,7 +284,7 @@ export function registerTools(ctx, service) {
228
284
  // ── engram_detail ───────────────────────────────────────────────────
229
285
  registerTool({
230
286
  name: "engram_detail",
231
- description: "Full record of one memory id (from engram_recall): text, tags, entity, signal, hits, provenance session#seq.",
287
+ description: "Full record of one memory id: text, tags, entity, signal, hits, session#seq provenance.",
232
288
  parameters: {
233
289
  type: "object",
234
290
  properties: {
@@ -245,21 +301,21 @@ export function registerTools(ctx, service) {
245
301
  const m = domain.getMemory(caller.cwd, id);
246
302
  if (m === void 0) throw new EngramError("ENGRAM_NOT_FOUND", `no active memory "${args.id}" in this workspace`);
247
303
  await domain.touchMemory(caller.cwd, m.id).catch(() => null);
248
- return memoryLine(m, "full");
304
+ return memoryLine(m, "full", domain.memoryRelations(caller.cwd));
249
305
  },
250
306
  }),
251
307
 
252
308
  // ── esr_task ──────────────────────────────────────────────────────
253
309
  registerTool({
254
310
  name: "esr_task",
255
- description: "Create/update an engineering task (draft→active). A task is only 'done' with evidence — finish via esr_close (artifact + evaluation + memory_ref). Set id to update an existing task. Pass entity= to auto-create the node and link the task onto it (one call instead of esr_node+esr_link).",
311
+ description: "Create/update an engineering task (draft→active). Done only with evidence — esr_close (artifact+evaluation+memory_ref). id=update; entity=auto-create node + relates_to link.",
256
312
  parameters: {
257
313
  type: "object",
258
314
  properties: {
259
315
  name: { type: "string", description: "Task name (required on create)" },
260
316
  description: { type: "string", description: "What must be produced / satisfied" },
261
317
  id: { type: "string", description: "Existing task id to update" },
262
- entity: { type: "string", description: "Optional domain object to hang this task on — auto-creates ent_<slug> node and links task --relates_to--> it" },
318
+ entity: { type: "string", description: "Domain object to hang this task on — auto-creates ent_<slug> node + task --relates_to--> edge" },
263
319
  },
264
320
  required: ["name"],
265
321
  },
@@ -307,12 +363,12 @@ export function registerTools(ctx, service) {
307
363
  // ── esr_close ─────────────────────────────────────────────────────
308
364
  registerTool({
309
365
  name: "esr_close",
310
- description: "Close a task via the ESR evidence protocol: requires artifact (path/url), evaluation (how verified) and memory_ref (memory ids) — missing gates keep it active; force:true skips the on-disk artifact check. Auto-stores a closing memory.",
366
+ description: "Close a task via the ESR evidence protocol: artifact (path/url) + evaluation + memory_ref — missing gates keep it active; force:true skips the disk check. Stores a closing memory.",
311
367
  parameters: {
312
368
  type: "object",
313
369
  properties: {
314
370
  task_id: { type: "string", description: "Task id from [ESR] or esr_task" },
315
- artifact: { type: "string", description: "Produced artifact (path/url). Paths are resolved against the workspace and must exist on disk unless force:true" },
371
+ artifact: { type: "string", description: "Produced artifact path/url. Resolved against workspace; must exist on disk unless force:true" },
316
372
  evaluation: { type: "string", description: "Verification evidence (test/review/benchmark)" },
317
373
  memory_refs: { type: "array", items: { type: "string" }, description: "Memory ids that record the decisions made" },
318
374
  force: { type: "boolean", description: "Skip the on-disk artifact existence check (gates are still required)" },
@@ -369,7 +425,7 @@ ${hint}` : ""}`;
369
425
  // ── esr_link ──────────────────────────────────────────────────────
370
426
  registerTool({
371
427
  name: "esr_link",
372
- description: "Add a typed relation between two entities (depends_on/implements/refines/contradicts…). Feeds entity queries and the workspace link count.",
428
+ description: "Add a typed relation between entities (depends_on/implements/refines/contradicts…). Feeds entity queries + link count.",
373
429
  parameters: {
374
430
  type: "object",
375
431
  properties: {
@@ -403,7 +459,7 @@ ${hint}` : ""}`;
403
459
 
404
460
  registerTool({
405
461
  name: "esr_dep",
406
- description: "Add a dependency edge between tasks: blocks (waits until dep closes), parent-of (child), relates-to (no blocking). Refuses self-deps and cycles.",
462
+ description: "Add a dependency edge between tasks: blocks (until dep closes), parent-of (child), relates-to (no blocking). Refuses self-deps/cycles.",
407
463
  parameters: {
408
464
  type: "object",
409
465
  properties: {
@@ -444,7 +500,7 @@ ${hint}` : ""}`;
444
500
  // ── esr_claim / esr_unclaim ─────────────────────────────────────
445
501
  registerTool({
446
502
  name: "esr_claim",
447
- description: "Atomically claim a task (assignee+claimedAt, draft→active). Refuses when another agent holds it unless force:true. Find work with esr_ready.",
503
+ description: "Atomically claim a task (draft→active, assignee+claimedAt). Refuses when another agent holds it unless force:true. Find work with esr_ready.",
448
504
  parameters: {
449
505
  type: "object",
450
506
  properties: {
@@ -467,7 +523,7 @@ ${hint}` : ""}`;
467
523
  }),
468
524
  registerTool({
469
525
  name: "esr_unclaim",
470
- description: "Release a claimed task's assignee back to null. Non-holder needs force:true. Inverse of esr_claim.",
526
+ description: "Release a claimed task's assignee to null. Non-holder needs force:true. Inverse of esr_claim.",
471
527
  parameters: {
472
528
  type: "object",
473
529
  properties: {
@@ -516,12 +572,12 @@ ${hint}` : ""}`;
516
572
  registerTool({
517
573
  name: "esr_status",
518
574
  description:
519
- "Pull the live ESR state (deterministic snapshot + revision) plus decision-point hints (promote / root-cause / close / stale / escalate). Pass since_revision to skip unchanged state — returns a short response when nothing changed. The [ESR] block in the prompt is a session-start snapshot that never auto-refreshes; call this for the current truth.",
575
+ "Live ESR state (deterministic snapshot + revision) + decision-point hints (promote/root-cause/close/stale/escalate). Pass since_revision to skip unchanged state (short response). Prompt [ESR] is a frozen session-start snapshot — call this for current truth.",
520
576
  parameters: {
521
577
  type: "object",
522
578
  properties: {
523
579
  workspace: { type: "string", description: "Override the workspace key (default: current session cwd)" },
524
- since_revision: { type: "string", description: "Last-seen ESR revision (from a previous esr_status response); unchanged state returns a short response" },
580
+ since_revision: { type: "string", description: "Last-seen ESR revision; unchanged state returns a short response" },
525
581
  },
526
582
  },
527
583
  output: TEXT_OUTPUT,
@@ -542,7 +598,7 @@ ${hint}` : ""}`;
542
598
  // ── esr_node ─────────────────────────────────────
543
599
  registerTool({
544
600
  name: "esr_node",
545
- description: "Create/update an entity node — stable symbol for a recurring thing (package/service/doc/repo/person/bug/concept). Nodes anchor esr_link relations and memory entity. Same name updates; id = ent_<slug>.",
601
+ description: "Create/update an entity node — stable symbol for a recurring thing (package/service/doc/repo/person…). Anchors esr_link + memory entity; same name updates; id=ent_<slug>.",
546
602
  parameters: {
547
603
  type: "object",
548
604
  properties: {
@@ -584,7 +640,7 @@ ${hint}` : ""}`;
584
640
  // ── esr_gc ────────────────────────────────────────────────────────
585
641
  registerTool({
586
642
  name: "esr_gc",
587
- description: "Run memory GC: archives TTL-expired, over-cap low-retention memories, stable tasks past retention, dangling links. Working set protected; archive-only (nothing hard-deleted). dry_run:true previews.",
643
+ description: "Run memory GC: archives TTL-expired, over-cap low-retention memories, stable tasks past retention, dangling links. Working set protected; archive-only, never hard-deletes. dry_run previews.",
588
644
  parameters: {
589
645
  type: "object",
590
646
  properties: {
@@ -605,7 +661,7 @@ ${hint}` : ""}`;
605
661
  // ── esr_model ─────────────────────────────────────────────────────
606
662
  registerTool({
607
663
  name: "esr_model",
608
- description: "Precomputed mental model of the workspace (task counts, entity graph, top evidence-grounded beliefs, risks). Zero LLM. mode=brief → ~50-token one-liner; max_chars trims. Read before scattering recall.",
664
+ description: "Precomputed mental model (task counts, entity graph, top evidence-grounded beliefs, risks). Zero LLM. mode=brief → one-liner; max_chars trims. Read before scattering recall.",
609
665
  parameters: {
610
666
  type: "object",
611
667
  properties: {
@@ -726,15 +782,17 @@ function collectNeighborhood(domain, workspace, items) {
726
782
  return lines;
727
783
  }
728
784
 
729
- /** FTS over past sessions via ctx.sessionQuery (mounted by the web profile). */
730
- async function searchSessions(service, caller, query, signal) {
785
+ /** FTS over past sessions via ctx.sessionQuery (mounted by the web profile).
786
+ * `global` (recallScope=global) drops the cwd filter so past-session hits from
787
+ * other workspaces are reachable too — matching the cross-workspace scope. */
788
+ async function searchSessions(service, caller, query, signal, global) {
731
789
  const sq = service.sessionQuery;
732
790
  if (sq === void 0 || query.trim().length === 0) return [];
733
791
  try {
734
792
  const { items = [] } = await sq.searchSessions(
735
793
  {
736
794
  query: query.trim(),
737
- sessionFilters: [{ kind: "cwd", values: [caller.cwd] }],
795
+ sessionFilters: global ? [] : [{ kind: "cwd", values: [caller.cwd] }],
738
796
  limit: 5,
739
797
  },
740
798
  signal !== void 0 ? { signal } : undefined,
package/lib/trigger.js CHANGED
@@ -97,6 +97,8 @@ export function pendingTodos(args) {
97
97
  export function makeTriggerRecorder(config = {}) {
98
98
  const minTodosForPromote = config.minTodosForPromote ?? 2;
99
99
  const todoPromoteRatio = config.todoPromoteRatio ?? 1;
100
+ // Injectable clock (deterministic unit tests): default to the wall clock.
101
+ const now = typeof config.now === "function" ? config.now : Date.now;
100
102
  /** workspace -> Map<day, {mem, esr}> (live balance, replaces dead usage table). */
101
103
  const usage = new Map();
102
104
  /** workspace -> { sessionId, count, names, updatedAt } (pending-todo plan). */
@@ -130,7 +132,7 @@ export function makeTriggerRecorder(config = {}) {
130
132
  cur[key] += 1;
131
133
  buckets.set(day, cur);
132
134
  if (buckets.size > BALANCE_DAYS + 2) {
133
- const oldest = dayKey(Date.now() - BALANCE_DAYS * DAY_MS);
135
+ const oldest = dayKey(now() - BALANCE_DAYS * DAY_MS);
134
136
  for (const d of [...buckets.keys()]) if (d < oldest) buckets.delete(d);
135
137
  }
136
138
  if (usage.size > WORKSPACE_CAP) {
@@ -160,7 +162,7 @@ export function makeTriggerRecorder(config = {}) {
160
162
  record(name, workspace);
161
163
  // P4 conversion: an esr_* call inside the window converts this
162
164
  // workspace's most recent pending hint.
163
- if (ESR_TOOLS.includes(name)) attributeConversion(workspace, Date.now());
165
+ if (ESR_TOOLS.includes(name)) attributeConversion(workspace, now());
164
166
  if (name !== "todo_write") return;
165
167
  // Snapshot this session's pending plan. When a plan that had pending
166
168
  // todos transitioned to a fully-completed list, that is a natural
@@ -168,7 +170,7 @@ export function makeTriggerRecorder(config = {}) {
168
170
  const names = pendingTodos(exec?.arguments);
169
171
  const prev = todos.get(workspace);
170
172
  const prevCount = prev !== void 0 && prev.sessionId === sessionId ? prev.count : -1;
171
- todos.set(workspace, { sessionId, count: names.length, names, updatedAt: Date.now() });
173
+ todos.set(workspace, { sessionId, count: names.length, names, updatedAt: now() });
172
174
  if (prevCount > 0 && names.length === 0) {
173
175
  workDone.add(`${workspace}::${sessionId}`);
174
176
  }
@@ -188,7 +190,7 @@ export function makeTriggerRecorder(config = {}) {
188
190
  function recentBalance(workspace) {
189
191
  const buckets = usage.get(workspace);
190
192
  if (buckets === void 0) return { memCalls: 0, esrCalls: 0, days: 0 };
191
- const oldest = dayKey(Date.now() - BALANCE_DAYS * DAY_MS);
193
+ const oldest = dayKey(now() - BALANCE_DAYS * DAY_MS);
192
194
  let memCalls = 0;
193
195
  let esrCalls = 0;
194
196
  let days = 0;
@@ -225,7 +227,7 @@ export function makeTriggerRecorder(config = {}) {
225
227
 
226
228
  /** Drop hints past the conversion window (or when already attributed). */
227
229
  function prunePending() {
228
- const cutoff = Date.now() - CONVERSION_WINDOW_MS;
230
+ const cutoff = now() - CONVERSION_WINDOW_MS;
229
231
  for (let i = pending.length - 1; i >= 0; i--) {
230
232
  if (pending[i].shownAt < cutoff) pending.splice(i, 1);
231
233
  }
@@ -243,7 +245,7 @@ export function makeTriggerRecorder(config = {}) {
243
245
  if (hintSeen.has(key)) return;
244
246
  hintSeen.add(key);
245
247
  bumpCount(shownCounts, workspace, kind, 1);
246
- pending.push({ kind, ws: workspace, session: sessionId, shownAt: Date.now() });
248
+ pending.push({ kind, ws: workspace, session: sessionId, shownAt: now() });
247
249
  prunePending();
248
250
  }
249
251
 
@@ -368,8 +370,8 @@ export function makeTriggerRecorder(config = {}) {
368
370
  const key = `${workspace}::${sessionId}::${t.id}`;
369
371
  if (staleSeen.has(key)) continue;
370
372
  staleSeen.add(key);
371
- const at = t.updatedAt ?? t.createdAt ?? Date.now();
372
- const days = Math.max(1, Math.round((Date.now() - at) / DAY_MS));
373
+ const at = t.updatedAt ?? t.createdAt ?? now();
374
+ const days = Math.max(1, Math.round((now() - at) / DAY_MS));
373
375
  return `stale: ${t.id.slice(0, 6)} · ${truncate(t.name, 40)} — no update ${days}d, esr_close with evidence or drop the intent ${HINT_TAGS.stale}`;
374
376
  }
375
377
  return "";
package/lib/usage.js CHANGED
@@ -11,13 +11,19 @@
11
11
  * with a short TTL cache. No tool call pays a write anymore (pure GUI-side
12
12
  * observability), and the log stream is the single source of truth.
13
13
  *
14
- * Zero-dependency: the host already carries the `zstd` CLI.
14
+ * Zero external dependency: Node >=22.19 provides the zstd codec in
15
+ * `node:zlib`. Session logs are concatenated zstd frames (each append is an
16
+ * independent frame), so we scan frame boundaries and reuse one decoder
17
+ * handle across all frames instead of relying on a system `zstd` CLI (which
18
+ * Windows hosts commonly lack) or the one-shot `zstdDecompressSync`, which
19
+ * only decodes the FIRST frame of a multi-frame container.
15
20
  */
16
21
 
17
- import { execFileSync } from "node:child_process";
18
22
  import fs from "node:fs";
19
23
  import os from "node:os";
20
24
  import path from "node:path";
25
+ import { constants as bufferConstants } from "node:buffer";
26
+ import { createZstdDecompress, zstdDecompressSync } from "node:zlib";
21
27
 
22
28
  import { workspaceKey as wk } from "./util.js";
23
29
 
@@ -28,8 +34,6 @@ export function dayKey(now = Date.now()) {
28
34
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
29
35
  }
30
36
 
31
- /** A big session log far exceeds Node's 1MB default exec buffer. */
32
- const MAX_BYTES = 300 * 1024 * 1024;
33
37
  const CACHE_TTL_MS = 60_000;
34
38
  const DAY_MS = 86_400_000;
35
39
 
@@ -40,13 +44,151 @@ export function sessionsRoot() {
40
44
  return path.join(os.homedir(), ".dsh", "sessions");
41
45
  }
42
46
 
47
+ /** Byte range of one structurally complete zstd frame in a concatenated stream. */
48
+ function frameRanges(buffer) {
49
+ const frames = [];
50
+ let offset = 0;
51
+ while (offset < buffer.length) {
52
+ const start = offset;
53
+ if (buffer.length - offset < 4) break;
54
+ if (buffer.readUInt32LE(offset) !== 0xfd2fb528) break; // ZSTD_MAGIC
55
+ offset += 4;
56
+ if (offset === buffer.length) break;
57
+ const descriptor = buffer.readUInt8(offset);
58
+ offset += 1;
59
+ if ((descriptor & 0x18) !== 0) break; // reserved frame-header bit
60
+ const contentSizeFlag = descriptor >>> 6;
61
+ const singleSegment = (descriptor & 0x20) !== 0;
62
+ const checksum = (descriptor & 0x04) !== 0;
63
+ const dictionaryFlag = descriptor & 0x03;
64
+ const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag;
65
+ const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag;
66
+ const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes;
67
+ if (buffer.length - offset < remainingHeaderBytes) break;
68
+ offset += remainingHeaderBytes;
69
+ for (;;) {
70
+ if (buffer.length - offset < 3) return frames;
71
+ const blockHeader = buffer.readUIntLE(offset, 3);
72
+ offset += 3;
73
+ const lastBlock = (blockHeader & 1) !== 0;
74
+ const blockType = (blockHeader >>> 1) & 0x03;
75
+ const blockSize = blockHeader >>> 3;
76
+ if (blockType === 0x03) return frames; // reserved block type
77
+ const payloadBytes = blockType === 0x01 ? 1 : blockSize;
78
+ if (buffer.length - offset < payloadBytes) return frames;
79
+ offset += payloadBytes;
80
+ if (lastBlock) break;
81
+ }
82
+ if (checksum) {
83
+ if (buffer.length - offset < 4) return frames;
84
+ offset += 4;
85
+ }
86
+ frames.push({ start, end: offset });
87
+ }
88
+ return frames;
89
+ }
90
+
91
+ /**
92
+ * Node-private synchronous multi-frame zstd decoder. Node exposes synchronous
93
+ * decoding only as a one-shot API that stops at the first frame of a
94
+ * concatenated container (session logs append a fresh frame per batch).
95
+ * Reusing one stream's `_handle.writeSync` across frames is the same
96
+ * optimization the harness's own `NodePrivateZstdFrameDecoder` uses; when the
97
+ * running Node release lacks that private shape we fall back to per-frame
98
+ * `zstdDecompressSync` (correct, slower — fine for the occasional /stats poll).
99
+ */
100
+ function createFrameDecoder() {
101
+ const stream = createZstdDecompress({ chunkSize: 1024 * 1024 });
102
+ const handle = stream._handle;
103
+ const errorKey = Reflect.ownKeys(stream).find(
104
+ (key) => typeof key === "symbol" && key.description === "kError",
105
+ );
106
+ if (
107
+ typeof handle !== "object" || handle === null ||
108
+ typeof handle?.writeSync !== "function" ||
109
+ !(stream._writeState instanceof Uint32Array) ||
110
+ stream._writeState.length < 2 ||
111
+ typeof stream._defaultFlushFlag !== "number" ||
112
+ errorKey === void 0 ||
113
+ stream[errorKey] !== null
114
+ ) {
115
+ stream.close();
116
+ return null;
117
+ }
118
+ const output = Buffer.allocUnsafe(1024 * 1024);
119
+ let decoderError;
120
+ stream.on("error", (error) => { decoderError ??= error; });
121
+ return {
122
+ decode(frame) {
123
+ if (decoderError !== void 0) throw decoderError;
124
+ let inputOffset = 0;
125
+ let inputRemaining = frame.length;
126
+ let outputBytes = 0;
127
+ const fullChunks = [];
128
+ for (;;) {
129
+ handle.writeSync(
130
+ stream._defaultFlushFlag,
131
+ frame,
132
+ inputOffset,
133
+ inputRemaining,
134
+ output,
135
+ 0,
136
+ output.length,
137
+ );
138
+ if (decoderError !== void 0) throw decoderError;
139
+ const internalError = stream[errorKey];
140
+ if (internalError !== null) throw internalError;
141
+ const outputAfter = stream._writeState[0];
142
+ const inputAfter = stream._writeState[1];
143
+ const consumed = inputRemaining - inputAfter;
144
+ const produced = output.length - outputAfter;
145
+ if (produced > 0) {
146
+ outputBytes += produced;
147
+ if (outputBytes > bufferConstants.MAX_LENGTH) throw new Error("frame output too large");
148
+ }
149
+ if (outputAfter !== 0) {
150
+ if (inputAfter !== 0) throw new Error("decoder left trailing input");
151
+ const finalChunk = output.subarray(0, produced);
152
+ if (fullChunks.length === 0) return Buffer.from(finalChunk);
153
+ if (produced > 0) fullChunks.push(Buffer.from(finalChunk));
154
+ const onlyChunk = fullChunks[0];
155
+ return fullChunks.length === 1 ? onlyChunk : Buffer.concat(fullChunks, outputBytes);
156
+ }
157
+ fullChunks.push(Buffer.from(output));
158
+ inputOffset += consumed;
159
+ inputRemaining = inputAfter;
160
+ }
161
+ },
162
+ close() {
163
+ stream.close();
164
+ },
165
+ };
166
+ }
167
+
43
168
  function decodeZstd(file) {
44
169
  try {
45
- return execFileSync("zstd", ["-dc", file], { maxBuffer: MAX_BYTES }).toString("utf8");
170
+ const buffer = fs.readFileSync(file);
171
+ const frames = frameRanges(buffer);
172
+ if (frames.length === 0) return "";
173
+ const decoder = createFrameDecoder();
174
+ if (decoder !== null) {
175
+ try {
176
+ let out = "";
177
+ for (const frame of frames) out += decoder.decode(buffer.subarray(frame.start, frame.end)).toString("utf8");
178
+ return out;
179
+ } finally {
180
+ decoder.close();
181
+ }
182
+ }
183
+ // Public fallback: one zstdDecompressSync per frame (correct, slower).
184
+ let out = "";
185
+ for (const frame of frames) out += zstdDecompressSync(buffer.subarray(frame.start, frame.end)).toString("utf8");
186
+ return out;
46
187
  } catch {
47
188
  return "";
48
189
  }
49
190
  }
191
+ export { decodeZstd };
50
192
 
51
193
  function listDirOrEmpty(dir) {
52
194
  try {