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