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 +27 -7
- package/lib/takeover-rows.js +259 -0
- package/package.json +1 -1
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
|
-
|
|
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 };
|