metame-cli 1.4.12 → 1.4.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,275 @@
1
+ 'use strict';
2
+
3
+ function createOpsCommandHandler(deps) {
4
+ const {
5
+ fs,
6
+ path,
7
+ spawn,
8
+ execSync,
9
+ log,
10
+ messageQueue,
11
+ activeProcesses,
12
+ getSession,
13
+ listCheckpoints,
14
+ cpDisplayLabel,
15
+ truncateSessionToCheckpoint,
16
+ findSessionFile,
17
+ clearSessionFileCache,
18
+ cpExtractTimestamp,
19
+ gitCheckpoint,
20
+ cleanupCheckpoints,
21
+ getNoSleepProcess,
22
+ setNoSleepProcess,
23
+ } = deps;
24
+
25
+ function clearMessageQueue(chatId) {
26
+ if (messageQueue.has(chatId)) {
27
+ const q = messageQueue.get(chatId);
28
+ if (q.timer) clearTimeout(q.timer);
29
+ messageQueue.delete(chatId);
30
+ }
31
+ }
32
+
33
+ function interruptActiveProcess(chatId) {
34
+ const proc = activeProcesses.get(chatId);
35
+ if (proc && proc.child) {
36
+ proc.aborted = true;
37
+ try { process.kill(-proc.child.pid, 'SIGINT'); } catch { proc.child.kill('SIGINT'); }
38
+ }
39
+ }
40
+
41
+ async function handleOpsCommand(ctx) {
42
+ const { bot, chatId, text } = ctx;
43
+
44
+ if (text === '/undo' || text.startsWith('/undo ')) {
45
+ clearMessageQueue(chatId);
46
+ interruptActiveProcess(chatId);
47
+
48
+ const session = getSession(chatId);
49
+ if (!session || !session.id) {
50
+ await bot.sendMessage(chatId, 'No active session to undo.');
51
+ return true;
52
+ }
53
+
54
+ const cwd = session.cwd;
55
+ const arg = text.slice(5).trim();
56
+
57
+ // /undo <hash> — git reset to specific checkpoint (advanced usage)
58
+ if (arg) {
59
+ if (!cwd) {
60
+ await bot.sendMessage(chatId, '❌ 当前 session 无工作目录,无法执行 git undo');
61
+ return true;
62
+ }
63
+ let isGitRepo = false;
64
+ try { execSync('git rev-parse --is-inside-work-tree', { cwd, stdio: 'ignore', timeout: 3000 }); isGitRepo = true; } catch { }
65
+ const checkpoints = isGitRepo ? listCheckpoints(cwd) : [];
66
+ const match = checkpoints.find(cp => cp.hash.startsWith(arg));
67
+ if (!match) {
68
+ await bot.sendMessage(chatId, `❌ 未找到 checkpoint: ${arg}`);
69
+ return true;
70
+ }
71
+ try {
72
+ let diffFiles = '';
73
+ try { diffFiles = execSync(`git diff --name-only HEAD ${match.hash}`, { cwd, encoding: 'utf8', timeout: 5000 }).trim(); } catch { }
74
+ execSync(`git reset --hard ${match.hash}`, { cwd, stdio: 'ignore', timeout: 10000 });
75
+ // Truncate context to checkpoint time (covers multi-turn rollback)
76
+ truncateSessionToCheckpoint(session.id, match.message);
77
+ const fileList = diffFiles ? diffFiles.split('\n').map(f => path.basename(f)).join(', ') : '';
78
+ const fileCount = diffFiles ? diffFiles.split('\n').length : 0;
79
+ let msg = `⏪ 已回退到 ${cpDisplayLabel(match.message)}`;
80
+ if (fileCount > 0) msg += `\n📁 ${fileCount} 个文件恢复: ${fileList}`;
81
+ log('INFO', `/undo <hash> executed for ${chatId}: reset to ${match.hash.slice(0, 8)}, files=${fileCount}`);
82
+ await bot.sendMessage(chatId, msg);
83
+ cleanupCheckpoints(cwd);
84
+ } catch (e) {
85
+ await bot.sendMessage(chatId, `❌ Undo failed: ${e.message}`);
86
+ }
87
+ return true;
88
+ }
89
+
90
+ // /undo (no arg) — show recent user messages as buttons to pick rollback point
91
+ try {
92
+ const sessionFile = findSessionFile(session.id);
93
+ if (!sessionFile) {
94
+ await bot.sendMessage(chatId, '⚠️ 找不到 session 文件,无法列出历史消息');
95
+ return true;
96
+ }
97
+ const lines = fs.readFileSync(sessionFile, 'utf8').split('\n').filter(l => l.trim());
98
+
99
+ // Helper: extract real user text (skip tool_result entries and system annotations)
100
+ const extractUserText = (obj) => {
101
+ try {
102
+ const content = obj.message?.content;
103
+ if (typeof content === 'string') return content.trim();
104
+ if (Array.isArray(content)) {
105
+ // Skip entries that are purely tool results
106
+ if (content.every(c => c.type === 'tool_result')) return '';
107
+ // Find first text item that isn't a system annotation (exact patterns only)
108
+ const SYSTEM_ANNOTATION = /^\[(Image source|Pasted|Attachment|File):/;
109
+ const item = content.find(c => c.type === 'text' && c.text && !SYSTEM_ANNOTATION.test(c.text));
110
+ return item?.text?.trim() || '';
111
+ }
112
+ } catch { }
113
+ return '';
114
+ };
115
+
116
+ // Collect only real human-written user messages (skip tool results / annotations)
117
+ const userMsgs = [];
118
+ for (let i = 0; i < lines.length; i++) {
119
+ try {
120
+ const obj = JSON.parse(lines[i]);
121
+ if (obj.type === 'user' && obj.message?.role === 'user') {
122
+ const msgText = extractUserText(obj);
123
+ if (msgText) userMsgs.push({ idx: i, obj, text: msgText });
124
+ }
125
+ } catch { }
126
+ }
127
+ if (userMsgs.length === 0) {
128
+ await bot.sendMessage(chatId, '⚠️ 没有可回退的历史消息');
129
+ return true;
130
+ }
131
+
132
+ // Show last 10 (most recent first)
133
+ const recent = userMsgs.slice(-10).reverse();
134
+ if (bot.sendButtons) {
135
+ const buttons = recent.map(({ idx, text: msgText, obj }) => {
136
+ const label = msgText.replace(/\n/g, ' ').slice(0, 28);
137
+ let timeLabel = '';
138
+ if (obj.timestamp) {
139
+ const d = new Date(obj.timestamp);
140
+ if (!isNaN(d)) timeLabel = ` (${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')})`;
141
+ }
142
+ return [{ text: `⏪ ${label}${timeLabel}`, callback_data: `/undo_to ${idx}` }];
143
+ });
144
+ await bot.sendButtons(chatId, `↩️ 回退到哪条消息之前?(共 ${userMsgs.length} 轮)`, buttons);
145
+ } else {
146
+ let msg = '回退到哪条消息之前?回复 /undo_to <序号>\n\n';
147
+ recent.forEach(({ idx, text: msgText }) => {
148
+ msg += `[${idx}] ${msgText.slice(0, 40)}\n`;
149
+ });
150
+ await bot.sendMessage(chatId, msg);
151
+ }
152
+ } catch (e) {
153
+ await bot.sendMessage(chatId, `❌ Undo failed: ${e.message}`);
154
+ }
155
+ return true;
156
+ }
157
+
158
+ // /undo_to <lineIdx> — restore session to before the message at given JSONL line index
159
+ if (text.startsWith('/undo_to ')) {
160
+ const idx = parseInt(text.slice(9).trim(), 10);
161
+ if (isNaN(idx) || idx < 0) {
162
+ await bot.sendMessage(chatId, '❌ 无效的回退序号');
163
+ return true;
164
+ }
165
+
166
+ // Kill any running task
167
+ clearMessageQueue(chatId);
168
+ interruptActiveProcess(chatId);
169
+
170
+ const session2 = getSession(chatId);
171
+ if (!session2 || !session2.id) {
172
+ await bot.sendMessage(chatId, 'No active session.');
173
+ return true;
174
+ }
175
+
176
+ try {
177
+ const sessionFile2 = findSessionFile(session2.id);
178
+ if (!sessionFile2) { await bot.sendMessage(chatId, '❌ 找不到 session 文件'); return true; }
179
+
180
+ const lines2 = fs.readFileSync(sessionFile2, 'utf8').split('\n').filter(l => l.trim());
181
+ if (idx >= lines2.length) {
182
+ await bot.sendMessage(chatId, '❌ 序号超出范围,session 已变化,请重新 /undo');
183
+ return true;
184
+ }
185
+
186
+ // Get target message text + timestamp for display and git matching
187
+ let targetMsg = '';
188
+ let targetTs = 0;
189
+ try {
190
+ const obj = JSON.parse(lines2[idx]);
191
+ const content = obj.message?.content;
192
+ if (typeof content === 'string') targetMsg = content;
193
+ else if (Array.isArray(content)) targetMsg = content.find(c => c.type === 'text')?.text || '';
194
+ if (obj.timestamp) targetTs = new Date(obj.timestamp).getTime() || 0;
195
+ } catch { }
196
+
197
+ // Git reset first (before JSONL truncation) so failure leaves state consistent
198
+ let gitMsg2 = '';
199
+ const cwd2 = session2.cwd;
200
+ if (cwd2) {
201
+ let isGitRepo2 = false;
202
+ try { execSync('git rev-parse --is-inside-work-tree', { cwd: cwd2, stdio: 'ignore', timeout: 3000 }); isGitRepo2 = true; } catch { }
203
+ if (isGitRepo2) {
204
+ // Exclude safety checkpoints from matching to avoid confusion
205
+ const checkpoints2 = listCheckpoints(cwd2).filter(cp => !cp.message.includes('[metame-safety]'));
206
+ const cpMatch = targetTs
207
+ ? checkpoints2.find(cp => { const t = new Date(cpExtractTimestamp(cp.message) || 0).getTime(); return t > 0 && t <= targetTs; })
208
+ : checkpoints2[0];
209
+ if (cpMatch) {
210
+ let diffFiles2 = '';
211
+ try { diffFiles2 = execSync(`git diff --name-only HEAD ${cpMatch.hash}`, { cwd: cwd2, encoding: 'utf8', timeout: 5000 }).trim(); } catch { }
212
+ if (diffFiles2) {
213
+ // Save current state with distinct prefix (excluded from normal /undo list)
214
+ gitCheckpoint(cwd2, `[metame-safety] before rollback to: ${targetMsg.slice(0, 40)}`);
215
+ execSync(`git reset --hard ${cpMatch.hash}`, { cwd: cwd2, stdio: 'ignore', timeout: 10000 });
216
+ gitMsg2 = `\n📁 ${diffFiles2.split('\n').length} 个文件已恢复`;
217
+ cleanupCheckpoints(cwd2);
218
+ }
219
+ }
220
+ }
221
+ }
222
+
223
+ // Truncate JSONL after git reset succeeds
224
+ const kept2 = lines2.slice(0, idx);
225
+ fs.writeFileSync(sessionFile2, kept2.length ? kept2.join('\n') + '\n' : '', 'utf8');
226
+ clearSessionFileCache(session2.id);
227
+ const removed2 = lines2.length - kept2.length;
228
+
229
+ const preview = targetMsg.replace(/\n/g, ' ').slice(0, 30) || `行 ${idx}`;
230
+ log('INFO', `/undo_to ${idx} for ${chatId}: removed=${removed2} lines${gitMsg2 ? ', ' + gitMsg2.trim() : ''}`);
231
+ await bot.sendMessage(chatId, `⏪ 已回退到「${preview}」之前\n🧠 上下文回滚 ${removed2} 行${gitMsg2}`);
232
+ } catch (e) {
233
+ await bot.sendMessage(chatId, `❌ 回退失败: ${e.message}`);
234
+ }
235
+ return true;
236
+ }
237
+
238
+ if (text === '/nosleep') {
239
+ if (process.platform !== 'darwin') {
240
+ await bot.sendMessage(chatId, '❌ /nosleep 仅支持 macOS');
241
+ return true;
242
+ }
243
+ if (getNoSleepProcess()) {
244
+ // Turn off — kill caffeinate
245
+ try { getNoSleepProcess().kill(); } catch { /* already dead */ }
246
+ setNoSleepProcess(null);
247
+ log('INFO', 'Caffeinate stopped — system sleep re-enabled');
248
+ await bot.sendMessage(chatId, '😴 已关闭防睡眠,系统恢复正常休眠');
249
+ } else {
250
+ // Turn on — spawn caffeinate (prevent display+idle+system sleep)
251
+ try {
252
+ const p = spawn('caffeinate', ['-dis'], {
253
+ detached: true,
254
+ stdio: 'ignore',
255
+ });
256
+ p.unref();
257
+ p.on('exit', () => { setNoSleepProcess(null); });
258
+ setNoSleepProcess(p);
259
+ log('INFO', 'Caffeinate started — preventing system sleep');
260
+ await bot.sendMessage(chatId, '☕ 防睡眠已开启,合盖不休眠\n再次 /nosleep 关闭');
261
+ } catch (e) {
262
+ log('ERROR', `Failed to start caffeinate: ${e.message}`);
263
+ await bot.sendMessage(chatId, `❌ 启动失败: ${e.message}`);
264
+ }
265
+ }
266
+ return true;
267
+ }
268
+
269
+ return false;
270
+ }
271
+
272
+ return { handleOpsCommand };
273
+ }
274
+
275
+ module.exports = { createOpsCommandHandler };
@@ -0,0 +1,133 @@
1
+ 'use strict';
2
+
3
+ function createPidManager(deps) {
4
+ const { fs, execSync, PID_FILE, log } = deps;
5
+
6
+ function killExistingDaemon() {
7
+ if (!fs.existsSync(PID_FILE)) return;
8
+ try {
9
+ const oldPid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10);
10
+ if (oldPid && oldPid !== process.pid) {
11
+ process.kill(oldPid, 'SIGTERM');
12
+ log('INFO', `Killed existing daemon (PID: ${oldPid})`);
13
+ for (let i = 0; i < 10; i++) {
14
+ try { process.kill(oldPid, 0); } catch { break; }
15
+ execSync('sleep 0.5', { stdio: 'ignore' });
16
+ }
17
+ }
18
+ } catch {
19
+ // Process doesn't exist or already dead
20
+ }
21
+ try { fs.unlinkSync(PID_FILE); } catch { }
22
+ }
23
+
24
+ function writePid() {
25
+ fs.writeFileSync(PID_FILE, String(process.pid), 'utf8');
26
+ }
27
+
28
+ function cleanPid() {
29
+ try {
30
+ if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE);
31
+ } catch { /* ignore */ }
32
+ }
33
+
34
+ return { killExistingDaemon, writePid, cleanPid };
35
+ }
36
+
37
+ function setupRuntimeWatchers(deps) {
38
+ const {
39
+ fs,
40
+ path,
41
+ CONFIG_FILE,
42
+ METAME_DIR,
43
+ loadConfig,
44
+ loadConfigStrict,
45
+ refreshLogMaxSize,
46
+ startHeartbeat,
47
+ getAllTasks,
48
+ log,
49
+ notifyFn,
50
+ adminNotifyFn,
51
+ activeProcesses,
52
+ getConfig,
53
+ setConfig,
54
+ getHeartbeatTimer,
55
+ setHeartbeatTimer,
56
+ onRestartRequested,
57
+ } = deps;
58
+
59
+ function reloadConfig() {
60
+ const strict = typeof loadConfigStrict === 'function'
61
+ ? loadConfigStrict()
62
+ : { ok: true, config: loadConfig() };
63
+ if (!strict.ok) return { success: false, error: strict.error || 'Failed to read config' };
64
+ const newConfig = strict.config;
65
+ setConfig(newConfig);
66
+ refreshLogMaxSize(newConfig);
67
+ const timer = getHeartbeatTimer();
68
+ if (timer) clearInterval(timer);
69
+ setHeartbeatTimer(startHeartbeat(newConfig, notifyFn));
70
+ const { general, project } = getAllTasks(newConfig);
71
+ const totalCount = general.length + project.length;
72
+ log('INFO', `Config reloaded: ${totalCount} tasks (${project.length} in projects)`);
73
+ return { success: true, tasks: totalCount };
74
+ }
75
+
76
+ let reloadDebounce = null;
77
+ fs.watchFile(CONFIG_FILE, { interval: 2000 }, (curr, prev) => {
78
+ if (curr.mtimeMs === prev.mtimeMs) return;
79
+ if (reloadDebounce) clearTimeout(reloadDebounce);
80
+ reloadDebounce = setTimeout(() => {
81
+ log('INFO', 'daemon.yaml changed on disk — auto-reloading config');
82
+ const r = reloadConfig();
83
+ if (r.success) {
84
+ log('INFO', `Auto-reload OK: ${r.tasks} tasks`);
85
+ adminNotifyFn(`🔄 Config auto-reloaded. ${r.tasks} heartbeat tasks active.`).catch(() => { });
86
+ } else {
87
+ log('ERROR', `Auto-reload failed: ${r.error}`);
88
+ }
89
+ }, 1000);
90
+ });
91
+
92
+ const daemonScript = path.join(METAME_DIR, 'daemon.js');
93
+ const startTime = Date.now();
94
+ let restartDebounce = null;
95
+ let pendingRestart = false;
96
+
97
+ fs.watchFile(daemonScript, { interval: 3000 }, (curr, prev) => {
98
+ if (curr.mtimeMs === prev.mtimeMs) return;
99
+ if (Date.now() - startTime < 10000) return;
100
+ if (restartDebounce) clearTimeout(restartDebounce);
101
+ restartDebounce = setTimeout(() => {
102
+ if (activeProcesses.size > 0) {
103
+ log('INFO', `daemon.js changed on disk — deferring restart (${activeProcesses.size} active task(s))`);
104
+ pendingRestart = true;
105
+ } else {
106
+ log('INFO', 'daemon.js changed on disk — exiting for restart...');
107
+ onRestartRequested();
108
+ }
109
+ }, 2000);
110
+ });
111
+
112
+ const origDelete = activeProcesses.delete.bind(activeProcesses);
113
+ activeProcesses.delete = function (key) {
114
+ const result = origDelete(key);
115
+ if (pendingRestart && activeProcesses.size === 0) {
116
+ log('INFO', 'All tasks completed — executing deferred restart...');
117
+ setTimeout(onRestartRequested, 500);
118
+ }
119
+ return result;
120
+ };
121
+
122
+ function stop() {
123
+ fs.unwatchFile(CONFIG_FILE);
124
+ fs.unwatchFile(daemonScript);
125
+ if (reloadDebounce) clearTimeout(reloadDebounce);
126
+ if (restartDebounce) clearTimeout(restartDebounce);
127
+ activeProcesses.delete = origDelete;
128
+ }
129
+
130
+ return { reloadConfig, stop };
131
+ }
132
+
133
+ module.exports = { createPidManager, setupRuntimeWatchers };