@chroxify/eunoe 1.2.0 → 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.
@@ -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}"`