context-doctor 0.31.1 → 0.32.0
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/README.md +25 -25
- package/dist/autoclear.d.ts +58 -7
- package/dist/autoclear.js +155 -30
- package/dist/autopilot.d.ts +23 -3
- package/dist/autopilot.js +100 -29
- package/dist/cache.d.ts +6 -2
- package/dist/cache.js +48 -23
- package/dist/cacheplan.d.ts +5 -3
- package/dist/cacheplan.js +46 -8
- package/dist/calibration.d.ts +7 -2
- package/dist/calibration.js +14 -3
- package/dist/ci.js +25 -5
- package/dist/cli.js +61 -26
- package/dist/coldresume.d.ts +32 -7
- package/dist/coldresume.js +148 -34
- package/dist/compactquality.js +34 -12
- package/dist/compactwindow.js +59 -34
- package/dist/config.d.ts +6 -0
- package/dist/config.js +4 -1
- package/dist/cursor.d.ts +9 -0
- package/dist/cursor.js +53 -22
- package/dist/dashboard.d.ts +10 -0
- package/dist/dashboard.js +27 -2
- package/dist/diff.js +10 -3
- package/dist/doctor.js +133 -18
- package/dist/embed.d.ts +9 -0
- package/dist/embed.js +40 -5
- package/dist/experiment.d.ts +1 -0
- package/dist/experiment.js +6 -3
- package/dist/hook.js +23 -4
- package/dist/impact.d.ts +32 -0
- package/dist/impact.js +45 -6
- package/dist/install.d.ts +34 -2
- package/dist/install.js +223 -30
- package/dist/ledger.js +9 -2
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.js +2 -2
- package/dist/mcpschema.d.ts +16 -1
- package/dist/mcpschema.js +95 -24
- package/dist/optimize.js +149 -64
- package/dist/overhead.js +57 -27
- package/dist/pack.d.ts +11 -0
- package/dist/pack.js +75 -12
- package/dist/parse.js +42 -18
- package/dist/pricing.d.ts +57 -5
- package/dist/pricing.js +94 -8
- package/dist/proxy.d.ts +13 -2
- package/dist/proxy.js +258 -43
- package/dist/reads.d.ts +1 -1
- package/dist/reads.js +61 -24
- package/dist/report.d.ts +10 -0
- package/dist/report.js +12 -4
- package/dist/savings.d.ts +20 -8
- package/dist/savings.js +79 -34
- package/dist/session.d.ts +37 -0
- package/dist/session.js +143 -6
- package/dist/split.js +6 -1
- package/dist/statusline.d.ts +3 -1
- package/dist/statusline.js +29 -17
- package/dist/subagentpay.d.ts +5 -2
- package/dist/subagentpay.js +50 -16
- package/dist/subagents.d.ts +7 -3
- package/dist/subagents.js +28 -8
- package/dist/tasks.d.ts +22 -7
- package/dist/tasks.js +47 -20
- package/dist/wrap.d.ts +6 -0
- package/dist/wrap.js +142 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# context-doctor 🩺
|
|
2
2
|
|
|
3
|
-
[](https://github.com/KushalP1/context-doctor/actions) [](https://www.npmjs.com/package/context-doctor) [](https://www.npmjs.com/package/context-doctor) [](https://registry.modelcontextprotocol.io) [](./LICENSE) 
|
|
3
|
+
[](https://github.com/KushalP1/context-doctor/actions) [](https://www.npmjs.com/package/context-doctor) [](https://www.npmjs.com/package/context-doctor) [](https://registry.modelcontextprotocol.io) [](https://myagenthub.cn/p/io.github.KushalP1/context-doctor) [](./LICENSE) 
|
|
4
4
|
|
|
5
5
|
**See where your AI agent's tokens go, and stop paying for the ones that do nothing.**
|
|
6
6
|
|
|
@@ -54,9 +54,9 @@ Pick where you work. Each takes about a minute and is fully reversible.
|
|
|
54
54
|
|
|
55
55
|
On the author's own Claude Code history (mostly Opus and Fable models with the 1M window, priced at API list prices; `savings` computes yours the same way):
|
|
56
56
|
|
|
57
|
-
- **Compacting when you come back after the cache expired:**
|
|
58
|
-
- **A 400k auto-compact window instead of ~970k:** **
|
|
59
|
-
- **Autopilot:** **
|
|
57
|
+
- **Compacting when you come back after the cache expired:** 479 such returns in 133 days; compacting then would have saved **$2,993 net, about $675 a month**. [Details →](#what-it-saves)
|
|
58
|
+
- **A 400k auto-compact window instead of ~970k:** **50% less input cost** over 30 days ($3,648 to $1,814), for 14 compactions a week instead of 5. [The trade-off →](#3-compacting-earlier-automatic)
|
|
59
|
+
- **Autopilot:** **12.7% less input cost, no session made more expensive.**
|
|
60
60
|
- **pack:** 9 of 10 questions about this repository answered from **0.6% of its tokens**. [How →](#before-it-enters-the-context-pack-and-overhead)
|
|
61
61
|
- **Counting Claude correctly:** current Claude models pack 2.75 characters per token, not the 4 most tools assume, so estimates built on 4 undercount Claude by about 40%. `context-doctor accuracy` re-checks the ratio on your own sessions.
|
|
62
62
|
|
|
@@ -86,39 +86,38 @@ Findings (4)
|
|
|
86
86
|
|
|
87
87
|
## What it saves
|
|
88
88
|
|
|
89
|
-
Measured, not modelled, on every Claude Code session on the author's machine (mostly Opus 5 and Fable 5 with the 1M window), priced the way the prompt cache bills it (cached reads
|
|
89
|
+
Measured, not modelled, on every Claude Code session on the author's machine (mostly Opus 5 and Fable 5 with the 1M window), priced the way the prompt cache bills it (cached reads at each model's read rate, 2.5-10% of input; writes 1.25x input for the 5-minute cache, 2x for the hour) at API list prices. Re-measured on 2026-10-13 with each request at its own model and current prices (Opus 5.5 is $4 per million input tokens with $0.20 reads). `context-doctor savings` runs the same measurement on yours.
|
|
90
90
|
|
|
91
|
-
**1. Compacting when you come back** (every Claude Code surface, desktop app included). 133 days,
|
|
91
|
+
**1. Compacting when you come back** (every Claude Code surface, desktop app included). 133 days, 479 returns to a session over 150k tokens after its prompt cache expired (65 idle minutes on the 1-hour cache, 10 on the 5-minute one that Claude Code 2.1.295 often writes); median context at the return, 522k tokens.
|
|
92
92
|
|
|
93
93
|
| | |
|
|
94
94
|
|---|---|
|
|
95
|
-
| Net saving had you run `/compact` after the first reply | **$
|
|
96
|
-
| Returns where it paid off |
|
|
97
|
-
| Worst single case | −$
|
|
95
|
+
| Net saving had you run `/compact` after the first reply | **$2,993 (about $675 a month)** |
|
|
96
|
+
| Returns where it paid off | 342 of 479 |
|
|
97
|
+
| Worst single case | −$1.79 (a session left a few messages later) |
|
|
98
98
|
|
|
99
99
|
Each return counts only the messages up to the next return, and the compaction request is charged as a cost. Not counted: files the model may re-read after compacting, and what the summary leaves out. That is why this is advice with the numbers attached, not something done for you.
|
|
100
100
|
|
|
101
|
-
**2. Autopilot** (terminal and IDE sessions, API apps).
|
|
101
|
+
**2. Autopilot** (terminal and IDE sessions, API apps). 133 days, 67 sessions, replayed through the shipped autopilot code with real timestamps:
|
|
102
102
|
|
|
103
103
|
| | Without autopilot | With autopilot | Saved |
|
|
104
104
|
|---|---|---|---|
|
|
105
|
-
| Input
|
|
106
|
-
|
|
|
107
|
-
| Sessions made more expensive | | | **0 of 43** |
|
|
105
|
+
| Input cost at API list price | $11,347 | $9,905 | **$1,442 (12.7%)** |
|
|
106
|
+
| Sessions made more expensive | | | **0 of 67** |
|
|
108
107
|
|
|
109
|
-
|
|
108
|
+
30 of 67 sessions save something, the best 34.9%: long sessions that go idle are where the money is. These sessions ran in the desktop app, which autopilot cannot reach, so for the author this is what the same sessions would have saved in a terminal; `context-doctor savings` separates the two for you.
|
|
110
109
|
|
|
111
110
|
**3. Compacting earlier** <a id="3-compacting-earlier-automatic"></a>(automatic, every Claude Code surface, desktop app included). The offer in 1 is advice, and on the author's machine it was followed 1 time in 33. Claude Code has a native setting that needs nobody to follow anything: `autoCompactWindow` in `~/.claude/settings.json` makes it compact as if the window were that size. `context-doctor compact-window` replays each session request by request at several sizes, priced as the cache bills (each simulated compaction pays its own request and a 20k-token summary at the output rate), and changes nothing until you pick one:
|
|
112
111
|
|
|
113
112
|
| Window | Input cost, last 30 days | Saved | Compactions a week |
|
|
114
113
|
|---|---|---|---|
|
|
115
|
-
| as now (~970k) | $
|
|
116
|
-
| 600k | $
|
|
117
|
-
| **400k** | **$
|
|
118
|
-
| 300k | $
|
|
119
|
-
| 200k | $1,
|
|
114
|
+
| as now (~970k) | $3,648 | | 4.7 |
|
|
115
|
+
| 600k | $2,242 | $1,405 (39%) | 6.5 |
|
|
116
|
+
| **400k** | **$1,814** | **$1,833 (50%)** | **14.2** |
|
|
117
|
+
| 300k | $1,664 | $1,983 (54%) | 25.2 |
|
|
118
|
+
| 200k | $1,501 | $2,146 (59%) | 48.1 |
|
|
120
119
|
|
|
121
|
-
|
|
120
|
+
41 sessions. The cost is real: each compaction replaces the session's history with a summary, so a smaller window trades detail for money. That is why the command shows the table first and sets a window only when you name one (`context-doctor compact-window 400k`; `off` undoes it, and a backup of settings.json is kept).
|
|
122
121
|
|
|
123
122
|
On a Claude subscription you do not pay list price; the same tokens come out of your usage limit instead. Anthropic does not publish how limits weight cached tokens, so read the dollars as the size of the effect, not as your bill.
|
|
124
123
|
|
|
@@ -180,6 +179,7 @@ MCP servers are part of the same overhead: each one's tool definitions ride on e
|
|
|
180
179
|
|
|
181
180
|
## What's new
|
|
182
181
|
|
|
182
|
+
- **0.32 A full audit, fixed**: five reviewers read every module against real data; each finding was reproduced, fixed and tested (list in [ROADMAP.md](./ROADMAP.md#fixed-by-a-local-audit-2026-10-13)). The ones that mattered most: `npx context-doctor install` wrote an MCP launcher for a package that does not exist; `uninstall` left Claude Code pointed at a dead proxy port; the optimizer could move one parallel tool result under another's id and drop the note beside them; autopilot could clear output the model had not read; edited histories now respect thinking-block signatures; current prices (Opus 5.5 $4, Sonnet 5.x $2, Haiku 5.5 $0.10) and 1-hour cache writes at 2x; replies counted once and `--days` applied per request. The cold-resume offer follows the cache lifetime a session writes. The figures above were re-measured with it.
|
|
183
183
|
- **0.31 Save tokens without losing quality**: a PreCompact hook makes every Claude Code compaction keep the session's changed files, open tasks, your instructions and still-failing tests; `context-doctor compactions` measures re-reads, tool errors and corrections before and after each compaction on your history; `compact-window` now charges the re-reads to every compaction it simulates.
|
|
184
184
|
- **0.30 Where the money goes, and a self-healing service**: `context-doctor subagents` compares each subagent's bill with the same work done in the parent session; `cache-plan` places Anthropic `cache_control` breakpoints in an API request and prices them. Fixes from a local audit: the autopilot service now restarts itself onto a newly installed version, `doctor` no longer writes fake hook checks into the activity ledger, and `<synthetic>` placeholder messages no longer hide a session's model.
|
|
185
185
|
- **0.29 Python, and where the work goes**: a dependency-free Python package (`python/`) with `pack_documents` for LangChain and LlamaIndex results, `pack_context`, `estimate_tokens` and `profile_messages`, held byte for byte to the TypeScript by parity tests. `context-doctor reads` shows the files read into session after session; `context-doctor tasks` shows cost per commit per project, from your own transcripts.
|
|
@@ -258,7 +258,7 @@ Practical upshot: a developer who only wants cheaper, faster API calls never tou
|
|
|
258
258
|
| `context-doctor savings [--days n] [--share [--copy]]` | What your recent Claude Code sessions cost, and what `/compact` at cold resumes, autopilot and an earlier auto-compact window would save, replayed through the shipped code against your actual billed usage. `--share` prints totals only (no project names or paths) to post. A bare `context-doctor` in a terminal shows the same |
|
|
259
259
|
| `context-doctor compact-window [status\|off\|<size>]` | Replay your last 30 days at auto-compact windows from 200k to 800k: input cost, saving, and how many more compactions each means. `400k` (or `250000`, `0.4m`) sets Claude Code's `autoCompactWindow`, with a backup; `off` removes it. Works in every Claude Code surface, the desktop app included |
|
|
260
260
|
| `context-doctor mcp [--http]` | The MCP server as a subcommand, for clients and registries that launch `npx -y context-doctor mcp` |
|
|
261
|
-
| `context-doctor autopilot on\|off\|pause\|resume\|status` | Every new Claude Code session goes through the local proxy, which clears stale tool output only when the prompt cache is cold: measured
|
|
261
|
+
| `context-doctor autopilot on\|off\|pause\|resume\|status` | Every new Claude Code session goes through the local proxy, which clears stale tool output only when the prompt cache is cold: measured 12.7% less input cost, no session worse |
|
|
262
262
|
| `context-doctor instructions [--copy]` | The ~180-token standing rules (~120 on GPT) for claude.ai / ChatGPT preferences, for web and phones where no server runs |
|
|
263
263
|
| `context-doctor pack <files\|dirs\|-> --query "…"` | Only the chunks of big documents, logs or code a question needs, ranked and fit to `--max-tokens` (default 4000). No query: an outline; `--ids a#2,b#5` picks chunks from it. Offline, no key |
|
|
264
264
|
| `context-doctor overhead [--days n] [--mcp]` | What every request re-reads before your message: measured first-request size (Claude Code, Codex and Gemini CLI sessions), each memory file (CLAUDE.md and imports, rules, auto memory, AGENTS.md, Cursor rules, GEMINI.md) priced per month, MCP calls per server; `--mcp` sizes each configured server's tool definitions and flags unused ones |
|
|
@@ -335,7 +335,7 @@ context-doctor autopilot off # remove it
|
|
|
335
335
|
|
|
336
336
|
**What it does to each request:** once old tool output adds up to 20k+ tokens (file reads, shell output, search and web results, screenshots inside them), it replaces that output with a one-line note, keeping the 3 most recent results. The tool call stays in the history, so the model can simply run it again if it needs the output. Your messages, its answers, answers you gave to its questions, subagent reports and MCP results are never touched.
|
|
337
337
|
|
|
338
|
-
**Why it cannot make a session more expensive.** A prompt cache matches a byte-identical prefix, and changing old history re-bills everything after the change at the write rate (1.25x instead of
|
|
338
|
+
**Why it cannot make a session more expensive.** A prompt cache matches a byte-identical prefix, and changing old history re-bills everything after the change at the write rate (1.25x or 2x input instead of a read's 2.5-10%). So autopilot changes history only when the cache is cold anyway: after an idle gap longer than the cache lifetime the request itself declares (1 hour for Claude Code on a subscription, 5 minutes otherwise), when the whole prompt is re-written regardless. Once cleared, an output stays cleared on every later request, so the prefix is identical between clearings and the cache keeps hitting. Output the model has not read yet is never cleared. On current Claude models a thinking block is bound to the history before it, and accounts created on or after 2026-08-31 get an error for an edited history; when that happens the proxy resends the request unedited and stops editing that account's histories where thinking follows.
|
|
339
339
|
|
|
340
340
|
**Measured before it shipped**, by replaying every Claude Code session on the author's machine (43 sessions, 130 days) request by request through the shipped code, priced as the cache bills it, with real timestamps: 3.8 billion input tokens not sent and $4,694 at list price, ~$1,080 a month ([details](#what-it-saves)). `scripts/replay-autopilot.mjs` does this on yours:
|
|
341
341
|
|
|
@@ -345,7 +345,7 @@ context-doctor autopilot off # remove it
|
|
|
345
345
|
| Also clear on a warm cache when the saving "should" repay the rewrite | 9.9% | −0.13% (one session worse) |
|
|
346
346
|
| The proxy's general strategies (dedupe, trim, strip-base64) | 6.7% | −13% (one session worse) |
|
|
347
347
|
|
|
348
|
-
94% of input cost on that machine came from requests above 200k tokens, which is where the clearing lands. Smaller requests also mean later auto-compaction and a faster first token.
|
|
348
|
+
Re-measured on 2026-10-13 with current prices, each request at its own model and the audit's fixes (output the model has not read is never cleared, a conversation the proxy has not seen counts as warm): 67 sessions over 133 days, $1,442 of $11,347 at list price (12.7%), no session worse. 94% of input cost on that machine came from requests above 200k tokens, which is where the clearing lands. Smaller requests also mean later auto-compaction and a faster first token.
|
|
349
349
|
|
|
350
350
|
**Never in the way:** anything it cannot parse is forwarded unchanged; only Anthropic `/v1/messages` requests are touched. It adds about 7 ms to a 2.9 MB (~1M token) request. If the proxy dies, the service restarts it within seconds, and the every-prompt hook checks it before each prompt and starts it if needed (measured: 0.6 s once, when it had to). `pause` turns it into a passthrough without restarting anything; `off` removes the setting before stopping the service, and sessions started while it was on need a restart.
|
|
351
351
|
|
|
@@ -431,7 +431,7 @@ For any other MCP client, the server entry is:
|
|
|
431
431
|
"mcpServers": {
|
|
432
432
|
"context-doctor": {
|
|
433
433
|
"command": "npx",
|
|
434
|
-
"args": ["-y", "context-doctor
|
|
434
|
+
"args": ["-y", "context-doctor", "mcp"]
|
|
435
435
|
}
|
|
436
436
|
}
|
|
437
437
|
}
|
|
@@ -768,7 +768,7 @@ For the editor extension: bump `vscode/package.json`, add a `vscode/CHANGELOG.md
|
|
|
768
768
|
|
|
769
769
|
Locally, `npm run build:mcpb` builds the bundle (`MCPB_SELF_SIGNED=1` exercises the signing path with a throwaway certificate; Desktop still warns for those).
|
|
770
770
|
|
|
771
|
-
**What the npm download number measures.** `install` writes `npx -y context-doctor
|
|
771
|
+
**What the npm download number measures.** `install` run through npx writes `npx -y context-doctor mcp` into MCP configs, and npx re-fetches the tarball whenever a new version exists. So every release is downloaded once by every active install within about a day, and the daily count is almost entirely those refreshes: on this package, release days run ~170 downloads and non-release days ~27. Read it as "size of the active installed base × number of releases", not as new users — a quiet week with no releases will look like a decline while nothing has changed. Two corollaries: the release-day figure is a live count of machines running context-doctor, and a broken release reaches all of them automatically, which is why `prepublishOnly` runs the full test suite. npm's stats also lag by several days and occasionally record a day as zero; a zero on a release day is a gap in their pipeline, not in usage.
|
|
772
772
|
|
|
773
773
|
Known gotcha: if `npm publish` fails with **`404 Not Found - PUT …/context-doctor`** on a package that clearly exists, the real cause is an **expired npm login token** — npm reports unauthenticated publishes as a 404, not a 401. Check with `npm whoami`; if that errors, run `npm login` and publish again.
|
|
774
774
|
|
package/dist/autoclear.d.ts
CHANGED
|
@@ -51,13 +51,21 @@ export interface AutoClearOptions {
|
|
|
51
51
|
* warm clearing off, cold cache only (the never-worse setting).
|
|
52
52
|
*/
|
|
53
53
|
paybackSafety?: number;
|
|
54
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* Where cleared ids persist so a restart does not un-clear (and re-bill)
|
|
56
|
+
* them, with each conversation's last request time so a restart does not
|
|
57
|
+
* mistake a warm cache for a cold one.
|
|
58
|
+
*/
|
|
55
59
|
statePath?: string;
|
|
56
60
|
/**
|
|
57
|
-
* Treat a conversation this instance has
|
|
58
|
-
* nothing is cleared on it).
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
+
* Treat a conversation this instance has no record of as warm-cached (so
|
|
62
|
+
* nothing is cleared on it). Both the proxy and the in-process wrapper turn
|
|
63
|
+
* this on: a wrapper in a fresh process cannot know whether another process
|
|
64
|
+
* just sent the conversation, and a restarted proxy cannot know either
|
|
65
|
+
* unless the conversation's last request time was persisted (statePath).
|
|
66
|
+
* Calling a warm cache cold pays a full rewrite; calling a cold one warm
|
|
67
|
+
* only delays a free clearing to the next idle gap. Off here so a bare
|
|
68
|
+
* AutoClearer (the savings replay) keeps its first-request-is-cold model.
|
|
61
69
|
*/
|
|
62
70
|
unseenIsWarm?: boolean;
|
|
63
71
|
}
|
|
@@ -71,7 +79,26 @@ export interface AutoClearResult {
|
|
|
71
79
|
reason: string;
|
|
72
80
|
/** Index of the first message rewritten on this request (the cache breaks from here), or -1. */
|
|
73
81
|
firstChanged: number;
|
|
82
|
+
/** Ids this request cleared for the first time. */
|
|
83
|
+
newIds: string[];
|
|
84
|
+
/** Every id whose clearing this request carries, earlier batches included. */
|
|
85
|
+
appliedIds: string[];
|
|
74
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Whether a thinking block comes at or after message `index`. On current
|
|
89
|
+
* Claude models (Fable 5.1, Opus 5.5, Sonnet 5.5, Haiku 5.5) a thinking
|
|
90
|
+
* block's signature binds the conversation before it: an edit to an earlier
|
|
91
|
+
* message invalidates every later thinking block, and accounts created on or
|
|
92
|
+
* after 2026-08-31 get a 400 for it ("... bound to a different
|
|
93
|
+
* conversation"), on every retry of the same body.
|
|
94
|
+
*/
|
|
95
|
+
export declare function thinkingAfter(messages: unknown[], index: number): boolean;
|
|
96
|
+
/**
|
|
97
|
+
* A request that sets thinking.block_binding has opted into that check (any
|
|
98
|
+
* value, "drop_block" included, which silently drops the reasoning an edit
|
|
99
|
+
* invalidates), so it is treated like an account known to enforce it.
|
|
100
|
+
*/
|
|
101
|
+
export declare function optsIntoThinkingCheck(body: Record<string, unknown>): boolean;
|
|
75
102
|
interface Found {
|
|
76
103
|
/** Index of the message / input item that holds the result (cache breaks from here). */
|
|
77
104
|
unit: number;
|
|
@@ -93,7 +120,12 @@ interface View {
|
|
|
93
120
|
toolName: Map<string, string>;
|
|
94
121
|
results: Array<Omit<Found, "tokens">>;
|
|
95
122
|
firstUser: unknown;
|
|
96
|
-
|
|
123
|
+
/**
|
|
124
|
+
* Index of the last unit the model wrote (-1 for none). Results after it
|
|
125
|
+
* arrive with this request: the model has not read them yet, so they are
|
|
126
|
+
* never eligible, however many there are (six parallel reads).
|
|
127
|
+
*/
|
|
128
|
+
seenUpTo: number;
|
|
97
129
|
ttlMs: number;
|
|
98
130
|
}
|
|
99
131
|
export declare function clearedNote(tokens: number): string;
|
|
@@ -114,12 +146,19 @@ export declare class AutoClearer {
|
|
|
114
146
|
private convs;
|
|
115
147
|
private opts;
|
|
116
148
|
private dirty;
|
|
149
|
+
/** Conversation times changed since the last save (written at most every CONV_SAVE_MS, or on flush). */
|
|
150
|
+
private convsDirty;
|
|
151
|
+
private lastSave;
|
|
152
|
+
/** Accounts (salted credential hashes from the caller) whose API rejected a history autopilot edited. */
|
|
153
|
+
private rejecting;
|
|
117
154
|
constructor(options?: AutoClearOptions);
|
|
118
155
|
/**
|
|
119
156
|
* Rewrite `body` in place: an Anthropic Messages, OpenAI Chat Completions or
|
|
120
157
|
* OpenAI Responses request. Never throws; anything unrecognised is left alone.
|
|
158
|
+
* `account` names the credential the request carries (the proxy passes a
|
|
159
|
+
* salted hash), for accounts whose API rejects edited history.
|
|
121
160
|
*/
|
|
122
|
-
apply(body: Record<string, unknown>, now?: number, modelHint?: string): AutoClearResult;
|
|
161
|
+
apply(body: Record<string, unknown>, now?: number, modelHint?: string, account?: string): AutoClearResult;
|
|
123
162
|
/**
|
|
124
163
|
* Warm cache: clearing rewrites everything after the first cleared result
|
|
125
164
|
* at the write rate (1.25x instead of 0.1x: 1.15x extra, once), and saves
|
|
@@ -129,6 +168,18 @@ export declare class AutoClearer {
|
|
|
129
168
|
* cover the rewrite `paybackSafety` times over.
|
|
130
169
|
*/
|
|
131
170
|
private shouldClearWarm;
|
|
171
|
+
/** Write pending state now (the proxy calls this on a timer and before it exits). */
|
|
172
|
+
flush(): void;
|
|
173
|
+
/** Whether this account's API has rejected a history autopilot edited (see thinkingAfter). */
|
|
174
|
+
rejectsEdits(account?: string): boolean;
|
|
175
|
+
/** Remember, across restarts, that this account's API rejects edited history. */
|
|
176
|
+
noteEditsRejected(account: string): void;
|
|
177
|
+
/**
|
|
178
|
+
* Stop applying these clearings: the API refused the request that carried
|
|
179
|
+
* them, and any thinking it produces from now on is bound to the history
|
|
180
|
+
* without them.
|
|
181
|
+
*/
|
|
182
|
+
forget(ids: string[]): void;
|
|
132
183
|
private load;
|
|
133
184
|
private save;
|
|
134
185
|
}
|
package/dist/autoclear.js
CHANGED
|
@@ -55,7 +55,44 @@ export const CLEARABLE_TOOLS = new Set([
|
|
|
55
55
|
]);
|
|
56
56
|
/** Tokens billed for one image inside a tool result (a full-size screenshot is ~1.6k on Claude). */
|
|
57
57
|
const IMAGE_TOKENS = 1600;
|
|
58
|
+
/**
|
|
59
|
+
* Whether a thinking block comes at or after message `index`. On current
|
|
60
|
+
* Claude models (Fable 5.1, Opus 5.5, Sonnet 5.5, Haiku 5.5) a thinking
|
|
61
|
+
* block's signature binds the conversation before it: an edit to an earlier
|
|
62
|
+
* message invalidates every later thinking block, and accounts created on or
|
|
63
|
+
* after 2026-08-31 get a 400 for it ("... bound to a different
|
|
64
|
+
* conversation"), on every retry of the same body.
|
|
65
|
+
*/
|
|
66
|
+
export function thinkingAfter(messages, index) {
|
|
67
|
+
for (let i = Math.max(0, index); i < messages.length; i++) {
|
|
68
|
+
const content = messages[i]?.content;
|
|
69
|
+
if (Array.isArray(content) && content.some((b) => b?.type === "thinking" || b?.type === "redacted_thinking"))
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* A request that sets thinking.block_binding has opted into that check (any
|
|
76
|
+
* value, "drop_block" included, which silently drops the reasoning an edit
|
|
77
|
+
* invalidates), so it is treated like an account known to enforce it.
|
|
78
|
+
*/
|
|
79
|
+
export function optsIntoThinkingCheck(body) {
|
|
80
|
+
const thinking = body.thinking;
|
|
81
|
+
return Boolean(thinking && typeof thinking === "object" && thinking.block_binding !== undefined);
|
|
82
|
+
}
|
|
58
83
|
const HOUR = 3_600_000;
|
|
84
|
+
/**
|
|
85
|
+
* Conversations' last request times are written at most this often (and on
|
|
86
|
+
* flush). A record read back after a crash can be this much older than the
|
|
87
|
+
* truth, so it is read as this much newer: when unsure, warm.
|
|
88
|
+
*/
|
|
89
|
+
const CONV_SAVE_MS = 60_000;
|
|
90
|
+
function lastIndex(items, pred) {
|
|
91
|
+
for (let i = items.length - 1; i >= 0; i--)
|
|
92
|
+
if (pred(items[i]))
|
|
93
|
+
return i;
|
|
94
|
+
return -1;
|
|
95
|
+
}
|
|
59
96
|
export function clearedNote(tokens) {
|
|
60
97
|
// Not "run it again": a cleared Bash result may come from a command with
|
|
61
98
|
// side effects (a push, a delete), and repeating that would be wrong.
|
|
@@ -101,11 +138,13 @@ function isOpenAIChat(messages) {
|
|
|
101
138
|
export function viewOf(body) {
|
|
102
139
|
const toolName = new Map();
|
|
103
140
|
const results = [];
|
|
104
|
-
let firstToolId = "";
|
|
105
141
|
// Gemini generateContent: contents[].parts[] with functionCall / functionResponse.
|
|
106
|
-
// Ids are optional there, so a missing one is derived
|
|
107
|
-
//
|
|
108
|
-
// cleared set is keyed by id
|
|
142
|
+
// Ids are optional there, so a missing one is derived. Position and the
|
|
143
|
+
// first user message alone repeat whenever the same prompt runs twice, and
|
|
144
|
+
// the cleared set is keyed by id: run 2 then had run 1's clearings applied
|
|
145
|
+
// to its own results, newest included. The response's own content is part
|
|
146
|
+
// of the derived id, so another run's result is another id unless it is
|
|
147
|
+
// byte-identical at the same place (and apply() still requires it eligible).
|
|
109
148
|
if (Array.isArray(body.contents)) {
|
|
110
149
|
const units = body.contents;
|
|
111
150
|
const firstUser = units.find((u) => u?.role === "user")?.parts;
|
|
@@ -118,12 +157,12 @@ export function viewOf(body) {
|
|
|
118
157
|
const call = p?.functionCall;
|
|
119
158
|
const resp = p?.functionResponse;
|
|
120
159
|
if (call && typeof call === "object") {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
firstToolId ||= id;
|
|
160
|
+
if (typeof call.id === "string" && call.id)
|
|
161
|
+
toolName.set(call.id, String(call.name ?? ""));
|
|
124
162
|
}
|
|
125
163
|
else if (resp && typeof resp === "object") {
|
|
126
|
-
const
|
|
164
|
+
const content = createHash("sha1").update(JSON.stringify(resp.response ?? "")).digest("hex").slice(0, 12);
|
|
165
|
+
const id = typeof resp.id === "string" && resp.id ? resp.id : `gemini:${scope}:${i}:${j}:${content}`;
|
|
127
166
|
// The response names its tool, so it classifies on its own.
|
|
128
167
|
if (!toolName.has(id))
|
|
129
168
|
toolName.set(id, String(resp.name ?? ""));
|
|
@@ -131,7 +170,8 @@ export function viewOf(body) {
|
|
|
131
170
|
}
|
|
132
171
|
});
|
|
133
172
|
});
|
|
134
|
-
|
|
173
|
+
const seenUpTo = lastIndex(units, (u) => u?.role === "model");
|
|
174
|
+
return { format: "gemini", units, toolName, results, firstUser, seenUpTo, ttlMs: requestTtlMs(body) };
|
|
135
175
|
}
|
|
136
176
|
if (Array.isArray(body.input)) {
|
|
137
177
|
const units = body.input;
|
|
@@ -139,19 +179,23 @@ export function viewOf(body) {
|
|
|
139
179
|
const t = it?.type;
|
|
140
180
|
if ((t === "function_call" || t === "custom_tool_call" || t === "local_shell_call") && typeof it.call_id === "string") {
|
|
141
181
|
toolName.set(it.call_id, t === "local_shell_call" ? "shell" : String(it.name ?? ""));
|
|
142
|
-
firstToolId ||= it.call_id;
|
|
143
182
|
}
|
|
144
183
|
else if ((t === "function_call_output" || t === "custom_tool_call_output" || t === "local_shell_call_output") && typeof it.call_id === "string") {
|
|
145
184
|
results.push({ unit: i, id: it.call_id, content: it.output, set: (note) => { units[i] = { ...it, output: note }; } });
|
|
146
185
|
}
|
|
147
186
|
});
|
|
148
187
|
const firstUser = units.find((u) => u?.role === "user")?.content;
|
|
149
|
-
|
|
188
|
+
// Model-written items: its tool calls, reasoning and assistant messages.
|
|
189
|
+
// With previous_response_id the input holds none of them, so every result
|
|
190
|
+
// in it is new to the model.
|
|
191
|
+
const seenUpTo = lastIndex(units, (u) => u?.role === "assistant" || ["function_call", "custom_tool_call", "local_shell_call", "reasoning"].includes(String(u?.type)));
|
|
192
|
+
return { format: "openai-responses", units, toolName, results, firstUser, seenUpTo, ttlMs: requestTtlMs(body) };
|
|
150
193
|
}
|
|
151
194
|
const messages = body.messages;
|
|
152
195
|
if (!Array.isArray(messages) || messages.length === 0)
|
|
153
196
|
return undefined;
|
|
154
197
|
const firstUser = messages.find((m) => m?.role === "user")?.content;
|
|
198
|
+
const seenUpTo = lastIndex(messages, (m) => m?.role === "assistant");
|
|
155
199
|
if (isOpenAIChat(messages)) {
|
|
156
200
|
messages.forEach((m, i) => {
|
|
157
201
|
if (m?.role === "assistant" && Array.isArray(m.tool_calls)) {
|
|
@@ -159,14 +203,13 @@ export function viewOf(body) {
|
|
|
159
203
|
if (typeof c?.id !== "string")
|
|
160
204
|
continue;
|
|
161
205
|
toolName.set(c.id, String(c.function?.name ?? c.name ?? ""));
|
|
162
|
-
firstToolId ||= c.id;
|
|
163
206
|
}
|
|
164
207
|
}
|
|
165
208
|
else if (m?.role === "tool" && typeof m.tool_call_id === "string") {
|
|
166
209
|
results.push({ unit: i, id: m.tool_call_id, content: m.content, set: (note) => { messages[i] = { ...m, content: note }; } });
|
|
167
210
|
}
|
|
168
211
|
});
|
|
169
|
-
return { format: "openai-chat", units: messages, toolName, results, firstUser,
|
|
212
|
+
return { format: "openai-chat", units: messages, toolName, results, firstUser, seenUpTo, ttlMs: requestTtlMs(body) };
|
|
170
213
|
}
|
|
171
214
|
messages.forEach((m, i) => {
|
|
172
215
|
if (!Array.isArray(m?.content))
|
|
@@ -175,25 +218,36 @@ export function viewOf(body) {
|
|
|
175
218
|
blocks.forEach((b, j) => {
|
|
176
219
|
if (m.role === "assistant" && b?.type === "tool_use" && typeof b.id === "string") {
|
|
177
220
|
toolName.set(b.id, String(b.name ?? ""));
|
|
178
|
-
firstToolId ||= b.id;
|
|
179
221
|
}
|
|
180
222
|
else if (m.role === "user" && b?.type === "tool_result" && typeof b.tool_use_id === "string") {
|
|
181
223
|
results.push({ unit: i, id: b.tool_use_id, content: b.content, set: (note) => { blocks[j] = { ...b, content: note }; } });
|
|
182
224
|
}
|
|
183
225
|
});
|
|
184
226
|
});
|
|
185
|
-
return { format: "anthropic", units: messages, toolName, results, firstUser,
|
|
227
|
+
return { format: "anthropic", units: messages, toolName, results, firstUser, seenUpTo, ttlMs: requestTtlMs(body) };
|
|
186
228
|
}
|
|
187
229
|
/**
|
|
188
|
-
* A conversation's identity
|
|
189
|
-
*
|
|
190
|
-
*
|
|
230
|
+
* A conversation's identity, the same from its first request on: the first
|
|
231
|
+
* user message, plus the client's own session tag when it sends one
|
|
232
|
+
* (Anthropic metadata.user_id, which Claude Code fills per session; OpenAI
|
|
233
|
+
* prompt_cache_key). The first user message is read without cache_control:
|
|
234
|
+
* Claude Code marks the last message, which on request 1 is the first one,
|
|
235
|
+
* and moves the marker on request 2. Through 0.31.1 the key also held the
|
|
236
|
+
* first tool call id, so request 2 (the first tool round) was a new,
|
|
237
|
+
* never-seen conversation, called cold eight seconds after request 1.
|
|
238
|
+
* Two sessions that open with the same message and send no tag share a key;
|
|
239
|
+
* that only ever makes one look warm when the other is, the safe direction.
|
|
191
240
|
* The model is left out on purpose: switching models mid-session keeps the
|
|
192
241
|
* conversation (and the cleared set) but not the cache, which the TTL check
|
|
193
242
|
* cannot see, so a model switch is treated as warm, the safe direction.
|
|
194
243
|
*/
|
|
195
|
-
function conversationKey(view) {
|
|
196
|
-
|
|
244
|
+
function conversationKey(view, body) {
|
|
245
|
+
const first = JSON.stringify(view.firstUser ?? "", (k, v) => (k === "cache_control" ? undefined : v)) ?? "";
|
|
246
|
+
const h = createHash("sha1").update(first.slice(0, 4000));
|
|
247
|
+
const tag = body.metadata?.user_id ?? body.prompt_cache_key;
|
|
248
|
+
if (typeof tag === "string")
|
|
249
|
+
h.update("\0").update(tag);
|
|
250
|
+
return h.digest("hex");
|
|
197
251
|
}
|
|
198
252
|
export class AutoClearer {
|
|
199
253
|
cleared = new Map();
|
|
@@ -202,6 +256,11 @@ export class AutoClearer {
|
|
|
202
256
|
convs = new Map();
|
|
203
257
|
opts;
|
|
204
258
|
dirty = false;
|
|
259
|
+
/** Conversation times changed since the last save (written at most every CONV_SAVE_MS, or on flush). */
|
|
260
|
+
convsDirty = false;
|
|
261
|
+
lastSave = 0;
|
|
262
|
+
/** Accounts (salted credential hashes from the caller) whose API rejected a history autopilot edited. */
|
|
263
|
+
rejecting = new Set();
|
|
205
264
|
constructor(options = {}) {
|
|
206
265
|
this.opts = {
|
|
207
266
|
keepRecent: options.keepRecent ?? 3,
|
|
@@ -215,21 +274,26 @@ export class AutoClearer {
|
|
|
215
274
|
/**
|
|
216
275
|
* Rewrite `body` in place: an Anthropic Messages, OpenAI Chat Completions or
|
|
217
276
|
* OpenAI Responses request. Never throws; anything unrecognised is left alone.
|
|
277
|
+
* `account` names the credential the request carries (the proxy passes a
|
|
278
|
+
* salted hash), for accounts whose API rejects edited history.
|
|
218
279
|
*/
|
|
219
|
-
apply(body, now = Date.now(), modelHint) {
|
|
220
|
-
const none = (reason, cold = false) => ({ changed: false, newlyCleared: 0, tokensRemoved: 0, cold, reason, firstChanged: -1 });
|
|
280
|
+
apply(body, now = Date.now(), modelHint, account) {
|
|
281
|
+
const none = (reason, cold = false) => ({ changed: false, newlyCleared: 0, tokensRemoved: 0, cold, reason, firstChanged: -1, newIds: [], appliedIds: [] });
|
|
221
282
|
try {
|
|
222
283
|
const view = viewOf(body);
|
|
223
284
|
if (!view)
|
|
224
285
|
return none("no messages");
|
|
225
286
|
// Gemini puts the model in the URL, not the body; the proxy passes it.
|
|
226
287
|
const model = typeof body.model === "string" ? body.model : modelHint;
|
|
227
|
-
const key = conversationKey(view);
|
|
288
|
+
const key = conversationKey(view, body);
|
|
228
289
|
const conv = this.convs.get(key);
|
|
229
290
|
const cold = conv ? now - conv.last > view.ttlMs : !this.opts.unseenIsWarm;
|
|
291
|
+
// Delete first so the map stays in last-used order and the cap evicts idle conversations.
|
|
292
|
+
this.convs.delete(key);
|
|
230
293
|
this.convs.set(key, { last: now, requests: (conv?.requests ?? 0) + 1 });
|
|
231
294
|
if (this.convs.size > 2000)
|
|
232
295
|
this.convs.delete(this.convs.keys().next().value);
|
|
296
|
+
this.convsDirty = true;
|
|
233
297
|
const results = view.results.map((r) => {
|
|
234
298
|
let tokens = this.sizes.get(r.id);
|
|
235
299
|
if (tokens === undefined) {
|
|
@@ -240,8 +304,11 @@ export class AutoClearer {
|
|
|
240
304
|
}
|
|
241
305
|
return { ...r, tokens };
|
|
242
306
|
});
|
|
307
|
+
// Results that arrive with this request (after the model's last turn)
|
|
308
|
+
// are not eligible however many there are: the model has not read them.
|
|
243
309
|
const eligible = results.slice(0, Math.max(0, results.length - this.opts.keepRecent))
|
|
244
|
-
.filter((r) => CLEARABLE_TOOLS.has(view.toolName.get(r.id) ?? ""));
|
|
310
|
+
.filter((r) => r.unit <= view.seenUpTo && CLEARABLE_TOOLS.has(view.toolName.get(r.id) ?? ""));
|
|
311
|
+
const eligibleIds = new Set(eligible.map((r) => r.id));
|
|
245
312
|
const pending = eligible.filter((r) => !this.cleared.has(r.id) && r.tokens > 200);
|
|
246
313
|
const pendingTokens = pending.reduce((n, r) => n + r.tokens, 0);
|
|
247
314
|
let reason = "nothing to clear";
|
|
@@ -262,17 +329,29 @@ export class AutoClearer {
|
|
|
262
329
|
else if (pendingTokens > 0) {
|
|
263
330
|
reason = `waiting for a ${Math.round(this.opts.minClearTokens / 1000)}k batch`;
|
|
264
331
|
}
|
|
332
|
+
// An account that rejects edited history gets no NEW clearing a
|
|
333
|
+
// thinking block comes after: that block is bound to the history as it
|
|
334
|
+
// was. Clearings already sent keep being applied, because thinking
|
|
335
|
+
// generated since then is bound to the cleared history; dropping them
|
|
336
|
+
// would break the conversation the other way.
|
|
337
|
+
if (taken.length && (this.rejectsEdits(account) || optsIntoThinkingCheck(body)) && thinkingAfter(view.units, Math.min(...taken.map((r) => r.unit)))) {
|
|
338
|
+
taken = [];
|
|
339
|
+
reason = "thinking blocks follow the stale output and this account rejects edited history: left as is";
|
|
340
|
+
}
|
|
265
341
|
for (const r of taken)
|
|
266
342
|
this.cleared.set(r.id, r.tokens);
|
|
267
343
|
if (taken.length)
|
|
268
344
|
this.dirty = true;
|
|
269
345
|
// Apply every clearing ever decided for this conversation, so the
|
|
270
|
-
// prefix is identical to what was sent last time.
|
|
346
|
+
// prefix is identical to what was sent last time. A remembered id must
|
|
347
|
+
// still be eligible here: a derived id (Gemini without ids) can recur in
|
|
348
|
+
// another conversation, where it must not cost that one its newest result.
|
|
271
349
|
let removed = 0;
|
|
272
350
|
let changed = false;
|
|
273
351
|
let firstChanged = -1;
|
|
352
|
+
const appliedIds = [];
|
|
274
353
|
for (const r of results) {
|
|
275
|
-
if (!this.cleared.has(r.id))
|
|
354
|
+
if (!this.cleared.has(r.id) || !eligibleIds.has(r.id))
|
|
276
355
|
continue;
|
|
277
356
|
const note = clearedNote(this.cleared.get(r.id));
|
|
278
357
|
if (r.content === note)
|
|
@@ -280,11 +359,12 @@ export class AutoClearer {
|
|
|
280
359
|
removed += Math.max(0, r.tokens - estimateTokens(note, model));
|
|
281
360
|
r.set(note);
|
|
282
361
|
changed = true;
|
|
362
|
+
appliedIds.push(r.id);
|
|
283
363
|
if (firstChanged === -1 || r.unit < firstChanged)
|
|
284
364
|
firstChanged = r.unit;
|
|
285
365
|
}
|
|
286
366
|
this.save();
|
|
287
|
-
return { changed, newlyCleared: taken.length, tokensRemoved: removed, cold, reason, firstChanged };
|
|
367
|
+
return { changed, newlyCleared: taken.length, tokensRemoved: removed, cold, reason, firstChanged, newIds: taken.map((r) => r.id), appliedIds };
|
|
288
368
|
}
|
|
289
369
|
catch {
|
|
290
370
|
return none("error: request forwarded unchanged");
|
|
@@ -307,6 +387,36 @@ export class AutoClearer {
|
|
|
307
387
|
const saving = 0.1 * pendingTokens * requestsSoFar;
|
|
308
388
|
return saving >= this.opts.paybackSafety * rewriteCost;
|
|
309
389
|
}
|
|
390
|
+
/** Write pending state now (the proxy calls this on a timer and before it exits). */
|
|
391
|
+
flush() {
|
|
392
|
+
this.save(true);
|
|
393
|
+
}
|
|
394
|
+
/** Whether this account's API has rejected a history autopilot edited (see thinkingAfter). */
|
|
395
|
+
rejectsEdits(account) {
|
|
396
|
+
return account !== undefined && this.rejecting.has(account);
|
|
397
|
+
}
|
|
398
|
+
/** Remember, across restarts, that this account's API rejects edited history. */
|
|
399
|
+
noteEditsRejected(account) {
|
|
400
|
+
if (this.rejecting.has(account))
|
|
401
|
+
return;
|
|
402
|
+
this.rejecting.add(account);
|
|
403
|
+
this.dirty = true;
|
|
404
|
+
this.save();
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Stop applying these clearings: the API refused the request that carried
|
|
408
|
+
* them, and any thinking it produces from now on is bound to the history
|
|
409
|
+
* without them.
|
|
410
|
+
*/
|
|
411
|
+
forget(ids) {
|
|
412
|
+
let any = false;
|
|
413
|
+
for (const id of ids)
|
|
414
|
+
any = this.cleared.delete(id) || any;
|
|
415
|
+
if (any) {
|
|
416
|
+
this.dirty = true;
|
|
417
|
+
this.save();
|
|
418
|
+
}
|
|
419
|
+
}
|
|
310
420
|
load() {
|
|
311
421
|
if (!this.opts.statePath || !existsSync(this.opts.statePath))
|
|
312
422
|
return;
|
|
@@ -314,20 +424,35 @@ export class AutoClearer {
|
|
|
314
424
|
const data = JSON.parse(readFileSync(this.opts.statePath, "utf8"));
|
|
315
425
|
for (const [id, tokens] of data.cleared ?? [])
|
|
316
426
|
this.cleared.set(id, tokens);
|
|
427
|
+
for (const account of data.rejectsEdits ?? [])
|
|
428
|
+
if (typeof account === "string")
|
|
429
|
+
this.rejecting.add(account);
|
|
430
|
+
// Each conversation's last request time, so a restart does not make
|
|
431
|
+
// every open conversation "unseen" (cold) and rewrite a warm cache.
|
|
432
|
+
for (const [key, last, requests] of data.convs ?? []) {
|
|
433
|
+
if (typeof key === "string" && typeof last === "number")
|
|
434
|
+
this.convs.set(key, { last: last + CONV_SAVE_MS, requests: Number(requests) || 1 });
|
|
435
|
+
}
|
|
317
436
|
}
|
|
318
437
|
catch {
|
|
319
438
|
/* a corrupt state file only means results may be re-cleared once */
|
|
320
439
|
}
|
|
321
440
|
}
|
|
322
|
-
save() {
|
|
323
|
-
if (!this.
|
|
441
|
+
save(force = false) {
|
|
442
|
+
if (!this.opts.statePath)
|
|
443
|
+
return;
|
|
444
|
+
const due = this.convsDirty && (force || Date.now() - this.lastSave >= CONV_SAVE_MS);
|
|
445
|
+
if (!this.dirty && !due)
|
|
324
446
|
return;
|
|
325
447
|
this.dirty = false;
|
|
448
|
+
this.convsDirty = false;
|
|
449
|
+
this.lastSave = Date.now();
|
|
326
450
|
try {
|
|
327
451
|
// Newest 50k ids: far more than any live session holds.
|
|
328
452
|
const entries = [...this.cleared.entries()].slice(-50_000);
|
|
453
|
+
const convs = [...this.convs.entries()].map(([k, c]) => [k, c.last, c.requests]);
|
|
329
454
|
mkdirSync(dirname(this.opts.statePath), { recursive: true });
|
|
330
|
-
writeFileSync(this.opts.statePath, JSON.stringify({ cleared: entries }));
|
|
455
|
+
writeFileSync(this.opts.statePath, JSON.stringify({ cleared: entries, convs, rejectsEdits: [...this.rejecting] }));
|
|
331
456
|
}
|
|
332
457
|
catch {
|
|
333
458
|
/* persistence is best effort */
|