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,393 @@
1
+ /**
2
+ * Reasoning ("thinking") effort.
3
+ *
4
+ * Models differ a lot: OpenAI's o3 has low / medium / high and cannot switch reasoning off,
5
+ * Claude takes a thinking budget in tokens, Gemini 2.5 Pro cannot be switched off, DeepSeek-R1
6
+ * always reasons and has no setting at all. And every API wants the value in another
7
+ * parameter. This module answers two questions:
8
+ *
9
+ * 1. What can THIS model do? -> `resolveReasoning(...).levels` (only these are offered)
10
+ * 2. How is a level sent? -> `caps.build(level)` (the request parameters for this API)
11
+ *
12
+ * The first answer comes from, in this order: a profile in the config file, what the
13
+ * endpoint itself reports (OpenRouter-style metadata, Ollama, LiteLLM...), the models.dev
14
+ * catalogue, and finally rules by model name. The second comes from the route (by base URL).
15
+ * A model that nothing describes gets no levels instead of a guess.
16
+ */
17
+ export const LEVELS = ['off', 'low', 'normal', 'high', 'xhigh', 'max'];
18
+ export const isLevel = (value) => typeof value === 'string' && LEVELS.includes(value);
19
+ // ───────────────────────── routes ─────────────────────────
20
+ export function detectRoute(baseUrl = '') {
21
+ let host = baseUrl.toLowerCase();
22
+ try {
23
+ host = new URL(baseUrl).hostname.toLowerCase();
24
+ }
25
+ catch { }
26
+ if (host.endsWith('openrouter.ai'))
27
+ return 'openrouter';
28
+ if (host === 'api.openai.com')
29
+ return 'openai';
30
+ if (host === 'api.anthropic.com')
31
+ return 'anthropic';
32
+ if (host.endsWith('generativelanguage.googleapis.com'))
33
+ return 'google';
34
+ if (host.endsWith('aliyuncs.com'))
35
+ return 'dashscope';
36
+ if (host === 'api.z.ai' || host.endsWith('bigmodel.cn'))
37
+ return 'zai';
38
+ if (host.endsWith('deepseek.com'))
39
+ return 'deepseek';
40
+ return 'generic';
41
+ }
42
+ const ALWAYS_ON = [
43
+ /^deepseek-reasoner/, /^deepseek-r1/, /^r1-/, /-r1(-|$)/, /^qwq/, /^magistral/, /thinking(-\d+)?$/,
44
+ /^kimi-k2-thinking/, /^minimax-m2/, /^glm-z1/, /^sonar-reasoning/, /^grok-4/,
45
+ ];
46
+ const OPENAI_EFFORT = { low: 'low', normal: 'medium', high: 'high' };
47
+ function normalizeModel(model) {
48
+ const parts = model.toLowerCase().trim().split('/');
49
+ const name = (parts[parts.length - 1] || '').replace(/:.*$/, '');
50
+ return { vendor: parts.length > 1 ? parts[parts.length - 2] : '', name };
51
+ }
52
+ function familyFor(name) {
53
+ // Specific families first, the "always on" list last-but-one so that e.g. grok-3-mini wins over grok-4.
54
+ if (/^gpt-oss/.test(name)) {
55
+ return { id: 'gpt-oss', kind: 'effort', levels: ['low', 'normal', 'high'], confidence: 'known', effort: OPENAI_EFFORT };
56
+ }
57
+ if (/^o[1345](-mini|-pro|-preview)?(-\d{4}-\d{2}-\d{2})?$/.test(name) && !/^o1-(mini|preview)/.test(name)) {
58
+ if (/-pro/.test(name))
59
+ return { id: 'openai-o-pro', kind: 'effort', levels: ['high'], confidence: 'known', effort: { high: 'high' } };
60
+ return { id: 'openai-o', kind: 'effort', levels: ['low', 'normal', 'high'], confidence: 'known', effort: OPENAI_EFFORT };
61
+ }
62
+ const gpt5 = name.match(/^gpt-5(?:\.(\d+))?/);
63
+ if (gpt5) {
64
+ if (/chat/.test(name))
65
+ return null; // gpt-5-chat does not reason
66
+ const minor = gpt5[1] ? parseInt(gpt5[1], 10) : 0;
67
+ if (/-pro/.test(name))
68
+ return { id: 'gpt-5-pro', kind: 'effort', levels: ['high'], confidence: 'known', effort: { high: 'high' } };
69
+ const levels = ['off', 'low', 'normal', 'high'];
70
+ const effort = {
71
+ // gpt-5 itself cannot be switched off, "minimal" is the closest; 5.1 and later accept "none"
72
+ off: minor === 0 ? 'minimal' : 'none', low: 'low', normal: 'medium', high: 'high',
73
+ };
74
+ if (minor >= 2 || /codex-max/.test(name)) {
75
+ levels.push('xhigh');
76
+ effort.xhigh = 'xhigh';
77
+ }
78
+ return { id: minor === 0 ? 'gpt-5' : `gpt-5.${minor}`, kind: 'effort', levels, confidence: minor <= 2 ? 'known' : 'guess', effort };
79
+ }
80
+ if (/^grok-3-mini/.test(name)) {
81
+ return { id: 'grok-3-mini', kind: 'effort', levels: ['low', 'high'], confidence: 'known', effort: { low: 'low', high: 'high' } };
82
+ }
83
+ if (/^claude-/.test(name) && /claude-(3[-.]7|(opus|sonnet|haiku)-[4-9]|[4-9])/.test(name)) {
84
+ // Opus 4 / 4.1 can write at most 32k tokens, newer models 64k: that bounds the thinking budget.
85
+ const smallOutput = /opus-4(?![.-]?[5-9])/.test(name);
86
+ const levels = smallOutput ? ['off', 'low', 'normal', 'high', 'xhigh'] : ['off', 'low', 'normal', 'high', 'xhigh', 'max'];
87
+ return {
88
+ id: 'claude', kind: 'budget', levels, confidence: 'known',
89
+ budgets: { low: 2048, normal: 6144, high: 16000, xhigh: smallOutput ? 24000 : 32000, max: 63000 },
90
+ };
91
+ }
92
+ if (/^gemini-2\.5-pro/.test(name)) {
93
+ return {
94
+ id: 'gemini-2.5-pro', kind: 'budget', levels: ['low', 'normal', 'high', 'max'], confidence: 'known', defaultOn: true,
95
+ budgets: { low: 1024, normal: 8192, high: 24576, max: 32768 },
96
+ };
97
+ }
98
+ if (/^gemini-2\.5-flash/.test(name)) {
99
+ return {
100
+ id: 'gemini-2.5-flash', kind: 'budget', levels: ['off', 'low', 'normal', 'high', 'max'], confidence: 'known', defaultOn: true,
101
+ budgets: { off: 0, low: 1024, normal: 8192, high: 16384, max: 24576 },
102
+ };
103
+ }
104
+ if (/^gemini-3/.test(name)) {
105
+ return { id: 'gemini-3', kind: 'effort', levels: ['low', 'high'], confidence: 'guess', effort: { low: 'low', high: 'high' } };
106
+ }
107
+ if (/^qwen3/.test(name) && !/(thinking|instruct|coder)/.test(name)) {
108
+ return {
109
+ id: 'qwen3', kind: 'budget', levels: ['off', 'low', 'normal', 'high', 'max'], confidence: 'known', defaultOn: true,
110
+ budgets: { low: 1024, normal: 4096, high: 16384, max: 38912 },
111
+ };
112
+ }
113
+ if (/^deepseek-(v3\.[1-9]|v[4-9]|chat-v3\.[1-9])/.test(name)) {
114
+ return { id: 'deepseek-hybrid', kind: 'toggle', levels: ['off', 'normal'], confidence: 'guess' };
115
+ }
116
+ if (/^glm-(4\.[5-9]|[5-9])/.test(name)) {
117
+ return { id: 'glm', kind: 'toggle', levels: ['off', 'normal'], confidence: 'guess' };
118
+ }
119
+ if (ALWAYS_ON.some((re) => re.test(name)))
120
+ return 'always-on';
121
+ return null;
122
+ }
123
+ // ───────────────────────── wiring a level into a request ─────────────────────────
124
+ const kTokens = (n) => (n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`);
125
+ const LEVEL_MEANING = {
126
+ off: 'no reasoning',
127
+ low: 'light reasoning',
128
+ normal: 'balanced',
129
+ high: 'deep reasoning',
130
+ xhigh: 'very deep reasoning',
131
+ max: 'maximum reasoning',
132
+ };
133
+ function adapt(family, route, source) {
134
+ const caps = (levels, confidence, note, build, detail) => ({
135
+ family: family.id,
136
+ route,
137
+ supported: levels.length > 0,
138
+ levels,
139
+ confidence,
140
+ alwaysOn: !levels.includes('off'),
141
+ source,
142
+ note,
143
+ build,
144
+ describe: (l) => `${LEVEL_MEANING[l]}${detail(l) ? ` (${detail(l)})` : ''}`,
145
+ });
146
+ // ── effort families: a named effort per level
147
+ if (family.kind === 'effort') {
148
+ const effort = family.effort ?? {};
149
+ const param = (level) => {
150
+ const value = effort[level];
151
+ if (value === undefined)
152
+ return null;
153
+ return route === 'openrouter' ? { reasoning: { effort: value } } : { reasoning_effort: value };
154
+ };
155
+ return caps(family.levels, family.confidence, route === 'openrouter' ? 'Sent as reasoning.effort (OpenRouter).' : 'Sent as reasoning_effort.', param, (l) => `effort: ${effort[l]}`);
156
+ }
157
+ // ── toggle families: thinking on or off
158
+ if (family.kind === 'toggle') {
159
+ const on = (level) => level !== 'off';
160
+ if (route === 'openrouter') {
161
+ return caps(family.levels, family.confidence, 'Sent as reasoning.enabled (OpenRouter).', (l) => ({ reasoning: { enabled: on(l) } }), () => '');
162
+ }
163
+ if (route === 'zai' || route === 'deepseek') {
164
+ return caps(family.levels, 'guess', 'Sent as thinking.type.', (l) => ({ thinking: { type: on(l) ? 'enabled' : 'disabled' } }), () => '');
165
+ }
166
+ return caps(family.levels, 'guess', 'Sent as reasoning_effort; devx retries without it if the endpoint rejects it.', (l) => ({ reasoning_effort: on(l) ? 'medium' : 'none' }), () => '');
167
+ }
168
+ // ── budget families: a number of thinking tokens
169
+ const budgets = family.budgets ?? {};
170
+ const budgetOf = (l) => budgets[l] ?? 0;
171
+ const detail = (l) => (l === 'off' ? '' : `~${kTokens(budgetOf(l))} thinking tokens`);
172
+ if (route === 'anthropic') {
173
+ return caps(family.levels, 'known', 'Sent as thinking.budget_tokens (Anthropic).', (l) => l === 'off' ? null : { thinking: { type: 'enabled', budget_tokens: budgetOf(l) }, max_tokens: budgetOf(l) + 16000 }, detail);
174
+ }
175
+ if (route === 'openrouter') {
176
+ return caps(family.levels, 'known', 'Sent as reasoning.max_tokens (OpenRouter).', (l) => {
177
+ if (l === 'off')
178
+ return family.defaultOn ? { reasoning: { enabled: false } } : null;
179
+ return { reasoning: { max_tokens: budgetOf(l) } };
180
+ }, detail);
181
+ }
182
+ if (route === 'google') {
183
+ return caps(family.levels, 'known', 'Sent as google.thinking_config.thinking_budget.', (l) => ({
184
+ extra_body: { google: { thinking_config: { thinking_budget: budgetOf(l), include_thoughts: true } } },
185
+ }), detail);
186
+ }
187
+ if (route === 'dashscope') {
188
+ return caps(family.levels, 'known', 'Sent as enable_thinking / thinking_budget (DashScope).', (l) => l === 'off' ? { enable_thinking: false } : { enable_thinking: true, thinking_budget: budgetOf(l) }, detail);
189
+ }
190
+ // Any other endpoint: only the widely understood reasoning_effort, with three coarse steps.
191
+ const generic = family.levels.filter((l) => l === 'low' || l === 'normal' || l === 'high' || l === 'off');
192
+ const mapping = { off: 'none', low: 'low', normal: 'medium', high: 'high' };
193
+ return caps(generic, 'guess', 'Sent as reasoning_effort; devx retries without it if the endpoint rejects it.', (l) => (mapping[l] ? { reasoning_effort: mapping[l] } : null), (l) => `effort: ${mapping[l]}`);
194
+ }
195
+ const NONE = (route, family, note, alwaysOn, source = 'none') => ({
196
+ supported: false, levels: [], family, route, confidence: 'none', alwaysOn, source, note,
197
+ build: () => null,
198
+ describe: () => '',
199
+ });
200
+ function fromProfile(profile, route) {
201
+ const levels = LEVELS.filter((l) => profile.levels.includes(l));
202
+ return {
203
+ supported: levels.length > 0,
204
+ levels,
205
+ family: 'custom',
206
+ route,
207
+ confidence: 'custom',
208
+ alwaysOn: !levels.includes('off'),
209
+ source: 'profile',
210
+ note: `From your config (reasoningProfiles): ${profile.param}.`,
211
+ build: (level) => {
212
+ const value = profile.values[level];
213
+ if (value === undefined)
214
+ return null;
215
+ const path = profile.param.split('.');
216
+ return path.reduceRight((inner, key) => ({ [key]: inner }), value);
217
+ },
218
+ describe: (l) => `${LEVEL_MEANING[l]} (${profile.param}: ${String(profile.values[l])})`,
219
+ };
220
+ }
221
+ // ───────────────────────── from source data to a family ─────────────────────────
222
+ const without = (family, level) => ({
223
+ ...family,
224
+ levels: family.levels.filter((l) => l !== level),
225
+ });
226
+ /** Effort values of an API -> our levels. "minimal" stands in for "off" where nothing is lower. */
227
+ function effortFamily(efforts) {
228
+ const has = (e) => efforts.includes(e);
229
+ const effort = {};
230
+ if (has('none'))
231
+ effort.off = 'none';
232
+ else if (has('minimal'))
233
+ effort.off = 'minimal';
234
+ if (has('low'))
235
+ effort.low = 'low';
236
+ if (has('medium'))
237
+ effort.normal = 'medium';
238
+ if (has('high'))
239
+ effort.high = 'high';
240
+ if (has('xhigh'))
241
+ effort.xhigh = 'xhigh';
242
+ if (has('max'))
243
+ effort.max = 'max';
244
+ const levels = LEVELS.filter((l) => effort[l] !== undefined);
245
+ return levels.length > 0 ? { id: 'catalog-effort', kind: 'effort', levels, confidence: 'known', effort } : null;
246
+ }
247
+ /** A token budget range -> five budgets, each clamped into the range the source allows. */
248
+ function budgetFamily(budget, toggle, mandatory) {
249
+ const min = Math.max(0, budget.min ?? 0);
250
+ const max = budget.max && budget.max > 0 ? budget.max : 32768;
251
+ const clamp = (n) => Math.min(max, Math.max(min || 1, n));
252
+ const wanted = [
253
+ ['low', 2048], ['normal', 8192], ['high', 16384], ['xhigh', 24576], ['max', max],
254
+ ];
255
+ const budgets = {};
256
+ const levels = [];
257
+ const seen = new Set();
258
+ for (const [level, value] of wanted) {
259
+ const v = clamp(value);
260
+ if (seen.has(v))
261
+ continue; // two levels with the same budget would be one choice
262
+ seen.add(v);
263
+ budgets[level] = v;
264
+ levels.push(level);
265
+ }
266
+ if (!mandatory && (toggle || min === 0)) {
267
+ budgets.off = 0;
268
+ levels.unshift('off');
269
+ }
270
+ return { id: 'catalog-budget', kind: 'budget', levels, confidence: 'known', budgets, defaultOn: true };
271
+ }
272
+ /**
273
+ * Turns what a source reports into a family. The source is authoritative for effort values
274
+ * (they change with every model generation); for budgets and toggles the hand-written rule
275
+ * is kept when there is one, because it knows vendor limits that sources leave out.
276
+ */
277
+ function familyFromData(data, rule) {
278
+ const efforts = data.efforts ?? [];
279
+ const hasEffort = efforts.length > 0;
280
+ const hasBudget = !!data.budget;
281
+ const mandatory = data.mandatory === true;
282
+ const ruleKept = (f) => (mandatory ? without(f, 'off') : f);
283
+ if (hasEffort && !hasBudget)
284
+ return effortFamily(efforts);
285
+ if (hasBudget)
286
+ return rule && rule.kind === 'budget' ? ruleKept(rule) : budgetFamily(data.budget, !!data.toggle, mandatory);
287
+ // Only "it can be switched" or "it reasons", without details.
288
+ if (rule)
289
+ return ruleKept(rule);
290
+ if (data.toggle)
291
+ return { id: 'catalog-toggle', kind: 'toggle', levels: mandatory ? ['normal'] : ['off', 'normal'], confidence: 'known' };
292
+ const levels = mandatory ? ['low', 'normal', 'high'] : ['off', 'low', 'normal', 'high'];
293
+ return {
294
+ id: 'generic-effort', kind: 'effort', levels, confidence: 'guess',
295
+ effort: { off: 'none', low: 'low', normal: 'medium', high: 'high' },
296
+ };
297
+ }
298
+ const SOURCE_NAME = {
299
+ endpoint: 'The endpoint',
300
+ catalog: 'The models.dev catalogue',
301
+ };
302
+ /**
303
+ * What a model can do, and how it is sent over this route.
304
+ *
305
+ * @param data what the endpoint or the catalogue reports for this model (optional)
306
+ */
307
+ export function resolveReasoning(model, baseUrl = '', profiles, data,
308
+ /** The wire format decides the route when it is not the one the host name suggests (Anthropic format on a gateway). */
309
+ routeOverride) {
310
+ const route = routeOverride ?? detectRoute(baseUrl);
311
+ const profile = profiles?.[model.toLowerCase().trim()] ?? profiles?.[model];
312
+ if (profile && Array.isArray(profile.levels) && typeof profile.param === 'string' && profile.values) {
313
+ return fromProfile(profile, route);
314
+ }
315
+ const { name } = normalizeModel(model);
316
+ const found = familyFor(name);
317
+ const rule = found && found !== 'always-on' ? found : null;
318
+ if (data) {
319
+ if (data.none) {
320
+ return NONE(route, 'none-by-source', `${SOURCE_NAME[data.source]} reports that this model has no reasoning mode.`, false, data.source);
321
+ }
322
+ const family = familyFromData(data, rule);
323
+ if (family)
324
+ return adapt(family, route, data.source);
325
+ }
326
+ if (found === 'always-on') {
327
+ return NONE(route, 'always-on', 'This model always reasons and has no setting.', true, 'rules');
328
+ }
329
+ if (!found) {
330
+ return NONE(route, 'unknown', 'No reasoning setting is known for this model.', false);
331
+ }
332
+ return adapt(found, route, 'rules');
333
+ }
334
+ let dataLookup = null;
335
+ /** Registers where the endpoint / catalogue data comes from (see capabilities.ts). */
336
+ export function setReasoningDataLookup(lookup) {
337
+ dataLookup = lookup;
338
+ }
339
+ let routeResolver = null;
340
+ /** Registers what decides the route besides the host name (see formats.ts). */
341
+ export function setRouteResolver(resolver) {
342
+ routeResolver = resolver;
343
+ }
344
+ export function modelKey(model) {
345
+ return (model || '').toLowerCase().trim();
346
+ }
347
+ /**
348
+ * The level that applies to the current model: the saved choice, or the one-off override,
349
+ * but only if this model still offers it (switching models never sends a stale setting).
350
+ */
351
+ export function effectiveReasoning(config, lookup = dataLookup) {
352
+ const data = lookup ? lookup(config) : undefined;
353
+ const caps = resolveReasoning(config.model || '', config.baseUrl || '', config.reasoningProfiles, data, routeResolver?.(config));
354
+ const raw = config.effortOverride ?? config.effort?.[modelKey(config.model)];
355
+ const level = isLevel(raw) && caps.supported && caps.levels.includes(raw) ? raw : undefined;
356
+ return { caps, level, params: level ? caps.build(level) : null };
357
+ }
358
+ /** Parses what the user typed after /effort. Accepts a few natural aliases. */
359
+ export function parseEffortArg(arg) {
360
+ const text = arg.trim().toLowerCase();
361
+ if (['auto', 'default', 'reset', 'clear'].includes(text))
362
+ return { kind: 'auto' };
363
+ const aliases = {
364
+ off: 'off', none: 'off', no: 'off', disable: 'off', disabled: 'off', '0': 'off',
365
+ low: 'low', min: 'low', minimal: 'low', light: 'low',
366
+ normal: 'normal', medium: 'normal', med: 'normal', mid: 'normal',
367
+ high: 'high', hi: 'high',
368
+ xhigh: 'xhigh', 'x-high': 'xhigh', extra: 'xhigh', 'extra-high': 'xhigh',
369
+ max: 'max', maximum: 'max', ultra: 'max',
370
+ };
371
+ return aliases[text] ? { kind: 'level', level: aliases[text] } : { kind: 'invalid', text };
372
+ }
373
+ /** Stores (or clears, with null) the choice for a model. Returns a new effort map. */
374
+ export function withEffort(effort, model, level) {
375
+ const next = { ...(effort || {}) };
376
+ if (level === null)
377
+ delete next[modelKey(model)];
378
+ else
379
+ next[modelKey(model)] = level;
380
+ return next;
381
+ }
382
+ /** Short label for the status line: "high", or nothing for the model default. */
383
+ export function effortLabel(config) {
384
+ return effectiveReasoning(config).level ?? '';
385
+ }
386
+ /**
387
+ * What the status line says about reasoning next to the model name: "think high", "think auto"
388
+ * (the model's own default) or nothing when the model has no reasoning setting.
389
+ */
390
+ export function thinkLabel(config) {
391
+ const { caps, level } = effectiveReasoning(config);
392
+ return caps.supported ? `think ${level ?? 'auto'}` : '';
393
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Hides secrets in the output of a tool before it reaches the model, the chat and the saved
3
+ * session: API keys, tokens and private keys that show up in a file, in `env` or in a log.
4
+ * It works on well-known shapes; a secret of an unknown shape can still get through, which is why
5
+ * reading credential files is also asked about (see the permission guard).
6
+ */
7
+ const HIDDEN = '[hidden secret]';
8
+ /** Keeps the first characters of a token (enough to recognise which key it is), hides the rest. */
9
+ const keepHead = (value, keep) => `${value.slice(0, keep)}…${HIDDEN}`;
10
+ const PATTERNS = [
11
+ // Private key blocks
12
+ { re: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$)/g, replace: () => `[hidden private key]` },
13
+ // sk-..., sk-or-v1-..., sk-ant-... (OpenAI, OpenRouter, Anthropic and compatible gateways)
14
+ { re: /\bsk-[A-Za-z0-9][A-Za-z0-9_\-]{15,}/g, replace: (m) => keepHead(m, 5) },
15
+ // GitHub tokens
16
+ { re: /\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,})/g, replace: (m) => keepHead(m, 4) },
17
+ // Slack tokens
18
+ { re: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g, replace: (m) => keepHead(m, 5) },
19
+ // AWS access key ids, Google API keys
20
+ { re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, replace: (m) => keepHead(m, 4) },
21
+ { re: /\bAIza[0-9A-Za-z_\-]{35}\b/g, replace: (m) => keepHead(m, 4) },
22
+ // Authorization headers
23
+ { re: /\b(Bearer|Basic)\s+[A-Za-z0-9._~+\/=\-]{16,}/g, replace: (_m, scheme) => `${scheme} ${HIDDEN}` },
24
+ // "apiKey": "value" and api_key=value style assignments (the name is kept, the value is hidden)
25
+ {
26
+ re: /((?:["']?)(?:api[_-]?key|apikey|secret|client[_-]?secret|access[_-]?token|auth[_-]?token|token|password|passwd)(?:["']?)\s*[:=]\s*["']?)([^\s"',}\]]{8,})(["']?)/gi,
27
+ replace: (_m, head, _value, tail) => `${head}${HIDDEN}${tail}`,
28
+ },
29
+ ];
30
+ export function redactSecrets(text) {
31
+ if (!text || typeof text !== 'string')
32
+ return text;
33
+ let out = text;
34
+ for (const { re, replace } of PATTERNS)
35
+ out = out.replace(re, replace);
36
+ return out;
37
+ }
38
+ /** Whether a text holds something that looks like a secret (for tests and notices). */
39
+ export function containsSecret(text) {
40
+ return redactSecrets(text) !== text;
41
+ }
@@ -0,0 +1,22 @@
1
+ import fs from 'fs/promises';
2
+ /**
3
+ * Writes a file that may contain secrets (API keys, conversation transcripts).
4
+ * New files are created with mode 0600 and existing files are tightened to 0600,
5
+ * so credentials are never left readable by other local users.
6
+ * On platforms without POSIX permissions (Windows) the chmod is a harmless no-op.
7
+ */
8
+ export async function writeSecureFile(filePath, data) {
9
+ await fs.writeFile(filePath, data, { encoding: 'utf8', mode: 0o600 });
10
+ try {
11
+ await fs.chmod(filePath, 0o600);
12
+ }
13
+ catch { }
14
+ }
15
+ /** Creates a private (0700) directory, tightening it if it already exists. */
16
+ export async function ensureSecureDir(dirPath) {
17
+ await fs.mkdir(dirPath, { recursive: true, mode: 0o700 });
18
+ try {
19
+ await fs.chmod(dirPath, 0o700);
20
+ }
21
+ catch { }
22
+ }
@@ -2,6 +2,7 @@ import fs from 'fs/promises';
2
2
  import fsSync from 'fs';
3
3
  import path from 'path';
4
4
  import os from 'os';
5
+ import { ensureSecureDir, writeSecureFile } from './secure-file.js';
5
6
  const SESSIONS_DIR = path.join(os.homedir(), '.devx', 'sessions');
6
7
  export class SessionManager {
7
8
  currentSession;
@@ -22,6 +23,14 @@ export class SessionManager {
22
23
  totalCost: 0
23
24
  };
24
25
  }
26
+ /** Names the chat (after the model made a title) and keeps it from being renamed by the first message again. */
27
+ setTitle(title) {
28
+ this.currentSession.title = title;
29
+ this.currentSession.titleGenerated = true;
30
+ }
31
+ get hasGeneratedTitle() {
32
+ return this.currentSession.titleGenerated === true;
33
+ }
25
34
  getSession() {
26
35
  return this.currentSession;
27
36
  }
@@ -29,12 +38,14 @@ export class SessionManager {
29
38
  this.currentSession = this.createEmptySession(model, planMode);
30
39
  return this.currentSession;
31
40
  }
32
- async save(messages, totalCost, model, planMode) {
33
- if (!fsSync.existsSync(SESSIONS_DIR)) {
34
- await fs.mkdir(SESSIONS_DIR, { recursive: true });
35
- }
41
+ async save(messages, totalCost, model, planMode, costEstimated) {
42
+ // Transcripts contain file contents and pasted images: keep them private to the user.
43
+ await ensureSecureDir(path.dirname(SESSIONS_DIR));
44
+ await ensureSecureDir(SESSIONS_DIR);
36
45
  this.currentSession.messages = messages;
37
46
  this.currentSession.totalCost = totalCost;
47
+ if (costEstimated !== undefined)
48
+ this.currentSession.costEstimated = costEstimated;
38
49
  this.currentSession.updatedAt = Date.now();
39
50
  if (model)
40
51
  this.currentSession.model = model;
@@ -51,7 +62,7 @@ export class SessionManager {
51
62
  }
52
63
  }
53
64
  const filePath = path.join(SESSIONS_DIR, `${this.currentSession.id}.json`);
54
- await fs.writeFile(filePath, JSON.stringify(this.currentSession, null, 2), 'utf8');
65
+ await writeSecureFile(filePath, JSON.stringify(this.currentSession, null, 2));
55
66
  }
56
67
  static async listSessions() {
57
68
  if (!fsSync.existsSync(SESSIONS_DIR)) {
@@ -1,6 +1,13 @@
1
1
  import fs from 'fs/promises';
2
2
  import fsSync from 'fs';
3
3
  import path from 'path';
4
+ /**
5
+ * Remembers the original state of every file changed through the file tools
6
+ * (write_file / edit_file) during a turn so that /undo can restore it.
7
+ *
8
+ * Limitations: snapshots live in memory only, and changes made by shell commands
9
+ * (the bash tool) are NOT tracked.
10
+ */
4
11
  export class SnapshotManager {
5
12
  history = [];
6
13
  currentTurn = null;
@@ -21,7 +28,7 @@ export class SnapshotManager {
21
28
  }
22
29
  try {
23
30
  if (fsSync.existsSync(resolved)) {
24
- const content = await fs.readFile(resolved, 'utf8');
31
+ const content = await fs.readFile(resolved);
25
32
  this.currentTurn.files.set(resolved, {
26
33
  filePath: resolved,
27
34
  existed: true,
@@ -60,7 +67,7 @@ export class SnapshotManager {
60
67
  if (!fsSync.existsSync(parentDir)) {
61
68
  await fs.mkdir(parentDir, { recursive: true });
62
69
  }
63
- await fs.writeFile(filePath, snap.content, 'utf8');
70
+ await fs.writeFile(filePath, snap.content);
64
71
  revertedFiles.push(relPath);
65
72
  }
66
73
  else {
@@ -80,5 +87,10 @@ export class SnapshotManager {
80
87
  getUndoCount() {
81
88
  return this.history.length;
82
89
  }
90
+ /** Forgets every snapshot (a new session cannot undo the edits of the previous one). */
91
+ reset() {
92
+ this.history = [];
93
+ this.currentTurn = null;
94
+ }
83
95
  }
84
96
  export const globalSnapshotManager = new SnapshotManager();
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The name of a chat: a few words made by the model from the first exchange, shown in the title of
3
+ * the terminal window and in the list of saved sessions. Everything here is a pure function except
4
+ * the one call to the model, which is given in (`chat`) so that it can be tested.
5
+ */
6
+ export const TITLE_SYMBOL = '◈';
7
+ export const BASE_TITLE = 'devx';
8
+ const MAX_TITLE_CHARS = 40;
9
+ /** What the model is asked: a short title in the language of the user, nothing else. */
10
+ export const TITLE_SYSTEM_PROMPT = 'You name chats. Write a title of 2 to 5 words for the conversation below, in the language the user writes in. ' +
11
+ 'No quotes, no trailing punctuation, no prefix such as "Title:". Output only the title.';
12
+ /** Cleans what the model returned: one line, no quotes or markup, short. Empty when nothing usable is left. */
13
+ export function cleanTitle(raw) {
14
+ let title = String(raw ?? '')
15
+ .replace(/<think>[\s\S]*?<\/think>/gi, '')
16
+ .split('\n')
17
+ .map((l) => l.trim())
18
+ .find((l) => l.length > 0) ?? '';
19
+ title = title
20
+ .replace(/^(?:title|название|заголовок)\s*[::-]\s*/i, '')
21
+ .replace(/^[\s"'`«»“”„*#_>\-]+|[\s"'`«»“”„*_.:;!?…]+$/g, '')
22
+ .replace(/\s+/g, ' ');
23
+ if (Array.from(title).length > MAX_TITLE_CHARS) {
24
+ const cut = Array.from(title).slice(0, MAX_TITLE_CHARS).join('');
25
+ title = cut.replace(/\s+\S*$/, '') || cut;
26
+ title = title.replace(/[\s,;:.\-]+$/, '');
27
+ }
28
+ return title;
29
+ }
30
+ /** A stand-in until the model has named the chat: the start of the first message. */
31
+ export function provisionalTitle(firstUserMessage) {
32
+ return cleanTitle(firstUserMessage.replace(/\s+/g, ' ').slice(0, 80));
33
+ }
34
+ /** Asks the model for the name. Never throws: on a failure or a timeout the answer is ''. */
35
+ export async function generateChatTitle(provider, firstUser, firstReply, timeoutMs = 20000) {
36
+ const sample = `User: ${firstUser.slice(0, 600)}\n\nAssistant: ${firstReply.slice(0, 600)}`;
37
+ try {
38
+ const answer = await Promise.race([
39
+ provider.chat({
40
+ messages: [
41
+ { role: 'system', content: TITLE_SYSTEM_PROMPT },
42
+ { role: 'user', content: sample },
43
+ ],
44
+ }),
45
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeoutMs).unref?.()),
46
+ ]);
47
+ return cleanTitle(answer.content);
48
+ }
49
+ catch {
50
+ return '';
51
+ }
52
+ }
53
+ // ───────────────────────── terminal window title ─────────────────────────
54
+ /** The escape sequence that sets the title of the terminal window (OSC 0): `◈ Short title`. */
55
+ export function terminalTitleSequence(title) {
56
+ const clean = title.replace(/[\x00-\x1f\x7f]/g, ' ').replace(/\s+/g, ' ').trim();
57
+ return `\x1b]0;${clean ? `${TITLE_SYMBOL} ${clean}` : BASE_TITLE}\x07`;
58
+ }
59
+ /** Remembers the title the terminal had (xterm title stack, also kitty, foot, wezterm), to give it back at exit. */
60
+ export const SAVE_TERMINAL_TITLE = '\x1b[22;0t';
61
+ export const RESTORE_TERMINAL_TITLE = '\x1b[23;0t';
@@ -1 +1 @@
1
- export const DEVX_VERSION = '1.6.0';
1
+ export const DEVX_VERSION = '1.8.0';