jaitus 0.3.14 → 0.3.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +192 -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 -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
|
},
|
package/src/machine.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// ─── This computer's identifiers, for the free tier ───────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Only so many accounts on one computer get free prompts (the server decides; see
|
|
4
|
+
// backend/services/deviceGuard.js). The CLI tells it which computer it is on with
|
|
5
|
+
// several identifiers, each one HASHED here — the raw values never leave the machine:
|
|
6
|
+
//
|
|
7
|
+
// m the operating system's machine id: Windows MachineGuid, Linux /etc/machine-id
|
|
8
|
+
// h the firmware's hardware UUID (SMBIOS / macOS IOPlatformUUID) — survives an OS
|
|
9
|
+
// reinstall
|
|
10
|
+
// i this CLI's install id, kept in two places (~/.jaitus and the OS's app-data
|
|
11
|
+
// folder), each restoring the other
|
|
12
|
+
// n the address of each physical network adapter (virtual and randomised ones are
|
|
13
|
+
// skipped: they change by themselves)
|
|
14
|
+
//
|
|
15
|
+
// A VPN, a new network or a new email changes none of these. They are reported by this
|
|
16
|
+
// program, so they identify a computer for an honest client — the same ceiling every
|
|
17
|
+
// product that does this has (Cursor's machine ids are reset by published tools).
|
|
18
|
+
//
|
|
19
|
+
// Everything here is best-effort and never blocks or breaks a request: a probe that
|
|
20
|
+
// fails just leaves its identifier out. The slow one (Windows SMBIOS, ~1 s through
|
|
21
|
+
// PowerShell) runs once in the background and is cached.
|
|
22
|
+
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import os from 'node:os';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import crypto from 'node:crypto';
|
|
27
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
28
|
+
import { HOME } from './config.js';
|
|
29
|
+
import { deviceId } from './session.js';
|
|
30
|
+
|
|
31
|
+
const CACHE_FILE = path.join(HOME, 'machine.json');
|
|
32
|
+
const hash = (kind, raw) => crypto.createHash('sha256').update(`jaitus-device:${kind}:${String(raw).trim().toLowerCase()}`).digest('hex');
|
|
33
|
+
|
|
34
|
+
// Values firmware vendors ship unset, shared by thousands of boards.
|
|
35
|
+
const JUNK_UUID = /^(0{8}-0{4}-0{4}-0{4}-0{12}|f{8}-f{4}-f{4}-f{4}-f{12}|03000200-0400-0500-0006-000700080009|00020003-0004-0005-0006-000700080009)$/i;
|
|
36
|
+
// Adapters that are virtual or move by themselves: Hyper-V, VirtualBox, VMware,
|
|
37
|
+
// Parallels, Docker/WSL bridges.
|
|
38
|
+
const VIRTUAL_OUI = /^(00:15:5d|08:00:27|0a:00:27|00:05:69|00:0c:29|00:1c:14|00:50:56|00:1c:42|02:42)/i;
|
|
39
|
+
|
|
40
|
+
function readCache() {
|
|
41
|
+
try { return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')) || {}; } catch { return {}; }
|
|
42
|
+
}
|
|
43
|
+
function writeCache(obj) {
|
|
44
|
+
try {
|
|
45
|
+
fs.mkdirSync(HOME, { recursive: true, mode: 0o700 });
|
|
46
|
+
fs.writeFileSync(CACHE_FILE, JSON.stringify(obj, null, 2), { mode: 0o600 });
|
|
47
|
+
} catch { /* unwritable home: probed again next launch */ }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The OS machine id (fast: one registry read or one file). */
|
|
51
|
+
function osMachineId() {
|
|
52
|
+
try {
|
|
53
|
+
if (process.platform === 'win32') {
|
|
54
|
+
const r = spawnSync('reg', ['query', 'HKLM\\SOFTWARE\\Microsoft\\Cryptography', '/v', 'MachineGuid'], { encoding: 'utf8', timeout: 2000, windowsHide: true });
|
|
55
|
+
const m = /MachineGuid\s+REG_SZ\s+([0-9a-f-]{36})/i.exec(r.stdout || '');
|
|
56
|
+
return m ? m[1] : null;
|
|
57
|
+
}
|
|
58
|
+
if (process.platform === 'linux') {
|
|
59
|
+
for (const f of ['/etc/machine-id', '/var/lib/dbus/machine-id']) {
|
|
60
|
+
try { const v = fs.readFileSync(f, 'utf8').trim(); if (/^[0-9a-f]{32}$/i.test(v)) return v; } catch { /* next */ }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} catch { /* left out */ }
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The firmware's hardware UUID, synchronously where that is cheap (macOS, Linux). */
|
|
68
|
+
function hardwareIdSync() {
|
|
69
|
+
try {
|
|
70
|
+
if (process.platform === 'darwin') {
|
|
71
|
+
const r = spawnSync('ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice'], { encoding: 'utf8', timeout: 2000 });
|
|
72
|
+
const m = /"IOPlatformUUID"\s*=\s*"([0-9A-F-]{36})"/i.exec(r.stdout || '');
|
|
73
|
+
return m && !JUNK_UUID.test(m[1]) ? m[1] : null;
|
|
74
|
+
}
|
|
75
|
+
if (process.platform === 'linux') {
|
|
76
|
+
const v = fs.readFileSync('/sys/class/dmi/id/product_uuid', 'utf8').trim(); // root-only on most distros
|
|
77
|
+
return /^[0-9a-f-]{36}$/i.test(v) && !JUNK_UUID.test(v) ? v : null;
|
|
78
|
+
}
|
|
79
|
+
} catch { /* left out */ }
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let smbiosProbe = null;
|
|
84
|
+
/** Windows: SMBIOS UUID through CIM, in the background, cached. */
|
|
85
|
+
function probeWindowsHardware() {
|
|
86
|
+
if (process.platform !== 'win32' || smbiosProbe) return smbiosProbe;
|
|
87
|
+
smbiosProbe = new Promise((resolve) => {
|
|
88
|
+
let out = '';
|
|
89
|
+
let child;
|
|
90
|
+
try {
|
|
91
|
+
child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', '(Get-CimInstance -ClassName Win32_ComputerSystemProduct).UUID'], { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
92
|
+
} catch { resolve(null); return; }
|
|
93
|
+
const t = setTimeout(() => { try { child.kill(); } catch { /* gone */ } resolve(null); }, 8000);
|
|
94
|
+
t.unref?.();
|
|
95
|
+
child.stdout.on('data', (d) => { out += d; });
|
|
96
|
+
child.on('error', () => { clearTimeout(t); resolve(null); });
|
|
97
|
+
child.on('close', () => {
|
|
98
|
+
clearTimeout(t);
|
|
99
|
+
const v = String(out).trim();
|
|
100
|
+
resolve(/^[0-9a-f-]{36}$/i.test(v) && !JUNK_UUID.test(v) ? v : null);
|
|
101
|
+
});
|
|
102
|
+
}).then((v) => {
|
|
103
|
+
const cache = readCache();
|
|
104
|
+
cache.h = v ? hash('h', v) : '';
|
|
105
|
+
cache.hAt = new Date().toISOString();
|
|
106
|
+
writeCache(cache);
|
|
107
|
+
return v;
|
|
108
|
+
});
|
|
109
|
+
return smbiosProbe;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Physical adapters' addresses, globally administered only, at most three. */
|
|
113
|
+
function adapterIds() {
|
|
114
|
+
const out = new Set();
|
|
115
|
+
try {
|
|
116
|
+
for (const list of Object.values(os.networkInterfaces())) {
|
|
117
|
+
for (const a of list || []) {
|
|
118
|
+
const mac = String(a.mac || '').toLowerCase();
|
|
119
|
+
if (a.internal || !/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/.test(mac) || mac === '00:00:00:00:00:00') continue;
|
|
120
|
+
if (parseInt(mac.slice(0, 2), 16) & 0x02) continue; // locally administered / randomised
|
|
121
|
+
if (VIRTUAL_OUI.test(mac)) continue;
|
|
122
|
+
out.add(mac);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
} catch { /* none */ }
|
|
126
|
+
return [...out].sort().slice(0, 3);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Where the install id's second copy lives: outside ~/.jaitus, in the OS's app-data
|
|
131
|
+
* folder. Always under os.homedir() (not LOCALAPPDATA / XDG_DATA_HOME), so a test run
|
|
132
|
+
* with its own home never writes into the real one.
|
|
133
|
+
*/
|
|
134
|
+
function mirrorPath() {
|
|
135
|
+
if (process.platform === 'win32') return path.join(os.homedir(), 'AppData', 'Local', 'Jaitus', 'install-id');
|
|
136
|
+
if (process.platform === 'darwin') return path.join(os.homedir(), 'Library', 'Application Support', 'Jaitus', 'install-id');
|
|
137
|
+
return path.join(os.homedir(), '.local', 'share', 'jaitus', 'install-id');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The install id, restored from either copy and written back to both. */
|
|
141
|
+
function installId() {
|
|
142
|
+
const mirror = mirrorPath();
|
|
143
|
+
let fromMirror = null;
|
|
144
|
+
try { const v = fs.readFileSync(mirror, 'utf8').trim(); if (/^[A-Za-z0-9_-]{8,80}$/.test(v)) fromMirror = v; } catch { /* first run */ }
|
|
145
|
+
const deviceFile = path.join(HOME, 'device.json');
|
|
146
|
+
let id = null;
|
|
147
|
+
try { const saved = JSON.parse(fs.readFileSync(deviceFile, 'utf8')); if (/^[A-Za-z0-9_-]{8,80}$/.test(saved?.id || '')) id = saved.id; } catch { /* none */ }
|
|
148
|
+
if (!id && fromMirror) {
|
|
149
|
+
id = fromMirror;
|
|
150
|
+
try { fs.mkdirSync(HOME, { recursive: true, mode: 0o700 }); fs.writeFileSync(deviceFile, JSON.stringify({ id, createdAt: new Date().toISOString() }, null, 2), { mode: 0o600 }); } catch { /* best effort */ }
|
|
151
|
+
}
|
|
152
|
+
if (!id) id = deviceId();
|
|
153
|
+
if (fromMirror !== id) {
|
|
154
|
+
try { fs.mkdirSync(path.dirname(mirror), { recursive: true }); fs.writeFileSync(mirror, id); } catch { /* best effort */ }
|
|
155
|
+
}
|
|
156
|
+
return id;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let header = null;
|
|
160
|
+
/**
|
|
161
|
+
* The `x-jaitus-device` header: "m=<hash>,h=<hash>,i=<hash>,n=<hash>,…". Built once
|
|
162
|
+
* per process; the Windows hardware id joins it once its background probe is done.
|
|
163
|
+
*/
|
|
164
|
+
export function machineHeader() {
|
|
165
|
+
if (process.env.JAITUS_DEVICE_HEADER !== undefined) return process.env.JAITUS_DEVICE_HEADER; // tests
|
|
166
|
+
if (header) return header;
|
|
167
|
+
const cache = readCache();
|
|
168
|
+
const parts = [];
|
|
169
|
+
try {
|
|
170
|
+
const m = cache.m || (osMachineId() ? hash('m', osMachineId()) : '');
|
|
171
|
+
if (m) parts.push(`m=${m}`);
|
|
172
|
+
let h = cache.h;
|
|
173
|
+
if (h === undefined) {
|
|
174
|
+
const sync = hardwareIdSync();
|
|
175
|
+
h = sync ? hash('h', sync) : undefined;
|
|
176
|
+
if (h === undefined && process.platform === 'win32') {
|
|
177
|
+
probeWindowsHardware()?.then(() => { header = null; }).catch(() => {});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (h) parts.push(`h=${h}`);
|
|
181
|
+
parts.push(`i=${hash('i', installId())}`);
|
|
182
|
+
for (const mac of adapterIds()) parts.push(`n=${hash('n', mac)}`);
|
|
183
|
+
if (m && cache.m !== m) writeCache({ ...cache, m, ...(h ? { h } : {}) });
|
|
184
|
+
} catch { /* whatever was gathered */ }
|
|
185
|
+
header = parts.join(',');
|
|
186
|
+
return header;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Start the slow probe early (at launch), so the first request already carries it. */
|
|
190
|
+
export function warmMachineHeader() {
|
|
191
|
+
try { if (readCache().h === undefined) probeWindowsHardware(); } catch { /* best effort */ }
|
|
192
|
+
}
|