dsh-auto-compaction 0.2.1 → 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: "状态条" },
@@ -480,7 +491,7 @@ window.__ModuleLoader__.load({
480
491
  indicator,
481
492
  h("div", { style: { fontSize: 12.5, fontWeight: 600, color: STATUS_TEXT } }, title),
482
493
  h("div", { style: { fontSize: 11, color: STATUS_MUTED } }, cardSub),
483
- h("div", { style: { fontSize: 10.5, color: STATUS_MUTED, opacity: 0.85 } }, "完成后整段历史收成一条交接文档")
494
+ h("div", { style: { fontSize: 10.5, color: STATUS_MUTED, opacity: 0.85 } }, "完成后把最旧的这段历史收成一条交接文档")
484
495
  )
485
496
  ),
486
497
  document.body
@@ -687,8 +698,31 @@ window.__ModuleLoader__.load({
687
698
  var draft = draftState[0], setDraft = draftState[1];
688
699
  var statusState = React.useState({ loading: true, error: "", saving: false, saved: false });
689
700
  var status = statusState[0], setStatus = statusState[1];
701
+ var scopeState = React.useState({ loading: true, error: "", value: null });
702
+ var scope = scopeState[0], setScope = scopeState[1];
703
+ var tokenState = React.useState(0);
704
+ var token = tokenState[0], setToken = tokenState[1];
690
705
  ensureStatusStyles();
691
706
 
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.
709
+ React.useEffect(function () {
710
+ if (!connection) {
711
+ setScope({ loading: false, error: "连接服务不可用", value: null });
712
+ return undefined;
713
+ }
714
+ var alive = true;
715
+ setScope(function (previous) { return { loading: true, error: "", value: previous.value }; });
716
+ call(connection, "scope").then(function (value) {
717
+ if (!alive) return;
718
+ rememberPresetLabels(value && Array.isArray(value.presets) ? value.presets : []);
719
+ setScope({ loading: false, error: "", value: value });
720
+ }).catch(function (error) {
721
+ if (alive) setScope({ loading: false, error: String(error && error.message || error), value: null });
722
+ });
723
+ return function () { alive = false; };
724
+ }, [connection, token]);
725
+
692
726
  React.useEffect(function () {
693
727
  var alive = true;
694
728
  if (!connection) { setStatus({ loading: false, error: "连接服务不可用", saving: false, saved: false }); return; }
@@ -715,6 +749,95 @@ window.__ModuleLoader__.load({
715
749
  var defaultTemplate = base.defaultTemplate || DEFAULT_TEMPLATE;
716
750
  var themeStyle = normalizeDisplayStyle(base.displayStyle);
717
751
 
752
+ // Detection only: who compacts is decided by the preset slot (read from
753
+ // disk) times the 启用插件 switch. This row never changes either of them.
754
+ var SCOPE_TONE = {
755
+ native: { dot: "#6b7178", text: C.muted },
756
+ plugin: { dot: C.accent, text: "#cfe0ff" },
757
+ part: { dot: "#d29922", text: "#e6c98a" },
758
+ err: { dot: "#e5534b", text: "#f0b8b4" },
759
+ };
760
+ var scopeView = (function () {
761
+ var st = scope.value;
762
+ if (!st) {
763
+ return {
764
+ tone: "err",
765
+ text: scope.loading ? "读取中…" : "读不到生效情况",
766
+ sub: scope.loading
767
+ ? "正在向 DSH 读取预设名单与本插件的接管行。"
768
+ : "读不到预设名单:" + (scope.error || "未知原因") + "。",
769
+ rows: [], retry: true,
770
+ };
771
+ }
772
+ var list = Array.isArray(st.presets) ? st.presets : [];
773
+ rememberPresetLabels(list);
774
+ var rows = list.map(function (p) {
775
+ return {
776
+ key: String(p.id),
777
+ label: presetLabel(p.id),
778
+ taken: p.effective === true,
779
+ owner: p.effective === true ? "插件" : p.covered === true ? "原生(未勾选)" : "原生(未覆盖)",
780
+ };
781
+ });
782
+ var names = rows.map(function (r) { return r.label; });
783
+ var takenNames = rows.filter(function (r) { return r.taken; }).map(function (r) { return r.label; });
784
+ var restNames = rows.filter(function (r) { return !r.taken; }).map(function (r) { return r.label; });
785
+ var total = Number(st.total) || list.length;
786
+ var covered = Number(st.covered) || 0;
787
+ var effective = Number(st.effective) || 0;
788
+ var globalSaved = st.scope !== "preset";
789
+ var scopeLead = globalSaved ? "全局生效:" : "按预设生效:";
790
+ var mechanism = "接管行写在本插件包内的补丁里,卸载插件时随包一起消失,你的预设文件不会被改动。";
791
+
792
+ if (total === 0) {
793
+ return {
794
+ tone: "err", text: "读不到预设", rows: [], retry: true,
795
+ sub: "DSH 没有返回任何 Agent 预设" + (st.reason ? "(" + st.reason + ")" : "") + ",所以无法判断压缩由谁负责。重新读取试试。",
796
+ };
797
+ }
798
+ if (covered === 0) {
799
+ return {
800
+ tone: "native", text: "DSH 原生压缩", rows: rows, retry: true,
801
+ sub: "本插件的接管行还没覆盖到任何预设" + (st.reason ? "(" + st.reason + ")" : "")
802
+ + ",压缩目前由 DSH 自己完成。" + mechanism + " 新生成或新装的预设需要重启 DSH 重新生成接管行。",
803
+ };
804
+ }
805
+ if (effective === 0 && !enabled) {
806
+ return {
807
+ tone: "native", text: "DSH 原生压缩(插件已停用)", rows: rows, retry: false,
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
+ + "勾选预设或切到「全局生效」并保存即可,下一次判定就生效,不需要重启。",
816
+ };
817
+ }
818
+ if (effective < total) {
819
+ return {
820
+ tone: "part", text: effective + "/" + total + " 预设已由插件压缩", rows: rows, retry: false,
821
+ sub: scopeLead + "由插件负责:" + takenNames.join("、") + ";" + restNames.join("、") + " 仍走各自的原生压缩。" + mechanism,
822
+ };
823
+ }
824
+ return {
825
+ tone: "plugin",
826
+ text: globalSaved ? "已由插件接管(全局)" : "已由插件接管",
827
+ rows: rows, retry: false,
828
+ sub: scopeLead + (globalSaved
829
+ ? "这 " + total + " 个预设(" + names.join("、") + ")"
830
+ : "勾选的 " + effective + " 个预设(" + takenNames.join("、") + ")")
831
+ + " 的压缩由本插件的交接文档负责,且「启用插件」是开的。" + mechanism,
832
+ };
833
+ })();
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
+ : [];
840
+
718
841
  var budget = EXAMPLE_WINDOW - EXAMPLE_RESERVED;
719
842
  var ceiling = budget - EXAMPLE_HEADROOM;
720
843
  var eff = Math.min(Math.round(budget * ratio / 100), ceiling);
@@ -726,6 +849,20 @@ window.__ModuleLoader__.load({
726
849
  (settings.templateFile || "") !== template ||
727
850
  normalizeDisplayStyle(settings.displayStyle) !== themeStyle;
728
851
 
852
+ // The detection row reports what is on disk, so every unsaved choice that
853
+ // would change that report has to be named — otherwise it looks ignored.
854
+ var enabledIsPending = settings !== null && (settings.enabled !== false) !== enabled;
855
+ var scopeIsPending = settings !== null && (settings.scope === "preset") !== scopePreset;
856
+ var presetsIsPending = settings !== null && scopePreset &&
857
+ JSON.stringify(presets.slice().sort()) !== JSON.stringify((settings.presets || []).slice().sort());
858
+ var pendingParts = [];
859
+ if (enabledIsPending) pendingParts.push("「启用插件」改成" + (enabled ? "开" : "关"));
860
+ if (scopeIsPending) pendingParts.push("生效范围改成「" + (scopePreset ? "按预设生效" : "全局生效") + "」");
861
+ if (presetsIsPending) pendingParts.push("勾选的预设改成" + (presets.length ? presets.map(presetLabel).join("、") : "(都不勾)"));
862
+ var pendingNote = pendingParts.length
863
+ ? "还没保存:" + pendingParts.join(";") + "。保存后开关与勾选在下一次判定就生效,不需要重启;只有接管行本身要覆盖新的预设时才需要重启 DSH 重新生成。"
864
+ : "";
865
+
729
866
  function patch(next) { setDraft(Object.assign({}, base, next)); setStatus({ loading: false, error: "", saving: false, saved: false }); }
730
867
  function setPreset(id) {
731
868
  var next = presets.indexOf(id) >= 0 ? presets.filter(function (v) { return v !== id; }) : presets.concat([id]);
@@ -744,10 +881,12 @@ window.__ModuleLoader__.load({
744
881
  setSettings(value); setDraft(value);
745
882
  publishDisplayStyle(value && value.displayStyle);
746
883
  setStatus({ loading: false, error: "", saving: false, saved: true });
884
+ setToken(token + 1);
747
885
  }).catch(function (error) {
748
886
  setStatus({ loading: false, error: String(error && error.message || error), saving: false, saved: false });
749
887
  });
750
888
  }
889
+
751
890
  function discard() { setDraft(settings); setStatus({ loading: false, error: "", saving: false, saved: false }); }
752
891
  function open(kind) {
753
892
  call(connection, "open", { kind: kind, path: template }).then(function () {
@@ -791,7 +930,7 @@ window.__ModuleLoader__.load({
791
930
  h("div", { style: rowStyle },
792
931
  h("div", null,
793
932
  h("div", { style: labelStyle }, "生效范围"),
794
- h("div", { style: subStyle }, scopePreset ? "仅勾选的预设切到插件并关闭其原生压缩;其余预设保持原生" : "所有带 handoff slot 的可编辑预设都切到插件,共用这份设置")
933
+ h("div", { style: subStyle }, scopePreset ? "只有勾选的预设由插件压缩,其余预设保持 DSH 原生行为;名单来自 DSH 的预设注册表" : "所有能被本插件接管的预设都由插件压缩,共用这份设置")
795
934
  ),
796
935
  h("div", { style: { display: "inline-flex", border: "1px solid " + C.line, borderRadius: 8, overflow: "hidden" } },
797
936
  seg(!scopePreset, "全局生效", function () { patch({ scope: "global" }); }),
@@ -799,15 +938,51 @@ window.__ModuleLoader__.load({
799
938
  )
800
939
  ),
801
940
  scopePreset ? h("div", { style: { display: "flex", gap: 8, flexWrap: "wrap", padding: "10px 0", borderBottom: "1px solid " + C.line } },
802
- PRESETS.map(function (p) {
803
- var on = presets.indexOf(p.id) >= 0;
804
- return h("button", {
805
- key: p.id, type: "button", onClick: function () { setPreset(p.id); },
806
- 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 },
807
- }, p.label);
808
- })
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
+ })
809
952
  ) : null,
810
953
 
954
+ h("div", { style: rowStyle },
955
+ h("div", { style: { minWidth: 0 } },
956
+ h("div", { style: labelStyle }, "当前生效情况"),
957
+ h("div", { style: subStyle }, scopeView.sub),
958
+ scopeView.rows.length
959
+ ? h("div", { style: { display: "flex", gap: "6px 16px", flexWrap: "wrap", marginTop: 7 } },
960
+ scopeView.rows.map(function (r) {
961
+ return h("span", { key: r.key, style: { display: "inline-flex", alignItems: "center", gap: 6, fontSize: 12, color: C.muted } },
962
+ h("i", { "aria-hidden": true, style: { width: 6, height: 6, borderRadius: "50%", background: r.taken ? C.accent : "#6b7178" } }),
963
+ r.label,
964
+ h("em", { style: { fontStyle: "normal", color: C.faint } }, r.owner));
965
+ })
966
+ )
967
+ : null,
968
+ pendingNote ? h("div", { style: { fontSize: 11.5, color: "#d29922", marginTop: 6 } }, pendingNote) : null,
969
+ scope.error && scope.value ? h("div", { style: { fontSize: 11.5, color: "#e5534b", marginTop: 6 } }, "读取失败:" + scope.error) : null
970
+ ),
971
+ h("div", { style: { display: "inline-flex", alignItems: "center", gap: 12, flex: "0 0 auto" } },
972
+ h("span", { "aria-live": "polite", style: { display: "inline-flex", alignItems: "center", gap: 7, fontSize: 12.5, color: scopeTone.text } },
973
+ h("i", { "aria-hidden": true, style: { width: 7, height: 7, borderRadius: "50%", background: scopeTone.dot } }),
974
+ scopeView.text
975
+ ),
976
+ scopeView.retry && !scope.loading
977
+ ? h("button", {
978
+ type: "button",
979
+ onClick: function () { setToken(token + 1); },
980
+ style: Object.assign({}, btnBase, { fontSize: 11.5, padding: "4px 9px" }),
981
+ }, "重新读取")
982
+ : null
983
+ )
984
+ ),
985
+
811
986
  h("div", { style: { padding: "12px 0", borderBottom: "1px solid " + C.line } },
812
987
  h("div", null,
813
988
  h("div", { style: labelStyle }, "压缩显示方式", h("span", { style: newBadgeStyle }, "新")),
@@ -881,8 +1056,8 @@ window.__ModuleLoader__.load({
881
1056
  h("ol", { style: { margin: 0, paddingLeft: 18, color: C.muted, fontSize: 12.5, lineHeight: 1.85 } },
882
1057
  h("li", null, "到点后不立即动手,等 agent 结束当前回合。"),
883
1058
  h("li", null, "用会话自己的前缀(命中缓存)让模型按模板写交接文档,不额外全量 prefill。"),
884
- h("li", null, "把整段历史替换成这一条消息;旧消息仍在会话日志里,rewind 可回滚。"),
885
- h("li", null, "下一轮只剩 系统提示词 + 工具定义 + 交接文档。")
1059
+ h("li", null, "只替换最旧的这一段历史,换成一条交接文档;最近约 16% 窗口的原文逐字留着,切点也不会把工具调用和它的结果拆开。"),
1060
+ h("li", null, "下一轮 = 系统提示词 + 工具定义 + 这条交接文档 + 那段逐字保留的最近消息;旧消息仍在会话日志里,rewind 可回滚。")
886
1061
  ),
887
1062
  h("div", { style: { marginTop: 8, fontSize: 11.5, color: C.faint } }, "手动压缩:会话里发送 /compact 可随时折叠一次,不受这个比例限制。")
888
1063
  ),
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"),
@@ -143,9 +144,9 @@ function scopeScriptPath() {
143
144
  }
144
145
  }
145
146
 
146
- function execSwitchOnce(command, env, script) {
147
+ function execSwitchOnce(command, env, script, action) {
147
148
  return new Promise((resolve, reject) => {
148
- execFile(command, [script, "enable"], { timeout: 6000, windowsHide: true, env }, (error, stdout, stderr) => {
149
+ execFile(command, [script, action], { timeout: 6000, windowsHide: true, env }, (error, stdout, stderr) => {
149
150
  if (error) {
150
151
  error.stdout = stdout;
151
152
  error.stderr = stderr;
@@ -158,15 +159,14 @@ function execSwitchOnce(command, env, script) {
158
159
  }
159
160
 
160
161
  /**
161
- * Apply the current settings sidecar to the preset/profile YAML. `node` is the
162
- * normal path; the Electron binary with ELECTRON_RUN_AS_NODE is the fallback
163
- * when the host has no node on PATH.
162
+ * Run one preset-scope action. `node` is the normal path; the Electron binary
163
+ * with ELECTRON_RUN_AS_NODE is the fallback when the host has no node on PATH.
164
164
  */
165
- async function applyScopeSwitch() {
165
+ async function runScope(action) {
166
166
  const script = scopeScriptPath();
167
167
  if (!existsSync(script)) {
168
168
  const message = "preset-scope.mjs not found: " + script;
169
- trace("scope switch failed: " + message);
169
+ trace("scope " + action + " failed: " + message);
170
170
  return { ok: false, message };
171
171
  }
172
172
  const explicit = process.env.DSH_NODE;
@@ -179,10 +179,11 @@ async function applyScopeSwitch() {
179
179
  let lastError;
180
180
  for (const attempt of attempts) {
181
181
  try {
182
- const result = await execSwitchOnce(attempt.command, attempt.env, script);
183
- const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
184
- trace("scope switch ok" + (output ? " | " + output : ""));
185
- return { ok: true, output };
182
+ const result = await execSwitchOnce(attempt.command, attempt.env, script, action);
183
+ const output = `${result.stdout ?? ""}`.trim();
184
+ const noise = `${result.stderr ?? ""}`.trim();
185
+ trace("scope " + action + " ok" + (output ? " | " + output : "") + (noise ? " | " + noise : ""));
186
+ return { ok: true, output, noise };
186
187
  } catch (error) {
187
188
  lastError = error;
188
189
  if (error?.code !== "ENOENT") break;
@@ -190,10 +191,44 @@ async function applyScopeSwitch() {
190
191
  }
191
192
  const output = `${lastError?.stdout ?? ""}${lastError?.stderr ?? ""}`.trim();
192
193
  const message = (lastError?.message ?? String(lastError)) + (output ? "\n" + output : "");
193
- trace("scope switch failed: " + message);
194
+ trace("scope " + action + " failed: " + message);
194
195
  return { ok: false, message };
195
196
  }
196
197
 
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;
215
+ try {
216
+ value = JSON.parse(line);
217
+ } catch {
218
+ return { reverted: false, reason: "unreadable legacy state" };
219
+ }
220
+ const legacy = Number(value?.taken) > 0 || value?.rootGuard === "off";
221
+ if (!legacy) return { reverted: false, legacy: false };
222
+
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 };
230
+ }
231
+
197
232
  const CHANNEL = "/dsh-auto-compaction";
198
233
 
199
234
  function readSettings() {
@@ -224,6 +259,22 @@ function sanitize(payload) {
224
259
  export function apply(ctx, config = {}) {
225
260
  const seeded = normalize(config);
226
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
+ });
227
278
  void handleOpen(ctx, config.openTemplate, seeded.templateFile || TEMPLATE_FILE).catch((error) => {
228
279
  ctx.logger?.warn?.(`[handoff-ui] openTemplate failed: ${error instanceof Error ? error.message : String(error)}`);
229
280
  });
@@ -259,11 +310,29 @@ export function apply(ctx, config = {}) {
259
310
  const next = sanitize(payload);
260
311
  trace("set " + JSON.stringify(next));
261
312
  writeSettings(ctx, next);
262
- const applied = await applyScopeSwitch();
263
- if (!applied.ok) {
264
- return { ok: false, error: { code: "auto-compaction/switch-failed", message: applied.message }, value: next };
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 } };
317
+ }
318
+ if (endpoint === "scope") {
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 } };
265
335
  }
266
- return { ok: true, value: { ...next, switchApplied: true, defaultTemplate: TEMPLATE_FILE } };
267
336
  }
268
337
  if (endpoint === "open") {
269
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.1",
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.1"
54
+ "dsh-auto-compaction-engine": "^0.2.3"
55
55
  }
56
56
  }