@mnemom/mnemom 0.17.4-next.8 → 0.17.5-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.js +16 -0
- package/dist/lib/first-run/backtest.d.ts +8 -0
- package/dist/lib/first-run/backtest.js +46 -36
- package/dist/lib/first-run/index.d.ts +20 -0
- package/dist/lib/first-run/index.js +35 -7
- package/dist/lib/first-run/scope.d.ts +5 -3
- package/dist/lib/first-run/scope.js +5 -3
- package/dist/lib/first-run/screen.d.ts +5 -1
- package/dist/lib/first-run/screen.js +32 -6
- package/dist/lib/statusline-bin.d.ts +5 -0
- package/dist/lib/statusline-bin.js +16 -0
- package/dist/lib/statusline.d.ts +86 -0
- package/dist/lib/statusline.js +240 -0
- package/package.json +1 -1
package/dist/commands/agent.js
CHANGED
|
@@ -31,6 +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
35
|
import { getApiUrl, getGatewayUrl, MNEMOM_DIR } from "../lib/config.js";
|
|
35
36
|
import { createApiKey, getAgentByName } from "../lib/api.js";
|
|
36
37
|
import { MNEMOM_KEY_STORE_FIELD, resolveAgentAuth, resolveSubscriptionMnemomKey, subscriptionChildEnv, subscriptionShape, } from "../lib/subscription-login.js";
|
|
@@ -978,6 +979,21 @@ function launchTerminal(ctx) {
|
|
|
978
979
|
applyGatewayHintHeaders(childEnv);
|
|
979
980
|
if (ctx.options.slack)
|
|
980
981
|
args.push(...slackChannelArgs(sessionName));
|
|
982
|
+
// The status-bar savings counter (lib/statusline.ts). Claude Code only; the
|
|
983
|
+
// user's own statusLine keeps running in front of it.
|
|
984
|
+
if (ctx.cli.name === "claude") {
|
|
985
|
+
const sl = statuslineLaunch({
|
|
986
|
+
passthrough: ctx.options.passthrough ?? [],
|
|
987
|
+
env: childEnv,
|
|
988
|
+
cwd: process.cwd(),
|
|
989
|
+
node: process.execPath,
|
|
990
|
+
bin: join(dirname(fileURLToPath(import.meta.url)), "..", "lib", "statusline-bin.js"),
|
|
991
|
+
});
|
|
992
|
+
if (sl) {
|
|
993
|
+
args.push(...sl.args);
|
|
994
|
+
Object.assign(childEnv, sl.env);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
981
997
|
if (ctx.contextHint) {
|
|
982
998
|
seedContextHintFlag(childEnv);
|
|
983
999
|
childEnv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
|
|
@@ -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
|
-
*
|
|
73
|
-
*
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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) {
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
* backtest the user's last 30 days of Claude Code sessions on this machine and show what
|
|
4
4
|
* Mnemom would have saved. `mnemom agent --first-run` shows it again at any time.
|
|
5
5
|
*
|
|
6
|
+
* Every session in the window is priced (operator 2026-10-02: "count all sessions"),
|
|
7
|
+
* including ones that already ran through Mnemom: their transcripts hold the full,
|
|
8
|
+
* unfolded context the client sent. Only copies of one session are dropped. The governed
|
|
9
|
+
* classification is still run, for the audit counts only.
|
|
10
|
+
*
|
|
6
11
|
* Privacy: READ-ONLY on ~/.claude and every transcript; no network call anywhere in this
|
|
7
12
|
* module tree; only token counts, model ids, timestamps and Claude Code's own limit
|
|
8
13
|
* records are read. Writes only the offer state file and a local audit (both 0600,
|
|
@@ -13,6 +18,7 @@
|
|
|
13
18
|
* Enter / y shows the screen and records acceptance; n skips until the next launch;
|
|
14
19
|
* never stops asking. A non-interactive launch is never asked and writes nothing.
|
|
15
20
|
*/
|
|
21
|
+
import { type ScopeResult, type Session } from "./scope.js";
|
|
16
22
|
import { type Audit, type Variation } from "./screen.js";
|
|
17
23
|
export declare const STATE_FILE = "first-run.json";
|
|
18
24
|
export declare const AUDIT_FILE = "first-run-audit.json";
|
|
@@ -51,6 +57,20 @@ export interface FirstRunResult {
|
|
|
51
57
|
}
|
|
52
58
|
/** Scan, classify, price and build the screen. Read-only on every transcript. */
|
|
53
59
|
export declare function computeFirstRun(opts?: ScanOptions): FirstRunResult;
|
|
60
|
+
/**
|
|
61
|
+
* The sessions the screen prices: every session in the window, one per session id (an
|
|
62
|
+
* ungoverned copy first), none without a parent transcript.
|
|
63
|
+
*/
|
|
64
|
+
export declare function pricedSessions(scope: ScopeResult): Array<{
|
|
65
|
+
s: Session;
|
|
66
|
+
viaMnemom: boolean;
|
|
67
|
+
}>;
|
|
68
|
+
/**
|
|
69
|
+
* Proof a session ran through Mnemom: `mnemom agent` recorded it in the launch ledger.
|
|
70
|
+
* Every other governed signal (an Agent-SDK host, a text marker, a time window) only
|
|
71
|
+
* means it may have, so those sessions are not said to have been saved on.
|
|
72
|
+
*/
|
|
73
|
+
export declare const LEDGER_SIGNALS: Set<string>;
|
|
54
74
|
/** The screen's hard budget: a regression in buildScreen's line dropping must fail loudly. */
|
|
55
75
|
export declare function assertScreenBudget(lines: readonly string[]): void;
|
|
56
76
|
/** A transcript's mtime (ms), 0 when it vanished since discovery (it then sorts first). */
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
* backtest the user's last 30 days of Claude Code sessions on this machine and show what
|
|
4
4
|
* Mnemom would have saved. `mnemom agent --first-run` shows it again at any time.
|
|
5
5
|
*
|
|
6
|
+
* Every session in the window is priced (operator 2026-10-02: "count all sessions"),
|
|
7
|
+
* including ones that already ran through Mnemom: their transcripts hold the full,
|
|
8
|
+
* unfolded context the client sent. Only copies of one session are dropped. The governed
|
|
9
|
+
* classification is still run, for the audit counts only.
|
|
10
|
+
*
|
|
6
11
|
* Privacy: READ-ONLY on ~/.claude and every transcript; no network call anywhere in this
|
|
7
12
|
* module tree; only token counts, model ids, timestamps and Claude Code's own limit
|
|
8
13
|
* records are read. Writes only the offer state file and a local audit (both 0600,
|
|
@@ -95,8 +100,8 @@ export function computeFirstRun(opts = {}) {
|
|
|
95
100
|
const counts = {
|
|
96
101
|
sessions_total: scope.sessions.size,
|
|
97
102
|
sessions_recent: scope.inWindow,
|
|
98
|
-
|
|
99
|
-
|
|
103
|
+
governed_or_doubt: scope.excluded.size,
|
|
104
|
+
ungoverned: scope.kept.size,
|
|
100
105
|
};
|
|
101
106
|
for (const sigs of scope.excluded.values()) {
|
|
102
107
|
for (const s of sigs)
|
|
@@ -104,10 +109,11 @@ export function computeFirstRun(opts = {}) {
|
|
|
104
109
|
}
|
|
105
110
|
// Oldest first, so a message copied into a later (resumed / forked) transcript is billed
|
|
106
111
|
// once, to its origin.
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
.map((x) => x.s)
|
|
112
|
+
const priced = pricedSessions(scope);
|
|
113
|
+
counts.priced = priced.length;
|
|
114
|
+
const ordered = priced
|
|
115
|
+
.map((x) => ({ ...x, m: mtimeOf(x.s.top) }))
|
|
116
|
+
.sort((a, b) => a.m - b.m);
|
|
111
117
|
const seen = new Set();
|
|
112
118
|
const shapes = new Map();
|
|
113
119
|
const sessions = [];
|
|
@@ -117,7 +123,7 @@ export function computeFirstRun(opts = {}) {
|
|
|
117
123
|
// their subscription (lib/subscription-login.ts), so only they get the usage sentence.
|
|
118
124
|
let orgLogin = false;
|
|
119
125
|
const seatInputs = new Map();
|
|
120
|
-
ordered.forEach((s, i) => {
|
|
126
|
+
ordered.forEach(({ s, viaMnemom }, i) => {
|
|
121
127
|
if (i % 25 === 0)
|
|
122
128
|
opts.progress?.(`pricing sessions ${i + 1}/${ordered.length}`);
|
|
123
129
|
const paths = [s.top, ...[...s.subs].sort()];
|
|
@@ -154,6 +160,7 @@ export function computeFirstRun(opts = {}) {
|
|
|
154
160
|
end,
|
|
155
161
|
actual: e.actual,
|
|
156
162
|
saved: e.saved,
|
|
163
|
+
viaMnemom,
|
|
157
164
|
};
|
|
158
165
|
sessions.push(sc);
|
|
159
166
|
if (cls === "subscription_org")
|
|
@@ -178,6 +185,27 @@ export function computeFirstRun(opts = {}) {
|
|
|
178
185
|
};
|
|
179
186
|
return { lines, audit, variation, counts, seconds: (Date.now() - t0) / 1000 };
|
|
180
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* The sessions the screen prices: every session in the window, one per session id (an
|
|
190
|
+
* ungoverned copy first), none without a parent transcript.
|
|
191
|
+
*/
|
|
192
|
+
export function pricedSessions(scope) {
|
|
193
|
+
const out = new Map();
|
|
194
|
+
for (const key of [...scope.kept.keys(), ...scope.excluded.keys()]) {
|
|
195
|
+
const s = scope.sessions.get(key);
|
|
196
|
+
if (!s.top || out.has(s.sid))
|
|
197
|
+
continue;
|
|
198
|
+
const sigs = scope.excluded.get(key) ?? [];
|
|
199
|
+
out.set(s.sid, { s, viaMnemom: sigs.some((x) => LEDGER_SIGNALS.has(x)) });
|
|
200
|
+
}
|
|
201
|
+
return [...out.values()];
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Proof a session ran through Mnemom: `mnemom agent` recorded it in the launch ledger.
|
|
205
|
+
* Every other governed signal (an Agent-SDK host, a text marker, a time window) only
|
|
206
|
+
* means it may have, so those sessions are not said to have been saved on.
|
|
207
|
+
*/
|
|
208
|
+
export const LEDGER_SIGNALS = new Set(["registry_cc_id", "registry_conv_id"]);
|
|
181
209
|
/** The screen's hard budget: a regression in buildScreen's line dropping must fail loudly. */
|
|
182
210
|
export function assertScreenBudget(lines) {
|
|
183
211
|
if (lines.length > MAX_LINES || lines.some((l) => l.length > WIDTH)) {
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
* Which local Claude Code sessions the first-run look may count, and how each was billed.
|
|
3
3
|
*
|
|
4
4
|
* Operator rulings (2026-09-30 / 2026-10-01), binding:
|
|
5
|
-
* -
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* - "Governed" (already ran through Mnemom, or in doubt) is classified here and
|
|
6
|
+
* recorded in the audit; since 2026-10-02 it no longer removes a session from the
|
|
7
|
+
* priced set (index.ts pricedSessions: every session is counted). A headless
|
|
8
|
+
* `claude -p` session (entrypoint sdk-cli) is marked: the env of the script that
|
|
9
|
+
* launched it (ANTHROPIC_BASE_URL, a proxy) is never recorded.
|
|
8
10
|
* - Sessions are discovered in ~/.claude/projects and every ~/.claude-*\/projects.
|
|
9
11
|
* - Dollars are shown for API-billed sessions; Team / Enterprise subscription sessions
|
|
10
12
|
* (the plans `mnemom agent --auth subscription` supports) are in scope as usage-limit
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
* Which local Claude Code sessions the first-run look may count, and how each was billed.
|
|
3
3
|
*
|
|
4
4
|
* Operator rulings (2026-09-30 / 2026-10-01), binding:
|
|
5
|
-
* -
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* - "Governed" (already ran through Mnemom, or in doubt) is classified here and
|
|
6
|
+
* recorded in the audit; since 2026-10-02 it no longer removes a session from the
|
|
7
|
+
* priced set (index.ts pricedSessions: every session is counted). A headless
|
|
8
|
+
* `claude -p` session (entrypoint sdk-cli) is marked: the env of the script that
|
|
9
|
+
* launched it (ANTHROPIC_BASE_URL, a proxy) is never recorded.
|
|
8
10
|
* - Sessions are discovered in ~/.claude/projects and every ~/.claude-*\/projects.
|
|
9
11
|
* - Dollars are shown for API-billed sessions; Team / Enterprise subscription sessions
|
|
10
12
|
* (the plans `mnemom agent --auth subscription` supports) are in scope as usage-limit
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* two lines make the same claim:
|
|
9
9
|
* A saving the shown saving is >= $5 and >= 5%: the usage at API prices, with Mnemom,
|
|
10
10
|
* the saving, the biggest session; plus ONE subscription sentence for a Team /
|
|
11
|
-
* Enterprise subscriber whose usage cut rounds to >= 5%.
|
|
11
|
+
* Enterprise subscriber whose usage cut rounds to >= 5%. Where sessions ran
|
|
12
|
+
* through Mnemom (launch ledger), that part is said to be SAVED: what it
|
|
13
|
+
* saved you, and what it would have saved on the rest (operator 2026-10-02).
|
|
12
14
|
* B small >= $5 of usage at API prices but a smaller saving: the usage, the honest
|
|
13
15
|
* "wouldn't have changed that much" line, and the forward line.
|
|
14
16
|
* C none too little history: the forward line only.
|
|
@@ -64,6 +66,8 @@ export interface SessionCost {
|
|
|
64
66
|
end: number;
|
|
65
67
|
actual: number;
|
|
66
68
|
saved: number;
|
|
69
|
+
/** The launch ledger proves it ran through Mnemom: its saving is said to be SAVED. */
|
|
70
|
+
viaMnemom?: boolean;
|
|
67
71
|
}
|
|
68
72
|
/** A Team / Enterprise subscriber's usage, at API prices (local evidence only). */
|
|
69
73
|
export interface SubUsage {
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* two lines make the same claim:
|
|
9
9
|
* A saving the shown saving is >= $5 and >= 5%: the usage at API prices, with Mnemom,
|
|
10
10
|
* the saving, the biggest session; plus ONE subscription sentence for a Team /
|
|
11
|
-
* Enterprise subscriber whose usage cut rounds to >= 5%.
|
|
11
|
+
* Enterprise subscriber whose usage cut rounds to >= 5%. Where sessions ran
|
|
12
|
+
* through Mnemom (launch ledger), that part is said to be SAVED: what it
|
|
13
|
+
* saved you, and what it would have saved on the rest (operator 2026-10-02).
|
|
12
14
|
* B small >= $5 of usage at API prices but a smaller saving: the usage, the honest
|
|
13
15
|
* "wouldn't have changed that much" line, and the forward line.
|
|
14
16
|
* C none too little history: the forward line only.
|
|
@@ -160,11 +162,33 @@ export function buildScreen(inp) {
|
|
|
160
162
|
L.push("");
|
|
161
163
|
if (variation === "A") {
|
|
162
164
|
A("spent_usd", { shown: a, raw: actual, method: SPENT_METHOD });
|
|
163
|
-
A("saved_usd", { shown: s, raw: saved, method: SAVED_METHOD });
|
|
164
|
-
A("with_mnemom_usd", { shown: e, raw: actual - saved, method: "spent shown - saved shown" });
|
|
165
165
|
A("saved_pct", { shown: p, raw: (100 * saved) / actual, method: "rounded DOWN to 5s" });
|
|
166
|
-
|
|
167
|
-
|
|
166
|
+
const viaRaw = ses.reduce((t, r) => t + (r.viaMnemom ? r.saved : 0), 0);
|
|
167
|
+
const via = sig2(viaRaw);
|
|
168
|
+
const rest = sig2(saved - viaRaw);
|
|
169
|
+
if (via < 1) {
|
|
170
|
+
A("saved_usd", { shown: s, raw: saved, method: SAVED_METHOD });
|
|
171
|
+
A("with_mnemom_usd", { shown: e, raw: actual - saved, method: "spent shown - saved shown" });
|
|
172
|
+
L.push(...wrap(`At API prices that usage cost about ${usd(a)}. With Mnemom: about ${usd(e)}.`));
|
|
173
|
+
L.push(...wrap(`That's about ${usd(s)} less (${p}%), and you don't change how you work.`));
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
const m = `${SAVED_METHOD}; sessions the launch ledger shows ran through Mnemom`;
|
|
177
|
+
A("saved_via_mnemom_usd", { shown: via, raw: viaRaw, method: m });
|
|
178
|
+
L.push(...wrap(`At API prices that usage comes to about ${usd(a)} without Mnemom.`));
|
|
179
|
+
if (rest < 1) {
|
|
180
|
+
L.push(...wrap(`Mnemom saved you about ${usd(via)} of it (${p}%), and you didn't change how you work.`));
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
A("saved_rest_usd", {
|
|
184
|
+
shown: rest,
|
|
185
|
+
raw: saved - viaRaw,
|
|
186
|
+
method: `${SAVED_METHOD}; the rest`,
|
|
187
|
+
});
|
|
188
|
+
L.push(...wrap(`Mnemom saved you about ${usd(via)} of it, and would have saved about ${usd(rest)} ` +
|
|
189
|
+
"more on the sessions that didn't run through it."));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
168
192
|
// A has a saving, so the session with the largest saving exists and saved something.
|
|
169
193
|
const big = ses.reduce((m, r) => (r.saved > m.saved ? r : m));
|
|
170
194
|
const [ba, , be] = pair(big.actual, big.saved);
|
|
@@ -179,7 +203,9 @@ export function buildScreen(inp) {
|
|
|
179
203
|
method: "its cost shown - its saving (2 sig figs DOWN)",
|
|
180
204
|
});
|
|
181
205
|
const where = big.label ? `${big.label} on ${day(big.end)}` : `on ${day(big.end)}`;
|
|
182
|
-
L.push(...wrap(
|
|
206
|
+
L.push(...wrap(big.viaMnemom
|
|
207
|
+
? `Your biggest session, ${where}: ${usd(ba)} without Mnemom, about ${usd(be)} with it.`
|
|
208
|
+
: `Your biggest session, ${where}: ${usd(ba)}, with Mnemom about ${usd(be)}.`));
|
|
183
209
|
L.push(...subLines(inp.sub, A));
|
|
184
210
|
}
|
|
185
211
|
else {
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `mnemom agent` status-bar savings counter: a Claude Code statusLine command that
|
|
3
|
+
* prints "mnemom saved ~$X this session".
|
|
4
|
+
*
|
|
5
|
+
* The number is the first-run look's estimator (first-run/backtest.ts), applied live to this
|
|
6
|
+
* session's own transcript: each request priced at API list prices, times the empirical
|
|
7
|
+
* curve's lower-bound saving rate for its original context. So the counter and the
|
|
8
|
+
* first-run screen always agree, and it rounds DOWN to the cent.
|
|
9
|
+
*
|
|
10
|
+
* Claude Code runs the command on every status update with its status JSON on stdin
|
|
11
|
+
* (session_id, transcript_path, …). It must be fast, so the transcript is read
|
|
12
|
+
* incrementally: per session, a state file (~/.mnemom/statusline/<session>.json, 0600)
|
|
13
|
+
* keeps each file's byte offset and each message id's contribution, and a tick reads at
|
|
14
|
+
* most MAX_BYTES_PER_TICK of new bytes (a big resumed session catches up over ticks).
|
|
15
|
+
*
|
|
16
|
+
* A user's own statusLine is never replaced: the launch passes it in
|
|
17
|
+
* MNEMOM_STATUSLINE_INNER, it runs with the same stdin, and our segment is appended.
|
|
18
|
+
* Read-only on every transcript; no network. It lives outside lib/first-run because it runs
|
|
19
|
+
* the user's own statusLine command, and the first-run code is held to spawning nothing.
|
|
20
|
+
*/
|
|
21
|
+
export declare const STATE_DIR = "statusline";
|
|
22
|
+
export declare const MAX_BYTES_PER_TICK: number;
|
|
23
|
+
/** The launcher passes the user's own statusLine command here. */
|
|
24
|
+
export declare const INNER_ENV = "MNEMOM_STATUSLINE_INNER";
|
|
25
|
+
/** Set to 1 to launch without the counter. */
|
|
26
|
+
export declare const NO_STATUSLINE_ENV = "MNEMOM_AGENT_NO_STATUSLINE";
|
|
27
|
+
export declare const INNER_TIMEOUT_MS = 2000;
|
|
28
|
+
export interface SessionState {
|
|
29
|
+
v: 1;
|
|
30
|
+
/** Byte offset read so far, per transcript file. */
|
|
31
|
+
files: Record<string, number>;
|
|
32
|
+
/** Per message id: [list-price cost, saving]. A re-written id replaces its old share. */
|
|
33
|
+
ids: Record<string, [number, number]>;
|
|
34
|
+
actual: number;
|
|
35
|
+
saved: number;
|
|
36
|
+
}
|
|
37
|
+
export declare function freshState(): SessionState;
|
|
38
|
+
/** The main transcript, then its subagent transcripts (sorted). */
|
|
39
|
+
export declare function sessionFiles(transcriptPath: string): string[];
|
|
40
|
+
/**
|
|
41
|
+
* The complete lines of `path` from `offset`, at most `maxBytes` of them, and the offset
|
|
42
|
+
* after the last complete line. null: the file is unreadable or shorter than `offset`.
|
|
43
|
+
*/
|
|
44
|
+
export declare function readNewLines(path: string, offset: number, maxBytes: number): {
|
|
45
|
+
lines: Buffer[];
|
|
46
|
+
next: number;
|
|
47
|
+
} | null;
|
|
48
|
+
/** Read what is new in the session's files into `st`. A file that shrank resets the count. */
|
|
49
|
+
export declare function update(st: SessionState, files: string[], maxBytes?: number): SessionState;
|
|
50
|
+
/** The segment, rounded DOWN to the cent and never below $0.00. */
|
|
51
|
+
export declare function segment(saved: number): string;
|
|
52
|
+
export declare function loadState(path: string): SessionState;
|
|
53
|
+
export declare function saveState(path: string, st: SessionState): void;
|
|
54
|
+
/** Run the user's own statusLine command with the same stdin; its first line, or "". */
|
|
55
|
+
export declare function runInner(command: string, input: string): string;
|
|
56
|
+
export interface RenderDeps {
|
|
57
|
+
stateDir: string;
|
|
58
|
+
inner?: string;
|
|
59
|
+
runInner?: (command: string, input: string) => string;
|
|
60
|
+
}
|
|
61
|
+
/** Claude Code's status JSON → the one status line. Never throws. */
|
|
62
|
+
export declare function render(stdin: string, deps: RenderDeps): string;
|
|
63
|
+
export interface UserStatusLine {
|
|
64
|
+
command: string;
|
|
65
|
+
padding?: number;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The statusLine Claude Code would use without us: the first settings file (highest
|
|
69
|
+
* precedence first) that sets one. Only a `command` statusLine can be wrapped.
|
|
70
|
+
*/
|
|
71
|
+
export declare function userStatusLine(cwd: string, configDir: string): UserStatusLine | null;
|
|
72
|
+
/**
|
|
73
|
+
* Launch args + env that add the counter, or null when it should stay off: the opt-out
|
|
74
|
+
* env, or the user passing their own --settings (two would fight over precedence).
|
|
75
|
+
*/
|
|
76
|
+
export declare function statuslineLaunch(o: {
|
|
77
|
+
passthrough: readonly string[];
|
|
78
|
+
env: NodeJS.ProcessEnv;
|
|
79
|
+
cwd: string;
|
|
80
|
+
home?: string;
|
|
81
|
+
node: string;
|
|
82
|
+
bin: string;
|
|
83
|
+
}): {
|
|
84
|
+
args: string[];
|
|
85
|
+
env: Record<string, string>;
|
|
86
|
+
} | null;
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `mnemom agent` status-bar savings counter: a Claude Code statusLine command that
|
|
3
|
+
* prints "mnemom saved ~$X this session".
|
|
4
|
+
*
|
|
5
|
+
* The number is the first-run look's estimator (first-run/backtest.ts), applied live to this
|
|
6
|
+
* session's own transcript: each request priced at API list prices, times the empirical
|
|
7
|
+
* curve's lower-bound saving rate for its original context. So the counter and the
|
|
8
|
+
* first-run screen always agree, and it rounds DOWN to the cent.
|
|
9
|
+
*
|
|
10
|
+
* Claude Code runs the command on every status update with its status JSON on stdin
|
|
11
|
+
* (session_id, transcript_path, …). It must be fast, so the transcript is read
|
|
12
|
+
* incrementally: per session, a state file (~/.mnemom/statusline/<session>.json, 0600)
|
|
13
|
+
* keeps each file's byte offset and each message id's contribution, and a tick reads at
|
|
14
|
+
* most MAX_BYTES_PER_TICK of new bytes (a big resumed session catches up over ticks).
|
|
15
|
+
*
|
|
16
|
+
* A user's own statusLine is never replaced: the launch passes it in
|
|
17
|
+
* MNEMOM_STATUSLINE_INNER, it runs with the same stdin, and our segment is appended.
|
|
18
|
+
* Read-only on every transcript; no network. It lives outside lib/first-run because it runs
|
|
19
|
+
* the user's own statusLine command, and the first-run code is held to spawning nothing.
|
|
20
|
+
*/
|
|
21
|
+
import { execSync } from "node:child_process";
|
|
22
|
+
import { closeSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, writeFileSync, } from "node:fs";
|
|
23
|
+
import { homedir } from "node:os";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { origContext, parseUsageLine, rowCost, ruleRate } from "./first-run/backtest.js";
|
|
26
|
+
import { parseJson } from "./first-run/lines.js";
|
|
27
|
+
export const STATE_DIR = "statusline";
|
|
28
|
+
export const MAX_BYTES_PER_TICK = 8 * 1024 * 1024;
|
|
29
|
+
/** The launcher passes the user's own statusLine command here. */
|
|
30
|
+
export const INNER_ENV = "MNEMOM_STATUSLINE_INNER";
|
|
31
|
+
/** Set to 1 to launch without the counter. */
|
|
32
|
+
export const NO_STATUSLINE_ENV = "MNEMOM_AGENT_NO_STATUSLINE";
|
|
33
|
+
export const INNER_TIMEOUT_MS = 2000;
|
|
34
|
+
export function freshState() {
|
|
35
|
+
return { v: 1, files: {}, ids: {}, actual: 0, saved: 0 };
|
|
36
|
+
}
|
|
37
|
+
/** The main transcript, then its subagent transcripts (sorted). */
|
|
38
|
+
export function sessionFiles(transcriptPath) {
|
|
39
|
+
const dir = join(transcriptPath.replace(/\.jsonl$/, ""), "subagents");
|
|
40
|
+
let subs = [];
|
|
41
|
+
try {
|
|
42
|
+
subs = readdirSync(dir)
|
|
43
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
44
|
+
.sort()
|
|
45
|
+
.map((f) => join(dir, f));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
/* no subagents */
|
|
49
|
+
}
|
|
50
|
+
return [transcriptPath, ...subs];
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The complete lines of `path` from `offset`, at most `maxBytes` of them, and the offset
|
|
54
|
+
* after the last complete line. null: the file is unreadable or shorter than `offset`.
|
|
55
|
+
*/
|
|
56
|
+
export function readNewLines(path, offset, maxBytes) {
|
|
57
|
+
let fd;
|
|
58
|
+
try {
|
|
59
|
+
fd = openSync(path, "r");
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const size = fstatSync(fd).size;
|
|
66
|
+
if (size < offset)
|
|
67
|
+
return null;
|
|
68
|
+
const n = Math.min(size - offset, maxBytes);
|
|
69
|
+
if (n <= 0)
|
|
70
|
+
return { lines: [], next: offset };
|
|
71
|
+
const buf = Buffer.allocUnsafe(n);
|
|
72
|
+
const got = readSync(fd, buf, 0, n, offset);
|
|
73
|
+
const view = buf.subarray(0, got);
|
|
74
|
+
const last = view.lastIndexOf(0x0a);
|
|
75
|
+
// One line longer than the whole budget: skip past it rather than stall forever.
|
|
76
|
+
if (last === -1)
|
|
77
|
+
return { lines: [], next: got === maxBytes ? offset + got : offset };
|
|
78
|
+
const lines = [];
|
|
79
|
+
let start = 0;
|
|
80
|
+
for (let nl = view.indexOf(0x0a); nl !== -1 && nl <= last; nl = view.indexOf(0x0a, start)) {
|
|
81
|
+
lines.push(view.subarray(start, nl));
|
|
82
|
+
start = nl + 1;
|
|
83
|
+
}
|
|
84
|
+
return { lines, next: offset + last + 1 };
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
closeSync(fd);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** Read what is new in the session's files into `st`. A file that shrank resets the count. */
|
|
91
|
+
export function update(st, files, maxBytes = MAX_BYTES_PER_TICK) {
|
|
92
|
+
let budget = maxBytes;
|
|
93
|
+
for (const f of files) {
|
|
94
|
+
if (budget <= 0)
|
|
95
|
+
break;
|
|
96
|
+
const from = st.files[f] ?? 0;
|
|
97
|
+
const r = readNewLines(f, from, budget);
|
|
98
|
+
if (r === null) {
|
|
99
|
+
if (from > 0)
|
|
100
|
+
return update(freshState(), files, maxBytes);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
budget -= r.next - from;
|
|
104
|
+
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
|
+
});
|
|
121
|
+
st.files[f] = r.next;
|
|
122
|
+
}
|
|
123
|
+
return st;
|
|
124
|
+
}
|
|
125
|
+
/** The segment, rounded DOWN to the cent and never below $0.00. */
|
|
126
|
+
export function segment(saved) {
|
|
127
|
+
const cents = Math.max(Math.floor(saved * 100 + 1e-9), 0);
|
|
128
|
+
return `mnemom saved ~$${(cents / 100).toFixed(2)} this session`;
|
|
129
|
+
}
|
|
130
|
+
function statePath(stateDir, sessionId) {
|
|
131
|
+
return join(stateDir, `${sessionId.replace(/[^A-Za-z0-9_-]/g, "_")}.json`);
|
|
132
|
+
}
|
|
133
|
+
export function loadState(path) {
|
|
134
|
+
try {
|
|
135
|
+
const v = parseJson(readFileSync(path, "utf8"));
|
|
136
|
+
if (v && v.v === 1 && v.files && v.ids)
|
|
137
|
+
return v;
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
/* none yet */
|
|
141
|
+
}
|
|
142
|
+
return freshState();
|
|
143
|
+
}
|
|
144
|
+
export function saveState(path, st) {
|
|
145
|
+
mkdirSync(join(path, ".."), { recursive: true, mode: 0o700 });
|
|
146
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
147
|
+
writeFileSync(tmp, JSON.stringify(st), { mode: 0o600 });
|
|
148
|
+
renameSync(tmp, path);
|
|
149
|
+
}
|
|
150
|
+
/** Run the user's own statusLine command with the same stdin; its first line, or "". */
|
|
151
|
+
export function runInner(command, input) {
|
|
152
|
+
try {
|
|
153
|
+
const out = execSync(command, {
|
|
154
|
+
input,
|
|
155
|
+
encoding: "utf8",
|
|
156
|
+
timeout: INNER_TIMEOUT_MS,
|
|
157
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
158
|
+
});
|
|
159
|
+
return (out.split("\n")[0] ?? "").trimEnd();
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return "";
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** Claude Code's status JSON → the one status line. Never throws. */
|
|
166
|
+
export function render(stdin, deps) {
|
|
167
|
+
const innerOut = deps.inner ? (deps.runInner ?? runInner)(deps.inner, stdin) : "";
|
|
168
|
+
let seg = "mnemom";
|
|
169
|
+
try {
|
|
170
|
+
const d = parseJson(stdin);
|
|
171
|
+
const tp = typeof d?.transcript_path === "string" ? d.transcript_path : null;
|
|
172
|
+
const sid = typeof d?.session_id === "string" && d.session_id ? d.session_id : null;
|
|
173
|
+
if (tp && sid) {
|
|
174
|
+
const path = statePath(deps.stateDir, sid);
|
|
175
|
+
const st = update(loadState(path), sessionFiles(tp));
|
|
176
|
+
seg = segment(st.saved);
|
|
177
|
+
try {
|
|
178
|
+
saveState(path, st);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
/* an unwritable state dir only means the next tick re-reads */
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
/* the counter never breaks the status bar */
|
|
187
|
+
}
|
|
188
|
+
return innerOut ? `${innerOut} · ${seg}` : seg;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The statusLine Claude Code would use without us: the first settings file (highest
|
|
192
|
+
* precedence first) that sets one. Only a `command` statusLine can be wrapped.
|
|
193
|
+
*/
|
|
194
|
+
export function userStatusLine(cwd, configDir) {
|
|
195
|
+
for (const p of [
|
|
196
|
+
join(cwd, ".claude", "settings.local.json"),
|
|
197
|
+
join(cwd, ".claude", "settings.json"),
|
|
198
|
+
join(configDir, "settings.json"),
|
|
199
|
+
]) {
|
|
200
|
+
let d;
|
|
201
|
+
try {
|
|
202
|
+
d = parseJson(readFileSync(p, "utf8"));
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const sl = d?.statusLine;
|
|
208
|
+
if (!sl || typeof sl !== "object")
|
|
209
|
+
continue;
|
|
210
|
+
if (sl.type !== "command" || typeof sl.command !== "string" || !sl.command)
|
|
211
|
+
return null;
|
|
212
|
+
return typeof sl.padding === "number"
|
|
213
|
+
? { command: sl.command, padding: sl.padding }
|
|
214
|
+
: { command: sl.command };
|
|
215
|
+
}
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
const sq = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
219
|
+
/**
|
|
220
|
+
* Launch args + env that add the counter, or null when it should stay off: the opt-out
|
|
221
|
+
* env, or the user passing their own --settings (two would fight over precedence).
|
|
222
|
+
*/
|
|
223
|
+
export function statuslineLaunch(o) {
|
|
224
|
+
if (o.env[NO_STATUSLINE_ENV] === "1")
|
|
225
|
+
return null;
|
|
226
|
+
if (o.passthrough.some((a) => a === "--settings" || a.startsWith("--settings=")))
|
|
227
|
+
return null;
|
|
228
|
+
const configDir = o.env.CLAUDE_CONFIG_DIR || join(o.home ?? homedir(), ".claude");
|
|
229
|
+
const user = userStatusLine(o.cwd, configDir);
|
|
230
|
+
const statusLine = {
|
|
231
|
+
type: "command",
|
|
232
|
+
command: `${sq(o.node)} ${sq(o.bin)}`,
|
|
233
|
+
};
|
|
234
|
+
if (user?.padding !== undefined)
|
|
235
|
+
statusLine.padding = user.padding;
|
|
236
|
+
return {
|
|
237
|
+
args: ["--settings", JSON.stringify({ statusLine })],
|
|
238
|
+
env: user ? { [INNER_ENV]: user.command } : {},
|
|
239
|
+
};
|
|
240
|
+
}
|