termux-dev 1.8.1 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +17 -4
  2. package/assets/banner.svg +1 -1
  3. package/assets/preview.svg +1 -1
  4. package/dist/cli/background-ui.js +116 -0
  5. package/dist/cli/files.js +2 -12
  6. package/dist/cli/finder.js +4 -0
  7. package/dist/cli/headless.js +6 -3
  8. package/dist/cli/index.js +259 -74
  9. package/dist/cli/keys.js +12 -3
  10. package/dist/cli/liveblock.js +38 -9
  11. package/dist/cli/mcp-panel.js +2 -0
  12. package/dist/cli/picker.js +2 -0
  13. package/dist/cli/prompt.js +93 -8
  14. package/dist/cli/provider-manager.js +197 -0
  15. package/dist/cli/providers.js +0 -5
  16. package/dist/cli/settings.js +38 -17
  17. package/dist/cli/typeahead.js +10 -17
  18. package/dist/cli/ui.js +189 -32
  19. package/dist/cli/updater.js +10 -8
  20. package/dist/cli/usage-panel.js +17 -4
  21. package/dist/cli/width.js +18 -0
  22. package/dist/core/background.js +390 -0
  23. package/dist/core/config-merge.js +67 -0
  24. package/dist/core/context-stats.js +1 -0
  25. package/dist/core/cost.js +25 -11
  26. package/dist/core/custom-providers.js +161 -0
  27. package/dist/core/images.js +46 -0
  28. package/dist/core/local-config.js +3 -1
  29. package/dist/core/locales/de.js +36 -7
  30. package/dist/core/locales/en.js +36 -7
  31. package/dist/core/locales/es.js +36 -7
  32. package/dist/core/locales/fr.js +36 -7
  33. package/dist/core/locales/ja.js +36 -7
  34. package/dist/core/locales/pt.js +36 -7
  35. package/dist/core/locales/ru.js +36 -7
  36. package/dist/core/locales/uk.js +36 -7
  37. package/dist/core/locales/zh.js +36 -7
  38. package/dist/core/loop.js +50 -2
  39. package/dist/core/prices.js +8 -4
  40. package/dist/core/session-media.js +115 -0
  41. package/dist/core/session.js +17 -2
  42. package/dist/core/title.js +19 -2
  43. package/dist/core/types.js +1 -1
  44. package/dist/core/usage.js +41 -4
  45. package/dist/permissions/guard.js +6 -1
  46. package/dist/prompts/builder.js +2 -1
  47. package/dist/providers/anthropic.js +67 -13
  48. package/dist/providers/index.js +16 -3
  49. package/dist/providers/openai.js +99 -41
  50. package/dist/providers/shared.js +83 -0
  51. package/dist/tools/background.js +118 -0
  52. package/dist/tools/bash.js +23 -1
  53. package/dist/tools/fs.js +32 -2
  54. package/dist/tools/index.js +2 -1
  55. package/package.json +1 -1
@@ -128,10 +128,23 @@ function buildRows(d, iw, detail) {
128
128
  const note = costNote(s);
129
129
  if (note && detail === 'full')
130
130
  line(' ' + palette.faint(truncateWidth(note, iw - 2)));
131
+ if (s.restoredCost && detail === 'full') {
132
+ line(' ' + palette.faint(truncateWidth(`Includes ${formatMoney(s.restoredCost, { estimated: s.costState.estimated })} from before this chat was resumed; the lines below count only since devx started.`, iw - 2)));
133
+ }
131
134
  const tokens = iw >= 40 ? `${fmtTokens(s.promptTokens)} in · ${fmtTokens(s.completionTokens)} out` : `${fmtTokens(s.promptTokens)}/${fmtTokens(s.completionTokens)}`;
132
135
  pair('Tokens', palette.text(tokens));
133
- if (s.cacheReadTokens > 0 && detail !== 'min')
134
- pair('Cached input', palette.text(fmtTokens(s.cacheReadTokens)));
136
+ if (detail !== 'min') {
137
+ // What the money was paid for. Every request resends the whole conversation, so the input
138
+ // is usually the biggest part, and the cache is what makes it cheaper.
139
+ const money = (v) => s.costParts && v !== undefined && iw >= 40 ? palette.faint(' ' + formatMoney(v, { estimated: s.costState.estimated })) : '';
140
+ const paid = (label, tokens, v) => pair(label, palette.text(fmtTokens(tokens)) + money(v));
141
+ paid(' Input', s.uncachedTokens, s.costParts?.input);
142
+ if (s.cacheReadTokens > 0)
143
+ paid(' Cached input', s.cacheReadTokens, s.costParts?.cacheRead);
144
+ if (s.cacheWriteTokens > 0)
145
+ paid(' Cache writes', s.cacheWriteTokens, s.costParts?.cacheWrite);
146
+ paid(' Output', s.completionTokens, s.costParts?.output);
147
+ }
135
148
  pair('Requests', palette.text(String(s.requestsCount)));
136
149
  // ── traffic
137
150
  if (detail === 'full') {
@@ -221,7 +234,7 @@ export function runUsagePanel(data, options = {}) {
221
234
  return;
222
235
  closed = true;
223
236
  input.off('data', onData);
224
- output.off?.('resize', draw);
237
+ stopWatching();
225
238
  block.erase();
226
239
  if (!wasRaw)
227
240
  input.setRawMode?.(false);
@@ -236,7 +249,7 @@ export function runUsagePanel(data, options = {}) {
236
249
  }
237
250
  };
238
251
  input.on('data', onData);
239
- output.on?.('resize', draw);
252
+ const stopWatching = block.watchResize(draw);
240
253
  draw();
241
254
  });
242
255
  }
package/dist/cli/width.js CHANGED
@@ -176,3 +176,21 @@ export function wrapText(text, width) {
176
176
  const unbreakable = text.split(/\s+/).some((word) => displayWidth(word) > width);
177
177
  return unbreakable ? wrapWidth(text, width) : wrapWords(text, width);
178
178
  }
179
+ /**
180
+ * How many rows up the top of a block is from the cursor, once the terminal is `cols` wide.
181
+ *
182
+ * A block drawn at one width is re-wrapped by the terminal when the window is resized: a line that
183
+ * was as wide as the old window now takes several rows, so counting the lines of the block is not
184
+ * enough to get back to its top (the top stays on screen and the block shows up twice).
185
+ *
186
+ * @param lineWidths visible width of each line of the block as it was drawn
187
+ * @param cursorRow the line the cursor is on
188
+ * @param cursorCol the column of the cursor in that line
189
+ */
190
+ export function rowsToTop(lineWidths, cursorRow, cursorCol, cols) {
191
+ const width = Math.max(1, cols);
192
+ let rows = 0;
193
+ for (let i = 0; i < cursorRow && i < lineWidths.length; i++)
194
+ rows += Math.max(1, Math.ceil(lineWidths[i] / width));
195
+ return rows + Math.floor(Math.max(0, cursorCol) / width);
196
+ }
@@ -0,0 +1,390 @@
1
+ import { spawn } from 'child_process';
2
+ import { EventEmitter } from 'events';
3
+ import { StringDecoder } from 'string_decoder';
4
+ export const MAX_BACKGROUND_TASKS = 8;
5
+ /** What is kept of the output of one task; older text is dropped. */
6
+ export const MAX_BUFFER = 200_000;
7
+ /** The most a single read gives back to the model. */
8
+ export const MAX_READ_CHARS = 8000;
9
+ const MAX_NOTES_PER_TASK = 12;
10
+ const NOTE_TAIL_CHARS = 1500;
11
+ const MATCH_DEBOUNCE_MS = 400;
12
+ /** Strips colour codes and carriage-return redraws so the model reads text, not terminal noise. */
13
+ export function cleanOutput(text) {
14
+ return text
15
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
16
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
17
+ .replace(/[^\n]*\r(?!\n)/g, '')
18
+ .replace(/\r/g, '');
19
+ }
20
+ function compileRegex(source) {
21
+ if (!source)
22
+ return undefined;
23
+ try {
24
+ return new RegExp(source, 'i');
25
+ }
26
+ catch {
27
+ throw new Error(`Invalid regular expression: ${source}`);
28
+ }
29
+ }
30
+ export function formatDuration(ms) {
31
+ const s = Math.max(0, Math.round(ms / 1000));
32
+ if (s < 60)
33
+ return `${s}s`;
34
+ const m = Math.floor(s / 60);
35
+ if (m < 60)
36
+ return `${m}m ${String(s % 60).padStart(2, '0')}s`;
37
+ return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`;
38
+ }
39
+ export class BackgroundManager extends EventEmitter {
40
+ tasks = new Map();
41
+ counter = 0;
42
+ notes = [];
43
+ // ───────────────────────── starting and stopping ─────────────────────────
44
+ start(command, options = {}) {
45
+ if (!command || !command.trim())
46
+ throw new Error('command is required');
47
+ if (this.running().length >= MAX_BACKGROUND_TASKS) {
48
+ throw new Error(`Too many background tasks running (${MAX_BACKGROUND_TASKS}). Stop one first (action "stop").`);
49
+ }
50
+ const watch = compileRegex(options.notifyOn);
51
+ this.installExitHook();
52
+ const isWin = process.platform === 'win32';
53
+ const cwd = options.cwd || process.cwd();
54
+ const proc = spawn(command, { shell: true, cwd, detached: !isWin, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
55
+ const id = `bg${++this.counter}`;
56
+ const info = {
57
+ id,
58
+ label: (options.label || command).replace(/\s+/g, ' ').trim().slice(0, 60),
59
+ command,
60
+ cwd,
61
+ pid: proc.pid,
62
+ status: 'running',
63
+ code: null,
64
+ startedAt: Date.now(),
65
+ outputChars: 0,
66
+ watching: options.notifyOn || undefined,
67
+ };
68
+ let finish;
69
+ const done = new Promise((resolve) => { finish = resolve; });
70
+ const task = {
71
+ info, proc, buffer: '', trimmed: 0, readCursor: 0, scanFrom: 0, pendingLine: '', watch,
72
+ notes: 0, matchedLines: [], stopRequested: false, timedOut: false, done,
73
+ };
74
+ this.tasks.set(id, task);
75
+ const decoders = { out: new StringDecoder('utf8'), err: new StringDecoder('utf8') };
76
+ proc.stdout.on('data', (d) => this.append(task, decoders.out.write(d)));
77
+ proc.stderr.on('data', (d) => this.append(task, decoders.err.write(d)));
78
+ let ended = false;
79
+ const end = (status, code) => {
80
+ if (ended)
81
+ return;
82
+ ended = true;
83
+ this.append(task, decoders.out.end() + decoders.err.end());
84
+ this.flushPendingLine(task);
85
+ clearTimeout(task.killTimer);
86
+ clearTimeout(task.runtimeTimer);
87
+ info.status = task.stopRequested ? 'stopped' : status;
88
+ info.code = code;
89
+ info.endedAt = Date.now();
90
+ this.flushMatches(task);
91
+ // A task the model stopped itself needs no note; anything else does.
92
+ if (!task.stopRequested || task.timedOut)
93
+ this.addNote(task, 'exit', this.exitNote(task));
94
+ this.emit('change', this.snapshot(task));
95
+ finish();
96
+ };
97
+ proc.on('error', () => end('failed', null));
98
+ proc.on('close', (code) => end(code === 0 ? 'exited' : 'failed', code));
99
+ // A child that got away can keep the pipes open for good: do not wait for them for long.
100
+ proc.on('exit', (code) => {
101
+ setTimeout(() => {
102
+ proc.stdout?.destroy();
103
+ proc.stderr?.destroy();
104
+ end(code === 0 ? 'exited' : 'failed', code);
105
+ }, 400).unref();
106
+ });
107
+ if (options.maxRuntimeSeconds && options.maxRuntimeSeconds > 0) {
108
+ task.runtimeTimer = setTimeout(() => { task.timedOut = true; void this.stop(id); }, options.maxRuntimeSeconds * 1000);
109
+ task.runtimeTimer.unref();
110
+ }
111
+ this.emit('change', this.snapshot(task));
112
+ return this.snapshot(task);
113
+ }
114
+ /** Stops a task (SIGTERM, then SIGKILL after a second). Resolves when it is gone. */
115
+ async stop(id) {
116
+ const task = this.must(id);
117
+ if (task.info.status !== 'running')
118
+ return this.snapshot(task);
119
+ task.stopRequested = true;
120
+ this.killTree(task, false);
121
+ task.killTimer = setTimeout(() => this.killTree(task, true), 1000);
122
+ task.killTimer.unref();
123
+ await Promise.race([task.done, new Promise((r) => setTimeout(r, 2500).unref())]);
124
+ return this.snapshot(task);
125
+ }
126
+ async stopAll() {
127
+ const running = this.running();
128
+ await Promise.all(running.map((t) => this.stop(t.id).catch(() => null)));
129
+ return running.length;
130
+ }
131
+ /** Forgets finished tasks (their output is gone with them). */
132
+ clearFinished() {
133
+ let n = 0;
134
+ for (const [id, task] of this.tasks) {
135
+ if (task.info.status !== 'running') {
136
+ this.tasks.delete(id);
137
+ n++;
138
+ }
139
+ }
140
+ if (n)
141
+ this.emit('change');
142
+ return n;
143
+ }
144
+ // ───────────────────────── looking at tasks ─────────────────────────
145
+ list() {
146
+ return [...this.tasks.values()].map((t) => this.snapshot(t));
147
+ }
148
+ running() {
149
+ return this.list().filter((t) => t.status === 'running');
150
+ }
151
+ get(id) {
152
+ const task = this.tasks.get(id);
153
+ return task ? this.snapshot(task) : undefined;
154
+ }
155
+ /** The output of a task as text for the model or the screen. */
156
+ read(id, options = {}) {
157
+ const task = this.must(id);
158
+ const grep = compileRegex(options.grep);
159
+ let from;
160
+ if (options.mode === 'tail')
161
+ from = task.trimmed;
162
+ else
163
+ from = Math.max(task.readCursor, task.trimmed);
164
+ let text = task.buffer.slice(from - task.trimmed);
165
+ if (options.mode !== 'tail')
166
+ task.readCursor = task.trimmed + task.buffer.length;
167
+ let lines = cleanOutput(text).split('\n');
168
+ if (lines.length && lines[lines.length - 1] === '')
169
+ lines.pop();
170
+ if (grep)
171
+ lines = lines.filter((l) => grep.test(l));
172
+ const want = Math.max(1, Math.min(Math.floor(options.lines ?? (options.mode === 'tail' ? 40 : 200)), 1000));
173
+ let omitted = 0;
174
+ if (lines.length > want) {
175
+ omitted = lines.length - want;
176
+ lines = lines.slice(-want);
177
+ }
178
+ text = lines.join('\n');
179
+ if (text.length > MAX_READ_CHARS) {
180
+ omitted += text.slice(0, text.length - MAX_READ_CHARS).split('\n').length - 1;
181
+ text = text.slice(-MAX_READ_CHARS).replace(/^[^\n]*\n/, '');
182
+ }
183
+ return { text, omitted };
184
+ }
185
+ /**
186
+ * Waits until the task ends, a line matches `pattern`, the time is up or the turn is cancelled.
187
+ * Lines the model has already read do not count; lines it has not read yet do.
188
+ */
189
+ wait(id, options = {}) {
190
+ const task = this.must(id);
191
+ const pattern = compileRegex(options.pattern);
192
+ const timeoutMs = Math.min(Math.max(options.timeoutSeconds ?? 60, 1), 600) * 1000;
193
+ return new Promise((resolve) => {
194
+ let timer;
195
+ // Output nobody has read yet counts: a line that came while the model was busy elsewhere is not lost.
196
+ let scan = Math.max(task.readCursor, task.trimmed);
197
+ const finish = (result) => {
198
+ clearTimeout(timer);
199
+ options.signal?.removeEventListener('abort', onAbort);
200
+ this.removeListener(`data:${id}`, onData);
201
+ resolve(result);
202
+ };
203
+ const onAbort = () => finish({ reason: 'aborted' });
204
+ const onClose = () => finish({ reason: 'exit' });
205
+ void task.done.then(onClose);
206
+ const onData = () => {
207
+ if (!pattern)
208
+ return;
209
+ const start = Math.max(scan, task.trimmed);
210
+ const chunk = cleanOutput(task.buffer.slice(start - task.trimmed));
211
+ const lastNl = chunk.lastIndexOf('\n');
212
+ if (lastNl < 0)
213
+ return;
214
+ scan = start + lastNl + 1;
215
+ const hit = chunk.slice(0, lastNl).split('\n').find((l) => pattern.test(l));
216
+ if (hit !== undefined)
217
+ finish({ reason: 'match', line: hit.trim() });
218
+ };
219
+ if (task.info.status !== 'running')
220
+ return resolve({ reason: 'exit' });
221
+ if (options.signal?.aborted)
222
+ return resolve({ reason: 'aborted' });
223
+ timer = setTimeout(() => finish({ reason: 'timeout' }), timeoutMs);
224
+ options.signal?.addEventListener('abort', onAbort, { once: true });
225
+ this.on(`data:${id}`, onData);
226
+ onData();
227
+ });
228
+ }
229
+ // ───────────────────────── notes for the model ─────────────────────────
230
+ hasNotes() {
231
+ return this.notes.length > 0;
232
+ }
233
+ /** The notes since the last call, as one message for the model (or '' when there are none). */
234
+ takeNotes() {
235
+ if (this.notes.length === 0)
236
+ return '';
237
+ const text = this.notes.map((n) => n.text).join('\n\n');
238
+ this.notes = [];
239
+ // What the prompt frame shows about unreported news is gone now: let it redraw.
240
+ this.emit('change');
241
+ return `[devx · background tasks]\n${text}`;
242
+ }
243
+ /**
244
+ * Tasks that ended badly and whose news the model has not received yet. The prompt frame shows
245
+ * this number: it goes away once devx has told the model about the failure.
246
+ */
247
+ unreportedFailures() {
248
+ const ids = new Set();
249
+ for (const note of this.notes) {
250
+ if (note.kind === 'exit' && this.tasks.get(note.taskId)?.info.status === 'failed')
251
+ ids.add(note.taskId);
252
+ }
253
+ return ids.size;
254
+ }
255
+ // ───────────────────────── internals ─────────────────────────
256
+ must(id) {
257
+ const task = this.tasks.get(String(id));
258
+ if (!task) {
259
+ const known = [...this.tasks.keys()].join(', ');
260
+ throw new Error(`No background task "${id}"${known ? ` (known: ${known})` : ' (none started)'}`);
261
+ }
262
+ return task;
263
+ }
264
+ snapshot(task) {
265
+ return { ...task.info, outputChars: task.trimmed + task.buffer.length };
266
+ }
267
+ append(task, text) {
268
+ if (!text)
269
+ return;
270
+ task.buffer += text;
271
+ if (task.buffer.length > MAX_BUFFER * 1.5) {
272
+ const cut = task.buffer.length - MAX_BUFFER;
273
+ task.buffer = task.buffer.slice(cut);
274
+ task.trimmed += cut;
275
+ }
276
+ if (task.watch)
277
+ this.scanLines(task, text);
278
+ this.emit(`data:${task.info.id}`);
279
+ this.emit('output', task.info.id);
280
+ }
281
+ scanLines(task, text) {
282
+ const parts = cleanOutput(task.pendingLine + text).split('\n');
283
+ task.pendingLine = parts.pop() ?? '';
284
+ for (const line of parts) {
285
+ if (task.watch.test(line))
286
+ this.matched(task, line);
287
+ }
288
+ }
289
+ flushPendingLine(task) {
290
+ if (task.watch && task.pendingLine && task.watch.test(task.pendingLine))
291
+ this.matched(task, task.pendingLine);
292
+ task.pendingLine = '';
293
+ }
294
+ matched(task, line) {
295
+ task.matchedLines.push(line.trim().slice(0, 300));
296
+ if (task.matchedLines.length > 20)
297
+ task.matchedLines.shift();
298
+ clearTimeout(task.matchTimer);
299
+ task.matchTimer = setTimeout(() => this.flushMatches(task), MATCH_DEBOUNCE_MS);
300
+ task.matchTimer.unref();
301
+ }
302
+ flushMatches(task) {
303
+ clearTimeout(task.matchTimer);
304
+ if (task.matchedLines.length === 0)
305
+ return;
306
+ const lines = task.matchedLines.splice(0);
307
+ const shown = lines.slice(-5).map((l) => ` ${l}`).join('\n');
308
+ this.addNote(task, 'match', {
309
+ summary: `${task.info.id} · ${lines[lines.length - 1]}`,
310
+ text: `${task.info.id} "${task.info.label}" printed ${lines.length} line${lines.length === 1 ? '' : 's'} matching /${task.info.watching}/:\n${shown}`,
311
+ });
312
+ }
313
+ exitNote(task) {
314
+ const { id, label, code, startedAt, endedAt } = task.info;
315
+ const how = task.timedOut ? 'was stopped: its time limit ran out' : code === 0 ? 'finished (exit code 0)' : code === null ? 'stopped unexpectedly' : `failed (exit code ${code})`;
316
+ const after = formatDuration((endedAt ?? Date.now()) - startedAt);
317
+ const tail = this.tailForNote(task);
318
+ return {
319
+ summary: `${id} ${code === 0 ? 'finished' : code === null ? 'stopped' : `failed · exit ${code}`} · ${after}`,
320
+ text: `${id} "${label}" ${how} after ${after}.${tail ? `\nLast output:\n${tail}` : ''}`,
321
+ };
322
+ }
323
+ tailForNote(task) {
324
+ const text = cleanOutput(task.buffer).trimEnd();
325
+ return text.length > NOTE_TAIL_CHARS ? '…' + text.slice(-NOTE_TAIL_CHARS).replace(/^[^\n]*\n/, '') : text;
326
+ }
327
+ addNote(task, kind, body) {
328
+ task.notes++;
329
+ // A chatty watcher must not flood the conversation: after a dozen notes only the end is reported.
330
+ if (kind === 'match' && task.notes > MAX_NOTES_PER_TASK)
331
+ return;
332
+ const note = { taskId: task.info.id, kind, ...body };
333
+ this.notes.push(note);
334
+ this.emit('note', note);
335
+ }
336
+ killTree(task, force) {
337
+ const pid = task.proc.pid;
338
+ if (!pid || task.info.status !== 'running')
339
+ return;
340
+ if (process.platform === 'win32') {
341
+ const killer = spawn('taskkill', ['/pid', String(pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
342
+ killer.on('error', () => task.proc.kill());
343
+ return;
344
+ }
345
+ try {
346
+ process.kill(-pid, force ? 'SIGKILL' : 'SIGTERM');
347
+ }
348
+ catch (err) {
349
+ if (err.code !== 'ESRCH') {
350
+ try {
351
+ task.proc.kill(force ? 'SIGKILL' : 'SIGTERM');
352
+ }
353
+ catch { }
354
+ }
355
+ }
356
+ }
357
+ /** Nothing started by devx may outlive it. */
358
+ installExitHook() {
359
+ liveManagers.add(this);
360
+ if (exitHookInstalled)
361
+ return;
362
+ exitHookInstalled = true;
363
+ process.on('exit', () => {
364
+ for (const manager of liveManagers)
365
+ manager.killAllNow();
366
+ });
367
+ }
368
+ /** Synchronous: used when the process is exiting. */
369
+ killAllNow() {
370
+ for (const task of this.tasks.values()) {
371
+ if (task.info.status === 'running')
372
+ this.killTree(task, true);
373
+ }
374
+ }
375
+ }
376
+ const liveManagers = new Set();
377
+ let exitHookInstalled = false;
378
+ let instance = null;
379
+ export function getBackgroundManager() {
380
+ if (!instance) {
381
+ instance = new BackgroundManager();
382
+ instance.setMaxListeners(50);
383
+ }
384
+ return instance;
385
+ }
386
+ /** For tests: a fresh manager. */
387
+ export function resetBackgroundManager() {
388
+ void instance?.stopAll();
389
+ instance = null;
390
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Saving the user's config when several devx processes run at once.
3
+ *
4
+ * Every process keeps its own copy of ~/.devxrc.json in memory. Writing that copy back whole would
5
+ * let the process that saves last overwrite what the others changed meanwhile (theme, API keys,
6
+ * "Always" permissions, the default model...). So a save writes only what THIS process changed
7
+ * since it last looked at the file (`base` -> `mine`) on top of what is on disk now (`disk`).
8
+ */
9
+ const isPlain = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
10
+ function isPrimitiveList(v) {
11
+ return Array.isArray(v) && v.every((x) => x === null || ['string', 'number', 'boolean'].includes(typeof x));
12
+ }
13
+ /** Structural equality for JSON-like values. */
14
+ export function sameValue(a, b) {
15
+ if (a === b)
16
+ return true;
17
+ if (Array.isArray(a) && Array.isArray(b))
18
+ return a.length === b.length && a.every((x, i) => sameValue(x, b[i]));
19
+ if (isPlain(a) && isPlain(b)) {
20
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
21
+ for (const k of keys)
22
+ if (!sameValue(a[k], b[k]))
23
+ return false;
24
+ return true;
25
+ }
26
+ return false;
27
+ }
28
+ /** A deep copy that is safe to keep as a baseline. */
29
+ export function cloneConfig(value) {
30
+ return value === undefined ? value : JSON.parse(JSON.stringify(value));
31
+ }
32
+ /**
33
+ * `disk` with the changes that `base` -> `mine` made applied to it.
34
+ * - a value that did not change is left as it is on disk (another process may have changed it);
35
+ * - objects (apiKeys, trustedProjects...) are merged key by key;
36
+ * - lists of plain values (the bash allowlist) get what this process added and lose what it removed.
37
+ */
38
+ export function mergeConfigChanges(base, mine, disk) {
39
+ const result = { ...disk };
40
+ const keys = new Set([...Object.keys(base), ...Object.keys(mine)]);
41
+ for (const key of keys) {
42
+ const b = base[key];
43
+ const m = mine[key];
44
+ if (sameValue(b, m))
45
+ continue;
46
+ if (m === undefined) {
47
+ delete result[key];
48
+ }
49
+ else if (isPlain(m) && (b === undefined || isPlain(b)) && (disk[key] === undefined || isPlain(disk[key]))) {
50
+ result[key] = mergeConfigChanges(isPlain(b) ? b : {}, m, isPlain(disk[key]) ? disk[key] : {});
51
+ }
52
+ else if (isPrimitiveList(m) && (b === undefined || isPrimitiveList(b)) && (disk[key] === undefined || isPrimitiveList(disk[key]))) {
53
+ const old = b ?? [];
54
+ const now = m;
55
+ const added = now.filter((x) => !old.includes(x));
56
+ const removed = old.filter((x) => !now.includes(x));
57
+ // Emptying a list ("/permissions clear") really empties it.
58
+ result[key] = m.length === 0
59
+ ? []
60
+ : [...new Set([...(disk[key] ?? []).filter((x) => !removed.includes(x)), ...added])];
61
+ }
62
+ else {
63
+ result[key] = m;
64
+ }
65
+ }
66
+ return result;
67
+ }
@@ -23,6 +23,7 @@ export function contextBreakdown(messages, definitions, limit) {
23
23
  user, assistant, toolResults,
24
24
  images, imageCount,
25
25
  total,
26
+ inUse: messages.some((m) => m.role !== 'system') ? total : 0,
26
27
  compactAt: Math.floor(limit * CONTEXT_USAGE_RATIO),
27
28
  free: Math.max(0, limit - total),
28
29
  };
package/dist/core/cost.js CHANGED
@@ -12,8 +12,8 @@
12
12
  */
13
13
  const PER_MILLION = 1_000_000;
14
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) {
15
+ /** The cost of the tokens at the given rates, split by what was paid for. */
16
+ export function costParts(rates, usage) {
17
17
  const prompt = Math.max(0, usage.promptTokens || 0);
18
18
  const tier = rates.tier && prompt > rates.tier.over ? rates.tier : null;
19
19
  const input = tier?.input ?? rates.input;
@@ -23,17 +23,31 @@ export function computeCost(rates, usage) {
23
23
  const read = Math.min(prompt, Math.max(0, usage.cacheReadTokens || 0));
24
24
  const written = Math.min(prompt - read, Math.max(0, usage.cacheWriteTokens || 0));
25
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
26
  const completion = Math.max(0, usage.completionTokens || 0);
29
27
  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;
28
+ const out = rates.reasoning !== undefined && reasoning > 0
29
+ ? (completion - reasoning) * output + reasoning * rates.reasoning
30
+ : completion * output;
31
+ // Without a cache price the cached tokens cost as much as ordinary input.
32
+ return {
33
+ input: (plain * input) / PER_MILLION,
34
+ cacheRead: (read * (cacheRead ?? input)) / PER_MILLION,
35
+ cacheWrite: (written * (cacheWrite ?? input)) / PER_MILLION,
36
+ output: out / PER_MILLION,
37
+ };
38
+ }
39
+ /** The cost of the tokens at the given rates. */
40
+ export function computeCost(rates, usage) {
41
+ const p = costParts(rates, usage);
42
+ return p.input + p.cacheRead + p.cacheWrite + p.output;
43
+ }
44
+ /** The same parts scaled so that they add up to `total` (the cost the provider reported). */
45
+ export function scaleParts(parts, total) {
46
+ const sum = parts.input + parts.cacheRead + parts.cacheWrite + parts.output;
47
+ if (!(sum > 0) || !Number.isFinite(total))
48
+ return undefined;
49
+ const k = total / sum;
50
+ return { input: parts.input * k, cacheRead: parts.cacheRead * k, cacheWrite: parts.cacheWrite * k, output: parts.output * k };
37
51
  }
38
52
  /** A price as the OpenRouter list writes it: USD per token, often a string. */
39
53
  function perToken(value) {