termux-dev 1.8.1 → 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 +17 -4
- package/assets/banner.svg +1 -1
- package/assets/preview.svg +1 -1
- package/dist/cli/background-ui.js +116 -0
- package/dist/cli/files.js +2 -12
- package/dist/cli/finder.js +4 -0
- package/dist/cli/headless.js +6 -3
- package/dist/cli/index.js +259 -74
- 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 +93 -8
- 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 +189 -32
- package/dist/cli/updater.js +10 -8
- package/dist/cli/usage-panel.js +17 -4
- package/dist/cli/width.js +18 -0
- package/dist/core/background.js +390 -0
- package/dist/core/config-merge.js +67 -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 +50 -2
- 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 +6 -1
- package/dist/prompts/builder.js +2 -1
- 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/background.js +118 -0
- package/dist/tools/bash.js +23 -1
- package/dist/tools/fs.js +32 -2
- package/dist/tools/index.js +2 -1
- package/package.json +1 -1
package/dist/core/loop.js
CHANGED
|
@@ -32,6 +32,17 @@ export function getActionDescription(toolName, args) {
|
|
|
32
32
|
if (t === 'bash' || t === 'execute_command' || t === 'run_command' || t === 'exec') {
|
|
33
33
|
return `→ Run: ${args.command || args.cmd || ''}`;
|
|
34
34
|
}
|
|
35
|
+
if (t === 'background') {
|
|
36
|
+
const id = args.id ? ` ${args.id}` : '';
|
|
37
|
+
switch (args.action) {
|
|
38
|
+
case 'start': return `→ Background: ${args.label ? `${args.label} · ` : ''}${args.command || ''}`;
|
|
39
|
+
case 'list': return `→ Background: list tasks`;
|
|
40
|
+
case 'output': return `→ Background: output${id}`;
|
|
41
|
+
case 'wait': return `→ Background: wait${id}${args.pattern ? ` for /${args.pattern}/` : ''}`;
|
|
42
|
+
case 'stop': return `→ Background: stop${id}`;
|
|
43
|
+
default: return `→ Background`;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
35
46
|
if (t === 'todo_list' || t === 'update_todos') {
|
|
36
47
|
return `→ Update Plan & Tasks`;
|
|
37
48
|
}
|
|
@@ -50,6 +61,15 @@ export function getActionDescription(toolName, args) {
|
|
|
50
61
|
return `→ Executing ${toolName}...`;
|
|
51
62
|
}
|
|
52
63
|
const CONTEXT_LENGTH_ERROR = /context[_ ]length|maximum context|context window|too many tokens|prompt is too long|reduce the length|input is too long|exceeds? the (?:model|maximum|max)/i;
|
|
64
|
+
/**
|
|
65
|
+
* A request that failed in a way that usually still costs money: the connection broke or timed out
|
|
66
|
+
* after the request had gone out (no HTTP status), or a gateway gave up waiting for a slow model
|
|
67
|
+
* (504, 524) that went on working. Rejections (400, 401, 429, ...) are not billed.
|
|
68
|
+
*/
|
|
69
|
+
function probablyBilled(err) {
|
|
70
|
+
const status = err?.status ?? err?.statusCode;
|
|
71
|
+
return status === undefined || status === 0 || status === 504 || status === 524;
|
|
72
|
+
}
|
|
53
73
|
function isContextLengthError(err) {
|
|
54
74
|
const status = err?.status;
|
|
55
75
|
if (status !== undefined && status !== 400 && status !== 413 && status !== 422)
|
|
@@ -83,6 +103,12 @@ export class Agent {
|
|
|
83
103
|
this.history.addMessage(message);
|
|
84
104
|
yield { type: 'user_message', content: message.content };
|
|
85
105
|
}
|
|
106
|
+
// A background task ended or printed what was asked for: the model learns it before it decides.
|
|
107
|
+
const news = hooks?.takeNotes?.();
|
|
108
|
+
if (news) {
|
|
109
|
+
this.history.addMessage({ role: 'user', content: news });
|
|
110
|
+
yield { type: 'background_note', content: news };
|
|
111
|
+
}
|
|
86
112
|
const toolDefinitions = Array.from(this.tools.values()).map((t) => t.definition);
|
|
87
113
|
// Tool schemas are sent with every request and count against the context window.
|
|
88
114
|
const schemaTokens = toolSchemaTokens(toolDefinitions);
|
|
@@ -103,21 +129,32 @@ export class Agent {
|
|
|
103
129
|
let attempt = 0;
|
|
104
130
|
let contextRetries = 0;
|
|
105
131
|
const maxRetries = 4;
|
|
132
|
+
// A request that goes out and gets no answer (cancelled, connection lost, gateway timeout) is
|
|
133
|
+
// usually billed all the same, and a retry sends the whole conversation again. Such requests
|
|
134
|
+
// are counted as an estimate instead of being left out of the cost.
|
|
135
|
+
let receivedChars = 0;
|
|
136
|
+
const lostUsage = () => this.provider.lostRequestUsage?.(this.history.getTotalTokens() + schemaTokens, Math.ceil(receivedChars * 0.33));
|
|
106
137
|
while (true) {
|
|
107
138
|
if (signal?.aborted)
|
|
108
139
|
return;
|
|
140
|
+
receivedChars = 0;
|
|
109
141
|
try {
|
|
110
142
|
if (this.provider.chatStream) {
|
|
111
143
|
let receivedAnyChunk = false;
|
|
112
144
|
for await (const chunk of this.provider.chatStream(request)) {
|
|
113
145
|
if (signal?.aborted) {
|
|
146
|
+
const lost = lostUsage();
|
|
147
|
+
if (lost)
|
|
148
|
+
yield { type: 'usage', usage: lost };
|
|
114
149
|
return;
|
|
115
150
|
}
|
|
116
151
|
receivedAnyChunk = true;
|
|
117
152
|
if (chunk.type === 'reasoning_delta') {
|
|
153
|
+
receivedChars += chunk.delta.length;
|
|
118
154
|
yield { type: 'reasoning_delta', delta: chunk.delta };
|
|
119
155
|
}
|
|
120
156
|
else if (chunk.type === 'content_delta') {
|
|
157
|
+
receivedChars += chunk.delta.length;
|
|
121
158
|
yield { type: 'text_delta', delta: chunk.delta };
|
|
122
159
|
}
|
|
123
160
|
else if (chunk.type === 'tool_generating') {
|
|
@@ -142,6 +179,9 @@ export class Agent {
|
|
|
142
179
|
}
|
|
143
180
|
catch (err) {
|
|
144
181
|
if (signal?.aborted || err.name === 'AbortError' || err.message?.includes('aborted')) {
|
|
182
|
+
const lost = lostUsage();
|
|
183
|
+
if (lost)
|
|
184
|
+
yield { type: 'usage', usage: lost };
|
|
145
185
|
return;
|
|
146
186
|
}
|
|
147
187
|
// The provider says the prompt is too long (our estimate was too optimistic):
|
|
@@ -155,6 +195,11 @@ export class Agent {
|
|
|
155
195
|
continue;
|
|
156
196
|
}
|
|
157
197
|
const isNetwork = isNetworkOrRetryableError(err);
|
|
198
|
+
if (isNetwork && probablyBilled(err)) {
|
|
199
|
+
const lost = lostUsage();
|
|
200
|
+
if (lost)
|
|
201
|
+
yield { type: 'usage', usage: lost };
|
|
202
|
+
}
|
|
158
203
|
if (isNetwork && attempt < maxRetries) {
|
|
159
204
|
attempt++;
|
|
160
205
|
const delayMs = Math.min(1500 * Math.pow(1.5, attempt - 1), 8000);
|
|
@@ -207,7 +252,7 @@ export class Agent {
|
|
|
207
252
|
}
|
|
208
253
|
if (!response.toolCalls || response.toolCalls.length === 0) {
|
|
209
254
|
// The user wrote something while the model was finishing: answer it instead of stopping.
|
|
210
|
-
if (hooks?.hasPending?.()) {
|
|
255
|
+
if (hooks?.hasPending?.() || hooks?.hasNotes?.()) {
|
|
211
256
|
iterations++;
|
|
212
257
|
continue;
|
|
213
258
|
}
|
|
@@ -223,6 +268,8 @@ export class Agent {
|
|
|
223
268
|
if (signal?.aborted)
|
|
224
269
|
return;
|
|
225
270
|
let result = '';
|
|
271
|
+
// Pictures a tool wants the model to see (read_file on an image) travel with its result.
|
|
272
|
+
const pictures = [];
|
|
226
273
|
const tool = this.tools.get(call.name);
|
|
227
274
|
if (!tool) {
|
|
228
275
|
result = `Error: Tool '${call.name}' not found.`;
|
|
@@ -246,7 +293,7 @@ export class Agent {
|
|
|
246
293
|
if (signal?.aborted)
|
|
247
294
|
return;
|
|
248
295
|
result = allowed
|
|
249
|
-
? await tool.execute(call.arguments, this.config, { signal })
|
|
296
|
+
? await tool.execute(call.arguments, this.config, { signal, attachImage: (image) => pictures.push(image) })
|
|
250
297
|
: 'Action denied by user.';
|
|
251
298
|
if (allowed) {
|
|
252
299
|
const post = await runHooks(this.config.hooks, 'PostToolUse', tool.name, call.arguments, result);
|
|
@@ -269,6 +316,7 @@ export class Agent {
|
|
|
269
316
|
content: result,
|
|
270
317
|
tool_call_id: call.id,
|
|
271
318
|
name: call.name,
|
|
319
|
+
...(pictures.length > 0 ? { images: pictures } : {}),
|
|
272
320
|
});
|
|
273
321
|
executedToolCallIds.add(call.id);
|
|
274
322
|
yield { type: 'tool_end', id: call.id, name: call.name, result };
|
package/dist/core/prices.js
CHANGED
|
@@ -3,7 +3,7 @@ import path from 'path';
|
|
|
3
3
|
import { fileURLToPath } from 'url';
|
|
4
4
|
import { NATIVE_PROVIDERS, devxDir, normalizeModelKey, readJsonFile } from './catalog-util.js';
|
|
5
5
|
import { ensureSecureDir } from './secure-file.js';
|
|
6
|
-
import {
|
|
6
|
+
import { costParts, isFreeModel, reportedCost, scaleParts, } from './cost.js';
|
|
7
7
|
/**
|
|
8
8
|
* Prices of models: where they come from and how a request is priced.
|
|
9
9
|
*
|
|
@@ -240,10 +240,14 @@ export function priceUsage(ctx, counts, rawUsage) {
|
|
|
240
240
|
const tokens = (counts.promptTokens || 0) + (counts.completionTokens || 0);
|
|
241
241
|
if (reported !== undefined) {
|
|
242
242
|
const suspicious = reported === 0 && tokens > 0 && !!info && info.source !== 'free' && info.rates.input + info.rates.output > 0;
|
|
243
|
-
if (!suspicious)
|
|
244
|
-
|
|
243
|
+
if (!suspicious) {
|
|
244
|
+
// The provider gave only the total: split it in the proportions of the known rates.
|
|
245
|
+
const parts = info ? scaleParts(costParts(info.rates, counts), reported) : undefined;
|
|
246
|
+
return { cost: reported, costSource: reported === 0 && info?.source === 'free' ? 'free' : 'reported', parts };
|
|
247
|
+
}
|
|
245
248
|
}
|
|
246
249
|
if (!info)
|
|
247
250
|
return {};
|
|
248
|
-
|
|
251
|
+
const parts = costParts(info.rates, counts);
|
|
252
|
+
return { cost: parts.input + parts.cacheRead + parts.cacheWrite + parts.output, costSource: info.source, parts };
|
|
249
253
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { ensureSecureDir } from './secure-file.js';
|
|
5
|
+
/**
|
|
6
|
+
* Pictures of a saved chat live in files next to it, not inside its JSON. A picture is a data URL
|
|
7
|
+
* of up to several megabytes; written into the chat file it made every save (after every turn)
|
|
8
|
+
* rewrite all of them, and the list of chats (/resume) had to read and parse every one.
|
|
9
|
+
*
|
|
10
|
+
* On disk a message carries `{ path, file }`; the bytes are in `<chat id>.images/<file>`, named by
|
|
11
|
+
* their hash, so the same picture is stored once however often it is saved.
|
|
12
|
+
*/
|
|
13
|
+
const EXT_BY_MIME = {
|
|
14
|
+
'image/png': 'png',
|
|
15
|
+
'image/jpeg': 'jpg',
|
|
16
|
+
'image/webp': 'webp',
|
|
17
|
+
'image/gif': 'gif',
|
|
18
|
+
};
|
|
19
|
+
const MIME_BY_EXT = Object.fromEntries(Object.entries(EXT_BY_MIME).map(([mime, ext]) => [ext, mime]));
|
|
20
|
+
export const imagesDirFor = (sessionsDir, sessionId) => path.join(sessionsDir, `${sessionId}.images`);
|
|
21
|
+
const DATA_URL = /^data:([^;,]+);base64,(.*)$/s;
|
|
22
|
+
/**
|
|
23
|
+
* The messages as they are written to disk: pictures replaced by the name of their file, the
|
|
24
|
+
* files written. Returns the names in use, so the others can be removed. The messages passed in
|
|
25
|
+
* are not changed.
|
|
26
|
+
*/
|
|
27
|
+
export async function externalizeImages(messages, dir, index = new Map()) {
|
|
28
|
+
const used = new Set();
|
|
29
|
+
let dirReady = false;
|
|
30
|
+
const store = async (image) => {
|
|
31
|
+
const match = DATA_URL.exec(image.dataUrl ?? '');
|
|
32
|
+
const ext = match ? EXT_BY_MIME[match[1]] : undefined;
|
|
33
|
+
if (!match || !ext)
|
|
34
|
+
return image; // not something we can store: keep it as it is
|
|
35
|
+
let file = index.get(image.dataUrl);
|
|
36
|
+
if (!file) {
|
|
37
|
+
file = `${crypto.createHash('sha256').update(image.dataUrl).digest('hex').slice(0, 32)}.${ext}`;
|
|
38
|
+
index.set(image.dataUrl, file);
|
|
39
|
+
}
|
|
40
|
+
used.add(file);
|
|
41
|
+
const target = path.join(dir, file);
|
|
42
|
+
try {
|
|
43
|
+
await fs.access(target);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
if (!dirReady) {
|
|
47
|
+
await ensureSecureDir(dir);
|
|
48
|
+
dirReady = true;
|
|
49
|
+
}
|
|
50
|
+
// Written aside and renamed: a crash must not leave half a picture under the real name.
|
|
51
|
+
const part = `${target}.part`;
|
|
52
|
+
await fs.writeFile(part, Buffer.from(match[2], 'base64'), { mode: 0o600 });
|
|
53
|
+
await fs.rename(part, target);
|
|
54
|
+
}
|
|
55
|
+
return { path: image.path, dataUrl: '', file };
|
|
56
|
+
};
|
|
57
|
+
const out = [];
|
|
58
|
+
for (const message of messages) {
|
|
59
|
+
if (!message.images || message.images.length === 0) {
|
|
60
|
+
out.push(message);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const images = [];
|
|
64
|
+
for (const image of message.images)
|
|
65
|
+
images.push(await store(image));
|
|
66
|
+
out.push({ ...message, images });
|
|
67
|
+
}
|
|
68
|
+
return { messages: out, used };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The messages of a loaded chat with their pictures read back from the files. A picture whose
|
|
72
|
+
* file is gone is dropped (the text of the message still says what it was); a chat saved by an
|
|
73
|
+
* older devx, with the data inside, is left as it is.
|
|
74
|
+
*/
|
|
75
|
+
export async function internalizeImages(messages, dir) {
|
|
76
|
+
const out = [];
|
|
77
|
+
for (const message of messages) {
|
|
78
|
+
if (!message.images || message.images.length === 0) {
|
|
79
|
+
out.push(message);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const images = [];
|
|
83
|
+
for (const image of message.images) {
|
|
84
|
+
const file = image.file;
|
|
85
|
+
if (!file) {
|
|
86
|
+
if (image.dataUrl)
|
|
87
|
+
images.push(image);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const mime = MIME_BY_EXT[path.extname(file).slice(1).toLowerCase()];
|
|
91
|
+
// A name from a file on disk must not lead out of the folder.
|
|
92
|
+
if (!mime || path.basename(file) !== file)
|
|
93
|
+
continue;
|
|
94
|
+
try {
|
|
95
|
+
const data = await fs.readFile(path.join(dir, file));
|
|
96
|
+
images.push({ path: image.path, dataUrl: `data:${mime};base64,${data.toString('base64')}` });
|
|
97
|
+
}
|
|
98
|
+
catch { }
|
|
99
|
+
}
|
|
100
|
+
const { images: _dropped, ...rest } = message;
|
|
101
|
+
out.push(images.length > 0 ? { ...rest, images } : rest);
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
/** Removes the picture files that no message uses any more (after a /rewind, for example). */
|
|
106
|
+
export async function pruneImages(dir, used) {
|
|
107
|
+
let names;
|
|
108
|
+
try {
|
|
109
|
+
names = await fs.readdir(dir);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
await Promise.all(names.filter((n) => !used.has(n)).map((n) => fs.rm(path.join(dir, n), { force: true }).catch(() => { })));
|
|
115
|
+
}
|
package/dist/core/session.js
CHANGED
|
@@ -3,9 +3,12 @@ import fsSync from 'fs';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import os from 'os';
|
|
5
5
|
import { ensureSecureDir, writeSecureFile } from './secure-file.js';
|
|
6
|
+
import { externalizeImages, imagesDirFor, internalizeImages, pruneImages } from './session-media.js';
|
|
6
7
|
const SESSIONS_DIR = path.join(os.homedir(), '.devx', 'sessions');
|
|
7
8
|
export class SessionManager {
|
|
8
9
|
currentSession;
|
|
10
|
+
/** The files of the pictures already seen (see session-media.ts). */
|
|
11
|
+
media = new Map();
|
|
9
12
|
constructor(model, planMode) {
|
|
10
13
|
this.currentSession = this.createEmptySession(model, planMode);
|
|
11
14
|
}
|
|
@@ -36,6 +39,7 @@ export class SessionManager {
|
|
|
36
39
|
}
|
|
37
40
|
startNewSession(model, planMode) {
|
|
38
41
|
this.currentSession = this.createEmptySession(model, planMode);
|
|
42
|
+
this.media = new Map();
|
|
39
43
|
return this.currentSession;
|
|
40
44
|
}
|
|
41
45
|
async save(messages, totalCost, model, planMode, costEstimated) {
|
|
@@ -61,8 +65,12 @@ export class SessionManager {
|
|
|
61
65
|
this.currentSession.title = title || 'Session';
|
|
62
66
|
}
|
|
63
67
|
}
|
|
68
|
+
// Pictures go into their own files; the chat file keeps only their names.
|
|
69
|
+
const imagesDir = imagesDirFor(SESSIONS_DIR, this.currentSession.id);
|
|
70
|
+
const stored = await externalizeImages(messages, imagesDir, this.media);
|
|
64
71
|
const filePath = path.join(SESSIONS_DIR, `${this.currentSession.id}.json`);
|
|
65
|
-
await writeSecureFile(filePath, JSON.stringify(this.currentSession, null, 2));
|
|
72
|
+
await writeSecureFile(filePath, JSON.stringify({ ...this.currentSession, messages: stored.messages }, null, 2));
|
|
73
|
+
await pruneImages(imagesDir, stored.used);
|
|
66
74
|
}
|
|
67
75
|
static async listSessions() {
|
|
68
76
|
if (!fsSync.existsSync(SESSIONS_DIR)) {
|
|
@@ -95,7 +103,9 @@ export class SessionManager {
|
|
|
95
103
|
return null;
|
|
96
104
|
try {
|
|
97
105
|
const content = await fs.readFile(filePath, 'utf8');
|
|
98
|
-
|
|
106
|
+
const session = JSON.parse(content);
|
|
107
|
+
session.messages = await internalizeImages(session.messages ?? [], imagesDirFor(SESSIONS_DIR, sessionId));
|
|
108
|
+
return session;
|
|
99
109
|
}
|
|
100
110
|
catch {
|
|
101
111
|
return null;
|
|
@@ -104,6 +114,7 @@ export class SessionManager {
|
|
|
104
114
|
static async deleteSession(sessionId) {
|
|
105
115
|
const filePath = path.join(SESSIONS_DIR, `${sessionId}.json`);
|
|
106
116
|
try {
|
|
117
|
+
await fs.rm(imagesDirFor(SESSIONS_DIR, sessionId), { recursive: true, force: true }).catch(() => { });
|
|
107
118
|
if (fsSync.existsSync(filePath)) {
|
|
108
119
|
await fs.unlink(filePath);
|
|
109
120
|
return true;
|
|
@@ -128,6 +139,10 @@ export class SessionManager {
|
|
|
128
139
|
}
|
|
129
140
|
catch { }
|
|
130
141
|
}
|
|
142
|
+
// The pictures of the chats, and those of chats whose file is already gone.
|
|
143
|
+
for (const name of files.filter(f => f.endsWith('.images'))) {
|
|
144
|
+
await fs.rm(path.join(SESSIONS_DIR, name), { recursive: true, force: true }).catch(() => { });
|
|
145
|
+
}
|
|
131
146
|
return count;
|
|
132
147
|
}
|
|
133
148
|
catch {
|
package/dist/core/title.js
CHANGED
|
@@ -31,9 +31,15 @@ export function cleanTitle(raw) {
|
|
|
31
31
|
export function provisionalTitle(firstUserMessage) {
|
|
32
32
|
return cleanTitle(firstUserMessage.replace(/\s+/g, ' ').slice(0, 80));
|
|
33
33
|
}
|
|
34
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Asks the model for the name. Never throws: on a failure or a timeout the answer is ''. When the
|
|
36
|
+
* time is up the request is cut off, so a slow thinking model does not go on (and bill) for a
|
|
37
|
+
* title nobody waits for.
|
|
38
|
+
*/
|
|
35
39
|
export async function generateChatTitle(provider, firstUser, firstReply, timeoutMs = 20000) {
|
|
36
40
|
const sample = `User: ${firstUser.slice(0, 600)}\n\nAssistant: ${firstReply.slice(0, 600)}`;
|
|
41
|
+
const controller = new AbortController();
|
|
42
|
+
let timer;
|
|
37
43
|
try {
|
|
38
44
|
const answer = await Promise.race([
|
|
39
45
|
provider.chat({
|
|
@@ -41,14 +47,25 @@ export async function generateChatTitle(provider, firstUser, firstReply, timeout
|
|
|
41
47
|
{ role: 'system', content: TITLE_SYSTEM_PROMPT },
|
|
42
48
|
{ role: 'user', content: sample },
|
|
43
49
|
],
|
|
50
|
+
signal: controller.signal,
|
|
51
|
+
}),
|
|
52
|
+
// Also for a provider that ignores the signal: the title is given up on when the time is up.
|
|
53
|
+
new Promise((_, reject) => {
|
|
54
|
+
timer = setTimeout(() => {
|
|
55
|
+
controller.abort();
|
|
56
|
+
reject(new Error('timeout'));
|
|
57
|
+
}, timeoutMs);
|
|
58
|
+
timer.unref?.();
|
|
44
59
|
}),
|
|
45
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeoutMs).unref?.()),
|
|
46
60
|
]);
|
|
47
61
|
return cleanTitle(answer.content);
|
|
48
62
|
}
|
|
49
63
|
catch {
|
|
50
64
|
return '';
|
|
51
65
|
}
|
|
66
|
+
finally {
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
}
|
|
52
69
|
}
|
|
53
70
|
// ───────────────────────── terminal window title ─────────────────────────
|
|
54
71
|
/** The escape sequence that sets the title of the terminal window (OSC 0): `◈ Short title`. */
|
package/dist/core/types.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const DEVX_VERSION = '1.
|
|
1
|
+
export const DEVX_VERSION = '1.9.0';
|
package/dist/core/usage.js
CHANGED
|
@@ -14,6 +14,11 @@ export class UsageTracker {
|
|
|
14
14
|
ledger = new CostLedger();
|
|
15
15
|
cacheReadTokens = 0;
|
|
16
16
|
cacheWriteTokens = 0;
|
|
17
|
+
uncachedTokens = 0;
|
|
18
|
+
parts = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
|
|
19
|
+
/** Priced requests that came without a split. */
|
|
20
|
+
unsplit = 0;
|
|
21
|
+
restored = 0;
|
|
17
22
|
lastPromptTokens = 0;
|
|
18
23
|
dataSaverLimitMB;
|
|
19
24
|
warnedThreshold = false;
|
|
@@ -37,9 +42,22 @@ export class UsageTracker {
|
|
|
37
42
|
this.ledger.reset();
|
|
38
43
|
this.cacheReadTokens = 0;
|
|
39
44
|
this.cacheWriteTokens = 0;
|
|
45
|
+
this.uncachedTokens = 0;
|
|
46
|
+
this.parts = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
|
|
47
|
+
this.unsplit = 0;
|
|
48
|
+
this.restored = 0;
|
|
40
49
|
this.lastPromptTokens = 0;
|
|
41
50
|
this.warnedThreshold = false;
|
|
42
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* A saved chat was resumed: what it cost so far is the starting point of the total, so that
|
|
54
|
+
* /usage and the prompt frame show the same number. Counting starts from zero before this.
|
|
55
|
+
*/
|
|
56
|
+
restoreCost(total, estimated = false) {
|
|
57
|
+
this.reset();
|
|
58
|
+
this.ledger.restore(total, estimated);
|
|
59
|
+
this.restored = Number.isFinite(total) && total > 0 ? total : 0;
|
|
60
|
+
}
|
|
43
61
|
recordRequest(payloadBytes) {
|
|
44
62
|
this.bytesSent += payloadBytes;
|
|
45
63
|
this.requestsCount += 1;
|
|
@@ -48,15 +66,29 @@ export class UsageTracker {
|
|
|
48
66
|
this.bytesReceived += chunkBytes;
|
|
49
67
|
}
|
|
50
68
|
/** Records the tokens of one request; an undefined cost means the price is not known. */
|
|
51
|
-
recordTokens(promptTokens, completionTokens, cost, costSource) {
|
|
69
|
+
recordTokens(promptTokens, completionTokens, cost, costSource, parts) {
|
|
52
70
|
this.promptTokens += promptTokens;
|
|
53
71
|
this.completionTokens += completionTokens;
|
|
54
72
|
this.ledger.add({ cost, costSource });
|
|
73
|
+
if (typeof cost === 'number' && Number.isFinite(cost)) {
|
|
74
|
+
if (parts) {
|
|
75
|
+
this.parts.input += parts.input;
|
|
76
|
+
this.parts.cacheRead += parts.cacheRead;
|
|
77
|
+
this.parts.cacheWrite += parts.cacheWrite;
|
|
78
|
+
this.parts.output += parts.output;
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
this.unsplit++;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
55
84
|
}
|
|
56
85
|
recordUsage(usage) {
|
|
57
|
-
this.recordTokens(usage.promptTokens, usage.completionTokens, usage.cost, usage.costSource);
|
|
58
|
-
|
|
59
|
-
|
|
86
|
+
this.recordTokens(usage.promptTokens, usage.completionTokens, usage.cost, usage.costSource, usage.costParts);
|
|
87
|
+
const read = Math.min(usage.promptTokens, usage.cacheReadTokens || 0);
|
|
88
|
+
const written = Math.min(usage.promptTokens - read, usage.cacheWriteTokens || 0);
|
|
89
|
+
this.cacheReadTokens += read;
|
|
90
|
+
this.cacheWriteTokens += written;
|
|
91
|
+
this.uncachedTokens += Math.max(0, usage.promptTokens - read - written);
|
|
60
92
|
if (!usage.tokensEstimated)
|
|
61
93
|
this.lastPromptTokens = usage.promptTokens;
|
|
62
94
|
}
|
|
@@ -72,6 +104,11 @@ export class UsageTracker {
|
|
|
72
104
|
totalCost: this.ledger.total,
|
|
73
105
|
costState: this.ledger.state(),
|
|
74
106
|
cacheReadTokens: this.cacheReadTokens,
|
|
107
|
+
cacheWriteTokens: this.cacheWriteTokens,
|
|
108
|
+
uncachedTokens: this.uncachedTokens,
|
|
109
|
+
// The split covers only what was counted since launch: with a restored total it would not add up.
|
|
110
|
+
costParts: this.unsplit === 0 && this.restored === 0 && this.ledger.priced > 0 ? { ...this.parts } : undefined,
|
|
111
|
+
restoredCost: this.restored > 0 ? this.restored : undefined,
|
|
75
112
|
lastPromptTokens: this.lastPromptTokens,
|
|
76
113
|
dataSaverLimitMB: this.dataSaverLimitMB
|
|
77
114
|
};
|
|
@@ -277,7 +277,7 @@ function hasComplexChainingOrRedirection(commandStr) {
|
|
|
277
277
|
commandStr.includes('\r'));
|
|
278
278
|
}
|
|
279
279
|
// ───────────────────────── guard ─────────────────────────
|
|
280
|
-
const SHELL_TOOLS = new Set(['bash', 'exec', 'run_command', 'execute_command']);
|
|
280
|
+
const SHELL_TOOLS = new Set(['bash', 'background', 'exec', 'run_command', 'execute_command']);
|
|
281
281
|
const READ_TOOLS = new Set(['read_file', 'view_file', 'read', 'list_dir', 'list_files', 'ls', 'search', 'grep_search', 'grep', 'diagnose_code']);
|
|
282
282
|
const WRITE_TOOLS = new Set(['write_file', 'edit_file', 'make_dir', 'mkdir', 'create_file', 'write_to_file', 'patch']);
|
|
283
283
|
function pathArg(args) {
|
|
@@ -328,6 +328,9 @@ export class CLIConsoleGuard {
|
|
|
328
328
|
const t = (toolName || '').toLowerCase();
|
|
329
329
|
const cmd = (args?.command || args?.cmd || '').trim();
|
|
330
330
|
const target = pathArg(args);
|
|
331
|
+
// Extra: the agent does anything it likes, nothing is asked.
|
|
332
|
+
if (this.options.extra && this.autoApprove)
|
|
333
|
+
return false;
|
|
331
334
|
// Critical safety net. These ask for confirmation even in YOLO mode.
|
|
332
335
|
if (SHELL_TOOLS.has(t) && (isDangerousBashCommand(cmd) || shellTouchesSecrets(cmd)))
|
|
333
336
|
return true;
|
|
@@ -409,6 +412,8 @@ export function buildPermissionRequest(toolName, args) {
|
|
|
409
412
|
notes.push('looks destructive or runs downloaded code');
|
|
410
413
|
else if (shellTouchesSecrets(cmd))
|
|
411
414
|
notes.push('reads a credentials / secrets file: its content would reach the model');
|
|
415
|
+
if (t === 'background')
|
|
416
|
+
notes.push('keeps running in the background until it ends or you stop it');
|
|
412
417
|
return { title: t === 'bash' ? 'bash' : toolName, danger, lines: cmd.split('\n'), notes };
|
|
413
418
|
}
|
|
414
419
|
if (t === 'mcp_manage') {
|
package/dist/prompts/builder.js
CHANGED
|
@@ -34,7 +34,8 @@ export async function buildSystemPrompt(planMode) {
|
|
|
34
34
|
prompt += `- Before using tools on a task, say in one short line what you are about to do. Do not narrate every step.\n`;
|
|
35
35
|
prompt += `- Do the whole task step by step. For a task with several steps, keep the checklist with 'todo_list'.\n`;
|
|
36
36
|
prompt += `- Read a file before you edit it. Change only what the task needs, with 'edit_file' for small changes. Do not rewrite a whole file or touch unrelated code.\n`;
|
|
37
|
-
prompt += `- 'bash' stops after 120 s and cuts long output: give long
|
|
37
|
+
prompt += `- 'bash' stops after 120 s and cuts long output: give a long job a timeout, and print only what you need (head, tail, grep).\n`;
|
|
38
|
+
prompt += `- Anything that keeps running (dev server, watcher, long test run or build) goes to the 'background' tool, not to 'bash': action 'start' returns at once and the work goes on while you continue. Set notify_on to a regex ('ready|listening|error|FAIL') to be told when such a line appears; you are always told when a task ends. Then do other work or finish your answer: do not poll. To wait for a line use action 'wait' with a pattern, to read output use 'output', to end it use 'stop'. The user sees the tasks under /tasks. Never start the same server twice: check 'list' first.\n`;
|
|
38
39
|
prompt += `- After you change code, run 'diagnose_code' and fix what it reports. Install missing dependencies with 'install_package'.\n`;
|
|
39
40
|
prompt += `- After you create or change a web page or frontend app, start the local preview with 'serve_preview' (action 'start').\n`;
|
|
40
41
|
if (MemoryManager.enabled)
|
|
@@ -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)) {
|