@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.
Files changed (88) hide show
  1. package/README.md +68 -9
  2. package/package.json +18 -2
  3. package/src/cli/{types.ts → command.ts} +0 -4
  4. package/src/cli/commands/index.ts +13 -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 +245 -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 +56 -21
  13. package/src/cli/commands/stats.ts +233 -0
  14. package/src/cli/commands/status.ts +98 -55
  15. package/src/cli/commands/stop.ts +8 -6
  16. package/src/cli/commands/uninstall.ts +10 -12
  17. package/src/cli/commands/watch.ts +16 -0
  18. package/src/cli/logo/art.ts +99 -0
  19. package/src/cli/logo/index.ts +12 -18
  20. package/src/cli/main.ts +3 -3
  21. package/src/cli/proxy/claude-settings.ts +69 -0
  22. package/src/cli/proxy/inspect.ts +67 -0
  23. package/src/cli/proxy/launchd.ts +53 -0
  24. package/src/cli/proxy/live.ts +57 -0
  25. package/src/cli/proxy/service.ts +53 -0
  26. package/src/cli/{stats.ts → proxy/stats.ts} +29 -16
  27. package/src/cli/proxy/watch.ts +60 -0
  28. package/src/cli/replay/index.ts +146 -0
  29. package/src/cli/replay/worker.ts +20 -0
  30. package/src/cli/sessions/browse.ts +88 -0
  31. package/src/cli/sessions/index.ts +76 -0
  32. package/src/cli/sessions/source.ts +74 -0
  33. package/src/cli/{help.ts → terminal/help.ts} +24 -8
  34. package/src/cli/terminal/prompt.ts +114 -0
  35. package/src/cli/{ui.ts → terminal/ui.ts} +55 -24
  36. package/src/cli/usage/index.ts +151 -0
  37. package/src/config/index.ts +99 -34
  38. package/src/config/paths.ts +17 -0
  39. package/src/context/content.ts +40 -14
  40. package/src/context/fold/evidence.ts +145 -0
  41. package/src/context/fold/index.ts +65 -0
  42. package/src/context/fold/strategies.ts +94 -0
  43. package/src/context/fold/turns.ts +76 -0
  44. package/src/context/prompts.ts +141 -22
  45. package/src/context/recall/index.ts +214 -0
  46. package/src/context/recall/rerank.ts +71 -0
  47. package/src/proxy/canonical.ts +17 -9
  48. package/src/proxy/compaction.ts +106 -53
  49. package/src/proxy/estimate.ts +14 -9
  50. package/src/proxy/fingerprint.ts +23 -9
  51. package/src/proxy/handoff.ts +71 -0
  52. package/src/proxy/log.ts +2 -2
  53. package/src/proxy/recall.ts +58 -0
  54. package/src/proxy/rewrite.ts +141 -119
  55. package/src/proxy/server.ts +108 -51
  56. package/src/proxy/sessions.ts +32 -14
  57. package/src/proxy/state.ts +17 -3
  58. package/src/proxy/usage.ts +50 -34
  59. package/src/proxy/window.ts +32 -34
  60. package/src/transcript/format.ts +88 -25
  61. package/src/transcript/locate.ts +38 -17
  62. package/src/transcript/messages.ts +3 -14
  63. package/src/transcript/parse.ts +137 -56
  64. package/src/transcript/render.ts +44 -17
  65. package/src/transcript/search.ts +20 -56
  66. package/src/transcript/subagents.ts +150 -0
  67. package/src/cli/claude-settings.ts +0 -42
  68. package/src/cli/constants.ts +0 -52
  69. package/src/cli/inspect.ts +0 -41
  70. package/src/cli/launchd.ts +0 -39
  71. package/src/cli/logo/pixels.ts +0 -34
  72. package/src/cli/prompt.ts +0 -91
  73. package/src/cli/service.ts +0 -33
  74. package/src/cli/sessions.ts +0 -25
  75. package/src/config/constants.ts +0 -39
  76. package/src/config/types.ts +0 -50
  77. package/src/context/constants.ts +0 -42
  78. package/src/context/evidence.ts +0 -92
  79. package/src/context/recall.ts +0 -155
  80. package/src/context/rerank.ts +0 -44
  81. package/src/context/strategies.ts +0 -89
  82. package/src/context/turns.ts +0 -87
  83. package/src/context/types.ts +0 -66
  84. package/src/proxy/constants.ts +0 -29
  85. package/src/proxy/types.ts +0 -82
  86. package/src/transcript/constants.ts +0 -15
  87. package/src/transcript/types.ts +0 -43
  88. /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,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. launchd keeps it alive, and `stop` leaves it running so sessions
384
- already pointed at it keep working.
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.0.0",
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
- "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,17 @@
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"
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
+ }
@@ -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,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
+ }