@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,155 @@
|
|
|
1
|
+
import { BM25_B, BM25_K1, RECALL_LINE_CHARS, RECALL_MAX_CHARS, RECALL_MAX_CHARS_MULTI, RECALL_MIN_TERMS, RECALL_NARRATIVE, RECALL_PER_QUERY, RECALL_RERANK_CANDIDATES, RECALL_SALIENT_EXCERPT, RECALL_SHOWN_LINES, RECALL_WINDOW_LINES, STOPWORDS } from "./constants"
|
|
2
|
+
import { blocks, clip, describeToolCall, resultText } from "./content"
|
|
3
|
+
import { isSalient, toolPairs } from "./evidence"
|
|
4
|
+
import { isPrompt, splitTurns } from "./turns"
|
|
5
|
+
import type { Message, RecallChunk, RecallOptions, RecallPick, Reranker } from "./types"
|
|
6
|
+
|
|
7
|
+
const REMINDERS = /<(system-reminder|system-message|recalled-context|tool-evidence)>[\s\S]*?<\/\1>/g
|
|
8
|
+
|
|
9
|
+
export function terms(text: string): string[] {
|
|
10
|
+
return (text.toLowerCase().match(/[a-z0-9_@$][a-z0-9_.:/@$-]*[a-z0-9_]|[a-z0-9]/g) ?? [])
|
|
11
|
+
.flatMap((term) => (/[._:/-]/.test(term) ? [term, ...term.split(/[._:/-]+/)] : [term]))
|
|
12
|
+
.filter((term) => term.length >= 3 && !STOPWORDS.has(term))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function promptText(message: Message): string {
|
|
16
|
+
return blocks(message).filter((b) => b.type === "text").map((b) => String(b.text ?? "")).join("\n").replace(REMINDERS, "").trim()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function windows(lines: string[], size: number): string[][] {
|
|
20
|
+
const out: string[][] = []
|
|
21
|
+
for (let start = 0; start < lines.length; start += size) out.push(lines.slice(start, start + size))
|
|
22
|
+
return out
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function recallChunks(messages: Message[], keptIds: Map<string, string>, options: RecallOptions = {}): RecallChunk[] {
|
|
26
|
+
const { turns } = splitTurns(messages)
|
|
27
|
+
const size = options.windowLines ?? RECALL_WINDOW_LINES
|
|
28
|
+
const weight = Math.max(1, options.callWeight ?? 1)
|
|
29
|
+
const chunks: RecallChunk[] = []
|
|
30
|
+
turns.slice(0, -1).forEach((turn, turnIndex) => {
|
|
31
|
+
for (const { call, result } of toolPairs(turn)) {
|
|
32
|
+
const text = resultText(result)
|
|
33
|
+
if (keptIds.get(call.id) === text || !text.trim()) continue
|
|
34
|
+
const head = describeToolCall(call)
|
|
35
|
+
const headTerms = Array.from({ length: weight }, () => terms(head)).flat()
|
|
36
|
+
for (const window of windows(text.split("\n"), size)) {
|
|
37
|
+
chunks.push({ turn: turnIndex + 1, call: head, lines: window, terms: [...headTerms, ...terms(window.join("\n"))] })
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (!(options.narrative ?? RECALL_NARRATIVE)) return
|
|
41
|
+
const said = turn.slice(1, -1).filter((m) => m.role === "assistant").flatMap((m) => blocks(m).filter((b) => b.type === "text").map((b) => String(b.text ?? "").trim())).filter(Boolean)
|
|
42
|
+
for (const window of windows(said.join("\n").split("\n"), size)) {
|
|
43
|
+
chunks.push({ turn: turnIndex + 1, call: "agent", lines: window, terms: terms(window.join("\n")) })
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
return chunks
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function rank(query: string[], chunks: RecallChunk[], minTerms = RECALL_MIN_TERMS) {
|
|
50
|
+
const df = new Map<string, number>()
|
|
51
|
+
for (const chunk of chunks) for (const term of new Set(chunk.terms)) df.set(term, (df.get(term) ?? 0) + 1)
|
|
52
|
+
const average = chunks.reduce((s, c) => s + c.terms.length, 0) / Math.max(1, chunks.length)
|
|
53
|
+
const wanted = [...new Set(query)].filter((t) => df.has(t))
|
|
54
|
+
return chunks.map((chunk, index) => {
|
|
55
|
+
const tf = new Map<string, number>()
|
|
56
|
+
for (const term of chunk.terms) tf.set(term, (tf.get(term) ?? 0) + 1)
|
|
57
|
+
let score = 0
|
|
58
|
+
let matched = 0
|
|
59
|
+
for (const term of wanted) {
|
|
60
|
+
const f = tf.get(term) ?? 0
|
|
61
|
+
if (!f) continue
|
|
62
|
+
matched += 1
|
|
63
|
+
const idf = Math.log(1 + (chunks.length - df.get(term)! + 0.5) / (df.get(term)! + 0.5))
|
|
64
|
+
score += idf * (f * (BM25_K1 + 1)) / (f + BM25_K1 * (1 - BM25_B + BM25_B * chunk.terms.length / average))
|
|
65
|
+
}
|
|
66
|
+
return { index, score, matched }
|
|
67
|
+
}).filter((r) => r.matched >= Math.min(minTerms, wanted.length) && r.score > 0).sort((a, b) => b.score - a.score)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function excerpt(chunk: RecallChunk, query: Set<string>, salient: boolean): string[] {
|
|
71
|
+
const hits = chunk.lines.map((line, i) => (terms(line).some((t) => query.has(t)) ? i : -1)).filter((i) => i >= 0)
|
|
72
|
+
const shown = new Set<number>()
|
|
73
|
+
for (const i of hits) for (const j of [i - 1, i, i + 1]) if (j >= 0 && j < chunk.lines.length) shown.add(j)
|
|
74
|
+
if (salient) for (let i = 0; i < chunk.lines.length && shown.size < RECALL_SHOWN_LINES + 2; i += 1) if (isSalient(chunk.lines[i])) shown.add(i)
|
|
75
|
+
const ordered = [...shown].sort((a, b) => a - b).slice(0, salient ? RECALL_SHOWN_LINES + 2 : RECALL_SHOWN_LINES)
|
|
76
|
+
return (ordered.length ? ordered : [0]).map((i) => clip(chunk.lines[i], RECALL_LINE_CHARS)).filter(Boolean)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function queriesOf(prompt: string): string[] {
|
|
80
|
+
const lines = prompt.split("\n").map((l) => l.trim()).filter((l) => terms(l).length >= 2)
|
|
81
|
+
return lines.length > 1 ? lines : [prompt]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function pickRecall(prompt: string, chunks: RecallChunk[], options: RecallOptions = {}): RecallPick[] {
|
|
85
|
+
const perQuery = options.perQuery ?? RECALL_PER_QUERY
|
|
86
|
+
const picked = new Map<number, Set<string>>()
|
|
87
|
+
for (const query of queriesOf(prompt)) {
|
|
88
|
+
const q = terms(query)
|
|
89
|
+
for (const hit of rank(q, chunks, options.minTerms).slice(0, perQuery)) {
|
|
90
|
+
if (!picked.has(hit.index)) picked.set(hit.index, new Set())
|
|
91
|
+
for (const t of q) picked.get(hit.index)!.add(t)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return [...picked.entries()].map(([index, query]) => ({ index, query }))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function pickRecallWith(prompt: string, chunks: RecallChunk[], rerank: Reranker, options: RecallOptions = {}): Promise<RecallPick[]> {
|
|
98
|
+
const perQuery = options.perQuery ?? RECALL_PER_QUERY
|
|
99
|
+
const width = options.candidates ?? RECALL_RERANK_CANDIDATES
|
|
100
|
+
const picked = new Map<number, Set<string>>()
|
|
101
|
+
for (const query of queriesOf(prompt)) {
|
|
102
|
+
const q = terms(query)
|
|
103
|
+
const candidates = rank(q, chunks, options.minTerms).slice(0, width).map((hit) => hit.index)
|
|
104
|
+
for (const index of (await rerank(query, chunks, candidates)).slice(0, perQuery)) {
|
|
105
|
+
if (!picked.has(index)) picked.set(index, new Set())
|
|
106
|
+
for (const t of q) picked.get(index)!.add(t)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return [...picked.entries()].map(([index, query]) => ({ index, query }))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function renderRecall(prompt: string, chunks: RecallChunk[], picks: RecallPick[], options: RecallOptions = {}): string | null {
|
|
113
|
+
if (!picks.length) return null
|
|
114
|
+
const budget = queriesOf(prompt).length > 1 ? options.maxCharsMulti ?? RECALL_MAX_CHARS_MULTI : options.maxChars ?? RECALL_MAX_CHARS
|
|
115
|
+
const entries: string[] = []
|
|
116
|
+
let used = 0
|
|
117
|
+
for (const pick of [...picks].sort((a, b) => chunks[a.index].turn - chunks[b.index].turn || a.index - b.index)) {
|
|
118
|
+
const chunk = chunks[pick.index]
|
|
119
|
+
const entry = `[turn ${chunk.turn}] ${chunk.call}:\n${excerpt(chunk, pick.query, options.salientExcerpt ?? RECALL_SALIENT_EXCERPT).map((l) => ` ${l}`).join("\n")}`
|
|
120
|
+
if (used + entry.length > budget) continue
|
|
121
|
+
entries.push(entry)
|
|
122
|
+
used += entry.length
|
|
123
|
+
}
|
|
124
|
+
return entries.length
|
|
125
|
+
? `<recalled-context>\nExcerpts from earlier in this session that match this message: tool output that is no longer in your context, quoted exactly, oldest first. Where the same thing appears twice, the later turn is current. Use them if they answer what was asked; ignore them otherwise.\n\n${entries.join("\n")}\n</recalled-context>`
|
|
126
|
+
: null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function recallFor(prompt: string, chunks: RecallChunk[], options: RecallOptions = {}): string | null {
|
|
130
|
+
if (!chunks.length) return null
|
|
131
|
+
return renderRecall(prompt, chunks, pickRecall(prompt, chunks, options), options)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function recallForWith(prompt: string, chunks: RecallChunk[], rerank: Reranker, options: RecallOptions = {}): Promise<string | null> {
|
|
135
|
+
if (!chunks.length) return null
|
|
136
|
+
return renderRecall(prompt, chunks, await pickRecallWith(prompt, chunks, rerank, options), options)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function livePrompt(messages: Message[]): { index: number; text: string } | null {
|
|
140
|
+
const { preamble, turns } = splitTurns(messages)
|
|
141
|
+
if (turns.length < 2) return null
|
|
142
|
+
const first = turns.at(-1)![0]
|
|
143
|
+
if (!isPrompt(first)) return null
|
|
144
|
+
const index = preamble.length + turns.slice(0, -1).reduce((s, t) => s + t.length, 0)
|
|
145
|
+
return { index, text: promptText(first) }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function withRecall(messages: Message[], fingerprint: (message: Message) => string, recalls: Record<string, string>): Message[] {
|
|
149
|
+
if (!Object.keys(recalls).length) return messages
|
|
150
|
+
return messages.map((message) => {
|
|
151
|
+
if (!isPrompt(message)) return message
|
|
152
|
+
const text = recalls[fingerprint(message)]
|
|
153
|
+
return text ? { ...message, content: [...blocks(message), { type: "text", text }] } : message
|
|
154
|
+
})
|
|
155
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import { paths } from "../config/constants"
|
|
3
|
+
import { RERANK_ENDPOINT, RERANK_LINE_CHARS, RERANK_TIMEOUT_MS } from "./constants"
|
|
4
|
+
import type { RecallChunk, Reranker } from "./types"
|
|
5
|
+
|
|
6
|
+
export function rerankKey(): string | null {
|
|
7
|
+
const env = process.env.TYPESAFE_API_KEY?.trim()
|
|
8
|
+
if (env) return env
|
|
9
|
+
return existsSync(paths.typesafeKey) ? readFileSync(paths.typesafeKey, "utf8").trim() : null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function rerankState(chunks: RecallChunk[], candidates: number[]): string {
|
|
13
|
+
return candidates.map((index, n) => `### ${n}\n[turn ${chunks[index].turn}] ${chunks[index].call}\n${chunks[index].lines.map((l) => l.slice(0, RERANK_LINE_CHARS)).join("\n")}`).join("\n\n")
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function orderByProbability(candidates: number[], probabilities: Record<string, number>): number[] {
|
|
17
|
+
return candidates.map((_, n) => n).sort((a, b) => (probabilities[String(b)] ?? 0) - (probabilities[String(a)] ?? 0) || a - b).map((n) => candidates[n])
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function jevReranker(model: string, key: string, endpoint = RERANK_ENDPOINT): Reranker {
|
|
21
|
+
return async (query, chunks, candidates) => {
|
|
22
|
+
if (candidates.length < 2) return candidates
|
|
23
|
+
try {
|
|
24
|
+
const response = await fetch(endpoint, {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
|
|
27
|
+
signal: AbortSignal.timeout(RERANK_TIMEOUT_MS),
|
|
28
|
+
body: JSON.stringify({
|
|
29
|
+
model,
|
|
30
|
+
state: rerankState(chunks, candidates),
|
|
31
|
+
questions: {
|
|
32
|
+
where: { type: "choice", instructions: `Which excerpt contains the exact answer to the developer's question about this coding session: "${query}"?`, criteria: Object.fromEntries(candidates.map((_, n) => [String(n), null])) },
|
|
33
|
+
},
|
|
34
|
+
}),
|
|
35
|
+
})
|
|
36
|
+
if (!response.ok) return candidates
|
|
37
|
+
const json: any = await response.json()
|
|
38
|
+
const probabilities = json?.answers?.where?.probabilities
|
|
39
|
+
return probabilities && typeof probabilities === "object" ? orderByProbability(candidates, probabilities) : candidates
|
|
40
|
+
} catch {
|
|
41
|
+
return candidates
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { CLEARED_INPUT_KEPT, CLEARED_INPUT_THRESHOLD } from "./constants"
|
|
2
|
-
import { blocks, withoutCacheControl } from "./content"
|
|
2
|
+
import { blocks, truncate, withoutCacheControl } from "./content"
|
|
3
3
|
import { compactionNotice, truncationNotice } from "./prompts"
|
|
4
4
|
import { hasContent, isHarness, reduceTurn, splitTurns } from "./turns"
|
|
5
|
-
import type { Block, Body, CompactOptions, Message } from "./types"
|
|
5
|
+
import type { Block, Body, CompactOptions, Message, ReduceOptions } from "./types"
|
|
6
6
|
|
|
7
|
-
export function trimMessages(messages: Message[]): Message[] {
|
|
7
|
+
export function trimMessages(messages: Message[], options: ReduceOptions = {}): Message[] {
|
|
8
8
|
const { preamble, turns } = splitTurns(messages)
|
|
9
9
|
if (turns.length < 2) return messages
|
|
10
|
-
return [...preamble, ...turns.slice(0, -1).flatMap(reduceTurn), ...turns.at(-1)!]
|
|
10
|
+
return [...preamble, ...turns.slice(0, -1).flatMap((turn) => reduceTurn(turn, options)), ...turns.at(-1)!]
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export function compactMessages(
|
|
@@ -28,7 +28,7 @@ export function compactMessages(
|
|
|
28
28
|
...preamble,
|
|
29
29
|
turns[0][0],
|
|
30
30
|
{ role: "user", content: [{ type: "text", text: notice }, ...carried] },
|
|
31
|
-
...turns.slice(keptFrom, cutIndex).flatMap(reduceTurn),
|
|
31
|
+
...turns.slice(keptFrom, cutIndex).flatMap((turn) => reduceTurn(turn, options)),
|
|
32
32
|
...turns.slice(cutIndex).flat(),
|
|
33
33
|
]
|
|
34
34
|
}
|
|
@@ -73,7 +73,7 @@ export function clearToolResults(messages: Message[], from: number, count: numbe
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
function shortenInput(input: unknown): unknown {
|
|
76
|
-
if (typeof input === "string") return input.length > CLEARED_INPUT_THRESHOLD ? `${input
|
|
76
|
+
if (typeof input === "string") return input.length > CLEARED_INPUT_THRESHOLD ? `${truncate(input, CLEARED_INPUT_KEPT)}… [${input.length - CLEARED_INPUT_KEPT} chars cleared; in the transcript]` : input
|
|
77
77
|
if (Array.isArray(input)) return input.map(shortenInput)
|
|
78
78
|
if (input && typeof input === "object") return Object.fromEntries(Object.entries(input).map(([k, v]) => [k, shortenInput(v)]))
|
|
79
79
|
return input
|
package/src/context/turns.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { blocks, clean,
|
|
3
|
-
import
|
|
1
|
+
import { REMINDER_ONLY, SUMMARY_CALLS_SHOWN, SUMMARY_LAST_WORDS_CHARS } from "./constants"
|
|
2
|
+
import { blocks, clean, describeToolCall, truncate, withoutCacheControl } from "./content"
|
|
3
|
+
import { turnEvidence } from "./evidence"
|
|
4
|
+
import type { Block, Message, ReduceOptions } from "./types"
|
|
4
5
|
|
|
5
6
|
export function isPrompt(message: Message) {
|
|
6
7
|
if (message.role !== "user") return false
|
|
@@ -43,14 +44,6 @@ function mergedHarness(turn: Message[]): Message[] {
|
|
|
43
44
|
return content.length > 0 ? [{ role: "system", content }] : []
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
function describeToolCall(block: Block): string {
|
|
47
|
-
const input = block.input ?? {}
|
|
48
|
-
if (typeof input.command === "string") return `${block.name} \`${clip(input.command, SUMMARY_COMMAND_CHARS)}\``
|
|
49
|
-
if (typeof input.file_path === "string") return `${block.name} ${input.file_path}`
|
|
50
|
-
if (typeof input.pattern === "string") return `${block.name} "${clip(input.pattern, SUMMARY_ARGUMENT_CHARS)}"`
|
|
51
|
-
return `${block.name} ${clip(JSON.stringify(input), SUMMARY_ARGUMENT_CHARS)}`
|
|
52
|
-
}
|
|
53
|
-
|
|
54
47
|
function interruptedSummary(turn: Message[]): string {
|
|
55
48
|
const assistant = turn.filter((m) => m.role === "assistant")
|
|
56
49
|
const lastWords = [...assistant].reverse()
|
|
@@ -60,17 +53,29 @@ function interruptedSummary(turn: Message[]): string {
|
|
|
60
53
|
const shown = calls.slice(-SUMMARY_CALLS_SHOWN)
|
|
61
54
|
return [
|
|
62
55
|
"[This turn was interrupted before a final reply.]",
|
|
63
|
-
lastWords ? `Last thing you said: ${lastWords.length > SUMMARY_LAST_WORDS_CHARS ? `${lastWords
|
|
56
|
+
lastWords ? `Last thing you said: ${lastWords.length > SUMMARY_LAST_WORDS_CHARS ? `${truncate(lastWords, SUMMARY_LAST_WORDS_CHARS)}…` : lastWords}` : "",
|
|
64
57
|
calls.length > 0 ? `Tool calls (${calls.length}${calls.length > shown.length ? `, last ${shown.length} shown` : ""}): ${shown.join("; ")}` : "",
|
|
65
58
|
"Their output is in the full transcript.",
|
|
66
59
|
].filter(Boolean).join("\n")
|
|
67
60
|
}
|
|
68
61
|
|
|
69
|
-
|
|
62
|
+
function carriedText(messages: Message[]): Block[] {
|
|
63
|
+
return messages
|
|
64
|
+
.filter((m) => m.role === "user")
|
|
65
|
+
.flatMap((m) => blocks(m).filter((b) => b.type === "text" && String(b.text ?? "").trim() && !REMINDER_ONLY.test(String(b.text))))
|
|
66
|
+
.map((b) => ({ type: "text", text: b.text }))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function reduceTurn(turn: Message[], options: ReduceOptions = {}): Message[] {
|
|
70
70
|
const opening: Message[] = []
|
|
71
|
-
|
|
71
|
+
let i = 0
|
|
72
|
+
for (; i < turn.length && turn[i].role !== "assistant"; i += 1) {
|
|
72
73
|
if (turn[i].role === "user") opening.push(clean(turn[i]))
|
|
73
74
|
}
|
|
75
|
+
const carried = carriedText(turn.slice(i))
|
|
76
|
+
if (carried.length > 0) opening.push({ role: "user", content: carried })
|
|
77
|
+
const evidence = options.evidence ? turnEvidence(turn) : null
|
|
78
|
+
if (evidence) opening.push({ role: "user", content: [{ type: "text", text: evidence }] })
|
|
74
79
|
opening.push(...mergedHarness(turn))
|
|
75
80
|
const last = [...turn].reverse().find((m) => m.role !== "system")!
|
|
76
81
|
const content = last.role === "assistant" ? blocks(last) : []
|
package/src/context/types.ts
CHANGED
|
@@ -11,7 +11,11 @@ export type Body = {
|
|
|
11
11
|
[key: string]: any
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
export interface
|
|
14
|
+
export interface ReduceOptions {
|
|
15
|
+
evidence?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CompactOptions extends ReduceOptions {
|
|
15
19
|
keepTurns: number
|
|
16
20
|
guide: string | null
|
|
17
21
|
omittedBefore?: number
|
|
@@ -22,3 +26,41 @@ export interface NoticeCounts {
|
|
|
22
26
|
kept: number
|
|
23
27
|
guide: string | null
|
|
24
28
|
}
|
|
29
|
+
|
|
30
|
+
export interface RecallChunk {
|
|
31
|
+
turn: number
|
|
32
|
+
call: string
|
|
33
|
+
lines: string[]
|
|
34
|
+
terms: string[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface EvidenceOptions {
|
|
38
|
+
linesPerCall?: number
|
|
39
|
+
lineChars?: number
|
|
40
|
+
turnChars?: number
|
|
41
|
+
scanLines?: number
|
|
42
|
+
minScore?: number
|
|
43
|
+
allTools?: boolean
|
|
44
|
+
ranked?: boolean
|
|
45
|
+
labels?: boolean
|
|
46
|
+
callFloor?: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface RecallOptions {
|
|
50
|
+
windowLines?: number
|
|
51
|
+
perQuery?: number
|
|
52
|
+
minTerms?: number
|
|
53
|
+
maxChars?: number
|
|
54
|
+
maxCharsMulti?: number
|
|
55
|
+
narrative?: boolean
|
|
56
|
+
callWeight?: number
|
|
57
|
+
salientExcerpt?: boolean
|
|
58
|
+
candidates?: number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type Reranker = (query: string, chunks: RecallChunk[], candidates: number[]) => Promise<number[]>
|
|
62
|
+
|
|
63
|
+
export interface RecallPick {
|
|
64
|
+
index: number
|
|
65
|
+
query: Set<string>
|
|
66
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import type { Body } from "../context/types"
|
|
4
|
+
import { BILLING_LINE } from "./constants"
|
|
5
|
+
import type { Canonical, CanonicalResult } from "./types"
|
|
6
|
+
|
|
7
|
+
const billingLine = (system: Body["system"]) => {
|
|
8
|
+
const first = Array.isArray(system) ? String(system[0]?.text ?? "") : String(system ?? "")
|
|
9
|
+
return first.match(BILLING_LINE)?.[0] ?? null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function withBilling(system: Body["system"], line: string | null): Body["system"] {
|
|
13
|
+
if (!line) return system
|
|
14
|
+
if (Array.isArray(system)) {
|
|
15
|
+
const [first, ...rest] = system
|
|
16
|
+
return [{ ...first, text: String(first?.text ?? "").replace(BILLING_LINE, line) }, ...rest]
|
|
17
|
+
}
|
|
18
|
+
return String(system ?? "").replace(BILLING_LINE, line)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function applyCanonical(body: Body, file: string): CanonicalResult {
|
|
22
|
+
if (!Array.isArray(body.tools) || body.tools.length === 0) return { body, status: "skipped" }
|
|
23
|
+
if (existsSync(file)) {
|
|
24
|
+
const canonical = JSON.parse(readFileSync(file, "utf8")) as Canonical
|
|
25
|
+
return { body: { ...body, system: withBilling(canonical.system, billingLine(body.system)), tools: canonical.tools }, status: "applied" }
|
|
26
|
+
}
|
|
27
|
+
mkdirSync(path.dirname(file), { recursive: true })
|
|
28
|
+
const partial = `${file}.${process.pid}.${Date.now()}`
|
|
29
|
+
writeFileSync(partial, JSON.stringify({ system: body.system, tools: body.tools } satisfies Canonical))
|
|
30
|
+
renameSync(partial, file)
|
|
31
|
+
return { body, status: "captured" }
|
|
32
|
+
}
|
package/src/proxy/compaction.ts
CHANGED
|
@@ -4,8 +4,15 @@ import { splitTurns, turnFingerprint } from "../context/turns"
|
|
|
4
4
|
import { DEFAULT_RATIO, PROTECTED_OUTPUTS } from "./constants"
|
|
5
5
|
import { estimator, targetSize } from "./estimate"
|
|
6
6
|
import { resetCut } from "./state"
|
|
7
|
+
import type { Message } from "../context/types"
|
|
7
8
|
import type { CompactInput, CompactResult } from "./types"
|
|
8
9
|
|
|
10
|
+
function settledCut(turns: Message[][], index: number) {
|
|
11
|
+
let cut = index
|
|
12
|
+
while (cut > 1 && JSON.stringify(turns[cut][0].content).includes("[Request interrupted")) cut -= 1
|
|
13
|
+
return cut
|
|
14
|
+
}
|
|
15
|
+
|
|
9
16
|
export function compact({ body, config, state, window, guide, mayCompact, omittedBefore = 0 }: CompactInput): CompactResult {
|
|
10
17
|
const { turns } = splitTurns(body.messages)
|
|
11
18
|
let stateChanged = false
|
|
@@ -15,9 +22,9 @@ export function compact({ body, config, state, window, guide, mayCompact, omitte
|
|
|
15
22
|
resetCut(state)
|
|
16
23
|
stateChanged = true
|
|
17
24
|
}
|
|
25
|
+
const rolling = config.eval?.rolling ?? (config.mode === "default" ? { keep: config.keep, budget: config.budget } : undefined)
|
|
18
26
|
if (cutIndex <= 0 && config.eval?.forceCut && turns.length >= 3) {
|
|
19
|
-
|
|
20
|
-
while (forced > 1 && JSON.stringify(turns[forced][0].content).includes("[Request interrupted")) forced -= 1
|
|
27
|
+
const forced = settledCut(turns, Math.max(1, turns.length - 1 - (rolling?.keep ?? 1)))
|
|
21
28
|
cutIndex = forced
|
|
22
29
|
state.cut = turnFingerprint(turns[forced])
|
|
23
30
|
stateChanged = true
|
|
@@ -26,7 +33,7 @@ export function compact({ body, config, state, window, guide, mayCompact, omitte
|
|
|
26
33
|
const configuredKeep = config.keepTurns === "all" ? Number.MAX_SAFE_INTEGER : config.keepTurns
|
|
27
34
|
const liveLength = () => turns.slice(Math.max(cutIndex, 0)).flat().length
|
|
28
35
|
const compacted = () => cutIndex > 0
|
|
29
|
-
? compactMessages(body.messages, cutIndex, { keepTurns: state.keep ?? configuredKeep, guide, omittedBefore })
|
|
36
|
+
? compactMessages(body.messages, cutIndex, { keepTurns: state.keep ?? configuredKeep, guide, omittedBefore, evidence: config.eval?.evidence ?? true })
|
|
30
37
|
: body.messages
|
|
31
38
|
const build = () => {
|
|
32
39
|
const messages = compacted()
|
|
@@ -41,16 +48,26 @@ export function compact({ body, config, state, window, guide, mayCompact, omitte
|
|
|
41
48
|
let estimate = size.of(messages)
|
|
42
49
|
const actions: string[] = []
|
|
43
50
|
const current = turns.length - 1
|
|
51
|
+
const moveCut = (index: number, action: string) => {
|
|
52
|
+
cutIndex = index
|
|
53
|
+
resetCut(state)
|
|
54
|
+
state.cut = turnFingerprint(turns[index])
|
|
55
|
+
actions.push(action)
|
|
56
|
+
messages = build()
|
|
57
|
+
estimate = size.of(messages)
|
|
58
|
+
}
|
|
44
59
|
|
|
45
|
-
if (mayCompact &&
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
messages = build()
|
|
52
|
-
estimate = size.of(messages)
|
|
60
|
+
if (mayCompact && rolling) {
|
|
61
|
+
const next = settledCut(turns, current - rolling.keep)
|
|
62
|
+
const backlog = size.of(turns.slice(Math.max(cutIndex, 0), Math.max(next, 0)).flat()) - size.fixed
|
|
63
|
+
if (next >= 1 && next > cutIndex && backlog > rolling.budget) {
|
|
64
|
+
moveCut(next, "roll")
|
|
65
|
+
stateChanged = true
|
|
53
66
|
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (mayCompact && estimate > limit) {
|
|
70
|
+
if (current > 0 && current > cutIndex) moveCut(current, "cut")
|
|
54
71
|
while (estimate > target) {
|
|
55
72
|
const keep = Math.min(state.keep ?? configuredKeep, cutIndex - 1)
|
|
56
73
|
if (cutIndex > 0 && keep > 0) {
|
|
@@ -78,7 +95,7 @@ export function compact({ body, config, state, window, guide, mayCompact, omitte
|
|
|
78
95
|
stateChanged,
|
|
79
96
|
note: {
|
|
80
97
|
action: cutIndex > 0 ? "compact" : "full",
|
|
81
|
-
compacted: actions.includes("cut"),
|
|
98
|
+
compacted: actions.includes("cut") || actions.includes("roll"),
|
|
82
99
|
actions: actions.length ? actions : undefined,
|
|
83
100
|
cutTurn: cutIndex > 0 ? cutIndex : null,
|
|
84
101
|
keep: state.keep,
|
package/src/proxy/constants.ts
CHANGED
|
@@ -2,6 +2,9 @@ export const HEALTH_PATH = "/_eunoe/health"
|
|
|
2
2
|
export const SESSION_HEADER = "x-claude-code-session-id"
|
|
3
3
|
export const TRANSCRIPT_HEADER = "x-eunoe-transcript"
|
|
4
4
|
export const OMITTED_BEFORE_HEADER = "x-eunoe-omitted-before"
|
|
5
|
+
export const CANONICAL_HEADER = "x-eunoe-canonical"
|
|
6
|
+
export const WINDOW_HEADER = "x-eunoe-window"
|
|
7
|
+
export const BILLING_LINE = /^x-anthropic-billing-header:[^\n]*/
|
|
5
8
|
|
|
6
9
|
export const TARGET_SHARE = 0.3
|
|
7
10
|
export const PROTECTED_OUTPUTS = 5
|
|
@@ -17,3 +20,10 @@ export const MODEL_LOOKUP_TIMEOUT_MS = 5_000
|
|
|
17
20
|
export const MAX_EVENT_BUFFER = 4_000_000
|
|
18
21
|
export const LOGGED_ERROR_CHARS = 500
|
|
19
22
|
export const IDLE_TIMEOUT_SECONDS = 255
|
|
23
|
+
|
|
24
|
+
export const SESSION_SEEN_INTERVAL_MS = 30_000
|
|
25
|
+
export const SESSION_HEAD_BYTES = 65_536
|
|
26
|
+
export const SESSIONS_KEPT = 200
|
|
27
|
+
|
|
28
|
+
export const FINGERPRINT_LENGTH = 12
|
|
29
|
+
export const SKILL_MARKER = /Base directory for this skill: ([^\s"\\]+)/g
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Body } from "../context/types"
|
|
2
|
+
import { FINGERPRINT_LENGTH, SKILL_MARKER } from "./constants"
|
|
3
|
+
import type { ContextFingerprint } from "./types"
|
|
4
|
+
|
|
5
|
+
const hash = (value: unknown) => {
|
|
6
|
+
const hasher = new Bun.CryptoHasher("sha256")
|
|
7
|
+
hasher.update(JSON.stringify(value ?? null, (key, inner) => (key === "cache_control" ? undefined : inner)))
|
|
8
|
+
return hasher.digest("hex").slice(0, FINGERPRINT_LENGTH)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function contextFingerprint(body: Body): ContextFingerprint {
|
|
12
|
+
const skills = new Set<string>()
|
|
13
|
+
for (const match of JSON.stringify(body.messages ?? []).matchAll(SKILL_MARKER)) skills.add(match[1].split("/").at(-1)!)
|
|
14
|
+
return { system: hash(body.system), tools: hash(body.tools), skills: [...skills].sort() }
|
|
15
|
+
}
|
package/src/proxy/rewrite.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { searchOnlyInstructions, trimInstructions, TRANSCRIPT_FALLBACK } from "../context/prompts"
|
|
2
|
+
import { recallChunks, recallFor, recallForWith, livePrompt, withRecall } from "../context/recall"
|
|
3
|
+
import { jevReranker, rerankKey } from "../context/rerank"
|
|
2
4
|
import { appendSystem, tailMessages, trimMessages } from "../context/strategies"
|
|
5
|
+
import { blocks, resultText, withoutCacheControl } from "../context/content"
|
|
3
6
|
import { splitTurns, turnFingerprint } from "../context/turns"
|
|
4
|
-
import type { Body } from "../context/types"
|
|
7
|
+
import type { Body, Message, Reranker } from "../context/types"
|
|
8
|
+
import { sessionMessages } from "../transcript/messages"
|
|
5
9
|
import { searchGuide } from "../transcript/search"
|
|
10
|
+
import { RERANK_OFF } from "../config/constants"
|
|
11
|
+
import type { Config } from "../config/types"
|
|
6
12
|
import { compact } from "./compaction"
|
|
7
13
|
import { DEFAULT_RATIO } from "./constants"
|
|
8
14
|
import { estimator, targetSize } from "./estimate"
|
|
@@ -13,7 +19,76 @@ const pass = (body: Body): Rewrite => ({ body, threadKey: null, note: { action:
|
|
|
13
19
|
|
|
14
20
|
const isAgentThread = (body: Body) => Array.isArray(body.tools) && body.tools.length > 0
|
|
15
21
|
|
|
22
|
+
function messageFingerprint(message: Message) {
|
|
23
|
+
const hasher = new Bun.CryptoHasher("sha256")
|
|
24
|
+
hasher.update(JSON.stringify(blocks(message).filter((b) => !(b.type === "text" && String(b.text ?? "").startsWith("<recalled-context>"))).map(withoutCacheControl)))
|
|
25
|
+
return hasher.digest("hex").slice(0, 24)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const wantsRecall = (input: RewriteInput) => (input.config.eval?.recall ?? true) && input.config.mode !== "off"
|
|
29
|
+
|
|
30
|
+
function rerankModel(config: Config): string | null {
|
|
31
|
+
const model = config.eval?.rerank?.model ?? config.rerank
|
|
32
|
+
return model && model !== RERANK_OFF ? model : null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function droppedChunks(original: Body, result: Rewrite, transcript: string | null) {
|
|
36
|
+
const kept = new Map<string, string>()
|
|
37
|
+
for (const message of result.body.messages) for (const b of blocks(message)) if (b.type === "tool_result") kept.set(b.tool_use_id, resultText(b))
|
|
38
|
+
const chunks = recallChunks(original.messages, kept)
|
|
39
|
+
return chunks.length || !transcript ? chunks : recallChunks([...sessionMessages(transcript), { role: "user", content: "" }], kept)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function recallTarget(original: Body, result: Rewrite): { key: string; text: string } | null {
|
|
43
|
+
if (!result.threadKey || result.body === original && result.note.action === "pass") return null
|
|
44
|
+
const live = livePrompt(original.messages)
|
|
45
|
+
return live ? { key: messageFingerprint(original.messages[live.index]), text: live.text } : null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function recalled(original: Body, result: Rewrite, transcript: string | null): Rewrite {
|
|
49
|
+
if (!result.threadKey || result.body === original && result.note.action === "pass") return result
|
|
50
|
+
const state = loadState(result.threadKey)
|
|
51
|
+
state.recalls ??= {}
|
|
52
|
+
const target = recallTarget(original, result)
|
|
53
|
+
let note = result.note
|
|
54
|
+
if (target && !(target.key in state.recalls)) {
|
|
55
|
+
state.recalls[target.key] = recallFor(target.text, droppedChunks(original, result, transcript)) ?? ""
|
|
56
|
+
saveState(result.threadKey, state)
|
|
57
|
+
note = { ...note, recalled: state.recalls[target.key].length }
|
|
58
|
+
}
|
|
59
|
+
const recalls = Object.fromEntries(Object.entries(state.recalls).filter(([, text]) => text))
|
|
60
|
+
const messages = withRecall(result.body.messages, messageFingerprint, recalls)
|
|
61
|
+
return messages === result.body.messages ? { ...result, note } : { ...result, body: { ...result.body, messages }, note }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function prefetchRecall(input: RewriteInput, rerank: Reranker): Promise<boolean> {
|
|
65
|
+
if (!wantsRecall(input)) return false
|
|
66
|
+
const result = strategize(input)
|
|
67
|
+
const target = recallTarget(input.body, result)
|
|
68
|
+
if (!target) return false
|
|
69
|
+
const state = loadState(result.threadKey!)
|
|
70
|
+
state.recalls ??= {}
|
|
71
|
+
if (target.key in state.recalls) return false
|
|
72
|
+
const chunks = droppedChunks(input.body, result, input.transcript?.jsonl ?? null)
|
|
73
|
+
state.recalls[target.key] = (await recallForWith(target.text, chunks, rerank, { candidates: input.config.eval?.rerank?.candidates })) ?? ""
|
|
74
|
+
saveState(result.threadKey!, state)
|
|
75
|
+
return true
|
|
76
|
+
}
|
|
77
|
+
|
|
16
78
|
export function rewrite(input: RewriteInput): Rewrite {
|
|
79
|
+
const result = strategize(input)
|
|
80
|
+
return wantsRecall(input) ? recalled(input.body, result, input.transcript?.jsonl ?? null) : result
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function rewriteAsync(input: RewriteInput): Promise<Rewrite> {
|
|
84
|
+
const model = rerankModel(input.config)
|
|
85
|
+
const key = model ? rerankKey() : null
|
|
86
|
+
const reranked = model && key ? await prefetchRecall(input, jevReranker(model, key)) : false
|
|
87
|
+
const result = rewrite(input)
|
|
88
|
+
return reranked ? { ...result, note: { ...result.note, reranked: model } } : result
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function strategize(input: RewriteInput): Rewrite {
|
|
17
92
|
const { body, sessionId, config, transcript } = input
|
|
18
93
|
if (config.mode === "off" || !sessionId || !Array.isArray(body.messages) || body.messages.length === 0) return pass(body)
|
|
19
94
|
const { turns } = splitTurns(body.messages)
|
|
@@ -48,15 +123,14 @@ export function rewrite(input: RewriteInput): Rewrite {
|
|
|
48
123
|
note: { action: "tail", keptTurns: turns.length - from, turns: turns.length, messagesIn: body.messages.length, messagesOut: messages.length, estimate: size.of(messages) },
|
|
49
124
|
}
|
|
50
125
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
note: { action: "trim", turns: turns.length, messagesIn: body.messages.length, messagesOut: messages.length },
|
|
126
|
+
case "trim": {
|
|
127
|
+
if (!isAgentThread(body)) return pass(body)
|
|
128
|
+
const messages = trimMessages(body.messages, { evidence: config.eval?.evidence ?? true })
|
|
129
|
+
return {
|
|
130
|
+
body: { ...body, system: appendSystem(body.system, trimInstructions(guide ?? TRANSCRIPT_FALLBACK)), messages },
|
|
131
|
+
threadKey,
|
|
132
|
+
note: { action: "trim", turns: turns.length, messagesIn: body.messages.length, messagesOut: messages.length },
|
|
133
|
+
}
|
|
60
134
|
}
|
|
61
135
|
}
|
|
62
136
|
|