@chroxify/eunoe 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -9
- package/package.json +18 -2
- package/src/cli/{types.ts → command.ts} +0 -4
- package/src/cli/commands/index.ts +13 -1
- package/src/cli/commands/load.ts +44 -0
- package/src/cli/commands/mode.ts +41 -33
- package/src/cli/commands/peek.ts +22 -0
- package/src/cli/commands/replay.ts +245 -0
- package/src/cli/commands/serve.ts +22 -7
- package/src/cli/commands/sessions.ts +70 -0
- package/src/cli/commands/settings.ts +79 -60
- package/src/cli/commands/start.ts +56 -21
- package/src/cli/commands/stats.ts +233 -0
- package/src/cli/commands/status.ts +98 -55
- package/src/cli/commands/stop.ts +8 -6
- package/src/cli/commands/uninstall.ts +10 -12
- package/src/cli/commands/watch.ts +16 -0
- package/src/cli/logo/art.ts +99 -0
- package/src/cli/logo/index.ts +12 -18
- package/src/cli/main.ts +3 -3
- package/src/cli/proxy/claude-settings.ts +69 -0
- package/src/cli/proxy/inspect.ts +67 -0
- package/src/cli/proxy/launchd.ts +53 -0
- package/src/cli/proxy/live.ts +57 -0
- package/src/cli/proxy/service.ts +53 -0
- package/src/cli/{stats.ts → proxy/stats.ts} +29 -16
- package/src/cli/proxy/watch.ts +60 -0
- package/src/cli/replay/index.ts +146 -0
- package/src/cli/replay/worker.ts +20 -0
- package/src/cli/sessions/browse.ts +88 -0
- package/src/cli/sessions/index.ts +76 -0
- package/src/cli/sessions/source.ts +74 -0
- package/src/cli/{help.ts → terminal/help.ts} +24 -8
- package/src/cli/terminal/prompt.ts +114 -0
- package/src/cli/{ui.ts → terminal/ui.ts} +55 -24
- package/src/cli/usage/index.ts +151 -0
- package/src/config/index.ts +99 -34
- package/src/config/paths.ts +17 -0
- package/src/context/content.ts +40 -14
- package/src/context/fold/evidence.ts +145 -0
- package/src/context/fold/index.ts +65 -0
- package/src/context/fold/strategies.ts +94 -0
- package/src/context/fold/turns.ts +76 -0
- package/src/context/prompts.ts +141 -22
- package/src/context/recall/index.ts +214 -0
- package/src/context/recall/rerank.ts +71 -0
- package/src/proxy/canonical.ts +17 -9
- package/src/proxy/compaction.ts +106 -53
- package/src/proxy/estimate.ts +14 -9
- package/src/proxy/fingerprint.ts +23 -9
- package/src/proxy/handoff.ts +71 -0
- package/src/proxy/log.ts +2 -2
- package/src/proxy/recall.ts +58 -0
- package/src/proxy/rewrite.ts +141 -119
- package/src/proxy/server.ts +108 -51
- package/src/proxy/sessions.ts +32 -14
- package/src/proxy/state.ts +17 -3
- package/src/proxy/usage.ts +50 -34
- package/src/proxy/window.ts +32 -34
- package/src/transcript/format.ts +88 -25
- package/src/transcript/locate.ts +38 -17
- package/src/transcript/messages.ts +3 -14
- package/src/transcript/parse.ts +137 -56
- package/src/transcript/render.ts +44 -17
- package/src/transcript/search.ts +20 -56
- package/src/transcript/subagents.ts +150 -0
- package/src/cli/claude-settings.ts +0 -42
- package/src/cli/constants.ts +0 -52
- package/src/cli/inspect.ts +0 -41
- package/src/cli/launchd.ts +0 -39
- package/src/cli/logo/pixels.ts +0 -34
- package/src/cli/prompt.ts +0 -91
- package/src/cli/service.ts +0 -33
- package/src/cli/sessions.ts +0 -25
- package/src/config/constants.ts +0 -39
- package/src/config/types.ts +0 -50
- package/src/context/constants.ts +0 -42
- package/src/context/evidence.ts +0 -92
- package/src/context/recall.ts +0 -155
- package/src/context/rerank.ts +0 -44
- package/src/context/strategies.ts +0 -89
- package/src/context/turns.ts +0 -87
- package/src/context/types.ts +0 -66
- package/src/proxy/constants.ts +0 -29
- package/src/proxy/types.ts +0 -82
- package/src/transcript/constants.ts +0 -15
- package/src/transcript/types.ts +0 -43
- /package/src/cli/{args.ts → terminal/args.ts} +0 -0
|
@@ -1,20 +1,36 @@
|
|
|
1
|
-
import { VERSION } from "
|
|
2
|
-
import {
|
|
3
|
-
import { withLogo } from "
|
|
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(
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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 =
|
|
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
|
-
|
|
32
|
+
line(`${c.green("✓")} ${text}`)
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
export function note(text: string) {
|
|
34
|
-
|
|
36
|
+
line(`${c.dim("·")} ${text}`)
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
export function warn(text: string) {
|
|
38
|
-
|
|
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)
|
|
55
|
+
for (const [label, value] of entries) line(`${" ".repeat(indent)}${c.dim(label.padEnd(width))} ${value}`)
|
|
54
56
|
}
|
|
55
57
|
|
|
56
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
+
}
|
package/src/config/index.ts
CHANGED
|
@@ -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 {
|
|
5
|
-
import type { Config, Mode, SessionSettings, Tunable, Tunables } from "./types"
|
|
4
|
+
import { EUNOE_DIR, paths } from "./paths"
|
|
6
5
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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 (!
|
|
29
|
-
const
|
|
30
|
-
const
|
|
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,
|
|
36
|
-
const config
|
|
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 (
|
|
39
|
-
for (const key of TUNABLES) if (!
|
|
40
|
-
const sessions =
|
|
41
|
-
config.sessions = Object.fromEntries(
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
+
}
|