@mnemom/mnemom 0.17.7 → 0.17.8

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/lib/agent.js CHANGED
@@ -452,32 +452,59 @@ export const CONTEXT_TAIL_BUDGET_KEY = "CONTEXT_RUNTIME_TAIL_BUDGET_TOKENS";
452
452
  export const CONTEXT_READ_CACHE_KEY = "CONTEXT_RUNTIME_READ_CACHE_TOKENS";
453
453
  export const CONTEXT_HEAD_RECLAIM_KEY = "CONTEXT_RUNTIME_HEAD_BREAKPOINT_RECLAIM";
454
454
  /**
455
- * The gateway key the `adaptive` context profile sets (allow-listed per session): turns on the
456
- * size-banded fold policy. Only the literal `banded` enables it; the band thresholds and budgets
457
- * stay at the gateway's own defaults.
455
+ * The gateway key that turns the size-banded fold policy on or off (allow-listed per session).
456
+ * Only the literal `banded` enables it; any other value — the profiles send `off` — disables it,
457
+ * and a session's value replaces the cell's own. The band thresholds and budgets stay at the
458
+ * gateway's own defaults.
458
459
  */
459
460
  export const CONTEXT_SIZE_POLICY_KEY = "CONTEXT_RUNTIME_SIZE_POLICY";
461
+ /**
462
+ * The two gateway env keys the `compaction_intercept` setting turns on for a single session
463
+ * (payload-bound fix; both default OFF on the gateway, both read from the per-session overlay):
464
+ * - `GATEWAY_USAGE_REAL_SIZE` — report the size of the body the client actually sent (the
465
+ * REAL, unfolded context) in the last `usage.iterations` entry, so Claude Code's own
466
+ * auto-compact threshold (its 1M default, or the user's `CLAUDE_AUTOCOMPACT_PCT_OVERRIDE` /
467
+ * `CLAUDE_CODE_AUTO_COMPACT_WINDOW` / `autoCompactWindow`) fires as it would without the fold;
468
+ * - `GATEWAY_COMPACTION_INTERCEPT` — answer the client's compaction request at once from the
469
+ * gateway's own fold digest (no model call), so the compaction never blocks the user.
470
+ * Sent together, value `"on"`, when the `compaction_intercept` setting is on
471
+ * (`resolveCompactionIntercept`) OR when `--compact-at` arms a target below 1M (below) — a
472
+ * target cannot be honored without both. The `off` context profile sends both as `"off"`.
473
+ */
474
+ export const USAGE_REAL_SIZE_KEY = "GATEWAY_USAGE_REAL_SIZE";
475
+ export const COMPACTION_INTERCEPT_KEY = "GATEWAY_COMPACTION_INTERCEPT";
460
476
  /**
461
477
  * The named presets for how the gateway folds a session's context (`--context-profile`,
462
- * launcher.toml `context_profile`, `MNEMOM_AGENT_CONTEXT_PROFILE`):
463
- * - `off` → `CONTEXT_RUNTIME_ENABLED: "false"` — compaction is left to the coding agent
464
- * (the same bytes `--no-context` has always sent).
465
- * - `balanced` → NO fold keys: the gateway's own defaults stand. The default.
466
- * - `aggressive` → a smaller tail budget + read cache and head-breakpoint reclaim on: folds
467
- * earlier and keeps a tighter working set.
478
+ * launcher.toml `context_profile`, `MNEMOM_AGENT_CONTEXT_PROFILE`). Choosing NONE sends no fold
479
+ * key at all and the cell's own default decides (see `resolveContextProfile`). A chosen profile
480
+ * is complete on its own, so a change to a cell's default can never change what it means:
481
+ * - `off` → `CONTEXT_RUNTIME_ENABLED: "false"` plus `GATEWAY_USAGE_REAL_SIZE: "off"` and
482
+ * `GATEWAY_COMPACTION_INTERCEPT: "off"` — compaction is left to the coding agent
483
+ * and never intercepted, even on a cell whose default turns the intercept on.
484
+ * The gateway already skips the intercept with the fold off, but the real-size
485
+ * usage rewrite is not gated on the fold, so `off` turns both off by name.
486
+ * - `balanced` → `CONTEXT_RUNTIME_SIZE_POLICY: "off"` — the cell's own fold budgets, never
487
+ * the size bands, even on a cell whose default is adaptive.
488
+ * - `aggressive` → a smaller tail budget + read cache and head-breakpoint reclaim on, size
489
+ * policy off: folds earlier and keeps a tighter working set at every size.
468
490
  * - `adaptive` → `CONTEXT_RUNTIME_SIZE_POLICY: "banded"` — the gateway picks the fold by the
469
491
  * conversation's original size: no fold below 200k tokens, the aggressive
470
- * budgets from 200k to 1M, balanced above. Opt-in; never the default.
492
+ * budgets from 200k to 1M, balanced above.
471
493
  */
472
494
  export const CONTEXT_PROFILES = ["off", "balanced", "aggressive", "adaptive"];
473
495
  /** The per-session env overlay each profile expands to (key order is the wire order). */
474
496
  const CONTEXT_PROFILE_CONFIG = {
475
- off: [[CONTEXT_RUNTIME_KEY, "false"]],
476
- balanced: [],
497
+ off: [
498
+ [CONTEXT_RUNTIME_KEY, "false"],
499
+ [USAGE_REAL_SIZE_KEY, "off"],
500
+ [COMPACTION_INTERCEPT_KEY, "off"],
501
+ ],
502
+ balanced: [[CONTEXT_SIZE_POLICY_KEY, "off"]],
477
503
  aggressive: [
478
504
  [CONTEXT_TAIL_BUDGET_KEY, "60000"],
479
505
  [CONTEXT_READ_CACHE_KEY, "8000"],
480
506
  [CONTEXT_HEAD_RECLAIM_KEY, "true"],
507
+ [CONTEXT_SIZE_POLICY_KEY, "off"],
481
508
  ],
482
509
  adaptive: [[CONTEXT_SIZE_POLICY_KEY, "banded"]],
483
510
  };
@@ -490,21 +517,6 @@ const CONTEXT_PROFILE_CONFIG = {
490
517
  * header without the tool.
491
518
  */
492
519
  export const CONTEXT_HANDOFF_KEY = "CONTEXT_HANDOFF_ENABLED";
493
- /**
494
- * The two gateway env keys the `compaction_intercept` setting turns on for a single session
495
- * (payload-bound fix; both default OFF on the gateway, both read from the per-session overlay):
496
- * - `GATEWAY_USAGE_REAL_SIZE` — report the size of the body the client actually sent (the
497
- * REAL, unfolded context) in the last `usage.iterations` entry, so Claude Code's own
498
- * auto-compact threshold (its 1M default, or the user's `CLAUDE_AUTOCOMPACT_PCT_OVERRIDE` /
499
- * `CLAUDE_CODE_AUTO_COMPACT_WINDOW` / `autoCompactWindow`) fires as it would without the fold;
500
- * - `GATEWAY_COMPACTION_INTERCEPT` — answer the client's compaction request at once from the
501
- * gateway's own fold digest (no model call), so the compaction never blocks the user.
502
- * Sent together, value `"on"`, when the `compaction_intercept` setting is on
503
- * (`resolveCompactionIntercept`) OR when `--compact-at` arms a target below 1M (below) — a
504
- * target cannot be honored without both.
505
- */
506
- export const USAGE_REAL_SIZE_KEY = "GATEWAY_USAGE_REAL_SIZE";
507
- export const COMPACTION_INTERCEPT_KEY = "GATEWAY_COMPACTION_INTERCEPT";
508
520
  // ---------------------------------------------------------------------------
509
521
  // Compaction target (`--compact-at`, launcher.toml `compact_at`, MNEMOM_COMPACT_AT)
510
522
  // ---------------------------------------------------------------------------
@@ -651,6 +663,28 @@ export function assertCompactAtCompatible(opts) {
651
663
  "enable compaction-intercept`), or pass --compact-at off.");
652
664
  }
653
665
  }
666
+ /**
667
+ * The `off` context profile turns the fold off, and the compaction intercept answers a compaction
668
+ * from that fold, so `off` sends the intercept and real-size keys as `"off"`. An explicit
669
+ * `--compaction-intercept` or `--compact-at <N>` in the same launch asks for the opposite —
670
+ * refuse rather than silently drop the flag. A standing intercept or target (environment or
671
+ * launcher.toml) is not refused: the session's `off` wins and `buildSessionConfig` leaves it out.
672
+ * `compactionIntercept` / `compactAt` here are the raw flag values (`undefined` when not passed).
673
+ */
674
+ export function assertOffProfileCompatible(opts) {
675
+ if (opts.profile !== "off")
676
+ return;
677
+ const flag = opts.compactionIntercept === true
678
+ ? "--compaction-intercept"
679
+ : typeof opts.compactAt === "number"
680
+ ? "--compact-at"
681
+ : null;
682
+ if (flag === null)
683
+ return;
684
+ throw new Error(`mnemom agent: context profile off and ${flag} disagree — with the fold off the gateway has ` +
685
+ `no fold to answer a compaction from, so off sends the compaction intercept off. Drop ` +
686
+ `${flag}, or choose another --context-profile.`);
687
+ }
654
688
  /**
655
689
  * `200000` → `"200k"`, anything not k-aligned → the bare digits. Every real caller only ever
656
690
  * passes a `parseCompactAt`-validated value, which is bounded to
@@ -910,17 +944,20 @@ export function parseContextProfile(raw, source) {
910
944
  throw new Error(`${source} must be one of ${CONTEXT_PROFILES.join(", ")} (got '${raw}').`);
911
945
  }
912
946
  /**
913
- * Which context profile this launch runs under. Precedence, highest wins:
947
+ * Which context profile this launch runs under, or `null` when none was chosen anywhere — then
948
+ * no fold key is sent and the cell's own default decides. Precedence, highest wins:
914
949
  * 1. `--no-context` — always `off`. Combined with an explicit `--context-profile
915
950
  * balanced|aggressive|adaptive` it is a CONFLICT and the launch refuses (never a silent override);
916
951
  * `--context-profile off` agrees with it.
917
952
  * 2. an explicit `--context-profile <profile>` flag (`--context` alongside it is fine — the
918
953
  * profile wins);
919
- * 3. `MNEMOM_AGENT_CONTEXT_PROFILE` (which `applyConfigToEnv` sets from the `context_profile`
954
+ * 3. `resumed`: the profile the session this launch resumes ran under (from the session
955
+ * ledger), so a resume keeps that session's own choice over the saved default;
956
+ * 4. `MNEMOM_AGENT_CONTEXT_PROFILE` (which `applyConfigToEnv` sets from the `context_profile`
920
957
  * config key) — an invalid value is an error, never a silent default;
921
- * 4. the folding boolean when NO profile is set anywhere: `context = false` in launcher.toml
958
+ * 5. the folding boolean when NO profile is set anywhere: `context = false` in launcher.toml
922
959
  * (→ `MNEMOM_AGENT_CONTEXT`, see `resolveContextFold`) means `off`;
923
- * 5. `balanced` (the default).
960
+ * 6. `null` — unset, the cell default.
924
961
  */
925
962
  export function resolveContextProfile(opts) {
926
963
  if (opts.context === false) {
@@ -931,34 +968,44 @@ export function resolveContextProfile(opts) {
931
968
  }
932
969
  if (opts.contextProfile !== undefined)
933
970
  return opts.contextProfile;
971
+ if (opts.resumed)
972
+ return opts.resumed;
934
973
  const raw = (opts.env?.MNEMOM_AGENT_CONTEXT_PROFILE ?? "").trim();
935
974
  if (raw !== "")
936
975
  return parseContextProfile(raw, "MNEMOM_AGENT_CONTEXT_PROFILE");
937
976
  if (!resolveContextFold({ context: opts.context, env: opts.env }))
938
977
  return "off";
939
- return "balanced";
978
+ return null;
940
979
  }
941
980
  /**
942
981
  * The decoded per-session env overlay for this launch — the `config` object of the
943
982
  * `x-mnemom-session-config` payload, in wire order: the profile's keys first
944
- * (`CONTEXT_RUNTIME_ENABLED` for `off`; the three fold-tuning keys for `aggressive`;
945
- * `CONTEXT_RUNTIME_SIZE_POLICY` for `adaptive`; nothing for `balanced`), then `CONTEXT_HANDOFF_ENABLED: "true"` when context handoff is ON, then
983
+ * (`CONTEXT_RUNTIME_ENABLED: "false"` + real-size and intercept `"off"` for `off`; the three fold-tuning keys + size policy off for
984
+ * `aggressive`; size policy off for `balanced`; size policy banded for `adaptive`; nothing when
985
+ * no profile was chosen), then `CONTEXT_HANDOFF_ENABLED: "true"` when context handoff is ON, then
946
986
  * `GATEWAY_USAGE_REAL_SIZE: "on"` + `GATEWAY_COMPACTION_INTERCEPT: "on"` when EITHER the
947
987
  * `compaction_intercept` setting is ON OR `--compact-at` arms a target below 1M (the target
948
988
  * cannot be honored without the intercept), then `GATEWAY_COMPACTION_UPLOAD_TARGET_TOKENS: "<N>"`
949
- * when a target is set. Empty for `balanced` with everything off — the common case, where no
950
- * header is sent at all. Exported so `--dry-run` can list exactly what the header carries.
989
+ * when a target is set. The `off` profile wins over the intercept and the target: a session with
990
+ * no fold never has its compaction intercepted (`assertOffProfileCompatible` refuses the explicit
991
+ * flag pair; a standing intercept or target from the environment or launcher.toml is dropped).
992
+ * Empty with no profile chosen and everything off — the common case, where
993
+ * no header is sent at all. Exported so `--dry-run` can list exactly what the header carries.
951
994
  */
952
995
  export function buildSessionConfig(opts) {
953
996
  const config = {};
954
- for (const [k, v] of CONTEXT_PROFILE_CONFIG[opts.profile])
955
- config[k] = v;
997
+ if (opts.profile !== null) {
998
+ for (const [k, v] of CONTEXT_PROFILE_CONFIG[opts.profile])
999
+ config[k] = v;
1000
+ }
956
1001
  if (opts.contextHandoff)
957
1002
  config[CONTEXT_HANDOFF_KEY] = "true";
958
1003
  const hasCompactionTarget = opts.compactAt !== undefined && opts.compactAt !== null;
959
1004
  // A target needs the compaction intercept + real-size rewrite to arm (#2216) — without them
960
1005
  // the upload-target key is silently inert (compaction-align.ts: "armed only inside the
961
1006
  // intercept"). Deliberately NOT GATEWAY_COMPACTION_REFOLD — a separate, costlier opt-in.
1007
+ if (opts.profile === "off")
1008
+ return config;
962
1009
  if (opts.compactionIntercept || hasCompactionTarget) {
963
1010
  config[USAGE_REAL_SIZE_KEY] = "on";
964
1011
  config[COMPACTION_INTERCEPT_KEY] = "on";
@@ -984,11 +1031,12 @@ export function buildSessionConfigPayload(opts) {
984
1031
  }
985
1032
  /**
986
1033
  * The `x-mnemom-session-config` value for this launch, or `undefined` when there is
987
- * nothing to override (the gateway's own defaults stand — `balanced` with everything else off,
988
- * byte-identical to a launch before profiles existed). Independent overlays:
1034
+ * nothing to override (the cell's own defaults stand — no profile chosen with everything else
1035
+ * off, byte-identical to a launch before profiles existed). Independent overlays:
989
1036
  * - the context profile (`buildSessionConfig`): `off` → `CONTEXT_RUNTIME_ENABLED: "false"`,
990
- * `aggressive` → the three fold-tuning keys, `adaptive` → `CONTEXT_RUNTIME_SIZE_POLICY:
991
- * "banded"`, `balanced` → nothing;
1037
+ * `aggressive` → the three fold-tuning keys + `CONTEXT_RUNTIME_SIZE_POLICY: "off"`,
1038
+ * `balanced` → `CONTEXT_RUNTIME_SIZE_POLICY: "off"`, `adaptive` →
1039
+ * `CONTEXT_RUNTIME_SIZE_POLICY: "banded"`, none chosen → nothing;
992
1040
  * - `compactionIntercept: true` → `GATEWAY_USAGE_REAL_SIZE` + `GATEWAY_COMPACTION_INTERCEPT`
993
1041
  * `"on"` (the `compaction_intercept` setting);
994
1042
  * - `compactAt: <N>` (`--compact-at`, below 1M) → the SAME two intercept keys plus
@@ -13,7 +13,8 @@
13
13
  * original context is at or above the aggressive profile's fold point (its 60k tail
14
14
  * budget); smaller requests never fold, so they save 0. A flat rate over-counts mid-size
15
15
  * contexts and under-counts large ones; in aggregate that evens out. Per-size rates come
16
- * back once there are enough measured pairs.
16
+ * back once there are enough measured pairs. The token saving uses the same rule on the
17
+ * prompt (operator 2026-10-02: one rule for dollars and tokens).
17
18
  */
18
19
  /** One billed request, deduped by message id. `t` is epoch seconds. */
19
20
  export interface Row {
@@ -50,12 +51,24 @@ export declare function parseUsageLine(line: Buffer, subagent: boolean): {
50
51
  id: string | null;
51
52
  row: Row;
52
53
  } | null;
54
+ /** One Claude Code compaction (`type:"system", subtype:"compact_boundary"`). */
55
+ export interface Compaction {
56
+ /** Epoch seconds. */
57
+ t: number;
58
+ /** compactMetadata.preTokens / postTokens (0 when absent). */
59
+ pre: number;
60
+ post: number;
61
+ }
62
+ /** One transcript line → its compaction record, or null. Reads only the metadata. */
63
+ export declare function parseCompactLine(line: Buffer): Compaction | null;
53
64
  /**
54
65
  * Billed requests of one transcript, deduped by message id (last copy wins, first-seen
55
66
  * order). With `seen`, an id already billed in an earlier transcript (a resumed / forked
56
67
  * session copies messages) is skipped, so each request is billed once, to its origin.
68
+ * With `compactions`, the transcript's compaction records are appended to it in the same
69
+ * pass (no second read of the file).
57
70
  */
58
- export declare function loadTranscript(path: string, seen?: Set<string>): Row[];
71
+ export declare function loadTranscript(path: string, seen?: Set<string>, compactions?: Compaction[]): Row[];
59
72
  /** S46 C2n vs stock, median paired total-bill saving (n = 13). */
60
73
  export declare const S46_MEDIAN_SAVING = 0.238;
61
74
  /** The aggressive profile's tail budget (cli/src/lib/agent.ts): below it nothing folds. */
@@ -73,11 +86,18 @@ export declare function ruleRate(x: number): {
73
86
  export interface Estimate {
74
87
  actual: number;
75
88
  saved: number;
89
+ /** Tokens used: prompt + output over every request. */
90
+ tokens: number;
91
+ /** The token saving: the same flat rule on the prompt, ruleRate(prompt) x prompt. */
92
+ savedTokens: number;
76
93
  /** Same as `saved` now that no rate is floored (kept for the audit record). */
77
94
  rawSaved: number;
78
95
  peak: number;
79
96
  requests: number;
80
97
  notes: Partial<Record<Exclude<RateNote, null>, number>>;
81
98
  }
82
- /** The one swap point: rows → list-price actual and the conservative saving. */
99
+ /**
100
+ * The one swap point for a session that did NOT run through Mnemom: rows → list-price
101
+ * actual, the conservative dollar saving (the cost curve) and the token saving.
102
+ */
83
103
  export declare function estimate(rows: readonly Row[]): Estimate;
@@ -13,7 +13,8 @@
13
13
  * original context is at or above the aggressive profile's fold point (its 60k tail
14
14
  * budget); smaller requests never fold, so they save 0. A flat rate over-counts mid-size
15
15
  * contexts and under-counts large ones; in aggregate that evens out. Per-size rates come
16
- * back once there are enough measured pairs.
16
+ * back once there are enough measured pairs. The token saving uses the same rule on the
17
+ * prompt (operator 2026-10-02: one rule for dollars and tokens).
17
18
  */
18
19
  import { forEachLine, isoSeconds, parseJson } from "./lines.js";
19
20
  /**
@@ -109,16 +110,39 @@ export function parseUsageLine(line, subagent) {
109
110
  },
110
111
  };
111
112
  }
113
+ const COMPACT = Buffer.from('"compact_boundary"');
114
+ /** One transcript line → its compaction record, or null. Reads only the metadata. */
115
+ export function parseCompactLine(line) {
116
+ if (line.indexOf(COMPACT) === -1)
117
+ return null;
118
+ const d = parseJson(line);
119
+ if (!d || d.type !== "system" || d.subtype !== "compact_boundary")
120
+ return null;
121
+ const t = isoSeconds(d.timestamp);
122
+ if (t === null)
123
+ return null;
124
+ const cm = d.compactMetadata ?? {};
125
+ return { t, pre: num(cm.preTokens), post: num(cm.postTokens) };
126
+ }
112
127
  /**
113
128
  * Billed requests of one transcript, deduped by message id (last copy wins, first-seen
114
129
  * order). With `seen`, an id already billed in an earlier transcript (a resumed / forked
115
130
  * session copies messages) is skipped, so each request is billed once, to its origin.
131
+ * With `compactions`, the transcript's compaction records are appended to it in the same
132
+ * pass (no second read of the file).
116
133
  */
117
- export function loadTranscript(path, seen) {
134
+ export function loadTranscript(path, seen, compactions) {
118
135
  const byId = new Map();
119
136
  const subagent = path.includes("/subagents/");
120
137
  let anon = 0;
121
138
  forEachLine(path, (line) => {
139
+ if (compactions) {
140
+ const c = parseCompactLine(line);
141
+ if (c) {
142
+ compactions.push(c);
143
+ return;
144
+ }
145
+ }
122
146
  const r = parseUsageLine(line, subagent);
123
147
  if (r)
124
148
  byId.set(r.id ?? `anon-${path}-${anon++}`, r.row);
@@ -148,11 +172,16 @@ export function ruleRate(x) {
148
172
  }
149
173
  return { strategy: "aggressive", raw: S46_MEDIAN_SAVING, screen: S46_MEDIAN_SAVING, note: null };
150
174
  }
151
- /** The one swap point: rows → list-price actual and the conservative saving. */
175
+ /**
176
+ * The one swap point for a session that did NOT run through Mnemom: rows → list-price
177
+ * actual, the conservative dollar saving (the cost curve) and the token saving.
178
+ */
152
179
  export function estimate(rows) {
153
180
  let actual = 0;
154
181
  let saved = 0;
155
182
  let rawSaved = 0;
183
+ let tokens = 0;
184
+ let savedTokens = 0;
156
185
  let peak = 0;
157
186
  const notes = {};
158
187
  for (const r of rows) {
@@ -164,8 +193,10 @@ export function estimate(rows) {
164
193
  actual += c;
165
194
  saved += rate.screen * c;
166
195
  rawSaved += rate.raw * c;
196
+ tokens += x + r.out;
197
+ savedTokens += rate.screen * x;
167
198
  if (rate.note)
168
199
  notes[rate.note] = (notes[rate.note] ?? 0) + 1;
169
200
  }
170
- return { actual, saved, rawSaved, peak, requests: rows.length, notes };
201
+ return { actual, saved, tokens, savedTokens, rawSaved, peak, requests: rows.length, notes };
171
202
  }
@@ -3,21 +3,30 @@
3
3
  * backtest the user's last 30 days of Claude Code sessions on this machine and show what
4
4
  * Mnemom would have saved. `mnemom agent --first-run` shows it again at any time.
5
5
  *
6
- * Every session in the window is priced (operator 2026-10-02: "count all sessions"),
7
- * including ones that already ran through Mnemom: their transcripts hold the full,
8
- * unfolded context the client sent. Only copies of one session are dropped. The governed
9
- * classification is still run, for the audit counts only.
6
+ * Every session in the window is priced (operator 2026-10-02: "count all sessions"), in
7
+ * one of two buckets (throughEvidence):
8
+ * THROUGH Mnemom a launch-ledger signal, a window / env signal, or fold evidence on
9
+ * the main thread. Its transcript does NOT hold the unfolded context:
10
+ * through the gateway the top-level `usage` is the folded, billed size
11
+ * (`usage.iterations` matches it on every one of 365k rows measured),
12
+ * so the curve cannot be applied to it. Its saving is REALIZED: a
13
+ * replay of stock Claude Code at its default window (replay.ts).
14
+ * NOT through everything else, headless, Agent-SDK and marker-only sessions
15
+ * included: the POTENTIAL saving (backtest.estimate()).
16
+ * Only copies of one session are dropped.
10
17
  *
11
- * Privacy: READ-ONLY on ~/.claude and every transcript; no network call anywhere in this
12
- * module tree; only token counts, model ids, timestamps and Claude Code's own limit
13
- * records are read. Writes only the offer state file and a local audit (both 0600,
14
- * under ~/.mnemom).
18
+ * Privacy: READ-ONLY on ~/.claude and every transcript, one pass per file; no network
19
+ * call anywhere in this module tree; only token counts, model ids, timestamps, Claude
20
+ * Code's own limit records and its compaction metadata are read.
21
+ *
22
+ * Writes only the offer state file and a local audit (both 0600, under ~/.mnemom).
15
23
  *
16
24
  * State file (~/.mnemom/first-run.json): { offers, last_offer, last_answer, accepted_at?,
17
25
  * never? }. The offer is made while neither `accepted_at` nor `never` is set:
18
26
  * Enter / y shows the screen and records acceptance; n skips until the next launch;
19
27
  * never stops asking. A non-interactive launch is never asked and writes nothing.
20
28
  */
29
+ import { type Compaction, type Row } from "./backtest.js";
21
30
  import { type ScopeResult, type Session } from "./scope.js";
22
31
  import { type Audit, type Variation } from "./screen.js";
23
32
  export declare const STATE_FILE = "first-run.json";
@@ -59,18 +68,30 @@ export interface FirstRunResult {
59
68
  export declare function computeFirstRun(opts?: ScanOptions): FirstRunResult;
60
69
  /**
61
70
  * The sessions the screen prices: every session in the window, one per session id (an
62
- * ungoverned copy first), none without a parent transcript.
71
+ * ungoverned copy first), none without a parent transcript, with its governed signals.
63
72
  */
64
73
  export declare function pricedSessions(scope: ScopeResult): Array<{
65
74
  s: Session;
66
- viaMnemom: boolean;
75
+ signals: string[];
67
76
  }>;
77
+ /** `mnemom agent` recorded the session in the launch ledger. */
78
+ export declare const LEDGER_SIGNALS: Set<string>;
68
79
  /**
69
- * Proof a session ran through Mnemom: `mnemom agent` recorded it in the launch ledger.
70
- * Every other governed signal (an Agent-SDK host, a text marker, a time window) only
71
- * means it may have, so those sessions are not said to have been saved on.
80
+ * A launch / Remote Control window match, or a settings env pointing at the gateway.
81
+ * These put a session in the through bucket (operator sign-off 2026-10-02: "launch
82
+ * ledger, OR inside a Mnemom launch window, OR shows a fold"), but they never make a
83
+ * saving claim: the realized saving comes only from fold drops the replay detects
84
+ * (replayStock caps it at 0 without one), so a window- or env-only session with no
85
+ * fold adds its usage to "Through Mnemom" and nothing to "That saved".
72
86
  */
73
- export declare const LEDGER_SIGNALS: Set<string>;
87
+ export declare const WINDOW_SIGNALS: Set<string>;
88
+ /**
89
+ * Did the session run through Mnemom, and on what evidence? A ledger or window / env
90
+ * signal, else fold evidence on its main thread (replay.hasFoldEvidence). A headless,
91
+ * Agent-SDK or marker-only session without fold evidence is NOT through: those signals
92
+ * only mean it may have been.
93
+ */
94
+ export declare function throughEvidence(signals: readonly string[], rows: readonly Row[], comps: readonly Compaction[]): "ledger" | "window" | "folds" | null;
74
95
  /** The screen's hard budget: a regression in buildScreen's line dropping must fail loudly. */
75
96
  export declare function assertScreenBudget(lines: readonly string[]): void;
76
97
  /** A transcript's mtime (ms), 0 when it vanished since discovery (it then sorts first). */
@@ -3,15 +3,23 @@
3
3
  * backtest the user's last 30 days of Claude Code sessions on this machine and show what
4
4
  * Mnemom would have saved. `mnemom agent --first-run` shows it again at any time.
5
5
  *
6
- * Every session in the window is priced (operator 2026-10-02: "count all sessions"),
7
- * including ones that already ran through Mnemom: their transcripts hold the full,
8
- * unfolded context the client sent. Only copies of one session are dropped. The governed
9
- * classification is still run, for the audit counts only.
6
+ * Every session in the window is priced (operator 2026-10-02: "count all sessions"), in
7
+ * one of two buckets (throughEvidence):
8
+ * THROUGH Mnemom a launch-ledger signal, a window / env signal, or fold evidence on
9
+ * the main thread. Its transcript does NOT hold the unfolded context:
10
+ * through the gateway the top-level `usage` is the folded, billed size
11
+ * (`usage.iterations` matches it on every one of 365k rows measured),
12
+ * so the curve cannot be applied to it. Its saving is REALIZED: a
13
+ * replay of stock Claude Code at its default window (replay.ts).
14
+ * NOT through everything else, headless, Agent-SDK and marker-only sessions
15
+ * included: the POTENTIAL saving (backtest.estimate()).
16
+ * Only copies of one session are dropped.
10
17
  *
11
- * Privacy: READ-ONLY on ~/.claude and every transcript; no network call anywhere in this
12
- * module tree; only token counts, model ids, timestamps and Claude Code's own limit
13
- * records are read. Writes only the offer state file and a local audit (both 0600,
14
- * under ~/.mnemom).
18
+ * Privacy: READ-ONLY on ~/.claude and every transcript, one pass per file; no network
19
+ * call anywhere in this module tree; only token counts, model ids, timestamps, Claude
20
+ * Code's own limit records and its compaction metadata are read.
21
+ *
22
+ * Writes only the offer state file and a local audit (both 0600, under ~/.mnemom).
15
23
  *
16
24
  * State file (~/.mnemom/first-run.json): { offers, last_offer, last_answer, accepted_at?,
17
25
  * never? }. The offer is made while neither `accepted_at` nor `never` is set:
@@ -21,7 +29,8 @@
21
29
  import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
22
30
  import { homedir } from "node:os";
23
31
  import { dirname, join } from "node:path";
24
- import { estimate, loadTranscript, origContext, ruleRate } from "./backtest.js";
32
+ import { estimate, loadTranscript, origContext, ruleRate, } from "./backtest.js";
33
+ import { defaultWindow, dominantModel, hasFoldEvidence, replayStock } from "./replay.js";
25
34
  import { analyzeSeat } from "./seat.js";
26
35
  import { ORG_SEAT_TYPES, authShape, billingClass, buildScope, configDirOf, prettyProject, } from "./scope.js";
27
36
  import { DAYS, MAX_LINES, WIDTH, buildScreen, labelFor, } from "./screen.js";
@@ -117,17 +126,22 @@ export function computeFirstRun(opts = {}) {
117
126
  const seen = new Set();
118
127
  const shapes = new Map();
119
128
  const sessions = [];
120
- const subSessions = [];
121
129
  // A Team / Enterprise subscriber: a subscription-billed session, or a Team / Enterprise
122
130
  // login in any kept session's config dir. Only these plans can run through Mnemom on
123
131
  // their subscription (lib/subscription-login.ts), so only they get the usage sentence.
124
- let orgLogin = false;
132
+ let subscriber = false;
133
+ let extraUsage = false;
125
134
  const seatInputs = new Map();
126
- ordered.forEach(({ s, viaMnemom }, i) => {
135
+ ordered.forEach(({ s, signals }, i) => {
127
136
  if (i % 25 === 0)
128
137
  opts.progress?.(`pricing sessions ${i + 1}/${ordered.length}`);
129
138
  const paths = [s.top, ...[...s.subs].sort()];
130
- const rows = paths.flatMap((p) => loadTranscript(p, seen));
139
+ // Compactions are the main thread's: read from the top transcript only.
140
+ const comps = [];
141
+ const rows = [
142
+ ...loadTranscript(paths[0], seen, comps),
143
+ ...paths.slice(1).flatMap((p) => loadTranscript(p, seen)),
144
+ ];
131
145
  if (!rows.length) {
132
146
  counts.no_usage = (counts.no_usage ?? 0) + 1;
133
147
  return;
@@ -152,30 +166,44 @@ export function computeFirstRun(opts = {}) {
152
166
  const rowsIn = rows.filter((r) => r.t >= cut);
153
167
  if (!rowsIn.length)
154
168
  return;
155
- // Every class is priced at API list prices; the screen says so.
169
+ const compsIn = comps.filter((c) => c.t >= cut);
170
+ // Every bucket is priced at API list prices; the screen says so.
156
171
  const e = estimate(rowsIn);
157
172
  const end = rowsIn.reduce((m, r) => Math.max(m, r.t), -Infinity);
158
- const sc = {
173
+ const via = throughEvidence(signals, rowsIn, compsIn);
174
+ let saved = e.saved;
175
+ let savedTokens = e.savedTokens;
176
+ if (via) {
177
+ counts[`through_${via}`] = (counts[`through_${via}`] ?? 0) + 1;
178
+ const W = defaultWindow(dominantModel(rowsIn));
179
+ const rp = replayStock(rowsIn, compsIn, W);
180
+ counts[`through_window_${W}`] = (counts[`through_window_${W}`] ?? 0) + 1;
181
+ counts.stock_compactions = (counts.stock_compactions ?? 0) + rp.stockCompactions;
182
+ counts.actual_compactions = (counts.actual_compactions ?? 0) + rp.actualCompactions;
183
+ if (rp.savedUsd < 0)
184
+ counts.through_negative = (counts.through_negative ?? 0) + 1;
185
+ saved = rp.savedUsd;
186
+ savedTokens = rp.savedTokens;
187
+ }
188
+ else {
189
+ counts.not_through = (counts.not_through ?? 0) + 1;
190
+ }
191
+ sessions.push({
159
192
  label: labelFor(prettyProject(s.pdir)),
160
193
  end,
161
194
  actual: e.actual,
162
- saved: e.saved,
163
- viaMnemom,
164
- };
165
- sessions.push(sc);
166
- if (cls === "subscription_org")
167
- subSessions.push(sc);
168
- orgLogin ||= subLogin;
195
+ tokens: e.tokens,
196
+ saved,
197
+ savedTokens,
198
+ through: via !== null,
199
+ });
200
+ if (cls === "subscription_org" || subLogin) {
201
+ subscriber = true;
202
+ extraUsage ||= shape.extraUsage === true;
203
+ }
169
204
  });
170
205
  const seat = [...seatInputs.values()].map((a) => analyzeSeat(a.ss, a.extra, now));
171
- const use = subSessions.length ? subSessions : sessions;
172
- const sub = subSessions.length || orgLogin
173
- ? {
174
- sessions: use.length,
175
- actual: use.reduce((t, r) => t + r.actual, 0),
176
- saved: use.reduce((t, r) => t + r.saved, 0),
177
- }
178
- : null;
206
+ const sub = subscriber ? { extraUsage } : null;
179
207
  const { lines, audit, variation } = buildScreen({ sessions, sub });
180
208
  assertScreenBudget(lines);
181
209
  audit.seat = {
@@ -187,7 +215,7 @@ export function computeFirstRun(opts = {}) {
187
215
  }
188
216
  /**
189
217
  * The sessions the screen prices: every session in the window, one per session id (an
190
- * ungoverned copy first), none without a parent transcript.
218
+ * ungoverned copy first), none without a parent transcript, with its governed signals.
191
219
  */
192
220
  export function pricedSessions(scope) {
193
221
  const out = new Map();
@@ -195,17 +223,34 @@ export function pricedSessions(scope) {
195
223
  const s = scope.sessions.get(key);
196
224
  if (!s.top || out.has(s.sid))
197
225
  continue;
198
- const sigs = scope.excluded.get(key) ?? [];
199
- out.set(s.sid, { s, viaMnemom: sigs.some((x) => LEDGER_SIGNALS.has(x)) });
226
+ out.set(s.sid, { s, signals: scope.excluded.get(key) ?? [] });
200
227
  }
201
228
  return [...out.values()];
202
229
  }
230
+ /** `mnemom agent` recorded the session in the launch ledger. */
231
+ export const LEDGER_SIGNALS = new Set(["registry_cc_id", "registry_conv_id"]);
203
232
  /**
204
- * Proof a session ran through Mnemom: `mnemom agent` recorded it in the launch ledger.
205
- * Every other governed signal (an Agent-SDK host, a text marker, a time window) only
206
- * means it may have, so those sessions are not said to have been saved on.
233
+ * A launch / Remote Control window match, or a settings env pointing at the gateway.
234
+ * These put a session in the through bucket (operator sign-off 2026-10-02: "launch
235
+ * ledger, OR inside a Mnemom launch window, OR shows a fold"), but they never make a
236
+ * saving claim: the realized saving comes only from fold drops the replay detects
237
+ * (replayStock caps it at 0 without one), so a window- or env-only session with no
238
+ * fold adds its usage to "Through Mnemom" and nothing to "That saved".
207
239
  */
208
- export const LEDGER_SIGNALS = new Set(["registry_cc_id", "registry_conv_id"]);
240
+ export const WINDOW_SIGNALS = new Set(["registry_window", "rc_proxy_window", "settings_env"]);
241
+ /**
242
+ * Did the session run through Mnemom, and on what evidence? A ledger or window / env
243
+ * signal, else fold evidence on its main thread (replay.hasFoldEvidence). A headless,
244
+ * Agent-SDK or marker-only session without fold evidence is NOT through: those signals
245
+ * only mean it may have been.
246
+ */
247
+ export function throughEvidence(signals, rows, comps) {
248
+ if (signals.some((x) => LEDGER_SIGNALS.has(x)))
249
+ return "ledger";
250
+ if (signals.some((x) => WINDOW_SIGNALS.has(x)))
251
+ return "window";
252
+ return hasFoldEvidence(rows, comps) ? "folds" : null;
253
+ }
209
254
  /** The screen's hard budget: a regression in buildScreen's line dropping must fail loudly. */
210
255
  export function assertScreenBudget(lines) {
211
256
  if (lines.length > MAX_LINES || lines.some((l) => l.length > WIDTH)) {