@mnemom/mnemom 0.17.15 → 0.17.16-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.
@@ -251,6 +251,8 @@ export interface ResolvedCli {
251
251
  /** The basename, e.g. "claude" — selects the launch adapter. */
252
252
  name: string;
253
253
  }
254
+ /** The CLI asked for — `--cli`, else MNEMOM_AGENT_CLI, else "claude" — before any lookup. */
255
+ export declare function requestedCli(cliOpt: string | undefined, env: NodeJS.ProcessEnv): string;
254
256
  /**
255
257
  * Resolve which coding-agent CLI to launch. `--cli` (or MNEMOM_AGENT_CLI) is a
256
258
  * name or a full path; default "claude". A value with a path separator is used
@@ -31,7 +31,7 @@ import { randomUUID } from "node:crypto";
31
31
  import { tmpdir, homedir, hostname, userInfo } from "node:os";
32
32
  import { basename, join, dirname } from "node:path";
33
33
  import { fileURLToPath } from "node:url";
34
- import { statuslineLaunch } from "../lib/statusline.js";
34
+ import { MEASURED_ENV, statuslineLaunch } from "../lib/statusline.js";
35
35
  import { getApiUrl, getGatewayUrl, MNEMOM_DIR } from "../lib/config.js";
36
36
  import { createApiKey } from "../lib/api.js";
37
37
  import { MNEMOM_KEY_STORE_FIELD, resolveAgentAuth, resolveSubscriptionMnemomKey, subscriptionChildEnv, subscriptionShape, } from "../lib/subscription-login.js";
@@ -46,7 +46,7 @@ import { authConfigured, chooseAuth, detectClaudeLogin, readClaudeLoginState, }
46
46
  import { requireConsoleKey, readConsoleKey } from "../lib/console-key.js";
47
47
  import { goalAlignmentLine, resolveSessionSetup } from "./agent-session.js";
48
48
  import { defaultAgentSlug, ensureClaimedServedAgent, ensureSessionAndBalance, isQuietPreambleExit, resolveDefaultSlug, resolvePreflightedAccount, } from "./agent-setup.js";
49
- import { assembleContract, buildGoalModeHeader, buildCustomHeaders, buildHandoffMcpConfigJson, buildSessionConfig, buildSessionConfigB64, assertCompactAtCompatible, assertOffProfileCompatible, compactAtWindowWarning, describeCompactAt, launchWindow, resolveCompactAt, applyGatewayHintHeaders, resolveCompactionIntercept, resolveContextHandoff, resolveContextProfile, resolveHandoffGatewayHost, HANDOFF_RC_KEY_ENV_NAME, isProdGateway, launchPreferenceExplicit, launchPreflighted, remoteControlAllowed, applyToolSearchDefault, DISABLE_1M_ENV, readCcSettings, resolveWindowModel, resolveAnthropicDoor, resolveLaunchModel, resolveConversationId, resolveGatewayHost, singleCellError, resolveLaunchShape, resolveRemoteControlShape, passthroughName, sanitizeAgentName, resumeCommand, sessionDisplayName, SESSION_NAME_ENV, SESSION_CONFIG_HEADER, } from "../lib/agent.js";
49
+ import { assembleContract, buildGoalModeHeader, buildCustomHeaders, buildHandoffMcpConfigJson, buildSessionConfig, buildSessionConfigB64, assertCompactAtCompatible, assertOffProfileCompatible, compactAtWindowWarning, describeCompactAt, launchWindow, resolveCompactAt, applyGatewayHintHeaders, resolveCompactionIntercept, resolveContextHandoff, resolveContextProfile, resolveHandoffGatewayHost, resolveUsageSavings, HANDOFF_RC_KEY_ENV_NAME, isProdGateway, launchPreferenceExplicit, launchPreflighted, remoteControlAllowed, applyToolSearchDefault, DISABLE_1M_ENV, readCcSettings, resolveWindowModel, resolveAnthropicDoor, resolveLaunchModel, resolveConversationId, resolveGatewayHost, singleCellError, resolveLaunchShape, resolveRemoteControlShape, passthroughName, sanitizeAgentName, resumeCommand, sessionDisplayName, SESSION_NAME_ENV, SESSION_CONFIG_HEADER, } from "../lib/agent.js";
50
50
  import { LAUNCHER_STORE_PATH, mergeStore } from "../lib/launcher-store.js";
51
51
  import { slackChannelArgs } from "../lib/slack-launch.js";
52
52
  /** The CLI's own provider-key store (zero 1Password): ~/.mnemom/launcher.json, 0600. */
@@ -232,17 +232,24 @@ export async function agentCommand(scenarioArg, options = {}) {
232
232
  compactAt: options.compactAt,
233
233
  });
234
234
  const experimental = options.experimental ?? [];
235
+ // Decided from the REQUESTED CLI: the header is built before the executable is resolved.
236
+ const usageSavings = resolveUsageSavings({
237
+ experimental,
238
+ cliName: basename(requestedCli(options.cli, env)),
239
+ });
235
240
  const sessionConfig = buildSessionConfig({
236
241
  profile: contextProfile,
237
242
  contextHandoff,
238
243
  compactionIntercept,
239
244
  compactAt,
245
+ usageSavings,
240
246
  });
241
247
  let sessionConfigB64 = buildSessionConfigB64({
242
248
  profile: contextProfile,
243
249
  contextHandoff,
244
250
  compactionIntercept,
245
251
  compactAt,
252
+ usageSavings,
246
253
  experimental,
247
254
  });
248
255
  const expNotice = experimentalNotice(experimental);
@@ -269,7 +276,7 @@ export async function agentCommand(scenarioArg, options = {}) {
269
276
  if (options.dryRun) {
270
277
  const planCli = cli ?? {
271
278
  bin: "(not found — pass --cli <path> or `mnemom agent config set cli <path>`)",
272
- name: basename((options.cli || env.MNEMOM_AGENT_CLI || "claude").trim()),
279
+ name: basename(requestedCli(options.cli, env)),
273
280
  };
274
281
  // A defaulted slug is `agent-<handle>` from the login (resolved for real after
275
282
  // sign-in, below). Signed in: show that name. Signed out, or no usable handle:
@@ -475,6 +482,7 @@ export async function agentCommand(scenarioArg, options = {}) {
475
482
  contextHandoff,
476
483
  compactionIntercept,
477
484
  compactAt,
485
+ usageSavings,
478
486
  experimental,
479
487
  });
480
488
  }
@@ -571,6 +579,7 @@ export async function agentCommand(scenarioArg, options = {}) {
571
579
  envelope,
572
580
  goalMode,
573
581
  sessionConfigB64,
582
+ usageSavings,
574
583
  options: launchOptions,
575
584
  yolo,
576
585
  contextHint,
@@ -923,6 +932,10 @@ function storeKey(key) {
923
932
  // best effort — the launch proceeds with the in-memory key either way
924
933
  }
925
934
  }
935
+ /** The CLI asked for — `--cli`, else MNEMOM_AGENT_CLI, else "claude" — before any lookup. */
936
+ export function requestedCli(cliOpt, env) {
937
+ return (cliOpt || env.MNEMOM_AGENT_CLI || "claude").trim();
938
+ }
926
939
  /**
927
940
  * Resolve which coding-agent CLI to launch. `--cli` (or MNEMOM_AGENT_CLI) is a
928
941
  * name or a full path; default "claude". A value with a path separator is used
@@ -932,7 +945,7 @@ function storeKey(key) {
932
945
  * a non-claude executable is launched through it with a warning.
933
946
  */
934
947
  export function resolveCliBin(cliOpt, env) {
935
- const requested = (cliOpt || env.MNEMOM_AGENT_CLI || "claude").trim();
948
+ const requested = requestedCli(cliOpt, env);
936
949
  const name = basename(requested);
937
950
  if (requested.includes("/")) {
938
951
  if (existsSync(requested))
@@ -985,7 +998,7 @@ export /**
985
998
  * so they never retype it). A non-interactive session re-throws the clear,
986
999
  * actionable error rather than hanging on a prompt.
987
1000
  */ async function promptForCliPath(cliOpt, env, origErr) {
988
- const requested = (cliOpt || env.MNEMOM_AGENT_CLI || "claude").trim();
1001
+ const requested = requestedCli(cliOpt, env);
989
1002
  if (!isInteractive()) {
990
1003
  throw origErr ?? new Error(`mnemom agent: coding-agent CLI '${requested}' not found.`);
991
1004
  }
@@ -1121,7 +1134,9 @@ function launchTerminal(ctx) {
1121
1134
  if (ctx.options.slack)
1122
1135
  args.push(...slackChannelArgs(sessionName));
1123
1136
  // The status-bar savings counter (lib/statusline.ts). Claude Code only; the
1124
- // user's own statusLine keeps running in front of it.
1137
+ // user's own statusLine keeps running in front of it. Measured mode is this launch's
1138
+ // decision alone: a value inherited from an outer `mnemom agent` session never rides along.
1139
+ Reflect.deleteProperty(childEnv, MEASURED_ENV);
1125
1140
  if (ctx.cli.name === "claude") {
1126
1141
  const sl = statuslineLaunch({
1127
1142
  passthrough: ctx.options.passthrough ?? [],
@@ -1129,6 +1144,7 @@ function launchTerminal(ctx) {
1129
1144
  cwd: process.cwd(),
1130
1145
  node: process.execPath,
1131
1146
  bin: join(dirname(fileURLToPath(import.meta.url)), "..", "lib", "statusline-bin.js"),
1147
+ measured: ctx.usageSavings,
1132
1148
  });
1133
1149
  if (sl) {
1134
1150
  args.push(...sl.args);
@@ -325,6 +325,15 @@ export declare const CONTEXT_SIZE_POLICY_KEY = "CONTEXT_RUNTIME_SIZE_POLICY";
325
325
  */
326
326
  export declare const USAGE_REAL_SIZE_KEY = "GATEWAY_USAGE_REAL_SIZE";
327
327
  export declare const COMPACTION_INTERCEPT_KEY = "GATEWAY_COMPACTION_INTERCEPT";
328
+ /**
329
+ * The gateway env key that has it measure each request's saving for a single session and put
330
+ * it in the client-facing usage (`mnemom_savings` in the read `usage.iterations` entry), where
331
+ * the status bar reads it from Claude Code's transcript. Default OFF on the gateway; sent `"on"`
332
+ * only while the `measured-savings` experimental feature is on and the launched CLI is Claude
333
+ * Code (`resolveUsageSavings`). Independent of folding, so every profile sends it — `off` too,
334
+ * where the measured saving is honestly $0.00.
335
+ */
336
+ export declare const USAGE_SAVINGS_KEY = "GATEWAY_USAGE_SAVINGS";
328
337
  /**
329
338
  * The named presets for how the gateway folds a session's context (`--context-profile`,
330
339
  * launcher.toml `context_profile`, `MNEMOM_AGENT_CONTEXT_PROFILE`). Choosing NONE sends no fold
@@ -607,6 +616,7 @@ export declare function resolveContextProfile(opts: {
607
616
  * (`CONTEXT_RUNTIME_ENABLED: "false"` + real-size and intercept `"off"` for `off`; the three fold-tuning keys + size policy off for
608
617
  * `aggressive`; size policy off for `balanced`; size policy banded for `adaptive`; nothing when
609
618
  * no profile was chosen), then `CONTEXT_HANDOFF_ENABLED: "true"` when context handoff is ON, then
619
+ * `GATEWAY_USAGE_SAVINGS: "on"` when measured savings is ON (under every profile), then
610
620
  * `GATEWAY_USAGE_REAL_SIZE: "on"` + `GATEWAY_COMPACTION_INTERCEPT: "on"` when EITHER the
611
621
  * `compaction_intercept` setting is ON OR `--compact-at` arms a target below 1M (the target
612
622
  * cannot be honored without the intercept), then `GATEWAY_COMPACTION_UPLOAD_TARGET_TOKENS: "<N>"`
@@ -623,6 +633,8 @@ export declare function buildSessionConfig(opts: {
623
633
  compactionIntercept?: boolean;
624
634
  /** `resolveCompactAt` output: `null` (or absent) ⇒ no target, send nothing new. */
625
635
  compactAt?: number | null;
636
+ /** `resolveUsageSavings` output: true ⇒ the gateway measures the session's saving. */
637
+ usageSavings?: boolean;
626
638
  }): Record<string, string>;
627
639
  /**
628
640
  * The `x-mnemom-session-config` payload: `{ v: 1, config, experimental? }`. The active
@@ -634,6 +646,7 @@ export declare function buildSessionConfigPayload(opts: {
634
646
  contextHandoff: boolean;
635
647
  compactionIntercept?: boolean;
636
648
  compactAt?: number | null;
649
+ usageSavings?: boolean;
637
650
  experimental?: readonly string[];
638
651
  }): {
639
652
  v: 1;
@@ -656,7 +669,8 @@ export declare function buildSessionConfigPayload(opts: {
656
669
  * - `contextHandoff: true` → `CONTEXT_HANDOFF_ENABLED: "true"` (the `context_handoff`
657
670
  * setting) — the SAME header the `handoff` MCP tool's own call to
658
671
  * `/mnemom/context/handoff` must also send, since the gateway resolves the flag
659
- * per request (see `commands/agent.ts` handoff MCP wiring).
672
+ * per request (see `commands/agent.ts` handoff MCP wiring);
673
+ * - `usageSavings: true` → `GATEWAY_USAGE_SAVINGS: "on"` (`resolveUsageSavings`).
660
674
  * base64url so it survives the gateway's inbound strip.
661
675
  */
662
676
  export declare function buildSessionConfigB64(opts: {
@@ -664,6 +678,7 @@ export declare function buildSessionConfigB64(opts: {
664
678
  contextHandoff: boolean;
665
679
  compactionIntercept?: boolean;
666
680
  compactAt?: number | null;
681
+ usageSavings?: boolean;
667
682
  experimental?: readonly string[];
668
683
  }): string | undefined;
669
684
  /**
@@ -689,6 +704,16 @@ export declare function resolveCompactionIntercept(opts: {
689
704
  compactionIntercept?: boolean;
690
705
  env?: NodeJS.ProcessEnv;
691
706
  }): boolean;
707
+ /**
708
+ * Does the gateway measure this launch's saving (`GATEWAY_USAGE_SAVINGS` in the header, and the
709
+ * status bar in measured mode)? Only with the `measured-savings` experimental feature on, and
710
+ * only for Claude Code — the one CLI whose status bar reads the measurement. No flag or
711
+ * launcher.toml key: `mnemom experimental enable measured-savings` is the switch.
712
+ */
713
+ export declare function resolveUsageSavings(opts: {
714
+ experimental: readonly string[];
715
+ cliName: string;
716
+ }): boolean;
692
717
  /** The name the `handoff` MCP tool server is registered under in `--mcp-config`. */
693
718
  export declare const HANDOFF_MCP_SERVER_NAME = "mnemom-handoff";
694
719
  /**
package/dist/lib/agent.js CHANGED
@@ -487,6 +487,15 @@ export const CONTEXT_SIZE_POLICY_KEY = "CONTEXT_RUNTIME_SIZE_POLICY";
487
487
  */
488
488
  export const USAGE_REAL_SIZE_KEY = "GATEWAY_USAGE_REAL_SIZE";
489
489
  export const COMPACTION_INTERCEPT_KEY = "GATEWAY_COMPACTION_INTERCEPT";
490
+ /**
491
+ * The gateway env key that has it measure each request's saving for a single session and put
492
+ * it in the client-facing usage (`mnemom_savings` in the read `usage.iterations` entry), where
493
+ * the status bar reads it from Claude Code's transcript. Default OFF on the gateway; sent `"on"`
494
+ * only while the `measured-savings` experimental feature is on and the launched CLI is Claude
495
+ * Code (`resolveUsageSavings`). Independent of folding, so every profile sends it — `off` too,
496
+ * where the measured saving is honestly $0.00.
497
+ */
498
+ export const USAGE_SAVINGS_KEY = "GATEWAY_USAGE_SAVINGS";
490
499
  /**
491
500
  * The named presets for how the gateway folds a session's context (`--context-profile`,
492
501
  * launcher.toml `context_profile`, `MNEMOM_AGENT_CONTEXT_PROFILE`). Choosing NONE sends no fold
@@ -997,6 +1006,7 @@ export function resolveContextProfile(opts) {
997
1006
  * (`CONTEXT_RUNTIME_ENABLED: "false"` + real-size and intercept `"off"` for `off`; the three fold-tuning keys + size policy off for
998
1007
  * `aggressive`; size policy off for `balanced`; size policy banded for `adaptive`; nothing when
999
1008
  * no profile was chosen), then `CONTEXT_HANDOFF_ENABLED: "true"` when context handoff is ON, then
1009
+ * `GATEWAY_USAGE_SAVINGS: "on"` when measured savings is ON (under every profile), then
1000
1010
  * `GATEWAY_USAGE_REAL_SIZE: "on"` + `GATEWAY_COMPACTION_INTERCEPT: "on"` when EITHER the
1001
1011
  * `compaction_intercept` setting is ON OR `--compact-at` arms a target below 1M (the target
1002
1012
  * cannot be honored without the intercept), then `GATEWAY_COMPACTION_UPLOAD_TARGET_TOKENS: "<N>"`
@@ -1014,6 +1024,9 @@ export function buildSessionConfig(opts) {
1014
1024
  }
1015
1025
  if (opts.contextHandoff)
1016
1026
  config[CONTEXT_HANDOFF_KEY] = "true";
1027
+ // Before the `off` return: measuring is not folding, so an off session sends it too.
1028
+ if (opts.usageSavings)
1029
+ config[USAGE_SAVINGS_KEY] = "on";
1017
1030
  const hasCompactionTarget = opts.compactAt !== undefined && opts.compactAt !== null;
1018
1031
  // A target needs the compaction intercept + real-size rewrite to arm (#2216) — without them
1019
1032
  // the upload-target key is silently inert (compaction-align.ts: "armed only inside the
@@ -1059,7 +1072,8 @@ export function buildSessionConfigPayload(opts) {
1059
1072
  * - `contextHandoff: true` → `CONTEXT_HANDOFF_ENABLED: "true"` (the `context_handoff`
1060
1073
  * setting) — the SAME header the `handoff` MCP tool's own call to
1061
1074
  * `/mnemom/context/handoff` must also send, since the gateway resolves the flag
1062
- * per request (see `commands/agent.ts` handoff MCP wiring).
1075
+ * per request (see `commands/agent.ts` handoff MCP wiring);
1076
+ * - `usageSavings: true` → `GATEWAY_USAGE_SAVINGS: "on"` (`resolveUsageSavings`).
1063
1077
  * base64url so it survives the gateway's inbound strip.
1064
1078
  */
1065
1079
  export function buildSessionConfigB64(opts) {
@@ -1099,6 +1113,15 @@ export function resolveCompactionIntercept(opts) {
1099
1113
  return false;
1100
1114
  return raw === "1" || raw === "true" || raw === "on" || raw === "yes";
1101
1115
  }
1116
+ /**
1117
+ * Does the gateway measure this launch's saving (`GATEWAY_USAGE_SAVINGS` in the header, and the
1118
+ * status bar in measured mode)? Only with the `measured-savings` experimental feature on, and
1119
+ * only for Claude Code — the one CLI whose status bar reads the measurement. No flag or
1120
+ * launcher.toml key: `mnemom experimental enable measured-savings` is the switch.
1121
+ */
1122
+ export function resolveUsageSavings(opts) {
1123
+ return opts.experimental.includes("measured-savings") && opts.cliName === "claude";
1124
+ }
1102
1125
  /** The name the `handoff` MCP tool server is registered under in `--mcp-config`. */
1103
1126
  export const HANDOFF_MCP_SERVER_NAME = "mnemom-handoff";
1104
1127
  /**
@@ -12,7 +12,7 @@
12
12
  * Everything here is pure or dependency-injected; index.ts and the command
13
13
  * modules are the thin drivers.
14
14
  */
15
- export type ExperimentalId = "context-handoff" | "slack" | "implicit" | "compaction-intercept";
15
+ export type ExperimentalId = "context-handoff" | "slack" | "implicit" | "compaction-intercept" | "measured-savings";
16
16
  /**
17
17
  * Is a `compact_at` / `MNEMOM_COMPACT_AT` value (trimmed, case-insensitive) an ARMED gateway
18
18
  * target, as opposed to "no target" (`""`, `"1m"`, `"off"`)? Deliberately duplicates
@@ -69,6 +69,18 @@ export const EXPERIMENTAL_FEATURES = [
69
69
  env: ["MNEMOM_AGENT_COMPACTION_INTERCEPT"],
70
70
  },
71
71
  },
72
+ {
73
+ // No flag, launcher.toml key or env of its own: `mnemom experimental enable` is the switch.
74
+ id: "measured-savings",
75
+ summary: "The status bar shows the saving the gateway measured on this session instead of the benchmark estimate",
76
+ since: "0.17.16",
77
+ gates: {
78
+ flags: [],
79
+ configKeys: [],
80
+ commands: [],
81
+ env: [],
82
+ },
83
+ },
72
84
  ];
73
85
  export const EXPERIMENTAL_IDS = EXPERIMENTAL_FEATURES.map((f) => f.id);
74
86
  export const EXPERIMENTAL_ENV = "MNEMOM_EXPERIMENTAL";
@@ -4,13 +4,14 @@
4
4
  */
5
5
  import { homedir } from "node:os";
6
6
  import { join } from "node:path";
7
- import { INNER_ENV, render } from "./statusline.js";
7
+ import { INNER_ENV, measuredMode, render } from "./statusline.js";
8
8
  const chunks = [];
9
9
  process.stdin.on("data", (c) => chunks.push(c));
10
10
  process.stdin.on("end", () => {
11
11
  const line = render(Buffer.concat(chunks).toString("utf8"), {
12
12
  stateDir: join(homedir(), ".mnemom", "statusline"),
13
13
  inner: process.env[INNER_ENV] || undefined,
14
+ measured: measuredMode(process.env),
14
15
  });
15
16
  process.stdout.write(`${line}\n`);
16
17
  });
@@ -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
@@ -24,6 +33,8 @@ export declare const MAX_BYTES_PER_TICK: number;
24
33
  export declare const INNER_ENV = "MNEMOM_STATUSLINE_INNER";
25
34
  /** Set to 1 to launch without the counter. */
26
35
  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";
27
38
  export declare const INNER_TIMEOUT_MS = 2000;
28
39
  export interface SessionState {
29
40
  v: 1;
@@ -35,6 +46,17 @@ export interface SessionState {
35
46
  saved: number;
36
47
  }
37
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;
38
60
  /** The main transcript, then its subagent transcripts (sorted). */
39
61
  export declare function sessionFiles(transcriptPath: string): string[];
40
62
  /**
@@ -47,17 +69,38 @@ export declare function readNewLines(path: string, offset: number, maxBytes: num
47
69
  } | null;
48
70
  /** Read what is new in the session's files into `st`. A file that shrank resets the count. */
49
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;
74
+ /**
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.
79
+ */
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;
50
86
  /** The segment, rounded DOWN to the cent and never below $0.00. */
51
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;
52
90
  export declare function loadState(path: string): SessionState;
53
- export declare function saveState(path: string, st: SessionState): void;
91
+ export declare function loadMeasuredState(path: string): MeasuredState;
92
+ export declare function saveState(path: string, st: SessionState | MeasuredState): void;
54
93
  /** Run the user's own statusLine command with the same stdin; its first line, or "". */
55
94
  export declare function runInner(command: string, input: string): string;
56
95
  export interface RenderDeps {
57
96
  stateDir: string;
58
97
  inner?: string;
98
+ /** Measured mode (`measuredMode`): the gateway's measured saving, not the estimate. */
99
+ measured?: boolean;
59
100
  runInner?: (command: string, input: string) => string;
60
101
  }
102
+ /** Is the counter in measured mode? The launch's MEASURED_ENV, exactly "1". */
103
+ export declare function measuredMode(env: NodeJS.ProcessEnv): boolean;
61
104
  /** Claude Code's status JSON → the one status line. Never throws. */
62
105
  export declare function render(stdin: string, deps: RenderDeps): string;
63
106
  export interface UserStatusLine {
@@ -72,6 +115,7 @@ export declare function userStatusLine(cwd: string, configDir: string): UserStat
72
115
  /**
73
116
  * Launch args + env that add the counter, or null when it should stay off: the opt-out
74
117
  * env, or the user passing their own --settings (two would fight over precedence).
118
+ * `measured` puts the counter in measured mode (MEASURED_ENV).
75
119
  */
76
120
  export declare function statuslineLaunch(o: {
77
121
  passthrough: readonly string[];
@@ -80,6 +124,7 @@ export declare function statuslineLaunch(o: {
80
124
  home?: string;
81
125
  node: string;
82
126
  bin: string;
127
+ measured?: boolean;
83
128
  }): {
84
129
  args: string[];
85
130
  env: Record<string, string>;
@@ -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-next.0",
4
4
  "description": "Transparent AI agent tracing",
5
5
  "type": "module",
6
6
  "bin": {