@chroxify/eunoe 0.1.0 → 1.1.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 (80) hide show
  1. package/README.md +364 -239
  2. package/package.json +20 -3
  3. package/src/cli/{types.ts → command.ts} +1 -4
  4. package/src/cli/commands/index.ts +27 -3
  5. package/src/cli/commands/load.ts +44 -0
  6. package/src/cli/commands/mode.ts +68 -24
  7. package/src/cli/commands/peek.ts +22 -0
  8. package/src/cli/commands/replay.ts +194 -0
  9. package/src/cli/commands/serve.ts +24 -8
  10. package/src/cli/commands/sessions.ts +70 -0
  11. package/src/cli/commands/settings.ts +119 -0
  12. package/src/cli/commands/start.ts +73 -0
  13. package/src/cli/commands/status.ts +107 -30
  14. package/src/cli/commands/stop.ts +36 -12
  15. package/src/cli/commands/uninstall.ts +14 -14
  16. package/src/cli/commands/watch.ts +16 -0
  17. package/src/cli/logo/art.ts +99 -0
  18. package/src/cli/logo/index.ts +12 -18
  19. package/src/cli/main.ts +3 -3
  20. package/src/cli/proxy/claude-settings.ts +59 -0
  21. package/src/cli/proxy/inspect.ts +67 -0
  22. package/src/cli/proxy/launchd.ts +53 -0
  23. package/src/cli/proxy/service.ts +53 -0
  24. package/src/cli/proxy/stats.ts +81 -0
  25. package/src/cli/proxy/watch.ts +60 -0
  26. package/src/cli/replay/index.ts +141 -0
  27. package/src/cli/replay/worker.ts +15 -0
  28. package/src/cli/sessions/browse.ts +88 -0
  29. package/src/cli/sessions/index.ts +76 -0
  30. package/src/cli/sessions/source.ts +74 -0
  31. package/src/cli/{args.ts → terminal/args.ts} +1 -1
  32. package/src/cli/{help.ts → terminal/help.ts} +25 -9
  33. package/src/cli/terminal/prompt.ts +114 -0
  34. package/src/cli/terminal/ui.ts +137 -0
  35. package/src/config/index.ts +128 -14
  36. package/src/config/{constants.ts → paths.ts} +4 -14
  37. package/src/context/content.ts +51 -3
  38. package/src/context/fold/evidence.ts +145 -0
  39. package/src/context/fold/index.ts +65 -0
  40. package/src/context/fold/strategies.ts +94 -0
  41. package/src/context/fold/turns.ts +76 -0
  42. package/src/context/prompts.ts +141 -22
  43. package/src/context/recall/index.ts +214 -0
  44. package/src/context/recall/rerank.ts +71 -0
  45. package/src/proxy/canonical.ts +40 -0
  46. package/src/proxy/compaction.ts +122 -52
  47. package/src/proxy/estimate.ts +14 -9
  48. package/src/proxy/fingerprint.ts +29 -0
  49. package/src/proxy/handoff.ts +71 -0
  50. package/src/proxy/log.ts +2 -2
  51. package/src/proxy/recall.ts +58 -0
  52. package/src/proxy/rewrite.ts +152 -56
  53. package/src/proxy/server.ts +122 -47
  54. package/src/proxy/sessions.ts +64 -0
  55. package/src/proxy/state.ts +17 -3
  56. package/src/proxy/usage.ts +50 -34
  57. package/src/proxy/window.ts +32 -34
  58. package/src/transcript/format.ts +88 -25
  59. package/src/transcript/locate.ts +38 -17
  60. package/src/transcript/messages.ts +18 -0
  61. package/src/transcript/parse.ts +137 -56
  62. package/src/transcript/render.ts +44 -17
  63. package/src/transcript/search.ts +20 -56
  64. package/src/transcript/subagents.ts +150 -0
  65. package/src/cli/claude-settings.ts +0 -42
  66. package/src/cli/commands/install.ts +0 -44
  67. package/src/cli/constants.ts +0 -29
  68. package/src/cli/inspect.ts +0 -41
  69. package/src/cli/launchd.ts +0 -34
  70. package/src/cli/logo/pixels.ts +0 -34
  71. package/src/cli/ui.ts +0 -91
  72. package/src/config/types.ts +0 -21
  73. package/src/context/constants.ts +0 -7
  74. package/src/context/strategies.ts +0 -89
  75. package/src/context/turns.ts +0 -82
  76. package/src/context/types.ts +0 -24
  77. package/src/proxy/constants.ts +0 -19
  78. package/src/proxy/types.ts +0 -58
  79. package/src/transcript/constants.ts +0 -16
  80. package/src/transcript/types.ts +0 -43
@@ -1,26 +1,52 @@
1
1
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
2
2
  import path from "node:path"
3
- import { paths } from "../config/constants"
4
- import { INDEX_FILE, RENDER_STAMP, TURN_COUNT_FILE } from "./constants"
5
- import { renderIndex, renderMarkdown, renderXml, turnFile } from "./format"
6
- import { parseTurns } from "./parse"
7
- import type { RenderResult, TranscriptFormat } from "./types"
3
+ import { paths } from "../config/paths"
4
+ import { renderIndex, renderMarkdown, renderSubagentMarkdown, renderSubagentXml, renderXml, type SubagentRecord, subagentFile, turnFile } from "./format"
5
+ import { parseJsonl, parseTurns, type ToolPart, type TranscriptFormat, type Turn } from "./parse"
6
+ import { type SubagentRun, subagentEntries, subagentRuns } from "./subagents"
7
+
8
+ interface RenderResult {
9
+ dir: string
10
+ changed: boolean
11
+ turns: number
12
+ }
8
13
 
9
14
  export function transcriptDir(sessionId: string) {
10
15
  return path.join(paths.transcripts, sessionId)
11
16
  }
12
17
 
13
- export function renderTranscript(sessionFile: string, sessionId: string, outDir = transcriptDir(sessionId), format: TranscriptFormat = "markdown"): RenderResult {
14
- mkdirSync(outDir, { recursive: true })
15
- const stampFile = path.join(outDir, RENDER_STAMP)
16
- const mtime = String(statSync(sessionFile).mtimeMs)
17
- if (existsSync(stampFile) && readFileSync(stampFile, "utf8") === mtime) {
18
- return { dir: outDir, changed: false, turns: Number(readFileSync(path.join(outDir, TURN_COUNT_FILE), "utf8") || 0) }
18
+ function launchingPart(turns: Turn[], run: SubagentRun): { turn: Turn; part: ToolPart } | null {
19
+ for (const turn of turns) {
20
+ for (const part of turn.parts) {
21
+ if (part.kind === "tool" && (run.toolUseId ? part.id === run.toolUseId : part.prompt === run.prompt)) return { turn, part }
22
+ }
19
23
  }
20
- const entries = readFileSync(sessionFile, "utf8").split("\n").flatMap((line) => {
21
- try { return line ? [JSON.parse(line)] : [] } catch { return [] }
24
+ return null
25
+ }
26
+
27
+ function subagentRecords(turns: Turn[], runs: SubagentRun[], format: TranscriptFormat): SubagentRecord[] {
28
+ const perTurn = new Map<number, number>()
29
+ return runs.map((run) => {
30
+ const launcher = launchingPart(turns, run)
31
+ const parentTurn = launcher?.turn.number ?? 0
32
+ const k = (perTurn.get(parentTurn) ?? 0) + 1
33
+ perTurn.set(parentTurn, k)
34
+ const file = subagentFile(parentTurn, k, format)
35
+ if (launcher) launcher.part.record = file
36
+ return { run, turns: parseTurns(subagentEntries(run), "any"), parentTurn, file }
22
37
  })
23
- const turns = parseTurns(entries)
38
+ }
39
+
40
+ export function renderTranscript(sessionFile: string, sessionId: string, outDir = transcriptDir(sessionId), format: TranscriptFormat = "markdown"): RenderResult {
41
+ mkdirSync(outDir, { recursive: true })
42
+ const stampFile = path.join(outDir, ".rendered")
43
+ const countFile = path.join(outDir, ".turns")
44
+ const runs = subagentRuns(sessionFile)
45
+ const stamp = [statSync(sessionFile).mtimeMs, ...runs.map((run) => `${run.id}:${run.mtimeMs}`)].join("|")
46
+ if (existsSync(stampFile) && readFileSync(stampFile, "utf8") === stamp) return { dir: outDir, changed: false, turns: Number(readFileSync(countFile, "utf8") || 0) }
47
+
48
+ const turns = parseTurns(parseJsonl(readFileSync(sessionFile, "utf8")))
49
+ const records = subagentRecords(turns, runs, format)
24
50
  let changed = false
25
51
  const write = (file: string, content: string) => {
26
52
  const target = path.join(outDir, file)
@@ -29,8 +55,9 @@ export function renderTranscript(sessionFile: string, sessionId: string, outDir
29
55
  changed = true
30
56
  }
31
57
  for (const turn of turns) write(turnFile(turn, format), format === "xml" ? renderXml(turn) : renderMarkdown(turn))
32
- write(INDEX_FILE, renderIndex(turns, format))
33
- writeFileSync(stampFile, mtime)
34
- writeFileSync(path.join(outDir, TURN_COUNT_FILE), String(turns.length))
58
+ for (const record of records) write(record.file, format === "xml" ? renderSubagentXml(record) : renderSubagentMarkdown(record))
59
+ write("index.md", renderIndex(turns, format, records))
60
+ writeFileSync(stampFile, stamp)
61
+ writeFileSync(countFile, String(turns.length))
35
62
  return { dir: outDir, changed, turns: turns.length }
36
63
  }
@@ -1,9 +1,13 @@
1
1
  import { existsSync } from "node:fs"
2
2
  import path from "node:path"
3
- import type { SearchMode } from "../config/types"
4
- import { QMD_DIR } from "./constants"
3
+ import type { SearchMode } from "../config"
5
4
  import { renderTranscript, transcriptDir } from "./render"
6
- import type { TranscriptRef } from "./types"
5
+
6
+ export interface TranscriptRef {
7
+ mode: SearchMode
8
+ jsonl: string
9
+ dir: string
10
+ }
7
11
 
8
12
  const indexing = new Map<string, Promise<void>>()
9
13
 
@@ -11,16 +15,19 @@ async function qmd(cwd: string, args: string[]) {
11
15
  await Bun.spawn(["qmd", ...args], { cwd, stdout: "ignore", stderr: "ignore" }).exited
12
16
  }
13
17
 
18
+ const isIndexed = (dir: string) => existsSync(path.join(dir, ".qmd"))
19
+
14
20
  function indexWithQmd(dir: string) {
15
- const previous = indexing.get(dir) ?? Promise.resolve()
16
- const next = previous.then(async () => {
17
- if (!existsSync(path.join(dir, QMD_DIR))) {
18
- await qmd(dir, ["init"])
19
- await qmd(dir, ["collection", "add", ".", "--name", "transcript"])
20
- }
21
- await qmd(dir, ["update"])
22
- await qmd(dir, ["embed"])
23
- }).catch(() => undefined)
21
+ const next = (indexing.get(dir) ?? Promise.resolve())
22
+ .then(async () => {
23
+ if (!isIndexed(dir)) {
24
+ await qmd(dir, ["init"])
25
+ await qmd(dir, ["collection", "add", ".", "--name", "transcript"])
26
+ }
27
+ await qmd(dir, ["update"])
28
+ await qmd(dir, ["embed"])
29
+ })
30
+ .catch(() => undefined)
24
31
  indexing.set(dir, next)
25
32
  return next
26
33
  }
@@ -30,51 +37,8 @@ export function prepareTranscript(mode: SearchMode, sessionFile: string, key: st
30
37
  if (mode !== "jsonl") {
31
38
  try {
32
39
  const { changed } = renderTranscript(sessionFile, key, dir, mode === "xml" ? "xml" : "markdown")
33
- if (mode === "qmd" && (changed || !existsSync(path.join(dir, QMD_DIR)))) void indexWithQmd(dir)
40
+ if (mode === "qmd" && (changed || !isIndexed(dir))) void indexWithQmd(dir)
34
41
  } catch {}
35
42
  }
36
43
  return { mode, jsonl: sessionFile, dir }
37
44
  }
38
-
39
- export function searchGuide(ref: TranscriptRef): string {
40
- if (ref.mode === "jsonl") {
41
- return [
42
- `The full transcript is Claude Code's session file: ${ref.jsonl}`,
43
- "It is JSONL, one entry per line. Your tool calls are `tool_use` blocks in `assistant` entries; their output is in the `tool_result` blocks of the `user` entries that follow. It is large: search it narrowly and never read it whole. For example:",
44
- ` grep -n 'distinctive text' ${ref.jsonl} | cut -c1-400`,
45
- ` jq -r 'select(.type=="assistant") | .message.content[]? | select(.type=="tool_use") | "\\(.name) \\(.input | tostring | .[0:200])"' ${ref.jsonl}`,
46
- ].join("\n")
47
- }
48
- const ext = ref.mode === "xml" ? "xml" : "md"
49
- const layout = ref.mode === "xml"
50
- ? [
51
- `The full transcript is a folder with one XML file per turn: ${ref.dir}`,
52
- "- `index.md`: one line per turn, what the user asked and how the turn ended. Skim it to find the turn you need.",
53
- "- `turn-0001.xml`, …: one turn each, `<turn n>` with `<user>` (the message), `<said>` (what you wrote while working), `<tool n name>` holding `<call>`, `<input>` and `<output>`, and `<final_reply>`.",
54
- ]
55
- : [
56
- `The full transcript is a folder of markdown files: ${ref.dir}`,
57
- "- `index.md`: one line per turn, what the user asked and how the turn ended. Skim it to find the turn you need.",
58
- "- `turn-0001.md`, …: one turn each. `## User` (the message), `### [tool 3/12] Bash: …` for every tool call with `[output of tool 3]` below it, and `## Final reply`.",
59
- ]
60
- const habits = "Search for more than one phrasing (the exact term, a synonym, a file or function name, an error string) before you conclude something isn't there. Read the whole turn file around a hit: the answer is often a few calls before or after it."
61
- if (ref.mode === "markdown" || ref.mode === "xml") {
62
- return [
63
- ...layout,
64
- "To find something:",
65
- ` grep -ril 'distinctive words' ${ref.dir} # which turns mention it`,
66
- ` grep -n -i 'distinctive words' ${ref.dir}/*.${ext} | cut -c1-300`,
67
- ` then Read ${ref.dir}/turn-NNNN.${ext}`,
68
- habits,
69
- ].join("\n")
70
- }
71
- return [
72
- ...layout,
73
- "It is indexed with qmd. Run qmd from inside that folder:",
74
- ` cd ${ref.dir} && qmd query "a natural-language question" # semantic + keyword, best when you don't know the exact words (5–20s)`,
75
- ` cd ${ref.dir} && qmd search "exact words" # keyword only, instant`,
76
- ` then Read ${ref.dir}/turn-NNNN.md (or qmd get turn-NNNN.md)`,
77
- "If qmd isn't ready yet (the index builds in the background), grep the folder instead.",
78
- habits,
79
- ].join("\n")
80
- }
@@ -0,0 +1,150 @@
1
+ import { readFileSync, statSync } from "node:fs"
2
+ import path from "node:path"
3
+ import { blocks, clip, contentBlocks, type Message } from "../context/content"
4
+ import { splitTurns } from "../context/fold/turns"
5
+ import { type RecallChunk, recallChunks } from "../context/recall"
6
+ import { listEntries } from "./locate"
7
+ import { sessionMessages } from "./messages"
8
+ import { entryMessages, parseJsonl, type SessionEntry } from "./parse"
9
+
10
+ export interface SubagentRun {
11
+ id: string
12
+ file: string
13
+ inline: boolean
14
+ description: string
15
+ toolUseId: string | null
16
+ prompt: string
17
+ started: string
18
+ mtimeMs: number
19
+ }
20
+
21
+ const INLINE_ID = "sidechain"
22
+
23
+ const entryCache = new Map<string, { mtimeMs: number; entries: SessionEntry[] }>()
24
+ const chunkCache = new Map<string, { mtimeMs: number; parentTurn: number; chunks: RecallChunk[] }>()
25
+
26
+ function mtimeOf(file: string): number | null {
27
+ try {
28
+ return statSync(file).mtimeMs
29
+ } catch {
30
+ return null
31
+ }
32
+ }
33
+
34
+ function readEntries(file: string): SessionEntry[] {
35
+ const mtimeMs = mtimeOf(file)
36
+ if (mtimeMs === null) return []
37
+ const hit = entryCache.get(file)
38
+ if (hit?.mtimeMs === mtimeMs) return hit.entries
39
+ const entries = parseJsonl(readFileSync(file, "utf8"))
40
+ entryCache.set(file, { mtimeMs, entries })
41
+ return entries
42
+ }
43
+
44
+ function firstPrompt(entries: SessionEntry[]): string {
45
+ for (const entry of entries) {
46
+ if (entry.type !== "user" || !entry.message) continue
47
+ const text = contentBlocks(entry.message.content)
48
+ .filter((block) => block.type === "text")
49
+ .map((block) => String(block.text ?? ""))
50
+ .join("\n")
51
+ .trim()
52
+ if (text) return text
53
+ }
54
+ return ""
55
+ }
56
+
57
+ function readMeta(file: string): { description?: string; toolUseId?: string } {
58
+ try {
59
+ return JSON.parse(readFileSync(file.replace(/\.jsonl$/, ".meta.json"), "utf8"))
60
+ } catch {
61
+ return {}
62
+ }
63
+ }
64
+
65
+ export function subagentsDir(sessionFile: string) {
66
+ return path.join(path.dirname(sessionFile), path.basename(sessionFile, ".jsonl"), "subagents")
67
+ }
68
+
69
+ function fileRuns(sessionFile: string): SubagentRun[] {
70
+ const dir = subagentsDir(sessionFile)
71
+ return listEntries(dir)
72
+ .filter((name) => name.startsWith("agent-") && name.endsWith(".jsonl"))
73
+ .map((name) => {
74
+ const file = path.join(dir, name)
75
+ const entries = readEntries(file)
76
+ const meta = readMeta(file)
77
+ const prompt = firstPrompt(entries)
78
+ return {
79
+ id: name.slice("agent-".length, -".jsonl".length),
80
+ file,
81
+ inline: false,
82
+ description: meta.description ? String(meta.description) : clip(prompt, 80),
83
+ toolUseId: meta.toolUseId ? String(meta.toolUseId) : null,
84
+ prompt,
85
+ started: String(entries[0]?.timestamp ?? ""),
86
+ mtimeMs: mtimeOf(file) ?? 0,
87
+ }
88
+ })
89
+ }
90
+
91
+ function inlineRuns(sessionFile: string, known: Set<string>): SubagentRun[] {
92
+ const byAgent = new Map<string, SessionEntry[]>()
93
+ for (const entry of readEntries(sessionFile)) {
94
+ if (!entry.isSidechain) continue
95
+ const id = entry.agentId ?? INLINE_ID
96
+ byAgent.set(id, [...(byAgent.get(id) ?? []), entry])
97
+ }
98
+ return [...byAgent]
99
+ .filter(([id]) => !known.has(id))
100
+ .map(([id, entries]) => {
101
+ const prompt = firstPrompt(entries)
102
+ return { id, file: sessionFile, inline: true, description: clip(prompt, 80), toolUseId: null, prompt, started: String(entries[0]?.timestamp ?? ""), mtimeMs: mtimeOf(sessionFile) ?? 0 }
103
+ })
104
+ }
105
+
106
+ export function subagentRuns(sessionFile: string): SubagentRun[] {
107
+ const files = fileRuns(sessionFile)
108
+ const inline = inlineRuns(sessionFile, new Set(files.map((run) => run.id)))
109
+ return [...files, ...inline].sort((a, b) => a.started.localeCompare(b.started))
110
+ }
111
+
112
+ export function subagentEntries(run: SubagentRun): SessionEntry[] {
113
+ const entries = readEntries(run.file)
114
+ if (!run.inline) return entries
115
+ return entries.filter((entry) => entry.isSidechain && (entry.agentId ?? INLINE_ID) === run.id)
116
+ }
117
+
118
+ export function subagentMessages(run: SubagentRun): Message[] {
119
+ return entryMessages(subagentEntries(run))
120
+ }
121
+
122
+ function launchedBy(run: SubagentRun, call: { id?: unknown; name?: unknown; prompt?: unknown }): boolean {
123
+ if (run.toolUseId) return call.id === run.toolUseId
124
+ return (call.name === "Agent" || call.name === "Task") && String(call.prompt ?? "") === run.prompt
125
+ }
126
+
127
+ export function parentTurnOf(run: SubagentRun, parentMessages: Message[]): number {
128
+ const { turns } = splitTurns(parentMessages)
129
+ const index = turns.findIndex((turn) =>
130
+ turn
131
+ .filter((message) => message.role === "assistant")
132
+ .flatMap(blocks)
133
+ .some((block) => block.type === "tool_use" && launchedBy(run, { id: block.id, name: block.name, prompt: block.input?.prompt })),
134
+ )
135
+ return index + 1
136
+ }
137
+
138
+ export function subagentChunks(sessionFile: string): RecallChunk[] {
139
+ const parent = sessionMessages(sessionFile)
140
+ return subagentRuns(sessionFile).flatMap((run) => {
141
+ const key = `${run.file}#${run.id}`
142
+ const parentTurn = parentTurnOf(run, parent)
143
+ const hit = chunkCache.get(key)
144
+ if (hit && hit.mtimeMs === run.mtimeMs && hit.parentTurn === parentTurn) return hit.chunks
145
+ const from = `subagent "${run.description}"`
146
+ const chunks = recallChunks([...subagentMessages(run), { role: "user", content: "" }], new Map()).map((chunk) => ({ ...chunk, from, turn: parentTurn }))
147
+ chunkCache.set(key, { mtimeMs: run.mtimeMs, parentTurn, chunks })
148
+ return chunks
149
+ })
150
+ }
@@ -1,42 +0,0 @@
1
- import { existsSync, readFileSync, writeFileSync } from "node:fs"
2
- import path from "node:path"
3
- import type { Settings } from "./types"
4
-
5
- export const proxyUrl = (port: number) => `http://127.0.0.1:${port}`
6
-
7
- export function withEunoe(settings: Settings, port: number, mode: string): Settings {
8
- const next = { ...settings, env: { ...settings.env, ANTHROPIC_BASE_URL: proxyUrl(port) } }
9
- return withCompactionOwner(next, mode)
10
- }
11
-
12
- export function withoutEunoe(settings: Settings, port: number): Settings {
13
- const next = { ...settings }
14
- if (next.env?.ANTHROPIC_BASE_URL === proxyUrl(port)) {
15
- const { ANTHROPIC_BASE_URL: _, ...env } = next.env
16
- if (Object.keys(env).length > 0) next.env = env
17
- else delete next.env
18
- }
19
- delete next.autoCompactEnabled
20
- return next
21
- }
22
-
23
- export function withCompactionOwner(settings: Settings, mode: string): Settings {
24
- const next = { ...settings }
25
- if (mode === "off") delete next.autoCompactEnabled
26
- else next.autoCompactEnabled = false
27
- return next
28
- }
29
-
30
- export function editSettings(dirs: string[], edit: (settings: Settings) => Settings): string[] {
31
- const touched: string[] = []
32
- for (const dir of dirs) {
33
- if (!existsSync(dir)) continue
34
- const file = path.join(dir, "settings.json")
35
- const current: Settings = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {}
36
- const next = edit(current)
37
- if (JSON.stringify(next) === JSON.stringify(current)) continue
38
- writeFileSync(file, JSON.stringify(next, null, 2) + "\n")
39
- touched.push(file)
40
- }
41
- return touched
42
- }
@@ -1,44 +0,0 @@
1
- import { existsSync } from "node:fs"
2
- import path from "node:path"
3
- import { claudeConfigDirs, loadConfig, saveConfig } from "../../config"
4
- import { paths } from "../../config/constants"
5
- import { flagValues } from "../args"
6
- import { editSettings, proxyUrl, withEunoe } from "../claude-settings"
7
- import { MODE_DESCRIPTIONS } from "../constants"
8
- import { routedDirs } from "../inspect"
9
- import * as launchd from "../launchd"
10
- import type { Command } from "../types"
11
- import { c, fail, line, listFiles, note, ok, rows, tilde, warn } from "../ui"
12
-
13
- export const install: Command = {
14
- summary: "Route Claude Code through eunoe and keep it running",
15
- usage: "eunoe install [--config-dir <dir>]",
16
- details: [
17
- "Starts the proxy in the background and points Claude Code at it by setting",
18
- "ANTHROPIC_BASE_URL in settings.json. New sessions pick it up; restart running ones.",
19
- ],
20
- options: [["--config-dir <dir>", "Also manage this Claude config directory (repeatable, remembered)"]],
21
- run(args) {
22
- const added = flagValues(args, "--config-dir").map((dir) => path.resolve(dir))
23
- for (const dir of added) if (!existsSync(dir)) fail(`No such directory: ${tilde(dir)}`)
24
- if (added.length) saveConfig({ claudeConfigDirs: [...new Set([...loadConfig().claudeConfigDirs, ...added])] })
25
- const config = loadConfig()
26
-
27
- line()
28
- if (launchd.supported) {
29
- launchd.installAgent([process.execPath, path.join(import.meta.dir, "..", "main.ts"), "serve"], paths.proxyLog)
30
- ok(`Proxy running in the background on ${c.cyan(proxyUrl(config.port))}`)
31
- } else {
32
- warn("No launchd here, so keep the proxy running yourself:")
33
- line(` ${c.cyan("eunoe serve")}`)
34
- }
35
- const touched = editSettings(claudeConfigDirs(config), (settings) => withEunoe(settings, config.port, config.mode))
36
- ok("Claude Code routed through eunoe")
37
- listFiles(touched.length ? touched : routedDirs(config).map((dir) => path.join(dir, "settings.json")))
38
- line()
39
- rows([["Mode", `${c.bold(config.mode)} ${c.dim(MODE_DESCRIPTIONS[config.mode])}`]])
40
- line()
41
- note(`New sessions go through eunoe. Restart any that are already running.`)
42
- line()
43
- },
44
- }
@@ -1,29 +0,0 @@
1
- import { homedir } from "node:os"
2
- import path from "node:path"
3
- import type { Mode } 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
- 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",
12
- }
13
-
14
- export const GLOBAL_OPTIONS: OptionRow[] = [
15
- ["-h, --help", "Show help for a command"],
16
- ["-v, --version", "Show the version"],
17
- ]
18
-
19
- export const LAUNCHD_LABEL = "com.chroxify.eunoe"
20
- export const LAUNCH_AGENT_PLIST = path.join(homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`)
21
-
22
- export const PROBE_TIMEOUT_MS = 800
23
- export const RECENT_REQUESTS = 200
24
- export const TYPO_DISTANCE = 2
25
-
26
- export const TRANSPARENT_PIXEL = "."
27
- export const HALF_BLOCKS = " ▀▄█"
28
- export const OCTANTS = [..." 𜺨𜺫🮂𜴀▘𜴁𜴂𜴃𜴄▝𜴅𜴆𜴇𜴈▀𜴉𜴊𜴋𜴌🯦𜴍𜴎𜴏𜴐𜴑𜴒𜴓𜴔𜴕𜴖𜴗𜴘𜴙𜴚𜴛𜴜𜴝𜴞𜴟🯧𜴠𜴡𜴢𜴣𜴤𜴥𜴦𜴧𜴨𜴩𜴪𜴫𜴬𜴭𜴮𜴯𜴰𜴱𜴲𜴳𜴴𜴵🮅𜺣𜴶𜴷𜴸𜴹𜴺𜴻𜴼𜴽𜴾𜴿𜵀𜵁𜵂𜵃𜵄▖𜵅𜵆𜵇𜵈▌𜵉𜵊𜵋𜵌▞𜵍𜵎𜵏𜵐▛𜵑𜵒𜵓𜵔𜵕𜵖𜵗𜵘𜵙𜵚𜵛𜵜𜵝𜵞𜵟𜵠𜵡𜵢𜵣𜵤𜵥𜵦𜵧𜵨𜵩𜵪𜵫𜵬𜵭𜵮𜵯𜵰𜺠𜵱𜵲𜵳𜵴𜵵𜵶𜵷𜵸𜵹𜵺𜵻𜵼𜵽𜵾𜵿𜶀𜶁𜶂𜶃𜶄𜶅𜶆𜶇𜶈𜶉𜶊𜶋𜶌𜶍𜶎𜶏▗𜶐𜶑𜶒𜶓▚𜶔𜶕𜶖𜶗▐𜶘𜶙𜶚𜶛▜𜶜𜶝𜶞𜶟𜶠𜶡𜶢𜶣𜶤𜶥𜶦𜶧𜶨𜶩𜶪𜶫▂𜶬𜶭𜶮𜶯𜶰𜶱𜶲𜶳𜶴𜶵𜶶𜶷𜶸𜶹𜶺𜶻𜶼𜶽𜶾𜶿𜷀𜷁𜷂𜷃𜷄𜷅𜷆𜷇𜷈𜷉𜷊𜷋𜷌𜷍𜷎𜷏𜷐𜷑𜷒𜷓𜷔𜷕𜷖𜷗𜷘𜷙𜷚▄𜷛𜷜𜷝𜷞▙𜷟𜷠𜷡𜷢▟𜷣▆𜷤𜷥█"]
29
- export const OCTANT_TERMINALS = ["ghostty"]
@@ -1,41 +0,0 @@
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
- }
@@ -1,34 +0,0 @@
1
- import { existsSync, mkdirSync, 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
- writeFileSync(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
- Bun.spawnSync(["launchctl", "unload", PLIST])
26
- Bun.spawnSync(["launchctl", "load", PLIST])
27
- }
28
-
29
- export function removeAgent() {
30
- if (!existsSync(PLIST)) return false
31
- Bun.spawnSync(["launchctl", "unload", PLIST])
32
- unlinkSync(PLIST)
33
- return true
34
- }
@@ -1,34 +0,0 @@
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/ui.ts DELETED
@@ -1,91 +0,0 @@
1
- import { homedir } from "node:os"
2
- import { TYPO_DISTANCE } from "./constants"
3
-
4
- const colorFor = (stream: NodeJS.WriteStream) => Boolean(stream.isTTY) && !process.env.NO_COLOR && process.env.TERM !== "dumb"
5
- export const useColor = colorFor(process.stdout)
6
- export const trueColor = /^(truecolor|24bit)$/i.test(process.env.COLORTERM ?? "")
7
- const useColorErr = colorFor(process.stderr)
8
-
9
- const paint = (open: number, close: number, enabled = useColor) => (text: string) => (enabled ? `\x1b[${open}m${text}\x1b[${close}m` : text)
10
-
11
- export const c = {
12
- bold: paint(1, 22),
13
- dim: paint(2, 22),
14
- italic: paint(3, 23),
15
- cyan: paint(36, 39),
16
- green: paint(32, 39),
17
- yellow: paint(33, 39),
18
- red: paint(31, 39),
19
- magenta: paint(35, 39),
20
- }
21
-
22
- const errRed = paint(31, 39, useColorErr)
23
- const errDim = paint(2, 22, useColorErr)
24
-
25
- export function line(text = "") {
26
- console.log(text)
27
- }
28
-
29
- export function ok(text: string) {
30
- console.log(`${c.green("✓")} ${text}`)
31
- }
32
-
33
- export function note(text: string) {
34
- console.log(`${c.dim("·")} ${text}`)
35
- }
36
-
37
- export function warn(text: string) {
38
- console.log(`${c.yellow("!")} ${text}`)
39
- }
40
-
41
- export function fail(text: string, hint?: string): never {
42
- console.error(`${errRed("✗")} ${text}`)
43
- if (hint) console.error(` ${errDim(hint)}`)
44
- process.exit(1)
45
- }
46
-
47
- export function listFiles(files: string[]) {
48
- for (const file of files) line(` ${c.dim(tilde(file))}`)
49
- }
50
-
51
- export function rows(entries: Array<[string, string]>, indent = 2) {
52
- const width = Math.max(...entries.map(([label]) => label.length))
53
- for (const [label, value] of entries) console.log(`${" ".repeat(indent)}${c.dim(label.padEnd(width))} ${value}`)
54
- }
55
-
56
- export function tilde(file: string) {
57
- const home = homedir()
58
- return file.startsWith(home) ? `~${file.slice(home.length)}` : file
59
- }
60
-
61
- export function tokens(count: number) {
62
- if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(count >= 10_000_000 ? 0 : 1)}M`
63
- if (count >= 1_000) return `${(count / 1_000).toFixed(count >= 10_000 ? 0 : 1)}k`
64
- return String(count)
65
- }
66
-
67
- export function ago(iso: string) {
68
- const seconds = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 1000))
69
- if (seconds < 60) return "just now"
70
- if (seconds < 3_600) return `${Math.round(seconds / 60)}m ago`
71
- if (seconds < 86_400) return `${Math.round(seconds / 3_600)}h ago`
72
- return `${Math.round(seconds / 86_400)}d ago`
73
- }
74
-
75
- export function closest(input: string, options: string[]) {
76
- const distance = (a: string, b: string) => {
77
- const row = Array.from({ length: b.length + 1 }, (_, i) => i)
78
- for (let i = 1; i <= a.length; i += 1) {
79
- let previous = row[0]
80
- row[0] = i
81
- for (let j = 1; j <= b.length; j += 1) {
82
- const current = row[j]
83
- row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1))
84
- previous = current
85
- }
86
- }
87
- return row[b.length]
88
- }
89
- const [best] = options.map((option) => [option, distance(input, option)] as const).sort((x, y) => x[1] - y[1])
90
- return best && best[1] <= TYPO_DISTANCE ? best[0] : null
91
- }