@chroxify/eunoe 1.2.2 → 1.3.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 +139 -2
- package/dist/core/index.js +2235 -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 +14 -0
- package/dist/types/context/prompts.d.ts +24 -0
- package/dist/types/context/recall/index.d.ts +44 -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 +33 -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 +8 -0
- package/dist/types/core/rewrite.d.ts +63 -0
- package/dist/types/core/state.d.ts +22 -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 +7 -0
- package/dist/types/core/window.d.ts +5 -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 +55 -10
- package/src/context/fold/turns.ts +22 -2
- package/src/context/prompts.ts +4 -3
- package/src/context/recall/index.ts +2 -3
- package/src/context/recall/rerank.ts +0 -8
- package/src/{proxy → core}/compaction.ts +56 -24
- package/src/core/config.ts +31 -0
- package/src/core/fold.ts +69 -0
- package/src/core/forward.ts +192 -0
- package/src/{proxy → core}/handoff.ts +23 -23
- package/src/core/index.ts +14 -0
- package/src/{proxy → core}/recall.ts +18 -20
- package/src/core/rewrite.ts +189 -0
- package/src/core/state.ts +40 -0
- package/src/core/subagents.ts +28 -0
- package/src/core/transcript.ts +44 -0
- package/src/{proxy → core}/usage.ts +34 -1
- package/src/core/window.ts +39 -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 → core}/estimate.ts +0 -0
- /package/src/{proxy → core}/fingerprint.ts +0 -0
|
@@ -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,32 @@ 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 overflowTokens(errorText: string): number | null {
|
|
71
|
+
const tokens = Number(errorText.match(/prompt is too long[^0-9]*(\d+)\s*tokens?\s*>\s*(\d+)/i)?.[1])
|
|
72
|
+
return Number.isFinite(tokens) && tokens > 0 ? tokens : null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function recordUsage(store: ThreadStore, threadKey: string, sent: Body | number, usage: Usage): boolean {
|
|
76
|
+
const input = inputTokens(usage)
|
|
77
|
+
const chars = charsOf(sent)
|
|
78
|
+
if (input <= 0 || chars <= 0) return false
|
|
79
|
+
const state = store.read(threadKey)
|
|
80
|
+
state.ratio = input / chars
|
|
81
|
+
store.save(threadKey, state)
|
|
82
|
+
return true
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function recordOverflow(store: ThreadStore, threadKey: string, sent: Body | number, errorText: string): number | null {
|
|
86
|
+
const tokens = overflowTokens(errorText)
|
|
87
|
+
const chars = charsOf(sent)
|
|
88
|
+
if (!tokens || chars <= 0) return null
|
|
89
|
+
const state = store.read(threadKey)
|
|
90
|
+
state.ratio = Math.max(state.ratio ?? 0, tokens / chars) * OVERFLOW_MARGIN
|
|
91
|
+
store.save(threadKey, state)
|
|
92
|
+
return tokens
|
|
93
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export type WindowLookup = (model: unknown, requestHeaders: Headers, upstream?: string) => Promise<number | null>
|
|
2
|
+
|
|
3
|
+
const LOOKUP_TIMEOUT_MS = 5_000
|
|
4
|
+
|
|
5
|
+
function lookupHeaders(requestHeaders: Headers): Headers {
|
|
6
|
+
const headers = new Headers()
|
|
7
|
+
for (const name of ["authorization", "x-api-key", "anthropic-version", "anthropic-beta"]) {
|
|
8
|
+
const value = requestHeaders.get(name)
|
|
9
|
+
if (value) headers.set(name, value)
|
|
10
|
+
}
|
|
11
|
+
if (!headers.has("anthropic-version")) headers.set("anthropic-version", "2023-06-01")
|
|
12
|
+
return headers
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createWindowLookup({ known = new Map<string, number>(), onLearn }: { known?: Map<string, number>; onLearn?: (windows: Map<string, number>) => void } = {}): WindowLookup {
|
|
16
|
+
const lookups = new Map<string, Promise<number | null>>()
|
|
17
|
+
const lookup = async (id: string, requestHeaders: Headers, upstream: string): Promise<number | null> => {
|
|
18
|
+
const response = await fetch(`${upstream}/v1/models/${encodeURIComponent(id)}`, { headers: lookupHeaders(requestHeaders), signal: AbortSignal.timeout(LOOKUP_TIMEOUT_MS) })
|
|
19
|
+
if (!response.ok) return null
|
|
20
|
+
const info = (await response.json()) as { max_input_tokens?: number }
|
|
21
|
+
if (typeof info.max_input_tokens !== "number" || info.max_input_tokens <= 0) return null
|
|
22
|
+
known.set(id, info.max_input_tokens)
|
|
23
|
+
onLearn?.(known)
|
|
24
|
+
return info.max_input_tokens
|
|
25
|
+
}
|
|
26
|
+
return (model, requestHeaders, upstream = "https://api.anthropic.com") => {
|
|
27
|
+
const id = String(model ?? "").replace(/\[[^\]]*\]$/, "")
|
|
28
|
+
if (!id) return Promise.resolve(null)
|
|
29
|
+
const window = known.get(id)
|
|
30
|
+
if (window) return Promise.resolve(window)
|
|
31
|
+
const inFlight = lookups.get(id)
|
|
32
|
+
if (inFlight) return inFlight
|
|
33
|
+
const pending = lookup(id, requestHeaders, upstream)
|
|
34
|
+
.catch(() => null)
|
|
35
|
+
.finally(() => lookups.delete(id))
|
|
36
|
+
lookups.set(id, pending)
|
|
37
|
+
return pending
|
|
38
|
+
}
|
|
39
|
+
}
|
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
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type Config, claudeConfigDirs } from "../config"
|
|
2
|
+
import type { Message } from "../context/content"
|
|
3
|
+
import { searchGuide } from "../context/prompts"
|
|
4
|
+
import type { ThreadSources } from "../core/rewrite"
|
|
5
|
+
import { findSessionByPrefix } from "../transcript/locate"
|
|
6
|
+
import { sessionMessages } from "../transcript/messages"
|
|
7
|
+
import type { TranscriptRef } from "../transcript/search"
|
|
8
|
+
import { subagentChunks } from "../transcript/subagents"
|
|
9
|
+
import { titleOf } from "./sessions"
|
|
10
|
+
|
|
11
|
+
function readSession(file: string): Message[] {
|
|
12
|
+
try {
|
|
13
|
+
return sessionMessages(file)
|
|
14
|
+
} catch {
|
|
15
|
+
return []
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function sessionSources(transcript: TranscriptRef | null, config: Config, sessionId: string | null): ThreadSources {
|
|
20
|
+
const handoffs = {
|
|
21
|
+
findHandoff: (id: string) => {
|
|
22
|
+
const file = findSessionByPrefix(id, claudeConfigDirs(config), sessionId ?? undefined)
|
|
23
|
+
return file ? { label: titleOf(file) ?? id, source: file } : null
|
|
24
|
+
},
|
|
25
|
+
readHandoff: readSession,
|
|
26
|
+
}
|
|
27
|
+
if (!transcript) return handoffs
|
|
28
|
+
return {
|
|
29
|
+
...handoffs,
|
|
30
|
+
guide: searchGuide(transcript),
|
|
31
|
+
transcriptDir: transcript.mode !== "jsonl" ? transcript.dir : null,
|
|
32
|
+
readHistory: () => sessionMessages(transcript.jsonl),
|
|
33
|
+
listSubagentChunks: () => subagentChunks(transcript.jsonl),
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/proxy/state.ts
CHANGED
|
@@ -1,21 +1,7 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
|
|
2
2
|
import path from "node:path"
|
|
3
3
|
import { paths } from "../config/paths"
|
|
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
|
-
ratio?: number
|
|
17
|
-
recalls?: Record<string, string>
|
|
18
|
-
}
|
|
4
|
+
import type { ThreadState, ThreadStore } from "../core/state"
|
|
19
5
|
|
|
20
6
|
function stateFile(key: string) {
|
|
21
7
|
return path.join(paths.threads, `${key.replace(/[^a-zA-Z0-9_-]/g, "_")}.json`)
|
|
@@ -63,8 +49,4 @@ export function saveState(key: string, state: ThreadState) {
|
|
|
63
49
|
if (version !== null) remember(file, version, structuredClone(state))
|
|
64
50
|
}
|
|
65
51
|
|
|
66
|
-
export
|
|
67
|
-
delete state.cut
|
|
68
|
-
delete state.keep
|
|
69
|
-
delete state.cleared
|
|
70
|
-
}
|
|
52
|
+
export const fileStore: ThreadStore = { read: readState, save: saveState }
|
package/src/proxy/window.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
2
|
import { EUNOE_DIR, paths } from "../config/paths"
|
|
3
|
+
import { createWindowLookup } from "../core/window"
|
|
3
4
|
|
|
4
5
|
function readCache(): Map<string, number> {
|
|
5
6
|
try {
|
|
@@ -9,40 +10,9 @@ function readCache(): Map<string, number> {
|
|
|
9
10
|
}
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
const lookups = new Map<string, Promise<number | null>>()
|
|
14
|
-
|
|
15
|
-
function lookupHeaders(requestHeaders: Headers): Headers {
|
|
16
|
-
const headers = new Headers()
|
|
17
|
-
for (const name of ["authorization", "x-api-key", "anthropic-version", "anthropic-beta"]) {
|
|
18
|
-
const value = requestHeaders.get(name)
|
|
19
|
-
if (value) headers.set(name, value)
|
|
20
|
-
}
|
|
21
|
-
if (!headers.has("anthropic-version")) headers.set("anthropic-version", "2023-06-01")
|
|
22
|
-
return headers
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
async function lookup(id: string, requestHeaders: Headers, upstream: string): Promise<number | null> {
|
|
26
|
-
const response = await fetch(`${upstream}/v1/models/${encodeURIComponent(id)}`, { headers: lookupHeaders(requestHeaders), signal: AbortSignal.timeout(5_000) })
|
|
27
|
-
if (!response.ok) return null
|
|
28
|
-
const info = (await response.json()) as { max_input_tokens?: number }
|
|
29
|
-
if (typeof info.max_input_tokens !== "number" || info.max_input_tokens <= 0) return null
|
|
30
|
-
windows.set(id, info.max_input_tokens)
|
|
13
|
+
function saveCache(windows: Map<string, number>) {
|
|
31
14
|
mkdirSync(EUNOE_DIR, { recursive: true })
|
|
32
15
|
writeFileSync(paths.models, JSON.stringify(Object.fromEntries(windows), null, 2))
|
|
33
|
-
return info.max_input_tokens
|
|
34
16
|
}
|
|
35
17
|
|
|
36
|
-
export
|
|
37
|
-
const id = String(model ?? "").replace(/\[[^\]]*\]$/, "")
|
|
38
|
-
if (!id) return Promise.resolve(null)
|
|
39
|
-
const known = windows.get(id)
|
|
40
|
-
if (known) return Promise.resolve(known)
|
|
41
|
-
const inFlight = lookups.get(id)
|
|
42
|
-
if (inFlight) return inFlight
|
|
43
|
-
const pending = lookup(id, requestHeaders, upstream)
|
|
44
|
-
.catch(() => null)
|
|
45
|
-
.finally(() => lookups.delete(id))
|
|
46
|
-
lookups.set(id, pending)
|
|
47
|
-
return pending
|
|
48
|
-
}
|
|
18
|
+
export const resolveWindow = createWindowLookup({ known: readCache(), onLearn: saveCache })
|
package/src/transcript/format.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { clip } from "../context/content"
|
|
2
2
|
import type { ToolPart, TranscriptFormat, Turn } from "./parse"
|
|
3
|
-
import type { SubagentRun } from "./subagents"
|
|
4
3
|
|
|
5
4
|
export interface SubagentRecord {
|
|
6
|
-
run:
|
|
5
|
+
run: { description: string }
|
|
7
6
|
turns: Turn[]
|
|
8
7
|
parentTurn: number
|
|
9
8
|
file: string
|
|
@@ -2,7 +2,8 @@ import { readFileSync, statSync } from "node:fs"
|
|
|
2
2
|
import path from "node:path"
|
|
3
3
|
import { blocks, clip, contentBlocks, type Message } from "../context/content"
|
|
4
4
|
import { splitTurns } from "../context/fold/turns"
|
|
5
|
-
import {
|
|
5
|
+
import type { RecallChunk } from "../context/recall"
|
|
6
|
+
import { subagentRecall } from "../core/subagents"
|
|
6
7
|
import { readSessionEntries } from "./entries"
|
|
7
8
|
import { listEntries } from "./locate"
|
|
8
9
|
import { sessionMessages } from "./messages"
|
|
@@ -163,8 +164,7 @@ export function subagentChunks(sessionFile: string): RecallChunk[] {
|
|
|
163
164
|
const parentTurn = launchTurn(run, launches)
|
|
164
165
|
const hit = chunkCache.get(key)
|
|
165
166
|
if (hit && hit.mtimeMs === run.mtimeMs && hit.parentTurn === parentTurn) return hit.chunks
|
|
166
|
-
const
|
|
167
|
-
const chunks = recallChunks([...subagentMessages(run), { role: "user", content: "" }], new Map()).map((chunk) => ({ ...chunk, from, turn: parentTurn }))
|
|
167
|
+
const chunks = subagentRecall(subagentMessages(run), run.description, parentTurn)
|
|
168
168
|
chunkCache.set(key, { mtimeMs: run.mtimeMs, parentTurn, chunks })
|
|
169
169
|
return chunks
|
|
170
170
|
})
|
|
File without changes
|
|
File without changes
|