@chroxify/eunoe 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +55 -9
  2. package/package.json +18 -2
  3. package/src/cli/{types.ts → command.ts} +0 -4
  4. package/src/cli/commands/index.ts +11 -1
  5. package/src/cli/commands/load.ts +44 -0
  6. package/src/cli/commands/mode.ts +41 -33
  7. package/src/cli/commands/peek.ts +22 -0
  8. package/src/cli/commands/replay.ts +194 -0
  9. package/src/cli/commands/serve.ts +22 -7
  10. package/src/cli/commands/sessions.ts +70 -0
  11. package/src/cli/commands/settings.ts +79 -60
  12. package/src/cli/commands/start.ts +27 -18
  13. package/src/cli/commands/status.ts +98 -55
  14. package/src/cli/commands/stop.ts +8 -6
  15. package/src/cli/commands/uninstall.ts +10 -12
  16. package/src/cli/commands/watch.ts +16 -0
  17. package/src/cli/logo/art.ts +99 -0
  18. package/src/cli/logo/index.ts +12 -18
  19. package/src/cli/main.ts +3 -3
  20. package/src/cli/proxy/claude-settings.ts +59 -0
  21. package/src/cli/proxy/inspect.ts +67 -0
  22. package/src/cli/proxy/launchd.ts +53 -0
  23. package/src/cli/proxy/service.ts +53 -0
  24. package/src/cli/{stats.ts → proxy/stats.ts} +23 -15
  25. package/src/cli/proxy/watch.ts +60 -0
  26. package/src/cli/replay/index.ts +141 -0
  27. package/src/cli/replay/worker.ts +15 -0
  28. package/src/cli/sessions/browse.ts +88 -0
  29. package/src/cli/sessions/index.ts +76 -0
  30. package/src/cli/sessions/source.ts +74 -0
  31. package/src/cli/{help.ts → terminal/help.ts} +24 -8
  32. package/src/cli/terminal/prompt.ts +114 -0
  33. package/src/cli/{ui.ts → terminal/ui.ts} +55 -24
  34. package/src/config/index.ts +99 -34
  35. package/src/config/paths.ts +17 -0
  36. package/src/context/content.ts +40 -14
  37. package/src/context/fold/evidence.ts +145 -0
  38. package/src/context/fold/index.ts +65 -0
  39. package/src/context/fold/strategies.ts +94 -0
  40. package/src/context/fold/turns.ts +76 -0
  41. package/src/context/prompts.ts +141 -22
  42. package/src/context/recall/index.ts +214 -0
  43. package/src/context/recall/rerank.ts +71 -0
  44. package/src/proxy/canonical.ts +17 -9
  45. package/src/proxy/compaction.ts +106 -53
  46. package/src/proxy/estimate.ts +14 -9
  47. package/src/proxy/fingerprint.ts +23 -9
  48. package/src/proxy/handoff.ts +71 -0
  49. package/src/proxy/log.ts +2 -2
  50. package/src/proxy/recall.ts +58 -0
  51. package/src/proxy/rewrite.ts +141 -119
  52. package/src/proxy/server.ts +108 -51
  53. package/src/proxy/sessions.ts +32 -14
  54. package/src/proxy/state.ts +17 -3
  55. package/src/proxy/usage.ts +50 -34
  56. package/src/proxy/window.ts +32 -34
  57. package/src/transcript/format.ts +88 -25
  58. package/src/transcript/locate.ts +38 -17
  59. package/src/transcript/messages.ts +3 -14
  60. package/src/transcript/parse.ts +137 -56
  61. package/src/transcript/render.ts +44 -17
  62. package/src/transcript/search.ts +20 -56
  63. package/src/transcript/subagents.ts +150 -0
  64. package/src/cli/claude-settings.ts +0 -42
  65. package/src/cli/constants.ts +0 -52
  66. package/src/cli/inspect.ts +0 -41
  67. package/src/cli/launchd.ts +0 -39
  68. package/src/cli/logo/pixels.ts +0 -34
  69. package/src/cli/prompt.ts +0 -91
  70. package/src/cli/service.ts +0 -33
  71. package/src/cli/sessions.ts +0 -25
  72. package/src/config/constants.ts +0 -39
  73. package/src/config/types.ts +0 -50
  74. package/src/context/constants.ts +0 -42
  75. package/src/context/evidence.ts +0 -92
  76. package/src/context/recall.ts +0 -155
  77. package/src/context/rerank.ts +0 -44
  78. package/src/context/strategies.ts +0 -89
  79. package/src/context/turns.ts +0 -87
  80. package/src/context/types.ts +0 -66
  81. package/src/proxy/constants.ts +0 -29
  82. package/src/proxy/types.ts +0 -82
  83. package/src/transcript/constants.ts +0 -15
  84. package/src/transcript/types.ts +0 -43
  85. /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; on other platforms keep `eunoe serve` running however
102
- you like and everything else works the same.
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:8788
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,35 @@ 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 replay # replay your whole Claude Code history through eunoe, offline, and see what it saves
324
340
  eunoe uninstall # stop and remove the background proxy
325
341
  ```
326
342
 
327
343
  `ee` is an alias, so `ee status` and `eunoe status` are the same command.
328
344
 
345
+ ### Earlier sessions
346
+
347
+ A different big problem with Claude is that new chats always start cold. eunoe
348
+ helps you carry an earlier session into one: run `eunoe sessions`, pick the
349
+ session (type to filter), and paste the copied `{ee:continue:<id>}` marker into
350
+ the chat along with what you want done. eunoe attaches that whole session to
351
+ the message, folded the same way it folds turns in a live session: your
352
+ messages, the key output lines and the agent's replies, in order. From then on
353
+ the earlier session's dropped tool output is searchable too, so an exact value
354
+ from it comes back as recalled context when a later message is about it.
355
+
356
+ You always name the session. eunoe never guesses which one you mean or injects
357
+ one on its own; in the benchmark that choice was wrong a third of the time,
358
+ while a named session remembered 98% of its facts and kept 95% of its rules.
359
+
360
+ ```sh
361
+ eunoe sessions [words] # browse earlier sessions here; enter copies {ee:continue:<id>}
362
+ ```
363
+
364
+ The agent is also taught two commands for digging on its own: `eunoe load
365
+ <id>` writes a session to `~/.eunoe/loaded/<id>.md` for it to read whole, and
366
+ `eunoe peek <id> <words>` prints just the excerpts that match.
367
+
329
368
  ### Per session
330
369
 
331
370
  Everything above takes `--session <id>` to apply to one session only. Session
@@ -350,6 +389,7 @@ session's next request.
350
389
  |---|---|---|
351
390
  | `keep` | `3` | turns kept whole at the end |
352
391
  | `budget` | `100000` | tokens of older detail that trigger a fold |
392
+ | `turnCap` | `100000` | a kept turn larger than this is folded at once, the live turn excepted; `off` to disable |
353
393
  | `compactAt` | `0.9` | share of the context window that triggers the full cut |
354
394
  | `keepTurns` | `all` | folded turns kept after a full cut |
355
395
  | `search` | `markdown` | transcript form for the agent: `markdown`, `xml`, `jsonl` or `qmd` (semantic, via [qmd](https://github.com/tobi/qmd)) |
@@ -380,8 +420,14 @@ to it and how much of it the API served from cache, which is also where
380
420
 
381
421
  - **It is a proxy, which makes it a single point of failure.** If it isn't
382
422
  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.
423
+ fails. Three things limit the damage: `start` refuses to route Claude Code
424
+ until the proxy answers, launchd restarts the proxy within a second whenever
425
+ it dies, and a watchdog that runs every 10 seconds takes eunoe out of
426
+ `settings.json` after 20 seconds of silence and puts it back when the proxy
427
+ answers again. A request the proxy can't deliver upstream comes back as a
428
+ 502 that Claude Code retries, never a hang. Sessions that were already
429
+ running keep the old address until restarted, so they wait for the proxy
430
+ rather than falling back.
385
431
  - **It only understands Claude Code's shape of conversation.** Other
386
432
  Anthropic API clients pass through unchanged and get nothing out of it.
387
433
  - **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.0.0",
3
+ "version": "1.1.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
- "typecheck": "tsc --noEmit -p ."
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,16 @@
1
- import type { Command } from "../types"
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"
6
10
  import { status } from "./status"
7
11
  import { stop } from "./stop"
8
12
  import { uninstall } from "./uninstall"
13
+ import { watch } from "./watch"
9
14
 
10
15
  const alias = (target: Command, prefix: string[] = []): Command => ({ ...target, hidden: true, run: (args) => target.run([...prefix, ...args]) })
11
16
 
@@ -15,8 +20,13 @@ export const commands: Record<string, Command> = {
15
20
  mode,
16
21
  settings,
17
22
  status,
23
+ replay,
24
+ sessions,
25
+ peek,
26
+ load,
18
27
  uninstall,
19
28
  serve,
29
+ watch,
20
30
  enable: alias(start),
21
31
  disable: alias(stop),
22
32
  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
+ }
@@ -1,13 +1,16 @@
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"
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 type { Command } from "../types"
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
- export function applyMode(next: Mode, ids: string[]) {
21
- const config = loadConfig()
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 (!routedDirs(config).length) warn("Claude Code isn't routed through eunoe. Run `eunoe start --session <id>`.")
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 = loadConfig()
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 = loadConfig()
43
- const given = args.find((arg, i) => !arg.startsWith("-") && args[i - 1] !== "--session") as Mode | undefined
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(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)
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,194 @@
1
+ import { 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 { recentRequests } from "../proxy/inspect"
8
+ import type { ReplayedSession, ReplayOptions } from "../replay"
9
+ import { shortId } from "../sessions"
10
+ import { c, fail, line, percent, plural, rows, table, tilde, tokens, useColor } from "../terminal/ui"
11
+
12
+ interface Target {
13
+ file: string
14
+ bytes: number
15
+ }
16
+
17
+ const WINDOW = 1_000_000
18
+ const TOP_SESSIONS = 10
19
+
20
+ function targets(configDirs: string[], ids: string[], here: string | null): Target[] {
21
+ const seen = new Map<string, Target>()
22
+ const wanted = (id: string) => ids.length === 0 || ids.some((prefix) => id.startsWith(prefix))
23
+ const onlyFolder = here ? projectFolderName(here) : null
24
+ for (const folder of projectFolders(configDirs)) {
25
+ if (onlyFolder && path.basename(folder) !== onlyFolder) continue
26
+ for (const name of listEntries(folder)) {
27
+ 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
28
+ const id = name.slice(0, -".jsonl".length)
29
+ if (!wanted(id) || seen.has(id)) continue
30
+ const file = path.join(folder, name)
31
+ seen.set(id, { file, bytes: statSync(file).size })
32
+ }
33
+ }
34
+ return [...seen.values()].sort((a, b) => b.bytes - a.bytes)
35
+ }
36
+
37
+ function fixedTokens(): { value: number; measured: boolean } {
38
+ const sizes = recentRequests()
39
+ .flatMap((r) => (r.charsFixed && r.charsOut && r.input ? [Math.round((r.input * r.charsFixed) / r.charsOut)] : []))
40
+ .sort((a, b) => a - b)
41
+ if (sizes.length === 0) return { value: 30_000, measured: false }
42
+ return { value: sizes[Math.floor(sizes.length / 2)], measured: true }
43
+ }
44
+
45
+ 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`)
46
+ const size = (bytes: number) => (bytes >= 1e9 ? `${(bytes / 1e9).toFixed(1)} GB` : `${Math.round(bytes / 1e6)} MB`)
47
+ const fewer = (plain: number, eunoe: number) => (plain > 0 ? percent(1 - eunoe / plain) : "–")
48
+
49
+ async function replayAll(list: Target[], options: ReplayOptions, onDone: (done: number, bytes: number) => void): Promise<ReplayedSession[]> {
50
+ const results: ReplayedSession[] = []
51
+ const queue = [...list]
52
+ let bytesDone = 0
53
+ const workers = Math.max(1, Math.min(8, cpus().length - 1, list.length))
54
+ await Promise.all(
55
+ Array.from(
56
+ { length: workers },
57
+ () =>
58
+ new Promise<void>((resolve) => {
59
+ const worker = new Worker(new URL("../replay/worker.ts", import.meta.url).href)
60
+ const next = () => {
61
+ const target = queue.shift()
62
+ if (!target) {
63
+ worker.terminate()
64
+ resolve()
65
+ return
66
+ }
67
+ worker.postMessage({ file: target.file, options })
68
+ }
69
+ worker.onmessage = (event: MessageEvent<ReplayedSession>) => {
70
+ results.push(event.data)
71
+ bytesDone += event.data.bytes
72
+ onDone(results.length, bytesDone)
73
+ next()
74
+ }
75
+ worker.onerror = () => next()
76
+ next()
77
+ }),
78
+ ),
79
+ )
80
+ return results
81
+ }
82
+
83
+ export const replay: Command = {
84
+ summary: "Replay your Claude Code history through eunoe and show what it saves",
85
+ usage: "eunoe replay [session…] [--here] [--window <tokens>]",
86
+ details: [
87
+ "Reads every Claude Code session on this machine and replays each request",
88
+ "through eunoe's folding, without calling Claude. Shows the input tokens",
89
+ "and the cache-weighted cost plain Claude Code would have paid against what",
90
+ "eunoe would have sent, the folds it would have made, and the compactions",
91
+ "Claude Code would have needed. Deterministic and offline.",
92
+ ],
93
+ options: [
94
+ ["--here", "Only sessions from the current directory"],
95
+ ["--window <tokens>", `Context window to assume (default ${tokens(WINDOW)})`],
96
+ ["--turn-cap <tokens|off>", "Override the turnCap setting for this replay"],
97
+ ],
98
+ async run(args) {
99
+ const windowAt = args.indexOf("--window")
100
+ const capAt = args.indexOf("--turn-cap")
101
+ const optionValues = [windowAt, capAt].filter((at) => at !== -1).map((at) => args[at + 1])
102
+ const ids = args.filter((a) => !a.startsWith("-") && !optionValues.includes(a))
103
+ const window = windowAt === -1 ? WINDOW : Number(args[windowAt + 1])
104
+ if (!(window > 0)) fail("--window needs a token count", "eunoe replay --window 200000")
105
+ const config = readConfig()
106
+ const turnCap = capAt === -1 ? config.turnCap : parseTunable("turnCap", args[capAt + 1] ?? "")
107
+ if (turnCap === null) fail("--turn-cap needs a token count or off", "eunoe replay --turn-cap off")
108
+ const list = targets(claudeConfigDirs(config), ids, args.includes("--here") ? process.cwd() : null)
109
+ if (list.length === 0) fail("No Claude Code sessions found.")
110
+ const totalBytes = list.reduce((total, entry) => total + entry.bytes, 0)
111
+ const fixed = fixedTokens()
112
+ line()
113
+ const options: ReplayOptions = { config: { ...config, turnCap, mode: config.mode === "off" ? DEFAULTS.mode : config.mode }, window, fixedTokens: fixed.value }
114
+ const started = Date.now()
115
+ const progress = useColor && process.stdout.isTTY
116
+ const results = await replayAll(list, options, (done, doneBytes) => {
117
+ if (!progress) return
118
+ const left = doneBytes > 0 ? ` · ~${duration(((Date.now() - started) / doneBytes) * (totalBytes - doneBytes))} left` : ""
119
+ process.stdout.write(`\r\x1b[K ${c.dim(`Replaying ${done} / ${list.length} sessions · ${size(doneBytes)} of ${size(totalBytes)}${left}`)}`)
120
+ })
121
+ if (progress) process.stdout.write("\r\x1b[K")
122
+ const sum = results.reduce(
123
+ (acc, r) => {
124
+ for (const key of ["requests", "folds", "dropped", "nativeCompactions", "discarded", "plainTokens", "eunoeTokens", "plainCost", "eunoeCost"] as const) acc[key] += r[key]
125
+ acc.peakPlain = Math.max(acc.peakPlain, r.peakPlain)
126
+ acc.peakEunoe = Math.max(acc.peakEunoe, r.peakEunoe)
127
+ return acc
128
+ },
129
+ { requests: 0, folds: 0, dropped: 0, nativeCompactions: 0, discarded: 0, plainTokens: 0, eunoeTokens: 0, plainCost: 0, eunoeCost: 0, peakPlain: 0, peakEunoe: 0 },
130
+ )
131
+ const compared = results.filter((r) => r.requests > 0)
132
+ line(` ${c.bold("eunoe replay")} ${c.dim("your Claude Code history, as eunoe would have sent it")}`)
133
+ line()
134
+ rows(
135
+ [
136
+ ["Sessions", `${c.bold(String(compared.length))} replayed ${c.dim("·")} ${size(totalBytes)} ${c.dim("·")} ${duration(Date.now() - started)}`],
137
+ ["Requests", c.bold(sum.requests.toLocaleString())],
138
+ ["Window", `${tokens(window)} ${c.dim(`· ${tokens(fixed.value)} of it system prompt and tools, ${fixed.measured ? "from your logged requests" : "assumed"}`)}`],
139
+ ],
140
+ 4,
141
+ )
142
+ line()
143
+ rows(
144
+ [
145
+ [
146
+ "History kept",
147
+ `eunoe ${c.bold(c.green("every turn"))}, folded or whole ${c.dim("·")} Claude Code compacted ${c.bold(String(sum.nativeCompactions))} time${sum.nativeCompactions === 1 ? "" : "s"}, ${c.bold(tokens(sum.discarded))} tokens of conversation replaced by summaries`,
148
+ ],
149
+ [
150
+ "Input tokens",
151
+ `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")}`,
152
+ ],
153
+ ["Cost", `${c.green(c.bold(`${fewer(sum.plainCost, sum.eunoeCost)} cheaper`))} ${c.dim("cache reads at 0.1×, writes at 1.25×")}`],
154
+ ["Folds", c.bold(String(sum.folds))],
155
+ ["Folds dropped", `${c.bold(String(sum.dropped))} ${c.dim("folded turns removed entirely to fit the window")}`],
156
+ ["Peak context", `plain ${tokens(sum.peakPlain)} ${c.dim("→")} eunoe ${c.bold(tokens(sum.peakEunoe))}`],
157
+ ],
158
+ 4,
159
+ )
160
+ const saved = [...compared]
161
+ .filter((r) => r.nativeCompactions > 0)
162
+ .sort((a, b) => b.discarded - a.discarded)
163
+ .slice(0, TOP_SESSIONS)
164
+ if (saved.length > 0) {
165
+ line()
166
+ line(` ${c.dim("Sessions Claude Code would have compacted, by history it would have lost")}`)
167
+ table(
168
+ saved.map((r) => [
169
+ c.cyan(shortId(r.id)),
170
+ (r.title ?? (r.cwd ? tilde(r.cwd) : "")).slice(0, 40),
171
+ c.bold(c.green(`${plural(r.nativeCompactions, "compaction")} avoided`)),
172
+ `${tokens(r.discarded)} tokens kept`,
173
+ c.dim(`${plural(r.folds, "fold")} · ${tokens(r.plainTokens)} → ${tokens(r.eunoeTokens)} tokens`),
174
+ ]),
175
+ )
176
+ }
177
+ const top = [...compared].sort((a, b) => b.plainTokens - b.eunoeTokens - (a.plainTokens - a.eunoeTokens)).slice(0, TOP_SESSIONS)
178
+ if (top.length > 1) {
179
+ line()
180
+ line(` ${c.dim("Biggest token savings")}`)
181
+ table(
182
+ top.map((r) => [
183
+ c.cyan(shortId(r.id)),
184
+ (r.title ?? (r.cwd ? tilde(r.cwd) : "")).slice(0, 40),
185
+ `${tokens(r.plainTokens)} ${c.dim("→")} ${tokens(r.eunoeTokens)} tokens`,
186
+ c.green(c.bold(`${tokens(r.plainTokens - r.eunoeTokens)} tokens saved`)),
187
+ c.dim(`${fewer(r.plainTokens, r.eunoeTokens)} fewer`),
188
+ c.dim(plural(r.folds, "fold")),
189
+ ]),
190
+ )
191
+ }
192
+ line()
193
+ },
194
+ }
@@ -1,10 +1,22 @@
1
- import { loadConfig } from "../../config"
1
+ import { readConfig } from "../../config"
2
2
  import { serve as startProxy } from "../../proxy/server"
3
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"
4
+ import type { Command } from "../command"
5
+ import { proxyUrl } from "../proxy/claude-settings"
6
+ import { probe } from "../proxy/inspect"
7
+ import { c, fail, line, warn } from "../terminal/ui"
8
+
9
+ async function bind(port: number) {
10
+ for (let attempt = 1; ; attempt++) {
11
+ try {
12
+ return startProxy(port)
13
+ } catch (error) {
14
+ if (attempt >= 30) throw error
15
+ warn(`Port ${port} is busy (${String(error)}), trying again in 1000ms`)
16
+ await Bun.sleep(1_000)
17
+ }
18
+ }
19
+ }
8
20
 
9
21
  export const serve: Command = {
10
22
  summary: "Run the proxy in the foreground",
@@ -12,9 +24,12 @@ export const serve: Command = {
12
24
  details: ["`eunoe start` runs this for you in the background. Use it directly where there is no launchd."],
13
25
  hidden: true,
14
26
  async run() {
15
- const config = loadConfig()
27
+ const config = readConfig()
16
28
  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)
29
+ const keepGoing = (error: unknown) => warn(`eunoe kept going after an error: ${String(error)}`)
30
+ process.on("uncaughtException", keepGoing)
31
+ process.on("unhandledRejection", keepGoing)
32
+ await bind(config.port)
18
33
  line(`${c.bold("eunoe")} ${c.dim(VERSION)} listening on ${c.cyan(proxyUrl(config.port))} ${c.dim(`mode ${config.mode} · Ctrl-C to stop`)}`)
19
34
  },
20
35
  }