jaitus 0.3.9 → 0.3.11

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.9",
3
+ "version": "0.3.11",
4
4
  "description": "Jaitus — a coding agent in your terminal.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent/loop.js CHANGED
@@ -353,6 +353,13 @@ async function streamTurn({ client, messages, model, system, tools, onText, onTo
353
353
  }
354
354
  };
355
355
  let buffer = '';
356
+ // A stream that never ends must die here, not in GC hell: the buffer below grows
357
+ // with every chunk and each arrival re-splits all of it, so a runaway provider
358
+ // (twelve silent minutes and counting, seen in the wild) grows memory
359
+ // quadratically until V8 falls over near 4 GB. Past the cap the turn ends as a
360
+ // cut (continued from what arrived) or, with nothing received, as a retryable
361
+ // timeout — never as a crash.
362
+ const MAX_STREAM_BUFFER = 8 * 1024 * 1024;
356
363
  // A failure reported INSIDE the stream, after a 200. Recorded here and thrown after
357
364
  // the parse below: handle() runs inside a try whose catch exists to skip keepalives,
358
365
  // so throwing from it would be swallowed — which is how a mid-stream error used to
@@ -412,6 +419,13 @@ async function streamTurn({ client, messages, model, system, tools, onText, onTo
412
419
  try {
413
420
  for await (const chunk of res.body) {
414
421
  buffer += decoder.decode(chunk, { stream: true });
422
+ if (buffer.length > MAX_STREAM_BUFFER) {
423
+ // Breaking the loop releases the reader (the socket is cancelled), so the
424
+ // runaway stops arriving too — not just stops being kept.
425
+ if (!blocks.size) throw new JaitusError({ code: 'timeout', status: 504, message: 'Jaitus took too long to respond. Try again.' });
426
+ cut = true;
427
+ break;
428
+ }
415
429
  const frames = buffer.split('\n\n');
416
430
  buffer = frames.pop() || '';
417
431
  for (const frame of frames) {
package/src/index.js CHANGED
@@ -1181,7 +1181,8 @@ async function session({ flags }) {
1181
1181
  ledger.note('compacted', `The conversation was automatically summarised to free context: ${dropped} messages became one summary. Detail you are not shown above is genuinely gone — ask or re-read rather than assuming.`);
1182
1182
  } catch (error) {
1183
1183
  // Not fatal: the turn can still run, it just runs closer to the ceiling.
1184
- tui.notice?.(`Auto-compaction failed (${error.message}) — continuing without it. /compact retries.`, 'warn');
1184
+ const id = isJaitusError(error) && error.requestId ? ` (request id ${error.requestId})` : '';
1185
+ tui.notice?.(`Auto-compaction failed (${error.message}${id}) — continuing without it. /compact retries.`, 'warn');
1185
1186
  ledger.note('compact-failed', `Automatic context compaction failed (${error.message}); the conversation is close to the context limit.`);
1186
1187
  } finally {
1187
1188
  tui.clearStatus?.();
@@ -1830,7 +1831,11 @@ async function slashCommand(verb, args, ctx) {
1830
1831
  ok('Conversation compacted. Context freed.');
1831
1832
  try { ctx.resetContext?.(); } catch { /* header keeps last value */ }
1832
1833
  } catch (e) {
1833
- fail(`Compact failed: ${e.message}`);
1834
+ // The request id is the only thing that ties this failure to a backend log
1835
+ // line. fail() takes a string, so a JaitusError passed bare would shed it —
1836
+ // and "where do I get the request id" is exactly what that produces.
1837
+ const id = isJaitusError(e) && e.requestId ? ` (request id ${e.requestId})` : '';
1838
+ fail(`Compact failed: ${e.message}${id}`);
1834
1839
  } finally {
1835
1840
  spin.stop();
1836
1841
  }
package/src/rewind.js CHANGED
@@ -29,11 +29,29 @@ export function createJournal() {
29
29
  return {
30
30
  /** Snapshot the conversation before a user prompt is processed. */
31
31
  checkpoint(messages, files = []) {
32
+ // Checkpoints are full deep clones with NO size cap (only file snapshots are
33
+ // capped above) — with pasted images in history, 60 checkpoints of megabytes
34
+ // of base64 each is a straight road to heap death. Past the cap the CHECKPOINT
35
+ // copy keeps its structure but drops the pixels (live messages are untouched):
36
+ // rewinding across an image restores a note, not the pixels. Undo stays usable
37
+ // instead of OOMing the session.
38
+ const MAX_CHECKPOINT_BYTES = 4_000_000;
39
+ let snap;
40
+ try {
41
+ const raw = JSON.stringify(messages);
42
+ snap = raw.length > MAX_CHECKPOINT_BYTES
43
+ ? JSON.parse(JSON.stringify(messages, (key, value) =>
44
+ (key === 'data' && typeof value === 'string' && value.length > 100_000
45
+ ? '[image data omitted from checkpoint]' : value)))
46
+ : JSON.parse(raw);
47
+ } catch {
48
+ snap = [];
49
+ }
32
50
  checkpoints.push({
33
51
  seq: ++seqCounter,
34
52
  at: new Date().toISOString(),
35
53
  prompt: messages.filter((m) => m.role === 'user' && typeof m.content === 'string').at(-1)?.content?.slice(0, 120) || '',
36
- messages: JSON.parse(JSON.stringify(messages)),
54
+ messages: snap,
37
55
  files,
38
56
  });
39
57
  if (checkpoints.length > 60) checkpoints.shift();