@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.
- package/README.md +364 -239
- package/package.json +20 -3
- package/src/cli/{types.ts → command.ts} +1 -4
- package/src/cli/commands/index.ts +27 -3
- package/src/cli/commands/load.ts +44 -0
- package/src/cli/commands/mode.ts +68 -24
- package/src/cli/commands/peek.ts +22 -0
- package/src/cli/commands/replay.ts +194 -0
- package/src/cli/commands/serve.ts +24 -8
- package/src/cli/commands/sessions.ts +70 -0
- package/src/cli/commands/settings.ts +119 -0
- package/src/cli/commands/start.ts +73 -0
- package/src/cli/commands/status.ts +107 -30
- package/src/cli/commands/stop.ts +36 -12
- package/src/cli/commands/uninstall.ts +14 -14
- package/src/cli/commands/watch.ts +16 -0
- package/src/cli/logo/art.ts +99 -0
- package/src/cli/logo/index.ts +12 -18
- package/src/cli/main.ts +3 -3
- package/src/cli/proxy/claude-settings.ts +59 -0
- package/src/cli/proxy/inspect.ts +67 -0
- package/src/cli/proxy/launchd.ts +53 -0
- package/src/cli/proxy/service.ts +53 -0
- package/src/cli/proxy/stats.ts +81 -0
- package/src/cli/proxy/watch.ts +60 -0
- package/src/cli/replay/index.ts +141 -0
- package/src/cli/replay/worker.ts +15 -0
- package/src/cli/sessions/browse.ts +88 -0
- package/src/cli/sessions/index.ts +76 -0
- package/src/cli/sessions/source.ts +74 -0
- package/src/cli/{args.ts → terminal/args.ts} +1 -1
- package/src/cli/{help.ts → terminal/help.ts} +25 -9
- package/src/cli/terminal/prompt.ts +114 -0
- package/src/cli/terminal/ui.ts +137 -0
- package/src/config/index.ts +128 -14
- package/src/config/{constants.ts → paths.ts} +4 -14
- package/src/context/content.ts +51 -3
- package/src/context/fold/evidence.ts +145 -0
- package/src/context/fold/index.ts +65 -0
- package/src/context/fold/strategies.ts +94 -0
- package/src/context/fold/turns.ts +76 -0
- package/src/context/prompts.ts +141 -22
- package/src/context/recall/index.ts +214 -0
- package/src/context/recall/rerank.ts +71 -0
- package/src/proxy/canonical.ts +40 -0
- package/src/proxy/compaction.ts +122 -52
- package/src/proxy/estimate.ts +14 -9
- package/src/proxy/fingerprint.ts +29 -0
- package/src/proxy/handoff.ts +71 -0
- package/src/proxy/log.ts +2 -2
- package/src/proxy/recall.ts +58 -0
- package/src/proxy/rewrite.ts +152 -56
- package/src/proxy/server.ts +122 -47
- package/src/proxy/sessions.ts +64 -0
- package/src/proxy/state.ts +17 -3
- package/src/proxy/usage.ts +50 -34
- package/src/proxy/window.ts +32 -34
- package/src/transcript/format.ts +88 -25
- package/src/transcript/locate.ts +38 -17
- package/src/transcript/messages.ts +18 -0
- package/src/transcript/parse.ts +137 -56
- package/src/transcript/render.ts +44 -17
- package/src/transcript/search.ts +20 -56
- package/src/transcript/subagents.ts +150 -0
- package/src/cli/claude-settings.ts +0 -42
- package/src/cli/commands/install.ts +0 -44
- package/src/cli/constants.ts +0 -29
- package/src/cli/inspect.ts +0 -41
- package/src/cli/launchd.ts +0 -34
- package/src/cli/logo/pixels.ts +0 -34
- package/src/cli/ui.ts +0 -91
- package/src/config/types.ts +0 -21
- package/src/context/constants.ts +0 -7
- package/src/context/strategies.ts +0 -89
- package/src/context/turns.ts +0 -82
- package/src/context/types.ts +0 -24
- package/src/proxy/constants.ts +0 -19
- package/src/proxy/types.ts +0 -58
- package/src/transcript/constants.ts +0 -16
- package/src/transcript/types.ts +0 -43
package/src/transcript/render.ts
CHANGED
|
@@ -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/
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
writeFileSync(
|
|
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
|
}
|
package/src/transcript/search.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { existsSync } from "node:fs"
|
|
2
2
|
import path from "node:path"
|
|
3
|
-
import type { SearchMode } from "../config
|
|
4
|
-
import { QMD_DIR } from "./constants"
|
|
3
|
+
import type { SearchMode } from "../config"
|
|
5
4
|
import { renderTranscript, transcriptDir } from "./render"
|
|
6
|
-
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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 || !
|
|
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
|
-
}
|
package/src/cli/constants.ts
DELETED
|
@@ -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"]
|
package/src/cli/inspect.ts
DELETED
|
@@ -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
|
-
}
|
package/src/cli/launchd.ts
DELETED
|
@@ -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
|
-
}
|
package/src/cli/logo/pixels.ts
DELETED
|
@@ -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
|
-
}
|