@mnemom/mnemom 0.17.15 → 0.17.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,6 +13,15 @@
13
13
  * keeps each file's byte offset and each message id's contribution, and a tick reads at
14
14
  * most MAX_BYTES_PER_TICK of new bytes (a big resumed session catches up over ticks).
15
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.
24
+ *
16
25
  * A user's own statusLine is never replaced: the launch passes it in
17
26
  * MNEMOM_STATUSLINE_INNER, it runs with the same stdin, and our segment is appended.
18
27
  * Read-only on every transcript; no network. It lives outside lib/first-run because it runs
@@ -30,10 +39,15 @@ export const MAX_BYTES_PER_TICK = 8 * 1024 * 1024;
30
39
  export const INNER_ENV = "MNEMOM_STATUSLINE_INNER";
31
40
  /** Set to 1 to launch without the counter. */
32
41
  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";
33
44
  export const INNER_TIMEOUT_MS = 2000;
34
45
  export function freshState() {
35
46
  return { v: 1, files: {}, ids: {}, actual: 0, saved: 0 };
36
47
  }
48
+ export function freshMeasuredState() {
49
+ return { v: 1, files: {}, ids: {}, saved: 0, seen: false };
50
+ }
37
51
  /** The main transcript, then its subagent transcripts (sorted). */
38
52
  export function sessionFiles(transcriptPath) {
39
53
  const dir = join(transcriptPath.replace(/\.jsonl$/, ""), "subagents");
@@ -87,8 +101,12 @@ export function readNewLines(path, offset, maxBytes) {
87
101
  closeSync(fd);
88
102
  }
89
103
  }
90
- /** Read what is new in the session's files into `st`. A file that shrank resets the count. */
91
- export function update(st, files, maxBytes = MAX_BYTES_PER_TICK) {
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) {
92
110
  let budget = maxBytes;
93
111
  for (const f of files) {
94
112
  if (budget <= 0)
@@ -97,38 +115,97 @@ export function update(st, files, maxBytes = MAX_BYTES_PER_TICK) {
97
115
  const r = readNewLines(f, from, budget);
98
116
  if (r === null) {
99
117
  if (from > 0)
100
- return update(freshState(), files, maxBytes);
118
+ return readSession(fresh(), files, maxBytes, fresh, add);
101
119
  continue;
102
120
  }
103
121
  budget -= r.next - from;
104
122
  const subagent = f.includes("/subagents/");
105
- r.lines.forEach((line, i) => {
106
- const p = parseUsageLine(line, subagent);
107
- if (!p)
108
- return;
109
- const key = p.id ?? `anon-${f}-${from}-${i}`;
110
- const cost = rowCost(p.row);
111
- const saved = ruleRate(origContext(p.row)).screen * cost;
112
- const prev = st.ids[key];
113
- if (prev) {
114
- st.actual -= prev[0];
115
- st.saved -= prev[1];
116
- }
117
- st.ids[key] = [cost, saved];
118
- st.actual += cost;
119
- st.saved += saved;
120
- });
123
+ r.lines.forEach((line, i) => add(st, line, subagent, `anon-${f}-${from}-${i}`));
121
124
  st.files[f] = r.next;
122
125
  }
123
126
  return st;
124
127
  }
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
+ });
146
+ }
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 };
175
+ }
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
+ });
187
+ }
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
+ }
125
192
  /** The segment, rounded DOWN to the cent and never below $0.00. */
126
193
  export function segment(saved) {
127
- const cents = Math.max(Math.floor(saved * 100 + 1e-9), 0);
128
- return `mnemom saved ~$${(cents / 100).toFixed(2)} this session`;
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, "_");
129
202
  }
130
203
  function statePath(stateDir, sessionId) {
131
- return join(stateDir, `${sessionId.replace(/[^A-Za-z0-9_-]/g, "_")}.json`);
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`);
132
209
  }
133
210
  export function loadState(path) {
134
211
  try {
@@ -141,6 +218,18 @@ export function loadState(path) {
141
218
  }
142
219
  return freshState();
143
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
+ }
144
233
  export function saveState(path, st) {
145
234
  mkdirSync(join(path, ".."), { recursive: true, mode: 0o700 });
146
235
  const tmp = `${path}.${process.pid}.tmp`;
@@ -162,6 +251,18 @@ export function runInner(command, input) {
162
251
  return "";
163
252
  }
164
253
  }
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
+ }
165
266
  /** Claude Code's status JSON → the one status line. Never throws. */
166
267
  export function render(stdin, deps) {
167
268
  const innerOut = deps.inner ? (deps.runInner ?? runInner)(deps.inner, stdin) : "";
@@ -170,16 +271,17 @@ export function render(stdin, deps) {
170
271
  const d = parseJson(stdin);
171
272
  const tp = typeof d?.transcript_path === "string" ? d.transcript_path : null;
172
273
  const sid = typeof d?.session_id === "string" && d.session_id ? d.session_id : null;
173
- if (tp && sid) {
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) {
174
281
  const path = statePath(deps.stateDir, sid);
175
282
  const st = update(loadState(path), sessionFiles(tp));
176
283
  seg = segment(st.saved);
177
- try {
178
- saveState(path, st);
179
- }
180
- catch {
181
- /* an unwritable state dir only means the next tick re-reads */
182
- }
284
+ saveQuietly(path, st);
183
285
  }
184
286
  }
185
287
  catch {
@@ -219,6 +321,7 @@ const sq = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
219
321
  /**
220
322
  * Launch args + env that add the counter, or null when it should stay off: the opt-out
221
323
  * env, or the user passing their own --settings (two would fight over precedence).
324
+ * `measured` puts the counter in measured mode (MEASURED_ENV).
222
325
  */
223
326
  export function statuslineLaunch(o) {
224
327
  if (o.env[NO_STATUSLINE_ENV] === "1")
@@ -233,8 +336,8 @@ export function statuslineLaunch(o) {
233
336
  };
234
337
  if (user?.padding !== undefined)
235
338
  statusLine.padding = user.padding;
236
- return {
237
- args: ["--settings", JSON.stringify({ statusLine })],
238
- env: user ? { [INNER_ENV]: user.command } : {},
239
- };
339
+ const env = user ? { [INNER_ENV]: user.command } : {};
340
+ if (o.measured)
341
+ env[MEASURED_ENV] = "1";
342
+ return { args: ["--settings", JSON.stringify({ statusLine })], env };
240
343
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemom/mnemom",
3
- "version": "0.17.15",
3
+ "version": "0.17.16",
4
4
  "description": "Transparent AI agent tracing",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,7 +21,7 @@
21
21
  "chalk": "^5.3.0",
22
22
  "commander": "^12.0.0",
23
23
  "js-yaml": "^4.3.2",
24
- "smol-toml": "^1.8.0"
24
+ "smol-toml": "^1.9.0"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/js-yaml": "^4.0.9",