llm-switcher 1.4.0 → 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,328 @@
1
+ // Idle compaction.
2
+ //
3
+ // The part worth testing hard is the session file, because nothing in any API guarantees it: the
4
+ // shape was read off a real session Claude Code wrote itself, and the only proof it works is that
5
+ // Claude Code reads it back. The test that matters is TestResumeReadsTheBoundary, which runs the
6
+ // real binary.
7
+ import { test } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import { execFile, execFileSync } from 'node:child_process';
13
+ import { promisify } from 'node:util';
14
+
15
+ // Async, never execFileSync: the probe server that answers Claude Code runs in this same process,
16
+ // and a blocking call would stop the event loop from ever answering it.
17
+ const run = promisify(execFile);
18
+ import http from 'node:http';
19
+ import crypto from 'node:crypto';
20
+
21
+ import {
22
+ idleCompactPolicy, conversationKey, idleFor, noteConversation, compactIR,
23
+ summaryMessages, clampSummary, SUMMARY_INSTRUCTION,
24
+ } from '../idlecompact.mjs';
25
+ import { sessionFileFor, claudeSessionId, writeCompaction, readLeaf, claudeConfigDir } from '../claudesession.mjs';
26
+
27
+ const long = 'X'.repeat(4000);
28
+
29
+ function agentIR(turns = 10, fill = 4000) {
30
+ const messages = [{ role: 'user', content: [{ type: 'text', text: 'fix the failing test' }] }];
31
+ for (let i = 0; i < turns; i++) {
32
+ messages.push({ role: 'assistant', content: [
33
+ { type: 'text', text: `step ${i}` },
34
+ { type: 'tool_use', id: `t${i}`, name: 'Bash', input: { command: 'ls' } },
35
+ ] });
36
+ messages.push({ role: 'user', content: [
37
+ { type: 'tool_result', tool_use_id: `t${i}`, content: `turn${i}:` + 'Y'.repeat(fill) },
38
+ ] });
39
+ }
40
+ return { system: 'be brief', messages, tools: [], model: 'claude-x' };
41
+ }
42
+
43
+ function policy(over = {}) {
44
+ return idleCompactPolicy({ idleCompact: { enabled: true, idleMinutes: 15, minBytes: 1024, ...over } });
45
+ }
46
+
47
+ // ---- the policy ----
48
+
49
+ test('the feature is off until it is asked for', () => {
50
+ assert.equal(idleCompactPolicy({}).enabled, false);
51
+ assert.equal(idleCompactPolicy({ idleCompact: { enabled: true } }).enabled, true);
52
+ });
53
+
54
+ test('a nonsense setting falls back to the default instead of breaking', () => {
55
+ const p = idleCompactPolicy({ idleCompact: { idleMinutes: -3, minBytes: 'x', keepRecent: 0 } });
56
+ assert.equal(p.idleMinutes, 15);
57
+ assert.ok(p.minBytes > 0);
58
+ assert.equal(p.keepRecent, 6);
59
+ });
60
+
61
+ test('a conversation that was never here is not idle', () => {
62
+ assert.equal(idleFor('nope'), 0);
63
+ });
64
+
65
+ test('the time since the last request is what the pause means', () => {
66
+ const now = 1_000_000;
67
+ noteConversation('k', now - 20 * 60000);
68
+ assert.ok(Math.abs(idleFor('k', now) - 20 * 60000) < 50);
69
+ });
70
+
71
+ // ---- which conversation ----
72
+
73
+ test('the session the client names wins over its content', () => {
74
+ const k = conversationKey('claude', { headers: { 'x-claude-code-session-id': 'abc' } }, agentIR());
75
+ assert.equal(k, 'h:abc');
76
+ });
77
+
78
+ test('a client that names nothing is keyed on its opening request', () => {
79
+ const k1 = conversationKey('claude', { headers: {} }, agentIR());
80
+ const k2 = conversationKey('claude', { headers: {} }, agentIR());
81
+ assert.equal(k1, k2, 'the same conversation keys the same both times');
82
+ const other = agentIR();
83
+ other.messages[0].content[0].text = 'a different task entirely';
84
+ assert.notEqual(k1, conversationKey('claude', { headers: {} }, other));
85
+ });
86
+
87
+ test('the key survives the compaction it causes', () => {
88
+ // The opening request is the one message a compaction keeps, which is the whole reason the
89
+ // fallback key is built from it and not from the whole body.
90
+ const ir = agentIR();
91
+ const before = conversationKey('claude', { headers: {} }, ir);
92
+ const c = compactIR(ir, policy());
93
+ ir.messages = c.messages;
94
+ assert.equal(conversationKey('claude', { headers: {} }, ir), before);
95
+ });
96
+
97
+ // ---- the shortened history ----
98
+
99
+ test('the opening request and the recent turns survive as bytes', () => {
100
+ const ir = agentIR(10);
101
+ const c = compactIR(ir, policy({ keepRecent: 6 }));
102
+ assert.ok(c);
103
+ assert.equal(c.messages[0].content[0].text, 'fix the failing test');
104
+ const tail = JSON.stringify(c.messages.slice(-6));
105
+ assert.ok(tail.includes('tool_use'), 'the recent tool calls are kept: the next turn reasons over them');
106
+ });
107
+
108
+ test('the bulky middle goes and its words stay', () => {
109
+ // Each turn is tagged, so the test can tell an early turn from a recent one. The recent turns
110
+ // keep their tool output on purpose: the next turn reasons over those results verbatim.
111
+ const ir = agentIR(10);
112
+ const c = compactIR(ir, policy({ keepRecent: 6 }));
113
+ const text = JSON.stringify(c.messages);
114
+ assert.ok(!text.includes('turn1:'), 'an early middle tool result came through whole');
115
+ assert.ok(text.includes('turn9:'), 'a recent tool result was cut, so the next turn lost what it reasons over');
116
+ assert.ok(text.includes('step 3'), 'a middle assistant message lost its text');
117
+ assert.ok(c.dropped > 0);
118
+ });
119
+
120
+ test('no tool result survives without the call that made it', () => {
121
+ // A result whose call was cut away is a request the provider refuses, so this is the invariant
122
+ // that has to hold whatever the window lands on.
123
+ for (const keep of [3, 4, 5, 6, 8]) {
124
+ const c = compactIR(agentIR(12), policy({ keepRecent: keep }));
125
+ assert.ok(c, `keepRecent=${keep} produced nothing`);
126
+ const calls = new Set();
127
+ for (const m of c.messages) {
128
+ for (const p of m.content || []) {
129
+ if (p?.type === 'tool_use') calls.add(p.id);
130
+ if (p?.type === 'tool_result') {
131
+ assert.ok(calls.has(p.tool_use_id), `keepRecent=${keep}: result ${p.tool_use_id} has no call`);
132
+ }
133
+ }
134
+ }
135
+ }
136
+ });
137
+
138
+ test('a conversation too short to be worth it is left alone', () => {
139
+ assert.equal(compactIR(agentIR(2, 10), policy()), null);
140
+ assert.equal(compactIR({ messages: [{ role: 'user', content: 'hi' }] }, policy()), null);
141
+ });
142
+
143
+ // ---- the summary ----
144
+
145
+ test('the summarizer is handed the history without its tool output', () => {
146
+ const msgs = summaryMessages(agentIR(10), policy());
147
+ assert.ok(msgs.length > 1);
148
+ assert.match(msgs[0].content[0].text, /summarize/i);
149
+ assert.ok(!JSON.stringify(msgs).includes('Y'.repeat(4000)));
150
+ });
151
+
152
+ test('an over-long summary is cut at a boundary, not mid-word', () => {
153
+ const s = clampSummary('A'.repeat(10) + '\n\n' + 'B'.repeat(30000), policy({ summaryMaxChars: 1000 }));
154
+ assert.ok(s.length <= 1000);
155
+ assert.ok(!s.includes('A'.repeat(11)));
156
+ });
157
+
158
+ test('a summary of nothing is nothing', () => {
159
+ assert.equal(clampSummary('', policy()), '');
160
+ assert.equal(clampSummary(null, policy()), '');
161
+ });
162
+
163
+ // ---- the session file ----
164
+
165
+ function tmpSession(t) {
166
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-sess-'));
167
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
168
+ const file = path.join(dir, 'sess.jsonl');
169
+ return { file, dir };
170
+ }
171
+
172
+ function seed(file, entries) {
173
+ fs.writeFileSync(file, entries.map(e => JSON.stringify(e)).join('\n') + '\n');
174
+ }
175
+
176
+ test('the compaction is written as the two entries Claude Code writes', (t) => {
177
+ const { file } = tmpSession(t);
178
+ seed(file, [{ type: 'user', uuid: 'u1', sessionId: 's1', message: { role: 'user', content: 'a' } }]);
179
+ const r = writeCompaction({ file, summary: 'the summary', sessionId: 's1', preTokens: 900, postTokens: 80 });
180
+ assert.ok(r);
181
+ const lines = fs.readFileSync(file, 'utf8').trim().split('\n').map(l => JSON.parse(l));
182
+ const boundary = lines.at(-2);
183
+ const summary = lines.at(-1);
184
+ assert.equal(boundary.type, 'system');
185
+ assert.equal(boundary.subtype, 'compact_boundary');
186
+ assert.equal(boundary.logicalParentUuid, 'u1', 'the chain is cut at the entry before');
187
+ assert.equal(boundary.compactMetadata.preTokens, 900);
188
+ assert.equal(summary.type, 'user');
189
+ assert.equal(summary.isCompactSummary, true);
190
+ assert.match(summary.message.content, /the summary/);
191
+ assert.match(summary.message.content, /continued from a previous conversation/);
192
+ });
193
+
194
+ test('the old entries stay, because a transcript is worth more than a small file', (t) => {
195
+ // This is what Claude Code itself does: its own compaction drops ~949k tokens and leaves the
196
+ // entries. The boundary makes them unreachable, not gone.
197
+ const { file } = tmpSession(t);
198
+ seed(file, Array.from({ length: 20 }, (_, i) => ({ type: 'user', uuid: `u${i}`, sessionId: 's1', message: { role: 'user', content: `m${i}` } })));
199
+ writeCompaction({ file, summary: 'the summary', sessionId: 's1' });
200
+ const lines = fs.readFileSync(file, 'utf8').trim().split('\n');
201
+ assert.equal(lines.length, 22);
202
+ assert.ok(lines.some(l => l.includes('"m0"')), 'the earliest entry is still readable');
203
+ });
204
+
205
+ test('a session already compacted is not compacted twice', (t) => {
206
+ const { file } = tmpSession(t);
207
+ seed(file, [{ type: 'user', uuid: 'u1', sessionId: 's1', message: { role: 'user', content: 'a' } }]);
208
+ assert.ok(writeCompaction({ file, summary: 'first', sessionId: 's1' }));
209
+ assert.equal(writeCompaction({ file, summary: 'second', sessionId: 's1' }), null);
210
+ });
211
+
212
+ test('nothing is written where there is nothing to write', (t) => {
213
+ const { file } = tmpSession(t);
214
+ seed(file, [{ type: 'user', uuid: 'u1', message: { role: 'user', content: 'a' } }]);
215
+ assert.equal(writeCompaction({ file, summary: '', sessionId: 's1' }), null);
216
+ assert.equal(writeCompaction({ file: path.join(path.dirname(file), 'gone.jsonl'), summary: 'x' }), null);
217
+ assert.equal(readLeaf(file).uuid, 'u1');
218
+ });
219
+
220
+ test('every appended line is valid json', (t) => {
221
+ // A torn line makes Claude Code drop the rest of the file, which loses the transcript.
222
+ const { file } = tmpSession(t);
223
+ seed(file, [{ type: 'user', uuid: 'u1', sessionId: 's1', message: { role: 'user', content: 'a' } }]);
224
+ writeCompaction({ file, summary: 'multi\nline\nsummary', sessionId: 's1' });
225
+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
226
+ if (line.trim()) JSON.parse(line);
227
+ }
228
+ });
229
+
230
+ test('the session id comes from the header or the body Claude Code sends', () => {
231
+ assert.equal(claudeSessionId({ headers: { 'x-claude-code-session-id': 'S1' } }, {}), 'S1');
232
+ assert.equal(claudeSessionId({ headers: {} }, { metadata: { user_id: 'u_1_account_a_session_dead-beef' } }), 'dead-beef');
233
+ assert.equal(claudeSessionId({ headers: {} }, {}), '');
234
+ });
235
+
236
+ test('the session file sits where Claude Code puts it', () => {
237
+ const { file } = sessionFileFor('abc', '/opt/hermes/proj', { CLAUDE_CONFIG_DIR: '/cfg' });
238
+ assert.equal(file, '/cfg/projects/-opt-hermes-proj/abc.jsonl');
239
+ assert.equal(claudeConfigDir({ CLAUDE_CONFIG_DIR: '/x' }), '/x');
240
+ });
241
+
242
+ // ---- the real thing ----
243
+
244
+ // This is the test that settles whether writing the file does anything at all. It runs the real
245
+ // Claude Code binary against a local server and reads the request it sends.
246
+ const probe = (() => {
247
+ let last = null;
248
+ const server = http.createServer((req, res) => {
249
+ let b = '';
250
+ req.on('data', c => (b += c));
251
+ req.on('end', () => {
252
+ try { last = JSON.parse(b); } catch { /* keep the last good one */ }
253
+ res.writeHead(200, { 'Content-Type': 'application/json' });
254
+ res.end(JSON.stringify({
255
+ id: 'msg_probe', type: 'message', role: 'assistant', model: last?.model || 'x',
256
+ content: [{ type: 'text', text: 'probe ok' }], stop_reason: 'end_turn',
257
+ usage: { input_tokens: 1, output_tokens: 1 },
258
+ }));
259
+ });
260
+ });
261
+ return { server, get last() { return last; } };
262
+ })();
263
+
264
+ test('Claude Code resumes from the boundary and leaves the old turns out', { timeout: 240000, skip: !hasClaude() }, async () => {
265
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-cc-'));
266
+ await new Promise(r => probe.server.listen(0, '127.0.0.1', r));
267
+ const port = probe.server.address().port;
268
+ const cfg = path.join(dir, 'cfg');
269
+ const proj = path.join(dir, 'proj');
270
+ fs.mkdirSync(proj, { recursive: true });
271
+ fs.writeFileSync(path.join(proj, 'CLAUDE.md'), 'probe project\n');
272
+ const env = {
273
+ ...process.env,
274
+ ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
275
+ ANTHROPIC_API_KEY: 'probe-key',
276
+ CLAUDE_CONFIG_DIR: cfg,
277
+ };
278
+
279
+ // One real turn, so the session file is written by Claude Code and has its own shape in it.
280
+ await run('claude', ['-p', 'say ok', '--model', 'claude-probe-1'], { env, cwd: proj, timeout: 180000 });
281
+ const sessions = path.join(cfg, 'projects', proj.replace(/[\\/:]/g, '-'));
282
+ const file = fs.readdirSync(sessions).map(f => path.join(sessions, f))[0];
283
+ const sid = path.basename(file, '.jsonl');
284
+ const sidFromBody = JSON.parse(fs.readFileSync(file, 'utf8').split('\n')[0]).sessionId;
285
+
286
+ // Twelve turns of history, then a compaction boundary before them.
287
+ const entries = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l));
288
+ const leaf = entries.filter(e => e.uuid).at(-1);
289
+ const base = (u, parent) => ({
290
+ parentUuid: parent, isSidechain: false, userType: 'external', cwd: proj,
291
+ version: '2.1.292', sessionId: sidFromBody || sid, uuid: u, timestamp: new Date().toISOString(),
292
+ });
293
+ let parent = leaf.uuid;
294
+ for (let i = 0; i < 12; i++) {
295
+ const u = crypto.randomUUID();
296
+ entries.push({ ...base(u, parent), type: 'user', message: { role: 'user', content: `turn ${i} question` } });
297
+ parent = u;
298
+ const a = crypto.randomUUID();
299
+ entries.push({ ...base(a, parent), type: 'assistant', message: { role: 'assistant', content: [
300
+ { type: 'text', text: `answer ${i}` },
301
+ { type: 'tool_use', id: `toolu_${i}`, name: 'Read', input: { file_path: '/x/y.py' } }] } });
302
+ parent = a;
303
+ const t = crypto.randomUUID();
304
+ entries.push({ ...base(t, parent), type: 'user', message: { role: 'user', content: [
305
+ { type: 'tool_result', tool_use_id: `toolu_${i}`, content: long }] } });
306
+ parent = t;
307
+ }
308
+ fs.writeFileSync(file, entries.map(e => JSON.stringify(e)).join('\n') + '\n');
309
+ const beforeBytes = fs.statSync(file).size;
310
+
311
+ const wrote = writeCompaction({ file, summary: 'PROBE_SUMMARY_MARKER_12345', sessionId: sid, cwd: proj });
312
+ assert.ok(wrote, 'the boundary was written');
313
+
314
+ await run('claude', ['-p', 'after compact, reply ok', '--model', 'claude-probe-1', '--resume', sid],
315
+ { env, cwd: proj, timeout: 180000 });
316
+
317
+ const sent = JSON.stringify(probe.last?.messages || []);
318
+ assert.match(sent, /PROBE_SUMMARY_MARKER_12345/, 'the summary is what the request carries');
319
+ assert.equal((sent.match(new RegExp(long, 'g')) || []).length, 0, 'none of the twelve turns came through');
320
+ assert.ok(fs.statSync(file).size > beforeBytes, 'the entries are appended, not rewritten');
321
+
322
+ await new Promise(r => probe.server.close(r));
323
+ fs.rmSync(dir, { recursive: true, force: true });
324
+ });
325
+
326
+ function hasClaude() {
327
+ try { execFileSync('claude', ['--version'], { stdio: 'ignore', timeout: 30000 }); return true; } catch { return false; }
328
+ }
@@ -1,14 +1,29 @@
1
- // The update itself: a git checkout pulls fast-forward only, an npm install asks the registry.
2
- // Every remote here is a local bare repository or a local HTTP server.
1
+ // The update itself: npm is asked first and installs the version the registry names; a git
2
+ // checkout is the fallback, and pulls fast-forward only. Every remote here is a local bare
3
+ // repository or a local HTTP server.
3
4
  import { test } from 'node:test';
4
5
  import assert from 'node:assert/strict';
5
6
  import fs from 'node:fs';
6
7
  import http from 'node:http';
7
8
  import os from 'node:os';
8
9
  import path from 'node:path';
9
- import { execFileSync } from 'node:child_process';
10
+ import { execFile, execFileSync } from 'node:child_process';
11
+ import { promisify } from 'node:util';
10
12
  import { applyUpdate } from '../update.mjs';
11
13
 
14
+ const execFileAsync = promisify(execFile);
15
+
16
+ // Records every command in the order it is asked. git runs for real, because the checkout has to
17
+ // move before the fallback can be observed. npm's install is recorded and not run: the version a
18
+ // fixture registry names is a number from the test, not one the real registry serves, and a real
19
+ // install would put a package on the machine running the suite.
20
+ const recording = (calls) => async (cmd, args, opts) => {
21
+ calls.push([cmd, ...args]);
22
+ if (cmd === 'npm' || cmd === 'cmd.exe') return '';
23
+ const { stdout } = await execFileAsync(cmd, args, { encoding: 'utf8', ...opts });
24
+ return stdout;
25
+ };
26
+
12
27
  const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
13
28
  const ID = ['-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'commit.gpgsign=false'];
14
29
 
@@ -50,14 +65,14 @@ function fixture(t) {
50
65
  test('a checkout behind its upstream pulls and reports the new version', async (t) => {
51
66
  const fx = fixture(t);
52
67
  fx.release('1.0.1');
53
- const r = await applyUpdate({ root: fx.install });
68
+ const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.0') });
54
69
  assert.deepEqual({ updated: r.updated, from: r.from, to: r.to }, { updated: true, from: '1.0.0', to: '1.0.1' });
55
70
  assert.equal(JSON.parse(fs.readFileSync(path.join(fx.install, 'package.json'), 'utf8')).version, '1.0.1');
56
71
  });
57
72
 
58
73
  test('a checkout level with its upstream does nothing', async (t) => {
59
74
  const fx = fixture(t);
60
- const r = await applyUpdate({ root: fx.install });
75
+ const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.0') });
61
76
  assert.equal(r.updated, false);
62
77
  assert.match(r.reason, /latest/i);
63
78
  });
@@ -66,7 +81,7 @@ test('a gateway that runs older code than its checkout restarts into it, with no
66
81
  const fx = fixture(t);
67
82
  fx.release('1.0.1');
68
83
  git(fx.install, 'pull', '-q', '--ff-only');
69
- const r = await applyUpdate({ root: fx.install, running: '1.0.0' });
84
+ const r = await applyUpdate({ root: fx.install, running: '1.0.0', registryUrl: await registry(t, '1.0.1') });
70
85
  assert.deepEqual({ updated: r.updated, from: r.from, to: r.to }, { updated: true, from: '1.0.0', to: '1.0.1' });
71
86
  });
72
87
 
@@ -75,7 +90,7 @@ test('a checkout with local edits is left alone, even when upstream moved', asyn
75
90
  fx.release('1.0.1');
76
91
  const before = fx.head();
77
92
  fs.appendFileSync(path.join(fx.install, 'package.json'), ' ');
78
- const r = await applyUpdate({ root: fx.install });
93
+ const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.0') });
79
94
  assert.equal(r.updated, false);
80
95
  assert.match(r.reason, /local changes/i);
81
96
  assert.equal(fx.head(), before);
@@ -85,7 +100,7 @@ test('a checkout that is only ahead of upstream does nothing', async (t) => {
85
100
  const fx = fixture(t);
86
101
  fs.writeFileSync(path.join(fx.install, 'local.txt'), 'x');
87
102
  commit(fx.install, 'local work');
88
- const r = await applyUpdate({ root: fx.install });
103
+ const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.0') });
89
104
  assert.equal(r.updated, false);
90
105
  });
91
106
 
@@ -95,7 +110,7 @@ test('a diverged checkout fails and is never merged', async (t) => {
95
110
  commit(fx.install, 'local work');
96
111
  fx.release('1.0.1');
97
112
  const before = fx.head();
98
- await assert.rejects(applyUpdate({ root: fx.install }), /fast-forward|diverged/i);
113
+ await assert.rejects(applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.0') }), /fast-forward|diverged/i);
99
114
  assert.equal(fx.head(), before);
100
115
  });
101
116
 
@@ -159,3 +174,71 @@ test('an unreachable registry is not an update', async (t) => {
159
174
  assert.equal(r.updated, false);
160
175
  assert.match(r.reason, /registry/i);
161
176
  });
177
+
178
+ // ---- npm first, and the checkout only when npm cannot serve it ----
179
+
180
+ test('a release on npm is installed before the checkout is asked at all', async (t) => {
181
+ const fx = fixture(t);
182
+ fx.release('1.0.1');
183
+ const calls = [];
184
+ const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.1'), run: recording(calls) });
185
+
186
+ const order = calls.map(c => c[0]);
187
+ assert.deepEqual(order.slice(0, 2), ['npm', 'git'], `npm first, then the checkout: ${order.join(' ')}`);
188
+ assert.deepEqual(calls[0].slice(-3), ['install', '-g', 'llm-switcher@1.0.1']);
189
+ // npm put the release in the global prefix, which is not this directory, so the checkout is what
190
+ // moved -- and that is the version this gateway runs, which is the only reason the pull happened.
191
+ assert.match(r.reason, /Pulled 1 commit/);
192
+ assert.deepEqual({ updated: r.updated, from: r.from, to: r.to }, { updated: true, from: '1.0.0', to: '1.0.1' });
193
+ });
194
+
195
+ test('a checkout level with its upstream runs no git pull when npm had nothing newer', async (t) => {
196
+ const fx = fixture(t);
197
+ const calls = [];
198
+ const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.0'), run: recording(calls) });
199
+
200
+ assert.equal(calls.some(c => c[0] === 'npm' && c.includes('install')), false, 'no install when npm is not newer');
201
+ assert.equal(calls.some(c => c.includes('pull')), false, 'nothing to pull');
202
+ assert.match(r.reason, /latest/i);
203
+ });
204
+
205
+ test('npm holding a release the checkout cannot take is said plainly, not hidden by "latest"', async (t) => {
206
+ const fx = fixture(t);
207
+ // The checkout is level, and the registry is a version ahead of it: the two answers disagree.
208
+ const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.4.0'), run: recording([]) });
209
+
210
+ assert.equal(r.updated, false);
211
+ assert.match(r.reason, /npm installed v1\.4\.0, but not into/);
212
+ assert.match(r.reason, /latest/i, 'the checkout answer is kept as well');
213
+ });
214
+
215
+ test('a checkout is still updated by git when the registry cannot be reached', async (t) => {
216
+ const fx = fixture(t);
217
+ fx.release('1.0.1');
218
+ const r = await applyUpdate({ root: fx.install, registryUrl: 'http://127.0.0.1:9/' });
219
+
220
+ assert.deepEqual({ updated: r.updated, from: r.from, to: r.to }, { updated: true, from: '1.0.0', to: '1.0.1' });
221
+ });
222
+
223
+ test('an npm install that fails does not stop a checkout from updating', async (t) => {
224
+ const fx = fixture(t);
225
+ fx.release('1.0.1');
226
+ const logs = [];
227
+ // npm refuses the version a fixture registry names, the way a real registry would.
228
+ const run = async (cmd, args, opts) => {
229
+ if (cmd === 'npm') throw new Error('npm install failed: notarget No matching version found');
230
+ return recording([])(cmd, args, opts);
231
+ };
232
+ const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.1'), run, logger: m => logs.push(m) });
233
+
234
+ assert.deepEqual({ updated: r.updated, from: r.from, to: r.to }, { updated: true, from: '1.0.0', to: '1.0.1' });
235
+ assert.match(r.reason, /Pulled 1 commit/);
236
+ // The npm failure is said out loud, not swallowed into a success nobody can explain.
237
+ assert.match(logs.join('\n'), /npm could not install it here/);
238
+ });
239
+
240
+ test('an npm install behind the registry still fails loudly when there is no checkout', async (t) => {
241
+ const root = npmInstall(t, '1.0.0');
242
+ const run = async () => { throw new Error('npm install failed: EACCES'); };
243
+ await assert.rejects(applyUpdate({ root, registryUrl: await registry(t, '1.0.1'), run }), /npm install failed/);
244
+ });
package/ui.html CHANGED
@@ -952,6 +952,57 @@
952
952
  <!-- VIEW 1: Routes & Profiles -->
953
953
  <div id="view-routes" class="view-panel" style="display:none;">
954
954
  <div class="sec">
955
+ <!-- Idle compaction -->
956
+ <div class="card">
957
+ <div class="cardh">
958
+ <h2 style="font-size:1.02rem; font-weight:600;">Idle compaction</h2>
959
+ <span class="badge outline" id="badge-idle-compact">OFF</span>
960
+ </div>
961
+ <p style="margin:.2rem 0 .7rem; color:var(--muted); font-size:.86rem;">
962
+ A Claude Code conversation that comes back after a pause has lost the provider's prompt
963
+ cache, so its history is paid for at full price. Shorten it then, and write the
964
+ compaction into the session file so the next resume starts short.
965
+ </p>
966
+ <div class="row">
967
+ <label class="cb" style="display:flex; align-items:center; gap:.5rem;">
968
+ <input type="checkbox" id="idle-compact-enabled" onchange="saveIdleCompact()">
969
+ <span>Shorten a conversation that was idle too long</span>
970
+ </label>
971
+ </div>
972
+ <div class="row">
973
+ <div class="who">
974
+ <div class="l">Pause that counts as losing the cache</div>
975
+ <div class="meta">minutes · Anthropic 5m default, OpenAI 30m</div>
976
+ </div>
977
+ <div style="min-width:9rem;">
978
+ <input type="number" id="idle-compact-minutes" min="1" max="1440" class="inp"
979
+ style="width:100%;" onchange="saveIdleCompact()">
980
+ </div>
981
+ </div>
982
+ <div class="row">
983
+ <div class="who">
984
+ <div class="l">Context it must be larger than</div>
985
+ <div class="meta">KB · below this nothing is touched</div>
986
+ </div>
987
+ <div style="min-width:9rem;">
988
+ <input type="number" id="idle-compact-kb" min="8" max="4096" class="inp"
989
+ style="width:100%;" onchange="saveIdleCompact()">
990
+ </div>
991
+ </div>
992
+ <div class="row">
993
+ <div class="who">
994
+ <div class="l">Model that writes the summary</div>
995
+ <div class="meta">a cheaper model writes a cheaper and worse summary</div>
996
+ </div>
997
+ <div style="min-width:16rem; flex:1;">
998
+ <input type="text" id="idle-compact-model" class="inp" style="width:100%;"
999
+ placeholder="empty = the model of the conversation itself"
1000
+ onchange="saveIdleCompact()">
1001
+ </div>
1002
+ </div>
1003
+ <span id="idle-compact-status" class="meta" aria-live="polite"></span>
1004
+ </div>
1005
+
955
1006
  <!-- Active Coding CLI Targets -->
956
1007
  <div class="card">
957
1008
  <div class="cardh">
@@ -1461,6 +1512,7 @@
1461
1512
  const activeTargets = Object.entries(activeMap).filter(([_, v]) => Boolean(v));
1462
1513
 
1463
1514
  if (route === 'routes') {
1515
+ loadIdleCompact();
1464
1516
  titleEl.textContent = 'Routes & Profiles';
1465
1517
  if (activeTargets.length > 0) {
1466
1518
  const parts = [];
@@ -1734,6 +1786,49 @@
1734
1786
  }
1735
1787
 
1736
1788
  // Load Status
1789
+ // Idle compaction, read and written through the same endpoint `switch compact` uses, so the
1790
+ // dashboard and the terminal cannot show two different settings.
1791
+ function showIdleCompact(c) {
1792
+ c = c || {};
1793
+ document.getElementById('idle-compact-enabled').checked = !!c.enabled;
1794
+ document.getElementById('idle-compact-minutes').value = c.idleMinutes ?? 15;
1795
+ document.getElementById('idle-compact-kb').value = Math.round((c.minBytes ?? 65536) / 1024);
1796
+ document.getElementById('idle-compact-model').value = c.model || '';
1797
+ const badge = document.getElementById('badge-idle-compact');
1798
+ badge.textContent = c.enabled ? 'ON' : 'OFF';
1799
+ badge.className = 'badge ' + (c.enabled ? 'ok' : 'outline');
1800
+ }
1801
+
1802
+ async function loadIdleCompact() {
1803
+ try {
1804
+ const r = await api('/api/idle-compact');
1805
+ if (r.ok) showIdleCompact((await r.json()).idleCompact);
1806
+ } catch { /* the card stays at its defaults; nothing here is required to route */ }
1807
+ }
1808
+
1809
+ async function saveIdleCompact() {
1810
+ const status = document.getElementById('idle-compact-status');
1811
+ const patch = {
1812
+ enabled: document.getElementById('idle-compact-enabled').checked,
1813
+ idleMinutes: Number(document.getElementById('idle-compact-minutes').value) || 15,
1814
+ minBytes: (Number(document.getElementById('idle-compact-kb').value) || 64) * 1024,
1815
+ model: document.getElementById('idle-compact-model').value.trim()
1816
+ };
1817
+ status.textContent = 'Saving...';
1818
+ try {
1819
+ const r = await api('/api/idle-compact', {
1820
+ method: 'POST',
1821
+ headers: { 'Content-Type': 'application/json' },
1822
+ body: JSON.stringify(patch)
1823
+ });
1824
+ if (!r.ok) throw new Error((await r.json()).error || r.status);
1825
+ showIdleCompact((await r.json()).idleCompact);
1826
+ status.textContent = 'Saved. It applies to the next conversation that comes back after a pause.';
1827
+ } catch (e) {
1828
+ status.textContent = 'Not saved: ' + e.message;
1829
+ }
1830
+ }
1831
+
1737
1832
  async function loadStatus() {
1738
1833
  try {
1739
1834
  const res = await api('/api/status');
package/update.mjs CHANGED
@@ -1,5 +1,8 @@
1
- // Brings this install to the newest release: a git checkout pulls fast-forward only, an npm install
2
- // asks the registry and installs that exact version. Restarting the gateway is the caller's job.
1
+ // Brings this install to the newest release. A release is published to npm, so npm is what is asked
2
+ // first: `npm install -g llm-switcher@<version>` for the exact version the registry names. A git
3
+ // checkout is the one install npm cannot serve, because npm writes into the global prefix while the
4
+ // running code is the checkout itself, so the checkout is asked second and only then. Restarting
5
+ // the gateway is the caller's job.
3
6
  import fs from 'node:fs';
4
7
  import os from 'node:os';
5
8
  import path from 'node:path';
@@ -80,8 +83,9 @@ async function updateNpmInstall(root, run, log, registryUrl) {
80
83
  throw failure('npm install', err);
81
84
  }
82
85
  const to = readVersion(root);
83
- // npm can install into another prefix than the one this gateway runs from; then nothing changed here.
84
- if (to === from) return noUpdate(from, `npm installed v${latest}, but not into ${root}.`);
86
+ // npm installs into the global prefix. When this gateway runs from a checkout that is a different
87
+ // directory, so the registry can name a release this code never receives; `elsewhere` says so.
88
+ if (to === from) return { ...noUpdate(from, `npm installed v${latest}, but not into ${root}.`), elsewhere: latest };
85
89
  return { updated: true, from, to, reason: `Installed v${to} from npm.` };
86
90
  }
87
91
 
@@ -93,10 +97,27 @@ export async function applyUpdate({
93
97
  registryUrl = process.env.LLM_SWITCHER_REGISTRY_URL || REGISTRY_URL,
94
98
  running = CURRENT_VERSION
95
99
  } = {}) {
96
- const r = fs.existsSync(path.join(root, '.git'))
97
- ? await updateCheckout(root, run, logger)
98
- : await updateNpmInstall(root, run, logger, registryUrl);
99
- // Newer code already on disk (a pull or an install outside the gateway) is an update for the
100
+ // npm writes into the global prefix, so a checkout is the one install it cannot serve: the running
101
+ // code is this directory, not the prefix. The registry is still asked, because the release on npm
102
+ // is the release every other install receives, but for a checkout a failed install is not the end
103
+ // of the update -- the checkout is the thing that can move, so it is asked next.
104
+ const checkout = fs.existsSync(path.join(root, '.git'));
105
+ let registry;
106
+ try {
107
+ registry = await updateNpmInstall(root, run, logger, registryUrl);
108
+ } catch (err) {
109
+ if (!checkout) throw err;
110
+ logger(`npm could not install it here: ${err.message}`);
111
+ registry = { ...noUpdate(readVersion(root), `npm could not install it here: ${err.message}`), elsewhere: true };
112
+ }
113
+ let r = registry;
114
+ if (!r.updated && checkout) {
115
+ r = await updateCheckout(root, run, logger);
116
+ // Neither could deliver it, and npm held a release this directory cannot take: both facts, because
117
+ // "already the latest version" on its own would hide the one version the reader does not have.
118
+ if (!r.updated && registry.elsewhere) r = { ...r, reason: `${registry.reason} ${r.reason}` };
119
+ }
120
+ // Newer code already on disk (an install outside the gateway) is an update for the
100
121
  // running process: without the restart it keeps the old code and the old version forever.
101
122
  const onDisk = readVersion(root);
102
123
  if (!r.updated && isNewer(onDisk, running)) {