jaitus 0.3.16 → 0.3.18

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.16",
3
+ "version": "0.3.18",
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/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",
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 && node tests/plan-percent.js",
20
20
  "preview": "node scripts/tui-preview.js",
21
21
  "prepublishOnly": "npm test"
22
22
  },
package/src/compact.js CHANGED
@@ -1,224 +1,286 @@
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
- }
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
+ // A reasoning model given a big part can spend its whole output on hidden reasoning and
109
+ // write nothing. Asked again with more room first; still nothing → the part is made
110
+ // smaller (summarise) and, past that, the session gets a mechanical digest.
111
+ const SUMMARY_OUTPUT = [6000, 16000];
112
+
113
+ async function summaryRequest(client, model, prompt, signal) {
114
+ for (const maxTokens of SUMMARY_OUTPUT) {
115
+ // Streamed, like every other turn: the proxy in front of the gateway cuts a response
116
+ // that is silent for 100 s, and a long summary used to be exactly that.
117
+ const turn = await streamTurn({
118
+ client,
119
+ model,
120
+ maxTokens,
121
+ system: 'You write precise working summaries of coding sessions.',
122
+ messages: [{ role: 'user', content: prompt }],
123
+ signal,
124
+ });
125
+ // Half a summary would silently become the whole memory of the session.
126
+ if (turn.stopReason === 'cut') throw new JaitusError({ code: 'interrupted', message: 'The summary was cut off on the way. Try /compact again.' });
127
+ const text = (turn.content || []).filter((b) => b.type === 'text').map((b) => b.text).join('\n').trim();
128
+ if (text) return text;
129
+ }
130
+ throw new JaitusError({ code: 'summary_empty', message: 'the summary came back empty' });
131
+ }
132
+
133
+ // Faults that a smaller part can fix.
134
+ const RETRY_SMALLER = new Set([...SIZE_FAULTS, 'summary_empty']);
135
+
136
+ /**
137
+ * The summary of last resort: built from the conversation itself, no model call, so it
138
+ * cannot fail. What the person asked (their words), the files touched, the commands run
139
+ * and where the work stood. Detail is lost — less than in the conversation, more than
140
+ * nothing — and the session keeps working instead of retrying a failing summary forever.
141
+ */
142
+ export function digestSummary(messages) {
143
+ const asks = [];
144
+ const files = new Set();
145
+ const commands = [];
146
+ let lastAnswer = '';
147
+ for (const m of messages || []) {
148
+ const blocks = typeof m.content === 'string' ? [{ type: 'text', text: m.content }] : Array.isArray(m.content) ? m.content : [];
149
+ for (const b of blocks) {
150
+ if (b?.type === 'text' && b.text?.trim()) {
151
+ if (m.role === 'user') asks.push(clip(b.text.trim(), 1500));
152
+ else lastAnswer = b.text.trim();
153
+ } else if (b?.type === 'tool_use') {
154
+ const input = b.input || {};
155
+ for (const k of ['path', 'file_path', 'file', 'filename']) if (typeof input[k] === 'string') files.add(input[k]);
156
+ if (Array.isArray(input.paths)) for (const p of input.paths) if (typeof p === 'string') files.add(p);
157
+ if (typeof input.command === 'string') commands.push(clip(input.command, 200));
158
+ }
159
+ }
160
+ }
161
+ const out = ['(A model summary could not be made, so this is a mechanical digest of the earlier conversation.)'];
162
+ if (asks.length) out.push('1. Request — what the user said, oldest first:', ...asks.slice(-25).map((a) => `- ${a}`));
163
+ if (files.size) out.push('3. Files touched:', ...[...files].slice(-80).map((f) => `- ${f}`));
164
+ if (commands.length) out.push('4. Commands run (latest):', ...commands.slice(-20).map((c) => `- ${c}`));
165
+ if (lastAnswer) out.push('6. Current state — the last thing the assistant said:', clip(lastAnswer, 3000));
166
+ return clip(out.join('\n'), 40_000);
167
+ }
168
+
169
+ /**
170
+ * Summarise `messages`. `contextTokens` is the window the gateway advertises; the
171
+ * transcript is cut into parts that fit it, and each part is folded into the summary so
172
+ * far (a rolling summary — nothing in the middle is dropped). A part the provider still
173
+ * refuses as too large is retried at half the size, down to a floor.
174
+ */
175
+ export async function summarise(client, model, messages, focus = '', { contextTokens = 128_000, signal, onProgress } = {}) {
176
+ const lines = transcriptLines(messages);
177
+ let budget = summaryBudgetChars(contextTokens);
178
+ const focusNote = focus ? ` Focus on: ${focus}.` : '';
179
+ for (let attempt = 0; ; attempt++) {
180
+ const parts = chunkLines(lines, budget);
181
+ try {
182
+ let summary = '';
183
+ for (let i = 0; i < parts.length; i++) {
184
+ if (parts.length > 1) onProgress?.(i + 1, parts.length);
185
+ const prompt = summary
186
+ ? `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`
187
+ + `Write ONE updated summary that keeps everything that still matters from it and adds this part. ${HEADINGS}${focusNote}`
188
+ + `\n\n<conversation part="${i + 1} of ${parts.length}">\n${parts[i]}\n</conversation>`
189
+ : 'Summarize this conversation for a successor agent that will continue the work with no other memory of it. '
190
+ + `${HEADINGS}${focusNote}\n\n<conversation>\n${parts[i]}\n</conversation>`;
191
+ summary = await summaryRequest(client, model, prompt, signal);
192
+ }
193
+ return summary;
194
+ } catch (e) {
195
+ if (!RETRY_SMALLER.has(e?.code) || budget <= 20_000 || attempt >= 3) throw e;
196
+ budget = Math.max(20_000, Math.floor(budget / 2));
197
+ }
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Whether to compact before the next step. Past the threshold — unless the conversation
203
+ * is no bigger than a compaction already left it (+10%): then what remains is the newest
204
+ * exchange, and compacting again would only repeat itself on every step.
205
+ */
206
+ export function needsAutoCompact(used, threshold, compactedAt = 0) {
207
+ if (used < threshold) return false;
208
+ return !(compactedAt > 0 && used <= compactedAt * 1.1);
209
+ }
210
+
211
+ /** The single message a summary becomes. */
212
+ export const summaryMessage = (summary) => ({ role: 'user', content: `[Earlier conversation, summarized:]\n${summary}\n\n[Continue from here.]` });
213
+
214
+ /**
215
+ * Where to cut so the newest exchange stays word for word. Between the steps of a task
216
+ * the history ends with the model's tool calls and their results: those stay, so the
217
+ * task carries on from them. Before a new prompt the last message is the person's: it
218
+ * stays, so their question is sent whole instead of as a summary of it.
219
+ */
220
+ export function tailStart(messages) {
221
+ const n = messages.length;
222
+ if (n < 2) return n;
223
+ const last = messages[n - 1];
224
+ const prev = messages[n - 2];
225
+ const hasResults = Array.isArray(last?.content) && last.content.some((b) => b?.type === 'tool_result');
226
+ if (last?.role === 'user' && hasResults && prev?.role === 'assistant') return n - 2;
227
+ if (last?.role === 'user') return n - 1;
228
+ return n;
229
+ }
230
+
231
+ /**
232
+ * Replace `messages` (in place) with a summary of everything before the tail, followed
233
+ * by the tail. Returns what happened, or null when there was too little to compact.
234
+ */
235
+ export async function compactInPlace(client, model, messages, { focus = '', contextTokens, keepTail = true, signal, onProgress } = {}) {
236
+ const cut = keepTail ? tailStart(messages) : messages.length;
237
+ const head = messages.slice(0, cut);
238
+ if (!head.length) return null;
239
+ const tail = messages.slice(cut);
240
+ const before = estimateTokens(messages);
241
+ let summary;
242
+ let digest = false;
243
+ try {
244
+ summary = await summarise(client, model, head, focus, { contextTokens, signal, onProgress });
245
+ } catch (e) {
246
+ // Stopped by the person: leave the conversation as it was.
247
+ if (signal?.aborted || e?.name === 'AbortError') throw e;
248
+ // Anything else (an empty or cut summary, a provider fault): the digest. Leaving the
249
+ // conversation at the limit only made the next step try — and fail — again.
250
+ summary = digestSummary(head);
251
+ digest = true;
252
+ }
253
+ messages.length = 0;
254
+ // The tail after a summary must still alternate: summary (user) → assistant tool calls
255
+ // → user results. A tail that starts with the person's message is joined to the
256
+ // summary instead, so two user messages never sit side by side.
257
+ if (tail[0]?.role === 'user') {
258
+ const t = tail[0];
259
+ const blocks = typeof t.content === 'string' ? [{ type: 'text', text: t.content }] : t.content;
260
+ messages.push({ role: 'user', content: [{ type: 'text', text: summaryMessage(summary).content }, ...blocks] }, ...tail.slice(1));
261
+ } else {
262
+ messages.push(summaryMessage(summary), ...tail);
263
+ }
264
+ return { before, after: estimateTokens(messages), dropped: head.length, digest };
265
+ }
266
+
267
+ /**
268
+ * Images are the usual reason a request is too large in BYTES. Every image except those
269
+ * in the newest message becomes a short note.
270
+ */
271
+ export function dropOldImages(messages) {
272
+ let dropped = 0;
273
+ for (let i = 0; i < messages.length - 1; i++) {
274
+ const m = messages[i];
275
+ if (!Array.isArray(m.content)) continue;
276
+ m.content = m.content.map((b) => {
277
+ if (b?.type === 'image') { dropped++; return { type: 'text', text: '[an image was here; removed to keep the request small]' }; }
278
+ if (b?.type === 'tool_result' && Array.isArray(b.content) && b.content.some((c) => c?.type === 'image')) {
279
+ dropped++;
280
+ return { ...b, content: b.content.map((c) => (c?.type === 'image' ? { type: 'text', text: '[an image was here; removed to keep the request small]' } : c)) };
281
+ }
282
+ return b;
283
+ });
284
+ }
285
+ return dropped;
286
+ }
package/src/errors.js CHANGED
@@ -13,15 +13,16 @@ 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: 'This computer already has its free accounts. Subscribe on the website to use this one.' },
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_check: { title: 'Unlock your free prompts', hint: 'A one-time ₹1 UPI check at jaitus.com/billing (refunded). Or subscribe.' },
17
18
  free_needs_cli: { title: 'Update Jaitus for free prompts', hint: 'Run npm i -g jaitus, then try again.' },
18
19
  free_request_too_large: { title: 'Too large for the free plan', hint: 'Run /compact or start a new session, or subscribe.' },
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.' },
20
+ plan_limit_reached: { title: 'Monthly usage used up', hint: 'It resets at the start of your next period, or upgrade on the website. /usage shows where you are.' },
20
21
  rate_limited: { title: 'Slow down', hint: 'Wait a moment, then retry.' },
21
22
  context_too_long: { title: 'Conversation too long', hint: 'Run /compact, or start a new session.' },
22
23
  request_too_large: { title: 'Request too large', hint: 'Usually images: remove some, or run /compact to drop old ones. Images over 3.5 MB are shrunk automatically.' },
23
24
  daily_limit_reached: { title: 'Usage limit reached', hint: 'It refills automatically. /usage shows where you are.' },
24
- window_limit_reached: { title: 'This usage window is used up', hint: 'It refills when the window ends — the rest of your month is still there. /usage shows when.' },
25
+ window_limit_reached: { title: 'Usage limit reached', hint: 'It resets by itself — /usage shows your 5-hour session and week, and when each resets.' },
25
26
  token_expired: { title: 'Signed out', hint: 'Type /login to sign in again (or run `jaitus login`).' },
26
27
  refresh_race: { title: 'Sign-in was being renewed', hint: 'Try again.' },
27
28
  content_blocked: { title: 'Request refused', hint: 'Rephrase the request and try again.' },