foliko 2.0.26 → 2.0.28

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "foliko",
3
- "version": "2.0.26",
3
+ "version": "2.0.28",
4
4
  "description": "简约的插件化 Agent 框架",
5
5
  "main": "src/index.js",
6
6
  "type": "commonjs",
package/src/agent/chat.js CHANGED
@@ -718,44 +718,31 @@ class AgentChatHandler extends EventEmitter {
718
718
  async *chatStream(message, options = {}) {
719
719
  const sessionId = options.sessionId || this.getDefaultSessionId();
720
720
  const framework = this.agent.framework;
721
- // ★ 提到外层作用域,catch 中要回滚 userMessage 时需要访问
722
721
  let messageStore = null;
723
722
  let messages = null;
724
723
  let userMessage = null;
724
+ let didRetryWithCompression = false;
725
725
 
726
- try {
727
- const prepared = await this._prepareSession(message, sessionId);
728
- messageStore = prepared.messageStore;
729
- messages = prepared.messages;
730
- userMessage = prepared.userMessage;
731
-
732
- if (!this._aiClient) {
733
- throw new Error('AI 客户端未配置');
734
- }
735
- const systemPrompt = framework.prompts.build();
736
- //await fs.promises.writeFile(`.${sessionId}_systemPrompt.md`, systemPrompt); // 调试用:保存系统提示词
737
- const tools = this._getToolMap();
726
+ // 内部异步函数执行真正的流式处理,便于压缩重试时重启
727
+ const doStream = async function* (self, sessionId, message, messageStore, messages, userMessage) {
728
+ const tools = self._getToolMap();
738
729
  const toolLoop = new ToolLoop({
739
730
  tools,
740
- maxSteps: this._maxSteps || 1000,
741
- prepareStep: this._createPrepareStep(),
742
- repairToolCall: this._repairToolCall.bind(this),
731
+ maxSteps: self._maxSteps || 1000,
732
+ prepareStep: self._createPrepareStep(),
733
+ repairToolCall: self._repairToolCall.bind(self),
743
734
  abortSignal: framework.getAbortSignal?.(),
744
- thinkingMode: this._thinkingMode,
735
+ thinkingMode: self._thinkingMode,
745
736
  });
746
737
 
747
- // DeepSeek thinking mode: 收集 reasoning_content
748
- const isThinking = this._thinkingMode;
749
738
  let reasoningContent = '';
750
-
751
739
  const stream = toolLoop.stream({
752
740
  messages,
753
- systemPrompt,
754
- model: this.model,
755
- client: this._aiClient,
756
- ...this.providerOptions,
741
+ systemPrompt: framework.prompts.build(),
742
+ model: self.model,
743
+ client: self._aiClient,
744
+ ...self.providerOptions,
757
745
  });
758
- // 遍历流式响应
759
746
  let fullText = '';
760
747
  let usageRecv = {};
761
748
 
@@ -778,67 +765,94 @@ class AgentChatHandler extends EventEmitter {
778
765
  yield { type: 'usage', inputTokens: usageRecv.inputTokens, outputTokens: usageRecv.outputTokens };
779
766
  }
780
767
  }
768
+
781
769
  // 持久化用量
782
770
  messageStore.usage = usageRecv;
783
-
784
- // 同时通过 framework 事件广播(绕开 queue 事件链路)
785
- if (this.agent && this.agent.framework) {
786
- this.agent.framework.emit('agent:usage', {
787
- sessionId,
788
- usage: usageRecv,
789
- });
771
+ if (self.agent && self.agent.framework) {
772
+ self.agent.framework.emit('agent:usage', { sessionId, usage: usageRecv });
790
773
  }
791
774
 
792
- // yield usage 数据,让 UI 层能直接获取
793
- // (usage 已在 stream 循环中 yield,无需重复)
794
-
795
775
  const userMsg = messages.length > 0 ? messages[messages.length - 1] : null;
796
- this.emit('message', { content: fullText, sessionId: sessionId, userMessage: userMsg });
776
+ self.emit('message', { content: fullText, sessionId, userMessage: userMsg });
797
777
 
798
- // ★ 修复:成功路径才持久化。AI 失败时若 finally 也 save,
799
- // 会把刚 push 的 userMessage 写盘 → 下次请求 loadHistory 再次
800
- // load 到,_prepareSession 再 push 一份 → 连续两条相同 user
801
- // → AI SDK 抛 "Invalid prompt: ModelMessage[] schema"
802
778
  try {
803
779
  await messageStore.save();
804
780
  } catch (err) {
805
781
  logger.error(`[${sessionId}] Failed to save message store: ${err.message}`);
806
782
  }
807
- } catch (err) {
808
- // ★ 修复:失败时打印 InvalidPromptError 的 cause(含 Zod issue 路径),
809
- // 方便定位是哪条消息、哪个字段不匹配 ModelMessage[] schema
810
- logger.error('[AgentChat] chatStream error:', err.message);
811
- if (err.cause) {
812
- const cause = err.cause;
813
- if (cause.issues) {
814
- for (const issue of cause.issues.slice(0, 5)) {
815
- logger.error(
816
- `[AgentChat] schema issue: path=${JSON.stringify(issue.path)} code=${issue.code} message=${issue.message}`
817
- );
818
- }
783
+ }.bind(null, this);
784
+
785
+ // 压缩重试循环
786
+ for (let attempt = 0; attempt <= 1; attempt++) {
787
+ try {
788
+ // 首次尝试前做 prepareSession;压缩重试时跳过
789
+ if (attempt === 0) {
790
+ const prepared = await this._prepareSession(message, sessionId);
791
+ messageStore = prepared.messageStore;
792
+ messages = prepared.messages;
793
+ userMessage = prepared.userMessage;
819
794
  } else {
820
- logger.error(`[AgentChat] error cause: ${cause.message || String(cause)}`);
795
+ // 重试前重新 build 系统提示词
796
+ this._systemPrompt = this.agent._buildSystemPrompt();
797
+ this._invalidateMessageTokensCache(messageStore);
821
798
  }
822
- }
823
799
 
824
- // 修复:失败时 ROLLBACK 掉本轮 push 的 userMessage
825
- try {
826
- if (messages && userMessage) {
827
- const last = messages[messages.length - 1];
828
- if (last && last.role === 'user' && last === userMessage) {
829
- messages.pop();
800
+ if (!this._aiClient) {
801
+ throw new Error('AI 客户端未配置');
802
+ }
803
+
804
+ // 执行流式生成
805
+ for await (const chunk of doStream(this, sessionId, message, messageStore, messages, userMessage)) {
806
+ yield chunk;
807
+ }
808
+ return; // 成功完成
809
+ } catch (err) {
810
+ // ★ 上下文超限:压缩后重试一次
811
+ if (!didRetryWithCompression && this._isContextOverflowError(err)) {
812
+ logger.warn(`[AgentChat] chatStream context overflow (attempt ${attempt + 1}), compressing and retrying...`);
813
+ try {
814
+ await this._compressContext(sessionId, messages, messageStore);
815
+ this._invalidateMessageTokensCache(messageStore);
816
+ logger.info(`[AgentChat] Compression done, retrying stream...`);
817
+ didRetryWithCompression = true;
818
+ continue; // 重试
819
+ } catch (compressErr) {
820
+ logger.error(`[AgentChat] Compression failed: ${compressErr.message}`);
830
821
  }
831
822
  }
832
- } catch { /* ignore */ }
833
823
 
834
- // 统一错误处理:提取简洁消息并通过 yield 传递
835
- const friendlyMessage = err?.message?.split('\n')[0] || '未知错误';
824
+ // 打印错误
825
+ logger.error('[AgentChat] chatStream error:', err.message);
826
+ if (err.cause) {
827
+ const cause = err.cause;
828
+ if (cause.issues) {
829
+ for (const issue of cause.issues.slice(0, 5)) {
830
+ logger.error(
831
+ `[AgentChat] schema issue: path=${JSON.stringify(issue.path)} code=${issue.code} message=${issue.message}`
832
+ );
833
+ }
834
+ } else {
835
+ logger.error(`[AgentChat] error cause: ${cause.message || String(cause)}`);
836
+ }
837
+ }
836
838
 
837
- // 用户主动中断(framework.stop())不上报 message:error,避免与上游重复
838
- if (err.name !== 'AbortError') {
839
- this.emit('message:error', { sessionId, error: friendlyMessage, originalError: err });
839
+ // 回滚 userMessage
840
+ try {
841
+ if (messages && userMessage) {
842
+ const last = messages[messages.length - 1];
843
+ if (last && last.role === 'user' && last === userMessage) {
844
+ messages.pop();
845
+ }
846
+ }
847
+ } catch { /* ignore */ }
848
+
849
+ const friendlyMessage = err?.message?.split('\n')[0] || '未知错误';
850
+ if (err.name !== 'AbortError') {
851
+ this.emit('message:error', { sessionId, error: friendlyMessage, originalError: err });
852
+ }
853
+ yield { type: 'error', error: friendlyMessage };
854
+ return; // 失败返回
840
855
  }
841
- yield { type: 'error', error: friendlyMessage };
842
856
  }
843
857
  }
844
858
 
@@ -891,109 +905,132 @@ class AgentChatHandler extends EventEmitter {
891
905
  let messageStore = null;
892
906
  let messages = null;
893
907
  let userMessage = null;
908
+ let didRetryWithCompression = false;
894
909
 
895
- try {
896
- const prepared = await this._prepareSession(message, sessionId);
897
- messageStore = prepared.messageStore;
898
- messages = prepared.messages;
899
- userMessage = prepared.userMessage;
910
+ // 最多压缩重试 1 次
911
+ const maxCompressionRetries = 1;
900
912
 
901
- if (!this._aiClient) {
902
- throw new Error('AI 客户端未配置');
903
- }
904
- const systemPrompt = framework.prompts.build();
905
- const tools = this._getToolMap();
913
+ for (let attempt = 0; attempt <= maxCompressionRetries; attempt++) {
914
+ try {
915
+ // 首次尝试前做 prepareSession;压缩重试时跳过(已压缩过)
916
+ if (attempt === 0) {
917
+ const prepared = await this._prepareSession(message, sessionId);
918
+ messageStore = prepared.messageStore;
919
+ messages = prepared.messages;
920
+ userMessage = prepared.userMessage;
921
+ } else {
922
+ // 压缩重试前重新 build 系统提示词(压缩后 messages 变了)
923
+ this._systemPrompt = this.agent._buildSystemPrompt();
924
+ this._invalidateMessageTokensCache(messageStore);
925
+ }
906
926
 
907
- // DeepSeek thinking mode: 处理参数
908
- const apiOptions = { ...this.providerOptions };
909
- if (this._thinkingMode) {
910
- delete apiOptions.temperature;
911
- }
927
+ if (!this._aiClient) {
928
+ throw new Error('AI 客户端未配置');
929
+ }
930
+ const systemPrompt = framework.prompts.build();
931
+ const tools = this._getToolMap();
912
932
 
913
- const toolLoop = new ToolLoop({
914
- tools,
915
- maxSteps: this._maxSteps || 1000,
916
- prepareStep: this._createPrepareStep(),
917
- repairToolCall: this._repairToolCall.bind(this),
918
- abortSignal: framework.getAbortSignal?.(),
919
- thinkingMode: this._thinkingMode,
920
- });
933
+ // DeepSeek thinking mode: 处理参数
934
+ const apiOptions = { ...this.providerOptions };
935
+ if (this._thinkingMode) {
936
+ delete apiOptions.temperature;
937
+ }
921
938
 
922
- const result = await toolLoop.generate({
923
- messages,
924
- systemPrompt,
925
- model: this.model,
926
- client: this._aiClient,
927
- ...apiOptions,
928
- });
929
- // result = { text, response: { messages }, steps, usage, reasoningText }
939
+ const toolLoop = new ToolLoop({
940
+ tools,
941
+ maxSteps: this._maxSteps || 1000,
942
+ prepareStep: this._createPrepareStep(),
943
+ repairToolCall: this._repairToolCall.bind(this),
944
+ abortSignal: framework.getAbortSignal?.(),
945
+ thinkingMode: this._thinkingMode,
946
+ });
930
947
 
931
- if (result.usage && (result.usage.promptTokens > 0 || result.usage.completionTokens > 0)) {
932
- this._updateMessageStoreUsage(messageStore, result.usage);
933
- } else {
934
- const inputTokens = this._tokenCounter.countMessages(messages);
935
- const outputTokens = this._tokenCounter.countText(result.text || '');
936
- messageStore.usage = { promptTokens: inputTokens, completionTokens: outputTokens };
937
- }
948
+ const result = await toolLoop.generate({
949
+ messages,
950
+ systemPrompt,
951
+ model: this.model,
952
+ client: this._aiClient,
953
+ ...apiOptions,
954
+ });
955
+ // result = { text, response: { messages }, steps, usage, reasoningText }
938
956
 
939
- // ToolLoop 已经直接在 messages 末尾添加了新消息,不需要再 push
940
- // 计算 original 的 user message
941
- const userMsg = result.text
942
- ? messages.slice(-1)[0]
943
- : messages[messages.length - 1];
944
- this.emit('message', { content: result.text, sessionId: sessionId, userMessage: userMsg });
945
-
946
- // ★ 修复:AI 调用成功后才持久化(之前 finally 总是 save,
947
- // 会把刚 push 的 userMessage 写盘;下次请求 loadHistory 再次 load 到,
948
- // _prepareSession 又 push 一份 → 连续两条相同 user 消息 →
949
- // AI SDK 抛 "Invalid prompt: ModelMessage[] schema")
950
- try {
951
- await messageStore.save();
952
- } catch (err) {
953
- logger.error(`[${sessionId}] Failed to save message store: ${err.message}`);
954
- }
957
+ if (result.usage && (result.usage.promptTokens > 0 || result.usage.completionTokens > 0)) {
958
+ this._updateMessageStoreUsage(messageStore, result.usage);
959
+ } else {
960
+ const inputTokens = this._tokenCounter.countMessages(messages);
961
+ const outputTokens = this._tokenCounter.countText(result.text || '');
962
+ messageStore.usage = { promptTokens: inputTokens, completionTokens: outputTokens };
963
+ }
955
964
 
956
- return {
957
- success: true,
958
- message: cleanResponse(result.text || ''),
959
- stepCount: result.stepCount || 1,
960
- };
961
- } catch (err) {
962
- // ★ 修复:失败时打印 InvalidPromptError 的 cause(含 Zod issue 路径),
963
- // 方便定位是哪条消息、哪个字段不匹配 ModelMessage[] schema
964
- logger.error('[AgentChat] _processMessage error:', err.message);
965
- if (err.cause) {
966
- const cause = err.cause;
967
- if (cause.issues) {
968
- // ZodError:逐条打印
969
- for (const issue of cause.issues.slice(0, 5)) {
970
- logger.error(
971
- `[AgentChat] schema issue: path=${JSON.stringify(issue.path)} code=${issue.code} message=${issue.message}`
972
- );
965
+ // ToolLoop 已经直接在 messages 末尾添加了新消息,不需要再 push
966
+ // 计算 original 的 user message
967
+ const userMsg = result.text
968
+ ? messages.slice(-1)[0]
969
+ : messages[messages.length - 1];
970
+ this.emit('message', { content: result.text, sessionId: sessionId, userMessage: userMsg });
971
+
972
+ // 修复:AI 调用成功后才持久化
973
+ try {
974
+ await messageStore.save();
975
+ } catch (err) {
976
+ logger.error(`[${sessionId}] Failed to save message store: ${err.message}`);
977
+ }
978
+
979
+ return {
980
+ success: true,
981
+ message: cleanResponse(result.text || ''),
982
+ stepCount: result.stepCount || 1,
983
+ };
984
+ } catch (err) {
985
+ // ★ 上下文超限:压缩后重试一次
986
+ if (!didRetryWithCompression && this._isContextOverflowError(err)) {
987
+ logger.warn(`[AgentChat] Context overflow (attempt ${attempt + 1}), compressing and retrying...`);
988
+ try {
989
+ // 强制压缩(忽略阈值检查)
990
+ await this._compressContext(sessionId, messages, messageStore);
991
+ this._invalidateMessageTokensCache(messageStore);
992
+ logger.info(`[AgentChat] Compression done, retrying...`);
993
+ didRetryWithCompression = true;
994
+ continue; // 重试
995
+ } catch (compressErr) {
996
+ logger.error(`[AgentChat] Compression failed: ${compressErr.message}`);
997
+ // 压缩失败,继续走下方错误处理
973
998
  }
974
- } else {
975
- logger.error(`[AgentChat] error cause: ${cause.message || String(cause)}`);
976
999
  }
977
- }
978
- const errorMsg = err.message || String(err);
979
1000
 
980
- // ★ 修复:失败时 ROLLBACK 掉本轮 push 的 userMessage,
981
- // 避免下次请求把它从 jsonl load 出来再 push 一遍
982
- try {
983
- if (messages && userMessage) {
984
- const last = messages[messages.length - 1];
985
- if (last && last.role === 'user' && last === userMessage) {
986
- messages.pop();
1001
+ // 打印错误信息
1002
+ logger.error('[AgentChat] _processMessage error:', err.message);
1003
+ if (err.cause) {
1004
+ const cause = err.cause;
1005
+ if (cause.issues) {
1006
+ for (const issue of cause.issues.slice(0, 5)) {
1007
+ logger.error(
1008
+ `[AgentChat] schema issue: path=${JSON.stringify(issue.path)} code=${issue.code} message=${issue.message}`
1009
+ );
1010
+ }
1011
+ } else {
1012
+ logger.error(`[AgentChat] error cause: ${cause.message || String(cause)}`);
987
1013
  }
988
1014
  }
989
- } catch { /* ignore */ }
1015
+ const errorMsg = err.message || String(err);
990
1016
 
991
- return {
992
- success: false,
993
- message: 'AI 服务暂时不可用,请稍后重试。',
994
- error: errorMsg,
995
- stepCount: 0,
996
- };
1017
+ // ★ 失败时 ROLLBACK 掉本轮 push 的 userMessage
1018
+ try {
1019
+ if (messages && userMessage) {
1020
+ const last = messages[messages.length - 1];
1021
+ if (last && last.role === 'user' && last === userMessage) {
1022
+ messages.pop();
1023
+ }
1024
+ }
1025
+ } catch { /* ignore */ }
1026
+
1027
+ return {
1028
+ success: false,
1029
+ message: 'AI 服务暂时不可用,请稍后重试。',
1030
+ error: errorMsg,
1031
+ stepCount: 0,
1032
+ };
1033
+ }
997
1034
  }
998
1035
  }
999
1036
 
@@ -1028,16 +1065,16 @@ class AgentChatHandler extends EventEmitter {
1028
1065
  const totalTokens = cachedMsgTokens + toolsTokens + systemPromptTokens;
1029
1066
  const limit = this._maxContextTokens * SIMPLE_COMPRESS_TOKEN_RATIO;
1030
1067
 
1031
- // logger.info(`BEFORE: messages=${messages.length}, tokens=${totalTokens}/${limit}`);
1068
+ logger.info(`[_prepareSession] BEFORE: messages=${messages.length}, tokens=${totalTokens}/${limit}, threshold=${this._compressionMessageThreshold}`);
1032
1069
 
1033
1070
  if (totalTokens > limit || messages.length > this._compressionMessageThreshold) {
1034
- // logger.info(
1035
- // `Context large (${messages.length} msgs, ${totalTokens}/${this._maxContextTokens} tokens), compressing...`
1036
- // );
1071
+ logger.info(
1072
+ `Context large (${messages.length} msgs, ${totalTokens}/${this._maxContextTokens} tokens), compressing...`
1073
+ );
1037
1074
  await this._compressContext(sessionId, messages, messageStore);
1038
1075
  // 压缩后消息被截断/替换,缓存已失效,下次 _getCachedMessageTokens 会重新计算
1039
1076
  this._invalidateMessageTokensCache(messageStore);
1040
- logger.debug(`[_prepareSession] AFTER compress: messages=${messages.length}`);
1077
+ logger.info(`[_prepareSession] AFTER compress: messages=${messages.length}`);
1041
1078
  }
1042
1079
 
1043
1080
  // 单次遍历完成:修复 invalid tool-call + 修复错误 text + 重映射历史 ID
@@ -1500,6 +1537,33 @@ class AgentChatHandler extends EventEmitter {
1500
1537
  return this._tokenCounter.countTools(this._toolExecutor.getAllTools());
1501
1538
  }
1502
1539
 
1540
+ /**
1541
+ * 判断是否为上下文超限错误
1542
+ * @param {Error} err
1543
+ * @returns {boolean}
1544
+ * @private
1545
+ */
1546
+ _isContextOverflowError(err) {
1547
+ if (!err) return false;
1548
+ const msg = err.message || '';
1549
+ const lowerMsg = msg.toLowerCase();
1550
+ // DeepSeek / OpenAI 兼容的上下文超限错误关键词
1551
+ const keywords = [
1552
+ 'context_length',
1553
+ 'context length',
1554
+ 'maximum context',
1555
+ 'max context',
1556
+ 'too many tokens',
1557
+ 'token limit',
1558
+ 'tokens exceed',
1559
+ 'exceed maximum',
1560
+ 'maximum tokens',
1561
+ 'context_exceeded',
1562
+ 'context overflow',
1563
+ ];
1564
+ return keywords.some(k => lowerMsg.includes(k));
1565
+ }
1566
+
1503
1567
  /**
1504
1568
  * 获取缓存的工具 token 数(工具列表不变时复用)
1505
1569
  * @private
@@ -51,10 +51,10 @@ const DEFAULT_MAX_MESSAGES_PER_SESSION = 1000;
51
51
  // ============================================================================
52
52
 
53
53
  /** 保留最近的 N 条消息 */
54
- const KEEP_RECENT_MESSAGES = 50;
54
+ const KEEP_RECENT_MESSAGES = 20;
55
55
 
56
56
  /** 压缩消息数量阈值 */
57
- const COMPRESSION_MESSAGE_THRESHOLD = 300;
57
+ const COMPRESSION_MESSAGE_THRESHOLD = 80;
58
58
 
59
59
  /** 智能压缩启用 */
60
60
  const ENABLE_SMART_COMPRESS = true;
package/src/llm/tokens.js CHANGED
@@ -5,7 +5,70 @@
5
5
  * Ported from pi's compaction/compaction.ts with enhancements
6
6
  */
7
7
 
8
- function encode(text, bytesPerToken = 4) {
8
+ // CJK 字符 Unicode 范围
9
+ const CJK_RANGES = [
10
+ [0x4e00, 0x9fff], // CJK Unified Ideographs (中文简体/繁体)
11
+ [0x3400, 0x4dbf], // CJK Unified Ideographs Extension A
12
+ [0x20000, 0x2a6df], // CJK Unified Ideographs Extension B
13
+ [0x3040, 0x309f], // Hiragana (日文平假名)
14
+ [0x30a0, 0x30ff], // Katakana (日文片假名)
15
+ [0xac00, 0xd7af], // Hangul Syllables (韩文)
16
+ [0x1100, 0x11ff], // Hangul Jamo
17
+ [0x3000, 0x303f], // CJK Symbols and Punctuation
18
+ [0xff00, 0xffef], // Halfwidth and Fullwidth Forms
19
+ ];
20
+
21
+ function isCJKChar(char) {
22
+ const code = char.codePointAt(0);
23
+ if (!code) return false;
24
+ for (const [start, end] of CJK_RANGES) {
25
+ if (code >= start && code <= end) return true;
26
+ }
27
+ return false;
28
+ }
29
+
30
+ /**
31
+ * 改进的 Token 估算
32
+ * 根据文本语言类型采用不同估算系数
33
+ *
34
+ * @param {string} text - 输入文本
35
+ * @returns {number} 估算的 token 数
36
+ */
37
+ function encode(text) {
38
+ if (!text || typeof text !== 'string') return 0;
39
+
40
+ let cjkChars = 0;
41
+ let asciiChars = 0;
42
+ let otherChars = 0;
43
+
44
+ // 逐字符统计
45
+ for (const char of text) {
46
+ if (isCJKChar(char)) {
47
+ cjkChars++;
48
+ } else if (char.charCodeAt(0) < 128) {
49
+ // ASCII 字符 (英文、数字、标点)
50
+ asciiChars++;
51
+ } else {
52
+ otherChars++;
53
+ }
54
+ }
55
+
56
+ // 更精确的估算系数 (基于 tiktoken 统计数据):
57
+ // - ASCII/英文: ~0.25 tokens/char (4 chars per token)
58
+ // - 中文: ~0.5 tokens/char (2 chars per token, 中文字符是 3 字节 UTF-8 但 tokenizer 输出约 0.5)
59
+ // - 其他 (emoji, 特殊符号): ~0.25 tokens/char
60
+ const asciiTokens = asciiChars * 0.25;
61
+ const cjkTokens = cjkChars * 0.5;
62
+ const otherTokens = otherChars * 0.25;
63
+
64
+ return Math.ceil(asciiTokens + cjkTokens + otherTokens);
65
+ }
66
+
67
+ /**
68
+ * 旧版 encode(兼容)
69
+ * @deprecated 使用新版 encode 替代
70
+ */
71
+ function encodeOld(text, bytesPerToken = 4) {
9
72
  if (!text) return 0;
10
73
  const bytes = Buffer.byteLength(String(text), 'utf8');
11
74
  return Math.ceil(bytes / bytesPerToken);
@@ -25,64 +88,66 @@ function safeJsonStringify(value) {
25
88
  }
26
89
  }
27
90
 
91
+ /**
92
+ * 估算单条消息的 token 数(使用改进的编码方法)
93
+ */
28
94
  function estimateTokens(message) {
29
- let chars = 0;
95
+ if (!message) return 0;
96
+
97
+ let total = 4; // message overhead
30
98
 
31
99
  switch (message.role) {
32
- case 'user': {
100
+ case 'user':
101
+ case 'assistant':
102
+ case 'custom':
103
+ case 'toolResult': {
33
104
  const content = message.content;
34
105
  if (typeof content === 'string') {
35
- chars = content.length;
106
+ total += encode(content);
36
107
  } else if (Array.isArray(content)) {
37
108
  for (const block of content) {
38
109
  if (block.type === 'text' && block.text) {
39
- chars += block.text.length;
110
+ total += encode(block.text);
111
+ } else if (block.type === 'tool-call' || block.type === 'toolCall') {
112
+ total += encode(block.name || '') + encode(safeJsonStringify(block.arguments || {}));
113
+ } else if (block.type === 'tool-result' || block.type === 'toolResult') {
114
+ total += encode(typeof block.content === 'string' ? block.content : safeJsonStringify(block.content));
115
+ } else if (block.type === 'image') {
116
+ total += 85;
117
+ } else if (block.type === 'thinking') {
118
+ total += encode(block.thinking || '');
40
119
  }
41
120
  }
42
121
  }
43
- return Math.ceil(chars / 4);
44
- }
45
- case 'assistant': {
46
- const assistant = message;
47
- for (const block of assistant.content || []) {
48
- if (block.type === 'text') {
49
- chars += block.text.length;
50
- } else if (block.type === 'thinking') {
51
- chars += block.thinking.length;
52
- } else if (block.type === 'toolCall') {
53
- chars += block.name.length + safeJsonStringify(block.arguments).length;
54
- }
55
- }
56
- return Math.ceil(chars / 4);
57
- }
58
- case 'custom':
59
- case 'toolResult': {
60
- if (typeof message.content === 'string') {
61
- chars = message.content.length;
62
- } else {
63
- for (const block of message.content || []) {
64
- if (block.type === 'text' && block.text) {
65
- chars += block.text.length;
66
- }
67
- if (block.type === 'image') {
68
- chars += 4800;
69
- }
70
- }
71
- }
72
- return Math.ceil(chars / 4);
122
+ break;
73
123
  }
74
124
  case 'bashExecution': {
75
- chars = message.command.length + (message.output?.length || 0);
76
- return Math.ceil(chars / 4);
125
+ total += encode(message.command || '');
126
+ total += encode(message.output || '');
127
+ break;
77
128
  }
78
129
  case 'branchSummary':
79
130
  case 'compactionSummary': {
80
- chars = message.summary.length;
81
- return Math.ceil(chars / 4);
131
+ total += encode(message.summary || '');
132
+ break;
133
+ }
134
+ }
135
+
136
+ // tool_calls overhead
137
+ if (message.tool_calls && Array.isArray(message.tool_calls)) {
138
+ total += 15; // tool_calls overhead
139
+ for (const tc of message.tool_calls) {
140
+ if (tc.function) {
141
+ total += encode(tc.function.name || '') + encode(safeJsonStringify(tc.function.arguments || {}));
142
+ }
82
143
  }
83
144
  }
84
145
 
85
- return 0;
146
+ if (message.tool_call_id) {
147
+ total += 15;
148
+ }
149
+
150
+ return total;
86
151
  }
87
152
 
88
153
  function calculateContextTokens(usage) {
@@ -157,8 +222,8 @@ class TokenCounter {
157
222
  this.toolSchema = config.toolSchema || null;
158
223
  }
159
224
 
160
- countText(text, bytesPerToken = 4) {
161
- return encode(text, bytesPerToken);
225
+ countText(text) {
226
+ return encode(text);
162
227
  }
163
228
 
164
229
  countMessages(messages) {