evolclaw 3.1.11 → 3.3.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 (89) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +27 -2
  3. package/dist/agents/{resolve.js → baseagent.js} +34 -5
  4. package/dist/agents/claude-runner.js +120 -27
  5. package/dist/agents/codex-app-server-client.js +364 -0
  6. package/dist/agents/codex-runner.js +1069 -141
  7. package/dist/agents/gemini-runner.js +2 -2
  8. package/dist/agents/runner-types.js +28 -0
  9. package/dist/aun/aid/control-aid.js +67 -0
  10. package/dist/aun/aid/identity.js +20 -7
  11. package/dist/aun/aid/store.js +2 -2
  12. package/dist/aun/storage/download.js +1 -1
  13. package/dist/aun/storage/upload.js +13 -1
  14. package/dist/channels/aun.js +538 -325
  15. package/dist/channels/dingtalk.js +77 -140
  16. package/dist/channels/feishu.js +98 -151
  17. package/dist/channels/qqbot.js +75 -138
  18. package/dist/channels/wechat.js +75 -136
  19. package/dist/channels/wecom.js +75 -138
  20. package/dist/cli/agent.js +44 -13
  21. package/dist/cli/index.js +207 -46
  22. package/dist/cli/init-channel.js +38 -148
  23. package/dist/cli/init.js +192 -85
  24. package/dist/cli/model.js +1 -1
  25. package/dist/cli/stats.js +558 -0
  26. package/dist/cli/version.js +87 -0
  27. package/dist/cli/watch-msg.js +5 -2
  28. package/dist/config-store.js +48 -11
  29. package/dist/core/channel-loader.js +84 -82
  30. package/dist/core/command-handler.js +754 -172
  31. package/dist/core/daemon-file-cache.js +216 -0
  32. package/dist/core/evolagent-registry.js +4 -0
  33. package/dist/core/evolagent.js +28 -23
  34. package/dist/core/interaction-router.js +8 -0
  35. package/dist/core/message/command-handler-agent-control.js +215 -0
  36. package/dist/core/message/create-status.js +67 -0
  37. package/dist/core/message/im-renderer.js +35 -13
  38. package/dist/core/message/items-formatter.js +9 -1
  39. package/dist/core/message/message-bridge.js +52 -22
  40. package/dist/core/message/message-log.js +1 -0
  41. package/dist/core/message/message-processor.js +336 -68
  42. package/dist/core/message/message-queue.js +15 -8
  43. package/dist/core/message/pending-hints.js +232 -0
  44. package/dist/core/message/response-depth.js +56 -0
  45. package/dist/core/model/model-catalog.js +1 -1
  46. package/dist/core/model/model-scope.js +40 -7
  47. package/dist/core/permission.js +9 -12
  48. package/dist/core/relation/peer-identity.js +16 -1
  49. package/dist/core/session/adapters/claude-session-file-adapter.js +48 -5
  50. package/dist/core/session/adapters/codex-session-file-adapter.js +4 -2
  51. package/dist/core/session/session-manager.js +27 -13
  52. package/dist/core/session/session-title.js +26 -0
  53. package/dist/core/stats/billing.js +151 -0
  54. package/dist/core/stats/budget.js +93 -0
  55. package/dist/core/stats/db.js +314 -0
  56. package/dist/core/stats/eck-vars.js +84 -0
  57. package/dist/core/stats/index.js +10 -0
  58. package/dist/core/stats/normalizer.js +78 -0
  59. package/dist/core/stats/query.js +760 -0
  60. package/dist/core/stats/writer.js +115 -0
  61. package/dist/core/trigger/manager.js +34 -0
  62. package/dist/core/trigger/parser.js +9 -3
  63. package/dist/core/trigger/scheduler.js +20 -17
  64. package/dist/{agents → eck}/kit-renderer.js +5 -1
  65. package/dist/{agents → eck}/manifest-engine.js +127 -35
  66. package/dist/{agents → eck}/message-renderer.js +26 -1
  67. package/dist/index.js +185 -8
  68. package/dist/ipc.js +22 -0
  69. package/dist/paths.js +7 -3
  70. package/dist/utils/cross-platform.js +23 -5
  71. package/dist/utils/ecweb-pair.js +20 -0
  72. package/dist/utils/stats.js +14 -0
  73. package/kits/docs/evolclaw/INDEX.md +3 -1
  74. package/kits/docs/evolclaw/fs-architecture.md +1215 -0
  75. package/kits/docs/evolclaw/fs.md +131 -0
  76. package/kits/docs/evolclaw/group-fs.md +209 -0
  77. package/kits/docs/evolclaw/stats.md +70 -0
  78. package/kits/docs/venues/aun-group.md +29 -6
  79. package/kits/docs/venues/group.md +5 -4
  80. package/kits/eck_manifest.json +12 -0
  81. package/kits/eck_message_manifest.json +30 -3
  82. package/kits/rules/05-venue.md +1 -1
  83. package/kits/templates/message-fragments/inject-default.md +2 -0
  84. package/kits/templates/message-fragments/item.md +1 -1
  85. package/kits/templates/system-fragments/response-depth.md +16 -0
  86. package/package.json +4 -4
  87. package/dist/agents/baseagent-normalize.js +0 -19
  88. package/dist/core/relation/peer-key.js +0 -16
  89. package/dist/utils/channel-helpers.js +0 -46
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Stats DB — SQLite 初始化、WAL、建表、索引、归档。
3
+ * 技术选型:node:sqlite (Node 22.5+),与主包 config-store.ts 保持一致。
4
+ */
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+ import { createRequire } from 'module';
8
+ import { logger } from '../../utils/logger.js';
9
+ const requireFromHere = createRequire(import.meta.url);
10
+ let sqliteModule; // undefined=未尝试, null=不可用
11
+ function loadSqlite() {
12
+ if (sqliteModule !== undefined)
13
+ return sqliteModule;
14
+ try {
15
+ sqliteModule = requireFromHere('node:sqlite');
16
+ }
17
+ catch {
18
+ logger.warn(`[StatsDB] node:sqlite unavailable (Node < 22.5?). Stats disabled.`);
19
+ sqliteModule = null;
20
+ }
21
+ return sqliteModule;
22
+ }
23
+ // 单例:写者(daemon)持有一个 read-write 实例
24
+ let _db = null;
25
+ export function getStatsDir(evolclawHome) {
26
+ return path.join(evolclawHome, 'data', 'stats');
27
+ }
28
+ export function getDbPath(evolclawHome) {
29
+ return path.join(getStatsDir(evolclawHome), 'usage.db');
30
+ }
31
+ /** 获取写者单例(daemon 调用)。首次调用时建表。 */
32
+ export function getDb(evolclawHome) {
33
+ if (_db)
34
+ return _db;
35
+ const sqlite = loadSqlite();
36
+ if (!sqlite)
37
+ return null;
38
+ const dir = getStatsDir(evolclawHome);
39
+ fs.mkdirSync(dir, { recursive: true });
40
+ const dbPath = getDbPath(evolclawHome);
41
+ try {
42
+ _db = new sqlite.DatabaseSync(dbPath);
43
+ _db.exec('PRAGMA journal_mode=WAL');
44
+ _db.exec('PRAGMA synchronous=NORMAL');
45
+ _initTables(_db);
46
+ logger.info(`[StatsDB] Opened: ${dbPath}`);
47
+ return _db;
48
+ }
49
+ catch (e) {
50
+ logger.error(`[StatsDB] Failed to open DB: ${e}`);
51
+ return null;
52
+ }
53
+ }
54
+ /** 只读连接(CLI / ecweb 调用)。每次返回新连接,调用方负责 close()。 */
55
+ export function openReadonlyDb(dbPath) {
56
+ const sqlite = loadSqlite();
57
+ if (!sqlite)
58
+ return null;
59
+ if (!fs.existsSync(dbPath))
60
+ return null;
61
+ try {
62
+ return new sqlite.DatabaseSync(dbPath, { readOnly: true });
63
+ }
64
+ catch (e) {
65
+ logger.warn(`[StatsDB] Failed to open readonly DB ${dbPath}: ${e}`);
66
+ return null;
67
+ }
68
+ }
69
+ function _initTables(db) {
70
+ db.exec(`
71
+ CREATE TABLE IF NOT EXISTS usage_events (
72
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
73
+ ts INTEGER NOT NULL,
74
+ agent_aid TEXT NOT NULL,
75
+ peer_key TEXT NOT NULL,
76
+ peer_type TEXT,
77
+ session_id TEXT,
78
+ model TEXT NOT NULL,
79
+ billing_fn TEXT NOT NULL,
80
+ input_tokens INTEGER NOT NULL DEFAULT 0,
81
+ output_tokens INTEGER NOT NULL DEFAULT 0,
82
+ cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
83
+ cache_read_tokens INTEGER NOT NULL DEFAULT 0,
84
+ cache_hit_tokens INTEGER,
85
+ cache_miss_tokens INTEGER,
86
+ image_tokens INTEGER,
87
+ total_context_tokens INTEGER,
88
+ turns INTEGER NOT NULL DEFAULT 1,
89
+ duration_ms INTEGER,
90
+ context_window_pct REAL
91
+ );
92
+ CREATE INDEX IF NOT EXISTS idx_ue_ts ON usage_events(ts);
93
+ CREATE INDEX IF NOT EXISTS idx_ue_agent_ts ON usage_events(agent_aid, ts);
94
+ CREATE INDEX IF NOT EXISTS idx_ue_peer_ts ON usage_events(agent_aid, peer_key, ts);
95
+ CREATE INDEX IF NOT EXISTS idx_ue_model_ts ON usage_events(model, ts);
96
+ CREATE INDEX IF NOT EXISTS idx_ue_session_ts ON usage_events(session_id, ts);
97
+
98
+ CREATE TABLE IF NOT EXISTS context_breakdown (
99
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
100
+ ts INTEGER NOT NULL,
101
+ agent_aid TEXT NOT NULL,
102
+ session_id TEXT NOT NULL,
103
+ turn_count INTEGER NOT NULL,
104
+ model TEXT NOT NULL,
105
+ max_tokens INTEGER NOT NULL,
106
+ system_prompt INTEGER,
107
+ system_tools INTEGER,
108
+ mcp_tools INTEGER,
109
+ custom_agents INTEGER,
110
+ memory_files INTEGER,
111
+ skills INTEGER,
112
+ messages INTEGER,
113
+ free_space INTEGER,
114
+ total_estimated INTEGER
115
+ );
116
+ CREATE INDEX IF NOT EXISTS idx_cb_session ON context_breakdown(session_id, ts);
117
+ CREATE INDEX IF NOT EXISTS idx_cb_agent ON context_breakdown(agent_aid, ts);
118
+
119
+ CREATE TABLE IF NOT EXISTS message_events (
120
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
121
+ ts INTEGER NOT NULL,
122
+ agent_aid TEXT NOT NULL,
123
+ peer_key TEXT NOT NULL,
124
+ direction TEXT NOT NULL,
125
+ msg_type TEXT,
126
+ bytes INTEGER NOT NULL DEFAULT 0,
127
+ encrypted INTEGER DEFAULT 0,
128
+ chatmode TEXT
129
+ );
130
+ CREATE INDEX IF NOT EXISTS idx_me_ts ON message_events(ts);
131
+ CREATE INDEX IF NOT EXISTS idx_me_agent_ts ON message_events(agent_aid, ts);
132
+ CREATE INDEX IF NOT EXISTS idx_me_peer_ts ON message_events(agent_aid, peer_key, ts);
133
+
134
+ -- 按天预聚合表:grain = 天 × agent × peer × session × model × billing_fn。
135
+ -- 由 writer.ts 写时增量 UPSERT 维护,rebuildDailyRollup() 全量重建纠偏。
136
+ -- 保留 model+billing_fn 维度以便查询时仍按 calcCost 现算成本。
137
+ -- 日级行很小,永留主库、不参与年度归档。
138
+ CREATE TABLE IF NOT EXISTS usage_daily (
139
+ day TEXT NOT NULL, -- 'YYYY-MM-DD' localtime
140
+ agent_aid TEXT NOT NULL,
141
+ peer_key TEXT NOT NULL,
142
+ peer_type TEXT NOT NULL DEFAULT '',-- 'private' | 'group',由 peer_key 唯一决定
143
+ session_id TEXT NOT NULL DEFAULT '',
144
+ model TEXT NOT NULL,
145
+ billing_fn TEXT NOT NULL,
146
+ input_tokens INTEGER NOT NULL DEFAULT 0,
147
+ output_tokens INTEGER NOT NULL DEFAULT 0,
148
+ cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
149
+ cache_read_tokens INTEGER NOT NULL DEFAULT 0,
150
+ cache_hit_tokens INTEGER NOT NULL DEFAULT 0,
151
+ cache_miss_tokens INTEGER NOT NULL DEFAULT 0,
152
+ image_tokens INTEGER NOT NULL DEFAULT 0,
153
+ total_context_tokens INTEGER NOT NULL DEFAULT 0,
154
+ turns INTEGER NOT NULL DEFAULT 0,
155
+ calls INTEGER NOT NULL DEFAULT 0,
156
+ PRIMARY KEY (day, agent_aid, peer_key, session_id, model, billing_fn)
157
+ );
158
+ CREATE INDEX IF NOT EXISTS idx_ud_day ON usage_daily(day);
159
+ CREATE INDEX IF NOT EXISTS idx_ud_session ON usage_daily(session_id);
160
+
161
+ -- 大模型调用明细:每次大模型调用一行(尽力而为;非 Claude 拿不到逐次时降级为单行累计)。
162
+ -- 关联三个 ID:task_id(一次 runQuery)、session_id(evolclaw 会话)、agent_session_id(SDK 会话)。
163
+ CREATE TABLE IF NOT EXISTS model_calls (
164
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
165
+ ts INTEGER NOT NULL,
166
+ task_id TEXT NOT NULL,
167
+ session_id TEXT,
168
+ agent_session_id TEXT,
169
+ agent_aid TEXT NOT NULL,
170
+ peer_key TEXT NOT NULL,
171
+ call_index INTEGER NOT NULL,
172
+ model TEXT NOT NULL,
173
+ request_id TEXT,
174
+ message_id TEXT,
175
+ input_tokens INTEGER NOT NULL DEFAULT 0,
176
+ output_tokens INTEGER NOT NULL DEFAULT 0,
177
+ cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
178
+ cache_read_tokens INTEGER NOT NULL DEFAULT 0,
179
+ degraded INTEGER NOT NULL DEFAULT 0
180
+ );
181
+ CREATE INDEX IF NOT EXISTS idx_mc_task ON model_calls(task_id);
182
+ CREATE INDEX IF NOT EXISTS idx_mc_session ON model_calls(session_id, ts);
183
+ CREATE INDEX IF NOT EXISTS idx_mc_agentsession ON model_calls(agent_session_id);
184
+ CREATE INDEX IF NOT EXISTS idx_mc_ts ON model_calls(ts);
185
+ `);
186
+ // 轻量迁移:旧库的 usage_daily 可能缺 peer_type 列(无 migration 机制,CREATE IF NOT EXISTS
187
+ // 不会补列)。检测后 ALTER 补上;补列后旧行 peer_type 为空,需跑一次 rebuildDailyRollup 回填。
188
+ try {
189
+ const cols = db.prepare(`PRAGMA table_info(usage_daily)`).all();
190
+ if (!cols.some(c => c.name === 'peer_type')) {
191
+ db.exec(`ALTER TABLE usage_daily ADD COLUMN peer_type TEXT NOT NULL DEFAULT ''`);
192
+ }
193
+ }
194
+ catch (e) {
195
+ logger.warn(`[StatsDB] usage_daily peer_type 迁移检测失败: ${e}`);
196
+ }
197
+ }
198
+ /**
199
+ * 全量重建 usage_daily:从 usage_events 明细按天聚合重算。
200
+ * 用途:首次回填历史数据、每日自愈纠正写时漂移、手动 `ec stats --rebuild`。
201
+ * 单事务内 DELETE + INSERT...SELECT,失败回滚不破坏现有 rollup。
202
+ * 仅扫主库明细(往年明细已归档,但其 rollup 行已在归档前写入并永留主库)。
203
+ * @returns 重建后的行数,DB 不可用时返回 -1。
204
+ */
205
+ export function rebuildDailyRollup(evolclawHome) {
206
+ const db = getDb(evolclawHome);
207
+ if (!db)
208
+ return -1;
209
+ try {
210
+ db.exec('BEGIN');
211
+ db.exec('DELETE FROM usage_daily');
212
+ db.exec(`
213
+ INSERT INTO usage_daily
214
+ (day, agent_aid, peer_key, peer_type, session_id, model, billing_fn,
215
+ input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
216
+ cache_hit_tokens, cache_miss_tokens, image_tokens, total_context_tokens,
217
+ turns, calls)
218
+ SELECT
219
+ strftime('%Y-%m-%d', ts/1000, 'unixepoch', 'localtime') AS day,
220
+ agent_aid, peer_key, MAX(COALESCE(peer_type, '')),
221
+ COALESCE(session_id, ''), model, billing_fn,
222
+ SUM(input_tokens), SUM(output_tokens),
223
+ SUM(cache_creation_tokens), SUM(cache_read_tokens),
224
+ SUM(COALESCE(cache_hit_tokens, 0)), SUM(COALESCE(cache_miss_tokens, 0)),
225
+ SUM(COALESCE(image_tokens, 0)), SUM(COALESCE(total_context_tokens, 0)),
226
+ SUM(turns), COUNT(*)
227
+ FROM usage_events
228
+ GROUP BY day, agent_aid, peer_key, COALESCE(session_id, ''), model, billing_fn
229
+ `);
230
+ db.exec('COMMIT');
231
+ const row = db.prepare('SELECT COUNT(*) AS n FROM usage_daily').get();
232
+ logger.info(`[StatsDB] Rebuilt usage_daily: ${row.n} rows`);
233
+ return row.n;
234
+ }
235
+ catch (e) {
236
+ try {
237
+ db.exec('ROLLBACK');
238
+ }
239
+ catch { }
240
+ logger.error(`[StatsDB] rebuildDailyRollup failed: ${e}`);
241
+ return -1;
242
+ }
243
+ }
244
+ // ── 归档 ────────────────────────────────────────────────────────────────────
245
+ /**
246
+ * 归档超过一整个自然年的数据到 usage-{year}.db,然后从主库删除。
247
+ * 例:当前 2026-05 → 归档所有 ts < 2025-01-01 00:00:00 的数据(即 2024 年及更早)。
248
+ * 安全:归档失败不删除主库数据。
249
+ */
250
+ export function archiveOldData(evolclawHome) {
251
+ const db = getDb(evolclawHome);
252
+ if (!db)
253
+ return;
254
+ const now = new Date();
255
+ // 归档截止:当前年份第一天 00:00:00
256
+ const cutoffYear = now.getFullYear(); // e.g. 2026
257
+ const cutoffTs = Date.UTC(cutoffYear, 0, 1); // 2026-01-01 00:00:00 UTC
258
+ // 找出主库中所有早于 cutoff 的年份
259
+ const years = db.prepare(`SELECT DISTINCT CAST(strftime('%Y', ts/1000, 'unixepoch') AS INTEGER) AS y
260
+ FROM usage_events WHERE ts < ? ORDER BY y`).all(cutoffTs).map((r) => r.y);
261
+ if (years.length === 0)
262
+ return;
263
+ const statsDir = getStatsDir(evolclawHome);
264
+ const sqlite = loadSqlite();
265
+ if (!sqlite)
266
+ return;
267
+ for (const year of years) {
268
+ const archivePath = path.join(statsDir, `usage-${year}.db`);
269
+ const yearStart = Date.UTC(year, 0, 1);
270
+ const yearEnd = Date.UTC(year + 1, 0, 1);
271
+ try {
272
+ // 打开/创建归档库,建同结构表
273
+ const archDb = new sqlite.DatabaseSync(archivePath);
274
+ archDb.exec('PRAGMA journal_mode=WAL');
275
+ _initTables(archDb);
276
+ archDb.close();
277
+ // ATTACH 方式批量迁移(同进程内最简洁)
278
+ db.exec(`ATTACH DATABASE '${archivePath.replace(/'/g, "''")}' AS arch`);
279
+ db.exec('BEGIN');
280
+ db.exec(`INSERT OR IGNORE INTO arch.usage_events SELECT * FROM main.usage_events WHERE ts >= ${yearStart} AND ts < ${yearEnd}`);
281
+ db.exec(`INSERT OR IGNORE INTO arch.context_breakdown SELECT * FROM main.context_breakdown WHERE ts >= ${yearStart} AND ts < ${yearEnd}`);
282
+ db.exec(`DELETE FROM main.usage_events WHERE ts >= ${yearStart} AND ts < ${yearEnd}`);
283
+ db.exec(`DELETE FROM main.context_breakdown WHERE ts >= ${yearStart} AND ts < ${yearEnd}`);
284
+ db.exec('COMMIT');
285
+ db.exec('DETACH DATABASE arch');
286
+ logger.info(`[StatsDB] Archived year ${year} → ${archivePath}`);
287
+ }
288
+ catch (e) {
289
+ try {
290
+ db.exec('ROLLBACK');
291
+ }
292
+ catch { }
293
+ try {
294
+ db.exec('DETACH DATABASE arch');
295
+ }
296
+ catch { }
297
+ logger.error(`[StatsDB] Archive failed for year ${year}: ${e}`);
298
+ }
299
+ }
300
+ // VACUUM 回收空间(WAL 模式下安全)
301
+ try {
302
+ db.exec('VACUUM');
303
+ }
304
+ catch { }
305
+ }
306
+ /** 列出所有可用的归档库路径(按年份排序)。 */
307
+ export function listArchivePaths(evolclawHome) {
308
+ const dir = getStatsDir(evolclawHome);
309
+ if (!fs.existsSync(dir))
310
+ return [];
311
+ return fs.readdirSync(dir)
312
+ .map(f => { const m = f.match(/^usage-(\d{4})\.db$/); return m ? { year: +m[1], path: path.join(dir, f) } : null; })
313
+ .filter(Boolean);
314
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * eck-vars.ts — 组装 $STATS_* ECK 注入变量。
3
+ *
4
+ * 分层原则(保护 prompt cache):
5
+ * - system prompt 层:今日累计、预算状态(变化慢,稳定可缓存)
6
+ * - 消息提示词层:会话级动态数据(每轮变,不注入 system)
7
+ */
8
+ import { openReadonlyDb, getDbPath } from './db.js';
9
+ import { calcCost } from './billing.js';
10
+ import { getBudgetStatus } from './budget.js';
11
+ function _todayRange() {
12
+ const now = new Date();
13
+ return { from_ts: Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) };
14
+ }
15
+ function _calcTodayCosts(evolclawHome, agentAid, peerKey) {
16
+ const db = openReadonlyDb(getDbPath(evolclawHome));
17
+ if (!db)
18
+ return { usd: 0, cny: 0, input: 0, output: 0, cacheRead: 0, calls: 0 };
19
+ const { from_ts } = _todayRange();
20
+ const conds = ['ts >= ?'];
21
+ const params = [from_ts];
22
+ if (agentAid) {
23
+ conds.push('agent_aid = ?');
24
+ params.push(agentAid);
25
+ }
26
+ if (peerKey) {
27
+ conds.push('peer_key = ?');
28
+ params.push(peerKey);
29
+ }
30
+ let usd = 0, cny = 0, input = 0, output = 0, cacheRead = 0, calls = 0;
31
+ try {
32
+ const rows = db.prepare(`SELECT * FROM usage_events WHERE ${conds.join(' AND ')}`).all(...params);
33
+ for (const r of rows) {
34
+ const cost = calcCost(evolclawHome, r);
35
+ usd += cost.usd ?? 0;
36
+ cny += cost.cny ?? 0;
37
+ input += r.input_tokens ?? 0;
38
+ output += r.output_tokens ?? 0;
39
+ cacheRead += r.cache_read_tokens ?? 0;
40
+ calls++;
41
+ }
42
+ }
43
+ finally {
44
+ db.close();
45
+ }
46
+ return { usd, cny, input, output, cacheRead, calls };
47
+ }
48
+ /** 注入 system prompt 的变量(变化慢,可缓存)。 */
49
+ export function buildSystemVars(evolclawHome, agentAid, peerKey) {
50
+ const agent = _calcTodayCosts(evolclawHome, agentAid);
51
+ const peer = peerKey ? _calcTodayCosts(evolclawHome, agentAid, peerKey) : { usd: 0, cny: 0 };
52
+ const totalIn = agent.input + agent.cacheRead;
53
+ const hitRate = totalIn > 0 ? ((agent.cacheRead / totalIn) * 100).toFixed(1) + '%' : '0%';
54
+ const budget = getBudgetStatus(evolclawHome, agentAid, peerKey);
55
+ return {
56
+ STATS_TODAY_INPUT_TOKENS: String(agent.input),
57
+ STATS_TODAY_OUTPUT_TOKENS: String(agent.output),
58
+ STATS_TODAY_CACHE_READ_TOKENS: String(agent.cacheRead),
59
+ STATS_TODAY_CACHE_HIT_RATE: hitRate,
60
+ STATS_TODAY_COST_USD: agent.usd.toFixed(4),
61
+ STATS_TODAY_COST_CNY: agent.cny.toFixed(4),
62
+ STATS_TODAY_CALL_COUNT: String(agent.calls),
63
+ STATS_PEER_TODAY_COST_USD: peer.usd?.toFixed(4) ?? '0',
64
+ STATS_PEER_TODAY_COST_CNY: peer.cny?.toFixed(4) ?? '0',
65
+ STATS_BUDGET_DAILY_LIMIT_USD: budget.daily_limit_usd >= 0 ? budget.daily_limit_usd.toFixed(2) : 'unlimited',
66
+ STATS_BUDGET_DAILY_USED_USD: budget.daily_used_usd.toFixed(4),
67
+ STATS_BUDGET_DAILY_REMAINING_USD: budget.daily_remaining_usd >= 0 ? budget.daily_remaining_usd.toFixed(2) : 'unlimited',
68
+ STATS_BUDGET_PCT_USED: budget.pct_used.toFixed(1),
69
+ STATS_BUDGET_WARN: String(budget.soft_warn),
70
+ };
71
+ }
72
+ /** 注入消息提示词的变量(每轮变,不进 system prompt)。 */
73
+ export function buildTurnVars(opts) {
74
+ return {
75
+ SESSION_TURN_COUNT: String(opts.sessionTurnCount),
76
+ SESSION_LLM_CALL_COUNT: String(opts.sessionLlmCallCount),
77
+ STATS_CTX_TOTAL_TOKENS: String(opts.ctxTotalTokens ?? 0),
78
+ STATS_CTX_PCT: opts.ctxPct != null ? opts.ctxPct.toFixed(1) : '0',
79
+ STATS_CTX_SYSTEM_TOKENS: String(opts.ctxSystemTokens ?? 0),
80
+ STATS_CTX_MESSAGES_TOKENS: String(opts.ctxMessagesTokens ?? 0),
81
+ STATS_CTX_TOOLS_TOKENS: String(opts.ctxToolsTokens ?? 0),
82
+ STATS_BUDGET_AUTO_WARN: String(opts.autoBudgetWarn),
83
+ };
84
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * src/core/stats/index.ts — Stats 模块公开 API。
3
+ */
4
+ export { getDb, openReadonlyDb, getStatsDir, getDbPath, archiveOldData, listArchivePaths, rebuildDailyRollup } from './db.js';
5
+ export { normalizeUsage } from './normalizer.js';
6
+ export { insertUsageEvent, insertContextBreakdown, insertMessageEvent, insertModelCalls } from './writer.js';
7
+ export { calcCost, resolvePriceRow, resolveCanonicalModel, registerBillingFn } from './billing.js';
8
+ export { queryAggregated, queryTodaySummary, querySessionTurns, queryContextBreakdown, queryTopPeers, queryTopModels, queryMessageAggregated, queryMessageTodaySummary, queryPeerList, querySummary, queryPeerDaily, queryTaskModelCalls, querySessionModelCalls } from './query.js';
9
+ export { getBudgetStatus } from './budget.js';
10
+ export { buildSystemVars, buildTurnVars } from './eck-vars.js';
@@ -0,0 +1,78 @@
1
+ /**
2
+ * normalizer.ts — 各模型 raw usage 字段 → 归一化 UsageEvent,同时推断 billing_fn。
3
+ * 按实际有什么字段智能探测,不假设接入方式。
4
+ */
5
+ export function normalizeUsage(raw, meta) {
6
+ let billing_fn;
7
+ let input_tokens = 0;
8
+ let output_tokens = 0;
9
+ let cache_creation_tokens = 0;
10
+ let cache_read_tokens = 0;
11
+ let cache_hit_tokens;
12
+ let cache_miss_tokens;
13
+ let image_tokens;
14
+ let total_context_tokens;
15
+ if (raw.cache_hit_tokens != null || raw.cache_miss_tokens != null) {
16
+ // DeepSeek 口径
17
+ billing_fn = 'per_token_deepseek_v1';
18
+ output_tokens = raw.output_tokens ?? raw.completion_tokens ?? 0;
19
+ cache_hit_tokens = raw.cache_hit_tokens ?? 0;
20
+ cache_miss_tokens = raw.cache_miss_tokens ?? 0;
21
+ input_tokens = cache_miss_tokens; // 未命中部分才算 input(计费口径)
22
+ // 实际上下文长度 = 命中 + 未命中(total KV cache input)
23
+ total_context_tokens = cache_hit_tokens + cache_miss_tokens;
24
+ }
25
+ else if (raw.promptTokenCount != null || raw.candidatesTokenCount != null) {
26
+ // Gemini 原生
27
+ billing_fn = 'per_token_tiered_v1';
28
+ input_tokens = raw.promptTokenCount ?? 0;
29
+ output_tokens = raw.candidatesTokenCount ?? 0;
30
+ cache_read_tokens = raw.cachedContentTokenCount ?? 0;
31
+ total_context_tokens = raw.totalTokenCount ?? (input_tokens + output_tokens);
32
+ }
33
+ else if (raw.image_tokens != null) {
34
+ // 视觉模型(Qwen-VL 等)
35
+ billing_fn = 'per_token_image_v1';
36
+ input_tokens = raw.input_tokens ?? raw.prompt_tokens ?? 0;
37
+ output_tokens = raw.output_tokens ?? raw.completion_tokens ?? 0;
38
+ image_tokens = raw.image_tokens;
39
+ }
40
+ else if (raw.cache_creation_input_tokens != null || raw.cache_read_input_tokens != null) {
41
+ // Anthropic / Claude 原生
42
+ billing_fn = 'per_token_v1';
43
+ input_tokens = raw.input_tokens ?? 0;
44
+ output_tokens = raw.output_tokens ?? 0;
45
+ cache_creation_tokens = raw.cache_creation_input_tokens ?? 0;
46
+ cache_read_tokens = raw.cache_read_input_tokens ?? 0;
47
+ }
48
+ else {
49
+ // OpenAI 兼容降级(Kimi / MiniMax / 截断网关等)
50
+ billing_fn = 'per_token_v1';
51
+ input_tokens = raw.input_tokens ?? raw.prompt_tokens ?? 0;
52
+ output_tokens = raw.output_tokens ?? raw.completion_tokens ?? 0;
53
+ cache_read_tokens = raw.prompt_tokens_details?.cached_tokens ?? 0;
54
+ }
55
+ if (total_context_tokens == null) {
56
+ total_context_tokens = input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens || undefined;
57
+ }
58
+ return {
59
+ ts: meta.ts,
60
+ agent_aid: meta.agent_aid,
61
+ peer_key: meta.peer_key,
62
+ peer_type: meta.peer_type,
63
+ session_id: meta.session_id,
64
+ model: meta.model,
65
+ billing_fn,
66
+ input_tokens,
67
+ output_tokens,
68
+ cache_creation_tokens,
69
+ cache_read_tokens,
70
+ cache_hit_tokens,
71
+ cache_miss_tokens,
72
+ image_tokens,
73
+ total_context_tokens,
74
+ turns: meta.turns ?? 1,
75
+ duration_ms: meta.duration_ms,
76
+ context_window_pct: meta.context_window_pct,
77
+ };
78
+ }