metame-cli 1.4.12 → 1.4.15

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,909 @@
1
+ 'use strict';
2
+
3
+ function createClaudeEngine(deps) {
4
+ const {
5
+ fs,
6
+ path,
7
+ spawn,
8
+ CLAUDE_BIN,
9
+ HOME,
10
+ CONFIG_FILE,
11
+ getActiveProviderEnv,
12
+ activeProcesses,
13
+ saveActivePids,
14
+ messageQueue,
15
+ log,
16
+ yaml,
17
+ providerMod,
18
+ writeConfigSafe,
19
+ loadConfig,
20
+ loadState,
21
+ saveState,
22
+ routeAgent,
23
+ routeSkill,
24
+ attachOrCreateSession,
25
+ normalizeCwd,
26
+ isContentFile,
27
+ sendFileButtons,
28
+ findSessionFile,
29
+ listRecentSessions,
30
+ getSession,
31
+ createSession,
32
+ getSessionName,
33
+ writeSessionName,
34
+ markSessionStarted,
35
+ gitCheckpoint,
36
+ recordTokens,
37
+ skillEvolution,
38
+ touchInteraction,
39
+ statusThrottleMs = 3000,
40
+ fallbackThrottleMs = 8000,
41
+ } = deps;
42
+ const SESSION_CWD_VALIDATION_TTL_MS = 30 * 1000;
43
+ const _sessionCwdValidationCache = new Map(); // key: `${sessionId}@@${cwd}` -> { inCwd, ts }
44
+
45
+ function decodeProjectDirName(dirName) {
46
+ const raw = String(dirName || '');
47
+ if (!raw) return '';
48
+ if (raw.startsWith('-')) return '/' + raw.slice(1).replace(/-/g, '/');
49
+ return raw.replace(/-/g, '/');
50
+ }
51
+
52
+ function cacheSessionCwdValidation(cacheKey, inCwd) {
53
+ _sessionCwdValidationCache.set(cacheKey, { inCwd: !!inCwd, ts: Date.now() });
54
+ if (_sessionCwdValidationCache.size > 512) {
55
+ const firstKey = _sessionCwdValidationCache.keys().next().value;
56
+ if (firstKey) _sessionCwdValidationCache.delete(firstKey);
57
+ }
58
+ return !!inCwd;
59
+ }
60
+
61
+ function isSessionInCwd(sessionId, cwd) {
62
+ const safeSessionId = String(sessionId || '').trim();
63
+ if (!safeSessionId || !cwd) return false;
64
+
65
+ const normCwd = normalizeCwd(cwd);
66
+ const cacheKey = `${safeSessionId}@@${normCwd}`;
67
+ const cached = _sessionCwdValidationCache.get(cacheKey);
68
+ if (cached && (Date.now() - cached.ts) < SESSION_CWD_VALIDATION_TTL_MS) {
69
+ return !!cached.inCwd;
70
+ }
71
+
72
+ try {
73
+ // Fast path: locate the exact session file, then validate its indexed projectPath.
74
+ if (typeof findSessionFile === 'function') {
75
+ const sessionFile = findSessionFile(safeSessionId);
76
+ if (!sessionFile) return cacheSessionCwdValidation(cacheKey, false);
77
+
78
+ const projectDir = path.dirname(sessionFile);
79
+ const indexFile = path.join(projectDir, 'sessions-index.json');
80
+ if (fs.existsSync(indexFile)) {
81
+ const data = JSON.parse(fs.readFileSync(indexFile, 'utf8'));
82
+ const entries = Array.isArray(data && data.entries) ? data.entries : [];
83
+ const entry = entries.find(e => e && e.sessionId === safeSessionId);
84
+ if (entry && entry.projectPath) {
85
+ return cacheSessionCwdValidation(cacheKey, normalizeCwd(entry.projectPath) === normCwd);
86
+ }
87
+ }
88
+
89
+ // Fallback: infer from encoded Claude project folder name.
90
+ const inferredPath = decodeProjectDirName(path.basename(projectDir));
91
+ if (inferredPath) {
92
+ return cacheSessionCwdValidation(cacheKey, normalizeCwd(inferredPath) === normCwd);
93
+ }
94
+ return cacheSessionCwdValidation(cacheKey, false);
95
+ }
96
+
97
+ // Ultimate fallback (legacy path): scoped scan in target cwd.
98
+ const recentInCwd = listRecentSessions(1000, normCwd);
99
+ const existsInCwd = recentInCwd.some(s => s.sessionId === safeSessionId);
100
+ return cacheSessionCwdValidation(cacheKey, existsInCwd);
101
+ } catch {
102
+ // Conservative fallback: if validation infra fails, avoid false positives by preserving current session.
103
+ return cacheSessionCwdValidation(cacheKey, true);
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Parse [[FILE:...]] markers from Claude output.
109
+ * Returns { markedFiles, cleanOutput }
110
+ */
111
+ function parseFileMarkers(output) {
112
+ const markers = output.match(/\[\[FILE:([^\]]+)\]\]/g) || [];
113
+ const markedFiles = markers.map(m => m.match(/\[\[FILE:([^\]]+)\]\]/)[1].trim());
114
+ const cleanOutput = output.replace(/\s*\[\[FILE:[^\]]+\]\]/g, '').trim();
115
+ return { markedFiles, cleanOutput };
116
+ }
117
+
118
+ /**
119
+ * Merge explicit [[FILE:...]] paths with auto-detected content files.
120
+ * Returns a Set of unique file paths.
121
+ */
122
+ function mergeFileCollections(markedFiles, sourceFiles) {
123
+ const result = new Set(markedFiles);
124
+ if (sourceFiles && sourceFiles.length > 0) {
125
+ for (const f of sourceFiles) { if (isContentFile(f)) result.add(f); }
126
+ }
127
+ return result;
128
+ }
129
+
130
+ /**
131
+ * Build a richer fact-retrieval query from the user prompt.
132
+ * Adds lightweight code anchors (filenames/commands/identifiers) for better recall.
133
+ */
134
+ function buildFactSearchQuery(prompt, projectKey) {
135
+ const text = String(prompt || '').replace(/\s+/g, ' ').trim();
136
+ if (!text) return projectKey || '';
137
+
138
+ const anchors = [];
139
+ const seen = new Set();
140
+ const add = (v) => {
141
+ const t = String(v || '').trim();
142
+ if (!t || seen.has(t)) return;
143
+ seen.add(t);
144
+ anchors.push(t);
145
+ };
146
+
147
+ // File/path-like anchors: daemon.js, scripts/memory-extract.js, foo.ts
148
+ const fileLike = text.match(/\b(?:[\w.-]+\/)*[\w.-]+\.[a-zA-Z0-9]{1,8}\b/g) || [];
149
+ for (const f of fileLike.slice(0, 6)) {
150
+ add(path.basename(f));
151
+ }
152
+
153
+ // Command-like anchors: git commit, npm run build, node index.js ...
154
+ const cmdLike = text.match(/\b(?:git|npm|pnpm|yarn|npx|node|python|pytest|make)\b[^,.;\n]{0,48}/gi) || [];
155
+ for (const c of cmdLike.slice(0, 4)) {
156
+ add(c.toLowerCase());
157
+ }
158
+
159
+ // Symbol-like anchors: snake_case / camelCase identifiers often present in bug reports
160
+ const idLike = text.match(/\b[A-Za-z_][A-Za-z0-9_]{2,}\b/g) || [];
161
+ for (const id of idLike) {
162
+ if (anchors.length >= 12) break;
163
+ if (id.includes('_') || /[a-z][A-Z]/.test(id)) add(id);
164
+ }
165
+
166
+ const parts = [text.slice(0, 260)];
167
+ if (projectKey) parts.push(projectKey);
168
+ if (anchors.length > 0) parts.push(anchors.slice(0, 10).join(' '));
169
+ return parts.join(' ').slice(0, 520);
170
+ }
171
+
172
+ /**
173
+ * Auto-generate a session name using Haiku (async, non-blocking).
174
+ * Writes to Claude's session file (unified with /rename).
175
+ */
176
+ async function autoNameSession(chatId, sessionId, firstPrompt, cwd) {
177
+ try {
178
+ const namePrompt = `Generate a very short session name (2-5 Chinese characters, no punctuation, no quotes) that captures the essence of this user request:
179
+
180
+ "${firstPrompt.slice(0, 200)}"
181
+
182
+ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug修复, 代码审查`;
183
+
184
+ const { output } = await spawnClaudeAsync(
185
+ ['-p', '--model', 'haiku'],
186
+ namePrompt,
187
+ HOME,
188
+ 15000 // 15s timeout
189
+ );
190
+
191
+ if (output) {
192
+ // Clean up: remove quotes, punctuation, trim
193
+ let name = output.replace(/["""''`]/g, '').replace(/[.,!?:;。,!?:;]/g, '').trim();
194
+ // Limit to reasonable length
195
+ if (name.length > 12) name = name.slice(0, 12);
196
+ if (name.length >= 2) {
197
+ // Write to Claude's session file (unified with /rename on desktop)
198
+ writeSessionName(sessionId, cwd, name);
199
+ }
200
+ }
201
+ } catch (e) {
202
+ log('DEBUG', `Auto-name failed for ${sessionId.slice(0, 8)}: ${e.message}`);
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Spawn claude as async child process (non-blocking).
208
+ * Returns { output, error } after process exits.
209
+ */
210
+ function spawnClaudeAsync(args, input, cwd, timeoutMs = 300000) {
211
+ return new Promise((resolve) => {
212
+ const child = spawn(CLAUDE_BIN, args, {
213
+ cwd,
214
+ stdio: ['pipe', 'pipe', 'pipe'],
215
+ env: { ...process.env, ...getActiveProviderEnv(), CLAUDECODE: undefined },
216
+ });
217
+
218
+ let stdout = '';
219
+ let stderr = '';
220
+ let killed = false;
221
+
222
+ const timer = setTimeout(() => {
223
+ killed = true;
224
+ try { process.kill(-child.pid, 'SIGTERM'); } catch { child.kill('SIGTERM'); }
225
+ setTimeout(() => {
226
+ try { process.kill(-child.pid, 'SIGKILL'); } catch { try { child.kill('SIGKILL'); } catch { } }
227
+ }, 5000);
228
+ }, timeoutMs);
229
+
230
+ child.stdout.on('data', (data) => { stdout += data.toString(); });
231
+ child.stderr.on('data', (data) => { stderr += data.toString(); });
232
+
233
+ child.on('close', (code) => {
234
+ clearTimeout(timer);
235
+ if (killed) {
236
+ resolve({ output: null, error: 'Timeout: Claude took too long' });
237
+ } else if (code !== 0) {
238
+ resolve({ output: null, error: stderr || `Exit code ${code}` });
239
+ } else {
240
+ resolve({ output: stdout.trim(), error: null });
241
+ }
242
+ });
243
+
244
+ child.on('error', (err) => {
245
+ clearTimeout(timer);
246
+ resolve({ output: null, error: err.message });
247
+ });
248
+
249
+ // Write input and close stdin
250
+ child.stdin.write(input);
251
+ child.stdin.end();
252
+ });
253
+ }
254
+
255
+ /**
256
+ * Tool name to emoji mapping for status display
257
+ */
258
+ const TOOL_EMOJI = {
259
+ Read: '📖',
260
+ Edit: '✏️',
261
+ Write: '📝',
262
+ Bash: '💻',
263
+ Glob: '🔍',
264
+ Grep: '🔎',
265
+ WebFetch: '🌐',
266
+ WebSearch: '🔍',
267
+ Task: '🤖',
268
+ Skill: '🔧',
269
+ TodoWrite: '📋',
270
+ NotebookEdit: '📓',
271
+ default: '🔧',
272
+ };
273
+
274
+ /**
275
+ * Spawn claude with streaming output (stream-json mode).
276
+ * Calls onStatus callback when tool usage is detected.
277
+ * Returns { output, error } after process exits.
278
+ */
279
+ function spawnClaudeStreaming(args, input, cwd, onStatus, timeoutMs = 600000, chatId = null) {
280
+ return new Promise((resolve) => {
281
+ // Add stream-json output format (requires --verbose)
282
+ const streamArgs = [...args, '--output-format', 'stream-json', '--verbose'];
283
+
284
+ const child = spawn(CLAUDE_BIN, streamArgs, {
285
+ cwd,
286
+ stdio: ['pipe', 'pipe', 'pipe'],
287
+ detached: true, // Create new process group so killing -pid kills all sub-agents too
288
+ env: { ...process.env, ...getActiveProviderEnv(), CLAUDECODE: undefined },
289
+ });
290
+
291
+ // Track active process for /stop
292
+ if (chatId) {
293
+ activeProcesses.set(chatId, { child, aborted: false });
294
+ saveActivePids(); // Fix3: persist PID to disk
295
+ }
296
+
297
+ let buffer = '';
298
+ let stderr = '';
299
+ let killed = false;
300
+ let finalResult = '';
301
+ let lastStatusTime = 0;
302
+ const STATUS_THROTTLE = statusThrottleMs;
303
+ const writtenFiles = []; // Track files created/modified by Write tool
304
+ const toolUsageLog = []; // Track all tool invocations for skill evolution
305
+
306
+ const timer = setTimeout(() => {
307
+ killed = true;
308
+ log('WARN', `Claude timeout (${timeoutMs / 60000}min) for chatId ${chatId} — killing process group`);
309
+ try { process.kill(-child.pid, 'SIGTERM'); } catch { child.kill('SIGTERM'); }
310
+ setTimeout(() => {
311
+ try { process.kill(-child.pid, 'SIGKILL'); } catch { try { child.kill('SIGKILL'); } catch { } }
312
+ }, 5000);
313
+ }, timeoutMs);
314
+
315
+ child.stdout.on('data', (data) => {
316
+ buffer += data.toString();
317
+
318
+ // Process complete JSON lines
319
+ const lines = buffer.split('\n');
320
+ buffer = lines.pop() || ''; // Keep incomplete line in buffer
321
+
322
+ for (const line of lines) {
323
+ if (!line.trim()) continue;
324
+ try {
325
+ const event = JSON.parse(line);
326
+
327
+ // Extract final result text
328
+ if (event.type === 'assistant' && event.message?.content) {
329
+ const textBlocks = event.message.content.filter(b => b.type === 'text');
330
+ if (textBlocks.length > 0) {
331
+ finalResult = textBlocks.map(b => b.text).join('\n');
332
+ }
333
+ }
334
+
335
+ // Detect tool usage and send status
336
+ if (event.type === 'assistant' && event.message?.content) {
337
+ for (const block of event.message.content) {
338
+ if (block.type === 'tool_use') {
339
+ const toolName = block.name || 'Tool';
340
+
341
+ // Track tool usage for skill evolution
342
+ const toolEntry = { tool: toolName };
343
+ if (toolName === 'Skill' && block.input?.skill) toolEntry.skill = block.input.skill;
344
+ else if (block.input?.command) toolEntry.context = block.input.command.slice(0, 50);
345
+ else if (block.input?.file_path) toolEntry.context = path.basename(block.input.file_path);
346
+ if (toolUsageLog.length < 50) toolUsageLog.push(toolEntry);
347
+
348
+ // Track files written by Write tool
349
+ if (toolName === 'Write' && block.input?.file_path) {
350
+ const filePath = block.input.file_path;
351
+ if (!writtenFiles.includes(filePath)) {
352
+ writtenFiles.push(filePath);
353
+ }
354
+ }
355
+
356
+ const now = Date.now();
357
+ if (now - lastStatusTime >= STATUS_THROTTLE) {
358
+ lastStatusTime = now;
359
+ const emoji = TOOL_EMOJI[toolName] || TOOL_EMOJI.default;
360
+
361
+ // Resolve display name and context for MCP/Skill/Task tools
362
+ let displayName = toolName;
363
+ let displayEmoji = emoji;
364
+ let context = '';
365
+
366
+ if (toolName === 'Skill' && block.input?.skill) {
367
+ // Skill invocation: show skill name
368
+ context = block.input.skill;
369
+ } else if (toolName === 'Task' && block.input?.description) {
370
+ // Agent task: show description
371
+ context = block.input.description.slice(0, 30);
372
+ } else if (toolName.startsWith('mcp__')) {
373
+ // MCP tool: mcp__server__action → "MCP server: action"
374
+ const parts = toolName.split('__');
375
+ const server = parts[1] || 'unknown';
376
+ const action = parts.slice(2).join('_') || '';
377
+ if (server === 'playwright') {
378
+ displayEmoji = '🌐';
379
+ displayName = 'Browser';
380
+ context = action.replace(/_/g, ' ');
381
+ } else {
382
+ displayEmoji = '🔗';
383
+ displayName = `MCP:${server}`;
384
+ context = action.replace(/_/g, ' ').slice(0, 25);
385
+ }
386
+ } else if (block.input) {
387
+ // Standard tools: extract brief context
388
+ if (block.input.file_path) {
389
+ // Insert zero-width space before extension to prevent link parsing
390
+ const basename = path.basename(block.input.file_path);
391
+ const dotIdx = basename.lastIndexOf('.');
392
+ context = dotIdx > 0 ? basename.slice(0, dotIdx) + '\u200B' + basename.slice(dotIdx) : basename;
393
+ } else if (block.input.command) {
394
+ context = block.input.command.slice(0, 30);
395
+ if (block.input.command.length > 30) context += '...';
396
+ } else if (block.input.pattern) {
397
+ context = block.input.pattern.slice(0, 20);
398
+ } else if (block.input.query) {
399
+ context = block.input.query.slice(0, 25);
400
+ } else if (block.input.url) {
401
+ try {
402
+ context = new URL(block.input.url).hostname;
403
+ } catch { context = 'web'; }
404
+ }
405
+ }
406
+
407
+ const status = context
408
+ ? `${displayEmoji} ${displayName}: 「${context}」`
409
+ : `${displayEmoji} ${displayName}...`;
410
+
411
+ if (onStatus) {
412
+ onStatus(status).catch(() => { });
413
+ }
414
+ }
415
+ }
416
+ }
417
+ }
418
+
419
+ // Also check for result message type
420
+ if (event.type === 'result' && event.result) {
421
+ finalResult = event.result;
422
+ }
423
+ } catch {
424
+ // Not valid JSON, ignore
425
+ }
426
+ }
427
+ });
428
+
429
+ child.stderr.on('data', (data) => { stderr += data.toString(); });
430
+
431
+ child.on('close', (code) => {
432
+ clearTimeout(timer);
433
+
434
+ // Process any remaining buffer
435
+ if (buffer.trim()) {
436
+ try {
437
+ const event = JSON.parse(buffer);
438
+ if (event.type === 'result' && event.result) {
439
+ finalResult = event.result;
440
+ }
441
+ } catch { /* ignore */ }
442
+ }
443
+
444
+ // Clean up active process tracking
445
+ const proc = chatId ? activeProcesses.get(chatId) : null;
446
+ const wasAborted = proc && proc.aborted;
447
+ if (chatId) { activeProcesses.delete(chatId); saveActivePids(); } // Fix3
448
+
449
+ if (wasAborted) {
450
+ resolve({ output: finalResult || null, error: 'Stopped by user', files: writtenFiles, toolUsageLog });
451
+ } else if (killed) {
452
+ resolve({ output: finalResult || null, error: 'Timeout: Claude took too long', files: writtenFiles, toolUsageLog });
453
+ } else if (code !== 0) {
454
+ resolve({ output: finalResult || null, error: stderr || `Exit code ${code}`, files: writtenFiles, toolUsageLog });
455
+ } else {
456
+ resolve({ output: finalResult || '', error: null, files: writtenFiles, toolUsageLog });
457
+ }
458
+ });
459
+
460
+ child.on('error', (err) => {
461
+ clearTimeout(timer);
462
+ if (chatId) { activeProcesses.delete(chatId); saveActivePids(); } // Fix3
463
+ resolve({ output: null, error: err.message, files: [], toolUsageLog: [] });
464
+ });
465
+
466
+ // Write input and close stdin
467
+ child.stdin.write(input);
468
+ child.stdin.end();
469
+ });
470
+ }
471
+
472
+ // Track outbound message_id → session for reply-based session restoration.
473
+ // Keeps last 200 entries to avoid unbounded growth.
474
+ function trackMsgSession(messageId, session) {
475
+ if (!messageId || !session || !session.id) return;
476
+ const st = loadState();
477
+ if (!st.msg_sessions) st.msg_sessions = {};
478
+ st.msg_sessions[messageId] = { id: session.id, cwd: session.cwd };
479
+ const keys = Object.keys(st.msg_sessions);
480
+ if (keys.length > 200) {
481
+ for (const k of keys.slice(0, keys.length - 200)) delete st.msg_sessions[k];
482
+ }
483
+ saveState(st);
484
+ }
485
+
486
+ /**
487
+ * Shared ask logic — full Claude Code session (stateful, with tools)
488
+ * Now uses spawn (async) instead of execSync to allow parallel requests.
489
+ */
490
+ async function askClaude(bot, chatId, prompt, config, readOnly = false) {
491
+ log('INFO', `askClaude for ${chatId}: ${prompt.slice(0, 50)}`);
492
+ // Track interaction time for idle/sleep detection
493
+ if (touchInteraction) touchInteraction();
494
+ // Track per-session last_active for summary generation (P2-B)
495
+ try {
496
+ const _st = loadState();
497
+ if (_st.sessions && _st.sessions[chatId]) {
498
+ _st.sessions[chatId].last_active = Date.now();
499
+ saveState(_st);
500
+ }
501
+ } catch { /* non-critical */ }
502
+ // Send a single status message, updated in-place, deleted on completion
503
+ let statusMsgId = null;
504
+ try {
505
+ const msg = await (bot.sendMarkdown ? bot.sendMarkdown(chatId, '🤔') : bot.sendMessage(chatId, '🤔'));
506
+ if (msg && msg.message_id) statusMsgId = msg.message_id;
507
+ } catch (e) {
508
+ log('ERROR', `Failed to send ack to ${chatId}: ${e.message}`);
509
+ }
510
+ await bot.sendTyping(chatId).catch(() => { });
511
+ const typingTimer = setInterval(() => {
512
+ bot.sendTyping(chatId).catch(() => { });
513
+ }, 4000);
514
+
515
+ // Agent nickname routing: "贾维斯" / "小美,帮我..." → switch project session
516
+ const agentMatch = routeAgent(prompt, config);
517
+ if (agentMatch) {
518
+ const { key, proj, rest } = agentMatch;
519
+ const projCwd = normalizeCwd(proj.cwd);
520
+ attachOrCreateSession(chatId, projCwd, proj.name || key);
521
+ log('INFO', `Agent switch via nickname: ${key} (${projCwd})`);
522
+ if (!rest) {
523
+ // Pure nickname call — confirm switch and stop
524
+ clearInterval(typingTimer);
525
+ await bot.sendMessage(chatId, `${proj.icon || '🤖'} ${proj.name || key} 在线`);
526
+ return;
527
+ }
528
+ // Nickname + content — strip nickname, continue with rest as prompt
529
+ prompt = rest;
530
+ }
531
+
532
+ // Skill routing: detect skill first, then decide session
533
+ // BUT: if agent was explicitly addressed by nickname, don't let skill routing hijack the session
534
+ const skill = agentMatch ? null : routeSkill(prompt);
535
+ const chatIdStr = String(chatId);
536
+ const chatAgentMap = { ...(config.telegram ? config.telegram.chat_agent_map : {}), ...(config.feishu ? config.feishu.chat_agent_map : {}) };
537
+ const boundProjectKey = chatAgentMap[chatIdStr] || (chatIdStr.startsWith('_agent_') ? chatIdStr.slice(7) : null);
538
+ const boundProject = boundProjectKey && config.projects ? config.projects[boundProjectKey] : null;
539
+ const boundCwd = (boundProject && boundProject.cwd) ? normalizeCwd(boundProject.cwd) : null;
540
+
541
+ // Skills with dedicated pinned sessions (reused across days, no re-injection needed)
542
+ const PINNED_SKILL_SESSIONS = new Set(['macos-mail-calendar', 'skill-manager']);
543
+ const usePinnedSkillSession = !!(skill && PINNED_SKILL_SESSIONS.has(skill));
544
+
545
+ let session = getSession(chatId);
546
+
547
+ if (usePinnedSkillSession) {
548
+ // Use a dedicated long-lived session per skill
549
+ const state = loadState();
550
+ if (!state.pinned_sessions) state.pinned_sessions = {};
551
+ const pinned = state.pinned_sessions[skill];
552
+ if (pinned) {
553
+ // Reuse existing pinned session
554
+ state.sessions[chatId] = { id: pinned.id, cwd: pinned.cwd, started: true };
555
+ saveState(state);
556
+ session = state.sessions[chatId];
557
+ log('INFO', `Pinned session reused for skill ${skill}: ${pinned.id.slice(0, 8)}`);
558
+ } else {
559
+ // First time — create session and pin it
560
+ session = createSession(chatId, HOME, skill);
561
+ const st2 = loadState();
562
+ if (!st2.pinned_sessions) st2.pinned_sessions = {};
563
+ st2.pinned_sessions[skill] = { id: session.id, cwd: session.cwd };
564
+ saveState(st2);
565
+ log('INFO', `Pinned session created for skill ${skill}: ${session.id.slice(0, 8)}`);
566
+ }
567
+ } else if (!session) {
568
+ if (boundCwd) {
569
+ // Agent-bound chats must stay in their own workspace: never attach to another project's session.
570
+ const recentInBound = listRecentSessions(1, boundCwd);
571
+ if (recentInBound.length > 0 && recentInBound[0].sessionId) {
572
+ const target = recentInBound[0];
573
+ const state = loadState();
574
+ state.sessions[chatId] = {
575
+ id: target.sessionId,
576
+ cwd: boundCwd,
577
+ started: true,
578
+ };
579
+ saveState(state);
580
+ session = state.sessions[chatId];
581
+ log('INFO', `Auto-attached ${chatId} to bound-session: ${target.sessionId.slice(0, 8)} (${path.basename(boundCwd)})`);
582
+ } else {
583
+ session = createSession(chatId, boundCwd, boundProject && boundProject.name ? boundProject.name : '');
584
+ log('INFO', `Created fresh session for bound workspace: ${path.basename(boundCwd)}`);
585
+ }
586
+ } else {
587
+ // Non-bound chats keep legacy behavior: attach global recent, else create.
588
+ const recent = listRecentSessions(1);
589
+ if (recent.length > 0 && recent[0].sessionId && recent[0].projectPath) {
590
+ const target = recent[0];
591
+ const state = loadState();
592
+ state.sessions[chatId] = {
593
+ id: target.sessionId,
594
+ cwd: target.projectPath,
595
+ started: true,
596
+ };
597
+ saveState(state);
598
+ session = state.sessions[chatId];
599
+ log('INFO', `Auto-attached ${chatId} to recent session: ${target.sessionId.slice(0, 8)} (${path.basename(target.projectPath)})`);
600
+ } else {
601
+ session = createSession(chatId);
602
+ }
603
+ }
604
+ }
605
+
606
+ // Safety guard: prevent stale state from resuming another workspace's session.
607
+ if (!usePinnedSkillSession && session && session.started && session.id && session.id !== '__continue__' && session.cwd) {
608
+ const sessionCwd = normalizeCwd(session.cwd);
609
+ const existsInCwd = isSessionInCwd(session.id, sessionCwd);
610
+ if (!existsInCwd) {
611
+ log('WARN', `Session mismatch detected for ${chatId}: ${session.id.slice(0, 8)} not found in ${sessionCwd}; creating fresh session`);
612
+ session = createSession(chatId, sessionCwd, boundProject && boundProject.name ? boundProject.name : '');
613
+ }
614
+ }
615
+
616
+ // Build claude command
617
+ const args = ['-p'];
618
+ const daemonCfg = loadConfig().daemon || {};
619
+ const model = daemonCfg.model || 'opus';
620
+ args.push('--model', model);
621
+ if (readOnly) {
622
+ // Read-only mode for non-operator users: query/chat only, no write/edit/execute
623
+ const READ_ONLY_TOOLS = ['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'Task'];
624
+ for (const tool of READ_ONLY_TOOLS) args.push('--allowedTools', tool);
625
+ } else if (daemonCfg.dangerously_skip_permissions) {
626
+ args.push('--dangerously-skip-permissions');
627
+ } else {
628
+ const sessionAllowed = daemonCfg.session_allowed_tools || [];
629
+ for (const tool of sessionAllowed) args.push('--allowedTools', tool);
630
+ }
631
+ if (session.id === '__continue__') {
632
+ args.push('--continue');
633
+ } else if (session.started) {
634
+ args.push('--resume', session.id);
635
+ } else {
636
+ args.push('--session-id', session.id);
637
+ }
638
+
639
+ // Memory & Knowledge Injection (RAG)
640
+ let memoryHint = '';
641
+ try {
642
+ const memory = require('./memory');
643
+ const _cid = String(chatId);
644
+ const _cfg = loadConfig();
645
+ const _agentMap = { ...(_cfg.telegram ? _cfg.telegram.chat_agent_map : {}), ...(_cfg.feishu ? _cfg.feishu.chat_agent_map : {}) };
646
+ const projectKey = _agentMap[_cid] || (_cid.startsWith('_agent_') ? _cid.slice(7) : null);
647
+
648
+ // 1. Inject recent session memories ONLY on first message of a session
649
+ if (!session.started) {
650
+ const recent = memory.recentSessions({ limit: 3, project: projectKey || undefined });
651
+ if (recent.length > 0) {
652
+ const items = recent.map(r => `- [${r.created_at}] ${r.summary}${r.keywords ? ' (keywords: ' + r.keywords + ')' : ''}`).join('\n');
653
+ memoryHint += `\n\n<!-- MEMORY:START -->\n[Session memory - recent context from past sessions, use to inform your responses:\n${items}]\n<!-- MEMORY:END -->`;
654
+ }
655
+ }
656
+
657
+ // 2. Dynamic Fact Injection (RAG) — first message only
658
+ // Facts stay in Claude's context for the rest of the session; no need to repeat.
659
+ // Uses QMD hybrid search if available, falls back to FTS5.
660
+ if (!session.started) {
661
+ const searchFn = memory.searchFactsAsync || memory.searchFacts;
662
+ const factQuery = buildFactSearchQuery(prompt, projectKey);
663
+ const facts = await Promise.resolve(searchFn(factQuery, { limit: 5, project: projectKey || undefined }));
664
+ if (facts.length > 0) {
665
+ const factItems = facts.map(f => `- [${f.relation}] ${f.value}`).join('\n');
666
+ memoryHint += `\n\n<!-- FACTS:START -->\n[Relevant knowledge and user preferences retrieved for this query. Follow these constraints implicitly:\n${factItems}]\n<!-- FACTS:END -->`;
667
+ log('INFO', `[MEMORY] Injected ${facts.length} facts (query_len=${factQuery.length})`);
668
+ }
669
+ }
670
+
671
+ memory.close();
672
+ } catch (e) {
673
+ if (e.code !== 'MODULE_NOT_FOUND') log('WARN', `Memory injection failed: ${e.message}`);
674
+ }
675
+
676
+ // Inject daemon hints only on first message of a session
677
+ const daemonHint = !session.started ? `\n\n[System hints - DO NOT mention these to user:
678
+ 1. Daemon config: The ONLY config is ~/.metame/daemon.yaml (never edit daemon-default.yaml). Auto-reloads on change.
679
+ 2. File sending: User is on MOBILE. When they ask to see/download a file:
680
+ - Just FIND the file path (use Glob/ls if needed)
681
+ - Do NOT read or summarize the file content (wastes tokens)
682
+ - Add at END of response: [[FILE:/absolute/path/to/file]]
683
+ - Keep response brief: "请查收~! [[FILE:/path/to/file]]"
684
+ - Multiple files: use multiple [[FILE:...]] tags]` : '';
685
+
686
+ const routedPrompt = skill ? `/${skill} ${prompt}` : prompt;
687
+
688
+ // P2-B: inject session summary when resuming after a 2h+ gap
689
+ let summaryHint = '';
690
+ if (session.started) {
691
+ try {
692
+ const _stSum = loadState();
693
+ const _sess = _stSum.sessions && _stSum.sessions[chatId];
694
+ if (_sess && _sess.last_summary && _sess.last_summary_at) {
695
+ const _idleMs = Date.now() - (_sess.last_active || 0);
696
+ const _summaryAgeH = (Date.now() - _sess.last_summary_at) / 3600000;
697
+ if (_idleMs > 2 * 60 * 60 * 1000 && _summaryAgeH < 168) {
698
+ summaryHint = `
699
+
700
+ [上次对话摘要,供参考]: ${_sess.last_summary}`;
701
+ log('INFO', `[DAEMON] Injected session summary for ${chatId} (idle ${Math.round(_idleMs / 3600000)}h)`);
702
+ }
703
+ }
704
+ } catch { /* non-critical */ }
705
+ }
706
+
707
+ const fullPrompt = routedPrompt + daemonHint + summaryHint + memoryHint;
708
+
709
+ // Git checkpoint before Claude modifies files (for /undo)
710
+ // Pass the user prompt as label so checkpoint list is human-readable
711
+ gitCheckpoint(session.cwd, prompt);
712
+
713
+ // Use streaming mode to show progress
714
+ // Telegram: edit status msg in-place; Feishu: edit or fallback to new messages
715
+ let editFailed = false;
716
+ let lastFallbackStatus = 0;
717
+ const FALLBACK_THROTTLE = fallbackThrottleMs;
718
+ const onStatus = async (status) => {
719
+ try {
720
+ if (statusMsgId && bot.editMessage && !editFailed) {
721
+ const ok = await bot.editMessage(chatId, statusMsgId, status);
722
+ if (ok !== false) return; // edit succeeded (true or undefined for Telegram)
723
+ editFailed = true; // edit failed, switch to fallback permanently
724
+ }
725
+ // Fallback: send as new message with extra throttle to avoid spam
726
+ const now = Date.now();
727
+ if (now - lastFallbackStatus < FALLBACK_THROTTLE) return;
728
+ lastFallbackStatus = now;
729
+ await bot.sendMessage(chatId, status);
730
+ } catch { /* ignore status update failures */ }
731
+ };
732
+
733
+ const { output, error, files, toolUsageLog } = await spawnClaudeStreaming(args, fullPrompt, session.cwd, onStatus, 600000, chatId);
734
+ clearInterval(typingTimer);
735
+
736
+ // Skill evolution: capture signal + hot path heuristic check
737
+ if (skillEvolution) {
738
+ try {
739
+ const signal = skillEvolution.extractSkillSignal(fullPrompt, output, error, files, session.cwd, toolUsageLog);
740
+ if (signal) {
741
+ skillEvolution.appendSkillSignal(signal);
742
+ skillEvolution.checkHotEvolution(signal);
743
+ }
744
+ } catch (e) { log('WARN', `Skill evolution signal capture failed: ${e.message}`); }
745
+ }
746
+
747
+ // Clean up status message
748
+ if (statusMsgId && bot.deleteMessage) {
749
+ bot.deleteMessage(chatId, statusMsgId).catch(() => { });
750
+ }
751
+
752
+ // When Claude completes with no text output (pure tool work), send a done notice
753
+ if (output === '' && !error) {
754
+ // Special case: if dispatch_to was called, send a "forwarded" confirmation
755
+ const dispatchedTargets = (toolUsageLog || [])
756
+ .filter(t => t.tool === 'Bash' && typeof t.context === 'string' && t.context.includes('dispatch_to'))
757
+ .map(t => { const m = t.context.match(/dispatch_to\s+(\S+)/); return m ? m[1] : null; })
758
+ .filter(Boolean);
759
+ if (dispatchedTargets.length > 0) {
760
+ const allProjects = (config && config.projects) || {};
761
+ const names = dispatchedTargets.map(k => (allProjects[k] && allProjects[k].name) || k).join('、');
762
+ const doneMsg = await bot.sendMessage(chatId, `✉️ 已转达给 ${names},处理中…`);
763
+ if (doneMsg && doneMsg.message_id && session) trackMsgSession(doneMsg.message_id, session);
764
+ const wasNew = !session.started;
765
+ if (wasNew) markSessionStarted(chatId);
766
+ return;
767
+ }
768
+ const filesDesc = files && files.length > 0 ? `\n修改了 ${files.length} 个文件` : '';
769
+ const doneMsg = await bot.sendMessage(chatId, `✅ 完成${filesDesc}`);
770
+ if (doneMsg && doneMsg.message_id && session) trackMsgSession(doneMsg.message_id, session);
771
+ const wasNew = !session.started;
772
+ if (wasNew) markSessionStarted(chatId);
773
+ return;
774
+ }
775
+
776
+ if (output) {
777
+ // Detect provider/model errors disguised as output (e.g., "model not found", API errors)
778
+ const activeProvCheck = providerMod ? providerMod.getActiveName() : 'anthropic';
779
+ const builtinModelsCheck = ['sonnet', 'opus', 'haiku'];
780
+ const looksLikeError = output.length < 300 && /\b(not found|invalid model|unauthorized|401|403|404|error|failed)\b/i.test(output);
781
+ if (looksLikeError && (activeProvCheck !== 'anthropic' || !builtinModelsCheck.includes(model))) {
782
+ log('WARN', `Custom provider/model may have failed (${activeProvCheck}/${model}), output: ${output.slice(0, 200)}`);
783
+ try {
784
+ if (providerMod && activeProvCheck !== 'anthropic') providerMod.setActive('anthropic');
785
+ const cfg = yaml.load(fs.readFileSync(CONFIG_FILE, 'utf8')) || {};
786
+ if (!cfg.daemon) cfg.daemon = {};
787
+ cfg.daemon.model = 'opus';
788
+ writeConfigSafe(cfg);
789
+ config = loadConfig();
790
+ await bot.sendMessage(chatId, `⚠️ ${activeProvCheck}/${model} 疑似失败,已回退到 anthropic/opus\n输出: ${output.slice(0, 150)}`);
791
+ } catch (fbErr) {
792
+ log('ERROR', `Fallback failed: ${fbErr.message}`);
793
+ await bot.sendMarkdown(chatId, output);
794
+ }
795
+ return;
796
+ }
797
+
798
+ // Mark session as started after first successful call
799
+ const wasNew = !session.started;
800
+ if (wasNew) markSessionStarted(chatId);
801
+
802
+ const estimated = Math.ceil((prompt.length + output.length) / 4);
803
+ recordTokens(loadState(), estimated);
804
+
805
+ // Parse [[FILE:...]] markers from output (Claude's explicit file sends)
806
+ const { markedFiles, cleanOutput } = parseFileMarkers(output);
807
+
808
+ // Match current session to a project for colored card display
809
+ let activeProject = null;
810
+ if (session && session.cwd && config && config.projects) {
811
+ const sessionCwd = path.resolve(normalizeCwd(session.cwd));
812
+ for (const [, proj] of Object.entries(config.projects)) {
813
+ if (!proj.cwd) continue;
814
+ const projCwd = path.resolve(normalizeCwd(proj.cwd));
815
+ if (sessionCwd === projCwd) { activeProject = proj; break; }
816
+ }
817
+ }
818
+
819
+ let replyMsg;
820
+ try {
821
+ if (activeProject && bot.sendCard) {
822
+ replyMsg = await bot.sendCard(chatId, {
823
+ title: `${activeProject.icon || '🤖'} ${activeProject.name || ''}`,
824
+ body: cleanOutput,
825
+ color: activeProject.color || 'blue',
826
+ });
827
+ } else {
828
+ replyMsg = await bot.sendMarkdown(chatId, cleanOutput);
829
+ }
830
+ } catch (sendErr) {
831
+ log('WARN', `sendCard/sendMarkdown failed (${sendErr.message}), falling back to sendMessage`);
832
+ try { replyMsg = await bot.sendMessage(chatId, cleanOutput); } catch (e2) {
833
+ log('ERROR', `sendMessage fallback also failed: ${e2.message}`);
834
+ }
835
+ }
836
+ if (replyMsg && replyMsg.message_id && session) trackMsgSession(replyMsg.message_id, session);
837
+
838
+ await sendFileButtons(bot, chatId, mergeFileCollections(markedFiles, files));
839
+
840
+ // Auto-name: if this was the first message and session has no name, generate one
841
+ if (wasNew && !getSessionName(session.id)) {
842
+ autoNameSession(chatId, session.id, prompt, session.cwd).catch(() => { });
843
+ }
844
+ } else {
845
+ const errMsg = error || 'Unknown error';
846
+ log('ERROR', `askClaude failed for ${chatId}: ${errMsg.slice(0, 300)}`);
847
+
848
+ // If session not found (expired/deleted), create new and retry once
849
+ if (errMsg.includes('not found') || errMsg.includes('No session')) {
850
+ log('WARN', `Session ${session.id} not found, creating new`);
851
+ session = createSession(chatId, session.cwd);
852
+
853
+ const retryArgs = ['-p', '--session-id', session.id];
854
+ if (daemonCfg.dangerously_skip_permissions) {
855
+ retryArgs.push('--dangerously-skip-permissions');
856
+ } else {
857
+ const sessionAllowed = daemonCfg.session_allowed_tools || [];
858
+ for (const tool of sessionAllowed) retryArgs.push('--allowedTools', tool);
859
+ }
860
+
861
+ const retry = await spawnClaudeStreaming(retryArgs, prompt, session.cwd, onStatus);
862
+ if (retry.output) {
863
+ markSessionStarted(chatId);
864
+ const { markedFiles: retryMarked, cleanOutput: retryClean } = parseFileMarkers(retry.output);
865
+ await bot.sendMarkdown(chatId, retryClean);
866
+ await sendFileButtons(bot, chatId, mergeFileCollections(retryMarked, retry.files));
867
+ } else {
868
+ log('ERROR', `askClaude retry failed: ${(retry.error || '').slice(0, 200)}`);
869
+ try { await bot.sendMessage(chatId, `Error: ${(retry.error || '').slice(0, 200)}`); } catch { /* */ }
870
+ }
871
+ } else if (errMsg === 'Stopped by user' && messageQueue.has(chatId)) {
872
+ // Interrupted by message queue — suppress error, queue timer will handle it
873
+ log('INFO', `Task interrupted by new message for ${chatId}`);
874
+ } else {
875
+ // Auto-fallback: if custom provider/model fails, revert to anthropic + opus
876
+ const activeProv = providerMod ? providerMod.getActiveName() : 'anthropic';
877
+ const builtinModels = ['sonnet', 'opus', 'haiku'];
878
+ if (activeProv !== 'anthropic' || !builtinModels.includes(model)) {
879
+ log('WARN', `Custom provider/model failed (${activeProv}/${model}), falling back to anthropic/opus`);
880
+ try {
881
+ if (providerMod && activeProv !== 'anthropic') providerMod.setActive('anthropic');
882
+ const cfg = yaml.load(fs.readFileSync(CONFIG_FILE, 'utf8')) || {};
883
+ if (!cfg.daemon) cfg.daemon = {};
884
+ cfg.daemon.model = 'opus';
885
+ writeConfigSafe(cfg);
886
+ config = loadConfig();
887
+ await bot.sendMessage(chatId, `⚠️ ${activeProv}/${model} 失败,已回退到 anthropic/opus\n原因: ${errMsg.slice(0, 100)}`);
888
+ } catch (fallbackErr) {
889
+ log('ERROR', `Fallback failed: ${fallbackErr.message}`);
890
+ try { await bot.sendMessage(chatId, `Error: ${errMsg.slice(0, 200)}`); } catch { /* */ }
891
+ }
892
+ } else {
893
+ try { await bot.sendMessage(chatId, `Error: ${errMsg.slice(0, 200)}`); } catch { /* */ }
894
+ }
895
+ }
896
+ }
897
+ }
898
+
899
+ return {
900
+ parseFileMarkers,
901
+ mergeFileCollections,
902
+ spawnClaudeAsync,
903
+ spawnClaudeStreaming,
904
+ trackMsgSession,
905
+ askClaude,
906
+ };
907
+ }
908
+
909
+ module.exports = { createClaudeEngine };