context-carwash 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -3
- package/package.json +1 -1
- package/src/cli.mjs +34 -3
- package/src/paths.mjs +7 -0
- package/src/skill/SKILL.md +1 -1
- package/src/stats/ledger.mjs +166 -0
- package/src/stats/report.mjs +125 -0
- package/src/wash/hooks.mjs +24 -6
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ A small, local-first utility for Claude Code.
|
|
|
5
5
|
- **Meter:** a quiet estimate of the active context window in the status line.
|
|
6
6
|
- **Wash:** when you ask, Claude writes a short handoff brief, and your next `/clear` starts a fresh session that begins with it.
|
|
7
7
|
|
|
8
|
-
Nothing happens automatically, and nothing leaves your machine.
|
|
8
|
+
Nothing happens automatically, and nothing leaves your machine. Two exceptions: the installer asks npm whether a newer version is out, and `carwash stats --share` sends anonymous totals if you run it and say yes.
|
|
9
9
|
|
|
10
10
|
**Status:** pre-release. The meter and the typed `/wash` command work today. Clickable links in the status line and the one-step restart are not built yet. See [plan-3.md](plan-3.md) for the design.
|
|
11
11
|
|
|
@@ -59,6 +59,7 @@ Your old conversation is not deleted. It stays in `/resume`.
|
|
|
59
59
|
| `/wash detailed` | Adds rationale, history, test results, risks, a step checklist |
|
|
60
60
|
| `/wash discard` | Drop the brief. `/clear` is a normal clear again |
|
|
61
61
|
| `/wash load [id]` | Make a waiting brief the one your next `/clear` uses |
|
|
62
|
+
| `/wash stats` | How much context your washes have saved (see below) |
|
|
62
63
|
| `/wash help` | Explain the meter and these options, with this session's size limits |
|
|
63
64
|
|
|
64
65
|
Each level has a size limit: the most tokens its brief may take, worked out from what the conversation is using (a hundredth for `light`, three hundredths for `mid`, a tenth for `detailed`). `/wash help` shows the limits for the session you are in.
|
|
@@ -74,9 +75,40 @@ Things worth knowing:
|
|
|
74
75
|
- **Closed the terminal before clearing?** Start `claude` in the same folder. If exactly one brief is waiting for that folder, it is under 12 hours old, and the session that wrote it has ended, it is loaded. If there is any doubt about which brief is meant, nothing is loaded and you are shown what is waiting, with the `/wash load <id>` to use.
|
|
75
76
|
- **Briefs expire after 48 hours.** A delivered brief is kept for 24 hours in case you want it again (`/wash load <id>`), then deleted.
|
|
76
77
|
|
|
78
|
+
## Stats
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
npx context-carwash stats
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
```text
|
|
85
|
+
Carwash stats, since 3 Oct 2026 (12 days)
|
|
86
|
+
|
|
87
|
+
Briefs written 7 (light 1 · mid 5 · detailed 1)
|
|
88
|
+
Started fresh with one 6
|
|
89
|
+
Prompts in fresh sessions 94 (410 requests to Claude)
|
|
90
|
+
Context not re-sent ~21M tokens
|
|
91
|
+
Spent writing briefs ~0.9M tokens
|
|
92
|
+
Net saved ~20M tokens
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`/wash stats` shows the same inside Claude Code, at no cost.
|
|
96
|
+
|
|
97
|
+
How it is worked out: every request to Claude carries the whole conversation, and a single prompt can make many requests while Claude uses tools. After a wash, each request in the fresh session starts from the brief instead of the old conversation. Claude Code's transcript of that session records the size of every request, so Carwash counts the requests and multiplies by the gap. Nothing is assumed about how long you would have kept going: only requests you actually made count. Counting stops where the old session would have compacted on its own.
|
|
98
|
+
|
|
99
|
+
Claude caches repeated context and charges about a tenth for it, so in money terms the saving is roughly a tenth of these numbers. The brief cost counts every brief written, used or not.
|
|
100
|
+
|
|
101
|
+
### Sharing your stats
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
npx context-carwash stats --share
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
This shows every field it would send, and sends only if you say yes. It sends totals to a Google Form: a random share id made for sharing only (so sharing again replaces your last share), the Carwash version, your operating system, days since your first brief, and the numbers above. No names, folders, projects, prompts or briefs. You are not signed in to send it.
|
|
108
|
+
|
|
77
109
|
## Where things are kept
|
|
78
110
|
|
|
79
|
-
Briefs are stored in `~/.carwash`, readable only by you, and never inside your project. Nothing is uploaded, and there is no telemetry. The installer reads the newest version number from npm
|
|
111
|
+
Briefs are stored in `~/.carwash`, readable only by you, and never inside your project. So is the stats ledger (`~/.carwash/stats`): one small file per brief with its size, its level, and which fresh sessions it went to, but never the brief or your prompts. Nothing is uploaded in the background, and there is no telemetry. The installer reads the newest version number from npm (set `CARWASH_NO_UPDATE_CHECK=1` to skip that), and `stats --share` sends only when you run it and say yes.
|
|
80
112
|
|
|
81
113
|
If a hook fails it stays silent and writes the error to `~/.carwash/errors.log`.
|
|
82
114
|
|
|
@@ -86,7 +118,7 @@ If a hook fails it stays silent and writes the error to `~/.carwash/errors.log`.
|
|
|
86
118
|
npx context-carwash uninstall
|
|
87
119
|
```
|
|
88
120
|
|
|
89
|
-
This puts `settings.json` back exactly as it was. If you have edited the file since installing, it removes only Carwash's own status line and hooks and keeps your edits. It then removes the `/wash` command and deletes `~/.carwash`, including any briefs still waiting.
|
|
121
|
+
This puts `settings.json` back exactly as it was. If you have edited the file since installing, it removes only Carwash's own status line and hooks and keeps your edits. It then removes the `/wash` command and deletes `~/.carwash`, including any briefs still waiting and your stats.
|
|
90
122
|
|
|
91
123
|
## Limitations
|
|
92
124
|
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -4,6 +4,8 @@ import { applyInstall, applyUninstall, describeInstall, describeUninstall, packa
|
|
|
4
4
|
import { UserError } from './install/settings.mjs';
|
|
5
5
|
import { isNewer, latestVersion, runRelease } from './install/update.mjs';
|
|
6
6
|
import { errorLogPath, welcomedPath } from './paths.mjs';
|
|
7
|
+
import { refreshLedger, totals } from './stats/ledger.mjs';
|
|
8
|
+
import { FORM, describeShare, sendShare, shareId, sharePayload, shareReady, statsReport } from './stats/report.mjs';
|
|
7
9
|
import { render } from './statusline/render.mjs';
|
|
8
10
|
import { statusline } from './statusline/run.mjs';
|
|
9
11
|
import { runHook } from './wash/hooks.mjs';
|
|
@@ -15,6 +17,7 @@ Usage: carwash <command>
|
|
|
15
17
|
install [--yes] Add the context meter and the /wash command to Claude Code
|
|
16
18
|
uninstall [--yes] Undo everything install did
|
|
17
19
|
preview Print sample meters in this terminal
|
|
20
|
+
stats [--share] Show how much context /wash has saved; --share sends the totals anonymously, after asking
|
|
18
21
|
--version Print the version
|
|
19
22
|
|
|
20
23
|
Inside Claude Code, once installed:
|
|
@@ -23,9 +26,11 @@ Inside Claude Code, once installed:
|
|
|
23
26
|
/clear Start fresh with the brief that is ready
|
|
24
27
|
/wash discard Drop the brief; /clear is a normal clear again
|
|
25
28
|
/wash load [id] Make a waiting brief the one /clear will use
|
|
29
|
+
/wash stats The same totals as carwash stats
|
|
26
30
|
/wash help Explain the meter and the options
|
|
27
31
|
|
|
28
|
-
Nothing happens automatically. Only
|
|
32
|
+
Nothing happens automatically. Only two things go online: install, to ask npm
|
|
33
|
+
for the newest version, and stats --share, once you have said yes.`;
|
|
29
34
|
|
|
30
35
|
async function readAll(stream) {
|
|
31
36
|
if (stream.isTTY) return '';
|
|
@@ -88,7 +93,7 @@ function logError(env, error) {
|
|
|
88
93
|
}
|
|
89
94
|
}
|
|
90
95
|
|
|
91
|
-
export async function main(argv, { env = process.env, stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, latest = latestVersion, handOff = runRelease } = {}) {
|
|
96
|
+
export async function main(argv, { env = process.env, stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, latest = latestVersion, handOff = runRelease, form = FORM, share = sendShare } = {}) {
|
|
92
97
|
const [command, ...rest] = argv;
|
|
93
98
|
const yes = rest.includes('--yes') || rest.includes('-y');
|
|
94
99
|
try {
|
|
@@ -99,7 +104,7 @@ export async function main(argv, { env = process.env, stdin = process.stdin, std
|
|
|
99
104
|
case 'hook': {
|
|
100
105
|
let output = null;
|
|
101
106
|
try {
|
|
102
|
-
output = runHook({ input: await readAll(stdin), env });
|
|
107
|
+
output = runHook({ input: await readAll(stdin), env, onError: (error) => logError(env, error) });
|
|
103
108
|
} catch (error) {
|
|
104
109
|
logError(env, error);
|
|
105
110
|
}
|
|
@@ -142,6 +147,32 @@ export async function main(argv, { env = process.env, stdin = process.stdin, std
|
|
|
142
147
|
stdout.write('Uninstalled.\n');
|
|
143
148
|
return 0;
|
|
144
149
|
}
|
|
150
|
+
case 'stats': {
|
|
151
|
+
const sum = totals(refreshLedger(env, { force: true }));
|
|
152
|
+
stdout.write(statsReport(sum) + '\n');
|
|
153
|
+
if (!rest.includes('--share')) return 0;
|
|
154
|
+
stdout.write('\n');
|
|
155
|
+
if (!shareReady(form)) {
|
|
156
|
+
stdout.write('Sharing is not switched on in this version yet. Nothing was sent.\n');
|
|
157
|
+
return 1;
|
|
158
|
+
}
|
|
159
|
+
if (!sum.washes) {
|
|
160
|
+
stdout.write('There is nothing to share yet. Nothing was sent.\n');
|
|
161
|
+
return 1;
|
|
162
|
+
}
|
|
163
|
+
const payload = sharePayload(sum, { shareId: shareId(env), version: packageVersion() });
|
|
164
|
+
stdout.write(describeShare(payload) + '\n\n');
|
|
165
|
+
if (!(await confirm('Send it?', { yes, stdin, stdout }))) {
|
|
166
|
+
stdout.write('Nothing was sent.\n');
|
|
167
|
+
return 1;
|
|
168
|
+
}
|
|
169
|
+
if (!(await share(payload, { form }))) {
|
|
170
|
+
stdout.write('Could not reach the form, so nothing was sent. Try again later.\n');
|
|
171
|
+
return 1;
|
|
172
|
+
}
|
|
173
|
+
stdout.write('Sent. Thank you.\n');
|
|
174
|
+
return 0;
|
|
175
|
+
}
|
|
145
176
|
case 'preview':
|
|
146
177
|
preview(env, stdout);
|
|
147
178
|
return 0;
|
package/src/paths.mjs
CHANGED
|
@@ -26,6 +26,13 @@ export const consumedDir = (env = process.env) => join(carwashHome(env), 'consum
|
|
|
26
26
|
// Small per-session notes: the last usage reading and a wash in progress.
|
|
27
27
|
export const sessionsDir = (env = process.env) => join(carwashHome(env), 'sessions');
|
|
28
28
|
|
|
29
|
+
// The savings ledger behind `carwash stats`: one file per brief written.
|
|
30
|
+
export const statsDir = (env = process.env) => join(carwashHome(env), 'stats');
|
|
31
|
+
|
|
32
|
+
// A random id made the first time someone shares their stats, so a repeat
|
|
33
|
+
// share can replace the earlier one. It is not derived from anything.
|
|
34
|
+
export const shareIdPath = (env = process.env) => join(carwashHome(env), 'share-id');
|
|
35
|
+
|
|
29
36
|
export const errorLogPath = (env = process.env) => join(carwashHome(env), 'errors.log');
|
|
30
37
|
|
|
31
38
|
// Present once the first-run welcome has been shown.
|
package/src/skill/SKILL.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: wash
|
|
3
3
|
description: Context Carwash. Writes a short handoff brief of this session, so that /clear can start a fresh session that carries on from it.
|
|
4
|
-
argument-hint: "[light | mid | detailed | discard | load <id> | help]"
|
|
4
|
+
argument-hint: "[light | mid | detailed | discard | load <id> | stats | help]"
|
|
5
5
|
disable-model-invocation: true
|
|
6
6
|
metadata:
|
|
7
7
|
installed-by: context-carwash
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { readJson, writePrivateJson } from '../files.mjs';
|
|
4
|
+
import { statsDir } from '../paths.mjs';
|
|
5
|
+
|
|
6
|
+
// The savings ledger. Briefs themselves are deleted within days, so each one
|
|
7
|
+
// written leaves a small entry here: its level, what writing it cost, and the
|
|
8
|
+
// fresh sessions it was delivered to. Nothing in it leaves this machine unless
|
|
9
|
+
// the user runs `carwash stats --share` and says yes, and then only totals.
|
|
10
|
+
//
|
|
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.
|
|
15
|
+
|
|
16
|
+
const DAY = 24 * 3_600_000;
|
|
17
|
+
|
|
18
|
+
// Roughly where Claude Code compacts a session on its own. Past this point the
|
|
19
|
+
// old session would have shrunk anyway, so requests beyond it count for nothing.
|
|
20
|
+
export const AUTO_COMPACT_SHARE = 0.9;
|
|
21
|
+
|
|
22
|
+
const keyOf = (record) => `${Date.parse(record.createdAt).toString(36)}-${record.id}`;
|
|
23
|
+
const fileOf = (env, key) => join(statsDir(env), `${key}.json`);
|
|
24
|
+
|
|
25
|
+
// A brief was saved. `wash` is the wash that produced it.
|
|
26
|
+
export function recordWash(env, record, wash) {
|
|
27
|
+
const key = keyOf(record);
|
|
28
|
+
writePrivateJson(fileOf(env, key), {
|
|
29
|
+
key,
|
|
30
|
+
level: record.level,
|
|
31
|
+
washedAt: record.createdAt,
|
|
32
|
+
used: record.used ?? null,
|
|
33
|
+
size: wash.size ?? null,
|
|
34
|
+
tokens: record.tokens,
|
|
35
|
+
attempts: (wash.rewrites ?? 0) + 1,
|
|
36
|
+
deliveries: [],
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A brief was handed to a fresh session.
|
|
41
|
+
export function recordDelivery(env, record, payload, now = Date.now()) {
|
|
42
|
+
const file = fileOf(env, keyOf(record));
|
|
43
|
+
const entry = readJson(file);
|
|
44
|
+
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 });
|
|
46
|
+
writePrivateJson(file, entry);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function readLedger(env) {
|
|
50
|
+
let names;
|
|
51
|
+
try {
|
|
52
|
+
names = readdirSync(statsDir(env));
|
|
53
|
+
} catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
const entries = [];
|
|
57
|
+
for (const name of names) {
|
|
58
|
+
if (!name.endsWith('.json')) continue;
|
|
59
|
+
const entry = readJson(join(statsDir(env), name));
|
|
60
|
+
if (entry && `${entry.key}.json` === name && Array.isArray(entry.deliveries) && Number.isFinite(Date.parse(entry.washedAt))) entries.push(entry);
|
|
61
|
+
}
|
|
62
|
+
return entries.sort((a, b) => Date.parse(a.washedAt) - Date.parse(b.washedAt));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// A line of the transcript the user typed, as opposed to a tool result, a
|
|
66
|
+
// shell command, a slash command's output or a compaction summary.
|
|
67
|
+
function isPrompt(line) {
|
|
68
|
+
if (line.type !== 'user' || line.isMeta || line.isCompactSummary) return false;
|
|
69
|
+
const content = line.message?.content;
|
|
70
|
+
if (typeof content === 'string') return !content.trimStart().startsWith('<');
|
|
71
|
+
return Array.isArray(content) && content.some((block) => block?.type === 'text') && !content.some((block) => block?.type === 'tool_result');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Count what a fresh session sent after a wash. `before` is the old session's
|
|
75
|
+
// size when the brief was written, `size` its context window.
|
|
76
|
+
export function measure(text, before, size) {
|
|
77
|
+
const limit = size ? size * AUTO_COMPACT_SHARE : Infinity;
|
|
78
|
+
const seen = new Set();
|
|
79
|
+
let start = null;
|
|
80
|
+
let prompts = 0;
|
|
81
|
+
let requests = 0;
|
|
82
|
+
for (const raw of text.split('\n')) {
|
|
83
|
+
let line;
|
|
84
|
+
try {
|
|
85
|
+
line = JSON.parse(raw);
|
|
86
|
+
} catch {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (!line || line.isSidechain) continue;
|
|
90
|
+
// Compacted: the comparison with the old session no longer holds.
|
|
91
|
+
if (line.type === 'system' && line.subtype === 'compact_boundary') break;
|
|
92
|
+
if (isPrompt(line)) {
|
|
93
|
+
prompts++;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (line.type !== 'assistant') continue;
|
|
97
|
+
// One request can be written as several lines; they share an id.
|
|
98
|
+
const id = line.message?.id;
|
|
99
|
+
const usage = line.message?.usage;
|
|
100
|
+
if (!id || !usage || seen.has(id)) continue;
|
|
101
|
+
seen.add(id);
|
|
102
|
+
const sent = (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
|
|
103
|
+
start ??= sent;
|
|
104
|
+
if (before - start <= 0 || sent + (before - start) > limit) break;
|
|
105
|
+
requests++;
|
|
106
|
+
}
|
|
107
|
+
const gap = start === null ? 0 : Math.max(0, before - start);
|
|
108
|
+
return { prompts: requests ? prompts : 0, requests, start, saved: gap * requests };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Bring each delivery's measurement up to date with its transcript. Without
|
|
112
|
+
// `force`, a measurement less than a day old is left alone, so a session start
|
|
113
|
+
// does not reread transcripts every time. A transcript that has gone (Claude
|
|
114
|
+
// Code deletes old ones) keeps its last measurement.
|
|
115
|
+
export function refreshLedger(env, { now = Date.now(), force = false } = {}) {
|
|
116
|
+
const entries = readLedger(env);
|
|
117
|
+
for (const entry of entries) {
|
|
118
|
+
if (!(entry.used > 0)) continue;
|
|
119
|
+
let changed = false;
|
|
120
|
+
for (const delivery of entry.deliveries) {
|
|
121
|
+
const last = delivery.measured;
|
|
122
|
+
if (last && !force && now - last.at < DAY) continue;
|
|
123
|
+
let mtime;
|
|
124
|
+
try {
|
|
125
|
+
mtime = statSync(delivery.transcript).mtimeMs;
|
|
126
|
+
} catch {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (last && last.mtime === mtime) continue;
|
|
130
|
+
let text;
|
|
131
|
+
try {
|
|
132
|
+
text = readFileSync(delivery.transcript, 'utf8');
|
|
133
|
+
} catch {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
delivery.measured = { ...measure(text, entry.used, entry.size), mtime, at: now };
|
|
137
|
+
changed = true;
|
|
138
|
+
}
|
|
139
|
+
if (changed) writePrivateJson(fileOf(env, entry.key), entry);
|
|
140
|
+
}
|
|
141
|
+
return entries;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// What writing a brief cost: each attempt re-read the old session and wrote
|
|
145
|
+
// the brief out.
|
|
146
|
+
export const costOf = (entry) => (entry.used > 0 ? entry.attempts * (entry.used + entry.tokens) : 0);
|
|
147
|
+
|
|
148
|
+
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 };
|
|
150
|
+
for (const entry of entries) {
|
|
151
|
+
sum.levels[entry.level] = (sum.levels[entry.level] ?? 0) + 1;
|
|
152
|
+
sum.briefCost += costOf(entry);
|
|
153
|
+
if (entry.deliveries.length) sum.delivered++;
|
|
154
|
+
for (const { measured } of entry.deliveries) {
|
|
155
|
+
if (!measured) continue;
|
|
156
|
+
sum.prompts += measured.prompts;
|
|
157
|
+
sum.requests += measured.requests;
|
|
158
|
+
sum.notResent += measured.saved;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (entries.length) {
|
|
162
|
+
sum.since = entries[0].washedAt;
|
|
163
|
+
sum.days = Math.max(1, Math.ceil((now - Date.parse(sum.since)) / DAY));
|
|
164
|
+
}
|
|
165
|
+
return sum;
|
|
166
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { writeFileAtomic } from '../files.mjs';
|
|
4
|
+
import { shareIdPath } from '../paths.mjs';
|
|
5
|
+
import { formatTokens } from '../statusline/render.mjs';
|
|
6
|
+
|
|
7
|
+
// `carwash stats` and `/wash stats`: the ledger's totals in words, and the
|
|
8
|
+
// anonymous share that `carwash stats --share` sends after asking.
|
|
9
|
+
|
|
10
|
+
const LEVEL_ORDER = ['light', 'mid', 'detailed'];
|
|
11
|
+
|
|
12
|
+
const tokens = (n) => `~${formatTokens(n)} tokens`;
|
|
13
|
+
|
|
14
|
+
export function statsReport(sum, { inClaude = false } = {}) {
|
|
15
|
+
if (!sum.washes) {
|
|
16
|
+
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.';
|
|
17
|
+
}
|
|
18
|
+
const since = new Date(sum.since).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
|
19
|
+
const levels = LEVEL_ORDER.filter((name) => sum.levels[name]).map((name) => `${name} ${sum.levels[name]}`).join(' · ');
|
|
20
|
+
const net = sum.notResent - sum.briefCost;
|
|
21
|
+
const row = (label, value) => ` ${label.padEnd(28)}${value}`;
|
|
22
|
+
return [
|
|
23
|
+
`Carwash stats, since ${since} (${sum.days} day${sum.days === 1 ? '' : 's'})`,
|
|
24
|
+
'',
|
|
25
|
+
row('Briefs written', `${sum.washes}${levels ? ` (${levels})` : ''}`),
|
|
26
|
+
row('Started fresh with one', `${sum.delivered}`),
|
|
27
|
+
row('Prompts in fresh sessions', `${sum.prompts} (${sum.requests} requests to Claude)`),
|
|
28
|
+
row('Context not re-sent', tokens(sum.notResent)),
|
|
29
|
+
row('Spent writing briefs', tokens(sum.briefCost)),
|
|
30
|
+
row(net >= 0 ? 'Net saved' : 'Net cost so far', tokens(Math.abs(net))),
|
|
31
|
+
'',
|
|
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.',
|
|
34
|
+
'',
|
|
35
|
+
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
|
+
].join('\n');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// --- sharing ---------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
// The Google Form shares go to. Each share becomes one row of the form's
|
|
42
|
+
// response sheet.
|
|
43
|
+
export const FORM = {
|
|
44
|
+
action: 'https://docs.google.com/forms/d/e/1FAIpQLScMU96dzQ1DC4p-K-09wRF6aTYC23Kw3VNmOZbnI55iyXQnYQ/formResponse',
|
|
45
|
+
fields: {
|
|
46
|
+
shareId: 'entry.444662411',
|
|
47
|
+
version: 'entry.237740820',
|
|
48
|
+
system: 'entry.1585190609',
|
|
49
|
+
days: 'entry.1959206368',
|
|
50
|
+
washes: 'entry.822622585',
|
|
51
|
+
prompts: 'entry.93311455',
|
|
52
|
+
notResent: 'entry.1048114223',
|
|
53
|
+
briefCost: 'entry.164624594',
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const SYSTEMS = { darwin: 'macOS', linux: 'Linux', win32: 'Windows' };
|
|
58
|
+
|
|
59
|
+
export function sharePayload(sum, { shareId, version, platform = process.platform }) {
|
|
60
|
+
return {
|
|
61
|
+
shareId,
|
|
62
|
+
version,
|
|
63
|
+
system: SYSTEMS[platform] ?? 'other',
|
|
64
|
+
days: sum.days,
|
|
65
|
+
washes: sum.washes,
|
|
66
|
+
prompts: sum.prompts,
|
|
67
|
+
notResent: sum.notResent,
|
|
68
|
+
briefCost: sum.briefCost,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const LABELS = {
|
|
73
|
+
shareId: 'Share id',
|
|
74
|
+
version: 'Carwash version',
|
|
75
|
+
system: 'Operating system',
|
|
76
|
+
days: 'Days since first brief',
|
|
77
|
+
washes: 'Briefs written',
|
|
78
|
+
prompts: 'Prompts in fresh sessions',
|
|
79
|
+
notResent: 'Context not re-sent',
|
|
80
|
+
briefCost: 'Spent writing briefs',
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Everything that would be sent, shown in full before asking.
|
|
84
|
+
export function describeShare(payload) {
|
|
85
|
+
return [
|
|
86
|
+
'This is everything that would be sent, to a Google Form the Carwash author reads:',
|
|
87
|
+
'',
|
|
88
|
+
...Object.entries(payload).map(([key, value]) => ` ${LABELS[key].padEnd(28)}${value}`),
|
|
89
|
+
'',
|
|
90
|
+
'The share id is a random number made on this machine for sharing only, so sharing again replaces your last share instead of counting twice.',
|
|
91
|
+
'No names, folders, projects, prompts, briefs or account details are included. You are not signed in to send it, and Google Forms does not show the author your IP address.',
|
|
92
|
+
].join('\n');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The random id for shares, made on first use.
|
|
96
|
+
export function shareId(env) {
|
|
97
|
+
try {
|
|
98
|
+
const id = readFileSync(shareIdPath(env), 'utf8').trim();
|
|
99
|
+
if (/^[0-9a-f]{12}$/.test(id)) return id;
|
|
100
|
+
} catch {
|
|
101
|
+
// not made yet
|
|
102
|
+
}
|
|
103
|
+
const id = randomBytes(6).toString('hex');
|
|
104
|
+
writeFileAtomic(shareIdPath(env), `${id}\n`, 0o600);
|
|
105
|
+
return id;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const shareReady = (form = FORM) => Boolean(form.action) && Object.values(form.fields).every(Boolean);
|
|
109
|
+
|
|
110
|
+
// Send a share. Returns true once the form has accepted it.
|
|
111
|
+
export async function sendShare(payload, { form = FORM, fetchImpl = fetch, timeoutMs = 10_000 } = {}) {
|
|
112
|
+
const body = new URLSearchParams();
|
|
113
|
+
for (const [key, value] of Object.entries(payload)) body.set(form.fields[key], String(value));
|
|
114
|
+
try {
|
|
115
|
+
const response = await fetchImpl(form.action, {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
body,
|
|
118
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
119
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
120
|
+
});
|
|
121
|
+
return response.ok;
|
|
122
|
+
} catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
package/src/wash/hooks.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { existsSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { DEFAULT_LEVEL, capFor, estimateTokens, isLevel } from '../levels.mjs';
|
|
3
3
|
import { installRecordPath, welcomedPath } from '../paths.mjs';
|
|
4
|
+
import { recordDelivery, recordWash, refreshLedger, totals } from '../stats/ledger.mjs';
|
|
5
|
+
import { statsReport } from '../stats/report.mjs';
|
|
4
6
|
import { budgetNote, composeContext, describe, helpMessage, loadedMessage, parseReply, pointerMessage, readyMessage, retryNote, stalenessLine, welcomeMessage } from './brief.mjs';
|
|
5
7
|
import { repoState, worktreeRoot } from './git.mjs';
|
|
6
8
|
import { currentProcess, isRunning, sameProcess } from './process.mjs';
|
|
@@ -10,7 +12,7 @@ import { AUTO_LOAD_MS, addRecord, claim, findRecords, purge, readyRecords, remov
|
|
|
10
12
|
// Carwash's three Claude Code hooks:
|
|
11
13
|
//
|
|
12
14
|
// UserPromptExpansion /wash was typed. A level starts a wash and tells
|
|
13
|
-
// Claude the size limit; discard, load and help are
|
|
15
|
+
// Claude the size limit; discard, load, stats and help are
|
|
14
16
|
// handled here and never reach Claude.
|
|
15
17
|
// Stop Claude finished replying. If that reply is the brief,
|
|
16
18
|
// save it.
|
|
@@ -20,7 +22,7 @@ import { AUTO_LOAD_MS, addRecord, claim, findRecords, purge, readyRecords, remov
|
|
|
20
22
|
// Each handler returns the JSON to print for Claude Code, or null to stay out
|
|
21
23
|
// of the way. None of them ever clears, restarts, or runs anything.
|
|
22
24
|
|
|
23
|
-
const USAGE = 'Usage: /wash [light | mid | detailed], /wash discard [id], /wash load [id], /wash help';
|
|
25
|
+
const USAGE = 'Usage: /wash [light | mid | detailed], /wash discard [id], /wash load [id], /wash stats, /wash help';
|
|
24
26
|
|
|
25
27
|
// How many times to ask for a shorter brief before keeping an oversized one.
|
|
26
28
|
const MAX_REWRITES = 2;
|
|
@@ -33,6 +35,15 @@ const block = (reason) => ({ decision: 'block', reason: `Carwash: ${reason}` });
|
|
|
33
35
|
// conversation, so it must not use up a brief meant for a person.
|
|
34
36
|
const unattended = (env) => env.CLAUDE_CODE_SESSION_ATTENDED === '0' || /^sdk/.test(env.CLAUDE_CODE_ENTRYPOINT ?? '');
|
|
35
37
|
|
|
38
|
+
// The savings ledger is bookkeeping: if it fails, the brief still has to work.
|
|
39
|
+
function quietly(ctx, fn) {
|
|
40
|
+
try {
|
|
41
|
+
fn();
|
|
42
|
+
} catch (error) {
|
|
43
|
+
ctx.onError?.(error);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
36
47
|
const cwdOf = (payload) => (typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd());
|
|
37
48
|
|
|
38
49
|
// --- /wash ---------------------------------------------------------------
|
|
@@ -43,14 +54,16 @@ function onExpansion(payload, ctx) {
|
|
|
43
54
|
if (isLevel(verb)) return begin(payload, verb, ctx);
|
|
44
55
|
if (verb === 'discard') return block(discard(payload, ref, ctx));
|
|
45
56
|
if (verb === 'load') return block(load(payload, ref, ctx));
|
|
57
|
+
if (verb === 'stats') return block(statsReport(totals(refreshLedger(ctx.env, { now: ctx.now, force: true }), ctx.now), { inClaude: true }));
|
|
46
58
|
if (verb === 'help') return block(helpMessage(loadUsage(ctx.env, payload.session_id)?.used ?? null));
|
|
47
59
|
return block(`/wash does not know "${verb}". ${USAGE}`);
|
|
48
60
|
}
|
|
49
61
|
|
|
50
62
|
function begin(payload, level, { env, now }) {
|
|
51
|
-
const
|
|
63
|
+
const usage = loadUsage(env, payload.session_id);
|
|
64
|
+
const used = usage?.used ?? null;
|
|
52
65
|
const cap = capFor(level, used);
|
|
53
|
-
startWash(env, payload.session_id, { promptId: payload.prompt_id ?? null, level, cap, used, startedAt: now, rewrites: 0 });
|
|
66
|
+
startWash(env, payload.session_id, { promptId: payload.prompt_id ?? null, level, cap, used, size: usage?.size ?? null, startedAt: now, rewrites: 0 });
|
|
54
67
|
return { hookSpecificOutput: { hookEventName: 'UserPromptExpansion', additionalContext: budgetNote(level, cap, used) } };
|
|
55
68
|
}
|
|
56
69
|
|
|
@@ -157,6 +170,7 @@ function onStop(payload, ctx) {
|
|
|
157
170
|
},
|
|
158
171
|
now,
|
|
159
172
|
);
|
|
173
|
+
quietly(ctx, () => recordWash(env, record, wash));
|
|
160
174
|
// One ready brief per terminal. An earlier wash of this same session is out
|
|
161
175
|
// of date and goes; anything else this terminal held goes back to waiting.
|
|
162
176
|
for (const other of readyRecords(env, now)) {
|
|
@@ -170,9 +184,11 @@ function onStop(payload, ctx) {
|
|
|
170
184
|
|
|
171
185
|
// --- SessionStart --------------------------------------------------------
|
|
172
186
|
|
|
173
|
-
function deliver(record, payload,
|
|
187
|
+
function deliver(record, payload, ctx) {
|
|
188
|
+
const { env, now } = ctx;
|
|
174
189
|
// Another session may have taken it between the listing and now.
|
|
175
190
|
if (!claim(env, record, payload.session_id, now)) return null;
|
|
191
|
+
quietly(ctx, () => recordDelivery(env, record, payload, now));
|
|
176
192
|
const stale = stalenessLine(record.source, repoState(cwdOf(payload)));
|
|
177
193
|
return {
|
|
178
194
|
systemMessage: loadedMessage(record, now, stale),
|
|
@@ -207,6 +223,7 @@ function onSessionStart(payload, ctx) {
|
|
|
207
223
|
function briefAtStart(payload, ctx) {
|
|
208
224
|
const { env, now, me } = ctx;
|
|
209
225
|
purge(env, now);
|
|
226
|
+
quietly(ctx, () => refreshLedger(env, { now }));
|
|
210
227
|
const ready = readyRecords(env, now);
|
|
211
228
|
|
|
212
229
|
// Relaunched by Carwash's shell function for one particular brief.
|
|
@@ -248,7 +265,7 @@ function briefAtStart(payload, ctx) {
|
|
|
248
265
|
// --- entry point -----------------------------------------------------------
|
|
249
266
|
|
|
250
267
|
// Handle one hook call. `input` is the JSON Claude Code sends on stdin.
|
|
251
|
-
export function runHook({ input, env = process.env, platform = process.platform, now = Date.now(), me, running } = {}) {
|
|
268
|
+
export function runHook({ input, env = process.env, platform = process.platform, now = Date.now(), me, running, onError } = {}) {
|
|
252
269
|
let payload;
|
|
253
270
|
try {
|
|
254
271
|
payload = JSON.parse(input);
|
|
@@ -261,6 +278,7 @@ export function runHook({ input, env = process.env, platform = process.platform,
|
|
|
261
278
|
now,
|
|
262
279
|
me: me ?? currentProcess(env, platform),
|
|
263
280
|
isRunning: running ?? ((owner) => isRunning(owner, platform)),
|
|
281
|
+
onError,
|
|
264
282
|
};
|
|
265
283
|
switch (payload.hook_event_name) {
|
|
266
284
|
case 'UserPromptExpansion':
|