context-carwash 0.2.2 → 0.2.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/CHANGELOG.md +20 -0
- package/README.md +11 -10
- package/package.json +1 -1
- package/src/install/doctor.mjs +4 -2
- package/src/install/install.mjs +60 -3
- package/src/paths.mjs +10 -0
- package/src/skill/SKILL.md +7 -5
- package/src/skill/codex/SKILL.md +6 -4
- package/src/stats/ledger.mjs +1 -1
- package/src/stats/report.mjs +1 -1
- package/src/wash/brief.mjs +38 -9
- package/src/wash/hooks.mjs +81 -21
- package/src/wash/recall.mjs +151 -0
- package/src/wash/store.mjs +6 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.5
|
|
4
|
+
|
|
5
|
+
- After you update, your next session says what's new, once, in Claude Code and in Codex. It waits for a start with nothing else to say, so it never gets in the way of a brief loading. Updating from 0.2.3 or earlier, it tells you about notes on what to keep.
|
|
6
|
+
- The welcome you see after installing now shows how to name what a brief must keep.
|
|
7
|
+
- The README shows notes in the command table, near the top, and in the Codex section.
|
|
8
|
+
|
|
9
|
+
## 0.2.4
|
|
10
|
+
|
|
11
|
+
- You can name what a brief must keep: `/wash keep the exact error and the rollback plan`. Claude puts it in a **Kept on request** section right after Core, keeps exact wording for errors, commands and paths, and cuts other sections first when the brief is too long. A note on its own gets a `mid` brief; to pick the level, put it first, as in `/wash detailed keep the test names`. The same works with `$wash` in Codex.
|
|
12
|
+
- A note can start with a word that is also a command: `/wash help me keep the API notes` writes a brief instead of showing help. `help` and `stats` are commands only when nothing follows them, and `load` and `discard` only with at most an id.
|
|
13
|
+
- Likely typos cost nothing: `/wash detaled` or `/wash discrad a3f9c2` asks "Did you mean…?" instead of writing a brief.
|
|
14
|
+
- `/wash help` shows how to add a note.
|
|
15
|
+
- Run `npx context-carwash@latest install` to get the new `/wash` instructions.
|
|
16
|
+
|
|
17
|
+
## 0.2.3
|
|
18
|
+
|
|
19
|
+
- A fresh session can now search the old conversation. When a brief is saved, Carwash keeps a text copy of the conversation in `~/.carwash/recall`, without images, and tells the fresh session where it is, so it can look up an exact error message, path or number the brief left out. The copy is deleted after 7 days, or when you discard the brief. Nothing goes in your project.
|
|
20
|
+
- `install` adds one permission to `~/.claude/settings.json` so Claude can search that folder without asking. It allows reading that folder and nothing else, and `uninstall` takes it out. `carwash doctor` checks it. Run `npx context-carwash@latest install` to get it.
|
|
21
|
+
- `carwash stats` now gives the right cached price on Claude: a twentieth of the normal input price on Opus 5.5 and Sonnet 5.5, not a tenth.
|
|
22
|
+
|
|
3
23
|
## 0.2.2
|
|
4
24
|
|
|
5
25
|
- `carwash stats` leads with a likelier saving and shows the old figure as the most it could be. It counts only the first part of the prompts after each wash, since you would have cleared the old conversation at some point anyway. That share starts at two-thirds and is learned from how far your own Claude Code conversations go, read on your machine. Output the fresh conversation had to read again, which the old one already had, is taken off.
|
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
Context Carwash adds a line to Claude Code in your terminal that:
|
|
8
8
|
|
|
9
9
|
1. **The Meter:** shows the current conversation's context size.
|
|
10
|
-
2. **/wash:** lets you compress your context and start a new session without losing important details.
|
|
10
|
+
2. **/wash:** lets you compress your context and start a new session without losing important details. You can name anything it must keep: `/wash keep the exact error`.
|
|
11
11
|
|
|
12
12
|
It works in Codex too: see [Codex](#codex).
|
|
13
13
|
|
|
@@ -23,9 +23,9 @@ npx context-carwash install
|
|
|
23
23
|
|
|
24
24
|
It shows you what it's going to change and asks before doing anything. Then start a new Claude Code session and you're set. If you use Codex as well, it sets that up in the same go.
|
|
25
25
|
|
|
26
|
-
When there's a new version, a line under the meter will tell you. To update, run `npx context-carwash@latest install`.
|
|
26
|
+
When there's a new version, a line under the meter will tell you. To update, run `npx context-carwash@latest install`. Your next session after updating tells you what's new, once.
|
|
27
27
|
|
|
28
|
-
Under the hood, it adds a status line
|
|
28
|
+
Under the hood, it adds a status line, three hooks and one read-only permission to `~/.claude/settings.json` (after whatever you already have there) and a `/wash` command in `~/.claude/skills/wash`. The permission lets Claude search `~/.carwash/recall` without asking, and nothing else. For Codex, it adds three hooks to `~/.codex/hooks.json` and a `$wash` command in `~/.codex/skills/wash`.
|
|
29
29
|
|
|
30
30
|
## The Meter
|
|
31
31
|
|
|
@@ -40,7 +40,7 @@ The number is roughly how many tokens your conversation is using (Claude Code's
|
|
|
40
40
|
1. Type `/wash`. Claude writes a short brief: what you're working on, where things stand, and what's next. The status line switches to `Brief ready ✓`.
|
|
41
41
|
2. Type `/clear`. Your new session starts with the brief already loaded.
|
|
42
42
|
|
|
43
|
-
Your old conversation isn't gone. You can still find it in `/resume
|
|
43
|
+
Your old conversation isn't gone. You can still find it in `/resume`, and the fresh session can search a text copy of it when it needs an exact detail the brief left out, like an error message or a file path. It only searches when it needs to, so otherwise it costs nothing extra.
|
|
44
44
|
|
|
45
45
|
| Command | What it does |
|
|
46
46
|
|---|---|
|
|
@@ -48,6 +48,7 @@ Your old conversation isn't gone. You can still find it in `/resume`.
|
|
|
48
48
|
| `/wash light` | Goal, current state, next step |
|
|
49
49
|
| `/wash mid` | Adds constraints, decisions, state detail, pitfalls |
|
|
50
50
|
| `/wash detailed` | Adds rationale, history, test results, risks, a step checklist |
|
|
51
|
+
| `/wash keep the exact error` | Write a `mid` brief that also keeps what you name, in its own section |
|
|
51
52
|
| `/wash discard` | Drop the brief. `/clear` is a normal clear again |
|
|
52
53
|
| `/wash load [id]` | Make a waiting brief the one your next `/clear` uses |
|
|
53
54
|
| `/wash stats` | How much context your washes have saved |
|
|
@@ -55,18 +56,18 @@ Your old conversation isn't gone. You can still find it in `/resume`.
|
|
|
55
56
|
|
|
56
57
|
A few things worth knowing:
|
|
57
58
|
|
|
58
|
-
-
|
|
59
|
+
- A note on what to keep goes in its own section near the top of the brief and is never cut to save space. For a level other than `mid`, put the level first: `/wash detailed keep the test names`.
|
|
59
60
|
- If you `/clear` without a brief ready, it's just a normal clear.
|
|
60
61
|
- Writing a brief is a normal Claude reply, so it uses some tokens. `help`, `discard` and `load` are free.
|
|
61
62
|
- Bigger conversations get bigger briefs. `/wash help` shows the size limit for each level.
|
|
62
63
|
- Closed the terminal before you got to `/clear`? Run `claude` in the same folder and your brief loads. If there's more than one waiting, it'll list them so you can pick with `/wash load <id>`.
|
|
63
|
-
- Briefs expire after 48 hours.
|
|
64
|
+
- Briefs expire after 48 hours. The text copy of the conversation is deleted after 7 days, or as soon as you discard the brief.
|
|
64
65
|
|
|
65
66
|
## Codex
|
|
66
67
|
|
|
67
68
|
If you have Codex, `install` sets it up too. It works the same way, with a few differences:
|
|
68
69
|
|
|
69
|
-
- The command is `$wash` (Codex keeps `/` for its own commands). Same options: `$wash light`, `$wash discard`, `$wash help` and so on.
|
|
70
|
+
- The command is `$wash` (Codex keeps `/` for its own commands). Same options: `$wash light`, `$wash keep the exact error`, `$wash discard`, `$wash help` and so on.
|
|
70
71
|
- Codex asks you to approve new hooks before it runs them. The first time you start Codex after installing, choose **Trust all and continue**.
|
|
71
72
|
- After `/clear`, send your next message (even just "continue"). The brief loads with it, because that's when Codex starts the new session.
|
|
72
73
|
- The meter shows up as a line after each reply, because Codex's status line can only show its own items:
|
|
@@ -96,7 +97,7 @@ Every request to Claude or Codex sends the whole conversation again. After a was
|
|
|
96
97
|
|
|
97
98
|
You wouldn't have kept going in the old conversation forever, so the main number only counts the first part of the prompts after each wash. That's two-thirds to begin with. Carwash then adjusts it by looking at how far your own past conversations went once they were that big, so if you usually clear around 300k, a wash at 300k counts for less. Anything the fresh conversation had to read again, like a file the old one already had open, is taken off too. "At most" counts every prompt. All of this is worked out on your computer from Claude Code's own history files, and only a few numbers per conversation are kept.
|
|
98
99
|
|
|
99
|
-
Repeated context is charged at
|
|
100
|
+
Repeated context is charged at a fraction of the price: a twentieth of the normal input price on Opus 5.5 and Sonnet 5.5, and about a tenth on most older models. So in dollars you're saving that fraction of these numbers. You can also see this inside Claude Code with `/wash stats`.
|
|
100
101
|
|
|
101
102
|
If you run `stats --share`, it shows you exactly what it would send before asking.
|
|
102
103
|
|
|
@@ -110,7 +111,7 @@ This checks that everything's still installed and working, and shows your versio
|
|
|
110
111
|
|
|
111
112
|
## Where things are kept
|
|
112
113
|
|
|
113
|
-
Your briefs, stats
|
|
114
|
+
Your briefs, stats, error logs and the text copies of old conversations live in `~/.carwash`, and only you can read them. Nothing goes in your project folders. The text copies leave out images, so they're small: usually under a megabyte each. Nothing about you gets uploaded. Once a day, Carwash checks npm for a newer version. If you'd rather it didn't, set `CARWASH_NO_UPDATE_CHECK=1`.
|
|
114
115
|
|
|
115
116
|
## Uninstall
|
|
116
117
|
|
|
@@ -118,7 +119,7 @@ Your briefs, stats and error logs live in `~/.carwash`, and only you can read th
|
|
|
118
119
|
npx context-carwash uninstall
|
|
119
120
|
```
|
|
120
121
|
|
|
121
|
-
This puts `settings.json` back exactly as it was. If you've edited the file since installing, it only removes Carwash's status line and
|
|
122
|
+
This puts `settings.json` back exactly as it was. If you've edited the file since installing, it only removes Carwash's status line, hooks and permission and leaves your changes alone. Codex's `hooks.json` gets the same treatment. It also removes the `/wash` and `$wash` commands and deletes `~/.carwash`, including any waiting briefs, text copies and your stats.
|
|
122
123
|
|
|
123
124
|
## Platforms
|
|
124
125
|
|
package/package.json
CHANGED
package/src/install/doctor.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { errorLogPath, errorsSeenPath, installRecordPath, settingsPath, skillDir
|
|
|
7
7
|
import { refreshLedger, totals } from '../stats/ledger.mjs';
|
|
8
8
|
import { readyRecords } from '../wash/store.mjs';
|
|
9
9
|
import { codexDetected, codexHooksCheck, codexSkillInstalled } from './codex.mjs';
|
|
10
|
-
import { commandProgram, isCarwashCommand, isCarwashHook } from './install.mjs';
|
|
10
|
+
import { commandProgram, isCarwashCommand, isCarwashHook, recallRule } from './install.mjs';
|
|
11
11
|
import { UPDATE_COMMAND } from './notice.mjs';
|
|
12
12
|
import { readSettings, resolveSettingsFile } from './settings.mjs';
|
|
13
13
|
import { isNewer } from './update.mjs';
|
|
@@ -77,7 +77,8 @@ function settingsCheck(env) {
|
|
|
77
77
|
const hooks = Object.fromEntries(HOOK_EVENTS.map((event) => [event, groups(event).some((group) => Array.isArray(group?.hooks) && group.hooks.some((hook) => isCarwashHook(hook?.command)))]));
|
|
78
78
|
const hookCommands = HOOK_EVENTS.flatMap((event) => groups(event).flatMap((group) => (Array.isArray(group?.hooks) ? group.hooks.map((hook) => hook?.command) : [])));
|
|
79
79
|
const ours = [data?.statusLine?.command, ...hookCommands].filter((command) => isCarwashCommand(command) || isCarwashHook(command));
|
|
80
|
-
|
|
80
|
+
const allow = data?.permissions?.allow;
|
|
81
|
+
return { readable: true, statusLine: isCarwashCommand(data?.statusLine?.command), hooks, recall: Array.isArray(allow) && allow.includes(recallRule(env)), nodes: [...new Set(ours.map(commandProgram).filter(Boolean))] };
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
// The Node that Carwash's commands start. Install writes its path into the
|
|
@@ -135,6 +136,7 @@ export function doctorReport({ env = process.env, now = Date.now(), running, new
|
|
|
135
136
|
} else {
|
|
136
137
|
out.push(row('Status line', yes(check.statusLine)));
|
|
137
138
|
out.push(row('Hooks', HOOK_EVENTS.map((event) => `${event} ${yes(check.hooks[event])}`).join(' · ')));
|
|
139
|
+
out.push(row('Session search', check.recall ? 'ok' : `MISSING: Claude will ask before searching an old session · to fix: ${UPDATE_COMMAND}`));
|
|
138
140
|
nodes.push(...check.nodes);
|
|
139
141
|
}
|
|
140
142
|
out.push(row('/wash command', yes(existsSync(join(skillDir(env), 'SKILL.md')))));
|
package/src/install/install.mjs
CHANGED
|
@@ -3,9 +3,10 @@ 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, claudeConfigDir, installRecordPath, legacyCommandPath, pendingDir, settingsPath, skillDir } from '../paths.mjs';
|
|
6
|
+
import { RECALL_DAYS, carwashHome, claudeConfigDir, installRecordPath, legacyCommandPath, pendingDir, recallDir, settingsPath, skillDir, updatedFromPath } from '../paths.mjs';
|
|
7
7
|
import { applyCodexInstall, applyCodexUninstall, codexDetected, describeCodexInstall, describeCodexUninstall, planCodexInstall, planCodexUninstall } from './codex.mjs';
|
|
8
8
|
import { UserError, readSettings, resolveSettingsFile, serializeSettings, sha256 } from './settings.mjs';
|
|
9
|
+
import { isNewer } from './update.mjs';
|
|
9
10
|
|
|
10
11
|
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
11
12
|
const APP_ENTRIES = ['bin', 'src', 'package.json', 'LICENSE'];
|
|
@@ -128,6 +129,37 @@ function withCarwashHooks(hooks, command, file) {
|
|
|
128
129
|
return next;
|
|
129
130
|
}
|
|
130
131
|
|
|
132
|
+
// The one permission Carwash adds: reading the saved copies of old sessions, so
|
|
133
|
+
// that a fresh session can search them without asking. A Read rule also covers
|
|
134
|
+
// Grep and read-only shell commands such as grep, and it allows nothing else.
|
|
135
|
+
export function recallRule(env = process.env) {
|
|
136
|
+
const posix = (path) => path.replace(/\\/g, '/').replace(/^([A-Za-z]):/, (_, drive) => `/${drive.toLowerCase()}`);
|
|
137
|
+
const dir = recallDir(env);
|
|
138
|
+
const path = dir.startsWith(homedir() + sep) ? `~/${posix(dir.slice(homedir().length + 1))}` : `/${posix(dir)}`;
|
|
139
|
+
return `Read(${path}/**)`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// The `permissions` setting with that rule taken out. `allow`, or the whole
|
|
143
|
+
// setting, goes too if it only existed to hold ours. Returns the same object
|
|
144
|
+
// when the rule was not there.
|
|
145
|
+
function withoutRecallRule(permissions, rule) {
|
|
146
|
+
if (!isPlainObject(permissions) || !Array.isArray(permissions.allow) || !permissions.allow.includes(rule)) return permissions;
|
|
147
|
+
const { allow, ...others } = permissions;
|
|
148
|
+
const theirs = allow.filter((entry) => entry !== rule);
|
|
149
|
+
const next = theirs.length ? { ...permissions, allow: theirs } : others;
|
|
150
|
+
return Object.keys(next).length ? next : undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// The `permissions` setting with the rule added after whatever is there. One
|
|
154
|
+
// Carwash cannot read is left as it is: a fresh session then asks before it
|
|
155
|
+
// searches, which is all that is lost.
|
|
156
|
+
function withRecallRule(permissions, rule) {
|
|
157
|
+
if (permissions !== undefined && !isPlainObject(permissions)) return permissions;
|
|
158
|
+
if (permissions?.allow !== undefined && !Array.isArray(permissions.allow)) return permissions;
|
|
159
|
+
const theirs = withoutRecallRule(permissions, rule);
|
|
160
|
+
return { ...theirs, allow: [...(theirs?.allow ?? []), rule] };
|
|
161
|
+
}
|
|
162
|
+
|
|
131
163
|
function isOurSkill(file) {
|
|
132
164
|
try {
|
|
133
165
|
return readFileSync(file, 'utf8').includes(SKILL_MARK);
|
|
@@ -207,14 +239,18 @@ function planClaudeInstall({ env, record, script }) {
|
|
|
207
239
|
}
|
|
208
240
|
|
|
209
241
|
const theirHooks = withoutCarwashHooks(settings.data.hooks);
|
|
242
|
+
const rule = recallRule(env);
|
|
243
|
+
const permissions = withRecallRule(settings.data.permissions, rule);
|
|
210
244
|
const next = {
|
|
211
245
|
...settings.data,
|
|
212
246
|
statusLine: { refreshInterval: REFRESH_SECONDS, ...(original ?? {}), type: 'command', command: commandFor('statusline', script) },
|
|
213
247
|
hooks: withCarwashHooks(settings.data.hooks, commandFor('hook', script), file),
|
|
248
|
+
...(permissions === undefined ? {} : { permissions }),
|
|
214
249
|
};
|
|
215
250
|
const skill = planSkill(env);
|
|
251
|
+
const recall = Array.isArray(permissions?.allow) && permissions.allow.includes(rule) ? recallDir(env) : null;
|
|
216
252
|
|
|
217
|
-
return { file, settings, claudeRecord: record, original, alreadyInstalled, next, skill, keepsHooks: isPlainObject(theirHooks) && Object.keys(theirHooks).length > 0 };
|
|
253
|
+
return { file, settings, claudeRecord: record, original, alreadyInstalled, next, skill, recall, keepsHooks: isPlainObject(theirHooks) && Object.keys(theirHooks).length > 0 };
|
|
218
254
|
}
|
|
219
255
|
|
|
220
256
|
export function describeInstall(plan) {
|
|
@@ -224,6 +260,7 @@ export function describeInstall(plan) {
|
|
|
224
260
|
if (plan.codex) lines.push(...describeCodexInstall(plan.codex, plan.claude ? 4 : 2, plan.home));
|
|
225
261
|
const model = plan.claude && plan.codex ? 'Claude or Codex' : plan.codex ? 'Codex' : 'Claude';
|
|
226
262
|
lines.push('', `Writing a brief is an ordinary ${model} reply, so it uses tokens like any other.`);
|
|
263
|
+
lines.push(`Each brief also saves a text copy of the conversation in ${tilde(join(plan.home, 'recall'))} for ${RECALL_DAYS} days, so the fresh session can search it for exact details.`);
|
|
227
264
|
// Version managers such as nvm keep a folder per Node version, so removing
|
|
228
265
|
// an old version takes this path with it, and nothing of Carwash can start.
|
|
229
266
|
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.`);
|
|
@@ -250,6 +287,7 @@ function describeClaudeInstall(plan) {
|
|
|
250
287
|
}
|
|
251
288
|
lines.push(' Adds three hooks, so /wash can save a brief and a fresh session can');
|
|
252
289
|
lines.push(` pick it up.${plan.keepsHooks ? ' The hooks you already have are kept as they are.' : ''}`);
|
|
290
|
+
if (plan.recall) lines.push(` Lets Claude read ${tilde(plan.recall)} without asking, and nothing else.`);
|
|
253
291
|
if (plan.settings.exists && !plan.claudeRecord) {
|
|
254
292
|
lines.push(` A backup of the file is kept in ${tilde(join(plan.home, 'backup'))}`);
|
|
255
293
|
}
|
|
@@ -279,6 +317,19 @@ export function applyInstall(plan) {
|
|
|
279
317
|
const extra = { ...(tools.length === 1 && tools[0] === 'claude' ? {} : { tools }), ...(codex ? { codex } : {}) };
|
|
280
318
|
const nextRecord = plan.claude ? { ...base, ...applyClaudeInstall(plan), ...extra } : { ...base, ...extra };
|
|
281
319
|
writeFileSync(installRecordPath({ CARWASH_HOME: home }), JSON.stringify(nextRecord, null, 2) + '\n', { mode: 0o600 });
|
|
320
|
+
|
|
321
|
+
// An update notes the version it replaced, so the next session in each tool
|
|
322
|
+
// can say what is new. Installing again before that session keeps the first
|
|
323
|
+
// note, which goes back further.
|
|
324
|
+
if (record?.version && isNewer(base.version, record.version)) {
|
|
325
|
+
for (const tool of tools) {
|
|
326
|
+
try {
|
|
327
|
+
writeFileSync(updatedFromPath({ CARWASH_HOME: home }, tool), `${record.version}\n`, { flag: 'wx', mode: 0o600 });
|
|
328
|
+
} catch {
|
|
329
|
+
// already noted
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
282
333
|
return nextRecord;
|
|
283
334
|
}
|
|
284
335
|
|
|
@@ -362,6 +413,12 @@ export function planUninstall({ env = process.env } = {}) {
|
|
|
362
413
|
else data.hooks = hooks;
|
|
363
414
|
changed = true;
|
|
364
415
|
}
|
|
416
|
+
const permissions = withoutRecallRule(data.permissions, recallRule(env));
|
|
417
|
+
if (permissions !== data.permissions) {
|
|
418
|
+
if (permissions === undefined) delete data.permissions;
|
|
419
|
+
else data.permissions = permissions;
|
|
420
|
+
changed = true;
|
|
421
|
+
}
|
|
365
422
|
if (changed) {
|
|
366
423
|
plan.action = 'edit';
|
|
367
424
|
plan.bytes = serializeSettings(data, settings);
|
|
@@ -395,7 +452,7 @@ export function describeUninstall(plan) {
|
|
|
395
452
|
const what = {
|
|
396
453
|
restore: `Restore ${tilde(plan.file)} to exactly what it was before install`,
|
|
397
454
|
delete: `Delete ${tilde(plan.file)} (Carwash created it and it has not changed since)`,
|
|
398
|
-
edit: `Edit ${tilde(plan.file)}: undo Carwash's status line and
|
|
455
|
+
edit: `Edit ${tilde(plan.file)}: undo Carwash's status line, hooks and read rule; your other changes stay`,
|
|
399
456
|
none: `Leave ${tilde(plan.file)} alone`,
|
|
400
457
|
}[plan.action];
|
|
401
458
|
const steps = [];
|
package/src/paths.mjs
CHANGED
|
@@ -31,6 +31,12 @@ export const consumedDir = (env = process.env) => join(carwashHome(env), 'consum
|
|
|
31
31
|
// Small per-session notes: the last usage reading and a wash in progress.
|
|
32
32
|
export const sessionsDir = (env = process.env) => join(carwashHome(env), 'sessions');
|
|
33
33
|
|
|
34
|
+
// A text copy of the session each brief came from, for the fresh session to
|
|
35
|
+
// search. Kept for a week.
|
|
36
|
+
export const RECALL_DAYS = 7;
|
|
37
|
+
export const recallDir = (env = process.env) => join(carwashHome(env), 'recall');
|
|
38
|
+
export const recallFile = (env, id) => join(recallDir(env), `${id}.log`);
|
|
39
|
+
|
|
34
40
|
// The savings ledger behind `carwash stats`: one file per brief written.
|
|
35
41
|
export const statsDir = (env = process.env) => join(carwashHome(env), 'stats');
|
|
36
42
|
|
|
@@ -51,6 +57,10 @@ export const noticePath = (env = process.env) => join(carwashHome(env), 'notice.
|
|
|
51
57
|
// Present once the first-run welcome has been shown. Each tool says hello once.
|
|
52
58
|
export const welcomedPath = (env = process.env, tool = 'claude') => join(carwashHome(env), tool === 'claude' ? 'welcomed' : `welcomed-${tool}`);
|
|
53
59
|
|
|
60
|
+
// Left by an install that updated Carwash: the version it replaced. The next
|
|
61
|
+
// session in each tool says what is new since then, once, and deletes it.
|
|
62
|
+
export const updatedFromPath = (env = process.env, tool = 'claude') => join(carwashHome(env), tool === 'claude' ? 'updated-from' : `updated-from-${tool}`);
|
|
63
|
+
|
|
54
64
|
// Where the /wash command is installed.
|
|
55
65
|
export const skillDir = (env = process.env) => join(claudeConfigDir(env), 'skills', 'wash');
|
|
56
66
|
export const legacyCommandPath = (env = process.env) => join(claudeConfigDir(env), 'commands', 'wash.md');
|
package/src/skill/SKILL.md
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
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> | stats | help
|
|
4
|
+
argument-hint: "[light | mid | detailed] [what to keep] | discard | load <id> | stats | help"
|
|
5
5
|
disable-model-invocation: true
|
|
6
6
|
metadata:
|
|
7
7
|
installed-by: context-carwash
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
-
The user typed /wash. They want 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
|
|
10
|
+
The user typed /wash. They want 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. It can search a saved copy for an exact detail, but only if it knows to look, so the brief has to stand on its own.
|
|
11
11
|
|
|
12
12
|
Arguments: $ARGUMENTS
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
Carwash's hooks send a line with this command that starts with "Carwash: level". It gives the level for this brief (`light`, `mid`, or `detailed`), its token ceiling, and the user's note on what the brief must keep, if they wrote one. Go by that line, not by the arguments above. If there is no such line, Carwash's hooks are not running: write no brief, and tell the user in one sentence to run `carwash install` again.
|
|
15
15
|
|
|
16
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
17
|
|
|
@@ -30,9 +30,11 @@ Start with exactly this heading, then the Core section:
|
|
|
30
30
|
|
|
31
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
32
|
|
|
33
|
-
|
|
33
|
+
If the user wrote a note, put a **Kept on request** section straight after Core, at every level. It holds everything the note asks for that this conversation has, with the exact wording of error messages, commands, paths, and numbers. If the note asks for something this conversation does not have, say so in one line instead of guessing. Never drop or shorten anything here to save space: cut from the other sections instead. Secrets stay out even when the note asks for them.
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
After that, add only the sections for the level, and skip any that would be empty:
|
|
36
|
+
|
|
37
|
+
- `light`: no other sections. Add one line for a critical constraint if breaking it would undo the work.
|
|
36
38
|
- `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
39
|
- `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
40
|
|
package/src/skill/codex/SKILL.md
CHANGED
|
@@ -9,9 +9,9 @@ The user typed $wash. Context Carwash saves your reply as a handoff brief, but o
|
|
|
9
9
|
|
|
10
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
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
|
|
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. It can search a saved copy for an exact detail, but only if it knows to look, so the brief has to stand on its own.
|
|
13
13
|
|
|
14
|
-
The
|
|
14
|
+
The "Carwash: level" message gives the level for this brief (`light`, `mid`, or `detailed`), its token ceiling, and the user's note on what the brief must keep, if they wrote one. Go by that message, not by the words after $wash.
|
|
15
15
|
|
|
16
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
17
|
|
|
@@ -30,9 +30,11 @@ Start with exactly this heading, then the Core section:
|
|
|
30
30
|
|
|
31
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
32
|
|
|
33
|
-
|
|
33
|
+
If the user wrote a note, put a **Kept on request** section straight after Core, at every level. It holds everything the note asks for that this conversation has, with the exact wording of error messages, commands, paths, and numbers. If the note asks for something this conversation does not have, say so in one line instead of guessing. Never drop or shorten anything here to save space: cut from the other sections instead. Secrets stay out even when the note asks for them.
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
After that, add only the sections for the level, and skip any that would be empty:
|
|
36
|
+
|
|
37
|
+
- `light`: no other sections. Add one line for a critical constraint if breaking it would undo the work.
|
|
36
38
|
- `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
39
|
- `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
40
|
|
package/src/stats/ledger.mjs
CHANGED
|
@@ -93,7 +93,7 @@ export const sentOf = (usage) => (usage.input_tokens ?? 0) + (usage.cache_read_i
|
|
|
93
93
|
|
|
94
94
|
const blocksOf = (line) => (Array.isArray(line.message?.content) ? line.message.content : []);
|
|
95
95
|
|
|
96
|
-
const resultText = (block) =>
|
|
96
|
+
export const resultText = (block) =>
|
|
97
97
|
typeof block.content === 'string' ? block.content : Array.isArray(block.content) ? block.content.map((part) => (part?.type === 'text' ? part.text : '')).join('') : '';
|
|
98
98
|
|
|
99
99
|
// Lines shorter than this are too common to say a file was read again.
|
package/src/stats/report.mjs
CHANGED
|
@@ -51,7 +51,7 @@ export function statsReport(sum, { inClaude = false } = {}) {
|
|
|
51
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
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
53
|
model === 'Claude'
|
|
54
|
-
? 'Claude caches repeated context and charges
|
|
54
|
+
? 'Claude caches repeated context and charges far less for it: a twentieth of the normal input price on Opus 5.5 and Sonnet 5.5, a tenth on most older models. In money terms the saving is that share of these numbers.'
|
|
55
55
|
: 'Repeated context is cached and charged at about a tenth, so in money terms the saving is roughly a tenth of these numbers.',
|
|
56
56
|
'',
|
|
57
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',
|
package/src/wash/brief.mjs
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
+
import { isNewer } from '../install/update.mjs';
|
|
1
2
|
import { CHARS_PER_TOKEN, capFor } from '../levels.mjs';
|
|
3
|
+
import { RECALL_DAYS } from '../paths.mjs';
|
|
2
4
|
import { colorAt, paint } from '../statusline/color.mjs';
|
|
3
5
|
import { formatTokens } from '../statusline/render.mjs';
|
|
4
6
|
import { toolInfo, toolOf } from '../tools.mjs';
|
|
5
7
|
|
|
6
8
|
// The wording around a brief: what Claude is told when writing one, how its
|
|
7
9
|
// reply is recognised, and what the user and the next session are shown. Also
|
|
8
|
-
// the
|
|
10
|
+
// the places Carwash explains itself: the first-run welcome, what is new after
|
|
11
|
+
// an update, and /wash help.
|
|
9
12
|
// Wording that names the tool or its commands takes the tool's id (see
|
|
10
13
|
// tools.mjs); Claude Code is the default.
|
|
11
14
|
|
|
@@ -54,23 +57,26 @@ const sizeOf = (record) => `${record.level} ~${formatTokens(record.tokens)}`;
|
|
|
54
57
|
// a3f9c2 "Fix login redirect" (mid ~1.1k, 14h ago)
|
|
55
58
|
export const describe = (record, now) => `${record.id} "${record.title}" (${sizeOf(record)}, ${ageOf(record, now)})`;
|
|
56
59
|
|
|
57
|
-
// Sent to Claude alongside the /wash prompt: the size limit for this brief
|
|
58
|
-
|
|
60
|
+
// Sent to Claude alongside the /wash prompt: the size limit for this brief,
|
|
61
|
+
// and the user's note on what it must keep, if they wrote one.
|
|
62
|
+
export function budgetNote(level, cap, used, note = '') {
|
|
63
|
+
const kept = note ? ` The user's note on what this brief must keep: ${note}` : '';
|
|
59
64
|
if (!cap) {
|
|
60
|
-
return `Carwash: level ${level}. The size of this session is not known yet, so there is no token ceiling for this brief. Keep to the ${level} scope and keep it short
|
|
65
|
+
return `Carwash: level ${level}. The size of this session is not known yet, so there is no token ceiling for this brief. Keep to the ${level} scope and keep it short.${kept}`;
|
|
61
66
|
}
|
|
62
67
|
const chars = Math.max(100, Math.round((cap * CHARS_PER_TOKEN) / 100) * 100);
|
|
63
68
|
return (
|
|
64
69
|
`Carwash: level ${level}. This session is using about ${formatTokens(used)} tokens, so the ceiling for this brief is ${cap} tokens, ` +
|
|
65
|
-
`which is roughly ${chars} characters. That is a limit, not a target: stop as soon as the brief is complete
|
|
70
|
+
`which is roughly ${chars} characters. That is a limit, not a target: stop as soon as the brief is complete.${kept}`
|
|
66
71
|
);
|
|
67
72
|
}
|
|
68
73
|
|
|
69
74
|
// Sent to Claude when its brief came out over the cap.
|
|
70
|
-
export function retryNote(level, cap, tokens) {
|
|
75
|
+
export function retryNote(level, cap, tokens, hasNote = false) {
|
|
76
|
+
const keep = hasNote ? ', the Core section, and everything in Kept on request' : ' and the Core section';
|
|
71
77
|
return (
|
|
72
78
|
`Carwash: that brief is about ${tokens} tokens, over the ${cap}-token ceiling for ${level}. Send it again shortened to fit. ` +
|
|
73
|
-
|
|
79
|
+
`Keep the "# Carwash brief:" heading${keep}, cut the least useful detail first, and reply with the brief alone.`
|
|
74
80
|
);
|
|
75
81
|
}
|
|
76
82
|
|
|
@@ -141,6 +147,7 @@ export function welcomeMessage(tool = 'claude') {
|
|
|
141
147
|
'Context Carwash installed ✓ (shown once)',
|
|
142
148
|
' • After each reply: tokens in use, and how full the context is',
|
|
143
149
|
' • Getting full? $wash, then /clear and send your next message. You start fresh, right where you left off',
|
|
150
|
+
' • Something the brief must keep? Name it: $wash keep the exact error',
|
|
144
151
|
' • Nothing runs on its own · $wash help for options',
|
|
145
152
|
].join('\n');
|
|
146
153
|
}
|
|
@@ -148,10 +155,25 @@ export function welcomeMessage(tool = 'claude') {
|
|
|
148
155
|
'Context Carwash installed ✓ (shown once)',
|
|
149
156
|
' • Status line: tokens in use, and how full the context is',
|
|
150
157
|
' • Getting full? /wash, then /clear. You start fresh, right where you left off',
|
|
158
|
+
' • Something the brief must keep? Name it: /wash keep the exact error',
|
|
151
159
|
' • Nothing runs on its own · /wash help for options',
|
|
152
160
|
].join('\n');
|
|
153
161
|
}
|
|
154
162
|
|
|
163
|
+
// What a release brings that someone updating should hear about, in the
|
|
164
|
+
// tool's own command. Most releases add nothing here.
|
|
165
|
+
const WHATS_NEW = [
|
|
166
|
+
['0.2.4', (wash) => `Name what a brief must keep: ${wash} keep the exact error. It gets its own section and is never cut to save space.`],
|
|
167
|
+
];
|
|
168
|
+
|
|
169
|
+
// Shown once, at the first quiet session after an update: what is new since
|
|
170
|
+
// `from`, or null when nothing is worth saying.
|
|
171
|
+
export function whatsNewMessage(from, to, tool = 'claude') {
|
|
172
|
+
const { wash } = toolInfo(tool);
|
|
173
|
+
const lines = WHATS_NEW.filter(([version]) => isNewer(version, from) && !isNewer(version, to)).map(([, line]) => ` • ${line(wash)}`);
|
|
174
|
+
return lines.length ? [`Context Carwash updated to ${to} ✓ (shown once)`, ...lines].join('\n') : null;
|
|
175
|
+
}
|
|
176
|
+
|
|
155
177
|
// What each level keeps, for /wash help.
|
|
156
178
|
const LEVEL_NOTES = {
|
|
157
179
|
light: 'goal, where things stand, next step',
|
|
@@ -180,9 +202,12 @@ export function helpMessage(used, footer = null, tool = 'claude') {
|
|
|
180
202
|
row('discard', 'drop the brief; /clear is a normal clear again'),
|
|
181
203
|
row('load [id]', 'make a waiting brief the one /clear uses'),
|
|
182
204
|
'',
|
|
205
|
+
`Add a note to name what the brief must keep: ${wash} keep the exact error, or ${wash} detailed keep the test names.`,
|
|
206
|
+
'',
|
|
183
207
|
...(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
208
|
...(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
209
|
`Writing a brief is an ordinary ${model} reply, so it uses tokens. help, discard and load do not.`,
|
|
210
|
+
`The old conversation is also saved as plain text for ${RECALL_DAYS} days, so the fresh session can search it for an exact detail the brief left out.`,
|
|
186
211
|
...(footer ? ['', footer] : []),
|
|
187
212
|
].join('\n');
|
|
188
213
|
}
|
|
@@ -202,11 +227,15 @@ export function pointerMessage(records, now, why, tool = 'claude') {
|
|
|
202
227
|
// What the fresh session's model is given. The framing is kept short: when the
|
|
203
228
|
// whole thing is over Claude Code's limit for hook output, only the first 2,000
|
|
204
229
|
// characters arrive inline, and the brief's Core section has to be among them.
|
|
205
|
-
|
|
230
|
+
// `recall` is the saved copy of the earlier conversation, when there is one.
|
|
231
|
+
export function composeContext(record, now, stale, recall = null) {
|
|
232
|
+
const earlier = recall
|
|
233
|
+
? `The brief is a summary; the earlier conversation is saved as plain text at ${recall}. When you need an exact detail it leaves out (an error message, path, number, command, or what the user said), search that file with grep instead of guessing, writing its full path in each command rather than in a shell variable. Do not read it whole.`
|
|
234
|
+
: 'The earlier conversation is not available to you.';
|
|
206
235
|
return [
|
|
207
236
|
'# Carwash handoff',
|
|
208
237
|
'',
|
|
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.
|
|
238
|
+
`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. ${earlier}`,
|
|
210
239
|
'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.',
|
|
211
240
|
'Check the current state of a file before acting on what the brief says about it.',
|
|
212
241
|
...(stale ? [stale] : []),
|
package/src/wash/hooks.mjs
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
|
-
import { existsSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { packageVersion } from '../install/install.mjs';
|
|
3
3
|
import { startNotice, versionLine } from '../install/notice.mjs';
|
|
4
4
|
import { isNewer } from '../install/update.mjs';
|
|
5
5
|
import { DEFAULT_LEVEL, capFor, estimateTokens, isLevel } from '../levels.mjs';
|
|
6
|
-
import { installRecordPath, welcomedPath } from '../paths.mjs';
|
|
6
|
+
import { installRecordPath, updatedFromPath, welcomedPath } from '../paths.mjs';
|
|
7
7
|
import { recordDelivery, recordWash, refreshLedger } from '../stats/ledger.mjs';
|
|
8
8
|
import { colorDepth } from '../statusline/color.mjs';
|
|
9
9
|
import { currentTotals, statsReport } from '../stats/report.mjs';
|
|
10
|
-
import { budgetNote, composeContext, describe, helpMessage, loadedMessage, parseReply, pointerMessage, readyMessage, replyMeterMessage, retryNote, stalenessLine, welcomeMessage } from './brief.mjs';
|
|
10
|
+
import { budgetNote, composeContext, describe, helpMessage, loadedMessage, parseReply, pointerMessage, readyMessage, replyMeterMessage, retryNote, stalenessLine, welcomeMessage, whatsNewMessage } from './brief.mjs';
|
|
11
11
|
import { isTool, toolInfo, toolOf } from '../tools.mjs';
|
|
12
12
|
import { repoState, worktreeRoot } from './git.mjs';
|
|
13
13
|
import { codexProcess, codexUnattended, currentProcess, isRunning, sameProcess } from './process.mjs';
|
|
14
|
+
import { keptRecall, saveRecall } from './recall.mjs';
|
|
14
15
|
import { readCodexReplyUsage, readCodexUsage, readCodexVersion } from './rollout.mjs';
|
|
15
16
|
import { currentWash, endWash, loadUsage, startWash } from './session.mjs';
|
|
16
17
|
import { AUTO_LOAD_MS, addRecord, claim, findRecords, purge, readyRecords, removeRecord, reopen, updateRecord } from './store.mjs';
|
|
@@ -20,9 +21,10 @@ import { AUTO_LOAD_MS, addRecord, claim, findRecords, purge, readyRecords, remov
|
|
|
20
21
|
// UserPromptExpansion /wash was typed (Claude Code). In Codex, where $wash
|
|
21
22
|
// UserPromptSubmit is a skill, every prompt comes through
|
|
22
23
|
// UserPromptSubmit and only one starting with $wash is
|
|
23
|
-
// ours. A level starts a wash and tells the
|
|
24
|
-
// size limit; discard, load,
|
|
25
|
-
// here and never reach the
|
|
24
|
+
// ours. A level or a note starts a wash and tells the
|
|
25
|
+
// model the size limit and the note; discard, load,
|
|
26
|
+
// stats and help are handled here and never reach the
|
|
27
|
+
// model.
|
|
26
28
|
// Stop The model finished replying. If that reply is the
|
|
27
29
|
// brief, save it. Otherwise, in Codex, show the meter.
|
|
28
30
|
// SessionStart A session began. If a brief is meant for it, hand the
|
|
@@ -36,7 +38,7 @@ import { AUTO_LOAD_MS, addRecord, claim, findRecords, purge, readyRecords, remov
|
|
|
36
38
|
|
|
37
39
|
const usageLine = (tool) => {
|
|
38
40
|
const wash = toolInfo(tool).wash;
|
|
39
|
-
return `Usage: ${wash} [light | mid | detailed], ${wash} discard [id], ${wash} load [id], ${wash} stats, ${wash} help`;
|
|
41
|
+
return `Usage: ${wash} [light | mid | detailed] [note on what to keep], ${wash} discard [id], ${wash} load [id], ${wash} stats, ${wash} help`;
|
|
40
42
|
};
|
|
41
43
|
|
|
42
44
|
// How many times to ask for a shorter brief before keeping an oversized one.
|
|
@@ -88,23 +90,59 @@ function onPromptSubmit(payload, ctx) {
|
|
|
88
90
|
return found ? washCommand(payload, found[1], 'UserPromptSubmit', ctx) : null;
|
|
89
91
|
}
|
|
90
92
|
|
|
93
|
+
// A first word that is a level, or a command with no more than it takes, is
|
|
94
|
+
// read as one. Anything else of more than a word is the user's note on what
|
|
95
|
+
// the brief must keep, at the default level: "/wash help me keep the API
|
|
96
|
+
// notes" is a note, not help. A lone unknown word, or a near miss of a long
|
|
97
|
+
// name, is taken for a typo and costs nothing.
|
|
91
98
|
function washCommand(payload, args, event, ctx) {
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (verb
|
|
96
|
-
if (verb === '
|
|
97
|
-
if (verb === '
|
|
98
|
-
return block(
|
|
99
|
+
const text = String(args ?? '').trim();
|
|
100
|
+
const words = text.toLowerCase().split(/\s+/).filter(Boolean);
|
|
101
|
+
const [verb = DEFAULT_LEVEL, ref] = words;
|
|
102
|
+
if (isLevel(verb)) return begin(payload, verb, text.replace(/^\S+\s*/, ''), event, ctx);
|
|
103
|
+
if (verb === 'discard' && words.length <= 2) return block(discard(payload, ref, ctx));
|
|
104
|
+
if (verb === 'load' && words.length <= 2) return block(load(payload, ref, ctx));
|
|
105
|
+
if (verb === 'stats' && words.length === 1) return block(statsReport(currentTotals(ctx.env, ctx.now), { inClaude: true }));
|
|
106
|
+
if (verb === 'help' && words.length === 1) return block(helpMessage(ctx.usage(payload)?.used ?? null, versionLine(ctx.version, ctx.env), ctx.tool));
|
|
107
|
+
const lone = words.length === 1;
|
|
108
|
+
const guess = meant(verb, lone ? NAMES : LONG_NAMES);
|
|
109
|
+
if (!lone && !guess) return begin(payload, DEFAULT_LEVEL, text, event, ctx);
|
|
110
|
+
const { wash } = toolInfo(ctx.tool);
|
|
111
|
+
const hint = guess ? ` Did you mean ${wash} ${guess}?` : '';
|
|
112
|
+
return block(`${wash} does not know "${verb}".${hint} For a note on what to keep, put a level first: ${wash} mid <note>. ${usageLine(ctx.tool)}`);
|
|
99
113
|
}
|
|
100
114
|
|
|
101
|
-
|
|
115
|
+
const NAMES = ['light', 'mid', 'detailed', 'discard', 'load', 'stats', 'help'];
|
|
116
|
+
// Short names are a letter away from everyday words ("right", "did", "lead")
|
|
117
|
+
// that may well start a note, so only these are checked there.
|
|
118
|
+
const LONG_NAMES = ['detailed', 'discard'];
|
|
119
|
+
|
|
120
|
+
// The name a typo was probably meant to be: one slip from a short name, two
|
|
121
|
+
// from a long one, where swapping two letters counts as one.
|
|
122
|
+
function meant(word, names) {
|
|
123
|
+
return names.find((name) => name !== word && slips(word, name) <= (name.length > 5 ? 2 : 1)) ?? null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function slips(a, b) {
|
|
127
|
+
const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
128
|
+
for (let j = 1; j <= b.length; j++) d[0][j] = j;
|
|
129
|
+
for (let i = 1; i <= a.length; i++) {
|
|
130
|
+
for (let j = 1; j <= b.length; j++) {
|
|
131
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
132
|
+
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
|
|
133
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return d[a.length][b.length];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function begin(payload, level, note, event, ctx) {
|
|
102
140
|
const { env, now } = ctx;
|
|
103
141
|
const usage = ctx.usage(payload);
|
|
104
142
|
const used = usage?.used ?? null;
|
|
105
143
|
const cap = capFor(level, used);
|
|
106
|
-
startWash(env, payload.session_id, { promptId: promptIdOf(payload), level, cap, used, size: usage?.size ?? null, startedAt: now, rewrites: 0 });
|
|
107
|
-
return { hookSpecificOutput: { hookEventName: event, additionalContext: budgetNote(level, cap, used) } };
|
|
144
|
+
startWash(env, payload.session_id, { promptId: promptIdOf(payload), level, cap, used, size: usage?.size ?? null, startedAt: now, rewrites: 0, ...(note ? { hasNote: true } : {}) });
|
|
145
|
+
return { hookSpecificOutput: { hookEventName: event, additionalContext: budgetNote(level, cap, used, note) } };
|
|
108
146
|
}
|
|
109
147
|
|
|
110
148
|
// Briefs waiting for the folder this session is in, other than its own. Only
|
|
@@ -211,7 +249,7 @@ function onStop(payload, ctx) {
|
|
|
211
249
|
const overCap = Boolean(wash.cap) && tokens > wash.cap;
|
|
212
250
|
if (overCap && wash.rewrites < MAX_REWRITES) {
|
|
213
251
|
startWash(env, sessionId, { ...wash, rewrites: wash.rewrites + 1 });
|
|
214
|
-
const note = retryNote(wash.level, wash.cap, tokens);
|
|
252
|
+
const note = retryNote(wash.level, wash.cap, tokens, wash.hasNote);
|
|
215
253
|
// Codex continues the turn with a Stop's block reason as the next prompt.
|
|
216
254
|
if (ctx.tool === 'codex') return { decision: 'block', reason: note };
|
|
217
255
|
return { hookSpecificOutput: { hookEventName: 'Stop', additionalContext: note } };
|
|
@@ -236,6 +274,7 @@ function onStop(payload, ctx) {
|
|
|
236
274
|
now,
|
|
237
275
|
);
|
|
238
276
|
quietly(ctx, () => recordWash(env, record, wash));
|
|
277
|
+
quietly(ctx, () => saveRecall(env, record.id, payload.transcript_path, ctx.tool));
|
|
239
278
|
// One ready brief per terminal. An earlier wash of this same session is out
|
|
240
279
|
// of date and goes; anything else this terminal held goes back to waiting.
|
|
241
280
|
for (const other of readyRecords(env, now)) {
|
|
@@ -257,7 +296,7 @@ function deliver(record, payload, ctx) {
|
|
|
257
296
|
const stale = stalenessLine(record.source, repoState(cwdOf(payload)));
|
|
258
297
|
return {
|
|
259
298
|
systemMessage: loadedMessage(record, now, stale, ctx.tool),
|
|
260
|
-
hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: composeContext(record, now, stale) },
|
|
299
|
+
hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: composeContext(record, now, stale, keptRecall(env, record.id)) },
|
|
261
300
|
};
|
|
262
301
|
}
|
|
263
302
|
|
|
@@ -275,9 +314,29 @@ function welcome(ctx) {
|
|
|
275
314
|
// Already shown, or nowhere to note it: better silent than said every time.
|
|
276
315
|
return null;
|
|
277
316
|
}
|
|
317
|
+
// The welcome already describes this version, so an update note goes with it.
|
|
318
|
+
rmSync(updatedFromPath(env, tool), { force: true });
|
|
278
319
|
return { systemMessage: welcomeMessage(tool) };
|
|
279
320
|
}
|
|
280
321
|
|
|
322
|
+
// After an update, the first quiet start in each tool says what is new, once.
|
|
323
|
+
// Deleting the installer's note is the claim, as creating the marker is for
|
|
324
|
+
// the welcome.
|
|
325
|
+
function whatsNew(ctx) {
|
|
326
|
+
const { env, tool } = ctx;
|
|
327
|
+
const file = updatedFromPath(env, tool);
|
|
328
|
+
if (!existsSync(file) || ctx.unattended()) return null;
|
|
329
|
+
let from;
|
|
330
|
+
try {
|
|
331
|
+
from = readFileSync(file, 'utf8').trim();
|
|
332
|
+
unlinkSync(file);
|
|
333
|
+
} catch {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
const message = whatsNewMessage(from, ctx.version, tool);
|
|
337
|
+
return message ? { systemMessage: message } : null;
|
|
338
|
+
}
|
|
339
|
+
|
|
281
340
|
function onSessionStart(payload, ctx) {
|
|
282
341
|
// Only a brand-new conversation gets a brief; a resumed, compacted or
|
|
283
342
|
// forked one already has its own history.
|
|
@@ -286,8 +345,9 @@ function onSessionStart(payload, ctx) {
|
|
|
286
345
|
// be asked through its command line, which on Windows takes a PowerShell
|
|
287
346
|
// run, so it is asked only once a brief or the welcome is at stake.
|
|
288
347
|
if (ctx.tool === 'claude' && ctx.unattended()) return null;
|
|
289
|
-
// The welcome waits for a start that has
|
|
290
|
-
|
|
348
|
+
// The welcome, or what is new since an update, waits for a start that has
|
|
349
|
+
// nothing else to say.
|
|
350
|
+
const result = briefAtStart(payload, ctx) ?? welcome(ctx) ?? whatsNew(ctx);
|
|
291
351
|
quietly(ctx, () => ctx.checkUpdates?.());
|
|
292
352
|
let notice = null;
|
|
293
353
|
quietly(ctx, () => (notice = startNotice(ctx.version, ctx.env)));
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { closeSync, existsSync, openSync, readSync } from 'node:fs';
|
|
2
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
3
|
+
import { privateDir, writeFileAtomic } from '../files.mjs';
|
|
4
|
+
import { recallDir, recallFile } from '../paths.mjs';
|
|
5
|
+
import { resultText } from '../stats/ledger.mjs';
|
|
6
|
+
import { toolInfo } from '../tools.mjs';
|
|
7
|
+
|
|
8
|
+
// A brief is a summary, so it leaves things out: the exact error, the path, the
|
|
9
|
+
// one number that matters. When a brief is saved, the session it came from is
|
|
10
|
+
// copied here as plain text, and the fresh session is told where to find it, so
|
|
11
|
+
// it can search for an exact detail instead of guessing.
|
|
12
|
+
//
|
|
13
|
+
// The session files themselves are poor to search. Theirs can run to hundreds
|
|
14
|
+
// of megabytes and one line of an image can be a megabyte or more, which a single
|
|
15
|
+
// search hit would pull into the conversation. This keeps only the words, ends
|
|
16
|
+
// at the wash so the brief is not in it twice, and wraps long lines. One file per
|
|
17
|
+
// brief, deleted after RECALL_DAYS or when the brief is discarded.
|
|
18
|
+
|
|
19
|
+
// Longest line kept whole, so one search hit stays a small one.
|
|
20
|
+
const WRAP = 2000;
|
|
21
|
+
const LONG_LINE = new RegExp(`.{1,${WRAP}}`, 'gsu');
|
|
22
|
+
|
|
23
|
+
// Past this, the oldest part of the session is left out. Real sessions are far
|
|
24
|
+
// smaller once images are gone: a few megabytes at the very most.
|
|
25
|
+
const MAX_BYTES = 16 * 1024 * 1024;
|
|
26
|
+
|
|
27
|
+
const CHUNK = 1024 * 1024;
|
|
28
|
+
|
|
29
|
+
// The lines of a file, without holding the whole of it in memory at once.
|
|
30
|
+
function* linesOf(file) {
|
|
31
|
+
const fd = openSync(file, 'r');
|
|
32
|
+
try {
|
|
33
|
+
const buffer = Buffer.alloc(CHUNK);
|
|
34
|
+
const decoder = new StringDecoder('utf8');
|
|
35
|
+
let rest = '';
|
|
36
|
+
for (;;) {
|
|
37
|
+
const read = readSync(fd, buffer, 0, CHUNK, null);
|
|
38
|
+
if (!read) break;
|
|
39
|
+
const lines = (rest + decoder.write(buffer.subarray(0, read))).split('\n');
|
|
40
|
+
rest = lines.pop();
|
|
41
|
+
yield* lines;
|
|
42
|
+
}
|
|
43
|
+
if (rest) yield rest;
|
|
44
|
+
} finally {
|
|
45
|
+
closeSync(fd);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// One transcript line as [who, text] pairs. Images, and anything else that is
|
|
50
|
+
// not words, come out empty and are dropped.
|
|
51
|
+
function claudeEntries(line) {
|
|
52
|
+
if ((line.type !== 'user' && line.type !== 'assistant') || line.isMeta || line.isSidechain) return [];
|
|
53
|
+
const content = line.message?.content;
|
|
54
|
+
if (typeof content === 'string') return [[line.type, content]];
|
|
55
|
+
if (!Array.isArray(content)) return [];
|
|
56
|
+
return content.flatMap((block) => {
|
|
57
|
+
if (block?.type === 'text') return [[line.type, block.text]];
|
|
58
|
+
if (block?.type === 'thinking') return [['assistant thinking', block.thinking]];
|
|
59
|
+
if (block?.type === 'tool_use') return [[`tool call ${block.name}`, JSON.stringify(block.input)]];
|
|
60
|
+
if (block?.type === 'tool_result') return [[block.is_error ? 'tool result (error)' : 'tool result', resultText(block)]];
|
|
61
|
+
return [];
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const partsText = (parts) => (Array.isArray(parts) ? parts.map((part) => part?.text ?? '').join('') : '');
|
|
66
|
+
|
|
67
|
+
// The same for a Codex rollout line. Codex says the format is not a stable
|
|
68
|
+
// interface, so anything unexpected is simply left out.
|
|
69
|
+
function codexEntries(line) {
|
|
70
|
+
if (line.type !== 'response_item') return [];
|
|
71
|
+
const item = line.payload;
|
|
72
|
+
switch (item?.type) {
|
|
73
|
+
case 'message':
|
|
74
|
+
return item.role === 'user' || item.role === 'assistant' ? [[item.role, partsText(item.content)]] : [];
|
|
75
|
+
case 'reasoning':
|
|
76
|
+
return [['assistant thinking', partsText(item.summary)]];
|
|
77
|
+
case 'function_call':
|
|
78
|
+
return [[`tool call ${item.name}`, item.arguments]];
|
|
79
|
+
case 'custom_tool_call':
|
|
80
|
+
return [[`tool call ${item.name}`, item.input]];
|
|
81
|
+
case 'function_call_output':
|
|
82
|
+
case 'custom_tool_call_output':
|
|
83
|
+
return [['tool result', typeof item.output === 'string' ? item.output : partsText(item.output)]];
|
|
84
|
+
default:
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const READERS = {
|
|
90
|
+
claude: { entries: claudeEntries, isWash: (text) => text.includes('<command-name>/wash</command-name>') || /^\s*\/wash(?![\w-])/.test(text) },
|
|
91
|
+
codex: { entries: codexEntries, isWash: (text) => /^\s*\$wash(?![\w-])/.test(text) },
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Everything said in a session, up to the last wash.
|
|
95
|
+
function readEntries(file, tool) {
|
|
96
|
+
const { entries: toEntries, isWash } = READERS[tool] ?? READERS.claude;
|
|
97
|
+
const entries = [];
|
|
98
|
+
let end = null;
|
|
99
|
+
for (const raw of linesOf(file)) {
|
|
100
|
+
let line;
|
|
101
|
+
try {
|
|
102
|
+
line = JSON.parse(raw);
|
|
103
|
+
} catch {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (!line || typeof line !== 'object') continue;
|
|
107
|
+
for (const [who, text] of toEntries(line)) {
|
|
108
|
+
if (typeof text !== 'string' || !text.trim()) continue;
|
|
109
|
+
if (who === 'user' && isWash(text)) end = entries.length;
|
|
110
|
+
entries.push({ who, time: line.timestamp, text });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return end === null ? entries : entries.slice(0, end);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const wrapped = (text) => text.split('\n').map((line) => (line.length > WRAP ? line.match(LONG_LINE).join('\n') : line)).join('\n');
|
|
117
|
+
|
|
118
|
+
function render(entries, tool) {
|
|
119
|
+
const kept = [];
|
|
120
|
+
let bytes = 0;
|
|
121
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
122
|
+
const { who, time, text } = entries[i];
|
|
123
|
+
const block = `--- #${i + 1} ${who}${time ? ` ${time}` : ''} ---\n${wrapped(text)}\n\n`;
|
|
124
|
+
bytes += Buffer.byteLength(block);
|
|
125
|
+
if (bytes > MAX_BYTES) break;
|
|
126
|
+
kept.push(block);
|
|
127
|
+
}
|
|
128
|
+
const { name, wash } = toolInfo(tool);
|
|
129
|
+
const dropped = entries.length - kept.length;
|
|
130
|
+
const note = dropped ? ` The first ${dropped === 1 ? 'entry is' : `${dropped} entries are`} left out too, to keep this file small.` : '';
|
|
131
|
+
return `A text copy of an earlier ${name} session, saved when ${wash} wrote its brief. Images are left out.${note}\nEach entry starts with a "--- #n who time ---" line.\n\n${kept.reverse().join('')}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Copy a session to the file for brief `id`. Returns that file, or null if
|
|
135
|
+
// there was nothing to copy. The brief works without it, so a caller should not
|
|
136
|
+
// let a failure here stop anything.
|
|
137
|
+
export function saveRecall(env, id, transcript, tool = 'claude') {
|
|
138
|
+
if (typeof transcript !== 'string' || !existsSync(transcript)) return null;
|
|
139
|
+
const entries = readEntries(transcript, tool);
|
|
140
|
+
if (!entries.length) return null;
|
|
141
|
+
privateDir(recallDir(env));
|
|
142
|
+
const file = recallFile(env, id);
|
|
143
|
+
writeFileAtomic(file, render(entries, tool), 0o600);
|
|
144
|
+
return file;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// The copy for brief `id`, or null if it is gone.
|
|
148
|
+
export function keptRecall(env, id) {
|
|
149
|
+
const file = recallFile(env, id);
|
|
150
|
+
return existsSync(file) ? file : null;
|
|
151
|
+
}
|
package/src/wash/store.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
|
-
import { existsSync, readdirSync, renameSync, statSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readdirSync, renameSync, rmSync, statSync, unlinkSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { privateDir, readJson, writePrivateJson } from '../files.mjs';
|
|
5
|
-
import { consumedDir, pendingDir, sessionsDir } from '../paths.mjs';
|
|
5
|
+
import { RECALL_DAYS, consumedDir, pendingDir, recallDir, recallFile, sessionsDir } from '../paths.mjs';
|
|
6
6
|
|
|
7
7
|
// The store of handoff briefs. One JSON file per brief, outside any project:
|
|
8
8
|
// pending/ holds briefs waiting for a fresh session, consumed/ holds delivered
|
|
@@ -62,7 +62,9 @@ export function updateRecord(env, record) {
|
|
|
62
62
|
if (existsSync(file)) writePrivateJson(file, record);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// Drop a waiting brief, and the copy of its session along with it.
|
|
65
66
|
export function removeRecord(env, id) {
|
|
67
|
+
rmSync(recallFile(env, id), { force: true });
|
|
66
68
|
try {
|
|
67
69
|
unlinkSync(fileIn(pendingDir(env), id));
|
|
68
70
|
return true;
|
|
@@ -132,7 +134,7 @@ function sweep(dir, isDue) {
|
|
|
132
134
|
}
|
|
133
135
|
|
|
134
136
|
// Delete what has outlived its use: expired briefs, delivered briefs past
|
|
135
|
-
// their retention, old session notes, and stray files.
|
|
137
|
+
// their retention, old session notes and copies, and stray files.
|
|
136
138
|
export function purge(env, now = Date.now()) {
|
|
137
139
|
const setting = (env.CARWASH_RETAIN_HOURS ?? '').trim();
|
|
138
140
|
const retainMs = (setting !== '' && Number(setting) >= 0 ? Number(setting) : RETAIN_HOURS) * HOUR;
|
|
@@ -148,4 +150,5 @@ export function purge(env, now = Date.now()) {
|
|
|
148
150
|
return Number.isFinite(consumed) ? now - consumed > retainMs : stale(file, HOUR);
|
|
149
151
|
});
|
|
150
152
|
sweep(sessionsDir(env), (file) => stale(file, SESSION_NOTE_MS));
|
|
153
|
+
sweep(recallDir(env), (file) => stale(file, RECALL_DAYS * 24 * HOUR));
|
|
151
154
|
}
|