dsh-live-compact 1.1.4 → 1.1.5
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 +260 -0
- package/README.md +72 -211
- package/index.js +365 -119
- package/package.json +5 -2
- package/plan.js +41 -11
- package/raw.js +6 -4
- package/shadow-save-worker.mjs +24 -0
- package/shadow.js +168 -17
- package/shell.js +4 -2
package/index.js
CHANGED
|
@@ -16,28 +16,26 @@ 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";
|
|
@@ -57,6 +55,100 @@ const CAPTURE_EVENT_BATCH = 128;
|
|
|
57
55
|
const CAPTURE_RESULT_BATCH = 1;
|
|
58
56
|
const RECOVER_RAW_BATCH = 1;
|
|
59
57
|
const CAPTURE_RETRY_LIMIT = 8;
|
|
58
|
+
const RECOVERY_ACL_REAUTH_LIMIT = 32;
|
|
59
|
+
|
|
60
|
+
const shadowWorkers = new Map();
|
|
61
|
+
|
|
62
|
+
function shadowWorkerFor(root) {
|
|
63
|
+
const key = String(root);
|
|
64
|
+
let entry = shadowWorkers.get(key);
|
|
65
|
+
if (entry) return entry;
|
|
66
|
+
const worker = new Worker(new URL("./shadow-save-worker.mjs", import.meta.url), {
|
|
67
|
+
workerData: { spineWorker: 1, accessFuse: shadowAccessFuseBuffer() },
|
|
68
|
+
});
|
|
69
|
+
entry = { worker, nextId: 1, pending: new Map(), idleTimer: null, retired: false };
|
|
70
|
+
const removeEntry = () => {
|
|
71
|
+
if (shadowWorkers.get(key) === entry) shadowWorkers.delete(key);
|
|
72
|
+
};
|
|
73
|
+
const scheduleIdleExit = () => {
|
|
74
|
+
if (entry.retired || entry.pending.size || entry.idleTimer) return;
|
|
75
|
+
entry.idleTimer = setTimeout(() => {
|
|
76
|
+
entry.idleTimer = null;
|
|
77
|
+
if (entry.pending.size) return;
|
|
78
|
+
entry.retired = true;
|
|
79
|
+
removeEntry();
|
|
80
|
+
worker.terminate().catch(() => {});
|
|
81
|
+
}, 500);
|
|
82
|
+
entry.idleTimer.unref?.();
|
|
83
|
+
};
|
|
84
|
+
entry.scheduleIdleExit = scheduleIdleExit;
|
|
85
|
+
const fail = (error) => {
|
|
86
|
+
entry.retired = true;
|
|
87
|
+
if (entry.idleTimer) {
|
|
88
|
+
clearTimeout(entry.idleTimer);
|
|
89
|
+
entry.idleTimer = null;
|
|
90
|
+
}
|
|
91
|
+
for (const { reject } of entry.pending.values()) reject(error);
|
|
92
|
+
entry.pending.clear();
|
|
93
|
+
removeEntry();
|
|
94
|
+
worker.terminate().catch(() => {});
|
|
95
|
+
};
|
|
96
|
+
worker.on("message", (message) => {
|
|
97
|
+
const task = entry.pending.get(message?.id);
|
|
98
|
+
if (!task) return;
|
|
99
|
+
entry.pending.delete(message.id);
|
|
100
|
+
if (message?.ok) {
|
|
101
|
+
task.resolve(message.result);
|
|
102
|
+
scheduleIdleExit();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const error = new Error(message?.error?.message || "spine: shadow worker failed");
|
|
106
|
+
if (message?.error?.code) error.code = message.error.code;
|
|
107
|
+
task.reject(error);
|
|
108
|
+
scheduleIdleExit();
|
|
109
|
+
});
|
|
110
|
+
worker.on("error", (error) => fail(error));
|
|
111
|
+
worker.on("exit", (code) => {
|
|
112
|
+
if (entry.pending.size || (!entry.retired && code !== 0)) {
|
|
113
|
+
fail(Object.assign(new Error(`spine: shadow worker exited ${code}`), { code: "ESHADOWWORKER" }));
|
|
114
|
+
} else {
|
|
115
|
+
entry.retired = true;
|
|
116
|
+
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
|
117
|
+
entry.idleTimer = null;
|
|
118
|
+
removeEntry();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
shadowWorkers.set(key, entry);
|
|
122
|
+
return entry;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function runShadowWorker(payload) {
|
|
126
|
+
const entry = shadowWorkerFor(payload.shadow);
|
|
127
|
+
if (entry.idleTimer) {
|
|
128
|
+
clearTimeout(entry.idleTimer);
|
|
129
|
+
entry.idleTimer = null;
|
|
130
|
+
}
|
|
131
|
+
const id = entry.nextId++;
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
entry.pending.set(id, { resolve, reject });
|
|
134
|
+
try {
|
|
135
|
+
entry.worker.postMessage({ id, payload });
|
|
136
|
+
} catch (error) {
|
|
137
|
+
entry.pending.delete(id);
|
|
138
|
+
reject(error);
|
|
139
|
+
entry.scheduleIdleExit();
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function recoveryRetryable(record, dir, toolCallId) {
|
|
145
|
+
if (record?.retryable === true || record?.reason === "unwritable") return true;
|
|
146
|
+
// A completely absent backup has never been captured and can be retried.
|
|
147
|
+
// If its metadata is still present, the body was damaged after capture and
|
|
148
|
+
// must remain fail-closed rather than being silently overwritten.
|
|
149
|
+
if (record?.reason === "missing-file") return !existsSync(join(dir, `${rawName(toolCallId)}.meta.json`));
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
60
152
|
|
|
61
153
|
function eventContent(session, seq) {
|
|
62
154
|
const event = session.eventAt(seq);
|
|
@@ -184,80 +276,41 @@ export function saveShadowImpl(
|
|
|
184
276
|
rotationIO = {},
|
|
185
277
|
) {
|
|
186
278
|
const shadow = shadowDirectory();
|
|
187
|
-
ensureShadowDir(shadow, access);
|
|
188
|
-
|
|
189
279
|
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
|
-
}
|
|
280
|
+
const body = shadowFileBody(warnKey, shadowBody(messages));
|
|
281
|
+
const cap = shadowMaxBytes();
|
|
282
|
+
const result = saveShadowTransaction({
|
|
283
|
+
shadow,
|
|
284
|
+
file,
|
|
285
|
+
body,
|
|
286
|
+
required,
|
|
287
|
+
protectPaths,
|
|
288
|
+
sessionKey: warnKey,
|
|
289
|
+
cap,
|
|
290
|
+
access,
|
|
291
|
+
rotationIO,
|
|
260
292
|
});
|
|
293
|
+
if (!result?.ok) {
|
|
294
|
+
if (result?.reason === "scan-incomplete" || result?.reason === "invalid-measurement") {
|
|
295
|
+
const error = result.reason === "scan-incomplete"
|
|
296
|
+
? "spine: shadow capacity scan is incomplete; backup refused; leaving the span"
|
|
297
|
+
: "spine: shadow capacity measurement is invalid; backup refused; leaving the span";
|
|
298
|
+
this.ctx.logger?.warn?.(error);
|
|
299
|
+
} else if (result?.rotation) {
|
|
300
|
+
this.warnShadowFull(warnKey, shadow, result.rotation.used, cap);
|
|
301
|
+
}
|
|
302
|
+
return result;
|
|
303
|
+
}
|
|
304
|
+
const rotation = result.rotation;
|
|
305
|
+
if (rotation.deleted > 0) {
|
|
306
|
+
this.ctx.logger?.info?.(
|
|
307
|
+
`spine: shadow 轮换 删除 ${rotation.deleted} 个文件,` +
|
|
308
|
+
`腾出 ${rotation.freed} 字节,` +
|
|
309
|
+
`用量 ${rotation.used + Buffer.byteLength(body)}/${cap}`,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
if (rotation.oversize) this.warnShadowOversize(warnKey, cap);
|
|
313
|
+
return result;
|
|
261
314
|
}
|
|
262
315
|
|
|
263
316
|
export { saveShadowImpl as saveShadow };
|
|
@@ -304,9 +357,11 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
|
|
|
304
357
|
type: event?.type === "tool/result" ? "tool" : "other",
|
|
305
358
|
toolCalls,
|
|
306
359
|
longRead: event?.type === "tool/result" && call?.name === "read" && (priced[index].tokens || 0) >= settings.chunkMin,
|
|
360
|
+
checkpoint: isCheckpoint(session, nodes[index]),
|
|
307
361
|
});
|
|
308
362
|
}
|
|
309
|
-
const
|
|
363
|
+
const floorVisible = visible.reduce((found, item, index) => item.checkpoint ? index : found, -1);
|
|
364
|
+
const rounds = keptToolRounds(visible, settings.keepRounds, floorVisible);
|
|
310
365
|
const newestLong = [...finishedToolRounds(visible)].reverse().find((round) => roundHasLongRead(visible, round));
|
|
311
366
|
const span = rounds.length ? { start: rounds[0].start, end: rounds.at(-1).end } : null;
|
|
312
367
|
const cutEnd = span ? reservedCutEnd(visible[span.start].index, first) : null;
|
|
@@ -365,7 +420,6 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
|
|
|
365
420
|
}
|
|
366
421
|
return pieces;
|
|
367
422
|
};
|
|
368
|
-
const spanText = (spanStart, spanEnd) => spanPieces(spanStart, spanEnd).map((piece) => piece.text).join("\n");
|
|
369
423
|
const liveText = (spanStart, spanEnd) => {
|
|
370
424
|
const bits = [];
|
|
371
425
|
for (let index = 0; index < nodes.length; index += 1) {
|
|
@@ -437,9 +491,11 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
|
|
|
437
491
|
const start = picked.start;
|
|
438
492
|
end = picked.end;
|
|
439
493
|
const tokens = items.slice(start, end + 1).reduce((sum, item) => sum + item.tokens, 0);
|
|
494
|
+
const pieces = spanPieces(start, end);
|
|
495
|
+
if (picked.pointerOnly && pieces.some((piece) => piece.role === "tool" && isToolFailure(piece.text, piece.isError))) return null;
|
|
440
496
|
let card = picked.pointerOnly
|
|
441
|
-
? { ok: true, text: pointerOnlyText(
|
|
442
|
-
: buildCard(
|
|
497
|
+
? { ok: true, text: pointerOnlyText(pieces), hardLines: [], missing: [], needsBackup: false, rawPaths: [] }
|
|
498
|
+
: buildCard(pieces.map((piece) => piece.text).join("\n"), liveText(start, end), pieces, extras);
|
|
443
499
|
if (card.ok !== true) return null;
|
|
444
500
|
card = attachBackupHint(card, freshShadowFile("cut", key));
|
|
445
501
|
if (!card.text.includes("不是备份文件路径")) card = { ...card, text: `${card.text}\n序号是本会话日志里的事件号,不是备份文件路径。` };
|
|
@@ -473,6 +529,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
473
529
|
captureRetryStats = new Map();
|
|
474
530
|
retryableCaptureSeqs = new Map();
|
|
475
531
|
retryableCaptureSeeded = new Set();
|
|
532
|
+
captureRecoveryHolds = new Map();
|
|
476
533
|
pruneGuarded = false;
|
|
477
534
|
|
|
478
535
|
constructor(ctx, config, resolvedConfig) {
|
|
@@ -585,7 +642,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
585
642
|
return planMessages(messages);
|
|
586
643
|
}
|
|
587
644
|
|
|
588
|
-
scheduleCaptureCatchup(agent, events, settings) {
|
|
645
|
+
scheduleCaptureCatchup(agent, events, settings, options = {}) {
|
|
589
646
|
this.pendingCatchups ||= new Map();
|
|
590
647
|
this.pendingCatchupJobs ||= new Set();
|
|
591
648
|
this.pendingCuts ||= new Set();
|
|
@@ -596,6 +653,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
596
653
|
task.session = agent.session;
|
|
597
654
|
task.events = events;
|
|
598
655
|
task.settings = settings;
|
|
656
|
+
task.freezeSnapshot ||= options.freezeSnapshot === true;
|
|
599
657
|
return task;
|
|
600
658
|
}
|
|
601
659
|
let resolve;
|
|
@@ -606,16 +664,17 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
606
664
|
session: agent.session,
|
|
607
665
|
events,
|
|
608
666
|
settings,
|
|
609
|
-
phase: "
|
|
667
|
+
phase: "previsible",
|
|
610
668
|
eventIndex: 0,
|
|
611
669
|
recoverIndex: 0,
|
|
612
|
-
visibleIds:
|
|
670
|
+
visibleIds: [...new Set(this.surfaceToolIds(agent.session))],
|
|
613
671
|
scheduled: false,
|
|
614
672
|
retryRequested: false,
|
|
615
673
|
captureRetryAttempts: 0,
|
|
616
674
|
progressed: false,
|
|
617
675
|
eventTail: Number.isInteger(events?.at?.(-1)?.seq) ? events.at(-1).seq : -1,
|
|
618
676
|
surfaceTail: agent.session.surface?.nodes?.at?.(-1) ?? -1,
|
|
677
|
+
freezeSnapshot: options.freezeSnapshot === true,
|
|
619
678
|
resolve,
|
|
620
679
|
promise,
|
|
621
680
|
};
|
|
@@ -641,13 +700,13 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
641
700
|
scheduleImmediate(() => {
|
|
642
701
|
task.scheduled = false;
|
|
643
702
|
try {
|
|
644
|
-
const latest = task.session.snapshotEvents?.();
|
|
703
|
+
const latest = task.freezeSnapshot ? null : task.session.snapshotEvents?.();
|
|
645
704
|
const latestTail = Number.isInteger(latest?.at?.(-1)?.seq) ? latest.at(-1).seq : -1;
|
|
646
705
|
const latestSurfaceTail = task.session.surface?.nodes?.at?.(-1) ?? -1;
|
|
647
706
|
if (Array.isArray(latest) && (latestTail !== task.eventTail || latest.length !== task.events.length)) {
|
|
648
707
|
task.events = latest;
|
|
649
708
|
task.eventTail = latestTail;
|
|
650
|
-
if (task.phase === "visible") {
|
|
709
|
+
if (task.phase === "previsible" || task.phase === "visible") {
|
|
651
710
|
task.phase = "events";
|
|
652
711
|
task.visibleIds = null;
|
|
653
712
|
task.recoverIndex = 0;
|
|
@@ -655,11 +714,22 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
655
714
|
}
|
|
656
715
|
if (latestSurfaceTail !== task.surfaceTail) {
|
|
657
716
|
task.surfaceTail = latestSurfaceTail;
|
|
658
|
-
if (task.phase === "visible") {
|
|
717
|
+
if (task.phase === "previsible" || task.phase === "visible") {
|
|
659
718
|
task.visibleIds = [...new Set(this.surfaceToolIds(task.session))];
|
|
660
719
|
task.recoverIndex = 0;
|
|
661
720
|
}
|
|
662
721
|
}
|
|
722
|
+
if (task.phase === "previsible") {
|
|
723
|
+
const recovered = this.recoverVisibleRawBatch(task.session, {}, task.recoverIndex, RECOVER_RAW_BATCH, task.visibleIds);
|
|
724
|
+
task.progressed ||= recovered.progressed;
|
|
725
|
+
task.recoverIndex = recovered.nextIndex;
|
|
726
|
+
if (!recovered.done) {
|
|
727
|
+
this.queueCaptureCatchup(task);
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
task.phase = "events";
|
|
731
|
+
task.eventIndex = 0;
|
|
732
|
+
}
|
|
663
733
|
if (task.phase === "events") {
|
|
664
734
|
const result = this.captureNewBatch(task.session, task.events, task.settings, {
|
|
665
735
|
eventLimit: CAPTURE_EVENT_BATCH,
|
|
@@ -739,12 +809,16 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
739
809
|
const signal = AbortSignal.timeout(30000);
|
|
740
810
|
const ev = events || agent.session.snapshotEvents();
|
|
741
811
|
const deferCapture = options.deferCapture === true;
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
812
|
+
let catchup = { finished: true, progressed: false, retryRequested: false };
|
|
813
|
+
if (deferCapture) {
|
|
814
|
+
catchup = this.scheduleCaptureCatchup(agent, ev, settings);
|
|
815
|
+
} else {
|
|
816
|
+
if (ev.length > CAPTURE_EVENT_BATCH) {
|
|
817
|
+
catchup = this.scheduleCaptureCatchup(agent, ev, settings, { freezeSnapshot: true });
|
|
818
|
+
} else {
|
|
819
|
+
await this.flushPendingCaptures();
|
|
820
|
+
this.captureNew(agent.session, ev, settings);
|
|
821
|
+
}
|
|
748
822
|
}
|
|
749
823
|
const jobs = collectJobs(ev);
|
|
750
824
|
const calls = indexToolCalls(ev);
|
|
@@ -805,7 +879,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
805
879
|
try {
|
|
806
880
|
state.localOnly = true;
|
|
807
881
|
state.approvedCard = chunk.card;
|
|
808
|
-
|
|
882
|
+
await this.flushPendingCaptures(agent.session, this.chunkToolCallIds(agent.session, chunk));
|
|
809
883
|
await this.commitSelectedSpan(agent, chunk, op);
|
|
810
884
|
measurement = this.ctx.tokenMeter.measure(agent.session);
|
|
811
885
|
const after = measurement.totalTokens;
|
|
@@ -914,7 +988,28 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
914
988
|
const event = session.eventAt(seq);
|
|
915
989
|
if (event?.type === "tool/result") {
|
|
916
990
|
const id = event.data?.message?.toolCallId;
|
|
917
|
-
|
|
991
|
+
let raw = id ? this.rawSeen.get(this.rawKey(session, id)) : null;
|
|
992
|
+
if (id && raw?.ok && raw.recovered && raw.aclVerified !== true) {
|
|
993
|
+
try {
|
|
994
|
+
const verified = readRawToolResult({
|
|
995
|
+
dir: sessionDir(shadowDirectory(), session.id || DEFAULT_KEY),
|
|
996
|
+
toolCallId: id,
|
|
997
|
+
tighten: true,
|
|
998
|
+
});
|
|
999
|
+
// ACL success cannot establish a new content identity. Keep the
|
|
1000
|
+
// first verified hash even if an external writer replaces both files.
|
|
1001
|
+
const sameBackup = verified?.ok && verified.path === raw.path &&
|
|
1002
|
+
verified.sha256 === raw.sha256 && verified.bytes === raw.bytes;
|
|
1003
|
+
raw = sameBackup
|
|
1004
|
+
? { ...verified, recovered: false, aclVerified: true }
|
|
1005
|
+
: { ...raw, ok: false, reason: verified?.reason || "hash-mismatch", aclVerified: false };
|
|
1006
|
+
this.rawSeen.set(this.rawKey(session, id), raw);
|
|
1007
|
+
} catch (error) {
|
|
1008
|
+
noteShadowWarning(this.ctx?.logger, error);
|
|
1009
|
+
raw = { ...raw, ok: false, reason: "unwritable", toolCallId: id, aclVerified: false };
|
|
1010
|
+
this.rawSeen.set(this.rawKey(session, id), raw);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
918
1013
|
if (!id || !verifiedRaw(raw).ok) {
|
|
919
1014
|
throw new Error(`spine: raw backup missing before cut: ${id || "unknown"}`);
|
|
920
1015
|
}
|
|
@@ -1137,8 +1232,9 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1137
1232
|
),
|
|
1138
1233
|
access,
|
|
1139
1234
|
});
|
|
1140
|
-
|
|
1141
|
-
|
|
1235
|
+
const cached = record?.ok ? { ...record, aclVerified: true } : record;
|
|
1236
|
+
this.rawSeen.set(mapKey, cached);
|
|
1237
|
+
return cached;
|
|
1142
1238
|
} catch (error) {
|
|
1143
1239
|
if (
|
|
1144
1240
|
error?.code === "ESHADOWACCESS" ||
|
|
@@ -1216,6 +1312,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1216
1312
|
const cursor = this.captureCursorStart(session, events, settings);
|
|
1217
1313
|
let next = cursor;
|
|
1218
1314
|
let retry = null;
|
|
1315
|
+
let retryIndex = null;
|
|
1219
1316
|
let processed = 0;
|
|
1220
1317
|
let captures = 0;
|
|
1221
1318
|
let progressed = false;
|
|
@@ -1245,7 +1342,10 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1245
1342
|
captures += 1;
|
|
1246
1343
|
}
|
|
1247
1344
|
this.noteCaptureResult(session, event, record);
|
|
1248
|
-
if (record?.retryable && retry === null)
|
|
1345
|
+
if (record?.retryable && retry === null) {
|
|
1346
|
+
retry = seq;
|
|
1347
|
+
retryIndex = index;
|
|
1348
|
+
}
|
|
1249
1349
|
}
|
|
1250
1350
|
}
|
|
1251
1351
|
next = seq + 1;
|
|
@@ -1266,7 +1366,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1266
1366
|
progressed,
|
|
1267
1367
|
retryable: retry !== null,
|
|
1268
1368
|
done: exhausted && retry === null && next > lastSeq,
|
|
1269
|
-
nextIndex: index,
|
|
1369
|
+
nextIndex: retryIndex ?? index,
|
|
1270
1370
|
};
|
|
1271
1371
|
}
|
|
1272
1372
|
|
|
@@ -1353,14 +1453,34 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1353
1453
|
|
|
1354
1454
|
recoverVisibleRaw(session, access = {}) {
|
|
1355
1455
|
const dir = sessionDir(shadowDirectory(), session?.id || DEFAULT_KEY);
|
|
1356
|
-
|
|
1456
|
+
const ids = [...new Set(this.surfaceToolIds(session))];
|
|
1457
|
+
if (ids.length && existsSync(dir)) ensureShadowDir(dir, access);
|
|
1458
|
+
for (const id of ids) {
|
|
1357
1459
|
const mapKey = this.rawKey(session, id);
|
|
1358
1460
|
if (this.rawSeen.has(mapKey)) continue;
|
|
1359
1461
|
try {
|
|
1360
|
-
|
|
1462
|
+
const record = readRawToolResult({ dir, toolCallId: id, access, tighten: false });
|
|
1463
|
+
const retryable = recoveryRetryable(record, dir, id);
|
|
1464
|
+
const cached = record?.ok
|
|
1465
|
+
? { ...record, recovered: true, aclVerified: false }
|
|
1466
|
+
: { ...record, ...(retryable ? { retryable: true } : {}), recovered: true, aclVerified: false };
|
|
1467
|
+
this.rawSeen.set(mapKey, cached);
|
|
1468
|
+
if (!cached.ok) {
|
|
1469
|
+
const event = (session?.surface?.nodes || [])
|
|
1470
|
+
.map((seq) => session.eventAt?.(seq))
|
|
1471
|
+
.find((candidate) => candidate?.type === "tool/result" && candidate.data?.message?.toolCallId === id);
|
|
1472
|
+
if (event) this.noteCaptureResult(session, event, cached);
|
|
1473
|
+
if (retryable) this.captureRecoveryHolds.set(session?.id || DEFAULT_KEY, this.captureRecoveryFingerprint(session));
|
|
1474
|
+
}
|
|
1361
1475
|
} catch (error) {
|
|
1362
1476
|
noteShadowWarning(this.ctx?.logger, error);
|
|
1363
|
-
|
|
1477
|
+
const failed = { ok: false, reason: "unwritable", toolCallId: id, retryable: true, recovered: true, aclVerified: false };
|
|
1478
|
+
this.rawSeen.set(mapKey, failed);
|
|
1479
|
+
const event = (session?.surface?.nodes || [])
|
|
1480
|
+
.map((seq) => session.eventAt?.(seq))
|
|
1481
|
+
.find((candidate) => candidate?.type === "tool/result" && candidate.data?.message?.toolCallId === id);
|
|
1482
|
+
if (event) this.noteCaptureResult(session, event, failed);
|
|
1483
|
+
this.captureRecoveryHolds.set(session?.id || DEFAULT_KEY, this.captureRecoveryFingerprint(session));
|
|
1364
1484
|
}
|
|
1365
1485
|
}
|
|
1366
1486
|
}
|
|
@@ -1368,6 +1488,14 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1368
1488
|
recoverVisibleRawBatch(session, access = {}, startIndex = 0, limit = RECOVER_RAW_BATCH, visibleIds = null) {
|
|
1369
1489
|
const ids = visibleIds || [...new Set(this.surfaceToolIds(session))];
|
|
1370
1490
|
const dir = sessionDir(shadowDirectory(), session?.id || DEFAULT_KEY);
|
|
1491
|
+
if (ids.length && startIndex === 0 && existsSync(dir)) {
|
|
1492
|
+
try {
|
|
1493
|
+
ensureShadowDir(dir, access);
|
|
1494
|
+
} catch (error) {
|
|
1495
|
+
noteShadowWarning(this.ctx?.logger, error);
|
|
1496
|
+
return { nextIndex: ids.length, done: true, progressed: false };
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1371
1499
|
let index = Math.max(0, startIndex);
|
|
1372
1500
|
let processed = 0;
|
|
1373
1501
|
let progressed = false;
|
|
@@ -1376,12 +1504,31 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1376
1504
|
const mapKey = this.rawKey(session, id);
|
|
1377
1505
|
if (this.rawSeen.has(mapKey)) continue;
|
|
1378
1506
|
try {
|
|
1379
|
-
const record = readRawToolResult({ dir, toolCallId: id, access });
|
|
1380
|
-
|
|
1507
|
+
const record = readRawToolResult({ dir, toolCallId: id, access, tighten: false });
|
|
1508
|
+
const retryable = recoveryRetryable(record, dir, id);
|
|
1509
|
+
const cached = record?.ok
|
|
1510
|
+
? { ...record, recovered: true, aclVerified: false }
|
|
1511
|
+
: { ...record, ...(retryable ? { retryable: true } : {}), recovered: true, aclVerified: false };
|
|
1512
|
+
this.rawSeen.set(mapKey, cached);
|
|
1513
|
+
if (!cached.ok) {
|
|
1514
|
+
const event = session?.eventAt?.(session?.surface?.nodes?.find?.((seq) => {
|
|
1515
|
+
const candidate = session.eventAt?.(seq);
|
|
1516
|
+
return candidate?.type === "tool/result" && candidate.data?.message?.toolCallId === id;
|
|
1517
|
+
}));
|
|
1518
|
+
if (event) this.noteCaptureResult(session, event, cached);
|
|
1519
|
+
if (retryable) this.captureRecoveryHolds.set(session?.id || DEFAULT_KEY, this.captureRecoveryFingerprint(session));
|
|
1520
|
+
}
|
|
1381
1521
|
progressed ||= Boolean(record?.ok);
|
|
1382
1522
|
} catch (error) {
|
|
1383
1523
|
noteShadowWarning(this.ctx?.logger, error);
|
|
1384
|
-
|
|
1524
|
+
const failed = { ok: false, reason: "unwritable", toolCallId: id, retryable: true, recovered: true, aclVerified: false };
|
|
1525
|
+
this.rawSeen.set(mapKey, failed);
|
|
1526
|
+
const event = session?.eventAt?.(session?.surface?.nodes?.find?.((seq) => {
|
|
1527
|
+
const candidate = session.eventAt?.(seq);
|
|
1528
|
+
return candidate?.type === "tool/result" && candidate.data?.message?.toolCallId === id;
|
|
1529
|
+
}));
|
|
1530
|
+
if (event) this.noteCaptureResult(session, event, failed);
|
|
1531
|
+
this.captureRecoveryHolds.set(session?.id || DEFAULT_KEY, this.captureRecoveryFingerprint(session));
|
|
1385
1532
|
}
|
|
1386
1533
|
processed += 1;
|
|
1387
1534
|
}
|
|
@@ -1389,7 +1536,20 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1389
1536
|
}
|
|
1390
1537
|
|
|
1391
1538
|
captureSession(session) {
|
|
1392
|
-
|
|
1539
|
+
const key = session?.id || DEFAULT_KEY;
|
|
1540
|
+
const events = session?.snapshotEvents?.() || [];
|
|
1541
|
+
const fingerprint = this.captureRecoveryFingerprint(session, events);
|
|
1542
|
+
if (this.captureRecoveryHolds.get(key) === fingerprint) {
|
|
1543
|
+
this.captureRecoveryHolds.delete(key);
|
|
1544
|
+
return new Set();
|
|
1545
|
+
}
|
|
1546
|
+
return this.captureNew(session, events);
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
captureRecoveryFingerprint(session, events = null) {
|
|
1550
|
+
const snapshot = events || session?.snapshotEvents?.() || [];
|
|
1551
|
+
const nodes = session?.surface?.nodes || [];
|
|
1552
|
+
return `${snapshot.length}:${snapshot.at(-1)?.seq ?? -1}|${nodes.length}:${nodes.at(-1) ?? -1}`;
|
|
1393
1553
|
}
|
|
1394
1554
|
|
|
1395
1555
|
surfaceToolIds(session) {
|
|
@@ -1406,14 +1566,24 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1406
1566
|
rawVerifiedSet(session) {
|
|
1407
1567
|
const ids = new Set();
|
|
1408
1568
|
const dir = sessionDir(shadowDirectory(), session?.id || DEFAULT_KEY);
|
|
1409
|
-
|
|
1569
|
+
const visibleIds = this.surfaceToolIds(session);
|
|
1570
|
+
const recoveredNeedsAcl = visibleIds.filter((id) => {
|
|
1410
1571
|
const cached = this.rawSeen.get(this.rawKey(session, id));
|
|
1411
|
-
|
|
1572
|
+
return cached?.ok && cached.recovered === true && cached.aclVerified !== true;
|
|
1573
|
+
});
|
|
1574
|
+
if (recoveredNeedsAcl.length > RECOVERY_ACL_REAUTH_LIMIT) return ids;
|
|
1575
|
+
for (const id of visibleIds) {
|
|
1576
|
+
const cached = this.rawSeen.get(this.rawKey(session, id));
|
|
1577
|
+
if (!cached?.ok || cached.aclVerified !== true && cached.recovered !== true) continue;
|
|
1412
1578
|
try {
|
|
1413
1579
|
// Recheck both files immediately before prune, retaining the first
|
|
1414
1580
|
// trusted hash rather than blessing a replacement raw/meta pair.
|
|
1415
|
-
const
|
|
1416
|
-
|
|
1581
|
+
const needsAcl = cached.recovered === true && cached.aclVerified !== true;
|
|
1582
|
+
const stored = readRawToolResult({ dir, toolCallId: id, tighten: needsAcl });
|
|
1583
|
+
if (stored.ok && stored.path === cached.path && stored.sha256 === cached.sha256 && stored.bytes === cached.bytes) {
|
|
1584
|
+
ids.add(id);
|
|
1585
|
+
if (needsAcl) this.rawSeen.set(this.rawKey(session, id), { ...stored, recovered: false, aclVerified: true });
|
|
1586
|
+
}
|
|
1417
1587
|
} catch (error) {
|
|
1418
1588
|
noteShadowWarning(this.ctx?.logger, error);
|
|
1419
1589
|
}
|
|
@@ -1479,6 +1649,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1479
1649
|
warnKey = DEFAULT_KEY,
|
|
1480
1650
|
protectPaths = [],
|
|
1481
1651
|
access = {},
|
|
1652
|
+
rotationIO = {},
|
|
1482
1653
|
) {
|
|
1483
1654
|
return saveShadowImpl.call(
|
|
1484
1655
|
this,
|
|
@@ -1488,9 +1659,68 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1488
1659
|
warnKey,
|
|
1489
1660
|
protectPaths,
|
|
1490
1661
|
access,
|
|
1662
|
+
rotationIO,
|
|
1491
1663
|
);
|
|
1492
1664
|
}
|
|
1493
1665
|
|
|
1666
|
+
async saveShadowAsync(
|
|
1667
|
+
messages,
|
|
1668
|
+
required,
|
|
1669
|
+
shadowFile,
|
|
1670
|
+
warnKey = DEFAULT_KEY,
|
|
1671
|
+
protectPaths = [],
|
|
1672
|
+
access = {},
|
|
1673
|
+
rotationIO = {},
|
|
1674
|
+
) {
|
|
1675
|
+
if (!access.runner && (access.platform ?? process.platform) === "win32" && shadowAccessFused()) {
|
|
1676
|
+
const error = new Error("spine: shadow permission already failed; backup refused");
|
|
1677
|
+
error.code = "ESHADOWACCESS";
|
|
1678
|
+
throw error;
|
|
1679
|
+
}
|
|
1680
|
+
const injected = Object.values(access || {}).some((value) => typeof value === "function") ||
|
|
1681
|
+
Object.values(rotationIO || {}).some((value) => typeof value === "function");
|
|
1682
|
+
// Node's test runner can monkey-patch fs bindings in the parent isolate;
|
|
1683
|
+
// a worker cannot observe those injected failures. Keep test/fault-injection
|
|
1684
|
+
// paths synchronous while production uses the complete off-thread txn.
|
|
1685
|
+
if (injected || process.env.NODE_TEST_CONTEXT) {
|
|
1686
|
+
return this.saveShadow(messages, required, shadowFile, warnKey, protectPaths, access, rotationIO);
|
|
1687
|
+
}
|
|
1688
|
+
const shadow = shadowDirectory();
|
|
1689
|
+
const file = shadowFilePath(shadowFile, warnKey);
|
|
1690
|
+
const body = shadowFileBody(warnKey, shadowBody(messages));
|
|
1691
|
+
const cap = shadowMaxBytes();
|
|
1692
|
+
const result = await runShadowWorker({
|
|
1693
|
+
shadow,
|
|
1694
|
+
file,
|
|
1695
|
+
body,
|
|
1696
|
+
required,
|
|
1697
|
+
protectPaths,
|
|
1698
|
+
sessionKey: warnKey,
|
|
1699
|
+
cap,
|
|
1700
|
+
access,
|
|
1701
|
+
rotationIO,
|
|
1702
|
+
});
|
|
1703
|
+
if (!result?.ok) {
|
|
1704
|
+
if (result?.reason === "scan-incomplete" || result?.reason === "invalid-measurement") {
|
|
1705
|
+
this.ctx.logger?.warn?.(result.reason === "scan-incomplete"
|
|
1706
|
+
? "spine: shadow capacity scan is incomplete; backup refused; leaving the span"
|
|
1707
|
+
: "spine: shadow capacity measurement is invalid; backup refused; leaving the span");
|
|
1708
|
+
} else if (result?.rotation) {
|
|
1709
|
+
this.warnShadowFull(warnKey, shadow, result.rotation.used, cap);
|
|
1710
|
+
}
|
|
1711
|
+
return result;
|
|
1712
|
+
}
|
|
1713
|
+
if (result.rotation?.deleted > 0) {
|
|
1714
|
+
this.ctx.logger?.info?.(
|
|
1715
|
+
`spine: shadow 轮换 删除 ${result.rotation.deleted} 个文件,` +
|
|
1716
|
+
`腾出 ${result.rotation.freed} 字节,` +
|
|
1717
|
+
`用量 ${result.rotation.used + Buffer.byteLength(body)}/${cap}`,
|
|
1718
|
+
);
|
|
1719
|
+
}
|
|
1720
|
+
if (result.rotation?.oversize) this.warnShadowOversize(warnKey, cap);
|
|
1721
|
+
return result;
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1494
1724
|
async summarize(input, agent, signal) {
|
|
1495
1725
|
const decision = takeLocalCut(input?.liveCompact);
|
|
1496
1726
|
const state = this.stateFor(agent);
|
|
@@ -1500,14 +1730,14 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1500
1730
|
const card = input?.liveCompact?.card;
|
|
1501
1731
|
if (card?.needsBackup && !card.shadowFile) throw new Error("spine: omit refused; backup path missing");
|
|
1502
1732
|
const restored = this.restoreRawMessages(input.messages, state.key);
|
|
1503
|
-
this.
|
|
1733
|
+
await this.saveShadowAsync(restored, true, card?.shadowFile, state.key, card?.rawPaths || []);
|
|
1504
1734
|
if (card?.needsBackup && !existsSync(card.shadowFile)) throw new Error("spine: shadow backup was not written");
|
|
1505
1735
|
const text = backupClaimOk(restored) ? decision.text : withoutFullBackupClaim(decision.text, card?.shadowFile);
|
|
1506
1736
|
const block = { type: "text", text };
|
|
1507
1737
|
return { summary: [block], provider: "local", model: "spine-extract", rawOutput: [block] };
|
|
1508
1738
|
}
|
|
1509
1739
|
try {
|
|
1510
|
-
this.
|
|
1740
|
+
await this.saveShadowAsync(
|
|
1511
1741
|
this.restoreRawMessages(input.messages, state.key),
|
|
1512
1742
|
false,
|
|
1513
1743
|
freshShadowFile("official", state.key),
|
|
@@ -1522,7 +1752,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1522
1752
|
}
|
|
1523
1753
|
}
|
|
1524
1754
|
const joined = input.messages.map(messageText).join("\n\n");
|
|
1525
|
-
if (process.env.SPINE_THUNDER !== "1") return
|
|
1755
|
+
if (process.env.SPINE_THUNDER !== "1") return this.summarizeOfficial(input, agent, signal);
|
|
1526
1756
|
const next = joined;
|
|
1527
1757
|
const mode = decideThunder({
|
|
1528
1758
|
previous: state.lastPrefix || "",
|
|
@@ -1530,7 +1760,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1530
1760
|
modelKind: process.env.SPINE_THUNDER_MODEL === "27b" ? "27b" : "flash",
|
|
1531
1761
|
prefixTokens: state.lastPrefixTokens || 0,
|
|
1532
1762
|
});
|
|
1533
|
-
if (mode === "attach") return
|
|
1763
|
+
if (mode === "attach") return this.summarizeOfficial(input, agent, signal);
|
|
1534
1764
|
const users = input.messages.filter((message) => (message?.role ?? message?.message?.role) === "user");
|
|
1535
1765
|
const taskLine = messageText(users.at(-1) || input.messages.at(-1)).slice(0, 500);
|
|
1536
1766
|
const trunk = buildCard(joined, "", input.messages.map((message) => ({
|
|
@@ -1539,9 +1769,25 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
|
1539
1769
|
})));
|
|
1540
1770
|
const facts = [...new Set([...(trunk.hardLines || []), ...(trunk.missing || [])])];
|
|
1541
1771
|
const slim = buildSendLess({ taskLine, uncovered: trunk.uncovered, facts });
|
|
1542
|
-
return
|
|
1772
|
+
return this.summarizeOfficial({
|
|
1543
1773
|
...input,
|
|
1544
1774
|
messages: [{ role: "user", content: [{ type: "text", text: slim }] }],
|
|
1545
1775
|
}, agent, signal);
|
|
1546
1776
|
}
|
|
1777
|
+
|
|
1778
|
+
async summarizeOfficial(input, agent, signal) {
|
|
1779
|
+
const result = await super.summarize(input, agent, signal);
|
|
1780
|
+
const blocks = Array.isArray(result?.summary) ? result.summary : [];
|
|
1781
|
+
const usable = blocks.some((block) => {
|
|
1782
|
+
if (typeof block === "string") return block.trim().length > 0;
|
|
1783
|
+
if (!block || typeof block !== "object") return false;
|
|
1784
|
+
if (typeof block.text === "string" && block.text.trim()) return true;
|
|
1785
|
+
return Array.isArray(block.content) && block.content.some((part) => typeof part?.text === "string" && part.text.trim());
|
|
1786
|
+
});
|
|
1787
|
+
if (!usable) {
|
|
1788
|
+
this.ctx.logger?.warn?.("spine: official summarizer returned no usable summary; leaving the span untouched");
|
|
1789
|
+
throw new Error("spine: official summarizer returned no usable summary");
|
|
1790
|
+
}
|
|
1791
|
+
return result;
|
|
1792
|
+
}
|
|
1547
1793
|
}
|