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
package/dist/core/locales/uk.js
CHANGED
|
@@ -20,25 +20,23 @@ export const uk = {
|
|
|
20
20
|
'conn.classicRestored': '✓ З’єднання відновлено! Продовжуємо генерацію...',
|
|
21
21
|
'stop.connection': 'підключення скасовано',
|
|
22
22
|
'stop.stopped': 'зупинено',
|
|
23
|
-
'stop.interrupted': 'перервано, надсилаю ваше повідомлення',
|
|
24
23
|
'stop.classicConnection': 'Підключення скасовано (Ctrl+C).',
|
|
25
24
|
'stop.classicStopped': 'Відповідь зупинено (Ctrl+C).',
|
|
26
|
-
'stop.classicInterrupted': 'Перервано: надсилаю ваше повідомлення.',
|
|
27
25
|
'status.waiting': 'Очікування моделі',
|
|
28
26
|
'status.thinking': 'Думає',
|
|
29
27
|
'status.writing': 'Пише',
|
|
30
28
|
'status.running': 'Виконується',
|
|
31
29
|
'status.reconnecting': 'Перепідключення',
|
|
32
|
-
'status.stop': '
|
|
30
|
+
'status.stop': 'esc — стоп',
|
|
33
31
|
'status.thinkingLine': 'роздуми',
|
|
34
32
|
'status.thoughtFor': 'думав {sec} с',
|
|
35
33
|
'composer.placeholder': 'Опишіть завдання або поставте питання…',
|
|
36
34
|
'composer.queued': 'у черзі',
|
|
37
35
|
'composer.queuedMany': 'у черзі: {n}',
|
|
38
|
-
'composer.queuedHint': '
|
|
39
|
-
'composer.hint1': 'enter — у чергу ·
|
|
36
|
+
'composer.queuedHint': '↓ редагувати',
|
|
37
|
+
'composer.hint1': 'enter — у чергу · esc — стоп',
|
|
40
38
|
'composer.hint2': 'enter — у чергу',
|
|
41
|
-
'composer.hint3': 'enter — у чергу ·
|
|
39
|
+
'composer.hint3': 'enter — у чергу · esc — стоп',
|
|
42
40
|
'composer.hint4': 'enter — у чергу',
|
|
43
41
|
'summary.toolOne': 'інструментів: {n}',
|
|
44
42
|
'summary.toolMany': 'інструментів: {n}',
|
|
@@ -54,6 +52,7 @@ export const uk = {
|
|
|
54
52
|
'settings.row.layout': 'Інтерфейс',
|
|
55
53
|
'settings.row.pureBlack': 'Чорний фон',
|
|
56
54
|
'settings.row.autoApprove': 'Автосхвалення (YOLO)',
|
|
55
|
+
'settings.row.extra': 'Extra (без питань)',
|
|
57
56
|
'settings.row.maxIterations': 'Макс. кроків',
|
|
58
57
|
'settings.row.memory': 'Пам’ять проєкту',
|
|
59
58
|
'settings.row.updates': 'Перевірка оновлень',
|
|
@@ -63,6 +62,7 @@ export const uk = {
|
|
|
63
62
|
'settings.short.layout': 'Інтерфейс',
|
|
64
63
|
'settings.short.pureBlack': 'Чорний фон',
|
|
65
64
|
'settings.short.autoApprove': 'YOLO',
|
|
65
|
+
'settings.short.extra': 'Extra',
|
|
66
66
|
'settings.short.maxIterations': 'Кроків',
|
|
67
67
|
'settings.short.memory': 'Пам’ять',
|
|
68
68
|
'settings.short.updates': 'Оновл.',
|
|
@@ -78,12 +78,15 @@ export const uk = {
|
|
|
78
78
|
'settings.hint.layout': 'Чистий: рамка біля вводу, картки й спокійні рядки інструментів. Класичний: колишній ASCII-вигляд.',
|
|
79
79
|
'settings.hint.pureBlack': 'Робить тло термінала глибоким чорним для OLED (#0a0a0c). Вимкнено — лишається ваше тло.',
|
|
80
80
|
'settings.hint.autoApprove': 'Пропускає звичайні запити дозволів. Небезпечні команди й файли з ключами все одно питають. Діє лише в цій сесії: під час наступного запуску devx вимкнено.',
|
|
81
|
+
'settings.hint.extra': 'Виконує БУДЬ-ЯКІ команди без питань: і руйнівні (rm -rf, sudo, git push --force), і завантажені скрипти, і читання ключів та секретів, і нові MCP-сервери. Жодної пісочниці немає. Діє лише в цій сесії.',
|
|
81
82
|
'settings.hint.maxIterations': 'Скільки кроків з інструментами (правки файлів, команди) агент може зробити на один запит.',
|
|
82
83
|
'settings.hint.memory': 'Завантажує правила з .devx/memory.md у кожну сесію.',
|
|
83
84
|
'settings.hint.updates': 'Під час запуску шукає нову версію devx на GitHub. Команда /update працює в будь-якому разі.',
|
|
84
85
|
'settings.hint.language': 'Мова інтерфейсу: повідомлення, налаштування й підказки. Код і назви інструментів лишаються англійськими.',
|
|
85
86
|
'settings.hint.about': 'Терміналний ШІ-агент для програмування від ApvCode. Ліцензія MIT. github.com/apvcode/Termux-Dev',
|
|
86
87
|
'settings.warnYolo': 'Увімкнено: агент виконує команди й правит файли без питань.',
|
|
88
|
+
'settings.warnExtra': 'Увага: нічого не питається. Лише де не шкода все.',
|
|
89
|
+
'settings.extraEnabled': 'Extra увімкнено: агент виконує будь-які команди без питань, зокрема небезпечні. Під час наступного запуску devx він знову вимкнений.',
|
|
87
90
|
'settings.keys.move': 'вибір',
|
|
88
91
|
'settings.keys.change': 'змінити',
|
|
89
92
|
'settings.keys.toggle': 'перемкнути',
|
|
@@ -219,9 +222,35 @@ export const uk = {
|
|
|
219
222
|
'onboard.language.note': 'Пізніше її можна змінити в /settings.',
|
|
220
223
|
'onboard.provider.title': 'провайдер',
|
|
221
224
|
'onboard.provider.placeholder': 'почни вводити назву',
|
|
222
|
-
'onboard.provider.note': 'Сервіс, що
|
|
225
|
+
'onboard.provider.note': 'Сервіс, що дає моделі. Немає в списку? Додайте свій через «Додати провайдера».',
|
|
223
226
|
'onboard.custom': 'Власна адреса',
|
|
224
227
|
'onboard.customDetail': 'Будь-яка адреса, сумісна з OpenAI',
|
|
228
|
+
'provider.add': 'Додати провайдера',
|
|
229
|
+
'provider.addDetail': 'Будь-яка адреса, сумісна з OpenAI або Anthropic',
|
|
230
|
+
'provider.customTag': 'власний',
|
|
231
|
+
'provider.new': 'новий провайдер',
|
|
232
|
+
'provider.name.label': 'Назва провайдера',
|
|
233
|
+
'provider.name.placeholder': 'Мій шлюз',
|
|
234
|
+
'provider.name.empty': 'Введіть назву.',
|
|
235
|
+
'provider.name.long': 'Назва задовга (не більше 40 символів).',
|
|
236
|
+
'provider.name.taken': 'Провайдер із такою назвою вже є.',
|
|
237
|
+
'provider.url.note': 'Адреса до /chat/completions або /messages, наприклад https://api.example.com/v1.',
|
|
238
|
+
'provider.url.invalid': 'Це не адреса. Приклад: https://api.example.com/v1',
|
|
239
|
+
'provider.url.scheme': 'Адреса має починатися з http:// або https://.',
|
|
240
|
+
'provider.key.none': 'Ключ не потрібен',
|
|
241
|
+
'provider.key.noneDetail': 'Сервер на цьому комп’ютері або в цій мережі',
|
|
242
|
+
'provider.key.enter': 'Ввести ключ',
|
|
243
|
+
'provider.menu.use': 'Використати цього провайдера',
|
|
244
|
+
'provider.menu.rename': 'Змінити назву',
|
|
245
|
+
'provider.menu.url': 'Змінити адресу',
|
|
246
|
+
'provider.menu.key': 'Змінити ключ',
|
|
247
|
+
'provider.menu.delete': 'Видалити',
|
|
248
|
+
'provider.menu.deleteDetail': 'Видалить провайдера і його ключ',
|
|
249
|
+
'provider.delete.question': 'Видалити провайдера {name} і його ключ?',
|
|
250
|
+
'provider.delete.active': 'Це поточний провайдер: він працюватиме, доки ви не оберете інший.',
|
|
251
|
+
'provider.saved': 'Провайдера {name} збережено',
|
|
252
|
+
'provider.updated': 'Провайдера {name} оновлено',
|
|
253
|
+
'provider.deleted': 'Провайдера {name} видалено',
|
|
225
254
|
'onboard.baseUrl.title': 'адреса',
|
|
226
255
|
'onboard.baseUrl.label': 'Базовий URL API',
|
|
227
256
|
'onboard.baseUrl.placeholder': 'https://api.example.com/v1',
|
package/dist/core/locales/zh.js
CHANGED
|
@@ -20,25 +20,23 @@ export const zh = {
|
|
|
20
20
|
'conn.classicRestored': '✓ 连接已恢复!继续生成...',
|
|
21
21
|
'stop.connection': '连接已取消',
|
|
22
22
|
'stop.stopped': '已停止',
|
|
23
|
-
'stop.interrupted': '已中断,正在发送你的消息',
|
|
24
23
|
'stop.classicConnection': '连接已取消 (Ctrl+C)。',
|
|
25
24
|
'stop.classicStopped': '生成已停止 (Ctrl+C)。',
|
|
26
|
-
'stop.classicInterrupted': '已中断:正在发送你的消息。',
|
|
27
25
|
'status.waiting': '等待模型',
|
|
28
26
|
'status.thinking': '思考中',
|
|
29
27
|
'status.writing': '撰写中',
|
|
30
28
|
'status.running': '运行中',
|
|
31
29
|
'status.reconnecting': '重连中',
|
|
32
|
-
'status.stop': '
|
|
30
|
+
'status.stop': 'esc 停止',
|
|
33
31
|
'status.thinkingLine': '思考',
|
|
34
32
|
'status.thoughtFor': '思考了 {sec} 秒',
|
|
35
33
|
'composer.placeholder': '描述任务或提出问题…',
|
|
36
34
|
'composer.queued': '已排队',
|
|
37
35
|
'composer.queuedMany': '{n} 条已排队',
|
|
38
|
-
'composer.queuedHint': '
|
|
39
|
-
'composer.hint1': 'enter 排队 ·
|
|
36
|
+
'composer.queuedHint': '↓ 编辑',
|
|
37
|
+
'composer.hint1': 'enter 排队 · esc 停止',
|
|
40
38
|
'composer.hint2': 'enter 排队',
|
|
41
|
-
'composer.hint3': 'enter 排队 ·
|
|
39
|
+
'composer.hint3': 'enter 排队 · esc 停止',
|
|
42
40
|
'composer.hint4': 'enter 排队',
|
|
43
41
|
'summary.toolOne': '{n} 个工具',
|
|
44
42
|
'summary.toolMany': '{n} 个工具',
|
|
@@ -54,6 +52,7 @@ export const zh = {
|
|
|
54
52
|
'settings.row.layout': '界面',
|
|
55
53
|
'settings.row.pureBlack': '黑色背景',
|
|
56
54
|
'settings.row.autoApprove': '自动批准 (YOLO)',
|
|
55
|
+
'settings.row.extra': 'Extra(不再询问)',
|
|
57
56
|
'settings.row.maxIterations': '最大步数',
|
|
58
57
|
'settings.row.memory': '项目记忆',
|
|
59
58
|
'settings.row.updates': '检查更新',
|
|
@@ -63,6 +62,7 @@ export const zh = {
|
|
|
63
62
|
'settings.short.layout': '界面',
|
|
64
63
|
'settings.short.pureBlack': '黑背景',
|
|
65
64
|
'settings.short.autoApprove': 'YOLO',
|
|
65
|
+
'settings.short.extra': 'Extra',
|
|
66
66
|
'settings.short.maxIterations': '步数',
|
|
67
67
|
'settings.short.memory': '记忆',
|
|
68
68
|
'settings.short.updates': '更新',
|
|
@@ -78,12 +78,15 @@ export const zh = {
|
|
|
78
78
|
'settings.hint.layout': '简洁:带边框的输入框、卡片和安静的工具行。经典:原来的 ASCII 布局。',
|
|
79
79
|
'settings.hint.pureBlack': '将终端背景设为深邃的 OLED 黑 (#0a0a0c)。关闭则保留你自己的背景。',
|
|
80
80
|
'settings.hint.autoApprove': '跳过常规的权限确认。危险命令和凭据文件仍会先询问。仅在本次会话有效:下次启动 devx 时会重新关闭。',
|
|
81
|
+
'settings.hint.extra': '不经询问执行任何命令:包括破坏性命令(rm -rf、sudo、git push --force)、下载的脚本、读取密钥和机密,以及新的 MCP 服务器。这里没有沙箱。仅在本次会话有效。',
|
|
81
82
|
'settings.hint.maxIterations': '智能体处理一次请求最多可执行多少个工具步骤(编辑文件、运行命令)。',
|
|
82
83
|
'settings.hint.memory': '每次会话都会加载 .devx/memory.md 中的规则。',
|
|
83
84
|
'settings.hint.updates': '启动时在 GitHub 上查找 devx 新版本。无论开关,/update 都可用。',
|
|
84
85
|
'settings.hint.language': '界面语言:消息、设置和提示。代码和工具名称保持英文。',
|
|
85
86
|
'settings.hint.about': 'ApvCode 出品的终端原生 AI 编程智能体。MIT 许可证。github.com/apvcode/Termux-Dev',
|
|
86
87
|
'settings.warnYolo': '已开启:智能体将不经询问直接运行命令并编辑文件。',
|
|
88
|
+
'settings.warnExtra': '警告:不再询问任何事,仅在可承受全部丢失的环境中使用。',
|
|
89
|
+
'settings.extraEnabled': 'Extra 已开启:智能体将不经询问执行任何命令,包括危险命令。下次启动 devx 时会再次关闭。',
|
|
87
90
|
'settings.keys.move': '移动',
|
|
88
91
|
'settings.keys.change': '更改',
|
|
89
92
|
'settings.keys.toggle': '切换',
|
|
@@ -219,9 +222,35 @@ export const zh = {
|
|
|
219
222
|
'onboard.language.note': '之后可以在 /settings 中更改。',
|
|
220
223
|
'onboard.provider.title': '服务商',
|
|
221
224
|
'onboard.provider.placeholder': '输入以查找服务商',
|
|
222
|
-
'onboard.provider.note': '
|
|
225
|
+
'onboard.provider.note': '为你提供模型的服务。不在列表里?用“添加提供商”添加你自己的。',
|
|
223
226
|
'onboard.custom': '自定义地址',
|
|
224
227
|
'onboard.customDetail': '任何兼容 OpenAI 的地址',
|
|
228
|
+
'provider.add': '添加提供商',
|
|
229
|
+
'provider.addDetail': '任何兼容 OpenAI 或 Anthropic 的地址',
|
|
230
|
+
'provider.customTag': '自定义',
|
|
231
|
+
'provider.new': '新提供商',
|
|
232
|
+
'provider.name.label': '提供商名称',
|
|
233
|
+
'provider.name.placeholder': '我的网关',
|
|
234
|
+
'provider.name.empty': '请输入名称。',
|
|
235
|
+
'provider.name.long': '名称太长(最多 40 个字符)。',
|
|
236
|
+
'provider.name.taken': '已有同名的提供商。',
|
|
237
|
+
'provider.url.note': '到 /chat/completions 或 /messages 之前的地址,例如 https://api.example.com/v1。',
|
|
238
|
+
'provider.url.invalid': '这不是有效的地址。示例:https://api.example.com/v1',
|
|
239
|
+
'provider.url.scheme': '地址必须以 http:// 或 https:// 开头。',
|
|
240
|
+
'provider.key.none': '不需要密钥',
|
|
241
|
+
'provider.key.noneDetail': '本机或本网络上的服务器',
|
|
242
|
+
'provider.key.enter': '输入密钥',
|
|
243
|
+
'provider.menu.use': '使用此提供商',
|
|
244
|
+
'provider.menu.rename': '修改名称',
|
|
245
|
+
'provider.menu.url': '修改地址',
|
|
246
|
+
'provider.menu.key': '修改密钥',
|
|
247
|
+
'provider.menu.delete': '删除',
|
|
248
|
+
'provider.menu.deleteDetail': '连同密钥一起移除',
|
|
249
|
+
'provider.delete.question': '删除提供商 {name} 及其密钥?',
|
|
250
|
+
'provider.delete.active': '这是正在使用的提供商:在你选择其他提供商之前它仍可继续使用。',
|
|
251
|
+
'provider.saved': '提供商 {name} 已保存',
|
|
252
|
+
'provider.updated': '提供商 {name} 已更新',
|
|
253
|
+
'provider.deleted': '提供商 {name} 已删除',
|
|
225
254
|
'onboard.baseUrl.title': '地址',
|
|
226
255
|
'onboard.baseUrl.label': 'API 的基础 URL',
|
|
227
256
|
'onboard.baseUrl.placeholder': 'https://api.example.com/v1',
|
package/dist/core/loop.js
CHANGED
|
@@ -61,6 +61,15 @@ export function getActionDescription(toolName, args) {
|
|
|
61
61
|
return `→ Executing ${toolName}...`;
|
|
62
62
|
}
|
|
63
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
|
+
}
|
|
64
73
|
function isContextLengthError(err) {
|
|
65
74
|
const status = err?.status;
|
|
66
75
|
if (status !== undefined && status !== 400 && status !== 413 && status !== 422)
|
|
@@ -120,21 +129,32 @@ export class Agent {
|
|
|
120
129
|
let attempt = 0;
|
|
121
130
|
let contextRetries = 0;
|
|
122
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));
|
|
123
137
|
while (true) {
|
|
124
138
|
if (signal?.aborted)
|
|
125
139
|
return;
|
|
140
|
+
receivedChars = 0;
|
|
126
141
|
try {
|
|
127
142
|
if (this.provider.chatStream) {
|
|
128
143
|
let receivedAnyChunk = false;
|
|
129
144
|
for await (const chunk of this.provider.chatStream(request)) {
|
|
130
145
|
if (signal?.aborted) {
|
|
146
|
+
const lost = lostUsage();
|
|
147
|
+
if (lost)
|
|
148
|
+
yield { type: 'usage', usage: lost };
|
|
131
149
|
return;
|
|
132
150
|
}
|
|
133
151
|
receivedAnyChunk = true;
|
|
134
152
|
if (chunk.type === 'reasoning_delta') {
|
|
153
|
+
receivedChars += chunk.delta.length;
|
|
135
154
|
yield { type: 'reasoning_delta', delta: chunk.delta };
|
|
136
155
|
}
|
|
137
156
|
else if (chunk.type === 'content_delta') {
|
|
157
|
+
receivedChars += chunk.delta.length;
|
|
138
158
|
yield { type: 'text_delta', delta: chunk.delta };
|
|
139
159
|
}
|
|
140
160
|
else if (chunk.type === 'tool_generating') {
|
|
@@ -159,6 +179,9 @@ export class Agent {
|
|
|
159
179
|
}
|
|
160
180
|
catch (err) {
|
|
161
181
|
if (signal?.aborted || err.name === 'AbortError' || err.message?.includes('aborted')) {
|
|
182
|
+
const lost = lostUsage();
|
|
183
|
+
if (lost)
|
|
184
|
+
yield { type: 'usage', usage: lost };
|
|
162
185
|
return;
|
|
163
186
|
}
|
|
164
187
|
// The provider says the prompt is too long (our estimate was too optimistic):
|
|
@@ -172,6 +195,11 @@ export class Agent {
|
|
|
172
195
|
continue;
|
|
173
196
|
}
|
|
174
197
|
const isNetwork = isNetworkOrRetryableError(err);
|
|
198
|
+
if (isNetwork && probablyBilled(err)) {
|
|
199
|
+
const lost = lostUsage();
|
|
200
|
+
if (lost)
|
|
201
|
+
yield { type: 'usage', usage: lost };
|
|
202
|
+
}
|
|
175
203
|
if (isNetwork && attempt < maxRetries) {
|
|
176
204
|
attempt++;
|
|
177
205
|
const delayMs = Math.min(1500 * Math.pow(1.5, attempt - 1), 8000);
|
|
@@ -240,6 +268,8 @@ export class Agent {
|
|
|
240
268
|
if (signal?.aborted)
|
|
241
269
|
return;
|
|
242
270
|
let result = '';
|
|
271
|
+
// Pictures a tool wants the model to see (read_file on an image) travel with its result.
|
|
272
|
+
const pictures = [];
|
|
243
273
|
const tool = this.tools.get(call.name);
|
|
244
274
|
if (!tool) {
|
|
245
275
|
result = `Error: Tool '${call.name}' not found.`;
|
|
@@ -263,7 +293,7 @@ export class Agent {
|
|
|
263
293
|
if (signal?.aborted)
|
|
264
294
|
return;
|
|
265
295
|
result = allowed
|
|
266
|
-
? await tool.execute(call.arguments, this.config, { signal })
|
|
296
|
+
? await tool.execute(call.arguments, this.config, { signal, attachImage: (image) => pictures.push(image) })
|
|
267
297
|
: 'Action denied by user.';
|
|
268
298
|
if (allowed) {
|
|
269
299
|
const post = await runHooks(this.config.hooks, 'PostToolUse', tool.name, call.arguments, result);
|
|
@@ -286,6 +316,7 @@ export class Agent {
|
|
|
286
316
|
content: result,
|
|
287
317
|
tool_call_id: call.id,
|
|
288
318
|
name: call.name,
|
|
319
|
+
...(pictures.length > 0 ? { images: pictures } : {}),
|
|
289
320
|
});
|
|
290
321
|
executedToolCallIds.add(call.id);
|
|
291
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
|
};
|
|
@@ -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;
|