context-carwash 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +27 -8
- package/package.json +3 -2
- package/src/cli.mjs +22 -11
- 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 +17 -2
- package/src/skill/codex/SKILL.md +47 -0
- package/src/skill/codex/agents/openai.yaml +3 -0
- package/src/stats/habits.mjs +107 -0
- package/src/stats/ledger.mjs +216 -17
- package/src/stats/report.mjs +32 -7
- package/src/tools.mjs +40 -0
- package/src/wash/brief.mjs +56 -17
- package/src/wash/hooks.mjs +141 -58
- package/src/wash/process.mjs +111 -0
- package/src/wash/rollout.mjs +111 -0
package/src/stats/ledger.mjs
CHANGED
|
@@ -1,24 +1,37 @@
|
|
|
1
1
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { readJson, writePrivateJson } from '../files.mjs';
|
|
4
|
-
import {
|
|
4
|
+
import { estimateTokens } from '../levels.mjs';
|
|
5
|
+
import { consumedDir, statsDir } from '../paths.mjs';
|
|
6
|
+
import { toolOf } from '../tools.mjs';
|
|
7
|
+
import { tokenReading } from '../wash/rollout.mjs';
|
|
5
8
|
|
|
6
9
|
// The savings ledger. Briefs themselves are deleted within days, so each one
|
|
7
10
|
// written leaves a small entry here: its level, what writing it cost, and the
|
|
8
11
|
// fresh sessions it was delivered to. Nothing in it leaves this machine unless
|
|
9
12
|
// the user runs `carwash stats --share` and says yes, and then only totals.
|
|
10
13
|
//
|
|
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
|
|
14
|
+
// What a wash saves is measured, not assumed. Every request Claude Code or
|
|
15
|
+
// Codex sends carries the whole conversation. A fresh session's transcript
|
|
16
|
+
// records the size of each request, so for every request after a wash the
|
|
17
|
+
// saving is the gap between the old session's size and the fresh session's
|
|
18
|
+
// starting size. Each tool writes its transcript its own way, so each has its
|
|
19
|
+
// own reader below; the arithmetic is the same.
|
|
15
20
|
|
|
16
21
|
const DAY = 24 * 3_600_000;
|
|
17
22
|
|
|
23
|
+
// Bumped when measuring changes, so kept measurements are redone.
|
|
24
|
+
const METHOD = 2;
|
|
25
|
+
|
|
18
26
|
// Roughly where Claude Code compacts a session on its own. Past this point the
|
|
19
27
|
// old session would have shrunk anyway, so requests beyond it count for nothing.
|
|
20
28
|
export const AUTO_COMPACT_SHARE = 0.9;
|
|
21
29
|
|
|
30
|
+
// The starting guess at how much of the work after a wash would have been
|
|
31
|
+
// done in the old session before it was cleared anyway. habits.mjs adjusts it
|
|
32
|
+
// from the person's own sessions.
|
|
33
|
+
export const DEFAULT_SHARE = 2 / 3;
|
|
34
|
+
|
|
22
35
|
const keyOf = (record) => `${Date.parse(record.createdAt).toString(36)}-${record.id}`;
|
|
23
36
|
const fileOf = (env, key) => join(statsDir(env), `${key}.json`);
|
|
24
37
|
|
|
@@ -27,22 +40,26 @@ export function recordWash(env, record, wash) {
|
|
|
27
40
|
const key = keyOf(record);
|
|
28
41
|
writePrivateJson(fileOf(env, key), {
|
|
29
42
|
key,
|
|
43
|
+
...(toolOf(record) === 'claude' ? {} : { tool: toolOf(record) }),
|
|
30
44
|
level: record.level,
|
|
31
45
|
washedAt: record.createdAt,
|
|
32
46
|
used: record.used ?? null,
|
|
33
47
|
size: wash.size ?? null,
|
|
34
48
|
tokens: record.tokens,
|
|
49
|
+
source: record.source?.transcriptPath ?? null,
|
|
35
50
|
attempts: (wash.rewrites ?? 0) + 1,
|
|
36
51
|
deliveries: [],
|
|
37
52
|
});
|
|
38
53
|
}
|
|
39
54
|
|
|
40
|
-
// A brief was handed to a fresh session
|
|
41
|
-
|
|
55
|
+
// A brief was handed to a fresh session, in `tool`. A brief asked for by id
|
|
56
|
+
// can be delivered in the other tool, so each delivery says whose transcript
|
|
57
|
+
// it is.
|
|
58
|
+
export function recordDelivery(env, record, payload, now = Date.now(), tool = 'claude') {
|
|
42
59
|
const file = fileOf(env, keyOf(record));
|
|
43
60
|
const entry = readJson(file);
|
|
44
61
|
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 });
|
|
62
|
+
entry.deliveries.push({ sessionId: payload.session_id ?? null, ...(tool === 'claude' ? {} : { tool }), transcript: payload.transcript_path, at: new Date(now).toISOString(), measured: null });
|
|
46
63
|
writePrivateJson(file, entry);
|
|
47
64
|
}
|
|
48
65
|
|
|
@@ -64,21 +81,81 @@ export function readLedger(env) {
|
|
|
64
81
|
|
|
65
82
|
// A line of the transcript the user typed, as opposed to a tool result, a
|
|
66
83
|
// shell command, a slash command's output or a compaction summary.
|
|
67
|
-
function isPrompt(line) {
|
|
84
|
+
export function isPrompt(line) {
|
|
68
85
|
if (line.type !== 'user' || line.isMeta || line.isCompactSummary) return false;
|
|
69
86
|
const content = line.message?.content;
|
|
70
87
|
if (typeof content === 'string') return !content.trimStart().startsWith('<');
|
|
71
88
|
return Array.isArray(content) && content.some((block) => block?.type === 'text') && !content.some((block) => block?.type === 'tool_result');
|
|
72
89
|
}
|
|
73
90
|
|
|
91
|
+
// The size of one request: everything the model was sent.
|
|
92
|
+
export const sentOf = (usage) => (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
|
|
93
|
+
|
|
94
|
+
const blocksOf = (line) => (Array.isArray(line.message?.content) ? line.message.content : []);
|
|
95
|
+
|
|
96
|
+
const resultText = (block) =>
|
|
97
|
+
typeof block.content === 'string' ? block.content : Array.isArray(block.content) ? block.content.map((part) => (part?.type === 'text' ? part.text : '')).join('') : '';
|
|
98
|
+
|
|
99
|
+
// Lines shorter than this are too common to say a file was read again.
|
|
100
|
+
const MIN_LINE = 24;
|
|
101
|
+
|
|
102
|
+
// A line as a tool shows it, without the line number a file read adds.
|
|
103
|
+
const normal = (line) => line.replace(/^\s*\d+(?:\t|→)/, '').trim();
|
|
104
|
+
|
|
105
|
+
function* lines(text) {
|
|
106
|
+
for (const line of text.split('\n')) {
|
|
107
|
+
const clean = normal(line);
|
|
108
|
+
if (clean.length >= MIN_LINE) yield clean;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Every line a session still held from tools and from the files it wrote:
|
|
113
|
+
// whatever came before its last compaction was summarised away. Compared by
|
|
114
|
+
// content, so a file read with cat counts the same as one read with Read.
|
|
115
|
+
export function heldLines(text) {
|
|
116
|
+
const held = new Set();
|
|
117
|
+
for (const raw of text.split('\n')) {
|
|
118
|
+
if (!raw.includes('tool_') && !raw.includes('compact_boundary')) continue;
|
|
119
|
+
let line;
|
|
120
|
+
try {
|
|
121
|
+
line = JSON.parse(raw);
|
|
122
|
+
} catch {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (!line || line.isSidechain) continue;
|
|
126
|
+
if (line.type === 'system' && line.subtype === 'compact_boundary') held.clear();
|
|
127
|
+
for (const block of blocksOf(line)) {
|
|
128
|
+
const texts = block?.type === 'tool_result' ? [resultText(block)] : block?.type === 'tool_use' ? [block.input?.content, block.input?.new_string] : [];
|
|
129
|
+
for (const text of texts) if (typeof text === 'string') for (const clean of lines(text)) held.add(clean);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return held;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The tokens of a tool result the old session already held.
|
|
136
|
+
function repeated(text, held) {
|
|
137
|
+
let chars = 0;
|
|
138
|
+
for (const clean of lines(text)) if (held.has(clean)) chars += clean.length + 1;
|
|
139
|
+
return chars ? estimateTokens('x'.repeat(chars)) : 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
74
142
|
// 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
|
-
|
|
143
|
+
// size when the brief was written, `size` its context window, `held` the
|
|
144
|
+
// lines of tool output the old session already had.
|
|
145
|
+
//
|
|
146
|
+
// `turns` breaks the count down by prompt, as [requests, reread]: how many
|
|
147
|
+
// requests each prompt made, and how many of the tokens they sent were tool
|
|
148
|
+
// output the old session already had, such as files read again. The old session already held those, so
|
|
149
|
+
// they are not a saving. `likelyOf` uses this to count only the prompts that
|
|
150
|
+
// would have been sent in the old session anyway.
|
|
151
|
+
export function measure(text, before, size, { held = null } = {}) {
|
|
77
152
|
const limit = size ? size * AUTO_COMPACT_SHARE : Infinity;
|
|
78
153
|
const seen = new Set();
|
|
154
|
+
const turns = [];
|
|
79
155
|
let start = null;
|
|
80
156
|
let prompts = 0;
|
|
81
157
|
let requests = 0;
|
|
158
|
+
let reread = 0;
|
|
82
159
|
for (const raw of text.split('\n')) {
|
|
83
160
|
let line;
|
|
84
161
|
try {
|
|
@@ -91,6 +168,11 @@ export function measure(text, before, size) {
|
|
|
91
168
|
if (line.type === 'system' && line.subtype === 'compact_boundary') break;
|
|
92
169
|
if (isPrompt(line)) {
|
|
93
170
|
prompts++;
|
|
171
|
+
turns.push([0, 0]);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (line.type === 'user') {
|
|
175
|
+
if (held) for (const block of blocksOf(line)) if (block?.type === 'tool_result') reread += repeated(resultText(block), held);
|
|
94
176
|
continue;
|
|
95
177
|
}
|
|
96
178
|
if (line.type !== 'assistant') continue;
|
|
@@ -99,13 +181,75 @@ export function measure(text, before, size) {
|
|
|
99
181
|
const usage = line.message?.usage;
|
|
100
182
|
if (!id || !usage || seen.has(id)) continue;
|
|
101
183
|
seen.add(id);
|
|
102
|
-
const sent = (usage
|
|
184
|
+
const sent = sentOf(usage);
|
|
103
185
|
start ??= sent;
|
|
104
186
|
if (before - start <= 0 || sent + (before - start) > limit) break;
|
|
105
187
|
requests++;
|
|
188
|
+
if (!turns.length) turns.push([0, 0]);
|
|
189
|
+
turns.at(-1)[0]++;
|
|
190
|
+
turns.at(-1)[1] += reread;
|
|
106
191
|
}
|
|
107
192
|
const gap = start === null ? 0 : Math.max(0, before - start);
|
|
108
|
-
return { prompts: requests ? prompts : 0, requests, start, saved: gap * requests };
|
|
193
|
+
return { prompts: requests ? prompts : 0, requests, start, saved: gap * requests, turns: turns.filter(([n]) => n > 0) };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// The same count from a Codex session file. A person's prompt is a UserMessage
|
|
197
|
+
// item (or, from older Codex, a user_message event), and each request leaves a
|
|
198
|
+
// token_usage_record (or, from older Codex, a token_count event). Requests made
|
|
199
|
+
// by subagents are left out, as Claude Code's sidechains are above.
|
|
200
|
+
export function measureCodex(text, before, size) {
|
|
201
|
+
const limit = size ? size * AUTO_COMPACT_SHARE : Infinity;
|
|
202
|
+
const records = text.includes('"token_usage_record"');
|
|
203
|
+
// Newer Codex marks a prompt both ways; count turns by whichever it uses.
|
|
204
|
+
const byItem = text.includes('"UserMessage"');
|
|
205
|
+
const seen = new Set();
|
|
206
|
+
const turns = [];
|
|
207
|
+
let thread = null;
|
|
208
|
+
let start = null;
|
|
209
|
+
let items = 0;
|
|
210
|
+
let events = 0;
|
|
211
|
+
let requests = 0;
|
|
212
|
+
for (const raw of text.split('\n')) {
|
|
213
|
+
let line;
|
|
214
|
+
try {
|
|
215
|
+
line = JSON.parse(raw);
|
|
216
|
+
} catch {
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (!line || typeof line !== 'object') continue;
|
|
220
|
+
const payload = line.payload && typeof line.payload === 'object' ? line.payload : {};
|
|
221
|
+
if (line.type === 'session_meta') thread ??= payload.id ?? payload.session_id ?? null;
|
|
222
|
+
// Compacted: the comparison with the old session no longer holds.
|
|
223
|
+
if (line.type === 'compacted' || (line.type === 'event_msg' && payload.type === 'context_compacted')) break;
|
|
224
|
+
if (line.type === 'event_msg' && payload.type === 'item_completed' && payload.item?.type === 'UserMessage') {
|
|
225
|
+
items++;
|
|
226
|
+
if (byItem) turns.push([0, 0]);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (line.type === 'event_msg' && payload.type === 'user_message') {
|
|
230
|
+
events++;
|
|
231
|
+
if (!byItem) turns.push([0, 0]);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
let sent = null;
|
|
235
|
+
if (records && line.type === 'token_usage_record') {
|
|
236
|
+
if (thread && payload.thread_id && payload.thread_id !== thread) continue;
|
|
237
|
+
const id = payload.response_id;
|
|
238
|
+
if (id && seen.has(id)) continue;
|
|
239
|
+
if (id) seen.add(id);
|
|
240
|
+
sent = payload.usage?.input_tokens;
|
|
241
|
+
} else if (!records) {
|
|
242
|
+
sent = tokenReading(line)?.used;
|
|
243
|
+
}
|
|
244
|
+
if (!Number.isFinite(sent) || sent <= 0) continue;
|
|
245
|
+
start ??= sent;
|
|
246
|
+
if (before - start <= 0 || sent + (before - start) > limit) break;
|
|
247
|
+
requests++;
|
|
248
|
+
if (!turns.length) turns.push([0, 0]);
|
|
249
|
+
turns.at(-1)[0]++;
|
|
250
|
+
}
|
|
251
|
+
const gap = start === null ? 0 : Math.max(0, before - start);
|
|
252
|
+
return { prompts: requests ? Math.max(items, events) : 0, requests, start, saved: gap * requests, turns: turns.filter(([n]) => n > 0) };
|
|
109
253
|
}
|
|
110
254
|
|
|
111
255
|
// Bring each delivery's measurement up to date with its transcript. Without
|
|
@@ -117,6 +261,11 @@ export function refreshLedger(env, { now = Date.now(), force = false } = {}) {
|
|
|
117
261
|
for (const entry of entries) {
|
|
118
262
|
if (!(entry.used > 0)) continue;
|
|
119
263
|
let changed = false;
|
|
264
|
+
if (entry.source === undefined) {
|
|
265
|
+
entry.source = sourceOf(env, entry);
|
|
266
|
+
changed = entry.source !== null;
|
|
267
|
+
}
|
|
268
|
+
let held;
|
|
120
269
|
for (const delivery of entry.deliveries) {
|
|
121
270
|
const last = delivery.measured;
|
|
122
271
|
if (last && !force && now - last.at < DAY) continue;
|
|
@@ -126,14 +275,20 @@ export function refreshLedger(env, { now = Date.now(), force = false } = {}) {
|
|
|
126
275
|
} catch {
|
|
127
276
|
continue;
|
|
128
277
|
}
|
|
129
|
-
|
|
278
|
+
// Measured the old way: measure again while the transcript is here.
|
|
279
|
+
if (last && last.mtime === mtime && last.method === METHOD) continue;
|
|
130
280
|
let text;
|
|
131
281
|
try {
|
|
132
282
|
text = readFileSync(delivery.transcript, 'utf8');
|
|
133
283
|
} catch {
|
|
134
284
|
continue;
|
|
135
285
|
}
|
|
136
|
-
|
|
286
|
+
if (toolOf(delivery) === 'codex') {
|
|
287
|
+
delivery.measured = { ...measureCodex(text, entry.used, entry.size), method: METHOD, mtime, at: now };
|
|
288
|
+
} else {
|
|
289
|
+
if (held === undefined) held = toolOf(entry) === 'claude' ? oldLines(entry.source) : null;
|
|
290
|
+
delivery.measured = { ...measure(text, entry.used, entry.size, { held }), method: METHOD, mtime, at: now };
|
|
291
|
+
}
|
|
137
292
|
changed = true;
|
|
138
293
|
}
|
|
139
294
|
if (changed) writePrivateJson(fileOf(env, entry.key), entry);
|
|
@@ -141,14 +296,52 @@ export function refreshLedger(env, { now = Date.now(), force = false } = {}) {
|
|
|
141
296
|
return entries;
|
|
142
297
|
}
|
|
143
298
|
|
|
299
|
+
// Where the washed session's transcript is, for entries written before the
|
|
300
|
+
// ledger kept it: the delivered brief remembers, until it is purged.
|
|
301
|
+
function sourceOf(env, entry) {
|
|
302
|
+
const record = readJson(join(consumedDir(env), `${entry.key.slice(entry.key.indexOf('-') + 1)}.json`));
|
|
303
|
+
return typeof record?.source?.transcriptPath === 'string' ? record.source.transcriptPath : null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function oldLines(path) {
|
|
307
|
+
if (typeof path !== 'string') return null;
|
|
308
|
+
try {
|
|
309
|
+
return heldLines(readFileSync(path, 'utf8'));
|
|
310
|
+
} catch {
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// The saving if only the first `share` of the fresh session's prompts would
|
|
316
|
+
// have been sent in the old session before it was cleared anyway. The
|
|
317
|
+
// measured total counts every prompt; this is the likelier figure. Files read
|
|
318
|
+
// again are taken off. Entries measured before turns were kept fall back to
|
|
319
|
+
// the same share of the total.
|
|
320
|
+
export function likelyOf(measured, share) {
|
|
321
|
+
if (!measured) return 0;
|
|
322
|
+
if (!Array.isArray(measured.turns)) return Math.round(measured.saved * share);
|
|
323
|
+
const gap = measured.requests ? measured.saved / measured.requests : 0;
|
|
324
|
+
const keep = measured.turns.length * share;
|
|
325
|
+
let total = 0;
|
|
326
|
+
measured.turns.forEach(([requests, reread], i) => {
|
|
327
|
+
total += Math.min(1, Math.max(0, keep - i)) * Math.max(0, gap * requests - reread);
|
|
328
|
+
});
|
|
329
|
+
return Math.round(total);
|
|
330
|
+
}
|
|
331
|
+
|
|
144
332
|
// What writing a brief cost: each attempt re-read the old session and wrote
|
|
145
333
|
// the brief out.
|
|
146
334
|
export const costOf = (entry) => (entry.used > 0 ? entry.attempts * (entry.used + entry.tokens) : 0);
|
|
147
335
|
|
|
148
|
-
|
|
149
|
-
|
|
336
|
+
// `shareOf` says, for a delivery, what share of its prompts would likely have
|
|
337
|
+
// been sent in the old session anyway.
|
|
338
|
+
export function totals(entries, now = Date.now(), { shareOf = () => DEFAULT_SHARE } = {}) {
|
|
339
|
+
const sum = { washes: entries.length, levels: {}, tools: [], delivered: 0, prompts: 0, requests: 0, notResent: 0, likely: 0, share: DEFAULT_SHARE, briefCost: 0, since: null, days: 0 };
|
|
340
|
+
let weighed = 0;
|
|
341
|
+
let turns = 0;
|
|
150
342
|
for (const entry of entries) {
|
|
151
343
|
sum.levels[entry.level] = (sum.levels[entry.level] ?? 0) + 1;
|
|
344
|
+
for (const tool of [toolOf(entry), ...entry.deliveries.map(toolOf)]) if (!sum.tools.includes(tool)) sum.tools.push(tool);
|
|
152
345
|
sum.briefCost += costOf(entry);
|
|
153
346
|
if (entry.deliveries.length) sum.delivered++;
|
|
154
347
|
for (const { measured } of entry.deliveries) {
|
|
@@ -156,8 +349,14 @@ export function totals(entries, now = Date.now()) {
|
|
|
156
349
|
sum.prompts += measured.prompts;
|
|
157
350
|
sum.requests += measured.requests;
|
|
158
351
|
sum.notResent += measured.saved;
|
|
352
|
+
const share = shareOf(entry, measured);
|
|
353
|
+
sum.likely += likelyOf(measured, share);
|
|
354
|
+
const count = measured.turns?.length ?? measured.prompts;
|
|
355
|
+
weighed += share * count;
|
|
356
|
+
turns += count;
|
|
159
357
|
}
|
|
160
358
|
}
|
|
359
|
+
if (turns) sum.share = weighed / turns;
|
|
161
360
|
if (entries.length) {
|
|
162
361
|
sum.since = entries[0].washedAt;
|
|
163
362
|
sum.days = Math.max(1, Math.ceil((now - Date.parse(sum.since)) / DAY));
|
package/src/stats/report.mjs
CHANGED
|
@@ -2,7 +2,10 @@ import { randomBytes } from 'node:crypto';
|
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { writeFileAtomic } from '../files.mjs';
|
|
4
4
|
import { shareIdPath } from '../paths.mjs';
|
|
5
|
+
import { learnHabits, shareFor } from './habits.mjs';
|
|
6
|
+
import { refreshLedger, totals } from './ledger.mjs';
|
|
5
7
|
import { formatTokens } from '../statusline/render.mjs';
|
|
8
|
+
import { toolInfo } from '../tools.mjs';
|
|
6
9
|
|
|
7
10
|
// `carwash stats` and `/wash stats`: the ledger's totals in words, and the
|
|
8
11
|
// anonymous share that `carwash stats --share` sends after asking.
|
|
@@ -11,26 +14,45 @@ const LEVEL_ORDER = ['light', 'mid', 'detailed'];
|
|
|
11
14
|
|
|
12
15
|
const tokens = (n) => `~${formatTokens(n)} tokens`;
|
|
13
16
|
|
|
17
|
+
// The ledger measured afresh, with the likely saving judged by this person's
|
|
18
|
+
// own habits.
|
|
19
|
+
export function currentTotals(env, now = Date.now()) {
|
|
20
|
+
const sessions = learnHabits(env, { now });
|
|
21
|
+
const sum = totals(refreshLedger(env, { now, force: true }), now, { shareOf: (entry, measured) => shareFor(sessions, entry.used, measured.turns?.length ?? measured.prompts) });
|
|
22
|
+
return { ...sum, learnedFrom: sessions.length };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const percent = (share) => `${Math.round(share * 100)}%`;
|
|
26
|
+
|
|
27
|
+
// Who the requests went to: Claude, Codex, or both.
|
|
28
|
+
const modelName = (tools = ['claude']) => (tools.length === 1 ? toolInfo(tools[0]).model : 'the model');
|
|
29
|
+
|
|
14
30
|
export function statsReport(sum, { inClaude = false } = {}) {
|
|
15
31
|
if (!sum.washes) {
|
|
16
32
|
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
33
|
}
|
|
18
34
|
const since = new Date(sum.since).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
|
19
35
|
const levels = LEVEL_ORDER.filter((name) => sum.levels[name]).map((name) => `${name} ${sum.levels[name]}`).join(' · ');
|
|
20
|
-
const
|
|
36
|
+
const likely = sum.likely ?? sum.notResent;
|
|
37
|
+
const net = likely - sum.briefCost;
|
|
38
|
+
const most = sum.notResent - sum.briefCost;
|
|
21
39
|
const row = (label, value) => ` ${label.padEnd(28)}${value}`;
|
|
40
|
+
const model = modelName(sum.tools);
|
|
22
41
|
return [
|
|
23
42
|
`Carwash stats, since ${since} (${sum.days} day${sum.days === 1 ? '' : 's'})`,
|
|
24
43
|
'',
|
|
25
44
|
row('Briefs written', `${sum.washes}${levels ? ` (${levels})` : ''}`),
|
|
26
45
|
row('Started fresh with one', `${sum.delivered}`),
|
|
27
|
-
row('Prompts in fresh sessions', `${sum.prompts} (${sum.requests} requests to
|
|
28
|
-
row('Context not re-sent', tokens(sum.notResent)),
|
|
46
|
+
row('Prompts in fresh sessions', `${sum.prompts} (${sum.requests} requests to ${model})`),
|
|
47
|
+
row('Context not re-sent', `${tokens(likely)} (at most ${tokens(sum.notResent)})`),
|
|
29
48
|
row('Spent writing briefs', tokens(sum.briefCost)),
|
|
30
|
-
row(net >= 0 ? 'Net saved' : 'Net cost so far', tokens(Math.abs(net))),
|
|
49
|
+
row(net >= 0 ? 'Net saved' : 'Net cost so far', `${tokens(Math.abs(net))} (at most ${most >= 0 ? tokens(most) : 'nothing'})`),
|
|
31
50
|
'',
|
|
32
|
-
|
|
33
|
-
|
|
51
|
+
`How it is worked out: every request to ${model} carries the whole conversation. After a wash, each request in the fresh session starts from the brief instead of the old conversation, and the difference is context that was not sent again. Counting stops where the old session would have compacted on its own.`,
|
|
52
|
+
`You would not have gone on forever in the old session, so the main figure counts only the first ${percent(sum.share ?? 2 / 3)} of the prompts after each wash. That share starts at two-thirds and is learned from how far your own sessions go (${sum.learnedFrom ?? 0} so far). Files the fresh session had to read again are taken off. "At most" counts every prompt.`,
|
|
53
|
+
model === 'Claude'
|
|
54
|
+
? 'Claude caches repeated context and charges about a tenth for it, so in money terms the saving is roughly a tenth of these numbers.'
|
|
55
|
+
: 'Repeated context is cached and charged at about a tenth, so in money terms the saving is roughly a tenth of these numbers.',
|
|
34
56
|
'',
|
|
35
57
|
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
58
|
].join('\n');
|
|
@@ -50,6 +72,7 @@ export const FORM = {
|
|
|
50
72
|
washes: 'entry.822622585',
|
|
51
73
|
prompts: 'entry.93311455',
|
|
52
74
|
notResent: 'entry.1048114223',
|
|
75
|
+
likely: 'entry.1068811941',
|
|
53
76
|
briefCost: 'entry.164624594',
|
|
54
77
|
},
|
|
55
78
|
};
|
|
@@ -65,6 +88,7 @@ export function sharePayload(sum, { shareId, version, platform = process.platfor
|
|
|
65
88
|
washes: sum.washes,
|
|
66
89
|
prompts: sum.prompts,
|
|
67
90
|
notResent: sum.notResent,
|
|
91
|
+
likely: sum.likely ?? sum.notResent,
|
|
68
92
|
briefCost: sum.briefCost,
|
|
69
93
|
};
|
|
70
94
|
}
|
|
@@ -76,7 +100,8 @@ const LABELS = {
|
|
|
76
100
|
days: 'Days since first brief',
|
|
77
101
|
washes: 'Briefs written',
|
|
78
102
|
prompts: 'Prompts in fresh sessions',
|
|
79
|
-
notResent: 'Context not re-sent',
|
|
103
|
+
notResent: 'Context not re-sent, at most',
|
|
104
|
+
likely: 'Context not re-sent, likely',
|
|
80
105
|
briefCost: 'Spent writing briefs',
|
|
81
106
|
};
|
|
82
107
|
|
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'];
|
package/src/wash/brief.mjs
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { CHARS_PER_TOKEN, capFor } from '../levels.mjs';
|
|
2
|
+
import { colorAt, paint } from '../statusline/color.mjs';
|
|
2
3
|
import { formatTokens } from '../statusline/render.mjs';
|
|
4
|
+
import { toolInfo, toolOf } from '../tools.mjs';
|
|
3
5
|
|
|
4
6
|
// The wording around a brief: what Claude is told when writing one, how its
|
|
5
7
|
// reply is recognised, and what the user and the next session are shown. Also
|
|
6
8
|
// the two places Carwash explains itself: the first-run welcome and /wash help.
|
|
9
|
+
// Wording that names the tool or its commands takes the tool's id (see
|
|
10
|
+
// tools.mjs); Claude Code is the default.
|
|
7
11
|
|
|
8
12
|
// Every brief opens with this heading. It is how a reply is known to be a
|
|
9
13
|
// brief, and where anything written before it is cut off.
|
|
@@ -70,14 +74,14 @@ export function retryNote(level, cap, tokens) {
|
|
|
70
74
|
);
|
|
71
75
|
}
|
|
72
76
|
|
|
73
|
-
const
|
|
77
|
+
const nextStep = (tool) => `${toolInfo(tool).afterClear} This is not a normal clear: your brief carries over. Old session stays in /resume.`;
|
|
74
78
|
|
|
75
79
|
// Shown to the user once a brief has been saved.
|
|
76
|
-
export function readyMessage(record) {
|
|
80
|
+
export function readyMessage(record, tool = toolOf(record)) {
|
|
77
81
|
let size = sizeOf(record);
|
|
78
82
|
if (record.overCap) size += `, which is over the ${formatTokens(record.cap)} cap even after asking for a shorter one`;
|
|
79
83
|
else if (record.cap) size += ` of ${formatTokens(record.cap)} cap`;
|
|
80
|
-
return `Brief ready ✓ ${size}. ${
|
|
84
|
+
return `Brief ready ✓ ${size}. ${nextStep(tool)}`;
|
|
81
85
|
}
|
|
82
86
|
|
|
83
87
|
// One line on how the repository differs from when the brief was written, or
|
|
@@ -100,16 +104,46 @@ export function stalenessLine(source, current) {
|
|
|
100
104
|
|
|
101
105
|
// Shown to the user when a fresh session starts with a brief. The old
|
|
102
106
|
// session's size is the reading /wash was sized from, when there was one.
|
|
103
|
-
export function loadedMessage(record, now, stale) {
|
|
107
|
+
export function loadedMessage(record, now, stale, tool = 'claude') {
|
|
104
108
|
const before = Number.isFinite(record.used) && record.used > 0 ? `, down from ~${formatTokens(record.used)} in the old session` : '';
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
+
const loaded = `Loaded your Carwash brief (${sizeOf(record)}, ${ageOf(record, now)})${before}.${stale ? ` ${stale}` : ''}`;
|
|
110
|
+
if (!toolInfo(tool).meter && !toolInfo(tool).replyMeter) return loaded;
|
|
111
|
+
return `${loaded} That size is the brief alone; the meter also counts ${toolInfo(tool).name}'s own baseline.`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const METER_CELLS = 15;
|
|
115
|
+
// From this share of the window on, the meter says so in words.
|
|
116
|
+
const FULL_AT = 75;
|
|
117
|
+
|
|
118
|
+
// The meter as one line, for a tool that shows it after each reply rather than
|
|
119
|
+
// in its status line: the tokens in use, a bar for how full the window is, and
|
|
120
|
+
// what to do about it. `size` may be unknown, and then there is no bar. The
|
|
121
|
+
// bar's cells and "getting full" take the status line's colours at `depth`
|
|
122
|
+
// ('none' for plain text).
|
|
123
|
+
export function replyMeterMessage({ used, size }, tool = 'codex', depth = 'none') {
|
|
124
|
+
const parts = [`session context ~${formatTokens(used)}`];
|
|
125
|
+
const pct = size ? Math.min(100, (used / size) * 100) : null;
|
|
126
|
+
if (pct !== null) {
|
|
127
|
+
const filled = Math.round((pct / 100) * METER_CELLS);
|
|
128
|
+
let cells = '';
|
|
129
|
+
for (let i = 0; i < filled; i++) cells += paint('█', colorAt(((i + 0.5) / METER_CELLS) * 100), depth);
|
|
130
|
+
parts.push(`[${cells}${'░'.repeat(METER_CELLS - filled)}]`);
|
|
131
|
+
}
|
|
132
|
+
const hint = `${toolInfo(tool).wash} to compress, then /clear`;
|
|
133
|
+
parts.push(pct !== null && pct >= FULL_AT ? `${paint('getting full', colorAt(pct), depth)} · ${hint}` : hint);
|
|
134
|
+
return parts.join(' ');
|
|
109
135
|
}
|
|
110
136
|
|
|
111
137
|
// Shown once, at the first session after install.
|
|
112
|
-
export function welcomeMessage() {
|
|
138
|
+
export function welcomeMessage(tool = 'claude') {
|
|
139
|
+
if (tool === 'codex') {
|
|
140
|
+
return [
|
|
141
|
+
'Context Carwash installed ✓ (shown once)',
|
|
142
|
+
' • After each reply: tokens in use, and how full the context is',
|
|
143
|
+
' • Getting full? $wash, then /clear and send your next message. You start fresh, right where you left off',
|
|
144
|
+
' • Nothing runs on its own · $wash help for options',
|
|
145
|
+
].join('\n');
|
|
146
|
+
}
|
|
113
147
|
return [
|
|
114
148
|
'Context Carwash installed ✓ (shown once)',
|
|
115
149
|
' • Status line: tokens in use, and how full the context is',
|
|
@@ -127,14 +161,18 @@ const LEVEL_NOTES = {
|
|
|
127
161
|
|
|
128
162
|
// /wash help. `used` is this session's reading, when known, so each level can
|
|
129
163
|
// show the most tokens its brief may take here.
|
|
130
|
-
export function helpMessage(used, footer = null) {
|
|
131
|
-
const
|
|
164
|
+
export function helpMessage(used, footer = null, tool = 'claude') {
|
|
165
|
+
const { wash, model, meter, replyMeter } = toolInfo(tool);
|
|
166
|
+
const row = (args, note) => ` ${wash} ${args.padEnd(16)}${note}`;
|
|
132
167
|
const level = (name) => {
|
|
133
168
|
const cap = capFor(name, used);
|
|
134
169
|
return row(name, `${LEVEL_NOTES[name]}${cap ? `, up to ${formatTokens(cap)} tokens` : ''}`);
|
|
135
170
|
};
|
|
171
|
+
const intro = meter
|
|
172
|
+
? `${wash} compresses this conversation into a short brief. Your next /clear then starts a fresh session that begins with it, and the old session stays in /resume.`
|
|
173
|
+
: `${wash} compresses this conversation into a short brief. Your next /clear then starts a fresh session, and the brief loads with the first message you send there. The old session stays in /resume.`;
|
|
136
174
|
return [
|
|
137
|
-
|
|
175
|
+
intro,
|
|
138
176
|
'',
|
|
139
177
|
level('light'),
|
|
140
178
|
level('mid'),
|
|
@@ -142,21 +180,22 @@ export function helpMessage(used, footer = null) {
|
|
|
142
180
|
row('discard', 'drop the brief; /clear is a normal clear again'),
|
|
143
181
|
row('load [id]', 'make a waiting brief the one /clear uses'),
|
|
144
182
|
'',
|
|
145
|
-
"The status line shows how many tokens this conversation is using, and the bar shows how close that is to the model's limit.",
|
|
146
|
-
|
|
183
|
+
...(meter ? ["The status line shows how many tokens this conversation is using, and the bar shows how close that is to the model's limit."] : []),
|
|
184
|
+
...(replyMeter ? ["The line after each reply shows how many tokens this conversation is using, and the bar shows how close that is to the model's limit. It is shown to you only and costs no tokens."] : []),
|
|
185
|
+
`Writing a brief is an ordinary ${model} reply, so it uses tokens. help, discard and load do not.`,
|
|
147
186
|
...(footer ? ['', footer] : []),
|
|
148
187
|
].join('\n');
|
|
149
188
|
}
|
|
150
189
|
|
|
151
190
|
// Shown to the user when briefs are waiting but none was loaded.
|
|
152
|
-
export function pointerMessage(records, now, why) {
|
|
191
|
+
export function pointerMessage(records, now, why, tool = 'claude') {
|
|
153
192
|
const one = records.length === 1;
|
|
154
193
|
const listed = records.slice(0, 3).map((record) => describe(record, now));
|
|
155
194
|
if (records.length > 3) listed.push(`and ${records.length - 3} more`);
|
|
156
195
|
return (
|
|
157
196
|
`Carwash: ${one ? 'a brief is' : `${records.length} briefs are`} waiting for this folder. ` +
|
|
158
197
|
`${one ? 'It was not' : 'None was'} loaded because ${why}. ${listed.join(' · ')}. ` +
|
|
159
|
-
`To use ${one ? 'it' : 'one'}:
|
|
198
|
+
`To use ${one ? 'it' : 'one'}: ${toolInfo(tool).wash} load ${one ? records[0].id : '<id>'}, then /clear.`
|
|
160
199
|
);
|
|
161
200
|
}
|
|
162
201
|
|
|
@@ -167,7 +206,7 @@ export function composeContext(record, now, stale) {
|
|
|
167
206
|
return [
|
|
168
207
|
'# Carwash handoff',
|
|
169
208
|
'',
|
|
170
|
-
`The user ran
|
|
209
|
+
`The user ran ${toolInfo(toolOf(record)).wash} in an earlier session and started this one to carry on from it. The brief below (${record.level}, written ${ageOf(record, now)}) is what that session handed over. The earlier conversation is not available to you.`,
|
|
171
210
|
'If the brief is cut off below, or a note says the full text was saved to a file, read that file in full before you reply.',
|
|
172
211
|
'Check the current state of a file before acting on what the brief says about it.',
|
|
173
212
|
...(stale ? [stale] : []),
|