context-carwash 0.2.3 → 0.2.6

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,19 @@
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
+
3
17
  ## 0.2.3
4
18
 
5
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.
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,7 +23,7 @@ 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
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
 
@@ -48,6 +48,7 @@ Your old conversation isn't gone. You can still find it in `/resume`, and the fr
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,7 +56,7 @@ Your old conversation isn't gone. You can still find it in `/resume`, and the fr
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.
@@ -66,7 +67,7 @@ A few things worth knowing:
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:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-carwash",
3
- "version": "0.2.3",
3
+ "version": "0.2.6",
4
4
  "description": "A quiet context meter and deliberate fresh-session handoff for Claude Code and Codex.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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 { RECALL_DAYS, carwashHome, claudeConfigDir, installRecordPath, legacyCommandPath, pendingDir, recallDir, 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'];
@@ -316,6 +317,19 @@ export function applyInstall(plan) {
316
317
  const extra = { ...(tools.length === 1 && tools[0] === 'claude' ? {} : { tools }), ...(codex ? { codex } : {}) };
317
318
  const nextRecord = plan.claude ? { ...base, ...applyClaudeInstall(plan), ...extra } : { ...base, ...extra };
318
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
+ }
319
333
  return nextRecord;
320
334
  }
321
335
 
package/src/paths.mjs CHANGED
@@ -57,6 +57,10 @@ export const noticePath = (env = process.env) => join(carwashHome(env), 'notice.
57
57
  // Present once the first-run welcome has been shown. Each tool says hello once.
58
58
  export const welcomedPath = (env = process.env, tool = 'claude') => join(carwashHome(env), tool === 'claude' ? 'welcomed' : `welcomed-${tool}`);
59
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
+
60
64
  // Where the /wash command is installed.
61
65
  export const skillDir = (env = process.env) => join(claudeConfigDir(env), 'skills', 'wash');
62
66
  export const legacyCommandPath = (env = process.env) => join(claudeConfigDir(env), 'commands', 'wash.md');
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: wash
3
3
  description: Context Carwash. Writes a short handoff brief of this session, so that /clear can start a fresh session that carries on from it.
4
- argument-hint: "[light | mid | detailed | discard | load <id> | 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
@@ -11,7 +11,7 @@ The user typed /wash. They want to start a fresh session that picks up where thi
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
 
@@ -11,7 +11,7 @@ The user typed $wash. Context Carwash saves your reply as a handoff brief, but o
11
11
 
12
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
 
@@ -1,3 +1,4 @@
1
+ import { isNewer } from '../install/update.mjs';
1
2
  import { CHARS_PER_TOKEN, capFor } from '../levels.mjs';
2
3
  import { RECALL_DAYS } from '../paths.mjs';
3
4
  import { colorAt, paint } from '../statusline/color.mjs';
@@ -6,7 +7,8 @@ import { toolInfo, toolOf } from '../tools.mjs';
6
7
 
7
8
  // The wording around a brief: what Claude is told when writing one, how its
8
9
  // reply is recognised, and what the user and the next session are shown. Also
9
- // 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.
10
12
  // Wording that names the tool or its commands takes the tool's id (see
11
13
  // tools.mjs); Claude Code is the default.
12
14
 
@@ -55,23 +57,26 @@ const sizeOf = (record) => `${record.level} ~${formatTokens(record.tokens)}`;
55
57
  // a3f9c2 "Fix login redirect" (mid ~1.1k, 14h ago)
56
58
  export const describe = (record, now) => `${record.id} "${record.title}" (${sizeOf(record)}, ${ageOf(record, now)})`;
57
59
 
58
- // Sent to Claude alongside the /wash prompt: the size limit for this brief.
59
- 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}` : '';
60
64
  if (!cap) {
61
- 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}`;
62
66
  }
63
67
  const chars = Math.max(100, Math.round((cap * CHARS_PER_TOKEN) / 100) * 100);
64
68
  return (
65
69
  `Carwash: level ${level}. This session is using about ${formatTokens(used)} tokens, so the ceiling for this brief is ${cap} tokens, ` +
66
- `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}`
67
71
  );
68
72
  }
69
73
 
70
74
  // Sent to Claude when its brief came out over the cap.
71
- 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';
72
77
  return (
73
78
  `Carwash: that brief is about ${tokens} tokens, over the ${cap}-token ceiling for ${level}. Send it again shortened to fit. ` +
74
- '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.`
75
80
  );
76
81
  }
77
82
 
@@ -142,6 +147,7 @@ export function welcomeMessage(tool = 'claude') {
142
147
  'Context Carwash installed ✓ (shown once)',
143
148
  ' • After each reply: tokens in use, and how full the context is',
144
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',
145
151
  ' • Nothing runs on its own · $wash help for options',
146
152
  ].join('\n');
147
153
  }
@@ -149,10 +155,25 @@ export function welcomeMessage(tool = 'claude') {
149
155
  'Context Carwash installed ✓ (shown once)',
150
156
  ' • Status line: tokens in use, and how full the context is',
151
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',
152
159
  ' • Nothing runs on its own · /wash help for options',
153
160
  ].join('\n');
154
161
  }
155
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
+
156
177
  // What each level keeps, for /wash help.
157
178
  const LEVEL_NOTES = {
158
179
  light: 'goal, where things stand, next step',
@@ -181,6 +202,8 @@ export function helpMessage(used, footer = null, tool = 'claude') {
181
202
  row('discard', 'drop the brief; /clear is a normal clear again'),
182
203
  row('load [id]', 'make a waiting brief the one /clear uses'),
183
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
+ '',
184
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."] : []),
185
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."] : []),
186
209
  `Writing a brief is an ordinary ${model} reply, so it uses tokens. help, discard and load do not.`,
@@ -1,13 +1,13 @@
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';
@@ -21,9 +21,10 @@ import { AUTO_LOAD_MS, addRecord, claim, findRecords, purge, readyRecords, remov
21
21
  // UserPromptExpansion /wash was typed (Claude Code). In Codex, where $wash
22
22
  // UserPromptSubmit is a skill, every prompt comes through
23
23
  // UserPromptSubmit and only one starting with $wash is
24
- // ours. A level starts a wash and tells the model the
25
- // size limit; discard, load, stats and help are handled
26
- // 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.
27
28
  // Stop The model finished replying. If that reply is the
28
29
  // brief, save it. Otherwise, in Codex, show the meter.
29
30
  // SessionStart A session began. If a brief is meant for it, hand the
@@ -37,7 +38,7 @@ import { AUTO_LOAD_MS, addRecord, claim, findRecords, purge, readyRecords, remov
37
38
 
38
39
  const usageLine = (tool) => {
39
40
  const wash = toolInfo(tool).wash;
40
- 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`;
41
42
  };
42
43
 
43
44
  // How many times to ask for a shorter brief before keeping an oversized one.
@@ -89,23 +90,59 @@ function onPromptSubmit(payload, ctx) {
89
90
  return found ? washCommand(payload, found[1], 'UserPromptSubmit', ctx) : null;
90
91
  }
91
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.
92
98
  function washCommand(payload, args, event, ctx) {
93
- const [verb = DEFAULT_LEVEL, ref] = String(args ?? '').trim().toLowerCase().split(/\s+/).filter(Boolean);
94
- if (isLevel(verb)) return begin(payload, verb, event, ctx);
95
- if (verb === 'discard') return block(discard(payload, ref, ctx));
96
- if (verb === 'load') return block(load(payload, ref, ctx));
97
- if (verb === 'stats') return block(statsReport(currentTotals(ctx.env, ctx.now), { inClaude: true }));
98
- if (verb === 'help') return block(helpMessage(ctx.usage(payload)?.used ?? null, versionLine(ctx.version, ctx.env), ctx.tool));
99
- 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)}`);
100
113
  }
101
114
 
102
- 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) {
103
140
  const { env, now } = ctx;
104
141
  const usage = ctx.usage(payload);
105
142
  const used = usage?.used ?? null;
106
143
  const cap = capFor(level, used);
107
- startWash(env, payload.session_id, { promptId: promptIdOf(payload), level, cap, used, size: usage?.size ?? null, startedAt: now, rewrites: 0 });
108
- 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) } };
109
146
  }
110
147
 
111
148
  // Briefs waiting for the folder this session is in, other than its own. Only
@@ -212,7 +249,7 @@ function onStop(payload, ctx) {
212
249
  const overCap = Boolean(wash.cap) && tokens > wash.cap;
213
250
  if (overCap && wash.rewrites < MAX_REWRITES) {
214
251
  startWash(env, sessionId, { ...wash, rewrites: wash.rewrites + 1 });
215
- const note = retryNote(wash.level, wash.cap, tokens);
252
+ const note = retryNote(wash.level, wash.cap, tokens, wash.hasNote);
216
253
  // Codex continues the turn with a Stop's block reason as the next prompt.
217
254
  if (ctx.tool === 'codex') return { decision: 'block', reason: note };
218
255
  return { hookSpecificOutput: { hookEventName: 'Stop', additionalContext: note } };
@@ -277,9 +314,29 @@ function welcome(ctx) {
277
314
  // Already shown, or nowhere to note it: better silent than said every time.
278
315
  return null;
279
316
  }
317
+ // The welcome already describes this version, so an update note goes with it.
318
+ rmSync(updatedFromPath(env, tool), { force: true });
280
319
  return { systemMessage: welcomeMessage(tool) };
281
320
  }
282
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
+
283
340
  function onSessionStart(payload, ctx) {
284
341
  // Only a brand-new conversation gets a brief; a resumed, compacted or
285
342
  // forked one already has its own history.
@@ -288,8 +345,9 @@ function onSessionStart(payload, ctx) {
288
345
  // be asked through its command line, which on Windows takes a PowerShell
289
346
  // run, so it is asked only once a brief or the welcome is at stake.
290
347
  if (ctx.tool === 'claude' && ctx.unattended()) return null;
291
- // The welcome waits for a start that has nothing else to say.
292
- 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);
293
351
  quietly(ctx, () => ctx.checkUpdates?.());
294
352
  let notice = null;
295
353
  quietly(ctx, () => (notice = startNotice(ctx.version, ctx.env)));