metame-cli 1.4.34 → 1.5.1
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 +136 -94
- package/index.js +312 -57
- package/package.json +8 -4
- package/scripts/agent-layer.js +320 -0
- package/scripts/daemon-admin-commands.js +328 -28
- package/scripts/daemon-agent-commands.js +145 -6
- package/scripts/daemon-agent-tools.js +163 -7
- package/scripts/daemon-bridges.js +110 -20
- package/scripts/daemon-checkpoints.js +36 -7
- package/scripts/daemon-claude-engine.js +849 -358
- package/scripts/daemon-command-router.js +31 -10
- package/scripts/daemon-default.yaml +28 -4
- package/scripts/daemon-engine-runtime.js +328 -0
- package/scripts/daemon-exec-commands.js +15 -7
- package/scripts/daemon-notify.js +37 -1
- package/scripts/daemon-ops-commands.js +8 -6
- package/scripts/daemon-runtime-lifecycle.js +129 -5
- package/scripts/daemon-session-commands.js +60 -25
- package/scripts/daemon-session-store.js +121 -13
- package/scripts/daemon-task-scheduler.js +129 -49
- package/scripts/daemon-user-acl.js +35 -9
- package/scripts/daemon.js +268 -33
- package/scripts/distill.js +327 -18
- package/scripts/docs/agent-guide.md +12 -0
- package/scripts/docs/maintenance-manual.md +155 -0
- package/scripts/docs/pointer-map.md +110 -0
- package/scripts/feishu-adapter.js +42 -13
- package/scripts/hooks/stop-session-capture.js +243 -0
- package/scripts/memory-extract.js +105 -6
- package/scripts/memory-nightly-reflect.js +199 -11
- package/scripts/memory.js +134 -3
- package/scripts/mentor-engine.js +405 -0
- package/scripts/platform.js +24 -0
- package/scripts/providers.js +182 -22
- package/scripts/schema.js +12 -0
- package/scripts/session-analytics.js +245 -12
- package/scripts/skill-changelog.js +245 -0
- package/scripts/skill-evolution.js +288 -5
- package/scripts/telegram-adapter.js +12 -8
- package/scripts/usage-classifier.js +1 -1
- package/scripts/daemon-admin-commands.test.js +0 -333
- package/scripts/daemon-task-envelope.test.js +0 -59
- package/scripts/daemon-task-scheduler.test.js +0 -106
- package/scripts/reliability-core.test.js +0 -280
- package/scripts/skill-evolution.test.js +0 -113
- package/scripts/task-board.test.js +0 -83
- package/scripts/test_daemon.js +0 -1407
- package/scripts/utils.test.js +0 -192
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { classifyChatUsage } = require('./usage-classifier');
|
|
4
|
+
const { deriveProjectInfo } = require('./utils');
|
|
5
|
+
const { createEngineRuntimeFactory, normalizeEngineName, ENGINE_MODEL_CONFIG } = require('./daemon-engine-runtime');
|
|
6
|
+
const { buildAgentContextForEngine, buildMemorySnapshotContent, refreshMemorySnapshot } = require('./agent-layer');
|
|
4
7
|
|
|
5
8
|
function createClaudeEngine(deps) {
|
|
6
9
|
const {
|
|
@@ -30,93 +33,158 @@ function createClaudeEngine(deps) {
|
|
|
30
33
|
findSessionFile,
|
|
31
34
|
listRecentSessions,
|
|
32
35
|
getSession,
|
|
36
|
+
getSessionForEngine,
|
|
33
37
|
createSession,
|
|
34
38
|
getSessionName,
|
|
35
39
|
writeSessionName,
|
|
36
40
|
markSessionStarted,
|
|
41
|
+
isEngineSessionValid,
|
|
37
42
|
gitCheckpoint,
|
|
43
|
+
gitCheckpointAsync,
|
|
38
44
|
recordTokens,
|
|
39
45
|
skillEvolution,
|
|
40
46
|
touchInteraction,
|
|
41
47
|
statusThrottleMs = 3000,
|
|
42
48
|
fallbackThrottleMs = 8000,
|
|
49
|
+
getEngineRuntime: injectedGetEngineRuntime,
|
|
50
|
+
getDefaultEngine: _getDefaultEngine,
|
|
43
51
|
} = deps;
|
|
52
|
+
function getDefaultEngine() {
|
|
53
|
+
return (typeof _getDefaultEngine === 'function') ? _getDefaultEngine() : 'claude';
|
|
54
|
+
}
|
|
55
|
+
let mentorEngine = null;
|
|
56
|
+
try { mentorEngine = require('./mentor-engine'); } catch { /* optional */ }
|
|
57
|
+
let sessionAnalytics = null;
|
|
58
|
+
try { sessionAnalytics = require('./session-analytics'); } catch { /* optional */ }
|
|
59
|
+
|
|
60
|
+
const getEngineRuntime = typeof injectedGetEngineRuntime === 'function'
|
|
61
|
+
? injectedGetEngineRuntime
|
|
62
|
+
: createEngineRuntimeFactory({ fs, path, HOME, CLAUDE_BIN, getActiveProviderEnv });
|
|
63
|
+
|
|
64
|
+
// On Windows, spawning .cmd files via shell:true causes cmd.exe to flash briefly.
|
|
65
|
+
// Instead, read the .cmd wrapper, extract the real Node.js entry point, and spawn
|
|
66
|
+
// `node <entry.js> <args>` directly — completely bypasses cmd.exe, zero flash.
|
|
67
|
+
function resolveNodeEntry(cmdPath) {
|
|
68
|
+
try {
|
|
69
|
+
const content = fs.readFileSync(cmdPath, 'utf8');
|
|
70
|
+
// Match the quoted .js path just before %* at end of last exec line
|
|
71
|
+
const m = content.match(/"([^"]+\.js)"\s*%\*\s*$/m);
|
|
72
|
+
if (m) {
|
|
73
|
+
// Substitute %dp0% (batch var for the cmd file's own directory)
|
|
74
|
+
const entry = m[1].replace(/%dp0%/gi, path.dirname(cmdPath) + path.sep);
|
|
75
|
+
if (fs.existsSync(entry)) return entry;
|
|
76
|
+
}
|
|
77
|
+
} catch { /* ignore */ }
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
44
80
|
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
81
|
+
// Cache resolved entries so we only read .cmd files once
|
|
82
|
+
const _nodeEntryCache = new Map();
|
|
83
|
+
function resolveNodeEntryForCmd(cmd) {
|
|
84
|
+
if (_nodeEntryCache.has(cmd)) return _nodeEntryCache.get(cmd);
|
|
85
|
+
let cmdPath = cmd;
|
|
86
|
+
const lowerCmd = String(cmd || '').toLowerCase();
|
|
87
|
+
// If bare name (not a file path), find the .cmd via where
|
|
88
|
+
if (lowerCmd === 'claude' || lowerCmd === 'codex') {
|
|
89
|
+
try {
|
|
90
|
+
const { execSync: _es } = require('child_process');
|
|
91
|
+
const lines = _es(`where ${cmd}`, { encoding: 'utf8', timeout: 3000 })
|
|
92
|
+
.split('\n').map(l => l.trim()).filter(Boolean);
|
|
93
|
+
cmdPath = lines.find(l => l.toLowerCase().endsWith(`${lowerCmd}.cmd`)) || lines[0] || cmd;
|
|
94
|
+
} catch { /* ignore */ }
|
|
49
95
|
}
|
|
50
|
-
|
|
96
|
+
const entry = resolveNodeEntry(cmdPath);
|
|
97
|
+
_nodeEntryCache.set(cmd, entry);
|
|
98
|
+
return entry;
|
|
51
99
|
}
|
|
52
100
|
|
|
53
|
-
|
|
54
|
-
|
|
101
|
+
function spawn(cmd, args, options) {
|
|
102
|
+
if (process.platform !== 'win32') return _spawn(cmd, args, options);
|
|
55
103
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
104
|
+
const lowerCmd = String(cmd || '').toLowerCase();
|
|
105
|
+
const isCmdLike = lowerCmd.endsWith('.cmd') || lowerCmd.endsWith('.bat')
|
|
106
|
+
|| cmd === CLAUDE_BIN || lowerCmd === 'claude' || lowerCmd === 'codex';
|
|
107
|
+
|
|
108
|
+
if (isCmdLike) {
|
|
109
|
+
const entry = resolveNodeEntryForCmd(cmd);
|
|
110
|
+
if (entry) {
|
|
111
|
+
// Run node directly — no cmd.exe, no flash
|
|
112
|
+
return _spawn(process.execPath, [entry, ...args], { ...options, windowsHide: true });
|
|
113
|
+
}
|
|
114
|
+
// Fallback: shell with windowsHide
|
|
115
|
+
return _spawn(cmd, args, { ...options, shell: process.env.COMSPEC || true, windowsHide: true });
|
|
61
116
|
}
|
|
62
|
-
return
|
|
117
|
+
return _spawn(cmd, args, { ...options, windowsHide: true });
|
|
63
118
|
}
|
|
64
119
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
120
|
+
// Per-chatId patch queues: Agent A's writes never block Agent B.
|
|
121
|
+
const _patchQueues = new Map(); // chatId -> Promise
|
|
122
|
+
function patchSessionSerialized(chatId, patchFn) {
|
|
123
|
+
const prev = _patchQueues.get(chatId) || Promise.resolve();
|
|
124
|
+
const next = prev.then(() => {
|
|
125
|
+
const state = loadState();
|
|
126
|
+
if (!state.sessions) state.sessions = {};
|
|
127
|
+
const cur = state.sessions[chatId] || {};
|
|
128
|
+
const patched = typeof patchFn === 'function' ? patchFn(cur) : cur;
|
|
129
|
+
state.sessions[chatId] = patched && typeof patched === 'object' ? patched : cur;
|
|
130
|
+
saveState(state);
|
|
131
|
+
}).catch((e) => {
|
|
132
|
+
log('WARN', `patchSessionSerialized failed for ${chatId}: ${e.message}`);
|
|
133
|
+
});
|
|
134
|
+
_patchQueues.set(chatId, next);
|
|
135
|
+
// GC: remove resolved entries to prevent unbounded Map growth
|
|
136
|
+
next.then(() => { if (_patchQueues.get(chatId) === next) _patchQueues.delete(chatId); });
|
|
137
|
+
return next;
|
|
138
|
+
}
|
|
68
139
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const cached = _sessionCwdValidationCache.get(cacheKey);
|
|
72
|
-
if (cached && (Date.now() - cached.ts) < SESSION_CWD_VALIDATION_TTL_MS) {
|
|
73
|
-
return !!cached.inCwd;
|
|
74
|
-
}
|
|
140
|
+
const CODEX_RESUME_RETRY_WINDOW_MS = 10 * 60 * 1000;
|
|
141
|
+
const _codexResumeRetryTs = new Map(); // chatId -> last retry ts
|
|
75
142
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const indexFile = path.join(projectDir, 'sessions-index.json');
|
|
84
|
-
if (fs.existsSync(indexFile)) {
|
|
85
|
-
const data = JSON.parse(fs.readFileSync(indexFile, 'utf8'));
|
|
86
|
-
const entries = Array.isArray(data && data.entries) ? data.entries : [];
|
|
87
|
-
const entry = entries.find(e => e && e.sessionId === safeSessionId);
|
|
88
|
-
if (entry && entry.projectPath) {
|
|
89
|
-
return cacheSessionCwdValidation(cacheKey, normalizeCwd(entry.projectPath) === normCwd);
|
|
90
|
-
}
|
|
91
|
-
// sessions-index may lag behind new sessions; use project-level path from any entry.
|
|
92
|
-
const anyProjectPath = (entries.find(e => e && e.projectPath) || {}).projectPath;
|
|
93
|
-
if (anyProjectPath) {
|
|
94
|
-
return cacheSessionCwdValidation(cacheKey, normalizeCwd(anyProjectPath) === normCwd);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
143
|
+
function canRetryCodexResume(chatId) {
|
|
144
|
+
const key = String(chatId || '');
|
|
145
|
+
if (!key) return false;
|
|
146
|
+
const last = Number(_codexResumeRetryTs.get(key) || 0);
|
|
147
|
+
if (!last) return true;
|
|
148
|
+
return (Date.now() - last) > CODEX_RESUME_RETRY_WINDOW_MS;
|
|
149
|
+
}
|
|
97
150
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (actualDirName === expectedDirName) {
|
|
104
|
-
return cacheSessionCwdValidation(cacheKey, true);
|
|
105
|
-
}
|
|
106
|
-
// Unable to prove mismatch safely.
|
|
107
|
-
return cacheSessionCwdValidation(cacheKey, true);
|
|
108
|
-
}
|
|
151
|
+
function markCodexResumeRetried(chatId) {
|
|
152
|
+
const key = String(chatId || '');
|
|
153
|
+
if (!key) return;
|
|
154
|
+
_codexResumeRetryTs.set(key, Date.now());
|
|
155
|
+
}
|
|
109
156
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
157
|
+
function shouldRetryCodexResumeFallback({ runtimeName, wasResumeAttempt, output, error, errorCode, canRetry }) {
|
|
158
|
+
return runtimeName === 'codex'
|
|
159
|
+
&& !!wasResumeAttempt
|
|
160
|
+
&& !!error
|
|
161
|
+
&& (!output || !!errorCode)
|
|
162
|
+
&& !!canRetry;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function formatEngineSpawnError(err, runtime) {
|
|
166
|
+
if (!err) return 'Unknown spawn error';
|
|
167
|
+
const rt = runtime || { name: getDefaultEngine() };
|
|
168
|
+
if (err.code === 'ENOENT') {
|
|
169
|
+
if (rt.name === 'codex') {
|
|
170
|
+
return 'Codex CLI 未安装。请先运行: npm install -g @openai/codex';
|
|
171
|
+
}
|
|
172
|
+
return 'Claude CLI 未安装或不在 PATH。请先确认 `claude` 可执行。';
|
|
117
173
|
}
|
|
174
|
+
return err.message || String(err);
|
|
118
175
|
}
|
|
119
176
|
|
|
177
|
+
function adaptDaemonHintForEngine(daemonHint, engineName) {
|
|
178
|
+
if (normalizeEngineName(engineName) === 'claude') return daemonHint;
|
|
179
|
+
let out = String(daemonHint || '');
|
|
180
|
+
// Keep this replacement conservative: only unwrap the known outer wrapper.
|
|
181
|
+
out = out.replace('[System hints - DO NOT mention these to user:', 'System hints (internal, do not mention to user):');
|
|
182
|
+
// The current daemonHint template ends with a single trailing `]`.
|
|
183
|
+
out = out.replace(/\]\s*$/, '');
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
120
188
|
/**
|
|
121
189
|
* Parse [[FILE:...]] markers from Claude output.
|
|
122
190
|
* Returns { markedFiles, cleanOutput }
|
|
@@ -192,6 +260,82 @@ function createClaudeEngine(deps) {
|
|
|
192
260
|
return null;
|
|
193
261
|
}
|
|
194
262
|
|
|
263
|
+
function resolveMentorMode(cfg = {}) {
|
|
264
|
+
const mode = String(cfg.mode || '').trim().toLowerCase();
|
|
265
|
+
if (mode === 'gentle' || mode === 'active' || mode === 'intense') return mode;
|
|
266
|
+
const level = Number(cfg.friction_level);
|
|
267
|
+
if (Number.isFinite(level)) {
|
|
268
|
+
if (level >= 8) return 'intense';
|
|
269
|
+
if (level >= 4) return 'active';
|
|
270
|
+
}
|
|
271
|
+
return 'gentle';
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function extractUserText(content) {
|
|
275
|
+
if (typeof content === 'string') return content;
|
|
276
|
+
if (!Array.isArray(content)) return '';
|
|
277
|
+
for (const item of content) {
|
|
278
|
+
if (item && item.type === 'text' && item.text) return item.text;
|
|
279
|
+
}
|
|
280
|
+
return '';
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function collectRecentSessionSignals(sessionId, limit = 6) {
|
|
284
|
+
const out = { recentMessages: [], sessionStartTime: null };
|
|
285
|
+
if (!sessionId || typeof findSessionFile !== 'function') return out;
|
|
286
|
+
const file = findSessionFile(sessionId);
|
|
287
|
+
if (!file || !fs.existsSync(file)) return out;
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
291
|
+
const lines = raw.split('\n').filter(Boolean).slice(-800);
|
|
292
|
+
let current = null;
|
|
293
|
+
for (const line of lines) {
|
|
294
|
+
let entry;
|
|
295
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
296
|
+
if (!out.sessionStartTime && entry.timestamp) out.sessionStartTime = entry.timestamp;
|
|
297
|
+
|
|
298
|
+
if (entry.type === 'user' && entry.message) {
|
|
299
|
+
if (current) out.recentMessages.push(current);
|
|
300
|
+
current = {
|
|
301
|
+
text: extractUserText(entry.message.content),
|
|
302
|
+
tool_calls: 0,
|
|
303
|
+
};
|
|
304
|
+
} else if (entry.type === 'assistant' && current && entry.message && Array.isArray(entry.message.content)) {
|
|
305
|
+
for (const item of entry.message.content) {
|
|
306
|
+
if (item && item.type === 'tool_use') current.tool_calls++;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (current) out.recentMessages.push(current);
|
|
311
|
+
if (out.recentMessages.length > limit) {
|
|
312
|
+
out.recentMessages = out.recentMessages.slice(-limit);
|
|
313
|
+
}
|
|
314
|
+
} catch {
|
|
315
|
+
return out;
|
|
316
|
+
}
|
|
317
|
+
return out;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function countCodeLines(output) {
|
|
321
|
+
const text = String(output || '');
|
|
322
|
+
if (!text.trim()) return 0;
|
|
323
|
+
const lines = text.split('\n');
|
|
324
|
+
let inFence = false;
|
|
325
|
+
let count = 0;
|
|
326
|
+
let sawFence = false;
|
|
327
|
+
for (const line of lines) {
|
|
328
|
+
if (/^\s*```/.test(line)) {
|
|
329
|
+
sawFence = true;
|
|
330
|
+
inFence = !inFence;
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (inFence && line.trim()) count++;
|
|
334
|
+
}
|
|
335
|
+
if (!sawFence) return 0;
|
|
336
|
+
return count;
|
|
337
|
+
}
|
|
338
|
+
|
|
195
339
|
function isMacAutomationIntent(prompt) {
|
|
196
340
|
const text = String(prompt || '').trim();
|
|
197
341
|
if (!text) return false;
|
|
@@ -243,21 +387,24 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
|
|
|
243
387
|
}
|
|
244
388
|
|
|
245
389
|
/**
|
|
246
|
-
* Spawn
|
|
390
|
+
* Spawn Claude as async child process (non-blocking).
|
|
391
|
+
* Intentionally Claude-only: used by naming/fallback helper paths that
|
|
392
|
+
* should not depend on project runtime adapter selection.
|
|
247
393
|
* Returns { output, error } after process exits.
|
|
248
394
|
*/
|
|
249
395
|
function spawnClaudeAsync(args, input, cwd, timeoutMs = 300000, metameProject = '') {
|
|
250
396
|
return new Promise((resolve) => {
|
|
397
|
+
const env = {
|
|
398
|
+
...process.env,
|
|
399
|
+
...getActiveProviderEnv(),
|
|
400
|
+
METAME_INTERNAL_PROMPT: '1',
|
|
401
|
+
METAME_PROJECT: metameProject || '',
|
|
402
|
+
};
|
|
403
|
+
delete env.CLAUDECODE;
|
|
251
404
|
const child = spawn(CLAUDE_BIN, args, {
|
|
252
405
|
cwd,
|
|
253
406
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
254
|
-
env
|
|
255
|
-
...process.env,
|
|
256
|
-
...getActiveProviderEnv(),
|
|
257
|
-
CLAUDECODE: undefined,
|
|
258
|
-
METAME_INTERNAL_PROMPT: '1',
|
|
259
|
-
METAME_PROJECT: metameProject || ''
|
|
260
|
-
},
|
|
407
|
+
env,
|
|
261
408
|
});
|
|
262
409
|
|
|
263
410
|
let stdout = '';
|
|
@@ -288,7 +435,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
|
|
|
288
435
|
|
|
289
436
|
child.on('error', (err) => {
|
|
290
437
|
clearTimeout(timer);
|
|
291
|
-
resolve({ output: null, error: err
|
|
438
|
+
resolve({ output: null, error: formatEngineSpawnError(err, { name: getDefaultEngine() }) });
|
|
292
439
|
});
|
|
293
440
|
|
|
294
441
|
// Write input and close stdin
|
|
@@ -317,55 +464,80 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
|
|
|
317
464
|
};
|
|
318
465
|
|
|
319
466
|
/**
|
|
320
|
-
* Spawn
|
|
321
|
-
*
|
|
322
|
-
* Returns { output, error } after process exits.
|
|
467
|
+
* Spawn engine with streaming output. Parser comes from runtime adapter.
|
|
468
|
+
* Returns { output, error, files, toolUsageLog, usage, sessionId }.
|
|
323
469
|
*/
|
|
324
|
-
function spawnClaudeStreaming(
|
|
470
|
+
function spawnClaudeStreaming(
|
|
471
|
+
args,
|
|
472
|
+
input,
|
|
473
|
+
cwd,
|
|
474
|
+
onStatus,
|
|
475
|
+
timeoutMs = 600000,
|
|
476
|
+
chatId = null,
|
|
477
|
+
metameProject = '',
|
|
478
|
+
runtime = null,
|
|
479
|
+
onSession = null,
|
|
480
|
+
) {
|
|
325
481
|
return new Promise((resolve) => {
|
|
326
|
-
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
482
|
+
let settled = false;
|
|
483
|
+
const finalize = (payload) => {
|
|
484
|
+
if (settled) return;
|
|
485
|
+
settled = true;
|
|
486
|
+
resolve(payload);
|
|
487
|
+
};
|
|
488
|
+
const rt = runtime || getEngineRuntime(getDefaultEngine());
|
|
489
|
+
const streamArgs = rt.name === 'claude'
|
|
490
|
+
? [...args, '--output-format', 'stream-json', '--verbose']
|
|
491
|
+
: args;
|
|
492
|
+
const _spawnAt = Date.now();
|
|
493
|
+
const child = spawn(rt.binary, streamArgs, {
|
|
330
494
|
cwd,
|
|
331
495
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
332
|
-
detached: process.platform !== 'win32',
|
|
333
|
-
env: {
|
|
334
|
-
...process.env,
|
|
335
|
-
...getActiveProviderEnv(),
|
|
336
|
-
CLAUDECODE: undefined,
|
|
337
|
-
METAME_PROJECT: metameProject || ''
|
|
338
|
-
},
|
|
496
|
+
detached: process.platform !== 'win32',
|
|
497
|
+
env: rt.buildEnv({ metameProject }),
|
|
339
498
|
});
|
|
499
|
+
log('INFO', `[TIMING:${chatId}] spawned ${rt.name} pid=${child.pid}`);
|
|
340
500
|
|
|
341
|
-
// Track active process for /stop
|
|
342
501
|
if (chatId) {
|
|
343
|
-
activeProcesses.set(chatId, {
|
|
344
|
-
|
|
502
|
+
activeProcesses.set(chatId, {
|
|
503
|
+
child,
|
|
504
|
+
aborted: false,
|
|
505
|
+
startedAt: _spawnAt,
|
|
506
|
+
engine: rt.name,
|
|
507
|
+
killSignal: rt.killSignal || 'SIGTERM',
|
|
508
|
+
});
|
|
509
|
+
saveActivePids();
|
|
345
510
|
}
|
|
346
511
|
|
|
347
512
|
let buffer = '';
|
|
348
513
|
let stderr = '';
|
|
349
514
|
let killed = false;
|
|
350
|
-
let killedReason = 'idle';
|
|
515
|
+
let killedReason = 'idle';
|
|
351
516
|
let finalResult = '';
|
|
517
|
+
let finalUsage = null;
|
|
518
|
+
let observedSessionId = '';
|
|
519
|
+
let _firstOutputLogged = false;
|
|
520
|
+
let classifiedError = null;
|
|
352
521
|
let lastStatusTime = 0;
|
|
353
522
|
const STATUS_THROTTLE = statusThrottleMs;
|
|
354
|
-
const writtenFiles = [];
|
|
355
|
-
const toolUsageLog = [];
|
|
523
|
+
const writtenFiles = [];
|
|
524
|
+
const toolUsageLog = [];
|
|
356
525
|
|
|
357
|
-
|
|
358
|
-
const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
359
|
-
const
|
|
526
|
+
const engineTimeouts = rt.timeouts || {};
|
|
527
|
+
const IDLE_TIMEOUT_MS = engineTimeouts.idleMs || (5 * 60 * 1000);
|
|
528
|
+
const TOOL_EXEC_TIMEOUT_MS = engineTimeouts.toolMs || (25 * 60 * 1000);
|
|
529
|
+
const HARD_CEILING_MS = engineTimeouts.ceilingMs || (60 * 60 * 1000);
|
|
360
530
|
const startTime = Date.now();
|
|
531
|
+
let waitingForTool = false;
|
|
361
532
|
|
|
362
533
|
let sigkillTimer = null;
|
|
363
534
|
function killChild(reason) {
|
|
364
535
|
if (killed) return;
|
|
365
536
|
killed = true;
|
|
366
537
|
killedReason = reason;
|
|
367
|
-
log('WARN', `
|
|
368
|
-
|
|
538
|
+
log('WARN', `[${rt.name}] ${reason} timeout for chatId ${chatId} — killing process group`);
|
|
539
|
+
const sig = rt.killSignal || 'SIGTERM';
|
|
540
|
+
try { process.kill(-child.pid, sig); } catch { child.kill(sig); }
|
|
369
541
|
sigkillTimer = setTimeout(() => {
|
|
370
542
|
try { process.kill(-child.pid, 'SIGKILL'); } catch { try { child.kill('SIGKILL'); } catch { } }
|
|
371
543
|
}, 5000);
|
|
@@ -376,10 +548,10 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
|
|
|
376
548
|
|
|
377
549
|
function resetIdleTimer() {
|
|
378
550
|
clearTimeout(idleTimer);
|
|
379
|
-
|
|
551
|
+
const timeout = waitingForTool ? TOOL_EXEC_TIMEOUT_MS : IDLE_TIMEOUT_MS;
|
|
552
|
+
idleTimer = setTimeout(() => killChild('idle'), timeout);
|
|
380
553
|
}
|
|
381
554
|
|
|
382
|
-
// ── 进度里程碑:2min 首报,之后每 5min 一次 ──
|
|
383
555
|
let toolCallCount = 0;
|
|
384
556
|
let lastMilestoneMin = 0;
|
|
385
557
|
const milestoneTimer = setInterval(() => {
|
|
@@ -400,161 +572,182 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
|
|
|
400
572
|
}
|
|
401
573
|
}, 30000);
|
|
402
574
|
|
|
575
|
+
function parseEventsFromLine(line) {
|
|
576
|
+
try {
|
|
577
|
+
return rt.parseStreamEvent(line) || [];
|
|
578
|
+
} catch {
|
|
579
|
+
return [];
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
403
583
|
child.stdout.on('data', (data) => {
|
|
404
584
|
resetIdleTimer();
|
|
405
585
|
buffer += data.toString();
|
|
406
|
-
|
|
407
|
-
// Process complete JSON lines
|
|
408
586
|
const lines = buffer.split('\n');
|
|
409
|
-
buffer = lines.pop() || '';
|
|
587
|
+
buffer = lines.pop() || '';
|
|
410
588
|
|
|
411
589
|
for (const line of lines) {
|
|
412
590
|
if (!line.trim()) continue;
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
591
|
+
if (!_firstOutputLogged) {
|
|
592
|
+
_firstOutputLogged = true;
|
|
593
|
+
log('INFO', `[TIMING:${chatId}] first-line +${Date.now() - _spawnAt}ms`);
|
|
594
|
+
}
|
|
595
|
+
const events = parseEventsFromLine(line);
|
|
596
|
+
for (const event of events) {
|
|
597
|
+
if (!event || !event.type) continue;
|
|
598
|
+
if (event.type === 'session' && event.sessionId) {
|
|
599
|
+
observedSessionId = String(event.sessionId);
|
|
600
|
+
if (typeof onSession === 'function') {
|
|
601
|
+
Promise.resolve(onSession(observedSessionId)).catch(() => { });
|
|
421
602
|
}
|
|
603
|
+
continue;
|
|
422
604
|
}
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
if (now - lastStatusTime >= STATUS_THROTTLE) {
|
|
448
|
-
lastStatusTime = now;
|
|
449
|
-
const emoji = TOOL_EMOJI[toolName] || TOOL_EMOJI.default;
|
|
450
|
-
|
|
451
|
-
// Resolve display name and context for MCP/Skill/Task tools
|
|
452
|
-
let displayName = toolName;
|
|
453
|
-
let displayEmoji = emoji;
|
|
454
|
-
let context = '';
|
|
455
|
-
|
|
456
|
-
if (toolName === 'Skill' && block.input?.skill) {
|
|
457
|
-
// Skill invocation: show skill name
|
|
458
|
-
context = block.input.skill;
|
|
459
|
-
} else if (toolName === 'Task' && block.input?.description) {
|
|
460
|
-
// Agent task: show description
|
|
461
|
-
context = block.input.description.slice(0, 30);
|
|
462
|
-
} else if (toolName.startsWith('mcp__')) {
|
|
463
|
-
// MCP tool: mcp__server__action → "MCP server: action"
|
|
464
|
-
const parts = toolName.split('__');
|
|
465
|
-
const server = parts[1] || 'unknown';
|
|
466
|
-
const action = parts.slice(2).join('_') || '';
|
|
467
|
-
if (server === 'playwright') {
|
|
468
|
-
displayEmoji = '🌐';
|
|
469
|
-
displayName = 'Browser';
|
|
470
|
-
context = action.replace(/_/g, ' ');
|
|
471
|
-
} else {
|
|
472
|
-
displayEmoji = '🔗';
|
|
473
|
-
displayName = `MCP:${server}`;
|
|
474
|
-
context = action.replace(/_/g, ' ').slice(0, 25);
|
|
475
|
-
}
|
|
476
|
-
} else if (block.input) {
|
|
477
|
-
// Standard tools: extract brief context
|
|
478
|
-
if (block.input.file_path) {
|
|
479
|
-
// Insert zero-width space before extension to prevent link parsing
|
|
480
|
-
const basename = path.basename(block.input.file_path);
|
|
481
|
-
const dotIdx = basename.lastIndexOf('.');
|
|
482
|
-
context = dotIdx > 0 ? basename.slice(0, dotIdx) + '\u200B' + basename.slice(dotIdx) : basename;
|
|
483
|
-
} else if (block.input.command) {
|
|
484
|
-
context = block.input.command.slice(0, 30);
|
|
485
|
-
if (block.input.command.length > 30) context += '...';
|
|
486
|
-
} else if (block.input.pattern) {
|
|
487
|
-
context = block.input.pattern.slice(0, 20);
|
|
488
|
-
} else if (block.input.query) {
|
|
489
|
-
context = block.input.query.slice(0, 25);
|
|
490
|
-
} else if (block.input.url) {
|
|
491
|
-
try {
|
|
492
|
-
context = new URL(block.input.url).hostname;
|
|
493
|
-
} catch { context = 'web'; }
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
const status = context
|
|
498
|
-
? `${displayEmoji} ${displayName}: 「${context}」`
|
|
499
|
-
: `${displayEmoji} ${displayName}...`;
|
|
500
|
-
|
|
501
|
-
if (onStatus) {
|
|
502
|
-
onStatus(status).catch(() => { });
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
}
|
|
605
|
+
if (event.type === 'error') {
|
|
606
|
+
classifiedError = event;
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
if (event.type === 'text' && event.text) {
|
|
610
|
+
finalResult = String(event.text);
|
|
611
|
+
if (waitingForTool) {
|
|
612
|
+
waitingForTool = false;
|
|
613
|
+
resetIdleTimer();
|
|
614
|
+
}
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
if (event.type === 'done') {
|
|
618
|
+
finalUsage = event.usage || null;
|
|
619
|
+
if (waitingForTool) {
|
|
620
|
+
waitingForTool = false;
|
|
621
|
+
resetIdleTimer();
|
|
622
|
+
}
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
if (event.type === 'tool_result') {
|
|
626
|
+
if (waitingForTool) {
|
|
627
|
+
waitingForTool = false;
|
|
628
|
+
resetIdleTimer();
|
|
506
629
|
}
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
if (event.type !== 'tool_use') continue;
|
|
633
|
+
|
|
634
|
+
toolCallCount++;
|
|
635
|
+
waitingForTool = true;
|
|
636
|
+
resetIdleTimer();
|
|
637
|
+
const toolName = event.toolName || 'Tool';
|
|
638
|
+
const toolInput = event.toolInput || {};
|
|
639
|
+
|
|
640
|
+
const toolEntry = { tool: toolName };
|
|
641
|
+
if (toolName === 'Skill' && toolInput.skill) toolEntry.skill = toolInput.skill;
|
|
642
|
+
else if (toolInput.command) toolEntry.context = String(toolInput.command).slice(0, 50);
|
|
643
|
+
else if (toolInput.file_path) toolEntry.context = path.basename(String(toolInput.file_path));
|
|
644
|
+
if (toolUsageLog.length < 50) toolUsageLog.push(toolEntry);
|
|
645
|
+
|
|
646
|
+
if (toolName === 'Write' && toolInput.file_path) {
|
|
647
|
+
const filePath = String(toolInput.file_path);
|
|
648
|
+
if (!writtenFiles.includes(filePath)) writtenFiles.push(filePath);
|
|
507
649
|
}
|
|
508
650
|
|
|
509
|
-
|
|
510
|
-
if (
|
|
511
|
-
|
|
651
|
+
const now = Date.now();
|
|
652
|
+
if (now - lastStatusTime < STATUS_THROTTLE) continue;
|
|
653
|
+
lastStatusTime = now;
|
|
654
|
+
|
|
655
|
+
const emoji = TOOL_EMOJI[toolName] || TOOL_EMOJI.default;
|
|
656
|
+
let displayName = toolName;
|
|
657
|
+
let displayEmoji = emoji;
|
|
658
|
+
let context = '';
|
|
659
|
+
|
|
660
|
+
if (toolName === 'Skill' && toolInput.skill) {
|
|
661
|
+
context = toolInput.skill;
|
|
662
|
+
} else if (toolName === 'Task' && toolInput.description) {
|
|
663
|
+
context = String(toolInput.description).slice(0, 30);
|
|
664
|
+
} else if (toolName.startsWith('mcp__')) {
|
|
665
|
+
const parts = toolName.split('__');
|
|
666
|
+
const server = parts[1] || 'unknown';
|
|
667
|
+
const action = parts.slice(2).join('_') || '';
|
|
668
|
+
if (server === 'playwright') {
|
|
669
|
+
displayEmoji = '🌐';
|
|
670
|
+
displayName = 'Browser';
|
|
671
|
+
context = action.replace(/_/g, ' ');
|
|
672
|
+
} else {
|
|
673
|
+
displayEmoji = '🔗';
|
|
674
|
+
displayName = `MCP:${server}`;
|
|
675
|
+
context = action.replace(/_/g, ' ').slice(0, 25);
|
|
676
|
+
}
|
|
677
|
+
} else if (toolInput.file_path) {
|
|
678
|
+
const basename = path.basename(String(toolInput.file_path));
|
|
679
|
+
const dotIdx = basename.lastIndexOf('.');
|
|
680
|
+
context = dotIdx > 0 ? basename.slice(0, dotIdx) + '\u200B' + basename.slice(dotIdx) : basename;
|
|
681
|
+
} else if (toolInput.command) {
|
|
682
|
+
context = String(toolInput.command).slice(0, 30);
|
|
683
|
+
if (String(toolInput.command).length > 30) context += '...';
|
|
684
|
+
} else if (toolInput.pattern) {
|
|
685
|
+
context = String(toolInput.pattern).slice(0, 20);
|
|
686
|
+
} else if (toolInput.query) {
|
|
687
|
+
context = String(toolInput.query).slice(0, 25);
|
|
688
|
+
} else if (toolInput.url) {
|
|
689
|
+
try { context = new URL(toolInput.url).hostname; } catch { context = 'web'; }
|
|
512
690
|
}
|
|
513
|
-
|
|
514
|
-
|
|
691
|
+
|
|
692
|
+
const status = context
|
|
693
|
+
? `${displayEmoji} ${displayName}: 「${context}」`
|
|
694
|
+
: `${displayEmoji} ${displayName}...`;
|
|
695
|
+
if (onStatus) onStatus(status).catch(() => { });
|
|
515
696
|
}
|
|
516
697
|
}
|
|
517
698
|
});
|
|
518
699
|
|
|
519
700
|
child.stderr.on('data', (data) => {
|
|
520
701
|
resetIdleTimer();
|
|
521
|
-
|
|
702
|
+
const chunk = data.toString();
|
|
703
|
+
stderr += chunk;
|
|
704
|
+
if (!classifiedError && typeof rt.classifyError === 'function') {
|
|
705
|
+
classifiedError = rt.classifyError(chunk);
|
|
706
|
+
}
|
|
522
707
|
});
|
|
523
708
|
|
|
524
709
|
child.on('close', (code) => {
|
|
710
|
+
log('INFO', `[TIMING:${chatId}] process-close code=${code} total=${Date.now() - _spawnAt}ms`);
|
|
525
711
|
clearTimeout(idleTimer);
|
|
526
712
|
clearTimeout(ceilingTimer);
|
|
527
713
|
clearTimeout(sigkillTimer);
|
|
528
714
|
clearInterval(milestoneTimer);
|
|
529
715
|
|
|
530
|
-
// Process any remaining buffer
|
|
531
716
|
if (buffer.trim()) {
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
if (event.type === '
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
717
|
+
const events = parseEventsFromLine(buffer.trim());
|
|
718
|
+
for (const event of events) {
|
|
719
|
+
if (event.type === 'text' && event.text) finalResult = String(event.text);
|
|
720
|
+
if (event.type === 'done') finalUsage = event.usage || null;
|
|
721
|
+
if (event.type === 'session' && event.sessionId) observedSessionId = String(event.sessionId);
|
|
722
|
+
if (event.type === 'error') classifiedError = event;
|
|
723
|
+
}
|
|
538
724
|
}
|
|
539
725
|
|
|
540
|
-
// Clean up active process tracking
|
|
541
726
|
const proc = chatId ? activeProcesses.get(chatId) : null;
|
|
542
727
|
const wasAborted = proc && proc.aborted;
|
|
543
|
-
if (chatId) { activeProcesses.delete(chatId); saveActivePids(); }
|
|
728
|
+
if (chatId) { activeProcesses.delete(chatId); saveActivePids(); }
|
|
544
729
|
|
|
545
730
|
if (wasAborted) {
|
|
546
|
-
|
|
547
|
-
|
|
731
|
+
finalize({ output: finalResult || null, error: 'Stopped by user', files: writtenFiles, toolUsageLog, usage: finalUsage, sessionId: observedSessionId || '' });
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
if (killed) {
|
|
548
735
|
const elapsed = Math.round((Date.now() - startTime) / 60000);
|
|
736
|
+
const idleMin = Math.max(1, Math.round(IDLE_TIMEOUT_MS / 60000));
|
|
549
737
|
const reason = killedReason === 'ceiling'
|
|
550
|
-
? `⏱ 已运行 ${elapsed}
|
|
551
|
-
: `⏱ 已
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
738
|
+
? `⏱ 已运行 ${elapsed} 分钟,达到上限(${Math.round(HARD_CEILING_MS / 60000)} 分钟)`
|
|
739
|
+
: `⏱ 已 ${idleMin} 分钟无输出,判定卡死(共运行 ${elapsed} 分钟)`;
|
|
740
|
+
finalize({ output: finalResult || null, error: reason, timedOut: true, files: writtenFiles, toolUsageLog, usage: finalUsage, sessionId: observedSessionId || '' });
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
if (code !== 0) {
|
|
744
|
+
const engineErr = classifiedError && classifiedError.message
|
|
745
|
+
? classifiedError.message
|
|
746
|
+
: (stderr || `Exit code ${code}`);
|
|
747
|
+
finalize({ output: finalResult || null, error: engineErr, errorCode: classifiedError ? classifiedError.code : undefined, files: writtenFiles, toolUsageLog, usage: finalUsage, sessionId: observedSessionId || '' });
|
|
748
|
+
return;
|
|
557
749
|
}
|
|
750
|
+
finalize({ output: finalResult || '', error: null, files: writtenFiles, toolUsageLog, usage: finalUsage, sessionId: observedSessionId || '' });
|
|
558
751
|
});
|
|
559
752
|
|
|
560
753
|
child.on('error', (err) => {
|
|
@@ -562,13 +755,28 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
|
|
|
562
755
|
clearTimeout(ceilingTimer);
|
|
563
756
|
clearTimeout(sigkillTimer);
|
|
564
757
|
clearInterval(milestoneTimer);
|
|
565
|
-
if (chatId) { activeProcesses.delete(chatId); saveActivePids(); }
|
|
566
|
-
|
|
758
|
+
if (chatId) { activeProcesses.delete(chatId); saveActivePids(); }
|
|
759
|
+
finalize({ output: null, error: formatEngineSpawnError(err, rt), files: [], toolUsageLog: [], usage: null, sessionId: '' });
|
|
567
760
|
});
|
|
568
761
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
762
|
+
try {
|
|
763
|
+
child.stdin.write(input);
|
|
764
|
+
child.stdin.end();
|
|
765
|
+
} catch (e) {
|
|
766
|
+
clearTimeout(idleTimer);
|
|
767
|
+
clearTimeout(ceilingTimer);
|
|
768
|
+
clearTimeout(sigkillTimer);
|
|
769
|
+
clearInterval(milestoneTimer);
|
|
770
|
+
if (chatId) { activeProcesses.delete(chatId); saveActivePids(); }
|
|
771
|
+
try { child.stdin.destroy(); } catch { /* ignore */ }
|
|
772
|
+
try {
|
|
773
|
+
const sig = rt.killSignal || 'SIGTERM';
|
|
774
|
+
process.kill(-child.pid, sig);
|
|
775
|
+
} catch {
|
|
776
|
+
try { child.kill(rt.killSignal || 'SIGTERM'); } catch { /* ignore */ }
|
|
777
|
+
}
|
|
778
|
+
finalize({ output: null, error: e.message, files: [], toolUsageLog: [], usage: null, sessionId: '' });
|
|
779
|
+
}
|
|
572
780
|
});
|
|
573
781
|
}
|
|
574
782
|
|
|
@@ -578,7 +786,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
|
|
|
578
786
|
if (!messageId || !session || !session.id) return;
|
|
579
787
|
const st = loadState();
|
|
580
788
|
if (!st.msg_sessions) st.msg_sessions = {};
|
|
581
|
-
st.msg_sessions[messageId] = { id: session.id, cwd: session.cwd };
|
|
789
|
+
st.msg_sessions[messageId] = { id: session.id, cwd: session.cwd, engine: session.engine || getDefaultEngine() };
|
|
582
790
|
const keys = Object.keys(st.msg_sessions);
|
|
583
791
|
if (keys.length > 200) {
|
|
584
792
|
for (const k of keys.slice(0, keys.length - 200)) delete st.msg_sessions[k];
|
|
@@ -608,6 +816,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
|
|
|
608
816
|
}
|
|
609
817
|
|
|
610
818
|
async function askClaude(bot, chatId, prompt, config, readOnly = false) {
|
|
819
|
+
const _t0 = Date.now();
|
|
611
820
|
log('INFO', `askClaude for ${chatId}: ${prompt.slice(0, 50)}`);
|
|
612
821
|
// Track interaction time for idle/sleep detection
|
|
613
822
|
if (touchInteraction) touchInteraction();
|
|
@@ -619,19 +828,24 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
|
|
|
619
828
|
saveState(_st);
|
|
620
829
|
}
|
|
621
830
|
} catch { /* non-critical */ }
|
|
622
|
-
// Send
|
|
831
|
+
// Send 🤔 ack and start typing — fire-and-forget so we don't block spawn on Telegram RTT.
|
|
832
|
+
// statusMsgId is resolved via a promise; it will be ready well before the first model output.
|
|
623
833
|
let statusMsgId = null;
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
log('ERROR', `Failed to send ack to ${chatId}: ${e.message}`);
|
|
629
|
-
}
|
|
630
|
-
await bot.sendTyping(chatId).catch(() => { });
|
|
834
|
+
// Fire-and-forget: don't await Telegram RTT before spawning the engine process.
|
|
835
|
+
// statusMsgId will be populated well before the first model output (~5s for codex).
|
|
836
|
+
(bot.sendMarkdown ? bot.sendMarkdown(chatId, '🤔') : bot.sendMessage(chatId, '🤔'))
|
|
837
|
+
.then(msg => { if (msg && msg.message_id) statusMsgId = msg.message_id; })
|
|
838
|
+
.catch(e => log('ERROR', `Failed to send ack to ${chatId}: ${e.message}`));
|
|
839
|
+
bot.sendTyping(chatId).catch(() => { });
|
|
631
840
|
const typingTimer = setInterval(() => {
|
|
632
841
|
bot.sendTyping(chatId).catch(() => { });
|
|
633
842
|
}, 4000);
|
|
634
843
|
|
|
844
|
+
// Top-level safety net: any uncaught error inside askClaude MUST clean up timers and notify user.
|
|
845
|
+
// Without this, a ReferenceError / TypeError in the routing or injection code would silently
|
|
846
|
+
// kill the handler, leaving the typing indicator spinning forever.
|
|
847
|
+
try { // ── safety-net-start ──
|
|
848
|
+
|
|
635
849
|
// Agent nickname routing: "贾维斯" / "小美,帮我..." → switch project session
|
|
636
850
|
// Strict chats (chat_agent_map bound groups) must NOT switch agents via nickname
|
|
637
851
|
const _strictAgentMap = { ...(config.telegram ? config.telegram.chat_agent_map : {}), ...(config.feishu ? config.feishu.chat_agent_map : {}) };
|
|
@@ -640,7 +854,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
|
|
|
640
854
|
if (agentMatch) {
|
|
641
855
|
const { key, proj, rest } = agentMatch;
|
|
642
856
|
const projCwd = normalizeCwd(proj.cwd);
|
|
643
|
-
attachOrCreateSession(chatId, projCwd, proj.name || key);
|
|
857
|
+
attachOrCreateSession(chatId, projCwd, proj.name || key, proj.engine ? normalizeEngineName(proj.engine) : getDefaultEngine());
|
|
644
858
|
log('INFO', `Agent switch via nickname: ${key} (${projCwd})`);
|
|
645
859
|
if (!rest) {
|
|
646
860
|
// Pure nickname call — confirm switch and stop
|
|
@@ -655,100 +869,140 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
|
|
|
655
869
|
// Skill routing: detect skill first, then decide session
|
|
656
870
|
// BUT: skip skill routing if agent addressed by nickname OR chat already has an active session
|
|
657
871
|
// (active conversation should never be hijacked by keyword-based skill matching)
|
|
658
|
-
|
|
659
|
-
const hasActiveSession = session && session.started;
|
|
660
|
-
const skill = (agentMatch || hasActiveSession) ? null : routeSkill(prompt);
|
|
872
|
+
const sessionRaw = getSession(chatId);
|
|
661
873
|
const chatIdStr = String(chatId);
|
|
662
874
|
const chatAgentMap = { ...(config.telegram ? config.telegram.chat_agent_map : {}), ...(config.feishu ? config.feishu.chat_agent_map : {}) };
|
|
663
875
|
const boundProjectKey = chatAgentMap[chatIdStr] || projectKeyFromVirtualChatId(chatIdStr);
|
|
664
876
|
const boundProject = boundProjectKey && config.projects ? config.projects[boundProjectKey] : null;
|
|
665
877
|
const boundCwd = (boundProject && boundProject.cwd) ? normalizeCwd(boundProject.cwd) : null;
|
|
878
|
+
const boundEngineName = (boundProject && boundProject.engine) ? normalizeEngineName(boundProject.engine) : getDefaultEngine();
|
|
879
|
+
|
|
880
|
+
// Engine is determined from config only — bound agent config wins, then global default.
|
|
881
|
+
const engineName = normalizeEngineName(
|
|
882
|
+
(boundProject && boundProject.engine) || getDefaultEngine()
|
|
883
|
+
);
|
|
884
|
+
const runtime = getEngineRuntime(engineName);
|
|
885
|
+
|
|
886
|
+
// hasActiveSession: does the current engine have an ongoing conversation?
|
|
887
|
+
const hasActiveSession = sessionRaw && (
|
|
888
|
+
sessionRaw.engines ? !!(sessionRaw.engines[engineName]?.started) : !!sessionRaw.started
|
|
889
|
+
);
|
|
890
|
+
const skill = (agentMatch || hasActiveSession) ? null : routeSkill(prompt);
|
|
666
891
|
|
|
667
|
-
if (!
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
} else {
|
|
687
|
-
// Non-bound chats keep legacy behavior: attach global recent, else create.
|
|
688
|
-
const recent = listRecentSessions(1);
|
|
689
|
-
if (recent.length > 0 && recent[0].sessionId && recent[0].projectPath) {
|
|
690
|
-
const target = recent[0];
|
|
691
|
-
const state = loadState();
|
|
692
|
-
state.sessions[chatId] = {
|
|
693
|
-
id: target.sessionId,
|
|
694
|
-
cwd: target.projectPath,
|
|
695
|
-
started: true,
|
|
696
|
-
};
|
|
697
|
-
saveState(state);
|
|
698
|
-
session = state.sessions[chatId];
|
|
699
|
-
log('INFO', `Auto-attached ${chatId} to recent session: ${target.sessionId.slice(0, 8)} (${path.basename(target.projectPath)})`);
|
|
700
|
-
} else {
|
|
701
|
-
session = createSession(chatId);
|
|
702
|
-
}
|
|
892
|
+
if (!sessionRaw) {
|
|
893
|
+
// No saved state for this chatId: start a fresh session.
|
|
894
|
+
// Note: daemon_state.json persists across restarts, so this only happens on truly first use
|
|
895
|
+
// or after an explicit /new command.
|
|
896
|
+
createSession(chatId, boundCwd || undefined, boundProject && boundProject.name ? boundProject.name : '', boundEngineName);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// Resolve flat view for current engine (id + started are engine-specific; cwd is shared)
|
|
900
|
+
let session = getSessionForEngine(chatId, engineName) || { cwd: boundCwd || HOME, engine: engineName, id: null, started: false };
|
|
901
|
+
session.engine = engineName; // keep local copy for Codex resume detection below
|
|
902
|
+
|
|
903
|
+
// Pre-spawn session validation: unified for all engines.
|
|
904
|
+
// Claude checks JSONL file existence; Codex checks SQLite. Same interface, different backend.
|
|
905
|
+
if (session.started && session.id && session.id !== '__continue__' && session.cwd) {
|
|
906
|
+
const valid = isEngineSessionValid(engineName, session.id, session.cwd);
|
|
907
|
+
if (!valid) {
|
|
908
|
+
log('WARN', `${engineName} session ${session.id.slice(0, 8)} invalid for ${chatId}; starting fresh ${engineName} session`);
|
|
909
|
+
await bot.sendMessage(chatId, '⚠️ 上次 session 已失效,已自动开启新 session。').catch(() => {});
|
|
910
|
+
session = createSession(chatId, session.cwd, boundProject && boundProject.name ? boundProject.name : '', engineName);
|
|
703
911
|
}
|
|
704
912
|
}
|
|
705
913
|
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
914
|
+
const daemonCfg = (config && config.daemon) || {};
|
|
915
|
+
const mentorCfg = (daemonCfg.mentor && typeof daemonCfg.mentor === 'object') ? daemonCfg.mentor : {};
|
|
916
|
+
const mentorEnabled = !!(mentorEngine && mentorCfg.enabled);
|
|
917
|
+
const excludeAgents = new Set(
|
|
918
|
+
(Array.isArray(mentorCfg.exclude_agents) ? mentorCfg.exclude_agents : [])
|
|
919
|
+
.map(x => String(x || '').trim())
|
|
920
|
+
.filter(Boolean)
|
|
921
|
+
);
|
|
922
|
+
const chatAgentKey = boundProjectKey || 'personal';
|
|
923
|
+
const mentorExcluded = excludeAgents.has(chatAgentKey);
|
|
924
|
+
let mentorSuppressed = false;
|
|
925
|
+
|
|
926
|
+
// Mentor pre-flight breaker: first hit sends a short reassurance; cooldown does not block normal answers.
|
|
927
|
+
if (mentorEnabled && !mentorExcluded) {
|
|
928
|
+
try {
|
|
929
|
+
const breaker = mentorEngine.checkEmotionBreaker(prompt, mentorCfg);
|
|
930
|
+
if (breaker && breaker.tripped) {
|
|
931
|
+
mentorSuppressed = true;
|
|
932
|
+
if (breaker.reason !== 'cooldown_active' && breaker.response) {
|
|
933
|
+
await bot.sendMessage(chatId, breaker.response).catch(() => { });
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
} catch (e) {
|
|
937
|
+
log('WARN', `Mentor breaker failed: ${e.message}`);
|
|
713
938
|
}
|
|
714
939
|
}
|
|
715
940
|
|
|
716
|
-
// Build
|
|
717
|
-
const
|
|
718
|
-
const
|
|
719
|
-
const model =
|
|
720
|
-
args.
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
941
|
+
// Build engine command — prefer per-engine model, fall back to legacy daemon.model
|
|
942
|
+
const engineModels = daemonCfg.models || {};
|
|
943
|
+
const engineModel = engineModels[runtime.name] || daemonCfg.model || runtime.defaultModel;
|
|
944
|
+
const model = (boundProject && boundProject.model) || engineModel;
|
|
945
|
+
const args = runtime.buildArgs({
|
|
946
|
+
model,
|
|
947
|
+
readOnly,
|
|
948
|
+
daemonCfg,
|
|
949
|
+
session,
|
|
950
|
+
cwd: session.cwd,
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
// Codex: write/refresh AGENTS.md = CLAUDE.md + SOUL.md on every new session.
|
|
954
|
+
// Written as a real file (not a symlink) for Windows compatibility.
|
|
955
|
+
// Refreshed each session so edits to CLAUDE.md or SOUL.md are always picked up.
|
|
956
|
+
// Codex auto-loads AGENTS.md from cwd and all parent dirs up to ~.
|
|
957
|
+
if (engineName === 'codex' && session.cwd && !session.started) {
|
|
958
|
+
try {
|
|
959
|
+
const parts = [];
|
|
960
|
+
const claudeMd = path.join(session.cwd, 'CLAUDE.md');
|
|
961
|
+
const soulMd = path.join(session.cwd, 'SOUL.md');
|
|
962
|
+
if (fs.existsSync(claudeMd)) parts.push(fs.readFileSync(claudeMd, 'utf8').trim());
|
|
963
|
+
if (fs.existsSync(soulMd)) {
|
|
964
|
+
const soulContent = fs.readFileSync(soulMd, 'utf8').trim();
|
|
965
|
+
if (soulContent) parts.push(soulContent);
|
|
966
|
+
}
|
|
967
|
+
if (parts.length > 0) {
|
|
968
|
+
fs.writeFileSync(path.join(session.cwd, 'AGENTS.md'), parts.join('\n\n'), 'utf8');
|
|
969
|
+
log('INFO', `Refreshed AGENTS.md (${parts.length} section(s)) in ${session.cwd}`);
|
|
970
|
+
}
|
|
971
|
+
} catch (e) {
|
|
972
|
+
log('WARN', `AGENTS.md refresh failed: ${e.message}`);
|
|
973
|
+
}
|
|
730
974
|
}
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
975
|
+
|
|
976
|
+
let agentHint = '';
|
|
977
|
+
if (!session.started && (boundProject || (session && session.cwd))) {
|
|
978
|
+
try {
|
|
979
|
+
// Engine-aware: Codex gets memory only (soul is already in AGENTS.md);
|
|
980
|
+
// Claude gets soul + memory (SOUL.md is not auto-loaded by Claude).
|
|
981
|
+
agentHint = buildAgentContextForEngine(
|
|
982
|
+
boundProject || { cwd: session.cwd },
|
|
983
|
+
engineName,
|
|
984
|
+
HOME,
|
|
985
|
+
).hint || '';
|
|
986
|
+
} catch (e) {
|
|
987
|
+
log('WARN', `Agent context injection failed: ${e.message}`);
|
|
988
|
+
}
|
|
737
989
|
}
|
|
738
990
|
|
|
739
991
|
// Memory & Knowledge Injection (RAG)
|
|
740
992
|
let memoryHint = '';
|
|
993
|
+
// projectKey must be declared outside the try block so the daemonHint template below can reference it.
|
|
994
|
+
const _cid0 = String(chatId);
|
|
995
|
+
const _agentMap0 = { ...(config.telegram ? config.telegram.chat_agent_map : {}), ...(config.feishu ? config.feishu.chat_agent_map : {}) };
|
|
996
|
+
const projectKey = _agentMap0[_cid0] || projectKeyFromVirtualChatId(_cid0);
|
|
741
997
|
try {
|
|
742
998
|
const memory = require('./memory');
|
|
743
|
-
const _cid = String(chatId);
|
|
744
|
-
const _cfg = loadConfig();
|
|
745
|
-
const _agentMap = { ...(_cfg.telegram ? _cfg.telegram.chat_agent_map : {}), ...(_cfg.feishu ? _cfg.feishu.chat_agent_map : {}) };
|
|
746
|
-
const projectKey = _agentMap[_cid] || projectKeyFromVirtualChatId(_cid);
|
|
747
999
|
|
|
748
|
-
// L1: NOW.md
|
|
1000
|
+
// L1: NOW.md per-agent whiteboard injection(按 projectKey 隔离,防并发冲突)
|
|
749
1001
|
if (!session.started) {
|
|
750
1002
|
try {
|
|
751
|
-
const
|
|
1003
|
+
const nowDir = path.join(HOME, '.metame', 'memory', 'now');
|
|
1004
|
+
const nowKey = projectKey || 'default';
|
|
1005
|
+
const nowPath = path.join(nowDir, `${nowKey}.md`);
|
|
752
1006
|
if (fs.existsSync(nowPath)) {
|
|
753
1007
|
const nowContent = fs.readFileSync(nowPath, 'utf8').trim();
|
|
754
1008
|
if (nowContent) {
|
|
@@ -788,11 +1042,13 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
|
|
|
788
1042
|
|
|
789
1043
|
// ZPD: build competence hint from brain profile
|
|
790
1044
|
let zdpHint = '';
|
|
1045
|
+
let brainDoc = null;
|
|
791
1046
|
if (!session.started) {
|
|
792
1047
|
try {
|
|
793
1048
|
const brainPath = path.join(HOME, '.claude_profile.yaml');
|
|
794
1049
|
if (fs.existsSync(brainPath)) {
|
|
795
1050
|
const brain = yaml.load(fs.readFileSync(brainPath, 'utf8'));
|
|
1051
|
+
brainDoc = brain;
|
|
796
1052
|
const cmap = brain && brain.user_competence_map;
|
|
797
1053
|
if (cmap && typeof cmap === 'object' && Object.keys(cmap).length > 0) {
|
|
798
1054
|
const lines = Object.entries(cmap)
|
|
@@ -803,6 +1059,12 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
|
|
|
803
1059
|
}
|
|
804
1060
|
} catch { /* non-critical */ }
|
|
805
1061
|
}
|
|
1062
|
+
if (!brainDoc) {
|
|
1063
|
+
try {
|
|
1064
|
+
const brainPath = path.join(HOME, '.claude_profile.yaml');
|
|
1065
|
+
if (fs.existsSync(brainPath)) brainDoc = yaml.load(fs.readFileSync(brainPath, 'utf8')) || {};
|
|
1066
|
+
} catch { /* ignore */ }
|
|
1067
|
+
}
|
|
806
1068
|
|
|
807
1069
|
// Inject daemon hints only on first message of a session
|
|
808
1070
|
// Task-specific rules (3-5) are injected only when isTaskIntent() returns true (~250 token saving for casual chat)
|
|
@@ -818,9 +1080,9 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
|
|
|
818
1080
|
Valid relations: tech_decision, bug_lesson, arch_convention, config_fact, config_change, workflow_rule, project_milestone
|
|
819
1081
|
Only write verified facts. Do not write speculative or process-description entries.
|
|
820
1082
|
When you observe the user is clearly expert or beginner in a domain, note it in your response and suggest: "要不要把你的 {domain} 水平 ({level}) 记录到能力雷达?"
|
|
821
|
-
5. Task handoff: When suspending a multi-step task or handing off to another agent, write current status to ~/.metame/memory/
|
|
822
|
-
\`printf '%s\\n' "## Current Task" "{task}" "" "## Progress" "{progress}" "" "## Next Step" "{next}" > ~/.metame/memory/
|
|
823
|
-
Keep it under 200 words. Clear it when the task is fully complete by running: \`> ~/.metame/memory/
|
|
1083
|
+
5. Task handoff: When suspending a multi-step task or handing off to another agent, write current status to ~/.metame/memory/now/${projectKey || 'default'}.md using:
|
|
1084
|
+
\`mkdir -p ~/.metame/memory/now && printf '%s\\n' "## Current Task" "{task}" "" "## Progress" "{progress}" "" "## Next Step" "{next}" > ~/.metame/memory/now/${projectKey || 'default'}.md\`
|
|
1085
|
+
Keep it under 200 words. Clear it when the task is fully complete by running: \`> ~/.metame/memory/now/${projectKey || 'default'}.md\`` : '';
|
|
824
1086
|
daemonHint = `\n\n[System hints - DO NOT mention these to user:
|
|
825
1087
|
1. Daemon config: The ONLY config is ~/.metame/daemon.yaml (never edit daemon-default.yaml). Auto-reloads on change.
|
|
826
1088
|
2. File sending: User is on MOBILE. When they ask to see/download a file:
|
|
@@ -829,7 +1091,9 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
|
|
|
829
1091
|
- Add at END of response: [[FILE:/absolute/path/to/file]]
|
|
830
1092
|
- Keep response brief: "请查收~! [[FILE:/path/to/file]]"
|
|
831
1093
|
- Multiple files: use multiple [[FILE:...]] tags${zdpHint ? '\n Explanation depth (ZPD):\n' + zdpHint : ''}${taskRules}]`;
|
|
832
|
-
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
daemonHint = adaptDaemonHintForEngine(daemonHint, runtime.name);
|
|
833
1097
|
|
|
834
1098
|
const routedPrompt = skill ? `/${skill} ${prompt}` : prompt;
|
|
835
1099
|
|
|
@@ -865,13 +1129,71 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
|
|
|
865
1129
|
} catch { /* non-critical */ }
|
|
866
1130
|
}
|
|
867
1131
|
|
|
868
|
-
//
|
|
869
|
-
|
|
870
|
-
|
|
1132
|
+
// Mentor context hook: inject after memoryHint, before langGuard.
|
|
1133
|
+
let mentorHint = '';
|
|
1134
|
+
if (mentorEnabled && !mentorExcluded && !mentorSuppressed) {
|
|
1135
|
+
try {
|
|
1136
|
+
const signals = collectRecentSessionSignals(session.id, 6);
|
|
1137
|
+
let skeleton = null;
|
|
1138
|
+
if (sessionAnalytics && typeof sessionAnalytics.extractSkeleton === 'function') {
|
|
1139
|
+
const file = findSessionFile(session.id);
|
|
1140
|
+
if (file && fs.existsSync(file)) {
|
|
1141
|
+
const st = fs.statSync(file);
|
|
1142
|
+
if (st.size <= 2 * 1024 * 1024) {
|
|
1143
|
+
skeleton = sessionAnalytics.extractSkeleton(file);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
const zone = skeleton && mentorEngine.computeZone
|
|
1148
|
+
? mentorEngine.computeZone(skeleton).zone
|
|
1149
|
+
: 'stretch';
|
|
1150
|
+
const sessionState = {
|
|
1151
|
+
zone,
|
|
1152
|
+
recentMessages: signals.recentMessages,
|
|
1153
|
+
cwd: session.cwd,
|
|
1154
|
+
skeleton,
|
|
1155
|
+
sessionStartTime: signals.sessionStartTime || new Date().toISOString(),
|
|
1156
|
+
topic: String(prompt || '').slice(0, 120),
|
|
1157
|
+
currentTopic: String(prompt || '').slice(0, 120),
|
|
1158
|
+
lastUserMessage: String(prompt || '').slice(0, 200),
|
|
1159
|
+
};
|
|
1160
|
+
const built = mentorEngine.buildMentorPrompt(sessionState, brainDoc || {}, mentorCfg);
|
|
1161
|
+
if (built && String(built).trim()) mentorHint = `\n\n${String(built).trim()}`;
|
|
1162
|
+
|
|
1163
|
+
// Collect reflection debt: if user returns to same project+topic, inject recall prompt.
|
|
1164
|
+
// Suppressed by quiet_until (user explicitly asked for silence), but NOT by expert skip
|
|
1165
|
+
// (even experts may not have reviewed AI-generated code).
|
|
1166
|
+
const quietUntil = brainDoc && brainDoc.growth ? brainDoc.growth.quiet_until : null;
|
|
1167
|
+
const quietMs = quietUntil ? new Date(quietUntil).getTime() : 0;
|
|
1168
|
+
const isQuiet = quietMs && quietMs > Date.now();
|
|
1169
|
+
if (!isQuiet && mentorEngine.collectDebt) {
|
|
1170
|
+
const info = deriveProjectInfo(session && session.cwd ? session.cwd : '');
|
|
1171
|
+
const projectId = info && info.project_id ? info.project_id : '';
|
|
1172
|
+
if (projectId) {
|
|
1173
|
+
const debt = mentorEngine.collectDebt(projectId, String(prompt || '').slice(0, 120));
|
|
1174
|
+
if (debt && debt.prompt) {
|
|
1175
|
+
mentorHint += `\n\n[Reflection debt] ${debt.prompt}`;
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
} catch (e) {
|
|
1180
|
+
log('WARN', `Mentor prompt build failed: ${e.message}`);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// Language guard: only inject on first message of a new session to avoid
|
|
1185
|
+
// linearly growing token cost on every turn in long conversations.
|
|
1186
|
+
// Claude Code preserves session context, so the guard persists after initial injection.
|
|
1187
|
+
const langGuard = session.started
|
|
1188
|
+
? ''
|
|
1189
|
+
: '\n\n[Respond in Simplified Chinese (简体中文) only. NEVER switch to Korean, Japanese, or other languages regardless of tool output or context language.]';
|
|
1190
|
+
const fullPrompt = routedPrompt + daemonHint + agentHint + macAutomationHint + summaryHint + memoryHint + mentorHint + langGuard;
|
|
871
1191
|
|
|
872
|
-
// Git checkpoint before Claude modifies files (for /undo)
|
|
873
|
-
//
|
|
874
|
-
|
|
1192
|
+
// Git checkpoint before Claude modifies files (for /undo).
|
|
1193
|
+
// Run async (fire-and-forget) to avoid blocking Claude spawn by ~600ms.
|
|
1194
|
+
// Completes well before Claude's first file write (~2s after spawn).
|
|
1195
|
+
(gitCheckpointAsync || gitCheckpoint)(session.cwd, prompt).catch?.(() => {});
|
|
1196
|
+
log('INFO', `[TIMING:${chatId}] pre-spawn +${Date.now() - _t0}ms (engine:${runtime.name} started:${session.started})`);
|
|
875
1197
|
|
|
876
1198
|
// Use streaming mode to show progress
|
|
877
1199
|
// Telegram: edit status msg in-place; Feishu: edit or fallback to new messages
|
|
@@ -893,9 +1215,103 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
|
|
|
893
1215
|
} catch { /* ignore status update failures */ }
|
|
894
1216
|
};
|
|
895
1217
|
|
|
896
|
-
|
|
1218
|
+
const wasCodexResumeAttempt = runtime.name === 'codex'
|
|
1219
|
+
&& !!(session && session.started && session.id && session.id !== '__continue__');
|
|
1220
|
+
const onSession = async (nextSessionId) => {
|
|
1221
|
+
const safeNextId = String(nextSessionId || '').trim();
|
|
1222
|
+
if (!safeNextId) return;
|
|
1223
|
+
const prevSessionId = session && session.id ? String(session.id) : '';
|
|
1224
|
+
const wasStarted = !!(session && session.started);
|
|
1225
|
+
session = {
|
|
1226
|
+
...session,
|
|
1227
|
+
id: safeNextId,
|
|
1228
|
+
engine: runtime.name,
|
|
1229
|
+
started: true,
|
|
1230
|
+
};
|
|
1231
|
+
await patchSessionSerialized(chatId, (cur) => {
|
|
1232
|
+
const engines = { ...(cur.engines || {}) };
|
|
1233
|
+
engines[runtime.name] = { ...(engines[runtime.name] || {}), id: safeNextId, started: true };
|
|
1234
|
+
return { ...cur, cwd: session.cwd || cur.cwd || HOME, engines };
|
|
1235
|
+
});
|
|
1236
|
+
if (runtime.name === 'codex' && wasStarted && prevSessionId && prevSessionId !== safeNextId && prevSessionId !== '__continue__') {
|
|
1237
|
+
log('WARN', `Codex thread migrated for ${chatId}: ${prevSessionId.slice(0, 8)} -> ${safeNextId.slice(0, 8)}`);
|
|
1238
|
+
}
|
|
1239
|
+
};
|
|
1240
|
+
|
|
1241
|
+
let output, error, errorCode, files, toolUsageLog, timedOut, usage, sessionId;
|
|
897
1242
|
try {
|
|
898
|
-
({
|
|
1243
|
+
({
|
|
1244
|
+
output,
|
|
1245
|
+
error,
|
|
1246
|
+
errorCode,
|
|
1247
|
+
timedOut,
|
|
1248
|
+
files,
|
|
1249
|
+
toolUsageLog,
|
|
1250
|
+
usage,
|
|
1251
|
+
sessionId,
|
|
1252
|
+
} = await spawnClaudeStreaming(
|
|
1253
|
+
args,
|
|
1254
|
+
fullPrompt,
|
|
1255
|
+
session.cwd,
|
|
1256
|
+
onStatus,
|
|
1257
|
+
600000,
|
|
1258
|
+
chatId,
|
|
1259
|
+
boundProjectKey || '',
|
|
1260
|
+
runtime,
|
|
1261
|
+
onSession,
|
|
1262
|
+
));
|
|
1263
|
+
|
|
1264
|
+
if (sessionId) await onSession(sessionId);
|
|
1265
|
+
|
|
1266
|
+
if (shouldRetryCodexResumeFallback({
|
|
1267
|
+
runtimeName: runtime.name,
|
|
1268
|
+
wasResumeAttempt: wasCodexResumeAttempt,
|
|
1269
|
+
output,
|
|
1270
|
+
error,
|
|
1271
|
+
errorCode,
|
|
1272
|
+
canRetry: canRetryCodexResume(chatId),
|
|
1273
|
+
})) {
|
|
1274
|
+
markCodexResumeRetried(chatId);
|
|
1275
|
+
log('WARN', `Codex resume failed for ${chatId}, retrying once with fresh exec: ${String(error).slice(0, 120)}`);
|
|
1276
|
+
// Notify user explicitly — silent context loss is worse than a visible warning.
|
|
1277
|
+
await bot.sendMessage(chatId, '⚠️ Codex session 已过期,上下文丢失。正在以全新 session 重试,请在回复后补充必要背景。').catch(() => {});
|
|
1278
|
+
session = createSession(
|
|
1279
|
+
chatId,
|
|
1280
|
+
session.cwd,
|
|
1281
|
+
boundProject && boundProject.name ? boundProject.name : '',
|
|
1282
|
+
'codex'
|
|
1283
|
+
);
|
|
1284
|
+
const retryArgs = runtime.buildArgs({
|
|
1285
|
+
model,
|
|
1286
|
+
readOnly,
|
|
1287
|
+
daemonCfg,
|
|
1288
|
+
session,
|
|
1289
|
+
cwd: session.cwd,
|
|
1290
|
+
});
|
|
1291
|
+
// Prepend a context-loss marker so Codex knows this is a fresh session mid-conversation.
|
|
1292
|
+
const retryPrompt = `[Note: previous Codex session expired and could not be resumed. Treating this as a new session. User message follows:]\n\n${fullPrompt}`;
|
|
1293
|
+
({
|
|
1294
|
+
output,
|
|
1295
|
+
error,
|
|
1296
|
+
errorCode,
|
|
1297
|
+
timedOut,
|
|
1298
|
+
files,
|
|
1299
|
+
toolUsageLog,
|
|
1300
|
+
usage,
|
|
1301
|
+
sessionId,
|
|
1302
|
+
} = await spawnClaudeStreaming(
|
|
1303
|
+
retryArgs,
|
|
1304
|
+
retryPrompt,
|
|
1305
|
+
session.cwd,
|
|
1306
|
+
onStatus,
|
|
1307
|
+
600000,
|
|
1308
|
+
chatId,
|
|
1309
|
+
boundProjectKey || '',
|
|
1310
|
+
runtime,
|
|
1311
|
+
onSession,
|
|
1312
|
+
));
|
|
1313
|
+
if (sessionId) await onSession(sessionId);
|
|
1314
|
+
}
|
|
899
1315
|
} catch (spawnErr) {
|
|
900
1316
|
clearInterval(typingTimer);
|
|
901
1317
|
if (statusMsgId && bot.deleteMessage) bot.deleteMessage(chatId, statusMsgId).catch(() => { });
|
|
@@ -921,6 +1337,24 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
|
|
|
921
1337
|
bot.deleteMessage(chatId, statusMsgId).catch(() => { });
|
|
922
1338
|
}
|
|
923
1339
|
|
|
1340
|
+
// Mentor post-flight debt registration (intense mode only).
|
|
1341
|
+
if (mentorEnabled && !mentorExcluded && !mentorSuppressed && mentorEngine && typeof mentorEngine.registerDebt === 'function' && output) {
|
|
1342
|
+
try {
|
|
1343
|
+
const mode = resolveMentorMode(mentorCfg);
|
|
1344
|
+
if (mode === 'intense') {
|
|
1345
|
+
const codeLines = countCodeLines(output);
|
|
1346
|
+
if (codeLines > 30) {
|
|
1347
|
+
const info = deriveProjectInfo(session && session.cwd ? session.cwd : '');
|
|
1348
|
+
const projectId = info && info.project_id ? info.project_id : 'proj_default';
|
|
1349
|
+
mentorEngine.registerDebt(projectId, String(prompt || '').slice(0, 120), codeLines);
|
|
1350
|
+
log('INFO', `[MENTOR] Registered reflection debt (${projectId}, lines=${codeLines})`);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
} catch (e) {
|
|
1354
|
+
log('WARN', `Mentor post-flight failed: ${e.message}`);
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
|
|
924
1358
|
// When Claude completes with no text output (pure tool work), send a done notice
|
|
925
1359
|
if (output === '' && !error) {
|
|
926
1360
|
// Special case: if dispatch_to was called, send a "forwarded" confirmation
|
|
@@ -934,36 +1368,39 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
|
|
|
934
1368
|
const doneMsg = await bot.sendMessage(chatId, `✉️ 已转达给 ${names},处理中…`);
|
|
935
1369
|
if (doneMsg && doneMsg.message_id && session) trackMsgSession(doneMsg.message_id, session);
|
|
936
1370
|
const wasNew = !session.started;
|
|
937
|
-
if (wasNew) markSessionStarted(chatId);
|
|
1371
|
+
if (wasNew) markSessionStarted(chatId, engineName);
|
|
938
1372
|
return { ok: true };
|
|
939
1373
|
}
|
|
940
1374
|
const filesDesc = files && files.length > 0 ? `\n修改了 ${files.length} 个文件` : '';
|
|
941
1375
|
const doneMsg = await bot.sendMessage(chatId, `✅ 完成${filesDesc}`);
|
|
942
1376
|
if (doneMsg && doneMsg.message_id && session) trackMsgSession(doneMsg.message_id, session);
|
|
943
1377
|
const wasNew = !session.started;
|
|
944
|
-
if (wasNew) markSessionStarted(chatId);
|
|
1378
|
+
if (wasNew) markSessionStarted(chatId, engineName);
|
|
945
1379
|
return { ok: true };
|
|
946
1380
|
}
|
|
947
1381
|
|
|
948
1382
|
if (output) {
|
|
1383
|
+
if (runtime.name === 'codex') _codexResumeRetryTs.delete(String(chatId));
|
|
949
1384
|
// Detect provider/model errors disguised as output (e.g., "model not found", API errors)
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
1385
|
+
if (runtime.name === 'claude') {
|
|
1386
|
+
const activeProvCheck = providerMod ? providerMod.getActiveName() : 'anthropic';
|
|
1387
|
+
const builtinModelsCheck = ['sonnet', 'opus', 'haiku'];
|
|
1388
|
+
const looksLikeError = output.length < 300 && /\b(not found|invalid model|unauthorized|401|403|404|error|failed)\b/i.test(output);
|
|
1389
|
+
if (looksLikeError && (activeProvCheck !== 'anthropic' || !builtinModelsCheck.includes(model))) {
|
|
1390
|
+
try {
|
|
1391
|
+
config = fallbackToDefaultProvider(`output looks like error for ${activeProvCheck}/${model}`);
|
|
1392
|
+
await bot.sendMessage(chatId, `⚠️ ${activeProvCheck}/${model} 疑似失败,已回退到 anthropic/opus\n输出: ${output.slice(0, 150)}`);
|
|
1393
|
+
} catch (fbErr) {
|
|
1394
|
+
log('ERROR', `Fallback failed: ${fbErr.message}`);
|
|
1395
|
+
await bot.sendMarkdown(chatId, output);
|
|
1396
|
+
}
|
|
1397
|
+
return { ok: false, error: output };
|
|
960
1398
|
}
|
|
961
|
-
return { ok: false, error: output };
|
|
962
1399
|
}
|
|
963
1400
|
|
|
964
1401
|
// Mark session as started after first successful call
|
|
965
1402
|
const wasNew = !session.started;
|
|
966
|
-
if (wasNew) markSessionStarted(chatId);
|
|
1403
|
+
if (wasNew) markSessionStarted(chatId, engineName);
|
|
967
1404
|
|
|
968
1405
|
const estimated = Math.ceil((prompt.length + output.length) / 4);
|
|
969
1406
|
const chatCategory = classifyChatUsage(chatId, {
|
|
@@ -1019,57 +1456,102 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
|
|
|
1019
1456
|
}
|
|
1020
1457
|
|
|
1021
1458
|
// Auto-name: if this was the first message and session has no name, generate one
|
|
1022
|
-
if (wasNew && !getSessionName(session.id)) {
|
|
1459
|
+
if (runtime.name === 'claude' && wasNew && !getSessionName(session.id)) {
|
|
1023
1460
|
autoNameSession(chatId, session.id, prompt, session.cwd).catch(() => { });
|
|
1024
1461
|
}
|
|
1462
|
+
|
|
1463
|
+
// Auto-refresh memory-snapshot.md for this agent on first session message (fire-and-forget)
|
|
1464
|
+
if (wasNew && boundProject && boundProject.agent_id) {
|
|
1465
|
+
setImmediate(async () => {
|
|
1466
|
+
try {
|
|
1467
|
+
const memory = require('./memory');
|
|
1468
|
+
const pKey = boundProjectKey || '';
|
|
1469
|
+
const sessions = memory.recentSessions({ limit: 5, project: pKey });
|
|
1470
|
+
const factsRaw = memory.searchFacts('', { limit: 10, project: pKey });
|
|
1471
|
+
const facts = Array.isArray(factsRaw) ? factsRaw : [];
|
|
1472
|
+
memory.close();
|
|
1473
|
+
const snapshotContent = buildMemorySnapshotContent(sessions, facts);
|
|
1474
|
+
const agentId = boundProject.agent_id;
|
|
1475
|
+
if (refreshMemorySnapshot(agentId, snapshotContent, HOME)) {
|
|
1476
|
+
log('DEBUG', `[AGENT] Memory snapshot refreshed for ${agentId}`);
|
|
1477
|
+
}
|
|
1478
|
+
} catch { /* non-critical — memory module may not be available */ }
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1025
1481
|
return { ok: !timedOut };
|
|
1026
1482
|
} else {
|
|
1027
1483
|
const errMsg = error || 'Unknown error';
|
|
1028
|
-
|
|
1484
|
+
const userErrMsg = (errorCode === 'AUTH_REQUIRED' || errorCode === 'RATE_LIMIT')
|
|
1485
|
+
? errMsg
|
|
1486
|
+
: `Error: ${errMsg.slice(0, 200)}`;
|
|
1487
|
+
log('ERROR', `ask${runtime.name === 'codex' ? 'Codex' : 'Claude'} failed for ${chatId}: ${errMsg.slice(0, 300)} (${errorCode || 'NO_CODE'})`);
|
|
1029
1488
|
|
|
1030
|
-
// If session not found (expired/deleted), create new and retry once
|
|
1031
|
-
if (errMsg.includes('not found') || errMsg.includes('No session') || errMsg.includes('already in use')) {
|
|
1489
|
+
// If session not found (expired/deleted), create new and retry once (Claude path)
|
|
1490
|
+
if (runtime.name === 'claude' && (errMsg.includes('not found') || errMsg.includes('No session') || errMsg.includes('already in use'))) {
|
|
1032
1491
|
log('WARN', `Session ${session.id} unusable (${errMsg.includes('already in use') ? 'locked' : 'not found'}), creating new`);
|
|
1033
|
-
session = createSession(chatId, session.cwd);
|
|
1034
|
-
|
|
1035
|
-
const retryArgs =
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
const retry = await spawnClaudeStreaming(
|
|
1492
|
+
session = createSession(chatId, session.cwd, '', runtime.name);
|
|
1493
|
+
|
|
1494
|
+
const retryArgs = runtime.buildArgs({
|
|
1495
|
+
model,
|
|
1496
|
+
readOnly,
|
|
1497
|
+
daemonCfg,
|
|
1498
|
+
session,
|
|
1499
|
+
cwd: session.cwd,
|
|
1500
|
+
});
|
|
1501
|
+
|
|
1502
|
+
const retry = await spawnClaudeStreaming(
|
|
1503
|
+
retryArgs,
|
|
1504
|
+
fullPrompt,
|
|
1505
|
+
session.cwd,
|
|
1506
|
+
onStatus,
|
|
1507
|
+
600000,
|
|
1508
|
+
chatId,
|
|
1509
|
+
boundProjectKey || '',
|
|
1510
|
+
runtime,
|
|
1511
|
+
onSession,
|
|
1512
|
+
);
|
|
1513
|
+
if (retry.sessionId) await onSession(retry.sessionId);
|
|
1044
1514
|
if (retry.output) {
|
|
1045
|
-
markSessionStarted(chatId);
|
|
1515
|
+
markSessionStarted(chatId, runtime.name);
|
|
1046
1516
|
const { markedFiles: retryMarked, cleanOutput: retryClean } = parseFileMarkers(retry.output);
|
|
1047
1517
|
await bot.sendMarkdown(chatId, retryClean);
|
|
1048
1518
|
await sendFileButtons(bot, chatId, mergeFileCollections(retryMarked, retry.files));
|
|
1049
1519
|
return { ok: true };
|
|
1050
1520
|
} else {
|
|
1051
1521
|
log('ERROR', `askClaude retry failed: ${(retry.error || '').slice(0, 200)}`);
|
|
1052
|
-
try { await bot.sendMessage(chatId,
|
|
1522
|
+
try { await bot.sendMessage(chatId, userErrMsg); } catch { /* */ }
|
|
1053
1523
|
return { ok: false, error: retry.error || errMsg };
|
|
1054
1524
|
}
|
|
1055
1525
|
} else {
|
|
1056
|
-
// Auto-fallback: if custom provider/model fails, revert to anthropic + opus
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1526
|
+
// Auto-fallback: if custom provider/model fails, revert to anthropic + opus (Claude path only)
|
|
1527
|
+
if (runtime.name === 'claude') {
|
|
1528
|
+
const activeProv = providerMod ? providerMod.getActiveName() : 'anthropic';
|
|
1529
|
+
const builtinModels = ENGINE_MODEL_CONFIG.claude.options;
|
|
1530
|
+
if (activeProv !== 'anthropic' || !builtinModels.includes(model)) {
|
|
1531
|
+
try {
|
|
1532
|
+
config = fallbackToDefaultProvider(`${activeProv}/${model} error: ${errMsg.slice(0, 100)}`);
|
|
1533
|
+
await bot.sendMessage(chatId, `⚠️ ${activeProv}/${model} 失败,已回退到 anthropic/opus\n原因: ${errMsg.slice(0, 100)}`);
|
|
1534
|
+
} catch (fallbackErr) {
|
|
1535
|
+
log('ERROR', `Fallback failed: ${fallbackErr.message}`);
|
|
1536
|
+
try { await bot.sendMessage(chatId, userErrMsg); } catch { /* */ }
|
|
1537
|
+
}
|
|
1538
|
+
} else {
|
|
1539
|
+
try { await bot.sendMessage(chatId, userErrMsg); } catch { /* */ }
|
|
1066
1540
|
}
|
|
1067
1541
|
} else {
|
|
1068
|
-
try { await bot.sendMessage(chatId,
|
|
1542
|
+
try { await bot.sendMessage(chatId, userErrMsg); } catch { /* */ }
|
|
1069
1543
|
}
|
|
1070
|
-
return { ok: false, error: errMsg };
|
|
1544
|
+
return { ok: false, error: errMsg, errorCode };
|
|
1071
1545
|
}
|
|
1072
1546
|
}
|
|
1547
|
+
|
|
1548
|
+
} catch (fatalErr) { // ── safety-net-catch ──
|
|
1549
|
+
clearInterval(typingTimer);
|
|
1550
|
+
if (statusMsgId && bot.deleteMessage) await bot.deleteMessage(chatId, statusMsgId).catch(() => { });
|
|
1551
|
+
log('FATAL', `[askClaude] Uncaught error for ${chatId}: ${fatalErr.message}\n${fatalErr.stack}`);
|
|
1552
|
+
try { await bot.sendMessage(chatId, `❌ 内部错误: ${fatalErr.message}`); } catch { /* */ }
|
|
1553
|
+
return { ok: false, error: fatalErr.message };
|
|
1554
|
+
}
|
|
1073
1555
|
}
|
|
1074
1556
|
|
|
1075
1557
|
return {
|
|
@@ -1079,6 +1561,15 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
|
|
|
1079
1561
|
spawnClaudeStreaming,
|
|
1080
1562
|
trackMsgSession,
|
|
1081
1563
|
askClaude,
|
|
1564
|
+
_private: {
|
|
1565
|
+
patchSessionSerialized,
|
|
1566
|
+
shouldRetryCodexResumeFallback,
|
|
1567
|
+
formatEngineSpawnError,
|
|
1568
|
+
adaptDaemonHintForEngine,
|
|
1569
|
+
canRetryCodexResume,
|
|
1570
|
+
markCodexResumeRetried,
|
|
1571
|
+
CODEX_RESUME_RETRY_WINDOW_MS,
|
|
1572
|
+
},
|
|
1082
1573
|
};
|
|
1083
1574
|
}
|
|
1084
1575
|
|