termux-dev 1.8.2 → 1.9.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.
- package/README.md +15 -5
- package/assets/banner.svg +1 -1
- package/assets/preview.svg +1 -1
- package/dist/cli/background-ui.js +8 -8
- package/dist/cli/files.js +2 -12
- package/dist/cli/finder.js +4 -0
- package/dist/cli/headless.js +3 -2
- package/dist/cli/index.js +128 -59
- package/dist/cli/keys.js +12 -3
- package/dist/cli/liveblock.js +38 -9
- package/dist/cli/mcp-panel.js +2 -0
- package/dist/cli/picker.js +2 -0
- package/dist/cli/prompt.js +54 -7
- package/dist/cli/provider-manager.js +197 -0
- package/dist/cli/providers.js +0 -5
- package/dist/cli/settings.js +38 -17
- package/dist/cli/typeahead.js +10 -17
- package/dist/cli/ui.js +33 -13
- package/dist/cli/usage-panel.js +17 -4
- package/dist/cli/width.js +18 -0
- package/dist/core/background.js +14 -0
- package/dist/core/context-stats.js +1 -0
- package/dist/core/cost.js +25 -11
- package/dist/core/custom-providers.js +161 -0
- package/dist/core/images.js +46 -0
- package/dist/core/local-config.js +3 -1
- package/dist/core/locales/de.js +36 -7
- package/dist/core/locales/en.js +36 -7
- package/dist/core/locales/es.js +36 -7
- package/dist/core/locales/fr.js +36 -7
- package/dist/core/locales/ja.js +36 -7
- package/dist/core/locales/pt.js +36 -7
- package/dist/core/locales/ru.js +36 -7
- package/dist/core/locales/uk.js +36 -7
- package/dist/core/locales/zh.js +36 -7
- package/dist/core/loop.js +32 -1
- package/dist/core/prices.js +8 -4
- package/dist/core/session-media.js +115 -0
- package/dist/core/session.js +17 -2
- package/dist/core/title.js +19 -2
- package/dist/core/types.js +1 -1
- package/dist/core/usage.js +41 -4
- package/dist/permissions/guard.js +3 -0
- package/dist/providers/anthropic.js +67 -13
- package/dist/providers/index.js +16 -3
- package/dist/providers/openai.js +99 -41
- package/dist/providers/shared.js +83 -0
- package/dist/tools/fs.js +32 -2
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { UsageTracker } from '../core/usage.js';
|
|
2
2
|
import { priceUsage } from '../core/prices.js';
|
|
3
3
|
import { ToolGuard } from './toolguard.js';
|
|
4
|
-
import { NOT_ABOUT_REASONING, PARAMETER_REJECTED, errorMessageOf, imageNotSentText, parseToolArguments, readSseData, toolTargetHint } from './shared.js';
|
|
4
|
+
import { DEFAULT_REQUEST_TIMEOUT_MS, NOT_ABOUT_REASONING, RequestWatchdog, PARAMETER_REJECTED, errorMessageOf, imageNotSentText, imagesToSend, parseToolArguments, readSseData, toolTargetHint } from './shared.js';
|
|
5
5
|
/**
|
|
6
6
|
* Speaks the native Anthropic Messages API: `POST {origin}/v1/messages`.
|
|
7
7
|
*
|
|
@@ -30,11 +30,11 @@ function splitDataUrl(dataUrl) {
|
|
|
30
30
|
export function toAnthropicMessages(messages, options = {}) {
|
|
31
31
|
const systemParts = [];
|
|
32
32
|
const out = [];
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
33
|
+
const send = imagesToSend(messages);
|
|
34
|
+
const imageBlock = (img) => {
|
|
35
|
+
const parts = splitDataUrl(img.dataUrl);
|
|
36
|
+
return parts ? { type: 'image', source: { type: 'base64', media_type: parts.mediaType, data: parts.data } } : null;
|
|
37
|
+
};
|
|
38
38
|
const push = (role, blocks) => {
|
|
39
39
|
if (blocks.length === 0)
|
|
40
40
|
return;
|
|
@@ -52,7 +52,21 @@ export function toAnthropicMessages(messages, options = {}) {
|
|
|
52
52
|
return;
|
|
53
53
|
}
|
|
54
54
|
if (m.role === 'tool') {
|
|
55
|
-
|
|
55
|
+
const text = m.content || '(empty)';
|
|
56
|
+
let content = text;
|
|
57
|
+
if (m.images && m.images.length > 0) {
|
|
58
|
+
if (options.images === false) {
|
|
59
|
+
content = `${text}\n${imageNotSentText(m.images.map((img) => img.path))}`;
|
|
60
|
+
}
|
|
61
|
+
else if (send.has(idx)) {
|
|
62
|
+
// This API takes pictures inside a tool result.
|
|
63
|
+
content = [{ type: 'text', text }, ...m.images.map(imageBlock).filter((b) => !!b)];
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
content = `${text}\n${m.images.map((img) => `[Attached Image: ${img.path}]`).join(' ')}`;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
push('user', [{ type: 'tool_result', tool_use_id: m.tool_call_id, content }]);
|
|
56
70
|
return;
|
|
57
71
|
}
|
|
58
72
|
if (m.role === 'assistant') {
|
|
@@ -76,11 +90,11 @@ export function toAnthropicMessages(messages, options = {}) {
|
|
|
76
90
|
const note = imageNotSentText(m.images.map((img) => img.path));
|
|
77
91
|
text = text ? `${text}\n${note}` : note;
|
|
78
92
|
}
|
|
79
|
-
else if (idx
|
|
93
|
+
else if (send.has(idx)) {
|
|
80
94
|
for (const img of m.images) {
|
|
81
|
-
const
|
|
82
|
-
if (
|
|
83
|
-
blocks.push(
|
|
95
|
+
const block = imageBlock(img);
|
|
96
|
+
if (block)
|
|
97
|
+
blocks.push(block);
|
|
84
98
|
}
|
|
85
99
|
}
|
|
86
100
|
else {
|
|
@@ -287,6 +301,10 @@ export class AnthropicProvider {
|
|
|
287
301
|
err.status = res.status;
|
|
288
302
|
throw err;
|
|
289
303
|
}
|
|
304
|
+
lostRequestUsage(promptTokens, completionTokens) {
|
|
305
|
+
// usageOf records the usage itself.
|
|
306
|
+
return this.usageOf(null, { prompt: promptTokens, completion: completionTokens });
|
|
307
|
+
}
|
|
290
308
|
usageOf(raw, fallback) {
|
|
291
309
|
const guessed = !raw || (raw.input + raw.cacheRead + raw.cacheWrite + raw.output === 0);
|
|
292
310
|
const base = raw ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
@@ -304,11 +322,44 @@ export class AnthropicProvider {
|
|
|
304
322
|
if (priced.cost !== undefined) {
|
|
305
323
|
usage.cost = priced.cost;
|
|
306
324
|
usage.costSource = guessed && fallback && priced.costSource !== 'free' ? 'estimated' : priced.costSource;
|
|
325
|
+
if (priced.parts)
|
|
326
|
+
usage.costParts = priced.parts;
|
|
307
327
|
}
|
|
308
328
|
UsageTracker.getInstance().recordUsage(usage);
|
|
309
329
|
return usage;
|
|
310
330
|
}
|
|
331
|
+
get timeoutMs() {
|
|
332
|
+
return this.options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
333
|
+
}
|
|
311
334
|
async chat(request) {
|
|
335
|
+
const watch = new RequestWatchdog(this.timeoutMs, request.signal);
|
|
336
|
+
try {
|
|
337
|
+
return await this.chatOnce({ ...request, signal: watch.signal });
|
|
338
|
+
}
|
|
339
|
+
catch (err) {
|
|
340
|
+
throw watch.timedOut ? watch.error() : err;
|
|
341
|
+
}
|
|
342
|
+
finally {
|
|
343
|
+
watch.stop();
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
async *chatStream(request) {
|
|
347
|
+
const watch = new RequestWatchdog(this.timeoutMs, request.signal);
|
|
348
|
+
try {
|
|
349
|
+
const response = yield* this.streamOnce({ ...request, signal: watch.signal }, watch);
|
|
350
|
+
// The time ran out and the stream was cut: what came so far is not an answer.
|
|
351
|
+
if (watch.timedOut)
|
|
352
|
+
throw watch.error();
|
|
353
|
+
return response;
|
|
354
|
+
}
|
|
355
|
+
catch (err) {
|
|
356
|
+
throw watch.timedOut ? watch.error() : err;
|
|
357
|
+
}
|
|
358
|
+
finally {
|
|
359
|
+
watch.stop();
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
async chatOnce(request) {
|
|
312
363
|
const res = await this.send(request, false);
|
|
313
364
|
if (!res.ok)
|
|
314
365
|
await this.failure(res);
|
|
@@ -363,7 +414,7 @@ export class AnthropicProvider {
|
|
|
363
414
|
usage,
|
|
364
415
|
};
|
|
365
416
|
}
|
|
366
|
-
async *
|
|
417
|
+
async *streamOnce(request, watch) {
|
|
367
418
|
const res = await this.send(request, true);
|
|
368
419
|
if (!res.ok)
|
|
369
420
|
await this.failure(res);
|
|
@@ -402,7 +453,7 @@ export class AnthropicProvider {
|
|
|
402
453
|
tally.cacheWrite = num(u.cache_creation_input_tokens);
|
|
403
454
|
};
|
|
404
455
|
try {
|
|
405
|
-
for await (const payload of readSseData(reader, request.signal, (n) => UsageTracker.getInstance().recordResponseChunk(n))) {
|
|
456
|
+
for await (const payload of readSseData(reader, request.signal, (n) => { watch.touch(); UsageTracker.getInstance().recordResponseChunk(n); })) {
|
|
406
457
|
if (payload === '[DONE]')
|
|
407
458
|
continue;
|
|
408
459
|
let event;
|
|
@@ -473,6 +524,9 @@ export class AnthropicProvider {
|
|
|
473
524
|
}
|
|
474
525
|
catch { }
|
|
475
526
|
}
|
|
527
|
+
// Cut off because it went silent: nothing here is an answer, and it must not be counted as one.
|
|
528
|
+
if (watch.timedOut)
|
|
529
|
+
throw watch.error();
|
|
476
530
|
const toolCalls = [];
|
|
477
531
|
const thinkingBlocks = [];
|
|
478
532
|
for (const index of [...blocks.keys()].sort((a, b) => a - b)) {
|
package/dist/providers/index.js
CHANGED
|
@@ -3,16 +3,29 @@ import { resolveFormat } from '../core/formats.js';
|
|
|
3
3
|
import { modelAcceptsImages } from '../core/vision.js';
|
|
4
4
|
import { OpenAIProvider } from './openai.js';
|
|
5
5
|
import { AnthropicProvider } from './anthropic.js';
|
|
6
|
-
|
|
6
|
+
const LEVEL_ORDER = ['off', 'low', 'normal', 'high', 'xhigh', 'max'];
|
|
7
|
+
export function createProvider(config, opts = {}) {
|
|
7
8
|
const baseUrl = config.baseUrl || 'https://api.openai.com/v1';
|
|
8
9
|
const apiKey = config.apiKey || process.env.OPENAI_API_KEY || '';
|
|
9
10
|
const model = config.model || 'gpt-4o-mini';
|
|
10
11
|
// OpenAI or Anthropic wire format: by the base URL, or by what the gateway offers for this model.
|
|
11
12
|
const format = resolveFormat({ baseUrl, model, apiFormat: config.apiFormat });
|
|
12
13
|
// The reasoning level chosen with /effort, translated for this model and this API.
|
|
13
|
-
const
|
|
14
|
+
const chosen = effectiveReasoning({ ...config, model, baseUrl });
|
|
15
|
+
let params = chosen.params;
|
|
16
|
+
if (opts.light && chosen.caps.supported) {
|
|
17
|
+
const lightest = LEVEL_ORDER.find((level) => chosen.caps.levels.includes(level));
|
|
18
|
+
if (lightest)
|
|
19
|
+
params = effectiveReasoning({ ...config, model, baseUrl, effortOverride: lightest }).params;
|
|
20
|
+
}
|
|
14
21
|
// Images are sent unless the catalogue says the model takes text only.
|
|
15
|
-
const
|
|
22
|
+
const seconds = Number(config.requestTimeoutSeconds);
|
|
23
|
+
const options = {
|
|
24
|
+
price: { provider: config.provider, prices: config.prices },
|
|
25
|
+
images: modelAcceptsImages(model) !== false,
|
|
26
|
+
// 0 turns the limit off; anything that is not a number keeps the default.
|
|
27
|
+
timeoutMs: Number.isFinite(seconds) && seconds >= 0 && config.requestTimeoutSeconds !== undefined && config.requestTimeoutSeconds !== '' ? seconds * 1000 : undefined,
|
|
28
|
+
};
|
|
16
29
|
if (format === 'anthropic') {
|
|
17
30
|
return new AnthropicProvider(baseUrl, apiKey || process.env.ANTHROPIC_API_KEY || '', model, params ?? undefined, options);
|
|
18
31
|
}
|
package/dist/providers/openai.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { UsageTracker } from '../core/usage.js';
|
|
2
2
|
import { ToolGuard } from './toolguard.js';
|
|
3
|
-
import { NOT_ABOUT_REASONING, PARAMETER_REJECTED, errorMessageOf, imageNotSentText, openAiUsage, parseToolArguments, toolTargetHint } from './shared.js';
|
|
3
|
+
import { DEFAULT_REQUEST_TIMEOUT_MS, NOT_ABOUT_REASONING, RequestWatchdog, PARAMETER_REJECTED, errorMessageOf, imageNotSentText, imagesToSend, openAiUsage, parseToolArguments, toolTargetHint } from './shared.js';
|
|
4
4
|
export { parseToolArguments };
|
|
5
5
|
// Fields the extra parameters may never overwrite.
|
|
6
6
|
const PROTECTED_FIELDS = new Set(['model', 'messages', 'stream', 'tools', 'stream_options']);
|
|
@@ -97,29 +97,41 @@ export class OpenAIProvider {
|
|
|
97
97
|
return retry;
|
|
98
98
|
}
|
|
99
99
|
buildPayload(request, stream, probe = false) {
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
100
|
+
const send = imagesToSend(request.messages);
|
|
101
|
+
const messages = [];
|
|
102
|
+
// Pictures that tool results carry: this format only takes text in a tool message, so they
|
|
103
|
+
// follow the run of tool messages as one message of the user.
|
|
104
|
+
let toolPictures = [];
|
|
105
|
+
const flushToolPictures = () => {
|
|
106
|
+
if (toolPictures.length === 0)
|
|
107
|
+
return;
|
|
108
|
+
messages.push({
|
|
109
|
+
role: 'user',
|
|
110
|
+
content: [
|
|
111
|
+
{ type: 'text', text: `Pictures returned by the tool call(s) above: ${toolPictures.map((img) => img.path).join(', ')}` },
|
|
112
|
+
...toolPictures.map((img) => ({ type: 'image_url', image_url: { url: img.dataUrl } })),
|
|
113
|
+
],
|
|
114
|
+
});
|
|
115
|
+
toolPictures = [];
|
|
116
|
+
};
|
|
117
|
+
request.messages.forEach((m, idx) => {
|
|
118
|
+
if (m.role !== 'tool')
|
|
119
|
+
flushToolPictures();
|
|
120
|
+
let content = m.content || "";
|
|
121
|
+
if (m.images && m.images.length > 0) {
|
|
122
|
+
if (this.options.images === false) {
|
|
123
|
+
const note = imageNotSentText(m.images.map(img => img.path));
|
|
124
|
+
content = m.content ? `${m.content}\n${note}` : note;
|
|
125
|
+
if (!this.imageNoticeShown) {
|
|
126
|
+
this.imageNoticeShown = true;
|
|
127
|
+
this.notices.push(`${this.model} cannot read images, so the image was not sent. Switch to a model with vision (/model).`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
else if (send.has(idx)) {
|
|
131
|
+
if (m.role === 'tool') {
|
|
132
|
+
toolPictures.push(...m.images);
|
|
121
133
|
}
|
|
122
|
-
else
|
|
134
|
+
else {
|
|
123
135
|
content = [
|
|
124
136
|
{ type: 'text', text: m.content || "" },
|
|
125
137
|
...m.images.map(img => ({
|
|
@@ -128,24 +140,30 @@ export class OpenAIProvider {
|
|
|
128
140
|
}))
|
|
129
141
|
];
|
|
130
142
|
}
|
|
131
|
-
else {
|
|
132
|
-
const imgRef = m.images.map(img => `[Attached Image: ${img.path}]`).join(' ');
|
|
133
|
-
content = m.content ? `${m.content}\n${imgRef}` : imgRef;
|
|
134
|
-
}
|
|
135
143
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
144
|
+
else {
|
|
145
|
+
const imgRef = m.images.map(img => `[Attached Image: ${img.path}]`).join(' ');
|
|
146
|
+
content = m.content ? `${m.content}\n${imgRef}` : imgRef;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const msg = { role: m.role, content };
|
|
150
|
+
if (m.name)
|
|
151
|
+
msg.name = m.name;
|
|
152
|
+
if (m.tool_calls)
|
|
153
|
+
msg.tool_calls = m.tool_calls.map(tc => ({
|
|
154
|
+
id: tc.id,
|
|
155
|
+
type: 'function',
|
|
156
|
+
function: { name: tc.name, arguments: JSON.stringify(tc.arguments) }
|
|
157
|
+
}));
|
|
158
|
+
if (m.tool_call_id)
|
|
159
|
+
msg.tool_call_id = m.tool_call_id;
|
|
160
|
+
messages.push(msg);
|
|
161
|
+
});
|
|
162
|
+
flushToolPictures();
|
|
163
|
+
const payload = {
|
|
164
|
+
model: this.model,
|
|
165
|
+
stream,
|
|
166
|
+
messages,
|
|
149
167
|
};
|
|
150
168
|
// Tools the gateway refused are left out; a probe is a request that costs next to nothing.
|
|
151
169
|
const tools = this.toolGuard.apply(request.tools);
|
|
@@ -168,7 +186,43 @@ export class OpenAIProvider {
|
|
|
168
186
|
}
|
|
169
187
|
return payload;
|
|
170
188
|
}
|
|
189
|
+
lostRequestUsage(promptTokens, completionTokens) {
|
|
190
|
+
const usage = openAiUsage({}, this.priceContext(), { prompt: promptTokens, completion: completionTokens });
|
|
191
|
+
UsageTracker.getInstance().recordUsage(usage);
|
|
192
|
+
return usage;
|
|
193
|
+
}
|
|
194
|
+
get timeoutMs() {
|
|
195
|
+
return this.options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
196
|
+
}
|
|
171
197
|
async chat(request) {
|
|
198
|
+
const watch = new RequestWatchdog(this.timeoutMs, request.signal);
|
|
199
|
+
try {
|
|
200
|
+
return await this.chatOnce({ ...request, signal: watch.signal });
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
throw watch.timedOut ? watch.error() : err;
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
watch.stop();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
async *chatStream(request) {
|
|
210
|
+
const watch = new RequestWatchdog(this.timeoutMs, request.signal);
|
|
211
|
+
try {
|
|
212
|
+
const response = yield* this.streamOnce({ ...request, signal: watch.signal }, watch);
|
|
213
|
+
// The time ran out and the stream was cut: what came so far is not an answer.
|
|
214
|
+
if (watch.timedOut)
|
|
215
|
+
throw watch.error();
|
|
216
|
+
return response;
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
throw watch.timedOut ? watch.error() : err;
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
watch.stop();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
async chatOnce(request) {
|
|
172
226
|
const res = await this.send(request, false);
|
|
173
227
|
if (!res.ok) {
|
|
174
228
|
const errText = await res.text();
|
|
@@ -216,7 +270,7 @@ export class OpenAIProvider {
|
|
|
216
270
|
usage
|
|
217
271
|
};
|
|
218
272
|
}
|
|
219
|
-
async *
|
|
273
|
+
async *streamOnce(request, watch) {
|
|
220
274
|
const res = await this.send(request, true);
|
|
221
275
|
if (!res.ok) {
|
|
222
276
|
const errText = await res.text();
|
|
@@ -255,6 +309,7 @@ export class OpenAIProvider {
|
|
|
255
309
|
if (request.signal?.aborted)
|
|
256
310
|
break;
|
|
257
311
|
const { done, value } = await reader.read();
|
|
312
|
+
watch.touch();
|
|
258
313
|
if (done || request.signal?.aborted)
|
|
259
314
|
break;
|
|
260
315
|
if (value) {
|
|
@@ -388,6 +443,9 @@ export class OpenAIProvider {
|
|
|
388
443
|
finally {
|
|
389
444
|
reader.releaseLock();
|
|
390
445
|
}
|
|
446
|
+
// Cut off because it went silent: nothing here is an answer, and it must not be counted as one.
|
|
447
|
+
if (watch.timedOut)
|
|
448
|
+
throw watch.error();
|
|
391
449
|
if (thinkBuffer.length > 0) {
|
|
392
450
|
if (inThinkTag) {
|
|
393
451
|
accumulatedReasoning += thinkBuffer;
|
package/dist/providers/shared.js
CHANGED
|
@@ -1,4 +1,85 @@
|
|
|
1
1
|
import { priceUsage } from '../core/prices.js';
|
|
2
|
+
/** A slow model may think for minutes without a byte: the limit is generous, it is there for a dead connection. */
|
|
3
|
+
export const DEFAULT_REQUEST_TIMEOUT_MS = 300_000;
|
|
4
|
+
/**
|
|
5
|
+
* Gives up on a request that has gone silent. On a phone the connection can die without the app
|
|
6
|
+
* noticing (the network changes, the app sleeps), and the request would wait for ever. The timer
|
|
7
|
+
* starts with the request and starts over with every piece of data that arrives.
|
|
8
|
+
*
|
|
9
|
+
* `signal` is aborted when the time is up or when the caller's own signal is aborted; `timedOut`
|
|
10
|
+
* tells the two apart. The error it makes has the code ETIMEDOUT, which devx treats as a lost
|
|
11
|
+
* connection: the request is sent again.
|
|
12
|
+
*/
|
|
13
|
+
export class RequestWatchdog {
|
|
14
|
+
ms;
|
|
15
|
+
controller = new AbortController();
|
|
16
|
+
timer;
|
|
17
|
+
fired = false;
|
|
18
|
+
unlink;
|
|
19
|
+
constructor(ms, parent) {
|
|
20
|
+
this.ms = ms;
|
|
21
|
+
if (parent) {
|
|
22
|
+
if (parent.aborted)
|
|
23
|
+
this.controller.abort();
|
|
24
|
+
else {
|
|
25
|
+
const onAbort = () => this.controller.abort();
|
|
26
|
+
parent.addEventListener('abort', onAbort, { once: true });
|
|
27
|
+
this.unlink = () => parent.removeEventListener('abort', onAbort);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
this.touch();
|
|
31
|
+
}
|
|
32
|
+
get signal() {
|
|
33
|
+
return this.controller.signal;
|
|
34
|
+
}
|
|
35
|
+
get timedOut() {
|
|
36
|
+
return this.fired;
|
|
37
|
+
}
|
|
38
|
+
/** Something arrived: the clock starts over. */
|
|
39
|
+
touch() {
|
|
40
|
+
if (!(this.ms > 0) || this.fired || this.controller.signal.aborted)
|
|
41
|
+
return;
|
|
42
|
+
clearTimeout(this.timer);
|
|
43
|
+
this.timer = setTimeout(() => {
|
|
44
|
+
this.fired = true;
|
|
45
|
+
this.controller.abort();
|
|
46
|
+
}, this.ms);
|
|
47
|
+
this.timer.unref?.();
|
|
48
|
+
}
|
|
49
|
+
stop() {
|
|
50
|
+
clearTimeout(this.timer);
|
|
51
|
+
this.unlink?.();
|
|
52
|
+
this.unlink = undefined;
|
|
53
|
+
}
|
|
54
|
+
error() {
|
|
55
|
+
const seconds = Math.round(this.ms / 1000);
|
|
56
|
+
return Object.assign(new Error(`No response from the server for ${seconds} s (the connection may have dropped)`), { code: 'ETIMEDOUT' });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The indexes of the messages whose pictures are really sent. Only the newest pictures go out in
|
|
61
|
+
* full; older ones become a `[Attached Image: path]` note, which keeps a long chat cheap (the
|
|
62
|
+
* model can read the file again if it needs to). Pictures that parallel tool calls returned in
|
|
63
|
+
* one step belong together, so they are all kept.
|
|
64
|
+
*/
|
|
65
|
+
export function imagesToSend(messages) {
|
|
66
|
+
const keep = new Set();
|
|
67
|
+
let last = -1;
|
|
68
|
+
messages.forEach((m, i) => {
|
|
69
|
+
if (m.images && m.images.length > 0)
|
|
70
|
+
last = i;
|
|
71
|
+
});
|
|
72
|
+
if (last === -1)
|
|
73
|
+
return keep;
|
|
74
|
+
keep.add(last);
|
|
75
|
+
if (messages[last].role === 'tool') {
|
|
76
|
+
for (let j = last - 1; j >= 0 && messages[j].role === 'tool'; j--) {
|
|
77
|
+
if (messages[j].images?.length)
|
|
78
|
+
keep.add(j);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return keep;
|
|
82
|
+
}
|
|
2
83
|
/**
|
|
3
84
|
* Parses the JSON arguments of a tool call. Invalid JSON is reported instead of being
|
|
4
85
|
* silently replaced by `{}` (a truncated write_file would otherwise look like an empty call).
|
|
@@ -80,6 +161,8 @@ export function openAiUsage(raw, ctx, fallback) {
|
|
|
80
161
|
if (priced.cost !== undefined) {
|
|
81
162
|
usage.cost = priced.cost;
|
|
82
163
|
usage.costSource = guessed && priced.costSource !== 'free' ? 'estimated' : priced.costSource;
|
|
164
|
+
if (priced.parts)
|
|
165
|
+
usage.costParts = priced.parts;
|
|
83
166
|
}
|
|
84
167
|
return usage;
|
|
85
168
|
}
|
package/dist/tools/fs.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from 'fs/promises';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { globalSnapshotManager } from '../core/snapshot.js';
|
|
4
|
+
import { IMAGE_WARN_BYTES, imageMimeForPath, sniffImageMime } from '../core/images.js';
|
|
5
|
+
import { modelAcceptsImages } from '../core/vision.js';
|
|
4
6
|
// A single read result lands in the model context, so it must stay bounded.
|
|
5
7
|
export const MAX_READ_CHARS = 60000;
|
|
6
8
|
const MAX_READ_FILE_BYTES = 10 * 1024 * 1024;
|
|
@@ -13,12 +15,36 @@ function toLineNumber(value, name) {
|
|
|
13
15
|
}
|
|
14
16
|
return n;
|
|
15
17
|
}
|
|
18
|
+
/** Hands an image file to the model. The text returned here is what the tool result says. */
|
|
19
|
+
async function readImage(file, size, model, context) {
|
|
20
|
+
const name = path.basename(file);
|
|
21
|
+
if (model && modelAcceptsImages(model) === false) {
|
|
22
|
+
return `[Image ${file}: the current model (${model}) cannot read images. Tell the user to switch to a model with vision (/model).]`;
|
|
23
|
+
}
|
|
24
|
+
if (!context?.attachImage) {
|
|
25
|
+
return `[Image ${file} (${size} bytes): images cannot be shown in this context]`;
|
|
26
|
+
}
|
|
27
|
+
// An image the API refuses stays in the history and breaks every following request,
|
|
28
|
+
// so the limit is the strictest one in use (Anthropic: 5 MB).
|
|
29
|
+
if (size > IMAGE_WARN_BYTES) {
|
|
30
|
+
throw new Error(`Image ${name} is ${(size / 1048576).toFixed(1)} MB: too large to show (limit ${IMAGE_WARN_BYTES / 1048576} MB). ` +
|
|
31
|
+
'Make a smaller copy first with bash (for example ImageMagick: convert in.png -resize 1600x out.png) and read that.');
|
|
32
|
+
}
|
|
33
|
+
const buffer = await fs.readFile(file);
|
|
34
|
+
const mime = sniffImageMime(buffer);
|
|
35
|
+
if (!mime) {
|
|
36
|
+
throw new Error(`${name} is not a valid PNG, JPEG, GIF or WebP image (the file contents do not match its extension)`);
|
|
37
|
+
}
|
|
38
|
+
context.attachImage({ path: file, dataUrl: `data:${mime};base64,${buffer.toString('base64')}` });
|
|
39
|
+
return `[Image ${file} (${mime}, ${(size / 1024).toFixed(0)} KB) is attached to this result: look at it]`;
|
|
40
|
+
}
|
|
16
41
|
export const readFileTool = {
|
|
17
42
|
name: 'read_file',
|
|
18
43
|
definition: {
|
|
19
44
|
name: 'read_file',
|
|
20
45
|
description: `Read contents of a file. Large files are returned in parts (max ~${MAX_READ_CHARS} characters per call): ` +
|
|
21
|
-
'use startLine/endLine (1-based, inclusive) to read a specific range or to continue reading.'
|
|
46
|
+
'use startLine/endLine (1-based, inclusive) to read a specific range or to continue reading. ' +
|
|
47
|
+
'Image files (png, jpg, webp, gif) are shown to you as a picture, so you can look at screenshots, photos and design mockups.',
|
|
22
48
|
parameters: {
|
|
23
49
|
type: 'object',
|
|
24
50
|
properties: {
|
|
@@ -38,12 +64,16 @@ export const readFileTool = {
|
|
|
38
64
|
throw new Error('endLine must be greater than or equal to startLine');
|
|
39
65
|
}
|
|
40
66
|
},
|
|
41
|
-
async execute(args) {
|
|
67
|
+
async execute(args, config, context) {
|
|
42
68
|
try {
|
|
43
69
|
const stat = await fs.stat(args.path);
|
|
44
70
|
if (stat.isDirectory()) {
|
|
45
71
|
throw new Error('Path is a directory, use list_dir instead');
|
|
46
72
|
}
|
|
73
|
+
// A picture is not text: it is shown to the model together with this result.
|
|
74
|
+
if (imageMimeForPath(args.path)) {
|
|
75
|
+
return await readImage(args.path, stat.size, config?.model, context);
|
|
76
|
+
}
|
|
47
77
|
if (stat.size > MAX_READ_FILE_BYTES) {
|
|
48
78
|
throw new Error(`File is too large (${(stat.size / 1024 / 1024).toFixed(1)} MB). Use the search tool, or bash (e.g. head/tail/sed -n) to look at part of it`);
|
|
49
79
|
}
|
package/package.json
CHANGED