@chroxify/eunoe 0.0.0-stage → 1.0.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 (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +406 -2
  3. package/package.json +42 -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 +23 -0
  7. package/src/cli/commands/mode.ts +66 -0
  8. package/src/cli/commands/serve.ts +20 -0
  9. package/src/cli/commands/settings.ts +100 -0
  10. package/src/cli/commands/start.ts +64 -0
  11. package/src/cli/commands/status.ts +82 -0
  12. package/src/cli/commands/stop.ts +38 -0
  13. package/src/cli/commands/uninstall.ts +27 -0
  14. package/src/cli/constants.ts +52 -0
  15. package/src/cli/help.ts +42 -0
  16. package/src/cli/inspect.ts +41 -0
  17. package/src/cli/launchd.ts +39 -0
  18. package/src/cli/logo/index.ts +89 -0
  19. package/src/cli/logo/pixels.ts +34 -0
  20. package/src/cli/main.ts +23 -0
  21. package/src/cli/prompt.ts +91 -0
  22. package/src/cli/service.ts +33 -0
  23. package/src/cli/sessions.ts +25 -0
  24. package/src/cli/stats.ts +73 -0
  25. package/src/cli/types.ts +14 -0
  26. package/src/cli/ui.ts +106 -0
  27. package/src/config/constants.ts +39 -0
  28. package/src/config/index.ts +98 -0
  29. package/src/config/types.ts +50 -0
  30. package/src/context/constants.ts +42 -0
  31. package/src/context/content.ts +46 -0
  32. package/src/context/evidence.ts +92 -0
  33. package/src/context/prompts.ts +66 -0
  34. package/src/context/recall.ts +155 -0
  35. package/src/context/rerank.ts +44 -0
  36. package/src/context/strategies.ts +89 -0
  37. package/src/context/turns.ts +87 -0
  38. package/src/context/types.ts +66 -0
  39. package/src/proxy/canonical.ts +32 -0
  40. package/src/proxy/compaction.ts +110 -0
  41. package/src/proxy/constants.ts +29 -0
  42. package/src/proxy/estimate.ts +26 -0
  43. package/src/proxy/fingerprint.ts +15 -0
  44. package/src/proxy/log.ts +9 -0
  45. package/src/proxy/rewrite.ts +146 -0
  46. package/src/proxy/server.ts +138 -0
  47. package/src/proxy/sessions.ts +46 -0
  48. package/src/proxy/state.ts +27 -0
  49. package/src/proxy/types.ts +82 -0
  50. package/src/proxy/usage.ts +44 -0
  51. package/src/proxy/window.ts +50 -0
  52. package/src/transcript/constants.ts +15 -0
  53. package/src/transcript/format.ts +68 -0
  54. package/src/transcript/locate.ts +28 -0
  55. package/src/transcript/messages.ts +29 -0
  56. package/src/transcript/parse.ts +93 -0
  57. package/src/transcript/render.ts +36 -0
  58. package/src/transcript/search.ts +80 -0
  59. package/src/transcript/types.ts +43 -0
  60. 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,407 @@
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 keeps long Claude Code sessions sharp. Smaller context,
10
+ nothing lost, no pause, no restart.
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
+ Every long Claude Code session eventually turns dumb on you, and it happens
23
+ well before the context window is full. The agent starts forgetting rules you
24
+ gave it an hour ago, re-reading files it already read, and asking about
25
+ decisions you made together. When compaction finally kicks in it makes things
26
+ worse, because the whole conversation gets replaced by a summary and anything
27
+ the summary left out is gone for good.
28
+
29
+ At that point the only fix is a fresh session, which means explaining the
30
+ project again, pasting the rules again, and hoping it gets further this time.
31
+
32
+ The reason is that every file the agent ever opened and every command it ever
33
+ ran stays in the prompt, so two hundred turns in the model is still reading a
34
+ log from the first hour on every request, with the thing you said five turns
35
+ ago buried somewhere in the middle of it.
36
+
37
+ | | Claude Code | eunoe |
38
+ |---|---|---|
39
+ | **context** | grows with every tool call until it fills the window | finished turns fold to your message, the output lines that mattered and the reply; turns in progress go out whole |
40
+ | **at the limit** | a two-minute summary call replaces the whole conversation | never reached |
41
+ | **old details** | gone unless the summary kept them | come back the moment a message is about them |
42
+ | **your instructions** | survive if the summary mentions them | kept word for word |
43
+ | **the session** | one window, then you start over | as sharp at turn three hundred as at turn three |
44
+
45
+ ## Results
46
+
47
+ Measured on 24 held-out sessions of 90 to 120 turns with 600 facts planted
48
+ at known depths, such as a value a command printed once, an error, an
49
+ instruction that was later revised or a decision with its rejected options,
50
+ and scored by exact match with no judge involved.
51
+
52
+ | | facts remembered | rules followed without a reminder | prompt size when asked | tokens per session | transcript lookups per session | time per session | said "I don't know" | answered wrong |
53
+ |---|---|---|---|---|---|---|---|---|
54
+ | Claude Code's compaction | 70% | 92% | 70k | 741k | 10.1 | 53s | 172 of 600 | 7 of 600 |
55
+ | Claude Code, told a transcript exists | 99% | 92% | 71k | 1,315k | 23.0 | 88s | 3 of 600 | 6 of 600 |
56
+ | keeping the whole history | 100% | 90% | 411k | 815k | 0 | 67s | 0 of 600 | 3 of 600 |
57
+ | eunoe, folding only | 99% | 92% | 118k | 490k | 2.7 | 40s | 4 of 600 | 5 of 600 |
58
+ | **eunoe** | **99%** | **95%** | **110k** | **305k** | **0.1** | **39s** | **1 of 600** | **3 of 600** |
59
+
60
+ *Facts remembered* is how many of the planted facts the agent could still
61
+ answer correctly after the cut. *Rules followed without a reminder* is how
62
+ often it applied an instruction from earlier in the session when the task
63
+ called for it, without being told again. *Prompt size* is the context the
64
+ model was reading when the question came, while *tokens per session* adds up
65
+ every request it took to get through the questions, so it grows whenever the
66
+ agent has to go and look something up.
67
+
68
+ That is **29 points more recall than Claude Code's compaction** (95% CI +17
69
+ to +42), better on 12 sessions and worse on none, with 3 wrong answers out
70
+ of 600, and the same margin holds on 27 cut points taken from real sessions.
71
+ eunoe matches keeping the whole history at a quarter of the context and 40%
72
+ of the tokens.
73
+
74
+ The surprising part is not that eunoe remembers more, it is how. Telling
75
+ Claude Code that a transcript exists gets it to 99% too, but it has to grep
76
+ for the answer 23 times a session to do it, which is why it burns four times
77
+ the tokens. eunoe reaches the same 99% with about one lookup every ten
78
+ sessions, because the answer is already in the prompt when the question
79
+ arrives, and it is faster than keeping the whole history even though that
80
+ arm never searches at all. The two eunoe rows differ only in whether the
81
+ exact lines of old output are kept and brought back, which is the part of
82
+ the design explained below.
83
+
84
+ The method was chosen on a separate development split and frozen before the
85
+ test split ran. The suites, the pre-registered spec, every per-arm table and
86
+ the limits of the study (one developer's repositories, one answering model,
87
+ one run per arm) are in [`bench/`](bench/README.md).
88
+
89
+ ## Install
90
+
91
+ ```sh
92
+ bun add -g @chroxify/eunoe # puts `eunoe` and the short `ee` on your PATH
93
+ eunoe start # sets up the background proxy, routes Claude Code through it
94
+ ```
95
+
96
+ Then use Claude Code exactly as before. New sessions go through eunoe, and
97
+ sessions that were already running pick it up when you restart them.
98
+ `eunoe stop` turns it off again.
99
+
100
+ Requires [Bun](https://bun.sh). On macOS `start` sets up a launchd agent that
101
+ keeps the proxy alive; on other platforms keep `eunoe serve` running however
102
+ you like and everything else works the same.
103
+
104
+ ## How it works
105
+
106
+ eunoe does three things, and each one was measured on its own before it was
107
+ kept.
108
+
109
+ ### Folding
110
+
111
+ A **turn** is one message from you and everything the agent did until your
112
+ next one. Once a turn is finished, most of what it holds is tool output the
113
+ agent needed in the moment and will never read again.
114
+
115
+ A finished turn, as Claude Code keeps it (about 9,000 tokens):
116
+
117
+ ```
118
+ [you] fix the failing lease test
119
+
120
+ [agent] I'll look at the test first.
121
+ [tool] Read src/lease.test.ts
122
+ ... 140 lines of file ...
123
+ [tool] Bash bun test
124
+ ... 80 lines of output ...
125
+
126
+ [agent] The expiry check is off by one.
127
+ [tool] Edit src/lease.ts
128
+ [tool] Bash bun test
129
+ ... 80 lines of output ...
130
+
131
+ [agent] Fixed the off-by-one in renew(): the expiry check used <= instead
132
+ of <. All 4 tests pass. The 6011 code comes from LEASE_EXPIRED in
133
+ src/errors.ts.
134
+ ```
135
+
136
+ The same turn after eunoe folds it (about 700 tokens):
137
+
138
+ ```
139
+ [you] fix the failing lease test
140
+
141
+ [evidence] Bash bun test
142
+ FAIL lease renews after expiry
143
+ expected 6011, got undefined
144
+ 3 pass, 1 fail
145
+
146
+ [agent] Fixed the off-by-one in renew(): the expiry check used <= instead
147
+ of <. All 4 tests pass. The 6011 code comes from LEASE_EXPIRED in
148
+ src/errors.ts.
149
+ ```
150
+
151
+ The agent already writes a usable summary of every turn in its final reply,
152
+ so folding keeps that reply, your message and the evidence described below,
153
+ and drops everything else. A hundred folded turns come to about 70k tokens,
154
+ which is why eunoe never has to throw a turn away.
155
+
156
+ The timing is what makes this cheap. The last 3 turns are always sent whole,
157
+ down to the last tool call, and older turns stay whole too until the detail
158
+ in them passes a budget of 100k tokens. At that point every turn except
159
+ those 3 folds in one step.
160
+
161
+ ```
162
+ turn 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
163
+ -----------------------------------------------------------------------
164
+ t=10 W W W W W W W W W L budget not reached
165
+ t=20 W W W W W W W W W W W W W W W W W W W L
166
+ t=21 F F F F F F F F F F F F F F F F F F W W L <- fold
167
+ t=24 F F F F F F F F F F F F F F F F F F W W W W W L
168
+
169
+ W whole F folded L the live turn
170
+ ```
171
+
172
+ It happens in a step rather than a slide because of the prompt cache. The
173
+ cache is a prefix match, so changing anything in the middle rewrites
174
+ everything after it at 1.25 times the price of a read. Folding one turn per
175
+ message would rewrite the cache on every message, whereas folding a budget
176
+ at a time writes the new prefix once and lets every request until the next
177
+ step read it at a tenth of the price.
178
+
179
+ The first message is always kept verbatim, because Claude Code packs
180
+ `CLAUDE.md`, your git status and your skills into it and its hash travels in
181
+ a billing header. The live turns are untouched too, thinking blocks and
182
+ interruptions included. If a turn was interrupted before the agent replied,
183
+ the fold keeps its last words and the list of what it ran instead.
184
+
185
+ ### Evidence
186
+
187
+ Folding on its own would lose the exact values in tool output, like the
188
+ deployment id, the error code or the test count, so when a turn folds each
189
+ command's output is scored line by line and the lines worth keeping are
190
+ quoted verbatim under the command that produced them.
191
+
192
+ ```
193
+ score line
194
+ ----- ------------------------------------------------------------
195
+ +3 FAIL lease renews after expiry error words and codes
196
+ +3 Deployment ID: dpl_qgthe6u4p7 ids, hashes, URLs
197
+ +2 3 pass, 1 fail test and diff summaries
198
+ +2 ERR_LEASE_6011: lease expired error codes
199
+ +2 took 412 ms durations, sizes, versions
200
+ +1 status: applied key: value
201
+ 0 Compiling 14 modules... dropped
202
+ 0 > lease@1.4.0 test dropped
203
+ ```
204
+
205
+ Every command keeps its best lines, and the highest scorers across the turn
206
+ fill a budget of about 4,000 characters, which is a few hundred tokens. The
207
+ cap exists because a long test or build log would otherwise fold to
208
+ something bigger than the turn it came from, and the size was chosen on the
209
+ development split, where it kept 97% of the planted values. Commands whose
210
+ output can simply be run again, such as `Read`, `Grep`, `Glob`, `Edit` and
211
+ `Write`, are skipped entirely.
212
+
213
+ ### Recall
214
+
215
+ Each time you send a message, before the agent responds, eunoe uses it as a
216
+ search query over every tool output that is no longer in context and
217
+ everything the agent said while working. Matching excerpts are quoted
218
+ exactly and appended to your message, oldest first.
219
+
220
+ ```
221
+ you (turn 71) deploy is failing with ERR_LEASE_6011 again
222
+
223
+ eunoe appends:
224
+
225
+ <recalled-context>
226
+ [turn 12] Bash `bun run deploy:staging`
227
+ Deployment ID: dpl_qgthe6u4p7
228
+ ERR_LEASE_6011: lease expired, retrying with --fresh
229
+ </recalled-context>
230
+ ```
231
+
232
+ Candidates come from BM25, the keyword ranking that search engines use,
233
+ which takes about 6 ms and no model. Optionally [Jev](https://typesafe.ai),
234
+ a small and very fast model, reorders the top 30 in about 300 ms, which
235
+ lifted the right excerpt from 88% to 95% of questions and beat every other
236
+ method and model that was tested. Without a key the BM25 order is used and
237
+ nothing else changes. The recall is computed once per message and pinned to
238
+ it, so it never disturbs the cache.
239
+
240
+ ## How it plugs in
241
+
242
+ ```
243
+ Claude Code ------> eunoe ------> api.anthropic.com
244
+ CLI, desktop app, 127.0.0.1:8788
245
+ Agent SDK rewrites /v1/messages
246
+ passes everything else through
247
+ ```
248
+
249
+ Claude Code honours `ANTHROPIC_BASE_URL` from its `settings.json`, so every
250
+ harness built on it works with no code changes, and because auth headers are
251
+ forwarded untouched your existing login keeps working whether it is a
252
+ subscription or an API key.
253
+
254
+ Claude Code still keeps the whole conversation on disk at
255
+ `~/.claude/projects/<project>/<session>.jsonl`, since eunoe only changes
256
+ what goes out on the wire.
257
+
258
+ ## The transcript
259
+
260
+ Nothing is ever lost, because eunoe keeps a readable copy of the whole
261
+ session and teaches the agent to use it. The session file is rendered into a
262
+ folder of markdown under `~/.eunoe/transcripts/<session>/`, with one file
263
+ per turn and an index.
264
+
265
+ ```
266
+ index.md one line per turn: what you asked and how the turn ended
267
+ turn-0001.md ## User your message
268
+ turn-0002.md ### [tool 3/12] every tool call, with its full output below it
269
+ ... ## Final reply the agent's reply
270
+ ```
271
+
272
+ Markdown is far easier for an agent to grep and read than Claude Code's raw
273
+ JSONL, and the folder is rebuilt as the session grows. The agent is given the
274
+ path and a short recipe for searching it, so when it needs an exact detail
275
+ that neither evidence nor recall surfaced, it can find it in a couple of
276
+ commands instead of guessing. In the benchmark that happened about once
277
+ every ten sessions.
278
+
279
+ ## Prompt caching
280
+
281
+ Cache writes cost 1.25 times the input price and reads a tenth of it, so a
282
+ context scheme that breaks the cache costs more than it saves. eunoe's
283
+ output is deterministic, which means the same history always produces the
284
+ same bytes, recall is pinned to the message it was computed for and nothing
285
+ time-based is ever inserted, so the prefix only ever changes at a fold.
286
+
287
+ ```
288
+ turns 1-20 byte-identical to no proxy at all -> cache hits as usual
289
+ turn 21 fold: one new prefix, written once -> one cache write
290
+ turns 22-58 same prefix, new turns appended -> reads hit again
291
+ turn 59 next fold
292
+ ```
293
+
294
+ Measured through the proxy right after a fold, 180k tokens came from the
295
+ cache and 1.5k were written. Claude Code's own compaction reads the entire
296
+ context into a summary call and then writes a new prefix anyway.
297
+
298
+ ## Modes
299
+
300
+ | mode | |
301
+ |---|---|
302
+ | **`default`** | everything above, recommended |
303
+ | **`compact`** | only the compaction is replaced: the context grows as it normally would, and at the limit eunoe folds instead of summarising, so you get the recall but none of the savings along the way |
304
+ | **`off`** | passthrough, Claude Code compacts on its own |
305
+
306
+ ```sh
307
+ eunoe mode # pick one
308
+ eunoe mode compact # or name it
309
+ ```
310
+
311
+ Whichever mode is on owns compaction, so eunoe disables Claude Code's
312
+ auto-compact while it is active and hands it back on `off` or `stop`.
313
+
314
+ ## Usage
315
+
316
+ ```sh
317
+ eunoe start # route Claude Code through eunoe (sets up the proxy the first time)
318
+ eunoe stop # new sessions bypass eunoe; running ones keep working
319
+ eunoe mode # pick default | compact | off
320
+ eunoe settings # pick a setting and change it
321
+ eunoe settings list # show them all
322
+ eunoe settings set keep 5 # change one directly
323
+ eunoe status # what's running, every session, context and tokens saved
324
+ eunoe uninstall # stop and remove the background proxy
325
+ ```
326
+
327
+ `ee` is an alias, so `ee status` and `eunoe status` are the same command.
328
+
329
+ ### Per session
330
+
331
+ Everything above takes `--session <id>` to apply to one session only. Session
332
+ ids are in Claude Code's `/status`, `eunoe status` lists the ones it has seen
333
+ with their working directory, and a unique prefix is enough.
334
+
335
+ ```sh
336
+ eunoe start --session 1baada86 # manage only this session, pass the rest through untouched
337
+ eunoe mode compact --session 1baada86
338
+ eunoe settings set keep 5 --session 1baada86
339
+ eunoe settings set keep default --session 1baada86 # back to the shared value
340
+ eunoe stop --session 1baada86
341
+ ```
342
+
343
+ Sessions that aren't managed still go through the proxy, so any of them can
344
+ be switched on later without a restart, and every change applies from the
345
+ session's next request.
346
+
347
+ ### Settings
348
+
349
+ | key | default | what it does |
350
+ |---|---|---|
351
+ | `keep` | `3` | turns kept whole at the end |
352
+ | `budget` | `100000` | tokens of older detail that trigger a fold |
353
+ | `compactAt` | `0.9` | share of the context window that triggers the full cut |
354
+ | `keepTurns` | `all` | folded turns kept after a full cut |
355
+ | `search` | `markdown` | transcript form for the agent: `markdown`, `xml`, `jsonl` or `qmd` (semantic, via [qmd](https://github.com/tobi/qmd)) |
356
+ | `rerank` | `jev-preview` | Typesafe model that reorders recall, or `off` |
357
+
358
+ `~/.eunoe/config.json` holds the same keys plus `port`, `claudeConfigDirs`
359
+ and `sessions`. Tools that run Claude Code from their own config directory
360
+ need eunoe to know about it, which `eunoe start --config-dir <dir>` does
361
+ once and remembers.
362
+
363
+ ### Better recall with Jev
364
+
365
+ Recall works out of the box, but it gets noticeably better with
366
+ [Jev](https://typesafe.ai) reordering the candidates. To turn that on, put a
367
+ Typesafe API key in `~/.eunoe/typesafe-key` or in the `TYPESAFE_API_KEY`
368
+ environment variable, and `eunoe status` will show the reranker as active.
369
+ It can't slow anything down, because if Jev errors or takes longer than four
370
+ seconds the recall simply uses the plain keyword order instead. To switch it
371
+ off, run `eunoe settings set rerank off`.
372
+
373
+ ### Logs
374
+
375
+ Every request is written to `~/.eunoe/requests.jsonl` with what eunoe did
376
+ to it and how much of it the API served from cache, which is also where
377
+ `eunoe status` gets its numbers.
378
+
379
+ ## What it doesn't do
380
+
381
+ - **It is a proxy, which makes it a single point of failure.** If it isn't
382
+ running while `ANTHROPIC_BASE_URL` still points at it, every Claude request
383
+ fails. launchd keeps it alive, and `stop` leaves it running so sessions
384
+ already pointed at it keep working.
385
+ - **It only understands Claude Code's shape of conversation.** Other
386
+ Anthropic API clients pass through unchanged and get nothing out of it.
387
+ - **It can't recover what Claude Code already compacted,** so turn it on
388
+ before a long session rather than after.
389
+ - **Recall is keyword search.** Ask about "the deploy that failed" and it
390
+ finds the deploy output, but ask about "that thing from before" and it
391
+ finds nothing, leaving the agent to search the transcript itself. Jev
392
+ narrows this gap without closing it.
393
+ - **It never cuts against a limit it can't verify.** The context window size
394
+ comes from the Models API for each model, and if that lookup fails the
395
+ budget folds still happen but the full cut does not.
396
+
397
+ ## Credits
398
+
399
+ - [qmd](https://github.com/tobi/qmd) by Tobi Lütke provides the local
400
+ semantic search behind the `qmd` setting.
401
+ - [Jev](https://typesafe.ai/blog/introducing-system-one-models-and-jev) by
402
+ Typesafe is the system-one model that reranks recall.
403
+
404
+ ## License
405
+
406
+ eunoe is licensed under the [MIT License](LICENSE), and welcomes
407
+ contributions and suggestions.
package/package.json CHANGED
@@ -1,6 +1,44 @@
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": "1.0.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
+ "ee": "src/cli/main.ts"
23
+ },
24
+ "files": [
25
+ "src",
26
+ "README.md",
27
+ "LICENSE"
28
+ ],
29
+ "engines": {
30
+ "bun": ">=1.1"
31
+ },
32
+ "scripts": {
33
+ "start": "bun src/cli/main.ts serve",
34
+ "test": "bun test",
35
+ "typecheck": "tsc --noEmit -p ."
36
+ },
37
+ "devDependencies": {
38
+ "@types/bun": "^1.4.2",
39
+ "typescript": "^7.0.2"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ }
44
+ }
@@ -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`)
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,23 @@
1
+ import type { Command } from "../types"
2
+ import { mode } from "./mode"
3
+ import { serve } from "./serve"
4
+ import { settings } from "./settings"
5
+ import { start } from "./start"
6
+ import { status } from "./status"
7
+ import { stop } from "./stop"
8
+ import { uninstall } from "./uninstall"
9
+
10
+ const alias = (target: Command, prefix: string[] = []): Command => ({ ...target, hidden: true, run: (args) => target.run([...prefix, ...args]) })
11
+
12
+ export const commands: Record<string, Command> = {
13
+ start,
14
+ stop,
15
+ mode,
16
+ settings,
17
+ status,
18
+ uninstall,
19
+ serve,
20
+ enable: alias(start),
21
+ disable: alias(stop),
22
+ set: alias(settings, ["set"]),
23
+ }
@@ -0,0 +1,66 @@
1
+ import { compactionOwner, loadConfig, saveConfig } from "../../config"
2
+ import { MODES } from "../../config/constants"
3
+ import type { Mode } from "../../config/types"
4
+ import { editSettings, withCompactionOwner } from "../claude-settings"
5
+ import { MODE_DESCRIPTIONS } from "../constants"
6
+ import { routedDirs } from "../inspect"
7
+ import { interactive, select } from "../prompt"
8
+ import { isKnown, sessionArgs, shortId } from "../sessions"
9
+ import type { Command } from "../types"
10
+ import { c, fail, line, note, ok, warn } from "../ui"
11
+
12
+ function listModes(current: Mode) {
13
+ line()
14
+ for (const name of MODES) {
15
+ const active = name === current
16
+ line(` ${active ? c.green("●") : c.dim("○")} ${active ? c.bold(name.padEnd(8)) : name.padEnd(8)} ${c.dim(MODE_DESCRIPTIONS[name])}`)
17
+ }
18
+ }
19
+
20
+ export function applyMode(next: Mode, ids: string[]) {
21
+ const config = loadConfig()
22
+ if (ids.length) {
23
+ saveConfig({ sessions: { ...config.sessions, ...Object.fromEntries(ids.map((id) => [id, { ...config.sessions[id], mode: next }])) } })
24
+ ok(`Mode ${c.bold(next)} for ${ids.map(shortId).join(", ")} ${c.dim(MODE_DESCRIPTIONS[next])}`)
25
+ if (!routedDirs(config).length) warn("Claude Code isn't routed through eunoe. Run `eunoe start --session <id>`.")
26
+ const unseen = ids.filter((id) => !isKnown(id))
27
+ if (unseen.length) note(`Not seen through eunoe yet: ${unseen.map(shortId).join(", ")}. Check the id in Claude Code's /status.`)
28
+ return
29
+ }
30
+ saveConfig({ mode: next })
31
+ const updated = loadConfig()
32
+ editSettings(routedDirs(updated), (settings) => withCompactionOwner(settings, compactionOwner(updated)))
33
+ ok(`Mode ${c.bold(next)} ${c.dim(MODE_DESCRIPTIONS[next])}`)
34
+ }
35
+
36
+ export const mode: Command = {
37
+ summary: "Pick how eunoe manages context",
38
+ usage: "eunoe mode [default | compact | off] [--session <id>]",
39
+ details: ["Without a mode it opens a picker. Takes effect on the next request; no restart needed."],
40
+ options: [["--session <id>", "Set the mode for just this session (repeatable; a unique prefix is enough)"]],
41
+ async run(args) {
42
+ const config = loadConfig()
43
+ const given = args.find((arg, i) => !arg.startsWith("-") && args[i - 1] !== "--session") as Mode | undefined
44
+ const ids = sessionArgs(args)
45
+ if (given) {
46
+ if (!MODES.includes(given)) fail(`Unknown mode: ${given}`, `Choose ${MODES.join(", ")}.`)
47
+ applyMode(given, ids)
48
+ return
49
+ }
50
+ const current = ids.length === 1 ? config.sessions[ids[0]]?.mode ?? config.mode : config.mode
51
+ if (!interactive) {
52
+ listModes(current)
53
+ const own = Object.entries(config.sessions).filter(([, settings]) => settings.mode)
54
+ if (own.length) {
55
+ line()
56
+ for (const [id, settings] of own) line(` ${c.dim(shortId(id))} ${settings.mode}`)
57
+ }
58
+ line()
59
+ return
60
+ }
61
+ line()
62
+ const chosen = await select(ids.length ? `Mode for ${ids.map(shortId).join(", ")}` : "Mode", MODES.map((value) => ({ value, hint: MODE_DESCRIPTIONS[value] })), current)
63
+ if (chosen === null) return
64
+ applyMode(chosen, ids)
65
+ },
66
+ }
@@ -0,0 +1,20 @@
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 start` runs this for you in the background. Use it directly where there is no launchd."],
13
+ hidden: true,
14
+ async run() {
15
+ const config = loadConfig()
16
+ 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.")
17
+ startProxy(config.port)
18
+ line(`${c.bold("eunoe")} ${c.dim(VERSION)} listening on ${c.cyan(proxyUrl(config.port))} ${c.dim(`mode ${config.mode} · Ctrl-C to stop`)}`)
19
+ },
20
+ }