@chroxify/eunoe 1.2.1 → 1.3.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 (70) hide show
  1. package/README.md +143 -5
  2. package/dist/core/index.js +2235 -0
  3. package/dist/types/context/content.d.ts +31 -0
  4. package/dist/types/context/fold/evidence.d.ts +20 -0
  5. package/dist/types/context/fold/index.d.ts +11 -0
  6. package/dist/types/context/fold/strategies.d.ts +20 -0
  7. package/dist/types/context/fold/turns.d.ts +14 -0
  8. package/dist/types/context/prompts.d.ts +24 -0
  9. package/dist/types/context/recall/index.d.ts +44 -0
  10. package/dist/types/context/recall/rerank.d.ts +3 -0
  11. package/dist/types/core/compaction.d.ts +32 -0
  12. package/dist/types/core/config.d.ts +33 -0
  13. package/dist/types/core/estimate.d.ts +9 -0
  14. package/dist/types/core/fingerprint.d.ts +10 -0
  15. package/dist/types/core/fold.d.ts +18 -0
  16. package/dist/types/core/forward.d.ts +24 -0
  17. package/dist/types/core/handoff.d.ts +10 -0
  18. package/dist/types/core/index.d.ts +14 -0
  19. package/dist/types/core/recall.d.ts +8 -0
  20. package/dist/types/core/rewrite.d.ts +63 -0
  21. package/dist/types/core/state.d.ts +22 -0
  22. package/dist/types/core/subagents.d.ts +10 -0
  23. package/dist/types/core/transcript.d.ts +9 -0
  24. package/dist/types/core/usage.d.ts +7 -0
  25. package/dist/types/core/window.d.ts +5 -0
  26. package/dist/types/transcript/format.d.ts +16 -0
  27. package/dist/types/transcript/parse.d.ts +45 -0
  28. package/package.json +15 -2
  29. package/src/cli/commands/mode.ts +2 -1
  30. package/src/cli/commands/serve.ts +2 -0
  31. package/src/cli/commands/status.ts +1 -1
  32. package/src/cli/proxy/stats.ts +1 -1
  33. package/src/cli/replay/index.ts +3 -3
  34. package/src/cli/sessions/index.ts +2 -2
  35. package/src/config/index.ts +4 -27
  36. package/src/context/content.ts +64 -1
  37. package/src/context/fold/strategies.ts +55 -10
  38. package/src/context/fold/turns.ts +22 -2
  39. package/src/context/prompts.ts +4 -3
  40. package/src/context/recall/index.ts +259 -35
  41. package/src/context/recall/rerank.ts +0 -8
  42. package/src/{proxy → core}/compaction.ts +74 -24
  43. package/src/core/config.ts +31 -0
  44. package/src/core/fold.ts +69 -0
  45. package/src/core/forward.ts +192 -0
  46. package/src/{proxy → core}/handoff.ts +23 -23
  47. package/src/core/index.ts +14 -0
  48. package/src/{proxy → core}/recall.ts +18 -20
  49. package/src/core/rewrite.ts +189 -0
  50. package/src/core/state.ts +40 -0
  51. package/src/core/subagents.ts +28 -0
  52. package/src/core/transcript.ts +44 -0
  53. package/src/{proxy → core}/usage.ts +34 -1
  54. package/src/core/window.ts +39 -0
  55. package/src/proxy/rewrite.ts +22 -139
  56. package/src/proxy/server.ts +24 -137
  57. package/src/proxy/sessions.ts +1 -1
  58. package/src/proxy/sources.ts +35 -0
  59. package/src/proxy/state.ts +32 -21
  60. package/src/proxy/warm.ts +68 -0
  61. package/src/proxy/window.ts +3 -33
  62. package/src/transcript/entries.ts +114 -0
  63. package/src/transcript/format.ts +1 -2
  64. package/src/transcript/messages.ts +14 -11
  65. package/src/transcript/render-worker.ts +24 -0
  66. package/src/transcript/render.ts +12 -3
  67. package/src/transcript/search.ts +46 -5
  68. package/src/transcript/subagents.ts +55 -34
  69. /package/src/{proxy → core}/estimate.ts +0 -0
  70. /package/src/{proxy → core}/fingerprint.ts +0 -0
@@ -1,5 +1,3 @@
1
- import { existsSync, readFileSync } from "node:fs"
2
- import { paths } from "../../config/paths"
3
1
  import { rerankQuestion } from "../prompts"
4
2
  import type { RecallChunk, Reranker } from "."
5
3
 
@@ -9,12 +7,6 @@ interface RerankAnswer {
9
7
 
10
8
  const TIMEOUT_MS = 4_000
11
9
 
12
- export function rerankKey(): string | null {
13
- const env = process.env.TYPESAFE_API_KEY?.trim()
14
- if (env) return env
15
- return existsSync(paths.typesafeKey) ? readFileSync(paths.typesafeKey, "utf8").trim() : null
16
- }
17
-
18
10
  function rerankState(chunks: RecallChunk[], candidates: number[]): string {
19
11
  return candidates
20
12
  .map((index, n) => {
@@ -1,14 +1,15 @@
1
- import type { Config } from "../config"
2
- import type { Body, Message } from "../context/content"
3
- import { type CompactOptions, capTurns, clearToolResults, compactMessages, countToolResults } from "../context/fold/strategies"
4
- import { splitTurns, turnFingerprint } from "../context/fold/turns"
1
+ import { type Body, type Message, quickHash } from "../context/content"
2
+ import { type CompactOptions, capTurns, clearToolResults, compactMessages, countPinned, countToolResults, foldsOpening } from "../context/fold/strategies"
3
+ import { type ReduceOptions, reduceTurn, splitTurns, turnFingerprint } from "../context/fold/turns"
5
4
  import { clearedOutput } from "../context/prompts"
5
+ import type { CoreConfig } from "./config"
6
6
  import { DEFAULT_RATIO, type Estimator, estimator, targetSize } from "./estimate"
7
+ import type { RewriteNote } from "./rewrite"
7
8
  import { resetCut, type ThreadState } from "./state"
8
9
 
9
10
  export interface CompactInput {
10
11
  body: Body
11
- config: Config
12
+ config: CoreConfig
12
13
  state: ThreadState
13
14
  window: number | null
14
15
  guide: string | null
@@ -19,10 +20,28 @@ export interface CompactInput {
19
20
  reduce?: CompactOptions["reduce"]
20
21
  }
21
22
 
23
+ const FOLDED_TURNS = 4096
24
+
25
+ const folded = new Map<string, Message[]>()
26
+
27
+ function reduceRemembered(turn: Message[], options: ReduceOptions): Message[] {
28
+ const key = `${options.evidence ? 1 : 0}:${quickHash(JSON.stringify(turn))}`
29
+ const known = folded.get(key)
30
+ if (known) {
31
+ folded.delete(key)
32
+ folded.set(key, known)
33
+ return known
34
+ }
35
+ const reduced = reduceTurn(turn, options)
36
+ folded.set(key, reduced)
37
+ if (folded.size > FOLDED_TURNS) folded.delete(folded.keys().next().value as string)
38
+ return reduced
39
+ }
40
+
22
41
  export interface CompactResult {
23
42
  messages: Message[]
24
43
  stateChanged: boolean
25
- note: Record<string, unknown>
44
+ note: RewriteNote
26
45
  }
27
46
 
28
47
  interface Rolling {
@@ -32,13 +51,15 @@ interface Rolling {
32
51
 
33
52
  const PROTECTED_OUTPUTS = 5
34
53
 
54
+ const STUCK_STEP = 0.1
55
+
35
56
  function settledCut(turns: Message[][], index: number) {
36
57
  let cut = index
37
58
  while (cut > 1 && JSON.stringify(turns[cut][0].content).includes("[Request interrupted")) cut -= 1
38
59
  return cut
39
60
  }
40
61
 
41
- function resolveCut(turns: Message[][], state: ThreadState, config: Config, rollingKeep: number | undefined) {
62
+ function resolveCut(turns: Message[][], state: ThreadState, config: CoreConfig, rollingKeep: number | undefined) {
42
63
  let cutIndex = state.cut ? turns.findIndex((turn) => turnFingerprint(turn) === state.cut) : -1
43
64
  let stateChanged = false
44
65
  if (state.cut && cutIndex === -1) {
@@ -71,17 +92,47 @@ function dueRoll(turns: Message[][], cutIndex: number, rolling: Rolling, size: E
71
92
  return next >= 1 && next > cutIndex && backlog > rolling.budget ? next : null
72
93
  }
73
94
 
74
- function shrinkToTarget(over: () => boolean, step: () => string, actions: string[], rebuild: () => void) {
95
+ function shrinkToTarget(over: () => boolean, step: () => string, actions: string[], rebuild: () => void): boolean {
75
96
  while (over()) {
76
97
  const action = step()
77
98
  actions.push(action)
78
- if (action === "stuck") return
99
+ if (action === "stuck") return true
79
100
  rebuild()
80
101
  }
102
+ return false
103
+ }
104
+
105
+ const holdsStuck = (state: ThreadState, estimate: number, window: number | null) => window !== null && state.stuck !== undefined && estimate <= state.stuck + STUCK_STEP * window
106
+
107
+ export type StuckSizes = ReturnType<typeof stuckSizes>
108
+
109
+ const headLength = (preamble: Message[], first: Message[], state: ThreadState) => preamble.length + 1 + (countPinned([first], new Set(state.pins)) > 0 ? first.length : 1)
110
+
111
+ function stuckSizes(messages: Message[], headEnd: number, liveFrom: number, size: Estimator) {
112
+ const of = (list: Message[]) => size.of(list) - size.fixed
113
+ return { fixed: size.fixed, head: of(messages.slice(0, headEnd)), folded: of(messages.slice(headEnd, liveFrom)), live: of(messages.slice(liveFrom)) }
114
+ }
115
+
116
+ function cutNote(state: ThreadState, turns: Message[][], cutIndex: number, configuredKeep: number, actions: string[]) {
117
+ const pins = new Set(state.pins)
118
+ const keptFrom = cutIndex > 0 ? Math.max(1, cutIndex - (state.keep ?? configuredKeep)) : 0
119
+ const omitted = cutIndex > 0 ? keptFrom - 1 - countPinned(turns.slice(1, keptFrom), pins) : 0
120
+ return {
121
+ action: cutIndex > 0 ? ("compact" as const) : ("full" as const),
122
+ compacted: actions.includes("cut") || actions.includes("roll"),
123
+ actions: actions.length > 0 ? actions : undefined,
124
+ cutTurn: cutIndex > 0 ? cutIndex : null,
125
+ omitted,
126
+ folded: cutIndex > 0 ? cutIndex - keptFrom - countPinned(turns.slice(keptFrom, cutIndex), pins) + (foldsOpening(turns[0], keptFrom) && countPinned([turns[0]], pins) === 0 ? 1 : 0) : 0,
127
+ pinned: cutIndex > 0 ? countPinned(turns.slice(0, cutIndex), pins) : 0,
128
+ host: state.host,
129
+ keep: state.keep,
130
+ cleared: state.cleared,
131
+ }
81
132
  }
82
133
 
83
134
  export function compact({ body, config, state, window, guide, transcriptDir, mayCompact, omittedBefore = 0, decorate = (m) => m, reduce }: CompactInput): CompactResult {
84
- const { turns } = splitTurns(body.messages)
135
+ const { preamble, turns } = splitTurns(body.messages)
85
136
  const rolling = config.eval?.rolling ?? (config.mode === "default" ? { keep: config.keep, budget: config.budget } : undefined)
86
137
  const resolved = resolveCut(turns, state, config, rolling?.keep)
87
138
  let cutIndex = resolved.cutIndex
@@ -89,7 +140,7 @@ export function compact({ body, config, state, window, guide, transcriptDir, may
89
140
 
90
141
  const configuredKeep = config.keepTurns === "all" ? Number.MAX_SAFE_INTEGER : config.keepTurns
91
142
  const ratio = state.ratio ?? DEFAULT_RATIO
92
- const options = { guide, omittedBefore, evidence: config.eval?.evidence ?? true, reduce, capChars: config.turnCap === "off" ? undefined : config.turnCap / ratio }
143
+ const options = { guide, omittedBefore, evidence: config.eval?.evidence ?? true, reduce: reduce ?? reduceRemembered, capChars: config.turnCap === "off" ? undefined : config.turnCap / ratio, pinned: new Set(state.pins) }
93
144
  const liveStart = (list: Message[]) =>
94
145
  list.length -
95
146
  splitTurns(list)
@@ -133,12 +184,16 @@ export function compact({ body, config, state, window, guide, transcriptDir, may
133
184
  if (mayCompact && estimate > limit) {
134
185
  const current = turns.length - 1
135
186
  if (current > 0 && current > cutIndex) moveCut(current, "cut")
136
- shrinkToTarget(
137
- () => estimate > target,
138
- () => shrinkOnce(state, cutIndex, configuredKeep, liveResults),
139
- actions,
140
- rebuild,
141
- )
187
+ if (!holdsStuck(state, estimate, window)) {
188
+ const stuck = shrinkToTarget(
189
+ () => estimate > target,
190
+ () => shrinkOnce(state, cutIndex, configuredKeep, liveResults),
191
+ actions,
192
+ rebuild,
193
+ )
194
+ if (stuck) state.stuck = estimate
195
+ else delete state.stuck
196
+ }
142
197
  stateChanged = true
143
198
  }
144
199
 
@@ -146,13 +201,8 @@ export function compact({ body, config, state, window, guide, transcriptDir, may
146
201
  messages,
147
202
  stateChanged,
148
203
  note: {
149
- action: cutIndex > 0 ? "compact" : "full",
150
- compacted: actions.includes("cut") || actions.includes("roll"),
151
- actions: actions.length > 0 ? actions : undefined,
152
- cutTurn: cutIndex > 0 ? cutIndex : null,
153
- omitted: cutIndex > 0 ? Math.max(1, cutIndex - (state.keep ?? configuredKeep)) - 1 : 0,
154
- keep: state.keep,
155
- cleared: state.cleared,
204
+ ...cutNote(state, turns, cutIndex, configuredKeep, actions),
205
+ stuck: state.stuck === undefined ? undefined : stuckSizes(messages, headLength(preamble, turns[0], state), liveStart(messages), size),
156
206
  turns: turns.length,
157
207
  messagesIn: body.messages.length,
158
208
  messagesOut: messages.length,
@@ -0,0 +1,31 @@
1
+ export type Mode = "off" | "compact" | "default"
2
+
3
+ interface EvalConfig {
4
+ recallOptions?: { maxChars?: number; maxCharsMulti?: number; perQuery?: number }
5
+ strategy?: "tail" | "guide" | "trim"
6
+ rolling?: { keep: number; budget: number }
7
+ forceCut?: boolean
8
+ trustHeaders?: boolean
9
+ evidence?: boolean
10
+ recall?: boolean
11
+ rerank?: { model: string; candidates?: number; endpoint?: string }
12
+ }
13
+
14
+ export interface CoreConfig {
15
+ mode: Mode
16
+ compactAt: number
17
+ keepTurns: number | "all"
18
+ keep: number
19
+ budget: number
20
+ turnCap: number | "off"
21
+ eval?: EvalConfig
22
+ }
23
+
24
+ export const CORE_DEFAULTS: CoreConfig = {
25
+ mode: "default",
26
+ compactAt: 0.9,
27
+ keepTurns: "all",
28
+ keep: 3,
29
+ budget: 100_000,
30
+ turnCap: 100_000,
31
+ }
@@ -0,0 +1,69 @@
1
+ import type { Message } from "../context/content"
2
+ import { splitTurns, turnFingerprint } from "../context/fold/turns"
3
+ import { threadKeyOf } from "./rewrite"
4
+ import { resetCut, type ThreadState, type ThreadStore } from "./state"
5
+
6
+ export interface FoldTarget {
7
+ store: ThreadStore
8
+ threadId: string
9
+ messages: Message[]
10
+ }
11
+
12
+ export interface FoldOptions {
13
+ at?: number | "latest"
14
+ pin?: number[]
15
+ }
16
+
17
+ export interface Folded {
18
+ threadKey: string
19
+ cutTurn: number | null
20
+ pinned: number[]
21
+ }
22
+
23
+ function located({ store, threadId, messages }: FoldTarget) {
24
+ const threadKey = threadKeyOf(threadId, messages)
25
+ if (!threadKey) throw new RangeError("eunoe can only fold a thread that has at least one user turn")
26
+ return { threadKey, turns: splitTurns(messages).turns, state: store.read(threadKey) }
27
+ }
28
+
29
+ function checkedTurn(turn: number, turns: Message[][], from: number): number {
30
+ if (turns.length <= from) throw new RangeError(`this thread has ${turns.length} turn${turns.length === 1 ? "" : "s"}, so there is no turn ${turn} to fold at`)
31
+ if (!Number.isInteger(turn) || turn < from || turn >= turns.length) throw new RangeError(`turn ${turn} is outside turns ${from}..${turns.length - 1}`)
32
+ return turn
33
+ }
34
+
35
+ function folded(threadKey: string, turns: Message[][], state: ThreadState): Folded {
36
+ const cutTurn = state.cut ? turns.findIndex((turn) => turnFingerprint(turn) === state.cut) : -1
37
+ const pins = new Set(state.pins)
38
+ return { threadKey, cutTurn: cutTurn > 0 ? cutTurn : null, pinned: turns.flatMap((turn, i) => (pins.has(turnFingerprint(turn)) ? [i] : [])) }
39
+ }
40
+
41
+ function withPins(state: ThreadState, turns: Message[][], indices: number[], pinned: boolean) {
42
+ const pins = new Set(state.pins)
43
+ for (const index of indices) {
44
+ const fingerprint = turnFingerprint(turns[checkedTurn(index, turns, 0)])
45
+ if (pinned) pins.add(fingerprint)
46
+ else pins.delete(fingerprint)
47
+ }
48
+ if (pins.size > 0) state.pins = [...pins].sort()
49
+ else delete state.pins
50
+ }
51
+
52
+ export function foldThread(target: FoldTarget, { at = "latest", pin = [] }: FoldOptions = {}): Folded {
53
+ const { threadKey, turns, state } = located(target)
54
+ const cut = checkedTurn(at === "latest" ? turns.length - 1 : at, turns, 1)
55
+ withPins(state, turns, pin, true)
56
+ resetCut(state)
57
+ state.cut = turnFingerprint(turns[cut])
58
+ state.keep = cut - 1
59
+ state.host = true
60
+ target.store.save(threadKey, state)
61
+ return folded(threadKey, turns, state)
62
+ }
63
+
64
+ export function pinTurns(target: FoldTarget, indices: number[], pinned = true): Folded {
65
+ const { threadKey, turns, state } = located(target)
66
+ withPins(state, turns, indices, pinned)
67
+ target.store.save(threadKey, state)
68
+ return folded(threadKey, turns, state)
69
+ }
@@ -0,0 +1,192 @@
1
+ import { type Body, wellFormed } from "../context/content"
2
+ import type { Reranker } from "../context/recall"
3
+ import { CORE_DEFAULTS, type CoreConfig } from "./config"
4
+ import { sizeOf } from "./estimate"
5
+ import { type Rewrite, type RewriteNote, rewriteAsync, type ThreadSources } from "./rewrite"
6
+ import type { ThreadStore } from "./state"
7
+ import { inputTokens, recordOverflow, recordUsage, watchUsage } from "./usage"
8
+ import { createWindowLookup, type WindowLookup } from "./window"
9
+
10
+ export type ForwardNote = RewriteNote | Record<string, unknown>
11
+
12
+ export type Prepared = Omit<Rewrite, "note"> & { note: ForwardNote }
13
+
14
+ export interface ForwarderOptions {
15
+ store: ThreadStore
16
+ upstream?: string
17
+ config?: CoreConfig
18
+ window?: number | null | WindowLookup
19
+ threadIdOf?: (request: Request, body: Body) => string | null
20
+ sources?: (threadId: string, request: Request, body: Body) => ThreadSources | undefined
21
+ reranker?: Reranker
22
+ prepare?: (request: Request, body: Body, mayCompact: boolean) => Promise<Prepared>
23
+ record?: (entry: Record<string, unknown>) => void
24
+ countTokensTimeoutMs?: number
25
+ }
26
+
27
+ interface Outgoing {
28
+ note: ForwardNote
29
+ threadKey: string | null
30
+ bodyText: string | undefined
31
+ sentSize: number
32
+ givenSize: number
33
+ fixedSize: number
34
+ }
35
+
36
+ const COUNT_TOKENS_ATTEMPTS = 3
37
+
38
+ const DROPPED_REQUEST_HEADERS = ["host", "content-length", "accept-encoding"]
39
+
40
+ const clipped = (error: string) => error.slice(0, 500)
41
+
42
+ export function claudeCodeSessionId(headers: Headers, body: Body): string | null {
43
+ const header = headers.get("x-claude-code-session-id")
44
+ if (header) return header
45
+ try {
46
+ return JSON.parse(body.metadata?.user_id ?? "{}").session_id ?? null
47
+ } catch {
48
+ return null
49
+ }
50
+ }
51
+
52
+ const defaultThreadId = (request: Request, body: Body) => request.headers.get("x-thread-id") ?? claudeCodeSessionId(request.headers, body)
53
+
54
+ function forwardableHeaders(headers: Headers) {
55
+ const out = new Headers(headers)
56
+ out.delete("content-encoding")
57
+ out.delete("content-length")
58
+ return out
59
+ }
60
+
61
+ function defaultPrepare(options: ForwarderOptions, upstream: string): NonNullable<ForwarderOptions["prepare"]> {
62
+ const threadIdOf = options.threadIdOf ?? defaultThreadId
63
+ const given = options.window
64
+ const lookup = typeof given === "function" ? given : createWindowLookup()
65
+ const windowFor: (model: unknown, headers: Headers) => Promise<number | null> = typeof given === "number" || given === null ? () => Promise.resolve(given) : (model, headers) => lookup(model, headers, upstream)
66
+ return async (request, body, mayCompact) => {
67
+ const threadId = threadIdOf(request, body)
68
+ const window = await windowFor(body.model, request.headers)
69
+ const sources = threadId ? options.sources?.(threadId, request, body) : undefined
70
+ return rewriteAsync({ body, threadId, config: options.config ?? CORE_DEFAULTS, store: options.store, sources, window, mayCompact }, options.reranker)
71
+ }
72
+ }
73
+
74
+ const passthrough = (bodyText: string | undefined): Outgoing => ({ note: { action: "pass" }, threadKey: null, bodyText, sentSize: 0, givenSize: 0, fixedSize: 0 })
75
+
76
+ async function outgoing(prepare: NonNullable<ForwarderOptions["prepare"]>, request: Request, givenText: string, mayCompact: boolean): Promise<Outgoing> {
77
+ try {
78
+ const given = JSON.parse(givenText) as Body
79
+ const result = await prepare(request, given, mayCompact)
80
+ const sentSize = sizeOf(result.body)
81
+ return {
82
+ note: result.note,
83
+ threadKey: result.threadKey,
84
+ bodyText: result.body !== given ? JSON.stringify(result.body, wellFormed) : givenText,
85
+ sentSize,
86
+ givenSize: result.body === given ? sentSize : sizeOf(given),
87
+ fixedSize: sizeOf({ ...result.body, messages: [] }),
88
+ }
89
+ } catch (error) {
90
+ return { ...passthrough(givenText), note: { action: "error-passthrough", error: String(error) } }
91
+ }
92
+ }
93
+
94
+ function createSender(request: Request, target: string, headers: Headers, timeout: number | null, stall: { count: number }) {
95
+ return async (body: string | undefined): Promise<Response> => {
96
+ if (timeout === null) return fetch(target, { method: request.method, headers, body })
97
+ for (;;) {
98
+ try {
99
+ return await fetch(target, { method: request.method, headers, body, signal: AbortSignal.timeout(timeout) })
100
+ } catch (error) {
101
+ const timedOut = error instanceof Error && error.name === "TimeoutError"
102
+ if (!timedOut || stall.count + 1 >= COUNT_TOKENS_ATTEMPTS) throw error
103
+ stall.count += 1
104
+ }
105
+ }
106
+ }
107
+ }
108
+
109
+ function delivered(answer: Response, out: Outgoing, metered: boolean, path: string, log: (entry: Record<string, unknown>) => void, store: ThreadStore): Response {
110
+ const { note, threadKey, sentSize, givenSize, fixedSize } = out
111
+ const response = new Response(answer.body, { status: answer.status, statusText: answer.statusText, headers: forwardableHeaders(answer.headers) })
112
+ if (note.action === "pass" || !metered) {
113
+ if (note.action !== "pass" || Object.keys(note).length > 1) log({ path, status: answer.status, ...note })
114
+ return response
115
+ }
116
+ return watchUsage(response, (usage) => {
117
+ log({
118
+ path,
119
+ status: answer.status,
120
+ thread: threadKey,
121
+ ...note,
122
+ input: inputTokens(usage),
123
+ cacheRead: usage.cache_read_input_tokens ?? 0,
124
+ cacheWrite: usage.cache_creation_input_tokens ?? 0,
125
+ uncached: usage.input_tokens ?? 0,
126
+ charsIn: givenSize,
127
+ charsOut: sentSize,
128
+ charsFixed: fixedSize,
129
+ })
130
+ if (threadKey) recordUsage(store, threadKey, sentSize, usage)
131
+ })
132
+ }
133
+
134
+ export function createForwarder(options: ForwarderOptions): (request: Request) => Promise<Response> {
135
+ const upstream = options.upstream ?? "https://api.anthropic.com"
136
+ const prepare = options.prepare ?? defaultPrepare(options, upstream)
137
+ const timeout = options.countTokensTimeoutMs ?? 10_000
138
+
139
+ async function route(request: Request, began: number): Promise<Response> {
140
+ const stall = { count: 0 }
141
+ let prep = 0
142
+ let upload = 0
143
+ const log = (entry: Record<string, unknown>) => options.record?.({ ...entry, ms: Date.now() - began, prep, upload, ...(stall.count > 0 && { stalled: stall.count }) })
144
+ const url = new URL(request.url)
145
+ const headers = new Headers(request.headers)
146
+ for (const name of DROPPED_REQUEST_HEADERS) headers.delete(name)
147
+
148
+ let givenText: string | undefined
149
+ if (request.method !== "GET" && request.method !== "HEAD") givenText = await request.text()
150
+ upload = Date.now() - began
151
+ const isMessages = request.method === "POST" && url.pathname.startsWith("/v1/messages")
152
+ const isCountTokens = url.pathname.includes("count_tokens")
153
+
154
+ const build = () => (givenText && isMessages ? outgoing(prepare, request, givenText, !isCountTokens) : Promise.resolve(passthrough(givenText)))
155
+ const send = createSender(request, upstream + url.pathname + url.search, headers, isCountTokens ? timeout : null, stall)
156
+ let out = await build()
157
+ prep = Date.now() - began - upload
158
+ let answer = await send(out.bodyText)
159
+ const errorResponse = (text: string) => new Response(text, { status: answer.status, statusText: answer.statusText, headers: forwardableHeaders(answer.headers) })
160
+
161
+ if (answer.status >= 400 && out.note.action !== "pass") {
162
+ const errorText = await answer.text()
163
+ const logged = { path: url.pathname, status: answer.status, thread: out.threadKey, ...out.note }
164
+ const overflow = out.threadKey && !isCountTokens ? recordOverflow(options.store, out.threadKey, out.sentSize, errorText) : null
165
+ if (!overflow) {
166
+ log({ ...logged, error: clipped(errorText) })
167
+ return errorResponse(errorText)
168
+ }
169
+ log({ ...logged, overflow, error: clipped(errorText) })
170
+ out = await build()
171
+ out = { ...out, note: { ...out.note, healed: overflow } }
172
+ answer = await send(out.bodyText)
173
+ if (answer.status >= 400) {
174
+ const retryError = await answer.text()
175
+ log({ path: url.pathname, status: answer.status, thread: out.threadKey, ...out.note, error: clipped(retryError) })
176
+ return errorResponse(retryError)
177
+ }
178
+ }
179
+
180
+ return delivered(answer, out, isMessages && !isCountTokens, url.pathname, log, options.store)
181
+ }
182
+
183
+ return async (request) => {
184
+ const began = Date.now()
185
+ try {
186
+ return await route(request, began)
187
+ } catch (error) {
188
+ options.record?.({ path: new URL(request.url).pathname, status: 502, action: "upstream-unreachable", error: clipped(String(error)), ms: Date.now() - began })
189
+ return Response.json({ type: "error", error: { type: "api_error", message: `eunoe could not reach the API: ${String(error)}` } }, { status: 502 })
190
+ }
191
+ }
192
+ }
@@ -1,17 +1,13 @@
1
- import { statSync } from "node:fs"
2
- import { type Config, claudeConfigDirs } from "../config"
3
1
  import { blocks, type Message } from "../context/content"
4
2
  import { FOLD_CAP_CHARS, foldedView, sourceChunks } from "../context/fold"
5
3
  import { isPrompt } from "../context/fold/turns"
6
4
  import { handoffBlock } from "../context/prompts"
7
5
  import type { RecallChunk } from "../context/recall"
8
- import { findSessionByPrefix } from "../transcript/locate"
9
- import { sessionMessages } from "../transcript/messages"
10
6
  import { isInjected, messageFingerprint } from "./fingerprint"
11
- import { titleOf } from "./sessions"
7
+ import type { ThreadSources } from "./rewrite"
12
8
  import type { Handoff, ThreadState } from "./state"
13
9
 
14
- const sourceCache = new Map<string, { mtime: number; chunks: RecallChunk[] }>()
10
+ const chunksOf = new WeakMap<Message[], { label: string; chunks: RecallChunk[] }>()
15
11
 
16
12
  export const continueToken = (id: string) => `{ee:continue:${id}}`
17
13
 
@@ -28,17 +24,23 @@ export function activeHandoffs(handoffs: Record<string, Handoff | null> = {}): R
28
24
  return Object.fromEntries(Object.entries(handoffs).filter((entry): entry is [string, Handoff] => Boolean(entry[1])))
29
25
  }
30
26
 
31
- export function attachHandoffs(messages: Message[], config: Config, sessionId: string | null, state: ThreadState): boolean {
27
+ function resolveHandoff(id: string, sources: ThreadSources, state: ThreadState): Handoff | null {
28
+ const found = sources.findHandoff?.(id) ?? null
29
+ if (!found || !sources.readHandoff) return null
30
+ const already = Object.values(state.handoffs ?? {}).some((h) => h?.source === found.source)
31
+ return already ? null : { label: found.label, source: found.source, text: foldedView(sources.readHandoff(found.source), FOLD_CAP_CHARS) }
32
+ }
33
+
34
+ export function attachHandoffs(messages: Message[], sources: ThreadSources, state: ThreadState): boolean {
32
35
  let changed = false
33
36
  for (const message of messages) {
34
37
  const id = continueIdOf(message)
35
38
  if (!id) continue
36
39
  const key = messageFingerprint(message)
37
40
  if (state.handoffs && key in state.handoffs) continue
41
+ const handoff = resolveHandoff(id, sources, state)
38
42
  state.handoffs ??= {}
39
- const file = findSessionByPrefix(id, claudeConfigDirs(config), sessionId ?? undefined)
40
- const already = Object.values(state.handoffs).some((h) => h?.source === file)
41
- state.handoffs[key] = file && !already ? { label: titleOf(file) ?? id, source: file, text: foldedView(sessionMessages(file), FOLD_CAP_CHARS) } : null
43
+ state.handoffs[key] = handoff
42
44
  changed = true
43
45
  }
44
46
  return changed
@@ -53,19 +55,17 @@ export function withHandoffs(messages: Message[], handoffs: Record<string, Hando
53
55
  })
54
56
  }
55
57
 
56
- export function handoffChunks(handoffs: Record<string, Handoff>): RecallChunk[] {
57
- const sources = new Map<string, string>()
58
- for (const handoff of Object.values(handoffs)) if (!sources.has(handoff.source)) sources.set(handoff.source, handoff.label)
59
- return [...sources].flatMap(([source, label]) => {
60
- let mtime: number
61
- try {
62
- mtime = statSync(source).mtimeMs
63
- } catch {
64
- return []
65
- }
66
- const cached = sourceCache.get(source)
67
- const chunks = cached?.mtime === mtime ? cached.chunks : sourceChunks(sessionMessages(source), label, true)
68
- sourceCache.set(source, { mtime, chunks })
58
+ export function handoffChunks(handoffs: Record<string, Handoff>, sources: ThreadSources): RecallChunk[] {
59
+ const read = sources.readHandoff
60
+ if (!read) return []
61
+ const labels = new Map<string, string>()
62
+ for (const handoff of Object.values(handoffs)) if (!labels.has(handoff.source)) labels.set(handoff.source, handoff.label)
63
+ return [...labels].flatMap(([source, label]) => {
64
+ const messages = read(source)
65
+ const known = chunksOf.get(messages)
66
+ if (known?.label === label) return known.chunks
67
+ const chunks = sourceChunks(messages, label, true)
68
+ chunksOf.set(messages, { label, chunks })
69
69
  return chunks
70
70
  })
71
71
  }
@@ -0,0 +1,14 @@
1
+ export type { Block, Body, Message } from "../context/content"
2
+ export type { RecallChunk, Reranker } from "../context/recall"
3
+ export { jevReranker } from "../context/recall/rerank"
4
+ export type { StuckSizes } from "./compaction"
5
+ export { CORE_DEFAULTS, type CoreConfig, type Mode } from "./config"
6
+ export { type Folded, type FoldOptions, type FoldTarget, foldThread, pinTurns } from "./fold"
7
+ export { claudeCodeSessionId, createForwarder, type ForwarderOptions, type ForwardNote, type Prepared } from "./forward"
8
+ export { continueToken } from "./handoff"
9
+ export { type Rewrite, type RewriteInput, type RewriteNote, rewrite, rewriteAsync, type ThreadSources, threadKeyOf } from "./rewrite"
10
+ export { memoryStore, type ThreadState, type ThreadStore } from "./state"
11
+ export { listSubagentRecall, type Subagent } from "./subagents"
12
+ export { renderTranscriptFiles, type TranscriptOptions, transcriptGuide } from "./transcript"
13
+ export { inputTokens, recordOverflow, recordUsage, type Usage, watchUsage } from "./usage"
14
+ export { createWindowLookup, type WindowLookup } from "./window"
@@ -1,11 +1,10 @@
1
- import { type Body, blocks, resultText } from "../context/content"
1
+ import { type Body, blocks, type Message, resultText } from "../context/content"
2
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
3
  import { messageFingerprint } from "./fingerprint"
6
4
  import { activeHandoffs, handoffChunks } from "./handoff"
7
5
  import type { Rewrite, RewriteInput } from "./rewrite"
8
- import { readState, saveState } from "./state"
6
+
7
+ const closingTurn: Message = { role: "user", content: "" }
9
8
 
10
9
  export const wantsRecall = (input: RewriteInput) => (input.config.eval?.recall ?? true) && input.config.mode !== "off"
11
10
 
@@ -13,31 +12,30 @@ export const activeRecalls = (recalls: Record<string, string> = {}) => Object.fr
13
12
 
14
13
  const touched = (input: RewriteInput, result: Rewrite) => result.threadKey !== null && !(result.body === input.body && result.note.action === "pass")
15
14
 
16
- function droppedChunks(input: RewriteInput, result: Rewrite): RecallChunk[] {
17
- const transcript = input.transcript?.jsonl ?? null
15
+ function droppedChunks(input: RewriteInput, result: Rewrite, threadKey: string): RecallChunk[] {
16
+ const sources = input.sources ?? {}
18
17
  const kept = new Map<string, string>()
19
18
  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
19
  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]
20
+ const session = own.length > 0 || !sources.readHistory ? own : recallChunks([...sources.readHistory(), closingTurn], kept)
21
+ const withSubagents = sources.listSubagentChunks ? [...session, ...sources.listSubagentChunks()] : session
22
+ return [...handoffChunks(activeHandoffs(input.store.read(threadKey).handoffs), sources), ...withSubagents]
25
23
  }
26
24
 
27
- function recallTarget(original: Body, result: Rewrite): { key: string; text: string } | null {
25
+ function recallTarget(original: Body): { key: string; text: string } | null {
28
26
  const live = livePrompt(original.messages)
29
- return live && result.threadKey ? { key: messageFingerprint(original.messages[live.index]), text: live.text } : null
27
+ return live ? { key: messageFingerprint(original.messages[live.index]), text: live.text } : null
30
28
  }
31
29
 
32
30
  export function applyRecall(input: RewriteInput, result: Rewrite): Rewrite {
33
31
  if (!touched(input, result) || !result.threadKey) return result
34
- const state = readState(result.threadKey)
32
+ const state = input.store.read(result.threadKey)
35
33
  state.recalls ??= {}
36
- const target = recallTarget(input.body, result)
34
+ const target = recallTarget(input.body)
37
35
  let note = result.note
38
36
  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)
37
+ state.recalls[target.key] = recallFor(target.text, droppedChunks(input, result, result.threadKey), input.config.eval?.recallOptions) ?? ""
38
+ input.store.save(result.threadKey, state)
41
39
  note = { ...note, recalled: state.recalls[target.key].length }
42
40
  }
43
41
  const messages = withRecall(result.body.messages, messageFingerprint, activeRecalls(state.recalls))
@@ -46,13 +44,13 @@ export function applyRecall(input: RewriteInput, result: Rewrite): Rewrite {
46
44
 
47
45
  export async function prefetchFor(input: RewriteInput, result: Rewrite, rerank: Reranker): Promise<number | null> {
48
46
  if (!touched(input, result) || !result.threadKey) return null
49
- const target = recallTarget(input.body, result)
47
+ const target = recallTarget(input.body)
50
48
  if (!target) return null
51
- const state = readState(result.threadKey)
49
+ const state = input.store.read(result.threadKey)
52
50
  state.recalls ??= {}
53
51
  if (target.key in state.recalls) return null
54
52
  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)
53
+ state.recalls[target.key] = (await recallForWith(target.text, droppedChunks(input, result, result.threadKey), rerank, options)) ?? ""
54
+ input.store.save(result.threadKey, state)
57
55
  return state.recalls[target.key].length
58
56
  }