termux-dev 1.6.0 → 1.8.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.
Files changed (96) hide show
  1. package/README.md +214 -166
  2. package/assets/banner.svg +23 -28
  3. package/assets/preview.png +0 -0
  4. package/assets/preview.svg +92 -10
  5. package/dist/cli/banner.js +1 -1
  6. package/dist/cli/clipboard-write.js +55 -0
  7. package/dist/cli/clipboard.js +182 -106
  8. package/dist/cli/colors.js +67 -0
  9. package/dist/cli/confirm.js +50 -0
  10. package/dist/cli/doctor.js +4 -4
  11. package/dist/cli/effort.js +80 -0
  12. package/dist/cli/export.js +3 -3
  13. package/dist/cli/files.js +19 -5
  14. package/dist/cli/headless.js +23 -11
  15. package/dist/cli/index.js +1377 -480
  16. package/dist/cli/keys.js +69 -0
  17. package/dist/cli/liveblock.js +45 -0
  18. package/dist/cli/log.js +19 -0
  19. package/dist/cli/markdown.js +158 -36
  20. package/dist/cli/mcp-panel.js +432 -0
  21. package/dist/cli/permission.js +84 -0
  22. package/dist/cli/picker.js +135 -0
  23. package/dist/cli/prompt.js +256 -56
  24. package/dist/cli/resume.js +15 -0
  25. package/dist/cli/rewind.js +51 -0
  26. package/dist/cli/runtime_errors.js +6 -0
  27. package/dist/cli/server.js +85 -24
  28. package/dist/cli/settings.js +307 -0
  29. package/dist/cli/statusline.js +1 -1
  30. package/dist/cli/theme.js +126 -108
  31. package/dist/cli/transcript.js +114 -0
  32. package/dist/cli/typeahead.js +371 -0
  33. package/dist/cli/ui.js +1021 -0
  34. package/dist/cli/updater.js +23 -21
  35. package/dist/cli/usage-panel.js +242 -0
  36. package/dist/cli/width.js +178 -0
  37. package/dist/core/capabilities.js +433 -0
  38. package/dist/core/catalog-util.js +27 -0
  39. package/dist/core/checkpoint.js +90 -0
  40. package/dist/core/context-stats.js +29 -0
  41. package/dist/core/cost.js +191 -0
  42. package/dist/core/formats.js +199 -0
  43. package/dist/core/history.js +51 -1
  44. package/dist/core/hooks.js +79 -0
  45. package/dist/core/i18n.js +42 -0
  46. package/dist/core/local-config.js +69 -0
  47. package/dist/core/locales/de.js +210 -0
  48. package/dist/core/locales/en.js +221 -0
  49. package/dist/core/locales/es.js +210 -0
  50. package/dist/core/locales/fr.js +210 -0
  51. package/dist/core/locales/ja.js +210 -0
  52. package/dist/core/locales/pt.js +210 -0
  53. package/dist/core/locales/ru.js +210 -0
  54. package/dist/core/locales/uk.js +210 -0
  55. package/dist/core/locales/zh.js +210 -0
  56. package/dist/core/loop.js +125 -49
  57. package/dist/core/memory.js +39 -12
  58. package/dist/core/models.js +33 -20
  59. package/dist/core/network.js +12 -11
  60. package/dist/core/notify.js +54 -0
  61. package/dist/core/planfile.js +43 -0
  62. package/dist/core/price-catalog.json +1 -0
  63. package/dist/core/prices.js +249 -0
  64. package/dist/core/reasoning-catalog.json +1 -0
  65. package/dist/core/reasoning.js +393 -0
  66. package/dist/core/redact.js +41 -0
  67. package/dist/core/secure-file.js +22 -0
  68. package/dist/core/session.js +16 -5
  69. package/dist/core/snapshot.js +14 -2
  70. package/dist/core/title.js +61 -0
  71. package/dist/core/types.js +1 -1
  72. package/dist/core/usage.js +43 -7
  73. package/dist/core/vision-catalog.json +1 -0
  74. package/dist/core/vision.js +123 -0
  75. package/dist/mcp/client.js +36 -5
  76. package/dist/mcp/http-client.js +174 -0
  77. package/dist/mcp/manager.js +330 -93
  78. package/dist/permissions/guard.js +385 -117
  79. package/dist/prompts/builder.js +46 -38
  80. package/dist/providers/anthropic.js +517 -0
  81. package/dist/providers/index.js +18 -1
  82. package/dist/providers/openai.js +138 -125
  83. package/dist/providers/shared.js +116 -0
  84. package/dist/providers/toolguard.js +101 -0
  85. package/dist/tools/bash.js +141 -51
  86. package/dist/tools/diagnostics.js +76 -48
  87. package/dist/tools/fs.js +77 -5
  88. package/dist/tools/index.js +2 -1
  89. package/dist/tools/mcp.js +72 -0
  90. package/dist/tools/packages.js +16 -1
  91. package/dist/tools/plan.js +8 -8
  92. package/dist/tools/questions.js +50 -54
  93. package/dist/tools/server.js +6 -0
  94. package/dist/tools/todo.js +20 -18
  95. package/dist/tools/web.js +143 -8
  96. package/package.json +1 -1
@@ -0,0 +1,210 @@
1
+ export const zh = {
2
+ 'net.unknown': '未知网络错误',
3
+ 'net.rateLimit': '已超出 API 请求频率限制 (429)',
4
+ 'net.badGateway': '服务商网关暂时不可用 (502)',
5
+ 'net.unavailable': '服务商服务器过载 (503)',
6
+ 'net.gatewayTimeout': '服务商网关超时 (504)',
7
+ 'net.offline': '无网络连接或 DNS 故障(离线)',
8
+ 'net.reset': '与服务器的连接被重置',
9
+ 'net.timeout': '网络连接超时',
10
+ 'net.refused': '服务器拒绝了连接',
11
+ 'net.fetchFailed': '网络请求失败(连接中断)',
12
+ 'net.generic': '互联网连接故障',
13
+ 'net.failedAfter': '网络错误:尝试 {n} 次后仍无法恢复连接({reason})。',
14
+ 'conn.unstable': '连接不稳定({reason})',
15
+ 'conn.reconnecting': '正在重连 {attempt}/{max} · {delay} 秒后重试',
16
+ 'conn.restored': '连接已恢复',
17
+ 'conn.classicUnstable': '连接不稳定({reason})',
18
+ 'conn.classicRetry': '正在重连 [{attempt}/{max}](暂停 {delay} 秒)...',
19
+ 'conn.classicWaiting': '等待网络 [{attempt}/{max}]...',
20
+ 'conn.classicRestored': '✓ 连接已恢复!继续生成...',
21
+ 'stop.connection': '连接已取消',
22
+ 'stop.stopped': '已停止',
23
+ 'stop.interrupted': '已中断,正在发送你的消息',
24
+ 'stop.classicConnection': '连接已取消 (Ctrl+C)。',
25
+ 'stop.classicStopped': '生成已停止 (Ctrl+C)。',
26
+ 'stop.classicInterrupted': '已中断:正在发送你的消息。',
27
+ 'status.waiting': '等待模型',
28
+ 'status.thinking': '思考中',
29
+ 'status.writing': '撰写中',
30
+ 'status.running': '运行中',
31
+ 'status.reconnecting': '重连中',
32
+ 'status.stop': 'ctrl+c 停止',
33
+ 'status.thinkingLine': '思考',
34
+ 'status.thoughtFor': '思考了 {sec} 秒',
35
+ 'composer.placeholder': '描述任务或提出问题…',
36
+ 'composer.queued': '已排队',
37
+ 'composer.queuedMany': '{n} 条已排队',
38
+ 'composer.queuedHint': 'ctrl+enter 立即发送 · ↓ 编辑',
39
+ 'composer.hint1': 'enter 排队 · ctrl+c 停止',
40
+ 'composer.hint2': 'enter 排队',
41
+ 'composer.hint3': 'enter 排队 · ctrl+c 停止',
42
+ 'composer.hint4': 'enter 排队',
43
+ 'summary.toolOne': '{n} 个工具',
44
+ 'summary.toolMany': '{n} 个工具',
45
+ 'summary.in': '输入 {n}',
46
+ 'summary.out': '输出 {n}',
47
+ 'summary.stopped': '已停止',
48
+ 'settings.title': '设置',
49
+ 'settings.section.appearance': '外观',
50
+ 'settings.section.agent': '智能体',
51
+ 'settings.section.general': '通用',
52
+ 'settings.section.about': '关于',
53
+ 'settings.row.theme': '主题',
54
+ 'settings.row.layout': '界面',
55
+ 'settings.row.pureBlack': '黑色背景',
56
+ 'settings.row.autoApprove': '自动批准 (YOLO)',
57
+ 'settings.row.maxIterations': '最大步数',
58
+ 'settings.row.memory': '项目记忆',
59
+ 'settings.row.updates': '检查更新',
60
+ 'settings.row.language': '语言',
61
+ 'settings.row.about': '关于 devx',
62
+ 'settings.short.theme': '主题',
63
+ 'settings.short.layout': '界面',
64
+ 'settings.short.pureBlack': '黑背景',
65
+ 'settings.short.autoApprove': 'YOLO',
66
+ 'settings.short.maxIterations': '步数',
67
+ 'settings.short.memory': '记忆',
68
+ 'settings.short.updates': '更新',
69
+ 'settings.short.language': '语言',
70
+ 'settings.short.about': '关于',
71
+ 'settings.value.on': '开',
72
+ 'settings.value.off': '关',
73
+ 'settings.value.clean': '简洁',
74
+ 'settings.value.classic': '经典',
75
+ 'settings.value.unlimited': '不限',
76
+ 'settings.value.steps': '{n} 步',
77
+ 'settings.hint.theme': '整个界面的配色。左右键切换主题,面板会立即变色。',
78
+ 'settings.hint.layout': '简洁:带边框的输入框、卡片和安静的工具行。经典:原来的 ASCII 布局。',
79
+ 'settings.hint.pureBlack': '将终端背景设为深邃的 OLED 黑 (#0a0a0c)。关闭则保留你自己的背景。',
80
+ 'settings.hint.autoApprove': '跳过常规的权限确认。危险命令和凭据文件仍会先询问。仅在本次会话有效:下次启动 devx 时会重新关闭。',
81
+ 'settings.hint.maxIterations': '智能体处理一次请求最多可执行多少个工具步骤(编辑文件、运行命令)。',
82
+ 'settings.hint.memory': '每次会话都会加载 .devx/memory.md 中的规则。',
83
+ 'settings.hint.updates': '启动时在 GitHub 上查找 devx 新版本。无论开关,/update 都可用。',
84
+ 'settings.hint.language': '界面语言:消息、设置和提示。代码和工具名称保持英文。',
85
+ 'settings.hint.about': 'ApvCode 出品的终端原生 AI 编程智能体。MIT 许可证。github.com/apvcode/Termux-Dev',
86
+ 'settings.warnYolo': '已开启:智能体将不经询问直接运行命令并编辑文件。',
87
+ 'settings.keys.move': '移动',
88
+ 'settings.keys.change': '更改',
89
+ 'settings.keys.toggle': '切换',
90
+ 'settings.keys.close': '关闭',
91
+ // ── /mcp panel
92
+ 'mcp.title': 'MCP 服务器',
93
+ 'mcp.count': '服务器:{n}',
94
+ 'mcp.scope.user': '用户服务器',
95
+ 'mcp.scope.project': '项目服务器',
96
+ 'mcp.toolsCount': '工具:{n}',
97
+ 'mcp.status.connected': '已连接',
98
+ 'mcp.status.connecting': '连接中…',
99
+ 'mcp.status.failed': '失败',
100
+ 'mcp.status.disabled': '已禁用',
101
+ 'mcp.empty': '尚未配置 MCP 服务器。',
102
+ 'mcp.emptyHint': '运行 /mcp import 导入 Claude Code 的服务器,或用 /mcp add <名称> <命令> [参数] 添加。',
103
+ 'mcp.detail.status': '状态',
104
+ 'mcp.detail.command': '命令',
105
+ 'mcp.detail.config': '配置',
106
+ 'mcp.detail.capabilities': '能力',
107
+ 'mcp.detail.tools': '工具',
108
+ 'mcp.detail.error': '错误',
109
+ 'mcp.action.tools': '查看工具',
110
+ 'mcp.action.reconnect': '重新连接',
111
+ 'mcp.action.disable': '禁用',
112
+ 'mcp.action.enable': '启用',
113
+ 'mcp.toolsTitle': '{name} 的工具',
114
+ 'mcp.noTools': '该服务器没有提供工具。',
115
+ 'mcp.tag.readOnly': '只读',
116
+ 'mcp.tag.destructive': '破坏性',
117
+ 'mcp.tag.openWorld': '开放世界',
118
+ 'mcp.params': '参数',
119
+ 'mcp.noParams': '没有参数。',
120
+ 'mcp.required': '必填',
121
+ 'mcp.more': '还有 {n} 个',
122
+ 'mcp.keys.open': '打开',
123
+ 'mcp.keys.back': '返回',
124
+ 'mcp.keys.reload': '全部重载',
125
+ 'mcp.keys.close': '关闭',
126
+ // ── questions from the agent
127
+ 'questions.title': '问题',
128
+ 'questions.custom': '输入自己的答案…',
129
+ 'questions.skip': '跳过',
130
+ 'questions.skipped': '(已跳过)',
131
+ 'questions.yourAnswer': '你的答案:',
132
+ 'questions.empty': '答案不能为空',
133
+ // ── confirmations
134
+ 'confirm.yes': '是',
135
+ 'confirm.no': '否',
136
+ 'confirm.exitTitle': '退出',
137
+ 'confirm.exit': '退出 devx?',
138
+ 'confirm.goodbye': '再见',
139
+ 'confirm.deleteAll': '删除全部 {n} 个已保存的会话?',
140
+ 'confirm.update': '现在将 devx 更新到 v{version}?',
141
+ 'confirm.commit': '用此信息提交更改?',
142
+ 'trust.title': '工作区信任',
143
+ 'trust.question': '首次进入此文件夹。信任它并允许智能体在这里工作吗?',
144
+ 'trust.yes': '是,信任此文件夹',
145
+ 'trust.no': '否,退出',
146
+ 'trust.wantsYolo': '项目配置要求自动批准 (YOLO)',
147
+ 'trust.wantsAllowlist': '项目配置允许这些命令:{list}',
148
+ 'trust.exiting': '未信任该文件夹,devx 将退出',
149
+ 'trust.mcpQuestion': '此项目想启动 MCP 服务器(本地程序,见上方列表)。允许吗?',
150
+ // ── view switches
151
+ 'view.expandThought': 'ctrl+p 展开',
152
+ 'view.expandOutput': 'ctrl+o 展开',
153
+ 'view.moreLines': '… 还有 {n} 行',
154
+ // ── plan approval
155
+ 'plan.cardTitle': '计划',
156
+ 'plan.files': '文件',
157
+ 'plan.ready': '计划已就绪。开始执行吗?',
158
+ 'plan.go': 'Go:切换到 AGENT 模式并开始',
159
+ 'plan.edit': '修改计划(留在 PLAN 模式)',
160
+ 'plan.editHint': '说明要改什么:计划保持在 PLAN 模式并会重做。',
161
+ 'plan.started': 'AGENT 模式:按已批准的计划执行',
162
+ // ── undo
163
+ 'undo.question': '将 {n} 个文件恢复到上一轮之前的状态?',
164
+ 'undo.note': '包括 shell 命令所做的更改。被 git 忽略的文件不受影响。',
165
+ 'undo.done': '已恢复 {n} 个文件',
166
+ // ── permissions
167
+ 'permissions.title': '无需询问即可执行',
168
+ 'permissions.none': '目前没有无需询问的项目:在批准卡片上选择 Session 或 Always 即可添加。',
169
+ 'permissions.hint': '/permissions clear 会清除全部(包括仅本次会话的)。',
170
+ 'permissions.cleared': '已清除所有无需询问的许可',
171
+ // ── cost limit
172
+ 'settings.row.costLimit': '费用上限',
173
+ 'settings.short.costLimit': '费用上限',
174
+ 'settings.hint.costLimit': '单次会话最多可花费的金额。达到 80% 时 devx 会提醒,达到上限时会停止任务,并在继续花费前询问。',
175
+ 'cost.title': '费用上限',
176
+ 'cost.warn': '已花费 {spent}:本次会话 {limit} 上限的 80%',
177
+ 'cost.stopped': '已达到 {limit} 的费用上限:任务已停止',
178
+ 'cost.reached': '本次会话已花费 {spent},上限为 {limit}。继续花费吗?',
179
+ 'cost.raise': '继续将把本次会话的上限提高到 {limit}。',
180
+ 'cost.continue': '是,继续',
181
+ 'cost.cancel': '否,暂不',
182
+ 'cost.cancelled': '未发送任何内容。你的消息已回到输入行。',
183
+ // ── /copy and /rename
184
+ 'copy.none': '还没有可复制的回答。',
185
+ 'copy.done': '已复制最后一条回答({n} 个字符)',
186
+ 'copy.failed': '无法访问剪贴板(没有 wl-copy、xclip、xsel,且回答对终端来说太长)。',
187
+ 'rename.done': '聊天已重命名:{name}',
188
+ 'rename.usage': '用法:/rename <名称>。不带名称时,有了第一条回答后由 AI 重新命名。',
189
+ 'rename.failed': 'AI 没有想出名称。请重试或自己指定:/rename <名称>。',
190
+ // ── memory
191
+ 'memory.capped': '记忆太长:最旧的 {n} 条不会发送给模型(只能放下最新的)。请从 .devx/memory.md 中删除旧的。',
192
+ 'mcp.removeUsage': '用法:/mcp remove <名称>',
193
+ 'mcp.removeNotFound': '"{name}" 不在 ~/.devx/mcp.json 中。其他文件(项目、~/.devxrc.json)里的服务器需要手动从该文件删除。',
194
+ 'mcp.removed': '已删除服务器 "{name}"',
195
+ // ── /rewind
196
+ 'rewind.title': '回退',
197
+ 'rewind.count': '{n} 条消息',
198
+ 'rewind.current': '(当前:留在这里)',
199
+ 'rewind.none': '还没有可以回到的你的消息。',
200
+ 'rewind.later': '之后 {n} 条',
201
+ 'rewind.ago.now': '刚刚',
202
+ 'rewind.ago.min': '{n} 分钟前',
203
+ 'rewind.ago.hour': '{n} 小时前',
204
+ 'rewind.ago.day': '{n} 天前',
205
+ 'rewind.filesQuestion': '也把文件({n} 个)恢复到当时的样子吗?',
206
+ 'rewind.filesYes': '是,文件也恢复',
207
+ 'rewind.filesNo': '否,只回退对话',
208
+ 'rewind.done': '已回到你的消息。之后的内容已被清除,模型也看不到了。修改消息后发送。',
209
+ 'rewind.doneImages': '已回到你的消息(其中的图片未保留,请重新添加)。之后的内容已被清除,模型也看不到了。',
210
+ };
package/dist/core/loop.js CHANGED
@@ -1,5 +1,9 @@
1
+ import { CONTEXT_USAGE_RATIO, toolSchemaTokens } from './context-stats.js';
2
+ import { t } from './i18n.js';
3
+ import { redactSecrets } from './redact.js';
4
+ import { runHooks } from './hooks.js';
1
5
  import { isNetworkOrRetryableError, getHumanReadableNetworkError, waitDelay } from './network.js';
2
- function getActionDescription(toolName, args) {
6
+ export function getActionDescription(toolName, args) {
3
7
  const t = toolName.toLowerCase();
4
8
  if (t === 'read_file' || t === 'view_file' || t === 'read') {
5
9
  return `→ Read ${args.path || args.filePath || args.targetFile || ''}`;
@@ -31,11 +35,27 @@ function getActionDescription(toolName, args) {
31
35
  if (t === 'todo_list' || t === 'update_todos') {
32
36
  return `→ Update Plan & Tasks`;
33
37
  }
38
+ if (t.startsWith('mcp__')) {
39
+ const [, server = 'mcp', ...rest] = toolName.split('__');
40
+ let brief = '';
41
+ try {
42
+ brief = args && Object.keys(args).length ? JSON.stringify(args) : '';
43
+ }
44
+ catch { }
45
+ return `→ MCP: ${server} · ${rest.join('__')}${brief ? ' ' + (brief.length > 120 ? brief.slice(0, 117) + '...' : brief) : ''}`;
46
+ }
34
47
  if (t === 'plan_ready') {
35
48
  return `→ Finalize Plan`;
36
49
  }
37
50
  return `→ Executing ${toolName}...`;
38
51
  }
52
+ 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;
53
+ function isContextLengthError(err) {
54
+ const status = err?.status;
55
+ if (status !== undefined && status !== 400 && status !== 413 && status !== 422)
56
+ return false;
57
+ return CONTEXT_LENGTH_ERROR.test(String(err?.message || ''));
58
+ }
39
59
  export class Agent {
40
60
  config;
41
61
  provider;
@@ -49,19 +69,31 @@ export class Agent {
49
69
  this.history = history;
50
70
  this.guard = guard;
51
71
  }
52
- async *run(signal) {
72
+ async *run(signal, hooks) {
53
73
  let iterations = 0;
54
- while (iterations < this.config.maxIterations) {
74
+ // A config without maxIterations used to make the loop exit immediately and silently.
75
+ const maxIterations = this.config.maxIterations > 0 ? this.config.maxIterations : 100;
76
+ while (iterations < maxIterations) {
55
77
  if (signal?.aborted) {
56
78
  return;
57
79
  }
58
- const wasCompacted = this.history.pruneToLimit(this.config.maxContextTokens);
80
+ // Messages the user typed while the previous step ran join the conversation here, between
81
+ // the tool results and the next request, so the model sees them before it decides what to do.
82
+ for (const message of (await hooks?.takePending?.()) ?? []) {
83
+ this.history.addMessage(message);
84
+ yield { type: 'user_message', content: message.content };
85
+ }
86
+ const toolDefinitions = Array.from(this.tools.values()).map((t) => t.definition);
87
+ // Tool schemas are sent with every request and count against the context window.
88
+ const schemaTokens = toolSchemaTokens(toolDefinitions);
89
+ const promptBudget = Math.max(1000, Math.floor(this.config.maxContextTokens * CONTEXT_USAGE_RATIO) - schemaTokens);
90
+ const wasCompacted = this.history.pruneToLimit(promptBudget);
59
91
  if (wasCompacted) {
60
92
  yield { type: 'system', message: 'Context compacted to fit model context window.' };
61
93
  }
62
94
  const request = {
63
95
  messages: this.history.getMessages(),
64
- tools: Array.from(this.tools.values()).map((t) => t.definition),
96
+ tools: toolDefinitions,
65
97
  signal,
66
98
  };
67
99
  if (request.tools.length === 0) {
@@ -69,6 +101,7 @@ export class Agent {
69
101
  }
70
102
  let response;
71
103
  let attempt = 0;
104
+ let contextRetries = 0;
72
105
  const maxRetries = 4;
73
106
  while (true) {
74
107
  if (signal?.aborted)
@@ -111,6 +144,16 @@ export class Agent {
111
144
  if (signal?.aborted || err.name === 'AbortError' || err.message?.includes('aborted')) {
112
145
  return;
113
146
  }
147
+ // The provider says the prompt is too long (our estimate was too optimistic):
148
+ // shrink the history harder and retry instead of failing the whole session.
149
+ if (contextRetries < 2 && isContextLengthError(err)) {
150
+ contextRetries++;
151
+ const target = Math.floor(this.history.getTotalTokens() * (contextRetries === 1 ? 0.6 : 0.35));
152
+ this.history.pruneToLimit(Math.max(500, target));
153
+ request.messages = this.history.getMessages();
154
+ yield { type: 'system', message: 'Provider reported the context is too long. Compacted history, retrying.' };
155
+ continue;
156
+ }
114
157
  const isNetwork = isNetworkOrRetryableError(err);
115
158
  if (isNetwork && attempt < maxRetries) {
116
159
  attempt++;
@@ -129,7 +172,7 @@ export class Agent {
129
172
  continue;
130
173
  }
131
174
  const errMsg = isNetwork
132
- ? `Ошибка сети: Не удалось восстановить интернет-соединение после ${attempt} попыток (${err.message}).`
175
+ ? t('net.failedAfter', { n: attempt, reason: err.message })
133
176
  : `API Error: ${err.message}`;
134
177
  yield { type: 'error', message: errMsg, isFatal: true };
135
178
  return;
@@ -138,6 +181,9 @@ export class Agent {
138
181
  if (signal?.aborted) {
139
182
  return;
140
183
  }
184
+ for (const notice of this.provider.drainNotices?.() ?? []) {
185
+ yield { type: 'system', message: notice };
186
+ }
141
187
  if (!response) {
142
188
  response = { content: '' };
143
189
  }
@@ -148,6 +194,9 @@ export class Agent {
148
194
  if (response.toolCalls && response.toolCalls.length > 0) {
149
195
  assistantMsg.tool_calls = response.toolCalls;
150
196
  }
197
+ if (response.thinkingBlocks && response.thinkingBlocks.length > 0) {
198
+ assistantMsg.thinking_blocks = response.thinkingBlocks;
199
+ }
151
200
  this.history.addMessage(assistantMsg);
152
201
  if (response.usage) {
153
202
  yield { type: 'usage', usage: response.usage };
@@ -157,64 +206,91 @@ export class Agent {
157
206
  yield { type: 'text', content: assistantMsg.content };
158
207
  }
159
208
  if (!response.toolCalls || response.toolCalls.length === 0) {
209
+ // The user wrote something while the model was finishing: answer it instead of stopping.
210
+ if (hooks?.hasPending?.()) {
211
+ iterations++;
212
+ continue;
213
+ }
160
214
  break;
161
215
  }
162
216
  const executedToolCallIds = new Set();
163
- for (const call of response.toolCalls) {
164
- if (signal?.aborted) {
165
- // Add synthetic responses for ALL remaining unexecuted tool calls
166
- for (const remaining of response.toolCalls) {
167
- if (!executedToolCallIds.has(remaining.id)) {
168
- this.history.addMessage({
169
- role: 'tool',
170
- content: '[Operation cancelled by user]',
171
- tool_call_id: remaining.id,
172
- name: remaining.name,
173
- });
174
- }
217
+ try {
218
+ for (const call of response.toolCalls) {
219
+ if (signal?.aborted)
220
+ return;
221
+ const actionDesc = getActionDescription(call.name, call.arguments);
222
+ yield { type: 'tool_start', id: call.id, name: call.name, argsRaw: JSON.stringify(call.arguments), actionDesc };
223
+ if (signal?.aborted)
224
+ return;
225
+ let result = '';
226
+ const tool = this.tools.get(call.name);
227
+ if (!tool) {
228
+ result = `Error: Tool '${call.name}' not found.`;
175
229
  }
176
- return;
177
- }
178
- const actionDesc = getActionDescription(call.name, call.arguments);
179
- yield { type: 'tool_start', id: call.id, name: call.name, argsRaw: JSON.stringify(call.arguments), actionDesc };
180
- let result = '';
181
- const tool = this.tools.get(call.name);
182
- if (!tool) {
183
- result = `Error: Tool '${call.name}' not found.`;
184
- }
185
- else {
186
- try {
187
- tool.validateArgs(call.arguments);
188
- if (this.guard.check(tool.name, call.arguments)) {
189
- const allowed = await this.guard.askUser(tool.name, call.arguments);
190
- if (allowed) {
191
- result = await tool.execute(call.arguments, this.config);
230
+ else if (call.argsError) {
231
+ // Never run a tool with arguments we could not parse ({} could mean something else).
232
+ result = `Error: ${call.argsError}`;
233
+ }
234
+ else {
235
+ try {
236
+ tool.validateArgs(call.arguments);
237
+ // A hook of the user may say no before the user is even asked.
238
+ const pre = await runHooks(this.config.hooks, 'PreToolUse', tool.name, call.arguments);
239
+ if (pre.blocked) {
240
+ result = `Blocked by a hook: ${pre.blocked}`;
192
241
  }
193
242
  else {
194
- result = 'Action denied by user.';
243
+ const allowed = !this.guard.check(tool.name, call.arguments)
244
+ || await this.guard.askUser(tool.name, call.arguments);
245
+ // Cancellation may arrive while waiting for confirmation.
246
+ if (signal?.aborted)
247
+ return;
248
+ result = allowed
249
+ ? await tool.execute(call.arguments, this.config, { signal })
250
+ : 'Action denied by user.';
251
+ if (allowed) {
252
+ const post = await runHooks(this.config.hooks, 'PostToolUse', tool.name, call.arguments, result);
253
+ if (post.notes.length > 0)
254
+ result += `\n[hook] ${post.notes.join('\n[hook] ')}`;
255
+ }
195
256
  }
196
257
  }
197
- else {
198
- result = await tool.execute(call.arguments, this.config);
258
+ catch (err) {
259
+ result = signal?.aborted
260
+ ? '[Operation cancelled by user]'
261
+ : `Error executing tool: ${err.message}`;
199
262
  }
200
263
  }
201
- catch (err) {
202
- result = `Error executing tool: ${err.message}`;
264
+ // Keys and tokens in the output must not reach the model, the chat or the saved session.
265
+ result = redactSecrets(result);
266
+ // Save the result before yielding: the UI can stop consuming at any event.
267
+ this.history.addMessage({
268
+ role: 'tool',
269
+ content: result,
270
+ tool_call_id: call.id,
271
+ name: call.name,
272
+ });
273
+ executedToolCallIds.add(call.id);
274
+ yield { type: 'tool_end', id: call.id, name: call.name, result };
275
+ }
276
+ }
277
+ finally {
278
+ // Keep every tool call paired with a response, even if the consumer exits early.
279
+ for (const call of response.toolCalls) {
280
+ if (!executedToolCallIds.has(call.id)) {
281
+ this.history.addMessage({
282
+ role: 'tool',
283
+ content: '[Operation cancelled by user]',
284
+ tool_call_id: call.id,
285
+ name: call.name,
286
+ });
203
287
  }
204
288
  }
205
- yield { type: 'tool_end', id: call.id, name: call.name, result };
206
- this.history.addMessage({
207
- role: 'tool',
208
- content: result,
209
- tool_call_id: call.id,
210
- name: call.name,
211
- });
212
- executedToolCallIds.add(call.id);
213
289
  }
214
290
  iterations++;
215
291
  }
216
- if (iterations >= this.config.maxIterations) {
217
- yield { type: 'system', message: `Reached max iterations (${this.config.maxIterations}).` };
292
+ if (iterations >= maxIterations) {
293
+ yield { type: 'system', message: `Reached max iterations (${maxIterations}).` };
218
294
  }
219
295
  }
220
296
  }
@@ -1,27 +1,54 @@
1
1
  import fs from 'fs/promises';
2
2
  import fsSync from 'fs';
3
3
  import path from 'path';
4
- const MEMORY_DIR = path.join(process.cwd(), '.devx');
5
- const MEMORY_FILE = path.join(MEMORY_DIR, 'memory.md');
4
+ // Looked up when used, not when the module loads: the memory belongs to the folder devx works in.
5
+ const memoryDir = () => path.join(process.cwd(), '.devx');
6
+ const memoryFile = () => path.join(memoryDir(), 'memory.md');
7
+ export const MEMORY_PROMPT_MAX_CHARS = 4000;
8
+ /**
9
+ * The memory for the system prompt: all of it when it is small, else the newest facts that fit
10
+ * (facts are appended, so the end is the newest) and a line that says how many were left out.
11
+ */
12
+ export function memoryForPrompt(text, max = MEMORY_PROMPT_MAX_CHARS) {
13
+ if (text.length <= max)
14
+ return { text, omitted: 0 };
15
+ const lines = text.split('\n');
16
+ const firstFact = lines.findIndex((l) => l.startsWith('•'));
17
+ const header = firstFact === -1 ? [] : lines.slice(0, firstFact);
18
+ const facts = (firstFact === -1 ? lines : lines.slice(firstFact)).filter((l) => l.trim().length > 0);
19
+ const note = (n) => `(${n} older facts left out: /memory shows all of them)`;
20
+ const kept = [];
21
+ let used = header.join('\n').length + note(facts.length).length + 2;
22
+ for (let i = facts.length - 1; i >= 0; i--) {
23
+ if (used + facts[i].length + 1 > max && kept.length > 0)
24
+ break;
25
+ kept.unshift(facts[i]);
26
+ used += facts[i].length + 1;
27
+ }
28
+ const omitted = facts.length - kept.length;
29
+ return { text: [...header, ...(omitted > 0 ? [note(omitted)] : []), ...kept].join('\n'), omitted };
30
+ }
6
31
  export class MemoryManager {
32
+ /** The `Project memory` switch of the settings: off means the bank is not put into the prompt. */
33
+ static enabled = true;
7
34
  static async loadMemory() {
8
- if (!fsSync.existsSync(MEMORY_FILE)) {
35
+ if (!fsSync.existsSync(memoryFile())) {
9
36
  return '';
10
37
  }
11
38
  try {
12
- return await fs.readFile(MEMORY_FILE, 'utf8');
39
+ return await fs.readFile(memoryFile(), 'utf8');
13
40
  }
14
41
  catch {
15
42
  return '';
16
43
  }
17
44
  }
18
45
  static async addFact(fact) {
19
- if (!fsSync.existsSync(MEMORY_DIR)) {
20
- await fs.mkdir(MEMORY_DIR, { recursive: true });
46
+ if (!fsSync.existsSync(memoryDir())) {
47
+ await fs.mkdir(memoryDir(), { recursive: true });
21
48
  }
22
49
  let current = '';
23
- if (fsSync.existsSync(MEMORY_FILE)) {
24
- current = await fs.readFile(MEMORY_FILE, 'utf8');
50
+ if (fsSync.existsSync(memoryFile())) {
51
+ current = await fs.readFile(memoryFile(), 'utf8');
25
52
  }
26
53
  const trimmedFact = fact.trim();
27
54
  if (!trimmedFact)
@@ -32,16 +59,16 @@ export class MemoryManager {
32
59
  current = `# Project Memory Bank\n\n## Established Rules & Architecture\n`;
33
60
  }
34
61
  current += entry;
35
- await fs.writeFile(MEMORY_FILE, current, 'utf8');
62
+ await fs.writeFile(memoryFile(), current, 'utf8');
36
63
  }
37
64
  }
38
65
  static async clearMemory() {
39
- if (fsSync.existsSync(MEMORY_FILE)) {
40
- await fs.unlink(MEMORY_FILE);
66
+ if (fsSync.existsSync(memoryFile())) {
67
+ await fs.unlink(memoryFile());
41
68
  }
42
69
  }
43
70
  static isMemoryPresent() {
44
- return fsSync.existsSync(MEMORY_FILE);
71
+ return fsSync.existsSync(memoryFile());
45
72
  }
46
73
  }
47
74
  export const saveMemoryTool = {
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { fileURLToPath } from 'url';
4
+ import { catalogContextWindow } from './vision.js';
4
5
  const FALLBACK_LIMITS = {
5
6
  'claude-3.5-sonnet': 200000,
6
7
  'claude-3-opus': 200000,
@@ -38,47 +39,59 @@ function loadLimits() {
38
39
  return limitsCache;
39
40
  }
40
41
  export function getModelContextLimit(modelName) {
42
+ return getModelContextInfo(modelName).limit;
43
+ }
44
+ /**
45
+ * The context window of a model and how sure we are. The models.dev catalogue is asked first
46
+ * (it knows the maker's own number for thousands of models and is refreshed with /effort refresh),
47
+ * then devx's table, then rules by name, and only then a default of 128k.
48
+ */
49
+ export function getModelContextInfo(modelName) {
50
+ const fromCatalogue = catalogContextWindow(modelName || '');
51
+ if (fromCatalogue)
52
+ return { limit: fromCatalogue, source: 'catalogue' };
53
+ const rule = (limit) => ({ limit, source: 'rule' });
41
54
  const cache = loadLimits();
42
55
  const clean = (modelName || '').trim().toLowerCase();
43
56
  const short = clean.split('/').pop() || clean;
44
57
  if (cache[clean])
45
- return cache[clean];
58
+ return { limit: cache[clean], source: 'table' };
46
59
  if (cache[short])
47
- return cache[short];
60
+ return { limit: cache[short], source: 'table' };
48
61
  const baseName = short.replace(/-\d{4,8}$/, '').replace(/:latest$/, '');
49
62
  if (cache[baseName])
50
- return cache[baseName];
63
+ return { limit: cache[baseName], source: 'table' };
51
64
  if (clean.includes('kimi-k2') || clean.includes('kimi'))
52
- return 256000;
65
+ return rule(256000);
53
66
  if (clean.includes('ox-alpha') || clean.includes('stealth'))
54
- return 1048576;
67
+ return rule(1048576);
55
68
  if (clean.includes('gemini-3') || clean.includes('gemini-2') || clean.includes('gemini-1.5') || clean.includes('gemini'))
56
- return 1048576;
69
+ return rule(1048576);
57
70
  if (clean.includes('claude-opus-4') || clean.includes('claude-4'))
58
- return 1000000;
71
+ return rule(1000000);
59
72
  if (clean.includes('claude-3-5') || clean.includes('claude-3.5') || clean.includes('claude-3-7') || clean.includes('claude-3.7') || clean.includes('claude'))
60
- return 200000;
73
+ return rule(200000);
61
74
  if (clean.includes('gpt-5'))
62
- return 1050000;
75
+ return rule(1050000);
63
76
  if (clean.includes('gpt-4o') || clean.includes('gpt-4-turbo') || clean.includes('o1') || clean.includes('o3'))
64
- return 128000;
77
+ return rule(128000);
65
78
  if (clean.includes('deepseek-v4') || clean.includes('deepseek-v3') || clean.includes('deepseek-r1') || clean.includes('deepseek'))
66
- return 128000;
79
+ return rule(128000);
67
80
  if (clean.includes('qwen3.5') || clean.includes('qwen-3.5'))
68
- return 262144;
81
+ return rule(262144);
69
82
  if (clean.includes('qwen2.5') || clean.includes('qwen-2.5') || clean.includes('qwen'))
70
- return 131072;
83
+ return rule(131072);
71
84
  if (clean.includes('glm-5') || clean.includes('glm-4.7') || clean.includes('glm-4.6'))
72
- return 200000;
85
+ return rule(200000);
73
86
  if (clean.includes('glm-4'))
74
- return 128000;
87
+ return rule(128000);
75
88
  if (clean.includes('minimax-m1') || clean.includes('minimax'))
76
- return 1000000;
89
+ return rule(1000000);
77
90
  if (clean.includes('doubao'))
78
- return 256000;
91
+ return rule(256000);
79
92
  if (clean.includes('llama-3.3') || clean.includes('llama-3.1') || clean.includes('llama-3'))
80
- return 128000;
93
+ return rule(128000);
81
94
  if (clean.includes('mistral-large') || clean.includes('codestral'))
82
- return 128000;
83
- return 128000;
95
+ return rule(128000);
96
+ return { limit: 128000, source: 'default' };
84
97
  }