jaitus 0.3.16 → 0.3.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/compact.js +286 -224
- package/src/errors.js +4 -3
- package/src/index.js +38 -102
- package/src/machine.js +35 -0
- package/src/planView.js +215 -0
- package/src/tui/entries.js +8 -2
- package/src/tui-ink.js +2921 -2922
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?.(
|
|
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()
|
|
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
|
|
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 ? ` · ${
|
|
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
|
-
['
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
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,8 @@
|
|
|
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
|
+
// g the git identity this computer commits as (global user.email) — the same on
|
|
15
|
+
// every computer one person sets up, so it recognises the person, not the machine
|
|
14
16
|
//
|
|
15
17
|
// A VPN, a new network or a new email changes none of these. They are reported by this
|
|
16
18
|
// program, so they identify a computer for an honest client — the same ceiling every
|
|
@@ -126,6 +128,32 @@ function adapterIds() {
|
|
|
126
128
|
return [...out].sort().slice(0, 3);
|
|
127
129
|
}
|
|
128
130
|
|
|
131
|
+
/** Hostname, OS user, CPU model, memory and OS — together, never one alone. */
|
|
132
|
+
function softFingerprint() {
|
|
133
|
+
try {
|
|
134
|
+
let user = '';
|
|
135
|
+
try { user = os.userInfo().username; } catch { /* no user database */ }
|
|
136
|
+
const cpu = (os.cpus()?.[0]?.model || '').replace(/\s+/g, ' ').trim();
|
|
137
|
+
const gb = Math.round(os.totalmem() / 2 ** 30);
|
|
138
|
+
const host = os.hostname();
|
|
139
|
+
if (!host && !user) return null;
|
|
140
|
+
return [process.platform, os.arch(), host, user, cpu, `${gb}GB`].join('|');
|
|
141
|
+
} catch { return null; }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Addresses git ships as examples or that many people leave unset-looking: not a person.
|
|
145
|
+
const PLACEHOLDER_EMAIL = /(^|@)(example\.(com|org|net)|localhost)$|^(you|your[._-]?email|email|user|root|test|admin)@/i;
|
|
146
|
+
|
|
147
|
+
/** The global git identity (user.email), or null when unset or a placeholder. */
|
|
148
|
+
function gitIdentity() {
|
|
149
|
+
try {
|
|
150
|
+
const r = spawnSync('git', ['config', '--global', 'user.email'], { encoding: 'utf8', timeout: 2000, windowsHide: true });
|
|
151
|
+
const v = String(r.stdout || '').trim().toLowerCase();
|
|
152
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) || PLACEHOLDER_EMAIL.test(v)) return null;
|
|
153
|
+
return v;
|
|
154
|
+
} catch { return null; }
|
|
155
|
+
}
|
|
156
|
+
|
|
129
157
|
/**
|
|
130
158
|
* Where the install id's second copy lives: outside ~/.jaitus, in the OS's app-data
|
|
131
159
|
* folder. Always under os.homedir() (not LOCALAPPDATA / XDG_DATA_HOME), so a test run
|
|
@@ -180,6 +208,13 @@ export function machineHeader() {
|
|
|
180
208
|
if (h) parts.push(`h=${h}`);
|
|
181
209
|
parts.push(`i=${hash('i', installId())}`);
|
|
182
210
|
for (const mac of adapterIds()) parts.push(`n=${hash('n', mac)}`);
|
|
211
|
+
// s: the computer as it describes itself — what an id-reset tool leaves alone. The
|
|
212
|
+
// server links it only together with a network both accounts were used from, so two
|
|
213
|
+
// people with the same laptop model are never taken for one.
|
|
214
|
+
const soft = softFingerprint();
|
|
215
|
+
if (soft) parts.push(`s=${hash('s', soft)}`);
|
|
216
|
+
const git = gitIdentity();
|
|
217
|
+
if (git) parts.push(`g=${hash('g', git)}`);
|
|
183
218
|
if (m && cache.m !== m) writeCache({ ...cache, m, ...(h ? { h } : {}) });
|
|
184
219
|
} catch { /* whatever was gathered */ }
|
|
185
220
|
header = parts.join(',');
|
package/src/planView.js
ADDED
|
@@ -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
|
+
}
|
package/src/tui/entries.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { palette, mix, ramp } from './theme.js';
|
|
12
12
|
import { renderMarkdown, inlineSpans } from './markdown.js';
|
|
13
|
-
import { ansiToRows, wrapRows, wrapRow, truncateRow, stringWidth, plain } from './ansi.js';
|
|
13
|
+
import { ansiToRows, wrapRows, wrapRow, truncateRow, stringWidth, plain, padRow } from './ansi.js';
|
|
14
14
|
import { highlightLine, languageForPath } from './highlight.js';
|
|
15
15
|
import { wordRows, wordSize } from './wordmark.js';
|
|
16
16
|
import { MASCOT_ROWS } from '../brand.js';
|
|
@@ -150,7 +150,13 @@ const BUILDERS = {
|
|
|
150
150
|
if (data.attachments?.length) {
|
|
151
151
|
rows.push([indent, { t: data.attachments.map((a) => `⧉ ${a}`).join(' '), color: p.purple }]);
|
|
152
152
|
}
|
|
153
|
-
|
|
153
|
+
// The whole message sits on a faint band the full width of the window, with a row of
|
|
154
|
+
// it above and below, so what the person said never reads as part of the answer. The
|
|
155
|
+
// band is drawn, never copied (the padding is decoration).
|
|
156
|
+
const band = p.raised;
|
|
157
|
+
const shade = (row) => padRow(row.map((span) => ({ ...span, backgroundColor: span.backgroundColor || band })), width, { backgroundColor: band, copy: '' });
|
|
158
|
+
const edge = [{ t: ' '.repeat(Math.max(1, width)), backgroundColor: band, copy: '' }];
|
|
159
|
+
return [edge, ...rows.map(shade), edge];
|
|
154
160
|
},
|
|
155
161
|
|
|
156
162
|
// ── What the model said ──────────────────────────────────────────────────────
|