@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
|
@@ -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
|
|
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 "
|
|
2
|
-
import {
|
|
3
|
-
import { withLogo } from "
|
|
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(
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
+
}
|
package/src/config/index.ts
CHANGED
|
@@ -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 {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
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
|
|
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)
|
|
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"]
|