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.
- package/CHANGELOG.md +79 -0
- package/README.md +42 -3
- package/README.vi.md +42 -3
- package/claudesession.mjs +196 -0
- package/codexsession.mjs +141 -0
- package/idlecall.mjs +67 -0
- package/idlecompact.mjs +284 -0
- package/package.json +1 -1
- package/proxy.mjs +178 -1
- package/switch.mjs +54 -0
- package/tests/idlecompact.test.mjs +328 -0
- package/tests/update.test.mjs +92 -9
- package/ui.html +95 -0
- package/update.mjs +29 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,84 @@
|
|
|
1
1
|
# Changelog — LLM Switcher
|
|
2
2
|
|
|
3
|
+
## Release 1.6.0 — 2026-10-07
|
|
4
|
+
|
|
5
|
+
- **A Codex thread now keeps the shortening, behind the same opt-in switch.** A Codex compaction is
|
|
6
|
+
three writes that have to agree, all read off a thread that had compacted seven times:
|
|
7
|
+
|
|
8
|
+
1. a `compacted` entry in the rollout file, carrying the summary and the history that replaces
|
|
9
|
+
the old one;
|
|
10
|
+
2. a row of type `contextCompaction` in `thread_items`, whose `item_json` is nothing but a type
|
|
11
|
+
and an id -- a marker, not a summary;
|
|
12
|
+
3. `thread_history_projection_state` moved forward to the byte offset and ordinal of that entry.
|
|
13
|
+
|
|
14
|
+
The third is the one that is easy to leave out, and leaving it out looks exactly like success.
|
|
15
|
+
That table records how far the store has consumed the rollout file: `next_rollout_byte_offset` is
|
|
16
|
+
the size of the file, exactly, and `next_rollout_ordinal` is the ordinal after the last entry
|
|
17
|
+
read. The rollout file is the record and the store is a projection of it, so an entry appended to
|
|
18
|
+
the file without moving the projection is simply never read. With all three written, Codex
|
|
19
|
+
resumes and answers from the summary; with the first two alone it resumes and says it has none.
|
|
20
|
+
|
|
21
|
+
`idleCompact.codex` remains off by default, because this opens Codex's database. With no summary
|
|
22
|
+
model named, no model is called: the request still goes out shortened, which is the whole of the
|
|
23
|
+
saving, and nothing is spent to earn it.
|
|
24
|
+
|
|
25
|
+
## Release 1.5.3 — 2026-10-07
|
|
26
|
+
|
|
27
|
+
- **Idle compaction no longer calls a model unless one was named for it.** With no summary model set,
|
|
28
|
+
a summary was written with the conversation's own model — the expensive one, on an official
|
|
29
|
+
account, and the one with limits. That spends the costly model to save the tokens of a single
|
|
30
|
+
turn, and on a plan with limits it is worse than doing nothing.
|
|
31
|
+
|
|
32
|
+
There was no point paying for it either: the summary is used only to write the session file, so
|
|
33
|
+
with no model named there was nothing to store and the call bought nothing. Now no model is called
|
|
34
|
+
at all. The saving is unchanged — the request still leaves shortened, which is the whole of what
|
|
35
|
+
this feature costs and all of what it saves without a model. Set one with
|
|
36
|
+
`switch compact model <id>` to also make the shortening last.
|
|
37
|
+
|
|
38
|
+
## Release 1.5.2 — 2026-10-07
|
|
39
|
+
|
|
40
|
+
- **Codex conversations can be shortened on an idle return too, behind its own switch.**
|
|
41
|
+
`idleCompact.codex`, off by default. The history is shortened in the request, which is
|
|
42
|
+
where a Responses client keeps it: a Responses client reaches a Responses provider as the
|
|
43
|
+
bytes it sent, so there is nothing to render through. Measured on a 25 KB conversation,
|
|
44
|
+
6 KB goes out.
|
|
45
|
+
|
|
46
|
+
The thread itself is left to Codex. Its history is a SQLite store of its own, and a
|
|
47
|
+
compaction there needs two halves: a `contextCompaction` row in `thread_items`, whose
|
|
48
|
+
`item_json` is nothing but a type and an id, and a `compacted` entry in the rollout file
|
|
49
|
+
carrying the summary. Both were written, in the shape a real compaction takes, and Codex
|
|
50
|
+
resumed as if neither existed. Something else in that store says where the summary begins;
|
|
51
|
+
it was not found. So the summary is produced and the request goes out short, which saves
|
|
52
|
+
the tokens of this turn, and no claim is made that the thread stays short.
|
|
53
|
+
|
|
54
|
+
## Release 1.5.1 — 2026-10-07
|
|
55
|
+
|
|
56
|
+
- **Idle compaction is now in the dashboard.** A card on the Routes page carries the switch, the pause that counts as losing the cache, the size it must exceed, and the model that writes the summary. It reads and writes `/api/idle-compact`, the same endpoint and the same key `switch compact` uses, so the page and the terminal cannot show two different settings.
|
|
57
|
+
|
|
58
|
+
One bug came out of writing the endpoint: the field types were checked as groups, so `{"enabled":"yes"}` was accepted and then read as `false` — a typo switched the feature off without a word. Each field is now checked as the type it actually is, and a wrong type is refused rather than quietly turned into the default.
|
|
59
|
+
|
|
60
|
+
## Release 1.5.0 — 2026-10-07
|
|
61
|
+
|
|
62
|
+
- **A conversation that comes back after a pause no longer pays full price for a cache nobody holds.** When a Claude Code session resumes after being idle long enough, and the context is large enough to be worth it, the gateway shortens the history and writes the compaction into the session file. Off by default. `switch compact on`, and `switch compact model <id>` for the model that writes the summary (empty means the conversation's own model).
|
|
63
|
+
|
|
64
|
+
Two decisions worth naming, because the obvious alternatives are worse.
|
|
65
|
+
|
|
66
|
+
Compacting once, not every turn. Changing the prefix on every turn means no turn ever hits the cache and the conversation is summarized over and over. Compacting when a pause says the cache is gone keeps the summary stable for the turns that follow.
|
|
67
|
+
|
|
68
|
+
The session file, not the API. Claude Code builds every request from the entry chain in `~/.claude/projects/<project>/<session>.jsonl`, so a compaction written there shortens every request after it rather than only the turn that triggered it. The two entries are the ones Claude Code writes itself, read off a real session: a `compact_boundary` and a `user` entry marked `isCompactSummary`. Old entries stay in the file, as they do after Claude Code's own compaction, because a transcript a person can still read is worth more than a small file.
|
|
69
|
+
|
|
70
|
+
Codex and the Antigravity CLI are deliberately untouched. Codex compaction is a protocol item the provider gateway answers and Codex stores in its own history, so shortening a Codex request here would replace that with a summary no client records. The CLI has no session file to write.
|
|
71
|
+
|
|
72
|
+
Tests: 14, including one that runs the real Claude Code binary against a local server and reads the request it sends. With twelve synthetic turns behind a boundary, that request carries the summary and none of the twelve.
|
|
73
|
+
|
|
74
|
+
## Release 1.4.1 — 2026-10-07
|
|
75
|
+
|
|
76
|
+
- **The update comes from npm, because that is where a release is published.** The gateway asked npm what the newest version is and ran `npm install -g llm-switcher@<version>` for exactly that version. Before, the install decided: a directory with a `.git` pulled and never asked npm, and the dashboard's Update now button read as a git operation on a project that ships to npm.
|
|
77
|
+
- **A git checkout is the one install npm cannot serve, and it says so.** npm writes into the global prefix while the running code is the checkout itself, so the install lands somewhere nothing runs. The checkout is then asked, and it pulls fast-forward only. When npm held a release the checkout could not take, the notice names that version and what the checkout has, because "already the latest version" on its own would hide the one version the reader does not have.
|
|
78
|
+
- **A failed npm install no longer fails the update of a checkout.** A checkout used to update without npm in the way. Asking npm first put an install between the reader and a pull, and any failure of that install -- a version the registry does not serve, a network, a read-only prefix -- ended the update even though the checkout could still have moved. Now a checkout reports what npm did and goes on to pull; an npm install with no checkout still fails loudly, because there is nothing else to try.
|
|
79
|
+
|
|
80
|
+
Tests: 17 in `tests/update.test.mjs`, plus the rest of the suite.
|
|
81
|
+
|
|
3
82
|
## Release 1.4.0 — 2026-10-07
|
|
4
83
|
|
|
5
84
|
- **A model answers with the tool names it was trained on, and the caller only runs its own.** A Codex model says `shell` where Claude Code declared `Bash`, and the turn ends with `Error: No such tool available: shell`. The gateway now writes the call back in the vocabulary the caller declared, and says the names in the prompt so most calls never need rewriting.
|
package/README.md
CHANGED
|
@@ -550,14 +550,53 @@ The service runs without your shell. `switch service install` therefore copies `
|
|
|
550
550
|
|
|
551
551
|
On Linux the unit is a systemd *user* service: it starts when you log in. To start it at boot without a login (a server reached over SSH), run `loginctl enable-linger $USER` once. The unit records the absolute path of the current `node`; if you manage Node with nvm and remove that version, run `switch service install` again.
|
|
552
552
|
|
|
553
|
+
### Idle compaction
|
|
554
|
+
|
|
555
|
+
A conversation that pauses long enough loses the provider's prompt cache. The next request pays full
|
|
556
|
+
price for a prefix the provider no longer holds, and pays it again on the turn after that. How long
|
|
557
|
+
a provider keeps its cache is its own business: Anthropic 5 minutes by default and 1 hour when the
|
|
558
|
+
request asks for it, OpenAI 30 minutes, Gemini and DeepSeek publish no lifetime at all.
|
|
559
|
+
|
|
560
|
+
When a Claude Code conversation comes back after a pause and is large enough to be worth it, the
|
|
561
|
+
gateway shortens the history and writes the compaction into the session file. The turn that would
|
|
562
|
+
have cost the whole history costs the summary instead.
|
|
563
|
+
|
|
564
|
+
| | |
|
|
565
|
+
| --- | --- |
|
|
566
|
+
| `switch compact status` | Show the settings |
|
|
567
|
+
| `switch compact on` / `off` | Turn it on or off (off by default) |
|
|
568
|
+
| `switch compact model <id>` | Which model writes the summary. Empty or `default`: the model of the conversation itself |
|
|
569
|
+
| `switch compact idle <minutes>` | How long a pause counts as losing the cache (15) |
|
|
570
|
+
| `switch compact min <KB>` | How large a conversation must be to be worth it (64) |
|
|
571
|
+
|
|
572
|
+
Two things are deliberate.
|
|
573
|
+
|
|
574
|
+
**Only once, not on every turn.** Compacting every turn would change the prefix every turn, so no
|
|
575
|
+
turn would ever hit the cache and the conversation would be summarized over and over. Compacting
|
|
576
|
+
when a pause says the cache is gone keeps the summary stable for the turns that follow.
|
|
577
|
+
|
|
578
|
+
**The session file, not the API.** Claude Code builds every request from a chain of entries in
|
|
579
|
+
`~/.claude/projects/<project>/<session>.jsonl`, so a compaction written there shortens every
|
|
580
|
+
request after it, not just the turn that triggered it. The two entries are the ones Claude Code
|
|
581
|
+
writes itself: a `compact_boundary` and a `user` entry marked `isCompactSummary`. The old entries
|
|
582
|
+
stay in the file, which is what Claude Code does too, so a transcript a person can still read is
|
|
583
|
+
worth more than a small file. The shortening takes effect the next time the session is resumed.
|
|
584
|
+
|
|
585
|
+
A free model writes a free summary and a worse one: the summary replaces the history, so what it
|
|
586
|
+
leaves out is gone. That is the reader's call, which is why the model is a setting.
|
|
587
|
+
|
|
588
|
+
Codex is not handled here. Its compaction is a protocol item rather than a file, which the provider
|
|
589
|
+
gateway answers, and the answer is stored by Codex in its own history. Shortening a Codex request at
|
|
590
|
+
the client would replace that with a summary no client records. The Antigravity CLI has no session
|
|
591
|
+
file of its own, so there would be nothing to make the shortening last.
|
|
592
|
+
|
|
553
593
|
### Updates
|
|
554
594
|
|
|
555
595
|
When npm has a newer release, the dashboard shows a notice. Click **Update now** in this notice. The gateway installs the release and starts the new code. Then the dashboard reloads. `switch update` does the same from a terminal.
|
|
556
596
|
|
|
557
|
-
The gateway installs a release
|
|
597
|
+
The gateway installs a release from npm, because that is where a release is published. It asks the registry for the newest version and runs `npm install -g llm-switcher@<version>`, then restarts into it.
|
|
558
598
|
|
|
559
|
-
|
|
560
|
-
- An npm install runs `npm install -g llm-switcher@<latest>`.
|
|
599
|
+
A git checkout is the one install npm cannot serve: npm writes into the global prefix while the running code is the checkout itself. So when npm did not move the directory the gateway runs from, and that directory is a checkout, the checkout is asked next, and it runs `git pull --ff-only`. If the checkout has local changes, or commits that are not on the upstream branch, the update stops and changes nothing. When npm held a release the checkout could not take, the notice says both: which version npm has, and that the checkout has nothing.
|
|
561
600
|
|
|
562
601
|
Requests that are open on the gateway when the update starts finish on the old code. New requests go to the new code. The Codex interceptor stops, and the new gateway starts it again. Thus a Codex request that is open at that moment fails one time. The old process stays as the parent of the new process until the gateway stops. Thus a service manager keeps the process that it started.
|
|
563
602
|
|
package/README.vi.md
CHANGED
|
@@ -548,14 +548,53 @@ Service không chạy trong shell của bạn. Vì vậy `switch service install
|
|
|
548
548
|
|
|
549
549
|
Trên Linux, unit là service systemd *của user*: nó khởi động khi bạn đăng nhập. Muốn nó chạy ngay khi máy boot mà không cần đăng nhập (ví dụ server truy cập qua SSH), chạy một lần `loginctl enable-linger $USER`. Unit ghi đường dẫn tuyệt đối của `node` hiện tại; nếu bạn quản lý Node bằng nvm và gỡ phiên bản đó, hãy chạy lại `switch service install`.
|
|
550
550
|
|
|
551
|
+
### Nén hội thoại khi để lâu
|
|
552
|
+
|
|
553
|
+
Một hội thoại để lâu đủ thì sẽ mất cache của provider. Request kế tiếp phải trả full price cho một
|
|
554
|
+
prefix mà provider không còn giữ, và lượt sau đó lại trả tiếp. Mỗi provider giữ cache bao lâu là
|
|
555
|
+
việc của riêng họ: Anthropic 5 phút mặc định, 1 giờ nếu request yêu cầu; OpenAI 30 phút; Gemini và
|
|
556
|
+
DeepSeek không công bố thời hạn nào.
|
|
557
|
+
|
|
558
|
+
Khi một hội thoại Claude Code quay lại sau một khoảng nghỉ, và đủ lớn để đáng xử lý, gateway rút gọn
|
|
559
|
+
lịch sử và ghi kết quả nén vào session file. Lượt lẽ ra phải tốn cả lịch sử giờ chỉ tốn phần tóm
|
|
560
|
+
tắt.
|
|
561
|
+
|
|
562
|
+
| | |
|
|
563
|
+
| --- | --- |
|
|
564
|
+
| `switch compact status` | Xem cấu hình |
|
|
565
|
+
| `switch compact on` / `off` | Bật hoặc tắt (mặc định tắt) |
|
|
566
|
+
| `switch compact model <id>` | Model nào viết phần tóm tắt. Rỗng hoặc `default`: dùng model của chính hội thoại đó |
|
|
567
|
+
| `switch compact idle <phút>` | Nghỉ bao lâu thì coi là mất cache (15) |
|
|
568
|
+
| `switch compact min <KB>` | Hội thoại phải lớn bao nhiêu mới đáng nén (64) |
|
|
569
|
+
|
|
570
|
+
Hai điều là cố ý.
|
|
571
|
+
|
|
572
|
+
**Chỉ một lần, không nén mọi lượt.** Nén mỗi lượt sẽ làm prefix đổi mỗi lượt, nên không lượt nào
|
|
573
|
+
trúng cache, và hội thoại bị tóm tắt đi đi lại. Nén đúng lúc một khoảng nghỉ báo đã mất cache thì
|
|
574
|
+
phần tóm tắt giữ nguyên cho các lượt sau.
|
|
575
|
+
|
|
576
|
+
**Ghi vào session file, không qua API.** Claude Code dựng mọi request từ chuỗi entry trong
|
|
577
|
+
`~/.claude/projects/<project>/<session>.jsonl`, nên một lần nén ghi vào đó rút gọn **mọi** request sau
|
|
578
|
+
đó, chứ không chỉ lượt đã kích hoạt. Hai entry đúng bằng hai entry Claude Code tự ghi: một
|
|
579
|
+
`compact_boundary` và một entry `user` mang cờ `isCompactSummary`. Các entry cũ vẫn nằm trong file —
|
|
580
|
+
Claude Code cũng vậy — nên một bản transcript người ta vẫn đọc được đáng giá hơn một file nhỏ. Việc
|
|
581
|
+
rút gọn có hiệu lực từ lần resume kế tiếp của phiên đó.
|
|
582
|
+
|
|
583
|
+
Model rẻ viết phần tóm tắt rẻ và tệ hơn: tóm tắt thay cả lịch sử, nên thứ nó bỏ sót là mất hẳn.
|
|
584
|
+
Đó là quyết định của người đọc, nên nó là một setting.
|
|
585
|
+
|
|
586
|
+
Codex không xử lý ở đây. Nén của Codex là một item protocol chứ không phải file, provider gateway trả
|
|
587
|
+
lời, và Codex tự lưu vào history của nó. Rút gọn request Codex ở phía client sẽ thay bằng một phần
|
|
588
|
+
tóm tắt mà không client nào ghi lại. Antigravity CLI không có session file riêng, nên không có gì để
|
|
589
|
+
làm cho việc rút gọn kéo dài.
|
|
590
|
+
|
|
551
591
|
### Cập nhật
|
|
552
592
|
|
|
553
593
|
Khi npm có bản mới, dashboard hiện một thông báo. Bấm **Update now** trong thông báo này. Gateway cài bản mới và chạy code mới. Sau đó dashboard tự tải lại. `switch update` làm việc tương tự từ terminal.
|
|
554
594
|
|
|
555
|
-
Gateway cài bản mới
|
|
595
|
+
Gateway cài bản mới từ npm, vì đó là nơi bản phát hành được đăng. Nó hỏi registry lấy số phiên bản mới nhất rồi chạy `npm install -g llm-switcher@<phiên bản>`, sau đó khởi động lại vào bản đó.
|
|
556
596
|
|
|
557
|
-
|
|
558
|
-
- Bản cài bằng npm chạy `npm install -g llm-switcher@<latest>`.
|
|
597
|
+
Bản git checkout là thứ duy nhất npm không phục vụ được: npm ghi vào global prefix, trong khi code đang chạy chính là thư mục checkout. Nên khi npm không dịch chuyển được thư mục mà gateway đang chạy, và thư mục đó là một checkout, gateway hỏi tiếp checkout, và checkout chạy `git pull --ff-only`. Nếu checkout có thay đổi cục bộ, hoặc có commit không nằm trên nhánh upstream, việc cập nhật dừng lại và không đổi gì. Khi npm có bản mới mà checkout không lấy được, thông báo nói cả hai: npm đang có số phiên bản nào, và checkout không có gì để cập nhật.
|
|
559
598
|
|
|
560
599
|
Các request đang chạy trên gateway lúc cập nhật sẽ chạy xong trên code cũ. Request mới đi vào code mới. Interceptor của Codex dừng lại, và gateway mới khởi động nó lại. Vì vậy một request Codex đang chạy đúng lúc đó sẽ lỗi một lần. Tiến trình cũ ở lại làm tiến trình cha của tiến trình mới cho tới khi gateway dừng. Nhờ vậy service manager vẫn giữ đúng tiến trình mà nó đã khởi động.
|
|
561
600
|
|
|
@@ -0,0 +1,196 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
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
|
+
}
|
package/idlecall.mjs
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// The summary call.
|
|
2
|
+
//
|
|
3
|
+
// One extra model call, made once, when a long conversation comes back after a pause. It is not
|
|
4
|
+
// free and it is not fast, and both costs are the price of not paying for the history on every
|
|
5
|
+
// turn of the new window.
|
|
6
|
+
//
|
|
7
|
+
// It is a separate call rather than a turn of the conversation because the answer serves two
|
|
8
|
+
// readers with different needs: the person waiting for the answer to their own question, and the
|
|
9
|
+
// agent that will read the summary next time. Folding them together would mean either the person
|
|
10
|
+
// reads a summary instead of an answer, or the summary is a summary of a summary.
|
|
11
|
+
//
|
|
12
|
+
// The model it uses is a setting, and empty means the one this conversation is already talking
|
|
13
|
+
// to. A cheaper model writes a cheaper summary and a worse one: the summary replaces the history,
|
|
14
|
+
// so what it leaves out is gone. That is the reader's call to make, not this gateway's.
|
|
15
|
+
|
|
16
|
+
import { clampSummary, summaryMessages } from './idlecompact.mjs';
|
|
17
|
+
|
|
18
|
+
/** Asks for the summary. Returns the text, or '' when nothing usable came back. */
|
|
19
|
+
export async function askSummary({ ir, policy, profile, mappedModel, model, build, fetchImpl = fetch, timeoutMs = 180000 }) {
|
|
20
|
+
const msgs = summaryMessages(ir, policy);
|
|
21
|
+
if (!msgs.length) return '';
|
|
22
|
+
|
|
23
|
+
const ir2 = { ...ir, messages: msgs, stream: false, tools: [], toolChoice: null };
|
|
24
|
+
const { url, headers, upBody } = build(profile, model, ir2);
|
|
25
|
+
const ac = new AbortController();
|
|
26
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
27
|
+
try {
|
|
28
|
+
const res = await fetchImpl(url, {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
headers,
|
|
31
|
+
body: JSON.stringify(upBody),
|
|
32
|
+
signal: ac.signal,
|
|
33
|
+
});
|
|
34
|
+
if (!res.ok) return '';
|
|
35
|
+
const raw = await res.text();
|
|
36
|
+
return clampSummary(answerText(raw, ir2), policy);
|
|
37
|
+
} catch {
|
|
38
|
+
// A summary that could not be produced is not a reason to fail the turn. The caller still
|
|
39
|
+
// shortens the request, because a shortened history is worth having even with nothing
|
|
40
|
+
// written to the session file.
|
|
41
|
+
return '';
|
|
42
|
+
} finally {
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The answer's text, from whichever shape came back.
|
|
48
|
+
function answerText(raw, ir) {
|
|
49
|
+
let doc;
|
|
50
|
+
try { doc = JSON.parse(raw); } catch { return ''; }
|
|
51
|
+
if (Array.isArray(doc?.content)) {
|
|
52
|
+
return doc.content.filter(b => b?.type === 'text').map(b => b.text || '').join('\n');
|
|
53
|
+
}
|
|
54
|
+
if (doc?.choices?.[0]?.message?.content) return String(doc.choices[0].message.content);
|
|
55
|
+
if (Array.isArray(doc?.output)) {
|
|
56
|
+
let out = '';
|
|
57
|
+
for (const item of doc.output) {
|
|
58
|
+
if (typeof item?.text === 'string') out += item.text;
|
|
59
|
+
for (const c of item?.content || []) if (c?.type === 'output_text' || c?.type === 'text') out += c.text || '';
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
if (Array.isArray(doc?.candidates)) {
|
|
64
|
+
return doc.candidates.map(c => (c?.content?.parts || []).map(p => p.text || '').join('')).join('\n');
|
|
65
|
+
}
|
|
66
|
+
return '';
|
|
67
|
+
}
|