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/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: 'This computer already has its free accounts. Subscribe on the website to use this one.' },
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.' },
package/src/index.js CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  endpoints, version, HOME, HISTORY,
18
18
  } from './config.js';
19
19
  import { runAgent, repairToolPairs, streamTurn } from './agent/loop.js';
20
+ import { estimateTokens, summarise, compactInPlace, dropOldImages, SIZE_FAULTS } from './compact.js';
20
21
  import { createPermissions, MODES as PERMISSION_MODES } from './agent/permissions.js';
21
22
  import { DEFAULT_DENY } from './agent/rules.js';
22
23
  import { forgetIgnoreCache, imageBlock, IMAGE_MIME } from './agent/tools.js';
@@ -45,6 +46,8 @@ import login from './commands/login.js';
45
46
  import logout from './commands/logout.js';
46
47
  import doctor from './commands/doctor.js';
47
48
  import update, { updateNotice, autoUpdate } from './commands/update.js';
49
+ import { startupNotice } from './serviceNotice.js';
50
+ import { warmMachineHeader } from './machine.js';
48
51
  import init from './commands/init.js';
49
52
  import research from './commands/research.js';
50
53
  import explain from './commands/explain.js';
@@ -240,21 +243,6 @@ async function resolveModel(client, preferred) {
240
243
  return available[0];
241
244
  }
242
245
 
243
- /** Rough token estimate for /context and /status — ~4 characters per token of English. */
244
- function estimateTokens(messages) {
245
- let chars = 0;
246
- for (const m of messages) {
247
- if (typeof m.content === 'string') chars += m.content.length;
248
- else if (Array.isArray(m.content)) {
249
- for (const b of m.content) {
250
- if (b.type === 'text') chars += (b.text || '').length;
251
- else if (b.type === 'tool_use') chars += JSON.stringify(b.input || {}).length;
252
- else if (b.type === 'tool_result') chars += (b.content || '').length;
253
- }
254
- }
255
- }
256
- return Math.ceil(chars / 4);
257
- }
258
246
 
259
247
  /** Clipboard write that degrades honestly: returns false when no path exists. */
260
248
  async function copyToClipboard(text) {
@@ -382,82 +370,8 @@ const compactThreshold = () => CONTEXT_BUDGET * AUTO_COMPACT_AT;
382
370
  // someone typed `@package-lock.json` would blow the context it is meant to save.
383
371
  const MENTION_MAX_BYTES = 48_000;
384
372
 
385
- /**
386
- * One summarisation call. Shared by /compact and auto-compaction so the two cannot
387
- * drift into producing different kinds of summary from the same conversation.
388
- */
389
- /**
390
- * The conversation as plain text, for summarising. Sending the raw history broke in
391
- * three ways: every image was sent again (past the gateway's size limit once there were
392
- * a few), tool calls went without a tools list (which some providers reject), and a long
393
- * session was larger than the window being compacted. Tool calls and results become
394
- * short lines, images a note, and the middle of a very long session is trimmed.
395
- */
396
- function transcriptForSummary(messages, maxChars = 360_000) {
397
- const clip = (s, n) => (s.length > n ? `${s.slice(0, n)}…` : s);
398
- const lines = [];
399
- for (const m of messages) {
400
- const who = m.role === 'assistant' ? 'ASSISTANT' : 'USER';
401
- if (typeof m.content === 'string') { lines.push(`${who}: ${m.content}`); continue; }
402
- for (const b of Array.isArray(m.content) ? m.content : []) {
403
- if (b?.type === 'text' && b.text) lines.push(`${who}: ${b.text}`);
404
- else if (b?.type === 'tool_use') lines.push(`${who} ran ${b.name}: ${clip(JSON.stringify(b.input ?? {}), 400)}`);
405
- else if (b?.type === 'tool_result') {
406
- const t = typeof b.content === 'string' ? b.content : Array.isArray(b.content) ? b.content.map((c) => c?.text || '').join('\n') : '';
407
- lines.push(`RESULT${b.is_error ? ' (error)' : ''}: ${clip(t, 1500)}`);
408
- } else if (b?.type === 'image') lines.push(`${who}: [an image]`);
409
- }
410
- }
411
- let text = lines.join('\n');
412
- if (text.length > maxChars) {
413
- const head = text.slice(0, Math.floor(maxChars * 0.25));
414
- const tail = text.slice(-Math.floor(maxChars * 0.7));
415
- text = `${head}\n\n[… the middle of the session is left out …]\n\n${tail}`;
416
- }
417
- return text;
418
- }
419
-
420
- export async function summarise(client, model, messages, focus) {
421
- // Streamed, like every other turn. Sent as one non-streamed request, nothing came
422
- // back until the whole summary was written — and the proxy in front of the gateway
423
- // cuts a response that is silent for 100 s, so a long session's summary failed with
424
- // the proxy's own error page ("Compact failed: Something went wrong"). A stream
425
- // carries the gateway's heartbeat the whole time.
426
- const turn = await streamTurn({
427
- client,
428
- model,
429
- // Room for the model to reason before it writes; at 2048 the reasoning could use
430
- // it all and the summary came back empty.
431
- maxTokens: 6000,
432
- system: 'You write precise working summaries of coding sessions.',
433
- messages: [
434
- {
435
- role: 'user',
436
- // Sections a successor needs to carry on without re-asking or re-reading — the
437
- // same structure agent tools use for their own compaction. Losing the user's
438
- // exact instructions or where the work stopped is what makes a compacted session
439
- // feel worse; everything else can be re-read from disk.
440
- content: 'Summarize this conversation for a successor agent that will continue the work with no other memory of it. '
441
- + 'Use these headings, and be specific (paths, names, numbers) rather than general:\n'
442
- + '1. Request — what the user asked for, with their exact words for any instruction, constraint or preference.\n'
443
- + '2. Corrections — anything the user rejected, corrected or asked to do differently, verbatim where short.\n'
444
- + '3. Files — every file read or changed: path, what changed or what matters in it.\n'
445
- + '4. Commands and results — what was run and what it showed (test/build results, errors and how each was fixed).\n'
446
- + '5. Decisions — what was chosen and why.\n'
447
- + '6. Current state — exactly what was being done when this summary was made, and what is unfinished.\n'
448
- + '7. Next step — the single next action, if the work was mid-way.\n'
449
- + 'Omit headings that have nothing. No preamble.'
450
- + (focus ? ` Focus on: ${focus}.` : '')
451
- + `\n\n<conversation>\n${transcriptForSummary(messages)}\n</conversation>`,
452
- },
453
- ],
454
- });
455
- // Half a summary would silently become the whole memory of the session.
456
- if (turn.stopReason === 'cut') throw new JaitusError({ code: 'interrupted', message: 'The summary was cut off on the way. Try /compact again.' });
457
- const text = (turn.content || []).filter((b) => b.type === 'text').map((b) => b.text).join('\n').trim();
458
- if (!text) throw new Error('the summary came back empty');
459
- return text;
460
- }
373
+ // Summarising lives in compact.js; re-exported for the callers and tests that import it here.
374
+ export { summarise };
461
375
 
462
376
  /**
463
377
  * `@path` in a message → the file's contents as a content block.
@@ -1051,7 +965,7 @@ async function session({ flags }) {
1051
965
  };
1052
966
 
1053
967
  /** One agent run with abort wiring, error accounting, and queue draining after. */
1054
- const runManaged = async (messages) => {
968
+ const runManaged = async (messages, { recovered = false } = {}) => {
1055
969
  const ctrl = new AbortController();
1056
970
  activeAbort = ctrl;
1057
971
  const turnStarted = Date.now();
@@ -1066,6 +980,9 @@ async function session({ flags }) {
1066
980
  const result = await runAgent({
1067
981
  client, messages, model, cwd: flags.cwd, permissions, signal: ctrl.signal, tui, journal,
1068
982
  onContext: (n) => { lastContext = n; sessionState.lastContext = n; showContext(); },
983
+ // Between the steps of one task: a long task used to grow past the window on its
984
+ // own, because the check only ran before a new prompt.
985
+ beforeStep: () => autoCompactIfNeeded({ signal: ctrl.signal }),
1069
986
  // `jaitus config trimToolResults off` keeps every old tool output in context.
1070
987
  trimContext: loadSettings().trimToolResults !== false,
1071
988
  // What happened since the model's last turn (a failed request, an interrupt, a
@@ -1103,7 +1020,30 @@ async function session({ flags }) {
1103
1020
  else out(meta);
1104
1021
  }
1105
1022
  } catch (e) {
1023
+ // Too big to send: compact (keeping the newest exchange whole) and drop old images,
1024
+ // then try once more — what the user would have done by hand, and what /compact
1025
+ // could not do while its own request was too big too.
1026
+ if (!recovered && SIZE_FAULTS.has(e?.code) && !ctrl.signal.aborted && messages.length > 1) {
1027
+ repairToolPairs(messages);
1028
+ const images = dropOldImages(messages);
1029
+ const compacted = await compactNow({ keepTail: true, why: `That request was too ${e.code === 'request_too_large' ? 'large to send' : 'long for the model'}${images ? ` (${images} old image${images === 1 ? '' : 's'} removed)` : ''}` });
1030
+ if (compacted || images) {
1031
+ activeAbort = null;
1032
+ return runManaged(messages, { recovered: true });
1033
+ }
1034
+ }
1106
1035
  reportAgentError(e);
1036
+ // After a recovery the newest message carries the summary of everything before it:
1037
+ // removing it would remove the whole memory of the session with it.
1038
+ if (recovered && MESSAGE_FAULTS.has(e?.code)) {
1039
+ const last = messages[messages.length - 1];
1040
+ const first = Array.isArray(last?.content) ? last.content[0] : null;
1041
+ if (last?.role === 'user' && first?.type === 'text' && String(first.text).startsWith('[Earlier conversation, summarized:]')) {
1042
+ last.content = [first];
1043
+ ledger.note('turn-failed', 'The user\'s newest message was too large to send even after the conversation was summarised, so it was removed. Ask them to send it in smaller parts (fewer or smaller images, a shorter paste).');
1044
+ return;
1045
+ }
1046
+ }
1107
1047
  // The failed turn is dropped so the next message is not sent on top of a broken
1108
1048
  // conversation the API will reject — but the model is told it happened. Without
1109
1049
  // that, its next turn begins as though nothing went wrong and it re-attempts the
@@ -1171,30 +1111,51 @@ async function session({ flags }) {
1171
1111
  * thing to talk to, and finding that out by noticing the agent forgot something is
1172
1112
  * worse than being told.
1173
1113
  */
1174
- const autoCompactIfNeeded = async () => {
1175
- if (loadSettings().autoCompact === false) return;
1176
- const used = estimateTokens(messages);
1177
- if (used < compactThreshold()) return;
1178
- if (messages.length < 4) return;
1114
+ // How full the context is. The provider's own count of the last prompt is exact and
1115
+ // includes what the character estimate cannot see (system prompt, tool schemas, the
1116
+ // gateway's prompt); the estimate covers what was added since. The larger wins — the
1117
+ // estimate alone missed both and let sessions run into the wall.
1118
+ const contextUsed = () => Math.max(lastContext || 0, estimateTokens(messages) + 6_000);
1179
1119
 
1180
- tui.setStatus?.(`Compacting — ${format(used)} of ${format(CONTEXT_BUDGET)} context used`);
1120
+ /**
1121
+ * Summarise in place. `keepTail` keeps the newest exchange word for word (the person's
1122
+ * new message, or the last tool calls and their results mid-task). `why` is for the
1123
+ * notice. Returns true when the history was rewritten.
1124
+ */
1125
+ const compactNow = async ({ keepTail = true, why = '', signal } = {}) => {
1126
+ const used = contextUsed();
1127
+ tui.setStatus?.(`${c.brand(glyph('◆', '*'))} Compacting — ${format(used)} of ${format(CONTEXT_BUDGET)} context used`);
1181
1128
  try {
1182
- const summary = await summarise(client, model, messages, '');
1183
- const dropped = messages.length;
1184
- messages.length = 0;
1185
- messages.push({ role: 'user', content: `[Earlier conversation, summarized:]\n${summary}\n\n[Continue from here.]` });
1186
- tui.notice?.(`Context was ${format(used)}; ${dropped} messages summarised into one. /rewind still has the originals.`, 'info');
1187
- 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.`);
1129
+ const r = await compactInPlace(client, model, messages, {
1130
+ contextTokens: CONTEXT_BUDGET, keepTail, signal,
1131
+ onProgress: (i, n) => tui.setStatus?.(`${c.brand(glyph('◆', '*'))} Compacting a long session — part ${i} of ${n}`),
1132
+ });
1133
+ if (!r) return false;
1134
+ lastContext = 0;
1135
+ sessionState.lastContext = 0;
1136
+ sessionState.messages = messages;
1137
+ saveSession(sessionState);
1138
+ showContext({ estimate: true });
1139
+ tui.notice?.(`${why || `Context was ${format(used)}`}; ${r.dropped} messages summarised into one. /rewind still has the originals.`, 'info');
1140
+ ledger.note('compacted', `The conversation was automatically summarised to free context: ${r.dropped} messages became one summary. Detail you are not shown above is genuinely gone — ask or re-read rather than assuming.`);
1141
+ return true;
1188
1142
  } catch (error) {
1189
- // Not fatal: the turn can still run, it just runs closer to the ceiling.
1190
1143
  const id = isJaitusError(error) && error.requestId ? ` (request id ${error.requestId})` : '';
1191
1144
  tui.notice?.(`Auto-compaction failed (${error.message}${id}) — continuing without it. /compact retries.`, 'warn');
1192
1145
  ledger.note('compact-failed', `Automatic context compaction failed (${error.message}); the conversation is close to the context limit.`);
1146
+ return false;
1193
1147
  } finally {
1194
1148
  tui.clearStatus?.();
1195
1149
  }
1196
1150
  };
1197
1151
 
1152
+ const autoCompactIfNeeded = async ({ signal } = {}) => {
1153
+ if (loadSettings().autoCompact === false) return false;
1154
+ if (contextUsed() < compactThreshold()) return false;
1155
+ if (messages.length < 3) return false;
1156
+ return compactNow({ keepTail: true, signal });
1157
+ };
1158
+
1198
1159
  const handleLineInner = async (rawLine) => {
1199
1160
  const input = rawLine.trim();
1200
1161
  if (!input) return;
@@ -1228,7 +1189,7 @@ async function session({ flags }) {
1228
1189
  tui.setBusy(true);
1229
1190
  try {
1230
1191
  result = await slashCommand(verb, args, {
1231
- client, messages, permissions, flags, spent, resetContext,
1192
+ client, messages, permissions, flags, spent, resetContext, lastContextUsed: () => contextUsed(),
1232
1193
  model, setModel: (m) => {
1233
1194
  model = m;
1234
1195
  sessionState.model = m;
@@ -1436,6 +1397,12 @@ async function session({ flags }) {
1436
1397
  // A global install never updates itself; say so when there is something newer.
1437
1398
  // Background and cached for a day — the app never waits on the network for this.
1438
1399
  void updateNotice().then((line) => { if (line) tui.notice(line, 'info'); }).catch(() => {});
1400
+ // An outage or maintenance on the Jaitus service, said once a day at most; the
1401
+ // status page has the rest. Background, short timeout, silent on failure.
1402
+ void startupNotice().then((line) => { if (line) tui.notice(line, 'warn'); }).catch(() => {});
1403
+ // The slow identifier probe (Windows hardware id) starts now, in the background, so
1404
+ // the first request already carries it (machine.js).
1405
+ warmMachineHeader();
1439
1406
  // A project's .mcp.json runs programs on this machine. Nothing in it starts until the
1440
1407
  // exact command is approved here — asked once, remembered per project and command.
1441
1408
  void (async () => {
@@ -1797,6 +1764,21 @@ async function slashCommand(verb, args, ctx) {
1797
1764
 
1798
1765
  case '/status': {
1799
1766
  const creds = loadCredentials();
1767
+ if (ctx.tui?.showMeters) {
1768
+ const used = ctx.lastContextUsed?.() || estimateTokens(ctx.messages);
1769
+ void ctx.tui.showMeters('Status', [
1770
+ { label: 'Model', value: ctx.model },
1771
+ { label: 'Mode', value: ctx.permissions.mode },
1772
+ { label: 'Account', value: creds?.user?.email || 'not signed in' },
1773
+ { label: 'Directory', value: ctx.flags.cwd },
1774
+ { gap: true },
1775
+ { meter: 'Context', used, total: CONTEXT_BUDGET, detail: `~${format(used)} of ${format(CONTEXT_BUDGET)} tokens · ${ctx.messages.length} messages`, note: used / CONTEXT_BUDGET > 0.6 ? 'compacts by itself at 78%' : '' },
1776
+ { label: 'This session', value: `${ctx.spent.turns} turns · ${ctx.spent.tools} tool calls${ctx.spent.credits ? ` · ${fmtCredits(ctx.spent.credits)} credits` : ''}` },
1777
+ { gap: true },
1778
+ { label: '', value: '/usage for your plan' },
1779
+ ]);
1780
+ return null;
1781
+ }
1800
1782
  table([
1801
1783
  [` ${c.dim('model')}`, ctx.model],
1802
1784
  [` ${c.dim('mode')}`, ctx.permissions.mode],
@@ -1810,6 +1792,21 @@ async function slashCommand(verb, args, ctx) {
1810
1792
 
1811
1793
  case '/context': {
1812
1794
  const tokens = estimateTokens(ctx.messages);
1795
+ if (ctx.tui?.showMeters) {
1796
+ const used = ctx.lastContextUsed?.() || tokens;
1797
+ const count = (pred) => ctx.messages.reduce((n, m) => n + (Array.isArray(m.content) ? m.content.filter(pred).length : 0), 0);
1798
+ void ctx.tui.showMeters('Context', [
1799
+ { meter: 'Used', used, total: CONTEXT_BUDGET, detail: `~${format(used)} of ${format(CONTEXT_BUDGET)} tokens`, note: 'compacts by itself at 78%' },
1800
+ { gap: true },
1801
+ { label: 'Messages', value: String(ctx.messages.length) },
1802
+ { label: 'Tool results', value: String(count((b) => b.type === 'tool_result')) },
1803
+ { label: 'Images', value: String(count((b) => b.type === 'image')) },
1804
+ ...(loadContext(ctx.flags.cwd) ? [{ label: 'Project notes', value: `${CONTEXT_FILE} (loaded)` }] : []),
1805
+ { gap: true },
1806
+ { label: '', value: '/compact summarises it now' },
1807
+ ]);
1808
+ return null;
1809
+ }
1813
1810
  table([
1814
1811
  [` ${c.dim('messages')}`, String(ctx.messages.length)],
1815
1812
  [` ${c.dim('user messages')}`, String(ctx.messages.filter((m) => m.role === 'user' && typeof m.content === 'string').length)],
@@ -1831,10 +1828,17 @@ async function slashCommand(verb, args, ctx) {
1831
1828
  : (await import('./ui.js')).spinner('Summarizing…');
1832
1829
  if (ctx.tui) ctx.tui.setStatus(`${c.brand(glyph('◆', '*'))} ${c.brand(glyph('Summarizing…', 'Summarizing...'))}`);
1833
1830
  try {
1834
- const text = await summarise(ctx.client, ctx.model, ctx.messages, args.join(' '));
1835
- ctx.messages.length = 0;
1836
- ctx.messages.push({ role: 'user', content: `[Earlier conversation, summarized:]\n${text}\n\n[Continue from here.]` });
1837
- ok('Conversation compacted. Context freed.');
1831
+ const before = estimateTokens(ctx.messages);
1832
+ // Images are the usual reason a request is too large in bytes; the summary keeps
1833
+ // a note of each, so nothing is lost by dropping them here.
1834
+ dropOldImages(ctx.messages);
1835
+ const r = await compactInPlace(ctx.client, ctx.model, ctx.messages, {
1836
+ focus: args.join(' '), contextTokens: CONTEXT_BUDGET, keepTail: false,
1837
+ onProgress: (i, n) => ctx.tui?.setStatus?.(`${c.brand(glyph('◆', '*'))} Summarizing a long session — part ${i} of ${n}`),
1838
+ });
1839
+ // Saved now: a crash before the next turn used to bring the whole history back.
1840
+ if (ctx.sessionState) { ctx.sessionState.messages = ctx.messages; ctx.sessionState.lastContext = 0; saveSession(ctx.sessionState); }
1841
+ ok(`Conversation compacted — about ${format(before)} tokens down to ${format(r?.after ?? estimateTokens(ctx.messages))}.`);
1838
1842
  try { ctx.resetContext?.(); } catch { /* header keeps last value */ }
1839
1843
  } catch (e) {
1840
1844
  // The request id is the only thing that ties this failure to a backend log
@@ -2567,7 +2571,7 @@ async function slashCommand(verb, args, ctx) {
2567
2571
  return null;
2568
2572
 
2569
2573
  case '/usage':
2570
- await commands.usage.run([], {});
2574
+ await commands.usage.run([], { tui: ctx.tui, spent });
2571
2575
  return null;
2572
2576
 
2573
2577
  case '/init':
@@ -2655,6 +2659,10 @@ async function slashCommand(verb, args, ctx) {
2655
2659
  }
2656
2660
 
2657
2661
  case '/cost':
2662
+ if (ctx.tui?.showMeters) {
2663
+ void ctx.tui.showMeters('This session', costSections(spent));
2664
+ return null;
2665
+ }
2658
2666
  table([
2659
2667
  [' Turns', String(spent.turns)],
2660
2668
  [' Tool calls', String(spent.tools)],
@@ -2692,6 +2700,90 @@ function fmtTokens(n) {
2692
2700
  if (v >= 1e3) return `${Math.round(v / 1e3)}K`;
2693
2701
  return String(Math.round(v));
2694
2702
  }
2703
+ /** Credits as people read them: whole numbers in the thousands, one decimal below. */
2704
+ function fmtCredits(n) {
2705
+ const v = Number(n) || 0;
2706
+ return v >= 1000 ? Math.round(v).toLocaleString('en-IN') : String(Math.round(v * 10) / 10);
2707
+ }
2708
+
2709
+ const clock = (d) => new Date(d).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
2710
+ const dayAndClock = (d) => new Date(d).toLocaleString([], { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
2711
+
2712
+ /**
2713
+ * The plan as meters: the 5-hour window, the week and the month each fill at their own
2714
+ * rate from the same requests, so each gets its own bar and its own refill time. Extra
2715
+ * credits and the free tier follow. Used by /usage (popup) and `jaitus usage` (text).
2716
+ */
2717
+ function planSections(u) {
2718
+ const st = u?.planStatus || null;
2719
+ const out = [];
2720
+ if (!u?.isPaid) {
2721
+ out.push({ heading: 'Free plan' });
2722
+ if (st?.freeTierStatus === 'locked') {
2723
+ out.push({ label: 'Free prompts', value: 'not available — this computer already has its free accounts', tone: 'bad' });
2724
+ } else {
2725
+ out.push({ meter: 'Free prompts', used: u?.freeRequestsUsed ?? 0, total: u?.freeRequestLimit || 0, detail: `${u?.freeRequestsUsed ?? 0} of ${u?.freeRequestLimit ?? '?'} used` });
2726
+ }
2727
+ } else {
2728
+ out.push({ heading: st?.name || 'Your plan' });
2729
+ if (st?.windowCredits) {
2730
+ out.push({
2731
+ meter: `${st.windowHours || 5}-hour window`, used: st.windowUsed, total: st.windowCredits,
2732
+ detail: `${fmtCredits(st.windowUsed)} of ${fmtCredits(st.windowCredits)} credits`,
2733
+ note: st.windowResetsAt ? `refills at ${clock(st.windowResetsAt)}` : 'starts with your next request',
2734
+ });
2735
+ }
2736
+ if (st?.weeklyCredits) {
2737
+ out.push({
2738
+ meter: 'This week', used: st.weekUsed, total: st.weeklyCredits,
2739
+ detail: `${fmtCredits(st.weekUsed)} of ${fmtCredits(st.weeklyCredits)} credits`,
2740
+ note: st.weekResetsAt ? `refills ${dayAndClock(st.weekResetsAt)}` : 'starts with your next request',
2741
+ });
2742
+ }
2743
+ if (st?.monthlyCredits) {
2744
+ out.push({
2745
+ meter: 'This month', used: st.periodCredits, total: st.monthlyCredits,
2746
+ detail: `${fmtCredits(st.periodCredits)} of ${fmtCredits(st.monthlyCredits)} credits`,
2747
+ note: st.periodResetsAt ? `resets ${new Date(st.periodResetsAt).toLocaleDateString([], { day: 'numeric', month: 'short' })}` : '',
2748
+ });
2749
+ }
2750
+ if (u.planExpiresAt) out.push({ label: 'Renews by', value: new Date(u.planExpiresAt).toLocaleDateString([], { day: 'numeric', month: 'short', year: 'numeric' }) });
2751
+ }
2752
+ if (st?.bonusCredits > 0) {
2753
+ out.push({ label: 'Extra credits', value: `${fmtCredits(st.bonusCredits)} left — used once a limit above runs out${st.bonusExpiresAt ? ` · until ${new Date(st.bonusExpiresAt).toLocaleDateString()}` : ''}`, tone: 'good' });
2754
+ }
2755
+ return out;
2756
+ }
2757
+
2758
+ /** The session's spend, for /cost and the foot of /usage. */
2759
+ function costSections(spent) {
2760
+ return [
2761
+ { label: 'Credits', value: spent.credits ? `${fmtCredits(spent.credits)}` : '—', tone: 'good' },
2762
+ { label: 'Turns', value: String(spent.turns) },
2763
+ { label: 'Tool calls', value: String(spent.tools) },
2764
+ { gap: true },
2765
+ { label: 'Fresh input', value: `${(spent.input || 0).toLocaleString()} tokens` },
2766
+ { label: 'Cached input', value: `${(spent.cached || 0).toLocaleString()} tokens (billed at the lower cached price)` },
2767
+ { label: 'Output', value: `${(spent.output || 0).toLocaleString()} tokens` },
2768
+ { gap: true },
2769
+ { label: '', value: '/usage for your plan' },
2770
+ ];
2771
+ }
2772
+
2773
+ /** A meters list as plain text, for `jaitus usage` outside the app view. */
2774
+ function sectionsAsTable(sections) {
2775
+ const rows = [];
2776
+ for (const s of sections) {
2777
+ if (s.gap || s.heading) { if (s.heading) rows.push(['Plan', s.heading]); continue; }
2778
+ if (s.meter !== undefined) {
2779
+ const pct = s.total > 0 ? Math.min(100, Math.round((100 * (Number(s.used) || 0)) / s.total)) : 0;
2780
+ const bar = `${glyph('█', '#').repeat(Math.round(pct / 5))}${glyph('░', '.').repeat(20 - Math.round(pct / 5))}`;
2781
+ rows.push([s.meter, `${bar} ${pct}% ${[s.detail, s.note].filter(Boolean).join(' · ')}`]);
2782
+ } else if (s.label) rows.push([s.label, s.value]);
2783
+ }
2784
+ return rows;
2785
+ }
2786
+
2695
2787
  function planRows(u) {
2696
2788
  const st = u.planStatus || null;
2697
2789
  if (!u.isPaid) return [['Plan', 'Free'], ['Free prompts', `${u.freeRequestsUsed ?? 0} of ${u.freeRequestLimit ?? '?'} used`]];
@@ -2757,18 +2849,31 @@ const commands = {
2757
2849
  usage: {
2758
2850
  summary: 'your usage and remaining free prompts',
2759
2851
  usage: ['jaitus usage [--json]'],
2760
- run: async (argv, { json }) => {
2852
+ run: async (argv, { json, tui = null, spent = null } = {}) => {
2761
2853
  const client = requireAuth();
2762
- const [{ ok: good, body }, me] = await Promise.all([client.api('/usage/summary'), client.api('/auth/me').catch(() => ({}))]);
2854
+ if (tui?.setStatus) tui.setStatus(`${c.brand(glyph('◆', '*'))} Loading your usage…`);
2855
+ let good; let body; let me;
2856
+ try {
2857
+ [{ ok: good, body }, me] = await Promise.all([client.api('/usage/summary'), client.api('/auth/me').catch(() => ({}))]);
2858
+ } finally { tui?.clearStatus?.(); }
2763
2859
  if (!good) { fail(body?.message || 'Could not load usage.'); return 1; }
2764
2860
  const d = body.data || {};
2765
2861
  const u = me?.body?.user || me?.body?.data || null;
2766
- present(json, { ...d, planStatus: u?.planStatus || null }, () => table([
2767
- ...(u ? planRows(u) : []),
2768
- ['Today', `${d.today?.requests ?? 0} requests`],
2769
- ['This month', `${d.month?.requests ?? 0} requests`],
2770
- ['Tokens used', `${(d.totalTokensConsumed ?? 0).toLocaleString()}`],
2771
- ]));
2862
+ const activity = [
2863
+ { gap: true },
2864
+ { heading: 'Requests' },
2865
+ { label: 'Today', value: `${(d.today?.requests ?? 0).toLocaleString()} requests` },
2866
+ { label: 'This month', value: `${(d.month?.requests ?? 0).toLocaleString()} requests · ${fmtTokens(d.totalTokensConsumed ?? 0)} tokens in total` },
2867
+ ];
2868
+ const session = spent ? [{ gap: true }, { heading: 'This session' }, { label: 'Credits', value: `${fmtCredits(spent.credits || 0)} · ${spent.turns} turns · ${spent.tools} tool calls` }] : [];
2869
+ const sections = [...(u ? planSections(u) : []), ...activity, ...session];
2870
+ // In the app view: a popup over the conversation that Esc closes, not rows that
2871
+ // stay in the transcript for good.
2872
+ if (tui?.showMeters && !json) {
2873
+ void tui.showMeters('Usage', sections);
2874
+ return 0;
2875
+ }
2876
+ present(json, { ...d, planStatus: u?.planStatus || null }, () => table(sectionsAsTable(sections)));
2772
2877
  return 0;
2773
2878
  },
2774
2879
  },
@@ -2878,7 +2983,7 @@ const commands = {
2878
2983
  const unset = rest.length === 1 && /^(--unset|none|unset)$/i.test(rest[0]);
2879
2984
  const value = unset ? '' : rest.join(' ').trim();
2880
2985
  const settings = loadSettings();
2881
- const ONOFF = ['copyOnSelect', 'wheelAccel', 'autoScroll', 'animations', 'autoCompact', 'trimToolResults', 'mouse', 'autoUpdate'];
2986
+ const ONOFF = ['copyOnSelect', 'wheelAccel', 'autoScroll', 'animations', 'autoCompact', 'trimToolResults', 'mouse'];
2882
2987
  const SECRET = ['braveSearchKey', 'tavilyKey', 'serperKey'];
2883
2988
  if (!key) {
2884
2989
  const e = endpoints();
@@ -3010,7 +3115,7 @@ const commands = {
3010
3115
  update: {
3011
3116
  summary: 'check whether a newer version is published',
3012
3117
  usage: ['jaitus update [--json]'],
3013
- detail: ['Checks npm for a newer version and, for a global npm install, offers to install it (--yes installs without asking). Launching jaitus also updates itself; turn that off with `jaitus config autoUpdate off`.'],
3118
+ detail: ['Checks npm for a newer version and, for a global npm install, installs it with progress. Starting jaitus does the same every time, so this is rarely needed.'],
3014
3119
  run: (argv, { json }) => update(argv, { json }),
3015
3120
  },
3016
3121
 
@@ -3088,7 +3193,7 @@ export default async function main(argv) {
3088
3193
  }
3089
3194
 
3090
3195
  // Always on the newest version: install it and reopen before doing anything else.
3091
- const relaunched = await autoUpdate({ argv, settings: loadSettings() }).catch(() => null);
3196
+ const relaunched = await autoUpdate({ argv }).catch(() => null);
3092
3197
  if (relaunched !== null) return relaunched;
3093
3198
 
3094
3199
  const [first, ...others] = rest;