jaitus 0.3.13 → 0.3.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jaitus",
3
- "version": "0.3.13",
3
+ "version": "0.3.16",
4
4
  "description": "Jaitus — a coding agent in your terminal.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,7 @@
16
16
  "node": ">=20"
17
17
  },
18
18
  "scripts": {
19
- "test": "node tests/source-sanity.js && node tests/react-build.js && node tests/stalls.js && node tests/clipboard-write.js && node tests/rewind-images.js && node tests/rules.js && node tests/run.js && node tests/commands.js && node tests/login.js && node tests/session.js && node tests/openUrl.js && node tests/ink-tui.js && node tests/tui-render.js && node tests/mouse-e2e.js && node tests/environment.js && node tests/update.js && node tests/continuation.js && node tests/steps.js && node tests/files.js && node tests/processes.js && node tests/security.js && node tests/context-budget.js && node tests/context-trim.js",
19
+ "test": "node tests/source-sanity.js && node tests/react-build.js && node tests/stalls.js && node tests/clipboard-write.js && node tests/rewind-images.js && node tests/rules.js && node tests/run.js && node tests/commands.js && node tests/login.js && node tests/session.js && node tests/openUrl.js && node tests/ink-tui.js && node tests/tui-render.js && node tests/mouse-e2e.js && node tests/environment.js && node tests/update.js && node tests/service-notices.js && node tests/continuation.js && node tests/steps.js && node tests/files.js && node tests/processes.js && node tests/security.js && node tests/context-budget.js && node tests/context-trim.js && node tests/compaction.js && node tests/copy-clean.js && node tests/parallel-tools.js",
20
20
  "preview": "node scripts/tui-preview.js",
21
21
  "prepublishOnly": "npm test"
22
22
  },
package/src/agent/loop.js CHANGED
@@ -32,6 +32,7 @@ import { c, out, write, spinner, glyph } from '../ui.js';
32
32
  import { discoverMcpTools } from '../mcp.js';
33
33
  import { unifiedDiff } from '../diff.js';
34
34
  import { fromResponse, fromGatewayBody, JaitusError } from '../errors.js';
35
+ import { explainFailure } from '../serviceNotice.js';
35
36
 
36
37
  const MAX_ITERATIONS = 1000;
37
38
  const STUCK_REPEATS = 4;
@@ -76,7 +77,10 @@ export function retryDelay(e, attempt = 0) {
76
77
  return hinted <= MAX_RETRY_WAIT_MS ? hinted : null;
77
78
  }
78
79
  if (!TRANSIENT_CODES.has(code) || code === 'internal') return null;
79
- return Math.min(BLIP_MAX_WAIT_MS, hinted ?? 2000 * 2 ** attempt);
80
+ if (hinted != null) return Math.min(BLIP_MAX_WAIT_MS, hinted);
81
+ // ±25% spread: every CLI cut off by the same restart would otherwise retry in the
82
+ // same instant and arrive together.
83
+ return Math.min(BLIP_MAX_WAIT_MS, Math.round(2000 * 2 ** attempt * (0.75 + Math.random() * 0.5)));
80
84
  }
81
85
 
82
86
  function sleepUnlessAborted(ms, signal) {
@@ -258,6 +262,12 @@ export function repairToolPairs(messages) {
258
262
  export const TRIM_AT = 40_000; // estimated tokens of conversation
259
263
  export const KEEP_RECENT = 6; // latest tool results always kept whole
260
264
  export const TRIM_AT_LEAST = 12_000; // estimated tokens a clearing must free
265
+ // Past DEEP_AT only the newest KEEP_RECENT_DEEP results stay whole. Production, 7 days to
266
+ // 2 Oct: requests carrying 150k+ tokens were a quarter of all requests and 62% of all
267
+ // credits — every step of a long task re-sent the outputs of its last six tools whole,
268
+ // up to 60,000 characters each.
269
+ export const DEEP_AT = 100_000;
270
+ export const KEEP_RECENT_DEEP = 3;
261
271
  const TRIM_MIN_CHARS = 1_500; // results shorter than this are never cleared
262
272
  const CLEARED = '[Output cleared';
263
273
 
@@ -277,8 +287,10 @@ export function conversationTokens(messages) {
277
287
  * Clear old tool outputs in place when the conversation is large. Returns how many
278
288
  * estimated tokens were freed (0 when nothing was worth clearing).
279
289
  */
280
- export function clearOldToolResults(messages, { at = TRIM_AT, keep = KEEP_RECENT, atLeast = TRIM_AT_LEAST } = {}) {
281
- if (conversationTokens(messages) < at) return 0;
290
+ export function clearOldToolResults(messages, { at = TRIM_AT, keep, atLeast = TRIM_AT_LEAST } = {}) {
291
+ const size = conversationTokens(messages);
292
+ if (size < at) return 0;
293
+ if (keep === undefined) keep = size >= DEEP_AT ? KEEP_RECENT_DEEP : KEEP_RECENT;
282
294
  const names = new Map();
283
295
  for (const m of messages) {
284
296
  if (m?.role === 'assistant' && Array.isArray(m.content)) for (const b of m.content) if (b?.type === 'tool_use') names.set(b.id, b.name);
@@ -302,6 +314,9 @@ export function clearOldToolResults(messages, { at = TRIM_AT, keep = KEEP_RECENT
302
314
  return freed;
303
315
  }
304
316
 
317
+ // Tools that change nothing and ask nothing: several in one batch run at the same time.
318
+ const PARALLEL_SAFE = new Set(['read_file', 'glob', 'list_files', 'search', 'web_search', 'web_fetch', 'background_list', 'background_read', 'load_skill']);
319
+
305
320
  export const IMAGE_BUDGET = 6_000_000;
306
321
  export function withinImageBudget(messages, budget = IMAGE_BUDGET) {
307
322
  let used = 0;
@@ -491,7 +506,7 @@ export async function runAgent({
491
506
  client, messages, model, cwd, permissions, signal, tui = null,
492
507
  extraSystem = '', maxIterations = MAX_ITERATIONS, silent = false,
493
508
  onText = null, onEvent = null, systemOverride = null, journal = null,
494
- subagents = true, onContext = null, trimContext = true,
509
+ subagents = true, onContext = null, trimContext = true, beforeStep = null,
495
510
  }) {
496
511
  // Loop detection. A call is identified by its tool, its input, and how many files had
497
512
  // been changed when it was made — so edit → test → edit → test is progress (the count
@@ -540,6 +555,11 @@ export async function runAgent({
540
555
  const freed = clearOldToolResults(messages);
541
556
  if (freed && tui?.notice) tui.notice(`Cleared old tool output (~${Math.round(freed / 1000)}k tokens) to keep each request small.`, 'info');
542
557
  }
558
+ // The caller may compact the history between steps (index.js: when the context is
559
+ // nearly full). Never before the first step — the caller already checked then.
560
+ if (beforeStep && iteration > 0 && !signal?.aborted) {
561
+ try { await beforeStep(messages); } catch { /* a failed compaction is reported by the caller; the step still runs */ }
562
+ }
543
563
  let streamed = false;
544
564
  const spin = tui ? { stop: () => {}, update: () => {} } : (silent ? { stop: () => {}, update: () => {} } : spinner(glyph('Working…', 'Working...')));
545
565
  if (tui) tui.setStatus(`${c.brand(glyph('•', '*'))} ${c.brand(glyph('Working…', 'Working...'))}`);
@@ -610,6 +630,10 @@ export async function runAgent({
610
630
  else if (!silent) out(c.yellow(` ${glyph('⏹', '[x]')} Interrupted.`));
611
631
  return { ...totals, stopReason: 'interrupted' };
612
632
  }
633
+ // Out of retries: say what the status page knows (back-at time, known problem).
634
+ // Asked of the same service the request went to; a client with no address (a test
635
+ // double) asks nothing.
636
+ if (client?.baseUrl) await explainFailure(e, { baseUrl: client.baseUrl }).catch(() => {});
613
637
  throw e;
614
638
  }
615
639
  spin.stop();
@@ -720,6 +744,26 @@ export async function runAgent({
720
744
  return { ...totals, stopReason: 'stuck' };
721
745
  }
722
746
 
747
+ // Read-only calls in one batch run AT THE SAME TIME. The model is told to batch
748
+ // independent reads, and then they ran one after another anyway: five file reads or
749
+ // three searches cost five or three round trips of wall time. Only tools that change
750
+ // nothing and need no permission start early; their rows, results and order are
751
+ // exactly as before — the loop below just finds the work already done. Anything
752
+ // that writes, runs a command or asks the user still runs one by one, in order.
753
+ const early = new Map();
754
+ const parallel = calls.filter((call) => {
755
+ const t = toolMap[call.name];
756
+ return t && PARALLEL_SAFE.has(call.name) && !t.needsPermission && call.input?.__parse_error === undefined
757
+ && !planBlocked.has(call.id) && permissions?.verdict?.(call.name, call.input, cwd) !== 'deny';
758
+ });
759
+ if (parallel.length > 1 && !signal?.aborted) {
760
+ for (const call of parallel) {
761
+ const started = Promise.resolve().then(() => toolMap[call.name].run(call.input, { cwd, signal }));
762
+ started.catch(() => { /* reported when the loop reaches the call */ });
763
+ early.set(call.id, started);
764
+ }
765
+ }
766
+
723
767
  // Every call gets a result, whatever happened.
724
768
  const results = [];
725
769
  for (const call of calls) {
@@ -803,7 +847,7 @@ export async function runAgent({
803
847
  } else if (journal && call.name === 'run_command') {
804
848
  journal.snapshotTracked(cwd);
805
849
  }
806
- const output = await tool.run(call.input, { cwd, signal });
850
+ const output = await (early.get(call.id) ?? tool.run(call.input, { cwd, signal }));
807
851
  spin2.stop(); stopElapsed();
808
852
  const took = ((Date.now() - toolStarted) / 1000).toFixed(1);
809
853
  // opencode tool icons: $ shell, ← edits, → reads, ✱ search,
@@ -746,11 +746,17 @@ export const TOOLS = [
746
746
  async run(input) {
747
747
  if (input.clear) { todoState.splice(0, todoState.length); return 'Task list cleared.'; }
748
748
  if (!Array.isArray(input.todos)) throw new ToolError('Give a todos array (or clear: true).');
749
- const cleaned = input.todos.map((t) => ({
750
- task: String(t?.task || '').trim(),
751
- status: ['pending', 'in_progress', 'completed'].includes(t?.status) ? t.status : 'pending',
752
- })).filter((t) => t.task);
753
- if (!cleaned.length) throw new ToolError('The list has no usable items.');
749
+ // Models trained on other agents name the step `content` (or title, text…) and the
750
+ // status "done" or "in-progress". Only `task` used to count, so a perfectly good
751
+ // plan was refused as "no usable items" and the list never appeared.
752
+ const STATUS = { pending: 'pending', todo: 'pending', not_started: 'pending', in_progress: 'in_progress', inprogress: 'in_progress', active: 'in_progress', doing: 'in_progress', started: 'in_progress', completed: 'completed', complete: 'completed', done: 'completed', finished: 'completed' };
753
+ const cleaned = input.todos.map((t) => {
754
+ const item = typeof t === 'string' ? { task: t } : (t || {});
755
+ const text = [item.task, item.content, item.title, item.text, item.description, item.step, item.name].find((v) => typeof v === 'string' && v.trim());
756
+ const key = String(item.status || '').trim().toLowerCase().replace(/[\s-]+/g, '_');
757
+ return { task: String(text || '').trim(), status: STATUS[key] || (item.done === true || item.completed === true ? 'completed' : 'pending') };
758
+ }).filter((t) => t.task);
759
+ if (!cleaned.length) throw new ToolError('The list has no usable items. Each item is { "task": "one step", "status": "pending" | "in_progress" | "completed" }.');
754
760
  todoState.splice(0, todoState.length, ...cleaned);
755
761
  return `Task list updated (${cleaned.filter((t) => t.status === 'completed').length}/${cleaned.length} done).`;
756
762
  },
package/src/api.js CHANGED
@@ -4,9 +4,10 @@
4
4
  // a JaitusError here, at the edge, so no caller ever has an HTTP body in its hands to
5
5
  // print — see errors.js for why that matters.
6
6
 
7
- import { endpoints } from './config.js';
7
+ import { endpoints, version } from './config.js';
8
8
  import { signedIn, withAuth } from './session.js';
9
9
  import { fromGatewayBody, fromNetwork, JaitusError } from './errors.js';
10
+ import { machineHeader } from './machine.js';
10
11
 
11
12
  export class NotSignedIn extends Error {}
12
13
 
@@ -93,8 +94,10 @@ export function createClient() {
93
94
 
94
95
  // x-jaitus-features: what this build understands, so the gateway can use newer error
95
96
  // codes (a used-up usage window) without an older CLI reading them as "went wrong".
96
- const base = { 'Content-Type': 'application/json', 'user-agent': 'jaitus-cli', 'x-jaitus-features': 'usage-window' };
97
- const headers = (token) => ({ ...base, authorization: `Bearer ${token}` });
97
+ const base = { 'Content-Type': 'application/json', 'user-agent': `jaitus-cli/${version()}`, 'x-jaitus-features': 'usage-window' };
98
+ // x-jaitus-device: this computer's hashed identifiers (machine.js), which the free
99
+ // tier is limited by. Built once per process.
100
+ const headers = (token) => ({ ...base, 'x-jaitus-device': machineHeader(), authorization: `Bearer ${token}` });
98
101
 
99
102
  return {
100
103
  baseUrl,
@@ -14,7 +14,7 @@ import fs from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import { spawn } from 'node:child_process';
16
16
  import { fileURLToPath } from 'node:url';
17
- import { c, out, ok, info, warn, fail, present, askKey } from '../ui.js';
17
+ import { c, out, ok, info, warn, fail, present } from '../ui.js';
18
18
  import { version, HOME } from '../config.js';
19
19
  import { RUNNING_FILE } from '../pendingUpdate.js';
20
20
 
@@ -89,7 +89,7 @@ export async function updateNotice(opts = {}) {
89
89
  const current = opts.current || version();
90
90
  const latest = await cachedLatest(opts).catch(() => null);
91
91
  if (!latest || !isNewer(latest, current)) return null;
92
- return `Jaitus v${latest} is out — you have v${current}. Quit and run \`jaitus update\` to install it.`;
92
+ return `Jaitus v${latest} is out — you have v${current}. Quit and start jaitus again: it updates itself as it starts.`;
93
93
  }
94
94
 
95
95
  function run(cmd, args, { capture = false } = {}) {
@@ -121,10 +121,15 @@ function run(cmd, args, { capture = false } = {}) {
121
121
  export function installGlobal(v, { onTick = () => {}, onStart = () => {} } = {}) {
122
122
  return new Promise((resolve) => {
123
123
  const args = ['install', '-g', `jaitus@${v}`, '--no-audit', '--no-fund', '--loglevel=error'];
124
- const opts = { stdio: ['ignore', 'ignore', 'pipe'], detached: true, windowsHide: true };
124
+ // Windows: `detached` gives the child a console of its OWN — the update opened a
125
+ // second terminal window. Instead npm runs in this window through `start /b`, which
126
+ // makes it ignore Ctrl+C (cmd's documented behaviour), so it still cannot be cut off
127
+ // halfway; delayed expansion hands npm's exit code back through `start /wait`.
128
+ // Elsewhere a detached process group does the same without any window.
125
129
  const child = process.platform === 'win32'
126
- ? spawn(['npm', ...args].join(' '), { ...opts, shell: true })
127
- : spawn('npm', args, opts);
130
+ ? spawn('cmd.exe', ['/d', '/v:on', '/s', '/c', `"start "" /b /wait npm ${args.join(' ')} & exit !errorlevel!"`],
131
+ { stdio: ['ignore', 'ignore', 'pipe'], windowsHide: true, windowsVerbatimArguments: true })
132
+ : spawn('npm', args, { stdio: ['ignore', 'ignore', 'pipe'], detached: true });
128
133
  if (child.pid) onStart(child.pid);
129
134
  let stderr = '';
130
135
  child.stderr?.on('data', (d) => { stderr = (stderr + d).slice(-4000); });
@@ -147,7 +152,14 @@ export async function installMethod({ here = fileURLToPath(import.meta.url), npm
147
152
  const norm = (p) => path.resolve(p).toLowerCase();
148
153
  if (/[\\/]_npx[\\/]/.test(real)) return 'npx';
149
154
  const root = npmRoot ?? (await run('npm', ['root', '-g'], { capture: true })).stdout.trim();
150
- if (root && norm(real).startsWith(norm(path.join(root, 'jaitus')) + path.sep.toLowerCase())) return 'npm-global';
155
+ if (!root) return 'other';
156
+ // Both sides through their links: with nvm (and similar) npm's global folder is a link
157
+ // to the active Node's folder, so this file's real path never started with the
158
+ // reported one, and a perfectly normal npm install was skipped as "not npm".
159
+ const realRoot = (() => { try { return fs.realpathSync(root); } catch { return root; } })();
160
+ for (const r of new Set([root, realRoot])) {
161
+ if (norm(real).startsWith(norm(path.join(r, 'jaitus')) + path.sep.toLowerCase())) return 'npm-global';
162
+ }
151
163
  return 'other';
152
164
  }
153
165
 
@@ -173,56 +185,70 @@ export function isNewer(a, b) {
173
185
  }
174
186
 
175
187
  /**
176
- * Update on launch, then reopen: every time `jaitus` starts, the registry is asked
177
- * (with a short timeout, so a slow network costs at most that); when a newer version is
178
- * out and this copy is a global npm install, it is installed and the same command is
179
- * started again on the new version.
188
+ * Update on launch, then reopen. Every time `jaitus` starts in a terminal, the registry
189
+ * is asked — always, not from a cache — and when a newer version is out it is installed,
190
+ * with progress on screen, and the same command is started again on the new version.
180
191
  *
181
- * It never blocks the session on a failure. A failed install (commonly: a system-wide
182
- * Node on macOS or Linux, where npm -g needs sudo) says so once and is not retried for a
183
- * day, so a machine that cannot update does not pay for trying on every start.
192
+ * It used to skip far too often: the registry got 1.5 s (a slower answer meant this
193
+ * launch went ahead on the old version), one failed install switched updating off for a
194
+ * day, a copy whose npm folder is reached through a link (nvm and similar) was taken for
195
+ * "not an npm install" and skipped without a word, and a setting could turn it off. Now:
196
+ * - the check waits up to `waitMs` (10 s), saying "Checking for updates…" while it does;
197
+ * - a failed install says why and how to fix it, and is tried again on the next launch;
198
+ * - a copy that cannot update itself (npx, a linked or vendored copy) says so, every time;
199
+ * - only CI, scripts/pipes and JAITUS_NO_AUTOUPDATE (for tests and automation) skip it.
184
200
  *
185
- * Off with `jaitus config autoUpdate off`, JAITUS_NO_AUTOUPDATE=1, NO_UPDATE_NOTIFIER or CI.
201
+ * It never blocks the session on a failure: the user is told, and the app starts.
186
202
  * Returns the child's exit code when it relaunched, or null to carry on in this process.
187
203
  */
188
- export async function autoUpdate({ argv = [], settings = {}, env = process.env, current = version(), log = out,
204
+ export async function autoUpdate({ argv = [], env = process.env, current = version(), log = out,
189
205
  check = () => checkRegistry(), method = () => installMethod(), install = null,
190
- relaunch = defaultRelaunch, now = Date.now(), failFile = path.join(HOME, 'update-failed.json'),
191
- cacheFile = CHECK_FILE, waitMs = 1500, write = (s) => process.stdout.write(s),
192
- runningFile = RUNNING_FILE } = {}) {
206
+ relaunch = defaultRelaunch, cacheFile = CHECK_FILE, waitMs = 10_000, write = (s) => process.stdout.write(s),
207
+ runningFile = RUNNING_FILE, showAfterMs = 300 } = {}) {
193
208
  if (env.JAITUS_UPDATED_FROM) return null; // just relaunched: never loop
194
- if (!updateCheckEnabled(env) || env.JAITUS_NO_AUTOUPDATE || settings.autoUpdate === false) return null;
209
+ if (!updateCheckEnabled(env) || env.JAITUS_NO_AUTOUPDATE) return null;
195
210
  if (!process.stdout.isTTY || !process.stdin.isTTY) return null; // scripts and pipes get a stable version
196
211
  const first = argv.find((a) => !a.startsWith('-'));
197
212
  if (['update', 'completion', 'help', 'logout'].includes(first) || argv.some((a) => ['--version', '-v', '--help', '-h', '--print', '-p'].includes(a))) return null;
198
- try {
199
- const failed = JSON.parse(fs.readFileSync(failFile, 'utf8'));
200
- if (now - Number(failed.at) < DAY_MS) return null;
201
- } catch { /* no recent failure */ }
202
213
 
203
- // What the last check found. A check that outlived its wait on a slow connection still
204
- // finishes and records its answer here, so the NEXT launch updates without waiting —
205
- // before this, a network slower than the wait skipped the update on every launch.
206
214
  const remember = (latest) => {
207
215
  try { fs.mkdirSync(path.dirname(cacheFile), { recursive: true }); fs.writeFileSync(cacheFile, JSON.stringify({ checkedAt: Date.now(), latest: latest || null })); } catch { /* read-only home */ }
208
216
  };
209
- let latest = null;
210
- try {
211
- const cached = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
212
- if (cached?.latest && isNewer(cached.latest, current) && now - Number(cached.checkedAt) < 6 * 3600e3) latest = cached.latest;
213
- } catch { /* no earlier answer */ }
217
+ // Ask the registry. A quick answer shows nothing; a slow one says what is happening.
218
+ let shown = false;
219
+ const showing = setTimeout(() => { shown = true; write(` ${c.dim('Checking for updates…')}`); }, showAfterMs);
220
+ // An answer that arrives after the wait is still recorded, for the next launch.
221
+ const asked = Promise.resolve().then(check).then((r) => { if (r?.version) remember(r.version); return r; }, () => ({ version: null, status: 'unreachable' }));
222
+ const answer = await Promise.race([
223
+ asked,
224
+ new Promise((resolve) => setTimeout(() => resolve({ version: null, status: 'timeout' }), waitMs).unref()),
225
+ ]);
226
+ clearTimeout(showing);
227
+ if (shown) write('\r\x1b[2K');
228
+ let latest = answer?.version || null;
229
+ // (an answer is recorded by `asked` itself, on time or late)
214
230
  if (!latest) {
215
- const asked = Promise.resolve().then(check).then((r) => { if (r?.version) remember(r.version); return r; }, () => ({ version: null }));
216
- ({ version: latest } = await Promise.race([
217
- asked,
218
- // unref: a quick command must not sit waiting for this timer after the answer came.
219
- new Promise((resolve) => setTimeout(() => resolve({ version: null }), waitMs).unref()),
220
- ]));
231
+ // No answer this time. A newer version seen earlier is still worth installing.
232
+ try {
233
+ const cached = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
234
+ if (cached?.latest && isNewer(cached.latest, current)) latest = cached.latest;
235
+ } catch { /* no earlier answer */ }
236
+ if (!latest) {
237
+ if (answer?.status !== 'unpublished') log(` ${c.dim(`Could not reach npm to check for updates — starting v${current}.`)}`);
238
+ return null;
239
+ }
240
+ }
241
+ if (!isNewer(latest, current)) return null;
242
+
243
+ const how = await method();
244
+ if (how !== 'npm-global') {
245
+ log(` ${c.yellow('!')} ${c.bold(`Jaitus v${latest}`)} ${c.dim(`is out — you have v${current}, and this copy cannot update itself.`)}`);
246
+ log(` ${c.dim(how === 'npx' ? 'Start it with' : 'Update it with')} ${c.bold(how === 'npx' ? 'npx jaitus@latest' : 'npm install -g jaitus@latest')}`);
247
+ return null;
221
248
  }
222
- if (!latest || !isNewer(latest, current)) return null;
223
- if ((await method()) !== 'npm-global') return null; // the in-app notice covers these
224
249
 
225
250
  const head = ` ${c.brand('Jaitus')} ${c.dim(`v${current} →`)} ${c.bold(`v${latest}`)}`;
251
+ const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
226
252
  write(`${head} ${c.dim('— updating…')}`);
227
253
  // Ctrl+C now leaves npm to finish on its own (it is detached) — say so rather than
228
254
  // leaving someone to wonder whether they broke the install. Runs before the bin's own
@@ -232,7 +258,7 @@ export async function autoUpdate({ argv = [], settings = {}, env = process.env,
232
258
  };
233
259
  process.prependListener('SIGINT', onInterrupt);
234
260
  const doInstall = install || ((v) => installGlobal(v, {
235
- onTick: (s) => write(`\r${head} ${c.dim(`— updating… ${s}s (this takes about a minute)`)}`),
261
+ onTick: (s) => write(`\r\x1b[2K${head} ${c.brand(frames[s % frames.length])} ${c.dim(`updating… ${s}s — this usually takes under a minute`)}`),
236
262
  // Left behind only if this process is gone before npm is — the next launch waits on it.
237
263
  onStart: (pid) => { try { fs.mkdirSync(path.dirname(runningFile), { recursive: true }); fs.writeFileSync(runningFile, JSON.stringify({ pid, at: Date.now(), version: v })); } catch { /* best effort */ } },
238
264
  }));
@@ -243,14 +269,15 @@ export async function autoUpdate({ argv = [], settings = {}, env = process.env,
243
269
  }
244
270
  write('\n');
245
271
  if (result.code !== 0) {
246
- try { fs.mkdirSync(path.dirname(failFile), { recursive: true }); fs.writeFileSync(failFile, JSON.stringify({ at: now, version: latest })); } catch { /* best effort */ }
247
272
  const needsSudo = /EACCES|permission denied/i.test(result.stderr || '');
248
- log(` ${c.yellow('!')} ${c.dim('Could not update automatically — continuing on')} v${current}${c.dim('. To update:')} ${c.bold(process.platform !== 'win32' && needsSudo ? 'sudo npm install -g jaitus@latest' : 'npm install -g jaitus@latest')}`);
273
+ const busy = /EBUSY|EPERM|resource busy|operation not permitted/i.test(result.stderr || '');
274
+ log(` ${c.yellow('!')} ${c.dim('The update to')} v${latest} ${c.dim('did not install — starting')} v${current}${c.dim('. It is tried again next time you start jaitus.')}`);
275
+ if (busy) log(` ${c.dim('Another jaitus window may be holding its files: close every jaitus window, then start it again.')}`);
276
+ log(` ${c.dim('To install it by hand:')} ${c.bold(process.platform !== 'win32' && needsSudo ? 'sudo npm install -g jaitus@latest' : 'npm install -g jaitus@latest')}`);
249
277
  const reason = (result.stderr || '').split('\n').map((l) => l.trim()).filter(Boolean).find((l) => /ERR|EACCES|EPERM|EBUSY|ENOTEMPTY/i.test(l));
250
- if (reason) log(` ${c.dim(reason.slice(0, 200))}`);
278
+ if (reason) log(` ${c.dim(reason.slice(0, 200))}`);
251
279
  return null;
252
280
  }
253
- remember(latest);
254
281
  log(` ${c.accent('✓')} ${c.dim(`Updated to v${latest} — reopening`)}`);
255
282
  return relaunch({ ...env, JAITUS_UPDATED_FROM: current });
256
283
  }
@@ -303,16 +330,20 @@ export default async function update(argv = [], { json = false, interactive = tr
303
330
  manual();
304
331
  return 0;
305
332
  }
306
- const yes = argv.includes('--yes') || argv.includes('-y');
307
- if (!yes) {
308
- if (!process.stdin.isTTY) { manual(); return 0; }
309
- const answer = await askKey(` Install v${latest} now? ${c.dim('[Y/n]')} `, ['y', 'n', '\r', '\n']);
310
- if (answer === 'n') { manual(); return 0; }
311
- }
312
- out(` ${c.dim('running')} npm install -g jaitus@${latest}`);
313
- const { code } = await run('npm', ['install', '-g', `jaitus@${latest}`]);
333
+ // Asking for the update IS the yes: it installs straight away, with the same progress
334
+ // line as an update on launch.
335
+ const tty = process.stdout.isTTY;
336
+ const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
337
+ const line = ` ${c.dim('installing')} v${latest}`;
338
+ if (tty) process.stdout.write(`${line}…`); else out(`${line} (npm install -g jaitus@${latest})`);
339
+ const { code, stderr } = await installGlobal(latest, {
340
+ onTick: (s) => { if (tty) process.stdout.write(`\r\x1b[2K${line} ${c.brand(frames[s % frames.length])} ${c.dim(`${s}s — this usually takes under a minute`)}`); },
341
+ });
342
+ if (tty) process.stdout.write('\n');
314
343
  if (code !== 0) {
315
344
  fail(`npm could not install it (exit ${code}). Try it yourself — on macOS or Linux it may need sudo, or a Node version manager:`);
345
+ const reason = (stderr || '').split('\n').map((l) => l.trim()).filter(Boolean).find((l) => /ERR|EACCES|EPERM|EBUSY|ENOTEMPTY/i.test(l));
346
+ if (reason) info(reason.slice(0, 200));
316
347
  manual();
317
348
  return 1;
318
349
  }
package/src/compact.js ADDED
@@ -0,0 +1,224 @@
1
+ // ─── Compaction: summarising a conversation so it fits again ──────────────────────
2
+ //
3
+ // Shared by /compact, automatic compaction between prompts, compaction between the
4
+ // steps of one long task, and the automatic recovery from "too large / too long".
5
+ //
6
+ // The one rule: the summary request itself must always fit. It used to carry up to
7
+ // 360,000 characters of transcript whatever the model's window was, with pasted text and
8
+ // @mentions unclipped — so on exactly the sessions that needed it, /compact failed with
9
+ // the same "too long" error it was meant to cure, and the history stayed as it was.
10
+ // Now the transcript is sized from the window the gateway advertises, a session bigger
11
+ // than that is summarised in parts (each part folded into the summary so far), and a
12
+ // refused summary request is retried with half as much.
13
+
14
+ import { streamTurn } from './agent/loop.js';
15
+ import { JaitusError } from './errors.js';
16
+
17
+ /** Errors that mean "this request is too big" — the ones compaction can cure. */
18
+ export const SIZE_FAULTS = new Set(['context_too_long', 'request_too_large']);
19
+
20
+ /** Rough token estimate — ~4 characters per token of English. */
21
+ export function estimateTokens(messages) {
22
+ let chars = 0;
23
+ for (const m of messages || []) {
24
+ if (typeof m.content === 'string') chars += m.content.length;
25
+ else if (Array.isArray(m.content)) {
26
+ for (const b of m.content) {
27
+ if (b.type === 'text') chars += (b.text || '').length;
28
+ else if (b.type === 'tool_use') chars += JSON.stringify(b.input || {}).length;
29
+ else if (b.type === 'tool_result') {
30
+ chars += typeof b.content === 'string' ? b.content.length
31
+ : Array.isArray(b.content) ? b.content.reduce((n, c) => n + (c?.text?.length || (c?.type === 'image' ? 6000 : 0)), 0) : 0;
32
+ } else if (b.type === 'image') chars += 6000; // ~1.5k tokens, what providers count for one
33
+ }
34
+ }
35
+ }
36
+ return Math.ceil(chars / 4);
37
+ }
38
+
39
+ /** How many characters of transcript one summary request may carry for this window. */
40
+ export function summaryBudgetChars(contextTokens) {
41
+ const window = Number(contextTokens) > 0 ? Number(contextTokens) : 128_000;
42
+ // Half the window for the transcript (≈ 2 characters per token of window, code and
43
+ // JSON tokenise denser than prose), the rest for the instructions, the reply and the
44
+ // gateway's own system prompt. Bounded: a 1M window does not need a 2 MB request.
45
+ return Math.max(20_000, Math.min(240_000, Math.floor(window * 2)));
46
+ }
47
+
48
+ const clip = (s, n) => (s.length > n ? `${s.slice(0, n)}… [${s.length - n} more characters left out]` : s);
49
+
50
+ /**
51
+ * The conversation as plain lines, for summarising. Tool calls and results become short
52
+ * lines, images a note, and any one text block is clipped — the person's own words are
53
+ * kept far longer than anything a tool printed, because they are what a summary must
54
+ * not lose.
55
+ */
56
+ export function transcriptLines(messages) {
57
+ const lines = [];
58
+ for (const m of messages || []) {
59
+ const who = m.role === 'assistant' ? 'ASSISTANT' : 'USER';
60
+ const textCap = m.role === 'assistant' ? 6_000 : 12_000;
61
+ if (typeof m.content === 'string') { lines.push(`${who}: ${clip(m.content, textCap)}`); continue; }
62
+ for (const b of Array.isArray(m.content) ? m.content : []) {
63
+ if (b?.type === 'text' && b.text) lines.push(`${who}: ${clip(b.text, textCap)}`);
64
+ else if (b?.type === 'tool_use') lines.push(`${who} ran ${b.name}: ${clip(JSON.stringify(b.input ?? {}), 400)}`);
65
+ else if (b?.type === 'tool_result') {
66
+ const t = typeof b.content === 'string' ? b.content : Array.isArray(b.content) ? b.content.map((c) => c?.text || '').join('\n') : '';
67
+ lines.push(`RESULT${b.is_error ? ' (error)' : ''}: ${clip(t, 1500)}`);
68
+ } else if (b?.type === 'image') lines.push(`${who}: [an image]`);
69
+ }
70
+ }
71
+ return lines;
72
+ }
73
+
74
+ /** Lines → parts of at most `maxChars` each, cutting only between lines. */
75
+ export function chunkLines(lines, maxChars) {
76
+ const parts = [];
77
+ let cur = '';
78
+ for (const line of lines) {
79
+ const l = line.length > maxChars ? clip(line, maxChars - 80) : line;
80
+ if (cur && cur.length + l.length + 1 > maxChars) { parts.push(cur); cur = ''; }
81
+ cur = cur ? `${cur}\n${l}` : l;
82
+ }
83
+ if (cur) parts.push(cur);
84
+ return parts;
85
+ }
86
+
87
+ /** Kept for callers that want the single-request transcript (and its tests). */
88
+ export function transcriptForSummary(messages, maxChars = 240_000) {
89
+ let text = transcriptLines(messages).join('\n');
90
+ if (text.length > maxChars) {
91
+ const head = text.slice(0, Math.floor(maxChars * 0.25));
92
+ const tail = text.slice(-Math.floor(maxChars * 0.7));
93
+ text = `${head}\n\n[… the middle of the session is left out …]\n\n${tail}`;
94
+ }
95
+ return text;
96
+ }
97
+
98
+ const HEADINGS = 'Use these headings, and be specific (paths, names, numbers) rather than general:\n'
99
+ + '1. Request — what the user asked for, with their exact words for any instruction, constraint or preference.\n'
100
+ + '2. Corrections — anything the user rejected, corrected or asked to do differently, verbatim where short.\n'
101
+ + '3. Files — every file read or changed: path, what changed or what matters in it.\n'
102
+ + '4. Commands and results — what was run and what it showed (test/build results, errors and how each was fixed).\n'
103
+ + '5. Decisions — what was chosen and why.\n'
104
+ + '6. Current state — exactly what was being done when this summary was made, and what is unfinished.\n'
105
+ + '7. Next step — the single next action, if the work was mid-way.\n'
106
+ + 'Omit headings that have nothing. No preamble.';
107
+
108
+ async function summaryRequest(client, model, prompt, signal) {
109
+ // Streamed, like every other turn: the proxy in front of the gateway cuts a response
110
+ // that is silent for 100 s, and a long summary used to be exactly that.
111
+ const turn = await streamTurn({
112
+ client,
113
+ model,
114
+ // Room for the model to reason before it writes; at 2048 the reasoning could use
115
+ // it all and the summary came back empty.
116
+ maxTokens: 6000,
117
+ system: 'You write precise working summaries of coding sessions.',
118
+ messages: [{ role: 'user', content: prompt }],
119
+ signal,
120
+ });
121
+ // Half a summary would silently become the whole memory of the session.
122
+ if (turn.stopReason === 'cut') throw new JaitusError({ code: 'interrupted', message: 'The summary was cut off on the way. Try /compact again.' });
123
+ const text = (turn.content || []).filter((b) => b.type === 'text').map((b) => b.text).join('\n').trim();
124
+ if (!text) throw new Error('the summary came back empty');
125
+ return text;
126
+ }
127
+
128
+ /**
129
+ * Summarise `messages`. `contextTokens` is the window the gateway advertises; the
130
+ * transcript is cut into parts that fit it, and each part is folded into the summary so
131
+ * far (a rolling summary — nothing in the middle is dropped). A part the provider still
132
+ * refuses as too large is retried at half the size, down to a floor.
133
+ */
134
+ export async function summarise(client, model, messages, focus = '', { contextTokens = 128_000, signal, onProgress } = {}) {
135
+ const lines = transcriptLines(messages);
136
+ let budget = summaryBudgetChars(contextTokens);
137
+ const focusNote = focus ? ` Focus on: ${focus}.` : '';
138
+ for (let attempt = 0; ; attempt++) {
139
+ const parts = chunkLines(lines, budget);
140
+ try {
141
+ let summary = '';
142
+ for (let i = 0; i < parts.length; i++) {
143
+ if (parts.length > 1) onProgress?.(i + 1, parts.length);
144
+ const prompt = summary
145
+ ? `You are summarising a long coding session in parts. Here is the summary of everything before this part:\n<summary>\n${summary}\n</summary>\n\n`
146
+ + `Write ONE updated summary that keeps everything that still matters from it and adds this part. ${HEADINGS}${focusNote}`
147
+ + `\n\n<conversation part="${i + 1} of ${parts.length}">\n${parts[i]}\n</conversation>`
148
+ : 'Summarize this conversation for a successor agent that will continue the work with no other memory of it. '
149
+ + `${HEADINGS}${focusNote}\n\n<conversation>\n${parts[i]}\n</conversation>`;
150
+ summary = await summaryRequest(client, model, prompt, signal);
151
+ }
152
+ return summary;
153
+ } catch (e) {
154
+ if (!SIZE_FAULTS.has(e?.code) || budget <= 20_000 || attempt >= 3) throw e;
155
+ budget = Math.max(20_000, Math.floor(budget / 2));
156
+ }
157
+ }
158
+ }
159
+
160
+ /** The single message a summary becomes. */
161
+ export const summaryMessage = (summary) => ({ role: 'user', content: `[Earlier conversation, summarized:]\n${summary}\n\n[Continue from here.]` });
162
+
163
+ /**
164
+ * Where to cut so the newest exchange stays word for word. Between the steps of a task
165
+ * the history ends with the model's tool calls and their results: those stay, so the
166
+ * task carries on from them. Before a new prompt the last message is the person's: it
167
+ * stays, so their question is sent whole instead of as a summary of it.
168
+ */
169
+ export function tailStart(messages) {
170
+ const n = messages.length;
171
+ if (n < 2) return n;
172
+ const last = messages[n - 1];
173
+ const prev = messages[n - 2];
174
+ const hasResults = Array.isArray(last?.content) && last.content.some((b) => b?.type === 'tool_result');
175
+ if (last?.role === 'user' && hasResults && prev?.role === 'assistant') return n - 2;
176
+ if (last?.role === 'user') return n - 1;
177
+ return n;
178
+ }
179
+
180
+ /**
181
+ * Replace `messages` (in place) with a summary of everything before the tail, followed
182
+ * by the tail. Returns what happened, or null when there was too little to compact.
183
+ */
184
+ export async function compactInPlace(client, model, messages, { focus = '', contextTokens, keepTail = true, signal, onProgress } = {}) {
185
+ const cut = keepTail ? tailStart(messages) : messages.length;
186
+ const head = messages.slice(0, cut);
187
+ if (!head.length) return null;
188
+ const tail = messages.slice(cut);
189
+ const before = estimateTokens(messages);
190
+ const summary = await summarise(client, model, head, focus, { contextTokens, signal, onProgress });
191
+ messages.length = 0;
192
+ // The tail after a summary must still alternate: summary (user) → assistant tool calls
193
+ // → user results. A tail that starts with the person's message is joined to the
194
+ // summary instead, so two user messages never sit side by side.
195
+ if (tail[0]?.role === 'user') {
196
+ const t = tail[0];
197
+ const blocks = typeof t.content === 'string' ? [{ type: 'text', text: t.content }] : t.content;
198
+ messages.push({ role: 'user', content: [{ type: 'text', text: summaryMessage(summary).content }, ...blocks] }, ...tail.slice(1));
199
+ } else {
200
+ messages.push(summaryMessage(summary), ...tail);
201
+ }
202
+ return { before, after: estimateTokens(messages), dropped: head.length };
203
+ }
204
+
205
+ /**
206
+ * Images are the usual reason a request is too large in BYTES. Every image except those
207
+ * in the newest message becomes a short note.
208
+ */
209
+ export function dropOldImages(messages) {
210
+ let dropped = 0;
211
+ for (let i = 0; i < messages.length - 1; i++) {
212
+ const m = messages[i];
213
+ if (!Array.isArray(m.content)) continue;
214
+ m.content = m.content.map((b) => {
215
+ if (b?.type === 'image') { dropped++; return { type: 'text', text: '[an image was here; removed to keep the request small]' }; }
216
+ if (b?.type === 'tool_result' && Array.isArray(b.content) && b.content.some((c) => c?.type === 'image')) {
217
+ dropped++;
218
+ return { ...b, content: b.content.map((c) => (c?.type === 'image' ? { type: 'text', text: '[an image was here; removed to keep the request small]' } : c)) };
219
+ }
220
+ return b;
221
+ });
222
+ }
223
+ return dropped;
224
+ }