@mnemom/mnemom 0.17.15-next.0 → 0.17.16-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/agent.d.ts +2 -0
- package/dist/commands/agent.js +22 -6
- package/dist/lib/agent.d.ts +26 -1
- package/dist/lib/agent.js +24 -1
- package/dist/lib/experimental.d.ts +1 -1
- package/dist/lib/experimental.js +12 -0
- package/dist/lib/statusline-bin.js +2 -1
- package/dist/lib/statusline.d.ts +46 -1
- package/dist/lib/statusline.js +136 -33
- package/package.json +1 -1
package/dist/commands/agent.d.ts
CHANGED
|
@@ -251,6 +251,8 @@ export interface ResolvedCli {
|
|
|
251
251
|
/** The basename, e.g. "claude" — selects the launch adapter. */
|
|
252
252
|
name: string;
|
|
253
253
|
}
|
|
254
|
+
/** The CLI asked for — `--cli`, else MNEMOM_AGENT_CLI, else "claude" — before any lookup. */
|
|
255
|
+
export declare function requestedCli(cliOpt: string | undefined, env: NodeJS.ProcessEnv): string;
|
|
254
256
|
/**
|
|
255
257
|
* Resolve which coding-agent CLI to launch. `--cli` (or MNEMOM_AGENT_CLI) is a
|
|
256
258
|
* name or a full path; default "claude". A value with a path separator is used
|
package/dist/commands/agent.js
CHANGED
|
@@ -31,7 +31,7 @@ import { randomUUID } from "node:crypto";
|
|
|
31
31
|
import { tmpdir, homedir, hostname, userInfo } from "node:os";
|
|
32
32
|
import { basename, join, dirname } from "node:path";
|
|
33
33
|
import { fileURLToPath } from "node:url";
|
|
34
|
-
import { statuslineLaunch } from "../lib/statusline.js";
|
|
34
|
+
import { MEASURED_ENV, statuslineLaunch } from "../lib/statusline.js";
|
|
35
35
|
import { getApiUrl, getGatewayUrl, MNEMOM_DIR } from "../lib/config.js";
|
|
36
36
|
import { createApiKey } from "../lib/api.js";
|
|
37
37
|
import { MNEMOM_KEY_STORE_FIELD, resolveAgentAuth, resolveSubscriptionMnemomKey, subscriptionChildEnv, subscriptionShape, } from "../lib/subscription-login.js";
|
|
@@ -46,7 +46,7 @@ import { authConfigured, chooseAuth, detectClaudeLogin, readClaudeLoginState, }
|
|
|
46
46
|
import { requireConsoleKey, readConsoleKey } from "../lib/console-key.js";
|
|
47
47
|
import { goalAlignmentLine, resolveSessionSetup } from "./agent-session.js";
|
|
48
48
|
import { defaultAgentSlug, ensureClaimedServedAgent, ensureSessionAndBalance, isQuietPreambleExit, resolveDefaultSlug, resolvePreflightedAccount, } from "./agent-setup.js";
|
|
49
|
-
import { assembleContract, buildGoalModeHeader, buildCustomHeaders, buildHandoffMcpConfigJson, buildSessionConfig, buildSessionConfigB64, assertCompactAtCompatible, assertOffProfileCompatible, compactAtWindowWarning, describeCompactAt, launchWindow, resolveCompactAt, applyGatewayHintHeaders, resolveCompactionIntercept, resolveContextHandoff, resolveContextProfile, resolveHandoffGatewayHost, HANDOFF_RC_KEY_ENV_NAME, isProdGateway, launchPreferenceExplicit, launchPreflighted, remoteControlAllowed, applyToolSearchDefault, DISABLE_1M_ENV, readCcSettings, resolveWindowModel, resolveAnthropicDoor, resolveLaunchModel, resolveConversationId, resolveGatewayHost, singleCellError, resolveLaunchShape, resolveRemoteControlShape, passthroughName, sanitizeAgentName, resumeCommand, sessionDisplayName, SESSION_NAME_ENV, SESSION_CONFIG_HEADER, } from "../lib/agent.js";
|
|
49
|
+
import { assembleContract, buildGoalModeHeader, buildCustomHeaders, buildHandoffMcpConfigJson, buildSessionConfig, buildSessionConfigB64, assertCompactAtCompatible, assertOffProfileCompatible, compactAtWindowWarning, describeCompactAt, launchWindow, resolveCompactAt, applyGatewayHintHeaders, resolveCompactionIntercept, resolveContextHandoff, resolveContextProfile, resolveHandoffGatewayHost, resolveUsageSavings, HANDOFF_RC_KEY_ENV_NAME, isProdGateway, launchPreferenceExplicit, launchPreflighted, remoteControlAllowed, applyToolSearchDefault, DISABLE_1M_ENV, readCcSettings, resolveWindowModel, resolveAnthropicDoor, resolveLaunchModel, resolveConversationId, resolveGatewayHost, singleCellError, resolveLaunchShape, resolveRemoteControlShape, passthroughName, sanitizeAgentName, resumeCommand, sessionDisplayName, SESSION_NAME_ENV, SESSION_CONFIG_HEADER, } from "../lib/agent.js";
|
|
50
50
|
import { LAUNCHER_STORE_PATH, mergeStore } from "../lib/launcher-store.js";
|
|
51
51
|
import { slackChannelArgs } from "../lib/slack-launch.js";
|
|
52
52
|
/** The CLI's own provider-key store (zero 1Password): ~/.mnemom/launcher.json, 0600. */
|
|
@@ -232,17 +232,24 @@ export async function agentCommand(scenarioArg, options = {}) {
|
|
|
232
232
|
compactAt: options.compactAt,
|
|
233
233
|
});
|
|
234
234
|
const experimental = options.experimental ?? [];
|
|
235
|
+
// Decided from the REQUESTED CLI: the header is built before the executable is resolved.
|
|
236
|
+
const usageSavings = resolveUsageSavings({
|
|
237
|
+
experimental,
|
|
238
|
+
cliName: basename(requestedCli(options.cli, env)),
|
|
239
|
+
});
|
|
235
240
|
const sessionConfig = buildSessionConfig({
|
|
236
241
|
profile: contextProfile,
|
|
237
242
|
contextHandoff,
|
|
238
243
|
compactionIntercept,
|
|
239
244
|
compactAt,
|
|
245
|
+
usageSavings,
|
|
240
246
|
});
|
|
241
247
|
let sessionConfigB64 = buildSessionConfigB64({
|
|
242
248
|
profile: contextProfile,
|
|
243
249
|
contextHandoff,
|
|
244
250
|
compactionIntercept,
|
|
245
251
|
compactAt,
|
|
252
|
+
usageSavings,
|
|
246
253
|
experimental,
|
|
247
254
|
});
|
|
248
255
|
const expNotice = experimentalNotice(experimental);
|
|
@@ -269,7 +276,7 @@ export async function agentCommand(scenarioArg, options = {}) {
|
|
|
269
276
|
if (options.dryRun) {
|
|
270
277
|
const planCli = cli ?? {
|
|
271
278
|
bin: "(not found — pass --cli <path> or `mnemom agent config set cli <path>`)",
|
|
272
|
-
name: basename((options.cli
|
|
279
|
+
name: basename(requestedCli(options.cli, env)),
|
|
273
280
|
};
|
|
274
281
|
// A defaulted slug is `agent-<handle>` from the login (resolved for real after
|
|
275
282
|
// sign-in, below). Signed in: show that name. Signed out, or no usable handle:
|
|
@@ -475,6 +482,7 @@ export async function agentCommand(scenarioArg, options = {}) {
|
|
|
475
482
|
contextHandoff,
|
|
476
483
|
compactionIntercept,
|
|
477
484
|
compactAt,
|
|
485
|
+
usageSavings,
|
|
478
486
|
experimental,
|
|
479
487
|
});
|
|
480
488
|
}
|
|
@@ -571,6 +579,7 @@ export async function agentCommand(scenarioArg, options = {}) {
|
|
|
571
579
|
envelope,
|
|
572
580
|
goalMode,
|
|
573
581
|
sessionConfigB64,
|
|
582
|
+
usageSavings,
|
|
574
583
|
options: launchOptions,
|
|
575
584
|
yolo,
|
|
576
585
|
contextHint,
|
|
@@ -923,6 +932,10 @@ function storeKey(key) {
|
|
|
923
932
|
// best effort — the launch proceeds with the in-memory key either way
|
|
924
933
|
}
|
|
925
934
|
}
|
|
935
|
+
/** The CLI asked for — `--cli`, else MNEMOM_AGENT_CLI, else "claude" — before any lookup. */
|
|
936
|
+
export function requestedCli(cliOpt, env) {
|
|
937
|
+
return (cliOpt || env.MNEMOM_AGENT_CLI || "claude").trim();
|
|
938
|
+
}
|
|
926
939
|
/**
|
|
927
940
|
* Resolve which coding-agent CLI to launch. `--cli` (or MNEMOM_AGENT_CLI) is a
|
|
928
941
|
* name or a full path; default "claude". A value with a path separator is used
|
|
@@ -932,7 +945,7 @@ function storeKey(key) {
|
|
|
932
945
|
* a non-claude executable is launched through it with a warning.
|
|
933
946
|
*/
|
|
934
947
|
export function resolveCliBin(cliOpt, env) {
|
|
935
|
-
const requested = (cliOpt
|
|
948
|
+
const requested = requestedCli(cliOpt, env);
|
|
936
949
|
const name = basename(requested);
|
|
937
950
|
if (requested.includes("/")) {
|
|
938
951
|
if (existsSync(requested))
|
|
@@ -985,7 +998,7 @@ export /**
|
|
|
985
998
|
* so they never retype it). A non-interactive session re-throws the clear,
|
|
986
999
|
* actionable error rather than hanging on a prompt.
|
|
987
1000
|
*/ async function promptForCliPath(cliOpt, env, origErr) {
|
|
988
|
-
const requested = (cliOpt
|
|
1001
|
+
const requested = requestedCli(cliOpt, env);
|
|
989
1002
|
if (!isInteractive()) {
|
|
990
1003
|
throw origErr ?? new Error(`mnemom agent: coding-agent CLI '${requested}' not found.`);
|
|
991
1004
|
}
|
|
@@ -1121,7 +1134,9 @@ function launchTerminal(ctx) {
|
|
|
1121
1134
|
if (ctx.options.slack)
|
|
1122
1135
|
args.push(...slackChannelArgs(sessionName));
|
|
1123
1136
|
// The status-bar savings counter (lib/statusline.ts). Claude Code only; the
|
|
1124
|
-
// user's own statusLine keeps running in front of it.
|
|
1137
|
+
// user's own statusLine keeps running in front of it. Measured mode is this launch's
|
|
1138
|
+
// decision alone: a value inherited from an outer `mnemom agent` session never rides along.
|
|
1139
|
+
Reflect.deleteProperty(childEnv, MEASURED_ENV);
|
|
1125
1140
|
if (ctx.cli.name === "claude") {
|
|
1126
1141
|
const sl = statuslineLaunch({
|
|
1127
1142
|
passthrough: ctx.options.passthrough ?? [],
|
|
@@ -1129,6 +1144,7 @@ function launchTerminal(ctx) {
|
|
|
1129
1144
|
cwd: process.cwd(),
|
|
1130
1145
|
node: process.execPath,
|
|
1131
1146
|
bin: join(dirname(fileURLToPath(import.meta.url)), "..", "lib", "statusline-bin.js"),
|
|
1147
|
+
measured: ctx.usageSavings,
|
|
1132
1148
|
});
|
|
1133
1149
|
if (sl) {
|
|
1134
1150
|
args.push(...sl.args);
|
package/dist/lib/agent.d.ts
CHANGED
|
@@ -325,6 +325,15 @@ export declare const CONTEXT_SIZE_POLICY_KEY = "CONTEXT_RUNTIME_SIZE_POLICY";
|
|
|
325
325
|
*/
|
|
326
326
|
export declare const USAGE_REAL_SIZE_KEY = "GATEWAY_USAGE_REAL_SIZE";
|
|
327
327
|
export declare const COMPACTION_INTERCEPT_KEY = "GATEWAY_COMPACTION_INTERCEPT";
|
|
328
|
+
/**
|
|
329
|
+
* The gateway env key that has it measure each request's saving for a single session and put
|
|
330
|
+
* it in the client-facing usage (`mnemom_savings` in the read `usage.iterations` entry), where
|
|
331
|
+
* the status bar reads it from Claude Code's transcript. Default OFF on the gateway; sent `"on"`
|
|
332
|
+
* only while the `measured-savings` experimental feature is on and the launched CLI is Claude
|
|
333
|
+
* Code (`resolveUsageSavings`). Independent of folding, so every profile sends it — `off` too,
|
|
334
|
+
* where the measured saving is honestly $0.00.
|
|
335
|
+
*/
|
|
336
|
+
export declare const USAGE_SAVINGS_KEY = "GATEWAY_USAGE_SAVINGS";
|
|
328
337
|
/**
|
|
329
338
|
* The named presets for how the gateway folds a session's context (`--context-profile`,
|
|
330
339
|
* launcher.toml `context_profile`, `MNEMOM_AGENT_CONTEXT_PROFILE`). Choosing NONE sends no fold
|
|
@@ -607,6 +616,7 @@ export declare function resolveContextProfile(opts: {
|
|
|
607
616
|
* (`CONTEXT_RUNTIME_ENABLED: "false"` + real-size and intercept `"off"` for `off`; the three fold-tuning keys + size policy off for
|
|
608
617
|
* `aggressive`; size policy off for `balanced`; size policy banded for `adaptive`; nothing when
|
|
609
618
|
* no profile was chosen), then `CONTEXT_HANDOFF_ENABLED: "true"` when context handoff is ON, then
|
|
619
|
+
* `GATEWAY_USAGE_SAVINGS: "on"` when measured savings is ON (under every profile), then
|
|
610
620
|
* `GATEWAY_USAGE_REAL_SIZE: "on"` + `GATEWAY_COMPACTION_INTERCEPT: "on"` when EITHER the
|
|
611
621
|
* `compaction_intercept` setting is ON OR `--compact-at` arms a target below 1M (the target
|
|
612
622
|
* cannot be honored without the intercept), then `GATEWAY_COMPACTION_UPLOAD_TARGET_TOKENS: "<N>"`
|
|
@@ -623,6 +633,8 @@ export declare function buildSessionConfig(opts: {
|
|
|
623
633
|
compactionIntercept?: boolean;
|
|
624
634
|
/** `resolveCompactAt` output: `null` (or absent) ⇒ no target, send nothing new. */
|
|
625
635
|
compactAt?: number | null;
|
|
636
|
+
/** `resolveUsageSavings` output: true ⇒ the gateway measures the session's saving. */
|
|
637
|
+
usageSavings?: boolean;
|
|
626
638
|
}): Record<string, string>;
|
|
627
639
|
/**
|
|
628
640
|
* The `x-mnemom-session-config` payload: `{ v: 1, config, experimental? }`. The active
|
|
@@ -634,6 +646,7 @@ export declare function buildSessionConfigPayload(opts: {
|
|
|
634
646
|
contextHandoff: boolean;
|
|
635
647
|
compactionIntercept?: boolean;
|
|
636
648
|
compactAt?: number | null;
|
|
649
|
+
usageSavings?: boolean;
|
|
637
650
|
experimental?: readonly string[];
|
|
638
651
|
}): {
|
|
639
652
|
v: 1;
|
|
@@ -656,7 +669,8 @@ export declare function buildSessionConfigPayload(opts: {
|
|
|
656
669
|
* - `contextHandoff: true` → `CONTEXT_HANDOFF_ENABLED: "true"` (the `context_handoff`
|
|
657
670
|
* setting) — the SAME header the `handoff` MCP tool's own call to
|
|
658
671
|
* `/mnemom/context/handoff` must also send, since the gateway resolves the flag
|
|
659
|
-
* per request (see `commands/agent.ts` handoff MCP wiring)
|
|
672
|
+
* per request (see `commands/agent.ts` handoff MCP wiring);
|
|
673
|
+
* - `usageSavings: true` → `GATEWAY_USAGE_SAVINGS: "on"` (`resolveUsageSavings`).
|
|
660
674
|
* base64url so it survives the gateway's inbound strip.
|
|
661
675
|
*/
|
|
662
676
|
export declare function buildSessionConfigB64(opts: {
|
|
@@ -664,6 +678,7 @@ export declare function buildSessionConfigB64(opts: {
|
|
|
664
678
|
contextHandoff: boolean;
|
|
665
679
|
compactionIntercept?: boolean;
|
|
666
680
|
compactAt?: number | null;
|
|
681
|
+
usageSavings?: boolean;
|
|
667
682
|
experimental?: readonly string[];
|
|
668
683
|
}): string | undefined;
|
|
669
684
|
/**
|
|
@@ -689,6 +704,16 @@ export declare function resolveCompactionIntercept(opts: {
|
|
|
689
704
|
compactionIntercept?: boolean;
|
|
690
705
|
env?: NodeJS.ProcessEnv;
|
|
691
706
|
}): boolean;
|
|
707
|
+
/**
|
|
708
|
+
* Does the gateway measure this launch's saving (`GATEWAY_USAGE_SAVINGS` in the header, and the
|
|
709
|
+
* status bar in measured mode)? Only with the `measured-savings` experimental feature on, and
|
|
710
|
+
* only for Claude Code — the one CLI whose status bar reads the measurement. No flag or
|
|
711
|
+
* launcher.toml key: `mnemom experimental enable measured-savings` is the switch.
|
|
712
|
+
*/
|
|
713
|
+
export declare function resolveUsageSavings(opts: {
|
|
714
|
+
experimental: readonly string[];
|
|
715
|
+
cliName: string;
|
|
716
|
+
}): boolean;
|
|
692
717
|
/** The name the `handoff` MCP tool server is registered under in `--mcp-config`. */
|
|
693
718
|
export declare const HANDOFF_MCP_SERVER_NAME = "mnemom-handoff";
|
|
694
719
|
/**
|
package/dist/lib/agent.js
CHANGED
|
@@ -487,6 +487,15 @@ export const CONTEXT_SIZE_POLICY_KEY = "CONTEXT_RUNTIME_SIZE_POLICY";
|
|
|
487
487
|
*/
|
|
488
488
|
export const USAGE_REAL_SIZE_KEY = "GATEWAY_USAGE_REAL_SIZE";
|
|
489
489
|
export const COMPACTION_INTERCEPT_KEY = "GATEWAY_COMPACTION_INTERCEPT";
|
|
490
|
+
/**
|
|
491
|
+
* The gateway env key that has it measure each request's saving for a single session and put
|
|
492
|
+
* it in the client-facing usage (`mnemom_savings` in the read `usage.iterations` entry), where
|
|
493
|
+
* the status bar reads it from Claude Code's transcript. Default OFF on the gateway; sent `"on"`
|
|
494
|
+
* only while the `measured-savings` experimental feature is on and the launched CLI is Claude
|
|
495
|
+
* Code (`resolveUsageSavings`). Independent of folding, so every profile sends it — `off` too,
|
|
496
|
+
* where the measured saving is honestly $0.00.
|
|
497
|
+
*/
|
|
498
|
+
export const USAGE_SAVINGS_KEY = "GATEWAY_USAGE_SAVINGS";
|
|
490
499
|
/**
|
|
491
500
|
* The named presets for how the gateway folds a session's context (`--context-profile`,
|
|
492
501
|
* launcher.toml `context_profile`, `MNEMOM_AGENT_CONTEXT_PROFILE`). Choosing NONE sends no fold
|
|
@@ -997,6 +1006,7 @@ export function resolveContextProfile(opts) {
|
|
|
997
1006
|
* (`CONTEXT_RUNTIME_ENABLED: "false"` + real-size and intercept `"off"` for `off`; the three fold-tuning keys + size policy off for
|
|
998
1007
|
* `aggressive`; size policy off for `balanced`; size policy banded for `adaptive`; nothing when
|
|
999
1008
|
* no profile was chosen), then `CONTEXT_HANDOFF_ENABLED: "true"` when context handoff is ON, then
|
|
1009
|
+
* `GATEWAY_USAGE_SAVINGS: "on"` when measured savings is ON (under every profile), then
|
|
1000
1010
|
* `GATEWAY_USAGE_REAL_SIZE: "on"` + `GATEWAY_COMPACTION_INTERCEPT: "on"` when EITHER the
|
|
1001
1011
|
* `compaction_intercept` setting is ON OR `--compact-at` arms a target below 1M (the target
|
|
1002
1012
|
* cannot be honored without the intercept), then `GATEWAY_COMPACTION_UPLOAD_TARGET_TOKENS: "<N>"`
|
|
@@ -1014,6 +1024,9 @@ export function buildSessionConfig(opts) {
|
|
|
1014
1024
|
}
|
|
1015
1025
|
if (opts.contextHandoff)
|
|
1016
1026
|
config[CONTEXT_HANDOFF_KEY] = "true";
|
|
1027
|
+
// Before the `off` return: measuring is not folding, so an off session sends it too.
|
|
1028
|
+
if (opts.usageSavings)
|
|
1029
|
+
config[USAGE_SAVINGS_KEY] = "on";
|
|
1017
1030
|
const hasCompactionTarget = opts.compactAt !== undefined && opts.compactAt !== null;
|
|
1018
1031
|
// A target needs the compaction intercept + real-size rewrite to arm (#2216) — without them
|
|
1019
1032
|
// the upload-target key is silently inert (compaction-align.ts: "armed only inside the
|
|
@@ -1059,7 +1072,8 @@ export function buildSessionConfigPayload(opts) {
|
|
|
1059
1072
|
* - `contextHandoff: true` → `CONTEXT_HANDOFF_ENABLED: "true"` (the `context_handoff`
|
|
1060
1073
|
* setting) — the SAME header the `handoff` MCP tool's own call to
|
|
1061
1074
|
* `/mnemom/context/handoff` must also send, since the gateway resolves the flag
|
|
1062
|
-
* per request (see `commands/agent.ts` handoff MCP wiring)
|
|
1075
|
+
* per request (see `commands/agent.ts` handoff MCP wiring);
|
|
1076
|
+
* - `usageSavings: true` → `GATEWAY_USAGE_SAVINGS: "on"` (`resolveUsageSavings`).
|
|
1063
1077
|
* base64url so it survives the gateway's inbound strip.
|
|
1064
1078
|
*/
|
|
1065
1079
|
export function buildSessionConfigB64(opts) {
|
|
@@ -1099,6 +1113,15 @@ export function resolveCompactionIntercept(opts) {
|
|
|
1099
1113
|
return false;
|
|
1100
1114
|
return raw === "1" || raw === "true" || raw === "on" || raw === "yes";
|
|
1101
1115
|
}
|
|
1116
|
+
/**
|
|
1117
|
+
* Does the gateway measure this launch's saving (`GATEWAY_USAGE_SAVINGS` in the header, and the
|
|
1118
|
+
* status bar in measured mode)? Only with the `measured-savings` experimental feature on, and
|
|
1119
|
+
* only for Claude Code — the one CLI whose status bar reads the measurement. No flag or
|
|
1120
|
+
* launcher.toml key: `mnemom experimental enable measured-savings` is the switch.
|
|
1121
|
+
*/
|
|
1122
|
+
export function resolveUsageSavings(opts) {
|
|
1123
|
+
return opts.experimental.includes("measured-savings") && opts.cliName === "claude";
|
|
1124
|
+
}
|
|
1102
1125
|
/** The name the `handoff` MCP tool server is registered under in `--mcp-config`. */
|
|
1103
1126
|
export const HANDOFF_MCP_SERVER_NAME = "mnemom-handoff";
|
|
1104
1127
|
/**
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* Everything here is pure or dependency-injected; index.ts and the command
|
|
13
13
|
* modules are the thin drivers.
|
|
14
14
|
*/
|
|
15
|
-
export type ExperimentalId = "context-handoff" | "slack" | "implicit" | "compaction-intercept";
|
|
15
|
+
export type ExperimentalId = "context-handoff" | "slack" | "implicit" | "compaction-intercept" | "measured-savings";
|
|
16
16
|
/**
|
|
17
17
|
* Is a `compact_at` / `MNEMOM_COMPACT_AT` value (trimmed, case-insensitive) an ARMED gateway
|
|
18
18
|
* target, as opposed to "no target" (`""`, `"1m"`, `"off"`)? Deliberately duplicates
|
package/dist/lib/experimental.js
CHANGED
|
@@ -69,6 +69,18 @@ export const EXPERIMENTAL_FEATURES = [
|
|
|
69
69
|
env: ["MNEMOM_AGENT_COMPACTION_INTERCEPT"],
|
|
70
70
|
},
|
|
71
71
|
},
|
|
72
|
+
{
|
|
73
|
+
// No flag, launcher.toml key or env of its own: `mnemom experimental enable` is the switch.
|
|
74
|
+
id: "measured-savings",
|
|
75
|
+
summary: "The status bar shows the saving the gateway measured on this session instead of the benchmark estimate",
|
|
76
|
+
since: "0.17.16",
|
|
77
|
+
gates: {
|
|
78
|
+
flags: [],
|
|
79
|
+
configKeys: [],
|
|
80
|
+
commands: [],
|
|
81
|
+
env: [],
|
|
82
|
+
},
|
|
83
|
+
},
|
|
72
84
|
];
|
|
73
85
|
export const EXPERIMENTAL_IDS = EXPERIMENTAL_FEATURES.map((f) => f.id);
|
|
74
86
|
export const EXPERIMENTAL_ENV = "MNEMOM_EXPERIMENTAL";
|
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
-
import { INNER_ENV, render } from "./statusline.js";
|
|
7
|
+
import { INNER_ENV, measuredMode, render } from "./statusline.js";
|
|
8
8
|
const chunks = [];
|
|
9
9
|
process.stdin.on("data", (c) => chunks.push(c));
|
|
10
10
|
process.stdin.on("end", () => {
|
|
11
11
|
const line = render(Buffer.concat(chunks).toString("utf8"), {
|
|
12
12
|
stateDir: join(homedir(), ".mnemom", "statusline"),
|
|
13
13
|
inner: process.env[INNER_ENV] || undefined,
|
|
14
|
+
measured: measuredMode(process.env),
|
|
14
15
|
});
|
|
15
16
|
process.stdout.write(`${line}\n`);
|
|
16
17
|
});
|
package/dist/lib/statusline.d.ts
CHANGED
|
@@ -13,6 +13,15 @@
|
|
|
13
13
|
* keeps each file's byte offset and each message id's contribution, and a tick reads at
|
|
14
14
|
* most MAX_BYTES_PER_TICK of new bytes (a big resumed session catches up over ticks).
|
|
15
15
|
*
|
|
16
|
+
* Measured mode (the `measured-savings` experimental feature; the launch sets
|
|
17
|
+
* MNEMOM_STATUSLINE_MEASURED=1): the gateway measures each request's saving — the stock
|
|
18
|
+
* request's input cost minus what it billed — and writes it into the response usage as
|
|
19
|
+
* `mnemom_savings` inside a `usage.iterations` entry, the one place Claude Code keeps an
|
|
20
|
+
* unknown usage key in its transcript. The counter sums those per message id
|
|
21
|
+
* ("mnemom saved $X this session", no "~": it is measured) and makes no claim before the
|
|
22
|
+
* first one arrives. Its state lives in its own file (<session>.measured.json), so the two
|
|
23
|
+
* modes never share a count.
|
|
24
|
+
*
|
|
16
25
|
* A user's own statusLine is never replaced: the launch passes it in
|
|
17
26
|
* MNEMOM_STATUSLINE_INNER, it runs with the same stdin, and our segment is appended.
|
|
18
27
|
* Read-only on every transcript; no network. It lives outside lib/first-run because it runs
|
|
@@ -24,6 +33,8 @@ export declare const MAX_BYTES_PER_TICK: number;
|
|
|
24
33
|
export declare const INNER_ENV = "MNEMOM_STATUSLINE_INNER";
|
|
25
34
|
/** Set to 1 to launch without the counter. */
|
|
26
35
|
export declare const NO_STATUSLINE_ENV = "MNEMOM_AGENT_NO_STATUSLINE";
|
|
36
|
+
/** The launcher sets this to 1 when the gateway measures the session's saving. */
|
|
37
|
+
export declare const MEASURED_ENV = "MNEMOM_STATUSLINE_MEASURED";
|
|
27
38
|
export declare const INNER_TIMEOUT_MS = 2000;
|
|
28
39
|
export interface SessionState {
|
|
29
40
|
v: 1;
|
|
@@ -35,6 +46,17 @@ export interface SessionState {
|
|
|
35
46
|
saved: number;
|
|
36
47
|
}
|
|
37
48
|
export declare function freshState(): SessionState;
|
|
49
|
+
/** Measured mode's state (<session>.measured.json). */
|
|
50
|
+
export interface MeasuredState {
|
|
51
|
+
v: 1;
|
|
52
|
+
files: Record<string, number>;
|
|
53
|
+
/** Per message id: the gateway's measured saving (USD). A re-written id replaces its old share. */
|
|
54
|
+
ids: Record<string, number>;
|
|
55
|
+
saved: number;
|
|
56
|
+
/** Has any record carried a measurement yet? Until then the segment makes no claim. */
|
|
57
|
+
seen: boolean;
|
|
58
|
+
}
|
|
59
|
+
export declare function freshMeasuredState(): MeasuredState;
|
|
38
60
|
/** The main transcript, then its subagent transcripts (sorted). */
|
|
39
61
|
export declare function sessionFiles(transcriptPath: string): string[];
|
|
40
62
|
/**
|
|
@@ -47,17 +69,38 @@ export declare function readNewLines(path: string, offset: number, maxBytes: num
|
|
|
47
69
|
} | null;
|
|
48
70
|
/** Read what is new in the session's files into `st`. A file that shrank resets the count. */
|
|
49
71
|
export declare function update(st: SessionState, files: string[], maxBytes?: number): SessionState;
|
|
72
|
+
/** A `usage.iterations` entry's measured saving (`mnemom_savings` v1), or null. */
|
|
73
|
+
export declare function measuredSaving(entry: unknown): number | null;
|
|
74
|
+
/**
|
|
75
|
+
* One transcript line → the gateway's measured saving for its request, or null (not an
|
|
76
|
+
* assistant record, or no measurement in its `message.usage.iterations`). `id` is the message
|
|
77
|
+
* id (or record uuid); null when the record carries neither. Claude Code writes one record per
|
|
78
|
+
* content block, all with the same id and usage, so the id is what dedupes them.
|
|
79
|
+
*/
|
|
80
|
+
export declare function parseMeasuredLine(line: Buffer): {
|
|
81
|
+
id: string | null;
|
|
82
|
+
saved: number;
|
|
83
|
+
} | null;
|
|
84
|
+
/** Measured mode's `update`: sums each request's measured saving, replace-by-id. */
|
|
85
|
+
export declare function updateMeasured(st: MeasuredState, files: string[], maxBytes?: number): MeasuredState;
|
|
50
86
|
/** The segment, rounded DOWN to the cent and never below $0.00. */
|
|
51
87
|
export declare function segment(saved: number): string;
|
|
88
|
+
/** Measured mode's segment: no claim until the gateway has measured something; no "~". */
|
|
89
|
+
export declare function measuredSegment(st: MeasuredState): string;
|
|
52
90
|
export declare function loadState(path: string): SessionState;
|
|
53
|
-
export declare function
|
|
91
|
+
export declare function loadMeasuredState(path: string): MeasuredState;
|
|
92
|
+
export declare function saveState(path: string, st: SessionState | MeasuredState): void;
|
|
54
93
|
/** Run the user's own statusLine command with the same stdin; its first line, or "". */
|
|
55
94
|
export declare function runInner(command: string, input: string): string;
|
|
56
95
|
export interface RenderDeps {
|
|
57
96
|
stateDir: string;
|
|
58
97
|
inner?: string;
|
|
98
|
+
/** Measured mode (`measuredMode`): the gateway's measured saving, not the estimate. */
|
|
99
|
+
measured?: boolean;
|
|
59
100
|
runInner?: (command: string, input: string) => string;
|
|
60
101
|
}
|
|
102
|
+
/** Is the counter in measured mode? The launch's MEASURED_ENV, exactly "1". */
|
|
103
|
+
export declare function measuredMode(env: NodeJS.ProcessEnv): boolean;
|
|
61
104
|
/** Claude Code's status JSON → the one status line. Never throws. */
|
|
62
105
|
export declare function render(stdin: string, deps: RenderDeps): string;
|
|
63
106
|
export interface UserStatusLine {
|
|
@@ -72,6 +115,7 @@ export declare function userStatusLine(cwd: string, configDir: string): UserStat
|
|
|
72
115
|
/**
|
|
73
116
|
* Launch args + env that add the counter, or null when it should stay off: the opt-out
|
|
74
117
|
* env, or the user passing their own --settings (two would fight over precedence).
|
|
118
|
+
* `measured` puts the counter in measured mode (MEASURED_ENV).
|
|
75
119
|
*/
|
|
76
120
|
export declare function statuslineLaunch(o: {
|
|
77
121
|
passthrough: readonly string[];
|
|
@@ -80,6 +124,7 @@ export declare function statuslineLaunch(o: {
|
|
|
80
124
|
home?: string;
|
|
81
125
|
node: string;
|
|
82
126
|
bin: string;
|
|
127
|
+
measured?: boolean;
|
|
83
128
|
}): {
|
|
84
129
|
args: string[];
|
|
85
130
|
env: Record<string, string>;
|
package/dist/lib/statusline.js
CHANGED
|
@@ -13,6 +13,15 @@
|
|
|
13
13
|
* keeps each file's byte offset and each message id's contribution, and a tick reads at
|
|
14
14
|
* most MAX_BYTES_PER_TICK of new bytes (a big resumed session catches up over ticks).
|
|
15
15
|
*
|
|
16
|
+
* Measured mode (the `measured-savings` experimental feature; the launch sets
|
|
17
|
+
* MNEMOM_STATUSLINE_MEASURED=1): the gateway measures each request's saving — the stock
|
|
18
|
+
* request's input cost minus what it billed — and writes it into the response usage as
|
|
19
|
+
* `mnemom_savings` inside a `usage.iterations` entry, the one place Claude Code keeps an
|
|
20
|
+
* unknown usage key in its transcript. The counter sums those per message id
|
|
21
|
+
* ("mnemom saved $X this session", no "~": it is measured) and makes no claim before the
|
|
22
|
+
* first one arrives. Its state lives in its own file (<session>.measured.json), so the two
|
|
23
|
+
* modes never share a count.
|
|
24
|
+
*
|
|
16
25
|
* A user's own statusLine is never replaced: the launch passes it in
|
|
17
26
|
* MNEMOM_STATUSLINE_INNER, it runs with the same stdin, and our segment is appended.
|
|
18
27
|
* Read-only on every transcript; no network. It lives outside lib/first-run because it runs
|
|
@@ -30,10 +39,15 @@ export const MAX_BYTES_PER_TICK = 8 * 1024 * 1024;
|
|
|
30
39
|
export const INNER_ENV = "MNEMOM_STATUSLINE_INNER";
|
|
31
40
|
/** Set to 1 to launch without the counter. */
|
|
32
41
|
export const NO_STATUSLINE_ENV = "MNEMOM_AGENT_NO_STATUSLINE";
|
|
42
|
+
/** The launcher sets this to 1 when the gateway measures the session's saving. */
|
|
43
|
+
export const MEASURED_ENV = "MNEMOM_STATUSLINE_MEASURED";
|
|
33
44
|
export const INNER_TIMEOUT_MS = 2000;
|
|
34
45
|
export function freshState() {
|
|
35
46
|
return { v: 1, files: {}, ids: {}, actual: 0, saved: 0 };
|
|
36
47
|
}
|
|
48
|
+
export function freshMeasuredState() {
|
|
49
|
+
return { v: 1, files: {}, ids: {}, saved: 0, seen: false };
|
|
50
|
+
}
|
|
37
51
|
/** The main transcript, then its subagent transcripts (sorted). */
|
|
38
52
|
export function sessionFiles(transcriptPath) {
|
|
39
53
|
const dir = join(transcriptPath.replace(/\.jsonl$/, ""), "subagents");
|
|
@@ -87,8 +101,12 @@ export function readNewLines(path, offset, maxBytes) {
|
|
|
87
101
|
closeSync(fd);
|
|
88
102
|
}
|
|
89
103
|
}
|
|
90
|
-
/**
|
|
91
|
-
|
|
104
|
+
/**
|
|
105
|
+
* The incremental read both modes share: each file's new complete lines, within the tick's
|
|
106
|
+
* byte budget, go to `add` (with the key a record without an id is counted under). A file
|
|
107
|
+
* that shrank or vanished starts over from `fresh()`.
|
|
108
|
+
*/
|
|
109
|
+
function readSession(st, files, maxBytes, fresh, add) {
|
|
92
110
|
let budget = maxBytes;
|
|
93
111
|
for (const f of files) {
|
|
94
112
|
if (budget <= 0)
|
|
@@ -97,38 +115,97 @@ export function update(st, files, maxBytes = MAX_BYTES_PER_TICK) {
|
|
|
97
115
|
const r = readNewLines(f, from, budget);
|
|
98
116
|
if (r === null) {
|
|
99
117
|
if (from > 0)
|
|
100
|
-
return
|
|
118
|
+
return readSession(fresh(), files, maxBytes, fresh, add);
|
|
101
119
|
continue;
|
|
102
120
|
}
|
|
103
121
|
budget -= r.next - from;
|
|
104
122
|
const subagent = f.includes("/subagents/");
|
|
105
|
-
r.lines.forEach((line, i) => {
|
|
106
|
-
const p = parseUsageLine(line, subagent);
|
|
107
|
-
if (!p)
|
|
108
|
-
return;
|
|
109
|
-
const key = p.id ?? `anon-${f}-${from}-${i}`;
|
|
110
|
-
const cost = rowCost(p.row);
|
|
111
|
-
const saved = ruleRate(origContext(p.row)).screen * cost;
|
|
112
|
-
const prev = st.ids[key];
|
|
113
|
-
if (prev) {
|
|
114
|
-
st.actual -= prev[0];
|
|
115
|
-
st.saved -= prev[1];
|
|
116
|
-
}
|
|
117
|
-
st.ids[key] = [cost, saved];
|
|
118
|
-
st.actual += cost;
|
|
119
|
-
st.saved += saved;
|
|
120
|
-
});
|
|
123
|
+
r.lines.forEach((line, i) => add(st, line, subagent, `anon-${f}-${from}-${i}`));
|
|
121
124
|
st.files[f] = r.next;
|
|
122
125
|
}
|
|
123
126
|
return st;
|
|
124
127
|
}
|
|
128
|
+
/** Read what is new in the session's files into `st`. A file that shrank resets the count. */
|
|
129
|
+
export function update(st, files, maxBytes = MAX_BYTES_PER_TICK) {
|
|
130
|
+
return readSession(st, files, maxBytes, freshState, (s, line, subagent, anonKey) => {
|
|
131
|
+
const p = parseUsageLine(line, subagent);
|
|
132
|
+
if (!p)
|
|
133
|
+
return;
|
|
134
|
+
const key = p.id ?? anonKey;
|
|
135
|
+
const cost = rowCost(p.row);
|
|
136
|
+
const saved = ruleRate(origContext(p.row)).screen * cost;
|
|
137
|
+
const prev = s.ids[key];
|
|
138
|
+
if (prev) {
|
|
139
|
+
s.actual -= prev[0];
|
|
140
|
+
s.saved -= prev[1];
|
|
141
|
+
}
|
|
142
|
+
s.ids[key] = [cost, saved];
|
|
143
|
+
s.actual += cost;
|
|
144
|
+
s.saved += saved;
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
const SAVINGS = Buffer.from('"mnemom_savings"');
|
|
148
|
+
const ASSISTANT = Buffer.from('"assistant"');
|
|
149
|
+
/** A `usage.iterations` entry's measured saving (`mnemom_savings` v1), or null. */
|
|
150
|
+
export function measuredSaving(entry) {
|
|
151
|
+
const m = entry?.mnemom_savings;
|
|
152
|
+
return m?.v === 1 && Number.isFinite(m.saved_usd) ? m.saved_usd : null;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* One transcript line → the gateway's measured saving for its request, or null (not an
|
|
156
|
+
* assistant record, or no measurement in its `message.usage.iterations`). `id` is the message
|
|
157
|
+
* id (or record uuid); null when the record carries neither. Claude Code writes one record per
|
|
158
|
+
* content block, all with the same id and usage, so the id is what dedupes them.
|
|
159
|
+
*/
|
|
160
|
+
export function parseMeasuredLine(line) {
|
|
161
|
+
if (line.indexOf(SAVINGS) === -1 || line.indexOf(ASSISTANT) === -1)
|
|
162
|
+
return null;
|
|
163
|
+
const d = parseJson(line);
|
|
164
|
+
if (d?.type !== "assistant")
|
|
165
|
+
return null;
|
|
166
|
+
const m = d.message;
|
|
167
|
+
const iterations = m?.usage?.iterations;
|
|
168
|
+
if (!Array.isArray(iterations))
|
|
169
|
+
return null;
|
|
170
|
+
const saved = iterations.map(measuredSaving).find((v) => v !== null);
|
|
171
|
+
if (saved === undefined)
|
|
172
|
+
return null;
|
|
173
|
+
const id = (typeof m?.id === "string" && m.id) || (typeof d.uuid === "string" && d.uuid) || null;
|
|
174
|
+
return { id, saved };
|
|
175
|
+
}
|
|
176
|
+
/** Measured mode's `update`: sums each request's measured saving, replace-by-id. */
|
|
177
|
+
export function updateMeasured(st, files, maxBytes = MAX_BYTES_PER_TICK) {
|
|
178
|
+
return readSession(st, files, maxBytes, freshMeasuredState, (s, line, _subagent, anonKey) => {
|
|
179
|
+
const p = parseMeasuredLine(line);
|
|
180
|
+
if (!p)
|
|
181
|
+
return;
|
|
182
|
+
const key = p.id ?? anonKey;
|
|
183
|
+
s.saved += p.saved - (s.ids[key] ?? 0);
|
|
184
|
+
s.ids[key] = p.saved;
|
|
185
|
+
s.seen = true;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
/** USD rounded DOWN to the cent and never below 0.00. */
|
|
189
|
+
function cents(usd) {
|
|
190
|
+
return (Math.max(Math.floor(usd * 100 + 1e-9), 0) / 100).toFixed(2);
|
|
191
|
+
}
|
|
125
192
|
/** The segment, rounded DOWN to the cent and never below $0.00. */
|
|
126
193
|
export function segment(saved) {
|
|
127
|
-
|
|
128
|
-
|
|
194
|
+
return `mnemom saved ~$${cents(saved)} this session`;
|
|
195
|
+
}
|
|
196
|
+
/** Measured mode's segment: no claim until the gateway has measured something; no "~". */
|
|
197
|
+
export function measuredSegment(st) {
|
|
198
|
+
return st.seen ? `mnemom saved $${cents(st.saved)} this session` : "mnemom";
|
|
199
|
+
}
|
|
200
|
+
function stateName(sessionId) {
|
|
201
|
+
return sessionId.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
129
202
|
}
|
|
130
203
|
function statePath(stateDir, sessionId) {
|
|
131
|
-
return join(stateDir, `${sessionId
|
|
204
|
+
return join(stateDir, `${stateName(sessionId)}.json`);
|
|
205
|
+
}
|
|
206
|
+
/** Measured mode's own file: a sanitized id has no ".", so it never collides with statePath. */
|
|
207
|
+
function measuredStatePath(stateDir, sessionId) {
|
|
208
|
+
return join(stateDir, `${stateName(sessionId)}.measured.json`);
|
|
132
209
|
}
|
|
133
210
|
export function loadState(path) {
|
|
134
211
|
try {
|
|
@@ -141,6 +218,18 @@ export function loadState(path) {
|
|
|
141
218
|
}
|
|
142
219
|
return freshState();
|
|
143
220
|
}
|
|
221
|
+
export function loadMeasuredState(path) {
|
|
222
|
+
try {
|
|
223
|
+
const v = parseJson(readFileSync(path, "utf8"));
|
|
224
|
+
if (v && v.v === 1 && v.files && v.ids && typeof v.seen === "boolean") {
|
|
225
|
+
return v;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
/* none yet */
|
|
230
|
+
}
|
|
231
|
+
return freshMeasuredState();
|
|
232
|
+
}
|
|
144
233
|
export function saveState(path, st) {
|
|
145
234
|
mkdirSync(join(path, ".."), { recursive: true, mode: 0o700 });
|
|
146
235
|
const tmp = `${path}.${process.pid}.tmp`;
|
|
@@ -162,6 +251,18 @@ export function runInner(command, input) {
|
|
|
162
251
|
return "";
|
|
163
252
|
}
|
|
164
253
|
}
|
|
254
|
+
/** Is the counter in measured mode? The launch's MEASURED_ENV, exactly "1". */
|
|
255
|
+
export function measuredMode(env) {
|
|
256
|
+
return env[MEASURED_ENV] === "1";
|
|
257
|
+
}
|
|
258
|
+
function saveQuietly(path, st) {
|
|
259
|
+
try {
|
|
260
|
+
saveState(path, st);
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
/* an unwritable state dir only means the next tick re-reads */
|
|
264
|
+
}
|
|
265
|
+
}
|
|
165
266
|
/** Claude Code's status JSON → the one status line. Never throws. */
|
|
166
267
|
export function render(stdin, deps) {
|
|
167
268
|
const innerOut = deps.inner ? (deps.runInner ?? runInner)(deps.inner, stdin) : "";
|
|
@@ -170,16 +271,17 @@ export function render(stdin, deps) {
|
|
|
170
271
|
const d = parseJson(stdin);
|
|
171
272
|
const tp = typeof d?.transcript_path === "string" ? d.transcript_path : null;
|
|
172
273
|
const sid = typeof d?.session_id === "string" && d.session_id ? d.session_id : null;
|
|
173
|
-
if (tp && sid) {
|
|
274
|
+
if (tp && sid && deps.measured) {
|
|
275
|
+
const path = measuredStatePath(deps.stateDir, sid);
|
|
276
|
+
const st = updateMeasured(loadMeasuredState(path), sessionFiles(tp));
|
|
277
|
+
seg = measuredSegment(st);
|
|
278
|
+
saveQuietly(path, st);
|
|
279
|
+
}
|
|
280
|
+
else if (tp && sid) {
|
|
174
281
|
const path = statePath(deps.stateDir, sid);
|
|
175
282
|
const st = update(loadState(path), sessionFiles(tp));
|
|
176
283
|
seg = segment(st.saved);
|
|
177
|
-
|
|
178
|
-
saveState(path, st);
|
|
179
|
-
}
|
|
180
|
-
catch {
|
|
181
|
-
/* an unwritable state dir only means the next tick re-reads */
|
|
182
|
-
}
|
|
284
|
+
saveQuietly(path, st);
|
|
183
285
|
}
|
|
184
286
|
}
|
|
185
287
|
catch {
|
|
@@ -219,6 +321,7 @@ const sq = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
|
219
321
|
/**
|
|
220
322
|
* Launch args + env that add the counter, or null when it should stay off: the opt-out
|
|
221
323
|
* env, or the user passing their own --settings (two would fight over precedence).
|
|
324
|
+
* `measured` puts the counter in measured mode (MEASURED_ENV).
|
|
222
325
|
*/
|
|
223
326
|
export function statuslineLaunch(o) {
|
|
224
327
|
if (o.env[NO_STATUSLINE_ENV] === "1")
|
|
@@ -233,8 +336,8 @@ export function statuslineLaunch(o) {
|
|
|
233
336
|
};
|
|
234
337
|
if (user?.padding !== undefined)
|
|
235
338
|
statusLine.padding = user.padding;
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
env
|
|
239
|
-
};
|
|
339
|
+
const env = user ? { [INNER_ENV]: user.command } : {};
|
|
340
|
+
if (o.measured)
|
|
341
|
+
env[MEASURED_ENV] = "1";
|
|
342
|
+
return { args: ["--settings", JSON.stringify({ statusLine })], env };
|
|
240
343
|
}
|