context-carwash 0.2.1 → 0.2.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.2
4
+
5
+ - `carwash stats` leads with a likelier saving and shows the old figure as the most it could be. It counts only the first part of the prompts after each wash, since you would have cleared the old conversation at some point anyway. That share starts at two-thirds and is learned from how far your own Claude Code conversations go, read on your machine. Output the fresh conversation had to read again, which the old one already had, is taken off.
6
+ - `stats --share` sends the likely figure as well as the full one.
7
+
3
8
  ## 0.2.1
4
9
 
5
10
  - Codex support. `install` sets up Codex too when it finds it: three hooks in `~/.codex/hooks.json` and a `$wash` command in `~/.codex/skills/wash`. Codex asks you to trust the hooks once. After `/clear`, the brief loads with your next message. Codex's status line can only show its own items, so the meter is a line after each reply instead, in colour from Codex 0.162.0 on. It is shown to you only and costs no tokens.
package/README.md CHANGED
@@ -87,12 +87,16 @@ Carwash stats, since 3 Oct 2026 (12 days)
87
87
  Briefs written 7 (light 1 · mid 5 · detailed 1)
88
88
  Started fresh with one 6
89
89
  Prompts in fresh sessions 94 (410 requests to Claude)
90
- Context not re-sent ~21M tokens
90
+ Context not re-sent ~11M tokens (at most ~21M tokens)
91
91
  Spent writing briefs ~0.9M tokens
92
- Net saved ~20M tokens
92
+ Net saved ~10M tokens (at most ~20M tokens)
93
93
  ```
94
94
 
95
- Every request to Claude or Codex sends the whole conversation again. After a wash, each request starts from the small brief instead, so Carwash counts the requests you actually made and adds up the difference. Repeated context is charged at about a tenth of the price, so in dollars you're saving roughly a tenth of these numbers. You can also see this inside Claude Code with `/wash stats`.
95
+ Every request to Claude or Codex sends the whole conversation again. After a wash, each request starts from the small brief instead, so Carwash counts the requests you actually made and adds up the difference. Counting stops where the old conversation would have filled up and compacted on its own.
96
+
97
+ You wouldn't have kept going in the old conversation forever, so the main number only counts the first part of the prompts after each wash. That's two-thirds to begin with. Carwash then adjusts it by looking at how far your own past conversations went once they were that big, so if you usually clear around 300k, a wash at 300k counts for less. Anything the fresh conversation had to read again, like a file the old one already had open, is taken off too. "At most" counts every prompt. All of this is worked out on your computer from Claude Code's own history files, and only a few numbers per conversation are kept.
98
+
99
+ Repeated context is charged at about a tenth of the price, so in dollars you're saving roughly a tenth of these numbers. You can also see this inside Claude Code with `/wash stats`.
96
100
 
97
101
  If you run `stats --share`, it shows you exactly what it would send before asking.
98
102
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-carwash",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "A quiet context meter and deliberate fresh-session handoff for Claude Code and Codex.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/cli.mjs CHANGED
@@ -7,8 +7,7 @@ import { UserError } from './install/settings.mjs';
7
7
  import { isNewer, latestVersion, runRelease } from './install/update.mjs';
8
8
  import { errorLogPath, installRecordPath, welcomedPath } from './paths.mjs';
9
9
  import { readJson } from './files.mjs';
10
- import { refreshLedger, totals } from './stats/ledger.mjs';
11
- import { FORM, describeShare, sendShare, shareId, sharePayload, shareReady, statsReport } from './stats/report.mjs';
10
+ import { FORM, describeShare, sendShare, shareId, sharePayload, shareReady, statsReport, currentTotals } from './stats/report.mjs';
12
11
  import { render } from './statusline/render.mjs';
13
12
  import { statusline } from './statusline/run.mjs';
14
13
  import { runHook } from './wash/hooks.mjs';
@@ -169,7 +168,7 @@ export async function main(argv, { env = process.env, stdin = process.stdin, std
169
168
  return 0;
170
169
  }
171
170
  case 'stats': {
172
- const sum = totals(refreshLedger(env, { force: true }));
171
+ const sum = currentTotals(env);
173
172
  stdout.write(statsReport(sum) + '\n');
174
173
  if (!rest.includes('--share')) return 0;
175
174
  stdout.write('\n');
package/src/paths.mjs CHANGED
@@ -60,3 +60,7 @@ export const legacyCommandPath = (env = process.env) => join(claudeConfigDir(env
60
60
  export const codexHooksPath = (env = process.env) => join(codexHome(env), 'hooks.json');
61
61
  export const codexSkillDir = (env = process.env) => join(codexHome(env), 'skills', 'wash');
62
62
  export const codexConfigPath = (env = process.env) => join(codexHome(env), 'config.toml');
63
+
64
+ // What Carwash has learned about how far sessions usually go: a few numbers
65
+ // per transcript, so the transcripts need not be read again.
66
+ export const habitsPath = (env = process.env) => join(carwashHome(env), 'habits.json');
@@ -0,0 +1,107 @@
1
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { readJson, writePrivateJson } from '../files.mjs';
4
+ import { claudeConfigDir, habitsPath } from '../paths.mjs';
5
+ import { DEFAULT_SHARE, isPrompt, sentOf } from './ledger.mjs';
6
+
7
+ // How far this person usually takes a session, learned from their own Claude
8
+ // Code transcripts. A wash saves context only for the prompts that would
9
+ // otherwise have been sent in the old session, and that depends on habit:
10
+ // someone who often clears at 300k would not have gone on for long past it.
11
+ // Everything here stays on this machine; the cache holds only sizes.
12
+
13
+ const PRIOR_WEIGHT = 10; // the starting guess counts as this many sessions
14
+ const SETTLE_MS = 3_600_000; // touched this recently, a session may still be going
15
+ const FIRST_READ_MS = 90 * 24 * 3_600_000; // older transcripts are not read for the first time
16
+
17
+ const WASH = /<command-name>\/wash<\/command-name>(?:\\n)?<command-args>(?:light|mid|detailed)?<\/command-args>/;
18
+
19
+ // A session's shape: its size when each prompt was answered, whether it
20
+ // compacted (so went on to the limit), and whether it ended in a wash.
21
+ export function sessionShape(text) {
22
+ const sizes = [];
23
+ let waiting = false;
24
+ let compacted = false;
25
+ for (const raw of text.split('\n')) {
26
+ if (!raw.includes('"user"') && !raw.includes('"usage"') && !raw.includes('compact_boundary')) continue;
27
+ let line;
28
+ try {
29
+ line = JSON.parse(raw);
30
+ } catch {
31
+ continue;
32
+ }
33
+ if (!line || line.isSidechain) continue;
34
+ if (line.type === 'system' && line.subtype === 'compact_boundary') {
35
+ compacted = true;
36
+ break;
37
+ }
38
+ if (isPrompt(line)) waiting = true;
39
+ else if (waiting && line.type === 'assistant' && line.message?.usage) {
40
+ sizes.push(sentOf(line.message.usage));
41
+ waiting = false;
42
+ }
43
+ }
44
+ return { sizes, compacted, washed: WASH.test(text) };
45
+ }
46
+
47
+ // Bring the cache up to date with Claude Code's transcripts and return every
48
+ // session's shape. Transcripts Claude Code has since deleted stay in the cache.
49
+ export function learnHabits(env, { now = Date.now() } = {}) {
50
+ const file = habitsPath(env);
51
+ const sessions = readJson(file)?.sessions ?? {};
52
+ const root = join(claudeConfigDir(env), 'projects');
53
+ let changed = false;
54
+ let dirs = [];
55
+ try {
56
+ dirs = readdirSync(root);
57
+ } catch {
58
+ // no transcripts
59
+ }
60
+ for (const dir of dirs) {
61
+ let names;
62
+ try {
63
+ names = readdirSync(join(root, dir));
64
+ } catch {
65
+ continue;
66
+ }
67
+ for (const name of names) {
68
+ if (!name.endsWith('.jsonl')) continue;
69
+ const path = join(root, dir, name);
70
+ try {
71
+ const { mtimeMs } = statSync(path);
72
+ if (now - mtimeMs < SETTLE_MS || sessions[path]?.mtime === mtimeMs) continue;
73
+ if (!sessions[path] && now - mtimeMs > FIRST_READ_MS) continue;
74
+ sessions[path] = { mtime: mtimeMs, ...sessionShape(readFileSync(path, 'utf8')) };
75
+ changed = true;
76
+ } catch {
77
+ continue;
78
+ }
79
+ }
80
+ }
81
+ if (changed) {
82
+ try {
83
+ writePrivateJson(file, { version: 1, sessions });
84
+ } catch {
85
+ // learned again next time
86
+ }
87
+ }
88
+ return Object.values(sessions).filter((s) => !s.washed && Array.isArray(s.sizes) && s.sizes.length);
89
+ }
90
+
91
+ // The share of `turns` prompts, sent after washing a session of `before`
92
+ // tokens, that would likely have been sent in that session anyway. Each past
93
+ // session that reached the same size says how many more prompts it went on
94
+ // for; one that compacted went on to the limit. The starting guess holds until
95
+ // enough sessions have reached that size to outweigh it.
96
+ export function shareFor(sessions, before, turns) {
97
+ if (!(turns > 0) || !(before > 0)) return DEFAULT_SHARE;
98
+ let seen = 0;
99
+ let kept = 0;
100
+ for (const { sizes, compacted } of sessions) {
101
+ const at = sizes.findIndex((size) => size >= before);
102
+ if (at < 0) continue;
103
+ seen++;
104
+ kept += compacted ? 1 : Math.min(turns, sizes.length - 1 - at) / turns;
105
+ }
106
+ return (DEFAULT_SHARE * PRIOR_WEIGHT + kept) / (PRIOR_WEIGHT + seen);
107
+ }
@@ -1,7 +1,8 @@
1
1
  import { readFileSync, readdirSync, statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { readJson, writePrivateJson } from '../files.mjs';
4
- import { statsDir } from '../paths.mjs';
4
+ import { estimateTokens } from '../levels.mjs';
5
+ import { consumedDir, statsDir } from '../paths.mjs';
5
6
  import { toolOf } from '../tools.mjs';
6
7
  import { tokenReading } from '../wash/rollout.mjs';
7
8
 
@@ -19,10 +20,18 @@ import { tokenReading } from '../wash/rollout.mjs';
19
20
 
20
21
  const DAY = 24 * 3_600_000;
21
22
 
23
+ // Bumped when measuring changes, so kept measurements are redone.
24
+ const METHOD = 2;
25
+
22
26
  // Roughly where Claude Code compacts a session on its own. Past this point the
23
27
  // old session would have shrunk anyway, so requests beyond it count for nothing.
24
28
  export const AUTO_COMPACT_SHARE = 0.9;
25
29
 
30
+ // The starting guess at how much of the work after a wash would have been
31
+ // done in the old session before it was cleared anyway. habits.mjs adjusts it
32
+ // from the person's own sessions.
33
+ export const DEFAULT_SHARE = 2 / 3;
34
+
26
35
  const keyOf = (record) => `${Date.parse(record.createdAt).toString(36)}-${record.id}`;
27
36
  const fileOf = (env, key) => join(statsDir(env), `${key}.json`);
28
37
 
@@ -37,6 +46,7 @@ export function recordWash(env, record, wash) {
37
46
  used: record.used ?? null,
38
47
  size: wash.size ?? null,
39
48
  tokens: record.tokens,
49
+ source: record.source?.transcriptPath ?? null,
40
50
  attempts: (wash.rewrites ?? 0) + 1,
41
51
  deliveries: [],
42
52
  });
@@ -71,21 +81,81 @@ export function readLedger(env) {
71
81
 
72
82
  // A line of the transcript the user typed, as opposed to a tool result, a
73
83
  // shell command, a slash command's output or a compaction summary.
74
- function isPrompt(line) {
84
+ export function isPrompt(line) {
75
85
  if (line.type !== 'user' || line.isMeta || line.isCompactSummary) return false;
76
86
  const content = line.message?.content;
77
87
  if (typeof content === 'string') return !content.trimStart().startsWith('<');
78
88
  return Array.isArray(content) && content.some((block) => block?.type === 'text') && !content.some((block) => block?.type === 'tool_result');
79
89
  }
80
90
 
91
+ // The size of one request: everything the model was sent.
92
+ export const sentOf = (usage) => (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
93
+
94
+ const blocksOf = (line) => (Array.isArray(line.message?.content) ? line.message.content : []);
95
+
96
+ const resultText = (block) =>
97
+ typeof block.content === 'string' ? block.content : Array.isArray(block.content) ? block.content.map((part) => (part?.type === 'text' ? part.text : '')).join('') : '';
98
+
99
+ // Lines shorter than this are too common to say a file was read again.
100
+ const MIN_LINE = 24;
101
+
102
+ // A line as a tool shows it, without the line number a file read adds.
103
+ const normal = (line) => line.replace(/^\s*\d+(?:\t|→)/, '').trim();
104
+
105
+ function* lines(text) {
106
+ for (const line of text.split('\n')) {
107
+ const clean = normal(line);
108
+ if (clean.length >= MIN_LINE) yield clean;
109
+ }
110
+ }
111
+
112
+ // Every line a session still held from tools and from the files it wrote:
113
+ // whatever came before its last compaction was summarised away. Compared by
114
+ // content, so a file read with cat counts the same as one read with Read.
115
+ export function heldLines(text) {
116
+ const held = new Set();
117
+ for (const raw of text.split('\n')) {
118
+ if (!raw.includes('tool_') && !raw.includes('compact_boundary')) continue;
119
+ let line;
120
+ try {
121
+ line = JSON.parse(raw);
122
+ } catch {
123
+ continue;
124
+ }
125
+ if (!line || line.isSidechain) continue;
126
+ if (line.type === 'system' && line.subtype === 'compact_boundary') held.clear();
127
+ for (const block of blocksOf(line)) {
128
+ const texts = block?.type === 'tool_result' ? [resultText(block)] : block?.type === 'tool_use' ? [block.input?.content, block.input?.new_string] : [];
129
+ for (const text of texts) if (typeof text === 'string') for (const clean of lines(text)) held.add(clean);
130
+ }
131
+ }
132
+ return held;
133
+ }
134
+
135
+ // The tokens of a tool result the old session already held.
136
+ function repeated(text, held) {
137
+ let chars = 0;
138
+ for (const clean of lines(text)) if (held.has(clean)) chars += clean.length + 1;
139
+ return chars ? estimateTokens('x'.repeat(chars)) : 0;
140
+ }
141
+
81
142
  // Count what a fresh session sent after a wash. `before` is the old session's
82
- // size when the brief was written, `size` its context window.
83
- export function measure(text, before, size) {
143
+ // size when the brief was written, `size` its context window, `held` the
144
+ // lines of tool output the old session already had.
145
+ //
146
+ // `turns` breaks the count down by prompt, as [requests, reread]: how many
147
+ // requests each prompt made, and how many of the tokens they sent were tool
148
+ // output the old session already had, such as files read again. The old session already held those, so
149
+ // they are not a saving. `likelyOf` uses this to count only the prompts that
150
+ // would have been sent in the old session anyway.
151
+ export function measure(text, before, size, { held = null } = {}) {
84
152
  const limit = size ? size * AUTO_COMPACT_SHARE : Infinity;
85
153
  const seen = new Set();
154
+ const turns = [];
86
155
  let start = null;
87
156
  let prompts = 0;
88
157
  let requests = 0;
158
+ let reread = 0;
89
159
  for (const raw of text.split('\n')) {
90
160
  let line;
91
161
  try {
@@ -98,6 +168,11 @@ export function measure(text, before, size) {
98
168
  if (line.type === 'system' && line.subtype === 'compact_boundary') break;
99
169
  if (isPrompt(line)) {
100
170
  prompts++;
171
+ turns.push([0, 0]);
172
+ continue;
173
+ }
174
+ if (line.type === 'user') {
175
+ if (held) for (const block of blocksOf(line)) if (block?.type === 'tool_result') reread += repeated(resultText(block), held);
101
176
  continue;
102
177
  }
103
178
  if (line.type !== 'assistant') continue;
@@ -106,13 +181,16 @@ export function measure(text, before, size) {
106
181
  const usage = line.message?.usage;
107
182
  if (!id || !usage || seen.has(id)) continue;
108
183
  seen.add(id);
109
- const sent = (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
184
+ const sent = sentOf(usage);
110
185
  start ??= sent;
111
186
  if (before - start <= 0 || sent + (before - start) > limit) break;
112
187
  requests++;
188
+ if (!turns.length) turns.push([0, 0]);
189
+ turns.at(-1)[0]++;
190
+ turns.at(-1)[1] += reread;
113
191
  }
114
192
  const gap = start === null ? 0 : Math.max(0, before - start);
115
- return { prompts: requests ? prompts : 0, requests, start, saved: gap * requests };
193
+ return { prompts: requests ? prompts : 0, requests, start, saved: gap * requests, turns: turns.filter(([n]) => n > 0) };
116
194
  }
117
195
 
118
196
  // The same count from a Codex session file. A person's prompt is a UserMessage
@@ -122,7 +200,10 @@ export function measure(text, before, size) {
122
200
  export function measureCodex(text, before, size) {
123
201
  const limit = size ? size * AUTO_COMPACT_SHARE : Infinity;
124
202
  const records = text.includes('"token_usage_record"');
203
+ // Newer Codex marks a prompt both ways; count turns by whichever it uses.
204
+ const byItem = text.includes('"UserMessage"');
125
205
  const seen = new Set();
206
+ const turns = [];
126
207
  let thread = null;
127
208
  let start = null;
128
209
  let items = 0;
@@ -142,10 +223,12 @@ export function measureCodex(text, before, size) {
142
223
  if (line.type === 'compacted' || (line.type === 'event_msg' && payload.type === 'context_compacted')) break;
143
224
  if (line.type === 'event_msg' && payload.type === 'item_completed' && payload.item?.type === 'UserMessage') {
144
225
  items++;
226
+ if (byItem) turns.push([0, 0]);
145
227
  continue;
146
228
  }
147
229
  if (line.type === 'event_msg' && payload.type === 'user_message') {
148
230
  events++;
231
+ if (!byItem) turns.push([0, 0]);
149
232
  continue;
150
233
  }
151
234
  let sent = null;
@@ -162,9 +245,11 @@ export function measureCodex(text, before, size) {
162
245
  start ??= sent;
163
246
  if (before - start <= 0 || sent + (before - start) > limit) break;
164
247
  requests++;
248
+ if (!turns.length) turns.push([0, 0]);
249
+ turns.at(-1)[0]++;
165
250
  }
166
251
  const gap = start === null ? 0 : Math.max(0, before - start);
167
- return { prompts: requests ? Math.max(items, events) : 0, requests, start, saved: gap * requests };
252
+ return { prompts: requests ? Math.max(items, events) : 0, requests, start, saved: gap * requests, turns: turns.filter(([n]) => n > 0) };
168
253
  }
169
254
 
170
255
  // Bring each delivery's measurement up to date with its transcript. Without
@@ -176,6 +261,11 @@ export function refreshLedger(env, { now = Date.now(), force = false } = {}) {
176
261
  for (const entry of entries) {
177
262
  if (!(entry.used > 0)) continue;
178
263
  let changed = false;
264
+ if (entry.source === undefined) {
265
+ entry.source = sourceOf(env, entry);
266
+ changed = entry.source !== null;
267
+ }
268
+ let held;
179
269
  for (const delivery of entry.deliveries) {
180
270
  const last = delivery.measured;
181
271
  if (last && !force && now - last.at < DAY) continue;
@@ -185,15 +275,20 @@ export function refreshLedger(env, { now = Date.now(), force = false } = {}) {
185
275
  } catch {
186
276
  continue;
187
277
  }
188
- if (last && last.mtime === mtime) continue;
278
+ // Measured the old way: measure again while the transcript is here.
279
+ if (last && last.mtime === mtime && last.method === METHOD) continue;
189
280
  let text;
190
281
  try {
191
282
  text = readFileSync(delivery.transcript, 'utf8');
192
283
  } catch {
193
284
  continue;
194
285
  }
195
- const count = toolOf(delivery) === 'codex' ? measureCodex : measure;
196
- delivery.measured = { ...count(text, entry.used, entry.size), mtime, at: now };
286
+ if (toolOf(delivery) === 'codex') {
287
+ delivery.measured = { ...measureCodex(text, entry.used, entry.size), method: METHOD, mtime, at: now };
288
+ } else {
289
+ if (held === undefined) held = toolOf(entry) === 'claude' ? oldLines(entry.source) : null;
290
+ delivery.measured = { ...measure(text, entry.used, entry.size, { held }), method: METHOD, mtime, at: now };
291
+ }
197
292
  changed = true;
198
293
  }
199
294
  if (changed) writePrivateJson(fileOf(env, entry.key), entry);
@@ -201,12 +296,49 @@ export function refreshLedger(env, { now = Date.now(), force = false } = {}) {
201
296
  return entries;
202
297
  }
203
298
 
299
+ // Where the washed session's transcript is, for entries written before the
300
+ // ledger kept it: the delivered brief remembers, until it is purged.
301
+ function sourceOf(env, entry) {
302
+ const record = readJson(join(consumedDir(env), `${entry.key.slice(entry.key.indexOf('-') + 1)}.json`));
303
+ return typeof record?.source?.transcriptPath === 'string' ? record.source.transcriptPath : null;
304
+ }
305
+
306
+ function oldLines(path) {
307
+ if (typeof path !== 'string') return null;
308
+ try {
309
+ return heldLines(readFileSync(path, 'utf8'));
310
+ } catch {
311
+ return null;
312
+ }
313
+ }
314
+
315
+ // The saving if only the first `share` of the fresh session's prompts would
316
+ // have been sent in the old session before it was cleared anyway. The
317
+ // measured total counts every prompt; this is the likelier figure. Files read
318
+ // again are taken off. Entries measured before turns were kept fall back to
319
+ // the same share of the total.
320
+ export function likelyOf(measured, share) {
321
+ if (!measured) return 0;
322
+ if (!Array.isArray(measured.turns)) return Math.round(measured.saved * share);
323
+ const gap = measured.requests ? measured.saved / measured.requests : 0;
324
+ const keep = measured.turns.length * share;
325
+ let total = 0;
326
+ measured.turns.forEach(([requests, reread], i) => {
327
+ total += Math.min(1, Math.max(0, keep - i)) * Math.max(0, gap * requests - reread);
328
+ });
329
+ return Math.round(total);
330
+ }
331
+
204
332
  // What writing a brief cost: each attempt re-read the old session and wrote
205
333
  // the brief out.
206
334
  export const costOf = (entry) => (entry.used > 0 ? entry.attempts * (entry.used + entry.tokens) : 0);
207
335
 
208
- export function totals(entries, now = Date.now()) {
209
- const sum = { washes: entries.length, levels: {}, tools: [], delivered: 0, prompts: 0, requests: 0, notResent: 0, briefCost: 0, since: null, days: 0 };
336
+ // `shareOf` says, for a delivery, what share of its prompts would likely have
337
+ // been sent in the old session anyway.
338
+ export function totals(entries, now = Date.now(), { shareOf = () => DEFAULT_SHARE } = {}) {
339
+ const sum = { washes: entries.length, levels: {}, tools: [], delivered: 0, prompts: 0, requests: 0, notResent: 0, likely: 0, share: DEFAULT_SHARE, briefCost: 0, since: null, days: 0 };
340
+ let weighed = 0;
341
+ let turns = 0;
210
342
  for (const entry of entries) {
211
343
  sum.levels[entry.level] = (sum.levels[entry.level] ?? 0) + 1;
212
344
  for (const tool of [toolOf(entry), ...entry.deliveries.map(toolOf)]) if (!sum.tools.includes(tool)) sum.tools.push(tool);
@@ -217,8 +349,14 @@ export function totals(entries, now = Date.now()) {
217
349
  sum.prompts += measured.prompts;
218
350
  sum.requests += measured.requests;
219
351
  sum.notResent += measured.saved;
352
+ const share = shareOf(entry, measured);
353
+ sum.likely += likelyOf(measured, share);
354
+ const count = measured.turns?.length ?? measured.prompts;
355
+ weighed += share * count;
356
+ turns += count;
220
357
  }
221
358
  }
359
+ if (turns) sum.share = weighed / turns;
222
360
  if (entries.length) {
223
361
  sum.since = entries[0].washedAt;
224
362
  sum.days = Math.max(1, Math.ceil((now - Date.parse(sum.since)) / DAY));
@@ -2,6 +2,8 @@ import { randomBytes } from 'node:crypto';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { writeFileAtomic } from '../files.mjs';
4
4
  import { shareIdPath } from '../paths.mjs';
5
+ import { learnHabits, shareFor } from './habits.mjs';
6
+ import { refreshLedger, totals } from './ledger.mjs';
5
7
  import { formatTokens } from '../statusline/render.mjs';
6
8
  import { toolInfo } from '../tools.mjs';
7
9
 
@@ -12,6 +14,16 @@ const LEVEL_ORDER = ['light', 'mid', 'detailed'];
12
14
 
13
15
  const tokens = (n) => `~${formatTokens(n)} tokens`;
14
16
 
17
+ // The ledger measured afresh, with the likely saving judged by this person's
18
+ // own habits.
19
+ export function currentTotals(env, now = Date.now()) {
20
+ const sessions = learnHabits(env, { now });
21
+ const sum = totals(refreshLedger(env, { now, force: true }), now, { shareOf: (entry, measured) => shareFor(sessions, entry.used, measured.turns?.length ?? measured.prompts) });
22
+ return { ...sum, learnedFrom: sessions.length };
23
+ }
24
+
25
+ const percent = (share) => `${Math.round(share * 100)}%`;
26
+
15
27
  // Who the requests went to: Claude, Codex, or both.
16
28
  const modelName = (tools = ['claude']) => (tools.length === 1 ? toolInfo(tools[0]).model : 'the model');
17
29
 
@@ -21,7 +33,9 @@ export function statsReport(sum, { inClaude = false } = {}) {
21
33
  }
22
34
  const since = new Date(sum.since).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
23
35
  const levels = LEVEL_ORDER.filter((name) => sum.levels[name]).map((name) => `${name} ${sum.levels[name]}`).join(' · ');
24
- const net = sum.notResent - sum.briefCost;
36
+ const likely = sum.likely ?? sum.notResent;
37
+ const net = likely - sum.briefCost;
38
+ const most = sum.notResent - sum.briefCost;
25
39
  const row = (label, value) => ` ${label.padEnd(28)}${value}`;
26
40
  const model = modelName(sum.tools);
27
41
  return [
@@ -30,11 +44,12 @@ export function statsReport(sum, { inClaude = false } = {}) {
30
44
  row('Briefs written', `${sum.washes}${levels ? ` (${levels})` : ''}`),
31
45
  row('Started fresh with one', `${sum.delivered}`),
32
46
  row('Prompts in fresh sessions', `${sum.prompts} (${sum.requests} requests to ${model})`),
33
- row('Context not re-sent', tokens(sum.notResent)),
47
+ row('Context not re-sent', `${tokens(likely)} (at most ${tokens(sum.notResent)})`),
34
48
  row('Spent writing briefs', tokens(sum.briefCost)),
35
- row(net >= 0 ? 'Net saved' : 'Net cost so far', tokens(Math.abs(net))),
49
+ row(net >= 0 ? 'Net saved' : 'Net cost so far', `${tokens(Math.abs(net))} (at most ${most >= 0 ? tokens(most) : 'nothing'})`),
36
50
  '',
37
51
  `How it is worked out: every request to ${model} carries the whole conversation. After a wash, each request in the fresh session starts from the brief instead of the old conversation, and the difference is context that was not sent again. Counting stops where the old session would have compacted on its own.`,
52
+ `You would not have gone on forever in the old session, so the main figure counts only the first ${percent(sum.share ?? 2 / 3)} of the prompts after each wash. That share starts at two-thirds and is learned from how far your own sessions go (${sum.learnedFrom ?? 0} so far). Files the fresh session had to read again are taken off. "At most" counts every prompt.`,
38
53
  model === 'Claude'
39
54
  ? 'Claude caches repeated context and charges about a tenth for it, so in money terms the saving is roughly a tenth of these numbers.'
40
55
  : 'Repeated context is cached and charged at about a tenth, so in money terms the saving is roughly a tenth of these numbers.',
@@ -57,6 +72,7 @@ export const FORM = {
57
72
  washes: 'entry.822622585',
58
73
  prompts: 'entry.93311455',
59
74
  notResent: 'entry.1048114223',
75
+ likely: 'entry.1068811941',
60
76
  briefCost: 'entry.164624594',
61
77
  },
62
78
  };
@@ -72,6 +88,7 @@ export function sharePayload(sum, { shareId, version, platform = process.platfor
72
88
  washes: sum.washes,
73
89
  prompts: sum.prompts,
74
90
  notResent: sum.notResent,
91
+ likely: sum.likely ?? sum.notResent,
75
92
  briefCost: sum.briefCost,
76
93
  };
77
94
  }
@@ -83,7 +100,8 @@ const LABELS = {
83
100
  days: 'Days since first brief',
84
101
  washes: 'Briefs written',
85
102
  prompts: 'Prompts in fresh sessions',
86
- notResent: 'Context not re-sent',
103
+ notResent: 'Context not re-sent, at most',
104
+ likely: 'Context not re-sent, likely',
87
105
  briefCost: 'Spent writing briefs',
88
106
  };
89
107
 
@@ -4,9 +4,9 @@ import { startNotice, versionLine } from '../install/notice.mjs';
4
4
  import { isNewer } from '../install/update.mjs';
5
5
  import { DEFAULT_LEVEL, capFor, estimateTokens, isLevel } from '../levels.mjs';
6
6
  import { installRecordPath, welcomedPath } from '../paths.mjs';
7
- import { recordDelivery, recordWash, refreshLedger, totals } from '../stats/ledger.mjs';
7
+ import { recordDelivery, recordWash, refreshLedger } from '../stats/ledger.mjs';
8
8
  import { colorDepth } from '../statusline/color.mjs';
9
- import { statsReport } from '../stats/report.mjs';
9
+ import { currentTotals, statsReport } from '../stats/report.mjs';
10
10
  import { budgetNote, composeContext, describe, helpMessage, loadedMessage, parseReply, pointerMessage, readyMessage, replyMeterMessage, retryNote, stalenessLine, welcomeMessage } from './brief.mjs';
11
11
  import { isTool, toolInfo, toolOf } from '../tools.mjs';
12
12
  import { repoState, worktreeRoot } from './git.mjs';
@@ -93,7 +93,7 @@ function washCommand(payload, args, event, ctx) {
93
93
  if (isLevel(verb)) return begin(payload, verb, event, ctx);
94
94
  if (verb === 'discard') return block(discard(payload, ref, ctx));
95
95
  if (verb === 'load') return block(load(payload, ref, ctx));
96
- if (verb === 'stats') return block(statsReport(totals(refreshLedger(ctx.env, { now: ctx.now, force: true }), ctx.now), { inClaude: true }));
96
+ if (verb === 'stats') return block(statsReport(currentTotals(ctx.env, ctx.now), { inClaude: true }));
97
97
  if (verb === 'help') return block(helpMessage(ctx.usage(payload)?.used ?? null, versionLine(ctx.version, ctx.env), ctx.tool));
98
98
  return block(`${toolInfo(ctx.tool).wash} does not know "${verb}". ${usageLine(ctx.tool)}`);
99
99
  }