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 +9 -9
- package/package.json +1 -1
- package/scripts/daemon-admin-commands.js +91 -0
- package/scripts/daemon-agent-commands.js +27 -3
- package/scripts/daemon-claude-engine.js +118 -15
- package/scripts/daemon-command-router.js +28 -6
- package/scripts/daemon-task-scheduler.js +3 -0
- package/scripts/daemon.js +15 -8
- package/scripts/skill-evolution.js +130 -19
- package/scripts/skill-evolution.test.js +107 -0
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.
|
|
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 #
|
|
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
|
-
|
|
323
|
-
|
|
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
|
|
328
|
-
- **Distillation** (`scripts/distill.js`):
|
|
329
|
-
- **Memory Extract** (`scripts/memory-extract.js`):
|
|
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
|
|
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
|
@@ -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
|
-
|
|
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 (
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
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(/(
|
|
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,
|
|
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
|
-
* -
|
|
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
|
+
});
|