@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
@@ -0,0 +1,76 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { claudeConfigDirs, readConfig } from "../../config"
3
+ import { FOLD_CAP_CHARS, foldedView, sourceChunks } from "../../context/fold"
4
+ import { recallFor } from "../../context/recall"
5
+ import { DEFAULT_RATIO } from "../../proxy/estimate"
6
+ import { continueToken } from "../../proxy/handoff"
7
+ import { seenSessions } from "../../proxy/sessions"
8
+ import { isSessionId } from "../../transcript/locate"
9
+ import { subagentChunks } from "../../transcript/subagents"
10
+ import { flagValues } from "../terminal/args"
11
+ import { ago, fail, tokens } from "../terminal/ui"
12
+ import { type SessionCandidate, sessionCandidates } from "./source"
13
+
14
+ const ASK_CHARS = 160
15
+
16
+ export const shortId = (sessionId: string) => sessionId.slice(0, 8)
17
+
18
+ function byPrefix<T>(input: string, items: T[], idOf: (item: T) => string): T | null {
19
+ if (!isSessionId(input)) fail(`Not a session id: ${input}`)
20
+ const exact = items.find((item) => idOf(item) === input)
21
+ if (exact) return exact
22
+ const matches = items.filter((item) => idOf(item).startsWith(input))
23
+ if (matches.length > 1) fail(`${input} matches ${matches.length} sessions`, "Use more of the id.")
24
+ return matches[0] ?? null
25
+ }
26
+
27
+ export function sessionArgs(args: string[]): string[] {
28
+ const seen = seenSessions().map(([id]) => id)
29
+ return [...new Set(flagValues(args, "--session").map((input) => byPrefix(input, seen, (id) => id) ?? input))]
30
+ }
31
+
32
+ export function isKnown(sessionId: string) {
33
+ return seenSessions().some(([id]) => id === sessionId)
34
+ }
35
+
36
+ export const candidatesHere = () => sessionCandidates(process.env.CLAUDE_CODE_SESSION_ID ?? "", process.cwd(), claudeConfigDirs(readConfig()))
37
+
38
+ export function candidateOf(input: string | undefined, candidates: SessionCandidate[]): SessionCandidate {
39
+ if (!input) fail("Say which session: eunoe sessions lists them.")
40
+ return byPrefix(input, candidates, (cand) => cand.id) ?? fail(`No earlier session ${input} in this directory.`, "eunoe sessions lists them.")
41
+ }
42
+
43
+ const tokensOf = (cand: SessionCandidate) => tokens(Math.round(foldedView(cand.messages, FOLD_CAP_CHARS).length * DEFAULT_RATIO))
44
+
45
+ export const titleOf = (cand: SessionCandidate) => cand.label || shortId(cand.id)
46
+
47
+ export const metaOf = (cand: SessionCandidate) => `${cand.asks.length} turns · ~${tokensOf(cand)} tokens · ${ago(new Date(cand.mtime).toISOString())}`
48
+
49
+ export function askOf(text: string | undefined): string {
50
+ const flat = (text ?? "").replace(/\s+/g, " ").trim()
51
+ if (flat.length <= ASK_CHARS) return flat
52
+ const cut = flat.slice(0, ASK_CHARS)
53
+ return `${cut.slice(0, Math.max(cut.lastIndexOf(" "), ASK_CHARS / 2))}…`
54
+ }
55
+
56
+ export const continuePrompt = (id: string) => continueToken(shortId(id))
57
+
58
+ export function copy(text: string): boolean {
59
+ const clipboards: Array<[string, string[]]> = [
60
+ ["pbcopy", []],
61
+ ["wl-copy", []],
62
+ ["xclip", ["-selection", "clipboard"]],
63
+ ]
64
+ return clipboards.some(([cmd, args]) => {
65
+ const result = spawnSync(cmd, args, { input: text })
66
+ return !result.error && result.status === 0
67
+ })
68
+ }
69
+
70
+ export function peekText(cand: SessionCandidate, words: string): string | null {
71
+ const chunks = [...sourceChunks(cand.messages, titleOf(cand), true), ...subagentChunks(cand.file)]
72
+ const found = recallFor(words, chunks, { maxChars: 12_000, maxCharsMulti: 12_000, perQuery: 6, minTerms: 1 })
73
+ if (!found) return null
74
+ const excerpts = found.replace(/^<recalled-context>\n[^\n]*\n\n|\n<\/recalled-context>$/g, "")
75
+ return `Excerpts of session ${shortId(cand.id)} "${titleOf(cand)}" that match "${words}", quoted exactly, oldest first:\n\n${excerpts}`
76
+ }
@@ -0,0 +1,74 @@
1
+ import { statSync } from "node:fs"
2
+ import path from "node:path"
3
+ import { type Message, textOf } from "../../context/content"
4
+ import { isPrompt, splitTurns } from "../../context/fold/turns"
5
+ import { type RecallChunk, rank, terms } from "../../context/recall"
6
+ import { cwdOf, seenSessions, titleOf } from "../../proxy/sessions"
7
+ import { findSessionFile, listEntries, projectFolderName } from "../../transcript/locate"
8
+ import { sessionMessages } from "../../transcript/messages"
9
+
10
+ export interface SessionCandidate {
11
+ id: string
12
+ file: string
13
+ label: string
14
+ mtime: number
15
+ asks: string[]
16
+ outline: string
17
+ messages: Message[]
18
+ }
19
+
20
+ export const SESSION_SOURCE_MIN_TURNS = 2
21
+ export const SESSION_SOURCE_CANDIDATES = 12
22
+ export const SESSION_MIN_TERMS = 2
23
+
24
+ const promptText = (message: Message) =>
25
+ textOf(message)
26
+ .replace(/<[a-z-]+(?:\s[^>]*)?>[\s\S]*?<\/[a-z-]+>/g, "")
27
+ .trim()
28
+
29
+ export function outlineOf(messages: Message[]): string[] {
30
+ return splitTurns(messages)
31
+ .turns.filter((turn) => isPrompt(turn[0]))
32
+ .map((turn) => promptText(turn[0]))
33
+ .filter(Boolean)
34
+ .map((text) => text.slice(0, 240))
35
+ }
36
+
37
+ function sessionFiles(sessionId: string, cwd: string, configDirs: string[]): Array<{ id: string; file: string; mtime: number }> {
38
+ const files = new Map<string, { file: string; mtime: number }>()
39
+ const add = (id: string, file: string) => {
40
+ if (id === sessionId || files.has(id)) return
41
+ try {
42
+ files.set(id, { file, mtime: statSync(file).mtimeMs })
43
+ } catch {}
44
+ }
45
+ for (const [id, seen] of seenSessions()) {
46
+ if (seen.cwd !== cwd) continue
47
+ const file = findSessionFile(id, configDirs)
48
+ if (file) add(id, file)
49
+ }
50
+ for (const dir of configDirs) {
51
+ const folder = path.join(dir, "projects", projectFolderName(cwd))
52
+ for (const name of listEntries(folder)) if (name.endsWith(".jsonl")) add(name.slice(0, -".jsonl".length), path.join(folder, name))
53
+ }
54
+ return [...files].map(([id, entry]) => ({ id, ...entry })).sort((a, b) => b.mtime - a.mtime)
55
+ }
56
+
57
+ export function sessionCandidates(sessionId: string, cwd: string, configDirs: string[]): SessionCandidate[] {
58
+ const out: SessionCandidate[] = []
59
+ for (const { id, file, mtime } of sessionFiles(sessionId, cwd, configDirs)) {
60
+ const recorded = cwdOf(file)
61
+ if (recorded && recorded !== cwd) continue
62
+ const messages = sessionMessages(file)
63
+ const asks = outlineOf(messages)
64
+ if (asks.length < SESSION_SOURCE_MIN_TURNS) continue
65
+ out.push({ id, file, label: titleOf(file) ?? "", mtime, asks, outline: asks.map((ask, i) => `${i + 1}. ${ask}`).join("\n"), messages })
66
+ if (out.length >= SESSION_SOURCE_CANDIDATES) break
67
+ }
68
+ return out
69
+ }
70
+
71
+ export function rankCandidates(prompt: string, candidates: SessionCandidate[]): SessionCandidate[] {
72
+ const chunks: RecallChunk[] = candidates.map((cand, i) => ({ turn: i, call: cand.id, lines: [cand.outline], terms: terms(cand.outline) }))
73
+ return rank(terms(prompt), chunks, SESSION_MIN_TERMS).map((r) => candidates[r.index])
74
+ }
@@ -5,7 +5,7 @@ export function flagValues(args: string[], flag: string): string[] {
5
5
  for (let i = 0; i < args.length; i += 1) {
6
6
  if (args[i] !== flag) continue
7
7
  const value = args[i + 1]
8
- if (!value || value.startsWith("-")) fail(`${flag} needs a value`, `eunoe install ${flag} <dir>`)
8
+ if (!value || value.startsWith("-")) fail(`${flag} needs a value`)
9
9
  values.push(value)
10
10
  i += 1
11
11
  }
@@ -1,24 +1,40 @@
1
- import { VERSION } from "../version"
2
- import { GLOBAL_OPTIONS, TAGLINE } from "./constants"
3
- import { withLogo } from "./logo"
4
- import type { Command } from "./types"
1
+ import { VERSION } from "../../version"
2
+ import type { Command, OptionRow } from "../command"
3
+ import { withLogo } from "../logo"
5
4
  import { c, line } from "./ui"
6
5
 
6
+ const GROUPS: Array<[title: string, names: string[]]> = [
7
+ ["Proxy", ["start", "stop", "status", "replay", "uninstall"]],
8
+ ["Context", ["mode", "settings"]],
9
+ ["Sessions", ["sessions"]],
10
+ ]
11
+
12
+ const GLOBAL_OPTIONS: OptionRow[] = [
13
+ ["-h, --help", "Show help for a command"],
14
+ ["-v, --version", "Show the version"],
15
+ ]
16
+
7
17
  export function printHelp(commands: Record<string, Command>) {
8
18
  const width = Math.max(...Object.keys(commands).map((name) => name.length), ...GLOBAL_OPTIONS.map(([flag]) => flag.length))
9
19
  line()
10
- for (const row of withLogo([`${c.bold("eunoe")} ${c.dim(VERSION)}`, c.italic(TAGLINE)])) line(row)
20
+ for (const row of withLogo([`${c.bold("eunoe")} ${c.dim(VERSION)}`, c.italic("Context management for Claude Code that never forgets what matters.")])) line(row)
11
21
  line()
12
22
  line(` ${c.dim("Usage")}`)
13
23
  line(` eunoe ${c.cyan("<command>")} ${c.dim("[options]")}`)
14
24
  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()
25
+ const visible = Object.keys(commands).filter((name) => !commands[name].hidden)
26
+ const grouped = new Set(GROUPS.flatMap(([, names]) => names))
27
+ for (const [title, names] of [...GROUPS, ["Other", visible.filter((name) => !grouped.has(name))] as const]) {
28
+ const shown = names.filter((name) => visible.includes(name))
29
+ if (shown.length === 0) continue
30
+ line(` ${c.dim(title)}`)
31
+ for (const name of shown) line(` ${c.cyan(name.padEnd(width))} ${commands[name].summary}`)
32
+ line()
33
+ }
18
34
  line(` ${c.dim("Options")}`)
19
35
  for (const [flag, description] of GLOBAL_OPTIONS) line(` ${flag.padEnd(width)} ${description}`)
20
36
  line()
21
- line(` ${c.dim("Run")} eunoe ${c.cyan("<command>")} --help ${c.dim("for details.")}`)
37
+ line(` ${c.dim("Run")} eunoe ${c.cyan("<command>")} --help ${c.dim("for details.")} ${c.dim("`ee` works anywhere `eunoe` does.")}`)
22
38
  line()
23
39
  }
24
40
 
@@ -0,0 +1,114 @@
1
+ import { c, useColor } from "./ui"
2
+
3
+ const ESC = "\x1b"
4
+ const KEY_SEQUENCE = new RegExp(`${ESC}\\[[0-9;]*[A-Za-z~]|${ESC}.|[\\s\\S]`, "g")
5
+
6
+ interface Choice<T extends string = string> {
7
+ value: T
8
+ label?: string
9
+ hint?: string
10
+ }
11
+
12
+ export const keys = {
13
+ up: "\x1b[A",
14
+ down: "\x1b[B",
15
+ enter: ["\r", "\n"],
16
+ escape: ["\x1b", "\x03"],
17
+ backspace: ["\x7f", "\b"],
18
+ clear: "\x15",
19
+ }
20
+
21
+ export const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY)
22
+
23
+ export const printable = (key: string) => key >= " " && !key.startsWith("\x1b")
24
+
25
+ function readKeys(onKey: (key: string) => boolean | undefined): Promise<void> {
26
+ return new Promise((resolve) => {
27
+ const stdin = process.stdin
28
+ stdin.setRawMode(true)
29
+ stdin.resume()
30
+ stdin.setEncoding("utf8")
31
+ const handler = (chunk: string) => {
32
+ for (const key of chunk.match(KEY_SEQUENCE) ?? []) {
33
+ if (onKey(key) !== true) continue
34
+ stdin.off("data", handler)
35
+ stdin.setRawMode(false)
36
+ stdin.pause()
37
+ resolve()
38
+ return
39
+ }
40
+ }
41
+ stdin.on("data", handler)
42
+ })
43
+ }
44
+
45
+ export async function interact(render: () => string[], onKey: (key: string) => boolean | undefined, hideCursor = true): Promise<void> {
46
+ let drawn = 0
47
+ const draw = () => {
48
+ const lines = render()
49
+ if (drawn) process.stdout.write(`\x1b[${drawn}A\x1b[J`)
50
+ process.stdout.write(`${lines.join("\n")}\n`)
51
+ drawn = lines.length
52
+ }
53
+ const toggleCursor = hideCursor && useColor
54
+ if (toggleCursor) process.stdout.write("\x1b[?25l")
55
+ draw()
56
+ await readKeys((key) => {
57
+ if (onKey(key) === true) return true
58
+ draw()
59
+ })
60
+ process.stdout.write(`\x1b[${drawn}A\x1b[J`)
61
+ if (toggleCursor) process.stdout.write("\x1b[?25h")
62
+ }
63
+
64
+ function renderChoices(title: string, choices: Choice[], index: number, width: number) {
65
+ const lines = [` ${c.bold(title)}`, ""]
66
+ for (const [i, choice] of choices.entries()) {
67
+ const label = (choice.label ?? choice.value).padEnd(width)
68
+ const marker = i === index ? c.green("●") : c.dim("○")
69
+ lines.push(` ${marker} ${i === index ? c.bold(label) : label}${choice.hint ? ` ${c.dim(choice.hint)}` : ""}`)
70
+ }
71
+ lines.push("", ` ${c.dim("↑↓ move · enter choose · esc cancel")}`)
72
+ return lines
73
+ }
74
+
75
+ export async function select<T extends string>(title: string, choices: Choice<T>[], current?: T): Promise<T | null> {
76
+ let index = Math.max(
77
+ 0,
78
+ choices.findIndex((choice) => choice.value === current),
79
+ )
80
+ const width = Math.max(...choices.map((choice) => (choice.label ?? choice.value).length))
81
+ let chosen: T | null = null
82
+ await interact(
83
+ () => renderChoices(title, choices, index, width),
84
+ (key) => {
85
+ if (key === keys.up || key === "k") index = (index + choices.length - 1) % choices.length
86
+ else if (key === keys.down || key === "j") index = (index + 1) % choices.length
87
+ else if (keys.enter.includes(key)) {
88
+ chosen = choices[index].value
89
+ return true
90
+ } else if (keys.escape.includes(key) || key === "q") return true
91
+ },
92
+ )
93
+ return chosen
94
+ }
95
+
96
+ export async function input(title: string, placeholder: string, initial = ""): Promise<string | null> {
97
+ let value = initial
98
+ let result: string | null = null
99
+ const render = () => [` ${c.bold(title)}`, ` ${c.cyan("›")} ${value || c.dim(placeholder)}`, "", ` ${c.dim("enter save · esc cancel")}`]
100
+ await interact(
101
+ render,
102
+ (key) => {
103
+ if (keys.enter.includes(key)) {
104
+ result = value
105
+ return true
106
+ }
107
+ if (keys.escape.includes(key)) return true
108
+ if (keys.backspace.includes(key)) value = value.slice(0, -1)
109
+ else if (printable(key)) value += key
110
+ },
111
+ false,
112
+ )
113
+ return result
114
+ }
@@ -0,0 +1,137 @@
1
+ import { homedir } from "node:os"
2
+
3
+ const colorFor = (stream: NodeJS.WriteStream) => Boolean(stream.isTTY) && !process.env.NO_COLOR && process.env.TERM !== "dumb"
4
+ export const useColor = colorFor(process.stdout)
5
+ export const trueColor = /^(truecolor|24bit)$/i.test(process.env.COLORTERM ?? "")
6
+ const useColorErr = colorFor(process.stderr)
7
+
8
+ const paint =
9
+ (open: number, close: number, enabled = useColor) =>
10
+ (text: string) =>
11
+ enabled ? `\x1b[${open}m${text}\x1b[${close}m` : text
12
+
13
+ export const c = {
14
+ bold: paint(1, 22),
15
+ dim: paint(2, 22),
16
+ italic: paint(3, 23),
17
+ cyan: paint(36, 39),
18
+ green: paint(32, 39),
19
+ yellow: paint(33, 39),
20
+ red: paint(31, 39),
21
+ magenta: paint(35, 39),
22
+ }
23
+
24
+ const errRed = paint(31, 39, useColorErr)
25
+ const errDim = paint(2, 22, useColorErr)
26
+
27
+ export function line(text = "") {
28
+ console.log(text)
29
+ }
30
+
31
+ export function ok(text: string) {
32
+ line(`${c.green("✓")} ${text}`)
33
+ }
34
+
35
+ export function note(text: string) {
36
+ line(`${c.dim("·")} ${text}`)
37
+ }
38
+
39
+ export function warn(text: string) {
40
+ line(`${c.yellow("!")} ${text}`)
41
+ }
42
+
43
+ export function fail(text: string, hint?: string): never {
44
+ console.error(`${errRed("✗")} ${text}`)
45
+ if (hint) console.error(` ${errDim(hint)}`)
46
+ process.exit(1)
47
+ }
48
+
49
+ export function listFiles(files: string[]) {
50
+ for (const file of files) line(` ${c.dim(tilde(file))}`)
51
+ }
52
+
53
+ export function rows(entries: Array<[string, string]>, indent = 2) {
54
+ const width = Math.max(...entries.map(([label]) => label.length))
55
+ for (const [label, value] of entries) line(`${" ".repeat(indent)}${c.dim(label.padEnd(width))} ${value}`)
56
+ }
57
+
58
+ const ESC = "\x1b"
59
+ const ANSI_STYLE = new RegExp(`${ESC}\\[[0-9;]*m`, "g")
60
+
61
+ const visible = (text: string) => text.replace(ANSI_STYLE, "")
62
+
63
+ export function table(entries: string[][], indent = 4, gap = 2) {
64
+ const columns = Math.max(...entries.map((row) => row.length))
65
+ const widths = Array.from({ length: columns }, (_, column) => Math.max(...entries.map((row) => visible(row[column] ?? "").length)))
66
+ for (const row of entries) {
67
+ const cells = widths.map((width, column) => (row[column] ?? "") + " ".repeat(Math.max(0, width - visible(row[column] ?? "").length)))
68
+ line(`${" ".repeat(indent)}${cells.join(" ".repeat(gap)).trimEnd()}`)
69
+ }
70
+ }
71
+
72
+ export function wrap(text: string, width: number): string[] {
73
+ const out: string[] = []
74
+ let current = ""
75
+ for (const word of text.split(" ")) {
76
+ if (current && current.length + 1 + word.length > width) {
77
+ out.push(current)
78
+ current = word
79
+ } else current = current ? `${current} ${word}` : word
80
+ }
81
+ if (current) out.push(current)
82
+ return out
83
+ }
84
+
85
+ export function plural(count: number, word: string) {
86
+ return `${count} ${word}${count === 1 ? "" : "s"}`
87
+ }
88
+
89
+ export function percent(share: number) {
90
+ return `${Math.round(share * 100)}%`
91
+ }
92
+
93
+ export function tilde(file: string) {
94
+ const home = homedir()
95
+ return file.startsWith(home) ? `~${file.slice(home.length)}` : file
96
+ }
97
+
98
+ export function tokens(count: number) {
99
+ for (const [scale, suffix] of [
100
+ [1e12, "T"],
101
+ [1e9, "B"],
102
+ [1e6, "M"],
103
+ [1e3, "k"],
104
+ ] as const) {
105
+ const scaled = count / scale
106
+ const shown = scaled.toFixed(scaled >= 10 ? 0 : 1)
107
+ if (Number(shown) >= 1) return `${shown}${suffix}`
108
+ }
109
+ return String(count)
110
+ }
111
+
112
+ export function ago(iso: string) {
113
+ const seconds = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 1000))
114
+ if (seconds < 60) return "just now"
115
+ if (seconds < 3_600) return `${Math.round(seconds / 60)}m ago`
116
+ if (seconds < 86_400) return `${Math.round(seconds / 3_600)}h ago`
117
+ return `${Math.round(seconds / 86_400)}d ago`
118
+ }
119
+
120
+ function editDistance(a: string, b: string) {
121
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i)
122
+ for (let i = 1; i <= a.length; i += 1) {
123
+ let previous = row[0]
124
+ row[0] = i
125
+ for (let j = 1; j <= b.length; j += 1) {
126
+ const current = row[j]
127
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1))
128
+ previous = current
129
+ }
130
+ }
131
+ return row[b.length]
132
+ }
133
+
134
+ export function closest(input: string, options: string[]) {
135
+ const [best] = options.map((option) => [option, editDistance(input, option)] as const).sort((x, y) => x[1] - y[1])
136
+ return best && best[1] <= 2 ? best[0] : null
137
+ }
@@ -1,15 +1,114 @@
1
1
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
2
2
  import { homedir } from "node:os"
3
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
4
+ import { EUNOE_DIR, paths } from "./paths"
5
+
6
+ export type Mode = "off" | "compact" | "default"
7
+ export type SearchMode = "jsonl" | "markdown" | "xml" | "qmd"
8
+ type Scope = "all" | "sessions"
9
+
10
+ interface EvalConfig {
11
+ recallOptions?: { maxChars?: number; maxCharsMulti?: number; perQuery?: number }
12
+ strategy?: "tail" | "guide" | "trim"
13
+ rolling?: { keep: number; budget: number }
14
+ forceCut?: boolean
15
+ trustHeaders?: boolean
16
+ evidence?: boolean
17
+ recall?: boolean
18
+ rerank?: { model: string; candidates?: number; endpoint?: string }
19
+ }
20
+
21
+ export interface Tunables {
22
+ compactAt: number
23
+ keepTurns: number | "all"
24
+ keep: number
25
+ budget: number
26
+ turnCap: number | "off"
27
+ search: SearchMode
28
+ rerank: string
29
+ }
30
+
31
+ export type Tunable = keyof Tunables
32
+
33
+ interface SessionSettings extends Partial<Tunables> {
34
+ mode?: Mode
35
+ }
36
+
37
+ export interface Config extends Tunables {
38
+ mode: Mode
39
+ scope: Scope
40
+ sessions: Record<string, SessionSettings>
41
+ port: number
42
+ upstream: string
43
+ claudeConfigDirs: string[]
44
+ window?: number
45
+ eval?: EvalConfig
46
+ }
47
+
48
+ export const DEFAULTS: Config = {
49
+ mode: "default",
50
+ scope: "all",
51
+ sessions: {},
52
+ port: 38663,
53
+ compactAt: 0.9,
54
+ keepTurns: "all",
55
+ keep: 3,
56
+ budget: 100_000,
57
+ turnCap: 100_000,
58
+ search: "markdown",
59
+ rerank: "jev-preview",
60
+ upstream: "https://api.anthropic.com",
61
+ claudeConfigDirs: [],
62
+ }
63
+
64
+ export const MODES: Mode[] = ["default", "compact", "off"]
65
+ export const TUNABLES: Tunable[] = ["keep", "budget", "turnCap", "compactAt", "keepTurns", "search", "rerank"]
66
+
67
+ const SEARCH_MODES: SearchMode[] = ["jsonl", "markdown", "xml", "qmd"]
68
+ const TEXT_TUNABLES: Tunable[] = ["search", "rerank"]
69
+
70
+ const isWhole = (value: unknown, min: number): value is number => Number.isInteger(value) && (value as number) >= min
71
+
72
+ const validators: { [K in Tunable]: (value: unknown) => value is Tunables[K] } = {
73
+ compactAt: (value): value is number => typeof value === "number" && value > 0 && value <= 1,
74
+ keepTurns: (value): value is number | "all" => value === "all" || isWhole(value, 0),
75
+ keep: (value): value is number => isWhole(value, 1),
76
+ budget: (value): value is number => isWhole(value, 1_000),
77
+ turnCap: (value): value is number | "off" => value === "off" || isWhole(value, 1_000),
78
+ search: (value): value is SearchMode => SEARCH_MODES.includes(value as SearchMode),
79
+ rerank: (value): value is string => typeof value === "string" && /^[\w.-]+$/.test(value),
80
+ }
81
+
82
+ const isMode = (value: unknown): value is Mode => MODES.includes(value as Mode)
83
+
84
+ const isRecord = (value: unknown): value is Record<string, unknown> => Boolean(value) && typeof value === "object" && !Array.isArray(value)
85
+
86
+ export function parseTunable<K extends Tunable>(key: K, raw: string): Tunables[K] | null {
87
+ const value = TEXT_TUNABLES.includes(key) || raw === "all" || raw === "off" ? raw : Number(raw.replace(/[_,]/g, ""))
88
+ return validators[key](value) ? (value as Tunables[K]) : null
89
+ }
90
+
91
+ function normalizeSession(value: unknown): SessionSettings | null {
92
+ if (value === null) return {}
93
+ if (isMode(value)) return { mode: value }
94
+ if (!isRecord(value)) return null
95
+ const settings: SessionSettings = isMode(value.mode) ? { mode: value.mode } : {}
96
+ for (const key of TUNABLES) if (validators[key](value[key])) Object.assign(settings, { [key]: value[key] })
97
+ return settings
98
+ }
99
+
100
+ export function normalizeConfig(raw: Record<string, unknown>): Config {
101
+ const config = { ...DEFAULTS, ...raw } as Config
102
+ if (!isMode(config.mode)) config.mode = DEFAULTS.mode
103
+ if (config.scope !== "all" && config.scope !== "sessions") config.scope = DEFAULTS.scope
104
+ for (const key of TUNABLES) if (!validators[key](config[key])) Object.assign(config, { [key]: DEFAULTS[key] })
105
+ const sessions = isRecord(config.sessions) ? config.sessions : {}
106
+ config.sessions = Object.fromEntries(
107
+ Object.entries(sessions).flatMap(([id, value]) => {
108
+ const settings = normalizeSession(value)
109
+ return settings ? [[id, settings]] : []
110
+ }),
111
+ )
13
112
  if (!Number.isInteger(config.port)) config.port = DEFAULTS.port
14
113
  if (!Array.isArray(config.claudeConfigDirs)) config.claudeConfigDirs = []
15
114
  return config
@@ -17,7 +116,7 @@ export function normalizeConfig(raw: Record<string, any>): Config {
17
116
 
18
117
  let cached: { mtimeMs: number; config: Config } | null = null
19
118
 
20
- export function loadConfig(): Config {
119
+ export function readConfig(): Config {
21
120
  let mtimeMs: number
22
121
  try {
23
122
  mtimeMs = statSync(paths.config).mtimeMs
@@ -28,9 +127,7 @@ export function loadConfig(): Config {
28
127
  let config = DEFAULTS
29
128
  try {
30
129
  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
- }
130
+ } catch {}
34
131
  cached = { mtimeMs, config }
35
132
  return config
36
133
  }
@@ -38,7 +135,7 @@ export function loadConfig(): Config {
38
135
  export function saveConfig(patch: Partial<Config>) {
39
136
  mkdirSync(EUNOE_DIR, { recursive: true })
40
137
  const current = existsSync(paths.config) ? JSON.parse(readFileSync(paths.config, "utf8")) : {}
41
- writeFileSync(paths.config, JSON.stringify({ ...current, ...patch }, null, 2) + "\n")
138
+ writeFileSync(paths.config, `${JSON.stringify({ ...current, ...patch }, null, 2)}\n`)
42
139
  }
43
140
 
44
141
  export function claudeConfigDirs(config: Config): string[] {
@@ -47,3 +144,20 @@ export function claudeConfigDirs(config: Config): string[] {
47
144
  for (const dir of config.claudeConfigDirs) dirs.add(dir.replace(/^~(?=$|\/)/, homedir()))
48
145
  return [...dirs]
49
146
  }
147
+
148
+ export function modeFor(config: Config, sessionId: string | null): Mode {
149
+ if (sessionId && sessionId in config.sessions) return config.sessions[sessionId].mode ?? config.mode
150
+ return config.scope === "all" ? config.mode : "off"
151
+ }
152
+
153
+ export function compactionOwner(config: Config): Mode {
154
+ return modeFor(config, null)
155
+ }
156
+
157
+ export function overrides(settings: SessionSettings | undefined): Partial<Tunables> {
158
+ return Object.fromEntries(TUNABLES.filter((key) => settings?.[key] !== undefined).map((key) => [key, settings?.[key]]))
159
+ }
160
+
161
+ export function settingsFor(config: Config, sessionId: string | null): Config {
162
+ return { ...config, ...overrides(sessionId ? config.sessions[sessionId] : undefined), mode: modeFor(config, sessionId) }
163
+ }
@@ -1,6 +1,5 @@
1
1
  import { homedir } from "node:os"
2
2
  import path from "node:path"
3
- import type { Config, Mode, SearchMode } from "./types"
4
3
 
5
4
  export const EUNOE_DIR = process.env.EUNOE_DIR ?? path.join(homedir(), ".eunoe")
6
5
 
@@ -10,18 +9,9 @@ export const paths = {
10
9
  threads: path.join(EUNOE_DIR, "threads"),
11
10
  transcripts: path.join(EUNOE_DIR, "transcripts"),
12
11
  models: path.join(EUNOE_DIR, "models.json"),
12
+ sessions: path.join(EUNOE_DIR, "sessions.json"),
13
13
  proxyLog: path.join(EUNOE_DIR, "proxy.log"),
14
+ typesafeKey: path.join(EUNOE_DIR, "typesafe-key"),
15
+ loaded: path.join(EUNOE_DIR, "loaded"),
16
+ watch: path.join(EUNOE_DIR, "watch.json"),
14
17
  }
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"]