@chroxify/eunoe 1.0.0 → 1.2.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/README.md +68 -9
- package/package.json +18 -2
- package/src/cli/{types.ts → command.ts} +0 -4
- package/src/cli/commands/index.ts +13 -1
- package/src/cli/commands/load.ts +44 -0
- package/src/cli/commands/mode.ts +41 -33
- package/src/cli/commands/peek.ts +22 -0
- package/src/cli/commands/replay.ts +245 -0
- package/src/cli/commands/serve.ts +22 -7
- package/src/cli/commands/sessions.ts +70 -0
- package/src/cli/commands/settings.ts +79 -60
- package/src/cli/commands/start.ts +56 -21
- package/src/cli/commands/stats.ts +233 -0
- package/src/cli/commands/status.ts +98 -55
- package/src/cli/commands/stop.ts +8 -6
- package/src/cli/commands/uninstall.ts +10 -12
- package/src/cli/commands/watch.ts +16 -0
- package/src/cli/logo/art.ts +99 -0
- package/src/cli/logo/index.ts +12 -18
- package/src/cli/main.ts +3 -3
- package/src/cli/proxy/claude-settings.ts +69 -0
- package/src/cli/proxy/inspect.ts +67 -0
- package/src/cli/proxy/launchd.ts +53 -0
- package/src/cli/proxy/live.ts +57 -0
- package/src/cli/proxy/service.ts +53 -0
- package/src/cli/{stats.ts → proxy/stats.ts} +29 -16
- package/src/cli/proxy/watch.ts +60 -0
- package/src/cli/replay/index.ts +146 -0
- package/src/cli/replay/worker.ts +20 -0
- package/src/cli/sessions/browse.ts +88 -0
- package/src/cli/sessions/index.ts +76 -0
- package/src/cli/sessions/source.ts +74 -0
- package/src/cli/{help.ts → terminal/help.ts} +24 -8
- package/src/cli/terminal/prompt.ts +114 -0
- package/src/cli/{ui.ts → terminal/ui.ts} +55 -24
- package/src/cli/usage/index.ts +151 -0
- package/src/config/index.ts +99 -34
- package/src/config/paths.ts +17 -0
- package/src/context/content.ts +40 -14
- package/src/context/fold/evidence.ts +145 -0
- package/src/context/fold/index.ts +65 -0
- package/src/context/fold/strategies.ts +94 -0
- package/src/context/fold/turns.ts +76 -0
- package/src/context/prompts.ts +141 -22
- package/src/context/recall/index.ts +214 -0
- package/src/context/recall/rerank.ts +71 -0
- package/src/proxy/canonical.ts +17 -9
- package/src/proxy/compaction.ts +106 -53
- package/src/proxy/estimate.ts +14 -9
- package/src/proxy/fingerprint.ts +23 -9
- package/src/proxy/handoff.ts +71 -0
- package/src/proxy/log.ts +2 -2
- package/src/proxy/recall.ts +58 -0
- package/src/proxy/rewrite.ts +141 -119
- package/src/proxy/server.ts +108 -51
- package/src/proxy/sessions.ts +32 -14
- package/src/proxy/state.ts +17 -3
- package/src/proxy/usage.ts +50 -34
- package/src/proxy/window.ts +32 -34
- package/src/transcript/format.ts +88 -25
- package/src/transcript/locate.ts +38 -17
- package/src/transcript/messages.ts +3 -14
- package/src/transcript/parse.ts +137 -56
- package/src/transcript/render.ts +44 -17
- package/src/transcript/search.ts +20 -56
- package/src/transcript/subagents.ts +150 -0
- package/src/cli/claude-settings.ts +0 -42
- package/src/cli/constants.ts +0 -52
- package/src/cli/inspect.ts +0 -41
- package/src/cli/launchd.ts +0 -39
- package/src/cli/logo/pixels.ts +0 -34
- package/src/cli/prompt.ts +0 -91
- package/src/cli/service.ts +0 -33
- package/src/cli/sessions.ts +0 -25
- package/src/config/constants.ts +0 -39
- package/src/config/types.ts +0 -50
- package/src/context/constants.ts +0 -42
- package/src/context/evidence.ts +0 -92
- package/src/context/recall.ts +0 -155
- package/src/context/rerank.ts +0 -44
- package/src/context/strategies.ts +0 -89
- package/src/context/turns.ts +0 -87
- package/src/context/types.ts +0 -66
- package/src/proxy/constants.ts +0 -29
- package/src/proxy/types.ts +0 -82
- package/src/transcript/constants.ts +0 -15
- package/src/transcript/types.ts +0 -43
- /package/src/cli/{args.ts → terminal/args.ts} +0 -0
package/README.md
CHANGED
|
@@ -11,10 +11,6 @@ nothing lost, no pause, no restart.
|
|
|
11
11
|
|
|
12
12
|
</div>
|
|
13
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
14
|
---
|
|
19
15
|
|
|
20
16
|
## The problem
|
|
@@ -49,6 +45,10 @@ at known depths, such as a value a command printed once, an error, an
|
|
|
49
45
|
instruction that was later revised or a decision with its rejected options,
|
|
50
46
|
and scored by exact match with no judge involved.
|
|
51
47
|
|
|
48
|
+
> These numbers come from one developer's sessions. They are a careful
|
|
49
|
+
> measurement, not a general one, so read them as a strong signal rather than
|
|
50
|
+
> a guarantee for your own work.
|
|
51
|
+
|
|
52
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
53
|
|---|---|---|---|---|---|---|---|---|
|
|
54
54
|
| Claude Code's compaction | 70% | 92% | 70k | 741k | 10.1 | 53s | 172 of 600 | 7 of 600 |
|
|
@@ -86,6 +86,12 @@ test split ran. The suites, the pre-registered spec, every per-arm table and
|
|
|
86
86
|
the limits of the study (one developer's repositories, one answering model,
|
|
87
87
|
one run per arm) are in [`bench/`](bench/README.md).
|
|
88
88
|
|
|
89
|
+
Context management is hard to benchmark well alone, because the honest test
|
|
90
|
+
is many people's real sessions. If you run the bench on your own sessions,
|
|
91
|
+
find a case where eunoe does worse, or have a better way to measure this,
|
|
92
|
+
please open an issue or a pull request. Results from other setups are the
|
|
93
|
+
contribution I'd value most.
|
|
94
|
+
|
|
89
95
|
## Install
|
|
90
96
|
|
|
91
97
|
```sh
|
|
@@ -98,8 +104,10 @@ sessions that were already running pick it up when you restart them.
|
|
|
98
104
|
`eunoe stop` turns it off again.
|
|
99
105
|
|
|
100
106
|
Requires [Bun](https://bun.sh). On macOS `start` sets up a launchd agent that
|
|
101
|
-
keeps the proxy alive
|
|
102
|
-
|
|
107
|
+
keeps the proxy alive and a watchdog that unroutes Claude Code if the proxy
|
|
108
|
+
should ever be down; on other platforms keep `eunoe serve` running however you like and
|
|
109
|
+
everything else works the same. The proxy listens on 127.0.0.1:38663 (`port`
|
|
110
|
+
in `~/.eunoe/config.json` changes it).
|
|
103
111
|
|
|
104
112
|
## How it works
|
|
105
113
|
|
|
@@ -241,7 +249,7 @@ it, so it never disturbs the cache.
|
|
|
241
249
|
|
|
242
250
|
```
|
|
243
251
|
Claude Code ------> eunoe ------> api.anthropic.com
|
|
244
|
-
CLI, desktop app, 127.0.0.1:
|
|
252
|
+
CLI, desktop app, 127.0.0.1:38663
|
|
245
253
|
Agent SDK rewrites /v1/messages
|
|
246
254
|
passes everything else through
|
|
247
255
|
```
|
|
@@ -267,8 +275,15 @@ index.md one line per turn: what you asked and how the turn ended
|
|
|
267
275
|
turn-0001.md ## User your message
|
|
268
276
|
turn-0002.md ### [tool 3/12] every tool call, with its full output below it
|
|
269
277
|
... ## Final reply the agent's reply
|
|
278
|
+
turn-0002-subagent-1.md the whole run of a subagent launched in that turn
|
|
270
279
|
```
|
|
271
280
|
|
|
281
|
+
A subagent's run is its own file, indexed under the turn that launched it
|
|
282
|
+
and linked from the Agent call; the turn itself only has the subagent's
|
|
283
|
+
report. What the subagent saw and ran is also part of recall, labelled
|
|
284
|
+
`subagent "…"`, so the agent gets a value only its subagent printed without
|
|
285
|
+
having to look.
|
|
286
|
+
|
|
272
287
|
Markdown is far easier for an agent to grep and read than Claude Code's raw
|
|
273
288
|
JSONL, and the folder is rebuilt as the session grows. The agent is given the
|
|
274
289
|
path and a short recipe for searching it, so when it needs an exact detail
|
|
@@ -321,11 +336,36 @@ eunoe settings # pick a setting and change it
|
|
|
321
336
|
eunoe settings list # show them all
|
|
322
337
|
eunoe settings set keep 5 # change one directly
|
|
323
338
|
eunoe status # what's running, every session, context and tokens saved
|
|
339
|
+
eunoe stats # tokens and API cost by day (--days 14, --weekly), and what eunoe saved
|
|
340
|
+
eunoe replay # replay your whole Claude Code history through eunoe, offline, and see what it saves
|
|
324
341
|
eunoe uninstall # stop and remove the background proxy
|
|
325
342
|
```
|
|
326
343
|
|
|
327
344
|
`ee` is an alias, so `ee status` and `eunoe status` are the same command.
|
|
328
345
|
|
|
346
|
+
### Earlier sessions
|
|
347
|
+
|
|
348
|
+
A different big problem with Claude is that new chats always start cold. eunoe
|
|
349
|
+
helps you carry an earlier session into one: run `eunoe sessions`, pick the
|
|
350
|
+
session (type to filter), and paste the copied `{ee:continue:<id>}` marker into
|
|
351
|
+
the chat along with what you want done. eunoe attaches that whole session to
|
|
352
|
+
the message, folded the same way it folds turns in a live session: your
|
|
353
|
+
messages, the key output lines and the agent's replies, in order. From then on
|
|
354
|
+
the earlier session's dropped tool output is searchable too, so an exact value
|
|
355
|
+
from it comes back as recalled context when a later message is about it.
|
|
356
|
+
|
|
357
|
+
You always name the session. eunoe never guesses which one you mean or injects
|
|
358
|
+
one on its own; in the benchmark that choice was wrong a third of the time,
|
|
359
|
+
while a named session remembered 98% of its facts and kept 95% of its rules.
|
|
360
|
+
|
|
361
|
+
```sh
|
|
362
|
+
eunoe sessions [words] # browse earlier sessions here; enter copies {ee:continue:<id>}
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
The agent is also taught two commands for digging on its own: `eunoe load
|
|
366
|
+
<id>` writes a session to `~/.eunoe/loaded/<id>.md` for it to read whole, and
|
|
367
|
+
`eunoe peek <id> <words>` prints just the excerpts that match.
|
|
368
|
+
|
|
329
369
|
### Per session
|
|
330
370
|
|
|
331
371
|
Everything above takes `--session <id>` to apply to one session only. Session
|
|
@@ -350,6 +390,7 @@ session's next request.
|
|
|
350
390
|
|---|---|---|
|
|
351
391
|
| `keep` | `3` | turns kept whole at the end |
|
|
352
392
|
| `budget` | `100000` | tokens of older detail that trigger a fold |
|
|
393
|
+
| `turnCap` | `100000` | a kept turn larger than this is folded at once, the live turn excepted; `off` to disable |
|
|
353
394
|
| `compactAt` | `0.9` | share of the context window that triggers the full cut |
|
|
354
395
|
| `keepTurns` | `all` | folded turns kept after a full cut |
|
|
355
396
|
| `search` | `markdown` | transcript form for the agent: `markdown`, `xml`, `jsonl` or `qmd` (semantic, via [qmd](https://github.com/tobi/qmd)) |
|
|
@@ -376,12 +417,30 @@ Every request is written to `~/.eunoe/requests.jsonl` with what eunoe did
|
|
|
376
417
|
to it and how much of it the API served from cache, which is also where
|
|
377
418
|
`eunoe status` gets its numbers.
|
|
378
419
|
|
|
420
|
+
`eunoe stats` reads the usage the API returned, from Claude Code's own session
|
|
421
|
+
files, subagents included, so it covers every session, routed through eunoe or
|
|
422
|
+
not. Claude Code writes one response as several lines, so each response is
|
|
423
|
+
counted once, with its final output count. Cost is at API list prices.
|
|
424
|
+
Its eunoe columns are only what eunoe actually did, from its request log: the
|
|
425
|
+
input tokens it kept out, and what that saved. Those tokens are mostly history
|
|
426
|
+
plain Claude Code would have read from cache, so they are priced as cache
|
|
427
|
+
reads, and every token a fold rewrote is charged back at the cache-write price.
|
|
428
|
+
`eunoe replay` and `eunoe stats` can only see what is still on disk: Claude Code
|
|
429
|
+
deletes sessions that have been idle for 30 days unless `cleanupPeriodDays` in
|
|
430
|
+
its `settings.json` says otherwise.
|
|
431
|
+
|
|
379
432
|
## What it doesn't do
|
|
380
433
|
|
|
381
434
|
- **It is a proxy, which makes it a single point of failure.** If it isn't
|
|
382
435
|
running while `ANTHROPIC_BASE_URL` still points at it, every Claude request
|
|
383
|
-
fails.
|
|
384
|
-
|
|
436
|
+
fails. Three things limit the damage: `start` refuses to route Claude Code
|
|
437
|
+
until the proxy answers, launchd restarts the proxy within a second whenever
|
|
438
|
+
it dies, and a watchdog that runs every 10 seconds takes eunoe out of
|
|
439
|
+
`settings.json` after 20 seconds of silence and puts it back when the proxy
|
|
440
|
+
answers again. A request the proxy can't deliver upstream comes back as a
|
|
441
|
+
502 that Claude Code retries, never a hang. Sessions that were already
|
|
442
|
+
running keep the old address until restarted, so they wait for the proxy
|
|
443
|
+
rather than falling back.
|
|
385
444
|
- **It only understands Claude Code's shape of conversation.** Other
|
|
386
445
|
Anthropic API clients pass through unchanged and get nothing out of it.
|
|
387
446
|
- **It can't recover what Claude Code already compacted,** so turn it on
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chroxify/eunoe",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Context management for Claude Code that never forgets what matters.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"url": "git+https://github.com/chroxify/eunoe.git"
|
|
9
9
|
},
|
|
10
10
|
"homepage": "https://github.com/chroxify/eunoe#readme",
|
|
11
|
+
"bugs": "https://github.com/chroxify/eunoe/issues",
|
|
11
12
|
"keywords": [
|
|
12
13
|
"claude-code",
|
|
13
14
|
"claude",
|
|
@@ -32,13 +33,28 @@
|
|
|
32
33
|
"scripts": {
|
|
33
34
|
"start": "bun src/cli/main.ts serve",
|
|
34
35
|
"test": "bun test",
|
|
35
|
-
"
|
|
36
|
+
"check-types": "tsc --noEmit -p .",
|
|
37
|
+
"lint": "biome check . && bun lint:conventions",
|
|
38
|
+
"lint:conventions": "bun scripts/lint-conventions.ts",
|
|
39
|
+
"format": "biome check .",
|
|
40
|
+
"format:fix": "biome check . --write",
|
|
41
|
+
"prepare": "husky"
|
|
36
42
|
},
|
|
37
43
|
"devDependencies": {
|
|
44
|
+
"@babel/parser": "7.27.0",
|
|
45
|
+
"@biomejs/biome": "2.5.9",
|
|
38
46
|
"@types/bun": "^1.4.2",
|
|
47
|
+
"husky": "9.1.7",
|
|
48
|
+
"knip": "6.41.0",
|
|
49
|
+
"lint-staged": "16.4.0",
|
|
39
50
|
"typescript": "^7.0.2"
|
|
40
51
|
},
|
|
41
52
|
"publishConfig": {
|
|
42
53
|
"access": "public"
|
|
54
|
+
},
|
|
55
|
+
"lint-staged": {
|
|
56
|
+
"*.{js,ts,json,jsonc,md,yml}": [
|
|
57
|
+
"biome check --write --files-ignore-unknown=true --no-errors-on-unmatched"
|
|
58
|
+
]
|
|
43
59
|
}
|
|
44
60
|
}
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
export type Settings = Record<string, any>
|
|
2
|
-
|
|
3
1
|
export type OptionRow = [flag: string, description: string]
|
|
4
2
|
|
|
5
3
|
export interface Command {
|
|
@@ -10,5 +8,3 @@ export interface Command {
|
|
|
10
8
|
hidden?: boolean
|
|
11
9
|
run: (args: string[]) => void | Promise<void>
|
|
12
10
|
}
|
|
13
|
-
|
|
14
|
-
export type RequestRecord = Record<string, any>
|
|
@@ -1,11 +1,17 @@
|
|
|
1
|
-
import type { Command } from "../
|
|
1
|
+
import type { Command } from "../command"
|
|
2
|
+
import { load } from "./load"
|
|
2
3
|
import { mode } from "./mode"
|
|
4
|
+
import { peek } from "./peek"
|
|
5
|
+
import { replay } from "./replay"
|
|
3
6
|
import { serve } from "./serve"
|
|
7
|
+
import { sessions } from "./sessions"
|
|
4
8
|
import { settings } from "./settings"
|
|
5
9
|
import { start } from "./start"
|
|
10
|
+
import { stats } from "./stats"
|
|
6
11
|
import { status } from "./status"
|
|
7
12
|
import { stop } from "./stop"
|
|
8
13
|
import { uninstall } from "./uninstall"
|
|
14
|
+
import { watch } from "./watch"
|
|
9
15
|
|
|
10
16
|
const alias = (target: Command, prefix: string[] = []): Command => ({ ...target, hidden: true, run: (args) => target.run([...prefix, ...args]) })
|
|
11
17
|
|
|
@@ -15,8 +21,14 @@ export const commands: Record<string, Command> = {
|
|
|
15
21
|
mode,
|
|
16
22
|
settings,
|
|
17
23
|
status,
|
|
24
|
+
stats,
|
|
25
|
+
replay,
|
|
26
|
+
sessions,
|
|
27
|
+
peek,
|
|
28
|
+
load,
|
|
18
29
|
uninstall,
|
|
19
30
|
serve,
|
|
31
|
+
watch,
|
|
20
32
|
enable: alias(start),
|
|
21
33
|
disable: alias(stop),
|
|
22
34
|
set: alias(settings, ["set"]),
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { paths } from "../../config/paths"
|
|
4
|
+
import { FOLD_CAP_CHARS, foldedView } from "../../context/fold"
|
|
5
|
+
import type { Command } from "../command"
|
|
6
|
+
import { candidateOf, candidatesHere, shortId, titleOf } from "../sessions"
|
|
7
|
+
import type { SessionCandidate } from "../sessions/source"
|
|
8
|
+
import { line } from "../terminal/ui"
|
|
9
|
+
|
|
10
|
+
interface Loaded {
|
|
11
|
+
file: string
|
|
12
|
+
lines: number
|
|
13
|
+
chars: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function writeLoaded(cand: SessionCandidate): Loaded {
|
|
17
|
+
const text = foldedView(cand.messages, FOLD_CAP_CHARS)
|
|
18
|
+
mkdirSync(paths.loaded, { recursive: true })
|
|
19
|
+
const file = path.join(paths.loaded, `${cand.id}.md`)
|
|
20
|
+
writeFileSync(file, text)
|
|
21
|
+
return { file, lines: text.split("\n").length, chars: text.length }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const loadedLine = (cand: SessionCandidate, written: Loaded) =>
|
|
25
|
+
`Session ${shortId(cand.id)} "${titleOf(cand)}" written to ${written.file} (${written.lines} lines, ${Math.round(written.chars / 1000)}k characters). Read the whole file with the Read tool, from line 1 to line ${written.lines}, before acting.`
|
|
26
|
+
|
|
27
|
+
export const load: Command = {
|
|
28
|
+
summary: "Write an earlier session to a file for the agent to read",
|
|
29
|
+
usage: "eunoe load <id>",
|
|
30
|
+
hidden: true,
|
|
31
|
+
details: [
|
|
32
|
+
"Writes the whole record of an earlier session in this directory to a file:",
|
|
33
|
+
"your messages, the key output lines and the agent's replies. The agent runs",
|
|
34
|
+
"this when a message continues that session, then reads the file start to",
|
|
35
|
+
"end. Ids come from eunoe sessions; a unique prefix is enough.",
|
|
36
|
+
],
|
|
37
|
+
run(args) {
|
|
38
|
+
const cand = candidateOf(
|
|
39
|
+
args.find((a) => !a.startsWith("-")),
|
|
40
|
+
candidatesHere(),
|
|
41
|
+
)
|
|
42
|
+
line(loadedLine(cand, writeLoaded(cand)))
|
|
43
|
+
},
|
|
44
|
+
}
|
package/src/cli/commands/mode.ts
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
import { compactionOwner,
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import { MODE_DESCRIPTIONS } from "../constants"
|
|
6
|
-
import { routedDirs } from "../inspect"
|
|
7
|
-
import { interactive, select } from "../prompt"
|
|
1
|
+
import { compactionOwner, MODES, type Mode, readConfig, saveConfig } from "../../config"
|
|
2
|
+
import type { Command } from "../command"
|
|
3
|
+
import { editSettings, withCompactionOwner } from "../proxy/claude-settings"
|
|
4
|
+
import { routedDirs } from "../proxy/inspect"
|
|
8
5
|
import { isKnown, sessionArgs, shortId } from "../sessions"
|
|
9
|
-
import
|
|
10
|
-
import { c, fail, line, note, ok, warn } from "../ui"
|
|
6
|
+
import { interactive, select } from "../terminal/prompt"
|
|
7
|
+
import { c, fail, line, note, ok, warn } from "../terminal/ui"
|
|
8
|
+
|
|
9
|
+
export const MODE_DESCRIPTIONS: Record<Mode, string> = {
|
|
10
|
+
default: "Recent turns whole, older turns folded; evidence and recall on",
|
|
11
|
+
compact: "Claude Code as it is, only its compaction replaced",
|
|
12
|
+
off: "Passthrough; Claude Code compacts on its own",
|
|
13
|
+
}
|
|
11
14
|
|
|
12
15
|
function listModes(current: Mode) {
|
|
13
16
|
line()
|
|
@@ -17,50 +20,55 @@ function listModes(current: Mode) {
|
|
|
17
20
|
}
|
|
18
21
|
}
|
|
19
22
|
|
|
20
|
-
|
|
21
|
-
const config =
|
|
22
|
-
if (ids.length) {
|
|
23
|
+
function applyMode(next: Mode, ids: string[]) {
|
|
24
|
+
const config = readConfig()
|
|
25
|
+
if (ids.length > 0) {
|
|
23
26
|
saveConfig({ sessions: { ...config.sessions, ...Object.fromEntries(ids.map((id) => [id, { ...config.sessions[id], mode: next }])) } })
|
|
24
27
|
ok(`Mode ${c.bold(next)} for ${ids.map(shortId).join(", ")} ${c.dim(MODE_DESCRIPTIONS[next])}`)
|
|
25
|
-
if (
|
|
28
|
+
if (routedDirs(config).length === 0) warn("Claude Code isn't routed through eunoe. Run `eunoe start --session <id>`.")
|
|
26
29
|
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.`)
|
|
30
|
+
if (unseen.length > 0) note(`Not seen through eunoe yet: ${unseen.map(shortId).join(", ")}. Check the id in Claude Code's /status.`)
|
|
28
31
|
return
|
|
29
32
|
}
|
|
30
33
|
saveConfig({ mode: next })
|
|
31
|
-
const updated =
|
|
34
|
+
const updated = readConfig()
|
|
32
35
|
editSettings(routedDirs(updated), (settings) => withCompactionOwner(settings, compactionOwner(updated)))
|
|
33
36
|
ok(`Mode ${c.bold(next)} ${c.dim(MODE_DESCRIPTIONS[next])}`)
|
|
34
37
|
}
|
|
35
38
|
|
|
39
|
+
function printModes(current: Mode) {
|
|
40
|
+
const config = readConfig()
|
|
41
|
+
listModes(current)
|
|
42
|
+
const own = Object.entries(config.sessions).filter(([, settings]) => settings.mode)
|
|
43
|
+
if (own.length > 0) {
|
|
44
|
+
line()
|
|
45
|
+
for (const [id, settings] of own) line(` ${c.dim(shortId(id))} ${settings.mode}`)
|
|
46
|
+
}
|
|
47
|
+
line()
|
|
48
|
+
}
|
|
49
|
+
|
|
36
50
|
export const mode: Command = {
|
|
37
51
|
summary: "Pick how eunoe manages context",
|
|
38
52
|
usage: "eunoe mode [default | compact | off] [--session <id>]",
|
|
39
53
|
details: ["Without a mode it opens a picker. Takes effect on the next request; no restart needed."],
|
|
40
54
|
options: [["--session <id>", "Set the mode for just this session (repeatable; a unique prefix is enough)"]],
|
|
41
55
|
async run(args) {
|
|
42
|
-
const config =
|
|
43
|
-
const given = args.find((arg, i) => !arg.startsWith("-") && args[i - 1] !== "--session")
|
|
56
|
+
const config = readConfig()
|
|
57
|
+
const given = args.find((arg, i) => !arg.startsWith("-") && args[i - 1] !== "--session")
|
|
44
58
|
const ids = sessionArgs(args)
|
|
45
59
|
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()
|
|
60
|
+
if (!MODES.includes(given as Mode)) fail(`Unknown mode: ${given}`, `Choose ${MODES.join(", ")}.`)
|
|
61
|
+
applyMode(given as Mode, ids)
|
|
59
62
|
return
|
|
60
63
|
}
|
|
64
|
+
const current = ids.length === 1 ? (config.sessions[ids[0]]?.mode ?? config.mode) : config.mode
|
|
65
|
+
if (!interactive) return printModes(current)
|
|
61
66
|
line()
|
|
62
|
-
const chosen = await select(
|
|
63
|
-
|
|
64
|
-
|
|
67
|
+
const chosen = await select(
|
|
68
|
+
ids.length > 0 ? `Mode for ${ids.map(shortId).join(", ")}` : "Mode",
|
|
69
|
+
MODES.map((value) => ({ value, hint: MODE_DESCRIPTIONS[value] })),
|
|
70
|
+
current,
|
|
71
|
+
)
|
|
72
|
+
if (chosen !== null) applyMode(chosen, ids)
|
|
65
73
|
},
|
|
66
74
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Command } from "../command"
|
|
2
|
+
import { candidateOf, candidatesHere, peekText, shortId } from "../sessions"
|
|
3
|
+
import { fail, line } from "../terminal/ui"
|
|
4
|
+
|
|
5
|
+
export const peek: Command = {
|
|
6
|
+
summary: "Show the excerpts of an earlier session that match some words",
|
|
7
|
+
usage: "eunoe peek <id> <words>",
|
|
8
|
+
hidden: true,
|
|
9
|
+
details: [
|
|
10
|
+
"Searches one earlier session in this directory for the words and prints the",
|
|
11
|
+
"excerpts that match: tool output and your messages, quoted exactly. For a",
|
|
12
|
+
"single fact such as an error message or a value. To continue the session's",
|
|
13
|
+
"work the agent should eunoe load it instead. Ids come from eunoe sessions.",
|
|
14
|
+
],
|
|
15
|
+
run(args) {
|
|
16
|
+
const [id, ...rest] = args.filter((a) => !a.startsWith("-"))
|
|
17
|
+
const cand = candidateOf(id, candidatesHere())
|
|
18
|
+
const words = rest.join(" ").trim()
|
|
19
|
+
if (!words) fail("Say what to look for: eunoe peek <id> <words>")
|
|
20
|
+
line(peekText(cand, words) ?? fail(`Nothing in ${shortId(cand.id)} matches "${words}".`))
|
|
21
|
+
},
|
|
22
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { closeSync, openSync, readSync, statSync } from "node:fs"
|
|
2
|
+
import { cpus } from "node:os"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import { claudeConfigDirs, DEFAULTS, parseTunable, readConfig } from "../../config"
|
|
5
|
+
import { listEntries, projectFolderName, projectFolders } from "../../transcript/locate"
|
|
6
|
+
import type { Command } from "../command"
|
|
7
|
+
import { cleanupDays } from "../proxy/claude-settings"
|
|
8
|
+
import { recentRequests } from "../proxy/inspect"
|
|
9
|
+
import type { ReplayedSession, ReplayFailure, ReplayOptions } from "../replay"
|
|
10
|
+
import { shortId } from "../sessions"
|
|
11
|
+
import { c, fail, line, percent, plural, rows, table, tilde, tokens, useColor, warn } from "../terminal/ui"
|
|
12
|
+
|
|
13
|
+
interface Target {
|
|
14
|
+
file: string
|
|
15
|
+
bytes: number
|
|
16
|
+
lastActive: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const WINDOW = 1_000_000
|
|
20
|
+
const HEAD_BYTES = 64 * 1024
|
|
21
|
+
const TOP_SESSIONS = 10
|
|
22
|
+
|
|
23
|
+
function targets(configDirs: string[], ids: string[], here: string | null): Target[] {
|
|
24
|
+
const seen = new Map<string, Target>()
|
|
25
|
+
const wanted = (id: string) => ids.length === 0 || ids.some((prefix) => id.startsWith(prefix))
|
|
26
|
+
const onlyFolder = here ? projectFolderName(here) : null
|
|
27
|
+
for (const folder of projectFolders(configDirs)) {
|
|
28
|
+
if (onlyFolder && path.basename(folder) !== onlyFolder) continue
|
|
29
|
+
for (const name of listEntries(folder)) {
|
|
30
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl$/.test(name)) continue
|
|
31
|
+
const id = name.slice(0, -".jsonl".length)
|
|
32
|
+
if (!wanted(id) || seen.has(id)) continue
|
|
33
|
+
const file = path.join(folder, name)
|
|
34
|
+
const stat = statSync(file)
|
|
35
|
+
seen.set(id, { file, bytes: stat.size, lastActive: stat.mtimeMs })
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return [...seen.values()].sort((a, b) => b.bytes - a.bytes)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function fixedTokens(): { value: number; measured: boolean } {
|
|
42
|
+
const sizes = recentRequests()
|
|
43
|
+
.flatMap((r) => (r.charsFixed && r.charsOut && r.input ? [Math.round((r.input * r.charsFixed) / r.charsOut)] : []))
|
|
44
|
+
.sort((a, b) => a - b)
|
|
45
|
+
if (sizes.length === 0) return { value: 30_000, measured: false }
|
|
46
|
+
return { value: sizes[Math.floor(sizes.length / 2)], measured: true }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const day = (ms: number) => new Date(ms).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })
|
|
50
|
+
|
|
51
|
+
const duration = (ms: number) => (ms < 60_000 ? `${Math.max(1, Math.round(ms / 1000))}s` : `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`)
|
|
52
|
+
const size = (bytes: number) => (bytes >= 1e9 ? `${(bytes / 1e9).toFixed(1)} GB` : `${Math.round(bytes / 1e6)} MB`)
|
|
53
|
+
const fewer = (plain: number, eunoe: number) => (plain > 0 ? percent(1 - eunoe / plain) : "–")
|
|
54
|
+
|
|
55
|
+
function startedAt(target: Target): number {
|
|
56
|
+
const buffer = Buffer.alloc(HEAD_BYTES)
|
|
57
|
+
let read = 0
|
|
58
|
+
try {
|
|
59
|
+
const fd = openSync(target.file, "r")
|
|
60
|
+
read = readSync(fd, buffer, 0, HEAD_BYTES, 0)
|
|
61
|
+
closeSync(fd)
|
|
62
|
+
} catch {
|
|
63
|
+
return target.lastActive
|
|
64
|
+
}
|
|
65
|
+
const found = /"timestamp":"([^"]+)"/.exec(buffer.toString("utf8", 0, read))
|
|
66
|
+
const at = found ? Date.parse(found[1]) : Number.NaN
|
|
67
|
+
return Number.isNaN(at) ? target.lastActive : Math.min(at, target.lastActive)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function historyLine(list: Target[], keepDays: number): string {
|
|
71
|
+
const span = `${day(Math.min(...list.map(startedAt)))} – ${day(Math.max(...list.map((t) => t.lastActive)))}`
|
|
72
|
+
return `${span} ${c.dim(`· the sessions still on disk; Claude Code deletes sessions idle for ${keepDays} days (cleanupPeriodDays), so totals cover a moving window`)}`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function replayAll(list: Target[], options: ReplayOptions, onDone: (done: number, bytes: number) => void): Promise<{ results: ReplayedSession[]; failures: ReplayFailure[] }> {
|
|
76
|
+
const results: ReplayedSession[] = []
|
|
77
|
+
const failures: ReplayFailure[] = []
|
|
78
|
+
const queue = [...list]
|
|
79
|
+
let bytesDone = 0
|
|
80
|
+
const workers = Math.max(1, Math.min(8, cpus().length - 1, list.length))
|
|
81
|
+
await Promise.all(
|
|
82
|
+
Array.from(
|
|
83
|
+
{ length: workers },
|
|
84
|
+
() =>
|
|
85
|
+
new Promise<void>((resolve) => {
|
|
86
|
+
let worker = new Worker(new URL("../replay/worker.ts", import.meta.url).href)
|
|
87
|
+
let current: Target | undefined
|
|
88
|
+
const finish = (target: Target) => {
|
|
89
|
+
bytesDone += target.bytes
|
|
90
|
+
onDone(results.length + failures.length, bytesDone)
|
|
91
|
+
}
|
|
92
|
+
const next = () => {
|
|
93
|
+
current = queue.shift()
|
|
94
|
+
if (!current) {
|
|
95
|
+
worker.terminate()
|
|
96
|
+
resolve()
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
worker.postMessage({ file: current.file, options })
|
|
100
|
+
}
|
|
101
|
+
const listen = () => {
|
|
102
|
+
worker.onmessage = (event: MessageEvent<ReplayedSession | ReplayFailure>) => {
|
|
103
|
+
if ("error" in event.data) failures.push(event.data)
|
|
104
|
+
else results.push(event.data)
|
|
105
|
+
if (current) finish(current)
|
|
106
|
+
next()
|
|
107
|
+
}
|
|
108
|
+
worker.onerror = (event) => {
|
|
109
|
+
event.preventDefault()
|
|
110
|
+
if (current) {
|
|
111
|
+
failures.push({ file: current.file, error: event.message || "worker crashed" })
|
|
112
|
+
finish(current)
|
|
113
|
+
}
|
|
114
|
+
worker.terminate()
|
|
115
|
+
worker = new Worker(new URL("../replay/worker.ts", import.meta.url).href)
|
|
116
|
+
listen()
|
|
117
|
+
next()
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
listen()
|
|
121
|
+
next()
|
|
122
|
+
}),
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
return { results, failures }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export const replay: Command = {
|
|
129
|
+
summary: "Replay your Claude Code history through eunoe and show what it saves",
|
|
130
|
+
usage: "eunoe replay [session…] [--here] [--window <tokens>]",
|
|
131
|
+
details: [
|
|
132
|
+
"Reads every Claude Code session on this machine and replays each request",
|
|
133
|
+
"through eunoe's folding, without calling Claude. Shows the input tokens",
|
|
134
|
+
"and the cache-weighted cost plain Claude Code would have paid against what",
|
|
135
|
+
"eunoe would have sent, the folds it would have made, and the compactions",
|
|
136
|
+
"Claude Code would have needed. Deterministic and offline.",
|
|
137
|
+
],
|
|
138
|
+
options: [
|
|
139
|
+
["--here", "Only sessions from the current directory"],
|
|
140
|
+
["--window <tokens>", `Context window to assume (default ${tokens(WINDOW)})`],
|
|
141
|
+
["--turn-cap <tokens|off>", "Override the turnCap setting for this replay"],
|
|
142
|
+
],
|
|
143
|
+
async run(args) {
|
|
144
|
+
const windowAt = args.indexOf("--window")
|
|
145
|
+
const capAt = args.indexOf("--turn-cap")
|
|
146
|
+
const optionValues = [windowAt, capAt].filter((at) => at !== -1).map((at) => args[at + 1])
|
|
147
|
+
const ids = args.filter((a) => !a.startsWith("-") && !optionValues.includes(a))
|
|
148
|
+
const window = windowAt === -1 ? WINDOW : Number(args[windowAt + 1])
|
|
149
|
+
if (!(window > 0)) fail("--window needs a token count", "eunoe replay --window 200000")
|
|
150
|
+
const config = readConfig()
|
|
151
|
+
const turnCap = capAt === -1 ? config.turnCap : parseTunable("turnCap", args[capAt + 1] ?? "")
|
|
152
|
+
if (turnCap === null) fail("--turn-cap needs a token count or off", "eunoe replay --turn-cap off")
|
|
153
|
+
const configDirs = claudeConfigDirs(config)
|
|
154
|
+
const list = targets(configDirs, ids, args.includes("--here") ? process.cwd() : null)
|
|
155
|
+
if (list.length === 0) fail("No Claude Code sessions found.")
|
|
156
|
+
const totalBytes = list.reduce((total, entry) => total + entry.bytes, 0)
|
|
157
|
+
const fixed = fixedTokens()
|
|
158
|
+
line()
|
|
159
|
+
const options: ReplayOptions = { config: { ...config, turnCap, mode: config.mode === "off" ? DEFAULTS.mode : config.mode }, window, fixedTokens: fixed.value }
|
|
160
|
+
const started = Date.now()
|
|
161
|
+
const progress = useColor && process.stdout.isTTY
|
|
162
|
+
const { results, failures } = await replayAll(list, options, (done, doneBytes) => {
|
|
163
|
+
if (!progress) return
|
|
164
|
+
const left = doneBytes > 0 ? ` · ~${duration(((Date.now() - started) / doneBytes) * (totalBytes - doneBytes))} left` : ""
|
|
165
|
+
process.stdout.write(`\r\x1b[K ${c.dim(`Replaying ${done} / ${list.length} sessions · ${size(doneBytes)} of ${size(totalBytes)}${left}`)}`)
|
|
166
|
+
})
|
|
167
|
+
if (progress) process.stdout.write("\r\x1b[K")
|
|
168
|
+
const sum = results.reduce(
|
|
169
|
+
(acc, r) => {
|
|
170
|
+
for (const key of ["requests", "folds", "dropped", "nativeCompactions", "discarded", "plainTokens", "eunoeTokens", "plainCost", "eunoeCost"] as const) acc[key] += r[key]
|
|
171
|
+
acc.peakPlain = Math.max(acc.peakPlain, r.peakPlain)
|
|
172
|
+
acc.peakEunoe = Math.max(acc.peakEunoe, r.peakEunoe)
|
|
173
|
+
return acc
|
|
174
|
+
},
|
|
175
|
+
{ requests: 0, folds: 0, dropped: 0, nativeCompactions: 0, discarded: 0, plainTokens: 0, eunoeTokens: 0, plainCost: 0, eunoeCost: 0, peakPlain: 0, peakEunoe: 0 },
|
|
176
|
+
)
|
|
177
|
+
const compared = results.filter((r) => r.requests > 0)
|
|
178
|
+
line(` ${c.bold("eunoe replay")} ${c.dim("your Claude Code history, as eunoe would have sent it")}`)
|
|
179
|
+
line()
|
|
180
|
+
rows(
|
|
181
|
+
[
|
|
182
|
+
["Sessions", `${c.bold(compared.length.toLocaleString())} replayed ${c.dim("·")} ${size(totalBytes)} ${c.dim("·")} ${duration(Date.now() - started)}`],
|
|
183
|
+
["History", historyLine(list, cleanupDays(configDirs))],
|
|
184
|
+
["Requests", c.bold(sum.requests.toLocaleString())],
|
|
185
|
+
["Window", `${tokens(window)} ${c.dim(`· ${tokens(fixed.value)} of it system prompt and tools, ${fixed.measured ? "from your logged requests" : "assumed"}`)}`],
|
|
186
|
+
],
|
|
187
|
+
4,
|
|
188
|
+
)
|
|
189
|
+
line()
|
|
190
|
+
rows(
|
|
191
|
+
[
|
|
192
|
+
[
|
|
193
|
+
"History kept",
|
|
194
|
+
`eunoe ${c.bold(c.green("every turn"))}, folded or whole ${c.dim("·")} Claude Code compacted ${c.bold(sum.nativeCompactions.toLocaleString())} time${sum.nativeCompactions === 1 ? "" : "s"}, ${c.bold(tokens(sum.discarded))} tokens of conversation replaced by summaries`,
|
|
195
|
+
],
|
|
196
|
+
[
|
|
197
|
+
"Input tokens",
|
|
198
|
+
`plain ${tokens(sum.plainTokens)} ${c.dim("→")} eunoe ${c.bold(tokens(sum.eunoeTokens))} tokens ${c.green(c.bold(`${fewer(sum.plainTokens, sum.eunoeTokens)} fewer`))} ${c.dim("the conversation; system prompt and tools are the same on both sides")}`,
|
|
199
|
+
],
|
|
200
|
+
["Cost", `${c.green(c.bold(`${fewer(sum.plainCost, sum.eunoeCost)} cheaper`))} ${c.dim("cache reads at 0.1×, writes at 1.25×")}`],
|
|
201
|
+
["Folds", c.bold(sum.folds.toLocaleString())],
|
|
202
|
+
["Folds dropped", `${c.bold(sum.dropped.toLocaleString())} ${c.dim("folded turns removed entirely to fit the window")}`],
|
|
203
|
+
["Peak context", `plain ${tokens(sum.peakPlain)} ${c.dim("→")} eunoe ${c.bold(tokens(sum.peakEunoe))}`],
|
|
204
|
+
],
|
|
205
|
+
4,
|
|
206
|
+
)
|
|
207
|
+
const saved = [...compared]
|
|
208
|
+
.filter((r) => r.nativeCompactions > 0)
|
|
209
|
+
.sort((a, b) => b.discarded - a.discarded)
|
|
210
|
+
.slice(0, TOP_SESSIONS)
|
|
211
|
+
if (saved.length > 0) {
|
|
212
|
+
line()
|
|
213
|
+
line(` ${c.dim("Sessions Claude Code would have compacted, by history it would have lost")}`)
|
|
214
|
+
table(
|
|
215
|
+
saved.map((r) => [
|
|
216
|
+
c.cyan(shortId(r.id)),
|
|
217
|
+
(r.title ?? (r.cwd ? tilde(r.cwd) : "")).slice(0, 40),
|
|
218
|
+
c.bold(c.green(`${plural(r.nativeCompactions, "compaction")} avoided`)),
|
|
219
|
+
`${tokens(r.discarded)} tokens kept`,
|
|
220
|
+
c.dim(`${plural(r.folds, "fold")} · ${tokens(r.plainTokens)} → ${tokens(r.eunoeTokens)} tokens`),
|
|
221
|
+
]),
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
const top = [...compared].sort((a, b) => b.plainTokens - b.eunoeTokens - (a.plainTokens - a.eunoeTokens)).slice(0, TOP_SESSIONS)
|
|
225
|
+
if (top.length > 1) {
|
|
226
|
+
line()
|
|
227
|
+
line(` ${c.dim("Biggest token savings")}`)
|
|
228
|
+
table(
|
|
229
|
+
top.map((r) => [
|
|
230
|
+
c.cyan(shortId(r.id)),
|
|
231
|
+
(r.title ?? (r.cwd ? tilde(r.cwd) : "")).slice(0, 40),
|
|
232
|
+
`${tokens(r.plainTokens)} ${c.dim("→")} ${tokens(r.eunoeTokens)} tokens`,
|
|
233
|
+
c.green(c.bold(`${tokens(r.plainTokens - r.eunoeTokens)} tokens saved`)),
|
|
234
|
+
c.dim(`${fewer(r.plainTokens, r.eunoeTokens)} fewer`),
|
|
235
|
+
c.dim(plural(r.folds, "fold")),
|
|
236
|
+
]),
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
if (failures.length > 0) {
|
|
240
|
+
line()
|
|
241
|
+
warn(`${plural(failures.length, "session")} couldn't be replayed and ${failures.length === 1 ? "is" : "are"} not counted: ${failures.map((f) => `${shortId(path.basename(f.file, ".jsonl"))} (${f.error})`).join(", ")}`)
|
|
242
|
+
}
|
|
243
|
+
line()
|
|
244
|
+
},
|
|
245
|
+
}
|