@chroxify/eunoe 1.2.2 → 1.3.1
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 +150 -6
- package/dist/core/index.js +2354 -0
- package/dist/types/context/content.d.ts +31 -0
- package/dist/types/context/fold/evidence.d.ts +20 -0
- package/dist/types/context/fold/index.d.ts +11 -0
- package/dist/types/context/fold/strategies.d.ts +20 -0
- package/dist/types/context/fold/turns.d.ts +16 -0
- package/dist/types/context/prompts.d.ts +24 -0
- package/dist/types/context/recall/index.d.ts +47 -0
- package/dist/types/context/recall/rerank.d.ts +3 -0
- package/dist/types/core/compaction.d.ts +32 -0
- package/dist/types/core/config.d.ts +34 -0
- package/dist/types/core/estimate.d.ts +9 -0
- package/dist/types/core/fingerprint.d.ts +10 -0
- package/dist/types/core/fold.d.ts +18 -0
- package/dist/types/core/forward.d.ts +24 -0
- package/dist/types/core/handoff.d.ts +10 -0
- package/dist/types/core/index.d.ts +14 -0
- package/dist/types/core/recall.d.ts +9 -0
- package/dist/types/core/rewrite.d.ts +63 -0
- package/dist/types/core/state.d.ts +25 -0
- package/dist/types/core/subagents.d.ts +10 -0
- package/dist/types/core/transcript.d.ts +9 -0
- package/dist/types/core/usage.d.ts +8 -0
- package/dist/types/core/window.d.ts +8 -0
- package/dist/types/transcript/format.d.ts +16 -0
- package/dist/types/transcript/parse.d.ts +45 -0
- package/package.json +15 -2
- package/src/cli/commands/mode.ts +2 -1
- package/src/cli/commands/status.ts +1 -1
- package/src/cli/proxy/stats.ts +1 -1
- package/src/cli/replay/index.ts +3 -3
- package/src/cli/sessions/index.ts +2 -2
- package/src/config/index.ts +4 -27
- package/src/context/content.ts +64 -1
- package/src/context/fold/strategies.ts +63 -10
- package/src/context/fold/turns.ts +34 -2
- package/src/context/prompts.ts +4 -3
- package/src/context/recall/index.ts +8 -4
- package/src/context/recall/rerank.ts +0 -8
- package/src/{proxy → core}/compaction.ts +85 -28
- package/src/core/config.ts +31 -0
- package/src/core/fold.ts +71 -0
- package/src/core/forward.ts +194 -0
- package/src/{proxy → core}/handoff.ts +23 -23
- package/src/core/index.ts +14 -0
- package/src/core/recall.ts +80 -0
- package/src/core/rewrite.ts +201 -0
- package/src/core/state.ts +50 -0
- package/src/core/subagents.ts +28 -0
- package/src/core/transcript.ts +44 -0
- package/src/{proxy → core}/usage.ts +43 -1
- package/src/core/window.ts +69 -0
- package/src/proxy/rewrite.ts +22 -139
- package/src/proxy/server.ts +21 -159
- package/src/proxy/sessions.ts +1 -1
- package/src/proxy/sources.ts +35 -0
- package/src/proxy/state.ts +2 -20
- package/src/proxy/window.ts +3 -33
- package/src/transcript/format.ts +1 -2
- package/src/transcript/subagents.ts +3 -3
- package/src/proxy/recall.ts +0 -58
- /package/src/{proxy → core}/estimate.ts +0 -0
- /package/src/{proxy → core}/fingerprint.ts +0 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Message } from "../context/content"
|
|
2
|
+
import { isPrompt } from "../context/fold/turns"
|
|
3
|
+
import { messageFingerprint } from "./fingerprint"
|
|
4
|
+
|
|
5
|
+
export interface Handoff {
|
|
6
|
+
label: string
|
|
7
|
+
source: string
|
|
8
|
+
text: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ThreadState {
|
|
12
|
+
handoffs?: Record<string, Handoff | null>
|
|
13
|
+
cut?: string
|
|
14
|
+
keep?: number
|
|
15
|
+
cleared?: number
|
|
16
|
+
stuck?: number
|
|
17
|
+
host?: boolean
|
|
18
|
+
pins?: string[]
|
|
19
|
+
ratio?: number
|
|
20
|
+
limits?: Record<string, number>
|
|
21
|
+
recalls?: Record<string, string>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ThreadStore {
|
|
25
|
+
read: (key: string) => ThreadState
|
|
26
|
+
save: (key: string, state: ThreadState) => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function memoryStore(initial: Record<string, ThreadState> = {}): ThreadStore {
|
|
30
|
+
const threads = new Map(Object.entries(initial).map(([key, state]) => [key, structuredClone(state)]))
|
|
31
|
+
return {
|
|
32
|
+
read: (key) => structuredClone(threads.get(key) ?? {}),
|
|
33
|
+
save: (key, state) => {
|
|
34
|
+
threads.set(key, structuredClone(state))
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function retireRecalls(state: ThreadState, folded: Message[][]) {
|
|
40
|
+
if (!state.recalls) return
|
|
41
|
+
for (const message of folded.flat()) if (isPrompt(message)) delete state.recalls[messageFingerprint(message)]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function resetCut(state: ThreadState) {
|
|
45
|
+
delete state.cut
|
|
46
|
+
delete state.keep
|
|
47
|
+
delete state.cleared
|
|
48
|
+
delete state.stuck
|
|
49
|
+
delete state.host
|
|
50
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { blocks, type Message } from "../context/content"
|
|
2
|
+
import { splitTurns } from "../context/fold/turns"
|
|
3
|
+
import { type RecallChunk, recallChunks } from "../context/recall"
|
|
4
|
+
|
|
5
|
+
export interface Subagent {
|
|
6
|
+
description: string
|
|
7
|
+
messages: Message[]
|
|
8
|
+
toolUseId?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const closingTurn: Message = { role: "user", content: "" }
|
|
12
|
+
|
|
13
|
+
export function launchTurnOf(parentMessages: Message[], toolUseId: string | undefined): number {
|
|
14
|
+
if (!toolUseId) return 0
|
|
15
|
+
const index = splitTurns(parentMessages).turns.findIndex((turn) => turn.some((message) => message.role === "assistant" && blocks(message).some((b) => b.type === "tool_use" && b.id === toolUseId)))
|
|
16
|
+
return index + 1
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const subagentLabel = (description: string) => `subagent "${description}"`
|
|
20
|
+
|
|
21
|
+
export function subagentRecall(messages: Message[], description: string, parentTurn: number): RecallChunk[] {
|
|
22
|
+
const from = subagentLabel(description)
|
|
23
|
+
return recallChunks([...messages, closingTurn], new Map()).map((chunk) => ({ ...chunk, from, turn: parentTurn }))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function listSubagentRecall(parentMessages: Message[], subagents: Subagent[]): RecallChunk[] {
|
|
27
|
+
return subagents.flatMap((subagent) => subagentRecall(subagent.messages, subagent.description, launchTurnOf(parentMessages, subagent.toolUseId)))
|
|
28
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { blocks, type Message } from "../context/content"
|
|
2
|
+
import { isPrompt } from "../context/fold/turns"
|
|
3
|
+
import { searchGuide } from "../context/prompts"
|
|
4
|
+
import { renderIndex, renderMarkdown, renderSubagentMarkdown, renderSubagentXml, renderXml, type SubagentRecord, subagentFile, turnFile } from "../transcript/format"
|
|
5
|
+
import { parseTurns, type SessionEntry, type TranscriptFormat, type Turn } from "../transcript/parse"
|
|
6
|
+
import { launchTurnOf, type Subagent } from "./subagents"
|
|
7
|
+
|
|
8
|
+
export interface TranscriptOptions {
|
|
9
|
+
format?: TranscriptFormat
|
|
10
|
+
subagents?: Subagent[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const isNote = (message: Message) => message.role === "user" && !isPrompt(message) && !blocks(message).some((b) => b.type === "tool_result")
|
|
14
|
+
|
|
15
|
+
const turnsOf = (messages: Message[], sidechain: boolean | "any" = false): Turn[] =>
|
|
16
|
+
parseTurns(
|
|
17
|
+
messages.map((message): SessionEntry => ({ type: message.role, isMeta: isNote(message), message })),
|
|
18
|
+
sidechain,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
function subagentRecords(messages: Message[], turns: Turn[], subagents: Subagent[], format: TranscriptFormat): SubagentRecord[] {
|
|
22
|
+
const perTurn = new Map<number, number>()
|
|
23
|
+
return subagents.map((subagent) => {
|
|
24
|
+
const parentTurn = launchTurnOf(messages, subagent.toolUseId)
|
|
25
|
+
const k = (perTurn.get(parentTurn) ?? 0) + 1
|
|
26
|
+
perTurn.set(parentTurn, k)
|
|
27
|
+
const file = subagentFile(parentTurn, k, format)
|
|
28
|
+
const launcher = turns[parentTurn - 1]?.parts.find((part) => part.kind === "tool" && part.id === subagent.toolUseId)
|
|
29
|
+
if (launcher?.kind === "tool") launcher.record = file
|
|
30
|
+
return { run: { description: subagent.description }, turns: turnsOf(subagent.messages, "any"), parentTurn, file }
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function renderTranscriptFiles(messages: Message[], { format = "markdown", subagents = [] }: TranscriptOptions = {}): Record<string, string> {
|
|
35
|
+
const turns = turnsOf(messages)
|
|
36
|
+
const records = subagentRecords(messages, turns, subagents, format)
|
|
37
|
+
const files: Record<string, string> = {}
|
|
38
|
+
for (const turn of turns) files[turnFile(turn, format)] = format === "xml" ? renderXml(turn) : renderMarkdown(turn)
|
|
39
|
+
for (const record of records) files[record.file] = format === "xml" ? renderSubagentXml(record) : renderSubagentMarkdown(record)
|
|
40
|
+
files["index.md"] = renderIndex(turns, format, records)
|
|
41
|
+
return files
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const transcriptGuide = (dir: string, format: TranscriptFormat = "markdown") => searchGuide({ mode: format, dir, jsonl: "" })
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
type
|
|
1
|
+
import type { Body } from "../context/content"
|
|
2
|
+
import { sizeOf } from "./estimate"
|
|
3
|
+
import type { ThreadStore } from "./state"
|
|
4
|
+
|
|
5
|
+
export type Usage = Record<string, number>
|
|
2
6
|
|
|
3
7
|
function usageIn(line: string): Usage | null {
|
|
4
8
|
if (!line.startsWith("data:") || !line.includes("message_start")) return null
|
|
@@ -58,3 +62,41 @@ export function watchUsage(response: Response, onUsage: (usage: Usage) => void):
|
|
|
58
62
|
export function inputTokens(usage: Usage) {
|
|
59
63
|
return (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0)
|
|
60
64
|
}
|
|
65
|
+
|
|
66
|
+
const OVERFLOW_MARGIN = 1.1
|
|
67
|
+
|
|
68
|
+
const charsOf = (sent: Body | number) => (typeof sent === "number" ? sent : sizeOf(sent))
|
|
69
|
+
|
|
70
|
+
function overflowOf(errorText: string): { tokens: number; maximum: number | null } | null {
|
|
71
|
+
const match = errorText.match(/prompt is too long[^0-9]*(\d+)\s*tokens?\s*>\s*(\d+)/i)
|
|
72
|
+
const tokens = Number(match?.[1])
|
|
73
|
+
const maximum = Number(match?.[2])
|
|
74
|
+
if (!Number.isFinite(tokens) || tokens <= 0) return null
|
|
75
|
+
return { tokens, maximum: Number.isFinite(maximum) && maximum > 0 ? maximum : null }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function recordUsage(store: ThreadStore, threadKey: string, sent: Body | number, usage: Usage): boolean {
|
|
79
|
+
const input = inputTokens(usage)
|
|
80
|
+
const chars = charsOf(sent)
|
|
81
|
+
if (input <= 0 || chars <= 0) return false
|
|
82
|
+
const state = store.read(threadKey)
|
|
83
|
+
state.ratio = input / chars
|
|
84
|
+
store.save(threadKey, state)
|
|
85
|
+
return true
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const limitKey = (model: unknown) => String(model ?? "")
|
|
89
|
+
|
|
90
|
+
export function recordOverflow(store: ThreadStore, threadKey: string, sent: Body | number, errorText: string, model: unknown = typeof sent === "number" ? undefined : sent.model): number | null {
|
|
91
|
+
const overflow = overflowOf(errorText)
|
|
92
|
+
const chars = charsOf(sent)
|
|
93
|
+
if (!overflow || chars <= 0) return null
|
|
94
|
+
const state = store.read(threadKey)
|
|
95
|
+
state.ratio = Math.max(state.ratio ?? 0, overflow.tokens / chars) * OVERFLOW_MARGIN
|
|
96
|
+
if (overflow.maximum) {
|
|
97
|
+
const key = limitKey(model)
|
|
98
|
+
state.limits = { ...state.limits, [key]: Math.min(state.limits?.[key] ?? Number.POSITIVE_INFINITY, overflow.maximum) }
|
|
99
|
+
}
|
|
100
|
+
store.save(threadKey, state)
|
|
101
|
+
return overflow.tokens
|
|
102
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export type WindowLookup = (model: unknown, requestHeaders: Headers, upstream?: string) => Promise<number | null>
|
|
2
|
+
|
|
3
|
+
export interface WindowLookupOptions {
|
|
4
|
+
known?: Map<string, number>
|
|
5
|
+
onLearn?: (windows: Map<string, number>) => void
|
|
6
|
+
timeoutMs?: number
|
|
7
|
+
retryAfterMs?: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const LOOKUP_TIMEOUT_MS = 1_500
|
|
11
|
+
|
|
12
|
+
const RETRY_AFTER_MS = 10 * 60_000
|
|
13
|
+
|
|
14
|
+
const LONG_SUFFIX = /\[1m\]$/i
|
|
15
|
+
|
|
16
|
+
const SUFFIX = /\[[^\]]*\]$/
|
|
17
|
+
|
|
18
|
+
function lookupHeaders(requestHeaders: Headers): Headers {
|
|
19
|
+
const headers = new Headers()
|
|
20
|
+
for (const name of ["authorization", "x-api-key", "anthropic-version", "anthropic-beta"]) {
|
|
21
|
+
const value = requestHeaders.get(name)
|
|
22
|
+
if (value) headers.set(name, value)
|
|
23
|
+
}
|
|
24
|
+
if (!headers.has("anthropic-version")) headers.set("anthropic-version", "2023-06-01")
|
|
25
|
+
return headers
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function familyWindow(model: string): number | null {
|
|
29
|
+
if (LONG_SUFFIX.test(model)) return 1_000_000
|
|
30
|
+
const id = model.replace(SUFFIX, "")
|
|
31
|
+
if (/^claude-(?:(?:opus|sonnet|haiku|fable)-5|opus-4-8)/.test(id)) return 1_000_000
|
|
32
|
+
if (id.startsWith("claude-")) return 200_000
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createWindowLookup({ known = new Map<string, number>(), onLearn, timeoutMs = LOOKUP_TIMEOUT_MS, retryAfterMs = RETRY_AFTER_MS }: WindowLookupOptions = {}): WindowLookup {
|
|
37
|
+
const lookups = new Map<string, Promise<number | null>>()
|
|
38
|
+
const failedAt = new Map<string, number>()
|
|
39
|
+
const lookup = async (id: string, requestHeaders: Headers, upstream: string): Promise<number | null> => {
|
|
40
|
+
const response = await fetch(`${upstream}/v1/models/${encodeURIComponent(id)}`, { headers: lookupHeaders(requestHeaders), signal: AbortSignal.timeout(timeoutMs) })
|
|
41
|
+
if (!response.ok) return null
|
|
42
|
+
const info = (await response.json()) as { max_input_tokens?: number }
|
|
43
|
+
if (typeof info.max_input_tokens !== "number" || info.max_input_tokens <= 0) return null
|
|
44
|
+
known.set(id, info.max_input_tokens)
|
|
45
|
+
onLearn?.(known)
|
|
46
|
+
return info.max_input_tokens
|
|
47
|
+
}
|
|
48
|
+
return (model, requestHeaders, upstream = "https://api.anthropic.com") => {
|
|
49
|
+
const given = String(model ?? "")
|
|
50
|
+
const id = given.replace(SUFFIX, "")
|
|
51
|
+
if (!id) return Promise.resolve(null)
|
|
52
|
+
const window = known.get(id)
|
|
53
|
+
if (window) return Promise.resolve(window)
|
|
54
|
+
const fallback = familyWindow(given)
|
|
55
|
+
const failed = failedAt.get(id)
|
|
56
|
+
if (failed !== undefined && Date.now() - failed < retryAfterMs) return Promise.resolve(fallback)
|
|
57
|
+
const pending =
|
|
58
|
+
lookups.get(id) ??
|
|
59
|
+
lookup(id, requestHeaders, upstream)
|
|
60
|
+
.catch(() => null)
|
|
61
|
+
.then((found) => {
|
|
62
|
+
if (found === null) failedAt.set(id, Date.now())
|
|
63
|
+
return found
|
|
64
|
+
})
|
|
65
|
+
.finally(() => lookups.delete(id))
|
|
66
|
+
lookups.set(id, pending)
|
|
67
|
+
return pending.then((found) => found ?? fallback)
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/proxy/rewrite.ts
CHANGED
|
@@ -1,20 +1,15 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
1
2
|
import type { Config } from "../config"
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { identityReranker, type Reranker, withRecall } from "../context/recall"
|
|
7
|
-
import { jevReranker, rerankKey } from "../context/recall/rerank"
|
|
3
|
+
import { paths } from "../config/paths"
|
|
4
|
+
import type { Reranker } from "../context/recall"
|
|
5
|
+
import { jevReranker } from "../context/recall/rerank"
|
|
6
|
+
import { type RewriteInput as CoreInput, prefetchRecall as prefetchCore, type Rewrite, rewrite as rewriteCore, rewriteAsync as rewriteCoreAsync } from "../core/rewrite"
|
|
8
7
|
import type { TranscriptRef } from "../transcript/search"
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import { messageFingerprint } from "./fingerprint"
|
|
12
|
-
import { activeHandoffs, attachHandoffs, withHandoffs } from "./handoff"
|
|
13
|
-
import { activeRecalls, applyRecall, prefetchFor, wantsRecall } from "./recall"
|
|
14
|
-
import { readState, saveState } from "./state"
|
|
8
|
+
import { sessionSources } from "./sources"
|
|
9
|
+
import { fileStore } from "./state"
|
|
15
10
|
|
|
16
11
|
export interface RewriteInput {
|
|
17
|
-
body:
|
|
12
|
+
body: CoreInput["body"]
|
|
18
13
|
sessionId: string | null
|
|
19
14
|
config: Config
|
|
20
15
|
transcript: TranscriptRef | null
|
|
@@ -23,146 +18,34 @@ export interface RewriteInput {
|
|
|
23
18
|
omittedBefore?: number
|
|
24
19
|
}
|
|
25
20
|
|
|
26
|
-
export
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
21
|
+
export function rerankKey(): string | null {
|
|
22
|
+
const env = process.env.TYPESAFE_API_KEY?.trim()
|
|
23
|
+
if (env) return env
|
|
24
|
+
return existsSync(paths.typesafeKey) ? readFileSync(paths.typesafeKey, "utf8").trim() : null
|
|
30
25
|
}
|
|
31
26
|
|
|
32
|
-
interface Prefetched {
|
|
33
|
-
reranked: boolean
|
|
34
|
-
rerankError: string | null
|
|
35
|
-
recalled: number
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const pass = (body: Body): Rewrite => ({ body, threadKey: null, note: { action: "pass" } })
|
|
39
|
-
|
|
40
|
-
const isAgentThread = (body: Body) => Array.isArray(body.tools) && body.tools.length > 0
|
|
41
|
-
|
|
42
|
-
const withSystemPrompt = (system: Body["system"], extra?: string | null) => appendSystem(extra ? appendSystem(system, extra) : system, SYSTEM_PROMPT)
|
|
43
|
-
|
|
44
|
-
const ratioOf = (threadKey: string) => readState(threadKey).ratio ?? DEFAULT_RATIO
|
|
45
|
-
|
|
46
27
|
function rerankModel(config: Config): string | null {
|
|
47
28
|
const model = config.eval?.rerank?.model ?? config.rerank
|
|
48
29
|
return model && model !== "off" ? model : null
|
|
49
30
|
}
|
|
50
31
|
|
|
51
|
-
|
|
52
|
-
const { body, config } = input
|
|
53
|
-
const strategy = config.eval?.strategy
|
|
54
|
-
if (strategy !== "guide" && strategy !== "trim" && strategy !== "tail") return null
|
|
55
|
-
if (!isAgentThread(body)) return pass(body)
|
|
56
|
-
const counts = { turns: turns.length, messagesIn: body.messages.length }
|
|
57
|
-
if (strategy === "guide") {
|
|
58
|
-
return { body: { ...body, system: withSystemPrompt(body.system, guide ? searchOnlyInstructions(guide) : null) }, threadKey, note: { action: "guide", ...counts } }
|
|
59
|
-
}
|
|
60
|
-
if (strategy === "trim") {
|
|
61
|
-
const messages = trimMessages(body.messages, { evidence: config.eval?.evidence ?? true })
|
|
62
|
-
return { body: { ...body, system: withSystemPrompt(body.system, trimInstructions(guide)), messages }, threadKey, note: { action: "trim", ...counts, messagesOut: messages.length } }
|
|
63
|
-
}
|
|
64
|
-
const based = { ...body, system: withSystemPrompt(body.system) }
|
|
65
|
-
const size = estimator(based, ratioOf(threadKey))
|
|
66
|
-
let from = 1
|
|
67
|
-
if (input.window) {
|
|
68
|
-
const target = targetSize(size, input.window)
|
|
69
|
-
from = turns.length - 1
|
|
70
|
-
while (from > 1 && size.of(tailMessages(body.messages, from - 1, guide)) <= target) from -= 1
|
|
71
|
-
}
|
|
72
|
-
const messages = tailMessages(body.messages, from, guide)
|
|
73
|
-
return { body: { ...based, messages }, threadKey, note: { action: "tail", keptTurns: turns.length - from, ...counts, messagesOut: messages.length, estimate: size.of(messages) } }
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function strategize(input: RewriteInput): Rewrite {
|
|
77
|
-
const { body, sessionId, config, transcript } = input
|
|
78
|
-
if (config.mode === "off" || !sessionId || !Array.isArray(body.messages) || body.messages.length === 0) return pass(body)
|
|
79
|
-
const { turns } = splitTurns(body.messages)
|
|
80
|
-
if (turns.length === 0) return pass(body)
|
|
81
|
-
const threadKey = `${sessionId}-${turnFingerprint(turns[0])}`
|
|
82
|
-
const guide = transcript ? searchGuide(transcript) : null
|
|
83
|
-
const state = readState(threadKey)
|
|
84
|
-
if (attachHandoffs(body.messages, config, sessionId, state)) saveState(threadKey, state)
|
|
85
|
-
|
|
86
|
-
const forced = evalStrategy(input, turns, threadKey, guide)
|
|
87
|
-
if (forced) return forced
|
|
88
|
-
|
|
89
|
-
const based = isAgentThread(body) ? { ...body, system: withSystemPrompt(body.system) } : body
|
|
90
|
-
if (body.messages.length < 2) return { body: based, threadKey, note: { action: "full", turns: turns.length } }
|
|
91
|
-
const handoffs = activeHandoffs(state.handoffs)
|
|
92
|
-
const recalls = wantsRecall(input) ? activeRecalls(state.recalls) : {}
|
|
93
|
-
const result = compact({
|
|
94
|
-
body: based,
|
|
95
|
-
config,
|
|
96
|
-
state,
|
|
97
|
-
window: input.window,
|
|
98
|
-
guide,
|
|
99
|
-
transcriptDir: transcript && transcript.mode !== "jsonl" ? transcript.dir : null,
|
|
100
|
-
mayCompact: input.mayCompact,
|
|
101
|
-
omittedBefore: input.omittedBefore,
|
|
102
|
-
decorate: (messages) => withRecall(withHandoffs(messages, handoffs), messageFingerprint, recalls),
|
|
103
|
-
})
|
|
104
|
-
if (result.stateChanged) saveState(threadKey, state)
|
|
105
|
-
return { body: result.messages === body.messages ? based : { ...based, messages: result.messages }, threadKey, note: result.note }
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function withAttachedHandoffs(result: Rewrite): Rewrite {
|
|
109
|
-
if (!result.threadKey) return result
|
|
110
|
-
const handoffs = activeHandoffs(readState(result.threadKey).handoffs)
|
|
111
|
-
const messages = withHandoffs(result.body.messages, handoffs)
|
|
112
|
-
if (messages === result.body.messages) return result
|
|
113
|
-
const attached = Object.values(handoffs)
|
|
114
|
-
const note = { ...result.note, handoff: attached.reduce((sum, h) => sum + h.text.length, 0), handoffFrom: attached.map((h) => h.label).join(", ") }
|
|
115
|
-
return { ...result, body: { ...result.body, messages }, note }
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function once(input: RewriteInput): Rewrite {
|
|
119
|
-
const result = withAttachedHandoffs(strategize(input))
|
|
120
|
-
return wantsRecall(input) ? applyRecall(input, result) : result
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function sentEstimate(result: Rewrite): number {
|
|
124
|
-
return Math.round(sizeOf(result.body) * (result.threadKey ? ratioOf(result.threadKey) : DEFAULT_RATIO))
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function overLimit(input: RewriteInput, result: Rewrite): boolean {
|
|
128
|
-
if (!input.window || !result.threadKey || input.mayCompact === false || result.note.action === "pass") return false
|
|
129
|
-
return sentEstimate(result) > input.config.compactAt * input.window
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function carried(from: Rewrite, to: Rewrite): Rewrite {
|
|
133
|
-
const actionsOf = ({ note }: Rewrite) => (note.actions as string[] | undefined) ?? []
|
|
134
|
-
const actions = [...actionsOf(from), ...actionsOf(to)]
|
|
135
|
-
if (!from.note.compacted && actions.length === 0) return to
|
|
136
|
-
return { ...to, note: { ...to.note, compacted: Boolean(to.note.compacted || from.note.compacted), actions } }
|
|
137
|
-
}
|
|
32
|
+
const toCore = ({ sessionId, transcript, ...input }: RewriteInput): CoreInput => ({ ...input, threadId: sessionId, store: fileStore, sources: sessionSources(transcript, input.config, sessionId) })
|
|
138
33
|
|
|
139
|
-
export
|
|
140
|
-
const result = before ? carried(before, once(input)) : once(input)
|
|
141
|
-
if (!overLimit(input, result)) return result
|
|
142
|
-
const recut = carried(result, once(input))
|
|
143
|
-
if (!overLimit(input, recut)) return { ...recut, note: { ...recut.note, fit: "recut" } }
|
|
144
|
-
const bare = carried(recut, once({ ...input, config: { ...input.config, eval: { ...input.config.eval, recall: false } } }))
|
|
145
|
-
return { ...bare, note: { ...bare.note, fit: overLimit(input, bare) ? "over" : "bare", fitEstimate: sentEstimate(bare) } }
|
|
146
|
-
}
|
|
34
|
+
export const rewrite = (input: RewriteInput): Rewrite => rewriteCore(toCore(input))
|
|
147
35
|
|
|
148
|
-
export
|
|
149
|
-
if (!wantsRecall(input)) return null
|
|
150
|
-
const recalled = await prefetchFor(input, strategize(input), rerank)
|
|
151
|
-
return recalled === null ? null : { reranked: rerank !== identityReranker, rerankError: null, recalled }
|
|
152
|
-
}
|
|
36
|
+
export const prefetchRecall = (input: RewriteInput, rerank: Reranker) => prefetchCore(toCore(input), rerank)
|
|
153
37
|
|
|
154
|
-
export async function rewriteAsync(input: RewriteInput): Promise<Rewrite> {
|
|
155
|
-
const
|
|
38
|
+
export async function rewriteAsync(input: RewriteInput): Promise<Rewrite & { note: { reranked?: string; rerankError?: string } }> {
|
|
39
|
+
const core = toCore(input)
|
|
40
|
+
const model = core.config.mode !== "off" && (core.config.eval?.recall ?? true) ? rerankModel(input.config) : null
|
|
156
41
|
const key = model ? rerankKey() : null
|
|
157
|
-
if (!model || !key) return
|
|
42
|
+
if (!model || !key) return rewriteCore(core)
|
|
158
43
|
const failure = { reason: null as string | null }
|
|
159
44
|
const reranker = jevReranker(model, key, input.config.eval?.rerank?.endpoint, (reason) => {
|
|
160
45
|
failure.reason = reason
|
|
161
46
|
})
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
const final = rewrite(input, first)
|
|
165
|
-
if (recalled === null) return final
|
|
47
|
+
const result = await rewriteCoreAsync(core, reranker)
|
|
48
|
+
if (result.note.recalled === undefined) return result
|
|
166
49
|
const outcome = failure.reason ? { rerankError: `${model}: ${failure.reason}` } : { reranked: model }
|
|
167
|
-
return { ...
|
|
50
|
+
return { ...result, note: { ...result.note, ...outcome } }
|
|
168
51
|
}
|
package/src/proxy/server.ts
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
import path from "node:path"
|
|
2
2
|
import { type Config, claudeConfigDirs, readConfig, settingsFor } from "../config"
|
|
3
|
-
import {
|
|
3
|
+
import type { Body } from "../context/content"
|
|
4
|
+
import { contextFingerprint } from "../core/fingerprint"
|
|
5
|
+
import { claudeCodeSessionId, createForwarder, type Prepared } from "../core/forward"
|
|
4
6
|
import { findSessionFile } from "../transcript/locate"
|
|
5
7
|
import { prepareTranscript } from "../transcript/search"
|
|
6
8
|
import { VERSION } from "../version"
|
|
7
9
|
import { applyCanonical } from "./canonical"
|
|
8
|
-
import { sizeOf } from "./estimate"
|
|
9
|
-
import { type ContextFingerprint, contextFingerprint } from "./fingerprint"
|
|
10
10
|
import { logRequest } from "./log"
|
|
11
11
|
import { rewriteAsync } from "./rewrite"
|
|
12
12
|
import { recordSession } from "./sessions"
|
|
13
|
-
import {
|
|
14
|
-
import { inputTokens, watchUsage } from "./usage"
|
|
13
|
+
import { fileStore } from "./state"
|
|
15
14
|
import { resolveWindow } from "./window"
|
|
16
15
|
|
|
17
16
|
export interface Health {
|
|
@@ -22,48 +21,14 @@ export interface Health {
|
|
|
22
21
|
startedAt?: number
|
|
23
22
|
}
|
|
24
23
|
|
|
25
|
-
interface Outgoing {
|
|
26
|
-
note: Record<string, unknown>
|
|
27
|
-
threadKey: string | null
|
|
28
|
-
bodyText: string | undefined
|
|
29
|
-
sentSize: number
|
|
30
|
-
givenSize: number
|
|
31
|
-
fixedSize: number
|
|
32
|
-
sent: (ContextFingerprint & { session: string | null }) | null
|
|
33
|
-
}
|
|
34
|
-
|
|
35
24
|
export const HEALTH_PATH = "/_eunoe/health"
|
|
36
25
|
|
|
37
26
|
const startedAt = Date.now()
|
|
38
27
|
|
|
39
28
|
const trustedHeader = (request: Request, config: Config, name: string) => (config.eval?.trustHeaders === true ? request.headers.get(`x-eunoe-${name}`) : null)
|
|
40
29
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
function sessionIdOf(headers: Headers, body: Body): string | null {
|
|
44
|
-
const header = headers.get("x-claude-code-session-id")
|
|
45
|
-
if (header) return header
|
|
46
|
-
try {
|
|
47
|
-
return JSON.parse(body.metadata?.user_id ?? "{}").session_id ?? null
|
|
48
|
-
} catch {
|
|
49
|
-
return null
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function forwardableHeaders(headers: Headers) {
|
|
54
|
-
const out = new Headers(headers)
|
|
55
|
-
out.delete("content-encoding")
|
|
56
|
-
out.delete("content-length")
|
|
57
|
-
return out
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function overflowTokens(errorText: string): number | null {
|
|
61
|
-
const tokens = Number(errorText.match(/prompt is too long[^0-9]*(\d+)\s*tokens?\s*>\s*(\d+)/i)?.[1])
|
|
62
|
-
return Number.isFinite(tokens) && tokens > 0 ? tokens : null
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
async function prepare(request: Request, body: Body, base: Config, isCountTokens: boolean) {
|
|
66
|
-
const sessionId = sessionIdOf(request.headers, body)
|
|
30
|
+
async function prepareSession(request: Request, body: Body, base: Config, isCountTokens: boolean) {
|
|
31
|
+
const sessionId = claudeCodeSessionId(request.headers, body)
|
|
67
32
|
const config = settingsFor(base, sessionId)
|
|
68
33
|
const trusted = config.eval?.trustHeaders === true
|
|
69
34
|
const transcriptOverride = trustedHeader(request, config, "transcript")
|
|
@@ -87,132 +52,29 @@ async function prepare(request: Request, body: Body, base: Config, isCountTokens
|
|
|
87
52
|
})
|
|
88
53
|
}
|
|
89
54
|
|
|
90
|
-
function
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
55
|
+
async function prepare(request: Request, given: Body, config: Config, mayCompact: boolean): Promise<Prepared> {
|
|
56
|
+
const canonicalFile = trustedHeader(request, config, "canonical")
|
|
57
|
+
const canonical = canonicalFile ? applyCanonical(given, canonicalFile) : null
|
|
58
|
+
const result = await prepareSession(request, canonical?.body ?? given, config, !mayCompact)
|
|
59
|
+
const context = config.eval ? { context: { session: claudeCodeSessionId(request.headers, given), ...contextFingerprint(result.body), given: contextFingerprint(given) } } : {}
|
|
60
|
+
return { ...result, note: { ...result.note, ...(canonical && { canonical: canonical.status }), ...context } }
|
|
94
61
|
}
|
|
95
62
|
|
|
96
63
|
interface Timing {
|
|
97
64
|
countTokensTimeoutMs: number
|
|
98
65
|
}
|
|
99
66
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
export async function handle(request: Request, timing: Timing = { countTokensTimeoutMs: 10_000 }): Promise<Response> {
|
|
103
|
-
const began = Date.now()
|
|
104
|
-
try {
|
|
105
|
-
return await route(request, began, timing)
|
|
106
|
-
} catch (error) {
|
|
107
|
-
logRequest({ path: new URL(request.url).pathname, status: 502, action: "upstream-unreachable", error: clipped(String(error)), ms: Date.now() - began })
|
|
108
|
-
return Response.json({ type: "error", error: { type: "api_error", message: `eunoe could not reach the API: ${String(error)}` } }, { status: 502 })
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
async function route(request: Request, began: number, timing: Timing): Promise<Response> {
|
|
113
|
-
let stalled = 0
|
|
114
|
-
let prep = 0
|
|
115
|
-
let upload = 0
|
|
116
|
-
const log = (record: Record<string, unknown>) => logRequest({ ...record, ms: Date.now() - began, prep, upload, ...(stalled > 0 && { stalled }) })
|
|
67
|
+
export function handle(request: Request, timing: Timing = { countTokensTimeoutMs: 10_000 }): Promise<Response> {
|
|
117
68
|
const config = readConfig()
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
upload = Date.now() - began
|
|
126
|
-
const isMessages = request.method === "POST" && url.pathname.startsWith("/v1/messages")
|
|
127
|
-
const isCountTokens = url.pathname.includes("count_tokens")
|
|
128
|
-
|
|
129
|
-
const build = async (): Promise<Outgoing> => {
|
|
130
|
-
const passthrough: Outgoing = { note: { action: "pass" }, threadKey: null, bodyText: givenText, sentSize: 0, givenSize: 0, fixedSize: 0, sent: null }
|
|
131
|
-
if (!givenText || !isMessages) return passthrough
|
|
132
|
-
try {
|
|
133
|
-
const given = JSON.parse(givenText) as Body
|
|
134
|
-
const canonicalFile = trustedHeader(request, config, "canonical")
|
|
135
|
-
const canonical = canonicalFile ? applyCanonical(given, canonicalFile) : null
|
|
136
|
-
const result = await prepare(request, canonical?.body ?? given, config, isCountTokens)
|
|
137
|
-
const sentSize = sizeOf(result.body)
|
|
138
|
-
return {
|
|
139
|
-
note: canonical ? { ...result.note, canonical: canonical.status } : result.note,
|
|
140
|
-
threadKey: result.threadKey,
|
|
141
|
-
bodyText: result.body !== given ? JSON.stringify(result.body, wellFormed) : givenText,
|
|
142
|
-
sentSize,
|
|
143
|
-
givenSize: result.body === given ? sentSize : sizeOf(given),
|
|
144
|
-
fixedSize: sizeOf({ ...result.body, messages: [] }),
|
|
145
|
-
sent: config.eval ? { session: sessionIdOf(request.headers, given), ...contextFingerprint(result.body), given: contextFingerprint(given) } : null,
|
|
146
|
-
}
|
|
147
|
-
} catch (error) {
|
|
148
|
-
return { ...passthrough, note: { action: "error-passthrough", error: String(error) } }
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
const target = config.upstream + url.pathname + url.search
|
|
152
|
-
const send = async (body: string | undefined): Promise<Response> => {
|
|
153
|
-
if (!isCountTokens) return fetch(target, { method: request.method, headers, body })
|
|
154
|
-
for (;;) {
|
|
155
|
-
try {
|
|
156
|
-
return await fetch(target, { method: request.method, headers, body, signal: AbortSignal.timeout(timing.countTokensTimeoutMs) })
|
|
157
|
-
} catch (error) {
|
|
158
|
-
const timedOut = error instanceof Error && error.name === "TimeoutError"
|
|
159
|
-
if (!timedOut || stalled + 1 >= COUNT_TOKENS_ATTEMPTS) throw error
|
|
160
|
-
stalled += 1
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
let out = await build()
|
|
166
|
-
prep = Date.now() - began - upload
|
|
167
|
-
let upstream = await send(out.bodyText)
|
|
168
|
-
const errorResponse = (text: string) => new Response(text, { status: upstream.status, statusText: upstream.statusText, headers: forwardableHeaders(upstream.headers) })
|
|
169
|
-
|
|
170
|
-
if (upstream.status >= 400 && out.note.action !== "pass") {
|
|
171
|
-
const errorText = await upstream.text()
|
|
172
|
-
const overflow = overflowTokens(errorText)
|
|
173
|
-
const logged = { path: url.pathname, status: upstream.status, thread: out.threadKey, ...out.note }
|
|
174
|
-
if (!overflow || !out.threadKey || out.sentSize === 0 || isCountTokens) {
|
|
175
|
-
log({ ...logged, error: clipped(errorText) })
|
|
176
|
-
return errorResponse(errorText)
|
|
177
|
-
}
|
|
178
|
-
const sentSize = out.sentSize
|
|
179
|
-
saveRatio(out.threadKey, (previous) => Math.max(previous ?? 0, overflow / sentSize) * 1.1)
|
|
180
|
-
log({ ...logged, overflow, error: clipped(errorText) })
|
|
181
|
-
out = await build()
|
|
182
|
-
out = { ...out, note: { ...out.note, healed: overflow } }
|
|
183
|
-
upstream = await send(out.bodyText)
|
|
184
|
-
if (upstream.status >= 400) {
|
|
185
|
-
const retryError = await upstream.text()
|
|
186
|
-
log({ path: url.pathname, status: upstream.status, thread: out.threadKey, ...out.note, error: clipped(retryError) })
|
|
187
|
-
return errorResponse(retryError)
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
const { note, threadKey, sentSize, givenSize, fixedSize, sent } = out
|
|
192
|
-
const response = new Response(upstream.body, { status: upstream.status, statusText: upstream.statusText, headers: forwardableHeaders(upstream.headers) })
|
|
193
|
-
if (note.action === "pass" || !isMessages || isCountTokens) {
|
|
194
|
-
if (note.action !== "pass" || sent) log({ path: url.pathname, status: upstream.status, ...note, ...(sent && { context: sent }) })
|
|
195
|
-
return response
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
return watchUsage(response, (usage) => {
|
|
199
|
-
const input = inputTokens(usage)
|
|
200
|
-
log({
|
|
201
|
-
path: url.pathname,
|
|
202
|
-
status: upstream.status,
|
|
203
|
-
thread: threadKey,
|
|
204
|
-
...note,
|
|
205
|
-
input,
|
|
206
|
-
cacheRead: usage.cache_read_input_tokens ?? 0,
|
|
207
|
-
cacheWrite: usage.cache_creation_input_tokens ?? 0,
|
|
208
|
-
uncached: usage.input_tokens ?? 0,
|
|
209
|
-
charsIn: givenSize,
|
|
210
|
-
charsOut: sentSize,
|
|
211
|
-
charsFixed: fixedSize,
|
|
212
|
-
...(sent && { context: sent }),
|
|
213
|
-
})
|
|
214
|
-
if (threadKey && input > 0 && sentSize > 0) saveRatio(threadKey, () => input / sentSize)
|
|
69
|
+
if (new URL(request.url).pathname === HEALTH_PATH) return Promise.resolve(Response.json({ ok: true, version: VERSION, mode: config.mode, pid: process.pid, startedAt } satisfies Health))
|
|
70
|
+
const forward = createForwarder({
|
|
71
|
+
store: fileStore,
|
|
72
|
+
upstream: config.upstream,
|
|
73
|
+
prepare: (incoming, body, mayCompact) => prepare(incoming, body, config, mayCompact),
|
|
74
|
+
record: logRequest,
|
|
75
|
+
countTokensTimeoutMs: timing.countTokensTimeoutMs,
|
|
215
76
|
})
|
|
77
|
+
return forward(request)
|
|
216
78
|
}
|
|
217
79
|
|
|
218
80
|
export function serve(port: number) {
|
package/src/proxy/sessions.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { closeSync, mkdirSync, openSync, readFileSync, readSync, writeFileSync } from "node:fs"
|
|
2
|
-
import type { Mode } from "../config"
|
|
3
2
|
import { EUNOE_DIR, paths } from "../config/paths"
|
|
3
|
+
import type { Mode } from "../core/config"
|
|
4
4
|
|
|
5
5
|
interface SeenSession {
|
|
6
6
|
lastSeen: string
|