dsh-engram 0.2.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/tools.js CHANGED
@@ -17,7 +17,6 @@
17
17
  import { z } from "zod";
18
18
  import { EngramError, hasCode } from "./store.js";
19
19
  import { truncate, shortId, slugId, fmtDate, escapeLt, workspaceKey as wk, uuid } from "./util.js";
20
- import { makeUsageTracker } from "./usage.js";
21
20
 
22
21
  const KINDS = [
23
22
  "decision",
@@ -75,115 +74,150 @@ async function requireDomain(service) {
75
74
  function memoryLine(m, verbosity = "short") {
76
75
  const tags = m.entity !== null ? ` · entity:${m.entity}` : "";
77
76
  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");
77
+ const access = m.lastAccessAt == null ? "never-accessed" : `lastAccess ${fmtDate(m.lastAccessAt)}`;
78
+ const pathLine = m.filePath ? ` · path ${m.filePath}` : "";
79
+ return [`# ${m.id} [${m.kind}]`, escapeLt(m.text), `tags: ${(m.tags ?? []).join(", ")}${tags}`, `signal ${m.signal} · hits ${m.hits ?? 0} · ${fmtDate(m.createdAt)} · session ${m.sessionId}#${m.seq} · ${access}${pathLine}`, ""].join("\n");
79
80
  }
80
81
  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
+ const path = m.filePath ? ` · ${truncate(m.filePath, 48)}` : "";
83
+ return `- ${m.id.slice(0, 8)} [${m.kind}] ${date} ${escapeLt(truncate(m.text, 200))}${tags}${m.hits > 0 ? ` · ×${m.hits}` : ""}${path}`;
84
+ }
85
+
86
+ /** Stable kind display order for grouped recall output (matches MEMORY_KINDS). */
87
+ const MEMORY_KIND_ORDER = ["decision", "error", "procedure", "fact", "insight", "handoff", "task"];
88
+
89
+ /**
90
+ * Render recall hits grouped by kind (MemoraX-style memory_type buckets):
91
+ * ## decision (2)
92
+ * - 08-21 …
93
+ * Within a group the original (relevance) order is preserved; unknown kinds
94
+ * (future-proof) trail the fixed order. Pure, deterministic.
95
+ */
96
+ function groupKindLines(items) {
97
+ const buckets = new Map();
98
+ for (const m of items) {
99
+ const kind = typeof m.kind === "string" && m.kind.length > 0 ? m.kind : "unknown";
100
+ if (!buckets.has(kind)) buckets.set(kind, []);
101
+ buckets.get(kind).push(m);
102
+ }
103
+ const lines = [];
104
+ const emit = (kind, group) => {
105
+ lines.push(`## ${kind} (${group.length})`);
106
+ for (const m of group) lines.push(memoryLine(m));
107
+ };
108
+ for (const kind of MEMORY_KIND_ORDER) if (buckets.has(kind)) emit(kind, buckets.get(kind));
109
+ for (const [kind, group] of buckets) if (!MEMORY_KIND_ORDER.includes(kind)) emit(kind, group);
110
+ return lines;
82
111
  }
83
112
 
84
113
  export function registerTools(ctx, service) {
85
114
  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
- };
115
+ // Assessment P3: no per-call usage writes — agent-behaviour observability
116
+ // is computed on demand from the session log stream (lib/usage.js), so
117
+ // tools register plain, without a recording wrapper.
118
+ const registerTool = (tool) => ctx.tools.register(tool);
110
119
  const disposers = [
111
120
  // ── engram_store ────────────────────────────────────────────────────
112
121
  registerTool({
113
122
  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.",
123
+ description: "Store one memory: kind=(decision|error|procedure|fact|insight|handoff|task), text, optional entity anchor. Exact-duplicate text refreshes instead of adding; auto-capture already handles git/file/error events — use this for anything it misses.",
115
124
  parameters: {
116
125
  type: "object",
117
126
  properties: {
118
127
  text: { type: "string", description: "Compact, information-dense memory text (required)" },
119
128
  ...p,
129
+ file_path: { type: "string", description: "Optional absolute path this memory anchors to (e.g. the failing file)" },
120
130
  },
121
131
  required: ["text"],
122
132
  },
123
133
  output: TEXT_OUTPUT,
124
134
  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");
135
+ 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");
126
136
  const caller = callerOf(exec);
127
137
  const domain = await requireDomain(service);
128
138
  await domain.expireSweep(caller.cwd);
129
- const { stored, duplicated } = await domain.storeMemory(
139
+ const { stored, duplicated, revived } = await domain.storeMemory(
130
140
  {
131
141
  workspace: caller.cwd,
132
142
  kind: args.kind ?? "fact",
133
143
  text: args.text,
134
144
  tags: args.tags ?? [],
135
145
  entity: args.entity ?? null,
146
+ filePath: args.file_path ?? null,
136
147
  sessionId: caller.sessionId,
137
148
  seq: caller.seq,
138
149
  signal: 0.6,
139
150
  },
140
151
  service.config,
141
152
  );
142
- return `stored ${duplicated ? "(duplicate refreshed) " : ""}memory ${stored.id} [${stored.kind}] — cited to session ${stored.sessionId}#${stored.seq}`;
153
+ const dupNote = duplicated ? (revived === true ? "(repeat failure — previous entry revived) " : "(duplicate refreshed) ") : "";
154
+ await domain.markModelDirty?.(caller.cwd).catch?.(() => null);
155
+ return `stored ${dupNote}memory ${stored.id} [${stored.kind}] — cited to session ${stored.sessionId}#${stored.seq}`;
143
156
  },
144
157
  }),
145
158
 
146
159
  // ── engram_recall ───────────────────────────────────────────────────
147
160
  registerTool({
148
161
  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.",
162
+ description: "Deterministic recall of this workspace's memories (BM25: exact tag first, recency ties). Literal, not semantic — query in the memory's likely language. query=filter, entity=timeline, search_sessions=true adds past-session FTS. limit/max_per_session/group_by_kind tune output.",
150
163
  parameters: {
151
164
  type: "object",
152
165
  properties: {
153
166
  query: { type: "string", description: "Words to match against memory text and tags" },
154
167
  entity: { type: "string", description: "Return that entity's memories chronologically" },
155
168
  limit: { type: "integer", minimum: 1, maximum: 50, description: "Max memories (default 12)" },
169
+ max_per_session: { type: "integer", minimum: 1, maximum: 10, description: "Session-diversified cap: max memories kept per sessionId (default config.maxRecallPerSession=3; >1 sessions get to share the list)" },
170
+ group_by_kind: { type: "boolean", description: "Group recall hits by kind into sections (default true); false = one flat list" },
156
171
  search_sessions: { type: "boolean", description: "Also FTS search past sessions (requires the session-query index)" },
157
172
  },
158
173
  },
159
174
  output: TEXT_OUTPUT,
160
175
  timeoutMs: 30000,
161
176
  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");
177
+ 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");
163
178
  const caller = callerOf(exec);
164
179
  const domain = await requireDomain(service);
165
180
  let lines = [];
181
+ let items = [];
182
+ // When a query/entity returns nothing locally, the FTS fallback below
183
+ // reuses DSH's own cross-session index (ctx.sessionQuery) instead of
184
+ // a self-built index — this keeps proactive coverage without new infra.
185
+ let zeroHits = false;
166
186
  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}`);
187
+ items = domain.timeline(caller.cwd, args.entity, args.limit ?? 30);
188
+ if (items.length === 0) zeroHits = true;
189
+ lines.push(zeroHits ? `no memories for entity "${args.entity}" in this workspace` : `# timeline: ${args.entity}`);
170
190
  lines.push(...items.map((m) => memoryLine(m)));
171
191
  await Promise.all(items.map((m) => domain.touchMemory(caller.cwd, m.id).catch(() => null)));
172
192
  } 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)));
193
+ const maxPerSession = args.max_per_session ?? service.config.maxRecallPerSession ?? 3;
194
+ items = domain.recall(caller.cwd, args.query, args.limit ?? 12, { maxPerSession });
195
+ if (items.length === 0) zeroHits = true;
196
+ lines.push(zeroHits ? `no active memories match ${JSON.stringify(args.query)} in this workspace` : `# recall: ${JSON.stringify(args.query)} (${items.length}) · ≤${maxPerSession}/session`);
197
+ lines.push(...(args.group_by_kind === false ? items.map((m) => memoryLine(m)) : groupKindLines(items)));
177
198
  await Promise.all(items.map((m) => domain.touchMemory(caller.cwd, m.id).catch(() => null)));
178
199
  } else {
179
- const items = domain.listMemories(caller.cwd, args.limit ?? 12);
200
+ items = domain.listMemories(caller.cwd, args.limit ?? 12);
180
201
  if (items.length === 0) return "no active memories in this workspace yet";
181
202
  lines.push(`# newest memories (${items.length})`);
182
203
  lines.push(...items.map((m) => memoryLine(m)));
183
204
  }
184
- if (args.search_sessions === true) {
205
+ // Entity neighborhood: items anchored to entities get a compact
206
+ // graph-retrieval pass over the existing ESR relation table (the
207
+ // lightweight knowledge-graph end of the spectrum — no graph DB).
208
+ const neighbors = collectNeighborhood(domain, caller.cwd, items);
209
+ if (neighbors.length > 0) lines.push("", "# entity neighborhood", ...neighbors);
210
+ // Evidence-grounded beliefs: repeated signals (proof >= 2) surface as
211
+ // high-confidence observations alongside the raw recall hits.
212
+ const strongObs = (domain.listObservations?.(caller.cwd) ?? []).filter((o) => (o.proof?.count ?? 0) >= 2);
213
+ if (strongObs.length > 0) {
214
+ lines.push("", "# observations (evidence ≥ 2)", ...strongObs.map((o) => `• ${o.text} — ×${o.proof.count} ${o.trend}${o.negations > 0 ? ` · 反证${o.negations}` : ""}`));
215
+ }
216
+ const wantSessions = args.search_sessions === true || (zeroHits && service.config.sessionSearch !== false);
217
+ if (wantSessions) {
185
218
  const extra = await searchSessions(service, caller, args.query ?? args.entity ?? "", exec.signal);
186
- if (extra.length > 0) lines.push("", "# past sessions (FTS)", ...extra);
219
+ if (extra.length > 0) lines.push("", "# past sessions (FTS fallback)", ...extra);
220
+ else if (zeroHits && lines.length === 1) lines[0] += " (no past-session hits either)";
187
221
  }
188
222
  return lines.join("\n");
189
223
  },
@@ -192,7 +226,7 @@ export function registerTools(ctx, service) {
192
226
  // ── engram_detail ───────────────────────────────────────────────────
193
227
  registerTool({
194
228
  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.",
229
+ description: "Full record of one memory id (from engram_recall): text, tags, entity, signal, hits, provenance session#seq.",
196
230
  parameters: {
197
231
  type: "object",
198
232
  properties: {
@@ -216,19 +250,20 @@ export function registerTools(ctx, service) {
216
250
  // ── esr_task ──────────────────────────────────────────────────────
217
251
  registerTool({
218
252
  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.",
253
+ description: "Create/update an engineering task (draft→active). A task is only 'done' with evidence — finish via esr_close (artifact + evaluation + memory_ref). Set id to update an existing task. Pass entity= to auto-create the node and link the task onto it (one call instead of esr_node+esr_link).",
220
254
  parameters: {
221
255
  type: "object",
222
256
  properties: {
223
257
  name: { type: "string", description: "Task name (required on create)" },
224
258
  description: { type: "string", description: "What must be produced / satisfied" },
225
259
  id: { type: "string", description: "Existing task id to update" },
260
+ entity: { type: "string", description: "Optional domain object to hang this task on — auto-creates ent_<slug> node and links task --relates_to--> it" },
226
261
  },
227
262
  required: ["name"],
228
263
  },
229
264
  output: TEXT_OUTPUT,
230
265
  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");
266
+ const args = parse(z.object({ name: z.string().min(1).max(200), description: z.string().optional(), id: z.string().optional(), entity: z.string().optional() }), raw, "esr_task");
232
267
  const caller = callerOf(exec);
233
268
  const domain = await requireDomain(service);
234
269
  const existing = args.id === void 0 ? void 0 : domain.getTask(caller.cwd, args.id);
@@ -250,29 +285,41 @@ export function registerTools(ctx, service) {
250
285
  createdAt: existing?.createdAt ?? now,
251
286
  updatedAt: now,
252
287
  stateChangedAt: existing?.stateChangedAt ?? 0,
288
+ deps: existing?.deps ?? [],
289
+ assignee: existing?.assignee ?? null,
290
+ claimedAt: existing?.claimedAt ?? null,
253
291
  };
254
292
  await domain.putTask(task);
255
- return `task ${task.id} [${task.state}] — ${task.name}`;
293
+ await domain.markModelDirty?.(caller.cwd).catch?.(() => null);
294
+ const wiring = args.entity !== void 0
295
+ ? await wireTaskEntity(domain, caller.cwd, task.id, args.entity, caller.sessionId, now)
296
+ : "";
297
+ const hint = esrModelingHint(domain, caller.cwd);
298
+ const parts = [`task ${task.id} [${task.state}] — ${task.name}`];
299
+ if (wiring !== "") parts.push(wiring);
300
+ if (hint !== "") parts.push(hint);
301
+ return parts.join("\n");
256
302
  },
257
303
  }),
258
304
 
259
305
  // ── esr_close ─────────────────────────────────────────────────────
260
306
  registerTool({
261
307
  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.",
308
+ description: "Close a task via the ESR evidence protocol: requires artifact (path/url), evaluation (how verified) and memory_ref (memory ids) — missing gates keep it active; force:true skips the on-disk artifact check. Auto-stores a closing memory.",
263
309
  parameters: {
264
310
  type: "object",
265
311
  properties: {
266
312
  task_id: { type: "string", description: "Task id from [ESR] or esr_task" },
267
- artifact: { type: "string", description: "Produced artifact (path/url)" },
313
+ artifact: { type: "string", description: "Produced artifact (path/url). Paths are resolved against the workspace and must exist on disk unless force:true" },
268
314
  evaluation: { type: "string", description: "Verification evidence (test/review/benchmark)" },
269
315
  memory_refs: { type: "array", items: { type: "string" }, description: "Memory ids that record the decisions made" },
316
+ force: { type: "boolean", description: "Skip the on-disk artifact existence check (gates are still required)" },
270
317
  },
271
318
  required: ["task_id"],
272
319
  },
273
320
  output: TEXT_OUTPUT,
274
321
  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");
322
+ 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(), force: z.boolean().optional() }), raw, "esr_close");
276
323
  const caller = callerOf(exec);
277
324
  const domain = await requireDomain(service);
278
325
  const task = domain.getTask(caller.cwd, resolveTaskId(domain, caller.cwd, args.task_id));
@@ -284,16 +331,20 @@ export function registerTools(ctx, service) {
284
331
  memoryRefs: args.memory_refs ?? task.memoryRefs,
285
332
  updatedAt: Date.now(),
286
333
  };
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");
334
+ // force only skips the on-disk existence check — the three gates
335
+ // (artifact/evaluation/memory_ref) are always required.
336
+ const gateCfg = args.force === true ? { ...service.config, verifyArtifact: false } : service.config;
337
+ const gate = domain.evidenceGate(caller.cwd, next, gateCfg);
338
+ const gaps = gate.gaps;
291
339
  if (gaps.length > 0) {
292
340
  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).`;
341
+ await domain.markModelDirty?.(caller.cwd).catch?.(() => null);
342
+ const reason = gate.artifactReason !== void 0 ? ` (${gate.artifactReason})` : "";
343
+ return `task ${task.id} still ACTIVE — evidence gaps: ${gaps.join(", ")}${reason}. Provide them (and ensure the artifact path exists on disk unless force:true) and call esr_close again (the [ESR] block shows current gaps).`;
294
344
  }
295
- const closed = { ...next, state: "stable", stateChangedAt: Date.now() };
345
+ const closed = domain.compactOnClose?.({ ...next, state: "stable", stateChangedAt: Date.now() }) ?? { ...next, state: "stable", stateChangedAt: Date.now() };
296
346
  await domain.putTask(closed);
347
+ await domain.markModelDirty?.(caller.cwd).catch?.(() => null);
297
348
  const mem = await domain.storeMemory(
298
349
  {
299
350
  workspace: caller.cwd,
@@ -307,14 +358,16 @@ export function registerTools(ctx, service) {
307
358
  },
308
359
  service.config,
309
360
  );
310
- return `task ${task.id} → STABLE ✓ (artifact, evaluation, ${next.memoryRefs.length} memory ref(s))\nclosure memory: ${mem.id}`;
361
+ const hint = esrModelingHint(domain, caller.cwd);
362
+ return `task ${task.id} → STABLE ✓ (artifact, evaluation, ${next.memoryRefs.length} memory ref(s))\nclosure memory: ${mem.id}${hint ? `
363
+ ${hint}` : ""}`;
311
364
  },
312
365
  }),
313
366
 
314
367
  // ── esr_link ──────────────────────────────────────────────────────
315
368
  registerTool({
316
369
  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.",
370
+ description: "Add a typed relation between two entities (depends_on/implements/refines/contradicts…). Feeds entity queries and the workspace link count.",
318
371
  parameters: {
319
372
  type: "object",
320
373
  properties: {
@@ -341,14 +394,126 @@ export function registerTools(ctx, service) {
341
394
  sessionId: caller.sessionId,
342
395
  createdAt: Date.now(),
343
396
  });
397
+ await domain.markModelDirty?.(caller.cwd).catch?.(() => null);
344
398
  return `link ${id} — ${args.source} --${args.relation}--> ${args.target}`;
345
399
  },
346
400
  }),
347
401
 
348
- // ── esr_node ──────────────────────────────────────────────────────
402
+ registerTool({
403
+ name: "esr_dep",
404
+ description: "Add a dependency edge between tasks: blocks (waits until dep closes), parent-of (child), relates-to (no blocking). Refuses self-deps and cycles.",
405
+ parameters: {
406
+ type: "object",
407
+ properties: {
408
+ task_id: { type: "string", description: "The task gaining the dependency" },
409
+ dep_id: { type: "string", description: "The task it depends on / relates to" },
410
+ kind: { type: "string", enum: ["blocks", "relates-to", "parent-of"], description: "Edge kind (default blocks)" },
411
+ },
412
+ required: ["task_id", "dep_id"],
413
+ },
414
+ output: TEXT_OUTPUT,
415
+ execute: async (raw, exec) => {
416
+ const args = parse(z.object({ task_id: z.string().min(1), dep_id: z.string().min(1), kind: z.enum(["blocks", "relates-to", "parent-of"]).optional() }), raw, "esr_dep");
417
+ const caller = callerOf(exec);
418
+ const domain = await requireDomain(service);
419
+ const taskId = resolveTaskId(domain, caller.cwd, args.task_id);
420
+ const depId = resolveTaskId(domain, caller.cwd, args.dep_id);
421
+ const kind = args.kind ?? "blocks";
422
+ const t = await domain.addDep(caller.cwd, taskId, depId, kind);
423
+ // Graph-hygiene (A): the same edge goes into the links table so the
424
+ // dependency is a first-class, visible relation everywhere (graph,
425
+ // /links, entity neighbourhood) — not just a blocker counter.
426
+ const now = Date.now();
427
+ await domain.addLinkOnce({
428
+ id: uuid(),
429
+ workspace: caller.cwd,
430
+ source: taskId,
431
+ relation: kind,
432
+ target: depId,
433
+ confidence: 1,
434
+ sessionId: caller.sessionId,
435
+ createdAt: now,
436
+ });
437
+ await domain.markModelDirty?.(caller.cwd).catch?.(() => null);
438
+ return `dep added: ${t.id} --${kind}--> ${depId} (total ${(t.deps ?? []).length} edges · now graphed)`;
439
+ },
440
+ }),
441
+
442
+ // ── esr_claim / esr_unclaim ─────────────────────────────────────
443
+ registerTool({
444
+ name: "esr_claim",
445
+ description: "Atomically claim a task (assignee+claimedAt, draft→active). Refuses when another agent holds it unless force:true. Find work with esr_ready.",
446
+ parameters: {
447
+ type: "object",
448
+ properties: {
449
+ task_id: { type: "string", description: "Task id from [ESR] or esr_task" },
450
+ agent: { type: "string", description: "Claimer id (default: current session agent)" },
451
+ force: { type: "boolean", description: "Steal the claim from its current holder (default false)" },
452
+ },
453
+ required: ["task_id"],
454
+ },
455
+ output: TEXT_OUTPUT,
456
+ execute: async (raw, exec) => {
457
+ const args = parse(z.object({ task_id: z.string().min(1), agent: z.string().optional(), force: z.boolean().optional() }), raw, "esr_claim");
458
+ const caller = callerOf(exec);
459
+ const domain = await requireDomain(service);
460
+ const agent = args.agent ?? caller.agentId ?? "agent";
461
+ const t = await domain.claimTask(caller.cwd, resolveTaskId(domain, caller.cwd, args.task_id), agent, { force: args.force === true });
462
+ await domain.markModelDirty?.(caller.cwd).catch?.(() => null);
463
+ return `claimed ${t.id} by ${agent} [${t.state}] — ${t.name}`;
464
+ },
465
+ }),
466
+ registerTool({
467
+ name: "esr_unclaim",
468
+ description: "Release a claimed task's assignee back to null. Non-holder needs force:true. Inverse of esr_claim.",
469
+ parameters: {
470
+ type: "object",
471
+ properties: {
472
+ task_id: { type: "string", description: "Task id from [ESR] or esr_task" },
473
+ force: { type: "boolean", description: "Release even when someone else holds it (default false)" },
474
+ },
475
+ required: ["task_id"],
476
+ },
477
+ output: TEXT_OUTPUT,
478
+ execute: async (raw, exec) => {
479
+ const args = parse(z.object({ task_id: z.string().min(1), force: z.boolean().optional() }), raw, "esr_unclaim");
480
+ const caller = callerOf(exec);
481
+ const domain = await requireDomain(service);
482
+ const agent = caller.agentId ?? "agent";
483
+ const t = await domain.unclaimTask(caller.cwd, resolveTaskId(domain, caller.cwd, args.task_id), agent, { force: args.force === true });
484
+ await domain.markModelDirty?.(caller.cwd).catch?.(() => null);
485
+ return `released ${t.id} — ${t.name} (unclaimed)`;
486
+ },
487
+ }),
488
+
489
+ // ── esr_ready ────────────────────────────────────────────────────
490
+ registerTool({
491
+ name: "esr_ready",
492
+ description: "List claimable tasks: no open blocker and nobody claimed. One line per task.",
493
+ parameters: {
494
+ type: "object",
495
+ properties: {
496
+ workspace: { type: "string", description: "Override the workspace key (default: current session cwd)" },
497
+ limit: { type: "integer", minimum: 1, maximum: 50, description: "Max tasks (default 10)" },
498
+ },
499
+ },
500
+ output: TEXT_OUTPUT,
501
+ timeoutMs: 20000,
502
+ execute: async (raw, exec) => {
503
+ const args = parse(z.object({ workspace: z.string().optional(), limit: z.number().int().min(1).max(50).optional() }), raw, "esr_ready");
504
+ const caller = callerOf(exec);
505
+ const domain = await requireDomain(service);
506
+ const ws = args.workspace ?? caller.cwd;
507
+ const ready = domain.readyTasks(ws, args.limit ?? 10);
508
+ if (ready.length === 0) return `no claimable work right now in ${ws} — tasks with open blockers stay blocked until their deps close`;
509
+ return `# ready (claimable): ${ready.length}\n` + ready.map((t) => `- ${t.id} [${t.state}] ${t.name}${(t.deps ?? []).length > 0 ? ` (deps ${t.deps.length})` : ""}`).join("\n");
510
+ },
511
+ }),
512
+
513
+ // ── esr_node ─────────────────────────────────────
349
514
  registerTool({
350
515
  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.",
516
+ description: "Create/update an entity node — stable symbol for a recurring thing (package/service/doc/repo/person/bug/concept). Nodes anchor esr_link relations and memory entity. Same name updates; id = ent_<slug>.",
352
517
  parameters: {
353
518
  type: "object",
354
519
  properties: {
@@ -377,16 +542,20 @@ export function registerTools(ctx, service) {
377
542
  updatedAt: now,
378
543
  };
379
544
  await domain.putEntity(node);
380
- return existing
545
+ await domain.markModelDirty?.(caller.cwd).catch?.(() => null);
546
+ const hint = esrModelingHint(domain, caller.cwd);
547
+ const base = existing
381
548
  ? `node ${id} updated — ${node.name}${node.kind ? ` (${node.kind})` : ""}`
382
549
  : `node ${id} created — ${node.name}${node.kind ? ` (${node.kind})` : ""}`;
550
+ return `${base}${hint ? `
551
+ ${hint}` : ""}`;
383
552
  },
384
553
  }),
385
554
 
386
555
  // ── esr_gc ────────────────────────────────────────────────────────
387
556
  registerTool({
388
557
  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.",
558
+ description: "Run memory GC: archives TTL-expired, over-cap low-retention memories, stable tasks past retention, dangling links. Working set protected; archive-only (nothing hard-deleted). dry_run:true previews.",
390
559
  parameters: {
391
560
  type: "object",
392
561
  properties: {
@@ -403,6 +572,36 @@ export function registerTools(ctx, service) {
403
572
  return gcReportText(report);
404
573
  },
405
574
  }),
575
+
576
+ // ── esr_model ─────────────────────────────────────────────────────
577
+ registerTool({
578
+ name: "esr_model",
579
+ description: "Precomputed mental model of the workspace (task counts, entity graph, top evidence-grounded beliefs, risks). Zero LLM. mode=brief → ~50-token one-liner; max_chars trims. Read before scattering recall.",
580
+ parameters: {
581
+ type: "object",
582
+ properties: {
583
+ ws: { type: "string", description: "Workspace to summarize (default: current session cwd)" },
584
+ mode: { type: "string", enum: ["full", "brief"], description: "brief = one-line headline (session-start injection); full = complete markdown (default)" },
585
+ max_chars: { type: "integer", minimum: 1, maximum: 4000, description: "Cap output length (default: no cap)" },
586
+ },
587
+ },
588
+ output: TEXT_OUTPUT,
589
+ timeoutMs: 20000,
590
+ execute: async (raw, exec) => {
591
+ const args = parse(z.object({ ws: z.string().optional(), mode: z.enum(["full", "brief"]).optional(), max_chars: z.number().int().min(1).max(4000).optional() }), raw, "esr_model");
592
+ const caller = callerOf(exec);
593
+ const domain = await requireDomain(service);
594
+ const ws = args.ws ?? caller.cwd;
595
+ const { getModel } = await import("./mental.js");
596
+ const model = await getModel(domain, ws, { mode: args.mode ?? "full" });
597
+ const mins = Math.max(0, Math.floor((Date.now() - model.generated_at) / 60000));
598
+ let content = model.content;
599
+ if (typeof args.max_chars === "number" && content.length > args.max_chars) {
600
+ content = `${content.slice(0, Math.max(1, args.max_chars - 1))}…`;
601
+ }
602
+ return `${content}\n\n(生成于 ${mins} 分钟前 · ${model.fresh ? "本次重算" : "缓存命中"} · mode=${args.mode ?? "full"} · ws=${ws})`;
603
+ },
604
+ }),
406
605
  ];
407
606
  return () => {
408
607
  for (const dispose of disposers) dispose();
@@ -410,6 +609,94 @@ export function registerTools(ctx, service) {
410
609
  });
411
610
  }
412
611
 
612
+ /**
613
+ * Compact graph-retrieval pass for engram_recall: entities the returned
614
+ * memories are anchored to, and the ESR relation lines touching them
615
+ * (`node --rel--> node · conf%`). This is the lightweight end of the
616
+ * graph-memory spectrum — it reuses the existing `links`/`entities` tables,
617
+ * no graph database. Deterministic, capped (≤ 8 lines), deduped by link id.
618
+ */
619
+ /**
620
+ * Model-facing nudge for the near-empty entity graph: after a task/node
621
+ * change, tell the model how the workspace's entity/relation graph looks so
622
+ * esr_node / esr_link stay in its decision space. Returns "" when the domain
623
+ * is unavailable so callers can skip the extra line silently.
624
+ */
625
+ export function esrModelingHint(domain, workspace) {
626
+ try {
627
+ const entities = domain.listEntities(workspace);
628
+ const links = domain.allLinks(workspace);
629
+ if (entities.length === 0) {
630
+ return "modeling: no entity graph in this workspace yet — esr_task(entity=…) auto-creates the node and hangs the task on it; or use esr_node for recurring domain objects, then esr_link to connect (graph: GUI Settings → Engram).";
631
+ }
632
+ const names = entities.slice(0, 3).map((e) => e.name).join(", ");
633
+ return `modeling: entities ${names}${entities.length > 3 ? ` +${entities.length - 3}` : ""} / links ${links.length} — esr_task(entity=…) hangs new work onto a node; esr_link can wire tasks & nodes further into this graph.`;
634
+ } catch {
635
+ return "";
636
+ }
637
+ }
638
+
639
+ /**
640
+ * Auto-wire a task onto a domain object (graph-hygiene fix): ensure the entity
641
+ * node exists (id = ent_<slug>, same derivation as esr_node) and add the
642
+ * one-way `task --relates_to--> entity` edge, idempotently. One call instead
643
+ * of esr_node + esr_link so a task is never an isolated node. Pure/mechanical,
644
+ * zero LLM. Returns a short report line ("" when entity is empty).
645
+ */
646
+ export async function wireTaskEntity(domain, workspace, taskId, entityName, sessionId, now = Date.now()) {
647
+ const name = String(entityName).trim().slice(0, 120);
648
+ if (name.length === 0) return "";
649
+ const entId = `ent_${slugId(name)}`;
650
+ let created = false;
651
+ if (domain.getEntity(workspace, entId) === void 0) {
652
+ await domain.putEntity({
653
+ id: entId,
654
+ workspace,
655
+ name,
656
+ description: "",
657
+ kind: "concept",
658
+ sessionId,
659
+ createdAt: now,
660
+ updatedAt: now,
661
+ });
662
+ created = true;
663
+ }
664
+ const added = await domain.addLinkOnce({
665
+ id: uuid(),
666
+ workspace,
667
+ source: taskId,
668
+ relation: "relates_to",
669
+ target: entId,
670
+ confidence: 1,
671
+ sessionId,
672
+ createdAt: now,
673
+ });
674
+ return `wired: ${taskId} --relates_to--> ${entId}${created ? " (node created)" : ""}${added ? "" : " (edge already exists)"}`;
675
+ }
676
+
677
+ function collectNeighborhood(domain, workspace, items) {
678
+ const involved = new Set();
679
+ for (const m of items ?? []) {
680
+ if (m?.entity !== null && m?.entity !== void 0 && m.entity !== "") involved.add(m.entity);
681
+ }
682
+ if (involved.size === 0) return [];
683
+ const label = (id) => {
684
+ const e = domain.getEntity(workspace, id);
685
+ return e !== void 0 ? e.name || id : id;
686
+ };
687
+ const seen = new Set();
688
+ const lines = [];
689
+ for (const ent of involved) {
690
+ for (const l of domain.linksFor(workspace, ent)) {
691
+ if (seen.has(l.id)) continue;
692
+ seen.add(l.id);
693
+ lines.push(`- ${label(l.source)} --${l.relation}--> ${label(l.target)} (${Math.round((l.confidence ?? 1) * 100)}%)`);
694
+ if (lines.length >= 8) return lines;
695
+ }
696
+ }
697
+ return lines;
698
+ }
699
+
413
700
  /** FTS over past sessions via ctx.sessionQuery (mounted by the web profile). */
414
701
  async function searchSessions(service, caller, query, signal) {
415
702
  const sq = service.sessionQuery;