context-carwash 0.2.3 → 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 +14 -0
- package/README.md +5 -4
- package/package.json +1 -1
- package/src/install/install.mjs +15 -1
- package/src/paths.mjs +4 -0
- package/src/skill/SKILL.md +6 -4
- package/src/skill/codex/SKILL.md +5 -3
- package/src/wash/brief.mjs +30 -7
- package/src/wash/hooks.mjs +78 -20
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
|
-
-
|
|
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
package/src/install/install.mjs
CHANGED
|
@@ -3,9 +3,10 @@ import { homedir } from 'node:os';
|
|
|
3
3
|
import { delimiter, dirname, join, resolve, sep } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { privateDir, readJson, writeFileAtomic } from '../files.mjs';
|
|
6
|
-
import { 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');
|
package/src/skill/SKILL.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: wash
|
|
3
3
|
description: Context Carwash. Writes a short handoff brief of this session, so that /clear can start a fresh session that carries on from it.
|
|
4
|
-
argument-hint: "[light | mid | detailed | discard | load <id> | 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
|
-
|
|
14
|
+
Carwash's hooks send a line with this command that starts with "Carwash: level". It gives the level for this brief (`light`, `mid`, or `detailed`), its token ceiling, and the user's note on what the brief must keep, if they wrote one. Go by that line, not by the arguments above. If there is no such line, Carwash's hooks are not running: write no brief, and tell the user in one sentence to run `carwash install` again.
|
|
15
15
|
|
|
16
16
|
Reply with the brief and nothing else: no preamble, no closing remark, no code fence around it. Do not call tools or read files to prepare. Write from what is already in this conversation.
|
|
17
17
|
|
|
@@ -30,9 +30,11 @@ Start with exactly this heading, then the Core section:
|
|
|
30
30
|
|
|
31
31
|
Core must stand on its own in under 1,000 characters. A session that reads only Core should still start in the right place.
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
If the user wrote a note, put a **Kept on request** section straight after Core, at every level. It holds everything the note asks for that this conversation has, with the exact wording of error messages, commands, paths, and numbers. If the note asks for something this conversation does not have, say so in one line instead of guessing. Never drop or shorten anything here to save space: cut from the other sections instead. Secrets stay out even when the note asks for them.
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
After that, add only the sections for the level, and skip any that would be empty:
|
|
36
|
+
|
|
37
|
+
- `light`: no other sections. Add one line for a critical constraint if breaking it would undo the work.
|
|
36
38
|
- `mid`: **Constraints** (the user's corrections and preferences that still apply), **Decisions** (what was settled, with the reason in a clause), **State** (files and branches changed, commands and tests run with their results, uncommitted work, blockers), **Pitfalls** (what to avoid, and approaches that failed).
|
|
37
39
|
- `detailed`: everything in `mid`, plus **Rationale** for the main decisions, **History** (experiments worth knowing about and what each one showed), **Test results**, **Risks and open assumptions**, and **Steps** (a checklist of what remains).
|
|
38
40
|
|
package/src/skill/codex/SKILL.md
CHANGED
|
@@ -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
|
|
14
|
+
The "Carwash: level" message gives the level for this brief (`light`, `mid`, or `detailed`), its token ceiling, and the user's note on what the brief must keep, if they wrote one. Go by that message, not by the words after $wash.
|
|
15
15
|
|
|
16
16
|
Reply with the brief and nothing else: no preamble, no closing remark, no code fence around it. Do not call tools or read files to prepare. Write from what is already in this conversation.
|
|
17
17
|
|
|
@@ -30,9 +30,11 @@ Start with exactly this heading, then the Core section:
|
|
|
30
30
|
|
|
31
31
|
Core must stand on its own in under 1,000 characters. A session that reads only Core should still start in the right place.
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
If the user wrote a note, put a **Kept on request** section straight after Core, at every level. It holds everything the note asks for that this conversation has, with the exact wording of error messages, commands, paths, and numbers. If the note asks for something this conversation does not have, say so in one line instead of guessing. Never drop or shorten anything here to save space: cut from the other sections instead. Secrets stay out even when the note asks for them.
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
After that, add only the sections for the level, and skip any that would be empty:
|
|
36
|
+
|
|
37
|
+
- `light`: no other sections. Add one line for a critical constraint if breaking it would undo the work.
|
|
36
38
|
- `mid`: **Constraints** (the user's corrections and preferences that still apply), **Decisions** (what was settled, with the reason in a clause), **State** (files and branches changed, commands and tests run with their results, uncommitted work, blockers), **Pitfalls** (what to avoid, and approaches that failed).
|
|
37
39
|
- `detailed`: everything in `mid`, plus **Rationale** for the main decisions, **History** (experiments worth knowing about and what each one showed), **Test results**, **Risks and open assumptions**, and **Steps** (a checklist of what remains).
|
|
38
40
|
|
package/src/wash/brief.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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.`,
|
package/src/wash/hooks.mjs
CHANGED
|
@@ -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
|
|
25
|
-
// size limit; discard, load,
|
|
26
|
-
// here and never reach the
|
|
24
|
+
// ours. A level or a note starts a wash and tells the
|
|
25
|
+
// model the size limit and the note; discard, load,
|
|
26
|
+
// stats and help are handled here and never reach the
|
|
27
|
+
// model.
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (verb
|
|
97
|
-
if (verb === '
|
|
98
|
-
if (verb === '
|
|
99
|
-
return block(
|
|
99
|
+
const text = String(args ?? '').trim();
|
|
100
|
+
const words = text.toLowerCase().split(/\s+/).filter(Boolean);
|
|
101
|
+
const [verb = DEFAULT_LEVEL, ref] = words;
|
|
102
|
+
if (isLevel(verb)) return begin(payload, verb, text.replace(/^\S+\s*/, ''), event, ctx);
|
|
103
|
+
if (verb === 'discard' && words.length <= 2) return block(discard(payload, ref, ctx));
|
|
104
|
+
if (verb === 'load' && words.length <= 2) return block(load(payload, ref, ctx));
|
|
105
|
+
if (verb === 'stats' && words.length === 1) return block(statsReport(currentTotals(ctx.env, ctx.now), { inClaude: true }));
|
|
106
|
+
if (verb === 'help' && words.length === 1) return block(helpMessage(ctx.usage(payload)?.used ?? null, versionLine(ctx.version, ctx.env), ctx.tool));
|
|
107
|
+
const lone = words.length === 1;
|
|
108
|
+
const guess = meant(verb, lone ? NAMES : LONG_NAMES);
|
|
109
|
+
if (!lone && !guess) return begin(payload, DEFAULT_LEVEL, text, event, ctx);
|
|
110
|
+
const { wash } = toolInfo(ctx.tool);
|
|
111
|
+
const hint = guess ? ` Did you mean ${wash} ${guess}?` : '';
|
|
112
|
+
return block(`${wash} does not know "${verb}".${hint} For a note on what to keep, put a level first: ${wash} mid <note>. ${usageLine(ctx.tool)}`);
|
|
100
113
|
}
|
|
101
114
|
|
|
102
|
-
|
|
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
|
|
292
|
-
|
|
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)));
|