metame-cli 1.4.34 → 1.5.1

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 (48) hide show
  1. package/README.md +136 -94
  2. package/index.js +312 -57
  3. package/package.json +8 -4
  4. package/scripts/agent-layer.js +320 -0
  5. package/scripts/daemon-admin-commands.js +328 -28
  6. package/scripts/daemon-agent-commands.js +145 -6
  7. package/scripts/daemon-agent-tools.js +163 -7
  8. package/scripts/daemon-bridges.js +110 -20
  9. package/scripts/daemon-checkpoints.js +36 -7
  10. package/scripts/daemon-claude-engine.js +849 -358
  11. package/scripts/daemon-command-router.js +31 -10
  12. package/scripts/daemon-default.yaml +28 -4
  13. package/scripts/daemon-engine-runtime.js +328 -0
  14. package/scripts/daemon-exec-commands.js +15 -7
  15. package/scripts/daemon-notify.js +37 -1
  16. package/scripts/daemon-ops-commands.js +8 -6
  17. package/scripts/daemon-runtime-lifecycle.js +129 -5
  18. package/scripts/daemon-session-commands.js +60 -25
  19. package/scripts/daemon-session-store.js +121 -13
  20. package/scripts/daemon-task-scheduler.js +129 -49
  21. package/scripts/daemon-user-acl.js +35 -9
  22. package/scripts/daemon.js +268 -33
  23. package/scripts/distill.js +327 -18
  24. package/scripts/docs/agent-guide.md +12 -0
  25. package/scripts/docs/maintenance-manual.md +155 -0
  26. package/scripts/docs/pointer-map.md +110 -0
  27. package/scripts/feishu-adapter.js +42 -13
  28. package/scripts/hooks/stop-session-capture.js +243 -0
  29. package/scripts/memory-extract.js +105 -6
  30. package/scripts/memory-nightly-reflect.js +199 -11
  31. package/scripts/memory.js +134 -3
  32. package/scripts/mentor-engine.js +405 -0
  33. package/scripts/platform.js +24 -0
  34. package/scripts/providers.js +182 -22
  35. package/scripts/schema.js +12 -0
  36. package/scripts/session-analytics.js +245 -12
  37. package/scripts/skill-changelog.js +245 -0
  38. package/scripts/skill-evolution.js +288 -5
  39. package/scripts/telegram-adapter.js +12 -8
  40. package/scripts/usage-classifier.js +1 -1
  41. package/scripts/daemon-admin-commands.test.js +0 -333
  42. package/scripts/daemon-task-envelope.test.js +0 -59
  43. package/scripts/daemon-task-scheduler.test.js +0 -106
  44. package/scripts/reliability-core.test.js +0 -280
  45. package/scripts/skill-evolution.test.js +0 -113
  46. package/scripts/task-board.test.js +0 -83
  47. package/scripts/test_daemon.js +0 -1407
  48. package/scripts/utils.test.js +0 -192
@@ -189,6 +189,7 @@ function createTaskScheduler(deps) {
189
189
  recordTokens,
190
190
  buildProfilePreamble,
191
191
  getDaemonProviderEnv,
192
+ getDistillModel,
192
193
  log,
193
194
  physiologicalHeartbeat,
194
195
  isUserIdle,
@@ -198,12 +199,31 @@ function createTaskScheduler(deps) {
198
199
  skillEvolution,
199
200
  } = deps;
200
201
 
201
- // On Windows, .cmd files need shell to spawn; use COMSPEC to avoid conda PATH issues
202
+ // Max characters from precondition context to inject into prompts (prevents token bombs)
203
+ const MAX_PRECONDITION_CHARS = 4000;
204
+
205
+ // On Windows, resolve .cmd → actual Node.js entry to avoid cmd.exe flash
206
+ function _resolveNodeEntry(cmdPath) {
207
+ try {
208
+ const content = require('fs').readFileSync(cmdPath, 'utf8');
209
+ const m = content.match(/"([^"]+\.js)"\s*%\*\s*$/m);
210
+ if (m) {
211
+ const entry = m[1].replace(/%dp0%/gi, require('path').dirname(cmdPath) + require('path').sep);
212
+ if (require('fs').existsSync(entry)) return entry;
213
+ }
214
+ } catch { /* ignore */ }
215
+ return null;
216
+ }
217
+
202
218
  function spawn(cmd, args, options) {
203
- if (process.platform === 'win32' && cmd === CLAUDE_BIN) {
204
- return _spawn(cmd, args, { ...options, shell: process.env.COMSPEC || true });
219
+ if (process.platform !== 'win32') return _spawn(cmd, args, options);
220
+ const lowerCmd = String(cmd || '').toLowerCase();
221
+ if (lowerCmd.endsWith('.cmd') || lowerCmd.endsWith('.bat') || lowerCmd === 'claude' || lowerCmd === 'codex') {
222
+ const entry = _resolveNodeEntry(cmd);
223
+ if (entry) return _spawn(process.execPath, [entry, ...args], { ...options, windowsHide: true });
224
+ return _spawn(cmd, args, { ...options, shell: process.env.COMSPEC || true, windowsHide: true });
205
225
  }
206
- return _spawn(cmd, args, options);
226
+ return _spawn(cmd, args, { ...options, windowsHide: true });
207
227
  }
208
228
 
209
229
  function checkPrecondition(task) {
@@ -212,31 +232,31 @@ function createTaskScheduler(deps) {
212
232
  try {
213
233
  let cmd = task.precondition;
214
234
 
215
- // Cross-platform: expand ~ to HOME and handle `test -s` (Unix-only) via Node.js
235
+ // Cross-platform: expand ~ to HOME and handle `test -s` via Node.js
216
236
  cmd = cmd.replace(/^~|(?<=\s)~/g, HOME);
217
- if (IS_WIN) {
218
- // `test -s <file>` checks file exists and is non-empty — do it in JS
219
- const testMatch = cmd.match(/^test\s+-s\s+(.+)$/);
220
- if (testMatch) {
221
- const filePath = testMatch[1].trim().replace(/["']/g, '');
222
- const fs = require('fs');
223
- try {
224
- const stat = fs.statSync(filePath);
225
- if (stat.size > 0) {
226
- const content = fs.readFileSync(filePath, 'utf8').trim();
227
- log('INFO', `Precondition passed for ${task.name} (${content.split('\n').length} lines)`);
228
- return { pass: true, context: content };
229
- }
230
- } catch { /* file doesn't exist */ }
231
- log('INFO', `Precondition failed for ${task.name}: file empty or missing`);
232
- return { pass: false, context: '' };
233
- }
237
+ // Handle `test -s <file>` natively in JS on ALL platforms.
238
+ // Shell `test -s` produces no stdout on success, which previously
239
+ // caused the empty-output check below to incorrectly skip the task.
240
+ const testMatch = cmd.match(/^test\s+-s\s+(.+)$/);
241
+ if (testMatch) {
242
+ const filePath = testMatch[1].trim().replace(/["']/g, '');
243
+ const fs = require('fs');
244
+ try {
245
+ const content = fs.readFileSync(filePath, 'utf8').trim();
246
+ if (content.length > 0) {
247
+ log('INFO', `Precondition passed for ${task.name} (${content.split('\n').length} lines)`);
248
+ return { pass: true, context: content };
249
+ }
250
+ } catch { /* file doesn't exist or unreadable */ }
251
+ log('INFO', `Precondition failed for ${task.name}: file empty or missing`);
252
+ return { pass: false, context: '' };
234
253
  }
235
254
 
236
255
  const output = execSync(cmd, {
237
256
  encoding: 'utf8',
238
257
  timeout: 15000,
239
258
  maxBuffer: 64 * 1024,
259
+ ...(process.platform === 'win32' ? { windowsHide: true } : {}),
240
260
  }).trim();
241
261
 
242
262
  if (!output) {
@@ -351,17 +371,33 @@ function createTaskScheduler(deps) {
351
371
  timeout: resolveTimeoutMs(task.timeout, 120),
352
372
  maxBuffer: 1024 * 1024,
353
373
  env: scriptEnv,
374
+ ...(process.platform === 'win32' ? { windowsHide: true } : {}),
354
375
  }).trim();
355
376
 
377
+ // Parse token report from script stdout: last line matching __TOKENS__:<number>
378
+ // Scripts that call LLM APIs should print this before exiting.
379
+ // Fallback: estimate from output length (rough, but better than 0).
380
+ let scriptTokens = 0;
381
+ const tokenMatch = output.match(/__TOKENS__:(\d+)/);
382
+ if (tokenMatch) {
383
+ scriptTokens = Number(tokenMatch[1]);
384
+ } else if (output.length > 100) {
385
+ // Conservative estimate for scripts that don't self-report
386
+ scriptTokens = Math.ceil(output.length / 4);
387
+ }
388
+ if (scriptTokens > 0) {
389
+ recordTokens(state, scriptTokens, { category: classifyTaskUsage(task) });
390
+ }
391
+
356
392
  state.tasks[task.name] = {
357
393
  last_run: new Date().toISOString(),
358
394
  status: 'success',
359
395
  output_preview: output.slice(0, 200),
360
396
  };
361
397
  saveState(state);
362
- if (output) log('INFO', `Script task ${task.name} completed: ${output.slice(0, 300)}`);
398
+ if (output) log('INFO', `Script task ${task.name} completed (${scriptTokens} tokens): ${output.slice(0, 300)}`);
363
399
  else log('INFO', `Script task ${task.name} completed`);
364
- return { success: true, output, tokens: 0 };
400
+ return { success: true, output, tokens: scriptTokens };
365
401
  } catch (e) {
366
402
  log('ERROR', `Script task ${task.name} failed: ${e.message}`);
367
403
  state.tasks[task.name] = {
@@ -375,11 +411,14 @@ function createTaskScheduler(deps) {
375
411
  }
376
412
 
377
413
  const preamble = buildProfilePreamble();
378
- const model = normalizeModel(task.model || 'haiku');
379
- // If precondition returned context data, append it to the prompt
414
+ const model = normalizeModel(task.model || getDistillModel());
415
+ // If precondition returned context data, append it to the prompt (truncated to prevent token bombs)
380
416
  let taskPrompt = task.prompt;
381
417
  if (precheck.context) {
382
- taskPrompt += `\n\n以下是相关原始数据:\n\`\`\`\n${precheck.context}\n\`\`\``;
418
+ const ctx = precheck.context.length > MAX_PRECONDITION_CHARS
419
+ ? precheck.context.slice(0, MAX_PRECONDITION_CHARS) + '\n... (truncated)'
420
+ : precheck.context;
421
+ taskPrompt += `\n\n以下是相关原始数据:\n\`\`\`\n${ctx}\n\`\`\``;
383
422
  }
384
423
  const fullPrompt = preamble + taskPrompt;
385
424
 
@@ -550,7 +589,9 @@ function createTaskScheduler(deps) {
550
589
  const steps = task.steps || [];
551
590
  if (steps.length === 0) return { success: false, error: 'No steps defined', output: '' };
552
591
 
553
- const model = normalizeModel(task.model || 'sonnet');
592
+ // Workflow tasks match the user's session model setting (same quality as interactive)
593
+ const sessionModel = (config && config.daemon && config.daemon.model) || 'sonnet';
594
+ const model = normalizeModel(task.model || sessionModel);
554
595
  const cwd = task.cwd ? task.cwd.replace(/^~/, HOME) : HOME;
555
596
  const sessionId = crypto.randomUUID();
556
597
  const outputs = [];
@@ -569,7 +610,12 @@ function createTaskScheduler(deps) {
569
610
  for (let i = 0; i < steps.length; i++) {
570
611
  const step = steps[i];
571
612
  let prompt = (step.skill ? `/${step.skill} ` : '') + (step.prompt || '');
572
- if (i === 0 && precheck.context) prompt += `\n\n相关数据:\n\`\`\`\n${precheck.context}\n\`\`\``;
613
+ if (i === 0 && precheck.context) {
614
+ const ctx = precheck.context.length > MAX_PRECONDITION_CHARS
615
+ ? precheck.context.slice(0, MAX_PRECONDITION_CHARS) + '\n... (truncated)'
616
+ : precheck.context;
617
+ prompt += `\n\n相关数据:\n\`\`\`\n${ctx}\n\`\`\``;
618
+ }
573
619
  const args = ['-p', '--model', model, '--dangerously-skip-permissions'];
574
620
  for (const tool of allowed) args.push('--allowedTools', tool);
575
621
  if (mcpConfig) args.push('--mcp-config', mcpConfig);
@@ -583,12 +629,14 @@ function createTaskScheduler(deps) {
583
629
  timeout: resolveTimeoutMs(step.timeout, 300),
584
630
  maxBuffer: 5 * 1024 * 1024,
585
631
  cwd,
632
+ ...(process.platform === 'win32' ? { windowsHide: true } : {}),
586
633
  env: {
587
634
  ...process.env,
588
635
  ...getDaemonProviderEnv(),
589
636
  CLAUDECODE: undefined,
590
637
  METAME_INTERNAL_PROMPT: '1',
591
638
  },
639
+ ...(process.platform === 'win32' ? { shell: process.env.COMSPEC || true, windowsHide: true } : {}),
592
640
  }).trim();
593
641
  const tk = Math.ceil((prompt.length + output.length) / 4);
594
642
  totalTokens += tk;
@@ -634,11 +682,22 @@ function createTaskScheduler(deps) {
634
682
  return found || null;
635
683
  }
636
684
 
637
- function startHeartbeat(config, notifyFn) {
685
+ function startHeartbeat(config, notifyFn, notifyPersonalFn) {
638
686
  const { all: tasks } = getAllTasks(config);
639
687
 
640
688
  const enabledTasks = tasks.filter(t => t.enabled !== false);
641
689
  const checkIntervalSec = (config.daemon && config.daemon.heartbeat_check_interval) || 60;
690
+
691
+ // Helper: compute next run time, falling back to 2-tick backoff on error.
692
+ function safeNextRun(taskName, schedule, now) {
693
+ try {
694
+ return { next: nextRunAfter(schedule, now), failed: false };
695
+ } catch (e) {
696
+ log('ERROR', `nextRunAfter failed for "${taskName}": ${e.message}`);
697
+ return { next: now + checkIntervalSec * 2 * 1000, failed: true };
698
+ }
699
+ }
700
+
642
701
  const taskSchedules = new Map();
643
702
  const runnableTasks = [];
644
703
  for (const task of enabledTasks) {
@@ -671,7 +730,25 @@ function createTaskScheduler(deps) {
671
730
  // Tracks tasks currently running (prevents concurrent runs of the same task)
672
731
  const runningTasks = new Set();
673
732
 
733
+ // Wake detection: if tick interval far exceeds expected, system likely slept (macOS lid close).
734
+ // Use 5min floor to avoid false triggers from CPU load spikes or GC pauses.
735
+ let lastTickTime = Date.now();
736
+ const WAKE_THRESHOLD = Math.max(checkIntervalSec * 3 * 1000, 5 * 60 * 1000);
737
+
674
738
  const timer = setInterval(() => {
739
+ const tickNow = Date.now();
740
+ const tickElapsed = tickNow - lastTickTime;
741
+ lastTickTime = tickNow;
742
+
743
+ if (tickElapsed > WAKE_THRESHOLD) {
744
+ log('FATAL', `[WAKE-DETECT] System resumed after ${Math.round(tickElapsed / 1000)}s sleep — restarting daemon to rebuild connections`);
745
+ const st = loadState();
746
+ st.wake_restart = new Date().toISOString();
747
+ st.wake_sleep_seconds = Math.round(tickElapsed / 1000);
748
+ saveState(st);
749
+ process.exit(1); // caffeinate will restart us with fresh connections
750
+ }
751
+
675
752
  // ① Physiological heartbeat (zero token, pure awareness)
676
753
  physiologicalHeartbeat(config);
677
754
 
@@ -703,24 +780,14 @@ function createTaskScheduler(deps) {
703
780
 
704
781
  if (runningTasks.has(task.name)) {
705
782
  // Task is still running; skip this cycle and keep full interval cadence.
706
- try {
707
- nextRun[task.name] = nextRunAfter(schedule, currentTime);
708
- } catch (schedErr) {
709
- nextRun[task.name] = currentTime + checkIntervalSec * 2 * 1000;
710
- log('ERROR', `nextRunAfter (running guard) failed for "${task.name}": ${schedErr.message}`);
711
- }
783
+ nextRun[task.name] = safeNextRun(task.name, schedule, currentTime).next;
712
784
  log('WARN', `Task ${task.name} still running — skipping this interval`);
713
785
  continue;
714
786
  }
715
787
 
716
- try {
717
- nextRun[task.name] = nextRunAfter(schedule, currentTime);
718
- } catch (schedErr) {
719
- // If next-run calculation fails, back off by at least 2 ticks to prevent infinite loop
720
- nextRun[task.name] = currentTime + checkIntervalSec * 2 * 1000;
721
- log('ERROR', `nextRunAfter failed for "${task.name}": ${schedErr.message} — backing off`);
722
- continue;
723
- }
788
+ const { next: nextRunTime, failed: schedFailed } = safeNextRun(task.name, schedule, currentTime);
789
+ nextRun[task.name] = nextRunTime;
790
+ if (schedFailed) continue; // back off, skip execution this cycle
724
791
  runningTasks.add(task.name);
725
792
  // executeTask now returns a Promise (async, non-blocking, process-group kill)
726
793
  Promise.resolve(executeTask(task, config))
@@ -743,13 +810,20 @@ function createTaskScheduler(deps) {
743
810
  }
744
811
 
745
812
  // Skill evolution: check queue and notify user of actionable items
746
- if (skillEvolution) {
813
+ // Can be disabled via daemon.yaml: skill_evolution_notify: false
814
+ const skillEvolutionNotifyEnabled = !config.daemon || config.daemon.skill_evolution_notify !== false;
815
+ if (skillEvolution && skillEvolutionNotifyEnabled) {
747
816
  try {
748
817
  const notifications = skillEvolution.checkEvolutionQueue();
749
818
  for (const item of notifications) {
750
819
  let msg = '';
751
820
  const idHint = item.id ? `\nID: \`${item.id}\`` : '';
752
- if (item.type === 'skill_gap') {
821
+ if (item.type === 'workflow_proposal') {
822
+ msg = `🔄 *工作流技能建议*\n检测到重复工作流: ${item.search_hint || item.reason}`;
823
+ if (item.tools_signature && item.tools_signature.length) msg += `\n工具: ${item.tools_signature.join(', ')}`;
824
+ if (item.example_prompt) msg += `\n示例: "${item.example_prompt}"`;
825
+ if (item.evidence_count) msg += `\n出现次数: ${item.evidence_count}`;
826
+ } else if (item.type === 'skill_gap') {
753
827
  msg = `🧬 *技能缺口检测*\n${item.reason}`;
754
828
  if (item.search_hint) msg += `\n搜索建议: \`${item.search_hint}\``;
755
829
  } else if (item.type === 'skill_fix') {
@@ -757,9 +831,14 @@ function createTaskScheduler(deps) {
757
831
  } else if (item.type === 'user_complaint') {
758
832
  msg = `⚠️ *技能反馈*\n技能 \`${item.skill_name}\` 收到用户反馈\n${item.reason}`;
759
833
  }
760
- if (msg && item.id) msg += `${idHint}\n处理: \`/skill-evo done ${item.id}\` \`/skill-evo dismiss ${item.id}\``;
761
- else if (msg) msg += idHint;
762
- if (msg && notifyFn) notifyFn(msg);
834
+ if (msg && item.id && item.type === 'workflow_proposal') {
835
+ msg += `${idHint}\n处理: \`/skill-evo approve ${item.id}\` 或 \`/skill-evo dismiss ${item.id}\``;
836
+ } else if (msg && item.id) {
837
+ msg += `${idHint}\n处理: \`/skill-evo done ${item.id}\` 或 \`/skill-evo dismiss ${item.id}\``;
838
+ } else if (msg) msg += idHint;
839
+ // Skill notifications go only to personal chats, not agent group chats
840
+ const skillNotifyFn = notifyPersonalFn || notifyFn;
841
+ if (msg && skillNotifyFn) skillNotifyFn(msg);
763
842
  }
764
843
  } catch (e) { log('WARN', `Skill evolution queue check failed: ${e.message}`); }
765
844
  }
@@ -780,6 +859,7 @@ function createTaskScheduler(deps) {
780
859
 
781
860
  module.exports = {
782
861
  createTaskScheduler,
862
+ normalizeModel,
783
863
  _private: {
784
864
  parseAtTime,
785
865
  parseDays,
@@ -166,7 +166,7 @@ const ACTION_GROUPS = {
166
166
  const ROLE_DEFAULT_ACTIONS = {
167
167
  admin: Object.keys(ACTION_GROUPS), // 全部权限
168
168
  member: ['query'], // 默认只能问答
169
- stranger: [], // 无系统权限,但允许基础问答由 askClaude readOnly 处理
169
+ stranger: ['query'], // 仅基础问答(只读)
170
170
  };
171
171
 
172
172
  // 不可赋予 member 的 admin 专属 action
@@ -182,6 +182,16 @@ const ADMIN_ONLY_ACTIONS = new Set(['system', 'agent', 'config', 'admin_acl']);
182
182
  */
183
183
  function resolveUserCtx(senderId, config) {
184
184
  const userData = loadUsers();
185
+ // Per-platform bootstrap: Feishu open_id starts with "ou_", Telegram IDs are numeric.
186
+ const allUsers = userData && userData.users ? userData.users : {};
187
+ const isFeishuId = senderId && senderId.startsWith('ou_');
188
+ const isTelegramId = senderId && /^\d+$/.test(senderId);
189
+ const hasPlatformAdmin = isFeishuId
190
+ ? Object.keys(allUsers).some(id => id.startsWith('ou_'))
191
+ : isTelegramId
192
+ ? Object.keys(allUsers).some(id => /^\d+$/.test(id))
193
+ : Object.keys(allUsers).length > 0;
194
+ const hasConfiguredUsers = hasPlatformAdmin;
185
195
 
186
196
  let role, name, allowedActions;
187
197
 
@@ -213,9 +223,25 @@ function resolveUserCtx(senderId, config) {
213
223
  name = senderId.slice(-6);
214
224
  allowedActions = ROLE_DEFAULT_ACTIONS.admin;
215
225
  } else {
216
- role = userData.default_role || 'stranger';
217
- name = senderId.slice(-6);
218
- allowedActions = ROLE_DEFAULT_ACTIONS[role] || [];
226
+ if (!hasConfiguredUsers) {
227
+ // Bootstrap only once: persist the first seen sender as admin.
228
+ const next = {
229
+ default_role: userData.default_role || 'stranger',
230
+ users: { ...(userData.users || {}) },
231
+ };
232
+ next.users[senderId] = {
233
+ role: 'admin',
234
+ name: senderId.slice(-6),
235
+ };
236
+ try { saveUsers(next); } catch { /* non-fatal: fallback to in-memory role */ }
237
+ role = 'admin';
238
+ name = senderId.slice(-6);
239
+ allowedActions = ROLE_DEFAULT_ACTIONS.admin;
240
+ } else {
241
+ role = userData.default_role || 'stranger';
242
+ name = senderId.slice(-6);
243
+ allowedActions = ROLE_DEFAULT_ACTIONS[role] || [];
244
+ }
219
245
  }
220
246
  }
221
247
  }
@@ -313,7 +339,7 @@ function handleUserCommand(text, userCtx) {
313
339
 
314
340
  if (sub === 'add') {
315
341
  // /user add <open_id> <role> [name...]
316
- const [, , , uid, role, ...nameParts] = args;
342
+ const [, , uid, role, ...nameParts] = args;
317
343
  if (!uid || !role) return { handled: true, reply: '用法: /user add <open_id> <role> [name]' };
318
344
  // [S2] open_id 格式校验
319
345
  if (!isValidOpenId(uid)) return { handled: true, reply: '❌ open_id 格式不合法(应为 10-64 位字母数字下划线)' };
@@ -328,7 +354,7 @@ function handleUserCommand(text, userCtx) {
328
354
  }
329
355
 
330
356
  if (sub === 'role') {
331
- const [, , , uid, role] = args;
357
+ const [, , uid, role] = args;
332
358
  if (!uid || !role) return { handled: true, reply: '用法: /user role <open_id> <role>' };
333
359
  if (!isValidOpenId(uid)) return { handled: true, reply: '❌ open_id 格式不合法' };
334
360
  if (!['admin', 'member', 'stranger'].includes(role)) {
@@ -343,7 +369,7 @@ function handleUserCommand(text, userCtx) {
343
369
  }
344
370
 
345
371
  if (sub === 'grant') {
346
- const [, , , uid, action] = args;
372
+ const [, , uid, action] = args;
347
373
  if (!uid || !action) return { handled: true, reply: '用法: /user grant <open_id> <action>' };
348
374
  if (!isValidOpenId(uid)) return { handled: true, reply: '❌ open_id 格式不合法' };
349
375
  if (ADMIN_ONLY_ACTIONS.has(action)) {
@@ -364,7 +390,7 @@ function handleUserCommand(text, userCtx) {
364
390
  }
365
391
 
366
392
  if (sub === 'revoke') {
367
- const [, , , uid, action] = args;
393
+ const [, , uid, action] = args;
368
394
  if (!uid || !action) return { handled: true, reply: '用法: /user revoke <open_id> <action>' };
369
395
  if (!isValidOpenId(uid)) return { handled: true, reply: '❌ open_id 格式不合法' };
370
396
  const data = loadUsers();
@@ -376,7 +402,7 @@ function handleUserCommand(text, userCtx) {
376
402
  }
377
403
 
378
404
  if (sub === 'remove') {
379
- const [, , , uid] = args;
405
+ const [, , uid] = args;
380
406
  if (!uid) return { handled: true, reply: '用法: /user remove <open_id>' };
381
407
  if (!isValidOpenId(uid)) return { handled: true, reply: '❌ open_id 格式不合法' };
382
408
  const data = loadUsers();