@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.
Files changed (43) hide show
  1. package/README.md +315 -236
  2. package/package.json +3 -2
  3. package/src/cli/args.ts +1 -1
  4. package/src/cli/commands/index.ts +16 -2
  5. package/src/cli/commands/mode.ts +53 -17
  6. package/src/cli/commands/serve.ts +2 -1
  7. package/src/cli/commands/settings.ts +100 -0
  8. package/src/cli/commands/start.ts +64 -0
  9. package/src/cli/commands/status.ts +50 -16
  10. package/src/cli/commands/stop.ts +33 -11
  11. package/src/cli/commands/uninstall.ts +11 -9
  12. package/src/cli/constants.ts +28 -5
  13. package/src/cli/help.ts +2 -2
  14. package/src/cli/launchd.ts +8 -3
  15. package/src/cli/prompt.ts +91 -0
  16. package/src/cli/service.ts +33 -0
  17. package/src/cli/sessions.ts +25 -0
  18. package/src/cli/stats.ts +73 -0
  19. package/src/cli/types.ts +1 -0
  20. package/src/cli/ui.ts +15 -0
  21. package/src/config/constants.ts +15 -3
  22. package/src/config/index.ts +55 -6
  23. package/src/config/types.ts +34 -5
  24. package/src/context/constants.ts +35 -0
  25. package/src/context/content.ts +23 -1
  26. package/src/context/evidence.ts +92 -0
  27. package/src/context/recall.ts +155 -0
  28. package/src/context/rerank.ts +44 -0
  29. package/src/context/strategies.ts +6 -6
  30. package/src/context/turns.ts +19 -14
  31. package/src/context/types.ts +43 -1
  32. package/src/proxy/canonical.ts +32 -0
  33. package/src/proxy/compaction.ts +29 -12
  34. package/src/proxy/constants.ts +10 -0
  35. package/src/proxy/fingerprint.ts +15 -0
  36. package/src/proxy/rewrite.ts +84 -10
  37. package/src/proxy/server.ts +30 -12
  38. package/src/proxy/sessions.ts +46 -0
  39. package/src/proxy/types.ts +24 -0
  40. package/src/transcript/constants.ts +0 -1
  41. package/src/transcript/locate.ts +1 -1
  42. package/src/transcript/messages.ts +29 -0
  43. 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": "0.1.0",
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`, `eunoe install ${flag} <dir>`)
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
- export const commands: Record<string, Command> = { install, uninstall, stop, mode, status, serve }
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
+ }
@@ -1,30 +1,66 @@
1
- import { claudeConfigDirs, loadConfig, saveConfig } from "../../config"
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 * as launchd from "../launchd"
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: "Show or switch how eunoe manages context",
11
- usage: "eunoe mode [compact | trim | off]",
12
- details: ["Takes effect on the next request; no restart needed."],
13
- run(args) {
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 next = args[0] as Mode | undefined
16
- if (!next) {
17
- line()
18
- for (const [name, description] of Object.entries(MODE_DESCRIPTIONS) as Array<[Mode, string]>) {
19
- const current = name === config.mode
20
- line(` ${current ? c.green("●") : c.dim("○")} ${current ? c.bold(name.padEnd(8)) : name.padEnd(8)} ${c.dim(description)}`)
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
- if (!(next in MODE_DESCRIPTIONS)) fail(`Unknown mode: ${next}`, "Choose compact, trim or off.")
26
- saveConfig({ mode: next })
27
- if (launchd.isInstalled()) editSettings(claudeConfigDirs(loadConfig()), (settings) => withCompactionOwner(settings, next))
28
- ok(`Mode ${c.bold(next)} ${c.dim(MODE_DESCRIPTIONS[next])}`)
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 install` runs this for you in the background."],
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 ? `${c.green("●")} running on ${c.cyan(proxyUrl(config.port))}` : `${c.red("○")} not running`],
25
- ["Claude Code", routed.length ? `${c.green("●")} routed through eunoe` : `${c.dim("○")} not routed ${c.dim("(eunoe install)")}`],
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
- ["Search", `${config.search} transcripts`],
28
- ["Background", launchd.isInstalled() ? "launchd agent" : c.dim("none")],
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
- if (recent.length) {
33
- const sum = (key: string) => recent.reduce((total, entry) => total + (entry[key] ?? 0), 0)
34
- const compactions = recent.filter((entry) => entry.compacted)
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(`Last ${recent.length} requests`)}`)
68
+ line(` ${c.dim("Last 24h")}`)
37
69
  rows([
38
- ["Cache", `${c.bold(tokens(sum("cacheRead")))} read · ${tokens(sum("cacheWrite"))} written · ${tokens(sum("uncached"))} uncached`],
39
- ["Cuts", compactions.length ? `${compactions.length}, last ${ago(compactions.at(-1)!.ts)}` : c.dim("none yet")],
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.`)
@@ -1,16 +1,38 @@
1
- import { loadConfig } from "../../config"
2
- import { routedDirs } from "../inspect"
3
- import * as launchd from "../launchd"
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 { fail, note, ok } from "../ui"
5
+ import { line, listFiles, note, ok } from "../ui"
6
6
 
7
7
  export const stop: Command = {
8
- summary: "Stop the background proxy",
9
- usage: "eunoe stop",
10
- details: ["Sessions still pointed at the proxy will fail until restarted, so run `eunoe uninstall` first."],
11
- run() {
12
- if (routedDirs(loadConfig()).length) fail("Claude Code is still routed through eunoe", "Run `eunoe uninstall` first, then restart your sessions.")
13
- if (launchd.removeAgent()) ok("Background proxy stopped")
14
- else note("The proxy isn't running as a background service.")
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, saveConfig } from "../../config"
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: "Send new sessions straight to the API again",
8
+ summary: "Stop eunoe and remove the background proxy",
8
9
  usage: "eunoe uninstall",
9
10
  details: [
10
- "Removes eunoe from Claude Code's settings and hands compaction back to it.",
11
- "The proxy keeps running as a passthrough, so sessions already pointed at it",
12
- "keep working. Run `eunoe stop` once they have been restarted.",
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
- note("The proxy stays up as a passthrough for sessions still using it.")
22
- line(` ${c.dim("Once they're restarted:")} ${c.cyan("eunoe stop")}`)
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
  }
@@ -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
- compact: "Full context until the window is nearly full, then eunoe's cut",
10
- trim: "Every finished turn kept as your message and the final reply",
11
- off: "Passthrough. Claude Code compacts on its own",
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 = 200
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
 
@@ -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
- writeFileSync(PLIST, `<?xml version="1.0" encoding="UTF-8"?>
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
  }