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.
- package/CHANGELOG.md +53 -0
- package/README.md +7 -4
- package/dist/agents/{resolve.js → baseagent.js} +34 -5
- package/dist/agents/claude-runner.js +120 -31
- package/dist/agents/codex-app-server-client.js +364 -0
- package/dist/agents/codex-runner.js +1152 -140
- package/dist/agents/gemini-runner.js +2 -2
- package/dist/agents/runner-types.js +58 -0
- package/dist/aun/aid/store.js +1 -1
- package/dist/aun/outbox.js +14 -2
- package/dist/aun/storage/download.js +1 -1
- package/dist/aun/storage/upload.js +13 -1
- package/dist/channels/aun.js +869 -358
- package/dist/channels/dingtalk.js +77 -140
- package/dist/channels/feishu.js +125 -154
- package/dist/channels/qqbot.js +75 -138
- package/dist/channels/wechat.js +75 -136
- package/dist/channels/wecom.js +75 -138
- package/dist/cli/agent-command.js +591 -0
- package/dist/cli/agent.js +23 -8
- package/dist/cli/aun-commands.js +1444 -0
- package/dist/cli/ctl-command.js +78 -0
- package/dist/cli/daemon-commands.js +2707 -0
- package/dist/cli/index.js +23 -4905
- package/dist/cli/init.js +33 -6
- package/dist/cli/model.js +1 -1
- package/dist/cli/restart-monitor.js +539 -0
- package/dist/cli/stats.js +558 -0
- package/dist/cli/version.js +87 -0
- package/dist/cli/watch-logs.js +33 -0
- package/dist/cli/watch-msg.js +5 -2
- package/dist/config-store.js +12 -6
- package/dist/core/channel-loader.js +88 -83
- package/dist/core/command/command-handler.js +1189 -0
- package/dist/core/command/menu-handler.js +1478 -0
- package/dist/core/command/slash-gate.js +142 -0
- package/dist/core/command/slash-handler.js +2090 -0
- package/dist/core/evolagent-registry.js +82 -0
- package/dist/core/evolagent.js +17 -1
- package/dist/core/interaction-router.js +8 -0
- package/dist/core/message/command-handler-agent-control.js +63 -1
- package/dist/core/message/im-renderer.js +91 -51
- package/dist/core/message/items-formatter.js +9 -1
- package/dist/core/message/message-bridge.js +73 -24
- package/dist/core/message/message-log.js +1 -0
- package/dist/core/message/message-processor.js +432 -94
- package/dist/core/message/message-queue.js +70 -2
- package/dist/core/message/pending-hints.js +232 -0
- package/dist/core/model/model-catalog.js +1 -1
- package/dist/core/model/model-scope.js +2 -2
- package/dist/core/permission.js +25 -12
- package/dist/core/relation/peer-identity.js +16 -1
- package/dist/core/session/adapters/codex-session-file-adapter.js +4 -2
- package/dist/core/session/session-manager.js +86 -26
- package/dist/core/session/session-title.js +26 -0
- package/dist/core/stats/billing.js +151 -0
- package/dist/core/stats/budget.js +93 -0
- package/dist/core/stats/db.js +334 -0
- package/dist/core/stats/eck-vars.js +84 -0
- package/dist/core/stats/index.js +10 -0
- package/dist/core/stats/normalizer.js +78 -0
- package/dist/core/stats/query.js +760 -0
- package/dist/core/stats/writer.js +115 -0
- package/dist/core/trigger/manager.js +34 -0
- package/dist/core/trigger/parser.js +9 -3
- package/dist/core/trigger/scheduler.js +20 -17
- package/dist/data/error-dict.json +7 -0
- package/dist/{agents → eck}/manifest-engine.js +20 -1
- package/dist/{agents → eck}/message-renderer.js +24 -1
- package/dist/index.js +174 -9
- package/dist/ipc.js +116 -1
- package/dist/utils/cross-platform.js +58 -5
- package/dist/utils/ecweb-launch.js +49 -0
- package/dist/utils/ecweb-pair.js +20 -0
- package/dist/utils/error-utils.js +18 -5
- package/dist/utils/npm-ops.js +38 -8
- package/dist/utils/stats.js +77 -6
- package/kits/docs/evolclaw/INDEX.md +3 -1
- package/kits/docs/evolclaw/fs-architecture.md +1215 -0
- package/kits/docs/evolclaw/fs.md +131 -0
- package/kits/docs/evolclaw/group-fs.md +209 -0
- package/kits/docs/evolclaw/stats.md +70 -0
- package/kits/docs/venues/aun-group.md +29 -6
- package/kits/docs/venues/group.md +5 -4
- package/kits/eck_message_manifest.json +30 -3
- package/kits/rules/05-venue.md +1 -1
- package/kits/templates/message-fragments/inject-default.md +2 -0
- package/package.json +5 -6
- package/dist/agents/baseagent-normalize.js +0 -19
- package/dist/core/command-handler.js +0 -3876
- package/dist/core/relation/peer-key.js +0 -16
- package/dist/evolclaw-config.js +0 -11
- package/dist/utils/channel-helpers.js +0 -46
- /package/dist/core/{cache/file-cache.js → daemon-file-cache.js} +0 -0
- /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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
637
|
-
const
|
|
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:
|
|
703
|
+
agentId: inheritedAgentId,
|
|
651
704
|
sessionKey,
|
|
652
|
-
chatType:
|
|
653
|
-
sessionMode: this.resolveDefaultSessionMode(channel,
|
|
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
|
|
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 ||
|
|
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
|
-
|
|
746
|
-
|
|
747
|
-
const
|
|
748
|
-
const
|
|
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
|
-
|
|
1026
|
-
let
|
|
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:
|
|
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
|
-
|
|
1150
|
-
let
|
|
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
|
+
}
|