dsh-auto-compaction 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/cordis.patch.yml CHANGED
@@ -8,3 +8,482 @@
8
8
  - insert:
9
9
  - id: dsh-auto-compaction
10
10
  name: dsh-auto-compaction
11
+ # >>> handoff:takeover:begin (dsh-auto-compaction 按当前预设组合生成,勿手改本块)
12
+ - id: preset-cordis
13
+ config:
14
+ id: cordis
15
+ order: 4
16
+ plugins:
17
+ - id: persona
18
+ name: '@deepseek-ai/dsh-persona'
19
+ config:
20
+ prefix: You are a coding agent powered by the {{model}} model.
21
+ - id: agent-instructions
22
+ name: '@deepseek-ai/dsh-agent-instructions'
23
+ config:
24
+ maxBytes: 65536
25
+ - id: time-context
26
+ name: '@deepseek-ai/dsh-time-context'
27
+ - id: tool-bash
28
+ name: '@deepseek-ai/dsh-tool-bash'
29
+ disabled: !!js process.platform === 'win32'
30
+ - id: tool-pwsh
31
+ name: '@deepseek-ai/dsh-tool-pwsh'
32
+ disabled: !!js process.platform !== 'win32'
33
+ - id: tool-fs
34
+ name: '@deepseek-ai/dsh-tool-fs'
35
+ - id: tool-fs-search
36
+ name: '@deepseek-ai/dsh-tool-fs-search'
37
+ config:
38
+ sampleOverCapGlobResults: false
39
+ - id: tool-jobs
40
+ name: '@deepseek-ai/dsh-tool-jobs'
41
+ - id: tool-schedule
42
+ name: '@deepseek-ai/dsh-tool-schedule'
43
+ - id: command-goal
44
+ name: '@deepseek-ai/dsh-command-goal'
45
+ - id: tool-goal
46
+ name: '@deepseek-ai/dsh-tool-goal'
47
+ - id: planning
48
+ name: cordis:group
49
+ group: true
50
+ isolate:
51
+ planMode: true
52
+ config:
53
+ - id: plan-mode
54
+ name: '@deepseek-ai/dsh-plan-mode'
55
+ config:
56
+ section: |
57
+ You are in plan mode. Stay in plan mode until the user approves your plan through exit_plan_mode or switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
58
+
59
+ Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery.
60
+
61
+ The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed to keep the tool catalog unchanged. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode.
62
+
63
+ Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out.
64
+
65
+ Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions.
66
+
67
+ When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation.
68
+ - id: compaction
69
+ name: cordis:group
70
+ group: true
71
+ isolate:
72
+ compaction: true
73
+ toolResultPruner: true
74
+ config:
75
+ - id: compaction-basic
76
+ name: dsh-auto-compaction-engine
77
+ config:
78
+ thresholdRatio: 0.9
79
+ headroomTokens: 10000
80
+ maxTokens: 12288
81
+ idleDelayMs: 1500
82
+ presetId: cordis
83
+ - id: command-compact
84
+ name: '@deepseek-ai/dsh-command-compact'
85
+ - id: tool-result-pruner
86
+ name: '@deepseek-ai/dsh-compaction-tool-result-pruner'
87
+ config:
88
+ thresholdChars: 8192
89
+ headChars: 4096
90
+ tailChars: 1024
91
+ - id: delegation
92
+ name: cordis:group
93
+ group: true
94
+ isolate:
95
+ workflowEngine: true
96
+ config:
97
+ - id: tool-subagent-control
98
+ name: '@deepseek-ai/dsh-tool-subagent-control'
99
+ - id: tool-subagent-list-agents
100
+ name: '@deepseek-ai/dsh-tool-subagent-control/list-agents'
101
+ - id: tool-subagent
102
+ name: '@deepseek-ai/dsh-tool-subagent'
103
+ config:
104
+ provider: spawn
105
+ toolName: subagent
106
+ modelSelectionSettings: true
107
+ - id: tool-subagent-fork
108
+ name: '@deepseek-ai/dsh-tool-subagent'
109
+ config:
110
+ provider: fork
111
+ toolName: subagent_fork
112
+ - id: workflow-ptc
113
+ name: '@deepseek-ai/dsh-workflow-ptc'
114
+ config:
115
+ provider: spawn
116
+ - id: tool-workflow
117
+ name: '@deepseek-ai/dsh-tool-workflow'
118
+ - id: tool-ask-user
119
+ name: '@deepseek-ai/dsh-tool-ask-user'
120
+ - id: tool-todo
121
+ name: '@deepseek-ai/dsh-tool-todo'
122
+ config:
123
+ allowParallelInProgress: true
124
+ - id: tool-web
125
+ name: '@deepseek-ai/dsh-tool-web'
126
+ config:
127
+ fetch: true
128
+ searchTimeoutMs: 60000
129
+ - id: tool-cordis
130
+ name: '@deepseek-ai/dsh-tool-cordis'
131
+ - id: skill-filesystem
132
+ name: '@deepseek-ai/dsh-skill-filesystem'
133
+ config:
134
+ customSkillDirs:
135
+ - !!js process.getBuiltinModule('node:path').join(process.getBuiltinModule('node:path').dirname(process.getBuiltinModule('node:module').createRequire(baseUrl).resolve('@deepseek-ai/dsh-agent-preset/package.json')), 'skills')
136
+ - id: tool-skill
137
+ name: '@deepseek-ai/dsh-tool-skill'
138
+ - id: present
139
+ name: '@deepseek-ai/dsh-tool-present'
140
+ - id: tool-plugin-manager
141
+ name: '@deepseek-ai/dsh-plugin-manager/tools'
142
+ disabled: !!js '!ctx.get(''profileContext'')'
143
+ - id: tool-plugin-market
144
+ name: '@deepseek-ai/dsh-plugin-market/tools'
145
+ disabled: !!js '!ctx.get(''profileContext'')'
146
+ - id: preset-minimal
147
+ config:
148
+ id: minimal
149
+ order: 3
150
+ plugins:
151
+ - id: persona
152
+ name: '@deepseek-ai/dsh-persona'
153
+ config:
154
+ prefix: You are a helpful software engineer assistant.
155
+ complete: true
156
+ includeRuntimeContext: false
157
+ - id: tool-fs
158
+ name: '@deepseek-ai/dsh-tool-fs'
159
+ config:
160
+ read: false
161
+ readImage: true
162
+ write: false
163
+ edit: false
164
+ - id: compaction
165
+ name: cordis:group
166
+ group: true
167
+ isolate:
168
+ compaction: true
169
+ config:
170
+ - id: compaction-basic
171
+ name: dsh-auto-compaction-engine
172
+ config:
173
+ thresholdRatio: 0.9
174
+ headroomTokens: 10000
175
+ maxTokens: 12288
176
+ idleDelayMs: 1500
177
+ presetId: minimal
178
+ - id: command-compact
179
+ name: '@deepseek-ai/dsh-command-compact'
180
+ - id: persistent-shell
181
+ name: cordis:group
182
+ group: true
183
+ isolate:
184
+ terminals: true
185
+ config:
186
+ - id: pty
187
+ name: '@deepseek-ai/dsh-terminal'
188
+ - id: terminal-bash
189
+ name: '@deepseek-ai/dsh-terminal-bash'
190
+ disabled: !!js process.platform === 'win32'
191
+ config:
192
+ timeoutMs: 300000
193
+ - id: persistent-bash
194
+ name: '@deepseek-ai/dsh-tool-bash-persistent'
195
+ disabled: !!js process.platform === 'win32'
196
+ config:
197
+ timeoutMs: 300000
198
+ description: |-
199
+ Run commands in a bash shell
200
+ * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped.
201
+ * Network access depends on the task environment. Prefer configured mirrors/proxies when they are available.
202
+ * State is persistent across command calls and discussions with the user.
203
+ * To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.
204
+ * Please avoid commands that may produce a very large amount of output.
205
+ * Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.
206
+ - id: terminal-pwsh
207
+ name: '@deepseek-ai/dsh-terminal-bash'
208
+ disabled: !!js process.platform !== 'win32'
209
+ config:
210
+ shellDialect: pwsh
211
+ timeoutMs: 300000
212
+ - id: persistent-pwsh
213
+ name: '@deepseek-ai/dsh-tool-pwsh-persistent'
214
+ disabled: !!js process.platform !== 'win32'
215
+ config:
216
+ timeoutMs: 300000
217
+ description: |-
218
+ Run commands in a PowerShell shell
219
+ * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped.
220
+ * You don't have access to the internet via this tool.
221
+ * State is persistent across command calls and discussions with the user.
222
+ * Use native Windows paths (C:\...) and $env:NAME variables; this is PowerShell, not bash.
223
+ * Please avoid commands that may produce a very large amount of output.
224
+ * Please run long lived commands in the background, e.g. 'Start-Job' or start a server with Start-Process.
225
+ - id: preset-ptc
226
+ config:
227
+ id: ptc
228
+ order: 2
229
+ plugins:
230
+ - id: persona
231
+ name: '@deepseek-ai/dsh-persona'
232
+ config:
233
+ prefix: You are a coding agent powered by the {{model}} model.
234
+ - id: agent-instructions
235
+ name: '@deepseek-ai/dsh-agent-instructions'
236
+ config:
237
+ maxBytes: 65536
238
+ - id: time-context
239
+ name: '@deepseek-ai/dsh-time-context'
240
+ - id: tool-bash
241
+ name: '@deepseek-ai/dsh-tool-bash'
242
+ disabled: !!js process.platform === 'win32'
243
+ - id: tool-pwsh
244
+ name: '@deepseek-ai/dsh-tool-pwsh'
245
+ disabled: !!js process.platform !== 'win32'
246
+ - id: tool-fs
247
+ name: '@deepseek-ai/dsh-tool-fs'
248
+ - id: tool-fs-search
249
+ name: '@deepseek-ai/dsh-tool-fs-search'
250
+ config:
251
+ sampleOverCapGlobResults: false
252
+ - id: tool-jobs
253
+ name: '@deepseek-ai/dsh-tool-jobs'
254
+ - id: tool-schedule
255
+ name: '@deepseek-ai/dsh-tool-schedule'
256
+ - id: skill-filesystem
257
+ name: '@deepseek-ai/dsh-skill-filesystem'
258
+ - id: tool-skill
259
+ name: '@deepseek-ai/dsh-tool-skill'
260
+ - id: command-goal
261
+ name: '@deepseek-ai/dsh-command-goal'
262
+ - id: tool-goal
263
+ name: '@deepseek-ai/dsh-tool-goal'
264
+ - id: planning
265
+ name: cordis:group
266
+ group: true
267
+ isolate:
268
+ planMode: true
269
+ config:
270
+ - id: plan-mode
271
+ name: '@deepseek-ai/dsh-plan-mode'
272
+ config:
273
+ section: |
274
+ You are in plan mode. Stay in plan mode until the user approves your plan through exit_plan_mode or switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
275
+
276
+ Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery.
277
+
278
+ The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed to keep the tool catalog unchanged. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode.
279
+
280
+ Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out.
281
+
282
+ Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions.
283
+
284
+ When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation.
285
+ - id: compaction
286
+ name: cordis:group
287
+ group: true
288
+ isolate:
289
+ compaction: true
290
+ toolResultPruner: true
291
+ config:
292
+ - id: compaction-basic
293
+ name: dsh-auto-compaction-engine
294
+ config:
295
+ thresholdRatio: 0.9
296
+ headroomTokens: 10000
297
+ maxTokens: 12288
298
+ idleDelayMs: 1500
299
+ presetId: ptc
300
+ - id: command-compact
301
+ name: '@deepseek-ai/dsh-command-compact'
302
+ - id: tool-result-pruner
303
+ name: '@deepseek-ai/dsh-compaction-tool-result-pruner'
304
+ config:
305
+ thresholdChars: 8192
306
+ headChars: 4096
307
+ tailChars: 1024
308
+ - id: delegation
309
+ name: cordis:group
310
+ group: true
311
+ isolate:
312
+ workflowEngine: true
313
+ config:
314
+ - id: tool-subagent-control
315
+ name: '@deepseek-ai/dsh-tool-subagent-control'
316
+ - id: tool-subagent-list-agents
317
+ name: '@deepseek-ai/dsh-tool-subagent-control/list-agents'
318
+ - id: tool-subagent
319
+ name: '@deepseek-ai/dsh-tool-subagent'
320
+ config:
321
+ provider: spawn
322
+ toolName: subagent
323
+ modelSelectionSettings: true
324
+ - id: tool-subagent-fork
325
+ name: '@deepseek-ai/dsh-tool-subagent'
326
+ config:
327
+ provider: fork
328
+ toolName: subagent_fork
329
+ - id: workflow-ptc
330
+ name: '@deepseek-ai/dsh-workflow-ptc'
331
+ disabled: true
332
+ config:
333
+ provider: spawn
334
+ - id: tool-workflow
335
+ name: '@deepseek-ai/dsh-tool-workflow'
336
+ disabled: true
337
+ - id: tool-ask-user
338
+ name: '@deepseek-ai/dsh-tool-ask-user'
339
+ - id: tool-todo
340
+ name: '@deepseek-ai/dsh-tool-todo'
341
+ config:
342
+ allowParallelInProgress: true
343
+ - id: tool-web
344
+ name: '@deepseek-ai/dsh-tool-web'
345
+ config:
346
+ fetch: true
347
+ searchTimeoutMs: 60000
348
+ - id: tool-presentation
349
+ name: '@deepseek-ai/dsh-agent-tool-presentation'
350
+ config:
351
+ mode: ptc
352
+ - id: present
353
+ name: '@deepseek-ai/dsh-tool-present'
354
+ - id: tool-plugin-manager
355
+ name: '@deepseek-ai/dsh-plugin-manager/tools'
356
+ disabled: true
357
+ - id: tool-plugin-market
358
+ name: '@deepseek-ai/dsh-plugin-market/tools'
359
+ disabled: true
360
+ - id: preset-standard
361
+ config:
362
+ id: standard
363
+ order: 1
364
+ plugins:
365
+ - id: persona
366
+ name: '@deepseek-ai/dsh-persona'
367
+ config:
368
+ prefix: You are a coding agent powered by the {{model}} model.
369
+ - id: agent-instructions
370
+ name: '@deepseek-ai/dsh-agent-instructions'
371
+ config:
372
+ maxBytes: 65536
373
+ - id: time-context
374
+ name: '@deepseek-ai/dsh-time-context'
375
+ - id: tool-bash
376
+ name: '@deepseek-ai/dsh-tool-bash'
377
+ disabled: !!js process.platform === 'win32'
378
+ - id: tool-pwsh
379
+ name: '@deepseek-ai/dsh-tool-pwsh'
380
+ disabled: !!js process.platform !== 'win32'
381
+ - id: tool-fs
382
+ name: '@deepseek-ai/dsh-tool-fs'
383
+ - id: tool-fs-search
384
+ name: '@deepseek-ai/dsh-tool-fs-search'
385
+ config:
386
+ sampleOverCapGlobResults: false
387
+ - id: tool-jobs
388
+ name: '@deepseek-ai/dsh-tool-jobs'
389
+ - id: tool-schedule
390
+ name: '@deepseek-ai/dsh-tool-schedule'
391
+ - id: skill-filesystem
392
+ name: '@deepseek-ai/dsh-skill-filesystem'
393
+ - id: tool-skill
394
+ name: '@deepseek-ai/dsh-tool-skill'
395
+ - id: command-goal
396
+ name: '@deepseek-ai/dsh-command-goal'
397
+ - id: tool-goal
398
+ name: '@deepseek-ai/dsh-tool-goal'
399
+ - id: planning
400
+ name: cordis:group
401
+ group: true
402
+ isolate:
403
+ planMode: true
404
+ config:
405
+ - id: plan-mode
406
+ name: '@deepseek-ai/dsh-plan-mode'
407
+ config:
408
+ section: |
409
+ You are in plan mode. Stay in plan mode until the user approves your plan through exit_plan_mode or switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
410
+
411
+ Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery.
412
+
413
+ The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed to keep the tool catalog unchanged. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode.
414
+
415
+ Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out.
416
+
417
+ Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions.
418
+
419
+ When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation.
420
+ - id: compaction
421
+ name: cordis:group
422
+ group: true
423
+ isolate:
424
+ compaction: true
425
+ toolResultPruner: true
426
+ config:
427
+ - id: compaction-basic
428
+ name: dsh-auto-compaction-engine
429
+ config:
430
+ thresholdRatio: 0.9
431
+ headroomTokens: 10000
432
+ maxTokens: 12288
433
+ idleDelayMs: 1500
434
+ presetId: standard
435
+ - id: command-compact
436
+ name: '@deepseek-ai/dsh-command-compact'
437
+ - id: tool-result-pruner
438
+ name: '@deepseek-ai/dsh-compaction-tool-result-pruner'
439
+ config:
440
+ thresholdChars: 8192
441
+ headChars: 4096
442
+ tailChars: 1024
443
+ - id: delegation
444
+ name: cordis:group
445
+ group: true
446
+ isolate:
447
+ workflowEngine: true
448
+ config:
449
+ - id: tool-subagent-control
450
+ name: '@deepseek-ai/dsh-tool-subagent-control'
451
+ - id: tool-subagent-list-agents
452
+ name: '@deepseek-ai/dsh-tool-subagent-control/list-agents'
453
+ - id: tool-subagent
454
+ name: '@deepseek-ai/dsh-tool-subagent'
455
+ config:
456
+ provider: spawn
457
+ toolName: subagent
458
+ modelSelectionSettings: true
459
+ - id: tool-subagent-fork
460
+ name: '@deepseek-ai/dsh-tool-subagent'
461
+ config:
462
+ provider: fork
463
+ toolName: subagent_fork
464
+ - id: workflow-ptc
465
+ name: '@deepseek-ai/dsh-workflow-ptc'
466
+ config:
467
+ provider: spawn
468
+ - id: tool-workflow
469
+ name: '@deepseek-ai/dsh-tool-workflow'
470
+ - id: tool-ask-user
471
+ name: '@deepseek-ai/dsh-tool-ask-user'
472
+ - id: tool-todo
473
+ name: '@deepseek-ai/dsh-tool-todo'
474
+ config:
475
+ allowParallelInProgress: true
476
+ - id: tool-web
477
+ name: '@deepseek-ai/dsh-tool-web'
478
+ config:
479
+ fetch: true
480
+ searchTimeoutMs: 60000
481
+ - id: present
482
+ name: '@deepseek-ai/dsh-tool-present'
483
+ - id: tool-plugin-manager
484
+ name: '@deepseek-ai/dsh-plugin-manager/tools'
485
+ disabled: true
486
+ - id: tool-plugin-market
487
+ name: '@deepseek-ai/dsh-plugin-market/tools'
488
+ disabled: true
489
+ # <<< handoff:takeover:end
package/lib/client.js CHANGED
@@ -32,10 +32,21 @@ window.__ModuleLoader__.load({
32
32
  };
33
33
 
34
34
  var DEFAULT_TEMPLATE = "~/.dsh-handoff-compaction/template.md";
35
- var PRESETS = [
36
- { id: "lightweight-local", label: "轻量本地" },
37
- { id: "lightweight-standard", label: "轻量标准" },
38
- ];
35
+ /* Preset names come from DSH's own registry through the `scope` RPC, never
36
+ * from a list baked in here: a release that renames or adds a preset must
37
+ * not leave this page showing names that no longer exist. */
38
+ var presetLabels = {};
39
+ function rememberPresetLabels(list) {
40
+ for (var i = 0; i < list.length; i += 1) {
41
+ var entry = list[i];
42
+ if (entry && typeof entry.id === "string") {
43
+ presetLabels[entry.id] = typeof entry.label === "string" && entry.label.length > 0 ? entry.label : entry.id;
44
+ }
45
+ }
46
+ }
47
+ function presetLabel(id) {
48
+ return presetLabels[String(id)] || String(id || "未命名预设");
49
+ }
39
50
  var DISPLAY_OPTIONS = [
40
51
  { id: "orbit", label: "轨道环" },
41
52
  { id: "strip", label: "状态条" },
@@ -43,10 +54,6 @@ window.__ModuleLoader__.load({
43
54
  { id: "topbar", label: "顶部条" },
44
55
  { id: "corner", label: "右上角" },
45
56
  ];
46
- function presetLabel(id) {
47
- for (var i = 0; i < PRESETS.length; i += 1) if (PRESETS[i].id === id) return PRESETS[i].label;
48
- return String(id || "未命名预设");
49
- }
50
57
  var EXAMPLE_WINDOW = 250000, EXAMPLE_RESERVED = 20000, EXAMPLE_HEADROOM = 10000;
51
58
 
52
59
  var DISPLAY_STYLES = ["orbit", "strip", "ring", "topbar", "corner"];
@@ -697,8 +704,8 @@ window.__ModuleLoader__.load({
697
704
  var token = tokenState[0], setToken = tokenState[1];
698
705
  ensureStatusStyles();
699
706
 
700
- // The compaction slot lives in the preset YAML, not in our settings file,
701
- // so the truth is read from disk each time the section opens.
707
+ // Which presets exist and which of them this plugin's patch reaches is
708
+ // DSH's answer, not ours, so it is read fresh each time the section opens.
702
709
  React.useEffect(function () {
703
710
  if (!connection) {
704
711
  setScope({ loading: false, error: "连接服务不可用", value: null });
@@ -707,7 +714,9 @@ window.__ModuleLoader__.load({
707
714
  var alive = true;
708
715
  setScope(function (previous) { return { loading: true, error: "", value: previous.value }; });
709
716
  call(connection, "scope").then(function (value) {
710
- if (alive) setScope({ loading: false, error: "", value: value });
717
+ if (!alive) return;
718
+ rememberPresetLabels(value && Array.isArray(value.presets) ? value.presets : []);
719
+ setScope({ loading: false, error: "", value: value });
711
720
  }).catch(function (error) {
712
721
  if (alive) setScope({ loading: false, error: String(error && error.message || error), value: null });
713
722
  });
@@ -755,51 +764,61 @@ window.__ModuleLoader__.load({
755
764
  tone: "err",
756
765
  text: scope.loading ? "读取中…" : "读不到生效情况",
757
766
  sub: scope.loading
758
- ? "正在读取各 Agent 预设里的 compaction 槽位。"
759
- : "读不到预设里的 compaction 槽位:" + (scope.error || "未知原因") + "。",
767
+ ? "正在向 DSH 读取预设名单与本插件的接管行。"
768
+ : "读不到预设名单:" + (scope.error || "未知原因") + "。",
760
769
  rows: [], retry: true,
761
770
  };
762
771
  }
763
772
  var list = Array.isArray(st.presets) ? st.presets : [];
773
+ rememberPresetLabels(list);
764
774
  var rows = list.map(function (p) {
765
775
  return {
766
- key: String(p.id), label: presetLabel(p.id), taken: p.owner === "plugin",
767
- owner: p.owner === "plugin" ? "插件" : p.owner === "native" ? "原生" : "未知",
776
+ key: String(p.id),
777
+ label: presetLabel(p.id),
778
+ taken: p.effective === true,
779
+ owner: p.effective === true ? "插件" : p.covered === true ? "原生(未勾选)" : "原生(未覆盖)",
768
780
  };
769
781
  });
770
782
  var names = rows.map(function (r) { return r.label; });
771
783
  var takenNames = rows.filter(function (r) { return r.taken; }).map(function (r) { return r.label; });
772
784
  var restNames = rows.filter(function (r) { return !r.taken; }).map(function (r) { return r.label; });
773
- var taken = Number(st.taken) || 0;
774
785
  var total = Number(st.total) || list.length;
786
+ var covered = Number(st.covered) || 0;
787
+ var effective = Number(st.effective) || 0;
775
788
  var globalSaved = st.scope !== "preset";
776
- var scopeLead = globalSaved
777
- ? "全局生效:"
778
- : "按预设生效:";
789
+ var scopeLead = globalSaved ? "全局生效:" : "按预设生效:";
790
+ var mechanism = "接管行写在本插件包内的补丁里,卸载插件时随包一起消失,你的预设文件不会被改动。";
791
+
779
792
  if (total === 0) {
780
793
  return {
781
794
  tone: "err", text: "读不到预设", rows: [], retry: true,
782
- sub: "没找到带 handoff 槽位的可编辑预设文件,所以无法判断压缩由谁负责。确认 DSH 已生成预设后重新读取。",
795
+ sub: "DSH 没有返回任何 Agent 预设" + (st.reason ? "(" + st.reason + ")" : "") + ",所以无法判断压缩由谁负责。重新读取试试。",
783
796
  };
784
797
  }
785
- if (taken === 0) {
798
+ if (covered === 0) {
786
799
  return {
787
- tone: "native", text: "DSH 原生压缩", rows: rows, retry: false,
788
- sub: "各预设的 compaction 槽位都还在原生实现手里,所以压缩目前由 DSH 自己完成。保存本页设置会自动接管 " + names.join("、") + "(写入预设文件并留备份,重启 DSH 后生效)。",
800
+ tone: "native", text: "DSH 原生压缩", rows: rows, retry: true,
801
+ sub: "本插件的接管行还没覆盖到任何预设" + (st.reason ? "(" + st.reason + ")" : "")
802
+ + ",压缩目前由 DSH 自己完成。" + mechanism + " 新生成或新装的预设需要重启 DSH 重新生成接管行。",
789
803
  };
790
804
  }
791
- if (!enabled) {
805
+ if (effective === 0 && !enabled) {
792
806
  return {
793
807
  tone: "native", text: "DSH 原生压缩(插件已停用)", rows: rows, retry: false,
794
- sub: taken < total
795
- ? "「启用插件」已关闭,压缩由 DSH 原生实现负责;" + takenNames.join("、") + " 的槽位仍指向插件," + restNames.join("、") + " 本来就走原生。"
796
- : "「启用插件」已关闭:槽位虽然指向插件,但插件把压缩交回 DSH 原生实现。打开开关即恢复,不需要重启。",
808
+ sub: "「启用插件」已关闭:" + takenNames.length + "/" + total + " 个预设虽然被接管行覆盖,但插件把压缩交回 DSH 原生实现。打开开关即恢复,不需要重启。",
809
+ };
810
+ }
811
+ if (effective === 0) {
812
+ return {
813
+ tone: "native", text: "DSH 原生压缩", rows: rows, retry: false,
814
+ sub: scopeLead + "当前没有任何预设处于接管状态(" + names.join("、") + " 都走原生)。"
815
+ + "勾选预设或切到「全局生效」并保存即可,下一次判定就生效,不需要重启。",
797
816
  };
798
817
  }
799
- if (taken < total) {
818
+ if (effective < total) {
800
819
  return {
801
- tone: "part", text: taken + "/" + total + " 预设已接管", rows: rows, retry: false,
802
- sub: scopeLead + "已交给插件:" + takenNames.join("、") + ";" + restNames.join("、") + " 仍走各自的原生压缩。",
820
+ tone: "part", text: effective + "/" + total + " 预设已由插件压缩", rows: rows, retry: false,
821
+ sub: scopeLead + "由插件负责:" + takenNames.join("、") + ";" + restNames.join("、") + " 仍走各自的原生压缩。" + mechanism,
803
822
  };
804
823
  }
805
824
  return {
@@ -807,12 +826,17 @@ window.__ModuleLoader__.load({
807
826
  text: globalSaved ? "已由插件接管(全局)" : "已由插件接管",
808
827
  rows: rows, retry: false,
809
828
  sub: scopeLead + (globalSaved
810
- ? "这 " + total + " 个带 handoff 槽位的可编辑预设(" + names.join("、") + ")"
811
- : "勾选的 " + taken + " 个预设(" + takenNames.join("、") + ")")
812
- + "的 compaction 槽位指向插件,且「启用插件」是开的,压缩由本插件的交接文档负责。",
829
+ ? "这 " + total + " 个预设(" + names.join("、") + ")"
830
+ : "勾选的 " + effective + " 个预设(" + takenNames.join("、") + ")")
831
+ + " 的压缩由本插件的交接文档负责,且「启用插件」是开的。" + mechanism,
813
832
  };
814
833
  })();
815
834
  var scopeTone = SCOPE_TONE[scopeView.tone] || SCOPE_TONE.native;
835
+ /* Only presets the takeover rows reach can be selected; the rest stay
836
+ * native and would be misleading to offer. */
837
+ var takeable = scope.value && Array.isArray(scope.value.presets)
838
+ ? scope.value.presets.filter(function (p) { return p && p.covered === true; })
839
+ : [];
816
840
 
817
841
  var budget = EXAMPLE_WINDOW - EXAMPLE_RESERVED;
818
842
  var ceiling = budget - EXAMPLE_HEADROOM;
@@ -836,7 +860,7 @@ window.__ModuleLoader__.load({
836
860
  if (scopeIsPending) pendingParts.push("生效范围改成「" + (scopePreset ? "按预设生效" : "全局生效") + "」");
837
861
  if (presetsIsPending) pendingParts.push("勾选的预设改成" + (presets.length ? presets.map(presetLabel).join("、") : "(都不勾)"));
838
862
  var pendingNote = pendingParts.length
839
- ? "还没保存:" + pendingParts.join(";") + "。保存后开关立刻生效,槽位改动要重启 DSH 才加载。"
863
+ ? "还没保存:" + pendingParts.join(";") + "。保存后开关与勾选在下一次判定就生效,不需要重启;只有接管行本身要覆盖新的预设时才需要重启 DSH 重新生成。"
840
864
  : "";
841
865
 
842
866
  function patch(next) { setDraft(Object.assign({}, base, next)); setStatus({ loading: false, error: "", saving: false, saved: false }); }
@@ -906,7 +930,7 @@ window.__ModuleLoader__.load({
906
930
  h("div", { style: rowStyle },
907
931
  h("div", null,
908
932
  h("div", { style: labelStyle }, "生效范围"),
909
- h("div", { style: subStyle }, scopePreset ? "仅勾选的预设切到插件并关闭其原生压缩;其余预设保持原生" : "所有带 handoff slot 的可编辑预设都切到插件,共用这份设置")
933
+ h("div", { style: subStyle }, scopePreset ? "只有勾选的预设由插件压缩,其余预设保持 DSH 原生行为;名单来自 DSH 的预设注册表" : "所有能被本插件接管的预设都由插件压缩,共用这份设置")
910
934
  ),
911
935
  h("div", { style: { display: "inline-flex", border: "1px solid " + C.line, borderRadius: 8, overflow: "hidden" } },
912
936
  seg(!scopePreset, "全局生效", function () { patch({ scope: "global" }); }),
@@ -914,13 +938,17 @@ window.__ModuleLoader__.load({
914
938
  )
915
939
  ),
916
940
  scopePreset ? h("div", { style: { display: "flex", gap: 8, flexWrap: "wrap", padding: "10px 0", borderBottom: "1px solid " + C.line } },
917
- PRESETS.map(function (p) {
918
- var on = presets.indexOf(p.id) >= 0;
919
- return h("button", {
920
- key: p.id, type: "button", onClick: function () { setPreset(p.id); },
921
- style: { fontSize: 12, padding: "5px 11px", borderRadius: 999, cursor: "pointer", font: "inherit", border: "1px solid " + (on ? C.accent : C.line), background: on ? "rgba(76,141,255,.12)" : "#191a1d", color: on ? C.text : C.muted },
922
- }, p.label);
923
- })
941
+ takeable.length === 0
942
+ ? h("span", { style: subStyle }, scope.loading
943
+ ? "正在向 DSH 读取预设名单…"
944
+ : "本插件的接管行还没覆盖到任何预设。重启 DSH 会重新生成接管行;仍未出现预设名单说明 DSH 没有返回预设。")
945
+ : takeable.map(function (p) {
946
+ var on = presets.indexOf(p.id) >= 0;
947
+ return h("button", {
948
+ key: p.id, type: "button", onClick: function () { setPreset(p.id); },
949
+ style: { fontSize: 12, padding: "5px 11px", borderRadius: 999, cursor: "pointer", font: "inherit", border: "1px solid " + (on ? C.accent : C.line), background: on ? "rgba(76,141,255,.12)" : "#191a1d", color: on ? C.text : C.muted },
950
+ }, presetLabel(p.id));
951
+ })
924
952
  ) : null,
925
953
 
926
954
  h("div", { style: rowStyle },
package/lib/index.js CHANGED
@@ -17,6 +17,7 @@ import { createRequire } from "node:module";
17
17
  import { homedir } from "node:os";
18
18
  import { dirname, join } from "node:path";
19
19
  import { execFile, spawn } from "node:child_process";
20
+ import { mountTakeover, presetInventory } from "./takeover-mount.js";
20
21
 
21
22
  export const name = "dsh-auto-compaction";
22
23
  export const inject = [];
@@ -25,7 +26,7 @@ export const Config = z.object({
25
26
  enabled: z.boolean().default(true),
26
27
  thresholdRatio: z.number().default(90),
27
28
  scopePreset: z.boolean().default(false),
28
- presets: z.string().default("lightweight-local"),
29
+ presets: z.string().default(""),
29
30
  templateFile: z.string().default(""),
30
31
  openTemplate: z.string().default(""),
31
32
  displayStyle: z.string().default("orbit"),
@@ -194,28 +195,38 @@ async function runScope(action) {
194
195
  return { ok: false, message };
195
196
  }
196
197
 
197
- /** Who actually compacts right now, straight from the preset YAML on disk. */
198
- async function readScopeState() {
199
- const result = await runScope("state");
200
- if (!result.ok) return { ok: false, message: result.message };
201
- const line = String(result.output ?? "").split(/\r?\n/).filter(Boolean).pop() ?? "";
198
+ /**
199
+ * Hand compaction back where the old mechanism left it.
200
+ *
201
+ * Up to 0.2.x the takeover was written into the user's own preset files plus a
202
+ * root-level guard in the profile patch. Those rows now live inside this
203
+ * package, so a leftover switch would mount two providers in the same realm and
204
+ * DSH would refuse to start. Detection goes through the script's own read-only
205
+ * report rather than a second YAML parser; the revert runs once and is recorded
206
+ * so later boots skip the subprocess entirely.
207
+ */
208
+ async function revertLegacyTakeover(ctx) {
209
+ const settings = readSettings();
210
+ if (settings?.legacyRevertedAt !== undefined) return { reverted: false, skipped: "already" };
211
+ const state = await runScope("state");
212
+ if (!state.ok) return { reverted: false, reason: state.message };
213
+ const line = String(state.output ?? "").split(/\r?\n/).filter(Boolean).pop() ?? "";
214
+ let value;
202
215
  try {
203
- return { ok: true, value: JSON.parse(line) };
204
- } catch (error) {
205
- const message = "unreadable scope state: " + (error instanceof Error ? error.message : String(error));
206
- trace("scope state failed: " + message + " | " + line.slice(0, 200));
207
- return { ok: false, message };
216
+ value = JSON.parse(line);
217
+ } catch {
218
+ return { reverted: false, reason: "unreadable legacy state" };
208
219
  }
209
- }
220
+ const legacy = Number(value?.taken) > 0 || value?.rootGuard === "off";
221
+ if (!legacy) return { reverted: false, legacy: false };
210
222
 
211
- /**
212
- * Apply the current settings sidecar to the preset/profile YAML.
213
- */
214
- async function applyScopeSwitch() {
215
- const result = await runScope("enable");
216
- return result.ok
217
- ? { ok: true, output: result.output }
218
- : { ok: false, message: result.message };
223
+ const disabled = await runScope("disable");
224
+ if (!disabled.ok) return { reverted: false, reason: disabled.message };
225
+
226
+ writeSettings(ctx, { ...settings, legacyRevertedAt: new Date().toISOString() });
227
+ trace(`legacy takeover reverted: taken=${value?.taken}/${value?.total} rootGuard=${value?.rootGuard}`);
228
+ ctx.logger?.info?.("[handoff-ui] 旧版接管残留已恢复为原生压缩(预设文件与根级守卫)");
229
+ return { reverted: true, taken: value?.taken, total: value?.total };
219
230
  }
220
231
 
221
232
  const CHANNEL = "/dsh-auto-compaction";
@@ -248,6 +259,22 @@ function sanitize(payload) {
248
259
  export function apply(ctx, config = {}) {
249
260
  const seeded = normalize(config);
250
261
  if (readSettings() === undefined) writeSettings(ctx, seeded);
262
+ // Takeover rows are regenerated from the live preset definitions on every
263
+ // load, into this package's own patch file. Nothing here depends on the
264
+ // user's scope selection: an unselected preset is handled by the engine
265
+ // delegating to the stock one, so installing is enough to take over and
266
+ // uninstalling is enough to hand compaction back.
267
+ void (async () => {
268
+ const legacy = await revertLegacyTakeover(ctx);
269
+ if (legacy.reverted) trace("legacy takeover reverted before mounting");
270
+ else if (legacy.reason) trace("legacy takeover check skipped: " + legacy.reason);
271
+ return mountTakeover(ctx);
272
+ })().then((result) => {
273
+ trace(`takeover mount ${result.ok ? "ok" : "failed"} taken=${result.taken?.join(",") ?? "-"} mounted=${result.mounted?.join(",") ?? "-"} skipped=${result.skipped?.join(",") ?? "-"} ${result.reason ?? ""}`);
274
+ }).catch((error) => {
275
+ trace("takeover mount threw: " + (error instanceof Error ? error.message : String(error)));
276
+ ctx.logger?.warn?.(`[handoff-ui] takeover mount failed: ${error instanceof Error ? error.message : String(error)}`);
277
+ });
251
278
  void handleOpen(ctx, config.openTemplate, seeded.templateFile || TEMPLATE_FILE).catch((error) => {
252
279
  ctx.logger?.warn?.(`[handoff-ui] openTemplate failed: ${error instanceof Error ? error.message : String(error)}`);
253
280
  });
@@ -283,17 +310,29 @@ export function apply(ctx, config = {}) {
283
310
  const next = sanitize(payload);
284
311
  trace("set " + JSON.stringify(next));
285
312
  writeSettings(ctx, next);
286
- const applied = await applyScopeSwitch();
287
- if (!applied.ok) {
288
- return { ok: false, error: { code: "auto-compaction/switch-failed", message: applied.message }, value: next };
289
- }
290
- return { ok: true, value: { ...next, switchApplied: true, defaultTemplate: TEMPLATE_FILE } };
313
+ // No rewriting of preset files: the engine applies the switch and
314
+ // the scope per preset at decision time, so a save is live at the
315
+ // next idle check and uninstalling still leaves nothing behind.
316
+ return { ok: true, value: { ...next, defaultTemplate: TEMPLATE_FILE } };
291
317
  }
292
318
  if (endpoint === "scope") {
293
- const state = await readScopeState();
294
- return state.ok
295
- ? { ok: true, value: state.value }
296
- : { ok: false, error: { code: "auto-compaction/scope-unknown", message: state.message } };
319
+ const stored = readSettings() ?? seeded;
320
+ try {
321
+ const inventory = await presetInventory(ctx, stored);
322
+ return {
323
+ ok: true,
324
+ value: {
325
+ enabled: stored.enabled !== false,
326
+ scope: stored.scope === "preset" ? "preset" : "global",
327
+ source: "bundle-patch",
328
+ ...inventory,
329
+ },
330
+ };
331
+ } catch (error) {
332
+ const message = error instanceof Error ? error.message : String(error);
333
+ trace("scope inventory failed: " + message);
334
+ return { ok: false, error: { code: "auto-compaction/scope-unknown", message } };
335
+ }
297
336
  }
298
337
  if (endpoint === "open") {
299
338
  trace("open " + JSON.stringify(payload));
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Mount the takeover, in this package's own patch file.
3
+ *
4
+ * The rows that hand compaction over to this plugin live in the bundle patch
5
+ * shipped inside the package, because that is the only place uninstalling
6
+ * removes them automatically: DSH's uninstall neither clears nor refuses
7
+ * config-replacement rows in a user's own patch, so writing them there would
8
+ * leave a preset pointing at a package that no longer exists — a broken
9
+ * compaction setup the user cannot see. Writing here costs nothing on removal.
10
+ *
11
+ * Rows are generated from the live preset definitions rather than baked in, so
12
+ * a preset that gains a tool in a later DSH release keeps it. The generator
13
+ * lives in the engine package and is loaded lazily: DSH's host module fallback
14
+ * cannot resolve a linked package's subpath from here, and a top-level import
15
+ * blocks startup.
16
+ */
17
+ import { createRequire } from "node:module";
18
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
19
+ import { dirname, join } from "node:path";
20
+
21
+ const requireFromHere = createRequire(import.meta.url);
22
+
23
+ /** The patch file this package is mounted from. */
24
+ function patchFile() {
25
+ const override = process.env.DSH_HANDOFF_TAKEOVER_FILE;
26
+ if (typeof override === "string" && override.trim().length > 0) return override.trim();
27
+ return join(dirname(requireFromHere.resolve("../package.json")), "cordis.patch.yml");
28
+ }
29
+
30
+ /**
31
+ * Load the row generator without importing the engine package statically.
32
+ * @returns the module, or undefined when it cannot be resolved.
33
+ */
34
+ async function loadGenerator() {
35
+ // Dynamic import rather than createRequire: the generator is ESM. Installed,
36
+ // the engine sits beside this package in the same node_modules and publishes
37
+ // ./lib/*; in development it is the monorepo sibling, since the workspace
38
+ // installs nothing and everything runs through junctions.
39
+ for (const attempt of [
40
+ () => import("dsh-auto-compaction-engine/lib/takeover-rows.js"),
41
+ () => import("../../dsh-auto-compaction-engine/lib/takeover-rows.js"),
42
+ ]) {
43
+ try {
44
+ return await attempt();
45
+ } catch { /* try the next location */ }
46
+ }
47
+ return undefined;
48
+ }
49
+
50
+ /** The profile manifest DSH composes its layers from. */
51
+ function profileManifest() {
52
+ const override = process.env.DSH_PROFILE_MANIFEST;
53
+ if (typeof override === "string" && override.trim().length > 0) return override.trim();
54
+ const appData = process.env.APP_DATA
55
+ ?? path.join(process.env.SystemDrive ?? "C:", "Users", process.env.USERNAME ?? process.env.USER ?? "", "AppData", "Roaming");
56
+ return path.join(appData, "dsh-desktop", "harness", "profiles", "web", "package.json");
57
+ }
58
+
59
+ /**
60
+ * Keep this package's patch layer behind every preset declaration.
61
+ *
62
+ * Layer order is the order of `dsh.profile.bundles`, and a takeover row is an
63
+ * update of a preset row that has to exist by the time our layer applies:
64
+ * presets declared by a later layer would leave our row with nothing to point
65
+ * at, silently. DSH's own presets come early, but a preset installed from the
66
+ * market or written by the user can come after us, so the entry belongs last.
67
+ *
68
+ * The desktop market appends newly installed bundles to the end, so this is a
69
+ * no-op for a fresh install; it matters when another plugin is installed after
70
+ * us. Verified on this machine: without the entry 4 of 6 presets mounted, with
71
+ * it at the end all 6 did.
72
+ *
73
+ * Text surgery rather than a JSON rewrite, because this file is the user's and
74
+ * the market rewrites it too.
75
+ */
76
+ export function ensureBundleLayer(text, name = "dsh-auto-compaction") {
77
+ if (typeof text !== "string" || text.length === 0) return { text, changed: false, reason: "manifest unreadable" };
78
+ const start = text.indexOf("\"bundles\"");
79
+ const open = start < 0 ? -1 : text.indexOf("[", start);
80
+ const close = open < 0 ? -1 : text.indexOf("]", open);
81
+ if (close < 0) return { text, changed: false, reason: "no dsh.profile.bundles array" };
82
+ const body = text.slice(open + 1, close);
83
+ const entries = [...body.matchAll(/"([^"]+)"/g)].map((m) => m[1]);
84
+ if (entries.length > 0 && entries[entries.length - 1] === name) return { text, changed: false, reason: "" };
85
+
86
+ const kept = entries.filter((entry) => entry !== name);
87
+ const rebuilt = kept.length === 0 ? `["${name}"]` : `[\n${kept.concat([name]).map((entry) => ` "${entry}"`).join(",\n")}\n ]`;
88
+ return { text: text.slice(0, open) + rebuilt + text.slice(close + 1), changed: true, reason: "" };
89
+ }
90
+
91
+ /** Apply the layer rule to the profile manifest, writing only on change. */
92
+ export function ensureBundleLayerFile() {
93
+ const file = profileManifest();
94
+ let text = "";
95
+ try {
96
+ text = readFileSync(file, "utf8");
97
+ } catch (error) {
98
+ return { ok: false, file, reason: `manifest missing: ${error instanceof Error ? error.message : String(error)}` };
99
+ }
100
+ const result = ensureBundleLayer(text);
101
+ if (!result.changed) return { ok: true, file, changed: false, reason: result.reason };
102
+ try {
103
+ JSON.parse(result.text);
104
+ } catch (error) {
105
+ return { ok: false, file, reason: `refusing to write invalid JSON: ${error.message}` };
106
+ }
107
+ const tmp = `${file}.tmp`;
108
+ writeFileSync(tmp, result.text, "utf8");
109
+ renameSync(tmp, file);
110
+ return { ok: true, file, changed: true };
111
+ }
112
+
113
+ /**
114
+ * Ask DSH which agent presets exist and what each one currently declares.
115
+ *
116
+ * `list()` publishes the display metadata ({id, name, description, order,
117
+ * broken?}) and `readDocument()` the plugin list as a YAML string; a preset's
118
+ * config is exactly `{id, name, description, order, plugins}`, so the two
119
+ * together are the whole definition and nothing here is guessed.
120
+ */
121
+ async function readPresets(ctx, generator) {
122
+ let service;
123
+ try {
124
+ service = ctx.get?.("agentPresets");
125
+ } catch {
126
+ return { presets: [], failures: [], reason: "agentPresets service unavailable" };
127
+ }
128
+ if (service === undefined || typeof service.list !== "function") {
129
+ return { presets: [], failures: [], reason: "agentPresets service has no list()" };
130
+ }
131
+ if (generator === undefined) {
132
+ return { presets: [], failures: [], reason: "row generator not resolvable" };
133
+ }
134
+ let listed;
135
+ try {
136
+ listed = await service.list();
137
+ } catch (error) {
138
+ return { presets: [], failures: [], reason: `list() failed: ${error instanceof Error ? error.message : String(error)}` };
139
+ }
140
+ const entries = Array.isArray(listed) ? listed : Array.isArray(listed?.presets) ? listed.presets : [];
141
+ const presets = [];
142
+ const failures = [];
143
+ for (const entry of entries) {
144
+ const id = typeof entry?.id === "string" && entry.id.length > 0 ? entry.id : undefined;
145
+ if (id === undefined) continue;
146
+ let document;
147
+ try {
148
+ document = typeof service.readDocument === "function" ? await service.readDocument(id) : undefined;
149
+ } catch (error) {
150
+ failures.push(`${id}: ${error instanceof Error ? error.message : String(error)}`);
151
+ continue;
152
+ }
153
+ const parsed = generator.parsePlugins(document?.content);
154
+ if (parsed.plugins === undefined) {
155
+ failures.push(`${id}: ${parsed.reason}`);
156
+ continue;
157
+ }
158
+ const config = { id };
159
+ for (const key of ["name", "description", "order"]) {
160
+ if (entry[key] !== undefined) config[key] = entry[key];
161
+ }
162
+ config.plugins = parsed.plugins;
163
+ presets.push({
164
+ id,
165
+ config,
166
+ label: typeof entry.name === "string" && entry.name.length > 0 ? entry.name : id,
167
+ // What the composition actually registers, and whether it failed to.
168
+ provider: generator.compactionProvider?.(config)?.name ?? "",
169
+ broken: typeof entry.broken === "string" ? entry.broken : "",
170
+ });
171
+ }
172
+ return { presets, failures };
173
+ }
174
+
175
+ /** Write the managed block, atomically, only when it actually changed. */
176
+ function writeBlock(text, file) {
177
+ const tmp = `${file}.tmp`;
178
+ writeFileSync(tmp, text, "utf8");
179
+ renameSync(tmp, file);
180
+ }
181
+
182
+ /**
183
+ * Regenerate the takeover rows at activation.
184
+ *
185
+ * @returns {ok, file, taken, skipped, reason} for the log and the settings page.
186
+ */
187
+ export async function mountTakeover(ctx) {
188
+ const generator = await loadGenerator();
189
+ if (generator === undefined) {
190
+ return { ok: false, reason: "row generator not resolvable", taken: [], skipped: [] };
191
+ }
192
+ const file = patchFile();
193
+ let current = "";
194
+ try {
195
+ current = readFileSync(file, "utf8");
196
+ } catch {
197
+ current = "";
198
+ }
199
+
200
+ const { presets, failures = [], reason = "" } = await readPresets(ctx, generator);
201
+ if (presets.length === 0) {
202
+ // Nothing could be read: leave the file as it is rather than wiping the
203
+ // shipped rows on a transient host failure.
204
+ return { ok: false, file, taken: [], skipped: [], reason: reason || failures[0] || "no presets declared" };
205
+ }
206
+
207
+ const engineName = process.env.DSH_HANDOFF_ENGINE_NAME ?? "dsh-auto-compaction-engine";
208
+ const layer = ensureBundleLayerFile();
209
+ if (layer.changed) ctx.logger?.info?.(`[handoff-ui] bundle layer moved to the end of dsh.profile.bundles`);
210
+ else if (layer.reason) ctx.logger?.warn?.(`[handoff-ui] bundle layer left as is: ${layer.reason}`);
211
+ const rows = generator.takeoverRows(presets, { engineName });
212
+ const taken = rows.map((row) => String(row.id).replace(/^preset-/, ""));
213
+ const skipped = presets.map((preset) => preset.id).filter((id) => !taken.includes(id));
214
+ /* Written rows and effective rows are different facts: the composition reads
215
+ * the patch at startup, so rows first written by this activation only take
216
+ * effect next boot. Reporting both is what keeps the settings page honest. */
217
+ const mounted = presets.filter((preset) => preset.provider === engineName).map((preset) => preset.id);
218
+ const broken = presets.filter((preset) => preset.broken).map((preset) => `${preset.id}: ${preset.broken}`);
219
+ /* Saying which presets were left out and why is the difference between an
220
+ * honest "not taken over" and a silent one. */
221
+ const skippedReasons = presets
222
+ .filter((preset) => !taken.includes(preset.id))
223
+ .map((preset) => {
224
+ const unsupported = generator.findUnsupported?.(preset.config ?? {});
225
+ return `${preset.id}: ${unsupported ? `含无法原样重写的值(${unsupported})` : "没有可接管的 compaction 组"}`;
226
+ });
227
+ const notes = [];
228
+ if (failures.length > 0) notes.push(`unreadable: ${failures.join("; ")}`);
229
+ if (skippedReasons.length > 0) notes.push(`skipped: ${skippedReasons.join("; ")}`);
230
+ if (broken.length > 0) notes.push(`broken: ${broken.join("; ")}`);
231
+ const block = generator.renderBlock(rows);
232
+
233
+ // Nothing to take over and nothing recorded before: leave the shipped file
234
+ // alone rather than appending an empty block.
235
+ if (rows.length === 0 && !current.includes(generator.BLOCK_MARKERS.BEGIN)) {
236
+ return { ok: true, file, taken: [], skipped, mounted, broken, reason: notes.join(" | ") };
237
+ }
238
+ if (generator.needsRewrite(current, block)) {
239
+ try {
240
+ writeBlock(generator.mergeBlock(current, block), file);
241
+ ctx.logger?.info?.(`[handoff-ui] takeover rows written to ${file} (${taken.length} preset(s))`);
242
+ } catch (error) {
243
+ return {
244
+ ok: false, file, taken, skipped,
245
+ reason: `patch write failed: ${error instanceof Error ? error.message : String(error)}`,
246
+ };
247
+ }
248
+ }
249
+ return { ok: true, file, taken, skipped, mounted, broken, reason: notes.join(" | ") };
250
+ }
251
+
252
+ /** Which presets the shipped block currently covers. */
253
+ export async function readTakenPresets() {
254
+ const generator = await loadGenerator();
255
+ const file = patchFile();
256
+ if (generator === undefined) return [];
257
+ let text = "";
258
+ try {
259
+ text = readFileSync(file, "utf8");
260
+ } catch {
261
+ return [];
262
+ }
263
+ const { BEGIN, END } = generator.BLOCK_MARKERS;
264
+ const begin = text.indexOf(BEGIN);
265
+ const end = text.indexOf(END);
266
+ if (begin < 0 || end < 0 || end < begin) return [];
267
+ return [...text.slice(begin + BEGIN.length, end).matchAll(/^-\s+id:\s*preset-(\S+)/gm)].map((m) => m[1]);
268
+ }
269
+
270
+ /**
271
+ * Everything the settings page needs to answer "who compacts each preset".
272
+ *
273
+ * `covered` is what the block in this package's patch reaches; `effective` adds
274
+ * the runtime gates the engine applies per preset (the switch and the scope).
275
+ */
276
+ export async function presetInventory(ctx, settings = {}) {
277
+ const generator = await loadGenerator();
278
+ const covered = new Set(await readTakenPresets());
279
+ const { presets, failures = [], reason = "" } = await readPresets(ctx, generator);
280
+ const selected = new Set(Array.isArray(settings.presets) ? settings.presets : []);
281
+ const global = settings.scope !== "preset";
282
+ const enabled = settings.enabled !== false;
283
+ const rows = presets.map((preset) => {
284
+ const isCovered = covered.has(preset.id);
285
+ const isSelected = global ? isCovered : isCovered && selected.has(preset.id);
286
+ return {
287
+ id: preset.id,
288
+ label: typeof preset.label === "string" ? preset.label : undefined,
289
+ covered: isCovered,
290
+ selected: isSelected,
291
+ effective: isSelected && enabled,
292
+ // Whether this boot's composition already registers our provider; a row
293
+ // written during this activation only shows up here after a restart.
294
+ mounted: preset.provider === (process.env.DSH_HANDOFF_ENGINE_NAME ?? "dsh-auto-compaction-engine"),
295
+ broken: preset.broken || undefined,
296
+ };
297
+ });
298
+ return {
299
+ presets: rows,
300
+ total: rows.length,
301
+ covered: rows.filter((row) => row.covered).length,
302
+ mounted: rows.filter((row) => row.mounted).length,
303
+ effective: rows.filter((row) => row.effective).length,
304
+ reason: reason || (failures.length > 0 ? `unreadable: ${failures.join("; ")}` : ""),
305
+ };
306
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-auto-compaction",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "main": "lib/index.js",
6
6
  "exports": {
@@ -51,6 +51,6 @@
51
51
  "access": "public"
52
52
  },
53
53
  "dependencies": {
54
- "dsh-auto-compaction-engine": "^0.2.2"
54
+ "dsh-auto-compaction-engine": "^0.2.3"
55
55
  }
56
56
  }