@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.
Files changed (80) hide show
  1. package/README.md +364 -239
  2. package/package.json +20 -3
  3. package/src/cli/{types.ts → command.ts} +1 -4
  4. package/src/cli/commands/index.ts +27 -3
  5. package/src/cli/commands/load.ts +44 -0
  6. package/src/cli/commands/mode.ts +68 -24
  7. package/src/cli/commands/peek.ts +22 -0
  8. package/src/cli/commands/replay.ts +194 -0
  9. package/src/cli/commands/serve.ts +24 -8
  10. package/src/cli/commands/sessions.ts +70 -0
  11. package/src/cli/commands/settings.ts +119 -0
  12. package/src/cli/commands/start.ts +73 -0
  13. package/src/cli/commands/status.ts +107 -30
  14. package/src/cli/commands/stop.ts +36 -12
  15. package/src/cli/commands/uninstall.ts +14 -14
  16. package/src/cli/commands/watch.ts +16 -0
  17. package/src/cli/logo/art.ts +99 -0
  18. package/src/cli/logo/index.ts +12 -18
  19. package/src/cli/main.ts +3 -3
  20. package/src/cli/proxy/claude-settings.ts +59 -0
  21. package/src/cli/proxy/inspect.ts +67 -0
  22. package/src/cli/proxy/launchd.ts +53 -0
  23. package/src/cli/proxy/service.ts +53 -0
  24. package/src/cli/proxy/stats.ts +81 -0
  25. package/src/cli/proxy/watch.ts +60 -0
  26. package/src/cli/replay/index.ts +141 -0
  27. package/src/cli/replay/worker.ts +15 -0
  28. package/src/cli/sessions/browse.ts +88 -0
  29. package/src/cli/sessions/index.ts +76 -0
  30. package/src/cli/sessions/source.ts +74 -0
  31. package/src/cli/{args.ts → terminal/args.ts} +1 -1
  32. package/src/cli/{help.ts → terminal/help.ts} +25 -9
  33. package/src/cli/terminal/prompt.ts +114 -0
  34. package/src/cli/terminal/ui.ts +137 -0
  35. package/src/config/index.ts +128 -14
  36. package/src/config/{constants.ts → paths.ts} +4 -14
  37. package/src/context/content.ts +51 -3
  38. package/src/context/fold/evidence.ts +145 -0
  39. package/src/context/fold/index.ts +65 -0
  40. package/src/context/fold/strategies.ts +94 -0
  41. package/src/context/fold/turns.ts +76 -0
  42. package/src/context/prompts.ts +141 -22
  43. package/src/context/recall/index.ts +214 -0
  44. package/src/context/recall/rerank.ts +71 -0
  45. package/src/proxy/canonical.ts +40 -0
  46. package/src/proxy/compaction.ts +122 -52
  47. package/src/proxy/estimate.ts +14 -9
  48. package/src/proxy/fingerprint.ts +29 -0
  49. package/src/proxy/handoff.ts +71 -0
  50. package/src/proxy/log.ts +2 -2
  51. package/src/proxy/recall.ts +58 -0
  52. package/src/proxy/rewrite.ts +152 -56
  53. package/src/proxy/server.ts +122 -47
  54. package/src/proxy/sessions.ts +64 -0
  55. package/src/proxy/state.ts +17 -3
  56. package/src/proxy/usage.ts +50 -34
  57. package/src/proxy/window.ts +32 -34
  58. package/src/transcript/format.ts +88 -25
  59. package/src/transcript/locate.ts +38 -17
  60. package/src/transcript/messages.ts +18 -0
  61. package/src/transcript/parse.ts +137 -56
  62. package/src/transcript/render.ts +44 -17
  63. package/src/transcript/search.ts +20 -56
  64. package/src/transcript/subagents.ts +150 -0
  65. package/src/cli/claude-settings.ts +0 -42
  66. package/src/cli/commands/install.ts +0 -44
  67. package/src/cli/constants.ts +0 -29
  68. package/src/cli/inspect.ts +0 -41
  69. package/src/cli/launchd.ts +0 -34
  70. package/src/cli/logo/pixels.ts +0 -34
  71. package/src/cli/ui.ts +0 -91
  72. package/src/config/types.ts +0 -21
  73. package/src/context/constants.ts +0 -7
  74. package/src/context/strategies.ts +0 -89
  75. package/src/context/turns.ts +0 -82
  76. package/src/context/types.ts +0 -24
  77. package/src/proxy/constants.ts +0 -19
  78. package/src/proxy/types.ts +0 -58
  79. package/src/transcript/constants.ts +0 -16
  80. 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/constants"
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 }) + "\n")
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
+ }
@@ -1,72 +1,168 @@
1
- import { searchOnlyInstructions, trimInstructions, TRANSCRIPT_FALLBACK } from "../context/prompts"
2
- import { appendSystem, tailMessages, trimMessages } from "../context/strategies"
3
- import { splitTurns, turnFingerprint } from "../context/turns"
4
- import type { Body } from "../context/types"
5
- import { searchGuide } from "../transcript/search"
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 "./constants"
8
- import { estimator, targetSize } from "./estimate"
9
- import { loadState, saveState } from "./state"
10
- 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
+ }
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
- export function rewrite(input: RewriteInput): Rewrite {
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
- switch (config.eval?.strategy) {
25
- case "guide": {
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
- if (body.messages.length < 2) return { body, threadKey, note: { action: "full", turns: turns.length } }
64
- const state = loadState(threadKey)
65
- const result = compact({ body, config, state, window: input.window, guide, mayCompact: input.mayCompact, omittedBefore: input.omittedBefore })
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
- body: result.messages === body.messages ? body : { ...body, messages: result.messages },
69
- threadKey,
70
- note: result.note,
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
  }
@@ -1,21 +1,47 @@
1
1
  import path from "node:path"
2
- import { claudeConfigDirs, loadConfig } from "../config"
3
- import type { Config } from "../config/types"
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 { HEALTH_PATH, IDLE_TIMEOUT_SECONDS, LOGGED_ERROR_CHARS, OMITTED_BEFORE_HEADER, SESSION_HEADER, TRANSCRIPT_HEADER } from "./constants"
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 { rewrite } from "./rewrite"
12
- import { loadState, saveState } from "./state"
13
- import type { Health, Note } from "./types"
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 function sessionIdOf(headers: Headers, body: Body): string | null {
18
- 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")
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
- async function prepare(request: Request, body: Body, config: Config, isCountTokens: boolean) {
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 = trusted ? request.headers.get(TRANSCRIPT_HEADER) : null
69
+ const transcriptOverride = trustedHeader(request, config, "transcript")
38
70
  const sessionFile = transcriptOverride ?? (sessionId ? findSessionFile(sessionId, claudeConfigDirs(config)) : null)
39
- const transcript = sessionFile && sessionId
40
- ? prepareTranscript(config.search, sessionFile, transcriptOverride ? path.basename(transcriptOverride, ".jsonl") : sessionId)
41
- : null
42
- const needsWindow = config.mode === "compact" || config.eval?.strategy === "tail"
43
- const window = config.window ?? (needsWindow ? await resolveWindow(body.model, request.headers, config.upstream) : null)
44
- return rewrite({
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: trusted ? Number(request.headers.get(OMITTED_BEFORE_HEADER) ?? 0) : 0,
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
- 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()
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
- headers.delete("host")
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 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()
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
- 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
72
120
  try {
73
- const body = JSON.parse(bodyText) as Body
74
- const result = await prepare(request, body, config, isCountTokens)
75
- note = result.note
76
- threadKey = result.threadKey
77
- if (result.body !== body) bodyText = JSON.stringify(result.body)
78
- sentSize = sizeOf(result.body)
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 = { action: "error-passthrough", error: String(error) }
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
- 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) })
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
- logRequest({ path: url.pathname, status: upstream.status, thread: threadKey, ...note, error: errorText.slice(0, LOGGED_ERROR_CHARS) })
89
- 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
+ }
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: IDLE_TIMEOUT_SECONDS, fetch: handle })
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
+ }
@@ -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 {