@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.
- package/LICENSE +21 -0
- package/README.md +327 -2
- package/package.json +41 -4
- package/src/cli/args.ts +17 -0
- package/src/cli/claude-settings.ts +42 -0
- package/src/cli/commands/index.ts +9 -0
- package/src/cli/commands/install.ts +44 -0
- package/src/cli/commands/mode.ts +30 -0
- package/src/cli/commands/serve.ts +19 -0
- package/src/cli/commands/status.ts +48 -0
- package/src/cli/commands/stop.ts +16 -0
- package/src/cli/commands/uninstall.ts +25 -0
- package/src/cli/constants.ts +29 -0
- package/src/cli/help.ts +42 -0
- package/src/cli/inspect.ts +41 -0
- package/src/cli/launchd.ts +34 -0
- package/src/cli/logo/index.ts +89 -0
- package/src/cli/logo/pixels.ts +34 -0
- package/src/cli/main.ts +23 -0
- package/src/cli/types.ts +13 -0
- package/src/cli/ui.ts +91 -0
- package/src/config/constants.ts +27 -0
- package/src/config/index.ts +49 -0
- package/src/config/types.ts +21 -0
- package/src/context/constants.ts +7 -0
- package/src/context/content.ts +24 -0
- package/src/context/prompts.ts +66 -0
- package/src/context/strategies.ts +89 -0
- package/src/context/turns.ts +82 -0
- package/src/context/types.ts +24 -0
- package/src/proxy/compaction.ts +93 -0
- package/src/proxy/constants.ts +19 -0
- package/src/proxy/estimate.ts +26 -0
- package/src/proxy/log.ts +9 -0
- package/src/proxy/rewrite.ts +72 -0
- package/src/proxy/server.ts +120 -0
- package/src/proxy/state.ts +27 -0
- package/src/proxy/types.ts +58 -0
- package/src/proxy/usage.ts +44 -0
- package/src/proxy/window.ts +50 -0
- package/src/transcript/constants.ts +16 -0
- package/src/transcript/format.ts +68 -0
- package/src/transcript/locate.ts +28 -0
- package/src/transcript/parse.ts +93 -0
- package/src/transcript/render.ts +36 -0
- package/src/transcript/search.ts +80 -0
- package/src/transcript/types.ts +43 -0
- 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
|
-
|
|
1
|
+
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+

|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|
package/src/cli/args.ts
ADDED
|
@@ -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
|
+
}
|