@gonzih/cc-tg 0.2.17 → 0.2.19

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 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;
@@ -41,6 +42,7 @@ export declare class CcTgBot {
41
42
  private handleClearNpxCache;
42
43
  private handleRestart;
43
44
  private handleGetFile;
45
+ private callCcAgentTool;
44
46
  private killSession;
45
47
  stop(): void;
46
48
  }
package/dist/bot.js CHANGED
@@ -3,10 +3,10 @@
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
- import { execSync } from "child_process";
9
+ import { execSync, spawn } from "child_process";
10
10
  import https from "https";
11
11
  import http from "http";
12
12
  import { ClaudeProcess, extractText } from "./claude.js";
@@ -24,14 +24,134 @@ 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
+ function formatAgentCostSummary(text) {
69
+ try {
70
+ const data = JSON.parse(text);
71
+ const totalCost = (data.total_cost_usd ?? data.total_cost ?? 0);
72
+ const totalJobs = (data.total_jobs ?? data.job_count ?? 0);
73
+ const byRepo = (data.by_repo ?? []);
74
+ const lines = [
75
+ "šŸ¤– Agent jobs (all time)",
76
+ `Total: $${totalCost.toFixed(2)} across ${totalJobs} jobs`,
77
+ ];
78
+ for (const entry of byRepo) {
79
+ const repo = (entry.repo ?? entry.repository ?? "unknown");
80
+ const cost = (entry.cost_usd ?? entry.cost ?? 0);
81
+ const jobs = (entry.job_count ?? entry.jobs ?? 0);
82
+ lines.push(` ${repo}: $${cost.toFixed(2)} (${jobs} jobs)`);
83
+ }
84
+ return lines.join("\n");
85
+ }
86
+ catch {
87
+ return `šŸ¤– Agent jobs (all time)\n${text}`;
88
+ }
89
+ }
90
+ class CostStore {
91
+ costs = new Map();
92
+ storePath;
93
+ constructor(cwd) {
94
+ this.storePath = join(cwd, ".cc-tg", "costs.json");
95
+ this.load();
96
+ }
97
+ get(chatId) {
98
+ let cost = this.costs.get(chatId);
99
+ if (!cost) {
100
+ cost = { totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0, totalCostUsd: 0, messageCount: 0 };
101
+ this.costs.set(chatId, cost);
102
+ }
103
+ return cost;
104
+ }
105
+ addUsage(chatId, usage) {
106
+ const cost = this.get(chatId);
107
+ cost.totalInputTokens += usage.inputTokens;
108
+ cost.totalOutputTokens += usage.outputTokens;
109
+ cost.totalCacheReadTokens += usage.cacheReadTokens;
110
+ cost.totalCacheWriteTokens += usage.cacheWriteTokens;
111
+ cost.totalCostUsd += computeCostUsd(usage);
112
+ this.persist();
113
+ }
114
+ incrementMessages(chatId) {
115
+ const cost = this.get(chatId);
116
+ cost.messageCount++;
117
+ this.persist();
118
+ }
119
+ persist() {
120
+ try {
121
+ const dir = join(this.storePath, "..");
122
+ if (!existsSync(dir))
123
+ mkdirSync(dir, { recursive: true });
124
+ const data = {};
125
+ for (const [chatId, cost] of this.costs) {
126
+ data[String(chatId)] = cost;
127
+ }
128
+ writeFileSync(this.storePath, JSON.stringify(data, null, 2));
129
+ }
130
+ catch (err) {
131
+ console.error("[costs] persist error:", err.message);
132
+ }
133
+ }
134
+ load() {
135
+ if (!existsSync(this.storePath))
136
+ return;
137
+ try {
138
+ const data = JSON.parse(readFileSync(this.storePath, "utf8"));
139
+ for (const [key, cost] of Object.entries(data)) {
140
+ this.costs.set(Number(key), cost);
141
+ }
142
+ console.log(`[costs] loaded ${this.costs.size} session costs from disk`);
143
+ }
144
+ catch (err) {
145
+ console.error("[costs] load error:", err.message);
146
+ }
147
+ }
148
+ }
30
149
  export class CcTgBot {
31
150
  bot;
32
151
  sessions = new Map();
33
152
  opts;
34
153
  cron;
154
+ costStore;
35
155
  constructor(opts) {
36
156
  this.opts = opts;
37
157
  this.bot = new TelegramBot(opts.telegramToken, { polling: true });
@@ -41,6 +161,7 @@ export class CcTgBot {
41
161
  this.cron = new CronManager(opts.cwd ?? process.cwd(), (chatId, prompt) => {
42
162
  this.runCronTask(chatId, prompt);
43
163
  });
164
+ this.costStore = new CostStore(opts.cwd ?? process.cwd());
44
165
  this.registerBotCommands();
45
166
  console.log("cc-tg bot started");
46
167
  console.log(`[voice] whisper available: ${isVoiceAvailable()}`);
@@ -135,6 +256,22 @@ export class CcTgBot {
135
256
  await this.handleGetFile(chatId, text);
136
257
  return;
137
258
  }
259
+ // /cost — show session token usage and cost
260
+ if (text === "/cost") {
261
+ const cost = this.costStore.get(chatId);
262
+ let reply = formatCostReport(cost);
263
+ try {
264
+ const rawSummary = await this.callCcAgentTool("cost_summary");
265
+ if (rawSummary) {
266
+ reply += "\n\n" + formatAgentCostSummary(rawSummary);
267
+ }
268
+ }
269
+ catch (err) {
270
+ console.error("[cost] cc-agent cost_summary failed:", err.message);
271
+ }
272
+ await this.bot.sendMessage(chatId, reply);
273
+ return;
274
+ }
138
275
  const session = this.getOrCreateSession(chatId);
139
276
  try {
140
277
  session.claude.sendPrompt(buildPromptWithReplyContext(text, msg));
@@ -235,6 +372,9 @@ export class CcTgBot {
235
372
  typingTimer: null,
236
373
  writtenFiles: new Set(),
237
374
  };
375
+ claude.on("usage", (usage) => {
376
+ this.costStore.addUsage(chatId, usage);
377
+ });
238
378
  claude.on("message", (msg) => {
239
379
  // Verbose logging — log every message type and subtype
240
380
  const subtype = msg.payload.subtype ?? "";
@@ -274,6 +414,7 @@ export class CcTgBot {
274
414
  if (msg.type !== "result")
275
415
  return;
276
416
  this.stopTyping(session);
417
+ this.costStore.incrementMessages(chatId);
277
418
  const text = extractText(msg);
278
419
  if (!text)
279
420
  return;
@@ -505,6 +646,13 @@ export class CcTgBot {
505
646
  `SCHEDULED TASK: ${prompt}`,
506
647
  ].join("\n");
507
648
  let output = "";
649
+ const cronUsage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
650
+ cronProcess.on("usage", (usage) => {
651
+ cronUsage.inputTokens += usage.inputTokens;
652
+ cronUsage.outputTokens += usage.outputTokens;
653
+ cronUsage.cacheReadTokens += usage.cacheReadTokens;
654
+ cronUsage.cacheWriteTokens += usage.cacheWriteTokens;
655
+ });
508
656
  cronProcess.on("message", (msg) => {
509
657
  if (msg.type === "result") {
510
658
  const text = extractText(msg);
@@ -512,7 +660,8 @@ export class CcTgBot {
512
660
  output += text;
513
661
  const result = output.trim();
514
662
  if (result) {
515
- const chunks = splitMessage(`šŸ• ${result}`);
663
+ const footer = formatCronCostFooter(cronUsage);
664
+ const chunks = splitMessage(`šŸ• ${result}${footer}`);
516
665
  (async () => {
517
666
  for (const chunk of chunks) {
518
667
  try {
@@ -773,6 +922,77 @@ export class CcTgBot {
773
922
  }
774
923
  await this.bot.sendDocument(chatId, filePath);
775
924
  }
925
+ callCcAgentTool(toolName, args = {}) {
926
+ return new Promise((resolve) => {
927
+ let settled = false;
928
+ const done = (val) => {
929
+ if (!settled) {
930
+ settled = true;
931
+ resolve(val);
932
+ }
933
+ };
934
+ let proc;
935
+ try {
936
+ proc = spawn("npx", ["-y", "@gonzih/cc-agent@latest"], {
937
+ env: { ...process.env },
938
+ stdio: ["pipe", "pipe", "pipe"],
939
+ });
940
+ }
941
+ catch (err) {
942
+ console.error("[mcp] failed to spawn cc-agent:", err.message);
943
+ done(null);
944
+ return;
945
+ }
946
+ const timeout = setTimeout(() => {
947
+ console.warn("[mcp] cc-agent tool call timed out");
948
+ proc.kill();
949
+ done(null);
950
+ }, 30_000);
951
+ let buffer = "";
952
+ const sendMsg = (msg) => { proc.stdin.write(JSON.stringify(msg) + "\n"); };
953
+ sendMsg({
954
+ jsonrpc: "2.0", id: 1, method: "initialize",
955
+ params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "cc-tg", version: "1.0.0" } },
956
+ });
957
+ proc.stdout.on("data", (chunk) => {
958
+ buffer += chunk.toString();
959
+ const lines = buffer.split("\n");
960
+ buffer = lines.pop() ?? "";
961
+ for (const line of lines) {
962
+ if (!line.trim())
963
+ continue;
964
+ try {
965
+ const msg = JSON.parse(line);
966
+ if (msg.id === 1 && "result" in msg) {
967
+ sendMsg({ jsonrpc: "2.0", method: "notifications/initialized" });
968
+ sendMsg({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: toolName, arguments: args } });
969
+ }
970
+ else if (msg.id === 2) {
971
+ clearTimeout(timeout);
972
+ if (msg.error) {
973
+ console.error("[mcp] cost_summary error:", JSON.stringify(msg.error));
974
+ proc.kill();
975
+ done(null);
976
+ return;
977
+ }
978
+ const result = msg.result;
979
+ const content = result?.content;
980
+ const text = (content ?? []).filter((b) => b.type === "text").map((b) => b.text).join("");
981
+ proc.kill();
982
+ done(text || null);
983
+ }
984
+ }
985
+ catch { /* ignore non-JSON lines */ }
986
+ }
987
+ });
988
+ proc.on("error", (err) => {
989
+ console.error("[mcp] cc-agent spawn error:", err.message);
990
+ clearTimeout(timeout);
991
+ done(null);
992
+ });
993
+ proc.on("exit", () => { clearTimeout(timeout); done(null); });
994
+ });
995
+ }
776
996
  killSession(chatId, keepCrons = true) {
777
997
  const session = this.sessions.get(chatId);
778
998
  if (session) {
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonzih/cc-tg",
3
- "version": "0.2.17",
3
+ "version": "0.2.19",
4
4
  "description": "Claude Code Telegram bot — chat with Claude Code via Telegram",
5
5
  "type": "module",
6
6
  "bin": {