@gonzih/cc-tg 0.9.12 → 0.9.14
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 +0 -36
- package/dist/bot.d.ts +4 -35
- package/dist/bot.js +317 -511
- package/dist/cron.d.ts +1 -7
- package/dist/cron.js +3 -24
- package/dist/formatter.d.ts +12 -14
- package/dist/formatter.js +36 -72
- package/dist/index.js +3 -68
- package/dist/usage-limit.js +3 -2
- package/dist/voice.js +19 -3
- package/package.json +3 -4
- package/dist/notifier.d.ts +0 -37
- package/dist/notifier.js +0 -132
- package/dist/tokens.d.ts +0 -22
- package/dist/tokens.js +0 -56
package/dist/bot.js
CHANGED
|
@@ -11,17 +11,16 @@ import https from "https";
|
|
|
11
11
|
import http from "http";
|
|
12
12
|
import { ClaudeProcess, extractText } from "./claude.js";
|
|
13
13
|
import { transcribeVoice, isVoiceAvailable } from "./voice.js";
|
|
14
|
+
import { CronManager } from "./cron.js";
|
|
14
15
|
import { formatForTelegram, splitLongMessage } from "./formatter.js";
|
|
15
16
|
import { detectUsageLimit } from "./usage-limit.js";
|
|
16
|
-
import { getCurrentToken, rotateToken, getTokenIndex, getTokenCount } from "./tokens.js";
|
|
17
|
-
import { writeChatLog } from "./notifier.js";
|
|
18
|
-
import { CronManager } from "./cron.js";
|
|
19
17
|
const BOT_COMMANDS = [
|
|
20
18
|
{ command: "start", description: "Reset session and start fresh" },
|
|
21
19
|
{ command: "reset", description: "Reset Claude session" },
|
|
22
20
|
{ command: "stop", description: "Stop the current Claude task" },
|
|
23
21
|
{ command: "status", description: "Check if a session is active" },
|
|
24
22
|
{ command: "help", description: "Show all available commands" },
|
|
23
|
+
{ command: "cron", description: "Manage cron jobs — add/list/edit/remove/clear" },
|
|
25
24
|
{ command: "reload_mcp", description: "Restart the cc-agent MCP server process" },
|
|
26
25
|
{ command: "mcp_status", description: "Check MCP server connection status" },
|
|
27
26
|
{ command: "mcp_version", description: "Show cc-agent npm version and npx cache info" },
|
|
@@ -29,9 +28,6 @@ const BOT_COMMANDS = [
|
|
|
29
28
|
{ command: "restart", description: "Restart the bot process in-place" },
|
|
30
29
|
{ command: "get_file", description: "Send a file from the server to this chat" },
|
|
31
30
|
{ command: "cost", description: "Show session token usage and cost" },
|
|
32
|
-
{ command: "skills", description: "List available Claude skills with descriptions" },
|
|
33
|
-
{ command: "cron", description: "Manage cron jobs — add/list/edit/remove/clear" },
|
|
34
|
-
{ command: "voice_retry", description: "Retry failed voice message transcriptions" },
|
|
35
31
|
];
|
|
36
32
|
const FLUSH_DELAY_MS = 800; // debounce streaming chunks into one Telegram message
|
|
37
33
|
const TYPING_INTERVAL_MS = 4000; // re-send typing action before Telegram's 5s expiry
|
|
@@ -68,6 +64,10 @@ function formatCostReport(cost) {
|
|
|
68
64
|
` Cache write: ${formatTokens(cost.totalCacheWriteTokens)} tokens ($${cacheWriteCost.toFixed(3)})`,
|
|
69
65
|
].join("\n");
|
|
70
66
|
}
|
|
67
|
+
function formatCronCostFooter(usage) {
|
|
68
|
+
const cost = computeCostUsd(usage);
|
|
69
|
+
return `\n💰 Cron cost: $${cost.toFixed(4)} (${formatTokens(usage.inputTokens)} in / ${formatTokens(usage.outputTokens)} out tokens)`;
|
|
70
|
+
}
|
|
71
71
|
function formatAgentCostSummary(text) {
|
|
72
72
|
try {
|
|
73
73
|
const data = JSON.parse(text);
|
|
@@ -154,17 +154,12 @@ export class CcTgBot {
|
|
|
154
154
|
sessions = new Map();
|
|
155
155
|
pendingRetries = new Map();
|
|
156
156
|
opts;
|
|
157
|
+
cron;
|
|
157
158
|
costStore;
|
|
158
159
|
botUsername = "";
|
|
159
160
|
botId = 0;
|
|
160
|
-
redis;
|
|
161
|
-
namespace;
|
|
162
|
-
lastActiveChatId;
|
|
163
|
-
cron;
|
|
164
161
|
constructor(opts) {
|
|
165
162
|
this.opts = opts;
|
|
166
|
-
this.redis = opts.redis;
|
|
167
|
-
this.namespace = opts.namespace ?? "default";
|
|
168
163
|
this.bot = new TelegramBot(opts.telegramToken, { polling: true });
|
|
169
164
|
this.bot.on("message", (msg) => this.handleTelegram(msg));
|
|
170
165
|
this.bot.on("polling_error", (err) => console.error("[tg]", err.message));
|
|
@@ -173,10 +168,11 @@ export class CcTgBot {
|
|
|
173
168
|
this.botId = me.id;
|
|
174
169
|
console.log(`[tg] bot identity: @${this.botUsername} (id=${this.botId})`);
|
|
175
170
|
}).catch((err) => console.error("[tg] getMe failed:", err.message));
|
|
176
|
-
|
|
177
|
-
this.cron = new CronManager(opts.cwd ?? process.cwd(), (chatId, prompt
|
|
178
|
-
this.runCronTask(chatId, prompt
|
|
171
|
+
// Cron manager — fires each task into an isolated ClaudeProcess
|
|
172
|
+
this.cron = new CronManager(opts.cwd ?? process.cwd(), (chatId, prompt) => {
|
|
173
|
+
this.runCronTask(chatId, prompt);
|
|
179
174
|
});
|
|
175
|
+
this.costStore = new CostStore(opts.cwd ?? process.cwd());
|
|
180
176
|
this.registerBotCommands();
|
|
181
177
|
console.log("cc-tg bot started");
|
|
182
178
|
console.log(`[voice] whisper available: ${isVoiceAvailable()}`);
|
|
@@ -186,55 +182,6 @@ export class CcTgBot {
|
|
|
186
182
|
.then(() => console.log("[tg] bot commands registered"))
|
|
187
183
|
.catch((err) => console.error("[tg] setMyCommands failed:", err.message));
|
|
188
184
|
}
|
|
189
|
-
/** Write a message to the Redis chat log. Fire-and-forget — no-op if Redis is not configured. */
|
|
190
|
-
writeChatMessage(role, source, content, chatId) {
|
|
191
|
-
if (!this.redis)
|
|
192
|
-
return;
|
|
193
|
-
const msg = {
|
|
194
|
-
id: `${source}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
195
|
-
source,
|
|
196
|
-
role,
|
|
197
|
-
content,
|
|
198
|
-
timestamp: new Date().toISOString(),
|
|
199
|
-
chatId,
|
|
200
|
-
};
|
|
201
|
-
writeChatLog(this.redis, this.namespace, msg);
|
|
202
|
-
}
|
|
203
|
-
/** Returns the last chatId that sent a message — used by the chat bridge when no fixed chatId is configured. */
|
|
204
|
-
getLastActiveChatId() {
|
|
205
|
-
return this.lastActiveChatId;
|
|
206
|
-
}
|
|
207
|
-
/** Session key: "chatId:threadId" for topics, "chatId:main" for DMs/non-topic groups */
|
|
208
|
-
sessionKey(chatId, threadId) {
|
|
209
|
-
return `${chatId}:${threadId ?? 'main'}`;
|
|
210
|
-
}
|
|
211
|
-
/**
|
|
212
|
-
* Send a message back to the correct thread (or plain chat if no thread).
|
|
213
|
-
* When threadId is undefined, calls sendMessage with exactly 2 args to preserve
|
|
214
|
-
* backward-compatible call signatures (no extra options object).
|
|
215
|
-
*/
|
|
216
|
-
replyToChat(chatId, text, threadId, opts) {
|
|
217
|
-
if (threadId !== undefined) {
|
|
218
|
-
return this.bot.sendMessage(chatId, text, { ...opts, message_thread_id: threadId });
|
|
219
|
-
}
|
|
220
|
-
if (opts) {
|
|
221
|
-
return this.bot.sendMessage(chatId, text, opts);
|
|
222
|
-
}
|
|
223
|
-
return this.bot.sendMessage(chatId, text);
|
|
224
|
-
}
|
|
225
|
-
/** Parse THREAD_CWD_MAP env var — maps thread name or thread_id to a CWD path */
|
|
226
|
-
getThreadCwdMap() {
|
|
227
|
-
const raw = process.env.THREAD_CWD_MAP;
|
|
228
|
-
if (!raw)
|
|
229
|
-
return {};
|
|
230
|
-
try {
|
|
231
|
-
return JSON.parse(raw);
|
|
232
|
-
}
|
|
233
|
-
catch {
|
|
234
|
-
console.warn('[cc-tg] THREAD_CWD_MAP is not valid JSON, ignoring');
|
|
235
|
-
return {};
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
185
|
isAllowed(userId) {
|
|
239
186
|
if (!this.opts.allowedUserIds?.length)
|
|
240
187
|
return true;
|
|
@@ -243,20 +190,10 @@ export class CcTgBot {
|
|
|
243
190
|
async handleTelegram(msg) {
|
|
244
191
|
const chatId = msg.chat.id;
|
|
245
192
|
const userId = msg.from?.id ?? chatId;
|
|
246
|
-
// Forum topic thread_id — undefined for DMs and non-topic group messages
|
|
247
|
-
const threadId = msg.message_thread_id;
|
|
248
|
-
// Thread name is available on the service message that creates a new topic.
|
|
249
|
-
// forum_topic_created is not in older @types/node-telegram-bot-api versions, so cast via unknown.
|
|
250
|
-
const rawMsg = msg;
|
|
251
|
-
const threadName = rawMsg.forum_topic_created
|
|
252
|
-
? rawMsg.forum_topic_created.name
|
|
253
|
-
: undefined;
|
|
254
193
|
if (!this.isAllowed(userId)) {
|
|
255
|
-
await this.
|
|
194
|
+
await this.bot.sendMessage(chatId, "Not authorized.");
|
|
256
195
|
return;
|
|
257
196
|
}
|
|
258
|
-
// Track the last chat that sent us a message for the chat bridge
|
|
259
|
-
this.lastActiveChatId = chatId;
|
|
260
197
|
// Group chat handling
|
|
261
198
|
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
|
|
262
199
|
if (isGroup) {
|
|
@@ -275,17 +212,17 @@ export class CcTgBot {
|
|
|
275
212
|
}
|
|
276
213
|
// Voice message — transcribe then feed as text
|
|
277
214
|
if (msg.voice || msg.audio) {
|
|
278
|
-
await this.handleVoice(chatId, msg
|
|
215
|
+
await this.handleVoice(chatId, msg);
|
|
279
216
|
return;
|
|
280
217
|
}
|
|
281
218
|
// Photo — send as base64 image content block to Claude
|
|
282
219
|
if (msg.photo?.length) {
|
|
283
|
-
await this.handlePhoto(chatId, msg
|
|
220
|
+
await this.handlePhoto(chatId, msg);
|
|
284
221
|
return;
|
|
285
222
|
}
|
|
286
223
|
// Document — download to CWD/.cc-tg/uploads/, tell Claude the path
|
|
287
224
|
if (msg.document) {
|
|
288
|
-
await this.handleDocument(chatId, msg
|
|
225
|
+
await this.handleDocument(chatId, msg);
|
|
289
226
|
return;
|
|
290
227
|
}
|
|
291
228
|
let text = msg.text?.trim();
|
|
@@ -295,69 +232,68 @@ export class CcTgBot {
|
|
|
295
232
|
if (this.botUsername) {
|
|
296
233
|
text = text.replace(new RegExp(`@${this.botUsername}\\s*`, "g"), "").trim();
|
|
297
234
|
}
|
|
298
|
-
const sessionKey = this.sessionKey(chatId, threadId);
|
|
299
235
|
// /start or /reset — kill existing session and ack
|
|
300
236
|
if (text === "/start" || text === "/reset") {
|
|
301
|
-
this.killSession(chatId
|
|
302
|
-
await this.
|
|
237
|
+
this.killSession(chatId);
|
|
238
|
+
await this.bot.sendMessage(chatId, "Session reset. Send a message to start.");
|
|
303
239
|
return;
|
|
304
240
|
}
|
|
305
241
|
// /stop — kill active session (interrupt running Claude task)
|
|
306
242
|
if (text === "/stop") {
|
|
307
|
-
const has = this.sessions.has(
|
|
308
|
-
this.killSession(chatId
|
|
309
|
-
await this.
|
|
243
|
+
const has = this.sessions.has(chatId);
|
|
244
|
+
this.killSession(chatId);
|
|
245
|
+
await this.bot.sendMessage(chatId, has ? "Stopped." : "No active session.");
|
|
310
246
|
return;
|
|
311
247
|
}
|
|
312
248
|
// /help — list all commands
|
|
313
249
|
if (text === "/help") {
|
|
314
250
|
const lines = BOT_COMMANDS.map((c) => `/${c.command} — ${c.description}`);
|
|
315
|
-
await this.
|
|
251
|
+
await this.bot.sendMessage(chatId, lines.join("\n"));
|
|
316
252
|
return;
|
|
317
253
|
}
|
|
318
254
|
// /status
|
|
319
255
|
if (text === "/status") {
|
|
320
|
-
const has = this.sessions.has(
|
|
256
|
+
const has = this.sessions.has(chatId);
|
|
321
257
|
let status = has ? "Session active." : "No active session.";
|
|
322
258
|
const sleeping = this.pendingRetries.size;
|
|
323
259
|
if (sleeping > 0)
|
|
324
260
|
status += `\n⏸ ${sleeping} request(s) sleeping (usage limit).`;
|
|
325
|
-
await this.
|
|
261
|
+
await this.bot.sendMessage(chatId, status);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
// /cron <schedule> <prompt> | /cron list | /cron clear | /cron remove <id>
|
|
265
|
+
if (text.startsWith("/cron")) {
|
|
266
|
+
await this.handleCron(chatId, text);
|
|
326
267
|
return;
|
|
327
268
|
}
|
|
328
269
|
// /reload_mcp — kill cc-agent process so Claude Code auto-restarts it
|
|
329
270
|
if (text === "/reload_mcp") {
|
|
330
|
-
await this.handleReloadMcp(chatId
|
|
271
|
+
await this.handleReloadMcp(chatId);
|
|
331
272
|
return;
|
|
332
273
|
}
|
|
333
274
|
// /mcp_status — run `claude mcp list` and show connection status
|
|
334
275
|
if (text === "/mcp_status") {
|
|
335
|
-
await this.handleMcpStatus(chatId
|
|
276
|
+
await this.handleMcpStatus(chatId);
|
|
336
277
|
return;
|
|
337
278
|
}
|
|
338
279
|
// /mcp_version — show published npm version and cached npx entries
|
|
339
280
|
if (text === "/mcp_version") {
|
|
340
|
-
await this.handleMcpVersion(chatId
|
|
281
|
+
await this.handleMcpVersion(chatId);
|
|
341
282
|
return;
|
|
342
283
|
}
|
|
343
284
|
// /clear_npx_cache — wipe ~/.npm/_npx/ then restart cc-agent
|
|
344
285
|
if (text === "/clear_npx_cache") {
|
|
345
|
-
await this.handleClearNpxCache(chatId
|
|
286
|
+
await this.handleClearNpxCache(chatId);
|
|
346
287
|
return;
|
|
347
288
|
}
|
|
348
289
|
// /restart — restart the bot process in-place
|
|
349
290
|
if (text === "/restart") {
|
|
350
|
-
await this.handleRestart(chatId
|
|
351
|
-
return;
|
|
352
|
-
}
|
|
353
|
-
// /cron <schedule> <prompt> | /cron list | /cron clear | /cron remove <id>
|
|
354
|
-
if (text.startsWith("/cron")) {
|
|
355
|
-
await this.handleCron(chatId, text, threadId);
|
|
291
|
+
await this.handleRestart(chatId);
|
|
356
292
|
return;
|
|
357
293
|
}
|
|
358
294
|
// /get_file <path> — send a file from the server to the user
|
|
359
295
|
if (text.startsWith("/get_file")) {
|
|
360
|
-
await this.handleGetFile(chatId, text
|
|
296
|
+
await this.handleGetFile(chatId, text);
|
|
361
297
|
return;
|
|
362
298
|
}
|
|
363
299
|
// /cost — show session token usage and cost
|
|
@@ -373,212 +309,83 @@ export class CcTgBot {
|
|
|
373
309
|
catch (err) {
|
|
374
310
|
console.error("[cost] cc-agent cost_summary failed:", err.message);
|
|
375
311
|
}
|
|
376
|
-
await this.
|
|
377
|
-
return;
|
|
378
|
-
}
|
|
379
|
-
// /skills — list available Claude skills from ~/.claude/skills/
|
|
380
|
-
if (text === "/skills") {
|
|
381
|
-
await this.replyToChat(chatId, listSkills(), threadId);
|
|
382
|
-
return;
|
|
383
|
-
}
|
|
384
|
-
// /voice_retry — retry failed voice message transcriptions
|
|
385
|
-
if (text === "/voice_retry") {
|
|
386
|
-
await this.handleVoiceRetry(chatId, threadId);
|
|
312
|
+
await this.bot.sendMessage(chatId, reply);
|
|
387
313
|
return;
|
|
388
314
|
}
|
|
389
|
-
const session = this.getOrCreateSession(chatId
|
|
315
|
+
const session = this.getOrCreateSession(chatId);
|
|
390
316
|
try {
|
|
391
|
-
const
|
|
392
|
-
const prompt = buildPromptWithReplyContext(enriched, msg);
|
|
317
|
+
const prompt = buildPromptWithReplyContext(text, msg);
|
|
393
318
|
session.currentPrompt = prompt;
|
|
394
319
|
session.claude.sendPrompt(prompt);
|
|
395
320
|
this.startTyping(chatId, session);
|
|
396
|
-
this.writeChatMessage("user", "telegram", text, chatId);
|
|
397
321
|
}
|
|
398
322
|
catch (err) {
|
|
399
|
-
await this.
|
|
400
|
-
this.killSession(chatId
|
|
323
|
+
await this.bot.sendMessage(chatId, `Error sending to Claude: ${err.message}`);
|
|
324
|
+
this.killSession(chatId);
|
|
401
325
|
}
|
|
402
326
|
}
|
|
403
|
-
|
|
404
|
-
* Feed a text message into the active Claude session for the given chat.
|
|
405
|
-
* Called by the notifier when a UI message arrives via Redis pub/sub.
|
|
406
|
-
*/
|
|
407
|
-
async handleUserMessage(chatId, text) {
|
|
408
|
-
const session = this.getOrCreateSession(chatId);
|
|
409
|
-
try {
|
|
410
|
-
const enriched = await enrichPromptWithUrls(text);
|
|
411
|
-
session.currentPrompt = enriched;
|
|
412
|
-
session.claude.sendPrompt(enriched);
|
|
413
|
-
this.startTyping(chatId, session);
|
|
414
|
-
this.writeChatMessage("user", "ui", text, chatId);
|
|
415
|
-
}
|
|
416
|
-
catch (err) {
|
|
417
|
-
await this.replyToChat(chatId, `Error sending to Claude: ${err.message}`);
|
|
418
|
-
this.killSession(chatId, true);
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
async handleVoice(chatId, msg, threadId, threadName) {
|
|
327
|
+
async handleVoice(chatId, msg) {
|
|
422
328
|
const fileId = msg.voice?.file_id ?? msg.audio?.file_id;
|
|
423
329
|
if (!fileId)
|
|
424
330
|
return;
|
|
425
331
|
console.log(`[voice:${chatId}] received voice message, transcribing...`);
|
|
426
|
-
this.bot.sendChatAction(chatId, "typing"
|
|
427
|
-
// Store in Redis before transcription so we can retry on failure
|
|
428
|
-
const pendingEntry = JSON.stringify({
|
|
429
|
-
file_id: fileId,
|
|
430
|
-
chat_id: chatId,
|
|
431
|
-
message_id: msg.message_id,
|
|
432
|
-
timestamp: Date.now(),
|
|
433
|
-
});
|
|
434
|
-
if (this.redis) {
|
|
435
|
-
await this.redis.rpush("voice:pending", pendingEntry).catch((err) => console.warn("[voice] redis rpush voice:pending failed:", err.message));
|
|
436
|
-
}
|
|
332
|
+
this.bot.sendChatAction(chatId, "typing").catch(() => { });
|
|
437
333
|
try {
|
|
438
334
|
const fileLink = await this.bot.getFileLink(fileId);
|
|
439
335
|
const transcript = await transcribeVoice(fileLink);
|
|
440
336
|
console.log(`[voice:${chatId}] transcribed: ${transcript}`);
|
|
441
|
-
// Remove from pending on success
|
|
442
|
-
if (this.redis) {
|
|
443
|
-
await this.redis.lrem("voice:pending", 0, pendingEntry).catch((err) => console.warn("[voice] redis lrem voice:pending failed:", err.message));
|
|
444
|
-
}
|
|
445
337
|
if (!transcript || transcript === "[empty transcription]") {
|
|
446
|
-
await this.
|
|
338
|
+
await this.bot.sendMessage(chatId, "Could not transcribe voice message.");
|
|
447
339
|
return;
|
|
448
340
|
}
|
|
449
341
|
// Feed transcript into Claude as if user typed it
|
|
450
|
-
const session = this.getOrCreateSession(chatId
|
|
342
|
+
const session = this.getOrCreateSession(chatId);
|
|
451
343
|
try {
|
|
452
344
|
const prompt = buildPromptWithReplyContext(transcript, msg);
|
|
453
|
-
this.writeChatMessage("user", "telegram", transcript, chatId);
|
|
454
345
|
session.currentPrompt = prompt;
|
|
455
346
|
session.claude.sendPrompt(prompt);
|
|
456
347
|
this.startTyping(chatId, session);
|
|
457
348
|
}
|
|
458
349
|
catch (err) {
|
|
459
|
-
await this.
|
|
460
|
-
this.killSession(chatId
|
|
350
|
+
await this.bot.sendMessage(chatId, `Error sending to Claude: ${err.message}`);
|
|
351
|
+
this.killSession(chatId);
|
|
461
352
|
}
|
|
462
353
|
}
|
|
463
354
|
catch (err) {
|
|
464
|
-
const errMsg = err
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
chat_id: chatId,
|
|
471
|
-
message_id: msg.message_id,
|
|
472
|
-
timestamp: Date.now(),
|
|
473
|
-
error: errMsg,
|
|
474
|
-
failed_at: Date.now(),
|
|
475
|
-
});
|
|
476
|
-
this.redis.rpush("voice:failed", failedEntry)
|
|
477
|
-
.then(() => this.redis.expire("voice:failed", 48 * 60 * 60))
|
|
478
|
-
.catch((redisErr) => console.warn("[voice] redis write voice:failed failed:", redisErr.message));
|
|
479
|
-
}
|
|
480
|
-
// User-friendly error messages
|
|
481
|
-
let userMsg;
|
|
482
|
-
if (errMsg.includes("whisper-cpp not found") || errMsg.includes("whisper not found")) {
|
|
483
|
-
userMsg = "Voice transcription unavailable — whisper-cpp not installed";
|
|
484
|
-
}
|
|
485
|
-
else if (errMsg.includes("No whisper model found")) {
|
|
486
|
-
userMsg = "Voice transcription unavailable — no whisper model found";
|
|
487
|
-
}
|
|
488
|
-
else if (errMsg.includes("HTTP") && errMsg.includes("downloading")) {
|
|
489
|
-
userMsg = "Could not download voice file from Telegram";
|
|
490
|
-
}
|
|
491
|
-
else {
|
|
492
|
-
userMsg = `Voice transcription failed: ${errMsg}`;
|
|
493
|
-
}
|
|
494
|
-
await this.replyToChat(chatId, userMsg, threadId);
|
|
355
|
+
const errMsg = err instanceof Error
|
|
356
|
+
? (err.message || err.toString() || `signal: ${err.signal || 'unknown'}`)
|
|
357
|
+
: String(err);
|
|
358
|
+
const stderr = err.stderr ? ` | stderr: ${err.stderr.slice(0, 200)}` : '';
|
|
359
|
+
console.error(`[voice:${chatId}] error:`, errMsg, stderr);
|
|
360
|
+
await this.bot.sendMessage(chatId, `Voice transcription failed: ${errMsg}${stderr}`);
|
|
495
361
|
}
|
|
496
362
|
}
|
|
497
|
-
async
|
|
498
|
-
if (!this.redis) {
|
|
499
|
-
await this.replyToChat(chatId, "Redis not configured — voice retry unavailable.", threadId);
|
|
500
|
-
return;
|
|
501
|
-
}
|
|
502
|
-
const [pendingRaw, failedRaw] = await Promise.all([
|
|
503
|
-
this.redis.lrange("voice:pending", 0, -1).catch(() => []),
|
|
504
|
-
this.redis.lrange("voice:failed", 0, -1).catch(() => []),
|
|
505
|
-
]);
|
|
506
|
-
// Deduplicate by file_id across both lists
|
|
507
|
-
const allEntries = new Map();
|
|
508
|
-
for (const raw of [...pendingRaw, ...failedRaw]) {
|
|
509
|
-
try {
|
|
510
|
-
const entry = JSON.parse(raw);
|
|
511
|
-
if (entry.file_id)
|
|
512
|
-
allEntries.set(entry.file_id, entry);
|
|
513
|
-
}
|
|
514
|
-
catch { /* skip malformed entries */ }
|
|
515
|
-
}
|
|
516
|
-
if (allEntries.size === 0) {
|
|
517
|
-
await this.replyToChat(chatId, "No pending voice messages to retry.", threadId);
|
|
518
|
-
return;
|
|
519
|
-
}
|
|
520
|
-
await this.replyToChat(chatId, `Retrying ${allEntries.size} voice message(s)...`, threadId);
|
|
521
|
-
let succeeded = 0;
|
|
522
|
-
let failed = 0;
|
|
523
|
-
const errors = [];
|
|
524
|
-
for (const [fileId, entry] of allEntries) {
|
|
525
|
-
try {
|
|
526
|
-
const fileLink = await this.bot.getFileLink(fileId);
|
|
527
|
-
const transcript = await transcribeVoice(fileLink);
|
|
528
|
-
if (transcript && transcript !== "[empty transcription]") {
|
|
529
|
-
const session = this.getOrCreateSession(entry.chat_id, threadId, undefined);
|
|
530
|
-
session.claude.sendPrompt(transcript);
|
|
531
|
-
this.writeChatMessage("user", "telegram", transcript, entry.chat_id);
|
|
532
|
-
// Remove from both lists
|
|
533
|
-
const matchPending = pendingRaw.find((r) => r.includes(`"${fileId}"`));
|
|
534
|
-
const matchFailed = failedRaw.find((r) => r.includes(`"${fileId}"`));
|
|
535
|
-
if (matchPending)
|
|
536
|
-
await this.redis.lrem("voice:pending", 0, matchPending).catch(() => { });
|
|
537
|
-
if (matchFailed)
|
|
538
|
-
await this.redis.lrem("voice:failed", 0, matchFailed).catch(() => { });
|
|
539
|
-
succeeded++;
|
|
540
|
-
}
|
|
541
|
-
else {
|
|
542
|
-
failed++;
|
|
543
|
-
errors.push(`${fileId}: empty transcription`);
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
catch (err) {
|
|
547
|
-
failed++;
|
|
548
|
-
errors.push(`${fileId}: ${err.message}`);
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
const lines = [`Voice retry complete: ${succeeded} succeeded, ${failed} failed.`];
|
|
552
|
-
if (errors.length > 0)
|
|
553
|
-
lines.push(...errors.map((e) => `• ${e}`));
|
|
554
|
-
await this.replyToChat(chatId, lines.join("\n"), threadId);
|
|
555
|
-
}
|
|
556
|
-
async handlePhoto(chatId, msg, threadId, threadName) {
|
|
363
|
+
async handlePhoto(chatId, msg) {
|
|
557
364
|
// Pick highest resolution photo
|
|
558
365
|
const photos = msg.photo;
|
|
559
366
|
const best = photos[photos.length - 1];
|
|
560
367
|
const caption = msg.caption?.trim();
|
|
561
368
|
console.log(`[photo:${chatId}] received image file_id=${best.file_id}`);
|
|
562
|
-
this.bot.sendChatAction(chatId, "typing"
|
|
369
|
+
this.bot.sendChatAction(chatId, "typing").catch(() => { });
|
|
563
370
|
try {
|
|
564
371
|
const fileLink = await this.bot.getFileLink(best.file_id);
|
|
565
372
|
const imageData = await fetchAsBase64(fileLink);
|
|
566
373
|
// Telegram photos are always JPEG
|
|
567
|
-
const session = this.getOrCreateSession(chatId
|
|
374
|
+
const session = this.getOrCreateSession(chatId);
|
|
568
375
|
session.claude.sendImage(imageData, "image/jpeg", caption);
|
|
569
376
|
this.startTyping(chatId, session);
|
|
570
377
|
}
|
|
571
378
|
catch (err) {
|
|
572
379
|
console.error(`[photo:${chatId}] error:`, err.message);
|
|
573
|
-
await this.
|
|
380
|
+
await this.bot.sendMessage(chatId, `Failed to process image: ${err.message}`);
|
|
574
381
|
}
|
|
575
382
|
}
|
|
576
|
-
async handleDocument(chatId, msg
|
|
383
|
+
async handleDocument(chatId, msg) {
|
|
577
384
|
const doc = msg.document;
|
|
578
385
|
const caption = msg.caption?.trim();
|
|
579
386
|
const fileName = doc.file_name ?? `file_${doc.file_id}`;
|
|
580
387
|
console.log(`[doc:${chatId}] received document file_name=${fileName} mime=${doc.mime_type}`);
|
|
581
|
-
this.bot.sendChatAction(chatId, "typing"
|
|
388
|
+
this.bot.sendChatAction(chatId, "typing").catch(() => { });
|
|
582
389
|
try {
|
|
583
390
|
const uploadsDir = join(this.opts.cwd ?? process.cwd(), ".cc-tg", "uploads");
|
|
584
391
|
mkdirSync(uploadsDir, { recursive: true });
|
|
@@ -589,34 +396,22 @@ export class CcTgBot {
|
|
|
589
396
|
const prompt = caption
|
|
590
397
|
? `${caption}\n\nATTACHMENTS: [${fileName}](${destPath})`
|
|
591
398
|
: `ATTACHMENTS: [${fileName}](${destPath})`;
|
|
592
|
-
const session = this.getOrCreateSession(chatId
|
|
399
|
+
const session = this.getOrCreateSession(chatId);
|
|
593
400
|
session.claude.sendPrompt(prompt);
|
|
594
401
|
this.startTyping(chatId, session);
|
|
595
402
|
}
|
|
596
403
|
catch (err) {
|
|
597
404
|
console.error(`[doc:${chatId}] error:`, err.message);
|
|
598
|
-
await this.
|
|
405
|
+
await this.bot.sendMessage(chatId, `Failed to receive document: ${err.message}`);
|
|
599
406
|
}
|
|
600
407
|
}
|
|
601
|
-
getOrCreateSession(chatId
|
|
602
|
-
const
|
|
603
|
-
const existing = this.sessions.get(key);
|
|
408
|
+
getOrCreateSession(chatId) {
|
|
409
|
+
const existing = this.sessions.get(chatId);
|
|
604
410
|
if (existing && !existing.claude.exited)
|
|
605
411
|
return existing;
|
|
606
|
-
// Determine CWD for this thread — check THREAD_CWD_MAP by name then by ID
|
|
607
|
-
let sessionCwd = this.opts.cwd;
|
|
608
|
-
const threadCwdMap = this.getThreadCwdMap();
|
|
609
|
-
if (threadName && threadCwdMap[threadName]) {
|
|
610
|
-
sessionCwd = threadCwdMap[threadName];
|
|
611
|
-
console.log(`[cc-tg] thread "${threadName}" → cwd: ${sessionCwd}`);
|
|
612
|
-
}
|
|
613
|
-
else if (threadId !== undefined && threadCwdMap[String(threadId)]) {
|
|
614
|
-
sessionCwd = threadCwdMap[String(threadId)];
|
|
615
|
-
console.log(`[cc-tg] thread ${threadId} → cwd: ${sessionCwd}`);
|
|
616
|
-
}
|
|
617
412
|
const claude = new ClaudeProcess({
|
|
618
|
-
cwd:
|
|
619
|
-
token:
|
|
413
|
+
cwd: this.opts.cwd,
|
|
414
|
+
token: this.opts.claudeToken,
|
|
620
415
|
});
|
|
621
416
|
const session = {
|
|
622
417
|
claude,
|
|
@@ -626,7 +421,6 @@ export class CcTgBot {
|
|
|
626
421
|
writtenFiles: new Set(),
|
|
627
422
|
currentPrompt: "",
|
|
628
423
|
isRetry: false,
|
|
629
|
-
threadId,
|
|
630
424
|
};
|
|
631
425
|
claude.on("usage", (usage) => {
|
|
632
426
|
this.costStore.addUsage(chatId, usage);
|
|
@@ -635,47 +429,33 @@ export class CcTgBot {
|
|
|
635
429
|
// Verbose logging — log every message type and subtype
|
|
636
430
|
const subtype = msg.payload.subtype ?? "";
|
|
637
431
|
const toolName = this.extractToolName(msg);
|
|
638
|
-
const logParts = [`[claude:${
|
|
432
|
+
const logParts = [`[claude:${chatId}] msg=${msg.type}`];
|
|
639
433
|
if (subtype)
|
|
640
434
|
logParts.push(`subtype=${subtype}`);
|
|
641
435
|
if (toolName)
|
|
642
436
|
logParts.push(`tool=${toolName}`);
|
|
643
437
|
console.log(logParts.join(" "));
|
|
644
438
|
// Track files written by Write/Edit tool calls
|
|
645
|
-
this.trackWrittenFiles(msg, session,
|
|
646
|
-
// Publish tool call events to the chat log
|
|
647
|
-
if (msg.type === "assistant") {
|
|
648
|
-
const message = msg.payload.message;
|
|
649
|
-
const content = message?.content;
|
|
650
|
-
if (Array.isArray(content)) {
|
|
651
|
-
for (const block of content) {
|
|
652
|
-
if (block.type !== "tool_use")
|
|
653
|
-
continue;
|
|
654
|
-
const name = block.name;
|
|
655
|
-
const input = block.input;
|
|
656
|
-
this.writeChatMessage("tool", "cc-tg", `[tool] ${name}: ${JSON.stringify(input ?? {})}`, chatId);
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
}
|
|
439
|
+
this.trackWrittenFiles(msg, session, this.opts.cwd);
|
|
660
440
|
this.handleClaudeMessage(chatId, session, msg);
|
|
661
441
|
});
|
|
662
442
|
claude.on("stderr", (data) => {
|
|
663
443
|
const line = data.trim();
|
|
664
444
|
if (line)
|
|
665
|
-
console.error(`[claude:${
|
|
445
|
+
console.error(`[claude:${chatId}:stderr]`, line);
|
|
666
446
|
});
|
|
667
447
|
claude.on("exit", (code) => {
|
|
668
|
-
console.log(`[claude:${
|
|
448
|
+
console.log(`[claude:${chatId}] exited code=${code}`);
|
|
669
449
|
this.stopTyping(session);
|
|
670
|
-
this.sessions.delete(
|
|
450
|
+
this.sessions.delete(chatId);
|
|
671
451
|
});
|
|
672
452
|
claude.on("error", (err) => {
|
|
673
|
-
console.error(`[claude:${
|
|
453
|
+
console.error(`[claude:${chatId}] process error: ${err.message}`);
|
|
674
454
|
this.bot.sendMessage(chatId, `Claude process error: ${err.message}`).catch(() => { });
|
|
675
455
|
this.stopTyping(session);
|
|
676
|
-
this.sessions.delete(
|
|
456
|
+
this.sessions.delete(chatId);
|
|
677
457
|
});
|
|
678
|
-
this.sessions.set(
|
|
458
|
+
this.sessions.set(chatId, session);
|
|
679
459
|
return session;
|
|
680
460
|
}
|
|
681
461
|
handleClaudeMessage(chatId, session, msg) {
|
|
@@ -691,58 +471,33 @@ export class CcTgBot {
|
|
|
691
471
|
// Check for usage/rate limit signals before forwarding to Telegram
|
|
692
472
|
const sig = detectUsageLimit(text);
|
|
693
473
|
if (sig.detected) {
|
|
694
|
-
const threadId = session.threadId;
|
|
695
|
-
const retryKey = this.sessionKey(chatId, threadId);
|
|
696
474
|
const lastPrompt = session.currentPrompt;
|
|
697
|
-
const prevRetry = this.pendingRetries.get(
|
|
475
|
+
const prevRetry = this.pendingRetries.get(chatId);
|
|
698
476
|
const attempt = (prevRetry?.attempt ?? 0) + 1;
|
|
699
477
|
if (prevRetry)
|
|
700
478
|
clearTimeout(prevRetry.timer);
|
|
701
|
-
this.
|
|
702
|
-
this.killSession(chatId
|
|
703
|
-
// Token rotation: if this is a usage_exhausted signal and we have multiple
|
|
704
|
-
// tokens, rotate to the next one and retry immediately instead of sleeping.
|
|
705
|
-
// Only rotate if we haven't yet cycled through all tokens (attempt <= count-1).
|
|
706
|
-
if (sig.reason === "usage_exhausted" && getTokenCount() > 1 && attempt <= getTokenCount() - 1) {
|
|
707
|
-
const prevIdx = getTokenIndex();
|
|
708
|
-
rotateToken();
|
|
709
|
-
const newIdx = getTokenIndex();
|
|
710
|
-
const total = getTokenCount();
|
|
711
|
-
console.log(`[cc-tg] Token ${prevIdx + 1}/${total} exhausted, rotating to token ${newIdx + 1}/${total}`);
|
|
712
|
-
this.replyToChat(chatId, `🔄 Token ${prevIdx + 1}/${total} exhausted, switching to token ${newIdx + 1}/${total}...`, threadId).catch(() => { });
|
|
713
|
-
this.pendingRetries.set(retryKey, { text: lastPrompt, attempt, timer: setTimeout(() => { }, 0) });
|
|
714
|
-
try {
|
|
715
|
-
const retrySession = this.getOrCreateSession(chatId, threadId);
|
|
716
|
-
retrySession.currentPrompt = lastPrompt;
|
|
717
|
-
retrySession.isRetry = true;
|
|
718
|
-
retrySession.claude.sendPrompt(lastPrompt);
|
|
719
|
-
this.startTyping(chatId, retrySession);
|
|
720
|
-
}
|
|
721
|
-
catch (err) {
|
|
722
|
-
this.replyToChat(chatId, `❌ Failed to retry with rotated token: ${err.message}`, threadId).catch(() => { });
|
|
723
|
-
}
|
|
724
|
-
return;
|
|
725
|
-
}
|
|
479
|
+
this.bot.sendMessage(chatId, sig.humanMessage).catch(() => { });
|
|
480
|
+
this.killSession(chatId);
|
|
726
481
|
if (attempt > 3) {
|
|
727
|
-
this.
|
|
728
|
-
this.pendingRetries.delete(
|
|
482
|
+
this.bot.sendMessage(chatId, "❌ Claude usage limit persists after 3 retries. Please try again later.").catch(() => { });
|
|
483
|
+
this.pendingRetries.delete(chatId);
|
|
729
484
|
return;
|
|
730
485
|
}
|
|
731
|
-
console.log(`[usage-limit:${
|
|
486
|
+
console.log(`[usage-limit:${chatId}] ${sig.reason} — scheduling retry attempt=${attempt} in ${sig.retryAfterMs}ms`);
|
|
732
487
|
const timer = setTimeout(() => {
|
|
733
|
-
this.pendingRetries.delete(
|
|
488
|
+
this.pendingRetries.delete(chatId);
|
|
734
489
|
try {
|
|
735
|
-
const retrySession = this.getOrCreateSession(chatId
|
|
490
|
+
const retrySession = this.getOrCreateSession(chatId);
|
|
736
491
|
retrySession.currentPrompt = lastPrompt;
|
|
737
492
|
retrySession.isRetry = true;
|
|
738
493
|
retrySession.claude.sendPrompt(lastPrompt);
|
|
739
494
|
this.startTyping(chatId, retrySession);
|
|
740
495
|
}
|
|
741
496
|
catch (err) {
|
|
742
|
-
this.
|
|
497
|
+
this.bot.sendMessage(chatId, `❌ Failed to retry: ${err.message}`).catch(() => { });
|
|
743
498
|
}
|
|
744
499
|
}, sig.retryAfterMs);
|
|
745
|
-
this.pendingRetries.set(
|
|
500
|
+
this.pendingRetries.set(chatId, { text: lastPrompt, attempt, timer });
|
|
746
501
|
return;
|
|
747
502
|
}
|
|
748
503
|
// Accumulate text and debounce — Claude streams chunks rapidly
|
|
@@ -754,11 +509,9 @@ export class CcTgBot {
|
|
|
754
509
|
startTyping(chatId, session) {
|
|
755
510
|
this.stopTyping(session);
|
|
756
511
|
// Send immediately, then keep alive every 4s
|
|
757
|
-
|
|
758
|
-
const threadOpts = session.threadId !== undefined ? { message_thread_id: session.threadId } : undefined;
|
|
759
|
-
this.bot.sendChatAction(chatId, "typing", threadOpts).catch(() => { });
|
|
512
|
+
this.bot.sendChatAction(chatId, "typing").catch(() => { });
|
|
760
513
|
session.typingTimer = setInterval(() => {
|
|
761
|
-
this.bot.sendChatAction(chatId, "typing"
|
|
514
|
+
this.bot.sendChatAction(chatId, "typing").catch(() => { });
|
|
762
515
|
}, TYPING_INTERVAL_MS);
|
|
763
516
|
}
|
|
764
517
|
stopTyping(session) {
|
|
@@ -773,17 +526,15 @@ export class CcTgBot {
|
|
|
773
526
|
session.flushTimer = null;
|
|
774
527
|
if (!raw)
|
|
775
528
|
return;
|
|
776
|
-
this.writeChatMessage("assistant", "cc-tg", raw, chatId);
|
|
777
529
|
const text = session.isRetry ? `✅ Claude is back!\n\n${raw}` : raw;
|
|
778
530
|
session.isRetry = false;
|
|
779
|
-
// Format for Telegram
|
|
531
|
+
// Format for Telegram MarkdownV2 and split if needed (max 4096 chars)
|
|
780
532
|
const formatted = formatForTelegram(text);
|
|
781
533
|
const chunks = splitLongMessage(formatted);
|
|
782
|
-
const threadId = session.threadId;
|
|
783
534
|
for (const chunk of chunks) {
|
|
784
|
-
this.
|
|
785
|
-
//
|
|
786
|
-
this.
|
|
535
|
+
this.bot.sendMessage(chatId, chunk, { parse_mode: "MarkdownV2" }).catch(() => {
|
|
536
|
+
// MarkdownV2 parse failed — retry as plain text
|
|
537
|
+
this.bot.sendMessage(chatId, chunk).catch((err) => console.error(`[tg:${chatId}] send failed:`, err.message));
|
|
787
538
|
});
|
|
788
539
|
}
|
|
789
540
|
// Hybrid file upload: find files mentioned in result text that Claude actually wrote
|
|
@@ -956,12 +707,11 @@ export class CcTgBot {
|
|
|
956
707
|
const MAX_TG_FILE_BYTES = 50 * 1024 * 1024;
|
|
957
708
|
if (fileSize > MAX_TG_FILE_BYTES) {
|
|
958
709
|
const mb = (fileSize / (1024 * 1024)).toFixed(1);
|
|
959
|
-
this.
|
|
710
|
+
this.bot.sendMessage(chatId, `File too large for Telegram (${mb}mb). Find it at: ${filePath}`).catch(() => { });
|
|
960
711
|
continue;
|
|
961
712
|
}
|
|
962
713
|
console.log(`[claude:files] uploading to telegram: ${filePath}`);
|
|
963
|
-
|
|
964
|
-
this.bot.sendDocument(chatId, filePath, docOpts).catch((err) => console.error(`[tg:${chatId}] sendDocument failed for ${filePath}:`, err.message));
|
|
714
|
+
this.bot.sendDocument(chatId, filePath).catch((err) => console.error(`[tg:${chatId}] sendDocument failed for ${filePath}:`, err.message));
|
|
965
715
|
}
|
|
966
716
|
// Clear written files for next turn
|
|
967
717
|
session.writtenFiles.clear();
|
|
@@ -976,6 +726,203 @@ export class CcTgBot {
|
|
|
976
726
|
const toolUse = content.find((b) => b.type === "tool_use");
|
|
977
727
|
return toolUse?.name ?? "";
|
|
978
728
|
}
|
|
729
|
+
runCronTask(chatId, prompt) {
|
|
730
|
+
// Fresh isolated Claude session — never touches main conversation
|
|
731
|
+
const cronProcess = new ClaudeProcess({
|
|
732
|
+
cwd: this.opts.cwd,
|
|
733
|
+
token: this.opts.claudeToken,
|
|
734
|
+
});
|
|
735
|
+
const taskPrompt = [
|
|
736
|
+
"You are handling a scheduled background task.",
|
|
737
|
+
"This is NOT part of the user's ongoing conversation.",
|
|
738
|
+
"Be concise. Report results only. No greetings or pleasantries.",
|
|
739
|
+
"If there is nothing to report, say so in one sentence.",
|
|
740
|
+
"",
|
|
741
|
+
`SCHEDULED TASK: ${prompt}`,
|
|
742
|
+
].join("\n");
|
|
743
|
+
let output = "";
|
|
744
|
+
const cronUsage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
|
|
745
|
+
cronProcess.on("usage", (usage) => {
|
|
746
|
+
cronUsage.inputTokens += usage.inputTokens;
|
|
747
|
+
cronUsage.outputTokens += usage.outputTokens;
|
|
748
|
+
cronUsage.cacheReadTokens += usage.cacheReadTokens;
|
|
749
|
+
cronUsage.cacheWriteTokens += usage.cacheWriteTokens;
|
|
750
|
+
});
|
|
751
|
+
cronProcess.on("message", (msg) => {
|
|
752
|
+
if (msg.type === "result") {
|
|
753
|
+
const text = extractText(msg);
|
|
754
|
+
if (text)
|
|
755
|
+
output += text;
|
|
756
|
+
const result = output.trim();
|
|
757
|
+
if (result) {
|
|
758
|
+
let footer = "";
|
|
759
|
+
try {
|
|
760
|
+
footer = formatCronCostFooter(cronUsage);
|
|
761
|
+
}
|
|
762
|
+
catch (err) {
|
|
763
|
+
console.error(`[cron] cost footer error:`, err.message);
|
|
764
|
+
}
|
|
765
|
+
const cronFormatted = formatForTelegram(`🕐 ${result}${footer}`);
|
|
766
|
+
const chunks = splitLongMessage(cronFormatted);
|
|
767
|
+
(async () => {
|
|
768
|
+
for (const chunk of chunks) {
|
|
769
|
+
try {
|
|
770
|
+
await this.bot.sendMessage(chatId, chunk, { parse_mode: "MarkdownV2" });
|
|
771
|
+
}
|
|
772
|
+
catch {
|
|
773
|
+
// MarkdownV2 parse failed — retry as plain text
|
|
774
|
+
try {
|
|
775
|
+
await this.bot.sendMessage(chatId, chunk);
|
|
776
|
+
}
|
|
777
|
+
catch (err) {
|
|
778
|
+
console.error(`[cron] failed to send result to chat=${chatId}:`, err.message);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
})();
|
|
783
|
+
}
|
|
784
|
+
cronProcess.kill();
|
|
785
|
+
}
|
|
786
|
+
});
|
|
787
|
+
cronProcess.on("error", (err) => {
|
|
788
|
+
console.error(`[cron] task error for chat=${chatId}:`, err.message);
|
|
789
|
+
cronProcess.kill();
|
|
790
|
+
});
|
|
791
|
+
cronProcess.on("exit", () => {
|
|
792
|
+
console.log(`[cron] task complete for chat=${chatId}`);
|
|
793
|
+
});
|
|
794
|
+
cronProcess.sendPrompt(taskPrompt);
|
|
795
|
+
}
|
|
796
|
+
async handleCron(chatId, text) {
|
|
797
|
+
const args = text.slice("/cron".length).trim();
|
|
798
|
+
// /cron list
|
|
799
|
+
if (args === "list" || args === "") {
|
|
800
|
+
const jobs = this.cron.list(chatId);
|
|
801
|
+
if (!jobs.length) {
|
|
802
|
+
await this.bot.sendMessage(chatId, "No cron jobs.");
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
const lines = jobs.map((j, i) => {
|
|
806
|
+
const short = j.prompt.length > 50 ? j.prompt.slice(0, 50) + "…" : j.prompt;
|
|
807
|
+
return `#${i + 1} ${j.schedule} — "${short}"`;
|
|
808
|
+
});
|
|
809
|
+
await this.bot.sendMessage(chatId, `Cron jobs (${jobs.length}):\n${lines.join("\n")}`);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
// /cron clear
|
|
813
|
+
if (args === "clear") {
|
|
814
|
+
const n = this.cron.clearAll(chatId);
|
|
815
|
+
await this.bot.sendMessage(chatId, `Cleared ${n} cron job(s).`);
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
// /cron remove <id>
|
|
819
|
+
if (args.startsWith("remove ")) {
|
|
820
|
+
const id = args.slice("remove ".length).trim();
|
|
821
|
+
const ok = this.cron.remove(chatId, id);
|
|
822
|
+
await this.bot.sendMessage(chatId, ok ? `Removed ${id}.` : `Not found: ${id}`);
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
// /cron edit [<#> ...]
|
|
826
|
+
if (args === "edit" || args.startsWith("edit ")) {
|
|
827
|
+
await this.handleCronEdit(chatId, args.slice("edit".length).trim());
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
// /cron every 1h <prompt>
|
|
831
|
+
const scheduleMatch = args.match(/^(every\s+\d+[mhd])\s+(.+)$/i);
|
|
832
|
+
if (!scheduleMatch) {
|
|
833
|
+
await this.bot.sendMessage(chatId, "Usage:\n/cron every 1h <prompt>\n/cron list\n/cron edit\n/cron remove <id>\n/cron clear");
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
const schedule = scheduleMatch[1];
|
|
837
|
+
const prompt = scheduleMatch[2];
|
|
838
|
+
const job = this.cron.add(chatId, schedule, prompt);
|
|
839
|
+
if (!job) {
|
|
840
|
+
await this.bot.sendMessage(chatId, "Invalid schedule. Use: every 30m / every 2h / every 1d");
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
await this.bot.sendMessage(chatId, `Cron set [${job.id}]: ${schedule} — "${prompt}"`);
|
|
844
|
+
}
|
|
845
|
+
async handleCronEdit(chatId, editArgs) {
|
|
846
|
+
const jobs = this.cron.list(chatId);
|
|
847
|
+
// No args — show numbered list with edit instructions
|
|
848
|
+
if (!editArgs) {
|
|
849
|
+
if (!jobs.length) {
|
|
850
|
+
await this.bot.sendMessage(chatId, "No cron jobs to edit.");
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
const lines = jobs.map((j, i) => {
|
|
854
|
+
const short = j.prompt.length > 50 ? j.prompt.slice(0, 50) + "…" : j.prompt;
|
|
855
|
+
return `#${i + 1} ${j.schedule} — "${short}"`;
|
|
856
|
+
});
|
|
857
|
+
await this.bot.sendMessage(chatId, `Cron jobs:\n${lines.join("\n")}\n\n` +
|
|
858
|
+
"Edit options:\n" +
|
|
859
|
+
"/cron edit <#> every <N><unit> <new prompt>\n" +
|
|
860
|
+
"/cron edit <#> schedule every <N><unit>\n" +
|
|
861
|
+
"/cron edit <#> prompt <new prompt>");
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
// Expect: <index> <rest>
|
|
865
|
+
const indexMatch = editArgs.match(/^(\d+)\s+(.+)$/);
|
|
866
|
+
if (!indexMatch) {
|
|
867
|
+
await this.bot.sendMessage(chatId, "Usage: /cron edit <#> every <N><unit> <new prompt>");
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
const index = parseInt(indexMatch[1], 10) - 1;
|
|
871
|
+
if (index < 0 || index >= jobs.length) {
|
|
872
|
+
await this.bot.sendMessage(chatId, `Invalid job number. Use /cron edit to see the list.`);
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
const job = jobs[index];
|
|
876
|
+
const editCmd = indexMatch[2];
|
|
877
|
+
// /cron edit <#> schedule every <N><unit>
|
|
878
|
+
if (editCmd.startsWith("schedule ")) {
|
|
879
|
+
const newSchedule = editCmd.slice("schedule ".length).trim();
|
|
880
|
+
const result = this.cron.update(chatId, job.id, { schedule: newSchedule });
|
|
881
|
+
if (result === null) {
|
|
882
|
+
await this.bot.sendMessage(chatId, "Invalid schedule. Use: every 30m / every 2h / every 1d");
|
|
883
|
+
}
|
|
884
|
+
else if (result === false) {
|
|
885
|
+
await this.bot.sendMessage(chatId, "Job not found.");
|
|
886
|
+
}
|
|
887
|
+
else {
|
|
888
|
+
await this.bot.sendMessage(chatId, `#${index + 1} schedule updated to ${newSchedule}.`);
|
|
889
|
+
}
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
// /cron edit <#> prompt <new-prompt>
|
|
893
|
+
if (editCmd.startsWith("prompt ")) {
|
|
894
|
+
const newPrompt = editCmd.slice("prompt ".length).trim();
|
|
895
|
+
const result = this.cron.update(chatId, job.id, { prompt: newPrompt });
|
|
896
|
+
if (result === false) {
|
|
897
|
+
await this.bot.sendMessage(chatId, "Job not found.");
|
|
898
|
+
}
|
|
899
|
+
else {
|
|
900
|
+
await this.bot.sendMessage(chatId, `#${index + 1} prompt updated to "${newPrompt}".`);
|
|
901
|
+
}
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
// /cron edit <#> every <N><unit> <new-prompt>
|
|
905
|
+
const fullMatch = editCmd.match(/^(every\s+\d+[mhd])\s+(.+)$/i);
|
|
906
|
+
if (fullMatch) {
|
|
907
|
+
const newSchedule = fullMatch[1];
|
|
908
|
+
const newPrompt = fullMatch[2];
|
|
909
|
+
const result = this.cron.update(chatId, job.id, { schedule: newSchedule, prompt: newPrompt });
|
|
910
|
+
if (result === null) {
|
|
911
|
+
await this.bot.sendMessage(chatId, "Invalid schedule. Use: every 30m / every 2h / every 1d");
|
|
912
|
+
}
|
|
913
|
+
else if (result === false) {
|
|
914
|
+
await this.bot.sendMessage(chatId, "Job not found.");
|
|
915
|
+
}
|
|
916
|
+
else {
|
|
917
|
+
await this.bot.sendMessage(chatId, `#${index + 1} updated: ${newSchedule} — "${newPrompt}"`);
|
|
918
|
+
}
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
await this.bot.sendMessage(chatId, "Edit options:\n" +
|
|
922
|
+
"/cron edit <#> every <N><unit> <new prompt>\n" +
|
|
923
|
+
"/cron edit <#> schedule every <N><unit>\n" +
|
|
924
|
+
"/cron edit <#> prompt <new prompt>");
|
|
925
|
+
}
|
|
979
926
|
/** Find cc-agent PIDs via pgrep. Returns array of numeric PIDs. */
|
|
980
927
|
findCcAgentPids() {
|
|
981
928
|
try {
|
|
@@ -1001,33 +948,33 @@ export class CcTgBot {
|
|
|
1001
948
|
}
|
|
1002
949
|
return pids;
|
|
1003
950
|
}
|
|
1004
|
-
async handleReloadMcp(chatId
|
|
1005
|
-
await this.
|
|
951
|
+
async handleReloadMcp(chatId) {
|
|
952
|
+
await this.bot.sendMessage(chatId, "Clearing npx cache and reloading MCP...");
|
|
1006
953
|
try {
|
|
1007
954
|
const home = process.env.HOME ?? "~";
|
|
1008
955
|
execSync(`rm -rf "${home}/.npm/_npx/"`, { encoding: "utf8", shell: "/bin/sh" });
|
|
1009
956
|
console.log("[mcp] cleared ~/.npm/_npx/");
|
|
1010
957
|
}
|
|
1011
958
|
catch (err) {
|
|
1012
|
-
await this.
|
|
959
|
+
await this.bot.sendMessage(chatId, `Warning: failed to clear npx cache: ${err.message}`);
|
|
1013
960
|
}
|
|
1014
961
|
const pids = this.killCcAgent();
|
|
1015
962
|
if (pids.length === 0) {
|
|
1016
|
-
await this.
|
|
963
|
+
await this.bot.sendMessage(chatId, "NPX cache cleared. No cc-agent process found — MCP will start fresh on the next agent call.");
|
|
1017
964
|
return;
|
|
1018
965
|
}
|
|
1019
|
-
await this.
|
|
966
|
+
await this.bot.sendMessage(chatId, `NPX cache cleared. Sent SIGTERM to cc-agent (pid${pids.length > 1 ? "s" : ""}: ${pids.join(", ")}).\nMCP restarted. New process will load on next agent call.`);
|
|
1020
967
|
}
|
|
1021
|
-
async handleMcpStatus(chatId
|
|
968
|
+
async handleMcpStatus(chatId) {
|
|
1022
969
|
try {
|
|
1023
970
|
const output = execSync("claude mcp list", { encoding: "utf8", shell: "/bin/sh" }).trim();
|
|
1024
|
-
await this.
|
|
971
|
+
await this.bot.sendMessage(chatId, `MCP server status:\n\n${output || "(no output)"}`);
|
|
1025
972
|
}
|
|
1026
973
|
catch (err) {
|
|
1027
|
-
await this.
|
|
974
|
+
await this.bot.sendMessage(chatId, `Failed to run claude mcp list: ${err.message}`);
|
|
1028
975
|
}
|
|
1029
976
|
}
|
|
1030
|
-
async handleMcpVersion(chatId
|
|
977
|
+
async handleMcpVersion(chatId) {
|
|
1031
978
|
let npmVersion = "unknown";
|
|
1032
979
|
let cacheEntries = "(unavailable)";
|
|
1033
980
|
try {
|
|
@@ -1044,9 +991,9 @@ export class CcTgBot {
|
|
|
1044
991
|
catch {
|
|
1045
992
|
cacheEntries = "(empty or not found)";
|
|
1046
993
|
}
|
|
1047
|
-
await this.
|
|
994
|
+
await this.bot.sendMessage(chatId, `cc-agent npm version: ${npmVersion}\n\nnpx cache (~/.npm/_npx/):\n${cacheEntries}`);
|
|
1048
995
|
}
|
|
1049
|
-
async handleClearNpxCache(chatId
|
|
996
|
+
async handleClearNpxCache(chatId) {
|
|
1050
997
|
const home = process.env.HOME ?? "/tmp";
|
|
1051
998
|
const cleared = [];
|
|
1052
999
|
const failed = [];
|
|
@@ -1069,10 +1016,10 @@ export class CcTgBot {
|
|
|
1069
1016
|
const clearNote = failed.length
|
|
1070
1017
|
? `Cleared: ${cleared.join(", ")}. Failed: ${failed.join(", ")}.`
|
|
1071
1018
|
: `Cleared: ${cleared.join(", ")}.`;
|
|
1072
|
-
await this.
|
|
1019
|
+
await this.bot.sendMessage(chatId, `${clearNote}${pidNote} Next call picks up latest npm version.`);
|
|
1073
1020
|
}
|
|
1074
|
-
async handleRestart(chatId
|
|
1075
|
-
await this.
|
|
1021
|
+
async handleRestart(chatId) {
|
|
1022
|
+
await this.bot.sendMessage(chatId, "Clearing cache and restarting... brb.");
|
|
1076
1023
|
await new Promise(resolve => setTimeout(resolve, 300));
|
|
1077
1024
|
// Clear npm caches before restart so launchd brings up fresh version
|
|
1078
1025
|
const home = process.env.HOME ?? "/tmp";
|
|
@@ -1083,103 +1030,45 @@ export class CcTgBot {
|
|
|
1083
1030
|
catch { }
|
|
1084
1031
|
}
|
|
1085
1032
|
// Kill all active Claude sessions cleanly
|
|
1086
|
-
for (const
|
|
1087
|
-
this.
|
|
1088
|
-
session.claude.kill();
|
|
1033
|
+
for (const [cid] of this.sessions) {
|
|
1034
|
+
this.killSession(cid);
|
|
1089
1035
|
}
|
|
1090
|
-
this.sessions.clear();
|
|
1091
1036
|
await new Promise(resolve => setTimeout(resolve, 200));
|
|
1092
1037
|
process.exit(0);
|
|
1093
1038
|
}
|
|
1094
|
-
async
|
|
1095
|
-
const args = text.slice("/cron".length).trim();
|
|
1096
|
-
if (args === "list" || args === "") {
|
|
1097
|
-
const jobs = this.cron.list(chatId);
|
|
1098
|
-
if (!jobs.length) {
|
|
1099
|
-
await this.replyToChat(chatId, "No cron jobs.", threadId);
|
|
1100
|
-
return;
|
|
1101
|
-
}
|
|
1102
|
-
const lines = jobs.map((j, i) => {
|
|
1103
|
-
const short = j.prompt.length > 50 ? j.prompt.slice(0, 50) + "…" : j.prompt;
|
|
1104
|
-
return `#${i + 1} ${j.schedule} — "${short}"`;
|
|
1105
|
-
});
|
|
1106
|
-
await this.replyToChat(chatId, `Cron jobs (${jobs.length}):\n${lines.join("\n")}`, threadId);
|
|
1107
|
-
return;
|
|
1108
|
-
}
|
|
1109
|
-
if (args === "clear") {
|
|
1110
|
-
const n = this.cron.clearAll(chatId);
|
|
1111
|
-
await this.replyToChat(chatId, `Cleared ${n} cron job(s).`, threadId);
|
|
1112
|
-
return;
|
|
1113
|
-
}
|
|
1114
|
-
if (args.startsWith("remove ")) {
|
|
1115
|
-
const id = args.slice("remove ".length).trim();
|
|
1116
|
-
const ok = this.cron.remove(chatId, id);
|
|
1117
|
-
await this.replyToChat(chatId, ok ? `Removed ${id}.` : `Not found: ${id}`, threadId);
|
|
1118
|
-
return;
|
|
1119
|
-
}
|
|
1120
|
-
const scheduleMatch = args.match(/^(every\s+\d+[mhd])\s+(.+)$/i);
|
|
1121
|
-
if (!scheduleMatch) {
|
|
1122
|
-
await this.replyToChat(chatId, "Usage:\n/cron every 1h <prompt>\n/cron list\n/cron remove <id>\n/cron clear", threadId);
|
|
1123
|
-
return;
|
|
1124
|
-
}
|
|
1125
|
-
const schedule = scheduleMatch[1];
|
|
1126
|
-
const prompt = scheduleMatch[2];
|
|
1127
|
-
const job = this.cron.add(chatId, schedule, prompt);
|
|
1128
|
-
if (!job) {
|
|
1129
|
-
await this.replyToChat(chatId, "Invalid schedule. Use: every 30m / every 2h / every 1d", threadId);
|
|
1130
|
-
return;
|
|
1131
|
-
}
|
|
1132
|
-
await this.replyToChat(chatId, `Cron set [${job.id}]: ${schedule} — "${prompt}"`, threadId);
|
|
1133
|
-
}
|
|
1134
|
-
runCronTask(chatId, prompt, done = () => { }) {
|
|
1135
|
-
const cronProcess = new ClaudeProcess({ cwd: this.opts.cwd ?? process.cwd() });
|
|
1136
|
-
cronProcess.sendPrompt(prompt);
|
|
1137
|
-
cronProcess.on("message", (msg) => {
|
|
1138
|
-
const result = extractText(msg);
|
|
1139
|
-
if (result) {
|
|
1140
|
-
const formatted = formatForTelegram(`🕐 ${result}`);
|
|
1141
|
-
const chunks = splitLongMessage(formatted);
|
|
1142
|
-
for (const chunk of chunks) {
|
|
1143
|
-
this.replyToChat(chatId, chunk).catch((err) => console.error("[cron] send failed:", err.message));
|
|
1144
|
-
}
|
|
1145
|
-
}
|
|
1146
|
-
});
|
|
1147
|
-
cronProcess.on("exit", () => done());
|
|
1148
|
-
}
|
|
1149
|
-
async handleGetFile(chatId, text, threadId) {
|
|
1039
|
+
async handleGetFile(chatId, text) {
|
|
1150
1040
|
const arg = text.slice("/get_file".length).trim();
|
|
1151
1041
|
if (!arg) {
|
|
1152
|
-
await this.
|
|
1042
|
+
await this.bot.sendMessage(chatId, "Usage: /get_file <path>");
|
|
1153
1043
|
return;
|
|
1154
1044
|
}
|
|
1155
1045
|
const filePath = resolve(arg);
|
|
1156
1046
|
const safeDirs = ["/tmp/", "/var/folders/", os.homedir() + "/Downloads/", this.opts.cwd ?? process.cwd()];
|
|
1157
1047
|
const inSafeDir = safeDirs.some(d => filePath.startsWith(d));
|
|
1158
1048
|
if (!inSafeDir) {
|
|
1159
|
-
await this.
|
|
1049
|
+
await this.bot.sendMessage(chatId, "Access denied: path not in allowed directories");
|
|
1160
1050
|
return;
|
|
1161
1051
|
}
|
|
1162
1052
|
if (!existsSync(filePath)) {
|
|
1163
|
-
await this.
|
|
1053
|
+
await this.bot.sendMessage(chatId, `File not found: ${filePath}`);
|
|
1164
1054
|
return;
|
|
1165
1055
|
}
|
|
1166
1056
|
if (!statSync(filePath).isFile()) {
|
|
1167
|
-
await this.
|
|
1057
|
+
await this.bot.sendMessage(chatId, `Not a file: ${filePath}`);
|
|
1168
1058
|
return;
|
|
1169
1059
|
}
|
|
1170
1060
|
if (this.isSensitiveFile(filePath)) {
|
|
1171
|
-
await this.
|
|
1061
|
+
await this.bot.sendMessage(chatId, "Access denied: sensitive file");
|
|
1172
1062
|
return;
|
|
1173
1063
|
}
|
|
1174
1064
|
const MAX_TG_FILE_BYTES = 50 * 1024 * 1024;
|
|
1175
1065
|
const fileSize = statSync(filePath).size;
|
|
1176
1066
|
if (fileSize > MAX_TG_FILE_BYTES) {
|
|
1177
1067
|
const mb = (fileSize / (1024 * 1024)).toFixed(1);
|
|
1178
|
-
await this.
|
|
1068
|
+
await this.bot.sendMessage(chatId, `File too large for Telegram (${mb}mb). Find it at: ${filePath}`);
|
|
1179
1069
|
return;
|
|
1180
1070
|
}
|
|
1181
|
-
|
|
1182
|
-
await this.bot.sendDocument(chatId, filePath, docOpts);
|
|
1071
|
+
await this.bot.sendDocument(chatId, filePath);
|
|
1183
1072
|
}
|
|
1184
1073
|
callCcAgentTool(toolName, args = {}) {
|
|
1185
1074
|
return new Promise((resolve) => {
|
|
@@ -1252,25 +1141,21 @@ export class CcTgBot {
|
|
|
1252
1141
|
proc.on("exit", () => { clearTimeout(timeout); done(null); });
|
|
1253
1142
|
});
|
|
1254
1143
|
}
|
|
1255
|
-
killSession(chatId,
|
|
1256
|
-
const
|
|
1257
|
-
const session = this.sessions.get(key);
|
|
1144
|
+
killSession(chatId, keepCrons = true) {
|
|
1145
|
+
const session = this.sessions.get(chatId);
|
|
1258
1146
|
if (session) {
|
|
1259
1147
|
this.stopTyping(session);
|
|
1260
1148
|
session.claude.kill();
|
|
1261
|
-
this.sessions.delete(
|
|
1149
|
+
this.sessions.delete(chatId);
|
|
1262
1150
|
}
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
return this.bot.getMe();
|
|
1151
|
+
if (!keepCrons)
|
|
1152
|
+
this.cron.clearAll(chatId);
|
|
1266
1153
|
}
|
|
1267
1154
|
stop() {
|
|
1268
1155
|
this.bot.stopPolling();
|
|
1269
|
-
for (const
|
|
1270
|
-
this.
|
|
1271
|
-
session.claude.kill();
|
|
1156
|
+
for (const [chatId] of this.sessions) {
|
|
1157
|
+
this.killSession(chatId);
|
|
1272
1158
|
}
|
|
1273
|
-
this.sessions.clear();
|
|
1274
1159
|
}
|
|
1275
1160
|
}
|
|
1276
1161
|
function buildPromptWithReplyContext(text, msg) {
|
|
@@ -1309,85 +1194,6 @@ function downloadToFile(url, destPath) {
|
|
|
1309
1194
|
}).on("error", reject);
|
|
1310
1195
|
});
|
|
1311
1196
|
}
|
|
1312
|
-
/** Fetch URL via Jina Reader and return first maxChars characters */
|
|
1313
|
-
function fetchUrlViaJina(url, maxChars = 2000) {
|
|
1314
|
-
const jinaUrl = `https://r.jina.ai/${url}`;
|
|
1315
|
-
return new Promise((resolve, reject) => {
|
|
1316
|
-
https.get(jinaUrl, (res) => {
|
|
1317
|
-
const chunks = [];
|
|
1318
|
-
res.on("data", (chunk) => chunks.push(chunk));
|
|
1319
|
-
res.on("end", () => {
|
|
1320
|
-
const text = Buffer.concat(chunks).toString("utf8");
|
|
1321
|
-
resolve(text.slice(0, maxChars));
|
|
1322
|
-
});
|
|
1323
|
-
res.on("error", reject);
|
|
1324
|
-
}).on("error", reject);
|
|
1325
|
-
});
|
|
1326
|
-
}
|
|
1327
|
-
/** Detect URLs in text, fetch each via Jina Reader, and prepend content to the prompt */
|
|
1328
|
-
export async function enrichPromptWithUrls(text) {
|
|
1329
|
-
const urlRegex = /https?:\/\/[^\s]+/g;
|
|
1330
|
-
const urls = text.match(urlRegex);
|
|
1331
|
-
if (!urls || urls.length === 0)
|
|
1332
|
-
return text;
|
|
1333
|
-
const prefixes = [];
|
|
1334
|
-
for (const url of urls) {
|
|
1335
|
-
// Skip jina.ai URLs to avoid recursion
|
|
1336
|
-
if (url.includes("r.jina.ai"))
|
|
1337
|
-
continue;
|
|
1338
|
-
try {
|
|
1339
|
-
const content = await fetchUrlViaJina(url);
|
|
1340
|
-
if (content.trim()) {
|
|
1341
|
-
prefixes.push(`[Web content from ${url}]:\n${content}`);
|
|
1342
|
-
}
|
|
1343
|
-
}
|
|
1344
|
-
catch (err) {
|
|
1345
|
-
console.warn(`[url-fetch] failed to fetch ${url}:`, err.message);
|
|
1346
|
-
}
|
|
1347
|
-
}
|
|
1348
|
-
if (prefixes.length === 0)
|
|
1349
|
-
return text;
|
|
1350
|
-
return prefixes.join("\n\n") + "\n\n" + text;
|
|
1351
|
-
}
|
|
1352
|
-
/** Parse frontmatter description from a skill markdown file */
|
|
1353
|
-
function parseSkillDescription(content) {
|
|
1354
|
-
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
1355
|
-
if (!match)
|
|
1356
|
-
return null;
|
|
1357
|
-
const frontmatter = match[1];
|
|
1358
|
-
const descMatch = frontmatter.match(/^description:\s*(.+)$/m);
|
|
1359
|
-
return descMatch ? descMatch[1].trim() : null;
|
|
1360
|
-
}
|
|
1361
|
-
/** List available skills from ~/.claude/skills/ */
|
|
1362
|
-
export function listSkills() {
|
|
1363
|
-
const skillsDir = join(os.homedir(), ".claude", "skills");
|
|
1364
|
-
if (!existsSync(skillsDir)) {
|
|
1365
|
-
return "No skills directory found at ~/.claude/skills/";
|
|
1366
|
-
}
|
|
1367
|
-
let files;
|
|
1368
|
-
try {
|
|
1369
|
-
files = readdirSync(skillsDir).filter((f) => f.endsWith(".md"));
|
|
1370
|
-
}
|
|
1371
|
-
catch {
|
|
1372
|
-
return "Could not read skills directory.";
|
|
1373
|
-
}
|
|
1374
|
-
if (files.length === 0) {
|
|
1375
|
-
return "No skills found in ~/.claude/skills/";
|
|
1376
|
-
}
|
|
1377
|
-
const lines = ["Available skills:"];
|
|
1378
|
-
for (const file of files.sort()) {
|
|
1379
|
-
const name = "/" + file.replace(/\.md$/, "");
|
|
1380
|
-
try {
|
|
1381
|
-
const content = readFileSync(join(skillsDir, file), "utf8");
|
|
1382
|
-
const description = parseSkillDescription(content);
|
|
1383
|
-
lines.push(description ? `${name} — ${description}` : name);
|
|
1384
|
-
}
|
|
1385
|
-
catch {
|
|
1386
|
-
lines.push(name);
|
|
1387
|
-
}
|
|
1388
|
-
}
|
|
1389
|
-
return lines.join("\n");
|
|
1390
|
-
}
|
|
1391
1197
|
export function splitMessage(text, maxLen = 4096) {
|
|
1392
1198
|
if (text.length <= maxLen)
|
|
1393
1199
|
return [text];
|