jaitus 0.3.1 → 0.3.3

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.1",
3
+ "version": "0.3.3",
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/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",
19
+ "test": "node tests/source-sanity.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/context-budget.js",
20
20
  "preview": "node scripts/tui-preview.js",
21
21
  "prepublishOnly": "npm test"
22
22
  },
package/src/agent/loop.js CHANGED
@@ -31,6 +31,23 @@ import { fromResponse, fromGatewayBody } from '../errors.js';
31
31
 
32
32
  const MAX_ITERATIONS = 40;
33
33
 
34
+ // Room for a long answer or a large file in one reply. The models behind Jaitus reason
35
+ // before they answer, and that reasoning is spent from the same budget — at 8192 a long
36
+ // answer, or any file of a few hundred lines, was cut off partway.
37
+ export const MAX_OUTPUT_TOKENS = 32000;
38
+
39
+ // A reply that stopped before it finished — the output limit, or a connection dropped
40
+ // somewhere between the provider and here — is continued, not presented as the answer.
41
+ // Bounded, so a model that never finishes cannot loop on the user's allowance.
42
+ export const MAX_CONTINUATIONS = 4;
43
+ export const CONTINUE_NOTE = 'Your previous reply was cut off before it finished (an output limit or a dropped connection). '
44
+ + 'Continue from exactly where it stopped: do not repeat or summarise what you already wrote, and do not mention this note. '
45
+ + 'If you were in the middle of a tool call, make that call again in full.';
46
+
47
+ // A request that failed before anything arrived (a network blip) is tried again this
48
+ // many times before it is reported.
49
+ const NETWORK_RETRIES = 2;
50
+
34
51
  /** opencode tool icon per tool, muted when done. ASCII twins included. */
35
52
  const TOOL_MARKS = {
36
53
  run_command: ['$', '$'], read_file: ['→', '>'], read_image: ['→', '>'],
@@ -145,7 +162,7 @@ ${project}
145
162
  async function streamTurn({ client, messages, model, system, tools, onText, onToolStart, signal }) {
146
163
  const res = await client.messages({
147
164
  model,
148
- max_tokens: 8192,
165
+ max_tokens: MAX_OUTPUT_TOKENS,
149
166
  system: system || SYSTEM_PROMPT,
150
167
  tools,
151
168
  messages,
@@ -158,6 +175,10 @@ async function streamTurn({ client, messages, model, system, tools, onText, onTo
158
175
  // Blocks are keyed by the index the stream gives them, because they interleave.
159
176
  const blocks = new Map();
160
177
  let stopReason = null;
178
+ // Whether the stream ended the way a finished message ends. A stream that just stops
179
+ // — no message_stop, no stop reason — was cut, and used to be taken as a complete,
180
+ // silent answer: the "it stops midway for no reason" bug.
181
+ let sawStop = false;
161
182
  const usage = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 };
162
183
  // Usage can arrive at the START of a stream (Anthropic-format providers) or only at
163
184
  // the END, in message_delta (OpenAI-format providers, translated by the gateway —
@@ -208,6 +229,9 @@ async function streamTurn({ client, messages, model, system, tools, onText, onTo
208
229
  if (event.delta?.stop_reason) stopReason = event.delta.stop_reason;
209
230
  takeUsage(event.usage);
210
231
  break;
232
+ case 'message_stop':
233
+ sawStop = true;
234
+ break;
211
235
  default:
212
236
  break;
213
237
  }
@@ -222,21 +246,31 @@ async function streamTurn({ client, messages, model, system, tools, onText, onTo
222
246
  // independently would corrupt it. The decoder holds the partial bytes until complete.
223
247
  const decoder = new TextDecoder();
224
248
 
225
- for await (const chunk of res.body) {
226
- buffer += decoder.decode(chunk, { stream: true });
227
- const frames = buffer.split('\n\n');
228
- buffer = frames.pop() || '';
229
- for (const frame of frames) {
230
- for (const line of frame.split('\n')) {
231
- if (!line.startsWith('data:')) continue;
232
- const raw = line.slice(5).trim();
233
- if (!raw || raw === '[DONE]') continue;
234
- try { handle(JSON.parse(raw)); } catch { /* keepalive or partial */ }
249
+ let cut = false;
250
+ try {
251
+ for await (const chunk of res.body) {
252
+ buffer += decoder.decode(chunk, { stream: true });
253
+ const frames = buffer.split('\n\n');
254
+ buffer = frames.pop() || '';
255
+ for (const frame of frames) {
256
+ for (const line of frame.split('\n')) {
257
+ if (!line.startsWith('data:')) continue;
258
+ const raw = line.slice(5).trim();
259
+ if (!raw || raw === '[DONE]') continue;
260
+ try { handle(JSON.parse(raw)); } catch { /* keepalive or partial */ }
261
+ }
235
262
  }
263
+ if (streamError) break;
236
264
  }
237
- if (streamError) break;
265
+ } catch (e) {
266
+ if (signal?.aborted || e?.name === 'AbortError') throw e;
267
+ // The connection dropped mid-answer. What arrived is kept and continued from; with
268
+ // nothing received yet, the caller retries the request instead.
269
+ if (!blocks.size) throw e;
270
+ cut = true;
238
271
  }
239
272
  if (streamError) throw streamError;
273
+ if (cut || (!sawStop && !stopReason)) stopReason = 'cut';
240
274
 
241
275
  const content = [...blocks.entries()]
242
276
  .sort((a, b) => a[0] - b[0])
@@ -285,6 +319,12 @@ export async function runAgent({
285
319
  ...Object.fromEntries(mcp.map((tool) => [tool.name, tool])),
286
320
  };
287
321
 
322
+ let continuations = 0;
323
+ let networkRetries = 0;
324
+ // True while the previous reply is being continued: its text is still on screen as
325
+ // one live message, and the continuation must extend it rather than start another.
326
+ let continuing = false;
327
+
288
328
  for (let iteration = 0; iteration < maxIterations; iteration++) {
289
329
  let streamed = false;
290
330
  const spin = tui ? { stop: () => {}, update: () => {} } : (silent ? { stop: () => {}, update: () => {} } : spinner(glyph('Working…', 'Working...')));
@@ -323,6 +363,15 @@ export async function runAgent({
323
363
  spin.stop();
324
364
  // Esc during streaming: stop mid-sentence, keep what landed, don't treat it
325
365
  // as an error. The partial text is already on screen.
366
+ // A network failure before anything arrived: try again, briefly, before reporting.
367
+ // Anything the gateway itself refused (a limit, a sign-in problem) has a status and
368
+ // is reported at once — retrying it would only repeat the refusal.
369
+ if (!(signal?.aborted || e?.name === 'AbortError') && !e?.status && networkRetries < NETWORK_RETRIES) {
370
+ networkRetries += 1;
371
+ await new Promise((r) => setTimeout(r, 800 * networkRetries));
372
+ iteration -= 1;
373
+ continue;
374
+ }
326
375
  if (signal?.aborted || e?.name === 'AbortError') {
327
376
  if (tui) tui.flushStream();
328
377
  if (streamed && !silent) out('');
@@ -334,8 +383,16 @@ export async function runAgent({
334
383
  throw e;
335
384
  }
336
385
  spin.stop();
337
- if (tui) tui.flushStream();
338
- if (streamed) { if (tui) tui.printStream('\n'); else if (!silent && !onText) out(''); }
386
+ networkRetries = 0;
387
+ const incomplete = turn.stopReason === 'max_tokens' || turn.stopReason === 'cut';
388
+ // A tool call cut off halfway has JSON that is not whole; it is dropped and the model
389
+ // is asked to make it again.
390
+ if (incomplete) turn.content = turn.content.filter((b) => b.type !== 'tool_use' || b.input?.__parse_error === undefined);
391
+ const willContinue = incomplete && continuations < MAX_CONTINUATIONS && !turn.content.some((b) => b.type === 'tool_use');
392
+ if (!willContinue) {
393
+ if (tui) tui.flushStream();
394
+ if (streamed || continuing) { if (tui) tui.printStream('\n'); else if (!silent && !onText) out(''); }
395
+ }
339
396
 
340
397
  totals.input += turn.usage.input_tokens || 0;
341
398
  totals.cached += (turn.usage.cache_read_input_tokens || 0) + (turn.usage.cache_creation_input_tokens || 0);
@@ -346,6 +403,24 @@ export async function runAgent({
346
403
  // appeared; now it moves after every step of the turn.
347
404
  tui?.setTurnTokens?.(totals.input + totals.cached + totals.output);
348
405
 
406
+ if (willContinue) {
407
+ continuations += 1;
408
+ continuing = true;
409
+ const text = turn.content.filter((b) => b.type === 'text');
410
+ if (text.length) messages.push({ role: 'assistant', content: text });
411
+ messages.push({ role: 'user', content: [{ type: 'text', text: CONTINUE_NOTE }] });
412
+ onEvent?.({ type: 'continued', reason: turn.stopReason, count: continuations });
413
+ continue;
414
+ }
415
+ continuing = false;
416
+ if (incomplete && !turn.content.some((b) => b.type === 'tool_use')) {
417
+ // Still unfinished after every continuation: say so, rather than let a partial
418
+ // answer pass as a whole one.
419
+ const why = 'The reply was still unfinished after several continuations. Ask for the rest, or narrow the request.';
420
+ if (tui?.notice) tui.notice(why, 'warn');
421
+ else if (!silent) out(c.yellow(` ${why}`));
422
+ }
423
+
349
424
  // Append the assistant turn verbatim — tool_use blocks and all.
350
425
  messages.push({ role: 'assistant', content: turn.content });
351
426
  onEvent?.({ type: 'assistant_turn', content: turn.content, usage: turn.usage, iteration });
package/src/api.js CHANGED
@@ -10,6 +10,10 @@ import { fromGatewayBody, fromNetwork, JaitusError } from './errors.js';
10
10
 
11
11
  export class NotSignedIn extends Error {}
12
12
 
13
+ // The context window the gateway last advertised, or null before it has been asked.
14
+ let contextWindowSeen = null;
15
+ export const advertisedContextWindow = () => contextWindowSeen;
16
+
13
17
  export function createClient() {
14
18
  const { baseUrl } = endpoints();
15
19
  if (!signedIn()) throw new NotSignedIn('Not signed in. Run `jaitus login` first.');
@@ -63,6 +67,9 @@ export function createClient() {
63
67
  const body = await res.json().catch(() => ({}));
64
68
  if (!res.ok) throw fromGatewayBody(res.status, body, { requestId: res.headers.get('x-request-id') || '' });
65
69
  const models = (body?.data || []).map((m) => m?.id).filter(Boolean);
70
+ // How much conversation fits, as the gateway computed it for this account.
71
+ const ctx = (body?.data || []).map((m) => Number(m?.context_window)).find((n) => n > 0);
72
+ if (ctx) contextWindowSeen = ctx;
66
73
  // An empty list is a service fault, not something the user can fix — and naming
67
74
  // the gateway's model map to them would be naming our internals.
68
75
  if (!models.length) throw new JaitusError({ code: 'unavailable', status: 502 });
package/src/index.js CHANGED
@@ -9,7 +9,7 @@
9
9
  import path from 'node:path';
10
10
  import fs from 'node:fs';
11
11
  import { spawn, spawnSync } from 'node:child_process';
12
- import { createClient, NotSignedIn } from './api.js';
12
+ import { createClient, NotSignedIn, advertisedContextWindow } from './api.js';
13
13
  import { fromResponse, isJaitusError } from './errors.js';
14
14
  import { signedIn } from './session.js';
15
15
  import {
@@ -300,10 +300,18 @@ function gitBranch(cwd) {
300
300
  }
301
301
  }
302
302
 
303
- // Used only to fill the context meter. The real ceiling is the model's, which the
304
- // gateway does not advertise; this is the conservative floor of what any model it
305
- // serves accepts, so the bar reads "getting full" before anything actually breaks.
306
- const CONTEXT_BUDGET = 180_000;
303
+ // How much conversation a session carries before it is compacted. The gateway works it
304
+ // out from the providers' real limits and the admin's cap, and advertises it on its
305
+ // model list; this floor is only used until that answer arrives, or if it never does.
306
+ const CONTEXT_FALLBACK = 128_000;
307
+ let CONTEXT_BUDGET = CONTEXT_FALLBACK;
308
+ async function loadContextBudget(client) {
309
+ try {
310
+ if (!advertisedContextWindow()) await client.models();
311
+ CONTEXT_BUDGET = advertisedContextWindow() || CONTEXT_FALLBACK;
312
+ } catch { /* keep the floor */ }
313
+ return CONTEXT_BUDGET;
314
+ }
307
315
 
308
316
  // Compact at this fraction of the budget. Low enough that the summarisation request
309
317
  // itself still fits comfortably, high enough that a normal session never sees it.
@@ -548,6 +556,7 @@ async function session({ flags }) {
548
556
  let [model, device] = await Promise.all([
549
557
  resolveModel(client, flags.model),
550
558
  probeDevice({}).catch(() => ({ vt: !!process.stdout.isTTY, sixel: false })),
559
+ loadContextBudget(client),
551
560
  ]);
552
561
  // Degrade on EVIDENCE, not on silence.
553
562
  //
@@ -792,9 +801,11 @@ async function session({ flags }) {
792
801
  spent.turns += 1;
793
802
  // opencode assistant metadata: ▣ Mode · model · duration.
794
803
  const secs = ((Date.now() - turnStarted) / 1000).toFixed(1);
795
- const posture = permissions.mode === 'plan' ? 'Read-only'
804
+ // The same words the header uses for the mode. "Off" on its own read as though
805
+ // something was broken; it meant approvals were off.
806
+ const posture = permissions.mode === 'plan' ? 'Plan'
796
807
  : permissions.mode === 'acceptEdits' ? 'Auto-edit'
797
- : permissions.mode === 'bypassPermissions' ? 'Off' : 'Ask';
808
+ : permissions.mode === 'bypassPermissions' ? 'Bypass' : 'Ask';
798
809
  const meta = ` ${c.brand(glyph('▣', '*'))} ${posture} ${c.dim(`· ${model} · ${secs}s`)}`;
799
810
  if (tui) tui.print(meta);
800
811
  else out(meta);
@@ -1131,7 +1142,7 @@ async function session({ flags }) {
1131
1142
  const { columns: termColumns } = await import('./ui.js');
1132
1143
  const approval = permissions.mode === 'plan' ? 'read-only'
1133
1144
  : permissions.mode === 'acceptEdits' ? 'auto-edit'
1134
- : permissions.mode === 'bypassPermissions' ? 'off' : 'ask';
1145
+ : permissions.mode === 'bypassPermissions' ? 'bypass' : 'ask';
1135
1146
  for (const line of renderClassicOpen({
1136
1147
  version: VERSION, model, approval, cwd: flags.cwd,
1137
1148
  account: creds?.user?.email ? `${creds.user.email}${creds.user.isPaid ? '' : ' (free plan)'}` : null,
@@ -2062,7 +2073,7 @@ async function slashCommand(verb, args, ctx) {
2062
2073
  // Session header on demand, codex-style.
2063
2074
  const modeApproval = ctx.permissions.mode === 'plan' ? 'read-only'
2064
2075
  : ctx.permissions.mode === 'acceptEdits' ? 'auto-edit'
2065
- : ctx.permissions.mode === 'bypassPermissions' ? 'off' : 'ask';
2076
+ : ctx.permissions.mode === 'bypassPermissions' ? 'bypass' : 'ask';
2066
2077
  ctx.tui.print(` ${c.dim(`jaitus v${VERSION}`)}`);
2067
2078
  ctx.tui.print(` ${c.dim(`model ${ctx.model} · approval ${modeApproval} · ${ctx.flags.cwd}`)}`);
2068
2079
  return null;