dsh-engram 0.3.2 → 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 +107 -20
- package/README.zh.md +79 -13
- package/cordis.patch.yml +12 -0
- package/lib/api.js +24 -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 +169 -39
- package/lib/index.js +78 -18
- package/lib/settings.js +4 -0
- package/lib/store.js +15 -0
- package/lib/tools.js +29 -0
- package/package.json +15 -2
package/lib/index-block.js
CHANGED
|
@@ -207,7 +207,14 @@ function taskNextRank(t) {
|
|
|
207
207
|
return 4;
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
-
/**
|
|
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
|
+
*/
|
|
211
218
|
export function sortActiveForNext(tasks) {
|
|
212
219
|
return [...tasks].sort((a, b) => {
|
|
213
220
|
const ra = taskNextRank(a);
|
|
@@ -216,7 +223,7 @@ export function sortActiveForNext(tasks) {
|
|
|
216
223
|
const ga = evidenceGaps(a).length;
|
|
217
224
|
const gb = evidenceGaps(b).length;
|
|
218
225
|
if (ga !== gb) return ga - gb;
|
|
219
|
-
return
|
|
226
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
220
227
|
});
|
|
221
228
|
}
|
|
222
229
|
|
|
@@ -249,50 +256,50 @@ export function selectStaleCandidates(tasks, config) {
|
|
|
249
256
|
}
|
|
250
257
|
|
|
251
258
|
/**
|
|
252
|
-
*
|
|
253
|
-
* at
|
|
254
|
-
*
|
|
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.
|
|
255
277
|
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
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.
|
|
260
285
|
*/
|
|
261
|
-
export function
|
|
262
|
-
const recorder = opts?.recorder;
|
|
263
|
-
const sessionId = opts?.sessionId;
|
|
264
|
-
const live = recorder !== void 0 && sessionId !== void 0;
|
|
286
|
+
export function buildEsrSnapshot(store, workspace, config, opts = {}) {
|
|
265
287
|
const active = store.listTasks(workspace, { includeStable: false });
|
|
266
288
|
const stable = store.listTasks(workspace, { includeStable: true }).filter((t) => t.state === "stable");
|
|
289
|
+
const draft = active.filter((t) => t.state === "draft").length;
|
|
267
290
|
const ordered = sortActiveForNext(active);
|
|
268
|
-
// Prefer the live in-memory balance (recorder); fall back to the legacy
|
|
269
|
-
// usage-table view so store-driven callers/tests keep working unchanged.
|
|
270
|
-
const balance = recorder !== void 0 ? recorder.recentBalance(workspace) : usageBalanceView(store, workspace);
|
|
271
|
-
const hint = esrBalanceHint(balance);
|
|
272
|
-
const promote = live ? recorder.promoteHint(sessionId, workspace, active.length) : "";
|
|
273
|
-
const rootCause = live ? recorder.errorHint(sessionId, workspace, selectRootCauseCandidates(store, workspace, config)) : "";
|
|
274
|
-
const closure = live ? recorder.closureHint(sessionId, workspace, ordered.filter((t) => evidenceGaps(t).length === 0)) : "";
|
|
275
|
-
const stale = live ? recorder.staleHint(sessionId, workspace, selectStaleCandidates(active, config)) : "";
|
|
276
|
-
// P4: count only what is actually injected for a live session (never the
|
|
277
|
-
// GUI preview, which renders the same lines without a sessionId).
|
|
278
|
-
if (live) {
|
|
279
|
-
if (promote !== "") recorder.emitHint("promote", workspace, sessionId);
|
|
280
|
-
if (rootCause !== "") recorder.emitHint("rootcause", workspace, sessionId);
|
|
281
|
-
if (closure !== "") recorder.emitHint("close", workspace, sessionId);
|
|
282
|
-
if (stale !== "") recorder.emitHint("stale", workspace, sessionId);
|
|
283
|
-
if (hint !== "") recorder.emitHint("escalate", workspace, sessionId);
|
|
284
|
-
}
|
|
285
291
|
|
|
286
292
|
if (active.length === 0 && stable.length === 0) {
|
|
287
293
|
// Keep the mechanism visible even when idle: one compact line teaches the
|
|
288
294
|
// model that multi-step work earns an esr_task closed with real evidence.
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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");
|
|
292
299
|
}
|
|
293
300
|
|
|
294
301
|
const lines = [];
|
|
295
|
-
lines.push(`[ESR] tasks: ${
|
|
302
|
+
lines.push(`[ESR] tasks: ${ordered.filter((t) => t.state !== "draft").length} active / ${stable.length} stable${draft > 0 ? ` / ${draft} draft` : ""}`);
|
|
296
303
|
const firstReady = ordered.find((t) => evidenceGaps(t).length === 0);
|
|
297
304
|
if (firstReady !== void 0) {
|
|
298
305
|
lines.push(`next: esr_close ${firstReady.id.slice(0, 6)} — ${escapeLt(truncate(firstReady.name, 40))} (all evidence present)`);
|
|
@@ -300,11 +307,134 @@ export function renderEsr(store, workspace, config, opts = {}) {
|
|
|
300
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)}`);
|
|
301
308
|
if (active.length > 8) lines.push(`- … ${active.length - 8} more`);
|
|
302
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}` : ""}`);
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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);
|
|
308
438
|
return lines.join("\n");
|
|
309
439
|
}
|
|
310
440
|
|
package/lib/index.js
CHANGED
|
@@ -20,10 +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
24
|
import { makeTriggerRecorder } from "./trigger.js";
|
|
25
25
|
import { installEngramSettings } from "./settings.js";
|
|
26
26
|
import { makeEngramRoutes } from "./api.js";
|
|
27
|
+
import { loadCompactionEngine, collectPointerContext } from "./context-gc.js";
|
|
28
|
+
import { workspaceKey as wk } from "./util.js";
|
|
27
29
|
|
|
28
30
|
/** Stable cordis plugin name. */
|
|
29
31
|
export const name = "engram";
|
|
@@ -57,6 +59,14 @@ const DEFAULTS = {
|
|
|
57
59
|
gcEnabled: true,
|
|
58
60
|
gcIntervalHours: 24,
|
|
59
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,
|
|
60
70
|
// GUI access: hostnames allowed past the loopback fence for /api/dsh-engram
|
|
61
71
|
// (e.g. an operator-authorized cloudflare tunnel). Empty = loopback-only.
|
|
62
72
|
trustedHosts: [],
|
|
@@ -66,6 +76,7 @@ const DEFAULTS = {
|
|
|
66
76
|
verifyArtifact: true,
|
|
67
77
|
// prompt section order (before the persona/tools bands per DSH convention)
|
|
68
78
|
engramIndexOrder: 40,
|
|
79
|
+
esrMethodOrder: 40.5, // static ESR protocol section (byte-identical every turn)
|
|
69
80
|
esrOrder: 41,
|
|
70
81
|
// decision-point triggers (P0-A): promote a session plan to an ESR task
|
|
71
82
|
// when the pending-todo count is at least minTodosForPromote and exceeds
|
|
@@ -165,11 +176,11 @@ export function apply(ctx, config) {
|
|
|
165
176
|
return "";
|
|
166
177
|
}
|
|
167
178
|
},
|
|
168
|
-
/** Recompute the exact [ESR]
|
|
179
|
+
/** Recompute the exact [ESR] snapshot for a workspace (GUI inject preview). */
|
|
169
180
|
renderEsrBlock(workspace) {
|
|
170
181
|
if (opened === void 0) return "";
|
|
171
182
|
try {
|
|
172
|
-
return renderEsr(opened, workspace, resolved
|
|
183
|
+
return renderEsr(opened, workspace, resolved);
|
|
173
184
|
} catch {
|
|
174
185
|
return "";
|
|
175
186
|
}
|
|
@@ -220,8 +231,10 @@ export function apply(ctx, config) {
|
|
|
220
231
|
};
|
|
221
232
|
});
|
|
222
233
|
|
|
223
|
-
//
|
|
224
|
-
//
|
|
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).
|
|
225
238
|
const sessionCache = new WeakMap();
|
|
226
239
|
const blockFor = (agent) => {
|
|
227
240
|
if (agent === void 0) return "";
|
|
@@ -246,6 +259,54 @@ export function apply(ctx, config) {
|
|
|
246
259
|
return block;
|
|
247
260
|
};
|
|
248
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
|
+
|
|
249
310
|
ctx.effect(() => {
|
|
250
311
|
const disposers = [];
|
|
251
312
|
|
|
@@ -270,25 +331,22 @@ export function apply(ctx, config) {
|
|
|
270
331
|
});
|
|
271
332
|
disposers.push(() => indexDispose());
|
|
272
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
|
+
|
|
273
343
|
const esrDispose = systemPrompt.section({
|
|
274
344
|
name: "engram:esr",
|
|
275
345
|
order: resolved.esrOrder,
|
|
276
346
|
text: (assemblyContext) => {
|
|
277
347
|
const agent = assemblyContext?.agent;
|
|
278
348
|
if (agent === void 0) return "";
|
|
279
|
-
|
|
280
|
-
if (session === void 0) return "";
|
|
281
|
-
const cwd = session.header?.cwd;
|
|
282
|
-
if (cwd === void 0 || cwd.length === 0) return "";
|
|
283
|
-
if (opened === void 0) return "";
|
|
284
|
-
try {
|
|
285
|
-
return renderEsr(opened, resolvePath(cwd), resolved, {
|
|
286
|
-
recorder,
|
|
287
|
-
sessionId: session.id,
|
|
288
|
-
});
|
|
289
|
-
} catch {
|
|
290
|
-
return "";
|
|
291
|
-
}
|
|
349
|
+
return esrBlockFor(agent);
|
|
292
350
|
},
|
|
293
351
|
});
|
|
294
352
|
disposers.push(() => esrDispose());
|
|
@@ -348,6 +406,8 @@ export function apply(ctx, config) {
|
|
|
348
406
|
}
|
|
349
407
|
disposers.push(gcDispose);
|
|
350
408
|
|
|
409
|
+
|
|
410
|
+
|
|
351
411
|
return async () => {
|
|
352
412
|
for (const dispose of disposers) {
|
|
353
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");
|
package/lib/tools.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* tool schemas cost tokens in every request):
|
|
6
6
|
* engram_store / engram_recall / engram_detail — the engram memory surface
|
|
7
7
|
* esr_task / esr_node / esr_close / esr_link — the ESR-lite evidence surface
|
|
8
|
+
* esr_ready / esr_status — the pull surface (live state + hints)
|
|
8
9
|
* esr_gc — pi-esr memory garbage collection
|
|
9
10
|
*
|
|
10
11
|
* Every call is recorded into the per-(workspace, day) usage rollup so the
|
|
@@ -16,6 +17,7 @@
|
|
|
16
17
|
|
|
17
18
|
import { z } from "zod";
|
|
18
19
|
import { EngramError, hasCode } from "./store.js";
|
|
20
|
+
import { buildEsrStatusView } from "./index-block.js";
|
|
19
21
|
import { truncate, shortId, slugId, fmtDate, escapeLt, workspaceKey as wk, uuid } from "./util.js";
|
|
20
22
|
|
|
21
23
|
const KINDS = [
|
|
@@ -510,6 +512,33 @@ ${hint}` : ""}`;
|
|
|
510
512
|
},
|
|
511
513
|
}),
|
|
512
514
|
|
|
515
|
+
// ── esr_status ──────────────────────────────────────────────────
|
|
516
|
+
registerTool({
|
|
517
|
+
name: "esr_status",
|
|
518
|
+
description:
|
|
519
|
+
"Pull the live ESR state (deterministic snapshot + revision) plus decision-point hints (promote / root-cause / close / stale / escalate). Pass since_revision to skip unchanged state — returns a short response when nothing changed. The [ESR] block in the prompt is a session-start snapshot that never auto-refreshes; call this for the current truth.",
|
|
520
|
+
parameters: {
|
|
521
|
+
type: "object",
|
|
522
|
+
properties: {
|
|
523
|
+
workspace: { type: "string", description: "Override the workspace key (default: current session cwd)" },
|
|
524
|
+
since_revision: { type: "string", description: "Last-seen ESR revision (from a previous esr_status response); unchanged state returns a short response" },
|
|
525
|
+
},
|
|
526
|
+
},
|
|
527
|
+
output: TEXT_OUTPUT,
|
|
528
|
+
timeoutMs: 20000,
|
|
529
|
+
execute: async (raw, exec) => {
|
|
530
|
+
const args = parse(z.object({ workspace: z.string().optional(), since_revision: z.string().optional() }), raw, "esr_status");
|
|
531
|
+
const caller = callerOf(exec);
|
|
532
|
+
const domain = await requireDomain(service);
|
|
533
|
+
const ws = wk(args.workspace ?? caller.cwd);
|
|
534
|
+
return buildEsrStatusView(domain, ws, service.config, {
|
|
535
|
+
recorder: service.recorder,
|
|
536
|
+
sessionId: caller.sessionId,
|
|
537
|
+
sinceRevision: args.since_revision,
|
|
538
|
+
});
|
|
539
|
+
},
|
|
540
|
+
}),
|
|
541
|
+
|
|
513
542
|
// ── esr_node ─────────────────────────────────────
|
|
514
543
|
registerTool({
|
|
515
544
|
name: "esr_node",
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-engram",
|
|
3
3
|
"description": "Minimalist long-term memory for DeepSeek Harness, distilling symbolic-index + pi-esr ideas: zero-LLM auto-capture, a symbolic [ENGRAM] index with progressive disclosure, and an ESR-lite evidence-closure protocol (esr_task / esr_node / esr_close / esr_link). Real agent-behaviour telemetry (usage rollup + /stats) and a deterministic offline recall benchmark (npm run eval). Hot path is model-free; storage on ctx.storageDomain; web memory viewer, observability panel and config card through DSH's native settings slots.",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.3",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"main": "lib/index.js",
|
|
16
16
|
"exports": {
|
|
17
17
|
".": "./lib/index.js",
|
|
18
|
+
"./compaction": "./lib/compaction.js",
|
|
18
19
|
"./client": "./lib/client.js",
|
|
19
20
|
"./package.json": "./package.json",
|
|
20
21
|
"./cordis.patch.yml": "./cordis.patch.yml"
|
|
@@ -60,7 +61,10 @@
|
|
|
60
61
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
61
62
|
"@deepseek-ai/dsh-storage-domain": "^0.1.0-rc.7",
|
|
62
63
|
"@deepseek-ai/dsh-settings": "^0.1.0-rc.7",
|
|
63
|
-
"@deepseek-ai/schemastery": "^3.18.1"
|
|
64
|
+
"@deepseek-ai/schemastery": "^3.18.1",
|
|
65
|
+
"@deepseek-ai/dsh-compaction": "^0.1.0-rc.7",
|
|
66
|
+
"@deepseek-ai/dsh-compaction-basic": "^0.1.0-rc.7",
|
|
67
|
+
"@deepseek-ai/dsh-llm": "^0.1.0-rc.7"
|
|
64
68
|
},
|
|
65
69
|
"peerDependenciesMeta": {
|
|
66
70
|
"@deepseek-ai/cordis": {
|
|
@@ -72,6 +76,15 @@
|
|
|
72
76
|
"@deepseek-ai/dsh-settings": {
|
|
73
77
|
"optional": true
|
|
74
78
|
},
|
|
79
|
+
"@deepseek-ai/dsh-compaction": {
|
|
80
|
+
"optional": true
|
|
81
|
+
},
|
|
82
|
+
"@deepseek-ai/dsh-compaction-basic": {
|
|
83
|
+
"optional": true
|
|
84
|
+
},
|
|
85
|
+
"@deepseek-ai/dsh-llm": {
|
|
86
|
+
"optional": true
|
|
87
|
+
},
|
|
75
88
|
"@deepseek-ai/schemastery": {
|
|
76
89
|
"optional": true
|
|
77
90
|
}
|