@chroxify/eunoe 0.1.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +364 -239
- package/package.json +20 -3
- package/src/cli/{types.ts → command.ts} +1 -4
- package/src/cli/commands/index.ts +27 -3
- package/src/cli/commands/load.ts +44 -0
- package/src/cli/commands/mode.ts +68 -24
- package/src/cli/commands/peek.ts +22 -0
- package/src/cli/commands/replay.ts +194 -0
- package/src/cli/commands/serve.ts +24 -8
- package/src/cli/commands/sessions.ts +70 -0
- package/src/cli/commands/settings.ts +119 -0
- package/src/cli/commands/start.ts +73 -0
- package/src/cli/commands/status.ts +107 -30
- package/src/cli/commands/stop.ts +36 -12
- package/src/cli/commands/uninstall.ts +14 -14
- package/src/cli/commands/watch.ts +16 -0
- package/src/cli/logo/art.ts +99 -0
- package/src/cli/logo/index.ts +12 -18
- package/src/cli/main.ts +3 -3
- package/src/cli/proxy/claude-settings.ts +59 -0
- package/src/cli/proxy/inspect.ts +67 -0
- package/src/cli/proxy/launchd.ts +53 -0
- package/src/cli/proxy/service.ts +53 -0
- package/src/cli/proxy/stats.ts +81 -0
- package/src/cli/proxy/watch.ts +60 -0
- package/src/cli/replay/index.ts +141 -0
- package/src/cli/replay/worker.ts +15 -0
- package/src/cli/sessions/browse.ts +88 -0
- package/src/cli/sessions/index.ts +76 -0
- package/src/cli/sessions/source.ts +74 -0
- package/src/cli/{args.ts → terminal/args.ts} +1 -1
- package/src/cli/{help.ts → terminal/help.ts} +25 -9
- package/src/cli/terminal/prompt.ts +114 -0
- package/src/cli/terminal/ui.ts +137 -0
- package/src/config/index.ts +128 -14
- package/src/config/{constants.ts → paths.ts} +4 -14
- package/src/context/content.ts +51 -3
- package/src/context/fold/evidence.ts +145 -0
- package/src/context/fold/index.ts +65 -0
- package/src/context/fold/strategies.ts +94 -0
- package/src/context/fold/turns.ts +76 -0
- package/src/context/prompts.ts +141 -22
- package/src/context/recall/index.ts +214 -0
- package/src/context/recall/rerank.ts +71 -0
- package/src/proxy/canonical.ts +40 -0
- package/src/proxy/compaction.ts +122 -52
- package/src/proxy/estimate.ts +14 -9
- package/src/proxy/fingerprint.ts +29 -0
- package/src/proxy/handoff.ts +71 -0
- package/src/proxy/log.ts +2 -2
- package/src/proxy/recall.ts +58 -0
- package/src/proxy/rewrite.ts +152 -56
- package/src/proxy/server.ts +122 -47
- package/src/proxy/sessions.ts +64 -0
- package/src/proxy/state.ts +17 -3
- package/src/proxy/usage.ts +50 -34
- package/src/proxy/window.ts +32 -34
- package/src/transcript/format.ts +88 -25
- package/src/transcript/locate.ts +38 -17
- package/src/transcript/messages.ts +18 -0
- package/src/transcript/parse.ts +137 -56
- package/src/transcript/render.ts +44 -17
- package/src/transcript/search.ts +20 -56
- package/src/transcript/subagents.ts +150 -0
- package/src/cli/claude-settings.ts +0 -42
- package/src/cli/commands/install.ts +0 -44
- package/src/cli/constants.ts +0 -29
- package/src/cli/inspect.ts +0 -41
- package/src/cli/launchd.ts +0 -34
- package/src/cli/logo/pixels.ts +0 -34
- package/src/cli/ui.ts +0 -91
- package/src/config/types.ts +0 -21
- package/src/context/constants.ts +0 -7
- package/src/context/strategies.ts +0 -89
- package/src/context/turns.ts +0 -82
- package/src/context/types.ts +0 -24
- package/src/proxy/constants.ts +0 -19
- package/src/proxy/types.ts +0 -58
- package/src/transcript/constants.ts +0 -16
- package/src/transcript/types.ts +0 -43
package/src/proxy/log.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { appendFileSync, mkdirSync } from "node:fs"
|
|
2
|
-
import { EUNOE_DIR, paths } from "../config/
|
|
2
|
+
import { EUNOE_DIR, paths } from "../config/paths"
|
|
3
3
|
|
|
4
4
|
export function logRequest(record: Record<string, unknown>) {
|
|
5
5
|
try {
|
|
6
6
|
mkdirSync(EUNOE_DIR, { recursive: true })
|
|
7
|
-
appendFileSync(paths.requests, JSON.stringify({ ts: new Date().toISOString(), ...record })
|
|
7
|
+
appendFileSync(paths.requests, `${JSON.stringify({ ts: new Date().toISOString(), ...record })}\n`)
|
|
8
8
|
} catch {}
|
|
9
9
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type Body, blocks, resultText } from "../context/content"
|
|
2
|
+
import { livePrompt, type RecallChunk, type Reranker, recallChunks, recallFor, recallForWith, withRecall } from "../context/recall"
|
|
3
|
+
import { sessionMessages } from "../transcript/messages"
|
|
4
|
+
import { subagentChunks } from "../transcript/subagents"
|
|
5
|
+
import { messageFingerprint } from "./fingerprint"
|
|
6
|
+
import { activeHandoffs, handoffChunks } from "./handoff"
|
|
7
|
+
import type { Rewrite, RewriteInput } from "./rewrite"
|
|
8
|
+
import { readState, saveState } from "./state"
|
|
9
|
+
|
|
10
|
+
export const wantsRecall = (input: RewriteInput) => (input.config.eval?.recall ?? true) && input.config.mode !== "off"
|
|
11
|
+
|
|
12
|
+
export const activeRecalls = (recalls: Record<string, string> = {}) => Object.fromEntries(Object.entries(recalls).filter(([, text]) => text))
|
|
13
|
+
|
|
14
|
+
const touched = (input: RewriteInput, result: Rewrite) => result.threadKey !== null && !(result.body === input.body && result.note.action === "pass")
|
|
15
|
+
|
|
16
|
+
function droppedChunks(input: RewriteInput, result: Rewrite): RecallChunk[] {
|
|
17
|
+
const transcript = input.transcript?.jsonl ?? null
|
|
18
|
+
const kept = new Map<string, string>()
|
|
19
|
+
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))
|
|
20
|
+
const own = recallChunks(input.body.messages, kept)
|
|
21
|
+
const session = own.length > 0 || !transcript ? own : recallChunks([...sessionMessages(transcript), { role: "user", content: "" }], kept)
|
|
22
|
+
const withSubagents = transcript ? [...session, ...subagentChunks(transcript)] : session
|
|
23
|
+
const handoffs = result.threadKey ? activeHandoffs(readState(result.threadKey).handoffs) : {}
|
|
24
|
+
return [...handoffChunks(handoffs), ...withSubagents]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function recallTarget(original: Body, result: Rewrite): { key: string; text: string } | null {
|
|
28
|
+
const live = livePrompt(original.messages)
|
|
29
|
+
return live && result.threadKey ? { key: messageFingerprint(original.messages[live.index]), text: live.text } : null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function applyRecall(input: RewriteInput, result: Rewrite): Rewrite {
|
|
33
|
+
if (!touched(input, result) || !result.threadKey) return result
|
|
34
|
+
const state = readState(result.threadKey)
|
|
35
|
+
state.recalls ??= {}
|
|
36
|
+
const target = recallTarget(input.body, result)
|
|
37
|
+
let note = result.note
|
|
38
|
+
if (target && !(target.key in state.recalls)) {
|
|
39
|
+
state.recalls[target.key] = recallFor(target.text, droppedChunks(input, result), input.config.eval?.recallOptions) ?? ""
|
|
40
|
+
saveState(result.threadKey, state)
|
|
41
|
+
note = { ...note, recalled: state.recalls[target.key].length }
|
|
42
|
+
}
|
|
43
|
+
const messages = withRecall(result.body.messages, messageFingerprint, activeRecalls(state.recalls))
|
|
44
|
+
return messages === result.body.messages ? { ...result, note } : { ...result, body: { ...result.body, messages }, note }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function prefetchFor(input: RewriteInput, result: Rewrite, rerank: Reranker): Promise<number | null> {
|
|
48
|
+
if (!touched(input, result) || !result.threadKey) return null
|
|
49
|
+
const target = recallTarget(input.body, result)
|
|
50
|
+
if (!target) return null
|
|
51
|
+
const state = readState(result.threadKey)
|
|
52
|
+
state.recalls ??= {}
|
|
53
|
+
if (target.key in state.recalls) return null
|
|
54
|
+
const options = { candidates: input.config.eval?.rerank?.candidates, ...input.config.eval?.recallOptions }
|
|
55
|
+
state.recalls[target.key] = (await recallForWith(target.text, droppedChunks(input, result), rerank, options)) ?? ""
|
|
56
|
+
saveState(result.threadKey, state)
|
|
57
|
+
return state.recalls[target.key].length
|
|
58
|
+
}
|
package/src/proxy/rewrite.ts
CHANGED
|
@@ -1,72 +1,168 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
import { searchGuide } from "../
|
|
1
|
+
import type { Config } from "../config"
|
|
2
|
+
import type { Body, Message } from "../context/content"
|
|
3
|
+
import { appendSystem, tailMessages, trimMessages } from "../context/fold/strategies"
|
|
4
|
+
import { splitTurns, turnFingerprint } from "../context/fold/turns"
|
|
5
|
+
import { SYSTEM_PROMPT, searchGuide, searchOnlyInstructions, trimInstructions } from "../context/prompts"
|
|
6
|
+
import { identityReranker, type Reranker, withRecall } from "../context/recall"
|
|
7
|
+
import { jevReranker, rerankKey } from "../context/recall/rerank"
|
|
8
|
+
import type { TranscriptRef } from "../transcript/search"
|
|
6
9
|
import { compact } from "./compaction"
|
|
7
|
-
import { DEFAULT_RATIO } from "./
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import
|
|
10
|
+
import { DEFAULT_RATIO, estimator, sizeOf, targetSize } from "./estimate"
|
|
11
|
+
import { messageFingerprint } from "./fingerprint"
|
|
12
|
+
import { activeHandoffs, attachHandoffs, withHandoffs } from "./handoff"
|
|
13
|
+
import { activeRecalls, applyRecall, prefetchFor, wantsRecall } from "./recall"
|
|
14
|
+
import { readState, saveState } from "./state"
|
|
15
|
+
|
|
16
|
+
export interface RewriteInput {
|
|
17
|
+
body: Body
|
|
18
|
+
sessionId: string | null
|
|
19
|
+
config: Config
|
|
20
|
+
transcript: TranscriptRef | null
|
|
21
|
+
window: number | null
|
|
22
|
+
mayCompact: boolean
|
|
23
|
+
omittedBefore?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Rewrite {
|
|
27
|
+
body: Body
|
|
28
|
+
threadKey: string | null
|
|
29
|
+
note: Record<string, unknown>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Prefetched {
|
|
33
|
+
reranked: boolean
|
|
34
|
+
rerankError: string | null
|
|
35
|
+
recalled: number
|
|
36
|
+
}
|
|
11
37
|
|
|
12
38
|
const pass = (body: Body): Rewrite => ({ body, threadKey: null, note: { action: "pass" } })
|
|
13
39
|
|
|
14
40
|
const isAgentThread = (body: Body) => Array.isArray(body.tools) && body.tools.length > 0
|
|
15
41
|
|
|
16
|
-
|
|
42
|
+
const withSystemPrompt = (system: Body["system"], extra?: string | null) => appendSystem(extra ? appendSystem(system, extra) : system, SYSTEM_PROMPT)
|
|
43
|
+
|
|
44
|
+
const ratioOf = (threadKey: string) => readState(threadKey).ratio ?? DEFAULT_RATIO
|
|
45
|
+
|
|
46
|
+
function rerankModel(config: Config): string | null {
|
|
47
|
+
const model = config.eval?.rerank?.model ?? config.rerank
|
|
48
|
+
return model && model !== "off" ? model : null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function evalStrategy(input: RewriteInput, turns: Message[][], threadKey: string, guide: string | null): Rewrite | null {
|
|
52
|
+
const { body, config } = input
|
|
53
|
+
const strategy = config.eval?.strategy
|
|
54
|
+
if (strategy !== "guide" && strategy !== "trim" && strategy !== "tail") return null
|
|
55
|
+
if (!isAgentThread(body)) return pass(body)
|
|
56
|
+
const counts = { turns: turns.length, messagesIn: body.messages.length }
|
|
57
|
+
if (strategy === "guide") {
|
|
58
|
+
return { body: { ...body, system: withSystemPrompt(body.system, guide ? searchOnlyInstructions(guide) : null) }, threadKey, note: { action: "guide", ...counts } }
|
|
59
|
+
}
|
|
60
|
+
if (strategy === "trim") {
|
|
61
|
+
const messages = trimMessages(body.messages, { evidence: config.eval?.evidence ?? true })
|
|
62
|
+
return { body: { ...body, system: withSystemPrompt(body.system, trimInstructions(guide)), messages }, threadKey, note: { action: "trim", ...counts, messagesOut: messages.length } }
|
|
63
|
+
}
|
|
64
|
+
const based = { ...body, system: withSystemPrompt(body.system) }
|
|
65
|
+
const size = estimator(based, ratioOf(threadKey))
|
|
66
|
+
let from = 1
|
|
67
|
+
if (input.window) {
|
|
68
|
+
const target = targetSize(size, input.window)
|
|
69
|
+
from = turns.length - 1
|
|
70
|
+
while (from > 1 && size.of(tailMessages(body.messages, from - 1, guide)) <= target) from -= 1
|
|
71
|
+
}
|
|
72
|
+
const messages = tailMessages(body.messages, from, guide)
|
|
73
|
+
return { body: { ...based, messages }, threadKey, note: { action: "tail", keptTurns: turns.length - from, ...counts, messagesOut: messages.length, estimate: size.of(messages) } }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function strategize(input: RewriteInput): Rewrite {
|
|
17
77
|
const { body, sessionId, config, transcript } = input
|
|
18
78
|
if (config.mode === "off" || !sessionId || !Array.isArray(body.messages) || body.messages.length === 0) return pass(body)
|
|
19
79
|
const { turns } = splitTurns(body.messages)
|
|
20
80
|
if (turns.length === 0) return pass(body)
|
|
21
81
|
const threadKey = `${sessionId}-${turnFingerprint(turns[0])}`
|
|
22
82
|
const guide = transcript ? searchGuide(transcript) : null
|
|
83
|
+
const state = readState(threadKey)
|
|
84
|
+
if (attachHandoffs(body.messages, config, sessionId, state)) saveState(threadKey, state)
|
|
23
85
|
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
}
|
|
86
|
+
const forced = evalStrategy(input, turns, threadKey, guide)
|
|
87
|
+
if (forced) return forced
|
|
62
88
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const
|
|
89
|
+
const based = isAgentThread(body) ? { ...body, system: withSystemPrompt(body.system) } : body
|
|
90
|
+
if (body.messages.length < 2) return { body: based, threadKey, note: { action: "full", turns: turns.length } }
|
|
91
|
+
const handoffs = activeHandoffs(state.handoffs)
|
|
92
|
+
const recalls = wantsRecall(input) ? activeRecalls(state.recalls) : {}
|
|
93
|
+
const result = compact({
|
|
94
|
+
body: based,
|
|
95
|
+
config,
|
|
96
|
+
state,
|
|
97
|
+
window: input.window,
|
|
98
|
+
guide,
|
|
99
|
+
transcriptDir: transcript && transcript.mode !== "jsonl" ? transcript.dir : null,
|
|
100
|
+
mayCompact: input.mayCompact,
|
|
101
|
+
omittedBefore: input.omittedBefore,
|
|
102
|
+
decorate: (messages) => withRecall(withHandoffs(messages, handoffs), messageFingerprint, recalls),
|
|
103
|
+
})
|
|
66
104
|
if (result.stateChanged) saveState(threadKey, state)
|
|
67
|
-
return {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
105
|
+
return { body: result.messages === body.messages ? based : { ...based, messages: result.messages }, threadKey, note: result.note }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function withAttachedHandoffs(result: Rewrite): Rewrite {
|
|
109
|
+
if (!result.threadKey) return result
|
|
110
|
+
const handoffs = activeHandoffs(readState(result.threadKey).handoffs)
|
|
111
|
+
const messages = withHandoffs(result.body.messages, handoffs)
|
|
112
|
+
if (messages === result.body.messages) return result
|
|
113
|
+
const attached = Object.values(handoffs)
|
|
114
|
+
const note = { ...result.note, handoff: attached.reduce((sum, h) => sum + h.text.length, 0), handoffFrom: attached.map((h) => h.label).join(", ") }
|
|
115
|
+
return { ...result, body: { ...result.body, messages }, note }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function once(input: RewriteInput): Rewrite {
|
|
119
|
+
const result = withAttachedHandoffs(strategize(input))
|
|
120
|
+
return wantsRecall(input) ? applyRecall(input, result) : result
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function sentEstimate(result: Rewrite): number {
|
|
124
|
+
return Math.round(sizeOf(result.body) * (result.threadKey ? ratioOf(result.threadKey) : DEFAULT_RATIO))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function overLimit(input: RewriteInput, result: Rewrite): boolean {
|
|
128
|
+
if (!input.window || !result.threadKey || input.mayCompact === false || result.note.action === "pass") return false
|
|
129
|
+
return sentEstimate(result) > input.config.compactAt * input.window
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function carried(from: Rewrite, to: Rewrite): Rewrite {
|
|
133
|
+
const actionsOf = ({ note }: Rewrite) => (note.actions as string[] | undefined) ?? []
|
|
134
|
+
const actions = [...actionsOf(from), ...actionsOf(to)]
|
|
135
|
+
if (!from.note.compacted && actions.length === 0) return to
|
|
136
|
+
return { ...to, note: { ...to.note, compacted: Boolean(to.note.compacted || from.note.compacted), actions } }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function rewrite(input: RewriteInput, before?: Rewrite): Rewrite {
|
|
140
|
+
const result = before ? carried(before, once(input)) : once(input)
|
|
141
|
+
if (!overLimit(input, result)) return result
|
|
142
|
+
const recut = carried(result, once(input))
|
|
143
|
+
if (!overLimit(input, recut)) return { ...recut, note: { ...recut.note, fit: "recut" } }
|
|
144
|
+
const bare = carried(recut, once({ ...input, config: { ...input.config, eval: { ...input.config.eval, recall: false } } }))
|
|
145
|
+
return { ...bare, note: { ...bare.note, fit: overLimit(input, bare) ? "over" : "bare", fitEstimate: sentEstimate(bare) } }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function prefetchRecall(input: RewriteInput, rerank: Reranker): Promise<Prefetched | null> {
|
|
149
|
+
if (!wantsRecall(input)) return null
|
|
150
|
+
const recalled = await prefetchFor(input, strategize(input), rerank)
|
|
151
|
+
return recalled === null ? null : { reranked: rerank !== identityReranker, rerankError: null, recalled }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function rewriteAsync(input: RewriteInput): Promise<Rewrite> {
|
|
155
|
+
const model = wantsRecall(input) ? rerankModel(input.config) : null
|
|
156
|
+
const key = model ? rerankKey() : null
|
|
157
|
+
if (!model || !key) return rewrite(input)
|
|
158
|
+
const failure = { reason: null as string | null }
|
|
159
|
+
const reranker = jevReranker(model, key, input.config.eval?.rerank?.endpoint, (reason) => {
|
|
160
|
+
failure.reason = reason
|
|
161
|
+
})
|
|
162
|
+
const first = strategize(input)
|
|
163
|
+
const recalled = await prefetchFor(input, first, reranker)
|
|
164
|
+
const final = rewrite(input, first)
|
|
165
|
+
if (recalled === null) return final
|
|
166
|
+
const outcome = failure.reason ? { rerankError: `${model}: ${failure.reason}` } : { reranked: model }
|
|
167
|
+
return { ...final, note: { ...final.note, recalled, ...outcome } }
|
|
72
168
|
}
|
package/src/proxy/server.ts
CHANGED
|
@@ -1,21 +1,47 @@
|
|
|
1
1
|
import path from "node:path"
|
|
2
|
-
import { claudeConfigDirs,
|
|
3
|
-
import type
|
|
4
|
-
import type { Body } from "../context/types"
|
|
2
|
+
import { type Config, claudeConfigDirs, readConfig, settingsFor } from "../config"
|
|
3
|
+
import { type Body, wellFormed } from "../context/content"
|
|
5
4
|
import { findSessionFile } from "../transcript/locate"
|
|
6
5
|
import { prepareTranscript } from "../transcript/search"
|
|
7
6
|
import { VERSION } from "../version"
|
|
8
|
-
import {
|
|
7
|
+
import { applyCanonical } from "./canonical"
|
|
9
8
|
import { sizeOf } from "./estimate"
|
|
9
|
+
import { type ContextFingerprint, contextFingerprint } from "./fingerprint"
|
|
10
10
|
import { logRequest } from "./log"
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import
|
|
11
|
+
import { rewriteAsync } from "./rewrite"
|
|
12
|
+
import { recordSession } from "./sessions"
|
|
13
|
+
import { readState, saveState } from "./state"
|
|
14
14
|
import { inputTokens, watchUsage } from "./usage"
|
|
15
15
|
import { resolveWindow } from "./window"
|
|
16
16
|
|
|
17
|
-
export
|
|
18
|
-
|
|
17
|
+
export interface Health {
|
|
18
|
+
ok: boolean
|
|
19
|
+
version: string
|
|
20
|
+
mode: Config["mode"]
|
|
21
|
+
pid: number
|
|
22
|
+
startedAt?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface Outgoing {
|
|
26
|
+
note: Record<string, unknown>
|
|
27
|
+
threadKey: string | null
|
|
28
|
+
bodyText: string | undefined
|
|
29
|
+
sentSize: number
|
|
30
|
+
givenSize: number
|
|
31
|
+
fixedSize: number
|
|
32
|
+
sent: (ContextFingerprint & { session: string | null }) | null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const HEALTH_PATH = "/_eunoe/health"
|
|
36
|
+
|
|
37
|
+
const startedAt = Date.now()
|
|
38
|
+
|
|
39
|
+
const trustedHeader = (request: Request, config: Config, name: string) => (config.eval?.trustHeaders === true ? request.headers.get(`x-eunoe-${name}`) : null)
|
|
40
|
+
|
|
41
|
+
const clipped = (error: string) => error.slice(0, 500)
|
|
42
|
+
|
|
43
|
+
function sessionIdOf(headers: Headers, body: Body): string | null {
|
|
44
|
+
const header = headers.get("x-claude-code-session-id")
|
|
19
45
|
if (header) return header
|
|
20
46
|
try {
|
|
21
47
|
return JSON.parse(body.metadata?.user_id ?? "{}").session_id ?? null
|
|
@@ -31,67 +57,116 @@ function forwardableHeaders(headers: Headers) {
|
|
|
31
57
|
return out
|
|
32
58
|
}
|
|
33
59
|
|
|
34
|
-
|
|
60
|
+
function overflowTokens(errorText: string): number | null {
|
|
61
|
+
const tokens = Number(errorText.match(/prompt is too long[^0-9]*(\d+)\s*tokens?\s*>\s*(\d+)/i)?.[1])
|
|
62
|
+
return Number.isFinite(tokens) && tokens > 0 ? tokens : null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function prepare(request: Request, body: Body, base: Config, isCountTokens: boolean) {
|
|
35
66
|
const sessionId = sessionIdOf(request.headers, body)
|
|
67
|
+
const config = settingsFor(base, sessionId)
|
|
36
68
|
const trusted = config.eval?.trustHeaders === true
|
|
37
|
-
const transcriptOverride =
|
|
69
|
+
const transcriptOverride = trustedHeader(request, config, "transcript")
|
|
38
70
|
const sessionFile = transcriptOverride ?? (sessionId ? findSessionFile(sessionId, claudeConfigDirs(config)) : null)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
|
|
71
|
+
if (sessionId && !trusted) recordSession(sessionId, config.mode, sessionFile)
|
|
72
|
+
if (config.mode === "off") return rewriteAsync({ body, sessionId, config, transcript: null, window: null, mayCompact: false })
|
|
73
|
+
const transcriptKey = transcriptOverride ? path.basename(transcriptOverride, ".jsonl") : sessionId
|
|
74
|
+
const transcript = sessionFile && sessionId && transcriptKey ? prepareTranscript(config.search, sessionFile, transcriptKey) : null
|
|
75
|
+
const needsWindow = !config.eval?.strategy || config.eval.strategy === "tail"
|
|
76
|
+
const windowOverride = Number(trustedHeader(request, config, "window")) || null
|
|
77
|
+
let resolved: number | null = null
|
|
78
|
+
if (needsWindow) resolved = await resolveWindow(body.model, request.headers, config.upstream)
|
|
79
|
+
return rewriteAsync({
|
|
45
80
|
body,
|
|
46
81
|
sessionId,
|
|
47
82
|
config,
|
|
48
83
|
transcript,
|
|
49
|
-
window,
|
|
84
|
+
window: windowOverride ?? config.window ?? resolved,
|
|
50
85
|
mayCompact: !isCountTokens,
|
|
51
|
-
omittedBefore:
|
|
86
|
+
omittedBefore: Number(trustedHeader(request, config, "omitted-before") ?? 0),
|
|
52
87
|
})
|
|
53
88
|
}
|
|
54
89
|
|
|
90
|
+
function saveRatio(threadKey: string, ratio: (previous: number | undefined) => number) {
|
|
91
|
+
const state = readState(threadKey)
|
|
92
|
+
state.ratio = ratio(state.ratio)
|
|
93
|
+
saveState(threadKey, state)
|
|
94
|
+
}
|
|
95
|
+
|
|
55
96
|
export async function handle(request: Request): Promise<Response> {
|
|
56
|
-
|
|
97
|
+
try {
|
|
98
|
+
return await route(request)
|
|
99
|
+
} catch (error) {
|
|
100
|
+
logRequest({ path: new URL(request.url).pathname, status: 502, action: "upstream-unreachable", error: clipped(String(error)) })
|
|
101
|
+
return Response.json({ type: "error", error: { type: "api_error", message: `eunoe could not reach the API: ${String(error)}` } }, { status: 502 })
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function route(request: Request): Promise<Response> {
|
|
106
|
+
const config = readConfig()
|
|
57
107
|
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)
|
|
108
|
+
if (url.pathname === HEALTH_PATH) return Response.json({ ok: true, version: VERSION, mode: config.mode, pid: process.pid, startedAt } satisfies Health)
|
|
59
109
|
const headers = new Headers(request.headers)
|
|
60
|
-
|
|
61
|
-
headers.delete("content-length")
|
|
62
|
-
headers.delete("accept-encoding")
|
|
110
|
+
for (const name of ["host", "content-length", "accept-encoding"]) headers.delete(name)
|
|
63
111
|
|
|
64
|
-
let
|
|
112
|
+
let givenText: string | undefined
|
|
113
|
+
if (request.method !== "GET" && request.method !== "HEAD") givenText = await request.text()
|
|
65
114
|
const isMessages = request.method === "POST" && url.pathname.startsWith("/v1/messages")
|
|
66
115
|
const isCountTokens = url.pathname.includes("count_tokens")
|
|
67
|
-
let note: Note = { action: "pass" }
|
|
68
|
-
let threadKey: string | null = null
|
|
69
|
-
let sentSize = 0
|
|
70
116
|
|
|
71
|
-
|
|
117
|
+
const build = async (): Promise<Outgoing> => {
|
|
118
|
+
const passthrough: Outgoing = { note: { action: "pass" }, threadKey: null, bodyText: givenText, sentSize: 0, givenSize: 0, fixedSize: 0, sent: null }
|
|
119
|
+
if (!givenText || !isMessages) return passthrough
|
|
72
120
|
try {
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
121
|
+
const given = JSON.parse(givenText) as Body
|
|
122
|
+
const canonicalFile = trustedHeader(request, config, "canonical")
|
|
123
|
+
const canonical = canonicalFile ? applyCanonical(given, canonicalFile) : null
|
|
124
|
+
const result = await prepare(request, canonical?.body ?? given, config, isCountTokens)
|
|
125
|
+
const sentSize = sizeOf(result.body)
|
|
126
|
+
return {
|
|
127
|
+
note: canonical ? { ...result.note, canonical: canonical.status } : result.note,
|
|
128
|
+
threadKey: result.threadKey,
|
|
129
|
+
bodyText: result.body !== given ? JSON.stringify(result.body, wellFormed) : givenText,
|
|
130
|
+
sentSize,
|
|
131
|
+
givenSize: result.body === given ? sentSize : sizeOf(given),
|
|
132
|
+
fixedSize: sizeOf({ ...result.body, messages: [] }),
|
|
133
|
+
sent: config.eval ? { session: sessionIdOf(request.headers, given), ...contextFingerprint(result.body), given: contextFingerprint(given) } : null,
|
|
134
|
+
}
|
|
79
135
|
} catch (error) {
|
|
80
|
-
note
|
|
136
|
+
return { ...passthrough, note: { action: "error-passthrough", error: String(error) } }
|
|
81
137
|
}
|
|
82
138
|
}
|
|
139
|
+
const send = (body: string | undefined) => fetch(config.upstream + url.pathname + url.search, { method: request.method, headers, body })
|
|
83
140
|
|
|
84
|
-
|
|
141
|
+
let out = await build()
|
|
142
|
+
let upstream = await send(out.bodyText)
|
|
143
|
+
const errorResponse = (text: string) => new Response(text, { status: upstream.status, statusText: upstream.statusText, headers: forwardableHeaders(upstream.headers) })
|
|
85
144
|
|
|
86
|
-
if (upstream.status >= 400 && note.action !== "pass") {
|
|
145
|
+
if (upstream.status >= 400 && out.note.action !== "pass") {
|
|
87
146
|
const errorText = await upstream.text()
|
|
88
|
-
|
|
89
|
-
|
|
147
|
+
const overflow = overflowTokens(errorText)
|
|
148
|
+
const logged = { path: url.pathname, status: upstream.status, thread: out.threadKey, ...out.note }
|
|
149
|
+
if (!overflow || !out.threadKey || out.sentSize === 0 || isCountTokens) {
|
|
150
|
+
logRequest({ ...logged, error: clipped(errorText) })
|
|
151
|
+
return errorResponse(errorText)
|
|
152
|
+
}
|
|
153
|
+
const sentSize = out.sentSize
|
|
154
|
+
saveRatio(out.threadKey, (previous) => Math.max(previous ?? 0, overflow / sentSize) * 1.1)
|
|
155
|
+
logRequest({ ...logged, overflow, error: clipped(errorText) })
|
|
156
|
+
out = await build()
|
|
157
|
+
out = { ...out, note: { ...out.note, healed: overflow } }
|
|
158
|
+
upstream = await send(out.bodyText)
|
|
159
|
+
if (upstream.status >= 400) {
|
|
160
|
+
const retryError = await upstream.text()
|
|
161
|
+
logRequest({ path: url.pathname, status: upstream.status, thread: out.threadKey, ...out.note, error: clipped(retryError) })
|
|
162
|
+
return errorResponse(retryError)
|
|
163
|
+
}
|
|
90
164
|
}
|
|
91
165
|
|
|
166
|
+
const { note, threadKey, sentSize, givenSize, fixedSize, sent } = out
|
|
92
167
|
const response = new Response(upstream.body, { status: upstream.status, statusText: upstream.statusText, headers: forwardableHeaders(upstream.headers) })
|
|
93
168
|
if (note.action === "pass" || !isMessages || isCountTokens) {
|
|
94
|
-
if (note.action !== "pass") logRequest({ path: url.pathname, status: upstream.status, ...note })
|
|
169
|
+
if (note.action !== "pass" || sent) logRequest({ path: url.pathname, status: upstream.status, ...note, ...(sent && { context: sent }) })
|
|
95
170
|
return response
|
|
96
171
|
}
|
|
97
172
|
|
|
@@ -106,15 +181,15 @@ export async function handle(request: Request): Promise<Response> {
|
|
|
106
181
|
cacheRead: usage.cache_read_input_tokens ?? 0,
|
|
107
182
|
cacheWrite: usage.cache_creation_input_tokens ?? 0,
|
|
108
183
|
uncached: usage.input_tokens ?? 0,
|
|
184
|
+
charsIn: givenSize,
|
|
185
|
+
charsOut: sentSize,
|
|
186
|
+
charsFixed: fixedSize,
|
|
187
|
+
...(sent && { context: sent }),
|
|
109
188
|
})
|
|
110
|
-
if (threadKey && input > 0 && sentSize > 0)
|
|
111
|
-
const state = loadState(threadKey)
|
|
112
|
-
state.ratio = input / sentSize
|
|
113
|
-
saveState(threadKey, state)
|
|
114
|
-
}
|
|
189
|
+
if (threadKey && input > 0 && sentSize > 0) saveRatio(threadKey, () => input / sentSize)
|
|
115
190
|
})
|
|
116
191
|
}
|
|
117
192
|
|
|
118
193
|
export function serve(port: number) {
|
|
119
|
-
return Bun.serve({ port, hostname: "127.0.0.1", idleTimeout:
|
|
194
|
+
return Bun.serve({ port, hostname: "127.0.0.1", idleTimeout: 255, fetch: handle })
|
|
120
195
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { closeSync, mkdirSync, openSync, readFileSync, readSync, writeFileSync } from "node:fs"
|
|
2
|
+
import type { Mode } from "../config"
|
|
3
|
+
import { EUNOE_DIR, paths } from "../config/paths"
|
|
4
|
+
|
|
5
|
+
interface SeenSession {
|
|
6
|
+
lastSeen: string
|
|
7
|
+
mode: Mode
|
|
8
|
+
cwd?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const HEAD_BYTES = 65_536
|
|
12
|
+
|
|
13
|
+
let seen: Record<string, SeenSession> | null = null
|
|
14
|
+
|
|
15
|
+
function readSeen(): Record<string, SeenSession> {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(readFileSync(paths.sessions, "utf8"))
|
|
18
|
+
} catch {
|
|
19
|
+
return {}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const newestFirst = (entries: Array<[string, SeenSession]>) => entries.sort(([, a], [, b]) => b.lastSeen.localeCompare(a.lastSeen))
|
|
24
|
+
|
|
25
|
+
export function cwdOf(sessionFile: string): string | undefined {
|
|
26
|
+
try {
|
|
27
|
+
const fd = openSync(sessionFile, "r")
|
|
28
|
+
const buffer = Buffer.alloc(HEAD_BYTES)
|
|
29
|
+
const read = readSync(fd, buffer, 0, HEAD_BYTES, 0)
|
|
30
|
+
closeSync(fd)
|
|
31
|
+
return buffer
|
|
32
|
+
.subarray(0, read)
|
|
33
|
+
.toString("utf8")
|
|
34
|
+
.match(/"cwd":"((?:[^"\\]|\\.)*)"/)?.[1]
|
|
35
|
+
} catch {
|
|
36
|
+
return undefined
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function titleOf(sessionFile: string): string | undefined {
|
|
41
|
+
try {
|
|
42
|
+
return readFileSync(sessionFile, "utf8").match(/"aiTitle":"((?:[^"\\]|\\.)*)"/)?.[1]
|
|
43
|
+
} catch {
|
|
44
|
+
return undefined
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function recordSession(sessionId: string, mode: Mode, sessionFile: string | null) {
|
|
49
|
+
seen ??= readSeen()
|
|
50
|
+
const previous = seen[sessionId]
|
|
51
|
+
const now = Date.now()
|
|
52
|
+
const needsCwd = !previous?.cwd && sessionFile !== null
|
|
53
|
+
if (previous && previous.mode === mode && !needsCwd && now - Date.parse(previous.lastSeen) < 30_000) return
|
|
54
|
+
seen[sessionId] = { lastSeen: new Date(now).toISOString(), mode, cwd: previous?.cwd ?? (sessionFile ? cwdOf(sessionFile) : undefined) }
|
|
55
|
+
seen = Object.fromEntries(newestFirst(Object.entries(seen)).slice(0, 200))
|
|
56
|
+
try {
|
|
57
|
+
mkdirSync(EUNOE_DIR, { recursive: true })
|
|
58
|
+
writeFileSync(paths.sessions, JSON.stringify(seen, null, 2))
|
|
59
|
+
} catch {}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function seenSessions(): Array<[string, SeenSession]> {
|
|
63
|
+
return newestFirst(Object.entries(readSeen()))
|
|
64
|
+
}
|
package/src/proxy/state.ts
CHANGED
|
@@ -1,13 +1,27 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
2
|
import path from "node:path"
|
|
3
|
-
import { paths } from "../config/
|
|
4
|
-
|
|
3
|
+
import { paths } from "../config/paths"
|
|
4
|
+
|
|
5
|
+
export interface Handoff {
|
|
6
|
+
label: string
|
|
7
|
+
source: string
|
|
8
|
+
text: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ThreadState {
|
|
12
|
+
handoffs?: Record<string, Handoff | null>
|
|
13
|
+
cut?: string
|
|
14
|
+
keep?: number
|
|
15
|
+
cleared?: number
|
|
16
|
+
ratio?: number
|
|
17
|
+
recalls?: Record<string, string>
|
|
18
|
+
}
|
|
5
19
|
|
|
6
20
|
function stateFile(key: string) {
|
|
7
21
|
return path.join(paths.threads, `${key.replace(/[^a-zA-Z0-9_-]/g, "_")}.json`)
|
|
8
22
|
}
|
|
9
23
|
|
|
10
|
-
export function
|
|
24
|
+
export function readState(key: string): ThreadState {
|
|
11
25
|
try {
|
|
12
26
|
return JSON.parse(readFileSync(stateFile(key), "utf8"))
|
|
13
27
|
} catch {
|