evolclaw 3.2.0 → 3.4.0

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.
Files changed (95) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +7 -4
  3. package/dist/agents/{resolve.js → baseagent.js} +34 -5
  4. package/dist/agents/claude-runner.js +120 -31
  5. package/dist/agents/codex-app-server-client.js +364 -0
  6. package/dist/agents/codex-runner.js +1152 -140
  7. package/dist/agents/gemini-runner.js +2 -2
  8. package/dist/agents/runner-types.js +58 -0
  9. package/dist/aun/aid/store.js +1 -1
  10. package/dist/aun/outbox.js +14 -2
  11. package/dist/aun/storage/download.js +1 -1
  12. package/dist/aun/storage/upload.js +13 -1
  13. package/dist/channels/aun.js +869 -358
  14. package/dist/channels/dingtalk.js +77 -140
  15. package/dist/channels/feishu.js +125 -154
  16. package/dist/channels/qqbot.js +75 -138
  17. package/dist/channels/wechat.js +75 -136
  18. package/dist/channels/wecom.js +75 -138
  19. package/dist/cli/agent-command.js +591 -0
  20. package/dist/cli/agent.js +23 -8
  21. package/dist/cli/aun-commands.js +1444 -0
  22. package/dist/cli/ctl-command.js +78 -0
  23. package/dist/cli/daemon-commands.js +2707 -0
  24. package/dist/cli/index.js +23 -4905
  25. package/dist/cli/init.js +33 -6
  26. package/dist/cli/model.js +1 -1
  27. package/dist/cli/restart-monitor.js +539 -0
  28. package/dist/cli/stats.js +558 -0
  29. package/dist/cli/version.js +87 -0
  30. package/dist/cli/watch-logs.js +33 -0
  31. package/dist/cli/watch-msg.js +5 -2
  32. package/dist/config-store.js +12 -6
  33. package/dist/core/channel-loader.js +88 -83
  34. package/dist/core/command/command-handler.js +1189 -0
  35. package/dist/core/command/menu-handler.js +1478 -0
  36. package/dist/core/command/slash-gate.js +142 -0
  37. package/dist/core/command/slash-handler.js +2090 -0
  38. package/dist/core/evolagent-registry.js +82 -0
  39. package/dist/core/evolagent.js +17 -1
  40. package/dist/core/interaction-router.js +8 -0
  41. package/dist/core/message/command-handler-agent-control.js +63 -1
  42. package/dist/core/message/im-renderer.js +91 -51
  43. package/dist/core/message/items-formatter.js +9 -1
  44. package/dist/core/message/message-bridge.js +73 -24
  45. package/dist/core/message/message-log.js +1 -0
  46. package/dist/core/message/message-processor.js +432 -94
  47. package/dist/core/message/message-queue.js +70 -2
  48. package/dist/core/message/pending-hints.js +232 -0
  49. package/dist/core/model/model-catalog.js +1 -1
  50. package/dist/core/model/model-scope.js +2 -2
  51. package/dist/core/permission.js +25 -12
  52. package/dist/core/relation/peer-identity.js +16 -1
  53. package/dist/core/session/adapters/codex-session-file-adapter.js +4 -2
  54. package/dist/core/session/session-manager.js +86 -26
  55. package/dist/core/session/session-title.js +26 -0
  56. package/dist/core/stats/billing.js +151 -0
  57. package/dist/core/stats/budget.js +93 -0
  58. package/dist/core/stats/db.js +334 -0
  59. package/dist/core/stats/eck-vars.js +84 -0
  60. package/dist/core/stats/index.js +10 -0
  61. package/dist/core/stats/normalizer.js +78 -0
  62. package/dist/core/stats/query.js +760 -0
  63. package/dist/core/stats/writer.js +115 -0
  64. package/dist/core/trigger/manager.js +34 -0
  65. package/dist/core/trigger/parser.js +9 -3
  66. package/dist/core/trigger/scheduler.js +20 -17
  67. package/dist/data/error-dict.json +7 -0
  68. package/dist/{agents → eck}/manifest-engine.js +20 -1
  69. package/dist/{agents → eck}/message-renderer.js +24 -1
  70. package/dist/index.js +174 -9
  71. package/dist/ipc.js +116 -1
  72. package/dist/utils/cross-platform.js +58 -5
  73. package/dist/utils/ecweb-launch.js +49 -0
  74. package/dist/utils/ecweb-pair.js +20 -0
  75. package/dist/utils/error-utils.js +18 -5
  76. package/dist/utils/npm-ops.js +38 -8
  77. package/dist/utils/stats.js +77 -6
  78. package/kits/docs/evolclaw/INDEX.md +3 -1
  79. package/kits/docs/evolclaw/fs-architecture.md +1215 -0
  80. package/kits/docs/evolclaw/fs.md +131 -0
  81. package/kits/docs/evolclaw/group-fs.md +209 -0
  82. package/kits/docs/evolclaw/stats.md +70 -0
  83. package/kits/docs/venues/aun-group.md +29 -6
  84. package/kits/docs/venues/group.md +5 -4
  85. package/kits/eck_message_manifest.json +30 -3
  86. package/kits/rules/05-venue.md +1 -1
  87. package/kits/templates/message-fragments/inject-default.md +2 -0
  88. package/package.json +5 -6
  89. package/dist/agents/baseagent-normalize.js +0 -19
  90. package/dist/core/command-handler.js +0 -3876
  91. package/dist/core/relation/peer-key.js +0 -16
  92. package/dist/evolclaw-config.js +0 -11
  93. package/dist/utils/channel-helpers.js +0 -46
  94. /package/dist/core/{cache/file-cache.js → daemon-file-cache.js} +0 -0
  95. /package/dist/{agents → eck}/kit-renderer.js +0 -0
@@ -5,6 +5,7 @@ import { encodePath } from '../../utils/cross-platform.js';
5
5
  import { chatDirPath, generateSessionId, formatTimestamp, atomicWriteJson, appendJsonl, readJsonFile, readLastJsonlLine, readAllJsonlLines, scanChatDirs, scanMetaFiles, ensureChatDir, readThreadIndex, writeThreadIndex, } from './session-fs-store.js';
6
6
  import { sessionToFile, fileToSession } from './session-mapper.js';
7
7
  import { formatSessionKey, DEFAULT_THREAD_ID } from './session-key.js';
8
+ import { tryParseChannelKey } from '../channel-loader.js';
8
9
  import path from 'path';
9
10
  import fs from 'fs';
10
11
  import os from 'os';
@@ -69,6 +70,9 @@ export class SessionManager {
69
70
  return { role: 'admin', mode: 'interactive' };
70
71
  return { role: 'guest', mode: 'interactive' };
71
72
  }
73
+ resolvePermissionMode(role) {
74
+ return (role === 'owner' || role === 'admin') ? 'bypass' : 'readonly';
75
+ }
72
76
  async updateIdentity(sessionId, identity) {
73
77
  logger.debug(`[SessionManager] updateIdentity: sessionId=${sessionId}, role=${identity.role}`);
74
78
  }
@@ -107,6 +111,26 @@ export class SessionManager {
107
111
  }
108
112
  return chatDirPath(this.sessionsDir, session.channelType, session.channelId, session.selfAID);
109
113
  }
114
+ deriveChannelIdentity(channel, channelId) {
115
+ const parsed = tryParseChannelKey(channel);
116
+ if (parsed) {
117
+ return { channelType: parsed.type, selfAID: parsed.type === 'aun' ? parsed.selfAID : '' };
118
+ }
119
+ const existingDir = this.findExistingChatDir(channel, channelId);
120
+ if (existingDir) {
121
+ const active = readJsonFile(path.join(existingDir, 'active.json'));
122
+ if (active) {
123
+ return { channelType: active.channelType || channel, selfAID: active.selfAID || '' };
124
+ }
125
+ for (const metaFile of scanMetaFiles(existingDir)) {
126
+ const meta = readLastJsonlLine(path.join(existingDir, metaFile));
127
+ if (meta) {
128
+ return { channelType: meta.channelType || channel, selfAID: meta.selfAID || '' };
129
+ }
130
+ }
131
+ }
132
+ return { channelType: channel, selfAID: '' };
133
+ }
110
134
  /** Public accessor: get the chat directory path for a session (for message log etc.) */
111
135
  getChatDir(session) {
112
136
  return this.resolveChatDirFromSession(session);
@@ -124,6 +148,30 @@ export class SessionManager {
124
148
  * 用于不知道 channelType/selfAID 的 caller 在调用 resolveChatDir 前定位已有目录。
125
149
  */
126
150
  findExistingChatDir(channel, channelId) {
151
+ const parsed = tryParseChannelKey(channel);
152
+ if (parsed) {
153
+ const exactDir = this.resolveChatDir(channel, channelId, parsed.type, parsed.type === 'aun' ? parsed.selfAID : undefined);
154
+ const active = readJsonFile(path.join(exactDir, 'active.json'));
155
+ if (active && active.channel === channel)
156
+ return exactDir;
157
+ for (const mf of scanMetaFiles(exactDir)) {
158
+ const meta = readLastJsonlLine(path.join(exactDir, mf));
159
+ if (meta && meta.channel === channel)
160
+ return exactDir;
161
+ }
162
+ const threadsDir = path.join(exactDir, '_threads');
163
+ if (fs.existsSync(threadsDir)) {
164
+ const threadMetas = scanMetaFiles(threadsDir);
165
+ for (const mf of threadMetas) {
166
+ const meta = readLastJsonlLine(path.join(threadsDir, mf));
167
+ if (meta && meta.channel === channel)
168
+ return exactDir;
169
+ }
170
+ }
171
+ if (fs.existsSync(exactDir))
172
+ return exactDir;
173
+ return undefined;
174
+ }
127
175
  const dirs = scanChatDirs(this.sessionsDir);
128
176
  for (const d of dirs) {
129
177
  if (d.channelId !== channelId)
@@ -479,10 +527,10 @@ export class SessionManager {
479
527
  throw new Error(`[SessionManager] getOrCreateSession requires channelType. channel="${channel}" channelId="${channelId}"`);
480
528
  }
481
529
  if (threadId) {
482
- const session = this.getOrCreateThreadSession(channel, channelId, threadId, defaultProjectPath, metadata, name, agentId, selfAID, channelType, peerType);
530
+ const session = this.getOrCreateThreadSession(channel, channelId, threadId, defaultProjectPath, metadata, name, agentId, selfAID, channelType, peerType, chatType);
483
531
  session.identity = this.resolveIdentity(channel, userId);
484
532
  if (session.metadata && !session.metadata.permissionMode) {
485
- session.metadata.permissionMode = DEFAULT_PERMISSION_MODE;
533
+ session.metadata.permissionMode = this.resolvePermissionMode(session.identity.role);
486
534
  this.persistSession(session, 'none');
487
535
  }
488
536
  return session;
@@ -564,8 +612,9 @@ export class SessionManager {
564
612
  }
565
613
  // Create new session
566
614
  const sessionMetadata = { ...(metadata || {}) };
615
+ const newIdentity = this.resolveIdentity(channel, userId);
567
616
  if (!sessionMetadata.permissionMode)
568
- sessionMetadata.permissionMode = DEFAULT_PERMISSION_MODE;
617
+ sessionMetadata.permissionMode = this.resolvePermissionMode(newIdentity.role);
569
618
  const session = {
570
619
  id: generateSessionId(),
571
620
  channel,
@@ -583,7 +632,7 @@ export class SessionManager {
583
632
  createdAt: Date.now(),
584
633
  updatedAt: Date.now(),
585
634
  };
586
- session.identity = this.resolveIdentity(channel, userId);
635
+ session.identity = newIdentity;
587
636
  this.persistSession(session, 'set');
588
637
  this.eventBus.publish({
589
638
  type: 'session:created',
@@ -614,7 +663,7 @@ export class SessionManager {
614
663
  current.agentSessionId = updates.agentSessionId ?? undefined;
615
664
  this.persistSession(current, 'sync');
616
665
  }
617
- getOrCreateThreadSession(channel, channelId, threadId, defaultProjectPath, metadata, name, agentId, selfAID, channelType, peerType) {
666
+ getOrCreateThreadSession(channel, channelId, threadId, defaultProjectPath, metadata, name, agentId, selfAID, channelType, peerType, chatType) {
618
667
  // 使用精确路径(channelType + selfAID)
619
668
  const chatDir = (channelType && selfAID)
620
669
  ? (() => { const d = chatDirPath(this.sessionsDir, channelType, channelId, selfAID); fs.mkdirSync(d, { recursive: true }); fs.mkdirSync(path.join(d, '_threads'), { recursive: true }); return d; })()
@@ -627,18 +676,22 @@ export class SessionManager {
627
676
  if (existing) {
628
677
  const validSessionId = this.validateSessionFile(existing);
629
678
  if (metadata) {
679
+ const creatorPeerId = existing.metadata?.peerId;
630
680
  existing.metadata = { ...(existing.metadata || {}), ...metadata };
681
+ if (creatorPeerId && existing.metadata)
682
+ existing.metadata.peerId = creatorPeerId;
631
683
  this.persistSession(existing, 'none');
632
684
  }
633
685
  return { ...existing, agentSessionId: validSessionId };
634
686
  }
635
687
  }
636
- // Inherit project path & chatType from active main session
637
- const activeMain = this.readActive(channel, channelId, channelType, selfAID);
638
- const projectPath = (activeMain && !activeMain.threadId ? activeMain.projectPath : undefined) || defaultProjectPath;
639
- const inheritedChatType = (activeMain && !activeMain.threadId ? activeMain.chatType : undefined) || 'private';
688
+ const projectPath = defaultProjectPath;
689
+ const effectiveChatType = chatType || 'private';
640
690
  const effectiveChannelType = channelType || channel;
641
691
  const sessionKey = formatSessionKey(effectiveChannelType, channelId, threadId);
692
+ // 继承主会话的 agentId(话题会话从属于主会话,应沿用相同 backend)
693
+ const mainActive = readJsonFile(path.join(chatDir, 'active.json'));
694
+ const inheritedAgentId = agentId || mainActive?.agentType || 'claude';
642
695
  const session = {
643
696
  id: generateSessionId(),
644
697
  channel,
@@ -647,10 +700,10 @@ export class SessionManager {
647
700
  selfAID: selfAID || '',
648
701
  projectPath,
649
702
  threadId,
650
- agentId: agentId || 'claude',
703
+ agentId: inheritedAgentId,
651
704
  sessionKey,
652
- chatType: inheritedChatType,
653
- sessionMode: this.resolveDefaultSessionMode(channel, inheritedChatType, peerType),
705
+ chatType: effectiveChatType,
706
+ sessionMode: this.resolveDefaultSessionMode(channel, effectiveChatType, peerType),
654
707
  metadata,
655
708
  name: name || '话题会话',
656
709
  createdAt: Date.now(),
@@ -678,7 +731,8 @@ export class SessionManager {
678
731
  const agentId = currentAgentId || 'claude';
679
732
  logger.info(`[SessionManager] switchProject: channel=${channel} channelId=${channelId} newPath=${newProjectPath} agent=${agentId}`);
680
733
  const inheritedChatType = this.getActiveChatType(channel, channelId);
681
- const chatDir = this.ensureResolvedChatDirSafe(channel, channelId);
734
+ const identity = this.deriveChannelIdentity(channel, channelId);
735
+ const chatDir = this.ensureResolvedChatDir(channel, channelId, identity.channelType, identity.selfAID);
682
736
  const allSessions = this.findAllSessionsInChat(chatDir, false);
683
737
  const target = allSessions
684
738
  .filter(s => s.projectPath === newProjectPath && (s.agentId || 'claude') === agentId && !s.threadId)
@@ -691,8 +745,8 @@ export class SessionManager {
691
745
  }
692
746
  // Derive selfAID and channelType from existing sessions in this chatDir
693
747
  const existingAny = allSessions[0];
694
- const selfAID = existingAny?.selfAID || '';
695
- const channelType = existingAny?.channelType || channel;
748
+ const selfAID = existingAny?.selfAID || identity.selfAID;
749
+ const channelType = existingAny?.channelType || identity.channelType;
696
750
  const session = {
697
751
  id: generateSessionId(),
698
752
  channel,
@@ -742,12 +796,10 @@ export class SessionManager {
742
796
  }
743
797
  async switchAgent(channel, channelId, projectPath, newAgentId) {
744
798
  const inheritedChatType = this.getActiveChatType(channel, channelId);
745
- // Derive channelType/selfAID from existing sessions; fall back to channel name
746
- const probeChatDir = this.resolveChatDir(channel, channelId, channel, '');
747
- const probeSessions = fs.existsSync(probeChatDir) ? this.findAllSessionsInChat(probeChatDir, false) : [];
748
- const existingAny = probeSessions[0];
749
- const channelType = existingAny?.channelType || channel;
750
- const selfAID = existingAny?.selfAID || '';
799
+ const identity = this.deriveChannelIdentity(channel, channelId);
800
+ // Derive channelType/selfAID from existing sessions; fall back to parsed channel key.
801
+ const channelType = identity.channelType;
802
+ const selfAID = identity.selfAID;
751
803
  const chatDir = this.ensureResolvedChatDir(channel, channelId, channelType, selfAID);
752
804
  const allSessions = this.findAllSessionsInChat(chatDir, false);
753
805
  const target = allSessions
@@ -1022,13 +1074,20 @@ export class SessionManager {
1022
1074
  const inheritedChatType = this.getActiveChatType(channel, channelId);
1023
1075
  // Derive selfAID and channelType from existing sessions
1024
1076
  const existingDir = this.findExistingChatDir(channel, channelId);
1025
- let channelType = channel;
1026
- let selfAID = '';
1077
+ const identity = this.deriveChannelIdentity(channel, channelId);
1078
+ let channelType = identity.channelType;
1079
+ let selfAID = identity.selfAID;
1080
+ let inheritedRole = 'guest';
1027
1081
  if (existingDir) {
1028
1082
  const active = readJsonFile(path.join(existingDir, 'active.json'));
1029
1083
  if (active) {
1030
1084
  channelType = active.channelType || channel;
1031
1085
  selfAID = active.selfAID || '';
1086
+ // 从现有 session 的 permissionMode 反推 role,bypass→owner,其余→guest
1087
+ if (active.permissionMode === 'bypass')
1088
+ inheritedRole = 'owner';
1089
+ if (!agentId && active.agentType)
1090
+ agentId = active.agentType;
1032
1091
  }
1033
1092
  }
1034
1093
  const session = {
@@ -1043,7 +1102,7 @@ export class SessionManager {
1043
1102
  sessionKey: formatSessionKey(channelType, channelId, DEFAULT_THREAD_ID),
1044
1103
  chatType: inheritedChatType,
1045
1104
  sessionMode: this.resolveDefaultSessionMode(channel, inheritedChatType),
1046
- metadata: { permissionMode: DEFAULT_PERMISSION_MODE },
1105
+ metadata: { permissionMode: this.resolvePermissionMode(inheritedRole) },
1047
1106
  name: name || '默认会话',
1048
1107
  createdAt: Date.now(),
1049
1108
  updatedAt: Date.now(),
@@ -1146,8 +1205,9 @@ export class SessionManager {
1146
1205
  const name = fileInfo.title || `CLI会话-${new Date().toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}`;
1147
1206
  // Derive selfAID and channelType from existing sessions
1148
1207
  const existingDir = this.findExistingChatDir(channel, channelId);
1149
- let channelType = channel;
1150
- let selfAID = '';
1208
+ const identity = this.deriveChannelIdentity(channel, channelId);
1209
+ let channelType = identity.channelType;
1210
+ let selfAID = identity.selfAID;
1151
1211
  if (existingDir) {
1152
1212
  const active = readJsonFile(path.join(existingDir, 'active.json'));
1153
1213
  if (active) {
@@ -0,0 +1,26 @@
1
+ const SYSTEM_PROMPT_MARKERS = [
2
+ '--- [SYSTEM_PROMPT_END] ---',
3
+ '<system-reminder>',
4
+ 'EvolClaw Context Kit documents are shown below.',
5
+ ];
6
+ const MESSAGE_ROUTE_PREFIX_RE = /^‹[^›]*·\s*from:[^›]*→\s*self:[^›]*›\s*/;
7
+ export function sanitizeSessionTitle(title) {
8
+ if (typeof title !== 'string')
9
+ return undefined;
10
+ let cleanTitle = title;
11
+ for (const marker of SYSTEM_PROMPT_MARKERS) {
12
+ const markerIndex = cleanTitle.indexOf(marker);
13
+ if (markerIndex >= 0) {
14
+ cleanTitle = cleanTitle.slice(0, markerIndex);
15
+ }
16
+ }
17
+ cleanTitle = cleanTitle.trim().replace(MESSAGE_ROUTE_PREFIX_RE, '').replace(/\s+/g, ' ');
18
+ if (!cleanTitle)
19
+ return undefined;
20
+ if (/^\.+$/.test(cleanTitle))
21
+ return undefined;
22
+ return cleanTitle.length > 80 ? `${cleanTitle.slice(0, 80)}...` : cleanTitle;
23
+ }
24
+ export function displaySessionTitle(title, fallback = '默认会话') {
25
+ return sanitizeSessionTitle(title) || sanitizeSessionTitle(fallback) || fallback;
26
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * billing.ts — 读 model-prices.jsonl,按 billing_fn 调对应算法函数计算费用。
3
+ * 费用查询时实时计算,不存入 DB(价格可能变动)。
4
+ */
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+ import { getPackageRoot } from '../../paths.js';
8
+ // 价格表缓存(进程内,5 分钟 TTL)
9
+ let _priceCache = null;
10
+ let _priceCacheTs = 0;
11
+ const PRICE_CACHE_TTL = 5 * 60 * 1000;
12
+ /**
13
+ * 从包路径 + 用户路径合并读取 JSONL 文件。
14
+ * 包路径($PACKAGE_ROOT/data/stats/)为基线,用户路径($EVOLCLAW_HOME/data/stats/)为追加/覆盖。
15
+ * 两层合并(append),用户层行追加在包层之后——查价时 effective_from 越大越优先,天然正确。
16
+ */
17
+ function _loadJsonlMerged(evolclawHome, filename) {
18
+ const results = [];
19
+ // 1. 包路径(基线)
20
+ const pkgFile = path.join(getPackageRoot(), 'data', 'stats', filename);
21
+ if (fs.existsSync(pkgFile)) {
22
+ try {
23
+ const lines = fs.readFileSync(pkgFile, 'utf-8').split('\n').filter(Boolean);
24
+ for (const l of lines)
25
+ results.push(JSON.parse(l));
26
+ }
27
+ catch { /* skip */ }
28
+ }
29
+ // 2. 用户路径(追加/覆盖)
30
+ const userFile = path.join(evolclawHome, 'data', 'stats', filename);
31
+ if (fs.existsSync(userFile)) {
32
+ try {
33
+ const lines = fs.readFileSync(userFile, 'utf-8').split('\n').filter(Boolean);
34
+ for (const l of lines)
35
+ results.push(JSON.parse(l));
36
+ }
37
+ catch { /* skip */ }
38
+ }
39
+ return results;
40
+ }
41
+ let _aliasCache = null;
42
+ let _aliasCacheTs = 0;
43
+ function loadAliases(evolclawHome) {
44
+ const now = Date.now();
45
+ if (_aliasCache && now - _aliasCacheTs < PRICE_CACHE_TTL)
46
+ return _aliasCache;
47
+ _aliasCache = _loadJsonlMerged(evolclawHome, 'model-aliases.jsonl');
48
+ _aliasCacheTs = now;
49
+ return _aliasCache;
50
+ }
51
+ /** 解析模型 ID → 定价表规范 ID。精确匹配 alias 表,找不到返回原 ID。 */
52
+ export function resolveCanonicalModel(evolclawHome, model) {
53
+ const aliases = loadAliases(evolclawHome);
54
+ const entry = aliases.find(a => a.alias === model);
55
+ return entry ? entry.canonical : model;
56
+ }
57
+ function loadPrices(evolclawHome) {
58
+ const now = Date.now();
59
+ if (_priceCache && now - _priceCacheTs < PRICE_CACHE_TTL)
60
+ return _priceCache;
61
+ _priceCache = _loadJsonlMerged(evolclawHome, 'model-prices.jsonl');
62
+ _priceCacheTs = now;
63
+ return _priceCache;
64
+ }
65
+ /** 取 model 在 ts 时刻生效的价格行(effective_from <= ts 中最新的一条)。
66
+ * 精确匹配失败时,通过 model-aliases.jsonl 映射到规范 ID 再查一次。 */
67
+ export function resolvePriceRow(evolclawHome, model, ts) {
68
+ const prices = loadPrices(evolclawHome);
69
+ let candidates = prices.filter(p => p.model === model && p.effective_from <= ts);
70
+ if (!candidates.length) {
71
+ // fallback: alias → canonical
72
+ const canonical = resolveCanonicalModel(evolclawHome, model);
73
+ if (canonical !== model) {
74
+ candidates = prices.filter(p => p.model === canonical && p.effective_from <= ts);
75
+ }
76
+ }
77
+ if (!candidates.length)
78
+ return null;
79
+ return candidates.reduce((a, b) => a.effective_from >= b.effective_from ? a : b);
80
+ }
81
+ const BILLING_FNS = {
82
+ // 通用 per-token(Claude / OpenAI 兼容 / Kimi / MiniMax)
83
+ per_token_v1: (e, p) => {
84
+ const r = (p.price_input ?? 0) * e.input_tokens / 1e6
85
+ + (p.price_output ?? 0) * e.output_tokens / 1e6
86
+ + (p.price_cache_creation ?? 0) * e.cache_creation_tokens / 1e6
87
+ + (p.price_cache_read ?? 0) * e.cache_read_tokens / 1e6;
88
+ return p.currency === 'CNY' ? { cny: r } : { usd: r };
89
+ },
90
+ // DeepSeek cache_hit / cache_miss 口径
91
+ per_token_deepseek_v1: (e, p) => {
92
+ const r = (p.price_cache_hit ?? 0) * (e.cache_hit_tokens ?? 0) / 1e6
93
+ + (p.price_cache_miss ?? 0) * (e.cache_miss_tokens ?? 0) / 1e6
94
+ + (p.price_output ?? 0) * e.output_tokens / 1e6;
95
+ return { cny: r };
96
+ },
97
+ // Gemini 分档(按 total_context_tokens 确定所在档位)
98
+ per_token_tiered_v1: (e, p) => {
99
+ const tiers = p.tiers;
100
+ if (!Array.isArray(tiers))
101
+ return {};
102
+ const ctx = e.total_context_tokens ?? e.input_tokens;
103
+ const tier = tiers.find(t => t.up_to_tokens == null || ctx <= t.up_to_tokens) ?? tiers[tiers.length - 1];
104
+ const r = (tier.price_input ?? 0) * e.input_tokens / 1e6
105
+ + (tier.price_output ?? 0) * e.output_tokens / 1e6
106
+ + (tier.price_cache_read ?? 0) * e.cache_read_tokens / 1e6;
107
+ return p.currency === 'CNY' ? { cny: r } : { usd: r };
108
+ },
109
+ // 视觉模型(含 image_tokens 单独计费)
110
+ per_token_image_v1: (e, p) => {
111
+ const r = (p.price_input ?? 0) * e.input_tokens / 1e6
112
+ + (p.price_output ?? 0) * e.output_tokens / 1e6
113
+ + (p.price_image ?? 0) * (e.image_tokens ?? 0) / 1e6;
114
+ return p.currency === 'CNY' ? { cny: r } : { usd: r };
115
+ },
116
+ };
117
+ /** 注册新计费函数(扩展点)。 */
118
+ export function registerBillingFn(id, fn) {
119
+ BILLING_FNS[id] = fn;
120
+ }
121
+ /** 计算一条 usage_event 的费用。找不到价格行时返回 {}。 */
122
+ export function calcCost(evolclawHome, event) {
123
+ const priceRow = resolvePriceRow(evolclawHome, event.model, event.ts);
124
+ if (!priceRow)
125
+ return {};
126
+ const fn = BILLING_FNS[event.billing_fn] ?? BILLING_FNS[priceRow.billing_fn];
127
+ if (!fn)
128
+ return {};
129
+ return fn(event, priceRow);
130
+ }
131
+ let _specCache = null;
132
+ let _specCacheTs = 0;
133
+ function loadSpecs(evolclawHome) {
134
+ const now = Date.now();
135
+ if (_specCache && now - _specCacheTs < PRICE_CACHE_TTL)
136
+ return _specCache;
137
+ _specCache = _loadJsonlMerged(evolclawHome, 'model-specs.jsonl');
138
+ _specCacheTs = now;
139
+ return _specCache;
140
+ }
141
+ /** 取模型在 ts 时刻的能力参数。找不到时返回默认值。 */
142
+ export function resolveModelSpec(evolclawHome, model, ts) {
143
+ const specs = loadSpecs(evolclawHome);
144
+ const t = ts ?? Date.now();
145
+ const candidates = specs.filter(s => s.model === model && s.effective_from <= t);
146
+ if (candidates.length) {
147
+ return candidates.reduce((a, b) => a.effective_from >= b.effective_from ? a : b);
148
+ }
149
+ // 默认值
150
+ return { model, effective_from: 0, context_window: 200000, max_input_tokens: 180000, max_output_tokens: 8192 };
151
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * budget.ts — 三档预算控制(硬上限 / 软上限 / 自主上限)。
3
+ */
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+ import { calcCost } from './billing.js';
7
+ import { openReadonlyDb, getDbPath } from './db.js';
8
+ function loadBudgets(evolclawHome) {
9
+ const file = path.join(evolclawHome, 'data', 'stats', 'budgets.json');
10
+ if (!fs.existsSync(file))
11
+ return {};
12
+ try {
13
+ return JSON.parse(fs.readFileSync(file, 'utf-8'));
14
+ }
15
+ catch {
16
+ return {};
17
+ }
18
+ }
19
+ function _resolveCfg(budgets, agentAid, peerKey) {
20
+ const g = budgets.global ?? {};
21
+ const a = agentAid ? (budgets.agents?.[agentAid] ?? {}) : {};
22
+ const p = peerKey ? (budgets.peers?.[peerKey] ?? {}) : {};
23
+ // 取最严格的限额
24
+ const daily_usd = Math.min(g.daily_usd ?? Infinity, a.daily_usd ?? Infinity, p.daily_usd ?? Infinity);
25
+ return {
26
+ daily_usd: isFinite(daily_usd) ? daily_usd : undefined,
27
+ monthly_usd: g.monthly_usd,
28
+ hard_limit_pct: g.hard_limit_pct ?? 100,
29
+ soft_limit_pct: g.soft_limit_pct ?? 80,
30
+ auto_limit_pct: g.auto_limit_pct ?? 60,
31
+ on_hard_limit: g.on_hard_limit ?? 'block',
32
+ downgrade_model: g.downgrade_model,
33
+ };
34
+ }
35
+ /** 计算今日实际 USD 消耗(逐行算 cost,以避免聚合误差)。 */
36
+ function _calcTodayUsd(evolclawHome, agentAid) {
37
+ const now = new Date();
38
+ const from_ts = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
39
+ return _calcRangeUsd(evolclawHome, from_ts, agentAid);
40
+ }
41
+ /** 计算本月实际 USD 消耗。 */
42
+ function _calcMonthUsd(evolclawHome, agentAid) {
43
+ const now = new Date();
44
+ const from_ts = new Date(now.getFullYear(), now.getMonth(), 1).getTime();
45
+ return _calcRangeUsd(evolclawHome, from_ts, agentAid);
46
+ }
47
+ /** 计算指定时间范围的 USD 消耗(逐行算 cost)。 */
48
+ function _calcRangeUsd(evolclawHome, from_ts, agentAid) {
49
+ const db = openReadonlyDb(getDbPath(evolclawHome));
50
+ if (!db)
51
+ return 0;
52
+ let total = 0;
53
+ try {
54
+ const clause = agentAid ? 'WHERE ts >= ? AND agent_aid = ?' : 'WHERE ts >= ?';
55
+ const params = agentAid ? [from_ts, agentAid] : [from_ts];
56
+ const rows = db.prepare(`SELECT * FROM usage_events ${clause}`).all(...params);
57
+ for (const r of rows) {
58
+ const cost = calcCost(evolclawHome, r);
59
+ total += cost.usd ?? 0;
60
+ }
61
+ }
62
+ finally {
63
+ db.close();
64
+ }
65
+ return total;
66
+ }
67
+ export function getBudgetStatus(evolclawHome, agentAid, peerKey) {
68
+ const budgets = loadBudgets(evolclawHome);
69
+ const cfg = _resolveCfg(budgets, agentAid, peerKey);
70
+ const dailyLimit = cfg.daily_usd ?? Infinity;
71
+ const dailyUsed = _calcTodayUsd(evolclawHome, agentAid);
72
+ const dailyRemaining = Math.max(0, dailyLimit - dailyUsed);
73
+ const dailyPct = isFinite(dailyLimit) && dailyLimit > 0 ? (dailyUsed / dailyLimit) * 100 : 0;
74
+ const monthlyLimit = cfg.monthly_usd ?? Infinity;
75
+ const monthlyUsed = isFinite(monthlyLimit) ? _calcMonthUsd(evolclawHome, agentAid) : 0;
76
+ const monthlyRemaining = Math.max(0, monthlyLimit - monthlyUsed);
77
+ const monthlyPct = isFinite(monthlyLimit) && monthlyLimit > 0 ? (monthlyUsed / monthlyLimit) * 100 : 0;
78
+ // 取 daily/monthly 中更高的百分比作为整体判断依据
79
+ const pct = Math.max(dailyPct, monthlyPct);
80
+ return {
81
+ daily_limit_usd: isFinite(dailyLimit) ? dailyLimit : -1,
82
+ daily_used_usd: dailyUsed,
83
+ daily_remaining_usd: isFinite(dailyLimit) ? dailyRemaining : -1,
84
+ monthly_limit_usd: isFinite(monthlyLimit) ? monthlyLimit : -1,
85
+ monthly_used_usd: monthlyUsed,
86
+ monthly_remaining_usd: isFinite(monthlyLimit) ? monthlyRemaining : -1,
87
+ pct_used: pct,
88
+ hard_blocked: pct >= (cfg.hard_limit_pct ?? 100),
89
+ soft_warn: pct >= (cfg.soft_limit_pct ?? 80),
90
+ auto_warn: pct >= (cfg.auto_limit_pct ?? 60),
91
+ downgrade_model: cfg.downgrade_model,
92
+ };
93
+ }