jaitus 0.3.14 → 0.3.17
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/agent/loop.js +40 -4
- package/src/api.js +177 -174
- package/src/commands/update.js +8 -3
- package/src/compact.js +224 -0
- package/src/errors.js +2 -0
- package/src/index.js +220 -119
- package/src/machine.js +227 -0
- package/src/tui/ansi.js +65 -1
- package/src/tui/entries.js +23 -11
- package/src/tui/markdown.js +40 -15
- package/src/tui-ink.js +2922 -2841
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
|
-
|
|
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
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
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
|
-
|
|
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
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
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
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
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
|
},
|