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/README.md +91 -5
- package/README.zh.md +46 -4
- package/lib/api.js +83 -10
- package/lib/capture.js +11 -7
- package/lib/client.js +4590 -1057
- package/lib/index-block.js +19 -5
- package/lib/index.js +16 -0
- package/lib/mental.js +170 -0
- package/lib/obs.js +71 -0
- package/lib/rerank.js +50 -0
- package/lib/settings.js +3 -0
- package/lib/store.js +314 -32
- package/lib/tools.js +287 -61
- package/lib/usage.js +224 -60
- package/lib/util.js +107 -0
- package/package.json +3 -7
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
//
|
|
88
|
-
//
|
|
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: "
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
168
|
-
if (items.length === 0)
|
|
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
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
lines.push(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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;
|