metame-cli 1.4.10 → 1.4.13

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,467 @@
1
+ 'use strict';
2
+
3
+ function createAgentCommandHandler(deps) {
4
+ const {
5
+ fs,
6
+ path,
7
+ HOME,
8
+ loadConfig,
9
+ loadState,
10
+ saveState,
11
+ normalizeCwd,
12
+ expandPath,
13
+ sendBrowse,
14
+ sendDirPicker,
15
+ getSession,
16
+ listRecentSessions,
17
+ buildSessionCardElements,
18
+ sessionLabel,
19
+ loadSessionTags,
20
+ sessionRichLabel,
21
+ pendingBinds,
22
+ pendingAgentFlows,
23
+ doBindAgent,
24
+ mergeAgentRole,
25
+ agentTools,
26
+ agentFlowTtlMs,
27
+ agentBindTtlMs,
28
+ } = deps;
29
+
30
+ function resolveTtl(valueOrGetter, fallbackMs) {
31
+ const raw = typeof valueOrGetter === 'function' ? valueOrGetter() : valueOrGetter;
32
+ const num = Number(raw);
33
+ return Number.isFinite(num) && num > 0 ? num : fallbackMs;
34
+ }
35
+
36
+ function getFreshFlow(flowKey) {
37
+ const flow = pendingAgentFlows.get(flowKey);
38
+ if (!flow) return null;
39
+ const FLOW_TTL_MS = resolveTtl(agentFlowTtlMs, 10 * 60 * 1000);
40
+ const ts = Number(flow.__ts || 0);
41
+ if (!(ts > 0) && flow && typeof flow === 'object') {
42
+ // Backfill timestamp for legacy in-memory flow so it can expire later.
43
+ const stamped = { ...flow, __ts: Date.now() };
44
+ pendingAgentFlows.set(flowKey, stamped);
45
+ return stamped;
46
+ }
47
+ if (ts > 0 && (Date.now() - ts) > FLOW_TTL_MS) {
48
+ pendingAgentFlows.delete(flowKey);
49
+ return null;
50
+ }
51
+ return flow;
52
+ }
53
+
54
+ function setFlow(flowKey, flow) {
55
+ pendingAgentFlows.set(flowKey, { ...flow, __ts: Date.now() });
56
+ }
57
+
58
+ function setPendingBind(chatKey, agentName) {
59
+ pendingBinds.set(chatKey, { name: agentName, __ts: Date.now() });
60
+ }
61
+
62
+ function getFreshPendingBind(chatKey) {
63
+ const raw = pendingBinds.get(chatKey);
64
+ if (!raw) return null;
65
+
66
+ if (typeof raw === 'string') {
67
+ // Backward compatibility: old in-memory shape was a plain agentName string.
68
+ pendingBinds.set(chatKey, { name: raw, __ts: Date.now() });
69
+ return raw;
70
+ }
71
+
72
+ const BIND_TTL_MS = resolveTtl(agentBindTtlMs, 10 * 60 * 1000);
73
+ const ts = Number(raw.__ts || 0);
74
+ if (ts > 0 && (Date.now() - ts) > BIND_TTL_MS) {
75
+ pendingBinds.delete(chatKey);
76
+ return null;
77
+ }
78
+ return raw.name || null;
79
+ }
80
+
81
+ function getBoundProject(chatId, cfg) {
82
+ const agentMap = {
83
+ ...(cfg.telegram ? cfg.telegram.chat_agent_map : {}),
84
+ ...(cfg.feishu ? cfg.feishu.chat_agent_map : {}),
85
+ };
86
+ const boundKey = agentMap[String(chatId)];
87
+ const boundProj = boundKey && cfg.projects && cfg.projects[boundKey];
88
+ return { boundKey: boundKey || null, boundProj: boundProj || null };
89
+ }
90
+
91
+ async function bindViaUnifiedApi(bot, chatId, agentName, agentCwd) {
92
+ if (agentTools && typeof agentTools.bindAgentToChat === 'function') {
93
+ const res = await agentTools.bindAgentToChat(chatId, agentName, agentCwd);
94
+ if (!res.ok) {
95
+ await bot.sendMessage(chatId, `❌ 绑定失败: ${res.error}`);
96
+ return { ok: false };
97
+ }
98
+ const p = res.data.project || {};
99
+ const icon = p.icon || '🤖';
100
+ const action = res.data.isNewProject ? '绑定成功' : '重新绑定';
101
+ const displayCwd = String(res.data.cwd || '').replace(HOME, '~');
102
+ await bot.sendMessage(chatId, `${icon} ${p.name || agentName} ${action}\n目录: ${displayCwd}`);
103
+ return { ok: true, data: res.data };
104
+ }
105
+
106
+ // Backward-compatible fallback
107
+ await doBindAgent(bot, chatId, agentName, agentCwd);
108
+ return { ok: true, data: { cwd: agentCwd } };
109
+ }
110
+
111
+ async function editRoleViaUnifiedApi(workspaceDir, deltaText) {
112
+ if (agentTools && typeof agentTools.editAgentRoleDefinition === 'function') {
113
+ return agentTools.editAgentRoleDefinition(workspaceDir, deltaText);
114
+ }
115
+ const legacy = await mergeAgentRole(workspaceDir, deltaText);
116
+ if (legacy.error) return { ok: false, error: legacy.error };
117
+ return { ok: true, data: legacy };
118
+ }
119
+
120
+ async function createAgentViaUnifiedApi(chatId, name, dir, roleDesc) {
121
+ if (agentTools && typeof agentTools.createNewWorkspaceAgent === 'function') {
122
+ return agentTools.createNewWorkspaceAgent(name, dir, roleDesc, chatId);
123
+ }
124
+ await doBindAgent({ sendMessage: async () => {} }, chatId, name, dir);
125
+ const merged = await mergeAgentRole(dir, roleDesc);
126
+ if (merged.error) return { ok: false, error: merged.error };
127
+ return { ok: true, data: { cwd: dir, project: { name }, role: merged } };
128
+ }
129
+
130
+ async function listAgentsViaUnifiedApi(chatId) {
131
+ if (agentTools && typeof agentTools.listAllAgents === 'function') {
132
+ return agentTools.listAllAgents(chatId);
133
+ }
134
+
135
+ const cfg = loadConfig();
136
+ const projects = cfg.projects || {};
137
+ const entries = Object.entries(projects)
138
+ .filter(([, p]) => p && p.cwd)
139
+ .map(([key, p]) => ({
140
+ key,
141
+ name: p.name || key,
142
+ cwd: p.cwd,
143
+ icon: p.icon || '🤖',
144
+ }));
145
+ const { boundKey } = getBoundProject(chatId, cfg);
146
+ return { ok: true, data: { agents: entries, boundKey } };
147
+ }
148
+
149
+ async function unbindViaUnifiedApi(chatId) {
150
+ if (agentTools && typeof agentTools.unbindCurrentAgent === 'function') {
151
+ return agentTools.unbindCurrentAgent(chatId);
152
+ }
153
+
154
+ const cfg = loadConfig();
155
+ const isTg = typeof chatId === 'number';
156
+ const ak = isTg ? 'telegram' : 'feishu';
157
+ if (!cfg[ak]) cfg[ak] = {};
158
+ if (!cfg[ak].chat_agent_map) cfg[ak].chat_agent_map = {};
159
+ const old = cfg[ak].chat_agent_map[String(chatId)] || null;
160
+ if (old) {
161
+ delete cfg[ak].chat_agent_map[String(chatId)];
162
+ }
163
+ return { ok: true, data: { unbound: !!old, previousProjectKey: old } };
164
+ }
165
+
166
+ async function handleAgentCommand(ctx) {
167
+ const { bot, chatId } = ctx;
168
+ const config = ctx.config || {};
169
+ const text = ctx.text || '';
170
+
171
+ if (text === '/resume' || text.startsWith('/resume ')) {
172
+ const arg = text.slice(7).trim();
173
+
174
+ // Get current workdir to scope session list
175
+ const curSession = getSession(chatId);
176
+ const curCwd = curSession ? curSession.cwd : null;
177
+ const recentSessions = listRecentSessions(5, curCwd);
178
+
179
+ if (!arg) {
180
+ if (recentSessions.length === 0) {
181
+ await bot.sendMessage(chatId, `No sessions found${curCwd ? ' in ' + path.basename(curCwd) : ''}. Try /new first.`);
182
+ return true;
183
+ }
184
+ const headerTitle = curCwd ? `📋 Sessions in ${path.basename(curCwd)}` : '📋 Recent Sessions';
185
+ if (bot.sendRawCard) {
186
+ await bot.sendRawCard(chatId, headerTitle, buildSessionCardElements(recentSessions));
187
+ } else if (bot.sendButtons) {
188
+ const buttons = recentSessions.map(s => {
189
+ return [{ text: sessionLabel(s), callback_data: `/resume ${s.sessionId}` }];
190
+ });
191
+ await bot.sendButtons(chatId, headerTitle, buttons);
192
+ } else {
193
+ const _tags2 = loadSessionTags();
194
+ let msg = `${headerTitle}\n\n`;
195
+ recentSessions.forEach((s, i) => {
196
+ msg += sessionRichLabel(s, i + 1, _tags2) + '\n';
197
+ });
198
+ await bot.sendMessage(chatId, msg);
199
+ }
200
+ return true;
201
+ }
202
+
203
+ // Argument given -> match by name, then by session ID prefix
204
+ const allSessions = listRecentSessions(50);
205
+ const argLower = arg.toLowerCase();
206
+ let fullMatch = allSessions.find(s => s.customTitle && s.customTitle.toLowerCase() === argLower);
207
+ if (!fullMatch) {
208
+ fullMatch = allSessions.find(s => s.customTitle && s.customTitle.toLowerCase().includes(argLower));
209
+ }
210
+ if (!fullMatch) {
211
+ fullMatch = recentSessions.find(s => s.sessionId.startsWith(arg))
212
+ || allSessions.find(s => s.sessionId.startsWith(arg));
213
+ }
214
+ if (!fullMatch) {
215
+ // keep historical behavior:
216
+ // "/resume 看到的session信息太少了" should be treated as normal text
217
+ return null;
218
+ }
219
+ const sessionId = fullMatch.sessionId;
220
+ const cwd = fullMatch.projectPath || (getSession(chatId) && getSession(chatId).cwd) || HOME;
221
+
222
+ const state2 = loadState();
223
+ state2.sessions[chatId] = {
224
+ id: sessionId,
225
+ cwd,
226
+ started: true,
227
+ };
228
+ saveState(state2);
229
+ const name = fullMatch.customTitle;
230
+ const label = name || (fullMatch.summary || fullMatch.firstPrompt || '').slice(0, 40) || sessionId.slice(0, 8);
231
+ await bot.sendMessage(chatId, `Resumed: ${label}\nWorkdir: ${cwd}`);
232
+ return true;
233
+ }
234
+
235
+ // /agent new wizard state machine (kept for command compatibility)
236
+ {
237
+ const flow = getFreshFlow(String(chatId));
238
+ if (flow && flow.step === 'name' && text && !text.startsWith('/')) {
239
+ flow.name = text.trim();
240
+ flow.step = 'desc';
241
+ setFlow(String(chatId), flow);
242
+ await bot.sendMessage(chatId, `好的,Agent 名称是「${flow.name}」\n\n请描述这个 Agent 的角色和职责(用自然语言):`);
243
+ return true;
244
+ }
245
+ if (flow && flow.step === 'desc' && text && !text.startsWith('/')) {
246
+ pendingAgentFlows.delete(String(chatId));
247
+ const { dir, name } = flow;
248
+ const description = text.trim();
249
+ await bot.sendMessage(chatId, `⏳ 正在配置 Agent「${name}」,稍等...`);
250
+ const created = await createAgentViaUnifiedApi(chatId, name, dir, description);
251
+ if (!created.ok) {
252
+ await bot.sendMessage(chatId, `❌ 创建 Agent 失败: ${created.error}`);
253
+ return true;
254
+ }
255
+ const roleInfo = created.data.role || {};
256
+ if (roleInfo.skipped) {
257
+ await bot.sendMessage(chatId, '✅ Agent 创建成功');
258
+ } else if (roleInfo.created) {
259
+ await bot.sendMessage(chatId, '📝 已创建 CLAUDE.md 并写入角色定义');
260
+ } else {
261
+ await bot.sendMessage(chatId, '📝 已将角色定义合并进现有 CLAUDE.md');
262
+ }
263
+ return true;
264
+ }
265
+ }
266
+
267
+ // /agent edit wait-input flow (kept for command compatibility)
268
+ {
269
+ const editFlow = getFreshFlow(String(chatId) + ':edit');
270
+ if (editFlow && text && !text.startsWith('/')) {
271
+ pendingAgentFlows.delete(String(chatId) + ':edit');
272
+ const { cwd } = editFlow;
273
+ await bot.sendMessage(chatId, '⏳ 正在更新 CLAUDE.md...');
274
+ const mergeResult = await editRoleViaUnifiedApi(cwd, text.trim());
275
+ if (!mergeResult.ok) {
276
+ await bot.sendMessage(chatId, `❌ 更新失败: ${mergeResult.error}`);
277
+ } else {
278
+ await bot.sendMessage(chatId, '✅ CLAUDE.md 已更新');
279
+ }
280
+ return true;
281
+ }
282
+ }
283
+
284
+ if (text === '/agent' || text.startsWith('/agent ')) {
285
+ const agentArg = text === '/agent' ? '' : text.slice(7).trim();
286
+ const agentParts = agentArg.split(/\s+/).filter(Boolean);
287
+ const agentSub = agentParts[0] || ''; // bind / list / new / edit / reset / unbind / ''
288
+
289
+ // /agent bind <名称> [目录]
290
+ if (agentSub === 'bind') {
291
+ const bindName = agentParts[1];
292
+ const bindCwd = agentParts.slice(2).join(' ');
293
+ if (!bindName) {
294
+ await bot.sendMessage(chatId, '用法: /agent bind <名称> [工作目录]\n例: /agent bind 小美 ~/\n或: /agent bind 教授 (弹出目录选择)');
295
+ return true;
296
+ }
297
+ if (!bindCwd) {
298
+ setPendingBind(String(chatId), bindName);
299
+ await sendDirPicker(bot, chatId, 'bind', `为「${bindName}」选择工作目录:`);
300
+ return true;
301
+ }
302
+ await bindViaUnifiedApi(bot, chatId, bindName, expandPath(bindCwd));
303
+ return true;
304
+ }
305
+
306
+ // /agent list
307
+ if (agentSub === 'list') {
308
+ const res = await listAgentsViaUnifiedApi(chatId);
309
+ if (!res.ok) {
310
+ await bot.sendMessage(chatId, `❌ 查询 Agent 失败: ${res.error}`);
311
+ return true;
312
+ }
313
+ const agents = res.data.agents || [];
314
+ if (agents.length === 0) {
315
+ await bot.sendMessage(chatId, '暂无已配置的 Agent。\n使用 /agent new 创建,或 /agent bind <名称> 绑定目录。');
316
+ return true;
317
+ }
318
+ const lines = ['📋 已配置的 Agent:', ''];
319
+ for (const a of agents) {
320
+ const icon = a.icon || '🤖';
321
+ const name = a.name || a.key;
322
+ const displayCwd = String(a.cwd || '').replace(HOME, '~');
323
+ const bound = a.key === res.data.boundKey ? ' ◀ 当前' : '';
324
+ lines.push(`${icon} ${name}${bound}`);
325
+ lines.push(` 目录: ${displayCwd}`);
326
+ lines.push(` Key: ${a.key}`);
327
+ lines.push('');
328
+ }
329
+ await bot.sendMessage(chatId, lines.join('\n').trimEnd());
330
+ return true;
331
+ }
332
+
333
+ // /agent new (wizard)
334
+ if (agentSub === 'new') {
335
+ setFlow(String(chatId), { step: 'dir' });
336
+ await sendBrowse(bot, chatId, 'agent-new', HOME, '步骤1/3:选择这个 Agent 的工作目录');
337
+ return true;
338
+ }
339
+
340
+ // /agent edit [描述]
341
+ if (agentSub === 'edit') {
342
+ const cfg = loadConfig();
343
+ const { boundProj } = getBoundProject(chatId, cfg);
344
+ if (!boundProj || !boundProj.cwd) {
345
+ await bot.sendMessage(chatId, '❌ 当前群未绑定 Agent,请先使用 /agent bind 或 /agent new');
346
+ return true;
347
+ }
348
+ const cwd = normalizeCwd(boundProj.cwd);
349
+ const inlineDelta = agentParts.slice(1).join(' ').trim();
350
+ if (inlineDelta) {
351
+ await bot.sendMessage(chatId, '⏳ 正在更新 CLAUDE.md...');
352
+ const mergeResult = await editRoleViaUnifiedApi(cwd, inlineDelta);
353
+ if (!mergeResult.ok) {
354
+ await bot.sendMessage(chatId, `❌ 更新失败: ${mergeResult.error}`);
355
+ } else {
356
+ await bot.sendMessage(chatId, '✅ CLAUDE.md 已更新');
357
+ }
358
+ return true;
359
+ }
360
+
361
+ const claudeMdPath = path.join(cwd, 'CLAUDE.md');
362
+ let currentContent = '(CLAUDE.md 不存在)';
363
+ if (fs.existsSync(claudeMdPath)) {
364
+ currentContent = fs.readFileSync(claudeMdPath, 'utf8');
365
+ if (currentContent.length > 500) currentContent = currentContent.slice(0, 500) + '\n...(已截断)';
366
+ }
367
+ setFlow(String(chatId) + ':edit', { cwd });
368
+ await bot.sendMessage(chatId, `📄 当前 CLAUDE.md 内容:\n\`\`\`\n${currentContent}\n\`\`\`\n\n请描述你想做的修改(用自然语言,例如:「把角色改成后端工程师,专注 Python」):`);
369
+ return true;
370
+ }
371
+
372
+ // /agent unbind
373
+ if (agentSub === 'unbind') {
374
+ const res = await unbindViaUnifiedApi(chatId);
375
+ if (!res.ok) {
376
+ await bot.sendMessage(chatId, `❌ 解绑失败: ${res.error}`);
377
+ return true;
378
+ }
379
+ if (res.data.unbound) {
380
+ await bot.sendMessage(chatId, `✅ 已解绑当前群(原 Agent: ${res.data.previousProjectKey})`);
381
+ } else {
382
+ await bot.sendMessage(chatId, '当前群没有绑定 Agent,无需解绑。');
383
+ }
384
+ return true;
385
+ }
386
+
387
+ // /agent reset — delete "## Agent 角色" section
388
+ if (agentSub === 'reset') {
389
+ const cfg = loadConfig();
390
+ const { boundProj } = getBoundProject(chatId, cfg);
391
+ if (!boundProj || !boundProj.cwd) {
392
+ await bot.sendMessage(chatId, '❌ 当前群未绑定 Agent,请先使用 /agent bind 或 /agent new');
393
+ return true;
394
+ }
395
+ const cwd = normalizeCwd(boundProj.cwd);
396
+ const claudeMdPath = path.join(cwd, 'CLAUDE.md');
397
+ if (!fs.existsSync(claudeMdPath)) {
398
+ await bot.sendMessage(chatId, '⚠️ CLAUDE.md 不存在,无需重置');
399
+ return true;
400
+ }
401
+ const before = fs.readFileSync(claudeMdPath, 'utf8');
402
+ const after = before.replace(/(?:^|\n)## Agent 角色\n[\s\S]*?(?=\n## |$)/, '').trimStart();
403
+ if (after === before.trimStart()) {
404
+ await bot.sendMessage(chatId, '⚠️ 未找到「## Agent 角色」section,CLAUDE.md 未修改');
405
+ return true;
406
+ }
407
+ fs.writeFileSync(claudeMdPath, after, 'utf8');
408
+ await bot.sendMessage(chatId, '✅ 已删除角色 section,请重新发送角色描述(/agent edit 或自然语言修改)');
409
+ return true;
410
+ }
411
+
412
+ // /agent (no sub command): show agent switch picker
413
+ {
414
+ const projects = config.projects || {};
415
+ const entries = Object.entries(projects).filter(([, p]) => p.cwd);
416
+ if (entries.length === 0) {
417
+ await bot.sendMessage(chatId, '暂无已配置的 Agent。\n使用 /agent new 新建,或 /agent bind <名称> 绑定目录。');
418
+ return true;
419
+ }
420
+ const currentSession = getSession(chatId);
421
+ const currentCwd = currentSession && currentSession.cwd ? path.resolve(expandPath(currentSession.cwd)) : null;
422
+ const buttons = entries.map(([key, p]) => {
423
+ const projCwd = normalizeCwd(p.cwd);
424
+ const active = currentCwd && path.resolve(projCwd) === currentCwd ? ' ◀' : '';
425
+ return [{ text: `${p.icon || '🤖'} ${p.name || key}${active}`, callback_data: `/cd ${projCwd}` }];
426
+ });
427
+ await bot.sendButtons(chatId, '切换对话对象', buttons);
428
+ return true;
429
+ }
430
+ }
431
+
432
+ // /agent-bind-dir <path>: internal callback for bind picker
433
+ if (text.startsWith('/agent-bind-dir ')) {
434
+ const dirPath = expandPath(text.slice(16).trim());
435
+ const agentName = getFreshPendingBind(String(chatId));
436
+ if (!agentName) {
437
+ await bot.sendMessage(chatId, '❌ 没有待完成的 /agent bind,请重新发送');
438
+ return true;
439
+ }
440
+ pendingBinds.delete(String(chatId));
441
+ await bindViaUnifiedApi(bot, chatId, agentName, dirPath);
442
+ return true;
443
+ }
444
+
445
+ // /agent-dir <path>: internal callback for /agent new wizard
446
+ if (text.startsWith('/agent-dir ')) {
447
+ const dirPath = expandPath(text.slice(11).trim());
448
+ const flow = getFreshFlow(String(chatId));
449
+ if (!flow || flow.step !== 'dir') {
450
+ await bot.sendMessage(chatId, '❌ 没有待完成的 /agent new,请重新发送 /agent new');
451
+ return true;
452
+ }
453
+ flow.dir = dirPath;
454
+ flow.step = 'name';
455
+ setFlow(String(chatId), flow);
456
+ const displayPath = dirPath.replace(HOME, '~');
457
+ await bot.sendMessage(chatId, `✓ 已选择目录:${displayPath}\n\n步骤2/3:给这个 Agent 起个名字?`);
458
+ return true;
459
+ }
460
+
461
+ return false;
462
+ }
463
+
464
+ return { handleAgentCommand };
465
+ }
466
+
467
+ module.exports = { createAgentCommandHandler };