@gonzih/cc-tg 0.2.16 ā 0.2.18
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/dist/bot.d.ts +2 -0
- package/dist/bot.js +167 -16
- package/dist/claude.d.ts +7 -0
- package/dist/claude.js +23 -0
- package/package.json +1 -1
package/dist/bot.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export declare class CcTgBot {
|
|
|
13
13
|
private sessions;
|
|
14
14
|
private opts;
|
|
15
15
|
private cron;
|
|
16
|
+
private costStore;
|
|
16
17
|
constructor(opts: BotOptions);
|
|
17
18
|
private registerBotCommands;
|
|
18
19
|
private isAllowed;
|
|
@@ -29,6 +30,7 @@ export declare class CcTgBot {
|
|
|
29
30
|
private isSensitiveFile;
|
|
30
31
|
private uploadMentionedFiles;
|
|
31
32
|
private extractToolName;
|
|
33
|
+
private runCronTask;
|
|
32
34
|
private handleCron;
|
|
33
35
|
private handleCronEdit;
|
|
34
36
|
/** Find cc-agent PIDs via pgrep. Returns array of numeric PIDs. */
|
package/dist/bot.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* One ClaudeProcess per chat_id ā sessions are isolated per user.
|
|
4
4
|
*/
|
|
5
5
|
import TelegramBot from "node-telegram-bot-api";
|
|
6
|
-
import { existsSync, createWriteStream, mkdirSync, statSync, readdirSync } from "fs";
|
|
6
|
+
import { existsSync, createWriteStream, mkdirSync, statSync, readdirSync, readFileSync, writeFileSync } from "fs";
|
|
7
7
|
import { resolve, basename, join } from "path";
|
|
8
8
|
import os from "os";
|
|
9
9
|
import { execSync } from "child_process";
|
|
@@ -24,32 +24,122 @@ const BOT_COMMANDS = [
|
|
|
24
24
|
{ command: "clear_npx_cache", description: "Clear npx cache and restart MCP to pick up latest version" },
|
|
25
25
|
{ command: "restart", description: "Restart the bot process in-place" },
|
|
26
26
|
{ command: "get_file", description: "Send a file from the server to this chat" },
|
|
27
|
+
{ command: "cost", description: "Show session token usage and cost" },
|
|
27
28
|
];
|
|
28
29
|
const FLUSH_DELAY_MS = 800; // debounce streaming chunks into one Telegram message
|
|
29
30
|
const TYPING_INTERVAL_MS = 4000; // re-send typing action before Telegram's 5s expiry
|
|
31
|
+
// Claude Sonnet 4.6 pricing (per 1M tokens)
|
|
32
|
+
const PRICING = {
|
|
33
|
+
inputPerM: 3.00,
|
|
34
|
+
outputPerM: 15.00,
|
|
35
|
+
cacheReadPerM: 0.30,
|
|
36
|
+
cacheWritePerM: 3.75,
|
|
37
|
+
};
|
|
38
|
+
function computeCostUsd(usage) {
|
|
39
|
+
return (usage.inputTokens * PRICING.inputPerM / 1_000_000 +
|
|
40
|
+
usage.outputTokens * PRICING.outputPerM / 1_000_000 +
|
|
41
|
+
usage.cacheReadTokens * PRICING.cacheReadPerM / 1_000_000 +
|
|
42
|
+
usage.cacheWriteTokens * PRICING.cacheWritePerM / 1_000_000);
|
|
43
|
+
}
|
|
44
|
+
function formatTokens(n) {
|
|
45
|
+
if (n >= 1000)
|
|
46
|
+
return `${(n / 1000).toFixed(1)}k`;
|
|
47
|
+
return String(n);
|
|
48
|
+
}
|
|
49
|
+
function formatCostReport(cost) {
|
|
50
|
+
const inputCost = cost.totalInputTokens * PRICING.inputPerM / 1_000_000;
|
|
51
|
+
const outputCost = cost.totalOutputTokens * PRICING.outputPerM / 1_000_000;
|
|
52
|
+
const cacheReadCost = cost.totalCacheReadTokens * PRICING.cacheReadPerM / 1_000_000;
|
|
53
|
+
const cacheWriteCost = cost.totalCacheWriteTokens * PRICING.cacheWritePerM / 1_000_000;
|
|
54
|
+
return [
|
|
55
|
+
"š Session cost",
|
|
56
|
+
`Messages: ${cost.messageCount}`,
|
|
57
|
+
`Total: $${cost.totalCostUsd.toFixed(3)}`,
|
|
58
|
+
` Input: ${formatTokens(cost.totalInputTokens)} tokens ($${inputCost.toFixed(3)})`,
|
|
59
|
+
` Output: ${formatTokens(cost.totalOutputTokens)} tokens ($${outputCost.toFixed(3)})`,
|
|
60
|
+
` Cache read: ${formatTokens(cost.totalCacheReadTokens)} tokens ($${cacheReadCost.toFixed(3)})`,
|
|
61
|
+
` Cache write: ${formatTokens(cost.totalCacheWriteTokens)} tokens ($${cacheWriteCost.toFixed(3)})`,
|
|
62
|
+
].join("\n");
|
|
63
|
+
}
|
|
64
|
+
function formatCronCostFooter(usage) {
|
|
65
|
+
const cost = computeCostUsd(usage);
|
|
66
|
+
return `\nš° Cron cost: $${cost.toFixed(4)} (${formatTokens(usage.inputTokens)} in / ${formatTokens(usage.outputTokens)} out tokens)`;
|
|
67
|
+
}
|
|
68
|
+
class CostStore {
|
|
69
|
+
costs = new Map();
|
|
70
|
+
storePath;
|
|
71
|
+
constructor(cwd) {
|
|
72
|
+
this.storePath = join(cwd, ".cc-tg", "costs.json");
|
|
73
|
+
this.load();
|
|
74
|
+
}
|
|
75
|
+
get(chatId) {
|
|
76
|
+
let cost = this.costs.get(chatId);
|
|
77
|
+
if (!cost) {
|
|
78
|
+
cost = { totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0, totalCostUsd: 0, messageCount: 0 };
|
|
79
|
+
this.costs.set(chatId, cost);
|
|
80
|
+
}
|
|
81
|
+
return cost;
|
|
82
|
+
}
|
|
83
|
+
addUsage(chatId, usage) {
|
|
84
|
+
const cost = this.get(chatId);
|
|
85
|
+
cost.totalInputTokens += usage.inputTokens;
|
|
86
|
+
cost.totalOutputTokens += usage.outputTokens;
|
|
87
|
+
cost.totalCacheReadTokens += usage.cacheReadTokens;
|
|
88
|
+
cost.totalCacheWriteTokens += usage.cacheWriteTokens;
|
|
89
|
+
cost.totalCostUsd += computeCostUsd(usage);
|
|
90
|
+
this.persist();
|
|
91
|
+
}
|
|
92
|
+
incrementMessages(chatId) {
|
|
93
|
+
const cost = this.get(chatId);
|
|
94
|
+
cost.messageCount++;
|
|
95
|
+
this.persist();
|
|
96
|
+
}
|
|
97
|
+
persist() {
|
|
98
|
+
try {
|
|
99
|
+
const dir = join(this.storePath, "..");
|
|
100
|
+
if (!existsSync(dir))
|
|
101
|
+
mkdirSync(dir, { recursive: true });
|
|
102
|
+
const data = {};
|
|
103
|
+
for (const [chatId, cost] of this.costs) {
|
|
104
|
+
data[String(chatId)] = cost;
|
|
105
|
+
}
|
|
106
|
+
writeFileSync(this.storePath, JSON.stringify(data, null, 2));
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
console.error("[costs] persist error:", err.message);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
load() {
|
|
113
|
+
if (!existsSync(this.storePath))
|
|
114
|
+
return;
|
|
115
|
+
try {
|
|
116
|
+
const data = JSON.parse(readFileSync(this.storePath, "utf8"));
|
|
117
|
+
for (const [key, cost] of Object.entries(data)) {
|
|
118
|
+
this.costs.set(Number(key), cost);
|
|
119
|
+
}
|
|
120
|
+
console.log(`[costs] loaded ${this.costs.size} session costs from disk`);
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
console.error("[costs] load error:", err.message);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
30
127
|
export class CcTgBot {
|
|
31
128
|
bot;
|
|
32
129
|
sessions = new Map();
|
|
33
130
|
opts;
|
|
34
131
|
cron;
|
|
132
|
+
costStore;
|
|
35
133
|
constructor(opts) {
|
|
36
134
|
this.opts = opts;
|
|
37
135
|
this.bot = new TelegramBot(opts.telegramToken, { polling: true });
|
|
38
136
|
this.bot.on("message", (msg) => this.handleTelegram(msg));
|
|
39
137
|
this.bot.on("polling_error", (err) => console.error("[tg]", err.message));
|
|
40
|
-
// Cron manager ā fires
|
|
138
|
+
// Cron manager ā fires each task into an isolated ClaudeProcess
|
|
41
139
|
this.cron = new CronManager(opts.cwd ?? process.cwd(), (chatId, prompt) => {
|
|
42
|
-
|
|
43
|
-
try {
|
|
44
|
-
session.claude.sendPrompt(`[CRON: ${prompt}]\n\n${prompt}`);
|
|
45
|
-
this.startTyping(chatId, session);
|
|
46
|
-
// Tag result with cron prefix
|
|
47
|
-
session.pendingPrefix = `CRON: ${prompt}\n\n`;
|
|
48
|
-
}
|
|
49
|
-
catch (err) {
|
|
50
|
-
console.error(`[cron] failed to fire for chat=${chatId}:`, err.message);
|
|
51
|
-
}
|
|
140
|
+
this.runCronTask(chatId, prompt);
|
|
52
141
|
});
|
|
142
|
+
this.costStore = new CostStore(opts.cwd ?? process.cwd());
|
|
53
143
|
this.registerBotCommands();
|
|
54
144
|
console.log("cc-tg bot started");
|
|
55
145
|
console.log(`[voice] whisper available: ${isVoiceAvailable()}`);
|
|
@@ -144,6 +234,12 @@ export class CcTgBot {
|
|
|
144
234
|
await this.handleGetFile(chatId, text);
|
|
145
235
|
return;
|
|
146
236
|
}
|
|
237
|
+
// /cost ā show session token usage and cost
|
|
238
|
+
if (text === "/cost") {
|
|
239
|
+
const cost = this.costStore.get(chatId);
|
|
240
|
+
await this.bot.sendMessage(chatId, formatCostReport(cost));
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
147
243
|
const session = this.getOrCreateSession(chatId);
|
|
148
244
|
try {
|
|
149
245
|
session.claude.sendPrompt(buildPromptWithReplyContext(text, msg));
|
|
@@ -240,11 +336,13 @@ export class CcTgBot {
|
|
|
240
336
|
const session = {
|
|
241
337
|
claude,
|
|
242
338
|
pendingText: "",
|
|
243
|
-
pendingPrefix: "",
|
|
244
339
|
flushTimer: null,
|
|
245
340
|
typingTimer: null,
|
|
246
341
|
writtenFiles: new Set(),
|
|
247
342
|
};
|
|
343
|
+
claude.on("usage", (usage) => {
|
|
344
|
+
this.costStore.addUsage(chatId, usage);
|
|
345
|
+
});
|
|
248
346
|
claude.on("message", (msg) => {
|
|
249
347
|
// Verbose logging ā log every message type and subtype
|
|
250
348
|
const subtype = msg.payload.subtype ?? "";
|
|
@@ -284,6 +382,7 @@ export class CcTgBot {
|
|
|
284
382
|
if (msg.type !== "result")
|
|
285
383
|
return;
|
|
286
384
|
this.stopTyping(session);
|
|
385
|
+
this.costStore.incrementMessages(chatId);
|
|
287
386
|
const text = extractText(msg);
|
|
288
387
|
if (!text)
|
|
289
388
|
return;
|
|
@@ -309,13 +408,11 @@ export class CcTgBot {
|
|
|
309
408
|
}
|
|
310
409
|
flushPending(chatId, session) {
|
|
311
410
|
const raw = session.pendingText.trim();
|
|
312
|
-
const prefix = session.pendingPrefix;
|
|
313
411
|
session.pendingText = "";
|
|
314
|
-
session.pendingPrefix = "";
|
|
315
412
|
session.flushTimer = null;
|
|
316
413
|
if (!raw)
|
|
317
414
|
return;
|
|
318
|
-
const text =
|
|
415
|
+
const text = raw;
|
|
319
416
|
// Telegram max message length is 4096 chars ā split if needed
|
|
320
417
|
const chunks = splitMessage(text);
|
|
321
418
|
for (const chunk of chunks) {
|
|
@@ -502,6 +599,60 @@ export class CcTgBot {
|
|
|
502
599
|
const toolUse = content.find((b) => b.type === "tool_use");
|
|
503
600
|
return toolUse?.name ?? "";
|
|
504
601
|
}
|
|
602
|
+
runCronTask(chatId, prompt) {
|
|
603
|
+
// Fresh isolated Claude session ā never touches main conversation
|
|
604
|
+
const cronProcess = new ClaudeProcess({
|
|
605
|
+
cwd: this.opts.cwd,
|
|
606
|
+
token: this.opts.claudeToken,
|
|
607
|
+
});
|
|
608
|
+
const taskPrompt = [
|
|
609
|
+
"You are handling a scheduled background task.",
|
|
610
|
+
"This is NOT part of the user's ongoing conversation.",
|
|
611
|
+
"Be concise. Report results only. No greetings or pleasantries.",
|
|
612
|
+
"If there is nothing to report, say so in one sentence.",
|
|
613
|
+
"",
|
|
614
|
+
`SCHEDULED TASK: ${prompt}`,
|
|
615
|
+
].join("\n");
|
|
616
|
+
let output = "";
|
|
617
|
+
const cronUsage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
|
|
618
|
+
cronProcess.on("usage", (usage) => {
|
|
619
|
+
cronUsage.inputTokens += usage.inputTokens;
|
|
620
|
+
cronUsage.outputTokens += usage.outputTokens;
|
|
621
|
+
cronUsage.cacheReadTokens += usage.cacheReadTokens;
|
|
622
|
+
cronUsage.cacheWriteTokens += usage.cacheWriteTokens;
|
|
623
|
+
});
|
|
624
|
+
cronProcess.on("message", (msg) => {
|
|
625
|
+
if (msg.type === "result") {
|
|
626
|
+
const text = extractText(msg);
|
|
627
|
+
if (text)
|
|
628
|
+
output += text;
|
|
629
|
+
const result = output.trim();
|
|
630
|
+
if (result) {
|
|
631
|
+
const footer = formatCronCostFooter(cronUsage);
|
|
632
|
+
const chunks = splitMessage(`š ${result}${footer}`);
|
|
633
|
+
(async () => {
|
|
634
|
+
for (const chunk of chunks) {
|
|
635
|
+
try {
|
|
636
|
+
await this.bot.sendMessage(chatId, chunk);
|
|
637
|
+
}
|
|
638
|
+
catch (err) {
|
|
639
|
+
console.error(`[cron] failed to send result to chat=${chatId}:`, err.message);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
})();
|
|
643
|
+
}
|
|
644
|
+
cronProcess.kill();
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
cronProcess.on("error", (err) => {
|
|
648
|
+
console.error(`[cron] task error for chat=${chatId}:`, err.message);
|
|
649
|
+
cronProcess.kill();
|
|
650
|
+
});
|
|
651
|
+
cronProcess.on("exit", () => {
|
|
652
|
+
console.log(`[cron] task complete for chat=${chatId}`);
|
|
653
|
+
});
|
|
654
|
+
cronProcess.sendPrompt(taskPrompt);
|
|
655
|
+
}
|
|
505
656
|
async handleCron(chatId, text) {
|
|
506
657
|
const args = text.slice("/cron".length).trim();
|
|
507
658
|
// /cron list
|
package/dist/claude.d.ts
CHANGED
|
@@ -18,8 +18,15 @@ export interface ClaudeOptions {
|
|
|
18
18
|
/** OAuth token (sk-ant-oat01-...) or API key (sk-ant-api03-...) */
|
|
19
19
|
token?: string;
|
|
20
20
|
}
|
|
21
|
+
export interface UsageEvent {
|
|
22
|
+
inputTokens: number;
|
|
23
|
+
outputTokens: number;
|
|
24
|
+
cacheReadTokens: number;
|
|
25
|
+
cacheWriteTokens: number;
|
|
26
|
+
}
|
|
21
27
|
export declare interface ClaudeProcess {
|
|
22
28
|
on(event: "message", listener: (msg: ClaudeMessage) => void): this;
|
|
29
|
+
on(event: "usage", listener: (usage: UsageEvent) => void): this;
|
|
23
30
|
on(event: "error", listener: (err: Error) => void): this;
|
|
24
31
|
on(event: "exit", listener: (code: number | null) => void): this;
|
|
25
32
|
on(event: "stderr", listener: (data: string) => void): this;
|
package/dist/claude.js
CHANGED
|
@@ -109,6 +109,29 @@ export class ClaudeProcess extends EventEmitter {
|
|
|
109
109
|
continue;
|
|
110
110
|
try {
|
|
111
111
|
const raw = JSON.parse(line);
|
|
112
|
+
// Emit usage events from Anthropic API stream events passed through by Claude CLI
|
|
113
|
+
if (raw.type === "message_start") {
|
|
114
|
+
const usage = (raw.message?.usage);
|
|
115
|
+
if (usage) {
|
|
116
|
+
this.emit("usage", {
|
|
117
|
+
inputTokens: usage.input_tokens ?? 0,
|
|
118
|
+
outputTokens: 0, // output_tokens at message_start is always 0
|
|
119
|
+
cacheReadTokens: usage.cache_read_input_tokens ?? 0,
|
|
120
|
+
cacheWriteTokens: usage.cache_creation_input_tokens ?? 0,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
else if (raw.type === "message_delta") {
|
|
125
|
+
const usage = raw.usage;
|
|
126
|
+
if (usage?.output_tokens) {
|
|
127
|
+
this.emit("usage", {
|
|
128
|
+
inputTokens: 0,
|
|
129
|
+
outputTokens: usage.output_tokens,
|
|
130
|
+
cacheReadTokens: 0,
|
|
131
|
+
cacheWriteTokens: 0,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
112
135
|
const msg = this.parseMessage(raw);
|
|
113
136
|
if (msg)
|
|
114
137
|
this.emit("message", msg);
|