metame-cli 1.3.18 → 1.3.22

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.
@@ -0,0 +1,792 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * MetaMe Skill Evolution Module
5
+ *
6
+ * Two-tier skill evolution system that mirrors the metacognition pipeline:
7
+ *
8
+ * HOT PATH (immediate, zero API cost):
9
+ * - After each Claude task completes in daemon.js
10
+ * - Heuristic rules detect skill failures, user complaints, skill decay
11
+ * - Writes to evolution_queue.yaml for immediate action
12
+ *
13
+ * COLD PATH (batched, Haiku-powered):
14
+ * - Piggybacks on distill.js (every 4h or on launch)
15
+ * - Haiku analyzes accumulated skill signals for nuanced insights
16
+ * - Merges evolution data into skill's evolution.json + SKILL.md
17
+ *
18
+ * Data flow:
19
+ * Claude completes → extractSkillSignal() → skill_signals.jsonl
20
+ * → checkHotEvolution() → evolution_queue.yaml
21
+ * → [distill.js] distillSkills() → evolution.json → SKILL.md
22
+ *
23
+ * SELF-EVOLUTION:
24
+ * All thresholds, rules, and even the Haiku prompt live in evolution_policy.yaml.
25
+ * The cold path periodically evaluates its own effectiveness and rewrites the policy.
26
+ * Nothing is hardcoded that can't be changed by the system itself.
27
+ */
28
+
29
+ 'use strict';
30
+
31
+ const fs = require('fs');
32
+ const path = require('path');
33
+ const os = require('os');
34
+
35
+ const HOME = os.homedir();
36
+ const METAME_DIR = path.join(HOME, '.metame');
37
+ const SKILL_SIGNAL_FILE = path.join(METAME_DIR, 'skill_signals.jsonl');
38
+ const EVOLUTION_QUEUE_FILE = path.join(METAME_DIR, 'evolution_queue.yaml');
39
+ const EVOLUTION_POLICY_FILE = path.join(METAME_DIR, 'evolution_policy.yaml');
40
+ const BRAIN_FILE = path.join(HOME, '.claude_profile.yaml');
41
+
42
+ // Skill directories (check both locations)
43
+ const SKILL_DIRS = [
44
+ path.join(HOME, '.claude', 'skills'),
45
+ path.join(HOME, '.opencode', 'skills'),
46
+ ];
47
+
48
+ // ─────────────────────────────────────────────
49
+ // Policy: all tunable params, self-modifiable
50
+ // ─────────────────────────────────────────────
51
+
52
+ const DEFAULT_POLICY = {
53
+ version: 1,
54
+
55
+ // Hot path params
56
+ hot_failure_threshold: 3,
57
+ hot_failure_window_minutes: 30,
58
+ complaint_patterns: ['不好用', '不对', 'wrong', 'broken', "doesn't work", '有问题', '不行', 'bug', '失败'],
59
+ missing_skill_patterns: ['没有找到.{0,10}技能', 'skill not found', 'no skill.{0,10}(available|installed)', '能力不足', '找不到'],
60
+
61
+ // Cold path params
62
+ min_signals_for_distill: 3,
63
+ max_signals_buffer: 200,
64
+ min_evidence_for_update: 2,
65
+ min_evidence_for_gap: 3,
66
+ max_updates_per_analysis: 3,
67
+ max_gaps_per_analysis: 2,
68
+
69
+ // Self-evaluation
70
+ self_eval_interval: 5, // every N cold-path runs, evaluate policy effectiveness
71
+ cold_path_run_count: 0,
72
+
73
+ // Haiku prompt (the system can rewrite this)
74
+ prompt_template: `You are a skill evolution analyzer for an AI coding assistant called MetaMe.
75
+ Analyze recent skill usage signals and provide actionable evolution insights.
76
+
77
+ INSTALLED SKILLS:
78
+ \${installedSkills}
79
+
80
+ RECENT SKILL SIGNALS (\${signalCount} interactions):
81
+ \${signalSummary}
82
+ \${patternContext}
83
+
84
+ RULES:
85
+ 1. Only reference skills in INSTALLED SKILLS or explicitly invoked in signals.
86
+ 2. For "updates": provide specific, concrete improvements (not vague suggestions).
87
+ 3. For "missing_skills": only when user repeatedly attempted a task with no matching skill (\${minEvidenceForGap}+ signals).
88
+ 4. Minimum evidence: updates need \${minEvidenceForUpdate}+ related signals, missing_skills need \${minEvidenceForGap}+ failed attempts.
89
+ 5. Do NOT suggest for one-off tasks.
90
+ 6. Maximum \${maxUpdates} updates and \${maxGaps} missing_skills per analysis.
91
+
92
+ Respond with ONLY a JSON code block:
93
+ \\\`\\\`\\\`json
94
+ {
95
+ "updates": [
96
+ {
97
+ "skill_name": "exact-installed-skill-name",
98
+ "category": "fix|preference|context",
99
+ "insight": "specific actionable text",
100
+ "evidence_count": 3
101
+ }
102
+ ],
103
+ "missing_skills": [
104
+ {
105
+ "task_pattern": "what the user keeps trying to do",
106
+ "search_query": "suggested search term",
107
+ "evidence_count": 4
108
+ }
109
+ ]
110
+ }
111
+ \\\`\\\`\\\`
112
+
113
+ If no actionable insights, respond with exactly: NO_EVOLUTION`,
114
+ };
115
+
116
+ function loadPolicy() {
117
+ let yaml;
118
+ try { yaml = require('js-yaml'); } catch { return { ...DEFAULT_POLICY }; }
119
+
120
+ try {
121
+ if (!fs.existsSync(EVOLUTION_POLICY_FILE)) {
122
+ // First run: write default policy
123
+ savePolicy(yaml, DEFAULT_POLICY);
124
+ return { ...DEFAULT_POLICY };
125
+ }
126
+ const content = fs.readFileSync(EVOLUTION_POLICY_FILE, 'utf8');
127
+ const loaded = yaml.load(content) || {};
128
+ // Merge with defaults (new fields auto-added on upgrade)
129
+ return { ...DEFAULT_POLICY, ...loaded };
130
+ } catch {
131
+ return { ...DEFAULT_POLICY };
132
+ }
133
+ }
134
+
135
+ function savePolicy(yaml, policy) {
136
+ try {
137
+ if (!fs.existsSync(METAME_DIR)) fs.mkdirSync(METAME_DIR, { mode: 0o700, recursive: true });
138
+ fs.writeFileSync(EVOLUTION_POLICY_FILE, yaml.dump(policy, { lineWidth: 120 }), 'utf8');
139
+ } catch {}
140
+ }
141
+
142
+ // ─────────────────────────────────────────────
143
+ // Signal Extraction (called from daemon.js)
144
+ // ─────────────────────────────────────────────
145
+
146
+ /**
147
+ * Extract structured skill signal from a completed Claude task.
148
+ * Returns null if the interaction has no skill-relevant data.
149
+ */
150
+ function extractSkillSignal(prompt, output, error, files, cwd, toolUsageLog) {
151
+ // Only capture if skills were involved OR task failed
152
+ const skills = [];
153
+ const tools = [];
154
+
155
+ if (Array.isArray(toolUsageLog)) {
156
+ for (const entry of toolUsageLog) {
157
+ if (entry.tool === 'Skill' && entry.skill) {
158
+ skills.push(entry.skill);
159
+ }
160
+ tools.push({ name: entry.tool, context: entry.context || null });
161
+ }
162
+ }
163
+
164
+ // Also detect skills from output text (fallback if toolUsageLog is sparse)
165
+ if (output) {
166
+ const skillMatches = output.match(/🔧 Skill: 「([^」]+)」/g);
167
+ if (skillMatches) {
168
+ for (const m of skillMatches) {
169
+ const name = m.match(/「([^」]+)」/)?.[1];
170
+ if (name && !skills.includes(name)) skills.push(name);
171
+ }
172
+ }
173
+ }
174
+
175
+ const hasSkills = skills.length > 0;
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);
178
+
179
+ // Skip if no skill involvement and no failure
180
+ if (!hasSkills && !hasError && !hasToolFailure) return null;
181
+
182
+ return {
183
+ ts: new Date().toISOString(),
184
+ prompt: (prompt || '').substring(0, 500),
185
+ skills_invoked: skills,
186
+ tools_used: tools.slice(0, 20), // cap for storage
187
+ error: error ? error.substring(0, 500) : null,
188
+ has_tool_failure: !!hasToolFailure,
189
+ files_modified: (files || []).slice(0, 10),
190
+ cwd: cwd || null,
191
+ outcome: error ? 'error' : (output ? 'success' : 'empty'),
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Append a skill signal to the JSONL buffer.
197
+ */
198
+ function appendSkillSignal(signal) {
199
+ if (!signal) return;
200
+ try {
201
+ if (!fs.existsSync(METAME_DIR)) fs.mkdirSync(METAME_DIR, { mode: 0o700, recursive: true });
202
+
203
+ // Append
204
+ fs.appendFileSync(SKILL_SIGNAL_FILE, JSON.stringify(signal) + '\n', 'utf8');
205
+
206
+ // Truncate if over limit (keep newest)
207
+ const content = fs.readFileSync(SKILL_SIGNAL_FILE, 'utf8').trim();
208
+ const lines = content.split('\n');
209
+ const policy = loadPolicy();
210
+ if (lines.length > policy.max_signals_buffer) {
211
+ fs.writeFileSync(SKILL_SIGNAL_FILE, lines.slice(-policy.max_signals_buffer).join('\n') + '\n', 'utf8');
212
+ }
213
+ } catch {
214
+ // Non-fatal
215
+ }
216
+ }
217
+
218
+ // ─────────────────────────────────────────────
219
+ // Hot Path: Heuristic Evolution (zero API cost)
220
+ // ─────────────────────────────────────────────
221
+
222
+ /**
223
+ * Immediate heuristic checks after each task.
224
+ * Detects acute issues that shouldn't wait for 4h distill cycle.
225
+ */
226
+ function checkHotEvolution(signal) {
227
+ if (!signal) return;
228
+
229
+ let yaml;
230
+ try { yaml = require('js-yaml'); } catch { return; }
231
+
232
+ const policy = loadPolicy();
233
+ const queue = loadEvolutionQueue(yaml);
234
+ const windowMs = policy.hot_failure_window_minutes * 60 * 1000;
235
+
236
+ // Rule 1: Repeated skill failures → queue discovery
237
+ if (signal.error || signal.has_tool_failure) {
238
+ const recentFailures = readRecentSignals()
239
+ .filter(s => {
240
+ if (!s.error && !s.has_tool_failure) return false;
241
+ return (Date.now() - new Date(s.ts).getTime()) < windowMs;
242
+ });
243
+
244
+ const failCounts = {};
245
+ for (const s of recentFailures) {
246
+ for (const sk of (s.skills_invoked || [])) {
247
+ failCounts[sk] = (failCounts[sk] || 0) + 1;
248
+ }
249
+ }
250
+
251
+ for (const [skillName, count] of Object.entries(failCounts)) {
252
+ if (count >= policy.hot_failure_threshold) {
253
+ addToQueue(queue, {
254
+ type: 'skill_fix',
255
+ skill_name: skillName,
256
+ reason: `Failed ${count} times in ${policy.hot_failure_window_minutes} minutes`,
257
+ evidence_count: count,
258
+ });
259
+ }
260
+ }
261
+ }
262
+
263
+ // Rule 2: User complaints about a skill
264
+ const complaintRe = new RegExp(policy.complaint_patterns.join('|'), 'i');
265
+ if (signal.prompt && complaintRe.test(signal.prompt) && signal.skills_invoked.length > 0) {
266
+ for (const sk of signal.skills_invoked) {
267
+ addToQueue(queue, {
268
+ type: 'user_complaint',
269
+ skill_name: sk,
270
+ reason: `User complaint: "${signal.prompt.substring(0, 100)}"`,
271
+ evidence_count: 1,
272
+ });
273
+ }
274
+ }
275
+
276
+ // Rule 3: Missing skill detection
277
+ const missingRe = new RegExp(policy.missing_skill_patterns.join('|'), 'i');
278
+ if (signal.outcome !== 'success' && signal.prompt && missingRe.test((signal.error || '') + (signal.prompt || ''))) {
279
+ addToQueue(queue, {
280
+ type: 'skill_gap',
281
+ skill_name: null,
282
+ reason: `Possible missing capability: "${signal.prompt.substring(0, 150)}"`,
283
+ search_hint: signal.prompt.substring(0, 80),
284
+ evidence_count: 1,
285
+ });
286
+ }
287
+
288
+ saveEvolutionQueue(yaml, queue);
289
+ }
290
+
291
+ // ─────────────────────────────────────────────
292
+ // Cold Path: Haiku-Powered Analysis
293
+ // (called from distill.js)
294
+ // ─────────────────────────────────────────────
295
+
296
+ /**
297
+ * Batch-analyze accumulated skill signals via Haiku.
298
+ * All params come from evolution_policy.yaml — including the prompt itself.
299
+ * Every N runs, triggers self-evaluation to optimize the policy.
300
+ * Returns { updates, missing_skills } or null if nothing to process.
301
+ */
302
+ function distillSkills() {
303
+ const { execSync } = require('child_process');
304
+ let yaml;
305
+ try { yaml = require('js-yaml'); } catch { return null; }
306
+
307
+ const policy = loadPolicy();
308
+
309
+ // Read signals
310
+ if (!fs.existsSync(SKILL_SIGNAL_FILE)) return null;
311
+ const content = fs.readFileSync(SKILL_SIGNAL_FILE, 'utf8').trim();
312
+ if (!content) return null;
313
+
314
+ const lines = content.split('\n');
315
+ const signals = lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
316
+ if (signals.length < policy.min_signals_for_distill) return null;
317
+
318
+ // Get installed skills list
319
+ const installedSkills = listInstalledSkills();
320
+ if (installedSkills.length === 0) {
321
+ clearSignals();
322
+ return null;
323
+ }
324
+
325
+ // Read metacognition patterns for bridge context
326
+ let patternContext = '';
327
+ try {
328
+ const profile = yaml.load(fs.readFileSync(BRAIN_FILE, 'utf8'));
329
+ const patterns = (profile?.growth?.patterns || [])
330
+ .filter(p => ['avoidance', 'friction', 'efficiency'].includes(p.type))
331
+ .map(p => `[${p.type}] ${p.summary}`);
332
+ if (patterns.length > 0) {
333
+ patternContext = `\nBEHAVIORAL CONTEXT (from metacognition):\n${patterns.join('\n')}`;
334
+ }
335
+ } catch {}
336
+
337
+ // Build signal summary (compact)
338
+ const signalSummary = signals.map((s, i) => {
339
+ const parts = [`${i + 1}. [${s.outcome}]`];
340
+ if (s.skills_invoked?.length) parts.push(`skills=[${s.skills_invoked.join(',')}]`);
341
+ if (s.error) parts.push(`error="${s.error.substring(0, 100)}"`);
342
+ if (s.has_tool_failure) parts.push('tool_failure=true');
343
+ parts.push(`prompt="${s.prompt?.substring(0, 80)}"`);
344
+ return parts.join(' ');
345
+ }).join('\n');
346
+
347
+ // Build prompt from policy template (all params injectable)
348
+ const installedSkillsStr = installedSkills.map(s => `- ${s.name}: ${s.description || 'no description'}`).join('\n');
349
+ const prompt = policy.prompt_template
350
+ .replace(/\$\{installedSkills\}/g, installedSkillsStr)
351
+ .replace(/\$\{signalCount\}/g, String(signals.length))
352
+ .replace(/\$\{signalSummary\}/g, signalSummary)
353
+ .replace(/\$\{patternContext\}/g, patternContext)
354
+ .replace(/\$\{minEvidenceForUpdate\}/g, String(policy.min_evidence_for_update))
355
+ .replace(/\$\{minEvidenceForGap\}/g, String(policy.min_evidence_for_gap))
356
+ .replace(/\$\{maxUpdates\}/g, String(policy.max_updates_per_analysis))
357
+ .replace(/\$\{maxGaps\}/g, String(policy.max_gaps_per_analysis));
358
+
359
+ try {
360
+ let distillEnv = {};
361
+ try {
362
+ const { buildDistillEnv } = require('./providers');
363
+ distillEnv = buildDistillEnv();
364
+ } catch {}
365
+
366
+ const result = execSync('claude -p --model haiku --no-session-persistence', {
367
+ input: prompt,
368
+ encoding: 'utf8',
369
+ timeout: 30000,
370
+ env: { ...process.env, ...distillEnv },
371
+ });
372
+
373
+ if (result.includes('NO_EVOLUTION')) {
374
+ clearSignals();
375
+ bumpRunCount(yaml, policy);
376
+ return { updates: [], missing_skills: [] };
377
+ }
378
+
379
+ const jsonMatch = result.match(/```json\s*([\s\S]*?)```/);
380
+ if (!jsonMatch) {
381
+ clearSignals();
382
+ return null;
383
+ }
384
+
385
+ const evolution = JSON.parse(jsonMatch[1]);
386
+ const updates = Array.isArray(evolution.updates) ? evolution.updates : [];
387
+ const missingSkills = Array.isArray(evolution.missing_skills) ? evolution.missing_skills : [];
388
+
389
+ // Apply updates to skill evolution.json files
390
+ for (const update of updates) {
391
+ if (!update.skill_name || !update.insight) continue;
392
+ const skillDir = findSkillDir(update.skill_name);
393
+ if (!skillDir) continue;
394
+
395
+ const evoData = {};
396
+ const key = update.category === 'fix' ? 'fixes'
397
+ : update.category === 'preference' ? 'preferences'
398
+ : 'contexts';
399
+ evoData[key] = [update.insight];
400
+
401
+ mergeEvolution(skillDir, evoData);
402
+ smartStitch(skillDir);
403
+ }
404
+
405
+ // Queue missing skills for user notification
406
+ if (missingSkills.length > 0) {
407
+ const queue = loadEvolutionQueue(yaml);
408
+ for (const ms of missingSkills) {
409
+ addToQueue(queue, {
410
+ type: 'skill_gap',
411
+ skill_name: null,
412
+ reason: `Haiku analysis: ${ms.task_pattern}`,
413
+ search_hint: ms.search_query,
414
+ evidence_count: ms.evidence_count || 3,
415
+ });
416
+ }
417
+ saveEvolutionQueue(yaml, queue);
418
+ }
419
+
420
+ // Log this run for self-evaluation
421
+ logEvolutionRun(yaml, policy, signals.length, updates.length, missingSkills.length);
422
+
423
+ clearSignals();
424
+
425
+ // Self-evaluation: periodically let Haiku review and rewrite the policy
426
+ bumpRunCount(yaml, policy);
427
+ if (policy.cold_path_run_count > 0 && policy.cold_path_run_count % policy.self_eval_interval === 0) {
428
+ selfEvaluatePolicy(yaml, policy, execSync, distillEnv);
429
+ }
430
+
431
+ return { updates, missing_skills: missingSkills };
432
+
433
+ } catch (err) {
434
+ try { console.log(`⚠️ Skill evolution analysis failed: ${err.message}`); } catch {}
435
+ return null;
436
+ }
437
+ }
438
+
439
+ /**
440
+ * Increment cold-path run counter in policy.
441
+ */
442
+ function bumpRunCount(yaml, policy) {
443
+ policy.cold_path_run_count = (policy.cold_path_run_count || 0) + 1;
444
+ savePolicy(yaml, policy);
445
+ }
446
+
447
+ /**
448
+ * Log each evolution run for self-evaluation audit trail.
449
+ */
450
+ function logEvolutionRun(yaml, policy, signalCount, updateCount, gapCount) {
451
+ const logFile = path.join(METAME_DIR, 'evolution_log.yaml');
452
+ let log = { runs: [] };
453
+ try {
454
+ if (fs.existsSync(logFile)) log = yaml.load(fs.readFileSync(logFile, 'utf8')) || { runs: [] };
455
+ } catch {}
456
+
457
+ log.runs.push({
458
+ ts: new Date().toISOString(),
459
+ signals: signalCount,
460
+ updates: updateCount,
461
+ gaps: gapCount,
462
+ policy_version: policy.version,
463
+ });
464
+
465
+ // Keep last 50 runs
466
+ if (log.runs.length > 50) log.runs = log.runs.slice(-50);
467
+ try { fs.writeFileSync(logFile, yaml.dump(log, { lineWidth: -1 }), 'utf8'); } catch {}
468
+ }
469
+
470
+ /**
471
+ * Self-evaluation: Haiku reviews the evolution_log and current policy,
472
+ * then rewrites evolution_policy.yaml if improvements are warranted.
473
+ * This is how the system optimizes its own parameters.
474
+ */
475
+ function selfEvaluatePolicy(yaml, policy, execSync, distillEnv) {
476
+ try {
477
+ // Read evolution log
478
+ const logFile = path.join(METAME_DIR, 'evolution_log.yaml');
479
+ if (!fs.existsSync(logFile)) return;
480
+ const log = yaml.load(fs.readFileSync(logFile, 'utf8'));
481
+ if (!log?.runs || log.runs.length < 3) return;
482
+
483
+ const recentRuns = log.runs.slice(-10);
484
+ const runSummary = recentRuns.map(r =>
485
+ `${r.ts}: ${r.signals} signals → ${r.updates} updates, ${r.gaps} gaps (policy v${r.policy_version})`
486
+ ).join('\n');
487
+
488
+ // Read queue effectiveness
489
+ const queue = loadEvolutionQueue(yaml);
490
+ const totalNotified = (queue.items || []).filter(i => i.status === 'notified').length;
491
+ const totalInstalled = (queue.items || []).filter(i => i.status === 'installed').length;
492
+ const totalDismissed = (queue.items || []).filter(i => i.status === 'dismissed').length;
493
+
494
+ const evalPrompt = `You are a meta-optimization system. Your job is to evaluate and improve the skill evolution policy for an AI assistant called MetaMe.
495
+
496
+ CURRENT POLICY:
497
+ ${yaml.dump(policy, { lineWidth: 120 })}
498
+
499
+ RECENT EVOLUTION RUNS (last ${recentRuns.length}):
500
+ ${runSummary}
501
+
502
+ QUEUE STATS: ${totalNotified} notified, ${totalInstalled} installed by user, ${totalDismissed} dismissed by user
503
+
504
+ EVALUATE:
505
+ 1. Are the thresholds appropriate? (too sensitive = noise, too strict = misses real issues)
506
+ 2. Is the prompt_template effective? (are runs producing useful updates, or mostly NO_EVOLUTION?)
507
+ 3. Are complaint_patterns and missing_skill_patterns catching real issues?
508
+ 4. Should self_eval_interval be adjusted?
509
+
510
+ If the policy is working well, respond with exactly: NO_CHANGE
511
+
512
+ If improvements are needed, respond with a YAML code block containing ONLY the fields that should change:
513
+ \`\`\`yaml
514
+ # Only include fields that need changing
515
+ hot_failure_threshold: 4
516
+ min_signals_for_distill: 5
517
+ version: ${(policy.version || 1) + 1}
518
+ \`\`\`
519
+
520
+ RULES:
521
+ - Increment version when making changes
522
+ - Never remove fields, only modify values
523
+ - Be conservative: only change what the data clearly supports
524
+ - prompt_template changes should be surgical, not full rewrites`;
525
+
526
+ const result = execSync('claude -p --model haiku --no-session-persistence', {
527
+ input: evalPrompt,
528
+ encoding: 'utf8',
529
+ timeout: 30000,
530
+ env: { ...process.env, ...distillEnv },
531
+ });
532
+
533
+ if (result.includes('NO_CHANGE')) {
534
+ console.log('🧬 Policy self-eval: no changes needed.');
535
+ return;
536
+ }
537
+
538
+ const yamlMatch = result.match(/```yaml\s*([\s\S]*?)```/);
539
+ if (!yamlMatch) return;
540
+
541
+ const patchData = yaml.load(yamlMatch[1]);
542
+ if (!patchData || typeof patchData !== 'object') return;
543
+
544
+ // Apply patch (merge, don't overwrite entirely)
545
+ const newPolicy = { ...policy, ...patchData };
546
+ savePolicy(yaml, newPolicy);
547
+ console.log(`🧬 Policy self-evolved: v${policy.version} → v${newPolicy.version}`);
548
+
549
+ } catch (err) {
550
+ try { console.log(`⚠️ Policy self-eval failed (non-fatal): ${err.message}`); } catch {}
551
+ }
552
+ }
553
+
554
+ // ─────────────────────────────────────────────
555
+ // Evolution Queue Management
556
+ // ─────────────────────────────────────────────
557
+
558
+ function loadEvolutionQueue(yaml) {
559
+ try {
560
+ if (!fs.existsSync(EVOLUTION_QUEUE_FILE)) return { items: [] };
561
+ const content = fs.readFileSync(EVOLUTION_QUEUE_FILE, 'utf8');
562
+ const data = yaml.load(content);
563
+ return data && Array.isArray(data.items) ? data : { items: [] };
564
+ } catch {
565
+ return { items: [] };
566
+ }
567
+ }
568
+
569
+ function saveEvolutionQueue(yaml, queue) {
570
+ try {
571
+ fs.writeFileSync(EVOLUTION_QUEUE_FILE, yaml.dump(queue, { lineWidth: -1 }), 'utf8');
572
+ } catch {}
573
+ }
574
+
575
+ function addToQueue(queue, entry) {
576
+ // Dedup by type + skill_name (update existing instead of adding duplicate)
577
+ const existing = queue.items.find(i =>
578
+ i.type === entry.type && i.skill_name === entry.skill_name && i.status === 'pending'
579
+ );
580
+
581
+ if (existing) {
582
+ existing.evidence_count = (existing.evidence_count || 0) + (entry.evidence_count || 1);
583
+ existing.last_seen = new Date().toISOString();
584
+ return;
585
+ }
586
+
587
+ queue.items.push({
588
+ ...entry,
589
+ detected: new Date().toISOString(),
590
+ last_seen: new Date().toISOString(),
591
+ status: 'pending',
592
+ });
593
+
594
+ // Keep queue manageable
595
+ if (queue.items.length > 50) {
596
+ queue.items = queue.items.slice(-50);
597
+ }
598
+ }
599
+
600
+ /**
601
+ * Check evolution queue and return items ready for user notification.
602
+ * Called from daemon.js heartbeat.
603
+ * Returns array of notification-ready items (marks them as 'notified').
604
+ */
605
+ function checkEvolutionQueue() {
606
+ let yaml;
607
+ try { yaml = require('js-yaml'); } catch { return []; }
608
+
609
+ const queue = loadEvolutionQueue(yaml);
610
+ const pendingItems = queue.items.filter(i => i.status === 'pending');
611
+ if (pendingItems.length === 0) return [];
612
+
613
+ const notifications = [];
614
+
615
+ for (const item of pendingItems) {
616
+ // Require minimum evidence before notifying
617
+ const policy = loadPolicy();
618
+ const minEvidence = item.type === 'skill_gap' ? policy.min_evidence_for_gap : policy.min_evidence_for_update;
619
+ if ((item.evidence_count || 1) < minEvidence) continue;
620
+
621
+ item.status = 'notified';
622
+ item.notified_at = new Date().toISOString();
623
+ notifications.push(item);
624
+ }
625
+
626
+ // Prune old resolved items (> 30 days)
627
+ const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000;
628
+ queue.items = queue.items.filter(i =>
629
+ i.status === 'pending' || i.status === 'notified' ||
630
+ (new Date(i.last_seen || i.detected).getTime() > cutoff)
631
+ );
632
+
633
+ if (notifications.length > 0) {
634
+ saveEvolutionQueue(yaml, queue);
635
+ }
636
+
637
+ return notifications;
638
+ }
639
+
640
+ /**
641
+ * Mark a queue item as resolved (installed or dismissed).
642
+ */
643
+ function resolveQueueItem(type, skillName, resolution) {
644
+ let yaml;
645
+ try { yaml = require('js-yaml'); } catch { return; }
646
+
647
+ const queue = loadEvolutionQueue(yaml);
648
+ const item = queue.items.find(i =>
649
+ i.type === type && i.skill_name === skillName &&
650
+ (i.status === 'pending' || i.status === 'notified')
651
+ );
652
+ if (item) {
653
+ item.status = resolution; // 'installed' | 'dismissed'
654
+ item.resolved_at = new Date().toISOString();
655
+ saveEvolutionQueue(yaml, queue);
656
+ }
657
+ }
658
+
659
+ // ─────────────────────────────────────────────
660
+ // Evolution.json Merge (JS port of merge_evolution.py)
661
+ // ─────────────────────────────────────────────
662
+
663
+ function mergeEvolution(skillDir, newData) {
664
+ const evoPath = path.join(skillDir, 'evolution.json');
665
+ let current = {};
666
+ try { current = JSON.parse(fs.readFileSync(evoPath, 'utf8')); } catch {}
667
+
668
+ current.last_updated = new Date().toISOString();
669
+
670
+ for (const key of ['preferences', 'fixes', 'contexts']) {
671
+ if (!newData[key]) continue;
672
+ const existing = current[key] || [];
673
+ const existingSet = new Set(existing);
674
+ current[key] = [...existing, ...newData[key].filter(item => !existingSet.has(item))];
675
+ }
676
+
677
+ if (newData.custom_prompts) {
678
+ current.custom_prompts = newData.custom_prompts;
679
+ }
680
+
681
+ fs.writeFileSync(evoPath, JSON.stringify(current, null, 2), 'utf8');
682
+ }
683
+
684
+ // ─────────────────────────────────────────────
685
+ // Smart Stitch (JS port of smart_stitch.py)
686
+ // ─────────────────────────────────────────────
687
+
688
+ function smartStitch(skillDir) {
689
+ const skillMdPath = path.join(skillDir, 'SKILL.md');
690
+ const evoPath = path.join(skillDir, 'evolution.json');
691
+
692
+ if (!fs.existsSync(skillMdPath) || !fs.existsSync(evoPath)) return;
693
+
694
+ let data;
695
+ try { data = JSON.parse(fs.readFileSync(evoPath, 'utf8')); } catch { return; }
696
+
697
+ // Build evolution section
698
+ const sections = [];
699
+ sections.push('\n\n## User-Learned Best Practices & Constraints');
700
+ sections.push('\n> **Auto-Generated Section**: Maintained by skill-evolution-manager. Do not edit manually.');
701
+
702
+ if (data.preferences?.length) {
703
+ sections.push('\n### User Preferences');
704
+ for (const item of data.preferences) sections.push(`- ${item}`);
705
+ }
706
+
707
+ if (data.fixes?.length) {
708
+ sections.push('\n### Known Fixes & Workarounds');
709
+ for (const item of data.fixes) sections.push(`- ${item}`);
710
+ }
711
+
712
+ if (data.custom_prompts) {
713
+ sections.push('\n### Custom Instruction Injection');
714
+ sections.push(`\n${data.custom_prompts}`);
715
+ }
716
+
717
+ const evolutionBlock = sections.join('\n');
718
+
719
+ let content = fs.readFileSync(skillMdPath, 'utf8');
720
+ const pattern = /(\n+## User-Learned Best Practices & Constraints[\s\S]*$)/;
721
+
722
+ if (pattern.test(content)) {
723
+ content = content.replace(pattern, evolutionBlock);
724
+ } else {
725
+ content = content + evolutionBlock;
726
+ }
727
+
728
+ fs.writeFileSync(skillMdPath, content, 'utf8');
729
+ }
730
+
731
+ // ─────────────────────────────────────────────
732
+ // Helpers
733
+ // ─────────────────────────────────────────────
734
+
735
+ function readRecentSignals() {
736
+ try {
737
+ if (!fs.existsSync(SKILL_SIGNAL_FILE)) return [];
738
+ const content = fs.readFileSync(SKILL_SIGNAL_FILE, 'utf8').trim();
739
+ if (!content) return [];
740
+ return content.split('\n')
741
+ .map(l => { try { return JSON.parse(l); } catch { return null; } })
742
+ .filter(Boolean);
743
+ } catch {
744
+ return [];
745
+ }
746
+ }
747
+
748
+ function clearSignals() {
749
+ try { fs.writeFileSync(SKILL_SIGNAL_FILE, '', 'utf8'); } catch {}
750
+ }
751
+
752
+ function listInstalledSkills() {
753
+ const skills = [];
754
+ for (const dir of SKILL_DIRS) {
755
+ if (!fs.existsSync(dir)) continue;
756
+ try {
757
+ for (const name of fs.readdirSync(dir)) {
758
+ const skillMd = path.join(dir, name, 'SKILL.md');
759
+ if (!fs.existsSync(skillMd)) continue;
760
+ // Read first line for description
761
+ const content = fs.readFileSync(skillMd, 'utf8');
762
+ const firstLine = content.split('\n').find(l => l.trim() && !l.startsWith('#') && !l.startsWith('---'));
763
+ skills.push({ name, description: (firstLine || '').substring(0, 100), dir: path.join(dir, name) });
764
+ }
765
+ } catch {}
766
+ }
767
+ return skills;
768
+ }
769
+
770
+ function findSkillDir(skillName) {
771
+ for (const dir of SKILL_DIRS) {
772
+ const candidate = path.join(dir, skillName);
773
+ if (fs.existsSync(path.join(candidate, 'SKILL.md'))) return candidate;
774
+ }
775
+ return null;
776
+ }
777
+
778
+ // ─────────────────────────────────────────────
779
+ // Exports
780
+ // ─────────────────────────────────────────────
781
+
782
+ module.exports = {
783
+ extractSkillSignal,
784
+ appendSkillSignal,
785
+ checkHotEvolution,
786
+ distillSkills,
787
+ checkEvolutionQueue,
788
+ resolveQueueItem,
789
+ mergeEvolution,
790
+ smartStitch,
791
+ listInstalledSkills,
792
+ };