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 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 and three hooks to `~/.claude/settings.json` (after whatever you already have there) and a `/wash` command in `~/.claude/skills/wash`. For Codex, it adds three hooks to `~/.codex/hooks.json` and a `$wash` command in `~/.codex/skills/wash`.
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
- - You can tell Claude what to focus on: `/wash mid focus on the failing test`.
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 about a tenth of the price, so in dollars you're saving roughly a tenth of these numbers. You can also see this inside Claude Code with `/wash stats`.
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 and error logs live in `~/.carwash`, and only you can read them. 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
+ 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 hooks 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 and your stats.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-carwash",
3
- "version": "0.2.2",
3
+ "version": "0.2.5",
4
4
  "description": "A quiet context meter and deliberate fresh-session handoff for Claude Code and Codex.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
- return { readable: true, statusLine: isCarwashCommand(data?.statusLine?.command), hooks, nodes: [...new Set(ours.map(commandProgram).filter(Boolean))] };
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')))));
@@ -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 hooks; your other changes stay`,
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');
@@ -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 anything the brief leaves out is gone.
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
- The first word is the level: `light`, `mid`, or `detailed`. No arguments means `mid`. Anything after the level is the user's note on what to emphasise. If the first word is something else, Carwash's hooks are not running: write no brief, and tell the user in one sentence to run `carwash install` again.
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
- After Core, add only the sections for the level, and skip any that would be empty:
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
- - `light`: Core only. Add one line for a critical constraint if breaking it would undo the work.
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
 
@@ -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 anything the brief leaves out is gone.
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 words after $wash in the user's message are the arguments. The first word is the level: `light`, `mid`, or `detailed`. No arguments means `mid`. Anything after the level is the user's note on what to emphasise.
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
- After Core, add only the sections for the level, and skip any that would be empty:
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
- - `light`: Core only. Add one line for a critical constraint if breaking it would undo the work.
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
 
@@ -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.
@@ -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 about a tenth for it, so in money terms the saving is roughly a tenth of these numbers.'
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',
@@ -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 two places Carwash explains itself: the first-run welcome and /wash help.
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
- export function budgetNote(level, cap, used) {
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
- 'Keep the "# Carwash brief:" heading and the Core section, cut the least useful detail first, and reply with the brief alone.'
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
- export function composeContext(record, now, stale) {
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. The earlier conversation is not available to you.`,
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] : []),
@@ -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 model the
24
- // size limit; discard, load, stats and help are handled
25
- // here and never reach the model.
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 [verb = DEFAULT_LEVEL, ref] = String(args ?? '').trim().toLowerCase().split(/\s+/).filter(Boolean);
93
- if (isLevel(verb)) return begin(payload, verb, event, ctx);
94
- if (verb === 'discard') return block(discard(payload, ref, ctx));
95
- if (verb === 'load') return block(load(payload, ref, ctx));
96
- if (verb === 'stats') return block(statsReport(currentTotals(ctx.env, ctx.now), { inClaude: true }));
97
- if (verb === 'help') return block(helpMessage(ctx.usage(payload)?.used ?? null, versionLine(ctx.version, ctx.env), ctx.tool));
98
- return block(`${toolInfo(ctx.tool).wash} does not know "${verb}". ${usageLine(ctx.tool)}`);
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
- function begin(payload, level, event, ctx) {
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 nothing else to say.
290
- const result = briefAtStart(payload, ctx) ?? welcome(ctx);
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
+ }
@@ -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
  }