dsh-live-compact 1.1.1 → 1.1.2

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