jaitus 0.3.14 → 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/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';
@@ -46,6 +47,7 @@ import logout from './commands/logout.js';
46
47
  import doctor from './commands/doctor.js';
47
48
  import update, { updateNotice, autoUpdate } from './commands/update.js';
48
49
  import { startupNotice } from './serviceNotice.js';
50
+ import { warmMachineHeader } from './machine.js';
49
51
  import init from './commands/init.js';
50
52
  import research from './commands/research.js';
51
53
  import explain from './commands/explain.js';
@@ -241,21 +243,6 @@ async function resolveModel(client, preferred) {
241
243
  return available[0];
242
244
  }
243
245
 
244
- /** Rough token estimate for /context and /status — ~4 characters per token of English. */
245
- function estimateTokens(messages) {
246
- let chars = 0;
247
- for (const m of messages) {
248
- if (typeof m.content === 'string') chars += m.content.length;
249
- else if (Array.isArray(m.content)) {
250
- for (const b of m.content) {
251
- if (b.type === 'text') chars += (b.text || '').length;
252
- else if (b.type === 'tool_use') chars += JSON.stringify(b.input || {}).length;
253
- else if (b.type === 'tool_result') chars += (b.content || '').length;
254
- }
255
- }
256
- }
257
- return Math.ceil(chars / 4);
258
- }
259
246
 
260
247
  /** Clipboard write that degrades honestly: returns false when no path exists. */
261
248
  async function copyToClipboard(text) {
@@ -383,82 +370,8 @@ const compactThreshold = () => CONTEXT_BUDGET * AUTO_COMPACT_AT;
383
370
  // someone typed `@package-lock.json` would blow the context it is meant to save.
384
371
  const MENTION_MAX_BYTES = 48_000;
385
372
 
386
- /**
387
- * One summarisation call. Shared by /compact and auto-compaction so the two cannot
388
- * drift into producing different kinds of summary from the same conversation.
389
- */
390
- /**
391
- * The conversation as plain text, for summarising. Sending the raw history broke in
392
- * three ways: every image was sent again (past the gateway's size limit once there were
393
- * a few), tool calls went without a tools list (which some providers reject), and a long
394
- * session was larger than the window being compacted. Tool calls and results become
395
- * short lines, images a note, and the middle of a very long session is trimmed.
396
- */
397
- function transcriptForSummary(messages, maxChars = 360_000) {
398
- const clip = (s, n) => (s.length > n ? `${s.slice(0, n)}…` : s);
399
- const lines = [];
400
- for (const m of messages) {
401
- const who = m.role === 'assistant' ? 'ASSISTANT' : 'USER';
402
- if (typeof m.content === 'string') { lines.push(`${who}: ${m.content}`); continue; }
403
- for (const b of Array.isArray(m.content) ? m.content : []) {
404
- if (b?.type === 'text' && b.text) lines.push(`${who}: ${b.text}`);
405
- else if (b?.type === 'tool_use') lines.push(`${who} ran ${b.name}: ${clip(JSON.stringify(b.input ?? {}), 400)}`);
406
- else if (b?.type === 'tool_result') {
407
- const t = typeof b.content === 'string' ? b.content : Array.isArray(b.content) ? b.content.map((c) => c?.text || '').join('\n') : '';
408
- lines.push(`RESULT${b.is_error ? ' (error)' : ''}: ${clip(t, 1500)}`);
409
- } else if (b?.type === 'image') lines.push(`${who}: [an image]`);
410
- }
411
- }
412
- let text = lines.join('\n');
413
- if (text.length > maxChars) {
414
- const head = text.slice(0, Math.floor(maxChars * 0.25));
415
- const tail = text.slice(-Math.floor(maxChars * 0.7));
416
- text = `${head}\n\n[… the middle of the session is left out …]\n\n${tail}`;
417
- }
418
- return text;
419
- }
420
-
421
- export async function summarise(client, model, messages, focus) {
422
- // Streamed, like every other turn. Sent as one non-streamed request, nothing came
423
- // back until the whole summary was written — and the proxy in front of the gateway
424
- // cuts a response that is silent for 100 s, so a long session's summary failed with
425
- // the proxy's own error page ("Compact failed: Something went wrong"). A stream
426
- // carries the gateway's heartbeat the whole time.
427
- const turn = await streamTurn({
428
- client,
429
- model,
430
- // Room for the model to reason before it writes; at 2048 the reasoning could use
431
- // it all and the summary came back empty.
432
- maxTokens: 6000,
433
- system: 'You write precise working summaries of coding sessions.',
434
- messages: [
435
- {
436
- role: 'user',
437
- // Sections a successor needs to carry on without re-asking or re-reading — the
438
- // same structure agent tools use for their own compaction. Losing the user's
439
- // exact instructions or where the work stopped is what makes a compacted session
440
- // feel worse; everything else can be re-read from disk.
441
- content: 'Summarize this conversation for a successor agent that will continue the work with no other memory of it. '
442
- + 'Use these headings, and be specific (paths, names, numbers) rather than general:\n'
443
- + '1. Request — what the user asked for, with their exact words for any instruction, constraint or preference.\n'
444
- + '2. Corrections — anything the user rejected, corrected or asked to do differently, verbatim where short.\n'
445
- + '3. Files — every file read or changed: path, what changed or what matters in it.\n'
446
- + '4. Commands and results — what was run and what it showed (test/build results, errors and how each was fixed).\n'
447
- + '5. Decisions — what was chosen and why.\n'
448
- + '6. Current state — exactly what was being done when this summary was made, and what is unfinished.\n'
449
- + '7. Next step — the single next action, if the work was mid-way.\n'
450
- + 'Omit headings that have nothing. No preamble.'
451
- + (focus ? ` Focus on: ${focus}.` : '')
452
- + `\n\n<conversation>\n${transcriptForSummary(messages)}\n</conversation>`,
453
- },
454
- ],
455
- });
456
- // Half a summary would silently become the whole memory of the session.
457
- if (turn.stopReason === 'cut') throw new JaitusError({ code: 'interrupted', message: 'The summary was cut off on the way. Try /compact again.' });
458
- const text = (turn.content || []).filter((b) => b.type === 'text').map((b) => b.text).join('\n').trim();
459
- if (!text) throw new Error('the summary came back empty');
460
- return text;
461
- }
373
+ // Summarising lives in compact.js; re-exported for the callers and tests that import it here.
374
+ export { summarise };
462
375
 
463
376
  /**
464
377
  * `@path` in a message → the file's contents as a content block.
@@ -1052,7 +965,7 @@ async function session({ flags }) {
1052
965
  };
1053
966
 
1054
967
  /** One agent run with abort wiring, error accounting, and queue draining after. */
1055
- const runManaged = async (messages) => {
968
+ const runManaged = async (messages, { recovered = false } = {}) => {
1056
969
  const ctrl = new AbortController();
1057
970
  activeAbort = ctrl;
1058
971
  const turnStarted = Date.now();
@@ -1067,6 +980,9 @@ async function session({ flags }) {
1067
980
  const result = await runAgent({
1068
981
  client, messages, model, cwd: flags.cwd, permissions, signal: ctrl.signal, tui, journal,
1069
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 }),
1070
986
  // `jaitus config trimToolResults off` keeps every old tool output in context.
1071
987
  trimContext: loadSettings().trimToolResults !== false,
1072
988
  // What happened since the model's last turn (a failed request, an interrupt, a
@@ -1104,7 +1020,30 @@ async function session({ flags }) {
1104
1020
  else out(meta);
1105
1021
  }
1106
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
+ }
1107
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
+ }
1108
1047
  // The failed turn is dropped so the next message is not sent on top of a broken
1109
1048
  // conversation the API will reject — but the model is told it happened. Without
1110
1049
  // that, its next turn begins as though nothing went wrong and it re-attempts the
@@ -1172,30 +1111,51 @@ async function session({ flags }) {
1172
1111
  * thing to talk to, and finding that out by noticing the agent forgot something is
1173
1112
  * worse than being told.
1174
1113
  */
1175
- const autoCompactIfNeeded = async () => {
1176
- if (loadSettings().autoCompact === false) return;
1177
- const used = estimateTokens(messages);
1178
- if (used < compactThreshold()) return;
1179
- 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);
1180
1119
 
1181
- 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`);
1182
1128
  try {
1183
- const summary = await summarise(client, model, messages, '');
1184
- const dropped = messages.length;
1185
- messages.length = 0;
1186
- messages.push({ role: 'user', content: `[Earlier conversation, summarized:]\n${summary}\n\n[Continue from here.]` });
1187
- tui.notice?.(`Context was ${format(used)}; ${dropped} messages summarised into one. /rewind still has the originals.`, 'info');
1188
- 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;
1189
1142
  } catch (error) {
1190
- // Not fatal: the turn can still run, it just runs closer to the ceiling.
1191
1143
  const id = isJaitusError(error) && error.requestId ? ` (request id ${error.requestId})` : '';
1192
1144
  tui.notice?.(`Auto-compaction failed (${error.message}${id}) — continuing without it. /compact retries.`, 'warn');
1193
1145
  ledger.note('compact-failed', `Automatic context compaction failed (${error.message}); the conversation is close to the context limit.`);
1146
+ return false;
1194
1147
  } finally {
1195
1148
  tui.clearStatus?.();
1196
1149
  }
1197
1150
  };
1198
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
+
1199
1159
  const handleLineInner = async (rawLine) => {
1200
1160
  const input = rawLine.trim();
1201
1161
  if (!input) return;
@@ -1229,7 +1189,7 @@ async function session({ flags }) {
1229
1189
  tui.setBusy(true);
1230
1190
  try {
1231
1191
  result = await slashCommand(verb, args, {
1232
- client, messages, permissions, flags, spent, resetContext,
1192
+ client, messages, permissions, flags, spent, resetContext, lastContextUsed: () => contextUsed(),
1233
1193
  model, setModel: (m) => {
1234
1194
  model = m;
1235
1195
  sessionState.model = m;
@@ -1440,6 +1400,9 @@ async function session({ flags }) {
1440
1400
  // An outage or maintenance on the Jaitus service, said once a day at most; the
1441
1401
  // status page has the rest. Background, short timeout, silent on failure.
1442
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();
1443
1406
  // A project's .mcp.json runs programs on this machine. Nothing in it starts until the
1444
1407
  // exact command is approved here — asked once, remembered per project and command.
1445
1408
  void (async () => {
@@ -1801,6 +1764,21 @@ async function slashCommand(verb, args, ctx) {
1801
1764
 
1802
1765
  case '/status': {
1803
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
+ }
1804
1782
  table([
1805
1783
  [` ${c.dim('model')}`, ctx.model],
1806
1784
  [` ${c.dim('mode')}`, ctx.permissions.mode],
@@ -1814,6 +1792,21 @@ async function slashCommand(verb, args, ctx) {
1814
1792
 
1815
1793
  case '/context': {
1816
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
+ }
1817
1810
  table([
1818
1811
  [` ${c.dim('messages')}`, String(ctx.messages.length)],
1819
1812
  [` ${c.dim('user messages')}`, String(ctx.messages.filter((m) => m.role === 'user' && typeof m.content === 'string').length)],
@@ -1835,10 +1828,17 @@ async function slashCommand(verb, args, ctx) {
1835
1828
  : (await import('./ui.js')).spinner('Summarizing…');
1836
1829
  if (ctx.tui) ctx.tui.setStatus(`${c.brand(glyph('◆', '*'))} ${c.brand(glyph('Summarizing…', 'Summarizing...'))}`);
1837
1830
  try {
1838
- const text = await summarise(ctx.client, ctx.model, ctx.messages, args.join(' '));
1839
- ctx.messages.length = 0;
1840
- ctx.messages.push({ role: 'user', content: `[Earlier conversation, summarized:]\n${text}\n\n[Continue from here.]` });
1841
- 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))}.`);
1842
1842
  try { ctx.resetContext?.(); } catch { /* header keeps last value */ }
1843
1843
  } catch (e) {
1844
1844
  // The request id is the only thing that ties this failure to a backend log
@@ -2571,7 +2571,7 @@ async function slashCommand(verb, args, ctx) {
2571
2571
  return null;
2572
2572
 
2573
2573
  case '/usage':
2574
- await commands.usage.run([], {});
2574
+ await commands.usage.run([], { tui: ctx.tui, spent });
2575
2575
  return null;
2576
2576
 
2577
2577
  case '/init':
@@ -2659,6 +2659,10 @@ async function slashCommand(verb, args, ctx) {
2659
2659
  }
2660
2660
 
2661
2661
  case '/cost':
2662
+ if (ctx.tui?.showMeters) {
2663
+ void ctx.tui.showMeters('This session', costSections(spent));
2664
+ return null;
2665
+ }
2662
2666
  table([
2663
2667
  [' Turns', String(spent.turns)],
2664
2668
  [' Tool calls', String(spent.tools)],
@@ -2696,6 +2700,90 @@ function fmtTokens(n) {
2696
2700
  if (v >= 1e3) return `${Math.round(v / 1e3)}K`;
2697
2701
  return String(Math.round(v));
2698
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
+
2699
2787
  function planRows(u) {
2700
2788
  const st = u.planStatus || null;
2701
2789
  if (!u.isPaid) return [['Plan', 'Free'], ['Free prompts', `${u.freeRequestsUsed ?? 0} of ${u.freeRequestLimit ?? '?'} used`]];
@@ -2761,18 +2849,31 @@ const commands = {
2761
2849
  usage: {
2762
2850
  summary: 'your usage and remaining free prompts',
2763
2851
  usage: ['jaitus usage [--json]'],
2764
- run: async (argv, { json }) => {
2852
+ run: async (argv, { json, tui = null, spent = null } = {}) => {
2765
2853
  const client = requireAuth();
2766
- 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?.(); }
2767
2859
  if (!good) { fail(body?.message || 'Could not load usage.'); return 1; }
2768
2860
  const d = body.data || {};
2769
2861
  const u = me?.body?.user || me?.body?.data || null;
2770
- present(json, { ...d, planStatus: u?.planStatus || null }, () => table([
2771
- ...(u ? planRows(u) : []),
2772
- ['Today', `${d.today?.requests ?? 0} requests`],
2773
- ['This month', `${d.month?.requests ?? 0} requests`],
2774
- ['Tokens used', `${(d.totalTokensConsumed ?? 0).toLocaleString()}`],
2775
- ]));
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)));
2776
2877
  return 0;
2777
2878
  },
2778
2879
  },
package/src/machine.js ADDED
@@ -0,0 +1,192 @@
1
+ // ─── This computer's identifiers, for the free tier ───────────────────────────────
2
+ //
3
+ // Only so many accounts on one computer get free prompts (the server decides; see
4
+ // backend/services/deviceGuard.js). The CLI tells it which computer it is on with
5
+ // several identifiers, each one HASHED here — the raw values never leave the machine:
6
+ //
7
+ // m the operating system's machine id: Windows MachineGuid, Linux /etc/machine-id
8
+ // h the firmware's hardware UUID (SMBIOS / macOS IOPlatformUUID) — survives an OS
9
+ // reinstall
10
+ // i this CLI's install id, kept in two places (~/.jaitus and the OS's app-data
11
+ // folder), each restoring the other
12
+ // n the address of each physical network adapter (virtual and randomised ones are
13
+ // skipped: they change by themselves)
14
+ //
15
+ // A VPN, a new network or a new email changes none of these. They are reported by this
16
+ // program, so they identify a computer for an honest client — the same ceiling every
17
+ // product that does this has (Cursor's machine ids are reset by published tools).
18
+ //
19
+ // Everything here is best-effort and never blocks or breaks a request: a probe that
20
+ // fails just leaves its identifier out. The slow one (Windows SMBIOS, ~1 s through
21
+ // PowerShell) runs once in the background and is cached.
22
+
23
+ import fs from 'node:fs';
24
+ import os from 'node:os';
25
+ import path from 'node:path';
26
+ import crypto from 'node:crypto';
27
+ import { spawn, spawnSync } from 'node:child_process';
28
+ import { HOME } from './config.js';
29
+ import { deviceId } from './session.js';
30
+
31
+ const CACHE_FILE = path.join(HOME, 'machine.json');
32
+ const hash = (kind, raw) => crypto.createHash('sha256').update(`jaitus-device:${kind}:${String(raw).trim().toLowerCase()}`).digest('hex');
33
+
34
+ // Values firmware vendors ship unset, shared by thousands of boards.
35
+ const JUNK_UUID = /^(0{8}-0{4}-0{4}-0{4}-0{12}|f{8}-f{4}-f{4}-f{4}-f{12}|03000200-0400-0500-0006-000700080009|00020003-0004-0005-0006-000700080009)$/i;
36
+ // Adapters that are virtual or move by themselves: Hyper-V, VirtualBox, VMware,
37
+ // Parallels, Docker/WSL bridges.
38
+ const VIRTUAL_OUI = /^(00:15:5d|08:00:27|0a:00:27|00:05:69|00:0c:29|00:1c:14|00:50:56|00:1c:42|02:42)/i;
39
+
40
+ function readCache() {
41
+ try { return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')) || {}; } catch { return {}; }
42
+ }
43
+ function writeCache(obj) {
44
+ try {
45
+ fs.mkdirSync(HOME, { recursive: true, mode: 0o700 });
46
+ fs.writeFileSync(CACHE_FILE, JSON.stringify(obj, null, 2), { mode: 0o600 });
47
+ } catch { /* unwritable home: probed again next launch */ }
48
+ }
49
+
50
+ /** The OS machine id (fast: one registry read or one file). */
51
+ function osMachineId() {
52
+ try {
53
+ if (process.platform === 'win32') {
54
+ const r = spawnSync('reg', ['query', 'HKLM\\SOFTWARE\\Microsoft\\Cryptography', '/v', 'MachineGuid'], { encoding: 'utf8', timeout: 2000, windowsHide: true });
55
+ const m = /MachineGuid\s+REG_SZ\s+([0-9a-f-]{36})/i.exec(r.stdout || '');
56
+ return m ? m[1] : null;
57
+ }
58
+ if (process.platform === 'linux') {
59
+ for (const f of ['/etc/machine-id', '/var/lib/dbus/machine-id']) {
60
+ try { const v = fs.readFileSync(f, 'utf8').trim(); if (/^[0-9a-f]{32}$/i.test(v)) return v; } catch { /* next */ }
61
+ }
62
+ }
63
+ } catch { /* left out */ }
64
+ return null;
65
+ }
66
+
67
+ /** The firmware's hardware UUID, synchronously where that is cheap (macOS, Linux). */
68
+ function hardwareIdSync() {
69
+ try {
70
+ if (process.platform === 'darwin') {
71
+ const r = spawnSync('ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice'], { encoding: 'utf8', timeout: 2000 });
72
+ const m = /"IOPlatformUUID"\s*=\s*"([0-9A-F-]{36})"/i.exec(r.stdout || '');
73
+ return m && !JUNK_UUID.test(m[1]) ? m[1] : null;
74
+ }
75
+ if (process.platform === 'linux') {
76
+ const v = fs.readFileSync('/sys/class/dmi/id/product_uuid', 'utf8').trim(); // root-only on most distros
77
+ return /^[0-9a-f-]{36}$/i.test(v) && !JUNK_UUID.test(v) ? v : null;
78
+ }
79
+ } catch { /* left out */ }
80
+ return null;
81
+ }
82
+
83
+ let smbiosProbe = null;
84
+ /** Windows: SMBIOS UUID through CIM, in the background, cached. */
85
+ function probeWindowsHardware() {
86
+ if (process.platform !== 'win32' || smbiosProbe) return smbiosProbe;
87
+ smbiosProbe = new Promise((resolve) => {
88
+ let out = '';
89
+ let child;
90
+ try {
91
+ child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', '(Get-CimInstance -ClassName Win32_ComputerSystemProduct).UUID'], { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
92
+ } catch { resolve(null); return; }
93
+ const t = setTimeout(() => { try { child.kill(); } catch { /* gone */ } resolve(null); }, 8000);
94
+ t.unref?.();
95
+ child.stdout.on('data', (d) => { out += d; });
96
+ child.on('error', () => { clearTimeout(t); resolve(null); });
97
+ child.on('close', () => {
98
+ clearTimeout(t);
99
+ const v = String(out).trim();
100
+ resolve(/^[0-9a-f-]{36}$/i.test(v) && !JUNK_UUID.test(v) ? v : null);
101
+ });
102
+ }).then((v) => {
103
+ const cache = readCache();
104
+ cache.h = v ? hash('h', v) : '';
105
+ cache.hAt = new Date().toISOString();
106
+ writeCache(cache);
107
+ return v;
108
+ });
109
+ return smbiosProbe;
110
+ }
111
+
112
+ /** Physical adapters' addresses, globally administered only, at most three. */
113
+ function adapterIds() {
114
+ const out = new Set();
115
+ try {
116
+ for (const list of Object.values(os.networkInterfaces())) {
117
+ for (const a of list || []) {
118
+ const mac = String(a.mac || '').toLowerCase();
119
+ if (a.internal || !/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/.test(mac) || mac === '00:00:00:00:00:00') continue;
120
+ if (parseInt(mac.slice(0, 2), 16) & 0x02) continue; // locally administered / randomised
121
+ if (VIRTUAL_OUI.test(mac)) continue;
122
+ out.add(mac);
123
+ }
124
+ }
125
+ } catch { /* none */ }
126
+ return [...out].sort().slice(0, 3);
127
+ }
128
+
129
+ /**
130
+ * Where the install id's second copy lives: outside ~/.jaitus, in the OS's app-data
131
+ * folder. Always under os.homedir() (not LOCALAPPDATA / XDG_DATA_HOME), so a test run
132
+ * with its own home never writes into the real one.
133
+ */
134
+ function mirrorPath() {
135
+ if (process.platform === 'win32') return path.join(os.homedir(), 'AppData', 'Local', 'Jaitus', 'install-id');
136
+ if (process.platform === 'darwin') return path.join(os.homedir(), 'Library', 'Application Support', 'Jaitus', 'install-id');
137
+ return path.join(os.homedir(), '.local', 'share', 'jaitus', 'install-id');
138
+ }
139
+
140
+ /** The install id, restored from either copy and written back to both. */
141
+ function installId() {
142
+ const mirror = mirrorPath();
143
+ let fromMirror = null;
144
+ try { const v = fs.readFileSync(mirror, 'utf8').trim(); if (/^[A-Za-z0-9_-]{8,80}$/.test(v)) fromMirror = v; } catch { /* first run */ }
145
+ const deviceFile = path.join(HOME, 'device.json');
146
+ let id = null;
147
+ try { const saved = JSON.parse(fs.readFileSync(deviceFile, 'utf8')); if (/^[A-Za-z0-9_-]{8,80}$/.test(saved?.id || '')) id = saved.id; } catch { /* none */ }
148
+ if (!id && fromMirror) {
149
+ id = fromMirror;
150
+ try { fs.mkdirSync(HOME, { recursive: true, mode: 0o700 }); fs.writeFileSync(deviceFile, JSON.stringify({ id, createdAt: new Date().toISOString() }, null, 2), { mode: 0o600 }); } catch { /* best effort */ }
151
+ }
152
+ if (!id) id = deviceId();
153
+ if (fromMirror !== id) {
154
+ try { fs.mkdirSync(path.dirname(mirror), { recursive: true }); fs.writeFileSync(mirror, id); } catch { /* best effort */ }
155
+ }
156
+ return id;
157
+ }
158
+
159
+ let header = null;
160
+ /**
161
+ * The `x-jaitus-device` header: "m=<hash>,h=<hash>,i=<hash>,n=<hash>,…". Built once
162
+ * per process; the Windows hardware id joins it once its background probe is done.
163
+ */
164
+ export function machineHeader() {
165
+ if (process.env.JAITUS_DEVICE_HEADER !== undefined) return process.env.JAITUS_DEVICE_HEADER; // tests
166
+ if (header) return header;
167
+ const cache = readCache();
168
+ const parts = [];
169
+ try {
170
+ const m = cache.m || (osMachineId() ? hash('m', osMachineId()) : '');
171
+ if (m) parts.push(`m=${m}`);
172
+ let h = cache.h;
173
+ if (h === undefined) {
174
+ const sync = hardwareIdSync();
175
+ h = sync ? hash('h', sync) : undefined;
176
+ if (h === undefined && process.platform === 'win32') {
177
+ probeWindowsHardware()?.then(() => { header = null; }).catch(() => {});
178
+ }
179
+ }
180
+ if (h) parts.push(`h=${h}`);
181
+ parts.push(`i=${hash('i', installId())}`);
182
+ for (const mac of adapterIds()) parts.push(`n=${hash('n', mac)}`);
183
+ if (m && cache.m !== m) writeCache({ ...cache, m, ...(h ? { h } : {}) });
184
+ } catch { /* whatever was gathered */ }
185
+ header = parts.join(',');
186
+ return header;
187
+ }
188
+
189
+ /** Start the slow probe early (at launch), so the first request already carries it. */
190
+ export function warmMachineHeader() {
191
+ try { if (readCache().h === undefined) probeWindowsHardware(); } catch { /* best effort */ }
192
+ }