metame-cli 1.4.15 → 1.4.18

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.
@@ -1,5 +1,11 @@
1
1
  'use strict';
2
2
 
3
+ const {
4
+ USAGE_CATEGORY_ORDER,
5
+ CORE_USAGE_CATEGORIES,
6
+ USAGE_CATEGORY_LABEL,
7
+ } = require('./usage-classifier');
8
+
3
9
  function createAdminCommandHandler(deps) {
4
10
  const {
5
11
  fs,
@@ -17,8 +23,71 @@ function createAdminCommandHandler(deps) {
17
23
  getAllTasks,
18
24
  dispatchTask,
19
25
  log,
26
+ skillEvolution,
27
+ taskBoard,
28
+ taskEnvelope,
20
29
  } = deps;
21
30
 
31
+ function resolveProjectKey(targetName, projects) {
32
+ if (!targetName || !projects) return null;
33
+ for (const [key, proj] of Object.entries(projects || {})) {
34
+ const nicknames = Array.isArray(proj.nicknames)
35
+ ? proj.nicknames
36
+ : (proj.nicknames ? [proj.nicknames] : []);
37
+ if (key === targetName || nicknames.some(n => n === targetName)) return key;
38
+ }
39
+ return null;
40
+ }
41
+
42
+ function resolveSenderKey(chatId, config) {
43
+ const map = {
44
+ ...(config && config.feishu ? config.feishu.chat_agent_map : {}),
45
+ ...(config && config.telegram ? config.telegram.chat_agent_map : {}),
46
+ };
47
+ return map[String(chatId)] || 'user';
48
+ }
49
+
50
+ function popFlag(input, flagName) {
51
+ const src = String(input || '');
52
+ const re = new RegExp(`(?:^|\\s)--${flagName}\\s+(\\S+)`, 'i');
53
+ const m = src.match(re);
54
+ if (!m) return { text: src.trim(), value: '' };
55
+ const value = String(m[1] || '').trim();
56
+ const text = src.replace(m[0], ' ').replace(/\s+/g, ' ').trim();
57
+ return { text, value };
58
+ }
59
+
60
+ function parseTeamTaskArgs(raw) {
61
+ const src = String(raw || '').trim();
62
+ const first = src.match(/^(\S+)\s+([\s\S]+)$/);
63
+ if (!first) return null;
64
+ const targetName = first[1];
65
+ let rest = first[2].trim();
66
+ const scopePop = popFlag(rest, 'scope');
67
+ rest = scopePop.text;
68
+ const parentPop = popFlag(rest, 'parent');
69
+ rest = parentPop.text;
70
+ return {
71
+ targetName,
72
+ goal: rest,
73
+ scopeId: scopePop.value || '',
74
+ parentTaskId: parentPop.value || '',
75
+ };
76
+ }
77
+
78
+ function formatTaskSchedule(task) {
79
+ const at = typeof task.at === 'string' ? task.at.trim() : '';
80
+ if (at) {
81
+ const rawDays = task.days !== undefined ? task.days : task.weekdays;
82
+ let daysLabel = '';
83
+ if (Array.isArray(rawDays)) daysLabel = rawDays.join(',');
84
+ else if (typeof rawDays === 'string') daysLabel = rawDays.trim();
85
+ return daysLabel ? `at ${at} ${daysLabel}` : `at ${at}`;
86
+ }
87
+ if (task.interval) return `every ${task.interval}`;
88
+ return 'unspecified';
89
+ }
90
+
22
91
  async function handleAdminCommand(ctx) {
23
92
  const { bot, chatId, text } = ctx;
24
93
  const state = ctx.state || {};
@@ -40,6 +109,96 @@ function createAdminCommandHandler(deps) {
40
109
  return { handled: true, config };
41
110
  }
42
111
 
112
+ // /skill-evo — inspect and resolve skill evolution queue
113
+ if (text === '/skill-evo' || text.startsWith('/skill-evo ')) {
114
+ if (!skillEvolution) {
115
+ await bot.sendMessage(chatId, '❌ skill-evolution 模块不可用');
116
+ return { handled: true, config };
117
+ }
118
+
119
+ const arg = text.slice('/skill-evo'.length).trim();
120
+ const renderItem = (i) => {
121
+ const id = i.id || '-';
122
+ const target = i.skill_name ? `skill=${i.skill_name}` : (i.search_hint ? `hint=${i.search_hint}` : 'global');
123
+ const seen = i.last_seen || i.detected || '-';
124
+ const ev = i.evidence_count || 1;
125
+ return `- [${id}] ${i.type}/${i.status} (${target}, ev=${ev})\n ${i.reason || '(no reason)'}\n last: ${seen}`;
126
+ };
127
+
128
+ if (!arg || arg === 'list') {
129
+ const pendingAll = skillEvolution.listQueueItems({ status: 'pending', limit: 200 });
130
+ const notifiedAll = skillEvolution.listQueueItems({ status: 'notified', limit: 200 });
131
+ const installedAll = skillEvolution.listQueueItems({ status: 'installed', limit: 200 });
132
+ const dismissedAll = skillEvolution.listQueueItems({ status: 'dismissed', limit: 200 });
133
+
134
+ const pending = pendingAll.slice(0, 10);
135
+ const notified = notifiedAll.slice(0, 10);
136
+ const resolved = [...installedAll, ...dismissedAll]
137
+ .sort((a, b) => new Date(b.last_seen || b.detected || 0).getTime() - new Date(a.last_seen || a.detected || 0).getTime())
138
+ .slice(0, 5);
139
+
140
+ const lines = ['🧬 Skill Evolution Queue'];
141
+ lines.push(`pending: ${pendingAll.length} | notified: ${notifiedAll.length} | resolved(total): ${installedAll.length + dismissedAll.length}`);
142
+ if (pending.length > 0) {
143
+ lines.push('\nPending:');
144
+ for (const item of pending.slice(0, 5)) lines.push(renderItem(item));
145
+ }
146
+ if (notified.length > 0) {
147
+ lines.push('\nNotified:');
148
+ for (const item of notified.slice(0, 5)) lines.push(renderItem(item));
149
+ }
150
+ if (resolved.length > 0) {
151
+ lines.push('\nResolved (latest):');
152
+ for (const item of resolved) lines.push(renderItem(item));
153
+ }
154
+ if (pending.length === 0 && notified.length === 0 && resolved.length === 0) {
155
+ lines.push('\n(queue empty)');
156
+ }
157
+ lines.push('\n用法: /skill-evo done <id> | /skill-evo dismiss <id>');
158
+
159
+ await bot.sendMessage(chatId, lines.join('\n'));
160
+
161
+ if (bot.sendButtons) {
162
+ const actionable = [...notified, ...pending].slice(0, 3);
163
+ if (actionable.length > 0) {
164
+ const buttons = [];
165
+ for (const item of actionable) {
166
+ const label = `${item.type}:${(item.skill_name || item.search_hint || 'item').slice(0, 10)}`;
167
+ buttons.push([
168
+ { text: `✅ ${label}`, callback_data: `/skill-evo done ${item.id}` },
169
+ { text: `🙈 ${label}`, callback_data: `/skill-evo dismiss ${item.id}` },
170
+ ]);
171
+ }
172
+ await bot.sendButtons(chatId, '处理建议项:', buttons);
173
+ }
174
+ }
175
+ return { handled: true, config };
176
+ }
177
+
178
+ const doneMatch = arg.match(/^(?:done|install|installed)\s+(\S+)$/i);
179
+ if (doneMatch) {
180
+ const id = doneMatch[1];
181
+ const ok = skillEvolution.resolveQueueItemById
182
+ ? skillEvolution.resolveQueueItemById(id, 'installed')
183
+ : false;
184
+ await bot.sendMessage(chatId, ok ? `✅ 已标记 installed: ${id}` : `❌ 未找到可处理项: ${id}`);
185
+ return { handled: true, config };
186
+ }
187
+
188
+ const dismissMatch = arg.match(/^(?:dismiss|skip|ignored?)\s+(\S+)$/i);
189
+ if (dismissMatch) {
190
+ const id = dismissMatch[1];
191
+ const ok = skillEvolution.resolveQueueItemById
192
+ ? skillEvolution.resolveQueueItemById(id, 'dismissed')
193
+ : false;
194
+ await bot.sendMessage(chatId, ok ? `✅ 已标记 dismissed: ${id}` : `❌ 未找到可处理项: ${id}`);
195
+ return { handled: true, config };
196
+ }
197
+
198
+ await bot.sendMessage(chatId, '用法: /skill-evo list | /skill-evo done <id> | /skill-evo dismiss <id>');
199
+ return { handled: true, config };
200
+ }
201
+
43
202
  if (text === '/tasks') {
44
203
  const { general, project } = getAllTasks(config);
45
204
  let msg = '';
@@ -47,7 +206,7 @@ function createAdminCommandHandler(deps) {
47
206
  msg += '📋 General:\n';
48
207
  for (const t of general) {
49
208
  const ts = state.tasks[t.name] || {};
50
- msg += `${t.enabled !== false ? '✅' : '⏸'} ${t.name} (${t.interval}) ${ts.status || 'never_run'}\n`;
209
+ msg += `${t.enabled !== false ? '✅' : '⏸'} ${t.name} (${formatTaskSchedule(t)}) ${ts.status || 'never_run'}\n`;
51
210
  }
52
211
  }
53
212
  // Project tasks grouped by _project
@@ -61,7 +220,7 @@ function createAdminCommandHandler(deps) {
61
220
  msg += `\n${proj.icon} ${proj.name}:\n`;
62
221
  for (const t of tasks) {
63
222
  const ts = state.tasks[t.name] || {};
64
- msg += `${t.enabled !== false ? '✅' : '⏸'} ${t.name} (${t.interval}) ${ts.status || 'never_run'}\n`;
223
+ msg += `${t.enabled !== false ? '✅' : '⏸'} ${t.name} (${formatTaskSchedule(t)}) ${ts.status || 'never_run'}\n`;
65
224
  }
66
225
  }
67
226
  if (!msg) {
@@ -72,6 +231,216 @@ function createAdminCommandHandler(deps) {
72
231
  return { handled: true, config };
73
232
  }
74
233
 
234
+ // /TeamTask — create/list/detail/resume team collaboration tasks
235
+ const teamTaskCmdMatch = text.match(/^\/teamtask(?:\s+([\s\S]+))?$/i);
236
+ if (teamTaskCmdMatch) {
237
+ const args = String(teamTaskCmdMatch[1] || '').trim();
238
+ if (/^create$/i.test(args)) {
239
+ await bot.sendMessage(chatId, '❌ 用法: /TeamTask create <agent> <目标> [--scope <scopeId>] [--parent <taskId>]');
240
+ return { handled: true, config };
241
+ }
242
+ const createMatch = args.match(/^create\s+([\s\S]+)$/i);
243
+ if (createMatch) {
244
+ if (!taskEnvelope) {
245
+ await bot.sendMessage(chatId, '❌ task protocol 不可用');
246
+ return { handled: true, config };
247
+ }
248
+ const parsed = parseTeamTaskArgs(createMatch[1]);
249
+ if (!parsed || !parsed.targetName || !parsed.goal) {
250
+ await bot.sendMessage(chatId, '❌ 用法: /TeamTask create <agent> <目标> [--scope <scopeId>] [--parent <taskId>]');
251
+ return { handled: true, config };
252
+ }
253
+ const { targetName, goal, scopeId, parentTaskId } = parsed;
254
+ const targetKey = resolveProjectKey(targetName, config.projects || {});
255
+ if (!targetKey) {
256
+ await bot.sendMessage(chatId, `未找到 agent: ${targetName}\n可用: ${Object.keys(config.projects || {}).join(', ')}`);
257
+ return { handled: true, config };
258
+ }
259
+ const senderKey = resolveSenderKey(chatId, config);
260
+ const participants = (scopeId && taskBoard && taskBoard.listScopeParticipants)
261
+ ? taskBoard.listScopeParticipants(scopeId)
262
+ : [];
263
+ participants.push(senderKey, targetKey);
264
+ const envelope = taskEnvelope.normalizeTaskEnvelope({
265
+ from_agent: senderKey,
266
+ to_agent: targetKey,
267
+ scope_id: scopeId || '',
268
+ parent_task_id: parentTaskId || null,
269
+ participants,
270
+ goal,
271
+ task_kind: 'team',
272
+ definition_of_done: [
273
+ '输出可执行结果和关键结论',
274
+ '必要时给出产物路径与下一步建议',
275
+ ],
276
+ inputs: {
277
+ source_chat_id: String(chatId),
278
+ source: 'mobile_teamtask',
279
+ },
280
+ priority: 'normal',
281
+ status: 'queued',
282
+ });
283
+ const checked = taskEnvelope.validateTaskEnvelope(envelope);
284
+ if (!checked.ok) {
285
+ await bot.sendMessage(chatId, `❌ TeamTask 无效: ${checked.error}`);
286
+ return { handled: true, config };
287
+ }
288
+ const result = dispatchTask(targetKey, {
289
+ from: senderKey,
290
+ type: 'task',
291
+ priority: envelope.priority,
292
+ payload: {
293
+ title: goal.slice(0, 60),
294
+ prompt: goal,
295
+ task_envelope: envelope,
296
+ },
297
+ callback: false,
298
+ }, config);
299
+ if (result.success) {
300
+ await bot.sendMessage(chatId, [
301
+ `✅ 已创建 TeamTask 并派发: ${envelope.task_id}`,
302
+ `Scope: ${envelope.scope_id || envelope.task_id}`,
303
+ `查看: /TeamTask ${envelope.task_id}`,
304
+ ].join('\n'));
305
+ } else {
306
+ await bot.sendMessage(chatId, `❌ 创建 TeamTask 失败: ${result.error}`);
307
+ }
308
+ return { handled: true, config };
309
+ }
310
+
311
+ if (!taskBoard) {
312
+ await bot.sendMessage(chatId, '❌ Task Board 不可用');
313
+ return { handled: true, config };
314
+ }
315
+
316
+ if (!args || /^list$/i.test(args)) {
317
+ const recent = taskBoard.listRecentTasks(10, null, 'team');
318
+ if (recent.length === 0) {
319
+ await bot.sendMessage(chatId, '暂无 TeamTask。\n使用 /TeamTask create <agent> <goal> 创建。');
320
+ return { handled: true, config };
321
+ }
322
+ let msg = '🧩 TeamTask (最近10条)\n';
323
+ for (const t of recent) {
324
+ msg += `\n- ${t.task_id} [${t.status}] scope=${t.scope_id || t.task_id}\n ${t.from_agent}→${t.to_agent} · ${t.goal.slice(0, 80)}`;
325
+ }
326
+ msg += '\n\n查看详情: /TeamTask <task_id>\n续跑: /TeamTask resume <task_id>';
327
+ await bot.sendMessage(chatId, msg);
328
+ return { handled: true, config };
329
+ }
330
+
331
+ const resumeMatch = args.match(/^resume\s+(\S+)$/i);
332
+ if (resumeMatch) {
333
+ const taskId = resumeMatch[1];
334
+ const task = taskBoard.getTask(taskId);
335
+ if (!task || task.task_kind !== 'team') {
336
+ await bot.sendMessage(chatId, `❌ 未找到 TeamTask: ${taskId}`);
337
+ return { handled: true, config };
338
+ }
339
+ const targetKey = task.to_agent;
340
+ if (!config.projects || !config.projects[targetKey]) {
341
+ await bot.sendMessage(chatId, `❌ 目标 agent 不存在: ${targetKey}`);
342
+ return { handled: true, config };
343
+ }
344
+ const envelope = taskEnvelope && taskEnvelope.normalizeTaskEnvelope
345
+ ? taskEnvelope.normalizeTaskEnvelope({
346
+ ...task,
347
+ status: 'queued',
348
+ updated_at: new Date().toISOString(),
349
+ task_kind: 'team',
350
+ participants: taskBoard.listScopeParticipants(task.scope_id || task.task_id),
351
+ }, {
352
+ from_agent: task.from_agent || resolveSenderKey(chatId, config),
353
+ to_agent: targetKey,
354
+ scope_id: task.scope_id || task.task_id,
355
+ })
356
+ : {
357
+ task_id: task.task_id,
358
+ scope_id: task.scope_id || task.task_id,
359
+ from_agent: task.from_agent || resolveSenderKey(chatId, config),
360
+ to_agent: targetKey,
361
+ participants: taskBoard.listScopeParticipants(task.scope_id || task.task_id),
362
+ goal: task.goal,
363
+ definition_of_done: task.definition_of_done || [],
364
+ inputs: task.inputs || {},
365
+ artifacts: task.artifacts || [],
366
+ owned_paths: task.owned_paths || [],
367
+ priority: task.priority || 'normal',
368
+ status: 'queued',
369
+ task_kind: 'team',
370
+ created_at: task.created_at,
371
+ updated_at: new Date().toISOString(),
372
+ };
373
+
374
+ const result = dispatchTask(targetKey, {
375
+ from: envelope.from_agent || 'user',
376
+ type: 'task',
377
+ priority: envelope.priority || 'normal',
378
+ payload: {
379
+ title: envelope.goal.slice(0, 60),
380
+ prompt: envelope.goal,
381
+ task_envelope: envelope,
382
+ },
383
+ callback: false,
384
+ new_session: false,
385
+ }, config);
386
+
387
+ if (result.success) {
388
+ taskBoard.appendTaskEvent(task.task_id, 'task_resume_requested', String(chatId), { by: String(chatId) });
389
+ await bot.sendMessage(chatId, `✅ 已续跑 TeamTask: ${task.task_id}`);
390
+ } else {
391
+ await bot.sendMessage(chatId, `❌ 续跑失败: ${result.error}`);
392
+ }
393
+ return { handled: true, config };
394
+ }
395
+
396
+ if (/^resume$/i.test(args)) {
397
+ await bot.sendMessage(chatId, '❌ 用法: /TeamTask resume <task_id>');
398
+ return { handled: true, config };
399
+ }
400
+
401
+ const task = taskBoard.getTask(args);
402
+ if (!task || task.task_kind !== 'team') {
403
+ await bot.sendMessage(chatId, `❌ 未找到 TeamTask: ${args}`);
404
+ return { handled: true, config };
405
+ }
406
+ const events = taskBoard.listTaskEvents(task.task_id, 8);
407
+ const scopeId = task.scope_id || task.task_id;
408
+ const scopeTasks = taskBoard.listScopeTasks(scopeId, 12);
409
+ const scopeParticipants = taskBoard.listScopeParticipants(scopeId);
410
+ let detail = [
411
+ `🧩 TeamTask: ${task.task_id}`,
412
+ `Scope: ${scopeId}`,
413
+ `状态: ${task.status}`,
414
+ `优先级: ${task.priority}`,
415
+ `流向: ${task.from_agent} → ${task.to_agent}`,
416
+ `目标: ${task.goal}`,
417
+ ];
418
+ if (scopeParticipants.length > 0) {
419
+ detail.push(`参与者: ${scopeParticipants.join(', ')}`);
420
+ }
421
+ if (Array.isArray(task.definition_of_done) && task.definition_of_done.length > 0) {
422
+ detail.push('DoD:');
423
+ for (const d of task.definition_of_done.slice(0, 6)) detail.push(`- ${d}`);
424
+ }
425
+ if (Array.isArray(task.artifacts) && task.artifacts.length > 0) {
426
+ detail.push('产物:');
427
+ for (const a of task.artifacts.slice(0, 6)) detail.push(`- ${a}`);
428
+ }
429
+ if (task.last_error) detail.push(`错误: ${task.last_error.slice(0, 180)}`);
430
+ if (events.length > 0) {
431
+ detail.push('最近事件:');
432
+ for (const ev of events.slice(0, 5)) detail.push(`- [${ev.event_type}] ${ev.actor} @ ${ev.created_at}`);
433
+ }
434
+ if (scopeTasks.length > 1) {
435
+ detail.push('同 Scope 相关任务:');
436
+ for (const st of scopeTasks.filter(x => x.task_id !== task.task_id).slice(0, 5)) {
437
+ detail.push(`- ${st.task_id} [${st.status}] ${st.from_agent}→${st.to_agent}`);
438
+ }
439
+ }
440
+ await bot.sendMessage(chatId, detail.join('\n'));
441
+ return { handled: true, config };
442
+ }
443
+
75
444
  // /dispatch — inter-agent task dispatch
76
445
  if (text.startsWith('/dispatch')) {
77
446
  const args = text.slice('/dispatch'.length).trim();
@@ -125,21 +494,14 @@ function createAdminCommandHandler(deps) {
125
494
  const prompt = toMatch[2].trim();
126
495
 
127
496
  // Resolve target by project key or nickname
128
- let targetKey = null;
129
- for (const [key, proj] of Object.entries(config.projects || {})) {
130
- if (key === targetName || (proj.nicknames || []).some(n => n === targetName)) {
131
- targetKey = key;
132
- break;
133
- }
134
- }
497
+ const targetKey = resolveProjectKey(targetName, config.projects || {});
135
498
  if (!targetKey) {
136
499
  await bot.sendMessage(chatId, `未找到 agent: ${targetName}\n可用: ${Object.keys(config.projects || {}).join(', ')}`);
137
500
  return { handled: true, config };
138
501
  }
139
502
 
140
503
  // Determine sender from current chat's project mapping
141
- const chatAgentMap = (config.feishu && config.feishu.chat_agent_map) || {};
142
- const senderKey = chatAgentMap[chatId] || 'user';
504
+ const senderKey = resolveSenderKey(chatId, config);
143
505
 
144
506
  const projInfo = config.projects[targetKey] || {};
145
507
  // Find the target project's own Feishu chat (reverse lookup of chat_agent_map)
@@ -172,7 +534,14 @@ function createAdminCommandHandler(deps) {
172
534
  return { handled: true, config };
173
535
  }
174
536
 
175
- await bot.sendMessage(chatId, '用法:\n/dispatch status — 查看状态\n/dispatch log — 查看记录\n/dispatch to <agent> <任务内容>');
537
+ await bot.sendMessage(chatId, [
538
+ '用法:',
539
+ '/dispatch status — 查看状态',
540
+ '/dispatch log — 查看记录',
541
+ '/dispatch to <agent> <任务内容> — 直接跨 agent 派发',
542
+ '/TeamTask create <agent> <目标> [--scope <id>] [--parent <id>] — 创建/续接 TeamTask',
543
+ '/TeamTask — 查看 TeamTask 列表',
544
+ ].join('\n'));
176
545
  return { handled: true, config };
177
546
  }
178
547
 
@@ -183,6 +552,66 @@ function createAdminCommandHandler(deps) {
183
552
  return { handled: true, config };
184
553
  }
185
554
 
555
+ if (text === '/usage' || text.startsWith('/usage ')) {
556
+ const arg = text.slice('/usage'.length).trim() || 'today';
557
+ const usage = state.usage || {};
558
+ const daily = usage.daily || {};
559
+ const categories = usage.categories || {};
560
+ const limit = (config.budget && config.budget.daily_limit) || 50000;
561
+ const todayIso = new Date().toISOString().slice(0, 10);
562
+
563
+ // Resolve date range
564
+ let days = 1;
565
+ if (arg === 'week') days = 7;
566
+ else if (arg === 'month') days = 30;
567
+ else if (/^\d+d$/.test(arg)) days = Math.min(90, parseInt(arg, 10));
568
+
569
+ const dates = [];
570
+ for (let i = days - 1; i >= 0; i--) {
571
+ const d = new Date(`${todayIso}T00:00:00.000Z`);
572
+ d.setUTCDate(d.getUTCDate() - i);
573
+ dates.push(d.toISOString().slice(0, 10));
574
+ }
575
+
576
+ // Aggregate tokens by category across the date window
577
+ const totals = {};
578
+ let grandTotal = 0;
579
+ for (const date of dates) {
580
+ const bucket = daily[date] || {};
581
+ for (const [key, val] of Object.entries(bucket)) {
582
+ if (key === 'total') continue;
583
+ const n = Math.max(0, Math.floor(Number(val) || 0));
584
+ totals[key] = (totals[key] || 0) + n;
585
+ grandTotal += n;
586
+ }
587
+ }
588
+ // Fallback: if no daily breakdown yet, use categories totals for today
589
+ if (grandTotal === 0 && days === 1) {
590
+ for (const [key, meta] of Object.entries(categories)) {
591
+ const n = Math.max(0, Math.floor(Number(meta && meta.total) || 0));
592
+ if (n > 0) { totals[key] = n; grandTotal += n; }
593
+ }
594
+ }
595
+
596
+ const label = days === 1 ? `今日 (${todayIso})` : `近 ${days} 天`;
597
+ const budgetPct = limit > 0 ? ((grandTotal / limit) * 100).toFixed(1) : '—';
598
+ let lines = [`📊 Token 用量 — ${label}`, `合计: ${grandTotal.toLocaleString()} / ${limit.toLocaleString()} tokens (${budgetPct}%)`];
599
+
600
+ // Render by canonical order, then extras
601
+ const orderedKeys = [...USAGE_CATEGORY_ORDER, ...Object.keys(totals).filter(k => !USAGE_CATEGORY_ORDER.includes(k))];
602
+ for (const key of orderedKeys) {
603
+ const n = totals[key] || 0;
604
+ if (n === 0 && !CORE_USAGE_CATEGORIES.includes(key)) continue;
605
+ const pct = grandTotal > 0 ? ((n / grandTotal) * 100).toFixed(1) : '0.0';
606
+ const lbl = USAGE_CATEGORY_LABEL[key] || key;
607
+ const bar = '█'.repeat(Math.round(Number(pct) / 10)).padEnd(10, '░');
608
+ lines.push(`${lbl}: ${n.toLocaleString()} tokens (${pct}%) ${bar}`);
609
+ }
610
+
611
+ await bot.sendMessage(chatId, lines.join('\n'));
612
+ return { handled: true, config };
613
+ }
614
+
186
615
  if (text === '/quiet') {
187
616
  try {
188
617
  const doc = yaml.load(fs.readFileSync(BRAIN_FILE, 'utf8')) || {};