context-carwash 0.2.0 → 0.2.2

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.
@@ -3,7 +3,8 @@ 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, installRecordPath, legacyCommandPath, pendingDir, settingsPath, skillDir } from '../paths.mjs';
6
+ import { carwashHome, claudeConfigDir, installRecordPath, legacyCommandPath, pendingDir, settingsPath, skillDir } from '../paths.mjs';
7
+ import { applyCodexInstall, applyCodexUninstall, codexDetected, describeCodexInstall, describeCodexUninstall, planCodexInstall, planCodexUninstall } from './codex.mjs';
7
8
  import { UserError, readSettings, resolveSettingsFile, serializeSettings, sha256 } from './settings.mjs';
8
9
 
9
10
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
@@ -85,8 +86,9 @@ export const isCarwashHook = runsCarwash('hook');
85
86
 
86
87
  // The `hooks` setting with Carwash's own entries taken out. An event's list,
87
88
  // or the whole setting, goes too if it only existed to hold ours. Returns the
88
- // same object when there was nothing of ours in it.
89
- export function withoutCarwashHooks(hooks) {
89
+ // same object when there was nothing of ours in it. `isOurs` tells our hook
90
+ // commands apart; Codex's hooks file has the same shape as Claude Code's.
91
+ export function withoutCarwashHooks(hooks, isOurs = isCarwashHook) {
90
92
  if (!isPlainObject(hooks)) return hooks;
91
93
  const next = {};
92
94
  let found = false;
@@ -97,13 +99,13 @@ export function withoutCarwashHooks(hooks) {
97
99
  }
98
100
  const kept = [];
99
101
  for (const group of groups) {
100
- const ours = Array.isArray(group?.hooks) && group.hooks.some((hook) => isCarwashHook(hook?.command));
102
+ const ours = Array.isArray(group?.hooks) && group.hooks.some((hook) => isOurs(hook?.command));
101
103
  if (!ours) {
102
104
  kept.push(group);
103
105
  continue;
104
106
  }
105
107
  found = true;
106
- const theirs = group.hooks.filter((hook) => !isCarwashHook(hook?.command));
108
+ const theirs = group.hooks.filter((hook) => !isOurs(hook?.command));
107
109
  if (theirs.length) kept.push({ ...group, hooks: theirs });
108
110
  }
109
111
  if (kept.length || kept.length === groups.length) next[event] = kept;
@@ -155,13 +157,38 @@ function copyTree(from, to) {
155
157
  }
156
158
  }
157
159
 
160
+ // Whether an install record covers Claude Code. Records from before Codex
161
+ // support do not list their tools, and were all Claude Code.
162
+ const claudeRecorded = (record) => Boolean(record) && (!Array.isArray(record.tools) || record.tools.includes('claude'));
163
+
164
+ // Which tools an install covers. Codex when it is set up here. Claude Code when
165
+ // it is set up here or was installed before, and always when Codex is not
166
+ // there: that was the whole install before Codex support.
167
+ export function detectTools(env = process.env, record = readJson(installRecordPath(env))) {
168
+ const codex = codexDetected(env);
169
+ const claude = existsSync(claudeConfigDir(env)) || claudeRecorded(record) || !codex;
170
+ return { claude, codex };
171
+ }
172
+
158
173
  // Work out what installing would change, without changing anything.
159
174
  export function planInstall({ env = process.env, platform = process.platform } = {}) {
160
175
  const home = carwashHome(env);
161
176
  const appDir = join(home, 'app');
177
+ const record = readJson(installRecordPath(env));
178
+ const tools = detectTools(env, record);
179
+ const script = { nodePath: stableNodePath(env), scriptPath: join(appDir, 'bin', 'carwash.mjs'), platform };
180
+ // The Node the commands will run with. Windows finds `node` on PATH instead.
181
+ const node = platform === 'win32' ? null : script.nodePath;
182
+ const codex = tools.codex ? planCodexInstall({ env, script, previous: record?.codex ?? null }) : null;
183
+ if (!tools.claude) return { home, appDir, record, node, claude: false, codex };
184
+ return { ...planClaudeInstall({ env, record: claudeRecorded(record) ? record : null, script }), home, appDir, record, node, claude: true, codex };
185
+ }
186
+
187
+ // The Claude Code half of an install: the status line, hooks and /wash.
188
+ // `record` is the install record when it covers Claude Code.
189
+ function planClaudeInstall({ env, record, script }) {
162
190
  const file = resolveSettingsFile(settingsPath(env));
163
191
  const settings = readSettings(file);
164
- const record = readJson(installRecordPath(env));
165
192
  const current = settings.data.statusLine;
166
193
 
167
194
  let original = null;
@@ -179,7 +206,6 @@ export function planInstall({ env = process.env, platform = process.platform } =
179
206
  }
180
207
  }
181
208
 
182
- const script = { nodePath: stableNodePath(env), scriptPath: join(appDir, 'bin', 'carwash.mjs'), platform };
183
209
  const theirHooks = withoutCarwashHooks(settings.data.hooks);
184
210
  const next = {
185
211
  ...settings.data,
@@ -188,16 +214,30 @@ export function planInstall({ env = process.env, platform = process.platform } =
188
214
  };
189
215
  const skill = planSkill(env);
190
216
 
191
- // The Node the commands will run with. Windows finds `node` on PATH instead.
192
- const node = platform === 'win32' ? null : script.nodePath;
193
-
194
- return { home, appDir, file, settings, record, original, alreadyInstalled, next, skill, node, keepsHooks: isPlainObject(theirHooks) && Object.keys(theirHooks).length > 0 };
217
+ return { file, settings, claudeRecord: record, original, alreadyInstalled, next, skill, keepsHooks: isPlainObject(theirHooks) && Object.keys(theirHooks).length > 0 };
195
218
  }
196
219
 
197
220
  export function describeInstall(plan) {
198
221
  const lines = ['Context Carwash will make these changes:', ''];
199
222
  lines.push(` 1. Copy its files to ${tilde(plan.appDir)}`);
200
- lines.push(` 2. ${plan.settings.exists ? 'Edit' : 'Create'} ${tilde(plan.file)}`);
223
+ if (plan.claude) lines.push(...describeClaudeInstall(plan));
224
+ if (plan.codex) lines.push(...describeCodexInstall(plan.codex, plan.claude ? 4 : 2, plan.home));
225
+ const model = plan.claude && plan.codex ? 'Claude or Codex' : plan.codex ? 'Codex' : 'Claude';
226
+ lines.push('', `Writing a brief is an ordinary ${model} reply, so it uses tokens like any other.`);
227
+ // Version managers such as nvm keep a folder per Node version, so removing
228
+ // an old version takes this path with it, and nothing of Carwash can start.
229
+ 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.`);
230
+ lines.push('Nothing else is touched. To undo everything: carwash uninstall');
231
+ return lines.join('\n');
232
+ }
233
+
234
+ // Steps 2 and 3: Claude Code. Labelled with its name only when Codex is in the
235
+ // list too.
236
+ function describeClaudeInstall(plan) {
237
+ const lines = [];
238
+ const label = plan.codex ? 'Claude Code: ' : '';
239
+ const verb = plan.settings.exists ? 'Edit' : 'Create';
240
+ lines.push(` 2. ${label}${label ? verb.toLowerCase() : verb} ${tilde(plan.file)}`);
201
241
  if (plan.alreadyInstalled) {
202
242
  lines.push(' The Carwash status line is already there; its command is refreshed.');
203
243
  } else if (plan.original) {
@@ -210,20 +250,15 @@ export function describeInstall(plan) {
210
250
  }
211
251
  lines.push(' Adds three hooks, so /wash can save a brief and a fresh session can');
212
252
  lines.push(` pick it up.${plan.keepsHooks ? ' The hooks you already have are kept as they are.' : ''}`);
213
- if (plan.settings.exists && !plan.record) {
253
+ if (plan.settings.exists && !plan.claudeRecord) {
214
254
  lines.push(` A backup of the file is kept in ${tilde(join(plan.home, 'backup'))}`);
215
255
  }
216
- lines.push(` 3. Add the /wash command at ${tilde(plan.skill.dir)}`);
217
- lines.push('', 'Writing a brief is an ordinary Claude reply, so it uses tokens like any other.');
218
- // Version managers such as nvm keep a folder per Node version, so removing
219
- // an old version takes this path with it, and nothing of Carwash can start.
220
- 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.`);
221
- lines.push('Nothing else is touched. To undo everything: carwash uninstall');
222
- return lines.join('\n');
256
+ lines.push(` 3. ${label ? `${label}add` : 'Add'} the /wash command at ${tilde(plan.skill.dir)}`);
257
+ return lines;
223
258
  }
224
259
 
225
260
  export function applyInstall(plan) {
226
- const { home, appDir, file, settings, record, skill } = plan;
261
+ const { home, appDir, record } = plan;
227
262
  privateDir(home);
228
263
 
229
264
  // Self-contained copy, so the status line keeps working if the package that
@@ -236,6 +271,20 @@ export function applyInstall(plan) {
236
271
  }
237
272
  }
238
273
 
274
+ const tools = [...(plan.claude ? ['claude'] : []), ...(plan.codex ? ['codex'] : [])];
275
+ const codex = plan.codex ? applyCodexInstall(plan.codex, home) : null;
276
+ const base = { version: packageVersion(), installedAt: record?.installedAt ?? new Date().toISOString() };
277
+ // Only the tools other than Claude Code are listed, so a Claude Code install
278
+ // records exactly what it did before Codex support.
279
+ const extra = { ...(tools.length === 1 && tools[0] === 'claude' ? {} : { tools }), ...(codex ? { codex } : {}) };
280
+ const nextRecord = plan.claude ? { ...base, ...applyClaudeInstall(plan), ...extra } : { ...base, ...extra };
281
+ writeFileSync(installRecordPath({ CARWASH_HOME: home }), JSON.stringify(nextRecord, null, 2) + '\n', { mode: 0o600 });
282
+ return nextRecord;
283
+ }
284
+
285
+ // Write the Claude Code half, and return what the install record keeps about it.
286
+ function applyClaudeInstall(plan) {
287
+ const { home, file, settings, claudeRecord: record, skill } = plan;
239
288
  mkdirSync(skill.dir, { recursive: true });
240
289
  copyFileSync(SKILL_SOURCE, skill.file);
241
290
 
@@ -255,9 +304,7 @@ export function applyInstall(plan) {
255
304
  // promise is off for good and uninstall edits our own keys instead.
256
305
  const pristine = !record || (settings.exists && record.installedSha256 === sha256(settings.bytes));
257
306
 
258
- const nextRecord = {
259
- version: packageVersion(),
260
- installedAt: record?.installedAt ?? new Date().toISOString(),
307
+ return {
261
308
  settingsPath: file,
262
309
  hadSettingsFile: record ? record.hadSettingsFile : settings.exists,
263
310
  backupPath,
@@ -266,8 +313,6 @@ export function applyInstall(plan) {
266
313
  statusLine: { original: plan.original },
267
314
  skillPath: skill.file,
268
315
  };
269
- writeFileSync(installRecordPath({ CARWASH_HOME: home }), JSON.stringify(nextRecord, null, 2) + '\n', { mode: 0o600 });
270
- return nextRecord;
271
316
  }
272
317
 
273
318
  function countBriefs(env) {
@@ -281,9 +326,13 @@ function countBriefs(env) {
281
326
  // Work out what uninstalling would change, without changing anything.
282
327
  export function planUninstall({ env = process.env } = {}) {
283
328
  const home = carwashHome(env);
284
- const record = readJson(installRecordPath(env));
329
+ const installRecord = readJson(installRecordPath(env));
330
+ // What install noted about Claude Code. An install for Codex alone has
331
+ // nothing to say about it, so Claude Code's settings are only checked for
332
+ // Carwash entries, as without a record.
333
+ const record = claudeRecorded(installRecord) ? installRecord : null;
285
334
  const file = resolveSettingsFile(record?.settingsPath ?? settingsPath(env));
286
- const plan = { home, file, record, action: 'none', note: null, bytes: null };
335
+ const plan = { home, file, record: installRecord, action: 'none', note: null, bytes: null };
287
336
 
288
337
  if (existsSync(file)) {
289
338
  const current = readFileSync(file);
@@ -325,6 +374,12 @@ export function planUninstall({ env = process.env } = {}) {
325
374
  plan.skillDir = dirname(skillFile);
326
375
  plan.skill = isOurSkill(skillFile) ? skillFile : null;
327
376
  plan.foreignSkill = !plan.skill && existsSync(skillFile);
377
+ // Claude Code is in the list unless install never touched it and there is
378
+ // nothing of Carwash's there now.
379
+ plan.claude = Boolean(record) || !installRecord || plan.action !== 'none' || Boolean(plan.skill);
380
+
381
+ plan.codex = planCodexUninstall({ env, record: installRecord?.codex ?? null });
382
+ if (!plan.codex.found && !installRecord?.codex) plan.codex = null;
328
383
 
329
384
  // Only ever delete a directory that is recognisably ours, and never the
330
385
  // home directory or a filesystem root, whatever CARWASH_HOME says.
@@ -343,9 +398,13 @@ export function describeUninstall(plan) {
343
398
  edit: `Edit ${tilde(plan.file)}: undo Carwash's status line and hooks; your other changes stay`,
344
399
  none: `Leave ${tilde(plan.file)} alone`,
345
400
  }[plan.action];
346
- const steps = [[what, plan.note]];
347
- if (plan.skill) steps.push([`Remove the /wash command at ${tilde(plan.skillDir)}`]);
348
- else if (plan.foreignSkill) steps.push([`Leave the /wash command at ${tilde(plan.skillDir)} alone (not Carwash's)`]);
401
+ const steps = [];
402
+ if (plan.claude) {
403
+ steps.push([what, plan.note]);
404
+ if (plan.skill) steps.push([`Remove the /wash command at ${tilde(plan.skillDir)}`]);
405
+ else if (plan.foreignSkill) steps.push([`Leave the /wash command at ${tilde(plan.skillDir)} alone (not Carwash's)`]);
406
+ }
407
+ if (plan.codex) steps.push(...describeCodexUninstall(plan.codex));
349
408
  const briefs = plan.briefs ? `, including ${plan.briefs} waiting ${plan.briefs === 1 ? 'brief' : 'briefs'}` : '';
350
409
  steps.push([plan.removeHome ? `Delete ${tilde(plan.home)}${briefs}` : `Leave ${tilde(plan.home)} alone (not a Carwash folder)`]);
351
410
  steps.forEach(([step, note], i) => {
@@ -366,5 +425,6 @@ export function applyUninstall(plan) {
366
425
  // the folder holds something else, which stays
367
426
  }
368
427
  }
428
+ if (plan.codex) applyCodexUninstall(plan.codex);
369
429
  if (plan.removeHome) rmSync(plan.home, { recursive: true, force: true });
370
430
  }
package/src/paths.mjs CHANGED
@@ -11,6 +11,11 @@ export function claudeConfigDir(env = process.env) {
11
11
  return env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
12
12
  }
13
13
 
14
+ // Codex's own home directory, honouring its documented override.
15
+ export function codexHome(env = process.env) {
16
+ return env.CODEX_HOME || join(homedir(), '.codex');
17
+ }
18
+
14
19
  export function settingsPath(env = process.env) {
15
20
  return join(claudeConfigDir(env), 'settings.json');
16
21
  }
@@ -43,9 +48,19 @@ export const errorsSeenPath = (env = process.env) => join(carwashHome(env), 'err
43
48
  // the author put on this one.
44
49
  export const noticePath = (env = process.env) => join(carwashHome(env), 'notice.json');
45
50
 
46
- // Present once the first-run welcome has been shown.
47
- export const welcomedPath = (env = process.env) => join(carwashHome(env), 'welcomed');
51
+ // Present once the first-run welcome has been shown. Each tool says hello once.
52
+ export const welcomedPath = (env = process.env, tool = 'claude') => join(carwashHome(env), tool === 'claude' ? 'welcomed' : `welcomed-${tool}`);
48
53
 
49
54
  // Where the /wash command is installed.
50
55
  export const skillDir = (env = process.env) => join(claudeConfigDir(env), 'skills', 'wash');
51
56
  export const legacyCommandPath = (env = process.env) => join(claudeConfigDir(env), 'commands', 'wash.md');
57
+
58
+ // Where Carwash goes in Codex: its hooks file, the $wash skill, and the config
59
+ // file Codex keeps hook approvals in (read, never written).
60
+ export const codexHooksPath = (env = process.env) => join(codexHome(env), 'hooks.json');
61
+ export const codexSkillDir = (env = process.env) => join(codexHome(env), 'skills', 'wash');
62
+ export const codexConfigPath = (env = process.env) => join(codexHome(env), 'config.toml');
63
+
64
+ // What Carwash has learned about how far sessions usually go: a few numbers
65
+ // per transcript, so the transcripts need not be read again.
66
+ export const habitsPath = (env = process.env) => join(carwashHome(env), 'habits.json');
@@ -0,0 +1,47 @@
1
+ ---
2
+ name: wash
3
+ description: Context Carwash. Writes a short handoff brief of this session when the user types $wash, so that /clear can start a fresh session that carries on from it. Use only when the user's message starts with $wash.
4
+ metadata:
5
+ installed-by: context-carwash
6
+ ---
7
+
8
+ The user typed $wash. Context Carwash saves your reply as a handoff brief, but only while its hooks are running. When they are, they add a developer message to this turn that starts with "Carwash: level".
9
+
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
+
12
+ When the message is there: the user wants to start a fresh session that picks up where this one leaves off. Your whole reply to this message is saved as the handoff brief, and it is the first thing the next session reads. That session cannot see this conversation, so anything the brief leaves out is gone.
13
+
14
+ The words after $wash in the user's message are the arguments. The first word is the level: `light`, `mid`, or `detailed`. No arguments means `mid`. Anything after the level is the user's note on what to emphasise.
15
+
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
+
18
+ ## Format
19
+
20
+ Start with exactly this heading, then the Core section:
21
+
22
+ ```
23
+ # Carwash brief: <the task, in under 60 characters>
24
+
25
+ ## Core
26
+ - Objective: what the user is trying to get done, and how they will know it is done
27
+ - State: where things stand right now
28
+ - Next: the one concrete action to take first
29
+ ```
30
+
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
+
33
+ After Core, add only the sections for the level, and skip any that would be empty:
34
+
35
+ - `light`: Core only. Add one line for a critical constraint if breaking it would undo the work.
36
+ - `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
+ - `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
+
39
+ ## Rules
40
+
41
+ - Write the smallest brief that lets a fresh session continue, and stop when it is complete. The token ceiling given with this command is a limit, not a target. Most briefs should end well under it. Never pad.
42
+ - This is a brief for continuing a task, not a summary of the conversation. Leave out greetings, repetition, plans that were replaced, and raw tool output.
43
+ - Leave out what the next session is given anyway: your standing instructions, project memory and AGENTS.md contents, tool descriptions, and the environment or git status shown at the start of this session.
44
+ - Point to files, diffs, and tests by path, with a line number or function name where that helps. Do not copy their contents.
45
+ - Keep confirmed facts apart from assumptions, and label the assumptions.
46
+ - Never invent a test result, and never say a file was inspected unless it was read in this conversation.
47
+ - Never copy credentials, tokens, keys, or other secrets.
@@ -0,0 +1,3 @@
1
+ # Context Carwash: $wash runs only when typed, never because a prompt seems to fit.
2
+ policy:
3
+ allow_implicit_invocation: false
@@ -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
+ }