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 +2 -2
- package/src/agent/loop.js +89 -14
- package/src/api.js +7 -0
- package/src/index.js +20 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jaitus",
|
|
3
|
-
"version": "0.3.
|
|
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:
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
338
|
-
|
|
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
|
-
//
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
const
|
|
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
|
-
|
|
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' ? '
|
|
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' ? '
|
|
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' ? '
|
|
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;
|