dsh-live-compact 0.0.0-stage → 1.0.7
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/LICENSE +21 -0
- package/README.md +212 -2
- package/bin/dsh-live-compact.js +26 -0
- package/cordis.patch.yml +18 -0
- package/gate.js +114 -0
- package/index.js +713 -0
- package/package.json +62 -4
- package/plan.js +1013 -0
- package/raw.js +162 -0
- package/shadow-search.js +134 -0
- package/shadow.js +251 -0
- package/shell.js +137 -0
- package/thunder.js +56 -0
package/index.js
ADDED
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 边做边压 / Live Compact。替换宿主的压缩后端,不改前缀字节。
|
|
3
|
+
* Replacement compaction backend. Do not rewrite the cached prefix.
|
|
4
|
+
*
|
|
5
|
+
* 注释说明来自 UnwhiteCat 的 PR #3,按当前行为改写。
|
|
6
|
+
* Notes follow UnwhiteCat's PR #3, rewritten for the current behavior.
|
|
7
|
+
*
|
|
8
|
+
* 小切在轮次结束后做,不调模型。最近 3 个已结束的工具轮留在原处。
|
|
9
|
+
* 本地切点不越过最后一条用户消息。云端最新一段不足 1000 时才跨较早的用户消息。
|
|
10
|
+
* 最后一条用户消息不进切段。不够 1000 token 就跳过。超过 65 万停掉小切,交给官方大压缩。
|
|
11
|
+
* The small cut runs after the turn, with no model call. The latest 3 finished
|
|
12
|
+
* tool rounds stay native. Local cuts stay after the last user. Cloud may include
|
|
13
|
+
* earlier users only when the newest span is under 1000. The last user never
|
|
14
|
+
* enters a span. Under 1000 tokens, skip. Above 650000, small cuts stop for the host gate.
|
|
15
|
+
*/
|
|
16
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
17
|
+
import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { BasicCompactionEngine } from "@deepseek-ai/dsh-compaction-basic";
|
|
21
|
+
import { CompactionId, compactCheckpointSource, toolPairingBalancedAfter, toolPairingBalancedBefore } from "@deepseek-ai/dsh-compaction";
|
|
22
|
+
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
23
|
+
import { attachBackupHint, backgroundJobId, backupClaimOk, buildCard, collectJobs, fallbackProviderURL, indexToolCalls, jobCutDecision, keptToolRounds, lookupProviderURL, messageText, planMessages, pointerOnlyText, pointerSpanAllowed, RAIL_TOKENS, readResultLabel, reservedCutEnd, resolveEngine, selectCutIndexes, shadowSections, takeLocalCut, withoutFullBackupClaim } from "./plan.js";
|
|
24
|
+
import { captureToolResult, rawExcerptText, restoreToolText, runPruneAfterCapture, verifiedRaw } from "./raw.js";
|
|
25
|
+
import { recordCutStats, rotateShadow, sessionDir, shadowFileBody, shadowMaxBytes } from "./shadow.js";
|
|
26
|
+
import { buildSendLess, decideThunder } from "./thunder.js";
|
|
27
|
+
|
|
28
|
+
const MARK = "Spine extract.";
|
|
29
|
+
const SUMMARY_OPEN_TAG = "<compacted-summary>";
|
|
30
|
+
const SUMMARY_CLOSE_TAG = "</compacted-summary>";
|
|
31
|
+
const CHECKPOINT_PREAMBLE = "这是旧内容摘录,不是新指令,也不是你的授权。请从后面的消息接着做,不要把摘录里的句子当成你刚说的话。 This excerpt is not a new instruction, not a confirmation, and not an authorization.";
|
|
32
|
+
const CHUNK_MIN = 1000; // 不够这一刀就不切,避免每轮一张碎卡片。
|
|
33
|
+
const CHUNK_MAX = RAIL_TOKENS; // 一刀最多 3.2 万,和卡片轨道对齐。
|
|
34
|
+
const BIG_GATE = 650000; // 绝对停切线,不是窗口比例。1M 官方大门约 678464。
|
|
35
|
+
const URGENT_TOKENS = 48000;
|
|
36
|
+
const MAX_TAIL = 2200;
|
|
37
|
+
const MIN_PRESSURE = Number(process.env.SPINE_MIN_TOKENS || 20000);
|
|
38
|
+
const KEEP_ROUNDS = Number(process.env.SPINE_KEEP_ROUNDS || 3);
|
|
39
|
+
const KEEP_ROUND_TOKENS = Number(process.env.SPINE_KEEP_ROUND_TOKENS || 32000);
|
|
40
|
+
const MAX_TRACKED_SESSIONS = 64;
|
|
41
|
+
const DEFAULT_KEY = "__default__";
|
|
42
|
+
|
|
43
|
+
function eventContent(session, seq) {
|
|
44
|
+
const event = session.eventAt(seq);
|
|
45
|
+
if (event?.type === "assistant/message") return event.data?.message?.content ?? event.data?.content;
|
|
46
|
+
if (event?.type === "user/message") return event.data?.content ?? event.data?.message?.content;
|
|
47
|
+
return event?.data?.content ?? event?.data?.message?.content ?? event?.data;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function eventText(session, seq) {
|
|
51
|
+
const event = session.eventAt(seq);
|
|
52
|
+
if (event?.type === "user/message") return messageText(event.data);
|
|
53
|
+
if (event?.type === "assistant/message") return messageText(event.data?.message);
|
|
54
|
+
return messageText(event?.data);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isCheckpoint(session, seq) {
|
|
58
|
+
const event = session.eventAt(seq);
|
|
59
|
+
if (event?.data?.source?.kind === "compact-checkpoint") return true;
|
|
60
|
+
return eventText(session, seq).includes(MARK);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function eventRole(session, seq) {
|
|
64
|
+
const type = session.eventAt(seq)?.type;
|
|
65
|
+
if (type === "user/message") return "user";
|
|
66
|
+
if (type === "assistant/message") return "assistant";
|
|
67
|
+
if (type === "tool/result") return "tool";
|
|
68
|
+
return "other";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isUser(session, seq) {
|
|
72
|
+
return session.eventAt(seq)?.type === "user/message";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function cutOk(fn, session, seq) {
|
|
76
|
+
try {
|
|
77
|
+
return fn(session, seq);
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function shadowDirectory() {
|
|
84
|
+
if (process.env.SPINE_SHADOW_DIR) return process.env.SPINE_SHADOW_DIR;
|
|
85
|
+
const root = process.env.DSH_HOME || join(homedir(), ".dsh");
|
|
86
|
+
return join(root, "compaction-spine", "shadow");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function sessionKey(agent) {
|
|
90
|
+
const id = agent?.session?.id;
|
|
91
|
+
return typeof id === "string" && id.length > 0 ? id : DEFAULT_KEY;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function tightenShadow(dir) {
|
|
95
|
+
try {
|
|
96
|
+
chmodSync(dir, 0o700);
|
|
97
|
+
} catch {}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function shadowBody(messages) {
|
|
101
|
+
return (messages || []).map((message) => shadowSections(message)).join("\n\n");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function freshShadowFile(kind, key) {
|
|
105
|
+
const dir = sessionDir(shadowDirectory(), key);
|
|
106
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
107
|
+
return join(dir, `${kind}-${Date.now()}-${randomBytes(4).toString("hex")}.txt`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function commandFromCall(event) {
|
|
111
|
+
if (event?.type !== "tool/call") return "";
|
|
112
|
+
let args = event.data?.arguments;
|
|
113
|
+
if (typeof args === "string") {
|
|
114
|
+
try {
|
|
115
|
+
args = JSON.parse(args);
|
|
116
|
+
} catch {
|
|
117
|
+
args = {};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const command = args?.command || args?.cmd || event.data?.name || "";
|
|
121
|
+
return String(command || "").split("\n")[0].trim();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function shadowFilePath(requested, key) {
|
|
125
|
+
if (requested) return requested;
|
|
126
|
+
return freshShadowFile("shadow", key);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 从后往前选一刀。最后一条用户消息不进切段。云端最新一段不足 1000 时,较早的用户消息可以进卡片。
|
|
130
|
+
// 工具对必须成对。最近 KEEP_ROUNDS 个已结束工具轮不进这刀。不够 CHUNK_MIN 就返回 null。
|
|
131
|
+
// 单个超过 CHUNK_MAX 的工具结果只留指针,并连同对应 tool-call 一起替换。
|
|
132
|
+
// 这段里不能有最后一条用户消息或 checkpoint。
|
|
133
|
+
// Pick one span from the tail. The last user stays out. Cloud may card earlier users
|
|
134
|
+
// only when the newest span is under 1000. Tool pairs stay balanced.
|
|
135
|
+
// The newest KEEP_ROUNDS finished tool rounds stay out of the cut. Under CHUNK_MIN, return null.
|
|
136
|
+
// One oversized tool result becomes a pointer and is replaced together with its tool-call.
|
|
137
|
+
// That span must not contain the last user or a checkpoint.
|
|
138
|
+
function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}) {
|
|
139
|
+
const decision = jobCutDecision(context.jobs || []);
|
|
140
|
+
if (!decision.cut) return null;
|
|
141
|
+
const nodes = session.surface.nodes;
|
|
142
|
+
const priced = measurement.nodes;
|
|
143
|
+
if (nodes.length === 0 || nodes.length !== priced.length) return null;
|
|
144
|
+
const first = session.eventAt(nodes[0])?.type === "system/message" ? 1 : 0;
|
|
145
|
+
let end = priced.length - 1;
|
|
146
|
+
while (end >= first) {
|
|
147
|
+
const event = session.eventAt(nodes[end]);
|
|
148
|
+
const shortTail = event?.type === "assistant/message" || (event?.type === "user/message" && priced[end].tokens < 400);
|
|
149
|
+
if (!shortTail) break;
|
|
150
|
+
end -= 1;
|
|
151
|
+
}
|
|
152
|
+
while (end >= first && isCheckpoint(session, nodes[end])) end -= 1;
|
|
153
|
+
while (end >= first && !cutOk(toolPairingBalancedAfter, session, nodes[end])) end -= 1;
|
|
154
|
+
let keptTokens = 0;
|
|
155
|
+
const visible = [];
|
|
156
|
+
for (let index = first; index <= end; index += 1) {
|
|
157
|
+
const event = session.eventAt(nodes[index]);
|
|
158
|
+
const toolCalls = event?.type === "assistant/message"
|
|
159
|
+
? (event.data?.message?.content || []).filter((block) => block.type === "tool-call").length
|
|
160
|
+
: 0;
|
|
161
|
+
visible.push({ index, type: event?.type === "tool/result" ? "tool" : "other", toolCalls });
|
|
162
|
+
}
|
|
163
|
+
const rounds = keptToolRounds(visible, KEEP_ROUNDS);
|
|
164
|
+
const span = rounds.length ? { start: rounds[0].start, end: rounds[rounds.length - 1].end } : null;
|
|
165
|
+
const cutEnd = span ? reservedCutEnd(visible[span.start].index, first) : null;
|
|
166
|
+
if (span && cutEnd === null) return null;
|
|
167
|
+
if (cutEnd !== null) {
|
|
168
|
+
const keptEnd = visible[span.end].index;
|
|
169
|
+
for (let index = visible[span.start].index; index <= keptEnd; index += 1) keptTokens += priced[index].tokens;
|
|
170
|
+
end = cutEnd;
|
|
171
|
+
while (end >= first && (isCheckpoint(session, nodes[end]) || isUser(session, nodes[end]))) end -= 1;
|
|
172
|
+
while (end >= first && !cutOk(toolPairingBalancedAfter, session, nodes[end])) end -= 1;
|
|
173
|
+
}
|
|
174
|
+
if (end < first || isCheckpoint(session, nodes[end])) return null;
|
|
175
|
+
const spanPieces = (spanStart, spanEnd) => {
|
|
176
|
+
const pieces = [];
|
|
177
|
+
let pendingCommand = "";
|
|
178
|
+
const callSeqs = new Map();
|
|
179
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
180
|
+
const event = session.eventAt(nodes[index]);
|
|
181
|
+
const command = commandFromCall(event);
|
|
182
|
+
if (command) pendingCommand = command;
|
|
183
|
+
for (const block of event?.data?.message?.content || []) {
|
|
184
|
+
if (block?.type === "tool-call" && block.id) callSeqs.set(block.id, nodes[index]);
|
|
185
|
+
}
|
|
186
|
+
if (index < spanStart || index > spanEnd) continue;
|
|
187
|
+
const toolCallId = event?.data?.message?.toolCallId || "";
|
|
188
|
+
const call = toolCallId ? context.calls?.get?.(toolCallId) : null;
|
|
189
|
+
const isTool = event?.type === "tool/result";
|
|
190
|
+
const looked = isTool && context.rawLookup && toolCallId ? (context.rawLookup(toolCallId) || { ok: false, toolCallId }) : null;
|
|
191
|
+
const excerpt = isTool ? rawExcerptText(eventText(session, nodes[index]), looked) : { text: eventText(session, nodes[index]) };
|
|
192
|
+
const text = excerpt.text;
|
|
193
|
+
pieces.push({
|
|
194
|
+
role: eventRole(session, nodes[index]),
|
|
195
|
+
text,
|
|
196
|
+
isError: Boolean(event?.data?.message?.isError || event?.data?.isError),
|
|
197
|
+
command: call?.command || (event?.type === "tool/result" ? pendingCommand : ""),
|
|
198
|
+
name: call?.name || "",
|
|
199
|
+
toolCallId,
|
|
200
|
+
seq: nodes[index],
|
|
201
|
+
callSeq: toolCallId ? callSeqs.get(toolCallId) : undefined,
|
|
202
|
+
turn: Number.isInteger(event?.data?.turn) ? event.data.turn : call?.turn,
|
|
203
|
+
backgroundStart: event?.type === "tool/result" && Boolean(backgroundJobId(text)),
|
|
204
|
+
raw: looked ? (excerpt.raw?.ok ? excerpt.raw : { ok: false, toolCallId }) : undefined,
|
|
205
|
+
});
|
|
206
|
+
if (event?.type === "tool/result") pendingCommand = "";
|
|
207
|
+
}
|
|
208
|
+
return pieces;
|
|
209
|
+
};
|
|
210
|
+
const spanText = (spanStart, spanEnd) => spanPieces(spanStart, spanEnd).map((piece) => piece.text).join("\n");
|
|
211
|
+
const liveText = (spanStart, spanEnd) => {
|
|
212
|
+
const bits = [];
|
|
213
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
214
|
+
if (index >= spanStart && index <= spanEnd) continue;
|
|
215
|
+
bits.push(eventText(session, nodes[index]));
|
|
216
|
+
}
|
|
217
|
+
return bits.join("\n");
|
|
218
|
+
};
|
|
219
|
+
const extras = { jobs: context.jobs || [] };
|
|
220
|
+
const items = nodes.map((seq, index) => {
|
|
221
|
+
const event = session.eventAt(seq);
|
|
222
|
+
const type = event?.type === "user/message" ? "user"
|
|
223
|
+
: event?.type === "assistant/message" ? "assistant"
|
|
224
|
+
: event?.type === "tool/result" ? "tool"
|
|
225
|
+
: event?.type === "system/message" ? "system" : "other";
|
|
226
|
+
return {
|
|
227
|
+
type,
|
|
228
|
+
tokens: priced[index].tokens,
|
|
229
|
+
checkpoint: isCheckpoint(session, seq),
|
|
230
|
+
balancedBefore: cutOk(toolPairingBalancedBefore, session, seq),
|
|
231
|
+
balancedAfter: cutOk(toolPairingBalancedAfter, session, seq),
|
|
232
|
+
};
|
|
233
|
+
});
|
|
234
|
+
const picked = selectCutIndexes(items, {
|
|
235
|
+
engine: context.engine || "cloud",
|
|
236
|
+
chunkMin: CHUNK_MIN,
|
|
237
|
+
chunkMax: CHUNK_MAX,
|
|
238
|
+
maxEnd: end,
|
|
239
|
+
});
|
|
240
|
+
if (!picked) return null;
|
|
241
|
+
if (picked.pointerOnly) {
|
|
242
|
+
const result = session.eventAt(nodes[picked.end]);
|
|
243
|
+
const id = result?.data?.message?.toolCallId;
|
|
244
|
+
let pairStart = -1;
|
|
245
|
+
if (id) {
|
|
246
|
+
for (let index = picked.end - 1; index >= 0; index -= 1) {
|
|
247
|
+
const blocks = session.eventAt(nodes[index])?.data?.message?.content || [];
|
|
248
|
+
if (blocks.some((block) => block?.type === "tool-call" && block.id === id)) {
|
|
249
|
+
pairStart = index;
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (pairStart < 0) return null;
|
|
255
|
+
if (!pointerSpanAllowed(items, pairStart, picked.end, context.engine || "cloud")) return null;
|
|
256
|
+
if (!cutOk(toolPairingBalancedBefore, session, nodes[pairStart])) return null;
|
|
257
|
+
picked.start = pairStart;
|
|
258
|
+
}
|
|
259
|
+
const start = picked.start;
|
|
260
|
+
end = picked.end;
|
|
261
|
+
const tokens = items.slice(start, end + 1).reduce((sum, item) => sum + item.tokens, 0);
|
|
262
|
+
let card = picked.pointerOnly
|
|
263
|
+
? { ok: true, text: pointerOnlyText(spanPieces(start, end)), hardLines: [], missing: [], needsBackup: false, rawPaths: [] }
|
|
264
|
+
: buildCard(spanText(start, end), liveText(start, end), spanPieces(start, end), extras);
|
|
265
|
+
if (card.ok !== true) return null;
|
|
266
|
+
card = attachBackupHint(card, freshShadowFile("cut", key));
|
|
267
|
+
if (!card.text.includes("不是备份文件路径")) card = { ...card, text: `${card.text}\n序号是本会话日志里的事件号,不是备份文件路径。` };
|
|
268
|
+
if (!cutOk(toolPairingBalancedAfter, session, nodes[end])) return null;
|
|
269
|
+
let after = 0;
|
|
270
|
+
for (let index = end + 1; index < priced.length; index += 1) after += priced[index].tokens;
|
|
271
|
+
let prefix = 0;
|
|
272
|
+
for (let index = 0; index < start; index += 1) prefix += priced[index].tokens;
|
|
273
|
+
return { start: nodes[start], end: nodes[end], tokens, after, keptTokens, card, prefix };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export { planMessages };
|
|
277
|
+
|
|
278
|
+
// 只换「切哪一段、怎么换成卡片」。官方大门仍由宿主算。
|
|
279
|
+
// Changes which span is cut and how it becomes a card. The host still owns the stock gate.
|
|
280
|
+
export default class SpineCompactionEngine extends BasicCompactionEngine {
|
|
281
|
+
// Per-session state. One engine instance can serve several agents, so nothing
|
|
282
|
+
// here may live on `this` directly or one agent's card/prefix leaks into another.
|
|
283
|
+
sessionState = new Map();
|
|
284
|
+
shadowFullWarned = new Set();
|
|
285
|
+
rawSeen = new Map();
|
|
286
|
+
pruneGuarded = false;
|
|
287
|
+
|
|
288
|
+
constructor(ctx, config) {
|
|
289
|
+
if (process.env.SPINE_ENGINE_FAIL === "construct") throw new Error("spine: constructor refused");
|
|
290
|
+
super(ctx, config);
|
|
291
|
+
if (process.env.SPINE_ENGINE_FAIL === "after-super") throw new Error("spine: after super");
|
|
292
|
+
try {
|
|
293
|
+
const pendingCuts = new Set();
|
|
294
|
+
ctx.effect(() => async () => {
|
|
295
|
+
await Promise.allSettled(pendingCuts);
|
|
296
|
+
});
|
|
297
|
+
ctx.on("session/event", (session, event) => {
|
|
298
|
+
if (event?.type !== "tool/result") return;
|
|
299
|
+
this.captureOne(session, event);
|
|
300
|
+
});
|
|
301
|
+
ctx.on("agent/status", ({ agent, status }) => {
|
|
302
|
+
if (status !== "idle") return;
|
|
303
|
+
const state = this.stateFor(agent);
|
|
304
|
+
if (state.warming) return;
|
|
305
|
+
const done = agent.session.snapshotEvents().some((event) => event.type === "turn/end" && event.data?.reason?.kind === "completed");
|
|
306
|
+
if (!done) return;
|
|
307
|
+
state.warming = true;
|
|
308
|
+
const job = this.finishTurn(agent).finally(() => {
|
|
309
|
+
state.warming = false;
|
|
310
|
+
pendingCuts.delete(job);
|
|
311
|
+
});
|
|
312
|
+
pendingCuts.add(job);
|
|
313
|
+
});
|
|
314
|
+
} catch (error) {
|
|
315
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
316
|
+
ctx.logger?.warn?.(`spine: 本会话未接管自动小切。${message}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
stateFor(agent) {
|
|
321
|
+
const key = sessionKey(agent);
|
|
322
|
+
let state = this.sessionState.get(key);
|
|
323
|
+
if (state) {
|
|
324
|
+
this.sessionState.delete(key);
|
|
325
|
+
this.sessionState.set(key, state);
|
|
326
|
+
return state;
|
|
327
|
+
}
|
|
328
|
+
state = {
|
|
329
|
+
key,
|
|
330
|
+
liveAgent: null,
|
|
331
|
+
warming: false,
|
|
332
|
+
localOnly: false,
|
|
333
|
+
approvedCard: null,
|
|
334
|
+
lastPrefix: "",
|
|
335
|
+
lastPrefixTokens: 0,
|
|
336
|
+
};
|
|
337
|
+
this.sessionState.set(key, state);
|
|
338
|
+
if (this.sessionState.size > MAX_TRACKED_SESSIONS) {
|
|
339
|
+
for (const [oldKey, old] of this.sessionState) {
|
|
340
|
+
if (this.sessionState.size <= MAX_TRACKED_SESSIONS) break;
|
|
341
|
+
if (oldKey === key || old.warming || old.localOnly) continue;
|
|
342
|
+
this.sessionState.delete(oldKey);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return state;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
chunkPlan(session, chunk) {
|
|
349
|
+
const messages = [];
|
|
350
|
+
let on = false;
|
|
351
|
+
for (const seq of session.surface.nodes) {
|
|
352
|
+
if (seq === chunk.start) on = true;
|
|
353
|
+
if (!on) continue;
|
|
354
|
+
messages.push({ role: eventRole(session, seq), content: eventText(session, seq) });
|
|
355
|
+
if (seq === chunk.end) break;
|
|
356
|
+
}
|
|
357
|
+
return planMessages(messages);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async finishTurn(agent) {
|
|
361
|
+
const state = this.stateFor(agent);
|
|
362
|
+
const signal = AbortSignal.timeout(30000);
|
|
363
|
+
try {
|
|
364
|
+
await agent.runMaintenance(async (agentSignal) => {
|
|
365
|
+
const op = AbortSignal.any([agentSignal, signal]);
|
|
366
|
+
const measurement = this.ctx.tokenMeter.measure(agent.session);
|
|
367
|
+
if (measurement.totalTokens < MIN_PRESSURE || measurement.totalTokens >= BIG_GATE) return;
|
|
368
|
+
this.ensurePruneGuard();
|
|
369
|
+
this.captureSession(agent.session);
|
|
370
|
+
const events = agent.session.snapshotEvents();
|
|
371
|
+
const resolved = this.engineFor(agent);
|
|
372
|
+
if (state.engine !== resolved.engine || state.engineSource !== resolved.source) {
|
|
373
|
+
this.ctx.logger?.info?.(`spine engine=${resolved.engine} source=${resolved.source}`);
|
|
374
|
+
state.engine = resolved.engine;
|
|
375
|
+
state.engineSource = resolved.source;
|
|
376
|
+
}
|
|
377
|
+
const chunk = selectNewestChunk(agent.session, measurement, state.key, {
|
|
378
|
+
engine: resolved.engine,
|
|
379
|
+
jobs: collectJobs(events),
|
|
380
|
+
calls: indexToolCalls(events),
|
|
381
|
+
rawLookup: (id) => this.rawSeen.get(`${state.key}\0${id}`),
|
|
382
|
+
});
|
|
383
|
+
if (chunk === null) {
|
|
384
|
+
this.ctx.logger?.debug?.("spine cut skipped");
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
const tailBeyondKept = chunk.after - (chunk.keptTokens || 0);
|
|
388
|
+
if (tailBeyondKept > MAX_TAIL && measurement.totalTokens < URGENT_TOKENS) return;
|
|
389
|
+
const before = measurement.totalTokens;
|
|
390
|
+
const started = Date.now();
|
|
391
|
+
let reduced = false;
|
|
392
|
+
try {
|
|
393
|
+
state.localOnly = true;
|
|
394
|
+
state.approvedCard = chunk.card;
|
|
395
|
+
await this.commitSelectedSpan(agent, chunk, op);
|
|
396
|
+
const after = this.ctx.tokenMeter.measure(agent.session).totalTokens;
|
|
397
|
+
reduced = after < before;
|
|
398
|
+
const saved = before - after;
|
|
399
|
+
const stats = saved > 0 ? recordCutStats(shadowDirectory(), { saved, rewrite: Math.max(0, after - (chunk.prefix || 0)) }) : null;
|
|
400
|
+
const total = stats ? stats.savedTokens : "unknown";
|
|
401
|
+
this.ctx.logger?.info?.(`spine cut ${Date.now() - started}ms ${before} -> ${after} chunk=${chunk.tokens} saved=${saved} total=${total}`);
|
|
402
|
+
} catch (error) {
|
|
403
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
404
|
+
this.ctx.logger?.warn?.(`spine cut failed: ${message}`);
|
|
405
|
+
}
|
|
406
|
+
if (!reduced) {
|
|
407
|
+
this.ctx.logger?.warn?.(`spine cut did not reduce ${before}; leaving the prefix for the gate`);
|
|
408
|
+
}
|
|
409
|
+
await this.ctx.sessions?.flush?.(agent.session);
|
|
410
|
+
this.rememberPrefix(agent);
|
|
411
|
+
});
|
|
412
|
+
} catch (error) {
|
|
413
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
414
|
+
this.ctx.logger?.warn?.(`spine after-turn skipped: ${message}`);
|
|
415
|
+
} finally {
|
|
416
|
+
state.localOnly = false;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async commitSelectedSpan(agent, chunk, signal) {
|
|
421
|
+
signal?.throwIfAborted();
|
|
422
|
+
const session = agent.session;
|
|
423
|
+
const nodes = session.surface.nodes;
|
|
424
|
+
const startIdx = nodes.indexOf(chunk.start);
|
|
425
|
+
const endIdx = nodes.indexOf(chunk.end);
|
|
426
|
+
if (startIdx < 0 || endIdx < startIdx) throw new Error("spine: selected span is not on the surface");
|
|
427
|
+
if (chunk.card?.ok !== true || !chunk.card.text) throw new Error("spine: approved card missing; leaving the span");
|
|
428
|
+
const shadowedSeqs = nodes.slice(startIdx, endIdx + 1);
|
|
429
|
+
const messages = [];
|
|
430
|
+
for (const seq of shadowedSeqs) {
|
|
431
|
+
const event = session.eventAt(seq);
|
|
432
|
+
messages.push({
|
|
433
|
+
role: eventRole(session, seq),
|
|
434
|
+
content: eventContent(session, seq),
|
|
435
|
+
toolCallId: event?.data?.message?.toolCallId || "",
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
const summarized = await this.summarize({ messages, liveCompact: { card: chunk.card } }, agent, signal);
|
|
439
|
+
const compactionId = CompactionId(randomUUID());
|
|
440
|
+
const checkpointMessage = createUserMessage({
|
|
441
|
+
content: [
|
|
442
|
+
{ type: "text", text: `${CHECKPOINT_PREAMBLE}\n\n${SUMMARY_OPEN_TAG}` },
|
|
443
|
+
...summarized.summary,
|
|
444
|
+
{ type: "text", text: SUMMARY_CLOSE_TAG },
|
|
445
|
+
],
|
|
446
|
+
source: compactCheckpointSource(compactionId),
|
|
447
|
+
});
|
|
448
|
+
const framedTokens = this.ctx.tokenMeter.estimateMessage?.(checkpointMessage);
|
|
449
|
+
if (typeof framedTokens === "number" && framedTokens >= chunk.tokens) {
|
|
450
|
+
throw new Error(`summary is not smaller than the shadowed content (${framedTokens} >= ${chunk.tokens})`);
|
|
451
|
+
}
|
|
452
|
+
const lifecycle = { compactionId, turn: null };
|
|
453
|
+
let started = false;
|
|
454
|
+
let replaced = false;
|
|
455
|
+
let ended = false;
|
|
456
|
+
try {
|
|
457
|
+
const startEvent = session.append("compaction/start", lifecycle);
|
|
458
|
+
started = true;
|
|
459
|
+
const summaryEvent = session.append("compaction/summary", {
|
|
460
|
+
compactionId,
|
|
461
|
+
summary: summarized.summary,
|
|
462
|
+
...summarized.rawOutput === undefined ? {} : { rawOutput: summarized.rawOutput },
|
|
463
|
+
shadowedRange: { start: chunk.start, end: chunk.end },
|
|
464
|
+
shadowedSeqs: [...shadowedSeqs],
|
|
465
|
+
shadowedTokenCount: chunk.tokens,
|
|
466
|
+
provider: summarized.provider,
|
|
467
|
+
model: summarized.model,
|
|
468
|
+
});
|
|
469
|
+
session.append("user/message", checkpointMessage, {
|
|
470
|
+
surfaceOp: { op: "replace", startSeq: chunk.start, endSeq: chunk.end },
|
|
471
|
+
sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs],
|
|
472
|
+
});
|
|
473
|
+
replaced = true;
|
|
474
|
+
session.append("compaction/end", lifecycle);
|
|
475
|
+
ended = true;
|
|
476
|
+
await this.ctx.sessions.flush(session);
|
|
477
|
+
} catch (error) {
|
|
478
|
+
if (started && !ended) {
|
|
479
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
480
|
+
try {
|
|
481
|
+
session.append("compaction/end", replaced ? lifecycle : { ...lifecycle, error: message });
|
|
482
|
+
} catch {}
|
|
483
|
+
}
|
|
484
|
+
if (!replaced) throw error;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
rememberPrefix(agent) {
|
|
489
|
+
const state = this.stateFor(agent);
|
|
490
|
+
try {
|
|
491
|
+
const messages = agent.session.deriveMessages();
|
|
492
|
+
state.lastPrefix = (messages || []).map(messageText).join("\n");
|
|
493
|
+
state.lastPrefixTokens = this.ctx.tokenMeter.measure(agent.session).totalTokens;
|
|
494
|
+
} catch {}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async compactIfNeeded(agent, trigger, signal) {
|
|
498
|
+
const state = this.stateFor(agent);
|
|
499
|
+
state.liveAgent = agent;
|
|
500
|
+
state.localOnly = false;
|
|
501
|
+
this.ensurePruneGuard();
|
|
502
|
+
return super.compactIfNeeded(agent, trigger, signal);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
engineFor(agent) {
|
|
506
|
+
const header = agent?.session?.requestHeader?.()?.config || {};
|
|
507
|
+
const context = agent?.session?.requestContext?.() || {};
|
|
508
|
+
const provider = header.provider || context.provider || "";
|
|
509
|
+
let baseURL = header.baseURL || context.baseURL || "";
|
|
510
|
+
if (!baseURL) {
|
|
511
|
+
try {
|
|
512
|
+
const listed = this.ctx.llm?.listProviders?.() || this.ctx.get?.("llm")?.listProviders?.() || [];
|
|
513
|
+
baseURL = lookupProviderURL(provider, listed);
|
|
514
|
+
} catch {}
|
|
515
|
+
}
|
|
516
|
+
if (!baseURL) baseURL = fallbackProviderURL(provider);
|
|
517
|
+
return resolveEngine({ provider, baseURL });
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
rawKey(session, toolCallId) {
|
|
521
|
+
return `${session?.id || DEFAULT_KEY}\0${toolCallId}`;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
captureOne(session, event) {
|
|
525
|
+
const id = event?.data?.message?.toolCallId;
|
|
526
|
+
if (!id) return null;
|
|
527
|
+
const key = session?.id || DEFAULT_KEY;
|
|
528
|
+
const mapKey = this.rawKey(session, id);
|
|
529
|
+
if (this.rawSeen.get(mapKey)?.ok) return this.rawSeen.get(mapKey);
|
|
530
|
+
let call = null;
|
|
531
|
+
try {
|
|
532
|
+
call = indexToolCalls(session.snapshotEvents?.() || []).get(id);
|
|
533
|
+
} catch {}
|
|
534
|
+
try {
|
|
535
|
+
const dir = sessionDir(shadowDirectory(), key);
|
|
536
|
+
try {
|
|
537
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
538
|
+
writeFileSync(join(dir, "owner"), String(process.pid), { mode: 0o600 });
|
|
539
|
+
} catch {}
|
|
540
|
+
const record = captureToolResult({
|
|
541
|
+
dir,
|
|
542
|
+
toolCallId: id,
|
|
543
|
+
text: messageText(event.data?.message),
|
|
544
|
+
turn: event.data?.turn,
|
|
545
|
+
name: call?.name || "",
|
|
546
|
+
pathLabel: readResultLabel(call?.name, call?.filePath, event.data?.turn),
|
|
547
|
+
});
|
|
548
|
+
this.rawSeen.set(mapKey, record);
|
|
549
|
+
return record;
|
|
550
|
+
} catch (error) {
|
|
551
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
552
|
+
this.ctx?.logger?.warn?.(`spine: raw backup failed: ${message}`);
|
|
553
|
+
const record = { ok: false, reason: "unwritable", toolCallId: id };
|
|
554
|
+
this.rawSeen.set(mapKey, record);
|
|
555
|
+
return record;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
captureSession(session) {
|
|
560
|
+
const seen = new Set();
|
|
561
|
+
for (const event of session?.snapshotEvents?.() || []) {
|
|
562
|
+
if (event?.type !== "tool/result") continue;
|
|
563
|
+
const id = event.data?.message?.toolCallId;
|
|
564
|
+
if (!id || seen.has(id)) continue;
|
|
565
|
+
seen.add(id);
|
|
566
|
+
this.captureOne(session, event);
|
|
567
|
+
}
|
|
568
|
+
return seen;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
surfaceToolIds(session) {
|
|
572
|
+
const ids = [];
|
|
573
|
+
for (const seq of session?.surface?.nodes || []) {
|
|
574
|
+
const event = session.eventAt?.(seq);
|
|
575
|
+
if (event?.type !== "tool/result") continue;
|
|
576
|
+
const id = event.data?.message?.toolCallId;
|
|
577
|
+
if (id) ids.push(id);
|
|
578
|
+
}
|
|
579
|
+
return ids;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
rawVerifiedSet(session) {
|
|
583
|
+
const prefix = `${session?.id || DEFAULT_KEY}\0`;
|
|
584
|
+
const ids = new Set();
|
|
585
|
+
for (const [mapKey, record] of this.rawSeen) {
|
|
586
|
+
if (!mapKey.startsWith(prefix)) continue;
|
|
587
|
+
if (verifiedRaw(record).ok) ids.add(mapKey.slice(prefix.length));
|
|
588
|
+
}
|
|
589
|
+
return ids;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
ensurePruneGuard() {
|
|
593
|
+
if (this.pruneGuarded) return;
|
|
594
|
+
let pruner;
|
|
595
|
+
try {
|
|
596
|
+
pruner = this.ctx.get("toolResultPruner");
|
|
597
|
+
} catch {
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
if (!pruner?.pruneSession) return;
|
|
601
|
+
pruner.__spineEngine = this;
|
|
602
|
+
if (pruner.pruneSession.__spineGuarded) {
|
|
603
|
+
this.pruneGuarded = true;
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
const original = pruner.pruneSession.bind(pruner);
|
|
607
|
+
function guarded(session) {
|
|
608
|
+
const engine = pruner.__spineEngine;
|
|
609
|
+
engine.captureSession(session);
|
|
610
|
+
return runPruneAfterCapture(engine.surfaceToolIds(session), engine.rawVerifiedSet(session), () => original(session));
|
|
611
|
+
}
|
|
612
|
+
guarded.__spineGuarded = true;
|
|
613
|
+
pruner.pruneSession = guarded;
|
|
614
|
+
this.pruneGuarded = true;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
restoreRawMessages(messages, key) {
|
|
618
|
+
return (messages || []).map((message) => {
|
|
619
|
+
const id = message?.toolCallId || message?.message?.toolCallId;
|
|
620
|
+
if (!id) return message;
|
|
621
|
+
return restoreToolText(message, this.rawSeen.get(`${key}\0${id}`));
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
warnShadowFull(key, shadow, used, cap) {
|
|
626
|
+
const id = key || DEFAULT_KEY;
|
|
627
|
+
if (this.shadowFullWarned.has(id)) return;
|
|
628
|
+
this.shadowFullWarned.add(id);
|
|
629
|
+
this.ctx.logger?.warn?.(`spine: shadow ${shadow} 已用 ${used},上限 ${cap}。删不掉旧备份,这一刀不切。`);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
warnShadowOversize(key, cap) {
|
|
633
|
+
const id = key || DEFAULT_KEY;
|
|
634
|
+
if (this.shadowFullWarned.has(`oversize:${id}`)) return;
|
|
635
|
+
this.shadowFullWarned.add(`oversize:${id}`);
|
|
636
|
+
this.ctx.logger?.warn?.(`spine: 这一份备份超过上限 ${cap},已写下,其他可删备份已清。`);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
saveShadow(messages, required, shadowFile, warnKey = DEFAULT_KEY, protectPaths = []) {
|
|
640
|
+
const shadow = shadowDirectory();
|
|
641
|
+
mkdirSync(shadow, { recursive: true, mode: 0o700 });
|
|
642
|
+
tightenShadow(shadow);
|
|
643
|
+
const file = shadowFilePath(shadowFile, warnKey);
|
|
644
|
+
const dir = sessionDir(shadow, warnKey);
|
|
645
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
646
|
+
writeFileSync(join(dir, "owner"), String(process.pid), { mode: 0o600 });
|
|
647
|
+
const body = shadowFileBody(warnKey, shadowBody(messages));
|
|
648
|
+
const incoming = Buffer.byteLength(body);
|
|
649
|
+
const cap = shadowMaxBytes();
|
|
650
|
+
const rotation = rotateShadow({
|
|
651
|
+
dir: shadow,
|
|
652
|
+
incomingBytes: incoming,
|
|
653
|
+
protectPath: file,
|
|
654
|
+
protectPaths,
|
|
655
|
+
sessionKey: warnKey,
|
|
656
|
+
cap,
|
|
657
|
+
});
|
|
658
|
+
if (!rotation.ok) {
|
|
659
|
+
this.warnShadowFull(warnKey, shadow, rotation.used, cap);
|
|
660
|
+
if (required) throw new Error("spine: shadow backup is full; leaving the span");
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
if (rotation.deleted > 0) {
|
|
664
|
+
this.ctx.logger?.info?.(`spine: shadow 轮换 删除 ${rotation.deleted} 个文件,腾出 ${rotation.freed} 字节,用量 ${rotation.used + incoming}/${cap}`);
|
|
665
|
+
}
|
|
666
|
+
if (rotation.oversize) this.warnShadowOversize(warnKey, cap);
|
|
667
|
+
writeFileSync(file, body, { mode: 0o600 });
|
|
668
|
+
chmodSync(file, 0o600);
|
|
669
|
+
if (!existsSync(file)) throw new Error("spine: shadow backup was not written");
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
async summarize(input, agent, signal) {
|
|
673
|
+
const decision = takeLocalCut(input?.liveCompact);
|
|
674
|
+
const state = this.stateFor(agent);
|
|
675
|
+
state.approvedCard = null;
|
|
676
|
+
if (decision.kind === "refuse") throw new Error(decision.reason);
|
|
677
|
+
if (decision.kind === "local") {
|
|
678
|
+
const card = input?.liveCompact?.card;
|
|
679
|
+
if (card?.needsBackup && !card.shadowFile) throw new Error("spine: omit refused; backup path missing");
|
|
680
|
+
const restored = this.restoreRawMessages(input.messages, state.key);
|
|
681
|
+
this.saveShadow(restored, true, card?.shadowFile, state.key, card?.rawPaths || []);
|
|
682
|
+
if (card?.needsBackup && !existsSync(card.shadowFile)) throw new Error("spine: shadow backup was not written");
|
|
683
|
+
const text = backupClaimOk(restored) ? decision.text : withoutFullBackupClaim(decision.text, card?.shadowFile);
|
|
684
|
+
const block = { type: "text", text };
|
|
685
|
+
return { summary: [block], provider: "local", model: "spine-extract", rawOutput: [block] };
|
|
686
|
+
}
|
|
687
|
+
try {
|
|
688
|
+
this.saveShadow(this.restoreRawMessages(input.messages, state.key), false, freshShadowFile("official", state.key), state.key);
|
|
689
|
+
} catch {}
|
|
690
|
+
const joined = input.messages.map(messageText).join("\n\n");
|
|
691
|
+
if (process.env.SPINE_THUNDER !== "1") return super.summarize(input, agent, signal);
|
|
692
|
+
const next = joined;
|
|
693
|
+
const mode = decideThunder({
|
|
694
|
+
previous: state.lastPrefix || "",
|
|
695
|
+
next,
|
|
696
|
+
modelKind: process.env.SPINE_THUNDER_MODEL === "27b" ? "27b" : "flash",
|
|
697
|
+
prefixTokens: state.lastPrefixTokens || 0,
|
|
698
|
+
});
|
|
699
|
+
if (mode === "attach") return super.summarize(input, agent, signal);
|
|
700
|
+
const users = input.messages.filter((message) => (message?.role ?? message?.message?.role) === "user");
|
|
701
|
+
const taskLine = messageText(users.at(-1) || input.messages.at(-1)).slice(0, 500);
|
|
702
|
+
const trunk = buildCard(joined, "", input.messages.map((message) => ({
|
|
703
|
+
role: message?.role ?? message?.message?.role,
|
|
704
|
+
text: messageText(message),
|
|
705
|
+
})));
|
|
706
|
+
const facts = [...new Set([...(trunk.hardLines || []), ...(trunk.missing || [])])];
|
|
707
|
+
const slim = buildSendLess({ taskLine, uncovered: trunk.uncovered, facts });
|
|
708
|
+
return super.summarize({
|
|
709
|
+
...input,
|
|
710
|
+
messages: [{ role: "user", content: [{ type: "text", text: slim }] }],
|
|
711
|
+
}, agent, signal);
|
|
712
|
+
}
|
|
713
|
+
}
|