@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/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
|
@@ -200,24 +200,24 @@ export const replay: Command = {
|
|
|
200
200
|
["Cost", `${c.green(c.bold(`${fewer(sum.plainCost, sum.eunoeCost)} cheaper`))} ${c.dim("cache reads at 0.1×, writes at 1.25×")}`],
|
|
201
201
|
["Folds", c.bold(sum.folds.toLocaleString())],
|
|
202
202
|
["Folds dropped", `${c.bold(sum.dropped.toLocaleString())} ${c.dim("folded turns removed entirely to fit the window")}`],
|
|
203
|
-
["Peak context", `plain ${tokens(sum.peakPlain)} ${c.dim("→")} eunoe ${c.bold(tokens(sum.peakEunoe))}`],
|
|
203
|
+
["Peak context", `plain ${c.green(c.bold(tokens(sum.peakPlain)))} ${c.dim("→")} eunoe ${c.bold(tokens(sum.peakEunoe))} ${c.dim("· the longest conversation whole, every compaction undone, against the most eunoe sent")}`],
|
|
204
204
|
],
|
|
205
205
|
4,
|
|
206
206
|
)
|
|
207
207
|
const saved = [...compared]
|
|
208
208
|
.filter((r) => r.nativeCompactions > 0)
|
|
209
|
-
.sort((a, b) => b.
|
|
209
|
+
.sort((a, b) => b.peakPlain - a.peakPlain)
|
|
210
210
|
.slice(0, TOP_SESSIONS)
|
|
211
211
|
if (saved.length > 0) {
|
|
212
212
|
line()
|
|
213
|
-
line(` ${c.dim("
|
|
213
|
+
line(` ${c.dim("Longest sessions Claude Code would have compacted, by their whole history")}`)
|
|
214
214
|
table(
|
|
215
215
|
saved.map((r) => [
|
|
216
216
|
c.cyan(shortId(r.id)),
|
|
217
217
|
(r.title ?? (r.cwd ? tilde(r.cwd) : "")).slice(0, 40),
|
|
218
|
-
c.
|
|
219
|
-
`${
|
|
220
|
-
c.dim(
|
|
218
|
+
`${c.green(c.bold(tokens(r.peakPlain)))} ${c.dim("of history")}`,
|
|
219
|
+
c.bold(`${plural(r.nativeCompactions, "compaction")} avoided`),
|
|
220
|
+
c.dim(`eunoe peak ${tokens(r.peakEunoe)} · ${plural(r.folds, "fold")}`),
|
|
221
221
|
]),
|
|
222
222
|
)
|
|
223
223
|
}
|
|
@@ -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
|
}
|
|
@@ -2,7 +2,9 @@ import { claudeConfigDirs, readConfig } from "../../config"
|
|
|
2
2
|
import type { Command } from "../command"
|
|
3
3
|
import { cleanupDays } from "../proxy/claude-settings"
|
|
4
4
|
import { type RequestRecord, recentRequests } from "../proxy/inspect"
|
|
5
|
-
import { savedTokens, totals } from "../proxy/stats"
|
|
5
|
+
import { nativeCompactions, savedTokens, type ThreadPeak, threadPeaks, totals } from "../proxy/stats"
|
|
6
|
+
import { REPLAY_NATIVE_COMPACT_AT } from "../replay"
|
|
7
|
+
import { shortId } from "../sessions"
|
|
6
8
|
import { c, fail, line, percent, plural, table, tokens, wrap } from "../terminal/ui"
|
|
7
9
|
import { costOf, ratesOf, readUsage, type UsageEntry, usageFiles } from "../usage"
|
|
8
10
|
|
|
@@ -20,6 +22,7 @@ const MATCH_WITHIN = 10 * 60_000
|
|
|
20
22
|
const NOTE_WIDTH = 110
|
|
21
23
|
const DEFAULT_DAYS = 7
|
|
22
24
|
const DEFAULT_WEEKS = 8
|
|
25
|
+
const LONGEST = 5
|
|
23
26
|
|
|
24
27
|
const midnight = (ms: number) => new Date(new Date(ms).toDateString()).getTime()
|
|
25
28
|
const addDays = (ms: number, days: number) => {
|
|
@@ -123,7 +126,13 @@ function notSentCell(logged: RequestRecord[]) {
|
|
|
123
126
|
const sum = totals(logged)
|
|
124
127
|
if (sum.requests === 0) return c.dim("–")
|
|
125
128
|
if (sum.saved <= 0) return c.dim("±0")
|
|
126
|
-
return `${tokens(sum.saved)} ${c.dim(plural(sum.folds, "fold"))}`
|
|
129
|
+
return `${c.green(c.bold(tokens(sum.saved)))} ${c.dim(plural(sum.folds, "fold"))}`
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function avoidedCell(logged: RequestRecord[], avoided: Set<RequestRecord>) {
|
|
133
|
+
if (!logged.some((record) => record.input)) return c.dim("–")
|
|
134
|
+
const count = logged.filter((record) => avoided.has(record)).length
|
|
135
|
+
return count > 0 ? c.green(c.bold(count.toLocaleString())) : c.dim("0")
|
|
127
136
|
}
|
|
128
137
|
|
|
129
138
|
function costSavedCell(logged: RequestRecord[], matched: Matched) {
|
|
@@ -131,13 +140,13 @@ function costSavedCell(logged: RequestRecord[], matched: Matched) {
|
|
|
131
140
|
if (sum.priced === 0) return c.dim("–")
|
|
132
141
|
if (Math.abs(sum.saved) < 0.005) return c.dim("±0")
|
|
133
142
|
const share = c.dim(percent(sum.saved / (sum.actual + sum.saved)))
|
|
134
|
-
return sum.saved > 0 ? `${
|
|
143
|
+
return sum.saved > 0 ? `${dollars(sum.saved)} ${share}` : `${c.yellow(`−${dollars(-sum.saved)}`)} ${share}`
|
|
135
144
|
}
|
|
136
145
|
|
|
137
|
-
function row(label: string, usage: UsageEntry[], logged: RequestRecord[], matched: Matched, bold = false) {
|
|
146
|
+
function row(label: string, usage: UsageEntry[], logged: RequestRecord[], matched: Matched, avoided: Set<RequestRecord>, bold = false) {
|
|
138
147
|
const sum = summarize(usage)
|
|
139
148
|
const strong = (text: string) => (bold ? c.bold(text) : text)
|
|
140
|
-
const eunoe = [notSentCell(logged), costSavedCell(logged, matched)]
|
|
149
|
+
const eunoe = [avoidedCell(logged, avoided), notSentCell(logged), costSavedCell(logged, matched)]
|
|
141
150
|
if (sum.requests === 0) return [strong(label), c.dim("–"), "", "", "", "", "", ...eunoe]
|
|
142
151
|
return [strong(label), strong(sum.requests.toLocaleString()), tokens(sum.input), tokens(sum.output), tokens(sum.cacheWrite), tokens(sum.cacheRead), strong(`${dollars(sum.cost)}${sum.unpriced ? c.dim("*") : ""}`), ...eunoe]
|
|
143
152
|
}
|
|
@@ -161,6 +170,15 @@ function byModel(usage: UsageEntry[]) {
|
|
|
161
170
|
])
|
|
162
171
|
}
|
|
163
172
|
|
|
173
|
+
function longestRow(peak: ThreadPeak) {
|
|
174
|
+
return [
|
|
175
|
+
c.cyan(shortId(peak.session)),
|
|
176
|
+
`${c.green(c.bold(tokens(peak.plain)))} ${c.dim("of history")}`,
|
|
177
|
+
peak.compactions > 0 ? `${c.bold(plural(peak.compactions, "compaction"))} avoided` : c.dim("no compaction needed"),
|
|
178
|
+
c.dim(`eunoe sent ${tokens(peak.sent)}${peak.turns ? ` · ${plural(peak.turns, "turn")}` : ""} · ${dayLabel(Date.parse(peak.lastSeen))}`),
|
|
179
|
+
]
|
|
180
|
+
}
|
|
181
|
+
|
|
164
182
|
const countArg = (args: string[], flag: string, fallback: number) => {
|
|
165
183
|
const at = args.indexOf(flag)
|
|
166
184
|
if (at === -1) return fallback
|
|
@@ -175,9 +193,10 @@ export const stats: Command = {
|
|
|
175
193
|
details: [
|
|
176
194
|
"Reads the usage the API returned for every request in your Claude Code",
|
|
177
195
|
"sessions, subagents included, counting each response once. Cost is at",
|
|
178
|
-
"API list prices. The eunoe columns come from its request log: the
|
|
179
|
-
"
|
|
180
|
-
"
|
|
196
|
+
"API list prices. The eunoe columns come from its request log: the",
|
|
197
|
+
"compactions Claude Code would have run on the conversations it handled,",
|
|
198
|
+
"the input tokens it kept out, and what that saved at cache-read prices",
|
|
199
|
+
"once the cache writes its folds caused are paid for.",
|
|
181
200
|
],
|
|
182
201
|
options: [
|
|
183
202
|
["--days <n>", `Days to show (default ${DEFAULT_DAYS})`],
|
|
@@ -192,7 +211,9 @@ export const stats: Command = {
|
|
|
192
211
|
const since = list[0].start
|
|
193
212
|
const configDirs = claudeConfigDirs(readConfig())
|
|
194
213
|
const usage = readUsage(usageFiles(configDirs, since), since)
|
|
195
|
-
const
|
|
214
|
+
const all = recentRequests(Number.POSITIVE_INFINITY)
|
|
215
|
+
const avoided = nativeCompactions(all)
|
|
216
|
+
const logged = all.filter((record) => Date.parse(record.ts) >= since)
|
|
196
217
|
for (const entry of usage) place(list, entry.at, (bucket) => bucket.usage.push(entry))
|
|
197
218
|
const matched = matchLogged(usage, logged)
|
|
198
219
|
for (const record of logged) place(list, Date.parse(record.ts), (bucket) => bucket.logged.push(record))
|
|
@@ -201,11 +222,20 @@ export const stats: Command = {
|
|
|
201
222
|
line(` ${c.bold("eunoe stats")} ${c.dim(`the last ${count} ${weekly ? "weeks" : "days"} of Claude Code, from your session files`)}`)
|
|
202
223
|
line()
|
|
203
224
|
table([
|
|
204
|
-
[weekly ? "Week" : "Day", "Requests", "Input", "Output", "Cache write", "Cache read", "API cost", "
|
|
205
|
-
...list.map((bucket) => row(bucket.label, bucket.usage, bucket.logged, matched)),
|
|
206
|
-
row("Total", usage, logged, matched, true),
|
|
225
|
+
[weekly ? "Week" : "Day", "Requests", "Input", "Output", "Cache write", "Cache read", "API cost", "Compactions avoided", "Tokens saved", "Cost saved"].map((title) => c.dim(title)),
|
|
226
|
+
...list.map((bucket) => row(bucket.label, bucket.usage, bucket.logged, matched, avoided)),
|
|
227
|
+
row("Total", usage, logged, matched, avoided, true),
|
|
207
228
|
])
|
|
208
229
|
|
|
230
|
+
const longest = threadPeaks(all, avoided)
|
|
231
|
+
.filter((peak) => Date.parse(peak.lastSeen) >= since)
|
|
232
|
+
.slice(0, LONGEST)
|
|
233
|
+
if (longest.length > 0) {
|
|
234
|
+
line()
|
|
235
|
+
line(` ${c.dim("Longest conversations, by the history Claude Code would have had to carry")}`)
|
|
236
|
+
table(longest.map(longestRow))
|
|
237
|
+
}
|
|
238
|
+
|
|
209
239
|
const models = byModel(usage)
|
|
210
240
|
if (models.length > 0) {
|
|
211
241
|
line()
|
|
@@ -216,7 +246,8 @@ export const stats: Command = {
|
|
|
216
246
|
const handled = savings(logged, matched)
|
|
217
247
|
const notes: Array<[label: string, text: string]> = [
|
|
218
248
|
["API cost", "At list prices, including cache writes and fast mode."],
|
|
219
|
-
["
|
|
249
|
+
["Compactions", `Times Claude Code would have compacted a conversation eunoe handled, at ${Math.round(REPLAY_NATIVE_COMPACT_AT * 100)}% of its window. eunoe kept every turn instead.`],
|
|
250
|
+
["Tokens saved", "Input eunoe kept out, mostly history Claude Code would have read from cache."],
|
|
220
251
|
["Cost saved", "Those tokens at cache-read prices, minus what each fold cost to rewrite."],
|
|
221
252
|
...(handled.priced < handled.handled ? [["Not priced", `${(handled.handled - handled.priced).toLocaleString()} of ${handled.handled.toLocaleString()} eunoe requests: ${leftOut(handled)}.`] as [string, string]] : []),
|
|
222
253
|
...(summarize(usage).unpriced ? [["* No price", "Some models have no known price, so their tokens count but their cost doesn't."] as [string, string]] : []),
|
package/src/cli/proxy/stats.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { DEFAULT_RATIO } from "../../proxy/estimate"
|
|
2
|
+
import { NATIVE_SUMMARY_CHARS, REPLAY_NATIVE_COMPACT_AT } from "../replay"
|
|
1
3
|
import type { RequestRecord } from "./inspect"
|
|
2
4
|
|
|
3
5
|
export interface SessionStats {
|
|
@@ -84,3 +86,49 @@ export function savedShare(context: number, plain: number | null): number | null
|
|
|
84
86
|
if (!plain) return null
|
|
85
87
|
return plain > context ? 1 - context / plain : 0
|
|
86
88
|
}
|
|
89
|
+
|
|
90
|
+
export function nativeCompactions(records: RequestRecord[]): Set<RequestRecord> {
|
|
91
|
+
const summary = Math.round(NATIVE_SUMMARY_CHARS * DEFAULT_RATIO)
|
|
92
|
+
const offsets = new Map<string, number>()
|
|
93
|
+
const compacted = new Set<RequestRecord>()
|
|
94
|
+
for (const record of records) {
|
|
95
|
+
const plain = scaled(record, record.charsIn)
|
|
96
|
+
const window = typeof record.window === "number" ? record.window : 0
|
|
97
|
+
if (typeof record.thread !== "string" || plain === null || window <= 0) continue
|
|
98
|
+
if (plain - (offsets.get(record.thread) ?? 0) <= REPLAY_NATIVE_COMPACT_AT * window) continue
|
|
99
|
+
compacted.add(record)
|
|
100
|
+
offsets.set(record.thread, plain - (scaled(record, record.charsFixed) ?? 0) - summary)
|
|
101
|
+
}
|
|
102
|
+
return compacted
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface ThreadPeak {
|
|
106
|
+
thread: string
|
|
107
|
+
session: string
|
|
108
|
+
plain: number
|
|
109
|
+
sent: number
|
|
110
|
+
turns: number | null
|
|
111
|
+
compactions: number
|
|
112
|
+
lastSeen: string
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function threadPeaks(records: RequestRecord[], compacted: Set<RequestRecord>): ThreadPeak[] {
|
|
116
|
+
const peaks = new Map<string, ThreadPeak>()
|
|
117
|
+
for (const record of records) {
|
|
118
|
+
const plain = scaled(record, record.charsIn)
|
|
119
|
+
const session = sessionOf(record)
|
|
120
|
+
if (typeof record.thread !== "string" || !session || plain === null) continue
|
|
121
|
+
const previous = peaks.get(record.thread)
|
|
122
|
+
const higher = !previous || plain >= previous.plain
|
|
123
|
+
peaks.set(record.thread, {
|
|
124
|
+
thread: record.thread,
|
|
125
|
+
session,
|
|
126
|
+
plain: higher ? plain : previous.plain,
|
|
127
|
+
sent: higher ? inputOf(record) : previous.sent,
|
|
128
|
+
turns: record.turns ?? previous?.turns ?? null,
|
|
129
|
+
compactions: (previous?.compactions ?? 0) + (compacted.has(record) ? 1 : 0),
|
|
130
|
+
lastSeen: record.ts,
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
return [...peaks.values()].sort((a, b) => b.plain - a.plain)
|
|
134
|
+
}
|
package/src/cli/replay/index.ts
CHANGED
|
@@ -43,7 +43,7 @@ export interface ReplayFailure {
|
|
|
43
43
|
|
|
44
44
|
export const REPLAY_NATIVE_COMPACT_AT = 0.92
|
|
45
45
|
|
|
46
|
-
const NATIVE_SUMMARY_CHARS = 30_000
|
|
46
|
+
export const NATIVE_SUMMARY_CHARS = 30_000
|
|
47
47
|
const CACHE_READ_WEIGHT = 0.1
|
|
48
48
|
const CACHE_WRITE_WEIGHT = 1.25
|
|
49
49
|
|
|
@@ -85,7 +85,7 @@ export function replaySession(messages: Message[], { config, window, fixedTokens
|
|
|
85
85
|
|
|
86
86
|
const state: ThreadState = {}
|
|
87
87
|
const eunoe = { sent: [] as Message[], sum: 0, count: 0, upTo: 0, recall: 0, compactedTurn: -2, previous: 0 }
|
|
88
|
-
const plain = { sum: 0, count: 0, since: 0, previous: 0 }
|
|
88
|
+
const plain = { sum: 0, count: 0, since: 0, previous: 0, whole: 0, wholeCount: 0 }
|
|
89
89
|
|
|
90
90
|
const sendEunoe = (i: number) => {
|
|
91
91
|
const turn = turnAt[i - 1]
|
|
@@ -119,10 +119,12 @@ export function replaySession(messages: Message[], { config, window, fixedTokens
|
|
|
119
119
|
for (; plain.since < i; plain.since += 1) {
|
|
120
120
|
plain.sum += sizes[plain.since]
|
|
121
121
|
plain.count += 1
|
|
122
|
+
plain.whole += sizes[plain.since]
|
|
123
|
+
plain.wholeCount += 1
|
|
122
124
|
}
|
|
125
|
+
out.peakPlain = Math.max(out.peakPlain, tokens(fixedChars + listChars(plain.whole, plain.wholeCount)))
|
|
123
126
|
let chars = fixedChars + listChars(plain.sum, plain.count)
|
|
124
127
|
let cacheable = plain.previous
|
|
125
|
-
out.peakPlain = Math.max(out.peakPlain, tokens(chars))
|
|
126
128
|
if (tokens(chars) > REPLAY_NATIVE_COMPACT_AT * window) {
|
|
127
129
|
out.nativeCompactions += 1
|
|
128
130
|
out.discarded += tokens(chars - fixedChars)
|
|
@@ -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)
|