dsh-engram 0.3.0 → 0.3.3
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 +113 -2
- package/README.zh.md +83 -3
- package/cordis.patch.yml +12 -0
- package/lib/api.js +32 -2
- package/lib/client.js +15 -1
- package/lib/compaction.js +85 -0
- package/lib/context-gc.js +626 -0
- package/lib/index-block.js +280 -28
- package/lib/index.js +100 -14
- package/lib/settings.js +4 -0
- package/lib/store.js +31 -0
- package/lib/tools.js +98 -8
- package/lib/trigger.js +379 -0
- package/package.json +15 -2
package/lib/index-block.js
CHANGED
|
@@ -130,16 +130,36 @@ function enforceCharCap(lines, maxChars) {
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
/**
|
|
133
|
-
* One-line
|
|
134
|
-
*
|
|
135
|
-
* its own under-proactivity
|
|
136
|
-
* Returns "" when there is nothing to correct (no data,
|
|
137
|
-
* or the balance is healthy) — the reminder disappears
|
|
138
|
-
* improves, closing the loop.
|
|
133
|
+
* One-line escalation reminder from an arbitrary balance view
|
|
134
|
+
* ({ memCalls, esrCalls, days }). Surfaces the workspace real recent
|
|
135
|
+
* mem-vs-esr balance so the model sees its own under-proactivity and can
|
|
136
|
+
* self-correct NOW. Returns "" when there is nothing to correct (no data,
|
|
137
|
+
* too small a sample, or the balance is healthy) — the reminder disappears
|
|
138
|
+
* once behaviour improves, closing the loop. Pure, deterministic.
|
|
139
139
|
*/
|
|
140
|
-
export function
|
|
140
|
+
export function esrBalanceHint(balance) {
|
|
141
|
+
if (balance === void 0 || balance === null) return "";
|
|
142
|
+
const memCalls = balance.memCalls ?? 0;
|
|
143
|
+
const esrCalls = balance.esrCalls ?? 0;
|
|
144
|
+
const days = balance.days ?? 0;
|
|
145
|
+
// No data or too little signal — lecturing on <3 memory ops is noise.
|
|
146
|
+
if (memCalls < 3 || days < 1) return "";
|
|
147
|
+
const ratio = esrCalls / (memCalls + esrCalls);
|
|
148
|
+
// Healthy once esr exceeds ~a third of memory-ops volume; otherwise nudge.
|
|
149
|
+
if (ratio >= 0.34) return "";
|
|
150
|
+
return `escalate: last ${days}d ${memCalls} mem ops vs ${esrCalls} esr calls (${Math.round(ratio * 100)}%) — multi-step work → esr_task now, recurring objects → esr_node, related things → esr_link, and close with evidence (esr_close) #suggest-escalate`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Balance view derived from the per-(workspace,day) `usage` table (legacy).
|
|
155
|
+
* The table is no longer written in production (assessment P3 moved
|
|
156
|
+
* observability to on-demand session-log scans); this path exists for
|
|
157
|
+
* backward compatibility and store-driven tests. Production prefers the
|
|
158
|
+
* live in-memory recorder view (lib/trigger.js) so the nudge is not starved.
|
|
159
|
+
*/
|
|
160
|
+
export function usageBalanceView(store, workspace) {
|
|
141
161
|
const rows = store.usageRows(workspace);
|
|
142
|
-
if (rows.length === 0) return
|
|
162
|
+
if (rows.length === 0) return null;
|
|
143
163
|
const recent = rows.slice(-14);
|
|
144
164
|
const MEM = ["engram_store", "engram_recall", "engram_detail", "loom_store", "loom_recall", "loom_detail"];
|
|
145
165
|
const ESR = ["esr_task", "esr_node", "esr_close", "esr_link", "esr_gc"];
|
|
@@ -150,44 +170,276 @@ export function escalationHint(store, workspace) {
|
|
|
150
170
|
for (const k of MEM) memCalls += c[k] ?? 0;
|
|
151
171
|
for (const k of ESR) esrCalls += c[k] ?? 0;
|
|
152
172
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
173
|
+
return { memCalls, esrCalls, days: recent.length };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Legacy entry point (store-driven escalation nudge). Kept for callers/tests
|
|
178
|
+
* that predate the decision-point recorder; prefer `esrBalanceHint` with a
|
|
179
|
+
* live balance view.
|
|
180
|
+
*/
|
|
181
|
+
export function escalationHint(store, workspace) {
|
|
182
|
+
return esrBalanceHint(usageBalanceView(store, workspace));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The three ESR closure evidence gates for a task. Null/empty counts as a gap.
|
|
187
|
+
*/
|
|
188
|
+
export function evidenceGaps(t) {
|
|
189
|
+
const gaps = [];
|
|
190
|
+
if (t.artifact === null || t.artifact === "") gaps.push("artifact");
|
|
191
|
+
if (t.evaluation === null || t.evaluation === "") gaps.push("evaluation");
|
|
192
|
+
if (t.memoryRefs.length === 0) gaps.push("memory_ref");
|
|
193
|
+
return gaps;
|
|
159
194
|
}
|
|
160
195
|
|
|
161
196
|
/**
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
197
|
+
* "What should I do with this task next" sort rank — READY-to-close first
|
|
198
|
+
* (evidence complete → esr_close is the highest-value next move), then claimed
|
|
199
|
+
* in-progress, then other active, then drafts.
|
|
165
200
|
*/
|
|
166
|
-
|
|
201
|
+
function taskNextRank(t) {
|
|
202
|
+
if (t.state === "stable") return 10;
|
|
203
|
+
if (evidenceGaps(t).length === 0) return 0;
|
|
204
|
+
if (t.assignee) return 1;
|
|
205
|
+
if (t.state === "active") return 2;
|
|
206
|
+
if (t.state === "draft") return 3;
|
|
207
|
+
return 4;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Order active tasks so the model can act top-down without re-scanning.
|
|
212
|
+
*
|
|
213
|
+
* The final tiebreak is the task id (lexicographic), NEVER updatedAt: a
|
|
214
|
+
* timestamp in the sort would make the frozen per-session snapshot drift
|
|
215
|
+
* between turns and shatter the prefix cache. pi-esr's rule 2 — timestamps
|
|
216
|
+
* excluded from context — applied verbatim.
|
|
217
|
+
*/
|
|
218
|
+
export function sortActiveForNext(tasks) {
|
|
219
|
+
return [...tasks].sort((a, b) => {
|
|
220
|
+
const ra = taskNextRank(a);
|
|
221
|
+
const rb = taskNextRank(b);
|
|
222
|
+
if (ra !== rb) return ra - rb;
|
|
223
|
+
const ga = evidenceGaps(a).length;
|
|
224
|
+
const gb = evidenceGaps(b).length;
|
|
225
|
+
if (ga !== gb) return ga - gb;
|
|
226
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Recurring-failure candidates (P0-B): error memories that earned
|
|
232
|
+
* `hits >= minErrorHits` through repeat-failure revival — durable breakage,
|
|
233
|
+
* the strongest "turn this into a root-cause task" signal. Most-hit first.
|
|
234
|
+
*/
|
|
235
|
+
export function selectRootCauseCandidates(store, workspace, config) {
|
|
236
|
+
const min = (config?.minErrorHits ?? 2) | 0;
|
|
237
|
+
if (min < 1) return [];
|
|
238
|
+
return store
|
|
239
|
+
.listMemories(workspace, 200)
|
|
240
|
+
.filter((m) => m.kind === "error" && (m.hits ?? 0) >= min)
|
|
241
|
+
.sort((a, b) => (b.hits ?? 0) - (a.hits ?? 0) || (b.updatedAt ?? 0) - (a.updatedAt ?? 0))
|
|
242
|
+
.map((m) => ({ id: m.id, text: String(m.text), hits: m.hits ?? 0 }));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Stale-task candidates (P3): active tasks that have not been touched for
|
|
247
|
+
* `staleTaskDays` (default 14) — close with evidence or drop the intent.
|
|
248
|
+
*/
|
|
249
|
+
export function selectStaleCandidates(tasks, config) {
|
|
250
|
+
const days = (config?.staleTaskDays ?? 14) | 0;
|
|
251
|
+
if (days < 1) return [];
|
|
252
|
+
const cutoff = Date.now() - days * 86_400_000;
|
|
253
|
+
return tasks
|
|
254
|
+
.filter((t) => (t.updatedAt ?? t.createdAt ?? 0) < cutoff)
|
|
255
|
+
.map((t) => ({ id: t.id, name: t.name, updatedAt: t.updatedAt ?? t.createdAt, createdAt: t.createdAt }));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Static ESR operating methodology (pi-esr rule 1: the system prompt never
|
|
260
|
+
* changes at runtime — this text is process-static and byte-identical every
|
|
261
|
+
* turn). Teaches WHEN to create / promote / close so the model no longer
|
|
262
|
+
* depends on runtime nudges to remember the protocol.
|
|
263
|
+
*/
|
|
264
|
+
export function esrMethodology() {
|
|
265
|
+
return [
|
|
266
|
+
"ESR(工程状态)操作协议 —— 多步工作建任务、用证据闭环:",
|
|
267
|
+
" 1. 动工前:esr_ready 看可认领工作;esr_status 拿实时状态(本块是会话开始快照,不会自动刷新)。",
|
|
268
|
+
" 2. 多步工作 → esr_task(name=\"…\", entity=…) 建任务(draft 起步);重复对象 → esr_node;关系 → esr_link。",
|
|
269
|
+
" 3. 动工 → esr_claim(draft → active)。",
|
|
270
|
+
" 4. 收工 → esr_close(artifact + evaluation + memory_ref 三证齐才算数;不齐的活继续挂着,别硬关)。",
|
|
271
|
+
" state 是唯一真相:拿不准 state 就 call esr_status。",
|
|
272
|
+
].join("\n");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Build the DETERMINISTIC per-session [ESR] snapshot for a workspace.
|
|
277
|
+
*
|
|
278
|
+
* Pure derivation from the store — no recorder, no timestamps, no hint lines.
|
|
279
|
+
* Same store snapshot → byte-identical output, so the section provider can
|
|
280
|
+
* render it ONCE per session and freeze it (prefix-cache stable, pi-esr rule
|
|
281
|
+
* 2: injected once, never auto-refreshed; the model PULLS live state).
|
|
282
|
+
*
|
|
283
|
+
* @param opts {{ sessionId?: string }} — reserved for future live-tail
|
|
284
|
+
* wording; the block itself is identical either way.
|
|
285
|
+
*/
|
|
286
|
+
export function buildEsrSnapshot(store, workspace, config, opts = {}) {
|
|
167
287
|
const active = store.listTasks(workspace, { includeStable: false });
|
|
168
288
|
const stable = store.listTasks(workspace, { includeStable: true }).filter((t) => t.state === "stable");
|
|
169
|
-
const
|
|
289
|
+
const draft = active.filter((t) => t.state === "draft").length;
|
|
290
|
+
const ordered = sortActiveForNext(active);
|
|
291
|
+
|
|
170
292
|
if (active.length === 0 && stable.length === 0) {
|
|
171
293
|
// Keep the mechanism visible even when idle: one compact line teaches the
|
|
172
294
|
// model that multi-step work earns an esr_task closed with real evidence.
|
|
173
|
-
|
|
174
|
-
|
|
295
|
+
return [
|
|
296
|
+
"[ESR] no open tasks — BE PROACTIVE: multi-step work gets a task now (esr_task); recurring domain objects get a node (esr_node); related things get a link (esr_link). Close tasks with real evidence (esr_close)",
|
|
297
|
+
"snapshot from session start — WILL NOT auto-refresh; call esr_status for live state",
|
|
298
|
+
].join("\n");
|
|
175
299
|
}
|
|
176
300
|
|
|
177
301
|
const lines = [];
|
|
178
|
-
lines.push(`[ESR] tasks: ${
|
|
179
|
-
|
|
302
|
+
lines.push(`[ESR] tasks: ${ordered.filter((t) => t.state !== "draft").length} active / ${stable.length} stable${draft > 0 ? ` / ${draft} draft` : ""}`);
|
|
303
|
+
const firstReady = ordered.find((t) => evidenceGaps(t).length === 0);
|
|
304
|
+
if (firstReady !== void 0) {
|
|
305
|
+
lines.push(`next: esr_close ${firstReady.id.slice(0, 6)} — ${escapeLt(truncate(firstReady.name, 40))} (all evidence present)`);
|
|
306
|
+
}
|
|
307
|
+
for (const t of ordered.slice(0, 8)) lines.push(`- ${t.id.slice(0, 6)}: ${escapeLt(truncate(t.name, 60))} — ${t.state.toUpperCase()}${taskGapSuffix(t)}`);
|
|
180
308
|
if (active.length > 8) lines.push(`- … ${active.length - 8} more`);
|
|
181
309
|
if (stable.length > 0) lines.push(`- closed: ${stable.slice(0, 6).map((t) => `${t.id.slice(0, 6)} (${truncate(t.name, 28)})`).join(" · ")}${stable.length > 6 ? ` +${stable.length - 6}` : ""}`);
|
|
182
|
-
|
|
310
|
+
lines.push("snapshot from session start — WILL NOT auto-refresh; call esr_status for live state");
|
|
311
|
+
return lines.join("\n");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The derived decision-point hint lines, assembled ONLY on the pull path
|
|
316
|
+
* (`esr_status`) — never in the frozen [ESR] block. Recorder-fed and
|
|
317
|
+
* one-shot per session; P4 exposure is counted here (real return path), while
|
|
318
|
+
* the GUI preview (no recorder/session) yields [] and counts nothing.
|
|
319
|
+
*
|
|
320
|
+
* @returns {Array<{ kind: string, line: string }>}
|
|
321
|
+
*/
|
|
322
|
+
export function esrHintLines(store, workspace, config, opts = {}) {
|
|
323
|
+
const recorder = opts?.recorder;
|
|
324
|
+
const sessionId = opts?.sessionId;
|
|
325
|
+
const live = recorder !== void 0 && sessionId !== void 0;
|
|
326
|
+
if (!live) return [];
|
|
327
|
+
const active = store.listTasks(workspace, { includeStable: false });
|
|
328
|
+
const ordered = sortActiveForNext(active);
|
|
329
|
+
const out = [];
|
|
330
|
+
const promote = recorder.promoteHint(sessionId, workspace, active.length);
|
|
331
|
+
if (promote !== "") {
|
|
332
|
+
out.push({ kind: "promote", line: promote });
|
|
333
|
+
recorder.emitHint("promote", workspace, sessionId);
|
|
334
|
+
}
|
|
335
|
+
const rootCause = recorder.errorHint(sessionId, workspace, selectRootCauseCandidates(store, workspace, config));
|
|
336
|
+
if (rootCause !== "") {
|
|
337
|
+
out.push({ kind: "rootcause", line: rootCause });
|
|
338
|
+
recorder.emitHint("rootcause", workspace, sessionId);
|
|
339
|
+
}
|
|
340
|
+
const closure = recorder.closureHint(sessionId, workspace, ordered.filter((t) => evidenceGaps(t).length === 0));
|
|
341
|
+
if (closure !== "") {
|
|
342
|
+
out.push({ kind: "close", line: closure });
|
|
343
|
+
recorder.emitHint("close", workspace, sessionId);
|
|
344
|
+
}
|
|
345
|
+
const stale = recorder.staleHint(sessionId, workspace, selectStaleCandidates(active, config));
|
|
346
|
+
if (stale !== "") {
|
|
347
|
+
out.push({ kind: "stale", line: stale });
|
|
348
|
+
recorder.emitHint("stale", workspace, sessionId);
|
|
349
|
+
}
|
|
350
|
+
// Live in-memory balance feeds the escalation reminder (falls back to the
|
|
351
|
+
// legacy usage-table view for store-driven callers without a recorder).
|
|
352
|
+
const balance = recorder !== void 0 ? recorder.recentBalance(workspace) : usageBalanceView(store, workspace);
|
|
353
|
+
const escalate = esrBalanceHint(balance);
|
|
354
|
+
if (escalate !== "") {
|
|
355
|
+
out.push({ kind: "escalate", line: escalate });
|
|
356
|
+
recorder.emitHint("escalate", workspace, sessionId);
|
|
357
|
+
}
|
|
358
|
+
return out;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Render the frozen [ESR] snapshot (backward-compatible face of
|
|
363
|
+
* `buildEsrSnapshot`). ALWAYS hint-free: the section provider renders this
|
|
364
|
+
* once per session and freezes it; decision-point lines live on the
|
|
365
|
+
* `esr_status` pull path via `esrHintLines`.
|
|
366
|
+
*/
|
|
367
|
+
export function renderEsr(store, workspace, config, opts = {}) {
|
|
368
|
+
return buildEsrSnapshot(store, workspace, config, opts);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Stable display order for accumulated this-session actionables. */
|
|
372
|
+
export const ESR_HINT_ORDER = ["promote", "rootcause", "close", "stale", "escalate"];
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Build the per-session [ESR] block the model actually sees: a FROZEN
|
|
376
|
+
* deterministic snapshot (never changes mid-session, pi-esr rule 2) PLUS the
|
|
377
|
+
* session's monotonically accumulated decision-point actionables.
|
|
378
|
+
*
|
|
379
|
+
* An actionable is APPENDED exactly once, the first time it matures (the
|
|
380
|
+
* recorder's per-(session,kind) suppression makes esrHintLines return it only
|
|
381
|
+
* that once); after appending it stays for the whole session (never removed,
|
|
382
|
+
* even if the underlying condition later clears) — so the prefix changes only
|
|
383
|
+
* when genuinely new decision-point info appears, never per-turn. Snapshot
|
|
384
|
+
* stays pure (no timestamps, no recorder) and live state is still pulled via
|
|
385
|
+
* `esr_status`.
|
|
386
|
+
*
|
|
387
|
+
* @param opts {{
|
|
388
|
+
* recorder?: import("./trigger.js").TriggerRecorder,
|
|
389
|
+
* sessionId?: string,
|
|
390
|
+
* holder?: { actionables: Map<string,string> }, // per-session accumulator
|
|
391
|
+
* snapshot?: string, // pre-frozen snapshot (host passes the session-start one)
|
|
392
|
+
* }}
|
|
393
|
+
*/
|
|
394
|
+
export function buildFrozenEsrBlock(store, workspace, config, opts = {}) {
|
|
395
|
+
// Host passes the session-start snapshot (frozen, pi-esr rule 2); callers
|
|
396
|
+
// without one get a fresh deterministic derivation (pure functions/tests).
|
|
397
|
+
const snapshot = typeof opts?.snapshot === "string" ? opts.snapshot : buildEsrSnapshot(store, workspace, config, opts);
|
|
398
|
+
const holder = opts?.holder;
|
|
399
|
+
const recorder = opts?.recorder;
|
|
400
|
+
const sessionId = opts?.sessionId;
|
|
401
|
+
if (holder !== void 0 && recorder !== void 0 && sessionId !== void 0) {
|
|
402
|
+
const fresh = esrHintLines(store, workspace, config, { recorder, sessionId });
|
|
403
|
+
for (const h of fresh) holder.actionables.set(h.kind, h.line);
|
|
404
|
+
if (holder.actionables.size > 0) {
|
|
405
|
+
const lines = [snapshot, "# this-session actionables (frozen)"];
|
|
406
|
+
for (const kind of ESR_HINT_ORDER) {
|
|
407
|
+
const line = holder.actionables.get(kind);
|
|
408
|
+
if (line !== void 0) lines.push(line);
|
|
409
|
+
}
|
|
410
|
+
return lines.join("\n");
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return snapshot;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Compose an `esr_status` response (the PULL surface): the deterministic ESR
|
|
418
|
+
* snapshot + revision, plus the derived decision-point hints. When the caller
|
|
419
|
+
* passes the current revision via `sinceRevision`, the state view collapses
|
|
420
|
+
* to a short "unchanged" line (pi-esr rule 3) while actionables still surface
|
|
421
|
+
* — they are the volatile part (balance / stale window) that motivates a pull.
|
|
422
|
+
*
|
|
423
|
+
* @param opts {{ recorder?, sessionId?, sinceRevision? }}
|
|
424
|
+
*/
|
|
425
|
+
export function buildEsrStatusView(store, workspace, config, opts = {}) {
|
|
426
|
+
const revision = store.esrFingerprint(workspace);
|
|
427
|
+
const hints = esrHintLines(store, workspace, config, opts);
|
|
428
|
+
const hintBlock = hints.length > 0 ? ["── actionables ──", ...hints.map((h) => h.line)].join("\n") : "";
|
|
429
|
+
const since = opts?.sinceRevision;
|
|
430
|
+
if (since !== void 0 && since !== null && since !== "" && since === revision) {
|
|
431
|
+
const lines = ["[ESR_STATUS]", `ESR state unchanged since revision ${since}.`, `ESR revision: ${revision}`];
|
|
432
|
+
if (hintBlock !== "") lines.push("", hintBlock);
|
|
433
|
+
return lines.join("\n");
|
|
434
|
+
}
|
|
435
|
+
const snapshot = buildEsrSnapshot(store, workspace, config, opts);
|
|
436
|
+
const lines = [snapshot, `ESR revision: ${revision}`];
|
|
437
|
+
if (hintBlock !== "") lines.push("", hintBlock);
|
|
183
438
|
return lines.join("\n");
|
|
184
439
|
}
|
|
185
440
|
|
|
186
441
|
function taskGapSuffix(t) {
|
|
187
442
|
if (t.state === "stable") return "";
|
|
188
|
-
const gaps =
|
|
189
|
-
if (t.artifact === null || t.artifact === "") gaps.push("artifact");
|
|
190
|
-
if (t.evaluation === null || t.evaluation === "") gaps.push("evaluation");
|
|
191
|
-
if (t.memoryRefs.length === 0) gaps.push("memory_ref");
|
|
443
|
+
const gaps = evidenceGaps(t);
|
|
192
444
|
return gaps.length === 0 ? " · READY to close" : ` · gap: ${gaps.join(", ")}`;
|
|
193
445
|
}
|
package/lib/index.js
CHANGED
|
@@ -20,9 +20,12 @@ import { resolve as resolvePath } from "node:path";
|
|
|
20
20
|
import { openEngramDomain } from "./store.js";
|
|
21
21
|
import { registerTools } from "./tools.js";
|
|
22
22
|
import { makeCaptureHandler } from "./capture.js";
|
|
23
|
-
import { renderIndex, renderEsr } from "./index-block.js";
|
|
23
|
+
import { renderIndex, renderEsr, esrMethodology, buildEsrSnapshot, buildFrozenEsrBlock } from "./index-block.js";
|
|
24
|
+
import { makeTriggerRecorder } from "./trigger.js";
|
|
24
25
|
import { installEngramSettings } from "./settings.js";
|
|
25
26
|
import { makeEngramRoutes } from "./api.js";
|
|
27
|
+
import { loadCompactionEngine, collectPointerContext } from "./context-gc.js";
|
|
28
|
+
import { workspaceKey as wk } from "./util.js";
|
|
26
29
|
|
|
27
30
|
/** Stable cordis plugin name. */
|
|
28
31
|
export const name = "engram";
|
|
@@ -56,6 +59,14 @@ const DEFAULTS = {
|
|
|
56
59
|
gcEnabled: true,
|
|
57
60
|
gcIntervalHours: 24,
|
|
58
61
|
gcStableRetentionDays: 120,
|
|
62
|
+
// Context GC — the auto-GC that REPLACES DSH's built-in lossy LLM-summary
|
|
63
|
+
// compaction (pi-esr CONTEXT_GC): mechanical eviction + re-fetch pointers,
|
|
64
|
+
// NOT the memory-panel GC above. `gcReplacesCompaction` off → the host keeps
|
|
65
|
+
// its default compaction backend; `gcNarrative` off → fully mechanical.
|
|
66
|
+
gcReplacesCompaction: true,
|
|
67
|
+
gcNarrative: true,
|
|
68
|
+
gcNarrativeMaxTokens: 1024,
|
|
69
|
+
gcNarrativeMaxChars: 4000,
|
|
59
70
|
// GUI access: hostnames allowed past the loopback fence for /api/dsh-engram
|
|
60
71
|
// (e.g. an operator-authorized cloudflare tunnel). Empty = loopback-only.
|
|
61
72
|
trustedHosts: [],
|
|
@@ -65,7 +76,18 @@ const DEFAULTS = {
|
|
|
65
76
|
verifyArtifact: true,
|
|
66
77
|
// prompt section order (before the persona/tools bands per DSH convention)
|
|
67
78
|
engramIndexOrder: 40,
|
|
79
|
+
esrMethodOrder: 40.5, // static ESR protocol section (byte-identical every turn)
|
|
68
80
|
esrOrder: 41,
|
|
81
|
+
// decision-point triggers (P0-A): promote a session plan to an ESR task
|
|
82
|
+
// when the pending-todo count is at least minTodosForPromote and exceeds
|
|
83
|
+
// active ESR tasks × todoPromoteRatio (default: pending > active).
|
|
84
|
+
minTodosForPromote: 2,
|
|
85
|
+
todoPromoteRatio: 1,
|
|
86
|
+
// P0-B: an error memory becomes a root-cause task candidate at this many
|
|
87
|
+
// repeat-failure hits.
|
|
88
|
+
minErrorHits: 2,
|
|
89
|
+
// P3: an active task with no update for this many days is surfaced as stale.
|
|
90
|
+
staleTaskDays: 14,
|
|
69
91
|
};
|
|
70
92
|
|
|
71
93
|
function resolveConfig(config) {
|
|
@@ -127,10 +149,15 @@ export function apply(ctx, config) {
|
|
|
127
149
|
return domainPromise;
|
|
128
150
|
};
|
|
129
151
|
|
|
152
|
+
// Decision-point triggers (P0-A + P1): live mem-vs-esr balance + the
|
|
153
|
+
// todo_write promote hook. Process-local; fed by a tools/result listener.
|
|
154
|
+
const recorder = makeTriggerRecorder(resolved);
|
|
155
|
+
|
|
130
156
|
const service = {
|
|
131
157
|
config: resolved,
|
|
132
158
|
getDomain,
|
|
133
159
|
openedDomain: () => opened,
|
|
160
|
+
recorder,
|
|
134
161
|
ensureDomain: () => {
|
|
135
162
|
getDomain().catch(() => {});
|
|
136
163
|
},
|
|
@@ -149,7 +176,7 @@ export function apply(ctx, config) {
|
|
|
149
176
|
return "";
|
|
150
177
|
}
|
|
151
178
|
},
|
|
152
|
-
/** Recompute the exact [ESR]
|
|
179
|
+
/** Recompute the exact [ESR] snapshot for a workspace (GUI inject preview). */
|
|
153
180
|
renderEsrBlock(workspace) {
|
|
154
181
|
if (opened === void 0) return "";
|
|
155
182
|
try {
|
|
@@ -204,8 +231,10 @@ export function apply(ctx, config) {
|
|
|
204
231
|
};
|
|
205
232
|
});
|
|
206
233
|
|
|
207
|
-
//
|
|
208
|
-
//
|
|
234
|
+
// [ENGRAM] block: render once, then reuse — prefix stability is the point
|
|
235
|
+
// (KV cache). New session (or resumed reload) object → fresh block. The
|
|
236
|
+
// [ESR] block below follows the same frozen-snapshot rule but additionally
|
|
237
|
+
// accumulates this-session actionables (see esrBlockFor).
|
|
209
238
|
const sessionCache = new WeakMap();
|
|
210
239
|
const blockFor = (agent) => {
|
|
211
240
|
if (agent === void 0) return "";
|
|
@@ -230,6 +259,54 @@ export function apply(ctx, config) {
|
|
|
230
259
|
return block;
|
|
231
260
|
};
|
|
232
261
|
|
|
262
|
+
// [ESR] block = FROZEN snapshot + this-session's monotonically accumulated
|
|
263
|
+
// actionables (promote/root-cause/close/stale/escalate).
|
|
264
|
+
// - The snapshot is rendered ONCE at the first assembly of the session and
|
|
265
|
+
// cached per session (pi-esr rule 2: injected once, never
|
|
266
|
+
// auto-refreshed) — tasks created/updated mid-session surface via
|
|
267
|
+
// `esr_status`, not by re-rendering this block.
|
|
268
|
+
// - An actionable is APPENDED exactly once, the moment it first matures
|
|
269
|
+
// (recorder-fed, one-shot via esrHintLines), then stays frozen — the
|
|
270
|
+
// prefix therefore changes only when genuinely new decision-point info
|
|
271
|
+
// exists, never per-turn.
|
|
272
|
+
const esrSnapshotCache = new WeakMap(); // session -> frozen snapshot text
|
|
273
|
+
const esrSessionHolders = new WeakMap(); // session -> { actionables: Map<kind,line> }
|
|
274
|
+
const esrBlockFor = (agent) => {
|
|
275
|
+
if (agent === void 0) return "";
|
|
276
|
+
const session = agent.session;
|
|
277
|
+
if (session === void 0) return "";
|
|
278
|
+
const cwd = session.header?.cwd;
|
|
279
|
+
if (cwd === void 0 || cwd.length === 0) return "";
|
|
280
|
+
if (opened === void 0) return "";
|
|
281
|
+
let snapshot = esrSnapshotCache.get(session);
|
|
282
|
+
if (snapshot === void 0) {
|
|
283
|
+
try {
|
|
284
|
+
snapshot = buildEsrSnapshot(opened, resolvePath(cwd), resolved, { sessionId: session.id });
|
|
285
|
+
} catch (error) {
|
|
286
|
+
log?.warn?.(`engram esr render failed: ${String(error)}`);
|
|
287
|
+
return "";
|
|
288
|
+
}
|
|
289
|
+
esrSnapshotCache.set(session, snapshot);
|
|
290
|
+
}
|
|
291
|
+
let holder = esrSessionHolders.get(session);
|
|
292
|
+
if (holder === void 0) {
|
|
293
|
+
holder = { actionables: new Map() };
|
|
294
|
+
esrSessionHolders.set(session, holder);
|
|
295
|
+
}
|
|
296
|
+
let block = "";
|
|
297
|
+
try {
|
|
298
|
+
block = buildFrozenEsrBlock(opened, resolvePath(cwd), resolved, {
|
|
299
|
+
recorder,
|
|
300
|
+
sessionId: session.id,
|
|
301
|
+
holder,
|
|
302
|
+
snapshot,
|
|
303
|
+
});
|
|
304
|
+
} catch (error) {
|
|
305
|
+
log?.warn?.(`engram esr render failed: ${String(error)}`);
|
|
306
|
+
}
|
|
307
|
+
return block;
|
|
308
|
+
};
|
|
309
|
+
|
|
233
310
|
ctx.effect(() => {
|
|
234
311
|
const disposers = [];
|
|
235
312
|
|
|
@@ -254,27 +331,34 @@ export function apply(ctx, config) {
|
|
|
254
331
|
});
|
|
255
332
|
disposers.push(() => indexDispose());
|
|
256
333
|
|
|
334
|
+
// Static ESR protocol (pi-esr rule 1): byte-identical every turn, so
|
|
335
|
+
// the model never depends on runtime nudges to know WHEN to use ESR.
|
|
336
|
+
const esrMethodDispose = systemPrompt.section({
|
|
337
|
+
name: "engram:esr-method",
|
|
338
|
+
order: resolved.esrMethodOrder,
|
|
339
|
+
text: () => esrMethodology(),
|
|
340
|
+
});
|
|
341
|
+
disposers.push(() => esrMethodDispose());
|
|
342
|
+
|
|
257
343
|
const esrDispose = systemPrompt.section({
|
|
258
344
|
name: "engram:esr",
|
|
259
345
|
order: resolved.esrOrder,
|
|
260
346
|
text: (assemblyContext) => {
|
|
261
347
|
const agent = assemblyContext?.agent;
|
|
262
348
|
if (agent === void 0) return "";
|
|
263
|
-
|
|
264
|
-
if (session === void 0) return "";
|
|
265
|
-
const cwd = session.header?.cwd;
|
|
266
|
-
if (cwd === void 0 || cwd.length === 0) return "";
|
|
267
|
-
if (opened === void 0) return "";
|
|
268
|
-
try {
|
|
269
|
-
return renderEsr(opened, resolvePath(cwd), resolved);
|
|
270
|
-
} catch {
|
|
271
|
-
return "";
|
|
272
|
-
}
|
|
349
|
+
return esrBlockFor(agent);
|
|
273
350
|
},
|
|
274
351
|
});
|
|
275
352
|
disposers.push(() => esrDispose());
|
|
276
353
|
}
|
|
277
354
|
|
|
355
|
+
// decision-point triggers: always on (independent of autoCapture) —
|
|
356
|
+
// feeds the live mem-vs-esr balance and the todo_write promote hint.
|
|
357
|
+
const disposeTrigger = ctx.on("tools/result", (exec, result) => {
|
|
358
|
+
recorder.handle(exec, result);
|
|
359
|
+
});
|
|
360
|
+
disposers.push(disposeTrigger);
|
|
361
|
+
|
|
278
362
|
// zero-LLM auto-capture
|
|
279
363
|
if (resolved.autoCapture) {
|
|
280
364
|
// The capture handler needs a storeMemory face, but the domain opens
|
|
@@ -322,6 +406,8 @@ export function apply(ctx, config) {
|
|
|
322
406
|
}
|
|
323
407
|
disposers.push(gcDispose);
|
|
324
408
|
|
|
409
|
+
|
|
410
|
+
|
|
325
411
|
return async () => {
|
|
326
412
|
for (const dispose of disposers) {
|
|
327
413
|
try {
|
package/lib/settings.js
CHANGED
|
@@ -32,6 +32,8 @@ export const SETTINGS_KEYS = [
|
|
|
32
32
|
"maxMemoriesPerWorkspace",
|
|
33
33
|
"gcEnabled",
|
|
34
34
|
"gcStableRetentionDays",
|
|
35
|
+
"gcReplacesCompaction",
|
|
36
|
+
"gcNarrative",
|
|
35
37
|
"trustedHosts",
|
|
36
38
|
];
|
|
37
39
|
|
|
@@ -59,6 +61,8 @@ export function makeSettingsSchema(defaults) {
|
|
|
59
61
|
maxMemoriesPerWorkspace: z.natural().max(10000).default(defaults.maxMemoriesPerWorkspace),
|
|
60
62
|
gcEnabled: z.boolean().default(defaults.gcEnabled),
|
|
61
63
|
gcStableRetentionDays: z.natural().max(3650).default(defaults.gcStableRetentionDays),
|
|
64
|
+
gcReplacesCompaction: z.boolean().default(defaults.gcReplacesCompaction),
|
|
65
|
+
gcNarrative: z.boolean().default(defaults.gcNarrative),
|
|
62
66
|
verifyArtifact: z.boolean().default(defaults.verifyArtifact).description("ESR 闭环时校验 artifact 路径真实存在"),
|
|
63
67
|
// Security knob, not rendered in the card: hostnames allowed past the
|
|
64
68
|
// loopback fence for /api/dsh-engram (operator-authorized tunnel hosts).
|
package/lib/store.js
CHANGED
|
@@ -490,6 +490,21 @@ export async function openEngramDomain(facility) {
|
|
|
490
490
|
.filter((t) => t.workspace === workspace && notArchivedTask(t) && (includeStable || t.state !== "stable"))
|
|
491
491
|
.sort(byRecency);
|
|
492
492
|
},
|
|
493
|
+
/**
|
|
494
|
+
* Deterministic fingerprint of the ESR task surface (what `esr_status` /
|
|
495
|
+
* the [ESR] snapshot render): id, state, evidence fields, assignee — NOT
|
|
496
|
+
* timestamps (pi-esr rule 2: timestamps excluded from context). Used as
|
|
497
|
+
* the `revision` token for incremental pulls: same tasks → same revision
|
|
498
|
+
* → "unchanged" short response.
|
|
499
|
+
*/
|
|
500
|
+
esrFingerprint(workspace) {
|
|
501
|
+
const parts = [...tasks.entries()]
|
|
502
|
+
.map(([, t]) => t)
|
|
503
|
+
.filter((t) => t.workspace === workspace && notArchivedTask(t))
|
|
504
|
+
.map((t) => `${t.id}|${t.state}|${t.artifact ?? ""}|${t.evaluation ?? ""}|${(t.memoryRefs ?? []).join(",")}|${t.assignee ?? ""}`)
|
|
505
|
+
.sort();
|
|
506
|
+
return hashText(`esr:${parts.join("\n")}`);
|
|
507
|
+
},
|
|
493
508
|
async putTask(task) {
|
|
494
509
|
if ([...task.name].length > 200) throw new EngramError("ENGRAM_CAP_EXCEEDED", "task name is over 200 characters");
|
|
495
510
|
if (task.id !== task.id) throw new EngramError("ENGRAM_INVALID_ARGS", "task id mismatch");
|
|
@@ -776,6 +791,22 @@ export async function openEngramDomain(facility) {
|
|
|
776
791
|
addLink(link) {
|
|
777
792
|
return links.put(link.id, link);
|
|
778
793
|
},
|
|
794
|
+
/** Idempotent variant: skip when the same (source, relation, target) edge
|
|
795
|
+
* already exists in the workspace — used by auto-wiring paths so repeated
|
|
796
|
+
* calls never duplicate a relation. */
|
|
797
|
+
async addLinkOnce(link) {
|
|
798
|
+
const dup = [...links.entries()]
|
|
799
|
+
.map(([, l]) => l)
|
|
800
|
+
.some(
|
|
801
|
+
(l) =>
|
|
802
|
+
l.workspace === link.workspace &&
|
|
803
|
+
l.source === link.source &&
|
|
804
|
+
l.target === link.target &&
|
|
805
|
+
(l.relation ?? "") === (link.relation ?? ""),
|
|
806
|
+
);
|
|
807
|
+
if (!dup) await links.put(link.id, link);
|
|
808
|
+
return !dup;
|
|
809
|
+
},
|
|
779
810
|
linksFor(workspace, entity) {
|
|
780
811
|
return [...links.entries()]
|
|
781
812
|
.map(([, l]) => l)
|