dsh-live-compact 1.1.1 → 1.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/README.md +89 -34
- package/adapter.js +3 -0
- package/client.js +145 -0
- package/cooldown.js +21 -5
- package/cordis.patch.yml +5 -9
- package/gate.js +72 -45
- package/index.js +304 -130
- package/package.json +21 -6
- package/plan.js +83 -21
- package/raw.js +52 -15
- package/settings.js +136 -0
- package/shadow.js +682 -64
- package/shell.js +36 -27
package/index.js
CHANGED
|
@@ -8,34 +8,39 @@
|
|
|
8
8
|
* 小切在轮次结束后做,不调模型。最近 3 个已结束的工具轮留在原处。
|
|
9
9
|
* 切法和 1.0.6 一样:不越过用户消息。不够 1000 token 就跳过,不回头凑。
|
|
10
10
|
* 超过 65 万停掉小切,交给官方大压缩。
|
|
11
|
-
* The small cut runs after the turn, with no model call. It does not cross a user
|
|
11
|
+
* The small cut runs after the turn, with no model call. It does not cross a genuine user
|
|
12
12
|
* message. Under 1000 tokens, skip. Do not walk back to manufacture a cut.
|
|
13
13
|
* Above 650000, small cuts stop for the host gate.
|
|
14
14
|
*/
|
|
15
15
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
16
|
-
import { existsSync,
|
|
16
|
+
import { closeSync, existsSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
17
17
|
import { createRequire } from "node:module";
|
|
18
|
-
import {
|
|
19
|
-
import { join } from "node:path";
|
|
18
|
+
import { setImmediate as scheduleImmediate } from "node:timers";
|
|
19
|
+
import { dirname, join } from "node:path";
|
|
20
20
|
import { pathToFileURL } from "node:url";
|
|
21
21
|
import { loadBase, loadCompaction, loadLlm } from "./adapter.js";
|
|
22
|
+
import { DEFAULTS, engineConfig, readSettings } from "./settings.js";
|
|
22
23
|
import { attachBackupHint, backgroundJobId, backupClaimOk, buildCard, collectJobs, cutLongReadRound, finishedToolRounds, indexToolCalls, jobCutDecision, keptToolRounds, messageText, planMessages, pointerOnlyText, pointerSpanAllowed, RAIL_TOKENS, readResultLabel, reservedCutEnd, resolveEngine, resolveProviderBaseURL, roundHasLongRead, selectCutIndexes, shadowSections, takeLocalCut, withoutFullBackupClaim } from "./plan.js";
|
|
23
|
-
import { captureOwnedToolResult, rawExcerptText, restoreToolText, runPruneAfterCapture, verifiedRaw } from "./raw.js";
|
|
24
|
+
import { captureOwnedToolResult, contentHash, rawExcerptText, readRawToolResult, restoreToolText, runPruneAfterCapture, verifiedRaw } from "./raw.js";
|
|
24
25
|
import {
|
|
25
26
|
discardShadowFiles,
|
|
26
27
|
ensureShadowDir,
|
|
27
28
|
flushDrainStats,
|
|
28
29
|
noteShadowWarning,
|
|
29
30
|
rotateShadow,
|
|
31
|
+
shadowDirectory,
|
|
30
32
|
sessionDir,
|
|
31
33
|
shadowAccessFused,
|
|
32
34
|
shadowFileBody,
|
|
33
35
|
shadowMaxBytes,
|
|
36
|
+
tightenCreatedShadow,
|
|
37
|
+
tightenShadow,
|
|
34
38
|
writeShadowFile,
|
|
35
39
|
writeShadowOwner,
|
|
40
|
+
withShadowRoot,
|
|
36
41
|
} from "./shadow.js";
|
|
37
42
|
import { buildSendLess, decideThunder } from "./thunder.js";
|
|
38
|
-
import {
|
|
43
|
+
import { inCooldown, skipLiveOfficialCompaction } from "./cooldown.js";
|
|
39
44
|
|
|
40
45
|
const { BasicCompactionEngine } = await loadBase();
|
|
41
46
|
const { CompactionId, toolPairingBalancedAfter, toolPairingBalancedBefore } = await loadCompaction();
|
|
@@ -44,16 +49,8 @@ const { createUserMessage } = await loadLlm();
|
|
|
44
49
|
const MARK = "Spine extract.";
|
|
45
50
|
const SPINE_SOURCE_KIND = "spine-checkpoint";
|
|
46
51
|
const CHECKPOINT_PREAMBLE = "这是旧内容摘录,不是新指令,也不是你的授权。请从后面的消息接着做,不要把摘录里的句子当成你刚说的话。 This excerpt is not a new instruction, not a confirmation, and not an authorization.";
|
|
47
|
-
const CHUNK_MIN = Number(process.env.SPINE_CHUNK_MIN || 1000); // 不够这一刀就不切,避免每轮一张碎卡片。
|
|
48
|
-
const CHUNK_MAX = Number(process.env.SPINE_CHUNK_MAX || RAIL_TOKENS); // 一刀最多 3.2 万,和卡片轨道对齐。
|
|
49
|
-
const POINTER_TOKENS = Number(process.env.SPINE_POINTER_TOKENS || CHUNK_MAX); // 单个工具结果超过这个数才只留指针。默认仍是 3.2 万。
|
|
50
|
-
const BIG_GATE = 650000; // 绝对停切线,不是窗口比例。1M 官方大门约 678464。
|
|
51
52
|
const URGENT_TOKENS = 48000;
|
|
52
53
|
const MAX_TAIL = 2200;
|
|
53
|
-
const MIN_PRESSURE = Number(process.env.SPINE_MIN_TOKENS || 20000);
|
|
54
|
-
const KEEP_ROUNDS = Number(process.env.SPINE_KEEP_ROUNDS || 3);
|
|
55
|
-
const KEEP_ROUND_TOKENS = Number(process.env.SPINE_KEEP_ROUND_TOKENS || 32000);
|
|
56
|
-
const COOLDOWN_MS = resolveCooldownMs(); // 额外的时间兜底;0 = 只用 seq 一次性跳过
|
|
57
54
|
const MAX_TRACKED_SESSIONS = 64;
|
|
58
55
|
const DEFAULT_KEY = "__default__";
|
|
59
56
|
|
|
@@ -94,8 +91,26 @@ function eventRole(session, seq) {
|
|
|
94
91
|
return "other";
|
|
95
92
|
}
|
|
96
93
|
|
|
97
|
-
|
|
98
|
-
|
|
94
|
+
// progress-rules 0.2.1's pre-step contract, not a general notice permission.
|
|
95
|
+
// Session source metadata does not authenticate producer identity. Unknown/extra metadata
|
|
96
|
+
// stays a boundary; neither body text nor the raw role is used as a substitute.
|
|
97
|
+
function classifyUserEvent(session, seq) {
|
|
98
|
+
const event = session.eventAt(seq);
|
|
99
|
+
const data = event?.data;
|
|
100
|
+
const source = data?.source;
|
|
101
|
+
const content = data?.content;
|
|
102
|
+
const pluginNotice = event?.type === "user/message" && !isCheckpoint(session, seq)
|
|
103
|
+
&& data?.role === "user"
|
|
104
|
+
&& typeof data.id === "string" && data.id.length > 0
|
|
105
|
+
&& !Object.hasOwn(event, "source")
|
|
106
|
+
&& Object.keys(data).every((key) => ["id", "role", "content", "source"].includes(key))
|
|
107
|
+
&& source?.kind === "progress-rules" && source.form === "notice"
|
|
108
|
+
&& source.summary === "过程纪律:要求正文通报"
|
|
109
|
+
&& Object.keys(source).length === 3
|
|
110
|
+
&& Array.isArray(content) && content.length === 1
|
|
111
|
+
&& content[0]?.type === "text" && typeof content[0].text === "string"
|
|
112
|
+
&& Object.keys(content[0]).length === 2;
|
|
113
|
+
return { pluginNotice, isGenuineUserBoundary: event?.type === "user/message" && !pluginNotice };
|
|
99
114
|
}
|
|
100
115
|
|
|
101
116
|
function cutOk(fn, session, seq) {
|
|
@@ -106,19 +121,13 @@ function cutOk(fn, session, seq) {
|
|
|
106
121
|
}
|
|
107
122
|
}
|
|
108
123
|
|
|
109
|
-
function restartCaptureCursor(events, keepRounds =
|
|
124
|
+
function restartCaptureCursor(events, keepRounds = DEFAULTS.keepRounds) {
|
|
110
125
|
const results = (events || []).filter((event) => event?.type === "tool/result");
|
|
111
126
|
if (results.length <= keepRounds) return 0;
|
|
112
127
|
const boundary = results[results.length - keepRounds - 1];
|
|
113
128
|
return Number.isInteger(boundary?.seq) ? boundary.seq + 1 : 0;
|
|
114
129
|
}
|
|
115
130
|
|
|
116
|
-
function shadowDirectory() {
|
|
117
|
-
if (process.env.SPINE_SHADOW_DIR) return process.env.SPINE_SHADOW_DIR;
|
|
118
|
-
const root = process.env.DSH_HOME || join(homedir(), ".dsh");
|
|
119
|
-
return join(root, "compaction-spine", "shadow");
|
|
120
|
-
}
|
|
121
|
-
|
|
122
131
|
function sessionKey(agent) {
|
|
123
132
|
const id = agent?.session?.id;
|
|
124
133
|
return typeof id === "string" && id.length > 0 ? id : DEFAULT_KEY;
|
|
@@ -168,6 +177,7 @@ export function saveShadowImpl(
|
|
|
168
177
|
warnKey = DEFAULT_KEY,
|
|
169
178
|
protectPaths = [],
|
|
170
179
|
access = {},
|
|
180
|
+
rotationIO = {},
|
|
171
181
|
) {
|
|
172
182
|
const shadow = shadowDirectory();
|
|
173
183
|
ensureShadowDir(shadow, access);
|
|
@@ -176,75 +186,91 @@ export function saveShadowImpl(
|
|
|
176
186
|
const dir = sessionDir(shadow, warnKey);
|
|
177
187
|
ensureShadowDir(dir, access);
|
|
178
188
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
189
|
+
return withShadowRoot(shadow, () => {
|
|
190
|
+
const owner = join(dir, "owner");
|
|
191
|
+
const ownerExisted = existsSync(owner);
|
|
192
|
+
let ownerWritten = false;
|
|
182
193
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
194
|
+
try {
|
|
195
|
+
writeShadowOwner(dir, access, shadow);
|
|
196
|
+
ownerWritten = true;
|
|
186
197
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
198
|
+
const body = shadowFileBody(warnKey, shadowBody(messages));
|
|
199
|
+
const incoming = Buffer.byteLength(body);
|
|
200
|
+
const cap = shadowMaxBytes();
|
|
190
201
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
202
|
+
const rotation = rotateShadow({
|
|
203
|
+
...rotationIO,
|
|
204
|
+
dir: shadow,
|
|
205
|
+
incomingBytes: incoming,
|
|
206
|
+
protectPath: file,
|
|
207
|
+
protectPaths,
|
|
208
|
+
sessionKey: warnKey,
|
|
209
|
+
cap,
|
|
210
|
+
});
|
|
199
211
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
212
|
+
if (!rotation.ok) {
|
|
213
|
+
if (rotation.reason) {
|
|
214
|
+
const incomplete = rotation.reason === "scan-incomplete";
|
|
215
|
+
const error = Object.assign(new Error(incomplete
|
|
216
|
+
? "spine: shadow capacity scan is incomplete; backup refused; leaving the span"
|
|
217
|
+
: "spine: shadow capacity measurement is invalid; backup refused; leaving the span"), {
|
|
218
|
+
code: incomplete ? "ESHADOWSCAN" : "ESHADOWMETER",
|
|
219
|
+
});
|
|
220
|
+
if (required) throw error;
|
|
221
|
+
this.ctx.logger?.warn?.(error.message);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
this.warnShadowFull(warnKey, shadow, rotation.used, cap);
|
|
225
|
+
if (required) {
|
|
226
|
+
throw new Error(
|
|
227
|
+
"spine: shadow backup is full; leaving the span",
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
return;
|
|
206
231
|
}
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
209
232
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
233
|
+
if (rotation.deleted > 0) {
|
|
234
|
+
this.ctx.logger?.info?.(
|
|
235
|
+
`spine: shadow 轮换 删除 ${rotation.deleted} 个文件,` +
|
|
236
|
+
`腾出 ${rotation.freed} 字节,` +
|
|
237
|
+
`用量 ${rotation.used + incoming}/${cap}`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
217
240
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
241
|
+
if (rotation.oversize) {
|
|
242
|
+
this.warnShadowOversize(warnKey, cap);
|
|
243
|
+
}
|
|
221
244
|
|
|
222
|
-
|
|
245
|
+
writeShadowFile(file, body, access, { root: shadow });
|
|
223
246
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
247
|
+
if (!existsSync(file)) {
|
|
248
|
+
throw new Error("spine: shadow backup was not written");
|
|
249
|
+
}
|
|
250
|
+
} catch (error) {
|
|
251
|
+
if (ownerWritten && !ownerExisted) {
|
|
252
|
+
discardShadowFiles([owner], error);
|
|
253
|
+
}
|
|
254
|
+
throw error;
|
|
230
255
|
}
|
|
231
|
-
|
|
232
|
-
}
|
|
256
|
+
});
|
|
233
257
|
}
|
|
234
258
|
|
|
235
259
|
export { saveShadowImpl as saveShadow };
|
|
236
260
|
|
|
237
261
|
// 从后往前选一刀,和 1.0.6 一样。不越过用户消息。工具对必须成对。
|
|
238
|
-
// 最近
|
|
239
|
-
// Pick one span from the tail, as in 1.0.6. Do not cross a user message.
|
|
240
|
-
// Tool pairs stay balanced. The newest
|
|
241
|
-
// Under
|
|
262
|
+
// 最近 settings.keepRounds 个已结束的工具轮留在原处。不够 settings.chunkMin 就返回 null。
|
|
263
|
+
// Pick one span from the tail, as in 1.0.6. Do not cross a genuine user message.
|
|
264
|
+
// Tool pairs stay balanced. The newest settings.keepRounds finished rounds stay verbatim.
|
|
265
|
+
// Under settings.chunkMin, return null.
|
|
242
266
|
function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}) {
|
|
267
|
+
const settings = context.settings ?? readSettings();
|
|
243
268
|
const decision = jobCutDecision(context.jobs || []);
|
|
244
269
|
if (!decision.cut) return null;
|
|
245
270
|
const nodes = session.surface.nodes;
|
|
246
271
|
const priced = measurement.nodes;
|
|
247
272
|
if (nodes.length === 0 || nodes.length !== priced.length) return null;
|
|
273
|
+
const classifications = nodes.map((seq) => classifyUserEvent(session, seq));
|
|
248
274
|
const first = session.eventAt(nodes[0])?.type === "system/message" ? 1 : 0;
|
|
249
275
|
let end = priced.length - 1;
|
|
250
276
|
let floorCard = -1;
|
|
@@ -273,10 +299,10 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
|
|
|
273
299
|
index,
|
|
274
300
|
type: event?.type === "tool/result" ? "tool" : "other",
|
|
275
301
|
toolCalls,
|
|
276
|
-
longRead: event?.type === "tool/result" && call?.name === "read" && (priced[index].tokens || 0) >=
|
|
302
|
+
longRead: event?.type === "tool/result" && call?.name === "read" && (priced[index].tokens || 0) >= settings.chunkMin,
|
|
277
303
|
});
|
|
278
304
|
}
|
|
279
|
-
const rounds = keptToolRounds(visible,
|
|
305
|
+
const rounds = keptToolRounds(visible, settings.keepRounds);
|
|
280
306
|
const newestLong = [...finishedToolRounds(visible)].reverse().find((round) => roundHasLongRead(visible, round));
|
|
281
307
|
const span = rounds.length ? { start: rounds[0].start, end: rounds.at(-1).end } : null;
|
|
282
308
|
const cutEnd = span ? reservedCutEnd(visible[span.start].index, first) : null;
|
|
@@ -285,7 +311,7 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
|
|
|
285
311
|
const keptEnd = visible[span.end].index;
|
|
286
312
|
for (let index = visible[span.start].index; index <= keptEnd; index += 1) keptTokens += priced[index].tokens;
|
|
287
313
|
end = cutEnd;
|
|
288
|
-
while (end >= first &&
|
|
314
|
+
while (end >= first && classifications[end].isGenuineUserBoundary && !(floorCard >= 0 && end - 1 <= floorCard)) end -= 1;
|
|
289
315
|
if (floorCard >= 0 && end <= floorCard) return null;
|
|
290
316
|
if (isCheckpoint(session, nodes[end])) return null;
|
|
291
317
|
while (end >= first && !cutOk(toolPairingBalancedAfter, session, nodes[end])) {
|
|
@@ -318,6 +344,8 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
|
|
|
318
344
|
const text = excerpt.text;
|
|
319
345
|
pieces.push({
|
|
320
346
|
role: eventRole(session, nodes[index]),
|
|
347
|
+
source: event?.data?.source,
|
|
348
|
+
...classifications[index],
|
|
321
349
|
text,
|
|
322
350
|
isError: Boolean(event?.data?.message?.isError || event?.data?.isError),
|
|
323
351
|
command: call?.command || (event?.type === "tool/result" ? pendingCommand : ""),
|
|
@@ -351,6 +379,7 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
|
|
|
351
379
|
: event?.type === "system/message" ? "system" : "other";
|
|
352
380
|
return {
|
|
353
381
|
type,
|
|
382
|
+
...classifications[index],
|
|
354
383
|
tokens: priced[index].tokens,
|
|
355
384
|
checkpoint: isCheckpoint(session, seq),
|
|
356
385
|
balancedBefore: cutOk(toolPairingBalancedBefore, session, seq),
|
|
@@ -359,9 +388,9 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
|
|
|
359
388
|
});
|
|
360
389
|
const cutOptions = {
|
|
361
390
|
engine: context.engine || "cloud",
|
|
362
|
-
chunkMin:
|
|
363
|
-
chunkMax:
|
|
364
|
-
pointerMax:
|
|
391
|
+
chunkMin: settings.chunkMin,
|
|
392
|
+
chunkMax: settings.chunkMax,
|
|
393
|
+
pointerMax: settings.pointerTokens,
|
|
365
394
|
maxEnd: end,
|
|
366
395
|
totalTokens: context.totalTokens,
|
|
367
396
|
drain: context.drain === true,
|
|
@@ -418,7 +447,8 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
|
|
|
418
447
|
return { start: nodes[start], end: nodes[end], tokens, after, keptTokens, card, prefix };
|
|
419
448
|
}
|
|
420
449
|
|
|
421
|
-
export
|
|
450
|
+
export const BIG_GATE = DEFAULTS.bigGate;
|
|
451
|
+
export { isCheckpoint, planMessages, spineCheckpointSource };
|
|
422
452
|
|
|
423
453
|
// 只换「切哪一段、怎么换成卡片」。官方大门仍由宿主算。
|
|
424
454
|
// Changes which span is cut and how it becomes a card. The host still owns the stock gate.
|
|
@@ -430,23 +460,48 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
430
460
|
rawSeen = new Map();
|
|
431
461
|
callIndex = new Map();
|
|
432
462
|
captureCursor = new Map();
|
|
463
|
+
pendingCaptures = new Set();
|
|
464
|
+
pendingCaptureKeys = new Set();
|
|
433
465
|
pruneGuarded = false;
|
|
434
466
|
|
|
435
|
-
constructor(ctx, config) {
|
|
467
|
+
constructor(ctx, config, resolvedConfig) {
|
|
436
468
|
if (process.env.SPINE_ENGINE_FAIL === "construct") throw new Error("spine: constructor refused");
|
|
437
|
-
super(ctx, config);
|
|
469
|
+
super(ctx, resolvedConfig ?? engineConfig(config));
|
|
438
470
|
this.persistCapture = true;
|
|
439
471
|
if (process.env.SPINE_ENGINE_FAIL === "after-super") throw new Error("spine: after super");
|
|
472
|
+
this.liveConfig = config;
|
|
440
473
|
try {
|
|
441
474
|
const pendingCuts = new Set();
|
|
442
475
|
ctx.effect(() => async () => {
|
|
443
|
-
await Promise.allSettled(pendingCuts);
|
|
476
|
+
await Promise.allSettled([...pendingCuts, ...this.pendingCaptures]);
|
|
444
477
|
});
|
|
445
478
|
ctx.on("session/event", (session, event) => {
|
|
446
479
|
try {
|
|
447
480
|
this.noteEvent(session, event);
|
|
448
481
|
if (event?.type !== "tool/result") return;
|
|
449
|
-
|
|
482
|
+
const id = event.data?.message?.toolCallId;
|
|
483
|
+
if (!id) return;
|
|
484
|
+
const key = this.rawKey(session, id);
|
|
485
|
+
const cached = this.rawSeen.get(key);
|
|
486
|
+
if (cached && !cached.retryable) return;
|
|
487
|
+
if (this.pendingCaptureKeys.has(key)) return;
|
|
488
|
+
this.pendingCaptureKeys.add(key);
|
|
489
|
+
let job;
|
|
490
|
+
job = new Promise((resolve) => {
|
|
491
|
+
scheduleImmediate(() => {
|
|
492
|
+
try {
|
|
493
|
+
const current = this.rawSeen.get(key);
|
|
494
|
+
if (!current || current.retryable) this.captureOne(session, event);
|
|
495
|
+
} catch (error) {
|
|
496
|
+
noteShadowWarning(ctx.logger, error);
|
|
497
|
+
} finally {
|
|
498
|
+
this.pendingCaptureKeys.delete(key);
|
|
499
|
+
this.pendingCaptures.delete(job);
|
|
500
|
+
resolve();
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
this.pendingCaptures.add(job);
|
|
450
505
|
} catch (error) {
|
|
451
506
|
noteShadowWarning(ctx.logger, error);
|
|
452
507
|
}
|
|
@@ -516,26 +571,26 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
516
571
|
}
|
|
517
572
|
|
|
518
573
|
async finishTurn(agent, events) {
|
|
574
|
+
const settings = readSettings(this.liveConfig);
|
|
519
575
|
const state = this.stateFor(agent);
|
|
520
576
|
const signal = AbortSignal.timeout(30000);
|
|
521
577
|
const ev = events || agent.session.snapshotEvents();
|
|
522
|
-
this.
|
|
578
|
+
await this.flushPendingCaptures();
|
|
579
|
+
this.captureNew(agent.session, ev, settings);
|
|
523
580
|
const jobs = collectJobs(ev);
|
|
524
581
|
const calls = indexToolCalls(ev);
|
|
525
582
|
try {
|
|
526
583
|
await agent.runMaintenance(async (agentSignal) => {
|
|
527
584
|
const op = AbortSignal.any([agentSignal, signal]);
|
|
528
585
|
let measurement = this.ctx.tokenMeter.measure(agent.session);
|
|
529
|
-
if (measurement.totalTokens <
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
state.cooldownSeenSeq = officialSeq;
|
|
535
|
-
this.ctx.logger?.info?.(`spine cooldown: official compaction ${officialSeq} seen, skip this pass`);
|
|
586
|
+
if (measurement.totalTokens < settings.minTokens || measurement.totalTokens >= settings.bigGate) return;
|
|
587
|
+
// Restored cards establish a baseline; a live card still skips once,
|
|
588
|
+
// even when maintenance or engine construction is delayed.
|
|
589
|
+
if (skipLiveOfficialCompaction(agent.session)) {
|
|
590
|
+
this.ctx.logger?.info?.("spine cooldown: live official compaction seen, skip this pass");
|
|
536
591
|
return;
|
|
537
592
|
}
|
|
538
|
-
if (inCooldown(agent.session, { windowMs:
|
|
593
|
+
if (inCooldown(agent.session, { windowMs: settings.cooldownMs })) return;
|
|
539
594
|
this.ensurePruneGuard();
|
|
540
595
|
const resolved = this.engineFor(agent);
|
|
541
596
|
if (state.engine !== resolved.engine || state.engineSource !== resolved.source) {
|
|
@@ -550,13 +605,14 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
550
605
|
const base = state.turnBase ?? last;
|
|
551
606
|
const growth = Math.max(0, last - base);
|
|
552
607
|
state.maxTurnGrowth = Math.max(state.maxTurnGrowth || 0, growth);
|
|
553
|
-
const projected = state.maxTurnGrowth > 0 && last + state.maxTurnGrowth >
|
|
608
|
+
const projected = state.maxTurnGrowth > 0 && last + state.maxTurnGrowth > settings.keepRoundTokens;
|
|
554
609
|
const drain = cloud && (projected || last >= URGENT_TOKENS);
|
|
555
610
|
const drainNotes = [];
|
|
556
611
|
for (let pass = 0; pass < 8; pass += 1) {
|
|
557
612
|
const measured = measurement;
|
|
558
|
-
if (drain && measured.totalTokens <=
|
|
613
|
+
if (drain && measured.totalTokens <= settings.keepRoundTokens) break;
|
|
559
614
|
const chunk = selectNewestChunk(agent.session, measured, state.key, {
|
|
615
|
+
settings,
|
|
560
616
|
engine: resolved.engine,
|
|
561
617
|
jobs,
|
|
562
618
|
calls,
|
|
@@ -565,7 +621,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
565
621
|
drain,
|
|
566
622
|
});
|
|
567
623
|
if (chunk === null) {
|
|
568
|
-
if (drain && measured.totalTokens >
|
|
624
|
+
if (drain && measured.totalTokens > settings.keepRoundTokens) {
|
|
569
625
|
this.ctx.logger?.warn?.(`spine drain stopped at ${measured.totalTokens}; no legal span`);
|
|
570
626
|
} else {
|
|
571
627
|
this.ctx.logger?.debug?.("spine cut skipped");
|
|
@@ -622,18 +678,48 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
622
678
|
}
|
|
623
679
|
}
|
|
624
680
|
|
|
681
|
+
async flushPendingCaptures() {
|
|
682
|
+
if (!this.pendingCaptures?.size) return;
|
|
683
|
+
await Promise.allSettled([...this.pendingCaptures]);
|
|
684
|
+
}
|
|
685
|
+
|
|
625
686
|
async commitSelectedSpan(agent, chunk, signal) {
|
|
626
687
|
signal?.throwIfAborted();
|
|
627
688
|
const session = agent.session;
|
|
628
|
-
const nodes = session.surface.nodes;
|
|
629
|
-
const startIdx = nodes.indexOf(chunk.start);
|
|
630
|
-
const endIdx = nodes.indexOf(chunk.end);
|
|
631
|
-
if (startIdx < 0 || endIdx < startIdx) throw new Error("spine: selected span is not on the surface");
|
|
632
689
|
if (chunk.card?.ok !== true || !chunk.card.text) throw new Error("spine: approved card missing; leaving the span");
|
|
633
|
-
const
|
|
690
|
+
const assertCurrentSpan = (expected) => {
|
|
691
|
+
const nodes = session.surface.nodes;
|
|
692
|
+
const start = nodes.indexOf(chunk.start);
|
|
693
|
+
const end = nodes.indexOf(chunk.end);
|
|
694
|
+
if (start < 0 || end < start) throw new Error("spine: selected span is not on the surface");
|
|
695
|
+
const selected = nodes.slice(start, end + 1);
|
|
696
|
+
if (expected && (selected.length !== expected.length || selected.some((seq, i) => seq !== expected[i]))) {
|
|
697
|
+
throw new Error("spine: selected span changed before commit");
|
|
698
|
+
}
|
|
699
|
+
const items = nodes.map(seq => ({
|
|
700
|
+
type: session.eventAt(seq)?.type === "user/message" ? "user" : "other",
|
|
701
|
+
...classifyUserEvent(session, seq),
|
|
702
|
+
checkpoint: isCheckpoint(session, seq),
|
|
703
|
+
}));
|
|
704
|
+
const floor = items.findLastIndex(item => item.checkpoint);
|
|
705
|
+
if (start <= floor || !pointerSpanAllowed(items, start, end, this.engineFor(agent).engine)) {
|
|
706
|
+
throw new Error("spine: selected span violates checkpoint or user boundary");
|
|
707
|
+
}
|
|
708
|
+
if (!cutOk(toolPairingBalancedBefore, session, chunk.start) || !cutOk(toolPairingBalancedAfter, session, chunk.end)) {
|
|
709
|
+
throw new Error("spine: selected span violates tool pairing");
|
|
710
|
+
}
|
|
711
|
+
return selected;
|
|
712
|
+
};
|
|
713
|
+
const shadowedSeqs = assertCurrentSpan();
|
|
634
714
|
const messages = [];
|
|
635
715
|
for (const seq of shadowedSeqs) {
|
|
636
716
|
const event = session.eventAt(seq);
|
|
717
|
+
if (event?.type === "tool/result") {
|
|
718
|
+
const id = event.data?.message?.toolCallId;
|
|
719
|
+
if (!id || !verifiedRaw(this.rawSeen.get(this.rawKey(session, id))).ok) {
|
|
720
|
+
throw new Error(`spine: raw backup missing before cut: ${id || "unknown"}`);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
637
723
|
messages.push({
|
|
638
724
|
role: eventRole(session, seq),
|
|
639
725
|
content: eventContent(session, seq),
|
|
@@ -641,6 +727,8 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
641
727
|
});
|
|
642
728
|
}
|
|
643
729
|
const summarized = await this.summarize({ messages, liveCompact: { card: chunk.card } }, agent, signal);
|
|
730
|
+
signal?.throwIfAborted();
|
|
731
|
+
assertCurrentSpan(shadowedSeqs);
|
|
644
732
|
const compactionId = CompactionId(randomUUID());
|
|
645
733
|
const checkpointMessage = createUserMessage({
|
|
646
734
|
content: [
|
|
@@ -706,10 +794,8 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
706
794
|
return super.compactIfNeeded(agent, trigger, signal);
|
|
707
795
|
}
|
|
708
796
|
|
|
709
|
-
//
|
|
710
|
-
//
|
|
711
|
-
// Headers often carry only the provider. After the catalog and official DeepSeek, read the live profile URL.
|
|
712
|
-
// No URL still means local, so an unconfigured session is not rewritten as cloud.
|
|
797
|
+
// Preserve explicit URL precedence. Only the exact account provider gets
|
|
798
|
+
// its root settings / launch environment sources and a no-address fallback.
|
|
713
799
|
engineFor(agent) {
|
|
714
800
|
const header = agent?.session?.requestHeader?.()?.config || {};
|
|
715
801
|
const context = agent?.session?.requestContext?.() || {};
|
|
@@ -720,7 +806,9 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
720
806
|
headerURL: header.baseURL || context.baseURL || "",
|
|
721
807
|
listed,
|
|
722
808
|
layers: this.providerLayers(),
|
|
809
|
+
directory: this.configurableProviders(provider),
|
|
723
810
|
patchText: this.profilePatchText(),
|
|
811
|
+
launchURL: this.accountLaunchURL(provider),
|
|
724
812
|
catalog: this.catalogProviders(),
|
|
725
813
|
});
|
|
726
814
|
return resolveEngine({ provider, baseURL });
|
|
@@ -749,12 +837,34 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
749
837
|
providerLayers() {
|
|
750
838
|
try {
|
|
751
839
|
const rows = this.hostService("configEditor")?.configuration?.() || [];
|
|
752
|
-
return rows.map((row) => ({ override: row?.override, inherited: row?.inherited }));
|
|
840
|
+
return rows.map((row) => ({ settingsNs: row?.entry?.options?.id, override: row?.override, inherited: row?.inherited }));
|
|
841
|
+
} catch {
|
|
842
|
+
return [];
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
configurableProviders(provider) {
|
|
847
|
+
if (provider !== "deepseek-account") return [];
|
|
848
|
+
try {
|
|
849
|
+
const llm = this.ctx.llm || this.hostService("llm");
|
|
850
|
+
return llm?.listConfigurableProviders?.() || [];
|
|
753
851
|
} catch {
|
|
754
852
|
return [];
|
|
755
853
|
}
|
|
756
854
|
}
|
|
757
855
|
|
|
856
|
+
accountLaunchURL(provider) {
|
|
857
|
+
if (provider !== "deepseek-account") return "";
|
|
858
|
+
try {
|
|
859
|
+
const environment = this.hostService("launchEnvironment");
|
|
860
|
+
// Match launchEnvironmentOf: process env applies only without a snapshot.
|
|
861
|
+
return environment ? environment.get("DEEPSEEK_BASE_URL")?.value ?? "" : process.env.DEEPSEEK_BASE_URL ?? "";
|
|
862
|
+
} catch {
|
|
863
|
+
// Unreadable is not evidence of absence. Keep the decision conservative.
|
|
864
|
+
return " ";
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
758
868
|
catalogProviders() {
|
|
759
869
|
const anchors = [import.meta.url];
|
|
760
870
|
try {
|
|
@@ -816,6 +926,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
816
926
|
const dir = sessionDir(shadow, key);
|
|
817
927
|
const record = captureOwnedToolResult({
|
|
818
928
|
dir,
|
|
929
|
+
root: shadow,
|
|
819
930
|
toolCallId: id,
|
|
820
931
|
text: messageText(event.data?.message),
|
|
821
932
|
turn: event.data?.turn,
|
|
@@ -841,7 +952,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
841
952
|
}
|
|
842
953
|
const message = error instanceof Error ? error.message : String(error);
|
|
843
954
|
this.ctx?.logger?.warn?.(`spine: raw backup failed: ${message}`);
|
|
844
|
-
const record = { ok: false, reason: "unwritable", toolCallId: id };
|
|
955
|
+
const record = { ok: false, reason: "unwritable", toolCallId: id, retryable: error?.code === "ESHADOWBUSY" };
|
|
845
956
|
this.rawSeen.set(mapKey, record);
|
|
846
957
|
return record;
|
|
847
958
|
}
|
|
@@ -861,13 +972,21 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
861
972
|
}
|
|
862
973
|
}
|
|
863
974
|
|
|
864
|
-
captureNew(session, events) {
|
|
975
|
+
captureNew(session, events, settings = readSettings(this.liveConfig)) {
|
|
976
|
+
if (!Array.isArray(events)) events = [];
|
|
865
977
|
const key = session?.id || DEFAULT_KEY;
|
|
866
978
|
let cursor = this.captureCursor.has(key) ? this.captureCursor.get(key) : this.readCaptureCursor(key);
|
|
867
|
-
if (cursor == null) cursor = restartCaptureCursor(events,
|
|
868
|
-
if (!
|
|
869
|
-
|
|
979
|
+
if (cursor == null) cursor = restartCaptureCursor(events, settings.keepRounds);
|
|
980
|
+
if (!Number.isSafeInteger(cursor) || cursor < 0 || events.length < cursor) cursor = restartCaptureCursor(events, settings.keepRounds);
|
|
981
|
+
// A live event callback may have failed before the recent-three restart
|
|
982
|
+
// boundary. Keep those visible originals eligible without replaying history.
|
|
983
|
+
for (const seq of session?.surface?.nodes || []) {
|
|
984
|
+
const event = session.eventAt?.(seq);
|
|
985
|
+
const id = event?.type === "tool/result" && event.data?.message?.toolCallId;
|
|
986
|
+
if (id && this.rawSeen.get(this.rawKey(session, id))?.retryable) cursor = Math.min(cursor, seq);
|
|
987
|
+
}
|
|
870
988
|
let next = cursor;
|
|
989
|
+
let retry = null;
|
|
871
990
|
const seen = new Set();
|
|
872
991
|
for (const event of events) {
|
|
873
992
|
const seq = Number.isInteger(event?.seq) ? event.seq : next;
|
|
@@ -877,40 +996,83 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
877
996
|
const id = event.data?.message?.toolCallId;
|
|
878
997
|
if (id && !seen.has(id)) {
|
|
879
998
|
seen.add(id);
|
|
880
|
-
this.
|
|
999
|
+
const cached = this.rawSeen.get(this.rawKey(session, id));
|
|
1000
|
+
const record = cached && !cached.retryable ? cached : this.captureOne(session, event);
|
|
1001
|
+
if (record?.retryable && retry === null) retry = seq;
|
|
881
1002
|
}
|
|
882
1003
|
}
|
|
883
1004
|
next = seq + 1;
|
|
884
1005
|
}
|
|
1006
|
+
// Scanning later events is useful, but is not proof of a successful backup.
|
|
1007
|
+
// Their completed records stay cached while the first busy result is retried.
|
|
1008
|
+
next = retry ?? next;
|
|
885
1009
|
this.captureCursor.set(key, next);
|
|
886
1010
|
this.writeCaptureCursor(key, next);
|
|
1011
|
+
this.recoverVisibleRaw(session);
|
|
887
1012
|
return seen;
|
|
888
1013
|
}
|
|
889
1014
|
|
|
890
|
-
|
|
1015
|
+
captureCursorFile(key) {
|
|
1016
|
+
// Include the full key hash: sanitized directory names can alias each other.
|
|
1017
|
+
return join(sessionDir(shadowDirectory(), key), `spine-capture-${contentHash(key)}.json`);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
readCaptureCursor(key, access = {}) {
|
|
891
1021
|
if (this.persistCapture !== true) return null;
|
|
892
1022
|
try {
|
|
893
|
-
const
|
|
1023
|
+
const current = this.captureCursorFile(key);
|
|
1024
|
+
const legacy = !existsSync(current);
|
|
1025
|
+
const file = legacy ? join(shadowDirectory(), "spine-capture.json") : current;
|
|
894
1026
|
if (!existsSync(file)) return null;
|
|
1027
|
+
tightenShadow(shadowDirectory(), "dir", access);
|
|
1028
|
+
if (!legacy) tightenShadow(sessionDir(shadowDirectory(), key), "dir", access);
|
|
1029
|
+
tightenShadow(file, "file", access);
|
|
895
1030
|
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
896
|
-
const cursor = parsed?.sessions?.[key];
|
|
897
|
-
return Number.
|
|
1031
|
+
const cursor = legacy ? parsed?.sessions?.[key] : parsed?.key === key && parsed?.v === 1 ? parsed.cursor : null;
|
|
1032
|
+
return Number.isSafeInteger(cursor) && cursor >= 0 ? cursor : null;
|
|
898
1033
|
} catch {
|
|
899
1034
|
return null;
|
|
900
1035
|
}
|
|
901
1036
|
}
|
|
902
1037
|
|
|
903
|
-
writeCaptureCursor(key, cursor) {
|
|
904
|
-
if (this.persistCapture !== true) return;
|
|
1038
|
+
writeCaptureCursor(key, cursor, access = {}) {
|
|
1039
|
+
if (this.persistCapture !== true || !Number.isSafeInteger(cursor) || cursor < 0) return;
|
|
1040
|
+
let tmp;
|
|
1041
|
+
let fd;
|
|
905
1042
|
try {
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
1043
|
+
ensureShadowDir(shadowDirectory(), access);
|
|
1044
|
+
const file = this.captureCursorFile(key);
|
|
1045
|
+
// Cursor metadata is outside the payload cap and needs no rotation lock.
|
|
1046
|
+
// Secure the parent first so Windows temps inherit its private ACL.
|
|
1047
|
+
ensureShadowDir(dirname(file), access);
|
|
1048
|
+
const candidate = `${file}.${process.pid}-${randomBytes(8).toString("hex")}.tmp`;
|
|
1049
|
+
fd = openSync(candidate, "wx", 0o600);
|
|
1050
|
+
tmp = candidate;
|
|
1051
|
+
tightenCreatedShadow(tmp, fd, access, { remember: false });
|
|
1052
|
+
writeFileSync(fd, JSON.stringify({ v: 1, key, cursor }));
|
|
1053
|
+
closeSync(fd);
|
|
1054
|
+
fd = undefined;
|
|
1055
|
+
renameSync(tmp, file);
|
|
1056
|
+
} catch (error) {
|
|
1057
|
+
noteShadowWarning(this.ctx?.logger, error);
|
|
1058
|
+
} finally {
|
|
1059
|
+
if (fd !== undefined) { try { closeSync(fd); } catch {} }
|
|
1060
|
+
if (tmp) { try { unlinkSync(tmp); } catch {} }
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
recoverVisibleRaw(session, access = {}) {
|
|
1065
|
+
const dir = sessionDir(shadowDirectory(), session?.id || DEFAULT_KEY);
|
|
1066
|
+
for (const id of new Set(this.surfaceToolIds(session))) {
|
|
1067
|
+
const mapKey = this.rawKey(session, id);
|
|
1068
|
+
if (this.rawSeen.has(mapKey)) continue;
|
|
1069
|
+
try {
|
|
1070
|
+
this.rawSeen.set(mapKey, readRawToolResult({ dir, toolCallId: id, access }));
|
|
1071
|
+
} catch (error) {
|
|
1072
|
+
noteShadowWarning(this.ctx?.logger, error);
|
|
1073
|
+
this.rawSeen.set(mapKey, { ok: false, reason: "unwritable", toolCallId: id });
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
914
1076
|
}
|
|
915
1077
|
|
|
916
1078
|
captureSession(session) {
|
|
@@ -929,11 +1091,19 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
929
1091
|
}
|
|
930
1092
|
|
|
931
1093
|
rawVerifiedSet(session) {
|
|
932
|
-
const prefix = `${session?.id || DEFAULT_KEY}\0`;
|
|
933
1094
|
const ids = new Set();
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
1095
|
+
const dir = sessionDir(shadowDirectory(), session?.id || DEFAULT_KEY);
|
|
1096
|
+
for (const id of this.surfaceToolIds(session)) {
|
|
1097
|
+
const cached = this.rawSeen.get(this.rawKey(session, id));
|
|
1098
|
+
if (!cached?.ok) continue;
|
|
1099
|
+
try {
|
|
1100
|
+
// Recheck both files immediately before prune, retaining the first
|
|
1101
|
+
// trusted hash rather than blessing a replacement raw/meta pair.
|
|
1102
|
+
const stored = readRawToolResult({ dir, toolCallId: id });
|
|
1103
|
+
if (stored.ok && stored.path === cached.path && stored.sha256 === cached.sha256 && stored.bytes === cached.bytes) ids.add(id);
|
|
1104
|
+
} catch (error) {
|
|
1105
|
+
noteShadowWarning(this.ctx?.logger, error);
|
|
1106
|
+
}
|
|
937
1107
|
}
|
|
938
1108
|
return ids;
|
|
939
1109
|
}
|
|
@@ -955,8 +1125,12 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
955
1125
|
const original = pruner.pruneSession.bind(pruner);
|
|
956
1126
|
function guarded(session) {
|
|
957
1127
|
const engine = pruner.__spineEngine;
|
|
958
|
-
|
|
959
|
-
|
|
1128
|
+
try {
|
|
1129
|
+
engine.captureSession(session);
|
|
1130
|
+
return runPruneAfterCapture(engine.surfaceToolIds(session), engine.rawVerifiedSet(session), () => original(session));
|
|
1131
|
+
} catch (error) {
|
|
1132
|
+
engine.ctx.logger?.warn?.(`spine: prune skipped: ${error instanceof Error ? error.message : String(error)}`);
|
|
1133
|
+
}
|
|
960
1134
|
}
|
|
961
1135
|
guarded.__spineGuarded = true;
|
|
962
1136
|
pruner.pruneSession = guarded;
|