dsh-engram 0.4.0 → 0.5.1

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/settings.js CHANGED
@@ -1,27 +1,51 @@
1
1
  /**
2
2
  * dsh-engram: user settings binding (host half).
3
3
  *
4
- * Registers the `dsh-engram` settings namespace through DSH's own settings
5
- * service so the web GUI's Plugins configuration tab pairs the namespace with
6
- * the plugin's card automatically. Changes land live on `service.config`
7
- * (new sessions pick them up; blocks already frozen for a session stay frozen
8
- * by design — prefix stability wins over mid-session knobs).
4
+ * Two DSH settings generations are in the wild:
5
+ * - ≤0.1.5 `settings.installSection(ctx, ns, schema, base, hooks)` mounts a
6
+ * free-form namespace (`dsh-engram`) with its own storage + change hooks.
7
+ * - ≥0.1.7 (and 0.2.x) forms are keyed by PROFILE ENTRY ID; editable fields
8
+ * are `.volatile()` fields of the plugin's own `Config` schema, values live
9
+ * in the profile row and hot-commit into the running volatile references
10
+ * (`loader/volatile-update`), so the GUI card needs no separate mount call.
9
11
  *
10
- * The schema is schemastery (DSH's own schema engine), not zod: the settings
11
- * transport and the form renderers are all schemastery-native.
12
+ * This module therefore:
13
+ * - exports `makeEngineConfigSchema(defaults)` — the plugin `Config`: every
14
+ * DEFAULTS key so pre-existing profile-patch config survives schema
15
+ * validation, with the curated SETTINGS_KEYS marked `.volatile()` so the
16
+ * settings service exposes exactly the card's knobs as a form;
17
+ * - `installEngramSettings` feature-detects the generation and exposes the
18
+ * single `service.settings` seam `{ ns, describe, mutate, update }` the
19
+ * `/api/dsh-engram/settings` routes use. The seam's `ns` is the ACTIVE
20
+ * namespace — entry id `engram` on new DSH, legacy `dsh-engram` on old —
21
+ * so the API layer never needs to know which generation it talks to.
22
+ *
23
+ * The client level keeps a stable wire label `dsh-engram`: api.js re-labels
24
+ * the describe view onto that label and lets the seam's active `ns` drive
25
+ * mutations, so the shipped client bundle works unchanged on both generations.
12
26
  */
13
27
 
14
28
  import z from "@deepseek-ai/schemastery";
15
29
 
16
- /** Namespace join key — must match the client card's `key`. */
30
+ /** Active settings namespace on DSH ≥0.1.7 / 0.2.x — the profile ENTRY ID of
31
+ * the plugin row (`id: engram` in cordis.patch.yml). Must match plugin name. */
32
+ export const SETTINGS_NS = "engram";
33
+ /** Legacy (≤0.1.5) free-form namespace, when installSection is present. */
34
+ export const LEGACY_SETTINGS_NS = "dsh-engram";
35
+ /** Stable wire label the client card filters on (unchanged across generations;
36
+ * api.js re-labels the describe view onto it). */
37
+ export const WIRE_SETTINGS_NS = "dsh-engram";
38
+ /** Back-compat alias some call sites used for the old namespace constant. */
17
39
  export const SETTINGS_NS_NAME = "dsh-engram";
18
- // dsh-settings 0.1.5 exposes the namespace as a validated string at the
19
- // provider boundary; the legacy branding helper was removed.
20
- export const SETTINGS_NS = SETTINGS_NS_NAME;
21
40
 
22
41
  /** Fields the GUI exposes (a curated subset of the plugin DEFAULTS). */
23
42
  export const SETTINGS_KEYS = [
24
43
  "autoCapture",
44
+ "autoCapturePreferences",
45
+ "boundaryPrune",
46
+ "boundaryPruneThresholdChars",
47
+ "boundaryPruneIdleCadence",
48
+ "resultPack",
25
49
  "sessionSearch",
26
50
  "recallScope",
27
51
  "autoRecallOnStart",
@@ -58,6 +82,11 @@ export function settingsBaseFrom(defaults) {
58
82
  export function makeSettingsSchema(defaults) {
59
83
  return z.object({
60
84
  autoCapture: z.boolean().default(defaults.autoCapture),
85
+ autoCapturePreferences: z.boolean().default(defaults.autoCapturePreferences).description("用户纠正/偏好学习(零 LLM):检测用户消息里的显式 prefer/avoid/switch-to 句式,自动沉淀为 preference 记忆并置顶 [ENGRAM] 索引(correx/PAHF 式偏好提取;关掉=只靠手动 engram_store)"),
86
+ boundaryPrune: z.boolean().default(defaults.boundaryPrune).description("ResultPack L1(BoundaryPrune 边界剪枝):在 todo 完成推进 / goal 终态这些子任务边界,主动调用 DSH 的确定性 toolResultPruner 剪掉超阈值旧工具结果(SoL-Pi Online Context Compact;默认关)"),
87
+ boundaryPruneThresholdChars: z.natural().default(0).description("边界剪枝阈值覆盖(字符):宿主 pruner 固定 8192;设更低(如 2048)可更早开剪——L0 仿真显示 T2048 净收益最高(+61.6Mc),前提是 L2 归档已开启可找回被剪中段;0=用宿主 8192"),
88
+ boundaryPruneIdleCadence: z.natural().default(0).description("空闲步数边界(请求计数):最多每 N 个请求触发一次剪枝,覆盖无 todo/goal 的会话(约 62% 会话永不触发事件边界);仍受偿还门约束;0=仅事件边界"),
89
+ resultPack: z.boolean().default(defaults.resultPack).description("ResultPack L2(结果归档召回):BoundaryPrune 剪掉的中段脱敏归档到本地,剪枝节点尾部追加 handle,模型可用 result_recall(callId) 按页召回(完整 ObservationPack;需与 boundaryPrune 一起开,L2 本身默认关)"),
61
90
  sessionSearch: z.boolean().default(defaults.sessionSearch),
62
91
  recallScope: z.union(["workspace", "global"]).default(defaults.recallScope ?? "workspace").description("召回范围:workspace=严格按当前工作区隔离(默认、token 纪律);global=本工作区命中弱/空时,从其它工作区池按 recency/origin 加权召回并标 [W:<workspace>] 来源。开启会打破跨工作区隔离,请按需选用"),
63
92
  autoRecallOnStart: z.boolean().default(defaults.autoRecallOnStart).description("新会话开始时,宿主按首条消息对工作区做确定性召回,把命中记忆直接注入 [RECALL] 块(无需模型自己想起调 engram_recall;关掉=回到纯拉取)"),
@@ -84,33 +113,80 @@ export function makeSettingsSchema(defaults) {
84
113
  });
85
114
  }
86
115
 
116
+ /** schemastery prototype for ONE DEFAULTS entry, used for the plugin Config. */
117
+ function configFieldType(value) {
118
+ if (typeof value === "boolean") return z.boolean();
119
+ if (typeof value === "number") return z.number();
120
+ if (typeof value === "string") return z.string();
121
+ if (Array.isArray(value)) return z.array(z.string());
122
+ return z.unknown();
123
+ }
124
+
87
125
  /**
88
- * Wire the settings section into `ctx` and keep `live` (the plugin's mutable
89
- * config object) merged with what the user stored. Called from `apply()`.
126
+ * The plugin's full Config schema (schemastery), i.e. the schema DSH ≥0.1.7
127
+ * validates the `engram` profile row against and projects the settings form
128
+ * from.
129
+ *
130
+ * - EVERY DEFAULTS key is declared (non-volatile, with its default) so a
131
+ * profile patch that sets an internal knob (e.g. `gcIntervalHours`,
132
+ * `enabled`) survives schema validation instead of being stripped.
133
+ * - The curated SETTINGS_KEYS are additionally `.volatile()` — the settings
134
+ * service then exposes exactly the card's knobs as an editable form keyed by
135
+ * the entry id, and writes hot-commit into the running volatile references.
90
136
  *
91
- * When a `service` object is supplied, a normalized `service.settings` seam
92
- * ({ describe, mutate, update }) is exposed for the web API — this is the
93
- * single host-side adapter point: both the current and the legacy DSH
94
- * settings APIs reduce onto the same three calls, so `/api/dsh-engram/settings`
95
- * never needs to know which generation it is talking to.
137
+ * Exported as `Config` by lib/index.js (cordis reads `plugin.Config`).
138
+ */
139
+ export function makeEngineConfigSchema(defaults) {
140
+ const fields = {};
141
+ for (const [key, value] of Object.entries(defaults)) {
142
+ let field = configFieldType(value).default(value);
143
+ if (SETTINGS_KEYS.includes(key)) field = field.volatile();
144
+ fields[key] = field;
145
+ }
146
+ return z.object(fields);
147
+ }
148
+
149
+ /**
150
+ * Wire the settings section into `ctx` and expose the normalized
151
+ * `service.settings` seam ({ ns, describe, mutate, update }) for the web API.
152
+ * Called from `apply()`.
153
+ *
154
+ * Generation-agnostic adapter (DSH ≥0.1.7 / 0.2.x on top):
155
+ * - new DSH (≥0.1.7): the form is entry-keyed (`engram`) and its fields are
156
+ * the plugin's volatile Config knobs — there is nothing to "mount":
157
+ * `apply()` already received the validated volatile references and
158
+ * re-syncs `live` on `loader/volatile-update`. The seam just forwards.
159
+ * - legacy DSH (≤0.1.5): `installSection` mounts the free-form `dsh-engram`
160
+ * namespace; the old onChange loop merges stored values onto `live`.
161
+ *
162
+ * When both APIs are absent (headless/other), the seam stays undefined and the
163
+ * /api/dsh-engram/settings routes answer 503 — same as before.
96
164
  */
97
165
  export function installEngramSettings(ctx, defaults, live, service) {
98
- const base = settingsBaseFrom(defaults);
99
- const schema = makeSettingsSchema(defaults);
100
- let source = () => base;
101
166
  ctx.inject(["settings"], (settingsCtx) => {
102
- // dsh-settings 0.1.5 moved the optional-consumer wiring onto the provider
103
- // instance. Keeping the registration on the scoped context preserves the
104
- // provider attach/detach lifecycle while avoiding a removed helper export.
105
167
  const provider = settingsCtx.settings;
106
- if (service) {
107
- service.settings = {
108
- describe: () => provider.describe({ redactSecrets: true }),
109
- mutate: (ns, ops, expectedRevision) => provider.mutate(ns, ops, expectedRevision),
110
- update: (ns, patch, expectedRevision) => provider.update(ns, patch, expectedRevision),
111
- };
168
+ if (!provider || typeof provider.describe !== "function") return;
169
+
170
+ // New generation: `configure()` exists and `installSection` is gone.
171
+ if (typeof provider.configure === "function") {
172
+ if (service) {
173
+ service.settings = {
174
+ ns: SETTINGS_NS,
175
+ describe: () => provider.describe({ redactSecrets: true }),
176
+ mutate: (ns, ops, expectedRevision) => provider.mutate(ns, ops, expectedRevision),
177
+ update: (ns, patch, expectedRevision) => provider.update(ns, patch, expectedRevision),
178
+ };
179
+ }
180
+ return;
112
181
  }
113
- provider.installSection(ctx, SETTINGS_NS, schema, base, {
182
+
183
+ // Legacy generation: free-form namespace via installSection.
184
+ if (typeof provider.installSection !== "function") return;
185
+ const base = settingsBaseFrom(defaults);
186
+ const schema = makeSettingsSchema(defaults);
187
+ let source = () => base;
188
+ const ns = LEGACY_SETTINGS_NS;
189
+ provider.installSection(ctx, ns, schema, base, {
114
190
  setSource(current) {
115
191
  source = current;
116
192
  },
@@ -131,5 +207,13 @@ export function installEngramSettings(ctx, defaults, live, service) {
131
207
  void value;
132
208
  },
133
209
  });
210
+ if (service) {
211
+ service.settings = {
212
+ ns,
213
+ describe: () => provider.describe({ redactSecrets: true }),
214
+ mutate: (ns, ops, expectedRevision) => provider.mutate(ns, ops, expectedRevision),
215
+ update: (ns, patch, expectedRevision) => provider.update(ns, patch, expectedRevision),
216
+ };
217
+ }
134
218
  });
135
219
  }
package/lib/store.js CHANGED
@@ -28,6 +28,7 @@ export const DOMAIN_VERSION = 1;
28
28
 
29
29
  /** Memory kinds, mapping to index prefixes. */
30
30
  const MEMORY_KINDS = [
31
+ "preference",
31
32
  "decision",
32
33
  "error",
33
34
  "procedure",
package/lib/tools.js CHANGED
@@ -23,6 +23,7 @@ import { truncate, shortId, slugId, fmtDate, escapeLt, workspaceKey as wk, uuid
23
23
  import { hasCJK, searchSessionsCJK } from "./session-cjk.js";
24
24
 
25
25
  const KINDS = [
26
+ "preference",
26
27
  "decision",
27
28
  "error",
28
29
  "procedure",
@@ -99,7 +100,7 @@ function memoryLine(m, verbosity = "short", rel, homeWs) {
99
100
  }
100
101
 
101
102
  /** Stable kind display order for grouped recall output (matches MEMORY_KINDS). */
102
- const MEMORY_KIND_ORDER = ["decision", "error", "procedure", "fact", "insight", "handoff", "task"];
103
+ const MEMORY_KIND_ORDER = ["preference", "decision", "error", "procedure", "fact", "insight", "handoff", "task"];
103
104
 
104
105
  /**
105
106
  * Render recall hits grouped by kind (MemoraX-style memory_type buckets):
package/lib/usage.js CHANGED
@@ -7,8 +7,8 @@
7
7
  * on every engram/esr tool call, and a separate session-log scan that counted
8
8
  * tool calls (to include the built-in `todo` tool). Both are now ONE module:
9
9
  * both /stats and /toolstats answer from the canonical log stream
10
- * `~/.dsh/sessions/<bucket>/<sid>/session.jsonl.zstd`, decompressed on demand
11
- * with a short TTL cache. No tool call pays a write anymore (pure GUI-side
10
+ * `~/.dsh/sessions/<bucket>/<sid>/session[.v<N>].jsonl.zstd`, decompressed on
11
+ * demand with a short TTL cache. No tool call pays a write anymore (pure GUI-side
12
12
  * observability), and the log stream is the single source of truth.
13
13
  *
14
14
  * Zero external dependency: Node >=22.19 provides the zstd codec in
@@ -44,6 +44,49 @@ export function sessionsRoot() {
44
44
  return path.join(os.homedir(), ".dsh", "sessions");
45
45
  }
46
46
 
47
+ /**
48
+ * Session log file names: current DSH stamps the session format version into
49
+ * the name (`session.v3.jsonl.zstd` on 0.1.x, `session.v4.jsonl.zstd` on 0.2);
50
+ * older releases used the unversioned `session.jsonl.zstd` (issue #8).
51
+ */
52
+ const SESSION_LOG_RE = /^session(?:\.v\d+)?\.jsonl\.zstd$/;
53
+
54
+ /**
55
+ * Resolve the log file inside one session directory.
56
+ *
57
+ * A directory normally holds exactly one log, but a session migrated across
58
+ * format versions can hold both a v3 and a v4 file (and a legacy unversioned
59
+ * one). The newest-mtime match wins — that is the file the host is currently
60
+ * appending to. When nothing matches, the historical unversioned name is
61
+ * returned so callers keep their existing ENOENT handling (`statSync` throws →
62
+ * session skipped) instead of silently scanning the wrong corpus.
63
+ */
64
+ export function sessionLogFile(dir) {
65
+ let names;
66
+ try {
67
+ names = fs.readdirSync(dir);
68
+ } catch {
69
+ return path.join(dir, "session.jsonl.zstd");
70
+ }
71
+ let best = null;
72
+ let bestMtime = -1;
73
+ for (const name of names) {
74
+ if (!SESSION_LOG_RE.test(name)) continue;
75
+ let st;
76
+ try {
77
+ st = fs.statSync(path.join(dir, name));
78
+ } catch {
79
+ continue; // unreadable entry: ignore
80
+ }
81
+ if (!st.isFile()) continue;
82
+ if (st.mtimeMs > bestMtime) {
83
+ bestMtime = st.mtimeMs;
84
+ best = name;
85
+ }
86
+ }
87
+ return path.join(dir, best ?? "session.jsonl.zstd");
88
+ }
89
+
47
90
  /** Byte range of one structurally complete zstd frame in a concatenated stream. */
48
91
  function frameRanges(buffer) {
49
92
  const frames = [];
@@ -244,7 +287,7 @@ export function collectToolCounts({ root = sessionsRoot(), days = 14, now = Date
244
287
  for (const bucket of buckets) {
245
288
  const bdir = path.join(root, bucket);
246
289
  for (const sid of listDirOrEmpty(bdir)) {
247
- const file = path.join(bdir, sid, "session.jsonl.zstd");
290
+ const file = sessionLogFile(path.join(bdir, sid));
248
291
  let st;
249
292
  try {
250
293
  st = fs.statSync(file);
@@ -330,7 +373,7 @@ export function collectUsageStats({ root = sessionsRoot(), days = 14, now = Date
330
373
  const buckets = listDirOrEmpty(root).filter((b) => b.startsWith("--"));
331
374
  for (const bucket of buckets) {
332
375
  for (const sid of listDirOrEmpty(path.join(root, bucket))) {
333
- const file = path.join(root, bucket, sid, "session.jsonl.zstd");
376
+ const file = sessionLogFile(path.join(root, bucket, sid));
334
377
  let st;
335
378
  try {
336
379
  st = fs.statSync(file);
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "dsh-engram",
3
3
  "description": "Minimalist long-term memory for DeepSeek Harness, distilling symbolic-index + pi-esr ideas: zero-LLM auto-capture, a symbolic [ENGRAM] index with progressive disclosure, and an ESR-lite evidence-closure protocol (esr_task / esr_node / esr_close / esr_link). Real agent-behaviour telemetry (usage rollup + /stats) and a deterministic offline recall benchmark (npm run eval). Hot path is model-free; storage on ctx.storageDomain; web memory viewer, observability panel and config card through DSH's native settings slots.",
4
- "version": "0.4.0",
4
+ "version": "0.5.1",
5
+ "packageManager": "pnpm@11.22.0",
5
6
  "license": "MIT",
6
7
  "repository": {
7
8
  "type": "git",
@@ -34,7 +35,7 @@
34
35
  ],
35
36
  "engines": {
36
37
  "node": ">=22.19.0",
37
- "dsh": ">=0.1.2-alpha.2 <0.2.0-0"
38
+ "dsh": ">=0.1.2-alpha.2 <0.3.0-0"
38
39
  },
39
40
  "scripts": {
40
41
  "setup": "node scripts/setup-links.mjs",
@@ -74,12 +75,12 @@
74
75
  },
75
76
  "peerDependencies": {
76
77
  "@deepseek-ai/cordis": "^4.0.2",
77
- "@deepseek-ai/dsh-storage-domain": ">=0.1.2-alpha.2 <0.2.0-0",
78
- "@deepseek-ai/dsh-settings": ">=0.1.2-alpha.2 <0.2.0-0",
78
+ "@deepseek-ai/dsh-storage-domain": ">=0.1.2-alpha.2 <0.3.0-0",
79
+ "@deepseek-ai/dsh-settings": ">=0.1.2-alpha.2 <0.3.0-0",
79
80
  "@deepseek-ai/schemastery": "^3.18.2",
80
- "@deepseek-ai/dsh-compaction": ">=0.1.2-alpha.2 <0.2.0-0",
81
- "@deepseek-ai/dsh-compaction-basic": ">=0.1.2-alpha.2 <0.2.0-0",
82
- "@deepseek-ai/dsh-llm": ">=0.1.2-alpha.2 <0.2.0-0"
81
+ "@deepseek-ai/dsh-compaction": ">=0.1.2-alpha.2 <0.3.0-0",
82
+ "@deepseek-ai/dsh-compaction-basic": ">=0.1.2-alpha.2 <0.3.0-0",
83
+ "@deepseek-ai/dsh-llm": ">=0.1.2-alpha.2 <0.3.0-0"
83
84
  },
84
85
  "peerDependenciesMeta": {
85
86
  "@deepseek-ai/cordis": {
@@ -24,8 +24,8 @@ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
24
24
  const tmp = mkdtempSync(join(tmpdir(), "dsh-engram-pack-"));
25
25
  const PEERS = [
26
26
  "@deepseek-ai/cordis@^4.0.2",
27
- "@deepseek-ai/dsh-settings@>=0.1.2-alpha.2 <0.2.0-0",
28
- "@deepseek-ai/dsh-storage-domain@>=0.1.2-alpha.2 <0.2.0-0",
27
+ "@deepseek-ai/dsh-settings@>=0.1.2-alpha.2 <0.3.0-0",
28
+ "@deepseek-ai/dsh-storage-domain@>=0.1.2-alpha.2 <0.3.0-0",
29
29
  "@deepseek-ai/schemastery@^3.18.2",
30
30
  ];
31
31
 
@@ -85,8 +85,11 @@ try {
85
85
  const manifest = JSON.parse(readFileSync(join(installed, "package.json"), "utf8"));
86
86
  const host = await import(pathToFileURL(join(installed, manifest.main)).href);
87
87
  // resolveConfig is a deliberate public export (settings test builds the
88
- // real DEFAULTS from it); widen the host surface consciously, not silently.
89
- const expected = ["apply", "inject", "name", "resolveConfig"].sort().join(",");
88
+ // real DEFAULTS from it); Config is read by cordis as runtime.Config (the
89
+ // entry-id / volatile settings model); collectVolatileRefs/syncVolatileRefs
90
+ // back that model's live re-sync and are unit-tested directly. Widen the
91
+ // host surface consciously, not silently.
92
+ const expected = ["Config", "apply", "collectVolatileRefs", "inject", "name", "resolveConfig", "syncVolatileRefs"].sort().join(",");
90
93
  const actual = Object.keys(host).sort().join(",");
91
94
  if (actual !== expected) {
92
95
  throw new Error(`host exports mismatch: got ${actual}, want ${expected}`);