@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.
Files changed (88) hide show
  1. package/README.md +68 -9
  2. package/package.json +18 -2
  3. package/src/cli/{types.ts → command.ts} +0 -4
  4. package/src/cli/commands/index.ts +13 -1
  5. package/src/cli/commands/load.ts +44 -0
  6. package/src/cli/commands/mode.ts +41 -33
  7. package/src/cli/commands/peek.ts +22 -0
  8. package/src/cli/commands/replay.ts +245 -0
  9. package/src/cli/commands/serve.ts +22 -7
  10. package/src/cli/commands/sessions.ts +70 -0
  11. package/src/cli/commands/settings.ts +79 -60
  12. package/src/cli/commands/start.ts +56 -21
  13. package/src/cli/commands/stats.ts +233 -0
  14. package/src/cli/commands/status.ts +98 -55
  15. package/src/cli/commands/stop.ts +8 -6
  16. package/src/cli/commands/uninstall.ts +10 -12
  17. package/src/cli/commands/watch.ts +16 -0
  18. package/src/cli/logo/art.ts +99 -0
  19. package/src/cli/logo/index.ts +12 -18
  20. package/src/cli/main.ts +3 -3
  21. package/src/cli/proxy/claude-settings.ts +69 -0
  22. package/src/cli/proxy/inspect.ts +67 -0
  23. package/src/cli/proxy/launchd.ts +53 -0
  24. package/src/cli/proxy/live.ts +57 -0
  25. package/src/cli/proxy/service.ts +53 -0
  26. package/src/cli/{stats.ts → proxy/stats.ts} +29 -16
  27. package/src/cli/proxy/watch.ts +60 -0
  28. package/src/cli/replay/index.ts +146 -0
  29. package/src/cli/replay/worker.ts +20 -0
  30. package/src/cli/sessions/browse.ts +88 -0
  31. package/src/cli/sessions/index.ts +76 -0
  32. package/src/cli/sessions/source.ts +74 -0
  33. package/src/cli/{help.ts → terminal/help.ts} +24 -8
  34. package/src/cli/terminal/prompt.ts +114 -0
  35. package/src/cli/{ui.ts → terminal/ui.ts} +55 -24
  36. package/src/cli/usage/index.ts +151 -0
  37. package/src/config/index.ts +99 -34
  38. package/src/config/paths.ts +17 -0
  39. package/src/context/content.ts +40 -14
  40. package/src/context/fold/evidence.ts +145 -0
  41. package/src/context/fold/index.ts +65 -0
  42. package/src/context/fold/strategies.ts +94 -0
  43. package/src/context/fold/turns.ts +76 -0
  44. package/src/context/prompts.ts +141 -22
  45. package/src/context/recall/index.ts +214 -0
  46. package/src/context/recall/rerank.ts +71 -0
  47. package/src/proxy/canonical.ts +17 -9
  48. package/src/proxy/compaction.ts +106 -53
  49. package/src/proxy/estimate.ts +14 -9
  50. package/src/proxy/fingerprint.ts +23 -9
  51. package/src/proxy/handoff.ts +71 -0
  52. package/src/proxy/log.ts +2 -2
  53. package/src/proxy/recall.ts +58 -0
  54. package/src/proxy/rewrite.ts +141 -119
  55. package/src/proxy/server.ts +108 -51
  56. package/src/proxy/sessions.ts +32 -14
  57. package/src/proxy/state.ts +17 -3
  58. package/src/proxy/usage.ts +50 -34
  59. package/src/proxy/window.ts +32 -34
  60. package/src/transcript/format.ts +88 -25
  61. package/src/transcript/locate.ts +38 -17
  62. package/src/transcript/messages.ts +3 -14
  63. package/src/transcript/parse.ts +137 -56
  64. package/src/transcript/render.ts +44 -17
  65. package/src/transcript/search.ts +20 -56
  66. package/src/transcript/subagents.ts +150 -0
  67. package/src/cli/claude-settings.ts +0 -42
  68. package/src/cli/constants.ts +0 -52
  69. package/src/cli/inspect.ts +0 -41
  70. package/src/cli/launchd.ts +0 -39
  71. package/src/cli/logo/pixels.ts +0 -34
  72. package/src/cli/prompt.ts +0 -91
  73. package/src/cli/service.ts +0 -33
  74. package/src/cli/sessions.ts +0 -25
  75. package/src/config/constants.ts +0 -39
  76. package/src/config/types.ts +0 -50
  77. package/src/context/constants.ts +0 -42
  78. package/src/context/evidence.ts +0 -92
  79. package/src/context/recall.ts +0 -155
  80. package/src/context/rerank.ts +0 -44
  81. package/src/context/strategies.ts +0 -89
  82. package/src/context/turns.ts +0 -87
  83. package/src/context/types.ts +0 -66
  84. package/src/proxy/constants.ts +0 -29
  85. package/src/proxy/types.ts +0 -82
  86. package/src/transcript/constants.ts +0 -15
  87. package/src/transcript/types.ts +0 -43
  88. /package/src/cli/{args.ts → terminal/args.ts} +0 -0
@@ -1,146 +1,168 @@
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"
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 "./constants"
14
- import { estimator, targetSize } from "./estimate"
15
- import { loadState, saveState } from "./state"
16
- import type { Rewrite, RewriteInput } from "./types"
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
- 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
- }
42
+ const withSystemPrompt = (system: Body["system"], extra?: string | null) => appendSystem(extra ? appendSystem(system, extra) : system, SYSTEM_PROMPT)
27
43
 
28
- const wantsRecall = (input: RewriteInput) => (input.config.eval?.recall ?? true) && input.config.mode !== "off"
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 !== RERANK_OFF ? model : null
48
+ return model && model !== "off" ? model : null
33
49
  }
34
50
 
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)
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 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
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 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 }
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
- 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
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
- export function rewrite(input: RewriteInput): Rewrite {
79
- const result = strategize(input)
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
- 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
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 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
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
- 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
- }
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
- 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
- }
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
  }
@@ -1,25 +1,47 @@
1
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"
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 { loadState, saveState } from "./state"
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 function sessionIdOf(headers: Headers, body: Body): string | null {
22
- const header = headers.get(SESSION_HEADER)
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 = trusted ? request.headers.get(TRANSCRIPT_HEADER) : null
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 transcript = sessionFile && sessionId
47
- ? prepareTranscript(config.search, sessionFile, transcriptOverride ? path.basename(transcriptOverride, ".jsonl") : sessionId)
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 = 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)
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: trusted ? Number(request.headers.get(OMITTED_BEFORE_HEADER) ?? 0) : 0,
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
- const config = loadConfig()
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
- headers.delete("host")
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 bodyText = request.method === "GET" || request.method === "HEAD" ? undefined : await request.text()
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
- if (bodyText && isMessages) {
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(bodyText) as Body
84
- const canonicalFile = config.eval?.trustHeaders ? request.headers.get(CANONICAL_HEADER) : null
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 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) }
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 = { action: "error-passthrough", error: String(error) }
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
- const upstream = await fetch(config.upstream + url.pathname + url.search, { method: request.method, headers, body: bodyText })
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
- 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) })
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: IDLE_TIMEOUT_SECONDS, fetch: handle })
194
+ return Bun.serve({ port, hostname: "127.0.0.1", idleTimeout: 255, fetch: handle })
138
195
  }
@@ -1,12 +1,18 @@
1
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"
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 load(): Record<string, SeenSession> {
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
- function cwdOf(sessionFile: string): string | undefined {
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(SESSION_HEAD_BYTES)
21
- const read = readSync(fd, buffer, 0, SESSION_HEAD_BYTES, 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.subarray(0, read).toString("utf8").match(/"cwd":"((?:[^"\\]|\\.)*)"/)?.[1]
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 ??= load()
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) < SESSION_SEEN_INTERVAL_MS) return
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
- const kept = Object.entries(seen).sort(([, a], [, b]) => b.lastSeen.localeCompare(a.lastSeen)).slice(0, SESSIONS_KEPT)
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(load()).sort(([, a], [, b]) => b.lastSeen.localeCompare(a.lastSeen))
63
+ return newestFirst(Object.entries(readSeen()))
46
64
  }
@@ -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/constants"
4
- import type { ThreadState } from "./types"
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 loadState(key: string): ThreadState {
24
+ export function readState(key: string): ThreadState {
11
25
  try {
12
26
  return JSON.parse(readFileSync(stateFile(key), "utf8"))
13
27
  } catch {