codeep 2.25.0 → 3.1.0

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.
@@ -0,0 +1,175 @@
1
+ /**
2
+ * One long poll, one offset, both kinds of update.
3
+ *
4
+ * Telegram's getUpdates has a single cursor per bot. `offset` confirms every
5
+ * update older than itself **regardless of `allowed_updates`** — that parameter
6
+ * only filters what comes back in the response, not what the call acknowledges.
7
+ * So two pollers on one bot do not coexist: each advances the cursor past
8
+ * updates the other never saw, and both start losing traffic silently. An
9
+ * approval tapped on the phone would simply not register, with nothing anywhere
10
+ * to say why.
11
+ *
12
+ * Hence this. Everything that wants updates subscribes here, the loop asks for
13
+ * every type any subscriber could want, and dispatch happens locally where
14
+ * losing one is impossible.
15
+ */
16
+ const API = 'https://api.telegram.org';
17
+ /** Server-side long-poll window. The request blocks for up to this long. */
18
+ const POLL_SECONDS = 25;
19
+ /** Local ceiling, comfortably past the server's own. */
20
+ const REQUEST_TIMEOUT_MS = (POLL_SECONDS + 10) * 1000;
21
+ /**
22
+ * Pause after a poll that brought nothing back.
23
+ *
24
+ * Telegram's long poll already blocks server-side for POLL_SECONDS, so in
25
+ * normal running this never fires. It exists for the case where the request
26
+ * returns immediately — a network failure, or a server that ignored the
27
+ * timeout — which without it turns this into a loop that hammers the API as
28
+ * fast as the connection allows.
29
+ */
30
+ const IDLE_PAUSE_MS = 1000;
31
+ /**
32
+ * Where the cursor goes after a batch.
33
+ *
34
+ * Never backwards: a retry that returns an older batch, or a response with ids
35
+ * this build does not understand, must not re-deliver what was already handled.
36
+ */
37
+ export function nextOffset(current, updates) {
38
+ let out = current;
39
+ for (const update of updates) {
40
+ if (typeof update.update_id === 'number')
41
+ out = Math.max(out, update.update_id + 1);
42
+ }
43
+ return out;
44
+ }
45
+ /** Every kind this loop asks for, so one cursor can serve every subscriber. */
46
+ export const POLLED_KINDS = ['callback_query', 'message'];
47
+ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
48
+ export class TelegramUpdates {
49
+ botToken;
50
+ handlers = new Map();
51
+ offset = 0;
52
+ running = false;
53
+ idlePauseMs;
54
+ observer = null;
55
+ /** Only the first failure of a streak is reported, then the recovery. */
56
+ failing = false;
57
+ constructor(botToken, idlePauseMs = IDLE_PAUSE_MS) {
58
+ this.botToken = botToken;
59
+ this.idlePauseMs = idlePauseMs;
60
+ }
61
+ /** Watch the health of the poll itself, separately from its payload. */
62
+ observe(observer) {
63
+ this.observer = observer;
64
+ }
65
+ report(ok, detail) {
66
+ if (ok === !this.failing)
67
+ return; // nothing changed; stay quiet
68
+ this.failing = !ok;
69
+ this.observer?.({ ok, detail });
70
+ }
71
+ /**
72
+ * Listen for one kind of update. Returns the function that stops listening.
73
+ *
74
+ * The loop runs while anyone is listening and stops when the last subscriber
75
+ * leaves, so a CLI with the inbox switched off never opens a connection.
76
+ */
77
+ subscribe(kind, handler) {
78
+ let set = this.handlers.get(kind);
79
+ if (!set) {
80
+ set = new Set();
81
+ this.handlers.set(kind, set);
82
+ }
83
+ set.add(handler);
84
+ if (!this.running)
85
+ void this.loop();
86
+ return () => {
87
+ set.delete(handler);
88
+ if (this.subscriberCount() === 0)
89
+ this.running = false;
90
+ };
91
+ }
92
+ subscriberCount() {
93
+ let total = 0;
94
+ for (const set of this.handlers.values())
95
+ total += set.size;
96
+ return total;
97
+ }
98
+ async loop() {
99
+ if (this.running)
100
+ return;
101
+ this.running = true;
102
+ while (this.running && this.subscriberCount() > 0) {
103
+ const json = await this.getUpdates();
104
+ if (!this.running)
105
+ break;
106
+ const updates = Array.isArray(json?.result) ? json.result : [];
107
+ // Advance before dispatching. A handler that throws must not make the
108
+ // loop re-read the same update forever.
109
+ this.offset = nextOffset(this.offset, updates);
110
+ for (const update of updates) {
111
+ for (const kind of POLLED_KINDS) {
112
+ const payload = update[kind];
113
+ if (payload === undefined)
114
+ continue;
115
+ for (const handler of this.handlers.get(kind) ?? []) {
116
+ try {
117
+ await handler(payload);
118
+ }
119
+ catch {
120
+ // One subscriber's failure is not the others' problem, and is
121
+ // certainly not a reason to stop reading the bot.
122
+ }
123
+ }
124
+ }
125
+ }
126
+ // Covers an empty batch as well as a failed request: both mean the loop
127
+ // would otherwise come straight back with nothing to do.
128
+ if (updates.length === 0)
129
+ await sleep(this.idlePauseMs);
130
+ }
131
+ this.running = false;
132
+ }
133
+ async getUpdates() {
134
+ try {
135
+ const response = await fetch(`${API}/bot${this.botToken}/getUpdates`, {
136
+ method: 'POST',
137
+ headers: { 'Content-Type': 'application/json' },
138
+ body: JSON.stringify({
139
+ offset: this.offset,
140
+ timeout: POLL_SECONDS,
141
+ allowed_updates: POLLED_KINDS,
142
+ }),
143
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
144
+ });
145
+ const json = await response.json().catch(() => null);
146
+ if (!response.ok || json?.ok === false) {
147
+ const description = typeof json?.description === 'string' ? json.description : `HTTP ${response.status}`;
148
+ this.report(false, description);
149
+ return null;
150
+ }
151
+ this.report(true, 'reading updates again');
152
+ return json;
153
+ }
154
+ catch (error) {
155
+ this.report(false, error?.message || 'could not reach Telegram');
156
+ return null;
157
+ }
158
+ }
159
+ }
160
+ /**
161
+ * The one poller per bot token.
162
+ *
163
+ * Approvals are constructed per dangerous tool call and the inbox lives for the
164
+ * whole session; both must reach the same cursor, so the instance is keyed by
165
+ * token rather than owned by either.
166
+ */
167
+ const shared = new Map();
168
+ export function sharedUpdates(botToken) {
169
+ let instance = shared.get(botToken);
170
+ if (!instance) {
171
+ instance = new TelegramUpdates(botToken);
172
+ shared.set(botToken, instance);
173
+ }
174
+ return instance;
175
+ }
@@ -22,6 +22,7 @@ const MODEL_CONTEXT_WINDOWS = {
22
22
  'gpt-5.4': 1_050_000,
23
23
  'gpt-5.4-mini': 400_000,
24
24
  // Anthropic
25
+ 'claude-fable-5-1': 1_000_000,
25
26
  'claude-fable-5': 1_000_000,
26
27
  'claude-opus-5': 1_000_000,
27
28
  'claude-sonnet-4-6': 1_000_000,
@@ -97,10 +98,11 @@ const MODEL_PRICING = {
97
98
  'gpt-5.4': { inputPer1M: 2.50, outputPer1M: 15.00 },
98
99
  'gpt-5.4-mini': { inputPer1M: 0.75, outputPer1M: 4.50 },
99
100
  // Anthropic
101
+ 'claude-fable-5-1': { inputPer1M: 10.00, outputPer1M: 50.00 },
100
102
  'claude-fable-5': { inputPer1M: 10.00, outputPer1M: 50.00 },
101
103
  'claude-opus-5': { inputPer1M: 5.00, outputPer1M: 25.00 },
102
104
  'claude-sonnet-4-6': { inputPer1M: 3.00, outputPer1M: 15.00 },
103
- 'claude-sonnet-5': { inputPer1M: 3.00, outputPer1M: 15.00 },
105
+ 'claude-sonnet-5': { inputPer1M: 2.00, outputPer1M: 10.00 },
104
106
  'claude-haiku-4-5-20251001': { inputPer1M: 1.00, outputPer1M: 5.00 },
105
107
  // DeepSeek (cache-miss input pricing)
106
108
  // DeepSeek moved to peak / off-peak billing on 2026-08-16, with off-peak at
@@ -206,11 +208,18 @@ export function recordTokenUsage(usage, model, provider, actualCostUsd) {
206
208
  */
207
209
  export function extractOpenAIUsage(data) {
208
210
  if (data?.usage) {
209
- // OpenAI-protocol `prompt_tokens` is INCLUSIVE of cached prompt tokens
210
- // (DeepSeek/OpenAI report cache hits in prompt_tokens_details.cached_tokens).
211
+ // OpenAI-protocol `prompt_tokens` is INCLUSIVE of cached prompt tokens.
211
212
  // Surface them so getCostBreakdown bills cache reads at the discounted
212
213
  // rate instead of the full cache-miss input rate.
213
- const cached = data.usage.prompt_tokens_details?.cached_tokens || 0;
214
+ //
215
+ // Two shapes are in the wild. OpenAI, DeepSeek and Qwen's text models nest
216
+ // it under prompt_tokens_details; Kimi returns it at the top level, and
217
+ // Alibaba's own docs say some Qwen models still do and will be migrated
218
+ // later. Reading only the nested form zeroed every Kimi cache hit, so the
219
+ // cached portion of a run billed at the full cache-miss rate — five times
220
+ // what it costs — with nothing anywhere to say so.
221
+ const nested = data.usage.prompt_tokens_details?.cached_tokens;
222
+ const cached = (typeof nested === 'number' ? nested : data.usage.cached_tokens) || 0;
214
223
  return {
215
224
  promptTokens: data.usage.prompt_tokens || 0,
216
225
  completionTokens: data.usage.completion_tokens || 0,
@@ -242,6 +251,36 @@ export function extractAnthropicUsage(data) {
242
251
  }
243
252
  return null;
244
253
  }
254
+ /**
255
+ * What a provider charges to read a cached token, as a fraction of its own
256
+ * cache-miss input rate.
257
+ *
258
+ * 0.1 is Anthropic's ratio and used to be applied to everyone. Kimi lists
259
+ * $0.19 against a $0.95 cache-miss rate, and Alibaba prices Qwen's implicit
260
+ * cache at 20% of input — both 0.2, so every cached token on those two was
261
+ * billed at half what it actually costs.
262
+ */
263
+ /**
264
+ * Models whose cache-read rate is not their provider's usual one.
265
+ *
266
+ * Fable 5.1 reads a cached token at 0.025× the base input price, where every
267
+ * other Anthropic model charges 0.1×. Checked before the provider map, because
268
+ * this is a property of the model and not of the account it runs under.
269
+ */
270
+ const MODEL_CACHE_READ_RATE = {
271
+ 'claude-fable-5-1': 0.025,
272
+ };
273
+ const CACHE_READ_RATE = {
274
+ 'kimi': 0.2,
275
+ 'kimi-api': 0.2,
276
+ 'qwen': 0.2,
277
+ 'qwen-api': 0.2,
278
+ 'qwen-cn': 0.2,
279
+ 'qwen-cn-api': 0.2,
280
+ 'qwen-token-plan': 0.2,
281
+ };
282
+ /** Anthropic's ratio, and the safest guess for a provider we have not priced. */
283
+ const DEFAULT_CACHE_READ_RATE = 0.1;
245
284
  /**
246
285
  * Get cost breakdown grouped by provider/model.
247
286
  *
@@ -269,16 +308,20 @@ export function getCostBreakdown(startIndex = 0) {
269
308
  else {
270
309
  const pricing = MODEL_PRICING[record.model];
271
310
  if (pricing) {
272
- // Anthropic prompt caching: cache_creation_input is billed at 1.25×
273
- // the base input rate, cache_read_input at 0.1×. The remaining
274
- // (uncached) prompt tokens bill at the standard 1.0× rate.
311
+ // Cache writes bill at 1.25× the base input rate (Anthropic's, and the
312
+ // only provider here that charges for them at all); cache reads bill
313
+ // at whatever fraction the provider charges. The remaining (uncached)
314
+ // prompt tokens bill at the standard 1.0× rate.
275
315
  const cacheCreate = record.cacheCreationTokens ?? 0;
276
316
  const cacheRead = record.cacheReadTokens ?? 0;
317
+ const cacheReadRate = MODEL_CACHE_READ_RATE[record.model]
318
+ ?? CACHE_READ_RATE[record.provider?.trim().toLowerCase()]
319
+ ?? DEFAULT_CACHE_READ_RATE;
277
320
  const uncachedPrompt = Math.max(0, record.promptTokens - cacheCreate - cacheRead);
278
321
  existing.estimatedCost +=
279
322
  (uncachedPrompt / 1_000_000) * pricing.inputPer1M
280
323
  + (cacheCreate / 1_000_000) * pricing.inputPer1M * 1.25
281
- + (cacheRead / 1_000_000) * pricing.inputPer1M * 0.1
324
+ + (cacheRead / 1_000_000) * pricing.inputPer1M * cacheReadRate
282
325
  + (record.completionTokens / 1_000_000) * pricing.outputPer1M;
283
326
  }
284
327
  }
@@ -57,7 +57,12 @@ export const AGENT_TOOLS = {
57
57
  },
58
58
  execute_command: {
59
59
  name: 'execute_command',
60
- description: 'Execute a shell command. Use for npm, git, build tools, tests, etc.',
60
+ // Naming a few examples read as an exhaustive list: the agent refused
61
+ // `sleep`, which IS on the allowlist, explaining that its tool was
62
+ // "limited to package managers and version control". It believed the
63
+ // description over its own capability. Say the shape instead, and let
64
+ // the runtime refuse — it already answers with a specific reason.
65
+ description: 'Execute a shell command. A safety allowlist applies and covers far more than build tooling — package managers, git, test runners, and ordinary utilities such as ls, cat, grep, find, echo, date, sleep and curl. Do not assume a command is forbidden: try it, and a refusal will say so.',
61
66
  parameters: {
62
67
  command: { type: 'string', description: 'The command to run (e.g., npm, git, node)', required: true },
63
68
  args: { type: 'array', description: 'Command arguments as array (e.g., ["install", "lodash"])', required: false },
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "2.25.0";
1
+ export declare const VERSION = "3.1.0";
package/dist/version.js CHANGED
@@ -1,4 +1,4 @@
1
1
  // AUTO-GENERATED by scripts/gen-version.js — do not edit by hand.
2
2
  // Baked from package.json at build time so the bun-compiled binary reports
3
3
  // the right version (it has no package.json on disk to read at runtime).
4
- export const VERSION = '2.25.0';
4
+ export const VERSION = '3.1.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeep",
3
- "version": "2.25.0",
3
+ "version": "3.1.0",
4
4
  "description": "AI-powered coding assistant built for the terminal. Multiple LLM providers, project-aware context, and a seamless development workflow.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",