foliko 2.0.30 → 2.0.32

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.
@@ -24,6 +24,20 @@ class ToolLoop extends EventEmitter {
24
24
  this.abortSignal = config.abortSignal || null;
25
25
  this._repairFn = config.repairToolCall || null;
26
26
  this._thinkingMode = config.thinkingMode === true;
27
+ // ★ 自愈开关:一旦服务端报"reasoning_content must be passed back",
28
+ // 置 true 后本 loop 的所有请求都强制给每条 assistant 补 reasoning_content。
29
+ // 彻底兜底掉主/子 Agent、白名单漏判、跨轮持久化丢失等一切场景。
30
+ this._forceReasoning = false;
31
+ }
32
+
33
+ /**
34
+ * 判断是否是"thinking 模式必须回传 reasoning_content"这一类服务端 400。
35
+ * 兼容不同网关的措辞。
36
+ */
37
+ _isReasoningPassbackError(err) {
38
+ const msg = (err && (err.message || err.error?.message || '')) || '';
39
+ const s = String(msg).toLowerCase();
40
+ return s.includes('reasoning_content') && (s.includes('thinking') || s.includes('passed back') || s.includes('must be'));
27
41
  }
28
42
 
29
43
  _checkAbort() {
@@ -183,6 +197,20 @@ class ToolLoop extends EventEmitter {
183
197
  const api = [];
184
198
  if (systemPrompt) api.push({ role: 'system', content: systemPrompt });
185
199
 
200
+ // ★ 动态判定是否处于 thinking 模式。
201
+ // isThinkingModel 是硬编码白名单,覆盖不到"服务端强制思考但模型名不在名单"的场景,
202
+ // 导致 this._thinkingMode=false → 不补 reasoning_content → 报
203
+ // "reasoning_content in the thinking mode must be passed back"。
204
+ // 只要本批消息里任意一条 assistant 已带 reasoning(服务端回过),就认定思考已激活,
205
+ // 给缺失的 assistant 消息(压缩摘要、上一轮纯文本答复等)补占位 reasoning_content。
206
+ const msgHasReasoning = (m) => {
207
+ if (!m || m.role !== 'assistant') return false;
208
+ if (m.reasoning_content || m.reasoning) return true;
209
+ if (Array.isArray(m.content)) return m.content.some(c => c && c.type === 'reasoning' && c.text);
210
+ return false;
211
+ };
212
+ const thinkingActive = this._thinkingMode || this._forceReasoning || messages.some(msgHasReasoning);
213
+
186
214
  for (const msg of messages) {
187
215
  if (msg.role === 'system') {
188
216
  api.push({ role: 'system', content: typeof msg.content === 'string' ? msg.content : '' });
@@ -194,24 +222,18 @@ class ToolLoop extends EventEmitter {
194
222
  api.push({ role: 'user', content: text || '' });
195
223
  }
196
224
  } else if (msg.role === 'assistant') {
197
- // ★ 检测是否有 reasoning 内容(thinking mode 必须携带)
198
- const hasReasoningInContent = Array.isArray(msg.content) && msg.content.some(p => p && p.type === 'reasoning');
199
- const hasReasoningTopLevel = msg.reasoning_content || msg.reasoning;
225
+ // ★ 提取 text + reasoning + tool_calls(兼容 content=string、content=array、顶层字段)
226
+ let text = '';
227
+ let reasoningText = '';
228
+ const toolCalls = [];
200
229
 
201
230
  if (typeof msg.content === 'string') {
202
- if (msg.tool_calls && Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) {
203
- // thinking mode 下,无 reasoning 则自动补 placeholder
204
- const reasonVal = hasReasoningTopLevel || (this._thinkingMode ? '...' : '');
205
- api.push({ role: 'assistant', content: msg.content || null, tool_calls: msg.tool_calls, ...(reasonVal ? { reasoning_content: reasonVal } : {}) });
206
- } else {
207
- api.push({ role: 'assistant', content: msg.content });
208
- }
231
+ text = msg.content;
209
232
  } else if (Array.isArray(msg.content)) {
210
- let text = '';
211
- const toolCalls = [];
212
233
  for (const part of msg.content) {
213
234
  if (!part) continue;
214
235
  if (part.type === 'text') text += part.text;
236
+ else if (part.type === 'reasoning') reasoningText += part.text || '';
215
237
  else if (part.type === 'tool-call' || part.type === 'tool-use') {
216
238
  toolCalls.push({
217
239
  id: part.toolCallId,
@@ -223,15 +245,28 @@ class ToolLoop extends EventEmitter {
223
245
  });
224
246
  }
225
247
  }
248
+ }
249
+
250
+ // 顶层 reasoning_content / reasoning 兜底(content 数组里没提取到时)
251
+ if (!reasoningText) reasoningText = msg.reasoning_content || msg.reasoning || '';
226
252
 
227
- if (toolCalls.length > 0) {
228
- const reasonVal = hasReasoningTopLevel || (this._thinkingMode ? '...' : '');
229
- api.push({ role: 'assistant', content: text || null, tool_calls: toolCalls, ...(reasonVal ? { reasoning_content: reasonVal } : {}) });
230
- } else {
231
- api.push({ role: 'assistant', content: text });
253
+ // 合并顶层 tool_calls(OpenAI 格式,来自 session 文件)
254
+ const existingIds = new Set(toolCalls.map(tc => tc.id));
255
+ if (msg.tool_calls && Array.isArray(msg.tool_calls)) {
256
+ for (const tc of msg.tool_calls) {
257
+ if (!existingIds.has(tc.id)) toolCalls.push(tc);
258
+ existingIds.add(tc.id);
232
259
  }
260
+ }
261
+
262
+ // thinking 激活时,缺失 reasoning 一律补 '...' 占位,确保被服务端接受
263
+ const reasonVal = reasoningText || (thinkingActive ? '...' : '');
264
+ const reasonField = reasonVal ? { reasoning_content: reasonVal } : {};
265
+
266
+ if (toolCalls.length > 0) {
267
+ api.push({ role: 'assistant', content: text || null, tool_calls: toolCalls, ...reasonField });
233
268
  } else {
234
- api.push({ role: 'assistant', content: String(msg.content ?? '') });
269
+ api.push({ role: 'assistant', content: text || '', ...reasonField });
235
270
  }
236
271
  } else if (msg.role === 'tool') {
237
272
  if (Array.isArray(msg.content)) {
@@ -432,6 +467,7 @@ class ToolLoop extends EventEmitter {
432
467
  let fullText = '';
433
468
  let fullReasoning = '';
434
469
  let totalUsage = null;
470
+ let finishedNaturally = false; // 是否自然结束(模型给出最终答复),用于区分 maxSteps 耗尽
435
471
 
436
472
  while (step < this.maxSteps) {
437
473
  this._checkAbort();
@@ -442,7 +478,7 @@ class ToolLoop extends EventEmitter {
442
478
  }
443
479
 
444
480
  // 2. 构建 API 请求
445
- const apiMessages = this._buildApiMessages(messages, systemPrompt);
481
+ let apiMessages = this._buildApiMessages(messages, systemPrompt);
446
482
  const apiTools = this._buildApiTools(this.tools);
447
483
  const apiOptions = this._prepareApiOptions(options);
448
484
 
@@ -456,12 +492,22 @@ class ToolLoop extends EventEmitter {
456
492
  stream: false,
457
493
  });
458
494
  } catch (apiErr) {
459
- log.error(`[ToolLoop] API call failed: ${apiErr.message}`);
460
- const recent = apiMessages.slice(-3).map(m => ({
461
- role: m.role, hasTc: !!(m.tool_calls?.length), hasReason: !!m.reasoning_content,
462
- }));
463
- log.error(`[ToolLoop] last msgs: ${JSON.stringify(recent)}`);
464
- throw apiErr;
495
+ // 自愈:服务端要求回传 reasoning_content → 置强制开关、重建消息、重试一次
496
+ if (!this._forceReasoning && this._isReasoningPassbackError(apiErr)) {
497
+ this._forceReasoning = true;
498
+ log.warn('[ToolLoop] reasoning_content required by server → force-inject & retry');
499
+ apiMessages = this._buildApiMessages(messages, systemPrompt);
500
+ response = await client.chat.completions.create({
501
+ model, messages: apiMessages, tools: apiTools, ...apiOptions, stream: false,
502
+ });
503
+ } else {
504
+ log.error(`[ToolLoop] API call failed: ${apiErr.message}`);
505
+ const recent = apiMessages.slice(-3).map(m => ({
506
+ role: m.role, hasTc: !!(m.tool_calls?.length), hasReason: !!m.reasoning_content,
507
+ }));
508
+ log.error(`[ToolLoop] last msgs: ${JSON.stringify(recent)}`);
509
+ throw apiErr;
510
+ }
465
511
  }
466
512
 
467
513
  // 3. 解析响应
@@ -482,7 +528,10 @@ class ToolLoop extends EventEmitter {
482
528
  if (reasoningContent) fullReasoning += reasoningContent;
483
529
 
484
530
  // 4. 处理工具调用
485
- if (choice.finish_reason === 'tool_calls' && message.tool_calls && message.tool_calls.length > 0) {
531
+ // 只要模型产出了 tool_calls 就执行,不依赖 finish_reason === 'tool_calls'。
532
+ // 部分 OpenAI 兼容 / reasoning 供应商即使有 tool_calls 也会返回
533
+ // finish_reason: 'stop',若严格判断会把工具调用丢弃 → 循环提前退出、任务未完成。
534
+ if (message.tool_calls && message.tool_calls.length > 0) {
486
535
  // 插入 assistant 消息(保留原格式,后面的 _processMessage 会存盘)
487
536
  const assistantMsg = {
488
537
  role: 'assistant',
@@ -505,6 +554,14 @@ class ToolLoop extends EventEmitter {
505
554
  } else {
506
555
  // 没有工具调用 → 完成
507
556
  fullText = message.content || '';
557
+ // ★ 把最终回复也写进 messages,否则持久化历史缺失助手每轮答复、
558
+ // 下一轮模型看不到自己上次说了什么(多轮记忆丢失)。
559
+ if (fullText || reasoningContent) {
560
+ const finalMsg = { role: 'assistant', content: fullText };
561
+ if (reasoningContent) finalMsg.reasoning_content = reasoningContent;
562
+ messages.push(finalMsg);
563
+ }
564
+ finishedNaturally = true;
508
565
  break;
509
566
  }
510
567
  }
@@ -515,6 +572,9 @@ class ToolLoop extends EventEmitter {
515
572
  steps: step,
516
573
  usage: totalUsage,
517
574
  reasoningText: fullReasoning || undefined,
575
+ // ★ 区分正常完成与 maxSteps 耗尽:耗尽时最后一步是工具调用、无最终答复,
576
+ // 调用方(SubAgent 等)不应把它当成功。
577
+ stopReason: finishedNaturally ? 'stop' : 'max_steps',
518
578
  };
519
579
  }
520
580
 
@@ -529,6 +589,7 @@ class ToolLoop extends EventEmitter {
529
589
  let fullText = '';
530
590
  let fullReasoning = '';
531
591
  let totalUsage = null;
592
+ let finishedNaturally = false;
532
593
 
533
594
  while (step < this.maxSteps) {
534
595
  this._checkAbort();
@@ -539,7 +600,7 @@ class ToolLoop extends EventEmitter {
539
600
  }
540
601
 
541
602
  // 2. 构建请求
542
- const apiMessages = this._buildApiMessages(messages, systemPrompt);
603
+ let apiMessages = this._buildApiMessages(messages, systemPrompt);
543
604
  const apiTools = this._buildApiTools(this.tools);
544
605
  const apiOptions = this._prepareApiOptions(options);
545
606
 
@@ -554,13 +615,24 @@ class ToolLoop extends EventEmitter {
554
615
  stream_options: { include_usage: true },
555
616
  });
556
617
  } catch (apiErr) {
557
- log.error(`[ToolLoop] stream call failed: ${apiErr.message}`);
558
- log.error(`[ToolLoop] model=${model} msgs=${apiMessages.length}`);
559
- const recent = apiMessages.slice(-3).map(m => ({
560
- role: m.role, hasTc: !!(m.tool_calls?.length), hasReason: !!m.reasoning_content,
561
- }));
562
- log.error(`[ToolLoop] last msgs: ${JSON.stringify(recent)}`);
563
- throw apiErr;
618
+ // 自愈:服务端要求回传 reasoning_content → 置强制开关、重建消息、重试一次
619
+ if (!this._forceReasoning && this._isReasoningPassbackError(apiErr)) {
620
+ this._forceReasoning = true;
621
+ log.warn('[ToolLoop] reasoning_content required by server → force-inject & retry (stream)');
622
+ apiMessages = this._buildApiMessages(messages, systemPrompt);
623
+ stream = await client.chat.completions.create({
624
+ model, messages: apiMessages, tools: apiTools, ...apiOptions,
625
+ stream: true, stream_options: { include_usage: true },
626
+ });
627
+ } else {
628
+ log.error(`[ToolLoop] stream call failed: ${apiErr.message}`);
629
+ log.error(`[ToolLoop] model=${model} msgs=${apiMessages.length}`);
630
+ const recent = apiMessages.slice(-3).map(m => ({
631
+ role: m.role, hasTc: !!(m.tool_calls?.length), hasReason: !!m.reasoning_content,
632
+ }));
633
+ log.error(`[ToolLoop] last msgs: ${JSON.stringify(recent)}`);
634
+ throw apiErr;
635
+ }
564
636
  }
565
637
 
566
638
  // 3. 处理流式响应(累计 tool_calls delta)
@@ -584,8 +656,14 @@ class ToolLoop extends EventEmitter {
584
656
  }
585
657
 
586
658
  // Reasoning content
659
+ // ★ 与 text-delta 对称:用 fullReasoning 作为"已发射长度"游标,只发新增部分。
660
+ // 否则每个 chunk 都 slice(旧游标) → 把本 step 从头到现在的 reasoning 重复发出。
587
661
  if (result.hasReasoning) {
588
- yield { type: 'thinking', text: state.fullReasoning.slice(fullReasoning.length) };
662
+ const delta = state.fullReasoning.slice(fullReasoning.length);
663
+ if (delta) {
664
+ fullReasoning += delta;
665
+ yield { type: 'thinking', text: delta };
666
+ }
589
667
  }
590
668
 
591
669
  // Text delta
@@ -605,9 +683,12 @@ class ToolLoop extends EventEmitter {
605
683
  totalUsage = state.totalUsage;
606
684
  fullReasoning = state.fullReasoning;
607
685
 
608
- const toolCalls = Object.values(toolCallAccum).filter(tc => tc.id);
686
+ const toolCalls = Object.values(toolCallAccum).filter(tc => tc.id && tc.function?.name);
609
687
 
610
- if (finishReason === 'tool_calls' && toolCalls.length > 0) {
688
+ // ★ 只要累积到有效 tool_calls 就执行,不依赖 finishReason === 'tool_calls'
689
+ // 部分供应商(尤其内联 <think> 的 reasoning 模型)即使调用工具也返回
690
+ // finish_reason: 'stop',严格判断会丢弃工具调用 → 提前退出、任务未完成。
691
+ if (toolCalls.length > 0) {
611
692
  // 插入 assistant 消息(含 tool_calls)
612
693
  const assistantMsg = { role: 'assistant', content: fullText || '', tool_calls: toolCalls };
613
694
  // ★ 保存 reasoning_content(下次 API 调用时需要传回)
@@ -624,6 +705,14 @@ class ToolLoop extends EventEmitter {
624
705
  } else {
625
706
  // 无工具调用 → 完成(与 generate() 保持一致:确保 fullText 有值)
626
707
  fullText = state.fullText || fullText;
708
+ // ★ 把最终回复也写进 messages,否则持久化历史缺失助手每轮答复、
709
+ // 下一轮模型看不到自己上次说了什么(多轮记忆丢失)。
710
+ if (fullText || fullReasoning) {
711
+ const finalMsg = { role: 'assistant', content: fullText };
712
+ if (fullReasoning) finalMsg.reasoning_content = fullReasoning;
713
+ messages.push(finalMsg);
714
+ }
715
+ finishedNaturally = true;
627
716
  break;
628
717
  }
629
718
  }
@@ -635,12 +724,18 @@ class ToolLoop extends EventEmitter {
635
724
  outputTokens: totalUsage?.completionTokens || 0,
636
725
  };
637
726
 
727
+ // ★ maxSteps 耗尽时提示调用方(区分正常完成)
728
+ if (!finishedNaturally) {
729
+ yield { type: 'max-steps', steps: step };
730
+ }
731
+
638
732
  return {
639
733
  text: fullText,
640
734
  response: { messages },
641
735
  steps: step,
642
736
  usage: totalUsage,
643
737
  reasoningText: fullReasoning || undefined,
738
+ stopReason: finishedNaturally ? 'stop' : 'max_steps',
644
739
  };
645
740
  }
646
741
  }
@@ -0,0 +1,33 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+
5
+ /**
6
+ * 原子写文件:写 tmp → fsync → rename。
7
+ *
8
+ * 直接 fs.writeFileSync 会先把目标文件截断再写;进程被杀 / 断电落在中途时,
9
+ * 磁盘上会留下被截断的半文件(甚至 0 字节),加载时按行解析要么整段丢、
10
+ * 要么把坏行静默跳过 → 静默丢数据。
11
+ *
12
+ * rename 在同一文件系统上是原子的:崩溃时要么看到旧文件、要么看到完整新文件,
13
+ * 不会出现半截。fsync 进一步保证内容真正落盘(防 rename 先于数据落盘的重排)。
14
+ *
15
+ * @param {string} filePath 目标路径
16
+ * @param {string|Buffer} content 完整内容
17
+ */
18
+ function atomicWriteFileSync(filePath, content) {
19
+ const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
20
+ let fd;
21
+ try {
22
+ fd = fs.openSync(tmp, 'w');
23
+ fs.writeFileSync(fd, content);
24
+ try { fs.fsyncSync(fd); } catch { /* 某些文件系统/平台不支持 fsync,忽略 */ }
25
+ } finally {
26
+ if (fd !== undefined) {
27
+ try { fs.closeSync(fd); } catch { /* ignore */ }
28
+ }
29
+ }
30
+ fs.renameSync(tmp, filePath);
31
+ }
32
+
33
+ module.exports = { atomicWriteFileSync };
@@ -194,9 +194,10 @@ class ContextCompressor {
194
194
  this._compactionSettings = config.compactionSettings || DEFAULT_COMPACTION_SETTINGS;
195
195
 
196
196
  this._compressionCount = 0;
197
- this._compressionInProgress = false;
198
- this._compressionPromise = null;
199
- this._compressionTimeoutId = null;
197
+ // 按 sessionId 隔离在途压缩:旧实现用单个全局 promise/flag,
198
+ // session B 在 A 压缩进行中调用会拿到 A 的 promise、对着 A 的 messages → 串味。
199
+ this._compressionPromises = new Map(); // sessionId -> 在途 promise
200
+ this._compressionTimeouts = new Map(); // sessionId -> setTimeout 句柄
200
201
  this._maxToolResultSize = config.maxToolResultSize || 4000;
201
202
  this._tokenCounter = new TokenCounter();
202
203
  }
@@ -213,20 +214,22 @@ class ContextCompressor {
213
214
  }
214
215
 
215
216
  async compress(sessionId, messages, messageStore) {
216
- if (this._compressionInProgress && this._compressionPromise) {
217
- logger.debug('Compression already in progress, waiting...');
218
- return this._compressionPromise;
217
+ // 同一 session 的并发压缩复用同一 promise;不同 session 互不干扰。
218
+ const existing = this._compressionPromises.get(sessionId);
219
+ if (existing) {
220
+ logger.debug(`Compression already in progress for ${sessionId}, waiting...`);
221
+ return existing;
219
222
  }
220
223
 
221
224
  if (messages.length <= this._keepRecentMessages) return;
222
225
 
223
- this._compressionInProgress = true;
224
- this._compressionPromise = this._executeWithTimeout(sessionId, messages, messageStore).finally(() => {
225
- this._compressionInProgress = false;
226
- this._compressionPromise = null;
227
- if (this._compressionTimeoutId) { clearTimeout(this._compressionTimeoutId); this._compressionTimeoutId = null; }
226
+ const p = this._executeWithTimeout(sessionId, messages, messageStore).finally(() => {
227
+ this._compressionPromises.delete(sessionId);
228
+ const t = this._compressionTimeouts.get(sessionId);
229
+ if (t) { clearTimeout(t); this._compressionTimeouts.delete(sessionId); }
228
230
  });
229
- return this._compressionPromise;
231
+ this._compressionPromises.set(sessionId, p);
232
+ return p;
230
233
  }
231
234
 
232
235
  async _executeWithTimeout(sessionId, messages, messageStore) {
@@ -234,10 +237,11 @@ class ContextCompressor {
234
237
  return await Promise.race([
235
238
  this._doCompress(sessionId, messages, messageStore),
236
239
  new Promise((_, reject) => {
237
- this._compressionTimeoutId = setTimeout(() => {
238
- this._compressionTimeoutId = null;
240
+ const tid = setTimeout(() => {
241
+ this._compressionTimeouts.delete(sessionId);
239
242
  reject(new Error(`Compression timeout (${COMPRESSION_TIMEOUT_MS}ms)`));
240
243
  }, COMPRESSION_TIMEOUT_MS);
244
+ this._compressionTimeouts.set(sessionId, tid);
241
245
  }),
242
246
  ]);
243
247
  } catch (err) {
@@ -247,9 +251,9 @@ class ContextCompressor {
247
251
  }
248
252
 
249
253
  cancelCompression() {
250
- if (this._compressionTimeoutId) { clearTimeout(this._compressionTimeoutId); this._compressionTimeoutId = null; }
251
- this._compressionInProgress = false;
252
- this._compressionPromise = null;
254
+ for (const t of this._compressionTimeouts.values()) clearTimeout(t);
255
+ this._compressionTimeouts.clear();
256
+ this._compressionPromises.clear();
253
257
  }
254
258
 
255
259
  async _doCompress(sessionId, messages, messageStore) {
@@ -390,7 +394,7 @@ class ContextCompressor {
390
394
  }
391
395
 
392
396
  getStats() {
393
- return { compressionCount: this._compressionCount, inProgress: this._compressionInProgress };
397
+ return { compressionCount: this._compressionCount, inProgress: this._compressionPromises.size > 0 };
394
398
  }
395
399
  }
396
400
 
@@ -1076,7 +1076,25 @@ class Framework extends EventEmitter {
1076
1076
  }
1077
1077
  if (!manager) {
1078
1078
  const { SessionManager } = require('../session/session');
1079
- manager = new SessionManager(cwd, sessionDir, sessionFile, true);
1079
+ const fs = require('fs');
1080
+ try {
1081
+ manager = new SessionManager(cwd, sessionDir, sessionFile, true);
1082
+ } catch (err) {
1083
+ // ★ 恢复分支同样可能因坏文件在构造时(_loadEntriesFromFile)抛错。
1084
+ // 之前这里不在 try 内 → 直接崩。改为:隔离坏文件后重新新建,最后兜底内存 session。
1085
+ this.logger.warn(`[Framework] Session '${sessionId}' file unreadable (${err.message}); quarantining and starting fresh`);
1086
+ try {
1087
+ if (fs.existsSync(sessionFile)) fs.renameSync(sessionFile, `${sessionFile}.corrupt-${Date.now()}`);
1088
+ } catch (e) {
1089
+ this.logger.warn(`[Framework] quarantine failed: ${e.message}`);
1090
+ }
1091
+ try {
1092
+ manager = new SessionManager(cwd, sessionDir, sessionFile, true);
1093
+ } catch (e2) {
1094
+ this.logger.warn(`[Framework] fresh session create failed: ${e2.message}; using in-memory`);
1095
+ manager = SessionManager.inMemory(cwd);
1096
+ }
1097
+ }
1080
1098
  }
1081
1099
  this._sessionContexts.set(sessionId, manager);
1082
1100
  this.touchSession(sessionId);
@@ -594,8 +594,18 @@ class PluginManager {
594
594
  try {
595
595
  const stateFile = this._getStateFile();
596
596
  if (fs.existsSync(stateFile)) {
597
- this._stateCache = safeJsonParse(fs.readFileSync(stateFile, 'utf-8'), {});
598
- return this._stateCache;
597
+ const raw = fs.readFileSync(stateFile, 'utf-8');
598
+ if (raw.trim() === '') { this._stateCache = {}; return this._stateCache; }
599
+ try {
600
+ this._stateCache = JSON.parse(raw);
601
+ return this._stateCache;
602
+ } catch (parseErr) {
603
+ // ★ 坏文件不能静默当空对象 → 否则下次 _saveState 覆盖它、
604
+ // 插件启停/配置全丢。先隔离坏文件保留现场,再以默认值启动。
605
+ this._log.error(`Plugin state file corrupt (${parseErr.message}); quarantining`);
606
+ try { fs.renameSync(stateFile, `${stateFile}.corrupt-${Date.now()}`); }
607
+ catch (e) { this._log.error('quarantine state file failed:', e.message); }
608
+ }
599
609
  }
600
610
  } catch (err) {
601
611
  this._log.error('Failed to load state:', err.message);
@@ -7,6 +7,7 @@
7
7
  const fs = require('fs');
8
8
  const path = require('path');
9
9
  const { JsonlSessionStorage } = require('../storage/jsonl');
10
+ const { atomicWriteFileSync } = require('../common/atomic-write');
10
11
  const {
11
12
  EntryTypes,
12
13
  generateEntryId,
@@ -202,9 +203,9 @@ class SessionManager {
202
203
 
203
204
  _rewriteFile() {
204
205
  if (!this.persist || !this.sessionFile) return;
205
- // 性能优化:单次写入代替多次追加
206
+ // 性能优化:单次写入代替多次追加。原子写避免崩溃时留下半截文件。
206
207
  const content = this.fileEntries.map(e => JSON.stringify(e)).join('\n') + '\n';
207
- fs.writeFileSync(this.sessionFile, content);
208
+ atomicWriteFileSync(this.sessionFile, content);
208
209
  }
209
210
 
210
211
  _persist(entry) {
@@ -221,7 +222,7 @@ class SessionManager {
221
222
  // 性能优化:使用批量写入代替多次追加
222
223
  if (!this.flushed) {
223
224
  const batch = this.fileEntries.map(e => `${JSON.stringify(e)}\n`).join('');
224
- fs.writeFileSync(this.sessionFile, batch);
225
+ atomicWriteFileSync(this.sessionFile, batch);
225
226
  this.flushed = true;
226
227
  } else {
227
228
  fs.appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
@@ -440,7 +441,11 @@ class SessionManager {
440
441
  * Build the session context (what gets sent to the LLM)
441
442
  */
442
443
  buildSessionContext() {
443
- return buildSessionContext(this.getEntries(), this.leafId, this.byId);
444
+ // 走活跃分支(leafId 到根的路径),而不是把所有分支的 entries 拍平。
445
+ // 否则被丢弃分支的消息会混进上下文,compaction/model 也可能取到废弃分支的值。
446
+ // entry.js 的 buildSessionContext 只接收单个数组参数,之前多传的 leafId/byId 被静默忽略。
447
+ const pathEntries = this.leafId ? this.getBranch(this.leafId) : this.getEntries();
448
+ return buildSessionContext(pathEntries);
444
449
  }
445
450
 
446
451
  /**
@@ -6,6 +6,7 @@
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
8
  const { generateEntryId } = require('../common/id');
9
+ const { atomicWriteFileSync } = require('../common/atomic-write');
9
10
 
10
11
  class StorageEntry {
11
12
  constructor(key, value, timestamp) {
@@ -94,7 +95,8 @@ class StorageManager {
94
95
  _rewrite() {
95
96
  if (!this.persist || !this.filePath) return;
96
97
  const lines = this.allEntries.map(e => JSON.stringify(e)).join('\n') + '\n';
97
- fs.writeFileSync(this.filePath, lines);
98
+ // 原子写:compaction 全量重写时崩溃不会留下半截文件(否则内存已"压缩"、磁盘却残缺)。
99
+ atomicWriteFileSync(this.filePath, lines);
98
100
  }
99
101
 
100
102
  /**