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/README.md +115 -5
- package/README.zh.md +60 -4
- package/lib/api.js +91 -10
- package/lib/capture.js +11 -7
- package/lib/client.js +4590 -1057
- package/lib/index-block.js +163 -27
- package/lib/index.js +43 -1
- 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 +330 -32
- package/lib/tools.js +349 -62
- package/lib/trigger.js +379 -0
- 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,19 +250,20 @@ 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. 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
|
-
|
|
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
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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;
|