@chroxify/eunoe 0.1.0 → 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/README.md +315 -236
- package/package.json +3 -2
- package/src/cli/args.ts +1 -1
- package/src/cli/commands/index.ts +16 -2
- package/src/cli/commands/mode.ts +53 -17
- package/src/cli/commands/serve.ts +2 -1
- package/src/cli/commands/settings.ts +100 -0
- package/src/cli/commands/start.ts +64 -0
- package/src/cli/commands/status.ts +50 -16
- package/src/cli/commands/stop.ts +33 -11
- package/src/cli/commands/uninstall.ts +11 -9
- package/src/cli/constants.ts +28 -5
- package/src/cli/help.ts +2 -2
- package/src/cli/launchd.ts +8 -3
- 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 +1 -0
- package/src/cli/ui.ts +15 -0
- package/src/config/constants.ts +15 -3
- package/src/config/index.ts +55 -6
- package/src/config/types.ts +34 -5
- package/src/context/constants.ts +35 -0
- package/src/context/content.ts +23 -1
- package/src/context/evidence.ts +92 -0
- package/src/context/recall.ts +155 -0
- package/src/context/rerank.ts +44 -0
- package/src/context/strategies.ts +6 -6
- package/src/context/turns.ts +19 -14
- package/src/context/types.ts +43 -1
- package/src/proxy/canonical.ts +32 -0
- package/src/proxy/compaction.ts +29 -12
- package/src/proxy/constants.ts +10 -0
- package/src/proxy/fingerprint.ts +15 -0
- package/src/proxy/rewrite.ts +84 -10
- package/src/proxy/server.ts +30 -12
- package/src/proxy/sessions.ts +46 -0
- package/src/proxy/types.ts +24 -0
- package/src/transcript/constants.ts +0 -1
- package/src/transcript/locate.ts +1 -1
- package/src/transcript/messages.ts +29 -0
- package/src/cli/commands/install.ts +0 -44
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chroxify/eunoe",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Context management for Claude Code that never forgets what matters.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
],
|
|
19
19
|
"type": "module",
|
|
20
20
|
"bin": {
|
|
21
|
-
"eunoe": "src/cli/main.ts"
|
|
21
|
+
"eunoe": "src/cli/main.ts",
|
|
22
|
+
"ee": "src/cli/main.ts"
|
|
22
23
|
},
|
|
23
24
|
"files": [
|
|
24
25
|
"src",
|
package/src/cli/args.ts
CHANGED
|
@@ -5,7 +5,7 @@ export function flagValues(args: string[], flag: string): string[] {
|
|
|
5
5
|
for (let i = 0; i < args.length; i += 1) {
|
|
6
6
|
if (args[i] !== flag) continue
|
|
7
7
|
const value = args[i + 1]
|
|
8
|
-
if (!value || value.startsWith("-")) fail(`${flag} needs a value
|
|
8
|
+
if (!value || value.startsWith("-")) fail(`${flag} needs a value`)
|
|
9
9
|
values.push(value)
|
|
10
10
|
i += 1
|
|
11
11
|
}
|
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
import type { Command } from "../types"
|
|
2
|
-
import { install } from "./install"
|
|
3
2
|
import { mode } from "./mode"
|
|
4
3
|
import { serve } from "./serve"
|
|
4
|
+
import { settings } from "./settings"
|
|
5
|
+
import { start } from "./start"
|
|
5
6
|
import { status } from "./status"
|
|
6
7
|
import { stop } from "./stop"
|
|
7
8
|
import { uninstall } from "./uninstall"
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
const alias = (target: Command, prefix: string[] = []): Command => ({ ...target, hidden: true, run: (args) => target.run([...prefix, ...args]) })
|
|
11
|
+
|
|
12
|
+
export const commands: Record<string, Command> = {
|
|
13
|
+
start,
|
|
14
|
+
stop,
|
|
15
|
+
mode,
|
|
16
|
+
settings,
|
|
17
|
+
status,
|
|
18
|
+
uninstall,
|
|
19
|
+
serve,
|
|
20
|
+
enable: alias(start),
|
|
21
|
+
disable: alias(stop),
|
|
22
|
+
set: alias(settings, ["set"]),
|
|
23
|
+
}
|
package/src/cli/commands/mode.ts
CHANGED
|
@@ -1,30 +1,66 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { compactionOwner, loadConfig, saveConfig } from "../../config"
|
|
2
|
+
import { MODES } from "../../config/constants"
|
|
2
3
|
import type { Mode } from "../../config/types"
|
|
3
4
|
import { editSettings, withCompactionOwner } from "../claude-settings"
|
|
4
5
|
import { MODE_DESCRIPTIONS } from "../constants"
|
|
5
|
-
import
|
|
6
|
+
import { routedDirs } from "../inspect"
|
|
7
|
+
import { interactive, select } from "../prompt"
|
|
8
|
+
import { isKnown, sessionArgs, shortId } from "../sessions"
|
|
6
9
|
import type { Command } from "../types"
|
|
7
|
-
import { c, fail, line, ok } from "../ui"
|
|
10
|
+
import { c, fail, line, note, ok, warn } from "../ui"
|
|
11
|
+
|
|
12
|
+
function listModes(current: Mode) {
|
|
13
|
+
line()
|
|
14
|
+
for (const name of MODES) {
|
|
15
|
+
const active = name === current
|
|
16
|
+
line(` ${active ? c.green("●") : c.dim("○")} ${active ? c.bold(name.padEnd(8)) : name.padEnd(8)} ${c.dim(MODE_DESCRIPTIONS[name])}`)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function applyMode(next: Mode, ids: string[]) {
|
|
21
|
+
const config = loadConfig()
|
|
22
|
+
if (ids.length) {
|
|
23
|
+
saveConfig({ sessions: { ...config.sessions, ...Object.fromEntries(ids.map((id) => [id, { ...config.sessions[id], mode: next }])) } })
|
|
24
|
+
ok(`Mode ${c.bold(next)} for ${ids.map(shortId).join(", ")} ${c.dim(MODE_DESCRIPTIONS[next])}`)
|
|
25
|
+
if (!routedDirs(config).length) warn("Claude Code isn't routed through eunoe. Run `eunoe start --session <id>`.")
|
|
26
|
+
const unseen = ids.filter((id) => !isKnown(id))
|
|
27
|
+
if (unseen.length) note(`Not seen through eunoe yet: ${unseen.map(shortId).join(", ")}. Check the id in Claude Code's /status.`)
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
saveConfig({ mode: next })
|
|
31
|
+
const updated = loadConfig()
|
|
32
|
+
editSettings(routedDirs(updated), (settings) => withCompactionOwner(settings, compactionOwner(updated)))
|
|
33
|
+
ok(`Mode ${c.bold(next)} ${c.dim(MODE_DESCRIPTIONS[next])}`)
|
|
34
|
+
}
|
|
8
35
|
|
|
9
36
|
export const mode: Command = {
|
|
10
|
-
summary: "
|
|
11
|
-
usage: "eunoe mode [
|
|
12
|
-
details: ["Takes effect on the next request; no restart needed."],
|
|
13
|
-
|
|
37
|
+
summary: "Pick how eunoe manages context",
|
|
38
|
+
usage: "eunoe mode [default | compact | off] [--session <id>]",
|
|
39
|
+
details: ["Without a mode it opens a picker. Takes effect on the next request; no restart needed."],
|
|
40
|
+
options: [["--session <id>", "Set the mode for just this session (repeatable; a unique prefix is enough)"]],
|
|
41
|
+
async run(args) {
|
|
14
42
|
const config = loadConfig()
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
43
|
+
const given = args.find((arg, i) => !arg.startsWith("-") && args[i - 1] !== "--session") as Mode | undefined
|
|
44
|
+
const ids = sessionArgs(args)
|
|
45
|
+
if (given) {
|
|
46
|
+
if (!MODES.includes(given)) fail(`Unknown mode: ${given}`, `Choose ${MODES.join(", ")}.`)
|
|
47
|
+
applyMode(given, ids)
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
const current = ids.length === 1 ? config.sessions[ids[0]]?.mode ?? config.mode : config.mode
|
|
51
|
+
if (!interactive) {
|
|
52
|
+
listModes(current)
|
|
53
|
+
const own = Object.entries(config.sessions).filter(([, settings]) => settings.mode)
|
|
54
|
+
if (own.length) {
|
|
55
|
+
line()
|
|
56
|
+
for (const [id, settings] of own) line(` ${c.dim(shortId(id))} ${settings.mode}`)
|
|
21
57
|
}
|
|
22
58
|
line()
|
|
23
59
|
return
|
|
24
60
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if (
|
|
28
|
-
|
|
61
|
+
line()
|
|
62
|
+
const chosen = await select(ids.length ? `Mode for ${ids.map(shortId).join(", ")}` : "Mode", MODES.map((value) => ({ value, hint: MODE_DESCRIPTIONS[value] })), current)
|
|
63
|
+
if (chosen === null) return
|
|
64
|
+
applyMode(chosen, ids)
|
|
29
65
|
},
|
|
30
66
|
}
|
|
@@ -9,7 +9,8 @@ import { c, fail, line } from "../ui"
|
|
|
9
9
|
export const serve: Command = {
|
|
10
10
|
summary: "Run the proxy in the foreground",
|
|
11
11
|
usage: "eunoe serve",
|
|
12
|
-
details: ["`eunoe
|
|
12
|
+
details: ["`eunoe start` runs this for you in the background. Use it directly where there is no launchd."],
|
|
13
|
+
hidden: true,
|
|
13
14
|
async run() {
|
|
14
15
|
const config = loadConfig()
|
|
15
16
|
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.")
|
|
@@ -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
|
+
}
|
|
@@ -1,44 +1,78 @@
|
|
|
1
1
|
import path from "node:path"
|
|
2
|
-
import { loadConfig } from "../../config"
|
|
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"
|
|
3
6
|
import { VERSION } from "../../version"
|
|
4
7
|
import { proxyUrl } from "../claude-settings"
|
|
5
|
-
import { MODE_DESCRIPTIONS } from "../constants"
|
|
8
|
+
import { MODE_DESCRIPTIONS, SESSIONS_SHOWN } from "../constants"
|
|
6
9
|
import { probe, recentRequests, routedDirs } from "../inspect"
|
|
7
10
|
import * as launchd from "../launchd"
|
|
11
|
+
import { shortId } from "../sessions"
|
|
12
|
+
import { perSession, savedShare, totals, withinWindow } from "../stats"
|
|
8
13
|
import type { Command } from "../types"
|
|
9
|
-
import { ago, c, line, rows, tilde, tokens, warn } from "../ui"
|
|
14
|
+
import { ago, c, line, percent, rows, table, tilde, tokens, warn } from "../ui"
|
|
15
|
+
|
|
16
|
+
const dot = (on: boolean) => (on ? c.green("●") : c.red("○"))
|
|
10
17
|
|
|
11
18
|
export const status: Command = {
|
|
12
|
-
summary: "Show what eunoe is doing",
|
|
19
|
+
summary: "Show what eunoe is doing and saving",
|
|
13
20
|
usage: "eunoe status",
|
|
14
21
|
async run() {
|
|
15
22
|
const config = loadConfig()
|
|
16
23
|
const health = await probe(config.port)
|
|
17
24
|
const routed = routedDirs(config)
|
|
18
25
|
const recent = recentRequests()
|
|
26
|
+
const today = withinWindow(recent)
|
|
27
|
+
const stats = perSession(recent)
|
|
28
|
+
const sum = totals(today)
|
|
19
29
|
|
|
20
30
|
line()
|
|
21
31
|
line(` ${c.bold("eunoe")} ${c.dim(VERSION)}`)
|
|
22
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)`)}`
|
|
23
35
|
rows([
|
|
24
|
-
["Proxy", health ? `${
|
|
25
|
-
["Claude Code", routed.length ? `${c.
|
|
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)")}`],
|
|
26
38
|
["Mode", `${c.bold(config.mode)} ${c.dim(MODE_DESCRIPTIONS[config.mode])}`],
|
|
27
|
-
["
|
|
28
|
-
["
|
|
39
|
+
["Sessions", config.scope === "all" ? "all" : `only the ones you started it for ${c.dim("(eunoe start --session <id>)")}`],
|
|
40
|
+
["Recall", recall],
|
|
29
41
|
])
|
|
30
|
-
for (const dir of routed) line(` ${" ".repeat(13)}${c.dim(tilde(path.join(dir, "settings.json")))}`)
|
|
31
42
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
35
67
|
line()
|
|
36
|
-
line(` ${c.dim(
|
|
68
|
+
line(` ${c.dim("Last 24h")}`)
|
|
37
69
|
rows([
|
|
38
|
-
["
|
|
39
|
-
["
|
|
40
|
-
|
|
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)
|
|
41
74
|
}
|
|
75
|
+
|
|
42
76
|
if (health && health.mode !== config.mode) {
|
|
43
77
|
line()
|
|
44
78
|
warn(`The running proxy reports mode ${health.mode}; it switches on its next request.`)
|
package/src/cli/commands/stop.ts
CHANGED
|
@@ -1,16 +1,38 @@
|
|
|
1
|
-
import { loadConfig } from "../../config"
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
1
|
+
import { claudeConfigDirs, loadConfig, saveConfig } from "../../config"
|
|
2
|
+
import { editSettings, withoutEunoe } from "../claude-settings"
|
|
3
|
+
import { sessionArgs, shortId } from "../sessions"
|
|
4
4
|
import type { Command } from "../types"
|
|
5
|
-
import {
|
|
5
|
+
import { line, listFiles, note, ok } from "../ui"
|
|
6
6
|
|
|
7
7
|
export const stop: Command = {
|
|
8
|
-
summary: "
|
|
9
|
-
usage: "eunoe stop",
|
|
10
|
-
details: [
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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()
|
|
15
37
|
},
|
|
16
38
|
}
|
|
@@ -1,25 +1,27 @@
|
|
|
1
|
-
import { claudeConfigDirs, loadConfig
|
|
1
|
+
import { claudeConfigDirs, loadConfig } from "../../config"
|
|
2
2
|
import { editSettings, withoutEunoe } from "../claude-settings"
|
|
3
|
+
import * as launchd from "../launchd"
|
|
3
4
|
import type { Command } from "../types"
|
|
4
|
-
import { c, line, listFiles, note, ok } from "../ui"
|
|
5
|
+
import { c, line, listFiles, note, ok, warn } from "../ui"
|
|
5
6
|
|
|
6
7
|
export const uninstall: Command = {
|
|
7
|
-
summary: "
|
|
8
|
+
summary: "Stop eunoe and remove the background proxy",
|
|
8
9
|
usage: "eunoe uninstall",
|
|
9
10
|
details: [
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
"
|
|
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.",
|
|
13
14
|
],
|
|
14
15
|
run() {
|
|
15
16
|
const config = loadConfig()
|
|
16
17
|
const touched = editSettings(claudeConfigDirs(config), (settings) => withoutEunoe(settings, config.port))
|
|
17
|
-
saveConfig({ mode: "off" })
|
|
18
18
|
line()
|
|
19
19
|
ok("Claude Code no longer routed through eunoe")
|
|
20
20
|
listFiles(touched)
|
|
21
|
-
|
|
22
|
-
|
|
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`)
|
|
23
25
|
line()
|
|
24
26
|
},
|
|
25
27
|
}
|
package/src/cli/constants.ts
CHANGED
|
@@ -1,14 +1,32 @@
|
|
|
1
1
|
import { homedir } from "node:os"
|
|
2
2
|
import path from "node:path"
|
|
3
|
-
import type { Mode } from "../config/types"
|
|
3
|
+
import type { Mode, Tunable } from "../config/types"
|
|
4
4
|
import type { OptionRow } from "./types"
|
|
5
5
|
|
|
6
6
|
export const TAGLINE = "Context management for Claude Code that never forgets what matters."
|
|
7
7
|
|
|
8
8
|
export const MODE_DESCRIPTIONS: Record<Mode, string> = {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
off: "Passthrough
|
|
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",
|
|
12
30
|
}
|
|
13
31
|
|
|
14
32
|
export const GLOBAL_OPTIONS: OptionRow[] = [
|
|
@@ -20,10 +38,15 @@ export const LAUNCHD_LABEL = "com.chroxify.eunoe"
|
|
|
20
38
|
export const LAUNCH_AGENT_PLIST = path.join(homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`)
|
|
21
39
|
|
|
22
40
|
export const PROBE_TIMEOUT_MS = 800
|
|
23
|
-
export const RECENT_REQUESTS =
|
|
41
|
+
export const RECENT_REQUESTS = 5_000
|
|
42
|
+
export const STATUS_WINDOW_MS = 24 * 3_600_000
|
|
24
43
|
export const TYPO_DISTANCE = 2
|
|
44
|
+
export const SHORT_ID_LENGTH = 8
|
|
45
|
+
export const SESSIONS_SHOWN = 5
|
|
25
46
|
|
|
26
47
|
export const TRANSPARENT_PIXEL = "."
|
|
27
48
|
export const HALF_BLOCKS = " ▀▄█"
|
|
28
49
|
export const OCTANTS = [..." 🮂▘▝▀🮅▖▌▞▛▗▚▐▜▂▄▙▟▆█"]
|
|
29
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
CHANGED
|
@@ -13,12 +13,12 @@ export function printHelp(commands: Record<string, Command>) {
|
|
|
13
13
|
line(` eunoe ${c.cyan("<command>")} ${c.dim("[options]")}`)
|
|
14
14
|
line()
|
|
15
15
|
line(` ${c.dim("Commands")}`)
|
|
16
|
-
for (const [name, command] of Object.entries(commands)) line(` ${c.cyan(name.padEnd(width))} ${command.summary}`)
|
|
16
|
+
for (const [name, command] of Object.entries(commands).filter(([, command]) => !command.hidden)) line(` ${c.cyan(name.padEnd(width))} ${command.summary}`)
|
|
17
17
|
line()
|
|
18
18
|
line(` ${c.dim("Options")}`)
|
|
19
19
|
for (const [flag, description] of GLOBAL_OPTIONS) line(` ${flag.padEnd(width)} ${description}`)
|
|
20
20
|
line()
|
|
21
|
-
line(` ${c.dim("Run")} eunoe ${c.cyan("<command>")} --help ${c.dim("for details.")}`)
|
|
21
|
+
line(` ${c.dim("Run")} eunoe ${c.cyan("<command>")} --help ${c.dim("for details.")} ${c.dim("`ee` works anywhere `eunoe` does.")}`)
|
|
22
22
|
line()
|
|
23
23
|
}
|
|
24
24
|
|
package/src/cli/launchd.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs"
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
|
|
2
2
|
import path from "node:path"
|
|
3
3
|
import { LAUNCH_AGENT_PLIST as PLIST, LAUNCHD_LABEL } from "./constants"
|
|
4
4
|
|
|
@@ -11,7 +11,7 @@ export function isInstalled() {
|
|
|
11
11
|
export function installAgent(program: string[], logFile: string) {
|
|
12
12
|
mkdirSync(path.dirname(PLIST), { recursive: true })
|
|
13
13
|
const args = program.map((arg) => `<string>${arg}</string>`).join("")
|
|
14
|
-
|
|
14
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
15
15
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
16
16
|
<plist version="1.0"><dict>
|
|
17
17
|
<key>Label</key><string>${LAUNCHD_LABEL}</string>
|
|
@@ -21,7 +21,12 @@ export function installAgent(program: string[], logFile: string) {
|
|
|
21
21
|
<key>StandardOutPath</key><string>${logFile}</string>
|
|
22
22
|
<key>StandardErrorPath</key><string>${logFile}</string>
|
|
23
23
|
</dict></plist>
|
|
24
|
-
`
|
|
24
|
+
`
|
|
25
|
+
if (existsSync(PLIST) && readFileSync(PLIST, "utf8") === plist) {
|
|
26
|
+
Bun.spawnSync(["launchctl", "load", PLIST])
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
writeFileSync(PLIST, plist)
|
|
25
30
|
Bun.spawnSync(["launchctl", "unload", PLIST])
|
|
26
31
|
Bun.spawnSync(["launchctl", "load", PLIST])
|
|
27
32
|
}
|