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.
- package/CHANGELOG.md +7 -0
- package/README.md +21 -6
- package/package.json +3 -2
- package/src/cli.mjs +20 -8
- package/src/install/codex.mjs +256 -0
- package/src/install/doctor.mjs +52 -11
- package/src/install/install.mjs +91 -31
- package/src/paths.mjs +13 -2
- package/src/skill/codex/SKILL.md +47 -0
- package/src/skill/codex/agents/openai.yaml +3 -0
- package/src/stats/ledger.mjs +70 -9
- package/src/stats/report.mjs +10 -3
- package/src/tools.mjs +40 -0
- package/src/wash/brief.mjs +56 -17
- package/src/wash/hooks.mjs +138 -55
- package/src/wash/process.mjs +111 -0
- package/src/wash/rollout.mjs +111 -0
package/src/install/install.mjs
CHANGED
|
@@ -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
|
-
|
|
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) =>
|
|
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) => !
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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 = [
|
|
347
|
-
if (plan.
|
|
348
|
-
|
|
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.
|
package/src/stats/ledger.mjs
CHANGED
|
@@ -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
|
|
12
|
-
// carries the whole conversation. A fresh session's transcript
|
|
13
|
-
// size of each request, so for every request after a wash the
|
|
14
|
-
// gap between the old session's size and the fresh session's
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/src/stats/report.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
33
|
-
|
|
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'];
|