@chroxify/eunoe 0.1.0 → 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/README.md +315 -236
- package/package.json +3 -2
- package/src/cli/args.ts +1 -1
- package/src/cli/commands/index.ts +16 -2
- package/src/cli/commands/mode.ts +53 -17
- package/src/cli/commands/serve.ts +2 -1
- package/src/cli/commands/settings.ts +100 -0
- package/src/cli/commands/start.ts +64 -0
- package/src/cli/commands/status.ts +50 -16
- package/src/cli/commands/stop.ts +33 -11
- package/src/cli/commands/uninstall.ts +11 -9
- package/src/cli/constants.ts +28 -5
- package/src/cli/help.ts +2 -2
- package/src/cli/launchd.ts +8 -3
- 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 +1 -0
- package/src/cli/ui.ts +15 -0
- package/src/config/constants.ts +15 -3
- package/src/config/index.ts +55 -6
- package/src/config/types.ts +34 -5
- package/src/context/constants.ts +35 -0
- package/src/context/content.ts +23 -1
- package/src/context/evidence.ts +92 -0
- package/src/context/recall.ts +155 -0
- package/src/context/rerank.ts +44 -0
- package/src/context/strategies.ts +6 -6
- package/src/context/turns.ts +19 -14
- package/src/context/types.ts +43 -1
- package/src/proxy/canonical.ts +32 -0
- package/src/proxy/compaction.ts +29 -12
- package/src/proxy/constants.ts +10 -0
- package/src/proxy/fingerprint.ts +15 -0
- package/src/proxy/rewrite.ts +84 -10
- package/src/proxy/server.ts +30 -12
- package/src/proxy/sessions.ts +46 -0
- package/src/proxy/types.ts +24 -0
- package/src/transcript/constants.ts +0 -1
- package/src/transcript/locate.ts +1 -1
- package/src/transcript/messages.ts +29 -0
- package/src/cli/commands/install.ts +0 -44
|
@@ -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
CHANGED
package/src/cli/ui.ts
CHANGED
|
@@ -53,6 +53,21 @@ export function rows(entries: Array<[string, string]>, indent = 2) {
|
|
|
53
53
|
for (const [label, value] of entries) console.log(`${" ".repeat(indent)}${c.dim(label.padEnd(width))} ${value}`)
|
|
54
54
|
}
|
|
55
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
|
+
|
|
56
71
|
export function tilde(file: string) {
|
|
57
72
|
const home = homedir()
|
|
58
73
|
return file.startsWith(home) ? `~${file.slice(home.length)}` : file
|
package/src/config/constants.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { homedir } from "node:os"
|
|
2
2
|
import path from "node:path"
|
|
3
|
-
import type { Config, Mode, SearchMode } from "./types"
|
|
3
|
+
import type { Config, Mode, Scope, SearchMode, Tunable } from "./types"
|
|
4
4
|
|
|
5
5
|
export const EUNOE_DIR = process.env.EUNOE_DIR ?? path.join(homedir(), ".eunoe")
|
|
6
6
|
|
|
@@ -10,18 +10,30 @@ export const paths = {
|
|
|
10
10
|
threads: path.join(EUNOE_DIR, "threads"),
|
|
11
11
|
transcripts: path.join(EUNOE_DIR, "transcripts"),
|
|
12
12
|
models: path.join(EUNOE_DIR, "models.json"),
|
|
13
|
+
sessions: path.join(EUNOE_DIR, "sessions.json"),
|
|
13
14
|
proxyLog: path.join(EUNOE_DIR, "proxy.log"),
|
|
15
|
+
typesafeKey: path.join(EUNOE_DIR, "typesafe-key"),
|
|
14
16
|
}
|
|
15
17
|
|
|
18
|
+
export const RERANK_OFF = "off"
|
|
19
|
+
|
|
16
20
|
export const DEFAULTS: Config = {
|
|
17
|
-
mode: "
|
|
21
|
+
mode: "default",
|
|
22
|
+
scope: "all",
|
|
23
|
+
sessions: {},
|
|
18
24
|
port: 8788,
|
|
19
25
|
compactAt: 0.9,
|
|
20
26
|
keepTurns: "all",
|
|
27
|
+
keep: 3,
|
|
28
|
+
budget: 100_000,
|
|
21
29
|
search: "markdown",
|
|
30
|
+
rerank: "jev-preview",
|
|
22
31
|
upstream: "https://api.anthropic.com",
|
|
23
32
|
claudeConfigDirs: [],
|
|
24
33
|
}
|
|
25
34
|
|
|
26
|
-
export const MODES: Mode[] = ["
|
|
35
|
+
export const MODES: Mode[] = ["default", "compact", "off"]
|
|
27
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-]+$/
|
package/src/config/index.ts
CHANGED
|
@@ -1,15 +1,47 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
|
|
2
2
|
import { homedir } from "node:os"
|
|
3
3
|
import path from "node:path"
|
|
4
|
-
import { DEFAULTS, EUNOE_DIR, MODES, paths, SEARCH_MODES } from "./constants"
|
|
5
|
-
import type { Config } from "./types"
|
|
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
|
+
}
|
|
6
34
|
|
|
7
35
|
export function normalizeConfig(raw: Record<string, any>): Config {
|
|
8
36
|
const config: Config = { ...DEFAULTS, ...raw }
|
|
9
|
-
if (!
|
|
10
|
-
if (!
|
|
11
|
-
|
|
12
|
-
|
|
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
|
+
}))
|
|
13
45
|
if (!Number.isInteger(config.port)) config.port = DEFAULTS.port
|
|
14
46
|
if (!Array.isArray(config.claudeConfigDirs)) config.claudeConfigDirs = []
|
|
15
47
|
return config
|
|
@@ -47,3 +79,20 @@ export function claudeConfigDirs(config: Config): string[] {
|
|
|
47
79
|
for (const dir of config.claudeConfigDirs) dirs.add(dir.replace(/^~(?=$|\/)/, homedir()))
|
|
48
80
|
return [...dirs]
|
|
49
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
|
+
}
|
package/src/config/types.ts
CHANGED
|
@@ -1,19 +1,48 @@
|
|
|
1
|
-
export type Mode = "off" | "
|
|
1
|
+
export type Mode = "off" | "compact" | "default"
|
|
2
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
|
+
}
|
|
3
14
|
|
|
4
15
|
export interface EvalConfig {
|
|
5
|
-
strategy?: "tail" | "guide"
|
|
16
|
+
strategy?: "tail" | "guide" | "trim"
|
|
17
|
+
rolling?: RollingConfig
|
|
6
18
|
tailTurns?: number
|
|
7
19
|
forceCut?: boolean
|
|
8
20
|
trustHeaders?: boolean
|
|
21
|
+
evidence?: boolean
|
|
22
|
+
recall?: boolean
|
|
23
|
+
rerank?: RerankConfig
|
|
9
24
|
}
|
|
10
25
|
|
|
11
|
-
export interface
|
|
12
|
-
mode: Mode
|
|
13
|
-
port: number
|
|
26
|
+
export interface Tunables {
|
|
14
27
|
compactAt: number
|
|
15
28
|
keepTurns: number | "all"
|
|
29
|
+
keep: number
|
|
30
|
+
budget: number
|
|
16
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
|
|
17
46
|
upstream: string
|
|
18
47
|
claudeConfigDirs: string[]
|
|
19
48
|
window?: number
|
package/src/context/constants.ts
CHANGED
|
@@ -5,3 +5,38 @@ export const SUMMARY_CALLS_SHOWN = 20
|
|
|
5
5
|
|
|
6
6
|
export const CLEARED_INPUT_THRESHOLD = 2_000
|
|
7
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(" "))
|
package/src/context/content.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SUMMARY_ARGUMENT_CHARS, SUMMARY_COMMAND_CHARS } from "./constants"
|
|
1
2
|
import type { Block, Message } from "./types"
|
|
2
3
|
|
|
3
4
|
export function contentBlocks(content: unknown): Block[] {
|
|
@@ -18,7 +19,28 @@ export function clean(message: Message): Message {
|
|
|
18
19
|
return { role: message.role, content: blocks(message).map(withoutCacheControl) }
|
|
19
20
|
}
|
|
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
|
+
|
|
21
31
|
export function clip(value: unknown, max: number): string {
|
|
22
32
|
const text = String(value).replace(/\s+/g, " ").trim()
|
|
23
|
-
return text.length > max ? `${text
|
|
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")
|
|
24
46
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { ERROR_CODE, ERROR_LINE, EVIDENCE_CALL_FLOOR, EVIDENCE_LABELS, EVIDENCE_LINE_CHARS, EVIDENCE_LINES_PER_CALL, EVIDENCE_MIN_SCORE, EVIDENCE_RANKED, EVIDENCE_SCAN_LINES, EVIDENCE_TURN_CHARS, ID_TOKEN, KEY_VALUE, REREADABLE_TOOLS, VALUE_LABEL, VALUE_TOKEN } from "./constants"
|
|
2
|
+
import { blocks, clip, describeToolCall, resultText } from "./content"
|
|
3
|
+
import type { Block, EvidenceOptions, Message } from "./types"
|
|
4
|
+
|
|
5
|
+
interface Scored {
|
|
6
|
+
index: number
|
|
7
|
+
line: string
|
|
8
|
+
score: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function isSalient(line: string): boolean {
|
|
12
|
+
return ERROR_LINE.test(line) || ERROR_CODE.test(line) || VALUE_TOKEN.test(line)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function scoreLines(text: string, options: EvidenceOptions): Scored[] {
|
|
16
|
+
const scan = options.scanLines ?? EVIDENCE_SCAN_LINES
|
|
17
|
+
const lineChars = options.lineChars ?? EVIDENCE_LINE_CHARS
|
|
18
|
+
const minScore = options.minScore ?? EVIDENCE_MIN_SCORE
|
|
19
|
+
const scored = text.split("\n").slice(0, scan).flatMap((raw, index) => {
|
|
20
|
+
const line = raw.trim()
|
|
21
|
+
if (line.length < 4) return []
|
|
22
|
+
const error = ERROR_LINE.test(line) || ERROR_CODE.test(line)
|
|
23
|
+
const value = VALUE_TOKEN.test(line)
|
|
24
|
+
if (!error && !value) return []
|
|
25
|
+
const score = (error ? 3 : 0) + (value ? (ID_TOKEN.test(line) ? 3 : 2) : 0) + (KEY_VALUE.test(line) ? 1 : 0) + (line.length < 120 ? 1 : 0) + ((options.labels ?? EVIDENCE_LABELS) && VALUE_LABEL.test(line) ? 1 : 0)
|
|
26
|
+
return score >= minScore ? [{ index, line: clip(line, lineChars), score }] : []
|
|
27
|
+
})
|
|
28
|
+
const seen = new Set<string>()
|
|
29
|
+
return scored.sort((a, b) => b.score - a.score || a.index - b.index).filter((s) => !seen.has(s.line) && seen.add(s.line))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function salientLines(text: string, options: EvidenceOptions = {}): string[] {
|
|
33
|
+
return scoreLines(text, options)
|
|
34
|
+
.slice(0, options.linesPerCall ?? EVIDENCE_LINES_PER_CALL)
|
|
35
|
+
.sort((a, b) => a.index - b.index)
|
|
36
|
+
.map((s) => s.line)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function toolPairs(turn: Message[]): Array<{ call: Block; result: Block }> {
|
|
40
|
+
const results = new Map<string, Block>()
|
|
41
|
+
for (const message of turn) {
|
|
42
|
+
if (message.role !== "user") continue
|
|
43
|
+
for (const block of blocks(message)) if (block.type === "tool_result") results.set(block.tool_use_id, block)
|
|
44
|
+
}
|
|
45
|
+
return turn
|
|
46
|
+
.filter((m) => m.role === "assistant")
|
|
47
|
+
.flatMap((m) => blocks(m).filter((b) => b.type === "tool_use"))
|
|
48
|
+
.flatMap((call) => (results.has(call.id) ? [{ call, result: results.get(call.id)! }] : []))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function render(entries: Array<{ head: string; lines: string[] }>): string | null {
|
|
52
|
+
const body = entries.filter((e) => e.lines.length).map((e) => `- ${e.head}:\n${e.lines.map((l) => ` ${l}`).join("\n")}`)
|
|
53
|
+
return body.length ? `<tool-evidence>\nKey lines of command output from this turn, kept verbatim (the full output is in the transcript):\n${body.join("\n")}\n</tool-evidence>` : null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function turnEvidence(turn: Message[], options: EvidenceOptions = {}): string | null {
|
|
57
|
+
const turnChars = options.turnChars ?? EVIDENCE_TURN_CHARS
|
|
58
|
+
const perCall = options.linesPerCall ?? EVIDENCE_LINES_PER_CALL
|
|
59
|
+
const pairs = toolPairs(turn).filter(({ call }) => options.allTools || !REREADABLE_TOOLS.has(call.name))
|
|
60
|
+
const heads = pairs.map(({ call, result }) => `${describeToolCall(call)}${result.is_error ? " (failed)" : ""}`)
|
|
61
|
+
if (!(options.ranked ?? EVIDENCE_RANKED)) {
|
|
62
|
+
const entries: Array<{ head: string; lines: string[] }> = []
|
|
63
|
+
let used = 0
|
|
64
|
+
pairs.forEach(({ result }, i) => {
|
|
65
|
+
const lines = salientLines(resultText(result), options)
|
|
66
|
+
if (!lines.length) return
|
|
67
|
+
const size = heads[i].length + 4 + lines.reduce((s, l) => s + l.length + 5, 0)
|
|
68
|
+
if (used + size > turnChars) return
|
|
69
|
+
entries.push({ head: heads[i], lines })
|
|
70
|
+
used += size
|
|
71
|
+
})
|
|
72
|
+
return render(entries)
|
|
73
|
+
}
|
|
74
|
+
const floor = options.callFloor ?? EVIDENCE_CALL_FLOOR
|
|
75
|
+
const perCallLines = pairs.map(({ result }) => scoreLines(resultText(result), options))
|
|
76
|
+
const chosen = new Map<number, Scored[]>()
|
|
77
|
+
let used = 0
|
|
78
|
+
const take = (call: number, line: Scored) => {
|
|
79
|
+
const bucket = chosen.get(call) ?? []
|
|
80
|
+
const cost = line.line.length + 5 + (bucket.length ? 0 : heads[call].length + 4)
|
|
81
|
+
if (bucket.length >= perCall || used + cost > turnChars) return
|
|
82
|
+
bucket.push(line)
|
|
83
|
+
chosen.set(call, bucket)
|
|
84
|
+
used += cost
|
|
85
|
+
}
|
|
86
|
+
perCallLines.forEach((lines, call) => lines.slice(0, floor).forEach((line) => take(call, line)))
|
|
87
|
+
perCallLines
|
|
88
|
+
.flatMap((lines, call) => lines.slice(floor).map((line) => ({ line, call })))
|
|
89
|
+
.sort((a, b) => b.line.score - a.line.score || a.call - b.call || a.line.index - b.line.index)
|
|
90
|
+
.forEach(({ line, call }) => take(call, line))
|
|
91
|
+
return render(heads.map((head, i) => ({ head, lines: (chosen.get(i) ?? []).sort((a, b) => a.index - b.index).map((s) => s.line) })))
|
|
92
|
+
}
|