dsh-engram 0.7.4 → 0.8.7
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 +100 -6
- package/README.zh.md +7 -2
- package/docs/CONTEXT-GC-GUIDE.zh.md +15 -1
- package/docs/G6-quality-ab.zh.md +540 -0
- package/docs/GATES.zh.md +41 -0
- package/docs/PROPOSAL-evolution.zh.md +12 -0
- package/docs/RELEASE-NOTES-v0.7.10.md +49 -0
- package/docs/RELEASE-NOTES-v0.7.11.md +31 -0
- package/docs/RELEASE-NOTES-v0.7.12.md +22 -0
- package/docs/RELEASE-NOTES-v0.7.13.md +41 -0
- package/docs/RELEASE-NOTES-v0.7.14.md +37 -0
- package/docs/RELEASE-NOTES-v0.7.15.md +36 -0
- package/docs/RELEASE-NOTES-v0.7.16.md +42 -0
- package/docs/RELEASE-NOTES-v0.7.5.md +134 -0
- package/docs/RELEASE-NOTES-v0.7.6.md +74 -0
- package/docs/RELEASE-NOTES-v0.7.7.md +60 -0
- package/docs/RELEASE-NOTES-v0.7.8.md +56 -0
- package/docs/RELEASE-NOTES-v0.7.9.md +46 -0
- package/docs/RELEASE-NOTES-v0.8.0.md +59 -0
- package/docs/RELEASE-NOTES-v0.8.1.md +70 -0
- package/docs/RELEASE-NOTES-v0.8.2.md +62 -0
- package/docs/RELEASE-NOTES-v0.8.3.md +65 -0
- package/docs/RELEASE-NOTES-v0.8.4.md +56 -0
- package/docs/RELEASE-NOTES-v0.8.5.md +60 -0
- package/docs/RELEASE-NOTES-v0.8.6.md +71 -0
- package/docs/RELEASE-NOTES-v0.8.7.md +69 -0
- package/eval/gate-audit.mjs +538 -0
- package/lib/api.js +70 -0
- package/lib/approvals.js +84 -0
- package/lib/boundary-prune.js +71 -5
- package/lib/capsule.js +134 -0
- package/lib/capture.js +83 -5
- package/lib/client.js +66 -0
- package/lib/compaction.js +4 -11
- package/lib/context-gc.js +63 -2
- package/lib/index-block.js +79 -4
- package/lib/index.js +105 -18
- package/lib/ledger.js +200 -10
- package/lib/pack-telemetry.js +10 -3
- package/lib/prune.js +89 -0
- package/lib/push-legs.js +121 -0
- package/lib/session-events.js +44 -2
- package/lib/skill-export.js +289 -0
- package/lib/status-insights.js +5 -0
- package/lib/status.js +138 -0
- package/lib/store.js +34 -0
- package/lib/usage.js +32 -3
- package/lib/web-provision.js +59 -14
- package/package.json +15 -8
- package/scripts/dsh-engram.mjs +247 -5
package/lib/context-gc.js
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
|
|
34
34
|
import { basename } from "node:path";
|
|
35
35
|
import { slugId } from "./util.js";
|
|
36
|
+
import { recordGcAttempt } from "./status.js";
|
|
36
37
|
|
|
37
38
|
/** Memory ids are uuids; the [ENGRAM] index shows `#` + first 8 alnum chars. */
|
|
38
39
|
const MEMORY_ID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
|
|
@@ -608,6 +609,43 @@ export async function summarizeUnprovenanced(messages, { enabled = true, maxChar
|
|
|
608
609
|
* Build the Context GC replacement summary (pointer block + narrative). This
|
|
609
610
|
* is the text that lands inside DSH's `<compacted-summary>` checkpoint node.
|
|
610
611
|
*/
|
|
612
|
+
/** The line every Context GC checkpoint carries — proof of authorship when a
|
|
613
|
+
* committed summary is read back from the session log. */
|
|
614
|
+
export const CONTEXT_GC_HEADING = "## Context GC — evicted detail is RE-FETCHABLE (do not re-derive)";
|
|
615
|
+
export const CONTEXT_GC_FOOTER = "_Context GC: detail above is re-fetchable via the listed tools. Prefer re-fetching over re-deriving._";
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Turn a committed `compaction/summary` event into a Context Ledger credit, or
|
|
619
|
+
* `null` when engram did not author it.
|
|
620
|
+
*
|
|
621
|
+
* The credit has to come from the COMMITTED record, not from inside
|
|
622
|
+
* `summarize()`: the harness may reject a checkpoint (framed ≥ shadowed) or fall
|
|
623
|
+
* back to its own summarizer, and a saving that never reached the session is not
|
|
624
|
+
* a saving. `shadowedTokenCount` is the span that stopped replaying, the summary
|
|
625
|
+
* text is what replaced it.
|
|
626
|
+
*/
|
|
627
|
+
export function committedGcSaving(event, charsPerToken = 4) {
|
|
628
|
+
if (event?.type !== "compaction/summary") return null;
|
|
629
|
+
// DSH publishes committed events as { type, seq, time, data: { summary,
|
|
630
|
+
// shadowedTokenCount, … } }. Reading the payload off the event root made this
|
|
631
|
+
// matcher return null for EVERY real checkpoint — 82 GC-authored summaries on
|
|
632
|
+
// this machine, none credited — while the unit test kept passing because it
|
|
633
|
+
// built a flat synthetic event. Accept both shapes; prefer the real one.
|
|
634
|
+
const payload = event?.data !== null && typeof event?.data === "object" ? event.data : event;
|
|
635
|
+
const text = Array.isArray(payload?.summary)
|
|
636
|
+
? payload.summary.map((part) => (typeof part?.text === "string" ? part.text : "")).join("")
|
|
637
|
+
: "";
|
|
638
|
+
// Either end identifies the checkpoint: the heading survives a trim (it is
|
|
639
|
+
// the head), the footer is the cheaper full-text check. A checkpoint trimmed
|
|
640
|
+
// to fit keeps the heading but may lose the footer, and it still committed.
|
|
641
|
+
const authored = text.includes(CONTEXT_GC_FOOTER) || [...text].slice(0, 200).join("").includes(CONTEXT_GC_HEADING);
|
|
642
|
+
if (!authored) return null;
|
|
643
|
+
const shadowed = Number(payload?.shadowedTokenCount ?? event?.shadowedTokenCount);
|
|
644
|
+
if (!Number.isFinite(shadowed) || shadowed <= 0) return null;
|
|
645
|
+
const charsSaved = shadowed * charsPerToken - [...text].length;
|
|
646
|
+
return charsSaved > 0 ? { charsSaved, shadowedTokens: shadowed, summaryChars: [...text].length } : null;
|
|
647
|
+
}
|
|
648
|
+
|
|
611
649
|
export function buildContextGcSummary(report, context, { turns, narrative }) {
|
|
612
650
|
const label = context?.label ?? "this workspace";
|
|
613
651
|
const pointer = buildPointerSummary(report, context, { turns, label });
|
|
@@ -617,7 +655,7 @@ export function buildContextGcSummary(report, context, { turns, narrative }) {
|
|
|
617
655
|
narrative.text,
|
|
618
656
|
"",
|
|
619
657
|
"---",
|
|
620
|
-
|
|
658
|
+
CONTEXT_GC_FOOTER,
|
|
621
659
|
];
|
|
622
660
|
return parts.join("\n");
|
|
623
661
|
}
|
|
@@ -732,15 +770,37 @@ export async function loadContextGcEngine(ctx, config = {}, deps = {}) {
|
|
|
732
770
|
constructor() {
|
|
733
771
|
super(ctx, engineConfig);
|
|
734
772
|
this.gcDeps = deps;
|
|
773
|
+
/** Last credit computed by `_contextGc`, surfaced in the attempt log. */
|
|
774
|
+
this.gcLastCredit = void 0;
|
|
735
775
|
}
|
|
736
776
|
|
|
737
777
|
/** The one seam we take over: produce the checkpoint summary. */
|
|
738
778
|
async summarize(input, agent, signal) {
|
|
779
|
+
const startedAt = Date.now();
|
|
780
|
+
const workspace = agent?.session?.header?.cwd;
|
|
739
781
|
try {
|
|
740
|
-
|
|
782
|
+
const result = await this._contextGc(input, agent, signal);
|
|
783
|
+
// Evidence, not vibes: the ledger's credit side only moves when THIS
|
|
784
|
+
// path runs, so every attempt is recorded where it can be inspected
|
|
785
|
+
// offline (a silent fallback used to look identical to a working GC).
|
|
786
|
+
// The head + length identify the checkpoint: if the committed
|
|
787
|
+
// `compaction/summary` in the session log does not start like this, the
|
|
788
|
+
// harness used someone else's summary and nothing was saved.
|
|
789
|
+
const text = Array.isArray(result?.summary) ? result.summary.map((b) => (typeof b?.text === "string" ? b.text : "")).join("") : "";
|
|
790
|
+
await recordGcAttempt({
|
|
791
|
+
plane: "compaction",
|
|
792
|
+
ok: true,
|
|
793
|
+
ms: Date.now() - startedAt,
|
|
794
|
+
workspace,
|
|
795
|
+
charsSaved: this.gcLastCredit?.charsSaved ?? 0,
|
|
796
|
+
summaryChars: [...text].length,
|
|
797
|
+
head: [...text].slice(0, 60).join(""),
|
|
798
|
+
});
|
|
799
|
+
return result;
|
|
741
800
|
} catch (error) {
|
|
742
801
|
const logger = this.ctx?.logger;
|
|
743
802
|
const message = error instanceof Error ? error.message : String(error);
|
|
803
|
+
await recordGcAttempt({ plane: "compaction", ok: false, ms: Date.now() - startedAt, workspace, error: message });
|
|
744
804
|
if (typeof logger?.warn === "function") logger.warn(`engram context-gc failed (${message}); falling back to default compaction`);
|
|
745
805
|
if (typeof this.gcDeps.defaultSummarize === "function") {
|
|
746
806
|
return this.gcDeps.defaultSummarize(input, agent, signal);
|
|
@@ -804,6 +864,7 @@ export async function loadContextGcEngine(ctx, config = {}, deps = {}) {
|
|
|
804
864
|
const summaryChars = [...summaryText].length;
|
|
805
865
|
const charsSaved = spanChars - summaryChars;
|
|
806
866
|
if (charsSaved > 0) {
|
|
867
|
+
this.gcLastCredit = { charsSaved, spanChars, summaryChars };
|
|
807
868
|
this.gcDeps.onCompacted({ session: agent?.session, workspace: ws, charsSaved, spanChars, summaryChars });
|
|
808
869
|
}
|
|
809
870
|
}
|
package/lib/index-block.js
CHANGED
|
@@ -13,6 +13,7 @@ import { label, shortId, fmtDate, escapeLt, truncate, byRecency } from "./util.j
|
|
|
13
13
|
import { buildEntityPage, renderEntityCard } from "./entity-page.js";
|
|
14
14
|
import { memoryIsStale } from "./anchor.js";
|
|
15
15
|
import { readSessionEvents } from "./session-events.js";
|
|
16
|
+
import fs from "node:fs";
|
|
16
17
|
|
|
17
18
|
/** Deterministic index line prefix per memory kind. */
|
|
18
19
|
export function prefixFor(kind) {
|
|
@@ -42,9 +43,77 @@ function isGitPlumbingEcho(m) {
|
|
|
42
43
|
return /(^|\s)(cd\s|\&\&|;|\|)/.test(text);
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Has the file this memory is anchored to changed since the memory was written?
|
|
48
|
+
*
|
|
49
|
+
* The reverse G6 experiment (docs/G6-quality-ab.zh.md §9) is why this exists:
|
|
50
|
+
* with a stale convention in memory and a contradicting docstring in the code,
|
|
51
|
+
* the agent followed memory on 1 of 6 tasks because nothing marked the memory as
|
|
52
|
+
* old. A memory that names a file can carry that check for free — one `stat`,
|
|
53
|
+
* and the injected line can say "the file moved on".
|
|
54
|
+
*
|
|
55
|
+
* Returns "" when there is nothing to say (no anchor, file unchanged, or the
|
|
56
|
+
* stat fails); a short marker otherwise. Never throws: injection must not break
|
|
57
|
+
* because a path is unreadable.
|
|
58
|
+
*/
|
|
59
|
+
export function fileDrift(m, nowMs = Date.now(), stat = void 0) {
|
|
60
|
+
const file = typeof m?.filePath === "string" ? m.filePath : "";
|
|
61
|
+
if (file.length === 0) return "";
|
|
62
|
+
const written = m?.createdAt ?? m?.updatedAt;
|
|
63
|
+
if (written === null || written === void 0) return "";
|
|
64
|
+
const writtenMs = typeof written === "number" ? written : Date.parse(String(written));
|
|
65
|
+
if (!Number.isFinite(writtenMs) || writtenMs > nowMs) return "";
|
|
66
|
+
const doStat = stat ?? ((target) => fs.statSync(target));
|
|
67
|
+
let statInfo;
|
|
68
|
+
try {
|
|
69
|
+
statInfo = doStat(file);
|
|
70
|
+
} catch {
|
|
71
|
+
return "⚠file-missing";
|
|
72
|
+
}
|
|
73
|
+
const mtimeMs = statInfo?.mtimeMs ?? 0;
|
|
74
|
+
if (!Number.isFinite(mtimeMs) || mtimeMs <= writtenMs) return "";
|
|
75
|
+
// Only a coarse signal: "the anchored file was edited after this was written".
|
|
76
|
+
// The model can then check the file instead of trusting the note.
|
|
77
|
+
return "⚠file-changed";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* v0.8-A promotion rules for procedures.
|
|
82
|
+
*
|
|
83
|
+
* A procedure earns an index line only when it is still true:
|
|
84
|
+
* - verified at least `procedureMinEvidence` (3) times,
|
|
85
|
+
* - verified within `procedureMaxAgeDays` (14),
|
|
86
|
+
* - and not failed since its last verification.
|
|
87
|
+
* A manual procedure with no evidence fields keeps the older hit-based path
|
|
88
|
+
* (`hits >= promoteHits`), so hand-written procedures are not silently demoted.
|
|
89
|
+
*/
|
|
90
|
+
export function procedureInvalidated(m) {
|
|
91
|
+
if ((m?.kind ?? "") !== "procedure") return false;
|
|
92
|
+
if (m?.lastFailedAt === null || m?.lastFailedAt === void 0) return false;
|
|
93
|
+
return m.lastVerifiedAt === null || m.lastVerifiedAt === void 0 || m.lastFailedAt >= m.lastVerifiedAt;
|
|
94
|
+
}
|
|
95
|
+
|
|
46
96
|
export function isProvenProcedure(m, config) {
|
|
47
|
-
|
|
97
|
+
if ((m?.kind ?? "") !== "procedure") return false;
|
|
98
|
+
if (procedureInvalidated(m)) return false;
|
|
99
|
+
const nowMs = config?.nowMs ?? Date.now();
|
|
100
|
+
const minEvidence = config?.procedureMinEvidence ?? 3;
|
|
101
|
+
const maxAgeMs = (config?.procedureMaxAgeDays ?? 14) * 24 * 60 * 60 * 1000;
|
|
102
|
+
if ((m.evidenceCount ?? 0) > 0) {
|
|
103
|
+
const verified = m.lastVerifiedAt ?? 0;
|
|
104
|
+
return (m.evidenceCount ?? 0) >= minEvidence && verified > 0 && nowMs - verified <= maxAgeMs;
|
|
105
|
+
}
|
|
106
|
+
return (m?.hits ?? 0) >= (config?.promoteHits ?? 3);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* An auto-captured `fixed:` row is a hypothesis until it has evidence — it
|
|
111
|
+
* must not earn an index line on the strength of one success.
|
|
112
|
+
*/
|
|
113
|
+
export function isUnprovenAutoProcedure(m, config) {
|
|
114
|
+
if ((m?.kind ?? "") !== "procedure") return false;
|
|
115
|
+
if (!Array.isArray(m.tags) || !m.tags.includes("auto-captured")) return false;
|
|
116
|
+
return !isProvenProcedure(m, config);
|
|
48
117
|
}
|
|
49
118
|
|
|
50
119
|
/**
|
|
@@ -67,6 +136,10 @@ export function selectIndexMemories(store, workspace, config) {
|
|
|
67
136
|
// A memory whose file source changed/vanished is no longer current truth
|
|
68
137
|
// for an auto-injected index line (still re-fetchable via engram_detail).
|
|
69
138
|
.filter((m) => !memoryIsStale(m))
|
|
139
|
+
// v0.8-A: a just-invalidated procedure is worse than none — the next agent
|
|
140
|
+
// would copy it verbatim. Unproven auto-captures stay out until they earn it.
|
|
141
|
+
.filter((m) => !procedureInvalidated(m))
|
|
142
|
+
.filter((m) => !isUnprovenAutoProcedure(m, config))
|
|
70
143
|
.filter(
|
|
71
144
|
(m) =>
|
|
72
145
|
(isGitPlumbingEcho(m) && (m.hits ?? 0) < config.promoteHits
|
|
@@ -99,7 +172,8 @@ function memoryLine(m, config) {
|
|
|
99
172
|
const mark = isProvenProcedure(m, config) ? `${px}✓` : px;
|
|
100
173
|
const date = fmtDate(m.createdAt ?? m.updatedAt);
|
|
101
174
|
const text = truncate(String(m.text).replace(/\s*\n+\s*/g, " ").trim(), INDEX_LINE_CHARS);
|
|
102
|
-
|
|
175
|
+
const drift = fileDrift(m, config?.nowMs ?? Date.now(), config?.stat);
|
|
176
|
+
return `[${mark}] ${date} ${escapeLt(text)}${drift === "" ? "" : ` ${drift}`} #${shortId(m.id)}`;
|
|
103
177
|
}
|
|
104
178
|
|
|
105
179
|
function taskStateLine(t) {
|
|
@@ -274,7 +348,8 @@ export function renderStartRecall(store, workspace, config = {}, opts = {}) {
|
|
|
274
348
|
const truncated = [...full].length > 140;
|
|
275
349
|
const text = truncate(full, 140);
|
|
276
350
|
if (truncated && truncatedId === null) truncatedId = shortId(m.id);
|
|
277
|
-
|
|
351
|
+
const drift = fileDrift(m, config?.nowMs ?? Date.now(), config?.stat);
|
|
352
|
+
lines.push(`- [${mark}] ${date} ${escapeLt(text)}${drift === "" ? "" : ` ${drift}`} #${shortId(m.id)}${truncated ? " …" : ""}${(m.hits ?? 0) > 0 ? ` ×${m.hits}` : ""}`);
|
|
278
353
|
}
|
|
279
354
|
const drill = truncatedId !== null
|
|
280
355
|
? `context: use above · #${truncatedId} truncated — engram_detail ${truncatedId} (full) · engram_recall <query>`
|
package/lib/index.js
CHANGED
|
@@ -27,19 +27,20 @@ import { installPreferenceCapture } from "./preference.js";
|
|
|
27
27
|
import { boundaryPruneAvailability, installBoundaryPrune } from "./boundary-prune.js";
|
|
28
28
|
import { resultPackDir, archivePrunedResults, resultRecallTool, sweepExpired } from "./result-pack.js";
|
|
29
29
|
import { recordBoundaryPruneFire, recordRecallInjection, readPackTelemetry, engramHome } from "./pack-telemetry.js";
|
|
30
|
-
import { createLedger, estimatePrefixChars } from "./ledger.js";
|
|
30
|
+
import { CHARS_PER_TOKEN, createLedger, estimatePrefixChars } from "./ledger.js";
|
|
31
31
|
import { createJit } from "./jit.js";
|
|
32
32
|
import { buildEpisode } from "./episode.js";
|
|
33
33
|
import { collectStaleAnchors } from "./anchor.js";
|
|
34
34
|
import { renderIndex, renderStartRecall, renderResumeBrief, firstUserMessageText, renderEsr, esrMethodology, buildEsrSnapshot, buildFrozenEsrBlock } from "./index-block.js";
|
|
35
|
-
import { sessionEventsSource, sessionSeq } from "./session-events.js";
|
|
35
|
+
import { describeSessionSurface, sessionEventsSource, sessionSeq } from "./session-events.js";
|
|
36
|
+
import { noteSessionEventsSource } from "./push-legs.js";
|
|
36
37
|
import { makeTriggerRecorder } from "./trigger.js";
|
|
37
38
|
import { pendingTodosFromSession, sinkPendingTodos } from "./todo-sink.js";
|
|
38
39
|
import { installEngramSettings, makeEngineConfigSchema, SETTINGS_KEYS } from "./settings.js";
|
|
39
40
|
import { makeEngramRoutes } from "./api.js";
|
|
40
|
-
import { collectPointerContext, mountCompactionEngine } from "./context-gc.js";
|
|
41
|
+
import { collectPointerContext, committedGcSaving, mountCompactionEngine } from "./context-gc.js";
|
|
41
42
|
import { provisionWebCompaction } from "./web-provision.js";
|
|
42
|
-
import { writeContextGcStatus } from "./status.js";
|
|
43
|
+
import { writeContextGcStatus, recordPruneAttempt } from "./status.js";
|
|
43
44
|
import { workspaceKey as wk } from "./util.js";
|
|
44
45
|
|
|
45
46
|
/** Stable cordis plugin name. */
|
|
@@ -81,11 +82,23 @@ const DEFAULTS = {
|
|
|
81
82
|
// memories that outrank the [ENGRAM] index.
|
|
82
83
|
autoCapturePreferences: true,
|
|
83
84
|
preferenceCapturePerSession: 8,
|
|
85
|
+
// v0.8-C (advisory): a subagent report above this many chars is distilled
|
|
86
|
+
// into a durable `handoff` capsule. The parent still receives the full text —
|
|
87
|
+
// DSH's tools/result is observer-only (no mutation channel) — so this buys
|
|
88
|
+
// durability and a measurable saving, not a smaller prompt. See lib/capsule.js.
|
|
89
|
+
capsuleMinSourceChars: 1500,
|
|
90
|
+
capsuleMaxChars: 1800,
|
|
84
91
|
// [ENGRAM] index budget (token discipline)
|
|
85
92
|
indexMaxLines: 12,
|
|
86
93
|
indexMaxChars: 700,
|
|
87
94
|
minIndexSignal: 0.4,
|
|
88
95
|
promoteHits: 3,
|
|
96
|
+
// v0.8-A evidence-based procedure promotion: a `fixed:` procedure reaches the
|
|
97
|
+
// [ENGRAM] index only after this many verified successes, and only while the
|
|
98
|
+
// last verification is younger than procedureMaxAgeDays. A later failure
|
|
99
|
+
// invalidates it immediately (see store.recordProcedureOutcome).
|
|
100
|
+
procedureMinEvidence: 3,
|
|
101
|
+
procedureMaxAgeDays: 14,
|
|
89
102
|
// retention / caps
|
|
90
103
|
expireDays: 180,
|
|
91
104
|
maxMemoriesPerWorkspace: 2000,
|
|
@@ -186,9 +199,13 @@ const DEFAULTS = {
|
|
|
186
199
|
// (deterministic surface-replace, zero LLM). L0 simulation (77 sessions):
|
|
187
200
|
// todo-progress boundaries cover 79% of replay exposure; the repayment
|
|
188
201
|
// gate below lifted cache-aware net savings 12–32% on every threshold.
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
|
|
202
|
+
//
|
|
203
|
+
// Default OFF since v0.7.11: 15 h of live accounting showed the host prunes
|
|
204
|
+
// over-threshold results by itself (64,200 chars across two sessions with
|
|
205
|
+
// zero engram involvement), while the boundary source never fired in an
|
|
206
|
+
// ordinary 220-request session — it only pays for itself in planning
|
|
207
|
+
// sessions that actually use todos/goals. `true` is the opt-in.
|
|
208
|
+
boundaryPrune: false,
|
|
192
209
|
// Adaptive off-switch (v0.6-B): the Context Ledger vetoes further fires in a
|
|
193
210
|
// workspace whose own accounting is net negative after real fires. Evidence
|
|
194
211
|
// can only turn pruning OFF — never on. `false` = repayment gate only.
|
|
@@ -422,6 +439,8 @@ export function apply(ctx, config) {
|
|
|
422
439
|
|
|
423
440
|
const service = {
|
|
424
441
|
config: resolved,
|
|
442
|
+
/** Where local (non-store) engram state lives — approvals, telemetry, ledger. */
|
|
443
|
+
engramHome: () => engramHome(),
|
|
425
444
|
getDomain,
|
|
426
445
|
openedDomain: () => opened,
|
|
427
446
|
recorder,
|
|
@@ -525,6 +544,8 @@ export function apply(ctx, config) {
|
|
|
525
544
|
const sessionCache = new WeakMap();
|
|
526
545
|
/** One-time-per-process notice: the host's session log is unreadable. */
|
|
527
546
|
let warnedUnreadableLog = false;
|
|
547
|
+
/** Sessions whose event-log read path was already recorded (push-legs.jsonl). */
|
|
548
|
+
const notedSessionEvents = new WeakSet();
|
|
528
549
|
const blockFor = (agent) => {
|
|
529
550
|
if (agent === void 0) return "";
|
|
530
551
|
const session = agent.session;
|
|
@@ -563,6 +584,17 @@ export function apply(ctx, config) {
|
|
|
563
584
|
// next assembly retry, so a new conversation never permanently misses its
|
|
564
585
|
// recalled memory.
|
|
565
586
|
if (resolved.autoRecallOnStart !== false) {
|
|
587
|
+
// Push-legs telemetry (issue #9): record which read path this session
|
|
588
|
+
// took, once per session object, so `dsh-engram status` can answer
|
|
589
|
+
// "were [RECALL]/episode/resume/todo-sink awake?" without a log capture.
|
|
590
|
+
if (!notedSessionEvents.has(session)) {
|
|
591
|
+
notedSessionEvents.add(session);
|
|
592
|
+
noteSessionEventsSource({
|
|
593
|
+
home: engramHome(),
|
|
594
|
+
source: sessionEventsSource(session, sessionQuery),
|
|
595
|
+
sessionId: session.header?.id ?? session.id,
|
|
596
|
+
});
|
|
597
|
+
}
|
|
566
598
|
const query = firstUserMessageText(session, sessionQuery);
|
|
567
599
|
if (query.length === 0) {
|
|
568
600
|
// Retry on the next assembly ONLY when the log is readable and the
|
|
@@ -574,7 +606,7 @@ export function apply(ctx, config) {
|
|
|
574
606
|
if (!pendingRecall && !warnedUnreadableLog) {
|
|
575
607
|
warnedUnreadableLog = true;
|
|
576
608
|
log?.warn?.(
|
|
577
|
-
|
|
609
|
+
`engram: this host exposes no readable session event log (no session.events, no ctx.sessionQuery, no snapshotEvents, no eventAt) — [RECALL] / episode / todo-sink stay dormant. Reporting once per session. Session surface: ${describeSessionSurface(session).join(", ") || "(empty)"}`,
|
|
578
610
|
);
|
|
579
611
|
}
|
|
580
612
|
} else {
|
|
@@ -668,6 +700,9 @@ export function apply(ctx, config) {
|
|
|
668
700
|
|
|
669
701
|
ctx.effect(() => {
|
|
670
702
|
const disposers = [];
|
|
703
|
+
// sessionId → { seqs, at }: the prunes this plugin caused, so the
|
|
704
|
+
// compaction/prune observer can tell engram's savings from the host's.
|
|
705
|
+
const ownPruneSeqs = new Map();
|
|
671
706
|
|
|
672
707
|
// Settings card edits (volatile Config fields) hot-commit into the refs
|
|
673
708
|
// in place; re-read them so `resolved` reflects new values for new
|
|
@@ -703,16 +738,10 @@ export function apply(ctx, config) {
|
|
|
703
738
|
return null;
|
|
704
739
|
}
|
|
705
740
|
},
|
|
706
|
-
//
|
|
707
|
-
//
|
|
708
|
-
//
|
|
709
|
-
|
|
710
|
-
try {
|
|
711
|
-
ledger?.noteCompaction(session?.id, workspace ?? wk(session?.header?.cwd), { charsSaved });
|
|
712
|
-
} catch (error) {
|
|
713
|
-
log?.warn?.(`engram ledger compaction credit failed: ${String(error)}`);
|
|
714
|
-
}
|
|
715
|
-
},
|
|
741
|
+
// Credit is committed-record-driven (see the `compaction/summary`
|
|
742
|
+
// listener below), not summarize()-driven: a checkpoint the harness
|
|
743
|
+
// rejects or overrides saves nothing, and crediting it would make the
|
|
744
|
+
// ledger claim savings that never landed.
|
|
716
745
|
});
|
|
717
746
|
disposers.push(() => {
|
|
718
747
|
// The engine's service registration is fiber-scoped and auto-removed on
|
|
@@ -735,6 +764,7 @@ export function apply(ctx, config) {
|
|
|
735
764
|
if (!hostSettled || !webSettled) return;
|
|
736
765
|
if (statusTimer !== void 0) { clearTimeout(statusTimer); statusTimer = void 0; }
|
|
737
766
|
void writeContextGcStatus({
|
|
767
|
+
pid: process.pid,
|
|
738
768
|
planes: resolved.autoWebCompaction === false ? ["host"] : ["host", "web"],
|
|
739
769
|
host: service.compactionStatus.host,
|
|
740
770
|
web: service.compactionStatus.web,
|
|
@@ -886,6 +916,45 @@ export function apply(ctx, config) {
|
|
|
886
916
|
ledger.dispose(session?.id);
|
|
887
917
|
});
|
|
888
918
|
disposers.push(disposeLedgerSession);
|
|
919
|
+
// Compaction credit, committed-record-driven: only a summary that carries
|
|
920
|
+
// the Context GC footer AND actually landed in the session counts. The
|
|
921
|
+
// engine's own measurement (`summarize`) is not evidence — the harness can
|
|
922
|
+
// reject or override it, and then nothing was saved.
|
|
923
|
+
const disposeLedgerCompaction = ctx.on("session/event", (session, event) => {
|
|
924
|
+
const credit = committedGcSaving(event);
|
|
925
|
+
if (credit === null) return;
|
|
926
|
+
ledger.noteCompaction(session?.id, wk(session?.header?.cwd), { charsSaved: credit.charsSaved });
|
|
927
|
+
});
|
|
928
|
+
disposers.push(disposeLedgerCompaction);
|
|
929
|
+
// Prune accounting: DSH prunes over-threshold tool results by itself
|
|
930
|
+
// (mid-step, no boundary involved). Those savings are real but they are
|
|
931
|
+
// NOT engram's, so they land in `hostPrunedChars` — a visible leg that
|
|
932
|
+
// never feeds NetTokens. Prunes we caused are already credited by the
|
|
933
|
+
// boundary hook (matched by seq here and skipped).
|
|
934
|
+
const disposeLedgerPrune = ctx.on("session/event", (session, event) => {
|
|
935
|
+
if (event?.type !== "compaction/prune") return;
|
|
936
|
+
const sid = session?.id;
|
|
937
|
+
const workspace = wk(session?.header?.cwd);
|
|
938
|
+
const tokens = Number(event?.data?.shadowedTokenCount);
|
|
939
|
+
if (!Number.isFinite(tokens) || tokens <= 0) return;
|
|
940
|
+
const shadowed = Array.isArray(event?.data?.shadowedSeqs) ? event.data.shadowedSeqs : [];
|
|
941
|
+
const own = typeof sid === "string" ? ownPruneSeqs.get(sid) : void 0;
|
|
942
|
+
let source = "host";
|
|
943
|
+
if (own !== void 0) {
|
|
944
|
+
const ours = shadowed.some((seq) => own.seqs.has(seq));
|
|
945
|
+
// A host prune that merely follows ours within the same step still
|
|
946
|
+
// counts as host work; only overlapping seqs mean "we did this".
|
|
947
|
+
if (ours) source = "engram";
|
|
948
|
+
if (Date.now() - own.at > 120_000) ownPruneSeqs.delete(sid);
|
|
949
|
+
}
|
|
950
|
+
if (source === "engram") return;
|
|
951
|
+
ledger.notePrune(sid, workspace, {
|
|
952
|
+
charsRemoved: tokens * CHARS_PER_TOKEN,
|
|
953
|
+
rewriteChars: estimatePrefixChars(session) + tokens * CHARS_PER_TOKEN,
|
|
954
|
+
source: "host",
|
|
955
|
+
});
|
|
956
|
+
});
|
|
957
|
+
disposers.push(disposeLedgerPrune);
|
|
889
958
|
}
|
|
890
959
|
|
|
891
960
|
// JIT observers: tool results (error recurrence / file focus) and goal
|
|
@@ -1052,6 +1121,14 @@ export function apply(ctx, config) {
|
|
|
1052
1121
|
// surface is already rewritten when this hook runs).
|
|
1053
1122
|
const removed = Number.isFinite(report?.charsRemoved) ? report.charsRemoved : 0;
|
|
1054
1123
|
const prefixChars = estimatePrefixChars(session);
|
|
1124
|
+
if (typeof session?.id === "string") {
|
|
1125
|
+
const seqs = new Set();
|
|
1126
|
+
for (const item of report?.pruned ?? []) {
|
|
1127
|
+
const seq = item?.seq ?? item?.event?.seq ?? item?.payload?.seq;
|
|
1128
|
+
if (Number.isFinite(seq)) seqs.add(seq);
|
|
1129
|
+
}
|
|
1130
|
+
ownPruneSeqs.set(session.id, { seqs, at: Date.now() });
|
|
1131
|
+
}
|
|
1055
1132
|
ledger?.notePrune(session?.id, workspace, {
|
|
1056
1133
|
charsRemoved: removed,
|
|
1057
1134
|
rewriteChars: prefixChars + removed,
|
|
@@ -1077,12 +1154,22 @@ export function apply(ctx, config) {
|
|
|
1077
1154
|
ctx.inject(["toolResultPruner"], (prunerCtx) => {
|
|
1078
1155
|
const dispose = installBoundaryPrune(prunerCtx, resolved, log, boundaryHooks, {
|
|
1079
1156
|
allowFire: boundaryGate,
|
|
1157
|
+
// Durability for "armed but never fires": this plane sees the host's
|
|
1158
|
+
// own sessions; the agent-preset realm reports the same shape under
|
|
1159
|
+
// plane "preset" (lib/prune.js). Comparing the two tells us where a
|
|
1160
|
+
// missing boundary went.
|
|
1161
|
+
onAttempt: (attempt) => {
|
|
1162
|
+
void recordPruneAttempt({ plane: "host", ...attempt });
|
|
1163
|
+
},
|
|
1080
1164
|
});
|
|
1081
1165
|
// L1 provably can (or cannot) fire from here on: record it for the
|
|
1082
1166
|
// durable snapshot, then rewrite it — the first flush may have landed
|
|
1083
1167
|
// before the pruner activated, and this status line is what users
|
|
1084
1168
|
// read when "节省" stays at 0.
|
|
1085
1169
|
boundaryPruneState = boundaryPruneAvailability(prunerCtx, resolved);
|
|
1170
|
+
// Mirror it onto the live view so `/overview` can answer "is the only
|
|
1171
|
+
// ledger credit path actually able to fire?" without reading the file.
|
|
1172
|
+
service.compactionStatus.boundaryPrune = boundaryPruneState;
|
|
1086
1173
|
flushStatus();
|
|
1087
1174
|
return dispose;
|
|
1088
1175
|
});
|