metame-cli 1.4.15 → 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/package.json +1 -1
- package/scripts/daemon-admin-commands.js +91 -0
- package/scripts/daemon-claude-engine.js +14 -12
- package/scripts/daemon-command-router.js +1 -0
- package/scripts/daemon-task-scheduler.js +3 -0
- package/scripts/daemon.js +3 -0
- package/scripts/skill-evolution.js +130 -19
- package/scripts/skill-evolution.test.js +107 -0
package/package.json
CHANGED
|
@@ -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 = '';
|
|
@@ -42,13 +42,6 @@ function createClaudeEngine(deps) {
|
|
|
42
42
|
const SESSION_CWD_VALIDATION_TTL_MS = 30 * 1000;
|
|
43
43
|
const _sessionCwdValidationCache = new Map(); // key: `${sessionId}@@${cwd}` -> { inCwd, ts }
|
|
44
44
|
|
|
45
|
-
function decodeProjectDirName(dirName) {
|
|
46
|
-
const raw = String(dirName || '');
|
|
47
|
-
if (!raw) return '';
|
|
48
|
-
if (raw.startsWith('-')) return '/' + raw.slice(1).replace(/-/g, '/');
|
|
49
|
-
return raw.replace(/-/g, '/');
|
|
50
|
-
}
|
|
51
|
-
|
|
52
45
|
function cacheSessionCwdValidation(cacheKey, inCwd) {
|
|
53
46
|
_sessionCwdValidationCache.set(cacheKey, { inCwd: !!inCwd, ts: Date.now() });
|
|
54
47
|
if (_sessionCwdValidationCache.size > 512) {
|
|
@@ -84,14 +77,23 @@ function createClaudeEngine(deps) {
|
|
|
84
77
|
if (entry && entry.projectPath) {
|
|
85
78
|
return cacheSessionCwdValidation(cacheKey, normalizeCwd(entry.projectPath) === normCwd);
|
|
86
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
|
+
}
|
|
87
85
|
}
|
|
88
86
|
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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);
|
|
93
94
|
}
|
|
94
|
-
|
|
95
|
+
// Unable to prove mismatch safely.
|
|
96
|
+
return cacheSessionCwdValidation(cacheKey, true);
|
|
95
97
|
}
|
|
96
98
|
|
|
97
99
|
// Ultimate fallback (legacy path): scoped scan in target cwd.
|
|
@@ -339,6 +339,7 @@ function createCommandRouter(deps) {
|
|
|
339
339
|
'',
|
|
340
340
|
`⚙️ /model [${currentModel}] /provider [${currentProvider}] /status /tasks /run /budget /reload`,
|
|
341
341
|
'🧠 /memory — 记忆统计 · /memory <关键词> — 搜索事实',
|
|
342
|
+
'🧬 /skill-evo — 查看/处理技能演化队列',
|
|
342
343
|
`🔧 /doctor /fix /reset /sh <cmd> /nosleep [${getNoSleepProcess() ? 'ON' : 'OFF'}]`,
|
|
343
344
|
'',
|
|
344
345
|
'直接打字即可对话 💬',
|
|
@@ -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) {
|
|
@@ -1031,6 +1033,7 @@ const { handleAdminCommand } = createAdminCommandHandler({
|
|
|
1031
1033
|
getAllTasks,
|
|
1032
1034
|
dispatchTask,
|
|
1033
1035
|
log,
|
|
1036
|
+
skillEvolution,
|
|
1034
1037
|
});
|
|
1035
1038
|
|
|
1036
1039
|
const { handleSessionCommand } = createSessionCommandHandler({
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* - Writes to evolution_queue.yaml for immediate action
|
|
12
12
|
*
|
|
13
13
|
* COLD PATH (batched, Haiku-powered):
|
|
14
|
-
* -
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 &&
|
|
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(
|
|
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
|
|
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 (
|
|
764
|
-
content = content.replace(
|
|
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
|
+
});
|