llm-switcher 1.6.0 → 1.6.3
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/CHANGELOG.md +30 -0
- package/README.md +42 -34
- package/README.vi.md +38 -33
- package/docs/session-2026-10-07-compaction.md +224 -0
- package/formats.mjs +85 -25
- package/idlecache.mjs +159 -0
- package/idlecall.mjs +46 -54
- package/idlecompact.mjs +171 -238
- package/package.json +1 -1
- package/proxy.mjs +111 -135
- package/shim.mjs +29 -0
- package/state.mjs +48 -2
- package/switch.mjs +12 -5
- package/tests/certs.test.mjs +7 -1
- package/tests/codex-daemon.test.mjs +57 -3
- package/tests/compact-cli.test.mjs +34 -0
- package/tests/dashboard.e2e.test.mjs +7 -1
- package/tests/formats.test.mjs +44 -1
- package/tests/idlecompact.e2e.test.mjs +203 -0
- package/tests/idlecompact.test.mjs +233 -279
- package/ui.html +657 -85
- package/claudesession.mjs +0 -196
- package/codexsession.mjs +0 -141
package/claudesession.mjs
DELETED
|
@@ -1,196 +0,0 @@
|
|
|
1
|
-
// Claude Code's session file.
|
|
2
|
-
//
|
|
3
|
-
// Claude Code keeps a conversation as a JSONL file, one entry per line, and builds every request
|
|
4
|
-
// it sends from the chain of entries linked by parentUuid. That is a fact about the file, not about
|
|
5
|
-
// any API, and it is the reason a compaction written here lasts: the next request is built from
|
|
6
|
-
// whatever the chain says, so an entry that starts a new chain shortens every request after it.
|
|
7
|
-
//
|
|
8
|
-
// A compaction is two entries, and this is the shape Claude Code itself writes. It was read off a
|
|
9
|
-
// real session on this machine, from Claude Code's own automatic compaction:
|
|
10
|
-
//
|
|
11
|
-
// {"type":"system","subtype":"compact_boundary","content":"Conversation compacted",
|
|
12
|
-
// "parentUuid":null,"logicalParentUuid":"<the last entry before>",
|
|
13
|
-
// "compactMetadata":{"trigger":"auto","preTokens":968627,"postTokens":19320, ...}}
|
|
14
|
-
//
|
|
15
|
-
// {"type":"user","isCompactSummary":true,
|
|
16
|
-
// "message":{"role":"user","content":"This session is being continued from a previous
|
|
17
|
-
// conversation that ran out of context. The summary below covers the earlier portion..."}}
|
|
18
|
-
//
|
|
19
|
-
// The boundary is what makes the old entries unreachable rather than deleted. That is Claude Code's
|
|
20
|
-
// own behaviour: its automatic compaction drops ~949k tokens of history and leaves the entries in
|
|
21
|
-
// the file, because a transcript the person can still read is worth more than a small file. This
|
|
22
|
-
// writes the same thing for the same reason, so nothing here destroys anything a resume would
|
|
23
|
-
// otherwise show.
|
|
24
|
-
//
|
|
25
|
-
// Verified against Claude Code 2.1.292 on this machine: with twelve synthetic turns and a boundary
|
|
26
|
-
// before them, the next resume sent four messages instead of the thirteen, and none of the twelve.
|
|
27
|
-
|
|
28
|
-
import crypto from 'node:crypto';
|
|
29
|
-
import fs from 'node:fs';
|
|
30
|
-
import os from 'node:os';
|
|
31
|
-
import path from 'node:path';
|
|
32
|
-
|
|
33
|
-
const UUID = () => crypto.randomUUID();
|
|
34
|
-
|
|
35
|
-
// The line Claude Code writes, kept verbatim so a summary written here is read exactly like one
|
|
36
|
-
// Claude Code wrote itself.
|
|
37
|
-
const SUMMARY_PREFIX =
|
|
38
|
-
'This session is being continued from a previous conversation. The summary below covers the ' +
|
|
39
|
-
'earlier portion of the conversation.\n\nSummary:\n';
|
|
40
|
-
|
|
41
|
-
export function claudeConfigDir(env = process.env) {
|
|
42
|
-
return env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* The file a session lives in. Claude Code names the directory after the working directory, with
|
|
47
|
-
* the separators folded into dashes, and the file after the session id.
|
|
48
|
-
*/
|
|
49
|
-
export function sessionFileFor(sessionId, cwd, env = process.env) {
|
|
50
|
-
const dir = path.join(claudeConfigDir(env), 'projects', encodeCwd(cwd));
|
|
51
|
-
return { dir, file: path.join(dir, `${sessionId}.jsonl`) };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// A gateway does not know the client's working directory: nothing in the request says so, and
|
|
55
|
-
// guessing it would write the compaction somewhere nothing reads it. The session id is enough,
|
|
56
|
-
// though, because Claude Code names the file after it and never reuses one. So the file is found
|
|
57
|
-
// by looking for it, which also means a session started somewhere else is found too.
|
|
58
|
-
export function findSessionFile(sessionId, env = process.env) {
|
|
59
|
-
if (!sessionId || !/^[A-Za-z0-9._-]{1,128}$/.test(sessionId)) return '';
|
|
60
|
-
const projects = path.join(claudeConfigDir(env), 'projects');
|
|
61
|
-
let dirs;
|
|
62
|
-
try {
|
|
63
|
-
dirs = fs.readdirSync(projects, { withFileTypes: true });
|
|
64
|
-
} catch {
|
|
65
|
-
return '';
|
|
66
|
-
}
|
|
67
|
-
for (const d of dirs) {
|
|
68
|
-
if (!d.isDirectory()) continue;
|
|
69
|
-
const f = path.join(projects, d.name, `${sessionId}.jsonl`);
|
|
70
|
-
try {
|
|
71
|
-
fs.accessSync(f, fs.constants.R_OK);
|
|
72
|
-
return f;
|
|
73
|
-
} catch { /* another project directory, keep looking */ }
|
|
74
|
-
}
|
|
75
|
-
return '';
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function encodeCwd(cwd) {
|
|
79
|
-
return String(cwd || '').replace(/[\\/:]/g, '-');
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Reads the session id out of the headers or body Claude Code sends. */
|
|
83
|
-
export function claudeSessionId(req, payload) {
|
|
84
|
-
const h = req?.headers?.['x-claude-code-session-id'];
|
|
85
|
-
if (h && typeof h === 'string' && h.trim()) return h.trim();
|
|
86
|
-
const uid = payload?.metadata?.user_id;
|
|
87
|
-
if (typeof uid === 'string') {
|
|
88
|
-
const m = /_session_([0-9a-f-]{8,})/i.exec(uid);
|
|
89
|
-
if (m) return m[1];
|
|
90
|
-
if (/^[0-9a-f-]{36}$/i.test(uid.trim())) return uid.trim();
|
|
91
|
-
}
|
|
92
|
-
return '';
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** The last entry in the chain, which is where a compaction cuts. */
|
|
96
|
-
export function readLeaf(file) {
|
|
97
|
-
let last = null;
|
|
98
|
-
let text;
|
|
99
|
-
try {
|
|
100
|
-
text = fs.readFileSync(file, 'utf8');
|
|
101
|
-
} catch {
|
|
102
|
-
return null;
|
|
103
|
-
}
|
|
104
|
-
for (const line of text.split('\n')) {
|
|
105
|
-
if (!line.trim()) continue;
|
|
106
|
-
let e;
|
|
107
|
-
try { e = JSON.parse(line); } catch { continue; }
|
|
108
|
-
if (e && typeof e.uuid === 'string') last = e;
|
|
109
|
-
}
|
|
110
|
-
return last;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Writes the two entries. Appends, because the chain is append-only and the old entries stay: the
|
|
115
|
-
* boundary makes them unreachable, and a person who resumes an old session id by hand still finds
|
|
116
|
-
* the transcript.
|
|
117
|
-
*
|
|
118
|
-
* Returns what it wrote, or null when it could not: a session file this cannot write is not a
|
|
119
|
-
* reason to fail the request. The turn is still shortened on the wire either way.
|
|
120
|
-
*/
|
|
121
|
-
export function writeCompaction({ file, summary, sessionId, cwd, preTokens, postTokens, version }) {
|
|
122
|
-
if (!file || !summary) return null;
|
|
123
|
-
const leaf = readLeaf(file);
|
|
124
|
-
if (!leaf) return null;
|
|
125
|
-
// A file that already ends in a boundary has nothing to cut again, and writing a second one
|
|
126
|
-
// would drop the summary that is already in place.
|
|
127
|
-
if (isBoundaryTail(file)) return null;
|
|
128
|
-
|
|
129
|
-
const sid = sessionId || leaf.sessionId || 'unknown';
|
|
130
|
-
const at = new Date().toISOString();
|
|
131
|
-
const base = {
|
|
132
|
-
isSidechain: false,
|
|
133
|
-
cwd: cwd || '',
|
|
134
|
-
version: version || '',
|
|
135
|
-
sessionId: sid,
|
|
136
|
-
timestamp: at,
|
|
137
|
-
};
|
|
138
|
-
const boundaryUuid = UUID();
|
|
139
|
-
const boundary = {
|
|
140
|
-
...base,
|
|
141
|
-
parentUuid: null,
|
|
142
|
-
logicalParentUuid: leaf.uuid,
|
|
143
|
-
type: 'system',
|
|
144
|
-
subtype: 'compact_boundary',
|
|
145
|
-
content: 'Conversation compacted',
|
|
146
|
-
level: 'info',
|
|
147
|
-
uuid: boundaryUuid,
|
|
148
|
-
compactMetadata: {
|
|
149
|
-
trigger: 'auto',
|
|
150
|
-
preTokens: preTokens || 0,
|
|
151
|
-
postTokens: postTokens || 0,
|
|
152
|
-
cumulativeDroppedTokens: Math.max(0, (preTokens || 0) - (postTokens || 0)),
|
|
153
|
-
durationMs: 0,
|
|
154
|
-
},
|
|
155
|
-
};
|
|
156
|
-
const summaryEntry = {
|
|
157
|
-
...base,
|
|
158
|
-
parentUuid: boundaryUuid,
|
|
159
|
-
uuid: UUID(),
|
|
160
|
-
promptId: UUID(),
|
|
161
|
-
type: 'user',
|
|
162
|
-
isCompactSummary: true,
|
|
163
|
-
message: { role: 'user', content: SUMMARY_PREFIX + summary },
|
|
164
|
-
};
|
|
165
|
-
|
|
166
|
-
const lines = [JSON.stringify(boundary), JSON.stringify(summaryEntry)].join('\n') + '\n';
|
|
167
|
-
try {
|
|
168
|
-
// Appended in place, never rewritten through a rename. Claude Code holds this file open while
|
|
169
|
-
// a session runs, so a rename would replace the file it is writing to: its next append would
|
|
170
|
-
// land on the old inode and every entry written in between would be lost. One O_APPEND write
|
|
171
|
-
// puts both entries at the end together.
|
|
172
|
-
fs.appendFileSync(file, lines, { mode: 0o600 });
|
|
173
|
-
} catch {
|
|
174
|
-
return null;
|
|
175
|
-
}
|
|
176
|
-
return { boundaryUuid, summaryUuid: summaryEntry.uuid };
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function isBoundaryTail(file) {
|
|
180
|
-
let text;
|
|
181
|
-
try {
|
|
182
|
-
text = fs.readFileSync(file, 'utf8');
|
|
183
|
-
} catch {
|
|
184
|
-
return false;
|
|
185
|
-
}
|
|
186
|
-
const lines = text.split('\n').filter(l => l.trim());
|
|
187
|
-
for (let i = lines.length - 1; i >= 0 && i >= lines.length - 40; i--) {
|
|
188
|
-
let e;
|
|
189
|
-
try { e = JSON.parse(lines[i]); } catch { continue; }
|
|
190
|
-
if (e?.type === 'system' && e?.subtype === 'compact_boundary') return true;
|
|
191
|
-
if (e?.type === 'user' && e?.isCompactSummary === true) return true;
|
|
192
|
-
}
|
|
193
|
-
return false;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
export { SUMMARY_PREFIX };
|
package/codexsession.mjs
DELETED
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
// Codex's thread store.
|
|
2
|
-
//
|
|
3
|
-
// Codex compacts by writing three things that have to agree, and all three were read off a real
|
|
4
|
-
// thread that had compacted seven times:
|
|
5
|
-
//
|
|
6
|
-
// 1. a `compacted` entry in the rollout file, carrying the summary and the history that replaces
|
|
7
|
-
// the old one;
|
|
8
|
-
// 2. a row of type contextCompaction in thread_items, whose item_json is nothing but a type and
|
|
9
|
-
// an id -- a marker, not a summary;
|
|
10
|
-
// 3. thread_history_projection_state moved forward to the byte offset and ordinal of the entry
|
|
11
|
-
// just written.
|
|
12
|
-
//
|
|
13
|
-
// The third is the one that is easy to miss, and it is why writing the first two looks like it
|
|
14
|
-
// worked and then changes nothing. thread_history_projection_state tracks how far the store has
|
|
15
|
-
// consumed the rollout file: next_rollout_byte_offset is the size of the file, exactly, and
|
|
16
|
-
// next_rollout_ordinal is the ordinal after the last entry read. The rollout file is the record;
|
|
17
|
-
// the store is a projection of it. Append to the file without moving the projection and the entry
|
|
18
|
-
// is simply unread.
|
|
19
|
-
//
|
|
20
|
-
// This writes all three, and it is opt-in, and it is opt-in because it opens Codex's database. It
|
|
21
|
-
// never deletes: earlier items stay, as they stay after Codex's own compaction, and the marker is
|
|
22
|
-
// what makes them unreachable.
|
|
23
|
-
|
|
24
|
-
import crypto from 'node:crypto';
|
|
25
|
-
import fs from 'node:fs';
|
|
26
|
-
import path from 'node:path';
|
|
27
|
-
|
|
28
|
-
const uuid = () => crypto.randomUUID();
|
|
29
|
-
|
|
30
|
-
function codexHome(env = process.env) {
|
|
31
|
-
return env.CODEX_HOME || path.join(env.HOME || '', '.codex');
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** The rollout file of the most recent thread: sessions/YYYY/MM/DD/rollout-<stamp>-<id>.jsonl */
|
|
35
|
-
export function latestRollout(env = process.env, now = new Date()) {
|
|
36
|
-
const root = codexHome(env);
|
|
37
|
-
let newest = { file: '', mtime: 0 };
|
|
38
|
-
const walk = (dir, depth) => {
|
|
39
|
-
if (depth > 4) return;
|
|
40
|
-
let entries;
|
|
41
|
-
try {
|
|
42
|
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
43
|
-
} catch {
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
46
|
-
for (const e of entries) {
|
|
47
|
-
const p = path.join(dir, e.name);
|
|
48
|
-
if (e.isDirectory()) walk(p, depth + 1);
|
|
49
|
-
else if (e.name.endsWith('.jsonl')) {
|
|
50
|
-
try {
|
|
51
|
-
const m = fs.statSync(p).mtimeMs;
|
|
52
|
-
if (m > newest.mtime) newest = { file: p, mtime: m };
|
|
53
|
-
} catch { /* vanished under us, keep looking */ }
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
};
|
|
57
|
-
walk(path.join(root, 'sessions'), 0);
|
|
58
|
-
return newest.file;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Writes a compaction into the thread Codex is working on. Returns what it wrote, or null when it
|
|
63
|
-
* wrote nothing -- which is never an error: the turn is still shortened on the wire either way.
|
|
64
|
-
*/
|
|
65
|
-
export async function writeCodexCompaction({ summary, env = process.env, now = Date.now() }) {
|
|
66
|
-
const text = String(summary || '').trim();
|
|
67
|
-
if (!text) return null;
|
|
68
|
-
|
|
69
|
-
let db;
|
|
70
|
-
try {
|
|
71
|
-
const { DatabaseSync } = await import('node:sqlite');
|
|
72
|
-
db = new DatabaseSync(path.join(codexHome(env), 'thread_history_1.sqlite'));
|
|
73
|
-
} catch {
|
|
74
|
-
return null;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
try {
|
|
78
|
-
const thread = db.prepare(
|
|
79
|
-
'SELECT thread_id FROM thread_items GROUP BY thread_id ORDER BY MAX(rollout_ordinal) DESC LIMIT 1'
|
|
80
|
-
).get();
|
|
81
|
-
if (!thread) return null;
|
|
82
|
-
const tid = thread.thread_id;
|
|
83
|
-
|
|
84
|
-
const turn = db.prepare(
|
|
85
|
-
'SELECT turn_id FROM thread_items WHERE thread_id = ? ORDER BY rollout_ordinal DESC LIMIT 1'
|
|
86
|
-
).get(tid);
|
|
87
|
-
const turnId = turn?.turn_id || uuid();
|
|
88
|
-
|
|
89
|
-
// The ordinal follows the projection, not MAX(rollout_ordinal): an entry appended to the file
|
|
90
|
-
// moves the projection's ordinal on, and the two are not the same number.
|
|
91
|
-
const proj = db.prepare(
|
|
92
|
-
'SELECT next_rollout_byte_offset AS off, next_rollout_ordinal AS ord FROM thread_history_projection_state WHERE thread_id = ?'
|
|
93
|
-
).get(tid);
|
|
94
|
-
const ordinal = proj?.ord ?? (db.prepare('SELECT MAX(rollout_ordinal) AS m FROM thread_items WHERE thread_id = ?').get(tid)?.m ?? 0) + 1;
|
|
95
|
-
|
|
96
|
-
const file = latestRollout(env);
|
|
97
|
-
if (!file) return null;
|
|
98
|
-
|
|
99
|
-
const itemId = uuid();
|
|
100
|
-
const entry = {
|
|
101
|
-
timestamp: new Date(now).toISOString(),
|
|
102
|
-
ordinal,
|
|
103
|
-
type: 'compacted',
|
|
104
|
-
payload: {
|
|
105
|
-
message: text,
|
|
106
|
-
// The summary doubles as the one message that survives, so the thread keeps something to
|
|
107
|
-
// answer from even if nothing else is carried over.
|
|
108
|
-
replacement_history: [{
|
|
109
|
-
type: 'message',
|
|
110
|
-
id: 'msg_' + uuid().replace(/-/g, '').slice(0, 24),
|
|
111
|
-
role: 'user',
|
|
112
|
-
content: [{ type: 'input_text', text }],
|
|
113
|
-
}],
|
|
114
|
-
window_number: 1,
|
|
115
|
-
},
|
|
116
|
-
};
|
|
117
|
-
const line = JSON.stringify(entry) + '\n';
|
|
118
|
-
|
|
119
|
-
// Appended in place. A rename would swap the file Codex holds open, and its next append
|
|
120
|
-
// would land on the old inode, taking everything written since with it.
|
|
121
|
-
fs.appendFileSync(file, line);
|
|
122
|
-
const size = fs.statSync(file).size;
|
|
123
|
-
|
|
124
|
-
db.prepare(
|
|
125
|
-
'INSERT INTO thread_items (thread_id, turn_id, item_id, rollout_ordinal, created_at_ms, item_json, item_type, updated_at_ordinal, started_at_ms, completed_at_ms) ' +
|
|
126
|
-
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
|
127
|
-
).run(tid, turnId, itemId, ordinal, now, JSON.stringify({ type: 'contextCompaction', id: itemId }),
|
|
128
|
-
'contextCompaction', ordinal, now, now);
|
|
129
|
-
|
|
130
|
-
// The part that is easy to leave out, and without which the first two are unread.
|
|
131
|
-
db.prepare(
|
|
132
|
-
'UPDATE thread_history_projection_state SET next_rollout_byte_offset = ?, next_rollout_ordinal = ? WHERE thread_id = ?'
|
|
133
|
-
).run(size, ordinal, tid);
|
|
134
|
-
|
|
135
|
-
db.close();
|
|
136
|
-
return { threadId: tid, ordinal, bytes: size, chars: text.length };
|
|
137
|
-
} catch {
|
|
138
|
-
try { db?.close(); } catch { /* already closed */ }
|
|
139
|
-
return null;
|
|
140
|
-
}
|
|
141
|
-
}
|