@chroxify/eunoe 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +406 -2
- package/package.json +42 -4
- package/src/cli/args.ts +17 -0
- package/src/cli/claude-settings.ts +42 -0
- package/src/cli/commands/index.ts +23 -0
- package/src/cli/commands/mode.ts +66 -0
- package/src/cli/commands/serve.ts +20 -0
- package/src/cli/commands/settings.ts +100 -0
- package/src/cli/commands/start.ts +64 -0
- package/src/cli/commands/status.ts +82 -0
- package/src/cli/commands/stop.ts +38 -0
- package/src/cli/commands/uninstall.ts +27 -0
- package/src/cli/constants.ts +52 -0
- package/src/cli/help.ts +42 -0
- package/src/cli/inspect.ts +41 -0
- package/src/cli/launchd.ts +39 -0
- package/src/cli/logo/index.ts +89 -0
- package/src/cli/logo/pixels.ts +34 -0
- package/src/cli/main.ts +23 -0
- package/src/cli/prompt.ts +91 -0
- package/src/cli/service.ts +33 -0
- package/src/cli/sessions.ts +25 -0
- package/src/cli/stats.ts +73 -0
- package/src/cli/types.ts +14 -0
- package/src/cli/ui.ts +106 -0
- package/src/config/constants.ts +39 -0
- package/src/config/index.ts +98 -0
- package/src/config/types.ts +50 -0
- package/src/context/constants.ts +42 -0
- package/src/context/content.ts +46 -0
- package/src/context/evidence.ts +92 -0
- package/src/context/prompts.ts +66 -0
- package/src/context/recall.ts +155 -0
- package/src/context/rerank.ts +44 -0
- package/src/context/strategies.ts +89 -0
- package/src/context/turns.ts +87 -0
- package/src/context/types.ts +66 -0
- package/src/proxy/canonical.ts +32 -0
- package/src/proxy/compaction.ts +110 -0
- package/src/proxy/constants.ts +29 -0
- package/src/proxy/estimate.ts +26 -0
- package/src/proxy/fingerprint.ts +15 -0
- package/src/proxy/log.ts +9 -0
- package/src/proxy/rewrite.ts +146 -0
- package/src/proxy/server.ts +138 -0
- package/src/proxy/sessions.ts +46 -0
- package/src/proxy/state.ts +27 -0
- package/src/proxy/types.ts +82 -0
- package/src/proxy/usage.ts +44 -0
- package/src/proxy/window.ts +50 -0
- package/src/transcript/constants.ts +15 -0
- package/src/transcript/format.ts +68 -0
- package/src/transcript/locate.ts +28 -0
- package/src/transcript/messages.ts +29 -0
- package/src/transcript/parse.ts +93 -0
- package/src/transcript/render.ts +36 -0
- package/src/transcript/search.ts +80 -0
- package/src/transcript/types.ts +43 -0
- package/src/version.ts +3 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { loadConfig, overrides, parseTunable, saveConfig } from "../../config"
|
|
2
|
+
import { DEFAULTS, TUNABLES } from "../../config/constants"
|
|
3
|
+
import type { Tunable } from "../../config/types"
|
|
4
|
+
import { TUNABLE_DESCRIPTIONS, TUNABLE_HINTS } from "../constants"
|
|
5
|
+
import { input, interactive, select } from "../prompt"
|
|
6
|
+
import { sessionArgs, shortId } from "../sessions"
|
|
7
|
+
import type { Command } from "../types"
|
|
8
|
+
import { c, fail, line, ok, rows } from "../ui"
|
|
9
|
+
|
|
10
|
+
const RESET = "default"
|
|
11
|
+
|
|
12
|
+
function listSettings() {
|
|
13
|
+
const config = loadConfig()
|
|
14
|
+
line()
|
|
15
|
+
const width = Math.max(...TUNABLES.map((name) => String(config[name]).length))
|
|
16
|
+
rows(TUNABLES.map((name) => {
|
|
17
|
+
const value = String(config[name])
|
|
18
|
+
const changed = config[name] !== DEFAULTS[name]
|
|
19
|
+
return [name, `${c.bold(value.padEnd(width))} ${c.dim(TUNABLE_DESCRIPTIONS[name])}${changed ? c.dim(` (default ${DEFAULTS[name]})`) : ""}`]
|
|
20
|
+
}))
|
|
21
|
+
const own = Object.entries(config.sessions).map(([id, settings]) => [id, overrides(settings)] as const).filter(([, values]) => Object.keys(values).length)
|
|
22
|
+
if (own.length) {
|
|
23
|
+
line()
|
|
24
|
+
for (const [id, values] of own) line(` ${c.cyan(shortId(id))} ${Object.entries(values).map(([name, value]) => `${name} ${c.bold(String(value))}`).join(c.dim(" · "))}`)
|
|
25
|
+
}
|
|
26
|
+
line()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function applySetting(name: Tunable, raw: string, ids: string[]) {
|
|
30
|
+
const config = loadConfig()
|
|
31
|
+
if (raw === RESET) {
|
|
32
|
+
if (!ids.length) fail(`\`${RESET}\` only applies with --session`, `eunoe settings set ${name} ${RESET} --session <id>`)
|
|
33
|
+
const sessions = { ...config.sessions }
|
|
34
|
+
for (const id of ids) {
|
|
35
|
+
if (!sessions[id]) continue
|
|
36
|
+
const { [name]: _, ...rest } = sessions[id]
|
|
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
|
|
42
|
+
}
|
|
43
|
+
const value = parseTunable(name, raw)
|
|
44
|
+
if (value === null) fail(`Not a valid ${name}: ${raw}`, `Expected ${TUNABLE_HINTS[name]}.`)
|
|
45
|
+
if (ids.length) {
|
|
46
|
+
saveConfig({ sessions: { ...config.sessions, ...Object.fromEntries(ids.map((id) => [id, { ...config.sessions[id], [name]: value }])) } })
|
|
47
|
+
ok(`${name} ${c.bold(String(value))} for ${ids.map(shortId).join(", ")}`)
|
|
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>")}`)
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
saveConfig({ [name]: value })
|
|
52
|
+
ok(`${name} ${c.bold(String(value))}`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function pickSetting(ids: string[]) {
|
|
56
|
+
const config = loadConfig()
|
|
57
|
+
line()
|
|
58
|
+
const name = await select<Tunable>(ids.length ? `Setting for ${ids.map(shortId).join(", ")}` : "Setting", TUNABLES.map((value) => ({ value, hint: `${String(config[value]).padEnd(12)} ${TUNABLE_DESCRIPTIONS[value]}` })))
|
|
59
|
+
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(TUNABLE_HINTS[name])}`, `${current} (current)`)
|
|
62
|
+
if (raw === null || raw === "") return
|
|
63
|
+
applySetting(name, raw, ids)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const settings: Command = {
|
|
67
|
+
summary: "List or change settings, for every session or just one",
|
|
68
|
+
usage: "eunoe settings [list | set <key> <value>] [--session <id>]",
|
|
69
|
+
details: [
|
|
70
|
+
"Without arguments it opens a picker. Keys:",
|
|
71
|
+
...TUNABLES.map((key) => ` ${key.padEnd(10)} ${TUNABLE_DESCRIPTIONS[key]} ${c.dim(`(${TUNABLE_HINTS[key]})`)}`),
|
|
72
|
+
"",
|
|
73
|
+
`With --session, the value applies to that session only; \`${RESET}\` removes its own value.`,
|
|
74
|
+
"Takes effect on the next request; no restart needed.",
|
|
75
|
+
],
|
|
76
|
+
options: [["--session <id>", "Change it for just this session (repeatable; a unique prefix is enough)"]],
|
|
77
|
+
async run(args) {
|
|
78
|
+
const words = args.filter((arg, i) => !arg.startsWith("-") && args[i - 1] !== "--session")
|
|
79
|
+
const ids = sessionArgs(args)
|
|
80
|
+
const [verb, key, raw] = words
|
|
81
|
+
if (!verb) {
|
|
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>`.")
|
|
91
|
+
if (!key) {
|
|
92
|
+
if (interactive) await pickSetting(ids)
|
|
93
|
+
else fail("set needs a key and a value", "eunoe settings set <key> <value>")
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
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} <${TUNABLE_HINTS[key as Tunable]}>`)
|
|
98
|
+
applySetting(key as Tunable, raw, ids)
|
|
99
|
+
},
|
|
100
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { existsSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { claudeConfigDirs, compactionOwner, loadConfig, saveConfig } from "../../config"
|
|
4
|
+
import type { Config } from "../../config/types"
|
|
5
|
+
import { flagValues } from "../args"
|
|
6
|
+
import { editSettings, withEunoe } from "../claude-settings"
|
|
7
|
+
import { MODE_DESCRIPTIONS } from "../constants"
|
|
8
|
+
import { routedDirs } from "../inspect"
|
|
9
|
+
import { startService } from "../service"
|
|
10
|
+
import { isKnown, sessionArgs, shortId } from "../sessions"
|
|
11
|
+
import type { Command } from "../types"
|
|
12
|
+
import { c, fail, line, listFiles, note, ok, rows, tilde } from "../ui"
|
|
13
|
+
|
|
14
|
+
function scoped(config: Config, ids: string[]): Partial<Config> {
|
|
15
|
+
if (!ids.length) return { scope: "all" }
|
|
16
|
+
const managingAll = config.scope === "all" && routedDirs(config).length > 0
|
|
17
|
+
const sessions = { ...config.sessions }
|
|
18
|
+
for (const id of ids) {
|
|
19
|
+
const { mode, ...rest } = sessions[id] ?? {}
|
|
20
|
+
sessions[id] = mode && mode !== "off" ? { mode, ...rest } : rest
|
|
21
|
+
}
|
|
22
|
+
return { scope: managingAll ? "all" : "sessions", sessions }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const start: Command = {
|
|
26
|
+
summary: "Route Claude Code through eunoe",
|
|
27
|
+
usage: "eunoe start [--session <id>] [--config-dir <dir>]",
|
|
28
|
+
details: [
|
|
29
|
+
"Starts the background proxy if it isn't running (setting it up the first time)",
|
|
30
|
+
"and points Claude Code at it through ANTHROPIC_BASE_URL in settings.json.",
|
|
31
|
+
"New sessions pick it up; restart the ones already running.",
|
|
32
|
+
"",
|
|
33
|
+
"With --session, eunoe manages only the sessions you name and passes the rest",
|
|
34
|
+
"through untouched. Session ids are in Claude Code's /status, or `eunoe status`.",
|
|
35
|
+
],
|
|
36
|
+
options: [
|
|
37
|
+
["--session <id>", "Only manage this session (repeatable; a unique prefix is enough)"],
|
|
38
|
+
["--config-dir <dir>", "Also manage this Claude config directory (repeatable, remembered)"],
|
|
39
|
+
],
|
|
40
|
+
async run(args) {
|
|
41
|
+
const added = flagValues(args, "--config-dir").map((dir) => path.resolve(dir))
|
|
42
|
+
for (const dir of added) if (!existsSync(dir)) fail(`No such directory: ${tilde(dir)}`)
|
|
43
|
+
if (added.length) saveConfig({ claudeConfigDirs: [...new Set([...loadConfig().claudeConfigDirs, ...added])] })
|
|
44
|
+
const ids = sessionArgs(args)
|
|
45
|
+
saveConfig(scoped(loadConfig(), ids))
|
|
46
|
+
const config = loadConfig()
|
|
47
|
+
|
|
48
|
+
line()
|
|
49
|
+
await startService(config)
|
|
50
|
+
const touched = editSettings(claudeConfigDirs(config), (settings) => withEunoe(settings, config.port, compactionOwner(config)))
|
|
51
|
+
ok("Claude Code routed through eunoe")
|
|
52
|
+
listFiles(touched.length ? touched : routedDirs(config).map((dir) => path.join(dir, "settings.json")))
|
|
53
|
+
line()
|
|
54
|
+
rows([
|
|
55
|
+
["Mode", `${c.bold(config.mode)} ${c.dim(MODE_DESCRIPTIONS[config.mode])}`],
|
|
56
|
+
["Sessions", config.scope === "all" ? "all" : `only ${Object.entries(config.sessions).filter(([, own]) => own.mode !== "off").map(([id]) => shortId(id)).join(", ")}`],
|
|
57
|
+
])
|
|
58
|
+
line()
|
|
59
|
+
const unseen = ids.filter((id) => !isKnown(id))
|
|
60
|
+
if (unseen.length) 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.`)
|
|
61
|
+
else note(ids.length ? "Takes effect on the session's next request." : "New sessions go through eunoe. Restart any that are already running.")
|
|
62
|
+
line()
|
|
63
|
+
},
|
|
64
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
import { loadConfig, modeFor, overrides } from "../../config"
|
|
3
|
+
import { paths, RERANK_OFF } from "../../config/constants"
|
|
4
|
+
import { rerankKey } from "../../context/rerank"
|
|
5
|
+
import { seenSessions } from "../../proxy/sessions"
|
|
6
|
+
import { VERSION } from "../../version"
|
|
7
|
+
import { proxyUrl } from "../claude-settings"
|
|
8
|
+
import { MODE_DESCRIPTIONS, SESSIONS_SHOWN } from "../constants"
|
|
9
|
+
import { probe, recentRequests, routedDirs } from "../inspect"
|
|
10
|
+
import * as launchd from "../launchd"
|
|
11
|
+
import { shortId } from "../sessions"
|
|
12
|
+
import { perSession, savedShare, totals, withinWindow } from "../stats"
|
|
13
|
+
import type { Command } from "../types"
|
|
14
|
+
import { ago, c, line, percent, rows, table, tilde, tokens, warn } from "../ui"
|
|
15
|
+
|
|
16
|
+
const dot = (on: boolean) => (on ? c.green("●") : c.red("○"))
|
|
17
|
+
|
|
18
|
+
export const status: Command = {
|
|
19
|
+
summary: "Show what eunoe is doing and saving",
|
|
20
|
+
usage: "eunoe status",
|
|
21
|
+
async run() {
|
|
22
|
+
const config = loadConfig()
|
|
23
|
+
const health = await probe(config.port)
|
|
24
|
+
const routed = routedDirs(config)
|
|
25
|
+
const recent = recentRequests()
|
|
26
|
+
const today = withinWindow(recent)
|
|
27
|
+
const stats = perSession(recent)
|
|
28
|
+
const sum = totals(today)
|
|
29
|
+
|
|
30
|
+
line()
|
|
31
|
+
line(` ${c.bold("eunoe")} ${c.dim(VERSION)}`)
|
|
32
|
+
line()
|
|
33
|
+
|
|
34
|
+
const recall = config.rerank === RERANK_OFF ? "bm25" : rerankKey() ? `bm25 ${c.dim("→")} ${config.rerank}` : `bm25 ${c.dim(`(${config.rerank} needs a key in ${tilde(paths.typesafeKey)} or TYPESAFE_API_KEY)`)}`
|
|
35
|
+
rows([
|
|
36
|
+
["Proxy", health ? `${dot(true)} running on ${c.cyan(proxyUrl(config.port))}${launchd.isInstalled() ? c.dim(" launchd") : ""}` : `${dot(false)} not running ${c.dim("(eunoe start)")}`],
|
|
37
|
+
["Claude Code", routed.length ? `${dot(true)} routed ${c.dim(routed.map((dir) => tilde(path.join(dir, "settings.json"))).join(", "))}` : `${c.dim("○")} not routed ${c.dim("(eunoe start)")}`],
|
|
38
|
+
["Mode", `${c.bold(config.mode)} ${c.dim(MODE_DESCRIPTIONS[config.mode])}`],
|
|
39
|
+
["Sessions", config.scope === "all" ? "all" : `only the ones you started it for ${c.dim("(eunoe start --session <id>)")}`],
|
|
40
|
+
["Recall", recall],
|
|
41
|
+
])
|
|
42
|
+
|
|
43
|
+
const sessions = seenSessions().slice(0, SESSIONS_SHOWN)
|
|
44
|
+
if (sessions.length) {
|
|
45
|
+
line()
|
|
46
|
+
line(` ${c.dim("Sessions")}`)
|
|
47
|
+
table(sessions.map(([id, session]) => {
|
|
48
|
+
const active = modeFor(config, id)
|
|
49
|
+
const own = Object.entries(overrides(config.sessions[id])).map(([name, value]) => `${name} ${value}`).join(" · ")
|
|
50
|
+
const stat = stats.get(id)
|
|
51
|
+
const saved = stat ? savedShare(stat.context, stat.plain) : null
|
|
52
|
+
return [
|
|
53
|
+
c.cyan(shortId(id)),
|
|
54
|
+
active === "off" ? c.dim("off") : c.green(active),
|
|
55
|
+
session.cwd ? tilde(session.cwd) : c.dim("–"),
|
|
56
|
+
stat?.turns ? c.dim(`${stat.turns} turns`) : "",
|
|
57
|
+
stat && active !== "off" ? `${c.bold(tokens(stat.context))} ctx` : "",
|
|
58
|
+
saved !== null && active !== "off" ? (saved > 0 ? c.green(`−${percent(saved)}`) : c.dim("±0")) : "",
|
|
59
|
+
c.dim(ago(session.lastSeen)),
|
|
60
|
+
own ? c.dim(own) : "",
|
|
61
|
+
]
|
|
62
|
+
}))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (sum.requests) {
|
|
66
|
+
const hit = sum.cacheRead + sum.cacheWrite + sum.uncached
|
|
67
|
+
line()
|
|
68
|
+
line(` ${c.dim("Last 24h")}`)
|
|
69
|
+
rows([
|
|
70
|
+
["Requests", `${c.bold(String(sum.requests))}${sum.folds ? ` ${c.dim(`${sum.folds} fold${sum.folds === 1 ? "" : "s"}, last ${ago(sum.lastFold!)}`)}` : ""}`],
|
|
71
|
+
["Cache", `${c.bold(tokens(sum.cacheRead))} read ${c.dim("·")} ${tokens(sum.cacheWrite)} written ${c.dim("·")} ${tokens(sum.uncached)} uncached${hit ? c.dim(` (${percent(sum.cacheRead / hit)} hit)`) : ""}`],
|
|
72
|
+
["Saved", sum.saved > 0 ? `${c.bold(c.green(tokens(sum.saved)))} input tokens not sent ${c.dim(`(${percent(sum.saved / (sum.sent + sum.saved))} less than plain Claude Code)`)}` : c.dim("no data yet")],
|
|
73
|
+
], 4)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (health && health.mode !== config.mode) {
|
|
77
|
+
line()
|
|
78
|
+
warn(`The running proxy reports mode ${health.mode}; it switches on its next request.`)
|
|
79
|
+
}
|
|
80
|
+
line()
|
|
81
|
+
},
|
|
82
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { claudeConfigDirs, loadConfig, saveConfig } from "../../config"
|
|
2
|
+
import { editSettings, withoutEunoe } from "../claude-settings"
|
|
3
|
+
import { sessionArgs, shortId } from "../sessions"
|
|
4
|
+
import type { Command } from "../types"
|
|
5
|
+
import { line, listFiles, note, ok } from "../ui"
|
|
6
|
+
|
|
7
|
+
export const stop: Command = {
|
|
8
|
+
summary: "Send new sessions straight to the API again",
|
|
9
|
+
usage: "eunoe stop [--session <id>]",
|
|
10
|
+
details: [
|
|
11
|
+
"Removes eunoe from Claude Code's settings and hands compaction back to it.",
|
|
12
|
+
"The proxy stays up for sessions that are already running through it, so they",
|
|
13
|
+
"keep working until you restart them. `eunoe start` turns it back on.",
|
|
14
|
+
"",
|
|
15
|
+
"With --session, only that session is passed through untouched from its next request.",
|
|
16
|
+
],
|
|
17
|
+
options: [["--session <id>", "Stop managing just this session (repeatable; a unique prefix is enough)"]],
|
|
18
|
+
run(args) {
|
|
19
|
+
const config = loadConfig()
|
|
20
|
+
const ids = sessionArgs(args)
|
|
21
|
+
if (ids.length) {
|
|
22
|
+
const sessions = { ...config.sessions }
|
|
23
|
+
for (const id of ids) {
|
|
24
|
+
if (config.scope === "all") sessions[id] = { ...sessions[id], mode: "off" }
|
|
25
|
+
else delete sessions[id]
|
|
26
|
+
}
|
|
27
|
+
saveConfig({ sessions })
|
|
28
|
+
ok(`eunoe off for ${ids.map(shortId).join(", ")} from the next request`)
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
const touched = editSettings(claudeConfigDirs(config), (settings) => withoutEunoe(settings, config.port))
|
|
32
|
+
line()
|
|
33
|
+
ok("Claude Code no longer routed through eunoe")
|
|
34
|
+
listFiles(touched)
|
|
35
|
+
note("Sessions already running through eunoe keep using it until restarted.")
|
|
36
|
+
line()
|
|
37
|
+
},
|
|
38
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { claudeConfigDirs, loadConfig } from "../../config"
|
|
2
|
+
import { editSettings, withoutEunoe } from "../claude-settings"
|
|
3
|
+
import * as launchd from "../launchd"
|
|
4
|
+
import type { Command } from "../types"
|
|
5
|
+
import { c, line, listFiles, note, ok, warn } from "../ui"
|
|
6
|
+
|
|
7
|
+
export const uninstall: Command = {
|
|
8
|
+
summary: "Stop eunoe and remove the background proxy",
|
|
9
|
+
usage: "eunoe uninstall",
|
|
10
|
+
details: [
|
|
11
|
+
"Everything `stop` does, then shuts the proxy down and removes its background service.",
|
|
12
|
+
"Sessions still running through eunoe lose their connection, so restart them first.",
|
|
13
|
+
"Your config and transcripts in ~/.eunoe are kept.",
|
|
14
|
+
],
|
|
15
|
+
run() {
|
|
16
|
+
const config = loadConfig()
|
|
17
|
+
const touched = editSettings(claudeConfigDirs(config), (settings) => withoutEunoe(settings, config.port))
|
|
18
|
+
line()
|
|
19
|
+
ok("Claude Code no longer routed through eunoe")
|
|
20
|
+
listFiles(touched)
|
|
21
|
+
if (launchd.removeAgent()) ok("Background proxy stopped and removed")
|
|
22
|
+
else note("No background proxy was installed.")
|
|
23
|
+
warn(`Restart any Claude Code session that was still using eunoe.`)
|
|
24
|
+
line(` ${c.dim("Data kept in")} ~/.eunoe`)
|
|
25
|
+
line()
|
|
26
|
+
},
|
|
27
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { homedir } from "node:os"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import type { Mode, Tunable } from "../config/types"
|
|
4
|
+
import type { OptionRow } from "./types"
|
|
5
|
+
|
|
6
|
+
export const TAGLINE = "Context management for Claude Code that never forgets what matters."
|
|
7
|
+
|
|
8
|
+
export const MODE_DESCRIPTIONS: Record<Mode, string> = {
|
|
9
|
+
default: "Recent turns whole, older turns folded; evidence and recall on",
|
|
10
|
+
compact: "Claude Code as it is, only its compaction replaced",
|
|
11
|
+
off: "Passthrough; Claude Code compacts on its own",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const TUNABLE_DESCRIPTIONS: Record<Tunable, string> = {
|
|
15
|
+
keep: "Turns kept whole at the end, in default mode",
|
|
16
|
+
budget: "Tokens of older detail that trigger a fold, in default mode",
|
|
17
|
+
compactAt: "Share of the context window that triggers the cut",
|
|
18
|
+
keepTurns: "Earlier turns kept after a cut",
|
|
19
|
+
search: "How transcripts are written for the agent to search",
|
|
20
|
+
rerank: "Typesafe model that reorders recall, or off",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const TUNABLE_HINTS: Record<Tunable, string> = {
|
|
24
|
+
keep: "a whole number, 1 or more",
|
|
25
|
+
budget: "a token count, 1000 or more",
|
|
26
|
+
compactAt: "a number above 0, up to 1",
|
|
27
|
+
keepTurns: "a whole number or all",
|
|
28
|
+
search: "markdown, xml, jsonl or qmd",
|
|
29
|
+
rerank: "a model name such as jev-preview, or off",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const GLOBAL_OPTIONS: OptionRow[] = [
|
|
33
|
+
["-h, --help", "Show help for a command"],
|
|
34
|
+
["-v, --version", "Show the version"],
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
export const LAUNCHD_LABEL = "com.chroxify.eunoe"
|
|
38
|
+
export const LAUNCH_AGENT_PLIST = path.join(homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`)
|
|
39
|
+
|
|
40
|
+
export const PROBE_TIMEOUT_MS = 800
|
|
41
|
+
export const RECENT_REQUESTS = 5_000
|
|
42
|
+
export const STATUS_WINDOW_MS = 24 * 3_600_000
|
|
43
|
+
export const TYPO_DISTANCE = 2
|
|
44
|
+
export const SHORT_ID_LENGTH = 8
|
|
45
|
+
export const SESSIONS_SHOWN = 5
|
|
46
|
+
|
|
47
|
+
export const TRANSPARENT_PIXEL = "."
|
|
48
|
+
export const HALF_BLOCKS = " ▀▄█"
|
|
49
|
+
export const OCTANTS = [..." 🮂▘▝▀🮅▖▌▞▛▗▚▐▜▂▄▙▟▆█"]
|
|
50
|
+
export const OCTANT_TERMINALS = ["ghostty"]
|
|
51
|
+
export const START_TIMEOUT_MS = 3_000
|
|
52
|
+
export const START_POLL_MS = 150
|
package/src/cli/help.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { VERSION } from "../version"
|
|
2
|
+
import { GLOBAL_OPTIONS, TAGLINE } from "./constants"
|
|
3
|
+
import { withLogo } from "./logo"
|
|
4
|
+
import type { Command } from "./types"
|
|
5
|
+
import { c, line } from "./ui"
|
|
6
|
+
|
|
7
|
+
export function printHelp(commands: Record<string, Command>) {
|
|
8
|
+
const width = Math.max(...Object.keys(commands).map((name) => name.length), ...GLOBAL_OPTIONS.map(([flag]) => flag.length))
|
|
9
|
+
line()
|
|
10
|
+
for (const row of withLogo([`${c.bold("eunoe")} ${c.dim(VERSION)}`, c.italic(TAGLINE)])) line(row)
|
|
11
|
+
line()
|
|
12
|
+
line(` ${c.dim("Usage")}`)
|
|
13
|
+
line(` eunoe ${c.cyan("<command>")} ${c.dim("[options]")}`)
|
|
14
|
+
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()
|
|
18
|
+
line(` ${c.dim("Options")}`)
|
|
19
|
+
for (const [flag, description] of GLOBAL_OPTIONS) line(` ${flag.padEnd(width)} ${description}`)
|
|
20
|
+
line()
|
|
21
|
+
line(` ${c.dim("Run")} eunoe ${c.cyan("<command>")} --help ${c.dim("for details.")} ${c.dim("`ee` works anywhere `eunoe` does.")}`)
|
|
22
|
+
line()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function printCommandHelp(name: string, command: Command) {
|
|
26
|
+
line()
|
|
27
|
+
line(` ${c.bold(`eunoe ${name}`)} ${c.dim(command.summary)}`)
|
|
28
|
+
line()
|
|
29
|
+
line(` ${c.dim("Usage")}`)
|
|
30
|
+
line(` ${command.usage}`)
|
|
31
|
+
if (command.details) {
|
|
32
|
+
line()
|
|
33
|
+
for (const detail of command.details) line(` ${detail}`)
|
|
34
|
+
}
|
|
35
|
+
if (command.options) {
|
|
36
|
+
line()
|
|
37
|
+
line(` ${c.dim("Options")}`)
|
|
38
|
+
const width = Math.max(...command.options.map(([flag]) => flag.length))
|
|
39
|
+
for (const [flag, description] of command.options) line(` ${c.cyan(flag.padEnd(width))} ${description}`)
|
|
40
|
+
}
|
|
41
|
+
line()
|
|
42
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { claudeConfigDirs } from "../config"
|
|
4
|
+
import { paths } from "../config/constants"
|
|
5
|
+
import type { Config } from "../config/types"
|
|
6
|
+
import { HEALTH_PATH } from "../proxy/constants"
|
|
7
|
+
import type { Health } from "../proxy/types"
|
|
8
|
+
import { proxyUrl } from "./claude-settings"
|
|
9
|
+
import { PROBE_TIMEOUT_MS, RECENT_REQUESTS } from "./constants"
|
|
10
|
+
import type { RequestRecord } from "./types"
|
|
11
|
+
|
|
12
|
+
export async function probe(port: number): Promise<Health | null> {
|
|
13
|
+
try {
|
|
14
|
+
const response = await fetch(`${proxyUrl(port)}${HEALTH_PATH}`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) })
|
|
15
|
+
return response.ok ? await response.json() as Health : null
|
|
16
|
+
} catch {
|
|
17
|
+
return null
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function routedDirs(config: Config) {
|
|
22
|
+
return claudeConfigDirs(config).filter((dir) => {
|
|
23
|
+
try {
|
|
24
|
+
const settings = JSON.parse(readFileSync(path.join(dir, "settings.json"), "utf8"))
|
|
25
|
+
return settings.env?.ANTHROPIC_BASE_URL === proxyUrl(config.port)
|
|
26
|
+
} catch {
|
|
27
|
+
return false
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function recentRequests(limit = RECENT_REQUESTS): RequestRecord[] {
|
|
33
|
+
if (!existsSync(paths.requests)) return []
|
|
34
|
+
return readFileSync(paths.requests, "utf8").trim().split("\n").slice(-limit).flatMap((entry) => {
|
|
35
|
+
try {
|
|
36
|
+
return [JSON.parse(entry)]
|
|
37
|
+
} catch {
|
|
38
|
+
return []
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { LAUNCH_AGENT_PLIST as PLIST, LAUNCHD_LABEL } from "./constants"
|
|
4
|
+
|
|
5
|
+
export const supported = process.platform === "darwin"
|
|
6
|
+
|
|
7
|
+
export function isInstalled() {
|
|
8
|
+
return existsSync(PLIST)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function installAgent(program: string[], logFile: string) {
|
|
12
|
+
mkdirSync(path.dirname(PLIST), { recursive: true })
|
|
13
|
+
const args = program.map((arg) => `<string>${arg}</string>`).join("")
|
|
14
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
15
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
16
|
+
<plist version="1.0"><dict>
|
|
17
|
+
<key>Label</key><string>${LAUNCHD_LABEL}</string>
|
|
18
|
+
<key>ProgramArguments</key><array>${args}</array>
|
|
19
|
+
<key>RunAtLoad</key><true/>
|
|
20
|
+
<key>KeepAlive</key><true/>
|
|
21
|
+
<key>StandardOutPath</key><string>${logFile}</string>
|
|
22
|
+
<key>StandardErrorPath</key><string>${logFile}</string>
|
|
23
|
+
</dict></plist>
|
|
24
|
+
`
|
|
25
|
+
if (existsSync(PLIST) && readFileSync(PLIST, "utf8") === plist) {
|
|
26
|
+
Bun.spawnSync(["launchctl", "load", PLIST])
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
writeFileSync(PLIST, plist)
|
|
30
|
+
Bun.spawnSync(["launchctl", "unload", PLIST])
|
|
31
|
+
Bun.spawnSync(["launchctl", "load", PLIST])
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function removeAgent() {
|
|
35
|
+
if (!existsSync(PLIST)) return false
|
|
36
|
+
Bun.spawnSync(["launchctl", "unload", PLIST])
|
|
37
|
+
unlinkSync(PLIST)
|
|
38
|
+
return true
|
|
39
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { HALF_BLOCKS, OCTANT_TERMINALS, OCTANTS, TRANSPARENT_PIXEL } from "../constants"
|
|
2
|
+
import { trueColor, useColor } from "../ui"
|
|
3
|
+
import { LOGO_KEYS, LOGO_PALETTE, LOGO_PIXELS } from "./pixels"
|
|
4
|
+
|
|
5
|
+
type Rgb = [number, number, number]
|
|
6
|
+
type Pixel = Rgb | null
|
|
7
|
+
|
|
8
|
+
const rgb = (hex: string): Rgb => [1, 3, 5].map((i) => Number.parseInt(hex.slice(i, i + 2), 16)) as Rgb
|
|
9
|
+
|
|
10
|
+
const toCube = (value: number) => (value < 48 ? 0 : value < 115 ? 1 : Math.min(5, Math.round((value - 35) / 40)))
|
|
11
|
+
|
|
12
|
+
const MISMATCH = 3 * 255 ** 2
|
|
13
|
+
|
|
14
|
+
function sgr(color: Rgb, layer: "fg" | "bg") {
|
|
15
|
+
const base = layer === "fg" ? 38 : 48
|
|
16
|
+
if (trueColor) return `${base};2;${color.join(";")}`
|
|
17
|
+
const [r, g, b] = color.map(toCube)
|
|
18
|
+
return `${base};5;${16 + 36 * r + 6 * g + b}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function pixel(key: string | undefined): Pixel {
|
|
22
|
+
if (!key || key === TRANSPARENT_PIXEL) return null
|
|
23
|
+
return rgb(LOGO_PALETTE[LOGO_KEYS.indexOf(key)])
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function distance(a: Pixel, b: Pixel) {
|
|
27
|
+
if (!a || !b) return a === b ? 0 : MISMATCH
|
|
28
|
+
return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function split(block: Pixel[]): [Pixel, Pixel] {
|
|
32
|
+
const colors = block.filter((color, i) => block.findIndex((other) => distance(other, color) === 0) === i)
|
|
33
|
+
if (colors.length <= 2) return [colors[0] ?? null, colors[1] ?? null]
|
|
34
|
+
let best: [Pixel, Pixel] = [colors[0], colors[1]]
|
|
35
|
+
let bestError = Number.POSITIVE_INFINITY
|
|
36
|
+
for (let i = 0; i < colors.length; i += 1) {
|
|
37
|
+
for (let j = i + 1; j < colors.length; j += 1) {
|
|
38
|
+
const error = block.reduce((total, color) => total + Math.min(distance(color, colors[i]), distance(color, colors[j])), 0)
|
|
39
|
+
if (error < bestError) [best, bestError] = [[colors[i], colors[j]], error]
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return best
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function dominant(block: Pixel[]): Pixel {
|
|
46
|
+
const counts = block.map((color) => block.filter((other) => distance(other, color) === 0).length)
|
|
47
|
+
return block[counts.indexOf(Math.max(...counts))]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function cell(block: Pixel[], glyphs: string[]) {
|
|
51
|
+
let [fg, bg] = split(block)
|
|
52
|
+
if (!fg) [fg, bg] = [bg, fg]
|
|
53
|
+
if (!fg) return " "
|
|
54
|
+
const mask = block.reduce((bits, color, i) => (distance(color, fg) <= distance(color, bg) ? bits | (1 << i) : bits), 0)
|
|
55
|
+
const codes = bg ? `${sgr(fg, "fg")};${sgr(bg, "bg")}` : sgr(fg, "fg")
|
|
56
|
+
return `\x1b[${codes}m${glyphs[mask]}\x1b[0m`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function supportsOctants() {
|
|
60
|
+
const terminal = `${process.env.TERM_PROGRAM ?? ""} ${process.env.TERM ?? ""}`.toLowerCase()
|
|
61
|
+
return OCTANT_TERMINALS.some((name) => terminal.includes(name))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function region(x: number, y: number, width: number, height: number): Pixel[] {
|
|
65
|
+
const block: Pixel[] = []
|
|
66
|
+
for (let dy = 0; dy < height; dy += 1) for (let dx = 0; dx < width; dx += 1) block.push(pixel(LOGO_PIXELS[y + dy]?.[x + dx]))
|
|
67
|
+
return block
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function logoLines(): string[] {
|
|
71
|
+
if (!useColor) return []
|
|
72
|
+
const octants = supportsOctants()
|
|
73
|
+
const lines: string[] = []
|
|
74
|
+
for (let y = 0; y < LOGO_PIXELS.length; y += 4) {
|
|
75
|
+
let row = ""
|
|
76
|
+
for (let x = 0; x < LOGO_PIXELS[0].length; x += 2) {
|
|
77
|
+
row += octants
|
|
78
|
+
? cell(region(x, y, 2, 4), OCTANTS)
|
|
79
|
+
: cell([dominant(region(x, y, 2, 2)), dominant(region(x, y + 2, 2, 2))], [...HALF_BLOCKS])
|
|
80
|
+
}
|
|
81
|
+
lines.push(row)
|
|
82
|
+
}
|
|
83
|
+
return lines
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function withLogo(text: string[]): string[] {
|
|
87
|
+
const logo = logoLines()
|
|
88
|
+
return [...logo.map((row) => ` ${row}`), ...(logo.length ? [""] : []), ...text.map((entry) => ` ${entry}`)]
|
|
89
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export const LOGO_KEYS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456"
|
|
2
|
+
|
|
3
|
+
export const LOGO_PALETTE = ["#fefefd", "#fdfcf1", "#fef7db", "#fef7da", "#fdf7dc", "#fef7d9", "#fdf6d9", "#fef6d6", "#fdf6d7", "#fdf4d5", "#fdf6cf", "#fef4ae", "#fde9a5", "#feec97", "#fde186", "#fddb6b", "#fdd763", "#fdd661", "#fdd55d", "#fcd259", "#fdcd49", "#fdc524", "#f6f5de", "#f5e6b1", "#d7ecc9", "#e2d596", "#e5b93b", "#7ce5d0", "#68dbd4", "#74cdd0", "#52d1cc", "#33c4c9", "#2bc0c7", "#2abcc5", "#29bbc4", "#27bcc5", "#20bccc", "#27bac3", "#24b7bc", "#12b3d0", "#0baccd", "#0fa7b4", "#7f7870", "#0678a5", "#038abb", "#04527e", "#018cd0", "#016b95", "#014b85", "#01417b", "#023d73", "#013a71", "#033b6a", "#023768", "#013367", "#092945", "#002d56", "#071a2c", "#041223"]
|
|
4
|
+
|
|
5
|
+
export const LOGO_PIXELS = [
|
|
6
|
+
"..............W4...........a",
|
|
7
|
+
".............Xutt..........U",
|
|
8
|
+
".........tt...kuut.......uu.",
|
|
9
|
+
"........tkr...1Yxt4tT....u..",
|
|
10
|
+
"...XXW..soq...X2Xtst0.......",
|
|
11
|
+
"..TqssW.xr6...0CJRXs4.......",
|
|
12
|
+
"..oCCCsW.srrr012S4R1s.1.....",
|
|
13
|
+
"..kCCPrsV.ttslRSRJCFEB0m....",
|
|
14
|
+
"...pCPPrqW4W43nJJJJJHCG0....",
|
|
15
|
+
"...koCPR0sppHEtEILIHHHGhTrq.",
|
|
16
|
+
"....oCOSSZSFFEFFIIIHHHhhkEp.",
|
|
17
|
+
"XXX..nyEVPByEEEGGIIIHHLbwLo.",
|
|
18
|
+
"YrrYYY04WMGFEEggdhIIHHH63Vx.",
|
|
19
|
+
".rqnnnxKKLwFifg66hhHHGJ6ujTe",
|
|
20
|
+
".ZrpoCNSLKkGfh3355fffB4ejmj.",
|
|
21
|
+
"..1qpCY1PMLFfg3553hhhh.ihgRw",
|
|
22
|
+
".....ZZ2POJGdd653AdcgQQhfe..",
|
|
23
|
+
".....ZZPZPJdxjj6vcddcAAffT..",
|
|
24
|
+
".CC.YpCE2VhGkjmmfdcddghdT..A",
|
|
25
|
+
"....Xpp1ZVRVxhhfgddfffTT..Ab",
|
|
26
|
+
".....1Z2pC02VxxkxxgnmzA....6",
|
|
27
|
+
".......qrp2ewVPMxxmmavvv6..A",
|
|
28
|
+
"..UU...YXY..PPFkEkkvvaaavA..",
|
|
29
|
+
".UUCU......XXPMCkkvalanxavvN",
|
|
30
|
+
".UCyUNN..1Yu4PMP055nlalxBavN",
|
|
31
|
+
"..UU.NN.1uD2PwJ0MP33nlmyBuav",
|
|
32
|
+
"QyDy3y..uo2RRwFPPPP6nnBBavaa",
|
|
33
|
+
"DQQQDyy.tB2RVPPPSR4poBBaaB6N",
|
|
34
|
+
]
|
package/src/cli/main.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { VERSION } from "../version"
|
|
3
|
+
import { wantsHelp } from "./args"
|
|
4
|
+
import { commands } from "./commands"
|
|
5
|
+
import { printCommandHelp, printHelp } from "./help"
|
|
6
|
+
import { closest, fail, line } from "./ui"
|
|
7
|
+
|
|
8
|
+
const [name, ...args] = process.argv.slice(2)
|
|
9
|
+
|
|
10
|
+
if (!name || name === "help" || name === "-h" || name === "--help") {
|
|
11
|
+
const topic = name === "help" ? args[0] : undefined
|
|
12
|
+
if (topic && commands[topic]) printCommandHelp(topic, commands[topic])
|
|
13
|
+
else printHelp(commands)
|
|
14
|
+
} else if (name === "-v" || name === "--version" || name === "version") {
|
|
15
|
+
line(VERSION)
|
|
16
|
+
} else if (!commands[name]) {
|
|
17
|
+
const suggestion = closest(name, Object.keys(commands))
|
|
18
|
+
fail(`Unknown command: ${name}`, suggestion ? `Did you mean \`eunoe ${suggestion}\`?` : "Run `eunoe --help` to see the commands.")
|
|
19
|
+
} else if (wantsHelp(args)) {
|
|
20
|
+
printCommandHelp(name, commands[name])
|
|
21
|
+
} else {
|
|
22
|
+
await commands[name].run(args)
|
|
23
|
+
}
|