dsh-auto-compaction-engine 0.2.2 → 0.2.3

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/index.js CHANGED
@@ -43,6 +43,12 @@ const SETTINGS_DIR = join(homedir(), ".dsh-handoff-compaction");
43
43
  // into a case that expects the configured ratio.
44
44
  const SETTINGS_FILE = process.env.DSH_HANDOFF_SETTINGS_FILE ?? join(SETTINGS_DIR, "settings.json");
45
45
  const DEFAULT_TEMPLATE_FILE = join(SETTINGS_DIR, "template.md");
46
+ /**
47
+ * Trace goes to the standard sidecar unless a row says otherwise. Generated
48
+ * takeover rows carry no path of their own, so defaulting here is what keeps
49
+ * the diagnostic channel alive; an explicit `traceFile: ""` still turns it off.
50
+ */
51
+ const DEFAULT_TRACE_FILE = process.env.DSH_HANDOFF_TRACE_FILE ?? join(SETTINGS_DIR, "trace.log");
46
52
 
47
53
  /**
48
54
  * Live settings written by the companion UI package (dsh-handoff-compaction-ui).
@@ -216,7 +222,7 @@ export default class HandoffCompaction extends BasicCompactionEngine {
216
222
  presetId: typeof presetId === "string" ? presetId : "",
217
223
  autoCreateTemplate: autoCreateTemplate !== false,
218
224
  };
219
- this.handoff.traceFile = typeof traceFile === "string" ? traceFile : "";
225
+ this.handoff.traceFile = typeof traceFile === "string" ? traceFile : DEFAULT_TRACE_FILE;
220
226
  this.handoff.traceConsole = traceConsole === true;
221
227
  // Materialise the template so the settings card can point at a real file.
222
228
  try { readTemplate(this.handoff.templateFile, this.handoff.autoCreateTemplate); } catch { /* never block construction */ }
@@ -245,10 +251,26 @@ export default class HandoffCompaction extends BasicCompactionEngine {
245
251
  /**
246
252
  * Settings switch. Missing settings or a missing field keeps the plugin on;
247
253
  * only an explicit `enabled: false` hands compaction back to the stock engine.
254
+ *
255
+ * Scope is decided here rather than at the idle gate because the takeover row
256
+ * is mounted in every preset: a preset the user did not select must behave
257
+ * exactly like the stock engine on every entry point, not just skip its own
258
+ * idle check. `presetId` empty means the row carried no identity, which keeps
259
+ * the plugin on rather than silently disabling it.
248
260
  */
249
261
  handoffEnabled() {
250
262
  const live = readSettings();
251
- return !(live !== undefined && live.enabled === false);
263
+ if (live !== undefined && live.enabled === false) return false;
264
+ return !this.excludedByScope(live);
265
+ }
266
+
267
+ /** True when `scope: "preset"` leaves this preset out of the takeover. */
268
+ excludedByScope(live = readSettings()) {
269
+ if (live?.scope !== "preset") return false;
270
+ const id = this.handoff?.presetId;
271
+ if (typeof id !== "string" || id === "") return false;
272
+ const selected = Array.isArray(live.presets) ? live.presets : [];
273
+ return !selected.includes(id);
252
274
  }
253
275
 
254
276
  /**
@@ -297,6 +319,9 @@ export default class HandoffCompaction extends BasicCompactionEngine {
297
319
 
298
320
  async checkAndHandoff(agent, why = "idle") {
299
321
  if (this.running.has(agent)) return;
322
+ if (this.excludedByScope()) {
323
+ return this.log(`${why}: skipped by scope (preset "${this.handoff.presetId}" not selected)`);
324
+ }
300
325
  if (!this.handoffEnabled()) return;
301
326
  try {
302
327
  const session = agent.session;
@@ -315,11 +340,6 @@ export default class HandoffCompaction extends BasicCompactionEngine {
315
340
  : info.context?.contextWindow;
316
341
 
317
342
  const live = readSettings();
318
- const scope = live?.scope === "preset" ? "preset" : "global";
319
- const selectedPresets = Array.isArray(live?.presets) ? live.presets : [];
320
- if (scope === "preset" && this.handoff.presetId !== "" && !selectedPresets.includes(this.handoff.presetId)) {
321
- return this.log(`${why}: skipped by scope (preset "${this.handoff.presetId}" not selected)`);
322
- }
323
343
  const liveRatio = typeof live?.thresholdRatio === "number" && live.thresholdRatio > 0 && live.thresholdRatio <= 1
324
344
  ? live.thresholdRatio
325
345
  : undefined;
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Takeover rows for the presets DSH currently declares.
3
+ *
4
+ * A preset runs in its own realm, so handing compaction to this plugin can only
5
+ * be expressed by replacing the preset's whole row: the patch language matches
6
+ * rows by id and assigns `config` wholesale, and it cannot reach a row nested
7
+ * inside an agent preset's plugin list. That makes generated rows the only
8
+ * correct way to take over, and it means every other plugin of that preset has
9
+ * to be carried along — which is why the input is the live definition read from
10
+ * `agentPresets`, never a copy baked into this package. A baked copy would
11
+ * silently delete tools DSH adds in a later release.
12
+ *
13
+ * The rows go into this plugin's own bundle patch, so uninstalling takes them
14
+ * away with the package: the user's preset files are never touched, and native
15
+ * compaction comes back on its own.
16
+ */
17
+
18
+ import { createRequire } from "node:module";
19
+ import path from "node:path";
20
+
21
+ /* js-yaml is this package's own dependency; the development workspace installs
22
+ * nothing, so fall back to the copy DSH keeps in the profile, same order as
23
+ * scripts/preset-scope.mjs. */
24
+ function loadYaml() {
25
+ const here = createRequire(import.meta.url);
26
+ try {
27
+ return here("js-yaml");
28
+ } catch { /* fall through */ }
29
+ const appData = process.env.APP_DATA
30
+ ?? path.join(process.env.SystemDrive ?? "C:", "Users", process.env.USERNAME ?? process.env.USER ?? "", "AppData", "Roaming");
31
+ return createRequire(path.join(appData, "dsh-desktop", "harness", "profiles", "web", "package.json"))("js-yaml");
32
+ }
33
+ const yaml = loadYaml();
34
+
35
+ const BEGIN = "# >>> handoff:takeover:begin (dsh-auto-compaction 按当前预设组合生成,勿手改本块)";
36
+ const END = "# <<< handoff:takeover:end";
37
+
38
+ const COMPACTION_GROUP = "compaction";
39
+ const NATIVE_ROW = "compaction-basic";
40
+
41
+ /**
42
+ * A `!!js` scalar carried through untouched.
43
+ *
44
+ * Presets use these for platform-conditional rows (`disabled: !!js
45
+ * process.platform === 'win32'`). Replacing a preset's config means re-emitting
46
+ * every one of its values, and writing such an expression as a plain string
47
+ * would silently change the preset: the loader only evaluates tagged scalars, so
48
+ * a string in `disabled` is just a truthy value. These rows must therefore
49
+ * survive as the tag they were written as.
50
+ */
51
+ export class JsExpression {
52
+ constructor(text) {
53
+ this.text = String(text ?? "");
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Path of the first value that cannot be re-emitted, or "" when all can.
59
+ *
60
+ * A function or symbol reaching us means the composition parsed `!!js` into
61
+ * something this writer cannot turn back into a tag; inventing a row from it
62
+ * would alter the preset, so the caller leaves that preset alone instead.
63
+ */
64
+ export function findUnsupported(value, trail = "") {
65
+ if (value instanceof JsExpression) return "";
66
+ if (typeof value === "function" || typeof value === "symbol") return trail || "根值";
67
+ if (Array.isArray(value)) {
68
+ for (let index = 0; index < value.length; index += 1) {
69
+ const found = findUnsupported(value[index], `${trail}[${index}]`);
70
+ if (found !== "") return found;
71
+ }
72
+ return "";
73
+ }
74
+ if (value !== null && typeof value === "object") {
75
+ for (const [key, inner] of Object.entries(value)) {
76
+ const found = findUnsupported(inner, trail ? `${trail}.${key}` : key);
77
+ if (found !== "") return found;
78
+ }
79
+ }
80
+ return "";
81
+ }
82
+
83
+ /** Defaults this plugin needs; values already on the preset's own row win. */
84
+ const ENGINE_DEFAULTS = {
85
+ thresholdRatio: 0.9,
86
+ headroomTokens: 10000,
87
+ maxTokens: 12288,
88
+ idleDelayMs: 1500,
89
+ };
90
+
91
+ /**
92
+ * Parse the plugin list `agentPresets.readDocument()` publishes.
93
+ *
94
+ * That method returns the composition **as a YAML string** ("read one
95
+ * declaration's child plugin list as YAML"), not as data, so the takeover rows
96
+ * have to parse it back with the same `!!js` type they write with. An array is
97
+ * accepted too, in case a future host hands back the value directly.
98
+ */
99
+ export function parsePlugins(content) {
100
+ if (Array.isArray(content)) return { plugins: content };
101
+ if (typeof content !== "string" || content.trim().length === 0) {
102
+ return { plugins: undefined, reason: "预设没有给出 plugins" };
103
+ }
104
+ try {
105
+ const parsed = yaml.load(content, { schema: jsSchema() });
106
+ if (!Array.isArray(parsed)) return { plugins: undefined, reason: "plugins 不是列表" };
107
+ return { plugins: parsed };
108
+ } catch (error) {
109
+ return { plugins: undefined, reason: `plugins 解析失败: ${error instanceof Error ? error.message : String(error)}` };
110
+ }
111
+ }
112
+
113
+ /**
114
+ * The row that replaces a preset's stock compaction provider.
115
+ *
116
+ * `presetId` is what lets one mounted engine serve several presets while the
117
+ * user's scope selection decides which ones it actually takes over.
118
+ *
119
+ * `inherit` is the config already sitting on the preset's stock row: a user who
120
+ * tuned `thresholdRatio` or `headroomTokens` there keeps that tuning, and this
121
+ * plugin only fills in what is missing.
122
+ */
123
+ export function engineRow(presetId, options = {}) {
124
+ return {
125
+ id: NATIVE_ROW,
126
+ name: options.engineName ?? "dsh-auto-compaction-engine",
127
+ config: { ...ENGINE_DEFAULTS, ...(options.inherit ?? {}), ...(options.config ?? {}), presetId },
128
+ };
129
+ }
130
+
131
+ /**
132
+ * The compaction provider a preset's live definition actually names.
133
+ *
134
+ * This is what tells "the rows were written" apart from "the rows are in
135
+ * effect": the definition DSH registers comes from the composition, so if the
136
+ * takeover row was applied and resolved, the provider here is this plugin's
137
+ * engine rather than the stock one.
138
+ */
139
+ export function compactionProvider(config) {
140
+ const group = compactionGroup(config?.plugins);
141
+ if (group === undefined) return undefined;
142
+ const row = group.config.find((entry) => entry?.id === NATIVE_ROW);
143
+ if (row === undefined) return undefined;
144
+ return { name: typeof row.name === "string" ? row.name : "", disabled: row.disabled === true };
145
+ }
146
+
147
+ /** The compaction group inside a preset's plugin list, or undefined. */
148
+ function compactionGroup(plugins) {
149
+ if (!Array.isArray(plugins)) return undefined;
150
+ return plugins.find(
151
+ (entry) => entry !== null && typeof entry === "object"
152
+ && entry.id === COMPACTION_GROUP && entry.group === true && Array.isArray(entry.config),
153
+ );
154
+ }
155
+
156
+ /**
157
+ * One preset's replacement row, or null when that preset cannot be taken over.
158
+ *
159
+ * @param preset the live row as DSH declares it: { id, name?, config: { plugins } }
160
+ */
161
+ export function takeoverRow(preset, options = {}) {
162
+ const id = typeof preset?.id === "string" && preset.id.length > 0 ? preset.id : undefined;
163
+ if (id === undefined) return null;
164
+ const plugins = Array.isArray(preset?.config?.plugins) ? preset.config.plugins : undefined;
165
+ if (plugins === undefined) return null;
166
+ // Replacing a preset's config re-emits everything in it; a value this writer
167
+ // cannot express faithfully is a reason to leave the preset alone.
168
+ if (findUnsupported(preset.config) !== "") return null;
169
+
170
+ const group = compactionGroup(plugins);
171
+ if (group === undefined) return null;
172
+ const index = group.config.findIndex((entry) => entry?.id === NATIVE_ROW);
173
+ if (index < 0) return null;
174
+ const current = group.config[index];
175
+ // A row the user switched off is their decision; taking it over would quietly
176
+ // turn compaction back on, so leave that preset alone.
177
+ if (current.disabled === true) return null;
178
+
179
+ const nextGroupRows = group.config.slice();
180
+ nextGroupRows[index] = engineRow(id, { ...options, inherit: current.config });
181
+
182
+ const nextPlugins = plugins.map((entry) => (entry === group ? { ...group, config: nextGroupRows } : entry));
183
+ const row = {
184
+ id: `preset-${id}`,
185
+ config: { ...preset.config, plugins: nextPlugins },
186
+ };
187
+ // `name` is an assertion, not a value to write: DSH skips the whole patch when
188
+ // it disagrees with the target, so carry it only when the composition has one.
189
+ if (typeof preset.name === "string" && preset.name.length > 0) row.name = preset.name;
190
+ return row;
191
+ }
192
+
193
+ /** Rows for every takeable preset, in the order given. */
194
+ export function takeoverRows(presets, options = {}) {
195
+ const rows = [];
196
+ for (const preset of presets ?? []) {
197
+ const row = takeoverRow(preset, options);
198
+ if (row !== null) rows.push(row);
199
+ }
200
+ return rows;
201
+ }
202
+
203
+ /** The managed block, empty markers included, for the given rows. */
204
+ export function renderBlock(rows) {
205
+ const lines = [BEGIN];
206
+ if (rows.length > 0) lines.push(stringifyRows(rows));
207
+ lines.push(END);
208
+ return lines.join("\n");
209
+ }
210
+
211
+ /**
212
+ * Put the block into a patch file, leaving hand-written content untouched.
213
+ * A file without the block yet gets it appended.
214
+ */
215
+ export function mergeBlock(text, block) {
216
+ const source = typeof text === "string" ? text : "";
217
+ const begin = source.indexOf(BEGIN);
218
+ const end = source.indexOf(END);
219
+ if (begin < 0 || end < 0 || end < begin) {
220
+ const trimmed = source.replace(/\s+$/, "");
221
+ return trimmed.length === 0 ? block + "\n" : `${trimmed}\n${block}\n`;
222
+ }
223
+ return source.slice(0, begin) + block + source.slice(end + END.length);
224
+ }
225
+
226
+ /** True when the file does not already carry this block. */
227
+ export function needsRewrite(text, block) {
228
+ return mergeBlock(text, block) !== text;
229
+ }
230
+
231
+ /* A dedicated writer is tempting but wrong here: presets carry long multi-line
232
+ * prompt strings, and hand-rolled quoting folds their line breaks differently
233
+ * from the source, which would silently rewrite the agent's instructions.
234
+ * js-yaml with a registered `!!js` type round-trips both, and the options pin
235
+ * the layout so the block stays byte-identical between boots. */
236
+ function stringifyRows(rows) {
237
+ return yaml.dump(rows, {
238
+ schema: jsSchema(),
239
+ indent: 2,
240
+ lineWidth: -1,
241
+ noRefs: true,
242
+ quotingType: "'",
243
+ forceQuotes: false,
244
+ skipInvalid: false,
245
+ }).replace(/\n+$/, "");
246
+ }
247
+
248
+ function jsSchema() {
249
+ return yaml.DEFAULT_SCHEMA.extend([
250
+ new yaml.Type("tag:yaml.org,2002:js", {
251
+ kind: "scalar",
252
+ construct: (data) => new JsExpression(data),
253
+ instanceOf: JsExpression,
254
+ represent: (value) => value.text,
255
+ }),
256
+ ]);
257
+ }
258
+
259
+ export const BLOCK_MARKERS = { BEGIN, END };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-auto-compaction-engine",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "main": "lib/index.js",
6
6
  "exports": {