dsh-engram 0.3.4 → 0.3.5

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,25 @@
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
19
  import { EngramError, hasCode } from "./store.js";
20
20
  import { buildEsrStatusView } from "./index-block.js";
21
21
  import { truncate, shortId, slugId, fmtDate, escapeLt, workspaceKey as wk, uuid } from "./util.js";
22
+ import { hasCJK, searchSessionsCJK } from "./session-cjk.js";
22
23
 
23
24
  const KINDS = [
24
25
  "decision",
@@ -73,16 +74,20 @@ async function requireDomain(service) {
73
74
  }
74
75
  }
75
76
 
76
- function memoryLine(m, verbosity = "short") {
77
+ function memoryLine(m, verbosity = "short", rel) {
77
78
  const tags = m.entity !== null ? ` · entity:${m.entity}` : "";
79
+ const relTail = rel
80
+ ? `${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(",")}` : ""}`
81
+ : "";
78
82
  if (verbosity === "full") {
79
83
  const access = m.lastAccessAt == null ? "never-accessed" : `lastAccess ${fmtDate(m.lastAccessAt)}`;
80
84
  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");
85
+ const relLine = relTail.length > 0 ? relTail.trim() : "";
86
+ 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}${relLine ? ` · ${relLine}` : ""}`, ""].join("\n");
82
87
  }
83
88
  const date = fmtDate(m.createdAt ?? m.updatedAt);
84
89
  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}`;
90
+ return `- ${m.id.slice(0, 8)} [${m.kind}] ${date} ${escapeLt(truncate(m.text, 200))}${tags}${m.hits > 0 ? ` · ×${m.hits}` : ""}${path}${relTail}`;
86
91
  }
87
92
 
88
93
  /** Stable kind display order for grouped recall output (matches MEMORY_KINDS). */
@@ -95,7 +100,7 @@ const MEMORY_KIND_ORDER = ["decision", "error", "procedure", "fact", "insight",
95
100
  * Within a group the original (relevance) order is preserved; unknown kinds
96
101
  * (future-proof) trail the fixed order. Pure, deterministic.
97
102
  */
98
- function groupKindLines(items) {
103
+ function groupKindLines(items, rel) {
99
104
  const buckets = new Map();
100
105
  for (const m of items) {
101
106
  const kind = typeof m.kind === "string" && m.kind.length > 0 ? m.kind : "unknown";
@@ -105,7 +110,7 @@ function groupKindLines(items) {
105
110
  const lines = [];
106
111
  const emit = (kind, group) => {
107
112
  lines.push(`## ${kind} (${group.length})`);
108
- for (const m of group) lines.push(memoryLine(m));
113
+ for (const m of group) lines.push(memoryLine(m, "short", rel));
109
114
  };
110
115
  for (const kind of MEMORY_KIND_ORDER) if (buckets.has(kind)) emit(kind, buckets.get(kind));
111
116
  for (const [kind, group] of buckets) if (!MEMORY_KIND_ORDER.includes(kind)) emit(kind, group);
@@ -122,19 +127,21 @@ export function registerTools(ctx, service) {
122
127
  // ── engram_store ────────────────────────────────────────────────────
123
128
  registerTool({
124
129
  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.",
130
+ 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
131
  parameters: {
127
132
  type: "object",
128
133
  properties: {
129
134
  text: { type: "string", description: "Compact, information-dense memory text (required)" },
130
135
  ...p,
131
136
  file_path: { type: "string", description: "Optional absolute path this memory anchors to (e.g. the failing file)" },
137
+ supersedes: { type: "string", description: "Optional memory id this new statement replaces (old row stays, demoted in recall)" },
138
+ contradicts: { type: "string", description: "Optional memory id this new statement directly contradicts (both stay, flagged)" },
132
139
  },
133
140
  required: ["text"],
134
141
  },
135
142
  output: TEXT_OUTPUT,
136
143
  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");
144
+ 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() }), raw, "engram_store");
138
145
  const caller = callerOf(exec);
139
146
  const domain = await requireDomain(service);
140
147
  await domain.expireSweep(caller.cwd);
@@ -146,6 +153,8 @@ export function registerTools(ctx, service) {
146
153
  tags: args.tags ?? [],
147
154
  entity: args.entity ?? null,
148
155
  filePath: args.file_path ?? null,
156
+ supersedes: args.supersedes ?? null,
157
+ contradicts: args.contradicts ?? null,
149
158
  sessionId: caller.sessionId,
150
159
  seq: caller.seq,
151
160
  signal: 0.6,
@@ -161,14 +170,14 @@ export function registerTools(ctx, service) {
161
170
  // ── engram_recall ───────────────────────────────────────────────────
162
171
  registerTool({
163
172
  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.",
173
+ 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
174
  parameters: {
166
175
  type: "object",
167
176
  properties: {
168
177
  query: { type: "string", description: "Words to match against memory text and tags" },
169
178
  entity: { type: "string", description: "Return that entity's memories chronologically" },
170
179
  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)" },
180
+ max_per_session: { type: "integer", minimum: 1, maximum: 10, description: "Max memories kept per sessionId (default 3; sessions share the cap)" },
172
181
  group_by_kind: { type: "boolean", description: "Group recall hits by kind into sections (default true); false = one flat list" },
173
182
  search_sessions: { type: "boolean", description: "Also FTS search past sessions (requires the session-query index)" },
174
183
  },
@@ -196,7 +205,8 @@ export function registerTools(ctx, service) {
196
205
  items = domain.recall(caller.cwd, args.query, args.limit ?? 12, { maxPerSession });
197
206
  if (items.length === 0) zeroHits = true;
198
207
  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)));
208
+ const rel = domain.memoryRelations(caller.cwd);
209
+ lines.push(...(args.group_by_kind === false ? items.map((m) => memoryLine(m, "short", rel)) : groupKindLines(items, rel)));
200
210
  await Promise.all(items.map((m) => domain.touchMemory(caller.cwd, m.id).catch(() => null)));
201
211
  } else {
202
212
  items = domain.listMemories(caller.cwd, args.limit ?? 12);
@@ -217,9 +227,23 @@ export function registerTools(ctx, service) {
217
227
  }
218
228
  const wantSessions = args.search_sessions === true || (zeroHits && service.config.sessionSearch !== false);
219
229
  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)";
230
+ const q = args.query ?? args.entity ?? "";
231
+ const extra = await searchSessions(service, caller, q, exec.signal);
232
+ if (extra.length > 0) {
233
+ lines.push("", "# past sessions (FTS fallback)", ...extra);
234
+ } else if (hasCJK(q)) {
235
+ // The host FTS index is tokenizer-blind to Chinese (unicode61
236
+ // keeps CJK runs as single tokens), so supplement it with a
237
+ // bounded substring scan over this workspace's own sessions.
238
+ const cjk = searchSessionsCJK({ cwd: caller.cwd, query: q });
239
+ if (cjk.length > 0) {
240
+ lines.push("", "# past sessions (CJK substring scan)", ...cjk.map((h, i) => `${i + 1}. session ${h.sessionId} · ${truncate(h.snippet, 160)}`));
241
+ } else if (zeroHits && lines.length === 1) {
242
+ lines[0] += " (no past-session hits either)";
243
+ }
244
+ } else if (zeroHits && lines.length === 1) {
245
+ lines[0] += " (no past-session hits either)";
246
+ }
223
247
  }
224
248
  return lines.join("\n");
225
249
  },
@@ -228,7 +252,7 @@ export function registerTools(ctx, service) {
228
252
  // ── engram_detail ───────────────────────────────────────────────────
229
253
  registerTool({
230
254
  name: "engram_detail",
231
- description: "Full record of one memory id (from engram_recall): text, tags, entity, signal, hits, provenance session#seq.",
255
+ description: "Full record of one memory id: text, tags, entity, signal, hits, session#seq provenance.",
232
256
  parameters: {
233
257
  type: "object",
234
258
  properties: {
@@ -245,21 +269,21 @@ export function registerTools(ctx, service) {
245
269
  const m = domain.getMemory(caller.cwd, id);
246
270
  if (m === void 0) throw new EngramError("ENGRAM_NOT_FOUND", `no active memory "${args.id}" in this workspace`);
247
271
  await domain.touchMemory(caller.cwd, m.id).catch(() => null);
248
- return memoryLine(m, "full");
272
+ return memoryLine(m, "full", domain.memoryRelations(caller.cwd));
249
273
  },
250
274
  }),
251
275
 
252
276
  // ── esr_task ──────────────────────────────────────────────────────
253
277
  registerTool({
254
278
  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).",
279
+ 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
280
  parameters: {
257
281
  type: "object",
258
282
  properties: {
259
283
  name: { type: "string", description: "Task name (required on create)" },
260
284
  description: { type: "string", description: "What must be produced / satisfied" },
261
285
  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" },
286
+ entity: { type: "string", description: "Domain object to hang this task on — auto-creates ent_<slug> node + task --relates_to--> edge" },
263
287
  },
264
288
  required: ["name"],
265
289
  },
@@ -307,12 +331,12 @@ export function registerTools(ctx, service) {
307
331
  // ── esr_close ─────────────────────────────────────────────────────
308
332
  registerTool({
309
333
  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.",
334
+ 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
335
  parameters: {
312
336
  type: "object",
313
337
  properties: {
314
338
  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" },
339
+ artifact: { type: "string", description: "Produced artifact path/url. Resolved against workspace; must exist on disk unless force:true" },
316
340
  evaluation: { type: "string", description: "Verification evidence (test/review/benchmark)" },
317
341
  memory_refs: { type: "array", items: { type: "string" }, description: "Memory ids that record the decisions made" },
318
342
  force: { type: "boolean", description: "Skip the on-disk artifact existence check (gates are still required)" },
@@ -369,7 +393,7 @@ ${hint}` : ""}`;
369
393
  // ── esr_link ──────────────────────────────────────────────────────
370
394
  registerTool({
371
395
  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.",
396
+ description: "Add a typed relation between entities (depends_on/implements/refines/contradicts…). Feeds entity queries + link count.",
373
397
  parameters: {
374
398
  type: "object",
375
399
  properties: {
@@ -403,7 +427,7 @@ ${hint}` : ""}`;
403
427
 
404
428
  registerTool({
405
429
  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.",
430
+ description: "Add a dependency edge between tasks: blocks (until dep closes), parent-of (child), relates-to (no blocking). Refuses self-deps/cycles.",
407
431
  parameters: {
408
432
  type: "object",
409
433
  properties: {
@@ -444,7 +468,7 @@ ${hint}` : ""}`;
444
468
  // ── esr_claim / esr_unclaim ─────────────────────────────────────
445
469
  registerTool({
446
470
  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.",
471
+ description: "Atomically claim a task (draft→active, assignee+claimedAt). Refuses when another agent holds it unless force:true. Find work with esr_ready.",
448
472
  parameters: {
449
473
  type: "object",
450
474
  properties: {
@@ -467,7 +491,7 @@ ${hint}` : ""}`;
467
491
  }),
468
492
  registerTool({
469
493
  name: "esr_unclaim",
470
- description: "Release a claimed task's assignee back to null. Non-holder needs force:true. Inverse of esr_claim.",
494
+ description: "Release a claimed task's assignee to null. Non-holder needs force:true. Inverse of esr_claim.",
471
495
  parameters: {
472
496
  type: "object",
473
497
  properties: {
@@ -516,12 +540,12 @@ ${hint}` : ""}`;
516
540
  registerTool({
517
541
  name: "esr_status",
518
542
  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.",
543
+ "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
544
  parameters: {
521
545
  type: "object",
522
546
  properties: {
523
547
  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" },
548
+ since_revision: { type: "string", description: "Last-seen ESR revision; unchanged state returns a short response" },
525
549
  },
526
550
  },
527
551
  output: TEXT_OUTPUT,
@@ -542,7 +566,7 @@ ${hint}` : ""}`;
542
566
  // ── esr_node ─────────────────────────────────────
543
567
  registerTool({
544
568
  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>.",
569
+ 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
570
  parameters: {
547
571
  type: "object",
548
572
  properties: {
@@ -584,7 +608,7 @@ ${hint}` : ""}`;
584
608
  // ── esr_gc ────────────────────────────────────────────────────────
585
609
  registerTool({
586
610
  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.",
611
+ 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
612
  parameters: {
589
613
  type: "object",
590
614
  properties: {
@@ -605,7 +629,7 @@ ${hint}` : ""}`;
605
629
  // ── esr_model ─────────────────────────────────────────────────────
606
630
  registerTool({
607
631
  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.",
632
+ 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
633
  parameters: {
610
634
  type: "object",
611
635
  properties: {
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
@@ -47,6 +47,7 @@ function decodeZstd(file) {
47
47
  return "";
48
48
  }
49
49
  }
50
+ export { decodeZstd };
50
51
 
51
52
  function listDirOrEmpty(dir) {
52
53
  try {