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.
- package/README.md +9 -9
- package/index.js +205 -57
- package/package.json +2 -2
- package/scripts/daemon-admin-commands.js +365 -0
- package/scripts/daemon-agent-commands.js +491 -0
- package/scripts/daemon-agent-tools.js +256 -0
- package/scripts/daemon-bridges.js +236 -0
- package/scripts/daemon-checkpoints.js +89 -0
- package/scripts/daemon-claude-engine.js +909 -0
- package/scripts/daemon-command-router.js +416 -0
- package/scripts/daemon-default.yaml +2 -2
- package/scripts/daemon-exec-commands.js +290 -0
- package/scripts/daemon-file-browser.js +219 -0
- package/scripts/daemon-notify.js +64 -0
- package/scripts/daemon-ops-commands.js +275 -0
- package/scripts/daemon-runtime-lifecycle.js +133 -0
- package/scripts/daemon-session-commands.js +436 -0
- package/scripts/daemon-session-store.js +423 -0
- package/scripts/daemon-task-scheduler.js +539 -0
- package/scripts/daemon.js +555 -4316
- package/scripts/memory-extract.js +15 -9
- package/scripts/session-analytics.js +116 -0
- package/scripts/test_daemon.js +1407 -0
|
@@ -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 };
|