@chroxify/eunoe 1.2.2 → 1.3.1

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 (64) hide show
  1. package/README.md +150 -6
  2. package/dist/core/index.js +2354 -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 +16 -0
  8. package/dist/types/context/prompts.d.ts +24 -0
  9. package/dist/types/context/recall/index.d.ts +47 -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 +34 -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 +9 -0
  20. package/dist/types/core/rewrite.d.ts +63 -0
  21. package/dist/types/core/state.d.ts +25 -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 +8 -0
  25. package/dist/types/core/window.d.ts +8 -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/status.ts +1 -1
  31. package/src/cli/proxy/stats.ts +1 -1
  32. package/src/cli/replay/index.ts +3 -3
  33. package/src/cli/sessions/index.ts +2 -2
  34. package/src/config/index.ts +4 -27
  35. package/src/context/content.ts +64 -1
  36. package/src/context/fold/strategies.ts +63 -10
  37. package/src/context/fold/turns.ts +34 -2
  38. package/src/context/prompts.ts +4 -3
  39. package/src/context/recall/index.ts +8 -4
  40. package/src/context/recall/rerank.ts +0 -8
  41. package/src/{proxy → core}/compaction.ts +85 -28
  42. package/src/core/config.ts +31 -0
  43. package/src/core/fold.ts +71 -0
  44. package/src/core/forward.ts +194 -0
  45. package/src/{proxy → core}/handoff.ts +23 -23
  46. package/src/core/index.ts +14 -0
  47. package/src/core/recall.ts +80 -0
  48. package/src/core/rewrite.ts +201 -0
  49. package/src/core/state.ts +50 -0
  50. package/src/core/subagents.ts +28 -0
  51. package/src/core/transcript.ts +44 -0
  52. package/src/{proxy → core}/usage.ts +43 -1
  53. package/src/core/window.ts +69 -0
  54. package/src/proxy/rewrite.ts +22 -139
  55. package/src/proxy/server.ts +21 -159
  56. package/src/proxy/sessions.ts +1 -1
  57. package/src/proxy/sources.ts +35 -0
  58. package/src/proxy/state.ts +2 -20
  59. package/src/proxy/window.ts +3 -33
  60. package/src/transcript/format.ts +1 -2
  61. package/src/transcript/subagents.ts +3 -3
  62. package/src/proxy/recall.ts +0 -58
  63. /package/src/{proxy → core}/estimate.ts +0 -0
  64. /package/src/{proxy → core}/fingerprint.ts +0 -0
@@ -0,0 +1,71 @@
1
+ import type { Message } from "../context/content"
2
+ import { splitTurns, turnKeys } from "../context/fold/turns"
3
+ import { threadKeyOf } from "./rewrite"
4
+ import { resetCut, retireRecalls, 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
+ const turns = splitTurns(messages).turns
27
+ return { threadKey, turns, keys: turnKeys(turns), state: store.read(threadKey) }
28
+ }
29
+
30
+ function checkedTurn(turn: number, turns: Message[][], from: number): number {
31
+ 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`)
32
+ if (!Number.isInteger(turn) || turn < from || turn >= turns.length) throw new RangeError(`turn ${turn} is outside turns ${from}..${turns.length - 1}`)
33
+ return turn
34
+ }
35
+
36
+ function folded(threadKey: string, keys: string[], state: ThreadState): Folded {
37
+ const cutTurn = state.cut ? keys.indexOf(state.cut) : -1
38
+ const pins = new Set(state.pins)
39
+ return { threadKey, cutTurn: cutTurn > 0 ? cutTurn : null, pinned: keys.flatMap((key, i) => (pins.has(key) ? [i] : [])) }
40
+ }
41
+
42
+ function withPins(state: ThreadState, turns: Message[][], keys: string[], indices: number[], pinned: boolean) {
43
+ const pins = new Set(state.pins)
44
+ for (const index of indices) {
45
+ const key = keys[checkedTurn(index, turns, 0)]
46
+ if (pinned) pins.add(key)
47
+ else pins.delete(key)
48
+ }
49
+ if (pins.size > 0) state.pins = [...pins].sort()
50
+ else delete state.pins
51
+ }
52
+
53
+ export function foldThread(target: FoldTarget, { at = "latest", pin = [] }: FoldOptions = {}): Folded {
54
+ const { threadKey, turns, keys, state } = located(target)
55
+ const cut = checkedTurn(at === "latest" ? turns.length - 1 : at, turns, 1)
56
+ withPins(state, turns, keys, pin, true)
57
+ resetCut(state)
58
+ state.cut = keys[cut]
59
+ retireRecalls(state, turns.slice(0, cut))
60
+ state.keep = cut - 1
61
+ state.host = true
62
+ target.store.save(threadKey, state)
63
+ return folded(threadKey, keys, state)
64
+ }
65
+
66
+ export function pinTurns(target: FoldTarget, indices: number[], pinned = true): Folded {
67
+ const { threadKey, turns, keys, state } = located(target)
68
+ withPins(state, turns, keys, indices, pinned)
69
+ target.store.save(threadKey, state)
70
+ return folded(threadKey, keys, state)
71
+ }
@@ -0,0 +1,194 @@
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
+ model: unknown
30
+ threadKey: string | null
31
+ bodyText: string | undefined
32
+ sentSize: number
33
+ givenSize: number
34
+ fixedSize: number
35
+ }
36
+
37
+ const COUNT_TOKENS_ATTEMPTS = 3
38
+
39
+ const DROPPED_REQUEST_HEADERS = ["host", "content-length", "accept-encoding"]
40
+
41
+ const clipped = (error: string) => error.slice(0, 500)
42
+
43
+ export function claudeCodeSessionId(headers: Headers, body: Body): string | null {
44
+ const header = headers.get("x-claude-code-session-id")
45
+ if (header) return header
46
+ try {
47
+ return JSON.parse(body.metadata?.user_id ?? "{}").session_id ?? null
48
+ } catch {
49
+ return null
50
+ }
51
+ }
52
+
53
+ const defaultThreadId = (request: Request, body: Body) => request.headers.get("x-thread-id") ?? claudeCodeSessionId(request.headers, body)
54
+
55
+ function forwardableHeaders(headers: Headers) {
56
+ const out = new Headers(headers)
57
+ out.delete("content-encoding")
58
+ out.delete("content-length")
59
+ return out
60
+ }
61
+
62
+ function defaultPrepare(options: ForwarderOptions, upstream: string): NonNullable<ForwarderOptions["prepare"]> {
63
+ const threadIdOf = options.threadIdOf ?? defaultThreadId
64
+ const given = options.window
65
+ const lookup = typeof given === "function" ? given : createWindowLookup()
66
+ const windowFor: (model: unknown, headers: Headers) => Promise<number | null> = typeof given === "number" || given === null ? () => Promise.resolve(given) : (model, headers) => lookup(model, headers, upstream)
67
+ return async (request, body, mayCompact) => {
68
+ const threadId = threadIdOf(request, body)
69
+ const window = await windowFor(body.model, request.headers)
70
+ const sources = threadId ? options.sources?.(threadId, request, body) : undefined
71
+ return rewriteAsync({ body, threadId, config: options.config ?? CORE_DEFAULTS, store: options.store, sources, window, mayCompact }, options.reranker)
72
+ }
73
+ }
74
+
75
+ const passthrough = (bodyText: string | undefined): Outgoing => ({ note: { action: "pass" }, model: undefined, threadKey: null, bodyText, sentSize: 0, givenSize: 0, fixedSize: 0 })
76
+
77
+ async function outgoing(prepare: NonNullable<ForwarderOptions["prepare"]>, request: Request, givenText: string, mayCompact: boolean): Promise<Outgoing> {
78
+ try {
79
+ const given = JSON.parse(givenText) as Body
80
+ const result = await prepare(request, given, mayCompact)
81
+ const sentSize = sizeOf(result.body)
82
+ return {
83
+ note: result.note,
84
+ model: given.model,
85
+ threadKey: result.threadKey,
86
+ bodyText: result.body !== given ? JSON.stringify(result.body, wellFormed) : givenText,
87
+ sentSize,
88
+ givenSize: result.body === given ? sentSize : sizeOf(given),
89
+ fixedSize: sizeOf({ ...result.body, messages: [] }),
90
+ }
91
+ } catch (error) {
92
+ return { ...passthrough(givenText), note: { action: "error-passthrough", error: String(error) } }
93
+ }
94
+ }
95
+
96
+ function createSender(request: Request, target: string, headers: Headers, timeout: number | null, stall: { count: number }) {
97
+ return async (body: string | undefined): Promise<Response> => {
98
+ if (timeout === null) return fetch(target, { method: request.method, headers, body })
99
+ for (;;) {
100
+ try {
101
+ return await fetch(target, { method: request.method, headers, body, signal: AbortSignal.timeout(timeout) })
102
+ } catch (error) {
103
+ const timedOut = error instanceof Error && error.name === "TimeoutError"
104
+ if (!timedOut || stall.count + 1 >= COUNT_TOKENS_ATTEMPTS) throw error
105
+ stall.count += 1
106
+ }
107
+ }
108
+ }
109
+ }
110
+
111
+ function delivered(answer: Response, out: Outgoing, metered: boolean, path: string, log: (entry: Record<string, unknown>) => void, store: ThreadStore): Response {
112
+ const { note, threadKey, sentSize, givenSize, fixedSize } = out
113
+ const response = new Response(answer.body, { status: answer.status, statusText: answer.statusText, headers: forwardableHeaders(answer.headers) })
114
+ if (note.action === "pass" || !metered) {
115
+ if (note.action !== "pass" || Object.keys(note).length > 1) log({ path, status: answer.status, ...note })
116
+ return response
117
+ }
118
+ return watchUsage(response, (usage) => {
119
+ log({
120
+ path,
121
+ status: answer.status,
122
+ thread: threadKey,
123
+ ...note,
124
+ input: inputTokens(usage),
125
+ cacheRead: usage.cache_read_input_tokens ?? 0,
126
+ cacheWrite: usage.cache_creation_input_tokens ?? 0,
127
+ uncached: usage.input_tokens ?? 0,
128
+ charsIn: givenSize,
129
+ charsOut: sentSize,
130
+ charsFixed: fixedSize,
131
+ })
132
+ if (threadKey) recordUsage(store, threadKey, sentSize, usage)
133
+ })
134
+ }
135
+
136
+ export function createForwarder(options: ForwarderOptions): (request: Request) => Promise<Response> {
137
+ const upstream = options.upstream ?? "https://api.anthropic.com"
138
+ const prepare = options.prepare ?? defaultPrepare(options, upstream)
139
+ const timeout = options.countTokensTimeoutMs ?? 10_000
140
+
141
+ async function route(request: Request, began: number): Promise<Response> {
142
+ const stall = { count: 0 }
143
+ let prep = 0
144
+ let upload = 0
145
+ const log = (entry: Record<string, unknown>) => options.record?.({ ...entry, ms: Date.now() - began, prep, upload, ...(stall.count > 0 && { stalled: stall.count }) })
146
+ const url = new URL(request.url)
147
+ const headers = new Headers(request.headers)
148
+ for (const name of DROPPED_REQUEST_HEADERS) headers.delete(name)
149
+
150
+ let givenText: string | undefined
151
+ if (request.method !== "GET" && request.method !== "HEAD") givenText = await request.text()
152
+ upload = Date.now() - began
153
+ const isMessages = request.method === "POST" && url.pathname.startsWith("/v1/messages")
154
+ const isCountTokens = url.pathname.includes("count_tokens")
155
+
156
+ const build = () => (givenText && isMessages ? outgoing(prepare, request, givenText, !isCountTokens) : Promise.resolve(passthrough(givenText)))
157
+ const send = createSender(request, upstream + url.pathname + url.search, headers, isCountTokens ? timeout : null, stall)
158
+ let out = await build()
159
+ prep = Date.now() - began - upload
160
+ let answer = await send(out.bodyText)
161
+ const errorResponse = (text: string) => new Response(text, { status: answer.status, statusText: answer.statusText, headers: forwardableHeaders(answer.headers) })
162
+
163
+ if (answer.status >= 400 && out.note.action !== "pass") {
164
+ const errorText = await answer.text()
165
+ const logged = { path: url.pathname, status: answer.status, thread: out.threadKey, ...out.note }
166
+ const overflow = out.threadKey && !isCountTokens ? recordOverflow(options.store, out.threadKey, out.sentSize, errorText, out.model) : null
167
+ if (!overflow) {
168
+ log({ ...logged, error: clipped(errorText) })
169
+ return errorResponse(errorText)
170
+ }
171
+ log({ ...logged, overflow, error: clipped(errorText) })
172
+ out = await build()
173
+ out = { ...out, note: { ...out.note, healed: overflow } }
174
+ answer = await send(out.bodyText)
175
+ if (answer.status >= 400) {
176
+ const retryError = await answer.text()
177
+ log({ path: url.pathname, status: answer.status, thread: out.threadKey, ...out.note, error: clipped(retryError) })
178
+ return errorResponse(retryError)
179
+ }
180
+ }
181
+
182
+ return delivered(answer, out, isMessages && !isCountTokens, url.pathname, log, options.store)
183
+ }
184
+
185
+ return async (request) => {
186
+ const began = Date.now()
187
+ try {
188
+ return await route(request, began)
189
+ } catch (error) {
190
+ options.record?.({ path: new URL(request.url).pathname, status: 502, action: "upstream-unreachable", error: clipped(String(error)), ms: Date.now() - began })
191
+ return Response.json({ type: "error", error: { type: "api_error", message: `eunoe could not reach the API: ${String(error)}` } }, { status: 502 })
192
+ }
193
+ }
194
+ }
@@ -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"
@@ -0,0 +1,80 @@
1
+ import { type Body, blocks, type Message, resultText } from "../context/content"
2
+ import { isPrompt } from "../context/fold/turns"
3
+ import { livePrompt, RECALL_CHARS_TOTAL, type RecallChunk, type RecallOptions, type Reranker, recallChunks, recallFor, recallForWith, withRecall } from "../context/recall"
4
+ import { messageFingerprint } from "./fingerprint"
5
+ import { activeHandoffs, handoffChunks } from "./handoff"
6
+ import type { Rewrite, RewriteInput } from "./rewrite"
7
+
8
+ const closingTurn: Message = { role: "user", content: "" }
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, threadKey: string): RecallChunk[] {
17
+ const sources = input.sources ?? {}
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 || !sources.readHistory ? own : recallChunks([...sources.readHistory(), closingTurn], kept)
22
+ const withSubagents = sources.listSubagentChunks ? [...session, ...sources.listSubagentChunks()] : session
23
+ return [...handoffChunks(activeHandoffs(input.store.read(threadKey).handoffs), sources), ...withSubagents]
24
+ }
25
+
26
+ function recallTarget(original: Body): { key: string; text: string } | null {
27
+ const live = livePrompt(original.messages)
28
+ return live ? { key: messageFingerprint(original.messages[live.index]), text: live.text } : null
29
+ }
30
+
31
+ function charsInUse(messages: Message[], recalls: Record<string, string>): number {
32
+ return messages.reduce((sum, message) => sum + (isPrompt(message) ? (recalls[messageFingerprint(message)]?.length ?? 0) : 0), 0)
33
+ }
34
+
35
+ const RECALL_SHARE = 0.25
36
+
37
+ const RECALL_FLOOR = 6_000
38
+
39
+ function withinTotal(input: RewriteInput, result: Rewrite, recalls: Record<string, string>): RecallOptions {
40
+ const options = input.config.eval?.recallOptions
41
+ const share = Math.max(RECALL_FLOOR, RECALL_SHARE * JSON.stringify(result.body.messages).length)
42
+ return { ...options, maxCharsTotal: Math.min(options?.maxCharsTotal ?? RECALL_CHARS_TOTAL, share), charsInUse: charsInUse(result.body.messages, recalls) }
43
+ }
44
+
45
+ export function dropLiveRecall(input: RewriteInput, threadKey: string) {
46
+ const target = recallTarget(input.body)
47
+ if (!target) return
48
+ const state = input.store.read(threadKey)
49
+ if (state.recalls?.[target.key] === "") return
50
+ state.recalls = { ...state.recalls, [target.key]: "" }
51
+ input.store.save(threadKey, state)
52
+ }
53
+
54
+ export function applyRecall(input: RewriteInput, result: Rewrite): Rewrite {
55
+ if (!touched(input, result) || !result.threadKey) return result
56
+ const state = input.store.read(result.threadKey)
57
+ state.recalls ??= {}
58
+ const target = recallTarget(input.body)
59
+ let note = result.note
60
+ if (target && !(target.key in state.recalls)) {
61
+ state.recalls[target.key] = recallFor(target.text, droppedChunks(input, result, result.threadKey), withinTotal(input, result, state.recalls)) ?? ""
62
+ input.store.save(result.threadKey, state)
63
+ note = { ...note, recalled: state.recalls[target.key].length }
64
+ }
65
+ const messages = withRecall(result.body.messages, messageFingerprint, activeRecalls(state.recalls))
66
+ return messages === result.body.messages ? { ...result, note } : { ...result, body: { ...result.body, messages }, note }
67
+ }
68
+
69
+ export async function prefetchFor(input: RewriteInput, result: Rewrite, rerank: Reranker): Promise<number | null> {
70
+ if (!touched(input, result) || !result.threadKey) return null
71
+ const target = recallTarget(input.body)
72
+ if (!target) return null
73
+ const state = input.store.read(result.threadKey)
74
+ state.recalls ??= {}
75
+ if (target.key in state.recalls) return null
76
+ const options = { candidates: input.config.eval?.rerank?.candidates, ...withinTotal(input, result, state.recalls) }
77
+ state.recalls[target.key] = (await recallForWith(target.text, droppedChunks(input, result, result.threadKey), rerank, options)) ?? ""
78
+ input.store.save(result.threadKey, state)
79
+ return state.recalls[target.key].length
80
+ }
@@ -0,0 +1,201 @@
1
+ import type { Body, Message } from "../context/content"
2
+ import { appendSystem, tailMessages, trimMessages } from "../context/fold/strategies"
3
+ import { splitTurns, turnFingerprint } from "../context/fold/turns"
4
+ import { SYSTEM_PROMPT, searchOnlyInstructions, trimInstructions } from "../context/prompts"
5
+ import { identityReranker, type RecallChunk, type Reranker, withRecall } from "../context/recall"
6
+ import { compact, type StuckSizes } from "./compaction"
7
+ import type { CoreConfig } from "./config"
8
+ import { DEFAULT_RATIO, estimator, sizeOf, targetSize } from "./estimate"
9
+ import { messageFingerprint } from "./fingerprint"
10
+ import { activeHandoffs, attachHandoffs, withHandoffs } from "./handoff"
11
+ import { activeRecalls, applyRecall, dropLiveRecall, prefetchFor, wantsRecall } from "./recall"
12
+ import type { ThreadStore } from "./state"
13
+ import { limitKey } from "./usage"
14
+
15
+ export interface ThreadSources {
16
+ guide?: string | null
17
+ transcriptDir?: string | null
18
+ readHistory?: () => Message[]
19
+ listSubagentChunks?: () => RecallChunk[]
20
+ findHandoff?: (id: string) => { label: string; source: string } | null
21
+ readHandoff?: (source: string) => Message[]
22
+ }
23
+
24
+ export interface RewriteInput {
25
+ body: Body
26
+ threadId: string | null
27
+ config: CoreConfig
28
+ store: ThreadStore
29
+ sources?: ThreadSources
30
+ window: number | null
31
+ mayCompact: boolean
32
+ omittedBefore?: number
33
+ }
34
+
35
+ export type RewriteNote = {
36
+ action: "pass" | "full" | "compact" | "guide" | "trim" | "tail"
37
+ compacted?: boolean
38
+ actions?: string[]
39
+ cutTurn?: number | null
40
+ omitted?: number
41
+ folded?: number
42
+ pinned?: number
43
+ host?: boolean
44
+ keep?: number
45
+ cleared?: number
46
+ stuck?: StuckSizes
47
+ turns?: number
48
+ keptTurns?: number
49
+ messagesIn?: number
50
+ messagesOut?: number
51
+ estimate?: number
52
+ window?: number | null
53
+ fit?: "recut" | "bare" | "over"
54
+ fitEstimate?: number
55
+ recalled?: number
56
+ handoff?: number
57
+ handoffFrom?: string
58
+ }
59
+
60
+ export interface Rewrite {
61
+ body: Body
62
+ threadKey: string | null
63
+ note: RewriteNote
64
+ }
65
+
66
+ const pass = (body: Body): Rewrite => ({ body, threadKey: null, note: { action: "pass" } })
67
+
68
+ const isAgentThread = (body: Body) => Array.isArray(body.tools) && body.tools.length > 0
69
+
70
+ const withSystemPrompt = (system: Body["system"], extra?: string | null) => appendSystem(extra ? appendSystem(system, extra) : system, SYSTEM_PROMPT)
71
+
72
+ const ratioOf = (input: RewriteInput, threadKey: string) => input.store.read(threadKey).ratio ?? DEFAULT_RATIO
73
+
74
+ export function threadKeyOf(threadId: string, messages: Message[]): string | null {
75
+ const first = splitTurns(messages).turns[0]
76
+ return first ? `${threadId}-${turnFingerprint(first)}` : null
77
+ }
78
+
79
+ function evalStrategy(input: RewriteInput, turns: Message[][], threadKey: string, guide: string | null): Rewrite | null {
80
+ const { body, config } = input
81
+ const strategy = config.eval?.strategy
82
+ if (strategy !== "guide" && strategy !== "trim" && strategy !== "tail") return null
83
+ if (!isAgentThread(body)) return pass(body)
84
+ const counts = { turns: turns.length, messagesIn: body.messages.length }
85
+ if (strategy === "guide") {
86
+ return { body: { ...body, system: withSystemPrompt(body.system, guide ? searchOnlyInstructions(guide) : null) }, threadKey, note: { action: "guide", ...counts } }
87
+ }
88
+ if (strategy === "trim") {
89
+ const messages = trimMessages(body.messages, { evidence: config.eval?.evidence ?? true })
90
+ return { body: { ...body, system: withSystemPrompt(body.system, trimInstructions(guide)), messages }, threadKey, note: { action: "trim", ...counts, messagesOut: messages.length } }
91
+ }
92
+ const based = { ...body, system: withSystemPrompt(body.system) }
93
+ const size = estimator(based, ratioOf(input, threadKey))
94
+ let from = 1
95
+ if (input.window) {
96
+ const target = targetSize(size, input.window)
97
+ from = turns.length - 1
98
+ while (from > 1 && size.of(tailMessages(body.messages, from - 1, guide)) <= target) from -= 1
99
+ }
100
+ const messages = tailMessages(body.messages, from, guide)
101
+ return { body: { ...based, messages }, threadKey, note: { action: "tail", keptTurns: turns.length - from, ...counts, messagesOut: messages.length, estimate: size.of(messages) } }
102
+ }
103
+
104
+ function strategize(input: RewriteInput): Rewrite {
105
+ const { body, threadId, config, store } = input
106
+ const sources = input.sources ?? {}
107
+ if (!threadId || !Array.isArray(body.messages) || body.messages.length === 0) return pass(body)
108
+ const threadKey = threadKeyOf(threadId, body.messages)
109
+ if (!threadKey) return pass(body)
110
+ const off = config.mode === "off"
111
+ const state = store.read(threadKey)
112
+ if (off && !state.host) return pass(body)
113
+ const { turns } = splitTurns(body.messages)
114
+ const guide = sources.guide ?? null
115
+ if (!off && attachHandoffs(body.messages, sources, state)) store.save(threadKey, state)
116
+
117
+ const forced = off ? null : evalStrategy(input, turns, threadKey, guide)
118
+ if (forced) return forced
119
+
120
+ const based = isAgentThread(body) ? { ...body, system: withSystemPrompt(body.system) } : body
121
+ if (body.messages.length < 2) return { body: based, threadKey, note: { action: "full", turns: turns.length } }
122
+ const handoffs = activeHandoffs(state.handoffs)
123
+ const recalling = wantsRecall(input)
124
+ const result = compact({
125
+ body: based,
126
+ config,
127
+ state,
128
+ window: input.window,
129
+ guide,
130
+ transcriptDir: sources.transcriptDir ?? null,
131
+ mayCompact: input.mayCompact && !off,
132
+ omittedBefore: input.omittedBefore,
133
+ decorate: (messages) => withRecall(withHandoffs(messages, handoffs), messageFingerprint, recalling ? activeRecalls(state.recalls) : {}),
134
+ })
135
+ if (result.stateChanged) store.save(threadKey, state)
136
+ return { body: result.messages === body.messages ? based : { ...based, messages: result.messages }, threadKey, note: result.note }
137
+ }
138
+
139
+ function withAttachedHandoffs(input: RewriteInput, result: Rewrite): Rewrite {
140
+ if (!result.threadKey) return result
141
+ const handoffs = activeHandoffs(input.store.read(result.threadKey).handoffs)
142
+ const messages = withHandoffs(result.body.messages, handoffs)
143
+ if (messages === result.body.messages) return result
144
+ const attached = Object.values(handoffs)
145
+ const note = { ...result.note, handoff: attached.reduce((sum, h) => sum + h.text.length, 0), handoffFrom: attached.map((h) => h.label).join(", ") }
146
+ return { ...result, body: { ...result.body, messages }, note }
147
+ }
148
+
149
+ function once(input: RewriteInput): Rewrite {
150
+ const result = withAttachedHandoffs(input, strategize(input))
151
+ return wantsRecall(input) ? applyRecall(input, result) : result
152
+ }
153
+
154
+ function sentEstimate(input: RewriteInput, result: Rewrite): number {
155
+ return Math.round(sizeOf(result.body) * (result.threadKey ? ratioOf(input, result.threadKey) : DEFAULT_RATIO))
156
+ }
157
+
158
+ function overLimit(input: RewriteInput, result: Rewrite): boolean {
159
+ if (!input.window || !result.threadKey || input.mayCompact === false || result.note.action === "pass") return false
160
+ return sentEstimate(input, result) > input.config.compactAt * input.window
161
+ }
162
+
163
+ function carried(from: Rewrite, to: Rewrite): Rewrite {
164
+ const actions = [...(from.note.actions ?? []), ...(to.note.actions ?? [])]
165
+ if (!from.note.compacted && actions.length === 0) return to
166
+ return { ...to, note: { ...to.note, compacted: Boolean(to.note.compacted || from.note.compacted), actions } }
167
+ }
168
+
169
+ function withLearnedLimit(input: RewriteInput): RewriteInput {
170
+ const threadKey = input.threadId && Array.isArray(input.body.messages) ? threadKeyOf(input.threadId, input.body.messages) : null
171
+ const limit = threadKey ? input.store.read(threadKey).limits?.[limitKey(input.body.model)] : undefined
172
+ if (!limit || (input.window !== null && input.window <= limit)) return input
173
+ return { ...input, window: limit }
174
+ }
175
+
176
+ export function rewrite(given: RewriteInput, before?: Rewrite): Rewrite {
177
+ const input = withLearnedLimit(given)
178
+ const result = before ? carried(before, once(input)) : once(input)
179
+ if (!overLimit(input, result)) return result
180
+ const recut = carried(result, once(input))
181
+ if (!overLimit(input, recut)) return { ...recut, note: { ...recut.note, fit: "recut" } }
182
+ if (recut.threadKey && wantsRecall(input)) dropLiveRecall(input, recut.threadKey)
183
+ const bare = carried(recut, once(input))
184
+ return { ...bare, note: { ...bare.note, fit: overLimit(input, bare) ? "over" : "bare", fitEstimate: sentEstimate(input, bare) } }
185
+ }
186
+
187
+ export async function prefetchRecall(given: RewriteInput, rerank: Reranker): Promise<{ reranked: boolean; rerankError: null; recalled: number } | null> {
188
+ const input = withLearnedLimit(given)
189
+ if (!wantsRecall(input)) return null
190
+ const recalled = await prefetchFor(input, strategize(input), rerank)
191
+ return recalled === null ? null : { reranked: rerank !== identityReranker, rerankError: null, recalled }
192
+ }
193
+
194
+ export async function rewriteAsync(given: RewriteInput, rerank: Reranker = identityReranker): Promise<Rewrite> {
195
+ const input = withLearnedLimit(given)
196
+ if (!wantsRecall(input) || rerank === identityReranker) return rewrite(input)
197
+ const first = strategize(input)
198
+ const recalled = await prefetchFor(input, first, rerank)
199
+ const final = rewrite(input, first)
200
+ return recalled === null ? final : { ...final, note: { ...final.note, recalled } }
201
+ }