llm-switcher 1.4.1 → 1.6.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.
@@ -0,0 +1,284 @@
1
+ // Idle-triggered compaction.
2
+ //
3
+ // A conversation that pauses long enough loses the provider's prompt cache. The next request pays
4
+ // full price for a prefix the provider no longer holds, and it pays it again on the turn after
5
+ // that if nothing changes. How long a provider keeps its cache is its own business and differs
6
+ // per provider: Anthropic 5 minutes by default and 1 hour when the request asks for it, OpenAI 30
7
+ // minutes, Gemini and DeepSeek publish no lifetime at all. So the time is a setting, not a
8
+ // constant, and the default is the longest of those that is documented rather than the shortest,
9
+ // because compacting a cache that is still warm throws history away and pays the cache write
10
+ // again for nothing.
11
+ //
12
+ // What this does with the result is the part that matters, and it depends on the client:
13
+ //
14
+ // - The request that is on its way out is shortened, so this turn costs the tokens of the
15
+ // summary instead of the tokens of the history. That saving is real whichever client it is.
16
+ // - For Claude Code the session file is also written, so the next resume starts from the
17
+ // summary instead of from the history. Claude Code reads that file to build every request, so
18
+ // the shortening lasts rather than lasting one turn.
19
+ //
20
+ // Codex needs neither half of that from here. Its compaction is a protocol item, not a file: a
21
+ // compaction_trigger in, one compaction output item out, and Codex stores it in its own history.
22
+ // The right place to serve that is the provider gateway, which can see the item, rather than a
23
+ // client-side gateway that would have to rewrite a thread file to reach the same state.
24
+ //
25
+ // Not every turn of a long session is compacted, and that is deliberate. Compacting on every turn
26
+ // would change the prefix every turn, so no turn would ever hit the cache, and the conversation
27
+ // would be summarized over and over. Compacting once, when a long pause says the cache is gone,
28
+ // keeps the summary stable for the turns that follow and spends one extra model call.
29
+
30
+ import crypto from 'node:crypto';
31
+ import fs from 'node:fs';
32
+ import os from 'node:os';
33
+ import path from 'node:path';
34
+
35
+ export const IDLE_COMPACT_KEY = 'idleCompact';
36
+
37
+ // Defaults. The idle time is the one setting worth being wrong about: too short and a warm cache
38
+ // is thrown away, too long and the history is paid for at full price on a turn that had no cache
39
+ // to lose anyway.
40
+ const DEFAULTS = {
41
+ enabled: false,
42
+ // Codex is off by default while Claude Code is the default on: a Codex compaction writes into
43
+ // Codex's own SQLite store, and that is opt-in per install on purpose.
44
+ codex: false,
45
+ model: '',
46
+ idleMinutes: 15,
47
+ minBytes: 64 * 1024,
48
+ keepRecent: 6,
49
+ summaryMaxChars: 24000,
50
+ sessionLookbackHours: 72,
51
+ };
52
+
53
+ export function idleCompactPolicy(cfg) {
54
+ const raw = cfg?.[IDLE_COMPACT_KEY];
55
+ const p = { ...DEFAULTS, ...(raw && typeof raw === 'object' ? raw : {}) };
56
+ p.enabled = p.enabled === true;
57
+ p.codex = p.codex === true;
58
+ p.model = typeof p.model === 'string' ? p.model.trim() : '';
59
+ p.idleMinutes = Number.isFinite(+p.idleMinutes) && +p.idleMinutes > 0 ? +p.idleMinutes : DEFAULTS.idleMinutes;
60
+ p.minBytes = Number.isFinite(+p.minBytes) && +p.minBytes > 0 ? +p.minBytes : DEFAULTS.minBytes;
61
+ p.keepRecent = Number.isFinite(+p.keepRecent) && +p.keepRecent > 0 ? Math.floor(+p.keepRecent) : DEFAULTS.keepRecent;
62
+ p.summaryMaxChars = Number.isFinite(+p.summaryMaxChars) && +p.summaryMaxChars > 0
63
+ ? Math.floor(+p.summaryMaxChars) : DEFAULTS.summaryMaxChars;
64
+ p.sessionLookbackHours = Number.isFinite(+p.sessionLookbackHours) && +p.sessionLookbackHours > 0
65
+ ? +p.sessionLookbackHours : DEFAULTS.sessionLookbackHours;
66
+ p.idleMs = p.idleMinutes * 60000;
67
+ return p;
68
+ }
69
+
70
+ // ---- when a conversation was last here ----
71
+
72
+ // A gateway restart forgets every conversation, which costs one compaction and nothing else: the
73
+ // next request is treated as new, which is the safe direction to be wrong in.
74
+ const seen = new Map();
75
+
76
+ function prune(now) {
77
+ const cutoff = now - DEFAULTS.sessionLookbackHours * 3600000;
78
+ for (const [k, t] of seen) if (t < cutoff) seen.delete(k);
79
+ }
80
+
81
+ /** Records that this conversation has just been answered. */
82
+ export function noteConversation(key, at = Date.now()) {
83
+ if (!key) return;
84
+ if (seen.size > 20000) prune(at);
85
+ seen.set(key, at);
86
+ }
87
+
88
+ /** Milliseconds since this conversation last asked for something, or 0 when it is not known. */
89
+ export function idleFor(key, now = Date.now()) {
90
+ if (!key) return 0;
91
+ const t = seen.get(key);
92
+ if (!t) return 0;
93
+ return now - t;
94
+ }
95
+
96
+ // ---- which conversation this is ----
97
+
98
+ // Claude Code names its session in a header and again inside metadata.user_id. Codex names it in
99
+ // prompt_cache_key. A client that names nothing is keyed on the opening request instead, which is
100
+ // the one message a compaction does not remove, so the key survives the compaction itself.
101
+ export function conversationKey(_clientFormat, req, ir) {
102
+ const header = req?.headers?.['x-claude-code-session-id'];
103
+ if (header) return `h:${header}`;
104
+ const pck = ir?.promptCacheKey;
105
+ if (pck) return `p:${pck}`;
106
+ const uid = ir?.metadataUserId;
107
+ if (typeof uid === 'string') {
108
+ const m = /_session_([0-9a-f-]{8,})/i.exec(uid);
109
+ if (m) return `u:${m[1]}`;
110
+ if (/^[0-9a-f-]{36}$/i.test(uid)) return `u:${uid}`;
111
+ }
112
+ const first = firstUserText(ir);
113
+ if (first) return `c:${crypto.createHash('sha256').update(first).digest('hex').slice(0, 16)}`;
114
+ return '';
115
+ }
116
+
117
+ // The opening request is read from the IR rather than from the raw body, because by this point the
118
+ // body has been parsed once already and the IR is what the rest of the gateway works with.
119
+ function firstUserText(ir) {
120
+ for (const m of ir?.messages || []) {
121
+ if (m.role !== 'user' || m.role === 'tool') continue;
122
+ const t = textOf(m);
123
+ if (t) return t.slice(0, 2000);
124
+ }
125
+ return '';
126
+ }
127
+
128
+ function textOf(msg) {
129
+ if (!msg) return '';
130
+ const c = msg.content;
131
+ if (typeof c === 'string') return c;
132
+ if (!Array.isArray(c)) return '';
133
+ let out = '';
134
+ for (const part of c) {
135
+ if (typeof part === 'string') { out += part; continue; }
136
+ if (!part || typeof part !== 'object') continue;
137
+ if (part.type === 'text' || part.type === 'input_text' || part.type === 'output_text') out += part.text || '';
138
+ }
139
+ return out;
140
+ }
141
+
142
+ // ---- the history, shortened ----
143
+
144
+ // The middle keeps its words and loses its tool calls and their results, because those are most
145
+ // of the weight of a session and none of what a summary is for. The opening request and the last
146
+ // few messages are kept as they are: the opening request is what the session is for, and the last
147
+ // few messages are what the next turn reasons over.
148
+ export function compactIR(ir, policy) {
149
+ const msgs = ir.messages || [];
150
+ if (msgs.length < policy.keepRecent + 2) return null;
151
+
152
+ const head = msgs.findIndex(m => m.role === 'user' && m.role !== 'tool');
153
+ if (head < 0) return null;
154
+ let tail = msgs.length - policy.keepRecent;
155
+ if (tail <= head + 1) return null;
156
+ // A tool result whose call was cut is a request the provider refuses, so the tail never starts
157
+ // on one.
158
+ while (tail < msgs.length && isToolResult(msgs[tail])) tail++;
159
+ if (tail <= head + 1) return null;
160
+
161
+ const kept = [];
162
+ kept.push(...msgs.slice(0, head));
163
+ kept.push(msgs[head]);
164
+ const middle = [];
165
+ for (let i = head + 1; i < tail; i++) {
166
+ const m = msgs[i];
167
+ if (m.role === 'tool' || m.role === 'assistant' && m.toolCalls) continue;
168
+ const t = textOf(m).trim();
169
+ if (!t) continue;
170
+ middle.push({ role: m.role === 'assistant' ? 'assistant' : 'user', content: trimText(t, policy.userChars ?? 3000) });
171
+ }
172
+ kept.push(...middle);
173
+ kept.push(...msgs.slice(tail));
174
+ // Where a summary belongs: right after the opening request, before the text that stands in for
175
+ // the middle. That is where Claude Code puts its own, and a reader meets it as the first thing
176
+ // after being told what it is.
177
+ return {
178
+ messages: kept,
179
+ insertAt: head + 1,
180
+ middle: middle.length,
181
+ dropped: (tail - head - 1) - middle.length,
182
+ };
183
+ }
184
+
185
+ function isToolResult(msg) {
186
+ if (!msg) return false;
187
+ if (msg.role === 'tool') return true;
188
+ return Array.isArray(msg.content) && msg.content.some(p => p && (p.type === 'tool_result'));
189
+ }
190
+
191
+ function trimText(t, max) {
192
+ if (t.length <= max) return t;
193
+ const cut = t.slice(0, max);
194
+ const nl = cut.lastIndexOf('\n');
195
+ return (nl > max / 2 ? cut.slice(0, nl) : cut) + `\n[... ${t.length - max} more characters cut here ...]`;
196
+ }
197
+
198
+ // ---- the summary request ----
199
+
200
+ // The instruction names what a continuation needs, because "summarize this" produces something
201
+ // useless to the agent that has to keep working: the decisions and their reasons, the files that
202
+ // changed, the errors and their fixes, and what is still open.
203
+ export const SUMMARY_INSTRUCTION = 'Summarize this conversation so it can replace the history. ' +
204
+ 'Keep: what was asked, the decisions made and why, the files and commands that changed things, ' +
205
+ 'the errors and how they were resolved, and what is still pending. Drop: full tool output, ' +
206
+ 'intermediate reasoning, and anything already superseded. Write plain prose a continuing agent ' +
207
+ 'can act on without the original transcript.';
208
+
209
+ // The history handed to the summarizer is text only and already cut down, so the summarizer is not
210
+ // asked to read a megabyte of tool output to produce a paragraph.
211
+ export function summaryMessages(ir, policy) {
212
+ const compacted = compactIR(ir, policy) || { messages: (ir.messages || []).slice(-policy.keepRecent) };
213
+ const msgs = [{ role: 'user', content: [{ type: 'text', text: SUMMARY_INSTRUCTION }] }];
214
+ for (const m of compacted.messages) {
215
+ const t = textOf(m).trim();
216
+ if (t) msgs.push({ role: m.role === 'assistant' ? 'assistant' : 'user', content: [{ type: 'text', text: trimText(t, policy.userChars ?? 3000) }] });
217
+ }
218
+ return msgs;
219
+ }
220
+
221
+ /** What a session file records: the words of the summary, cut to the budget. */
222
+ export function clampSummary(text, policy) {
223
+ const s = String(text || '').trim();
224
+ if (s.length <= policy.summaryMaxChars) return s;
225
+ const cut = s.slice(0, policy.summaryMaxChars);
226
+ const nl = cut.lastIndexOf('\n\n');
227
+ if (nl > policy.summaryMaxChars / 2) return cut.slice(0, nl);
228
+ const dot = cut.lastIndexOf('. ');
229
+ if (dot > policy.summaryMaxChars / 2) return cut.slice(0, dot + 1);
230
+ return cut;
231
+ }
232
+ // ---- the Responses shape, where the history lives in the request ----
233
+
234
+ // Codex sends its history as an input array and reaches a Responses provider as the bytes it
235
+ // wrote, so its history is shortened here rather than through a renderer. Same rule as the
236
+ // intermediate form: the opening request and the recent turns stay as they are, the middle keeps
237
+ // its words and loses its calls and their output.
238
+ export function compactResponsesInput(input, policy) {
239
+ if (!Array.isArray(input) || input.length < policy.keepRecent + 2) return input;
240
+ const head = input.findIndex(isResponseMessage);
241
+ if (head < 0) return input;
242
+ let tail = input.length - policy.keepRecent;
243
+ if (tail <= head + 1) return input;
244
+ while (tail < input.length && isResponseResult(input[tail])) tail++;
245
+ if (tail <= head + 1) return input;
246
+
247
+ const kept = [];
248
+ kept.push(...input.slice(0, head));
249
+ kept.push(input[head]);
250
+ for (let i = head + 1; i < tail; i++) {
251
+ // A call, a result and a reasoning block are the weight of the session and none of the thread.
252
+ const t = responseText(input[i]);
253
+ if (!t) continue;
254
+ kept.push({
255
+ type: 'message',
256
+ role: input[i]?.role === 'assistant' ? 'assistant' : 'user',
257
+ content: [{ type: 'input_text', text: trimText(t, policy.userChars ?? 3000) }],
258
+ });
259
+ }
260
+ kept.push(...input.slice(tail));
261
+ return kept;
262
+ }
263
+
264
+ function isResponseMessage(item) {
265
+ return item && item.type === 'message' && item.role !== 'developer' && item.role !== 'system';
266
+ }
267
+
268
+ function isResponseResult(item) {
269
+ return !!item && (item.type === 'function_call_output' || item.type === 'custom_tool_call_output'
270
+ || item.type === 'local_shell_call_output');
271
+ }
272
+
273
+ function responseText(item) {
274
+ if (!isResponseMessage(item)) return '';
275
+ const c = item.content;
276
+ if (typeof c === 'string') return c;
277
+ if (!Array.isArray(c)) return '';
278
+ let out = '';
279
+ for (const b of c) {
280
+ if (b && typeof b === 'object' && typeof b.text === 'string' &&
281
+ ['input_text', 'output_text', 'summary_text', 'text'].includes(b.type)) out += b.text;
282
+ }
283
+ return out;
284
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-switcher",
3
- "version": "1.4.1",
3
+ "version": "1.6.0",
4
4
  "description": "Zero-dependency multi-protocol edge gateway & provider switcher for Claude Code and Codex, with OpenAI-compatible, Anthropic and Vertex upstreams",
5
5
  "keywords": [
6
6
  "llm",
package/proxy.mjs CHANGED
@@ -9,7 +9,7 @@ import { spawn } from 'node:child_process';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { createFrameReader } from './blindfold/wsframe.mjs';
11
11
  import {
12
- OUT_FORMATS, parseToIR, emitUpstreamBody, createUpstreamNormalizer, createCollector,
12
+ OUT_FORMATS, parseToIR, emitUpstreamBody, irToAnthropicBody, createUpstreamNormalizer, createCollector,
13
13
  createThinkTagSplitter, splitThinkTags, healAnthropicPayload, estimateTokens, THINKING_MODES,
14
14
  toGeminiSchema, isAntigravityModel,
15
15
  createAnthropicStream, createChatStream, createResponsesStream, createVertexStream,
@@ -32,6 +32,12 @@ import { classifyCodexRole, classifyClaudeTier, syncLocalCatalog, refreshCatalog
32
32
  import { checkForUpdate } from './version.mjs';
33
33
  import { applyUpdate } from './update.mjs';
34
34
  import { createContractLab, createHalfTap, tapClientWrites, capText, capJson, toolVersionFromUA, finishHalf, PROBE_HEADER, TRACE_ID_RE } from './contract.mjs';
35
+ import {
36
+ idleCompactPolicy, conversationKey, idleFor, noteConversation, compactIR, compactResponsesInput, IDLE_COMPACT_KEY,
37
+ } from './idlecompact.mjs';
38
+ import { askSummary } from './idlecall.mjs';
39
+ import { claudeSessionId, findSessionFile, writeCompaction } from './claudesession.mjs';
40
+ import { writeCodexCompaction } from './codexsession.mjs';
35
41
 
36
42
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
37
43
  const uiHtmlPath = path.join(__dirname, 'ui.html');
@@ -743,6 +749,124 @@ function cleanSchemaDeep(obj) {
743
749
  // 1. Strip disallowed keywords ('encrypted', '$schema', 'cache_control')
744
750
  // 2. Fix invalid schema values where a property has a string value "object" instead of a valid schema object
745
751
  // 3. For ag/* targets (Gemini behind 9Router): rewrite to the strict Schema subset
752
+ // ---- idle compaction ----
753
+
754
+ // Claude Code only. Codex is left alone on purpose: its compaction is a protocol item, which the
755
+ // provider gateway answers, and the answer is stored by Codex in its own history. Shortening a
756
+ // Codex request here would replace that with a summary no client ever records, which costs the
757
+ // tokens of the summary and saves only one turn. The Antigravity CLI has no session file of its
758
+ // own to write, so there would be nothing to make the shortening last.
759
+ async function maybeCompactIdle(req, clientFormat, payload, ir, profile, profileKey, bodyBuffer) {
760
+ const policy = idleCompactPolicy(loadConfig());
761
+ const key = conversationKey(clientFormat, req, ir);
762
+ if (!key) return null;
763
+ const wants = clientFormat === 'anthropic'
764
+ ? policy.enabled
765
+ : clientFormat === 'responses' && policy.enabled && policy.codex;
766
+ if (wants && idleFor(key) > policy.idleMs && payloadBytes(payload) >= policy.minBytes) {
767
+ const r = compactNow({ req, payload, ir, profile, profileKey, policy, key, bodyBuffer, clientFormat });
768
+ // Recorded on the way through rather than on the way out, so a request the provider then
769
+ // fails still counts as the conversation having been here. What is being measured is the
770
+ // client's pause, not the provider's answer.
771
+ noteConversation(key);
772
+ return r;
773
+ }
774
+ noteConversation(key);
775
+ return null;
776
+ }
777
+
778
+ // Measured from the parsed body rather than from Content-Length, which is absent on a chunked
779
+ // request and smaller than the body once it has been decoded.
780
+ function payloadBytes(payload) {
781
+ try { return JSON.stringify(payload).length; } catch { return 0; }
782
+ }
783
+
784
+ function compactNow({ req, payload, ir, profile, profileKey, policy, key, bodyBuffer, clientFormat }) {
785
+ const compacted = compactIR(ir, policy);
786
+ if (!compacted) return null;
787
+
788
+ const before = JSON.stringify(ir.messages || []).length;
789
+ const mapped = mapModel(ir.model || payload.model || '', profile, 'anthropic');
790
+ const outFormat = resolveOutFormat(profile, mapped);
791
+ const build = (prof, model, ir2) => buildUpstreamRequest(prof, outFormat, ir2, model, req);
792
+
793
+ // A summary is only made when the reader named a model for it, and never with the conversation's
794
+ // own: that would spend the expensive model of an official account to save the tokens of the
795
+ // turn, which is a bad trade and, on a plan with limits, worse. With no model named there is
796
+ // still a saving, because the request leaves shortened, and without a summary there is nothing to
797
+ // write to the session file -- so a model call here would cost tokens and buy nothing.
798
+ const summary = policy.model
799
+ ? askSummary({ ir, policy, profile, model: policy.model, build })
800
+ : Promise.resolve('');
801
+
802
+ // The request goes out shortened now, before the summary exists. The summary belongs in the
803
+ // next resume, not in this answer, so this turn carries the text that stands in for the middle
804
+ // and nothing else; a turn that also carried the summary would pay for it twice.
805
+ // Written back into the client's own body. Claude Code goes through the renderer this gateway
806
+ // already uses, because it is translated on the way out. Codex is not: a Responses client
807
+ // reaches a Responses provider as the bytes it sent, so its history is shortened where it
808
+ // lives, in the request itself.
809
+ const field = clientFormat === 'responses' ? 'input' : 'messages';
810
+ const rendered = clientFormat === 'responses'
811
+ ? compactResponsesInput(payload.input, policy)
812
+ : irToAnthropicBody({ ...ir, messages: compacted.messages }, ir.model || payload.model).messages;
813
+ const next = { ...payload, [field]: rendered };
814
+ const nextBuf = Buffer.from(JSON.stringify(next), 'utf8');
815
+
816
+ console.log(
817
+ `[llm-switcher] idle compact ${key.slice(0, 12)} [${profileKey}]: ${before} -> ${JSON.stringify(rendered).length} bytes, ` +
818
+ `${compacted.middle} messages kept as text, ${compacted.dropped} dropped; summary call in flight`);
819
+
820
+ // The summary is not waited for. It serves the next resume, not this answer, and holding the
821
+ // client's turn open for a call nobody reads would make the saving feel like a cost. The file is
822
+ // written when it lands, which is also later than the response and therefore never races the
823
+ // entries Claude Code is appending right now.
824
+ Promise.resolve(summary).then(async (s) => {
825
+ if (!s) {
826
+ console.log(`[llm-switcher] idle compact ${key.slice(0, 12)}: the summary call produced nothing; the request went out shortened anyway`);
827
+ return;
828
+ }
829
+ // Found by session id rather than by working directory: nothing in the request says what the
830
+ // client's directory is, and a compaction written to the wrong project is one nothing reads.
831
+ // Codex and Claude Code keep their history in different places, so each is written its own
832
+ // way: Codex in its rollout file and its store, Claude Code in its session file.
833
+ let written;
834
+ if (clientFormat === 'responses') {
835
+ written = await writeCodexCompaction({ summary: s });
836
+ } else {
837
+ const sessionId = claudeSessionId(req, payload);
838
+ const file = sessionId ? findSessionFile(sessionId) : '';
839
+ written = file
840
+ ? writeCompaction({
841
+ file, summary: s, sessionId,
842
+ preTokens: Math.round(before / 4), postTokens: Math.round(JSON.stringify(rendered).length / 4),
843
+ })
844
+ : null;
845
+ }
846
+ console.log(`[llm-switcher] idle compact ${key.slice(0, 12)}: summary ${written ? 'stored' : 'made, not stored'}`);
847
+ }).catch(() => {});
848
+
849
+ return { payload: next, bodyBuffer: nextBuf };
850
+ }
851
+
852
+ // The summary goes in as a user message right after the opening request, which is where Claude
853
+ // Code puts its own. A conversation handed a hole in it reads the hole as something it forgot
854
+ // unless something says otherwise.
855
+ function withSummary(msgs, insertAt, summary) {
856
+ const at = Math.min(Math.max(1, insertAt), msgs.length);
857
+ return [
858
+ ...msgs.slice(0, at),
859
+ { role: 'user', content: [{ type: 'text', text: summaryNotice(summary) }] },
860
+ ...msgs.slice(at),
861
+ ];
862
+ }
863
+
864
+ function summaryNotice(summary) {
865
+ return 'Earlier in this conversation the history was shortened because it had been idle long ' +
866
+ 'enough that the provider no longer held its prompt cache. What follows is a summary of the ' +
867
+ 'part that was replaced, then the more recent messages unchanged.\n\nSummary:\n' + summary;
868
+ }
869
+
746
870
  function buildUpstreamRequest(profile, outFormat, ir, mappedModel, req) {
747
871
  const upBody = emitUpstreamBody(outFormat, ir, mappedModel, { thinkingMode: profile.thinkingMode });
748
872
  if (upBody?.tools && Array.isArray(upBody.tools)) {
@@ -839,6 +963,23 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
839
963
  // Code Assist names streaming in the path (:streamGenerateContent), not in the body.
840
964
  if (clientFormat === 'codeassist') ir.stream = Boolean(opts.codeAssistStream);
841
965
 
966
+ // Compaction rewrites the request itself, not just the intermediate form: when the client and
967
+ // the provider speak the same shape this gateway forwards the client's own bytes, so a change
968
+ // made only to the parsed form would shorten nothing that leaves the building.
969
+ const shrunk = await maybeCompactIdle(req, clientFormat, payload, ir, profile, profileKey, bodyBuffer);
970
+ if (shrunk) {
971
+ payload = shrunk.payload;
972
+ bodyBuffer = shrunk.bodyBuffer;
973
+ try {
974
+ ir = parseToIR(clientFormat, payload);
975
+ } catch {
976
+ // The compacted body was built from this one, so it parses. If it ever did not, the
977
+ // original is still what the caller meant to send.
978
+ payload = JSON.parse(bodyBuffer.toString('utf8'));
979
+ return sendClientError(res, clientFormat, 400, 'Cannot reparse the compacted request');
980
+ }
981
+ }
982
+
842
983
  const reqStartTime = Date.now();
843
984
  const requestPreview = previewOf(ir);
844
985
  const requestedModel = ir.model || payload.model || '';
@@ -1575,6 +1716,42 @@ async function routeApi(req, res, method, pathname) {
1575
1716
  return sendJson(res, 200, syncLocalCatalog(STATE_DIR));
1576
1717
  }
1577
1718
 
1719
+ // Idle compaction. The settings live under one key so the dashboard and `switch compact`
1720
+ // write the same place, and the same defaults, instead of two views that can disagree.
1721
+ if (method === 'GET' && pathname === '/api/idle-compact') {
1722
+ return sendJson(res, 200, { idleCompact: idleCompactPolicy(loadConfig()) });
1723
+ }
1724
+ if (method === 'POST' && pathname === '/api/idle-compact') {
1725
+ let patch;
1726
+ try {
1727
+ patch = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8'));
1728
+ } catch (err) {
1729
+ return sendJson(res, err.status || 400, { error: err.message });
1730
+ }
1731
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
1732
+ return sendJson(res, 400, { error: 'body must be an object' });
1733
+ }
1734
+ // Per key, not per type-group. A loose check lets "yes" through for a boolean, and the
1735
+ // policy then reads it as false: a typo would switch the feature off without saying so.
1736
+ const kinds = { enabled: 'boolean', model: 'string', idleMinutes: 'number', minBytes: 'number', keepRecent: 'number' };
1737
+ for (const [k, want] of Object.entries(kinds)) {
1738
+ if (!(k in patch)) continue;
1739
+ const v = patch[k];
1740
+ const ok = want === 'number'
1741
+ ? (typeof v === 'number' && Number.isFinite(v))
1742
+ : typeof v === want;
1743
+ if (!ok) return sendJson(res, 400, { error: `${k} must be ${want === 'number' ? 'a number' : `a ${want}`}` });
1744
+ }
1745
+ const cfg = loadConfig() || {};
1746
+ cfg[IDLE_COMPACT_KEY] = { ...(cfg[IDLE_COMPACT_KEY] || {}), ...patch };
1747
+ try {
1748
+ saveConfig(cfg);
1749
+ } catch (err) {
1750
+ return sendJson(res, 500, { error: err.message });
1751
+ }
1752
+ return sendJson(res, 200, { idleCompact: idleCompactPolicy(loadConfig()) });
1753
+ }
1754
+
1578
1755
  if (method !== 'POST') {
1579
1756
  req.resume();
1580
1757
  return sendJson(res, 404, { error: `Not found: ${method} ${pathname}` });
package/switch.mjs CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  getMigrationError, getMigrationCollision, getLastLoadError,
14
14
  casClearToolPointers, emptyToolEnvFiles, syncInterceptorTools
15
15
  } from './state.mjs';
16
+ import { idleCompactPolicy } from './idlecompact.mjs';
16
17
  import { runProbe, runCheck } from './contract.mjs';
17
18
  import { installPlugin, uninstallPlugin, pluginStatus, PLUGIN_NAME } from './plugin.mjs';
18
19
  import {
@@ -845,6 +846,57 @@ async function managePlugin(action = 'status') {
845
846
  }
846
847
  }
847
848
 
849
+ // Idle compaction: a conversation that comes back after a pause has lost the provider's prompt
850
+ // cache, so its history is paid for at full price. This turns that on and says what it will do.
851
+ async function manageCompact(action = 'status', arg = '') {
852
+ const cfg = loadConfig() || {};
853
+ const policy = idleCompactPolicy(cfg);
854
+ const key = 'idleCompact';
855
+ const set = (patch) => {
856
+ cfg[key] = { ...(cfg[key] || {}), ...patch };
857
+ saveConfig(cfg);
858
+ };
859
+ const act = (action || 'status').toLowerCase();
860
+ const show = () => {
861
+ const p = idleCompactPolicy(loadConfig() || {});
862
+ console.log('Idle compaction (Claude Code)');
863
+ console.log(` Enabled: ${p.enabled ? 'yes' : 'no'}`);
864
+ console.log(` Idle over: ${p.idleMinutes} minutes`);
865
+ console.log(` Context over: ${Math.round(p.minBytes / 1024)} KB`);
866
+ console.log(` Keep recent: ${p.keepRecent} messages`);
867
+ console.log(` Summary model: ${p.model || '(none set)'}`);
868
+ if (!p.enabled) console.log('\n Turn it on with: switch compact on');
869
+ else if (!p.model) console.log('\n No summary model: requests still go out shortened, and no model is called at all.');
870
+ };
871
+
872
+ if (act === 'on' || act === 'enable') {
873
+ set({ enabled: true });
874
+ console.log('[OK] Idle compaction is on.');
875
+ show();
876
+ } else if (act === 'off' || act === 'disable') {
877
+ set({ enabled: false });
878
+ console.log('[OK] Idle compaction is off.');
879
+ } else if (act === 'model') {
880
+ const m = String(arg || '').trim();
881
+ if (!m) { console.log(`Summary model is currently: ${policy.model || '(none set)'}`); return; }
882
+ if (m === 'none' || m === 'default') { set({ model: '' }); console.log('[OK] The conversation\'s own model will write the summary.'); return; }
883
+ set({ model: m });
884
+ console.log(`[OK] Summaries will be written by: ${m}`);
885
+ } else if (act === 'idle') {
886
+ const n = Number(arg);
887
+ if (!Number.isFinite(n) || n <= 0) { console.error('Usage: switch compact idle <minutes>'); process.exit(1); }
888
+ set({ idleMinutes: Math.floor(n) });
889
+ console.log(`[OK] Compact a conversation idle over ${Math.floor(n)} minutes.`);
890
+ } else if (act === 'min') {
891
+ const n = Number(arg);
892
+ if (!Number.isFinite(n) || n <= 0) { console.error('Usage: switch compact min <KB>'); process.exit(1); }
893
+ set({ minBytes: Math.floor(n) * 1024 });
894
+ console.log(`[OK] Compact a conversation over ${Math.floor(n)} KB.`);
895
+ } else {
896
+ show();
897
+ }
898
+ }
899
+
848
900
  async function manageShim(action = 'status') {
849
901
  const act = (action || 'status').toLowerCase();
850
902
 
@@ -1247,6 +1299,8 @@ if (cmd === 'off' || cmd === 'stop') {
1247
1299
  await runDoctor();
1248
1300
  } else if (cmd === 'service' || cmd === 'daemon') {
1249
1301
  await manageService(subArg.toLowerCase() || 'status');
1302
+ } else if (cmd === 'compact') {
1303
+ await manageCompact(subArg.toLowerCase() || 'status', positionalArgs()[2] || '');
1250
1304
  } else if (cmd === 'shim' || cmd === 'shims') {
1251
1305
  await manageShim(subArg.toLowerCase() || 'status');
1252
1306
  } else if (cmd === 'plugin' || cmd === 'plugins') {