@chroxify/eunoe 1.0.0 → 1.2.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 +68 -9
- package/package.json +18 -2
- package/src/cli/{types.ts → command.ts} +0 -4
- package/src/cli/commands/index.ts +13 -1
- package/src/cli/commands/load.ts +44 -0
- package/src/cli/commands/mode.ts +41 -33
- package/src/cli/commands/peek.ts +22 -0
- package/src/cli/commands/replay.ts +245 -0
- package/src/cli/commands/serve.ts +22 -7
- package/src/cli/commands/sessions.ts +70 -0
- package/src/cli/commands/settings.ts +79 -60
- package/src/cli/commands/start.ts +56 -21
- package/src/cli/commands/stats.ts +233 -0
- package/src/cli/commands/status.ts +98 -55
- package/src/cli/commands/stop.ts +8 -6
- package/src/cli/commands/uninstall.ts +10 -12
- 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 +69 -0
- package/src/cli/proxy/inspect.ts +67 -0
- package/src/cli/proxy/launchd.ts +53 -0
- package/src/cli/proxy/live.ts +57 -0
- package/src/cli/proxy/service.ts +53 -0
- package/src/cli/{stats.ts → proxy/stats.ts} +29 -16
- package/src/cli/proxy/watch.ts +60 -0
- package/src/cli/replay/index.ts +146 -0
- package/src/cli/replay/worker.ts +20 -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/{help.ts → terminal/help.ts} +24 -8
- package/src/cli/terminal/prompt.ts +114 -0
- package/src/cli/{ui.ts → terminal/ui.ts} +55 -24
- package/src/cli/usage/index.ts +151 -0
- package/src/config/index.ts +99 -34
- package/src/config/paths.ts +17 -0
- package/src/context/content.ts +40 -14
- 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 +17 -9
- package/src/proxy/compaction.ts +106 -53
- package/src/proxy/estimate.ts +14 -9
- package/src/proxy/fingerprint.ts +23 -9
- 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 +141 -119
- package/src/proxy/server.ts +108 -51
- package/src/proxy/sessions.ts +32 -14
- 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 +3 -14
- 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/constants.ts +0 -52
- package/src/cli/inspect.ts +0 -41
- package/src/cli/launchd.ts +0 -39
- package/src/cli/logo/pixels.ts +0 -34
- package/src/cli/prompt.ts +0 -91
- package/src/cli/service.ts +0 -33
- package/src/cli/sessions.ts +0 -25
- package/src/config/constants.ts +0 -39
- package/src/config/types.ts +0 -50
- package/src/context/constants.ts +0 -42
- package/src/context/evidence.ts +0 -92
- package/src/context/recall.ts +0 -155
- package/src/context/rerank.ts +0 -44
- package/src/context/strategies.ts +0 -89
- package/src/context/turns.ts +0 -87
- package/src/context/types.ts +0 -66
- package/src/proxy/constants.ts +0 -29
- package/src/proxy/types.ts +0 -82
- package/src/transcript/constants.ts +0 -15
- package/src/transcript/types.ts +0 -43
- /package/src/cli/{args.ts → terminal/args.ts} +0 -0
package/src/proxy/rewrite.ts
CHANGED
|
@@ -1,146 +1,168 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
9
|
-
import { searchGuide } from "../transcript/search"
|
|
10
|
-
import { RERANK_OFF } from "../config/constants"
|
|
11
|
-
import type { Config } from "../config/types"
|
|
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"
|
|
12
9
|
import { compact } from "./compaction"
|
|
13
|
-
import { DEFAULT_RATIO } from "./
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
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
|
+
}
|
|
17
37
|
|
|
18
38
|
const pass = (body: Body): Rewrite => ({ body, threadKey: null, note: { action: "pass" } })
|
|
19
39
|
|
|
20
40
|
const isAgentThread = (body: Body) => Array.isArray(body.tools) && body.tools.length > 0
|
|
21
41
|
|
|
22
|
-
|
|
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
|
-
}
|
|
42
|
+
const withSystemPrompt = (system: Body["system"], extra?: string | null) => appendSystem(extra ? appendSystem(system, extra) : system, SYSTEM_PROMPT)
|
|
27
43
|
|
|
28
|
-
const
|
|
44
|
+
const ratioOf = (threadKey: string) => readState(threadKey).ratio ?? DEFAULT_RATIO
|
|
29
45
|
|
|
30
46
|
function rerankModel(config: Config): string | null {
|
|
31
47
|
const model = config.eval?.rerank?.model ?? config.rerank
|
|
32
|
-
return model && model !==
|
|
48
|
+
return model && model !== "off" ? model : null
|
|
33
49
|
}
|
|
34
50
|
|
|
35
|
-
function
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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) } }
|
|
40
74
|
}
|
|
41
75
|
|
|
42
|
-
function
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
76
|
+
function strategize(input: RewriteInput): Rewrite {
|
|
77
|
+
const { body, sessionId, config, transcript } = input
|
|
78
|
+
if (config.mode === "off" || !sessionId || !Array.isArray(body.messages) || body.messages.length === 0) return pass(body)
|
|
79
|
+
const { turns } = splitTurns(body.messages)
|
|
80
|
+
if (turns.length === 0) return pass(body)
|
|
81
|
+
const threadKey = `${sessionId}-${turnFingerprint(turns[0])}`
|
|
82
|
+
const guide = transcript ? searchGuide(transcript) : null
|
|
83
|
+
const state = readState(threadKey)
|
|
84
|
+
if (attachHandoffs(body.messages, config, sessionId, state)) saveState(threadKey, state)
|
|
85
|
+
|
|
86
|
+
const forced = evalStrategy(input, turns, threadKey, guide)
|
|
87
|
+
if (forced) return forced
|
|
88
|
+
|
|
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
|
+
})
|
|
104
|
+
if (result.stateChanged) saveState(threadKey, state)
|
|
105
|
+
return { body: result.messages === body.messages ? based : { ...based, messages: result.messages }, threadKey, note: result.note }
|
|
46
106
|
}
|
|
47
107
|
|
|
48
|
-
function
|
|
49
|
-
if (!result.threadKey
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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 }
|
|
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 }
|
|
62
116
|
}
|
|
63
117
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
118
|
+
function once(input: RewriteInput): Rewrite {
|
|
119
|
+
const result = withAttachedHandoffs(strategize(input))
|
|
120
|
+
return wantsRecall(input) ? applyRecall(input, result) : result
|
|
76
121
|
}
|
|
77
122
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return wantsRecall(input) ? recalled(input.body, result, input.transcript?.jsonl ?? null) : result
|
|
123
|
+
function sentEstimate(result: Rewrite): number {
|
|
124
|
+
return Math.round(sizeOf(result.body) * (result.threadKey ? ratioOf(result.threadKey) : DEFAULT_RATIO))
|
|
81
125
|
}
|
|
82
126
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
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
|
|
89
130
|
}
|
|
90
131
|
|
|
91
|
-
function
|
|
92
|
-
const {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const guide = transcript ? searchGuide(transcript) : null
|
|
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
|
+
}
|
|
98
138
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
}
|
|
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
|
+
}
|
|
136
147
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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 } }
|
|
146
168
|
}
|
package/src/proxy/server.ts
CHANGED
|
@@ -1,25 +1,47 @@
|
|
|
1
1
|
import path from "node:path"
|
|
2
|
-
import { claudeConfigDirs,
|
|
3
|
-
import type
|
|
4
|
-
import { wellFormed } from "../context/content"
|
|
5
|
-
import type { Body } from "../context/types"
|
|
2
|
+
import { type Config, claudeConfigDirs, readConfig, settingsFor } from "../config"
|
|
3
|
+
import { type Body, wellFormed } from "../context/content"
|
|
6
4
|
import { findSessionFile } from "../transcript/locate"
|
|
7
5
|
import { prepareTranscript } from "../transcript/search"
|
|
8
6
|
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
7
|
import { applyCanonical } from "./canonical"
|
|
11
8
|
import { sizeOf } from "./estimate"
|
|
12
|
-
import { contextFingerprint } from "./fingerprint"
|
|
9
|
+
import { type ContextFingerprint, contextFingerprint } from "./fingerprint"
|
|
13
10
|
import { logRequest } from "./log"
|
|
14
11
|
import { rewriteAsync } from "./rewrite"
|
|
15
12
|
import { recordSession } from "./sessions"
|
|
16
|
-
import {
|
|
17
|
-
import type { ContextFingerprint, Health, Note } from "./types"
|
|
13
|
+
import { readState, saveState } from "./state"
|
|
18
14
|
import { inputTokens, watchUsage } from "./usage"
|
|
19
15
|
import { resolveWindow } from "./window"
|
|
20
16
|
|
|
21
|
-
export
|
|
22
|
-
|
|
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")
|
|
23
45
|
if (header) return header
|
|
24
46
|
try {
|
|
25
47
|
return JSON.parse(body.metadata?.user_id ?? "{}").session_id ?? null
|
|
@@ -35,75 +57,113 @@ function forwardableHeaders(headers: Headers) {
|
|
|
35
57
|
return out
|
|
36
58
|
}
|
|
37
59
|
|
|
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
|
+
|
|
38
65
|
async function prepare(request: Request, body: Body, base: Config, isCountTokens: boolean) {
|
|
39
66
|
const sessionId = sessionIdOf(request.headers, body)
|
|
40
67
|
const config = settingsFor(base, sessionId)
|
|
41
68
|
const trusted = config.eval?.trustHeaders === true
|
|
42
|
-
const transcriptOverride =
|
|
69
|
+
const transcriptOverride = trustedHeader(request, config, "transcript")
|
|
43
70
|
const sessionFile = transcriptOverride ?? (sessionId ? findSessionFile(sessionId, claudeConfigDirs(config)) : null)
|
|
44
71
|
if (sessionId && !trusted) recordSession(sessionId, config.mode, sessionFile)
|
|
45
72
|
if (config.mode === "off") return rewriteAsync({ body, sessionId, config, transcript: null, window: null, mayCompact: false })
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
: null
|
|
73
|
+
const transcriptKey = transcriptOverride ? path.basename(transcriptOverride, ".jsonl") : sessionId
|
|
74
|
+
const transcript = sessionFile && sessionId && transcriptKey ? prepareTranscript(config.search, sessionFile, transcriptKey) : null
|
|
49
75
|
const needsWindow = !config.eval?.strategy || config.eval.strategy === "tail"
|
|
50
|
-
const windowOverride =
|
|
51
|
-
|
|
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)
|
|
52
79
|
return rewriteAsync({
|
|
53
80
|
body,
|
|
54
81
|
sessionId,
|
|
55
82
|
config,
|
|
56
83
|
transcript,
|
|
57
|
-
window,
|
|
84
|
+
window: windowOverride ?? config.window ?? resolved,
|
|
58
85
|
mayCompact: !isCountTokens,
|
|
59
|
-
omittedBefore:
|
|
86
|
+
omittedBefore: Number(trustedHeader(request, config, "omitted-before") ?? 0),
|
|
60
87
|
})
|
|
61
88
|
}
|
|
62
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
|
+
|
|
63
96
|
export async function handle(request: Request): Promise<Response> {
|
|
64
|
-
|
|
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()
|
|
65
107
|
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)
|
|
108
|
+
if (url.pathname === HEALTH_PATH) return Response.json({ ok: true, version: VERSION, mode: config.mode, pid: process.pid, startedAt } satisfies Health)
|
|
67
109
|
const headers = new Headers(request.headers)
|
|
68
|
-
|
|
69
|
-
headers.delete("content-length")
|
|
70
|
-
headers.delete("accept-encoding")
|
|
110
|
+
for (const name of ["host", "content-length", "accept-encoding"]) headers.delete(name)
|
|
71
111
|
|
|
72
|
-
let
|
|
112
|
+
let givenText: string | undefined
|
|
113
|
+
if (request.method !== "GET" && request.method !== "HEAD") givenText = await request.text()
|
|
73
114
|
const isMessages = request.method === "POST" && url.pathname.startsWith("/v1/messages")
|
|
74
115
|
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
116
|
|
|
81
|
-
|
|
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
|
|
82
120
|
try {
|
|
83
|
-
const given = JSON.parse(
|
|
84
|
-
const canonicalFile = config
|
|
121
|
+
const given = JSON.parse(givenText) as Body
|
|
122
|
+
const canonicalFile = trustedHeader(request, config, "canonical")
|
|
85
123
|
const canonical = canonicalFile ? applyCanonical(given, canonicalFile) : null
|
|
86
|
-
const
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
+
}
|
|
94
135
|
} catch (error) {
|
|
95
|
-
note
|
|
136
|
+
return { ...passthrough, note: { action: "error-passthrough", error: String(error) } }
|
|
96
137
|
}
|
|
97
138
|
}
|
|
139
|
+
const send = (body: string | undefined) => fetch(config.upstream + url.pathname + url.search, { method: request.method, headers, body })
|
|
98
140
|
|
|
99
|
-
|
|
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) })
|
|
100
144
|
|
|
101
|
-
if (upstream.status >= 400 && note.action !== "pass") {
|
|
145
|
+
if (upstream.status >= 400 && out.note.action !== "pass") {
|
|
102
146
|
const errorText = await upstream.text()
|
|
103
|
-
|
|
104
|
-
|
|
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
|
+
}
|
|
105
164
|
}
|
|
106
165
|
|
|
166
|
+
const { note, threadKey, sentSize, givenSize, fixedSize, sent } = out
|
|
107
167
|
const response = new Response(upstream.body, { status: upstream.status, statusText: upstream.statusText, headers: forwardableHeaders(upstream.headers) })
|
|
108
168
|
if (note.action === "pass" || !isMessages || isCountTokens) {
|
|
109
169
|
if (note.action !== "pass" || sent) logRequest({ path: url.pathname, status: upstream.status, ...note, ...(sent && { context: sent }) })
|
|
@@ -123,16 +183,13 @@ export async function handle(request: Request): Promise<Response> {
|
|
|
123
183
|
uncached: usage.input_tokens ?? 0,
|
|
124
184
|
charsIn: givenSize,
|
|
125
185
|
charsOut: sentSize,
|
|
186
|
+
charsFixed: fixedSize,
|
|
126
187
|
...(sent && { context: sent }),
|
|
127
188
|
})
|
|
128
|
-
if (threadKey && input > 0 && sentSize > 0)
|
|
129
|
-
const state = loadState(threadKey)
|
|
130
|
-
state.ratio = input / sentSize
|
|
131
|
-
saveState(threadKey, state)
|
|
132
|
-
}
|
|
189
|
+
if (threadKey && input > 0 && sentSize > 0) saveRatio(threadKey, () => input / sentSize)
|
|
133
190
|
})
|
|
134
191
|
}
|
|
135
192
|
|
|
136
193
|
export function serve(port: number) {
|
|
137
|
-
return Bun.serve({ port, hostname: "127.0.0.1", idleTimeout:
|
|
194
|
+
return Bun.serve({ port, hostname: "127.0.0.1", idleTimeout: 255, fetch: handle })
|
|
138
195
|
}
|
package/src/proxy/sessions.ts
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import { closeSync, mkdirSync, openSync, readFileSync, readSync, writeFileSync } from "node:fs"
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
6
12
|
|
|
7
13
|
let seen: Record<string, SeenSession> | null = null
|
|
8
14
|
|
|
9
|
-
function
|
|
15
|
+
function readSeen(): Record<string, SeenSession> {
|
|
10
16
|
try {
|
|
11
17
|
return JSON.parse(readFileSync(paths.sessions, "utf8"))
|
|
12
18
|
} catch {
|
|
@@ -14,27 +20,39 @@ function load(): Record<string, SeenSession> {
|
|
|
14
20
|
}
|
|
15
21
|
}
|
|
16
22
|
|
|
17
|
-
|
|
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 {
|
|
18
26
|
try {
|
|
19
27
|
const fd = openSync(sessionFile, "r")
|
|
20
|
-
const buffer = Buffer.alloc(
|
|
21
|
-
const read = readSync(fd, buffer, 0,
|
|
28
|
+
const buffer = Buffer.alloc(HEAD_BYTES)
|
|
29
|
+
const read = readSync(fd, buffer, 0, HEAD_BYTES, 0)
|
|
22
30
|
closeSync(fd)
|
|
23
|
-
return buffer
|
|
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]
|
|
24
43
|
} catch {
|
|
25
44
|
return undefined
|
|
26
45
|
}
|
|
27
46
|
}
|
|
28
47
|
|
|
29
48
|
export function recordSession(sessionId: string, mode: Mode, sessionFile: string | null) {
|
|
30
|
-
seen ??=
|
|
49
|
+
seen ??= readSeen()
|
|
31
50
|
const previous = seen[sessionId]
|
|
32
51
|
const now = Date.now()
|
|
33
52
|
const needsCwd = !previous?.cwd && sessionFile !== null
|
|
34
|
-
if (previous && previous.mode === mode && !needsCwd && now - Date.parse(previous.lastSeen) <
|
|
53
|
+
if (previous && previous.mode === mode && !needsCwd && now - Date.parse(previous.lastSeen) < 30_000) return
|
|
35
54
|
seen[sessionId] = { lastSeen: new Date(now).toISOString(), mode, cwd: previous?.cwd ?? (sessionFile ? cwdOf(sessionFile) : undefined) }
|
|
36
|
-
|
|
37
|
-
seen = Object.fromEntries(kept)
|
|
55
|
+
seen = Object.fromEntries(newestFirst(Object.entries(seen)).slice(0, 200))
|
|
38
56
|
try {
|
|
39
57
|
mkdirSync(EUNOE_DIR, { recursive: true })
|
|
40
58
|
writeFileSync(paths.sessions, JSON.stringify(seen, null, 2))
|
|
@@ -42,5 +60,5 @@ export function recordSession(sessionId: string, mode: Mode, sessionFile: string
|
|
|
42
60
|
}
|
|
43
61
|
|
|
44
62
|
export function seenSessions(): Array<[string, SeenSession]> {
|
|
45
|
-
return Object.entries(
|
|
63
|
+
return newestFirst(Object.entries(readSeen()))
|
|
46
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 {
|