dsh-auto-compaction 0.1.2 → 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 +280 -16
- package/lib/status.js +52 -0
- package/lib/summary.js +147 -0
- package/package.json +1 -1
- package/scripts/preset-scope.mjs +4 -1
- package/scripts/row-plugin.yml +1 -1
package/lib/index.js
CHANGED
|
@@ -22,8 +22,19 @@ 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
40
|
// Test harnesses point this at a missing file so the live sidecar cannot leak
|
|
@@ -126,6 +137,22 @@ function finishError(finish) {
|
|
|
126
137
|
}
|
|
127
138
|
}
|
|
128
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
|
+
|
|
129
156
|
export default class HandoffCompaction extends BasicCompactionEngine {
|
|
130
157
|
static inject = ["llm", "tokenMeter", "sessions"];
|
|
131
158
|
|
|
@@ -171,9 +198,10 @@ export default class HandoffCompaction extends BasicCompactionEngine {
|
|
|
171
198
|
autoCreateTemplate,
|
|
172
199
|
...base
|
|
173
200
|
} = config;
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
|
|
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 });
|
|
177
205
|
this.handoff = {
|
|
178
206
|
instruction: typeof handoffInstruction === "string" && handoffInstruction.trim().length > 0 ? handoffInstruction : undefined,
|
|
179
207
|
contextWindowOverride: contextWindowOverride ?? 0,
|
|
@@ -213,6 +241,26 @@ export default class HandoffCompaction extends BasicCompactionEngine {
|
|
|
213
241
|
return `${Math.round((this.config.thresholdRatio ?? 0.9) * 100)}%`;
|
|
214
242
|
}
|
|
215
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
|
+
|
|
216
264
|
log(message, level = "info") {
|
|
217
265
|
const line = `${new Date().toISOString()} ${LOG} ${message}`;
|
|
218
266
|
if (this.handoff?.traceConsole === true) {
|
|
@@ -248,6 +296,7 @@ export default class HandoffCompaction extends BasicCompactionEngine {
|
|
|
248
296
|
|
|
249
297
|
async checkAndHandoff(agent, why = "idle") {
|
|
250
298
|
if (this.running.has(agent)) return;
|
|
299
|
+
if (!this.handoffEnabled()) return;
|
|
251
300
|
try {
|
|
252
301
|
const session = agent.session;
|
|
253
302
|
if (!session) return;
|
|
@@ -303,24 +352,123 @@ export default class HandoffCompaction extends BasicCompactionEngine {
|
|
|
303
352
|
}
|
|
304
353
|
|
|
305
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
|
+
});
|
|
306
366
|
try {
|
|
307
367
|
const result = await this.compactNow(agent, AbortSignal.timeout(this.handoff.timeoutMs));
|
|
308
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
|
+
});
|
|
309
377
|
this.log("idle: no compactable range; nothing replaced", "warn");
|
|
310
378
|
return;
|
|
311
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
|
+
});
|
|
312
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;
|
|
313
400
|
} finally {
|
|
314
401
|
this.running.delete(agent);
|
|
315
402
|
}
|
|
316
403
|
} catch (error) {
|
|
317
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
|
+
});
|
|
318
412
|
this.log(`failed: ${message}`, "warn");
|
|
319
413
|
}
|
|
320
414
|
}
|
|
321
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
|
+
|
|
322
467
|
/** Handoff template instead of the stock compaction instruction. */
|
|
323
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);
|
|
324
472
|
const latest = agent.session.requestHeader?.()?.config;
|
|
325
473
|
const configured = this.config.summarizationProvider && this.config.summarizationModel
|
|
326
474
|
? { provider: this.config.summarizationProvider, model: this.config.summarizationModel }
|
|
@@ -346,15 +494,14 @@ export default class HandoffCompaction extends BasicCompactionEngine {
|
|
|
346
494
|
: BUILTIN_INSTRUCTION;
|
|
347
495
|
this.log(`template: ${resolved.source}${resolved.text === undefined ? " (fallback)" : ""}`);
|
|
348
496
|
|
|
349
|
-
const
|
|
350
|
-
const
|
|
497
|
+
const expected = expectedSections(instruction);
|
|
498
|
+
const baseMessages = [
|
|
351
499
|
...input.messages,
|
|
352
500
|
Object.freeze({ role: "user", content: [{ type: "text", text: instruction }] }),
|
|
353
501
|
];
|
|
354
|
-
const
|
|
502
|
+
const baseOptions = {
|
|
355
503
|
provider: target.provider,
|
|
356
504
|
model: target.model,
|
|
357
|
-
messages,
|
|
358
505
|
toolHistory: agent.session.toolHistory(),
|
|
359
506
|
...(input.tools === undefined ? {} : { tools: [...input.tools] }),
|
|
360
507
|
maxTokens: this.config.maxTokens,
|
|
@@ -363,24 +510,141 @@ export default class HandoffCompaction extends BasicCompactionEngine {
|
|
|
363
510
|
purpose: "compaction",
|
|
364
511
|
...(signal === undefined ? {} : { signal }),
|
|
365
512
|
};
|
|
513
|
+
const sessionId = agent.session?.id;
|
|
366
514
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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 = [];
|
|
370
526
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
+
});
|
|
375
618
|
|
|
376
619
|
return {
|
|
377
|
-
summary,
|
|
620
|
+
summary: [{ type: "text", text: finalText }],
|
|
378
621
|
rawOutput,
|
|
379
622
|
llmStreamCall: true,
|
|
380
623
|
provider: target.provider,
|
|
381
624
|
model: target.model,
|
|
382
625
|
maxTokens: this.config.maxTokens,
|
|
383
|
-
...(
|
|
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,
|
|
384
648
|
};
|
|
385
649
|
}
|
|
386
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
package/scripts/preset-scope.mjs
CHANGED
|
@@ -360,7 +360,10 @@ for (const entry of plan) {
|
|
|
360
360
|
: current === "@deepseek-ai/dsh-compaction-basic" ? "DSH 原生压缩"
|
|
361
361
|
: "未知";
|
|
362
362
|
const selected = entry.selected === undefined ? "" : `\n 勾选: ${entry.selected ? "是(插件)" : "否(原生)"}`;
|
|
363
|
-
|
|
363
|
+
const switchLine = readSettings()?.enabled === false
|
|
364
|
+
? "\n 开关: 已停用(设置页关闭,运行时压缩交回原生行为)"
|
|
365
|
+
: "\n 开关: 启用";
|
|
366
|
+
console.log(`${file}\n 当前: ${current}\n 含义: ${kind}${selected}${switchLine}\n YAML: ${validatePreset(original)}`);
|
|
364
367
|
continue;
|
|
365
368
|
}
|
|
366
369
|
|