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/lib/api.js ADDED
@@ -0,0 +1,418 @@
1
+ /**
2
+ * dsh-engram: read-mostly web API for the GUI (host half).
3
+ *
4
+ * A small same-origin JSON route family under `/api/dsh-engram`. Every route
5
+ * wears the loopback-only trust fence (copied from the SSH family): these
6
+ * routes expose the on-disk memory store, so a LAN-exposed dsh web must not
7
+ * serve them. All reads go straight at the opened storage-domain handle
8
+ * (synchronous after the first open); the two mutations (archive / delete)
9
+ * go through the same store methods the agent tools use.
10
+ */
11
+
12
+ import { uuid, shortId } from "./util.js";
13
+
14
+ /** Route family prefix (kept in sync with the client half). */
15
+ export const API_PREFIX = "/api/dsh-engram";
16
+
17
+ /** Cap on JSON request bodies (archives/deletes are tiny). */
18
+ const MAX_JSON_BODY_BYTES = 32 * 1024;
19
+
20
+ /** Loopback literal check plus browser same-origin markers. */
21
+ function isLoopbackRequest(request, trustedHosts = []) {
22
+ const address = request.socket?.remoteAddress;
23
+ if (address !== "127.0.0.1" && address !== "::1" && address !== "::ffff:127.0.0.1") return false;
24
+ const host = request.headers.host;
25
+ if (typeof host !== "string") return false;
26
+ let hostUrl;
27
+ try {
28
+ hostUrl = new URL(`http://${host}`);
29
+ } catch {
30
+ return false;
31
+ }
32
+ const hostname = hostUrl.hostname;
33
+ if (hostname === "127.0.0.1" || hostname === "localhost" || hostname === "[::1]") {
34
+ if (request.headers["sec-fetch-site"] === "cross-site") return false;
35
+ const origin = request.headers.origin;
36
+ if (origin === undefined) return true;
37
+ try {
38
+ return new URL(origin).host === hostUrl.host;
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+ // Not loopback: only an explicitly configured trusted hostname (e.g. a
44
+ // cloudflare tunnel the operator authorized) may read the store. This is
45
+ // the opt-in that makes the GUI usable when it is reached through a
46
+ // tunnel; the default stays loopback-only.
47
+ if (trustedHosts.some((h) => typeof h === "string" && h.length > 0 && h.toLowerCase() === hostname.toLowerCase())) {
48
+ if (request.headers["sec-fetch-site"] === "cross-site") return false;
49
+ return true;
50
+ }
51
+ return false;
52
+ }
53
+
54
+ function writeJson(res, status, body) {
55
+ const payload = JSON.stringify(body);
56
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8", "referrer-policy": "no-referrer" });
57
+ res.end(payload);
58
+ }
59
+
60
+ function writeError(res, status, message) {
61
+ writeJson(res, status, { error: message });
62
+ }
63
+
64
+ function readQuery(req) {
65
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
66
+ return url.searchParams;
67
+ }
68
+
69
+ function readJsonBody(req) {
70
+ return new Promise((resolve) => {
71
+ let size = 0;
72
+ const chunks = [];
73
+ req.on("data", (chunk) => {
74
+ size += chunk.length;
75
+ if (size > MAX_JSON_BODY_BYTES) {
76
+ resolve({ error: "body too large" });
77
+ req.destroy();
78
+ return;
79
+ }
80
+ chunks.push(chunk);
81
+ });
82
+ req.on("end", () => {
83
+ if (size === 0) {
84
+ resolve({ value: {} });
85
+ return;
86
+ }
87
+ try {
88
+ resolve({ value: JSON.parse(Buffer.concat(chunks).toString("utf8")) });
89
+ } catch {
90
+ resolve({ error: "invalid JSON body" });
91
+ }
92
+ });
93
+ req.on("error", () => resolve({ error: "request error" }));
94
+ });
95
+ }
96
+
97
+ /** Coarse token estimate for a [ENGRAM] index block (ASCII ≈ 4 chars/token). */
98
+ function estimateTokens(text) {
99
+ if (!text) return 0;
100
+ return Math.max(1, Math.ceil([...text].length / 4));
101
+ }
102
+
103
+ /**
104
+ * Build the route list for one plugin instance.
105
+ * @param service - the engram service ({ config, getDomain, openedDomain, ensureDomain }).
106
+ * @returns the routes to pass to `ctx.webServer.register(route)`.
107
+ */
108
+ export function makeEngramRoutes(service) {
109
+ const trustedHosts = Array.isArray(service?.config?.trustedHosts) ? service.config.trustedHosts : [];
110
+ const withDomain = async (res) => {
111
+ const opened = service.openedDomain?.();
112
+ if (opened !== void 0) return opened;
113
+ try {
114
+ return await service.getDomain();
115
+ } catch (error) {
116
+ writeError(res, 503, `engram storage unavailable: ${String(error?.message ?? error)}`);
117
+ return void 0;
118
+ }
119
+ };
120
+
121
+ const readConfig = (_req, res) => {
122
+ const c = service.config ?? {};
123
+ const payload = {
124
+ autoCapture: c.autoCapture,
125
+ sessionSearch: c.sessionSearch,
126
+ autoCapturePerSession: c.autoCapturePerSession,
127
+ indexMaxLines: c.indexMaxLines,
128
+ indexMaxChars: c.indexMaxChars,
129
+ minIndexSignal: c.minIndexSignal,
130
+ promoteHits: c.promoteHits,
131
+ expireDays: c.expireDays,
132
+ maxMemoriesPerWorkspace: c.maxMemoriesPerWorkspace,
133
+ };
134
+ writeJson(res, 200, payload);
135
+ };
136
+
137
+ const overview = async (req, res) => {
138
+ const domain = await withDomain(res);
139
+ if (domain === void 0) return;
140
+ const summary = domain.summarize();
141
+ // [ENGRAM] index estimate: recompute the exact block cost per workspace.
142
+ const indexes = {};
143
+ for (const ws of Object.keys(summary.workspaces)) {
144
+ const block = service.renderIndexBlock?.(ws) ?? "";
145
+ indexes[ws] = { chars: [...block].length, tokens: estimateTokens(block), lines: block.split("\n").length - 1 };
146
+ }
147
+ writeJson(res, 200, {
148
+ ...summary,
149
+ indexes,
150
+ captures: service.captureStats ?? { total: 0, git: 0, file: 0, error: 0 },
151
+ gc: service.gcStats ?? { lastRun: 0, archivedMemories: 0, archivedTasks: 0, removedLinks: 0 },
152
+ config: service.config ?? {},
153
+ anonymous: {
154
+ version: 2,
155
+ note: "counts exclude archived/expired entries; tasks exclude stable.",
156
+ },
157
+ });
158
+ void req;
159
+ };
160
+
161
+ const memories = async (req, res) => {
162
+ const domain = await withDomain(res);
163
+ if (domain === void 0) return;
164
+ const params = readQuery(req);
165
+ const items = domain.searchMemories({
166
+ workspace: params.get("workspace") || void 0,
167
+ q: params.get("q") || void 0,
168
+ kind: params.get("kind") || void 0,
169
+ status: params.get("status") || void 0,
170
+ limit: Number(params.get("limit") ?? 100),
171
+ });
172
+ writeJson(res, 200, { items });
173
+ };
174
+
175
+ const tasks = async (req, res) => {
176
+ const domain = await withDomain(res);
177
+ if (domain === void 0) return;
178
+ const params = readQuery(req);
179
+ const workspace = params.get("workspace");
180
+ if (!workspace) {
181
+ writeError(res, 400, "workspace query parameter is required");
182
+ return;
183
+ }
184
+ const includeStable = params.get("includeStable") === "1";
185
+ writeJson(res, 200, { items: domain.listTasks(workspace, { includeStable }) });
186
+ };
187
+
188
+ const links = async (req, res) => {
189
+ const domain = await withDomain(res);
190
+ if (domain === void 0) return;
191
+ const params = readQuery(req);
192
+ const workspace = params.get("workspace");
193
+ if (!workspace) {
194
+ writeError(res, 400, "workspace query parameter is required");
195
+ return;
196
+ }
197
+ writeJson(res, 200, { items: domain.allLinks(workspace) });
198
+ };
199
+
200
+ const entities = async (req, res) => {
201
+ const domain = await withDomain(res);
202
+ if (domain === void 0) return;
203
+ const params = readQuery(req);
204
+ const workspace = params.get("workspace");
205
+ if (!workspace) {
206
+ writeError(res, 400, "workspace query parameter is required");
207
+ return;
208
+ }
209
+ writeJson(res, 200, { items: domain.listEntities(workspace) });
210
+ };
211
+
212
+ const archive = async (req, res) => {
213
+ const domain = await withDomain(res);
214
+ if (domain === void 0) return;
215
+ const body = await readJsonBody(req);
216
+ if (body.error) return writeError(res, 400, body.error);
217
+ const { id, workspace } = body.value ?? {};
218
+ if (typeof id !== "string" || typeof workspace !== "string") {
219
+ return writeError(res, 400, "id and workspace are required");
220
+ }
221
+ try {
222
+ const ok = await domain.archiveMemory(workspace, id);
223
+ if (!ok) return writeError(res, 404, "memory not found or already archived");
224
+ writeJson(res, 200, { ok: true });
225
+ } catch (error) {
226
+ writeError(res, 500, String(error?.message ?? error));
227
+ }
228
+ };
229
+
230
+ const remove = async (req, res) => {
231
+ const domain = await withDomain(res);
232
+ if (domain === void 0) return;
233
+ const body = await readJsonBody(req);
234
+ if (body.error) return writeError(res, 400, body.error);
235
+ const { id, workspace } = body.value ?? {};
236
+ if (typeof id !== "string" || typeof workspace !== "string") {
237
+ return writeError(res, 400, "id and workspace are required");
238
+ }
239
+ try {
240
+ const ok = await domain.deleteMemory(workspace, id);
241
+ if (!ok) return writeError(res, 404, "memory not found");
242
+ writeJson(res, 200, { ok: true });
243
+ } catch (error) {
244
+ writeError(res, 500, String(error?.message ?? error));
245
+ }
246
+ };
247
+
248
+ const gcRun = async (req, res) => {
249
+ const domain = await withDomain(res);
250
+ if (domain === void 0) return;
251
+ const body = await readJsonBody(req);
252
+ if (body.error) return writeError(res, 400, body.error);
253
+ const { workspace, dryRun } = body.value ?? {};
254
+ // Workspace optional -> sweep every workspace (same semantics as the tool).
255
+ if (workspace !== void 0 && typeof workspace !== "string") {
256
+ return writeError(res, 400, "workspace must be a string when provided");
257
+ }
258
+ try {
259
+ const report = await domain.gc(
260
+ workspace === void 0 || workspace.length === 0 ? void 0 : workspace,
261
+ service.config ?? {},
262
+ { dryRun: dryRun === true },
263
+ );
264
+ if (dryRun !== true) {
265
+ service.gcStats ??= { lastRun: 0, archivedMemories: 0, archivedTasks: 0, removedLinks: 0 };
266
+ service.gcStats.lastRun = Date.now();
267
+ service.gcStats.archivedMemories += report.archivedMemories.length;
268
+ service.gcStats.archivedTasks += report.archivedTasks.length;
269
+ service.gcStats.removedLinks += report.removedLinks.length;
270
+ }
271
+ writeJson(res, 200, { report });
272
+ } catch (error) {
273
+ writeError(res, 500, String(error?.message ?? error));
274
+ }
275
+ };
276
+
277
+ const guard = (req, res, method, handler) => {
278
+ if ((req.method ?? "GET").toUpperCase() !== method) {
279
+ writeError(res, 405, "method not allowed");
280
+ return;
281
+ }
282
+ if (!isLoopbackRequest(req, trustedHosts)) {
283
+ writeError(res, 403, "loopback-only");
284
+ return;
285
+ }
286
+ return handler(req, res);
287
+ };
288
+
289
+ /** GUI task creation — mirrors esr_task (id + state defaults), no session. */
290
+ const createTask = async (req, res) => {
291
+ const domain = await withDomain(res);
292
+ if (domain === void 0) return;
293
+ const body = await readJsonBody(req);
294
+ if (body.error) return writeError(res, 400, body.error);
295
+ const { workspace, name, description } = body.value ?? {};
296
+ if (typeof workspace !== "string" || workspace.length === 0) return writeError(res, 400, "workspace is required");
297
+ if (typeof name !== "string" || name.trim().length === 0) return writeError(res, 400, "name is required");
298
+ const max = service.config?.maxTasksPerWorkspace ?? 10;
299
+ if (domain.activeTaskCount(workspace) >= max) {
300
+ return writeError(res, 409, `workspace already holds ${max} active tasks; close some first`);
301
+ }
302
+ const now = Date.now();
303
+ const task = {
304
+ id: `tsk_${shortId(uuid())}`,
305
+ workspace,
306
+ name: name.trim().slice(0, 200),
307
+ description: typeof description === "string" ? description.trim().slice(0, 2000) : "",
308
+ state: "active",
309
+ artifact: null,
310
+ evaluation: null,
311
+ memoryRefs: [],
312
+ sessionId: "gui",
313
+ createdAt: now,
314
+ updatedAt: now,
315
+ stateChangedAt: 0,
316
+ };
317
+ await domain.putTask(task);
318
+ writeJson(res, 200, { task });
319
+ };
320
+
321
+ /** GUI task close — mirrors esr_close's evidence gates without a session. */
322
+ const closeTask = async (req, res) => {
323
+ const domain = await withDomain(res);
324
+ if (domain === void 0) return;
325
+ const body = await readJsonBody(req);
326
+ if (body.error) return writeError(res, 400, body.error);
327
+ const { workspace, id, artifact, evaluation, memory_refs } = body.value ?? {};
328
+ if (typeof workspace !== "string" || workspace.length === 0 || typeof id !== "string" || id.length === 0) {
329
+ return writeError(res, 400, "workspace and id are required");
330
+ }
331
+ const task = domain.getTask(workspace, id);
332
+ if (task === void 0) return writeError(res, 404, "task not found");
333
+ const str = (v) => (typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined);
334
+ const refs = Array.isArray(memory_refs)
335
+ ? memory_refs.filter((x) => typeof x === "string" && x.trim().length > 0).map((x) => x.trim())
336
+ : undefined;
337
+ const next = {
338
+ ...task,
339
+ artifact: str(artifact) ?? task.artifact ?? null,
340
+ evaluation: str(evaluation) ?? task.evaluation ?? null,
341
+ memoryRefs: refs ?? task.memoryRefs ?? [],
342
+ updatedAt: Date.now(),
343
+ };
344
+ const gaps = [];
345
+ if (next.artifact === null || next.artifact === "") gaps.push("artifact");
346
+ if (next.evaluation === null || next.evaluation === "") gaps.push("evaluation");
347
+ if (!Array.isArray(next.memoryRefs) || next.memoryRefs.length === 0) gaps.push("memory_ref");
348
+ if (gaps.length > 0) {
349
+ await domain.putTask(next);
350
+ writeJson(res, 200, { ok: true, state: "active", gaps });
351
+ return;
352
+ }
353
+ const closed = { ...next, state: "stable", stateChangedAt: Date.now() };
354
+ await domain.putTask(closed);
355
+ writeJson(res, 200, { ok: true, state: "stable", task: closed });
356
+ };
357
+
358
+ /** Real agent-behaviour observability: per-(workspace, day) usage rollup + ratios. */
359
+ const stats = async (req, res) => {
360
+ const domain = await withDomain(res);
361
+ if (domain === void 0) return;
362
+ const params = readQuery(req);
363
+ const workspace = params.get("workspace") ?? void 0;
364
+ const rows = domain.usageRows(workspace);
365
+ const totals = { counts: {}, failures: 0, recall: {} };
366
+ for (const r of rows) {
367
+ for (const [k, v] of Object.entries(r.counts ?? {})) totals.counts[k] = (totals.counts[k] ?? 0) + v;
368
+ totals.failures += r.failures ?? 0;
369
+ for (const [k, v] of Object.entries(r.recall ?? {})) totals.recall[k] = (totals.recall[k] ?? 0) + v;
370
+ }
371
+ const ENGRAM = ["engram_store", "engram_recall", "engram_detail", "loom_store", "loom_recall", "loom_detail"];
372
+ const ESR = ["esr_task", "esr_node", "esr_close", "esr_link", "esr_gc"];
373
+ const sum = (keys) => keys.reduce((acc, k) => acc + (totals.counts[k] ?? 0), 0);
374
+ const memCalls = sum(ENGRAM);
375
+ const esrCalls = sum(ESR);
376
+ const calls = memCalls + esrCalls;
377
+ const queries = totals.recall.queries ?? 0;
378
+ const withHits = totals.recall.withHits ?? 0;
379
+ const hitsTotal = totals.recall.hitsTotal ?? 0;
380
+ const detailFollows = totals.recall.detailFollows ?? 0;
381
+ writeJson(res, 200, {
382
+ workspace: workspace ?? null,
383
+ byDay: rows.slice(-14).reverse(),
384
+ totals,
385
+ ratios: {
386
+ calls,
387
+ esrCalls,
388
+ memCalls,
389
+ esrRatio: calls > 0 ? +(esrCalls / calls).toFixed(4) : null,
390
+ recallHitRate: queries > 0 ? +(withHits / queries).toFixed(4) : null,
391
+ recallHitsPerQuery: queries > 0 ? +(hitsTotal / queries).toFixed(3) : null,
392
+ detailFollowRate: queries > 0 ? +(detailFollows / queries).toFixed(4) : null,
393
+ },
394
+ });
395
+ };
396
+
397
+ const make = (method, path, handler) => ({
398
+ kind: "exact",
399
+ path: `${API_PREFIX}${path}`,
400
+ method,
401
+ handler: (req, res) => guard(req, res, method, handler),
402
+ });
403
+
404
+ return [
405
+ make("GET", "/overview", overview),
406
+ make("GET", "/memories", memories),
407
+ make("GET", "/tasks", tasks),
408
+ make("GET", "/links", links),
409
+ make("GET", "/nodes", entities),
410
+ make("GET", "/stats", stats),
411
+ make("GET", "/config", readConfig),
412
+ make("POST", "/memories/archive", archive),
413
+ make("POST", "/memories/delete", remove),
414
+ make("POST", "/tasks", createTask),
415
+ make("POST", "/tasks/close", closeTask),
416
+ make("POST", "/gc", gcRun),
417
+ ];
418
+ }
package/lib/capture.js ADDED
@@ -0,0 +1,208 @@
1
+ /**
2
+ * dsh-engram: zero-LLM auto-capture from tool results.
3
+ *
4
+ * Watches `tools/result` (the DSH-native equivalent of symbolic-index's PostToolUse
5
+ * hook) and extracts meaningful events by pure pattern matching on tool name +
6
+ * arguments. No model call, no embeddings — just deterministic extractors,
7
+ * in-memory rate limiting, and exact-duplicate dedup at the store layer.
8
+ *
9
+ * Extractors (default on):
10
+ * - git milestones via bash (commit with an -m subject / merge / rebase /
11
+ * branch / tag / cherry-pick). Pure plumbing (`push`, `stash`, `status`)
12
+ * is deliberately NOT captured: those are operational echoes, not lasting
13
+ * decisions, and would only drown the memory pool and the [ENGRAM] index.
14
+ * - writes/edits to significant config & doc paths
15
+ * - repeated tool errors (error signal, deduped by message)
16
+ * symbolic-index's own insight: one file edit is noise; accumulated edits and rare
17
+ * git milestones are signal. Low-signal entries stay out of the [ENGRAM] index
18
+ * until they accumulate recall hits.
19
+ */
20
+
21
+ import { workspaceKey as workspaceKeyOf } from "./util.js";
22
+
23
+ const GIT_MILESTONE_SIGNAL = 0.5;
24
+ const GIT_COMMIT_SIGNAL = 0.55;
25
+
26
+ /** Git operations that carry lasting meaning (milestones). */
27
+ const GIT_OPS = [
28
+ // commit: only with an explicit -m subject — that's the semantic content.
29
+ { kind: "commit", pattern: /\bgit\s+commit\b/, text: (_cmd) => gitSubject(_cmd) },
30
+ { kind: "milestone", pattern: /\bgit\s+merge\b/, text: (cmd) => `git merge: ${headLine(cmd)}` },
31
+ { kind: "milestone", pattern: /\bgit\s+rebase\b/, text: (cmd) => `git rebase: ${headLine(cmd)}` },
32
+ { kind: "milestone", pattern: /\bgit\s+cherry-pick\b/, text: (cmd) => `git cherry-pick: ${headLine(cmd)}` },
33
+ { kind: "milestone", pattern: /\bgit\s+tag\b/, text: (cmd) => `git tag: ${headLine(cmd)}` },
34
+ { kind: "milestone", pattern: /\bgit\s+checkout\s+-b\s+(\S+)/, text: (_cmd, m) => `git branch created: ${m[1]}` },
35
+ ];
36
+
37
+ /**
38
+ * Extract the `-m "subject"` (or -m 'subject') from a commit command.
39
+ * Returns null when there is no written commit message — a `git commit` run
40
+ * via an editor, `--amend`, or an un-messaged commit is an operational echo,
41
+ * not a memory. (The --amend/-a case for an already-reworded subject still
42
+ * carries its own -m, so it keeps working; silent commits are dropped.)
43
+ */
44
+ function gitSubject(cmd) {
45
+ const m = cmd.match(/-m\s+["']([^"']{0,64})["']/i);
46
+ if (m) return `git commit: ${m[1]}`;
47
+ return null;
48
+ }
49
+
50
+ function headLine(cmd) {
51
+ return String(cmd).replace(/\s*\n+\s*/g, " ").trim().slice(0, 64);
52
+ }
53
+
54
+ /** Paths where a write is significant enough to remember (mirrors symbolic-index). */
55
+ const SIGNIFICANT_PATH = [
56
+ /(^|\/)(AGENTS|CLAUDE|README|CHANGELOG|CONTRIBUTING)\.md$/i,
57
+ /(^|\/)package\.json$/i,
58
+ /(^|\/)tsconfig.*\.json$/i,
59
+ /(^|\/)biome\.json$/i,
60
+ /(^|\/)Dockerfile$/i,
61
+ /(^|\/)\.esr/i,
62
+ /(^|\/)\.engram/i,
63
+ /(^|\/)\.symbolic-index/i,
64
+ /docs\/.*\.(md|rst)$/i,
65
+ ];
66
+
67
+ const EDIT_TOOLS = new Set(["write", "str_replace_editor", "edit"]);
68
+ const READ_TOOLS = new Set(["read"]);
69
+ /** Never capture our own noise or the explicit memory tools. */
70
+ const OWN_TOOLS = new Set(["engram_store", "engram_recall", "engram_detail", "esr_task", "esr_close", "esr_link"]);
71
+
72
+ /**
73
+ * Build the tools/result listener. `store` is the opened engram domain handle;
74
+ * `config.autoCapture*` control rate. Returns a handler (exec, result) => void
75
+ * (fire-and-forget — never blocks the tool pipeline, never throws).
76
+ * `stats` (optional) is a mutable {total, git, file, error} counter the GUI
77
+ * overview reads for capture observability.
78
+ */
79
+ export function makeCaptureHandler(store, config, log, stats) {
80
+ const recent = new Set();
81
+ const perSession = new Map(); // sessionId -> count, to bound captures
82
+ let captures = 0;
83
+
84
+ return (exec, result) => {
85
+ try {
86
+ const name = String(exec?.name ?? "").toLowerCase();
87
+ if (name.length === 0 || OWN_TOOLS.has(name)) return;
88
+ const agent = exec?.agent;
89
+ if (agent === void 0) return;
90
+ const cwd = agent.session?.header?.cwd;
91
+ const sessionId = agent.session?.id;
92
+ if (cwd === void 0 || cwd.length === 0 || sessionId === void 0) return;
93
+
94
+ // per-session rate limit
95
+ const used = perSession.get(sessionId) ?? 0;
96
+ if (used >= config.autoCapturePerSession) return;
97
+ perSession.set(sessionId, used + 1);
98
+
99
+ if (captures >= (config.autoCaptureGlobalCap ?? 500)) return;
100
+ captures += 1;
101
+
102
+ const args = (exec.arguments ?? {});
103
+ const git = extractGit(name, args.command);
104
+ let entry = null;
105
+
106
+ if (git !== null) {
107
+ entry = { kind: "decision", text: git.text, signal: git.signal, tags: git.tags };
108
+ } else if (EDIT_TOOLS.has(name)) {
109
+ const path = String(args.path ?? args.file_path ?? args.filePath ?? "");
110
+ if (path.length > 0 && SIGNIFICANT_PATH.some((re) => re.test(path))) {
111
+ entry = { kind: "fact", text: `Edited ${path}`, signal: 0.3, tags: ["file", "edit", "auto-captured", `dir:${dirOf(path)}`] };
112
+ }
113
+ } else if (READ_TOOLS.has(name)) {
114
+ const path = String(args.path ?? args.file_path ?? args.filePath ?? "");
115
+ if (path.length > 0 && /(^|\/)(AGENTS|CLAUDE)\.md$/i.test(path)) {
116
+ entry = { kind: "fact", text: `Read ${path}`, signal: 0.3, tags: ["file", "read", "config", "auto-captured"] };
117
+ }
118
+ } else if (result?.isError === true) {
119
+ const msg = errorHead(result);
120
+ if (msg !== null) {
121
+ entry = { kind: "error", text: `${name} failed: ${msg}`, signal: 0.25, tags: ["error", "auto-captured"] };
122
+ }
123
+ }
124
+
125
+ if (entry === null) return;
126
+ if (stats) {
127
+ stats.total += 1;
128
+ if (entry.kind === "decision") stats.git += 1;
129
+ else if (entry.kind === "error") stats.error += 1;
130
+ else stats.file += 1;
131
+ }
132
+
133
+ // exact-duplicate guard across the recent window (store re-dedups by hash anyway)
134
+ const key = `${entry.kind}|${entry.text}`;
135
+ if (recent.has(key)) return;
136
+ recent.add(key);
137
+ if (recent.size > 500) {
138
+ const first = recent.values().next().value;
139
+ recent.delete(first);
140
+ }
141
+
142
+ void store
143
+ .storeMemory(
144
+ {
145
+ workspace: workspaceKeyOf(cwd),
146
+ kind: entry.kind,
147
+ text: entry.text,
148
+ tags: entry.tags,
149
+ signal: entry.signal,
150
+ sessionId,
151
+ seq: agent.session.events?.length ?? 0,
152
+ },
153
+ config,
154
+ )
155
+ .then(() => {})
156
+ .catch((error) => log?.warn?.(`engram auto-capture failed: ${String(error)}`));
157
+ } catch (error) {
158
+ // fully contained — a capture bug must never disturb the tool pipeline
159
+ log?.warn?.(`engram auto-capture threw: ${String(error)}`);
160
+ }
161
+ };
162
+ }
163
+
164
+ function extractGit(name, command) {
165
+ if (name !== "bash") return null;
166
+ if (typeof command !== "string" || command.length === 0) return null;
167
+ for (const op of GIT_OPS) {
168
+ const m = command.match(op.pattern);
169
+ if (m) {
170
+ try {
171
+ const text = op.text(command, m);
172
+ if (text === null) return null; // git commit without an -m subject: drop
173
+ return {
174
+ text,
175
+ signal: op.kind === "commit" ? GIT_COMMIT_SIGNAL : GIT_MILESTONE_SIGNAL,
176
+ tags: ["git", "auto-captured", op.kind],
177
+ };
178
+ } catch {
179
+ return null;
180
+ }
181
+ }
182
+ }
183
+ return null;
184
+ }
185
+
186
+ function errorHead(result) {
187
+ // canonical value or rendered content
188
+ const value = result?.value;
189
+ if (typeof value === "string" && value.length > 0) return value.slice(0, 80);
190
+ if (value !== null && typeof value === "object") {
191
+ const first = value.error ?? value.stderr ?? value.message ?? value.stdout;
192
+ if (typeof first === "string" && first.length > 0) return first.slice(0, 80);
193
+ }
194
+ const content = result?.content;
195
+ if (Array.isArray(content) && content.length > 0) {
196
+ for (const block of content) {
197
+ if (block?.type === "text" && typeof block.text === "string" && block.text.length > 0) {
198
+ return block.text.slice(0, 80);
199
+ }
200
+ }
201
+ }
202
+ return null;
203
+ }
204
+
205
+ function dirOf(path) {
206
+ const i = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
207
+ return i > 0 ? path.slice(0, i) : ".";
208
+ }