cachetoast 0.1.0 → 0.1.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.
- package/README.md +22 -7
- package/bin/cachetoast.mjs +17 -7
- package/docs/research.md +15 -3
- package/lib/claude-mod.mjs +65 -0
- package/lib/idle.mjs +35 -0
- package/lib/install.mjs +13 -1
- package/lib/pi-extension.mjs +49 -7
- package/lib/policy.mjs +4 -2
- package/lib/runtime.mjs +29 -4
- package/lib/transcript.mjs +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Cachetoast
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Cachetoast tries to compact **large, idle** Claude Code and Pi sessions before their prompt cache expires. If compaction cannot run, it holds your next prompt once and offers a handoff to a fresh session. Codex uses that handoff fallback.
|
|
4
4
|
|
|
5
|
-
Zero dependencies. No
|
|
5
|
+
Zero dependencies. No separate API keys or telemetry. Timers and handoffs run locally; native compaction uses your agent's model and normal billing. Node.js 22+, macOS/Linux.
|
|
6
6
|
|
|
7
7
|
Built by [R. Hunter Harris](https://huntersoftwareconsulting.com) at [Hunter Software Consulting](https://huntersoftwareconsulting.com), fractional product and technology leadership for B2B SaaS.
|
|
8
8
|
|
|
@@ -20,12 +20,25 @@ It installs for the agents you have (`--provider codex` to choose, `--dry-run` t
|
|
|
20
20
|
|
|
21
21
|
Then restart your agent.
|
|
22
22
|
|
|
23
|
+
- **Claude Code:** start from the repo root and trust the project. The repo-local `cachetoast-idle` plugin provides the timer; mods require terminal v2.1.287+ or desktop v2.1.286+. Check `/plugin` for loading errors. Older clients retain the handoff fallback.
|
|
23
24
|
- **Codex:** trust the project, run `codex` in the repo, and approve the hooks in `/hooks`. The Codex app skips unapproved hooks and can't approve them ([openai/codex#47283](https://github.com/openai/codex/issues/47283)).
|
|
24
25
|
- **Pi:** trust the project.
|
|
25
26
|
|
|
26
27
|
## How it works
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
For sessions of at least 100k tokens (`--min-context-tokens`), Claude Code and Pi schedule one native compaction attempt per idle period:
|
|
30
|
+
|
|
31
|
+
| Cache TTL | Compact after idle | Time before expiry |
|
|
32
|
+
| --- | --- | --- |
|
|
33
|
+
| 5 minutes | 4 minutes | 1 minute |
|
|
34
|
+
| 30 minutes | 25 minutes | 5 minutes |
|
|
35
|
+
| 1 hour | 55 minutes | 5 minutes |
|
|
36
|
+
|
|
37
|
+
New activity cancels the timer. Successful compaction avoids the handoff for that idle period and does not start another timer. If the agent closes or the computer sleeps past expiry, Cachetoast skips the cold compaction. Resuming while still warm can restore the remaining timer.
|
|
38
|
+
|
|
39
|
+
Claude Code 2.1.299+ has its own [idle compaction](https://code.claude.com/docs/en/settings-reference#idlecompaction): terminal sessions on the 1h TTL, about 54 minutes idle, 200k tokens by default. Cachetoast lowers that minimum to match its own (`CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS`, 100k at least), so Claude usually compacts first and Cachetoast's timer covers what it skips: the 5m TTL, the desktop app and other clients, older versions, and `idleCompaction: false`. Either compaction prevents the hold.
|
|
40
|
+
|
|
41
|
+
If compaction failed, was skipped, or is unavailable, your next prompt after the deadline is held once:
|
|
29
42
|
|
|
30
43
|
```text
|
|
31
44
|
Cachetoast held this prompt!
|
|
@@ -49,11 +62,11 @@ Small sessions are left alone, since a fresh session wouldn't be cheaper. Cachet
|
|
|
49
62
|
The hold starts at the TTL minus up to 5 minutes (1h → 55 min, 30m → 25 min, 5m → 4 min).
|
|
50
63
|
|
|
51
64
|
- **Claude Code:** read from the session (1h on Pro/Max; 5m on API keys, cloud providers, or usage credits).
|
|
52
|
-
- **Codex:** 30m (GPT-5.6 and later).
|
|
65
|
+
- **Codex:** 30m (GPT-5.6 and later). OpenAI's API now also offers `12h`, but Codex CLI 0.163 doesn't request it. A Codex lead has said signed-in Codex users keep their cache indefinitely; OpenAI hasn't documented this, so Cachetoast still assumes 30m. If you trust it, raise the TTL with `--codex-cache-ttl 12h` or `24h`.
|
|
53
66
|
- **Pi:** 5m for Claude models (1h with `PI_CACHE_RETENTION=long`), otherwise 30m.
|
|
54
67
|
- **Anything else:** 30m.
|
|
55
68
|
|
|
56
|
-
To override, run `init --pi-cache-ttl 1h` (or `--codex-cache-ttl`, `--claude-cache-ttl`; `5m`, `30m`, `1h`, `24h`). That saves to `~/.config/cachetoast/config.json`; `.cachetoast/local.json` takes the same format per repo. A TTL reported by a Claude session always wins.
|
|
69
|
+
To override, run `init --pi-cache-ttl 1h` (or `--codex-cache-ttl`, `--claude-cache-ttl`; `5m`, `30m`, `1h`, `12h`, `24h`). That saves to `~/.config/cachetoast/config.json`; `.cachetoast/local.json` takes the same format per repo. A TTL reported by a Claude session always wins.
|
|
57
70
|
|
|
58
71
|
## Commands
|
|
59
72
|
|
|
@@ -65,7 +78,7 @@ node .cachetoast/runtime/bin/cachetoast.mjs uninstall # restore origin
|
|
|
65
78
|
|
|
66
79
|
## What to commit
|
|
67
80
|
|
|
68
|
-
Commit `.cachetoast/runtime`, `.cachetoast/config.json`, and the agent settings, hooks,
|
|
81
|
+
Commit `.cachetoast/runtime`, `.cachetoast/config.json`, and the agent settings, hooks, handoff command or skill, and `.claude/skills/cachetoast-idle` plugin. These use repo-relative paths and need no launchd, cron, or global service. `local.json`, `state/`, `handoffs/`, and `manifest.json` are gitignored; handoffs contain conversation excerpts.
|
|
69
82
|
|
|
70
83
|
`init` and `update` are idempotent and leave unrelated settings alone. `uninstall` restores the original files and removes local state. Each stops if you've edited the managed files by hand.
|
|
71
84
|
|
|
@@ -74,8 +87,10 @@ Commit `.cachetoast/runtime`, `.cachetoast/config.json`, and the agent settings,
|
|
|
74
87
|
- Savings come mostly from Claude Code quota: its large contexts are rewritten to cache at 2× the input price after a miss. Codex and Pi misses cost less.
|
|
75
88
|
- Mobile and remote clients may not show the notice clearly, and can't start a fresh local session to run the handoff. Send the prompt again to continue, or run the handoff later from the CLI or desktop app.
|
|
76
89
|
- Expiry is estimated from a local clock; Cachetoast can't see the provider's cache.
|
|
90
|
+
- Timers require the agent to remain running and awake. Starting a compaction before expiry does not guarantee completion or a cache hit. A failed attempt keeps the handoff available.
|
|
91
|
+
- Codex exposes compaction to an owning app-server connection, but its repo hooks cannot portably reach an arbitrary open session. Codex gets the native context ceiling and handoff guard, without a proactive timer.
|
|
77
92
|
- No hold if hooks are disabled, untrusted, or time out.
|
|
78
|
-
-
|
|
93
|
+
- Claude plugin statically validated with Claude Code 2.1.292; adapter behavior tested with synthetic sessions. Claude's native idle compaction is read from its transcript summary; no live run confirmed it. No paid live-compaction experiment was run. Other integration baselines: Codex CLI 0.163.0 and Pi 0.85.1. Research: [docs/research.md](docs/research.md).
|
|
79
94
|
|
|
80
95
|
## Develop
|
|
81
96
|
|
package/bin/cachetoast.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { handleHook, configAt, ttlChoice, statePath, userConfigPath, KEY_RE, PROVIDERS } from '../lib/runtime.mjs';
|
|
4
|
+
import { handleHook, idleState, recordIdle, configAt, ttlChoice, statePath, userConfigPath, KEY_RE, PROVIDERS } from '../lib/runtime.mjs';
|
|
5
5
|
import { readJson, readText, safePath } from '../lib/storage.mjs';
|
|
6
6
|
|
|
7
|
-
const HELP = `Cachetoast 0.1.
|
|
7
|
+
const HELP = `Cachetoast 0.1.2 — local handoffs, inspired in part by Antiburn
|
|
8
8
|
Usage: cachetoast <command> [--repo PATH] [options]
|
|
9
9
|
init Install in the current repo (or --repo) for the agents you have installed.
|
|
10
10
|
[--provider claude,codex,pi] [--dry-run]
|
|
@@ -17,10 +17,12 @@ Usage: cachetoast <command> [--repo PATH] [options]
|
|
|
17
17
|
uninstall Restore installation files if they have no external edits
|
|
18
18
|
hook --provider claude|codex: JSON lifecycle event on stdin (internal; Pi runs in-process)
|
|
19
19
|
Cache TTLs are read from Claude Code sessions, 30m for Codex (GPT-5.6+), and guessed from Pi's model.
|
|
20
|
-
To override (5m, 30m, 1h, 24h), pass --<agent>-cache-ttl: saved per user in ~/.config/cachetoast/config.json,
|
|
20
|
+
To override (5m, 30m, 1h, 12h, 24h), pass --<agent>-cache-ttl: saved per user in ~/.config/cachetoast/config.json,
|
|
21
21
|
or per repo in the gitignored .cachetoast/local.json. A TTL a Claude session reports always wins.
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
Claude Code and Pi attempt idle native compaction at 4m / 25m / 55m for 5m / 30m / 1h TTLs.
|
|
23
|
+
Codex uses the handoff fallback; its hooks cannot portably compact an already-open session.
|
|
24
|
+
Only sessions of 100k+ tokens are held, once per idle period, when compaction did not succeed.
|
|
25
|
+
Hooks and handoffs run locally. Native compaction uses the agent's model and normal billing.
|
|
24
26
|
`;
|
|
25
27
|
function options(args) {
|
|
26
28
|
const result = {};
|
|
@@ -65,6 +67,13 @@ async function main() {
|
|
|
65
67
|
if (argv[0] === '--help' || argv[0] === '-h') { process.stdout.write(HELP); return; }
|
|
66
68
|
const [command = 'help', ...args] = argv, o = options(args);
|
|
67
69
|
if (command === 'help' || o.help) { process.stdout.write(HELP); return; }
|
|
70
|
+
if (command === 'idle') {
|
|
71
|
+
const input = JSON.parse(await stdin()), root = findRoot(o.repo ?? process.cwd());
|
|
72
|
+
const out = input.status ? recordIdle(root, o.provider, input.session_id, input)
|
|
73
|
+
: idleState(root, o.provider, input.session_id);
|
|
74
|
+
process.stdout.write(JSON.stringify(out) + '\n');
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
68
77
|
if (command === 'hook') {
|
|
69
78
|
// A cost advisory must never stop work because of its own failure: fail open with a visible warning.
|
|
70
79
|
let result;
|
|
@@ -104,8 +113,9 @@ async function main() {
|
|
|
104
113
|
if (result.userConfig) console.log(`${result.dryRun ? 'Would save' : 'Saved'} cache TTLs to ${result.userConfig}.`);
|
|
105
114
|
if (result.dryRun || !result.changed.length) return;
|
|
106
115
|
const next = ['Restart your agent.'];
|
|
116
|
+
if (result.providers.includes('claude')) next.push('Claude: launch from the repo root, trust the project, and check /plugin for cachetoast-idle (requires mods support).');
|
|
107
117
|
if (result.providers.includes('codex') || result.providers.includes('pi')) next.push('Trust the repo.');
|
|
108
|
-
if (result.providers.includes('codex')) next.push('Codex: review /hooks.');
|
|
118
|
+
if (result.providers.includes('codex')) next.push('Codex: review /hooks. Proactive idle compaction is unavailable, so the handoff is the fallback.');
|
|
109
119
|
console.log(next.join(' '));
|
|
110
120
|
return;
|
|
111
121
|
}
|
|
@@ -127,7 +137,7 @@ async function main() {
|
|
|
127
137
|
const sessions = keys.map(key => {
|
|
128
138
|
try {
|
|
129
139
|
const s = readJson(safePath(root, statePath(key)));
|
|
130
|
-
return { key, provider: s.provider, ...ttlChoice(config, s.provider, s), heldAt: s.heldAt ?? null, contextTokens: s.contextTokens ?? null, lastActivity: s.lastActivity, warnings: s.warnings ?? [] };
|
|
140
|
+
return { key, provider: s.provider, ...ttlChoice(config, s.provider, s), heldAt: s.heldAt ?? null, contextTokens: s.contextTokens ?? null, lastActivity: s.lastActivity, compaction: s.compaction ?? null, warnings: s.warnings ?? [] };
|
|
131
141
|
} catch (error) { return { key, error: `Unreadable state (${error.message}); delete it to reset` }; }
|
|
132
142
|
});
|
|
133
143
|
console.log(JSON.stringify({ config, userConfig: userConfigPath(), sessions, note: 'Cache availability is inferred, never guaranteed. Sessions with unknown context size are never held.' }, null, 2));
|
package/docs/research.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
Checked October 1, 2026 against primary provider documentation. Installed clients: Codex CLI 0.159.2 and Claude Code 2.1.285. No paid inference or quota experiment was performed. The private discussion was used to identify questions; its claims were not treated as documentation.
|
|
4
4
|
|
|
5
|
+
Updated October 17, 2026: Claude's current mods API supports host timers and in-process compaction. The generated plugin passed strict static validation with Claude Code 2.1.292. Timing, cancellation, native API calls, and fallback state are tested with synthetic sessions; no paid live-compaction experiment was performed.
|
|
6
|
+
|
|
7
|
+
Updated October 21, 2026: Claude Code added native idle compaction (2.1.299), and OpenAI added a 12-hour cache lifetime for GPT-5.6+ (October 20). Checked against Codex CLI 0.163.0; Pi was not rechecked.
|
|
8
|
+
|
|
5
9
|
## What survives cache expiry
|
|
6
10
|
|
|
7
11
|
Cache expiry removes reusable inference state, not the transcript or its context window. Continuing sends the current context again. Earlier compacted-away history is not magically restored. Native compaction changes the prompt prefix and can require a new cache write. A handoff prepared locally avoids submitting long history solely to summarize it.
|
|
@@ -12,13 +16,15 @@ Claude’s current docs say a cold `/compact` summarization request reprocesses
|
|
|
12
16
|
|
|
13
17
|
Included subscription main turns normally request one hour. API keys, cloud providers, and usage-credit billing normally use five minutes. Most helpers, including compaction, use five minutes unless overridden. Explicit controls exist: `promptCacheTtl` and `subagentPromptCacheTtl` settings, `CLAUDE_CODE_PROMPT_CACHE_TTL` (`5m`/`1h`), `ENABLE_PROMPT_CACHING_1H`, and `FORCE_PROMPT_CACHING_5M`. Switching from included usage to credits can shorten the effective lifetime. These are per-request controls, not a universal one-hour guarantee. Each assistant transcript record's `usage.cache_creation` splits cache writes into `ephemeral_1h_input_tokens` and `ephemeral_5m_input_tokens`, so Cachetoast reads the TTL a session actually gets from its latest write rather than from settings or billing. See [Claude Code prompt caching](https://code.claude.com/docs/en/prompt-caching).
|
|
14
18
|
|
|
19
|
+
Claude Code 2.1.299 added [idle compaction](https://code.claude.com/docs/en/settings-reference#idlecompaction) (settings exist from 2.1.290): in an interactive terminal session on the one-hour TTL, with feature flags reachable, a conversation of at least 200K tokens is compacted after about 54 idle minutes, and the conversation shows `Compacted while idle, before the prompt cache expired`. `CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS` changes the minimum (values below 100K are raised to 100K); `idleCompaction: false` or `DISABLE_AUTO_COMPACT` turns it off. It does not cover the five-minute TTL, the desktop app, or 100K–200K sessions at its default. Cachetoast sets the minimum to its own (at least 100K) and keeps its mod for the rest. The docs do not say whether this compaction reaches a mod's `session.compact` hook, so Cachetoast instead treats a compaction summary after the latest usage record as an unmeasured context: no hold until the next response reports a size.
|
|
20
|
+
|
|
15
21
|
Anthropic’s API supports five-minute and one-hour cache writes, priced at 1.25× and 2× ordinary input respectively. Reads are usually 0.1× with model-specific exceptions. Hits refresh the TTL. This is API pricing, not a formula for subscription quota. See [Anthropic prompt caching](https://platform.claude.com/docs/en/build-with-claude/prompt-caching).
|
|
16
22
|
|
|
17
23
|
Claude plan usage is shared across Claude and Claude Code. API-key authentication can use separate API billing. Exact subscription allowance savings should not be inferred from API-dollar estimates. See [Claude Pro/Max usage and billing](https://support.claude.com/en/articles/11145838-use-claude-code-with-your-pro-or-max-plan).
|
|
18
24
|
|
|
19
25
|
## OpenAI API versus Codex quota/credits
|
|
20
26
|
|
|
21
|
-
Current API docs distinguish model generations. GPT-5.6 and later
|
|
27
|
+
Current API docs distinguish model generations. GPT-5.6 and later set the lifetime with `prompt_cache_options.ttl`: `30m` (default and minimum, writes at 1.25× uncached input) or, since October 20, 2026, `12h` (Responses API only, writes at 2.5×); `prompt_cache_breakpoint.ttl` overrides one breakpoint. Reads are 0.1× on most models and 0.05× on GPT-6.1 Sol. Codex CLI 0.163.0 sends neither `prompt_cache_options` nor `prompt_cache_retention` (the strings appear only in its bundled migration notes), so API-key Codex gets the 30-minute default. Older models use `prompt_cache_retention`: `in_memory` entries commonly last 5–10 idle minutes, up to an hour; `24h` extended retention can last a day and is the default for organizations without Zero Data Retention where both are supported. GPT-5.5 supports only `24h`. Prefix changes and routing also affect hits. A single “30-minute Codex cache” rule cannot be applied to every model and billing mode. Codex transcripts record no TTL (0.163 adds `cache_write_input_tokens` but no lifetime), so Cachetoast supports GPT-5.6 and later only and assumes their 30-minute minimum. On October 20, 2026, Codex lead Tibo Sottiaux posted on X that signed-in Codex and Work users keep their cache indefinitely. Neither the Codex nor the API docs say so, and the API docs state cache entries are not stored indefinitely, so Cachetoast still assumes 30m for Codex. Users who rely on the claim can set `--codex-cache-ttl 12h` or `24h`. See [OpenAI prompt caching](https://developers.openai.com/api/docs/guides/prompt-caching).
|
|
22
28
|
|
|
23
29
|
Codex credit billing explicitly has no separate cache-write charge; API-key usage follows API pricing. Cached and uncached input have distinct credit rates. Included subscription usage is not determined solely by credit prices. Consequently “rehydration is free/irrelevant on subscriptions” does not follow. No public source reviewed establishes a universal exact quota weighting or Codex-client retention contract. See [Codex pricing and limits](https://learn.chatgpt.com/docs/pricing).
|
|
24
30
|
|
|
@@ -34,11 +40,17 @@ A universal “shorter sessions always perform better” or “250K is optimal
|
|
|
34
40
|
|
|
35
41
|
## Available hooks
|
|
36
42
|
|
|
37
|
-
Both
|
|
43
|
+
Both clients expose command hooks including `UserPromptSubmit`, `Stop`, and compaction events. `UserPromptSubmit` supports blocking before the model processes the prompt. Cachetoast holds the first continuation after the idle deadline once (`decision: "block"`) when native compaction has not succeeded; sending it again proceeds. Codex's CLI adds a submitted prompt to message history before hooks run, so Up-arrow recalls a held prompt. Handoff extraction is deterministic and does not modify provider transcripts.
|
|
44
|
+
|
|
45
|
+
Claude's newer [mods API](https://code.claude.com/docs/en/plugins/mods/reference) exposes `$.clock.after()` and `$.session.compact()`. The latter uses native semantic compaction between turns, rejects while a turn runs, and returns a skip reason when vetoed. Cachetoast installs a bundled module under `.claude/skills/cachetoast-idle`; [project plugins](https://code.claude.com/docs/en/plugins/loading#plugins-shared-through-a-repository) load after trust, from the primary working directory (desktop worktrees may load the main checkout's plugin). Launch from the repo root. Mods require terminal v2.1.287+ or desktop v2.1.286+ per the [mod creation docs](https://code.claude.com/docs/en/plugins/mods/create). The module uses the host process API to read/write Cachetoast state through its copied Node runtime, without invoking another agent or editing the transcript itself.
|
|
46
|
+
|
|
47
|
+
Claude (alongside Claude Code's own idle compaction, which fires about a minute earlier on one-hour terminal sessions) and Pi attempt compaction at TTL minus up to five minutes: 5m → 4m, 30m → 25m, 1h → 55m. New activity cancels the timer. Each idle cycle has at most one attempt; success suppresses the fallback for that cycle. Suspended timers waking at or beyond expiry skip compaction. Native compaction has model cost and cache hits remain uncertain.
|
|
48
|
+
|
|
49
|
+
Codex's [app-server compaction API](https://learn.chatgpt.com/docs/app-server) exposes `thread/compact/start` to an owning connection. This does not establish a portable repo-hook connection to an arbitrary running CLI or desktop session. Cachetoast keeps Codex's threshold and handoff fallback, and installs no private-socket, UI, or second-process transcript workaround.
|
|
38
50
|
|
|
39
51
|
Codex loads trusted repo hooks from `.codex/hooks.json`; new or changed hook definitions require review in `/hooks`. Hooks run with the session cwd, which may be a subdirectory. See [Codex hooks](https://learn.chatgpt.com/docs/hooks).
|
|
40
52
|
|
|
41
|
-
Claude’s `UserPromptSubmit` hooks fail open on timeout or non-zero exit (other than exit 2), and `Stop` does not fire on user interrupt. Cachetoast
|
|
53
|
+
Claude’s `UserPromptSubmit` hooks fail open on timeout or non-zero exit (other than exit 2), and `Stop` does not fire on user interrupt. Cachetoast fails open on its own errors too, and reads response timestamps from the transcript to cover interrupted turns. Command hooks use `UserPromptSubmit` and `Stop`; the native mod also handles startup, turn start, compaction, and shutdown. See [Claude hooks](https://code.claude.com/docs/en/hooks).
|
|
42
54
|
|
|
43
55
|
## Pi
|
|
44
56
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createIdleCompactor } from './idle.mjs';
|
|
2
|
+
|
|
3
|
+
// The installer bundles this module into a repo-local Claude Code plugin.
|
|
4
|
+
// No Node globals: all host access goes through Claude's portable mod API.
|
|
5
|
+
let controller, root, sessionId;
|
|
6
|
+
function warn($, error) { void Promise.resolve($.ui.log(`Cachetoast idle compaction skipped: ${error.message}. The handoff remains available.`)).catch(() => {}); }
|
|
7
|
+
async function command($, input, target = { root, sessionId }) {
|
|
8
|
+
const result = await $.process.run(['node', `${target.root}/.cachetoast/runtime/bin/cachetoast.mjs`, 'idle', '--provider', 'claude', '--repo', target.root], {
|
|
9
|
+
cwd: target.root, stdin: JSON.stringify({ session_id: target.sessionId, ...input }), timeoutMs: 10_000
|
|
10
|
+
});
|
|
11
|
+
if (result.exitCode !== 0) throw new Error(result.stderr || 'Cannot read compaction state');
|
|
12
|
+
return JSON.parse(result.stdout);
|
|
13
|
+
}
|
|
14
|
+
async function connect($) {
|
|
15
|
+
const id = await $.session.id();
|
|
16
|
+
let dir = await $.session.cwd();
|
|
17
|
+
while (!await $.fs.exists(`${dir}/.cachetoast/config.json`)) {
|
|
18
|
+
const parent = dir.slice(0, dir.lastIndexOf('/')) || '/';
|
|
19
|
+
if (parent === dir) return false;
|
|
20
|
+
dir = parent;
|
|
21
|
+
}
|
|
22
|
+
if (controller && (sessionId !== id || root !== dir)) { controller.cancel(); controller = undefined; }
|
|
23
|
+
root = dir; sessionId = id;
|
|
24
|
+
const target = { root, sessionId };
|
|
25
|
+
controller ??= createIdleCompactor({
|
|
26
|
+
read: () => command($, {}, target),
|
|
27
|
+
record: (s, status, result) => command($, { activityId: s.activityId, lastActivity: s.lastActivity, status, skip: result?.skip, tokensAfter: result?.tokensAfter }, target),
|
|
28
|
+
compact: () => $.session.compact(),
|
|
29
|
+
now: () => $.clock.now(), after: (ms, fn) => $.clock.after(ms, fn), onError: error => warn($, error)
|
|
30
|
+
});
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
export function register(on) {
|
|
34
|
+
on('classic.Stop', async ($, e, next) => {
|
|
35
|
+
const result = await next(e);
|
|
36
|
+
if (!e.agent_id && !controller?.isRunning()) {
|
|
37
|
+
try { if (await connect($)) await controller.arm(); } catch (error) { warn($, error); }
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
});
|
|
41
|
+
on('classic.SessionStart', async ($, e, next) => {
|
|
42
|
+
controller?.cancel();
|
|
43
|
+
const result = await next(e);
|
|
44
|
+
try { if (await connect($)) await controller.arm(); } catch (error) { warn($, error); }
|
|
45
|
+
return result;
|
|
46
|
+
});
|
|
47
|
+
on('classic.UserPromptSubmit', ($, e, next) => { controller?.cancel(); return next(e); });
|
|
48
|
+
on('turn.start', ($, e, next) => { controller?.cancel(); return next(e); });
|
|
49
|
+
on('session.end', ($, e, next) => { controller?.cancel(); return next(e); });
|
|
50
|
+
// Manual/threshold compaction also satisfies this idle cycle; precompute does not install a summary.
|
|
51
|
+
on('session.compact', async ($, e, next) => {
|
|
52
|
+
if (e.agentId || e.trigger === 'precompute') return next(e);
|
|
53
|
+
if (controller?.isRunning()) return next(e);
|
|
54
|
+
controller?.cancel();
|
|
55
|
+
let state;
|
|
56
|
+
try { if (await connect($)) state = await command($, {}); } catch (error) { warn($, error); }
|
|
57
|
+
const target = { root, sessionId };
|
|
58
|
+
const result = await next(e);
|
|
59
|
+
if (state && !result.skip) {
|
|
60
|
+
try { await command($, { activityId: state.activityId, lastActivity: state.lastActivity, status: 'succeeded', tokensAfter: result.tokensAfter }, target); }
|
|
61
|
+
catch (error) { warn($, error); }
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
});
|
|
65
|
+
}
|
package/lib/idle.mjs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Host-neutral, single-shot timer. Hosts supply their own clock and native compaction API.
|
|
2
|
+
// Bundled into Claude's sandboxed module as well as imported by Pi.
|
|
3
|
+
export function createIdleCompactor({ read, record, compact, now, after, onError }) {
|
|
4
|
+
let timer, revision = 0, running = false;
|
|
5
|
+
const cancel = () => { revision++; timer?.cancel(); timer = undefined; };
|
|
6
|
+
const eligible = s => s && s.lastActivity != null && Number.isFinite(s.contextTokens)
|
|
7
|
+
&& s.contextTokens >= s.p.minContextTokens && s.compaction?.activityId !== s.activityId;
|
|
8
|
+
const arm = async () => {
|
|
9
|
+
cancel();
|
|
10
|
+
const ticket = revision;
|
|
11
|
+
try {
|
|
12
|
+
const s = await read(), time = await now();
|
|
13
|
+
if (ticket !== revision || running || !eligible(s) || time >= s.lastActivity + s.p.ttlMs) return;
|
|
14
|
+
timer = after(Math.max(0, s.lastActivity + s.p.idleMs - time), async () => {
|
|
15
|
+
timer = undefined;
|
|
16
|
+
try {
|
|
17
|
+
const current = await read(), firedAt = await now();
|
|
18
|
+
if (ticket !== revision || running || !eligible(current) || current.activityId !== s.activityId
|
|
19
|
+
|| current.lastActivity !== s.lastActivity || firedAt < current.lastActivity + current.p.idleMs
|
|
20
|
+
|| firedAt >= current.lastActivity + current.p.ttlMs) return;
|
|
21
|
+
if (!await record(s, 'attempting') || ticket !== revision) return;
|
|
22
|
+
running = true;
|
|
23
|
+
try {
|
|
24
|
+
const result = await compact();
|
|
25
|
+
await record(s, result?.skip ? 'skipped' : 'succeeded', result);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
await record(s, 'failed', { skip: error.message });
|
|
28
|
+
throw error;
|
|
29
|
+
} finally { running = false; }
|
|
30
|
+
} catch (error) { onError(error); }
|
|
31
|
+
});
|
|
32
|
+
} catch (error) { onError(error); }
|
|
33
|
+
};
|
|
34
|
+
return { arm, cancel, isRunning: () => running };
|
|
35
|
+
}
|
package/lib/install.mjs
CHANGED
|
@@ -97,10 +97,22 @@ export function install(root, options = {}) {
|
|
|
97
97
|
const prior = settings.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW;
|
|
98
98
|
if (prior != null && !/^\d+$/.test(String(prior))) throw new Error('Existing Claude compact window must be a plain integer');
|
|
99
99
|
settings.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = String(Math.min(prior == null ? target : Number(prior), target));
|
|
100
|
+
// Claude Code's own idle compaction (1h TTL, terminal) starts at 200K; lower it to this policy's minimum
|
|
101
|
+
// (Claude's floor is 100K) so it covers the same sessions. The mod still covers 5m TTLs and other clients.
|
|
102
|
+
const priorIdle = settings.env.CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS, idleMin = Math.max(100_000, config.minContextTokens);
|
|
103
|
+
if (priorIdle != null && !/^\d+$/.test(String(priorIdle))) throw new Error('Existing Claude idle compaction minimum must be a plain integer');
|
|
104
|
+
settings.env.CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS = String(Math.min(priorIdle == null ? idleMin : Number(priorIdle), idleMin));
|
|
100
105
|
// The bootstrap explicitly enables this compaction policy in the repo.
|
|
101
106
|
settings.autoCompactEnabled = true;
|
|
102
107
|
for (const flag of ['DISABLE_AUTO_COMPACT', 'DISABLE_COMPACT']) if (settings.env[flag] === '1') settings.env[flag] = '0';
|
|
103
108
|
set('.claude/settings.json', JSON.stringify(mergeHooks(settings, 'claude'), null, 2) + '\n');
|
|
109
|
+
// Claude discovers trusted project plugins here. Relative paths survive moves and clones.
|
|
110
|
+
const plugin = '.claude/skills/cachetoast-idle';
|
|
111
|
+
set(`${plugin}/.claude-plugin/plugin.json`, JSON.stringify({ name: 'cachetoast-idle', version: '0.1.2', author: { name: 'R. Hunter Harris' }, description: 'Compact large idle sessions before estimated cache expiry' }, null, 2) + '\n');
|
|
112
|
+
set(`${plugin}/hooks/hooks.json`, JSON.stringify({ modules: ['./register.mjs'] }, null, 2) + '\n');
|
|
113
|
+
const idle = readText(path.join(PACKAGE_ROOT, 'lib/idle.mjs')).replace(/^export /gm, '');
|
|
114
|
+
const adapter = readText(path.join(PACKAGE_ROOT, 'lib/claude-mod.mjs')).replace(/^import .*\n/, '');
|
|
115
|
+
set(`${plugin}/hooks/register.mjs`, `// Managed by Cachetoast; bundled for Claude's mod sandbox.\n${idle}\n${adapter}`);
|
|
104
116
|
// Arguments are prompt data, never interpolated into an injected shell command.
|
|
105
117
|
set('.claude/commands/cachetoast-handoff.md', `---\ndescription: Continue a Cachetoast-held session by its key (latest if omitted)\nargument-hint: "[session-key]"\nallowed-tools: Bash(node ${SCRIPT} handoff:*)\n---\n<!-- Managed by Cachetoast; edits are overwritten by init. -->\n${handoffInstructions('claude', 'Requested session key: $ARGUMENTS')}`);
|
|
106
118
|
}
|
|
@@ -115,7 +127,7 @@ export function install(root, options = {}) {
|
|
|
115
127
|
set('.pi/extensions/cachetoast.ts', '// Managed by Cachetoast; edits are overwritten by init.\nexport { default } from "../../.cachetoast/runtime/lib/pi-extension.mjs";\n');
|
|
116
128
|
}
|
|
117
129
|
set('.cachetoast/config.json', JSON.stringify(config, null, 2) + '\n');
|
|
118
|
-
set('.cachetoast/README.md', '# Cachetoast\n\nThe runtime, config.json, and provider hook settings can be committed. local.json (optional per-repo cache TTL overrides), state, handoffs, and the manifest stay local: handoffs contain conversation excerpts.\n\
|
|
130
|
+
set('.cachetoast/README.md', '# Cachetoast\n\nThe runtime, config.json, and provider hook settings can be committed. local.json (optional per-repo cache TTL overrides), state, handoffs, and the manifest stay local: handoffs contain conversation excerpts.\n\nLarge idle Claude Code and Pi sessions get one native compaction attempt before estimated expiry: 4m for a 5m TTL, 25m for 30m, 55m for 1h. Claude uses the trusted repo plugin in .claude/skills/cachetoast-idle (launch from the repo root; requires mods support); Pi uses its extension. Keep the agent running and awake. Native compaction uses the agent’s model and normal billing. Claude Code 2.1.299+ also compacts idle 1h terminal sessions itself (about 54 minutes); its minimum is set to match. Codex has no portable repo-hook compaction trigger. If compaction cannot run or fails, the first prompt after the idle deadline is held once with a fresh-session handoff (in a new session, copy `/cachetoast-handoff KEY` in Claude Code or `$cachetoast-handoff KEY` in Codex from the notice; in Pi, `/handoff` opens one); sending it again continues. The key selects the held session even when others have been held since. Omitting it loads the latest handoff for that provider. Handoffs are local extracts, not model summaries. Cache TTLs are read from Claude Code sessions, 30m for Codex, and guessed from the Pi model; override one with `node .cachetoast/runtime/bin/cachetoast.mjs init --pi-cache-ttl 1h` (saved in ~/.config/cachetoast/config.json). Inspect with `status` and `handoff --key KEY`.\n\nInspired in part by [Antiburn](https://antiburn.com/) and its [open source work](https://github.com/antiburn/antiburn).\n');
|
|
119
131
|
const ignore = readText(safePath(root, '.gitignore'));
|
|
120
132
|
const ignores = ['state/', 'handoffs/', 'manifest.json', 'local.json'].map(p => `.cachetoast/${p}`);
|
|
121
133
|
const missing = ignores.filter(p => !ignore.split('\n').includes(p));
|
package/lib/pi-extension.mjs
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// Runs in-process: same policy, state, and handoffs as the Claude/Codex hooks.
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import { handleHook, handoffFor, handoffName, configAt, policyFor, statePath } from './runtime.mjs';
|
|
5
|
+
import { handleHook, handoffFor, handoffName, configAt, policyFor, statePath, idleState, recordIdle } from './runtime.mjs';
|
|
6
|
+
import { createIdleCompactor } from './idle.mjs';
|
|
6
7
|
import { statusText } from './policy.mjs';
|
|
7
8
|
import { readJson, safePath, keyFor } from './storage.mjs';
|
|
8
9
|
|
|
@@ -24,7 +25,7 @@ const ttlGuess = ({ model } = {}) => model?.provider !== 'anthropic' && !/claude
|
|
|
24
25
|
: model.provider === 'anthropic' && process.env.PI_CACHE_RETENTION === 'long' ? '1h' : '5m';
|
|
25
26
|
|
|
26
27
|
export default function cachetoast(pi) {
|
|
27
|
-
let lastAssistant = '', compacting = false, timer = null;
|
|
28
|
+
let lastAssistant = '', compacting = false, compacted = false, timer = null, idle = null;
|
|
28
29
|
// Fail open: any error becomes a warning and Pi continues.
|
|
29
30
|
const guarded = (ctx, action) => {
|
|
30
31
|
try {
|
|
@@ -37,6 +38,40 @@ export default function cachetoast(pi) {
|
|
|
37
38
|
session_name: ctx.sessionManager.getSessionName?.(),
|
|
38
39
|
context_tokens: ctx.getContextUsage()?.tokens ?? undefined, cache_ttl_guess: ttlGuess(ctx), ...extra
|
|
39
40
|
});
|
|
41
|
+
const compact = ctx => new Promise((resolve, reject) => {
|
|
42
|
+
if (!ctx.isIdle() || ctx.hasPendingMessages?.() || compacting) return resolve({ skip: 'Session is busy' });
|
|
43
|
+
compacting = true;
|
|
44
|
+
try {
|
|
45
|
+
ctx.compact({
|
|
46
|
+
onComplete: () => {
|
|
47
|
+
compacting = false; compacted = true;
|
|
48
|
+
// Usage can be unavailable immediately after native compaction; success still stands.
|
|
49
|
+
let tokensAfter;
|
|
50
|
+
try { tokensAfter = ctx.getContextUsage()?.tokens; } catch {}
|
|
51
|
+
resolve({ tokensAfter });
|
|
52
|
+
},
|
|
53
|
+
onError: error => { compacting = false; reject(error); }
|
|
54
|
+
});
|
|
55
|
+
} catch (error) { compacting = false; reject(error); }
|
|
56
|
+
});
|
|
57
|
+
const arm = ctx => {
|
|
58
|
+
idle?.cancel();
|
|
59
|
+
guarded(ctx, root => {
|
|
60
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
61
|
+
idle = createIdleCompactor({
|
|
62
|
+
read: () => {
|
|
63
|
+
const state = idleState(root, 'pi', sessionId);
|
|
64
|
+
if (!state || !ctx.isIdle() || ctx.hasPendingMessages?.()) return null;
|
|
65
|
+
return { ...state, contextTokens: ctx.getContextUsage()?.tokens, p: policyFor(configAt(root), 'pi', { ...state, guessedTtl: ttlGuess(ctx) }) };
|
|
66
|
+
},
|
|
67
|
+
record: (s, status, result) => recordIdle(root, 'pi', sessionId, { activityId: s.activityId, lastActivity: s.lastActivity, status, ...result }),
|
|
68
|
+
compact: () => compact(ctx), now: Date.now,
|
|
69
|
+
after: (ms, fn) => { const timeout = setTimeout(fn, ms); timeout.unref?.(); return { cancel: () => clearTimeout(timeout) }; },
|
|
70
|
+
onError: error => ctx.ui.notify(`Cachetoast idle compaction skipped: ${error.message}. The handoff remains available.`, 'warning')
|
|
71
|
+
});
|
|
72
|
+
void idle.arm();
|
|
73
|
+
});
|
|
74
|
+
};
|
|
40
75
|
|
|
41
76
|
// Footer status, visible before the next prompt; a timer flips it to cold while idle. Errors just clear it.
|
|
42
77
|
const status = ctx => {
|
|
@@ -48,13 +83,17 @@ export default function cachetoast(pi) {
|
|
|
48
83
|
} catch { try { ctx.ui.setStatus('cachetoast', undefined); } catch {} }
|
|
49
84
|
};
|
|
50
85
|
pi.on('session_start', (_event, ctx) => {
|
|
86
|
+
compacted = false;
|
|
51
87
|
clearInterval(timer);
|
|
52
88
|
timer = setInterval(() => status(ctx), 30_000);
|
|
53
89
|
timer.unref?.();
|
|
54
90
|
status(ctx);
|
|
91
|
+
arm(ctx);
|
|
55
92
|
});
|
|
56
|
-
pi.on('session_shutdown', () => { clearInterval(timer); timer = null; });
|
|
93
|
+
pi.on('session_shutdown', () => { clearInterval(timer); timer = null; idle?.cancel(); });
|
|
94
|
+
pi.on('agent_start', () => { idle?.cancel(); compacted = false; });
|
|
57
95
|
pi.on('input', (event, ctx) => {
|
|
96
|
+
idle?.cancel();
|
|
58
97
|
// Only prompts that start a new run after idle; steering and queued follow-ups do not.
|
|
59
98
|
if (event.source === 'extension' || event.streamingBehavior) return { action: 'continue' };
|
|
60
99
|
const out = guarded(ctx, root => hook(root, ctx, 'UserPromptSubmit', { prompt: event.text }));
|
|
@@ -65,6 +104,7 @@ export default function cachetoast(pi) {
|
|
|
65
104
|
return { action: 'handled' };
|
|
66
105
|
}
|
|
67
106
|
ctx.ui.setStatus('cachetoast', undefined);
|
|
107
|
+
compacted = false;
|
|
68
108
|
return { action: 'continue' };
|
|
69
109
|
});
|
|
70
110
|
pi.on('agent_end', event => {
|
|
@@ -72,14 +112,16 @@ export default function cachetoast(pi) {
|
|
|
72
112
|
if (last) lastAssistant = messageText(last);
|
|
73
113
|
});
|
|
74
114
|
pi.on('agent_settled', (_event, ctx) => guarded(ctx, root => {
|
|
115
|
+
if (compacting || compacted || idle?.isRunning()) return;
|
|
75
116
|
hook(root, ctx, 'Stop', { last_assistant_message: lastAssistant });
|
|
76
117
|
// Same context ceiling as Claude/Codex. Pi's compact() aborts a running turn, so only compact once idle.
|
|
77
118
|
const tokens = ctx.getContextUsage()?.tokens;
|
|
78
119
|
if (!compacting && ctx.isIdle() && Number.isFinite(tokens) && tokens >= configAt(root).targetTokens) {
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
ctx.
|
|
82
|
-
|
|
120
|
+
idle?.cancel();
|
|
121
|
+
const state = idleState(root, 'pi', ctx.sessionManager.getSessionId());
|
|
122
|
+
void compact(ctx).then(result => recordIdle(root, 'pi', state.sessionId, { ...state, status: result.skip ? 'skipped' : 'succeeded', ...result }))
|
|
123
|
+
.catch(error => ctx.ui.notify(`Cachetoast compaction failed: ${error.message}`, 'warning'));
|
|
124
|
+
} else arm(ctx);
|
|
83
125
|
status(ctx);
|
|
84
126
|
}));
|
|
85
127
|
pi.registerCommand('handoff', {
|
package/lib/policy.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const DEFAULT_TARGET = 250_000;
|
|
2
2
|
export const DEFAULT_MIN_CONTEXT = 100_000;
|
|
3
|
-
export const CACHE_TTLS = Object.freeze({ '5m': 300_000, '30m': 1_800_000, '1h': 3_600_000, '24h': 86_400_000 });
|
|
3
|
+
export const CACHE_TTLS = Object.freeze({ '5m': 300_000, '30m': 1_800_000, '1h': 3_600_000, '12h': 43_200_000, '24h': 86_400_000 });
|
|
4
4
|
|
|
5
5
|
export function positive(value, name) {
|
|
6
6
|
if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`);
|
|
@@ -29,7 +29,8 @@ export function policy(options = {}) {
|
|
|
29
29
|
export function evaluate(session, p = policy(), now = Date.now()) {
|
|
30
30
|
const idleForMs = session.lastActivity == null ? null : Math.max(0, now - session.lastActivity);
|
|
31
31
|
const large = Number.isFinite(session.contextTokens) && session.contextTokens >= p.minContextTokens;
|
|
32
|
-
const
|
|
32
|
+
const compacted = session.compaction?.status === 'succeeded' && session.compaction.activityId === session.activityId;
|
|
33
|
+
const due = !compacted && large && idleForMs !== null && idleForMs >= p.idleMs;
|
|
33
34
|
return {
|
|
34
35
|
action: due ? 'handoff' : 'wait',
|
|
35
36
|
idleForMs,
|
|
@@ -40,6 +41,7 @@ export function evaluate(session, p = policy(), now = Date.now()) {
|
|
|
40
41
|
|
|
41
42
|
// Short status for the Pi footer, shown before the next prompt. Null when the session is small or unmeasured.
|
|
42
43
|
export function statusText(session, p = policy(), now = Date.now()) {
|
|
44
|
+
if (session.compaction?.status === 'succeeded' && session.compaction.activityId === session.activityId) return 'idle context compacted';
|
|
43
45
|
if (!Number.isFinite(session.contextTokens) || session.contextTokens < p.minContextTokens || session.lastActivity == null) return null;
|
|
44
46
|
const size = `${Math.round(session.contextTokens / 1000)}k`, left = p.idleMs - (now - session.lastActivity);
|
|
45
47
|
return left > 0 ? `cache warm ${Math.ceil(left / 60_000)}m · ${size}` : `cache cold · ${size}`;
|
package/lib/runtime.mjs
CHANGED
|
@@ -75,9 +75,14 @@ function refreshEvidence(state, input, provider) {
|
|
|
75
75
|
state.warnings = evidence.warnings;
|
|
76
76
|
// Only a fresh measurement counts: an unreadable or truncated tail must not warn on stale numbers.
|
|
77
77
|
// In-process adapters (Pi) report context size directly.
|
|
78
|
-
|
|
78
|
+
const compacted = state.compaction?.status === 'succeeded' && state.compaction.activityId === state.activityId
|
|
79
|
+
&& (evidence.lastActivity == null || evidence.lastActivity <= state.compaction.at);
|
|
80
|
+
state.contextTokens = Number.isFinite(input.context_tokens) ? input.context_tokens : compacted ? state.contextTokens : evidence.contextTokens;
|
|
79
81
|
// Transcript response times cover interrupted turns, where Stop does not fire.
|
|
80
|
-
if (evidence.lastActivity !== null
|
|
82
|
+
if (!compacted && evidence.lastActivity !== null && (state.lastActivity == null || evidence.lastActivity > state.lastActivity)) {
|
|
83
|
+
state.lastActivity = evidence.lastActivity;
|
|
84
|
+
state.activityId = (state.activityId ?? 0) + 1;
|
|
85
|
+
}
|
|
81
86
|
if (input.last_assistant_message) state.evidence.assistant = terse(input.last_assistant_message, 2400);
|
|
82
87
|
if (evidence.cacheTtl) state.observedTtl = evidence.cacheTtl;
|
|
83
88
|
// Pi sends a guess with every event (none means the default), so a model switch replaces it.
|
|
@@ -98,10 +103,10 @@ export function handleHook(root, provider, input, { now = Date.now() } = {}) {
|
|
|
98
103
|
if (event === 'Stop') {
|
|
99
104
|
// Stop fires right after a model response, which refreshes the provider cache.
|
|
100
105
|
state.lastActivity = Math.max(state.lastActivity ?? 0, now);
|
|
106
|
+
state.activityId = (state.activityId ?? 0) + 1;
|
|
101
107
|
} else {
|
|
102
108
|
const result = evaluate(state, policyFor(c, provider, state), now);
|
|
103
|
-
//
|
|
104
|
-
// that is the last moment the cost can be avoided. Sending it again goes through.
|
|
109
|
+
// If native compaction did not succeed, hold the first continuation after the idle deadline once.
|
|
105
110
|
const held = state.heldAt != null && state.heldAt > (state.lastActivity ?? 0);
|
|
106
111
|
if (result.action === 'handoff' && !held) {
|
|
107
112
|
atomicWrite(root, `.cachetoast/handoffs/${key}.md`, handoffPrompt(root, state, input.prompt) + '\n');
|
|
@@ -120,7 +125,27 @@ export function handleHook(root, provider, input, { now = Date.now() } = {}) {
|
|
|
120
125
|
state.objective ||= state.latestUser;
|
|
121
126
|
// The prompt is sent to the model now, so the cache clock restarts.
|
|
122
127
|
state.lastActivity = now;
|
|
128
|
+
state.activityId = (state.activityId ?? 0) + 1;
|
|
123
129
|
}
|
|
124
130
|
atomicWrite(root, statePath(key), state);
|
|
125
131
|
return {};
|
|
126
132
|
}
|
|
133
|
+
|
|
134
|
+
// Shared with native in-process adapters; this never calls a model or edits its transcript.
|
|
135
|
+
export function idleState(root, provider, sessionId) {
|
|
136
|
+
if (!PROVIDERS.includes(provider) || !sessionId || typeof sessionId !== 'string') throw new Error('Invalid idle session');
|
|
137
|
+
const state = readJson(safePath(root, statePath(keyFor(provider, sessionId))));
|
|
138
|
+
return state ? { ...state, activityId: state.activityId ?? 0, p: policyFor(configAt(root), provider, state) } : null;
|
|
139
|
+
}
|
|
140
|
+
export function recordIdle(root, provider, sessionId, input, { now = Date.now() } = {}) {
|
|
141
|
+
const state = idleState(root, provider, sessionId);
|
|
142
|
+
if (!state || input.activityId !== state.activityId || input.lastActivity !== state.lastActivity) return false;
|
|
143
|
+
if (!['attempting', 'succeeded', 'skipped', 'failed'].includes(input.status)) throw new Error('Invalid compaction status');
|
|
144
|
+
if (input.status === 'attempting' && state.compaction?.activityId === state.activityId) return false;
|
|
145
|
+
delete state.p;
|
|
146
|
+
state.compaction = { activityId: state.activityId, status: input.status, at: now };
|
|
147
|
+
if (input.skip) state.compaction.reason = terse(input.skip, 300);
|
|
148
|
+
if (input.status === 'succeeded') state.contextTokens = Number.isFinite(input.tokensAfter) ? input.tokensAfter : null;
|
|
149
|
+
atomicWrite(root, statePath(state.key), state);
|
|
150
|
+
return true;
|
|
151
|
+
}
|
package/lib/transcript.mjs
CHANGED
|
@@ -59,6 +59,9 @@ export function readTranscript(file, provider) {
|
|
|
59
59
|
if (assistant) result.assistant = terse(assistant, 2400);
|
|
60
60
|
if (summary) result.summary = terse(summary, 3000);
|
|
61
61
|
if (Number.isFinite(tokens) && tokens >= 0) result.contextTokens = tokens;
|
|
62
|
+
// A summary after the latest usage means the context was replaced, e.g. by Claude Code's own idle
|
|
63
|
+
// compaction, which no hook reports. The old size no longer applies until the next response measures it.
|
|
64
|
+
else if (summary) result.contextTokens = null;
|
|
62
65
|
const ts = Date.parse(r.timestamp);
|
|
63
66
|
if (activity && Number.isFinite(ts)) result.lastActivity = Math.max(result.lastActivity ?? 0, ts);
|
|
64
67
|
}
|