@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,29 @@
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"]
@@ -0,0 +1,42 @@
1
+ import { VERSION } from "../version"
2
+ import { GLOBAL_OPTIONS, TAGLINE } from "./constants"
3
+ import { withLogo } from "./logo"
4
+ import type { Command } from "./types"
5
+ import { c, line } from "./ui"
6
+
7
+ export function printHelp(commands: Record<string, Command>) {
8
+ const width = Math.max(...Object.keys(commands).map((name) => name.length), ...GLOBAL_OPTIONS.map(([flag]) => flag.length))
9
+ line()
10
+ for (const row of withLogo([`${c.bold("eunoe")} ${c.dim(VERSION)}`, c.italic(TAGLINE)])) line(row)
11
+ line()
12
+ line(` ${c.dim("Usage")}`)
13
+ line(` eunoe ${c.cyan("<command>")} ${c.dim("[options]")}`)
14
+ line()
15
+ line(` ${c.dim("Commands")}`)
16
+ for (const [name, command] of Object.entries(commands)) line(` ${c.cyan(name.padEnd(width))} ${command.summary}`)
17
+ line()
18
+ line(` ${c.dim("Options")}`)
19
+ for (const [flag, description] of GLOBAL_OPTIONS) line(` ${flag.padEnd(width)} ${description}`)
20
+ line()
21
+ line(` ${c.dim("Run")} eunoe ${c.cyan("<command>")} --help ${c.dim("for details.")}`)
22
+ line()
23
+ }
24
+
25
+ export function printCommandHelp(name: string, command: Command) {
26
+ line()
27
+ line(` ${c.bold(`eunoe ${name}`)} ${c.dim(command.summary)}`)
28
+ line()
29
+ line(` ${c.dim("Usage")}`)
30
+ line(` ${command.usage}`)
31
+ if (command.details) {
32
+ line()
33
+ for (const detail of command.details) line(` ${detail}`)
34
+ }
35
+ if (command.options) {
36
+ line()
37
+ line(` ${c.dim("Options")}`)
38
+ const width = Math.max(...command.options.map(([flag]) => flag.length))
39
+ for (const [flag, description] of command.options) line(` ${c.cyan(flag.padEnd(width))} ${description}`)
40
+ }
41
+ line()
42
+ }
@@ -0,0 +1,41 @@
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
+ }
@@ -0,0 +1,34 @@
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
+ }
@@ -0,0 +1,89 @@
1
+ import { HALF_BLOCKS, OCTANT_TERMINALS, OCTANTS, TRANSPARENT_PIXEL } from "../constants"
2
+ import { trueColor, useColor } from "../ui"
3
+ import { LOGO_KEYS, LOGO_PALETTE, LOGO_PIXELS } from "./pixels"
4
+
5
+ type Rgb = [number, number, number]
6
+ type Pixel = Rgb | null
7
+
8
+ const rgb = (hex: string): Rgb => [1, 3, 5].map((i) => Number.parseInt(hex.slice(i, i + 2), 16)) as Rgb
9
+
10
+ const toCube = (value: number) => (value < 48 ? 0 : value < 115 ? 1 : Math.min(5, Math.round((value - 35) / 40)))
11
+
12
+ const MISMATCH = 3 * 255 ** 2
13
+
14
+ function sgr(color: Rgb, layer: "fg" | "bg") {
15
+ const base = layer === "fg" ? 38 : 48
16
+ if (trueColor) return `${base};2;${color.join(";")}`
17
+ const [r, g, b] = color.map(toCube)
18
+ return `${base};5;${16 + 36 * r + 6 * g + b}`
19
+ }
20
+
21
+ function pixel(key: string | undefined): Pixel {
22
+ if (!key || key === TRANSPARENT_PIXEL) return null
23
+ return rgb(LOGO_PALETTE[LOGO_KEYS.indexOf(key)])
24
+ }
25
+
26
+ function distance(a: Pixel, b: Pixel) {
27
+ if (!a || !b) return a === b ? 0 : MISMATCH
28
+ return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2
29
+ }
30
+
31
+ function split(block: Pixel[]): [Pixel, Pixel] {
32
+ const colors = block.filter((color, i) => block.findIndex((other) => distance(other, color) === 0) === i)
33
+ if (colors.length <= 2) return [colors[0] ?? null, colors[1] ?? null]
34
+ let best: [Pixel, Pixel] = [colors[0], colors[1]]
35
+ let bestError = Number.POSITIVE_INFINITY
36
+ for (let i = 0; i < colors.length; i += 1) {
37
+ for (let j = i + 1; j < colors.length; j += 1) {
38
+ const error = block.reduce((total, color) => total + Math.min(distance(color, colors[i]), distance(color, colors[j])), 0)
39
+ if (error < bestError) [best, bestError] = [[colors[i], colors[j]], error]
40
+ }
41
+ }
42
+ return best
43
+ }
44
+
45
+ function dominant(block: Pixel[]): Pixel {
46
+ const counts = block.map((color) => block.filter((other) => distance(other, color) === 0).length)
47
+ return block[counts.indexOf(Math.max(...counts))]
48
+ }
49
+
50
+ function cell(block: Pixel[], glyphs: string[]) {
51
+ let [fg, bg] = split(block)
52
+ if (!fg) [fg, bg] = [bg, fg]
53
+ if (!fg) return " "
54
+ const mask = block.reduce((bits, color, i) => (distance(color, fg) <= distance(color, bg) ? bits | (1 << i) : bits), 0)
55
+ const codes = bg ? `${sgr(fg, "fg")};${sgr(bg, "bg")}` : sgr(fg, "fg")
56
+ return `\x1b[${codes}m${glyphs[mask]}\x1b[0m`
57
+ }
58
+
59
+ function supportsOctants() {
60
+ const terminal = `${process.env.TERM_PROGRAM ?? ""} ${process.env.TERM ?? ""}`.toLowerCase()
61
+ return OCTANT_TERMINALS.some((name) => terminal.includes(name))
62
+ }
63
+
64
+ function region(x: number, y: number, width: number, height: number): Pixel[] {
65
+ const block: Pixel[] = []
66
+ for (let dy = 0; dy < height; dy += 1) for (let dx = 0; dx < width; dx += 1) block.push(pixel(LOGO_PIXELS[y + dy]?.[x + dx]))
67
+ return block
68
+ }
69
+
70
+ export function logoLines(): string[] {
71
+ if (!useColor) return []
72
+ const octants = supportsOctants()
73
+ const lines: string[] = []
74
+ for (let y = 0; y < LOGO_PIXELS.length; y += 4) {
75
+ let row = ""
76
+ for (let x = 0; x < LOGO_PIXELS[0].length; x += 2) {
77
+ row += octants
78
+ ? cell(region(x, y, 2, 4), OCTANTS)
79
+ : cell([dominant(region(x, y, 2, 2)), dominant(region(x, y + 2, 2, 2))], [...HALF_BLOCKS])
80
+ }
81
+ lines.push(row)
82
+ }
83
+ return lines
84
+ }
85
+
86
+ export function withLogo(text: string[]): string[] {
87
+ const logo = logoLines()
88
+ return [...logo.map((row) => ` ${row}`), ...(logo.length ? [""] : []), ...text.map((entry) => ` ${entry}`)]
89
+ }
@@ -0,0 +1,34 @@
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
+ ]
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env bun
2
+ import { VERSION } from "../version"
3
+ import { wantsHelp } from "./args"
4
+ import { commands } from "./commands"
5
+ import { printCommandHelp, printHelp } from "./help"
6
+ import { closest, fail, line } from "./ui"
7
+
8
+ const [name, ...args] = process.argv.slice(2)
9
+
10
+ if (!name || name === "help" || name === "-h" || name === "--help") {
11
+ const topic = name === "help" ? args[0] : undefined
12
+ if (topic && commands[topic]) printCommandHelp(topic, commands[topic])
13
+ else printHelp(commands)
14
+ } else if (name === "-v" || name === "--version" || name === "version") {
15
+ line(VERSION)
16
+ } else if (!commands[name]) {
17
+ const suggestion = closest(name, Object.keys(commands))
18
+ fail(`Unknown command: ${name}`, suggestion ? `Did you mean \`eunoe ${suggestion}\`?` : "Run `eunoe --help` to see the commands.")
19
+ } else if (wantsHelp(args)) {
20
+ printCommandHelp(name, commands[name])
21
+ } else {
22
+ await commands[name].run(args)
23
+ }
@@ -0,0 +1,13 @@
1
+ export type Settings = Record<string, any>
2
+
3
+ export type OptionRow = [flag: string, description: string]
4
+
5
+ export interface Command {
6
+ summary: string
7
+ usage: string
8
+ details?: string[]
9
+ options?: OptionRow[]
10
+ run: (args: string[]) => void | Promise<void>
11
+ }
12
+
13
+ export type RequestRecord = Record<string, any>
package/src/cli/ui.ts ADDED
@@ -0,0 +1,91 @@
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
+ }
@@ -0,0 +1,27 @@
1
+ import { homedir } from "node:os"
2
+ import path from "node:path"
3
+ import type { Config, Mode, SearchMode } from "./types"
4
+
5
+ export const EUNOE_DIR = process.env.EUNOE_DIR ?? path.join(homedir(), ".eunoe")
6
+
7
+ export const paths = {
8
+ config: path.join(EUNOE_DIR, "config.json"),
9
+ requests: path.join(EUNOE_DIR, "requests.jsonl"),
10
+ threads: path.join(EUNOE_DIR, "threads"),
11
+ transcripts: path.join(EUNOE_DIR, "transcripts"),
12
+ models: path.join(EUNOE_DIR, "models.json"),
13
+ proxyLog: path.join(EUNOE_DIR, "proxy.log"),
14
+ }
15
+
16
+ export const DEFAULTS: Config = {
17
+ mode: "compact",
18
+ port: 8788,
19
+ compactAt: 0.9,
20
+ keepTurns: "all",
21
+ search: "markdown",
22
+ upstream: "https://api.anthropic.com",
23
+ claudeConfigDirs: [],
24
+ }
25
+
26
+ export const MODES: Mode[] = ["off", "trim", "compact"]
27
+ export const SEARCH_MODES: SearchMode[] = ["jsonl", "markdown", "xml", "qmd"]
@@ -0,0 +1,49 @@
1
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
2
+ import { homedir } from "node:os"
3
+ import path from "node:path"
4
+ import { DEFAULTS, EUNOE_DIR, MODES, paths, SEARCH_MODES } from "./constants"
5
+ import type { Config } from "./types"
6
+
7
+ export function normalizeConfig(raw: Record<string, any>): Config {
8
+ const config: Config = { ...DEFAULTS, ...raw }
9
+ if (!MODES.includes(config.mode)) config.mode = DEFAULTS.mode
10
+ if (!SEARCH_MODES.includes(config.search)) config.search = DEFAULTS.search
11
+ if (typeof config.compactAt !== "number" || config.compactAt <= 0 || config.compactAt > 1) config.compactAt = DEFAULTS.compactAt
12
+ if (config.keepTurns !== "all" && (typeof config.keepTurns !== "number" || config.keepTurns < 0)) config.keepTurns = DEFAULTS.keepTurns
13
+ if (!Number.isInteger(config.port)) config.port = DEFAULTS.port
14
+ if (!Array.isArray(config.claudeConfigDirs)) config.claudeConfigDirs = []
15
+ return config
16
+ }
17
+
18
+ let cached: { mtimeMs: number; config: Config } | null = null
19
+
20
+ export function loadConfig(): Config {
21
+ let mtimeMs: number
22
+ try {
23
+ mtimeMs = statSync(paths.config).mtimeMs
24
+ } catch {
25
+ return DEFAULTS
26
+ }
27
+ if (cached?.mtimeMs === mtimeMs) return cached.config
28
+ let config = DEFAULTS
29
+ try {
30
+ config = normalizeConfig(JSON.parse(readFileSync(paths.config, "utf8")))
31
+ } catch (error) {
32
+ console.error(`[eunoe] ${paths.config} is not valid JSON; using defaults`, error)
33
+ }
34
+ cached = { mtimeMs, config }
35
+ return config
36
+ }
37
+
38
+ export function saveConfig(patch: Partial<Config>) {
39
+ mkdirSync(EUNOE_DIR, { recursive: true })
40
+ const current = existsSync(paths.config) ? JSON.parse(readFileSync(paths.config, "utf8")) : {}
41
+ writeFileSync(paths.config, JSON.stringify({ ...current, ...patch }, null, 2) + "\n")
42
+ }
43
+
44
+ export function claudeConfigDirs(config: Config): string[] {
45
+ const dirs = new Set([path.join(homedir(), ".claude")])
46
+ if (process.env.CLAUDE_CONFIG_DIR) dirs.add(process.env.CLAUDE_CONFIG_DIR)
47
+ for (const dir of config.claudeConfigDirs) dirs.add(dir.replace(/^~(?=$|\/)/, homedir()))
48
+ return [...dirs]
49
+ }
@@ -0,0 +1,21 @@
1
+ export type Mode = "off" | "trim" | "compact"
2
+ export type SearchMode = "jsonl" | "markdown" | "xml" | "qmd"
3
+
4
+ export interface EvalConfig {
5
+ strategy?: "tail" | "guide"
6
+ tailTurns?: number
7
+ forceCut?: boolean
8
+ trustHeaders?: boolean
9
+ }
10
+
11
+ export interface Config {
12
+ mode: Mode
13
+ port: number
14
+ compactAt: number
15
+ keepTurns: number | "all"
16
+ search: SearchMode
17
+ upstream: string
18
+ claudeConfigDirs: string[]
19
+ window?: number
20
+ eval?: EvalConfig
21
+ }
@@ -0,0 +1,7 @@
1
+ export const SUMMARY_COMMAND_CHARS = 100
2
+ export const SUMMARY_ARGUMENT_CHARS = 60
3
+ export const SUMMARY_LAST_WORDS_CHARS = 600
4
+ export const SUMMARY_CALLS_SHOWN = 20
5
+
6
+ export const CLEARED_INPUT_THRESHOLD = 2_000
7
+ export const CLEARED_INPUT_KEPT = 500
@@ -0,0 +1,24 @@
1
+ import type { Block, Message } from "./types"
2
+
3
+ export function contentBlocks(content: unknown): Block[] {
4
+ return typeof content === "string" ? [{ type: "text", text: content }] : Array.isArray(content) ? content : []
5
+ }
6
+
7
+ export function blocks(message: Message): Block[] {
8
+ return contentBlocks(message.content)
9
+ }
10
+
11
+ export function withoutCacheControl(block: Block): Block {
12
+ if (!("cache_control" in block)) return block
13
+ const { cache_control: _, ...rest } = block
14
+ return rest
15
+ }
16
+
17
+ export function clean(message: Message): Message {
18
+ return { role: message.role, content: blocks(message).map(withoutCacheControl) }
19
+ }
20
+
21
+ export function clip(value: unknown, max: number): string {
22
+ const text = String(value).replace(/\s+/g, " ").trim()
23
+ return text.length > max ? `${text.slice(0, max)}…` : text
24
+ }
@@ -0,0 +1,66 @@
1
+ import type { NoticeCounts } from "./types"
2
+
3
+ export function compactionNotice(args: NoticeCounts) {
4
+ return [
5
+ "<context-compacted>",
6
+ "Your context was compacted here, so tool calls and their output from before this point were removed.",
7
+ args.omitted > 0
8
+ ? `${args.omitted} of the oldest turns didn't fit and were removed entirely. The ${args.kept} turn${args.kept === 1 ? "" : "s"} after them are shown as the user's message and your final reply.`
9
+ : args.kept > 0
10
+ ? "Every earlier turn is shown below as the user's message and your final reply."
11
+ : "",
12
+ "The turn that was in progress when the context filled up follows in full. Continue from it.",
13
+ "Instructions and preferences the user gave in earlier turns still apply: how they want things done, and what to do or not do on your own. Look back over their messages before you act.",
14
+ "For an exact detail from before this point (an ID, a path, a command, an error, a value, what a tool printed), look it up instead of going from memory. A lookup is cheap; a wrong detail is not. Never say you don't know about something earlier in this session without searching for it first, and if what you remember contradicts what the user says, search before telling them they are wrong.",
15
+ args.guide ?? "",
16
+ "</context-compacted>",
17
+ ].filter(Boolean).join("\n\n")
18
+ }
19
+
20
+ export function truncationNotice(args: NoticeCounts) {
21
+ return [
22
+ "<context-truncated>",
23
+ `${args.omitted} earlier turn${args.omitted === 1 ? " was" : "s were"} dropped to fit the context window. What follows is the most recent ${args.kept} turn${args.kept === 1 ? "" : "s"}, unchanged.`,
24
+ "Instructions and preferences the user gave in earlier turns still apply.",
25
+ args.guide ?? "",
26
+ "</context-truncated>",
27
+ ].filter(Boolean).join("\n\n")
28
+ }
29
+
30
+ export function trimInstructions(guide: string): string {
31
+ return [
32
+ "# Context between turns",
33
+ "",
34
+ "This session trims its context between turns: of every earlier turn you keep only the user's message (with anything that arrived with it) and your final message. Everything in between is gone when the next turn starts: tool calls and their results, files you read, command output, your thinking, and anything you wrote along the way. Within a turn, nothing is trimmed.",
35
+ "",
36
+ "So your final message is the only memory you carry forward. End each turn with a final message that lets you pick the work back up cold:",
37
+ "- What changed: files with their paths, branches, commits, PRs, anything created, moved or deleted.",
38
+ "- What you found: facts you would otherwise have to dig up again. Where code lives, how something works, the cause of a bug, exact error text, IDs, URLs, the numbers that matter.",
39
+ "- Decisions and their reasons, including approaches you tried and ruled out.",
40
+ "- Where things stand: what is done, what is not, what you verified and how, and what is waiting on the user.",
41
+ "",
42
+ "It is still your reply to the user, so write it for them, but leave out nothing you will need. State things outright instead of pointing back at tool output: \"as shown above\" means nothing next turn. One exact line (a path, a command, a value) beats a paragraph describing it. A turn that ends on a question still needs the state written down. A trivial turn (a quick answer, small talk) needs no state.",
43
+ "",
44
+ "Instructions and preferences the user gave in earlier turns still apply: how they want things done, and what to do or not do on your own.",
45
+ "",
46
+ "Nothing is lost. What you see of earlier turns is only their outcome. For an exact detail from an earlier turn (an ID, a path, a command you ran, what it printed, a file's earlier content, an error), look it up in the full transcript instead of going from memory or redoing the work. A lookup is cheap; a wrong detail is not.",
47
+ "",
48
+ guide,
49
+ "",
50
+ "What you read from it stays in context for this turn only. If you will need it again later, put it in your final message.",
51
+ ].join("\n")
52
+ }
53
+
54
+ export function searchOnlyInstructions(guide: string): string {
55
+ return [
56
+ "# The full transcript",
57
+ "",
58
+ "Earlier parts of this session may be summarised or missing from your context. The complete record is on disk, and for any exact detail from earlier (an ID, a path, a command you ran, what it printed, an error, a value) you should look it up there rather than answer from memory. A lookup is cheap; a wrong detail is not. Never say you don't know about something earlier in this session without searching for it first.",
59
+ "",
60
+ guide,
61
+ ].join("\n")
62
+ }
63
+
64
+ export const CLEARED_OUTPUT = "[Output cleared to keep the context within its limit. It is in the full transcript: search there for this call.]"
65
+
66
+ export const TRANSCRIPT_FALLBACK = "The full transcript is your Claude Code session file under ~/.claude/projects."