evolclaw 3.2.0 → 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 (83) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +1 -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/store.js +1 -1
  10. package/dist/aun/storage/download.js +1 -1
  11. package/dist/aun/storage/upload.js +13 -1
  12. package/dist/channels/aun.js +406 -293
  13. package/dist/channels/dingtalk.js +77 -140
  14. package/dist/channels/feishu.js +97 -150
  15. package/dist/channels/qqbot.js +75 -138
  16. package/dist/channels/wechat.js +75 -136
  17. package/dist/channels/wecom.js +75 -138
  18. package/dist/cli/agent.js +8 -5
  19. package/dist/cli/index.js +177 -44
  20. package/dist/cli/init.js +33 -6
  21. package/dist/cli/model.js +1 -1
  22. package/dist/cli/stats.js +558 -0
  23. package/dist/cli/version.js +87 -0
  24. package/dist/cli/watch-msg.js +5 -2
  25. package/dist/config-store.js +12 -6
  26. package/dist/core/channel-loader.js +84 -82
  27. package/dist/core/command-handler.js +473 -114
  28. package/dist/core/evolagent-registry.js +1 -0
  29. package/dist/core/evolagent.js +1 -1
  30. package/dist/core/interaction-router.js +8 -0
  31. package/dist/core/message/command-handler-agent-control.js +63 -1
  32. package/dist/core/message/im-renderer.js +35 -13
  33. package/dist/core/message/items-formatter.js +9 -1
  34. package/dist/core/message/message-bridge.js +49 -21
  35. package/dist/core/message/message-log.js +1 -0
  36. package/dist/core/message/message-processor.js +295 -35
  37. package/dist/core/message/message-queue.js +2 -2
  38. package/dist/core/message/pending-hints.js +232 -0
  39. package/dist/core/message/response-depth.js +56 -0
  40. package/dist/core/model/model-catalog.js +1 -1
  41. package/dist/core/model/model-scope.js +2 -2
  42. package/dist/core/permission.js +9 -12
  43. package/dist/core/relation/peer-identity.js +16 -1
  44. package/dist/core/session/adapters/codex-session-file-adapter.js +4 -2
  45. package/dist/core/session/session-manager.js +27 -13
  46. package/dist/core/session/session-title.js +26 -0
  47. package/dist/core/stats/billing.js +151 -0
  48. package/dist/core/stats/budget.js +93 -0
  49. package/dist/core/stats/db.js +314 -0
  50. package/dist/core/stats/eck-vars.js +84 -0
  51. package/dist/core/stats/index.js +10 -0
  52. package/dist/core/stats/normalizer.js +78 -0
  53. package/dist/core/stats/query.js +760 -0
  54. package/dist/core/stats/writer.js +115 -0
  55. package/dist/core/trigger/manager.js +34 -0
  56. package/dist/core/trigger/parser.js +9 -3
  57. package/dist/core/trigger/scheduler.js +20 -17
  58. package/dist/{agents → eck}/manifest-engine.js +20 -1
  59. package/dist/{agents → eck}/message-renderer.js +24 -1
  60. package/dist/index.js +130 -8
  61. package/dist/ipc.js +17 -1
  62. package/dist/utils/cross-platform.js +23 -5
  63. package/dist/utils/ecweb-pair.js +20 -0
  64. package/dist/utils/stats.js +14 -0
  65. package/kits/docs/evolclaw/INDEX.md +3 -1
  66. package/kits/docs/evolclaw/fs-architecture.md +1215 -0
  67. package/kits/docs/evolclaw/fs.md +131 -0
  68. package/kits/docs/evolclaw/group-fs.md +209 -0
  69. package/kits/docs/evolclaw/stats.md +70 -0
  70. package/kits/docs/venues/aun-group.md +29 -6
  71. package/kits/docs/venues/group.md +5 -4
  72. package/kits/eck_manifest.json +12 -0
  73. package/kits/eck_message_manifest.json +30 -3
  74. package/kits/rules/05-venue.md +1 -1
  75. package/kits/templates/message-fragments/inject-default.md +2 -0
  76. package/kits/templates/system-fragments/response-depth.md +16 -0
  77. package/package.json +4 -4
  78. package/dist/agents/baseagent-normalize.js +0 -19
  79. package/dist/core/relation/peer-key.js +0 -16
  80. package/dist/evolclaw-config.js +0 -11
  81. package/dist/utils/channel-helpers.js +0 -46
  82. /package/dist/core/{cache/file-cache.js → daemon-file-cache.js} +0 -0
  83. /package/dist/{agents → eck}/kit-renderer.js +0 -0
@@ -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
+ }
@@ -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';