codeclaw 0.2.3 → 0.2.4
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 +11 -5
- package/dist/bot-telegram.js +192 -10
- package/dist/bot.js +3 -2
- package/dist/channel-telegram.js +29 -2
- package/dist/code-agent.js +296 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -48,10 +48,12 @@ No server. No Docker. No config files. Just one process bridging Telegram to you
|
|
|
48
48
|
- **Keep-alive** — OS-level sleep prevention (macOS `caffeinate`, Linux `systemd-inhibit`)
|
|
49
49
|
- **Directory browser** — switch working directory interactively via `/switch` with inline navigation
|
|
50
50
|
- **Image input** — send photos to the bot for visual context (screenshots, diagrams)
|
|
51
|
+
- **Artifact return** — agent can write screenshots/files to a per-turn manifest and codeclaw uploads them back to Telegram
|
|
51
52
|
- **Quick replies** — auto-detects yes/no questions and numbered options, shows inline buttons
|
|
52
53
|
- **Long output** — responses exceeding Telegram limits are split with a full `.md` file attachment
|
|
53
54
|
- **Thinking display** — shows agent thinking/reasoning process in collapsible blocks
|
|
54
55
|
- **Token tracking** — per-turn and cumulative input/output/cached token counts
|
|
56
|
+
- **Provider usage** — `/status` shows recent Codex/Claude usage windows and reset timing when local telemetry is available
|
|
55
57
|
- **Access control** — restrict by chat/user ID whitelist
|
|
56
58
|
- **Startup notice** — sends online status to all known chats on startup
|
|
57
59
|
- **Full access / safe mode** — let the agent run freely, or require confirmation before destructive actions
|
|
@@ -144,8 +146,9 @@ Once running, these commands are available in Telegram:
|
|
|
144
146
|
| `/sessions` | List, switch, or create sessions (paginated inline keyboard) |
|
|
145
147
|
| `/agents` | List installed agents, switch between them |
|
|
146
148
|
| `/switch` | Browse and change working directory (interactive file browser) |
|
|
147
|
-
| `/status` | Bot status: uptime, memory, agent, session, token usage |
|
|
149
|
+
| `/status` | Bot status: uptime, memory, agent, session, provider usage, token usage |
|
|
148
150
|
| `/host` | Host machine info: CPU, memory, disk, top processes |
|
|
151
|
+
| `/restart` | Restart with latest version via `npx --yes codeclaw@latest` |
|
|
149
152
|
| `/start` | Welcome message with command list |
|
|
150
153
|
|
|
151
154
|
> In private chats, just send text directly — no command prefix needed. Any unrecognized `/command` is forwarded to the agent as a prompt.
|
|
@@ -177,9 +180,9 @@ cli.ts → bot-telegram.ts → bot.ts → code-agent.ts
|
|
|
177
180
|
```
|
|
178
181
|
|
|
179
182
|
- **bot.ts** — channel-agnostic business logic, state, streaming bridge
|
|
180
|
-
- **bot-telegram.ts** — Telegram-specific rendering, keyboards, callbacks
|
|
181
|
-
- **channel-telegram.ts** — pure Telegram API transport (polling, sending, file download)
|
|
182
|
-
- **code-agent.ts** — AI agent abstraction (spawn CLI, parse JSONL stream)
|
|
183
|
+
- **bot-telegram.ts** — Telegram-specific rendering, keyboards, callbacks, artifact upload flow
|
|
184
|
+
- **channel-telegram.ts** — pure Telegram API transport (polling, sending, file download/upload routing)
|
|
185
|
+
- **code-agent.ts** — AI agent abstraction (spawn CLI, parse JSONL stream, inspect local usage telemetry)
|
|
183
186
|
|
|
184
187
|
Adding a new IM channel means creating `channel-xxx.ts` + `bot-xxx.ts` without touching shared logic.
|
|
185
188
|
|
|
@@ -229,10 +232,12 @@ claude / codex CLI
|
|
|
229
232
|
- **系统保活** — 操作系统级防休眠(macOS `caffeinate`、Linux `systemd-inhibit`)
|
|
230
233
|
- **目录浏览器** — 通过 `/switch` 交互式切换工作目录,内联导航
|
|
231
234
|
- **图片输入** — 向机器人发送图片提供视觉上下文(截图、设计图)
|
|
235
|
+
- **产物回传** — Agent 可按每轮 manifest 写出截图/文件,codeclaw 会自动回传到 Telegram
|
|
232
236
|
- **快捷回复** — 自动检测是/否问题和编号选项,显示内联按钮
|
|
233
237
|
- **长文本处理** — 超出 Telegram 限制的回复自动拆分,并附带完整 `.md` 文件
|
|
234
238
|
- **思考展示** — 在可折叠区块中显示 Agent 的思考/推理过程
|
|
235
239
|
- **Token 统计** — 每轮和累计的输入/输出/缓存 token 计数
|
|
240
|
+
- **Provider 用量** — `/status` 可展示最近的 Codex/Claude 用量窗口和重置时间(本地遥测可用时)
|
|
236
241
|
- **访问控制** — 按聊天/用户 ID 白名单限制
|
|
237
242
|
- **启动通知** — 启动时向所有已知聊天发送在线状态
|
|
238
243
|
- **完全访问 / 安全模式** — 让 AI 自由运行,或限制危险操作需确认
|
|
@@ -325,8 +330,9 @@ TELEGRAM_BOT_TOKEN=xxx CODEX_MODEL=o3 npx codeclaw -a codex
|
|
|
325
330
|
| `/sessions` | 列出、切换或创建会话(分页内联键盘) |
|
|
326
331
|
| `/agents` | 列出已安装的 Agent,切换使用 |
|
|
327
332
|
| `/switch` | 浏览和切换工作目录(交互式文件浏览器) |
|
|
328
|
-
| `/status` | 机器人状态:运行时间、内存、Agent、会话、Token 用量 |
|
|
333
|
+
| `/status` | 机器人状态:运行时间、内存、Agent、会话、Provider 用量、Token 用量 |
|
|
329
334
|
| `/host` | 宿主机信息:CPU、内存、磁盘、进程排行 |
|
|
335
|
+
| `/restart` | 通过 `npx --yes codeclaw@latest` 拉取最新版本并重启 |
|
|
330
336
|
| `/start` | 欢迎消息和命令列表 |
|
|
331
337
|
|
|
332
338
|
> 在私聊中直接发送文字即可,无需命令前缀。未识别的 `/命令` 会作为 prompt 转发给 Agent。
|
package/dist/bot-telegram.js
CHANGED
|
@@ -87,6 +87,90 @@ function detectQuickReplies(text) {
|
|
|
87
87
|
return numbered.map(m => `${m[1]}. ${m[2].trim().slice(0, 30)}`);
|
|
88
88
|
return [];
|
|
89
89
|
}
|
|
90
|
+
function isNpxBinary(bin) {
|
|
91
|
+
return path.basename(bin, path.extname(bin)).toLowerCase() === 'npx';
|
|
92
|
+
}
|
|
93
|
+
function ensureNonInteractiveRestartArgs(bin, args) {
|
|
94
|
+
if (!isNpxBinary(bin))
|
|
95
|
+
return args;
|
|
96
|
+
if (args.includes('--yes') || args.includes('-y'))
|
|
97
|
+
return args;
|
|
98
|
+
return ['--yes', ...args];
|
|
99
|
+
}
|
|
100
|
+
const ARTIFACT_MANIFEST = 'manifest.json';
|
|
101
|
+
const ARTIFACT_ROOT = path.join(os.tmpdir(), 'codeclaw-artifacts');
|
|
102
|
+
const ARTIFACT_MAX_FILES = 8;
|
|
103
|
+
const ARTIFACT_MAX_BYTES = 20 * 1024 * 1024;
|
|
104
|
+
const ARTIFACT_PHOTO_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp']);
|
|
105
|
+
function isPhotoFilename(filename) {
|
|
106
|
+
return ARTIFACT_PHOTO_EXTS.has(path.extname(filename).toLowerCase());
|
|
107
|
+
}
|
|
108
|
+
function buildArtifactPrompt(prompt, artifactDir, manifestPath) {
|
|
109
|
+
const base = prompt.trim() || 'Please help with this request.';
|
|
110
|
+
return [
|
|
111
|
+
base,
|
|
112
|
+
'',
|
|
113
|
+
'[Telegram Artifact Return]',
|
|
114
|
+
'If you create screenshots, images, logs, or other files that should be sent back to the Telegram user, write them only inside this directory:',
|
|
115
|
+
artifactDir,
|
|
116
|
+
'',
|
|
117
|
+
`When you want a file returned, also write this JSON manifest: ${manifestPath}`,
|
|
118
|
+
'Format:',
|
|
119
|
+
'{"files":[{"path":"screenshot.png","kind":"photo","caption":"optional caption"}]}',
|
|
120
|
+
'Rules:',
|
|
121
|
+
'- Use relative paths in "path". Never use absolute paths.',
|
|
122
|
+
'- Use "photo" for png/jpg/jpeg/webp images. Use "document" for everything else.',
|
|
123
|
+
'- Omit the manifest entirely if there is nothing to send back.',
|
|
124
|
+
].join('\n');
|
|
125
|
+
}
|
|
126
|
+
function humanizeUsageStatus(status) {
|
|
127
|
+
return (status || '').replace(/_/g, ' ').trim();
|
|
128
|
+
}
|
|
129
|
+
function usageRemainingSeconds(capturedAt, resetAfterSeconds) {
|
|
130
|
+
if (resetAfterSeconds == null)
|
|
131
|
+
return null;
|
|
132
|
+
const capturedAtMs = capturedAt ? Date.parse(capturedAt) : Number.NaN;
|
|
133
|
+
if (Number.isFinite(capturedAtMs)) {
|
|
134
|
+
return Math.round((capturedAtMs + resetAfterSeconds * 1000 - Date.now()) / 1000);
|
|
135
|
+
}
|
|
136
|
+
return resetAfterSeconds;
|
|
137
|
+
}
|
|
138
|
+
function formatProviderUsageLines(usage) {
|
|
139
|
+
const lines = ['', '<b>Provider Usage</b>'];
|
|
140
|
+
if (!usage.ok) {
|
|
141
|
+
lines.push(` Unavailable: ${escapeHtml(usage.error || 'No recent usage data found.')}`);
|
|
142
|
+
return lines;
|
|
143
|
+
}
|
|
144
|
+
if (usage.capturedAt) {
|
|
145
|
+
const capturedAtMs = Date.parse(usage.capturedAt);
|
|
146
|
+
if (Number.isFinite(capturedAtMs)) {
|
|
147
|
+
lines.push(` Updated: ${fmtUptime(Math.max(0, Date.now() - capturedAtMs))} ago`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (!usage.windows.length) {
|
|
151
|
+
const status = humanizeUsageStatus(usage.status);
|
|
152
|
+
lines.push(` ${escapeHtml(status || 'No window data')}`);
|
|
153
|
+
return lines;
|
|
154
|
+
}
|
|
155
|
+
for (const window of usage.windows) {
|
|
156
|
+
const parts = [];
|
|
157
|
+
if (window.usedPercent != null && window.remainingPercent != null) {
|
|
158
|
+
parts.push(`${window.usedPercent}% used / ${window.remainingPercent}% left`);
|
|
159
|
+
}
|
|
160
|
+
else if (window.usedPercent != null) {
|
|
161
|
+
parts.push(`${window.usedPercent}% used`);
|
|
162
|
+
}
|
|
163
|
+
const status = humanizeUsageStatus(window.status);
|
|
164
|
+
if (status)
|
|
165
|
+
parts.push(status);
|
|
166
|
+
const remainingSeconds = usageRemainingSeconds(usage.capturedAt, window.resetAfterSeconds);
|
|
167
|
+
if (remainingSeconds != null) {
|
|
168
|
+
parts.push(remainingSeconds > 0 ? `resets in ${fmtUptime(remainingSeconds * 1000)}` : 'reset passed');
|
|
169
|
+
}
|
|
170
|
+
lines.push(` ${escapeHtml(window.label)}: ${escapeHtml(parts.join(' | ') || 'No details')}`);
|
|
171
|
+
}
|
|
172
|
+
return lines;
|
|
173
|
+
}
|
|
90
174
|
// ---------------------------------------------------------------------------
|
|
91
175
|
// Directory browser (Telegram callback_data 64-byte limit)
|
|
92
176
|
// ---------------------------------------------------------------------------
|
|
@@ -252,7 +336,7 @@ export class TelegramBot extends Bot {
|
|
|
252
336
|
if (d.running) {
|
|
253
337
|
lines.push(`<b>Running:</b> ${fmtUptime(Date.now() - d.running.startedAt)} - ${escapeHtml(d.running.prompt.slice(0, 50))}`);
|
|
254
338
|
}
|
|
255
|
-
lines.push('', '<b>Usage</b>', ` Turns: ${d.stats.totalTurns}`);
|
|
339
|
+
lines.push(...formatProviderUsageLines(d.usage), '', '<b>Bot Usage</b>', ` Turns: ${d.stats.totalTurns}`);
|
|
256
340
|
if (d.stats.totalInputTokens || d.stats.totalOutputTokens) {
|
|
257
341
|
lines.push(` In: ${fmtTokens(d.stats.totalInputTokens)} Out: ${fmtTokens(d.stats.totalOutputTokens)}`);
|
|
258
342
|
if (d.stats.totalCachedTokens)
|
|
@@ -311,7 +395,7 @@ export class TelegramBot extends Bot {
|
|
|
311
395
|
return;
|
|
312
396
|
}
|
|
313
397
|
await ctx.reply(`<b>Restarting codeclaw...</b>\n\n` +
|
|
314
|
-
`Pulling latest version via <code>npx codeclaw@latest</code>.\n` +
|
|
398
|
+
`Pulling latest version via <code>npx --yes codeclaw@latest</code>.\n` +
|
|
315
399
|
`The bot will be back shortly.`, { parseMode: 'HTML' });
|
|
316
400
|
this.performRestart();
|
|
317
401
|
}
|
|
@@ -320,14 +404,18 @@ export class TelegramBot extends Bot {
|
|
|
320
404
|
this.log('restart: disconnecting...');
|
|
321
405
|
this.channel.disconnect();
|
|
322
406
|
this.stopKeepAlive();
|
|
323
|
-
const restartCmd = process.env.CODECLAW_RESTART_CMD || 'npx codeclaw@latest';
|
|
324
|
-
const [bin, ...
|
|
407
|
+
const restartCmd = process.env.CODECLAW_RESTART_CMD || 'npx --yes codeclaw@latest';
|
|
408
|
+
const [bin, ...rawArgs] = shellSplit(restartCmd);
|
|
409
|
+
const baseArgs = ensureNonInteractiveRestartArgs(bin, rawArgs);
|
|
325
410
|
const allArgs = [...baseArgs, ...process.argv.slice(2)];
|
|
326
411
|
this.log(`restart: spawning \`${bin} ${allArgs.join(' ')}\``);
|
|
327
412
|
const child = spawn(bin, allArgs, {
|
|
328
413
|
stdio: 'inherit',
|
|
329
414
|
detached: true,
|
|
330
|
-
env:
|
|
415
|
+
env: {
|
|
416
|
+
...process.env,
|
|
417
|
+
npm_config_yes: process.env.npm_config_yes || 'true',
|
|
418
|
+
},
|
|
331
419
|
});
|
|
332
420
|
child.unref();
|
|
333
421
|
this.log(`restart: new process spawned (PID ${child.pid}), exiting...`);
|
|
@@ -339,7 +427,8 @@ export class TelegramBot extends Bot {
|
|
|
339
427
|
if (!text && !msg.files.length)
|
|
340
428
|
return;
|
|
341
429
|
const cs = this.chat(ctx.chatId);
|
|
342
|
-
const
|
|
430
|
+
const artifactTurn = this.createArtifactTurn(ctx.chatId);
|
|
431
|
+
const prompt = buildArtifactPrompt(buildPrompt(text, msg.files), artifactTurn.dir, artifactTurn.manifestPath);
|
|
343
432
|
this.log(`[handleMessage] chat=${ctx.chatId} agent=${cs.agent} session=${cs.sessionId || '(new)'} prompt="${prompt.slice(0, 100)}" files=${msg.files.length}`);
|
|
344
433
|
const phId = await ctx.reply(`<code>${escapeHtml(cs.agent)} | thinking ...</code>`, { parseMode: 'HTML' });
|
|
345
434
|
if (!phId) {
|
|
@@ -382,14 +471,105 @@ export class TelegramBot extends Bot {
|
|
|
382
471
|
editCount++;
|
|
383
472
|
};
|
|
384
473
|
const result = await this.runStream(prompt, cs, msg.files, onText);
|
|
474
|
+
const artifacts = this.collectArtifacts(artifactTurn.dir, artifactTurn.manifestPath);
|
|
385
475
|
this.log(`[handleMessage] done agent=${cs.agent} ok=${result.ok} session=${result.sessionId || '?'} elapsed=${result.elapsedS.toFixed(1)}s edits=${editCount} ` +
|
|
386
476
|
`tokens=in:${fmtTokens(result.inputTokens)}/cached:${fmtTokens(result.cachedInputTokens)}/out:${fmtTokens(result.outputTokens)}`);
|
|
387
477
|
this.log(`[handleMessage] response preview: "${result.message.slice(0, 150)}"`);
|
|
388
|
-
await this.sendFinalReply(ctx, phId, cs.agent, result);
|
|
478
|
+
const finalMsgId = await this.sendFinalReply(ctx, phId, cs.agent, result);
|
|
479
|
+
await this.sendArtifacts(ctx, finalMsgId ?? phId, artifacts);
|
|
389
480
|
this.log(`[handleMessage] final reply sent to chat=${ctx.chatId}`);
|
|
390
481
|
}
|
|
391
482
|
finally {
|
|
392
483
|
this.activeTasks.delete(ctx.chatId);
|
|
484
|
+
this.cleanupArtifactTurn(artifactTurn.dir);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
createArtifactTurn(chatId) {
|
|
488
|
+
const turnId = `${chatId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
489
|
+
const dir = path.join(ARTIFACT_ROOT, String(chatId), turnId);
|
|
490
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
491
|
+
return { dir, manifestPath: path.join(dir, ARTIFACT_MANIFEST) };
|
|
492
|
+
}
|
|
493
|
+
cleanupArtifactTurn(dirPath) {
|
|
494
|
+
fs.rmSync(dirPath, { recursive: true, force: true });
|
|
495
|
+
}
|
|
496
|
+
collectArtifacts(dirPath, manifestPath) {
|
|
497
|
+
if (!fs.existsSync(manifestPath))
|
|
498
|
+
return [];
|
|
499
|
+
let parsed;
|
|
500
|
+
try {
|
|
501
|
+
parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
502
|
+
}
|
|
503
|
+
catch (e) {
|
|
504
|
+
this.log(`artifact manifest parse error: ${e}`);
|
|
505
|
+
return [];
|
|
506
|
+
}
|
|
507
|
+
const entries = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.files) ? parsed.files : [];
|
|
508
|
+
if (!entries.length)
|
|
509
|
+
return [];
|
|
510
|
+
const realDir = fs.realpathSync(dirPath);
|
|
511
|
+
const artifacts = [];
|
|
512
|
+
for (const entry of entries.slice(0, ARTIFACT_MAX_FILES)) {
|
|
513
|
+
const rawPath = typeof entry?.path === 'string' ? entry.path
|
|
514
|
+
: typeof entry?.name === 'string' ? entry.name
|
|
515
|
+
: '';
|
|
516
|
+
const relPath = rawPath.trim();
|
|
517
|
+
if (!relPath || path.isAbsolute(relPath)) {
|
|
518
|
+
this.log(`artifact skipped: invalid path "${rawPath}"`);
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
const resolved = path.resolve(dirPath, relPath);
|
|
522
|
+
const relative = path.relative(dirPath, resolved);
|
|
523
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
524
|
+
this.log(`artifact skipped: outside turn dir "${relPath}"`);
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
if (!fs.existsSync(resolved)) {
|
|
528
|
+
this.log(`artifact skipped: missing file "${relPath}"`);
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
const realFile = fs.realpathSync(resolved);
|
|
532
|
+
const realRelative = path.relative(realDir, realFile);
|
|
533
|
+
if (realRelative.startsWith('..') || path.isAbsolute(realRelative)) {
|
|
534
|
+
this.log(`artifact skipped: symlink outside turn dir "${relPath}"`);
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
const stat = fs.statSync(realFile);
|
|
538
|
+
if (!stat.isFile()) {
|
|
539
|
+
this.log(`artifact skipped: not a file "${relPath}"`);
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
if (stat.size > ARTIFACT_MAX_BYTES) {
|
|
543
|
+
this.log(`artifact skipped: too large "${relPath}" (${stat.size} bytes)`);
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
const filename = path.basename(realFile);
|
|
547
|
+
const requestedKind = typeof entry?.kind === 'string' ? entry.kind.toLowerCase()
|
|
548
|
+
: typeof entry?.type === 'string' ? entry.type.toLowerCase()
|
|
549
|
+
: '';
|
|
550
|
+
let kind = requestedKind === 'document' ? 'document'
|
|
551
|
+
: requestedKind === 'photo' ? 'photo'
|
|
552
|
+
: isPhotoFilename(filename) ? 'photo' : 'document';
|
|
553
|
+
if (kind === 'photo' && !isPhotoFilename(filename))
|
|
554
|
+
kind = 'document';
|
|
555
|
+
const caption = typeof entry?.caption === 'string' ? entry.caption.trim().slice(0, 1024) || undefined : undefined;
|
|
556
|
+
artifacts.push({ filePath: realFile, filename, kind, caption });
|
|
557
|
+
}
|
|
558
|
+
return artifacts;
|
|
559
|
+
}
|
|
560
|
+
async sendArtifacts(ctx, replyTo, artifacts) {
|
|
561
|
+
for (const artifact of artifacts) {
|
|
562
|
+
try {
|
|
563
|
+
await this.channel.sendFile(ctx.chatId, artifact.filePath, {
|
|
564
|
+
caption: artifact.caption,
|
|
565
|
+
replyTo,
|
|
566
|
+
asPhoto: artifact.kind === 'photo',
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
catch (e) {
|
|
570
|
+
this.log(`artifact upload failed for ${artifact.filename}: ${e}`);
|
|
571
|
+
await this.channel.send(ctx.chatId, `Artifact upload failed: <code>${escapeHtml(artifact.filename)}</code>`, { parseMode: 'HTML', replyTo }).catch(() => { });
|
|
572
|
+
}
|
|
393
573
|
}
|
|
394
574
|
}
|
|
395
575
|
async sendFinalReply(ctx, phId, agent, result) {
|
|
@@ -458,12 +638,13 @@ export class TelegramBot extends Bot {
|
|
|
458
638
|
}
|
|
459
639
|
const bodyHtml = mdToTgHtml(result.message);
|
|
460
640
|
const fullHtml = `${statusHtml}${thinkingHtml}${bodyHtml}\n\n${meta}${tokenBlock}`;
|
|
641
|
+
let finalMsgId = phId;
|
|
461
642
|
if (fullHtml.length <= 3900) {
|
|
462
643
|
try {
|
|
463
644
|
await this.channel.editMessage(ctx.chatId, phId, fullHtml, { parseMode: 'HTML', keyboard });
|
|
464
645
|
}
|
|
465
646
|
catch {
|
|
466
|
-
await this.channel.send(ctx.chatId, fullHtml, { parseMode: 'HTML', replyTo: ctx.messageId, keyboard });
|
|
647
|
+
finalMsgId = await this.channel.send(ctx.chatId, fullHtml, { parseMode: 'HTML', replyTo: ctx.messageId, keyboard });
|
|
467
648
|
}
|
|
468
649
|
}
|
|
469
650
|
else {
|
|
@@ -475,13 +656,14 @@ export class TelegramBot extends Bot {
|
|
|
475
656
|
await this.channel.editMessage(ctx.chatId, phId, previewHtml, { parseMode: 'HTML', keyboard });
|
|
476
657
|
}
|
|
477
658
|
catch {
|
|
478
|
-
await this.channel.send(ctx.chatId, previewHtml, { parseMode: 'HTML', replyTo: ctx.messageId, keyboard });
|
|
659
|
+
finalMsgId = await this.channel.send(ctx.chatId, previewHtml, { parseMode: 'HTML', replyTo: ctx.messageId, keyboard });
|
|
479
660
|
}
|
|
480
661
|
const thinkingMd = result.thinking
|
|
481
662
|
? `> **${thinkLabel(agent)}**\n${result.thinking.split('\n').map(l => `> ${l}`).join('\n')}\n\n---\n\n`
|
|
482
663
|
: '';
|
|
483
|
-
await this.channel.sendDocument(ctx.chatId, thinkingMd + result.message, `response_${phId}.md`, { caption: `Full response (${result.message.length} chars)`, replyTo: phId });
|
|
664
|
+
await this.channel.sendDocument(ctx.chatId, thinkingMd + result.message, `response_${phId}.md`, { caption: `Full response (${result.message.length} chars)`, replyTo: finalMsgId ?? phId });
|
|
484
665
|
}
|
|
666
|
+
return finalMsgId;
|
|
485
667
|
}
|
|
486
668
|
// ---- callbacks ------------------------------------------------------------
|
|
487
669
|
async handleCallback(data, ctx) {
|
package/dist/bot.js
CHANGED
|
@@ -7,8 +7,8 @@ import os from 'node:os';
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { execSync, spawn } from 'node:child_process';
|
|
10
|
-
import { doStream, getSessions, listAgents, } from './code-agent.js';
|
|
11
|
-
export const VERSION = '0.2.
|
|
10
|
+
import { doStream, getSessions, getUsage, listAgents, } from './code-agent.js';
|
|
11
|
+
export const VERSION = '0.2.4';
|
|
12
12
|
// ---------------------------------------------------------------------------
|
|
13
13
|
// Helpers
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
@@ -194,6 +194,7 @@ export class Bot {
|
|
|
194
194
|
memRss: mem.rss, memHeap: mem.heapUsed, pid: process.pid,
|
|
195
195
|
workdir: this.workdir, agent: cs.agent, model: this.modelForAgent(cs.agent), sessionId: cs.sessionId,
|
|
196
196
|
running: this.activeTasks.get(chatId) ?? null, stats: this.stats,
|
|
197
|
+
usage: getUsage({ agent: cs.agent, model: this.modelForAgent(cs.agent) }),
|
|
197
198
|
};
|
|
198
199
|
}
|
|
199
200
|
getHostData() {
|
package/dist/channel-telegram.js
CHANGED
|
@@ -56,6 +56,17 @@ import path from 'node:path';
|
|
|
56
56
|
import { Channel, splitText, sleep } from './channel-base.js';
|
|
57
57
|
export { TelegramChannel };
|
|
58
58
|
const TG_MAX = 4096;
|
|
59
|
+
const PHOTO_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp']);
|
|
60
|
+
function mimeTypeForFilename(filename) {
|
|
61
|
+
switch (path.extname(filename).toLowerCase()) {
|
|
62
|
+
case '.png': return 'image/png';
|
|
63
|
+
case '.webp': return 'image/webp';
|
|
64
|
+
case '.jpg':
|
|
65
|
+
case '.jpeg':
|
|
66
|
+
default:
|
|
67
|
+
return 'image/jpeg';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
59
70
|
// ---------------------------------------------------------------------------
|
|
60
71
|
// TelegramChannel
|
|
61
72
|
// ---------------------------------------------------------------------------
|
|
@@ -203,12 +214,14 @@ class TelegramChannel extends Channel {
|
|
|
203
214
|
const boundary = `----codeclaw${hash}`;
|
|
204
215
|
const parts = [];
|
|
205
216
|
const add = (s) => parts.push(Buffer.from(s, 'utf-8'));
|
|
217
|
+
const filename = opts.filename || 'photo.jpg';
|
|
218
|
+
const mimeType = opts.mimeType || mimeTypeForFilename(filename);
|
|
206
219
|
add(`--${boundary}\r\nContent-Disposition: form-data; name="chat_id"\r\n\r\n${chatId}\r\n`);
|
|
207
|
-
if (opts.replyTo)
|
|
220
|
+
if (opts.replyTo != null)
|
|
208
221
|
add(`--${boundary}\r\nContent-Disposition: form-data; name="reply_to_message_id"\r\n\r\n${opts.replyTo}\r\n`);
|
|
209
222
|
if (opts.caption)
|
|
210
223
|
add(`--${boundary}\r\nContent-Disposition: form-data; name="caption"\r\n\r\n${opts.caption.slice(0, 1024)}\r\n`);
|
|
211
|
-
add(`--${boundary}\r\nContent-Disposition: form-data; name="photo"; filename="
|
|
224
|
+
add(`--${boundary}\r\nContent-Disposition: form-data; name="photo"; filename="${filename}"\r\nContent-Type: ${mimeType}\r\n\r\n`);
|
|
212
225
|
parts.push(photo);
|
|
213
226
|
add(`\r\n--${boundary}--\r\n`);
|
|
214
227
|
try {
|
|
@@ -251,6 +264,20 @@ class TelegramChannel extends Channel {
|
|
|
251
264
|
throw e;
|
|
252
265
|
}
|
|
253
266
|
}
|
|
267
|
+
async sendFile(chatId, filePath, opts = {}) {
|
|
268
|
+
const content = fs.readFileSync(filePath);
|
|
269
|
+
const filename = path.basename(filePath);
|
|
270
|
+
const wantsPhoto = opts.asPhoto ?? PHOTO_EXTS.has(path.extname(filename).toLowerCase());
|
|
271
|
+
if (wantsPhoto) {
|
|
272
|
+
return this.sendPhoto(chatId, content, {
|
|
273
|
+
caption: opts.caption,
|
|
274
|
+
replyTo: opts.replyTo,
|
|
275
|
+
filename,
|
|
276
|
+
mimeType: mimeTypeForFilename(filename),
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
return this.sendDocument(chatId, content, filename, { caption: opts.caption, replyTo: opts.replyTo });
|
|
280
|
+
}
|
|
254
281
|
/** Set bottom menu commands and ensure the menu button is visible.
|
|
255
282
|
* Automatically applies to all known chats (from incoming updates). */
|
|
256
283
|
async setMenu(commands) {
|
package/dist/code-agent.js
CHANGED
|
@@ -485,3 +485,299 @@ export function listAgents() {
|
|
|
485
485
|
],
|
|
486
486
|
};
|
|
487
487
|
}
|
|
488
|
+
function toIsoFromEpochSeconds(value) {
|
|
489
|
+
const n = Number(value);
|
|
490
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
491
|
+
return null;
|
|
492
|
+
return new Date(n * 1000).toISOString();
|
|
493
|
+
}
|
|
494
|
+
function roundPercent(value) {
|
|
495
|
+
const n = Number(value);
|
|
496
|
+
if (!Number.isFinite(n))
|
|
497
|
+
return null;
|
|
498
|
+
return Math.max(0, Math.min(100, Math.round(n * 10) / 10));
|
|
499
|
+
}
|
|
500
|
+
function labelFromWindowMinutes(value, fallback) {
|
|
501
|
+
const minutes = Number(value);
|
|
502
|
+
if (!Number.isFinite(minutes) || minutes <= 0)
|
|
503
|
+
return fallback;
|
|
504
|
+
if (minutes === 300)
|
|
505
|
+
return '5h';
|
|
506
|
+
if (minutes === 10080)
|
|
507
|
+
return '7d';
|
|
508
|
+
if (minutes % 1440 === 0)
|
|
509
|
+
return `${minutes / 1440}d`;
|
|
510
|
+
if (minutes % 60 === 0)
|
|
511
|
+
return `${minutes / 60}h`;
|
|
512
|
+
return `${minutes}m`;
|
|
513
|
+
}
|
|
514
|
+
function usageWindowFromRateLimit(fallback, limit) {
|
|
515
|
+
if (!limit || typeof limit !== 'object')
|
|
516
|
+
return null;
|
|
517
|
+
const usedPercent = roundPercent(limit.used_percent);
|
|
518
|
+
const remainingPercent = usedPercent == null ? null : Math.max(0, Math.round((100 - usedPercent) * 10) / 10);
|
|
519
|
+
const resetAt = toIsoFromEpochSeconds(limit.reset_at ?? limit.resets_at);
|
|
520
|
+
let resetAfterSeconds = null;
|
|
521
|
+
const directResetAfter = Number(limit.reset_after_seconds);
|
|
522
|
+
if (Number.isFinite(directResetAfter) && directResetAfter >= 0) {
|
|
523
|
+
resetAfterSeconds = Math.round(directResetAfter);
|
|
524
|
+
}
|
|
525
|
+
else if (resetAt) {
|
|
526
|
+
const resetAtMs = Date.parse(resetAt);
|
|
527
|
+
if (Number.isFinite(resetAtMs)) {
|
|
528
|
+
resetAfterSeconds = Math.max(0, Math.round((resetAtMs - Date.now()) / 1000));
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return {
|
|
532
|
+
label: labelFromWindowMinutes(limit.window_minutes, fallback),
|
|
533
|
+
usedPercent,
|
|
534
|
+
remainingPercent,
|
|
535
|
+
resetAt,
|
|
536
|
+
resetAfterSeconds,
|
|
537
|
+
status: typeof limit.status === 'string' ? limit.status : null,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
function parseJsonTail(raw) {
|
|
541
|
+
const start = raw.indexOf('{');
|
|
542
|
+
if (start < 0)
|
|
543
|
+
return null;
|
|
544
|
+
try {
|
|
545
|
+
return JSON.parse(raw.slice(start));
|
|
546
|
+
}
|
|
547
|
+
catch {
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
function modelFamily(model) {
|
|
552
|
+
const lower = model?.toLowerCase() || '';
|
|
553
|
+
if (!lower)
|
|
554
|
+
return null;
|
|
555
|
+
if (lower.includes('opus'))
|
|
556
|
+
return 'opus';
|
|
557
|
+
if (lower.includes('sonnet'))
|
|
558
|
+
return 'sonnet';
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
function emptyUsage(agent, error) {
|
|
562
|
+
return {
|
|
563
|
+
ok: false,
|
|
564
|
+
agent,
|
|
565
|
+
source: null,
|
|
566
|
+
capturedAt: null,
|
|
567
|
+
status: null,
|
|
568
|
+
windows: [],
|
|
569
|
+
error,
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
function getCodexStateDbPath(home) {
|
|
573
|
+
const root = path.join(home, '.codex');
|
|
574
|
+
if (!fs.existsSync(root))
|
|
575
|
+
return null;
|
|
576
|
+
try {
|
|
577
|
+
const files = fs.readdirSync(root)
|
|
578
|
+
.filter(name => /^state.*\.sqlite$/i.test(name))
|
|
579
|
+
.map(name => ({ name, full: path.join(root, name), mtime: fs.statSync(path.join(root, name)).mtimeMs }))
|
|
580
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
581
|
+
return files[0]?.full || null;
|
|
582
|
+
}
|
|
583
|
+
catch {
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
function codexUsageFromRateLimits(rateLimits, capturedAt, source) {
|
|
588
|
+
if (!rateLimits || typeof rateLimits !== 'object')
|
|
589
|
+
return null;
|
|
590
|
+
const windows = [
|
|
591
|
+
usageWindowFromRateLimit('Primary', rateLimits.primary),
|
|
592
|
+
usageWindowFromRateLimit('Secondary', rateLimits.secondary),
|
|
593
|
+
].filter((v) => !!v);
|
|
594
|
+
if (!windows.length)
|
|
595
|
+
return null;
|
|
596
|
+
let status = null;
|
|
597
|
+
if (rateLimits.limit_reached === true)
|
|
598
|
+
status = 'limit_reached';
|
|
599
|
+
else if (rateLimits.allowed === true)
|
|
600
|
+
status = 'allowed';
|
|
601
|
+
return {
|
|
602
|
+
ok: true,
|
|
603
|
+
agent: 'codex',
|
|
604
|
+
source,
|
|
605
|
+
capturedAt,
|
|
606
|
+
status,
|
|
607
|
+
windows,
|
|
608
|
+
error: null,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
function getCodexUsageFromStateDb(home) {
|
|
612
|
+
const dbPath = getCodexStateDbPath(home);
|
|
613
|
+
if (!dbPath)
|
|
614
|
+
return null;
|
|
615
|
+
try {
|
|
616
|
+
const query = "SELECT ts || '|' || message FROM logs WHERE message LIKE '%codex.rate_limits%' ORDER BY ts DESC LIMIT 1;";
|
|
617
|
+
const out = execSync(`sqlite3 -noheader ${Q(dbPath)} ${Q(query)}`, { encoding: 'utf-8', timeout: 3000 }).trim();
|
|
618
|
+
if (!out)
|
|
619
|
+
return null;
|
|
620
|
+
const sep = out.indexOf('|');
|
|
621
|
+
const rawTs = sep >= 0 ? out.slice(0, sep) : '';
|
|
622
|
+
const rawMessage = sep >= 0 ? out.slice(sep + 1) : out;
|
|
623
|
+
const payload = parseJsonTail(rawMessage);
|
|
624
|
+
const capturedAt = toIsoFromEpochSeconds(rawTs);
|
|
625
|
+
return codexUsageFromRateLimits(payload?.rate_limits, capturedAt, 'state-db');
|
|
626
|
+
}
|
|
627
|
+
catch {
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
function getCodexUsageFromSessions(home) {
|
|
632
|
+
const sessionsRoot = path.join(home, '.codex', 'sessions');
|
|
633
|
+
if (!fs.existsSync(sessionsRoot))
|
|
634
|
+
return null;
|
|
635
|
+
const all = [];
|
|
636
|
+
try {
|
|
637
|
+
for (const year of fs.readdirSync(sessionsRoot)) {
|
|
638
|
+
const yp = path.join(sessionsRoot, year);
|
|
639
|
+
if (!fs.statSync(yp).isDirectory())
|
|
640
|
+
continue;
|
|
641
|
+
for (const month of fs.readdirSync(yp)) {
|
|
642
|
+
const mp = path.join(yp, month);
|
|
643
|
+
if (!fs.statSync(mp).isDirectory())
|
|
644
|
+
continue;
|
|
645
|
+
for (const day of fs.readdirSync(mp)) {
|
|
646
|
+
const dp = path.join(mp, day);
|
|
647
|
+
if (!fs.statSync(dp).isDirectory())
|
|
648
|
+
continue;
|
|
649
|
+
for (const f of fs.readdirSync(dp)) {
|
|
650
|
+
if (!f.endsWith('.jsonl'))
|
|
651
|
+
continue;
|
|
652
|
+
const full = path.join(dp, f);
|
|
653
|
+
all.push({ path: full, mtime: fs.statSync(full).mtimeMs });
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
catch {
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
all.sort((a, b) => b.mtime - a.mtime);
|
|
663
|
+
for (const entry of all.slice(0, 30)) {
|
|
664
|
+
try {
|
|
665
|
+
const lines = fs.readFileSync(entry.path, 'utf-8').trim().split('\n');
|
|
666
|
+
for (let i = lines.length - 1; i >= 0 && i >= lines.length - 200; i--) {
|
|
667
|
+
const raw = lines[i];
|
|
668
|
+
if (!raw || raw[0] !== '{' || !raw.includes('rate_limits'))
|
|
669
|
+
continue;
|
|
670
|
+
let ev;
|
|
671
|
+
try {
|
|
672
|
+
ev = JSON.parse(raw);
|
|
673
|
+
}
|
|
674
|
+
catch {
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
const result = codexUsageFromRateLimits(ev?.payload?.rate_limits, typeof ev?.timestamp === 'string' ? ev.timestamp : null, 'session-history');
|
|
678
|
+
if (result)
|
|
679
|
+
return result;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
catch {
|
|
683
|
+
// ignore malformed or unreadable session files
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
return null;
|
|
687
|
+
}
|
|
688
|
+
function getClaudeUsageFromTelemetry(home, model) {
|
|
689
|
+
const telemetryRoot = path.join(home, '.claude', 'telemetry');
|
|
690
|
+
if (!fs.existsSync(telemetryRoot))
|
|
691
|
+
return null;
|
|
692
|
+
const preferredFamily = modelFamily(model);
|
|
693
|
+
let bestAny = null;
|
|
694
|
+
let bestMatch = null;
|
|
695
|
+
try {
|
|
696
|
+
const files = fs.readdirSync(telemetryRoot)
|
|
697
|
+
.filter(name => name.endsWith('.json'))
|
|
698
|
+
.map(name => ({ full: path.join(telemetryRoot, name), mtime: fs.statSync(path.join(telemetryRoot, name)).mtimeMs }))
|
|
699
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
700
|
+
.slice(0, 50);
|
|
701
|
+
for (const file of files) {
|
|
702
|
+
const lines = fs.readFileSync(file.full, 'utf-8').trim().split('\n');
|
|
703
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
704
|
+
const raw = lines[i];
|
|
705
|
+
if (!raw || raw[0] !== '{' || !raw.includes('tengu_claudeai_limits_status_changed'))
|
|
706
|
+
continue;
|
|
707
|
+
let parsed;
|
|
708
|
+
try {
|
|
709
|
+
parsed = JSON.parse(raw);
|
|
710
|
+
}
|
|
711
|
+
catch {
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
const data = parsed?.event_data;
|
|
715
|
+
if (data?.event_name !== 'tengu_claudeai_limits_status_changed')
|
|
716
|
+
continue;
|
|
717
|
+
const capturedAtMs = Date.parse(data.client_timestamp || '');
|
|
718
|
+
if (!Number.isFinite(capturedAtMs))
|
|
719
|
+
continue;
|
|
720
|
+
let meta = data.additional_metadata;
|
|
721
|
+
if (typeof meta === 'string') {
|
|
722
|
+
try {
|
|
723
|
+
meta = JSON.parse(meta);
|
|
724
|
+
}
|
|
725
|
+
catch {
|
|
726
|
+
meta = null;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
const hoursTillReset = Number(meta?.hoursTillReset);
|
|
730
|
+
const candidate = {
|
|
731
|
+
capturedAtMs,
|
|
732
|
+
capturedAt: new Date(capturedAtMs).toISOString(),
|
|
733
|
+
status: typeof meta?.status === 'string' ? meta.status : null,
|
|
734
|
+
hoursTillReset: Number.isFinite(hoursTillReset) ? hoursTillReset : null,
|
|
735
|
+
model: typeof data.model === 'string' ? data.model : null,
|
|
736
|
+
};
|
|
737
|
+
if (!bestAny || candidate.capturedAtMs > bestAny.capturedAtMs)
|
|
738
|
+
bestAny = candidate;
|
|
739
|
+
if (preferredFamily && candidate.model?.toLowerCase().includes(preferredFamily)) {
|
|
740
|
+
if (!bestMatch || candidate.capturedAtMs > bestMatch.capturedAtMs)
|
|
741
|
+
bestMatch = candidate;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
catch {
|
|
747
|
+
return null;
|
|
748
|
+
}
|
|
749
|
+
const chosen = bestMatch || bestAny;
|
|
750
|
+
if (!chosen)
|
|
751
|
+
return null;
|
|
752
|
+
const resetAfterSeconds = chosen.hoursTillReset == null ? null : Math.max(0, Math.round(chosen.hoursTillReset * 3600));
|
|
753
|
+
const resetAt = resetAfterSeconds == null ? null : new Date(chosen.capturedAtMs + resetAfterSeconds * 1000).toISOString();
|
|
754
|
+
const windows = [{
|
|
755
|
+
label: 'Current',
|
|
756
|
+
usedPercent: null,
|
|
757
|
+
remainingPercent: null,
|
|
758
|
+
resetAt,
|
|
759
|
+
resetAfterSeconds,
|
|
760
|
+
status: chosen.status,
|
|
761
|
+
}];
|
|
762
|
+
return {
|
|
763
|
+
ok: true,
|
|
764
|
+
agent: 'claude',
|
|
765
|
+
source: 'telemetry',
|
|
766
|
+
capturedAt: chosen.capturedAt,
|
|
767
|
+
status: chosen.status,
|
|
768
|
+
windows,
|
|
769
|
+
error: null,
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
export function getUsage(opts) {
|
|
773
|
+
const home = process.env.HOME || '';
|
|
774
|
+
if (!home)
|
|
775
|
+
return emptyUsage(opts.agent, 'HOME is not set.');
|
|
776
|
+
if (opts.agent === 'codex') {
|
|
777
|
+
return getCodexUsageFromStateDb(home)
|
|
778
|
+
|| getCodexUsageFromSessions(home)
|
|
779
|
+
|| emptyUsage('codex', 'No recent Codex usage data found.');
|
|
780
|
+
}
|
|
781
|
+
return getClaudeUsageFromTelemetry(home, opts.model)
|
|
782
|
+
|| emptyUsage('claude', 'No recent Claude usage data found.');
|
|
783
|
+
}
|