jaitus 0.3.14 → 0.3.17
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 +40 -4
- package/src/api.js +177 -174
- package/src/commands/update.js +8 -3
- package/src/compact.js +224 -0
- package/src/errors.js +2 -0
- package/src/index.js +220 -119
- package/src/machine.js +227 -0
- package/src/tui/ansi.js +65 -1
- package/src/tui/entries.js +23 -11
- package/src/tui/markdown.js +40 -15
- package/src/tui-ink.js +2922 -2841
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
|
+
}
|
package/src/errors.js
CHANGED
|
@@ -13,6 +13,8 @@ const CATALOG = {
|
|
|
13
13
|
account_suspended: { title: 'Account suspended', hint: 'Contact support if you think this is a mistake.' },
|
|
14
14
|
email_unverified: { title: 'Email not verified', hint: 'Open the verification link in your inbox, then retry.' },
|
|
15
15
|
free_limit_reached: { title: 'Free prompts used up', hint: 'Subscribe on the website, then retry this turn.' },
|
|
16
|
+
free_machine_limit: { title: 'No free prompts on this account', hint: 'Subscribe on the website to keep using Jaitus on this account.' },
|
|
17
|
+
free_needs_cli: { title: 'Update Jaitus for free prompts', hint: 'Run npm i -g jaitus, then try again.' },
|
|
16
18
|
free_request_too_large: { title: 'Too large for the free plan', hint: 'Run /compact or start a new session, or subscribe.' },
|
|
17
19
|
plan_limit_reached: { title: 'Monthly allowance used', hint: 'It resets at the start of your next period, or upgrade on the website. /usage shows where you are.' },
|
|
18
20
|
rate_limited: { title: 'Slow down', hint: 'Wait a moment, then retry.' },
|