acuvo-code 0.2.0 → 0.3.0
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/ENTERPRISE.md +927 -927
- package/bin/acuvo.mjs +95 -2
- package/lib/device-login.mjs +151 -0
- package/lib/model.mjs +49 -4
- package/lib/self-update.mjs +174 -0
- package/lib/turn.mjs +37 -3
- package/package.json +1 -1
package/bin/acuvo.mjs
CHANGED
|
@@ -1109,7 +1109,61 @@ ${formatBoard(listed)}
|
|
|
1109
1109
|
let raw = life.loginToken;
|
|
1110
1110
|
if (raw === null) {
|
|
1111
1111
|
if (process.stdin.isTTY) {
|
|
1112
|
-
|
|
1112
|
+
/**
|
|
1113
|
+
* ── ⭐⭐⭐ NO KEY AND A REAL PERSON: LOG THEM IN (2026-08-22) ─────────
|
|
1114
|
+
*
|
|
1115
|
+
* This branch used to `die()` with instructions to go and find a key.
|
|
1116
|
+
* That was the five-step path — sign in, find Settings, create a key,
|
|
1117
|
+
* copy it, come back — where Claude Code has one step.
|
|
1118
|
+
*
|
|
1119
|
+
* ⭐ Roman: *"like how I pay and I can type claude and it works, but it
|
|
1120
|
+
* probably works for anyone, but they have to log in."* This is that.
|
|
1121
|
+
*/
|
|
1122
|
+
const { requestDeviceCode, pollForKey, openBrowser } = await import('../lib/device-login.mjs');
|
|
1123
|
+
const { spawn } = await import('node:child_process');
|
|
1124
|
+
const gateway = process.env.ACUVO_GATEWAY_URL || DEFAULT_GATEWAY_URL;
|
|
1125
|
+
|
|
1126
|
+
let start;
|
|
1127
|
+
try {
|
|
1128
|
+
start = await requestDeviceCode(gateway);
|
|
1129
|
+
} catch (e) {
|
|
1130
|
+
die(e?.message ?? 'could not start a login.', EXIT_USAGE);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
const url = start.verification_uri_complete || start.verification_uri;
|
|
1134
|
+
/**
|
|
1135
|
+
* ⚠️ THE URL AND CODE ARE PRINTED WHETHER OR NOT THE BROWSER OPENS.
|
|
1136
|
+
* This runs over SSH, in containers and in terminals with no desktop
|
|
1137
|
+
* session, where opening a browser is impossible — a flow that assumes
|
|
1138
|
+
* it worked strands every remote user.
|
|
1139
|
+
*/
|
|
1140
|
+
process.stderr.write(`\nYour code: ${start.user_code}\n\n`);
|
|
1141
|
+
const opened = openBrowser(url, { spawn });
|
|
1142
|
+
process.stderr.write(
|
|
1143
|
+
opened
|
|
1144
|
+
? `Opened your browser to approve it. If nothing appeared:\n ${url}\n\n`
|
|
1145
|
+
: `Open this to approve it:\n ${url}\n\n`,
|
|
1146
|
+
);
|
|
1147
|
+
process.stderr.write('Waiting for approval… (Ctrl-C to cancel)\n');
|
|
1148
|
+
|
|
1149
|
+
let granted;
|
|
1150
|
+
try {
|
|
1151
|
+
granted = await pollForKey(gateway, start.device_code, {
|
|
1152
|
+
intervalMs: (start.interval ?? 2) * 1000,
|
|
1153
|
+
expiresInMs: (start.expires_in ?? 600) * 1000,
|
|
1154
|
+
});
|
|
1155
|
+
} catch (e) {
|
|
1156
|
+
die(e?.message ?? 'login failed.', EXIT_USAGE);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
const saved = writeAccount({ token: granted.api_key, gatewayUrl: gateway });
|
|
1160
|
+
const { maskToken: mask } = await import('../lib/login.mjs');
|
|
1161
|
+
process.stderr.write(`\nSigned in. Key ${mask(granted.api_key)} saved.\n`);
|
|
1162
|
+
if (saved && saved.restricted === false) {
|
|
1163
|
+
process.stderr.write('⚠️ Could not restrict permissions on the credentials file — check it yourself.\n');
|
|
1164
|
+
}
|
|
1165
|
+
process.stderr.write('Run `acuvo` to start.\n');
|
|
1166
|
+
process.exit(0);
|
|
1113
1167
|
}
|
|
1114
1168
|
const chunks = [];
|
|
1115
1169
|
for await (const c of process.stdin) chunks.push(c);
|
|
@@ -3096,8 +3150,47 @@ ${formatBoard(listed)}
|
|
|
3096
3150
|
return verdictExit(outcome);
|
|
3097
3151
|
}
|
|
3098
3152
|
|
|
3153
|
+
/**
|
|
3154
|
+
* ── ⭐⭐⭐ THE UPDATE CHECK RUNS AT EXIT, NEVER AT STARTUP ────────────────────
|
|
3155
|
+
*
|
|
3156
|
+
* Roman, 2026-08-22: *"how do we do self updates so all users get updates as
|
|
3157
|
+
* soon as we do it… like Claude."*
|
|
3158
|
+
*
|
|
3159
|
+
* ⚠️ AT EXIT BECAUSE STARTUP LATENCY IS THE ONE THING A CLI CANNOT SPEND. The
|
|
3160
|
+
* user has their answer by the time this runs, so the worst case — a 3-second
|
|
3161
|
+
* timeout against an unreachable registry — costs them nothing they were
|
|
3162
|
+
* waiting on. At startup the same code would delay the first token of every
|
|
3163
|
+
* single run to serve a check that matters once a day.
|
|
3164
|
+
*
|
|
3165
|
+
* ⚠️ AND IT CANNOT FAIL THE RUN. Every path is caught and the exit code is the
|
|
3166
|
+
* one `main()` decided. An update mechanism that can turn a successful task into
|
|
3167
|
+
* a failure has inverted its own purpose.
|
|
3168
|
+
*/
|
|
3169
|
+
async function noticeUpdateQuietly() {
|
|
3170
|
+
try {
|
|
3171
|
+
const { updatesEnabled, checkForUpdate, applyUpdate, updateNotice } = await import('../lib/self-update.mjs');
|
|
3172
|
+
if (!updatesEnabled()) return;
|
|
3173
|
+
// A machine-readable run must stay machine-readable — a friendly line on
|
|
3174
|
+
// stderr is still a surprise to something parsing this.
|
|
3175
|
+
if (process.env.ACUVO_JSON === '1') return;
|
|
3176
|
+
|
|
3177
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
3178
|
+
const { latest, isNewer } = await checkForUpdate({ current: pkg.version });
|
|
3179
|
+
if (!isNewer) return;
|
|
3180
|
+
|
|
3181
|
+
const { spawn } = await import('node:child_process');
|
|
3182
|
+
const started = applyUpdate({ spawn });
|
|
3183
|
+
process.stderr.write(updateNotice(pkg.version, latest, started));
|
|
3184
|
+
} catch {
|
|
3185
|
+
// Deliberately total. Nothing about staying current is worth a stack trace.
|
|
3186
|
+
}
|
|
3187
|
+
}
|
|
3188
|
+
|
|
3099
3189
|
main().then(
|
|
3100
|
-
(code) =>
|
|
3190
|
+
async (code) => {
|
|
3191
|
+
await noticeUpdateQuietly();
|
|
3192
|
+
process.exit(code);
|
|
3193
|
+
},
|
|
3101
3194
|
(err) => {
|
|
3102
3195
|
// Nothing should reach here — every expected failure is a returned value.
|
|
3103
3196
|
// A stack trace escaping to the user is therefore a BUG in this package,
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐⭐ `acuvo` LOGS ITSELF IN ────────────────────────────────────────────
|
|
3
|
+
*
|
|
4
|
+
* Roman, 2026-08-22: *"paying users should be able to type acuvo into a terminal
|
|
5
|
+
* and then it works... like how I pay and I can type claude and it works, but it
|
|
6
|
+
* probably works for anyone, but they have to log in."*
|
|
7
|
+
*
|
|
8
|
+
* RFC 8628 device-authorization grant, client half. Replaces the five-step
|
|
9
|
+
* manual path (sign in → find Settings → create key → copy → `acuvo --login
|
|
10
|
+
* xxi_live_…`) with: run `acuvo`, approve in the browser, done.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ EVERY PIECE OF IO IS INJECTED — `fetchImpl`, `openBrowser`, `sleep`, `now`.
|
|
13
|
+
* Not for purity's sake: this module's whole job is a timing loop against a
|
|
14
|
+
* remote service, and a version that can only be tested by actually waiting two
|
|
15
|
+
* seconds a tick against production is a version nobody tests. The CLI's success
|
|
16
|
+
* path had zero coverage once before, for exactly this reason.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { DEFAULT_GATEWAY_URL } from './account.mjs';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The gateway constant points at the completions endpoint; the device endpoints
|
|
23
|
+
* are siblings of it. Derived rather than duplicated so a self-hosted or staging
|
|
24
|
+
* gateway moves all three together.
|
|
25
|
+
*/
|
|
26
|
+
export function deviceEndpoints(gatewayUrl = DEFAULT_GATEWAY_URL) {
|
|
27
|
+
const base = String(gatewayUrl).replace(/\/api\/cli\/v1\/chat\/completions\/?$/, '');
|
|
28
|
+
return {
|
|
29
|
+
code: `${base}/api/cli/v1/device/code`,
|
|
30
|
+
token: `${base}/api/cli/v1/device/token`,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Ask for a device code. Returns the server's payload, or throws a readable error. */
|
|
35
|
+
export async function requestDeviceCode(gatewayUrl = DEFAULT_GATEWAY_URL, { fetchImpl = fetch, timeoutMs = 15000 } = {}) {
|
|
36
|
+
const { code } = deviceEndpoints(gatewayUrl);
|
|
37
|
+
const controller = new AbortController();
|
|
38
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
39
|
+
let res;
|
|
40
|
+
try {
|
|
41
|
+
res = await fetchImpl(code, { method: 'POST', signal: controller.signal });
|
|
42
|
+
} catch (e) {
|
|
43
|
+
throw new Error(`could not reach Acuvo to start a login (${e?.message ?? e}). Check your connection.`);
|
|
44
|
+
} finally {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* ⚠️ A NON-JSON BODY IS THE SYMPTOM THAT MATTERS HERE. If the middleware ever
|
|
50
|
+
* stops exempting this path, the response is a 307 to an HTML login page —
|
|
51
|
+
* and "unexpected token < in JSON" tells the user nothing they can act on.
|
|
52
|
+
* That exact failure has shipped on this path before.
|
|
53
|
+
*/
|
|
54
|
+
const text = await res.text();
|
|
55
|
+
let json;
|
|
56
|
+
try {
|
|
57
|
+
json = JSON.parse(text);
|
|
58
|
+
} catch {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Acuvo returned a ${res.status} that was not JSON — the login endpoint is not reachable. ` +
|
|
61
|
+
`This is a server-side problem, not something you can fix locally.`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if (!res.ok) throw new Error(json?.error ? `login could not start: ${json.error}` : `login could not start (HTTP ${res.status})`);
|
|
65
|
+
if (!json?.device_code || !json?.user_code) throw new Error('Acuvo did not return a login code.');
|
|
66
|
+
return json;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** RFC 8628 statuses that mean "keep waiting" rather than "stop". */
|
|
70
|
+
const PENDING = new Set(['authorization_pending', 'slow_down']);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Poll until the human approves, denies, or the code expires.
|
|
74
|
+
*
|
|
75
|
+
* @returns {Promise<{api_key: string, tenant_id: string|null}>}
|
|
76
|
+
*/
|
|
77
|
+
export async function pollForKey(
|
|
78
|
+
gatewayUrl,
|
|
79
|
+
deviceCode,
|
|
80
|
+
{
|
|
81
|
+
intervalMs = 2000,
|
|
82
|
+
expiresInMs = 600000,
|
|
83
|
+
fetchImpl = fetch,
|
|
84
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
85
|
+
now = () => Date.now(),
|
|
86
|
+
onTick = () => {},
|
|
87
|
+
} = {},
|
|
88
|
+
) {
|
|
89
|
+
const { token } = deviceEndpoints(gatewayUrl);
|
|
90
|
+
const deadline = now() + expiresInMs;
|
|
91
|
+
let wait = intervalMs;
|
|
92
|
+
|
|
93
|
+
while (now() < deadline) {
|
|
94
|
+
await sleep(wait);
|
|
95
|
+
onTick();
|
|
96
|
+
|
|
97
|
+
let res;
|
|
98
|
+
let json = {};
|
|
99
|
+
try {
|
|
100
|
+
res = await fetchImpl(token, {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
headers: { 'content-type': 'application/json' },
|
|
103
|
+
body: JSON.stringify({ device_code: deviceCode }),
|
|
104
|
+
});
|
|
105
|
+
json = await res.json().catch(() => ({}));
|
|
106
|
+
} catch {
|
|
107
|
+
/**
|
|
108
|
+
* ⚠️ A DROPPED POLL IS NOT A FAILED LOGIN. Wifi blips mid-approval are
|
|
109
|
+
* ordinary; aborting here would throw away an approval the user already
|
|
110
|
+
* gave. Keep waiting until the code itself expires.
|
|
111
|
+
*/
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (res.ok && json.api_key) return { api_key: json.api_key, tenant_id: json.tenant_id ?? null };
|
|
116
|
+
|
|
117
|
+
const err = String(json.error ?? '');
|
|
118
|
+
if (err === 'access_denied') throw new Error('login was denied in the browser.');
|
|
119
|
+
if (err === 'expired_token') throw new Error('the login code expired. Run `acuvo --login` again.');
|
|
120
|
+
if (err === 'already_claimed') throw new Error('that login code was already used. Run `acuvo --login` again.');
|
|
121
|
+
if (err === 'invalid_grant') throw new Error('that login code is not recognised. Run `acuvo --login` again.');
|
|
122
|
+
|
|
123
|
+
// `slow_down` is the server asking for room; honouring it is the difference
|
|
124
|
+
// between a polite client and one that gets rate-limited mid-login.
|
|
125
|
+
if (err === 'slow_down') wait = Math.min(wait * 2, 10000);
|
|
126
|
+
else if (!PENDING.has(err) && !res.ok && res.status >= 500) wait = Math.min(wait * 2, 10000);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
throw new Error('the login code expired before it was approved. Run `acuvo --login` again.');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Best-effort browser open. NEVER throws and never blocks the flow.
|
|
134
|
+
*
|
|
135
|
+
* ⚠️ THE URL IS ALWAYS PRINTED TOO, and that is not redundancy — this runs over
|
|
136
|
+
* SSH, in containers, and in terminals with no desktop session, where opening a
|
|
137
|
+
* browser is impossible by definition. A flow that depends on the open
|
|
138
|
+
* succeeding is a flow that strands every remote user.
|
|
139
|
+
*/
|
|
140
|
+
export function openBrowser(url, { platform = process.platform, spawn } = {}) {
|
|
141
|
+
if (!spawn) return false;
|
|
142
|
+
const cmd = platform === 'win32' ? 'cmd' : platform === 'darwin' ? 'open' : 'xdg-open';
|
|
143
|
+
const args = platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
144
|
+
try {
|
|
145
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
146
|
+
child.unref?.();
|
|
147
|
+
return true;
|
|
148
|
+
} catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
package/lib/model.mjs
CHANGED
|
@@ -91,6 +91,26 @@ export const DEEPSEEK_DIRECT_MODELS = Object.freeze({
|
|
|
91
91
|
* @returns {{ url: string, apiKey: string, model: string } | null}
|
|
92
92
|
*/
|
|
93
93
|
export function directDeepSeek(model, env = process.env) {
|
|
94
|
+
/**
|
|
95
|
+
* ── ⭐⭐⭐ OFF UNLESS EXPLICITLY ASKED FOR (Roman, 2026-08-22) ──────────────
|
|
96
|
+
*
|
|
97
|
+
* *"no direct deepseek api, we can just use the rest of it for testing."*
|
|
98
|
+
*
|
|
99
|
+
* ⚠️ A KEY BEING PRESENT IS NOT A REQUEST TO USE IT. Before this line, merely
|
|
100
|
+
* exporting `DEEPSEEK_API_KEY` silently re-routed every build onto the direct
|
|
101
|
+
* endpoint — which is 3.7x dearer on OUTPUT ($0.66/M vs OpenRouter's $0.18/M)
|
|
102
|
+
* and doubles for 7 hours a day under DeepSeek's peak billing (01:00-04:00 and
|
|
103
|
+
* 06:00-10:00 UTC = 11am-2pm / 4pm-8pm AEST). Measured over 95M tokens that is
|
|
104
|
+
* 62.3% margin against 85.6%.
|
|
105
|
+
*
|
|
106
|
+
* ⭐ Direct's cache READ is genuinely cheaper ($0.007/M vs $0.0154/M) and that
|
|
107
|
+
* is why it once led. It cannot pay for the cache MISSES (2.9x dearer) or the
|
|
108
|
+
* output (3.7x dearer), and output is ~60% of the bill.
|
|
109
|
+
*
|
|
110
|
+
* Mirrors `deepSeekDirectEnabled()` in `console/lib/llm.ts` — the builder and
|
|
111
|
+
* the CLI must not disagree about which vendor serves a build.
|
|
112
|
+
*/
|
|
113
|
+
if (String(env?.ACUVO_DEEPSEEK_DIRECT ?? '') !== '1') return null;
|
|
94
114
|
const key = String(env?.DEEPSEEK_API_KEY ?? '').trim();
|
|
95
115
|
if (!key) return null;
|
|
96
116
|
const mapped = DEEPSEEK_DIRECT_MODELS[String(model ?? '')];
|
|
@@ -325,17 +345,42 @@ export function readModelConfig(env = process.env) {
|
|
|
325
345
|
* is stuck: it needs no key, runs offline, and every line it prints names the
|
|
326
346
|
* variable that fixes it.
|
|
327
347
|
*/
|
|
348
|
+
/**
|
|
349
|
+
* ── ⭐⭐⭐ THE GATEWAY SHIPPED, SO THIS MESSAGE CHANGED WITH IT (2026-08-22) ──
|
|
350
|
+
*
|
|
351
|
+
* The note above promised exactly that: *"When the gateway ships, this message
|
|
352
|
+
* changes with it."* It shipped — `acuvo --login` lands an Acuvo key, and the
|
|
353
|
+
* metered path recorded its first real usage row today after never once having
|
|
354
|
+
* worked.
|
|
355
|
+
*
|
|
356
|
+
* ⚠️⚠️ AND UNTIL THIS EDIT THE FRONT DOOR SOLD THE COMPETITION. The first thing
|
|
357
|
+
* a brand-new user saw was "create your own OpenRouter key" — BYOK, which Roman
|
|
358
|
+
* has ruled out twice, printed as step 1 of onboarding on a package anyone can
|
|
359
|
+
* now `npm i -g`. `--help` did list `--login`; the message people actually hit
|
|
360
|
+
* did not. Every stranger who installed this brought their own key, so we
|
|
361
|
+
* metered nothing and earned nothing.
|
|
362
|
+
*
|
|
363
|
+
* ⭐ BOTH PATHS STAY, ORDER REVERSED. BYOK is not removed — it is honest, it
|
|
364
|
+
* works, and hiding it would make the tool look locked. It is simply no longer
|
|
365
|
+
* the default answer to "how do I start".
|
|
366
|
+
*
|
|
367
|
+
* ⚠️ IT STILL PROMISES NOTHING THAT DOES NOT EXIST. No pricing, no "sign up
|
|
368
|
+
* free", no plan names — self-serve signup has never been walked end to end
|
|
369
|
+
* (every tenant today is operated · unmetered). It names the two commands that
|
|
370
|
+
* are real and stops there.
|
|
371
|
+
*/
|
|
328
372
|
export const MISSING_KEY_MESSAGE = [
|
|
329
373
|
'Acuvo Code — a terminal coding agent that tells you the price before it runs,',
|
|
330
374
|
'stops at the number you set, and can re-check every claim it ever made.',
|
|
331
375
|
'',
|
|
332
|
-
'It needs a
|
|
376
|
+
'It needs a key. Two ways — then run the same command again:',
|
|
377
|
+
'',
|
|
378
|
+
' A) Your Acuvo account, billed to your Acuvo credits:',
|
|
379
|
+
' acuvo --login (paste the key from Settings → API keys)',
|
|
333
380
|
'',
|
|
334
|
-
'
|
|
335
|
-
' 2. Set it:',
|
|
381
|
+
' B) Your own key, billed to you — https://openrouter.ai/keys',
|
|
336
382
|
' export OPENROUTER_API_KEY=sk-or-v1-... (bash / zsh)',
|
|
337
383
|
' $env:OPENROUTER_API_KEY = "sk-or-v1-..." (PowerShell)',
|
|
338
|
-
' 3. Run the same command again.',
|
|
339
384
|
'',
|
|
340
385
|
'A typical task costs $0.001-$0.003. The ceiling is $0.02 a run unless you',
|
|
341
386
|
'raise it, so a mistake costs two cents to find.',
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐⭐ USERS GET UPDATES WITHOUT BEING ASKED TO ──────────────────────────
|
|
3
|
+
*
|
|
4
|
+
* Roman, 2026-08-22: *"how do we do self updates so all users get updates as
|
|
5
|
+
* soon as we do it, and we just advertise the new npm version instead of having
|
|
6
|
+
* users constantly download new versions, like Claude."*
|
|
7
|
+
*
|
|
8
|
+
* npm is IMMUTABLE — a published version can never be changed — so shipping a
|
|
9
|
+
* fix means publishing a new version, and without this module every user sits on
|
|
10
|
+
* whatever they first installed, forever. A bug we fixed in an hour would live
|
|
11
|
+
* on their machine for months.
|
|
12
|
+
*
|
|
13
|
+
* ── ⚠️⚠️ WHAT THIS MUST NEVER DO, WHICH IS MOST OF THE DESIGN ───────────────
|
|
14
|
+
*
|
|
15
|
+
* It must never block a run, never throw, never slow the first token, and never
|
|
16
|
+
* swap files underneath a session that is already executing. An update mechanism
|
|
17
|
+
* that can break the tool is worse than no update mechanism: the failure lands
|
|
18
|
+
* on someone who was in the middle of real work and did not ask for any of it.
|
|
19
|
+
*
|
|
20
|
+
* So: the check is THROTTLED to once a day against a cache, the install runs
|
|
21
|
+
* DETACHED after the decision, and the new version applies on the NEXT run —
|
|
22
|
+
* never the current one.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
26
|
+
import { join, dirname } from 'node:path';
|
|
27
|
+
import { accountDir } from './account.mjs';
|
|
28
|
+
|
|
29
|
+
export const PACKAGE_NAME = 'acuvo-code';
|
|
30
|
+
|
|
31
|
+
/** How long between registry checks. A day is plenty and keeps npm quiet. */
|
|
32
|
+
export const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Compare two semver-ish strings.
|
|
36
|
+
*
|
|
37
|
+
* ⚠️ NUMERIC PER SEGMENT, NOT LEXICOGRAPHIC. `'0.10.0' > '0.9.0'` is TRUE
|
|
38
|
+
* numerically and FALSE as strings — so a string compare stops offering updates
|
|
39
|
+
* exactly when the minor version reaches 10, and does it silently.
|
|
40
|
+
*
|
|
41
|
+
* @returns 1 if a > b, -1 if a < b, 0 if equal
|
|
42
|
+
*/
|
|
43
|
+
export function compareVersions(a, b) {
|
|
44
|
+
const parse = (v) =>
|
|
45
|
+
String(v ?? '')
|
|
46
|
+
.trim()
|
|
47
|
+
.replace(/^v/, '')
|
|
48
|
+
// A prerelease suffix (`1.0.0-beta.1`) is dropped rather than ranked. We
|
|
49
|
+
// do not publish them, and inventing an ordering for something that does
|
|
50
|
+
// not exist is how you offer people a "newer" version that is older.
|
|
51
|
+
.split('-')[0]
|
|
52
|
+
.split('.')
|
|
53
|
+
.map((n) => Number.parseInt(n, 10) || 0);
|
|
54
|
+
const [x, y] = [parse(a), parse(b)];
|
|
55
|
+
for (let i = 0; i < Math.max(x.length, y.length); i += 1) {
|
|
56
|
+
const d = (x[i] ?? 0) - (y[i] ?? 0);
|
|
57
|
+
if (d !== 0) return d > 0 ? 1 : -1;
|
|
58
|
+
}
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function updateCachePath(env = process.env) {
|
|
63
|
+
return join(accountDir(env), 'update-check.json');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readCache(path) {
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function writeCache(path, value) {
|
|
75
|
+
try {
|
|
76
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
77
|
+
writeFileSync(path, JSON.stringify(value), 'utf8');
|
|
78
|
+
} catch {
|
|
79
|
+
// A read-only home is not a reason to fail a run. Worst case we check again
|
|
80
|
+
// next time, which costs one HTTP request.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Is there a newer published version?
|
|
86
|
+
*
|
|
87
|
+
* @returns {Promise<{latest: string, isNewer: boolean, checked: boolean}>}
|
|
88
|
+
*/
|
|
89
|
+
export async function checkForUpdate({
|
|
90
|
+
current,
|
|
91
|
+
fetchImpl = fetch,
|
|
92
|
+
now = () => Date.now(),
|
|
93
|
+
cachePath = updateCachePath(),
|
|
94
|
+
intervalMs = CHECK_INTERVAL_MS,
|
|
95
|
+
timeoutMs = 3000,
|
|
96
|
+
force = false,
|
|
97
|
+
} = {}) {
|
|
98
|
+
const cached = readCache(cachePath);
|
|
99
|
+
if (!force && cached && now() - (cached.at ?? 0) < intervalMs) {
|
|
100
|
+
return { latest: cached.latest ?? current, isNewer: compareVersions(cached.latest, current) > 0, checked: false };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let latest = current;
|
|
104
|
+
try {
|
|
105
|
+
const controller = new AbortController();
|
|
106
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
107
|
+
/**
|
|
108
|
+
* ⚠️ THE `latest` ENDPOINT, NOT THE FULL PACKUMENT. The full document for
|
|
109
|
+
* this package is megabytes of version history; this one is a few hundred
|
|
110
|
+
* bytes. On a slow connection that difference is the whole reason the check
|
|
111
|
+
* finishes inside its timeout instead of being abandoned every run.
|
|
112
|
+
*/
|
|
113
|
+
const res = await fetchImpl(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
|
|
114
|
+
signal: controller.signal,
|
|
115
|
+
headers: { accept: 'application/json' },
|
|
116
|
+
});
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
const json = await res.json();
|
|
119
|
+
if (typeof json?.version === 'string') latest = json.version;
|
|
120
|
+
} catch {
|
|
121
|
+
// Offline, DNS down, npm having a moment. None of these are the user's
|
|
122
|
+
// problem and none should surface.
|
|
123
|
+
return { latest: current, isNewer: false, checked: false };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
writeCache(cachePath, { at: now(), latest });
|
|
127
|
+
return { latest, isNewer: compareVersions(latest, current) > 0, checked: true };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Install the newer version, DETACHED, so it applies to the next run.
|
|
132
|
+
*
|
|
133
|
+
* ⚠️⚠️ NEVER IN-PROCESS AND NEVER AWAITED. Rewriting `lib/*.mjs` underneath a
|
|
134
|
+
* session that is mid-task is how an update becomes a crash in someone else's
|
|
135
|
+
* work — and the person it lands on never asked for the update at all.
|
|
136
|
+
*
|
|
137
|
+
* @returns {boolean} whether the install was successfully STARTED (not finished)
|
|
138
|
+
*/
|
|
139
|
+
export function applyUpdate({ spawn, version = 'latest' } = {}) {
|
|
140
|
+
if (!spawn) return false;
|
|
141
|
+
try {
|
|
142
|
+
const child = spawn(
|
|
143
|
+
process.platform === 'win32' ? 'npm.cmd' : 'npm',
|
|
144
|
+
['install', '-g', `${PACKAGE_NAME}@${version}`],
|
|
145
|
+
{ stdio: 'ignore', detached: true },
|
|
146
|
+
);
|
|
147
|
+
child.unref?.();
|
|
148
|
+
return true;
|
|
149
|
+
} catch {
|
|
150
|
+
// A global install can fail on permissions (a root-owned prefix is common).
|
|
151
|
+
// Silently — the notice below still tells them the command to run.
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The one line a user sees. Deliberately small; nobody wants a changelog here. */
|
|
157
|
+
export function updateNotice(current, latest, applied) {
|
|
158
|
+
return applied
|
|
159
|
+
? `\nacuvo ${latest} is available (you have ${current}) — installing in the background, it will apply next run.\n`
|
|
160
|
+
: `\nacuvo ${latest} is available (you have ${current}) — update with: npm i -g ${PACKAGE_NAME}@latest\n`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Whether we should look at all.
|
|
165
|
+
*
|
|
166
|
+
* ⚠️ OFF FOR CI AND FOR ANYONE WHO SAYS SO. A build machine that silently
|
|
167
|
+
* upgrades its own toolchain mid-pipeline produces results nobody can reproduce,
|
|
168
|
+
* which is precisely the thing CI exists to prevent.
|
|
169
|
+
*/
|
|
170
|
+
export function updatesEnabled(env = process.env) {
|
|
171
|
+
if (String(env.ACUVO_NO_UPDATE ?? '') === '1') return false;
|
|
172
|
+
if (String(env.CI ?? '').toLowerCase() === 'true' || env.CI === '1') return false;
|
|
173
|
+
return true;
|
|
174
|
+
}
|
package/lib/turn.mjs
CHANGED
|
@@ -49,7 +49,7 @@ import { budgetedAsker } from './ask-user.mjs';
|
|
|
49
49
|
import { normalizeRelativePath } from './workspace.mjs';
|
|
50
50
|
import { stringifyForModel } from './model-json.mjs';
|
|
51
51
|
import { callModel, DEFAULT_MAX_TOKENS, DEFAULT_TIMEOUT_MS, pinOutcome, providerOrderFor } from './model.mjs';
|
|
52
|
-
import { randomUUID } from 'node:crypto';
|
|
52
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
53
53
|
/**
|
|
54
54
|
* The pure prefix instrument. See the prefix-stability block in the round loop.
|
|
55
55
|
* It was written, tested, and imported by nothing until 2026-08-20 —
|
|
@@ -71,8 +71,42 @@ import { detectDrift, driftNudge, reanchorDecision, reconcile, formatReconciliat
|
|
|
71
71
|
* key OpenRouter groups by — and a resumed session passes its own id anyway,
|
|
72
72
|
* which is exactly where the two need to agree and do.
|
|
73
73
|
*/
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
/**
|
|
75
|
+
* ── 💰⭐⭐⭐ DERIVED FROM THE WORKSPACE, NOT RANDOM (2026-08-22) ──────────────
|
|
76
|
+
*
|
|
77
|
+
* This returned `acuvo-${randomUUID()}` and the note below already recorded the
|
|
78
|
+
* damage without naming it as a bug: *"each cold process rolled the dice
|
|
79
|
+
* afresh"*, measured as a **65 / 98 / 31 / 98** cache alternation across runs.
|
|
80
|
+
*
|
|
81
|
+
* ⭐ THAT ALTERNATION *IS* THE MISSING CACHE. `session_id` is what OpenRouter
|
|
82
|
+
* groups by when choosing an upstream, and `deepseek-v4-flash-0731` has 28 of
|
|
83
|
+
* them. A fresh id every process means a fresh upstream every process — and a
|
|
84
|
+
* cold prompt cache each time, because a cache lives on ONE provider. Only
|
|
85
|
+
* resumed sessions escaped it, and the common case (`acuvo "do the thing"` in a
|
|
86
|
+
* project, over and over) never resumes.
|
|
87
|
+
*
|
|
88
|
+
* ⭐ THE WORKSPACE IS THE RIGHT ANCHOR. The same project returns to the same
|
|
89
|
+
* upstream every run, so its prefix stays warm across processes, across days,
|
|
90
|
+
* and across several terminals open on the same repo — which now SHARE a cache
|
|
91
|
+
* instead of each warming their own.
|
|
92
|
+
*
|
|
93
|
+
* ⚠️ IT IS A PREFERENCE, NOT A PIN. OpenRouter still falls back when that
|
|
94
|
+
* upstream is unhealthy, so this trades no availability for the cache.
|
|
95
|
+
*
|
|
96
|
+
* ⚠️ AND IT MUST NOT BE THE PATH ITSELF. The key goes over the wire to a third
|
|
97
|
+
* party; `C:/Projects/clients/<name>` would leak a customer list into request
|
|
98
|
+
* metadata. Hashed, so it is stable and says nothing.
|
|
99
|
+
*/
|
|
100
|
+
export function defaultStickyKey() {
|
|
101
|
+
let root;
|
|
102
|
+
try {
|
|
103
|
+
root = process.cwd();
|
|
104
|
+
} catch {
|
|
105
|
+
// A deleted cwd throws here. Falling back to a random id is correct — an
|
|
106
|
+
// unidentifiable workspace should not collide with a real one's cache.
|
|
107
|
+
return `acuvo-${randomUUID()}`;
|
|
108
|
+
}
|
|
109
|
+
return `acuvo-${createHash('sha256').update(root).digest('hex').slice(0, 32)}`;
|
|
76
110
|
}
|
|
77
111
|
/**
|
|
78
112
|
* ⭐ THE DEFAULT IS THE CHAIN, NOT THE SINGLE CALL. `callModel` is still
|