metame-cli 1.4.13 → 1.4.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -105,11 +105,11 @@ Built into the daemon. Runs every 60 seconds regardless of what's in your config
105
105
  - Detects when you go idle → generates session continuity summaries
106
106
 
107
107
  **Layer 1 — System Evolution (built-in defaults)**
108
- Three tasks shipped out of the box. Only fire when you're idle they never interrupt active work:
108
+ Three tasks shipped out of the box. They are precondition-gated and run only when useful:
109
109
 
110
110
  ```yaml
111
111
  - cognitive-distill # 4h · has signals? → distill preferences into profile
112
- - memory-extract # 2h · scan sessions → extract long-term facts + topic tags
112
+ - memory-extract # 4h · scan sessions → extract long-term facts + topic tags
113
113
  - skill-evolve # 6h · has signals? → evolve skills from task outcomes
114
114
  ```
115
115
 
@@ -150,7 +150,7 @@ Chain skills into multi-step workflows — research → write → publish — fu
150
150
  prompt: "Publish it"
151
151
  ```
152
152
 
153
- Task options: `require_idle` (defer when you're active), `precondition` (shell guard — skip if false, zero tokens), `notify` (push result to phone), `model`, `cwd`, `allowedTools`, `timeout`.
153
+ Task options: `require_idle` (defer when you're active, retry on next heartbeat tick), `precondition` (shell guard — skip if false, zero tokens), `notify` (push result to phone), `model`, `cwd`, `allowedTools`, `timeout`.
154
154
 
155
155
  ### 5. Skills That Evolve Themselves
156
156
 
@@ -319,14 +319,14 @@ All agents share your cognitive profile (`~/.claude_profile.yaml`) — they all
319
319
  │ (memory layer) ← NEW │
320
320
  └──────────────────────────────┘
321
321
 
322
- sleep mode → memory consolidation
323
- (background, automatic)
322
+ idle mode → summaries + background memory tasks
323
+ (automatic, precondition-gated)
324
324
  ```
325
325
 
326
326
  - **Profile** (`~/.claude_profile.yaml`): Your cognitive fingerprint. Injected into every Claude session via `CLAUDE.md`.
327
- - **Daemon** (`scripts/daemon.js`): Background process handling Telegram/Feishu messages, heartbeat tasks, Unix socket dispatch, and sleep-mode memory triggers.
328
- - **Distillation** (`scripts/distill.js`): On each launch, silently analyzes your recent messages and updates your profile.
329
- - **Memory Extract** (`scripts/memory-extract.js`): Triggered on sleep mode. Extracts long-term facts and session topic tags from completed conversations.
327
+ - **Daemon** (`scripts/daemon.js`): Background process handling Telegram/Feishu messages, heartbeat tasks, Unix socket dispatch, and idle/sleep transitions.
328
+ - **Distillation** (`scripts/distill.js`): Heartbeat task (default 4h, signal-gated) that updates your profile.
329
+ - **Memory Extract** (`scripts/memory-extract.js`): Heartbeat task (default 4h, idle-gated) that extracts long-term facts and session topic tags.
330
330
  - **Session Summarize** (`scripts/session-summarize.js`): Generates a 2-4 sentence summary for idle sessions. Injected as context when resuming after a 2h+ gap.
331
331
 
332
332
  ## Security
@@ -349,7 +349,7 @@ All agents share your cognitive profile (`~/.claude_profile.yaml`) — they all
349
349
  | Session summary (per session) | ~400–900 tokens input + ≤250 tokens output (Haiku) |
350
350
  | Mobile commands (`/stop`, `/list`, `/undo`) | 0 tokens |
351
351
 
352
- > Both memory consolidation and session summarization run in the background via Haiku (`--model haiku`). Input is capped by code: skeleton text ≤ 3,000 chars, summary output ≤ 500 chars. Neither runs per-message — memory consolidation triggers on sleep mode (30-min idle), summaries trigger once per idle session.
352
+ > Both memory consolidation and session summarization run in the background via Haiku (`--model haiku`). Input is capped by code: skeleton text ≤ 3,000 chars, summary output ≤ 500 chars. Neither runs per-message — memory consolidation follows heartbeat schedule with idle/precondition guards, and summaries trigger once per idle session on sleep-mode transitions.
353
353
 
354
354
  ## Plugin
355
355
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metame-cli",
3
- "version": "1.4.13",
3
+ "version": "1.4.17",
4
4
  "description": "The Cognitive Profile Layer for Claude Code. Knows how you think, not just what you said.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -17,6 +17,7 @@ function createAdminCommandHandler(deps) {
17
17
  getAllTasks,
18
18
  dispatchTask,
19
19
  log,
20
+ skillEvolution,
20
21
  } = deps;
21
22
 
22
23
  async function handleAdminCommand(ctx) {
@@ -40,6 +41,96 @@ function createAdminCommandHandler(deps) {
40
41
  return { handled: true, config };
41
42
  }
42
43
 
44
+ // /skill-evo — inspect and resolve skill evolution queue
45
+ if (text === '/skill-evo' || text.startsWith('/skill-evo ')) {
46
+ if (!skillEvolution) {
47
+ await bot.sendMessage(chatId, '❌ skill-evolution 模块不可用');
48
+ return { handled: true, config };
49
+ }
50
+
51
+ const arg = text.slice('/skill-evo'.length).trim();
52
+ const renderItem = (i) => {
53
+ const id = i.id || '-';
54
+ const target = i.skill_name ? `skill=${i.skill_name}` : (i.search_hint ? `hint=${i.search_hint}` : 'global');
55
+ const seen = i.last_seen || i.detected || '-';
56
+ const ev = i.evidence_count || 1;
57
+ return `- [${id}] ${i.type}/${i.status} (${target}, ev=${ev})\n ${i.reason || '(no reason)'}\n last: ${seen}`;
58
+ };
59
+
60
+ if (!arg || arg === 'list') {
61
+ const pendingAll = skillEvolution.listQueueItems({ status: 'pending', limit: 200 });
62
+ const notifiedAll = skillEvolution.listQueueItems({ status: 'notified', limit: 200 });
63
+ const installedAll = skillEvolution.listQueueItems({ status: 'installed', limit: 200 });
64
+ const dismissedAll = skillEvolution.listQueueItems({ status: 'dismissed', limit: 200 });
65
+
66
+ const pending = pendingAll.slice(0, 10);
67
+ const notified = notifiedAll.slice(0, 10);
68
+ const resolved = [...installedAll, ...dismissedAll]
69
+ .sort((a, b) => new Date(b.last_seen || b.detected || 0).getTime() - new Date(a.last_seen || a.detected || 0).getTime())
70
+ .slice(0, 5);
71
+
72
+ const lines = ['🧬 Skill Evolution Queue'];
73
+ lines.push(`pending: ${pendingAll.length} | notified: ${notifiedAll.length} | resolved(total): ${installedAll.length + dismissedAll.length}`);
74
+ if (pending.length > 0) {
75
+ lines.push('\nPending:');
76
+ for (const item of pending.slice(0, 5)) lines.push(renderItem(item));
77
+ }
78
+ if (notified.length > 0) {
79
+ lines.push('\nNotified:');
80
+ for (const item of notified.slice(0, 5)) lines.push(renderItem(item));
81
+ }
82
+ if (resolved.length > 0) {
83
+ lines.push('\nResolved (latest):');
84
+ for (const item of resolved) lines.push(renderItem(item));
85
+ }
86
+ if (pending.length === 0 && notified.length === 0 && resolved.length === 0) {
87
+ lines.push('\n(queue empty)');
88
+ }
89
+ lines.push('\n用法: /skill-evo done <id> | /skill-evo dismiss <id>');
90
+
91
+ await bot.sendMessage(chatId, lines.join('\n'));
92
+
93
+ if (bot.sendButtons) {
94
+ const actionable = [...notified, ...pending].slice(0, 3);
95
+ if (actionable.length > 0) {
96
+ const buttons = [];
97
+ for (const item of actionable) {
98
+ const label = `${item.type}:${(item.skill_name || item.search_hint || 'item').slice(0, 10)}`;
99
+ buttons.push([
100
+ { text: `✅ ${label}`, callback_data: `/skill-evo done ${item.id}` },
101
+ { text: `🙈 ${label}`, callback_data: `/skill-evo dismiss ${item.id}` },
102
+ ]);
103
+ }
104
+ await bot.sendButtons(chatId, '处理建议项:', buttons);
105
+ }
106
+ }
107
+ return { handled: true, config };
108
+ }
109
+
110
+ const doneMatch = arg.match(/^(?:done|install|installed)\s+(\S+)$/i);
111
+ if (doneMatch) {
112
+ const id = doneMatch[1];
113
+ const ok = skillEvolution.resolveQueueItemById
114
+ ? skillEvolution.resolveQueueItemById(id, 'installed')
115
+ : false;
116
+ await bot.sendMessage(chatId, ok ? `✅ 已标记 installed: ${id}` : `❌ 未找到可处理项: ${id}`);
117
+ return { handled: true, config };
118
+ }
119
+
120
+ const dismissMatch = arg.match(/^(?:dismiss|skip|ignored?)\s+(\S+)$/i);
121
+ if (dismissMatch) {
122
+ const id = dismissMatch[1];
123
+ const ok = skillEvolution.resolveQueueItemById
124
+ ? skillEvolution.resolveQueueItemById(id, 'dismissed')
125
+ : false;
126
+ await bot.sendMessage(chatId, ok ? `✅ 已标记 dismissed: ${id}` : `❌ 未找到可处理项: ${id}`);
127
+ return { handled: true, config };
128
+ }
129
+
130
+ await bot.sendMessage(chatId, '用法: /skill-evo list | /skill-evo done <id> | /skill-evo dismiss <id>');
131
+ return { handled: true, config };
132
+ }
133
+
43
134
  if (text === '/tasks') {
44
135
  const { general, project } = getAllTasks(config);
45
136
  let msg = '';
@@ -23,6 +23,7 @@ function createAgentCommandHandler(deps) {
23
23
  doBindAgent,
24
24
  mergeAgentRole,
25
25
  agentTools,
26
+ attachOrCreateSession,
26
27
  agentFlowTtlMs,
27
28
  agentBindTtlMs,
28
29
  } = deps;
@@ -99,13 +100,30 @@ function createAgentCommandHandler(deps) {
99
100
  const icon = p.icon || '🤖';
100
101
  const action = res.data.isNewProject ? '绑定成功' : '重新绑定';
101
102
  const displayCwd = String(res.data.cwd || '').replace(HOME, '~');
103
+ if (res.data.cwd && typeof attachOrCreateSession === 'function') {
104
+ attachOrCreateSession(chatId, normalizeCwd(res.data.cwd), p.name || agentName || res.data.projectKey || '');
105
+ }
102
106
  await bot.sendMessage(chatId, `${icon} ${p.name || agentName} ${action}\n目录: ${displayCwd}`);
103
107
  return { ok: true, data: res.data };
104
108
  }
105
109
 
106
110
  // Backward-compatible fallback
107
- await doBindAgent(bot, chatId, agentName, agentCwd);
108
- return { ok: true, data: { cwd: agentCwd } };
111
+ const fallback = await doBindAgent(bot, chatId, agentName, agentCwd);
112
+ if (!fallback || fallback.ok === false) {
113
+ return { ok: false, error: (fallback && fallback.error) || 'bind failed' };
114
+ }
115
+ const fallbackCwd = (fallback.data && fallback.data.cwd) || agentCwd;
116
+ if (fallbackCwd && typeof attachOrCreateSession === 'function') {
117
+ attachOrCreateSession(chatId, normalizeCwd(fallbackCwd), agentName || '');
118
+ }
119
+ return {
120
+ ok: true,
121
+ data: {
122
+ cwd: fallbackCwd,
123
+ projectKey: fallback && fallback.data ? fallback.data.projectKey : null,
124
+ project: fallback && fallback.data ? fallback.data.project : null,
125
+ },
126
+ };
109
127
  }
110
128
 
111
129
  async function editRoleViaUnifiedApi(workspaceDir, deltaText) {
@@ -121,7 +139,10 @@ function createAgentCommandHandler(deps) {
121
139
  if (agentTools && typeof agentTools.createNewWorkspaceAgent === 'function') {
122
140
  return agentTools.createNewWorkspaceAgent(name, dir, roleDesc, chatId);
123
141
  }
124
- await doBindAgent({ sendMessage: async () => {} }, chatId, name, dir);
142
+ const bound = await doBindAgent({ sendMessage: async () => {} }, chatId, name, dir);
143
+ if (!bound || bound.ok === false) {
144
+ return { ok: false, error: (bound && bound.error) || 'bind failed' };
145
+ }
125
146
  const merged = await mergeAgentRole(dir, roleDesc);
126
147
  if (merged.error) return { ok: false, error: merged.error };
127
148
  return { ok: true, data: { cwd: dir, project: { name }, role: merged } };
@@ -252,6 +273,9 @@ function createAgentCommandHandler(deps) {
252
273
  await bot.sendMessage(chatId, `❌ 创建 Agent 失败: ${created.error}`);
253
274
  return true;
254
275
  }
276
+ if (created.data && created.data.cwd && typeof attachOrCreateSession === 'function') {
277
+ attachOrCreateSession(chatId, normalizeCwd(created.data.cwd), name || '');
278
+ }
255
279
  const roleInfo = created.data.role || {};
256
280
  if (roleInfo.skipped) {
257
281
  await bot.sendMessage(chatId, '✅ Agent 创建成功');
@@ -25,6 +25,7 @@ function createClaudeEngine(deps) {
25
25
  normalizeCwd,
26
26
  isContentFile,
27
27
  sendFileButtons,
28
+ findSessionFile,
28
29
  listRecentSessions,
29
30
  getSession,
30
31
  createSession,
@@ -38,6 +39,72 @@ function createClaudeEngine(deps) {
38
39
  statusThrottleMs = 3000,
39
40
  fallbackThrottleMs = 8000,
40
41
  } = deps;
42
+ const SESSION_CWD_VALIDATION_TTL_MS = 30 * 1000;
43
+ const _sessionCwdValidationCache = new Map(); // key: `${sessionId}@@${cwd}` -> { inCwd, ts }
44
+
45
+ function cacheSessionCwdValidation(cacheKey, inCwd) {
46
+ _sessionCwdValidationCache.set(cacheKey, { inCwd: !!inCwd, ts: Date.now() });
47
+ if (_sessionCwdValidationCache.size > 512) {
48
+ const firstKey = _sessionCwdValidationCache.keys().next().value;
49
+ if (firstKey) _sessionCwdValidationCache.delete(firstKey);
50
+ }
51
+ return !!inCwd;
52
+ }
53
+
54
+ function isSessionInCwd(sessionId, cwd) {
55
+ const safeSessionId = String(sessionId || '').trim();
56
+ if (!safeSessionId || !cwd) return false;
57
+
58
+ const normCwd = normalizeCwd(cwd);
59
+ const cacheKey = `${safeSessionId}@@${normCwd}`;
60
+ const cached = _sessionCwdValidationCache.get(cacheKey);
61
+ if (cached && (Date.now() - cached.ts) < SESSION_CWD_VALIDATION_TTL_MS) {
62
+ return !!cached.inCwd;
63
+ }
64
+
65
+ try {
66
+ // Fast path: locate the exact session file, then validate its indexed projectPath.
67
+ if (typeof findSessionFile === 'function') {
68
+ const sessionFile = findSessionFile(safeSessionId);
69
+ if (!sessionFile) return cacheSessionCwdValidation(cacheKey, false);
70
+
71
+ const projectDir = path.dirname(sessionFile);
72
+ const indexFile = path.join(projectDir, 'sessions-index.json');
73
+ if (fs.existsSync(indexFile)) {
74
+ const data = JSON.parse(fs.readFileSync(indexFile, 'utf8'));
75
+ const entries = Array.isArray(data && data.entries) ? data.entries : [];
76
+ const entry = entries.find(e => e && e.sessionId === safeSessionId);
77
+ if (entry && entry.projectPath) {
78
+ return cacheSessionCwdValidation(cacheKey, normalizeCwd(entry.projectPath) === normCwd);
79
+ }
80
+ // sessions-index may lag behind new sessions; use project-level path from any entry.
81
+ const anyProjectPath = (entries.find(e => e && e.projectPath) || {}).projectPath;
82
+ if (anyProjectPath) {
83
+ return cacheSessionCwdValidation(cacheKey, normalizeCwd(anyProjectPath) === normCwd);
84
+ }
85
+ }
86
+
87
+ // Weak fallback: encode normCwd using Claude's folder convention and accept
88
+ // only positive match. If it doesn't match, keep current session to avoid
89
+ // false mismatches for paths with non-ASCII/special characters.
90
+ const expectedDirName = '-' + normCwd.replace(/^\//, '').replace(/[\/_ ]/g, '-');
91
+ const actualDirName = path.basename(projectDir);
92
+ if (actualDirName === expectedDirName) {
93
+ return cacheSessionCwdValidation(cacheKey, true);
94
+ }
95
+ // Unable to prove mismatch safely.
96
+ return cacheSessionCwdValidation(cacheKey, true);
97
+ }
98
+
99
+ // Ultimate fallback (legacy path): scoped scan in target cwd.
100
+ const recentInCwd = listRecentSessions(1000, normCwd);
101
+ const existsInCwd = recentInCwd.some(s => s.sessionId === safeSessionId);
102
+ return cacheSessionCwdValidation(cacheKey, existsInCwd);
103
+ } catch {
104
+ // Conservative fallback: if validation infra fails, avoid false positives by preserving current session.
105
+ return cacheSessionCwdValidation(cacheKey, true);
106
+ }
107
+ }
41
108
 
42
109
  /**
43
110
  * Parse [[FILE:...]] markers from Claude output.
@@ -467,13 +534,19 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
467
534
  // Skill routing: detect skill first, then decide session
468
535
  // BUT: if agent was explicitly addressed by nickname, don't let skill routing hijack the session
469
536
  const skill = agentMatch ? null : routeSkill(prompt);
537
+ const chatIdStr = String(chatId);
538
+ const chatAgentMap = { ...(config.telegram ? config.telegram.chat_agent_map : {}), ...(config.feishu ? config.feishu.chat_agent_map : {}) };
539
+ const boundProjectKey = chatAgentMap[chatIdStr] || (chatIdStr.startsWith('_agent_') ? chatIdStr.slice(7) : null);
540
+ const boundProject = boundProjectKey && config.projects ? config.projects[boundProjectKey] : null;
541
+ const boundCwd = (boundProject && boundProject.cwd) ? normalizeCwd(boundProject.cwd) : null;
470
542
 
471
543
  // Skills with dedicated pinned sessions (reused across days, no re-injection needed)
472
544
  const PINNED_SKILL_SESSIONS = new Set(['macos-mail-calendar', 'skill-manager']);
545
+ const usePinnedSkillSession = !!(skill && PINNED_SKILL_SESSIONS.has(skill));
473
546
 
474
547
  let session = getSession(chatId);
475
548
 
476
- if (skill && PINNED_SKILL_SESSIONS.has(skill)) {
549
+ if (usePinnedSkillSession) {
477
550
  // Use a dedicated long-lived session per skill
478
551
  const state = loadState();
479
552
  if (!state.pinned_sessions) state.pinned_sessions = {};
@@ -494,21 +567,51 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
494
567
  log('INFO', `Pinned session created for skill ${skill}: ${session.id.slice(0, 8)}`);
495
568
  }
496
569
  } else if (!session) {
497
- // Auto-attach to most recent Claude session (unified session management)
498
- const recent = listRecentSessions(1);
499
- if (recent.length > 0 && recent[0].sessionId && recent[0].projectPath) {
500
- const target = recent[0];
501
- const state = loadState();
502
- state.sessions[chatId] = {
503
- id: target.sessionId,
504
- cwd: target.projectPath,
505
- started: true,
506
- };
507
- saveState(state);
508
- session = state.sessions[chatId];
509
- log('INFO', `Auto-attached ${chatId} to recent session: ${target.sessionId.slice(0, 8)} (${path.basename(target.projectPath)})`);
570
+ if (boundCwd) {
571
+ // Agent-bound chats must stay in their own workspace: never attach to another project's session.
572
+ const recentInBound = listRecentSessions(1, boundCwd);
573
+ if (recentInBound.length > 0 && recentInBound[0].sessionId) {
574
+ const target = recentInBound[0];
575
+ const state = loadState();
576
+ state.sessions[chatId] = {
577
+ id: target.sessionId,
578
+ cwd: boundCwd,
579
+ started: true,
580
+ };
581
+ saveState(state);
582
+ session = state.sessions[chatId];
583
+ log('INFO', `Auto-attached ${chatId} to bound-session: ${target.sessionId.slice(0, 8)} (${path.basename(boundCwd)})`);
584
+ } else {
585
+ session = createSession(chatId, boundCwd, boundProject && boundProject.name ? boundProject.name : '');
586
+ log('INFO', `Created fresh session for bound workspace: ${path.basename(boundCwd)}`);
587
+ }
510
588
  } else {
511
- session = createSession(chatId);
589
+ // Non-bound chats keep legacy behavior: attach global recent, else create.
590
+ const recent = listRecentSessions(1);
591
+ if (recent.length > 0 && recent[0].sessionId && recent[0].projectPath) {
592
+ const target = recent[0];
593
+ const state = loadState();
594
+ state.sessions[chatId] = {
595
+ id: target.sessionId,
596
+ cwd: target.projectPath,
597
+ started: true,
598
+ };
599
+ saveState(state);
600
+ session = state.sessions[chatId];
601
+ log('INFO', `Auto-attached ${chatId} to recent session: ${target.sessionId.slice(0, 8)} (${path.basename(target.projectPath)})`);
602
+ } else {
603
+ session = createSession(chatId);
604
+ }
605
+ }
606
+ }
607
+
608
+ // Safety guard: prevent stale state from resuming another workspace's session.
609
+ if (!usePinnedSkillSession && session && session.started && session.id && session.id !== '__continue__' && session.cwd) {
610
+ const sessionCwd = normalizeCwd(session.cwd);
611
+ const existsInCwd = isSessionInCwd(session.id, sessionCwd);
612
+ if (!existsInCwd) {
613
+ log('WARN', `Session mismatch detected for ${chatId}: ${session.id.slice(0, 8)} not found in ${sessionCwd}; creating fresh session`);
614
+ session = createSession(chatId, sessionCwd, boundProject && boundProject.name ? boundProject.name : '');
512
615
  }
513
616
  }
514
617
 
@@ -6,6 +6,7 @@ function createCommandRouter(deps) {
6
6
  loadConfig,
7
7
  checkBudget,
8
8
  checkCooldown,
9
+ resetCooldown,
9
10
  routeAgent,
10
11
  normalizeCwd,
11
12
  attachOrCreateSession,
@@ -58,7 +59,7 @@ function createCommandRouter(deps) {
58
59
  }
59
60
 
60
61
  function extractPathFromText(input) {
61
- const m = String(input || '').match(/(?:~\/|\/)[^\s,。;;!!??"“”'‘’`]+/);
62
+ const m = String(input || '').match(/(?:~\/|\/|\.\/|\.\.\/)[^\s,。;;!!??"“”'‘’`]+/);
62
63
  if (!m) return '';
63
64
  return m[0].replace(/[,。;;!!??]+$/, '');
64
65
  }
@@ -100,6 +101,18 @@ function createCommandRouter(deps) {
100
101
  return '';
101
102
  }
102
103
 
104
+ function isLikelyDirectAgentAction(input) {
105
+ const text = String(input || '').trim();
106
+ return /^(?:请|帮我|麻烦|给我|给这个群|给当前群|在这个群|把这个群|把当前群|将这个群|这个群|当前群|本群|群里|我想|我要|我需要|创建|新建|新增|搞一个|加一个|create|bind|绑定|列出|查看|显示|有哪些|解绑|取消绑定|断开绑定|修改|调整)/i.test(text);
107
+ }
108
+
109
+ function looksLikeAgentIssueReport(input) {
110
+ const text = String(input || '').trim();
111
+ const hasIssueWords = /(用户反馈|反馈|报错|bug|问题|故障|异常|修复|改一下|修一下|任务|工单|代码)/i.test(text);
112
+ const hasAgentWords = /(agent|智能体|session|会话|目录|工作区|绑定|切换)/i.test(text);
113
+ return hasIssueWords && hasAgentWords;
114
+ }
115
+
103
116
  function projectNameFromResult(data, fallbackName) {
104
117
  if (data && data.project && data.project.name) return data.project.name;
105
118
  if (data && data.projectKey) return data.projectKey;
@@ -122,6 +135,11 @@ function createCommandRouter(deps) {
122
135
  if (hasFreshPendingFlow(key) || hasFreshPendingFlow(key + ':edit')) return false;
123
136
  const input = text.trim();
124
137
  if (!input) return false;
138
+ const directAction = isLikelyDirectAgentAction(input);
139
+ const issueReport = looksLikeAgentIssueReport(input);
140
+ if (issueReport && !directAction) return false;
141
+ const workspaceDir = extractPathFromText(input);
142
+ const hasWorkspacePath = !!workspaceDir;
125
143
 
126
144
  const hasAgentContext = /(agent|智能体|工作区|人设|绑定|当前群|这个群|chat|workspace)/i.test(input);
127
145
  const wantsList = /(列出|查看|显示|有哪些|list|show)/i.test(input) && /(agent|智能体|工作区|绑定)/i.test(input);
@@ -130,10 +148,10 @@ function createCommandRouter(deps) {
130
148
  ((/(角色|职责|人设)/i.test(input) && /(改|修改|调整|更新|变成|改成|改为)/i.test(input)) ||
131
149
  /(把这个agent|把当前agent|当前群.*角色|当前群.*职责)/i.test(input));
132
150
  const wantsCreate =
133
- (/(创建|新建|新增|搞一个|加一个|create)/i.test(input) && /(agent|智能体|人设|工作区)/i.test(input));
151
+ (/(创建|新建|新增|搞一个|加一个|create)/i.test(input) && /(agent|智能体|人设|工作区)/i.test(input) && (directAction || hasWorkspacePath));
134
152
  const wantsBind =
135
153
  !wantsCreate &&
136
- (/(绑定|bind)/i.test(input) && hasAgentContext);
154
+ (/(绑定|bind)/i.test(input) && hasAgentContext && (directAction || hasWorkspacePath));
137
155
 
138
156
  if (!wantsList && !wantsUnbind && !wantsEditRole && !wantsCreate && !wantsBind) {
139
157
  return false;
@@ -194,9 +212,12 @@ function createCommandRouter(deps) {
194
212
  }
195
213
 
196
214
  if (wantsCreate) {
197
- const workspaceDir = extractPathFromText(input);
198
215
  if (!workspaceDir) {
199
- await bot.sendMessage(chatId, '请补充工作目录,例如:`给这个群创建一个 Agent,目录是 ~/projects/foo`');
216
+ await bot.sendMessage(chatId, [
217
+ '我可以帮你创建并绑定 Agent,还差一个工作目录。',
218
+ '例如:`给这个群创建一个 Agent,目录是 ~/projects/foo`',
219
+ '也可以直接回我一个路径(`~/`、`/`、`./`、`../` 开头都行)。',
220
+ ].join('\n'));
200
221
  return true;
201
222
  }
202
223
  const agentName = deriveAgentName(input, workspaceDir);
@@ -214,7 +235,6 @@ function createCommandRouter(deps) {
214
235
  }
215
236
 
216
237
  if (wantsBind) {
217
- const workspaceDir = extractPathFromText(input);
218
238
  const agentName = deriveAgentName(input, workspaceDir);
219
239
  const res = await agentTools.bindAgentToChat(chatId, agentName, workspaceDir || null);
220
240
  if (!res.ok) {
@@ -319,6 +339,7 @@ function createCommandRouter(deps) {
319
339
  '',
320
340
  `⚙️ /model [${currentModel}] /provider [${currentProvider}] /status /tasks /run /budget /reload`,
321
341
  '🧠 /memory — 记忆统计 · /memory <关键词> — 搜索事实',
342
+ '🧬 /skill-evo — 查看/处理技能演化队列',
322
343
  `🔧 /doctor /fix /reset /sh <cmd> /nosleep [${getNoSleepProcess() ? 'ON' : 'OFF'}]`,
323
344
  '',
324
345
  '直接打字即可对话 💬',
@@ -357,6 +378,7 @@ function createCommandRouter(deps) {
357
378
  if (msgs.length === 0) return;
358
379
  const combined = msgs.join('\n');
359
380
  log('INFO', `Processing ${msgs.length} queued message(s) for ${chatId}`);
381
+ resetCooldown(chatId); // queued msgs already waited, skip cooldown
360
382
  try {
361
383
  await handleCommand(bot, chatId, combined, config, executeTaskByName);
362
384
  } catch (e) {
@@ -509,6 +509,7 @@ function createTaskScheduler(deps) {
509
509
  const notifications = skillEvolution.checkEvolutionQueue();
510
510
  for (const item of notifications) {
511
511
  let msg = '';
512
+ const idHint = item.id ? `\nID: \`${item.id}\`` : '';
512
513
  if (item.type === 'skill_gap') {
513
514
  msg = `🧬 *技能缺口检测*\n${item.reason}`;
514
515
  if (item.search_hint) msg += `\n搜索建议: \`${item.search_hint}\``;
@@ -517,6 +518,8 @@ function createTaskScheduler(deps) {
517
518
  } else if (item.type === 'user_complaint') {
518
519
  msg = `⚠️ *技能反馈*\n技能 \`${item.skill_name}\` 收到用户反馈\n${item.reason}`;
519
520
  }
521
+ if (msg && item.id) msg += `${idHint}\n处理: \`/skill-evo done ${item.id}\` 或 \`/skill-evo dismiss ${item.id}\``;
522
+ else if (msg) msg += idHint;
520
523
  if (msg && notifyFn) notifyFn(msg);
521
524
  }
522
525
  } catch (e) { log('WARN', `Skill evolution queue check failed: ${e.message}`); }
package/scripts/daemon.js CHANGED
@@ -52,6 +52,8 @@ try { skillEvolution = require('./skill-evolution'); } catch { /* graceful fallb
52
52
  const SKILL_ROUTES = [
53
53
  { name: 'macos-mail-calendar', pattern: /邮件|邮箱|收件箱|日历|日程|会议|schedule|email|mail|calendar|unread|inbox/i },
54
54
  { name: 'heartbeat-task-manager', pattern: /提醒|remind|闹钟|定时|每[天周月]/i },
55
+ { name: 'skill-manager', pattern: /找技能|管理技能|更新技能|安装技能|skill manager|skill scout|(?:find|look for)\s+skills?/i },
56
+ { name: 'skill-evolution-manager', pattern: /\/evolve\b|复盘一下|记录一下(这个)?经验|保存到\s*skill|skill evolution/i },
55
57
  ];
56
58
 
57
59
  function routeSkill(prompt) {
@@ -728,6 +730,10 @@ function checkCooldown(chatId) {
728
730
  return { ok: true };
729
731
  }
730
732
 
733
+ function resetCooldown(chatId) {
734
+ delete _lastClaudeCall[chatId];
735
+ }
736
+
731
737
  // Path shortener — imported from ./utils
732
738
  const { shortenPath, expandPath } = createPathMap();
733
739
  const {
@@ -753,14 +759,6 @@ function attachOrCreateSession(chatId, projCwd, name) {
753
759
  const state = loadState();
754
760
  // Virtual agent chatIds (_agent_*) always get a fresh one-shot session.
755
761
  // They must not resume real sessions, to avoid concurrency conflicts.
756
- if (!String(chatId).startsWith('_agent_')) {
757
- const recent = listRecentSessions(1, projCwd);
758
- if (recent.length > 0 && recent[0].sessionId) {
759
- state.sessions[chatId] = { id: recent[0].sessionId, cwd: projCwd, started: true };
760
- saveState(state);
761
- return;
762
- }
763
- }
764
762
  const newSess = createSession(chatId, projCwd, name || '');
765
763
  state.sessions[chatId] = { id: newSess.id, cwd: projCwd, started: false };
766
764
  saveState(state);
@@ -867,8 +865,13 @@ async function doBindAgent(bot, chatId, agentName, agentCwd) {
867
865
  } else {
868
866
  await bot.sendMessage(chatId, `${icon} ${agentName} ${action}\n目录: ${displayCwd}`);
869
867
  }
868
+ return {
869
+ ok: true,
870
+ data: { projectKey, cwd: agentCwd, isNewProject: isNew, project: cfg.projects[projectKey] },
871
+ };
870
872
  } catch (e) {
871
873
  await bot.sendMessage(chatId, `❌ 绑定失败: ${e.message}`);
874
+ return { ok: false, error: e.message };
872
875
  }
873
876
  }
874
877
 
@@ -1030,6 +1033,7 @@ const { handleAdminCommand } = createAdminCommandHandler({
1030
1033
  getAllTasks,
1031
1034
  dispatchTask,
1032
1035
  log,
1036
+ skillEvolution,
1033
1037
  });
1034
1038
 
1035
1039
  const { handleSessionCommand } = createSessionCommandHandler({
@@ -1086,6 +1090,7 @@ const { spawnClaudeAsync, askClaude } = createClaudeEngine({
1086
1090
  normalizeCwd,
1087
1091
  isContentFile,
1088
1092
  sendFileButtons,
1093
+ findSessionFile,
1089
1094
  listRecentSessions,
1090
1095
  getSession,
1091
1096
  createSession,
@@ -1152,6 +1157,7 @@ const { handleAgentCommand } = createAgentCommandHandler({
1152
1157
  doBindAgent,
1153
1158
  mergeAgentRole,
1154
1159
  agentTools,
1160
+ attachOrCreateSession,
1155
1161
  agentFlowTtlMs: getAgentFlowTtlMs,
1156
1162
  agentBindTtlMs: getAgentBindTtlMs,
1157
1163
  });
@@ -1207,6 +1213,7 @@ const { handleCommand } = createCommandRouter({
1207
1213
  loadConfig,
1208
1214
  checkBudget,
1209
1215
  checkCooldown,
1216
+ resetCooldown,
1210
1217
  routeAgent,
1211
1218
  normalizeCwd,
1212
1219
  attachOrCreateSession,
@@ -11,7 +11,7 @@
11
11
  * - Writes to evolution_queue.yaml for immediate action
12
12
  *
13
13
  * COLD PATH (batched, Haiku-powered):
14
- * - Piggybacks on distill.js (every 4h or on launch)
14
+ * - Runs as standalone heartbeat script task (skill-evolve, default 6h)
15
15
  * - Haiku analyzes accumulated skill signals for nuanced insights
16
16
  * - Merges evolution data into skill's evolution.json + SKILL.md
17
17
  *
@@ -174,7 +174,8 @@ function extractSkillSignal(prompt, output, error, files, cwd, toolUsageLog) {
174
174
 
175
175
  const hasSkills = skills.length > 0;
176
176
  const hasError = !!error;
177
- const hasToolFailure = output && /(?:failed|error|not found|not available|skill.{0,20}(?:missing|absent|not.{0,10}install))/i.test(output);
177
+ const outputText = typeof output === 'string' ? output : '';
178
+ const hasToolFailure = /(?:failed|error|not found|not available|skill.{0,20}(?:missing|absent|not.{0,10}install))/i.test(outputText);
178
179
 
179
180
  // Skip if no skill involvement and no failure
180
181
  if (!hasSkills && !hasError && !hasToolFailure) return null;
@@ -185,10 +186,11 @@ function extractSkillSignal(prompt, output, error, files, cwd, toolUsageLog) {
185
186
  skills_invoked: skills,
186
187
  tools_used: tools.slice(0, 20), // cap for storage
187
188
  error: error ? error.substring(0, 500) : null,
189
+ output_excerpt: outputText.substring(0, 500),
188
190
  has_tool_failure: !!hasToolFailure,
189
191
  files_modified: (files || []).slice(0, 10),
190
192
  cwd: cwd || null,
191
- outcome: error ? 'error' : (output ? 'success' : 'empty'),
193
+ outcome: (hasError || hasToolFailure) ? 'error' : (outputText ? 'success' : 'empty'),
192
194
  };
193
195
  }
194
196
 
@@ -275,7 +277,12 @@ function checkHotEvolution(signal) {
275
277
 
276
278
  // Rule 3: Missing skill detection
277
279
  const missingRe = new RegExp(policy.missing_skill_patterns.join('|'), 'i');
278
- if (signal.outcome !== 'success' && signal.prompt && missingRe.test((signal.error || '') + (signal.prompt || ''))) {
280
+ const missText = [(signal.error || ''), (signal.prompt || ''), (signal.output_excerpt || '')].join('\n');
281
+ const hasMissingPattern = missingRe.test(missText);
282
+ const hasExplicitSkillMiss = signal.has_tool_failure &&
283
+ /skill|技能|能力/i.test(missText) &&
284
+ /not found|missing|absent|no skill|找不到|没有找到|能力不足/i.test(missText);
285
+ if (signal.prompt && (hasMissingPattern || hasExplicitSkillMiss)) {
279
286
  addToQueue(queue, {
280
287
  type: 'skill_gap',
281
288
  skill_name: null,
@@ -295,7 +302,7 @@ function checkHotEvolution(signal) {
295
302
  if (isSuccess || isFail) {
296
303
  for (const sk of signal.skills_invoked) {
297
304
  const skillDir = findSkillDir(sk);
298
- if (skillDir) trackInsightOutcome(skillDir, isSuccess);
305
+ if (skillDir) trackInsightOutcome(skillDir, isSuccess, signal);
299
306
  }
300
307
  }
301
308
  }
@@ -305,7 +312,51 @@ function checkHotEvolution(signal) {
305
312
  * Update insight outcome stats in evolution.json for a skill.
306
313
  * Tracks success_count, fail_count, last_applied_at per insight text.
307
314
  */
308
- function trackInsightOutcome(skillDir, isSuccess) {
315
+ const INSIGHT_STOPWORDS = new Set([
316
+ 'the', 'and', 'for', 'with', 'that', 'this', 'from', 'into', 'when', 'where',
317
+ 'user', 'skill', 'meta', 'metame', 'should', 'always', 'never', 'please',
318
+ '问题', '用户', '技能', '需要', '应该', '这个', '那个', '以及', '如果', '然后',
319
+ ]);
320
+
321
+ function extractInsightTokens(text) {
322
+ const raw = String(text || '').toLowerCase();
323
+ const en = raw.match(/[a-z][a-z0-9_.-]{2,}/g) || [];
324
+ const zh = raw.match(/[\u4e00-\u9fff]{2,}/g) || [];
325
+ const tokens = [...en, ...zh]
326
+ .map(t => t.trim())
327
+ .filter(t => t.length >= 2 && !INSIGHT_STOPWORDS.has(t));
328
+ return [...new Set(tokens)];
329
+ }
330
+
331
+ function pickMatchedInsights(allInsights, signal) {
332
+ if (!signal || !Array.isArray(allInsights) || allInsights.length === 0) return [];
333
+
334
+ const context = [
335
+ signal.prompt || '',
336
+ signal.error || '',
337
+ signal.output_excerpt || '',
338
+ ...(Array.isArray(signal.tools_used) ? signal.tools_used.map(t => `${t.name || ''} ${t.context || ''}`) : []),
339
+ ...(Array.isArray(signal.files_modified) ? signal.files_modified : []),
340
+ ].join('\n').toLowerCase();
341
+
342
+ const scored = allInsights
343
+ .map((insight) => {
344
+ const tokens = extractInsightTokens(insight).slice(0, 12);
345
+ if (tokens.length === 0) return { insight, score: 0 };
346
+ let score = 0;
347
+ for (const token of tokens) {
348
+ if (context.includes(token)) score++;
349
+ }
350
+ return { insight, score };
351
+ })
352
+ .filter(x => x.score > 0)
353
+ .sort((a, b) => b.score - a.score);
354
+
355
+ if (scored.length > 0) return scored.slice(0, 3).map(x => x.insight);
356
+ return allInsights.length === 1 ? [allInsights[0]] : [];
357
+ }
358
+
359
+ function trackInsightOutcome(skillDir, isSuccess, signal = null) {
309
360
  const evoPath = path.join(skillDir, 'evolution.json');
310
361
  let data = {};
311
362
  try { data = JSON.parse(fs.readFileSync(evoPath, 'utf8')); } catch { return; }
@@ -317,8 +368,9 @@ function trackInsightOutcome(skillDir, isSuccess) {
317
368
  ...(data.fixes || []),
318
369
  ...(data.contexts || []),
319
370
  ];
371
+ const targetInsights = signal ? pickMatchedInsights(allInsights, signal) : allInsights;
320
372
 
321
- for (const insight of allInsights) {
373
+ for (const insight of targetInsights) {
322
374
  if (!data.insights_stats[insight]) {
323
375
  data.insights_stats[insight] = { success_count: 0, fail_count: 0, last_applied_at: null };
324
376
  }
@@ -333,7 +385,7 @@ function trackInsightOutcome(skillDir, isSuccess) {
333
385
 
334
386
  // ─────────────────────────────────────────────
335
387
  // Cold Path: Haiku-Powered Analysis
336
- // (called from distill.js)
388
+ // (called by heartbeat script task: skill-evolve)
337
389
  // ─────────────────────────────────────────────
338
390
 
339
391
  /**
@@ -591,7 +643,16 @@ function loadEvolutionQueue(yaml) {
591
643
  if (!fs.existsSync(EVOLUTION_QUEUE_FILE)) return { items: [] };
592
644
  const content = fs.readFileSync(EVOLUTION_QUEUE_FILE, 'utf8');
593
645
  const data = yaml.load(content);
594
- return data && Array.isArray(data.items) ? data : { items: [] };
646
+ const queue = data && Array.isArray(data.items) ? data : { items: [] };
647
+ let changed = false;
648
+ for (const item of queue.items) {
649
+ if (!item.id) {
650
+ item.id = `q-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
651
+ changed = true;
652
+ }
653
+ }
654
+ if (changed) saveEvolutionQueue(yaml, queue);
655
+ return queue;
595
656
  } catch {
596
657
  return { items: [] };
597
658
  }
@@ -604,18 +665,24 @@ function saveEvolutionQueue(yaml, queue) {
604
665
  }
605
666
 
606
667
  function addToQueue(queue, entry) {
607
- // Dedup by type + skill_name (update existing instead of adding duplicate)
668
+ // Dedup pending entries by core key. Skill gaps also include search_hint so unrelated gaps don't collapse.
608
669
  const existing = queue.items.find(i =>
609
- i.type === entry.type && i.skill_name === entry.skill_name && i.status === 'pending'
670
+ i.type === entry.type &&
671
+ i.skill_name === entry.skill_name &&
672
+ i.status === 'pending' &&
673
+ (entry.type !== 'skill_gap' || (i.search_hint || '') === (entry.search_hint || ''))
610
674
  );
611
675
 
612
676
  if (existing) {
613
677
  existing.evidence_count = (existing.evidence_count || 0) + (entry.evidence_count || 1);
614
678
  existing.last_seen = new Date().toISOString();
679
+ if (entry.reason) existing.reason = entry.reason;
680
+ if (entry.search_hint) existing.search_hint = entry.search_hint;
615
681
  return;
616
682
  }
617
683
 
618
684
  queue.items.push({
685
+ id: `q-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
619
686
  ...entry,
620
687
  detected: new Date().toISOString(),
621
688
  last_seen: new Date().toISOString(),
@@ -639,13 +706,11 @@ function checkEvolutionQueue() {
639
706
 
640
707
  const queue = loadEvolutionQueue(yaml);
641
708
  const pendingItems = queue.items.filter(i => i.status === 'pending');
642
- if (pendingItems.length === 0) return [];
643
-
644
709
  const notifications = [];
710
+ const policy = loadPolicy();
645
711
 
646
712
  for (const item of pendingItems) {
647
713
  // Require minimum evidence before notifying
648
- const policy = loadPolicy();
649
714
  const minEvidence = item.type === 'skill_gap' ? policy.min_evidence_for_gap : policy.min_evidence_for_update;
650
715
  if ((item.evidence_count || 1) < minEvidence) continue;
651
716
 
@@ -655,13 +720,14 @@ function checkEvolutionQueue() {
655
720
  }
656
721
 
657
722
  // Prune old resolved items (> 30 days)
723
+ const beforeLen = queue.items.length;
658
724
  const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000;
659
725
  queue.items = queue.items.filter(i =>
660
726
  i.status === 'pending' || i.status === 'notified' ||
661
727
  (new Date(i.last_seen || i.detected).getTime() > cutoff)
662
728
  );
663
729
 
664
- if (notifications.length > 0) {
730
+ if (notifications.length > 0 || queue.items.length !== beforeLen) {
665
731
  saveEvolutionQueue(yaml, queue);
666
732
  }
667
733
 
@@ -687,6 +753,42 @@ function resolveQueueItem(type, skillName, resolution) {
687
753
  }
688
754
  }
689
755
 
756
+ /**
757
+ * Mark queue item resolved by queue id.
758
+ * Returns true when updated.
759
+ */
760
+ function resolveQueueItemById(id, resolution) {
761
+ let yaml;
762
+ try { yaml = require('js-yaml'); } catch { return false; }
763
+ if (!id) return false;
764
+
765
+ const queue = loadEvolutionQueue(yaml);
766
+ const item = queue.items.find(i =>
767
+ i.id === id && (i.status === 'pending' || i.status === 'notified')
768
+ );
769
+ if (!item) return false;
770
+
771
+ item.status = resolution; // 'installed' | 'dismissed'
772
+ item.resolved_at = new Date().toISOString();
773
+ saveEvolutionQueue(yaml, queue);
774
+ return true;
775
+ }
776
+
777
+ /**
778
+ * List queue items for manual triage.
779
+ */
780
+ function listQueueItems({ status = null, limit = 20 } = {}) {
781
+ let yaml;
782
+ try { yaml = require('js-yaml'); } catch { return []; }
783
+ const queue = loadEvolutionQueue(yaml);
784
+ const items = Array.isArray(queue.items) ? queue.items : [];
785
+ const filtered = status ? items.filter(i => i.status === status) : items;
786
+ return filtered
787
+ .slice()
788
+ .sort((a, b) => new Date(b.last_seen || b.detected || 0).getTime() - new Date(a.last_seen || a.detected || 0).getTime())
789
+ .slice(0, Math.max(1, limit));
790
+ }
791
+
690
792
  // ─────────────────────────────────────────────
691
793
  // Evolution.json Merge (JS port of merge_evolution.py)
692
794
  // ─────────────────────────────────────────────
@@ -726,8 +828,11 @@ function smartStitch(skillDir) {
726
828
  try { data = JSON.parse(fs.readFileSync(evoPath, 'utf8')); } catch { return; }
727
829
 
728
830
  // Build evolution section
831
+ const AUTO_START = '<!-- METAME-EVOLUTION:START -->';
832
+ const AUTO_END = '<!-- METAME-EVOLUTION:END -->';
729
833
  const sections = [];
730
- sections.push('\n\n## User-Learned Best Practices & Constraints');
834
+ sections.push(`\n\n${AUTO_START}`);
835
+ sections.push('\n## User-Learned Best Practices & Constraints');
731
836
  sections.push('\n> **Auto-Generated Section**: Maintained by skill-evolution-manager. Do not edit manually.');
732
837
 
733
838
  // Helper: get quality indicator for an insight based on stats
@@ -755,13 +860,17 @@ function smartStitch(skillDir) {
755
860
  sections.push(`\n${data.custom_prompts}`);
756
861
  }
757
862
 
863
+ sections.push(`\n${AUTO_END}\n`);
758
864
  const evolutionBlock = sections.join('\n');
759
865
 
760
866
  let content = fs.readFileSync(skillMdPath, 'utf8');
761
- const pattern = /(\n+## User-Learned Best Practices & Constraints[\s\S]*$)/;
867
+ const markerPattern = new RegExp(`${AUTO_START}[\\s\\S]*?${AUTO_END}\\n?`);
868
+ const legacyPattern = /\n+## User-Learned Best Practices & Constraints[\s\S]*?(?=\n##\s+|\n#\s+|$)/;
762
869
 
763
- if (pattern.test(content)) {
764
- content = content.replace(pattern, evolutionBlock);
870
+ if (markerPattern.test(content)) {
871
+ content = content.replace(markerPattern, evolutionBlock.trimStart());
872
+ } else if (legacyPattern.test(content)) {
873
+ content = content.replace(legacyPattern, evolutionBlock);
765
874
  } else {
766
875
  content = content + evolutionBlock;
767
876
  }
@@ -827,6 +936,8 @@ module.exports = {
827
936
  distillSkills,
828
937
  checkEvolutionQueue,
829
938
  resolveQueueItem,
939
+ resolveQueueItemById,
940
+ listQueueItems,
830
941
  mergeEvolution,
831
942
  smartStitch,
832
943
  trackInsightOutcome,
@@ -0,0 +1,107 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { execFileSync } = require('child_process');
7
+ const yaml = require('js-yaml');
8
+
9
+ const ROOT = path.resolve(__dirname, '..');
10
+
11
+ function mkHome() {
12
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'metame-skill-evo-'));
13
+ }
14
+
15
+ function runWithHome(home, code) {
16
+ return execFileSync(process.execPath, ['-e', code], {
17
+ cwd: ROOT,
18
+ env: { ...process.env, HOME: home },
19
+ encoding: 'utf8',
20
+ });
21
+ }
22
+
23
+ test('captures missing-skill failures into skill_gap queue', () => {
24
+ const home = mkHome();
25
+ runWithHome(home, `
26
+ const se = require('./scripts/skill-evolution');
27
+ const s = se.extractSkillSignal('帮我做封面图', 'Error: skill not found: nano-banana', null, [], process.cwd(), []);
28
+ se.appendSkillSignal(s);
29
+ se.checkHotEvolution(s);
30
+ `);
31
+
32
+ const queuePath = path.join(home, '.metame', 'evolution_queue.yaml');
33
+ const queue = yaml.load(fs.readFileSync(queuePath, 'utf8'));
34
+ assert.equal(Array.isArray(queue.items), true);
35
+ assert.equal(queue.items.length, 1);
36
+ assert.equal(queue.items[0].type, 'skill_gap');
37
+ assert.equal(queue.items[0].status, 'pending');
38
+ assert.ok(queue.items[0].id);
39
+ });
40
+
41
+ test('resolves queue item by id', () => {
42
+ const home = mkHome();
43
+ runWithHome(home, `
44
+ const se = require('./scripts/skill-evolution');
45
+ const s = se.extractSkillSignal('帮我做封面图', 'Error: skill not found: nano-banana', null, [], process.cwd(), []);
46
+ se.appendSkillSignal(s);
47
+ se.checkHotEvolution(s);
48
+ const items = se.listQueueItems({ status: 'pending', limit: 5 });
49
+ if (!items[0]) throw new Error('no pending queue item');
50
+ const ok = se.resolveQueueItemById(items[0].id, 'installed');
51
+ if (!ok) throw new Error('resolveQueueItemById returned false');
52
+ `);
53
+
54
+ const queuePath = path.join(home, '.metame', 'evolution_queue.yaml');
55
+ const queue = yaml.load(fs.readFileSync(queuePath, 'utf8'));
56
+ assert.equal(queue.items.length, 1);
57
+ assert.equal(queue.items[0].status, 'installed');
58
+ });
59
+
60
+ test('smartStitch preserves sections after evolution block', () => {
61
+ const home = mkHome();
62
+ runWithHome(home, `
63
+ const fs = require('fs');
64
+ const path = require('path');
65
+ const se = require('./scripts/skill-evolution');
66
+ const dir = path.join(process.env.HOME, 'sample-skill');
67
+ fs.mkdirSync(dir, { recursive: true });
68
+ fs.writeFileSync(path.join(dir, 'SKILL.md'), '# Demo\\n\\nBody\\n\\n## User-Learned Best Practices & Constraints\\nOld\\n\\n## KeepMe\\nKeep this section\\n');
69
+ fs.writeFileSync(path.join(dir, 'evolution.json'), JSON.stringify({ preferences: ['prefer concise output'] }, null, 2));
70
+ se.smartStitch(dir);
71
+ `);
72
+
73
+ const content = fs.readFileSync(path.join(home, 'sample-skill', 'SKILL.md'), 'utf8');
74
+ assert.match(content, /METAME-EVOLUTION:START/);
75
+ assert.match(content, /prefer concise output/);
76
+ assert.match(content, /## KeepMe/);
77
+ assert.match(content, /Keep this section/);
78
+ });
79
+
80
+ test('trackInsightOutcome updates only matched insights', () => {
81
+ const home = mkHome();
82
+ runWithHome(home, `
83
+ const fs = require('fs');
84
+ const path = require('path');
85
+ const se = require('./scripts/skill-evolution');
86
+ const dir = path.join(process.env.HOME, '.claude', 'skills', 'demo-skill');
87
+ fs.mkdirSync(dir, { recursive: true });
88
+ fs.writeFileSync(path.join(dir, 'SKILL.md'), '# Demo');
89
+ fs.writeFileSync(path.join(dir, 'evolution.json'), JSON.stringify({
90
+ preferences: ['alpha_mode'],
91
+ fixes: ['beta_mode']
92
+ }, null, 2));
93
+ se.trackInsightOutcome(dir, true, {
94
+ prompt: 'please use alpha_mode',
95
+ error: '',
96
+ output_excerpt: '',
97
+ tools_used: [],
98
+ files_modified: []
99
+ });
100
+ `);
101
+
102
+ const evo = JSON.parse(fs.readFileSync(path.join(home, '.claude', 'skills', 'demo-skill', 'evolution.json'), 'utf8'));
103
+ assert.equal(typeof evo.insights_stats, 'object');
104
+ assert.ok(evo.insights_stats.alpha_mode);
105
+ assert.equal(evo.insights_stats.alpha_mode.success_count, 1);
106
+ assert.equal(evo.insights_stats.beta_mode, undefined);
107
+ });