@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 +4 -3
- package/package.json +1 -1
- package/src/cli/commands/serve.ts +2 -0
- 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/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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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,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
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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 *
|
|
268
|
+
score += (idf * (f * (BM25_K1 + 1))) / (f + BM25_K1 * (1 - BM25_B + (BM25_B * length) / average))
|
|
100
269
|
}
|
|
101
|
-
|
|
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
|
-
.
|
|
104
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
158
|
-
const
|
|
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)
|
package/src/proxy/compaction.ts
CHANGED
|
@@ -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)
|
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}"`
|