termux-dev 1.6.0 → 1.8.1

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 (99) 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 +101 -10
  5. package/dist/cli/banner.js +3 -4
  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/finder.js +241 -0
  15. package/dist/cli/headless.js +23 -11
  16. package/dist/cli/index.js +1557 -744
  17. package/dist/cli/keys.js +69 -0
  18. package/dist/cli/liveblock.js +45 -0
  19. package/dist/cli/log.js +19 -0
  20. package/dist/cli/logo.js +30 -0
  21. package/dist/cli/markdown.js +174 -37
  22. package/dist/cli/mcp-panel.js +432 -0
  23. package/dist/cli/permission.js +84 -0
  24. package/dist/cli/picker.js +143 -0
  25. package/dist/cli/prompt.js +256 -56
  26. package/dist/cli/resume.js +15 -0
  27. package/dist/cli/rewind.js +51 -0
  28. package/dist/cli/runtime_errors.js +6 -0
  29. package/dist/cli/server.js +85 -24
  30. package/dist/cli/settings.js +307 -0
  31. package/dist/cli/statusline.js +1 -1
  32. package/dist/cli/theme.js +126 -108
  33. package/dist/cli/transcript.js +134 -0
  34. package/dist/cli/typeahead.js +371 -0
  35. package/dist/cli/ui.js +1022 -0
  36. package/dist/cli/updater.js +23 -21
  37. package/dist/cli/usage-panel.js +242 -0
  38. package/dist/cli/welcome.js +56 -0
  39. package/dist/cli/width.js +178 -0
  40. package/dist/core/capabilities.js +433 -0
  41. package/dist/core/catalog-util.js +27 -0
  42. package/dist/core/checkpoint.js +90 -0
  43. package/dist/core/context-stats.js +29 -0
  44. package/dist/core/cost.js +191 -0
  45. package/dist/core/formats.js +199 -0
  46. package/dist/core/history.js +51 -1
  47. package/dist/core/hooks.js +79 -0
  48. package/dist/core/i18n.js +42 -0
  49. package/dist/core/local-config.js +69 -0
  50. package/dist/core/locales/de.js +265 -0
  51. package/dist/core/locales/en.js +276 -0
  52. package/dist/core/locales/es.js +265 -0
  53. package/dist/core/locales/fr.js +265 -0
  54. package/dist/core/locales/ja.js +265 -0
  55. package/dist/core/locales/pt.js +265 -0
  56. package/dist/core/locales/ru.js +265 -0
  57. package/dist/core/locales/uk.js +265 -0
  58. package/dist/core/locales/zh.js +265 -0
  59. package/dist/core/loop.js +125 -49
  60. package/dist/core/memory.js +39 -12
  61. package/dist/core/models.js +33 -20
  62. package/dist/core/network.js +12 -11
  63. package/dist/core/notify.js +54 -0
  64. package/dist/core/planfile.js +43 -0
  65. package/dist/core/price-catalog.json +1 -0
  66. package/dist/core/prices.js +249 -0
  67. package/dist/core/reasoning-catalog.json +1 -0
  68. package/dist/core/reasoning.js +393 -0
  69. package/dist/core/redact.js +41 -0
  70. package/dist/core/secure-file.js +22 -0
  71. package/dist/core/session.js +16 -5
  72. package/dist/core/snapshot.js +14 -2
  73. package/dist/core/title.js +61 -0
  74. package/dist/core/types.js +1 -1
  75. package/dist/core/usage.js +43 -7
  76. package/dist/core/vision-catalog.json +1 -0
  77. package/dist/core/vision.js +123 -0
  78. package/dist/mcp/client.js +36 -5
  79. package/dist/mcp/http-client.js +174 -0
  80. package/dist/mcp/manager.js +330 -93
  81. package/dist/permissions/guard.js +385 -117
  82. package/dist/prompts/builder.js +46 -38
  83. package/dist/providers/anthropic.js +517 -0
  84. package/dist/providers/index.js +18 -1
  85. package/dist/providers/openai.js +138 -125
  86. package/dist/providers/shared.js +116 -0
  87. package/dist/providers/toolguard.js +101 -0
  88. package/dist/tools/bash.js +141 -51
  89. package/dist/tools/diagnostics.js +76 -48
  90. package/dist/tools/fs.js +77 -5
  91. package/dist/tools/index.js +2 -1
  92. package/dist/tools/mcp.js +72 -0
  93. package/dist/tools/packages.js +16 -1
  94. package/dist/tools/plan.js +8 -8
  95. package/dist/tools/questions.js +50 -54
  96. package/dist/tools/server.js +6 -0
  97. package/dist/tools/todo.js +20 -18
  98. package/dist/tools/web.js +143 -8
  99. package/package.json +1 -1
@@ -0,0 +1,191 @@
1
+ /**
2
+ * What a request cost, and how sure we are about it.
3
+ *
4
+ * Sources, best first:
5
+ * reported the provider put the cost into its response (OpenRouter `usage.cost`, LiteLLM, ...)
6
+ * configured the user wrote the prices into the config ("prices")
7
+ * listed the endpoint's own model list carries the prices (OpenRouter, LiteLLM)
8
+ * estimated the models.dev catalogue price of the same model; a gateway may charge differently
9
+ * free a local server or a ":free" model
10
+ * When none of them knows the model the cost is left undefined: "unknown" is shown as such and
11
+ * never as $0.
12
+ */
13
+ const PER_MILLION = 1_000_000;
14
+ const finite = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0;
15
+ /** The cost of the tokens at the given rates. */
16
+ export function computeCost(rates, usage) {
17
+ const prompt = Math.max(0, usage.promptTokens || 0);
18
+ const tier = rates.tier && prompt > rates.tier.over ? rates.tier : null;
19
+ const input = tier?.input ?? rates.input;
20
+ const output = tier?.output ?? rates.output;
21
+ const cacheRead = tier ? (tier.cacheRead ?? rates.cacheRead) : rates.cacheRead;
22
+ const cacheWrite = tier ? (tier.cacheWrite ?? rates.cacheWrite) : rates.cacheWrite;
23
+ const read = Math.min(prompt, Math.max(0, usage.cacheReadTokens || 0));
24
+ const written = Math.min(prompt - read, Math.max(0, usage.cacheWriteTokens || 0));
25
+ const plain = prompt - read - written;
26
+ // Without a cache price the cached tokens cost as much as ordinary input.
27
+ let cost = plain * input + read * (cacheRead ?? input) + written * (cacheWrite ?? input);
28
+ const completion = Math.max(0, usage.completionTokens || 0);
29
+ const reasoning = Math.min(completion, Math.max(0, usage.reasoningTokens || 0));
30
+ if (rates.reasoning !== undefined && reasoning > 0) {
31
+ cost += (completion - reasoning) * output + reasoning * rates.reasoning;
32
+ }
33
+ else {
34
+ cost += completion * output;
35
+ }
36
+ return cost / PER_MILLION;
37
+ }
38
+ /** A price as the OpenRouter list writes it: USD per token, often a string. */
39
+ function perToken(value) {
40
+ const n = typeof value === 'string' ? Number(value) : value;
41
+ return finite(n) ? n * PER_MILLION : undefined;
42
+ }
43
+ /**
44
+ * Reads the prices from one entry of an endpoint's model list. Understands the OpenRouter
45
+ * schema (`pricing.prompt` / `completion` / `input_cache_read` in USD per token) and the
46
+ * LiteLLM schema (`input_cost_per_token`). Returns undefined when the entry has no prices.
47
+ * A negative price (OpenRouter's "variable") is not a price.
48
+ */
49
+ export function priceFromModelEntry(entry) {
50
+ if (!entry || typeof entry !== 'object')
51
+ return undefined;
52
+ const pricing = entry.pricing;
53
+ if (pricing && typeof pricing === 'object') {
54
+ const input = perToken(pricing.prompt ?? pricing.input);
55
+ const output = perToken(pricing.completion ?? pricing.output);
56
+ if (input !== undefined && output !== undefined) {
57
+ const rates = { input, output };
58
+ const read = perToken(pricing.input_cache_read ?? pricing.cache_read);
59
+ const write = perToken(pricing.input_cache_write ?? pricing.cache_write);
60
+ const reasoning = perToken(pricing.internal_reasoning);
61
+ if (read !== undefined)
62
+ rates.cacheRead = read;
63
+ if (write !== undefined)
64
+ rates.cacheWrite = write;
65
+ if (reasoning !== undefined && reasoning > 0 && reasoning !== output)
66
+ rates.reasoning = reasoning;
67
+ return rates;
68
+ }
69
+ }
70
+ const info = entry.model_info ?? entry;
71
+ const input = perToken(info.input_cost_per_token);
72
+ const output = perToken(info.output_cost_per_token);
73
+ if (input !== undefined && output !== undefined) {
74
+ const rates = { input, output };
75
+ const read = perToken(info.cache_read_input_token_cost);
76
+ const write = perToken(info.cache_creation_input_token_cost);
77
+ if (read !== undefined)
78
+ rates.cacheRead = read;
79
+ if (write !== undefined)
80
+ rates.cacheWrite = write;
81
+ return rates;
82
+ }
83
+ return undefined;
84
+ }
85
+ function numberOf(value) {
86
+ const n = typeof value === 'string' ? Number(value) : value;
87
+ return finite(n) ? n : undefined;
88
+ }
89
+ /**
90
+ * The cost the provider itself reported in the usage block (several spellings exist).
91
+ * Returns undefined when it did not report one.
92
+ */
93
+ export function reportedCost(usage) {
94
+ if (!usage || typeof usage !== 'object')
95
+ return undefined;
96
+ for (const key of ['cost', 'total_cost', 'cost_usd', 'estimated_cost']) {
97
+ const value = usage[key];
98
+ const direct = numberOf(value);
99
+ if (direct !== undefined)
100
+ return direct;
101
+ if (value && typeof value === 'object') {
102
+ const nested = numberOf(value.total ?? value.total_cost ?? value.usd);
103
+ if (nested !== undefined)
104
+ return nested;
105
+ }
106
+ }
107
+ return undefined;
108
+ }
109
+ export function isLocalBaseUrl(baseUrl) {
110
+ if (!baseUrl)
111
+ return false;
112
+ let host = '';
113
+ try {
114
+ host = new URL(baseUrl).hostname.toLowerCase();
115
+ }
116
+ catch {
117
+ return false;
118
+ }
119
+ return (host === 'localhost' || host === '[::1]' || host === '::1' ||
120
+ /^127\./.test(host) || /^10\./.test(host) || /^192\.168\./.test(host) || /^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
121
+ host.endsWith('.local'));
122
+ }
123
+ export function isFreeModel(model, baseUrl) {
124
+ const id = (model || '').toLowerCase().trim();
125
+ return id.endsWith(':free') || isLocalBaseUrl(baseUrl);
126
+ }
127
+ /**
128
+ * "$0.0123" exact, "~$0.0123" estimated, "$0.0123+" when some requests are missing,
129
+ * "n/a" when nothing could be priced at all.
130
+ */
131
+ export function formatMoney(cost, state = {}) {
132
+ const unpriced = state.unpriced ?? 0;
133
+ const priced = state.priced ?? (cost > 0 ? 1 : 0);
134
+ if (unpriced > 0 && priced === 0 && cost === 0)
135
+ return 'n/a';
136
+ const digits = cost >= 1 ? 2 : 4;
137
+ let text = `$${cost.toFixed(digits)}`;
138
+ if (state.estimated)
139
+ text = `~${text}`;
140
+ if (unpriced > 0)
141
+ text += '+';
142
+ return text;
143
+ }
144
+ /** One line that says what the number means (for /usage). */
145
+ export function describeCostState(state) {
146
+ const parts = [];
147
+ if (state.estimated)
148
+ parts.push('estimated from catalogue prices');
149
+ if ((state.unpriced ?? 0) > 0)
150
+ parts.push(`${state.unpriced} request${state.unpriced === 1 ? '' : 's'} without a known price`);
151
+ return parts.join(', ');
152
+ }
153
+ /** Adds up the cost of the requests of a session. */
154
+ export class CostLedger {
155
+ total = 0;
156
+ estimated = false;
157
+ unpriced = 0;
158
+ priced = 0;
159
+ add(usage) {
160
+ if (!usage)
161
+ return;
162
+ if (typeof usage.cost === 'number' && Number.isFinite(usage.cost)) {
163
+ this.total += usage.cost;
164
+ this.priced++;
165
+ if (usage.costSource === 'estimated')
166
+ this.estimated = true;
167
+ }
168
+ else {
169
+ this.unpriced++;
170
+ }
171
+ }
172
+ reset() {
173
+ this.total = 0;
174
+ this.estimated = false;
175
+ this.unpriced = 0;
176
+ this.priced = 0;
177
+ }
178
+ /** Restores a saved session (only the total was stored; it is shown as an estimate if it was one). */
179
+ restore(total, estimated = false) {
180
+ this.reset();
181
+ this.total = Number.isFinite(total) ? total : 0;
182
+ this.estimated = estimated;
183
+ this.priced = this.total > 0 ? 1 : 0;
184
+ }
185
+ state() {
186
+ return { estimated: this.estimated, unpriced: this.unpriced, priced: this.priced };
187
+ }
188
+ format() {
189
+ return formatMoney(this.total, this.state());
190
+ }
191
+ }
@@ -0,0 +1,199 @@
1
+ import path from 'path';
2
+ import { devxDir, readJsonFile } from './catalog-util.js';
3
+ import { ensureSecureDir, writeSecureFile } from './secure-file.js';
4
+ import { isLocalBaseUrl } from './cost.js';
5
+ import { detectRoute, setRouteResolver } from './reasoning.js';
6
+ export const API_FORMATS = ['openai', 'anthropic'];
7
+ export function isApiFormat(value) {
8
+ return value === 'openai' || value === 'anthropic';
9
+ }
10
+ /** The format a base URL names by itself, if it does. */
11
+ export function formatFromBaseUrl(baseUrl) {
12
+ if (!baseUrl)
13
+ return null;
14
+ try {
15
+ const u = new URL(baseUrl);
16
+ if (u.hostname.toLowerCase() === 'api.anthropic.com')
17
+ return 'anthropic';
18
+ if (u.pathname.toLowerCase().split('/').includes('anthropic'))
19
+ return 'anthropic';
20
+ }
21
+ catch { }
22
+ return null;
23
+ }
24
+ /**
25
+ * The part of a base URL that both formats share: "https://host/v1" and "https://host" give
26
+ * "https://host". Returns null for URLs with another path (a gateway with its own layout),
27
+ * which are not probed for a second format.
28
+ */
29
+ export function gatewayRoot(baseUrl) {
30
+ try {
31
+ const u = new URL(baseUrl);
32
+ const rest = u.pathname.replace(/\/+$/, '');
33
+ if (rest !== '' && rest !== '/v1')
34
+ return null;
35
+ return u.origin;
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ const POSITIVE_TTL_MS = 24 * 60 * 60 * 1000;
42
+ const NEGATIVE_TTL_MS = 6 * 60 * 60 * 1000;
43
+ export class FormatStore {
44
+ entries = new Map();
45
+ static key(root) {
46
+ return root.replace(/\/+$/, '').toLowerCase();
47
+ }
48
+ get(root) {
49
+ return this.entries.get(FormatStore.key(root));
50
+ }
51
+ set(root, record) {
52
+ this.entries.set(FormatStore.key(root), record);
53
+ }
54
+ clear() {
55
+ this.entries.clear();
56
+ }
57
+ isFresh(root, now = Date.now()) {
58
+ const record = this.get(root);
59
+ if (!record)
60
+ return false;
61
+ return now - record.at < (record.anthropic.length > 0 ? POSITIVE_TTL_MS : NEGATIVE_TTL_MS);
62
+ }
63
+ toJSON() {
64
+ return { version: 1, gateways: Object.fromEntries(this.entries) };
65
+ }
66
+ load(raw) {
67
+ if (!raw || raw.version !== 1 || typeof raw.gateways !== 'object')
68
+ return;
69
+ for (const [key, r] of Object.entries(raw.gateways)) {
70
+ if (r && typeof r.at === 'number' && Array.isArray(r.openai) && Array.isArray(r.anthropic)) {
71
+ this.entries.set(key, { at: r.at, openai: r.openai.map(String), anthropic: r.anthropic.map(String) });
72
+ }
73
+ }
74
+ }
75
+ }
76
+ export const formatStore = new FormatStore();
77
+ const lower = (s) => s.toLowerCase().trim();
78
+ /**
79
+ * Which format a request for this model uses. Decided without network access, from the base
80
+ * URL and what discovery remembered.
81
+ */
82
+ export function resolveFormat(config) {
83
+ if (isApiFormat(config.apiFormat))
84
+ return config.apiFormat;
85
+ const named = formatFromBaseUrl(config.baseUrl);
86
+ if (named)
87
+ return named;
88
+ const root = config.baseUrl ? gatewayRoot(config.baseUrl) : null;
89
+ const record = root ? formatStore.get(root) : undefined;
90
+ const model = lower(config.model || '');
91
+ if (!record || !model)
92
+ return 'openai';
93
+ const inOpenAI = record.openai.some((id) => lower(id) === model);
94
+ const inAnthropic = record.anthropic.some((id) => lower(id) === model);
95
+ if (inAnthropic && !inOpenAI)
96
+ return 'anthropic';
97
+ if (inAnthropic && inOpenAI)
98
+ return /claude/.test(model) ? 'anthropic' : 'openai';
99
+ if (inOpenAI)
100
+ return 'openai';
101
+ // A model typed by hand: Claude models of a gateway that lists them only in the Anthropic format.
102
+ if (/^claude/.test(model) && record.anthropic.length > 0 && !record.openai.some((id) => /claude/i.test(id)))
103
+ return 'anthropic';
104
+ return 'openai';
105
+ }
106
+ /** The reasoning parameters are written for the route; the Anthropic format always uses its own. */
107
+ export function routeFor(config) {
108
+ return resolveFormat(config) === 'anthropic' ? 'anthropic' : undefined;
109
+ }
110
+ function idsOf(body) {
111
+ const items = Array.isArray(body) ? body : Array.isArray(body?.data) ? body.data : Array.isArray(body?.models) ? body.models : [];
112
+ return items.map((m) => (typeof m === 'string' ? m : m?.id ?? m?.name)).filter((id) => typeof id === 'string' && id.length > 0);
113
+ }
114
+ /**
115
+ * Asks the gateway for its model list in both formats (two small GET requests to the user's own
116
+ * base URL, with the user's own key). Returns null when the URL is not a plain gateway.
117
+ */
118
+ export async function discoverFormats(input) {
119
+ const root = gatewayRoot(input.baseUrl);
120
+ if (!root)
121
+ return null;
122
+ const doFetch = input.fetchImpl ?? fetch;
123
+ const timeout = input.timeoutMs ?? 4000;
124
+ const url = `${root}/v1/models`;
125
+ const list = async (headers) => {
126
+ try {
127
+ const res = await doFetch(url, { headers, signal: AbortSignal.timeout(timeout) });
128
+ return res.ok ? idsOf(await res.json()) : null;
129
+ }
130
+ catch {
131
+ return null;
132
+ }
133
+ };
134
+ const key = input.apiKey && input.apiKey !== 'ollama' ? input.apiKey : '';
135
+ const [asOpenAI, asAnthropic] = await Promise.all([
136
+ list(key ? { Authorization: `Bearer ${key}` } : {}),
137
+ list({ 'anthropic-version': '2023-06-01', ...(key ? { 'x-api-key': key } : {}) }),
138
+ ]);
139
+ if (asOpenAI === null && asAnthropic === null)
140
+ return null;
141
+ const openai = asOpenAI ?? [];
142
+ const known = new Set(openai.map(lower));
143
+ // A gateway that ignores the Anthropic headers answers with the same list: no second format.
144
+ const only = (asAnthropic ?? []).filter((id) => !known.has(lower(id)));
145
+ const anthropic = asOpenAI === null ? (asAnthropic ?? []) : only;
146
+ return { at: Date.now(), openai, anthropic };
147
+ }
148
+ /** Models that are reachable only through the Anthropic format (for the model picker). */
149
+ export function anthropicOnlyModels(baseUrl) {
150
+ const root = baseUrl ? gatewayRoot(baseUrl) : null;
151
+ return (root ? formatStore.get(root)?.anthropic : undefined) ?? [];
152
+ }
153
+ // ───────────────────────── remembering the answers ─────────────────────────
154
+ export function loadFormatCache(dir = devxDir()) {
155
+ formatStore.load(readJsonFile(path.join(dir, 'formats.json')));
156
+ }
157
+ async function saveFormatCache(dir) {
158
+ try {
159
+ await ensureSecureDir(dir);
160
+ await writeSecureFile(path.join(dir, 'formats.json'), JSON.stringify(formatStore.toJSON()));
161
+ }
162
+ catch { }
163
+ }
164
+ const inflight = new Map();
165
+ /**
166
+ * Makes sure a custom gateway has been asked about its formats (cached for a day). Vendors with
167
+ * a single format, local servers and `"probeEndpoints": false` are never asked.
168
+ */
169
+ export async function refreshFormats(config, options = {}) {
170
+ const baseUrl = config.baseUrl || '';
171
+ if (!baseUrl || config.probeEndpoints === false || isApiFormat(config.apiFormat))
172
+ return null;
173
+ if (formatFromBaseUrl(baseUrl) || isLocalBaseUrl(baseUrl) || detectRoute(baseUrl) !== 'generic')
174
+ return null;
175
+ const root = gatewayRoot(baseUrl);
176
+ if (!root)
177
+ return null;
178
+ if (!options.force && formatStore.isFresh(root))
179
+ return formatStore.get(root) ?? null;
180
+ const pending = inflight.get(root);
181
+ if (pending)
182
+ return pending;
183
+ const job = (async () => {
184
+ const record = await discoverFormats({ baseUrl, apiKey: config.apiKey, fetchImpl: options.fetchImpl, timeoutMs: options.timeoutMs });
185
+ if (!record)
186
+ return null;
187
+ formatStore.set(root, record);
188
+ if (options.persist !== false)
189
+ await saveFormatCache(options.dir ?? devxDir());
190
+ return record;
191
+ })().finally(() => inflight.delete(root));
192
+ inflight.set(root, job);
193
+ return job;
194
+ }
195
+ /** Loads what was learned earlier and lets the reasoning settings follow the format. */
196
+ export function installFormats(dir = devxDir()) {
197
+ loadFormatCache(dir);
198
+ setRouteResolver((config) => routeFor(config));
199
+ }
@@ -1,9 +1,27 @@
1
+ const IMAGE_TOKEN_ESTIMATE = 1500;
2
+ // Last-resort size for tool output that is still too large once everything
3
+ // that can be dropped has been dropped.
4
+ const EMERGENCY_TOOL_CHARS = 4000;
5
+ function shortenMiddle(text, maxChars) {
6
+ if (text.length <= maxChars)
7
+ return text;
8
+ const head = Math.floor(maxChars * 0.4);
9
+ const tail = maxChars - head;
10
+ return `${text.slice(0, head)}\n[... ${text.length - maxChars} characters removed to fit the context window ...]\n${text.slice(text.length - tail)}`;
11
+ }
1
12
  export class History {
2
13
  messages = [];
14
+ /** How often old messages had to be shortened or dropped, and how many were dropped (for /usage). */
15
+ compactions = 0;
16
+ droppedMessages = 0;
3
17
  constructor() { }
4
18
  addMessage(msg) {
5
19
  this.messages.push(msg);
6
20
  }
21
+ /** Forgets the message at `index` and everything after it (the system prompt at 0 stays). */
22
+ rewindBefore(index) {
23
+ this.messages = this.messages.slice(0, Math.max(1, index));
24
+ }
7
25
  updateSystemPrompt(content) {
8
26
  const idx = this.messages.findIndex(m => m.role === 'system');
9
27
  if (idx >= 0) {
@@ -28,6 +46,8 @@ export class History {
28
46
  }
29
47
  clear() {
30
48
  this.messages = [];
49
+ this.compactions = 0;
50
+ this.droppedMessages = 0;
31
51
  }
32
52
  getTotalTokens() {
33
53
  return this.estimateTokens(this.messages);
@@ -51,13 +71,21 @@ export class History {
51
71
  t += 1.2;
52
72
  }
53
73
  else {
54
- t += 0.25;
74
+ // Source code and JSON average ~3 characters per token; 0.25 (4 chars/token)
75
+ // under-counts them and lets requests overflow the context window.
76
+ t += 0.33;
55
77
  }
56
78
  }
57
79
  return t;
58
80
  };
59
81
  for (const m of messages) {
60
82
  tokens += countStringTokens(m.content || '');
83
+ if (m.images && m.images.length > 0) {
84
+ tokens += m.images.length * IMAGE_TOKEN_ESTIMATE;
85
+ }
86
+ for (const t of m.thinking_blocks ?? []) {
87
+ tokens += countStringTokens(t.type === 'thinking' ? t.thinking : t.data);
88
+ }
61
89
  if (m.tool_calls) {
62
90
  for (const tc of m.tool_calls) {
63
91
  tokens += countStringTokens(tc.name);
@@ -129,6 +157,7 @@ export class History {
129
157
  }
130
158
  if (this.estimateTokens(blocks.flat()) <= maxTokens) {
131
159
  this.messages = blocks.flat();
160
+ this.compactions++;
132
161
  return true;
133
162
  }
134
163
  // Фаза 2: Удаление незащищенных блоков целиком (от старых к новым)
@@ -143,7 +172,28 @@ export class History {
143
172
  blocks[i] = []; // помечаем как удаленный
144
173
  }
145
174
  }
175
+ // Фаза 3: остались только защищённые блоки, но они всё ещё не помещаются
176
+ // (например, огромный результат инструмента в последнем ходе). Сокращаем
177
+ // содержимое tool-сообщений, начиная со старых.
178
+ if (currentTokens > maxTokens) {
179
+ for (const block of blocks) {
180
+ if (currentTokens <= maxTokens)
181
+ break;
182
+ for (const msg of block) {
183
+ if (currentTokens <= maxTokens)
184
+ break;
185
+ if (msg.role === 'tool' && msg.content && msg.content.length > EMERGENCY_TOOL_CHARS) {
186
+ const before = this.estimateTokens([msg]);
187
+ msg.content = shortenMiddle(msg.content, EMERGENCY_TOOL_CHARS);
188
+ currentTokens -= before - this.estimateTokens([msg]);
189
+ }
190
+ }
191
+ }
192
+ }
193
+ const before = this.messages.length;
146
194
  this.messages = blocks.filter(b => b.length > 0).flat();
195
+ this.compactions++;
196
+ this.droppedMessages += before - this.messages.length;
147
197
  return true;
148
198
  }
149
199
  }
@@ -0,0 +1,79 @@
1
+ import { spawn } from 'child_process';
2
+ const DEFAULT_TIMEOUT_S = 10;
3
+ const MAX_OUTPUT = 4000;
4
+ /** The entries of an event that apply to a tool. A matcher that is not a valid expression matches nothing. */
5
+ export function matchingHooks(entries, toolName) {
6
+ if (!Array.isArray(entries))
7
+ return [];
8
+ return entries.filter((e) => {
9
+ if (!e || typeof e.command !== 'string' || !e.command.trim())
10
+ return false;
11
+ if (!e.matcher || e.matcher === '*')
12
+ return true;
13
+ try {
14
+ return new RegExp(`^(?:${e.matcher})$`).test(toolName);
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ });
20
+ }
21
+ export const runHookProcess = (entry, payload, env) => new Promise((resolve) => {
22
+ const isWindows = process.platform === 'win32';
23
+ const child = spawn(isWindows ? 'cmd' : 'sh', isWindows ? ['/c', entry.command] : ['-c', entry.command], {
24
+ env: { ...process.env, ...env },
25
+ stdio: ['pipe', 'pipe', 'pipe'],
26
+ });
27
+ let stdout = '';
28
+ let stderr = '';
29
+ let timedOut = false;
30
+ let settled = false;
31
+ const finish = (code) => {
32
+ if (settled)
33
+ return;
34
+ settled = true;
35
+ clearTimeout(timer);
36
+ resolve({ code, stdout: stdout.slice(0, MAX_OUTPUT), stderr: stderr.slice(0, MAX_OUTPUT), timedOut });
37
+ };
38
+ const timer = setTimeout(() => {
39
+ timedOut = true;
40
+ try {
41
+ child.kill('SIGKILL');
42
+ }
43
+ catch { }
44
+ finish(null);
45
+ }, (entry.timeout && entry.timeout > 0 ? entry.timeout : DEFAULT_TIMEOUT_S) * 1000);
46
+ child.stdout.on('data', (d) => { if (stdout.length < MAX_OUTPUT)
47
+ stdout += d; });
48
+ child.stderr.on('data', (d) => { if (stderr.length < MAX_OUTPUT)
49
+ stderr += d; });
50
+ child.on('error', () => finish(null));
51
+ child.on('close', (code) => finish(code));
52
+ child.stdin.on('error', () => { });
53
+ child.stdin.end(JSON.stringify(payload));
54
+ });
55
+ export async function runHooks(hooks, event, toolName, toolInput, toolResult, run = runHookProcess) {
56
+ const outcome = { notes: [] };
57
+ for (const entry of matchingHooks(hooks?.[event], toolName)) {
58
+ const payload = { event, tool_name: toolName, tool_input: toolInput };
59
+ if (toolResult !== undefined)
60
+ payload.tool_result = toolResult;
61
+ const result = await run(entry, payload, { DEVX_HOOK_EVENT: event, DEVX_TOOL_NAME: toolName });
62
+ const said = (result.stderr || result.stdout).trim();
63
+ if (result.timedOut) {
64
+ outcome.notes.push(`hook "${entry.command}" timed out`);
65
+ }
66
+ else if (result.code === 2) {
67
+ if (event === 'PreToolUse') {
68
+ outcome.blocked = said || `blocked by the hook "${entry.command}"`;
69
+ return outcome;
70
+ }
71
+ if (said)
72
+ outcome.notes.push(said);
73
+ }
74
+ else if (result.code !== 0) {
75
+ outcome.notes.push(`hook "${entry.command}" failed (exit ${result.code ?? 'none'})${said ? ': ' + said : ''}`);
76
+ }
77
+ }
78
+ return outcome;
79
+ }
@@ -0,0 +1,42 @@
1
+ import { en } from './locales/en.js';
2
+ import { ru } from './locales/ru.js';
3
+ import { uk } from './locales/uk.js';
4
+ import { de } from './locales/de.js';
5
+ import { es } from './locales/es.js';
6
+ import { fr } from './locales/fr.js';
7
+ import { pt } from './locales/pt.js';
8
+ import { zh } from './locales/zh.js';
9
+ import { ja } from './locales/ja.js';
10
+ export const LANGUAGES = [
11
+ { id: 'en', name: 'English' },
12
+ { id: 'ru', name: 'Русский' },
13
+ { id: 'uk', name: 'Українська' },
14
+ { id: 'de', name: 'Deutsch' },
15
+ { id: 'es', name: 'Español' },
16
+ { id: 'fr', name: 'Français' },
17
+ { id: 'pt', name: 'Português' },
18
+ { id: 'zh', name: 'Chinese' },
19
+ { id: 'ja', name: 'Japanese' },
20
+ ];
21
+ export const LANGUAGE_IDS = LANGUAGES.map((l) => l.id);
22
+ export const DEFAULT_LANGUAGE = 'en';
23
+ const CATALOGS = { en, ru, uk, de, es, fr, pt, zh, ja };
24
+ let current = DEFAULT_LANGUAGE;
25
+ /** Switches the language. An unknown id (a typo in the config file) means English. */
26
+ export function setLanguage(id) {
27
+ current = id && CATALOGS[id] ? id : DEFAULT_LANGUAGE;
28
+ return current;
29
+ }
30
+ export function getLanguage() {
31
+ return current;
32
+ }
33
+ export function languageName(id) {
34
+ return LANGUAGES.find((l) => l.id === id)?.name ?? LANGUAGES[0].name;
35
+ }
36
+ /** The text for a key in the current language, with `{name}` replaced from `vars`. */
37
+ export function t(key, vars) {
38
+ const text = CATALOGS[current]?.[key] ?? en[key];
39
+ if (!vars)
40
+ return text;
41
+ return text.replace(/\{(\w+)\}/g, (whole, name) => (name in vars ? String(vars[name]) : whole));
42
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Applies a project-level config file (.devx.json / .devxrc.json found in the
3
+ * working directory) on top of the user's own configuration.
4
+ *
5
+ * That file comes from the repository, i.e. from whoever wrote the repository, so:
6
+ * - harmless tuning (model, limits, theme) is always applied;
7
+ * - everything that decides WHERE the conversation and the API key are sent
8
+ * (provider, apiKey, baseUrl) or that loosens the permission guard
9
+ * (autoApprove, bashAllowlist) is applied only for a workspace the user trusted.
10
+ *
11
+ * Without that split a cloned repository could set `baseUrl` to its own server and
12
+ * receive the user's API key together with the contents of their files.
13
+ *
14
+ * Returns the names of the settings that were ignored.
15
+ */
16
+ export function applyLocalConfig(target, local, trusted) {
17
+ const ignored = [];
18
+ if (!local || typeof local !== 'object')
19
+ return ignored;
20
+ if (local.model)
21
+ target.model = local.model;
22
+ if (local.maxIterations)
23
+ target.maxIterations = local.maxIterations;
24
+ if (local.maxContextTokens)
25
+ target.maxContextTokens = local.maxContextTokens;
26
+ if (local.dataSaverLimitMB)
27
+ target.dataSaverLimitMB = local.dataSaverLimitMB;
28
+ if (local.pureBlackTheme !== undefined)
29
+ target.pureBlackTheme = local.pureBlackTheme;
30
+ const elevated = [
31
+ ['provider', !!local.provider],
32
+ ['apiKey', !!local.apiKey],
33
+ ['baseUrl', !!local.baseUrl],
34
+ ['autoApprove', local.autoApprove !== undefined],
35
+ ['bashAllowlist', Array.isArray(local.bashAllowlist)],
36
+ ['hooks', local.hooks !== undefined],
37
+ ];
38
+ for (const [key, present] of elevated) {
39
+ if (!present)
40
+ continue;
41
+ if (trusted) {
42
+ target[key] = local[key];
43
+ }
44
+ else {
45
+ ignored.push(key);
46
+ }
47
+ }
48
+ return ignored;
49
+ }
50
+ /**
51
+ * Settings that last for one run only and are never written to ~/.devxrc.json.
52
+ * - `autoApprove` (YOLO): switched on in the settings or with `--yolo`, it is off again the
53
+ * next time devx starts. A mode that skips confirmations should not stay on by accident.
54
+ * - `projectMcpTrusted`, `effortOverride`: runtime flags.
55
+ */
56
+ export const SESSION_ONLY_SETTINGS = ['autoApprove', 'projectMcpTrusted', 'effortOverride'];
57
+ /** The config as it goes to disk: without the settings that last for one run only. */
58
+ export function persistedConfig(config) {
59
+ const out = { ...config };
60
+ for (const key of SESSION_ONLY_SETTINGS)
61
+ delete out[key];
62
+ return out;
63
+ }
64
+ /** The config as it was read from disk: a value left there by an older version is dropped. */
65
+ export function withoutSessionOnlySettings(config) {
66
+ const out = { ...config };
67
+ delete out.autoApprove;
68
+ return out;
69
+ }