@mnemom/mnemom 0.17.31 → 0.17.32-next.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/dist/base-url-proxy.mjs +30 -0
- package/dist/commands/agent-fuel.d.ts +75 -0
- package/dist/commands/agent-fuel.js +298 -0
- package/dist/commands/agent-implicit-notice.d.ts +31 -0
- package/dist/commands/agent-implicit-notice.js +73 -0
- package/dist/commands/agent-session.d.ts +2 -1
- package/dist/commands/agent-session.js +2 -1
- package/dist/commands/agent.d.ts +52 -1
- package/dist/commands/agent.js +111 -26
- package/dist/commands/status.js +1 -1
- package/dist/commands/update.js +3 -1
- package/dist/commands/validate.d.ts +1 -1
- package/dist/commands/validate.js +1 -1
- package/dist/index.js +51 -12
- package/dist/lib/agent.d.ts +32 -19
- package/dist/lib/agent.js +34 -32
- package/dist/lib/api.d.ts +53 -0
- package/dist/lib/api.js +31 -10
- package/dist/lib/canonical-json.d.ts +12 -0
- package/dist/lib/canonical-json.js +22 -0
- package/dist/lib/conduct-receipt.d.ts +81 -0
- package/dist/lib/conduct-receipt.js +177 -0
- package/dist/lib/contract-hash.d.ts +61 -0
- package/dist/lib/contract-hash.js +126 -0
- package/dist/lib/experimental.d.ts +10 -1
- package/dist/lib/experimental.js +35 -1
- package/dist/lib/oauth.d.ts +2 -1
- package/dist/lib/oauth.js +2 -1
- package/dist/lib/statusline-bin.d.ts +2 -2
- package/dist/lib/statusline-bin.js +4 -7
- package/dist/lib/statusline-feed.d.ts +33 -0
- package/dist/lib/statusline-feed.js +68 -0
- package/dist/lib/statusline.d.ts +39 -88
- package/dist/lib/statusline.js +58 -250
- package/dist/lib/update/decide.d.ts +22 -3
- package/dist/lib/update/decide.js +35 -12
- package/dist/lib/update/doctor.js +5 -1
- package/dist/lib/update/run.d.ts +2 -0
- package/dist/lib/update/run.js +22 -8
- package/package.json +1 -1
package/dist/lib/statusline.js
CHANGED
|
@@ -1,240 +1,82 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The `mnemom agent` status-bar
|
|
3
|
-
*
|
|
2
|
+
* The `mnemom agent` status-bar segment: a Claude Code statusLine command that prints
|
|
3
|
+
* "mnemom", after the user's own status line when they have one.
|
|
4
4
|
*
|
|
5
|
-
* The
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* first-run screen always agree, and it rounds DOWN to the cent.
|
|
5
|
+
* The segment never says "saved" and never shows an estimate (decision 14). The old
|
|
6
|
+
* "mnemom saved ~$X this session" counter priced the transcript with the first-run look's
|
|
7
|
+
* benchmark estimate; it is gone on every channel, stable included.
|
|
9
8
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* Measured mode (the `measured-savings` experimental feature; the launch sets
|
|
17
|
-
* MNEMOM_STATUSLINE_MEASURED=1): the gateway measures each request's saving — the stock
|
|
18
|
-
* request's input cost minus what it billed — and writes it into the response usage as
|
|
19
|
-
* `mnemom_savings` inside a `usage.iterations` entry, the one place Claude Code keeps an
|
|
20
|
-
* unknown usage key in its transcript. The counter sums those per message id
|
|
21
|
-
* ("mnemom saved $X this session", no "~": it is measured) and makes no claim before the
|
|
22
|
-
* first one arrives. Its state lives in its own file (<session>.measured.json), so the two
|
|
23
|
-
* modes never share a count.
|
|
9
|
+
* On a prerelease (`next`) launch the segment is fed: the launcher writes this session's
|
|
10
|
+
* measured model cost and μ from Record's accumulator to the file named in
|
|
11
|
+
* MNEMOM_STATUSLINE_FEED (lib/statusline-feed.ts), and the segment reads
|
|
12
|
+
* "mnemom · $0.42 · 1.70 μ". Until that file holds valid totals it is only "mnemom".
|
|
13
|
+
* Stable launches set no feed, so stable is only "mnemom".
|
|
24
14
|
*
|
|
25
15
|
* A user's own statusLine is never replaced: the launch passes it in
|
|
26
16
|
* MNEMOM_STATUSLINE_INNER, it runs with the same stdin, and our segment is appended.
|
|
27
|
-
*
|
|
28
|
-
* the user's own statusLine command, and the first-run code is held to spawning nothing.
|
|
17
|
+
* No transcript reads and no network: the only file read is the feed.
|
|
29
18
|
*/
|
|
30
19
|
import { execSync } from "node:child_process";
|
|
31
|
-
import {
|
|
20
|
+
import { readFileSync } from "node:fs";
|
|
32
21
|
import { homedir } from "node:os";
|
|
33
22
|
import { join } from "node:path";
|
|
34
|
-
import { origContext, parseUsageLine, rowCost, ruleRate } from "./first-run/backtest.js";
|
|
35
23
|
import { parseJson } from "./first-run/lines.js";
|
|
36
|
-
export const STATE_DIR = "statusline";
|
|
37
|
-
export const MAX_BYTES_PER_TICK = 8 * 1024 * 1024;
|
|
38
24
|
/** The launcher passes the user's own statusLine command here. */
|
|
39
25
|
export const INNER_ENV = "MNEMOM_STATUSLINE_INNER";
|
|
40
|
-
/** Set to 1 to launch without the
|
|
26
|
+
/** Set to 1 to launch without the segment. */
|
|
41
27
|
export const NO_STATUSLINE_ENV = "MNEMOM_AGENT_NO_STATUSLINE";
|
|
42
|
-
/** The launcher sets this to 1 when the gateway measures the session's saving. */
|
|
43
|
-
export const MEASURED_ENV = "MNEMOM_STATUSLINE_MEASURED";
|
|
44
28
|
export const INNER_TIMEOUT_MS = 2000;
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
export
|
|
49
|
-
return { v: 1, files: {}, ids: {}, saved: 0, seen: false };
|
|
50
|
-
}
|
|
51
|
-
/** The main transcript, then its subagent transcripts (sorted). */
|
|
52
|
-
export function sessionFiles(transcriptPath) {
|
|
53
|
-
const dir = join(transcriptPath.replace(/\.jsonl$/, ""), "subagents");
|
|
54
|
-
let subs = [];
|
|
55
|
-
try {
|
|
56
|
-
subs = readdirSync(dir)
|
|
57
|
-
.filter((f) => f.endsWith(".jsonl"))
|
|
58
|
-
.sort()
|
|
59
|
-
.map((f) => join(dir, f));
|
|
60
|
-
}
|
|
61
|
-
catch {
|
|
62
|
-
/* no subagents */
|
|
63
|
-
}
|
|
64
|
-
return [transcriptPath, ...subs];
|
|
65
|
-
}
|
|
29
|
+
/** What the segment says until it is fed. */
|
|
30
|
+
export const SEGMENT = "mnemom";
|
|
31
|
+
/** The launcher names the feed file here (prerelease launches only). */
|
|
32
|
+
export const FEED_ENV = "MNEMOM_STATUSLINE_FEED";
|
|
66
33
|
/**
|
|
67
|
-
* The
|
|
68
|
-
*
|
|
34
|
+
* The gateway's per-conversation lineage key (deriveLineageKey): `cid:` or `cr:` + 16 hex.
|
|
35
|
+
* base-url-proxy.mjs (a standalone script) keeps its own copy; a test pins the two equal.
|
|
69
36
|
*/
|
|
70
|
-
export
|
|
71
|
-
|
|
37
|
+
export const LINEAGE_KEY_RE = /^(cid|cr):[0-9a-f]{16}$/;
|
|
38
|
+
/** A millimu amount as the record route returns it: a decimal string, no sign, no leading 0. */
|
|
39
|
+
export const MMU_RE = /^(0|[1-9][0-9]{0,18})$/;
|
|
40
|
+
/** The feed file's totals, or null when it is absent or anything in it is off. */
|
|
41
|
+
export function readFeed(path) {
|
|
72
42
|
try {
|
|
73
|
-
|
|
43
|
+
const d = parseJson(readFileSync(path, "utf8"));
|
|
44
|
+
if (!d)
|
|
45
|
+
return null;
|
|
46
|
+
const cost = d.model_cost_mmu;
|
|
47
|
+
const mu = d.mu_charged_mmu;
|
|
48
|
+
if (typeof cost !== "string" || !MMU_RE.test(cost))
|
|
49
|
+
return null;
|
|
50
|
+
if (mu !== null && (typeof mu !== "string" || !MMU_RE.test(mu)))
|
|
51
|
+
return null;
|
|
52
|
+
return { model_cost_mmu: cost, mu_charged_mmu: mu };
|
|
74
53
|
}
|
|
75
54
|
catch {
|
|
76
55
|
return null;
|
|
77
56
|
}
|
|
78
|
-
try {
|
|
79
|
-
const size = fstatSync(fd).size;
|
|
80
|
-
if (size < offset)
|
|
81
|
-
return null;
|
|
82
|
-
const n = Math.min(size - offset, maxBytes);
|
|
83
|
-
if (n <= 0)
|
|
84
|
-
return { lines: [], next: offset };
|
|
85
|
-
const buf = Buffer.allocUnsafe(n);
|
|
86
|
-
const got = readSync(fd, buf, 0, n, offset);
|
|
87
|
-
const view = buf.subarray(0, got);
|
|
88
|
-
const last = view.lastIndexOf(0x0a);
|
|
89
|
-
// One line longer than the whole budget: skip past it rather than stall forever.
|
|
90
|
-
if (last === -1)
|
|
91
|
-
return { lines: [], next: got === maxBytes ? offset + got : offset };
|
|
92
|
-
const lines = [];
|
|
93
|
-
let start = 0;
|
|
94
|
-
for (let nl = view.indexOf(0x0a); nl !== -1 && nl <= last; nl = view.indexOf(0x0a, start)) {
|
|
95
|
-
lines.push(view.subarray(start, nl));
|
|
96
|
-
start = nl + 1;
|
|
97
|
-
}
|
|
98
|
-
return { lines, next: offset + last + 1 };
|
|
99
|
-
}
|
|
100
|
-
finally {
|
|
101
|
-
closeSync(fd);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
/**
|
|
105
|
-
* The incremental read both modes share: each file's new complete lines, within the tick's
|
|
106
|
-
* byte budget, go to `add` (with the key a record without an id is counted under). A file
|
|
107
|
-
* that shrank or vanished starts over from `fresh()`.
|
|
108
|
-
*/
|
|
109
|
-
function readSession(st, files, maxBytes, fresh, add) {
|
|
110
|
-
let budget = maxBytes;
|
|
111
|
-
for (const f of files) {
|
|
112
|
-
if (budget <= 0)
|
|
113
|
-
break;
|
|
114
|
-
const from = st.files[f] ?? 0;
|
|
115
|
-
const r = readNewLines(f, from, budget);
|
|
116
|
-
if (r === null) {
|
|
117
|
-
if (from > 0)
|
|
118
|
-
return readSession(fresh(), files, maxBytes, fresh, add);
|
|
119
|
-
continue;
|
|
120
|
-
}
|
|
121
|
-
budget -= r.next - from;
|
|
122
|
-
const subagent = f.includes("/subagents/");
|
|
123
|
-
r.lines.forEach((line, i) => add(st, line, subagent, `anon-${f}-${from}-${i}`));
|
|
124
|
-
st.files[f] = r.next;
|
|
125
|
-
}
|
|
126
|
-
return st;
|
|
127
57
|
}
|
|
128
|
-
/**
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
return;
|
|
134
|
-
const key = p.id ?? anonKey;
|
|
135
|
-
const cost = rowCost(p.row);
|
|
136
|
-
const saved = ruleRate(origContext(p.row)).screen * cost;
|
|
137
|
-
const prev = s.ids[key];
|
|
138
|
-
if (prev) {
|
|
139
|
-
s.actual -= prev[0];
|
|
140
|
-
s.saved -= prev[1];
|
|
141
|
-
}
|
|
142
|
-
s.ids[key] = [cost, saved];
|
|
143
|
-
s.actual += cost;
|
|
144
|
-
s.saved += saved;
|
|
145
|
-
});
|
|
58
|
+
/** `n / 10^scale` rounded half up, as a decimal with `places` digits, in BigInt (never a float). */
|
|
59
|
+
function fixed(mmu, divisor, places) {
|
|
60
|
+
const v = (BigInt(mmu) * 10n ** BigInt(places) * 2n + divisor) / (2n * divisor);
|
|
61
|
+
const s = v.toString().padStart(places + 1, "0");
|
|
62
|
+
return `${s.slice(0, -places)}.${s.slice(-places)}`;
|
|
146
63
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
return
|
|
153
|
-
}
|
|
154
|
-
/**
|
|
155
|
-
* One transcript line → the gateway's measured saving for its request, or null (not an
|
|
156
|
-
* assistant record, or no measurement in its `message.usage.iterations`). `id` is the message
|
|
157
|
-
* id (or record uuid); null when the record carries neither. Claude Code writes one record per
|
|
158
|
-
* content block, all with the same id and usage, so the id is what dedupes them.
|
|
159
|
-
*/
|
|
160
|
-
export function parseMeasuredLine(line) {
|
|
161
|
-
if (line.indexOf(SAVINGS) === -1 || line.indexOf(ASSISTANT) === -1)
|
|
162
|
-
return null;
|
|
163
|
-
const d = parseJson(line);
|
|
164
|
-
if (d?.type !== "assistant")
|
|
165
|
-
return null;
|
|
166
|
-
const m = d.message;
|
|
167
|
-
const iterations = m?.usage?.iterations;
|
|
168
|
-
if (!Array.isArray(iterations))
|
|
169
|
-
return null;
|
|
170
|
-
const saved = iterations.map(measuredSaving).find((v) => v !== null);
|
|
171
|
-
if (saved === undefined)
|
|
172
|
-
return null;
|
|
173
|
-
const id = (typeof m?.id === "string" && m.id) || (typeof d.uuid === "string" && d.uuid) || null;
|
|
174
|
-
return { id, saved };
|
|
64
|
+
/** Model cost in dollars: 1000 mmu = 1 μ = 1¢. Under half a cent reads "<$0.01", never "$0.00". */
|
|
65
|
+
export function formatCost(mmu) {
|
|
66
|
+
const big = BigInt(mmu);
|
|
67
|
+
if (big > 0n && big < 500n)
|
|
68
|
+
return "<$0.01";
|
|
69
|
+
return `$${fixed(mmu, 100000n, 2)}`;
|
|
175
70
|
}
|
|
176
|
-
/**
|
|
177
|
-
export function
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
return;
|
|
182
|
-
const key = p.id ?? anonKey;
|
|
183
|
-
s.saved += p.saved - (s.ids[key] ?? 0);
|
|
184
|
-
s.ids[key] = p.saved;
|
|
185
|
-
s.seen = true;
|
|
186
|
-
});
|
|
71
|
+
/** μ charged, two places; null (no billing row) reads "μ not charged" (OD-9). */
|
|
72
|
+
export function formatMu(mmu) {
|
|
73
|
+
if (mmu === null)
|
|
74
|
+
return "μ not charged";
|
|
75
|
+
return `${fixed(mmu, 1000n, 2)} μ`;
|
|
187
76
|
}
|
|
188
|
-
/**
|
|
189
|
-
function
|
|
190
|
-
return (
|
|
191
|
-
}
|
|
192
|
-
/** The segment, rounded DOWN to the cent and never below $0.00. */
|
|
193
|
-
export function segment(saved) {
|
|
194
|
-
return `mnemom saved ~$${cents(saved)} this session`;
|
|
195
|
-
}
|
|
196
|
-
/** Measured mode's segment: no claim until the gateway has measured something; no "~". */
|
|
197
|
-
export function measuredSegment(st) {
|
|
198
|
-
return st.seen ? `mnemom saved $${cents(st.saved)} this session` : "mnemom";
|
|
199
|
-
}
|
|
200
|
-
function stateName(sessionId) {
|
|
201
|
-
return sessionId.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
202
|
-
}
|
|
203
|
-
function statePath(stateDir, sessionId) {
|
|
204
|
-
return join(stateDir, `${stateName(sessionId)}.json`);
|
|
205
|
-
}
|
|
206
|
-
/** Measured mode's own file: a sanitized id has no ".", so it never collides with statePath. */
|
|
207
|
-
function measuredStatePath(stateDir, sessionId) {
|
|
208
|
-
return join(stateDir, `${stateName(sessionId)}.measured.json`);
|
|
209
|
-
}
|
|
210
|
-
export function loadState(path) {
|
|
211
|
-
try {
|
|
212
|
-
const v = parseJson(readFileSync(path, "utf8"));
|
|
213
|
-
if (v && v.v === 1 && v.files && v.ids)
|
|
214
|
-
return v;
|
|
215
|
-
}
|
|
216
|
-
catch {
|
|
217
|
-
/* none yet */
|
|
218
|
-
}
|
|
219
|
-
return freshState();
|
|
220
|
-
}
|
|
221
|
-
export function loadMeasuredState(path) {
|
|
222
|
-
try {
|
|
223
|
-
const v = parseJson(readFileSync(path, "utf8"));
|
|
224
|
-
if (v && v.v === 1 && v.files && v.ids && typeof v.seen === "boolean") {
|
|
225
|
-
return v;
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
catch {
|
|
229
|
-
/* none yet */
|
|
230
|
-
}
|
|
231
|
-
return freshMeasuredState();
|
|
232
|
-
}
|
|
233
|
-
export function saveState(path, st) {
|
|
234
|
-
mkdirSync(join(path, ".."), { recursive: true, mode: 0o700 });
|
|
235
|
-
const tmp = `${path}.${process.pid}.tmp`;
|
|
236
|
-
writeFileSync(tmp, JSON.stringify(st), { mode: 0o600 });
|
|
237
|
-
renameSync(tmp, path);
|
|
77
|
+
/** The segment for these totals: "mnemom · $0.42 · 1.70 μ". */
|
|
78
|
+
export function fedSegment(t) {
|
|
79
|
+
return `${SEGMENT} · ${formatCost(t.model_cost_mmu)} · ${formatMu(t.mu_charged_mmu)}`;
|
|
238
80
|
}
|
|
239
81
|
/** Run the user's own statusLine command with the same stdin; its first line, or "". */
|
|
240
82
|
export function runInner(command, input) {
|
|
@@ -251,43 +93,12 @@ export function runInner(command, input) {
|
|
|
251
93
|
return "";
|
|
252
94
|
}
|
|
253
95
|
}
|
|
254
|
-
/** Is the counter in measured mode? The launch's MEASURED_ENV, exactly "1". */
|
|
255
|
-
export function measuredMode(env) {
|
|
256
|
-
return env[MEASURED_ENV] === "1";
|
|
257
|
-
}
|
|
258
|
-
function saveQuietly(path, st) {
|
|
259
|
-
try {
|
|
260
|
-
saveState(path, st);
|
|
261
|
-
}
|
|
262
|
-
catch {
|
|
263
|
-
/* an unwritable state dir only means the next tick re-reads */
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
96
|
/** Claude Code's status JSON → the one status line. Never throws. */
|
|
267
|
-
export function render(stdin, deps) {
|
|
97
|
+
export function render(stdin, deps = {}) {
|
|
268
98
|
const innerOut = deps.inner ? (deps.runInner ?? runInner)(deps.inner, stdin) : "";
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
const tp = typeof d?.transcript_path === "string" ? d.transcript_path : null;
|
|
273
|
-
const sid = typeof d?.session_id === "string" && d.session_id ? d.session_id : null;
|
|
274
|
-
if (tp && sid && deps.measured) {
|
|
275
|
-
const path = measuredStatePath(deps.stateDir, sid);
|
|
276
|
-
const st = updateMeasured(loadMeasuredState(path), sessionFiles(tp));
|
|
277
|
-
seg = measuredSegment(st);
|
|
278
|
-
saveQuietly(path, st);
|
|
279
|
-
}
|
|
280
|
-
else if (tp && sid) {
|
|
281
|
-
const path = statePath(deps.stateDir, sid);
|
|
282
|
-
const st = update(loadState(path), sessionFiles(tp));
|
|
283
|
-
seg = segment(st.saved);
|
|
284
|
-
saveQuietly(path, st);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
catch {
|
|
288
|
-
/* the counter never breaks the status bar */
|
|
289
|
-
}
|
|
290
|
-
return innerOut ? `${innerOut} · ${seg}` : seg;
|
|
99
|
+
const totals = deps.feed ? readFeed(deps.feed) : null;
|
|
100
|
+
const segment = totals ? fedSegment(totals) : SEGMENT;
|
|
101
|
+
return innerOut ? `${innerOut} · ${segment}` : segment;
|
|
291
102
|
}
|
|
292
103
|
/**
|
|
293
104
|
* The statusLine Claude Code would use without us: the first settings file (highest
|
|
@@ -319,9 +130,8 @@ export function userStatusLine(cwd, configDir) {
|
|
|
319
130
|
}
|
|
320
131
|
const sq = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
321
132
|
/**
|
|
322
|
-
* Launch args + env that add the
|
|
133
|
+
* Launch args + env that add the segment, or null when it should stay off: the opt-out
|
|
323
134
|
* env, or the user passing their own --settings (two would fight over precedence).
|
|
324
|
-
* `measured` puts the counter in measured mode (MEASURED_ENV).
|
|
325
135
|
*/
|
|
326
136
|
export function statuslineLaunch(o) {
|
|
327
137
|
if (o.env[NO_STATUSLINE_ENV] === "1")
|
|
@@ -337,8 +147,6 @@ export function statuslineLaunch(o) {
|
|
|
337
147
|
if (user?.padding !== undefined)
|
|
338
148
|
statusLine.padding = user.padding;
|
|
339
149
|
const env = user ? { [INNER_ENV]: user.command } : {};
|
|
340
|
-
if (o.measured)
|
|
341
|
-
env[MEASURED_ENV] = "1";
|
|
342
150
|
// `settings` is the same layer as `args`, for a caller that merges it with its own (Claude Code
|
|
343
151
|
// keeps only the last --settings it is given).
|
|
344
152
|
return { args: ["--settings", JSON.stringify({ statusLine })], env, settings: { statusLine } };
|
|
@@ -15,8 +15,13 @@
|
|
|
15
15
|
* for entitled callers (internal cohort, staff, `cli_next` invite —
|
|
16
16
|
* GET /v1/cli/channel-entitlement); otherwise it behaves as latest.
|
|
17
17
|
*
|
|
18
|
-
* Never downgrades
|
|
19
|
-
* keeps the current build until latest
|
|
18
|
+
* Never downgrades on its own, with one exception: a moved tag. Switching
|
|
19
|
+
* next → latest while ahead of latest keeps the current build until latest
|
|
20
|
+
* overtakes it (`mnemom update --force`). But when the channel pointed at the
|
|
21
|
+
* running build on the last check and now points lower, the tag was moved back
|
|
22
|
+
* (a bad release rolled back by hand: OIDC publishing can't move a tag, a
|
|
23
|
+
* person can), and the next check follows it down. A build the channel never
|
|
24
|
+
* pointed at (a dev build, a newer local install) is never downgraded.
|
|
20
25
|
*/
|
|
21
26
|
export type UpdateChannel = "latest" | "next";
|
|
22
27
|
export type UpdateMode = "auto" | "notify" | "off";
|
|
@@ -64,6 +69,13 @@ export interface UpdateState {
|
|
|
64
69
|
method?: InstallMethod;
|
|
65
70
|
/** Channel the last successful update installed from; feeds defaultChannel. */
|
|
66
71
|
followChannel?: UpdateChannel;
|
|
72
|
+
/** What the channel pointed at on the last check; detects a tag moved back. */
|
|
73
|
+
channelTarget?: {
|
|
74
|
+
channel: UpdateChannel;
|
|
75
|
+
version: string;
|
|
76
|
+
};
|
|
77
|
+
/** `available` is the channel's tag moved back below the running build. */
|
|
78
|
+
movedBack?: boolean;
|
|
67
79
|
/** The background worker startup last spawned, while it runs (cleared when it finishes). */
|
|
68
80
|
workerPid?: number;
|
|
69
81
|
workerSince?: number;
|
|
@@ -124,6 +136,11 @@ export declare function entitlementFresh(cache: EntitlementCache | undefined, no
|
|
|
124
136
|
* registry response).
|
|
125
137
|
*/
|
|
126
138
|
export declare function pickTarget(tags: DistTags, channel: UpdateChannel): string | undefined;
|
|
139
|
+
/**
|
|
140
|
+
* The channel's tag was moved back off the running build: on the last check it
|
|
141
|
+
* pointed at `current`, and now it points at an older version.
|
|
142
|
+
*/
|
|
143
|
+
export declare function tagMovedBack(previous: UpdateState["channelTarget"], channel: UpdateChannel, current: string, target: string | undefined): boolean;
|
|
127
144
|
/**
|
|
128
145
|
* Longest a worker lives: it waits for its parent `mnemom` (a whole agent session)
|
|
129
146
|
* up to this long. A recorded worker older than this is treated as gone, so a
|
|
@@ -155,7 +172,8 @@ export type UpdateAction = {
|
|
|
155
172
|
/**
|
|
156
173
|
* The single decision: given where the registry is and how we're installed,
|
|
157
174
|
* install / notify / do nothing. `force` (explicit `mnemom update --force`)
|
|
158
|
-
* permits a downgrade to the channel's target
|
|
175
|
+
* permits a downgrade to the channel's target; `followMovedTag` (see
|
|
176
|
+
* tagMovedBack) permits one to the version the channel's tag was moved back to.
|
|
159
177
|
*/
|
|
160
178
|
export declare function decide(input: {
|
|
161
179
|
current: string;
|
|
@@ -164,6 +182,7 @@ export declare function decide(input: {
|
|
|
164
182
|
method: InstallMethod;
|
|
165
183
|
installBlocked?: boolean;
|
|
166
184
|
force?: boolean;
|
|
185
|
+
followMovedTag?: boolean;
|
|
167
186
|
}): UpdateAction;
|
|
168
187
|
/** The command a human should run to upgrade, for the given install method. */
|
|
169
188
|
export declare function manualUpgradeCommand(method: InstallMethod, version: string): string;
|
|
@@ -15,8 +15,13 @@
|
|
|
15
15
|
* for entitled callers (internal cohort, staff, `cli_next` invite —
|
|
16
16
|
* GET /v1/cli/channel-entitlement); otherwise it behaves as latest.
|
|
17
17
|
*
|
|
18
|
-
* Never downgrades
|
|
19
|
-
* keeps the current build until latest
|
|
18
|
+
* Never downgrades on its own, with one exception: a moved tag. Switching
|
|
19
|
+
* next → latest while ahead of latest keeps the current build until latest
|
|
20
|
+
* overtakes it (`mnemom update --force`). But when the channel pointed at the
|
|
21
|
+
* running build on the last check and now points lower, the tag was moved back
|
|
22
|
+
* (a bad release rolled back by hand: OIDC publishing can't move a tag, a
|
|
23
|
+
* person can), and the next check follows it down. A build the channel never
|
|
24
|
+
* pointed at (a dev build, a newer local install) is never downgraded.
|
|
20
25
|
*/
|
|
21
26
|
import { compareVersions, isPrerelease, isValidVersion } from "./semver.js";
|
|
22
27
|
export const UPDATE_CHANNELS = ["latest", "next"];
|
|
@@ -115,6 +120,24 @@ export function pickTarget(tags, channel) {
|
|
|
115
120
|
return next;
|
|
116
121
|
return compareVersions(next, stable) >= 0 ? next : stable;
|
|
117
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* The channel's tag was moved back off the running build: on the last check it
|
|
125
|
+
* pointed at `current`, and now it points at an older version.
|
|
126
|
+
*/
|
|
127
|
+
export function tagMovedBack(previous, channel, current, target) {
|
|
128
|
+
return (previous !== undefined &&
|
|
129
|
+
previous.channel === channel &&
|
|
130
|
+
previous.version === current &&
|
|
131
|
+
target !== undefined &&
|
|
132
|
+
compareVersions(target, current) < 0);
|
|
133
|
+
}
|
|
134
|
+
/** `state.available` is something to act on: newer, or the tag moved back to it. */
|
|
135
|
+
function actionable(state, current) {
|
|
136
|
+
if (!state.available || !isValidVersion(state.available))
|
|
137
|
+
return false;
|
|
138
|
+
const cmp = compareVersions(state.available, current);
|
|
139
|
+
return cmp > 0 || (state.movedBack === true && cmp < 0);
|
|
140
|
+
}
|
|
118
141
|
/**
|
|
119
142
|
* Longest a worker lives: it waits for its parent `mnemom` (a whole agent session)
|
|
120
143
|
* up to this long. A recorded worker older than this is treated as gone, so a
|
|
@@ -128,9 +151,7 @@ export const WORKER_MAX_AGE_MS = 12 * HOUR;
|
|
|
128
151
|
* scheduled check.
|
|
129
152
|
*/
|
|
130
153
|
export function shouldResume(state, current, now, alive) {
|
|
131
|
-
if (!state
|
|
132
|
-
return false;
|
|
133
|
-
if (compareVersions(state.available, current) <= 0)
|
|
154
|
+
if (!actionable(state, current))
|
|
134
155
|
return false;
|
|
135
156
|
if (state.installBlocked || !canSelfUpdate(state.method ?? "unknown"))
|
|
136
157
|
return false;
|
|
@@ -160,10 +181,11 @@ export function canSelfUpdate(method) {
|
|
|
160
181
|
/**
|
|
161
182
|
* The single decision: given where the registry is and how we're installed,
|
|
162
183
|
* install / notify / do nothing. `force` (explicit `mnemom update --force`)
|
|
163
|
-
* permits a downgrade to the channel's target
|
|
184
|
+
* permits a downgrade to the channel's target; `followMovedTag` (see
|
|
185
|
+
* tagMovedBack) permits one to the version the channel's tag was moved back to.
|
|
164
186
|
*/
|
|
165
187
|
export function decide(input) {
|
|
166
|
-
const { current, target, mode, method, installBlocked, force } = input;
|
|
188
|
+
const { current, target, mode, method, installBlocked, force, followMovedTag } = input;
|
|
167
189
|
if (mode === "off")
|
|
168
190
|
return { kind: "noop", reason: "updates are off" };
|
|
169
191
|
if (!target)
|
|
@@ -171,7 +193,7 @@ export function decide(input) {
|
|
|
171
193
|
const cmp = compareVersions(target, current);
|
|
172
194
|
if (cmp === 0)
|
|
173
195
|
return { kind: "noop", reason: "already on the newest version" };
|
|
174
|
-
if (cmp < 0 && !force)
|
|
196
|
+
if (cmp < 0 && !force && !followMovedTag)
|
|
175
197
|
return { kind: "noop", reason: "current build is newer than the channel" };
|
|
176
198
|
if (!canSelfUpdate(method)) {
|
|
177
199
|
return { kind: "notify", version: target, reason: `installed via ${method}` };
|
|
@@ -215,9 +237,7 @@ export function startupNotice(input) {
|
|
|
215
237
|
patch: { pending: undefined },
|
|
216
238
|
};
|
|
217
239
|
}
|
|
218
|
-
if (mode === "off" || !state
|
|
219
|
-
return null;
|
|
220
|
-
if (compareVersions(state.available, current) <= 0)
|
|
240
|
+
if (mode === "off" || !actionable(state, current))
|
|
221
241
|
return null;
|
|
222
242
|
// In auto mode with a working self-update, the worker installs it — stay quiet.
|
|
223
243
|
if (mode === "auto" && canSelfUpdate(method) && !state.installBlocked)
|
|
@@ -229,8 +249,11 @@ export function startupNotice(input) {
|
|
|
229
249
|
: state.installBlocked
|
|
230
250
|
? ` — the global npm prefix isn't writable; run: ${manualUpgradeCommand(method, state.available)} (see https://docs.npmjs.com/resolving-eacces-permissions-errors-when-installing-packages-globally)`
|
|
231
251
|
: " — run: mnemom update";
|
|
252
|
+
const what = state.movedBack
|
|
253
|
+
? `mnemom ${current} was withdrawn; the channel is back on ${state.available}`
|
|
254
|
+
: `mnemom ${state.available} is available (you have ${current})`;
|
|
232
255
|
return {
|
|
233
|
-
message:
|
|
256
|
+
message: `${what}${cmd}`,
|
|
234
257
|
patch: { lastNotified: now },
|
|
235
258
|
};
|
|
236
259
|
}
|
|
@@ -46,7 +46,11 @@ export function updateDoctorRow(input) {
|
|
|
46
46
|
hint: "it retries automatically with backoff; `mnemom update` retries now",
|
|
47
47
|
};
|
|
48
48
|
}
|
|
49
|
-
const pending = state.available
|
|
49
|
+
const pending = !state.available
|
|
50
|
+
? ""
|
|
51
|
+
: state.movedBack
|
|
52
|
+
? `, withdrawn: the channel is back on ${state.available}`
|
|
53
|
+
: `, ${state.available} available`;
|
|
50
54
|
return {
|
|
51
55
|
mark: "ok",
|
|
52
56
|
text: `auto-update: ${settings.mode} on ${settings.channel} (v${current}${pending})`,
|
package/dist/lib/update/run.d.ts
CHANGED
|
@@ -42,6 +42,8 @@ export interface RunResult {
|
|
|
42
42
|
action?: UpdateAction;
|
|
43
43
|
installed?: string;
|
|
44
44
|
error?: string;
|
|
45
|
+
/** The channel's tag moved back below the running build (a withdrawn release). */
|
|
46
|
+
movedBack?: boolean;
|
|
45
47
|
}
|
|
46
48
|
/**
|
|
47
49
|
* Resolve the `next` entitlement: a fresh cache wins; otherwise ask the API.
|
package/dist/lib/update/run.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* (maybe) install → persist state. Never throws; failures become state
|
|
6
6
|
* (backoff) and a result the caller may print.
|
|
7
7
|
*/
|
|
8
|
-
import { backoffDelay, decide, effectiveChannel, entitlementFresh, pickTarget, } from "./decide.js";
|
|
8
|
+
import { backoffDelay, decide, effectiveChannel, entitlementFresh, pickTarget, tagMovedBack, } from "./decide.js";
|
|
9
9
|
import { compareVersions } from "./semver.js";
|
|
10
10
|
/**
|
|
11
11
|
* Resolve the `next` entitlement: a fresh cache wins; otherwise ask the API.
|
|
@@ -55,6 +55,7 @@ export async function runUpdate(deps, settings, opts = {}) {
|
|
|
55
55
|
const channelDowngraded = channel !== settings.channel;
|
|
56
56
|
const target = pickTarget(tags, channel);
|
|
57
57
|
const method = await deps.installMethod();
|
|
58
|
+
const movedBack = tagMovedBack(state.channelTarget, channel, deps.current, target);
|
|
58
59
|
const action = decide({
|
|
59
60
|
current: deps.current,
|
|
60
61
|
target,
|
|
@@ -63,17 +64,30 @@ export async function runUpdate(deps, settings, opts = {}) {
|
|
|
63
64
|
// An explicit `mnemom update` retries even after an EACCES (the user may have fixed it).
|
|
64
65
|
installBlocked: opts.foreground ? false : state.installBlocked,
|
|
65
66
|
force: opts.force,
|
|
67
|
+
followMovedTag: movedBack,
|
|
66
68
|
});
|
|
67
69
|
const newer = target !== undefined && compareVersions(target, deps.current) > 0;
|
|
68
70
|
const base = {
|
|
69
71
|
lastCheck: now,
|
|
70
72
|
method,
|
|
71
|
-
available: newer ? target : undefined,
|
|
73
|
+
available: newer || movedBack ? target : undefined,
|
|
74
|
+
movedBack: movedBack || undefined,
|
|
75
|
+
// A moved-back tag keeps the old record until it is followed, so a deferred
|
|
76
|
+
// or notify-only pass doesn't forget the move on the next check; an empty
|
|
77
|
+
// answer from the registry keeps it too.
|
|
78
|
+
...(movedBack || target === undefined ? {} : { channelTarget: { channel, version: target } }),
|
|
72
79
|
failures: undefined,
|
|
73
80
|
backoffUntil: undefined,
|
|
74
81
|
lastError: undefined,
|
|
75
82
|
};
|
|
76
|
-
const result = {
|
|
83
|
+
const result = {
|
|
84
|
+
channel,
|
|
85
|
+
channelDowngraded,
|
|
86
|
+
method,
|
|
87
|
+
target,
|
|
88
|
+
action,
|
|
89
|
+
...(movedBack ? { movedBack: true } : {}),
|
|
90
|
+
};
|
|
77
91
|
if (action.kind !== "install") {
|
|
78
92
|
deps.saveState({ ...base, ...done });
|
|
79
93
|
return result;
|
|
@@ -94,9 +108,11 @@ export async function runUpdate(deps, settings, opts = {}) {
|
|
|
94
108
|
deps.saveState({
|
|
95
109
|
...base,
|
|
96
110
|
available: undefined,
|
|
111
|
+
movedBack: undefined,
|
|
97
112
|
installBlocked: undefined,
|
|
98
113
|
pending: { from: deps.current, to: action.version, channel },
|
|
99
114
|
followChannel: channel,
|
|
115
|
+
channelTarget: { channel, version: action.version },
|
|
100
116
|
...done,
|
|
101
117
|
});
|
|
102
118
|
return { ...result, installed: action.version };
|
|
@@ -106,11 +122,9 @@ export async function runUpdate(deps, settings, opts = {}) {
|
|
|
106
122
|
deps.saveState({ ...base, installBlocked: true, lastError: outcome.message, ...done });
|
|
107
123
|
}
|
|
108
124
|
else {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
...done,
|
|
113
|
-
});
|
|
125
|
+
// Keep the moved-tag record: startup reads `movedBack` to notify and resume,
|
|
126
|
+
// and a moved-back tag must stay remembered until it is followed.
|
|
127
|
+
deps.saveState({ ...base, ...failurePatch(state, now, outcome.message), ...done });
|
|
114
128
|
}
|
|
115
129
|
return { ...result, error: outcome.message };
|
|
116
130
|
}
|