dsh-engram 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +331 -0
- package/README.zh.md +276 -0
- package/cordis.patch.yml +10 -0
- package/lib/api.js +418 -0
- package/lib/capture.js +208 -0
- package/lib/client.js +1438 -0
- package/lib/index-block.js +179 -0
- package/lib/index.js +322 -0
- package/lib/settings.js +95 -0
- package/lib/store.js +565 -0
- package/lib/tools.js +471 -0
- package/lib/usage.js +96 -0
- package/lib/util.js +119 -0
- package/package.json +92 -0
package/lib/tools.js
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-engram: model-facing tools.
|
|
3
|
+
*
|
|
4
|
+
* Eight small, tight-schema tools (deliberately fewer than the market's 8-12 —
|
|
5
|
+
* tool schemas cost tokens in every request):
|
|
6
|
+
* 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_gc — pi-esr memory garbage collection
|
|
9
|
+
*
|
|
10
|
+
* Every call is recorded into the per-(workspace, day) usage rollup so the
|
|
11
|
+
* GUI can report real ESR-proactivity and recall rates (see usage.js).
|
|
12
|
+
*
|
|
13
|
+
* All reads are deterministic local scans; `engram_recall` may additionally
|
|
14
|
+
* fall back to `ctx.sessionQuery` FTS over past sessions when mounted.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
import { EngramError, hasCode } from "./store.js";
|
|
19
|
+
import { truncate, shortId, slugId, fmtDate, escapeLt, workspaceKey as wk, uuid } from "./util.js";
|
|
20
|
+
import { makeUsageTracker } from "./usage.js";
|
|
21
|
+
|
|
22
|
+
const KINDS = [
|
|
23
|
+
"decision",
|
|
24
|
+
"error",
|
|
25
|
+
"procedure",
|
|
26
|
+
"fact",
|
|
27
|
+
"insight",
|
|
28
|
+
"handoff",
|
|
29
|
+
"task",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
/** Standard string output projection (mirrors native tool conventions). */
|
|
33
|
+
const TEXT_OUTPUT = {
|
|
34
|
+
schema: { type: "string" },
|
|
35
|
+
render: (_args, value) => [{ type: "text", text: String(value) }],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const p = {
|
|
39
|
+
kind: { type: "string", enum: KINDS, description: "Memory kind (default fact)" },
|
|
40
|
+
tags: { type: "array", items: { type: "string" }, description: "Routing tags" },
|
|
41
|
+
entity: { type: "string", description: "Opaque entity anchor (pi-esr entity_id convention)" },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function callerOf(exec) {
|
|
45
|
+
const agent = exec?.agent;
|
|
46
|
+
if (agent === void 0) throw new EngramError("ENGRAM_MISSING_AGENT", "engram tools require an agent-bound caller");
|
|
47
|
+
const cwd = agent.session?.header?.cwd;
|
|
48
|
+
if (cwd === void 0 || cwd.length === 0) throw new EngramError("ENGRAM_UNAUTHORIZED", "this session has no workspace cwd; engram memory is workspace-scoped");
|
|
49
|
+
return { agent, cwd: wk(cwd), sessionId: agent.session.id, seq: agent.session.events?.length ?? 0 };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parse(zodSchema, raw, tool) {
|
|
53
|
+
const result = zodSchema.safeParse(raw);
|
|
54
|
+
if (!result.success) throw new EngramError("ENGRAM_INVALID_ARGS", `${tool}: ${describeZodError(result.error)}`);
|
|
55
|
+
return result.data;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function describeZodError(error) {
|
|
59
|
+
try {
|
|
60
|
+
return z.prettifyError(error);
|
|
61
|
+
} catch {
|
|
62
|
+
return String(error);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function requireDomain(service) {
|
|
67
|
+
try {
|
|
68
|
+
return await service.getDomain();
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error instanceof EngramError) throw error;
|
|
71
|
+
throw new EngramError("ENGRAM_UNAVAILABLE", `engram domain could not be opened: ${String(error)} — memory offline`, { cause: error });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function memoryLine(m, verbosity = "short") {
|
|
76
|
+
const tags = m.entity !== null ? ` · entity:${m.entity}` : "";
|
|
77
|
+
if (verbosity === "full") {
|
|
78
|
+
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}`, ""].join("\n");
|
|
79
|
+
}
|
|
80
|
+
const date = fmtDate(m.createdAt ?? m.updatedAt);
|
|
81
|
+
return `- ${m.id.slice(0, 8)} [${m.kind}] ${date} ${escapeLt(truncate(m.text, 200))}${tags}${m.hits > 0 ? ` · ×${m.hits}` : ""}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function registerTools(ctx, service) {
|
|
85
|
+
return ctx.effect(() => {
|
|
86
|
+
const usage = makeUsageTracker();
|
|
87
|
+
// Wrap registration so every model-facing tool call is recorded into the
|
|
88
|
+
// per-(workspace, day) usage rollup (real ESR-proactivity / recall stats).
|
|
89
|
+
const registerTool = (tool) => {
|
|
90
|
+
const execute = tool.execute;
|
|
91
|
+
return ctx.tools.register({
|
|
92
|
+
...tool,
|
|
93
|
+
execute: async (raw, exec) => {
|
|
94
|
+
try {
|
|
95
|
+
const out = await execute(raw, exec);
|
|
96
|
+
void usage
|
|
97
|
+
.record(service, exec, tool.name, {
|
|
98
|
+
ok: true,
|
|
99
|
+
recallOutput: tool.name === "engram_recall" ? out : void 0,
|
|
100
|
+
})
|
|
101
|
+
.catch(() => {});
|
|
102
|
+
return out;
|
|
103
|
+
} catch (error) {
|
|
104
|
+
void usage.record(service, exec, tool.name, { ok: false }).catch(() => {});
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
const disposers = [
|
|
111
|
+
// ── engram_store ────────────────────────────────────────────────────
|
|
112
|
+
registerTool({
|
|
113
|
+
name: "engram_store",
|
|
114
|
+
description: "Explicitly store one memory in this workspace's long-term memory. Kinds: decision/error/procedure/fact/insight/handoff/task (D/E/P/F/I/H/T prefixes in the [ENGRAM] index). Pass an entity to anchor it (pi-esr entity_id convention). Auto-capture already records git/file/error events — use this for anything it misses. Exact-duplicate text refreshes the existing entry instead of adding.",
|
|
115
|
+
parameters: {
|
|
116
|
+
type: "object",
|
|
117
|
+
properties: {
|
|
118
|
+
text: { type: "string", description: "Compact, information-dense memory text (required)" },
|
|
119
|
+
...p,
|
|
120
|
+
},
|
|
121
|
+
required: ["text"],
|
|
122
|
+
},
|
|
123
|
+
output: TEXT_OUTPUT,
|
|
124
|
+
execute: async (raw, exec) => {
|
|
125
|
+
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() }), raw, "engram_store");
|
|
126
|
+
const caller = callerOf(exec);
|
|
127
|
+
const domain = await requireDomain(service);
|
|
128
|
+
await domain.expireSweep(caller.cwd);
|
|
129
|
+
const { stored, duplicated } = await domain.storeMemory(
|
|
130
|
+
{
|
|
131
|
+
workspace: caller.cwd,
|
|
132
|
+
kind: args.kind ?? "fact",
|
|
133
|
+
text: args.text,
|
|
134
|
+
tags: args.tags ?? [],
|
|
135
|
+
entity: args.entity ?? null,
|
|
136
|
+
sessionId: caller.sessionId,
|
|
137
|
+
seq: caller.seq,
|
|
138
|
+
signal: 0.6,
|
|
139
|
+
},
|
|
140
|
+
service.config,
|
|
141
|
+
);
|
|
142
|
+
return `stored ${duplicated ? "(duplicate refreshed) " : ""}memory ${stored.id} [${stored.kind}] — cited to session ${stored.sessionId}#${stored.seq}`;
|
|
143
|
+
},
|
|
144
|
+
}),
|
|
145
|
+
|
|
146
|
+
// ── engram_recall ───────────────────────────────────────────────────
|
|
147
|
+
registerTool({
|
|
148
|
+
name: "engram_recall",
|
|
149
|
+
description: "Deterministic recall over this workspace's engram memories: exact tag match ranks first, case-insensitive text substring next, recency breaks ties. Literal, not semantic — query in the language the memory is likely written in. Pass query to filter, entity to restrict to an entity's memories (timeline), search_sessions:true to also full-text search PAST sessions of this workspace via the harness session index. Hit counts promote low-signal auto-captures into the [ENGRAM] index.",
|
|
150
|
+
parameters: {
|
|
151
|
+
type: "object",
|
|
152
|
+
properties: {
|
|
153
|
+
query: { type: "string", description: "Words to match against memory text and tags" },
|
|
154
|
+
entity: { type: "string", description: "Return that entity's memories chronologically" },
|
|
155
|
+
limit: { type: "integer", minimum: 1, maximum: 50, description: "Max memories (default 12)" },
|
|
156
|
+
search_sessions: { type: "boolean", description: "Also FTS search past sessions (requires the session-query index)" },
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
output: TEXT_OUTPUT,
|
|
160
|
+
timeoutMs: 30000,
|
|
161
|
+
execute: async (raw, exec) => {
|
|
162
|
+
const args = parse(z.object({ query: z.string().optional(), entity: z.string().optional(), limit: z.number().int().min(1).max(50).optional(), search_sessions: z.boolean().optional() }), raw, "engram_recall");
|
|
163
|
+
const caller = callerOf(exec);
|
|
164
|
+
const domain = await requireDomain(service);
|
|
165
|
+
let lines = [];
|
|
166
|
+
if (args.entity !== void 0 && args.entity.trim().length > 0) {
|
|
167
|
+
const items = domain.timeline(caller.cwd, args.entity, args.limit ?? 30);
|
|
168
|
+
if (items.length === 0) return `no memories for entity "${args.entity}" in this workspace`;
|
|
169
|
+
lines.push(`# timeline: ${args.entity}`);
|
|
170
|
+
lines.push(...items.map((m) => memoryLine(m)));
|
|
171
|
+
await Promise.all(items.map((m) => domain.touchMemory(caller.cwd, m.id).catch(() => null)));
|
|
172
|
+
} else if (args.query !== void 0) {
|
|
173
|
+
const items = domain.recall(caller.cwd, args.query, args.limit ?? 12);
|
|
174
|
+
if (items.length === 0) return `no active memories match ${JSON.stringify(args.query)} in this workspace`;
|
|
175
|
+
lines.push(`# recall: ${JSON.stringify(args.query)} (${items.length})`);
|
|
176
|
+
lines.push(...items.map((m) => memoryLine(m)));
|
|
177
|
+
await Promise.all(items.map((m) => domain.touchMemory(caller.cwd, m.id).catch(() => null)));
|
|
178
|
+
} else {
|
|
179
|
+
const items = domain.listMemories(caller.cwd, args.limit ?? 12);
|
|
180
|
+
if (items.length === 0) return "no active memories in this workspace yet";
|
|
181
|
+
lines.push(`# newest memories (${items.length})`);
|
|
182
|
+
lines.push(...items.map((m) => memoryLine(m)));
|
|
183
|
+
}
|
|
184
|
+
if (args.search_sessions === true) {
|
|
185
|
+
const extra = await searchSessions(service, caller, args.query ?? args.entity ?? "", exec.signal);
|
|
186
|
+
if (extra.length > 0) lines.push("", "# past sessions (FTS)", ...extra);
|
|
187
|
+
}
|
|
188
|
+
return lines.join("\n");
|
|
189
|
+
},
|
|
190
|
+
}),
|
|
191
|
+
|
|
192
|
+
// ── engram_detail ───────────────────────────────────────────────────
|
|
193
|
+
registerTool({
|
|
194
|
+
name: "engram_detail",
|
|
195
|
+
description: "Full record of one memory id from the [ENGRAM] index (run engram_recall to find ids): text, tags, entity, signal, hits, provenance session#seq.",
|
|
196
|
+
parameters: {
|
|
197
|
+
type: "object",
|
|
198
|
+
properties: {
|
|
199
|
+
id: { type: "string", description: "Memory id (e.g. the #xxxxx marker in the [ENGRAM] index is the first 8 chars)" },
|
|
200
|
+
},
|
|
201
|
+
required: ["id"],
|
|
202
|
+
},
|
|
203
|
+
output: TEXT_OUTPUT,
|
|
204
|
+
execute: async (raw, exec) => {
|
|
205
|
+
const args = parse(z.object({ id: z.string().min(1) }), raw, "engram_detail");
|
|
206
|
+
const caller = callerOf(exec);
|
|
207
|
+
const domain = await requireDomain(service);
|
|
208
|
+
const id = resolveId(domain, caller.cwd, args.id);
|
|
209
|
+
const m = domain.getMemory(caller.cwd, id);
|
|
210
|
+
if (m === void 0) throw new EngramError("ENGRAM_NOT_FOUND", `no active memory "${args.id}" in this workspace`);
|
|
211
|
+
await domain.touchMemory(caller.cwd, m.id).catch(() => null);
|
|
212
|
+
return memoryLine(m, "full");
|
|
213
|
+
},
|
|
214
|
+
}),
|
|
215
|
+
|
|
216
|
+
// ── esr_task ──────────────────────────────────────────────────────
|
|
217
|
+
registerTool({
|
|
218
|
+
name: "esr_task",
|
|
219
|
+
description: "Create or update an engineering task entity (draft→active). ESR: a task is only 'done' when it has real evidence — close it with esr_close (artifact + evaluation + memory_ref). Set id to update an existing task.",
|
|
220
|
+
parameters: {
|
|
221
|
+
type: "object",
|
|
222
|
+
properties: {
|
|
223
|
+
name: { type: "string", description: "Task name (required on create)" },
|
|
224
|
+
description: { type: "string", description: "What must be produced / satisfied" },
|
|
225
|
+
id: { type: "string", description: "Existing task id to update" },
|
|
226
|
+
},
|
|
227
|
+
required: ["name"],
|
|
228
|
+
},
|
|
229
|
+
output: TEXT_OUTPUT,
|
|
230
|
+
execute: async (raw, exec) => {
|
|
231
|
+
const args = parse(z.object({ name: z.string().min(1).max(200), description: z.string().optional(), id: z.string().optional() }), raw, "esr_task");
|
|
232
|
+
const caller = callerOf(exec);
|
|
233
|
+
const domain = await requireDomain(service);
|
|
234
|
+
const existing = args.id === void 0 ? void 0 : domain.getTask(caller.cwd, args.id);
|
|
235
|
+
if (args.id !== void 0 && existing === void 0) throw new EngramError("ENGRAM_NOT_FOUND", `no task "${args.id}" in this workspace`);
|
|
236
|
+
if (existing === void 0 && domain.activeTaskCount(caller.cwd) >= service.config.maxTasksPerWorkspace) {
|
|
237
|
+
throw new EngramError("ENGRAM_CAP_EXCEEDED", `workspace already holds ${service.config.maxTasksPerWorkspace} active tasks; close some with esr_close first`);
|
|
238
|
+
}
|
|
239
|
+
const now = Date.now();
|
|
240
|
+
const task = {
|
|
241
|
+
id: existing?.id ?? `tsk_${shortId(uuid())}`,
|
|
242
|
+
workspace: caller.cwd,
|
|
243
|
+
name: args.name,
|
|
244
|
+
description: args.description ?? existing?.description ?? "",
|
|
245
|
+
state: existing?.state ?? "active",
|
|
246
|
+
artifact: existing?.artifact ?? null,
|
|
247
|
+
evaluation: existing?.evaluation ?? null,
|
|
248
|
+
memoryRefs: existing?.memoryRefs ?? [],
|
|
249
|
+
sessionId: caller.sessionId,
|
|
250
|
+
createdAt: existing?.createdAt ?? now,
|
|
251
|
+
updatedAt: now,
|
|
252
|
+
stateChangedAt: existing?.stateChangedAt ?? 0,
|
|
253
|
+
};
|
|
254
|
+
await domain.putTask(task);
|
|
255
|
+
return `task ${task.id} [${task.state}] — ${task.name}`;
|
|
256
|
+
},
|
|
257
|
+
}),
|
|
258
|
+
|
|
259
|
+
// ── esr_close ─────────────────────────────────────────────────────
|
|
260
|
+
registerTool({
|
|
261
|
+
name: "esr_close",
|
|
262
|
+
description: "Close a task via the ESR evidence protocol. 'stable' requires ALL THREE gates: artifact (path/url of the produced artifact), evaluation (how it was verified — test run, review, benchmark score), and memory_ref (list of engram memory ids capturing decisions made). Missing gates are reported as gaps and the task stays active. On success a 'task closed' memory is auto-stored with the evidence.",
|
|
263
|
+
parameters: {
|
|
264
|
+
type: "object",
|
|
265
|
+
properties: {
|
|
266
|
+
task_id: { type: "string", description: "Task id from [ESR] or esr_task" },
|
|
267
|
+
artifact: { type: "string", description: "Produced artifact (path/url)" },
|
|
268
|
+
evaluation: { type: "string", description: "Verification evidence (test/review/benchmark)" },
|
|
269
|
+
memory_refs: { type: "array", items: { type: "string" }, description: "Memory ids that record the decisions made" },
|
|
270
|
+
},
|
|
271
|
+
required: ["task_id"],
|
|
272
|
+
},
|
|
273
|
+
output: TEXT_OUTPUT,
|
|
274
|
+
execute: async (raw, exec) => {
|
|
275
|
+
const args = parse(z.object({ task_id: z.string().min(1), artifact: z.string().optional(), evaluation: z.string().optional(), memory_refs: z.array(z.string()).optional() }), raw, "esr_close");
|
|
276
|
+
const caller = callerOf(exec);
|
|
277
|
+
const domain = await requireDomain(service);
|
|
278
|
+
const task = domain.getTask(caller.cwd, resolveTaskId(domain, caller.cwd, args.task_id));
|
|
279
|
+
if (task === void 0) throw new EngramError("ENGRAM_NOT_FOUND", `no task "${args.task_id}" in this workspace`);
|
|
280
|
+
const next = {
|
|
281
|
+
...task,
|
|
282
|
+
artifact: args.artifact ?? task.artifact ?? null,
|
|
283
|
+
evaluation: args.evaluation ?? task.evaluation ?? null,
|
|
284
|
+
memoryRefs: args.memory_refs ?? task.memoryRefs,
|
|
285
|
+
updatedAt: Date.now(),
|
|
286
|
+
};
|
|
287
|
+
const gaps = [];
|
|
288
|
+
if (next.artifact === null || next.artifact === "") gaps.push("artifact");
|
|
289
|
+
if (next.evaluation === null || next.evaluation === "") gaps.push("evaluation");
|
|
290
|
+
if (next.memoryRefs.length === 0) gaps.push("memory_ref");
|
|
291
|
+
if (gaps.length > 0) {
|
|
292
|
+
await domain.putTask(next);
|
|
293
|
+
return `task ${task.id} still ACTIVE — evidence gaps: ${gaps.join(", ")}. Provide them and call esr_close again (the [ESR] block shows current gaps).`;
|
|
294
|
+
}
|
|
295
|
+
const closed = { ...next, state: "stable", stateChangedAt: Date.now() };
|
|
296
|
+
await domain.putTask(closed);
|
|
297
|
+
const mem = await domain.storeMemory(
|
|
298
|
+
{
|
|
299
|
+
workspace: caller.cwd,
|
|
300
|
+
kind: "task",
|
|
301
|
+
text: `Task closed (${task.name}): artifact=${next.artifact} eval=${next.evaluation} refs=${next.memoryRefs.length}`,
|
|
302
|
+
tags: ["esr-closure", "task"],
|
|
303
|
+
entity: task.id,
|
|
304
|
+
sessionId: caller.sessionId,
|
|
305
|
+
seq: caller.seq,
|
|
306
|
+
signal: 0.8,
|
|
307
|
+
},
|
|
308
|
+
service.config,
|
|
309
|
+
);
|
|
310
|
+
return `task ${task.id} → STABLE ✓ (artifact, evaluation, ${next.memoryRefs.length} memory ref(s))\nclosure memory: ${mem.id}`;
|
|
311
|
+
},
|
|
312
|
+
}),
|
|
313
|
+
|
|
314
|
+
// ── esr_link ──────────────────────────────────────────────────────
|
|
315
|
+
registerTool({
|
|
316
|
+
name: "esr_link",
|
|
317
|
+
description: "Add a typed relation between two entities (mini graph, pi-esr flavor), e.g. depends_on / implements / refines / contradicts. Relations surface in engram_timeline-style queries via entity and in the workspace's link count.",
|
|
318
|
+
parameters: {
|
|
319
|
+
type: "object",
|
|
320
|
+
properties: {
|
|
321
|
+
source: { type: "string", description: "Source entity id/label (required)" },
|
|
322
|
+
relation: { type: "string", description: "Relation type, e.g. depends_on (required)" },
|
|
323
|
+
target: { type: "string", description: "Target entity id/label (required)" },
|
|
324
|
+
confidence: { type: "number", minimum: 0, maximum: 1, description: "Confidence 0..1 (default 1)" },
|
|
325
|
+
},
|
|
326
|
+
required: ["source", "relation", "target"],
|
|
327
|
+
},
|
|
328
|
+
output: TEXT_OUTPUT,
|
|
329
|
+
execute: async (raw, exec) => {
|
|
330
|
+
const args = parse(z.object({ source: z.string().min(1), relation: z.string().min(1), target: z.string().min(1), confidence: z.number().min(0).max(1).optional() }), raw, "esr_link");
|
|
331
|
+
const caller = callerOf(exec);
|
|
332
|
+
const domain = await requireDomain(service);
|
|
333
|
+
const id = uuid();
|
|
334
|
+
await domain.addLink({
|
|
335
|
+
id,
|
|
336
|
+
workspace: caller.cwd,
|
|
337
|
+
source: args.source,
|
|
338
|
+
relation: args.relation,
|
|
339
|
+
target: args.target,
|
|
340
|
+
confidence: args.confidence ?? 1,
|
|
341
|
+
sessionId: caller.sessionId,
|
|
342
|
+
createdAt: Date.now(),
|
|
343
|
+
});
|
|
344
|
+
return `link ${id} — ${args.source} --${args.relation}--> ${args.target}`;
|
|
345
|
+
},
|
|
346
|
+
}),
|
|
347
|
+
|
|
348
|
+
// ── esr_node ──────────────────────────────────────────────────────
|
|
349
|
+
registerTool({
|
|
350
|
+
name: "esr_node",
|
|
351
|
+
description: "Create or update an entity node — a stable symbol for a thing this workspace keeps referring to (a package, service, document, repository, person, bug, concept). Nodes anchor esr_link relations and the `entity` field of memories. The id is derived from the name (`ent_<slug>`), so calling esr_node with the same name updates the same node and esr_link can target it without a prior id lookup. Be proactive: create a node when a domain object recurs across your work, and link related nodes/tasks with esr_link.",
|
|
352
|
+
parameters: {
|
|
353
|
+
type: "object",
|
|
354
|
+
properties: {
|
|
355
|
+
name: { type: "string", description: "Node name — e.g. \"dsh-engram\" gives id ent_dsh-engram" },
|
|
356
|
+
description: { type: "string", description: "What this node is / why it matters (optional)" },
|
|
357
|
+
kind: { type: "string", description: "Kind tag: package / service / doc / repo / person / bug / module ... (optional)" },
|
|
358
|
+
},
|
|
359
|
+
required: ["name"],
|
|
360
|
+
},
|
|
361
|
+
output: TEXT_OUTPUT,
|
|
362
|
+
execute: async (raw, exec) => {
|
|
363
|
+
const args = parse(z.object({ name: z.string().min(1).max(120), description: z.string().optional(), kind: z.string().optional() }), raw, "esr_node");
|
|
364
|
+
const caller = callerOf(exec);
|
|
365
|
+
const domain = await requireDomain(service);
|
|
366
|
+
const id = `ent_${slugId(args.name)}`;
|
|
367
|
+
const existing = domain.getEntity(caller.cwd, id);
|
|
368
|
+
const now = Date.now();
|
|
369
|
+
const node = {
|
|
370
|
+
id,
|
|
371
|
+
workspace: caller.cwd,
|
|
372
|
+
name: args.name.trim().slice(0, 120),
|
|
373
|
+
description: (args.description ?? existing?.description ?? "").trim().slice(0, 2000),
|
|
374
|
+
kind: (args.kind ?? existing?.kind ?? "").trim().slice(0, 40),
|
|
375
|
+
sessionId: caller.sessionId,
|
|
376
|
+
createdAt: existing?.createdAt ?? now,
|
|
377
|
+
updatedAt: now,
|
|
378
|
+
};
|
|
379
|
+
await domain.putEntity(node);
|
|
380
|
+
return existing
|
|
381
|
+
? `node ${id} updated — ${node.name}${node.kind ? ` (${node.kind})` : ""}`
|
|
382
|
+
: `node ${id} created — ${node.name}${node.kind ? ` (${node.kind})` : ""}`;
|
|
383
|
+
},
|
|
384
|
+
}),
|
|
385
|
+
|
|
386
|
+
// ── esr_gc ────────────────────────────────────────────────────────
|
|
387
|
+
registerTool({
|
|
388
|
+
name: "esr_gc",
|
|
389
|
+
description: "Run the pi-esr-style memory GC for this workspace. Archives TTL-expired memories, evicts over-cap low-value memories, archives stable tasks past their retention window, and removes dangling link edges. The WORKING SET is never touched (memories referenced by active tasks, task memories, already-indexed hits). Archive-only — nothing is hard-deleted, every entry stays re-fetchable by id. dry_run:true previews without writing.",
|
|
390
|
+
parameters: {
|
|
391
|
+
type: "object",
|
|
392
|
+
properties: {
|
|
393
|
+
workspace: { type: "string", description: "Override the workspace key (default: current session cwd)" },
|
|
394
|
+
dry_run: { type: "boolean", description: "Preview the sweep without mutating (default false)" },
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
output: TEXT_OUTPUT,
|
|
398
|
+
execute: async (raw, exec) => {
|
|
399
|
+
const args = parse(z.object({ workspace: z.string().optional(), dry_run: z.boolean().optional() }), raw, "esr_gc");
|
|
400
|
+
const caller = callerOf(exec);
|
|
401
|
+
const domain = await requireDomain(service);
|
|
402
|
+
const report = await domain.gc(args.workspace ?? caller.cwd, service.config, { dryRun: args.dry_run === true });
|
|
403
|
+
return gcReportText(report);
|
|
404
|
+
},
|
|
405
|
+
}),
|
|
406
|
+
];
|
|
407
|
+
return () => {
|
|
408
|
+
for (const dispose of disposers) dispose();
|
|
409
|
+
};
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** FTS over past sessions via ctx.sessionQuery (mounted by the web profile). */
|
|
414
|
+
async function searchSessions(service, caller, query, signal) {
|
|
415
|
+
const sq = service.sessionQuery;
|
|
416
|
+
if (sq === void 0 || query.trim().length === 0) return [];
|
|
417
|
+
try {
|
|
418
|
+
const { items = [] } = await sq.searchSessions(
|
|
419
|
+
{
|
|
420
|
+
query: query.trim(),
|
|
421
|
+
sessionFilters: [{ kind: "cwd", values: [caller.cwd] }],
|
|
422
|
+
limit: 5,
|
|
423
|
+
},
|
|
424
|
+
signal !== void 0 ? { signal } : undefined,
|
|
425
|
+
);
|
|
426
|
+
return items
|
|
427
|
+
.filter((hit) => hit.header?.id !== caller.sessionId)
|
|
428
|
+
.slice(0, 3)
|
|
429
|
+
.map((hit, i) => `${i + 1}. session ${hit.header?.id ?? "?"} · ${truncate(hit.bestMatch?.snippet ?? "", 160)}`);
|
|
430
|
+
} catch (error) {
|
|
431
|
+
if (hasCode(error, "SESSION_QUERY_SEARCH_DISABLED")) return [];
|
|
432
|
+
service.log?.warn?.(`engram_recall session search failed: ${String(error)}`);
|
|
433
|
+
return [];
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Resolve a partial id (the #xxxx 8-char marker) to a full memory id. */
|
|
438
|
+
function resolveId(domain, workspace, input) {
|
|
439
|
+
const candidates = domain.listMemories(workspace, 200).filter((m) => m.id.startsWith(input) || m.id.slice(0, 8) === input);
|
|
440
|
+
if (candidates.length === 1) return candidates[0].id;
|
|
441
|
+
return input; // pass through; getMemory will yield NOT_FOUND if no exact match
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Resolve partial task ids the same way. */
|
|
445
|
+
function resolveTaskId(domain, workspace, input) {
|
|
446
|
+
const candidates = domain.listTasks(workspace, { includeStable: true }).filter((t) => t.id === input || t.id.startsWith(input));
|
|
447
|
+
if (candidates.length === 1) return candidates[0].id;
|
|
448
|
+
return input;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Compact, pointer-honest report text for `esr_gc` (token-conscious). */
|
|
452
|
+
function gcReportText(report) {
|
|
453
|
+
const prefix = report.dryRun ? "GC dry-run" : "GC done";
|
|
454
|
+
const lines = [
|
|
455
|
+
`${prefix}${report.workspaces.length === 1 ? ` workspace ${report.workspaces[0]}` : ` ${report.workspaces.length} workspaces`}: ` +
|
|
456
|
+
`${report.archivedMemories.length} memories archived · ${report.archivedTasks.length} tasks archived · ` +
|
|
457
|
+
`${report.removedLinks.length} dangling links removed · ${report.protectedMemories} protected (working set)`,
|
|
458
|
+
];
|
|
459
|
+
for (const e of report.archivedMemories.slice(0, 8)) {
|
|
460
|
+
lines.push(`- archive memory ${e.id.slice(0, 8)} [${e.kind}] (${e.reason}): ${truncate(e.text, 56)}`);
|
|
461
|
+
}
|
|
462
|
+
if (report.archivedMemories.length > 8) lines.push(`… +${report.archivedMemories.length - 8} more archived memories`);
|
|
463
|
+
for (const t of report.archivedTasks.slice(0, 5)) {
|
|
464
|
+
lines.push(`- archive task ${t.id.slice(0, 6)} (${t.reason}): ${truncate(t.name, 56)}`);
|
|
465
|
+
}
|
|
466
|
+
for (const l of report.removedLinks.slice(0, 5)) {
|
|
467
|
+
lines.push(`- drop link ${l.source.slice(0, 8)} --${l.relation}--> ${l.target.slice(0, 8)} (dangling)`);
|
|
468
|
+
}
|
|
469
|
+
lines.push("pointers: archived entries keep their ids — re-fetch via engram_detail / GUI archived filter; nothing was hard-deleted");
|
|
470
|
+
return lines.join("\n");
|
|
471
|
+
}
|
package/lib/usage.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-engram: real-data agent-behaviour observability.
|
|
3
|
+
*
|
|
4
|
+
* Every engram_* / esr_* tool call made by the model is recorded into the
|
|
5
|
+
* store's per-(workspace, day) `usage` rollup: counts per tool, failures,
|
|
6
|
+
* and recall mechanics. The GUI (`/api/dsh-engram/stats`) turns that into the
|
|
7
|
+
* "proactive ESR ratio" and "recall hit rate" numbers — real measurements
|
|
8
|
+
* from real sessions, not a static benchmark.
|
|
9
|
+
*
|
|
10
|
+
* Zero-dependency, fire-and-forget: recording must never disturb the tool
|
|
11
|
+
* pipeline, so callers use `void record(...).catch(() => {})`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { workspaceKey as wk } from "./util.js";
|
|
15
|
+
|
|
16
|
+
/** Local YYYY-MM-DD, so daily rollups land on calendar days for the user. */
|
|
17
|
+
export function dayKey(now = Date.now()) {
|
|
18
|
+
const d = new Date(now);
|
|
19
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
20
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Count recalled memory rows from a engram_recall output string. The tool
|
|
25
|
+
* renders one `- <id> …` line per item (or a `no active memories…` / `no
|
|
26
|
+
* memories for entity…` message on zero hits); we parse that shape.
|
|
27
|
+
*/
|
|
28
|
+
export function recallStatsFromOutput(out) {
|
|
29
|
+
const s = String(out ?? "");
|
|
30
|
+
if (/^\s*(no active memories|no memories for entity)/i.test(s)) {
|
|
31
|
+
return { withHits: 0, hitsTotal: 0 };
|
|
32
|
+
}
|
|
33
|
+
const hitsTotal = (s.match(/\n- /g) ?? []).length + (s.startsWith("- ") ? 1 : 0);
|
|
34
|
+
return { withHits: hitsTotal > 0 ? 1 : 0, hitsTotal };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Session-local drill-through window: a engram_detail within N events of a hit recall counts as a follow-up. */
|
|
38
|
+
const DETAIL_FOLLOW_WINDOW = 8;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One tracker per plugin instance. Holds the cross-call state used to infer
|
|
42
|
+
* "recall → detail drill-through" from the raw tool stream.
|
|
43
|
+
*/
|
|
44
|
+
export function makeUsageTracker() {
|
|
45
|
+
const recallFresh = new Map(); // sessionId -> event seq of last hit recall
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
/**
|
|
49
|
+
* Record one tool-call outcome. `opts`: { ok, recallOutput? }.
|
|
50
|
+
* Resolves the caller from `exec` exactly like tools.js `callerOf`. Returns
|
|
51
|
+
* a promise; callers should fire-and-forget it.
|
|
52
|
+
*/
|
|
53
|
+
async record(service, exec, name, opts) {
|
|
54
|
+
const agent = exec?.agent;
|
|
55
|
+
if (agent === void 0) return;
|
|
56
|
+
const rawCwd = agent.session?.header?.cwd;
|
|
57
|
+
const sessionId = agent.session?.id;
|
|
58
|
+
if (rawCwd === void 0 || rawCwd.length === 0 || sessionId === void 0) return;
|
|
59
|
+
const workspace = wk(rawCwd);
|
|
60
|
+
const seq = agent.session?.events?.length ?? 0;
|
|
61
|
+
|
|
62
|
+
const counts = { [name]: 1 };
|
|
63
|
+
const recall = {};
|
|
64
|
+
if (name === "engram_recall") {
|
|
65
|
+
if (opts.ok) {
|
|
66
|
+
const { withHits, hitsTotal } = recallStatsFromOutput(opts.recallOutput);
|
|
67
|
+
recall.queries = 1;
|
|
68
|
+
recall.withHits = withHits;
|
|
69
|
+
recall.hitsTotal = hitsTotal;
|
|
70
|
+
if (withHits > 0) recallFresh.set(sessionId, seq);
|
|
71
|
+
else recallFresh.delete(sessionId);
|
|
72
|
+
} else {
|
|
73
|
+
recallFresh.delete(sessionId);
|
|
74
|
+
}
|
|
75
|
+
} else if (name === "engram_detail" && opts.ok && recallFresh.has(sessionId)) {
|
|
76
|
+
const at = recallFresh.get(sessionId);
|
|
77
|
+
if (seq - at <= DETAIL_FOLLOW_WINDOW) {
|
|
78
|
+
recall.detailFollows = 1;
|
|
79
|
+
recallFresh.delete(sessionId);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const domain = await service.getDomain();
|
|
85
|
+
await domain.bumpUsage(workspace, dayKey(), {
|
|
86
|
+
counts,
|
|
87
|
+
failures: opts.ok ? 0 : 1,
|
|
88
|
+
recall,
|
|
89
|
+
});
|
|
90
|
+
} catch (error) {
|
|
91
|
+
// observability is best-effort; a store hiccup must not surface here
|
|
92
|
+
service?.log?.warn?.(`engram usage record failed: ${String(error)}`);
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|