dsh-engram 0.7.0 → 0.7.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.
@@ -10,16 +10,21 @@
10
10
  *
11
11
  * `status`/`doctor` read the durable snapshot the plugin writes at boot
12
12
  * (`$DSH_HOME/engram/context-gc.status.json`) and cross-check the live preset
13
- * files. `enable`/`revert` act on preset compositions found under the shipped
14
- * and user preset roots (or a single file via `--file <path>`).
13
+ * files — both preset generations: legacy compositions
14
+ * (`<root>/<id>/agent.cordis.yml`, DSH ≤ 0.1.x) and the declarative shipped
15
+ * presets (`@deepseek-ai/dsh-web-app/presets/<id>.patch.yml`, DSH ≥ 0.2.0).
16
+ * `enable`/`revert` act on the discovered presets (or a single file via
17
+ * `--file <path>`; `--presets-dir <path>` overrides discovery).
15
18
  */
16
19
  import { readFile, readdir, access } from "node:fs/promises";
17
20
  import { readFileSync } from "node:fs";
21
+ import { execFileSync } from "node:child_process";
18
22
  import { dirname, basename, resolve, join } from "node:path";
19
23
  import { homedir } from "node:os";
20
24
  import { fileURLToPath } from "node:url";
21
25
  import {
22
26
  compactionWireStatus,
27
+ discoverPresetsDir,
23
28
  provisionWebCompaction,
24
29
  revertWebCompaction,
25
30
  } from "../lib/web-provision.js";
@@ -50,32 +55,61 @@ async function compositionIn(dir) {
50
55
  return undefined;
51
56
  }
52
57
 
53
- /** Preset roots this CLI can act on: last-boot facts + the two well-known ones. */
54
- async function discoverRoots(status) {
55
- const roots = [];
56
- const seen = new Set();
57
- const push = (root) => {
58
- const abs = resolve(root);
59
- if (seen.has(abs)) return;
60
- seen.add(abs);
61
- roots.push(abs);
62
- };
63
- // Recorded directly from the last boot (authoritative for real installs).
64
- for (const entry of status?.web?.presets ?? []) {
65
- if (typeof entry?.path === "string") push(dirname(entry.path));
66
- }
67
- // Well-known roots: shipped harness + the user root.
68
- push(resolve(REPO_ROOT, "..", "deepseek-harness/apps/cli/config/agent-presets"));
58
+ /** Well-known LEGACY preset roots (DSH ≤ 0.1.x: one directory per preset). */
59
+ function legacyPresetRoots() {
69
60
  const dshHome = process.env.DSH_HOME ?? join(homedir(), ".dsh");
70
- push(join(dshHome, ".agent-presets"));
71
- return roots;
61
+ return [
62
+ resolve(REPO_ROOT, "..", "deepseek-harness/apps/cli/config/agent-presets"),
63
+ join(dshHome, ".agent-presets"),
64
+ ];
65
+ }
66
+
67
+ /** The `dsh` bin on PATH — anchors declarative preset discovery. */
68
+ function dshBinAnchor() {
69
+ try {
70
+ const found = execFileSync("which", ["dsh"], { encoding: "utf8" }).trim();
71
+ return found.length > 0 ? found : undefined;
72
+ } catch {
73
+ return undefined;
74
+ }
72
75
  }
73
76
 
74
- /** Discover preset compositions, shadowing duplicate ids like the roster does. */
75
- async function discoverPresets(status) {
76
- const seen = new Set();
77
+ /** Preset id from a composition file path (both generations). */
78
+ function presetIdFromPath(path) {
79
+ const base = basename(path);
80
+ if (base.endsWith(".patch.yml")) return base.slice(0, -".patch.yml".length);
81
+ return basename(dirname(path));
82
+ }
83
+
84
+ /**
85
+ * Discover preset compositions across BOTH generations, first match per id
86
+ * wins (the roster shadows duplicates the same way):
87
+ * 1. paths recorded by the last boot (authoritative for real installs);
88
+ * 2. legacy roots (`<root>/<id>/agent.cordis.yml` or `agent.json`);
89
+ * 3. declarative shipped presets (`<presetsDir>/<id>.patch.yml`).
90
+ */
91
+ async function discoverPresets(status, presetsDirFlag) {
77
92
  const presets = [];
78
- for (const root of await discoverRoots(status)) {
93
+ const seenId = new Set();
94
+ const seenPath = new Set();
95
+ const push = (id, path) => {
96
+ if (typeof path !== "string" || path.length === 0) return;
97
+ if (seenId.has(id) || seenPath.has(path)) return;
98
+ seenId.add(id);
99
+ seenPath.add(path);
100
+ presets.push({ id, path });
101
+ };
102
+
103
+ // 1. Recorded by the last boot.
104
+ for (const entry of status?.web?.presets ?? []) {
105
+ if (typeof entry?.path !== "string") continue;
106
+ if (!(await exists(entry.path))) continue;
107
+ const id = typeof entry.presetId === "string" ? entry.presetId : presetIdFromPath(entry.path);
108
+ push(id, entry.path);
109
+ }
110
+
111
+ // 2. Legacy roots.
112
+ for (const root of legacyPresetRoots()) {
79
113
  let entries = [];
80
114
  try {
81
115
  entries = await readdir(root, { withFileTypes: true });
@@ -84,11 +118,27 @@ async function discoverPresets(status) {
84
118
  }
85
119
  for (const entry of entries) {
86
120
  if (!entry.isDirectory()) continue;
87
- if (seen.has(entry.name)) continue;
88
121
  const file = await compositionIn(join(root, entry.name));
89
122
  if (file === undefined) continue;
90
- seen.add(entry.name);
91
- presets.push({ id: entry.name, path: file });
123
+ push(entry.name, file);
124
+ }
125
+ }
126
+
127
+ // 3. Declarative shipped presets.
128
+ const presetsDir = discoverPresetsDir({
129
+ presetsDir: presetsDirFlag,
130
+ anchors: [dshBinAnchor()].filter((anchor) => typeof anchor === "string"),
131
+ });
132
+ if (presetsDir !== undefined) {
133
+ let entries = [];
134
+ try {
135
+ entries = await readdir(presetsDir, { withFileTypes: true });
136
+ } catch {
137
+ entries = [];
138
+ }
139
+ for (const entry of entries) {
140
+ if (!entry.isFile() || !entry.name.endsWith(".patch.yml")) continue;
141
+ push(entry.name.slice(0, -".patch.yml".length), join(presetsDir, entry.name));
92
142
  }
93
143
  }
94
144
  return presets;
@@ -112,9 +162,35 @@ function webLine(entry) {
112
162
  }
113
163
  }
114
164
 
115
- async function printStatus() {
165
+ /** One-line label for the web PLANE (distinct from a single preset report). */
166
+ function webPlaneLine(web) {
167
+ const action = web?.action ?? "?";
168
+ const count = (web?.presets ?? []).length;
169
+ switch (action) {
170
+ case "pending": return "启动中(agentPresets 尚未就绪)";
171
+ case "disabled": return "未启用(autoWebCompaction=false;或用 npx dsh-engram enable 手动接管)";
172
+ case "no-default": return "未发现可用预设";
173
+ case "failed": return `失败 · ${web?.error ?? "?"}`;
174
+ default: return `上次装配 ${action},${count} 个预设`;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * The snapshot's web plane, normalized against the recorded opt-in: a `pending`
180
+ * plane while `autoWebCompaction=false` is impossible under the current plugin
181
+ * (that state is `disabled`); old snapshots written before v0.7.1 can carry it.
182
+ */
183
+ function effectiveWebPlane(status) {
184
+ const web = status.web;
185
+ if (status.configSource?.autoWebCompaction === false && (web === undefined || web.action === undefined || web.action === "pending")) {
186
+ return { action: "disabled", presets: web?.presets ?? [] };
187
+ }
188
+ return web;
189
+ }
190
+
191
+ async function printStatus(presetsDirFlag) {
116
192
  const status = await readContextGcStatus();
117
- const presets = await discoverPresets(status);
193
+ const presets = await discoverPresets(status, presetsDirFlag);
118
194
 
119
195
  log("── dsh-engram Context GC 状态 ──────────────────────────────");
120
196
  if (status === undefined) {
@@ -122,14 +198,31 @@ async function printStatus() {
122
198
  } else {
123
199
  log(` 状态快照:${new Date(status.writtenAt ?? 0).toLocaleString()}(插件上次启动时写入)`);
124
200
  log(` host 平面:${hostLine(status.host)}`);
125
- log(` web 平面:${status.web?.action === "pending" ? "启动中(agentPresets 尚未就绪)" : `上次装配 ${status.web?.action ?? "?"},${(status.web?.presets ?? []).length} 个预设`}`);
201
+ log(` web 平面:${webPlaneLine(effectiveWebPlane(status))}`);
126
202
  log(` 配置:autoWebCompaction=${status.configSource?.autoWebCompaction ?? "?"} · gcReplacesCompaction=${status.configSource?.gcReplacesCompaction ?? "?"} · gcNarrative=${status.configSource?.gcNarrative ?? "?"}`);
203
+ if (status.boundaryPrune !== undefined) {
204
+ const bp = status.boundaryPrune;
205
+ log(` L1 边界剪枝:${bp.armed ? "已武装" : "未启用"}${bp.reason ? `(${bp.reason})` : ""}`);
206
+ if (!bp.armed) {
207
+ log(" ⚠ L1 未武装时「节省 · 剪枝/压缩」不会入账(账本净 token 只会是负值);0.2.0 web profile 需要 bundle patch 重新启用 host 平面的 tool-result-pruner 行。");
208
+ }
209
+ }
210
+ if (status.configSource?.autoWebCompaction === false) {
211
+ log(" ⚠ autoWebCompaction=false:web 平面不会自动接管(设置卡「记忆 GC」里打开后重启 dsh web)");
212
+ }
127
213
  }
128
214
 
129
215
  log("");
130
216
  if (presets.length === 0) {
131
- log(" 未发现任何 web 代理预设(shipped 根与用户根都找不到)。");
217
+ log(" 未发现任何 web 代理预设(声明式 presets 目录、旧版 shipped 根与用户根都找不到)。");
218
+ log(" 提示:声明式预设需要能找到 dsh 安装(PATH 上的 dsh 或在 dsh 进程内运行),也可用 --presets-dir 指定。");
132
219
  } else {
220
+ const declarative = presets.filter((preset) => preset.path.endsWith(".patch.yml")).length;
221
+ if (declarative > 0) {
222
+ log(` 预设布局:声明式(dsh-web-app/presets/*.patch.yml,DSH ≥ 0.2.0)`);
223
+ } else {
224
+ log(" 预设布局:旧版组合文件(agent.cordis.yml,DSH ≤ 0.1.x)");
225
+ }
133
226
  log(` ── web 预设现场(${presets.length} 个)──`);
134
227
  for (const preset of presets) {
135
228
  let text;
@@ -151,22 +244,27 @@ async function printStatus() {
151
244
  return status;
152
245
  }
153
246
 
154
- async function printDoctor() {
155
- const status = await printStatus();
247
+ async function printDoctor(presetsDirFlag) {
248
+ const status = await printStatus(presetsDirFlag);
156
249
  log("");
157
250
  log("── 建议(doctor)──────────────────────────────────────────");
158
- const prev = status?.web?.presets ?? [];
159
- const presets = await discoverPresets(status);
251
+ const presets = await discoverPresets(status, presetsDirFlag);
160
252
  const issues = [];
253
+ const notes = [];
161
254
 
162
255
  if (status === undefined) {
163
256
  issues.push("尚无状态快照:把 dsh-engram 装进 profile 并启动一次(dsh web / dsh headless),插件会在启动时写入状态与自动装配。");
164
- } else if (status.host !== "context-gc") {
165
- issues.push(
166
- `host 平面 ${status.host}:${status.host === "unavailable"
167
- ? "引擎不可用——确认 @deepseek-ai/dsh-compaction-basic 依赖已安装(见启动日志 dsh-compaction-basic unavailable)。"
168
- : "当前是 DSH 默认 LLM 摘要——要在 host 平面启用 Context GC,把 gcReplacesCompaction 设为 true。"}`,
169
- );
257
+ } else {
258
+ if (status.host !== "context-gc") {
259
+ issues.push(
260
+ `host 平面 ${status.host}:${status.host === "unavailable"
261
+ ? "引擎不可用——确认 @deepseek-ai/dsh-compaction-basic 依赖已安装(见启动日志 dsh-compaction-basic unavailable)。"
262
+ : "当前是 DSH 默认 LLM 摘要——要在 host 平面启用 Context GC,把 gcReplacesCompaction 设为 true。"}`,
263
+ );
264
+ }
265
+ if (status.configSource?.autoWebCompaction === false) {
266
+ issues.push("autoWebCompaction=false:web 平面不会自动接管(即便预设仍是 stock 布局)。在设置卡「记忆 GC」里打开,或改 profile patch 后重启 dsh web。");
267
+ }
170
268
  }
171
269
 
172
270
  const stockLeft = presets.filter((p) => {
@@ -177,7 +275,7 @@ async function printDoctor() {
177
275
  }
178
276
  });
179
277
  if (stockLeft.length > 0) {
180
- issues.push(`web 平面还有 ${stockLeft.length} 个出厂布局预设未接管(${stockLeft.map((p) => p.id).join("、")}):重启 dsh web 让自动装配跑一次,或 npx dsh-engram enable。`);
278
+ issues.push(`web 平面还有 ${stockLeft.length} 个出厂布局预设未接管(${stockLeft.map((p) => p.id).join("、")}):重启 dsh web 让自动装配跑一次(需 autoWebCompaction=true),或 npx dsh-engram enable。`);
181
279
  }
182
280
  const customLeft = presets.filter((p) => {
183
281
  try {
@@ -189,14 +287,48 @@ async function printDoctor() {
189
287
  if (customLeft.length > 0) {
190
288
  issues.push(`${customLeft.length} 个预设是自定义/无 compaction 布局(${customLeft.map((p) => p.id).join("、")}),插件不会碰;需要的话手动把 compaction 组换成 dsh-engram/compaction。`);
191
289
  }
192
- if (presets.some((p) => compactionWireStatus(readFileSyncSafe(p.path)) === "wired")) {
290
+ const wired = presets.filter((p) => compactionWireStatus(readFileSyncSafe(p.path)) === "wired");
291
+ if (wired.length > 0) {
193
292
  issues.push("有预设已被接管:卸载 dsh-engram 前先 npx dsh-engram revert,避免预设留下悬空引用。");
293
+ if (wired.some((p) => /[\\/]node_modules[\\/]/.test(p.path))) {
294
+ notes.push("已接管的声明式预设在 node_modules 里(npm 更新 dsh 会恢复出厂布局):保持 autoWebCompaction=true 时下次启动 dsh web 会自动重新接管,否则升级后手动 npx dsh-engram enable。");
295
+ }
194
296
  }
297
+ const overrides = await presetOverrides();
298
+ if (overrides.length > 0) {
299
+ notes.push(`profile patch 里有 preset 覆写行(${overrides.join("、")}):Web 编辑器保存的 config.plugins 优先于 shipped 文件,改 shipped 文件可能不生效——请在编辑器里改,或删掉这些覆写行。`);
300
+ }
301
+
195
302
  if (issues.length === 0) {
196
303
  log(" 一切正常:host 平面 + 全部 stock 预设都已接管 Context GC。");
197
- return;
304
+ } else {
305
+ issues.forEach((line, index) => log(` ${index + 1}. ${line}`));
306
+ }
307
+ notes.forEach((line) => log(` · ${line}`));
308
+ }
309
+
310
+ /** `preset-*` rows saved in profile patches (Web-editor overrides). */
311
+ async function presetOverrides() {
312
+ const dshHome = process.env.DSH_HOME ?? join(homedir(), ".dsh");
313
+ const profilesDir = join(dshHome, "profiles");
314
+ let dirs = [];
315
+ try {
316
+ dirs = await readdir(profilesDir, { withFileTypes: true });
317
+ } catch {
318
+ return [];
198
319
  }
199
- issues.forEach((line, index) => log(` ${index + 1}. ${line}`));
320
+ const hits = new Set();
321
+ for (const dir of dirs) {
322
+ if (!dir.isDirectory()) continue;
323
+ let text;
324
+ try {
325
+ text = await readFile(join(profilesDir, dir.name, "cordis.patch.yml"), "utf8");
326
+ } catch {
327
+ continue;
328
+ }
329
+ for (const match of text.matchAll(/^\s*- id: (preset-[\w.-]+)\s*$/gm)) hits.add(match[1]);
330
+ }
331
+ return [...hits].sort();
200
332
  }
201
333
 
202
334
  /** Read helper that never throws (used by the sync-style filtering above). */
@@ -216,27 +348,36 @@ function toService(preset) {
216
348
  };
217
349
  }
218
350
 
219
- async function runEnableRevert(command, fileFlag) {
351
+ async function runEnableRevert(command, fileFlag, presetsDirFlag) {
220
352
  const status = await readContextGcStatus();
221
353
  let presets;
222
354
  if (fileFlag !== undefined) {
223
355
  const abs = resolve(fileFlag);
224
356
  if (!(await exists(abs))) throw new Error(`file not found: ${abs}`);
225
- presets = [{ id: basename(dirname(abs)), path: abs }];
357
+ presets = [{ id: presetIdFromPath(abs), path: abs }];
226
358
  } else {
227
- presets = await discoverPresets(status);
359
+ presets = await discoverPresets(status, presetsDirFlag);
228
360
  }
229
361
  if (presets.length === 0) {
230
- log("未发现可操作预设。用 --file <path> 明确指定一个预设组合文件。");
362
+ log("未发现可操作预设。用 --file <path> 明确指定一个预设组合文件,或用 --presets-dir <path> 指定声明式预设目录。");
231
363
  return;
232
364
  }
233
365
 
366
+ // Mirror the engine config the plugin last recorded, so a manual `enable`
367
+ // does not temporarily flip the toggles away from the settings card.
368
+ const rowConfig = status?.configSource !== undefined
369
+ ? {
370
+ gcReplacesCompaction: status.configSource.gcReplacesCompaction,
371
+ gcNarrative: status.configSource.gcNarrative,
372
+ }
373
+ : undefined;
374
+
234
375
  log(`dsh-engram: ${command} — ${presets.length} 个预设`);
235
376
  let changed = 0;
236
377
  for (const preset of presets) {
237
378
  const service = toService(preset);
238
379
  const report = command === "enable"
239
- ? await provisionWebCompaction(service, { log })
380
+ ? await provisionWebCompaction(service, { log, rowConfig })
240
381
  : await revertWebCompaction(service, { log });
241
382
  const entry = report.presets[0];
242
383
  log(` ${preset.id}: ${entry?.action ?? report.action}${entry?.reason ? ` (${entry.reason})` : ""}`);
@@ -251,12 +392,14 @@ async function main() {
251
392
  const command = rawCommand ?? "status";
252
393
  const fileFlagIndex = rest.indexOf("--file");
253
394
  const fileFlag = fileFlagIndex >= 0 ? rest[fileFlagIndex + 1] : undefined;
395
+ const presetsDirIndex = rest.indexOf("--presets-dir");
396
+ const presetsDirFlag = presetsDirIndex >= 0 ? rest[presetsDirIndex + 1] : undefined;
254
397
  if (command === "status") {
255
- await printStatus();
398
+ await printStatus(presetsDirFlag);
256
399
  } else if (command === "doctor") {
257
- await printDoctor();
400
+ await printDoctor(presetsDirFlag);
258
401
  } else if (command === "enable" || command === "revert") {
259
- await runEnableRevert(command, fileFlag);
402
+ await runEnableRevert(command, fileFlag, presetsDirFlag);
260
403
  } else {
261
404
  throw new Error(`未知命令 "${command}"(用 status | doctor | enable | revert)`);
262
405
  }