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.
- package/README.md +214 -166
- package/assets/banner.svg +23 -28
- package/assets/preview.png +0 -0
- package/assets/preview.svg +92 -10
- package/dist/cli/banner.js +1 -1
- package/dist/cli/clipboard-write.js +55 -0
- package/dist/cli/clipboard.js +182 -106
- package/dist/cli/colors.js +67 -0
- package/dist/cli/confirm.js +50 -0
- package/dist/cli/doctor.js +4 -4
- package/dist/cli/effort.js +80 -0
- package/dist/cli/export.js +3 -3
- package/dist/cli/files.js +19 -5
- package/dist/cli/headless.js +23 -11
- package/dist/cli/index.js +1377 -480
- package/dist/cli/keys.js +69 -0
- package/dist/cli/liveblock.js +45 -0
- package/dist/cli/log.js +19 -0
- package/dist/cli/markdown.js +158 -36
- package/dist/cli/mcp-panel.js +432 -0
- package/dist/cli/permission.js +84 -0
- package/dist/cli/picker.js +135 -0
- package/dist/cli/prompt.js +256 -56
- package/dist/cli/resume.js +15 -0
- package/dist/cli/rewind.js +51 -0
- package/dist/cli/runtime_errors.js +6 -0
- package/dist/cli/server.js +85 -24
- package/dist/cli/settings.js +307 -0
- package/dist/cli/statusline.js +1 -1
- package/dist/cli/theme.js +126 -108
- package/dist/cli/transcript.js +114 -0
- package/dist/cli/typeahead.js +371 -0
- package/dist/cli/ui.js +1021 -0
- package/dist/cli/updater.js +23 -21
- package/dist/cli/usage-panel.js +242 -0
- package/dist/cli/width.js +178 -0
- package/dist/core/capabilities.js +433 -0
- package/dist/core/catalog-util.js +27 -0
- package/dist/core/checkpoint.js +90 -0
- package/dist/core/context-stats.js +29 -0
- package/dist/core/cost.js +191 -0
- package/dist/core/formats.js +199 -0
- package/dist/core/history.js +51 -1
- package/dist/core/hooks.js +79 -0
- package/dist/core/i18n.js +42 -0
- package/dist/core/local-config.js +69 -0
- package/dist/core/locales/de.js +210 -0
- package/dist/core/locales/en.js +221 -0
- package/dist/core/locales/es.js +210 -0
- package/dist/core/locales/fr.js +210 -0
- package/dist/core/locales/ja.js +210 -0
- package/dist/core/locales/pt.js +210 -0
- package/dist/core/locales/ru.js +210 -0
- package/dist/core/locales/uk.js +210 -0
- package/dist/core/locales/zh.js +210 -0
- package/dist/core/loop.js +125 -49
- package/dist/core/memory.js +39 -12
- package/dist/core/models.js +33 -20
- package/dist/core/network.js +12 -11
- package/dist/core/notify.js +54 -0
- package/dist/core/planfile.js +43 -0
- package/dist/core/price-catalog.json +1 -0
- package/dist/core/prices.js +249 -0
- package/dist/core/reasoning-catalog.json +1 -0
- package/dist/core/reasoning.js +393 -0
- package/dist/core/redact.js +41 -0
- package/dist/core/secure-file.js +22 -0
- package/dist/core/session.js +16 -5
- package/dist/core/snapshot.js +14 -2
- package/dist/core/title.js +61 -0
- package/dist/core/types.js +1 -1
- package/dist/core/usage.js +43 -7
- package/dist/core/vision-catalog.json +1 -0
- package/dist/core/vision.js +123 -0
- package/dist/mcp/client.js +36 -5
- package/dist/mcp/http-client.js +174 -0
- package/dist/mcp/manager.js +330 -93
- package/dist/permissions/guard.js +385 -117
- package/dist/prompts/builder.js +46 -38
- package/dist/providers/anthropic.js +517 -0
- package/dist/providers/index.js +18 -1
- package/dist/providers/openai.js +138 -125
- package/dist/providers/shared.js +116 -0
- package/dist/providers/toolguard.js +101 -0
- package/dist/tools/bash.js +141 -51
- package/dist/tools/diagnostics.js +76 -48
- package/dist/tools/fs.js +77 -5
- package/dist/tools/index.js +2 -1
- package/dist/tools/mcp.js +72 -0
- package/dist/tools/packages.js +16 -1
- package/dist/tools/plan.js +8 -8
- package/dist/tools/questions.js +50 -54
- package/dist/tools/server.js +6 -0
- package/dist/tools/todo.js +20 -18
- package/dist/tools/web.js +143 -8
- package/package.json +1 -1
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import fsp from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { ensureSecureDir, writeSecureFile } from './secure-file.js';
|
|
5
|
+
import { NATIVE_PROVIDERS, devxDir, normalizeModelKey, readJsonFile } from './catalog-util.js';
|
|
6
|
+
import { installFormats } from './formats.js';
|
|
7
|
+
import { loadUserVision, slimVision, storeVision } from './vision.js';
|
|
8
|
+
import { priceFromModelEntry } from './cost.js';
|
|
9
|
+
import { loadUserPrices, setEndpointPriceLookup, slimPrices, storePrices } from './prices.js';
|
|
10
|
+
import { detectRoute, setReasoningDataLookup } from './reasoning.js';
|
|
11
|
+
export { devxDir, normalizeModelKey };
|
|
12
|
+
const CATALOG_URL = 'https://models.dev/api.json';
|
|
13
|
+
function sanitizeOptions(raw) {
|
|
14
|
+
if (!Array.isArray(raw))
|
|
15
|
+
return [];
|
|
16
|
+
const out = [];
|
|
17
|
+
for (const option of raw) {
|
|
18
|
+
if (!option || typeof option !== 'object')
|
|
19
|
+
continue;
|
|
20
|
+
const o = option;
|
|
21
|
+
if (o.type === 'effort' && Array.isArray(o.values)) {
|
|
22
|
+
out.push({ type: 'effort', values: o.values.filter((v) => typeof v === 'string') });
|
|
23
|
+
}
|
|
24
|
+
else if (o.type === 'toggle') {
|
|
25
|
+
out.push({ type: 'toggle' });
|
|
26
|
+
}
|
|
27
|
+
else if (o.type === 'budget_tokens') {
|
|
28
|
+
const budget = { type: 'budget_tokens' };
|
|
29
|
+
if (typeof o.min === 'number')
|
|
30
|
+
budget.min = o.min;
|
|
31
|
+
if (typeof o.max === 'number')
|
|
32
|
+
budget.max = o.max;
|
|
33
|
+
out.push(budget);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
/** Reduces the models.dev API response to the reasoning options of each model. */
|
|
39
|
+
export function slimCatalog(api, now = new Date()) {
|
|
40
|
+
const shapes = [];
|
|
41
|
+
const shapeIndex = new Map();
|
|
42
|
+
const models = {};
|
|
43
|
+
for (const [providerId, provider] of Object.entries(api || {})) {
|
|
44
|
+
for (const [modelId, model] of Object.entries(provider?.models || {})) {
|
|
45
|
+
const options = sanitizeOptions(model?.reasoning_options);
|
|
46
|
+
if (options.length === 0)
|
|
47
|
+
continue;
|
|
48
|
+
const key = JSON.stringify(options);
|
|
49
|
+
let index = shapeIndex.get(key);
|
|
50
|
+
if (index === undefined) {
|
|
51
|
+
index = shapes.length;
|
|
52
|
+
shapes.push(options);
|
|
53
|
+
shapeIndex.set(key, index);
|
|
54
|
+
}
|
|
55
|
+
(models[providerId] ||= {})[modelId.toLowerCase()] = index;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { version: 1, generated: now.toISOString().slice(0, 10), source: CATALOG_URL, shapes, models };
|
|
59
|
+
}
|
|
60
|
+
/** What the options of a catalogue entry mean. */
|
|
61
|
+
export function dataFromOptions(options, source = 'catalog') {
|
|
62
|
+
const data = { source };
|
|
63
|
+
for (const option of options) {
|
|
64
|
+
if (option.type === 'effort')
|
|
65
|
+
data.efforts = [...new Set([...(data.efforts ?? []), ...option.values])];
|
|
66
|
+
else if (option.type === 'toggle')
|
|
67
|
+
data.toggle = true;
|
|
68
|
+
else if (option.type === 'budget_tokens' && !data.budget)
|
|
69
|
+
data.budget = { min: option.min, max: option.max };
|
|
70
|
+
}
|
|
71
|
+
return data;
|
|
72
|
+
}
|
|
73
|
+
export class Catalog {
|
|
74
|
+
byProvider = new Map();
|
|
75
|
+
normalized = new Map();
|
|
76
|
+
byName = new Map();
|
|
77
|
+
generated;
|
|
78
|
+
size;
|
|
79
|
+
constructor(file) {
|
|
80
|
+
this.generated = file?.generated ?? '';
|
|
81
|
+
let size = 0;
|
|
82
|
+
if (file && file.version === 1) {
|
|
83
|
+
for (const [provider, models] of Object.entries(file.models)) {
|
|
84
|
+
const raw = new Map();
|
|
85
|
+
const norm = new Map();
|
|
86
|
+
for (const [id, shapeIndex] of Object.entries(models)) {
|
|
87
|
+
const options = file.shapes[shapeIndex];
|
|
88
|
+
if (!options)
|
|
89
|
+
continue;
|
|
90
|
+
raw.set(id, options);
|
|
91
|
+
norm.set(normalizeModelKey(id), options);
|
|
92
|
+
const key = normalizeModelKey(id);
|
|
93
|
+
const list = this.byName.get(key) ?? [];
|
|
94
|
+
list.push({ provider, options });
|
|
95
|
+
this.byName.set(key, list);
|
|
96
|
+
size++;
|
|
97
|
+
}
|
|
98
|
+
this.byProvider.set(provider, raw);
|
|
99
|
+
this.normalized.set(provider, norm);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
this.size = size;
|
|
103
|
+
}
|
|
104
|
+
/** The entry for a model: first on the user's own provider, then on the model's maker, then anywhere. */
|
|
105
|
+
lookup(provider, model) {
|
|
106
|
+
const id = model.toLowerCase().trim();
|
|
107
|
+
const key = normalizeModelKey(model);
|
|
108
|
+
if (!key)
|
|
109
|
+
return undefined;
|
|
110
|
+
if (provider) {
|
|
111
|
+
const exact = this.byProvider.get(provider)?.get(id) ?? this.normalized.get(provider)?.get(key);
|
|
112
|
+
if (exact)
|
|
113
|
+
return dataFromOptions(exact);
|
|
114
|
+
}
|
|
115
|
+
const candidates = this.byName.get(key);
|
|
116
|
+
if (!candidates || candidates.length === 0)
|
|
117
|
+
return undefined;
|
|
118
|
+
const rank = (p) => {
|
|
119
|
+
const i = NATIVE_PROVIDERS.indexOf(p);
|
|
120
|
+
return i === -1 ? NATIVE_PROVIDERS.length : i;
|
|
121
|
+
};
|
|
122
|
+
const best = [...candidates].sort((a, b) => rank(a.provider) - rank(b.provider))[0];
|
|
123
|
+
return dataFromOptions(best.options);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
let bundled = null;
|
|
127
|
+
let fresher = null;
|
|
128
|
+
/** The snapshot shipped with devx (scripts/build-reasoning-catalog.mjs creates it). */
|
|
129
|
+
export function bundledCatalog() {
|
|
130
|
+
if (bundled)
|
|
131
|
+
return bundled;
|
|
132
|
+
let file = null;
|
|
133
|
+
try {
|
|
134
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
135
|
+
file = readJsonFile(path.join(here, 'reasoning-catalog.json'));
|
|
136
|
+
}
|
|
137
|
+
catch { }
|
|
138
|
+
file ??= readJsonFile(path.join(process.cwd(), 'src/core/reasoning-catalog.json'));
|
|
139
|
+
bundled = new Catalog(file);
|
|
140
|
+
return bundled;
|
|
141
|
+
}
|
|
142
|
+
/** A catalogue downloaded earlier with /effort refresh; it wins over the shipped snapshot. */
|
|
143
|
+
export function loadUserCatalog(dir = devxDir()) {
|
|
144
|
+
const file = readJsonFile(path.join(dir, 'reasoning-catalog.json'));
|
|
145
|
+
fresher = file ? new Catalog(file) : null;
|
|
146
|
+
return fresher;
|
|
147
|
+
}
|
|
148
|
+
/** For tests: replace the catalogues. */
|
|
149
|
+
export function setCatalogs(shipped, downloaded = null) {
|
|
150
|
+
bundled = shipped;
|
|
151
|
+
fresher = downloaded;
|
|
152
|
+
}
|
|
153
|
+
export function catalogInfo() {
|
|
154
|
+
const active = fresher && fresher.size > 0 ? fresher : bundledCatalog();
|
|
155
|
+
return { generated: active.generated, models: active.size, downloaded: active === fresher };
|
|
156
|
+
}
|
|
157
|
+
function lookupCatalogs(provider, model) {
|
|
158
|
+
return fresher?.lookup(provider, model) ?? bundledCatalog().lookup(provider, model);
|
|
159
|
+
}
|
|
160
|
+
/** Downloads models.dev, keeps the reasoning options and stores them for later runs. */
|
|
161
|
+
export async function refreshCatalog(options = {}) {
|
|
162
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
163
|
+
const res = await doFetch(options.url ?? CATALOG_URL, { signal: AbortSignal.timeout(options.timeoutMs ?? 30_000) });
|
|
164
|
+
if (!res.ok)
|
|
165
|
+
throw new Error(`models.dev answered HTTP ${res.status}`);
|
|
166
|
+
const api = await res.json();
|
|
167
|
+
const file = slimCatalog(api);
|
|
168
|
+
const total = Object.values(file.models).reduce((n, m) => n + Object.keys(m).length, 0);
|
|
169
|
+
if (total === 0)
|
|
170
|
+
throw new Error('models.dev returned no reasoning data');
|
|
171
|
+
const prices = slimPrices(api);
|
|
172
|
+
const dir = options.dir ?? devxDir();
|
|
173
|
+
await ensureSecureDir(dir);
|
|
174
|
+
const text = JSON.stringify(file);
|
|
175
|
+
const target = path.join(dir, 'reasoning-catalog.json');
|
|
176
|
+
await fsp.writeFile(target + '.tmp', text, 'utf8');
|
|
177
|
+
await fsp.rename(target + '.tmp', target);
|
|
178
|
+
fresher = new Catalog(file);
|
|
179
|
+
const priced = Object.values(prices.models).reduce((n, m) => n + Object.keys(m).length, 0);
|
|
180
|
+
if (priced > 0)
|
|
181
|
+
await storePrices(prices, dir);
|
|
182
|
+
const vision = slimVision(api);
|
|
183
|
+
if (vision.yes.length > 0)
|
|
184
|
+
await storeVision(vision, dir);
|
|
185
|
+
return { models: total, providers: Object.keys(file.models).length, bytes: text.length, generated: file.generated, priced };
|
|
186
|
+
}
|
|
187
|
+
// ───────────────────────── asking the endpoint ─────────────────────────
|
|
188
|
+
/**
|
|
189
|
+
* Reads reasoning information from one entry of a model list. Understands the OpenRouter
|
|
190
|
+
* schema (`reasoning`, `supported_parameters`), models.dev style `reasoning_options`,
|
|
191
|
+
* capability lists (Ollama, LM Studio, gateways) and plain boolean flags.
|
|
192
|
+
* Returns null when the entry says nothing about reasoning.
|
|
193
|
+
*/
|
|
194
|
+
export function dataFromModelEntry(entry, source = 'endpoint') {
|
|
195
|
+
if (!entry || typeof entry !== 'object')
|
|
196
|
+
return null;
|
|
197
|
+
const reasoning = entry.reasoning;
|
|
198
|
+
if (reasoning && typeof reasoning === 'object' && !Array.isArray(reasoning)) {
|
|
199
|
+
const efforts = Array.isArray(reasoning.supported_efforts) ? reasoning.supported_efforts.map(String) : [];
|
|
200
|
+
const data = { source, supported: true };
|
|
201
|
+
if (efforts.length > 0)
|
|
202
|
+
data.efforts = efforts;
|
|
203
|
+
if (reasoning.mandatory === true)
|
|
204
|
+
data.mandatory = true;
|
|
205
|
+
if (reasoning.supports_max_tokens === true)
|
|
206
|
+
data.budget = {};
|
|
207
|
+
else if (efforts.length === 0 && reasoning.mandatory === false)
|
|
208
|
+
data.toggle = true;
|
|
209
|
+
return data;
|
|
210
|
+
}
|
|
211
|
+
if (Array.isArray(entry.reasoning_options)) {
|
|
212
|
+
const options = sanitizeOptions(entry.reasoning_options);
|
|
213
|
+
if (options.length > 0)
|
|
214
|
+
return dataFromOptions(options, source);
|
|
215
|
+
}
|
|
216
|
+
const params = Array.isArray(entry.supported_parameters) ? entry.supported_parameters.map(String) : null;
|
|
217
|
+
if (params) {
|
|
218
|
+
if (params.includes('reasoning_effort'))
|
|
219
|
+
return { source, supported: true, efforts: ['low', 'medium', 'high'] };
|
|
220
|
+
if (params.some((p) => p === 'reasoning' || p === 'include_reasoning' || p === 'thinking'))
|
|
221
|
+
return { source, supported: true };
|
|
222
|
+
if (params.length > 0)
|
|
223
|
+
return { source, none: true };
|
|
224
|
+
}
|
|
225
|
+
const caps = entry.capabilities;
|
|
226
|
+
const capList = Array.isArray(caps)
|
|
227
|
+
? caps.map(String)
|
|
228
|
+
: caps && typeof caps === 'object'
|
|
229
|
+
? Object.keys(caps).filter((k) => caps[k])
|
|
230
|
+
: null;
|
|
231
|
+
if (capList && capList.length > 0) {
|
|
232
|
+
return capList.some((c) => /reason|think/i.test(c)) ? { source, supported: true, toggle: true } : { source, none: true };
|
|
233
|
+
}
|
|
234
|
+
for (const key of ['supports_reasoning', 'reasoning_supported', 'supports_thinking', 'reasoning', 'thinking']) {
|
|
235
|
+
if (entry[key] === true)
|
|
236
|
+
return { source, supported: true };
|
|
237
|
+
if (entry[key] === false)
|
|
238
|
+
return { source, none: true };
|
|
239
|
+
}
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
function isLocalHost(host) {
|
|
243
|
+
return (host === 'localhost' || host === '::1' || host === '[::1]' ||
|
|
244
|
+
/^127\./.test(host) || /^10\./.test(host) || /^192\.168\./.test(host) || /^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
|
|
245
|
+
host.endsWith('.local') || host === 'ollama.com' || host.endsWith('.ollama.com'));
|
|
246
|
+
}
|
|
247
|
+
const sameModel = (id, model) => {
|
|
248
|
+
if (typeof id !== 'string')
|
|
249
|
+
return false;
|
|
250
|
+
const a = id.toLowerCase().replace(/^models\//, '');
|
|
251
|
+
return a === model.toLowerCase().replace(/^models\//, '');
|
|
252
|
+
};
|
|
253
|
+
/**
|
|
254
|
+
* Asks the endpoint what it knows about one model. Every request goes to the base URL the
|
|
255
|
+
* user configured, with the key the user configured, and has a short timeout.
|
|
256
|
+
* - GET {base}/models: the list devx already uses for the model picker; its entry for the
|
|
257
|
+
* model may carry reasoning metadata (OpenRouter and compatible gateways do)
|
|
258
|
+
* - POST {origin}/api/show on a local machine or Ollama Cloud: "capabilities" list
|
|
259
|
+
* - GET {origin}/model/info: a LiteLLM proxy
|
|
260
|
+
*/
|
|
261
|
+
export async function probeEndpoint(input) {
|
|
262
|
+
return (await probeEndpointFull(input)).data;
|
|
263
|
+
}
|
|
264
|
+
export async function probeEndpointFull(input) {
|
|
265
|
+
const doFetch = input.fetchImpl ?? fetch;
|
|
266
|
+
const timeout = input.timeoutMs ?? 4000;
|
|
267
|
+
const base = input.baseUrl.replace(/\/+$/, '');
|
|
268
|
+
const headers = {};
|
|
269
|
+
if (input.apiKey && input.apiKey !== 'ollama')
|
|
270
|
+
headers.Authorization = `Bearer ${input.apiKey}`;
|
|
271
|
+
const request = async (url, init = {}) => {
|
|
272
|
+
try {
|
|
273
|
+
const res = await doFetch(url, { ...init, headers: { ...headers, ...init.headers }, signal: AbortSignal.timeout(timeout) });
|
|
274
|
+
return res.ok ? await res.json() : null;
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
let origin = '';
|
|
281
|
+
let host = '';
|
|
282
|
+
try {
|
|
283
|
+
const u = new URL(base);
|
|
284
|
+
origin = u.origin;
|
|
285
|
+
host = u.hostname.toLowerCase();
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return { data: null };
|
|
289
|
+
}
|
|
290
|
+
const list = await request(`${base}/models`);
|
|
291
|
+
const items = Array.isArray(list) ? list : Array.isArray(list?.data) ? list.data : Array.isArray(list?.models) ? list.models : [];
|
|
292
|
+
const entry = items.find((e) => sameModel(e?.id ?? e?.name ?? e?.model, input.model));
|
|
293
|
+
let price = priceFromModelEntry(entry);
|
|
294
|
+
const fromList = dataFromModelEntry(entry);
|
|
295
|
+
if (fromList)
|
|
296
|
+
return { data: fromList, price };
|
|
297
|
+
if (isLocalHost(host)) {
|
|
298
|
+
const show = await request(`${origin}/api/show`, {
|
|
299
|
+
method: 'POST',
|
|
300
|
+
headers: { 'Content-Type': 'application/json' },
|
|
301
|
+
body: JSON.stringify({ model: input.model }),
|
|
302
|
+
});
|
|
303
|
+
if (show && Array.isArray(show.capabilities)) {
|
|
304
|
+
const capabilities = show.capabilities.map(String);
|
|
305
|
+
return {
|
|
306
|
+
data: capabilities.includes('thinking') ? { source: 'endpoint', supported: true, toggle: true } : { source: 'endpoint', none: true },
|
|
307
|
+
price,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const info = await request(`${origin}/model/info`);
|
|
312
|
+
if (Array.isArray(info?.data)) {
|
|
313
|
+
const hit = info.data.find((m) => sameModel(m?.model_name, input.model));
|
|
314
|
+
price ??= priceFromModelEntry(hit);
|
|
315
|
+
if (hit?.model_info?.supports_reasoning === true)
|
|
316
|
+
return { data: { source: 'endpoint', supported: true }, price };
|
|
317
|
+
}
|
|
318
|
+
return { data: null, price };
|
|
319
|
+
}
|
|
320
|
+
const POSITIVE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
321
|
+
const NEGATIVE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
322
|
+
export class CapabilityStore {
|
|
323
|
+
entries = new Map();
|
|
324
|
+
static key(baseUrl, model) {
|
|
325
|
+
return `${baseUrl.replace(/\/+$/, '').toLowerCase()}|${model.toLowerCase().trim()}`;
|
|
326
|
+
}
|
|
327
|
+
get(baseUrl, model) {
|
|
328
|
+
return this.entries.get(CapabilityStore.key(baseUrl, model));
|
|
329
|
+
}
|
|
330
|
+
set(baseUrl, model, record) {
|
|
331
|
+
this.entries.set(CapabilityStore.key(baseUrl, model), record);
|
|
332
|
+
}
|
|
333
|
+
delete(baseUrl, model) {
|
|
334
|
+
this.entries.delete(CapabilityStore.key(baseUrl, model));
|
|
335
|
+
}
|
|
336
|
+
clear() {
|
|
337
|
+
this.entries.clear();
|
|
338
|
+
}
|
|
339
|
+
isFresh(baseUrl, model, now = Date.now()) {
|
|
340
|
+
const record = this.get(baseUrl, model);
|
|
341
|
+
if (!record)
|
|
342
|
+
return false;
|
|
343
|
+
return now - record.at < (record.data || record.price ? POSITIVE_TTL_MS : NEGATIVE_TTL_MS);
|
|
344
|
+
}
|
|
345
|
+
toJSON() {
|
|
346
|
+
return { version: 1, endpoints: Object.fromEntries(this.entries) };
|
|
347
|
+
}
|
|
348
|
+
load(raw) {
|
|
349
|
+
if (!raw || raw.version !== 1 || typeof raw.endpoints !== 'object')
|
|
350
|
+
return;
|
|
351
|
+
for (const [key, record] of Object.entries(raw.endpoints)) {
|
|
352
|
+
if (record && typeof record.at === 'number') {
|
|
353
|
+
const entry = { at: record.at, data: record.data ?? null };
|
|
354
|
+
if (record.price && typeof record.price.input === 'number' && typeof record.price.output === 'number')
|
|
355
|
+
entry.price = record.price;
|
|
356
|
+
this.entries.set(key, entry);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
export const capabilityStore = new CapabilityStore();
|
|
362
|
+
export function loadCapabilityCache(dir = devxDir()) {
|
|
363
|
+
capabilityStore.load(readJsonFile(path.join(dir, 'capabilities.json')));
|
|
364
|
+
}
|
|
365
|
+
async function saveCapabilityCache(dir) {
|
|
366
|
+
try {
|
|
367
|
+
await ensureSecureDir(dir);
|
|
368
|
+
await writeSecureFile(path.join(dir, 'capabilities.json'), JSON.stringify(capabilityStore.toJSON()));
|
|
369
|
+
}
|
|
370
|
+
catch { }
|
|
371
|
+
}
|
|
372
|
+
const inflight = new Map();
|
|
373
|
+
/**
|
|
374
|
+
* Makes sure the endpoint of the current config has been asked about the current model.
|
|
375
|
+
* Only OpenRouter-style and custom endpoints are asked: for the big vendors the catalogue
|
|
376
|
+
* is enough and their model lists say nothing about reasoning. Turn it off completely with
|
|
377
|
+
* `"probeEndpoints": false` in the config.
|
|
378
|
+
*/
|
|
379
|
+
export async function refreshCapabilities(config, options = {}) {
|
|
380
|
+
const baseUrl = config.baseUrl || '';
|
|
381
|
+
const model = config.model || '';
|
|
382
|
+
if (!baseUrl || !model || config.probeEndpoints === false)
|
|
383
|
+
return null;
|
|
384
|
+
const route = detectRoute(baseUrl);
|
|
385
|
+
if (route !== 'generic' && route !== 'openrouter')
|
|
386
|
+
return null;
|
|
387
|
+
if (!options.force && capabilityStore.isFresh(baseUrl, model))
|
|
388
|
+
return capabilityStore.get(baseUrl, model) ?? null;
|
|
389
|
+
const key = CapabilityStore.key(baseUrl, model);
|
|
390
|
+
const pending = inflight.get(key);
|
|
391
|
+
if (pending)
|
|
392
|
+
return pending;
|
|
393
|
+
const job = (async () => {
|
|
394
|
+
const { data, price } = await probeEndpointFull({ baseUrl, apiKey: config.apiKey, model, fetchImpl: options.fetchImpl, timeoutMs: options.timeoutMs });
|
|
395
|
+
const record = { at: Date.now(), data, ...(price ? { price } : {}) };
|
|
396
|
+
capabilityStore.set(baseUrl, model, record);
|
|
397
|
+
if (options.persist !== false)
|
|
398
|
+
await saveCapabilityCache(options.dir ?? devxDir());
|
|
399
|
+
return record;
|
|
400
|
+
})().finally(() => inflight.delete(key));
|
|
401
|
+
inflight.set(key, job);
|
|
402
|
+
return job;
|
|
403
|
+
}
|
|
404
|
+
// ───────────────────────── putting it together ─────────────────────────
|
|
405
|
+
function mergeData(live, catalog) {
|
|
406
|
+
if (!live)
|
|
407
|
+
return catalog;
|
|
408
|
+
if (!catalog || live.none || catalog.none)
|
|
409
|
+
return live;
|
|
410
|
+
const liveHasDetail = !!(live.efforts?.length || live.toggle || live.budget);
|
|
411
|
+
if (liveHasDetail)
|
|
412
|
+
return live;
|
|
413
|
+
// The endpoint only says "it reasons" (and maybe "cannot be switched off"): the details come from the catalogue.
|
|
414
|
+
return { ...catalog, mandatory: live.mandatory ?? catalog.mandatory };
|
|
415
|
+
}
|
|
416
|
+
/** What is known about the model of this config: the endpoint's answer, completed by the catalogue. */
|
|
417
|
+
export function lookupReasoningData(config) {
|
|
418
|
+
const model = config.model || '';
|
|
419
|
+
if (!model)
|
|
420
|
+
return undefined;
|
|
421
|
+
const live = capabilityStore.get(config.baseUrl || '', model)?.data ?? undefined;
|
|
422
|
+
return mergeData(live, lookupCatalogs(config.provider, model));
|
|
423
|
+
}
|
|
424
|
+
/** Loads the stored answers and connects them to the reasoning resolver. Call once at startup. */
|
|
425
|
+
export function installCapabilities(dir = devxDir()) {
|
|
426
|
+
loadUserCatalog(dir);
|
|
427
|
+
loadUserPrices(dir);
|
|
428
|
+
loadUserVision(dir);
|
|
429
|
+
loadCapabilityCache(dir);
|
|
430
|
+
setReasoningDataLookup(lookupReasoningData);
|
|
431
|
+
setEndpointPriceLookup((baseUrl, model) => capabilityStore.get(baseUrl, model)?.price);
|
|
432
|
+
installFormats(dir);
|
|
433
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
export function devxDir() {
|
|
5
|
+
return path.join(os.homedir(), '.devx');
|
|
6
|
+
}
|
|
7
|
+
export function readJsonFile(file) {
|
|
8
|
+
try {
|
|
9
|
+
if (!fs.existsSync(file))
|
|
10
|
+
return null;
|
|
11
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** The key used to match model ids written differently ("claude-sonnet-4.5" / "claude-sonnet-4-5-20250929"). */
|
|
18
|
+
export function normalizeModelKey(model) {
|
|
19
|
+
const parts = model.toLowerCase().trim().split('/');
|
|
20
|
+
return (parts[parts.length - 1] || '')
|
|
21
|
+
.replace(/:.*$/, '')
|
|
22
|
+
.replace(/-latest$/, '')
|
|
23
|
+
.replace(/-\d{4}-?\d{2}-?\d{2}$/, '')
|
|
24
|
+
.replace(/\./g, '-');
|
|
25
|
+
}
|
|
26
|
+
// Providers that make the models themselves are preferred when several offer the same model.
|
|
27
|
+
export const NATIVE_PROVIDERS = ['openai', 'anthropic', 'google', 'deepseek', 'alibaba', 'xai', 'zai', 'moonshotai', 'mistral', 'meta', 'nvidia', 'groq'];
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { execFile } from 'child_process';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
const GIT_TIMEOUT_MS = 10000;
|
|
6
|
+
const REF = 'refs/devx/checkpoint';
|
|
7
|
+
function git(cwd, args, env = {}) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
execFile('git', args, { cwd, env: { ...process.env, ...env }, timeout: GIT_TIMEOUT_MS, maxBuffer: 256 * 1024 * 1024, encoding: 'buffer' }, (err, stdout) => {
|
|
10
|
+
if (err)
|
|
11
|
+
reject(err);
|
|
12
|
+
else
|
|
13
|
+
resolve(stdout);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
/** The tree of the working folder as it is now, written through a temporary index. */
|
|
18
|
+
async function snapshotTree(root) {
|
|
19
|
+
const indexFile = path.join(os.tmpdir(), `devx-index-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`);
|
|
20
|
+
const env = { GIT_INDEX_FILE: indexFile };
|
|
21
|
+
try {
|
|
22
|
+
await git(root, ['add', '-A', '--', '.'], env);
|
|
23
|
+
return (await git(root, ['write-tree'], env)).toString().trim();
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
await fs.rm(indexFile, { force: true }).catch(() => { });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Takes a snapshot, or returns null when the folder is not in a git repository or git is too slow or missing. */
|
|
30
|
+
export async function createCheckpoint(cwd, parent) {
|
|
31
|
+
try {
|
|
32
|
+
const root = (await git(cwd, ['rev-parse', '--show-toplevel'])).toString().trim();
|
|
33
|
+
if (!root)
|
|
34
|
+
return null;
|
|
35
|
+
const tree = await snapshotTree(root);
|
|
36
|
+
const author = { GIT_AUTHOR_NAME: 'devx', GIT_AUTHOR_EMAIL: 'devx@localhost', GIT_COMMITTER_NAME: 'devx', GIT_COMMITTER_EMAIL: 'devx@localhost' };
|
|
37
|
+
const sha = (await git(root, ['commit-tree', tree, ...(parent ? ['-p', parent] : []), '-m', 'devx checkpoint'], author)).toString().trim();
|
|
38
|
+
await git(root, ['update-ref', REF, sha]);
|
|
39
|
+
return { sha, root };
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** What changed in the working folder since the snapshot. */
|
|
46
|
+
export async function diffCheckpoint(cp) {
|
|
47
|
+
const now = await snapshotTree(cp.root);
|
|
48
|
+
const out = (await git(cp.root, ['diff-tree', '-r', '--name-status', '-z', '--no-renames', cp.sha, now])).toString('utf8');
|
|
49
|
+
const parts = out.split('\0').filter((p) => p.length > 0);
|
|
50
|
+
const diff = { modified: [], created: [], deleted: [] };
|
|
51
|
+
for (let i = 0; i + 1 < parts.length; i += 2) {
|
|
52
|
+
const status = parts[i];
|
|
53
|
+
const file = parts[i + 1];
|
|
54
|
+
if (status === 'A')
|
|
55
|
+
diff.created.push(file);
|
|
56
|
+
else if (status === 'D')
|
|
57
|
+
diff.deleted.push(file);
|
|
58
|
+
else
|
|
59
|
+
diff.modified.push(file);
|
|
60
|
+
}
|
|
61
|
+
return diff;
|
|
62
|
+
}
|
|
63
|
+
/** Puts the folder back to the snapshot: changed and deleted files come back, files created since are removed. */
|
|
64
|
+
export async function restoreCheckpoint(cp, diff) {
|
|
65
|
+
const changes = diff ?? (await diffCheckpoint(cp));
|
|
66
|
+
for (const file of [...changes.modified, ...changes.deleted]) {
|
|
67
|
+
const target = path.join(cp.root, file);
|
|
68
|
+
const content = await git(cp.root, ['cat-file', 'blob', `${cp.sha}:${file}`]);
|
|
69
|
+
const mode = (await git(cp.root, ['ls-tree', cp.sha, '--', file])).toString().split(' ')[0];
|
|
70
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
71
|
+
await fs.writeFile(target, content);
|
|
72
|
+
if (mode === '100755')
|
|
73
|
+
await fs.chmod(target, 0o755).catch(() => { });
|
|
74
|
+
}
|
|
75
|
+
for (const file of changes.created) {
|
|
76
|
+
await fs.rm(path.join(cp.root, file), { force: true });
|
|
77
|
+
// An empty folder left by it goes too (up to the repository root).
|
|
78
|
+
let dir = path.dirname(path.join(cp.root, file));
|
|
79
|
+
while (dir.startsWith(cp.root + path.sep)) {
|
|
80
|
+
try {
|
|
81
|
+
await fs.rmdir(dir);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
dir = path.dirname(dir);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return changes;
|
|
90
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { History } from './history.js';
|
|
2
|
+
/** The tool schemas travel with every request and count against the context window. */
|
|
3
|
+
export function toolSchemaTokens(definitions) {
|
|
4
|
+
return Math.ceil(JSON.stringify(definitions).length * 0.33);
|
|
5
|
+
}
|
|
6
|
+
/** The share of the context window used for the prompt; the rest is left for the answer. */
|
|
7
|
+
export const CONTEXT_USAGE_RATIO = 0.85;
|
|
8
|
+
/** What fills the context window, by kind. The numbers are devx's estimate (about 3 characters per token). */
|
|
9
|
+
export function contextBreakdown(messages, definitions, limit) {
|
|
10
|
+
const meter = new History();
|
|
11
|
+
const of = (role) => meter.estimateTokens(messages.filter((m) => m.role === role).map((m) => ({ ...m, images: undefined })));
|
|
12
|
+
const imageCount = messages.reduce((n, m) => n + (m.images?.length ?? 0), 0);
|
|
13
|
+
const images = meter.estimateTokens(messages.filter((m) => m.images?.length).map((m) => ({ role: 'user', content: '', images: m.images })));
|
|
14
|
+
const system = of('system');
|
|
15
|
+
const tools = definitions.length > 0 ? toolSchemaTokens(definitions) : 0;
|
|
16
|
+
const user = of('user');
|
|
17
|
+
const assistant = of('assistant');
|
|
18
|
+
const toolResults = of('tool');
|
|
19
|
+
const total = system + tools + user + assistant + toolResults + images;
|
|
20
|
+
return {
|
|
21
|
+
limit,
|
|
22
|
+
system, tools, toolCount: definitions.length,
|
|
23
|
+
user, assistant, toolResults,
|
|
24
|
+
images, imageCount,
|
|
25
|
+
total,
|
|
26
|
+
compactAt: Math.floor(limit * CONTEXT_USAGE_RATIO),
|
|
27
|
+
free: Math.max(0, limit - total),
|
|
28
|
+
};
|
|
29
|
+
}
|