dsh-engram 0.2.0 → 0.3.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/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,7 +250,7 @@ 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.",
220
254
  parameters: {
221
255
  type: "object",
222
256
  properties: {
@@ -250,29 +284,36 @@ export function registerTools(ctx, service) {
250
284
  createdAt: existing?.createdAt ?? now,
251
285
  updatedAt: now,
252
286
  stateChangedAt: existing?.stateChangedAt ?? 0,
287
+ deps: existing?.deps ?? [],
288
+ assignee: existing?.assignee ?? null,
289
+ claimedAt: existing?.claimedAt ?? null,
253
290
  };
254
291
  await domain.putTask(task);
255
- return `task ${task.id} [${task.state}] — ${task.name}`;
292
+ await domain.markModelDirty?.(caller.cwd).catch?.(() => null);
293
+ const hint = esrModelingHint(domain, caller.cwd);
294
+ return `task ${task.id} [${task.state}] — ${task.name}${hint ? `
295
+ ${hint}` : ""}`;
256
296
  },
257
297
  }),
258
298
 
259
299
  // ── esr_close ─────────────────────────────────────────────────────
260
300
  registerTool({
261
301
  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.",
302
+ 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
303
  parameters: {
264
304
  type: "object",
265
305
  properties: {
266
306
  task_id: { type: "string", description: "Task id from [ESR] or esr_task" },
267
- artifact: { type: "string", description: "Produced artifact (path/url)" },
307
+ artifact: { type: "string", description: "Produced artifact (path/url). Paths are resolved against the workspace and must exist on disk unless force:true" },
268
308
  evaluation: { type: "string", description: "Verification evidence (test/review/benchmark)" },
269
309
  memory_refs: { type: "array", items: { type: "string" }, description: "Memory ids that record the decisions made" },
310
+ force: { type: "boolean", description: "Skip the on-disk artifact existence check (gates are still required)" },
270
311
  },
271
312
  required: ["task_id"],
272
313
  },
273
314
  output: TEXT_OUTPUT,
274
315
  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");
316
+ 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
317
  const caller = callerOf(exec);
277
318
  const domain = await requireDomain(service);
278
319
  const task = domain.getTask(caller.cwd, resolveTaskId(domain, caller.cwd, args.task_id));
@@ -284,16 +325,20 @@ export function registerTools(ctx, service) {
284
325
  memoryRefs: args.memory_refs ?? task.memoryRefs,
285
326
  updatedAt: Date.now(),
286
327
  };
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");
328
+ // force only skips the on-disk existence check — the three gates
329
+ // (artifact/evaluation/memory_ref) are always required.
330
+ const gateCfg = args.force === true ? { ...service.config, verifyArtifact: false } : service.config;
331
+ const gate = domain.evidenceGate(caller.cwd, next, gateCfg);
332
+ const gaps = gate.gaps;
291
333
  if (gaps.length > 0) {
292
334
  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).`;
335
+ await domain.markModelDirty?.(caller.cwd).catch?.(() => null);
336
+ const reason = gate.artifactReason !== void 0 ? ` (${gate.artifactReason})` : "";
337
+ 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
338
  }
295
- const closed = { ...next, state: "stable", stateChangedAt: Date.now() };
339
+ const closed = domain.compactOnClose?.({ ...next, state: "stable", stateChangedAt: Date.now() }) ?? { ...next, state: "stable", stateChangedAt: Date.now() };
296
340
  await domain.putTask(closed);
341
+ await domain.markModelDirty?.(caller.cwd).catch?.(() => null);
297
342
  const mem = await domain.storeMemory(
298
343
  {
299
344
  workspace: caller.cwd,
@@ -307,14 +352,16 @@ export function registerTools(ctx, service) {
307
352
  },
308
353
  service.config,
309
354
  );
310
- return `task ${task.id} → STABLE ✓ (artifact, evaluation, ${next.memoryRefs.length} memory ref(s))\nclosure memory: ${mem.id}`;
355
+ const hint = esrModelingHint(domain, caller.cwd);
356
+ return `task ${task.id} → STABLE ✓ (artifact, evaluation, ${next.memoryRefs.length} memory ref(s))\nclosure memory: ${mem.id}${hint ? `
357
+ ${hint}` : ""}`;
311
358
  },
312
359
  }),
313
360
 
314
361
  // ── esr_link ──────────────────────────────────────────────────────
315
362
  registerTool({
316
363
  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.",
364
+ description: "Add a typed relation between two entities (depends_on/implements/refines/contradicts…). Feeds entity queries and the workspace link count.",
318
365
  parameters: {
319
366
  type: "object",
320
367
  properties: {
@@ -341,14 +388,109 @@ export function registerTools(ctx, service) {
341
388
  sessionId: caller.sessionId,
342
389
  createdAt: Date.now(),
343
390
  });
391
+ await domain.markModelDirty?.(caller.cwd).catch?.(() => null);
344
392
  return `link ${id} — ${args.source} --${args.relation}--> ${args.target}`;
345
393
  },
346
394
  }),
347
395
 
348
- // ── esr_node ──────────────────────────────────────────────────────
396
+ registerTool({
397
+ name: "esr_dep",
398
+ description: "Add a dependency edge between tasks: blocks (waits until dep closes), parent-of (child), relates-to (no blocking). Refuses self-deps and cycles.",
399
+ parameters: {
400
+ type: "object",
401
+ properties: {
402
+ task_id: { type: "string", description: "The task gaining the dependency" },
403
+ dep_id: { type: "string", description: "The task it depends on / relates to" },
404
+ kind: { type: "string", enum: ["blocks", "relates-to", "parent-of"], description: "Edge kind (default blocks)" },
405
+ },
406
+ required: ["task_id", "dep_id"],
407
+ },
408
+ output: TEXT_OUTPUT,
409
+ execute: async (raw, exec) => {
410
+ 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");
411
+ const caller = callerOf(exec);
412
+ const domain = await requireDomain(service);
413
+ const t = await domain.addDep(caller.cwd, resolveTaskId(domain, caller.cwd, args.task_id), resolveTaskId(domain, caller.cwd, args.dep_id), args.kind ?? "blocks");
414
+ await domain.markModelDirty?.(caller.cwd).catch?.(() => null);
415
+ return `dep added: ${t.id} --${args.kind ?? "blocks"}--> ${args.dep_id} (total ${(t.deps ?? []).length} edges)`;
416
+ },
417
+ }),
418
+
419
+ // ── esr_claim / esr_unclaim ─────────────────────────────────────
420
+ registerTool({
421
+ name: "esr_claim",
422
+ description: "Atomically claim a task (assignee+claimedAt, draft→active). Refuses when another agent holds it unless force:true. Find work with esr_ready.",
423
+ parameters: {
424
+ type: "object",
425
+ properties: {
426
+ task_id: { type: "string", description: "Task id from [ESR] or esr_task" },
427
+ agent: { type: "string", description: "Claimer id (default: current session agent)" },
428
+ force: { type: "boolean", description: "Steal the claim from its current holder (default false)" },
429
+ },
430
+ required: ["task_id"],
431
+ },
432
+ output: TEXT_OUTPUT,
433
+ execute: async (raw, exec) => {
434
+ const args = parse(z.object({ task_id: z.string().min(1), agent: z.string().optional(), force: z.boolean().optional() }), raw, "esr_claim");
435
+ const caller = callerOf(exec);
436
+ const domain = await requireDomain(service);
437
+ const agent = args.agent ?? caller.agentId ?? "agent";
438
+ const t = await domain.claimTask(caller.cwd, resolveTaskId(domain, caller.cwd, args.task_id), agent, { force: args.force === true });
439
+ await domain.markModelDirty?.(caller.cwd).catch?.(() => null);
440
+ return `claimed ${t.id} by ${agent} [${t.state}] — ${t.name}`;
441
+ },
442
+ }),
443
+ registerTool({
444
+ name: "esr_unclaim",
445
+ description: "Release a claimed task's assignee back to null. Non-holder needs force:true. Inverse of esr_claim.",
446
+ parameters: {
447
+ type: "object",
448
+ properties: {
449
+ task_id: { type: "string", description: "Task id from [ESR] or esr_task" },
450
+ force: { type: "boolean", description: "Release even when someone else holds it (default false)" },
451
+ },
452
+ required: ["task_id"],
453
+ },
454
+ output: TEXT_OUTPUT,
455
+ execute: async (raw, exec) => {
456
+ const args = parse(z.object({ task_id: z.string().min(1), force: z.boolean().optional() }), raw, "esr_unclaim");
457
+ const caller = callerOf(exec);
458
+ const domain = await requireDomain(service);
459
+ const agent = caller.agentId ?? "agent";
460
+ const t = await domain.unclaimTask(caller.cwd, resolveTaskId(domain, caller.cwd, args.task_id), agent, { force: args.force === true });
461
+ await domain.markModelDirty?.(caller.cwd).catch?.(() => null);
462
+ return `released ${t.id} — ${t.name} (unclaimed)`;
463
+ },
464
+ }),
465
+
466
+ // ── esr_ready ────────────────────────────────────────────────────
467
+ registerTool({
468
+ name: "esr_ready",
469
+ description: "List claimable tasks: no open blocker and nobody claimed. One line per task.",
470
+ parameters: {
471
+ type: "object",
472
+ properties: {
473
+ workspace: { type: "string", description: "Override the workspace key (default: current session cwd)" },
474
+ limit: { type: "integer", minimum: 1, maximum: 50, description: "Max tasks (default 10)" },
475
+ },
476
+ },
477
+ output: TEXT_OUTPUT,
478
+ timeoutMs: 20000,
479
+ execute: async (raw, exec) => {
480
+ const args = parse(z.object({ workspace: z.string().optional(), limit: z.number().int().min(1).max(50).optional() }), raw, "esr_ready");
481
+ const caller = callerOf(exec);
482
+ const domain = await requireDomain(service);
483
+ const ws = args.workspace ?? caller.cwd;
484
+ const ready = domain.readyTasks(ws, args.limit ?? 10);
485
+ if (ready.length === 0) return `no claimable work right now in ${ws} — tasks with open blockers stay blocked until their deps close`;
486
+ 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");
487
+ },
488
+ }),
489
+
490
+ // ── esr_node ─────────────────────────────────────
349
491
  registerTool({
350
492
  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.",
493
+ 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
494
  parameters: {
353
495
  type: "object",
354
496
  properties: {
@@ -377,16 +519,20 @@ export function registerTools(ctx, service) {
377
519
  updatedAt: now,
378
520
  };
379
521
  await domain.putEntity(node);
380
- return existing
522
+ await domain.markModelDirty?.(caller.cwd).catch?.(() => null);
523
+ const hint = esrModelingHint(domain, caller.cwd);
524
+ const base = existing
381
525
  ? `node ${id} updated — ${node.name}${node.kind ? ` (${node.kind})` : ""}`
382
526
  : `node ${id} created — ${node.name}${node.kind ? ` (${node.kind})` : ""}`;
527
+ return `${base}${hint ? `
528
+ ${hint}` : ""}`;
383
529
  },
384
530
  }),
385
531
 
386
532
  // ── esr_gc ────────────────────────────────────────────────────────
387
533
  registerTool({
388
534
  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.",
535
+ 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
536
  parameters: {
391
537
  type: "object",
392
538
  properties: {
@@ -403,6 +549,36 @@ export function registerTools(ctx, service) {
403
549
  return gcReportText(report);
404
550
  },
405
551
  }),
552
+
553
+ // ── esr_model ─────────────────────────────────────────────────────
554
+ registerTool({
555
+ name: "esr_model",
556
+ 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.",
557
+ parameters: {
558
+ type: "object",
559
+ properties: {
560
+ ws: { type: "string", description: "Workspace to summarize (default: current session cwd)" },
561
+ mode: { type: "string", enum: ["full", "brief"], description: "brief = one-line headline (session-start injection); full = complete markdown (default)" },
562
+ max_chars: { type: "integer", minimum: 1, maximum: 4000, description: "Cap output length (default: no cap)" },
563
+ },
564
+ },
565
+ output: TEXT_OUTPUT,
566
+ timeoutMs: 20000,
567
+ execute: async (raw, exec) => {
568
+ 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");
569
+ const caller = callerOf(exec);
570
+ const domain = await requireDomain(service);
571
+ const ws = args.ws ?? caller.cwd;
572
+ const { getModel } = await import("./mental.js");
573
+ const model = await getModel(domain, ws, { mode: args.mode ?? "full" });
574
+ const mins = Math.max(0, Math.floor((Date.now() - model.generated_at) / 60000));
575
+ let content = model.content;
576
+ if (typeof args.max_chars === "number" && content.length > args.max_chars) {
577
+ content = `${content.slice(0, Math.max(1, args.max_chars - 1))}…`;
578
+ }
579
+ return `${content}\n\n(生成于 ${mins} 分钟前 · ${model.fresh ? "本次重算" : "缓存命中"} · mode=${args.mode ?? "full"} · ws=${ws})`;
580
+ },
581
+ }),
406
582
  ];
407
583
  return () => {
408
584
  for (const dispose of disposers) dispose();
@@ -410,6 +586,56 @@ export function registerTools(ctx, service) {
410
586
  });
411
587
  }
412
588
 
589
+ /**
590
+ * Compact graph-retrieval pass for engram_recall: entities the returned
591
+ * memories are anchored to, and the ESR relation lines touching them
592
+ * (`node --rel--> node · conf%`). This is the lightweight end of the
593
+ * graph-memory spectrum — it reuses the existing `links`/`entities` tables,
594
+ * no graph database. Deterministic, capped (≤ 8 lines), deduped by link id.
595
+ */
596
+ /**
597
+ * Model-facing nudge for the near-empty entity graph: after a task/node
598
+ * change, tell the model how the workspace's entity/relation graph looks so
599
+ * esr_node / esr_link stay in its decision space. Returns "" when the domain
600
+ * is unavailable so callers can skip the extra line silently.
601
+ */
602
+ export function esrModelingHint(domain, workspace) {
603
+ try {
604
+ const entities = domain.listEntities(workspace);
605
+ const links = domain.allLinks(workspace);
606
+ if (entities.length === 0) {
607
+ return "modeling: no entity graph in this workspace yet — create esr_node entries for recurring domain objects, then esr_link to connect them (graph: GUI Settings → Engram).";
608
+ }
609
+ const names = entities.slice(0, 3).map((e) => e.name).join(", ");
610
+ return `modeling: entities ${names}${entities.length > 3 ? ` +${entities.length - 3}` : ""} / links ${links.length} — esr_link can wire new tasks & nodes into this graph.`;
611
+ } catch {
612
+ return "";
613
+ }
614
+ }
615
+
616
+ function collectNeighborhood(domain, workspace, items) {
617
+ const involved = new Set();
618
+ for (const m of items ?? []) {
619
+ if (m?.entity !== null && m?.entity !== void 0 && m.entity !== "") involved.add(m.entity);
620
+ }
621
+ if (involved.size === 0) return [];
622
+ const label = (id) => {
623
+ const e = domain.getEntity(workspace, id);
624
+ return e !== void 0 ? e.name || id : id;
625
+ };
626
+ const seen = new Set();
627
+ const lines = [];
628
+ for (const ent of involved) {
629
+ for (const l of domain.linksFor(workspace, ent)) {
630
+ if (seen.has(l.id)) continue;
631
+ seen.add(l.id);
632
+ lines.push(`- ${label(l.source)} --${l.relation}--> ${label(l.target)} (${Math.round((l.confidence ?? 1) * 100)}%)`);
633
+ if (lines.length >= 8) return lines;
634
+ }
635
+ }
636
+ return lines;
637
+ }
638
+
413
639
  /** FTS over past sessions via ctx.sessionQuery (mounted by the web profile). */
414
640
  async function searchSessions(service, caller, query, signal) {
415
641
  const sq = service.sessionQuery;