context-carwash 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,8 @@ import { homedir } from 'node:os';
3
3
  import { delimiter, dirname, join, resolve, sep } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { privateDir, readJson, writeFileAtomic } from '../files.mjs';
6
- import { carwashHome, installRecordPath, legacyCommandPath, pendingDir, settingsPath, skillDir } from '../paths.mjs';
6
+ import { carwashHome, claudeConfigDir, installRecordPath, legacyCommandPath, pendingDir, settingsPath, skillDir } from '../paths.mjs';
7
+ import { applyCodexInstall, applyCodexUninstall, codexDetected, describeCodexInstall, describeCodexUninstall, planCodexInstall, planCodexUninstall } from './codex.mjs';
7
8
  import { UserError, readSettings, resolveSettingsFile, serializeSettings, sha256 } from './settings.mjs';
8
9
 
9
10
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
@@ -85,8 +86,9 @@ export const isCarwashHook = runsCarwash('hook');
85
86
 
86
87
  // The `hooks` setting with Carwash's own entries taken out. An event's list,
87
88
  // or the whole setting, goes too if it only existed to hold ours. Returns the
88
- // same object when there was nothing of ours in it.
89
- export function withoutCarwashHooks(hooks) {
89
+ // same object when there was nothing of ours in it. `isOurs` tells our hook
90
+ // commands apart; Codex's hooks file has the same shape as Claude Code's.
91
+ export function withoutCarwashHooks(hooks, isOurs = isCarwashHook) {
90
92
  if (!isPlainObject(hooks)) return hooks;
91
93
  const next = {};
92
94
  let found = false;
@@ -97,13 +99,13 @@ export function withoutCarwashHooks(hooks) {
97
99
  }
98
100
  const kept = [];
99
101
  for (const group of groups) {
100
- const ours = Array.isArray(group?.hooks) && group.hooks.some((hook) => isCarwashHook(hook?.command));
102
+ const ours = Array.isArray(group?.hooks) && group.hooks.some((hook) => isOurs(hook?.command));
101
103
  if (!ours) {
102
104
  kept.push(group);
103
105
  continue;
104
106
  }
105
107
  found = true;
106
- const theirs = group.hooks.filter((hook) => !isCarwashHook(hook?.command));
108
+ const theirs = group.hooks.filter((hook) => !isOurs(hook?.command));
107
109
  if (theirs.length) kept.push({ ...group, hooks: theirs });
108
110
  }
109
111
  if (kept.length || kept.length === groups.length) next[event] = kept;
@@ -155,13 +157,38 @@ function copyTree(from, to) {
155
157
  }
156
158
  }
157
159
 
160
+ // Whether an install record covers Claude Code. Records from before Codex
161
+ // support do not list their tools, and were all Claude Code.
162
+ const claudeRecorded = (record) => Boolean(record) && (!Array.isArray(record.tools) || record.tools.includes('claude'));
163
+
164
+ // Which tools an install covers. Codex when it is set up here. Claude Code when
165
+ // it is set up here or was installed before, and always when Codex is not
166
+ // there: that was the whole install before Codex support.
167
+ export function detectTools(env = process.env, record = readJson(installRecordPath(env))) {
168
+ const codex = codexDetected(env);
169
+ const claude = existsSync(claudeConfigDir(env)) || claudeRecorded(record) || !codex;
170
+ return { claude, codex };
171
+ }
172
+
158
173
  // Work out what installing would change, without changing anything.
159
174
  export function planInstall({ env = process.env, platform = process.platform } = {}) {
160
175
  const home = carwashHome(env);
161
176
  const appDir = join(home, 'app');
177
+ const record = readJson(installRecordPath(env));
178
+ const tools = detectTools(env, record);
179
+ const script = { nodePath: stableNodePath(env), scriptPath: join(appDir, 'bin', 'carwash.mjs'), platform };
180
+ // The Node the commands will run with. Windows finds `node` on PATH instead.
181
+ const node = platform === 'win32' ? null : script.nodePath;
182
+ const codex = tools.codex ? planCodexInstall({ env, script, previous: record?.codex ?? null }) : null;
183
+ if (!tools.claude) return { home, appDir, record, node, claude: false, codex };
184
+ return { ...planClaudeInstall({ env, record: claudeRecorded(record) ? record : null, script }), home, appDir, record, node, claude: true, codex };
185
+ }
186
+
187
+ // The Claude Code half of an install: the status line, hooks and /wash.
188
+ // `record` is the install record when it covers Claude Code.
189
+ function planClaudeInstall({ env, record, script }) {
162
190
  const file = resolveSettingsFile(settingsPath(env));
163
191
  const settings = readSettings(file);
164
- const record = readJson(installRecordPath(env));
165
192
  const current = settings.data.statusLine;
166
193
 
167
194
  let original = null;
@@ -179,7 +206,6 @@ export function planInstall({ env = process.env, platform = process.platform } =
179
206
  }
180
207
  }
181
208
 
182
- const script = { nodePath: stableNodePath(env), scriptPath: join(appDir, 'bin', 'carwash.mjs'), platform };
183
209
  const theirHooks = withoutCarwashHooks(settings.data.hooks);
184
210
  const next = {
185
211
  ...settings.data,
@@ -188,16 +214,30 @@ export function planInstall({ env = process.env, platform = process.platform } =
188
214
  };
189
215
  const skill = planSkill(env);
190
216
 
191
- // The Node the commands will run with. Windows finds `node` on PATH instead.
192
- const node = platform === 'win32' ? null : script.nodePath;
193
-
194
- return { home, appDir, file, settings, record, original, alreadyInstalled, next, skill, node, keepsHooks: isPlainObject(theirHooks) && Object.keys(theirHooks).length > 0 };
217
+ return { file, settings, claudeRecord: record, original, alreadyInstalled, next, skill, keepsHooks: isPlainObject(theirHooks) && Object.keys(theirHooks).length > 0 };
195
218
  }
196
219
 
197
220
  export function describeInstall(plan) {
198
221
  const lines = ['Context Carwash will make these changes:', ''];
199
222
  lines.push(` 1. Copy its files to ${tilde(plan.appDir)}`);
200
- lines.push(` 2. ${plan.settings.exists ? 'Edit' : 'Create'} ${tilde(plan.file)}`);
223
+ if (plan.claude) lines.push(...describeClaudeInstall(plan));
224
+ if (plan.codex) lines.push(...describeCodexInstall(plan.codex, plan.claude ? 4 : 2, plan.home));
225
+ const model = plan.claude && plan.codex ? 'Claude or Codex' : plan.codex ? 'Codex' : 'Claude';
226
+ lines.push('', `Writing a brief is an ordinary ${model} reply, so it uses tokens like any other.`);
227
+ // Version managers such as nvm keep a folder per Node version, so removing
228
+ // an old version takes this path with it, and nothing of Carwash can start.
229
+ if (plan.node) lines.push(`Carwash runs with the Node at ${tilde(plan.node)}. If you remove that Node later, for example an old version in nvm, run install again.`);
230
+ lines.push('Nothing else is touched. To undo everything: carwash uninstall');
231
+ return lines.join('\n');
232
+ }
233
+
234
+ // Steps 2 and 3: Claude Code. Labelled with its name only when Codex is in the
235
+ // list too.
236
+ function describeClaudeInstall(plan) {
237
+ const lines = [];
238
+ const label = plan.codex ? 'Claude Code: ' : '';
239
+ const verb = plan.settings.exists ? 'Edit' : 'Create';
240
+ lines.push(` 2. ${label}${label ? verb.toLowerCase() : verb} ${tilde(plan.file)}`);
201
241
  if (plan.alreadyInstalled) {
202
242
  lines.push(' The Carwash status line is already there; its command is refreshed.');
203
243
  } else if (plan.original) {
@@ -210,20 +250,15 @@ export function describeInstall(plan) {
210
250
  }
211
251
  lines.push(' Adds three hooks, so /wash can save a brief and a fresh session can');
212
252
  lines.push(` pick it up.${plan.keepsHooks ? ' The hooks you already have are kept as they are.' : ''}`);
213
- if (plan.settings.exists && !plan.record) {
253
+ if (plan.settings.exists && !plan.claudeRecord) {
214
254
  lines.push(` A backup of the file is kept in ${tilde(join(plan.home, 'backup'))}`);
215
255
  }
216
- lines.push(` 3. Add the /wash command at ${tilde(plan.skill.dir)}`);
217
- lines.push('', 'Writing a brief is an ordinary Claude reply, so it uses tokens like any other.');
218
- // Version managers such as nvm keep a folder per Node version, so removing
219
- // an old version takes this path with it, and nothing of Carwash can start.
220
- if (plan.node) lines.push(`Carwash runs with the Node at ${tilde(plan.node)}. If you remove that Node later, for example an old version in nvm, run install again.`);
221
- lines.push('Nothing else is touched. To undo everything: carwash uninstall');
222
- return lines.join('\n');
256
+ lines.push(` 3. ${label ? `${label}add` : 'Add'} the /wash command at ${tilde(plan.skill.dir)}`);
257
+ return lines;
223
258
  }
224
259
 
225
260
  export function applyInstall(plan) {
226
- const { home, appDir, file, settings, record, skill } = plan;
261
+ const { home, appDir, record } = plan;
227
262
  privateDir(home);
228
263
 
229
264
  // Self-contained copy, so the status line keeps working if the package that
@@ -236,6 +271,20 @@ export function applyInstall(plan) {
236
271
  }
237
272
  }
238
273
 
274
+ const tools = [...(plan.claude ? ['claude'] : []), ...(plan.codex ? ['codex'] : [])];
275
+ const codex = plan.codex ? applyCodexInstall(plan.codex, home) : null;
276
+ const base = { version: packageVersion(), installedAt: record?.installedAt ?? new Date().toISOString() };
277
+ // Only the tools other than Claude Code are listed, so a Claude Code install
278
+ // records exactly what it did before Codex support.
279
+ const extra = { ...(tools.length === 1 && tools[0] === 'claude' ? {} : { tools }), ...(codex ? { codex } : {}) };
280
+ const nextRecord = plan.claude ? { ...base, ...applyClaudeInstall(plan), ...extra } : { ...base, ...extra };
281
+ writeFileSync(installRecordPath({ CARWASH_HOME: home }), JSON.stringify(nextRecord, null, 2) + '\n', { mode: 0o600 });
282
+ return nextRecord;
283
+ }
284
+
285
+ // Write the Claude Code half, and return what the install record keeps about it.
286
+ function applyClaudeInstall(plan) {
287
+ const { home, file, settings, claudeRecord: record, skill } = plan;
239
288
  mkdirSync(skill.dir, { recursive: true });
240
289
  copyFileSync(SKILL_SOURCE, skill.file);
241
290
 
@@ -255,9 +304,7 @@ export function applyInstall(plan) {
255
304
  // promise is off for good and uninstall edits our own keys instead.
256
305
  const pristine = !record || (settings.exists && record.installedSha256 === sha256(settings.bytes));
257
306
 
258
- const nextRecord = {
259
- version: packageVersion(),
260
- installedAt: record?.installedAt ?? new Date().toISOString(),
307
+ return {
261
308
  settingsPath: file,
262
309
  hadSettingsFile: record ? record.hadSettingsFile : settings.exists,
263
310
  backupPath,
@@ -266,8 +313,6 @@ export function applyInstall(plan) {
266
313
  statusLine: { original: plan.original },
267
314
  skillPath: skill.file,
268
315
  };
269
- writeFileSync(installRecordPath({ CARWASH_HOME: home }), JSON.stringify(nextRecord, null, 2) + '\n', { mode: 0o600 });
270
- return nextRecord;
271
316
  }
272
317
 
273
318
  function countBriefs(env) {
@@ -281,9 +326,13 @@ function countBriefs(env) {
281
326
  // Work out what uninstalling would change, without changing anything.
282
327
  export function planUninstall({ env = process.env } = {}) {
283
328
  const home = carwashHome(env);
284
- const record = readJson(installRecordPath(env));
329
+ const installRecord = readJson(installRecordPath(env));
330
+ // What install noted about Claude Code. An install for Codex alone has
331
+ // nothing to say about it, so Claude Code's settings are only checked for
332
+ // Carwash entries, as without a record.
333
+ const record = claudeRecorded(installRecord) ? installRecord : null;
285
334
  const file = resolveSettingsFile(record?.settingsPath ?? settingsPath(env));
286
- const plan = { home, file, record, action: 'none', note: null, bytes: null };
335
+ const plan = { home, file, record: installRecord, action: 'none', note: null, bytes: null };
287
336
 
288
337
  if (existsSync(file)) {
289
338
  const current = readFileSync(file);
@@ -325,6 +374,12 @@ export function planUninstall({ env = process.env } = {}) {
325
374
  plan.skillDir = dirname(skillFile);
326
375
  plan.skill = isOurSkill(skillFile) ? skillFile : null;
327
376
  plan.foreignSkill = !plan.skill && existsSync(skillFile);
377
+ // Claude Code is in the list unless install never touched it and there is
378
+ // nothing of Carwash's there now.
379
+ plan.claude = Boolean(record) || !installRecord || plan.action !== 'none' || Boolean(plan.skill);
380
+
381
+ plan.codex = planCodexUninstall({ env, record: installRecord?.codex ?? null });
382
+ if (!plan.codex.found && !installRecord?.codex) plan.codex = null;
328
383
 
329
384
  // Only ever delete a directory that is recognisably ours, and never the
330
385
  // home directory or a filesystem root, whatever CARWASH_HOME says.
@@ -343,9 +398,13 @@ export function describeUninstall(plan) {
343
398
  edit: `Edit ${tilde(plan.file)}: undo Carwash's status line and hooks; your other changes stay`,
344
399
  none: `Leave ${tilde(plan.file)} alone`,
345
400
  }[plan.action];
346
- const steps = [[what, plan.note]];
347
- if (plan.skill) steps.push([`Remove the /wash command at ${tilde(plan.skillDir)}`]);
348
- else if (plan.foreignSkill) steps.push([`Leave the /wash command at ${tilde(plan.skillDir)} alone (not Carwash's)`]);
401
+ const steps = [];
402
+ if (plan.claude) {
403
+ steps.push([what, plan.note]);
404
+ if (plan.skill) steps.push([`Remove the /wash command at ${tilde(plan.skillDir)}`]);
405
+ else if (plan.foreignSkill) steps.push([`Leave the /wash command at ${tilde(plan.skillDir)} alone (not Carwash's)`]);
406
+ }
407
+ if (plan.codex) steps.push(...describeCodexUninstall(plan.codex));
349
408
  const briefs = plan.briefs ? `, including ${plan.briefs} waiting ${plan.briefs === 1 ? 'brief' : 'briefs'}` : '';
350
409
  steps.push([plan.removeHome ? `Delete ${tilde(plan.home)}${briefs}` : `Leave ${tilde(plan.home)} alone (not a Carwash folder)`]);
351
410
  steps.forEach(([step, note], i) => {
@@ -366,5 +425,6 @@ export function applyUninstall(plan) {
366
425
  // the folder holds something else, which stays
367
426
  }
368
427
  }
428
+ if (plan.codex) applyCodexUninstall(plan.codex);
369
429
  if (plan.removeHome) rmSync(plan.home, { recursive: true, force: true });
370
430
  }
package/src/paths.mjs CHANGED
@@ -11,6 +11,11 @@ export function claudeConfigDir(env = process.env) {
11
11
  return env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
12
12
  }
13
13
 
14
+ // Codex's own home directory, honouring its documented override.
15
+ export function codexHome(env = process.env) {
16
+ return env.CODEX_HOME || join(homedir(), '.codex');
17
+ }
18
+
14
19
  export function settingsPath(env = process.env) {
15
20
  return join(claudeConfigDir(env), 'settings.json');
16
21
  }
@@ -43,9 +48,15 @@ export const errorsSeenPath = (env = process.env) => join(carwashHome(env), 'err
43
48
  // the author put on this one.
44
49
  export const noticePath = (env = process.env) => join(carwashHome(env), 'notice.json');
45
50
 
46
- // Present once the first-run welcome has been shown.
47
- export const welcomedPath = (env = process.env) => join(carwashHome(env), 'welcomed');
51
+ // Present once the first-run welcome has been shown. Each tool says hello once.
52
+ export const welcomedPath = (env = process.env, tool = 'claude') => join(carwashHome(env), tool === 'claude' ? 'welcomed' : `welcomed-${tool}`);
48
53
 
49
54
  // Where the /wash command is installed.
50
55
  export const skillDir = (env = process.env) => join(claudeConfigDir(env), 'skills', 'wash');
51
56
  export const legacyCommandPath = (env = process.env) => join(claudeConfigDir(env), 'commands', 'wash.md');
57
+
58
+ // Where Carwash goes in Codex: its hooks file, the $wash skill, and the config
59
+ // file Codex keeps hook approvals in (read, never written).
60
+ export const codexHooksPath = (env = process.env) => join(codexHome(env), 'hooks.json');
61
+ export const codexSkillDir = (env = process.env) => join(codexHome(env), 'skills', 'wash');
62
+ export const codexConfigPath = (env = process.env) => join(codexHome(env), 'config.toml');
@@ -0,0 +1,47 @@
1
+ ---
2
+ name: wash
3
+ description: Context Carwash. Writes a short handoff brief of this session when the user types $wash, so that /clear can start a fresh session that carries on from it. Use only when the user's message starts with $wash.
4
+ metadata:
5
+ installed-by: context-carwash
6
+ ---
7
+
8
+ The user typed $wash. Context Carwash saves your reply as a handoff brief, but only while its hooks are running. When they are, they add a developer message to this turn that starts with "Carwash: level".
9
+
10
+ **First, look for that message.** If this turn has no developer message starting with "Carwash: level", the hooks are not running and a brief would be lost. Then do not write a brief. Reply with only this: "Carwash's hooks aren't running in Codex yet, so no brief was saved. Restart Codex and choose "Trust all and continue" when it asks about hooks, then run $wash again. If that doesn't help, run `npx context-carwash doctor` in a terminal."
11
+
12
+ When the message is there: the user wants to start a fresh session that picks up where this one leaves off. Your whole reply to this message is saved as the handoff brief, and it is the first thing the next session reads. That session cannot see this conversation, so anything the brief leaves out is gone.
13
+
14
+ The words after $wash in the user's message are the arguments. The first word is the level: `light`, `mid`, or `detailed`. No arguments means `mid`. Anything after the level is the user's note on what to emphasise.
15
+
16
+ Reply with the brief and nothing else: no preamble, no closing remark, no code fence around it. Do not call tools or read files to prepare. Write from what is already in this conversation.
17
+
18
+ ## Format
19
+
20
+ Start with exactly this heading, then the Core section:
21
+
22
+ ```
23
+ # Carwash brief: <the task, in under 60 characters>
24
+
25
+ ## Core
26
+ - Objective: what the user is trying to get done, and how they will know it is done
27
+ - State: where things stand right now
28
+ - Next: the one concrete action to take first
29
+ ```
30
+
31
+ Core must stand on its own in under 1,000 characters. A session that reads only Core should still start in the right place.
32
+
33
+ After Core, add only the sections for the level, and skip any that would be empty:
34
+
35
+ - `light`: Core only. Add one line for a critical constraint if breaking it would undo the work.
36
+ - `mid`: **Constraints** (the user's corrections and preferences that still apply), **Decisions** (what was settled, with the reason in a clause), **State** (files and branches changed, commands and tests run with their results, uncommitted work, blockers), **Pitfalls** (what to avoid, and approaches that failed).
37
+ - `detailed`: everything in `mid`, plus **Rationale** for the main decisions, **History** (experiments worth knowing about and what each one showed), **Test results**, **Risks and open assumptions**, and **Steps** (a checklist of what remains).
38
+
39
+ ## Rules
40
+
41
+ - Write the smallest brief that lets a fresh session continue, and stop when it is complete. The token ceiling given with this command is a limit, not a target. Most briefs should end well under it. Never pad.
42
+ - This is a brief for continuing a task, not a summary of the conversation. Leave out greetings, repetition, plans that were replaced, and raw tool output.
43
+ - Leave out what the next session is given anyway: your standing instructions, project memory and AGENTS.md contents, tool descriptions, and the environment or git status shown at the start of this session.
44
+ - Point to files, diffs, and tests by path, with a line number or function name where that helps. Do not copy their contents.
45
+ - Keep confirmed facts apart from assumptions, and label the assumptions.
46
+ - Never invent a test result, and never say a file was inspected unless it was read in this conversation.
47
+ - Never copy credentials, tokens, keys, or other secrets.
@@ -0,0 +1,3 @@
1
+ # Context Carwash: $wash runs only when typed, never because a prompt seems to fit.
2
+ policy:
3
+ allow_implicit_invocation: false
@@ -2,16 +2,20 @@ import { readFileSync, readdirSync, statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { readJson, writePrivateJson } from '../files.mjs';
4
4
  import { statsDir } from '../paths.mjs';
5
+ import { toolOf } from '../tools.mjs';
6
+ import { tokenReading } from '../wash/rollout.mjs';
5
7
 
6
8
  // The savings ledger. Briefs themselves are deleted within days, so each one
7
9
  // written leaves a small entry here: its level, what writing it cost, and the
8
10
  // fresh sessions it was delivered to. Nothing in it leaves this machine unless
9
11
  // the user runs `carwash stats --share` and says yes, and then only totals.
10
12
  //
11
- // What a wash saves is measured, not assumed. Every request Claude Code sends
12
- // carries the whole conversation. A fresh session's transcript records the
13
- // size of each request, so for every request after a wash the saving is the
14
- // gap between the old session's size and the fresh session's starting size.
13
+ // What a wash saves is measured, not assumed. Every request Claude Code or
14
+ // Codex sends carries the whole conversation. A fresh session's transcript
15
+ // records the size of each request, so for every request after a wash the
16
+ // saving is the gap between the old session's size and the fresh session's
17
+ // starting size. Each tool writes its transcript its own way, so each has its
18
+ // own reader below; the arithmetic is the same.
15
19
 
16
20
  const DAY = 24 * 3_600_000;
17
21
 
@@ -27,6 +31,7 @@ export function recordWash(env, record, wash) {
27
31
  const key = keyOf(record);
28
32
  writePrivateJson(fileOf(env, key), {
29
33
  key,
34
+ ...(toolOf(record) === 'claude' ? {} : { tool: toolOf(record) }),
30
35
  level: record.level,
31
36
  washedAt: record.createdAt,
32
37
  used: record.used ?? null,
@@ -37,12 +42,14 @@ export function recordWash(env, record, wash) {
37
42
  });
38
43
  }
39
44
 
40
- // A brief was handed to a fresh session.
41
- export function recordDelivery(env, record, payload, now = Date.now()) {
45
+ // A brief was handed to a fresh session, in `tool`. A brief asked for by id
46
+ // can be delivered in the other tool, so each delivery says whose transcript
47
+ // it is.
48
+ export function recordDelivery(env, record, payload, now = Date.now(), tool = 'claude') {
42
49
  const file = fileOf(env, keyOf(record));
43
50
  const entry = readJson(file);
44
51
  if (!entry || typeof payload.transcript_path !== 'string') return;
45
- entry.deliveries.push({ sessionId: payload.session_id ?? null, transcript: payload.transcript_path, at: new Date(now).toISOString(), measured: null });
52
+ entry.deliveries.push({ sessionId: payload.session_id ?? null, ...(tool === 'claude' ? {} : { tool }), transcript: payload.transcript_path, at: new Date(now).toISOString(), measured: null });
46
53
  writePrivateJson(file, entry);
47
54
  }
48
55
 
@@ -108,6 +115,58 @@ export function measure(text, before, size) {
108
115
  return { prompts: requests ? prompts : 0, requests, start, saved: gap * requests };
109
116
  }
110
117
 
118
+ // The same count from a Codex session file. A person's prompt is a UserMessage
119
+ // item (or, from older Codex, a user_message event), and each request leaves a
120
+ // token_usage_record (or, from older Codex, a token_count event). Requests made
121
+ // by subagents are left out, as Claude Code's sidechains are above.
122
+ export function measureCodex(text, before, size) {
123
+ const limit = size ? size * AUTO_COMPACT_SHARE : Infinity;
124
+ const records = text.includes('"token_usage_record"');
125
+ const seen = new Set();
126
+ let thread = null;
127
+ let start = null;
128
+ let items = 0;
129
+ let events = 0;
130
+ let requests = 0;
131
+ for (const raw of text.split('\n')) {
132
+ let line;
133
+ try {
134
+ line = JSON.parse(raw);
135
+ } catch {
136
+ continue;
137
+ }
138
+ if (!line || typeof line !== 'object') continue;
139
+ const payload = line.payload && typeof line.payload === 'object' ? line.payload : {};
140
+ if (line.type === 'session_meta') thread ??= payload.id ?? payload.session_id ?? null;
141
+ // Compacted: the comparison with the old session no longer holds.
142
+ if (line.type === 'compacted' || (line.type === 'event_msg' && payload.type === 'context_compacted')) break;
143
+ if (line.type === 'event_msg' && payload.type === 'item_completed' && payload.item?.type === 'UserMessage') {
144
+ items++;
145
+ continue;
146
+ }
147
+ if (line.type === 'event_msg' && payload.type === 'user_message') {
148
+ events++;
149
+ continue;
150
+ }
151
+ let sent = null;
152
+ if (records && line.type === 'token_usage_record') {
153
+ if (thread && payload.thread_id && payload.thread_id !== thread) continue;
154
+ const id = payload.response_id;
155
+ if (id && seen.has(id)) continue;
156
+ if (id) seen.add(id);
157
+ sent = payload.usage?.input_tokens;
158
+ } else if (!records) {
159
+ sent = tokenReading(line)?.used;
160
+ }
161
+ if (!Number.isFinite(sent) || sent <= 0) continue;
162
+ start ??= sent;
163
+ if (before - start <= 0 || sent + (before - start) > limit) break;
164
+ requests++;
165
+ }
166
+ const gap = start === null ? 0 : Math.max(0, before - start);
167
+ return { prompts: requests ? Math.max(items, events) : 0, requests, start, saved: gap * requests };
168
+ }
169
+
111
170
  // Bring each delivery's measurement up to date with its transcript. Without
112
171
  // `force`, a measurement less than a day old is left alone, so a session start
113
172
  // does not reread transcripts every time. A transcript that has gone (Claude
@@ -133,7 +192,8 @@ export function refreshLedger(env, { now = Date.now(), force = false } = {}) {
133
192
  } catch {
134
193
  continue;
135
194
  }
136
- delivery.measured = { ...measure(text, entry.used, entry.size), mtime, at: now };
195
+ const count = toolOf(delivery) === 'codex' ? measureCodex : measure;
196
+ delivery.measured = { ...count(text, entry.used, entry.size), mtime, at: now };
137
197
  changed = true;
138
198
  }
139
199
  if (changed) writePrivateJson(fileOf(env, entry.key), entry);
@@ -146,9 +206,10 @@ export function refreshLedger(env, { now = Date.now(), force = false } = {}) {
146
206
  export const costOf = (entry) => (entry.used > 0 ? entry.attempts * (entry.used + entry.tokens) : 0);
147
207
 
148
208
  export function totals(entries, now = Date.now()) {
149
- const sum = { washes: entries.length, levels: {}, delivered: 0, prompts: 0, requests: 0, notResent: 0, briefCost: 0, since: null, days: 0 };
209
+ const sum = { washes: entries.length, levels: {}, tools: [], delivered: 0, prompts: 0, requests: 0, notResent: 0, briefCost: 0, since: null, days: 0 };
150
210
  for (const entry of entries) {
151
211
  sum.levels[entry.level] = (sum.levels[entry.level] ?? 0) + 1;
212
+ for (const tool of [toolOf(entry), ...entry.deliveries.map(toolOf)]) if (!sum.tools.includes(tool)) sum.tools.push(tool);
152
213
  sum.briefCost += costOf(entry);
153
214
  if (entry.deliveries.length) sum.delivered++;
154
215
  for (const { measured } of entry.deliveries) {
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs';
3
3
  import { writeFileAtomic } from '../files.mjs';
4
4
  import { shareIdPath } from '../paths.mjs';
5
5
  import { formatTokens } from '../statusline/render.mjs';
6
+ import { toolInfo } from '../tools.mjs';
6
7
 
7
8
  // `carwash stats` and `/wash stats`: the ledger's totals in words, and the
8
9
  // anonymous share that `carwash stats --share` sends after asking.
@@ -11,6 +12,9 @@ const LEVEL_ORDER = ['light', 'mid', 'detailed'];
11
12
 
12
13
  const tokens = (n) => `~${formatTokens(n)} tokens`;
13
14
 
15
+ // Who the requests went to: Claude, Codex, or both.
16
+ const modelName = (tools = ['claude']) => (tools.length === 1 ? toolInfo(tools[0]).model : 'the model');
17
+
14
18
  export function statsReport(sum, { inClaude = false } = {}) {
15
19
  if (!sum.washes) {
16
20
  return 'Carwash stats: no briefs written yet. Once you have used /wash and /clear, this shows how much context they kept from being sent again.';
@@ -19,18 +23,21 @@ export function statsReport(sum, { inClaude = false } = {}) {
19
23
  const levels = LEVEL_ORDER.filter((name) => sum.levels[name]).map((name) => `${name} ${sum.levels[name]}`).join(' · ');
20
24
  const net = sum.notResent - sum.briefCost;
21
25
  const row = (label, value) => ` ${label.padEnd(28)}${value}`;
26
+ const model = modelName(sum.tools);
22
27
  return [
23
28
  `Carwash stats, since ${since} (${sum.days} day${sum.days === 1 ? '' : 's'})`,
24
29
  '',
25
30
  row('Briefs written', `${sum.washes}${levels ? ` (${levels})` : ''}`),
26
31
  row('Started fresh with one', `${sum.delivered}`),
27
- row('Prompts in fresh sessions', `${sum.prompts} (${sum.requests} requests to Claude)`),
32
+ row('Prompts in fresh sessions', `${sum.prompts} (${sum.requests} requests to ${model})`),
28
33
  row('Context not re-sent', tokens(sum.notResent)),
29
34
  row('Spent writing briefs', tokens(sum.briefCost)),
30
35
  row(net >= 0 ? 'Net saved' : 'Net cost so far', tokens(Math.abs(net))),
31
36
  '',
32
- 'How it is worked out: every request to Claude 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.',
33
- 'Claude caches repeated context and charges about a tenth for it, so in money terms the saving is roughly a tenth of these numbers.',
37
+ `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.`,
38
+ model === 'Claude'
39
+ ? '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
+ : 'Repeated context is cached and charged at about a tenth, so in money terms the saving is roughly a tenth of these numbers.',
34
41
  '',
35
42
  inClaude ? 'To share these totals anonymously, run in a terminal: npx context-carwash stats --share' : 'To share these totals anonymously: npx context-carwash stats --share',
36
43
  ].join('\n');
package/src/tools.mjs ADDED
@@ -0,0 +1,40 @@
1
+ // The coding tools Carwash works with, and the few things that differ between
2
+ // them in what people see: the tool's name, how the command is typed, and how
3
+ // a brief is picked up after /clear.
4
+ //
5
+ // Claude Code /wash is a skill. A brief loads the moment /clear runs, and
6
+ // the status line shows the meter.
7
+ // Codex $wash is a skill, mentioned with a dollar sign (Codex keeps
8
+ // the slash for its own commands). Codex starts the new session
9
+ // when the first message after /clear is sent, so that is when
10
+ // the brief loads. Its status line only shows its own items,
11
+ // so the meter is a line after each reply instead.
12
+
13
+ export const TOOLS = {
14
+ claude: {
15
+ id: 'claude',
16
+ name: 'Claude Code',
17
+ model: 'Claude',
18
+ wash: '/wash',
19
+ meter: true,
20
+ replyMeter: false,
21
+ afterClear: 'Type /clear to start fresh with it.',
22
+ },
23
+ codex: {
24
+ id: 'codex',
25
+ name: 'Codex',
26
+ model: 'Codex',
27
+ wash: '$wash',
28
+ meter: false,
29
+ replyMeter: true,
30
+ afterClear: 'Type /clear, then send your next message (even just "continue"): the brief loads with it.',
31
+ },
32
+ };
33
+
34
+ export const isTool = (id) => Object.hasOwn(TOOLS, id);
35
+
36
+ // The tool behind a brief or a ledger entry. Everything written before Codex
37
+ // support came from Claude Code and carries no tool.
38
+ export const toolOf = (thing) => (thing && isTool(thing.tool) ? thing.tool : 'claude');
39
+
40
+ export const toolInfo = (id) => TOOLS[isTool(id) ? id : 'claude'];