@mnemom/mnemom 0.17.5 → 0.17.6-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lib/agent.js CHANGED
@@ -489,10 +489,175 @@ export const CONTEXT_HANDOFF_KEY = "CONTEXT_HANDOFF_ENABLED";
489
489
  * `CLAUDE_CODE_AUTO_COMPACT_WINDOW` / `autoCompactWindow`) fires as it would without the fold;
490
490
  * - `GATEWAY_COMPACTION_INTERCEPT` — answer the client's compaction request at once from the
491
491
  * gateway's own fold digest (no model call), so the compaction never blocks the user.
492
- * Sent together, value `"on"`, ONLY when the setting is on (see `resolveCompactionIntercept`).
492
+ * Sent together, value `"on"`, when the `compaction_intercept` setting is on
493
+ * (`resolveCompactionIntercept`) OR when `--compact-at` arms a target below 1M (below) — a
494
+ * target cannot be honored without both.
493
495
  */
494
496
  export const USAGE_REAL_SIZE_KEY = "GATEWAY_USAGE_REAL_SIZE";
495
497
  export const COMPACTION_INTERCEPT_KEY = "GATEWAY_COMPACTION_INTERCEPT";
498
+ // ---------------------------------------------------------------------------
499
+ // Compaction target (`--compact-at`, launcher.toml `compact_at`, MNEMOM_COMPACT_AT)
500
+ // ---------------------------------------------------------------------------
501
+ //
502
+ // Lets an operator play with WHEN the gateway pulls this session's own client-visible
503
+ // compaction point forward (GATEWAY_COMPACTION_UPLOAD_TARGET_TOKENS, #2251), instead of
504
+ // leaving Claude Code to compact on its own 1M-window schedule. Unset / `1m` / `off` all
505
+ // mean the SAME thing — no target — so a launch with nothing else configured sends no new
506
+ // header keys at all and behaves exactly as it did before this setting existed: Claude
507
+ // Code's own 1M window, no gateway-triggered compaction. A value strictly below 1,000,000
508
+ // arms the compaction intercept (USAGE_REAL_SIZE_KEY + COMPACTION_INTERCEPT_KEY, above — the
509
+ // same two keys `--compaction-intercept` arms, #2216, both required for the upload pull to be
510
+ // honored instead of silently inert) plus the target itself. Refold (GATEWAY_COMPACTION_REFOLD)
511
+ // is a SEPARATE, costlier opt-in (+9.5pts measured) — this setting never touches it. This never
512
+ // sets a Claude Code autocompact env/window: Claude Code's own compaction math is untouched;
513
+ // only what the GATEWAY reports back changes.
514
+ export const GATEWAY_COMPACTION_UPLOAD_TARGET_KEY = "GATEWAY_COMPACTION_UPLOAD_TARGET_TOKENS";
515
+ /** The lowest token count `--compact-at` / `compact_at` / `MNEMOM_COMPACT_AT` will accept. */
516
+ export const COMPACT_AT_MIN_TOKENS = 50_000;
517
+ /**
518
+ * The parser's ceiling (exclusive): the largest window Claude Code runs. It is NOT where a target
519
+ * stops working — the gateway ignores a target at or above the compaction point of the window
520
+ * the session actually runs on (COMPACT_AT_TRIGGER_1M / COMPACT_AT_TRIGGER_200K, below), which
521
+ * `compactAtWindowWarning` tells the user about at launch.
522
+ */
523
+ export const COMPACT_AT_MAX_TOKENS = 1_000_000;
524
+ /**
525
+ * Claude Code's own compaction point on a 1M and on a 200k window — the gateway's
526
+ * `ALIGN_T_1M` / `ALIGN_T_200K` (gateway/src/compaction-align.ts). The gateway honours an upload
527
+ * target only BELOW the point of the window the request runs on (`target_over_threshold`
528
+ * otherwise), and says nothing back to the client when it ignores one.
529
+ */
530
+ export const COMPACT_AT_TRIGGER_1M = 967_000;
531
+ export const COMPACT_AT_TRIGGER_200K = 167_000;
532
+ /**
533
+ * `resolveWindowModel`'s answer as a window. `one-m` and a model already suffixed `[1m]` are
534
+ * 1M; the `CLAUDE_CODE_DISABLE_1M_CONTEXT` opt-out and a model with no 1M window are 200k (the
535
+ * request then carries no 1M beta, so the gateway uses its 200k point too). A passthrough
536
+ * `-- --model`, a resume, Claude Code's own default, any other suffix, and a non-Claude-Code CLI
537
+ * (`null`) are unknown.
538
+ */
539
+ export function launchWindow(win) {
540
+ if (win === null)
541
+ return "unknown";
542
+ switch (win.reason) {
543
+ case "one-m":
544
+ return "1m";
545
+ case "suffixed":
546
+ return /\[1m\]\s*$/i.test(win.model ?? "") ? "1m" : "unknown";
547
+ case "opt-out":
548
+ case "unsupported":
549
+ return "200k";
550
+ default:
551
+ return "unknown";
552
+ }
553
+ }
554
+ /**
555
+ * The launch warning for a target the gateway will (or may) ignore because it is at or above
556
+ * the compaction point of the window this launch runs on; `null` when the target can take
557
+ * effect (or there is no target). On an unknown window a target in `[167k, 967k)` works only
558
+ * if the session lands on a 1M window, so the line says so instead of staying silent.
559
+ */
560
+ export function compactAtWindowWarning(compactAt, window) {
561
+ if (compactAt === null)
562
+ return null;
563
+ const at = formatTokenCount(compactAt);
564
+ const t1m = formatTokenCount(COMPACT_AT_TRIGGER_1M);
565
+ const t200k = formatTokenCount(COMPACT_AT_TRIGGER_200K);
566
+ if (compactAt >= COMPACT_AT_TRIGGER_1M ||
567
+ (window === "200k" && compactAt >= COMPACT_AT_TRIGGER_200K)) {
568
+ const [w, t] = window === "200k" ? ["200k", t200k] : window === "1m" ? ["1M", t1m] : ["any", t1m];
569
+ return (`mnemom agent: warning: compaction target ${at} is at or above Claude Code's own compaction ` +
570
+ `point on ${w === "any" ? "any" : `this launch's ${w}`} window (${t}), so the gateway will ignore it. ` +
571
+ `Pick a value below ${t}.`);
572
+ }
573
+ if (window === "unknown" && compactAt >= COMPACT_AT_TRIGGER_200K) {
574
+ return (`mnemom agent: note: compaction target ${at} takes effect only on a 1M window (below ${t1m}); ` +
575
+ `if this session runs on a 200k window, the gateway's point is ${t200k} and it ignores the target.`);
576
+ }
577
+ return null;
578
+ }
579
+ /**
580
+ * Parse a `--compact-at` / `compact_at` / `MNEMOM_COMPACT_AT` value. `source` names the
581
+ * surface in the error so a customer knows which setting to fix (same convention as
582
+ * `parseContextProfile`).
583
+ *
584
+ * - "" (unset), "1m", "off" (trimmed, case-insensitive) → `null`: no gateway target —
585
+ * Claude Code runs its normal 1M window and nothing new is sent.
586
+ * - a bare integer, or one with a `k`/`m` suffix (`200k` → 200000, `0.5m` → 500000) →
587
+ * that many tokens, when it is in `[COMPACT_AT_MIN_TOKENS, COMPACT_AT_MAX_TOKENS)`.
588
+ * - anything else, or a number outside that range (use `1m`/`off` for the ceiling and
589
+ * above, never a bare `1000000`) → throws with a human-readable message.
590
+ */
591
+ export function parseCompactAt(raw, source) {
592
+ const v = raw.trim().toLowerCase();
593
+ if (v === "" || v === "1m" || v === "off")
594
+ return null;
595
+ const m = /^(\d+(?:\.\d+)?)(k|m)?$/.exec(v);
596
+ if (!m) {
597
+ throw new Error(`${source} must be a token count (optionally with a k/m suffix), '1m', or 'off' (got '${raw}').`);
598
+ }
599
+ const mult = m[2] === "k" ? 1_000 : m[2] === "m" ? 1_000_000 : 1;
600
+ const tokens = Math.round(Number(m[1]) * mult);
601
+ if (!Number.isFinite(tokens) ||
602
+ tokens < COMPACT_AT_MIN_TOKENS ||
603
+ tokens >= COMPACT_AT_MAX_TOKENS) {
604
+ throw new Error(`${source} must be between ${COMPACT_AT_MIN_TOKENS} and ${COMPACT_AT_MAX_TOKENS - 1} tokens` +
605
+ ` (or '1m' / 'off' for no gateway compaction target); got '${raw}'.`);
606
+ }
607
+ return tokens;
608
+ }
609
+ /**
610
+ * Which compaction target this launch runs under. Precedence, highest wins:
611
+ * 1. `opts.compactAt` — an already-parsed `--compact-at` flag value (`undefined` when the
612
+ * flag was not given at all; `null` is a valid, explicit "no target" from `1m`/`off`).
613
+ * 2. `MNEMOM_COMPACT_AT` (which `applyConfigToEnv` sets from the `compact_at` launcher.toml
614
+ * key, UNDER any real env var — so an operator's own env always wins over the file).
615
+ * 3. `null` — the default: no target, nothing new sent.
616
+ */
617
+ export function resolveCompactAt(opts) {
618
+ if (opts.compactAt !== undefined)
619
+ return opts.compactAt;
620
+ const raw = (opts.env?.MNEMOM_COMPACT_AT ?? "").trim();
621
+ if (raw === "")
622
+ return null;
623
+ return parseCompactAt(raw, "MNEMOM_COMPACT_AT");
624
+ }
625
+ /**
626
+ * `--no-compaction-intercept` is a deliberate, documented "keep the intercept off for this
627
+ * launch no matter what toml/env says" signal. A compaction target needs that SAME intercept
628
+ * to arm (`buildSessionConfig` ORs them), so pairing an explicit `--no-compaction-intercept`
629
+ * with an armed `--compact-at` is a contradiction — refuse rather than silently pick a side
630
+ * (either silently re-arming the intercept the user just turned off, or silently sending an
631
+ * inert upload-target the gateway can never act on). `compactionIntercept` here is the raw,
632
+ * pre-`resolveCompactionIntercept` flag value: `false` only when `--no-compaction-intercept`
633
+ * was explicitly passed (or the experimental gate forced it, which `compactAt` cannot reach
634
+ * past either — see index.ts's `assertEnabled`), never the mere absence of `--compaction-intercept`.
635
+ */
636
+ export function assertCompactAtCompatible(opts) {
637
+ if (opts.compactionIntercept === false && opts.compactAt !== null) {
638
+ throw new Error("mnemom agent: --compact-at needs the compaction intercept to arm, but it is off for " +
639
+ "this launch (--no-compaction-intercept, or the compaction-intercept experimental " +
640
+ "feature is disabled). Drop --no-compaction-intercept (and run `mnemom experimental " +
641
+ "enable compaction-intercept`), or pass --compact-at off.");
642
+ }
643
+ }
644
+ /**
645
+ * `200000` → `"200k"`, anything not k-aligned → the bare digits. Every real caller only ever
646
+ * passes a `parseCompactAt`-validated value, which is bounded to
647
+ * `[COMPACT_AT_MIN_TOKENS, COMPACT_AT_MAX_TOKENS)` — i.e. `[50_000, 999_999]` — so there is no
648
+ * multiple of 1,000,000 to round to an `"Nm"` form; that case is intentionally not handled here.
649
+ */
650
+ function formatTokenCount(tokens) {
651
+ if (tokens % 1_000 === 0)
652
+ return `${tokens / 1_000}k`;
653
+ return String(tokens);
654
+ }
655
+ /** The launch-banner line for the resolved compaction target (always printed). */
656
+ export function describeCompactAt(compactAt) {
657
+ return compactAt === null
658
+ ? "Claude Code default (1M)"
659
+ : `gateway at ${formatTokenCount(compactAt)}`;
660
+ }
496
661
  /**
497
662
  * Claude Code's opt-in for its gateway hint headers (`x-claude-code-request-class: main |
498
663
  * compaction`, `x-claude-code-compaction: auto | manual | reactive`,
@@ -768,9 +933,11 @@ export function resolveContextProfile(opts) {
768
933
  * `x-mnemom-session-config` payload, in wire order: the profile's keys first
769
934
  * (`CONTEXT_RUNTIME_ENABLED` for `off`; the three fold-tuning keys for `aggressive`; nothing
770
935
  * for `balanced`), then `CONTEXT_HANDOFF_ENABLED: "true"` when context handoff is ON, then
771
- * `GATEWAY_USAGE_REAL_SIZE: "on"` + `GATEWAY_COMPACTION_INTERCEPT: "on"` when the compaction
772
- * intercept is ON. Empty for `balanced` with both off — the common case, where no header is sent at all.
773
- * Exported so `--dry-run` can list exactly what the header carries.
936
+ * `GATEWAY_USAGE_REAL_SIZE: "on"` + `GATEWAY_COMPACTION_INTERCEPT: "on"` when EITHER the
937
+ * `compaction_intercept` setting is ON OR `--compact-at` arms a target below 1M (the target
938
+ * cannot be honored without the intercept), then `GATEWAY_COMPACTION_UPLOAD_TARGET_TOKENS: "<N>"`
939
+ * when a target is set. Empty for `balanced` with everything off — the common case, where no
940
+ * header is sent at all. Exported so `--dry-run` can list exactly what the header carries.
774
941
  */
775
942
  export function buildSessionConfig(opts) {
776
943
  const config = {};
@@ -778,10 +945,17 @@ export function buildSessionConfig(opts) {
778
945
  config[k] = v;
779
946
  if (opts.contextHandoff)
780
947
  config[CONTEXT_HANDOFF_KEY] = "true";
781
- if (opts.compactionIntercept) {
948
+ const hasCompactionTarget = opts.compactAt !== undefined && opts.compactAt !== null;
949
+ // A target needs the compaction intercept + real-size rewrite to arm (#2216) — without them
950
+ // the upload-target key is silently inert (compaction-align.ts: "armed only inside the
951
+ // intercept"). Deliberately NOT GATEWAY_COMPACTION_REFOLD — a separate, costlier opt-in.
952
+ if (opts.compactionIntercept || hasCompactionTarget) {
782
953
  config[USAGE_REAL_SIZE_KEY] = "on";
783
954
  config[COMPACTION_INTERCEPT_KEY] = "on";
784
955
  }
956
+ if (hasCompactionTarget) {
957
+ config[GATEWAY_COMPACTION_UPLOAD_TARGET_KEY] = String(opts.compactAt);
958
+ }
785
959
  return config;
786
960
  }
787
961
  /**
@@ -800,12 +974,15 @@ export function buildSessionConfigPayload(opts) {
800
974
  }
801
975
  /**
802
976
  * The `x-mnemom-session-config` value for this launch, or `undefined` when there is
803
- * nothing to override (the gateway's own defaults stand — `balanced` with handoff off,
804
- * byte-identical to a launch before profiles existed). Two independent overlays:
977
+ * nothing to override (the gateway's own defaults stand — `balanced` with everything else off,
978
+ * byte-identical to a launch before profiles existed). Independent overlays:
805
979
  * - the context profile (`buildSessionConfig`): `off` → `CONTEXT_RUNTIME_ENABLED: "false"`,
806
980
  * `aggressive` → the three fold-tuning keys, `balanced` → nothing;
807
981
  * - `compactionIntercept: true` → `GATEWAY_USAGE_REAL_SIZE` + `GATEWAY_COMPACTION_INTERCEPT`
808
982
  * `"on"` (the `compaction_intercept` setting);
983
+ * - `compactAt: <N>` (`--compact-at`, below 1M) → the SAME two intercept keys plus
984
+ * `GATEWAY_COMPACTION_UPLOAD_TARGET_TOKENS: "<N>"`; unset, `1m` or `off` → nothing beyond
985
+ * what `compactionIntercept` already sent (Claude Code's own 1M window, untouched);
809
986
  * - `contextHandoff: true` → `CONTEXT_HANDOFF_ENABLED: "true"` (the `context_handoff`
810
987
  * setting) — the SAME header the `handoff` MCP tool's own call to
811
988
  * `/mnemom/context/handoff` must also send, since the gateway resolves the flag
@@ -13,6 +13,15 @@
13
13
  * modules are the thin drivers.
14
14
  */
15
15
  export type ExperimentalId = "context-handoff" | "slack" | "implicit" | "compaction-intercept";
16
+ /**
17
+ * Is a `compact_at` / `MNEMOM_COMPACT_AT` value (trimmed, case-insensitive) an ARMED gateway
18
+ * target, as opposed to "no target" (`""`, `"1m"`, `"off"`)? Deliberately duplicates
19
+ * `parseCompactAt`'s no-target spellings rather than importing it, to keep this registry
20
+ * dependency-free (see the module doc); an unparseable value (bad k/m suffix, out of range)
21
+ * is treated as armed so the gate errs toward requiring the opt-in rather than toward silently
22
+ * admitting a value the real parser would reject anyway.
23
+ */
24
+ export declare function compactAtArmed(raw: string | undefined): boolean;
16
25
  export interface ExperimentalFeature {
17
26
  id: ExperimentalId;
18
27
  summary: string;
@@ -73,8 +82,8 @@ export declare function assertEnabled(uses: ReadonlyArray<{
73
82
  export declare function experimentalIdForConfigKey(key: string, value: string): ExperimentalId | undefined;
74
83
  /**
75
84
  * The stderr lines for experimental settings a launch ignores because their feature
76
- * is off: launcher.toml keys (`entries` as `[key, value]`), MNEMOM_AGENT_CONTEXT_HANDOFF and
77
- * MNEMOM_AGENT_COMPACTION_INTERCEPT.
85
+ * is off: launcher.toml keys (`entries` as `[key, value]`), MNEMOM_AGENT_CONTEXT_HANDOFF,
86
+ * MNEMOM_AGENT_COMPACTION_INTERCEPT and MNEMOM_COMPACT_AT (an armed target only).
78
87
  */
79
88
  export declare function ignoredSettingLines(opts: {
80
89
  entries: ReadonlyArray<[string, string]>;
@@ -12,6 +12,18 @@
12
12
  * Everything here is pure or dependency-injected; index.ts and the command
13
13
  * modules are the thin drivers.
14
14
  */
15
+ /**
16
+ * Is a `compact_at` / `MNEMOM_COMPACT_AT` value (trimmed, case-insensitive) an ARMED gateway
17
+ * target, as opposed to "no target" (`""`, `"1m"`, `"off"`)? Deliberately duplicates
18
+ * `parseCompactAt`'s no-target spellings rather than importing it, to keep this registry
19
+ * dependency-free (see the module doc); an unparseable value (bad k/m suffix, out of range)
20
+ * is treated as armed so the gate errs toward requiring the opt-in rather than toward silently
21
+ * admitting a value the real parser would reject anyway.
22
+ */
23
+ export function compactAtArmed(raw) {
24
+ const v = (raw ?? "").trim().toLowerCase();
25
+ return v !== "" && v !== "1m" && v !== "off";
26
+ }
15
27
  export const EXPERIMENTAL_FEATURES = [
16
28
  {
17
29
  id: "context-handoff",
@@ -140,6 +152,9 @@ export function experimentalIdForConfigKey(key, value) {
140
152
  return value === "true" ? "context-handoff" : undefined;
141
153
  case "compaction_intercept":
142
154
  return value === "true" ? "compaction-intercept" : undefined;
155
+ case "compact_at":
156
+ // Arms the SAME gateway keys compaction_intercept does — same gate.
157
+ return compactAtArmed(value) ? "compaction-intercept" : undefined;
143
158
  case "goal_mode":
144
159
  return value === "implicit" ? "implicit" : undefined;
145
160
  default:
@@ -152,8 +167,8 @@ function truthy(raw) {
152
167
  }
153
168
  /**
154
169
  * The stderr lines for experimental settings a launch ignores because their feature
155
- * is off: launcher.toml keys (`entries` as `[key, value]`), MNEMOM_AGENT_CONTEXT_HANDOFF and
156
- * MNEMOM_AGENT_COMPACTION_INTERCEPT.
170
+ * is off: launcher.toml keys (`entries` as `[key, value]`), MNEMOM_AGENT_CONTEXT_HANDOFF,
171
+ * MNEMOM_AGENT_COMPACTION_INTERCEPT and MNEMOM_COMPACT_AT (an armed target only).
157
172
  */
158
173
  export function ignoredSettingLines(opts) {
159
174
  const lines = [];
@@ -170,6 +185,10 @@ export function ignoredSettingLines(opts) {
170
185
  !opts.enabled.includes("compaction-intercept")) {
171
186
  lines.push(`mnemom agent: ignoring ${gatedMessage("MNEMOM_AGENT_COMPACTION_INTERCEPT", "compaction-intercept")}`);
172
187
  }
188
+ if (compactAtArmed(opts.env.MNEMOM_COMPACT_AT) &&
189
+ !opts.enabled.includes("compaction-intercept")) {
190
+ lines.push(`mnemom agent: ignoring ${gatedMessage("MNEMOM_COMPACT_AT", "compaction-intercept")}`);
191
+ }
173
192
  return lines;
174
193
  }
175
194
  /** The features an existing launcher.toml already uses (the one-time migration). */
@@ -45,6 +45,14 @@ export declare function rowCost(r: Row): number;
45
45
  export declare function actualBill(rows: readonly Row[]): number;
46
46
  /** An ungoverned request's original context = its billed prompt. */
47
47
  export declare function origContext(r: Row): number;
48
+ /**
49
+ * One transcript line → its billed request, or null (not an assistant record with usage).
50
+ * `id` is the message id (or record uuid); null when the record carries neither.
51
+ */
52
+ export declare function parseUsageLine(line: Buffer, subagent: boolean): {
53
+ id: string | null;
54
+ row: Row;
55
+ } | null;
48
56
  /**
49
57
  * Billed requests of one transcript, deduped by message id (last copy wins, first-seen
50
58
  * order). With `seen`, an id already billed in an earlier transcript (a resumed / forked
@@ -69,42 +69,37 @@ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
69
69
  const USAGE = Buffer.from('"usage"');
70
70
  const ASSISTANT = Buffer.from('"assistant"');
71
71
  /**
72
- * Billed requests of one transcript, deduped by message id (last copy wins, first-seen
73
- * order). With `seen`, an id already billed in an earlier transcript (a resumed / forked
74
- * session copies messages) is skipped, so each request is billed once, to its origin.
72
+ * One transcript line → its billed request, or null (not an assistant record with usage).
73
+ * `id` is the message id (or record uuid); null when the record carries neither.
75
74
  */
76
- export function loadTranscript(path, seen) {
77
- const byId = new Map();
78
- const subagent = path.includes("/subagents/");
79
- let anon = 0;
80
- forEachLine(path, (line) => {
81
- if (line.indexOf(USAGE) === -1 || line.indexOf(ASSISTANT) === -1)
82
- return;
83
- const d = parseJson(line);
84
- if (!d || d.type !== "assistant")
85
- return;
86
- const m = d.message;
87
- if (!m || typeof m !== "object")
88
- return;
89
- const u = m.usage;
90
- if (!u || typeof u !== "object")
91
- return;
92
- const model = typeof m.model === "string" ? m.model : "";
93
- if (!model || model.startsWith("<"))
94
- return;
95
- const t = isoSeconds(d.timestamp);
96
- if (t === null)
97
- return;
98
- const cc = u.cache_creation ?? {};
99
- const cw = num(u.cache_creation_input_tokens);
100
- const cw1h = num(cc.ephemeral_1h_input_tokens);
101
- // Older Claude Code versions omit the 5m split (or the whole cache_creation object):
102
- // derive 5m as total − 1h, and never let a reported 5m share leave writes unbilled.
103
- const cw5 = Math.max(num(cc.ephemeral_5m_input_tokens), cw - cw1h);
104
- const id = (typeof m.id === "string" && m.id) ||
105
- (typeof d.uuid === "string" && d.uuid) ||
106
- `anon-${path}-${anon++}`;
107
- byId.set(id, {
75
+ export function parseUsageLine(line, subagent) {
76
+ if (line.indexOf(USAGE) === -1 || line.indexOf(ASSISTANT) === -1)
77
+ return null;
78
+ const d = parseJson(line);
79
+ if (!d || d.type !== "assistant")
80
+ return null;
81
+ const m = d.message;
82
+ if (!m || typeof m !== "object")
83
+ return null;
84
+ const u = m.usage;
85
+ if (!u || typeof u !== "object")
86
+ return null;
87
+ const model = typeof m.model === "string" ? m.model : "";
88
+ if (!model || model.startsWith("<"))
89
+ return null;
90
+ const t = isoSeconds(d.timestamp);
91
+ if (t === null)
92
+ return null;
93
+ const cc = u.cache_creation ?? {};
94
+ const cw = num(u.cache_creation_input_tokens);
95
+ const cw1h = num(cc.ephemeral_1h_input_tokens);
96
+ // Older Claude Code versions omit the 5m split (or the whole cache_creation object):
97
+ // derive 5m as total − 1h, and never let a reported 5m share leave writes unbilled.
98
+ const cw5 = Math.max(num(cc.ephemeral_5m_input_tokens), cw - cw1h);
99
+ const id = (typeof m.id === "string" && m.id) || (typeof d.uuid === "string" && d.uuid) || null;
100
+ return {
101
+ id,
102
+ row: {
108
103
  t,
109
104
  model,
110
105
  inp: num(u.input_tokens),
@@ -114,7 +109,22 @@ export function loadTranscript(path, seen) {
114
109
  out: num(u.output_tokens),
115
110
  mainThread: d.isSidechain !== true,
116
111
  subagent,
117
- });
112
+ },
113
+ };
114
+ }
115
+ /**
116
+ * Billed requests of one transcript, deduped by message id (last copy wins, first-seen
117
+ * order). With `seen`, an id already billed in an earlier transcript (a resumed / forked
118
+ * session copies messages) is skipped, so each request is billed once, to its origin.
119
+ */
120
+ export function loadTranscript(path, seen) {
121
+ const byId = new Map();
122
+ const subagent = path.includes("/subagents/");
123
+ let anon = 0;
124
+ forEachLine(path, (line) => {
125
+ const r = parseUsageLine(line, subagent);
126
+ if (r)
127
+ byId.set(r.id ?? `anon-${path}-${anon++}`, r.row);
118
128
  });
119
129
  const out = [];
120
130
  for (const [id, row] of byId) {
@@ -16,7 +16,7 @@
16
16
  * the caller folds the plain-value settings under the CLI flags.
17
17
  */
18
18
  import { type AgentAuth } from "./subscription-login.js";
19
- import type { ExperimentalId } from "./experimental.js";
19
+ import { type ExperimentalId } from "./experimental.js";
20
20
  import { type ContextProfile, type GoalModeDefault, type LaunchShape } from "./agent.js";
21
21
  /** The human-editable settings file. Secret key lives elsewhere (launcher.json). */
22
22
  export declare const LAUNCHER_CONFIG_PATH: string;
@@ -61,6 +61,12 @@ export interface LauncherConfig {
61
61
  * folds earlier with a tighter working set. `--context-profile` overrides it per launch.
62
62
  */
63
63
  contextProfile?: ContextProfile;
64
+ /**
65
+ * The gateway compaction target (`--compact-at`; unset means no target — Claude Code's own
66
+ * 1M window, nothing new sent). Stored as the validated raw string (e.g. "200k", "1m",
67
+ * "off", "350000"), parsed with `parseCompactAt` at the point of use.
68
+ */
69
+ compactAt?: string;
64
70
  guardrails?: {
65
71
  maxTurns?: number;
66
72
  budgetUsd?: number;
@@ -113,6 +119,9 @@ export declare function launcherEntries(cfg: LauncherConfig): Array<[string, str
113
119
  /**
114
120
  * `cfg` without the settings of experimental features that are off (a copy; `cfg` is
115
121
  * untouched): `slack_*` while `slack` is off, `context_handoff` while `context-handoff` is
116
- * off, `compaction_intercept` while `compaction-intercept` is off, and `goal_mode = implicit` while `implicit` is off (`goal_mode = off` is kept).
122
+ * off, `compaction_intercept` (and an ARMED `compact_at` — it arms the same two gateway keys,
123
+ * so it needs the same opt-in; `compact_at = "off"`/`"1m"` carries no gated behaviour and is
124
+ * kept) while `compaction-intercept` is off, and `goal_mode = implicit` while `implicit` is
125
+ * off (`goal_mode = off` is kept).
117
126
  */
118
127
  export declare function withoutDisabledExperimental(cfg: LauncherConfig, enabled: readonly ExperimentalId[]): LauncherConfig;
@@ -20,7 +20,8 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "
20
20
  import { dirname, join } from "node:path";
21
21
  import { parse as parseToml, stringify as stringifyToml, TomlError } from "smol-toml";
22
22
  import { MNEMOM_DIR } from "./config.js";
23
- import { CONTEXT_PROFILES, isPositiveInt, isPositiveNumber, parseContextProfile, } from "./agent.js";
23
+ import { compactAtArmed } from "./experimental.js";
24
+ import { CONTEXT_PROFILES, isPositiveInt, isPositiveNumber, parseCompactAt, parseContextProfile, } from "./agent.js";
24
25
  /** The human-editable settings file. Secret key lives elsewhere (launcher.json). */
25
26
  export const LAUNCHER_CONFIG_PATH = join(MNEMOM_DIR, "launcher.toml");
26
27
  const LAUNCH_SHAPES = ["terminal", "remote-control", "server"];
@@ -67,6 +68,13 @@ function coerceNonEmpty(name, raw) {
67
68
  throw new Error(`${name} must be a non-empty string.`);
68
69
  return v;
69
70
  }
71
+ function coerceCompactAt(raw) {
72
+ const v = raw.trim().toLowerCase();
73
+ // Validate against the same parser the flag/env use, but keep the human-chosen spelling
74
+ // ("200k" vs "200000") — resolveCompactAt re-parses it at launch time anyway.
75
+ parseCompactAt(v, "compact_at");
76
+ return v;
77
+ }
70
78
  function coerceMemberIds(raw) {
71
79
  const ids = raw
72
80
  .split(/[\s,]+/)
@@ -213,6 +221,14 @@ export const CONFIG_KEYS = [
213
221
  c.contextProfile = parseContextProfile(v, "context_profile");
214
222
  },
215
223
  },
224
+ {
225
+ key: "compact_at",
226
+ describe: "Gateway compaction target: a token count (k/m suffix OK), '1m' or 'off' for none (default: none — Claude Code's own 1M window)",
227
+ get: (c) => c.compactAt,
228
+ set: (c, v) => {
229
+ c.compactAt = coerceCompactAt(v);
230
+ },
231
+ },
216
232
  {
217
233
  key: "guardrails.max_turns",
218
234
  describe: "Contract guardrail: turn ceiling (positive integer)",
@@ -297,6 +313,8 @@ export function parseLauncherConfig(text) {
297
313
  cfg.compactionIntercept = coerceBool("compaction_intercept", str(raw.compaction_intercept) ?? "");
298
314
  if (raw.context_profile !== undefined)
299
315
  cfg.contextProfile = parseContextProfile(str(raw.context_profile) ?? "", "context_profile");
316
+ if (raw.compact_at !== undefined)
317
+ cfg.compactAt = coerceCompactAt(str(raw.compact_at) ?? "");
300
318
  const g = raw.guardrails;
301
319
  if (g !== undefined) {
302
320
  if (typeof g !== "object" || g === null || Array.isArray(g)) {
@@ -371,6 +389,8 @@ export function serializeLauncherConfig(cfg) {
371
389
  doc.compaction_intercept = cfg.compactionIntercept;
372
390
  if (cfg.contextProfile !== undefined)
373
391
  doc.context_profile = cfg.contextProfile;
392
+ if (cfg.compactAt !== undefined)
393
+ doc.compact_at = cfg.compactAt;
374
394
  const g = cfg.guardrails;
375
395
  if (g && (g.maxTurns !== undefined || g.budgetUsd !== undefined || g.stallTurns !== undefined)) {
376
396
  const gr = {};
@@ -433,6 +453,9 @@ export function applyConfigToEnv(cfg, env = process.env) {
433
453
  if (cfg.contextProfile !== undefined && !(env.MNEMOM_AGENT_CONTEXT_PROFILE ?? "").trim()) {
434
454
  env.MNEMOM_AGENT_CONTEXT_PROFILE = cfg.contextProfile;
435
455
  }
456
+ if (cfg.compactAt !== undefined && !(env.MNEMOM_COMPACT_AT ?? "").trim()) {
457
+ env.MNEMOM_COMPACT_AT = cfg.compactAt;
458
+ }
436
459
  }
437
460
  /** The set keys of `cfg` as `[key, value]` pairs (CONFIG_KEYS order, values stringified). */
438
461
  export function launcherEntries(cfg) {
@@ -444,7 +467,10 @@ export function launcherEntries(cfg) {
444
467
  /**
445
468
  * `cfg` without the settings of experimental features that are off (a copy; `cfg` is
446
469
  * untouched): `slack_*` while `slack` is off, `context_handoff` while `context-handoff` is
447
- * off, `compaction_intercept` while `compaction-intercept` is off, and `goal_mode = implicit` while `implicit` is off (`goal_mode = off` is kept).
470
+ * off, `compaction_intercept` (and an ARMED `compact_at` — it arms the same two gateway keys,
471
+ * so it needs the same opt-in; `compact_at = "off"`/`"1m"` carries no gated behaviour and is
472
+ * kept) while `compaction-intercept` is off, and `goal_mode = implicit` while `implicit` is
473
+ * off (`goal_mode = off` is kept).
448
474
  */
449
475
  export function withoutDisabledExperimental(cfg, enabled) {
450
476
  const out = { ...cfg };
@@ -455,8 +481,11 @@ export function withoutDisabledExperimental(cfg, enabled) {
455
481
  }
456
482
  if (!enabled.includes("context-handoff"))
457
483
  delete out.contextHandoff;
458
- if (!enabled.includes("compaction-intercept"))
484
+ if (!enabled.includes("compaction-intercept")) {
459
485
  delete out.compactionIntercept;
486
+ if (compactAtArmed(out.compactAt))
487
+ delete out.compactAt;
488
+ }
460
489
  if (!enabled.includes("implicit") && out.goalMode === "implicit")
461
490
  delete out.goalMode;
462
491
  return out;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Entry for the `mnemom agent` status-bar counter (lib/statusline.ts). Claude Code runs it
3
+ * on every status update, so it imports only the counter — not the whole CLI.
4
+ */
5
+ export {};
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Entry for the `mnemom agent` status-bar counter (lib/statusline.ts). Claude Code runs it
3
+ * on every status update, so it imports only the counter — not the whole CLI.
4
+ */
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { INNER_ENV, render } from "./statusline.js";
8
+ const chunks = [];
9
+ process.stdin.on("data", (c) => chunks.push(c));
10
+ process.stdin.on("end", () => {
11
+ const line = render(Buffer.concat(chunks).toString("utf8"), {
12
+ stateDir: join(homedir(), ".mnemom", "statusline"),
13
+ inner: process.env[INNER_ENV] || undefined,
14
+ });
15
+ process.stdout.write(`${line}\n`);
16
+ });