dsh-live-compact 1.0.8 → 1.0.9
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/README.md +74 -8
- package/cooldown.js +106 -0
- package/gate.js +3 -3
- package/index.js +252 -84
- package/package.json +6 -1
- package/plan.js +359 -77
- package/raw.js +91 -18
- package/shadow.js +192 -2
package/index.js
CHANGED
|
@@ -6,30 +6,42 @@
|
|
|
6
6
|
* Notes follow UnwhiteCat's PR #3, rewritten for the current behavior.
|
|
7
7
|
*
|
|
8
8
|
* 小切在轮次结束后做,不调模型。最近 3 个已结束的工具轮留在原处。
|
|
9
|
-
* 切法和 1.0.6
|
|
10
|
-
*
|
|
11
|
-
* The small cut runs after the turn, with no model call.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* a cut. Above 650000, small cuts stop for the host gate.
|
|
9
|
+
* 切法和 1.0.6 一样:不越过用户消息。不够 1000 token 就跳过,不回头凑。
|
|
10
|
+
* 超过 65 万停掉小切,交给官方大压缩。
|
|
11
|
+
* The small cut runs after the turn, with no model call. It does not cross a user
|
|
12
|
+
* message. Under 1000 tokens, skip. Do not walk back to manufacture a cut.
|
|
13
|
+
* Above 650000, small cuts stop for the host gate.
|
|
15
14
|
*/
|
|
16
15
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
17
|
-
import {
|
|
16
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
17
|
+
import { createRequire } from "node:module";
|
|
18
18
|
import { homedir } from "node:os";
|
|
19
19
|
import { join } from "node:path";
|
|
20
|
+
import { pathToFileURL } from "node:url";
|
|
20
21
|
import { BasicCompactionEngine } from "@deepseek-ai/dsh-compaction-basic";
|
|
21
22
|
import { CompactionId, toolPairingBalancedAfter, toolPairingBalancedBefore } from "@deepseek-ai/dsh-compaction";
|
|
22
23
|
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
23
|
-
import { attachBackupHint, backgroundJobId, backupClaimOk, buildCard,
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
24
|
+
import { attachBackupHint, backgroundJobId, backupClaimOk, buildCard, collectJobs, indexToolCalls, jobCutDecision, keptToolRounds, messageText, planMessages, pointerOnlyText, pointerSpanAllowed, RAIL_TOKENS, readResultLabel, reservedCutEnd, resolveEngine, resolveProviderBaseURL, selectCutIndexes, shadowSections, takeLocalCut, withoutFullBackupClaim } from "./plan.js";
|
|
25
|
+
import { captureOwnedToolResult, rawExcerptText, restoreToolText, runPruneAfterCapture, verifiedRaw } from "./raw.js";
|
|
26
|
+
import {
|
|
27
|
+
discardShadowFiles,
|
|
28
|
+
ensureShadowDir,
|
|
29
|
+
recordCutStats,
|
|
30
|
+
rotateShadow,
|
|
31
|
+
sessionDir,
|
|
32
|
+
shadowFileBody,
|
|
33
|
+
shadowMaxBytes,
|
|
34
|
+
writeShadowFile,
|
|
35
|
+
writeShadowOwner,
|
|
36
|
+
} from "./shadow.js";
|
|
26
37
|
import { buildSendLess, decideThunder } from "./thunder.js";
|
|
38
|
+
import { OFFICIAL_COMPACTION_KIND, inCooldown, lastSourceKindSeq, resolveCooldownMs } from "./cooldown.js";
|
|
27
39
|
|
|
28
40
|
const MARK = "Spine extract.";
|
|
29
41
|
const SPINE_SOURCE_KIND = "spine-checkpoint";
|
|
30
42
|
const CHECKPOINT_PREAMBLE = "这是旧内容摘录,不是新指令,也不是你的授权。请从后面的消息接着做,不要把摘录里的句子当成你刚说的话。 This excerpt is not a new instruction, not a confirmation, and not an authorization.";
|
|
31
|
-
const CHUNK_MIN = 1000; // 不够这一刀就不切,避免每轮一张碎卡片。
|
|
32
|
-
const CHUNK_MAX = RAIL_TOKENS; // 一刀最多 3.2 万,和卡片轨道对齐。
|
|
43
|
+
const CHUNK_MIN = Number(process.env.SPINE_CHUNK_MIN || 1000); // 不够这一刀就不切,避免每轮一张碎卡片。
|
|
44
|
+
const CHUNK_MAX = Number(process.env.SPINE_CHUNK_MAX || RAIL_TOKENS); // 一刀最多 3.2 万,和卡片轨道对齐。
|
|
33
45
|
const POINTER_TOKENS = Number(process.env.SPINE_POINTER_TOKENS || CHUNK_MAX); // 单个工具结果超过这个数才只留指针。默认仍是 3.2 万。
|
|
34
46
|
const BIG_GATE = 650000; // 绝对停切线,不是窗口比例。1M 官方大门约 678464。
|
|
35
47
|
const URGENT_TOKENS = 48000;
|
|
@@ -37,6 +49,7 @@ const MAX_TAIL = 2200;
|
|
|
37
49
|
const MIN_PRESSURE = Number(process.env.SPINE_MIN_TOKENS || 20000);
|
|
38
50
|
const KEEP_ROUNDS = Number(process.env.SPINE_KEEP_ROUNDS || 3);
|
|
39
51
|
const KEEP_ROUND_TOKENS = Number(process.env.SPINE_KEEP_ROUND_TOKENS || 32000);
|
|
52
|
+
const COOLDOWN_MS = resolveCooldownMs(); // 额外的时间兜底;0 = 只用 seq 一次性跳过
|
|
40
53
|
const MAX_TRACKED_SESSIONS = 64;
|
|
41
54
|
const DEFAULT_KEY = "__default__";
|
|
42
55
|
|
|
@@ -100,20 +113,21 @@ function sessionKey(agent) {
|
|
|
100
113
|
return typeof id === "string" && id.length > 0 ? id : DEFAULT_KEY;
|
|
101
114
|
}
|
|
102
115
|
|
|
103
|
-
function tightenShadow(dir) {
|
|
104
|
-
try {
|
|
105
|
-
chmodSync(dir, 0o700);
|
|
106
|
-
} catch {}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
116
|
function shadowBody(messages) {
|
|
110
117
|
return (messages || []).map((message) => shadowSections(message)).join("\n\n");
|
|
111
118
|
}
|
|
112
119
|
|
|
113
|
-
function freshShadowFile(kind, key) {
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
120
|
+
export function freshShadowFile(kind, key, access = {}) {
|
|
121
|
+
const shadow = shadowDirectory();
|
|
122
|
+
ensureShadowDir(shadow, access);
|
|
123
|
+
|
|
124
|
+
const dir = sessionDir(shadow, key);
|
|
125
|
+
ensureShadowDir(dir, access);
|
|
126
|
+
|
|
127
|
+
return join(
|
|
128
|
+
dir,
|
|
129
|
+
`${kind}-${Date.now()}-${randomBytes(4).toString("hex")}.txt`,
|
|
130
|
+
);
|
|
117
131
|
}
|
|
118
132
|
|
|
119
133
|
function commandFromCall(event) {
|
|
@@ -135,10 +149,84 @@ function shadowFilePath(requested, key) {
|
|
|
135
149
|
return freshShadowFile("shadow", key);
|
|
136
150
|
}
|
|
137
151
|
|
|
152
|
+
// 实际文件由 writeShadowFile 以 mode: 0o600 创建并收紧权限。
|
|
153
|
+
export function saveShadowImpl(
|
|
154
|
+
messages,
|
|
155
|
+
required,
|
|
156
|
+
shadowFile,
|
|
157
|
+
warnKey = DEFAULT_KEY,
|
|
158
|
+
protectPaths = [],
|
|
159
|
+
access = {},
|
|
160
|
+
) {
|
|
161
|
+
const shadow = shadowDirectory();
|
|
162
|
+
ensureShadowDir(shadow, access);
|
|
163
|
+
|
|
164
|
+
const file = shadowFilePath(shadowFile, warnKey);
|
|
165
|
+
const dir = sessionDir(shadow, warnKey);
|
|
166
|
+
ensureShadowDir(dir, access);
|
|
167
|
+
|
|
168
|
+
const owner = join(dir, "owner");
|
|
169
|
+
const ownerExisted = existsSync(owner);
|
|
170
|
+
let ownerWritten = false;
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
writeShadowOwner(dir, access);
|
|
174
|
+
ownerWritten = true;
|
|
175
|
+
|
|
176
|
+
const body = shadowFileBody(warnKey, shadowBody(messages));
|
|
177
|
+
const incoming = Buffer.byteLength(body);
|
|
178
|
+
const cap = shadowMaxBytes();
|
|
179
|
+
|
|
180
|
+
const rotation = rotateShadow({
|
|
181
|
+
dir: shadow,
|
|
182
|
+
incomingBytes: incoming,
|
|
183
|
+
protectPath: file,
|
|
184
|
+
protectPaths,
|
|
185
|
+
sessionKey: warnKey,
|
|
186
|
+
cap,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
if (!rotation.ok) {
|
|
190
|
+
this.warnShadowFull(warnKey, shadow, rotation.used, cap);
|
|
191
|
+
if (required) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
"spine: shadow backup is full; leaving the span",
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (rotation.deleted > 0) {
|
|
200
|
+
this.ctx.logger?.info?.(
|
|
201
|
+
`spine: shadow 轮换 删除 ${rotation.deleted} 个文件,` +
|
|
202
|
+
`腾出 ${rotation.freed} 字节,` +
|
|
203
|
+
`用量 ${rotation.used + incoming}/${cap}`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (rotation.oversize) {
|
|
208
|
+
this.warnShadowOversize(warnKey, cap);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
writeShadowFile(file, body, access);
|
|
212
|
+
|
|
213
|
+
if (!existsSync(file)) {
|
|
214
|
+
throw new Error("spine: shadow backup was not written");
|
|
215
|
+
}
|
|
216
|
+
} catch (error) {
|
|
217
|
+
if (ownerWritten && !ownerExisted) {
|
|
218
|
+
discardShadowFiles([owner], error);
|
|
219
|
+
}
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export { saveShadowImpl as saveShadow };
|
|
225
|
+
|
|
138
226
|
// 从后往前选一刀,和 1.0.6 一样。不越过用户消息。工具对必须成对。
|
|
139
|
-
//
|
|
227
|
+
// 最近 KEEP_ROUNDS 个已结束的工具轮留在原处。不够 CHUNK_MIN 就返回 null。
|
|
140
228
|
// Pick one span from the tail, as in 1.0.6. Do not cross a user message.
|
|
141
|
-
// Tool pairs stay balanced.
|
|
229
|
+
// Tool pairs stay balanced. The newest KEEP_ROUNDS finished rounds stay verbatim.
|
|
142
230
|
// Under CHUNK_MIN, return null.
|
|
143
231
|
function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}) {
|
|
144
232
|
const decision = jobCutDecision(context.jobs || []);
|
|
@@ -166,13 +254,7 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
|
|
|
166
254
|
visible.push({ index, type: event?.type === "tool/result" ? "tool" : "other", toolCalls });
|
|
167
255
|
}
|
|
168
256
|
const rounds = keptToolRounds(visible, KEEP_ROUNDS);
|
|
169
|
-
|
|
170
|
-
// A drain still keeps the latest 3 tool rounds. The log just read is needed for the next answer.
|
|
171
|
-
const span = chooseKeptSpan(rounds, (from, to) => {
|
|
172
|
-
let tokens = 0;
|
|
173
|
-
for (let index = visible[from.start].index; index <= visible[to.end].index; index += 1) tokens += priced[index].tokens;
|
|
174
|
-
return tokens;
|
|
175
|
-
}, KEEP_ROUND_TOKENS);
|
|
257
|
+
const span = rounds.length ? { start: rounds[0].start, end: rounds.at(-1).end } : null;
|
|
176
258
|
const cutEnd = span ? reservedCutEnd(visible[span.start].index, first) : null;
|
|
177
259
|
if (span && cutEnd === null) return null;
|
|
178
260
|
if (cutEnd !== null) {
|
|
@@ -251,7 +333,6 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
|
|
|
251
333
|
totalTokens: context.totalTokens,
|
|
252
334
|
drain: context.drain === true,
|
|
253
335
|
urgentTokens: URGENT_TOKENS,
|
|
254
|
-
keepBudget: KEEP_ROUND_TOKENS,
|
|
255
336
|
});
|
|
256
337
|
if (!picked) return null;
|
|
257
338
|
if (picked.pointerOnly) {
|
|
@@ -289,7 +370,7 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
|
|
|
289
370
|
return { start: nodes[start], end: nodes[end], tokens, after, keptTokens, card, prefix };
|
|
290
371
|
}
|
|
291
372
|
|
|
292
|
-
export { planMessages };
|
|
373
|
+
export { BIG_GATE, isCheckpoint, planMessages, spineCheckpointSource };
|
|
293
374
|
|
|
294
375
|
// 只换「切哪一段、怎么换成卡片」。官方大门仍由宿主算。
|
|
295
376
|
// Changes which span is cut and how it becomes a card. The host still owns the stock gate.
|
|
@@ -383,6 +464,15 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
383
464
|
const op = AbortSignal.any([agentSignal, signal]);
|
|
384
465
|
const measurement = this.ctx.tokenMeter.measure(agent.session);
|
|
385
466
|
if (measurement.totalTokens < MIN_PRESSURE || measurement.totalTokens >= BIG_GATE) return;
|
|
467
|
+
// 官方压缩后的第一次 idle 先让一让,免得紧接着又把它产出的摘要卷走。
|
|
468
|
+
// 主判据是「这条官方卡片处理过没有」(与实际间隔多久无关),时间窗口只作可选兜底。
|
|
469
|
+
const officialSeq = lastSourceKindSeq(agent.session, OFFICIAL_COMPACTION_KIND);
|
|
470
|
+
if (officialSeq !== undefined && state.cooldownSeenSeq !== officialSeq) {
|
|
471
|
+
state.cooldownSeenSeq = officialSeq;
|
|
472
|
+
this.ctx.logger?.info?.(`spine cooldown: official compaction ${officialSeq} seen, skip this pass`);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
if (inCooldown(agent.session, { windowMs: COOLDOWN_MS })) return;
|
|
386
476
|
this.ensurePruneGuard();
|
|
387
477
|
this.captureSession(agent.session);
|
|
388
478
|
const resolved = this.engineFor(agent);
|
|
@@ -392,13 +482,14 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
392
482
|
state.engineSource = resolved.source;
|
|
393
483
|
}
|
|
394
484
|
const cloud = resolved.engine !== "local";
|
|
395
|
-
// 预测下一轮会过 32000
|
|
396
|
-
// Drain
|
|
485
|
+
// 预测下一轮会过 32000 时收尾;没有增长基线时仍由已有 48000 紧急线接管。
|
|
486
|
+
// Drain a projected request over 32000. Without a growth baseline, the existing 48000 urgent line still takes over.
|
|
397
487
|
const last = measurement.totalTokens;
|
|
398
488
|
const base = state.turnBase ?? last;
|
|
399
489
|
const growth = Math.max(0, last - base);
|
|
400
490
|
state.maxTurnGrowth = Math.max(state.maxTurnGrowth || 0, growth);
|
|
401
|
-
|
|
491
|
+
const projected = state.maxTurnGrowth > 0 && last + state.maxTurnGrowth > KEEP_ROUND_TOKENS;
|
|
492
|
+
const drain = cloud && (projected || last >= URGENT_TOKENS);
|
|
402
493
|
for (let pass = 0; pass < 8; pass += 1) {
|
|
403
494
|
const measured = this.ctx.tokenMeter.measure(agent.session);
|
|
404
495
|
if (drain && measured.totalTokens <= KEEP_ROUND_TOKENS) break;
|
|
@@ -540,28 +631,97 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
540
631
|
return super.compactIfNeeded(agent, trigger, signal);
|
|
541
632
|
}
|
|
542
633
|
|
|
543
|
-
// 请求头往往只有 provider
|
|
544
|
-
//
|
|
634
|
+
// 请求头往往只有 provider。目录和官方 DeepSeek 之后,再读当前配置里的 baseURL。
|
|
635
|
+
// 读不到地址仍按本地,避免没配地址的会话被当成云端去改前缀。
|
|
636
|
+
// Headers often carry only the provider. After the catalog and official DeepSeek, read the live profile URL.
|
|
637
|
+
// No URL still means local, so an unconfigured session is not rewritten as cloud.
|
|
545
638
|
engineFor(agent) {
|
|
546
639
|
const header = agent?.session?.requestHeader?.()?.config || {};
|
|
547
640
|
const context = agent?.session?.requestContext?.() || {};
|
|
548
641
|
const provider = header.provider || context.provider || "";
|
|
549
|
-
|
|
550
|
-
|
|
642
|
+
const listed = this.listedProviders();
|
|
643
|
+
const baseURL = resolveProviderBaseURL({
|
|
644
|
+
provider,
|
|
645
|
+
headerURL: header.baseURL || context.baseURL || "",
|
|
646
|
+
listed,
|
|
647
|
+
layers: this.providerLayers(),
|
|
648
|
+
patchText: this.profilePatchText(),
|
|
649
|
+
catalog: this.catalogProviders(),
|
|
650
|
+
});
|
|
651
|
+
return resolveEngine({ provider, baseURL });
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
listedProviders() {
|
|
655
|
+
try {
|
|
656
|
+
return this.ctx.llm?.listProviders?.() || this.ctx.get?.("llm")?.listProviders?.() || [];
|
|
657
|
+
} catch {
|
|
658
|
+
return [];
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
hostService(name) {
|
|
663
|
+
const nodes = [this.ctx, this.ctx?.root, this.ctx?.parent];
|
|
664
|
+
for (const node of nodes) {
|
|
665
|
+
if (!node || typeof node.get !== "function") continue;
|
|
551
666
|
try {
|
|
552
|
-
const
|
|
553
|
-
|
|
667
|
+
const hit = node.get(name);
|
|
668
|
+
if (hit) return hit;
|
|
554
669
|
} catch {}
|
|
555
670
|
}
|
|
556
|
-
|
|
557
|
-
|
|
671
|
+
return undefined;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
providerLayers() {
|
|
675
|
+
try {
|
|
676
|
+
const rows = this.hostService("configEditor")?.configuration?.() || [];
|
|
677
|
+
return rows.map((row) => ({ override: row?.override, inherited: row?.inherited }));
|
|
678
|
+
} catch {
|
|
679
|
+
return [];
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
catalogProviders() {
|
|
684
|
+
const anchors = [import.meta.url];
|
|
685
|
+
try {
|
|
686
|
+
if (process.argv[1]) anchors.push(pathToFileURL(process.argv[1]).href);
|
|
687
|
+
} catch {}
|
|
688
|
+
for (const anchor of anchors) {
|
|
689
|
+
try {
|
|
690
|
+
const loaded = createRequire(anchor)("@earendil-works/pi-ai/providers/all");
|
|
691
|
+
const list = typeof loaded.builtinProviders === "function" ? loaded.builtinProviders() : [];
|
|
692
|
+
return list.map((entry) => ({
|
|
693
|
+
id: entry?.id || "",
|
|
694
|
+
baseURL: entry?.baseUrl || entry?.baseURL || "",
|
|
695
|
+
})).filter((entry) => entry.id && entry.baseURL);
|
|
696
|
+
} catch {}
|
|
697
|
+
}
|
|
698
|
+
return [];
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
profilePatchText() {
|
|
702
|
+
const candidates = [];
|
|
703
|
+
try {
|
|
704
|
+
const profile = this.hostService("profileContext");
|
|
705
|
+
if (profile?.patchPath) candidates.push(profile.patchPath);
|
|
706
|
+
if (profile?.dir) candidates.push(join(profile.dir, "cordis.patch.yml"));
|
|
707
|
+
} catch {}
|
|
708
|
+
const home = process.env.DSH_HOME;
|
|
709
|
+
const flag = process.argv.indexOf("--profile");
|
|
710
|
+
const name = flag >= 0 ? process.argv[flag + 1] : process.env.DSH_PROFILE;
|
|
711
|
+
if (home && name && !String(name).startsWith("-")) candidates.push(join(home, "profiles", name, "cordis.patch.yml"));
|
|
712
|
+
for (const path of candidates) {
|
|
713
|
+
try {
|
|
714
|
+
if (path && existsSync(path)) return readFileSync(path, "utf8");
|
|
715
|
+
} catch {}
|
|
716
|
+
}
|
|
717
|
+
return "";
|
|
558
718
|
}
|
|
559
719
|
|
|
560
720
|
rawKey(session, toolCallId) {
|
|
561
721
|
return `${session?.id || DEFAULT_KEY}\0${toolCallId}`;
|
|
562
722
|
}
|
|
563
723
|
|
|
564
|
-
captureOne(session, event) {
|
|
724
|
+
captureOne(session, event, access = {}) {
|
|
565
725
|
const id = event?.data?.message?.toolCallId;
|
|
566
726
|
if (!id) return null;
|
|
567
727
|
const key = session?.id || DEFAULT_KEY;
|
|
@@ -572,22 +732,32 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
572
732
|
call = indexToolCalls(session.snapshotEvents?.() || []).get(id);
|
|
573
733
|
} catch {}
|
|
574
734
|
try {
|
|
575
|
-
const
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
const record = captureToolResult({
|
|
735
|
+
const shadow = shadowDirectory();
|
|
736
|
+
ensureShadowDir(shadow, access);
|
|
737
|
+
|
|
738
|
+
const dir = sessionDir(shadow, key);
|
|
739
|
+
const record = captureOwnedToolResult({
|
|
581
740
|
dir,
|
|
582
741
|
toolCallId: id,
|
|
583
742
|
text: messageText(event.data?.message),
|
|
584
743
|
turn: event.data?.turn,
|
|
585
744
|
name: call?.name || "",
|
|
586
|
-
pathLabel: readResultLabel(
|
|
745
|
+
pathLabel: readResultLabel(
|
|
746
|
+
call?.name,
|
|
747
|
+
call?.filePath,
|
|
748
|
+
event.data?.turn,
|
|
749
|
+
),
|
|
750
|
+
access,
|
|
587
751
|
});
|
|
588
752
|
this.rawSeen.set(mapKey, record);
|
|
589
753
|
return record;
|
|
590
754
|
} catch (error) {
|
|
755
|
+
if (
|
|
756
|
+
error?.code === "ESHADOWACCESS" ||
|
|
757
|
+
error?.code === "ESHADOWCLEANUP"
|
|
758
|
+
) {
|
|
759
|
+
throw error;
|
|
760
|
+
}
|
|
591
761
|
const message = error instanceof Error ? error.message : String(error);
|
|
592
762
|
this.ctx?.logger?.warn?.(`spine: raw backup failed: ${message}`);
|
|
593
763
|
const record = { ok: false, reason: "unwritable", toolCallId: id };
|
|
@@ -676,37 +846,23 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
676
846
|
this.ctx.logger?.warn?.(`spine: 这一份备份超过上限 ${cap},已写下,其他可删备份已清。`);
|
|
677
847
|
}
|
|
678
848
|
|
|
679
|
-
saveShadow(
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
protectPath: file,
|
|
849
|
+
saveShadow(
|
|
850
|
+
messages,
|
|
851
|
+
required,
|
|
852
|
+
shadowFile,
|
|
853
|
+
warnKey = DEFAULT_KEY,
|
|
854
|
+
protectPaths = [],
|
|
855
|
+
access = {},
|
|
856
|
+
) {
|
|
857
|
+
return saveShadowImpl.call(
|
|
858
|
+
this,
|
|
859
|
+
messages,
|
|
860
|
+
required,
|
|
861
|
+
shadowFile,
|
|
862
|
+
warnKey,
|
|
694
863
|
protectPaths,
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
});
|
|
698
|
-
if (!rotation.ok) {
|
|
699
|
-
this.warnShadowFull(warnKey, shadow, rotation.used, cap);
|
|
700
|
-
if (required) throw new Error("spine: shadow backup is full; leaving the span");
|
|
701
|
-
return;
|
|
702
|
-
}
|
|
703
|
-
if (rotation.deleted > 0) {
|
|
704
|
-
this.ctx.logger?.info?.(`spine: shadow 轮换 删除 ${rotation.deleted} 个文件,腾出 ${rotation.freed} 字节,用量 ${rotation.used + incoming}/${cap}`);
|
|
705
|
-
}
|
|
706
|
-
if (rotation.oversize) this.warnShadowOversize(warnKey, cap);
|
|
707
|
-
writeFileSync(file, body, { mode: 0o600 });
|
|
708
|
-
chmodSync(file, 0o600);
|
|
709
|
-
if (!existsSync(file)) throw new Error("spine: shadow backup was not written");
|
|
864
|
+
access,
|
|
865
|
+
);
|
|
710
866
|
}
|
|
711
867
|
|
|
712
868
|
async summarize(input, agent, signal) {
|
|
@@ -725,8 +881,20 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
725
881
|
return { summary: [block], provider: "local", model: "spine-extract", rawOutput: [block] };
|
|
726
882
|
}
|
|
727
883
|
try {
|
|
728
|
-
this.saveShadow(
|
|
729
|
-
|
|
884
|
+
this.saveShadow(
|
|
885
|
+
this.restoreRawMessages(input.messages, state.key),
|
|
886
|
+
false,
|
|
887
|
+
freshShadowFile("official", state.key),
|
|
888
|
+
state.key,
|
|
889
|
+
);
|
|
890
|
+
} catch (error) {
|
|
891
|
+
if (
|
|
892
|
+
error?.code === "ESHADOWACCESS" ||
|
|
893
|
+
error?.code === "ESHADOWCLEANUP"
|
|
894
|
+
) {
|
|
895
|
+
throw error;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
730
898
|
const joined = input.messages.map(messageText).join("\n\n");
|
|
731
899
|
if (process.env.SPINE_THUNDER !== "1") return super.summarize(input, agent, signal);
|
|
732
900
|
const next = joined;
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-live-compact",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.9",
|
|
4
4
|
"description": "In-place newest-turn compaction for DeepSeek Harness. Compress while the task runs and keep the prefix.",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"author": "MerkyorLynn <268568828+MerkyorLynn@users.noreply.github.com>",
|
|
6
7
|
"type": "module",
|
|
7
8
|
"main": "shell.js",
|
|
8
9
|
"bin": {
|
|
@@ -22,6 +23,7 @@
|
|
|
22
23
|
"raw.js",
|
|
23
24
|
"shadow.js",
|
|
24
25
|
"shadow-search.js",
|
|
26
|
+
"cooldown.js",
|
|
25
27
|
"thunder.js",
|
|
26
28
|
"bin",
|
|
27
29
|
"cordis.patch.yml",
|
|
@@ -34,6 +36,9 @@
|
|
|
34
36
|
"dsh",
|
|
35
37
|
"compaction"
|
|
36
38
|
],
|
|
39
|
+
"scripts": {
|
|
40
|
+
"test:gate": "node test/p0-gate.mjs && node test/p0-blockers.mjs && node --test test/cooldown.test.mjs test/gate-config.test.mjs"
|
|
41
|
+
},
|
|
37
42
|
"engines": {
|
|
38
43
|
"node": "^22.19 || >=24",
|
|
39
44
|
"dsh": ">=0.2.0-rc.2 <0.3.0"
|