dsh-engram 0.3.7 → 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/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"] }
@@ -3614,6 +3626,9 @@ function EngramConfigCard({ scope }) {
3614
3626
  const off = scope.subscribe(() => {
3615
3627
  const next = scope.getSnapshot();
3616
3628
  setSnap(next);
3629
+ if (next.status === "unavailable") {
3630
+ console.warn("[dsh-engram] settings scope unavailable \u2014 Plugins \u914D\u7F6E\u5361\u5DF2\u9690\u85CF:", next.reason ?? "no reason served");
3631
+ }
3617
3632
  const value2 = next.value ?? next.base;
3618
3633
  if (value2) setDraft((prev) => ({ ...value2, ...prev }));
3619
3634
  });
@@ -3665,6 +3680,7 @@ function EngramConfigCard({ scope }) {
3665
3680
  const writable = snap?.writable !== false && snap?.status !== "unavailable";
3666
3681
  const available = snap?.status === "ready";
3667
3682
  const { vars } = useEngramTheme();
3683
+ if (!available) return null;
3668
3684
  const renderField = (field) => {
3669
3685
  const raw = value[field.key];
3670
3686
  const overridden = snap?.user !== void 0 && field.key in snap.user;
@@ -3738,7 +3754,7 @@ function EngramConfigCard({ scope }) {
3738
3754
  ] }),
3739
3755
  group.fields.map(renderField)
3740
3756
  ] }, group.id);
3741
- return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("div", { style: vars, children: /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { style: available ? { ...s3.card, ...open ? s3.cardOpen : void 0 } : { display: "none" }, children: [
3757
+ return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("div", { style: vars, children: /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { style: { ...s3.card, ...open ? s3.cardOpen : void 0 }, children: [
3742
3758
  /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(
3743
3759
  "button",
3744
3760
  {
@@ -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
- const ap = isProvenProcedure(a, config) ? 0 : 1;
69
- const bp = isProvenProcedure(b, config) ? 0 : 1;
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 text = truncate(String(m.text).replace(/\s*\n+\s*/g, " ").trim(), 140);
207
- lines.push(`- [${mark}] ${date} ${escapeLt(text)} #${shortId(m.id)}${(m.hits ?? 0) > 0 ? ` ×${m.hits}` : ""}`);
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
- lines.push("context: use above · engram_detail <id> · engram_recall <query>");
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,10 +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";
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";
26
30
  import { renderIndex, renderStartRecall, firstUserMessageText, renderEsr, esrMethodology, buildEsrSnapshot, buildFrozenEsrBlock } from "./index-block.js";
27
31
  import { makeTriggerRecorder } from "./trigger.js";
28
32
  import { pendingTodosFromSession, sinkPendingTodos } from "./todo-sink.js";
29
- import { installEngramSettings } from "./settings.js";
33
+ import { installEngramSettings, makeEngineConfigSchema, SETTINGS_KEYS } from "./settings.js";
30
34
  import { makeEngramRoutes } from "./api.js";
31
35
  import { collectPointerContext, mountCompactionEngine } from "./context-gc.js";
32
36
  import { provisionWebCompaction } from "./web-provision.js";
@@ -67,6 +71,11 @@ const DEFAULTS = {
67
71
  autoRecallOnStart: true,
68
72
  autoRecallLimit: 3,
69
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,
70
79
  // [ENGRAM] index budget (token discipline)
71
80
  indexMaxLines: 12,
72
81
  indexMaxChars: 700,
@@ -131,23 +140,122 @@ const DEFAULTS = {
131
140
  // auto-marks the matching older same-entity+kind memory as superseded.
132
141
  // OFF by default — explicit `supersedes` is always the primary path.
133
142
  autoSupersede: false,
143
+ // BoundaryPrune (PROPOSAL-resultpack L1): prune over-threshold tool
144
+ // results at todo/goal subtask boundaries via DSH's toolResultPruner
145
+ // (deterministic surface-replace, zero LLM). L0 simulation (77 sessions):
146
+ // todo-progress boundaries cover 79% of replay exposure; the repayment
147
+ // gate below lifted cache-aware net savings 12–32% on every threshold.
148
+ // Default OFF (SoL-Pi C13: a dormant mechanism costs nothing); the
149
+ // pruner service is feature-detected — absent ⇒ disabled with one log.
150
+ boundaryPrune: false,
151
+ // Repayment gate: prune only while the session is expected to run at
152
+ // least this many more requests (empirical workspace prior, see
153
+ // lib/boundary-prune.js). 50 = L0 sweep optimum.
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,
134
181
  };
135
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
+
136
192
  export function resolveConfig(config) {
137
193
  const src = config && typeof config === "object" ? config : {};
138
194
  const out = { ...DEFAULTS };
139
195
  for (const key of Object.keys(DEFAULTS)) {
140
- if (src[key] !== void 0) out[key] = src[key];
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
+ }
141
202
  }
142
203
  return out;
143
204
  }
144
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
+
145
247
  /**
146
248
  * Mount the plugin. `ctx` is the scoped cordis context; `config` the optional
147
249
  * resolved plugin config (plain object from the profile patch).
148
250
  */
149
251
  export function apply(ctx, config) {
150
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);
151
259
  if (!resolved.enabled) return;
152
260
 
153
261
  const log = ctx.logger ? ctx.logger("engram") : undefined;
@@ -207,7 +315,10 @@ export function apply(ctx, config) {
207
315
  sessionQuery,
208
316
  log,
209
317
  /** Cumulative auto-capture totals (GUI overview observability). */
210
- 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() }),
211
322
  /** Last GC sweep totals (GUI overview observability). */
212
323
  gcStats: { lastRun: 0, archivedMemories: 0, archivedTasks: 0, removedLinks: 0 },
213
324
  /** Live Context GC status (API overview + durable status file). */
@@ -251,6 +362,11 @@ export function apply(ctx, config) {
251
362
  if (report.archivedMemories.length + report.archivedTasks.length + report.removedLinks.length > 0) {
252
363
  log?.info?.(`engram gc: ${report.archivedMemories.length} memories archived, ${report.archivedTasks.length} tasks, ${report.removedLinks.length} links removed`);
253
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
+ }
254
370
  } catch (error) {
255
371
  log?.warn?.(`engram gc sweep failed: ${String(error)}`);
256
372
  }
@@ -314,7 +430,18 @@ export function apply(ctx, config) {
314
430
  pendingRecall = true;
315
431
  } else {
316
432
  const recall = renderStartRecall(opened, workspace, resolved, { query, allowRecencyFallback: false });
317
- block = recall.length > 0 ? `${block}\n${recall}`.trim() : block;
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
+ }
318
445
  }
319
446
  }
320
447
  } catch (error) {
@@ -379,6 +506,17 @@ export function apply(ctx, config) {
379
506
  ctx.effect(() => {
380
507
  const disposers = [];
381
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
+
382
520
  // tools
383
521
  disposers.push(registerTools(ctx, service));
384
522
 
@@ -608,6 +746,63 @@ export function apply(ctx, config) {
608
746
  // outcome survives the session without any LLM call.
609
747
  const disposeGoal = installGoalCapture(ctx, captureStore, log);
610
748
  disposers.push(disposeGoal);
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
+
757
+ // BoundaryPrune (PROPOSAL-resultpack L1, default OFF): prune
758
+ // over-threshold tool results at todo/goal subtask boundaries through
759
+ // DSH's own deterministic toolResultPruner, gated by a repayment clock.
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
+ });
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
+ }
611
806
  }
612
807
 
613
808
  // memory GC scheduling: one sweep after the store is reachable, then on
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Activation telemetry for the SoL-Pi mechanisms (dsh-engram).
3
+ *
4
+ * SoL-Pi methodology pair rule: M15 "mechanisms must prove activation" and
5
+ * C13 "dormant mechanisms are off by default" — a mechanism that cannot be
6
+ * observed activating is treated as dormant. This module gives BoundaryPrune
7
+ * (L1) + ResultPack (L2) an observable, durable activation record:
8
+ *
9
+ * - `recordBoundaryPruneFire` is called on every real prune (fires > 0) —
10
+ * durable JSON under the engram home, cumulative across restarts, so
11
+ * /stats and the GUI overview can prove the mechanism actually fires on
12
+ * the live profile (not just in the simulation).
13
+ * - `readBoundaryPruneStats` / `readRecallInjections` are pure readers.
14
+ *
15
+ * Zero LLM, pure deterministic bookkeeping. Two files today:
16
+ * ~/.dsh/engram/boundary-prune.status.json
17
+ * ~/.dsh/engram/recall-injections.json
18
+ */
19
+
20
+ import fs from "node:fs";
21
+ import os from "node:os";
22
+ import path from "node:path";
23
+
24
+ /** Default engram home (mirrors result-pack.js). */
25
+ export function engramHome(home = path.join(os.homedir(), ".dsh", "engram")) {
26
+ return home;
27
+ }
28
+
29
+ function statusFile(home, name) {
30
+ return path.join(home, name);
31
+ }
32
+
33
+ /** Atomic-ish write: tmp + rename, so a crash never leaves a torn file. */
34
+ function writeJsonAtomic(file, payload) {
35
+ try {
36
+ fs.mkdirSync(path.dirname(file), { recursive: true });
37
+ const tmp = `${file}.${process.pid}.tmp`;
38
+ fs.writeFileSync(tmp, `${JSON.stringify(payload)}\n`, "utf8");
39
+ fs.renameSync(tmp, file);
40
+ } catch {
41
+ // best-effort telemetry — never throw into the prune pipeline
42
+ }
43
+ }
44
+
45
+ function readJson(file) {
46
+ try {
47
+ return JSON.parse(fs.readFileSync(file, "utf8"));
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Record one BoundaryPrune fire (a real prune happened) into durable state.
55
+ * `report` is the pruner's pruneSession result. Returns the new cumulative
56
+ * stats object (or null when writing failed).
57
+ */
58
+ export function recordBoundaryPruneFire({ home, boundary, report, workspace, now = Date.now() } = {}) {
59
+ const prev = readBoundaryPruneStats({ home }) ?? {};
60
+ const pruned = Array.isArray(report?.pruned) ? report.pruned.length : 0;
61
+ const chars = Number.isFinite(report?.charsRemoved) ? report.charsRemoved : 0;
62
+ const kind = boundary?.kind ?? "unknown";
63
+ const byKind = { ...(prev.byKind ?? {}) };
64
+ byKind[kind] = (byKind[kind] ?? 0) + 1;
65
+ const next = {
66
+ version: 1,
67
+ fires: (prev.fires ?? 0) + 1,
68
+ prunedResults: (prev.prunedResults ?? 0) + pruned,
69
+ charsRemoved: (prev.charsRemoved ?? 0) + chars,
70
+ uses: (prev.uses ?? 0) + 1, // legacy alias kept for GUI tolerance
71
+ lastFire: now,
72
+ lastBoundary: kind,
73
+ lastWorkspace: workspace ?? prev.lastWorkspace ?? null,
74
+ byKind,
75
+ };
76
+ writeJsonAtomic(statusFile(home, "boundary-prune.status.json"), next);
77
+ return next;
78
+ }
79
+
80
+ /** Read the cumulative BoundaryPrune activation record (never throws). */
81
+ export function readBoundaryPruneStats({ home } = {}) {
82
+ return readJson(statusFile(home, "boundary-prune.status.json"));
83
+ }
84
+
85
+ /**
86
+ * Record one [RECALL] injection (a session-start recall block was actually
87
+ * appended to a session) for the activation funnel: injections → tool
88
+ * queries → hits → detail-follows.
89
+ */
90
+ export function recordRecallInjection({ home, workspace, hits, now = Date.now() } = {}) {
91
+ const prev = readRecallInjections({ home }) ?? { version: 1, injections: 0, hitInjections: 0 };
92
+ const next = {
93
+ version: 1,
94
+ injections: (prev.injections ?? 0) + 1,
95
+ hitInjections: (prev.hitInjections ?? 0) + (hits > 0 ? 1 : 0),
96
+ hitsInjected: (prev.hitsInjected ?? 0) + (hits > 0 ? hits : 0),
97
+ lastInjection: now,
98
+ lastWorkspace: workspace ?? prev.lastWorkspace ?? null,
99
+ };
100
+ writeJsonAtomic(statusFile(home, "recall-injections.json"), next);
101
+ return next;
102
+ }
103
+
104
+ /** Read the cumulative [RECALL] injection record (never throws). */
105
+ export function readRecallInjections({ home } = {}) {
106
+ return readJson(statusFile(home, "recall-injections.json"));
107
+ }
108
+
109
+ /** Aggregate both telemetry files into one summary for /stats + overview. */
110
+ export function readPackTelemetry({ home } = {}) {
111
+ return {
112
+ boundaryPrune: readBoundaryPruneStats({ home }),
113
+ recall: readRecallInjections({ home }),
114
+ };
115
+ }