@mnemom/mnemom 0.17.31 → 0.17.32-next.1

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.
Files changed (46) hide show
  1. package/dist/base-url-proxy.mjs +30 -0
  2. package/dist/commands/agent-fuel.d.ts +75 -0
  3. package/dist/commands/agent-fuel.js +298 -0
  4. package/dist/commands/agent-implicit-notice.d.ts +31 -0
  5. package/dist/commands/agent-implicit-notice.js +73 -0
  6. package/dist/commands/agent-session.d.ts +2 -1
  7. package/dist/commands/agent-session.js +2 -1
  8. package/dist/commands/agent-share-fixture.d.ts +20 -0
  9. package/dist/commands/agent-share-fixture.js +134 -0
  10. package/dist/commands/agent-share.d.ts +87 -0
  11. package/dist/commands/agent-share.js +251 -0
  12. package/dist/commands/agent.d.ts +75 -2
  13. package/dist/commands/agent.js +165 -28
  14. package/dist/commands/status.js +1 -1
  15. package/dist/commands/update.js +3 -1
  16. package/dist/commands/validate.d.ts +1 -1
  17. package/dist/commands/validate.js +1 -1
  18. package/dist/index.js +68 -12
  19. package/dist/lib/agent.d.ts +34 -19
  20. package/dist/lib/agent.js +41 -32
  21. package/dist/lib/api.d.ts +66 -0
  22. package/dist/lib/api.js +50 -10
  23. package/dist/lib/canonical-json.d.ts +12 -0
  24. package/dist/lib/canonical-json.js +22 -0
  25. package/dist/lib/commit-report.d.ts +109 -0
  26. package/dist/lib/commit-report.js +205 -0
  27. package/dist/lib/conduct-receipt.d.ts +81 -0
  28. package/dist/lib/conduct-receipt.js +177 -0
  29. package/dist/lib/contract-hash.d.ts +61 -0
  30. package/dist/lib/contract-hash.js +126 -0
  31. package/dist/lib/experimental.d.ts +10 -1
  32. package/dist/lib/experimental.js +35 -1
  33. package/dist/lib/oauth.d.ts +2 -1
  34. package/dist/lib/oauth.js +2 -1
  35. package/dist/lib/statusline-bin.d.ts +2 -2
  36. package/dist/lib/statusline-bin.js +4 -7
  37. package/dist/lib/statusline-feed.d.ts +33 -0
  38. package/dist/lib/statusline-feed.js +68 -0
  39. package/dist/lib/statusline.d.ts +39 -88
  40. package/dist/lib/statusline.js +58 -250
  41. package/dist/lib/update/decide.d.ts +22 -3
  42. package/dist/lib/update/decide.js +35 -12
  43. package/dist/lib/update/doctor.js +5 -1
  44. package/dist/lib/update/run.d.ts +2 -0
  45. package/dist/lib/update/run.js +22 -8
  46. package/package.json +1 -1
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The launcher side of the fed status line (W1.4, decision 14), prerelease only.
3
+ *
4
+ * The local base-URL proxy records the gateway's `x-mnemom-lineage-key` for this session in
5
+ * its record-state file (the one the conduct receipt reads). While Claude Code runs, the launcher reads Record's accumulator totals for that
6
+ * lineage every few seconds and writes them to the feed file the status-line bin reads
7
+ * (lib/statusline.ts). The bin never touches the network.
8
+ *
9
+ * Fail open everywhere: no lineage yet, a dark route (404), a non-billing role (403) or any
10
+ * error leaves the feed as it was, so the status line stays "mnemom" until fed.
11
+ */
12
+ import { renameSync, writeFileSync } from "node:fs";
13
+ import { getRecordTotals } from "./api.js";
14
+ import { readRecordState } from "./conduct-receipt.js";
15
+ import { LINEAGE_KEY_RE, MMU_RE } from "./statusline.js";
16
+ /** How often the launcher re-reads the totals. */
17
+ export const FEED_INTERVAL_MS = 10_000;
18
+ /**
19
+ * The lineage key the proxy recorded, or null when there is none (yet) or it is not the
20
+ * gateway's `cid:`/`cr:` shape (the proxy's own check is looser; this read is exact).
21
+ */
22
+ export function readLineage(path) {
23
+ const key = readRecordState(path).lineageKey;
24
+ return key && LINEAGE_KEY_RE.test(key) ? key : null;
25
+ }
26
+ /** One poll: read the lineage, fetch its totals, write the feed. Never throws. */
27
+ export async function feedOnce(o) {
28
+ const key = readLineage(o.recordStateFile);
29
+ if (!key)
30
+ return false;
31
+ try {
32
+ const totals = await (o.fetchTotals ?? getRecordTotals)(o.orgId, key);
33
+ if (!totals || totals.lineage_key !== key)
34
+ return false;
35
+ // Persist only amounts the bin would render: a malformed reply never replaces a good feed.
36
+ const mu = totals.mu_charged_mmu;
37
+ if (typeof totals.model_cost_mmu !== "string" || !MMU_RE.test(totals.model_cost_mmu)) {
38
+ return false;
39
+ }
40
+ if (mu !== null && (typeof mu !== "string" || !MMU_RE.test(mu)))
41
+ return false;
42
+ const tmp = `${o.feedFile}.tmp`;
43
+ writeFileSync(tmp, JSON.stringify({
44
+ model_cost_mmu: totals.model_cost_mmu,
45
+ mu_charged_mmu: totals.mu_charged_mmu,
46
+ }));
47
+ renameSync(tmp, o.feedFile);
48
+ return true;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ /** Poll until stopped. The timer is unref'd, so it never holds the launcher open. */
55
+ export function startFeed(o) {
56
+ let running = false;
57
+ const tick = () => {
58
+ if (running)
59
+ return;
60
+ running = true;
61
+ void feedOnce(o).finally(() => {
62
+ running = false;
63
+ });
64
+ };
65
+ const timer = setInterval(tick, o.intervalMs ?? FEED_INTERVAL_MS);
66
+ timer.unref();
67
+ return () => clearInterval(timer);
68
+ }
@@ -1,108 +1,61 @@
1
1
  /**
2
- * The `mnemom agent` status-bar savings counter: a Claude Code statusLine command that
3
- * prints "mnemom saved ~$X this session".
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 number is the first-run look's estimator (first-run/backtest.ts), applied live to this
6
- * session's own transcript: each request priced at API list prices, times the empirical
7
- * curve's lower-bound saving rate for its original context. So the counter and the
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
- * Claude Code runs the command on every status update with its status JSON on stdin
11
- * (session_id, transcript_path, …). It must be fast, so the transcript is read
12
- * incrementally: per session, a state file (~/.mnemom/statusline/<session>.json, 0600)
13
- * keeps each file's byte offset and each message id's contribution, and a tick reads at
14
- * most MAX_BYTES_PER_TICK of new bytes (a big resumed session catches up over ticks).
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
- * Read-only on every transcript; no network. It lives outside lib/first-run because it runs
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
- export declare const STATE_DIR = "statusline";
31
- export declare const MAX_BYTES_PER_TICK: number;
32
19
  /** The launcher passes the user's own statusLine command here. */
33
20
  export declare const INNER_ENV = "MNEMOM_STATUSLINE_INNER";
34
- /** Set to 1 to launch without the counter. */
21
+ /** Set to 1 to launch without the segment. */
35
22
  export declare const NO_STATUSLINE_ENV = "MNEMOM_AGENT_NO_STATUSLINE";
36
- /** The launcher sets this to 1 when the gateway measures the session's saving. */
37
- export declare const MEASURED_ENV = "MNEMOM_STATUSLINE_MEASURED";
38
23
  export declare const INNER_TIMEOUT_MS = 2000;
39
- export interface SessionState {
40
- v: 1;
41
- /** Byte offset read so far, per transcript file. */
42
- files: Record<string, number>;
43
- /** Per message id: [list-price cost, saving]. A re-written id replaces its old share. */
44
- ids: Record<string, [number, number]>;
45
- actual: number;
46
- saved: number;
47
- }
48
- export declare function freshState(): SessionState;
49
- /** Measured mode's state (<session>.measured.json). */
50
- export interface MeasuredState {
51
- v: 1;
52
- files: Record<string, number>;
53
- /** Per message id: the gateway's measured saving (USD). A re-written id replaces its old share. */
54
- ids: Record<string, number>;
55
- saved: number;
56
- /** Has any record carried a measurement yet? Until then the segment makes no claim. */
57
- seen: boolean;
58
- }
59
- export declare function freshMeasuredState(): MeasuredState;
60
- /** The main transcript, then its subagent transcripts (sorted). */
61
- export declare function sessionFiles(transcriptPath: string): string[];
62
- /**
63
- * The complete lines of `path` from `offset`, at most `maxBytes` of them, and the offset
64
- * after the last complete line. null: the file is unreadable or shorter than `offset`.
65
- */
66
- export declare function readNewLines(path: string, offset: number, maxBytes: number): {
67
- lines: Buffer[];
68
- next: number;
69
- } | null;
70
- /** Read what is new in the session's files into `st`. A file that shrank resets the count. */
71
- export declare function update(st: SessionState, files: string[], maxBytes?: number): SessionState;
72
- /** A `usage.iterations` entry's measured saving (`mnemom_savings` v1), or null. */
73
- export declare function measuredSaving(entry: unknown): number | null;
24
+ /** What the segment says until it is fed. */
25
+ export declare const SEGMENT = "mnemom";
26
+ /** The launcher names the feed file here (prerelease launches only). */
27
+ export declare const FEED_ENV = "MNEMOM_STATUSLINE_FEED";
74
28
  /**
75
- * One transcript line → the gateway's measured saving for its request, or null (not an
76
- * assistant record, or no measurement in its `message.usage.iterations`). `id` is the message
77
- * id (or record uuid); null when the record carries neither. Claude Code writes one record per
78
- * content block, all with the same id and usage, so the id is what dedupes them.
29
+ * The gateway's per-conversation lineage key (deriveLineageKey): `cid:` or `cr:` + 16 hex.
30
+ * base-url-proxy.mjs (a standalone script) keeps its own copy; a test pins the two equal.
79
31
  */
80
- export declare function parseMeasuredLine(line: Buffer): {
81
- id: string | null;
82
- saved: number;
83
- } | null;
84
- /** Measured mode's `update`: sums each request's measured saving, replace-by-id. */
85
- export declare function updateMeasured(st: MeasuredState, files: string[], maxBytes?: number): MeasuredState;
86
- /** The segment, rounded DOWN to the cent and never below $0.00. */
87
- export declare function segment(saved: number): string;
88
- /** Measured mode's segment: no claim until the gateway has measured something; no "~". */
89
- export declare function measuredSegment(st: MeasuredState): string;
90
- export declare function loadState(path: string): SessionState;
91
- export declare function loadMeasuredState(path: string): MeasuredState;
92
- export declare function saveState(path: string, st: SessionState | MeasuredState): void;
32
+ export declare const LINEAGE_KEY_RE: RegExp;
33
+ /** A millimu amount as the record route returns it: a decimal string, no sign, no leading 0. */
34
+ export declare const MMU_RE: RegExp;
35
+ export interface FedTotals {
36
+ /** Integer millimu (1 μ = 1¢ = 1000 mmu), decimal string. */
37
+ model_cost_mmu: string;
38
+ /** null: no billing row yet, shown "μ not charged" (OD-9). */
39
+ mu_charged_mmu: string | null;
40
+ }
41
+ /** The feed file's totals, or null when it is absent or anything in it is off. */
42
+ export declare function readFeed(path: string): FedTotals | null;
43
+ /** Model cost in dollars: 1000 mmu = 1 μ = 1¢. Under half a cent reads "<$0.01", never "$0.00". */
44
+ export declare function formatCost(mmu: string): string;
45
+ /** μ charged, two places; null (no billing row) reads "μ not charged" (OD-9). */
46
+ export declare function formatMu(mmu: string | null): string;
47
+ /** The segment for these totals: "mnemom · $0.42 · 1.70 μ". */
48
+ export declare function fedSegment(t: FedTotals): string;
93
49
  /** Run the user's own statusLine command with the same stdin; its first line, or "". */
94
50
  export declare function runInner(command: string, input: string): string;
95
51
  export interface RenderDeps {
96
- stateDir: string;
97
52
  inner?: string;
98
- /** Measured mode (`measuredMode`): the gateway's measured saving, not the estimate. */
99
- measured?: boolean;
100
53
  runInner?: (command: string, input: string) => string;
54
+ /** The feed file (FEED_ENV); absent on stable, so the segment is only "mnemom". */
55
+ feed?: string;
101
56
  }
102
- /** Is the counter in measured mode? The launch's MEASURED_ENV, exactly "1". */
103
- export declare function measuredMode(env: NodeJS.ProcessEnv): boolean;
104
57
  /** Claude Code's status JSON → the one status line. Never throws. */
105
- export declare function render(stdin: string, deps: RenderDeps): string;
58
+ export declare function render(stdin: string, deps?: RenderDeps): string;
106
59
  export interface UserStatusLine {
107
60
  command: string;
108
61
  padding?: number;
@@ -113,9 +66,8 @@ export interface UserStatusLine {
113
66
  */
114
67
  export declare function userStatusLine(cwd: string, configDir: string): UserStatusLine | null;
115
68
  /**
116
- * Launch args + env that add the counter, or null when it should stay off: the opt-out
69
+ * Launch args + env that add the segment, or null when it should stay off: the opt-out
117
70
  * env, or the user passing their own --settings (two would fight over precedence).
118
- * `measured` puts the counter in measured mode (MEASURED_ENV).
119
71
  */
120
72
  export declare function statuslineLaunch(o: {
121
73
  passthrough: readonly string[];
@@ -124,7 +76,6 @@ export declare function statuslineLaunch(o: {
124
76
  home?: string;
125
77
  node: string;
126
78
  bin: string;
127
- measured?: boolean;
128
79
  }): {
129
80
  args: string[];
130
81
  env: Record<string, string>;
@@ -1,240 +1,82 @@
1
1
  /**
2
- * The `mnemom agent` status-bar savings counter: a Claude Code statusLine command that
3
- * prints "mnemom saved ~$X this session".
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 number is the first-run look's estimator (first-run/backtest.ts), applied live to this
6
- * session's own transcript: each request priced at API list prices, times the empirical
7
- * curve's lower-bound saving rate for its original context. So the counter and the
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
- * Claude Code runs the command on every status update with its status JSON on stdin
11
- * (session_id, transcript_path, …). It must be fast, so the transcript is read
12
- * incrementally: per session, a state file (~/.mnemom/statusline/<session>.json, 0600)
13
- * keeps each file's byte offset and each message id's contribution, and a tick reads at
14
- * most MAX_BYTES_PER_TICK of new bytes (a big resumed session catches up over ticks).
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
- * Read-only on every transcript; no network. It lives outside lib/first-run because it runs
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 { closeSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, writeFileSync, } from "node:fs";
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 counter. */
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
- export function freshState() {
46
- return { v: 1, files: {}, ids: {}, actual: 0, saved: 0 };
47
- }
48
- export function freshMeasuredState() {
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 complete lines of `path` from `offset`, at most `maxBytes` of them, and the offset
68
- * after the last complete line. null: the file is unreadable or shorter than `offset`.
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 function readNewLines(path, offset, maxBytes) {
71
- let fd;
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
- fd = openSync(path, "r");
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
- /** Read what is new in the session's files into `st`. A file that shrank resets the count. */
129
- export function update(st, files, maxBytes = MAX_BYTES_PER_TICK) {
130
- return readSession(st, files, maxBytes, freshState, (s, line, subagent, anonKey) => {
131
- const p = parseUsageLine(line, subagent);
132
- if (!p)
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
- const SAVINGS = Buffer.from('"mnemom_savings"');
148
- const ASSISTANT = Buffer.from('"assistant"');
149
- /** A `usage.iterations` entry's measured saving (`mnemom_savings` v1), or null. */
150
- export function measuredSaving(entry) {
151
- const m = entry?.mnemom_savings;
152
- return m?.v === 1 && Number.isFinite(m.saved_usd) ? m.saved_usd : null;
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
- /** Measured mode's `update`: sums each request's measured saving, replace-by-id. */
177
- export function updateMeasured(st, files, maxBytes = MAX_BYTES_PER_TICK) {
178
- return readSession(st, files, maxBytes, freshMeasuredState, (s, line, _subagent, anonKey) => {
179
- const p = parseMeasuredLine(line);
180
- if (!p)
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
- /** USD rounded DOWN to the cent and never below 0.00. */
189
- function cents(usd) {
190
- return (Math.max(Math.floor(usd * 100 + 1e-9), 0) / 100).toFixed(2);
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
- let seg = "mnemom";
270
- try {
271
- const d = parseJson(stdin);
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 counter, or null when it should stay off: the opt-out
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 } };