@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
@@ -1,20 +1,36 @@
1
- import { VERSION } from "../version"
2
- import { GLOBAL_OPTIONS, TAGLINE } from "./constants"
3
- import { withLogo } from "./logo"
4
- import type { Command } from "./types"
1
+ import { VERSION } from "../../version"
2
+ import type { Command, OptionRow } from "../command"
3
+ import { withLogo } from "../logo"
5
4
  import { c, line } from "./ui"
6
5
 
6
+ const GROUPS: Array<[title: string, names: string[]]> = [
7
+ ["Proxy", ["start", "stop", "status", "replay", "uninstall"]],
8
+ ["Context", ["mode", "settings"]],
9
+ ["Sessions", ["sessions"]],
10
+ ]
11
+
12
+ const GLOBAL_OPTIONS: OptionRow[] = [
13
+ ["-h, --help", "Show help for a command"],
14
+ ["-v, --version", "Show the version"],
15
+ ]
16
+
7
17
  export function printHelp(commands: Record<string, Command>) {
8
18
  const width = Math.max(...Object.keys(commands).map((name) => name.length), ...GLOBAL_OPTIONS.map(([flag]) => flag.length))
9
19
  line()
10
- for (const row of withLogo([`${c.bold("eunoe")} ${c.dim(VERSION)}`, c.italic(TAGLINE)])) line(row)
20
+ for (const row of withLogo([`${c.bold("eunoe")} ${c.dim(VERSION)}`, c.italic("Context management for Claude Code that never forgets what matters.")])) line(row)
11
21
  line()
12
22
  line(` ${c.dim("Usage")}`)
13
23
  line(` eunoe ${c.cyan("<command>")} ${c.dim("[options]")}`)
14
24
  line()
15
- line(` ${c.dim("Commands")}`)
16
- for (const [name, command] of Object.entries(commands).filter(([, command]) => !command.hidden)) line(` ${c.cyan(name.padEnd(width))} ${command.summary}`)
17
- line()
25
+ const visible = Object.keys(commands).filter((name) => !commands[name].hidden)
26
+ const grouped = new Set(GROUPS.flatMap(([, names]) => names))
27
+ for (const [title, names] of [...GROUPS, ["Other", visible.filter((name) => !grouped.has(name))] as const]) {
28
+ const shown = names.filter((name) => visible.includes(name))
29
+ if (shown.length === 0) continue
30
+ line(` ${c.dim(title)}`)
31
+ for (const name of shown) line(` ${c.cyan(name.padEnd(width))} ${commands[name].summary}`)
32
+ line()
33
+ }
18
34
  line(` ${c.dim("Options")}`)
19
35
  for (const [flag, description] of GLOBAL_OPTIONS) line(` ${flag.padEnd(width)} ${description}`)
20
36
  line()
@@ -0,0 +1,114 @@
1
+ import { c, useColor } from "./ui"
2
+
3
+ const ESC = "\x1b"
4
+ const KEY_SEQUENCE = new RegExp(`${ESC}\\[[0-9;]*[A-Za-z~]|${ESC}.|[\\s\\S]`, "g")
5
+
6
+ interface Choice<T extends string = string> {
7
+ value: T
8
+ label?: string
9
+ hint?: string
10
+ }
11
+
12
+ export const keys = {
13
+ up: "\x1b[A",
14
+ down: "\x1b[B",
15
+ enter: ["\r", "\n"],
16
+ escape: ["\x1b", "\x03"],
17
+ backspace: ["\x7f", "\b"],
18
+ clear: "\x15",
19
+ }
20
+
21
+ export const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY)
22
+
23
+ export const printable = (key: string) => key >= " " && !key.startsWith("\x1b")
24
+
25
+ function readKeys(onKey: (key: string) => boolean | undefined): Promise<void> {
26
+ return new Promise((resolve) => {
27
+ const stdin = process.stdin
28
+ stdin.setRawMode(true)
29
+ stdin.resume()
30
+ stdin.setEncoding("utf8")
31
+ const handler = (chunk: string) => {
32
+ for (const key of chunk.match(KEY_SEQUENCE) ?? []) {
33
+ if (onKey(key) !== true) continue
34
+ stdin.off("data", handler)
35
+ stdin.setRawMode(false)
36
+ stdin.pause()
37
+ resolve()
38
+ return
39
+ }
40
+ }
41
+ stdin.on("data", handler)
42
+ })
43
+ }
44
+
45
+ export async function interact(render: () => string[], onKey: (key: string) => boolean | undefined, hideCursor = true): Promise<void> {
46
+ let drawn = 0
47
+ const draw = () => {
48
+ const lines = render()
49
+ if (drawn) process.stdout.write(`\x1b[${drawn}A\x1b[J`)
50
+ process.stdout.write(`${lines.join("\n")}\n`)
51
+ drawn = lines.length
52
+ }
53
+ const toggleCursor = hideCursor && useColor
54
+ if (toggleCursor) process.stdout.write("\x1b[?25l")
55
+ draw()
56
+ await readKeys((key) => {
57
+ if (onKey(key) === true) return true
58
+ draw()
59
+ })
60
+ process.stdout.write(`\x1b[${drawn}A\x1b[J`)
61
+ if (toggleCursor) process.stdout.write("\x1b[?25h")
62
+ }
63
+
64
+ function renderChoices(title: string, choices: Choice[], index: number, width: number) {
65
+ const lines = [` ${c.bold(title)}`, ""]
66
+ for (const [i, choice] of choices.entries()) {
67
+ const label = (choice.label ?? choice.value).padEnd(width)
68
+ const marker = i === index ? c.green("●") : c.dim("○")
69
+ lines.push(` ${marker} ${i === index ? c.bold(label) : label}${choice.hint ? ` ${c.dim(choice.hint)}` : ""}`)
70
+ }
71
+ lines.push("", ` ${c.dim("↑↓ move · enter choose · esc cancel")}`)
72
+ return lines
73
+ }
74
+
75
+ export async function select<T extends string>(title: string, choices: Choice<T>[], current?: T): Promise<T | null> {
76
+ let index = Math.max(
77
+ 0,
78
+ choices.findIndex((choice) => choice.value === current),
79
+ )
80
+ const width = Math.max(...choices.map((choice) => (choice.label ?? choice.value).length))
81
+ let chosen: T | null = null
82
+ await interact(
83
+ () => renderChoices(title, choices, index, width),
84
+ (key) => {
85
+ if (key === keys.up || key === "k") index = (index + choices.length - 1) % choices.length
86
+ else if (key === keys.down || key === "j") index = (index + 1) % choices.length
87
+ else if (keys.enter.includes(key)) {
88
+ chosen = choices[index].value
89
+ return true
90
+ } else if (keys.escape.includes(key) || key === "q") return true
91
+ },
92
+ )
93
+ return chosen
94
+ }
95
+
96
+ export async function input(title: string, placeholder: string, initial = ""): Promise<string | null> {
97
+ let value = initial
98
+ let result: string | null = null
99
+ const render = () => [` ${c.bold(title)}`, ` ${c.cyan("›")} ${value || c.dim(placeholder)}`, "", ` ${c.dim("enter save · esc cancel")}`]
100
+ await interact(
101
+ render,
102
+ (key) => {
103
+ if (keys.enter.includes(key)) {
104
+ result = value
105
+ return true
106
+ }
107
+ if (keys.escape.includes(key)) return true
108
+ if (keys.backspace.includes(key)) value = value.slice(0, -1)
109
+ else if (printable(key)) value += key
110
+ },
111
+ false,
112
+ )
113
+ return result
114
+ }
@@ -1,12 +1,14 @@
1
1
  import { homedir } from "node:os"
2
- import { TYPO_DISTANCE } from "./constants"
3
2
 
4
3
  const colorFor = (stream: NodeJS.WriteStream) => Boolean(stream.isTTY) && !process.env.NO_COLOR && process.env.TERM !== "dumb"
5
4
  export const useColor = colorFor(process.stdout)
6
5
  export const trueColor = /^(truecolor|24bit)$/i.test(process.env.COLORTERM ?? "")
7
6
  const useColorErr = colorFor(process.stderr)
8
7
 
9
- const paint = (open: number, close: number, enabled = useColor) => (text: string) => (enabled ? `\x1b[${open}m${text}\x1b[${close}m` : text)
8
+ const paint =
9
+ (open: number, close: number, enabled = useColor) =>
10
+ (text: string) =>
11
+ enabled ? `\x1b[${open}m${text}\x1b[${close}m` : text
10
12
 
11
13
  export const c = {
12
14
  bold: paint(1, 22),
@@ -27,15 +29,15 @@ export function line(text = "") {
27
29
  }
28
30
 
29
31
  export function ok(text: string) {
30
- console.log(`${c.green("✓")} ${text}`)
32
+ line(`${c.green("✓")} ${text}`)
31
33
  }
32
34
 
33
35
  export function note(text: string) {
34
- console.log(`${c.dim("·")} ${text}`)
36
+ line(`${c.dim("·")} ${text}`)
35
37
  }
36
38
 
37
39
  export function warn(text: string) {
38
- console.log(`${c.yellow("!")} ${text}`)
40
+ line(`${c.yellow("!")} ${text}`)
39
41
  }
40
42
 
41
43
  export function fail(text: string, hint?: string): never {
@@ -50,18 +52,38 @@ export function listFiles(files: string[]) {
50
52
 
51
53
  export function rows(entries: Array<[string, string]>, indent = 2) {
52
54
  const width = Math.max(...entries.map(([label]) => label.length))
53
- for (const [label, value] of entries) console.log(`${" ".repeat(indent)}${c.dim(label.padEnd(width))} ${value}`)
55
+ for (const [label, value] of entries) line(`${" ".repeat(indent)}${c.dim(label.padEnd(width))} ${value}`)
54
56
  }
55
57
 
56
- const visible = (text: string) => text.replace(/\x1b\[[0-9;]*m/g, "")
58
+ const ESC = "\x1b"
59
+ const ANSI_STYLE = new RegExp(`${ESC}\\[[0-9;]*m`, "g")
60
+
61
+ const visible = (text: string) => text.replace(ANSI_STYLE, "")
57
62
 
58
63
  export function table(entries: string[][], indent = 4, gap = 2) {
59
64
  const columns = Math.max(...entries.map((row) => row.length))
60
65
  const widths = Array.from({ length: columns }, (_, column) => Math.max(...entries.map((row) => visible(row[column] ?? "").length)))
61
66
  for (const row of entries) {
62
67
  const cells = widths.map((width, column) => (row[column] ?? "") + " ".repeat(Math.max(0, width - visible(row[column] ?? "").length)))
63
- console.log(`${" ".repeat(indent)}${cells.join(" ".repeat(gap)).trimEnd()}`)
68
+ line(`${" ".repeat(indent)}${cells.join(" ".repeat(gap)).trimEnd()}`)
69
+ }
70
+ }
71
+
72
+ export function wrap(text: string, width: number): string[] {
73
+ const out: string[] = []
74
+ let current = ""
75
+ for (const word of text.split(" ")) {
76
+ if (current && current.length + 1 + word.length > width) {
77
+ out.push(current)
78
+ current = word
79
+ } else current = current ? `${current} ${word}` : word
64
80
  }
81
+ if (current) out.push(current)
82
+ return out
83
+ }
84
+
85
+ export function plural(count: number, word: string) {
86
+ return `${count} ${word}${count === 1 ? "" : "s"}`
65
87
  }
66
88
 
67
89
  export function percent(share: number) {
@@ -74,8 +96,16 @@ export function tilde(file: string) {
74
96
  }
75
97
 
76
98
  export function tokens(count: number) {
77
- if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(count >= 10_000_000 ? 0 : 1)}M`
78
- if (count >= 1_000) return `${(count / 1_000).toFixed(count >= 10_000 ? 0 : 1)}k`
99
+ for (const [scale, suffix] of [
100
+ [1e12, "T"],
101
+ [1e9, "B"],
102
+ [1e6, "M"],
103
+ [1e3, "k"],
104
+ ] as const) {
105
+ const scaled = count / scale
106
+ const shown = scaled.toFixed(scaled >= 10 ? 0 : 1)
107
+ if (Number(shown) >= 1) return `${shown}${suffix}`
108
+ }
79
109
  return String(count)
80
110
  }
81
111
 
@@ -87,20 +117,21 @@ export function ago(iso: string) {
87
117
  return `${Math.round(seconds / 86_400)}d ago`
88
118
  }
89
119
 
90
- export function closest(input: string, options: string[]) {
91
- const distance = (a: string, b: string) => {
92
- const row = Array.from({ length: b.length + 1 }, (_, i) => i)
93
- for (let i = 1; i <= a.length; i += 1) {
94
- let previous = row[0]
95
- row[0] = i
96
- for (let j = 1; j <= b.length; j += 1) {
97
- const current = row[j]
98
- row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1))
99
- previous = current
100
- }
120
+ function editDistance(a: string, b: string) {
121
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i)
122
+ for (let i = 1; i <= a.length; i += 1) {
123
+ let previous = row[0]
124
+ row[0] = i
125
+ for (let j = 1; j <= b.length; j += 1) {
126
+ const current = row[j]
127
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1))
128
+ previous = current
101
129
  }
102
- return row[b.length]
103
130
  }
104
- const [best] = options.map((option) => [option, distance(input, option)] as const).sort((x, y) => x[1] - y[1])
105
- return best && best[1] <= TYPO_DISTANCE ? best[0] : null
131
+ return row[b.length]
132
+ }
133
+
134
+ export function closest(input: string, options: string[]) {
135
+ const [best] = options.map((option) => [option, editDistance(input, option)] as const).sort((x, y) => x[1] - y[1])
136
+ return best && best[1] <= 2 ? best[0] : null
106
137
  }
@@ -0,0 +1,151 @@
1
+ import { readFileSync, statSync } from "node:fs"
2
+ import path from "node:path"
3
+ import { listEntries, projectFolders } from "../../transcript/locate"
4
+ import { subagentsDir } from "../../transcript/subagents"
5
+
6
+ export interface UsageEntry {
7
+ at: number
8
+ model: string
9
+ input: number
10
+ output: number
11
+ cacheWrite5m: number
12
+ cacheWrite1h: number
13
+ cacheRead: number
14
+ fast: boolean
15
+ }
16
+
17
+ interface Price {
18
+ input: number
19
+ output: number
20
+ cacheRead: number
21
+ }
22
+
23
+ interface RawUsage {
24
+ input_tokens?: number
25
+ output_tokens?: number
26
+ cache_creation_input_tokens?: number
27
+ cache_read_input_tokens?: number
28
+ cache_creation?: { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number }
29
+ service_tier?: string
30
+ speed?: string
31
+ }
32
+
33
+ interface RawLine {
34
+ timestamp?: string
35
+ requestId?: string
36
+ message?: { id?: string; model?: string; usage?: RawUsage }
37
+ }
38
+
39
+ const PRICES: Array<[prefix: string, price: Price]> = [
40
+ ["claude-fable-5-1", { input: 10, output: 50, cacheRead: 0.25 }],
41
+ ["claude-mythos-5-1", { input: 10, output: 50, cacheRead: 0.25 }],
42
+ ["claude-fable-5", { input: 10, output: 50, cacheRead: 1 }],
43
+ ["claude-mythos-5", { input: 10, output: 50, cacheRead: 1 }],
44
+ ["claude-opus-5-5", { input: 4, output: 20, cacheRead: 0.2 }],
45
+ ["claude-opus-5", { input: 5, output: 25, cacheRead: 0.5 }],
46
+ ["claude-opus-4-8", { input: 5, output: 25, cacheRead: 0.5 }],
47
+ ["claude-opus-4-7", { input: 5, output: 25, cacheRead: 0.5 }],
48
+ ["claude-opus-4-6", { input: 5, output: 25, cacheRead: 0.5 }],
49
+ ["claude-opus-4-5", { input: 5, output: 25, cacheRead: 0.5 }],
50
+ ["claude-sonnet-5-5", { input: 2, output: 10, cacheRead: 0.2 }],
51
+ ["claude-sonnet-5", { input: 2, output: 10, cacheRead: 0.2 }],
52
+ ["claude-sonnet-4", { input: 3, output: 15, cacheRead: 0.3 }],
53
+ ["claude-haiku-4-5", { input: 1, output: 5, cacheRead: 0.1 }],
54
+ ]
55
+
56
+ const CACHE_WRITE_5M = 1.25
57
+ const CACHE_WRITE_1H = 2
58
+ const FAST_MULTIPLIER = 2
59
+ const PLACEHOLDER_INPUT = 1000
60
+
61
+ const priceFor = (model: string) => PRICES.find(([prefix]) => model === prefix || model.startsWith(`${prefix}-`))?.[1] ?? null
62
+
63
+ export function ratesOf(entry: UsageEntry): { read: number; write: number } | null {
64
+ const price = priceFor(entry.model)
65
+ if (!price) return null
66
+ const writes = entry.cacheWrite5m + entry.cacheWrite1h
67
+ const writeShare = writes > 0 ? (entry.cacheWrite5m * CACHE_WRITE_5M + entry.cacheWrite1h * CACHE_WRITE_1H) / writes : CACHE_WRITE_1H
68
+ const scale = (entry.fast ? FAST_MULTIPLIER : 1) / 1e6
69
+ return { read: price.cacheRead * scale, write: price.input * writeShare * scale }
70
+ }
71
+
72
+ export function costOf(entry: UsageEntry): number | null {
73
+ const price = priceFor(entry.model)
74
+ if (!price) return null
75
+ const dollars = entry.input * price.input + entry.output * price.output + entry.cacheRead * price.cacheRead + entry.cacheWrite5m * price.input * CACHE_WRITE_5M + entry.cacheWrite1h * price.input * CACHE_WRITE_1H
76
+ return (dollars / 1e6) * (entry.fast ? FAST_MULTIPLIER : 1)
77
+ }
78
+
79
+ const fromApi = (usage: RawUsage) => usage.service_tier !== undefined
80
+
81
+ const isPlaceholder = (usage: RawUsage) => !fromApi(usage) && usage.input_tokens === PLACEHOLDER_INPUT && !usage.cache_read_input_tokens && !usage.cache_creation_input_tokens
82
+
83
+ const supersedes = (next: RawUsage, known: RawUsage) =>
84
+ fromApi(next) !== fromApi(known)
85
+ ? fromApi(next)
86
+ : (next.output_tokens ?? 0) > (known.output_tokens ?? 0) || ((next.output_tokens ?? 0) === (known.output_tokens ?? 0) && (next.cache_creation_input_tokens ?? 0) > (known.cache_creation_input_tokens ?? 0))
87
+
88
+ function entryOf(line: RawLine, usage: RawUsage, at: number): UsageEntry {
89
+ const written = usage.cache_creation_input_tokens ?? 0
90
+ const hour = Math.min(written, usage.cache_creation?.ephemeral_1h_input_tokens ?? 0)
91
+ return {
92
+ at,
93
+ model: line.message?.model ?? "unknown",
94
+ input: usage.input_tokens ?? 0,
95
+ output: usage.output_tokens ?? 0,
96
+ cacheWrite5m: written - hour,
97
+ cacheWrite1h: hour,
98
+ cacheRead: usage.cache_read_input_tokens ?? 0,
99
+ fast: usage.speed === "fast",
100
+ }
101
+ }
102
+
103
+ export function usageFiles(configDirs: string[], since: number): string[] {
104
+ const files: string[] = []
105
+ const recent = (file: string) => {
106
+ try {
107
+ return statSync(file).mtimeMs >= since
108
+ } catch {
109
+ return false
110
+ }
111
+ }
112
+ for (const folder of projectFolders(configDirs)) {
113
+ for (const name of listEntries(folder)) {
114
+ if (!name.endsWith(".jsonl")) continue
115
+ const file = path.join(folder, name)
116
+ if (recent(file)) files.push(file)
117
+ const subagents = subagentsDir(file)
118
+ for (const agent of listEntries(subagents)) if (agent.endsWith(".jsonl") && recent(path.join(subagents, agent))) files.push(path.join(subagents, agent))
119
+ }
120
+ }
121
+ return files.sort()
122
+ }
123
+
124
+ export function readUsage(files: string[], since: number): UsageEntry[] {
125
+ const seen = new Map<string, { usage: RawUsage; entry: UsageEntry }>()
126
+ for (const file of files) {
127
+ let text: string
128
+ try {
129
+ text = readFileSync(file, "utf8")
130
+ } catch {
131
+ continue
132
+ }
133
+ for (const [index, raw] of text.split("\n").entries()) {
134
+ if (!raw.includes('"usage"')) continue
135
+ let line: RawLine
136
+ try {
137
+ line = JSON.parse(raw)
138
+ } catch {
139
+ continue
140
+ }
141
+ const usage = line.message?.usage
142
+ const at = Date.parse(line.timestamp ?? "")
143
+ if (!usage || !(at >= since) || line.message?.model === "<synthetic>") continue
144
+ const key = line.message?.id ? `${line.message.id}:${line.requestId ?? ""}` : `${file}:${index}`
145
+ const known = seen.get(key)
146
+ if (known && !supersedes(usage, known.usage)) continue
147
+ seen.set(key, { usage, entry: entryOf(line, usage, at) })
148
+ }
149
+ }
150
+ return [...seen.values()].filter(({ usage }) => !isPlaceholder(usage)).map(({ entry }) => entry)
151
+ }
@@ -1,47 +1,114 @@
1
1
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
2
2
  import { homedir } from "node:os"
3
3
  import path from "node:path"
4
- import { DEFAULTS, EUNOE_DIR, MODES, paths, SCOPES, SEARCH_MODES, TUNABLES } from "./constants"
5
- import type { Config, Mode, SessionSettings, Tunable, Tunables } from "./types"
4
+ import { EUNOE_DIR, paths } from "./paths"
6
5
 
7
- const VALID: { [K in Tunable]: (value: unknown) => value is Tunables[K] } = {
8
- compactAt: (value): value is number => typeof value === "number" && value > 0 && value <= 1,
9
- keepTurns: (value): value is number | "all" => value === "all" || (Number.isInteger(value) && (value as number) >= 0),
10
- keep: (value): value is number => Number.isInteger(value) && (value as number) >= 1,
11
- budget: (value): value is number => Number.isInteger(value) && (value as number) >= 1_000,
12
- search: (value): value is Tunables["search"] => SEARCH_MODES.includes(value as Tunables["search"]),
13
- rerank: (value): value is string => typeof value === "string" && /^[\w.-]+$/.test(value),
6
+ export type Mode = "off" | "compact" | "default"
7
+ export type SearchMode = "jsonl" | "markdown" | "xml" | "qmd"
8
+ type Scope = "all" | "sessions"
9
+
10
+ interface EvalConfig {
11
+ recallOptions?: { maxChars?: number; maxCharsMulti?: number; perQuery?: number }
12
+ strategy?: "tail" | "guide" | "trim"
13
+ rolling?: { keep: number; budget: number }
14
+ forceCut?: boolean
15
+ trustHeaders?: boolean
16
+ evidence?: boolean
17
+ recall?: boolean
18
+ rerank?: { model: string; candidates?: number; endpoint?: string }
19
+ }
20
+
21
+ export interface Tunables {
22
+ compactAt: number
23
+ keepTurns: number | "all"
24
+ keep: number
25
+ budget: number
26
+ turnCap: number | "off"
27
+ search: SearchMode
28
+ rerank: string
29
+ }
30
+
31
+ export type Tunable = keyof Tunables
32
+
33
+ interface SessionSettings extends Partial<Tunables> {
34
+ mode?: Mode
35
+ }
36
+
37
+ export interface Config extends Tunables {
38
+ mode: Mode
39
+ scope: Scope
40
+ sessions: Record<string, SessionSettings>
41
+ port: number
42
+ upstream: string
43
+ claudeConfigDirs: string[]
44
+ window?: number
45
+ eval?: EvalConfig
14
46
  }
15
47
 
48
+ export const DEFAULTS: Config = {
49
+ mode: "default",
50
+ scope: "all",
51
+ sessions: {},
52
+ port: 38663,
53
+ compactAt: 0.9,
54
+ keepTurns: "all",
55
+ keep: 3,
56
+ budget: 100_000,
57
+ turnCap: 100_000,
58
+ search: "markdown",
59
+ rerank: "jev-preview",
60
+ upstream: "https://api.anthropic.com",
61
+ claudeConfigDirs: [],
62
+ }
63
+
64
+ export const MODES: Mode[] = ["default", "compact", "off"]
65
+ export const TUNABLES: Tunable[] = ["keep", "budget", "turnCap", "compactAt", "keepTurns", "search", "rerank"]
66
+
67
+ const SEARCH_MODES: SearchMode[] = ["jsonl", "markdown", "xml", "qmd"]
16
68
  const TEXT_TUNABLES: Tunable[] = ["search", "rerank"]
17
69
 
70
+ const isWhole = (value: unknown, min: number): value is number => Number.isInteger(value) && (value as number) >= min
71
+
72
+ const validators: { [K in Tunable]: (value: unknown) => value is Tunables[K] } = {
73
+ compactAt: (value): value is number => typeof value === "number" && value > 0 && value <= 1,
74
+ keepTurns: (value): value is number | "all" => value === "all" || isWhole(value, 0),
75
+ keep: (value): value is number => isWhole(value, 1),
76
+ budget: (value): value is number => isWhole(value, 1_000),
77
+ turnCap: (value): value is number | "off" => value === "off" || isWhole(value, 1_000),
78
+ search: (value): value is SearchMode => SEARCH_MODES.includes(value as SearchMode),
79
+ rerank: (value): value is string => typeof value === "string" && /^[\w.-]+$/.test(value),
80
+ }
81
+
18
82
  const isMode = (value: unknown): value is Mode => MODES.includes(value as Mode)
19
83
 
84
+ const isRecord = (value: unknown): value is Record<string, unknown> => Boolean(value) && typeof value === "object" && !Array.isArray(value)
85
+
20
86
  export function parseTunable<K extends Tunable>(key: K, raw: string): Tunables[K] | null {
21
- const value = TEXT_TUNABLES.includes(key) || raw === "all" ? raw : Number(raw.replace(/[_,]/g, ""))
22
- return VALID[key](value) ? value as Tunables[K] : null
87
+ const value = TEXT_TUNABLES.includes(key) || raw === "all" || raw === "off" ? raw : Number(raw.replace(/[_,]/g, ""))
88
+ return validators[key](value) ? (value as Tunables[K]) : null
23
89
  }
24
90
 
25
91
  function normalizeSession(value: unknown): SessionSettings | null {
26
92
  if (value === null) return {}
27
93
  if (isMode(value)) return { mode: value }
28
- if (!value || typeof value !== "object" || Array.isArray(value)) return null
29
- const raw = value as Record<string, unknown>
30
- const settings: SessionSettings = isMode(raw.mode) ? { mode: raw.mode } : {}
31
- for (const key of TUNABLES) if (VALID[key](raw[key])) Object.assign(settings, { [key]: raw[key] })
94
+ if (!isRecord(value)) return null
95
+ const settings: SessionSettings = isMode(value.mode) ? { mode: value.mode } : {}
96
+ for (const key of TUNABLES) if (validators[key](value[key])) Object.assign(settings, { [key]: value[key] })
32
97
  return settings
33
98
  }
34
99
 
35
- export function normalizeConfig(raw: Record<string, any>): Config {
36
- const config: Config = { ...DEFAULTS, ...raw }
100
+ export function normalizeConfig(raw: Record<string, unknown>): Config {
101
+ const config = { ...DEFAULTS, ...raw } as Config
37
102
  if (!isMode(config.mode)) config.mode = DEFAULTS.mode
38
- if (!SCOPES.includes(config.scope)) config.scope = DEFAULTS.scope
39
- for (const key of TUNABLES) if (!VALID[key](config[key])) Object.assign(config, { [key]: DEFAULTS[key] })
40
- const sessions = config.sessions && typeof config.sessions === "object" && !Array.isArray(config.sessions) ? config.sessions : {}
41
- config.sessions = Object.fromEntries(Object.entries(sessions).flatMap(([id, value]) => {
42
- const settings = normalizeSession(value)
43
- return settings ? [[id, settings]] : []
44
- }))
103
+ if (config.scope !== "all" && config.scope !== "sessions") config.scope = DEFAULTS.scope
104
+ for (const key of TUNABLES) if (!validators[key](config[key])) Object.assign(config, { [key]: DEFAULTS[key] })
105
+ const sessions = isRecord(config.sessions) ? config.sessions : {}
106
+ config.sessions = Object.fromEntries(
107
+ Object.entries(sessions).flatMap(([id, value]) => {
108
+ const settings = normalizeSession(value)
109
+ return settings ? [[id, settings]] : []
110
+ }),
111
+ )
45
112
  if (!Number.isInteger(config.port)) config.port = DEFAULTS.port
46
113
  if (!Array.isArray(config.claudeConfigDirs)) config.claudeConfigDirs = []
47
114
  return config
@@ -49,7 +116,7 @@ export function normalizeConfig(raw: Record<string, any>): Config {
49
116
 
50
117
  let cached: { mtimeMs: number; config: Config } | null = null
51
118
 
52
- export function loadConfig(): Config {
119
+ export function readConfig(): Config {
53
120
  let mtimeMs: number
54
121
  try {
55
122
  mtimeMs = statSync(paths.config).mtimeMs
@@ -60,9 +127,7 @@ export function loadConfig(): Config {
60
127
  let config = DEFAULTS
61
128
  try {
62
129
  config = normalizeConfig(JSON.parse(readFileSync(paths.config, "utf8")))
63
- } catch (error) {
64
- console.error(`[eunoe] ${paths.config} is not valid JSON; using defaults`, error)
65
- }
130
+ } catch {}
66
131
  cached = { mtimeMs, config }
67
132
  return config
68
133
  }
@@ -70,7 +135,7 @@ export function loadConfig(): Config {
70
135
  export function saveConfig(patch: Partial<Config>) {
71
136
  mkdirSync(EUNOE_DIR, { recursive: true })
72
137
  const current = existsSync(paths.config) ? JSON.parse(readFileSync(paths.config, "utf8")) : {}
73
- writeFileSync(paths.config, JSON.stringify({ ...current, ...patch }, null, 2) + "\n")
138
+ writeFileSync(paths.config, `${JSON.stringify({ ...current, ...patch }, null, 2)}\n`)
74
139
  }
75
140
 
76
141
  export function claudeConfigDirs(config: Config): string[] {
@@ -85,14 +150,14 @@ export function modeFor(config: Config, sessionId: string | null): Mode {
85
150
  return config.scope === "all" ? config.mode : "off"
86
151
  }
87
152
 
153
+ export function compactionOwner(config: Config): Mode {
154
+ return modeFor(config, null)
155
+ }
156
+
88
157
  export function overrides(settings: SessionSettings | undefined): Partial<Tunables> {
89
- return Object.fromEntries(TUNABLES.filter((key) => settings?.[key] !== undefined).map((key) => [key, settings![key]]))
158
+ return Object.fromEntries(TUNABLES.filter((key) => settings?.[key] !== undefined).map((key) => [key, settings?.[key]]))
90
159
  }
91
160
 
92
161
  export function settingsFor(config: Config, sessionId: string | null): Config {
93
162
  return { ...config, ...overrides(sessionId ? config.sessions[sessionId] : undefined), mode: modeFor(config, sessionId) }
94
163
  }
95
-
96
- export function compactionOwner(config: Config): Mode {
97
- return config.scope === "all" ? config.mode : "off"
98
- }
@@ -0,0 +1,17 @@
1
+ import { homedir } from "node:os"
2
+ import path from "node:path"
3
+
4
+ export const EUNOE_DIR = process.env.EUNOE_DIR ?? path.join(homedir(), ".eunoe")
5
+
6
+ export const paths = {
7
+ config: path.join(EUNOE_DIR, "config.json"),
8
+ requests: path.join(EUNOE_DIR, "requests.jsonl"),
9
+ threads: path.join(EUNOE_DIR, "threads"),
10
+ transcripts: path.join(EUNOE_DIR, "transcripts"),
11
+ models: path.join(EUNOE_DIR, "models.json"),
12
+ sessions: path.join(EUNOE_DIR, "sessions.json"),
13
+ proxyLog: path.join(EUNOE_DIR, "proxy.log"),
14
+ typesafeKey: path.join(EUNOE_DIR, "typesafe-key"),
15
+ loaded: path.join(EUNOE_DIR, "loaded"),
16
+ watch: path.join(EUNOE_DIR, "watch.json"),
17
+ }