@gonzih/cc-tg 0.9.13 → 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 -531
- 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,232 +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);
|
|
312
|
+
await this.bot.sendMessage(chatId, reply);
|
|
382
313
|
return;
|
|
383
314
|
}
|
|
384
|
-
|
|
385
|
-
if (text === "/voice_retry") {
|
|
386
|
-
await this.handleVoiceRetry(chatId, threadId);
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
|
-
const session = this.getOrCreateSession(chatId, threadId, threadName);
|
|
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
|
-
const errMsg = err.message;
|
|
548
|
-
failed++;
|
|
549
|
-
errors.push(`${fileId}: ${errMsg}`);
|
|
550
|
-
// Permanently unretryable (expired Telegram link) — remove from voice:pending
|
|
551
|
-
if (errMsg.includes("Bad Request") || errMsg.includes("file_id")) {
|
|
552
|
-
const matchPending = pendingRaw.find((r) => r.includes(`"${fileId}"`));
|
|
553
|
-
if (matchPending)
|
|
554
|
-
await this.redis.lrem("voice:pending", 0, matchPending).catch(() => { });
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
// Purge stale entries from voice:pending older than 48h
|
|
559
|
-
const staleThreshold = 48 * 60 * 60 * 1000;
|
|
560
|
-
let purged = 0;
|
|
561
|
-
for (const raw of pendingRaw) {
|
|
562
|
-
try {
|
|
563
|
-
const entry = JSON.parse(raw);
|
|
564
|
-
if (entry.timestamp && Date.now() - entry.timestamp > staleThreshold) {
|
|
565
|
-
await this.redis.lrem("voice:pending", 0, raw).catch(() => { });
|
|
566
|
-
purged++;
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
catch { /* skip malformed entries */ }
|
|
570
|
-
}
|
|
571
|
-
const lines = [`Voice retry complete: ${succeeded} succeeded, ${failed} failed, ${purged} stale entries purged.`];
|
|
572
|
-
if (errors.length > 0)
|
|
573
|
-
lines.push(...errors.map((e) => `• ${e}`));
|
|
574
|
-
await this.replyToChat(chatId, lines.join("\n"), threadId);
|
|
575
|
-
}
|
|
576
|
-
async handlePhoto(chatId, msg, threadId, threadName) {
|
|
363
|
+
async handlePhoto(chatId, msg) {
|
|
577
364
|
// Pick highest resolution photo
|
|
578
365
|
const photos = msg.photo;
|
|
579
366
|
const best = photos[photos.length - 1];
|
|
580
367
|
const caption = msg.caption?.trim();
|
|
581
368
|
console.log(`[photo:${chatId}] received image file_id=${best.file_id}`);
|
|
582
|
-
this.bot.sendChatAction(chatId, "typing"
|
|
369
|
+
this.bot.sendChatAction(chatId, "typing").catch(() => { });
|
|
583
370
|
try {
|
|
584
371
|
const fileLink = await this.bot.getFileLink(best.file_id);
|
|
585
372
|
const imageData = await fetchAsBase64(fileLink);
|
|
586
373
|
// Telegram photos are always JPEG
|
|
587
|
-
const session = this.getOrCreateSession(chatId
|
|
374
|
+
const session = this.getOrCreateSession(chatId);
|
|
588
375
|
session.claude.sendImage(imageData, "image/jpeg", caption);
|
|
589
376
|
this.startTyping(chatId, session);
|
|
590
377
|
}
|
|
591
378
|
catch (err) {
|
|
592
379
|
console.error(`[photo:${chatId}] error:`, err.message);
|
|
593
|
-
await this.
|
|
380
|
+
await this.bot.sendMessage(chatId, `Failed to process image: ${err.message}`);
|
|
594
381
|
}
|
|
595
382
|
}
|
|
596
|
-
async handleDocument(chatId, msg
|
|
383
|
+
async handleDocument(chatId, msg) {
|
|
597
384
|
const doc = msg.document;
|
|
598
385
|
const caption = msg.caption?.trim();
|
|
599
386
|
const fileName = doc.file_name ?? `file_${doc.file_id}`;
|
|
600
387
|
console.log(`[doc:${chatId}] received document file_name=${fileName} mime=${doc.mime_type}`);
|
|
601
|
-
this.bot.sendChatAction(chatId, "typing"
|
|
388
|
+
this.bot.sendChatAction(chatId, "typing").catch(() => { });
|
|
602
389
|
try {
|
|
603
390
|
const uploadsDir = join(this.opts.cwd ?? process.cwd(), ".cc-tg", "uploads");
|
|
604
391
|
mkdirSync(uploadsDir, { recursive: true });
|
|
@@ -609,34 +396,22 @@ export class CcTgBot {
|
|
|
609
396
|
const prompt = caption
|
|
610
397
|
? `${caption}\n\nATTACHMENTS: [${fileName}](${destPath})`
|
|
611
398
|
: `ATTACHMENTS: [${fileName}](${destPath})`;
|
|
612
|
-
const session = this.getOrCreateSession(chatId
|
|
399
|
+
const session = this.getOrCreateSession(chatId);
|
|
613
400
|
session.claude.sendPrompt(prompt);
|
|
614
401
|
this.startTyping(chatId, session);
|
|
615
402
|
}
|
|
616
403
|
catch (err) {
|
|
617
404
|
console.error(`[doc:${chatId}] error:`, err.message);
|
|
618
|
-
await this.
|
|
405
|
+
await this.bot.sendMessage(chatId, `Failed to receive document: ${err.message}`);
|
|
619
406
|
}
|
|
620
407
|
}
|
|
621
|
-
getOrCreateSession(chatId
|
|
622
|
-
const
|
|
623
|
-
const existing = this.sessions.get(key);
|
|
408
|
+
getOrCreateSession(chatId) {
|
|
409
|
+
const existing = this.sessions.get(chatId);
|
|
624
410
|
if (existing && !existing.claude.exited)
|
|
625
411
|
return existing;
|
|
626
|
-
// Determine CWD for this thread — check THREAD_CWD_MAP by name then by ID
|
|
627
|
-
let sessionCwd = this.opts.cwd;
|
|
628
|
-
const threadCwdMap = this.getThreadCwdMap();
|
|
629
|
-
if (threadName && threadCwdMap[threadName]) {
|
|
630
|
-
sessionCwd = threadCwdMap[threadName];
|
|
631
|
-
console.log(`[cc-tg] thread "${threadName}" → cwd: ${sessionCwd}`);
|
|
632
|
-
}
|
|
633
|
-
else if (threadId !== undefined && threadCwdMap[String(threadId)]) {
|
|
634
|
-
sessionCwd = threadCwdMap[String(threadId)];
|
|
635
|
-
console.log(`[cc-tg] thread ${threadId} → cwd: ${sessionCwd}`);
|
|
636
|
-
}
|
|
637
412
|
const claude = new ClaudeProcess({
|
|
638
|
-
cwd:
|
|
639
|
-
token:
|
|
413
|
+
cwd: this.opts.cwd,
|
|
414
|
+
token: this.opts.claudeToken,
|
|
640
415
|
});
|
|
641
416
|
const session = {
|
|
642
417
|
claude,
|
|
@@ -646,7 +421,6 @@ export class CcTgBot {
|
|
|
646
421
|
writtenFiles: new Set(),
|
|
647
422
|
currentPrompt: "",
|
|
648
423
|
isRetry: false,
|
|
649
|
-
threadId,
|
|
650
424
|
};
|
|
651
425
|
claude.on("usage", (usage) => {
|
|
652
426
|
this.costStore.addUsage(chatId, usage);
|
|
@@ -655,47 +429,33 @@ export class CcTgBot {
|
|
|
655
429
|
// Verbose logging — log every message type and subtype
|
|
656
430
|
const subtype = msg.payload.subtype ?? "";
|
|
657
431
|
const toolName = this.extractToolName(msg);
|
|
658
|
-
const logParts = [`[claude:${
|
|
432
|
+
const logParts = [`[claude:${chatId}] msg=${msg.type}`];
|
|
659
433
|
if (subtype)
|
|
660
434
|
logParts.push(`subtype=${subtype}`);
|
|
661
435
|
if (toolName)
|
|
662
436
|
logParts.push(`tool=${toolName}`);
|
|
663
437
|
console.log(logParts.join(" "));
|
|
664
438
|
// Track files written by Write/Edit tool calls
|
|
665
|
-
this.trackWrittenFiles(msg, session,
|
|
666
|
-
// Publish tool call events to the chat log
|
|
667
|
-
if (msg.type === "assistant") {
|
|
668
|
-
const message = msg.payload.message;
|
|
669
|
-
const content = message?.content;
|
|
670
|
-
if (Array.isArray(content)) {
|
|
671
|
-
for (const block of content) {
|
|
672
|
-
if (block.type !== "tool_use")
|
|
673
|
-
continue;
|
|
674
|
-
const name = block.name;
|
|
675
|
-
const input = block.input;
|
|
676
|
-
this.writeChatMessage("tool", "cc-tg", `[tool] ${name}: ${JSON.stringify(input ?? {})}`, chatId);
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
}
|
|
439
|
+
this.trackWrittenFiles(msg, session, this.opts.cwd);
|
|
680
440
|
this.handleClaudeMessage(chatId, session, msg);
|
|
681
441
|
});
|
|
682
442
|
claude.on("stderr", (data) => {
|
|
683
443
|
const line = data.trim();
|
|
684
444
|
if (line)
|
|
685
|
-
console.error(`[claude:${
|
|
445
|
+
console.error(`[claude:${chatId}:stderr]`, line);
|
|
686
446
|
});
|
|
687
447
|
claude.on("exit", (code) => {
|
|
688
|
-
console.log(`[claude:${
|
|
448
|
+
console.log(`[claude:${chatId}] exited code=${code}`);
|
|
689
449
|
this.stopTyping(session);
|
|
690
|
-
this.sessions.delete(
|
|
450
|
+
this.sessions.delete(chatId);
|
|
691
451
|
});
|
|
692
452
|
claude.on("error", (err) => {
|
|
693
|
-
console.error(`[claude:${
|
|
453
|
+
console.error(`[claude:${chatId}] process error: ${err.message}`);
|
|
694
454
|
this.bot.sendMessage(chatId, `Claude process error: ${err.message}`).catch(() => { });
|
|
695
455
|
this.stopTyping(session);
|
|
696
|
-
this.sessions.delete(
|
|
456
|
+
this.sessions.delete(chatId);
|
|
697
457
|
});
|
|
698
|
-
this.sessions.set(
|
|
458
|
+
this.sessions.set(chatId, session);
|
|
699
459
|
return session;
|
|
700
460
|
}
|
|
701
461
|
handleClaudeMessage(chatId, session, msg) {
|
|
@@ -711,58 +471,33 @@ export class CcTgBot {
|
|
|
711
471
|
// Check for usage/rate limit signals before forwarding to Telegram
|
|
712
472
|
const sig = detectUsageLimit(text);
|
|
713
473
|
if (sig.detected) {
|
|
714
|
-
const threadId = session.threadId;
|
|
715
|
-
const retryKey = this.sessionKey(chatId, threadId);
|
|
716
474
|
const lastPrompt = session.currentPrompt;
|
|
717
|
-
const prevRetry = this.pendingRetries.get(
|
|
475
|
+
const prevRetry = this.pendingRetries.get(chatId);
|
|
718
476
|
const attempt = (prevRetry?.attempt ?? 0) + 1;
|
|
719
477
|
if (prevRetry)
|
|
720
478
|
clearTimeout(prevRetry.timer);
|
|
721
|
-
this.
|
|
722
|
-
this.killSession(chatId
|
|
723
|
-
// Token rotation: if this is a usage_exhausted signal and we have multiple
|
|
724
|
-
// tokens, rotate to the next one and retry immediately instead of sleeping.
|
|
725
|
-
// Only rotate if we haven't yet cycled through all tokens (attempt <= count-1).
|
|
726
|
-
if (sig.reason === "usage_exhausted" && getTokenCount() > 1 && attempt <= getTokenCount() - 1) {
|
|
727
|
-
const prevIdx = getTokenIndex();
|
|
728
|
-
rotateToken();
|
|
729
|
-
const newIdx = getTokenIndex();
|
|
730
|
-
const total = getTokenCount();
|
|
731
|
-
console.log(`[cc-tg] Token ${prevIdx + 1}/${total} exhausted, rotating to token ${newIdx + 1}/${total}`);
|
|
732
|
-
this.replyToChat(chatId, `🔄 Token ${prevIdx + 1}/${total} exhausted, switching to token ${newIdx + 1}/${total}...`, threadId).catch(() => { });
|
|
733
|
-
this.pendingRetries.set(retryKey, { text: lastPrompt, attempt, timer: setTimeout(() => { }, 0) });
|
|
734
|
-
try {
|
|
735
|
-
const retrySession = this.getOrCreateSession(chatId, threadId);
|
|
736
|
-
retrySession.currentPrompt = lastPrompt;
|
|
737
|
-
retrySession.isRetry = true;
|
|
738
|
-
retrySession.claude.sendPrompt(lastPrompt);
|
|
739
|
-
this.startTyping(chatId, retrySession);
|
|
740
|
-
}
|
|
741
|
-
catch (err) {
|
|
742
|
-
this.replyToChat(chatId, `❌ Failed to retry with rotated token: ${err.message}`, threadId).catch(() => { });
|
|
743
|
-
}
|
|
744
|
-
return;
|
|
745
|
-
}
|
|
479
|
+
this.bot.sendMessage(chatId, sig.humanMessage).catch(() => { });
|
|
480
|
+
this.killSession(chatId);
|
|
746
481
|
if (attempt > 3) {
|
|
747
|
-
this.
|
|
748
|
-
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);
|
|
749
484
|
return;
|
|
750
485
|
}
|
|
751
|
-
console.log(`[usage-limit:${
|
|
486
|
+
console.log(`[usage-limit:${chatId}] ${sig.reason} — scheduling retry attempt=${attempt} in ${sig.retryAfterMs}ms`);
|
|
752
487
|
const timer = setTimeout(() => {
|
|
753
|
-
this.pendingRetries.delete(
|
|
488
|
+
this.pendingRetries.delete(chatId);
|
|
754
489
|
try {
|
|
755
|
-
const retrySession = this.getOrCreateSession(chatId
|
|
490
|
+
const retrySession = this.getOrCreateSession(chatId);
|
|
756
491
|
retrySession.currentPrompt = lastPrompt;
|
|
757
492
|
retrySession.isRetry = true;
|
|
758
493
|
retrySession.claude.sendPrompt(lastPrompt);
|
|
759
494
|
this.startTyping(chatId, retrySession);
|
|
760
495
|
}
|
|
761
496
|
catch (err) {
|
|
762
|
-
this.
|
|
497
|
+
this.bot.sendMessage(chatId, `❌ Failed to retry: ${err.message}`).catch(() => { });
|
|
763
498
|
}
|
|
764
499
|
}, sig.retryAfterMs);
|
|
765
|
-
this.pendingRetries.set(
|
|
500
|
+
this.pendingRetries.set(chatId, { text: lastPrompt, attempt, timer });
|
|
766
501
|
return;
|
|
767
502
|
}
|
|
768
503
|
// Accumulate text and debounce — Claude streams chunks rapidly
|
|
@@ -774,11 +509,9 @@ export class CcTgBot {
|
|
|
774
509
|
startTyping(chatId, session) {
|
|
775
510
|
this.stopTyping(session);
|
|
776
511
|
// Send immediately, then keep alive every 4s
|
|
777
|
-
|
|
778
|
-
const threadOpts = session.threadId !== undefined ? { message_thread_id: session.threadId } : undefined;
|
|
779
|
-
this.bot.sendChatAction(chatId, "typing", threadOpts).catch(() => { });
|
|
512
|
+
this.bot.sendChatAction(chatId, "typing").catch(() => { });
|
|
780
513
|
session.typingTimer = setInterval(() => {
|
|
781
|
-
this.bot.sendChatAction(chatId, "typing"
|
|
514
|
+
this.bot.sendChatAction(chatId, "typing").catch(() => { });
|
|
782
515
|
}, TYPING_INTERVAL_MS);
|
|
783
516
|
}
|
|
784
517
|
stopTyping(session) {
|
|
@@ -793,17 +526,15 @@ export class CcTgBot {
|
|
|
793
526
|
session.flushTimer = null;
|
|
794
527
|
if (!raw)
|
|
795
528
|
return;
|
|
796
|
-
this.writeChatMessage("assistant", "cc-tg", raw, chatId);
|
|
797
529
|
const text = session.isRetry ? `✅ Claude is back!\n\n${raw}` : raw;
|
|
798
530
|
session.isRetry = false;
|
|
799
|
-
// Format for Telegram
|
|
531
|
+
// Format for Telegram MarkdownV2 and split if needed (max 4096 chars)
|
|
800
532
|
const formatted = formatForTelegram(text);
|
|
801
533
|
const chunks = splitLongMessage(formatted);
|
|
802
|
-
const threadId = session.threadId;
|
|
803
534
|
for (const chunk of chunks) {
|
|
804
|
-
this.
|
|
805
|
-
//
|
|
806
|
-
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));
|
|
807
538
|
});
|
|
808
539
|
}
|
|
809
540
|
// Hybrid file upload: find files mentioned in result text that Claude actually wrote
|
|
@@ -976,12 +707,11 @@ export class CcTgBot {
|
|
|
976
707
|
const MAX_TG_FILE_BYTES = 50 * 1024 * 1024;
|
|
977
708
|
if (fileSize > MAX_TG_FILE_BYTES) {
|
|
978
709
|
const mb = (fileSize / (1024 * 1024)).toFixed(1);
|
|
979
|
-
this.
|
|
710
|
+
this.bot.sendMessage(chatId, `File too large for Telegram (${mb}mb). Find it at: ${filePath}`).catch(() => { });
|
|
980
711
|
continue;
|
|
981
712
|
}
|
|
982
713
|
console.log(`[claude:files] uploading to telegram: ${filePath}`);
|
|
983
|
-
|
|
984
|
-
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));
|
|
985
715
|
}
|
|
986
716
|
// Clear written files for next turn
|
|
987
717
|
session.writtenFiles.clear();
|
|
@@ -996,6 +726,203 @@ export class CcTgBot {
|
|
|
996
726
|
const toolUse = content.find((b) => b.type === "tool_use");
|
|
997
727
|
return toolUse?.name ?? "";
|
|
998
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
|
+
}
|
|
999
926
|
/** Find cc-agent PIDs via pgrep. Returns array of numeric PIDs. */
|
|
1000
927
|
findCcAgentPids() {
|
|
1001
928
|
try {
|
|
@@ -1021,33 +948,33 @@ export class CcTgBot {
|
|
|
1021
948
|
}
|
|
1022
949
|
return pids;
|
|
1023
950
|
}
|
|
1024
|
-
async handleReloadMcp(chatId
|
|
1025
|
-
await this.
|
|
951
|
+
async handleReloadMcp(chatId) {
|
|
952
|
+
await this.bot.sendMessage(chatId, "Clearing npx cache and reloading MCP...");
|
|
1026
953
|
try {
|
|
1027
954
|
const home = process.env.HOME ?? "~";
|
|
1028
955
|
execSync(`rm -rf "${home}/.npm/_npx/"`, { encoding: "utf8", shell: "/bin/sh" });
|
|
1029
956
|
console.log("[mcp] cleared ~/.npm/_npx/");
|
|
1030
957
|
}
|
|
1031
958
|
catch (err) {
|
|
1032
|
-
await this.
|
|
959
|
+
await this.bot.sendMessage(chatId, `Warning: failed to clear npx cache: ${err.message}`);
|
|
1033
960
|
}
|
|
1034
961
|
const pids = this.killCcAgent();
|
|
1035
962
|
if (pids.length === 0) {
|
|
1036
|
-
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.");
|
|
1037
964
|
return;
|
|
1038
965
|
}
|
|
1039
|
-
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.`);
|
|
1040
967
|
}
|
|
1041
|
-
async handleMcpStatus(chatId
|
|
968
|
+
async handleMcpStatus(chatId) {
|
|
1042
969
|
try {
|
|
1043
970
|
const output = execSync("claude mcp list", { encoding: "utf8", shell: "/bin/sh" }).trim();
|
|
1044
|
-
await this.
|
|
971
|
+
await this.bot.sendMessage(chatId, `MCP server status:\n\n${output || "(no output)"}`);
|
|
1045
972
|
}
|
|
1046
973
|
catch (err) {
|
|
1047
|
-
await this.
|
|
974
|
+
await this.bot.sendMessage(chatId, `Failed to run claude mcp list: ${err.message}`);
|
|
1048
975
|
}
|
|
1049
976
|
}
|
|
1050
|
-
async handleMcpVersion(chatId
|
|
977
|
+
async handleMcpVersion(chatId) {
|
|
1051
978
|
let npmVersion = "unknown";
|
|
1052
979
|
let cacheEntries = "(unavailable)";
|
|
1053
980
|
try {
|
|
@@ -1064,9 +991,9 @@ export class CcTgBot {
|
|
|
1064
991
|
catch {
|
|
1065
992
|
cacheEntries = "(empty or not found)";
|
|
1066
993
|
}
|
|
1067
|
-
await this.
|
|
994
|
+
await this.bot.sendMessage(chatId, `cc-agent npm version: ${npmVersion}\n\nnpx cache (~/.npm/_npx/):\n${cacheEntries}`);
|
|
1068
995
|
}
|
|
1069
|
-
async handleClearNpxCache(chatId
|
|
996
|
+
async handleClearNpxCache(chatId) {
|
|
1070
997
|
const home = process.env.HOME ?? "/tmp";
|
|
1071
998
|
const cleared = [];
|
|
1072
999
|
const failed = [];
|
|
@@ -1089,10 +1016,10 @@ export class CcTgBot {
|
|
|
1089
1016
|
const clearNote = failed.length
|
|
1090
1017
|
? `Cleared: ${cleared.join(", ")}. Failed: ${failed.join(", ")}.`
|
|
1091
1018
|
: `Cleared: ${cleared.join(", ")}.`;
|
|
1092
|
-
await this.
|
|
1019
|
+
await this.bot.sendMessage(chatId, `${clearNote}${pidNote} Next call picks up latest npm version.`);
|
|
1093
1020
|
}
|
|
1094
|
-
async handleRestart(chatId
|
|
1095
|
-
await this.
|
|
1021
|
+
async handleRestart(chatId) {
|
|
1022
|
+
await this.bot.sendMessage(chatId, "Clearing cache and restarting... brb.");
|
|
1096
1023
|
await new Promise(resolve => setTimeout(resolve, 300));
|
|
1097
1024
|
// Clear npm caches before restart so launchd brings up fresh version
|
|
1098
1025
|
const home = process.env.HOME ?? "/tmp";
|
|
@@ -1103,103 +1030,45 @@ export class CcTgBot {
|
|
|
1103
1030
|
catch { }
|
|
1104
1031
|
}
|
|
1105
1032
|
// Kill all active Claude sessions cleanly
|
|
1106
|
-
for (const
|
|
1107
|
-
this.
|
|
1108
|
-
session.claude.kill();
|
|
1033
|
+
for (const [cid] of this.sessions) {
|
|
1034
|
+
this.killSession(cid);
|
|
1109
1035
|
}
|
|
1110
|
-
this.sessions.clear();
|
|
1111
1036
|
await new Promise(resolve => setTimeout(resolve, 200));
|
|
1112
1037
|
process.exit(0);
|
|
1113
1038
|
}
|
|
1114
|
-
async
|
|
1115
|
-
const args = text.slice("/cron".length).trim();
|
|
1116
|
-
if (args === "list" || args === "") {
|
|
1117
|
-
const jobs = this.cron.list(chatId);
|
|
1118
|
-
if (!jobs.length) {
|
|
1119
|
-
await this.replyToChat(chatId, "No cron jobs.", threadId);
|
|
1120
|
-
return;
|
|
1121
|
-
}
|
|
1122
|
-
const lines = jobs.map((j, i) => {
|
|
1123
|
-
const short = j.prompt.length > 50 ? j.prompt.slice(0, 50) + "…" : j.prompt;
|
|
1124
|
-
return `#${i + 1} ${j.schedule} — "${short}"`;
|
|
1125
|
-
});
|
|
1126
|
-
await this.replyToChat(chatId, `Cron jobs (${jobs.length}):\n${lines.join("\n")}`, threadId);
|
|
1127
|
-
return;
|
|
1128
|
-
}
|
|
1129
|
-
if (args === "clear") {
|
|
1130
|
-
const n = this.cron.clearAll(chatId);
|
|
1131
|
-
await this.replyToChat(chatId, `Cleared ${n} cron job(s).`, threadId);
|
|
1132
|
-
return;
|
|
1133
|
-
}
|
|
1134
|
-
if (args.startsWith("remove ")) {
|
|
1135
|
-
const id = args.slice("remove ".length).trim();
|
|
1136
|
-
const ok = this.cron.remove(chatId, id);
|
|
1137
|
-
await this.replyToChat(chatId, ok ? `Removed ${id}.` : `Not found: ${id}`, threadId);
|
|
1138
|
-
return;
|
|
1139
|
-
}
|
|
1140
|
-
const scheduleMatch = args.match(/^(every\s+\d+[mhd])\s+(.+)$/i);
|
|
1141
|
-
if (!scheduleMatch) {
|
|
1142
|
-
await this.replyToChat(chatId, "Usage:\n/cron every 1h <prompt>\n/cron list\n/cron remove <id>\n/cron clear", threadId);
|
|
1143
|
-
return;
|
|
1144
|
-
}
|
|
1145
|
-
const schedule = scheduleMatch[1];
|
|
1146
|
-
const prompt = scheduleMatch[2];
|
|
1147
|
-
const job = this.cron.add(chatId, schedule, prompt);
|
|
1148
|
-
if (!job) {
|
|
1149
|
-
await this.replyToChat(chatId, "Invalid schedule. Use: every 30m / every 2h / every 1d", threadId);
|
|
1150
|
-
return;
|
|
1151
|
-
}
|
|
1152
|
-
await this.replyToChat(chatId, `Cron set [${job.id}]: ${schedule} — "${prompt}"`, threadId);
|
|
1153
|
-
}
|
|
1154
|
-
runCronTask(chatId, prompt, done = () => { }) {
|
|
1155
|
-
const cronProcess = new ClaudeProcess({ cwd: this.opts.cwd ?? process.cwd() });
|
|
1156
|
-
cronProcess.sendPrompt(prompt);
|
|
1157
|
-
cronProcess.on("message", (msg) => {
|
|
1158
|
-
const result = extractText(msg);
|
|
1159
|
-
if (result) {
|
|
1160
|
-
const formatted = formatForTelegram(`🕐 ${result}`);
|
|
1161
|
-
const chunks = splitLongMessage(formatted);
|
|
1162
|
-
for (const chunk of chunks) {
|
|
1163
|
-
this.replyToChat(chatId, chunk).catch((err) => console.error("[cron] send failed:", err.message));
|
|
1164
|
-
}
|
|
1165
|
-
}
|
|
1166
|
-
});
|
|
1167
|
-
cronProcess.on("exit", () => done());
|
|
1168
|
-
}
|
|
1169
|
-
async handleGetFile(chatId, text, threadId) {
|
|
1039
|
+
async handleGetFile(chatId, text) {
|
|
1170
1040
|
const arg = text.slice("/get_file".length).trim();
|
|
1171
1041
|
if (!arg) {
|
|
1172
|
-
await this.
|
|
1042
|
+
await this.bot.sendMessage(chatId, "Usage: /get_file <path>");
|
|
1173
1043
|
return;
|
|
1174
1044
|
}
|
|
1175
1045
|
const filePath = resolve(arg);
|
|
1176
1046
|
const safeDirs = ["/tmp/", "/var/folders/", os.homedir() + "/Downloads/", this.opts.cwd ?? process.cwd()];
|
|
1177
1047
|
const inSafeDir = safeDirs.some(d => filePath.startsWith(d));
|
|
1178
1048
|
if (!inSafeDir) {
|
|
1179
|
-
await this.
|
|
1049
|
+
await this.bot.sendMessage(chatId, "Access denied: path not in allowed directories");
|
|
1180
1050
|
return;
|
|
1181
1051
|
}
|
|
1182
1052
|
if (!existsSync(filePath)) {
|
|
1183
|
-
await this.
|
|
1053
|
+
await this.bot.sendMessage(chatId, `File not found: ${filePath}`);
|
|
1184
1054
|
return;
|
|
1185
1055
|
}
|
|
1186
1056
|
if (!statSync(filePath).isFile()) {
|
|
1187
|
-
await this.
|
|
1057
|
+
await this.bot.sendMessage(chatId, `Not a file: ${filePath}`);
|
|
1188
1058
|
return;
|
|
1189
1059
|
}
|
|
1190
1060
|
if (this.isSensitiveFile(filePath)) {
|
|
1191
|
-
await this.
|
|
1061
|
+
await this.bot.sendMessage(chatId, "Access denied: sensitive file");
|
|
1192
1062
|
return;
|
|
1193
1063
|
}
|
|
1194
1064
|
const MAX_TG_FILE_BYTES = 50 * 1024 * 1024;
|
|
1195
1065
|
const fileSize = statSync(filePath).size;
|
|
1196
1066
|
if (fileSize > MAX_TG_FILE_BYTES) {
|
|
1197
1067
|
const mb = (fileSize / (1024 * 1024)).toFixed(1);
|
|
1198
|
-
await this.
|
|
1068
|
+
await this.bot.sendMessage(chatId, `File too large for Telegram (${mb}mb). Find it at: ${filePath}`);
|
|
1199
1069
|
return;
|
|
1200
1070
|
}
|
|
1201
|
-
|
|
1202
|
-
await this.bot.sendDocument(chatId, filePath, docOpts);
|
|
1071
|
+
await this.bot.sendDocument(chatId, filePath);
|
|
1203
1072
|
}
|
|
1204
1073
|
callCcAgentTool(toolName, args = {}) {
|
|
1205
1074
|
return new Promise((resolve) => {
|
|
@@ -1272,25 +1141,21 @@ export class CcTgBot {
|
|
|
1272
1141
|
proc.on("exit", () => { clearTimeout(timeout); done(null); });
|
|
1273
1142
|
});
|
|
1274
1143
|
}
|
|
1275
|
-
killSession(chatId,
|
|
1276
|
-
const
|
|
1277
|
-
const session = this.sessions.get(key);
|
|
1144
|
+
killSession(chatId, keepCrons = true) {
|
|
1145
|
+
const session = this.sessions.get(chatId);
|
|
1278
1146
|
if (session) {
|
|
1279
1147
|
this.stopTyping(session);
|
|
1280
1148
|
session.claude.kill();
|
|
1281
|
-
this.sessions.delete(
|
|
1149
|
+
this.sessions.delete(chatId);
|
|
1282
1150
|
}
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
return this.bot.getMe();
|
|
1151
|
+
if (!keepCrons)
|
|
1152
|
+
this.cron.clearAll(chatId);
|
|
1286
1153
|
}
|
|
1287
1154
|
stop() {
|
|
1288
1155
|
this.bot.stopPolling();
|
|
1289
|
-
for (const
|
|
1290
|
-
this.
|
|
1291
|
-
session.claude.kill();
|
|
1156
|
+
for (const [chatId] of this.sessions) {
|
|
1157
|
+
this.killSession(chatId);
|
|
1292
1158
|
}
|
|
1293
|
-
this.sessions.clear();
|
|
1294
1159
|
}
|
|
1295
1160
|
}
|
|
1296
1161
|
function buildPromptWithReplyContext(text, msg) {
|
|
@@ -1329,85 +1194,6 @@ function downloadToFile(url, destPath) {
|
|
|
1329
1194
|
}).on("error", reject);
|
|
1330
1195
|
});
|
|
1331
1196
|
}
|
|
1332
|
-
/** Fetch URL via Jina Reader and return first maxChars characters */
|
|
1333
|
-
function fetchUrlViaJina(url, maxChars = 2000) {
|
|
1334
|
-
const jinaUrl = `https://r.jina.ai/${url}`;
|
|
1335
|
-
return new Promise((resolve, reject) => {
|
|
1336
|
-
https.get(jinaUrl, (res) => {
|
|
1337
|
-
const chunks = [];
|
|
1338
|
-
res.on("data", (chunk) => chunks.push(chunk));
|
|
1339
|
-
res.on("end", () => {
|
|
1340
|
-
const text = Buffer.concat(chunks).toString("utf8");
|
|
1341
|
-
resolve(text.slice(0, maxChars));
|
|
1342
|
-
});
|
|
1343
|
-
res.on("error", reject);
|
|
1344
|
-
}).on("error", reject);
|
|
1345
|
-
});
|
|
1346
|
-
}
|
|
1347
|
-
/** Detect URLs in text, fetch each via Jina Reader, and prepend content to the prompt */
|
|
1348
|
-
export async function enrichPromptWithUrls(text) {
|
|
1349
|
-
const urlRegex = /https?:\/\/[^\s]+/g;
|
|
1350
|
-
const urls = text.match(urlRegex);
|
|
1351
|
-
if (!urls || urls.length === 0)
|
|
1352
|
-
return text;
|
|
1353
|
-
const prefixes = [];
|
|
1354
|
-
for (const url of urls) {
|
|
1355
|
-
// Skip jina.ai URLs to avoid recursion
|
|
1356
|
-
if (url.includes("r.jina.ai"))
|
|
1357
|
-
continue;
|
|
1358
|
-
try {
|
|
1359
|
-
const content = await fetchUrlViaJina(url);
|
|
1360
|
-
if (content.trim()) {
|
|
1361
|
-
prefixes.push(`[Web content from ${url}]:\n${content}`);
|
|
1362
|
-
}
|
|
1363
|
-
}
|
|
1364
|
-
catch (err) {
|
|
1365
|
-
console.warn(`[url-fetch] failed to fetch ${url}:`, err.message);
|
|
1366
|
-
}
|
|
1367
|
-
}
|
|
1368
|
-
if (prefixes.length === 0)
|
|
1369
|
-
return text;
|
|
1370
|
-
return prefixes.join("\n\n") + "\n\n" + text;
|
|
1371
|
-
}
|
|
1372
|
-
/** Parse frontmatter description from a skill markdown file */
|
|
1373
|
-
function parseSkillDescription(content) {
|
|
1374
|
-
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
1375
|
-
if (!match)
|
|
1376
|
-
return null;
|
|
1377
|
-
const frontmatter = match[1];
|
|
1378
|
-
const descMatch = frontmatter.match(/^description:\s*(.+)$/m);
|
|
1379
|
-
return descMatch ? descMatch[1].trim() : null;
|
|
1380
|
-
}
|
|
1381
|
-
/** List available skills from ~/.claude/skills/ */
|
|
1382
|
-
export function listSkills() {
|
|
1383
|
-
const skillsDir = join(os.homedir(), ".claude", "skills");
|
|
1384
|
-
if (!existsSync(skillsDir)) {
|
|
1385
|
-
return "No skills directory found at ~/.claude/skills/";
|
|
1386
|
-
}
|
|
1387
|
-
let files;
|
|
1388
|
-
try {
|
|
1389
|
-
files = readdirSync(skillsDir).filter((f) => f.endsWith(".md"));
|
|
1390
|
-
}
|
|
1391
|
-
catch {
|
|
1392
|
-
return "Could not read skills directory.";
|
|
1393
|
-
}
|
|
1394
|
-
if (files.length === 0) {
|
|
1395
|
-
return "No skills found in ~/.claude/skills/";
|
|
1396
|
-
}
|
|
1397
|
-
const lines = ["Available skills:"];
|
|
1398
|
-
for (const file of files.sort()) {
|
|
1399
|
-
const name = "/" + file.replace(/\.md$/, "");
|
|
1400
|
-
try {
|
|
1401
|
-
const content = readFileSync(join(skillsDir, file), "utf8");
|
|
1402
|
-
const description = parseSkillDescription(content);
|
|
1403
|
-
lines.push(description ? `${name} — ${description}` : name);
|
|
1404
|
-
}
|
|
1405
|
-
catch {
|
|
1406
|
-
lines.push(name);
|
|
1407
|
-
}
|
|
1408
|
-
}
|
|
1409
|
-
return lines.join("\n");
|
|
1410
|
-
}
|
|
1411
1197
|
export function splitMessage(text, maxLen = 4096) {
|
|
1412
1198
|
if (text.length <= maxLen)
|
|
1413
1199
|
return [text];
|