codeclaw 0.1.0 → 0.2.2

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.js ADDED
@@ -0,0 +1,288 @@
1
+ /**
2
+ * bot.ts — shared bot logic: config, state, streaming bridge, helpers, keep-alive.
3
+ *
4
+ * Channel-agnostic. Subclass per IM (see bot-telegram.ts).
5
+ */
6
+ import os from 'node:os';
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { execSync, spawn } from 'node:child_process';
10
+ import { doStream, getSessions, listAgents, } from './code-agent.js';
11
+ export const VERSION = '0.2.2';
12
+ // ---------------------------------------------------------------------------
13
+ // Helpers
14
+ // ---------------------------------------------------------------------------
15
+ export function envBool(name, def) {
16
+ const raw = process.env[name];
17
+ if (raw == null)
18
+ return def;
19
+ return ['1', 'true', 'yes', 'on'].includes(raw.trim().toLowerCase());
20
+ }
21
+ export function envInt(name, def) {
22
+ const raw = process.env[name];
23
+ if (raw == null || raw.trim() === '')
24
+ return def;
25
+ const n = parseInt(raw, 10);
26
+ return Number.isNaN(n) ? def : n;
27
+ }
28
+ export function shellSplit(str) {
29
+ const args = [];
30
+ let cur = '', inS = false, inD = false;
31
+ for (const ch of str) {
32
+ if (ch === "'" && !inD) {
33
+ inS = !inS;
34
+ continue;
35
+ }
36
+ if (ch === '"' && !inS) {
37
+ inD = !inD;
38
+ continue;
39
+ }
40
+ if (ch === ' ' && !inS && !inD) {
41
+ if (cur) {
42
+ args.push(cur);
43
+ cur = '';
44
+ }
45
+ continue;
46
+ }
47
+ cur += ch;
48
+ }
49
+ if (cur)
50
+ args.push(cur);
51
+ return args;
52
+ }
53
+ export function whichSync(cmd) {
54
+ try {
55
+ return execSync(`which ${cmd} 2>/dev/null`, { encoding: 'utf-8' }).trim() || null;
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ }
61
+ export function fmtTokens(n) {
62
+ if (n == null)
63
+ return '-';
64
+ return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
65
+ }
66
+ export function fmtUptime(ms) {
67
+ const s = Math.floor(ms / 1000);
68
+ if (s < 60)
69
+ return `${s}s`;
70
+ const m = Math.floor(s / 60);
71
+ if (m < 60)
72
+ return `${m}m ${s % 60}s`;
73
+ const h = Math.floor(m / 60);
74
+ if (h < 24)
75
+ return `${h}h ${m % 60}m`;
76
+ const d = Math.floor(h / 24);
77
+ return `${d}d ${h % 24}h`;
78
+ }
79
+ export function fmtBytes(bytes) {
80
+ if (bytes < 1024)
81
+ return `${bytes}B`;
82
+ if (bytes < 1024 * 1024)
83
+ return `${(bytes / 1024).toFixed(0)}KB`;
84
+ if (bytes < 1024 * 1024 * 1024)
85
+ return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
86
+ if (bytes < 1024 * 1024 * 1024 * 1024)
87
+ return `${(bytes / 1024 / 1024 / 1024).toFixed(1)}GB`;
88
+ return `${(bytes / 1024 / 1024 / 1024 / 1024).toFixed(1)}TB`;
89
+ }
90
+ export function parseAllowedChatIds(raw) {
91
+ const ids = new Set();
92
+ for (const t of raw.split(',')) {
93
+ const n = parseInt(t.trim(), 10);
94
+ if (!Number.isNaN(n))
95
+ ids.add(n);
96
+ }
97
+ return ids;
98
+ }
99
+ const VALID_AGENTS = new Set(['codex', 'claude']);
100
+ export function normalizeAgent(raw) {
101
+ const v = raw.trim().toLowerCase();
102
+ if (!VALID_AGENTS.has(v))
103
+ throw new Error(`Invalid agent: ${v}. Use: codex, claude`);
104
+ return v;
105
+ }
106
+ export function listSubdirs(dirPath) {
107
+ try {
108
+ return fs.readdirSync(dirPath)
109
+ .filter(name => {
110
+ if (name.startsWith('.'))
111
+ return false;
112
+ try {
113
+ return fs.statSync(path.join(dirPath, name)).isDirectory();
114
+ }
115
+ catch {
116
+ return false;
117
+ }
118
+ })
119
+ .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
120
+ }
121
+ catch {
122
+ return [];
123
+ }
124
+ }
125
+ export function thinkLabel(agent) {
126
+ return agent === 'codex' ? 'Reasoning' : 'Thinking';
127
+ }
128
+ export function buildPrompt(text, files) {
129
+ if (!files.length)
130
+ return text;
131
+ return `${text || 'Please analyze this.'}\n\n[Files: ${files.map(f => path.basename(f)).join(', ')}]`;
132
+ }
133
+ // ---------------------------------------------------------------------------
134
+ // Bot
135
+ // ---------------------------------------------------------------------------
136
+ export class Bot {
137
+ workdir;
138
+ defaultAgent;
139
+ runTimeout;
140
+ allowedChatIds;
141
+ codexModel;
142
+ codexReasoningEffort;
143
+ codexFullAccess;
144
+ codexExtraArgs;
145
+ claudeModel;
146
+ claudePermissionMode;
147
+ claudeExtraArgs;
148
+ chats = new Map();
149
+ activeTasks = new Map();
150
+ startedAt = Date.now();
151
+ stats = { totalTurns: 0, totalInputTokens: 0, totalOutputTokens: 0, totalCachedTokens: 0 };
152
+ keepAliveProc = null;
153
+ constructor() {
154
+ this.workdir = path.resolve((process.env.CODECLAW_WORKDIR || process.cwd()).replace(/^~/, process.env.HOME || ''));
155
+ this.defaultAgent = normalizeAgent(process.env.DEFAULT_AGENT || 'claude');
156
+ this.runTimeout = envInt('CODECLAW_TIMEOUT', 300);
157
+ this.allowedChatIds = parseAllowedChatIds(process.env.CODECLAW_ALLOWED_IDS || '');
158
+ this.codexModel = (process.env.CODEX_MODEL || 'gpt-5.4').trim();
159
+ this.codexReasoningEffort = (process.env.CODEX_REASONING_EFFORT || 'xhigh').trim().toLowerCase();
160
+ this.codexFullAccess = envBool('CODEX_FULL_ACCESS', true);
161
+ this.codexExtraArgs = shellSplit(process.env.CODEX_EXTRA_ARGS || '');
162
+ this.claudeModel = (process.env.CLAUDE_MODEL || 'claude-opus-4-6').trim();
163
+ this.claudePermissionMode = (process.env.CLAUDE_PERMISSION_MODE || 'bypassPermissions').trim();
164
+ this.claudeExtraArgs = shellSplit(process.env.CLAUDE_EXTRA_ARGS || '');
165
+ }
166
+ log(msg) {
167
+ const ts = new Date().toTimeString().slice(0, 8);
168
+ process.stdout.write(`[codeclaw ${ts}] ${msg}\n`);
169
+ }
170
+ chat(chatId) {
171
+ let s = this.chats.get(chatId);
172
+ if (!s) {
173
+ s = { agent: this.defaultAgent, sessionId: null };
174
+ this.chats.set(chatId, s);
175
+ }
176
+ return s;
177
+ }
178
+ modelForAgent(agent) {
179
+ if (agent === 'codex')
180
+ return this.codexModel;
181
+ return this.claudeModel;
182
+ }
183
+ fetchSessions(agent) {
184
+ return getSessions({ agent, workdir: this.workdir });
185
+ }
186
+ fetchAgents() {
187
+ return listAgents();
188
+ }
189
+ getStatusData(chatId) {
190
+ const cs = this.chat(chatId);
191
+ const mem = process.memoryUsage();
192
+ return {
193
+ version: VERSION, uptime: Date.now() - this.startedAt,
194
+ memRss: mem.rss, memHeap: mem.heapUsed, pid: process.pid,
195
+ workdir: this.workdir, agent: cs.agent, model: this.modelForAgent(cs.agent), sessionId: cs.sessionId,
196
+ running: this.activeTasks.get(chatId) ?? null, stats: this.stats,
197
+ };
198
+ }
199
+ getHostData() {
200
+ const cpus = os.cpus();
201
+ const totalMem = os.totalmem(), freeMem = os.freemem();
202
+ let disk = null;
203
+ try {
204
+ const df = execSync(`df -h "${this.workdir}" | tail -1`, { encoding: 'utf-8', timeout: 3000 }).trim().split(/\s+/);
205
+ if (df.length >= 5)
206
+ disk = { used: df[2], total: df[1], percent: df[4] };
207
+ }
208
+ catch { }
209
+ let topProcs = [];
210
+ try {
211
+ topProcs = execSync(`ps -eo pid,pcpu,pmem,comm --sort=-pcpu 2>/dev/null | head -6 || ps -eo pid,%cpu,%mem,comm -r 2>/dev/null | head -6`, { encoding: 'utf-8', timeout: 3000 }).trim().split('\n');
212
+ }
213
+ catch { }
214
+ const mem = process.memoryUsage();
215
+ return {
216
+ cpuModel: cpus[0]?.model || 'unknown', cpuCount: cpus.length,
217
+ totalMem, freeMem, disk, topProcs,
218
+ selfPid: process.pid, selfRss: mem.rss, selfHeap: mem.heapUsed,
219
+ };
220
+ }
221
+ switchWorkdir(newPath) {
222
+ const old = this.workdir;
223
+ this.workdir = newPath;
224
+ for (const [, cs] of this.chats)
225
+ cs.sessionId = null;
226
+ this.log(`switch workdir: ${old} -> ${newPath}`);
227
+ return old;
228
+ }
229
+ async runStream(prompt, cs, attachments, onText) {
230
+ this.log(`[runStream] agent=${cs.agent} session=${cs.sessionId || '(new)'} workdir=${this.workdir} timeout=${this.runTimeout}s attachments=${attachments.length}`);
231
+ if (cs.agent === 'claude') {
232
+ this.log(`[runStream] claude config: model=${this.claudeModel} permission=${this.claudePermissionMode} extraArgs=[${this.claudeExtraArgs.join(' ')}]`);
233
+ }
234
+ else if (cs.agent === 'codex') {
235
+ this.log(`[runStream] codex config: model=${this.codexModel} reasoning=${this.codexReasoningEffort} fullAccess=${this.codexFullAccess} extraArgs=[${this.codexExtraArgs.join(' ')}]`);
236
+ }
237
+ const opts = {
238
+ agent: cs.agent, prompt, workdir: this.workdir, timeout: this.runTimeout,
239
+ sessionId: cs.sessionId, model: null, thinkingEffort: this.codexReasoningEffort, onText,
240
+ attachments: attachments.length ? attachments : undefined,
241
+ codexModel: this.codexModel, codexFullAccess: this.codexFullAccess,
242
+ codexExtraArgs: this.codexExtraArgs.length ? this.codexExtraArgs : undefined,
243
+ claudeModel: this.claudeModel, claudePermissionMode: this.claudePermissionMode,
244
+ claudeExtraArgs: this.claudeExtraArgs.length ? this.claudeExtraArgs : undefined,
245
+ };
246
+ const result = await doStream(opts);
247
+ this.stats.totalTurns++;
248
+ if (result.inputTokens)
249
+ this.stats.totalInputTokens += result.inputTokens;
250
+ if (result.outputTokens)
251
+ this.stats.totalOutputTokens += result.outputTokens;
252
+ if (result.cachedInputTokens)
253
+ this.stats.totalCachedTokens += result.cachedInputTokens;
254
+ if (result.sessionId)
255
+ cs.sessionId = result.sessionId;
256
+ this.log(`[runStream] completed turn=${this.stats.totalTurns} cumulative: in=${fmtTokens(this.stats.totalInputTokens)} out=${fmtTokens(this.stats.totalOutputTokens)} cached=${fmtTokens(this.stats.totalCachedTokens)}`);
257
+ return result;
258
+ }
259
+ startKeepAlive() {
260
+ if (process.platform === 'darwin') {
261
+ const bin = whichSync('caffeinate');
262
+ if (bin) {
263
+ this.keepAliveProc = spawn('caffeinate', ['-dis'], { stdio: 'ignore', detached: true });
264
+ this.keepAliveProc.unref();
265
+ this.log(`keep-alive: caffeinate (PID ${this.keepAliveProc.pid})`);
266
+ }
267
+ }
268
+ else if (process.platform === 'linux') {
269
+ const bin = whichSync('systemd-inhibit');
270
+ if (bin) {
271
+ this.keepAliveProc = spawn('systemd-inhibit', [
272
+ '--what=idle', '--who=codeclaw', '--why=AI coding agent running', 'sleep', 'infinity',
273
+ ], { stdio: 'ignore', detached: true });
274
+ this.keepAliveProc.unref();
275
+ this.log(`keep-alive: systemd-inhibit (PID ${this.keepAliveProc.pid})`);
276
+ }
277
+ }
278
+ }
279
+ stopKeepAlive() {
280
+ if (this.keepAliveProc) {
281
+ try {
282
+ this.keepAliveProc.kill('SIGTERM');
283
+ }
284
+ catch { }
285
+ this.keepAliveProc = null;
286
+ }
287
+ }
288
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Channel base — minimal abstract for all IM platforms.
3
+ *
4
+ * Only defines: lifecycle + outgoing primitives.
5
+ * Hooks (onCommand, onMessage, onCallback, ...) are platform-specific
6
+ * and belong in each subclass — different IMs expose different interaction models.
7
+ */
8
+ export class Channel {
9
+ bot = null;
10
+ }
11
+ // ---------------------------------------------------------------------------
12
+ // Shared helpers
13
+ // ---------------------------------------------------------------------------
14
+ export function splitText(text, max) {
15
+ if (text.length <= max)
16
+ return [text];
17
+ const chunks = [];
18
+ let rest = text;
19
+ while (rest.length > max) {
20
+ let cut = rest.lastIndexOf('\n', max);
21
+ if (cut < max * 0.3)
22
+ cut = max;
23
+ chunks.push(rest.slice(0, cut));
24
+ rest = rest.slice(cut);
25
+ }
26
+ if (rest)
27
+ chunks.push(rest);
28
+ return chunks;
29
+ }
30
+ export function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }