@chroxify/eunoe 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +406 -2
- package/package.json +42 -4
- package/src/cli/args.ts +17 -0
- package/src/cli/claude-settings.ts +42 -0
- package/src/cli/commands/index.ts +23 -0
- package/src/cli/commands/mode.ts +66 -0
- package/src/cli/commands/serve.ts +20 -0
- package/src/cli/commands/settings.ts +100 -0
- package/src/cli/commands/start.ts +64 -0
- package/src/cli/commands/status.ts +82 -0
- package/src/cli/commands/stop.ts +38 -0
- package/src/cli/commands/uninstall.ts +27 -0
- package/src/cli/constants.ts +52 -0
- package/src/cli/help.ts +42 -0
- package/src/cli/inspect.ts +41 -0
- package/src/cli/launchd.ts +39 -0
- package/src/cli/logo/index.ts +89 -0
- package/src/cli/logo/pixels.ts +34 -0
- package/src/cli/main.ts +23 -0
- package/src/cli/prompt.ts +91 -0
- package/src/cli/service.ts +33 -0
- package/src/cli/sessions.ts +25 -0
- package/src/cli/stats.ts +73 -0
- package/src/cli/types.ts +14 -0
- package/src/cli/ui.ts +106 -0
- package/src/config/constants.ts +39 -0
- package/src/config/index.ts +98 -0
- package/src/config/types.ts +50 -0
- package/src/context/constants.ts +42 -0
- package/src/context/content.ts +46 -0
- package/src/context/evidence.ts +92 -0
- package/src/context/prompts.ts +66 -0
- package/src/context/recall.ts +155 -0
- package/src/context/rerank.ts +44 -0
- package/src/context/strategies.ts +89 -0
- package/src/context/turns.ts +87 -0
- package/src/context/types.ts +66 -0
- package/src/proxy/canonical.ts +32 -0
- package/src/proxy/compaction.ts +110 -0
- package/src/proxy/constants.ts +29 -0
- package/src/proxy/estimate.ts +26 -0
- package/src/proxy/fingerprint.ts +15 -0
- package/src/proxy/log.ts +9 -0
- package/src/proxy/rewrite.ts +146 -0
- package/src/proxy/server.ts +138 -0
- package/src/proxy/sessions.ts +46 -0
- package/src/proxy/state.ts +27 -0
- package/src/proxy/types.ts +82 -0
- package/src/proxy/usage.ts +44 -0
- package/src/proxy/window.ts +50 -0
- package/src/transcript/constants.ts +15 -0
- package/src/transcript/format.ts +68 -0
- package/src/transcript/locate.ts +28 -0
- package/src/transcript/messages.ts +29 -0
- package/src/transcript/parse.ts +93 -0
- package/src/transcript/render.ts +36 -0
- package/src/transcript/search.ts +80 -0
- package/src/transcript/types.ts +43 -0
- package/src/version.ts +3 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { c, useColor } from "./ui"
|
|
2
|
+
|
|
3
|
+
export interface Choice<T extends string = string> {
|
|
4
|
+
value: T
|
|
5
|
+
label?: string
|
|
6
|
+
hint?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY)
|
|
10
|
+
|
|
11
|
+
function keys(onKey: (key: string) => boolean | void): Promise<void> {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
const stdin = process.stdin
|
|
14
|
+
stdin.setRawMode(true)
|
|
15
|
+
stdin.resume()
|
|
16
|
+
stdin.setEncoding("utf8")
|
|
17
|
+
const handler = (chunk: string) => {
|
|
18
|
+
if (onKey(chunk) === true) {
|
|
19
|
+
stdin.off("data", handler)
|
|
20
|
+
stdin.setRawMode(false)
|
|
21
|
+
stdin.pause()
|
|
22
|
+
resolve()
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
stdin.on("data", handler)
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function render(title: string, choices: Choice[], index: number, width: number) {
|
|
30
|
+
const lines = [` ${c.bold(title)}`, ""]
|
|
31
|
+
for (const [i, choice] of choices.entries()) {
|
|
32
|
+
const label = (choice.label ?? choice.value).padEnd(width)
|
|
33
|
+
const marker = i === index ? c.green("●") : c.dim("○")
|
|
34
|
+
lines.push(` ${marker} ${i === index ? c.bold(label) : label}${choice.hint ? ` ${c.dim(choice.hint)}` : ""}`)
|
|
35
|
+
}
|
|
36
|
+
lines.push("", ` ${c.dim("↑↓ move · enter choose · esc cancel")}`)
|
|
37
|
+
return lines
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function select<T extends string>(title: string, choices: Choice<T>[], current?: T): Promise<T | null> {
|
|
41
|
+
let index = Math.max(0, choices.findIndex((choice) => choice.value === current))
|
|
42
|
+
const width = Math.max(...choices.map((choice) => (choice.label ?? choice.value).length))
|
|
43
|
+
let drawn = 0
|
|
44
|
+
const draw = () => {
|
|
45
|
+
const lines = render(title, choices, index, width)
|
|
46
|
+
if (drawn) process.stdout.write(`\x1b[${drawn}A\x1b[J`)
|
|
47
|
+
process.stdout.write(lines.join("\n") + "\n")
|
|
48
|
+
drawn = lines.length
|
|
49
|
+
}
|
|
50
|
+
let chosen: T | null = null
|
|
51
|
+
process.stdout.write(useColor ? "\x1b[?25l" : "")
|
|
52
|
+
draw()
|
|
53
|
+
await keys((key) => {
|
|
54
|
+
if (key === "\x1b[A" || key === "k") index = (index + choices.length - 1) % choices.length
|
|
55
|
+
else if (key === "\x1b[B" || key === "j") index = (index + 1) % choices.length
|
|
56
|
+
else if (key === "\r" || key === "\n") {
|
|
57
|
+
chosen = choices[index].value
|
|
58
|
+
return true
|
|
59
|
+
} else if (key === "\x1b" || key === "q" || key === "\x03") return true
|
|
60
|
+
draw()
|
|
61
|
+
})
|
|
62
|
+
process.stdout.write(`\x1b[${drawn}A\x1b[J`)
|
|
63
|
+
process.stdout.write(useColor ? "\x1b[?25h" : "")
|
|
64
|
+
return chosen
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function input(title: string, placeholder: string, initial = ""): Promise<string | null> {
|
|
68
|
+
let value = initial
|
|
69
|
+
let drawn = 0
|
|
70
|
+
const draw = () => {
|
|
71
|
+
const shown = value ? value : c.dim(placeholder)
|
|
72
|
+
const lines = [` ${c.bold(title)}`, ` ${c.cyan("›")} ${shown}`, "", ` ${c.dim("enter save · esc cancel")}`]
|
|
73
|
+
if (drawn) process.stdout.write(`\x1b[${drawn}A\x1b[J`)
|
|
74
|
+
process.stdout.write(lines.join("\n") + "\n")
|
|
75
|
+
drawn = lines.length
|
|
76
|
+
}
|
|
77
|
+
let result: string | null = null
|
|
78
|
+
draw()
|
|
79
|
+
await keys((key) => {
|
|
80
|
+
if (key === "\r" || key === "\n") {
|
|
81
|
+
result = value
|
|
82
|
+
return true
|
|
83
|
+
}
|
|
84
|
+
if (key === "\x1b" || key === "\x03") return true
|
|
85
|
+
if (key === "\x7f" || key === "\b") value = value.slice(0, -1)
|
|
86
|
+
else if (key >= " " && !key.startsWith("\x1b")) value += key
|
|
87
|
+
draw()
|
|
88
|
+
})
|
|
89
|
+
process.stdout.write(`\x1b[${drawn}A\x1b[J`)
|
|
90
|
+
return result
|
|
91
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
import { paths } from "../config/constants"
|
|
3
|
+
import type { Config } from "../config/types"
|
|
4
|
+
import { proxyUrl } from "./claude-settings"
|
|
5
|
+
import { START_POLL_MS, START_TIMEOUT_MS } from "./constants"
|
|
6
|
+
import { probe } from "./inspect"
|
|
7
|
+
import * as launchd from "./launchd"
|
|
8
|
+
import { c, line, ok, warn } from "./ui"
|
|
9
|
+
|
|
10
|
+
async function waitForProxy(port: number) {
|
|
11
|
+
const deadline = Date.now() + START_TIMEOUT_MS
|
|
12
|
+
while (Date.now() < deadline) {
|
|
13
|
+
if (await probe(port)) return true
|
|
14
|
+
await Bun.sleep(START_POLL_MS)
|
|
15
|
+
}
|
|
16
|
+
return false
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function startService(config: Config) {
|
|
20
|
+
if (!launchd.supported) {
|
|
21
|
+
if (await probe(config.port)) return true
|
|
22
|
+
warn("No launchd here, so keep the proxy running yourself:")
|
|
23
|
+
line(` ${c.cyan("eunoe serve")}`)
|
|
24
|
+
return false
|
|
25
|
+
}
|
|
26
|
+
launchd.installAgent([process.execPath, path.join(import.meta.dir, "main.ts"), "serve"], paths.proxyLog)
|
|
27
|
+
if (await waitForProxy(config.port)) {
|
|
28
|
+
ok(`Proxy running in the background on ${c.cyan(proxyUrl(config.port))}`)
|
|
29
|
+
return true
|
|
30
|
+
}
|
|
31
|
+
warn(`The proxy didn't answer on ${proxyUrl(config.port)} yet. Check ${paths.proxyLog}.`)
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { SESSION_ID_PATTERN } from "../config/constants"
|
|
2
|
+
import { seenSessions } from "../proxy/sessions"
|
|
3
|
+
import { flagValues } from "./args"
|
|
4
|
+
import { SHORT_ID_LENGTH } from "./constants"
|
|
5
|
+
import { fail } from "./ui"
|
|
6
|
+
|
|
7
|
+
export const shortId = (sessionId: string) => sessionId.slice(0, SHORT_ID_LENGTH)
|
|
8
|
+
|
|
9
|
+
function resolve(input: string): string {
|
|
10
|
+
if (!SESSION_ID_PATTERN.test(input)) fail(`Not a session id: ${input}`)
|
|
11
|
+
const seen = seenSessions().map(([id]) => id)
|
|
12
|
+
if (seen.includes(input)) return input
|
|
13
|
+
const matches = seen.filter((id) => id.startsWith(input))
|
|
14
|
+
if (matches.length === 1) return matches[0]
|
|
15
|
+
if (matches.length > 1) fail(`${input} matches ${matches.length} sessions`, "Use more of the id.")
|
|
16
|
+
return input
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function sessionArgs(args: string[]): string[] {
|
|
20
|
+
return [...new Set(flagValues(args, "--session").map(resolve))]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isKnown(sessionId: string) {
|
|
24
|
+
return seenSessions().some(([id]) => id === sessionId)
|
|
25
|
+
}
|
package/src/cli/stats.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { STATUS_WINDOW_MS } from "./constants"
|
|
2
|
+
import type { RequestRecord } from "./types"
|
|
3
|
+
|
|
4
|
+
export interface SessionStats {
|
|
5
|
+
context: number
|
|
6
|
+
plain: number | null
|
|
7
|
+
turns: number | null
|
|
8
|
+
requests: number
|
|
9
|
+
folds: number
|
|
10
|
+
lastSeen: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Totals {
|
|
14
|
+
requests: number
|
|
15
|
+
cacheRead: number
|
|
16
|
+
cacheWrite: number
|
|
17
|
+
uncached: number
|
|
18
|
+
folds: number
|
|
19
|
+
lastFold: string | null
|
|
20
|
+
sent: number
|
|
21
|
+
saved: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const THREAD_SUFFIX = /-[0-9a-f]{24}$/
|
|
25
|
+
|
|
26
|
+
export const sessionOf = (record: RequestRecord): string | null => (typeof record.thread === "string" ? record.thread.replace(THREAD_SUFFIX, "") : null)
|
|
27
|
+
|
|
28
|
+
const plainTokens = (record: RequestRecord): number | null => (record.charsIn > 0 && record.charsOut > 0 && record.input > 0 ? Math.round(record.input * record.charsIn / record.charsOut) : null)
|
|
29
|
+
|
|
30
|
+
export function withinWindow(records: RequestRecord[], now = Date.now(), window = STATUS_WINDOW_MS): RequestRecord[] {
|
|
31
|
+
return records.filter((record) => now - Date.parse(record.ts) <= window)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function perSession(records: RequestRecord[]): Map<string, SessionStats> {
|
|
35
|
+
const stats = new Map<string, SessionStats>()
|
|
36
|
+
for (const record of records) {
|
|
37
|
+
const id = sessionOf(record)
|
|
38
|
+
if (!id || !(record.input > 0)) continue
|
|
39
|
+
const previous = stats.get(id)
|
|
40
|
+
stats.set(id, {
|
|
41
|
+
context: record.input,
|
|
42
|
+
plain: plainTokens(record),
|
|
43
|
+
turns: typeof record.turns === "number" ? record.turns : previous?.turns ?? null,
|
|
44
|
+
requests: (previous?.requests ?? 0) + 1,
|
|
45
|
+
folds: (previous?.folds ?? 0) + (record.compacted ? 1 : 0),
|
|
46
|
+
lastSeen: record.ts,
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
return stats
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function totals(records: RequestRecord[]): Totals {
|
|
53
|
+
const out: Totals = { requests: 0, cacheRead: 0, cacheWrite: 0, uncached: 0, folds: 0, lastFold: null, sent: 0, saved: 0 }
|
|
54
|
+
for (const record of records) {
|
|
55
|
+
if (!(record.input > 0)) continue
|
|
56
|
+
out.requests += 1
|
|
57
|
+
out.cacheRead += record.cacheRead ?? 0
|
|
58
|
+
out.cacheWrite += record.cacheWrite ?? 0
|
|
59
|
+
out.uncached += record.uncached ?? 0
|
|
60
|
+
if (record.compacted) {
|
|
61
|
+
out.folds += 1
|
|
62
|
+
out.lastFold = record.ts
|
|
63
|
+
}
|
|
64
|
+
const plain = plainTokens(record)
|
|
65
|
+
if (plain !== null) {
|
|
66
|
+
out.sent += record.input
|
|
67
|
+
out.saved += plain - record.input
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const savedShare = (context: number, plain: number | null): number | null => (plain && plain > context ? 1 - context / plain : plain ? 0 : null)
|
package/src/cli/types.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
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
|
+
hidden?: boolean
|
|
11
|
+
run: (args: string[]) => void | Promise<void>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type RequestRecord = Record<string, any>
|
package/src/cli/ui.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
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
|
+
const visible = (text: string) => text.replace(/\x1b\[[0-9;]*m/g, "")
|
|
57
|
+
|
|
58
|
+
export function table(entries: string[][], indent = 4, gap = 2) {
|
|
59
|
+
const columns = Math.max(...entries.map((row) => row.length))
|
|
60
|
+
const widths = Array.from({ length: columns }, (_, column) => Math.max(...entries.map((row) => visible(row[column] ?? "").length)))
|
|
61
|
+
for (const row of entries) {
|
|
62
|
+
const cells = widths.map((width, column) => (row[column] ?? "") + " ".repeat(Math.max(0, width - visible(row[column] ?? "").length)))
|
|
63
|
+
console.log(`${" ".repeat(indent)}${cells.join(" ".repeat(gap)).trimEnd()}`)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function percent(share: number) {
|
|
68
|
+
return `${Math.round(share * 100)}%`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function tilde(file: string) {
|
|
72
|
+
const home = homedir()
|
|
73
|
+
return file.startsWith(home) ? `~${file.slice(home.length)}` : file
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function tokens(count: number) {
|
|
77
|
+
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(count >= 10_000_000 ? 0 : 1)}M`
|
|
78
|
+
if (count >= 1_000) return `${(count / 1_000).toFixed(count >= 10_000 ? 0 : 1)}k`
|
|
79
|
+
return String(count)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function ago(iso: string) {
|
|
83
|
+
const seconds = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 1000))
|
|
84
|
+
if (seconds < 60) return "just now"
|
|
85
|
+
if (seconds < 3_600) return `${Math.round(seconds / 60)}m ago`
|
|
86
|
+
if (seconds < 86_400) return `${Math.round(seconds / 3_600)}h ago`
|
|
87
|
+
return `${Math.round(seconds / 86_400)}d ago`
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function closest(input: string, options: string[]) {
|
|
91
|
+
const distance = (a: string, b: string) => {
|
|
92
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i)
|
|
93
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
94
|
+
let previous = row[0]
|
|
95
|
+
row[0] = i
|
|
96
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
97
|
+
const current = row[j]
|
|
98
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1))
|
|
99
|
+
previous = current
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return row[b.length]
|
|
103
|
+
}
|
|
104
|
+
const [best] = options.map((option) => [option, distance(input, option)] as const).sort((x, y) => x[1] - y[1])
|
|
105
|
+
return best && best[1] <= TYPO_DISTANCE ? best[0] : null
|
|
106
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { homedir } from "node:os"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import type { Config, Mode, Scope, SearchMode, Tunable } 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
|
+
sessions: path.join(EUNOE_DIR, "sessions.json"),
|
|
14
|
+
proxyLog: path.join(EUNOE_DIR, "proxy.log"),
|
|
15
|
+
typesafeKey: path.join(EUNOE_DIR, "typesafe-key"),
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const RERANK_OFF = "off"
|
|
19
|
+
|
|
20
|
+
export const DEFAULTS: Config = {
|
|
21
|
+
mode: "default",
|
|
22
|
+
scope: "all",
|
|
23
|
+
sessions: {},
|
|
24
|
+
port: 8788,
|
|
25
|
+
compactAt: 0.9,
|
|
26
|
+
keepTurns: "all",
|
|
27
|
+
keep: 3,
|
|
28
|
+
budget: 100_000,
|
|
29
|
+
search: "markdown",
|
|
30
|
+
rerank: "jev-preview",
|
|
31
|
+
upstream: "https://api.anthropic.com",
|
|
32
|
+
claudeConfigDirs: [],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const MODES: Mode[] = ["default", "compact", "off"]
|
|
36
|
+
export const SEARCH_MODES: SearchMode[] = ["jsonl", "markdown", "xml", "qmd"]
|
|
37
|
+
export const TUNABLES: Tunable[] = ["keep", "budget", "compactAt", "keepTurns", "search", "rerank"]
|
|
38
|
+
export const SCOPES: Scope[] = ["all", "sessions"]
|
|
39
|
+
export const SESSION_ID_PATTERN = /^[a-zA-Z0-9-]+$/
|
|
@@ -0,0 +1,98 @@
|
|
|
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, SCOPES, SEARCH_MODES, TUNABLES } from "./constants"
|
|
5
|
+
import type { Config, Mode, SessionSettings, Tunable, Tunables } from "./types"
|
|
6
|
+
|
|
7
|
+
const VALID: { [K in Tunable]: (value: unknown) => value is Tunables[K] } = {
|
|
8
|
+
compactAt: (value): value is number => typeof value === "number" && value > 0 && value <= 1,
|
|
9
|
+
keepTurns: (value): value is number | "all" => value === "all" || (Number.isInteger(value) && (value as number) >= 0),
|
|
10
|
+
keep: (value): value is number => Number.isInteger(value) && (value as number) >= 1,
|
|
11
|
+
budget: (value): value is number => Number.isInteger(value) && (value as number) >= 1_000,
|
|
12
|
+
search: (value): value is Tunables["search"] => SEARCH_MODES.includes(value as Tunables["search"]),
|
|
13
|
+
rerank: (value): value is string => typeof value === "string" && /^[\w.-]+$/.test(value),
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const TEXT_TUNABLES: Tunable[] = ["search", "rerank"]
|
|
17
|
+
|
|
18
|
+
const isMode = (value: unknown): value is Mode => MODES.includes(value as Mode)
|
|
19
|
+
|
|
20
|
+
export function parseTunable<K extends Tunable>(key: K, raw: string): Tunables[K] | null {
|
|
21
|
+
const value = TEXT_TUNABLES.includes(key) || raw === "all" ? raw : Number(raw.replace(/[_,]/g, ""))
|
|
22
|
+
return VALID[key](value) ? value as Tunables[K] : null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizeSession(value: unknown): SessionSettings | null {
|
|
26
|
+
if (value === null) return {}
|
|
27
|
+
if (isMode(value)) return { mode: value }
|
|
28
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null
|
|
29
|
+
const raw = value as Record<string, unknown>
|
|
30
|
+
const settings: SessionSettings = isMode(raw.mode) ? { mode: raw.mode } : {}
|
|
31
|
+
for (const key of TUNABLES) if (VALID[key](raw[key])) Object.assign(settings, { [key]: raw[key] })
|
|
32
|
+
return settings
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function normalizeConfig(raw: Record<string, any>): Config {
|
|
36
|
+
const config: Config = { ...DEFAULTS, ...raw }
|
|
37
|
+
if (!isMode(config.mode)) config.mode = DEFAULTS.mode
|
|
38
|
+
if (!SCOPES.includes(config.scope)) config.scope = DEFAULTS.scope
|
|
39
|
+
for (const key of TUNABLES) if (!VALID[key](config[key])) Object.assign(config, { [key]: DEFAULTS[key] })
|
|
40
|
+
const sessions = config.sessions && typeof config.sessions === "object" && !Array.isArray(config.sessions) ? config.sessions : {}
|
|
41
|
+
config.sessions = Object.fromEntries(Object.entries(sessions).flatMap(([id, value]) => {
|
|
42
|
+
const settings = normalizeSession(value)
|
|
43
|
+
return settings ? [[id, settings]] : []
|
|
44
|
+
}))
|
|
45
|
+
if (!Number.isInteger(config.port)) config.port = DEFAULTS.port
|
|
46
|
+
if (!Array.isArray(config.claudeConfigDirs)) config.claudeConfigDirs = []
|
|
47
|
+
return config
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let cached: { mtimeMs: number; config: Config } | null = null
|
|
51
|
+
|
|
52
|
+
export function loadConfig(): Config {
|
|
53
|
+
let mtimeMs: number
|
|
54
|
+
try {
|
|
55
|
+
mtimeMs = statSync(paths.config).mtimeMs
|
|
56
|
+
} catch {
|
|
57
|
+
return DEFAULTS
|
|
58
|
+
}
|
|
59
|
+
if (cached?.mtimeMs === mtimeMs) return cached.config
|
|
60
|
+
let config = DEFAULTS
|
|
61
|
+
try {
|
|
62
|
+
config = normalizeConfig(JSON.parse(readFileSync(paths.config, "utf8")))
|
|
63
|
+
} catch (error) {
|
|
64
|
+
console.error(`[eunoe] ${paths.config} is not valid JSON; using defaults`, error)
|
|
65
|
+
}
|
|
66
|
+
cached = { mtimeMs, config }
|
|
67
|
+
return config
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function saveConfig(patch: Partial<Config>) {
|
|
71
|
+
mkdirSync(EUNOE_DIR, { recursive: true })
|
|
72
|
+
const current = existsSync(paths.config) ? JSON.parse(readFileSync(paths.config, "utf8")) : {}
|
|
73
|
+
writeFileSync(paths.config, JSON.stringify({ ...current, ...patch }, null, 2) + "\n")
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function claudeConfigDirs(config: Config): string[] {
|
|
77
|
+
const dirs = new Set([path.join(homedir(), ".claude")])
|
|
78
|
+
if (process.env.CLAUDE_CONFIG_DIR) dirs.add(process.env.CLAUDE_CONFIG_DIR)
|
|
79
|
+
for (const dir of config.claudeConfigDirs) dirs.add(dir.replace(/^~(?=$|\/)/, homedir()))
|
|
80
|
+
return [...dirs]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function modeFor(config: Config, sessionId: string | null): Mode {
|
|
84
|
+
if (sessionId && sessionId in config.sessions) return config.sessions[sessionId].mode ?? config.mode
|
|
85
|
+
return config.scope === "all" ? config.mode : "off"
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function overrides(settings: SessionSettings | undefined): Partial<Tunables> {
|
|
89
|
+
return Object.fromEntries(TUNABLES.filter((key) => settings?.[key] !== undefined).map((key) => [key, settings![key]]))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function settingsFor(config: Config, sessionId: string | null): Config {
|
|
93
|
+
return { ...config, ...overrides(sessionId ? config.sessions[sessionId] : undefined), mode: modeFor(config, sessionId) }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function compactionOwner(config: Config): Mode {
|
|
97
|
+
return config.scope === "all" ? config.mode : "off"
|
|
98
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export type Mode = "off" | "compact" | "default"
|
|
2
|
+
export type SearchMode = "jsonl" | "markdown" | "xml" | "qmd"
|
|
3
|
+
export type Scope = "all" | "sessions"
|
|
4
|
+
|
|
5
|
+
export interface RollingConfig {
|
|
6
|
+
keep: number
|
|
7
|
+
budget: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface RerankConfig {
|
|
11
|
+
model: string
|
|
12
|
+
candidates?: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface EvalConfig {
|
|
16
|
+
strategy?: "tail" | "guide" | "trim"
|
|
17
|
+
rolling?: RollingConfig
|
|
18
|
+
tailTurns?: number
|
|
19
|
+
forceCut?: boolean
|
|
20
|
+
trustHeaders?: boolean
|
|
21
|
+
evidence?: boolean
|
|
22
|
+
recall?: boolean
|
|
23
|
+
rerank?: RerankConfig
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Tunables {
|
|
27
|
+
compactAt: number
|
|
28
|
+
keepTurns: number | "all"
|
|
29
|
+
keep: number
|
|
30
|
+
budget: number
|
|
31
|
+
search: SearchMode
|
|
32
|
+
rerank: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type Tunable = keyof Tunables
|
|
36
|
+
|
|
37
|
+
export interface SessionSettings extends Partial<Tunables> {
|
|
38
|
+
mode?: Mode
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface Config extends Tunables {
|
|
42
|
+
mode: Mode
|
|
43
|
+
scope: Scope
|
|
44
|
+
sessions: Record<string, SessionSettings>
|
|
45
|
+
port: number
|
|
46
|
+
upstream: string
|
|
47
|
+
claudeConfigDirs: string[]
|
|
48
|
+
window?: number
|
|
49
|
+
eval?: EvalConfig
|
|
50
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
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
|
|
8
|
+
|
|
9
|
+
export const REMINDER_ONLY = /^\s*(?:<system-reminder>[\s\S]*?<\/system-reminder>\s*)+$/
|
|
10
|
+
|
|
11
|
+
export const REREADABLE_TOOLS = new Set(["Read", "Grep", "Glob", "LS", "NotebookRead", "WebFetch", "WebSearch", "TodoWrite", "Edit", "Write", "MultiEdit", "NotebookEdit"])
|
|
12
|
+
export const EVIDENCE_LINES_PER_CALL = 12
|
|
13
|
+
export const EVIDENCE_LINE_CHARS = 220
|
|
14
|
+
export const EVIDENCE_TURN_CHARS = 4_000
|
|
15
|
+
export const EVIDENCE_SCAN_LINES = 1_000_000
|
|
16
|
+
export const EVIDENCE_MIN_SCORE = 2
|
|
17
|
+
export const EVIDENCE_RANKED = true
|
|
18
|
+
export const EVIDENCE_LABELS = true
|
|
19
|
+
export const EVIDENCE_CALL_FLOOR = 2
|
|
20
|
+
export const ERROR_LINE = /\b(?:error|errors|fail|fails|failed|failure|fatal|exception|panic|denied|refused|timed? ?out|traceback|cannot|unable to)\b/i
|
|
21
|
+
export const ERROR_CODE = /\b[A-Z][A-Z0-9]*_[A-Z0-9_]{2,}\b|\bSIG[A-Z]{2,}\d*\b|\b[A-Z]{2,}\d{2,}\b|\bE(?:NOENT|ACCES|PERM|EXIST|CONN(?:REFUSED|RESET|ABORTED)|PIPE|ADDRINUSE|TIMEDOUT|NOTFOUND|INVAL|BUSY|MFILE|AGAIN|HOSTUNREACH|NOTDIR|ISDIR|BADF|NOSPC|AI_[A-Z]+)\b/
|
|
22
|
+
export const VALUE_TOKEN = /\b[a-z]{2,}[_-](?=[a-z0-9]*\d)[a-z0-9]{6,}\b|\b(?=[0-9a-f]*\d)(?=[0-9a-f]*[a-f])[0-9a-f]{7,}\b|https?:\/\/[^\s)"']+|\b\d{4,}\b|\b\d{6,}_[a-z0-9_]{3,}\b|\bv?\d+\.\d+\.\d+\b|\b\d+(?:\.\d+)?\s?(?:ms|s|[KMGT]i?B|kB|%)(?![a-z])|\bsha\d*:[0-9a-f]+|@[a-z][\w-]+|--[a-z][\w-]*=\S+|\b\d+ (?:pass(?:ed|ing)?|fail(?:ed|ing)?|tests?|files? changed|insertions?|deletions?|rows?|errors?|warnings?)\b|[$€£]\d+(?:[.,]\d+)?|\b(?:status|HTTP\/\d(?:\.\d)?)\s*[:=]?\s*[45]\d{2}\b|\bexit(?: code| status)?\s*[:=]?\s*(?!0\b)\d{1,3}\b/i
|
|
23
|
+
export const ID_TOKEN = /\b[a-z]{2,}[_-](?=[a-z0-9]*\d)[a-z0-9]{6,}\b|\b(?=[0-9a-f]*\d)(?=[0-9a-f]*[a-f])[0-9a-f]{7,}\b|https?:\/\/[^\s)"']+|\b\d{6,}_[a-z0-9_]{3,}\b|\bv?\d+\.\d+\.\d+\b|\bsha\d*:[0-9a-f]+|@[a-z][\w-]+|--[a-z][\w-]*=\S+/i
|
|
24
|
+
export const KEY_VALUE =/^[\w .\-/()]{2,48}[:=]\s*\S/
|
|
25
|
+
export const VALUE_LABEL = /\b(?:ids?|hash|sha(?:256)?|checksum|trace|deploy(?:ment)?|preview|url|version|rows|p9[59]|exit(?: code)?|signal|request[_-]?id|job|migration|applied|bundle|build|status|took|updated|created)\b/i
|
|
26
|
+
|
|
27
|
+
export const RECALL_WINDOW_LINES = 6
|
|
28
|
+
export const RECALL_SHOWN_LINES = 8
|
|
29
|
+
export const RECALL_PER_QUERY = 12
|
|
30
|
+
export const RECALL_MAX_CHARS = 18_000
|
|
31
|
+
export const RECALL_MAX_CHARS_MULTI = 30_000
|
|
32
|
+
export const RECALL_MIN_TERMS = 2
|
|
33
|
+
export const RECALL_SALIENT_EXCERPT = true
|
|
34
|
+
export const RECALL_NARRATIVE = true
|
|
35
|
+
export const RECALL_LINE_CHARS = 240
|
|
36
|
+
export const RECALL_RERANK_CANDIDATES = 30
|
|
37
|
+
export const RERANK_ENDPOINT = "https://api.typesafe.ai/v1/systemone"
|
|
38
|
+
export const RERANK_TIMEOUT_MS = 4_000
|
|
39
|
+
export const RERANK_LINE_CHARS = 240
|
|
40
|
+
export const BM25_K1 = 1.2
|
|
41
|
+
export const BM25_B = 0.75
|
|
42
|
+
export const STOPWORDS = new Set("the and for with that this what which when where who how was were did does done from into onto have has had you your our out not but are can could would should will just then than them they there their its it's about after before again also any each other some such only own same very more most over under while because been being into".split(" "))
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { SUMMARY_ARGUMENT_CHARS, SUMMARY_COMMAND_CHARS } from "./constants"
|
|
2
|
+
import type { Block, Message } from "./types"
|
|
3
|
+
|
|
4
|
+
export function contentBlocks(content: unknown): Block[] {
|
|
5
|
+
return typeof content === "string" ? [{ type: "text", text: content }] : Array.isArray(content) ? content : []
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function blocks(message: Message): Block[] {
|
|
9
|
+
return contentBlocks(message.content)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function withoutCacheControl(block: Block): Block {
|
|
13
|
+
if (!("cache_control" in block)) return block
|
|
14
|
+
const { cache_control: _, ...rest } = block
|
|
15
|
+
return rest
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function clean(message: Message): Message {
|
|
19
|
+
return { role: message.role, content: blocks(message).map(withoutCacheControl) }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function truncate(text: string, max: number): string {
|
|
23
|
+
const cut = text.slice(0, max)
|
|
24
|
+
return /[\uD800-\uDBFF]$/.test(cut) ? cut.slice(0, -1) : cut
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function wellFormed(_key: string, value: unknown): unknown {
|
|
28
|
+
return typeof value === "string" ? value.toWellFormed() : value
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function clip(value: unknown, max: number): string {
|
|
32
|
+
const text = String(value).replace(/\s+/g, " ").trim()
|
|
33
|
+
return text.length > max ? `${truncate(text, max)}…` : text
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function describeToolCall(block: Block): string {
|
|
37
|
+
const input = block.input ?? {}
|
|
38
|
+
if (typeof input.command === "string") return `${block.name} \`${clip(input.command, SUMMARY_COMMAND_CHARS)}\``
|
|
39
|
+
if (typeof input.file_path === "string") return `${block.name} ${input.file_path}`
|
|
40
|
+
if (typeof input.pattern === "string") return `${block.name} "${clip(input.pattern, SUMMARY_ARGUMENT_CHARS)}"`
|
|
41
|
+
return `${block.name} ${clip(JSON.stringify(input), SUMMARY_ARGUMENT_CHARS)}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function resultText(block: Block): string {
|
|
45
|
+
return typeof block.content === "string" ? block.content : contentBlocks(block.content).filter((b) => b.type === "text").map((b) => String(b.text ?? "")).join("\n")
|
|
46
|
+
}
|