dsh-live-compact 1.1.4 → 1.1.6
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/CHANGELOG.md +268 -0
- package/README.md +86 -213
- package/gate.js +59 -6
- package/index.js +547 -141
- package/package.json +5 -2
- package/plan.js +68 -14
- package/raw.js +33 -11
- package/shadow-save-worker.mjs +24 -0
- package/shadow.js +210 -32
- package/shell.js +4 -2
- package/thunder.js +8 -1
package/index.js
CHANGED
|
@@ -16,31 +16,30 @@ import { randomBytes, randomUUID } from "node:crypto";
|
|
|
16
16
|
import { closeSync, existsSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
17
17
|
import { createRequire } from "node:module";
|
|
18
18
|
import { setImmediate as scheduleImmediate } from "node:timers";
|
|
19
|
+
import { Worker } from "node:worker_threads";
|
|
19
20
|
import { dirname, join } from "node:path";
|
|
20
21
|
import { pathToFileURL } from "node:url";
|
|
21
22
|
import { loadBase, loadCompaction, loadLlm } from "./adapter.js";
|
|
22
23
|
import { DEFAULTS, engineConfig, readSettings } from "./settings.js";
|
|
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";
|
|
24
|
-
import { captureOwnedToolResult, contentHash, rawExcerptText, readRawToolResult, restoreToolText, runPruneAfterCapture, verifiedRaw } from "./raw.js";
|
|
24
|
+
import { attachBackupHint, backgroundJobId, backupClaimOk, buildCard, collectJobs, cutLongReadRound, finishedToolRounds, indexToolCalls, isToolFailure, jobCutDecision, keptToolRounds, messageText, planMessages, pointerOnlyText, pointerSpanAllowed, RAIL_TOKENS, readResultLabel, reservedCutEnd, resolveEngine, resolveProviderBaseURL, roundHasLongRead, selectCutIndexes, shadowSections, takeLocalCut, withoutFullBackupClaim } from "./plan.js";
|
|
25
|
+
import { captureOwnedToolResult, contentHash, rawExcerptText, rawName, readRawToolResult, restoreToolText, runPruneAfterCapture, verifiedRaw } from "./raw.js";
|
|
25
26
|
import {
|
|
26
|
-
discardShadowFiles,
|
|
27
27
|
ensureShadowDir,
|
|
28
28
|
flushDrainStats,
|
|
29
29
|
noteShadowWarning,
|
|
30
|
-
|
|
30
|
+
saveShadowTransaction,
|
|
31
31
|
shadowDirectory,
|
|
32
|
-
sessionDir,
|
|
33
|
-
shadowAccessFused,
|
|
34
32
|
shadowFileBody,
|
|
35
33
|
shadowMaxBytes,
|
|
34
|
+
sessionDir,
|
|
35
|
+
shadowAccessFused,
|
|
36
|
+
shadowAccessFuseBuffer,
|
|
36
37
|
tightenCreatedShadow,
|
|
37
38
|
tightenShadow,
|
|
38
|
-
writeShadowFile,
|
|
39
|
-
writeShadowOwner,
|
|
40
|
-
withShadowRoot,
|
|
41
39
|
} from "./shadow.js";
|
|
42
40
|
import { buildSendLess, decideThunder } from "./thunder.js";
|
|
43
41
|
import { inCooldown, skipLiveOfficialCompaction } from "./cooldown.js";
|
|
42
|
+
import { activeCompactionPeers, warnCompactionStandDown } from "./gate.js";
|
|
44
43
|
|
|
45
44
|
const { BasicCompactionEngine } = await loadBase();
|
|
46
45
|
const { CompactionId, toolPairingBalancedAfter, toolPairingBalancedBefore } = await loadCompaction();
|
|
@@ -57,6 +56,134 @@ const CAPTURE_EVENT_BATCH = 128;
|
|
|
57
56
|
const CAPTURE_RESULT_BATCH = 1;
|
|
58
57
|
const RECOVER_RAW_BATCH = 1;
|
|
59
58
|
const CAPTURE_RETRY_LIMIT = 8;
|
|
59
|
+
const RECOVERY_ACL_REAUTH_LIMIT = 32;
|
|
60
|
+
const SHADOW_WORKER_TIMEOUT_MS = 30000;
|
|
61
|
+
|
|
62
|
+
const shadowWorkers = new Map();
|
|
63
|
+
|
|
64
|
+
function shadowWorkerFor(root) {
|
|
65
|
+
const key = String(root);
|
|
66
|
+
let entry = shadowWorkers.get(key);
|
|
67
|
+
if (entry) return entry;
|
|
68
|
+
const worker = new Worker(new URL("./shadow-save-worker.mjs", import.meta.url), {
|
|
69
|
+
workerData: { spineWorker: 1, accessFuse: shadowAccessFuseBuffer() },
|
|
70
|
+
});
|
|
71
|
+
entry = { worker, nextId: 1, pending: new Map(), idleTimer: null, retired: false };
|
|
72
|
+
const removeEntry = () => {
|
|
73
|
+
if (shadowWorkers.get(key) === entry) shadowWorkers.delete(key);
|
|
74
|
+
};
|
|
75
|
+
const scheduleIdleExit = () => {
|
|
76
|
+
if (entry.retired || entry.pending.size || entry.idleTimer) return;
|
|
77
|
+
entry.idleTimer = setTimeout(() => {
|
|
78
|
+
entry.idleTimer = null;
|
|
79
|
+
if (entry.pending.size) return;
|
|
80
|
+
entry.retired = true;
|
|
81
|
+
removeEntry();
|
|
82
|
+
worker.terminate().catch(() => {});
|
|
83
|
+
}, 500);
|
|
84
|
+
entry.idleTimer.unref?.();
|
|
85
|
+
};
|
|
86
|
+
entry.scheduleIdleExit = scheduleIdleExit;
|
|
87
|
+
const fail = (error) => {
|
|
88
|
+
entry.retired = true;
|
|
89
|
+
if (entry.idleTimer) {
|
|
90
|
+
clearTimeout(entry.idleTimer);
|
|
91
|
+
entry.idleTimer = null;
|
|
92
|
+
}
|
|
93
|
+
for (const { reject, timer } of entry.pending.values()) {
|
|
94
|
+
if (timer) clearTimeout(timer);
|
|
95
|
+
reject(error);
|
|
96
|
+
}
|
|
97
|
+
entry.pending.clear();
|
|
98
|
+
removeEntry();
|
|
99
|
+
worker.terminate().catch(() => {});
|
|
100
|
+
};
|
|
101
|
+
worker.on("message", (message) => {
|
|
102
|
+
const task = entry.pending.get(message?.id);
|
|
103
|
+
if (!task) return;
|
|
104
|
+
if (task.timer) clearTimeout(task.timer);
|
|
105
|
+
entry.pending.delete(message.id);
|
|
106
|
+
if (message?.ok) {
|
|
107
|
+
task.resolve(message.result);
|
|
108
|
+
scheduleIdleExit();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const error = new Error(message?.error?.message || "spine: shadow worker failed");
|
|
112
|
+
if (message?.error?.code) error.code = message.error.code;
|
|
113
|
+
task.reject(error);
|
|
114
|
+
scheduleIdleExit();
|
|
115
|
+
});
|
|
116
|
+
worker.on("error", (error) => fail(error));
|
|
117
|
+
worker.on("exit", (code) => {
|
|
118
|
+
if (entry.pending.size || (!entry.retired && code !== 0)) {
|
|
119
|
+
fail(Object.assign(new Error(`spine: shadow worker exited ${code}`), { code: "ESHADOWWORKER" }));
|
|
120
|
+
} else {
|
|
121
|
+
entry.retired = true;
|
|
122
|
+
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
|
123
|
+
entry.idleTimer = null;
|
|
124
|
+
removeEntry();
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
shadowWorkers.set(key, entry);
|
|
128
|
+
return entry;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function runShadowWorker(payload) {
|
|
132
|
+
const entry = shadowWorkerFor(payload.shadow);
|
|
133
|
+
if (entry.idleTimer) {
|
|
134
|
+
clearTimeout(entry.idleTimer);
|
|
135
|
+
entry.idleTimer = null;
|
|
136
|
+
}
|
|
137
|
+
const id = entry.nextId++;
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
const timer = setTimeout(() => {
|
|
140
|
+
const error = Object.assign(new Error("spine: shadow worker timed out"), { code: "ESHADOWWORKER" });
|
|
141
|
+
failShadowWorker(entry, error);
|
|
142
|
+
}, SHADOW_WORKER_TIMEOUT_MS);
|
|
143
|
+
timer.unref?.();
|
|
144
|
+
entry.pending.set(id, { resolve, reject, timer });
|
|
145
|
+
try {
|
|
146
|
+
entry.worker.postMessage({ id, payload });
|
|
147
|
+
} catch (error) {
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
entry.pending.delete(id);
|
|
150
|
+
reject(error);
|
|
151
|
+
entry.scheduleIdleExit();
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function failShadowWorker(entry, error) {
|
|
157
|
+
if (!entry || entry.retired) return;
|
|
158
|
+
entry.retired = true;
|
|
159
|
+
if (entry.idleTimer) {
|
|
160
|
+
clearTimeout(entry.idleTimer);
|
|
161
|
+
entry.idleTimer = null;
|
|
162
|
+
}
|
|
163
|
+
for (const { reject, timer } of entry.pending.values()) {
|
|
164
|
+
if (timer) clearTimeout(timer);
|
|
165
|
+
reject(error);
|
|
166
|
+
}
|
|
167
|
+
entry.pending.clear();
|
|
168
|
+
for (const [key, value] of shadowWorkers) {
|
|
169
|
+
if (value === entry) shadowWorkers.delete(key);
|
|
170
|
+
}
|
|
171
|
+
entry.worker.terminate().catch(() => {});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function recoveryRetryable(record, dir, toolCallId) {
|
|
175
|
+
if (record?.retryable === true || record?.reason === "unwritable") return true;
|
|
176
|
+
// A completely absent backup has never been captured and can be retried.
|
|
177
|
+
// If its metadata is still present, the body was damaged after capture and
|
|
178
|
+
// must remain fail-closed rather than being silently overwritten.
|
|
179
|
+
if (record?.reason === "missing-file") return !existsSync(join(dir, `${rawName(toolCallId)}.meta.json`));
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function captureErrorRetryable(error) {
|
|
184
|
+
const code = error?.code;
|
|
185
|
+
return code === "ESHADOWBUSY" || ["EAGAIN", "EBUSY", "EINTR", "EIO"].includes(code);
|
|
186
|
+
}
|
|
60
187
|
|
|
61
188
|
function eventContent(session, seq) {
|
|
62
189
|
const event = session.eventAt(seq);
|
|
@@ -78,7 +205,27 @@ function isCheckpoint(session, seq) {
|
|
|
78
205
|
// 自己的卡片,以及已经写过的官方标记,都要停住。新卡片不再用官方标记。
|
|
79
206
|
// Stop on our own cards and on older official markers. New cards do not use the official marker.
|
|
80
207
|
if (kind === SPINE_SOURCE_KIND || kind === "compact-checkpoint") return true;
|
|
81
|
-
|
|
208
|
+
// The legacy text marker is only meaningful on a user/message card and only
|
|
209
|
+
// at the beginning of its text. A tool result or an ordinary sentence that
|
|
210
|
+
// merely mentions the marker must not create a permanent cut boundary.
|
|
211
|
+
if (event?.type !== "user/message") return false;
|
|
212
|
+
const text = eventText(session, seq).trimStart();
|
|
213
|
+
return text === MARK || text.startsWith(`${MARK}\n`) || text.startsWith(`${MARK}\r\n`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function latestTurnCompleted(events) {
|
|
217
|
+
let lastStart = -1;
|
|
218
|
+
let lastEnd = -1;
|
|
219
|
+
let reason;
|
|
220
|
+
for (let index = 0; index < (events || []).length; index += 1) {
|
|
221
|
+
const event = events[index];
|
|
222
|
+
if (event?.type === "turn/start") lastStart = index;
|
|
223
|
+
if (event?.type === "turn/end") {
|
|
224
|
+
lastEnd = index;
|
|
225
|
+
reason = event.data?.reason?.kind;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return lastEnd > lastStart && reason === "completed";
|
|
82
229
|
}
|
|
83
230
|
|
|
84
231
|
function spineCheckpointSource(compactionId) {
|
|
@@ -184,80 +331,41 @@ export function saveShadowImpl(
|
|
|
184
331
|
rotationIO = {},
|
|
185
332
|
) {
|
|
186
333
|
const shadow = shadowDirectory();
|
|
187
|
-
ensureShadowDir(shadow, access);
|
|
188
|
-
|
|
189
334
|
const file = shadowFilePath(shadowFile, warnKey);
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const body = shadowFileBody(warnKey, shadowBody(messages));
|
|
203
|
-
const incoming = Buffer.byteLength(body);
|
|
204
|
-
const cap = shadowMaxBytes();
|
|
205
|
-
|
|
206
|
-
const rotation = rotateShadow({
|
|
207
|
-
...rotationIO,
|
|
208
|
-
dir: shadow,
|
|
209
|
-
incomingBytes: incoming,
|
|
210
|
-
protectPath: file,
|
|
211
|
-
protectPaths,
|
|
212
|
-
sessionKey: warnKey,
|
|
213
|
-
cap,
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
if (!rotation.ok) {
|
|
217
|
-
if (rotation.reason) {
|
|
218
|
-
const incomplete = rotation.reason === "scan-incomplete";
|
|
219
|
-
const error = Object.assign(new Error(incomplete
|
|
220
|
-
? "spine: shadow capacity scan is incomplete; backup refused; leaving the span"
|
|
221
|
-
: "spine: shadow capacity measurement is invalid; backup refused; leaving the span"), {
|
|
222
|
-
code: incomplete ? "ESHADOWSCAN" : "ESHADOWMETER",
|
|
223
|
-
});
|
|
224
|
-
if (required) throw error;
|
|
225
|
-
this.ctx.logger?.warn?.(error.message);
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
this.warnShadowFull(warnKey, shadow, rotation.used, cap);
|
|
229
|
-
if (required) {
|
|
230
|
-
throw new Error(
|
|
231
|
-
"spine: shadow backup is full; leaving the span",
|
|
232
|
-
);
|
|
233
|
-
}
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
if (rotation.deleted > 0) {
|
|
238
|
-
this.ctx.logger?.info?.(
|
|
239
|
-
`spine: shadow 轮换 删除 ${rotation.deleted} 个文件,` +
|
|
240
|
-
`腾出 ${rotation.freed} 字节,` +
|
|
241
|
-
`用量 ${rotation.used + incoming}/${cap}`,
|
|
242
|
-
);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
if (rotation.oversize) {
|
|
246
|
-
this.warnShadowOversize(warnKey, cap);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
writeShadowFile(file, body, access, { root: shadow });
|
|
250
|
-
|
|
251
|
-
if (!existsSync(file)) {
|
|
252
|
-
throw new Error("spine: shadow backup was not written");
|
|
253
|
-
}
|
|
254
|
-
} catch (error) {
|
|
255
|
-
if (ownerWritten && !ownerExisted) {
|
|
256
|
-
discardShadowFiles([owner], error);
|
|
257
|
-
}
|
|
258
|
-
throw error;
|
|
259
|
-
}
|
|
335
|
+
const body = shadowFileBody(warnKey, shadowBody(messages));
|
|
336
|
+
const cap = shadowMaxBytes();
|
|
337
|
+
const result = saveShadowTransaction({
|
|
338
|
+
shadow,
|
|
339
|
+
file,
|
|
340
|
+
body,
|
|
341
|
+
required,
|
|
342
|
+
protectPaths,
|
|
343
|
+
sessionKey: warnKey,
|
|
344
|
+
cap,
|
|
345
|
+
access,
|
|
346
|
+
rotationIO,
|
|
260
347
|
});
|
|
348
|
+
if (!result?.ok) {
|
|
349
|
+
if (result?.reason === "scan-incomplete" || result?.reason === "invalid-measurement") {
|
|
350
|
+
const error = result.reason === "scan-incomplete"
|
|
351
|
+
? "spine: shadow capacity scan is incomplete; backup refused; leaving the span"
|
|
352
|
+
: "spine: shadow capacity measurement is invalid; backup refused; leaving the span";
|
|
353
|
+
this.ctx.logger?.warn?.(error);
|
|
354
|
+
} else if (result?.rotation) {
|
|
355
|
+
this.warnShadowFull(warnKey, shadow, result.rotation.used, cap);
|
|
356
|
+
}
|
|
357
|
+
return result;
|
|
358
|
+
}
|
|
359
|
+
const rotation = result.rotation;
|
|
360
|
+
if (rotation.deleted > 0) {
|
|
361
|
+
this.ctx.logger?.info?.(
|
|
362
|
+
`spine: shadow 轮换 删除 ${rotation.deleted} 个文件,` +
|
|
363
|
+
`腾出 ${rotation.freed} 字节,` +
|
|
364
|
+
`用量 ${rotation.used + Buffer.byteLength(body)}/${cap}`,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
if (rotation.oversize) this.warnShadowOversize(warnKey, cap);
|
|
368
|
+
return result;
|
|
261
369
|
}
|
|
262
370
|
|
|
263
371
|
export { saveShadowImpl as saveShadow };
|
|
@@ -304,16 +412,16 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
|
|
|
304
412
|
type: event?.type === "tool/result" ? "tool" : "other",
|
|
305
413
|
toolCalls,
|
|
306
414
|
longRead: event?.type === "tool/result" && call?.name === "read" && (priced[index].tokens || 0) >= settings.chunkMin,
|
|
415
|
+
checkpoint: isCheckpoint(session, nodes[index]),
|
|
307
416
|
});
|
|
308
417
|
}
|
|
309
|
-
const
|
|
418
|
+
const floorVisible = visible.reduce((found, item, index) => item.checkpoint ? index : found, -1);
|
|
419
|
+
const rounds = keptToolRounds(visible, settings.keepRounds, floorVisible);
|
|
310
420
|
const newestLong = [...finishedToolRounds(visible)].reverse().find((round) => roundHasLongRead(visible, round));
|
|
311
421
|
const span = rounds.length ? { start: rounds[0].start, end: rounds.at(-1).end } : null;
|
|
312
422
|
const cutEnd = span ? reservedCutEnd(visible[span.start].index, first) : null;
|
|
313
423
|
if (span && cutEnd === null) return null;
|
|
314
424
|
if (cutEnd !== null) {
|
|
315
|
-
const keptEnd = visible[span.end].index;
|
|
316
|
-
for (let index = visible[span.start].index; index <= keptEnd; index += 1) keptTokens += priced[index].tokens;
|
|
317
425
|
end = cutEnd;
|
|
318
426
|
while (end >= first && classifications[end].isGenuineUserBoundary && !(floorCard >= 0 && end - 1 <= floorCard)) end -= 1;
|
|
319
427
|
if (floorCard >= 0 && end <= floorCard) return null;
|
|
@@ -365,7 +473,6 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
|
|
|
365
473
|
}
|
|
366
474
|
return pieces;
|
|
367
475
|
};
|
|
368
|
-
const spanText = (spanStart, spanEnd) => spanPieces(spanStart, spanEnd).map((piece) => piece.text).join("\n");
|
|
369
476
|
const liveText = (spanStart, spanEnd) => {
|
|
370
477
|
const bits = [];
|
|
371
478
|
for (let index = 0; index < nodes.length; index += 1) {
|
|
@@ -402,6 +509,9 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
|
|
|
402
509
|
};
|
|
403
510
|
let usedLongCut = false;
|
|
404
511
|
let picked = null;
|
|
512
|
+
const fallbackWithoutLongRead = () => context.disableLongRead
|
|
513
|
+
? null
|
|
514
|
+
: selectNewestChunk(session, measurement, key, { ...context, disableLongRead: true });
|
|
405
515
|
if (longSpan) {
|
|
406
516
|
const longPicked = cutLongReadRound(items, longSpan.start, longSpan.end, cutOptions);
|
|
407
517
|
if (longPicked && longPicked.start >= longSpan.start && longPicked.end <= longSpan.end) {
|
|
@@ -427,25 +537,39 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
|
|
|
427
537
|
}
|
|
428
538
|
}
|
|
429
539
|
}
|
|
430
|
-
if (pairStart < 0) return null;
|
|
431
|
-
if (usedLongCut && pairStart < longSpan.start) return
|
|
432
|
-
if (!pointerSpanAllowed(items, pairStart, picked.end, context.engine || "cloud")) return null;
|
|
433
|
-
if (!cutOk(toolPairingBalancedBefore, session, nodes[pairStart])) return null;
|
|
540
|
+
if (pairStart < 0) return usedLongCut ? fallbackWithoutLongRead() : null;
|
|
541
|
+
if (usedLongCut && pairStart < longSpan.start) return fallbackWithoutLongRead();
|
|
542
|
+
if (!pointerSpanAllowed(items, pairStart, picked.end, context.engine || "cloud")) return usedLongCut ? fallbackWithoutLongRead() : null;
|
|
543
|
+
if (!cutOk(toolPairingBalancedBefore, session, nodes[pairStart])) return usedLongCut ? fallbackWithoutLongRead() : null;
|
|
434
544
|
picked.start = pairStart;
|
|
435
545
|
}
|
|
436
|
-
if (usedLongCut && (picked.start < longSpan.start || picked.end > longSpan.end)) return
|
|
546
|
+
if (usedLongCut && (picked.start < longSpan.start || picked.end > longSpan.end)) return fallbackWithoutLongRead();
|
|
437
547
|
const start = picked.start;
|
|
438
548
|
end = picked.end;
|
|
439
549
|
const tokens = items.slice(start, end + 1).reduce((sum, item) => sum + item.tokens, 0);
|
|
550
|
+
const pieces = spanPieces(start, end);
|
|
551
|
+
if (picked.pointerOnly && pieces.some((piece) => piece.role === "tool" && isToolFailure(piece.text, piece.isError))) {
|
|
552
|
+
return usedLongCut ? fallbackWithoutLongRead() : null;
|
|
553
|
+
}
|
|
440
554
|
let card = picked.pointerOnly
|
|
441
|
-
? { ok: true, text: pointerOnlyText(
|
|
442
|
-
: buildCard(
|
|
443
|
-
if (card.ok !== true) return null;
|
|
555
|
+
? { ok: true, text: pointerOnlyText(pieces), hardLines: [], missing: [], needsBackup: false, rawPaths: [] }
|
|
556
|
+
: buildCard(pieces.map((piece) => piece.text).join("\n"), liveText(start, end), pieces, extras);
|
|
557
|
+
if (card.ok !== true) return usedLongCut ? fallbackWithoutLongRead() : null;
|
|
444
558
|
card = attachBackupHint(card, freshShadowFile("cut", key));
|
|
445
559
|
if (!card.text.includes("不是备份文件路径")) card = { ...card, text: `${card.text}\n序号是本会话日志里的事件号,不是备份文件路径。` };
|
|
446
560
|
if (!cutOk(toolPairingBalancedAfter, session, nodes[end])) return null;
|
|
447
561
|
let after = 0;
|
|
448
562
|
for (let index = end + 1; index < priced.length; index += 1) after += priced[index].tokens;
|
|
563
|
+
// `keptTokens` must describe retained rounds to the right of the actual
|
|
564
|
+
// cut. A long-read cut can be newer than the ordinary rounds selected above;
|
|
565
|
+
// counting those older rounds made `tailBeyondKept` negative and disabled
|
|
566
|
+
// the tail guard.
|
|
567
|
+
for (const round of rounds) {
|
|
568
|
+
const roundStart = visible[round.start]?.index;
|
|
569
|
+
const roundEnd = visible[round.end]?.index;
|
|
570
|
+
if (!Number.isInteger(roundStart) || !Number.isInteger(roundEnd) || roundStart <= end) continue;
|
|
571
|
+
for (let index = roundStart; index <= roundEnd; index += 1) keptTokens += priced[index].tokens;
|
|
572
|
+
}
|
|
449
573
|
let prefix = 0;
|
|
450
574
|
for (let index = 0; index < start; index += 1) prefix += priced[index].tokens;
|
|
451
575
|
return { start: nodes[start], end: nodes[end], tokens, after, keptTokens, card, prefix };
|
|
@@ -473,6 +597,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
473
597
|
captureRetryStats = new Map();
|
|
474
598
|
retryableCaptureSeqs = new Map();
|
|
475
599
|
retryableCaptureSeeded = new Set();
|
|
600
|
+
captureRecoveryHolds = new Map();
|
|
476
601
|
pruneGuarded = false;
|
|
477
602
|
|
|
478
603
|
constructor(ctx, config, resolvedConfig) {
|
|
@@ -487,6 +612,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
487
612
|
});
|
|
488
613
|
ctx.on("session/event", (session, event) => {
|
|
489
614
|
try {
|
|
615
|
+
if (this.standDown?.()) return;
|
|
490
616
|
this.noteEvent(session, event);
|
|
491
617
|
if (event?.type !== "tool/result") return;
|
|
492
618
|
const id = event.data?.message?.toolCallId;
|
|
@@ -500,6 +626,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
500
626
|
job = new Promise((resolve) => {
|
|
501
627
|
scheduleImmediate(() => {
|
|
502
628
|
try {
|
|
629
|
+
if (this.standDown?.()) return;
|
|
503
630
|
const current = this.rawSeen.get(key);
|
|
504
631
|
if (!current || current.retryable) {
|
|
505
632
|
const record = this.captureOne(session, event);
|
|
@@ -523,10 +650,11 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
523
650
|
});
|
|
524
651
|
ctx.on("agent/status", ({ agent, status }) => {
|
|
525
652
|
if (status !== "idle") return;
|
|
653
|
+
if (this.standDown?.()) return;
|
|
526
654
|
const state = this.stateFor(agent);
|
|
527
655
|
if (state.warming) return;
|
|
528
656
|
const events = agent.session.snapshotEvents();
|
|
529
|
-
const done = events
|
|
657
|
+
const done = latestTurnCompleted(events);
|
|
530
658
|
if (!done) return;
|
|
531
659
|
state.warming = true;
|
|
532
660
|
const job = this.finishTurn(agent, events, { deferCapture: true })
|
|
@@ -543,6 +671,24 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
543
671
|
}
|
|
544
672
|
}
|
|
545
673
|
|
|
674
|
+
standDown() {
|
|
675
|
+
const peers = activeCompactionPeers(this.ctx);
|
|
676
|
+
if (peers.length) warnCompactionStandDown(this.ctx);
|
|
677
|
+
return peers.length > 0;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
ownedSignal(signal) {
|
|
681
|
+
const controller = new AbortController();
|
|
682
|
+
const dispose = this.ctx.on("internal/status", () => {
|
|
683
|
+
if (this.standDown?.()) controller.abort();
|
|
684
|
+
}, { global: true });
|
|
685
|
+
if (this.standDown?.()) controller.abort();
|
|
686
|
+
return {
|
|
687
|
+
signal: signal ? AbortSignal.any([signal, controller.signal]) : controller.signal,
|
|
688
|
+
dispose,
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
546
692
|
stateFor(agent) {
|
|
547
693
|
const key = sessionKey(agent);
|
|
548
694
|
let state = this.sessionState.get(key);
|
|
@@ -585,7 +731,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
585
731
|
return planMessages(messages);
|
|
586
732
|
}
|
|
587
733
|
|
|
588
|
-
scheduleCaptureCatchup(agent, events, settings) {
|
|
734
|
+
scheduleCaptureCatchup(agent, events, settings, options = {}) {
|
|
589
735
|
this.pendingCatchups ||= new Map();
|
|
590
736
|
this.pendingCatchupJobs ||= new Set();
|
|
591
737
|
this.pendingCuts ||= new Set();
|
|
@@ -596,6 +742,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
596
742
|
task.session = agent.session;
|
|
597
743
|
task.events = events;
|
|
598
744
|
task.settings = settings;
|
|
745
|
+
task.freezeSnapshot ||= options.freezeSnapshot === true;
|
|
599
746
|
return task;
|
|
600
747
|
}
|
|
601
748
|
let resolve;
|
|
@@ -606,16 +753,17 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
606
753
|
session: agent.session,
|
|
607
754
|
events,
|
|
608
755
|
settings,
|
|
609
|
-
phase: "
|
|
756
|
+
phase: "previsible",
|
|
610
757
|
eventIndex: 0,
|
|
611
758
|
recoverIndex: 0,
|
|
612
|
-
visibleIds:
|
|
759
|
+
visibleIds: [...new Set(this.surfaceToolIds(agent.session))],
|
|
613
760
|
scheduled: false,
|
|
614
761
|
retryRequested: false,
|
|
615
762
|
captureRetryAttempts: 0,
|
|
616
763
|
progressed: false,
|
|
617
764
|
eventTail: Number.isInteger(events?.at?.(-1)?.seq) ? events.at(-1).seq : -1,
|
|
618
765
|
surfaceTail: agent.session.surface?.nodes?.at?.(-1) ?? -1,
|
|
766
|
+
freezeSnapshot: options.freezeSnapshot === true,
|
|
619
767
|
resolve,
|
|
620
768
|
promise,
|
|
621
769
|
};
|
|
@@ -641,13 +789,17 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
641
789
|
scheduleImmediate(() => {
|
|
642
790
|
task.scheduled = false;
|
|
643
791
|
try {
|
|
644
|
-
|
|
792
|
+
if (this.standDown?.()) {
|
|
793
|
+
this.finishCaptureCatchup(task);
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
const latest = task.freezeSnapshot ? null : task.session.snapshotEvents?.();
|
|
645
797
|
const latestTail = Number.isInteger(latest?.at?.(-1)?.seq) ? latest.at(-1).seq : -1;
|
|
646
798
|
const latestSurfaceTail = task.session.surface?.nodes?.at?.(-1) ?? -1;
|
|
647
799
|
if (Array.isArray(latest) && (latestTail !== task.eventTail || latest.length !== task.events.length)) {
|
|
648
800
|
task.events = latest;
|
|
649
801
|
task.eventTail = latestTail;
|
|
650
|
-
if (task.phase === "visible") {
|
|
802
|
+
if (task.phase === "previsible" || task.phase === "visible") {
|
|
651
803
|
task.phase = "events";
|
|
652
804
|
task.visibleIds = null;
|
|
653
805
|
task.recoverIndex = 0;
|
|
@@ -655,11 +807,22 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
655
807
|
}
|
|
656
808
|
if (latestSurfaceTail !== task.surfaceTail) {
|
|
657
809
|
task.surfaceTail = latestSurfaceTail;
|
|
658
|
-
if (task.phase === "visible") {
|
|
810
|
+
if (task.phase === "previsible" || task.phase === "visible") {
|
|
659
811
|
task.visibleIds = [...new Set(this.surfaceToolIds(task.session))];
|
|
660
812
|
task.recoverIndex = 0;
|
|
661
813
|
}
|
|
662
814
|
}
|
|
815
|
+
if (task.phase === "previsible") {
|
|
816
|
+
const recovered = this.recoverVisibleRawBatch(task.session, {}, task.recoverIndex, RECOVER_RAW_BATCH, task.visibleIds);
|
|
817
|
+
task.progressed ||= recovered.progressed;
|
|
818
|
+
task.recoverIndex = recovered.nextIndex;
|
|
819
|
+
if (!recovered.done) {
|
|
820
|
+
this.queueCaptureCatchup(task);
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
task.phase = "events";
|
|
824
|
+
task.eventIndex = 0;
|
|
825
|
+
}
|
|
663
826
|
if (task.phase === "events") {
|
|
664
827
|
const result = this.captureNewBatch(task.session, task.events, task.settings, {
|
|
665
828
|
eventLimit: CAPTURE_EVENT_BATCH,
|
|
@@ -709,18 +872,20 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
709
872
|
this.pendingCatchups.delete(task.key);
|
|
710
873
|
this.pendingCatchupJobs.delete(task.promise);
|
|
711
874
|
task.resolve();
|
|
712
|
-
if (task.retryRequested && task.progressed) this.scheduleMaintenanceRetry(task.agent);
|
|
875
|
+
if (!this.standDown?.() && task.retryRequested && task.progressed) this.scheduleMaintenanceRetry(task.agent);
|
|
713
876
|
}
|
|
714
877
|
|
|
715
878
|
scheduleMaintenanceRetry(agent) {
|
|
879
|
+
if (this.standDown?.()) return;
|
|
716
880
|
const state = this.stateFor(agent);
|
|
717
881
|
if (state.retryScheduled) return;
|
|
718
882
|
state.retryScheduled = true;
|
|
719
883
|
scheduleImmediate(() => {
|
|
720
884
|
state.retryScheduled = false;
|
|
885
|
+
if (this.standDown?.()) return;
|
|
721
886
|
if (state.warming) return;
|
|
722
887
|
const events = agent.session.snapshotEvents();
|
|
723
|
-
const done = events
|
|
888
|
+
const done = latestTurnCompleted(events);
|
|
724
889
|
if (!done) return;
|
|
725
890
|
state.warming = true;
|
|
726
891
|
const job = this.finishTurn(agent, events, { deferCapture: true })
|
|
@@ -734,32 +899,49 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
734
899
|
}
|
|
735
900
|
|
|
736
901
|
async finishTurn(agent, events, options = {}) {
|
|
902
|
+
if (this.standDown?.()) return null;
|
|
737
903
|
const settings = readSettings(this.liveConfig);
|
|
738
904
|
const state = this.stateFor(agent);
|
|
739
905
|
const signal = AbortSignal.timeout(30000);
|
|
740
906
|
const ev = events || agent.session.snapshotEvents();
|
|
741
907
|
const deferCapture = options.deferCapture === true;
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
908
|
+
let catchup = { finished: true, progressed: false, retryRequested: false };
|
|
909
|
+
if (deferCapture) {
|
|
910
|
+
catchup = this.scheduleCaptureCatchup(agent, ev, settings);
|
|
911
|
+
} else {
|
|
912
|
+
if (ev.length > CAPTURE_EVENT_BATCH) {
|
|
913
|
+
catchup = this.scheduleCaptureCatchup(agent, ev, settings, { freezeSnapshot: true });
|
|
914
|
+
} else {
|
|
915
|
+
await this.flushPendingCaptures();
|
|
916
|
+
this.captureNew(agent.session, ev, settings);
|
|
917
|
+
}
|
|
748
918
|
}
|
|
749
919
|
const jobs = collectJobs(ev);
|
|
750
920
|
const calls = indexToolCalls(ev);
|
|
751
921
|
try {
|
|
752
922
|
await agent.runMaintenance(async (agentSignal) => {
|
|
923
|
+
if (this.standDown?.()) return;
|
|
753
924
|
const op = AbortSignal.any([agentSignal, signal]);
|
|
754
925
|
let measurement = this.ctx.tokenMeter.measure(agent.session);
|
|
755
|
-
|
|
926
|
+
const settleTurnBaseline = () => {
|
|
927
|
+
state.turnBase = measurement.totalTokens;
|
|
928
|
+
state.maxTurnGrowth = 0;
|
|
929
|
+
};
|
|
930
|
+
if (measurement.totalTokens < settings.minTokens || measurement.totalTokens >= settings.bigGate) {
|
|
931
|
+
settleTurnBaseline();
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
756
934
|
// Restored cards establish a baseline; a live card still skips once,
|
|
757
935
|
// even when maintenance or engine construction is delayed.
|
|
758
936
|
if (skipLiveOfficialCompaction(agent.session)) {
|
|
759
937
|
this.ctx.logger?.info?.("spine cooldown: live official compaction seen, skip this pass");
|
|
938
|
+
settleTurnBaseline();
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
if (inCooldown(agent.session, { windowMs: settings.cooldownMs })) {
|
|
942
|
+
settleTurnBaseline();
|
|
760
943
|
return;
|
|
761
944
|
}
|
|
762
|
-
if (inCooldown(agent.session, { windowMs: settings.cooldownMs })) return;
|
|
763
945
|
this.ensurePruneGuard();
|
|
764
946
|
const resolved = this.engineFor(agent);
|
|
765
947
|
if (state.engine !== resolved.engine || state.engineSource !== resolved.source) {
|
|
@@ -805,8 +987,9 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
805
987
|
try {
|
|
806
988
|
state.localOnly = true;
|
|
807
989
|
state.approvedCard = chunk.card;
|
|
808
|
-
|
|
809
|
-
await this.commitSelectedSpan(agent, chunk, op);
|
|
990
|
+
await this.flushPendingCaptures(agent.session, this.chunkToolCallIds(agent.session, chunk));
|
|
991
|
+
const committed = await this.commitSelectedSpan(agent, chunk, op);
|
|
992
|
+
if (committed === false) break;
|
|
810
993
|
measurement = this.ctx.tokenMeter.measure(agent.session);
|
|
811
994
|
const after = measurement.totalTokens;
|
|
812
995
|
reduced = after < before;
|
|
@@ -841,6 +1024,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
841
1024
|
}
|
|
842
1025
|
if (drainNotes.length) flushDrainStats(shadowDirectory(), drainNotes, { sid: state.key, drain });
|
|
843
1026
|
state.turnBase = measurement.totalTokens;
|
|
1027
|
+
state.maxTurnGrowth = 0;
|
|
844
1028
|
await this.ctx.sessions?.flush?.(agent.session);
|
|
845
1029
|
this.rememberPrefix(agent);
|
|
846
1030
|
});
|
|
@@ -882,6 +1066,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
882
1066
|
}
|
|
883
1067
|
|
|
884
1068
|
async commitSelectedSpan(agent, chunk, signal) {
|
|
1069
|
+
if (this.standDown?.()) return false;
|
|
885
1070
|
signal?.throwIfAborted();
|
|
886
1071
|
const session = agent.session;
|
|
887
1072
|
if (chunk.card?.ok !== true || !chunk.card.text) throw new Error("spine: approved card missing; leaving the span");
|
|
@@ -914,7 +1099,28 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
914
1099
|
const event = session.eventAt(seq);
|
|
915
1100
|
if (event?.type === "tool/result") {
|
|
916
1101
|
const id = event.data?.message?.toolCallId;
|
|
917
|
-
|
|
1102
|
+
let raw = id ? this.rawSeen.get(this.rawKey(session, id)) : null;
|
|
1103
|
+
if (id && raw?.ok && raw.recovered && raw.aclVerified !== true) {
|
|
1104
|
+
try {
|
|
1105
|
+
const verified = readRawToolResult({
|
|
1106
|
+
dir: sessionDir(shadowDirectory(), session.id || DEFAULT_KEY),
|
|
1107
|
+
toolCallId: id,
|
|
1108
|
+
tighten: true,
|
|
1109
|
+
});
|
|
1110
|
+
// ACL success cannot establish a new content identity. Keep the
|
|
1111
|
+
// first verified hash even if an external writer replaces both files.
|
|
1112
|
+
const sameBackup = verified?.ok && verified.path === raw.path &&
|
|
1113
|
+
verified.sha256 === raw.sha256 && verified.bytes === raw.bytes;
|
|
1114
|
+
raw = sameBackup
|
|
1115
|
+
? { ...verified, recovered: false, aclVerified: true }
|
|
1116
|
+
: { ...raw, ok: false, reason: verified?.reason || "hash-mismatch", aclVerified: false };
|
|
1117
|
+
this.rawSeen.set(this.rawKey(session, id), raw);
|
|
1118
|
+
} catch (error) {
|
|
1119
|
+
noteShadowWarning(this.ctx?.logger, error);
|
|
1120
|
+
raw = { ...raw, ok: false, reason: "unwritable", toolCallId: id, aclVerified: false };
|
|
1121
|
+
this.rawSeen.set(this.rawKey(session, id), raw);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
918
1124
|
if (!id || !verifiedRaw(raw).ok) {
|
|
919
1125
|
throw new Error(`spine: raw backup missing before cut: ${id || "unknown"}`);
|
|
920
1126
|
}
|
|
@@ -927,6 +1133,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
927
1133
|
}
|
|
928
1134
|
const summarized = await this.summarize({ messages, liveCompact: { card: chunk.card } }, agent, signal);
|
|
929
1135
|
signal?.throwIfAborted();
|
|
1136
|
+
if (this.standDown?.()) return false;
|
|
930
1137
|
assertCurrentSpan(shadowedSeqs);
|
|
931
1138
|
const compactionId = CompactionId(randomUUID());
|
|
932
1139
|
const checkpointMessage = createUserMessage({
|
|
@@ -986,11 +1193,27 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
986
1193
|
}
|
|
987
1194
|
|
|
988
1195
|
async compactIfNeeded(agent, trigger, signal) {
|
|
1196
|
+
if (this.standDown?.()) return null;
|
|
989
1197
|
const state = this.stateFor(agent);
|
|
990
1198
|
state.liveAgent = agent;
|
|
991
1199
|
state.localOnly = false;
|
|
992
1200
|
this.ensurePruneGuard();
|
|
993
|
-
|
|
1201
|
+
const owned = this.ownedSignal(signal);
|
|
1202
|
+
try {
|
|
1203
|
+
return await super.compactIfNeeded(agent, trigger, owned.signal);
|
|
1204
|
+
} finally {
|
|
1205
|
+
owned.dispose?.();
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
async compactNow(agent, signal, sourceCommandId) {
|
|
1210
|
+
if (this.standDown?.()) return null;
|
|
1211
|
+
const owned = this.ownedSignal(signal);
|
|
1212
|
+
try {
|
|
1213
|
+
return await super.compactNow(agent, owned.signal, sourceCommandId);
|
|
1214
|
+
} finally {
|
|
1215
|
+
owned.dispose?.();
|
|
1216
|
+
}
|
|
994
1217
|
}
|
|
995
1218
|
|
|
996
1219
|
// Preserve explicit URL precedence. Only the exact account provider gets
|
|
@@ -999,9 +1222,11 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
999
1222
|
const header = agent?.session?.requestHeader?.()?.config || {};
|
|
1000
1223
|
const context = agent?.session?.requestContext?.() || {};
|
|
1001
1224
|
const provider = header.provider || context.provider || "";
|
|
1225
|
+
const model = header.model || context.model || "";
|
|
1002
1226
|
const listed = this.listedProviders();
|
|
1003
1227
|
const baseURL = resolveProviderBaseURL({
|
|
1004
1228
|
provider,
|
|
1229
|
+
model,
|
|
1005
1230
|
headerURL: header.baseURL || context.baseURL || "",
|
|
1006
1231
|
listed,
|
|
1007
1232
|
layers: this.providerLayers(),
|
|
@@ -1073,10 +1298,21 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1073
1298
|
try {
|
|
1074
1299
|
const loaded = createRequire(anchor)("@earendil-works/pi-ai/providers/all");
|
|
1075
1300
|
const list = typeof loaded.builtinProviders === "function" ? loaded.builtinProviders() : [];
|
|
1076
|
-
return list.map((entry) =>
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1301
|
+
return list.map((entry) => {
|
|
1302
|
+
let models = [];
|
|
1303
|
+
try {
|
|
1304
|
+
const declared = typeof entry?.getModels === "function" ? entry.getModels() : entry?.models || [];
|
|
1305
|
+
models = (Array.isArray(declared) ? declared : []).map((model) => ({
|
|
1306
|
+
id: model?.id || "",
|
|
1307
|
+
baseURL: model?.baseUrl || model?.baseURL || "",
|
|
1308
|
+
})).filter((model) => model.id && model.baseURL);
|
|
1309
|
+
} catch {}
|
|
1310
|
+
return {
|
|
1311
|
+
id: entry?.id || "",
|
|
1312
|
+
baseURL: entry?.baseUrl || entry?.baseURL || "",
|
|
1313
|
+
models,
|
|
1314
|
+
};
|
|
1315
|
+
}).filter((entry) => entry.id && (entry.baseURL || entry.models.length));
|
|
1080
1316
|
} catch {}
|
|
1081
1317
|
}
|
|
1082
1318
|
return [];
|
|
@@ -1106,6 +1342,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1106
1342
|
}
|
|
1107
1343
|
|
|
1108
1344
|
captureOne(session, event, access = {}) {
|
|
1345
|
+
if (this.standDown?.()) return null;
|
|
1109
1346
|
const id = event?.data?.message?.toolCallId;
|
|
1110
1347
|
if (!id) return null;
|
|
1111
1348
|
const key = session?.id || DEFAULT_KEY;
|
|
@@ -1137,8 +1374,9 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1137
1374
|
),
|
|
1138
1375
|
access,
|
|
1139
1376
|
});
|
|
1140
|
-
|
|
1141
|
-
|
|
1377
|
+
const cached = record?.ok ? { ...record, aclVerified: true } : record;
|
|
1378
|
+
this.rawSeen.set(mapKey, cached);
|
|
1379
|
+
return cached;
|
|
1142
1380
|
} catch (error) {
|
|
1143
1381
|
if (
|
|
1144
1382
|
error?.code === "ESHADOWACCESS" ||
|
|
@@ -1151,7 +1389,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1151
1389
|
}
|
|
1152
1390
|
const message = error instanceof Error ? error.message : String(error);
|
|
1153
1391
|
this.ctx?.logger?.warn?.(`spine: raw backup failed: ${message}`);
|
|
1154
|
-
const record = { ok: false, reason: "unwritable", toolCallId: id, retryable: error
|
|
1392
|
+
const record = { ok: false, reason: "unwritable", toolCallId: id, retryable: captureErrorRetryable(error) };
|
|
1155
1393
|
this.rawSeen.set(mapKey, record);
|
|
1156
1394
|
return record;
|
|
1157
1395
|
}
|
|
@@ -1211,11 +1449,21 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1211
1449
|
}
|
|
1212
1450
|
|
|
1213
1451
|
captureNewBatch(session, events, settings = readSettings(this.liveConfig), limits = {}) {
|
|
1452
|
+
if (this.standDown?.()) {
|
|
1453
|
+
return {
|
|
1454
|
+
seen: new Set(),
|
|
1455
|
+
progressed: false,
|
|
1456
|
+
retryable: false,
|
|
1457
|
+
done: true,
|
|
1458
|
+
nextIndex: Number.isInteger(limits.startIndex) ? limits.startIndex : 0,
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1214
1461
|
if (!Array.isArray(events)) events = [];
|
|
1215
1462
|
const key = session?.id || DEFAULT_KEY;
|
|
1216
1463
|
const cursor = this.captureCursorStart(session, events, settings);
|
|
1217
1464
|
let next = cursor;
|
|
1218
1465
|
let retry = null;
|
|
1466
|
+
let retryIndex = null;
|
|
1219
1467
|
let processed = 0;
|
|
1220
1468
|
let captures = 0;
|
|
1221
1469
|
let progressed = false;
|
|
@@ -1245,7 +1493,10 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1245
1493
|
captures += 1;
|
|
1246
1494
|
}
|
|
1247
1495
|
this.noteCaptureResult(session, event, record);
|
|
1248
|
-
if (record?.retryable && retry === null)
|
|
1496
|
+
if (record?.retryable && retry === null) {
|
|
1497
|
+
retry = seq;
|
|
1498
|
+
retryIndex = index;
|
|
1499
|
+
}
|
|
1249
1500
|
}
|
|
1250
1501
|
}
|
|
1251
1502
|
next = seq + 1;
|
|
@@ -1266,11 +1517,12 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1266
1517
|
progressed,
|
|
1267
1518
|
retryable: retry !== null,
|
|
1268
1519
|
done: exhausted && retry === null && next > lastSeq,
|
|
1269
|
-
nextIndex: index,
|
|
1520
|
+
nextIndex: retryIndex ?? index,
|
|
1270
1521
|
};
|
|
1271
1522
|
}
|
|
1272
1523
|
|
|
1273
1524
|
captureNew(session, events, settings = readSettings(this.liveConfig)) {
|
|
1525
|
+
if (this.standDown?.()) return new Set();
|
|
1274
1526
|
if (!Array.isArray(events)) events = [];
|
|
1275
1527
|
const key = session?.id || DEFAULT_KEY;
|
|
1276
1528
|
const cursor = this.captureCursorStart(session, events, settings);
|
|
@@ -1326,6 +1578,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1326
1578
|
}
|
|
1327
1579
|
|
|
1328
1580
|
writeCaptureCursor(key, cursor, access = {}) {
|
|
1581
|
+
if (this.standDown?.()) return;
|
|
1329
1582
|
if (this.persistCapture !== true || !Number.isSafeInteger(cursor) || cursor < 0) return;
|
|
1330
1583
|
let tmp;
|
|
1331
1584
|
let fd;
|
|
@@ -1352,22 +1605,52 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1352
1605
|
}
|
|
1353
1606
|
|
|
1354
1607
|
recoverVisibleRaw(session, access = {}) {
|
|
1608
|
+
if (this.standDown?.()) return;
|
|
1355
1609
|
const dir = sessionDir(shadowDirectory(), session?.id || DEFAULT_KEY);
|
|
1356
|
-
|
|
1610
|
+
const ids = [...new Set(this.surfaceToolIds(session))];
|
|
1611
|
+
if (ids.length && existsSync(dir)) ensureShadowDir(dir, access);
|
|
1612
|
+
for (const id of ids) {
|
|
1357
1613
|
const mapKey = this.rawKey(session, id);
|
|
1358
1614
|
if (this.rawSeen.has(mapKey)) continue;
|
|
1359
1615
|
try {
|
|
1360
|
-
|
|
1616
|
+
const record = readRawToolResult({ dir, toolCallId: id, access, tighten: false });
|
|
1617
|
+
const retryable = recoveryRetryable(record, dir, id);
|
|
1618
|
+
const cached = record?.ok
|
|
1619
|
+
? { ...record, recovered: true, aclVerified: false }
|
|
1620
|
+
: { ...record, ...(retryable ? { retryable: true } : {}), recovered: true, aclVerified: false };
|
|
1621
|
+
this.rawSeen.set(mapKey, cached);
|
|
1622
|
+
if (!cached.ok) {
|
|
1623
|
+
const event = (session?.surface?.nodes || [])
|
|
1624
|
+
.map((seq) => session.eventAt?.(seq))
|
|
1625
|
+
.find((candidate) => candidate?.type === "tool/result" && candidate.data?.message?.toolCallId === id);
|
|
1626
|
+
if (event) this.noteCaptureResult(session, event, cached);
|
|
1627
|
+
if (retryable) this.captureRecoveryHolds.set(session?.id || DEFAULT_KEY, this.captureRecoveryFingerprint(session));
|
|
1628
|
+
}
|
|
1361
1629
|
} catch (error) {
|
|
1362
1630
|
noteShadowWarning(this.ctx?.logger, error);
|
|
1363
|
-
|
|
1631
|
+
const failed = { ok: false, reason: "unwritable", toolCallId: id, retryable: true, recovered: true, aclVerified: false };
|
|
1632
|
+
this.rawSeen.set(mapKey, failed);
|
|
1633
|
+
const event = (session?.surface?.nodes || [])
|
|
1634
|
+
.map((seq) => session.eventAt?.(seq))
|
|
1635
|
+
.find((candidate) => candidate?.type === "tool/result" && candidate.data?.message?.toolCallId === id);
|
|
1636
|
+
if (event) this.noteCaptureResult(session, event, failed);
|
|
1637
|
+
this.captureRecoveryHolds.set(session?.id || DEFAULT_KEY, this.captureRecoveryFingerprint(session));
|
|
1364
1638
|
}
|
|
1365
1639
|
}
|
|
1366
1640
|
}
|
|
1367
1641
|
|
|
1368
1642
|
recoverVisibleRawBatch(session, access = {}, startIndex = 0, limit = RECOVER_RAW_BATCH, visibleIds = null) {
|
|
1643
|
+
if (this.standDown?.()) return { nextIndex: startIndex, done: true, progressed: false };
|
|
1369
1644
|
const ids = visibleIds || [...new Set(this.surfaceToolIds(session))];
|
|
1370
1645
|
const dir = sessionDir(shadowDirectory(), session?.id || DEFAULT_KEY);
|
|
1646
|
+
if (ids.length && startIndex === 0 && existsSync(dir)) {
|
|
1647
|
+
try {
|
|
1648
|
+
ensureShadowDir(dir, access);
|
|
1649
|
+
} catch (error) {
|
|
1650
|
+
noteShadowWarning(this.ctx?.logger, error);
|
|
1651
|
+
return { nextIndex: ids.length, done: true, progressed: false };
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1371
1654
|
let index = Math.max(0, startIndex);
|
|
1372
1655
|
let processed = 0;
|
|
1373
1656
|
let progressed = false;
|
|
@@ -1376,12 +1659,31 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1376
1659
|
const mapKey = this.rawKey(session, id);
|
|
1377
1660
|
if (this.rawSeen.has(mapKey)) continue;
|
|
1378
1661
|
try {
|
|
1379
|
-
const record = readRawToolResult({ dir, toolCallId: id, access });
|
|
1380
|
-
|
|
1662
|
+
const record = readRawToolResult({ dir, toolCallId: id, access, tighten: false });
|
|
1663
|
+
const retryable = recoveryRetryable(record, dir, id);
|
|
1664
|
+
const cached = record?.ok
|
|
1665
|
+
? { ...record, recovered: true, aclVerified: false }
|
|
1666
|
+
: { ...record, ...(retryable ? { retryable: true } : {}), recovered: true, aclVerified: false };
|
|
1667
|
+
this.rawSeen.set(mapKey, cached);
|
|
1668
|
+
if (!cached.ok) {
|
|
1669
|
+
const event = session?.eventAt?.(session?.surface?.nodes?.find?.((seq) => {
|
|
1670
|
+
const candidate = session.eventAt?.(seq);
|
|
1671
|
+
return candidate?.type === "tool/result" && candidate.data?.message?.toolCallId === id;
|
|
1672
|
+
}));
|
|
1673
|
+
if (event) this.noteCaptureResult(session, event, cached);
|
|
1674
|
+
if (retryable) this.captureRecoveryHolds.set(session?.id || DEFAULT_KEY, this.captureRecoveryFingerprint(session));
|
|
1675
|
+
}
|
|
1381
1676
|
progressed ||= Boolean(record?.ok);
|
|
1382
1677
|
} catch (error) {
|
|
1383
1678
|
noteShadowWarning(this.ctx?.logger, error);
|
|
1384
|
-
|
|
1679
|
+
const failed = { ok: false, reason: "unwritable", toolCallId: id, retryable: true, recovered: true, aclVerified: false };
|
|
1680
|
+
this.rawSeen.set(mapKey, failed);
|
|
1681
|
+
const event = session?.eventAt?.(session?.surface?.nodes?.find?.((seq) => {
|
|
1682
|
+
const candidate = session.eventAt?.(seq);
|
|
1683
|
+
return candidate?.type === "tool/result" && candidate.data?.message?.toolCallId === id;
|
|
1684
|
+
}));
|
|
1685
|
+
if (event) this.noteCaptureResult(session, event, failed);
|
|
1686
|
+
this.captureRecoveryHolds.set(session?.id || DEFAULT_KEY, this.captureRecoveryFingerprint(session));
|
|
1385
1687
|
}
|
|
1386
1688
|
processed += 1;
|
|
1387
1689
|
}
|
|
@@ -1389,7 +1691,21 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1389
1691
|
}
|
|
1390
1692
|
|
|
1391
1693
|
captureSession(session) {
|
|
1392
|
-
|
|
1694
|
+
if (this.standDown?.()) return new Set();
|
|
1695
|
+
const key = session?.id || DEFAULT_KEY;
|
|
1696
|
+
const events = session?.snapshotEvents?.() || [];
|
|
1697
|
+
const fingerprint = this.captureRecoveryFingerprint(session, events);
|
|
1698
|
+
if (this.captureRecoveryHolds.get(key) === fingerprint) {
|
|
1699
|
+
this.captureRecoveryHolds.delete(key);
|
|
1700
|
+
return new Set();
|
|
1701
|
+
}
|
|
1702
|
+
return this.captureNew(session, events);
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
captureRecoveryFingerprint(session, events = null) {
|
|
1706
|
+
const snapshot = events || session?.snapshotEvents?.() || [];
|
|
1707
|
+
const nodes = session?.surface?.nodes || [];
|
|
1708
|
+
return `${snapshot.length}:${snapshot.at(-1)?.seq ?? -1}|${nodes.length}:${nodes.at(-1) ?? -1}`;
|
|
1393
1709
|
}
|
|
1394
1710
|
|
|
1395
1711
|
surfaceToolIds(session) {
|
|
@@ -1406,14 +1722,24 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1406
1722
|
rawVerifiedSet(session) {
|
|
1407
1723
|
const ids = new Set();
|
|
1408
1724
|
const dir = sessionDir(shadowDirectory(), session?.id || DEFAULT_KEY);
|
|
1409
|
-
|
|
1725
|
+
const visibleIds = this.surfaceToolIds(session);
|
|
1726
|
+
const recoveredNeedsAcl = visibleIds.filter((id) => {
|
|
1410
1727
|
const cached = this.rawSeen.get(this.rawKey(session, id));
|
|
1411
|
-
|
|
1728
|
+
return cached?.ok && cached.recovered === true && cached.aclVerified !== true;
|
|
1729
|
+
});
|
|
1730
|
+
if (recoveredNeedsAcl.length > RECOVERY_ACL_REAUTH_LIMIT) return ids;
|
|
1731
|
+
for (const id of visibleIds) {
|
|
1732
|
+
const cached = this.rawSeen.get(this.rawKey(session, id));
|
|
1733
|
+
if (!cached?.ok || cached.aclVerified !== true && cached.recovered !== true) continue;
|
|
1412
1734
|
try {
|
|
1413
1735
|
// Recheck both files immediately before prune, retaining the first
|
|
1414
1736
|
// trusted hash rather than blessing a replacement raw/meta pair.
|
|
1415
|
-
const
|
|
1416
|
-
|
|
1737
|
+
const needsAcl = cached.recovered === true && cached.aclVerified !== true;
|
|
1738
|
+
const stored = readRawToolResult({ dir, toolCallId: id, tighten: needsAcl });
|
|
1739
|
+
if (stored.ok && stored.path === cached.path && stored.sha256 === cached.sha256 && stored.bytes === cached.bytes) {
|
|
1740
|
+
ids.add(id);
|
|
1741
|
+
if (needsAcl) this.rawSeen.set(this.rawKey(session, id), { ...stored, recovered: false, aclVerified: true });
|
|
1742
|
+
}
|
|
1417
1743
|
} catch (error) {
|
|
1418
1744
|
noteShadowWarning(this.ctx?.logger, error);
|
|
1419
1745
|
}
|
|
@@ -1422,6 +1748,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1422
1748
|
}
|
|
1423
1749
|
|
|
1424
1750
|
ensurePruneGuard() {
|
|
1751
|
+
if (this.standDown?.()) return;
|
|
1425
1752
|
if (this.pruneGuarded) return;
|
|
1426
1753
|
let pruner;
|
|
1427
1754
|
try {
|
|
@@ -1439,6 +1766,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1439
1766
|
function guarded(session) {
|
|
1440
1767
|
const engine = pruner.__spineEngine;
|
|
1441
1768
|
try {
|
|
1769
|
+
if (engine.standDown?.()) return;
|
|
1442
1770
|
engine.captureSession(session);
|
|
1443
1771
|
return runPruneAfterCapture(engine.surfaceToolIds(session), engine.rawVerifiedSet(session), () => original(session));
|
|
1444
1772
|
} catch (error) {
|
|
@@ -1479,7 +1807,9 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1479
1807
|
warnKey = DEFAULT_KEY,
|
|
1480
1808
|
protectPaths = [],
|
|
1481
1809
|
access = {},
|
|
1810
|
+
rotationIO = {},
|
|
1482
1811
|
) {
|
|
1812
|
+
if (this.standDown?.()) return { ok: false, reason: "stand-down" };
|
|
1483
1813
|
return saveShadowImpl.call(
|
|
1484
1814
|
this,
|
|
1485
1815
|
messages,
|
|
@@ -1488,9 +1818,69 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1488
1818
|
warnKey,
|
|
1489
1819
|
protectPaths,
|
|
1490
1820
|
access,
|
|
1821
|
+
rotationIO,
|
|
1491
1822
|
);
|
|
1492
1823
|
}
|
|
1493
1824
|
|
|
1825
|
+
async saveShadowAsync(
|
|
1826
|
+
messages,
|
|
1827
|
+
required,
|
|
1828
|
+
shadowFile,
|
|
1829
|
+
warnKey = DEFAULT_KEY,
|
|
1830
|
+
protectPaths = [],
|
|
1831
|
+
access = {},
|
|
1832
|
+
rotationIO = {},
|
|
1833
|
+
) {
|
|
1834
|
+
if (this.standDown?.()) return { ok: false, reason: "stand-down" };
|
|
1835
|
+
if (!access.runner && (access.platform ?? process.platform) === "win32" && shadowAccessFused()) {
|
|
1836
|
+
const error = new Error("spine: shadow permission already failed; backup refused");
|
|
1837
|
+
error.code = "ESHADOWACCESS";
|
|
1838
|
+
throw error;
|
|
1839
|
+
}
|
|
1840
|
+
const injected = Object.values(access || {}).some((value) => typeof value === "function") ||
|
|
1841
|
+
Object.values(rotationIO || {}).some((value) => typeof value === "function");
|
|
1842
|
+
// Node's test runner can monkey-patch fs bindings in the parent isolate;
|
|
1843
|
+
// a worker cannot observe those injected failures. Keep test/fault-injection
|
|
1844
|
+
// paths synchronous while production uses the complete off-thread txn.
|
|
1845
|
+
if (injected || process.env.NODE_TEST_CONTEXT) {
|
|
1846
|
+
return this.saveShadow(messages, required, shadowFile, warnKey, protectPaths, access, rotationIO);
|
|
1847
|
+
}
|
|
1848
|
+
const shadow = shadowDirectory();
|
|
1849
|
+
const file = shadowFilePath(shadowFile, warnKey);
|
|
1850
|
+
const body = shadowFileBody(warnKey, shadowBody(messages));
|
|
1851
|
+
const cap = shadowMaxBytes();
|
|
1852
|
+
const result = await runShadowWorker({
|
|
1853
|
+
shadow,
|
|
1854
|
+
file,
|
|
1855
|
+
body,
|
|
1856
|
+
required,
|
|
1857
|
+
protectPaths,
|
|
1858
|
+
sessionKey: warnKey,
|
|
1859
|
+
cap,
|
|
1860
|
+
access,
|
|
1861
|
+
rotationIO,
|
|
1862
|
+
});
|
|
1863
|
+
if (!result?.ok) {
|
|
1864
|
+
if (result?.reason === "scan-incomplete" || result?.reason === "invalid-measurement") {
|
|
1865
|
+
this.ctx.logger?.warn?.(result.reason === "scan-incomplete"
|
|
1866
|
+
? "spine: shadow capacity scan is incomplete; backup refused; leaving the span"
|
|
1867
|
+
: "spine: shadow capacity measurement is invalid; backup refused; leaving the span");
|
|
1868
|
+
} else if (result?.rotation) {
|
|
1869
|
+
this.warnShadowFull(warnKey, shadow, result.rotation.used, cap);
|
|
1870
|
+
}
|
|
1871
|
+
return result;
|
|
1872
|
+
}
|
|
1873
|
+
if (result.rotation?.deleted > 0) {
|
|
1874
|
+
this.ctx.logger?.info?.(
|
|
1875
|
+
`spine: shadow 轮换 删除 ${result.rotation.deleted} 个文件,` +
|
|
1876
|
+
`腾出 ${result.rotation.freed} 字节,` +
|
|
1877
|
+
`用量 ${result.rotation.used + Buffer.byteLength(body)}/${cap}`,
|
|
1878
|
+
);
|
|
1879
|
+
}
|
|
1880
|
+
if (result.rotation?.oversize) this.warnShadowOversize(warnKey, cap);
|
|
1881
|
+
return result;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1494
1884
|
async summarize(input, agent, signal) {
|
|
1495
1885
|
const decision = takeLocalCut(input?.liveCompact);
|
|
1496
1886
|
const state = this.stateFor(agent);
|
|
@@ -1500,14 +1890,14 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1500
1890
|
const card = input?.liveCompact?.card;
|
|
1501
1891
|
if (card?.needsBackup && !card.shadowFile) throw new Error("spine: omit refused; backup path missing");
|
|
1502
1892
|
const restored = this.restoreRawMessages(input.messages, state.key);
|
|
1503
|
-
this.
|
|
1893
|
+
await this.saveShadowAsync(restored, true, card?.shadowFile, state.key, card?.rawPaths || []);
|
|
1504
1894
|
if (card?.needsBackup && !existsSync(card.shadowFile)) throw new Error("spine: shadow backup was not written");
|
|
1505
1895
|
const text = backupClaimOk(restored) ? decision.text : withoutFullBackupClaim(decision.text, card?.shadowFile);
|
|
1506
1896
|
const block = { type: "text", text };
|
|
1507
1897
|
return { summary: [block], provider: "local", model: "spine-extract", rawOutput: [block] };
|
|
1508
1898
|
}
|
|
1509
1899
|
try {
|
|
1510
|
-
this.
|
|
1900
|
+
await this.saveShadowAsync(
|
|
1511
1901
|
this.restoreRawMessages(input.messages, state.key),
|
|
1512
1902
|
false,
|
|
1513
1903
|
freshShadowFile("official", state.key),
|
|
@@ -1522,7 +1912,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1522
1912
|
}
|
|
1523
1913
|
}
|
|
1524
1914
|
const joined = input.messages.map(messageText).join("\n\n");
|
|
1525
|
-
if (process.env.SPINE_THUNDER !== "1") return
|
|
1915
|
+
if (process.env.SPINE_THUNDER !== "1") return this.summarizeOfficial(input, agent, signal);
|
|
1526
1916
|
const next = joined;
|
|
1527
1917
|
const mode = decideThunder({
|
|
1528
1918
|
previous: state.lastPrefix || "",
|
|
@@ -1530,18 +1920,34 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1530
1920
|
modelKind: process.env.SPINE_THUNDER_MODEL === "27b" ? "27b" : "flash",
|
|
1531
1921
|
prefixTokens: state.lastPrefixTokens || 0,
|
|
1532
1922
|
});
|
|
1533
|
-
if (mode === "attach") return
|
|
1923
|
+
if (mode === "attach") return this.summarizeOfficial(input, agent, signal);
|
|
1534
1924
|
const users = input.messages.filter((message) => (message?.role ?? message?.message?.role) === "user");
|
|
1535
1925
|
const taskLine = messageText(users.at(-1) || input.messages.at(-1)).slice(0, 500);
|
|
1536
1926
|
const trunk = buildCard(joined, "", input.messages.map((message) => ({
|
|
1537
1927
|
role: message?.role ?? message?.message?.role,
|
|
1538
1928
|
text: messageText(message),
|
|
1539
1929
|
})));
|
|
1540
|
-
const facts = [...
|
|
1930
|
+
const facts = [...(trunk.hardFacts || []), ...(trunk.missingFacts || [])];
|
|
1541
1931
|
const slim = buildSendLess({ taskLine, uncovered: trunk.uncovered, facts });
|
|
1542
|
-
return
|
|
1932
|
+
return this.summarizeOfficial({
|
|
1543
1933
|
...input,
|
|
1544
1934
|
messages: [{ role: "user", content: [{ type: "text", text: slim }] }],
|
|
1545
1935
|
}, agent, signal);
|
|
1546
1936
|
}
|
|
1937
|
+
|
|
1938
|
+
async summarizeOfficial(input, agent, signal) {
|
|
1939
|
+
const result = await super.summarize(input, agent, signal);
|
|
1940
|
+
const blocks = Array.isArray(result?.summary) ? result.summary : [];
|
|
1941
|
+
const usable = blocks.some((block) => {
|
|
1942
|
+
if (typeof block === "string") return block.trim().length > 0;
|
|
1943
|
+
if (!block || typeof block !== "object") return false;
|
|
1944
|
+
if (typeof block.text === "string" && block.text.trim()) return true;
|
|
1945
|
+
return Array.isArray(block.content) && block.content.some((part) => typeof part?.text === "string" && part.text.trim());
|
|
1946
|
+
});
|
|
1947
|
+
if (!usable) {
|
|
1948
|
+
this.ctx.logger?.warn?.("spine: official summarizer returned no usable summary; leaving the span untouched");
|
|
1949
|
+
throw new Error("spine: official summarizer returned no usable summary");
|
|
1950
|
+
}
|
|
1951
|
+
return result;
|
|
1952
|
+
}
|
|
1547
1953
|
}
|