@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/README.md +31 -13
- package/dist/commands/agent.d.ts +7 -6
- package/dist/commands/agent.js +78 -20
- package/dist/index.js +1 -1
- package/dist/lib/agent-ledger.d.ts +22 -3
- package/dist/lib/agent-ledger.js +23 -0
- package/dist/lib/agent.d.ts +71 -41
- package/dist/lib/agent.js +90 -42
- package/dist/lib/first-run/backtest.d.ts +23 -3
- package/dist/lib/first-run/backtest.js +35 -4
- package/dist/lib/first-run/index.d.ts +35 -14
- package/dist/lib/first-run/index.js +82 -37
- package/dist/lib/first-run/replay.d.ts +86 -0
- package/dist/lib/first-run/replay.js +162 -0
- package/dist/lib/first-run/screen.d.ts +67 -31
- package/dist/lib/first-run/screen.js +260 -104
- package/dist/lib/launcher-config.d.ts +5 -4
- package/dist/lib/launcher-config.js +1 -1
- package/package.json +1 -1
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
|
|
456
|
-
*
|
|
457
|
-
*
|
|
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
|
-
*
|
|
464
|
-
*
|
|
465
|
-
* - `
|
|
466
|
-
*
|
|
467
|
-
*
|
|
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.
|
|
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: [
|
|
476
|
-
|
|
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
|
|
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. `
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
|
945
|
-
* `
|
|
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.
|
|
950
|
-
*
|
|
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
|
-
|
|
955
|
-
|
|
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
|
|
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
|
|
991
|
-
* "
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
12
|
-
* module tree; only token counts, model ids, timestamps
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
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
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
12
|
-
* module tree; only token counts, model ids, timestamps
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
132
|
+
let subscriber = false;
|
|
133
|
+
let extraUsage = false;
|
|
125
134
|
const seatInputs = new Map();
|
|
126
|
-
ordered.forEach(({ s,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
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
|
|
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)) {
|