@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.
- package/LICENSE +21 -0
- package/README.md +406 -2
- package/package.json +42 -4
- package/src/cli/args.ts +17 -0
- package/src/cli/claude-settings.ts +42 -0
- package/src/cli/commands/index.ts +23 -0
- package/src/cli/commands/mode.ts +66 -0
- package/src/cli/commands/serve.ts +20 -0
- package/src/cli/commands/settings.ts +100 -0
- package/src/cli/commands/start.ts +64 -0
- package/src/cli/commands/status.ts +82 -0
- package/src/cli/commands/stop.ts +38 -0
- package/src/cli/commands/uninstall.ts +27 -0
- package/src/cli/constants.ts +52 -0
- package/src/cli/help.ts +42 -0
- package/src/cli/inspect.ts +41 -0
- package/src/cli/launchd.ts +39 -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/prompt.ts +91 -0
- package/src/cli/service.ts +33 -0
- package/src/cli/sessions.ts +25 -0
- package/src/cli/stats.ts +73 -0
- package/src/cli/types.ts +14 -0
- package/src/cli/ui.ts +106 -0
- package/src/config/constants.ts +39 -0
- package/src/config/index.ts +98 -0
- package/src/config/types.ts +50 -0
- package/src/context/constants.ts +42 -0
- package/src/context/content.ts +46 -0
- package/src/context/evidence.ts +92 -0
- package/src/context/prompts.ts +66 -0
- package/src/context/recall.ts +155 -0
- package/src/context/rerank.ts +44 -0
- package/src/context/strategies.ts +89 -0
- package/src/context/turns.ts +87 -0
- package/src/context/types.ts +66 -0
- package/src/proxy/canonical.ts +32 -0
- package/src/proxy/compaction.ts +110 -0
- package/src/proxy/constants.ts +29 -0
- package/src/proxy/estimate.ts +26 -0
- package/src/proxy/fingerprint.ts +15 -0
- package/src/proxy/log.ts +9 -0
- package/src/proxy/rewrite.ts +146 -0
- package/src/proxy/server.ts +138 -0
- package/src/proxy/sessions.ts +46 -0
- package/src/proxy/state.ts +27 -0
- package/src/proxy/types.ts +82 -0
- package/src/proxy/usage.ts +44 -0
- package/src/proxy/window.ts +50 -0
- package/src/transcript/constants.ts +15 -0
- package/src/transcript/format.ts +68 -0
- package/src/transcript/locate.ts +28 -0
- package/src/transcript/messages.ts +29 -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,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
|
+
}
|
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,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
|
+
}
|