metame-cli 1.4.12 → 1.4.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +205 -57
- package/package.json +2 -2
- package/scripts/daemon-admin-commands.js +365 -0
- package/scripts/daemon-agent-commands.js +467 -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 +808 -0
- package/scripts/daemon-command-router.js +395 -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 +543 -4308
- package/scripts/memory-extract.js +15 -9
- package/scripts/session-analytics.js +116 -0
- package/scripts/test_daemon.js +1407 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function createExecCommandHandler(deps) {
|
|
4
|
+
const {
|
|
5
|
+
fs,
|
|
6
|
+
path,
|
|
7
|
+
spawn,
|
|
8
|
+
HOME,
|
|
9
|
+
checkCooldown,
|
|
10
|
+
activeProcesses,
|
|
11
|
+
messageQueue,
|
|
12
|
+
findTask,
|
|
13
|
+
checkPrecondition,
|
|
14
|
+
buildProfilePreamble,
|
|
15
|
+
spawnClaudeAsync,
|
|
16
|
+
recordTokens,
|
|
17
|
+
loadState,
|
|
18
|
+
saveState,
|
|
19
|
+
getSession,
|
|
20
|
+
getSessionName,
|
|
21
|
+
createSession,
|
|
22
|
+
findSessionFile,
|
|
23
|
+
loadConfig,
|
|
24
|
+
} = deps;
|
|
25
|
+
|
|
26
|
+
async function handleExecCommand(ctx) {
|
|
27
|
+
const { bot, chatId, text, config, executeTaskByName } = ctx;
|
|
28
|
+
|
|
29
|
+
if (text.startsWith('/run ')) {
|
|
30
|
+
const cd = checkCooldown(chatId);
|
|
31
|
+
if (!cd.ok) { await bot.sendMessage(chatId, `Cooldown: ${cd.wait}s`); return true; }
|
|
32
|
+
if (activeProcesses.has(chatId)) {
|
|
33
|
+
await bot.sendMessage(chatId, '⏳ 任务进行中,/stop 中断');
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
const taskName = text.slice(5).trim();
|
|
37
|
+
const task = findTask(config, taskName);
|
|
38
|
+
if (!task) { await bot.sendMessage(chatId, `❌ Task "${taskName}" not found`); return true; }
|
|
39
|
+
|
|
40
|
+
// Script tasks: quick, run inline
|
|
41
|
+
if (task.type === 'script') {
|
|
42
|
+
await bot.sendMessage(chatId, `Running: ${taskName}...`);
|
|
43
|
+
const result = executeTaskByName(taskName);
|
|
44
|
+
await bot.sendMessage(chatId, result.success ? `${taskName}\n\n${result.output}` : `Error: ${result.error}`);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Claude tasks: run async via spawn
|
|
49
|
+
const precheck = checkPrecondition(task);
|
|
50
|
+
if (!precheck.pass) {
|
|
51
|
+
await bot.sendMessage(chatId, `${taskName}: skipped (no activity)`);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
const preamble = buildProfilePreamble();
|
|
55
|
+
let taskPrompt = task.prompt;
|
|
56
|
+
if (precheck.context) taskPrompt += `\n\n以下是相关原始数据:\n\`\`\`\n${precheck.context}\n\`\`\``;
|
|
57
|
+
const fullPrompt = preamble + taskPrompt;
|
|
58
|
+
const model = task.model || 'haiku';
|
|
59
|
+
const claudeArgs = ['-p', '--model', model, '--dangerously-skip-permissions'];
|
|
60
|
+
for (const t of (task.allowedTools || [])) claudeArgs.push('--allowedTools', t);
|
|
61
|
+
|
|
62
|
+
await bot.sendMessage(chatId, `Running: ${taskName} (${model})...`);
|
|
63
|
+
const { output, error } = await spawnClaudeAsync(claudeArgs, fullPrompt, HOME, 120000);
|
|
64
|
+
if (error) {
|
|
65
|
+
await bot.sendMessage(chatId, `❌ ${taskName}: ${error}`);
|
|
66
|
+
} else {
|
|
67
|
+
const est = Math.ceil((fullPrompt.length + (output || '').length) / 4);
|
|
68
|
+
recordTokens(loadState(), est);
|
|
69
|
+
const st = loadState();
|
|
70
|
+
st.tasks[taskName] = { last_run: new Date().toISOString(), status: 'success', output_preview: (output || '').slice(0, 200) };
|
|
71
|
+
saveState(st);
|
|
72
|
+
let reply = output || '(no output)';
|
|
73
|
+
if (reply.length > 4000) reply = reply.slice(0, 4000) + '\n... (truncated)';
|
|
74
|
+
await bot.sendMessage(chatId, `${taskName}\n\n${reply}`);
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (text === '/stop') {
|
|
80
|
+
// Clear message queue (don't process queued messages after stop)
|
|
81
|
+
if (messageQueue.has(chatId)) {
|
|
82
|
+
const q = messageQueue.get(chatId);
|
|
83
|
+
if (q.timer) clearTimeout(q.timer);
|
|
84
|
+
messageQueue.delete(chatId);
|
|
85
|
+
}
|
|
86
|
+
const proc = activeProcesses.get(chatId);
|
|
87
|
+
if (proc && proc.child) {
|
|
88
|
+
proc.aborted = true;
|
|
89
|
+
try { process.kill(-proc.child.pid, 'SIGINT'); } catch { proc.child.kill('SIGINT'); }
|
|
90
|
+
await bot.sendMessage(chatId, '⏹ Stopping Claude...');
|
|
91
|
+
} else {
|
|
92
|
+
await bot.sendMessage(chatId, 'No active task to stop.');
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// /quit — restart session process (reloads MCP/config, keeps same session)
|
|
98
|
+
if (text === '/quit') {
|
|
99
|
+
// Stop running task if any
|
|
100
|
+
if (messageQueue.has(chatId)) {
|
|
101
|
+
const q = messageQueue.get(chatId);
|
|
102
|
+
if (q.timer) clearTimeout(q.timer);
|
|
103
|
+
messageQueue.delete(chatId);
|
|
104
|
+
}
|
|
105
|
+
const proc = activeProcesses.get(chatId);
|
|
106
|
+
if (proc && proc.child) {
|
|
107
|
+
proc.aborted = true;
|
|
108
|
+
try { process.kill(-proc.child.pid, 'SIGINT'); } catch { proc.child.kill('SIGINT'); }
|
|
109
|
+
}
|
|
110
|
+
const session = getSession(chatId);
|
|
111
|
+
const name = session ? getSessionName(session.id) : null;
|
|
112
|
+
const label = name || (session ? session.id.slice(0, 8) : 'none');
|
|
113
|
+
await bot.sendMessage(chatId, `🔄 Session restarted. MCP/config reloaded.\n📁 ${session ? path.basename(session.cwd) : '~'} [${label}]`);
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// /compact — compress current session context to save tokens
|
|
118
|
+
if (text === '/compact') {
|
|
119
|
+
const session = getSession(chatId);
|
|
120
|
+
if (!session || !session.started) {
|
|
121
|
+
await bot.sendMessage(chatId, '❌ No active session to compact.');
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
await bot.sendMessage(chatId, '🗜 Compacting session...');
|
|
125
|
+
|
|
126
|
+
// Step 1: Read conversation from JSONL (fast, no Claude needed)
|
|
127
|
+
const jsonlPath = findSessionFile(session.id);
|
|
128
|
+
if (!jsonlPath) {
|
|
129
|
+
await bot.sendMessage(chatId, '❌ Session file not found.');
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
const messages = [];
|
|
133
|
+
try {
|
|
134
|
+
const lines = fs.readFileSync(jsonlPath, 'utf8').split('\n').filter(Boolean);
|
|
135
|
+
for (const line of lines) {
|
|
136
|
+
try {
|
|
137
|
+
const obj = JSON.parse(line);
|
|
138
|
+
if (obj.type === 'user' || obj.type === 'assistant') {
|
|
139
|
+
const msg = obj.message || {};
|
|
140
|
+
const content = msg.content;
|
|
141
|
+
let textContent = '';
|
|
142
|
+
if (typeof content === 'string') {
|
|
143
|
+
textContent = content;
|
|
144
|
+
} else if (Array.isArray(content)) {
|
|
145
|
+
textContent = content
|
|
146
|
+
.filter(c => c.type === 'text')
|
|
147
|
+
.map(c => c.text || '')
|
|
148
|
+
.join(' ');
|
|
149
|
+
}
|
|
150
|
+
if (textContent.trim()) {
|
|
151
|
+
messages.push({ role: obj.type, text: textContent.trim() });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} catch { /* skip malformed lines */ }
|
|
155
|
+
}
|
|
156
|
+
} catch (e) {
|
|
157
|
+
await bot.sendMessage(chatId, `❌ Cannot read session: ${e.message}`);
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (messages.length === 0) {
|
|
162
|
+
await bot.sendMessage(chatId, '❌ No messages found in session.');
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Step 2: Build a truncated conversation digest (keep under ~20k chars for haiku)
|
|
167
|
+
const MAX_DIGEST = 20000;
|
|
168
|
+
let digest = '';
|
|
169
|
+
// Take messages from newest to oldest until we hit the limit
|
|
170
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
171
|
+
const m = messages[i];
|
|
172
|
+
const prefix = m.role === 'user' ? 'USER' : 'ASSISTANT';
|
|
173
|
+
const entry = `[${prefix}]: ${m.text.slice(0, 800)}\n\n`;
|
|
174
|
+
if (digest.length + entry.length > MAX_DIGEST) break;
|
|
175
|
+
digest = entry + digest;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Step 3: Summarize with haiku (new process, no --resume, fast)
|
|
179
|
+
const daemonCfg = loadConfig().daemon || {};
|
|
180
|
+
const compactArgs = ['-p', '--model', 'haiku', '--no-session-persistence'];
|
|
181
|
+
if (daemonCfg.dangerously_skip_permissions) compactArgs.push('--dangerously-skip-permissions');
|
|
182
|
+
const { output, error } = await spawnClaudeAsync(
|
|
183
|
+
compactArgs,
|
|
184
|
+
`Summarize the following conversation into a compact context document. Include: (1) what was being worked on, (2) key decisions made, (3) current state, (4) pending tasks. Be concise but preserve ALL important technical context (file names, function names, variable names, specific values). Output ONLY the summary.\n\n--- CONVERSATION ---\n${digest}`,
|
|
185
|
+
session.cwd,
|
|
186
|
+
60000
|
|
187
|
+
);
|
|
188
|
+
if (error || !output) {
|
|
189
|
+
await bot.sendMessage(chatId, `❌ Compact failed: ${error || 'no output'}`);
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Step 4: Create new session with the summary
|
|
194
|
+
const model = daemonCfg.model || 'opus';
|
|
195
|
+
const oldName = getSessionName(session.id);
|
|
196
|
+
const newSession = createSession(chatId, session.cwd, oldName ? oldName + ' (compacted)' : '');
|
|
197
|
+
const initArgs = ['-p', '--session-id', newSession.id, '--model', model];
|
|
198
|
+
if (daemonCfg.dangerously_skip_permissions) initArgs.push('--dangerously-skip-permissions');
|
|
199
|
+
const preamble = buildProfilePreamble();
|
|
200
|
+
const initPrompt = preamble + `Here is the context from our previous session (compacted):\n\n${output}\n\nContext loaded. Ready to continue.`;
|
|
201
|
+
const { error: initErr } = await spawnClaudeAsync(initArgs, initPrompt, session.cwd, 60000);
|
|
202
|
+
if (initErr) {
|
|
203
|
+
await bot.sendMessage(chatId, `⚠️ Summary saved but new session init failed: ${initErr}`);
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
// Mark as started
|
|
207
|
+
const state2 = loadState();
|
|
208
|
+
if (state2.sessions[chatId]) {
|
|
209
|
+
state2.sessions[chatId].started = true;
|
|
210
|
+
saveState(state2);
|
|
211
|
+
}
|
|
212
|
+
const tokenEst = Math.round(output.length / 3.5);
|
|
213
|
+
await bot.sendMessage(chatId, `✅ Compacted! ~${tokenEst} tokens of context carried over.\nNew session: ${newSession.id.slice(0, 8)}`);
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// /publish <otp> — npm publish with OTP (zero latency, no Claude)
|
|
218
|
+
if (text.startsWith('/publish ')) {
|
|
219
|
+
const otp = text.slice(9).trim();
|
|
220
|
+
if (!otp || !/^\d{6}$/.test(otp)) {
|
|
221
|
+
await bot.sendMessage(chatId, '用法: /publish 123456');
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
const session = getSession(chatId);
|
|
225
|
+
const cwd = session?.cwd || HOME;
|
|
226
|
+
await bot.sendMessage(chatId, `📦 npm publish --otp=${otp} ...`);
|
|
227
|
+
try {
|
|
228
|
+
const child = spawn('npm', ['publish', `--otp=${otp}`], { cwd, timeout: 60000 });
|
|
229
|
+
let stdout = '';
|
|
230
|
+
let stderr = '';
|
|
231
|
+
child.stdout.on('data', d => { stdout += d; });
|
|
232
|
+
child.stderr.on('data', d => { stderr += d; });
|
|
233
|
+
const exitCode = await new Promise((resolve) => {
|
|
234
|
+
child.on('close', (code) => resolve(code));
|
|
235
|
+
child.on('error', () => resolve(1));
|
|
236
|
+
});
|
|
237
|
+
const output = (stdout + stderr).trim();
|
|
238
|
+
if (exitCode === 0 && output.includes('+ metame-cli@')) {
|
|
239
|
+
const ver = output.match(/metame-cli@([\d.]+)/);
|
|
240
|
+
await bot.sendMessage(chatId, `✅ Published${ver ? ' v' + ver[1] : ''}!`);
|
|
241
|
+
} else {
|
|
242
|
+
let msg = output.slice(0, 2000) || `(exit code ${exitCode}, no output)`;
|
|
243
|
+
await bot.sendMessage(chatId, `❌ ${msg}`);
|
|
244
|
+
}
|
|
245
|
+
} catch (e) {
|
|
246
|
+
await bot.sendMessage(chatId, `❌ ${e.message}`);
|
|
247
|
+
}
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// /sh [command] — direct shell execution (emergency lifeline)
|
|
252
|
+
if (text === '/sh' || text.startsWith('/sh ')) {
|
|
253
|
+
const command = text.slice(3).trim();
|
|
254
|
+
if (!command) {
|
|
255
|
+
if (bot.sendButtons) {
|
|
256
|
+
await bot.sendButtons(chatId, '💻 应急命令', [
|
|
257
|
+
[{ text: '📝 最近日志', callback_data: '/sh tail -30 ~/.metame/daemon.log' }],
|
|
258
|
+
[{ text: '📋 原始配置', callback_data: '/sh cat ~/.metame/daemon.yaml' }],
|
|
259
|
+
]);
|
|
260
|
+
} else {
|
|
261
|
+
await bot.sendMessage(chatId, '用法: /sh <command>');
|
|
262
|
+
}
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
const child = spawn('sh', ['-c', command], { timeout: 30000 });
|
|
267
|
+
let stdout = '';
|
|
268
|
+
let stderr = '';
|
|
269
|
+
child.stdout.on('data', d => { stdout += d; });
|
|
270
|
+
child.stderr.on('data', d => { stderr += d; });
|
|
271
|
+
await new Promise((resolve) => {
|
|
272
|
+
child.on('close', resolve);
|
|
273
|
+
child.on('error', resolve);
|
|
274
|
+
});
|
|
275
|
+
let output = (stdout + stderr).trim() || '(no output)';
|
|
276
|
+
if (output.length > 4000) output = output.slice(0, 4000) + '\n... (truncated)';
|
|
277
|
+
await bot.sendMessage(chatId, `💻 $ ${command}\n${output}`);
|
|
278
|
+
} catch (e) {
|
|
279
|
+
await bot.sendMessage(chatId, `❌ ${e.message}`);
|
|
280
|
+
}
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return { handleExecCommand };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
module.exports = { createExecCommandHandler };
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function createFileBrowser(deps) {
|
|
4
|
+
const {
|
|
5
|
+
fs,
|
|
6
|
+
path,
|
|
7
|
+
HOME,
|
|
8
|
+
shortenPath,
|
|
9
|
+
expandPath,
|
|
10
|
+
} = deps;
|
|
11
|
+
|
|
12
|
+
const CONTENT_EXTENSIONS = new Set([
|
|
13
|
+
'.md', '.txt', '.rtf',
|
|
14
|
+
'.doc', '.docx', '.pdf', '.odt',
|
|
15
|
+
'.wav', '.mp3', '.m4a', '.ogg', '.flac',
|
|
16
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg',
|
|
17
|
+
'.mp4', '.mov', '.avi', '.webm',
|
|
18
|
+
'.csv', '.xlsx', '.xls',
|
|
19
|
+
'.html', '.htm',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const fileCache = new Map();
|
|
23
|
+
const FILE_CACHE_TTL = 1800000; // 30 minutes
|
|
24
|
+
|
|
25
|
+
const DIR_LIST_TYPE_EMOJI = {
|
|
26
|
+
'.md': '📄', '.txt': '📄', '.pdf': '📕',
|
|
27
|
+
'.js': '⚙️', '.ts': '⚙️', '.py': '🐍', '.json': '📋', '.yaml': '📋', '.yml': '📋',
|
|
28
|
+
'.png': '🖼️', '.jpg': '🖼️', '.jpeg': '🖼️', '.gif': '🖼️', '.svg': '🖼️', '.webp': '🖼️',
|
|
29
|
+
'.wav': '🎵', '.mp3': '🎵', '.m4a': '🎵', '.flac': '🎵',
|
|
30
|
+
'.mp4': '🎬', '.mov': '🎬',
|
|
31
|
+
'.csv': '📊', '.xlsx': '📊',
|
|
32
|
+
'.html': '🌐', '.css': '🎨',
|
|
33
|
+
'.sh': '💻', '.bash': '💻',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function normalizeCwd(p) {
|
|
37
|
+
return expandPath(p).replace(/^~/, HOME);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isContentFile(filePath) {
|
|
41
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
42
|
+
return CONTENT_EXTENSIONS.has(ext);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function cacheFile(filePath) {
|
|
46
|
+
const shortId = Math.random().toString(36).slice(2, 10);
|
|
47
|
+
fileCache.set(shortId, { path: filePath, expires: Date.now() + FILE_CACHE_TTL });
|
|
48
|
+
return shortId;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getCachedFile(shortId) {
|
|
52
|
+
const entry = fileCache.get(shortId);
|
|
53
|
+
if (!entry) return null;
|
|
54
|
+
if (Date.now() > entry.expires) {
|
|
55
|
+
fileCache.delete(shortId);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return entry.path;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function sendFileButtons(bot, chatId, files) {
|
|
62
|
+
if (!bot.sendButtons || files.size === 0) return;
|
|
63
|
+
const validFiles = [...files].filter(f => fs.existsSync(f));
|
|
64
|
+
if (validFiles.length === 0) return;
|
|
65
|
+
const buttons = validFiles.map(filePath => {
|
|
66
|
+
const shortId = cacheFile(filePath);
|
|
67
|
+
return [{ text: `📎 ${path.basename(filePath)}`, callback_data: `/file ${shortId}` }];
|
|
68
|
+
});
|
|
69
|
+
await bot.sendButtons(chatId, '📂 文件:', buttons);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function sendDirPicker(bot, chatId, mode, title) {
|
|
73
|
+
await sendBrowse(bot, chatId, mode, HOME, title);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function sendBrowse(bot, chatId, mode, dirPath, title, page = 0) {
|
|
77
|
+
try {
|
|
78
|
+
const stat = fs.statSync(dirPath);
|
|
79
|
+
if (!stat.isDirectory()) throw new Error('Not a directory');
|
|
80
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
|
|
81
|
+
.filter(e => e.isDirectory() && !e.name.startsWith('.'))
|
|
82
|
+
.map(e => e.name)
|
|
83
|
+
.sort();
|
|
84
|
+
const parent = path.dirname(dirPath);
|
|
85
|
+
const displayPath = dirPath.replace(HOME, '~');
|
|
86
|
+
|
|
87
|
+
const cmd = mode === 'new' ? '/new'
|
|
88
|
+
: mode === 'bind' ? '/agent-bind-dir'
|
|
89
|
+
: mode === 'agent-new' ? '/agent-dir'
|
|
90
|
+
: '/cd';
|
|
91
|
+
|
|
92
|
+
if (bot.sendButtons) {
|
|
93
|
+
const PAGE_SIZE = 10;
|
|
94
|
+
const totalPages = Math.max(1, Math.ceil(entries.length / PAGE_SIZE));
|
|
95
|
+
const safePage = Math.max(0, Math.min(page, totalPages - 1));
|
|
96
|
+
const start = safePage * PAGE_SIZE;
|
|
97
|
+
const pageSubdirs = entries.slice(start, start + PAGE_SIZE);
|
|
98
|
+
const buttons = [];
|
|
99
|
+
buttons.push([{ text: `✓ 选择「${displayPath}」`, callback_data: `${cmd} ${shortenPath(dirPath)}` }]);
|
|
100
|
+
for (const name of pageSubdirs) {
|
|
101
|
+
const full = path.join(dirPath, name);
|
|
102
|
+
buttons.push([{ text: `📁 ${name}`, callback_data: `/browse ${mode} ${shortenPath(full)}` }]);
|
|
103
|
+
}
|
|
104
|
+
const nav = [];
|
|
105
|
+
if (safePage > 0) nav.push({ text: '← 上页', callback_data: `/browse ${mode} ${shortenPath(dirPath)} ${safePage - 1}` });
|
|
106
|
+
if (safePage < totalPages - 1) nav.push({ text: '下页 →', callback_data: `/browse ${mode} ${shortenPath(dirPath)} ${safePage + 1}` });
|
|
107
|
+
if (nav.length) buttons.push(nav);
|
|
108
|
+
if (parent !== dirPath) {
|
|
109
|
+
buttons.push([{ text: '⬆ 上级目录', callback_data: `/browse ${mode} ${shortenPath(parent)}` }]);
|
|
110
|
+
}
|
|
111
|
+
const header = title ? `${title}\n📂 ${displayPath}` : `📂 ${displayPath}`;
|
|
112
|
+
await bot.sendButtons(chatId, header, buttons);
|
|
113
|
+
} else {
|
|
114
|
+
let msg = `📂 ${displayPath}\n\n`;
|
|
115
|
+
pageSubdirs.forEach((name, i) => {
|
|
116
|
+
msg += `${safePage * PAGE_SIZE + i + 1}. ${name}/\n /browse ${mode} ${path.join(dirPath, name)}\n`;
|
|
117
|
+
});
|
|
118
|
+
msg += `\n✓ 选择此目录: ${cmd} ${dirPath}`;
|
|
119
|
+
if (parent !== dirPath) msg += `\n⬆ 上级: /browse ${mode} ${parent}`;
|
|
120
|
+
await bot.sendMessage(chatId, msg);
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
await bot.sendMessage(chatId, `无法读取目录: ${dirPath}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function sendDirListing(bot, chatId, baseDir, arg) {
|
|
128
|
+
let targetDir = baseDir;
|
|
129
|
+
let globFilter = null;
|
|
130
|
+
|
|
131
|
+
if (arg) {
|
|
132
|
+
if (arg.includes('*')) {
|
|
133
|
+
globFilter = arg;
|
|
134
|
+
} else {
|
|
135
|
+
const sub = path.resolve(baseDir, arg);
|
|
136
|
+
if (fs.existsSync(sub) && fs.statSync(sub).isDirectory()) {
|
|
137
|
+
targetDir = sub;
|
|
138
|
+
} else {
|
|
139
|
+
await bot.sendMessage(chatId, `❌ Not found: ${arg}`);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
let entries = fs.readdirSync(targetDir, { withFileTypes: true });
|
|
147
|
+
if (globFilter) {
|
|
148
|
+
const pattern = globFilter.replace(/\./g, '\\.').replace(/\*/g, '.*');
|
|
149
|
+
const re = new RegExp('^' + pattern + '$', 'i');
|
|
150
|
+
entries = entries.filter(e => re.test(e.name));
|
|
151
|
+
}
|
|
152
|
+
entries.sort((a, b) => {
|
|
153
|
+
if (a.isDirectory() && !b.isDirectory()) return -1;
|
|
154
|
+
if (!a.isDirectory() && b.isDirectory()) return 1;
|
|
155
|
+
return a.name.localeCompare(b.name);
|
|
156
|
+
});
|
|
157
|
+
entries = entries.filter(e => !e.name.startsWith('.'));
|
|
158
|
+
|
|
159
|
+
if (entries.length === 0) {
|
|
160
|
+
await bot.sendMessage(chatId, `📁 ${path.basename(targetDir)}/\n(empty)`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const allButtons = [];
|
|
165
|
+
const MAX_BUTTONS = 20;
|
|
166
|
+
|
|
167
|
+
for (const entry of entries.slice(0, MAX_BUTTONS)) {
|
|
168
|
+
const fullPath = path.join(targetDir, entry.name);
|
|
169
|
+
if (entry.isDirectory()) {
|
|
170
|
+
const cbPath = fullPath.length <= 58 ? fullPath : shortenPath(fullPath);
|
|
171
|
+
allButtons.push([{ text: `📂 ${entry.name}/`, callback_data: `/list ${cbPath}` }]);
|
|
172
|
+
} else {
|
|
173
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
174
|
+
const emoji = DIR_LIST_TYPE_EMOJI[ext] || '📎';
|
|
175
|
+
let size = '';
|
|
176
|
+
try {
|
|
177
|
+
const stat = fs.statSync(fullPath);
|
|
178
|
+
const bytes = stat.size;
|
|
179
|
+
if (bytes < 1024) size = ` ${bytes}B`;
|
|
180
|
+
else if (bytes < 1048576) size = ` ${(bytes / 1024).toFixed(0)}KB`;
|
|
181
|
+
else size = ` ${(bytes / 1048576).toFixed(1)}MB`;
|
|
182
|
+
} catch { /* ignore */ }
|
|
183
|
+
if (isContentFile(fullPath)) {
|
|
184
|
+
const shortId = cacheFile(fullPath);
|
|
185
|
+
allButtons.push([{ text: `${emoji} ${entry.name}${size}`, callback_data: `/file ${shortId}` }]);
|
|
186
|
+
} else {
|
|
187
|
+
allButtons.push([{ text: `${emoji} ${entry.name}${size}`, callback_data: 'noop' }]);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const header = `📁 ${path.basename(targetDir)}/` + (entries.length > MAX_BUTTONS ? ` (${MAX_BUTTONS}/${entries.length})` : '');
|
|
193
|
+
if (allButtons.length > 0 && bot.sendButtons) {
|
|
194
|
+
await bot.sendButtons(chatId, header, allButtons);
|
|
195
|
+
} else {
|
|
196
|
+
const lines = [header];
|
|
197
|
+
for (const entry of entries.slice(0, MAX_BUTTONS)) {
|
|
198
|
+
const isDir = entry.isDirectory();
|
|
199
|
+
lines.push(isDir ? ` 📂 ${entry.name}/` : ` 📎 ${entry.name}`);
|
|
200
|
+
}
|
|
201
|
+
await bot.sendMessage(chatId, lines.join('\n'));
|
|
202
|
+
}
|
|
203
|
+
} catch (e) {
|
|
204
|
+
await bot.sendMessage(chatId, `❌ ${e.message}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
normalizeCwd,
|
|
210
|
+
isContentFile,
|
|
211
|
+
getCachedFile,
|
|
212
|
+
sendFileButtons,
|
|
213
|
+
sendDirPicker,
|
|
214
|
+
sendBrowse,
|
|
215
|
+
sendDirListing,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
module.exports = { createFileBrowser };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function createNotifier(deps) {
|
|
4
|
+
const { log, getConfig, getBridges } = deps;
|
|
5
|
+
|
|
6
|
+
async function notify(message, project = null) {
|
|
7
|
+
const config = getConfig();
|
|
8
|
+
const { telegramBridge, feishuBridge } = getBridges();
|
|
9
|
+
|
|
10
|
+
if (feishuBridge && feishuBridge.bot) {
|
|
11
|
+
const chatAgentMap = (config.feishu && config.feishu.chat_agent_map) || {};
|
|
12
|
+
const fsIds = (config.feishu && config.feishu.allowed_chat_ids) || [];
|
|
13
|
+
let targetIds;
|
|
14
|
+
if (project) {
|
|
15
|
+
targetIds = fsIds.filter(id => chatAgentMap[id] === project.key);
|
|
16
|
+
if (targetIds.length === 0) targetIds = fsIds.slice(0, 1);
|
|
17
|
+
} else {
|
|
18
|
+
targetIds = fsIds;
|
|
19
|
+
}
|
|
20
|
+
for (const chatId of targetIds) {
|
|
21
|
+
try {
|
|
22
|
+
if (project && feishuBridge.bot.sendCard) {
|
|
23
|
+
await feishuBridge.bot.sendCard(chatId, {
|
|
24
|
+
title: `${project.icon} ${project.name}`,
|
|
25
|
+
body: message,
|
|
26
|
+
color: project.color,
|
|
27
|
+
});
|
|
28
|
+
} else {
|
|
29
|
+
await feishuBridge.bot.sendMessage(chatId, message);
|
|
30
|
+
}
|
|
31
|
+
} catch (e) {
|
|
32
|
+
log('ERROR', `Feishu notify failed ${chatId}: ${e.message}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (telegramBridge && telegramBridge.bot) {
|
|
38
|
+
const tgIds = (config.telegram && config.telegram.allowed_chat_ids) || [];
|
|
39
|
+
for (const chatId of tgIds) {
|
|
40
|
+
try { await telegramBridge.bot.sendMarkdown(chatId, message); } catch (e) {
|
|
41
|
+
log('ERROR', `Telegram notify failed ${chatId}: ${e.message}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function notifyAdmin(message) {
|
|
48
|
+
const config = getConfig();
|
|
49
|
+
const { feishuBridge } = getBridges();
|
|
50
|
+
if (feishuBridge && feishuBridge.bot) {
|
|
51
|
+
const fsIds = (config.feishu && config.feishu.allowed_chat_ids) || [];
|
|
52
|
+
const adminId = fsIds[0];
|
|
53
|
+
if (adminId) {
|
|
54
|
+
try { await feishuBridge.bot.sendMessage(adminId, message); } catch (e) {
|
|
55
|
+
log('ERROR', `Feishu admin notify failed ${adminId}: ${e.message}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { notify, notifyAdmin };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { createNotifier };
|