@chroxify/eunoe 0.1.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 (80) hide show
  1. package/README.md +364 -239
  2. package/package.json +20 -3
  3. package/src/cli/{types.ts → command.ts} +1 -4
  4. package/src/cli/commands/index.ts +27 -3
  5. package/src/cli/commands/load.ts +44 -0
  6. package/src/cli/commands/mode.ts +68 -24
  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 +24 -8
  10. package/src/cli/commands/sessions.ts +70 -0
  11. package/src/cli/commands/settings.ts +119 -0
  12. package/src/cli/commands/start.ts +73 -0
  13. package/src/cli/commands/status.ts +107 -30
  14. package/src/cli/commands/stop.ts +36 -12
  15. package/src/cli/commands/uninstall.ts +14 -14
  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/proxy/stats.ts +81 -0
  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/{args.ts → terminal/args.ts} +1 -1
  32. package/src/cli/{help.ts → terminal/help.ts} +25 -9
  33. package/src/cli/terminal/prompt.ts +114 -0
  34. package/src/cli/terminal/ui.ts +137 -0
  35. package/src/config/index.ts +128 -14
  36. package/src/config/{constants.ts → paths.ts} +4 -14
  37. package/src/context/content.ts +51 -3
  38. package/src/context/fold/evidence.ts +145 -0
  39. package/src/context/fold/index.ts +65 -0
  40. package/src/context/fold/strategies.ts +94 -0
  41. package/src/context/fold/turns.ts +76 -0
  42. package/src/context/prompts.ts +141 -22
  43. package/src/context/recall/index.ts +214 -0
  44. package/src/context/recall/rerank.ts +71 -0
  45. package/src/proxy/canonical.ts +40 -0
  46. package/src/proxy/compaction.ts +122 -52
  47. package/src/proxy/estimate.ts +14 -9
  48. package/src/proxy/fingerprint.ts +29 -0
  49. package/src/proxy/handoff.ts +71 -0
  50. package/src/proxy/log.ts +2 -2
  51. package/src/proxy/recall.ts +58 -0
  52. package/src/proxy/rewrite.ts +152 -56
  53. package/src/proxy/server.ts +122 -47
  54. package/src/proxy/sessions.ts +64 -0
  55. package/src/proxy/state.ts +17 -3
  56. package/src/proxy/usage.ts +50 -34
  57. package/src/proxy/window.ts +32 -34
  58. package/src/transcript/format.ts +88 -25
  59. package/src/transcript/locate.ts +38 -17
  60. package/src/transcript/messages.ts +18 -0
  61. package/src/transcript/parse.ts +137 -56
  62. package/src/transcript/render.ts +44 -17
  63. package/src/transcript/search.ts +20 -56
  64. package/src/transcript/subagents.ts +150 -0
  65. package/src/cli/claude-settings.ts +0 -42
  66. package/src/cli/commands/install.ts +0 -44
  67. package/src/cli/constants.ts +0 -29
  68. package/src/cli/inspect.ts +0 -41
  69. package/src/cli/launchd.ts +0 -34
  70. package/src/cli/logo/pixels.ts +0 -34
  71. package/src/cli/ui.ts +0 -91
  72. package/src/config/types.ts +0 -21
  73. package/src/context/constants.ts +0 -7
  74. package/src/context/strategies.ts +0 -89
  75. package/src/context/turns.ts +0 -82
  76. package/src/context/types.ts +0 -24
  77. package/src/proxy/constants.ts +0 -19
  78. package/src/proxy/types.ts +0 -58
  79. package/src/transcript/constants.ts +0 -16
  80. package/src/transcript/types.ts +0 -43
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chroxify/eunoe",
3
- "version": "0.1.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",
@@ -18,7 +19,8 @@
18
19
  ],
19
20
  "type": "module",
20
21
  "bin": {
21
- "eunoe": "src/cli/main.ts"
22
+ "eunoe": "src/cli/main.ts",
23
+ "ee": "src/cli/main.ts"
22
24
  },
23
25
  "files": [
24
26
  "src",
@@ -31,13 +33,28 @@
31
33
  "scripts": {
32
34
  "start": "bun src/cli/main.ts serve",
33
35
  "test": "bun test",
34
- "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"
35
42
  },
36
43
  "devDependencies": {
44
+ "@babel/parser": "7.27.0",
45
+ "@biomejs/biome": "2.5.9",
37
46
  "@types/bun": "^1.4.2",
47
+ "husky": "9.1.7",
48
+ "knip": "6.41.0",
49
+ "lint-staged": "16.4.0",
38
50
  "typescript": "^7.0.2"
39
51
  },
40
52
  "publishConfig": {
41
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
+ ]
42
59
  }
43
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 {
@@ -7,7 +5,6 @@ export interface Command {
7
5
  usage: string
8
6
  details?: string[]
9
7
  options?: OptionRow[]
8
+ hidden?: boolean
10
9
  run: (args: string[]) => void | Promise<void>
11
10
  }
12
-
13
- export type RequestRecord = Record<string, any>
@@ -1,9 +1,33 @@
1
- import type { Command } from "../types"
2
- import { install } from "./install"
1
+ import type { Command } from "../command"
2
+ import { load } from "./load"
3
3
  import { mode } from "./mode"
4
+ import { peek } from "./peek"
5
+ import { replay } from "./replay"
4
6
  import { serve } from "./serve"
7
+ import { sessions } from "./sessions"
8
+ import { settings } from "./settings"
9
+ import { start } from "./start"
5
10
  import { status } from "./status"
6
11
  import { stop } from "./stop"
7
12
  import { uninstall } from "./uninstall"
13
+ import { watch } from "./watch"
8
14
 
9
- export const commands: Record<string, Command> = { install, uninstall, stop, mode, status, serve }
15
+ const alias = (target: Command, prefix: string[] = []): Command => ({ ...target, hidden: true, run: (args) => target.run([...prefix, ...args]) })
16
+
17
+ export const commands: Record<string, Command> = {
18
+ start,
19
+ stop,
20
+ mode,
21
+ settings,
22
+ status,
23
+ replay,
24
+ sessions,
25
+ peek,
26
+ load,
27
+ uninstall,
28
+ serve,
29
+ watch,
30
+ enable: alias(start),
31
+ disable: alias(stop),
32
+ set: alias(settings, ["set"]),
33
+ }
@@ -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,30 +1,74 @@
1
- import { claudeConfigDirs, loadConfig, saveConfig } from "../../config"
2
- import type { Mode } from "../../config/types"
3
- import { editSettings, withCompactionOwner } from "../claude-settings"
4
- import { MODE_DESCRIPTIONS } from "../constants"
5
- import * as launchd from "../launchd"
6
- import type { Command } from "../types"
7
- import { c, fail, line, ok } from "../ui"
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"
5
+ import { isKnown, sessionArgs, shortId } from "../sessions"
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
+ }
14
+
15
+ function listModes(current: Mode) {
16
+ line()
17
+ for (const name of MODES) {
18
+ const active = name === current
19
+ line(` ${active ? c.green("●") : c.dim("○")} ${active ? c.bold(name.padEnd(8)) : name.padEnd(8)} ${c.dim(MODE_DESCRIPTIONS[name])}`)
20
+ }
21
+ }
22
+
23
+ function applyMode(next: Mode, ids: string[]) {
24
+ const config = readConfig()
25
+ if (ids.length > 0) {
26
+ saveConfig({ sessions: { ...config.sessions, ...Object.fromEntries(ids.map((id) => [id, { ...config.sessions[id], mode: next }])) } })
27
+ ok(`Mode ${c.bold(next)} for ${ids.map(shortId).join(", ")} ${c.dim(MODE_DESCRIPTIONS[next])}`)
28
+ if (routedDirs(config).length === 0) warn("Claude Code isn't routed through eunoe. Run `eunoe start --session <id>`.")
29
+ const unseen = ids.filter((id) => !isKnown(id))
30
+ if (unseen.length > 0) note(`Not seen through eunoe yet: ${unseen.map(shortId).join(", ")}. Check the id in Claude Code's /status.`)
31
+ return
32
+ }
33
+ saveConfig({ mode: next })
34
+ const updated = readConfig()
35
+ editSettings(routedDirs(updated), (settings) => withCompactionOwner(settings, compactionOwner(updated)))
36
+ ok(`Mode ${c.bold(next)} ${c.dim(MODE_DESCRIPTIONS[next])}`)
37
+ }
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
+ }
8
49
 
9
50
  export const mode: Command = {
10
- summary: "Show or switch how eunoe manages context",
11
- usage: "eunoe mode [compact | trim | off]",
12
- details: ["Takes effect on the next request; no restart needed."],
13
- run(args) {
14
- const config = loadConfig()
15
- const next = args[0] as Mode | undefined
16
- if (!next) {
17
- line()
18
- for (const [name, description] of Object.entries(MODE_DESCRIPTIONS) as Array<[Mode, string]>) {
19
- const current = name === config.mode
20
- line(` ${current ? c.green("●") : c.dim("○")} ${current ? c.bold(name.padEnd(8)) : name.padEnd(8)} ${c.dim(description)}`)
21
- }
22
- line()
51
+ summary: "Pick how eunoe manages context",
52
+ usage: "eunoe mode [default | compact | off] [--session <id>]",
53
+ details: ["Without a mode it opens a picker. Takes effect on the next request; no restart needed."],
54
+ options: [["--session <id>", "Set the mode for just this session (repeatable; a unique prefix is enough)"]],
55
+ async run(args) {
56
+ const config = readConfig()
57
+ const given = args.find((arg, i) => !arg.startsWith("-") && args[i - 1] !== "--session")
58
+ const ids = sessionArgs(args)
59
+ if (given) {
60
+ if (!MODES.includes(given as Mode)) fail(`Unknown mode: ${given}`, `Choose ${MODES.join(", ")}.`)
61
+ applyMode(given as Mode, ids)
23
62
  return
24
63
  }
25
- if (!(next in MODE_DESCRIPTIONS)) fail(`Unknown mode: ${next}`, "Choose compact, trim or off.")
26
- saveConfig({ mode: next })
27
- if (launchd.isInstalled()) editSettings(claudeConfigDirs(loadConfig()), (settings) => withCompactionOwner(settings, next))
28
- ok(`Mode ${c.bold(next)} ${c.dim(MODE_DESCRIPTIONS[next])}`)
64
+ const current = ids.length === 1 ? (config.sessions[ids[0]]?.mode ?? config.mode) : config.mode
65
+ if (!interactive) return printModes(current)
66
+ line()
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)
29
73
  },
30
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,19 +1,35 @@
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",
11
23
  usage: "eunoe serve",
12
- details: ["`eunoe install` runs this for you in the background."],
24
+ details: ["`eunoe start` runs this for you in the background. Use it directly where there is no launchd."],
25
+ hidden: true,
13
26
  async run() {
14
- const config = loadConfig()
27
+ const config = readConfig()
15
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.")
16
- 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)
17
33
  line(`${c.bold("eunoe")} ${c.dim(VERSION)} listening on ${c.cyan(proxyUrl(config.port))} ${c.dim(`mode ${config.mode} · Ctrl-C to stop`)}`)
18
34
  },
19
35
  }
@@ -0,0 +1,70 @@
1
+ import { readConfig } from "../../config"
2
+ import type { Command } from "../command"
3
+ import { routedDirs } from "../proxy/inspect"
4
+ import { askOf, candidatesHere, continuePrompt, copy, metaOf, shortId } from "../sessions"
5
+ import { browse, filterCandidates, UNTITLED } from "../sessions/browse"
6
+ import type { SessionCandidate } from "../sessions/source"
7
+ import { interactive } from "../terminal/prompt"
8
+ import { c, fail, line, ok, warn, wrap } from "../terminal/ui"
9
+
10
+ const INDENT = 6
11
+
12
+ const askWidth = () => Math.max(40, Math.min(100, (process.stdout.columns ?? 100 + INDENT) - INDENT - 12))
13
+
14
+ function ask(label: string, text: string | undefined) {
15
+ const [first = "", ...rest] = wrap(askOf(text), askWidth())
16
+ line(`${" ".repeat(INDENT)}${c.dim(label)} ${first}`)
17
+ for (const more of rest) line(`${" ".repeat(INDENT + label.length + 2)}${more}`)
18
+ }
19
+
20
+ function show(cand: SessionCandidate) {
21
+ const title = cand.label ? c.bold(cand.label) : c.dim(UNTITLED)
22
+ line(` ${c.cyan(shortId(cand.id))} ${title} ${c.dim(metaOf(cand))}`)
23
+ ask("first", cand.asks[0])
24
+ ask("last ", cand.asks.at(-1))
25
+ line()
26
+ }
27
+
28
+ function listSessions(words: string, candidates: SessionCandidate[]) {
29
+ const order = filterCandidates(words, candidates)
30
+ line()
31
+ line(` ${c.dim(words ? `Earlier sessions here, closest to "${words}" first` : "Earlier sessions here, newest first")}`)
32
+ line()
33
+ for (const cand of order) show(cand)
34
+ if (order.length === 0) line(` ${c.dim(`Nothing matches "${words}".`)}`)
35
+ }
36
+
37
+ function handOff(id: string) {
38
+ const prompt = continuePrompt(id)
39
+ line()
40
+ ok(`${c.bold(prompt)} ${copy(prompt) ? c.dim("copied") : ""}`)
41
+ if (routedDirs(readConfig()).length === 0) warn("Claude Code isn't routed through eunoe, so the agent won't know to load it (eunoe start).")
42
+ line()
43
+ }
44
+
45
+ export const sessions: Command = {
46
+ summary: "Pick an earlier session for the agent to continue from",
47
+ usage: "eunoe sessions [words] [--list]",
48
+ details: [
49
+ "Browse this directory's earlier Claude Code sessions: type to filter, pick",
50
+ "one, and a {ee:continue:<id>} marker is copied. Paste it into any Claude Code",
51
+ "chat along with what you want done: eunoe attaches that whole session to the",
52
+ "message, and its dropped output stays searchable from then on. Words start",
53
+ "the filter.",
54
+ "",
55
+ "--list, or a pipe, prints the sessions newest first with each one's first",
56
+ "and last request. That is what the agent reads when it runs this itself.",
57
+ ],
58
+ options: [["--list", "Print the sessions instead of browsing them"]],
59
+ async run(args) {
60
+ const words = args
61
+ .filter((a) => !a.startsWith("-"))
62
+ .join(" ")
63
+ .trim()
64
+ const candidates = candidatesHere()
65
+ if (candidates.length === 0) fail("No earlier sessions in this directory.")
66
+ if (args.includes("--list") || !interactive) return listSessions(words, candidates)
67
+ const chosen = await browse("Earlier sessions here", candidates, words, "↑↓ move · type to filter · enter copy the marker · esc quit")
68
+ if (chosen) handOff(chosen.id)
69
+ },
70
+ }