@chroxify/eunoe 0.0.0-stage → 0.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 (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +327 -2
  3. package/package.json +41 -4
  4. package/src/cli/args.ts +17 -0
  5. package/src/cli/claude-settings.ts +42 -0
  6. package/src/cli/commands/index.ts +9 -0
  7. package/src/cli/commands/install.ts +44 -0
  8. package/src/cli/commands/mode.ts +30 -0
  9. package/src/cli/commands/serve.ts +19 -0
  10. package/src/cli/commands/status.ts +48 -0
  11. package/src/cli/commands/stop.ts +16 -0
  12. package/src/cli/commands/uninstall.ts +25 -0
  13. package/src/cli/constants.ts +29 -0
  14. package/src/cli/help.ts +42 -0
  15. package/src/cli/inspect.ts +41 -0
  16. package/src/cli/launchd.ts +34 -0
  17. package/src/cli/logo/index.ts +89 -0
  18. package/src/cli/logo/pixels.ts +34 -0
  19. package/src/cli/main.ts +23 -0
  20. package/src/cli/types.ts +13 -0
  21. package/src/cli/ui.ts +91 -0
  22. package/src/config/constants.ts +27 -0
  23. package/src/config/index.ts +49 -0
  24. package/src/config/types.ts +21 -0
  25. package/src/context/constants.ts +7 -0
  26. package/src/context/content.ts +24 -0
  27. package/src/context/prompts.ts +66 -0
  28. package/src/context/strategies.ts +89 -0
  29. package/src/context/turns.ts +82 -0
  30. package/src/context/types.ts +24 -0
  31. package/src/proxy/compaction.ts +93 -0
  32. package/src/proxy/constants.ts +19 -0
  33. package/src/proxy/estimate.ts +26 -0
  34. package/src/proxy/log.ts +9 -0
  35. package/src/proxy/rewrite.ts +72 -0
  36. package/src/proxy/server.ts +120 -0
  37. package/src/proxy/state.ts +27 -0
  38. package/src/proxy/types.ts +58 -0
  39. package/src/proxy/usage.ts +44 -0
  40. package/src/proxy/window.ts +50 -0
  41. package/src/transcript/constants.ts +16 -0
  42. package/src/transcript/format.ts +68 -0
  43. package/src/transcript/locate.ts +28 -0
  44. package/src/transcript/parse.ts +93 -0
  45. package/src/transcript/render.ts +36 -0
  46. package/src/transcript/search.ts +80 -0
  47. package/src/transcript/types.ts +43 -0
  48. package/src/version.ts +3 -0
@@ -0,0 +1,50 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
2
+ import { EUNOE_DIR, paths } from "../config/constants"
3
+ import { ANTHROPIC_VERSION, MODEL_HEADERS, MODEL_LOOKUP_TIMEOUT_MS } from "./constants"
4
+
5
+ const CACHE = paths.models
6
+
7
+ function readCache(): Map<string, number> {
8
+ try {
9
+ return new Map(existsSync(CACHE) ? Object.entries(JSON.parse(readFileSync(CACHE, "utf8"))) : [])
10
+ } catch {
11
+ return new Map()
12
+ }
13
+ }
14
+
15
+ const windows = readCache()
16
+ const lookups = new Map<string, Promise<number | null>>()
17
+
18
+ function modelId(model: unknown) {
19
+ return String(model ?? "").replace(/\[[^\]]*\]$/, "")
20
+ }
21
+
22
+ export async function resolveWindow(model: unknown, requestHeaders: Headers, upstream: string): Promise<number | null> {
23
+ const id = modelId(model)
24
+ if (!id) return null
25
+ const known = windows.get(id)
26
+ if (known) return known
27
+ let lookup = lookups.get(id)
28
+ if (!lookup) {
29
+ const headers = new Headers()
30
+ for (const name of MODEL_HEADERS) {
31
+ const value = requestHeaders.get(name)
32
+ if (value) headers.set(name, value)
33
+ }
34
+ if (!headers.has("anthropic-version")) headers.set("anthropic-version", ANTHROPIC_VERSION)
35
+ lookup = fetch(`${upstream}/v1/models/${encodeURIComponent(id)}`, { headers, signal: AbortSignal.timeout(MODEL_LOOKUP_TIMEOUT_MS) })
36
+ .then(async (response) => {
37
+ if (!response.ok) return null
38
+ const info = await response.json() as { max_input_tokens?: number }
39
+ if (typeof info.max_input_tokens !== "number" || info.max_input_tokens <= 0) return null
40
+ windows.set(id, info.max_input_tokens)
41
+ mkdirSync(EUNOE_DIR, { recursive: true })
42
+ writeFileSync(CACHE, JSON.stringify(Object.fromEntries(windows), null, 2))
43
+ return info.max_input_tokens
44
+ })
45
+ .catch(() => null)
46
+ .finally(() => lookups.delete(id))
47
+ lookups.set(id, lookup)
48
+ }
49
+ return lookup
50
+ }
@@ -0,0 +1,16 @@
1
+ export const OUTPUT_CAP = 40_000
2
+
3
+ export const CALL_COMMAND_CHARS = 300
4
+ export const CALL_PATTERN_CHARS = 120
5
+ export const CALL_DESCRIPTION_CHARS = 160
6
+ export const CALL_INPUT_CHARS = 200
7
+
8
+ export const INDEX_PROMPT_CHARS = 140
9
+ export const INDEX_REPLY_CHARS = 160
10
+
11
+ export const RENDER_STAMP = ".rendered"
12
+ export const TURN_COUNT_FILE = ".turns"
13
+ export const INDEX_FILE = "index.md"
14
+ export const QMD_DIR = ".qmd"
15
+
16
+ export const SESSION_ID_PATTERN = /^[a-zA-Z0-9-]+$/
@@ -0,0 +1,68 @@
1
+ import { clip } from "../context/content"
2
+ import { INDEX_PROMPT_CHARS, INDEX_REPLY_CHARS } from "./constants"
3
+ import type { TranscriptFormat, Turn } from "./types"
4
+
5
+ export function turnFile(turn: Turn, format: TranscriptFormat) {
6
+ return `turn-${String(turn.number).padStart(4, "0")}.${format === "xml" ? "xml" : "md"}`
7
+ }
8
+
9
+ export function renderMarkdown(turn: Turn) {
10
+ const work = turn.parts.map((part) => part.kind === "say"
11
+ ? `> ${part.text.replace(/\n/g, "\n> ")}`
12
+ : [
13
+ `### [tool ${part.n}/${turn.calls}] ${part.name}: ${part.call}`,
14
+ part.body,
15
+ part.output ? `[output of tool ${part.n}${part.error ? ", error" : ""}]\n\`\`\`\n${part.output}\n\`\`\`` : `[tool ${part.n}: no output]`,
16
+ ].filter(Boolean).join("\n\n"))
17
+ return [
18
+ `# Turn ${turn.number}${turn.time ? ` · ${turn.time}` : ""}`,
19
+ "",
20
+ "## User",
21
+ "",
22
+ turn.prompt,
23
+ "",
24
+ `## Work (${turn.calls} tool call${turn.calls === 1 ? "" : "s"})`,
25
+ "",
26
+ work.join("\n\n") || "_No tool calls._",
27
+ "",
28
+ "## Final reply",
29
+ "",
30
+ turn.final || "_No final reply: the turn was interrupted or is still running._",
31
+ "",
32
+ ].join("\n")
33
+ }
34
+
35
+ function attr(value: string) {
36
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/\n/g, " ")
37
+ }
38
+
39
+ export function renderXml(turn: Turn) {
40
+ const work = turn.parts.map((part) => part.kind === "say"
41
+ ? `<said>\n${part.text}\n</said>`
42
+ : [
43
+ `<tool n="${part.n}" of="${turn.calls}" name="${attr(part.name)}">`,
44
+ `<call>${part.call.replace(/^`|`$/g, "")}</call>`,
45
+ part.body ? `<input>\n${part.body}\n</input>` : "",
46
+ part.output ? `<output${part.error ? ' error="true"' : ""}>\n${part.output}\n</output>` : "<output/>",
47
+ "</tool>",
48
+ ].filter(Boolean).join("\n"))
49
+ return [
50
+ `<turn n="${turn.number}"${turn.time ? ` time="${turn.time}"` : ""} tool_calls="${turn.calls}">`,
51
+ `<user>\n${turn.prompt}\n</user>`,
52
+ ...work,
53
+ turn.final ? `<final_reply>\n${turn.final}\n</final_reply>` : `<final_reply interrupted="true"/>`,
54
+ "</turn>",
55
+ "",
56
+ ].join("\n")
57
+ }
58
+
59
+ export function renderIndex(turns: Turn[], format: TranscriptFormat) {
60
+ return [
61
+ "# Session index",
62
+ "",
63
+ `One line per turn: what the user asked → how the turn ended. Open turn-NNNN.${format === "xml" ? "xml" : "md"} for everything that happened in it.`,
64
+ "",
65
+ ...turns.map((turn) => `- **${turnFile(turn, format)}** (${turn.time}, ${turn.calls} calls): ${clip(turn.prompt, INDEX_PROMPT_CHARS)} → ${turn.final ? clip(turn.final, INDEX_REPLY_CHARS) : "_(no final reply)_"}`),
66
+ "",
67
+ ].join("\n")
68
+ }
@@ -0,0 +1,28 @@
1
+ import { existsSync, readdirSync } from "node:fs"
2
+ import path from "node:path"
3
+ import { SESSION_ID_PATTERN } from "./constants"
4
+
5
+ const found = new Map<string, string>()
6
+
7
+ export function findSessionFile(sessionId: string, configDirs: string[]): string | null {
8
+ const hit = found.get(sessionId)
9
+ if (hit && existsSync(hit)) return hit
10
+ if (!SESSION_ID_PATTERN.test(sessionId)) return null
11
+ for (const configDir of configDirs) {
12
+ const projects = path.join(configDir, "projects")
13
+ let dirs: string[]
14
+ try {
15
+ dirs = readdirSync(projects)
16
+ } catch {
17
+ continue
18
+ }
19
+ for (const dir of dirs) {
20
+ const file = path.join(projects, dir, `${sessionId}.jsonl`)
21
+ if (existsSync(file)) {
22
+ found.set(sessionId, file)
23
+ return file
24
+ }
25
+ }
26
+ }
27
+ return null
28
+ }
@@ -0,0 +1,93 @@
1
+ import { contentBlocks } from "../context/content"
2
+ import type { Block } from "../context/types"
3
+ import { CALL_COMMAND_CHARS, CALL_DESCRIPTION_CHARS, CALL_INPUT_CHARS, CALL_PATTERN_CHARS, OUTPUT_CAP } from "./constants"
4
+ import type { SessionEntry, ToolPart, Turn } from "./types"
5
+
6
+ function stripReminders(text: string) {
7
+ return text
8
+ .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, "")
9
+ .replace(/<system-message>[\s\S]*?<\/system-message>/g, "")
10
+ .trim()
11
+ }
12
+
13
+ function isPrompt(entry: SessionEntry) {
14
+ if (entry.type !== "user" || entry.isMeta || entry.isCompactSummary || entry.isSidechain) return false
15
+ const content = contentBlocks(entry.message?.content)
16
+ if (content.some((block) => block.type === "tool_result")) return false
17
+ const text = stripReminders(content.filter((block) => block.type === "text").map((block) => block.text ?? "").join("\n"))
18
+ return Boolean(text) || content.some((block) => block.type === "image")
19
+ }
20
+
21
+ function describeCall(block: Block): string {
22
+ const input = block.input ?? {}
23
+ if (typeof input.command === "string") return `${block.name}: \`${input.command.replace(/\s+/g, " ").slice(0, CALL_COMMAND_CHARS)}\``
24
+ if (typeof input.file_path === "string") return `${block.name}: ${input.file_path}`
25
+ if (typeof input.pattern === "string") return `${block.name}: "${input.pattern.slice(0, CALL_PATTERN_CHARS)}"${input.path ? ` in ${input.path}` : ""}`
26
+ if (typeof input.url === "string") return `${block.name}: ${input.url}`
27
+ if (typeof input.description === "string") return `${block.name}: ${input.description.slice(0, CALL_DESCRIPTION_CHARS)}`
28
+ return `${block.name}: ${JSON.stringify(input).slice(0, CALL_INPUT_CHARS)}`
29
+ }
30
+
31
+ function callBody(block: Block): string {
32
+ const input = block.input ?? {}
33
+ if (typeof input.new_string === "string") return `Replaced:\n\`\`\`\n${input.old_string ?? ""}\n\`\`\`\nWith:\n\`\`\`\n${input.new_string}\n\`\`\``
34
+ if (typeof input.content === "string" && block.name === "Write") return `\`\`\`\n${input.content.slice(0, OUTPUT_CAP)}\n\`\`\``
35
+ return ""
36
+ }
37
+
38
+ function resultText(block: Block): string {
39
+ const content = typeof block.content === "string"
40
+ ? block.content
41
+ : contentBlocks(block.content).map((part) => (part.type === "text" ? part.text : `[${part.type}]`)).join("\n")
42
+ return content.length > OUTPUT_CAP ? `${content.slice(0, OUTPUT_CAP)}\n… [${content.length - OUTPUT_CAP} more characters]` : content
43
+ }
44
+
45
+ export function parseTurns(entries: SessionEntry[]): Turn[] {
46
+ const turns: Turn[] = []
47
+ const pending = new Map<string, ToolPart>()
48
+ let narration = ""
49
+ for (const entry of entries) {
50
+ if (entry.isSidechain) continue
51
+ if (isPrompt(entry)) {
52
+ const text = stripReminders(contentBlocks(entry.message.content).filter((block) => block.type === "text").map((block) => block.text).join("\n"))
53
+ turns.push({ number: turns.length + 1, time: String(entry.timestamp ?? "").slice(0, 16).replace("T", " "), prompt: text || "[image]", parts: [], final: "", calls: 0 })
54
+ continue
55
+ }
56
+ const turn = turns.at(-1)
57
+ if (!turn) continue
58
+ if (entry.type === "system" && entry.subtype === "compact_boundary") {
59
+ turn.parts.push({ kind: "say", text: "(Claude Code compacted the context here.)" })
60
+ continue
61
+ }
62
+ if (entry.type === "assistant") {
63
+ for (const block of contentBlocks(entry.message?.content)) {
64
+ if (block.type === "text" && String(block.text).trim()) {
65
+ narration = String(block.text).trim()
66
+ turn.final = narration
67
+ }
68
+ if (block.type === "tool_use") {
69
+ turn.calls += 1
70
+ if (narration) {
71
+ turn.parts.push({ kind: "say", text: narration })
72
+ narration = ""
73
+ }
74
+ turn.final = ""
75
+ const call: ToolPart = { kind: "tool", n: turn.calls, name: block.name, call: describeCall(block).slice(block.name.length + 2), body: callBody(block), output: "", error: false }
76
+ pending.set(block.id, call)
77
+ turn.parts.push(call)
78
+ }
79
+ }
80
+ }
81
+ if (entry.type === "user") {
82
+ for (const block of contentBlocks(entry.message?.content)) {
83
+ if (block.type !== "tool_result") continue
84
+ const call = pending.get(block.tool_use_id)
85
+ if (!call) continue
86
+ pending.delete(block.tool_use_id)
87
+ call.output = resultText(block).trim()
88
+ call.error = Boolean(block.is_error)
89
+ }
90
+ }
91
+ }
92
+ return turns
93
+ }
@@ -0,0 +1,36 @@
1
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
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"
8
+
9
+ export function transcriptDir(sessionId: string) {
10
+ return path.join(paths.transcripts, sessionId)
11
+ }
12
+
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) }
19
+ }
20
+ const entries = readFileSync(sessionFile, "utf8").split("\n").flatMap((line) => {
21
+ try { return line ? [JSON.parse(line)] : [] } catch { return [] }
22
+ })
23
+ const turns = parseTurns(entries)
24
+ let changed = false
25
+ const write = (file: string, content: string) => {
26
+ const target = path.join(outDir, file)
27
+ if (existsSync(target) && readFileSync(target, "utf8") === content) return
28
+ writeFileSync(target, content)
29
+ changed = true
30
+ }
31
+ 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))
35
+ return { dir: outDir, changed, turns: turns.length }
36
+ }
@@ -0,0 +1,80 @@
1
+ import { existsSync } from "node:fs"
2
+ import path from "node:path"
3
+ import type { SearchMode } from "../config/types"
4
+ import { QMD_DIR } from "./constants"
5
+ import { renderTranscript, transcriptDir } from "./render"
6
+ import type { TranscriptRef } from "./types"
7
+
8
+ const indexing = new Map<string, Promise<void>>()
9
+
10
+ async function qmd(cwd: string, args: string[]) {
11
+ await Bun.spawn(["qmd", ...args], { cwd, stdout: "ignore", stderr: "ignore" }).exited
12
+ }
13
+
14
+ 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)
24
+ indexing.set(dir, next)
25
+ return next
26
+ }
27
+
28
+ export function prepareTranscript(mode: SearchMode, sessionFile: string, key: string): TranscriptRef {
29
+ const dir = transcriptDir(key)
30
+ if (mode !== "jsonl") {
31
+ try {
32
+ const { changed } = renderTranscript(sessionFile, key, dir, mode === "xml" ? "xml" : "markdown")
33
+ if (mode === "qmd" && (changed || !existsSync(path.join(dir, QMD_DIR)))) void indexWithQmd(dir)
34
+ } catch {}
35
+ }
36
+ return { mode, jsonl: sessionFile, dir }
37
+ }
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,43 @@
1
+ import type { SearchMode } from "../config/types"
2
+
3
+ export type SessionEntry = Record<string, any>
4
+
5
+ export type TranscriptFormat = "markdown" | "xml"
6
+
7
+ export interface ToolPart {
8
+ kind: "tool"
9
+ n: number
10
+ name: string
11
+ call: string
12
+ body: string
13
+ output: string
14
+ error: boolean
15
+ }
16
+
17
+ export interface SayPart {
18
+ kind: "say"
19
+ text: string
20
+ }
21
+
22
+ export type TurnPart = SayPart | ToolPart
23
+
24
+ export interface Turn {
25
+ number: number
26
+ time: string
27
+ prompt: string
28
+ parts: TurnPart[]
29
+ final: string
30
+ calls: number
31
+ }
32
+
33
+ export interface RenderResult {
34
+ dir: string
35
+ changed: boolean
36
+ turns: number
37
+ }
38
+
39
+ export interface TranscriptRef {
40
+ mode: SearchMode
41
+ jsonl: string
42
+ dir: string
43
+ }
package/src/version.ts ADDED
@@ -0,0 +1,3 @@
1
+ import pkg from "../package.json"
2
+
3
+ export const VERSION: string = pkg.version