@chroxify/eunoe 0.0.0-stage → 0.1.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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +327 -2
  3. package/package.json +41 -4
  4. package/src/cli/args.ts +17 -0
  5. package/src/cli/claude-settings.ts +42 -0
  6. package/src/cli/commands/index.ts +9 -0
  7. package/src/cli/commands/install.ts +44 -0
  8. package/src/cli/commands/mode.ts +30 -0
  9. package/src/cli/commands/serve.ts +19 -0
  10. package/src/cli/commands/status.ts +48 -0
  11. package/src/cli/commands/stop.ts +16 -0
  12. package/src/cli/commands/uninstall.ts +25 -0
  13. package/src/cli/constants.ts +29 -0
  14. package/src/cli/help.ts +42 -0
  15. package/src/cli/inspect.ts +41 -0
  16. package/src/cli/launchd.ts +34 -0
  17. package/src/cli/logo/index.ts +89 -0
  18. package/src/cli/logo/pixels.ts +34 -0
  19. package/src/cli/main.ts +23 -0
  20. package/src/cli/types.ts +13 -0
  21. package/src/cli/ui.ts +91 -0
  22. package/src/config/constants.ts +27 -0
  23. package/src/config/index.ts +49 -0
  24. package/src/config/types.ts +21 -0
  25. package/src/context/constants.ts +7 -0
  26. package/src/context/content.ts +24 -0
  27. package/src/context/prompts.ts +66 -0
  28. package/src/context/strategies.ts +89 -0
  29. package/src/context/turns.ts +82 -0
  30. package/src/context/types.ts +24 -0
  31. package/src/proxy/compaction.ts +93 -0
  32. package/src/proxy/constants.ts +19 -0
  33. package/src/proxy/estimate.ts +26 -0
  34. package/src/proxy/log.ts +9 -0
  35. package/src/proxy/rewrite.ts +72 -0
  36. package/src/proxy/server.ts +120 -0
  37. package/src/proxy/state.ts +27 -0
  38. package/src/proxy/types.ts +58 -0
  39. package/src/proxy/usage.ts +44 -0
  40. package/src/proxy/window.ts +50 -0
  41. package/src/transcript/constants.ts +16 -0
  42. package/src/transcript/format.ts +68 -0
  43. package/src/transcript/locate.ts +28 -0
  44. package/src/transcript/parse.ts +93 -0
  45. package/src/transcript/render.ts +36 -0
  46. package/src/transcript/search.ts +80 -0
  47. package/src/transcript/types.ts +43 -0
  48. package/src/version.ts +3 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Christo Todorov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,328 @@
1
- # Temporary Holding Version
1
+ <div align="center">
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ ![eunoe, an axolotl holding a glowing pearl](assets/logo.svg)
4
+
5
+ # eunoe
6
+
7
+ **Context management for Claude Code that never forgets what matters.**
8
+
9
+ A local proxy that replaces Claude Code's context compaction. No summary call,
10
+ no two-minute pause, no agent that wakes up having forgotten what you told it.
11
+
12
+ </div>
13
+
14
+ > At the top of Dante's Purgatory two rivers run from one spring. **Lethe**
15
+ > washes away the memory of what weighed on you. **Eunoe** — *good mind* —
16
+ > gives back the memory of the good. You drink from both. Lethe first.
17
+
18
+ ---
19
+
20
+ ## The problem
21
+
22
+ Fill up a long Claude Code session and it compacts. The agent writes a summary
23
+ of everything so far, throws the conversation away, and continues from the
24
+ summary.
25
+
26
+ You can feel it the moment it happens.
27
+
28
+ ```
29
+ you (turn 6) from now on, deploy to staging yourself, don't ask me
30
+ ...
31
+ you (turn 41) ok ship it
32
+ agent Here's the deploy command — want me to run it?
33
+ ```
34
+
35
+ The instruction was five turns old and three paragraphs into a 21,000-character
36
+ summary. So were the exact version ID from the last deploy, the error you spent
37
+ two turns tracking down, and the approach you already ruled out.
38
+
39
+ Compaction keeps the gist. The gist is not what you need.
40
+
41
+ It also costs real time. A measured compaction at 922k tokens: **the full
42
+ context read for the summary call, 2½ minutes of generation**, then a brand-new
43
+ prefix written to cache anyway.
44
+
45
+ ## Install
46
+
47
+ ```sh
48
+ bun add -g @chroxify/eunoe # puts `eunoe` on your PATH
49
+ eunoe install # background proxy + ANTHROPIC_BASE_URL in ~/.claude/settings.json
50
+ ```
51
+
52
+ Then use Claude Code exactly as before. New sessions go through eunoe; restart
53
+ any that are already running. `eunoe uninstall` reverses it.
54
+
55
+ Requires [Bun](https://bun.sh). `install` sets up a launchd agent on macOS; on
56
+ other platforms, keep `eunoe serve` running however you like (systemd, a
57
+ terminal) and the rest works the same.
58
+
59
+ ## What it does instead
60
+
61
+ Nothing, until the context is nearly full. Every request up to that point goes
62
+ upstream byte for byte as Claude Code built it.
63
+
64
+ At the limit, eunoe rewrites the conversation once. Your first message stays.
65
+ Every turn after it collapses to **what you asked and what the agent answered**.
66
+ The turn in progress is kept whole, down to the last tool call.
67
+
68
+ ```
69
+ WHAT CLAUDE CODE HOLDS (~900k) WHAT eunoe SENDS
70
+ ────────────────────────────── ────────────────────────────────────────
71
+ turn 1 first message turn 1 first message, byte for byte
72
+ + the agent's work + reply ┌──────────────────────────────────────┐
73
+ turn 2 prompt, 14 tool calls, reply │ <context-compacted> │
74
+ turn 3 prompt, 6 tool calls, reply │ Tool calls before this point were │
75
+ ⋮ │ removed. Earlier turns follow as │
76
+ turn 38 prompt, 9 tool calls ✋ │ your message + the final reply. │
77
+ (you interrupted mid-work) │ Instructions you gave still apply. │
78
+ turn 39 prompt, 3 tool calls, reply │ Exact details → search the │
79
+ turn 40 prompt, 22 tool calls ← LIMIT │ transcript: <how> │
80
+ └──────────────────────────────────────┘
81
+ turn 2 your message → final reply
82
+ turn 3 your message → final reply
83
+ ⋮ every turn, ~580 tokens each
84
+ turn 38 your message → [interrupted:
85
+ last words + its tool calls]
86
+ turn 39 your message → final reply
87
+ turn 40 your message + every tool call
88
+ and result, exactly as it was
89
+ turn 41+ grows normally until the next cut
90
+ ```
91
+
92
+ **Every turn survives.** A message plus its final reply averages ~580 tokens, so
93
+ a hundred turns is ~60k. There is no reason to throw them away. This is what
94
+ keeps *"what did we do about the Stripe thing"* and *"you told me to deploy it
95
+ yourself"* answerable after a cut.
96
+
97
+ **The first message is kept verbatim.** Claude Code packs `CLAUDE.md`, your git
98
+ status, your identity and the skill list into it. Dropping it drops your
99
+ instructions. Its hash also travels in a billing header, so eunoe never edits it.
100
+
101
+ **The turn in progress is untouched.** The agent is mid-work: it needs the actual
102
+ tool calls, their output and its own thinking blocks to carry on. If you
103
+ interrupted it, the interruption marker is right there too, so it resumes from
104
+ where it stopped rather than starting the turn over.
105
+
106
+ ## How it plugs in
107
+
108
+ ```
109
+ Claude CLI ─┐
110
+ Desktop app ┼──► eunoe (127.0.0.1:8788) ──► api.anthropic.com
111
+ Agent SDK ──┘ rewrites /v1/messages
112
+ passes everything else straight through
113
+ ```
114
+
115
+ Claude Code honours `ANTHROPIC_BASE_URL` from its `settings.json`, so every
116
+ harness built on it works with no code changes. Auth headers are forwarded
117
+ untouched — your existing login keeps working, subscription or API key.
118
+
119
+ Claude Code still keeps the whole conversation in memory and on disk at
120
+ `~/.claude/projects/<project>/<session>.jsonl`. eunoe only changes what goes out
121
+ on the wire. That untouched file is the transcript the agent gets pointed at.
122
+
123
+ ## Modes
124
+
125
+ | mode | before the limit | at the limit |
126
+ |---|---|---|
127
+ | **`compact`** (default) | everything, unchanged | the cut, above |
128
+ | **`trim`** | every finished turn is already just your message + the final reply | rarely reached |
129
+ | **`off`** | passthrough | Claude Code's own compaction |
130
+
131
+ `trim` is the aggressive option: tool output leaves context the moment a turn
132
+ ends, so a long session never grows past a few tens of thousands of tokens. It
133
+ is noticeably cheaper and, in benchmarks, no less accurate — but the agent has
134
+ to search its transcript more often, so turns start slower.
135
+
136
+ ```sh
137
+ eunoe mode compact # or trim, or off
138
+ ```
139
+
140
+ Whichever is on owns compaction: eunoe disables Claude Code's auto-compact while
141
+ active and hands it back on `off` or `uninstall`.
142
+
143
+ ## What counts as a final reply
144
+
145
+ A **turn** starts with a message you sent — text or an image, not a tool result —
146
+ and runs until your next one.
147
+
148
+ Its **final reply** is the last assistant message of that turn, if the turn ended
149
+ on text with no tool call in it. Only the text is kept; thinking is dropped.
150
+
151
+ Across 18,966 real turns:
152
+
153
+ | how the turn ended | share | what eunoe keeps |
154
+ |---|---|---|
155
+ | on a text reply | 73% | that reply (median 1.9k chars; 57% name a file path) |
156
+ | still running | 17% | nothing — it's the live turn, never reduced |
157
+ | interrupted or steered | 10% | last words + the list of tool calls |
158
+
159
+ Only 1% of finished work turns end in a reply shorter than 120 characters.
160
+ Agents already close turns with something usable; eunoe just stops throwing it
161
+ away.
162
+
163
+ In `trim` mode the system prompt tells the agent its final reply is the only
164
+ thing it carries forward, and what belongs in it.
165
+
166
+ ## Interruptions and steering
167
+
168
+ Interrupted turns are where naive summarisation loses the most, so they get
169
+ handled explicitly.
170
+
171
+ | what happened | how eunoe treats it |
172
+ |---|---|
173
+ | **Esc mid-work**, then a new message | The interrupted turn ends there. Your new message, marker included, opens the next turn. |
174
+ | **Typing while the agent works** | Claude Code sends a mid-conversation `system` message. It stays inside the running turn; in a reduced turn it is merged in right before the final reply. |
175
+ | **An interrupted turn gets reduced** | No final reply exists, so eunoe writes one: `[This turn was interrupted before a final reply.] Last thing you said: … Tool calls (12): Edit src/app.ts; Bash \`bun test\`; …` |
176
+ | **The cut lands on an interrupted turn** | Kept verbatim. The agent sees exactly where it stopped. |
177
+ | **Esc-Esc rewind past the cut** | eunoe notices its cut turn is gone from the history and starts over from whatever Claude Code sends. |
178
+
179
+ ## Searching the transcript
180
+
181
+ Nothing is lost, so the agent is told where everything is and how to look.
182
+ `search` picks the form:
183
+
184
+ | `search` | what the agent gets |
185
+ |---|---|
186
+ | `jsonl` | Claude Code's raw session file. Complete, but one long JSON-escaped line per entry. |
187
+ | **`markdown`** (default) | `index.md` — one line per turn, what was asked and how it ended — plus `turn-0001.md`… with the message, every tool call tagged `[tool 3/12]` with its output, and the final reply. |
188
+ | `xml` | The same, as `<turn>` / `<user>` / `<tool n name>` / `<output>` / `<final_reply>`. Easier for the model to parse, slightly harder to grep. |
189
+ | `qmd` | The markdown folder indexed by [qmd](https://github.com/tobi/qmd) for semantic search, for when the agent doesn't know the exact words. |
190
+
191
+ Rendering is fast and idempotent — a 199-turn session takes 0.13s — so it just
192
+ runs again as the session grows. qmd's embedding pass takes ~83s and runs in the
193
+ background; queries take 3–20s.
194
+
195
+ ## Prompt caching
196
+
197
+ A compaction scheme that breaks the cache costs more than it saves. Cache writes
198
+ are 1.25× the input price and reads are 0.05×, so rewriting a cached prefix is
199
+ 25× more expensive than reading it.
200
+
201
+ eunoe's output is deterministic. The same history always produces the same bytes,
202
+ a reduced turn always reduces identically, and nothing time-based is ever
203
+ inserted. The prefix changes only at a cut.
204
+
205
+ ```
206
+ turns 1–39 byte-identical to no proxy at all → cache hits as usual
207
+ turn 40 CUT: one new prefix, written once → one cache write
208
+ turns 41+ same prefix + new turns appended → reads hit again
209
+ ```
210
+
211
+ Measured through the proxy, right after a cut: **180k tokens read from cache,
212
+ 1.5k written.** Claude Code's own compaction instead reads the entire ~900k
213
+ context into a summary call and *then* writes a new prefix.
214
+
215
+ ## Edge cases
216
+
217
+ The interesting ones are all about what happens when the cut itself doesn't fit.
218
+
219
+ | case | behaviour |
220
+ |---|---|
221
+ | **Where a cut lands** | It aims for 30% of the room available — the window minus the fixed system prompt and tool definitions. Land at 70% and you compact again two turns later. |
222
+ | **The cut doesn't fit** | Shrink in order, stopping as soon as it fits. **1.** Drop reduced turns, oldest first. **2.** Only then clear the *oldest* tool outputs of the live turn, half of what remains per step, shortening oversized inputs too. |
223
+ | **Never removed from the live turn** | Your message, the agent's own text, every tool call, and the **latest 5 tool outputs** — what it is actually working from. |
224
+ | **Compaction loops** | Impossible by construction. A cut only ever moves to a newer turn, every shrink step only removes more, and each step is persisted so the bytes stay cache-stable. |
225
+ | **Nothing left to remove** | Sends anyway and logs `stuck`. |
226
+ | **Context window** | Asked once per model via the Models API (`max_input_tokens`) and cached in `~/.eunoe/models.json`. If the lookup fails the window is unknown and **eunoe does not compact** — it won't cut against a limit it can't verify. |
227
+ | **Images and PDFs** | Counted at ~1.6k tokens each, not at their base64 length. |
228
+ | **Subagents, titles, side calls** | Each conversation is its own thread (session id + a fingerprint of its first turn) with its own cut. |
229
+ | **Mid-conversation system messages** | Never dropped. The API only accepts them directly before an assistant message, so they are merged into that position. |
230
+ | **Thinking blocks** | Kept with their signatures in the live turn, where tool use requires them. Dropped from reduced turns. |
231
+ | **eunoe is down** | Every Claude request fails. launchd keeps it alive; `uninstall` leaves it running as a passthrough so sessions already pointed at it survive, and `stop` removes it once they're restarted. |
232
+
233
+ ## Early results
234
+
235
+ A full, pre-registered benchmark is in progress and will be published with its
236
+ harness, raw data and every per-point result. Until then, here is what an
237
+ earlier internal run showed, with its limits stated.
238
+
239
+ **Setup.** 45 cut points across 14 projects, from one developer's real Claude
240
+ Code history: real auto-compactions, plus long sessions cut where they crossed
241
+ 350k tokens. At each point, every strategy answered the same 10 questions about
242
+ the session — recent details, what was worked on 5–15 turns earlier, standing
243
+ instructions, decisions, and what was in progress — through a real Claude Code
244
+ session, with read-only access to a transcript that stopped at the cut. Answers
245
+ were judged blind.
246
+
247
+ | strategy | score | context after the cut | vs native, 95% CI |
248
+ |---|---|---|---|
249
+ | Claude Code's compaction | 84% | 82k | — |
250
+ | **eunoe** | **95%** | 183k | **+11.0%** [+8.1, +13.9] |
251
+ | eunoe, `trim` mode | 95% | 179k | +11.6% [+8.8, +14.4] |
252
+ | eunoe, last 5 turns only | 90% | 164k | +6.4% [+2.4, +10.1] |
253
+ | no compaction at all (ceiling) | 97% | 348k | — (7 points) |
254
+
255
+ eunoe came within two points of never compacting at all, at about half the
256
+ context. Claude Code's compaction lost most on what was worked on mid-session
257
+ (74%), decisions (77%) and what was in progress (77%).
258
+
259
+ **What this does not show yet.** The run had no budget-matched baseline — a plain
260
+ sliding window given the same token budget — so it can't separate "eunoe keeps
261
+ the right things" from "eunoe keeps more". It also used one judge, and many of its
262
+ questions were easy enough that every strategy passed them. The full benchmark
263
+ adds that baseline, a second judge, harder questions vetted before scoring, and a
264
+ task-continuation test, and reports whatever it finds.
265
+
266
+ ## Usage
267
+
268
+ ```sh
269
+ eunoe serve # run in the foreground
270
+ eunoe mode compact # off | trim | compact
271
+ eunoe status # config, plus cache stats for recent requests
272
+ eunoe install # background proxy + Claude Code settings
273
+ eunoe uninstall # new sessions bypass eunoe; proxy stays up as a passthrough
274
+ eunoe stop # remove the background proxy
275
+ ```
276
+
277
+ `~/.eunoe/config.json`:
278
+
279
+ ```json
280
+ {
281
+ "mode": "compact",
282
+ "compactAt": 0.9,
283
+ "keepTurns": "all",
284
+ "search": "markdown",
285
+ "port": 8788,
286
+ "claudeConfigDirs": []
287
+ }
288
+ ```
289
+
290
+ Tools that run Claude Code with their own config directory (one per account,
291
+ say) need eunoe to know about it, both to find their transcripts and to route
292
+ them through the proxy:
293
+
294
+ ```sh
295
+ eunoe install --config-dir ~/.my-tool/claude-account-1
296
+ ```
297
+
298
+ The directory is remembered in `claudeConfigDirs`.
299
+
300
+ `compactAt` is a share of the model's real context window. Claude Code compacts
301
+ around 93%, so eunoe goes slightly earlier at 90% to win the race.
302
+
303
+ Every request is logged to `~/.eunoe/requests.jsonl` with what eunoe did to it
304
+ and the cache read/write that came back.
305
+
306
+ ## What it doesn't do
307
+
308
+ - **It is a proxy, so it is a single point of failure.** If it isn't running, and
309
+ `ANTHROPIC_BASE_URL` still points at it, every Claude request fails.
310
+ - **It only understands Claude Code's shape** of conversation — first-message
311
+ system reminders, mid-conversation system messages, interruption markers. Other
312
+ Anthropic API clients pass through unchanged but get nothing out of it.
313
+ - **It can't recover what Claude Code already compacted.** Turn it on before a
314
+ long session, not after.
315
+ - **`trim` mode changes how the agent should write.** It's told to put state in
316
+ its final reply, and it mostly does — but a sloppy final reply costs more in
317
+ `trim` than in `compact`.
318
+
319
+ ## Prior art
320
+
321
+ Claude Code's own compaction is the thing being replaced, and the summary format
322
+ it produces is a reasonable piece of prompt engineering — it just runs after the
323
+ conversation is already gone. [qmd](https://github.com/tobi/qmd) does the local
324
+ semantic search for the `qmd` mode.
325
+
326
+ ## License
327
+
328
+ MIT
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "@chroxify/eunoe",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Context management for Claude Code that never forgets what matters.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/chroxify/eunoe.git"
9
+ },
10
+ "homepage": "https://github.com/chroxify/eunoe#readme",
11
+ "keywords": [
12
+ "claude-code",
13
+ "claude",
14
+ "anthropic",
15
+ "context",
16
+ "compaction",
17
+ "proxy"
18
+ ],
19
+ "type": "module",
20
+ "bin": {
21
+ "eunoe": "src/cli/main.ts"
22
+ },
23
+ "files": [
24
+ "src",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "engines": {
29
+ "bun": ">=1.1"
30
+ },
31
+ "scripts": {
32
+ "start": "bun src/cli/main.ts serve",
33
+ "test": "bun test",
34
+ "typecheck": "tsc --noEmit -p ."
35
+ },
36
+ "devDependencies": {
37
+ "@types/bun": "^1.4.2",
38
+ "typescript": "^7.0.2"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ }
43
+ }
@@ -0,0 +1,17 @@
1
+ import { fail } from "./ui"
2
+
3
+ export function flagValues(args: string[], flag: string): string[] {
4
+ const values: string[] = []
5
+ for (let i = 0; i < args.length; i += 1) {
6
+ if (args[i] !== flag) continue
7
+ const value = args[i + 1]
8
+ if (!value || value.startsWith("-")) fail(`${flag} needs a value`, `eunoe install ${flag} <dir>`)
9
+ values.push(value)
10
+ i += 1
11
+ }
12
+ return values
13
+ }
14
+
15
+ export function wantsHelp(args: string[]) {
16
+ return args.includes("--help") || args.includes("-h")
17
+ }
@@ -0,0 +1,42 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+ import type { Settings } from "./types"
4
+
5
+ export const proxyUrl = (port: number) => `http://127.0.0.1:${port}`
6
+
7
+ export function withEunoe(settings: Settings, port: number, mode: string): Settings {
8
+ const next = { ...settings, env: { ...settings.env, ANTHROPIC_BASE_URL: proxyUrl(port) } }
9
+ return withCompactionOwner(next, mode)
10
+ }
11
+
12
+ export function withoutEunoe(settings: Settings, port: number): Settings {
13
+ const next = { ...settings }
14
+ if (next.env?.ANTHROPIC_BASE_URL === proxyUrl(port)) {
15
+ const { ANTHROPIC_BASE_URL: _, ...env } = next.env
16
+ if (Object.keys(env).length > 0) next.env = env
17
+ else delete next.env
18
+ }
19
+ delete next.autoCompactEnabled
20
+ return next
21
+ }
22
+
23
+ export function withCompactionOwner(settings: Settings, mode: string): Settings {
24
+ const next = { ...settings }
25
+ if (mode === "off") delete next.autoCompactEnabled
26
+ else next.autoCompactEnabled = false
27
+ return next
28
+ }
29
+
30
+ export function editSettings(dirs: string[], edit: (settings: Settings) => Settings): string[] {
31
+ const touched: string[] = []
32
+ for (const dir of dirs) {
33
+ if (!existsSync(dir)) continue
34
+ const file = path.join(dir, "settings.json")
35
+ const current: Settings = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {}
36
+ const next = edit(current)
37
+ if (JSON.stringify(next) === JSON.stringify(current)) continue
38
+ writeFileSync(file, JSON.stringify(next, null, 2) + "\n")
39
+ touched.push(file)
40
+ }
41
+ return touched
42
+ }
@@ -0,0 +1,9 @@
1
+ import type { Command } from "../types"
2
+ import { install } from "./install"
3
+ import { mode } from "./mode"
4
+ import { serve } from "./serve"
5
+ import { status } from "./status"
6
+ import { stop } from "./stop"
7
+ import { uninstall } from "./uninstall"
8
+
9
+ export const commands: Record<string, Command> = { install, uninstall, stop, mode, status, serve }
@@ -0,0 +1,44 @@
1
+ import { existsSync } from "node:fs"
2
+ import path from "node:path"
3
+ import { claudeConfigDirs, loadConfig, saveConfig } from "../../config"
4
+ import { paths } from "../../config/constants"
5
+ import { flagValues } from "../args"
6
+ import { editSettings, proxyUrl, withEunoe } from "../claude-settings"
7
+ import { MODE_DESCRIPTIONS } from "../constants"
8
+ import { routedDirs } from "../inspect"
9
+ import * as launchd from "../launchd"
10
+ import type { Command } from "../types"
11
+ import { c, fail, line, listFiles, note, ok, rows, tilde, warn } from "../ui"
12
+
13
+ export const install: Command = {
14
+ summary: "Route Claude Code through eunoe and keep it running",
15
+ usage: "eunoe install [--config-dir <dir>]",
16
+ details: [
17
+ "Starts the proxy in the background and points Claude Code at it by setting",
18
+ "ANTHROPIC_BASE_URL in settings.json. New sessions pick it up; restart running ones.",
19
+ ],
20
+ options: [["--config-dir <dir>", "Also manage this Claude config directory (repeatable, remembered)"]],
21
+ run(args) {
22
+ const added = flagValues(args, "--config-dir").map((dir) => path.resolve(dir))
23
+ for (const dir of added) if (!existsSync(dir)) fail(`No such directory: ${tilde(dir)}`)
24
+ if (added.length) saveConfig({ claudeConfigDirs: [...new Set([...loadConfig().claudeConfigDirs, ...added])] })
25
+ const config = loadConfig()
26
+
27
+ line()
28
+ if (launchd.supported) {
29
+ launchd.installAgent([process.execPath, path.join(import.meta.dir, "..", "main.ts"), "serve"], paths.proxyLog)
30
+ ok(`Proxy running in the background on ${c.cyan(proxyUrl(config.port))}`)
31
+ } else {
32
+ warn("No launchd here, so keep the proxy running yourself:")
33
+ line(` ${c.cyan("eunoe serve")}`)
34
+ }
35
+ const touched = editSettings(claudeConfigDirs(config), (settings) => withEunoe(settings, config.port, config.mode))
36
+ ok("Claude Code routed through eunoe")
37
+ listFiles(touched.length ? touched : routedDirs(config).map((dir) => path.join(dir, "settings.json")))
38
+ line()
39
+ rows([["Mode", `${c.bold(config.mode)} ${c.dim(MODE_DESCRIPTIONS[config.mode])}`]])
40
+ line()
41
+ note(`New sessions go through eunoe. Restart any that are already running.`)
42
+ line()
43
+ },
44
+ }
@@ -0,0 +1,30 @@
1
+ import { claudeConfigDirs, loadConfig, saveConfig } from "../../config"
2
+ import type { Mode } from "../../config/types"
3
+ import { editSettings, withCompactionOwner } from "../claude-settings"
4
+ import { MODE_DESCRIPTIONS } from "../constants"
5
+ import * as launchd from "../launchd"
6
+ import type { Command } from "../types"
7
+ import { c, fail, line, ok } from "../ui"
8
+
9
+ export const mode: Command = {
10
+ summary: "Show or switch how eunoe manages context",
11
+ usage: "eunoe mode [compact | trim | off]",
12
+ details: ["Takes effect on the next request; no restart needed."],
13
+ run(args) {
14
+ const config = loadConfig()
15
+ const next = args[0] as Mode | undefined
16
+ if (!next) {
17
+ line()
18
+ for (const [name, description] of Object.entries(MODE_DESCRIPTIONS) as Array<[Mode, string]>) {
19
+ const current = name === config.mode
20
+ line(` ${current ? c.green("●") : c.dim("○")} ${current ? c.bold(name.padEnd(8)) : name.padEnd(8)} ${c.dim(description)}`)
21
+ }
22
+ line()
23
+ return
24
+ }
25
+ if (!(next in MODE_DESCRIPTIONS)) fail(`Unknown mode: ${next}`, "Choose compact, trim or off.")
26
+ saveConfig({ mode: next })
27
+ if (launchd.isInstalled()) editSettings(claudeConfigDirs(loadConfig()), (settings) => withCompactionOwner(settings, next))
28
+ ok(`Mode ${c.bold(next)} ${c.dim(MODE_DESCRIPTIONS[next])}`)
29
+ },
30
+ }
@@ -0,0 +1,19 @@
1
+ import { loadConfig } from "../../config"
2
+ import { serve as startProxy } from "../../proxy/server"
3
+ import { VERSION } from "../../version"
4
+ import { proxyUrl } from "../claude-settings"
5
+ import { probe } from "../inspect"
6
+ import type { Command } from "../types"
7
+ import { c, fail, line } from "../ui"
8
+
9
+ export const serve: Command = {
10
+ summary: "Run the proxy in the foreground",
11
+ usage: "eunoe serve",
12
+ details: ["`eunoe install` runs this for you in the background."],
13
+ async run() {
14
+ const config = loadConfig()
15
+ if (await probe(config.port)) fail(`Something is already serving eunoe on port ${config.port}`, "Stop it first, or set another port in ~/.eunoe/config.json.")
16
+ startProxy(config.port)
17
+ line(`${c.bold("eunoe")} ${c.dim(VERSION)} listening on ${c.cyan(proxyUrl(config.port))} ${c.dim(`mode ${config.mode} · Ctrl-C to stop`)}`)
18
+ },
19
+ }
@@ -0,0 +1,48 @@
1
+ import path from "node:path"
2
+ import { loadConfig } from "../../config"
3
+ import { VERSION } from "../../version"
4
+ import { proxyUrl } from "../claude-settings"
5
+ import { MODE_DESCRIPTIONS } from "../constants"
6
+ import { probe, recentRequests, routedDirs } from "../inspect"
7
+ import * as launchd from "../launchd"
8
+ import type { Command } from "../types"
9
+ import { ago, c, line, rows, tilde, tokens, warn } from "../ui"
10
+
11
+ export const status: Command = {
12
+ summary: "Show what eunoe is doing",
13
+ usage: "eunoe status",
14
+ async run() {
15
+ const config = loadConfig()
16
+ const health = await probe(config.port)
17
+ const routed = routedDirs(config)
18
+ const recent = recentRequests()
19
+
20
+ line()
21
+ line(` ${c.bold("eunoe")} ${c.dim(VERSION)}`)
22
+ line()
23
+ rows([
24
+ ["Proxy", health ? `${c.green("●")} running on ${c.cyan(proxyUrl(config.port))}` : `${c.red("○")} not running`],
25
+ ["Claude Code", routed.length ? `${c.green("●")} routed through eunoe` : `${c.dim("○")} not routed ${c.dim("(eunoe install)")}`],
26
+ ["Mode", `${c.bold(config.mode)} ${c.dim(MODE_DESCRIPTIONS[config.mode])}`],
27
+ ["Search", `${config.search} transcripts`],
28
+ ["Background", launchd.isInstalled() ? "launchd agent" : c.dim("none")],
29
+ ])
30
+ for (const dir of routed) line(` ${" ".repeat(13)}${c.dim(tilde(path.join(dir, "settings.json")))}`)
31
+
32
+ if (recent.length) {
33
+ const sum = (key: string) => recent.reduce((total, entry) => total + (entry[key] ?? 0), 0)
34
+ const compactions = recent.filter((entry) => entry.compacted)
35
+ line()
36
+ line(` ${c.dim(`Last ${recent.length} requests`)}`)
37
+ rows([
38
+ ["Cache", `${c.bold(tokens(sum("cacheRead")))} read · ${tokens(sum("cacheWrite"))} written · ${tokens(sum("uncached"))} uncached`],
39
+ ["Cuts", compactions.length ? `${compactions.length}, last ${ago(compactions.at(-1)!.ts)}` : c.dim("none yet")],
40
+ ])
41
+ }
42
+ if (health && health.mode !== config.mode) {
43
+ line()
44
+ warn(`The running proxy reports mode ${health.mode}; it switches on its next request.`)
45
+ }
46
+ line()
47
+ },
48
+ }
@@ -0,0 +1,16 @@
1
+ import { loadConfig } from "../../config"
2
+ import { routedDirs } from "../inspect"
3
+ import * as launchd from "../launchd"
4
+ import type { Command } from "../types"
5
+ import { fail, note, ok } from "../ui"
6
+
7
+ export const stop: Command = {
8
+ summary: "Stop the background proxy",
9
+ usage: "eunoe stop",
10
+ details: ["Sessions still pointed at the proxy will fail until restarted, so run `eunoe uninstall` first."],
11
+ run() {
12
+ if (routedDirs(loadConfig()).length) fail("Claude Code is still routed through eunoe", "Run `eunoe uninstall` first, then restart your sessions.")
13
+ if (launchd.removeAgent()) ok("Background proxy stopped")
14
+ else note("The proxy isn't running as a background service.")
15
+ },
16
+ }
@@ -0,0 +1,25 @@
1
+ import { claudeConfigDirs, loadConfig, saveConfig } from "../../config"
2
+ import { editSettings, withoutEunoe } from "../claude-settings"
3
+ import type { Command } from "../types"
4
+ import { c, line, listFiles, note, ok } from "../ui"
5
+
6
+ export const uninstall: Command = {
7
+ summary: "Send new sessions straight to the API again",
8
+ usage: "eunoe uninstall",
9
+ details: [
10
+ "Removes eunoe from Claude Code's settings and hands compaction back to it.",
11
+ "The proxy keeps running as a passthrough, so sessions already pointed at it",
12
+ "keep working. Run `eunoe stop` once they have been restarted.",
13
+ ],
14
+ run() {
15
+ const config = loadConfig()
16
+ const touched = editSettings(claudeConfigDirs(config), (settings) => withoutEunoe(settings, config.port))
17
+ saveConfig({ mode: "off" })
18
+ line()
19
+ ok("Claude Code no longer routed through eunoe")
20
+ listFiles(touched)
21
+ note("The proxy stays up as a passthrough for sessions still using it.")
22
+ line(` ${c.dim("Once they're restarted:")} ${c.cyan("eunoe stop")}`)
23
+ line()
24
+ },
25
+ }