@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,10 +1,22 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readConfig } from "../../config"
|
|
2
2
|
import { serve as startProxy } from "../../proxy/server"
|
|
3
3
|
import { VERSION } from "../../version"
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
7
|
-
import { c, fail, line } from "../ui"
|
|
4
|
+
import type { Command } from "../command"
|
|
5
|
+
import { proxyUrl } from "../proxy/claude-settings"
|
|
6
|
+
import { probe } from "../proxy/inspect"
|
|
7
|
+
import { c, fail, line, warn } from "../terminal/ui"
|
|
8
|
+
|
|
9
|
+
async function bind(port: number) {
|
|
10
|
+
for (let attempt = 1; ; attempt++) {
|
|
11
|
+
try {
|
|
12
|
+
return startProxy(port)
|
|
13
|
+
} catch (error) {
|
|
14
|
+
if (attempt >= 30) throw error
|
|
15
|
+
warn(`Port ${port} is busy (${String(error)}), trying again in 1000ms`)
|
|
16
|
+
await Bun.sleep(1_000)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
8
20
|
|
|
9
21
|
export const serve: Command = {
|
|
10
22
|
summary: "Run the proxy in the foreground",
|
|
@@ -12,9 +24,12 @@ export const serve: Command = {
|
|
|
12
24
|
details: ["`eunoe start` runs this for you in the background. Use it directly where there is no launchd."],
|
|
13
25
|
hidden: true,
|
|
14
26
|
async run() {
|
|
15
|
-
const config =
|
|
27
|
+
const config = readConfig()
|
|
16
28
|
if (await probe(config.port)) fail(`Something is already serving eunoe on port ${config.port}`, "Stop it first, or set another port in ~/.eunoe/config.json.")
|
|
17
|
-
|
|
29
|
+
const keepGoing = (error: unknown) => warn(`eunoe kept going after an error: ${String(error)}`)
|
|
30
|
+
process.on("uncaughtException", keepGoing)
|
|
31
|
+
process.on("unhandledRejection", keepGoing)
|
|
32
|
+
await bind(config.port)
|
|
18
33
|
line(`${c.bold("eunoe")} ${c.dim(VERSION)} listening on ${c.cyan(proxyUrl(config.port))} ${c.dim(`mode ${config.mode} · Ctrl-C to stop`)}`)
|
|
19
34
|
},
|
|
20
35
|
}
|
|
@@ -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
|
+
}
|
|
@@ -1,66 +1,95 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import type { Tunable } from "../../config/types"
|
|
4
|
-
import { TUNABLE_DESCRIPTIONS, TUNABLE_HINTS } from "../constants"
|
|
5
|
-
import { input, interactive, select } from "../prompt"
|
|
1
|
+
import { DEFAULTS, overrides, parseTunable, readConfig, saveConfig, TUNABLES, type Tunable } from "../../config"
|
|
2
|
+
import type { Command } from "../command"
|
|
6
3
|
import { sessionArgs, shortId } from "../sessions"
|
|
7
|
-
import
|
|
8
|
-
import { c, fail, line, ok, rows } from "../ui"
|
|
4
|
+
import { input, interactive, select } from "../terminal/prompt"
|
|
5
|
+
import { c, fail, line, ok, rows } from "../terminal/ui"
|
|
6
|
+
|
|
7
|
+
const DESCRIPTIONS: Record<Tunable, string> = {
|
|
8
|
+
keep: "Turns kept whole at the end, in default mode",
|
|
9
|
+
budget: "Tokens of older detail that trigger a fold, in default mode",
|
|
10
|
+
turnCap: "A kept turn larger than this many tokens is folded at once, live turn excepted; or off",
|
|
11
|
+
compactAt: "Share of the context window that triggers the cut",
|
|
12
|
+
keepTurns: "Earlier turns kept after a cut",
|
|
13
|
+
search: "How transcripts are written for the agent to search",
|
|
14
|
+
rerank: "Typesafe model that reorders recall, or off",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const HINTS: Record<Tunable, string> = {
|
|
18
|
+
keep: "a whole number, 1 or more",
|
|
19
|
+
budget: "a token count, 1000 or more",
|
|
20
|
+
turnCap: "a token count, 1000 or more, or off",
|
|
21
|
+
compactAt: "a number above 0, up to 1",
|
|
22
|
+
keepTurns: "a whole number or all",
|
|
23
|
+
search: "markdown, xml, jsonl or qmd",
|
|
24
|
+
rerank: "a model name such as jev-preview, or off",
|
|
25
|
+
}
|
|
9
26
|
|
|
10
27
|
const RESET = "default"
|
|
11
28
|
|
|
29
|
+
const forSessions = (ids: string[]) => ids.map(shortId).join(", ")
|
|
30
|
+
|
|
12
31
|
function listSettings() {
|
|
13
|
-
const config =
|
|
32
|
+
const config = readConfig()
|
|
14
33
|
line()
|
|
15
34
|
const width = Math.max(...TUNABLES.map((name) => String(config[name]).length))
|
|
16
|
-
rows(
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
35
|
+
rows(
|
|
36
|
+
TUNABLES.map((name) => {
|
|
37
|
+
const changed = config[name] !== DEFAULTS[name]
|
|
38
|
+
return [name, `${c.bold(String(config[name]).padEnd(width))} ${c.dim(DESCRIPTIONS[name])}${changed ? c.dim(` (default ${DEFAULTS[name]})`) : ""}`]
|
|
39
|
+
}),
|
|
40
|
+
)
|
|
41
|
+
const own = Object.entries(config.sessions)
|
|
42
|
+
.map(([id, session]) => [id, overrides(session)] as const)
|
|
43
|
+
.filter(([, values]) => Object.keys(values).length > 0)
|
|
44
|
+
if (own.length > 0) {
|
|
23
45
|
line()
|
|
24
|
-
for (const [id, values] of own)
|
|
46
|
+
for (const [id, values] of own) {
|
|
47
|
+
const shown = Object.entries(values).map(([name, value]) => `${name} ${c.bold(String(value))}`)
|
|
48
|
+
line(` ${c.cyan(shortId(id))} ${shown.join(c.dim(" · "))}`)
|
|
49
|
+
}
|
|
25
50
|
}
|
|
26
51
|
line()
|
|
27
52
|
}
|
|
28
53
|
|
|
29
|
-
function
|
|
30
|
-
const config =
|
|
31
|
-
if (
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
sessions[id] = rest
|
|
38
|
-
}
|
|
39
|
-
saveConfig({ sessions })
|
|
40
|
-
ok(`${name} back to the default (${c.bold(String(config[name]))}) for ${ids.map(shortId).join(", ")}`)
|
|
41
|
-
return
|
|
54
|
+
function resetSetting(name: Tunable, ids: string[]) {
|
|
55
|
+
const config = readConfig()
|
|
56
|
+
if (ids.length === 0) fail(`\`${RESET}\` only applies with --session`, `eunoe settings set ${name} ${RESET} --session <id>`)
|
|
57
|
+
const sessions = { ...config.sessions }
|
|
58
|
+
for (const id of ids) {
|
|
59
|
+
if (!sessions[id]) continue
|
|
60
|
+
const { [name]: _, ...rest } = sessions[id]
|
|
61
|
+
sessions[id] = rest
|
|
42
62
|
}
|
|
63
|
+
saveConfig({ sessions })
|
|
64
|
+
ok(`${name} back to the default (${c.bold(String(config[name]))}) for ${forSessions(ids)}`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function applySetting(name: Tunable, raw: string, ids: string[]) {
|
|
68
|
+
if (raw === RESET) return resetSetting(name, ids)
|
|
69
|
+
const config = readConfig()
|
|
43
70
|
const value = parseTunable(name, raw)
|
|
44
|
-
if (value === null) fail(`Not a valid ${name}: ${raw}`, `Expected ${
|
|
45
|
-
if (ids.length) {
|
|
46
|
-
saveConfig({
|
|
47
|
-
ok(`${name} ${c.bold(String(value))}
|
|
48
|
-
if (config.scope === "sessions" && ids.some((id) => !config.sessions[id])) line(` ${c.dim("eunoe only manages sessions you started it for: eunoe start --session <id>")}`)
|
|
71
|
+
if (value === null) fail(`Not a valid ${name}: ${raw}`, `Expected ${HINTS[name]}.`)
|
|
72
|
+
if (ids.length === 0) {
|
|
73
|
+
saveConfig({ [name]: value })
|
|
74
|
+
ok(`${name} ${c.bold(String(value))}`)
|
|
49
75
|
return
|
|
50
76
|
}
|
|
51
|
-
saveConfig({ [name]: value })
|
|
52
|
-
ok(`${name} ${c.bold(String(value))}`)
|
|
77
|
+
saveConfig({ sessions: { ...config.sessions, ...Object.fromEntries(ids.map((id) => [id, { ...config.sessions[id], [name]: value }])) } })
|
|
78
|
+
ok(`${name} ${c.bold(String(value))} for ${forSessions(ids)}`)
|
|
79
|
+
if (config.scope === "sessions" && ids.some((id) => !config.sessions[id])) line(` ${c.dim("eunoe only manages sessions you started it for: eunoe start --session <id>")}`)
|
|
53
80
|
}
|
|
54
81
|
|
|
55
82
|
async function pickSetting(ids: string[]) {
|
|
56
|
-
const config =
|
|
83
|
+
const config = readConfig()
|
|
57
84
|
line()
|
|
58
|
-
const name = await select<Tunable>(
|
|
85
|
+
const name = await select<Tunable>(
|
|
86
|
+
ids.length > 0 ? `Setting for ${forSessions(ids)}` : "Setting",
|
|
87
|
+
TUNABLES.map((value) => ({ value, hint: `${String(config[value]).padEnd(12)} ${DESCRIPTIONS[value]}` })),
|
|
88
|
+
)
|
|
59
89
|
if (name === null) return
|
|
60
|
-
const current = ids.length === 1 ? config.sessions[ids[0]]?.[name] ?? config[name] : config[name]
|
|
61
|
-
const raw = await input(`${name} ${c.dim(
|
|
62
|
-
if (raw
|
|
63
|
-
applySetting(name, raw, ids)
|
|
90
|
+
const current = ids.length === 1 ? (config.sessions[ids[0]]?.[name] ?? config[name]) : config[name]
|
|
91
|
+
const raw = await input(`${name} ${c.dim(HINTS[name])}`, `${current} (current)`)
|
|
92
|
+
if (raw) applySetting(name, raw, ids)
|
|
64
93
|
}
|
|
65
94
|
|
|
66
95
|
export const settings: Command = {
|
|
@@ -68,33 +97,23 @@ export const settings: Command = {
|
|
|
68
97
|
usage: "eunoe settings [list | set <key> <value>] [--session <id>]",
|
|
69
98
|
details: [
|
|
70
99
|
"Without arguments it opens a picker. Keys:",
|
|
71
|
-
...TUNABLES.map((key) => ` ${key.padEnd(10)} ${
|
|
100
|
+
...TUNABLES.map((key) => ` ${key.padEnd(10)} ${DESCRIPTIONS[key]} ${c.dim(`(${HINTS[key]})`)}`),
|
|
72
101
|
"",
|
|
73
102
|
`With --session, the value applies to that session only; \`${RESET}\` removes its own value.`,
|
|
74
103
|
"Takes effect on the next request; no restart needed.",
|
|
75
104
|
],
|
|
76
105
|
options: [["--session <id>", "Change it for just this session (repeatable; a unique prefix is enough)"]],
|
|
77
|
-
|
|
78
|
-
const
|
|
106
|
+
run(args) {
|
|
107
|
+
const [verb, key, raw] = args.filter((arg, i) => !arg.startsWith("-") && args[i - 1] !== "--session")
|
|
79
108
|
const ids = sessionArgs(args)
|
|
80
|
-
|
|
81
|
-
if (
|
|
82
|
-
if (interactive) await pickSetting(ids)
|
|
83
|
-
else listSettings()
|
|
84
|
-
return
|
|
85
|
-
}
|
|
86
|
-
if (verb === "list") {
|
|
87
|
-
listSettings()
|
|
88
|
-
return
|
|
89
|
-
}
|
|
90
|
-
if (verb !== "set") fail(`Unknown subcommand: ${verb}`, "Use `eunoe settings list` or `eunoe settings set <key> <value>`.")
|
|
109
|
+
if (verb === "list" || (!verb && !interactive)) return listSettings()
|
|
110
|
+
if (verb && verb !== "set") fail(`Unknown subcommand: ${verb}`, "Use `eunoe settings list` or `eunoe settings set <key> <value>`.")
|
|
91
111
|
if (!key) {
|
|
92
|
-
if (interactive)
|
|
93
|
-
|
|
94
|
-
return
|
|
112
|
+
if (!interactive) fail("set needs a key and a value", "eunoe settings set <key> <value>")
|
|
113
|
+
return pickSetting(ids)
|
|
95
114
|
}
|
|
96
115
|
if (!TUNABLES.includes(key as Tunable)) fail(`Unknown setting: ${key}`, `Choose ${TUNABLES.join(", ")}.`)
|
|
97
|
-
if (raw === undefined) fail(`${key} needs a value`, `eunoe settings set ${key} <${
|
|
116
|
+
if (raw === undefined) fail(`${key} needs a value`, `eunoe settings set ${key} <${HINTS[key as Tunable]}>`)
|
|
98
117
|
applySetting(key as Tunable, raw, ids)
|
|
99
118
|
},
|
|
100
119
|
}
|
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
import { existsSync } from "node:fs"
|
|
2
2
|
import path from "node:path"
|
|
3
|
-
import { claudeConfigDirs, compactionOwner,
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
import { editSettings, withEunoe } from "../claude-settings"
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { startService } from "../service"
|
|
3
|
+
import { type Config, claudeConfigDirs, compactionOwner, modeFor, readConfig, saveConfig } from "../../config"
|
|
4
|
+
import { seenSessions } from "../../proxy/sessions"
|
|
5
|
+
import type { Command } from "../command"
|
|
6
|
+
import { editSettings, withEunoe } from "../proxy/claude-settings"
|
|
7
|
+
import { routedDirs } from "../proxy/inspect"
|
|
8
|
+
import { isIdle, isTerminal, type LiveSession, listLiveSessions, restartSessions, unroutedSessions } from "../proxy/live"
|
|
9
|
+
import { startService } from "../proxy/service"
|
|
10
|
+
import { clearWatch } from "../proxy/watch"
|
|
10
11
|
import { isKnown, sessionArgs, shortId } from "../sessions"
|
|
11
|
-
import
|
|
12
|
-
import { c, fail, line, listFiles, note, ok, rows, tilde } from "../ui"
|
|
12
|
+
import { flagValues } from "../terminal/args"
|
|
13
|
+
import { c, fail, line, listFiles, note, ok, plural, rows, tilde } from "../terminal/ui"
|
|
14
|
+
import { MODE_DESCRIPTIONS } from "./mode"
|
|
13
15
|
|
|
14
16
|
function scoped(config: Config, ids: string[]): Partial<Config> {
|
|
15
|
-
if (
|
|
17
|
+
if (ids.length === 0) return { scope: "all" }
|
|
16
18
|
const managingAll = config.scope === "all" && routedDirs(config).length > 0
|
|
17
19
|
const sessions = { ...config.sessions }
|
|
18
20
|
for (const id of ids) {
|
|
@@ -22,13 +24,42 @@ function scoped(config: Config, ids: string[]): Partial<Config> {
|
|
|
22
24
|
return { scope: managingAll ? "all" : "sessions", sessions }
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
function managedSessions(config: Config): string {
|
|
28
|
+
if (config.scope === "all") return "all"
|
|
29
|
+
const ids = Object.entries(config.sessions)
|
|
30
|
+
.filter(([, own]) => own.mode !== "off")
|
|
31
|
+
.map(([id]) => shortId(id))
|
|
32
|
+
return `only ${ids.join(", ")}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function restartUnrouted(config: Config, skip: boolean): { restarted: LiveSession[]; busy: LiveSession[] } {
|
|
36
|
+
const lastSeen = new Map(seenSessions().map(([id, seen]) => [id, seen.lastSeen]))
|
|
37
|
+
const managed = (id: string) => modeFor(config, id) !== "off"
|
|
38
|
+
const unrouted = unroutedSessions(listLiveSessions(claudeConfigDirs(config)), lastSeen, managed, process.env.CLAUDE_CODE_SESSION_ID)
|
|
39
|
+
if (skip) return { restarted: [], busy: unrouted }
|
|
40
|
+
return { restarted: restartSessions(unrouted.filter(isIdle)), busy: unrouted.filter((session) => !isIdle(session)) }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function reportRestarts({ restarted, busy }: { restarted: LiveSession[]; busy: LiveSession[] }) {
|
|
44
|
+
if (restarted.length > 0) {
|
|
45
|
+
ok(`Restarted ${plural(restarted.length, "idle session")} so they go through eunoe`)
|
|
46
|
+
if (restarted.some((session) => !isTerminal(session))) note("App sessions come back on their next message.")
|
|
47
|
+
if (restarted.some(isTerminal)) note("Resume the terminal ones with:")
|
|
48
|
+
for (const session of restarted.filter(isTerminal)) line(` ${c.dim(`cd ${tilde(session.cwd)} && claude --resume ${session.sessionId}`)}`)
|
|
49
|
+
line()
|
|
50
|
+
}
|
|
51
|
+
if (busy.length > 0) note(`Still on the direct route until restarted: ${busy.map((session) => shortId(session.sessionId)).join(", ")}.`)
|
|
52
|
+
}
|
|
53
|
+
|
|
25
54
|
export const start: Command = {
|
|
26
55
|
summary: "Route Claude Code through eunoe",
|
|
27
|
-
usage: "eunoe start [--session <id>] [--config-dir <dir>]",
|
|
56
|
+
usage: "eunoe start [--session <id>] [--config-dir <dir>] [--no-restart]",
|
|
28
57
|
details: [
|
|
29
58
|
"Starts the background proxy if it isn't running (setting it up the first time)",
|
|
30
59
|
"and points Claude Code at it through ANTHROPIC_BASE_URL in settings.json.",
|
|
31
|
-
"New sessions pick it up
|
|
60
|
+
"New sessions pick it up. Idle sessions already running are restarted so they",
|
|
61
|
+
"pick it up too: app-hosted ones resume on their next message, terminal ones",
|
|
62
|
+
"print the command that resumes them. Busy sessions are left alone.",
|
|
32
63
|
"",
|
|
33
64
|
"With --session, eunoe manages only the sessions you name and passes the rest",
|
|
34
65
|
"through untouched. Session ids are in Claude Code's /status, or `eunoe status`.",
|
|
@@ -36,29 +67,33 @@ export const start: Command = {
|
|
|
36
67
|
options: [
|
|
37
68
|
["--session <id>", "Only manage this session (repeatable; a unique prefix is enough)"],
|
|
38
69
|
["--config-dir <dir>", "Also manage this Claude config directory (repeatable, remembered)"],
|
|
70
|
+
["--no-restart", "Leave running sessions alone"],
|
|
39
71
|
],
|
|
40
72
|
async run(args) {
|
|
41
73
|
const added = flagValues(args, "--config-dir").map((dir) => path.resolve(dir))
|
|
42
74
|
for (const dir of added) if (!existsSync(dir)) fail(`No such directory: ${tilde(dir)}`)
|
|
43
|
-
if (added.length) saveConfig({ claudeConfigDirs: [...new Set([...
|
|
75
|
+
if (added.length > 0) saveConfig({ claudeConfigDirs: [...new Set([...readConfig().claudeConfigDirs, ...added])] })
|
|
44
76
|
const ids = sessionArgs(args)
|
|
45
|
-
saveConfig(scoped(
|
|
46
|
-
const config =
|
|
77
|
+
saveConfig(scoped(readConfig(), ids))
|
|
78
|
+
const config = readConfig()
|
|
47
79
|
|
|
48
80
|
line()
|
|
49
|
-
await startService(config)
|
|
81
|
+
if (!(await startService(config))) fail("Claude Code was left alone because the proxy isn't up.", "Nothing is routed through a proxy that can't answer. Fix the proxy and run `eunoe start` again.")
|
|
82
|
+
clearWatch()
|
|
50
83
|
const touched = editSettings(claudeConfigDirs(config), (settings) => withEunoe(settings, config.port, compactionOwner(config)))
|
|
51
84
|
ok("Claude Code routed through eunoe")
|
|
52
|
-
listFiles(touched.length ? touched : routedDirs(config).map((dir) => path.join(dir, "settings.json")))
|
|
85
|
+
listFiles(touched.length > 0 ? touched : routedDirs(config).map((dir) => path.join(dir, "settings.json")))
|
|
53
86
|
line()
|
|
54
87
|
rows([
|
|
55
88
|
["Mode", `${c.bold(config.mode)} ${c.dim(MODE_DESCRIPTIONS[config.mode])}`],
|
|
56
|
-
["Sessions",
|
|
89
|
+
["Sessions", managedSessions(config)],
|
|
57
90
|
])
|
|
58
91
|
line()
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
92
|
+
const restarts = restartUnrouted(config, args.includes("--no-restart"))
|
|
93
|
+
reportRestarts(restarts)
|
|
94
|
+
const unseen = ids.filter((id) => !isKnown(id) && !restarts.restarted.some((session) => session.sessionId === id))
|
|
95
|
+
if (unseen.length > 0) note(`Not seen through eunoe yet: ${unseen.map(shortId).join(", ")}. It applies once that session's requests come through; one started before routing needs a restart.`)
|
|
96
|
+
else note(ids.length > 0 ? "Takes effect on the session's next request." : "New sessions go through eunoe.")
|
|
62
97
|
line()
|
|
63
98
|
},
|
|
64
99
|
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { claudeConfigDirs, readConfig } from "../../config"
|
|
2
|
+
import type { Command } from "../command"
|
|
3
|
+
import { cleanupDays } from "../proxy/claude-settings"
|
|
4
|
+
import { type RequestRecord, recentRequests } from "../proxy/inspect"
|
|
5
|
+
import { savedTokens, totals } from "../proxy/stats"
|
|
6
|
+
import { c, fail, line, percent, plural, table, tokens, wrap } from "../terminal/ui"
|
|
7
|
+
import { costOf, ratesOf, readUsage, type UsageEntry, usageFiles } from "../usage"
|
|
8
|
+
|
|
9
|
+
interface Bucket {
|
|
10
|
+
start: number
|
|
11
|
+
label: string
|
|
12
|
+
usage: UsageEntry[]
|
|
13
|
+
logged: RequestRecord[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type Matched = Map<RequestRecord, UsageEntry>
|
|
17
|
+
|
|
18
|
+
const DAY = 86_400_000
|
|
19
|
+
const MATCH_WITHIN = 10 * 60_000
|
|
20
|
+
const NOTE_WIDTH = 110
|
|
21
|
+
const DEFAULT_DAYS = 7
|
|
22
|
+
const DEFAULT_WEEKS = 8
|
|
23
|
+
|
|
24
|
+
const midnight = (ms: number) => new Date(new Date(ms).toDateString()).getTime()
|
|
25
|
+
const addDays = (ms: number, days: number) => {
|
|
26
|
+
const date = new Date(ms)
|
|
27
|
+
date.setDate(date.getDate() + days)
|
|
28
|
+
return date.getTime()
|
|
29
|
+
}
|
|
30
|
+
const monday = (ms: number) => addDays(midnight(ms), -((new Date(ms).getDay() + 6) % 7))
|
|
31
|
+
const short = (ms: number) => new Date(ms).toLocaleDateString(undefined, { month: "short", day: "numeric" })
|
|
32
|
+
const dayLabel = (ms: number) => new Date(ms).toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })
|
|
33
|
+
const dollars = (amount: number) => `$${amount >= 100 ? Math.round(amount).toLocaleString() : amount.toFixed(2)}`
|
|
34
|
+
|
|
35
|
+
export function buckets(now: number, weekly: boolean, count: number): Bucket[] {
|
|
36
|
+
const step = weekly ? 7 : 1
|
|
37
|
+
const last = weekly ? monday(now) : midnight(now)
|
|
38
|
+
return Array.from({ length: count }, (_, i) => {
|
|
39
|
+
const start = addDays(last, -step * (count - 1 - i))
|
|
40
|
+
const label = weekly ? `${short(start)} – ${short(addDays(start, 6))}` : dayLabel(start)
|
|
41
|
+
return { start, label, usage: [], logged: [] }
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function place(list: Bucket[], at: number, add: (bucket: Bucket) => void) {
|
|
46
|
+
for (let i = list.length - 1; i >= 0; i -= 1) {
|
|
47
|
+
if (at < list[i].start) continue
|
|
48
|
+
add(list[i])
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function summarize(usage: UsageEntry[]) {
|
|
54
|
+
const sum = { requests: usage.length, input: 0, output: 0, cacheWrite: 0, cacheRead: 0, cost: 0, unpriced: 0 }
|
|
55
|
+
for (const entry of usage) {
|
|
56
|
+
sum.input += entry.input
|
|
57
|
+
sum.output += entry.output
|
|
58
|
+
sum.cacheWrite += entry.cacheWrite5m + entry.cacheWrite1h
|
|
59
|
+
sum.cacheRead += entry.cacheRead
|
|
60
|
+
const cost = costOf(entry)
|
|
61
|
+
if (cost === null) sum.unpriced += 1
|
|
62
|
+
else sum.cost += cost
|
|
63
|
+
}
|
|
64
|
+
return sum
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const usageKey = (cacheRead: number, cacheWrite: number, uncached: number) => `${cacheRead}:${cacheWrite}:${uncached}`
|
|
68
|
+
|
|
69
|
+
export function matchLogged(usage: UsageEntry[], logged: RequestRecord[]): Matched {
|
|
70
|
+
const index = new Map<string, UsageEntry[]>()
|
|
71
|
+
for (const entry of usage) {
|
|
72
|
+
const key = usageKey(entry.cacheRead, entry.cacheWrite5m + entry.cacheWrite1h, entry.input)
|
|
73
|
+
index.set(key, [...(index.get(key) ?? []), entry])
|
|
74
|
+
}
|
|
75
|
+
const matched: Matched = new Map()
|
|
76
|
+
for (const record of logged) {
|
|
77
|
+
if (!record.input) continue
|
|
78
|
+
const at = Date.parse(record.ts)
|
|
79
|
+
const distance = (entry: UsageEntry) => Math.abs(entry.at - at)
|
|
80
|
+
const nearest = (index.get(usageKey(record.cacheRead ?? 0, record.cacheWrite ?? 0, record.uncached ?? 0)) ?? []).reduce<UsageEntry | null>((best, entry) => (!best || distance(entry) < distance(best) ? entry : best), null)
|
|
81
|
+
if (nearest && distance(nearest) <= MATCH_WITHIN) matched.set(record, nearest)
|
|
82
|
+
}
|
|
83
|
+
return matched
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function costSaved(record: RequestRecord, entry: UsageEntry): number | null {
|
|
87
|
+
const saved = savedTokens(record)
|
|
88
|
+
const rates = ratesOf(entry)
|
|
89
|
+
if (saved === null || !rates) return null
|
|
90
|
+
const foldRewrite = record.compacted ? (record.cacheWrite ?? 0) * (rates.write - rates.read) : 0
|
|
91
|
+
return saved * rates.read - foldRewrite
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function savings(logged: RequestRecord[], matched: Matched) {
|
|
95
|
+
const sum = { handled: 0, priced: 0, unsized: 0, unmatched: 0, actual: 0, saved: 0 }
|
|
96
|
+
for (const record of logged) {
|
|
97
|
+
if (!record.input) continue
|
|
98
|
+
sum.handled += 1
|
|
99
|
+
const entry = matched.get(record)
|
|
100
|
+
if (savedTokens(record) === null) sum.unsized += 1
|
|
101
|
+
else if (!entry) sum.unmatched += 1
|
|
102
|
+
const saved = entry ? costSaved(record, entry) : null
|
|
103
|
+
const actual = entry ? costOf(entry) : null
|
|
104
|
+
if (saved === null || actual === null) continue
|
|
105
|
+
sum.priced += 1
|
|
106
|
+
sum.actual += actual
|
|
107
|
+
sum.saved += saved
|
|
108
|
+
}
|
|
109
|
+
return sum
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function leftOut(sum: ReturnType<typeof savings>) {
|
|
113
|
+
const reasons = [
|
|
114
|
+
sum.unsized ? `${sum.unsized.toLocaleString()} predate size logging` : "",
|
|
115
|
+
sum.unmatched ? `${sum.unmatched.toLocaleString()} aren't in session files` : "",
|
|
116
|
+
sum.handled - sum.priced - sum.unsized - sum.unmatched > 0 ? `${sum.handled - sum.priced - sum.unsized - sum.unmatched} to models without a price` : "",
|
|
117
|
+
]
|
|
118
|
+
const listed = reasons.filter(Boolean)
|
|
119
|
+
return listed.length > 1 ? `${listed.slice(0, -1).join(", ")} and ${listed.at(-1)}` : listed.join("")
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function notSentCell(logged: RequestRecord[]) {
|
|
123
|
+
const sum = totals(logged)
|
|
124
|
+
if (sum.requests === 0) return c.dim("–")
|
|
125
|
+
if (sum.saved <= 0) return c.dim("±0")
|
|
126
|
+
return `${tokens(sum.saved)} ${c.dim(plural(sum.folds, "fold"))}`
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function costSavedCell(logged: RequestRecord[], matched: Matched) {
|
|
130
|
+
const sum = savings(logged, matched)
|
|
131
|
+
if (sum.priced === 0) return c.dim("–")
|
|
132
|
+
if (Math.abs(sum.saved) < 0.005) return c.dim("±0")
|
|
133
|
+
const share = c.dim(percent(sum.saved / (sum.actual + sum.saved)))
|
|
134
|
+
return sum.saved > 0 ? `${c.green(c.bold(dollars(sum.saved)))} ${share}` : `${c.yellow(`−${dollars(-sum.saved)}`)} ${share}`
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function row(label: string, usage: UsageEntry[], logged: RequestRecord[], matched: Matched, bold = false) {
|
|
138
|
+
const sum = summarize(usage)
|
|
139
|
+
const strong = (text: string) => (bold ? c.bold(text) : text)
|
|
140
|
+
const eunoe = [notSentCell(logged), costSavedCell(logged, matched)]
|
|
141
|
+
if (sum.requests === 0) return [strong(label), c.dim("–"), "", "", "", "", "", ...eunoe]
|
|
142
|
+
return [strong(label), strong(sum.requests.toLocaleString()), tokens(sum.input), tokens(sum.output), tokens(sum.cacheWrite), tokens(sum.cacheRead), strong(`${dollars(sum.cost)}${sum.unpriced ? c.dim("*") : ""}`), ...eunoe]
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function byModel(usage: UsageEntry[]) {
|
|
146
|
+
const models = new Map<string, UsageEntry[]>()
|
|
147
|
+
for (const entry of usage) models.set(entry.model, [...(models.get(entry.model) ?? []), entry])
|
|
148
|
+
const total = summarize(usage).cost
|
|
149
|
+
return [...models]
|
|
150
|
+
.map(([model, entries]) => ({ model, sum: summarize(entries) }))
|
|
151
|
+
.sort((a, b) => b.sum.cost - a.sum.cost || b.sum.requests - a.sum.requests)
|
|
152
|
+
.map(({ model, sum }) => [
|
|
153
|
+
model,
|
|
154
|
+
sum.requests.toLocaleString(),
|
|
155
|
+
tokens(sum.input),
|
|
156
|
+
tokens(sum.output),
|
|
157
|
+
tokens(sum.cacheWrite),
|
|
158
|
+
tokens(sum.cacheRead),
|
|
159
|
+
sum.unpriced ? c.dim("no price") : c.bold(dollars(sum.cost)),
|
|
160
|
+
c.dim(total > 0 && !sum.unpriced ? percent(sum.cost / total) : ""),
|
|
161
|
+
])
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const countArg = (args: string[], flag: string, fallback: number) => {
|
|
165
|
+
const at = args.indexOf(flag)
|
|
166
|
+
if (at === -1) return fallback
|
|
167
|
+
const value = Number(args[at + 1])
|
|
168
|
+
if (!Number.isInteger(value) || value < 1) fail(`${flag} needs a whole number`, `eunoe stats ${flag} 14`)
|
|
169
|
+
return value
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export const stats: Command = {
|
|
173
|
+
summary: "Token usage and cost by day or week, and what eunoe saved",
|
|
174
|
+
usage: "eunoe stats [--days <n>] [--weekly] [--weeks <n>]",
|
|
175
|
+
details: [
|
|
176
|
+
"Reads the usage the API returned for every request in your Claude Code",
|
|
177
|
+
"sessions, subagents included, counting each response once. Cost is at",
|
|
178
|
+
"API list prices. The eunoe columns come from its request log: the input",
|
|
179
|
+
"tokens it kept out of the requests it handled, and what that saved at",
|
|
180
|
+
"cache-read prices once the cache writes its folds caused are paid for.",
|
|
181
|
+
],
|
|
182
|
+
options: [
|
|
183
|
+
["--days <n>", `Days to show (default ${DEFAULT_DAYS})`],
|
|
184
|
+
["--weekly", `Group by week, Monday first (default ${DEFAULT_WEEKS} weeks)`],
|
|
185
|
+
["--weeks <n>", "Weeks to show; implies --weekly"],
|
|
186
|
+
],
|
|
187
|
+
run(args) {
|
|
188
|
+
const weekly = args.includes("--weekly") || args.includes("--weeks")
|
|
189
|
+
const count = weekly ? countArg(args, "--weeks", DEFAULT_WEEKS) : countArg(args, "--days", DEFAULT_DAYS)
|
|
190
|
+
const now = Date.now()
|
|
191
|
+
const list = buckets(now, weekly, count)
|
|
192
|
+
const since = list[0].start
|
|
193
|
+
const configDirs = claudeConfigDirs(readConfig())
|
|
194
|
+
const usage = readUsage(usageFiles(configDirs, since), since)
|
|
195
|
+
const logged = recentRequests(Number.POSITIVE_INFINITY).filter((record) => Date.parse(record.ts) >= since)
|
|
196
|
+
for (const entry of usage) place(list, entry.at, (bucket) => bucket.usage.push(entry))
|
|
197
|
+
const matched = matchLogged(usage, logged)
|
|
198
|
+
for (const record of logged) place(list, Date.parse(record.ts), (bucket) => bucket.logged.push(record))
|
|
199
|
+
|
|
200
|
+
line()
|
|
201
|
+
line(` ${c.bold("eunoe stats")} ${c.dim(`the last ${count} ${weekly ? "weeks" : "days"} of Claude Code, from your session files`)}`)
|
|
202
|
+
line()
|
|
203
|
+
table([
|
|
204
|
+
[weekly ? "Week" : "Day", "Requests", "Input", "Output", "Cache write", "Cache read", "API cost", "Not sent by eunoe", "Cost saved"].map((title) => c.dim(title)),
|
|
205
|
+
...list.map((bucket) => row(bucket.label, bucket.usage, bucket.logged, matched)),
|
|
206
|
+
row("Total", usage, logged, matched, true),
|
|
207
|
+
])
|
|
208
|
+
|
|
209
|
+
const models = byModel(usage)
|
|
210
|
+
if (models.length > 0) {
|
|
211
|
+
line()
|
|
212
|
+
table([["Model", "Requests", "Input", "Output", "Cache write", "Cache read", "API cost", "Share"].map((title) => c.dim(title)), ...models])
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const keepDays = cleanupDays(configDirs)
|
|
216
|
+
const handled = savings(logged, matched)
|
|
217
|
+
const notes: Array<[label: string, text: string]> = [
|
|
218
|
+
["API cost", "At list prices, including cache writes and fast mode."],
|
|
219
|
+
["Not sent", "Input eunoe kept out, mostly history Claude Code would have read from cache."],
|
|
220
|
+
["Cost saved", "Those tokens at cache-read prices, minus what each fold cost to rewrite."],
|
|
221
|
+
...(handled.priced < handled.handled ? [["Not priced", `${(handled.handled - handled.priced).toLocaleString()} of ${handled.handled.toLocaleString()} eunoe requests: ${leftOut(handled)}.`] as [string, string]] : []),
|
|
222
|
+
...(summarize(usage).unpriced ? [["* No price", "Some models have no known price, so their tokens count but their cost doesn't."] as [string, string]] : []),
|
|
223
|
+
...(since < now - keepDays * DAY ? [["History", `Claude Code deletes sessions after ${keepDays} idle days, so older rows may be incomplete.`] as [string, string]] : []),
|
|
224
|
+
]
|
|
225
|
+
const labelWidth = Math.max(...notes.map(([label]) => label.length)) + 3
|
|
226
|
+
const width = Math.min(process.stdout.columns || NOTE_WIDTH, NOTE_WIDTH) - 2 - labelWidth
|
|
227
|
+
for (const [label, text] of notes) {
|
|
228
|
+
line()
|
|
229
|
+
for (const [index, part] of wrap(text, width).entries()) line(` ${c.dim((index === 0 ? label : "").padEnd(labelWidth))}${part}`)
|
|
230
|
+
}
|
|
231
|
+
line()
|
|
232
|
+
},
|
|
233
|
+
}
|