dsh-engram 0.4.0 → 0.5.0
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 +134 -17
- package/README.zh.md +103 -15
- package/docs/PROPOSAL-resultpack.zh.md +7 -3
- package/docs/RELEASE-NOTES-v0.5.0.md +58 -0
- package/lib/api.js +19 -6
- package/lib/boundary-prune.js +113 -13
- package/lib/client.js +12 -0
- package/lib/index-block.js +27 -5
- package/lib/index.js +181 -5
- package/lib/pack-telemetry.js +115 -0
- package/lib/preference.js +276 -0
- package/lib/result-pack.js +276 -0
- package/lib/settings.js +115 -31
- package/lib/store.js +1 -0
- package/lib/tools.js +2 -1
- package/package.json +8 -7
- package/scripts/prepublish-check.mjs +7 -4
package/lib/boundary-prune.js
CHANGED
|
@@ -128,7 +128,7 @@ export function expectedSessionRequests(requestsSoFar, history, workspace, floor
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
/** Install the boundary-prune listener. Returns a disposer. */
|
|
131
|
-
export function installBoundaryPrune(ctx, config, log) {
|
|
131
|
+
export function installBoundaryPrune(ctx, config, log, hooks = {}, opts = {}) {
|
|
132
132
|
const enabled = config?.boundaryPrune === true;
|
|
133
133
|
if (!enabled) return () => {};
|
|
134
134
|
const pruner = ctx.get?.("toolResultPruner");
|
|
@@ -139,7 +139,44 @@ export function installBoundaryPrune(ctx, config, log) {
|
|
|
139
139
|
const minRemaining = Number.isFinite(config?.boundaryPruneMinRemaining)
|
|
140
140
|
? config.boundaryPruneMinRemaining
|
|
141
141
|
: DEFAULT_MIN_REMAINING;
|
|
142
|
-
|
|
142
|
+
// History is injectable for tests; production reads the real session corpus.
|
|
143
|
+
const history = opts?.history ?? workspaceHistory();
|
|
144
|
+
|
|
145
|
+
// Optional threshold override (ResultPack L0, T2048 landing): the host pruner
|
|
146
|
+
// ships a fixed threshold (8192) that the plugin cannot change in place. When
|
|
147
|
+
// `boundaryPruneThresholdChars` is set, we instantiate our own pruner at that
|
|
148
|
+
// threshold — same deterministic head/middle/tail engine, lower budget — via
|
|
149
|
+
// the exported `ToolResultPruner` prototype (bypassing the cordis Service
|
|
150
|
+
// constructor, which needs a full context; the instance only uses its own
|
|
151
|
+
// `config` + `ctx.tokenMeter`). Falls back to the host pruner when the package
|
|
152
|
+
// or the token meter is unavailable. `boundaryPruneHeadChars` /
|
|
153
|
+
// `boundaryPruneTailChars` tune the retained edges (defaults stay at the
|
|
154
|
+
// host's 4096/1024).
|
|
155
|
+
let activePruner = pruner;
|
|
156
|
+
const thresholdOverride = Number.isFinite(config?.boundaryPruneThresholdChars)
|
|
157
|
+
? Math.trunc(config.boundaryPruneThresholdChars)
|
|
158
|
+
: null;
|
|
159
|
+
if (thresholdOverride !== null && thresholdOverride > 0) {
|
|
160
|
+
activePruner = makeThresholdPruner(pruner, {
|
|
161
|
+
thresholdChars: thresholdOverride,
|
|
162
|
+
headChars: Number.isFinite(config?.boundaryPruneHeadChars) && config.boundaryPruneHeadChars > 0
|
|
163
|
+
? Math.trunc(config.boundaryPruneHeadChars)
|
|
164
|
+
: 4096,
|
|
165
|
+
tailChars: Number.isFinite(config?.boundaryPruneTailChars) && config.boundaryPruneTailChars > 0
|
|
166
|
+
? Math.trunc(config.boundaryPruneTailChars)
|
|
167
|
+
: 1024,
|
|
168
|
+
});
|
|
169
|
+
if (activePruner === null) {
|
|
170
|
+
log?.warn?.(
|
|
171
|
+
`engram boundary-prune: threshold override ${thresholdOverride} unavailable (pruner package/tokenMeter) — using host pruner ${pruner.config?.thresholdChars ?? "default"}`,
|
|
172
|
+
);
|
|
173
|
+
activePruner = pruner;
|
|
174
|
+
} else {
|
|
175
|
+
log?.info?.(
|
|
176
|
+
`engram boundary-prune: using threshold ${activePruner.config?.thresholdChars} (head ${activePruner.config?.headChars} / tail ${activePruner.config?.tailChars})`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
143
180
|
|
|
144
181
|
/** Per-session boundary state (todo high-water mark, request count, total prunes). */
|
|
145
182
|
const stateBySession = new Map();
|
|
@@ -150,12 +187,32 @@ export function installBoundaryPrune(ctx, config, log) {
|
|
|
150
187
|
if (typeof sid !== "string" || sid.length === 0) return;
|
|
151
188
|
let state = stateBySession.get(sid);
|
|
152
189
|
if (!state) {
|
|
153
|
-
state = { todoCompleted: 0, requests: 0, prunes: 0, prunedResults: 0 };
|
|
190
|
+
state = { todoCompleted: 0, requests: 0, prunes: 0, prunedResults: 0, lastFireRequest: void 0 };
|
|
154
191
|
stateBySession.set(sid, state);
|
|
155
192
|
}
|
|
156
193
|
const type = event?.type;
|
|
157
194
|
if (type === "assistant/message") {
|
|
158
195
|
state.requests += 1;
|
|
196
|
+
// Request-count boundary (SoL-Pi "step idle", default OFF): sessions
|
|
197
|
+
// with no todo progress / no goal never fire the event boundaries (L0
|
|
198
|
+
// corpus: ~62% of sessions), leaving their over-threshold replay
|
|
199
|
+
// exposure permanently untouched. When `boundaryPruneIdleCadence` is
|
|
200
|
+
// set, the counter itself is a boundary source: fire at most once per
|
|
201
|
+
// cadence requests, still under the repayment gate (so cadence ≠
|
|
202
|
+
// aggressive — the gate keeps per-session reuse healthy). The implied
|
|
203
|
+
// boundary kind is `step` so telemetry can attribute it.
|
|
204
|
+
const cadence = config?.boundaryPruneIdleCadence;
|
|
205
|
+
if (Number.isFinite(cadence) && cadence > 0 && state.requests % Math.trunc(cadence) === 0) {
|
|
206
|
+
const sinceLastPrune = state.lastFireRequest === void 0 ? state.requests : state.requests - state.lastFireRequest;
|
|
207
|
+
if (sinceLastPrune >= Math.trunc(cadence)) {
|
|
208
|
+
const workspace = wk(session?.header?.cwd);
|
|
209
|
+
const expectedTotal = expectedSessionRequests(state.requests, history, workspace, minRemaining);
|
|
210
|
+
if (state.requests + minRemaining <= expectedTotal) {
|
|
211
|
+
// reuse the same fire path via a synthetic boundary
|
|
212
|
+
return fireBoundary(session, { kind: "step" }, state, expectedTotal);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
159
216
|
return;
|
|
160
217
|
}
|
|
161
218
|
const boundary = isBoundary(event, state);
|
|
@@ -168,21 +225,43 @@ export function installBoundaryPrune(ctx, config, log) {
|
|
|
168
225
|
const workspace = wk(session?.header?.cwd);
|
|
169
226
|
const expectedTotal = expectedSessionRequests(state.requests, history, workspace, minRemaining);
|
|
170
227
|
if (state.requests + minRemaining > expectedTotal) return;
|
|
171
|
-
|
|
172
|
-
const result = pruner.pruneSession(session);
|
|
173
|
-
const prunedNow = result?.pruned?.length ?? 0;
|
|
174
|
-
if (prunedNow > 0) {
|
|
175
|
-
state.prunes += 1;
|
|
176
|
-
state.prunedResults += prunedNow;
|
|
177
|
-
log?.info?.(
|
|
178
|
-
`engram boundary-prune (${boundary.kind} @${state.requests} reqs): pruned ${prunedNow} results, saved ${result.charsRemoved} chars`,
|
|
179
|
-
);
|
|
180
|
-
}
|
|
228
|
+
return fireBoundary(session, boundary, state, expectedTotal);
|
|
181
229
|
} catch (error) {
|
|
182
230
|
// fully contained — a prune bug must never disturb the session pipeline
|
|
183
231
|
log?.warn?.(`engram boundary-prune threw: ${String(error)}`);
|
|
184
232
|
}
|
|
185
233
|
};
|
|
234
|
+
|
|
235
|
+
/** Shared fire path for every boundary source (event + step-cadence). */
|
|
236
|
+
const fireBoundary = (session, boundary, state, expectedTotal) => {
|
|
237
|
+
const result = activePruner.pruneSession(session);
|
|
238
|
+
const prunedNow = result?.pruned?.length ?? 0;
|
|
239
|
+
if (prunedNow > 0) {
|
|
240
|
+
state.prunes += 1;
|
|
241
|
+
state.prunedResults += prunedNow;
|
|
242
|
+
state.lastFireRequest = state.requests;
|
|
243
|
+
log?.info?.(
|
|
244
|
+
`engram boundary-prune (${boundary.kind} @${state.requests} reqs): pruned ${prunedNow} results, saved ${result.charsRemoved} chars`,
|
|
245
|
+
);
|
|
246
|
+
// L2 hook (ResultPack): archiving + handle tail is wired by index.js
|
|
247
|
+
// when `resultPack` is enabled. Contained there — never throws here.
|
|
248
|
+
// Attach the live pruner budgets so the archiver extracts the exact
|
|
249
|
+
// removed span (archives must match what the surface now displays).
|
|
250
|
+
try {
|
|
251
|
+
const budgets = {
|
|
252
|
+
headChars: activePruner.config?.headChars ?? 4096,
|
|
253
|
+
tailChars: activePruner.config?.tailChars ?? 1024,
|
|
254
|
+
};
|
|
255
|
+
for (const item of result?.pruned ?? []) {
|
|
256
|
+
if (item && typeof item === "object") Object.assign(item, budgets);
|
|
257
|
+
}
|
|
258
|
+
hooks?.onPruned?.(session, result, boundary);
|
|
259
|
+
} catch (error) {
|
|
260
|
+
log?.warn?.(`engram boundary-prune: L2 hook threw: ${String(error)}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
void expectedTotal;
|
|
264
|
+
};
|
|
186
265
|
const disposeListener = ctx.on("session/event", onEvent);
|
|
187
266
|
const disposeSink = ctx.on("session/disposed", (session) => {
|
|
188
267
|
stateBySession.delete(session?.id);
|
|
@@ -192,3 +271,24 @@ export function installBoundaryPrune(ctx, config, log) {
|
|
|
192
271
|
disposeSink?.();
|
|
193
272
|
};
|
|
194
273
|
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Build a pruner at an override threshold by prototyping off the injected
|
|
277
|
+
* host service instance.
|
|
278
|
+
*
|
|
279
|
+
* The host `toolResultPruner` ships a fixed budget (8192/4096/1024) that
|
|
280
|
+
* cannot be mutated in place. DSH's pruner is a plain prototype-owning class
|
|
281
|
+
* whose methods only read `this.config` and `this.ctx.tokenMeter`, so we
|
|
282
|
+
* create a child object inheriting the host instance (methods + ctx shared,
|
|
283
|
+
* config overridden on the child). Zero LLM, deterministic, contained — the
|
|
284
|
+
* host instance and its config stay untouched.
|
|
285
|
+
*/
|
|
286
|
+
function makeThresholdPruner(pruner, cfg) {
|
|
287
|
+
try {
|
|
288
|
+
const clone = Object.create(pruner);
|
|
289
|
+
clone.config = cfg;
|
|
290
|
+
return clone;
|
|
291
|
+
} catch {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -2209,6 +2209,7 @@ function EngramTelemetry({ api, workspace }) {
|
|
|
2209
2209
|
var import_react6 = require("react");
|
|
2210
2210
|
var import_jsx_runtime6 = require("react/jsx-runtime");
|
|
2211
2211
|
var KIND_COLORS = {
|
|
2212
|
+
preference: "#b45309",
|
|
2212
2213
|
decision: "#2563eb",
|
|
2213
2214
|
error: "#dc2626",
|
|
2214
2215
|
procedure: "#7c3aed",
|
|
@@ -2218,6 +2219,7 @@ var KIND_COLORS = {
|
|
|
2218
2219
|
task: "#4f46e5"
|
|
2219
2220
|
};
|
|
2220
2221
|
var KIND_LABEL = {
|
|
2222
|
+
preference: "\u504F\u597D",
|
|
2221
2223
|
decision: "\u51B3\u5B9A",
|
|
2222
2224
|
error: "\u9519\u8BEF",
|
|
2223
2225
|
procedure: "\u6D41\u7A0B",
|
|
@@ -2567,6 +2569,7 @@ function EngramDetail({ target, api, tasks, nodes, links, onClose, onNavigateMem
|
|
|
2567
2569
|
// client/src/EngramSection.tsx
|
|
2568
2570
|
var import_jsx_runtime7 = require("react/jsx-runtime");
|
|
2569
2571
|
var KIND_COLORS2 = {
|
|
2572
|
+
preference: "#b45309",
|
|
2570
2573
|
decision: "#2563eb",
|
|
2571
2574
|
error: "#dc2626",
|
|
2572
2575
|
procedure: "#7c3aed",
|
|
@@ -2576,6 +2579,7 @@ var KIND_COLORS2 = {
|
|
|
2576
2579
|
task: "#4f46e5"
|
|
2577
2580
|
};
|
|
2578
2581
|
var KIND_LABEL2 = {
|
|
2582
|
+
preference: "\u504F\u597D",
|
|
2579
2583
|
decision: "\u51B3\u5B9A",
|
|
2580
2584
|
error: "\u9519\u8BEF",
|
|
2581
2585
|
procedure: "\u6D41\u7A0B",
|
|
@@ -2995,6 +2999,7 @@ function EngramSection({ api, t }) {
|
|
|
2995
2999
|
/* @__PURE__ */ (0, import_jsx_runtime7.jsx)(StatCard, { num: String(wsCounts ? wsCounts.nodes ?? 0 : overview.totals.nodes ?? 0), label: wsCounts ? "\u8282\u70B9" : "\u8282\u70B9 (\u5168\u5C40)" }),
|
|
2996
3000
|
/* @__PURE__ */ (0, import_jsx_runtime7.jsx)(StatCard, { num: String(workspaces.length), label: "\u5DE5\u4F5C\u533A" }),
|
|
2997
3001
|
/* @__PURE__ */ (0, import_jsx_runtime7.jsx)(StatCard, { num: String(overview.captures.total), label: "\u81EA\u52A8\u6355\u83B7" }),
|
|
3002
|
+
/* @__PURE__ */ (0, import_jsx_runtime7.jsx)(StatCard, { num: String(overview.captures.preference ?? 0), label: "\u504F\u597D\u6355\u83B7" }),
|
|
2998
3003
|
/* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
|
|
2999
3004
|
StatCard,
|
|
3000
3005
|
{
|
|
@@ -3369,6 +3374,10 @@ var import_react8 = require("react");
|
|
|
3369
3374
|
var import_jsx_runtime8 = require("react/jsx-runtime");
|
|
3370
3375
|
var COMMON_FIELDS = [
|
|
3371
3376
|
{ key: "autoCapture", label: "\u81EA\u52A8\u6355\u83B7", hint: "\u96F6 LLM \u4ECE\u5DE5\u5177\u7ED3\u679C\u63D0\u53D6\u8BB0\u5FC6\uFF08git/\u5173\u952E\u6587\u4EF6/\u9519\u8BEF\uFF09", kind: "bool" },
|
|
3377
|
+
{ key: "autoCapturePreferences", label: "\u504F\u597D\u6355\u83B7", hint: "\u7528\u6237\u7EA0\u6B63/\u504F\u597D\u5B66\u4E60\uFF1A\u68C0\u6D4B\u6D88\u606F\u91CC\u7684\u300C\u4E0D\u8981\u7528X/\u6539\u7528Y/prefer/never use\u300D\u53E5\u5F0F\uFF0C\u81EA\u52A8\u6C89\u6DC0\u4E3A preference \u8BB0\u5FC6\u5E76\u7F6E\u9876 [ENGRAM]\uFF08correx/PAHF \u5F0F\uFF0C\u96F6 LLM\uFF09", kind: "bool" },
|
|
3378
|
+
{ key: "boundaryPrune", label: "\u8FB9\u754C\u526A\u679D", hint: "ResultPack L1\uFF1Atodo \u5B8C\u6210/goal \u7EC8\u6001\u8FB9\u754C\u4E3B\u52A8\u526A\u6389\u8D85\u9608\u503C\u65E7\u5DE5\u5177\u7ED3\u679C\uFF08DSH \u786E\u5B9A\u6027 surface-replace\u3001\u507F\u8FD8\u95E8\u3001\u96F6 LLM\uFF1B\u9ED8\u8BA4\u5173\uFF09", kind: "bool" },
|
|
3379
|
+
{ key: "boundaryPruneThresholdChars", label: "\u526A\u679D\u9608\u503C(\u5B57\u7B26)", hint: "\u8986\u76D6\u5BBF\u4E3B 8192\uFF1A\u8BBE 2048 \u53EF\u66F4\u65E9\u5F00\u526A\uFF0C\u5E76\u914D L2 \u5F52\u6863\u627E\u56DE\u88AB\u526A\u4E2D\u6BB5\uFF1B\u7559\u7A7A=\u5BBF\u4E3B\u9ED8\u8BA4\u3002\u5EFA\u8BAE\u540C\u65F6\u628A\u4FDD\u7559\u8FB9\u7F18\u8C03\u5C0F\uFF08\u5982 1024/256\uFF09\u4EE5\u83B7\u5F97\u6700\u5927\u8282\u7701", kind: "num", min: 256, max: 16384, step: 256 },
|
|
3380
|
+
{ key: "resultPack", label: "\u7ED3\u679C\u5F52\u6863\u53EC\u56DE", hint: "ResultPack L2\uFF1A\u526A\u6389\u7684\u4E2D\u6BB5\u8131\u654F\u5F52\u6863\uFF0C\u8282\u70B9\u5C3E\u90E8\u9644 handle\uFF0C\u6A21\u578B\u53EF\u7528 result_recall(callId) \u6309\u9875\u53EC\u56DE\uFF08\u5B8C\u6574 ObservationPack\uFF1B\u9700\u4E0E\u8FB9\u754C\u526A\u679D\u4E00\u8D77\u5F00\uFF09", kind: "bool" },
|
|
3372
3381
|
{ key: "autoRecallOnStart", label: "\u4F1A\u8BDD\u542F\u52A8\u81EA\u52A8\u53EC\u56DE", hint: "\u65B0\u4F1A\u8BDD\u6309\u9996\u6761\u6D88\u606F\u81EA\u52A8\u628A\u6700\u76F8\u5173\u8BB0\u5FC6\u6CE8\u5165 [RECALL] \u5757\uFF08\u65E0\u9700\u6A21\u578B\u60F3\u8D77\u8C03 engram_recall\uFF09\uFF1B\u5173\u6389=\u56DE\u5230\u7EAF\u62C9\u53D6", kind: "bool" },
|
|
3373
3382
|
{ key: "maxRecallPerSession", label: "\u6BCF\u4F1A\u8BDD\u53BB\u91CD\u4E0A\u9650", hint: "recall \u6BCF\u4F1A\u8BDD\u6700\u591A\u4FDD\u7559 N \u6761\uFF08\u8DE8\u4F1A\u8BDD\u53BB\u91CD\uFF09", kind: "num", min: 1, max: 10 },
|
|
3374
3383
|
{ key: "expireDays", label: "TTL\uFF08\u5929\uFF09", hint: "0 = \u4E0D\u8FC7\u671F", kind: "num", min: 0, max: 3650 },
|
|
@@ -3381,6 +3390,9 @@ var ADVANCED_GROUPS = [
|
|
|
3381
3390
|
description: "\u5176\u4F59\u6355\u83B7/\u641C\u7D22\u65CB\u94AE",
|
|
3382
3391
|
fields: [
|
|
3383
3392
|
{ key: "autoCapturePerSession", label: "\u6BCF\u4F1A\u8BDD\u6355\u83B7\u4E0A\u9650", hint: "\u5355\u4F1A\u8BDD\u81EA\u52A8\u6355\u83B7\u6761\u6570\u4E0A\u9650", kind: "num", min: 0, max: 1e3 },
|
|
3393
|
+
{ key: "boundaryPruneHeadChars", label: "\u526A\u679D\u4FDD\u7559\u5934\u90E8(\u5B57\u7B26)", hint: "\u9608\u503C\u8986\u76D6\u751F\u6548\u65F6\u7ED3\u679C\u5F00\u5934\u4FDD\u7559\u957F\u5EA6\uFF08\u9ED8\u8BA4 4096\uFF09\uFF1B\u8C03\u5C0F\uFF08\u5982 1024\uFF09\u914D\u5408 T2048 \u66F4\u7701", kind: "num", min: 0, max: 16384, step: 128 },
|
|
3394
|
+
{ key: "boundaryPruneTailChars", label: "\u526A\u679D\u4FDD\u7559\u5C3E\u90E8(\u5B57\u7B26)", hint: "\u9608\u503C\u8986\u76D6\u751F\u6548\u65F6\u7ED3\u679C\u7ED3\u5C3E\u4FDD\u7559\u957F\u5EA6\uFF08\u9ED8\u8BA4 1024\uFF09\uFF1B\u8C03\u5C0F\uFF08\u5982 256\uFF09\u914D\u5408 T2048 \u66F4\u7701", kind: "num", min: 0, max: 16384, step: 128 },
|
|
3395
|
+
{ key: "boundaryPruneIdleCadence", label: "\u7A7A\u95F2\u6B65\u6570\u8FB9\u754C", hint: "\u6700\u591A\u6BCF N \u8BF7\u6C42\u89E6\u53D1\u4E00\u6B21\u526A\u679D\uFF0C\u8986\u76D6\u65E0 todo/goal \u7684\u4F1A\u8BDD\uFF08\u7EA6 62% \u4F1A\u8BDD\uFF09\uFF1B\u4ECD\u53D7\u507F\u8FD8\u95E8\u7EA6\u675F\uFF1B\u7559\u7A7A=\u4EC5\u4E8B\u4EF6\u8FB9\u754C", kind: "num", min: 10, max: 1e3, step: 10 },
|
|
3384
3396
|
{ key: "autoRecallLimit", label: "\u81EA\u52A8\u53EC\u56DE\u6761\u6570", hint: "[RECALL] \u5757\u6700\u591A\u6CE8\u5165\u7684\u8BB0\u5FC6\u6761\u6570\uFF08\u5C11\u800C\u51C6\uFF09", kind: "num", min: 1, max: 8 },
|
|
3385
3397
|
{ key: "sessionSearch", label: "\u4F1A\u8BDD\u5386\u53F2\u641C\u7D22", hint: "engram_recall \u652F\u6301\u8DE8\u4F1A\u8BDD FTS \u515C\u5E95", kind: "bool" },
|
|
3386
3398
|
{ key: "recallScope", label: "\u53EC\u56DE\u8303\u56F4", hint: "workspace=\u4E25\u683C\u5F53\u524D\u5DE5\u4F5C\u533A\u9694\u79BB\uFF08\u9ED8\u8BA4\uFF0Ctoken \u7EAA\u5F8B\uFF09\uFF1Bglobal=\u672C\u5DE5\u4F5C\u533A\u547D\u4E2D\u5F31/\u7A7A\u65F6\u4ECE\u5176\u5B83\u5DE5\u4F5C\u533A\u6C60\u53EC\u56DE\u5E76\u6807 [W:<workspace>] \u6765\u6E90\uFF08\u4F1A\u6253\u7834\u9694\u79BB\uFF09", kind: "select", options: ["workspace", "global"] }
|
package/lib/index-block.js
CHANGED
|
@@ -20,6 +20,7 @@ export function prefixFor(kind) {
|
|
|
20
20
|
case "insight": return "I";
|
|
21
21
|
case "handoff": return "H";
|
|
22
22
|
case "task": return "T";
|
|
23
|
+
case "preference": return "R";
|
|
23
24
|
default: return "F";
|
|
24
25
|
}
|
|
25
26
|
}
|
|
@@ -65,14 +66,24 @@ export function selectIndexMemories(store, workspace, config) {
|
|
|
65
66
|
m.kind === "task"),
|
|
66
67
|
)
|
|
67
68
|
.sort((a, b) => {
|
|
68
|
-
|
|
69
|
-
|
|
69
|
+
// User preferences are standing rules — rank them above everything
|
|
70
|
+
// (still newest-first within the group). Proven procedures follow.
|
|
71
|
+
const ap = kindRank(a, config) ? 0 : 1;
|
|
72
|
+
const bp = kindRank(b, config) ? 0 : 1;
|
|
70
73
|
if (ap !== bp) return ap - bp;
|
|
74
|
+
const ap2 = isProvenProcedure(a, config) ? 0 : 1;
|
|
75
|
+
const bp2 = isProvenProcedure(b, config) ? 0 : 1;
|
|
76
|
+
if (ap2 !== bp2) return ap2 - bp2;
|
|
71
77
|
return byRecency(a, b);
|
|
72
78
|
})
|
|
73
79
|
.slice(0, config.indexMaxLines);
|
|
74
80
|
}
|
|
75
81
|
|
|
82
|
+
/** Preference memories outrank everything in the [ENGRAM] index block. */
|
|
83
|
+
function kindRank(m, config) {
|
|
84
|
+
return (m?.kind ?? "") === "preference" && (m.signal ?? 0.5) >= (config?.minIndexSignal ?? 0.4);
|
|
85
|
+
}
|
|
86
|
+
|
|
76
87
|
function memoryLine(m, config) {
|
|
77
88
|
const px = prefixFor(m.kind);
|
|
78
89
|
const mark = isProvenProcedure(m, config) ? `${px}✓` : px;
|
|
@@ -199,14 +210,25 @@ export function renderStartRecall(store, workspace, config = {}, opts = {}) {
|
|
|
199
210
|
const lines = [];
|
|
200
211
|
const source = query === null ? "newest memory" : "recall";
|
|
201
212
|
lines.push(`[RECALL] ${source} · ${fresh.length} hit(s)${query ? ` · first msg: ${escapeLt(truncate(query, 40))}` : " · no user text yet"}`);
|
|
213
|
+
// UAP (read-path activation): when a hit's oneline is truncated, say so —
|
|
214
|
+
// the model should know the full record is one `engram_detail` call away
|
|
215
|
+
// instead of assuming the 140-char preview is the whole memory. The trailing
|
|
216
|
+
// guidance then points at a concrete truncated id (or the top hit).
|
|
217
|
+
let truncatedId = null;
|
|
202
218
|
for (const m of fresh) {
|
|
203
219
|
const px = prefixFor(m.kind);
|
|
204
220
|
const mark = isProvenProcedure(m, config) ? `${px}✓` : px;
|
|
205
221
|
const date = fmtDate(m.createdAt ?? m.updatedAt);
|
|
206
|
-
const
|
|
207
|
-
|
|
222
|
+
const full = String(m.text).replace(/\s*\n+\s*/g, " ").trim();
|
|
223
|
+
const truncated = [...full].length > 140;
|
|
224
|
+
const text = truncate(full, 140);
|
|
225
|
+
if (truncated && truncatedId === null) truncatedId = shortId(m.id);
|
|
226
|
+
lines.push(`- [${mark}] ${date} ${escapeLt(text)} #${shortId(m.id)}${truncated ? " …" : ""}${(m.hits ?? 0) > 0 ? ` ×${m.hits}` : ""}`);
|
|
208
227
|
}
|
|
209
|
-
|
|
228
|
+
const drill = truncatedId !== null
|
|
229
|
+
? `context: use above · #${truncatedId} truncated — engram_detail ${truncatedId} (full) · engram_recall <query>`
|
|
230
|
+
: `context: use above · engram_detail <id> · engram_recall <query>`;
|
|
231
|
+
lines.push(drill);
|
|
210
232
|
return enforceStartRecallCap(lines, maxChars);
|
|
211
233
|
}
|
|
212
234
|
|
package/lib/index.js
CHANGED
|
@@ -23,11 +23,14 @@ import { openEngramDomain } from "./store.js";
|
|
|
23
23
|
import { registerTools } from "./tools.js";
|
|
24
24
|
import { makeCaptureHandler } from "./capture.js";
|
|
25
25
|
import { installGoalCapture } from "./goal-capture.js";
|
|
26
|
+
import { installPreferenceCapture } from "./preference.js";
|
|
26
27
|
import { installBoundaryPrune } from "./boundary-prune.js";
|
|
28
|
+
import { resultPackDir, archivePrunedResults, resultRecallTool, sweepExpired } from "./result-pack.js";
|
|
29
|
+
import { recordBoundaryPruneFire, recordRecallInjection, readPackTelemetry, engramHome } from "./pack-telemetry.js";
|
|
27
30
|
import { renderIndex, renderStartRecall, firstUserMessageText, renderEsr, esrMethodology, buildEsrSnapshot, buildFrozenEsrBlock } from "./index-block.js";
|
|
28
31
|
import { makeTriggerRecorder } from "./trigger.js";
|
|
29
32
|
import { pendingTodosFromSession, sinkPendingTodos } from "./todo-sink.js";
|
|
30
|
-
import { installEngramSettings } from "./settings.js";
|
|
33
|
+
import { installEngramSettings, makeEngineConfigSchema, SETTINGS_KEYS } from "./settings.js";
|
|
31
34
|
import { makeEngramRoutes } from "./api.js";
|
|
32
35
|
import { collectPointerContext, mountCompactionEngine } from "./context-gc.js";
|
|
33
36
|
import { provisionWebCompaction } from "./web-provision.js";
|
|
@@ -68,6 +71,11 @@ const DEFAULTS = {
|
|
|
68
71
|
autoRecallOnStart: true,
|
|
69
72
|
autoRecallLimit: 3,
|
|
70
73
|
autoRecallMaxChars: 700,
|
|
74
|
+
// user-correction / preference learning (zero-LLM, auto-captured from
|
|
75
|
+
// user/message events; prefer/avoid/switch-to cues). Store as `preference`
|
|
76
|
+
// memories that outrank the [ENGRAM] index.
|
|
77
|
+
autoCapturePreferences: true,
|
|
78
|
+
preferenceCapturePerSession: 8,
|
|
71
79
|
// [ENGRAM] index budget (token discipline)
|
|
72
80
|
indexMaxLines: 12,
|
|
73
81
|
indexMaxChars: 700,
|
|
@@ -144,23 +152,110 @@ const DEFAULTS = {
|
|
|
144
152
|
// least this many more requests (empirical workspace prior, see
|
|
145
153
|
// lib/boundary-prune.js). 50 = L0 sweep optimum.
|
|
146
154
|
boundaryPruneMinRemaining: 50,
|
|
155
|
+
// Threshold override (ResultPack L0 "T2048 landing"): the host pruner is
|
|
156
|
+
// fixed at 8192; set this to a lower budget to prune earlier at boundaries
|
|
157
|
+
// (L0 says T2048 has the highest net savings +61.6Mc, gated only by the
|
|
158
|
+
// L2 archive requirement — now satisfied). 0/absent → host threshold.
|
|
159
|
+
boundaryPruneThresholdChars: 0,
|
|
160
|
+
// Edges retained by the override pruner (unless set, host's 4096/1024).
|
|
161
|
+
boundaryPruneHeadChars: 0,
|
|
162
|
+
boundaryPruneTailChars: 0,
|
|
163
|
+
// Request-count boundary (SoL-Pi "step idle", default OFF): fire at most once
|
|
164
|
+
// per N requests even in sessions with no todo/goal boundary (~62% of the L0
|
|
165
|
+
// corpus never fires the event boundaries). Still under the repayment gate.
|
|
166
|
+
// 0 → event boundaries only.
|
|
167
|
+
boundaryPruneIdleCadence: 0,
|
|
168
|
+
// ResultPack L2 (PROPOSAL-resultpack §L2): archive the middles L1 prunes
|
|
169
|
+
// off the surface (~/.dsh/engram/resultpack/<callId>.json, redacted) and
|
|
170
|
+
// append a `[middle archived: result_recall(<callId>) 可取]` handle tail to
|
|
171
|
+
// each pruned node; register `result_recall(callId, offset?)` for paged
|
|
172
|
+
// recall — the full ObservationPack pattern (SoL-Pi mech ③; paired
|
|
173
|
+
// EdgeBench −23.58%). Requires `boundaryPrune: true` to have anything to
|
|
174
|
+
// archive (L2 without L1 is a no-op with a log line). Default OFF (C13).
|
|
175
|
+
resultPack: false,
|
|
176
|
+
// result_recall page budget (chars per page). 4000 fits a typical model
|
|
177
|
+
// context band without re-materializing huge results whole.
|
|
178
|
+
resultPackPageChars: 4000,
|
|
179
|
+
// Retain archived middles this many days (mtime sweep, same GC cadence).
|
|
180
|
+
resultPackRetentionDays: 7,
|
|
147
181
|
};
|
|
148
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Cordis reads `plugin.Config` off the module: DSH ≥0.1.7 validates the
|
|
185
|
+
* `engram` profile row against it AND projects the settings form from it —
|
|
186
|
+
* the curated SETTINGS_KEYS are `.volatile()`, so the settings service exposes
|
|
187
|
+
* exactly the GUI card's knobs (entry id `engram`), while every other DEFAULTS
|
|
188
|
+
* key stays a plain declared field so profile-patch config survives validation.
|
|
189
|
+
*/
|
|
190
|
+
export const Config = makeEngineConfigSchema(DEFAULTS);
|
|
191
|
+
|
|
149
192
|
export function resolveConfig(config) {
|
|
150
193
|
const src = config && typeof config === "object" ? config : {};
|
|
151
194
|
const out = { ...DEFAULTS };
|
|
152
195
|
for (const key of Object.keys(DEFAULTS)) {
|
|
153
|
-
if (src[key] !== void 0)
|
|
196
|
+
if (src[key] !== void 0) {
|
|
197
|
+
// A volatile reference may hold a nullish value (e.g. a profile patch
|
|
198
|
+
// listed a knob as null, or schemastery's null-default sentinel); the
|
|
199
|
+
// `??` keeps the DEFAULTS value in that case instead of nulling out.
|
|
200
|
+
out[key] = unwrapValue(src[key]) ?? DEFAULTS[key];
|
|
201
|
+
}
|
|
154
202
|
}
|
|
155
203
|
return out;
|
|
156
204
|
}
|
|
157
205
|
|
|
206
|
+
/** Unwrap a volatile config reference (DSH ≥0.1.7 settings) to its plain value. */
|
|
207
|
+
function unwrapValue(value) {
|
|
208
|
+
return value !== null && typeof value === "object" && typeof value.get === "function" ? value.get() : value;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Collect the volatile config references for the given keys from a plugin
|
|
213
|
+
* config object (DSH ≥0.1.7 validates `Config` into volatile references that
|
|
214
|
+
* the loader commits into IN PLACE on settings-card edits). Plain values are
|
|
215
|
+
* skipped, so this is a no-op on legacy DSH.
|
|
216
|
+
*/
|
|
217
|
+
export function collectVolatileRefs(config, keys) {
|
|
218
|
+
const refs = {};
|
|
219
|
+
if (config !== null && typeof config === "object") {
|
|
220
|
+
for (const key of keys) {
|
|
221
|
+
const ref = config[key];
|
|
222
|
+
if (ref !== null && typeof ref === "object" && typeof ref.get === "function") refs[key] = ref;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return refs;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Re-read volatile references into a resolved config (`target`, mutated in
|
|
230
|
+
* place): the loader has committed a settings-card edit into the same refs and
|
|
231
|
+
* emitted `loader/volatile-update`. Nullish refs fall back to DEFAULTS (the
|
|
232
|
+
* schemastery null-default sentinel yields an undefined ref value). Returns
|
|
233
|
+
* the keys whose value actually changed.
|
|
234
|
+
*/
|
|
235
|
+
export function syncVolatileRefs(refs, target) {
|
|
236
|
+
const changed = [];
|
|
237
|
+
for (const key of Object.keys(refs)) {
|
|
238
|
+
const next = unwrapValue(refs[key]) ?? DEFAULTS[key];
|
|
239
|
+
if (next !== target[key]) {
|
|
240
|
+
target[key] = next;
|
|
241
|
+
changed.push(key);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return changed;
|
|
245
|
+
}
|
|
246
|
+
|
|
158
247
|
/**
|
|
159
248
|
* Mount the plugin. `ctx` is the scoped cordis context; `config` the optional
|
|
160
249
|
* resolved plugin config (plain object from the profile patch).
|
|
161
250
|
*/
|
|
162
251
|
export function apply(ctx, config) {
|
|
163
252
|
const resolved = resolveConfig(config);
|
|
253
|
+
// DSH ≥0.1.7 / 0.2.x settings model: the GUI card edits the `engram`
|
|
254
|
+
// entry's VOLATILE Config fields. The loader commits those into the same
|
|
255
|
+
// references (in place) and emits `loader/volatile-update` — hold the refs
|
|
256
|
+
// so a card edit re-syncs `resolved` live for new sessions (blocks already
|
|
257
|
+
// frozen for a session stay frozen — the plugin's documented contract).
|
|
258
|
+
const volatileRefs = collectVolatileRefs(config, SETTINGS_KEYS);
|
|
164
259
|
if (!resolved.enabled) return;
|
|
165
260
|
|
|
166
261
|
const log = ctx.logger ? ctx.logger("engram") : undefined;
|
|
@@ -220,7 +315,10 @@ export function apply(ctx, config) {
|
|
|
220
315
|
sessionQuery,
|
|
221
316
|
log,
|
|
222
317
|
/** Cumulative auto-capture totals (GUI overview observability). */
|
|
223
|
-
captureStats: { total: 0, git: 0, file: 0, error: 0 },
|
|
318
|
+
captureStats: { total: 0, git: 0, file: 0, error: 0, preference: 0 },
|
|
319
|
+
/** SoL-Pi activation telemetry (M15/C13): durable boundary-prune fires +
|
|
320
|
+
* [RECALL] injection funnel, read at request time (readPackTelemetry). */
|
|
321
|
+
packTelemetry: () => readPackTelemetry({ home: engramHome() }),
|
|
224
322
|
/** Last GC sweep totals (GUI overview observability). */
|
|
225
323
|
gcStats: { lastRun: 0, archivedMemories: 0, archivedTasks: 0, removedLinks: 0 },
|
|
226
324
|
/** Live Context GC status (API overview + durable status file). */
|
|
@@ -264,6 +362,11 @@ export function apply(ctx, config) {
|
|
|
264
362
|
if (report.archivedMemories.length + report.archivedTasks.length + report.removedLinks.length > 0) {
|
|
265
363
|
log?.info?.(`engram gc: ${report.archivedMemories.length} memories archived, ${report.archivedTasks.length} tasks, ${report.removedLinks.length} links removed`);
|
|
266
364
|
}
|
|
365
|
+
// ResultPack L2 archive sweep rides the same GC cadence (mtime-based).
|
|
366
|
+
if (resolved.resultPack === true) {
|
|
367
|
+
const removed = sweepExpired(resultPackDir(), resolved.resultPackRetentionDays ?? 7);
|
|
368
|
+
if (removed > 0) log?.info?.(`engram gc: ${removed} expired result-pack archives removed`);
|
|
369
|
+
}
|
|
267
370
|
} catch (error) {
|
|
268
371
|
log?.warn?.(`engram gc sweep failed: ${String(error)}`);
|
|
269
372
|
}
|
|
@@ -327,7 +430,18 @@ export function apply(ctx, config) {
|
|
|
327
430
|
pendingRecall = true;
|
|
328
431
|
} else {
|
|
329
432
|
const recall = renderStartRecall(opened, workspace, resolved, { query, allowRecencyFallback: false });
|
|
330
|
-
|
|
433
|
+
if (recall.length > 0) {
|
|
434
|
+
// Activation funnel, injection leg: a [RECALL] block actually made
|
|
435
|
+
// it into a session. Counted here (the block only materializes when
|
|
436
|
+
// the render produced hits), so /stats can close the loop with
|
|
437
|
+
// engram_recall/engram_detail tool calls from the session logs.
|
|
438
|
+
recordRecallInjection({
|
|
439
|
+
home: engramHome(),
|
|
440
|
+
workspace,
|
|
441
|
+
hits: (recall.match(/- \[/g) ?? []).length,
|
|
442
|
+
});
|
|
443
|
+
block = `${block}\n${recall}`.trim();
|
|
444
|
+
}
|
|
331
445
|
}
|
|
332
446
|
}
|
|
333
447
|
} catch (error) {
|
|
@@ -392,6 +506,17 @@ export function apply(ctx, config) {
|
|
|
392
506
|
ctx.effect(() => {
|
|
393
507
|
const disposers = [];
|
|
394
508
|
|
|
509
|
+
// Settings card edits (volatile Config fields) hot-commit into the refs
|
|
510
|
+
// in place; re-read them so `resolved` reflects new values for new
|
|
511
|
+
// sessions without a profile restart. Legacy DSH never emits this event
|
|
512
|
+
// and leaves `volatileRefs` empty, so the listener is inert there.
|
|
513
|
+
if (Object.keys(volatileRefs).length > 0) {
|
|
514
|
+
const disposeVolatile = ctx.on?.("loader/volatile-update", () => {
|
|
515
|
+
syncVolatileRefs(volatileRefs, resolved);
|
|
516
|
+
});
|
|
517
|
+
if (disposeVolatile !== void 0) disposers.push(disposeVolatile);
|
|
518
|
+
}
|
|
519
|
+
|
|
395
520
|
// tools
|
|
396
521
|
disposers.push(registerTools(ctx, service));
|
|
397
522
|
|
|
@@ -622,11 +747,62 @@ export function apply(ctx, config) {
|
|
|
622
747
|
const disposeGoal = installGoalCapture(ctx, captureStore, log);
|
|
623
748
|
disposers.push(disposeGoal);
|
|
624
749
|
|
|
750
|
+
// User-correction / preference learning (zero-LLM): `user/message`
|
|
751
|
+
// events carrying explicit prefer/avoid/switch-to cues auto-sediment as
|
|
752
|
+
// `preference` memories (correx / PAHF / Mem0 preference-extraction
|
|
753
|
+
// pattern, distilled to pure rules). No model call.
|
|
754
|
+
const disposePreference = installPreferenceCapture(ctx, captureStore, resolved, log, service.captureStats);
|
|
755
|
+
disposers.push(disposePreference);
|
|
756
|
+
|
|
625
757
|
// BoundaryPrune (PROPOSAL-resultpack L1, default OFF): prune
|
|
626
758
|
// over-threshold tool results at todo/goal subtask boundaries through
|
|
627
759
|
// DSH's own deterministic toolResultPruner, gated by a repayment clock.
|
|
628
|
-
const
|
|
760
|
+
const l2Dir = resultPackDir();
|
|
761
|
+
const disposeBoundaryPrune = installBoundaryPrune(ctx, resolved, log, {
|
|
762
|
+
// ResultPack L2 hook: archive the pruned middles (redacted) + append
|
|
763
|
+
// a handle tail on each pruned node. Fail-contained in result-pack.js.
|
|
764
|
+
// Also records durable activation telemetry (SoL-Pi M15/C13) —
|
|
765
|
+
// independent of `resultPack`: /stats must be able to prove the L1
|
|
766
|
+
// mechanism actually fires on the live profile.
|
|
767
|
+
onPruned: (session, report, boundary) => {
|
|
768
|
+
recordBoundaryPruneFire({
|
|
769
|
+
home: engramHome(),
|
|
770
|
+
boundary,
|
|
771
|
+
report,
|
|
772
|
+
workspace: wk(session?.header?.cwd),
|
|
773
|
+
});
|
|
774
|
+
if (resolved.resultPack !== true) return;
|
|
775
|
+
const workspace = wk(session?.header?.cwd);
|
|
776
|
+
archivePrunedResults({
|
|
777
|
+
dir: l2Dir,
|
|
778
|
+
session,
|
|
779
|
+
report,
|
|
780
|
+
workspace,
|
|
781
|
+
log,
|
|
782
|
+
});
|
|
783
|
+
},
|
|
784
|
+
});
|
|
629
785
|
disposers.push(disposeBoundaryPrune);
|
|
786
|
+
|
|
787
|
+
// ResultPack L2 tool (default OFF with L1): `result_recall(callId, offset?)`
|
|
788
|
+
// pages back an archived middle. Independent of the memory domain — the
|
|
789
|
+
// archive lives on the filesystem. Registers only when enabled; without
|
|
790
|
+
// boundayPrune there is nothing to archive, so it self-disables.
|
|
791
|
+
if (resolved.resultPack === true) {
|
|
792
|
+
if (resolved.boundaryPrune !== true) {
|
|
793
|
+
log?.warn?.("engram result-pack: requires boundaryPrune: true to prune middles — result_recall not registered");
|
|
794
|
+
} else {
|
|
795
|
+
const pageChars = Number.isFinite(resolved.resultPackPageChars)
|
|
796
|
+
? resolved.resultPackPageChars
|
|
797
|
+
: 4000;
|
|
798
|
+
disposers.push(
|
|
799
|
+
ctx.effect(() => {
|
|
800
|
+
ctx.tools.register(resultRecallTool(l2Dir, pageChars));
|
|
801
|
+
log?.info?.(`engram result-pack enabled: result_recall(callId, offset) registered; archive dir ${l2Dir}`);
|
|
802
|
+
}),
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
630
806
|
}
|
|
631
807
|
|
|
632
808
|
// memory GC scheduling: one sweep after the store is reachable, then on
|