jaitus 0.3.17 → 0.3.19

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,7 +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
+ import { estimateTokens, summarise, compactInPlace, dropOldImages, needsAutoCompact, SIZE_FAULTS } from './compact.js';
21
21
  import { createPermissions, MODES as PERMISSION_MODES } from './agent/permissions.js';
22
22
  import { DEFAULT_DENY } from './agent/rules.js';
23
23
  import { forgetIgnoreCache, imageBlock, IMAGE_MIME } from './agent/tools.js';
@@ -38,6 +38,7 @@ import {
38
38
  } from './ui.js';
39
39
  import { createTui as createClassicTui } from './tui.js';
40
40
  import { createTui as createInkTui } from './tui-ink.js';
41
+ import { planSections, costSections, planRows, spendText, rememberPlan, planBadge, withoutCredits } from './planView.js';
41
42
 
42
43
  // The interactive surface is Node-only. The classic renderer remains an
43
44
  // explicit escape hatch for terminals that cannot support fullscreen Ink.
@@ -964,6 +965,20 @@ async function session({ flags }) {
964
965
  }
965
966
  };
966
967
 
968
+ // The plan as the status bar shows it ("5h 12% · week 3%"): asked for at the start
969
+ // and after each turn (at most every 3 s), never in the way of the turn itself.
970
+ let planAskedAt = 0;
971
+ const refreshPlan = () => {
972
+ if (Date.now() - planAskedAt < 3_000) return;
973
+ planAskedAt = Date.now();
974
+ client.api('/auth/me').then(({ ok: good, body }) => {
975
+ const u = body?.user || body?.data;
976
+ if (!good || !u) return;
977
+ rememberPlan(u.planStatus || null);
978
+ try { tui?.setPlan?.(u.isPaid ? planBadge(u.planStatus) : null); } catch { /* bar keeps last value */ }
979
+ }).catch(() => { /* offline: the bar keeps what it had */ });
980
+ };
981
+
967
982
  /** One agent run with abort wiring, error accounting, and queue draining after. */
968
983
  const runManaged = async (messages, { recovered = false } = {}) => {
969
984
  const ctrl = new AbortController();
@@ -1082,6 +1097,7 @@ async function session({ flags }) {
1082
1097
  tui?.setSpent?.(spent);
1083
1098
  } catch { /* header keeps last value */ }
1084
1099
  refreshStatusLine('turn');
1100
+ refreshPlan();
1085
1101
  }
1086
1102
  };
1087
1103
 
@@ -1116,6 +1132,9 @@ async function session({ flags }) {
1116
1132
  // gateway's prompt); the estimate covers what was added since. The larger wins — the
1117
1133
  // estimate alone missed both and let sessions run into the wall.
1118
1134
  const contextUsed = () => Math.max(lastContext || 0, estimateTokens(messages) + 6_000);
1135
+ // The size the conversation was left at by the last compaction: until it has grown
1136
+ // past that, compacting again cannot help (what is left is the newest exchange).
1137
+ let compactedAt = 0;
1119
1138
 
1120
1139
  /**
1121
1140
  * Summarise in place. `keepTail` keeps the newest exchange word for word (the person's
@@ -1135,8 +1154,11 @@ async function session({ flags }) {
1135
1154
  sessionState.lastContext = 0;
1136
1155
  sessionState.messages = messages;
1137
1156
  saveSession(sessionState);
1157
+ compactedAt = contextUsed();
1138
1158
  showContext({ estimate: true });
1139
- tui.notice?.(`${why || `Context was ${format(used)}`}; ${r.dropped} messages summarised into one. /rewind still has the originals.`, 'info');
1159
+ tui.notice?.(r.digest
1160
+ ? `${why || `Context was ${format(used)}`}; the summary could not be written, so ${r.dropped} messages were replaced by a digest of your requests, files and commands. /rewind still has the originals.`
1161
+ : `${why || `Context was ${format(used)}`}; ${r.dropped} messages summarised into one. /rewind still has the originals.`, r.digest ? 'warn' : 'info');
1140
1162
  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
1163
  return true;
1142
1164
  } catch (error) {
@@ -1151,7 +1173,7 @@ async function session({ flags }) {
1151
1173
 
1152
1174
  const autoCompactIfNeeded = async ({ signal } = {}) => {
1153
1175
  if (loadSettings().autoCompact === false) return false;
1154
- if (contextUsed() < compactThreshold()) return false;
1176
+ if (!needsAutoCompact(contextUsed(), compactThreshold(), compactedAt)) return false;
1155
1177
  if (messages.length < 3) return false;
1156
1178
  return compactNow({ keepTail: true, signal });
1157
1179
  };
@@ -1459,6 +1481,7 @@ async function session({ flags }) {
1459
1481
  showContext();
1460
1482
  tui.setSpent?.(spent);
1461
1483
  refreshStatusLine('start');
1484
+ refreshPlan();
1462
1485
  // Why the session is in the mode it is in, when that is not obvious from the badge.
1463
1486
  const startNotes = [
1464
1487
  bypassIsDefault && permissions.mode === 'bypassPermissions' ? 'Bypass is your default for new sessions — /permissions default ask turns that off.' : '',
@@ -1773,7 +1796,8 @@ async function slashCommand(verb, args, ctx) {
1773
1796
  { label: 'Directory', value: ctx.flags.cwd },
1774
1797
  { gap: true },
1775
1798
  { 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` : ''}` },
1799
+ { label: 'This session', value: `${ctx.spent.turns} turns · ${ctx.spent.tools} tool calls` },
1800
+ ...(spendText(ctx.spent.credits) ? [{ label: 'Plan usage', value: spendText(ctx.spent.credits) }] : []),
1777
1801
  { gap: true },
1778
1802
  { label: '', value: '/usage for your plan' },
1779
1803
  ]);
@@ -1785,7 +1809,7 @@ async function slashCommand(verb, args, ctx) {
1785
1809
  [` ${c.dim('account')}`, creds?.user?.email || 'not signed in'],
1786
1810
  [` ${c.dim('directory')}`, ctx.flags.cwd],
1787
1811
  [` ${c.dim('context')}`, `${ctx.messages.length} messages · ~${estimateTokens(ctx.messages).toLocaleString()} tokens`],
1788
- [` ${c.dim('session')}`, `${ctx.spent.turns} turns · ${ctx.spent.tools} tool calls${ctx.spent.credits ? ` · ${Math.round(ctx.spent.credits * 10) / 10} credits` : ''}`],
1812
+ [` ${c.dim('session')}`, `${ctx.spent.turns} turns · ${ctx.spent.tools} tool calls${spendText(ctx.spent.credits) ? ` · ${spendText(ctx.spent.credits)}` : ''}`],
1789
1813
  ]);
1790
1814
  return null;
1791
1815
  }
@@ -1838,7 +1862,7 @@ async function slashCommand(verb, args, ctx) {
1838
1862
  });
1839
1863
  // Saved now: a crash before the next turn used to bring the whole history back.
1840
1864
  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))}.`);
1865
+ ok(`Conversation compacted — about ${format(before)} tokens down to ${format(r?.after ?? estimateTokens(ctx.messages))}${r?.digest ? ' (the summary could not be written; a digest of your requests, files and commands replaced it)' : ''}.`);
1842
1866
  try { ctx.resetContext?.(); } catch { /* header keeps last value */ }
1843
1867
  } catch (e) {
1844
1868
  // The request id is the only thing that ties this failure to a backend log
@@ -2666,12 +2690,12 @@ async function slashCommand(verb, args, ctx) {
2666
2690
  table([
2667
2691
  [' Turns', String(spent.turns)],
2668
2692
  [' Tool calls', String(spent.tools)],
2669
- [' Credits', spent.credits ? String(Math.round(spent.credits * 100) / 100) : '—'],
2693
+ [' Plan usage', spendText(spent.credits) || '—'],
2670
2694
  [' Input tokens', spent.input.toLocaleString()],
2671
2695
  [' Cached input tokens', (spent.cached || 0).toLocaleString()],
2672
2696
  [' Output tokens', spent.output.toLocaleString()],
2673
2697
  ]);
2674
- info(`${c.dim('Credits are what your plan counts: real cost, cached context included at its lower price. See')} ${c.bold('/usage')} ${c.dim('for your plan.')}`);
2698
+ info(`${c.dim('Plan usage is this session as a share of your 5-hour session and your week. See')} ${c.bold('/usage')} ${c.dim('for your plan.')}`);
2675
2699
  return null;
2676
2700
 
2677
2701
  case '/cwd':
@@ -2700,76 +2724,6 @@ function fmtTokens(n) {
2700
2724
  if (v >= 1e3) return `${Math.round(v / 1e3)}K`;
2701
2725
  return String(Math.round(v));
2702
2726
  }
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
2727
  /** A meters list as plain text, for `jaitus usage` outside the app view. */
2774
2728
  function sectionsAsTable(sections) {
2775
2729
  const rows = [];
@@ -2784,27 +2738,6 @@ function sectionsAsTable(sections) {
2784
2738
  return rows;
2785
2739
  }
2786
2740
 
2787
- function planRows(u) {
2788
- const st = u.planStatus || null;
2789
- if (!u.isPaid) return [['Plan', 'Free'], ['Free prompts', `${u.freeRequestsUsed ?? 0} of ${u.freeRequestLimit ?? '?'} used`]];
2790
- const rows = [['Plan', st?.name || 'Paid']];
2791
- // Credits: what the plan counts (real cost), the same number the website shows.
2792
- const cr = (n) => ((Number(n) || 0) >= 1000 ? Math.round(n).toLocaleString('en-IN') : String(Math.round((Number(n) || 0) * 10) / 10));
2793
- if (st?.monthlyCredits) {
2794
- const resets = st.periodResetsAt ? ` · resets ${new Date(st.periodResetsAt).toLocaleDateString()}` : '';
2795
- rows.push(['This period', `${cr(st.periodCredits)} of ${cr(st.monthlyCredits)} credits${resets}`]);
2796
- if (st.windowCredits) {
2797
- const refill = st.windowResetsAt
2798
- ? `refills at ${new Date(st.windowResetsAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
2799
- : 'opens with your next request';
2800
- rows.push([`This ${st.windowHours || 5}h window`, `${cr(st.windowUsed)} of ${cr(st.windowCredits)} credits · ${refill}`]);
2801
- }
2802
- if (st.periodTokens) rows.push(['Behind them', `${fmtTokens(st.periodTokens)} tokens of input and replies`]);
2803
- }
2804
- if (u.planExpiresAt) rows.push(['Renews by', new Date(u.planExpiresAt).toLocaleDateString()]);
2805
- return rows;
2806
- }
2807
-
2808
2741
  const commands = {
2809
2742
  login: {
2810
2743
  summary: 'sign in through your browser',
@@ -2837,7 +2770,8 @@ const commands = {
2837
2770
  const { ok: good, body } = await client.api('/auth/me');
2838
2771
  if (!good) { fail(body?.message || 'Could not reach your account.'); return 1; }
2839
2772
  const u = body.user || body.data || {};
2840
- present(json, u, () => table([
2773
+ rememberPlan(u.planStatus);
2774
+ present(json, withoutCredits(u), () => table([
2841
2775
  ['Account', u.email || '—'],
2842
2776
  ...planRows(u),
2843
2777
  ['Gateway', endpoints().baseUrl],
@@ -2865,7 +2799,9 @@ const commands = {
2865
2799
  { label: 'Today', value: `${(d.today?.requests ?? 0).toLocaleString()} requests` },
2866
2800
  { label: 'This month', value: `${(d.month?.requests ?? 0).toLocaleString()} requests · ${fmtTokens(d.totalTokensConsumed ?? 0)} tokens in total` },
2867
2801
  ];
2868
- const session = spent ? [{ gap: true }, { heading: 'This session' }, { label: 'Credits', value: `${fmtCredits(spent.credits || 0)} · ${spent.turns} turns · ${spent.tools} tool calls` }] : [];
2802
+ if (u?.planStatus) rememberPlan(u.planStatus);
2803
+ const used = spent ? spendText(spent.credits || 0) : '';
2804
+ const session = spent ? [{ gap: true }, { heading: 'This session' }, { label: 'Turns', value: `${spent.turns} turns · ${spent.tools} tool calls` }, ...(used ? [{ label: 'Plan usage', value: used }] : [])] : [];
2869
2805
  const sections = [...(u ? planSections(u) : []), ...activity, ...session];
2870
2806
  // In the app view: a popup over the conversation that Esc closes, not rows that
2871
2807
  // stay in the transcript for good.
@@ -2873,7 +2809,7 @@ const commands = {
2873
2809
  void tui.showMeters('Usage', sections);
2874
2810
  return 0;
2875
2811
  }
2876
- present(json, { ...d, planStatus: u?.planStatus || null }, () => table(sectionsAsTable(sections)));
2812
+ present(json, withoutCredits({ ...d, planStatus: u?.planStatus || null }), () => table(sectionsAsTable(sections)));
2877
2813
  return 0;
2878
2814
  },
2879
2815
  },
package/src/machine.js CHANGED
@@ -11,6 +11,7 @@
11
11
  // folder), each restoring the other
12
12
  // n the address of each physical network adapter (virtual and randomised ones are
13
13
  // skipped: they change by themselves)
14
+ // r the first commit of the git project the CLI runs in (and its regular authors, as g)
14
15
  // g the git identity this computer commits as (global user.email) — the same on
15
16
  // every computer one person sets up, so it recognises the person, not the machine
16
17
  //
@@ -154,6 +155,35 @@ function gitIdentity() {
154
155
  } catch { return null; }
155
156
  }
156
157
 
158
+ // Commit authors that are services, not people.
159
+ const BOT_EMAIL = /(\[bot\]|noreply@github\.com$|^actions@|dependabot|renovate|github-actions|@users\.noreply\.gitlab\.com$|^git@)/i;
160
+
161
+ /**
162
+ * The project this CLI runs in, if it is a git repository:
163
+ * roots its first commit(s) — the same on every clone, on every computer
164
+ * authors the people who commit to it most (last 300 commits), so a person is known by
165
+ * the work they come back to even after changing the global git identity
166
+ * The server treats a project or author seen on many unrelated accounts as public (an
167
+ * open-source repository cloned by strangers) and links nobody through it.
168
+ */
169
+ export function projectIdentity(cwd = process.cwd()) {
170
+ const git = (args) => {
171
+ try {
172
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 2000, windowsHide: true });
173
+ return r.status === 0 ? String(r.stdout || '') : '';
174
+ } catch { return ''; }
175
+ };
176
+ const roots = git(['rev-list', '--max-parents=0', 'HEAD']).split(/\s+/).filter((s) => /^[0-9a-f]{40,64}$/.test(s)).sort().slice(0, 2);
177
+ const counts = new Map();
178
+ for (const line of git(['log', '-n', '300', '--format=%ae']).split('\n')) {
179
+ const e = line.trim().toLowerCase();
180
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e) || PLACEHOLDER_EMAIL.test(e) || BOT_EMAIL.test(e)) continue;
181
+ counts.set(e, (counts.get(e) || 0) + 1);
182
+ }
183
+ const authors = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([e]) => e);
184
+ return { roots, authors };
185
+ }
186
+
157
187
  /**
158
188
  * Where the install id's second copy lives: outside ~/.jaitus, in the OS's app-data
159
189
  * folder. Always under os.homedir() (not LOCALAPPDATA / XDG_DATA_HOME), so a test run
@@ -215,6 +245,11 @@ export function machineHeader() {
215
245
  if (soft) parts.push(`s=${hash('s', soft)}`);
216
246
  const git = gitIdentity();
217
247
  if (git) parts.push(`g=${hash('g', git)}`);
248
+ // r: the project's first commit; its regular authors are git identities like the one
249
+ // above (same kind, same hash), so they match a git identity seen on another computer.
250
+ const project = projectIdentity();
251
+ for (const root of project.roots) parts.push(`r=${hash('r', root)}`);
252
+ for (const a of project.authors) if (a !== git) parts.push(`g=${hash('g', a)}`);
218
253
  if (m && cache.m !== m) writeCache({ ...cache, m, ...(h ? { h } : {}) });
219
254
  } catch { /* whatever was gathered */ }
220
255
  header = parts.join(',');
@@ -0,0 +1,215 @@
1
+ // ─── How a plan reads to the person using it: percentages, never credits ───────
2
+ //
3
+ // The gateway counts usage in credits (a fixed slice of real model cost) — that is its
4
+ // internal unit and an admin's, not a customer's. Everything the CLI shows about a plan
5
+ // is a percentage of the 5-hour session or of the week, with when each resets, the way
6
+ // Claude's /usage reads. /usage, /cost, /status, `jaitus whoami`, `jaitus usage`, the
7
+ // status bar and the work bar all come from here, so no screen can say it differently.
8
+
9
+ const pctOf = (used, total) => (Number(total) > 0
10
+ ? Math.max(0, Math.min(100, Math.floor((100 * (Number(used) || 0)) / Number(total))))
11
+ : null);
12
+
13
+ /** "4%", "0.6%", "<0.1%" — a share that can be small (one turn) or whole (a limit). */
14
+ export function fmtShare(p) {
15
+ const v = Number(p);
16
+ if (!Number.isFinite(v) || v <= 0) return '0%';
17
+ if (v >= 10) return `${Math.round(v)}%`;
18
+ if (v >= 0.1) return `${Math.round(v * 10) / 10}%`;
19
+ return '<0.1%';
20
+ }
21
+
22
+ /** Whether this plan status has a month that is enforced (and so worth showing). */
23
+ function monthShown(st) {
24
+ if (st?.monthlyLimit === true) return true;
25
+ // A gateway from before the switch existed enforced every month it sent.
26
+ return st?.monthlyLimit === undefined && Number(st?.monthlyCredits) > 0;
27
+ }
28
+
29
+ /**
30
+ * Where a subscriber stands, as whole percentages (null = no such limit). Read from the
31
+ * gateway's own percentages when it sends them, else worked out the same way (floor, so
32
+ * 100% means stopped).
33
+ */
34
+ export function planPercents(st) {
35
+ if (!st) return null;
36
+ const window = st.windowPercent ?? pctOf(st.windowUsed, st.windowCredits);
37
+ const week = st.weekPercent ?? pctOf(st.weekUsed, st.weeklyCredits);
38
+ const month = monthShown(st) ? (st.monthPercent ?? pctOf(st.periodCredits, st.monthlyCredits)) : null;
39
+ const extra = Number(st.bonusCredits) > 0
40
+ ? (st.extraWeekPercent ?? (Number(st.weeklyCredits) > 0 ? Math.round((100 * st.bonusCredits) / st.weeklyCredits) : null))
41
+ : null;
42
+ return {
43
+ window, week, month,
44
+ windowHours: st.windowHours || 5,
45
+ windowResetsAt: st.windowResetsAt || null,
46
+ weekResetsAt: st.weekResetsAt || null,
47
+ monthResetsAt: month != null ? (st.periodResetsAt || null) : null,
48
+ hasExtra: Number(st.bonusCredits) > 0,
49
+ extraWeekPercent: extra,
50
+ extraExpiresAt: st.bonusExpiresAt || null,
51
+ };
52
+ }
53
+
54
+ /**
55
+ * A spend in credits (one turn, one session) as a share of the 5-hour session and of
56
+ * the week — null for either the plan does not have.
57
+ */
58
+ export function spendShare(credits, st) {
59
+ const c = Number(credits) || 0;
60
+ if (!st || c <= 0) return { window: null, week: null };
61
+ return {
62
+ window: Number(st.windowCredits) > 0 ? (100 * c) / st.windowCredits : null,
63
+ week: Number(st.weeklyCredits) > 0 ? (100 * c) / st.weeklyCredits : null,
64
+ };
65
+ }
66
+
67
+ // The last plan status the gateway sent this process, so the status bar, /cost and
68
+ // /status can turn a spend into a share without asking again.
69
+ let lastPlan = null;
70
+ export const rememberPlan = (st) => { if (st && typeof st === 'object') lastPlan = st; return lastPlan; };
71
+ export const knownPlan = () => lastPlan;
72
+
73
+ /**
74
+ * What the status bar and the work bar need: the two percentages, and the two budgets
75
+ * a turn's spend is measured against. null for an account with no plan limits.
76
+ */
77
+ export function planBadge(st) {
78
+ const p = planPercents(st);
79
+ if (!p || (p.window == null && p.week == null)) return null;
80
+ return {
81
+ window: p.window, week: p.week, windowHours: p.windowHours,
82
+ windowCredits: Number(st.windowCredits) || 0, weeklyCredits: Number(st.weeklyCredits) || 0,
83
+ };
84
+ }
85
+
86
+ /** "5h 12% · week 3%" for the status bar. */
87
+ export function badgeText(b) {
88
+ if (!b) return '';
89
+ const parts = [];
90
+ if (b.window != null) parts.push(`${b.windowHours || 5}h ${b.window}%`);
91
+ if (b.week != null) parts.push(`week ${b.week}%`);
92
+ return parts.join(' · ');
93
+ }
94
+
95
+ /** What one turn has used so far, for the work bar: "0.6% of session". '' when unknown. */
96
+ export function turnShareText(credits, b) {
97
+ const c = Number(credits) || 0;
98
+ if (!b || c <= 0) return '';
99
+ if (b.windowCredits > 0) return `${fmtShare((100 * c) / b.windowCredits)} of session`;
100
+ if (b.weeklyCredits > 0) return `${fmtShare((100 * c) / b.weeklyCredits)} of week`;
101
+ return '';
102
+ }
103
+
104
+ const clock = (d) => new Date(d).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
105
+ const dayAndClock = (d) => new Date(d).toLocaleString([], { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
106
+ const shortDate = (d) => new Date(d).toLocaleDateString([], { day: 'numeric', month: 'short' });
107
+ const longDate = (d) => new Date(d).toLocaleDateString([], { day: 'numeric', month: 'short', year: 'numeric' });
108
+
109
+ function extraLine(p) {
110
+ if (!p?.hasExtra) return null;
111
+ const until = p.extraExpiresAt ? ` · until ${longDate(p.extraExpiresAt)}` : '';
112
+ return p.extraWeekPercent != null
113
+ ? `${p.extraWeekPercent}% of a week left — used once a limit above runs out${until}`
114
+ : `available — used once your free prompts run out${until}`;
115
+ }
116
+
117
+ /**
118
+ * The plan as meters for /usage (popup) and `jaitus usage` (text): the 5-hour session
119
+ * and the week fill at their own rates from the same requests, so each gets its own bar
120
+ * and its own reset time. Extra usage and the free tier follow.
121
+ */
122
+ export function planSections(u) {
123
+ const st = u?.planStatus || null;
124
+ const out = [];
125
+ if (!u?.isPaid) {
126
+ out.push({ heading: 'Free plan' });
127
+ if (st?.freeTierStatus === 'locked') {
128
+ out.push({ label: 'Free prompts', value: 'not available — this computer already has its free accounts', tone: 'bad' });
129
+ } else {
130
+ out.push({ meter: 'Free prompts', used: u?.freeRequestsUsed ?? 0, total: u?.freeRequestLimit || 0, detail: `${u?.freeRequestsUsed ?? 0} of ${u?.freeRequestLimit ?? '?'} prompts used` });
131
+ }
132
+ } else {
133
+ out.push({ heading: st?.name || 'Your plan' });
134
+ const p = planPercents(st);
135
+ if (p?.window != null) {
136
+ out.push({ meter: `${p.windowHours}-hour session`, used: p.window, total: 100, note: p.windowResetsAt ? `resets at ${clock(p.windowResetsAt)}` : 'starts with your next request' });
137
+ }
138
+ if (p?.week != null) {
139
+ out.push({ meter: 'Week', used: p.week, total: 100, note: p.weekResetsAt ? `resets ${dayAndClock(p.weekResetsAt)}` : 'starts with your next request' });
140
+ }
141
+ if (p?.month != null) {
142
+ out.push({ meter: 'Month', used: p.month, total: 100, note: p.monthResetsAt ? `resets ${shortDate(p.monthResetsAt)}` : '' });
143
+ }
144
+ if (u.planExpiresAt) out.push({ label: 'Renews by', value: longDate(u.planExpiresAt) });
145
+ }
146
+ const extra = extraLine(planPercents(st));
147
+ if (extra) out.push({ label: 'Extra usage', value: extra, tone: 'good' });
148
+ return out;
149
+ }
150
+
151
+ /** A session's spend as plan shares: "2.1% of a 5-hour session · 0.5% of your week". */
152
+ export function spendText(credits, st = lastPlan) {
153
+ const s = spendShare(credits, st);
154
+ const parts = [];
155
+ if (s.window != null) parts.push(`${fmtShare(s.window)} of a 5-hour session`);
156
+ if (s.week != null) parts.push(`${fmtShare(s.week)} of your week`);
157
+ return parts.join(' · ');
158
+ }
159
+
160
+ /** The session's spend, for /cost and the foot of /usage. */
161
+ export function costSections(spent, st = lastPlan) {
162
+ const used = spendText(spent?.credits, st);
163
+ return [
164
+ ...(used ? [{ label: 'Plan usage', value: used, tone: 'good' }] : []),
165
+ { label: 'Turns', value: String(spent?.turns || 0) },
166
+ { label: 'Tool calls', value: String(spent?.tools || 0) },
167
+ { gap: true },
168
+ { label: 'Fresh input', value: `${(spent?.input || 0).toLocaleString()} tokens` },
169
+ { label: 'Cached input', value: `${(spent?.cached || 0).toLocaleString()} tokens (counted at the lower cached price)` },
170
+ { label: 'Output', value: `${(spent?.output || 0).toLocaleString()} tokens` },
171
+ { gap: true },
172
+ { label: '', value: '/usage for your plan' },
173
+ ];
174
+ }
175
+
176
+ /** The plan as rows for `jaitus whoami`. */
177
+ export function planRows(u) {
178
+ const st = u?.planStatus || null;
179
+ if (!u?.isPaid) return [['Plan', 'Free'], ['Free prompts', `${u?.freeRequestsUsed ?? 0} of ${u?.freeRequestLimit ?? '?'} used`]];
180
+ const rows = [['Plan', st?.name || 'Paid']];
181
+ const p = planPercents(st);
182
+ if (p?.window != null) rows.push([`${p.windowHours}-hour session`, `${p.window}% used · ${p.windowResetsAt ? `resets at ${clock(p.windowResetsAt)}` : 'starts with your next request'}`]);
183
+ if (p?.week != null) rows.push(['Week', `${p.week}% used · ${p.weekResetsAt ? `resets ${dayAndClock(p.weekResetsAt)}` : 'starts with your next request'}`]);
184
+ if (p?.month != null) rows.push(['Month', `${p.month}% used${p.monthResetsAt ? ` · resets ${shortDate(p.monthResetsAt)}` : ''}`]);
185
+ const extra = extraLine(p);
186
+ if (extra) rows.push(['Extra usage', extra]);
187
+ if (u.planExpiresAt) rows.push(['Renews by', new Date(u.planExpiresAt).toLocaleDateString()]);
188
+ return rows;
189
+ }
190
+
191
+ /** The plan as --json prints it: percentages and times, no credits. */
192
+ export function publicPlan(st) {
193
+ if (!st) return null;
194
+ const p = planPercents(st);
195
+ return {
196
+ key: st.key, name: st.name, paid: Boolean(st.paid), expiresAt: st.expiresAt || null,
197
+ session: p.window != null ? { hours: p.windowHours, percentUsed: p.window, resetsAt: p.windowResetsAt } : null,
198
+ week: p.week != null ? { percentUsed: p.week, resetsAt: p.weekResetsAt } : null,
199
+ month: p.month != null ? { percentUsed: p.month, resetsAt: p.monthResetsAt } : null,
200
+ extraUsage: p.hasExtra ? { percentOfWeek: p.extraWeekPercent, expiresAt: p.extraExpiresAt } : null,
201
+ freeTierStatus: st.freeTierStatus || null,
202
+ };
203
+ }
204
+
205
+ /** A copy of a response for --json with every credit field taken out, at any depth. */
206
+ export function withoutCredits(value) {
207
+ if (Array.isArray(value)) return value.map(withoutCredits);
208
+ if (!value || typeof value !== 'object' || value instanceof Date) return value;
209
+ const out = {};
210
+ for (const [k, v] of Object.entries(value)) {
211
+ if (/credit/i.test(k)) continue;
212
+ out[k] = k === 'planStatus' ? publicPlan(v) : withoutCredits(v);
213
+ }
214
+ return out;
215
+ }