dsh-engram 0.3.3 → 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/README.md +156 -22
- package/README.zh.md +108 -13
- package/cordis.patch.yml +6 -0
- package/docs/CONTEXT-GC-GUIDE.zh.md +192 -0
- package/docs/PROPOSAL-agentmemory.zh.md +108 -0
- package/docs/PROPOSAL-beads.zh.md +81 -0
- package/docs/PROPOSAL-contextgc.zh.md +188 -0
- package/docs/PROPOSAL-esrtrigger.zh.md +199 -0
- package/docs/PROPOSAL-hindsight.zh.md +156 -0
- package/lib/api.js +19 -0
- package/lib/capture.js +132 -4
- package/lib/client.js +93 -90
- package/lib/compaction.js +2 -18
- package/lib/context-gc.js +234 -2
- package/lib/esrModel.mjs +49 -0
- package/lib/goal-capture.js +89 -0
- package/lib/index-block.js +4 -0
- package/lib/index.js +196 -4
- package/lib/redact.js +48 -0
- package/lib/session-cjk.js +159 -0
- package/lib/settings.js +7 -0
- package/lib/status.js +57 -0
- package/lib/store.js +124 -6
- package/lib/todo-sink.js +146 -0
- package/lib/tools.js +58 -34
- package/lib/trigger.js +10 -8
- package/lib/usage.js +1 -0
- package/lib/web-provision.js +430 -0
- package/package.json +14 -3
- package/scripts/dsh-engram.mjs +268 -0
- package/scripts/engram-web-compaction.mjs +7 -0
- package/scripts/prepublish-check.mjs +106 -0
- package/scripts/setup-links.mjs +241 -0
package/lib/tools.js
CHANGED
|
@@ -1,24 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-engram: model-facing tools.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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 /
|
|
8
|
-
* esr_ready / esr_status
|
|
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
|
-
|
|
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
|
|
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
|
|
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: "
|
|
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
|
-
|
|
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
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
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).
|
|
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: "
|
|
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:
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
"
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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,
|
|
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:
|
|
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(
|
|
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 =
|
|
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:
|
|
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 ??
|
|
372
|
-
const days = Math.max(1, Math.round((
|
|
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 "";
|