@chroxify/eunoe 1.2.1 → 1.2.2

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.
package/README.md CHANGED
@@ -407,9 +407,10 @@ Recall works out of the box, but it gets noticeably better with
407
407
  [Jev](https://typesafe.ai) reordering the candidates. To turn that on, put a
408
408
  Typesafe API key in `~/.eunoe/typesafe-key` or in the `TYPESAFE_API_KEY`
409
409
  environment variable, and `eunoe status` will show the reranker as active.
410
- It can't slow anything down, because if Jev errors or takes longer than four
411
- seconds the recall simply uses the plain keyword order instead. To switch it
412
- off, run `eunoe settings set rerank off`.
410
+ The cost is time: the first request of each new message waits for Jev, about
411
+ 300 ms and at most four seconds, after which the recall falls back to the
412
+ plain keyword order. Every other request is unaffected. To switch it off, run
413
+ `eunoe settings set rerank off`.
413
414
 
414
415
  ### Logs
415
416
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chroxify/eunoe",
3
- "version": "1.2.1",
3
+ "version": "1.2.2",
4
4
  "description": "Context management for Claude Code that never forgets what matters.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,5 +1,6 @@
1
1
  import { readConfig } from "../../config"
2
2
  import { serve as startProxy } from "../../proxy/server"
3
+ import { warmRecentSessions } from "../../proxy/warm"
3
4
  import { VERSION } from "../../version"
4
5
  import type { Command } from "../command"
5
6
  import { proxyUrl } from "../proxy/claude-settings"
@@ -30,6 +31,7 @@ export const serve: Command = {
30
31
  process.on("uncaughtException", keepGoing)
31
32
  process.on("unhandledRejection", keepGoing)
32
33
  await bind(config.port)
34
+ if (config.mode !== "off") void warmRecentSessions(config).catch(keepGoing)
33
35
  line(`${c.bold("eunoe")} ${c.dim(VERSION)} listening on ${c.cyan(proxyUrl(config.port))} ${c.dim(`mode ${config.mode} · Ctrl-C to stop`)}`)
34
36
  },
35
37
  }
@@ -50,6 +50,50 @@ function windows(lines: string[], size: number): string[][] {
50
50
  return out
51
51
  }
52
52
 
53
+ interface ChunkedText {
54
+ text: string
55
+ head: string
56
+ turn: number
57
+ chunks: RecallChunk[]
58
+ }
59
+
60
+ const CHUNK_CACHE_TERMS = 8_000_000
61
+
62
+ const chunked = new Map<string, ChunkedText>()
63
+ let chunkedTerms = 0
64
+
65
+ const termCount = (chunks: RecallChunk[]) => chunks.reduce((sum, chunk) => sum + chunk.terms.length, 0)
66
+
67
+ function forget(key: string) {
68
+ const known = chunked.get(key)
69
+ if (!known) return
70
+ chunked.delete(key)
71
+ chunkedTerms -= termCount(known.chunks)
72
+ }
73
+
74
+ function chunksOf(key: string | null, text: string, head: string, turn: number, build: () => RecallChunk[]): RecallChunk[] {
75
+ if (key === null) return build()
76
+ const known = chunked.get(key)
77
+ if (known && known.text === text && known.head === head) {
78
+ chunked.delete(key)
79
+ chunked.set(key, known)
80
+ if (known.turn !== turn) {
81
+ known.turn = turn
82
+ known.chunks = known.chunks.map((chunk) => ({ ...chunk, turn }))
83
+ }
84
+ return known.chunks
85
+ }
86
+ forget(key)
87
+ const chunks = build()
88
+ chunked.set(key, { text, head, turn, chunks })
89
+ chunkedTerms += termCount(chunks)
90
+ for (const oldest of chunked.keys()) {
91
+ if (chunkedTerms <= CHUNK_CACHE_TERMS || oldest === key) break
92
+ forget(oldest)
93
+ }
94
+ return chunks
95
+ }
96
+
53
97
  export function recallChunks(messages: Message[], keptIds: Map<string, string>, options: RecallOptions = {}): RecallChunk[] {
54
98
  const size = options.windowLines ?? 6
55
99
  const weight = Math.max(1, options.callWeight ?? 1)
@@ -61,8 +105,12 @@ export function recallChunks(messages: Message[], keptIds: Map<string, string>,
61
105
  const text = resultText(result)
62
106
  if (keptIds.get(call.id) === text || !text.trim()) continue
63
107
  const head = describeToolCall(call)
64
- const headTerms = Array.from({ length: weight }, () => terms(head)).flat()
65
- for (const window of windows(text.split("\n"), size)) chunks.push({ turn: turnIndex + 1, call: head, lines: window, terms: [...headTerms, ...terms(window.join("\n"))] })
108
+ const key = typeof call.id === "string" && call.id ? `${size}|${weight}|${call.id}` : null
109
+ const built = chunksOf(key, text, head, turnIndex + 1, () => {
110
+ const headTerms = Array.from({ length: weight }, () => terms(head)).flat()
111
+ return windows(text.split("\n"), size).map((window) => ({ turn: turnIndex + 1, call: head, lines: window, terms: [...headTerms, ...terms(window.join("\n"))] }))
112
+ })
113
+ for (const chunk of built) chunks.push(chunk)
66
114
  }
67
115
  if (!(options.narrative ?? true)) return
68
116
  const said = turn
@@ -74,34 +122,215 @@ export function recallChunks(messages: Message[], keptIds: Map<string, string>,
74
122
  .map((b) => String(b.text ?? "").trim()),
75
123
  )
76
124
  .filter(Boolean)
77
- for (const window of windows(said.join("\n").split("\n"), size)) chunks.push({ turn: turnIndex + 1, call: "agent", lines: window, terms: terms(window.join("\n")) })
125
+ .join("\n")
126
+ const built = chunksOf(said ? `${size}|said|${turnIndex}|${said}` : null, said, "agent", turnIndex + 1, () =>
127
+ windows(said.split("\n"), size).map((window) => ({ turn: turnIndex + 1, call: "agent", lines: window, terms: terms(window.join("\n")) })),
128
+ )
129
+ for (const chunk of built) chunks.push(chunk)
78
130
  })
79
131
  return chunks
80
132
  }
81
133
 
82
- export function rank(query: string[], chunks: RecallChunk[], minTerms = 2) {
83
- const df = new Map<string, number>()
84
- for (const chunk of chunks) for (const term of new Set(chunk.terms)) df.set(term, (df.get(term) ?? 0) + 1)
85
- const average = chunks.reduce((s, c) => s + c.terms.length, 0) / Math.max(1, chunks.length)
86
- const wanted = [...new Set(query)].filter((t) => df.has(t))
87
- return chunks
88
- .map((chunk, index) => {
89
- const tf = new Map<string, number>()
90
- for (const term of chunk.terms) tf.set(term, (tf.get(term) ?? 0) + 1)
134
+ interface RankedChunk {
135
+ index: number
136
+ score: number
137
+ matched: number
138
+ }
139
+
140
+ interface Postings {
141
+ chunks: RecallChunk[]
142
+ counts: number[]
143
+ }
144
+
145
+ interface CorpusIndex {
146
+ members: Set<RecallChunk>
147
+ postings: Map<string, Postings>
148
+ }
149
+
150
+ const INDEXED_FROM = 2_000
151
+ const KEPT_INDEXES = 6
152
+ const SAMPLED = 32
153
+
154
+ const indexes: CorpusIndex[] = []
155
+
156
+ function addToIndex(index: CorpusIndex, chunk: RecallChunk) {
157
+ index.members.add(chunk)
158
+ const counts = new Map<string, number>()
159
+ for (const term of chunk.terms) counts.set(term, (counts.get(term) ?? 0) + 1)
160
+ for (const [term, count] of counts) {
161
+ const postings = index.postings.get(term)
162
+ if (postings) {
163
+ postings.chunks.push(chunk)
164
+ postings.counts.push(count)
165
+ } else index.postings.set(term, { chunks: [chunk], counts: [count] })
166
+ }
167
+ }
168
+
169
+ function overlap(index: CorpusIndex, chunks: RecallChunk[]): number {
170
+ const step = Math.max(1, Math.floor(chunks.length / SAMPLED))
171
+ let found = 0
172
+ let sampled = 0
173
+ for (let i = 0; i < chunks.length; i += step) {
174
+ sampled += 1
175
+ if (index.members.has(chunks[i])) found += 1
176
+ }
177
+ return found / Math.max(1, sampled)
178
+ }
179
+
180
+ function indexFor(chunks: RecallChunk[]): CorpusIndex {
181
+ let best: CorpusIndex | null = null
182
+ let bestOverlap = 0.5
183
+ for (const index of indexes) {
184
+ const shared = overlap(index, chunks)
185
+ if (shared >= bestOverlap) {
186
+ best = index
187
+ bestOverlap = shared
188
+ }
189
+ }
190
+ let present = 0
191
+ if (best) for (const chunk of chunks) if (best.members.has(chunk)) present += 1
192
+ if (!best || best.members.size - present > chunks.length / 2) {
193
+ if (best) indexes.splice(indexes.indexOf(best), 1)
194
+ best = { members: new Set(), postings: new Map() }
195
+ present = 0
196
+ } else indexes.splice(indexes.indexOf(best), 1)
197
+ indexes.push(best)
198
+ if (indexes.length > KEPT_INDEXES) indexes.shift()
199
+ if (present < chunks.length) for (const chunk of chunks) if (!best.members.has(chunk)) addToIndex(best, chunk)
200
+ return best
201
+ }
202
+
203
+ let warming: { chunks: RecallChunk[]; index: CorpusIndex; next: number } | null = null
204
+
205
+ export function warmIndex(chunks: RecallChunk[], until: number): boolean {
206
+ if (chunks.length < INDEXED_FROM) return true
207
+ if (warming?.chunks !== chunks) {
208
+ const index: CorpusIndex = { members: new Set(), postings: new Map() }
209
+ indexes.push(index)
210
+ if (indexes.length > KEPT_INDEXES) indexes.shift()
211
+ warming = { chunks, index, next: 0 }
212
+ }
213
+ const { index } = warming
214
+ while (warming.next < chunks.length) {
215
+ const chunk = chunks[warming.next]
216
+ warming.next += 1
217
+ if (!index.members.has(chunk)) addToIndex(index, chunk)
218
+ if (warming.next < chunks.length && performance.now() > until) return false
219
+ }
220
+ warming = null
221
+ return true
222
+ }
223
+
224
+ function indexedAll(queries: string[][], chunks: RecallChunk[], minTerms: number): RankedChunk[][] | null {
225
+ const position = new Map<RecallChunk, number>()
226
+ let total = 0
227
+ chunks.forEach((chunk, i) => {
228
+ position.set(chunk, i)
229
+ total += chunk.terms.length
230
+ })
231
+ if (position.size !== chunks.length) return null
232
+ const index = indexFor(chunks)
233
+ const average = total / Math.max(1, chunks.length)
234
+ const hitsByTerm = new Map<string, Array<[number, number]>>()
235
+ const hitsOf = (term: string) => {
236
+ const known = hitsByTerm.get(term)
237
+ if (known) return known
238
+ const hits: Array<[number, number]> = []
239
+ const postings = index.postings.get(term)
240
+ if (postings)
241
+ postings.chunks.forEach((chunk, k) => {
242
+ const at = position.get(chunk)
243
+ if (at !== undefined) hits.push([at, postings.counts[k]])
244
+ })
245
+ hitsByTerm.set(term, hits)
246
+ return hits
247
+ }
248
+ return queries.map((query) => {
249
+ const lists = [...new Set(query)].map(hitsOf)
250
+ const needed = Math.max(1, Math.min(minTerms, lists.filter((hits) => hits.length > 0).length))
251
+ const found = new Map<number, Array<[number, number]>>()
252
+ lists.forEach((hits, k) => {
253
+ for (const [at, f] of hits) {
254
+ const mine = found.get(at)
255
+ if (mine) mine.push([k, f])
256
+ else found.set(at, [[k, f]])
257
+ }
258
+ })
259
+ const scored: RankedChunk[] = []
260
+ for (const at of [...found.keys()].sort((a, b) => a - b)) {
261
+ const mine = found.get(at) as Array<[number, number]>
262
+ if (mine.length < needed) continue
263
+ const length = chunks[at].terms.length
91
264
  let score = 0
92
- let matched = 0
93
- for (const term of wanted) {
94
- const f = tf.get(term) ?? 0
95
- if (!f) continue
96
- matched += 1
97
- const n = df.get(term) ?? 0
265
+ for (const [k, f] of mine) {
266
+ const n = lists[k].length
98
267
  const idf = Math.log(1 + (chunks.length - n + 0.5) / (n + 0.5))
99
- score += (idf * (f * (BM25_K1 + 1))) / (f + BM25_K1 * (1 - BM25_B + (BM25_B * chunk.terms.length) / average))
268
+ score += (idf * (f * (BM25_K1 + 1))) / (f + BM25_K1 * (1 - BM25_B + (BM25_B * length) / average))
100
269
  }
101
- return { index, score, matched }
270
+ if (score > 0) scored.push({ index: at, score, matched: mine.length })
271
+ }
272
+ return scored.sort((a, b) => b.score - a.score)
273
+ })
274
+ }
275
+
276
+ function rankAll(queries: string[][], chunks: RecallChunk[], minTerms = 2): RankedChunk[][] {
277
+ return (chunks.length >= INDEXED_FROM && indexedAll(queries, chunks, minTerms)) || scanAll(queries, chunks, minTerms)
278
+ }
279
+
280
+ function scanAll(queries: string[][], chunks: RecallChunk[], minTerms: number): RankedChunk[][] {
281
+ const ids = new Map<string, number>()
282
+ for (const query of queries) for (const term of query) if (!ids.has(term)) ids.set(term, ids.size)
283
+ const df = new Int32Array(ids.size)
284
+ const counts = new Int32Array(ids.size)
285
+ const touched: number[] = []
286
+ const hits: Array<{ index: number; found: Array<[number, number]> }> = []
287
+ let total = 0
288
+ chunks.forEach((chunk, index) => {
289
+ total += chunk.terms.length
290
+ touched.length = 0
291
+ for (const term of chunk.terms) {
292
+ const id = ids.get(term)
293
+ if (id === undefined) continue
294
+ if (counts[id] === 0) touched.push(id)
295
+ counts[id] += 1
296
+ }
297
+ if (touched.length === 0) return
298
+ const found = touched.map((id): [number, number] => {
299
+ const f = counts[id]
300
+ counts[id] = 0
301
+ df[id] += 1
302
+ return [id, f]
102
303
  })
103
- .filter((r) => r.matched >= Math.min(minTerms, wanted.length) && r.score > 0)
104
- .sort((a, b) => b.score - a.score)
304
+ hits.push({ index, found })
305
+ })
306
+ const average = total / Math.max(1, chunks.length)
307
+ return queries.map((query) => {
308
+ const asked = [...new Set(query)].map((term) => ids.get(term) ?? -1)
309
+ const position = new Int32Array(ids.size).fill(-1)
310
+ asked.forEach((id, i) => {
311
+ position[id] = i
312
+ })
313
+ const needed = Math.max(1, Math.min(minTerms, asked.filter((id) => df[id] > 0).length))
314
+ const scored: RankedChunk[] = []
315
+ for (const { index, found } of hits) {
316
+ const mine = found.filter(([id]) => position[id] >= 0)
317
+ if (mine.length < needed) continue
318
+ if (mine.length > 1) mine.sort((a, b) => position[a[0]] - position[b[0]])
319
+ const length = chunks[index].terms.length
320
+ let score = 0
321
+ for (const [id, f] of mine) {
322
+ const n = df[id]
323
+ const idf = Math.log(1 + (chunks.length - n + 0.5) / (n + 0.5))
324
+ score += (idf * (f * (BM25_K1 + 1))) / (f + BM25_K1 * (1 - BM25_B + (BM25_B * length) / average))
325
+ }
326
+ if (score > 0) scored.push({ index, score, matched: mine.length })
327
+ }
328
+ return scored.sort((a, b) => b.score - a.score)
329
+ })
330
+ }
331
+
332
+ export function rank(query: string[], chunks: RecallChunk[], minTerms = 2): RankedChunk[] {
333
+ return rankAll([query], chunks, minTerms)[0]
105
334
  }
106
335
 
107
336
  function excerpt(chunk: RecallChunk, query: Set<string>, salient: boolean): string[] {
@@ -136,27 +365,23 @@ function merged(hits: Array<{ query: string[]; indices: number[] }>): RecallPick
136
365
  return [...picked.entries()].map(([index, query]) => ({ index, query }))
137
366
  }
138
367
 
139
- const ranked = (query: string[], chunks: RecallChunk[], options: RecallOptions, width: number) =>
140
- rank(query, chunks, options.minTerms)
141
- .slice(0, width)
142
- .map((hit) => hit.index)
368
+ function rankedLines(prompt: string, chunks: RecallChunk[], options: RecallOptions, width: number) {
369
+ const lines = queriesOf(prompt)
370
+ const queries = lines.map(terms)
371
+ const results = rankAll(queries, chunks, options.minTerms)
372
+ return lines.map((line, i) => ({ line, query: queries[i], indices: results[i].slice(0, width).map((hit) => hit.index) }))
373
+ }
143
374
 
144
375
  function pickRecall(prompt: string, chunks: RecallChunk[], options: RecallOptions): RecallPick[] {
145
- return merged(
146
- queriesOf(prompt).map((line) => {
147
- const query = terms(line)
148
- return { query, indices: ranked(query, chunks, options, options.perQuery ?? 12) }
149
- }),
150
- )
376
+ return merged(rankedLines(prompt, chunks, options, options.perQuery ?? 12))
151
377
  }
152
378
 
153
379
  export const identityReranker: Reranker = async (_query, _chunks, candidates) => candidates
154
380
 
155
381
  export async function pickRecallWith(prompt: string, chunks: RecallChunk[], rerank: Reranker, options: RecallOptions = {}): Promise<RecallPick[]> {
156
382
  const hits: Array<{ query: string[]; indices: number[] }> = []
157
- for (const line of queriesOf(prompt)) {
158
- const query = terms(line)
159
- const reordered = await rerank(line, chunks, ranked(query, chunks, options, options.candidates ?? 30))
383
+ for (const { line, query, indices } of rankedLines(prompt, chunks, options, options.candidates ?? 30)) {
384
+ const reordered = await rerank(line, chunks, indices)
160
385
  hits.push({ query, indices: reordered.slice(0, options.perQuery ?? 12) })
161
386
  }
162
387
  return merged(hits)
@@ -1,7 +1,7 @@
1
1
  import type { Config } from "../config"
2
2
  import type { Body, Message } from "../context/content"
3
3
  import { type CompactOptions, capTurns, clearToolResults, compactMessages, countToolResults } from "../context/fold/strategies"
4
- import { splitTurns, turnFingerprint } from "../context/fold/turns"
4
+ import { type ReduceOptions, reduceTurn, splitTurns, turnFingerprint } from "../context/fold/turns"
5
5
  import { clearedOutput } from "../context/prompts"
6
6
  import { DEFAULT_RATIO, type Estimator, estimator, targetSize } from "./estimate"
7
7
  import { resetCut, type ThreadState } from "./state"
@@ -19,6 +19,24 @@ export interface CompactInput {
19
19
  reduce?: CompactOptions["reduce"]
20
20
  }
21
21
 
22
+ const FOLDED_TURNS = 4096
23
+
24
+ const folded = new Map<string, Message[]>()
25
+
26
+ function reduceRemembered(turn: Message[], options: ReduceOptions): Message[] {
27
+ const key = `${options.evidence ? 1 : 0}:${Bun.hash(JSON.stringify(turn))}`
28
+ const known = folded.get(key)
29
+ if (known) {
30
+ folded.delete(key)
31
+ folded.set(key, known)
32
+ return known
33
+ }
34
+ const reduced = reduceTurn(turn, options)
35
+ folded.set(key, reduced)
36
+ if (folded.size > FOLDED_TURNS) folded.delete(folded.keys().next().value as string)
37
+ return reduced
38
+ }
39
+
22
40
  export interface CompactResult {
23
41
  messages: Message[]
24
42
  stateChanged: boolean
@@ -89,7 +107,7 @@ export function compact({ body, config, state, window, guide, transcriptDir, may
89
107
 
90
108
  const configuredKeep = config.keepTurns === "all" ? Number.MAX_SAFE_INTEGER : config.keepTurns
91
109
  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 }
110
+ const options = { guide, omittedBefore, evidence: config.eval?.evidence ?? true, reduce: reduce ?? reduceRemembered, capChars: config.turnCap === "off" ? undefined : config.turnCap / ratio }
93
111
  const liveStart = (list: Message[]) =>
94
112
  list.length -
95
113
  splitTurns(list)
@@ -93,16 +93,27 @@ function saveRatio(threadKey: string, ratio: (previous: number | undefined) => n
93
93
  saveState(threadKey, state)
94
94
  }
95
95
 
96
- export async function handle(request: Request): Promise<Response> {
96
+ interface Timing {
97
+ countTokensTimeoutMs: number
98
+ }
99
+
100
+ const COUNT_TOKENS_ATTEMPTS = 3
101
+
102
+ export async function handle(request: Request, timing: Timing = { countTokensTimeoutMs: 10_000 }): Promise<Response> {
103
+ const began = Date.now()
97
104
  try {
98
- return await route(request)
105
+ return await route(request, began, timing)
99
106
  } catch (error) {
100
- logRequest({ path: new URL(request.url).pathname, status: 502, action: "upstream-unreachable", error: clipped(String(error)) })
107
+ logRequest({ path: new URL(request.url).pathname, status: 502, action: "upstream-unreachable", error: clipped(String(error)), ms: Date.now() - began })
101
108
  return Response.json({ type: "error", error: { type: "api_error", message: `eunoe could not reach the API: ${String(error)}` } }, { status: 502 })
102
109
  }
103
110
  }
104
111
 
105
- async function route(request: Request): Promise<Response> {
112
+ async function route(request: Request, began: number, timing: Timing): Promise<Response> {
113
+ let stalled = 0
114
+ let prep = 0
115
+ let upload = 0
116
+ const log = (record: Record<string, unknown>) => logRequest({ ...record, ms: Date.now() - began, prep, upload, ...(stalled > 0 && { stalled }) })
106
117
  const config = readConfig()
107
118
  const url = new URL(request.url)
108
119
  if (url.pathname === HEALTH_PATH) return Response.json({ ok: true, version: VERSION, mode: config.mode, pid: process.pid, startedAt } satisfies Health)
@@ -111,6 +122,7 @@ async function route(request: Request): Promise<Response> {
111
122
 
112
123
  let givenText: string | undefined
113
124
  if (request.method !== "GET" && request.method !== "HEAD") givenText = await request.text()
125
+ upload = Date.now() - began
114
126
  const isMessages = request.method === "POST" && url.pathname.startsWith("/v1/messages")
115
127
  const isCountTokens = url.pathname.includes("count_tokens")
116
128
 
@@ -136,9 +148,22 @@ async function route(request: Request): Promise<Response> {
136
148
  return { ...passthrough, note: { action: "error-passthrough", error: String(error) } }
137
149
  }
138
150
  }
139
- const send = (body: string | undefined) => fetch(config.upstream + url.pathname + url.search, { method: request.method, headers, body })
151
+ const target = config.upstream + url.pathname + url.search
152
+ const send = async (body: string | undefined): Promise<Response> => {
153
+ if (!isCountTokens) return fetch(target, { method: request.method, headers, body })
154
+ for (;;) {
155
+ try {
156
+ return await fetch(target, { method: request.method, headers, body, signal: AbortSignal.timeout(timing.countTokensTimeoutMs) })
157
+ } catch (error) {
158
+ const timedOut = error instanceof Error && error.name === "TimeoutError"
159
+ if (!timedOut || stalled + 1 >= COUNT_TOKENS_ATTEMPTS) throw error
160
+ stalled += 1
161
+ }
162
+ }
163
+ }
140
164
 
141
165
  let out = await build()
166
+ prep = Date.now() - began - upload
142
167
  let upstream = await send(out.bodyText)
143
168
  const errorResponse = (text: string) => new Response(text, { status: upstream.status, statusText: upstream.statusText, headers: forwardableHeaders(upstream.headers) })
144
169
 
@@ -147,18 +172,18 @@ async function route(request: Request): Promise<Response> {
147
172
  const overflow = overflowTokens(errorText)
148
173
  const logged = { path: url.pathname, status: upstream.status, thread: out.threadKey, ...out.note }
149
174
  if (!overflow || !out.threadKey || out.sentSize === 0 || isCountTokens) {
150
- logRequest({ ...logged, error: clipped(errorText) })
175
+ log({ ...logged, error: clipped(errorText) })
151
176
  return errorResponse(errorText)
152
177
  }
153
178
  const sentSize = out.sentSize
154
179
  saveRatio(out.threadKey, (previous) => Math.max(previous ?? 0, overflow / sentSize) * 1.1)
155
- logRequest({ ...logged, overflow, error: clipped(errorText) })
180
+ log({ ...logged, overflow, error: clipped(errorText) })
156
181
  out = await build()
157
182
  out = { ...out, note: { ...out.note, healed: overflow } }
158
183
  upstream = await send(out.bodyText)
159
184
  if (upstream.status >= 400) {
160
185
  const retryError = await upstream.text()
161
- logRequest({ path: url.pathname, status: upstream.status, thread: out.threadKey, ...out.note, error: clipped(retryError) })
186
+ log({ path: url.pathname, status: upstream.status, thread: out.threadKey, ...out.note, error: clipped(retryError) })
162
187
  return errorResponse(retryError)
163
188
  }
164
189
  }
@@ -166,13 +191,13 @@ async function route(request: Request): Promise<Response> {
166
191
  const { note, threadKey, sentSize, givenSize, fixedSize, sent } = out
167
192
  const response = new Response(upstream.body, { status: upstream.status, statusText: upstream.statusText, headers: forwardableHeaders(upstream.headers) })
168
193
  if (note.action === "pass" || !isMessages || isCountTokens) {
169
- if (note.action !== "pass" || sent) logRequest({ path: url.pathname, status: upstream.status, ...note, ...(sent && { context: sent }) })
194
+ if (note.action !== "pass" || sent) log({ path: url.pathname, status: upstream.status, ...note, ...(sent && { context: sent }) })
170
195
  return response
171
196
  }
172
197
 
173
198
  return watchUsage(response, (usage) => {
174
199
  const input = inputTokens(usage)
175
- logRequest({
200
+ log({
176
201
  path: url.pathname,
177
202
  status: upstream.status,
178
203
  thread: threadKey,
@@ -191,5 +216,5 @@ async function route(request: Request): Promise<Response> {
191
216
  }
192
217
 
193
218
  export function serve(port: number) {
194
- return Bun.serve({ port, hostname: "127.0.0.1", idleTimeout: 255, fetch: handle })
219
+ return Bun.serve({ port, hostname: "127.0.0.1", idleTimeout: 255, fetch: (request) => handle(request) })
195
220
  }
@@ -1,4 +1,4 @@
1
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
1
+ import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
2
2
  import path from "node:path"
3
3
  import { paths } from "../config/paths"
4
4
 
@@ -21,9 +21,35 @@ function stateFile(key: string) {
21
21
  return path.join(paths.threads, `${key.replace(/[^a-zA-Z0-9_-]/g, "_")}.json`)
22
22
  }
23
23
 
24
+ const KEPT_THREADS = 64
25
+
26
+ const known = new Map<string, { version: string; state: ThreadState }>()
27
+
28
+ function versionOf(file: string): string | null {
29
+ try {
30
+ const { mtimeMs, size } = statSync(file)
31
+ return `${mtimeMs}:${size}`
32
+ } catch {
33
+ return null
34
+ }
35
+ }
36
+
37
+ function remember(file: string, version: string, state: ThreadState) {
38
+ known.delete(file)
39
+ known.set(file, { version, state })
40
+ if (known.size > KEPT_THREADS) known.delete(known.keys().next().value as string)
41
+ }
42
+
24
43
  export function readState(key: string): ThreadState {
44
+ const file = stateFile(key)
45
+ const version = versionOf(file)
46
+ if (version === null) return {}
47
+ const hit = known.get(file)
48
+ if (hit?.version === version) return structuredClone(hit.state)
25
49
  try {
26
- return JSON.parse(readFileSync(stateFile(key), "utf8"))
50
+ const state = JSON.parse(readFileSync(file, "utf8")) as ThreadState
51
+ remember(file, version, state)
52
+ return structuredClone(state)
27
53
  } catch {
28
54
  return {}
29
55
  }
@@ -31,7 +57,10 @@ export function readState(key: string): ThreadState {
31
57
 
32
58
  export function saveState(key: string, state: ThreadState) {
33
59
  mkdirSync(paths.threads, { recursive: true })
34
- writeFileSync(stateFile(key), JSON.stringify(state))
60
+ const file = stateFile(key)
61
+ writeFileSync(file, JSON.stringify(state))
62
+ const version = versionOf(file)
63
+ if (version !== null) remember(file, version, structuredClone(state))
35
64
  }
36
65
 
37
66
  export function resetCut(state: ThreadState) {
@@ -0,0 +1,68 @@
1
+ import path from "node:path"
2
+ import { type Config, claudeConfigDirs } from "../config"
3
+ import type { Message } from "../context/content"
4
+ import { splitTurns } from "../context/fold/turns"
5
+ import { recallChunks, warmIndex } from "../context/recall"
6
+ import { readSessionSlice } from "../transcript/entries"
7
+ import { findSessionFile, listEntries } from "../transcript/locate"
8
+ import { sessionMessages } from "../transcript/messages"
9
+ import { subagentChunks, subagentMessages, subagentRuns, subagentsDir } from "../transcript/subagents"
10
+ import { seenSessions } from "./sessions"
11
+
12
+ const SLICE_MS = 8
13
+ const SLICE_BYTES = 2 * 1024 * 1024
14
+ const RECENT_MS = 24 * 60 * 60 * 1000
15
+ const MAX_SESSIONS = 16
16
+ const TURNS_PER_SLICE = 40
17
+
18
+ let deadline = 0
19
+
20
+ async function breathe() {
21
+ if (performance.now() < deadline) return
22
+ await new Promise((resolve) => setTimeout(resolve, 1))
23
+ deadline = performance.now() + SLICE_MS
24
+ }
25
+
26
+ const closingTurn: Message = { role: "user", content: "" }
27
+
28
+ async function warmSession(file: string) {
29
+ while (!readSessionSlice(file, SLICE_BYTES)) await breathe()
30
+ await breathe()
31
+ for (const name of listEntries(subagentsDir(file))) {
32
+ if (!name.endsWith(".jsonl")) continue
33
+ while (!readSessionSlice(path.join(subagentsDir(file), name), SLICE_BYTES)) await breathe()
34
+ await breathe()
35
+ }
36
+ const messages = sessionMessages(file)
37
+ await breathe()
38
+ const { preamble, turns } = splitTurns(messages)
39
+ let end = preamble.length
40
+ for (let i = 0; i < turns.length; i += 1) {
41
+ end += turns[i].length
42
+ if ((i + 1) % TURNS_PER_SLICE !== 0 && i !== turns.length - 1) continue
43
+ recallChunks([...messages.slice(0, end), closingTurn], new Map())
44
+ await breathe()
45
+ }
46
+ for (const run of subagentRuns(file)) {
47
+ recallChunks([...subagentMessages(run), closingTurn], new Map())
48
+ await breathe()
49
+ }
50
+ const corpus = [...recallChunks([...messages, closingTurn], new Map()), ...subagentChunks(file)]
51
+ await breathe()
52
+ while (!warmIndex(corpus, performance.now() + SLICE_MS)) await breathe()
53
+ }
54
+
55
+ export async function warmRecentSessions(config: Config, now = Date.now()) {
56
+ deadline = performance.now() + SLICE_MS
57
+ const recent = seenSessions()
58
+ .filter(([, seen]) => seen.mode !== "off" && now - Date.parse(seen.lastSeen) < RECENT_MS)
59
+ .slice(0, MAX_SESSIONS)
60
+ for (const [id] of recent) {
61
+ const file = findSessionFile(id, claudeConfigDirs(config))
62
+ if (!file) continue
63
+ try {
64
+ await warmSession(file)
65
+ } catch {}
66
+ await breathe()
67
+ }
68
+ }
@@ -0,0 +1,114 @@
1
+ import { closeSync, openSync, readSync, statSync } from "node:fs"
2
+ import type { SessionEntry } from "./parse"
3
+
4
+ interface Parsed {
5
+ size: number
6
+ mtimeMs: number
7
+ offset: number
8
+ seam: Buffer
9
+ committed: SessionEntry[]
10
+ entries: SessionEntry[]
11
+ }
12
+
13
+ const BUDGET_BYTES = 768 * 1024 * 1024
14
+ const NEWLINE = 0x0a
15
+ const SEAM_BYTES = 64
16
+
17
+ const parsed = new Map<string, Parsed>()
18
+ let heldBytes = 0
19
+
20
+ function parseLines(text: string): SessionEntry[] {
21
+ const out: SessionEntry[] = []
22
+ for (const line of text.split("\n")) {
23
+ if (!line) continue
24
+ try {
25
+ out.push(JSON.parse(line) as SessionEntry)
26
+ } catch {}
27
+ }
28
+ return out
29
+ }
30
+
31
+ function readRange(file: string, from: number, to: number): Buffer {
32
+ const buffer = Buffer.allocUnsafe(Math.max(0, to - from))
33
+ const fd = openSync(file, "r")
34
+ try {
35
+ let read = 0
36
+ while (read < buffer.length) {
37
+ const n = readSync(fd, buffer, read, buffer.length - read, from + read)
38
+ if (n === 0) break
39
+ read += n
40
+ }
41
+ return buffer.subarray(0, read)
42
+ } finally {
43
+ closeSync(fd)
44
+ }
45
+ }
46
+
47
+ function remember(file: string, next: Parsed) {
48
+ heldBytes += next.size - (parsed.get(file)?.size ?? 0)
49
+ parsed.delete(file)
50
+ parsed.set(file, next)
51
+ for (const [key, value] of parsed) {
52
+ if (heldBytes <= BUDGET_BYTES || key === file) break
53
+ parsed.delete(key)
54
+ heldBytes -= value.size
55
+ }
56
+ }
57
+
58
+ function appendable(file: string, known: Parsed | undefined, size: number): known is Parsed {
59
+ if (!known || size < known.size || known.offset === 0) return false
60
+ return readRange(file, known.offset - known.seam.length, known.offset).equals(known.seam)
61
+ }
62
+
63
+ export function readSessionSlice(file: string, maxBytes: number): boolean {
64
+ let size: number
65
+ let mtimeMs: number
66
+ try {
67
+ ;({ size, mtimeMs } = statSync(file))
68
+ } catch {
69
+ return true
70
+ }
71
+ const known = parsed.get(file)
72
+ if (known && known.size === size && known.mtimeMs === mtimeMs) return true
73
+ const appended = appendable(file, known, size)
74
+ const from = appended ? known.offset : 0
75
+ const bytes = from + maxBytes < size ? readRange(file, from, from + maxBytes) : null
76
+ const complete = bytes ? bytes.lastIndexOf(NEWLINE) + 1 : 0
77
+ if (!bytes || complete === 0) {
78
+ readSessionEntries(file)
79
+ return true
80
+ }
81
+ const added = parseLines(bytes.subarray(0, complete).toString("utf8"))
82
+ const committed = appended ? known.committed.concat(added) : added
83
+ const offset = from + complete
84
+ remember(file, { size: offset, mtimeMs: 0, offset, seam: readRange(file, Math.max(0, offset - SEAM_BYTES), offset), committed, entries: committed })
85
+ return false
86
+ }
87
+
88
+ export function readSessionEntries(file: string): SessionEntry[] {
89
+ let size: number
90
+ let mtimeMs: number
91
+ try {
92
+ ;({ size, mtimeMs } = statSync(file))
93
+ } catch {
94
+ return []
95
+ }
96
+ const known = parsed.get(file)
97
+ if (known && known.size === size && known.mtimeMs === mtimeMs) {
98
+ parsed.delete(file)
99
+ parsed.set(file, known)
100
+ return known.entries
101
+ }
102
+ const appended = appendable(file, known, size)
103
+ const from = appended ? known.offset : 0
104
+ const bytes = readRange(file, from, size)
105
+ const complete = bytes.lastIndexOf(NEWLINE) + 1
106
+ const added = parseLines(bytes.subarray(0, complete).toString("utf8"))
107
+ const committed = appended ? (added.length > 0 ? known.committed.concat(added) : known.committed) : added
108
+ const tail = parseLines(bytes.subarray(complete).toString("utf8"))
109
+ const entries = tail.length > 0 ? committed.concat(tail) : committed
110
+ const offset = from + complete
111
+ const seam = readRange(file, Math.max(0, offset - SEAM_BYTES), offset)
112
+ remember(file, { size, mtimeMs, offset, seam, committed, entries })
113
+ return entries
114
+ }
@@ -1,18 +1,21 @@
1
- import { readFileSync, statSync } from "node:fs"
2
1
  import type { Message } from "../context/content"
3
- import { entryMessages, parseJsonl } from "./parse"
2
+ import { readSessionEntries } from "./entries"
3
+ import { entryMessages, type SessionEntry } from "./parse"
4
4
 
5
- let cached: { file: string; mtimeMs: number; messages: Message[] } | null = null
5
+ const KEPT_SESSIONS = 32
6
+
7
+ const cached = new Map<string, { entries: SessionEntry[]; messages: Message[] }>()
6
8
 
7
9
  export function sessionMessages(file: string): Message[] {
8
- let mtimeMs: number
9
- try {
10
- mtimeMs = statSync(file).mtimeMs
11
- } catch {
12
- return []
10
+ const entries = readSessionEntries(file)
11
+ const known = cached.get(file)
12
+ cached.delete(file)
13
+ if (known?.entries === entries) {
14
+ cached.set(file, known)
15
+ return known.messages
13
16
  }
14
- if (cached?.file === file && cached.mtimeMs === mtimeMs) return cached.messages
15
- const messages = entryMessages(parseJsonl(readFileSync(file, "utf8")).filter((entry) => !entry.isSidechain))
16
- cached = { file, mtimeMs, messages }
17
+ const messages = entryMessages(entries.filter((entry) => !entry.isSidechain))
18
+ cached.set(file, { entries, messages })
19
+ if (cached.size > KEPT_SESSIONS) cached.delete(cached.keys().next().value as string)
17
20
  return messages
18
21
  }
@@ -0,0 +1,24 @@
1
+ import path from "node:path"
2
+ import type { TranscriptFormat } from "./parse"
3
+ import { renderTranscript } from "./render"
4
+
5
+ declare const self: Worker
6
+
7
+ export interface RenderJob {
8
+ dir: string
9
+ sessionFile: string
10
+ format: TranscriptFormat
11
+ }
12
+
13
+ export interface RenderDone {
14
+ dir: string
15
+ changed: boolean
16
+ }
17
+
18
+ self.onmessage = ({ data }: MessageEvent<RenderJob>) => {
19
+ let changed = false
20
+ try {
21
+ changed = renderTranscript(data.sessionFile, path.basename(data.dir), data.dir, data.format).changed
22
+ } catch {}
23
+ self.postMessage({ dir: data.dir, changed } satisfies RenderDone)
24
+ }
@@ -1,8 +1,9 @@
1
1
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
2
2
  import path from "node:path"
3
3
  import { paths } from "../config/paths"
4
+ import { readSessionEntries } from "./entries"
4
5
  import { renderIndex, renderMarkdown, renderSubagentMarkdown, renderSubagentXml, renderXml, type SubagentRecord, subagentFile, turnFile } from "./format"
5
- import { parseJsonl, parseTurns, type ToolPart, type TranscriptFormat, type Turn } from "./parse"
6
+ import { parseTurns, type ToolPart, type TranscriptFormat, type Turn } from "./parse"
6
7
  import { type SubagentRun, subagentEntries, subagentRuns } from "./subagents"
7
8
 
8
9
  interface RenderResult {
@@ -11,6 +12,8 @@ interface RenderResult {
11
12
  turns: number
12
13
  }
13
14
 
15
+ const written = new Map<string, number | bigint>()
16
+
14
17
  export function transcriptDir(sessionId: string) {
15
18
  return path.join(paths.transcripts, sessionId)
16
19
  }
@@ -45,13 +48,19 @@ export function renderTranscript(sessionFile: string, sessionId: string, outDir
45
48
  const stamp = [statSync(sessionFile).mtimeMs, ...runs.map((run) => `${run.id}:${run.mtimeMs}`)].join("|")
46
49
  if (existsSync(stampFile) && readFileSync(stampFile, "utf8") === stamp) return { dir: outDir, changed: false, turns: Number(readFileSync(countFile, "utf8") || 0) }
47
50
 
48
- const turns = parseTurns(parseJsonl(readFileSync(sessionFile, "utf8")))
51
+ const turns = parseTurns(readSessionEntries(sessionFile))
49
52
  const records = subagentRecords(turns, runs, format)
50
53
  let changed = false
51
54
  const write = (file: string, content: string) => {
52
55
  const target = path.join(outDir, file)
53
- if (existsSync(target) && readFileSync(target, "utf8") === content) return
56
+ const hash = Bun.hash(content)
57
+ if (written.get(target) === hash && existsSync(target)) return
58
+ if (!written.has(target) && existsSync(target) && readFileSync(target, "utf8") === content) {
59
+ written.set(target, hash)
60
+ return
61
+ }
54
62
  writeFileSync(target, content)
63
+ written.set(target, hash)
55
64
  changed = true
56
65
  }
57
66
  for (const turn of turns) write(turnFile(turn, format), format === "xml" ? renderXml(turn) : renderMarkdown(turn))
@@ -1,7 +1,8 @@
1
1
  import { existsSync } from "node:fs"
2
2
  import path from "node:path"
3
3
  import type { SearchMode } from "../config"
4
- import { renderTranscript, transcriptDir } from "./render"
4
+ import { transcriptDir } from "./render"
5
+ import type { RenderDone, RenderJob } from "./render-worker"
5
6
 
6
7
  export interface TranscriptRef {
7
8
  mode: SearchMode
@@ -32,13 +33,53 @@ function indexWithQmd(dir: string) {
32
33
  return next
33
34
  }
34
35
 
36
+ const queued = new Map<string, { mode: SearchMode; sessionFile: string }>()
37
+ let renderer: Worker | null = null
38
+ let rendering: { dir: string; mode: SearchMode } | null = null
39
+ let drained: Array<() => void> = []
40
+
41
+ function finished(changed: boolean) {
42
+ if (rendering?.mode === "qmd" && (changed || !isIndexed(rendering.dir))) void indexWithQmd(rendering.dir)
43
+ rendering = null
44
+ renderNext()
45
+ }
46
+
47
+ function startRenderer(): Worker {
48
+ const worker = new Worker(new URL("./render-worker.ts", import.meta.url))
49
+ worker.onmessage = ({ data }: MessageEvent<RenderDone>) => finished(data.changed)
50
+ worker.onerror = () => {
51
+ renderer = null
52
+ finished(false)
53
+ }
54
+ ;(worker as Worker & { unref(): void }).unref()
55
+ return worker
56
+ }
57
+
58
+ function renderNext() {
59
+ const next = queued.entries().next()
60
+ if (next.done) {
61
+ const waiting = drained
62
+ drained = []
63
+ for (const resolve of waiting) resolve()
64
+ return
65
+ }
66
+ const [dir, { mode, sessionFile }] = next.value
67
+ queued.delete(dir)
68
+ rendering = { dir, mode }
69
+ renderer ??= startRenderer()
70
+ renderer.postMessage({ dir, sessionFile, format: mode === "xml" ? "xml" : "markdown" } satisfies RenderJob)
71
+ }
72
+
73
+ export function flushTranscripts(): Promise<void> {
74
+ if (!rendering && queued.size === 0) return Promise.resolve()
75
+ return new Promise((resolve) => drained.push(resolve))
76
+ }
77
+
35
78
  export function prepareTranscript(mode: SearchMode, sessionFile: string, key: string): TranscriptRef {
36
79
  const dir = transcriptDir(key)
37
80
  if (mode !== "jsonl") {
38
- try {
39
- const { changed } = renderTranscript(sessionFile, key, dir, mode === "xml" ? "xml" : "markdown")
40
- if (mode === "qmd" && (changed || !isIndexed(dir))) void indexWithQmd(dir)
41
- } catch {}
81
+ queued.set(dir, { mode, sessionFile })
82
+ if (!rendering) renderNext()
42
83
  }
43
84
  return { mode, jsonl: sessionFile, dir }
44
85
  }
@@ -3,9 +3,10 @@ import path from "node:path"
3
3
  import { blocks, clip, contentBlocks, type Message } from "../context/content"
4
4
  import { splitTurns } from "../context/fold/turns"
5
5
  import { type RecallChunk, recallChunks } from "../context/recall"
6
+ import { readSessionEntries } from "./entries"
6
7
  import { listEntries } from "./locate"
7
8
  import { sessionMessages } from "./messages"
8
- import { entryMessages, parseJsonl, type SessionEntry } from "./parse"
9
+ import { entryMessages, type SessionEntry } from "./parse"
9
10
 
10
11
  export interface SubagentRun {
11
12
  id: string
@@ -20,7 +21,7 @@ export interface SubagentRun {
20
21
 
21
22
  const INLINE_ID = "sidechain"
22
23
 
23
- const entryCache = new Map<string, { mtimeMs: number; entries: SessionEntry[] }>()
24
+ const runCache = new Map<string, SubagentRun>()
24
25
  const chunkCache = new Map<string, { mtimeMs: number; parentTurn: number; chunks: RecallChunk[] }>()
25
26
 
26
27
  function mtimeOf(file: string): number | null {
@@ -31,16 +32,6 @@ function mtimeOf(file: string): number | null {
31
32
  }
32
33
  }
33
34
 
34
- function readEntries(file: string): SessionEntry[] {
35
- const mtimeMs = mtimeOf(file)
36
- if (mtimeMs === null) return []
37
- const hit = entryCache.get(file)
38
- if (hit?.mtimeMs === mtimeMs) return hit.entries
39
- const entries = parseJsonl(readFileSync(file, "utf8"))
40
- entryCache.set(file, { mtimeMs, entries })
41
- return entries
42
- }
43
-
44
35
  function firstPrompt(entries: SessionEntry[]): string {
45
36
  for (const entry of entries) {
46
37
  if (entry.type !== "user" || !entry.message) continue
@@ -72,10 +63,13 @@ function fileRuns(sessionFile: string): SubagentRun[] {
72
63
  .filter((name) => name.startsWith("agent-") && name.endsWith(".jsonl"))
73
64
  .map((name) => {
74
65
  const file = path.join(dir, name)
75
- const entries = readEntries(file)
66
+ const mtimeMs = mtimeOf(file) ?? 0
67
+ const known = runCache.get(file)
68
+ if (known?.mtimeMs === mtimeMs) return known
69
+ const entries = readSessionEntries(file)
76
70
  const meta = readMeta(file)
77
71
  const prompt = firstPrompt(entries)
78
- return {
72
+ const run: SubagentRun = {
79
73
  id: name.slice("agent-".length, -".jsonl".length),
80
74
  file,
81
75
  inline: false,
@@ -83,17 +77,21 @@ function fileRuns(sessionFile: string): SubagentRun[] {
83
77
  toolUseId: meta.toolUseId ? String(meta.toolUseId) : null,
84
78
  prompt,
85
79
  started: String(entries[0]?.timestamp ?? ""),
86
- mtimeMs: mtimeOf(file) ?? 0,
80
+ mtimeMs,
87
81
  }
82
+ runCache.set(file, run)
83
+ return run
88
84
  })
89
85
  }
90
86
 
91
87
  function inlineRuns(sessionFile: string, known: Set<string>): SubagentRun[] {
92
88
  const byAgent = new Map<string, SessionEntry[]>()
93
- for (const entry of readEntries(sessionFile)) {
89
+ for (const entry of readSessionEntries(sessionFile)) {
94
90
  if (!entry.isSidechain) continue
95
91
  const id = entry.agentId ?? INLINE_ID
96
- byAgent.set(id, [...(byAgent.get(id) ?? []), entry])
92
+ const group = byAgent.get(id)
93
+ if (group) group.push(entry)
94
+ else byAgent.set(id, [entry])
97
95
  }
98
96
  return [...byAgent]
99
97
  .filter(([id]) => !known.has(id))
@@ -110,7 +108,7 @@ export function subagentRuns(sessionFile: string): SubagentRun[] {
110
108
  }
111
109
 
112
110
  export function subagentEntries(run: SubagentRun): SessionEntry[] {
113
- const entries = readEntries(run.file)
111
+ const entries = readSessionEntries(run.file)
114
112
  if (!run.inline) return entries
115
113
  return entries.filter((entry) => entry.isSidechain && (entry.agentId ?? INLINE_ID) === run.id)
116
114
  }
@@ -119,27 +117,50 @@ export function subagentMessages(run: SubagentRun): Message[] {
119
117
  return entryMessages(subagentEntries(run))
120
118
  }
121
119
 
122
- function launchedBy(run: SubagentRun, call: { id?: unknown; name?: unknown; prompt?: unknown }): boolean {
123
- if (run.toolUseId) return call.id === run.toolUseId
124
- return (call.name === "Agent" || call.name === "Task") && String(call.prompt ?? "") === run.prompt
120
+ interface Launches {
121
+ byId: Map<unknown, number>
122
+ byPrompt: Map<string, number>
123
+ }
124
+
125
+ function launchesIn(parentMessages: Message[]): Launches {
126
+ const launches: Launches = { byId: new Map(), byPrompt: new Map() }
127
+ splitTurns(parentMessages).turns.forEach((turn, index) => {
128
+ for (const message of turn) {
129
+ if (message.role !== "assistant") continue
130
+ for (const block of blocks(message)) {
131
+ if (block.type !== "tool_use") continue
132
+ if (!launches.byId.has(block.id)) launches.byId.set(block.id, index + 1)
133
+ const prompt = String(block.input?.prompt ?? "")
134
+ if ((block.name === "Agent" || block.name === "Task") && !launches.byPrompt.has(prompt)) launches.byPrompt.set(prompt, index + 1)
135
+ }
136
+ }
137
+ })
138
+ return launches
125
139
  }
126
140
 
141
+ const launchTurn = (run: SubagentRun, launches: Launches) => (run.toolUseId ? launches.byId.get(run.toolUseId) : launches.byPrompt.get(run.prompt)) ?? 0
142
+
127
143
  export function parentTurnOf(run: SubagentRun, parentMessages: Message[]): number {
128
- const { turns } = splitTurns(parentMessages)
129
- const index = turns.findIndex((turn) =>
130
- turn
131
- .filter((message) => message.role === "assistant")
132
- .flatMap(blocks)
133
- .some((block) => block.type === "tool_use" && launchedBy(run, { id: block.id, name: block.name, prompt: block.input?.prompt })),
134
- )
135
- return index + 1
144
+ return launchTurn(run, launchesIn(parentMessages))
145
+ }
146
+
147
+ const KEPT_LAUNCHES = 8
148
+ const launchCache = new Map<Message[], Launches>()
149
+
150
+ function cachedLaunches(parentMessages: Message[]): Launches {
151
+ const known = launchCache.get(parentMessages)
152
+ if (known) return known
153
+ const launches = launchesIn(parentMessages)
154
+ launchCache.set(parentMessages, launches)
155
+ if (launchCache.size > KEPT_LAUNCHES) launchCache.delete(launchCache.keys().next().value as Message[])
156
+ return launches
136
157
  }
137
158
 
138
159
  export function subagentChunks(sessionFile: string): RecallChunk[] {
139
- const parent = sessionMessages(sessionFile)
160
+ const launches = cachedLaunches(sessionMessages(sessionFile))
140
161
  return subagentRuns(sessionFile).flatMap((run) => {
141
162
  const key = `${run.file}#${run.id}`
142
- const parentTurn = parentTurnOf(run, parent)
163
+ const parentTurn = launchTurn(run, launches)
143
164
  const hit = chunkCache.get(key)
144
165
  if (hit && hit.mtimeMs === run.mtimeMs && hit.parentTurn === parentTurn) return hit.chunks
145
166
  const from = `subagent "${run.description}"`