@chroxify/eunoe 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -9
- package/package.json +18 -2
- package/src/cli/{types.ts → command.ts} +0 -4
- package/src/cli/commands/index.ts +11 -1
- package/src/cli/commands/load.ts +44 -0
- package/src/cli/commands/mode.ts +41 -33
- package/src/cli/commands/peek.ts +22 -0
- package/src/cli/commands/replay.ts +194 -0
- package/src/cli/commands/serve.ts +22 -7
- package/src/cli/commands/sessions.ts +70 -0
- package/src/cli/commands/settings.ts +79 -60
- package/src/cli/commands/start.ts +27 -18
- package/src/cli/commands/status.ts +98 -55
- package/src/cli/commands/stop.ts +8 -6
- package/src/cli/commands/uninstall.ts +10 -12
- package/src/cli/commands/watch.ts +16 -0
- package/src/cli/logo/art.ts +99 -0
- package/src/cli/logo/index.ts +12 -18
- package/src/cli/main.ts +3 -3
- package/src/cli/proxy/claude-settings.ts +59 -0
- package/src/cli/proxy/inspect.ts +67 -0
- package/src/cli/proxy/launchd.ts +53 -0
- package/src/cli/proxy/service.ts +53 -0
- package/src/cli/{stats.ts → proxy/stats.ts} +23 -15
- package/src/cli/proxy/watch.ts +60 -0
- package/src/cli/replay/index.ts +141 -0
- package/src/cli/replay/worker.ts +15 -0
- package/src/cli/sessions/browse.ts +88 -0
- package/src/cli/sessions/index.ts +76 -0
- package/src/cli/sessions/source.ts +74 -0
- package/src/cli/{help.ts → terminal/help.ts} +24 -8
- package/src/cli/terminal/prompt.ts +114 -0
- package/src/cli/{ui.ts → terminal/ui.ts} +55 -24
- package/src/config/index.ts +99 -34
- package/src/config/paths.ts +17 -0
- package/src/context/content.ts +40 -14
- package/src/context/fold/evidence.ts +145 -0
- package/src/context/fold/index.ts +65 -0
- package/src/context/fold/strategies.ts +94 -0
- package/src/context/fold/turns.ts +76 -0
- package/src/context/prompts.ts +141 -22
- package/src/context/recall/index.ts +214 -0
- package/src/context/recall/rerank.ts +71 -0
- package/src/proxy/canonical.ts +17 -9
- package/src/proxy/compaction.ts +106 -53
- package/src/proxy/estimate.ts +14 -9
- package/src/proxy/fingerprint.ts +23 -9
- package/src/proxy/handoff.ts +71 -0
- package/src/proxy/log.ts +2 -2
- package/src/proxy/recall.ts +58 -0
- package/src/proxy/rewrite.ts +141 -119
- package/src/proxy/server.ts +108 -51
- package/src/proxy/sessions.ts +32 -14
- package/src/proxy/state.ts +17 -3
- package/src/proxy/usage.ts +50 -34
- package/src/proxy/window.ts +32 -34
- package/src/transcript/format.ts +88 -25
- package/src/transcript/locate.ts +38 -17
- package/src/transcript/messages.ts +3 -14
- package/src/transcript/parse.ts +137 -56
- package/src/transcript/render.ts +44 -17
- package/src/transcript/search.ts +20 -56
- package/src/transcript/subagents.ts +150 -0
- package/src/cli/claude-settings.ts +0 -42
- package/src/cli/constants.ts +0 -52
- package/src/cli/inspect.ts +0 -41
- package/src/cli/launchd.ts +0 -39
- package/src/cli/logo/pixels.ts +0 -34
- package/src/cli/prompt.ts +0 -91
- package/src/cli/service.ts +0 -33
- package/src/cli/sessions.ts +0 -25
- package/src/config/constants.ts +0 -39
- package/src/config/types.ts +0 -50
- package/src/context/constants.ts +0 -42
- package/src/context/evidence.ts +0 -92
- package/src/context/recall.ts +0 -155
- package/src/context/rerank.ts +0 -44
- package/src/context/strategies.ts +0 -89
- package/src/context/turns.ts +0 -87
- package/src/context/types.ts +0 -66
- package/src/proxy/constants.ts +0 -29
- package/src/proxy/types.ts +0 -82
- package/src/transcript/constants.ts +0 -15
- package/src/transcript/types.ts +0 -43
- /package/src/cli/{args.ts → terminal/args.ts} +0 -0
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { homedir } from "node:os"
|
|
2
|
-
import { TYPO_DISTANCE } from "./constants"
|
|
3
2
|
|
|
4
3
|
const colorFor = (stream: NodeJS.WriteStream) => Boolean(stream.isTTY) && !process.env.NO_COLOR && process.env.TERM !== "dumb"
|
|
5
4
|
export const useColor = colorFor(process.stdout)
|
|
6
5
|
export const trueColor = /^(truecolor|24bit)$/i.test(process.env.COLORTERM ?? "")
|
|
7
6
|
const useColorErr = colorFor(process.stderr)
|
|
8
7
|
|
|
9
|
-
const paint =
|
|
8
|
+
const paint =
|
|
9
|
+
(open: number, close: number, enabled = useColor) =>
|
|
10
|
+
(text: string) =>
|
|
11
|
+
enabled ? `\x1b[${open}m${text}\x1b[${close}m` : text
|
|
10
12
|
|
|
11
13
|
export const c = {
|
|
12
14
|
bold: paint(1, 22),
|
|
@@ -27,15 +29,15 @@ export function line(text = "") {
|
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
export function ok(text: string) {
|
|
30
|
-
|
|
32
|
+
line(`${c.green("✓")} ${text}`)
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
export function note(text: string) {
|
|
34
|
-
|
|
36
|
+
line(`${c.dim("·")} ${text}`)
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
export function warn(text: string) {
|
|
38
|
-
|
|
40
|
+
line(`${c.yellow("!")} ${text}`)
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
export function fail(text: string, hint?: string): never {
|
|
@@ -50,18 +52,38 @@ export function listFiles(files: string[]) {
|
|
|
50
52
|
|
|
51
53
|
export function rows(entries: Array<[string, string]>, indent = 2) {
|
|
52
54
|
const width = Math.max(...entries.map(([label]) => label.length))
|
|
53
|
-
for (const [label, value] of entries)
|
|
55
|
+
for (const [label, value] of entries) line(`${" ".repeat(indent)}${c.dim(label.padEnd(width))} ${value}`)
|
|
54
56
|
}
|
|
55
57
|
|
|
56
|
-
const
|
|
58
|
+
const ESC = "\x1b"
|
|
59
|
+
const ANSI_STYLE = new RegExp(`${ESC}\\[[0-9;]*m`, "g")
|
|
60
|
+
|
|
61
|
+
const visible = (text: string) => text.replace(ANSI_STYLE, "")
|
|
57
62
|
|
|
58
63
|
export function table(entries: string[][], indent = 4, gap = 2) {
|
|
59
64
|
const columns = Math.max(...entries.map((row) => row.length))
|
|
60
65
|
const widths = Array.from({ length: columns }, (_, column) => Math.max(...entries.map((row) => visible(row[column] ?? "").length)))
|
|
61
66
|
for (const row of entries) {
|
|
62
67
|
const cells = widths.map((width, column) => (row[column] ?? "") + " ".repeat(Math.max(0, width - visible(row[column] ?? "").length)))
|
|
63
|
-
|
|
68
|
+
line(`${" ".repeat(indent)}${cells.join(" ".repeat(gap)).trimEnd()}`)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function wrap(text: string, width: number): string[] {
|
|
73
|
+
const out: string[] = []
|
|
74
|
+
let current = ""
|
|
75
|
+
for (const word of text.split(" ")) {
|
|
76
|
+
if (current && current.length + 1 + word.length > width) {
|
|
77
|
+
out.push(current)
|
|
78
|
+
current = word
|
|
79
|
+
} else current = current ? `${current} ${word}` : word
|
|
64
80
|
}
|
|
81
|
+
if (current) out.push(current)
|
|
82
|
+
return out
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function plural(count: number, word: string) {
|
|
86
|
+
return `${count} ${word}${count === 1 ? "" : "s"}`
|
|
65
87
|
}
|
|
66
88
|
|
|
67
89
|
export function percent(share: number) {
|
|
@@ -74,8 +96,16 @@ export function tilde(file: string) {
|
|
|
74
96
|
}
|
|
75
97
|
|
|
76
98
|
export function tokens(count: number) {
|
|
77
|
-
|
|
78
|
-
|
|
99
|
+
for (const [scale, suffix] of [
|
|
100
|
+
[1e12, "T"],
|
|
101
|
+
[1e9, "B"],
|
|
102
|
+
[1e6, "M"],
|
|
103
|
+
[1e3, "k"],
|
|
104
|
+
] as const) {
|
|
105
|
+
const scaled = count / scale
|
|
106
|
+
const shown = scaled.toFixed(scaled >= 10 ? 0 : 1)
|
|
107
|
+
if (Number(shown) >= 1) return `${shown}${suffix}`
|
|
108
|
+
}
|
|
79
109
|
return String(count)
|
|
80
110
|
}
|
|
81
111
|
|
|
@@ -87,20 +117,21 @@ export function ago(iso: string) {
|
|
|
87
117
|
return `${Math.round(seconds / 86_400)}d ago`
|
|
88
118
|
}
|
|
89
119
|
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
previous = current
|
|
100
|
-
}
|
|
120
|
+
function editDistance(a: string, b: string) {
|
|
121
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i)
|
|
122
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
123
|
+
let previous = row[0]
|
|
124
|
+
row[0] = i
|
|
125
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
126
|
+
const current = row[j]
|
|
127
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1))
|
|
128
|
+
previous = current
|
|
101
129
|
}
|
|
102
|
-
return row[b.length]
|
|
103
130
|
}
|
|
104
|
-
|
|
105
|
-
|
|
131
|
+
return row[b.length]
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function closest(input: string, options: string[]) {
|
|
135
|
+
const [best] = options.map((option) => [option, editDistance(input, option)] as const).sort((x, y) => x[1] - y[1])
|
|
136
|
+
return best && best[1] <= 2 ? best[0] : null
|
|
106
137
|
}
|
package/src/config/index.ts
CHANGED
|
@@ -1,47 +1,114 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
|
|
2
2
|
import { homedir } from "node:os"
|
|
3
3
|
import path from "node:path"
|
|
4
|
-
import {
|
|
5
|
-
import type { Config, Mode, SessionSettings, Tunable, Tunables } from "./types"
|
|
4
|
+
import { EUNOE_DIR, paths } from "./paths"
|
|
6
5
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
6
|
+
export type Mode = "off" | "compact" | "default"
|
|
7
|
+
export type SearchMode = "jsonl" | "markdown" | "xml" | "qmd"
|
|
8
|
+
type Scope = "all" | "sessions"
|
|
9
|
+
|
|
10
|
+
interface EvalConfig {
|
|
11
|
+
recallOptions?: { maxChars?: number; maxCharsMulti?: number; perQuery?: number }
|
|
12
|
+
strategy?: "tail" | "guide" | "trim"
|
|
13
|
+
rolling?: { keep: number; budget: number }
|
|
14
|
+
forceCut?: boolean
|
|
15
|
+
trustHeaders?: boolean
|
|
16
|
+
evidence?: boolean
|
|
17
|
+
recall?: boolean
|
|
18
|
+
rerank?: { model: string; candidates?: number; endpoint?: string }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface Tunables {
|
|
22
|
+
compactAt: number
|
|
23
|
+
keepTurns: number | "all"
|
|
24
|
+
keep: number
|
|
25
|
+
budget: number
|
|
26
|
+
turnCap: number | "off"
|
|
27
|
+
search: SearchMode
|
|
28
|
+
rerank: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type Tunable = keyof Tunables
|
|
32
|
+
|
|
33
|
+
interface SessionSettings extends Partial<Tunables> {
|
|
34
|
+
mode?: Mode
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface Config extends Tunables {
|
|
38
|
+
mode: Mode
|
|
39
|
+
scope: Scope
|
|
40
|
+
sessions: Record<string, SessionSettings>
|
|
41
|
+
port: number
|
|
42
|
+
upstream: string
|
|
43
|
+
claudeConfigDirs: string[]
|
|
44
|
+
window?: number
|
|
45
|
+
eval?: EvalConfig
|
|
14
46
|
}
|
|
15
47
|
|
|
48
|
+
export const DEFAULTS: Config = {
|
|
49
|
+
mode: "default",
|
|
50
|
+
scope: "all",
|
|
51
|
+
sessions: {},
|
|
52
|
+
port: 38663,
|
|
53
|
+
compactAt: 0.9,
|
|
54
|
+
keepTurns: "all",
|
|
55
|
+
keep: 3,
|
|
56
|
+
budget: 100_000,
|
|
57
|
+
turnCap: 100_000,
|
|
58
|
+
search: "markdown",
|
|
59
|
+
rerank: "jev-preview",
|
|
60
|
+
upstream: "https://api.anthropic.com",
|
|
61
|
+
claudeConfigDirs: [],
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const MODES: Mode[] = ["default", "compact", "off"]
|
|
65
|
+
export const TUNABLES: Tunable[] = ["keep", "budget", "turnCap", "compactAt", "keepTurns", "search", "rerank"]
|
|
66
|
+
|
|
67
|
+
const SEARCH_MODES: SearchMode[] = ["jsonl", "markdown", "xml", "qmd"]
|
|
16
68
|
const TEXT_TUNABLES: Tunable[] = ["search", "rerank"]
|
|
17
69
|
|
|
70
|
+
const isWhole = (value: unknown, min: number): value is number => Number.isInteger(value) && (value as number) >= min
|
|
71
|
+
|
|
72
|
+
const validators: { [K in Tunable]: (value: unknown) => value is Tunables[K] } = {
|
|
73
|
+
compactAt: (value): value is number => typeof value === "number" && value > 0 && value <= 1,
|
|
74
|
+
keepTurns: (value): value is number | "all" => value === "all" || isWhole(value, 0),
|
|
75
|
+
keep: (value): value is number => isWhole(value, 1),
|
|
76
|
+
budget: (value): value is number => isWhole(value, 1_000),
|
|
77
|
+
turnCap: (value): value is number | "off" => value === "off" || isWhole(value, 1_000),
|
|
78
|
+
search: (value): value is SearchMode => SEARCH_MODES.includes(value as SearchMode),
|
|
79
|
+
rerank: (value): value is string => typeof value === "string" && /^[\w.-]+$/.test(value),
|
|
80
|
+
}
|
|
81
|
+
|
|
18
82
|
const isMode = (value: unknown): value is Mode => MODES.includes(value as Mode)
|
|
19
83
|
|
|
84
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => Boolean(value) && typeof value === "object" && !Array.isArray(value)
|
|
85
|
+
|
|
20
86
|
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
|
|
87
|
+
const value = TEXT_TUNABLES.includes(key) || raw === "all" || raw === "off" ? raw : Number(raw.replace(/[_,]/g, ""))
|
|
88
|
+
return validators[key](value) ? (value as Tunables[K]) : null
|
|
23
89
|
}
|
|
24
90
|
|
|
25
91
|
function normalizeSession(value: unknown): SessionSettings | null {
|
|
26
92
|
if (value === null) return {}
|
|
27
93
|
if (isMode(value)) return { mode: value }
|
|
28
|
-
if (!
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
for (const key of TUNABLES) if (VALID[key](raw[key])) Object.assign(settings, { [key]: raw[key] })
|
|
94
|
+
if (!isRecord(value)) return null
|
|
95
|
+
const settings: SessionSettings = isMode(value.mode) ? { mode: value.mode } : {}
|
|
96
|
+
for (const key of TUNABLES) if (validators[key](value[key])) Object.assign(settings, { [key]: value[key] })
|
|
32
97
|
return settings
|
|
33
98
|
}
|
|
34
99
|
|
|
35
|
-
export function normalizeConfig(raw: Record<string,
|
|
36
|
-
const config
|
|
100
|
+
export function normalizeConfig(raw: Record<string, unknown>): Config {
|
|
101
|
+
const config = { ...DEFAULTS, ...raw } as Config
|
|
37
102
|
if (!isMode(config.mode)) config.mode = DEFAULTS.mode
|
|
38
|
-
if (
|
|
39
|
-
for (const key of TUNABLES) if (!
|
|
40
|
-
const sessions =
|
|
41
|
-
config.sessions = Object.fromEntries(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
103
|
+
if (config.scope !== "all" && config.scope !== "sessions") config.scope = DEFAULTS.scope
|
|
104
|
+
for (const key of TUNABLES) if (!validators[key](config[key])) Object.assign(config, { [key]: DEFAULTS[key] })
|
|
105
|
+
const sessions = isRecord(config.sessions) ? config.sessions : {}
|
|
106
|
+
config.sessions = Object.fromEntries(
|
|
107
|
+
Object.entries(sessions).flatMap(([id, value]) => {
|
|
108
|
+
const settings = normalizeSession(value)
|
|
109
|
+
return settings ? [[id, settings]] : []
|
|
110
|
+
}),
|
|
111
|
+
)
|
|
45
112
|
if (!Number.isInteger(config.port)) config.port = DEFAULTS.port
|
|
46
113
|
if (!Array.isArray(config.claudeConfigDirs)) config.claudeConfigDirs = []
|
|
47
114
|
return config
|
|
@@ -49,7 +116,7 @@ export function normalizeConfig(raw: Record<string, any>): Config {
|
|
|
49
116
|
|
|
50
117
|
let cached: { mtimeMs: number; config: Config } | null = null
|
|
51
118
|
|
|
52
|
-
export function
|
|
119
|
+
export function readConfig(): Config {
|
|
53
120
|
let mtimeMs: number
|
|
54
121
|
try {
|
|
55
122
|
mtimeMs = statSync(paths.config).mtimeMs
|
|
@@ -60,9 +127,7 @@ export function loadConfig(): Config {
|
|
|
60
127
|
let config = DEFAULTS
|
|
61
128
|
try {
|
|
62
129
|
config = normalizeConfig(JSON.parse(readFileSync(paths.config, "utf8")))
|
|
63
|
-
} catch
|
|
64
|
-
console.error(`[eunoe] ${paths.config} is not valid JSON; using defaults`, error)
|
|
65
|
-
}
|
|
130
|
+
} catch {}
|
|
66
131
|
cached = { mtimeMs, config }
|
|
67
132
|
return config
|
|
68
133
|
}
|
|
@@ -70,7 +135,7 @@ export function loadConfig(): Config {
|
|
|
70
135
|
export function saveConfig(patch: Partial<Config>) {
|
|
71
136
|
mkdirSync(EUNOE_DIR, { recursive: true })
|
|
72
137
|
const current = existsSync(paths.config) ? JSON.parse(readFileSync(paths.config, "utf8")) : {}
|
|
73
|
-
writeFileSync(paths.config, JSON.stringify({ ...current, ...patch }, null, 2)
|
|
138
|
+
writeFileSync(paths.config, `${JSON.stringify({ ...current, ...patch }, null, 2)}\n`)
|
|
74
139
|
}
|
|
75
140
|
|
|
76
141
|
export function claudeConfigDirs(config: Config): string[] {
|
|
@@ -85,14 +150,14 @@ export function modeFor(config: Config, sessionId: string | null): Mode {
|
|
|
85
150
|
return config.scope === "all" ? config.mode : "off"
|
|
86
151
|
}
|
|
87
152
|
|
|
153
|
+
export function compactionOwner(config: Config): Mode {
|
|
154
|
+
return modeFor(config, null)
|
|
155
|
+
}
|
|
156
|
+
|
|
88
157
|
export function overrides(settings: SessionSettings | undefined): Partial<Tunables> {
|
|
89
|
-
return Object.fromEntries(TUNABLES.filter((key) => settings?.[key] !== undefined).map((key) => [key, settings
|
|
158
|
+
return Object.fromEntries(TUNABLES.filter((key) => settings?.[key] !== undefined).map((key) => [key, settings?.[key]]))
|
|
90
159
|
}
|
|
91
160
|
|
|
92
161
|
export function settingsFor(config: Config, sessionId: string | null): Config {
|
|
93
162
|
return { ...config, ...overrides(sessionId ? config.sessions[sessionId] : undefined), mode: modeFor(config, sessionId) }
|
|
94
163
|
}
|
|
95
|
-
|
|
96
|
-
export function compactionOwner(config: Config): Mode {
|
|
97
|
-
return config.scope === "all" ? config.mode : "off"
|
|
98
|
-
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { homedir } from "node:os"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
export const EUNOE_DIR = process.env.EUNOE_DIR ?? path.join(homedir(), ".eunoe")
|
|
5
|
+
|
|
6
|
+
export const paths = {
|
|
7
|
+
config: path.join(EUNOE_DIR, "config.json"),
|
|
8
|
+
requests: path.join(EUNOE_DIR, "requests.jsonl"),
|
|
9
|
+
threads: path.join(EUNOE_DIR, "threads"),
|
|
10
|
+
transcripts: path.join(EUNOE_DIR, "transcripts"),
|
|
11
|
+
models: path.join(EUNOE_DIR, "models.json"),
|
|
12
|
+
sessions: path.join(EUNOE_DIR, "sessions.json"),
|
|
13
|
+
proxyLog: path.join(EUNOE_DIR, "proxy.log"),
|
|
14
|
+
typesafeKey: path.join(EUNOE_DIR, "typesafe-key"),
|
|
15
|
+
loaded: path.join(EUNOE_DIR, "loaded"),
|
|
16
|
+
watch: path.join(EUNOE_DIR, "watch.json"),
|
|
17
|
+
}
|
package/src/context/content.ts
CHANGED
|
@@ -1,14 +1,40 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
export type Block = { type: string; [key: string]: any }
|
|
2
|
+
|
|
3
|
+
export type Message = { role: string; content: string | Block[] }
|
|
4
|
+
|
|
5
|
+
export type Body = {
|
|
6
|
+
model?: string
|
|
7
|
+
system?: string | Block[]
|
|
8
|
+
messages: Message[]
|
|
9
|
+
tools?: unknown[]
|
|
10
|
+
metadata?: { user_id?: string }
|
|
11
|
+
[key: string]: any
|
|
12
|
+
}
|
|
3
13
|
|
|
4
14
|
export function contentBlocks(content: unknown): Block[] {
|
|
5
|
-
|
|
15
|
+
if (typeof content === "string") return [{ type: "text", text: content }]
|
|
16
|
+
return Array.isArray(content) ? content : []
|
|
6
17
|
}
|
|
7
18
|
|
|
8
19
|
export function blocks(message: Message): Block[] {
|
|
9
20
|
return contentBlocks(message.content)
|
|
10
21
|
}
|
|
11
22
|
|
|
23
|
+
function joinedText(list: Block[]): string {
|
|
24
|
+
return list
|
|
25
|
+
.filter((b) => b.type === "text")
|
|
26
|
+
.map((b) => String(b.text ?? ""))
|
|
27
|
+
.join("\n")
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function textOf(message: Message): string {
|
|
31
|
+
return joinedText(blocks(message))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function resultText(block: Block): string {
|
|
35
|
+
return typeof block.content === "string" ? block.content : joinedText(contentBlocks(block.content))
|
|
36
|
+
}
|
|
37
|
+
|
|
12
38
|
export function withoutCacheControl(block: Block): Block {
|
|
13
39
|
if (!("cache_control" in block)) return block
|
|
14
40
|
const { cache_control: _, ...rest } = block
|
|
@@ -19,28 +45,28 @@ export function clean(message: Message): Message {
|
|
|
19
45
|
return { role: message.role, content: blocks(message).map(withoutCacheControl) }
|
|
20
46
|
}
|
|
21
47
|
|
|
48
|
+
export function shortHash(json: string, chars: number): string {
|
|
49
|
+
return new Bun.CryptoHasher("sha256").update(json).digest("hex").slice(0, chars)
|
|
50
|
+
}
|
|
51
|
+
|
|
22
52
|
export function truncate(text: string, max: number): string {
|
|
23
53
|
const cut = text.slice(0, max)
|
|
24
54
|
return /[\uD800-\uDBFF]$/.test(cut) ? cut.slice(0, -1) : cut
|
|
25
55
|
}
|
|
26
56
|
|
|
27
|
-
export function wellFormed(_key: string, value: unknown): unknown {
|
|
28
|
-
return typeof value === "string" ? value.toWellFormed() : value
|
|
29
|
-
}
|
|
30
|
-
|
|
31
57
|
export function clip(value: unknown, max: number): string {
|
|
32
58
|
const text = String(value).replace(/\s+/g, " ").trim()
|
|
33
59
|
return text.length > max ? `${truncate(text, max)}…` : text
|
|
34
60
|
}
|
|
35
61
|
|
|
62
|
+
export function wellFormed(_key: string, value: unknown): unknown {
|
|
63
|
+
return typeof value === "string" ? value.toWellFormed() : value
|
|
64
|
+
}
|
|
65
|
+
|
|
36
66
|
export function describeToolCall(block: Block): string {
|
|
37
67
|
const input = block.input ?? {}
|
|
38
|
-
if (typeof input.command === "string") return `${block.name} \`${clip(input.command,
|
|
68
|
+
if (typeof input.command === "string") return `${block.name} \`${clip(input.command, 100)}\``
|
|
39
69
|
if (typeof input.file_path === "string") return `${block.name} ${input.file_path}`
|
|
40
|
-
if (typeof input.pattern === "string") return `${block.name} "${clip(input.pattern,
|
|
41
|
-
return `${block.name} ${clip(JSON.stringify(input),
|
|
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")
|
|
70
|
+
if (typeof input.pattern === "string") return `${block.name} "${clip(input.pattern, 60)}"`
|
|
71
|
+
return `${block.name} ${clip(JSON.stringify(input), 60)}`
|
|
46
72
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { type Block, blocks, clip, describeToolCall, type Message, resultText } from "../content"
|
|
2
|
+
import { toolEvidence } from "../prompts"
|
|
3
|
+
|
|
4
|
+
export interface EvidenceOptions {
|
|
5
|
+
linesPerCall?: number
|
|
6
|
+
lineChars?: number
|
|
7
|
+
turnChars?: number
|
|
8
|
+
scanLines?: number
|
|
9
|
+
minScore?: number
|
|
10
|
+
allTools?: boolean
|
|
11
|
+
ranked?: boolean
|
|
12
|
+
labels?: boolean
|
|
13
|
+
callFloor?: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface Scored {
|
|
17
|
+
index: number
|
|
18
|
+
line: string
|
|
19
|
+
score: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface Entry {
|
|
23
|
+
head: string
|
|
24
|
+
lines: string[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const REREADABLE_TOOLS = new Set(["Read", "Grep", "Glob", "LS", "NotebookRead", "WebFetch", "WebSearch", "TodoWrite", "Edit", "Write", "MultiEdit", "NotebookEdit"])
|
|
28
|
+
|
|
29
|
+
const ERROR_LINE = /\b(?:error|errors|fail|fails|failed|failure|fatal|exception|panic|denied|refused|timed? ?out|traceback|cannot|unable to)\b/i
|
|
30
|
+
|
|
31
|
+
const ERROR_CODE =
|
|
32
|
+
/\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/
|
|
33
|
+
|
|
34
|
+
const VALUE_TOKEN =
|
|
35
|
+
/\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
|
|
36
|
+
|
|
37
|
+
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
|
|
38
|
+
|
|
39
|
+
const KEY_VALUE = /^[\w .\-/()]{2,48}[:=]\s*\S/
|
|
40
|
+
|
|
41
|
+
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
|
|
42
|
+
|
|
43
|
+
function resolved(options: EvidenceOptions) {
|
|
44
|
+
return {
|
|
45
|
+
linesPerCall: options.linesPerCall ?? 12,
|
|
46
|
+
lineChars: options.lineChars ?? 220,
|
|
47
|
+
turnChars: options.turnChars ?? 4_000,
|
|
48
|
+
scanLines: options.scanLines ?? 1_000_000,
|
|
49
|
+
minScore: options.minScore ?? 2,
|
|
50
|
+
ranked: options.ranked ?? true,
|
|
51
|
+
labels: options.labels ?? true,
|
|
52
|
+
callFloor: options.callFloor ?? 2,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const isError = (line: string) => ERROR_LINE.test(line) || ERROR_CODE.test(line)
|
|
57
|
+
|
|
58
|
+
export function isSalient(line: string): boolean {
|
|
59
|
+
return isError(line) || VALUE_TOKEN.test(line)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function scoreLines(text: string, options: EvidenceOptions): Scored[] {
|
|
63
|
+
const { scanLines, lineChars, minScore, labels } = resolved(options)
|
|
64
|
+
const scored = text
|
|
65
|
+
.split("\n")
|
|
66
|
+
.slice(0, scanLines)
|
|
67
|
+
.flatMap((raw, index) => {
|
|
68
|
+
const line = raw.trim()
|
|
69
|
+
if (line.length < 4) return []
|
|
70
|
+
const error = isError(line)
|
|
71
|
+
const value = VALUE_TOKEN.test(line)
|
|
72
|
+
if (!error && !value) return []
|
|
73
|
+
const score = (error ? 3 : 0) + (value ? (ID_TOKEN.test(line) ? 3 : 2) : 0) + (KEY_VALUE.test(line) ? 1 : 0) + (line.length < 120 ? 1 : 0) + (labels && VALUE_LABEL.test(line) ? 1 : 0)
|
|
74
|
+
return score >= minScore ? [{ index, line: clip(line, lineChars), score }] : []
|
|
75
|
+
})
|
|
76
|
+
const seen = new Set<string>()
|
|
77
|
+
return scored.sort((a, b) => b.score - a.score || a.index - b.index).filter((s) => !seen.has(s.line) && seen.add(s.line))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function salientLines(text: string, options: EvidenceOptions = {}): string[] {
|
|
81
|
+
return scoreLines(text, options)
|
|
82
|
+
.slice(0, resolved(options).linesPerCall)
|
|
83
|
+
.sort((a, b) => a.index - b.index)
|
|
84
|
+
.map((s) => s.line)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function toolPairs(turn: Message[]): Array<{ call: Block; result: Block }> {
|
|
88
|
+
const results = new Map<string, Block>()
|
|
89
|
+
for (const message of turn) {
|
|
90
|
+
if (message.role !== "user") continue
|
|
91
|
+
for (const block of blocks(message)) if (block.type === "tool_result") results.set(block.tool_use_id, block)
|
|
92
|
+
}
|
|
93
|
+
return turn
|
|
94
|
+
.filter((m) => m.role === "assistant")
|
|
95
|
+
.flatMap((m) => blocks(m).filter((b) => b.type === "tool_use"))
|
|
96
|
+
.flatMap((call) => {
|
|
97
|
+
const result = results.get(call.id)
|
|
98
|
+
return result ? [{ call, result }] : []
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function render(entries: Entry[]): string | null {
|
|
103
|
+
const shown = entries.filter((e) => e.lines.length > 0).map((e) => `- ${e.head}:\n${e.lines.map((l) => ` ${l}`).join("\n")}`)
|
|
104
|
+
return shown.length > 0 ? toolEvidence(shown) : null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function inCallOrder(heads: string[], pairs: Array<{ result: Block }>, options: EvidenceOptions): Entry[] {
|
|
108
|
+
const { turnChars } = resolved(options)
|
|
109
|
+
const entries: Entry[] = []
|
|
110
|
+
let used = 0
|
|
111
|
+
pairs.forEach(({ result }, i) => {
|
|
112
|
+
const lines = salientLines(resultText(result), options)
|
|
113
|
+
if (lines.length === 0) return
|
|
114
|
+
const size = heads[i].length + 4 + lines.reduce((s, l) => s + l.length + 5, 0)
|
|
115
|
+
if (used + size > turnChars) return
|
|
116
|
+
entries.push({ head: heads[i], lines })
|
|
117
|
+
used += size
|
|
118
|
+
})
|
|
119
|
+
return entries
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function byScore(heads: string[], pairs: Array<{ result: Block }>, options: EvidenceOptions): Entry[] {
|
|
123
|
+
const { turnChars, linesPerCall, callFloor } = resolved(options)
|
|
124
|
+
const perCall = pairs.map(({ result }) => scoreLines(resultText(result), options))
|
|
125
|
+
const chosen = new Map<number, Scored[]>()
|
|
126
|
+
let used = 0
|
|
127
|
+
const take = (call: number, line: Scored) => {
|
|
128
|
+
const bucket = chosen.get(call) ?? []
|
|
129
|
+
const cost = line.line.length + 5 + (bucket.length > 0 ? 0 : heads[call].length + 4)
|
|
130
|
+
if (bucket.length >= linesPerCall || used + cost > turnChars) return
|
|
131
|
+
bucket.push(line)
|
|
132
|
+
chosen.set(call, bucket)
|
|
133
|
+
used += cost
|
|
134
|
+
}
|
|
135
|
+
for (const [call, lines] of perCall.entries()) for (const line of lines.slice(0, callFloor)) take(call, line)
|
|
136
|
+
const rest = perCall.flatMap((lines, call) => lines.slice(callFloor).map((line) => ({ line, call }))).sort((a, b) => b.line.score - a.line.score || a.call - b.call || a.line.index - b.line.index)
|
|
137
|
+
for (const { line, call } of rest) take(call, line)
|
|
138
|
+
return heads.map((head, i) => ({ head, lines: (chosen.get(i) ?? []).sort((a, b) => a.index - b.index).map((s) => s.line) }))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function turnEvidence(turn: Message[], options: EvidenceOptions = {}): string | null {
|
|
142
|
+
const pairs = toolPairs(turn).filter(({ call }) => options.allTools || !REREADABLE_TOOLS.has(call.name))
|
|
143
|
+
const heads = pairs.map(({ call, result }) => `${describeToolCall(call)}${result.is_error ? " (failed)" : ""}`)
|
|
144
|
+
return render(resolved(options).ranked ? byScore(heads, pairs, options) : inCallOrder(heads, pairs, options))
|
|
145
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { type Message, textOf } from "../content"
|
|
2
|
+
import { type RecallChunk, recallChunks, terms } from "../recall"
|
|
3
|
+
import { isPrompt, reduceTurn, splitTurns } from "./turns"
|
|
4
|
+
|
|
5
|
+
export interface FoldedTurn {
|
|
6
|
+
user: string
|
|
7
|
+
evidence: string
|
|
8
|
+
reply: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const FOLD_CAP_CHARS = 400_000
|
|
12
|
+
|
|
13
|
+
const spoken = (message: Message) =>
|
|
14
|
+
textOf(message)
|
|
15
|
+
.replace(/<(system-reminder|system-message|recalled-context|handoff)(?:\s[^>]*)?>[\s\S]*?<\/\1>/g, "")
|
|
16
|
+
.trim()
|
|
17
|
+
|
|
18
|
+
const isEvidence = (text: string) => text.startsWith("<tool-evidence>")
|
|
19
|
+
|
|
20
|
+
export function foldedTurns(messages: Message[]): FoldedTurn[] {
|
|
21
|
+
return splitTurns(messages)
|
|
22
|
+
.turns.filter((turn) => isPrompt(turn[0]))
|
|
23
|
+
.map((turn) => {
|
|
24
|
+
const reduced = reduceTurn(turn, { evidence: true })
|
|
25
|
+
const users = reduced.filter((m) => m.role === "user").map(spoken)
|
|
26
|
+
return {
|
|
27
|
+
user: users.filter((t) => t && !isEvidence(t)).join("\n\n"),
|
|
28
|
+
evidence: users.filter(isEvidence).join("\n"),
|
|
29
|
+
reply: reduced
|
|
30
|
+
.filter((m) => m.role === "assistant")
|
|
31
|
+
.map(spoken)
|
|
32
|
+
.join("\n"),
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function renderTurn(turn: FoldedTurn, index: number, full: boolean): string {
|
|
38
|
+
const parts = [`### Turn ${index + 1}`, `**You:** ${turn.user}`]
|
|
39
|
+
if (full && turn.evidence) parts.push(turn.evidence)
|
|
40
|
+
if (full && turn.reply) parts.push(`**Agent:** ${turn.reply}`)
|
|
41
|
+
return parts.join("\n\n")
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function foldedView(messages: Message[], cap = Number.POSITIVE_INFINITY): string {
|
|
45
|
+
const turns = foldedTurns(messages)
|
|
46
|
+
const full = new Set<number>()
|
|
47
|
+
let used = 0
|
|
48
|
+
for (let i = turns.length - 1; i >= 0; i -= 1) {
|
|
49
|
+
const size = renderTurn(turns[i], i, true).length
|
|
50
|
+
if (used + size > cap) break
|
|
51
|
+
full.add(i)
|
|
52
|
+
used += size
|
|
53
|
+
}
|
|
54
|
+
return turns.map((turn, i) => renderTurn(turn, i, full.has(i))).join("\n\n")
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function sourceChunks(messages: Message[], from: string, users = false): RecallChunk[] {
|
|
58
|
+
const chunks = recallChunks([...messages, { role: "user", content: "" }], new Map()).map((chunk) => ({ ...chunk, from }))
|
|
59
|
+
if (!users) return chunks
|
|
60
|
+
const said = splitTurns(messages)
|
|
61
|
+
.turns.filter((turn) => isPrompt(turn[0]))
|
|
62
|
+
.map((turn, i) => ({ turn: i + 1, text: spoken(turn[0]) }))
|
|
63
|
+
.filter((t) => t.text)
|
|
64
|
+
return [...said.map((t) => ({ turn: t.turn, from, call: "you", lines: t.text.split("\n"), terms: terms(t.text) })), ...chunks]
|
|
65
|
+
}
|