@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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +327 -2
  3. package/package.json +41 -4
  4. package/src/cli/args.ts +17 -0
  5. package/src/cli/claude-settings.ts +42 -0
  6. package/src/cli/commands/index.ts +9 -0
  7. package/src/cli/commands/install.ts +44 -0
  8. package/src/cli/commands/mode.ts +30 -0
  9. package/src/cli/commands/serve.ts +19 -0
  10. package/src/cli/commands/status.ts +48 -0
  11. package/src/cli/commands/stop.ts +16 -0
  12. package/src/cli/commands/uninstall.ts +25 -0
  13. package/src/cli/constants.ts +29 -0
  14. package/src/cli/help.ts +42 -0
  15. package/src/cli/inspect.ts +41 -0
  16. package/src/cli/launchd.ts +34 -0
  17. package/src/cli/logo/index.ts +89 -0
  18. package/src/cli/logo/pixels.ts +34 -0
  19. package/src/cli/main.ts +23 -0
  20. package/src/cli/types.ts +13 -0
  21. package/src/cli/ui.ts +91 -0
  22. package/src/config/constants.ts +27 -0
  23. package/src/config/index.ts +49 -0
  24. package/src/config/types.ts +21 -0
  25. package/src/context/constants.ts +7 -0
  26. package/src/context/content.ts +24 -0
  27. package/src/context/prompts.ts +66 -0
  28. package/src/context/strategies.ts +89 -0
  29. package/src/context/turns.ts +82 -0
  30. package/src/context/types.ts +24 -0
  31. package/src/proxy/compaction.ts +93 -0
  32. package/src/proxy/constants.ts +19 -0
  33. package/src/proxy/estimate.ts +26 -0
  34. package/src/proxy/log.ts +9 -0
  35. package/src/proxy/rewrite.ts +72 -0
  36. package/src/proxy/server.ts +120 -0
  37. package/src/proxy/state.ts +27 -0
  38. package/src/proxy/types.ts +58 -0
  39. package/src/proxy/usage.ts +44 -0
  40. package/src/proxy/window.ts +50 -0
  41. package/src/transcript/constants.ts +16 -0
  42. package/src/transcript/format.ts +68 -0
  43. package/src/transcript/locate.ts +28 -0
  44. package/src/transcript/parse.ts +93 -0
  45. package/src/transcript/render.ts +36 -0
  46. package/src/transcript/search.ts +80 -0
  47. package/src/transcript/types.ts +43 -0
  48. 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
+ }
@@ -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
+ }