codeclaw 0.2.7 → 0.2.9
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-telegram.js +85 -1
- package/dist/bot.js +19 -3
- package/dist/code-agent.js +296 -11
- package/package.json +1 -1
package/dist/bot-telegram.js
CHANGED
|
@@ -12,11 +12,35 @@ import { Bot, VERSION, fmtTokens, fmtUptime, fmtBytes, whichSync, listSubdirs, b
|
|
|
12
12
|
import { TelegramChannel } from './channel-telegram.js';
|
|
13
13
|
import { splitText } from './channel-base.js';
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
15
|
+
// Context window sizes (max input tokens per model family)
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
15
18
|
// Telegram HTML formatting
|
|
16
19
|
// ---------------------------------------------------------------------------
|
|
17
20
|
function escapeHtml(t) {
|
|
18
21
|
return t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
19
22
|
}
|
|
23
|
+
function claudeModelAlias(modelId) {
|
|
24
|
+
const value = String(modelId || '').trim().toLowerCase();
|
|
25
|
+
if (!value)
|
|
26
|
+
return null;
|
|
27
|
+
if (value === 'opus' || value.startsWith('claude-opus-'))
|
|
28
|
+
return 'opus';
|
|
29
|
+
if (value === 'sonnet' || value.startsWith('claude-sonnet-'))
|
|
30
|
+
return 'sonnet';
|
|
31
|
+
if (value === 'haiku' || value.startsWith('claude-haiku-'))
|
|
32
|
+
return 'haiku';
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
function modelMatchesSelection(agent, selection, currentModel) {
|
|
36
|
+
if (selection === currentModel)
|
|
37
|
+
return true;
|
|
38
|
+
if (agent !== 'claude')
|
|
39
|
+
return false;
|
|
40
|
+
const a = claudeModelAlias(selection);
|
|
41
|
+
const b = claudeModelAlias(currentModel);
|
|
42
|
+
return !!a && a === b;
|
|
43
|
+
}
|
|
20
44
|
function mdToTgHtml(text) {
|
|
21
45
|
const result = [];
|
|
22
46
|
const lines = text.split('\n');
|
|
@@ -321,6 +345,7 @@ export class TelegramBot extends Bot {
|
|
|
321
345
|
static MENU_COMMANDS = [
|
|
322
346
|
{ command: 'sessions', description: 'List / switch sessions' },
|
|
323
347
|
{ command: 'agents', description: 'List / switch agents' },
|
|
348
|
+
{ command: 'models', description: 'List / switch models' },
|
|
324
349
|
{ command: 'status', description: 'Bot status' },
|
|
325
350
|
{ command: 'host', description: 'Host machine info' },
|
|
326
351
|
{ command: 'switch', description: 'Switch working directory' },
|
|
@@ -336,6 +361,7 @@ export class TelegramBot extends Bot {
|
|
|
336
361
|
await ctx.reply(`<b>codeclaw</b> v${VERSION}\n\n` +
|
|
337
362
|
`/sessions \u2014 List / switch sessions\n` +
|
|
338
363
|
`/agents \u2014 List / switch agents\n` +
|
|
364
|
+
`/models \u2014 List / switch models\n` +
|
|
339
365
|
`/status \u2014 Bot status\n` +
|
|
340
366
|
`/host \u2014 Host machine info\n` +
|
|
341
367
|
`/switch \u2014 Switch working directory\n` +
|
|
@@ -454,6 +480,33 @@ export class TelegramBot extends Bot {
|
|
|
454
480
|
}
|
|
455
481
|
await ctx.reply(lines.join('\n'), { parseMode: 'HTML', keyboard: { inline_keyboard: rows } });
|
|
456
482
|
}
|
|
483
|
+
async cmdModels(ctx) {
|
|
484
|
+
const cs = this.chat(ctx.chatId);
|
|
485
|
+
const res = this.fetchModels(cs.agent);
|
|
486
|
+
const currentModel = this.modelForAgent(cs.agent);
|
|
487
|
+
const lines = [`<b>Models for ${escapeHtml(cs.agent)}</b>`];
|
|
488
|
+
if (res.sources.length)
|
|
489
|
+
lines.push(`<i>Source: ${escapeHtml(res.sources.join(', '))}</i>`);
|
|
490
|
+
if (res.note)
|
|
491
|
+
lines.push(`<i>${escapeHtml(res.note)}</i>`);
|
|
492
|
+
lines.push('');
|
|
493
|
+
const rows = [];
|
|
494
|
+
if (!res.models.length) {
|
|
495
|
+
lines.push('<i>No discoverable models found.</i>');
|
|
496
|
+
}
|
|
497
|
+
for (const m of res.models) {
|
|
498
|
+
const isCurrent = modelMatchesSelection(cs.agent, m.id, currentModel);
|
|
499
|
+
const status = isCurrent ? '\u25CF' : '\u25CB';
|
|
500
|
+
const display = m.alias ? `${m.alias} (${m.id})` : m.id;
|
|
501
|
+
const currentSuffix = isCurrent
|
|
502
|
+
? (m.id === currentModel ? ' \u2190 current' : ` \u2190 current (${escapeHtml(currentModel)})`)
|
|
503
|
+
: '';
|
|
504
|
+
lines.push(`${status} <code>${escapeHtml(display)}</code>${currentSuffix}`);
|
|
505
|
+
const label = isCurrent ? `\u25CF ${m.alias || m.id}` : (m.alias || m.id);
|
|
506
|
+
rows.push([{ text: label, callback_data: `mod:${m.id}` }]);
|
|
507
|
+
}
|
|
508
|
+
await ctx.reply(lines.join('\n'), { parseMode: 'HTML', keyboard: { inline_keyboard: rows } });
|
|
509
|
+
}
|
|
457
510
|
async cmdRestart(ctx) {
|
|
458
511
|
const activeTasks = this.activeTasks.size;
|
|
459
512
|
if (activeTasks > 0) {
|
|
@@ -477,7 +530,8 @@ export class TelegramBot extends Bot {
|
|
|
477
530
|
this.log(`restart: spawning \`${bin} ${allArgs.join(' ')}\``);
|
|
478
531
|
// Collect all known chat IDs so the new process can send startup notices
|
|
479
532
|
const knownIds = new Set(this.allowedChatIds);
|
|
480
|
-
|
|
533
|
+
const knownChats = this.channel.knownChats instanceof Set ? this.channel.knownChats : new Set();
|
|
534
|
+
for (const cid of knownChats)
|
|
481
535
|
knownIds.add(cid);
|
|
482
536
|
const child = spawn(bin, allArgs, {
|
|
483
537
|
stdio: 'inherit',
|
|
@@ -598,6 +652,14 @@ export class TelegramBot extends Bot {
|
|
|
598
652
|
tp.push(`cached: ${fmtTokens(result.cachedInputTokens)}`);
|
|
599
653
|
if (result.outputTokens != null)
|
|
600
654
|
tp.push(`out: ${fmtTokens(result.outputTokens)}`);
|
|
655
|
+
// Context window usage percentage from CLI-reported contextWindow
|
|
656
|
+
// For Codex use cumulative input (= full session context); for Claude use inputTokens
|
|
657
|
+
const ctxMax = result.contextWindow;
|
|
658
|
+
const ctxInput = result.codexCumulative?.input ?? result.inputTokens;
|
|
659
|
+
if (ctxMax && ctxInput != null) {
|
|
660
|
+
const pct = (ctxInput / ctxMax * 100).toFixed(1);
|
|
661
|
+
tp.push(`ctx: ${pct}%`);
|
|
662
|
+
}
|
|
601
663
|
tokenBlock = `\n<blockquote expandable>${tp.join(' ')}</blockquote>`;
|
|
602
664
|
}
|
|
603
665
|
const quickReplies = result.incomplete ? [] : detectQuickReplies(result.message);
|
|
@@ -736,11 +798,13 @@ export class TelegramBot extends Bot {
|
|
|
736
798
|
const cs = this.chat(ctx.chatId);
|
|
737
799
|
if (sessionId === 'new') {
|
|
738
800
|
cs.sessionId = null;
|
|
801
|
+
cs.codexCumulative = undefined;
|
|
739
802
|
await ctx.answerCallback('New session');
|
|
740
803
|
await ctx.editReply(ctx.messageId, 'Session reset. Send a message to start.', {});
|
|
741
804
|
}
|
|
742
805
|
else {
|
|
743
806
|
cs.sessionId = sessionId;
|
|
807
|
+
cs.codexCumulative = undefined;
|
|
744
808
|
await ctx.answerCallback(`Session: ${sessionId.slice(0, 12)}`);
|
|
745
809
|
await ctx.editReply(ctx.messageId, `Switched to session: <code>${escapeHtml(sessionId.slice(0, 16))}</code>`, { parseMode: 'HTML' });
|
|
746
810
|
}
|
|
@@ -755,11 +819,28 @@ export class TelegramBot extends Bot {
|
|
|
755
819
|
}
|
|
756
820
|
cs.agent = agent;
|
|
757
821
|
cs.sessionId = null;
|
|
822
|
+
cs.codexCumulative = undefined;
|
|
758
823
|
this.log(`agent switched to ${agent} chat=${ctx.chatId}`);
|
|
759
824
|
await ctx.answerCallback(`Switched to ${agent}`);
|
|
760
825
|
await ctx.editReply(ctx.messageId, `<b>Switched to ${escapeHtml(agent)}</b>\n\nSession has been reset. Previous conversation history will not carry over.\nSend a message to start a new conversation.`, { parseMode: 'HTML' });
|
|
761
826
|
return;
|
|
762
827
|
}
|
|
828
|
+
if (data.startsWith('mod:')) {
|
|
829
|
+
const modelId = data.slice(4);
|
|
830
|
+
const cs = this.chat(ctx.chatId);
|
|
831
|
+
const currentModel = this.modelForAgent(cs.agent);
|
|
832
|
+
if (currentModel === modelId) {
|
|
833
|
+
await ctx.answerCallback(`Already using ${modelId}`);
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
this.setModelForAgent(cs.agent, modelId);
|
|
837
|
+
cs.sessionId = null;
|
|
838
|
+
cs.codexCumulative = undefined;
|
|
839
|
+
this.log(`model switched to ${modelId} for ${cs.agent} chat=${ctx.chatId}`);
|
|
840
|
+
await ctx.answerCallback(`Switched to ${modelId}`);
|
|
841
|
+
await ctx.editReply(ctx.messageId, `<b>Model switched to <code>${escapeHtml(modelId)}</code></b>\n\nAgent: ${escapeHtml(cs.agent)}\nSession has been reset. Send a message to start a new conversation.`, { parseMode: 'HTML' });
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
763
844
|
if (data.startsWith('qr:')) {
|
|
764
845
|
const parts = data.split(':');
|
|
765
846
|
if (parts.length === 3) {
|
|
@@ -788,6 +869,9 @@ export class TelegramBot extends Bot {
|
|
|
788
869
|
case 'agents':
|
|
789
870
|
await this.cmdAgents(ctx);
|
|
790
871
|
return;
|
|
872
|
+
case 'models':
|
|
873
|
+
await this.cmdModels(ctx);
|
|
874
|
+
return;
|
|
791
875
|
case 'status':
|
|
792
876
|
await this.cmdStatus(ctx);
|
|
793
877
|
return;
|
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, getUsage, listAgents, } from './code-agent.js';
|
|
11
|
-
export const VERSION = '0.2.
|
|
10
|
+
import { doStream, getSessions, getUsage, listAgents, listModels, } from './code-agent.js';
|
|
11
|
+
export const VERSION = '0.2.9';
|
|
12
12
|
// ---------------------------------------------------------------------------
|
|
13
13
|
// Helpers
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
@@ -186,6 +186,16 @@ export class Bot {
|
|
|
186
186
|
fetchAgents() {
|
|
187
187
|
return listAgents();
|
|
188
188
|
}
|
|
189
|
+
fetchModels(agent) {
|
|
190
|
+
return listModels(agent, { workdir: this.workdir, currentModel: this.modelForAgent(agent) });
|
|
191
|
+
}
|
|
192
|
+
setModelForAgent(agent, modelId) {
|
|
193
|
+
if (agent === 'codex')
|
|
194
|
+
this.codexModel = modelId;
|
|
195
|
+
else
|
|
196
|
+
this.claudeModel = modelId;
|
|
197
|
+
this.log(`model for ${agent} changed to ${modelId}`);
|
|
198
|
+
}
|
|
189
199
|
getStatusData(chatId) {
|
|
190
200
|
const cs = this.chat(chatId);
|
|
191
201
|
const mem = process.memoryUsage();
|
|
@@ -222,8 +232,10 @@ export class Bot {
|
|
|
222
232
|
switchWorkdir(newPath) {
|
|
223
233
|
const old = this.workdir;
|
|
224
234
|
this.workdir = newPath;
|
|
225
|
-
for (const [, cs] of this.chats)
|
|
235
|
+
for (const [, cs] of this.chats) {
|
|
226
236
|
cs.sessionId = null;
|
|
237
|
+
cs.codexCumulative = undefined;
|
|
238
|
+
}
|
|
227
239
|
this.log(`switch workdir: ${old} -> ${newPath}`);
|
|
228
240
|
return old;
|
|
229
241
|
}
|
|
@@ -242,6 +254,7 @@ export class Bot {
|
|
|
242
254
|
attachments: attachments.length ? attachments : undefined,
|
|
243
255
|
codexModel: this.codexModel, codexFullAccess: this.codexFullAccess,
|
|
244
256
|
codexExtraArgs: this.codexExtraArgs.length ? this.codexExtraArgs : undefined,
|
|
257
|
+
codexPrevCumulative: cs.codexCumulative,
|
|
245
258
|
claudeModel: this.claudeModel, claudePermissionMode: this.claudePermissionMode,
|
|
246
259
|
claudeExtraArgs: this.claudeExtraArgs.length ? this.claudeExtraArgs : undefined,
|
|
247
260
|
};
|
|
@@ -253,6 +266,9 @@ export class Bot {
|
|
|
253
266
|
this.stats.totalOutputTokens += result.outputTokens;
|
|
254
267
|
if (result.cachedInputTokens)
|
|
255
268
|
this.stats.totalCachedTokens += result.cachedInputTokens;
|
|
269
|
+
// Store cumulative Codex totals for next invocation delta
|
|
270
|
+
if (result.codexCumulative)
|
|
271
|
+
cs.codexCumulative = result.codexCumulative;
|
|
256
272
|
// Only update sessionId if it hasn't been changed externally (e.g. user switched session during run)
|
|
257
273
|
if (result.sessionId && cs.sessionId === snapshotSessionId)
|
|
258
274
|
cs.sessionId = result.sessionId;
|
package/dist/code-agent.js
CHANGED
|
@@ -16,6 +16,8 @@ async function run(cmd, opts, parseLine) {
|
|
|
16
16
|
sessionId: opts.sessionId, text: '', thinking: '', msgs: [], thinkParts: [],
|
|
17
17
|
model: opts.model, thinkingEffort: opts.thinkingEffort, errors: null,
|
|
18
18
|
inputTokens: null, outputTokens: null, cachedInputTokens: null,
|
|
19
|
+
contextWindow: null,
|
|
20
|
+
codexCumulative: null,
|
|
19
21
|
stopReason: null,
|
|
20
22
|
};
|
|
21
23
|
const shellCmd = cmd.map(Q).join(' ');
|
|
@@ -89,6 +91,8 @@ async function run(cmd, opts, parseLine) {
|
|
|
89
91
|
thinking: s.thinking.trim() || null,
|
|
90
92
|
elapsedS: (Date.now() - start) / 1000,
|
|
91
93
|
inputTokens: s.inputTokens, outputTokens: s.outputTokens, cachedInputTokens: s.cachedInputTokens,
|
|
94
|
+
contextWindow: s.contextWindow,
|
|
95
|
+
codexCumulative: s.codexCumulative,
|
|
92
96
|
error,
|
|
93
97
|
stopReason: s.stopReason,
|
|
94
98
|
incomplete,
|
|
@@ -116,7 +120,7 @@ function codexCmd(o) {
|
|
|
116
120
|
args.push('-');
|
|
117
121
|
return args;
|
|
118
122
|
}
|
|
119
|
-
function codexParse(ev, s) {
|
|
123
|
+
function codexParse(ev, s, opts) {
|
|
120
124
|
const t = ev.type || '';
|
|
121
125
|
if (t === 'thread.started') {
|
|
122
126
|
s.sessionId = ev.thread_id ?? s.sessionId;
|
|
@@ -136,15 +140,26 @@ function codexParse(ev, s) {
|
|
|
136
140
|
if (t === 'turn.completed') {
|
|
137
141
|
const u = ev.usage;
|
|
138
142
|
if (u) {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
143
|
+
// Codex reports cumulative session totals in turn.completed.
|
|
144
|
+
// Store raw cumulative and compute per-invocation delta.
|
|
145
|
+
const cumInput = u.input_tokens ?? 0;
|
|
146
|
+
const cumOutput = u.output_tokens ?? 0;
|
|
147
|
+
const cumCached = u.cached_input_tokens ?? 0;
|
|
148
|
+
s.codexCumulative = { input: cumInput, output: cumOutput, cached: cumCached };
|
|
149
|
+
const prev = opts.codexPrevCumulative;
|
|
150
|
+
s.inputTokens = prev ? Math.max(0, cumInput - prev.input) : cumInput;
|
|
151
|
+
s.outputTokens = prev ? Math.max(0, cumOutput - prev.output) : cumOutput;
|
|
152
|
+
s.cachedInputTokens = prev ? Math.max(0, cumCached - prev.cached) : cumCached;
|
|
142
153
|
}
|
|
143
154
|
s.model = ev.model ?? s.model;
|
|
144
155
|
}
|
|
145
156
|
}
|
|
146
|
-
export function doCodexStream(opts) {
|
|
147
|
-
|
|
157
|
+
export async function doCodexStream(opts) {
|
|
158
|
+
const result = await run(codexCmd(opts), opts, (ev, s) => codexParse(ev, s, opts));
|
|
159
|
+
// Codex doesn't report context_window in stream events; read from models_cache.json
|
|
160
|
+
if (!result.contextWindow)
|
|
161
|
+
result.contextWindow = readCodexContextWindow(result.model);
|
|
162
|
+
return result;
|
|
148
163
|
}
|
|
149
164
|
// --- claude ---
|
|
150
165
|
const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
|
|
@@ -260,6 +275,16 @@ function claudeParse(ev, s) {
|
|
|
260
275
|
s.cachedInputTokens = (u.cache_read_input_tokens ?? u.cached_input_tokens) ?? s.cachedInputTokens;
|
|
261
276
|
s.outputTokens = u.output_tokens ?? s.outputTokens;
|
|
262
277
|
}
|
|
278
|
+
// Extract contextWindow from modelUsage (Claude CLI reports this in result event)
|
|
279
|
+
const mu = ev.modelUsage;
|
|
280
|
+
if (mu && typeof mu === 'object') {
|
|
281
|
+
for (const info of Object.values(mu)) {
|
|
282
|
+
if (info?.contextWindow > 0) {
|
|
283
|
+
s.contextWindow = info.contextWindow;
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
263
288
|
}
|
|
264
289
|
}
|
|
265
290
|
export async function doClaudeStream(opts) {
|
|
@@ -361,7 +386,7 @@ function readLastLine(filePath) {
|
|
|
361
386
|
}
|
|
362
387
|
}
|
|
363
388
|
function parseCodexSession(filePath) {
|
|
364
|
-
const lines = readLines(filePath,
|
|
389
|
+
const lines = readLines(filePath, 40);
|
|
365
390
|
const line = lines[0];
|
|
366
391
|
if (!line)
|
|
367
392
|
return null;
|
|
@@ -370,24 +395,32 @@ function parseCodexSession(filePath) {
|
|
|
370
395
|
if (ev.type !== 'session_meta')
|
|
371
396
|
return null;
|
|
372
397
|
const p = ev.payload || {};
|
|
398
|
+
let model = typeof p.model === 'string' ? p.model : null;
|
|
373
399
|
let title = null;
|
|
374
400
|
for (const raw of lines.slice(1)) {
|
|
375
401
|
if (!raw || raw[0] !== '{')
|
|
376
402
|
continue;
|
|
377
403
|
try {
|
|
378
404
|
const item = JSON.parse(raw);
|
|
405
|
+
if (!model && item.type === 'turn_context') {
|
|
406
|
+
const payload = item.payload || {};
|
|
407
|
+
model =
|
|
408
|
+
(typeof payload.model === 'string' ? payload.model : null)
|
|
409
|
+
|| (typeof payload?.collaboration_mode?.settings?.model === 'string' ? payload.collaboration_mode.settings.model : null)
|
|
410
|
+
|| model;
|
|
411
|
+
}
|
|
379
412
|
if (item.type === 'response_item' && item.payload?.role === 'user' && item.payload?.type === 'message') {
|
|
380
413
|
const content = item.payload.content;
|
|
381
414
|
if (Array.isArray(content)) {
|
|
382
415
|
const textBlock = content.find((b) => b?.type === 'input_text' && b.text && !/^[<#]/.test(b.text));
|
|
383
|
-
if (textBlock)
|
|
416
|
+
if (textBlock)
|
|
384
417
|
title = textBlock.text.slice(0, 120);
|
|
385
|
-
break;
|
|
386
|
-
}
|
|
387
418
|
}
|
|
388
419
|
}
|
|
389
420
|
}
|
|
390
421
|
catch { /* skip */ }
|
|
422
|
+
if (model && title)
|
|
423
|
+
break;
|
|
391
424
|
}
|
|
392
425
|
// Codex writes task_complete as the last event when done
|
|
393
426
|
let running = false;
|
|
@@ -403,7 +436,7 @@ function parseCodexSession(filePath) {
|
|
|
403
436
|
sessionId: p.id ?? path.basename(filePath, '.jsonl'),
|
|
404
437
|
agent: 'codex',
|
|
405
438
|
workdir: p.cwd ?? null,
|
|
406
|
-
model:
|
|
439
|
+
model: model ?? null,
|
|
407
440
|
createdAt: p.timestamp ?? null,
|
|
408
441
|
title,
|
|
409
442
|
running,
|
|
@@ -528,6 +561,258 @@ export function listAgents() {
|
|
|
528
561
|
],
|
|
529
562
|
};
|
|
530
563
|
}
|
|
564
|
+
function shellOutput(cmd) {
|
|
565
|
+
try {
|
|
566
|
+
return execSync(cmd, { encoding: 'utf-8', timeout: 3000 }).trim() || null;
|
|
567
|
+
}
|
|
568
|
+
catch {
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
function pushUnique(items, value) {
|
|
573
|
+
if (!items.includes(value))
|
|
574
|
+
items.push(value);
|
|
575
|
+
}
|
|
576
|
+
function pushModel(models, seen, id, alias) {
|
|
577
|
+
const cleanId = id.trim();
|
|
578
|
+
if (!cleanId || seen.has(cleanId))
|
|
579
|
+
return;
|
|
580
|
+
seen.add(cleanId);
|
|
581
|
+
models.push({ id: cleanId, alias: alias?.trim() || null });
|
|
582
|
+
}
|
|
583
|
+
function claudeModelAlias(modelId) {
|
|
584
|
+
const value = String(modelId || '').trim().toLowerCase();
|
|
585
|
+
if (!value)
|
|
586
|
+
return null;
|
|
587
|
+
if (value === 'opus' || value.startsWith('claude-opus-'))
|
|
588
|
+
return 'opus';
|
|
589
|
+
if (value === 'sonnet' || value.startsWith('claude-sonnet-'))
|
|
590
|
+
return 'sonnet';
|
|
591
|
+
if (value === 'haiku' || value.startsWith('claude-haiku-'))
|
|
592
|
+
return 'haiku';
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
function isClaudeModelToken(token) {
|
|
596
|
+
return token === 'opus' || token === 'sonnet' || token === 'haiku' || token.startsWith('claude-');
|
|
597
|
+
}
|
|
598
|
+
function addClaudeModel(models, seen, rawModel) {
|
|
599
|
+
const clean = String(rawModel || '').trim();
|
|
600
|
+
if (!clean)
|
|
601
|
+
return false;
|
|
602
|
+
const alias = claudeModelAlias(clean);
|
|
603
|
+
if (!alias && !clean.toLowerCase().startsWith('claude-'))
|
|
604
|
+
return false;
|
|
605
|
+
if (clean === alias) {
|
|
606
|
+
if (models.some((m) => m.alias === alias))
|
|
607
|
+
return false;
|
|
608
|
+
pushModel(models, seen, clean, null);
|
|
609
|
+
return true;
|
|
610
|
+
}
|
|
611
|
+
if (alias) {
|
|
612
|
+
const aliasIndex = models.findIndex((m) => m.id === alias && !m.alias);
|
|
613
|
+
if (aliasIndex >= 0) {
|
|
614
|
+
models.splice(aliasIndex, 1);
|
|
615
|
+
seen.delete(alias);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
pushModel(models, seen, clean, alias);
|
|
619
|
+
return true;
|
|
620
|
+
}
|
|
621
|
+
function isCodexModelToken(token) {
|
|
622
|
+
return /^(?:o\d(?:-[a-z0-9.-]+)?|gpt-[a-z0-9.-]+|codex-mini(?:-[a-z0-9.-]+)?)$/i.test(token);
|
|
623
|
+
}
|
|
624
|
+
function addCodexModel(models, seen, rawModel) {
|
|
625
|
+
const clean = String(rawModel || '').trim();
|
|
626
|
+
if (!clean || !isCodexModelToken(clean))
|
|
627
|
+
return false;
|
|
628
|
+
pushModel(models, seen, clean, null);
|
|
629
|
+
return true;
|
|
630
|
+
}
|
|
631
|
+
function readCodexConfigModels(home) {
|
|
632
|
+
const configPath = path.join(home, '.codex', 'config.toml');
|
|
633
|
+
if (!fs.existsSync(configPath))
|
|
634
|
+
return [];
|
|
635
|
+
try {
|
|
636
|
+
const raw = fs.readFileSync(configPath, 'utf-8');
|
|
637
|
+
const found = [];
|
|
638
|
+
const defaultModel = raw.match(/^\s*model\s*=\s*"([^"]+)"/m)?.[1];
|
|
639
|
+
if (defaultModel)
|
|
640
|
+
pushUnique(found, defaultModel);
|
|
641
|
+
const migrationsSection = raw.match(/\[notice\.model_migrations\]\n([\s\S]*?)(?:\n\[|$)/)?.[1] || '';
|
|
642
|
+
for (const match of migrationsSection.matchAll(/"[^"]+"\s*=\s*"([^"]+)"/g)) {
|
|
643
|
+
if (match[1])
|
|
644
|
+
pushUnique(found, match[1]);
|
|
645
|
+
}
|
|
646
|
+
return found.filter(isCodexModelToken);
|
|
647
|
+
}
|
|
648
|
+
catch {
|
|
649
|
+
return [];
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
function readCodexCachedModels(home) {
|
|
653
|
+
const cachePath = path.join(home, '.codex', 'models_cache.json');
|
|
654
|
+
if (!fs.existsSync(cachePath))
|
|
655
|
+
return [];
|
|
656
|
+
try {
|
|
657
|
+
const raw = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
|
|
658
|
+
const models = Array.isArray(raw?.models) ? raw.models : [];
|
|
659
|
+
return models
|
|
660
|
+
.filter((m) => m?.visibility === 'list')
|
|
661
|
+
.sort((a, b) => (Number(a?.priority) || 0) - (Number(b?.priority) || 0))
|
|
662
|
+
.map((m) => String(m?.slug || '').trim())
|
|
663
|
+
.filter(isCodexModelToken);
|
|
664
|
+
}
|
|
665
|
+
catch {
|
|
666
|
+
return [];
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
/** Look up context_window for a Codex model from ~/.codex/models_cache.json */
|
|
670
|
+
function readCodexContextWindow(model) {
|
|
671
|
+
if (!model)
|
|
672
|
+
return null;
|
|
673
|
+
const home = process.env.HOME || '';
|
|
674
|
+
const cachePath = path.join(home, '.codex', 'models_cache.json');
|
|
675
|
+
if (!fs.existsSync(cachePath))
|
|
676
|
+
return null;
|
|
677
|
+
try {
|
|
678
|
+
const raw = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
|
|
679
|
+
const models = Array.isArray(raw?.models) ? raw.models : [];
|
|
680
|
+
const entry = models.find((m) => m?.slug === model);
|
|
681
|
+
const cw = Number(entry?.context_window);
|
|
682
|
+
return cw > 0 ? cw : null;
|
|
683
|
+
}
|
|
684
|
+
catch {
|
|
685
|
+
return null;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
function readClaudeStateModels(home, workdir) {
|
|
689
|
+
const statePath = path.join(home, '.claude.json');
|
|
690
|
+
if (!fs.existsSync(statePath))
|
|
691
|
+
return [];
|
|
692
|
+
try {
|
|
693
|
+
const raw = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
|
|
694
|
+
const projects = raw?.projects;
|
|
695
|
+
if (!projects || typeof projects !== 'object')
|
|
696
|
+
return [];
|
|
697
|
+
const found = [];
|
|
698
|
+
const addFromProject = (projectState) => {
|
|
699
|
+
const usage = projectState?.lastModelUsage;
|
|
700
|
+
if (!usage || typeof usage !== 'object')
|
|
701
|
+
return;
|
|
702
|
+
for (const modelId of Object.keys(usage)) {
|
|
703
|
+
if (isClaudeModelToken(modelId))
|
|
704
|
+
pushUnique(found, modelId);
|
|
705
|
+
}
|
|
706
|
+
};
|
|
707
|
+
if (workdir && typeof projects[workdir] === 'object') {
|
|
708
|
+
addFromProject(projects[workdir]);
|
|
709
|
+
}
|
|
710
|
+
for (const [projectPath, projectState] of Object.entries(projects)) {
|
|
711
|
+
if (projectPath === workdir)
|
|
712
|
+
continue;
|
|
713
|
+
addFromProject(projectState);
|
|
714
|
+
}
|
|
715
|
+
return found;
|
|
716
|
+
}
|
|
717
|
+
catch {
|
|
718
|
+
return [];
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
function discoverClaudeModels(opts) {
|
|
722
|
+
const models = [];
|
|
723
|
+
const seen = new Set();
|
|
724
|
+
const sources = [];
|
|
725
|
+
const home = process.env.HOME || '';
|
|
726
|
+
if (opts.currentModel?.trim()) {
|
|
727
|
+
addClaudeModel(models, seen, opts.currentModel);
|
|
728
|
+
pushUnique(sources, 'current config');
|
|
729
|
+
}
|
|
730
|
+
let foundStateModel = false;
|
|
731
|
+
for (const modelId of readClaudeStateModels(home, opts.workdir)) {
|
|
732
|
+
foundStateModel = addClaudeModel(models, seen, modelId) || foundStateModel;
|
|
733
|
+
}
|
|
734
|
+
if (foundStateModel)
|
|
735
|
+
pushUnique(sources, '~/.claude.json');
|
|
736
|
+
const help = shellOutput('claude --help 2>/dev/null');
|
|
737
|
+
if (help) {
|
|
738
|
+
let foundHelpModel = false;
|
|
739
|
+
for (const match of help.matchAll(/\b(?:opus|sonnet|haiku|claude-(?:opus|sonnet|haiku)-[a-z0-9-]+)\b/gi)) {
|
|
740
|
+
const token = match[0].trim();
|
|
741
|
+
foundHelpModel = addClaudeModel(models, seen, token) || foundHelpModel;
|
|
742
|
+
}
|
|
743
|
+
if (foundHelpModel)
|
|
744
|
+
pushUnique(sources, 'claude --help');
|
|
745
|
+
}
|
|
746
|
+
if (opts.workdir) {
|
|
747
|
+
const sessions = getClaudeSessions({ agent: 'claude', workdir: opts.workdir, limit: 20 });
|
|
748
|
+
let foundSessionModel = false;
|
|
749
|
+
for (const session of sessions.sessions) {
|
|
750
|
+
if (!session.model)
|
|
751
|
+
continue;
|
|
752
|
+
foundSessionModel = addClaudeModel(models, seen, session.model) || foundSessionModel;
|
|
753
|
+
}
|
|
754
|
+
if (foundSessionModel)
|
|
755
|
+
pushUnique(sources, 'recent sessions');
|
|
756
|
+
}
|
|
757
|
+
return {
|
|
758
|
+
agent: 'claude',
|
|
759
|
+
models,
|
|
760
|
+
sources,
|
|
761
|
+
note: 'Claude CLI does not expose a machine-readable model list; entries are discovered from current config, ~/.claude.json, CLI help, and local session state.',
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
function discoverCodexModels(opts) {
|
|
765
|
+
const models = [];
|
|
766
|
+
const seen = new Set();
|
|
767
|
+
const sources = [];
|
|
768
|
+
const home = process.env.HOME || '';
|
|
769
|
+
let foundCacheModel = false;
|
|
770
|
+
for (const modelId of readCodexCachedModels(home)) {
|
|
771
|
+
foundCacheModel = addCodexModel(models, seen, modelId) || foundCacheModel;
|
|
772
|
+
}
|
|
773
|
+
if (foundCacheModel)
|
|
774
|
+
pushUnique(sources, '~/.codex/models_cache.json');
|
|
775
|
+
const help = shellOutput('codex --help 2>/dev/null');
|
|
776
|
+
if (help) {
|
|
777
|
+
let foundHelpModel = false;
|
|
778
|
+
for (const match of help.matchAll(/model="([^"]+)"/g)) {
|
|
779
|
+
addCodexModel(models, seen, match[1]);
|
|
780
|
+
foundHelpModel = true;
|
|
781
|
+
}
|
|
782
|
+
if (foundHelpModel)
|
|
783
|
+
pushUnique(sources, 'codex --help');
|
|
784
|
+
}
|
|
785
|
+
if (opts.currentModel?.trim()) {
|
|
786
|
+
addCodexModel(models, seen, opts.currentModel);
|
|
787
|
+
pushUnique(sources, 'current config');
|
|
788
|
+
}
|
|
789
|
+
let foundConfigModel = false;
|
|
790
|
+
for (const modelId of readCodexConfigModels(home)) {
|
|
791
|
+
foundConfigModel = addCodexModel(models, seen, modelId) || foundConfigModel;
|
|
792
|
+
}
|
|
793
|
+
if (foundConfigModel)
|
|
794
|
+
pushUnique(sources, '~/.codex/config.toml');
|
|
795
|
+
if (opts.workdir) {
|
|
796
|
+
const sessions = getCodexSessions({ agent: 'codex', workdir: opts.workdir, limit: 20 });
|
|
797
|
+
let foundSessionModel = false;
|
|
798
|
+
for (const session of sessions.sessions) {
|
|
799
|
+
if (!session.model)
|
|
800
|
+
continue;
|
|
801
|
+
foundSessionModel = addCodexModel(models, seen, session.model) || foundSessionModel;
|
|
802
|
+
}
|
|
803
|
+
if (foundSessionModel)
|
|
804
|
+
pushUnique(sources, 'recent sessions');
|
|
805
|
+
}
|
|
806
|
+
return {
|
|
807
|
+
agent: 'codex',
|
|
808
|
+
models,
|
|
809
|
+
sources,
|
|
810
|
+
note: 'Codex CLI does not expose a model-list subcommand; entries are discovered from the local Codex model cache and other local state.',
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
export function listModels(agent, opts = {}) {
|
|
814
|
+
return agent === 'codex' ? discoverCodexModels(opts) : discoverClaudeModels(opts);
|
|
815
|
+
}
|
|
531
816
|
function toIsoFromEpochSeconds(value) {
|
|
532
817
|
const n = Number(value);
|
|
533
818
|
if (!Number.isFinite(n) || n <= 0)
|