@chroxify/eunoe 0.1.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +364 -239
- package/package.json +20 -3
- package/src/cli/{types.ts → command.ts} +1 -4
- package/src/cli/commands/index.ts +27 -3
- package/src/cli/commands/load.ts +44 -0
- package/src/cli/commands/mode.ts +68 -24
- package/src/cli/commands/peek.ts +22 -0
- package/src/cli/commands/replay.ts +194 -0
- package/src/cli/commands/serve.ts +24 -8
- package/src/cli/commands/sessions.ts +70 -0
- package/src/cli/commands/settings.ts +119 -0
- package/src/cli/commands/start.ts +73 -0
- package/src/cli/commands/status.ts +107 -30
- package/src/cli/commands/stop.ts +36 -12
- package/src/cli/commands/uninstall.ts +14 -14
- package/src/cli/commands/watch.ts +16 -0
- package/src/cli/logo/art.ts +99 -0
- package/src/cli/logo/index.ts +12 -18
- package/src/cli/main.ts +3 -3
- package/src/cli/proxy/claude-settings.ts +59 -0
- package/src/cli/proxy/inspect.ts +67 -0
- package/src/cli/proxy/launchd.ts +53 -0
- package/src/cli/proxy/service.ts +53 -0
- package/src/cli/proxy/stats.ts +81 -0
- package/src/cli/proxy/watch.ts +60 -0
- package/src/cli/replay/index.ts +141 -0
- package/src/cli/replay/worker.ts +15 -0
- package/src/cli/sessions/browse.ts +88 -0
- package/src/cli/sessions/index.ts +76 -0
- package/src/cli/sessions/source.ts +74 -0
- package/src/cli/{args.ts → terminal/args.ts} +1 -1
- package/src/cli/{help.ts → terminal/help.ts} +25 -9
- package/src/cli/terminal/prompt.ts +114 -0
- package/src/cli/terminal/ui.ts +137 -0
- package/src/config/index.ts +128 -14
- package/src/config/{constants.ts → paths.ts} +4 -14
- package/src/context/content.ts +51 -3
- package/src/context/fold/evidence.ts +145 -0
- package/src/context/fold/index.ts +65 -0
- package/src/context/fold/strategies.ts +94 -0
- package/src/context/fold/turns.ts +76 -0
- package/src/context/prompts.ts +141 -22
- package/src/context/recall/index.ts +214 -0
- package/src/context/recall/rerank.ts +71 -0
- package/src/proxy/canonical.ts +40 -0
- package/src/proxy/compaction.ts +122 -52
- package/src/proxy/estimate.ts +14 -9
- package/src/proxy/fingerprint.ts +29 -0
- package/src/proxy/handoff.ts +71 -0
- package/src/proxy/log.ts +2 -2
- package/src/proxy/recall.ts +58 -0
- package/src/proxy/rewrite.ts +152 -56
- package/src/proxy/server.ts +122 -47
- package/src/proxy/sessions.ts +64 -0
- package/src/proxy/state.ts +17 -3
- package/src/proxy/usage.ts +50 -34
- package/src/proxy/window.ts +32 -34
- package/src/transcript/format.ts +88 -25
- package/src/transcript/locate.ts +38 -17
- package/src/transcript/messages.ts +18 -0
- package/src/transcript/parse.ts +137 -56
- package/src/transcript/render.ts +44 -17
- package/src/transcript/search.ts +20 -56
- package/src/transcript/subagents.ts +150 -0
- package/src/cli/claude-settings.ts +0 -42
- package/src/cli/commands/install.ts +0 -44
- package/src/cli/constants.ts +0 -29
- package/src/cli/inspect.ts +0 -41
- package/src/cli/launchd.ts +0 -34
- package/src/cli/logo/pixels.ts +0 -34
- package/src/cli/ui.ts +0 -91
- package/src/config/types.ts +0 -21
- package/src/context/constants.ts +0 -7
- package/src/context/strategies.ts +0 -89
- package/src/context/turns.ts +0 -82
- package/src/context/types.ts +0 -24
- package/src/proxy/constants.ts +0 -19
- package/src/proxy/types.ts +0 -58
- package/src/transcript/constants.ts +0 -16
- package/src/transcript/types.ts +0 -43
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { blocks, clip, describeToolCall, type Message, resultText, textOf } from "../content"
|
|
2
|
+
import { isSalient, toolPairs } from "../fold/evidence"
|
|
3
|
+
import { isPrompt, splitTurns } from "../fold/turns"
|
|
4
|
+
import { recalledContext } from "../prompts"
|
|
5
|
+
|
|
6
|
+
export interface RecallChunk {
|
|
7
|
+
turn: number
|
|
8
|
+
from?: string
|
|
9
|
+
call: string
|
|
10
|
+
lines: string[]
|
|
11
|
+
terms: string[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface RecallOptions {
|
|
15
|
+
windowLines?: number
|
|
16
|
+
perQuery?: number
|
|
17
|
+
minTerms?: number
|
|
18
|
+
maxChars?: number
|
|
19
|
+
maxCharsMulti?: number
|
|
20
|
+
narrative?: boolean
|
|
21
|
+
callWeight?: number
|
|
22
|
+
salientExcerpt?: boolean
|
|
23
|
+
candidates?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RecallPick {
|
|
27
|
+
index: number
|
|
28
|
+
query: Set<string>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type Reranker = (query: string, chunks: RecallChunk[], candidates: number[]) => Promise<number[]>
|
|
32
|
+
|
|
33
|
+
const SHOWN_LINES = 8
|
|
34
|
+
const BM25_K1 = 1.2
|
|
35
|
+
const BM25_B = 0.75
|
|
36
|
+
|
|
37
|
+
const STOPWORDS = new Set(
|
|
38
|
+
"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(
|
|
39
|
+
" ",
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
export function terms(text: string): string[] {
|
|
44
|
+
return (text.toLowerCase().match(/[a-z0-9_@$][a-z0-9_.:/@$-]*[a-z0-9_]|[a-z0-9]/g) ?? []).flatMap((term) => (/[._:/-]/.test(term) ? [term, ...term.split(/[._:/-]+/)] : [term])).filter((term) => term.length >= 3 && !STOPWORDS.has(term))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function windows(lines: string[], size: number): string[][] {
|
|
48
|
+
const out: string[][] = []
|
|
49
|
+
for (let start = 0; start < lines.length; start += size) out.push(lines.slice(start, start + size))
|
|
50
|
+
return out
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function recallChunks(messages: Message[], keptIds: Map<string, string>, options: RecallOptions = {}): RecallChunk[] {
|
|
54
|
+
const size = options.windowLines ?? 6
|
|
55
|
+
const weight = Math.max(1, options.callWeight ?? 1)
|
|
56
|
+
const chunks: RecallChunk[] = []
|
|
57
|
+
splitTurns(messages)
|
|
58
|
+
.turns.slice(0, -1)
|
|
59
|
+
.forEach((turn, turnIndex) => {
|
|
60
|
+
for (const { call, result } of toolPairs(turn)) {
|
|
61
|
+
const text = resultText(result)
|
|
62
|
+
if (keptIds.get(call.id) === text || !text.trim()) continue
|
|
63
|
+
const head = describeToolCall(call)
|
|
64
|
+
const headTerms = Array.from({ length: weight }, () => terms(head)).flat()
|
|
65
|
+
for (const window of windows(text.split("\n"), size)) chunks.push({ turn: turnIndex + 1, call: head, lines: window, terms: [...headTerms, ...terms(window.join("\n"))] })
|
|
66
|
+
}
|
|
67
|
+
if (!(options.narrative ?? true)) return
|
|
68
|
+
const said = turn
|
|
69
|
+
.slice(1, -1)
|
|
70
|
+
.filter((m) => m.role === "assistant")
|
|
71
|
+
.flatMap((m) =>
|
|
72
|
+
blocks(m)
|
|
73
|
+
.filter((b) => b.type === "text")
|
|
74
|
+
.map((b) => String(b.text ?? "").trim()),
|
|
75
|
+
)
|
|
76
|
+
.filter(Boolean)
|
|
77
|
+
for (const window of windows(said.join("\n").split("\n"), size)) chunks.push({ turn: turnIndex + 1, call: "agent", lines: window, terms: terms(window.join("\n")) })
|
|
78
|
+
})
|
|
79
|
+
return chunks
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function rank(query: string[], chunks: RecallChunk[], minTerms = 2) {
|
|
83
|
+
const df = new Map<string, number>()
|
|
84
|
+
for (const chunk of chunks) for (const term of new Set(chunk.terms)) df.set(term, (df.get(term) ?? 0) + 1)
|
|
85
|
+
const average = chunks.reduce((s, c) => s + c.terms.length, 0) / Math.max(1, chunks.length)
|
|
86
|
+
const wanted = [...new Set(query)].filter((t) => df.has(t))
|
|
87
|
+
return chunks
|
|
88
|
+
.map((chunk, index) => {
|
|
89
|
+
const tf = new Map<string, number>()
|
|
90
|
+
for (const term of chunk.terms) tf.set(term, (tf.get(term) ?? 0) + 1)
|
|
91
|
+
let score = 0
|
|
92
|
+
let matched = 0
|
|
93
|
+
for (const term of wanted) {
|
|
94
|
+
const f = tf.get(term) ?? 0
|
|
95
|
+
if (!f) continue
|
|
96
|
+
matched += 1
|
|
97
|
+
const n = df.get(term) ?? 0
|
|
98
|
+
const idf = Math.log(1 + (chunks.length - n + 0.5) / (n + 0.5))
|
|
99
|
+
score += (idf * (f * (BM25_K1 + 1))) / (f + BM25_K1 * (1 - BM25_B + (BM25_B * chunk.terms.length) / average))
|
|
100
|
+
}
|
|
101
|
+
return { index, score, matched }
|
|
102
|
+
})
|
|
103
|
+
.filter((r) => r.matched >= Math.min(minTerms, wanted.length) && r.score > 0)
|
|
104
|
+
.sort((a, b) => b.score - a.score)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function excerpt(chunk: RecallChunk, query: Set<string>, salient: boolean): string[] {
|
|
108
|
+
const limit = salient ? SHOWN_LINES + 2 : SHOWN_LINES
|
|
109
|
+
const shown = new Set<number>()
|
|
110
|
+
chunk.lines.forEach((line, i) => {
|
|
111
|
+
if (!terms(line).some((t) => query.has(t))) return
|
|
112
|
+
for (const j of [i - 1, i, i + 1]) if (j >= 0 && j < chunk.lines.length) shown.add(j)
|
|
113
|
+
})
|
|
114
|
+
if (salient) for (let i = 0; i < chunk.lines.length && shown.size < limit; i += 1) if (isSalient(chunk.lines[i])) shown.add(i)
|
|
115
|
+
const ordered = [...shown].sort((a, b) => a - b).slice(0, limit)
|
|
116
|
+
return (ordered.length > 0 ? ordered : [0]).map((i) => clip(chunk.lines[i], 240)).filter(Boolean)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function queriesOf(prompt: string): string[] {
|
|
120
|
+
const lines = prompt
|
|
121
|
+
.split("\n")
|
|
122
|
+
.map((l) => l.trim())
|
|
123
|
+
.filter((l) => terms(l).length >= 2)
|
|
124
|
+
return lines.length > 1 ? lines : [prompt]
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function merged(hits: Array<{ query: string[]; indices: number[] }>): RecallPick[] {
|
|
128
|
+
const picked = new Map<number, Set<string>>()
|
|
129
|
+
for (const { query, indices } of hits) {
|
|
130
|
+
for (const index of indices) {
|
|
131
|
+
const matched = picked.get(index) ?? new Set()
|
|
132
|
+
for (const term of query) matched.add(term)
|
|
133
|
+
picked.set(index, matched)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return [...picked.entries()].map(([index, query]) => ({ index, query }))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const ranked = (query: string[], chunks: RecallChunk[], options: RecallOptions, width: number) =>
|
|
140
|
+
rank(query, chunks, options.minTerms)
|
|
141
|
+
.slice(0, width)
|
|
142
|
+
.map((hit) => hit.index)
|
|
143
|
+
|
|
144
|
+
function pickRecall(prompt: string, chunks: RecallChunk[], options: RecallOptions): RecallPick[] {
|
|
145
|
+
return merged(
|
|
146
|
+
queriesOf(prompt).map((line) => {
|
|
147
|
+
const query = terms(line)
|
|
148
|
+
return { query, indices: ranked(query, chunks, options, options.perQuery ?? 12) }
|
|
149
|
+
}),
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export const identityReranker: Reranker = async (_query, _chunks, candidates) => candidates
|
|
154
|
+
|
|
155
|
+
export async function pickRecallWith(prompt: string, chunks: RecallChunk[], rerank: Reranker, options: RecallOptions = {}): Promise<RecallPick[]> {
|
|
156
|
+
const hits: Array<{ query: string[]; indices: number[] }> = []
|
|
157
|
+
for (const line of queriesOf(prompt)) {
|
|
158
|
+
const query = terms(line)
|
|
159
|
+
const reordered = await rerank(line, chunks, ranked(query, chunks, options, options.candidates ?? 30))
|
|
160
|
+
hits.push({ query, indices: reordered.slice(0, options.perQuery ?? 12) })
|
|
161
|
+
}
|
|
162
|
+
return merged(hits)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function renderRecall(prompt: string, chunks: RecallChunk[], picks: RecallPick[], options: RecallOptions = {}): string | null {
|
|
166
|
+
if (picks.length === 0) return null
|
|
167
|
+
const budget = queriesOf(prompt).length > 1 ? (options.maxCharsMulti ?? 30_000) : (options.maxChars ?? 18_000)
|
|
168
|
+
const entries: string[] = []
|
|
169
|
+
let used = 0
|
|
170
|
+
for (const { index, query } of [...picks].sort((a, b) => chunks[a.index].turn - chunks[b.index].turn || a.index - b.index)) {
|
|
171
|
+
const chunk = chunks[index]
|
|
172
|
+
const where = chunk.from ? `${chunk.from}, turn ${chunk.turn}` : `turn ${chunk.turn}`
|
|
173
|
+
const lines = excerpt(chunk, query, options.salientExcerpt ?? true).map((l) => ` ${l}`)
|
|
174
|
+
const entry = `[${where}] ${chunk.call}:\n${lines.join("\n")}`
|
|
175
|
+
if (used + entry.length > budget) continue
|
|
176
|
+
entries.push(entry)
|
|
177
|
+
used += entry.length
|
|
178
|
+
}
|
|
179
|
+
if (entries.length === 0) return null
|
|
180
|
+
return recalledContext(
|
|
181
|
+
entries,
|
|
182
|
+
picks.some((p) => Boolean(chunks[p.index].from)),
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function recallFor(prompt: string, chunks: RecallChunk[], options: RecallOptions = {}): string | null {
|
|
187
|
+
if (chunks.length === 0) return null
|
|
188
|
+
return renderRecall(prompt, chunks, pickRecall(prompt, chunks, options), options)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function recallForWith(prompt: string, chunks: RecallChunk[], rerank: Reranker, options: RecallOptions = {}): Promise<string | null> {
|
|
192
|
+
if (chunks.length === 0) return null
|
|
193
|
+
return renderRecall(prompt, chunks, await pickRecallWith(prompt, chunks, rerank, options), options)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function livePrompt(messages: Message[]): { index: number; text: string } | null {
|
|
197
|
+
const { preamble, turns } = splitTurns(messages)
|
|
198
|
+
const live = turns.at(-1)
|
|
199
|
+
if (!live || !isPrompt(live[0])) return null
|
|
200
|
+
const index = preamble.length + turns.slice(0, -1).reduce((s, t) => s + t.length, 0)
|
|
201
|
+
const text = textOf(live[0])
|
|
202
|
+
.replace(/<(system-reminder|system-message|recalled-context|tool-evidence|handoff)(?:\s[^>]*)?>[\s\S]*?<\/\1>/g, "")
|
|
203
|
+
.trim()
|
|
204
|
+
return { index, text }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function withRecall(messages: Message[], fingerprint: (message: Message) => string, recalls: Record<string, string>): Message[] {
|
|
208
|
+
if (Object.keys(recalls).length === 0) return messages
|
|
209
|
+
return messages.map((message) => {
|
|
210
|
+
if (!isPrompt(message)) return message
|
|
211
|
+
const text = recalls[fingerprint(message)]
|
|
212
|
+
return text ? { ...message, content: [...blocks(message), { type: "text", text }] } : message
|
|
213
|
+
})
|
|
214
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import { paths } from "../../config/paths"
|
|
3
|
+
import { rerankQuestion } from "../prompts"
|
|
4
|
+
import type { RecallChunk, Reranker } from "."
|
|
5
|
+
|
|
6
|
+
interface RerankAnswer {
|
|
7
|
+
answers?: { where?: { probabilities?: Record<string, number> } }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const TIMEOUT_MS = 4_000
|
|
11
|
+
|
|
12
|
+
export function rerankKey(): string | null {
|
|
13
|
+
const env = process.env.TYPESAFE_API_KEY?.trim()
|
|
14
|
+
if (env) return env
|
|
15
|
+
return existsSync(paths.typesafeKey) ? readFileSync(paths.typesafeKey, "utf8").trim() : null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function rerankState(chunks: RecallChunk[], candidates: number[]): string {
|
|
19
|
+
return candidates
|
|
20
|
+
.map((index, n) => {
|
|
21
|
+
const chunk = chunks[index]
|
|
22
|
+
return `### ${n}\n[turn ${chunk.turn}] ${chunk.call}\n${chunk.lines.map((l) => l.slice(0, 240)).join("\n")}`
|
|
23
|
+
})
|
|
24
|
+
.join("\n\n")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function orderByProbability(candidates: number[], probabilities: Record<string, number>): number[] {
|
|
28
|
+
return candidates
|
|
29
|
+
.map((_, n) => n)
|
|
30
|
+
.sort((a, b) => (probabilities[String(b)] ?? 0) - (probabilities[String(a)] ?? 0) || a - b)
|
|
31
|
+
.map((n) => candidates[n])
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function failureReason(error: unknown): string {
|
|
35
|
+
if (!(error instanceof Error)) return String(error)
|
|
36
|
+
return error.name === "TimeoutError" ? `timeout after ${TIMEOUT_MS}ms` : error.message
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function jevReranker(model: string, key: string, endpoint = "https://api.typesafe.ai/v1/systemone", onError?: (reason: string) => void): Reranker {
|
|
40
|
+
return async (query, chunks, candidates) => {
|
|
41
|
+
if (candidates.length < 2) return candidates
|
|
42
|
+
try {
|
|
43
|
+
const response = await fetch(endpoint, {
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
|
|
46
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
47
|
+
body: JSON.stringify({
|
|
48
|
+
model,
|
|
49
|
+
state: rerankState(chunks, candidates),
|
|
50
|
+
questions: {
|
|
51
|
+
where: {
|
|
52
|
+
type: "choice",
|
|
53
|
+
instructions: rerankQuestion(query),
|
|
54
|
+
criteria: Object.fromEntries(candidates.map((_, n) => [String(n), null])),
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
}),
|
|
58
|
+
})
|
|
59
|
+
if (!response.ok) {
|
|
60
|
+
const body = await response.text().catch(() => "")
|
|
61
|
+
onError?.(`${response.status} ${/error_type"\s*:\s*"([^"]+)/.exec(body)?.[1] ?? body.slice(0, 60)}`.trim())
|
|
62
|
+
return candidates
|
|
63
|
+
}
|
|
64
|
+
const probabilities = ((await response.json()) as RerankAnswer).answers?.where?.probabilities
|
|
65
|
+
return probabilities && typeof probabilities === "object" ? orderByProbability(candidates, probabilities) : candidates
|
|
66
|
+
} catch (error) {
|
|
67
|
+
onError?.(failureReason(error))
|
|
68
|
+
return candidates
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import type { Body } from "../context/content"
|
|
4
|
+
|
|
5
|
+
interface Canonical {
|
|
6
|
+
system: Body["system"]
|
|
7
|
+
tools: Body["tools"]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface CanonicalResult {
|
|
11
|
+
body: Body
|
|
12
|
+
status: "applied" | "captured" | "skipped"
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const BILLING_LINE = /^x-anthropic-billing-header:[^\n]*/
|
|
16
|
+
|
|
17
|
+
function billingLine(system: Body["system"]): string | null {
|
|
18
|
+
const first = Array.isArray(system) ? String(system[0]?.text ?? "") : String(system ?? "")
|
|
19
|
+
return first.match(BILLING_LINE)?.[0] ?? null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function withBilling(system: Body["system"], line: string | null): Body["system"] {
|
|
23
|
+
if (!line) return system
|
|
24
|
+
if (!Array.isArray(system)) return String(system ?? "").replace(BILLING_LINE, line)
|
|
25
|
+
const [first, ...rest] = system
|
|
26
|
+
return [{ ...first, text: String(first?.text ?? "").replace(BILLING_LINE, line) }, ...rest]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function applyCanonical(body: Body, file: string): CanonicalResult {
|
|
30
|
+
if (!Array.isArray(body.tools) || body.tools.length === 0) return { body, status: "skipped" }
|
|
31
|
+
if (existsSync(file)) {
|
|
32
|
+
const canonical = JSON.parse(readFileSync(file, "utf8")) as Canonical
|
|
33
|
+
return { body: { ...body, system: withBilling(canonical.system, billingLine(body.system)), tools: canonical.tools }, status: "applied" }
|
|
34
|
+
}
|
|
35
|
+
mkdirSync(path.dirname(file), { recursive: true })
|
|
36
|
+
const partial = `${file}.${process.pid}.${Date.now()}`
|
|
37
|
+
writeFileSync(partial, JSON.stringify({ system: body.system, tools: body.tools } satisfies Canonical))
|
|
38
|
+
renameSync(partial, file)
|
|
39
|
+
return { body, status: "captured" }
|
|
40
|
+
}
|
package/src/proxy/compaction.ts
CHANGED
|
@@ -1,75 +1,144 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import
|
|
8
|
-
|
|
9
|
-
export
|
|
10
|
-
|
|
11
|
-
|
|
1
|
+
import type { Config } from "../config"
|
|
2
|
+
import type { Body, Message } from "../context/content"
|
|
3
|
+
import { type CompactOptions, capTurns, clearToolResults, compactMessages, countToolResults } from "../context/fold/strategies"
|
|
4
|
+
import { splitTurns, turnFingerprint } from "../context/fold/turns"
|
|
5
|
+
import { clearedOutput } from "../context/prompts"
|
|
6
|
+
import { DEFAULT_RATIO, type Estimator, estimator, targetSize } from "./estimate"
|
|
7
|
+
import { resetCut, type ThreadState } from "./state"
|
|
8
|
+
|
|
9
|
+
export interface CompactInput {
|
|
10
|
+
body: Body
|
|
11
|
+
config: Config
|
|
12
|
+
state: ThreadState
|
|
13
|
+
window: number | null
|
|
14
|
+
guide: string | null
|
|
15
|
+
transcriptDir?: string | null
|
|
16
|
+
mayCompact: boolean
|
|
17
|
+
omittedBefore?: number
|
|
18
|
+
decorate?: (messages: Message[]) => Message[]
|
|
19
|
+
reduce?: CompactOptions["reduce"]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CompactResult {
|
|
23
|
+
messages: Message[]
|
|
24
|
+
stateChanged: boolean
|
|
25
|
+
note: Record<string, unknown>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface Rolling {
|
|
29
|
+
keep: number
|
|
30
|
+
budget: number
|
|
31
|
+
}
|
|
12
32
|
|
|
33
|
+
const PROTECTED_OUTPUTS = 5
|
|
34
|
+
|
|
35
|
+
function settledCut(turns: Message[][], index: number) {
|
|
36
|
+
let cut = index
|
|
37
|
+
while (cut > 1 && JSON.stringify(turns[cut][0].content).includes("[Request interrupted")) cut -= 1
|
|
38
|
+
return cut
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function resolveCut(turns: Message[][], state: ThreadState, config: Config, rollingKeep: number | undefined) {
|
|
13
42
|
let cutIndex = state.cut ? turns.findIndex((turn) => turnFingerprint(turn) === state.cut) : -1
|
|
43
|
+
let stateChanged = false
|
|
14
44
|
if (state.cut && cutIndex === -1) {
|
|
15
45
|
resetCut(state)
|
|
16
46
|
stateChanged = true
|
|
17
47
|
}
|
|
18
48
|
if (cutIndex <= 0 && config.eval?.forceCut && turns.length >= 3) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
cutIndex = forced
|
|
22
|
-
state.cut = turnFingerprint(turns[forced])
|
|
49
|
+
cutIndex = settledCut(turns, Math.max(1, turns.length - 1 - (rollingKeep ?? 1)))
|
|
50
|
+
state.cut = turnFingerprint(turns[cutIndex])
|
|
23
51
|
stateChanged = true
|
|
24
52
|
}
|
|
53
|
+
return { cutIndex, stateChanged }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function shrinkOnce(state: ThreadState, cutIndex: number, configuredKeep: number, liveResults: () => number): string {
|
|
57
|
+
const keep = Math.min(state.keep ?? configuredKeep, cutIndex - 1)
|
|
58
|
+
if (cutIndex > 0 && keep > 0) {
|
|
59
|
+
state.keep = keep - 1
|
|
60
|
+
return `keep=${state.keep}`
|
|
61
|
+
}
|
|
62
|
+
const remaining = liveResults() - (state.cleared ?? 0) - PROTECTED_OUTPUTS
|
|
63
|
+
if (remaining <= 0) return "stuck"
|
|
64
|
+
state.cleared = (state.cleared ?? 0) + Math.ceil(remaining / 2)
|
|
65
|
+
return `cleared=${state.cleared}`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function dueRoll(turns: Message[][], cutIndex: number, rolling: Rolling, size: Estimator): number | null {
|
|
69
|
+
const next = settledCut(turns, turns.length - 1 - rolling.keep)
|
|
70
|
+
const backlog = size.of(turns.slice(Math.max(cutIndex, 0), Math.max(next, 0)).flat()) - size.fixed
|
|
71
|
+
return next >= 1 && next > cutIndex && backlog > rolling.budget ? next : null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function shrinkToTarget(over: () => boolean, step: () => string, actions: string[], rebuild: () => void) {
|
|
75
|
+
while (over()) {
|
|
76
|
+
const action = step()
|
|
77
|
+
actions.push(action)
|
|
78
|
+
if (action === "stuck") return
|
|
79
|
+
rebuild()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function compact({ body, config, state, window, guide, transcriptDir, mayCompact, omittedBefore = 0, decorate = (m) => m, reduce }: CompactInput): CompactResult {
|
|
84
|
+
const { turns } = splitTurns(body.messages)
|
|
85
|
+
const rolling = config.eval?.rolling ?? (config.mode === "default" ? { keep: config.keep, budget: config.budget } : undefined)
|
|
86
|
+
const resolved = resolveCut(turns, state, config, rolling?.keep)
|
|
87
|
+
let cutIndex = resolved.cutIndex
|
|
88
|
+
let stateChanged = resolved.stateChanged
|
|
25
89
|
|
|
26
90
|
const configuredKeep = config.keepTurns === "all" ? Number.MAX_SAFE_INTEGER : config.keepTurns
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
91
|
+
const ratio = state.ratio ?? DEFAULT_RATIO
|
|
92
|
+
const options = { guide, omittedBefore, evidence: config.eval?.evidence ?? true, reduce, capChars: config.turnCap === "off" ? undefined : config.turnCap / ratio }
|
|
93
|
+
const liveStart = (list: Message[]) =>
|
|
94
|
+
list.length -
|
|
95
|
+
splitTurns(list)
|
|
96
|
+
.turns.slice(-(turns.length - Math.max(cutIndex, 0)))
|
|
97
|
+
.flat().length
|
|
98
|
+
const compacted = () => capTurns(cutIndex > 0 ? compactMessages(body.messages, cutIndex, { ...options, keepTurns: state.keep ?? configuredKeep }) : body.messages, { ...options, keepTurns: 0 })
|
|
31
99
|
const build = () => {
|
|
32
100
|
const messages = compacted()
|
|
33
|
-
return state.cleared ? clearToolResults(messages, messages
|
|
101
|
+
return state.cleared ? clearToolResults(messages, liveStart(messages), state.cleared, clearedOutput(transcriptDir ?? null)) : messages
|
|
102
|
+
}
|
|
103
|
+
const liveResults = () => {
|
|
104
|
+
const uncleared = compacted()
|
|
105
|
+
return countToolResults(uncleared, liveStart(uncleared))
|
|
34
106
|
}
|
|
35
107
|
|
|
36
|
-
const size = estimator(body,
|
|
108
|
+
const size = estimator(body, ratio)
|
|
37
109
|
const limit = window ? config.compactAt * window : Number.POSITIVE_INFINITY
|
|
38
110
|
const target = window ? targetSize(size, window) : Number.POSITIVE_INFINITY
|
|
39
111
|
|
|
40
112
|
let messages = build()
|
|
41
|
-
let estimate = size.of(messages)
|
|
113
|
+
let estimate = size.of(decorate(messages))
|
|
42
114
|
const actions: string[] = []
|
|
43
|
-
const
|
|
115
|
+
const rebuild = () => {
|
|
116
|
+
messages = build()
|
|
117
|
+
estimate = size.of(decorate(messages))
|
|
118
|
+
}
|
|
119
|
+
const moveCut = (index: number, action: string) => {
|
|
120
|
+
cutIndex = index
|
|
121
|
+
resetCut(state)
|
|
122
|
+
state.cut = turnFingerprint(turns[index])
|
|
123
|
+
actions.push(action)
|
|
124
|
+
rebuild()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const roll = mayCompact && rolling ? dueRoll(turns, cutIndex, rolling, size) : null
|
|
128
|
+
if (roll !== null) {
|
|
129
|
+
moveCut(roll, "roll")
|
|
130
|
+
stateChanged = true
|
|
131
|
+
}
|
|
44
132
|
|
|
45
133
|
if (mayCompact && estimate > limit) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
while (estimate > target) {
|
|
55
|
-
const keep = Math.min(state.keep ?? configuredKeep, cutIndex - 1)
|
|
56
|
-
if (cutIndex > 0 && keep > 0) {
|
|
57
|
-
state.keep = keep - 1
|
|
58
|
-
actions.push(`keep=${state.keep}`)
|
|
59
|
-
} else {
|
|
60
|
-
const unclearedLive = compacted()
|
|
61
|
-
const total = countToolResults(unclearedLive, unclearedLive.length - liveLength())
|
|
62
|
-
const remaining = total - (state.cleared ?? 0) - PROTECTED_OUTPUTS
|
|
63
|
-
if (remaining <= 0) {
|
|
64
|
-
actions.push("stuck")
|
|
65
|
-
break
|
|
66
|
-
}
|
|
67
|
-
state.cleared = (state.cleared ?? 0) + Math.ceil(remaining / 2)
|
|
68
|
-
actions.push(`cleared=${state.cleared}`)
|
|
69
|
-
}
|
|
70
|
-
messages = build()
|
|
71
|
-
estimate = size.of(messages)
|
|
72
|
-
}
|
|
134
|
+
const current = turns.length - 1
|
|
135
|
+
if (current > 0 && current > cutIndex) moveCut(current, "cut")
|
|
136
|
+
shrinkToTarget(
|
|
137
|
+
() => estimate > target,
|
|
138
|
+
() => shrinkOnce(state, cutIndex, configuredKeep, liveResults),
|
|
139
|
+
actions,
|
|
140
|
+
rebuild,
|
|
141
|
+
)
|
|
73
142
|
stateChanged = true
|
|
74
143
|
}
|
|
75
144
|
|
|
@@ -78,9 +147,10 @@ export function compact({ body, config, state, window, guide, mayCompact, omitte
|
|
|
78
147
|
stateChanged,
|
|
79
148
|
note: {
|
|
80
149
|
action: cutIndex > 0 ? "compact" : "full",
|
|
81
|
-
compacted: actions.includes("cut"),
|
|
82
|
-
actions: actions.length ? actions : undefined,
|
|
150
|
+
compacted: actions.includes("cut") || actions.includes("roll"),
|
|
151
|
+
actions: actions.length > 0 ? actions : undefined,
|
|
83
152
|
cutTurn: cutIndex > 0 ? cutIndex : null,
|
|
153
|
+
omitted: cutIndex > 0 ? Math.max(1, cutIndex - (state.keep ?? configuredKeep)) - 1 : 0,
|
|
84
154
|
keep: state.keep,
|
|
85
155
|
cleared: state.cleared,
|
|
86
156
|
turns: turns.length,
|
package/src/proxy/estimate.ts
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
|
-
import type { Body, Message } from "../context/
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import type { Body, Message } from "../context/content"
|
|
2
|
+
|
|
3
|
+
export interface Estimator {
|
|
4
|
+
of: (messages: Message[]) => number
|
|
5
|
+
fixed: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_RATIO = 1 / 3.6
|
|
9
|
+
|
|
10
|
+
const MEDIA_CHARS = 5_800
|
|
4
11
|
|
|
5
12
|
export function sizeOf(body: Pick<Body, "system" | "tools" | "messages">): number {
|
|
6
13
|
let media = 0
|
|
7
14
|
const json = JSON.stringify({ system: body.system, tools: body.tools, messages: body.messages }, (key, value) => {
|
|
8
|
-
if (key
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
}
|
|
12
|
-
return value
|
|
15
|
+
if (key !== "data" || typeof value !== "string" || value.length <= 2_000) return value
|
|
16
|
+
media += 1
|
|
17
|
+
return ""
|
|
13
18
|
})
|
|
14
19
|
return json.length + media * MEDIA_CHARS
|
|
15
20
|
}
|
|
@@ -22,5 +27,5 @@ export function estimator(body: Body, ratio: number): Estimator {
|
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
export function targetSize(size: Estimator, window: number): number {
|
|
25
|
-
return size.fixed +
|
|
30
|
+
return size.fixed + 0.3 * Math.max(0, window - size.fixed)
|
|
26
31
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type Body, blocks, type Message, shortHash, withoutCacheControl } from "../context/content"
|
|
2
|
+
|
|
3
|
+
export interface ContextFingerprint {
|
|
4
|
+
system: string
|
|
5
|
+
tools: string
|
|
6
|
+
skills: string[]
|
|
7
|
+
given?: ContextFingerprint
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const isInjected = (text: unknown) => /^<(?:recalled-context|handoff)[\s>]/.test(String(text ?? ""))
|
|
11
|
+
|
|
12
|
+
export function messageFingerprint(message: Message) {
|
|
13
|
+
const own = blocks(message)
|
|
14
|
+
.filter((b) => !(b.type === "text" && isInjected(b.text)))
|
|
15
|
+
.map(withoutCacheControl)
|
|
16
|
+
return shortHash(JSON.stringify(own), 24)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const hash = (value: unknown) =>
|
|
20
|
+
shortHash(
|
|
21
|
+
JSON.stringify(value ?? null, (key, inner) => (key === "cache_control" ? undefined : inner)),
|
|
22
|
+
12,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
export function contextFingerprint(body: Body): ContextFingerprint {
|
|
26
|
+
const skills = new Set<string>()
|
|
27
|
+
for (const match of JSON.stringify(body.messages ?? []).matchAll(/Base directory for this skill: ([^\s"\\]+)/g)) skills.add(match[1].split("/").at(-1) ?? match[1])
|
|
28
|
+
return { system: hash(body.system), tools: hash(body.tools), skills: [...skills].sort() }
|
|
29
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { statSync } from "node:fs"
|
|
2
|
+
import { type Config, claudeConfigDirs } from "../config"
|
|
3
|
+
import { blocks, type Message } from "../context/content"
|
|
4
|
+
import { FOLD_CAP_CHARS, foldedView, sourceChunks } from "../context/fold"
|
|
5
|
+
import { isPrompt } from "../context/fold/turns"
|
|
6
|
+
import { handoffBlock } from "../context/prompts"
|
|
7
|
+
import type { RecallChunk } from "../context/recall"
|
|
8
|
+
import { findSessionByPrefix } from "../transcript/locate"
|
|
9
|
+
import { sessionMessages } from "../transcript/messages"
|
|
10
|
+
import { isInjected, messageFingerprint } from "./fingerprint"
|
|
11
|
+
import { titleOf } from "./sessions"
|
|
12
|
+
import type { Handoff, ThreadState } from "./state"
|
|
13
|
+
|
|
14
|
+
const sourceCache = new Map<string, { mtime: number; chunks: RecallChunk[] }>()
|
|
15
|
+
|
|
16
|
+
export const continueToken = (id: string) => `{ee:continue:${id}}`
|
|
17
|
+
|
|
18
|
+
export function continueIdOf(message: Message): string | null {
|
|
19
|
+
if (!isPrompt(message)) return null
|
|
20
|
+
const own = blocks(message)
|
|
21
|
+
.filter((b) => b.type === "text" && !isInjected(b.text))
|
|
22
|
+
.map((b) => String(b.text ?? ""))
|
|
23
|
+
.join("\n")
|
|
24
|
+
return own.match(/\{ee:continue:([A-Za-z0-9-]{8,})\}/)?.[1] ?? null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function activeHandoffs(handoffs: Record<string, Handoff | null> = {}): Record<string, Handoff> {
|
|
28
|
+
return Object.fromEntries(Object.entries(handoffs).filter((entry): entry is [string, Handoff] => Boolean(entry[1])))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function attachHandoffs(messages: Message[], config: Config, sessionId: string | null, state: ThreadState): boolean {
|
|
32
|
+
let changed = false
|
|
33
|
+
for (const message of messages) {
|
|
34
|
+
const id = continueIdOf(message)
|
|
35
|
+
if (!id) continue
|
|
36
|
+
const key = messageFingerprint(message)
|
|
37
|
+
if (state.handoffs && key in state.handoffs) continue
|
|
38
|
+
state.handoffs ??= {}
|
|
39
|
+
const file = findSessionByPrefix(id, claudeConfigDirs(config), sessionId ?? undefined)
|
|
40
|
+
const already = Object.values(state.handoffs).some((h) => h?.source === file)
|
|
41
|
+
state.handoffs[key] = file && !already ? { label: titleOf(file) ?? id, source: file, text: foldedView(sessionMessages(file), FOLD_CAP_CHARS) } : null
|
|
42
|
+
changed = true
|
|
43
|
+
}
|
|
44
|
+
return changed
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function withHandoffs(messages: Message[], handoffs: Record<string, Handoff>): Message[] {
|
|
48
|
+
if (Object.keys(handoffs).length === 0) return messages
|
|
49
|
+
return messages.map((message) => {
|
|
50
|
+
if (!isPrompt(message)) return message
|
|
51
|
+
const handoff = handoffs[messageFingerprint(message)]
|
|
52
|
+
return handoff ? { ...message, content: [{ type: "text", text: handoffBlock(handoff.label, handoff.text) }, ...blocks(message)] } : message
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function handoffChunks(handoffs: Record<string, Handoff>): RecallChunk[] {
|
|
57
|
+
const sources = new Map<string, string>()
|
|
58
|
+
for (const handoff of Object.values(handoffs)) if (!sources.has(handoff.source)) sources.set(handoff.source, handoff.label)
|
|
59
|
+
return [...sources].flatMap(([source, label]) => {
|
|
60
|
+
let mtime: number
|
|
61
|
+
try {
|
|
62
|
+
mtime = statSync(source).mtimeMs
|
|
63
|
+
} catch {
|
|
64
|
+
return []
|
|
65
|
+
}
|
|
66
|
+
const cached = sourceCache.get(source)
|
|
67
|
+
const chunks = cached?.mtime === mtime ? cached.chunks : sourceChunks(sessionMessages(source), label, true)
|
|
68
|
+
sourceCache.set(source, { mtime, chunks })
|
|
69
|
+
return chunks
|
|
70
|
+
})
|
|
71
|
+
}
|