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/README.md +283 -27
- package/README.zh.md +184 -16
- 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 +76 -2
- package/lib/capture.js +132 -4
- package/lib/client.js +45 -2
- package/lib/compaction.js +2 -18
- package/lib/context-gc.js +234 -2
- package/lib/goal-capture.js +89 -0
- package/lib/index-block.js +96 -1
- package/lib/index.js +233 -5
- package/lib/redact.js +48 -0
- package/lib/session-cjk.js +159 -0
- package/lib/settings.js +13 -0
- package/lib/status.js +57 -0
- package/lib/store.js +411 -6
- package/lib/todo-sink.js +146 -0
- package/lib/tools.js +98 -40
- package/lib/trigger.js +10 -8
- package/lib/usage.js +147 -5
- package/lib/web-provision.js +430 -0
- package/package.json +13 -2
- 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,26 @@
|
|
|
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
|
+
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
|
-
|
|
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
|
|
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
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
199
|
-
lines.push(
|
|
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
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
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).
|
|
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: "
|
|
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:
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
"
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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 "";
|
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
|
|
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
|
-
|
|
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 {
|