@chroxify/eunoe 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +327 -2
- package/package.json +41 -4
- package/src/cli/args.ts +17 -0
- package/src/cli/claude-settings.ts +42 -0
- package/src/cli/commands/index.ts +9 -0
- package/src/cli/commands/install.ts +44 -0
- package/src/cli/commands/mode.ts +30 -0
- package/src/cli/commands/serve.ts +19 -0
- package/src/cli/commands/status.ts +48 -0
- package/src/cli/commands/stop.ts +16 -0
- package/src/cli/commands/uninstall.ts +25 -0
- package/src/cli/constants.ts +29 -0
- package/src/cli/help.ts +42 -0
- package/src/cli/inspect.ts +41 -0
- package/src/cli/launchd.ts +34 -0
- package/src/cli/logo/index.ts +89 -0
- package/src/cli/logo/pixels.ts +34 -0
- package/src/cli/main.ts +23 -0
- package/src/cli/types.ts +13 -0
- package/src/cli/ui.ts +91 -0
- package/src/config/constants.ts +27 -0
- package/src/config/index.ts +49 -0
- package/src/config/types.ts +21 -0
- package/src/context/constants.ts +7 -0
- package/src/context/content.ts +24 -0
- package/src/context/prompts.ts +66 -0
- package/src/context/strategies.ts +89 -0
- package/src/context/turns.ts +82 -0
- package/src/context/types.ts +24 -0
- package/src/proxy/compaction.ts +93 -0
- package/src/proxy/constants.ts +19 -0
- package/src/proxy/estimate.ts +26 -0
- package/src/proxy/log.ts +9 -0
- package/src/proxy/rewrite.ts +72 -0
- package/src/proxy/server.ts +120 -0
- package/src/proxy/state.ts +27 -0
- package/src/proxy/types.ts +58 -0
- package/src/proxy/usage.ts +44 -0
- package/src/proxy/window.ts +50 -0
- package/src/transcript/constants.ts +16 -0
- package/src/transcript/format.ts +68 -0
- package/src/transcript/locate.ts +28 -0
- package/src/transcript/parse.ts +93 -0
- package/src/transcript/render.ts +36 -0
- package/src/transcript/search.ts +80 -0
- package/src/transcript/types.ts +43 -0
- package/src/version.ts +3 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { CLEARED_INPUT_KEPT, CLEARED_INPUT_THRESHOLD } from "./constants"
|
|
2
|
+
import { blocks, withoutCacheControl } from "./content"
|
|
3
|
+
import { compactionNotice, truncationNotice } from "./prompts"
|
|
4
|
+
import { hasContent, isHarness, reduceTurn, splitTurns } from "./turns"
|
|
5
|
+
import type { Block, Body, CompactOptions, Message } from "./types"
|
|
6
|
+
|
|
7
|
+
export function trimMessages(messages: Message[]): Message[] {
|
|
8
|
+
const { preamble, turns } = splitTurns(messages)
|
|
9
|
+
if (turns.length < 2) return messages
|
|
10
|
+
return [...preamble, ...turns.slice(0, -1).flatMap(reduceTurn), ...turns.at(-1)!]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function compactMessages(
|
|
14
|
+
messages: Message[],
|
|
15
|
+
cutIndex: number,
|
|
16
|
+
options: CompactOptions,
|
|
17
|
+
): Message[] {
|
|
18
|
+
const { preamble, turns } = splitTurns(messages)
|
|
19
|
+
if (cutIndex <= 0 || cutIndex >= turns.length) return messages
|
|
20
|
+
const keptFrom = Math.max(1, cutIndex - options.keepTurns)
|
|
21
|
+
const notice = compactionNotice({ omitted: keptFrom - 1 + (options.omittedBefore ?? 0), kept: cutIndex - keptFrom, guide: options.guide })
|
|
22
|
+
const removed = [...turns[0].slice(1), ...turns.slice(1, keptFrom).flat()]
|
|
23
|
+
const carried = removed
|
|
24
|
+
.filter((m) => isHarness(m) && hasContent(m))
|
|
25
|
+
.flatMap((m) => blocks(m).filter((b) => b.type === "text"))
|
|
26
|
+
.map((b) => ({ type: "text", text: `<system-reminder>\n${b.text}\n</system-reminder>` }))
|
|
27
|
+
return [
|
|
28
|
+
...preamble,
|
|
29
|
+
turns[0][0],
|
|
30
|
+
{ role: "user", content: [{ type: "text", text: notice }, ...carried] },
|
|
31
|
+
...turns.slice(keptFrom, cutIndex).flatMap(reduceTurn),
|
|
32
|
+
...turns.slice(cutIndex).flat(),
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function tailMessages(messages: Message[], from: number, guide: string | null): Message[] {
|
|
37
|
+
const { preamble, turns } = splitTurns(messages)
|
|
38
|
+
if (turns.length === 0 || from <= 1) return messages
|
|
39
|
+
const notice = truncationNotice({ omitted: from - 1, kept: turns.length - from, guide })
|
|
40
|
+
return [
|
|
41
|
+
...preamble,
|
|
42
|
+
turns[0][0],
|
|
43
|
+
{ role: "user", content: [{ type: "text", text: notice }] },
|
|
44
|
+
...turns.slice(from).flat(),
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function countToolResults(messages: Message[], from: number): number {
|
|
49
|
+
return messages.slice(from).reduce((total, m) => total + (m.role === "user" ? blocks(m).filter((b) => b.type === "tool_result").length : 0), 0)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function clearToolResults(messages: Message[], from: number, count: number, placeholder: string): Message[] {
|
|
53
|
+
let left = count
|
|
54
|
+
const cleared = new Set<string>()
|
|
55
|
+
const results = messages.map((message, index) => {
|
|
56
|
+
if (left <= 0 || index < from || message.role !== "user" || typeof message.content === "string") return message
|
|
57
|
+
if (!message.content.some((b) => b.type === "tool_result")) return message
|
|
58
|
+
return {
|
|
59
|
+
...message,
|
|
60
|
+
content: message.content.map((b) => {
|
|
61
|
+
if (b.type !== "tool_result" || left <= 0) return b
|
|
62
|
+
left -= 1
|
|
63
|
+
cleared.add(b.tool_use_id)
|
|
64
|
+
return { type: "tool_result", tool_use_id: b.tool_use_id, ...(b.is_error ? { is_error: true } : {}), content: placeholder }
|
|
65
|
+
}),
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
return results.map((message) => {
|
|
69
|
+
if (message.role !== "assistant" || typeof message.content === "string") return message
|
|
70
|
+
if (!message.content.some((b) => b.type === "tool_use" && cleared.has(b.id))) return message
|
|
71
|
+
return { ...message, content: message.content.map((b) => (b.type === "tool_use" && cleared.has(b.id) ? { ...b, input: shortenInput(b.input) } : b)) }
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function shortenInput(input: unknown): unknown {
|
|
76
|
+
if (typeof input === "string") return input.length > CLEARED_INPUT_THRESHOLD ? `${input.slice(0, CLEARED_INPUT_KEPT)}… [${input.length - CLEARED_INPUT_KEPT} chars cleared; in the transcript]` : input
|
|
77
|
+
if (Array.isArray(input)) return input.map(shortenInput)
|
|
78
|
+
if (input && typeof input === "object") return Object.fromEntries(Object.entries(input).map(([k, v]) => [k, shortenInput(v)]))
|
|
79
|
+
return input
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function appendSystem(system: Body["system"], text: string): Block[] {
|
|
83
|
+
const list: Block[] = typeof system === "string" ? [{ type: "text", text: system }] : [...(system ?? [])]
|
|
84
|
+
const lastCached = list.findLastIndex((b) => b.cache_control)
|
|
85
|
+
const cacheControl = lastCached >= 0 ? list[lastCached].cache_control : undefined
|
|
86
|
+
if (lastCached >= 0) list[lastCached] = withoutCacheControl(list[lastCached])
|
|
87
|
+
list.push({ type: "text", text, ...(cacheControl ? { cache_control: cacheControl } : {}) })
|
|
88
|
+
return list
|
|
89
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { SUMMARY_ARGUMENT_CHARS, SUMMARY_CALLS_SHOWN, SUMMARY_COMMAND_CHARS, SUMMARY_LAST_WORDS_CHARS } from "./constants"
|
|
2
|
+
import { blocks, clean, clip, withoutCacheControl } from "./content"
|
|
3
|
+
import type { Block, Message } from "./types"
|
|
4
|
+
|
|
5
|
+
export function isPrompt(message: Message) {
|
|
6
|
+
if (message.role !== "user") return false
|
|
7
|
+
const content = blocks(message)
|
|
8
|
+
return !content.some((block) => block.type === "tool_result")
|
|
9
|
+
&& content.some((block) => block.type === "text" || block.type === "image" || block.type === "document")
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function splitTurns(messages: Message[]): { preamble: Message[]; turns: Message[][] } {
|
|
13
|
+
const preamble: Message[] = []
|
|
14
|
+
const turns: Message[][] = []
|
|
15
|
+
for (const message of messages) {
|
|
16
|
+
if (isPrompt(message) && !(turns.length > 0 && turns.at(-1)!.every((m) => m.role === "user"))) {
|
|
17
|
+
turns.push([message])
|
|
18
|
+
} else if (turns.length > 0) {
|
|
19
|
+
turns.at(-1)!.push(message)
|
|
20
|
+
} else {
|
|
21
|
+
preamble.push(message)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return { preamble, turns }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function turnFingerprint(turn: Message[]): string {
|
|
28
|
+
const hasher = new Bun.CryptoHasher("sha256")
|
|
29
|
+
hasher.update(JSON.stringify(blocks(turn[0]).map(withoutCacheControl)))
|
|
30
|
+
return hasher.digest("hex").slice(0, 24)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isHarness(message: Message) {
|
|
34
|
+
return message.role === "system"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function hasContent(message: Message) {
|
|
38
|
+
return blocks(message).some((block) => block.type !== "text" || String(block.text ?? "").trim())
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function mergedHarness(turn: Message[]): Message[] {
|
|
42
|
+
const content = turn.filter((m) => isHarness(m) && hasContent(m)).flatMap((m) => blocks(m).map(withoutCacheControl))
|
|
43
|
+
return content.length > 0 ? [{ role: "system", content }] : []
|
|
44
|
+
}
|
|
45
|
+
|
|
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
|
+
function interruptedSummary(turn: Message[]): string {
|
|
55
|
+
const assistant = turn.filter((m) => m.role === "assistant")
|
|
56
|
+
const lastWords = [...assistant].reverse()
|
|
57
|
+
.map((m) => blocks(m).filter((b) => b.type === "text").map((b) => String(b.text ?? "")).join("\n").trim())
|
|
58
|
+
.find(Boolean)
|
|
59
|
+
const calls = assistant.flatMap((m) => blocks(m).filter((b) => b.type === "tool_use")).map(describeToolCall)
|
|
60
|
+
const shown = calls.slice(-SUMMARY_CALLS_SHOWN)
|
|
61
|
+
return [
|
|
62
|
+
"[This turn was interrupted before a final reply.]",
|
|
63
|
+
lastWords ? `Last thing you said: ${lastWords.length > SUMMARY_LAST_WORDS_CHARS ? `${lastWords.slice(0, SUMMARY_LAST_WORDS_CHARS)}…` : lastWords}` : "",
|
|
64
|
+
calls.length > 0 ? `Tool calls (${calls.length}${calls.length > shown.length ? `, last ${shown.length} shown` : ""}): ${shown.join("; ")}` : "",
|
|
65
|
+
"Their output is in the full transcript.",
|
|
66
|
+
].filter(Boolean).join("\n")
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function reduceTurn(turn: Message[]): Message[] {
|
|
70
|
+
const opening: Message[] = []
|
|
71
|
+
for (let i = 0; i < turn.length && turn[i].role !== "assistant"; i += 1) {
|
|
72
|
+
if (turn[i].role === "user") opening.push(clean(turn[i]))
|
|
73
|
+
}
|
|
74
|
+
opening.push(...mergedHarness(turn))
|
|
75
|
+
const last = [...turn].reverse().find((m) => m.role !== "system")!
|
|
76
|
+
const content = last.role === "assistant" ? blocks(last) : []
|
|
77
|
+
const text = content.filter((b) => b.type === "text" && String(b.text ?? "").trim())
|
|
78
|
+
const final = text.length > 0 && !content.some((b) => b.type === "tool_use")
|
|
79
|
+
? text.map((b) => ({ type: "text", text: b.text }))
|
|
80
|
+
: [{ type: "text", text: interruptedSummary(turn) }]
|
|
81
|
+
return [...opening, { role: "assistant", content: final }]
|
|
82
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type Block = { type: string; [key: string]: any }
|
|
2
|
+
|
|
3
|
+
export type Message = { role: string; content: string | Block[] }
|
|
4
|
+
|
|
5
|
+
export type Body = {
|
|
6
|
+
model?: string
|
|
7
|
+
system?: string | Block[]
|
|
8
|
+
messages: Message[]
|
|
9
|
+
tools?: unknown[]
|
|
10
|
+
metadata?: { user_id?: string }
|
|
11
|
+
[key: string]: any
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CompactOptions {
|
|
15
|
+
keepTurns: number
|
|
16
|
+
guide: string | null
|
|
17
|
+
omittedBefore?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface NoticeCounts {
|
|
21
|
+
omitted: number
|
|
22
|
+
kept: number
|
|
23
|
+
guide: string | null
|
|
24
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { CLEARED_OUTPUT } from "../context/prompts"
|
|
2
|
+
import { clearToolResults, compactMessages, countToolResults } from "../context/strategies"
|
|
3
|
+
import { splitTurns, turnFingerprint } from "../context/turns"
|
|
4
|
+
import { DEFAULT_RATIO, PROTECTED_OUTPUTS } from "./constants"
|
|
5
|
+
import { estimator, targetSize } from "./estimate"
|
|
6
|
+
import { resetCut } from "./state"
|
|
7
|
+
import type { CompactInput, CompactResult } from "./types"
|
|
8
|
+
|
|
9
|
+
export function compact({ body, config, state, window, guide, mayCompact, omittedBefore = 0 }: CompactInput): CompactResult {
|
|
10
|
+
const { turns } = splitTurns(body.messages)
|
|
11
|
+
let stateChanged = false
|
|
12
|
+
|
|
13
|
+
let cutIndex = state.cut ? turns.findIndex((turn) => turnFingerprint(turn) === state.cut) : -1
|
|
14
|
+
if (state.cut && cutIndex === -1) {
|
|
15
|
+
resetCut(state)
|
|
16
|
+
stateChanged = true
|
|
17
|
+
}
|
|
18
|
+
if (cutIndex <= 0 && config.eval?.forceCut && turns.length >= 3) {
|
|
19
|
+
let forced = turns.length - 2
|
|
20
|
+
while (forced > 1 && JSON.stringify(turns[forced][0].content).includes("[Request interrupted")) forced -= 1
|
|
21
|
+
cutIndex = forced
|
|
22
|
+
state.cut = turnFingerprint(turns[forced])
|
|
23
|
+
stateChanged = true
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const configuredKeep = config.keepTurns === "all" ? Number.MAX_SAFE_INTEGER : config.keepTurns
|
|
27
|
+
const liveLength = () => turns.slice(Math.max(cutIndex, 0)).flat().length
|
|
28
|
+
const compacted = () => cutIndex > 0
|
|
29
|
+
? compactMessages(body.messages, cutIndex, { keepTurns: state.keep ?? configuredKeep, guide, omittedBefore })
|
|
30
|
+
: body.messages
|
|
31
|
+
const build = () => {
|
|
32
|
+
const messages = compacted()
|
|
33
|
+
return state.cleared ? clearToolResults(messages, messages.length - liveLength(), state.cleared, CLEARED_OUTPUT) : messages
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const size = estimator(body, state.ratio ?? DEFAULT_RATIO)
|
|
37
|
+
const limit = window ? config.compactAt * window : Number.POSITIVE_INFINITY
|
|
38
|
+
const target = window ? targetSize(size, window) : Number.POSITIVE_INFINITY
|
|
39
|
+
|
|
40
|
+
let messages = build()
|
|
41
|
+
let estimate = size.of(messages)
|
|
42
|
+
const actions: string[] = []
|
|
43
|
+
const current = turns.length - 1
|
|
44
|
+
|
|
45
|
+
if (mayCompact && estimate > limit) {
|
|
46
|
+
if (current > 0 && current > cutIndex) {
|
|
47
|
+
cutIndex = current
|
|
48
|
+
resetCut(state)
|
|
49
|
+
state.cut = turnFingerprint(turns[current])
|
|
50
|
+
actions.push("cut")
|
|
51
|
+
messages = build()
|
|
52
|
+
estimate = size.of(messages)
|
|
53
|
+
}
|
|
54
|
+
while (estimate > target) {
|
|
55
|
+
const keep = Math.min(state.keep ?? configuredKeep, cutIndex - 1)
|
|
56
|
+
if (cutIndex > 0 && keep > 0) {
|
|
57
|
+
state.keep = keep - 1
|
|
58
|
+
actions.push(`keep=${state.keep}`)
|
|
59
|
+
} else {
|
|
60
|
+
const unclearedLive = compacted()
|
|
61
|
+
const total = countToolResults(unclearedLive, unclearedLive.length - liveLength())
|
|
62
|
+
const remaining = total - (state.cleared ?? 0) - PROTECTED_OUTPUTS
|
|
63
|
+
if (remaining <= 0) {
|
|
64
|
+
actions.push("stuck")
|
|
65
|
+
break
|
|
66
|
+
}
|
|
67
|
+
state.cleared = (state.cleared ?? 0) + Math.ceil(remaining / 2)
|
|
68
|
+
actions.push(`cleared=${state.cleared}`)
|
|
69
|
+
}
|
|
70
|
+
messages = build()
|
|
71
|
+
estimate = size.of(messages)
|
|
72
|
+
}
|
|
73
|
+
stateChanged = true
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
messages,
|
|
78
|
+
stateChanged,
|
|
79
|
+
note: {
|
|
80
|
+
action: cutIndex > 0 ? "compact" : "full",
|
|
81
|
+
compacted: actions.includes("cut"),
|
|
82
|
+
actions: actions.length ? actions : undefined,
|
|
83
|
+
cutTurn: cutIndex > 0 ? cutIndex : null,
|
|
84
|
+
keep: state.keep,
|
|
85
|
+
cleared: state.cleared,
|
|
86
|
+
turns: turns.length,
|
|
87
|
+
messagesIn: body.messages.length,
|
|
88
|
+
messagesOut: messages.length,
|
|
89
|
+
estimate,
|
|
90
|
+
window,
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const HEALTH_PATH = "/_eunoe/health"
|
|
2
|
+
export const SESSION_HEADER = "x-claude-code-session-id"
|
|
3
|
+
export const TRANSCRIPT_HEADER = "x-eunoe-transcript"
|
|
4
|
+
export const OMITTED_BEFORE_HEADER = "x-eunoe-omitted-before"
|
|
5
|
+
|
|
6
|
+
export const TARGET_SHARE = 0.3
|
|
7
|
+
export const PROTECTED_OUTPUTS = 5
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_RATIO = 1 / 3.6
|
|
10
|
+
export const MEDIA_CHARS = 5_800
|
|
11
|
+
export const MEDIA_MIN_LENGTH = 2_000
|
|
12
|
+
|
|
13
|
+
export const MODEL_HEADERS = ["authorization", "x-api-key", "anthropic-version", "anthropic-beta"]
|
|
14
|
+
export const ANTHROPIC_VERSION = "2023-06-01"
|
|
15
|
+
export const MODEL_LOOKUP_TIMEOUT_MS = 5_000
|
|
16
|
+
|
|
17
|
+
export const MAX_EVENT_BUFFER = 4_000_000
|
|
18
|
+
export const LOGGED_ERROR_CHARS = 500
|
|
19
|
+
export const IDLE_TIMEOUT_SECONDS = 255
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Body, Message } from "../context/types"
|
|
2
|
+
import { MEDIA_CHARS, MEDIA_MIN_LENGTH, TARGET_SHARE } from "./constants"
|
|
3
|
+
import type { Estimator } from "./types"
|
|
4
|
+
|
|
5
|
+
export function sizeOf(body: Pick<Body, "system" | "tools" | "messages">): number {
|
|
6
|
+
let media = 0
|
|
7
|
+
const json = JSON.stringify({ system: body.system, tools: body.tools, messages: body.messages }, (key, value) => {
|
|
8
|
+
if (key === "data" && typeof value === "string" && value.length > MEDIA_MIN_LENGTH) {
|
|
9
|
+
media += 1
|
|
10
|
+
return ""
|
|
11
|
+
}
|
|
12
|
+
return value
|
|
13
|
+
})
|
|
14
|
+
return json.length + media * MEDIA_CHARS
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function estimator(body: Body, ratio: number): Estimator {
|
|
18
|
+
return {
|
|
19
|
+
of: (messages: Message[]) => Math.round(sizeOf({ ...body, messages }) * ratio),
|
|
20
|
+
fixed: Math.round(sizeOf({ ...body, messages: [] }) * ratio),
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function targetSize(size: Estimator, window: number): number {
|
|
25
|
+
return size.fixed + TARGET_SHARE * Math.max(0, window - size.fixed)
|
|
26
|
+
}
|
package/src/proxy/log.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync } from "node:fs"
|
|
2
|
+
import { EUNOE_DIR, paths } from "../config/constants"
|
|
3
|
+
|
|
4
|
+
export function logRequest(record: Record<string, unknown>) {
|
|
5
|
+
try {
|
|
6
|
+
mkdirSync(EUNOE_DIR, { recursive: true })
|
|
7
|
+
appendFileSync(paths.requests, JSON.stringify({ ts: new Date().toISOString(), ...record }) + "\n")
|
|
8
|
+
} catch {}
|
|
9
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { searchOnlyInstructions, trimInstructions, TRANSCRIPT_FALLBACK } from "../context/prompts"
|
|
2
|
+
import { appendSystem, tailMessages, trimMessages } from "../context/strategies"
|
|
3
|
+
import { splitTurns, turnFingerprint } from "../context/turns"
|
|
4
|
+
import type { Body } from "../context/types"
|
|
5
|
+
import { searchGuide } from "../transcript/search"
|
|
6
|
+
import { compact } from "./compaction"
|
|
7
|
+
import { DEFAULT_RATIO } from "./constants"
|
|
8
|
+
import { estimator, targetSize } from "./estimate"
|
|
9
|
+
import { loadState, saveState } from "./state"
|
|
10
|
+
import type { Rewrite, RewriteInput } from "./types"
|
|
11
|
+
|
|
12
|
+
const pass = (body: Body): Rewrite => ({ body, threadKey: null, note: { action: "pass" } })
|
|
13
|
+
|
|
14
|
+
const isAgentThread = (body: Body) => Array.isArray(body.tools) && body.tools.length > 0
|
|
15
|
+
|
|
16
|
+
export function rewrite(input: RewriteInput): Rewrite {
|
|
17
|
+
const { body, sessionId, config, transcript } = input
|
|
18
|
+
if (config.mode === "off" || !sessionId || !Array.isArray(body.messages) || body.messages.length === 0) return pass(body)
|
|
19
|
+
const { turns } = splitTurns(body.messages)
|
|
20
|
+
if (turns.length === 0) return pass(body)
|
|
21
|
+
const threadKey = `${sessionId}-${turnFingerprint(turns[0])}`
|
|
22
|
+
const guide = transcript ? searchGuide(transcript) : null
|
|
23
|
+
|
|
24
|
+
switch (config.eval?.strategy) {
|
|
25
|
+
case "guide": {
|
|
26
|
+
if (!isAgentThread(body)) return pass(body)
|
|
27
|
+
return {
|
|
28
|
+
body: guide ? { ...body, system: appendSystem(body.system, searchOnlyInstructions(guide)) } : body,
|
|
29
|
+
threadKey,
|
|
30
|
+
note: { action: "guide", turns: turns.length, messagesIn: body.messages.length },
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
case "tail": {
|
|
34
|
+
if (!isAgentThread(body)) return pass(body)
|
|
35
|
+
const size = estimator(body, loadState(threadKey).ratio ?? DEFAULT_RATIO)
|
|
36
|
+
let from = 1
|
|
37
|
+
if (config.eval.tailTurns) {
|
|
38
|
+
from = Math.max(1, turns.length - config.eval.tailTurns)
|
|
39
|
+
} else if (input.window) {
|
|
40
|
+
const target = targetSize(size, input.window)
|
|
41
|
+
from = turns.length - 1
|
|
42
|
+
while (from > 1 && size.of(tailMessages(body.messages, from - 1, guide)) <= target) from -= 1
|
|
43
|
+
}
|
|
44
|
+
const messages = tailMessages(body.messages, from, guide)
|
|
45
|
+
return {
|
|
46
|
+
body: { ...body, messages },
|
|
47
|
+
threadKey,
|
|
48
|
+
note: { action: "tail", keptTurns: turns.length - from, turns: turns.length, messagesIn: body.messages.length, messagesOut: messages.length, estimate: size.of(messages) },
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (config.mode === "trim") {
|
|
54
|
+
if (!isAgentThread(body)) return pass(body)
|
|
55
|
+
const messages = trimMessages(body.messages)
|
|
56
|
+
return {
|
|
57
|
+
body: { ...body, system: appendSystem(body.system, trimInstructions(guide ?? TRANSCRIPT_FALLBACK)), messages },
|
|
58
|
+
threadKey,
|
|
59
|
+
note: { action: "trim", turns: turns.length, messagesIn: body.messages.length, messagesOut: messages.length },
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (body.messages.length < 2) return { body, threadKey, note: { action: "full", turns: turns.length } }
|
|
64
|
+
const state = loadState(threadKey)
|
|
65
|
+
const result = compact({ body, config, state, window: input.window, guide, mayCompact: input.mayCompact, omittedBefore: input.omittedBefore })
|
|
66
|
+
if (result.stateChanged) saveState(threadKey, state)
|
|
67
|
+
return {
|
|
68
|
+
body: result.messages === body.messages ? body : { ...body, messages: result.messages },
|
|
69
|
+
threadKey,
|
|
70
|
+
note: result.note,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
import { claudeConfigDirs, loadConfig } from "../config"
|
|
3
|
+
import type { Config } from "../config/types"
|
|
4
|
+
import type { Body } from "../context/types"
|
|
5
|
+
import { findSessionFile } from "../transcript/locate"
|
|
6
|
+
import { prepareTranscript } from "../transcript/search"
|
|
7
|
+
import { VERSION } from "../version"
|
|
8
|
+
import { HEALTH_PATH, IDLE_TIMEOUT_SECONDS, LOGGED_ERROR_CHARS, OMITTED_BEFORE_HEADER, SESSION_HEADER, TRANSCRIPT_HEADER } from "./constants"
|
|
9
|
+
import { sizeOf } from "./estimate"
|
|
10
|
+
import { logRequest } from "./log"
|
|
11
|
+
import { rewrite } from "./rewrite"
|
|
12
|
+
import { loadState, saveState } from "./state"
|
|
13
|
+
import type { Health, Note } from "./types"
|
|
14
|
+
import { inputTokens, watchUsage } from "./usage"
|
|
15
|
+
import { resolveWindow } from "./window"
|
|
16
|
+
|
|
17
|
+
export function sessionIdOf(headers: Headers, body: Body): string | null {
|
|
18
|
+
const header = headers.get(SESSION_HEADER)
|
|
19
|
+
if (header) return header
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(body.metadata?.user_id ?? "{}").session_id ?? null
|
|
22
|
+
} catch {
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function forwardableHeaders(headers: Headers) {
|
|
28
|
+
const out = new Headers(headers)
|
|
29
|
+
out.delete("content-encoding")
|
|
30
|
+
out.delete("content-length")
|
|
31
|
+
return out
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function prepare(request: Request, body: Body, config: Config, isCountTokens: boolean) {
|
|
35
|
+
const sessionId = sessionIdOf(request.headers, body)
|
|
36
|
+
const trusted = config.eval?.trustHeaders === true
|
|
37
|
+
const transcriptOverride = trusted ? request.headers.get(TRANSCRIPT_HEADER) : null
|
|
38
|
+
const sessionFile = transcriptOverride ?? (sessionId ? findSessionFile(sessionId, claudeConfigDirs(config)) : null)
|
|
39
|
+
const transcript = sessionFile && sessionId
|
|
40
|
+
? prepareTranscript(config.search, sessionFile, transcriptOverride ? path.basename(transcriptOverride, ".jsonl") : sessionId)
|
|
41
|
+
: null
|
|
42
|
+
const needsWindow = config.mode === "compact" || config.eval?.strategy === "tail"
|
|
43
|
+
const window = config.window ?? (needsWindow ? await resolveWindow(body.model, request.headers, config.upstream) : null)
|
|
44
|
+
return rewrite({
|
|
45
|
+
body,
|
|
46
|
+
sessionId,
|
|
47
|
+
config,
|
|
48
|
+
transcript,
|
|
49
|
+
window,
|
|
50
|
+
mayCompact: !isCountTokens,
|
|
51
|
+
omittedBefore: trusted ? Number(request.headers.get(OMITTED_BEFORE_HEADER) ?? 0) : 0,
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function handle(request: Request): Promise<Response> {
|
|
56
|
+
const config = loadConfig()
|
|
57
|
+
const url = new URL(request.url)
|
|
58
|
+
if (url.pathname === HEALTH_PATH) return Response.json({ ok: true, version: VERSION, mode: config.mode, pid: process.pid } satisfies Health)
|
|
59
|
+
const headers = new Headers(request.headers)
|
|
60
|
+
headers.delete("host")
|
|
61
|
+
headers.delete("content-length")
|
|
62
|
+
headers.delete("accept-encoding")
|
|
63
|
+
|
|
64
|
+
let bodyText = request.method === "GET" || request.method === "HEAD" ? undefined : await request.text()
|
|
65
|
+
const isMessages = request.method === "POST" && url.pathname.startsWith("/v1/messages")
|
|
66
|
+
const isCountTokens = url.pathname.includes("count_tokens")
|
|
67
|
+
let note: Note = { action: "pass" }
|
|
68
|
+
let threadKey: string | null = null
|
|
69
|
+
let sentSize = 0
|
|
70
|
+
|
|
71
|
+
if (bodyText && isMessages) {
|
|
72
|
+
try {
|
|
73
|
+
const body = JSON.parse(bodyText) as Body
|
|
74
|
+
const result = await prepare(request, body, config, isCountTokens)
|
|
75
|
+
note = result.note
|
|
76
|
+
threadKey = result.threadKey
|
|
77
|
+
if (result.body !== body) bodyText = JSON.stringify(result.body)
|
|
78
|
+
sentSize = sizeOf(result.body)
|
|
79
|
+
} catch (error) {
|
|
80
|
+
note = { action: "error-passthrough", error: String(error) }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const upstream = await fetch(config.upstream + url.pathname + url.search, { method: request.method, headers, body: bodyText })
|
|
85
|
+
|
|
86
|
+
if (upstream.status >= 400 && note.action !== "pass") {
|
|
87
|
+
const errorText = await upstream.text()
|
|
88
|
+
logRequest({ path: url.pathname, status: upstream.status, thread: threadKey, ...note, error: errorText.slice(0, LOGGED_ERROR_CHARS) })
|
|
89
|
+
return new Response(errorText, { status: upstream.status, statusText: upstream.statusText, headers: forwardableHeaders(upstream.headers) })
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const response = new Response(upstream.body, { status: upstream.status, statusText: upstream.statusText, headers: forwardableHeaders(upstream.headers) })
|
|
93
|
+
if (note.action === "pass" || !isMessages || isCountTokens) {
|
|
94
|
+
if (note.action !== "pass") logRequest({ path: url.pathname, status: upstream.status, ...note })
|
|
95
|
+
return response
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return watchUsage(response, (usage) => {
|
|
99
|
+
const input = inputTokens(usage)
|
|
100
|
+
logRequest({
|
|
101
|
+
path: url.pathname,
|
|
102
|
+
status: upstream.status,
|
|
103
|
+
thread: threadKey,
|
|
104
|
+
...note,
|
|
105
|
+
input,
|
|
106
|
+
cacheRead: usage.cache_read_input_tokens ?? 0,
|
|
107
|
+
cacheWrite: usage.cache_creation_input_tokens ?? 0,
|
|
108
|
+
uncached: usage.input_tokens ?? 0,
|
|
109
|
+
})
|
|
110
|
+
if (threadKey && input > 0 && sentSize > 0) {
|
|
111
|
+
const state = loadState(threadKey)
|
|
112
|
+
state.ratio = input / sentSize
|
|
113
|
+
saveState(threadKey, state)
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function serve(port: number) {
|
|
119
|
+
return Bun.serve({ port, hostname: "127.0.0.1", idleTimeout: IDLE_TIMEOUT_SECONDS, fetch: handle })
|
|
120
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { paths } from "../config/constants"
|
|
4
|
+
import type { ThreadState } from "./types"
|
|
5
|
+
|
|
6
|
+
function stateFile(key: string) {
|
|
7
|
+
return path.join(paths.threads, `${key.replace(/[^a-zA-Z0-9_-]/g, "_")}.json`)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function loadState(key: string): ThreadState {
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(readFileSync(stateFile(key), "utf8"))
|
|
13
|
+
} catch {
|
|
14
|
+
return {}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function saveState(key: string, state: ThreadState) {
|
|
19
|
+
mkdirSync(paths.threads, { recursive: true })
|
|
20
|
+
writeFileSync(stateFile(key), JSON.stringify(state))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resetCut(state: ThreadState) {
|
|
24
|
+
delete state.cut
|
|
25
|
+
delete state.keep
|
|
26
|
+
delete state.cleared
|
|
27
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Config } from "../config/types"
|
|
2
|
+
import type { Body, Message } from "../context/types"
|
|
3
|
+
import type { TranscriptRef } from "../transcript/types"
|
|
4
|
+
|
|
5
|
+
export interface ThreadState {
|
|
6
|
+
cut?: string
|
|
7
|
+
keep?: number
|
|
8
|
+
cleared?: number
|
|
9
|
+
ratio?: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type Note = Record<string, unknown>
|
|
13
|
+
|
|
14
|
+
export interface RewriteInput {
|
|
15
|
+
body: Body
|
|
16
|
+
sessionId: string | null
|
|
17
|
+
config: Config
|
|
18
|
+
transcript: TranscriptRef | null
|
|
19
|
+
window: number | null
|
|
20
|
+
mayCompact: boolean
|
|
21
|
+
omittedBefore?: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Rewrite {
|
|
25
|
+
body: Body
|
|
26
|
+
threadKey: string | null
|
|
27
|
+
note: Note
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CompactInput {
|
|
31
|
+
body: Body
|
|
32
|
+
config: Config
|
|
33
|
+
state: ThreadState
|
|
34
|
+
window: number | null
|
|
35
|
+
guide: string | null
|
|
36
|
+
mayCompact: boolean
|
|
37
|
+
omittedBefore?: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface CompactResult {
|
|
41
|
+
messages: Message[]
|
|
42
|
+
stateChanged: boolean
|
|
43
|
+
note: Note
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface Estimator {
|
|
47
|
+
of: (messages: Message[]) => number
|
|
48
|
+
fixed: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type Usage = Record<string, number>
|
|
52
|
+
|
|
53
|
+
export interface Health {
|
|
54
|
+
ok: boolean
|
|
55
|
+
version: string
|
|
56
|
+
mode: Config["mode"]
|
|
57
|
+
pid: number
|
|
58
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { MAX_EVENT_BUFFER } from "./constants"
|
|
2
|
+
import type { Usage } from "./types"
|
|
3
|
+
|
|
4
|
+
export function watchUsage(response: Response, onUsage: (usage: Usage) => void): Response {
|
|
5
|
+
if (!response.body) return response
|
|
6
|
+
const [toClient, toUs] = response.body.tee()
|
|
7
|
+
void (async () => {
|
|
8
|
+
const reader = toUs.pipeThrough(new TextDecoderStream()).getReader()
|
|
9
|
+
let buffer = ""
|
|
10
|
+
let found = false
|
|
11
|
+
while (true) {
|
|
12
|
+
const { value, done } = await reader.read()
|
|
13
|
+
if (done) break
|
|
14
|
+
if (found) continue
|
|
15
|
+
buffer += value
|
|
16
|
+
let newline: number
|
|
17
|
+
while (!found && (newline = buffer.indexOf("\n")) !== -1) {
|
|
18
|
+
const line = buffer.slice(0, newline).trim()
|
|
19
|
+
buffer = buffer.slice(newline + 1)
|
|
20
|
+
if (!line.startsWith("data:") || !line.includes("message_start")) continue
|
|
21
|
+
try {
|
|
22
|
+
const event = JSON.parse(line.slice(5))
|
|
23
|
+
if (event.message?.usage) {
|
|
24
|
+
found = true
|
|
25
|
+
onUsage(event.message.usage)
|
|
26
|
+
}
|
|
27
|
+
} catch {}
|
|
28
|
+
}
|
|
29
|
+
if (!found && buffer.length > MAX_EVENT_BUFFER) buffer = ""
|
|
30
|
+
}
|
|
31
|
+
if (!found && buffer.trim().startsWith("{")) {
|
|
32
|
+
try {
|
|
33
|
+
const json = JSON.parse(buffer)
|
|
34
|
+
if (json.usage) onUsage(json.usage)
|
|
35
|
+
else if (typeof json.input_tokens === "number") onUsage({ input_tokens: json.input_tokens })
|
|
36
|
+
} catch {}
|
|
37
|
+
}
|
|
38
|
+
})().catch(() => undefined)
|
|
39
|
+
return new Response(toClient, { status: response.status, statusText: response.statusText, headers: response.headers })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function inputTokens(usage: Usage) {
|
|
43
|
+
return (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0)
|
|
44
|
+
}
|