@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.
- package/README.md +4 -3
- package/package.json +1 -1
- package/src/cli/commands/replay.ts +6 -6
- package/src/cli/commands/serve.ts +2 -0
- package/src/cli/commands/stats.ts +44 -13
- package/src/cli/proxy/stats.ts +48 -0
- package/src/cli/replay/index.ts +5 -3
- package/src/context/recall/index.ts +260 -35
- package/src/proxy/compaction.ts +20 -2
- package/src/proxy/server.ts +36 -11
- package/src/proxy/state.ts +32 -3
- package/src/proxy/warm.ts +68 -0
- package/src/transcript/entries.ts +114 -0
- package/src/transcript/messages.ts +14 -11
- package/src/transcript/render-worker.ts +24 -0
- package/src/transcript/render.ts +12 -3
- package/src/transcript/search.ts +46 -5
- package/src/transcript/subagents.ts +52 -31
package/src/proxy/server.ts
CHANGED
|
@@ -93,16 +93,27 @@ function saveRatio(threadKey: string, ratio: (previous: number | undefined) => n
|
|
|
93
93
|
saveState(threadKey, state)
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
}
|
package/src/proxy/state.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
|
2
|
+
import { readSessionEntries } from "./entries"
|
|
3
|
+
import { entryMessages, type SessionEntry } from "./parse"
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
cached
|
|
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
|
+
}
|
package/src/transcript/render.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
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
|
-
|
|
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))
|
package/src/transcript/search.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
39
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
89
|
+
for (const entry of readSessionEntries(sessionFile)) {
|
|
94
90
|
if (!entry.isSidechain) continue
|
|
95
91
|
const id = entry.agentId ?? INLINE_ID
|
|
96
|
-
|
|
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 =
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
160
|
+
const launches = cachedLaunches(sessionMessages(sessionFile))
|
|
140
161
|
return subagentRuns(sessionFile).flatMap((run) => {
|
|
141
162
|
const key = `${run.file}#${run.id}`
|
|
142
|
-
const parentTurn =
|
|
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}"`
|