context-carwash 0.2.1 → 0.2.3
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 +11 -0
- package/README.md +12 -8
- package/package.json +1 -1
- package/src/cli.mjs +2 -3
- package/src/install/doctor.mjs +4 -2
- package/src/install/install.mjs +46 -3
- package/src/paths.mjs +10 -0
- package/src/skill/SKILL.md +1 -1
- package/src/skill/codex/SKILL.md +1 -1
- package/src/stats/habits.mjs +107 -0
- package/src/stats/ledger.mjs +150 -12
- package/src/stats/report.mjs +23 -5
- package/src/wash/brief.mjs +8 -2
- package/src/wash/hooks.mjs +6 -4
- package/src/wash/recall.mjs +151 -0
- package/src/wash/store.mjs +6 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.3
|
|
4
|
+
|
|
5
|
+
- 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.
|
|
6
|
+
- `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.
|
|
7
|
+
- `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.
|
|
8
|
+
|
|
9
|
+
## 0.2.2
|
|
10
|
+
|
|
11
|
+
- `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.
|
|
12
|
+
- `stats --share` sends the likely figure as well as the full one.
|
|
13
|
+
|
|
3
14
|
## 0.2.1
|
|
4
15
|
|
|
5
16
|
- Codex support. `install` sets up Codex too when it finds it: three hooks in `~/.codex/hooks.json` and a `$wash` command in `~/.codex/skills/wash`. Codex asks you to trust the hooks once. After `/clear`, the brief loads with your next message. Codex's status line can only show its own items, so the meter is a line after each reply instead, in colour from Codex 0.162.0 on. It is shown to you only and costs no tokens.
|
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ It shows you what it's going to change and asks before doing anything. Then star
|
|
|
25
25
|
|
|
26
26
|
When there's a new version, a line under the meter will tell you. To update, run `npx context-carwash@latest install`.
|
|
27
27
|
|
|
28
|
-
Under the hood, it adds a status line
|
|
28
|
+
Under the hood, it adds a status line, three hooks and one read-only permission to `~/.claude/settings.json` (after whatever you already have there) and a `/wash` command in `~/.claude/skills/wash`. The permission lets Claude search `~/.carwash/recall` without asking, and nothing else. For Codex, it adds three hooks to `~/.codex/hooks.json` and a `$wash` command in `~/.codex/skills/wash`.
|
|
29
29
|
|
|
30
30
|
## The Meter
|
|
31
31
|
|
|
@@ -40,7 +40,7 @@ The number is roughly how many tokens your conversation is using (Claude Code's
|
|
|
40
40
|
1. Type `/wash`. Claude writes a short brief: what you're working on, where things stand, and what's next. The status line switches to `Brief ready ✓`.
|
|
41
41
|
2. Type `/clear`. Your new session starts with the brief already loaded.
|
|
42
42
|
|
|
43
|
-
Your old conversation isn't gone. You can still find it in `/resume
|
|
43
|
+
Your old conversation isn't gone. You can still find it in `/resume`, and the fresh session can search a text copy of it when it needs an exact detail the brief left out, like an error message or a file path. It only searches when it needs to, so otherwise it costs nothing extra.
|
|
44
44
|
|
|
45
45
|
| Command | What it does |
|
|
46
46
|
|---|---|
|
|
@@ -60,7 +60,7 @@ A few things worth knowing:
|
|
|
60
60
|
- Writing a brief is a normal Claude reply, so it uses some tokens. `help`, `discard` and `load` are free.
|
|
61
61
|
- Bigger conversations get bigger briefs. `/wash help` shows the size limit for each level.
|
|
62
62
|
- 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.
|
|
63
|
+
- 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
64
|
|
|
65
65
|
## Codex
|
|
66
66
|
|
|
@@ -87,12 +87,16 @@ Carwash stats, since 3 Oct 2026 (12 days)
|
|
|
87
87
|
Briefs written 7 (light 1 · mid 5 · detailed 1)
|
|
88
88
|
Started fresh with one 6
|
|
89
89
|
Prompts in fresh sessions 94 (410 requests to Claude)
|
|
90
|
-
Context not re-sent ~21M tokens
|
|
90
|
+
Context not re-sent ~11M tokens (at most ~21M tokens)
|
|
91
91
|
Spent writing briefs ~0.9M tokens
|
|
92
|
-
Net saved ~20M tokens
|
|
92
|
+
Net saved ~10M tokens (at most ~20M tokens)
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
-
Every request to Claude or Codex sends the whole conversation again. After a wash, each request starts from the small brief instead, so Carwash counts the requests you actually made and adds up the difference.
|
|
95
|
+
Every request to Claude or Codex sends the whole conversation again. After a wash, each request starts from the small brief instead, so Carwash counts the requests you actually made and adds up the difference. Counting stops where the old conversation would have filled up and compacted on its own.
|
|
96
|
+
|
|
97
|
+
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
|
+
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`.
|
|
96
100
|
|
|
97
101
|
If you run `stats --share`, it shows you exactly what it would send before asking.
|
|
98
102
|
|
|
@@ -106,7 +110,7 @@ This checks that everything's still installed and working, and shows your versio
|
|
|
106
110
|
|
|
107
111
|
## Where things are kept
|
|
108
112
|
|
|
109
|
-
Your briefs, stats
|
|
113
|
+
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`.
|
|
110
114
|
|
|
111
115
|
## Uninstall
|
|
112
116
|
|
|
@@ -114,7 +118,7 @@ Your briefs, stats and error logs live in `~/.carwash`, and only you can read th
|
|
|
114
118
|
npx context-carwash uninstall
|
|
115
119
|
```
|
|
116
120
|
|
|
117
|
-
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
|
|
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, 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.
|
|
118
122
|
|
|
119
123
|
## Platforms
|
|
120
124
|
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -7,8 +7,7 @@ import { UserError } from './install/settings.mjs';
|
|
|
7
7
|
import { isNewer, latestVersion, runRelease } from './install/update.mjs';
|
|
8
8
|
import { errorLogPath, installRecordPath, welcomedPath } from './paths.mjs';
|
|
9
9
|
import { readJson } from './files.mjs';
|
|
10
|
-
import {
|
|
11
|
-
import { FORM, describeShare, sendShare, shareId, sharePayload, shareReady, statsReport } from './stats/report.mjs';
|
|
10
|
+
import { FORM, describeShare, sendShare, shareId, sharePayload, shareReady, statsReport, currentTotals } from './stats/report.mjs';
|
|
12
11
|
import { render } from './statusline/render.mjs';
|
|
13
12
|
import { statusline } from './statusline/run.mjs';
|
|
14
13
|
import { runHook } from './wash/hooks.mjs';
|
|
@@ -169,7 +168,7 @@ export async function main(argv, { env = process.env, stdin = process.stdin, std
|
|
|
169
168
|
return 0;
|
|
170
169
|
}
|
|
171
170
|
case 'stats': {
|
|
172
|
-
const sum =
|
|
171
|
+
const sum = currentTotals(env);
|
|
173
172
|
stdout.write(statsReport(sum) + '\n');
|
|
174
173
|
if (!rest.includes('--share')) return 0;
|
|
175
174
|
stdout.write('\n');
|
package/src/install/doctor.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { errorLogPath, errorsSeenPath, installRecordPath, settingsPath, skillDir
|
|
|
7
7
|
import { refreshLedger, totals } from '../stats/ledger.mjs';
|
|
8
8
|
import { readyRecords } from '../wash/store.mjs';
|
|
9
9
|
import { codexDetected, codexHooksCheck, codexSkillInstalled } from './codex.mjs';
|
|
10
|
-
import { commandProgram, isCarwashCommand, isCarwashHook } from './install.mjs';
|
|
10
|
+
import { commandProgram, isCarwashCommand, isCarwashHook, recallRule } from './install.mjs';
|
|
11
11
|
import { UPDATE_COMMAND } from './notice.mjs';
|
|
12
12
|
import { readSettings, resolveSettingsFile } from './settings.mjs';
|
|
13
13
|
import { isNewer } from './update.mjs';
|
|
@@ -77,7 +77,8 @@ function settingsCheck(env) {
|
|
|
77
77
|
const hooks = Object.fromEntries(HOOK_EVENTS.map((event) => [event, groups(event).some((group) => Array.isArray(group?.hooks) && group.hooks.some((hook) => isCarwashHook(hook?.command)))]));
|
|
78
78
|
const hookCommands = HOOK_EVENTS.flatMap((event) => groups(event).flatMap((group) => (Array.isArray(group?.hooks) ? group.hooks.map((hook) => hook?.command) : [])));
|
|
79
79
|
const ours = [data?.statusLine?.command, ...hookCommands].filter((command) => isCarwashCommand(command) || isCarwashHook(command));
|
|
80
|
-
|
|
80
|
+
const allow = data?.permissions?.allow;
|
|
81
|
+
return { readable: true, statusLine: isCarwashCommand(data?.statusLine?.command), hooks, recall: Array.isArray(allow) && allow.includes(recallRule(env)), nodes: [...new Set(ours.map(commandProgram).filter(Boolean))] };
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
// The Node that Carwash's commands start. Install writes its path into the
|
|
@@ -135,6 +136,7 @@ export function doctorReport({ env = process.env, now = Date.now(), running, new
|
|
|
135
136
|
} else {
|
|
136
137
|
out.push(row('Status line', yes(check.statusLine)));
|
|
137
138
|
out.push(row('Hooks', HOOK_EVENTS.map((event) => `${event} ${yes(check.hooks[event])}`).join(' · ')));
|
|
139
|
+
out.push(row('Session search', check.recall ? 'ok' : `MISSING: Claude will ask before searching an old session · to fix: ${UPDATE_COMMAND}`));
|
|
138
140
|
nodes.push(...check.nodes);
|
|
139
141
|
}
|
|
140
142
|
out.push(row('/wash command', yes(existsSync(join(skillDir(env), 'SKILL.md')))));
|
package/src/install/install.mjs
CHANGED
|
@@ -3,7 +3,7 @@ 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 } 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
9
|
|
|
@@ -128,6 +128,37 @@ function withCarwashHooks(hooks, command, file) {
|
|
|
128
128
|
return next;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
// The one permission Carwash adds: reading the saved copies of old sessions, so
|
|
132
|
+
// that a fresh session can search them without asking. A Read rule also covers
|
|
133
|
+
// Grep and read-only shell commands such as grep, and it allows nothing else.
|
|
134
|
+
export function recallRule(env = process.env) {
|
|
135
|
+
const posix = (path) => path.replace(/\\/g, '/').replace(/^([A-Za-z]):/, (_, drive) => `/${drive.toLowerCase()}`);
|
|
136
|
+
const dir = recallDir(env);
|
|
137
|
+
const path = dir.startsWith(homedir() + sep) ? `~/${posix(dir.slice(homedir().length + 1))}` : `/${posix(dir)}`;
|
|
138
|
+
return `Read(${path}/**)`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The `permissions` setting with that rule taken out. `allow`, or the whole
|
|
142
|
+
// setting, goes too if it only existed to hold ours. Returns the same object
|
|
143
|
+
// when the rule was not there.
|
|
144
|
+
function withoutRecallRule(permissions, rule) {
|
|
145
|
+
if (!isPlainObject(permissions) || !Array.isArray(permissions.allow) || !permissions.allow.includes(rule)) return permissions;
|
|
146
|
+
const { allow, ...others } = permissions;
|
|
147
|
+
const theirs = allow.filter((entry) => entry !== rule);
|
|
148
|
+
const next = theirs.length ? { ...permissions, allow: theirs } : others;
|
|
149
|
+
return Object.keys(next).length ? next : undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// The `permissions` setting with the rule added after whatever is there. One
|
|
153
|
+
// Carwash cannot read is left as it is: a fresh session then asks before it
|
|
154
|
+
// searches, which is all that is lost.
|
|
155
|
+
function withRecallRule(permissions, rule) {
|
|
156
|
+
if (permissions !== undefined && !isPlainObject(permissions)) return permissions;
|
|
157
|
+
if (permissions?.allow !== undefined && !Array.isArray(permissions.allow)) return permissions;
|
|
158
|
+
const theirs = withoutRecallRule(permissions, rule);
|
|
159
|
+
return { ...theirs, allow: [...(theirs?.allow ?? []), rule] };
|
|
160
|
+
}
|
|
161
|
+
|
|
131
162
|
function isOurSkill(file) {
|
|
132
163
|
try {
|
|
133
164
|
return readFileSync(file, 'utf8').includes(SKILL_MARK);
|
|
@@ -207,14 +238,18 @@ function planClaudeInstall({ env, record, script }) {
|
|
|
207
238
|
}
|
|
208
239
|
|
|
209
240
|
const theirHooks = withoutCarwashHooks(settings.data.hooks);
|
|
241
|
+
const rule = recallRule(env);
|
|
242
|
+
const permissions = withRecallRule(settings.data.permissions, rule);
|
|
210
243
|
const next = {
|
|
211
244
|
...settings.data,
|
|
212
245
|
statusLine: { refreshInterval: REFRESH_SECONDS, ...(original ?? {}), type: 'command', command: commandFor('statusline', script) },
|
|
213
246
|
hooks: withCarwashHooks(settings.data.hooks, commandFor('hook', script), file),
|
|
247
|
+
...(permissions === undefined ? {} : { permissions }),
|
|
214
248
|
};
|
|
215
249
|
const skill = planSkill(env);
|
|
250
|
+
const recall = Array.isArray(permissions?.allow) && permissions.allow.includes(rule) ? recallDir(env) : null;
|
|
216
251
|
|
|
217
|
-
return { file, settings, claudeRecord: record, original, alreadyInstalled, next, skill, keepsHooks: isPlainObject(theirHooks) && Object.keys(theirHooks).length > 0 };
|
|
252
|
+
return { file, settings, claudeRecord: record, original, alreadyInstalled, next, skill, recall, keepsHooks: isPlainObject(theirHooks) && Object.keys(theirHooks).length > 0 };
|
|
218
253
|
}
|
|
219
254
|
|
|
220
255
|
export function describeInstall(plan) {
|
|
@@ -224,6 +259,7 @@ export function describeInstall(plan) {
|
|
|
224
259
|
if (plan.codex) lines.push(...describeCodexInstall(plan.codex, plan.claude ? 4 : 2, plan.home));
|
|
225
260
|
const model = plan.claude && plan.codex ? 'Claude or Codex' : plan.codex ? 'Codex' : 'Claude';
|
|
226
261
|
lines.push('', `Writing a brief is an ordinary ${model} reply, so it uses tokens like any other.`);
|
|
262
|
+
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
263
|
// Version managers such as nvm keep a folder per Node version, so removing
|
|
228
264
|
// an old version takes this path with it, and nothing of Carwash can start.
|
|
229
265
|
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 +286,7 @@ function describeClaudeInstall(plan) {
|
|
|
250
286
|
}
|
|
251
287
|
lines.push(' Adds three hooks, so /wash can save a brief and a fresh session can');
|
|
252
288
|
lines.push(` pick it up.${plan.keepsHooks ? ' The hooks you already have are kept as they are.' : ''}`);
|
|
289
|
+
if (plan.recall) lines.push(` Lets Claude read ${tilde(plan.recall)} without asking, and nothing else.`);
|
|
253
290
|
if (plan.settings.exists && !plan.claudeRecord) {
|
|
254
291
|
lines.push(` A backup of the file is kept in ${tilde(join(plan.home, 'backup'))}`);
|
|
255
292
|
}
|
|
@@ -362,6 +399,12 @@ export function planUninstall({ env = process.env } = {}) {
|
|
|
362
399
|
else data.hooks = hooks;
|
|
363
400
|
changed = true;
|
|
364
401
|
}
|
|
402
|
+
const permissions = withoutRecallRule(data.permissions, recallRule(env));
|
|
403
|
+
if (permissions !== data.permissions) {
|
|
404
|
+
if (permissions === undefined) delete data.permissions;
|
|
405
|
+
else data.permissions = permissions;
|
|
406
|
+
changed = true;
|
|
407
|
+
}
|
|
365
408
|
if (changed) {
|
|
366
409
|
plan.action = 'edit';
|
|
367
410
|
plan.bytes = serializeSettings(data, settings);
|
|
@@ -395,7 +438,7 @@ export function describeUninstall(plan) {
|
|
|
395
438
|
const what = {
|
|
396
439
|
restore: `Restore ${tilde(plan.file)} to exactly what it was before install`,
|
|
397
440
|
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
|
|
441
|
+
edit: `Edit ${tilde(plan.file)}: undo Carwash's status line, hooks and read rule; your other changes stay`,
|
|
399
442
|
none: `Leave ${tilde(plan.file)} alone`,
|
|
400
443
|
}[plan.action];
|
|
401
444
|
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
|
|
|
@@ -60,3 +66,7 @@ export const legacyCommandPath = (env = process.env) => join(claudeConfigDir(env
|
|
|
60
66
|
export const codexHooksPath = (env = process.env) => join(codexHome(env), 'hooks.json');
|
|
61
67
|
export const codexSkillDir = (env = process.env) => join(codexHome(env), 'skills', 'wash');
|
|
62
68
|
export const codexConfigPath = (env = process.env) => join(codexHome(env), 'config.toml');
|
|
69
|
+
|
|
70
|
+
// What Carwash has learned about how far sessions usually go: a few numbers
|
|
71
|
+
// per transcript, so the transcripts need not be read again.
|
|
72
|
+
export const habitsPath = (env = process.env) => join(carwashHome(env), 'habits.json');
|
package/src/skill/SKILL.md
CHANGED
|
@@ -7,7 +7,7 @@ metadata:
|
|
|
7
7
|
installed-by: context-carwash
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
-
The user typed /wash. They want to start a fresh session that picks up where this one leaves off. Your whole reply to this message is saved as the handoff brief, and it is the first thing the next session reads. That session cannot see this conversation, so
|
|
10
|
+
The user typed /wash. They want to start a fresh session that picks up where this one leaves off. Your whole reply to this message is saved as the handoff brief, and it is the first thing the next session reads. That session cannot see this conversation. It can search a saved copy for an exact detail, but only if it knows to look, so the brief has to stand on its own.
|
|
11
11
|
|
|
12
12
|
Arguments: $ARGUMENTS
|
|
13
13
|
|
package/src/skill/codex/SKILL.md
CHANGED
|
@@ -9,7 +9,7 @@ The user typed $wash. Context Carwash saves your reply as a handoff brief, but o
|
|
|
9
9
|
|
|
10
10
|
**First, look for that message.** If this turn has no developer message starting with "Carwash: level", the hooks are not running and a brief would be lost. Then do not write a brief. Reply with only this: "Carwash's hooks aren't running in Codex yet, so no brief was saved. Restart Codex and choose "Trust all and continue" when it asks about hooks, then run $wash again. If that doesn't help, run `npx context-carwash doctor` in a terminal."
|
|
11
11
|
|
|
12
|
-
When the message is there: the user wants to start a fresh session that picks up where this one leaves off. Your whole reply to this message is saved as the handoff brief, and it is the first thing the next session reads. That session cannot see this conversation, so
|
|
12
|
+
When the message is there: the user wants to start a fresh session that picks up where this one leaves off. Your whole reply to this message is saved as the handoff brief, and it is the first thing the next session reads. That session cannot see this conversation. It can search a saved copy for an exact detail, but only if it knows to look, so the brief has to stand on its own.
|
|
13
13
|
|
|
14
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.
|
|
15
15
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { readJson, writePrivateJson } from '../files.mjs';
|
|
4
|
+
import { claudeConfigDir, habitsPath } from '../paths.mjs';
|
|
5
|
+
import { DEFAULT_SHARE, isPrompt, sentOf } from './ledger.mjs';
|
|
6
|
+
|
|
7
|
+
// How far this person usually takes a session, learned from their own Claude
|
|
8
|
+
// Code transcripts. A wash saves context only for the prompts that would
|
|
9
|
+
// otherwise have been sent in the old session, and that depends on habit:
|
|
10
|
+
// someone who often clears at 300k would not have gone on for long past it.
|
|
11
|
+
// Everything here stays on this machine; the cache holds only sizes.
|
|
12
|
+
|
|
13
|
+
const PRIOR_WEIGHT = 10; // the starting guess counts as this many sessions
|
|
14
|
+
const SETTLE_MS = 3_600_000; // touched this recently, a session may still be going
|
|
15
|
+
const FIRST_READ_MS = 90 * 24 * 3_600_000; // older transcripts are not read for the first time
|
|
16
|
+
|
|
17
|
+
const WASH = /<command-name>\/wash<\/command-name>(?:\\n)?<command-args>(?:light|mid|detailed)?<\/command-args>/;
|
|
18
|
+
|
|
19
|
+
// A session's shape: its size when each prompt was answered, whether it
|
|
20
|
+
// compacted (so went on to the limit), and whether it ended in a wash.
|
|
21
|
+
export function sessionShape(text) {
|
|
22
|
+
const sizes = [];
|
|
23
|
+
let waiting = false;
|
|
24
|
+
let compacted = false;
|
|
25
|
+
for (const raw of text.split('\n')) {
|
|
26
|
+
if (!raw.includes('"user"') && !raw.includes('"usage"') && !raw.includes('compact_boundary')) continue;
|
|
27
|
+
let line;
|
|
28
|
+
try {
|
|
29
|
+
line = JSON.parse(raw);
|
|
30
|
+
} catch {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (!line || line.isSidechain) continue;
|
|
34
|
+
if (line.type === 'system' && line.subtype === 'compact_boundary') {
|
|
35
|
+
compacted = true;
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
if (isPrompt(line)) waiting = true;
|
|
39
|
+
else if (waiting && line.type === 'assistant' && line.message?.usage) {
|
|
40
|
+
sizes.push(sentOf(line.message.usage));
|
|
41
|
+
waiting = false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { sizes, compacted, washed: WASH.test(text) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Bring the cache up to date with Claude Code's transcripts and return every
|
|
48
|
+
// session's shape. Transcripts Claude Code has since deleted stay in the cache.
|
|
49
|
+
export function learnHabits(env, { now = Date.now() } = {}) {
|
|
50
|
+
const file = habitsPath(env);
|
|
51
|
+
const sessions = readJson(file)?.sessions ?? {};
|
|
52
|
+
const root = join(claudeConfigDir(env), 'projects');
|
|
53
|
+
let changed = false;
|
|
54
|
+
let dirs = [];
|
|
55
|
+
try {
|
|
56
|
+
dirs = readdirSync(root);
|
|
57
|
+
} catch {
|
|
58
|
+
// no transcripts
|
|
59
|
+
}
|
|
60
|
+
for (const dir of dirs) {
|
|
61
|
+
let names;
|
|
62
|
+
try {
|
|
63
|
+
names = readdirSync(join(root, dir));
|
|
64
|
+
} catch {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
for (const name of names) {
|
|
68
|
+
if (!name.endsWith('.jsonl')) continue;
|
|
69
|
+
const path = join(root, dir, name);
|
|
70
|
+
try {
|
|
71
|
+
const { mtimeMs } = statSync(path);
|
|
72
|
+
if (now - mtimeMs < SETTLE_MS || sessions[path]?.mtime === mtimeMs) continue;
|
|
73
|
+
if (!sessions[path] && now - mtimeMs > FIRST_READ_MS) continue;
|
|
74
|
+
sessions[path] = { mtime: mtimeMs, ...sessionShape(readFileSync(path, 'utf8')) };
|
|
75
|
+
changed = true;
|
|
76
|
+
} catch {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (changed) {
|
|
82
|
+
try {
|
|
83
|
+
writePrivateJson(file, { version: 1, sessions });
|
|
84
|
+
} catch {
|
|
85
|
+
// learned again next time
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return Object.values(sessions).filter((s) => !s.washed && Array.isArray(s.sizes) && s.sizes.length);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The share of `turns` prompts, sent after washing a session of `before`
|
|
92
|
+
// tokens, that would likely have been sent in that session anyway. Each past
|
|
93
|
+
// session that reached the same size says how many more prompts it went on
|
|
94
|
+
// for; one that compacted went on to the limit. The starting guess holds until
|
|
95
|
+
// enough sessions have reached that size to outweigh it.
|
|
96
|
+
export function shareFor(sessions, before, turns) {
|
|
97
|
+
if (!(turns > 0) || !(before > 0)) return DEFAULT_SHARE;
|
|
98
|
+
let seen = 0;
|
|
99
|
+
let kept = 0;
|
|
100
|
+
for (const { sizes, compacted } of sessions) {
|
|
101
|
+
const at = sizes.findIndex((size) => size >= before);
|
|
102
|
+
if (at < 0) continue;
|
|
103
|
+
seen++;
|
|
104
|
+
kept += compacted ? 1 : Math.min(turns, sizes.length - 1 - at) / turns;
|
|
105
|
+
}
|
|
106
|
+
return (DEFAULT_SHARE * PRIOR_WEIGHT + kept) / (PRIOR_WEIGHT + seen);
|
|
107
|
+
}
|
package/src/stats/ledger.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { readJson, writePrivateJson } from '../files.mjs';
|
|
4
|
-
import {
|
|
4
|
+
import { estimateTokens } from '../levels.mjs';
|
|
5
|
+
import { consumedDir, statsDir } from '../paths.mjs';
|
|
5
6
|
import { toolOf } from '../tools.mjs';
|
|
6
7
|
import { tokenReading } from '../wash/rollout.mjs';
|
|
7
8
|
|
|
@@ -19,10 +20,18 @@ import { tokenReading } from '../wash/rollout.mjs';
|
|
|
19
20
|
|
|
20
21
|
const DAY = 24 * 3_600_000;
|
|
21
22
|
|
|
23
|
+
// Bumped when measuring changes, so kept measurements are redone.
|
|
24
|
+
const METHOD = 2;
|
|
25
|
+
|
|
22
26
|
// Roughly where Claude Code compacts a session on its own. Past this point the
|
|
23
27
|
// old session would have shrunk anyway, so requests beyond it count for nothing.
|
|
24
28
|
export const AUTO_COMPACT_SHARE = 0.9;
|
|
25
29
|
|
|
30
|
+
// The starting guess at how much of the work after a wash would have been
|
|
31
|
+
// done in the old session before it was cleared anyway. habits.mjs adjusts it
|
|
32
|
+
// from the person's own sessions.
|
|
33
|
+
export const DEFAULT_SHARE = 2 / 3;
|
|
34
|
+
|
|
26
35
|
const keyOf = (record) => `${Date.parse(record.createdAt).toString(36)}-${record.id}`;
|
|
27
36
|
const fileOf = (env, key) => join(statsDir(env), `${key}.json`);
|
|
28
37
|
|
|
@@ -37,6 +46,7 @@ export function recordWash(env, record, wash) {
|
|
|
37
46
|
used: record.used ?? null,
|
|
38
47
|
size: wash.size ?? null,
|
|
39
48
|
tokens: record.tokens,
|
|
49
|
+
source: record.source?.transcriptPath ?? null,
|
|
40
50
|
attempts: (wash.rewrites ?? 0) + 1,
|
|
41
51
|
deliveries: [],
|
|
42
52
|
});
|
|
@@ -71,21 +81,81 @@ export function readLedger(env) {
|
|
|
71
81
|
|
|
72
82
|
// A line of the transcript the user typed, as opposed to a tool result, a
|
|
73
83
|
// shell command, a slash command's output or a compaction summary.
|
|
74
|
-
function isPrompt(line) {
|
|
84
|
+
export function isPrompt(line) {
|
|
75
85
|
if (line.type !== 'user' || line.isMeta || line.isCompactSummary) return false;
|
|
76
86
|
const content = line.message?.content;
|
|
77
87
|
if (typeof content === 'string') return !content.trimStart().startsWith('<');
|
|
78
88
|
return Array.isArray(content) && content.some((block) => block?.type === 'text') && !content.some((block) => block?.type === 'tool_result');
|
|
79
89
|
}
|
|
80
90
|
|
|
91
|
+
// The size of one request: everything the model was sent.
|
|
92
|
+
export const sentOf = (usage) => (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
|
|
93
|
+
|
|
94
|
+
const blocksOf = (line) => (Array.isArray(line.message?.content) ? line.message.content : []);
|
|
95
|
+
|
|
96
|
+
export const resultText = (block) =>
|
|
97
|
+
typeof block.content === 'string' ? block.content : Array.isArray(block.content) ? block.content.map((part) => (part?.type === 'text' ? part.text : '')).join('') : '';
|
|
98
|
+
|
|
99
|
+
// Lines shorter than this are too common to say a file was read again.
|
|
100
|
+
const MIN_LINE = 24;
|
|
101
|
+
|
|
102
|
+
// A line as a tool shows it, without the line number a file read adds.
|
|
103
|
+
const normal = (line) => line.replace(/^\s*\d+(?:\t|→)/, '').trim();
|
|
104
|
+
|
|
105
|
+
function* lines(text) {
|
|
106
|
+
for (const line of text.split('\n')) {
|
|
107
|
+
const clean = normal(line);
|
|
108
|
+
if (clean.length >= MIN_LINE) yield clean;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Every line a session still held from tools and from the files it wrote:
|
|
113
|
+
// whatever came before its last compaction was summarised away. Compared by
|
|
114
|
+
// content, so a file read with cat counts the same as one read with Read.
|
|
115
|
+
export function heldLines(text) {
|
|
116
|
+
const held = new Set();
|
|
117
|
+
for (const raw of text.split('\n')) {
|
|
118
|
+
if (!raw.includes('tool_') && !raw.includes('compact_boundary')) continue;
|
|
119
|
+
let line;
|
|
120
|
+
try {
|
|
121
|
+
line = JSON.parse(raw);
|
|
122
|
+
} catch {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (!line || line.isSidechain) continue;
|
|
126
|
+
if (line.type === 'system' && line.subtype === 'compact_boundary') held.clear();
|
|
127
|
+
for (const block of blocksOf(line)) {
|
|
128
|
+
const texts = block?.type === 'tool_result' ? [resultText(block)] : block?.type === 'tool_use' ? [block.input?.content, block.input?.new_string] : [];
|
|
129
|
+
for (const text of texts) if (typeof text === 'string') for (const clean of lines(text)) held.add(clean);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return held;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The tokens of a tool result the old session already held.
|
|
136
|
+
function repeated(text, held) {
|
|
137
|
+
let chars = 0;
|
|
138
|
+
for (const clean of lines(text)) if (held.has(clean)) chars += clean.length + 1;
|
|
139
|
+
return chars ? estimateTokens('x'.repeat(chars)) : 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
81
142
|
// Count what a fresh session sent after a wash. `before` is the old session's
|
|
82
|
-
// size when the brief was written, `size` its context window
|
|
83
|
-
|
|
143
|
+
// size when the brief was written, `size` its context window, `held` the
|
|
144
|
+
// lines of tool output the old session already had.
|
|
145
|
+
//
|
|
146
|
+
// `turns` breaks the count down by prompt, as [requests, reread]: how many
|
|
147
|
+
// requests each prompt made, and how many of the tokens they sent were tool
|
|
148
|
+
// output the old session already had, such as files read again. The old session already held those, so
|
|
149
|
+
// they are not a saving. `likelyOf` uses this to count only the prompts that
|
|
150
|
+
// would have been sent in the old session anyway.
|
|
151
|
+
export function measure(text, before, size, { held = null } = {}) {
|
|
84
152
|
const limit = size ? size * AUTO_COMPACT_SHARE : Infinity;
|
|
85
153
|
const seen = new Set();
|
|
154
|
+
const turns = [];
|
|
86
155
|
let start = null;
|
|
87
156
|
let prompts = 0;
|
|
88
157
|
let requests = 0;
|
|
158
|
+
let reread = 0;
|
|
89
159
|
for (const raw of text.split('\n')) {
|
|
90
160
|
let line;
|
|
91
161
|
try {
|
|
@@ -98,6 +168,11 @@ export function measure(text, before, size) {
|
|
|
98
168
|
if (line.type === 'system' && line.subtype === 'compact_boundary') break;
|
|
99
169
|
if (isPrompt(line)) {
|
|
100
170
|
prompts++;
|
|
171
|
+
turns.push([0, 0]);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (line.type === 'user') {
|
|
175
|
+
if (held) for (const block of blocksOf(line)) if (block?.type === 'tool_result') reread += repeated(resultText(block), held);
|
|
101
176
|
continue;
|
|
102
177
|
}
|
|
103
178
|
if (line.type !== 'assistant') continue;
|
|
@@ -106,13 +181,16 @@ export function measure(text, before, size) {
|
|
|
106
181
|
const usage = line.message?.usage;
|
|
107
182
|
if (!id || !usage || seen.has(id)) continue;
|
|
108
183
|
seen.add(id);
|
|
109
|
-
const sent = (usage
|
|
184
|
+
const sent = sentOf(usage);
|
|
110
185
|
start ??= sent;
|
|
111
186
|
if (before - start <= 0 || sent + (before - start) > limit) break;
|
|
112
187
|
requests++;
|
|
188
|
+
if (!turns.length) turns.push([0, 0]);
|
|
189
|
+
turns.at(-1)[0]++;
|
|
190
|
+
turns.at(-1)[1] += reread;
|
|
113
191
|
}
|
|
114
192
|
const gap = start === null ? 0 : Math.max(0, before - start);
|
|
115
|
-
return { prompts: requests ? prompts : 0, requests, start, saved: gap * requests };
|
|
193
|
+
return { prompts: requests ? prompts : 0, requests, start, saved: gap * requests, turns: turns.filter(([n]) => n > 0) };
|
|
116
194
|
}
|
|
117
195
|
|
|
118
196
|
// The same count from a Codex session file. A person's prompt is a UserMessage
|
|
@@ -122,7 +200,10 @@ export function measure(text, before, size) {
|
|
|
122
200
|
export function measureCodex(text, before, size) {
|
|
123
201
|
const limit = size ? size * AUTO_COMPACT_SHARE : Infinity;
|
|
124
202
|
const records = text.includes('"token_usage_record"');
|
|
203
|
+
// Newer Codex marks a prompt both ways; count turns by whichever it uses.
|
|
204
|
+
const byItem = text.includes('"UserMessage"');
|
|
125
205
|
const seen = new Set();
|
|
206
|
+
const turns = [];
|
|
126
207
|
let thread = null;
|
|
127
208
|
let start = null;
|
|
128
209
|
let items = 0;
|
|
@@ -142,10 +223,12 @@ export function measureCodex(text, before, size) {
|
|
|
142
223
|
if (line.type === 'compacted' || (line.type === 'event_msg' && payload.type === 'context_compacted')) break;
|
|
143
224
|
if (line.type === 'event_msg' && payload.type === 'item_completed' && payload.item?.type === 'UserMessage') {
|
|
144
225
|
items++;
|
|
226
|
+
if (byItem) turns.push([0, 0]);
|
|
145
227
|
continue;
|
|
146
228
|
}
|
|
147
229
|
if (line.type === 'event_msg' && payload.type === 'user_message') {
|
|
148
230
|
events++;
|
|
231
|
+
if (!byItem) turns.push([0, 0]);
|
|
149
232
|
continue;
|
|
150
233
|
}
|
|
151
234
|
let sent = null;
|
|
@@ -162,9 +245,11 @@ export function measureCodex(text, before, size) {
|
|
|
162
245
|
start ??= sent;
|
|
163
246
|
if (before - start <= 0 || sent + (before - start) > limit) break;
|
|
164
247
|
requests++;
|
|
248
|
+
if (!turns.length) turns.push([0, 0]);
|
|
249
|
+
turns.at(-1)[0]++;
|
|
165
250
|
}
|
|
166
251
|
const gap = start === null ? 0 : Math.max(0, before - start);
|
|
167
|
-
return { prompts: requests ? Math.max(items, events) : 0, requests, start, saved: gap * requests };
|
|
252
|
+
return { prompts: requests ? Math.max(items, events) : 0, requests, start, saved: gap * requests, turns: turns.filter(([n]) => n > 0) };
|
|
168
253
|
}
|
|
169
254
|
|
|
170
255
|
// Bring each delivery's measurement up to date with its transcript. Without
|
|
@@ -176,6 +261,11 @@ export function refreshLedger(env, { now = Date.now(), force = false } = {}) {
|
|
|
176
261
|
for (const entry of entries) {
|
|
177
262
|
if (!(entry.used > 0)) continue;
|
|
178
263
|
let changed = false;
|
|
264
|
+
if (entry.source === undefined) {
|
|
265
|
+
entry.source = sourceOf(env, entry);
|
|
266
|
+
changed = entry.source !== null;
|
|
267
|
+
}
|
|
268
|
+
let held;
|
|
179
269
|
for (const delivery of entry.deliveries) {
|
|
180
270
|
const last = delivery.measured;
|
|
181
271
|
if (last && !force && now - last.at < DAY) continue;
|
|
@@ -185,15 +275,20 @@ export function refreshLedger(env, { now = Date.now(), force = false } = {}) {
|
|
|
185
275
|
} catch {
|
|
186
276
|
continue;
|
|
187
277
|
}
|
|
188
|
-
|
|
278
|
+
// Measured the old way: measure again while the transcript is here.
|
|
279
|
+
if (last && last.mtime === mtime && last.method === METHOD) continue;
|
|
189
280
|
let text;
|
|
190
281
|
try {
|
|
191
282
|
text = readFileSync(delivery.transcript, 'utf8');
|
|
192
283
|
} catch {
|
|
193
284
|
continue;
|
|
194
285
|
}
|
|
195
|
-
|
|
196
|
-
|
|
286
|
+
if (toolOf(delivery) === 'codex') {
|
|
287
|
+
delivery.measured = { ...measureCodex(text, entry.used, entry.size), method: METHOD, mtime, at: now };
|
|
288
|
+
} else {
|
|
289
|
+
if (held === undefined) held = toolOf(entry) === 'claude' ? oldLines(entry.source) : null;
|
|
290
|
+
delivery.measured = { ...measure(text, entry.used, entry.size, { held }), method: METHOD, mtime, at: now };
|
|
291
|
+
}
|
|
197
292
|
changed = true;
|
|
198
293
|
}
|
|
199
294
|
if (changed) writePrivateJson(fileOf(env, entry.key), entry);
|
|
@@ -201,12 +296,49 @@ export function refreshLedger(env, { now = Date.now(), force = false } = {}) {
|
|
|
201
296
|
return entries;
|
|
202
297
|
}
|
|
203
298
|
|
|
299
|
+
// Where the washed session's transcript is, for entries written before the
|
|
300
|
+
// ledger kept it: the delivered brief remembers, until it is purged.
|
|
301
|
+
function sourceOf(env, entry) {
|
|
302
|
+
const record = readJson(join(consumedDir(env), `${entry.key.slice(entry.key.indexOf('-') + 1)}.json`));
|
|
303
|
+
return typeof record?.source?.transcriptPath === 'string' ? record.source.transcriptPath : null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function oldLines(path) {
|
|
307
|
+
if (typeof path !== 'string') return null;
|
|
308
|
+
try {
|
|
309
|
+
return heldLines(readFileSync(path, 'utf8'));
|
|
310
|
+
} catch {
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// The saving if only the first `share` of the fresh session's prompts would
|
|
316
|
+
// have been sent in the old session before it was cleared anyway. The
|
|
317
|
+
// measured total counts every prompt; this is the likelier figure. Files read
|
|
318
|
+
// again are taken off. Entries measured before turns were kept fall back to
|
|
319
|
+
// the same share of the total.
|
|
320
|
+
export function likelyOf(measured, share) {
|
|
321
|
+
if (!measured) return 0;
|
|
322
|
+
if (!Array.isArray(measured.turns)) return Math.round(measured.saved * share);
|
|
323
|
+
const gap = measured.requests ? measured.saved / measured.requests : 0;
|
|
324
|
+
const keep = measured.turns.length * share;
|
|
325
|
+
let total = 0;
|
|
326
|
+
measured.turns.forEach(([requests, reread], i) => {
|
|
327
|
+
total += Math.min(1, Math.max(0, keep - i)) * Math.max(0, gap * requests - reread);
|
|
328
|
+
});
|
|
329
|
+
return Math.round(total);
|
|
330
|
+
}
|
|
331
|
+
|
|
204
332
|
// What writing a brief cost: each attempt re-read the old session and wrote
|
|
205
333
|
// the brief out.
|
|
206
334
|
export const costOf = (entry) => (entry.used > 0 ? entry.attempts * (entry.used + entry.tokens) : 0);
|
|
207
335
|
|
|
208
|
-
|
|
209
|
-
|
|
336
|
+
// `shareOf` says, for a delivery, what share of its prompts would likely have
|
|
337
|
+
// been sent in the old session anyway.
|
|
338
|
+
export function totals(entries, now = Date.now(), { shareOf = () => DEFAULT_SHARE } = {}) {
|
|
339
|
+
const sum = { washes: entries.length, levels: {}, tools: [], delivered: 0, prompts: 0, requests: 0, notResent: 0, likely: 0, share: DEFAULT_SHARE, briefCost: 0, since: null, days: 0 };
|
|
340
|
+
let weighed = 0;
|
|
341
|
+
let turns = 0;
|
|
210
342
|
for (const entry of entries) {
|
|
211
343
|
sum.levels[entry.level] = (sum.levels[entry.level] ?? 0) + 1;
|
|
212
344
|
for (const tool of [toolOf(entry), ...entry.deliveries.map(toolOf)]) if (!sum.tools.includes(tool)) sum.tools.push(tool);
|
|
@@ -217,8 +349,14 @@ export function totals(entries, now = Date.now()) {
|
|
|
217
349
|
sum.prompts += measured.prompts;
|
|
218
350
|
sum.requests += measured.requests;
|
|
219
351
|
sum.notResent += measured.saved;
|
|
352
|
+
const share = shareOf(entry, measured);
|
|
353
|
+
sum.likely += likelyOf(measured, share);
|
|
354
|
+
const count = measured.turns?.length ?? measured.prompts;
|
|
355
|
+
weighed += share * count;
|
|
356
|
+
turns += count;
|
|
220
357
|
}
|
|
221
358
|
}
|
|
359
|
+
if (turns) sum.share = weighed / turns;
|
|
222
360
|
if (entries.length) {
|
|
223
361
|
sum.since = entries[0].washedAt;
|
|
224
362
|
sum.days = Math.max(1, Math.ceil((now - Date.parse(sum.since)) / DAY));
|
package/src/stats/report.mjs
CHANGED
|
@@ -2,6 +2,8 @@ import { randomBytes } from 'node:crypto';
|
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { writeFileAtomic } from '../files.mjs';
|
|
4
4
|
import { shareIdPath } from '../paths.mjs';
|
|
5
|
+
import { learnHabits, shareFor } from './habits.mjs';
|
|
6
|
+
import { refreshLedger, totals } from './ledger.mjs';
|
|
5
7
|
import { formatTokens } from '../statusline/render.mjs';
|
|
6
8
|
import { toolInfo } from '../tools.mjs';
|
|
7
9
|
|
|
@@ -12,6 +14,16 @@ const LEVEL_ORDER = ['light', 'mid', 'detailed'];
|
|
|
12
14
|
|
|
13
15
|
const tokens = (n) => `~${formatTokens(n)} tokens`;
|
|
14
16
|
|
|
17
|
+
// The ledger measured afresh, with the likely saving judged by this person's
|
|
18
|
+
// own habits.
|
|
19
|
+
export function currentTotals(env, now = Date.now()) {
|
|
20
|
+
const sessions = learnHabits(env, { now });
|
|
21
|
+
const sum = totals(refreshLedger(env, { now, force: true }), now, { shareOf: (entry, measured) => shareFor(sessions, entry.used, measured.turns?.length ?? measured.prompts) });
|
|
22
|
+
return { ...sum, learnedFrom: sessions.length };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const percent = (share) => `${Math.round(share * 100)}%`;
|
|
26
|
+
|
|
15
27
|
// Who the requests went to: Claude, Codex, or both.
|
|
16
28
|
const modelName = (tools = ['claude']) => (tools.length === 1 ? toolInfo(tools[0]).model : 'the model');
|
|
17
29
|
|
|
@@ -21,7 +33,9 @@ export function statsReport(sum, { inClaude = false } = {}) {
|
|
|
21
33
|
}
|
|
22
34
|
const since = new Date(sum.since).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
|
23
35
|
const levels = LEVEL_ORDER.filter((name) => sum.levels[name]).map((name) => `${name} ${sum.levels[name]}`).join(' · ');
|
|
24
|
-
const
|
|
36
|
+
const likely = sum.likely ?? sum.notResent;
|
|
37
|
+
const net = likely - sum.briefCost;
|
|
38
|
+
const most = sum.notResent - sum.briefCost;
|
|
25
39
|
const row = (label, value) => ` ${label.padEnd(28)}${value}`;
|
|
26
40
|
const model = modelName(sum.tools);
|
|
27
41
|
return [
|
|
@@ -30,13 +44,14 @@ export function statsReport(sum, { inClaude = false } = {}) {
|
|
|
30
44
|
row('Briefs written', `${sum.washes}${levels ? ` (${levels})` : ''}`),
|
|
31
45
|
row('Started fresh with one', `${sum.delivered}`),
|
|
32
46
|
row('Prompts in fresh sessions', `${sum.prompts} (${sum.requests} requests to ${model})`),
|
|
33
|
-
row('Context not re-sent', tokens(sum.notResent)),
|
|
47
|
+
row('Context not re-sent', `${tokens(likely)} (at most ${tokens(sum.notResent)})`),
|
|
34
48
|
row('Spent writing briefs', tokens(sum.briefCost)),
|
|
35
|
-
row(net >= 0 ? 'Net saved' : 'Net cost so far', tokens(Math.abs(net))),
|
|
49
|
+
row(net >= 0 ? 'Net saved' : 'Net cost so far', `${tokens(Math.abs(net))} (at most ${most >= 0 ? tokens(most) : 'nothing'})`),
|
|
36
50
|
'',
|
|
37
51
|
`How it is worked out: every request to ${model} carries the whole conversation. After a wash, each request in the fresh session starts from the brief instead of the old conversation, and the difference is context that was not sent again. Counting stops where the old session would have compacted on its own.`,
|
|
52
|
+
`You would not have gone on forever in the old session, so the main figure counts only the first ${percent(sum.share ?? 2 / 3)} of the prompts after each wash. That share starts at two-thirds and is learned from how far your own sessions go (${sum.learnedFrom ?? 0} so far). Files the fresh session had to read again are taken off. "At most" counts every prompt.`,
|
|
38
53
|
model === 'Claude'
|
|
39
|
-
? 'Claude caches repeated context and charges
|
|
54
|
+
? 'Claude caches repeated context and charges far less for it: a twentieth of the normal input price on Opus 5.5 and Sonnet 5.5, a tenth on most older models. In money terms the saving is that share of these numbers.'
|
|
40
55
|
: 'Repeated context is cached and charged at about a tenth, so in money terms the saving is roughly a tenth of these numbers.',
|
|
41
56
|
'',
|
|
42
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',
|
|
@@ -57,6 +72,7 @@ export const FORM = {
|
|
|
57
72
|
washes: 'entry.822622585',
|
|
58
73
|
prompts: 'entry.93311455',
|
|
59
74
|
notResent: 'entry.1048114223',
|
|
75
|
+
likely: 'entry.1068811941',
|
|
60
76
|
briefCost: 'entry.164624594',
|
|
61
77
|
},
|
|
62
78
|
};
|
|
@@ -72,6 +88,7 @@ export function sharePayload(sum, { shareId, version, platform = process.platfor
|
|
|
72
88
|
washes: sum.washes,
|
|
73
89
|
prompts: sum.prompts,
|
|
74
90
|
notResent: sum.notResent,
|
|
91
|
+
likely: sum.likely ?? sum.notResent,
|
|
75
92
|
briefCost: sum.briefCost,
|
|
76
93
|
};
|
|
77
94
|
}
|
|
@@ -83,7 +100,8 @@ const LABELS = {
|
|
|
83
100
|
days: 'Days since first brief',
|
|
84
101
|
washes: 'Briefs written',
|
|
85
102
|
prompts: 'Prompts in fresh sessions',
|
|
86
|
-
notResent: 'Context not re-sent',
|
|
103
|
+
notResent: 'Context not re-sent, at most',
|
|
104
|
+
likely: 'Context not re-sent, likely',
|
|
87
105
|
briefCost: 'Spent writing briefs',
|
|
88
106
|
};
|
|
89
107
|
|
package/src/wash/brief.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CHARS_PER_TOKEN, capFor } from '../levels.mjs';
|
|
2
|
+
import { RECALL_DAYS } from '../paths.mjs';
|
|
2
3
|
import { colorAt, paint } from '../statusline/color.mjs';
|
|
3
4
|
import { formatTokens } from '../statusline/render.mjs';
|
|
4
5
|
import { toolInfo, toolOf } from '../tools.mjs';
|
|
@@ -183,6 +184,7 @@ export function helpMessage(used, footer = null, tool = 'claude') {
|
|
|
183
184
|
...(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
185
|
...(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
186
|
`Writing a brief is an ordinary ${model} reply, so it uses tokens. help, discard and load do not.`,
|
|
187
|
+
`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
188
|
...(footer ? ['', footer] : []),
|
|
187
189
|
].join('\n');
|
|
188
190
|
}
|
|
@@ -202,11 +204,15 @@ export function pointerMessage(records, now, why, tool = 'claude') {
|
|
|
202
204
|
// What the fresh session's model is given. The framing is kept short: when the
|
|
203
205
|
// whole thing is over Claude Code's limit for hook output, only the first 2,000
|
|
204
206
|
// characters arrive inline, and the brief's Core section has to be among them.
|
|
205
|
-
|
|
207
|
+
// `recall` is the saved copy of the earlier conversation, when there is one.
|
|
208
|
+
export function composeContext(record, now, stale, recall = null) {
|
|
209
|
+
const earlier = recall
|
|
210
|
+
? `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.`
|
|
211
|
+
: 'The earlier conversation is not available to you.';
|
|
206
212
|
return [
|
|
207
213
|
'# Carwash handoff',
|
|
208
214
|
'',
|
|
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.
|
|
215
|
+
`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
216
|
'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
217
|
'Check the current state of a file before acting on what the brief says about it.',
|
|
212
218
|
...(stale ? [stale] : []),
|
package/src/wash/hooks.mjs
CHANGED
|
@@ -4,13 +4,14 @@ 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
6
|
import { installRecordPath, welcomedPath } from '../paths.mjs';
|
|
7
|
-
import { recordDelivery, recordWash, refreshLedger
|
|
7
|
+
import { recordDelivery, recordWash, refreshLedger } from '../stats/ledger.mjs';
|
|
8
8
|
import { colorDepth } from '../statusline/color.mjs';
|
|
9
|
-
import { statsReport } from '../stats/report.mjs';
|
|
9
|
+
import { currentTotals, statsReport } from '../stats/report.mjs';
|
|
10
10
|
import { budgetNote, composeContext, describe, helpMessage, loadedMessage, parseReply, pointerMessage, readyMessage, replyMeterMessage, retryNote, stalenessLine, welcomeMessage } 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';
|
|
@@ -93,7 +94,7 @@ function washCommand(payload, args, event, ctx) {
|
|
|
93
94
|
if (isLevel(verb)) return begin(payload, verb, event, ctx);
|
|
94
95
|
if (verb === 'discard') return block(discard(payload, ref, ctx));
|
|
95
96
|
if (verb === 'load') return block(load(payload, ref, ctx));
|
|
96
|
-
if (verb === 'stats') return block(statsReport(
|
|
97
|
+
if (verb === 'stats') return block(statsReport(currentTotals(ctx.env, ctx.now), { inClaude: true }));
|
|
97
98
|
if (verb === 'help') return block(helpMessage(ctx.usage(payload)?.used ?? null, versionLine(ctx.version, ctx.env), ctx.tool));
|
|
98
99
|
return block(`${toolInfo(ctx.tool).wash} does not know "${verb}". ${usageLine(ctx.tool)}`);
|
|
99
100
|
}
|
|
@@ -236,6 +237,7 @@ function onStop(payload, ctx) {
|
|
|
236
237
|
now,
|
|
237
238
|
);
|
|
238
239
|
quietly(ctx, () => recordWash(env, record, wash));
|
|
240
|
+
quietly(ctx, () => saveRecall(env, record.id, payload.transcript_path, ctx.tool));
|
|
239
241
|
// One ready brief per terminal. An earlier wash of this same session is out
|
|
240
242
|
// of date and goes; anything else this terminal held goes back to waiting.
|
|
241
243
|
for (const other of readyRecords(env, now)) {
|
|
@@ -257,7 +259,7 @@ function deliver(record, payload, ctx) {
|
|
|
257
259
|
const stale = stalenessLine(record.source, repoState(cwdOf(payload)));
|
|
258
260
|
return {
|
|
259
261
|
systemMessage: loadedMessage(record, now, stale, ctx.tool),
|
|
260
|
-
hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: composeContext(record, now, stale) },
|
|
262
|
+
hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: composeContext(record, now, stale, keptRecall(env, record.id)) },
|
|
261
263
|
};
|
|
262
264
|
}
|
|
263
265
|
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { closeSync, existsSync, openSync, readSync } from 'node:fs';
|
|
2
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
3
|
+
import { privateDir, writeFileAtomic } from '../files.mjs';
|
|
4
|
+
import { recallDir, recallFile } from '../paths.mjs';
|
|
5
|
+
import { resultText } from '../stats/ledger.mjs';
|
|
6
|
+
import { toolInfo } from '../tools.mjs';
|
|
7
|
+
|
|
8
|
+
// A brief is a summary, so it leaves things out: the exact error, the path, the
|
|
9
|
+
// one number that matters. When a brief is saved, the session it came from is
|
|
10
|
+
// copied here as plain text, and the fresh session is told where to find it, so
|
|
11
|
+
// it can search for an exact detail instead of guessing.
|
|
12
|
+
//
|
|
13
|
+
// The session files themselves are poor to search. Theirs can run to hundreds
|
|
14
|
+
// of megabytes and one line of an image can be a megabyte or more, which a single
|
|
15
|
+
// search hit would pull into the conversation. This keeps only the words, ends
|
|
16
|
+
// at the wash so the brief is not in it twice, and wraps long lines. One file per
|
|
17
|
+
// brief, deleted after RECALL_DAYS or when the brief is discarded.
|
|
18
|
+
|
|
19
|
+
// Longest line kept whole, so one search hit stays a small one.
|
|
20
|
+
const WRAP = 2000;
|
|
21
|
+
const LONG_LINE = new RegExp(`.{1,${WRAP}}`, 'gsu');
|
|
22
|
+
|
|
23
|
+
// Past this, the oldest part of the session is left out. Real sessions are far
|
|
24
|
+
// smaller once images are gone: a few megabytes at the very most.
|
|
25
|
+
const MAX_BYTES = 16 * 1024 * 1024;
|
|
26
|
+
|
|
27
|
+
const CHUNK = 1024 * 1024;
|
|
28
|
+
|
|
29
|
+
// The lines of a file, without holding the whole of it in memory at once.
|
|
30
|
+
function* linesOf(file) {
|
|
31
|
+
const fd = openSync(file, 'r');
|
|
32
|
+
try {
|
|
33
|
+
const buffer = Buffer.alloc(CHUNK);
|
|
34
|
+
const decoder = new StringDecoder('utf8');
|
|
35
|
+
let rest = '';
|
|
36
|
+
for (;;) {
|
|
37
|
+
const read = readSync(fd, buffer, 0, CHUNK, null);
|
|
38
|
+
if (!read) break;
|
|
39
|
+
const lines = (rest + decoder.write(buffer.subarray(0, read))).split('\n');
|
|
40
|
+
rest = lines.pop();
|
|
41
|
+
yield* lines;
|
|
42
|
+
}
|
|
43
|
+
if (rest) yield rest;
|
|
44
|
+
} finally {
|
|
45
|
+
closeSync(fd);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// One transcript line as [who, text] pairs. Images, and anything else that is
|
|
50
|
+
// not words, come out empty and are dropped.
|
|
51
|
+
function claudeEntries(line) {
|
|
52
|
+
if ((line.type !== 'user' && line.type !== 'assistant') || line.isMeta || line.isSidechain) return [];
|
|
53
|
+
const content = line.message?.content;
|
|
54
|
+
if (typeof content === 'string') return [[line.type, content]];
|
|
55
|
+
if (!Array.isArray(content)) return [];
|
|
56
|
+
return content.flatMap((block) => {
|
|
57
|
+
if (block?.type === 'text') return [[line.type, block.text]];
|
|
58
|
+
if (block?.type === 'thinking') return [['assistant thinking', block.thinking]];
|
|
59
|
+
if (block?.type === 'tool_use') return [[`tool call ${block.name}`, JSON.stringify(block.input)]];
|
|
60
|
+
if (block?.type === 'tool_result') return [[block.is_error ? 'tool result (error)' : 'tool result', resultText(block)]];
|
|
61
|
+
return [];
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const partsText = (parts) => (Array.isArray(parts) ? parts.map((part) => part?.text ?? '').join('') : '');
|
|
66
|
+
|
|
67
|
+
// The same for a Codex rollout line. Codex says the format is not a stable
|
|
68
|
+
// interface, so anything unexpected is simply left out.
|
|
69
|
+
function codexEntries(line) {
|
|
70
|
+
if (line.type !== 'response_item') return [];
|
|
71
|
+
const item = line.payload;
|
|
72
|
+
switch (item?.type) {
|
|
73
|
+
case 'message':
|
|
74
|
+
return item.role === 'user' || item.role === 'assistant' ? [[item.role, partsText(item.content)]] : [];
|
|
75
|
+
case 'reasoning':
|
|
76
|
+
return [['assistant thinking', partsText(item.summary)]];
|
|
77
|
+
case 'function_call':
|
|
78
|
+
return [[`tool call ${item.name}`, item.arguments]];
|
|
79
|
+
case 'custom_tool_call':
|
|
80
|
+
return [[`tool call ${item.name}`, item.input]];
|
|
81
|
+
case 'function_call_output':
|
|
82
|
+
case 'custom_tool_call_output':
|
|
83
|
+
return [['tool result', typeof item.output === 'string' ? item.output : partsText(item.output)]];
|
|
84
|
+
default:
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const READERS = {
|
|
90
|
+
claude: { entries: claudeEntries, isWash: (text) => text.includes('<command-name>/wash</command-name>') || /^\s*\/wash(?![\w-])/.test(text) },
|
|
91
|
+
codex: { entries: codexEntries, isWash: (text) => /^\s*\$wash(?![\w-])/.test(text) },
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Everything said in a session, up to the last wash.
|
|
95
|
+
function readEntries(file, tool) {
|
|
96
|
+
const { entries: toEntries, isWash } = READERS[tool] ?? READERS.claude;
|
|
97
|
+
const entries = [];
|
|
98
|
+
let end = null;
|
|
99
|
+
for (const raw of linesOf(file)) {
|
|
100
|
+
let line;
|
|
101
|
+
try {
|
|
102
|
+
line = JSON.parse(raw);
|
|
103
|
+
} catch {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (!line || typeof line !== 'object') continue;
|
|
107
|
+
for (const [who, text] of toEntries(line)) {
|
|
108
|
+
if (typeof text !== 'string' || !text.trim()) continue;
|
|
109
|
+
if (who === 'user' && isWash(text)) end = entries.length;
|
|
110
|
+
entries.push({ who, time: line.timestamp, text });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return end === null ? entries : entries.slice(0, end);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const wrapped = (text) => text.split('\n').map((line) => (line.length > WRAP ? line.match(LONG_LINE).join('\n') : line)).join('\n');
|
|
117
|
+
|
|
118
|
+
function render(entries, tool) {
|
|
119
|
+
const kept = [];
|
|
120
|
+
let bytes = 0;
|
|
121
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
122
|
+
const { who, time, text } = entries[i];
|
|
123
|
+
const block = `--- #${i + 1} ${who}${time ? ` ${time}` : ''} ---\n${wrapped(text)}\n\n`;
|
|
124
|
+
bytes += Buffer.byteLength(block);
|
|
125
|
+
if (bytes > MAX_BYTES) break;
|
|
126
|
+
kept.push(block);
|
|
127
|
+
}
|
|
128
|
+
const { name, wash } = toolInfo(tool);
|
|
129
|
+
const dropped = entries.length - kept.length;
|
|
130
|
+
const note = dropped ? ` The first ${dropped === 1 ? 'entry is' : `${dropped} entries are`} left out too, to keep this file small.` : '';
|
|
131
|
+
return `A text copy of an earlier ${name} session, saved when ${wash} wrote its brief. Images are left out.${note}\nEach entry starts with a "--- #n who time ---" line.\n\n${kept.reverse().join('')}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Copy a session to the file for brief `id`. Returns that file, or null if
|
|
135
|
+
// there was nothing to copy. The brief works without it, so a caller should not
|
|
136
|
+
// let a failure here stop anything.
|
|
137
|
+
export function saveRecall(env, id, transcript, tool = 'claude') {
|
|
138
|
+
if (typeof transcript !== 'string' || !existsSync(transcript)) return null;
|
|
139
|
+
const entries = readEntries(transcript, tool);
|
|
140
|
+
if (!entries.length) return null;
|
|
141
|
+
privateDir(recallDir(env));
|
|
142
|
+
const file = recallFile(env, id);
|
|
143
|
+
writeFileAtomic(file, render(entries, tool), 0o600);
|
|
144
|
+
return file;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// The copy for brief `id`, or null if it is gone.
|
|
148
|
+
export function keptRecall(env, id) {
|
|
149
|
+
const file = recallFile(env, id);
|
|
150
|
+
return existsSync(file) ? file : null;
|
|
151
|
+
}
|
package/src/wash/store.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
|
-
import { existsSync, readdirSync, renameSync, statSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readdirSync, renameSync, rmSync, statSync, unlinkSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { privateDir, readJson, writePrivateJson } from '../files.mjs';
|
|
5
|
-
import { consumedDir, pendingDir, sessionsDir } from '../paths.mjs';
|
|
5
|
+
import { RECALL_DAYS, consumedDir, pendingDir, recallDir, recallFile, sessionsDir } from '../paths.mjs';
|
|
6
6
|
|
|
7
7
|
// The store of handoff briefs. One JSON file per brief, outside any project:
|
|
8
8
|
// pending/ holds briefs waiting for a fresh session, consumed/ holds delivered
|
|
@@ -62,7 +62,9 @@ export function updateRecord(env, record) {
|
|
|
62
62
|
if (existsSync(file)) writePrivateJson(file, record);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// Drop a waiting brief, and the copy of its session along with it.
|
|
65
66
|
export function removeRecord(env, id) {
|
|
67
|
+
rmSync(recallFile(env, id), { force: true });
|
|
66
68
|
try {
|
|
67
69
|
unlinkSync(fileIn(pendingDir(env), id));
|
|
68
70
|
return true;
|
|
@@ -132,7 +134,7 @@ function sweep(dir, isDue) {
|
|
|
132
134
|
}
|
|
133
135
|
|
|
134
136
|
// Delete what has outlived its use: expired briefs, delivered briefs past
|
|
135
|
-
// their retention, old session notes, and stray files.
|
|
137
|
+
// their retention, old session notes and copies, and stray files.
|
|
136
138
|
export function purge(env, now = Date.now()) {
|
|
137
139
|
const setting = (env.CARWASH_RETAIN_HOURS ?? '').trim();
|
|
138
140
|
const retainMs = (setting !== '' && Number(setting) >= 0 ? Number(setting) : RETAIN_HOURS) * HOUR;
|
|
@@ -148,4 +150,5 @@ export function purge(env, now = Date.now()) {
|
|
|
148
150
|
return Number.isFinite(consumed) ? now - consumed > retainMs : stale(file, HOUR);
|
|
149
151
|
});
|
|
150
152
|
sweep(sessionsDir(env), (file) => stale(file, SESSION_NOTE_MS));
|
|
153
|
+
sweep(recallDir(env), (file) => stale(file, RECALL_DAYS * 24 * HOUR));
|
|
151
154
|
}
|