@chroxify/eunoe 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +406 -2
  3. package/package.json +42 -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 +23 -0
  7. package/src/cli/commands/mode.ts +66 -0
  8. package/src/cli/commands/serve.ts +20 -0
  9. package/src/cli/commands/settings.ts +100 -0
  10. package/src/cli/commands/start.ts +64 -0
  11. package/src/cli/commands/status.ts +82 -0
  12. package/src/cli/commands/stop.ts +38 -0
  13. package/src/cli/commands/uninstall.ts +27 -0
  14. package/src/cli/constants.ts +52 -0
  15. package/src/cli/help.ts +42 -0
  16. package/src/cli/inspect.ts +41 -0
  17. package/src/cli/launchd.ts +39 -0
  18. package/src/cli/logo/index.ts +89 -0
  19. package/src/cli/logo/pixels.ts +34 -0
  20. package/src/cli/main.ts +23 -0
  21. package/src/cli/prompt.ts +91 -0
  22. package/src/cli/service.ts +33 -0
  23. package/src/cli/sessions.ts +25 -0
  24. package/src/cli/stats.ts +73 -0
  25. package/src/cli/types.ts +14 -0
  26. package/src/cli/ui.ts +106 -0
  27. package/src/config/constants.ts +39 -0
  28. package/src/config/index.ts +98 -0
  29. package/src/config/types.ts +50 -0
  30. package/src/context/constants.ts +42 -0
  31. package/src/context/content.ts +46 -0
  32. package/src/context/evidence.ts +92 -0
  33. package/src/context/prompts.ts +66 -0
  34. package/src/context/recall.ts +155 -0
  35. package/src/context/rerank.ts +44 -0
  36. package/src/context/strategies.ts +89 -0
  37. package/src/context/turns.ts +87 -0
  38. package/src/context/types.ts +66 -0
  39. package/src/proxy/canonical.ts +32 -0
  40. package/src/proxy/compaction.ts +110 -0
  41. package/src/proxy/constants.ts +29 -0
  42. package/src/proxy/estimate.ts +26 -0
  43. package/src/proxy/fingerprint.ts +15 -0
  44. package/src/proxy/log.ts +9 -0
  45. package/src/proxy/rewrite.ts +146 -0
  46. package/src/proxy/server.ts +138 -0
  47. package/src/proxy/sessions.ts +46 -0
  48. package/src/proxy/state.ts +27 -0
  49. package/src/proxy/types.ts +82 -0
  50. package/src/proxy/usage.ts +44 -0
  51. package/src/proxy/window.ts +50 -0
  52. package/src/transcript/constants.ts +15 -0
  53. package/src/transcript/format.ts +68 -0
  54. package/src/transcript/locate.ts +28 -0
  55. package/src/transcript/messages.ts +29 -0
  56. package/src/transcript/parse.ts +93 -0
  57. package/src/transcript/render.ts +36 -0
  58. package/src/transcript/search.ts +80 -0
  59. package/src/transcript/types.ts +43 -0
  60. package/src/version.ts +3 -0
@@ -0,0 +1,110 @@
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 { Message } from "../context/types"
8
+ import type { CompactInput, CompactResult } from "./types"
9
+
10
+ function settledCut(turns: Message[][], index: number) {
11
+ let cut = index
12
+ while (cut > 1 && JSON.stringify(turns[cut][0].content).includes("[Request interrupted")) cut -= 1
13
+ return cut
14
+ }
15
+
16
+ export function compact({ body, config, state, window, guide, mayCompact, omittedBefore = 0 }: CompactInput): CompactResult {
17
+ const { turns } = splitTurns(body.messages)
18
+ let stateChanged = false
19
+
20
+ let cutIndex = state.cut ? turns.findIndex((turn) => turnFingerprint(turn) === state.cut) : -1
21
+ if (state.cut && cutIndex === -1) {
22
+ resetCut(state)
23
+ stateChanged = true
24
+ }
25
+ const rolling = config.eval?.rolling ?? (config.mode === "default" ? { keep: config.keep, budget: config.budget } : undefined)
26
+ if (cutIndex <= 0 && config.eval?.forceCut && turns.length >= 3) {
27
+ const forced = settledCut(turns, Math.max(1, turns.length - 1 - (rolling?.keep ?? 1)))
28
+ cutIndex = forced
29
+ state.cut = turnFingerprint(turns[forced])
30
+ stateChanged = true
31
+ }
32
+
33
+ const configuredKeep = config.keepTurns === "all" ? Number.MAX_SAFE_INTEGER : config.keepTurns
34
+ const liveLength = () => turns.slice(Math.max(cutIndex, 0)).flat().length
35
+ const compacted = () => cutIndex > 0
36
+ ? compactMessages(body.messages, cutIndex, { keepTurns: state.keep ?? configuredKeep, guide, omittedBefore, evidence: config.eval?.evidence ?? true })
37
+ : body.messages
38
+ const build = () => {
39
+ const messages = compacted()
40
+ return state.cleared ? clearToolResults(messages, messages.length - liveLength(), state.cleared, CLEARED_OUTPUT) : messages
41
+ }
42
+
43
+ const size = estimator(body, state.ratio ?? DEFAULT_RATIO)
44
+ const limit = window ? config.compactAt * window : Number.POSITIVE_INFINITY
45
+ const target = window ? targetSize(size, window) : Number.POSITIVE_INFINITY
46
+
47
+ let messages = build()
48
+ let estimate = size.of(messages)
49
+ const actions: string[] = []
50
+ const current = turns.length - 1
51
+ const moveCut = (index: number, action: string) => {
52
+ cutIndex = index
53
+ resetCut(state)
54
+ state.cut = turnFingerprint(turns[index])
55
+ actions.push(action)
56
+ messages = build()
57
+ estimate = size.of(messages)
58
+ }
59
+
60
+ if (mayCompact && rolling) {
61
+ const next = settledCut(turns, current - rolling.keep)
62
+ const backlog = size.of(turns.slice(Math.max(cutIndex, 0), Math.max(next, 0)).flat()) - size.fixed
63
+ if (next >= 1 && next > cutIndex && backlog > rolling.budget) {
64
+ moveCut(next, "roll")
65
+ stateChanged = true
66
+ }
67
+ }
68
+
69
+ if (mayCompact && estimate > limit) {
70
+ if (current > 0 && current > cutIndex) moveCut(current, "cut")
71
+ while (estimate > target) {
72
+ const keep = Math.min(state.keep ?? configuredKeep, cutIndex - 1)
73
+ if (cutIndex > 0 && keep > 0) {
74
+ state.keep = keep - 1
75
+ actions.push(`keep=${state.keep}`)
76
+ } else {
77
+ const unclearedLive = compacted()
78
+ const total = countToolResults(unclearedLive, unclearedLive.length - liveLength())
79
+ const remaining = total - (state.cleared ?? 0) - PROTECTED_OUTPUTS
80
+ if (remaining <= 0) {
81
+ actions.push("stuck")
82
+ break
83
+ }
84
+ state.cleared = (state.cleared ?? 0) + Math.ceil(remaining / 2)
85
+ actions.push(`cleared=${state.cleared}`)
86
+ }
87
+ messages = build()
88
+ estimate = size.of(messages)
89
+ }
90
+ stateChanged = true
91
+ }
92
+
93
+ return {
94
+ messages,
95
+ stateChanged,
96
+ note: {
97
+ action: cutIndex > 0 ? "compact" : "full",
98
+ compacted: actions.includes("cut") || actions.includes("roll"),
99
+ actions: actions.length ? actions : undefined,
100
+ cutTurn: cutIndex > 0 ? cutIndex : null,
101
+ keep: state.keep,
102
+ cleared: state.cleared,
103
+ turns: turns.length,
104
+ messagesIn: body.messages.length,
105
+ messagesOut: messages.length,
106
+ estimate,
107
+ window,
108
+ },
109
+ }
110
+ }
@@ -0,0 +1,29 @@
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
+ export const CANONICAL_HEADER = "x-eunoe-canonical"
6
+ export const WINDOW_HEADER = "x-eunoe-window"
7
+ export const BILLING_LINE = /^x-anthropic-billing-header:[^\n]*/
8
+
9
+ export const TARGET_SHARE = 0.3
10
+ export const PROTECTED_OUTPUTS = 5
11
+
12
+ export const DEFAULT_RATIO = 1 / 3.6
13
+ export const MEDIA_CHARS = 5_800
14
+ export const MEDIA_MIN_LENGTH = 2_000
15
+
16
+ export const MODEL_HEADERS = ["authorization", "x-api-key", "anthropic-version", "anthropic-beta"]
17
+ export const ANTHROPIC_VERSION = "2023-06-01"
18
+ export const MODEL_LOOKUP_TIMEOUT_MS = 5_000
19
+
20
+ export const MAX_EVENT_BUFFER = 4_000_000
21
+ export const LOGGED_ERROR_CHARS = 500
22
+ export const IDLE_TIMEOUT_SECONDS = 255
23
+
24
+ export const SESSION_SEEN_INTERVAL_MS = 30_000
25
+ export const SESSION_HEAD_BYTES = 65_536
26
+ export const SESSIONS_KEPT = 200
27
+
28
+ export const FINGERPRINT_LENGTH = 12
29
+ export const SKILL_MARKER = /Base directory for this skill: ([^\s"\\]+)/g
@@ -0,0 +1,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,15 @@
1
+ import type { Body } from "../context/types"
2
+ import { FINGERPRINT_LENGTH, SKILL_MARKER } from "./constants"
3
+ import type { ContextFingerprint } from "./types"
4
+
5
+ const hash = (value: unknown) => {
6
+ const hasher = new Bun.CryptoHasher("sha256")
7
+ hasher.update(JSON.stringify(value ?? null, (key, inner) => (key === "cache_control" ? undefined : inner)))
8
+ return hasher.digest("hex").slice(0, FINGERPRINT_LENGTH)
9
+ }
10
+
11
+ export function contextFingerprint(body: Body): ContextFingerprint {
12
+ const skills = new Set<string>()
13
+ for (const match of JSON.stringify(body.messages ?? []).matchAll(SKILL_MARKER)) skills.add(match[1].split("/").at(-1)!)
14
+ return { system: hash(body.system), tools: hash(body.tools), skills: [...skills].sort() }
15
+ }
@@ -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,146 @@
1
+ import { searchOnlyInstructions, trimInstructions, TRANSCRIPT_FALLBACK } from "../context/prompts"
2
+ import { recallChunks, recallFor, recallForWith, livePrompt, withRecall } from "../context/recall"
3
+ import { jevReranker, rerankKey } from "../context/rerank"
4
+ import { appendSystem, tailMessages, trimMessages } from "../context/strategies"
5
+ import { blocks, resultText, withoutCacheControl } from "../context/content"
6
+ import { splitTurns, turnFingerprint } from "../context/turns"
7
+ import type { Body, Message, Reranker } from "../context/types"
8
+ import { sessionMessages } from "../transcript/messages"
9
+ import { searchGuide } from "../transcript/search"
10
+ import { RERANK_OFF } from "../config/constants"
11
+ import type { Config } from "../config/types"
12
+ import { compact } from "./compaction"
13
+ import { DEFAULT_RATIO } from "./constants"
14
+ import { estimator, targetSize } from "./estimate"
15
+ import { loadState, saveState } from "./state"
16
+ import type { Rewrite, RewriteInput } from "./types"
17
+
18
+ const pass = (body: Body): Rewrite => ({ body, threadKey: null, note: { action: "pass" } })
19
+
20
+ const isAgentThread = (body: Body) => Array.isArray(body.tools) && body.tools.length > 0
21
+
22
+ function messageFingerprint(message: Message) {
23
+ const hasher = new Bun.CryptoHasher("sha256")
24
+ hasher.update(JSON.stringify(blocks(message).filter((b) => !(b.type === "text" && String(b.text ?? "").startsWith("<recalled-context>"))).map(withoutCacheControl)))
25
+ return hasher.digest("hex").slice(0, 24)
26
+ }
27
+
28
+ const wantsRecall = (input: RewriteInput) => (input.config.eval?.recall ?? true) && input.config.mode !== "off"
29
+
30
+ function rerankModel(config: Config): string | null {
31
+ const model = config.eval?.rerank?.model ?? config.rerank
32
+ return model && model !== RERANK_OFF ? model : null
33
+ }
34
+
35
+ function droppedChunks(original: Body, result: Rewrite, transcript: string | null) {
36
+ const kept = new Map<string, string>()
37
+ for (const message of result.body.messages) for (const b of blocks(message)) if (b.type === "tool_result") kept.set(b.tool_use_id, resultText(b))
38
+ const chunks = recallChunks(original.messages, kept)
39
+ return chunks.length || !transcript ? chunks : recallChunks([...sessionMessages(transcript), { role: "user", content: "" }], kept)
40
+ }
41
+
42
+ function recallTarget(original: Body, result: Rewrite): { key: string; text: string } | null {
43
+ if (!result.threadKey || result.body === original && result.note.action === "pass") return null
44
+ const live = livePrompt(original.messages)
45
+ return live ? { key: messageFingerprint(original.messages[live.index]), text: live.text } : null
46
+ }
47
+
48
+ function recalled(original: Body, result: Rewrite, transcript: string | null): Rewrite {
49
+ if (!result.threadKey || result.body === original && result.note.action === "pass") return result
50
+ const state = loadState(result.threadKey)
51
+ state.recalls ??= {}
52
+ const target = recallTarget(original, result)
53
+ let note = result.note
54
+ if (target && !(target.key in state.recalls)) {
55
+ state.recalls[target.key] = recallFor(target.text, droppedChunks(original, result, transcript)) ?? ""
56
+ saveState(result.threadKey, state)
57
+ note = { ...note, recalled: state.recalls[target.key].length }
58
+ }
59
+ const recalls = Object.fromEntries(Object.entries(state.recalls).filter(([, text]) => text))
60
+ const messages = withRecall(result.body.messages, messageFingerprint, recalls)
61
+ return messages === result.body.messages ? { ...result, note } : { ...result, body: { ...result.body, messages }, note }
62
+ }
63
+
64
+ export async function prefetchRecall(input: RewriteInput, rerank: Reranker): Promise<boolean> {
65
+ if (!wantsRecall(input)) return false
66
+ const result = strategize(input)
67
+ const target = recallTarget(input.body, result)
68
+ if (!target) return false
69
+ const state = loadState(result.threadKey!)
70
+ state.recalls ??= {}
71
+ if (target.key in state.recalls) return false
72
+ const chunks = droppedChunks(input.body, result, input.transcript?.jsonl ?? null)
73
+ state.recalls[target.key] = (await recallForWith(target.text, chunks, rerank, { candidates: input.config.eval?.rerank?.candidates })) ?? ""
74
+ saveState(result.threadKey!, state)
75
+ return true
76
+ }
77
+
78
+ export function rewrite(input: RewriteInput): Rewrite {
79
+ const result = strategize(input)
80
+ return wantsRecall(input) ? recalled(input.body, result, input.transcript?.jsonl ?? null) : result
81
+ }
82
+
83
+ export async function rewriteAsync(input: RewriteInput): Promise<Rewrite> {
84
+ const model = rerankModel(input.config)
85
+ const key = model ? rerankKey() : null
86
+ const reranked = model && key ? await prefetchRecall(input, jevReranker(model, key)) : false
87
+ const result = rewrite(input)
88
+ return reranked ? { ...result, note: { ...result.note, reranked: model } } : result
89
+ }
90
+
91
+ function strategize(input: RewriteInput): Rewrite {
92
+ const { body, sessionId, config, transcript } = input
93
+ if (config.mode === "off" || !sessionId || !Array.isArray(body.messages) || body.messages.length === 0) return pass(body)
94
+ const { turns } = splitTurns(body.messages)
95
+ if (turns.length === 0) return pass(body)
96
+ const threadKey = `${sessionId}-${turnFingerprint(turns[0])}`
97
+ const guide = transcript ? searchGuide(transcript) : null
98
+
99
+ switch (config.eval?.strategy) {
100
+ case "guide": {
101
+ if (!isAgentThread(body)) return pass(body)
102
+ return {
103
+ body: guide ? { ...body, system: appendSystem(body.system, searchOnlyInstructions(guide)) } : body,
104
+ threadKey,
105
+ note: { action: "guide", turns: turns.length, messagesIn: body.messages.length },
106
+ }
107
+ }
108
+ case "tail": {
109
+ if (!isAgentThread(body)) return pass(body)
110
+ const size = estimator(body, loadState(threadKey).ratio ?? DEFAULT_RATIO)
111
+ let from = 1
112
+ if (config.eval.tailTurns) {
113
+ from = Math.max(1, turns.length - config.eval.tailTurns)
114
+ } else if (input.window) {
115
+ const target = targetSize(size, input.window)
116
+ from = turns.length - 1
117
+ while (from > 1 && size.of(tailMessages(body.messages, from - 1, guide)) <= target) from -= 1
118
+ }
119
+ const messages = tailMessages(body.messages, from, guide)
120
+ return {
121
+ body: { ...body, messages },
122
+ threadKey,
123
+ note: { action: "tail", keptTurns: turns.length - from, turns: turns.length, messagesIn: body.messages.length, messagesOut: messages.length, estimate: size.of(messages) },
124
+ }
125
+ }
126
+ case "trim": {
127
+ if (!isAgentThread(body)) return pass(body)
128
+ const messages = trimMessages(body.messages, { evidence: config.eval?.evidence ?? true })
129
+ return {
130
+ body: { ...body, system: appendSystem(body.system, trimInstructions(guide ?? TRANSCRIPT_FALLBACK)), messages },
131
+ threadKey,
132
+ note: { action: "trim", turns: turns.length, messagesIn: body.messages.length, messagesOut: messages.length },
133
+ }
134
+ }
135
+ }
136
+
137
+ if (body.messages.length < 2) return { body, threadKey, note: { action: "full", turns: turns.length } }
138
+ const state = loadState(threadKey)
139
+ const result = compact({ body, config, state, window: input.window, guide, mayCompact: input.mayCompact, omittedBefore: input.omittedBefore })
140
+ if (result.stateChanged) saveState(threadKey, state)
141
+ return {
142
+ body: result.messages === body.messages ? body : { ...body, messages: result.messages },
143
+ threadKey,
144
+ note: result.note,
145
+ }
146
+ }
@@ -0,0 +1,138 @@
1
+ import path from "node:path"
2
+ import { claudeConfigDirs, loadConfig, settingsFor } from "../config"
3
+ import type { Config } from "../config/types"
4
+ import { wellFormed } from "../context/content"
5
+ import type { Body } from "../context/types"
6
+ import { findSessionFile } from "../transcript/locate"
7
+ import { prepareTranscript } from "../transcript/search"
8
+ import { VERSION } from "../version"
9
+ import { CANONICAL_HEADER, HEALTH_PATH, IDLE_TIMEOUT_SECONDS, LOGGED_ERROR_CHARS, OMITTED_BEFORE_HEADER, SESSION_HEADER, TRANSCRIPT_HEADER, WINDOW_HEADER } from "./constants"
10
+ import { applyCanonical } from "./canonical"
11
+ import { sizeOf } from "./estimate"
12
+ import { contextFingerprint } from "./fingerprint"
13
+ import { logRequest } from "./log"
14
+ import { rewriteAsync } from "./rewrite"
15
+ import { recordSession } from "./sessions"
16
+ import { loadState, saveState } from "./state"
17
+ import type { ContextFingerprint, Health, Note } from "./types"
18
+ import { inputTokens, watchUsage } from "./usage"
19
+ import { resolveWindow } from "./window"
20
+
21
+ export function sessionIdOf(headers: Headers, body: Body): string | null {
22
+ const header = headers.get(SESSION_HEADER)
23
+ if (header) return header
24
+ try {
25
+ return JSON.parse(body.metadata?.user_id ?? "{}").session_id ?? null
26
+ } catch {
27
+ return null
28
+ }
29
+ }
30
+
31
+ function forwardableHeaders(headers: Headers) {
32
+ const out = new Headers(headers)
33
+ out.delete("content-encoding")
34
+ out.delete("content-length")
35
+ return out
36
+ }
37
+
38
+ async function prepare(request: Request, body: Body, base: Config, isCountTokens: boolean) {
39
+ const sessionId = sessionIdOf(request.headers, body)
40
+ const config = settingsFor(base, sessionId)
41
+ const trusted = config.eval?.trustHeaders === true
42
+ const transcriptOverride = trusted ? request.headers.get(TRANSCRIPT_HEADER) : null
43
+ const sessionFile = transcriptOverride ?? (sessionId ? findSessionFile(sessionId, claudeConfigDirs(config)) : null)
44
+ if (sessionId && !trusted) recordSession(sessionId, config.mode, sessionFile)
45
+ if (config.mode === "off") return rewriteAsync({ body, sessionId, config, transcript: null, window: null, mayCompact: false })
46
+ const transcript = sessionFile && sessionId
47
+ ? prepareTranscript(config.search, sessionFile, transcriptOverride ? path.basename(transcriptOverride, ".jsonl") : sessionId)
48
+ : null
49
+ const needsWindow = !config.eval?.strategy || config.eval.strategy === "tail"
50
+ const windowOverride = trusted ? Number(request.headers.get(WINDOW_HEADER)) || null : null
51
+ const window = windowOverride ?? config.window ?? (needsWindow ? await resolveWindow(body.model, request.headers, config.upstream) : null)
52
+ return rewriteAsync({
53
+ body,
54
+ sessionId,
55
+ config,
56
+ transcript,
57
+ window,
58
+ mayCompact: !isCountTokens,
59
+ omittedBefore: trusted ? Number(request.headers.get(OMITTED_BEFORE_HEADER) ?? 0) : 0,
60
+ })
61
+ }
62
+
63
+ export async function handle(request: Request): Promise<Response> {
64
+ const config = loadConfig()
65
+ const url = new URL(request.url)
66
+ if (url.pathname === HEALTH_PATH) return Response.json({ ok: true, version: VERSION, mode: config.mode, pid: process.pid } satisfies Health)
67
+ const headers = new Headers(request.headers)
68
+ headers.delete("host")
69
+ headers.delete("content-length")
70
+ headers.delete("accept-encoding")
71
+
72
+ let bodyText = request.method === "GET" || request.method === "HEAD" ? undefined : await request.text()
73
+ const isMessages = request.method === "POST" && url.pathname.startsWith("/v1/messages")
74
+ const isCountTokens = url.pathname.includes("count_tokens")
75
+ let note: Note = { action: "pass" }
76
+ let threadKey: string | null = null
77
+ let sentSize = 0
78
+ let givenSize = 0
79
+ let sent: (ContextFingerprint & { session: string | null }) | null = null
80
+
81
+ if (bodyText && isMessages) {
82
+ try {
83
+ const given = JSON.parse(bodyText) as Body
84
+ const canonicalFile = config.eval?.trustHeaders ? request.headers.get(CANONICAL_HEADER) : null
85
+ const canonical = canonicalFile ? applyCanonical(given, canonicalFile) : null
86
+ const body = canonical?.body ?? given
87
+ const result = await prepare(request, body, config, isCountTokens)
88
+ note = canonical ? { ...result.note, canonical: canonical.status } : result.note
89
+ threadKey = result.threadKey
90
+ if (result.body !== given) bodyText = JSON.stringify(result.body, wellFormed)
91
+ sentSize = sizeOf(result.body)
92
+ givenSize = result.body === given ? sentSize : sizeOf(given)
93
+ if (config.eval) sent = { session: sessionIdOf(request.headers, given), ...contextFingerprint(result.body), given: contextFingerprint(given) }
94
+ } catch (error) {
95
+ note = { action: "error-passthrough", error: String(error) }
96
+ }
97
+ }
98
+
99
+ const upstream = await fetch(config.upstream + url.pathname + url.search, { method: request.method, headers, body: bodyText })
100
+
101
+ if (upstream.status >= 400 && note.action !== "pass") {
102
+ const errorText = await upstream.text()
103
+ logRequest({ path: url.pathname, status: upstream.status, thread: threadKey, ...note, error: errorText.slice(0, LOGGED_ERROR_CHARS) })
104
+ return new Response(errorText, { status: upstream.status, statusText: upstream.statusText, headers: forwardableHeaders(upstream.headers) })
105
+ }
106
+
107
+ const response = new Response(upstream.body, { status: upstream.status, statusText: upstream.statusText, headers: forwardableHeaders(upstream.headers) })
108
+ if (note.action === "pass" || !isMessages || isCountTokens) {
109
+ if (note.action !== "pass" || sent) logRequest({ path: url.pathname, status: upstream.status, ...note, ...(sent && { context: sent }) })
110
+ return response
111
+ }
112
+
113
+ return watchUsage(response, (usage) => {
114
+ const input = inputTokens(usage)
115
+ logRequest({
116
+ path: url.pathname,
117
+ status: upstream.status,
118
+ thread: threadKey,
119
+ ...note,
120
+ input,
121
+ cacheRead: usage.cache_read_input_tokens ?? 0,
122
+ cacheWrite: usage.cache_creation_input_tokens ?? 0,
123
+ uncached: usage.input_tokens ?? 0,
124
+ charsIn: givenSize,
125
+ charsOut: sentSize,
126
+ ...(sent && { context: sent }),
127
+ })
128
+ if (threadKey && input > 0 && sentSize > 0) {
129
+ const state = loadState(threadKey)
130
+ state.ratio = input / sentSize
131
+ saveState(threadKey, state)
132
+ }
133
+ })
134
+ }
135
+
136
+ export function serve(port: number) {
137
+ return Bun.serve({ port, hostname: "127.0.0.1", idleTimeout: IDLE_TIMEOUT_SECONDS, fetch: handle })
138
+ }
@@ -0,0 +1,46 @@
1
+ import { closeSync, mkdirSync, openSync, readFileSync, readSync, writeFileSync } from "node:fs"
2
+ import { EUNOE_DIR, paths } from "../config/constants"
3
+ import type { Mode } from "../config/types"
4
+ import { SESSION_HEAD_BYTES, SESSION_SEEN_INTERVAL_MS, SESSIONS_KEPT } from "./constants"
5
+ import type { SeenSession } from "./types"
6
+
7
+ let seen: Record<string, SeenSession> | null = null
8
+
9
+ function load(): Record<string, SeenSession> {
10
+ try {
11
+ return JSON.parse(readFileSync(paths.sessions, "utf8"))
12
+ } catch {
13
+ return {}
14
+ }
15
+ }
16
+
17
+ function cwdOf(sessionFile: string): string | undefined {
18
+ try {
19
+ const fd = openSync(sessionFile, "r")
20
+ const buffer = Buffer.alloc(SESSION_HEAD_BYTES)
21
+ const read = readSync(fd, buffer, 0, SESSION_HEAD_BYTES, 0)
22
+ closeSync(fd)
23
+ return buffer.subarray(0, read).toString("utf8").match(/"cwd":"((?:[^"\\]|\\.)*)"/)?.[1]
24
+ } catch {
25
+ return undefined
26
+ }
27
+ }
28
+
29
+ export function recordSession(sessionId: string, mode: Mode, sessionFile: string | null) {
30
+ seen ??= load()
31
+ const previous = seen[sessionId]
32
+ const now = Date.now()
33
+ const needsCwd = !previous?.cwd && sessionFile !== null
34
+ if (previous && previous.mode === mode && !needsCwd && now - Date.parse(previous.lastSeen) < SESSION_SEEN_INTERVAL_MS) return
35
+ seen[sessionId] = { lastSeen: new Date(now).toISOString(), mode, cwd: previous?.cwd ?? (sessionFile ? cwdOf(sessionFile) : undefined) }
36
+ const kept = Object.entries(seen).sort(([, a], [, b]) => b.lastSeen.localeCompare(a.lastSeen)).slice(0, SESSIONS_KEPT)
37
+ seen = Object.fromEntries(kept)
38
+ try {
39
+ mkdirSync(EUNOE_DIR, { recursive: true })
40
+ writeFileSync(paths.sessions, JSON.stringify(seen, null, 2))
41
+ } catch {}
42
+ }
43
+
44
+ export function seenSessions(): Array<[string, SeenSession]> {
45
+ return Object.entries(load()).sort(([, a], [, b]) => b.lastSeen.localeCompare(a.lastSeen))
46
+ }
@@ -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,82 @@
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
+ recalls?: Record<string, string>
11
+ }
12
+
13
+ export type Note = Record<string, unknown>
14
+
15
+ export interface ContextFingerprint {
16
+ system: string
17
+ tools: string
18
+ skills: string[]
19
+ given?: ContextFingerprint
20
+ }
21
+
22
+ export interface Canonical {
23
+ system: Body["system"]
24
+ tools: Body["tools"]
25
+ }
26
+
27
+ export interface CanonicalResult {
28
+ body: Body
29
+ status: "applied" | "captured" | "skipped"
30
+ }
31
+
32
+ export interface RewriteInput {
33
+ body: Body
34
+ sessionId: string | null
35
+ config: Config
36
+ transcript: TranscriptRef | null
37
+ window: number | null
38
+ mayCompact: boolean
39
+ omittedBefore?: number
40
+ }
41
+
42
+ export interface Rewrite {
43
+ body: Body
44
+ threadKey: string | null
45
+ note: Note
46
+ }
47
+
48
+ export interface CompactInput {
49
+ body: Body
50
+ config: Config
51
+ state: ThreadState
52
+ window: number | null
53
+ guide: string | null
54
+ mayCompact: boolean
55
+ omittedBefore?: number
56
+ }
57
+
58
+ export interface CompactResult {
59
+ messages: Message[]
60
+ stateChanged: boolean
61
+ note: Note
62
+ }
63
+
64
+ export interface Estimator {
65
+ of: (messages: Message[]) => number
66
+ fixed: number
67
+ }
68
+
69
+ export type Usage = Record<string, number>
70
+
71
+ export interface SeenSession {
72
+ lastSeen: string
73
+ mode: Config["mode"]
74
+ cwd?: string
75
+ }
76
+
77
+ export interface Health {
78
+ ok: boolean
79
+ version: string
80
+ mode: Config["mode"]
81
+ pid: number
82
+ }
@@ -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
+ }