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 +2 -2
- package/src/agent/loop.js +49 -5
- package/src/agent/tools.js +11 -5
- package/src/api.js +6 -3
- package/src/commands/update.js +83 -52
- package/src/compact.js +224 -0
- package/src/errors.js +2 -0
- package/src/index.js +227 -122
- package/src/machine.js +192 -0
- package/src/serviceNotice.js +151 -0
- package/src/sessions.js +8 -0
- package/src/tui/ansi.js +65 -1
- package/src/tui/entries.js +15 -9
- package/src/tui/markdown.js +40 -15
- package/src/tui-ink.js +2922 -2619
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jaitus",
|
|
3
|
-
"version": "0.3.
|
|
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
|
|
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
|
|
281
|
-
|
|
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,
|
package/src/agent/tools.js
CHANGED
|
@@ -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
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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':
|
|
97
|
-
|
|
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,
|
package/src/commands/update.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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(['
|
|
127
|
-
|
|
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
|
|
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
|
|
177
|
-
*
|
|
178
|
-
*
|
|
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
|
|
182
|
-
*
|
|
183
|
-
* day,
|
|
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
|
-
*
|
|
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 = [],
|
|
204
|
+
export async function autoUpdate({ argv = [], env = process.env, current = version(), log = out,
|
|
189
205
|
check = () => checkRegistry(), method = () => installMethod(), install = null,
|
|
190
|
-
relaunch = defaultRelaunch,
|
|
191
|
-
|
|
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
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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(
|
|
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
|
-
|
|
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(`
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
|
|
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
|
+
}
|