dsh-auto-compaction 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -22,11 +22,24 @@ import { join } from "node:path";
22
22
  import { BlockAssembler, LlmError, contentHasImage } from "@deepseek-ai/dsh-llm";
23
23
  import BasicCompactionEngine from "@deepseek-ai/dsh-compaction-basic";
24
24
  import { decideTrigger, shouldAttempt } from "./plan.js";
25
+ import { writeStatus } from "./status.js";
26
+ import {
27
+ continuationInstruction,
28
+ expectedSections,
29
+ looksTruncated,
30
+ mergeSummary,
31
+ rewriteInstruction,
32
+ stripTruncationMarkers,
33
+ validateSummary,
34
+ } from "./summary.js";
25
35
 
26
36
  const LOG = "[handoff]";
37
+ const MAX_SUMMARY_ATTEMPTS = 3;
27
38
 
28
39
  const SETTINGS_DIR = join(homedir(), ".dsh-handoff-compaction");
29
- const SETTINGS_FILE = join(SETTINGS_DIR, "settings.json");
40
+ // Test harnesses point this at a missing file so the live sidecar cannot leak
41
+ // into a case that expects the configured ratio.
42
+ const SETTINGS_FILE = process.env.DSH_HANDOFF_SETTINGS_FILE ?? join(SETTINGS_DIR, "settings.json");
30
43
  const DEFAULT_TEMPLATE_FILE = join(SETTINGS_DIR, "template.md");
31
44
 
32
45
  /**
@@ -124,6 +137,22 @@ function finishError(finish) {
124
137
  }
125
138
  }
126
139
 
140
+ function mergeUsage(left, right) {
141
+ if (left === undefined) return right;
142
+ if (right === undefined) return left;
143
+ const merged = { ...left };
144
+ for (const key of Object.keys(right)) {
145
+ const value = right[key];
146
+ if (typeof value === "number" && typeof merged[key] === "number") merged[key] += value;
147
+ else if (merged[key] === undefined) merged[key] = value;
148
+ }
149
+ return merged;
150
+ }
151
+
152
+ function errorCodeOf(error) {
153
+ return error !== null && typeof error === "object" && typeof error.code === "string" ? error.code : "";
154
+ }
155
+
127
156
  export default class HandoffCompaction extends BasicCompactionEngine {
128
157
  static inject = ["llm", "tokenMeter", "sessions"];
129
158
 
@@ -169,9 +198,10 @@ export default class HandoffCompaction extends BasicCompactionEngine {
169
198
  autoCreateTemplate,
170
199
  ...base
171
200
  } = config;
172
- // auto:false keeps the stock between-step pressure trigger out of the way;
173
- // this backend decides on idle instead.
174
- super(ctx, { ...base, auto: false });
201
+ // The stock trigger is registered so the plugin switch can hand compaction
202
+ // back to the native engine live; compactIfNeeded() suppresses it while the
203
+ // handoff path is enabled.
204
+ super(ctx, { ...base, auto: true });
175
205
  this.handoff = {
176
206
  instruction: typeof handoffInstruction === "string" && handoffInstruction.trim().length > 0 ? handoffInstruction : undefined,
177
207
  contextWindowOverride: contextWindowOverride ?? 0,
@@ -211,6 +241,26 @@ export default class HandoffCompaction extends BasicCompactionEngine {
211
241
  return `${Math.round((this.config.thresholdRatio ?? 0.9) * 100)}%`;
212
242
  }
213
243
 
244
+ /**
245
+ * Settings switch. Missing settings or a missing field keeps the plugin on;
246
+ * only an explicit `enabled: false` hands compaction back to the stock engine.
247
+ */
248
+ handoffEnabled() {
249
+ const live = readSettings();
250
+ return !(live !== undefined && live.enabled === false);
251
+ }
252
+
253
+ /**
254
+ * The base registers its between-step pressure / overflow triggers whenever
255
+ * `auto` is on. While the handoff path is enabled they must stay inert, so
256
+ * the plugin still only compacts from its idle gate. Disabled: delegate, and
257
+ * DSH's native compaction behaves exactly as without the plugin.
258
+ */
259
+ async compactIfNeeded(agent, trigger, signal) {
260
+ if (this.handoffEnabled()) return null;
261
+ return super.compactIfNeeded(agent, trigger, signal);
262
+ }
263
+
214
264
  log(message, level = "info") {
215
265
  const line = `${new Date().toISOString()} ${LOG} ${message}`;
216
266
  if (this.handoff?.traceConsole === true) {
@@ -246,6 +296,7 @@ export default class HandoffCompaction extends BasicCompactionEngine {
246
296
 
247
297
  async checkAndHandoff(agent, why = "idle") {
248
298
  if (this.running.has(agent)) return;
299
+ if (!this.handoffEnabled()) return;
249
300
  try {
250
301
  const session = agent.session;
251
302
  if (!session) return;
@@ -301,24 +352,123 @@ export default class HandoffCompaction extends BasicCompactionEngine {
301
352
  }
302
353
 
303
354
  this.running.add(agent);
355
+ writeStatus(session.id, {
356
+ phase: "running",
357
+ step: "prepare",
358
+ startedAt: Date.now(),
359
+ totalTokens: measurement.totalTokens,
360
+ thresholdTokens: decision.thresholdTokens,
361
+ contextWindow,
362
+ timeoutMs: this.handoff.timeoutMs,
363
+ provider: target.provider,
364
+ model: target.model,
365
+ });
304
366
  try {
305
367
  const result = await this.compactNow(agent, AbortSignal.timeout(this.handoff.timeoutMs));
306
368
  if (result === null || result === undefined) {
369
+ writeStatus(session.id, {
370
+ phase: "done",
371
+ step: "done",
372
+ completedAt: Date.now(),
373
+ shadowedNodes: 0,
374
+ shadowedTokens: 0,
375
+ note: "no compactable range",
376
+ });
307
377
  this.log("idle: no compactable range; nothing replaced", "warn");
308
378
  return;
309
379
  }
380
+ writeStatus(session.id, {
381
+ phase: "done",
382
+ step: "done",
383
+ completedAt: Date.now(),
384
+ shadowedNodes: result.shadowedSeqs.length,
385
+ shadowedTokens: result.shadowedTokenCount,
386
+ missingSections: [],
387
+ retrying: false,
388
+ });
310
389
  this.log(`committed: replaced ${result.shadowedSeqs.length} node(s) (~${result.shadowedTokenCount} tokens) with the handoff checkpoint`);
390
+ } catch (error) {
391
+ const message = error instanceof Error ? error.message : String(error);
392
+ writeStatus(agent.session?.id, {
393
+ phase: "failed",
394
+ step: "failed",
395
+ failedAt: Date.now(),
396
+ error: message,
397
+ errorCode: errorCodeOf(error),
398
+ });
399
+ throw error;
311
400
  } finally {
312
401
  this.running.delete(agent);
313
402
  }
314
403
  } catch (error) {
315
404
  const message = error instanceof Error ? error.message : String(error);
405
+ writeStatus(agent.session?.id, {
406
+ phase: "failed",
407
+ step: "failed",
408
+ failedAt: Date.now(),
409
+ error: message,
410
+ errorCode: errorCodeOf(error),
411
+ });
316
412
  this.log(`failed: ${message}`, "warn");
317
413
  }
318
414
  }
319
415
 
416
+ /**
417
+ * Wrap the stock transaction so manual /compact and automatic idle
418
+ * compaction share the same status sidecar. The base method still owns the
419
+ * surface lock, summarization and commit.
420
+ */
421
+ async compactNow(agent, signal, sourceCommandId) {
422
+ // Manual /compact while the plugin is off stays a stock native compaction:
423
+ // no handoff template, no plugin status sidecar.
424
+ if (!this.handoffEnabled()) return super.compactNow(agent, signal, sourceCommandId);
425
+ const sessionId = agent?.session?.id;
426
+ let totalTokens;
427
+ try { totalTokens = this.ctx.tokenMeter.measure(agent.session).totalTokens; } catch { totalTokens = undefined; }
428
+ writeStatus(sessionId, {
429
+ phase: "running",
430
+ step: "prepare",
431
+ startedAt: Date.now(),
432
+ timeoutMs: this.handoff.timeoutMs,
433
+ ...(typeof totalTokens === "number" ? { totalTokens } : {}),
434
+ retrying: false,
435
+ writing: false,
436
+ missingSections: [],
437
+ error: "",
438
+ errorCode: "",
439
+ });
440
+ try {
441
+ const result = await super.compactNow(agent, signal, sourceCommandId);
442
+ writeStatus(sessionId, {
443
+ phase: "done",
444
+ step: "done",
445
+ completedAt: Date.now(),
446
+ shadowedNodes: Array.isArray(result?.shadowedSeqs) ? result.shadowedSeqs.length : 0,
447
+ shadowedTokens: typeof result?.shadowedTokenCount === "number" ? result.shadowedTokenCount : 0,
448
+ missingSections: [],
449
+ retrying: false,
450
+ writing: false,
451
+ ...(result === null || result === undefined ? { note: "no compactable range" } : {}),
452
+ });
453
+ return result;
454
+ } catch (error) {
455
+ const message = error instanceof Error ? error.message : String(error);
456
+ writeStatus(sessionId, {
457
+ phase: "failed",
458
+ step: "failed",
459
+ failedAt: Date.now(),
460
+ error: message,
461
+ errorCode: errorCodeOf(error),
462
+ });
463
+ throw error;
464
+ }
465
+ }
466
+
320
467
  /** Handoff template instead of the stock compaction instruction. */
321
468
  async summarize(input, agent, signal) {
469
+ // Native pressure/overflow compaction runs through this same hook, so the
470
+ // stock prompt must come back the moment the plugin is switched off.
471
+ if (!this.handoffEnabled()) return super.summarize(input, agent, signal);
322
472
  const latest = agent.session.requestHeader?.()?.config;
323
473
  const configured = this.config.summarizationProvider && this.config.summarizationModel
324
474
  ? { provider: this.config.summarizationProvider, model: this.config.summarizationModel }
@@ -328,6 +478,13 @@ export default class HandoffCompaction extends BasicCompactionEngine {
328
478
  : undefined;
329
479
  const target = configured ?? latest ?? agentTarget;
330
480
  if (target === undefined) throw new Error("no provider/model available for the handoff summary");
481
+ // Replaying the prefix only reuses the provider cache when the auxiliary
482
+ // request carries the same routing knobs as the routed one. Strata writes
483
+ // the reasoning directive into the prefix, so a missing effort breaks the
484
+ // cache a few thousand tokens in. Only inherit it when the summary target
485
+ // is the same model that produced the latest request.
486
+ const sameTarget = latest !== undefined && target.provider === latest.provider && target.model === latest.model;
487
+ const reasoningEffort = sameTarget ? latest.reasoningEffort : undefined;
331
488
 
332
489
  const resolved = this.handoff.instruction === undefined
333
490
  ? readTemplate(this.handoff.templateFile, this.handoff.autoCreateTemplate)
@@ -337,40 +494,157 @@ export default class HandoffCompaction extends BasicCompactionEngine {
337
494
  : BUILTIN_INSTRUCTION;
338
495
  this.log(`template: ${resolved.source}${resolved.text === undefined ? " (fallback)" : ""}`);
339
496
 
340
- const assembler = new BlockAssembler();
341
- const messages = [
497
+ const expected = expectedSections(instruction);
498
+ const baseMessages = [
342
499
  ...input.messages,
343
500
  Object.freeze({ role: "user", content: [{ type: "text", text: instruction }] }),
344
501
  ];
345
- const options = {
502
+ const baseOptions = {
346
503
  provider: target.provider,
347
504
  model: target.model,
348
- messages,
349
505
  toolHistory: agent.session.toolHistory(),
350
506
  ...(input.tools === undefined ? {} : { tools: [...input.tools] }),
351
507
  maxTokens: this.config.maxTokens,
508
+ ...(reasoningEffort === undefined ? {} : { reasoningEffort }),
352
509
  sessionId: agent.session.id,
353
510
  purpose: "compaction",
354
511
  ...(signal === undefined ? {} : { signal }),
355
512
  };
513
+ const sessionId = agent.session?.id;
356
514
 
357
- for await (const chunk of this.ctx.llm.stream(options)) assembler.push(chunk);
358
- const error = finishError(assembler.finish);
359
- if (error !== undefined) throw error;
515
+ let combined = "";
516
+ let rawOutput = [];
517
+ let usage;
518
+ let validation = {
519
+ ok: expected.length === 0,
520
+ missing: [...expected],
521
+ truncated: false,
522
+ endsWithHeading: false,
523
+ reason: "not attempted",
524
+ };
525
+ const attempts = [];
360
526
 
361
- const rawOutput = assembler.blocks();
362
- if (contentHasImage(rawOutput)) throw new LlmError("handoff summary cannot contain image output", "UNSUPPORTED_CONTENT");
363
- const summary = rawOutput.filter((block) => block.type === "text");
364
- if (!summary.some((block) => block.text.trim().length > 0)) throw new Error("handoff summary produced no text content");
527
+ let liveTotalTokens;
528
+ try { liveTotalTokens = this.ctx.tokenMeter.measure(agent.session).totalTokens; } catch { liveTotalTokens = undefined; }
529
+ writeStatus(sessionId, {
530
+ phase: "running",
531
+ step: "summary",
532
+ summaryAttempt: 1,
533
+ retrying: false,
534
+ writing: false,
535
+ missingSections: [],
536
+ ...(typeof liveTotalTokens === "number" ? { totalTokens: liveTotalTokens } : {}),
537
+ });
538
+
539
+ for (let attempt = 1; attempt <= MAX_SUMMARY_ATTEMPTS; attempt += 1) {
540
+ signal?.throwIfAborted?.();
541
+ const messages = attempt === 1
542
+ ? baseMessages
543
+ : attempt === 2
544
+ ? [
545
+ ...baseMessages,
546
+ Object.freeze({ role: "assistant", content: [{ type: "text", text: combined }] }),
547
+ Object.freeze({ role: "user", content: [{ type: "text", text: continuationInstruction(validation) }] }),
548
+ ]
549
+ : [
550
+ ...baseMessages,
551
+ Object.freeze({
552
+ role: "user",
553
+ content: [{ type: "text", text: rewriteInstruction(validation, this.config.maxTokens) }],
554
+ }),
555
+ ];
556
+
557
+ const result = await this.runSummaryRequest(baseOptions, messages);
558
+ const truncatedNow = looksTruncated(result.text);
559
+ const clean = stripTruncationMarkers(result.text);
560
+ combined = attempt === 1
561
+ ? clean
562
+ : attempt === 2
563
+ ? mergeSummary(combined, clean)
564
+ : clean;
565
+ rawOutput = result.rawOutput;
566
+ usage = mergeUsage(usage, result.usage);
567
+ validation = validateSummary(combined, expected);
568
+ if (truncatedNow) {
569
+ validation = {
570
+ ...validation,
571
+ ok: false,
572
+ truncated: true,
573
+ reason: validation.reason.length > 0 ? `${validation.reason}; truncation marker` : "truncation marker",
574
+ };
575
+ }
576
+ attempts.push({
577
+ attempt,
578
+ finish: result.finishKind,
579
+ chars: result.text.length,
580
+ ok: validation.ok,
581
+ });
582
+ if (validation.ok) break;
583
+ if (attempt < MAX_SUMMARY_ATTEMPTS) {
584
+ const next = attempt + 1;
585
+ this.log(`summary incomplete after attempt ${attempt}: ${validation.reason}; ${attempt === 1 ? "continuing" : "rewriting"}`);
586
+ writeStatus(sessionId, {
587
+ phase: "running",
588
+ step: "summary",
589
+ summaryAttempt: next,
590
+ retrying: true,
591
+ writing: next === MAX_SUMMARY_ATTEMPTS,
592
+ missingSections: validation.missing,
593
+ summaryChars: combined.length,
594
+ summaryAttempts: attempts,
595
+ });
596
+ }
597
+ }
598
+
599
+ if (!validation.ok) {
600
+ const error = new Error(`handoff summary incomplete after ${attempts.length} attempt(s): ${validation.reason}`);
601
+ error.code = "INCOMPLETE_SUMMARY";
602
+ error.attempts = attempts;
603
+ throw error;
604
+ }
605
+
606
+ const finalText = combined.trim();
607
+ if (finalText.length === 0) throw new Error("handoff summary produced no text content");
608
+ writeStatus(sessionId, {
609
+ phase: "running",
610
+ step: "commit",
611
+ summaryAttempt: attempts.length,
612
+ retrying: false,
613
+ writing: false,
614
+ missingSections: [],
615
+ summaryChars: finalText.length,
616
+ summaryAttempts: attempts,
617
+ });
365
618
 
366
619
  return {
367
- summary,
620
+ summary: [{ type: "text", text: finalText }],
368
621
  rawOutput,
369
622
  llmStreamCall: true,
370
623
  provider: target.provider,
371
624
  model: target.model,
372
625
  maxTokens: this.config.maxTokens,
373
- ...(assembler.usage === undefined ? {} : { usage: assembler.usage }),
626
+ ...(usage === undefined ? {} : { usage }),
627
+ };
628
+ }
629
+
630
+ /** One summary LLM call; max-tokens is returned as a partial, not thrown. */
631
+ async runSummaryRequest(baseOptions, messages) {
632
+ const assembler = new BlockAssembler();
633
+ for await (const chunk of this.ctx.llm.stream({ ...baseOptions, messages })) assembler.push(chunk);
634
+ const finish = assembler.finish ?? { kind: "stop" };
635
+ if (finish.kind === "error" || finish.kind === "aborted") {
636
+ const error = finishError(finish);
637
+ if (error !== undefined) throw error;
638
+ }
639
+ const rawOutput = assembler.blocks();
640
+ if (contentHasImage(rawOutput)) throw new LlmError("handoff summary cannot contain image output", "UNSUPPORTED_CONTENT");
641
+ const text = rawOutput.filter((block) => block.type === "text").map((block) => block.text).join("");
642
+ if (text.trim().length === 0) throw new Error("handoff summary produced no text content");
643
+ return {
644
+ rawOutput,
645
+ text,
646
+ finishKind: finish.kind,
647
+ usage: assembler.usage,
374
648
  };
375
649
  }
376
650
  }
package/lib/status.js ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Per-session compaction status sidecar.
3
+ *
4
+ * The engine writes one small JSON file while it prepares, summarizes and
5
+ * commits a handoff; the UI dock polls it through the companion package's RPC
6
+ * channel. Kept free of DSH imports so the UI's node half can reuse it.
7
+ */
8
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ const STATUS_DIR = process.env.DSH_HANDOFF_STATUS_DIR || join(homedir(), ".dsh-handoff-compaction", "status");
13
+ const SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/;
14
+
15
+ /** Absolute status file for a session id, or undefined when the id is unsafe. */
16
+ export function statusFileFor(sessionId) {
17
+ if (typeof sessionId !== "string" || !SESSION_ID.test(sessionId)) return undefined;
18
+ return join(STATUS_DIR, `${sessionId}.json`);
19
+ }
20
+
21
+ /** Latest status for a session, or undefined when absent/unreadable. */
22
+ export function readStatus(sessionId) {
23
+ const file = statusFileFor(sessionId);
24
+ if (file === undefined) return undefined;
25
+ try {
26
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
27
+ return parsed && typeof parsed === "object" ? parsed : undefined;
28
+ } catch {
29
+ return undefined;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Merge one patch into the session status and replace the file atomically.
35
+ * Returns false when the session id is unsafe or the write failed; status
36
+ * writes must never break a compaction.
37
+ */
38
+ export function writeStatus(sessionId, patch) {
39
+ const file = statusFileFor(sessionId);
40
+ if (file === undefined || patch === undefined || patch === null) return false;
41
+ try {
42
+ mkdirSync(STATUS_DIR, { recursive: true });
43
+ const previous = readStatus(sessionId) ?? {};
44
+ const next = { ...previous, ...patch, sessionId, updatedAt: Date.now() };
45
+ const temp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
46
+ writeFileSync(temp, JSON.stringify(next), "utf8");
47
+ renameSync(temp, file);
48
+ return true;
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
package/lib/summary.js ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Pure completeness checks for the generated handoff document.
3
+ *
4
+ * The local models used with DSH sometimes stop early and append a literal
5
+ * "(截断)" marker (or stop right after a heading) instead of failing the
6
+ * request. These helpers let the engine treat such output as incomplete,
7
+ * continue it, or rewrite it before the checkpoint is committed.
8
+ */
9
+
10
+ const FENCE = /^\s*(```|~~~)/;
11
+ const HEADING = /^\s*(#{2,3})\s+(.+?)\s*$/;
12
+
13
+ function normalizeHeading(text) {
14
+ return String(text ?? "")
15
+ .replace(/^#{2,3}\s*/, "")
16
+ .replace(/^\s*\d+[.、)]\s*/, "")
17
+ .replace(/[*_`]/g, "")
18
+ .replace(/[\s::]+$/g, "")
19
+ .trim();
20
+ }
21
+
22
+ function headingsIn(text) {
23
+ const out = [];
24
+ let fenced = false;
25
+ for (const line of String(text ?? "").split(/\r?\n/)) {
26
+ if (FENCE.test(line)) {
27
+ fenced = !fenced;
28
+ continue;
29
+ }
30
+ if (fenced) continue;
31
+ const match = HEADING.exec(line);
32
+ if (match === null) continue;
33
+ const heading = normalizeHeading(match[2]);
34
+ if (heading.length > 0 && !out.includes(heading)) out.push(heading);
35
+ }
36
+ return out;
37
+ }
38
+
39
+ /** Section headings the template asks the model to produce. */
40
+ export function expectedSections(templateText) {
41
+ return headingsIn(templateText);
42
+ }
43
+
44
+ /** Section headings actually present in a generated summary. */
45
+ export function summarySections(summaryText) {
46
+ return headingsIn(summaryText);
47
+ }
48
+
49
+ /** Whether the text ends with a model-generated truncation marker. */
50
+ export function looksTruncated(text) {
51
+ const tail = String(text ?? "").trimEnd();
52
+ if (tail.length === 0) return false;
53
+ if (/[((]\s*截断\s*[))]\s*$/.test(tail)) return true;
54
+ if (/[\[【]\s*截断\s*[\]】]\s*$/.test(tail)) return true;
55
+ const last = tail.slice(-48);
56
+ return /(?:输出|回答|摘要|文档|内容|响应)\s*(?:已|被)?\s*截断\s*$/.test(last);
57
+ }
58
+
59
+ /** Remove literal truncation markers the model wrote about its own output. */
60
+ export function stripTruncationMarkers(text) {
61
+ return String(text ?? "")
62
+ .replace(/[…\.]{0,3}\s*[((]\s*截断\s*[))]/g, "")
63
+ .replace(/[…\.]{0,3}\s*[\[【]\s*截断\s*[\]】]/g, "")
64
+ .trimEnd();
65
+ }
66
+
67
+ /**
68
+ * Validate one generated handoff document against the template sections.
69
+ * `ok` is false when a section is missing, the text ends on a bare heading,
70
+ * or the model marked its own output as truncated.
71
+ */
72
+ export function validateSummary(text, expected = []) {
73
+ const headings = headingsIn(text);
74
+ const missing = expected.filter(
75
+ (section) => !headings.some((heading) => heading.includes(section) || section.includes(heading)),
76
+ );
77
+ const truncated = looksTruncated(text);
78
+ const endsWithHeading = /(?:^|\n)\s*#{2,3}\s+[^\n]*\s*$/.test(String(text ?? "").trimEnd());
79
+ const reasons = [];
80
+ if (missing.length > 0) reasons.push(`missing sections: ${missing.join(", ")}`);
81
+ if (truncated) reasons.push("truncation marker");
82
+ if (endsWithHeading) reasons.push("ends on a heading");
83
+ return {
84
+ ok: missing.length === 0 && !truncated && !endsWithHeading,
85
+ missing,
86
+ truncated,
87
+ endsWithHeading,
88
+ reason: reasons.join("; "),
89
+ };
90
+ }
91
+
92
+ function lastHeading(text) {
93
+ const headings = headingsIn(text);
94
+ return headings.length === 0 ? undefined : headings[headings.length - 1];
95
+ }
96
+
97
+ function startsWithHeading(text) {
98
+ const first = String(text ?? "").trimStart().split(/\r?\n/, 1)[0] ?? "";
99
+ const match = HEADING.exec(first);
100
+ return match === null ? undefined : normalizeHeading(match[2]);
101
+ }
102
+
103
+ /** Join a partial summary and a continuation without duplicating its heading. */
104
+ export function mergeSummary(partial, tail) {
105
+ const head = String(partial ?? "").trimEnd();
106
+ const rest = String(tail ?? "").trim();
107
+ if (head.length === 0) return rest;
108
+ if (rest.length === 0) return head;
109
+ const repeated = lastHeading(head);
110
+ const first = startsWithHeading(rest);
111
+ if (repeated !== undefined && first !== undefined && repeated === first) {
112
+ const newline = rest.indexOf("\n");
113
+ const body = newline === -1 ? "" : rest.slice(newline + 1).trim();
114
+ return body.length === 0 ? head : `${head}\n${body}`;
115
+ }
116
+ if (first !== undefined) return `${head}\n\n${rest}`;
117
+ const safeBreak = /[。!?.!?;;::)\])】]$/.test(head);
118
+ return safeBreak ? `${head}\n\n${rest}` : `${head}${rest}`;
119
+ }
120
+
121
+ /** User turn appended after the partial assistant document. */
122
+ export function continuationInstruction(validation) {
123
+ const missing = validation?.missing ?? [];
124
+ const lines = [
125
+ "你刚才写到一半中断了。请从断点继续这份交接文档:",
126
+ "- 先补完断点处的句子,不要重复已经写过的内容或标题;",
127
+ ];
128
+ if (missing.length > 0) {
129
+ lines.push(`- 然后按模板顺序补完这些缺失小节:${missing.join("、")};`);
130
+ } else {
131
+ lines.push("- 并完成文档剩余部分;");
132
+ }
133
+ lines.push("- 只输出 Markdown 正文,不要解释,不要写「(截断)」。");
134
+ return lines.join("\n");
135
+ }
136
+
137
+ /** User turn appended when a continuation still does not pass validation. */
138
+ export function rewriteInstruction(validation, maxTokens) {
139
+ const missing = validation?.missing ?? [];
140
+ const why = missing.length > 0 ? `缺少这些小节:${missing.join("、")}` : "输出在末尾被截断";
141
+ return [
142
+ `上一次的交接文档不完整(${why})。`,
143
+ "请重新完整输出整份交接文档,必须包含模板要求的全部小节;",
144
+ `不要因为长度自行截断,不要写「(截断)」;本任务允许输出到 ${String(maxTokens)} tokens。`,
145
+ "只输出 Markdown 正文。",
146
+ ].join("\n");
147
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-auto-compaction",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "main": "lib/index.js",
6
6
  "exports": {
@@ -40,5 +40,8 @@
40
40
  ],
41
41
  "publishConfig": {
42
42
  "access": "public"
43
+ },
44
+ "dependencies": {
45
+ "js-yaml": "^4.1.0"
43
46
  }
44
47
  }
@@ -2,42 +2,141 @@
2
2
  * preset-scope.mjs — switch an editable DSH preset between the handoff plugin and
3
3
  * DSH's native compaction, reversibly.
4
4
  *
5
- * A preset context can only have ONE provider of `ctx.compaction`, so "native off"
6
- * means "which row is active". The script replaces a marker-delimited slot in the
7
- * preset's cordis.patch.yml with either row file, validates the resulting YAML, and
8
- * keeps a .bak-scope copy. Disabling restores the stock row (`auto: true`), so a
9
- * preset goes back to DSH native compaction the moment the plugin is switched off.
5
+ * Two layers must agree for "native off" to be real:
6
+ * 1. the preset's marker-delimited compaction slot (the row that supplies
7
+ * `ctx.compaction` inside the preset group), and
8
+ * 2. the profile-level root `compaction-basic` node (DSH's default engine,
9
+ * which also listens on `agent/pre-step` and can compact before the
10
+ * preset's isolated plugin is consulted).
11
+ *
12
+ * Layer 1 is replaced wholesale between row-plugin.yml and row-native.yml.
13
+ * Layer 2 is guarded by a marker comment that sets `auto: false` while the
14
+ * plugin is enabled and is removed on disable, restoring the stock default.
15
+ *
16
+ * Without --preset/--all-local, `enable` reads the plugin settings sidecar:
17
+ * `scope: "preset"` switches exactly the listed preset ids to the plugin and
18
+ * resets every other editable preset to native; `scope: "global"` switches all.
19
+ * `disable` restores native everywhere. The root guard is on only while at
20
+ * least one preset uses the plugin.
10
21
  *
11
22
  * Usage:
12
- * node scripts/preset-scope.mjs status [--preset <cordis.patch.yml>] [--all-local]
13
- * node scripts/preset-scope.mjs enable [--preset <cordis.patch.yml>] [--all-local]
14
- * node scripts/preset-scope.mjs disable [--preset <cordis.patch.yml>] [--all-local]
23
+ * node scripts/preset-scope.mjs status [--preset <cordis.patch.yml>] [--profile-patch <cordis.patch.yml>] [--all-local] [--skip-profile]
24
+ * node scripts/preset-scope.mjs enable [--preset <cordis.patch.yml>] [--profile-patch <cordis.patch.yml>] [--all-local] [--skip-profile]
25
+ * node scripts/preset-scope.mjs disable [--preset <cordis.patch.yml>] [--profile-patch <cordis.patch.yml>] [--all-local] [--skip-profile]
15
26
  * Restart DSH afterwards.
16
27
  */
17
28
  import fs from "node:fs";
29
+ import { homedir } from "node:os";
18
30
  import path from "node:path";
19
31
  import { fileURLToPath } from "node:url";
20
32
  import { createRequire } from "node:module";
21
33
 
22
34
  const HERE = path.dirname(fileURLToPath(import.meta.url));
23
- const DEFAULT_PRESET = "C:/Users/Me/.dsh-local-pkgs/dsh-preset-lightweight-local/cordis.patch.yml";
24
- const LOCAL_PKGS = "C:/Users/Me/.dsh-local-pkgs";
35
+ const APP_DATA = process.env.APPDATA ?? path.join(homedir(), "AppData", "Roaming");
36
+ const DEFAULT_PROFILE_PATCH = process.env.DSH_PROFILE_PATCH
37
+ ?? path.join(APP_DATA, "dsh-desktop", "harness", "profiles", "web", "cordis.patch.yml");
38
+ const DEFAULT_SETTINGS = path.join(homedir(), ".dsh-handoff-compaction", "settings.json");
39
+ const LOCAL_PKGS = process.env.DSH_LOCAL_PKGS ?? path.join(homedir(), ".dsh-local-pkgs");
40
+ const SETTINGS_FILE = process.env.DSH_HANDOFF_SETTINGS_FILE ?? DEFAULT_SETTINGS;
25
41
  const SLOT_BEGIN = "# >>> handoff:slot:begin";
26
42
  const SLOT_END = "# <<< handoff:slot:end";
43
+ const GUARD_BEGIN = "# >>> handoff:native-guard:begin";
44
+ const GUARD_END = "# <<< handoff:native-guard:end";
27
45
  const ROW_INDENT = 14;
28
46
 
29
47
  const argv = process.argv.slice(2);
30
48
  const mode = (argv.find((a) => !a.startsWith("--")) ?? "status").toLowerCase();
31
49
  const presetArg = argv.indexOf("--preset");
50
+ const profileArg = argv.indexOf("--profile-patch");
32
51
  const allLocal = argv.includes("--all-local");
52
+ const skipProfile = argv.includes("--skip-profile");
53
+ const profilePatch = profileArg >= 0
54
+ ? argv[profileArg + 1]
55
+ : (process.env.DSH_PROFILE_PATCH ?? DEFAULT_PROFILE_PATCH);
56
+
57
+ function readSettings() {
58
+ try {
59
+ const parsed = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf8"));
60
+ return parsed && typeof parsed === "object" ? parsed : undefined;
61
+ } catch {
62
+ return undefined;
63
+ }
64
+ }
65
+
66
+ function presetIdOf(text) {
67
+ const yaml = loadYaml();
68
+ if (yaml === undefined) return undefined;
69
+ try {
70
+ const doc = yaml.load(text, { schema: yamlSchema(yaml) });
71
+ return doc?.[0]?.insert?.[0]?.config?.id;
72
+ } catch {
73
+ return undefined;
74
+ }
75
+ }
76
+
77
+ /** Every editable local preset that carries the handoff slot markers. */
78
+ function discoverPresetFiles() {
79
+ if (!fs.existsSync(LOCAL_PKGS)) return [];
80
+ return fs.readdirSync(LOCAL_PKGS)
81
+ .map((name) => path.join(LOCAL_PKGS, name, "cordis.patch.yml"))
82
+ .filter((file) => fs.existsSync(file) && fs.readFileSync(file, "utf8").includes(SLOT_BEGIN));
83
+ }
33
84
 
34
- function targets() {
35
- if (allLocal) {
36
- return fs.readdirSync(LOCAL_PKGS)
37
- .map((name) => path.join(LOCAL_PKGS, name, "cordis.patch.yml"))
38
- .filter((file) => fs.existsSync(file) && fs.readFileSync(file, "utf8").includes(SLOT_BEGIN));
85
+ function defaultPresetFile() {
86
+ return discoverPresetFiles()[0]
87
+ ?? path.join(LOCAL_PKGS, "dsh-preset-lightweight-local", "cordis.patch.yml");
88
+ }
89
+
90
+ /**
91
+ * Rows to apply. `enable: true` means the plugin takes this preset; `false`
92
+ * restores its own native row. Non-selected presets are reset to native so a
93
+ * previous switch cannot leak into a preset the user did not select.
94
+ */
95
+ function selection(action) {
96
+ if (presetArg >= 0) {
97
+ return [{ file: argv[presetArg + 1], enable: action === "enable" }];
98
+ }
99
+ const files = discoverPresetFiles();
100
+ if (action === "disable") return files.map((file) => ({ file, enable: false }));
101
+ if (allLocal) return files.map((file) => ({ file, enable: true }));
102
+ const settings = readSettings();
103
+ if (settings?.scope === "global") return files.map((file) => ({ file, enable: true }));
104
+ if (settings?.scope === "preset") {
105
+ const selected = new Set(Array.isArray(settings.presets) ? settings.presets : []);
106
+ const planned = files.map((file) => {
107
+ const id = presetIdOf(fs.readFileSync(file, "utf8"));
108
+ return { file, id, enable: id !== undefined && selected.has(id) };
109
+ });
110
+ for (const id of selected) {
111
+ if (!planned.some((entry) => entry.id === id)) {
112
+ console.error(`warn: selected preset "${id}" has no editable handoff slot; left native`);
113
+ }
114
+ }
115
+ return planned;
39
116
  }
40
- return [presetArg >= 0 ? argv[presetArg + 1] : DEFAULT_PRESET];
117
+ return [{ file: defaultPresetFile(), enable: action === "enable" }];
118
+ }
119
+
120
+ function statusPlan() {
121
+ if (presetArg >= 0) return [{ file: argv[presetArg + 1] }];
122
+ const settings = readSettings();
123
+ const files = discoverPresetFiles();
124
+ if (settings?.scope === "global") return files.map((file) => ({ file, selected: true }));
125
+ if (settings?.scope === "preset") {
126
+ const selected = new Set(Array.isArray(settings.presets) ? settings.presets : []);
127
+ return files.map((file) => ({ file, selected: selected.has(presetIdOf(fs.readFileSync(file, "utf8"))) }));
128
+ }
129
+ return [{ file: defaultPresetFile() }];
130
+ }
131
+
132
+ function pluginRow(presetId) {
133
+ const text = fs.readFileSync(path.join(HERE, "row-plugin.yml"), "utf8");
134
+ const trace = path.join(homedir(), ".dsh-handoff-compaction", "trace.log").replace(/\\/g, "/");
135
+ const withId = (presetId === undefined
136
+ ? text
137
+ : text.replace(/^(\s*presetId:\s*).*$/m, `$1${presetId}`))
138
+ .replace(/^(\s*traceFile:\s*).*$/m, `$1"${trace}"`);
139
+ return indentRow(withId);
41
140
  }
42
141
 
43
142
  function indentRow(text) {
@@ -47,23 +146,43 @@ function indentRow(text) {
47
146
 
48
147
  function loadYaml() {
49
148
  try {
50
- return createRequire("C:/Users/Me/AppData/Roaming/dsh-desktop/harness/profiles/web/")("js-yaml");
149
+ return createRequire(import.meta.url)("js-yaml");
150
+ } catch {
151
+ // The engine's own dependency is preferred; a profile-installed copy is the fallback.
152
+ }
153
+ try {
154
+ const profileRoot = path.join(APP_DATA, "dsh-desktop", "harness", "profiles", "web");
155
+ return createRequire(path.join(profileRoot, "package.json"))("js-yaml");
51
156
  } catch {
52
157
  return undefined;
53
158
  }
54
159
  }
55
160
 
56
- function validate(text) {
161
+ function yamlSchema(yaml) {
162
+ const jsTag = new yaml.Type("tag:yaml.org,2002:js", { kind: "scalar", construct: (d) => d });
163
+ return yaml.DEFAULT_SCHEMA.extend([jsTag]);
164
+ }
165
+
166
+ function validatePreset(text) {
57
167
  const yaml = loadYaml();
58
168
  if (yaml === undefined) return "skipped (js-yaml unavailable)";
59
- const jsTag = new yaml.Type("tag:yaml.org,2002:js", { kind: "scalar", construct: (d) => d });
60
- const doc = yaml.load(text, { schema: yaml.DEFAULT_SCHEMA.extend([jsTag]) });
169
+ const doc = yaml.load(text, { schema: yamlSchema(yaml) });
61
170
  const group = doc[0].insert[0].config.plugins.find((entry) => entry.id === "compaction");
62
171
  const rows = group.config.filter((entry) => entry.id === "compaction-basic");
63
172
  if (rows.length !== 1) throw new Error(`expected exactly one compaction-basic row, found ${rows.length}`);
64
173
  return `ok -> ${rows[0].name}`;
65
174
  }
66
175
 
176
+ function validateProfile(text) {
177
+ const yaml = loadYaml();
178
+ if (yaml === undefined) return "skipped (js-yaml unavailable)";
179
+ const doc = yaml.load(text, { schema: yamlSchema(yaml) });
180
+ if (!Array.isArray(doc)) throw new Error("profile patch: expected a top-level array");
181
+ const row = doc.find((entry) => entry?.id === "compaction-basic" && entry?.name === "@deepseek-ai/dsh-compaction-basic");
182
+ if (row === undefined) throw new Error("profile patch: native compaction-basic entry not found");
183
+ return `ok -> ${row.name}, auto=${row.config?.auto === undefined ? "default" : String(row.config.auto)}`;
184
+ }
185
+
67
186
  function slotBounds(lines) {
68
187
  const begin = lines.findIndex((line) => line.includes(SLOT_BEGIN));
69
188
  const end = lines.findIndex((line) => line.includes(SLOT_END));
@@ -81,7 +200,148 @@ function activeName(lines) {
81
200
  return "unknown";
82
201
  }
83
202
 
84
- for (const file of targets()) {
203
+ /** Locate the top-level native compaction-basic entry that carries a name + config. */
204
+ function nativeBounds(lines) {
205
+ const starts = [];
206
+ for (let i = 0; i < lines.length; i += 1) {
207
+ if (/^-\s+id:\s*compaction-basic\s*$/.test(lines[i])) starts.push(i);
208
+ }
209
+ for (let index = 0; index < starts.length; index += 1) {
210
+ const start = starts[index];
211
+ const end = index + 1 < starts.length ? starts[index + 1] : lines.length;
212
+ const block = lines.slice(start, end).join("\n");
213
+ if (/name:\s*["']?@deepseek-ai\/dsh-compaction-basic["']?/.test(block) && /^\s{2}config:\s*$/m.test(block)) {
214
+ return { start, end };
215
+ }
216
+ }
217
+ return undefined;
218
+ }
219
+
220
+ function configLine(lines, bounds) {
221
+ for (let i = bounds.start + 1; i < bounds.end; i += 1) {
222
+ if (/^\s{2}config:\s*$/.test(lines[i])) return i;
223
+ }
224
+ return -1;
225
+ }
226
+
227
+ function guardBounds(lines, bounds) {
228
+ let begin = -1;
229
+ let end = -1;
230
+ for (let i = bounds.start; i < bounds.end; i += 1) {
231
+ if (lines[i].includes(GUARD_BEGIN)) begin = i;
232
+ if (lines[i].includes(GUARD_END)) end = i;
233
+ }
234
+ return begin >= 0 && end > begin ? { begin, end } : undefined;
235
+ }
236
+
237
+ function autoLine(lines, configIdx, bounds) {
238
+ for (let i = configIdx + 1; i < bounds.end; i += 1) {
239
+ if (/^- /.test(lines[i])) break;
240
+ if (/^\s{2}[A-Za-z]/.test(lines[i])) break;
241
+ if (/^\s{4}auto:\s*/.test(lines[i])) return i;
242
+ }
243
+ return -1;
244
+ }
245
+
246
+ function guardState(lines, bounds) {
247
+ const cfg = configLine(lines, bounds);
248
+ if (cfg < 0) return "no config";
249
+ const guard = guardBounds(lines, bounds);
250
+ if (guard !== undefined) {
251
+ const hasFalse = lines.slice(guard.begin + 1, guard.end).some((line) => /^\s*auto:\s*false\s*$/.test(line));
252
+ return hasFalse ? "auto:false (plugin guard)" : "guarded (auto missing!)";
253
+ }
254
+ const auto = autoLine(lines, cfg, bounds);
255
+ return auto < 0 ? "absent (default true)" : `auto:${lines[auto].split(":")[1].trim()}`;
256
+ }
257
+
258
+ function editProfilePatch(file, action) {
259
+ if (!fs.existsSync(file)) {
260
+ console.error(`skip (missing profile patch): ${file}`);
261
+ return;
262
+ }
263
+ const original = fs.readFileSync(file, "utf8");
264
+ const lines = original.split(/\r?\n/);
265
+ const bounds = nativeBounds(lines);
266
+ if (bounds === undefined) {
267
+ console.error(`skip (no native compaction-basic entry): ${file}`);
268
+ return;
269
+ }
270
+ const cfg = configLine(lines, bounds);
271
+ if (cfg < 0) {
272
+ console.error(`skip (native entry has no config block): ${file}`);
273
+ return;
274
+ }
275
+ const guard = guardBounds(lines, bounds);
276
+ let nextLines = [...lines];
277
+
278
+ if (action === "enable") {
279
+ if (guard !== undefined) {
280
+ const hasAuto = nextLines.slice(guard.begin + 1, guard.end).some((line) => /^\s*auto:\s*/.test(line));
281
+ nextLines = nextLines.map((line, index) => (index > guard.begin && index < guard.end && /^\s*auto:\s*/.test(line)
282
+ ? line.replace(/^(\s*)auto:\s*.*$/, "$1auto: false")
283
+ : line));
284
+ if (!hasAuto) nextLines.splice(guard.begin + 1, 0, " auto: false");
285
+ } else {
286
+ const auto = autoLine(nextLines, cfg, bounds);
287
+ if (auto >= 0) {
288
+ nextLines[auto] = nextLines[auto].replace(/^(\s*)auto:\s*.*$/, "$1auto: false");
289
+ } else {
290
+ nextLines.splice(cfg + 1, 0,
291
+ " " + GUARD_BEGIN,
292
+ " auto: false",
293
+ " " + GUARD_END);
294
+ }
295
+ }
296
+ } else {
297
+ if (guard !== undefined) {
298
+ nextLines.splice(guard.begin, guard.end - guard.begin + 1);
299
+ } else {
300
+ const auto = autoLine(nextLines, cfg, bounds);
301
+ if (auto >= 0) nextLines[auto] = nextLines[auto].replace(/^(\s*)auto:\s*.*$/, "$1auto: true");
302
+ }
303
+ }
304
+
305
+ const next = nextLines.join("\n");
306
+ if (next === original) {
307
+ console.log(`${file}\n -> native ${action === "enable" ? "auto:false" : "auto:true"} [no change]`);
308
+ return;
309
+ }
310
+ const verdict = validateProfile(next);
311
+ if (!verdict.startsWith("ok")) {
312
+ console.error(`aborted (validation): ${verdict}`);
313
+ process.exitCode = 1;
314
+ return;
315
+ }
316
+ fs.copyFileSync(file, `${file}.bak-scope`);
317
+ fs.writeFileSync(file, next);
318
+ console.log(`${file}\n -> native ${action === "enable" ? "auto:false" : "auto:true"} [${verdict}]`);
319
+ }
320
+
321
+ function profileStatus() {
322
+ if (skipProfile) return;
323
+ if (!fs.existsSync(profilePatch)) {
324
+ console.log(`${profilePatch}\n missing`);
325
+ return;
326
+ }
327
+ const original = fs.readFileSync(profilePatch, "utf8");
328
+ const lines = original.split(/\r?\n/);
329
+ const bounds = nativeBounds(lines);
330
+ if (bounds === undefined) {
331
+ console.log(`${profilePatch}\n native compaction-basic entry not found`);
332
+ return;
333
+ }
334
+ console.log(`${profilePatch}\n 原生守卫: ${guardState(lines, bounds)}\n YAML: ${validateProfile(original)}`);
335
+ }
336
+
337
+ if (mode !== "status" && mode !== "enable" && mode !== "disable") {
338
+ console.error("usage: preset-scope.mjs <status|enable|disable> [--preset <file>] [--profile-patch <file>] [--all-local] [--skip-profile]");
339
+ process.exit(2);
340
+ }
341
+
342
+ const plan = mode === "status" ? statusPlan() : selection(mode);
343
+ for (const entry of plan) {
344
+ const file = entry.file;
85
345
  if (!fs.existsSync(file)) {
86
346
  console.error(`skip (missing): ${file}`);
87
347
  continue;
@@ -96,27 +356,37 @@ for (const file of targets()) {
96
356
 
97
357
  if (mode === "status") {
98
358
  const current = activeName(lines);
99
- const kind = current === "dsh-handoff-compaction" ? "插件接管(原生关闭)"
359
+ const kind = current === "dsh-handoff-compaction" || current === "dsh-auto-compaction" ? "插件接管(原生关闭)"
100
360
  : current === "@deepseek-ai/dsh-compaction-basic" ? "DSH 原生压缩"
101
361
  : "未知";
102
- console.log(`${file}\n 当前: ${current}\n 含义: ${kind}\n YAML: ${validate(original)}`);
362
+ const selected = entry.selected === undefined ? "" : `\n 勾选: ${entry.selected ? "是(插件)" : "否(原生)"}`;
363
+ const switchLine = readSettings()?.enabled === false
364
+ ? "\n 开关: 已停用(设置页关闭,运行时压缩交回原生行为)"
365
+ : "\n 开关: 启用";
366
+ console.log(`${file}\n 当前: ${current}\n 含义: ${kind}${selected}${switchLine}\n YAML: ${validatePreset(original)}`);
103
367
  continue;
104
368
  }
105
369
 
106
- if (mode !== "enable" && mode !== "disable") {
107
- console.error("usage: preset-scope.mjs <status|enable|disable> [--preset <file>] [--all-local]");
108
- process.exit(2);
370
+ const id = presetIdOf(original);
371
+ if (entry.enable && id === undefined) {
372
+ console.error(`skip (cannot read preset id): ${file}`);
373
+ continue;
109
374
  }
110
-
111
- const rowFile = path.join(HERE, mode === "enable" ? "row-plugin.yml" : "row-native.yml");
112
- const row = indentRow(fs.readFileSync(rowFile, "utf8"));
375
+ const row = entry.enable
376
+ ? pluginRow(id)
377
+ : indentRow(fs.readFileSync(path.join(HERE, "row-native.yml"), "utf8"));
113
378
  const next = [
114
379
  ...lines.slice(0, bounds.begin + 1),
115
380
  ...row,
116
381
  ...lines.slice(bounds.end),
117
382
  ].join("\n");
118
383
 
119
- const verdict = validate(next);
384
+ if (next === original) {
385
+ console.log(`${file}\n -> ${activeName(lines)} [no change]`);
386
+ continue;
387
+ }
388
+
389
+ const verdict = validatePreset(next);
120
390
  if (!verdict.startsWith("ok")) {
121
391
  console.error(`aborted (validation): ${verdict}`);
122
392
  process.exit(1);
@@ -126,4 +396,14 @@ for (const file of targets()) {
126
396
  console.log(`${file}\n -> ${activeName(next.split(/\r?\n/))} [${verdict}]`);
127
397
  }
128
398
 
129
- if (mode === "enable" || mode === "disable") console.log("重启 DSH 后生效。");
399
+ if (mode === "status") {
400
+ profileStatus();
401
+ } else {
402
+ // The root guard only disables DSH's profile-level duplicate engine. Each
403
+ // preset's own compaction row stays native unless the plan selected it, so
404
+ // this is not "native off for every preset".
405
+ const anyPlugin = plan.some((entry) => entry.enable);
406
+ if (!skipProfile) editProfilePatch(profilePatch, mode === "enable" && anyPlugin ? "enable" : "disable");
407
+ if (mode === "enable" && !anyPlugin) console.error("warn: no selected preset uses the plugin; root native fallback left on");
408
+ console.log("重启 DSH 后生效。");
409
+ }
@@ -3,8 +3,8 @@
3
3
  config:
4
4
  thresholdRatio: 0.9
5
5
  headroomTokens: 10000
6
- maxTokens: 8192
6
+ maxTokens: 12288
7
7
  idleDelayMs: 1500
8
8
  dryRun: false
9
9
  presetId: lightweight-local
10
- traceFile: "C:/Users/Me/.dsh-handoff-work/trace.log"
10
+ traceFile: ""