@mnemom/mnemom 0.17.15 → 0.17.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -0
- package/dist/commands/agent-invite.d.ts +17 -2
- package/dist/commands/agent-invite.js +48 -5
- package/dist/commands/agent-sessions.d.ts +56 -1
- package/dist/commands/agent-sessions.js +196 -26
- package/dist/commands/agent.d.ts +6 -0
- package/dist/commands/agent.js +24 -6
- package/dist/index.js +21 -2
- package/dist/lib/agent.d.ts +56 -1
- package/dist/lib/agent.js +60 -6
- package/dist/lib/api.d.ts +18 -0
- 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 +2 -2
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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mnemom/mnemom",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.16",
|
|
4
4
|
"description": "Transparent AI agent tracing",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"chalk": "^5.3.0",
|
|
22
22
|
"commander": "^12.0.0",
|
|
23
23
|
"js-yaml": "^4.3.2",
|
|
24
|
-
"smol-toml": "^1.
|
|
24
|
+
"smol-toml": "^1.9.0"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@types/js-yaml": "^4.0.9",
|