dsh-engram 0.3.4 → 0.3.5
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 +156 -22
- package/README.zh.md +108 -13
- package/cordis.patch.yml +6 -0
- package/docs/CONTEXT-GC-GUIDE.zh.md +192 -0
- package/docs/PROPOSAL-agentmemory.zh.md +108 -0
- package/docs/PROPOSAL-beads.zh.md +81 -0
- package/docs/PROPOSAL-contextgc.zh.md +188 -0
- package/docs/PROPOSAL-esrtrigger.zh.md +199 -0
- package/docs/PROPOSAL-hindsight.zh.md +156 -0
- package/lib/api.js +19 -0
- package/lib/capture.js +132 -4
- package/lib/client.js +32 -1
- package/lib/compaction.js +2 -18
- package/lib/context-gc.js +234 -2
- package/lib/goal-capture.js +89 -0
- package/lib/index-block.js +4 -0
- package/lib/index.js +196 -4
- package/lib/redact.js +48 -0
- package/lib/session-cjk.js +159 -0
- package/lib/settings.js +7 -0
- package/lib/status.js +57 -0
- package/lib/store.js +124 -6
- package/lib/todo-sink.js +146 -0
- package/lib/tools.js +58 -34
- package/lib/trigger.js +10 -8
- package/lib/usage.js +1 -0
- package/lib/web-provision.js +430 -0
- package/package.json +13 -2
- package/scripts/dsh-engram.mjs +268 -0
- package/scripts/engram-web-compaction.mjs +7 -0
- package/scripts/prepublish-check.mjs +106 -0
- package/scripts/setup-links.mjs +241 -0
package/lib/context-gc.js
CHANGED
|
@@ -12,12 +12,18 @@
|
|
|
12
12
|
* with no re-fetchable backing) get a scoped LLM narrative — the safety net,
|
|
13
13
|
* configurable off for a pure-mechanical zero-LLM path.
|
|
14
14
|
*
|
|
15
|
-
* The 6 pi-esr GC constraints:
|
|
15
|
+
* The 6 pi-esr GC constraints (+ the harness shrink gate that keeps them legal):
|
|
16
16
|
* - indexed-output-evictable → pointer to `engram_detail(id)` / `engram_recall`
|
|
17
17
|
* - stable-task-evictable → pointer to the [ESR] block / `esr_ready`
|
|
18
18
|
* - working-set-protected → active tasks restated in the summary
|
|
19
19
|
* - pointer-salience → every evicted category has an explicit re-fetch call
|
|
20
20
|
* - no-provenance-no-evict → un-provenanced turns via scoped narrative or verbatim
|
|
21
|
+
* - shrink-gated → the framed checkpoint must be STRICTLY SMALLER
|
|
22
|
+
* than the evicted span (`fitSummaryToSpan`): the harness refuses and rolls
|
|
23
|
+
* back any summary that is not smaller, which otherwise blocks every auto
|
|
24
|
+
* compaction and pushes the session into the model-side context overflow.
|
|
25
|
+
* The pointer/narrative body is therefore fitted under the span with the
|
|
26
|
+
* host token meter, truncating tail detail (still durable in the log).
|
|
21
27
|
*
|
|
22
28
|
* Layout: the pure (zero-LLM, dependency-free) layer lives here; the DSH
|
|
23
29
|
* `CompactionEngine` binding is `loadContextGcEngine`, behind a guarded dynamic
|
|
@@ -67,6 +73,152 @@ export function blockText(content) {
|
|
|
67
73
|
return out.trim();
|
|
68
74
|
}
|
|
69
75
|
|
|
76
|
+
/**
|
|
77
|
+
* The durable checkpoint framing the harness applies to a compaction summary
|
|
78
|
+
* before accepting it (`frameSummary()` in dsh-compaction-basic): the summary
|
|
79
|
+
* body is wrapped in a preamble + `<compacted-summary>` tags and the WHOLE
|
|
80
|
+
* framed user message must estimate strictly SMALLER than the evicted span,
|
|
81
|
+
* or the harness throws `summary is not smaller than the shadowed content`
|
|
82
|
+
* and rolls the compaction back. These constants mirror that framing so the
|
|
83
|
+
* Context GC can self-predict acceptance with the host token meter (verified
|
|
84
|
+
* to reproduce the harness's estimate exactly); a small safety margin absorbs
|
|
85
|
+
* any harness drift.
|
|
86
|
+
*/
|
|
87
|
+
const FRAME_PREAMBLE =
|
|
88
|
+
"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.";
|
|
89
|
+
const FRAME_OPEN_TAG = "<compacted-summary>";
|
|
90
|
+
const FRAME_CLOSE_TAG = "</compacted-summary>";
|
|
91
|
+
|
|
92
|
+
/** Best-effort token estimate of one derived message through the host meter. */
|
|
93
|
+
function estimateMessageTokens(meter, message) {
|
|
94
|
+
if (typeof meter?.estimateMessage !== "function") return 0;
|
|
95
|
+
try {
|
|
96
|
+
const tokens = meter.estimateMessage(message);
|
|
97
|
+
return Number.isFinite(tokens) && tokens > 0 ? tokens : 0;
|
|
98
|
+
} catch {
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Exact token estimate of the span a compaction is about to shadow: the sum of
|
|
105
|
+
* the evicted region's derived messages through the SAME token meter the
|
|
106
|
+
* harness compares against (verified to equal the harness's shadowed count).
|
|
107
|
+
* Returns 0 when no meter is available — callers then skip the shrink gate
|
|
108
|
+
* rather than guess.
|
|
109
|
+
* @param {object|undefined} meter - `ctx.tokenMeter` (host), or undefined.
|
|
110
|
+
* @param {Array<object>} messages - the region messages passed to `summarize`.
|
|
111
|
+
* @returns {number}
|
|
112
|
+
*/
|
|
113
|
+
export function estimateShadowedTokens(meter, messages) {
|
|
114
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
115
|
+
let total = 0;
|
|
116
|
+
for (const message of list) total += estimateMessageTokens(meter, message);
|
|
117
|
+
return total;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The harness-framed checkpoint user message for a summary body. */
|
|
121
|
+
function framedCheckpointMessage(bodyText) {
|
|
122
|
+
return {
|
|
123
|
+
role: "user",
|
|
124
|
+
content: [
|
|
125
|
+
{ type: "text", text: `${FRAME_PREAMBLE}\n\n${FRAME_OPEN_TAG}` },
|
|
126
|
+
{ type: "text", text: bodyText },
|
|
127
|
+
{ type: "text", text: FRAME_CLOSE_TAG },
|
|
128
|
+
],
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Token estimate of the framed checkpoint the harness will generate for this
|
|
134
|
+
* body (mirrors `frameSummary` + `createUserMessage` + `estimateMessage`;
|
|
135
|
+
* verified byte-identical to the live meter). Without a meter, a conservative
|
|
136
|
+
* chars→tokens heuristic is used — divided by two so both CJK and latin
|
|
137
|
+
* over-estimate, biasing toward trimming more, never less.
|
|
138
|
+
* @param {object|undefined} meter - `ctx.tokenMeter`, or undefined.
|
|
139
|
+
* @param {string} bodyText - the summary body inside the checkpoint.
|
|
140
|
+
* @returns {number}
|
|
141
|
+
*/
|
|
142
|
+
export function estimateFramedTokens(meter, bodyText) {
|
|
143
|
+
if (typeof meter?.estimateMessage === "function") {
|
|
144
|
+
try {
|
|
145
|
+
const tokens = meter.estimateMessage(framedCheckpointMessage(bodyText));
|
|
146
|
+
if (Number.isFinite(tokens)) return Math.max(1, tokens);
|
|
147
|
+
} catch {
|
|
148
|
+
// fall through to the char heuristic
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const text = typeof bodyText === "string" ? bodyText : "";
|
|
152
|
+
return (
|
|
153
|
+
Math.ceil((FRAME_PREAMBLE.length + FRAME_OPEN_TAG.length + FRAME_CLOSE_TAG.length + text.length) / 2) + 8
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Truncation note appended when the checkpoint must shrink to fit the span. */
|
|
158
|
+
const FIT_SUFFIX = "\n_…(full detail stays re-fetchable via the engram/ESR tools; see the session log)_";
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Shrink a Context GC summary body so the framed checkpoint stays strictly
|
|
162
|
+
* under the evicted span — the harness's hard shrink gate. Without this, a
|
|
163
|
+
* large verbatim/pointer body makes every automatic pressure / overflow
|
|
164
|
+
* compaction roll back (`summary is not smaller than the shadowed content`),
|
|
165
|
+
* the context never shrinks, and the session eventually hits the model-side
|
|
166
|
+
* context overflow.
|
|
167
|
+
*
|
|
168
|
+
* Truncation keeps the largest PREFIX that still fits — the pointer head and
|
|
169
|
+
* working set survive, only tail detail is cut — and appends a short note when
|
|
170
|
+
* the span is large enough to afford one. Exact under the host token meter;
|
|
171
|
+
* deterministic everywhere; degrades to an empty body for spans so small that
|
|
172
|
+
* the exact framing overhead does not fit (the stock LLM summarizer would be
|
|
173
|
+
* refused by the harness for those too).
|
|
174
|
+
*
|
|
175
|
+
* @param {string} bodyText - the full Context GC body.
|
|
176
|
+
* @param {number} shadowedTokens - evicted-span token estimate (0 = unknown).
|
|
177
|
+
* @param {{meter?: object, margin?: number}} [opts]
|
|
178
|
+
* @returns {{ text: string, framed: number, shadowed: number, trimmed: boolean }}
|
|
179
|
+
*/
|
|
180
|
+
export function fitSummaryToSpan(bodyText, shadowedTokens, opts = {}) {
|
|
181
|
+
const meter = opts?.meter;
|
|
182
|
+
const margin = opts?.margin ?? 4;
|
|
183
|
+
const budget = shadowedTokens - margin;
|
|
184
|
+
if (!Number.isFinite(shadowedTokens) || shadowedTokens <= 0 || budget <= 1) {
|
|
185
|
+
return {
|
|
186
|
+
text: bodyText,
|
|
187
|
+
framed: estimateFramedTokens(meter, bodyText),
|
|
188
|
+
shadowed: shadowedTokens,
|
|
189
|
+
trimmed: false,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const source = typeof bodyText === "string" && bodyText.length > 0 ? bodyText : "";
|
|
193
|
+
if (estimateFramedTokens(meter, source) < budget) {
|
|
194
|
+
return {
|
|
195
|
+
text: source,
|
|
196
|
+
framed: estimateFramedTokens(meter, source),
|
|
197
|
+
shadowed: shadowedTokens,
|
|
198
|
+
trimmed: false,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
// Largest prefix of the source that fits WITHOUT the note; the note is only
|
|
202
|
+
// re-added when the budget still affords it (the note itself is ~25 tokens).
|
|
203
|
+
let lo = 0;
|
|
204
|
+
let hi = source.length;
|
|
205
|
+
let best = 0;
|
|
206
|
+
while (lo <= hi) {
|
|
207
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
208
|
+
if (estimateFramedTokens(meter, source.slice(0, mid)) < budget) {
|
|
209
|
+
best = mid;
|
|
210
|
+
lo = mid + 1;
|
|
211
|
+
} else {
|
|
212
|
+
hi = mid - 1;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const prefix = source.slice(0, best);
|
|
216
|
+
const withNote = `${prefix}${FIT_SUFFIX}`;
|
|
217
|
+
const text = best > 0 && estimateFramedTokens(meter, withNote) < budget ? withNote : prefix;
|
|
218
|
+
const trimmed = text !== source;
|
|
219
|
+
return { text, framed: estimateFramedTokens(meter, text), shadowed: shadowedTokens, trimmed };
|
|
220
|
+
}
|
|
221
|
+
|
|
70
222
|
function addSet(set, value) {
|
|
71
223
|
const v = String(value ?? "").trim();
|
|
72
224
|
if (v.length > 0) set.add(v);
|
|
@@ -466,6 +618,57 @@ export async function loadCompactionEngine(ctx, config = {}, deps = {}, { mode =
|
|
|
466
618
|
return loadContextGcEngine(ctx, config, deps);
|
|
467
619
|
}
|
|
468
620
|
|
|
621
|
+
/**
|
|
622
|
+
* Mount the compaction engine onto `ctx` — THE shared entry both planes use
|
|
623
|
+
* (host plane: the main engram plugin's `apply`; preset plane: the
|
|
624
|
+
* `dsh-engram/compaction` row shown in an agent preset's `compaction` group).
|
|
625
|
+
*
|
|
626
|
+
* `config.gcReplacesCompaction === false` → mount the bare default LLM
|
|
627
|
+
* summarizer instead (keeps the `compaction` service alive after the profile
|
|
628
|
+
* patch disabled the base `compaction-basic` row); otherwise mount the
|
|
629
|
+
* ContextGcEngine override. `deps.readWorkspace(ws, report)` is how the engine
|
|
630
|
+
* reaches engram/ESR state for pointer classification — the caller supplies
|
|
631
|
+
* its own lazy-domain binding.
|
|
632
|
+
*
|
|
633
|
+
* Fully contained: an unimportable backend or a duplicate `compaction`
|
|
634
|
+
* provider (cordis refuses second registration) only warns and returns null —
|
|
635
|
+
* the host keeps whatever compaction it already has, never a crash.
|
|
636
|
+
*
|
|
637
|
+
* @returns {Promise<Function|null>} the mounted engine class, or null.
|
|
638
|
+
*/
|
|
639
|
+
export async function mountCompactionEngine(ctx, config = {}, deps = {}) {
|
|
640
|
+
const mode = config.gcReplacesCompaction === false ? "default" : "context-gc";
|
|
641
|
+
const engineDeps =
|
|
642
|
+
mode === "context-gc"
|
|
643
|
+
? {
|
|
644
|
+
narrativeEnabled: config.gcNarrative !== false,
|
|
645
|
+
narrativeMaxTokens: config.gcNarrativeMaxTokens ?? 1024,
|
|
646
|
+
narrativeMaxChars: config.gcNarrativeMaxChars ?? 4000,
|
|
647
|
+
...deps,
|
|
648
|
+
}
|
|
649
|
+
: {};
|
|
650
|
+
const Engine = await loadCompactionEngine(ctx, { auto: true }, engineDeps, { mode });
|
|
651
|
+
if (!Engine) {
|
|
652
|
+
ctx?.logger?.warn?.("engram context-gc: dsh-compaction-basic unavailable — no compaction engine");
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
try {
|
|
656
|
+
new Engine();
|
|
657
|
+
} catch (error) {
|
|
658
|
+
// Duplicate `compaction` provider (base row not disabled) or constructor
|
|
659
|
+
// refusal: keep whatever is already registered, never crash the host.
|
|
660
|
+
ctx?.logger?.warn?.(
|
|
661
|
+
`engram context-gc mount skipped (${error instanceof Error ? error.message : String(error)}) — keeping the existing compaction service`,
|
|
662
|
+
);
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
665
|
+
ctx?.logger?.info?.(
|
|
666
|
+
`engram context-gc: compaction = ${mode === "context-gc" ? "Context GC (mechanical eviction + re-fetch pointers)" : "default LLM summarizer (gcReplacesCompaction:false)"}`,
|
|
667
|
+
);
|
|
668
|
+
return Engine;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
|
|
469
672
|
/**
|
|
470
673
|
* Load the DSH binding: a `ContextGcEngine extends BasicCompactionEngine`
|
|
471
674
|
* whose sole override is `summarize()`. Returns `null` when
|
|
@@ -542,9 +745,38 @@ export async function loadContextGcEngine(ctx, config = {}, deps = {}) {
|
|
|
542
745
|
callLlm,
|
|
543
746
|
});
|
|
544
747
|
|
|
545
|
-
|
|
748
|
+
let summaryText = buildContextGcSummary(report, safeContext, { turns, narrative });
|
|
749
|
+
|
|
750
|
+
// The harness refuses any checkpoint whose framed token estimate is >=
|
|
751
|
+
// the evicted span (see fitSummaryToSpan). Fit the body under that span
|
|
752
|
+
// so automatic pressure / context-overflow compaction COMMITS instead of
|
|
753
|
+
// rolling back — otherwise the session only grows until the model-side
|
|
754
|
+
// context overflow fires.
|
|
755
|
+
const meter = this.ctx?.tokenMeter;
|
|
756
|
+
const shadowedTokens = estimateShadowedTokens(meter, messages);
|
|
757
|
+
const fit = fitSummaryToSpan(summaryText, shadowedTokens, { meter });
|
|
758
|
+
if (fit.trimmed && typeof this.ctx?.logger?.info === "function") {
|
|
759
|
+
this.ctx.logger.info(
|
|
760
|
+
`engram context-gc: checkpoint trimmed to fit the evicted span (${fit.framed} framed vs ${fit.shadowed} shadowed tokens)`,
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
summaryText = fit.text;
|
|
764
|
+
|
|
546
765
|
const result = { summary: [{ type: "text", text: summaryText }] };
|
|
547
766
|
if (narrative.llm && typeof narrative.llm === "object") Object.assign(result, narrative.llm);
|
|
767
|
+
// The harness's durable `compaction/summary` record ALWAYS carries
|
|
768
|
+
// `provider`/`model` (spread unconditionally; undefined values reject the
|
|
769
|
+
// whole event as non-JSON-serializable). The mechanical path makes no LLM
|
|
770
|
+
// call, so stamp the conversation's routed target — the same model the
|
|
771
|
+
// default summarizer would have used — as the record's provenance.
|
|
772
|
+
const target = this._narrativeTarget(agent);
|
|
773
|
+
if (target && typeof target.provider === "string" && typeof target.model === "string") {
|
|
774
|
+
result.provider = result.provider ?? target.provider;
|
|
775
|
+
result.model = result.model ?? target.model;
|
|
776
|
+
}
|
|
777
|
+
if (result.maxTokens === undefined && this.config && Number.isFinite(this.config.maxTokens)) {
|
|
778
|
+
result.maxTokens = this.config.maxTokens;
|
|
779
|
+
}
|
|
548
780
|
return result;
|
|
549
781
|
}
|
|
550
782
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-engram: DSH goal-domain integration.
|
|
3
|
+
*
|
|
4
|
+
* The harness's goal service persists a durable `goal/change` session event on
|
|
5
|
+
* every goal mutation. This module listens for those events and turns the two
|
|
6
|
+
* terminal states into memories, so a completed / blocked goal survives the
|
|
7
|
+
* session — deterministic, zero LLM, purely mechanical:
|
|
8
|
+
*
|
|
9
|
+
* - complete → `handoff` memory (high signal, tag `goal`)
|
|
10
|
+
* - block → `error` memory (tag `goal` + `blocked`) — the blocker is a
|
|
11
|
+
* real, recall-worthy signal
|
|
12
|
+
*
|
|
13
|
+
* The goal snapshot fields are read verbatim; nothing here calls a model and
|
|
14
|
+
* the listener never throws into the session pipeline (fully contained).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { workspaceKey as wk } from "./util.js";
|
|
18
|
+
|
|
19
|
+
function isRecord(v) {
|
|
20
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Pure reducer: map a `goal/change` session-event payload to a memory entry,
|
|
25
|
+
* or null when the event is not a terminal goal state worth sedimenting.
|
|
26
|
+
* Deterministic and unit-testable without the harness.
|
|
27
|
+
*/
|
|
28
|
+
export function goalChangeToMemory(change) {
|
|
29
|
+
if (!isRecord(change) || change.kind !== "goal/change") return null;
|
|
30
|
+
if (change.operation !== "complete" && change.operation !== "block") return null;
|
|
31
|
+
const goal = change.goal;
|
|
32
|
+
if (!isRecord(goal) || typeof goal.objective !== "string") return null;
|
|
33
|
+
const objective = goal.objective.trim().slice(0, 400);
|
|
34
|
+
if (objective.length === 0) return null;
|
|
35
|
+
if (change.operation === "complete") {
|
|
36
|
+
return {
|
|
37
|
+
kind: "handoff",
|
|
38
|
+
text: `goal complete: ${objective}`,
|
|
39
|
+
tags: ["goal", "auto-captured", "complete"],
|
|
40
|
+
signal: 0.6,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const reason = typeof goal.blockedReason === "string" && goal.blockedReason.trim().length > 0
|
|
44
|
+
? ` — ${goal.blockedReason.trim().slice(0, 160)}`
|
|
45
|
+
: "";
|
|
46
|
+
return {
|
|
47
|
+
kind: "error",
|
|
48
|
+
text: `goal blocked: ${objective}${reason}`,
|
|
49
|
+
tags: ["goal", "auto-captured", "blocked"],
|
|
50
|
+
signal: 0.4,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Install the `session/event` listener that auto-sediments terminal goals.
|
|
56
|
+
* `storeAdapter` is any { storeMemory(entry) -> Promise } face (the same lazy
|
|
57
|
+
* adapter auto-capture uses); it is resolved per event, so early events that
|
|
58
|
+
* beat the first domain open are simply dropped. Returns the disposer.
|
|
59
|
+
*/
|
|
60
|
+
export function installGoalCapture(ctx, storeAdapter, log) {
|
|
61
|
+
const onEvent = (session, event) => {
|
|
62
|
+
try {
|
|
63
|
+
if (event?.type !== "goal/change") return;
|
|
64
|
+
const entry = goalChangeToMemory(event.data);
|
|
65
|
+
if (entry === null) return;
|
|
66
|
+
const cwd = session?.header?.cwd;
|
|
67
|
+
const sessionId = session?.id;
|
|
68
|
+
if (typeof cwd !== "string" || cwd.length === 0) return;
|
|
69
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) return;
|
|
70
|
+
storeAdapter
|
|
71
|
+
.storeMemory({
|
|
72
|
+
workspace: wk(cwd),
|
|
73
|
+
kind: entry.kind,
|
|
74
|
+
text: entry.text,
|
|
75
|
+
tags: entry.tags,
|
|
76
|
+
signal: entry.signal,
|
|
77
|
+
filePath: null,
|
|
78
|
+
sessionId,
|
|
79
|
+
seq: 0,
|
|
80
|
+
})
|
|
81
|
+
.then(() => {})
|
|
82
|
+
.catch((error) => log?.warn?.(`engram goal capture failed: ${String(error)}`));
|
|
83
|
+
} catch (error) {
|
|
84
|
+
// fully contained — a capture bug must never disturb the session pipeline
|
|
85
|
+
log?.warn?.(`engram goal capture threw: ${String(error)}`);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
return ctx.on("session/event", onEvent);
|
|
89
|
+
}
|
package/lib/index-block.js
CHANGED
|
@@ -51,7 +51,11 @@ export function isProvenProcedure(m, config) {
|
|
|
51
51
|
*/
|
|
52
52
|
export function selectIndexMemories(store, workspace, config) {
|
|
53
53
|
const all = store.listMemories(workspace, 200);
|
|
54
|
+
// Stale truth (a newer memory marked itself as replacing that row) never
|
|
55
|
+
// earns an index line — the current statement is the one worth surfacing.
|
|
56
|
+
const { supersededBy } = store.memoryRelations?.(workspace) ?? { supersededBy: new Map() };
|
|
54
57
|
return all
|
|
58
|
+
.filter((m) => !supersededBy.has(m.id))
|
|
55
59
|
.filter(
|
|
56
60
|
(m) =>
|
|
57
61
|
(isGitPlumbingEcho(m) && (m.hits ?? 0) < config.promoteHits
|
package/lib/index.js
CHANGED
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
* - a compact, per-session-frozen [ENGRAM] symbolic index injected through a
|
|
7
7
|
* systemPrompt section (order 40) — prefix-stable for KV cache reuse
|
|
8
8
|
* - an [ESR] task/closure block (order 41) listing evidence gaps
|
|
9
|
-
* -
|
|
10
|
-
* esr_close / esr_link
|
|
9
|
+
* - fourteen tight tools: memory plane (engram_store / engram_recall /
|
|
10
|
+
* engram_detail) + ESR protocol (esr_task / esr_close / esr_link / esr_dep /
|
|
11
|
+
* esr_claim / esr_unclaim / esr_ready / esr_status / esr_node / esr_gc /
|
|
12
|
+
* esr_model)
|
|
11
13
|
* - storage on ctx.storageDomain (JSON unit dsh_engram), reads synchronous
|
|
12
14
|
*
|
|
13
15
|
* All model-visible writes are immediate (no approval gate — zero friction,
|
|
@@ -20,11 +22,15 @@ import { resolve as resolvePath } from "node:path";
|
|
|
20
22
|
import { openEngramDomain } from "./store.js";
|
|
21
23
|
import { registerTools } from "./tools.js";
|
|
22
24
|
import { makeCaptureHandler } from "./capture.js";
|
|
25
|
+
import { installGoalCapture } from "./goal-capture.js";
|
|
23
26
|
import { renderIndex, renderEsr, esrMethodology, buildEsrSnapshot, buildFrozenEsrBlock } from "./index-block.js";
|
|
24
27
|
import { makeTriggerRecorder } from "./trigger.js";
|
|
28
|
+
import { pendingTodosFromSession, sinkPendingTodos } from "./todo-sink.js";
|
|
25
29
|
import { installEngramSettings } from "./settings.js";
|
|
26
30
|
import { makeEngramRoutes } from "./api.js";
|
|
27
|
-
import {
|
|
31
|
+
import { collectPointerContext, mountCompactionEngine } from "./context-gc.js";
|
|
32
|
+
import { provisionWebCompaction } from "./web-provision.js";
|
|
33
|
+
import { writeContextGcStatus } from "./status.js";
|
|
28
34
|
import { workspaceKey as wk } from "./util.js";
|
|
29
35
|
|
|
30
36
|
/** Stable cordis plugin name. */
|
|
@@ -88,9 +94,30 @@ const DEFAULTS = {
|
|
|
88
94
|
minErrorHits: 2,
|
|
89
95
|
// P3: an active task with no update for this many days is surfaced as stale.
|
|
90
96
|
staleTaskDays: 14,
|
|
97
|
+
// Session-end todo continuity (audit follow-up): when a session is disposed
|
|
98
|
+
// while its plan still has pending todos, auto-sink them into ESR as DRAFT
|
|
99
|
+
// tasks (deduped by name, capped by maxTasksPerWorkspace). During the session
|
|
100
|
+
// nothing is auto-written — todo stays cheap/ephemeral and the [ESR] prefix
|
|
101
|
+
// stays stable; the sink only lands drafts, which still need the deliberate
|
|
102
|
+
// esr_claim / esr_task step to become active.
|
|
103
|
+
autoSinkTodosOnEnd: true,
|
|
104
|
+
// Web-plane auto-wiring for Context GC: web sessions resolve `compaction`
|
|
105
|
+
// inside the agent preset's own realm, which a profile patch cannot reach,
|
|
106
|
+
// so the plugin edits every stock-layout preset's `compaction` group once —
|
|
107
|
+
// the default preset plus the whole roster (shipped + user roots) — swapping
|
|
108
|
+
// the `compaction-basic` row for `dsh-engram/compaction` (backup + strict
|
|
109
|
+
// validation + idempotent; a preset with a custom compaction layout — or
|
|
110
|
+
// none, like `minimal` — is never touched). Off = every preset keeps DSH's
|
|
111
|
+
// default LLM summarizer; the host plane (headless/TUI/base) is unaffected.
|
|
112
|
+
autoWebCompaction: true,
|
|
113
|
+
// Memory-to-memory semantics (zero LLM): when ON, an entity-anchored write
|
|
114
|
+
// that carries a replacement/negation cue ("改用 / no longer / switched…")
|
|
115
|
+
// auto-marks the matching older same-entity+kind memory as superseded.
|
|
116
|
+
// OFF by default — explicit `supersedes` is always the primary path.
|
|
117
|
+
autoSupersede: false,
|
|
91
118
|
};
|
|
92
119
|
|
|
93
|
-
function resolveConfig(config) {
|
|
120
|
+
export function resolveConfig(config) {
|
|
94
121
|
const src = config && typeof config === "object" ? config : {};
|
|
95
122
|
const out = { ...DEFAULTS };
|
|
96
123
|
for (const key of Object.keys(DEFAULTS)) {
|
|
@@ -167,6 +194,12 @@ export function apply(ctx, config) {
|
|
|
167
194
|
captureStats: { total: 0, git: 0, file: 0, error: 0 },
|
|
168
195
|
/** Last GC sweep totals (GUI overview observability). */
|
|
169
196
|
gcStats: { lastRun: 0, archivedMemories: 0, archivedTasks: 0, removedLinks: 0 },
|
|
197
|
+
/** Live Context GC status (API overview + durable status file). */
|
|
198
|
+
compactionStatus: {
|
|
199
|
+
host: "pending",
|
|
200
|
+
web: { action: "pending", presets: [] },
|
|
201
|
+
config: { autoWebCompaction: resolved.autoWebCompaction, gcReplacesCompaction: resolved.gcReplacesCompaction, gcNarrative: resolved.gcNarrative },
|
|
202
|
+
},
|
|
170
203
|
/** Recompute the exact [ENGRAM] block for a workspace (GUI overview cost). */
|
|
171
204
|
renderIndexBlock(workspace) {
|
|
172
205
|
if (opened === void 0) return "";
|
|
@@ -313,6 +346,117 @@ export function apply(ctx, config) {
|
|
|
313
346
|
// tools
|
|
314
347
|
disposers.push(registerTools(ctx, service));
|
|
315
348
|
|
|
349
|
+
// Context GC (host plane) — the auto-GC that REPLACES DSH's built-in
|
|
350
|
+
// lossy LLM-summary compaction. Mounts the `compaction` service on THIS
|
|
351
|
+
// fiber (cordis removes it again when the fiber tears down, so reloading /
|
|
352
|
+
// unloading engram rolls back automatically). The profile patch disables
|
|
353
|
+
// the base `compaction-basic` row, making engram the sole provider:
|
|
354
|
+
// `gcReplacesCompaction:true` (default) → ContextGcEngine; `false` → a bare
|
|
355
|
+
// BasicCompactionEngine so the `compaction` service never disappears while
|
|
356
|
+
// dsh-engram is installed. Web profiles mount per-session compaction inside
|
|
357
|
+
// the agent preset's own realm (see README); this host-plane provider
|
|
358
|
+
// covers headless/TUI/base profiles and any host-plane consumer.
|
|
359
|
+
const hostCompactionPromise = mountCompactionEngine(ctx, resolved, {
|
|
360
|
+
readWorkspace: async (workspace, report) => {
|
|
361
|
+
if (!workspace) return null;
|
|
362
|
+
try {
|
|
363
|
+
const domain = await service.getDomain();
|
|
364
|
+
return collectPointerContext(domain, wk(workspace), report);
|
|
365
|
+
} catch {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
disposers.push(() => {
|
|
371
|
+
// The engine's service registration is fiber-scoped and auto-removed on
|
|
372
|
+
// teardown; this only settles the async mount and surfaces late errors.
|
|
373
|
+
void hostCompactionPromise.catch(() => {});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// Context GC status: record the settled host/web facts and flush a durable
|
|
377
|
+
// snapshot to $DSH_HOME/engram/context-gc.status.json once BOTH planes have
|
|
378
|
+
// settled (the web inject may never fire on a profile without agentPresets,
|
|
379
|
+
// so a bounded timer marks the web plane done for host-only profiles).
|
|
380
|
+
let hostSettled = false;
|
|
381
|
+
let webSettled = resolved.autoWebCompaction === false;
|
|
382
|
+
let statusTimer = void 0;
|
|
383
|
+
const flushStatus = () => {
|
|
384
|
+
if (!hostSettled || !webSettled) return;
|
|
385
|
+
if (statusTimer !== void 0) { clearTimeout(statusTimer); statusTimer = void 0; }
|
|
386
|
+
void writeContextGcStatus({
|
|
387
|
+
planes: resolved.autoWebCompaction === false ? ["host"] : ["host", "web"],
|
|
388
|
+
host: service.compactionStatus.host,
|
|
389
|
+
web: service.compactionStatus.web,
|
|
390
|
+
config: service.compactionStatus.config,
|
|
391
|
+
configSource: {
|
|
392
|
+
autoWebCompaction: resolved.autoWebCompaction,
|
|
393
|
+
gcReplacesCompaction: resolved.gcReplacesCompaction,
|
|
394
|
+
gcNarrative: resolved.gcNarrative,
|
|
395
|
+
},
|
|
396
|
+
}).catch(() => {});
|
|
397
|
+
};
|
|
398
|
+
void hostCompactionPromise
|
|
399
|
+
.then((Engine) => {
|
|
400
|
+
hostSettled = true;
|
|
401
|
+
service.compactionStatus.host = Engine
|
|
402
|
+
? (resolved.gcReplacesCompaction ? "context-gc" : "default")
|
|
403
|
+
: "unavailable";
|
|
404
|
+
flushStatus();
|
|
405
|
+
})
|
|
406
|
+
.catch(() => {
|
|
407
|
+
hostSettled = true;
|
|
408
|
+
service.compactionStatus.host = "unavailable";
|
|
409
|
+
flushStatus();
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// Web-plane auto-wiring (Context GC): web sessions resolve `compaction`
|
|
413
|
+
// inside the agent preset's own realm, which a profile patch cannot reach.
|
|
414
|
+
// When the `agentPresets` service is present, swap the stock
|
|
415
|
+
// `compaction-basic` row for `dsh-engram/compaction` in every stock-layout
|
|
416
|
+
// preset — the default one plus the whole roster (shipped + user roots) —
|
|
417
|
+
// so a session composed from any preset gets Context GC. Surgical,
|
|
418
|
+
// idempotent, backed up, validated; a preset with a custom compaction
|
|
419
|
+
// layout (or none) is never touched. The row's engine config is derived
|
|
420
|
+
// from the settings knobs (`gcReplacesCompaction` / `gcNarrative`), so
|
|
421
|
+
// changing them re-provisions the web rows on the next boot. Flip
|
|
422
|
+
// `autoWebCompaction:false` to stop auto-wiring on later boots (it does
|
|
423
|
+
// not un-wire already-wired presets — use `npm run web-compaction:revert`
|
|
424
|
+
// for that). Best-effort: a failure only warns.
|
|
425
|
+
if (resolved.autoWebCompaction !== false) {
|
|
426
|
+
ctx.inject(["agentPresets"], (webCtx) => {
|
|
427
|
+
const agentPresets = webCtx.get("agentPresets");
|
|
428
|
+
provisionWebCompaction(agentPresets, {
|
|
429
|
+
rowConfig: {
|
|
430
|
+
gcReplacesCompaction: resolved.gcReplacesCompaction,
|
|
431
|
+
gcNarrative: resolved.gcNarrative,
|
|
432
|
+
},
|
|
433
|
+
log: (line) => webCtx.logger.info(line),
|
|
434
|
+
})
|
|
435
|
+
.then((report) => {
|
|
436
|
+
webSettled = true;
|
|
437
|
+
service.compactionStatus.web = { action: report.action, presets: report.presets };
|
|
438
|
+
flushStatus();
|
|
439
|
+
})
|
|
440
|
+
.catch((error) => {
|
|
441
|
+
webSettled = true;
|
|
442
|
+
service.compactionStatus.web = { action: "failed", presets: [], error: String(error?.message ?? error) };
|
|
443
|
+
webCtx.logger.warn(`engram web-provision failed: ${String(error?.message ?? error)}`);
|
|
444
|
+
flushStatus();
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
// Host-only profiles never provide `agentPresets`; settle the web plane
|
|
448
|
+
// after a bounded wait so the status flush still happens there.
|
|
449
|
+
statusTimer = setTimeout(() => {
|
|
450
|
+
if (!webSettled) {
|
|
451
|
+
webSettled = true;
|
|
452
|
+
flushStatus();
|
|
453
|
+
}
|
|
454
|
+
}, 15_000);
|
|
455
|
+
setTimeout(() => { statusTimer = void 0; }, 16_000);
|
|
456
|
+
} else {
|
|
457
|
+
flushStatus();
|
|
458
|
+
}
|
|
459
|
+
|
|
316
460
|
// systemPrompt sections (frozen per session)
|
|
317
461
|
const systemPrompt = ctx.get("systemPrompt");
|
|
318
462
|
if (systemPrompt !== void 0) {
|
|
@@ -359,6 +503,38 @@ export function apply(ctx, config) {
|
|
|
359
503
|
});
|
|
360
504
|
disposers.push(disposeTrigger);
|
|
361
505
|
|
|
506
|
+
// session-end todo auto-sink: a session whose plan still has pending todos
|
|
507
|
+
// lands them as ESR drafts at its teardown edge, so the plan survives the
|
|
508
|
+
// session (flip `autoSinkTodosOnEnd` off to opt out). Live-toggleable — the
|
|
509
|
+
// flag is read per event. Fully contained: a sink failure only warns.
|
|
510
|
+
const disposeSink = ctx.on("session/disposed", (session) => {
|
|
511
|
+
if (resolved.autoSinkTodosOnEnd === false) return;
|
|
512
|
+
const cwd = session?.header?.cwd;
|
|
513
|
+
const sessionId = session?.id;
|
|
514
|
+
if (typeof cwd !== "string" || cwd.length === 0) return;
|
|
515
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) return;
|
|
516
|
+
const todos = pendingTodosFromSession(session);
|
|
517
|
+
if (todos.length === 0) return;
|
|
518
|
+
const workspace = wk(cwd);
|
|
519
|
+
service
|
|
520
|
+
.getDomain()
|
|
521
|
+
.then((domain) =>
|
|
522
|
+
sinkPendingTodos(domain, workspace, todos, {
|
|
523
|
+
sessionId,
|
|
524
|
+
maxTasks: resolved.maxTasksPerWorkspace,
|
|
525
|
+
}),
|
|
526
|
+
)
|
|
527
|
+
.then((report) => {
|
|
528
|
+
if (report.created.length > 0) {
|
|
529
|
+
log?.info?.(`engram todo auto-sink: ${report.created.length} pending todo(s) → ${report.created.length} ESR draft(s) in ${workspace}`);
|
|
530
|
+
} else if (report.existed.length > 0 || report.atCap.length > 0) {
|
|
531
|
+
log?.info?.(`engram todo auto-sink: ${report.existed.length} already in ESR, ${report.atCap.length} skipped (cap) in ${workspace}`);
|
|
532
|
+
}
|
|
533
|
+
})
|
|
534
|
+
.catch((error) => log?.warn?.(`engram todo auto-sink failed: ${String(error)}`));
|
|
535
|
+
});
|
|
536
|
+
disposers.push(disposeSink);
|
|
537
|
+
|
|
362
538
|
// zero-LLM auto-capture
|
|
363
539
|
if (resolved.autoCapture) {
|
|
364
540
|
// The capture handler needs a storeMemory face, but the domain opens
|
|
@@ -374,12 +550,28 @@ export function apply(ctx, config) {
|
|
|
374
550
|
}
|
|
375
551
|
return domain.storeMemory(...args);
|
|
376
552
|
},
|
|
553
|
+
// failure→fix closure reads (sync; [] until the domain is open)
|
|
554
|
+
listMemories(workspace, limit) {
|
|
555
|
+
const domain = service.openedDomain();
|
|
556
|
+
return domain === void 0 ? [] : domain.listMemories(workspace, limit);
|
|
557
|
+
},
|
|
558
|
+
// failure→fix closure tags prior error rows as resolved (async)
|
|
559
|
+
tagMemory(workspace, id, tag) {
|
|
560
|
+
const domain = service.openedDomain();
|
|
561
|
+
return domain === void 0 ? Promise.resolve(false) : domain.tagMemory(workspace, id, tag);
|
|
562
|
+
},
|
|
377
563
|
};
|
|
378
564
|
const capture = makeCaptureHandler(captureStore, resolved, log, service.captureStats);
|
|
379
565
|
const disposeCapture = ctx.on("tools/result", (exec, result) => {
|
|
380
566
|
capture(exec, result);
|
|
381
567
|
});
|
|
382
568
|
disposers.push(disposeCapture);
|
|
569
|
+
|
|
570
|
+
// DSH goal-domain integration: a completed/blocked goal auto-sediments
|
|
571
|
+
// as a memory (`goal/change` session event → handoff/error row), so the
|
|
572
|
+
// outcome survives the session without any LLM call.
|
|
573
|
+
const disposeGoal = installGoalCapture(ctx, captureStore, log);
|
|
574
|
+
disposers.push(disposeGoal);
|
|
383
575
|
}
|
|
384
576
|
|
|
385
577
|
// memory GC scheduling: one sweep after the store is reachable, then on
|
package/lib/redact.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-engram: deterministic secret redaction for the memory write path.
|
|
3
|
+
*
|
|
4
|
+
* Auto-captured tool results, git commit subjects and error messages can
|
|
5
|
+
* carry real credentials (API keys, tokens, private keys) into long-term
|
|
6
|
+
* memory — and later straight back into a prompt via recall/[ENGRAM]. This
|
|
7
|
+
* module strips them BEFORE persistence so nothing sensitive is ever stored.
|
|
8
|
+
*
|
|
9
|
+
* Rule-based and deterministic (same input ⇒ same output, zero deps, zero
|
|
10
|
+
* LLM). Replacement markers are derived from the secret kind, so exact
|
|
11
|
+
* duplicate detection and error-revival hashing keep behaving consistently
|
|
12
|
+
* across runs. Applied once at the single store write choke point
|
|
13
|
+
* (`storeMemory`), which covers engram_store, auto-capture and any future
|
|
14
|
+
* write path.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const RULES = [
|
|
18
|
+
// Private key blocks (longest first so later rules cannot see them).
|
|
19
|
+
[/-----BEGIN [A-Z0-9 ]+PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]+PRIVATE KEY-----/g, "<REDACTED:private-key>"],
|
|
20
|
+
// JWT (header.payload.signature).
|
|
21
|
+
[/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, "<REDACTED:jwt>"],
|
|
22
|
+
// Stripe live/test keys.
|
|
23
|
+
[/\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b/g, "<REDACTED:stripe>"],
|
|
24
|
+
// OpenAI/Anthropic/OpenRouter style sk-… keys.
|
|
25
|
+
[/\bsk-(?:ant-)?[A-Za-z0-9_-]{16,}\b/g, "<REDACTED:sk>"],
|
|
26
|
+
// GitHub fine-grained / classic tokens.
|
|
27
|
+
[/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, "<REDACTED:github>"],
|
|
28
|
+
// AWS access key IDs.
|
|
29
|
+
[/\b(?:AKIA|ASIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA)[0-9A-Z]{16}\b/g, "<REDACTED:aws>"],
|
|
30
|
+
// Slack workspace tokens.
|
|
31
|
+
[/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, "<REDACTED:slack>"],
|
|
32
|
+
// Basic auth user:pass inside URLs.
|
|
33
|
+
[/\/\/[^:\/\s]+:[^@\s\/]+@/g, "//<REDACTED>@"],
|
|
34
|
+
// Authorization: Bearer <long token>.
|
|
35
|
+
[/\bBearer\s+[A-Za-z0-9._~+\/-]{16,}\b/gi, "<REDACTED:bearer>"],
|
|
36
|
+
// Generic key=value / key: value assignments for obvious secret keys.
|
|
37
|
+
[/\b(?:api[_-]?key|apikey|access[_-]?token|auth[_-]?token|client[_-]?secret|secret|passwd|password|persist[_-]?token|refresh[_-]?token|token)\b\s*[:=]\s*['"]?[A-Za-z0-9_\-.\/+=]{6,}['"]?/gi, "<REDACTED:key>"],
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Redact known secret shapes in `text`. Deterministic: identical input yields
|
|
42
|
+
* identical output. Non-secret prose is untouched.
|
|
43
|
+
*/
|
|
44
|
+
export function redactText(text) {
|
|
45
|
+
let out = String(text ?? "");
|
|
46
|
+
for (const [re, repl] of RULES) out = out.replace(re, repl);
|
|
47
|
+
return out;
|
|
48
|
+
}
|