@chroxify/eunoe 1.2.2 → 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 (63) hide show
  1. package/README.md +139 -2
  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/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 +55 -10
  37. package/src/context/fold/turns.ts +22 -2
  38. package/src/context/prompts.ts +4 -3
  39. package/src/context/recall/index.ts +2 -3
  40. package/src/context/recall/rerank.ts +0 -8
  41. package/src/{proxy → core}/compaction.ts +56 -24
  42. package/src/core/config.ts +31 -0
  43. package/src/core/fold.ts +69 -0
  44. package/src/core/forward.ts +192 -0
  45. package/src/{proxy → core}/handoff.ts +23 -23
  46. package/src/core/index.ts +14 -0
  47. package/src/{proxy → core}/recall.ts +18 -20
  48. package/src/core/rewrite.ts +189 -0
  49. package/src/core/state.ts +40 -0
  50. package/src/core/subagents.ts +28 -0
  51. package/src/core/transcript.ts +44 -0
  52. package/src/{proxy → core}/usage.ts +34 -1
  53. package/src/core/window.ts +39 -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 → core}/estimate.ts +0 -0
  63. /package/src/{proxy → core}/fingerprint.ts +0 -0
@@ -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
  }
@@ -0,0 +1,189 @@
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, prefetchFor, wantsRecall } from "./recall"
12
+ import type { ThreadStore } from "./state"
13
+
14
+ export interface ThreadSources {
15
+ guide?: string | null
16
+ transcriptDir?: string | null
17
+ readHistory?: () => Message[]
18
+ listSubagentChunks?: () => RecallChunk[]
19
+ findHandoff?: (id: string) => { label: string; source: string } | null
20
+ readHandoff?: (source: string) => Message[]
21
+ }
22
+
23
+ export interface RewriteInput {
24
+ body: Body
25
+ threadId: string | null
26
+ config: CoreConfig
27
+ store: ThreadStore
28
+ sources?: ThreadSources
29
+ window: number | null
30
+ mayCompact: boolean
31
+ omittedBefore?: number
32
+ }
33
+
34
+ export type RewriteNote = {
35
+ action: "pass" | "full" | "compact" | "guide" | "trim" | "tail"
36
+ compacted?: boolean
37
+ actions?: string[]
38
+ cutTurn?: number | null
39
+ omitted?: number
40
+ folded?: number
41
+ pinned?: number
42
+ host?: boolean
43
+ keep?: number
44
+ cleared?: number
45
+ stuck?: StuckSizes
46
+ turns?: number
47
+ keptTurns?: number
48
+ messagesIn?: number
49
+ messagesOut?: number
50
+ estimate?: number
51
+ window?: number | null
52
+ fit?: "recut" | "bare" | "over"
53
+ fitEstimate?: number
54
+ recalled?: number
55
+ handoff?: number
56
+ handoffFrom?: string
57
+ }
58
+
59
+ export interface Rewrite {
60
+ body: Body
61
+ threadKey: string | null
62
+ note: RewriteNote
63
+ }
64
+
65
+ const pass = (body: Body): Rewrite => ({ body, threadKey: null, note: { action: "pass" } })
66
+
67
+ const isAgentThread = (body: Body) => Array.isArray(body.tools) && body.tools.length > 0
68
+
69
+ const withSystemPrompt = (system: Body["system"], extra?: string | null) => appendSystem(extra ? appendSystem(system, extra) : system, SYSTEM_PROMPT)
70
+
71
+ const ratioOf = (input: RewriteInput, threadKey: string) => input.store.read(threadKey).ratio ?? DEFAULT_RATIO
72
+
73
+ export function threadKeyOf(threadId: string, messages: Message[]): string | null {
74
+ const first = splitTurns(messages).turns[0]
75
+ return first ? `${threadId}-${turnFingerprint(first)}` : null
76
+ }
77
+
78
+ function evalStrategy(input: RewriteInput, turns: Message[][], threadKey: string, guide: string | null): Rewrite | null {
79
+ const { body, config } = input
80
+ const strategy = config.eval?.strategy
81
+ if (strategy !== "guide" && strategy !== "trim" && strategy !== "tail") return null
82
+ if (!isAgentThread(body)) return pass(body)
83
+ const counts = { turns: turns.length, messagesIn: body.messages.length }
84
+ if (strategy === "guide") {
85
+ return { body: { ...body, system: withSystemPrompt(body.system, guide ? searchOnlyInstructions(guide) : null) }, threadKey, note: { action: "guide", ...counts } }
86
+ }
87
+ if (strategy === "trim") {
88
+ const messages = trimMessages(body.messages, { evidence: config.eval?.evidence ?? true })
89
+ return { body: { ...body, system: withSystemPrompt(body.system, trimInstructions(guide)), messages }, threadKey, note: { action: "trim", ...counts, messagesOut: messages.length } }
90
+ }
91
+ const based = { ...body, system: withSystemPrompt(body.system) }
92
+ const size = estimator(based, ratioOf(input, threadKey))
93
+ let from = 1
94
+ if (input.window) {
95
+ const target = targetSize(size, input.window)
96
+ from = turns.length - 1
97
+ while (from > 1 && size.of(tailMessages(body.messages, from - 1, guide)) <= target) from -= 1
98
+ }
99
+ const messages = tailMessages(body.messages, from, guide)
100
+ return { body: { ...based, messages }, threadKey, note: { action: "tail", keptTurns: turns.length - from, ...counts, messagesOut: messages.length, estimate: size.of(messages) } }
101
+ }
102
+
103
+ function strategize(input: RewriteInput): Rewrite {
104
+ const { body, threadId, config, store } = input
105
+ const sources = input.sources ?? {}
106
+ if (!threadId || !Array.isArray(body.messages) || body.messages.length === 0) return pass(body)
107
+ const threadKey = threadKeyOf(threadId, body.messages)
108
+ if (!threadKey) return pass(body)
109
+ const off = config.mode === "off"
110
+ const state = store.read(threadKey)
111
+ if (off && !state.host) return pass(body)
112
+ const { turns } = splitTurns(body.messages)
113
+ const guide = sources.guide ?? null
114
+ if (!off && attachHandoffs(body.messages, sources, state)) store.save(threadKey, state)
115
+
116
+ const forced = off ? null : evalStrategy(input, turns, threadKey, guide)
117
+ if (forced) return forced
118
+
119
+ const based = isAgentThread(body) ? { ...body, system: withSystemPrompt(body.system) } : body
120
+ if (body.messages.length < 2) return { body: based, threadKey, note: { action: "full", turns: turns.length } }
121
+ const handoffs = activeHandoffs(state.handoffs)
122
+ const recalls = wantsRecall(input) ? activeRecalls(state.recalls) : {}
123
+ const result = compact({
124
+ body: based,
125
+ config,
126
+ state,
127
+ window: input.window,
128
+ guide,
129
+ transcriptDir: sources.transcriptDir ?? null,
130
+ mayCompact: input.mayCompact && !off,
131
+ omittedBefore: input.omittedBefore,
132
+ decorate: (messages) => withRecall(withHandoffs(messages, handoffs), messageFingerprint, recalls),
133
+ })
134
+ if (result.stateChanged) store.save(threadKey, state)
135
+ return { body: result.messages === body.messages ? based : { ...based, messages: result.messages }, threadKey, note: result.note }
136
+ }
137
+
138
+ function withAttachedHandoffs(input: RewriteInput, result: Rewrite): Rewrite {
139
+ if (!result.threadKey) return result
140
+ const handoffs = activeHandoffs(input.store.read(result.threadKey).handoffs)
141
+ const messages = withHandoffs(result.body.messages, handoffs)
142
+ if (messages === result.body.messages) return result
143
+ const attached = Object.values(handoffs)
144
+ const note = { ...result.note, handoff: attached.reduce((sum, h) => sum + h.text.length, 0), handoffFrom: attached.map((h) => h.label).join(", ") }
145
+ return { ...result, body: { ...result.body, messages }, note }
146
+ }
147
+
148
+ function once(input: RewriteInput): Rewrite {
149
+ const result = withAttachedHandoffs(input, strategize(input))
150
+ return wantsRecall(input) ? applyRecall(input, result) : result
151
+ }
152
+
153
+ function sentEstimate(input: RewriteInput, result: Rewrite): number {
154
+ return Math.round(sizeOf(result.body) * (result.threadKey ? ratioOf(input, result.threadKey) : DEFAULT_RATIO))
155
+ }
156
+
157
+ function overLimit(input: RewriteInput, result: Rewrite): boolean {
158
+ if (!input.window || !result.threadKey || input.mayCompact === false || result.note.action === "pass") return false
159
+ return sentEstimate(input, result) > input.config.compactAt * input.window
160
+ }
161
+
162
+ function carried(from: Rewrite, to: Rewrite): Rewrite {
163
+ const actions = [...(from.note.actions ?? []), ...(to.note.actions ?? [])]
164
+ if (!from.note.compacted && actions.length === 0) return to
165
+ return { ...to, note: { ...to.note, compacted: Boolean(to.note.compacted || from.note.compacted), actions } }
166
+ }
167
+
168
+ export function rewrite(input: RewriteInput, before?: Rewrite): Rewrite {
169
+ const result = before ? carried(before, once(input)) : once(input)
170
+ if (!overLimit(input, result)) return result
171
+ const recut = carried(result, once(input))
172
+ if (!overLimit(input, recut)) return { ...recut, note: { ...recut.note, fit: "recut" } }
173
+ const bare = carried(recut, once({ ...input, config: { ...input.config, eval: { ...input.config.eval, recall: false } } }))
174
+ return { ...bare, note: { ...bare.note, fit: overLimit(input, bare) ? "over" : "bare", fitEstimate: sentEstimate(input, bare) } }
175
+ }
176
+
177
+ export async function prefetchRecall(input: RewriteInput, rerank: Reranker): Promise<{ reranked: boolean; rerankError: null; recalled: number } | null> {
178
+ if (!wantsRecall(input)) return null
179
+ const recalled = await prefetchFor(input, strategize(input), rerank)
180
+ return recalled === null ? null : { reranked: rerank !== identityReranker, rerankError: null, recalled }
181
+ }
182
+
183
+ export async function rewriteAsync(input: RewriteInput, rerank: Reranker = identityReranker): Promise<Rewrite> {
184
+ if (!wantsRecall(input) || rerank === identityReranker) return rewrite(input)
185
+ const first = strategize(input)
186
+ const recalled = await prefetchFor(input, first, rerank)
187
+ const final = rewrite(input, first)
188
+ return recalled === null ? final : { ...final, note: { ...final.note, recalled } }
189
+ }
@@ -0,0 +1,40 @@
1
+ export interface Handoff {
2
+ label: string
3
+ source: string
4
+ text: string
5
+ }
6
+
7
+ export interface ThreadState {
8
+ handoffs?: Record<string, Handoff | null>
9
+ cut?: string
10
+ keep?: number
11
+ cleared?: number
12
+ stuck?: number
13
+ host?: boolean
14
+ pins?: string[]
15
+ ratio?: number
16
+ recalls?: Record<string, string>
17
+ }
18
+
19
+ export interface ThreadStore {
20
+ read: (key: string) => ThreadState
21
+ save: (key: string, state: ThreadState) => void
22
+ }
23
+
24
+ export function memoryStore(initial: Record<string, ThreadState> = {}): ThreadStore {
25
+ const threads = new Map(Object.entries(initial).map(([key, state]) => [key, structuredClone(state)]))
26
+ return {
27
+ read: (key) => structuredClone(threads.get(key) ?? {}),
28
+ save: (key, state) => {
29
+ threads.set(key, structuredClone(state))
30
+ },
31
+ }
32
+ }
33
+
34
+ export function resetCut(state: ThreadState) {
35
+ delete state.cut
36
+ delete state.keep
37
+ delete state.cleared
38
+ delete state.stuck
39
+ delete state.host
40
+ }
@@ -0,0 +1,28 @@
1
+ import { blocks, type Message } from "../context/content"
2
+ import { splitTurns } from "../context/fold/turns"
3
+ import { type RecallChunk, recallChunks } from "../context/recall"
4
+
5
+ export interface Subagent {
6
+ description: string
7
+ messages: Message[]
8
+ toolUseId?: string
9
+ }
10
+
11
+ const closingTurn: Message = { role: "user", content: "" }
12
+
13
+ export function launchTurnOf(parentMessages: Message[], toolUseId: string | undefined): number {
14
+ if (!toolUseId) return 0
15
+ const index = splitTurns(parentMessages).turns.findIndex((turn) => turn.some((message) => message.role === "assistant" && blocks(message).some((b) => b.type === "tool_use" && b.id === toolUseId)))
16
+ return index + 1
17
+ }
18
+
19
+ const subagentLabel = (description: string) => `subagent "${description}"`
20
+
21
+ export function subagentRecall(messages: Message[], description: string, parentTurn: number): RecallChunk[] {
22
+ const from = subagentLabel(description)
23
+ return recallChunks([...messages, closingTurn], new Map()).map((chunk) => ({ ...chunk, from, turn: parentTurn }))
24
+ }
25
+
26
+ export function listSubagentRecall(parentMessages: Message[], subagents: Subagent[]): RecallChunk[] {
27
+ return subagents.flatMap((subagent) => subagentRecall(subagent.messages, subagent.description, launchTurnOf(parentMessages, subagent.toolUseId)))
28
+ }
@@ -0,0 +1,44 @@
1
+ import { blocks, type Message } from "../context/content"
2
+ import { isPrompt } from "../context/fold/turns"
3
+ import { searchGuide } from "../context/prompts"
4
+ import { renderIndex, renderMarkdown, renderSubagentMarkdown, renderSubagentXml, renderXml, type SubagentRecord, subagentFile, turnFile } from "../transcript/format"
5
+ import { parseTurns, type SessionEntry, type TranscriptFormat, type Turn } from "../transcript/parse"
6
+ import { launchTurnOf, type Subagent } from "./subagents"
7
+
8
+ export interface TranscriptOptions {
9
+ format?: TranscriptFormat
10
+ subagents?: Subagent[]
11
+ }
12
+
13
+ const isNote = (message: Message) => message.role === "user" && !isPrompt(message) && !blocks(message).some((b) => b.type === "tool_result")
14
+
15
+ const turnsOf = (messages: Message[], sidechain: boolean | "any" = false): Turn[] =>
16
+ parseTurns(
17
+ messages.map((message): SessionEntry => ({ type: message.role, isMeta: isNote(message), message })),
18
+ sidechain,
19
+ )
20
+
21
+ function subagentRecords(messages: Message[], turns: Turn[], subagents: Subagent[], format: TranscriptFormat): SubagentRecord[] {
22
+ const perTurn = new Map<number, number>()
23
+ return subagents.map((subagent) => {
24
+ const parentTurn = launchTurnOf(messages, subagent.toolUseId)
25
+ const k = (perTurn.get(parentTurn) ?? 0) + 1
26
+ perTurn.set(parentTurn, k)
27
+ const file = subagentFile(parentTurn, k, format)
28
+ const launcher = turns[parentTurn - 1]?.parts.find((part) => part.kind === "tool" && part.id === subagent.toolUseId)
29
+ if (launcher?.kind === "tool") launcher.record = file
30
+ return { run: { description: subagent.description }, turns: turnsOf(subagent.messages, "any"), parentTurn, file }
31
+ })
32
+ }
33
+
34
+ export function renderTranscriptFiles(messages: Message[], { format = "markdown", subagents = [] }: TranscriptOptions = {}): Record<string, string> {
35
+ const turns = turnsOf(messages)
36
+ const records = subagentRecords(messages, turns, subagents, format)
37
+ const files: Record<string, string> = {}
38
+ for (const turn of turns) files[turnFile(turn, format)] = format === "xml" ? renderXml(turn) : renderMarkdown(turn)
39
+ for (const record of records) files[record.file] = format === "xml" ? renderSubagentXml(record) : renderSubagentMarkdown(record)
40
+ files["index.md"] = renderIndex(turns, format, records)
41
+ return files
42
+ }
43
+
44
+ export const transcriptGuide = (dir: string, format: TranscriptFormat = "markdown") => searchGuide({ mode: format, dir, jsonl: "" })