@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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +406 -2
  3. package/package.json +42 -4
  4. package/src/cli/args.ts +17 -0
  5. package/src/cli/claude-settings.ts +42 -0
  6. package/src/cli/commands/index.ts +23 -0
  7. package/src/cli/commands/mode.ts +66 -0
  8. package/src/cli/commands/serve.ts +20 -0
  9. package/src/cli/commands/settings.ts +100 -0
  10. package/src/cli/commands/start.ts +64 -0
  11. package/src/cli/commands/status.ts +82 -0
  12. package/src/cli/commands/stop.ts +38 -0
  13. package/src/cli/commands/uninstall.ts +27 -0
  14. package/src/cli/constants.ts +52 -0
  15. package/src/cli/help.ts +42 -0
  16. package/src/cli/inspect.ts +41 -0
  17. package/src/cli/launchd.ts +39 -0
  18. package/src/cli/logo/index.ts +89 -0
  19. package/src/cli/logo/pixels.ts +34 -0
  20. package/src/cli/main.ts +23 -0
  21. package/src/cli/prompt.ts +91 -0
  22. package/src/cli/service.ts +33 -0
  23. package/src/cli/sessions.ts +25 -0
  24. package/src/cli/stats.ts +73 -0
  25. package/src/cli/types.ts +14 -0
  26. package/src/cli/ui.ts +106 -0
  27. package/src/config/constants.ts +39 -0
  28. package/src/config/index.ts +98 -0
  29. package/src/config/types.ts +50 -0
  30. package/src/context/constants.ts +42 -0
  31. package/src/context/content.ts +46 -0
  32. package/src/context/evidence.ts +92 -0
  33. package/src/context/prompts.ts +66 -0
  34. package/src/context/recall.ts +155 -0
  35. package/src/context/rerank.ts +44 -0
  36. package/src/context/strategies.ts +89 -0
  37. package/src/context/turns.ts +87 -0
  38. package/src/context/types.ts +66 -0
  39. package/src/proxy/canonical.ts +32 -0
  40. package/src/proxy/compaction.ts +110 -0
  41. package/src/proxy/constants.ts +29 -0
  42. package/src/proxy/estimate.ts +26 -0
  43. package/src/proxy/fingerprint.ts +15 -0
  44. package/src/proxy/log.ts +9 -0
  45. package/src/proxy/rewrite.ts +146 -0
  46. package/src/proxy/server.ts +138 -0
  47. package/src/proxy/sessions.ts +46 -0
  48. package/src/proxy/state.ts +27 -0
  49. package/src/proxy/types.ts +82 -0
  50. package/src/proxy/usage.ts +44 -0
  51. package/src/proxy/window.ts +50 -0
  52. package/src/transcript/constants.ts +15 -0
  53. package/src/transcript/format.ts +68 -0
  54. package/src/transcript/locate.ts +28 -0
  55. package/src/transcript/messages.ts +29 -0
  56. package/src/transcript/parse.ts +93 -0
  57. package/src/transcript/render.ts +36 -0
  58. package/src/transcript/search.ts +80 -0
  59. package/src/transcript/types.ts +43 -0
  60. 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
+ }
@@ -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)
@@ -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
+ }