@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 CHANGED
@@ -407,9 +407,10 @@ Recall works out of the box, but it gets noticeably better with
407
407
  [Jev](https://typesafe.ai) reordering the candidates. To turn that on, put a
408
408
  Typesafe API key in `~/.eunoe/typesafe-key` or in the `TYPESAFE_API_KEY`
409
409
  environment variable, and `eunoe status` will show the reranker as active.
410
- It can't slow anything down, because if Jev errors or takes longer than four
411
- seconds the recall simply uses the plain keyword order instead. To switch it
412
- off, run `eunoe settings set rerank off`.
410
+ The cost is time: the first request of each new message waits for Jev, about
411
+ 300 ms and at most four seconds, after which the recall falls back to the
412
+ plain keyword order. Every other request is unaffected. To switch it off, run
413
+ `eunoe settings set rerank off`.
413
414
 
414
415
  ### Logs
415
416
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chroxify/eunoe",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "Context management for Claude Code that never forgets what matters.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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.discarded - a.discarded)
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("Sessions Claude Code would have compacted, by history it would have lost")}`)
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.bold(c.green(`${plural(r.nativeCompactions, "compaction")} avoided`)),
219
- `${tokens(r.discarded)} tokens kept`,
220
- c.dim(`${plural(r.folds, "fold")} · ${tokens(r.plainTokens)} → ${tokens(r.eunoeTokens)} tokens`),
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 ? `${c.green(c.bold(dollars(sum.saved)))} ${share}` : `${c.yellow(`−${dollars(-sum.saved)}`)} ${share}`
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 input",
179
- "tokens it kept out of the requests it handled, and what that saved at",
180
- "cache-read prices once the cache writes its folds caused are paid for.",
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 logged = recentRequests(Number.POSITIVE_INFINITY).filter((record) => Date.parse(record.ts) >= since)
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", "Not sent by eunoe", "Cost saved"].map((title) => c.dim(title)),
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
- ["Not sent", "Input eunoe kept out, mostly history Claude Code would have read from cache."],
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]] : []),
@@ -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
+ }
@@ -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 headTerms = Array.from({ length: weight }, () => terms(head)).flat()
65
- for (const window of windows(text.split("\n"), size)) chunks.push({ turn: turnIndex + 1, call: head, lines: window, terms: [...headTerms, ...terms(window.join("\n"))] })
108
+ const key = typeof call.id === "string" && call.id ? `${size}|${weight}|${call.id}` : null
109
+ const built = chunksOf(key, text, head, turnIndex + 1, () => {
110
+ const headTerms = Array.from({ length: weight }, () => terms(head)).flat()
111
+ return windows(text.split("\n"), size).map((window) => ({ turn: turnIndex + 1, call: head, lines: window, terms: [...headTerms, ...terms(window.join("\n"))] }))
112
+ })
113
+ for (const chunk of built) chunks.push(chunk)
66
114
  }
67
115
  if (!(options.narrative ?? true)) return
68
116
  const said = turn
@@ -74,34 +122,215 @@ export function recallChunks(messages: Message[], keptIds: Map<string, string>,
74
122
  .map((b) => String(b.text ?? "").trim()),
75
123
  )
76
124
  .filter(Boolean)
77
- for (const window of windows(said.join("\n").split("\n"), size)) chunks.push({ turn: turnIndex + 1, call: "agent", lines: window, terms: terms(window.join("\n")) })
125
+ .join("\n")
126
+ const built = chunksOf(said ? `${size}|said|${turnIndex}|${said}` : null, said, "agent", turnIndex + 1, () =>
127
+ windows(said.split("\n"), size).map((window) => ({ turn: turnIndex + 1, call: "agent", lines: window, terms: terms(window.join("\n")) })),
128
+ )
129
+ for (const chunk of built) chunks.push(chunk)
78
130
  })
79
131
  return chunks
80
132
  }
81
133
 
82
- export function rank(query: string[], chunks: RecallChunk[], minTerms = 2) {
83
- const df = new Map<string, number>()
84
- for (const chunk of chunks) for (const term of new Set(chunk.terms)) df.set(term, (df.get(term) ?? 0) + 1)
85
- const average = chunks.reduce((s, c) => s + c.terms.length, 0) / Math.max(1, chunks.length)
86
- const wanted = [...new Set(query)].filter((t) => df.has(t))
87
- return chunks
88
- .map((chunk, index) => {
89
- const tf = new Map<string, number>()
90
- for (const term of chunk.terms) tf.set(term, (tf.get(term) ?? 0) + 1)
134
+ interface RankedChunk {
135
+ index: number
136
+ score: number
137
+ matched: number
138
+ }
139
+
140
+ interface Postings {
141
+ chunks: RecallChunk[]
142
+ counts: number[]
143
+ }
144
+
145
+ interface CorpusIndex {
146
+ members: Set<RecallChunk>
147
+ postings: Map<string, Postings>
148
+ }
149
+
150
+ const INDEXED_FROM = 2_000
151
+ const KEPT_INDEXES = 6
152
+ const SAMPLED = 32
153
+
154
+ const indexes: CorpusIndex[] = []
155
+
156
+ function addToIndex(index: CorpusIndex, chunk: RecallChunk) {
157
+ index.members.add(chunk)
158
+ const counts = new Map<string, number>()
159
+ for (const term of chunk.terms) counts.set(term, (counts.get(term) ?? 0) + 1)
160
+ for (const [term, count] of counts) {
161
+ const postings = index.postings.get(term)
162
+ if (postings) {
163
+ postings.chunks.push(chunk)
164
+ postings.counts.push(count)
165
+ } else index.postings.set(term, { chunks: [chunk], counts: [count] })
166
+ }
167
+ }
168
+
169
+ function overlap(index: CorpusIndex, chunks: RecallChunk[]): number {
170
+ const step = Math.max(1, Math.floor(chunks.length / SAMPLED))
171
+ let found = 0
172
+ let sampled = 0
173
+ for (let i = 0; i < chunks.length; i += step) {
174
+ sampled += 1
175
+ if (index.members.has(chunks[i])) found += 1
176
+ }
177
+ return found / Math.max(1, sampled)
178
+ }
179
+
180
+ function indexFor(chunks: RecallChunk[]): CorpusIndex {
181
+ let best: CorpusIndex | null = null
182
+ let bestOverlap = 0.5
183
+ for (const index of indexes) {
184
+ const shared = overlap(index, chunks)
185
+ if (shared >= bestOverlap) {
186
+ best = index
187
+ bestOverlap = shared
188
+ }
189
+ }
190
+ let present = 0
191
+ if (best) for (const chunk of chunks) if (best.members.has(chunk)) present += 1
192
+ if (!best || best.members.size - present > chunks.length / 2) {
193
+ if (best) indexes.splice(indexes.indexOf(best), 1)
194
+ best = { members: new Set(), postings: new Map() }
195
+ present = 0
196
+ } else indexes.splice(indexes.indexOf(best), 1)
197
+ indexes.push(best)
198
+ if (indexes.length > KEPT_INDEXES) indexes.shift()
199
+ if (present < chunks.length) for (const chunk of chunks) if (!best.members.has(chunk)) addToIndex(best, chunk)
200
+ return best
201
+ }
202
+
203
+ let warming: { chunks: RecallChunk[]; index: CorpusIndex; next: number } | null = null
204
+
205
+ export function warmIndex(chunks: RecallChunk[], until: number): boolean {
206
+ if (chunks.length < INDEXED_FROM) return true
207
+ if (warming?.chunks !== chunks) {
208
+ const index: CorpusIndex = { members: new Set(), postings: new Map() }
209
+ indexes.push(index)
210
+ if (indexes.length > KEPT_INDEXES) indexes.shift()
211
+ warming = { chunks, index, next: 0 }
212
+ }
213
+ const { index } = warming
214
+ while (warming.next < chunks.length) {
215
+ const chunk = chunks[warming.next]
216
+ warming.next += 1
217
+ if (!index.members.has(chunk)) addToIndex(index, chunk)
218
+ if (warming.next < chunks.length && performance.now() > until) return false
219
+ }
220
+ warming = null
221
+ return true
222
+ }
223
+
224
+ function indexedAll(queries: string[][], chunks: RecallChunk[], minTerms: number): RankedChunk[][] | null {
225
+ const position = new Map<RecallChunk, number>()
226
+ let total = 0
227
+ chunks.forEach((chunk, i) => {
228
+ position.set(chunk, i)
229
+ total += chunk.terms.length
230
+ })
231
+ if (position.size !== chunks.length) return null
232
+ const index = indexFor(chunks)
233
+ const average = total / Math.max(1, chunks.length)
234
+ const hitsByTerm = new Map<string, Array<[number, number]>>()
235
+ const hitsOf = (term: string) => {
236
+ const known = hitsByTerm.get(term)
237
+ if (known) return known
238
+ const hits: Array<[number, number]> = []
239
+ const postings = index.postings.get(term)
240
+ if (postings)
241
+ postings.chunks.forEach((chunk, k) => {
242
+ const at = position.get(chunk)
243
+ if (at !== undefined) hits.push([at, postings.counts[k]])
244
+ })
245
+ hitsByTerm.set(term, hits)
246
+ return hits
247
+ }
248
+ return queries.map((query) => {
249
+ const lists = [...new Set(query)].map(hitsOf)
250
+ const needed = Math.max(1, Math.min(minTerms, lists.filter((hits) => hits.length > 0).length))
251
+ const found = new Map<number, Array<[number, number]>>()
252
+ lists.forEach((hits, k) => {
253
+ for (const [at, f] of hits) {
254
+ const mine = found.get(at)
255
+ if (mine) mine.push([k, f])
256
+ else found.set(at, [[k, f]])
257
+ }
258
+ })
259
+ const scored: RankedChunk[] = []
260
+ for (const at of [...found.keys()].sort((a, b) => a - b)) {
261
+ const mine = found.get(at) as Array<[number, number]>
262
+ if (mine.length < needed) continue
263
+ const length = chunks[at].terms.length
91
264
  let score = 0
92
- let matched = 0
93
- for (const term of wanted) {
94
- const f = tf.get(term) ?? 0
95
- if (!f) continue
96
- matched += 1
97
- const n = df.get(term) ?? 0
265
+ for (const [k, f] of mine) {
266
+ const n = lists[k].length
98
267
  const idf = Math.log(1 + (chunks.length - n + 0.5) / (n + 0.5))
99
- score += (idf * (f * (BM25_K1 + 1))) / (f + BM25_K1 * (1 - BM25_B + (BM25_B * chunk.terms.length) / average))
268
+ score += (idf * (f * (BM25_K1 + 1))) / (f + BM25_K1 * (1 - BM25_B + (BM25_B * length) / average))
100
269
  }
101
- return { index, score, matched }
270
+ if (score > 0) scored.push({ index: at, score, matched: mine.length })
271
+ }
272
+ return scored.sort((a, b) => b.score - a.score)
273
+ })
274
+ }
275
+
276
+ function rankAll(queries: string[][], chunks: RecallChunk[], minTerms = 2): RankedChunk[][] {
277
+ return (chunks.length >= INDEXED_FROM && indexedAll(queries, chunks, minTerms)) || scanAll(queries, chunks, minTerms)
278
+ }
279
+
280
+ function scanAll(queries: string[][], chunks: RecallChunk[], minTerms: number): RankedChunk[][] {
281
+ const ids = new Map<string, number>()
282
+ for (const query of queries) for (const term of query) if (!ids.has(term)) ids.set(term, ids.size)
283
+ const df = new Int32Array(ids.size)
284
+ const counts = new Int32Array(ids.size)
285
+ const touched: number[] = []
286
+ const hits: Array<{ index: number; found: Array<[number, number]> }> = []
287
+ let total = 0
288
+ chunks.forEach((chunk, index) => {
289
+ total += chunk.terms.length
290
+ touched.length = 0
291
+ for (const term of chunk.terms) {
292
+ const id = ids.get(term)
293
+ if (id === undefined) continue
294
+ if (counts[id] === 0) touched.push(id)
295
+ counts[id] += 1
296
+ }
297
+ if (touched.length === 0) return
298
+ const found = touched.map((id): [number, number] => {
299
+ const f = counts[id]
300
+ counts[id] = 0
301
+ df[id] += 1
302
+ return [id, f]
102
303
  })
103
- .filter((r) => r.matched >= Math.min(minTerms, wanted.length) && r.score > 0)
104
- .sort((a, b) => b.score - a.score)
304
+ hits.push({ index, found })
305
+ })
306
+ const average = total / Math.max(1, chunks.length)
307
+ return queries.map((query) => {
308
+ const asked = [...new Set(query)].map((term) => ids.get(term) ?? -1)
309
+ const position = new Int32Array(ids.size).fill(-1)
310
+ asked.forEach((id, i) => {
311
+ position[id] = i
312
+ })
313
+ const needed = Math.max(1, Math.min(minTerms, asked.filter((id) => df[id] > 0).length))
314
+ const scored: RankedChunk[] = []
315
+ for (const { index, found } of hits) {
316
+ const mine = found.filter(([id]) => position[id] >= 0)
317
+ if (mine.length < needed) continue
318
+ if (mine.length > 1) mine.sort((a, b) => position[a[0]] - position[b[0]])
319
+ const length = chunks[index].terms.length
320
+ let score = 0
321
+ for (const [id, f] of mine) {
322
+ const n = df[id]
323
+ const idf = Math.log(1 + (chunks.length - n + 0.5) / (n + 0.5))
324
+ score += (idf * (f * (BM25_K1 + 1))) / (f + BM25_K1 * (1 - BM25_B + (BM25_B * length) / average))
325
+ }
326
+ if (score > 0) scored.push({ index, score, matched: mine.length })
327
+ }
328
+ return scored.sort((a, b) => b.score - a.score)
329
+ })
330
+ }
331
+
332
+ export function rank(query: string[], chunks: RecallChunk[], minTerms = 2): RankedChunk[] {
333
+ return rankAll([query], chunks, minTerms)[0]
105
334
  }
106
335
 
107
336
  function excerpt(chunk: RecallChunk, query: Set<string>, salient: boolean): string[] {
@@ -136,27 +365,23 @@ function merged(hits: Array<{ query: string[]; indices: number[] }>): RecallPick
136
365
  return [...picked.entries()].map(([index, query]) => ({ index, query }))
137
366
  }
138
367
 
139
- const ranked = (query: string[], chunks: RecallChunk[], options: RecallOptions, width: number) =>
140
- rank(query, chunks, options.minTerms)
141
- .slice(0, width)
142
- .map((hit) => hit.index)
368
+ function rankedLines(prompt: string, chunks: RecallChunk[], options: RecallOptions, width: number) {
369
+ const lines = queriesOf(prompt)
370
+ const queries = lines.map(terms)
371
+ const results = rankAll(queries, chunks, options.minTerms)
372
+ return lines.map((line, i) => ({ line, query: queries[i], indices: results[i].slice(0, width).map((hit) => hit.index) }))
373
+ }
143
374
 
144
375
  function pickRecall(prompt: string, chunks: RecallChunk[], options: RecallOptions): RecallPick[] {
145
- return merged(
146
- queriesOf(prompt).map((line) => {
147
- const query = terms(line)
148
- return { query, indices: ranked(query, chunks, options, options.perQuery ?? 12) }
149
- }),
150
- )
376
+ return merged(rankedLines(prompt, chunks, options, options.perQuery ?? 12))
151
377
  }
152
378
 
153
379
  export const identityReranker: Reranker = async (_query, _chunks, candidates) => candidates
154
380
 
155
381
  export async function pickRecallWith(prompt: string, chunks: RecallChunk[], rerank: Reranker, options: RecallOptions = {}): Promise<RecallPick[]> {
156
382
  const hits: Array<{ query: string[]; indices: number[] }> = []
157
- for (const line of queriesOf(prompt)) {
158
- const query = terms(line)
159
- const reordered = await rerank(line, chunks, ranked(query, chunks, options, options.candidates ?? 30))
383
+ for (const { line, query, indices } of rankedLines(prompt, chunks, options, options.candidates ?? 30)) {
384
+ const reordered = await rerank(line, chunks, indices)
160
385
  hits.push({ query, indices: reordered.slice(0, options.perQuery ?? 12) })
161
386
  }
162
387
  return merged(hits)
@@ -1,7 +1,7 @@
1
1
  import type { Config } from "../config"
2
2
  import type { Body, Message } from "../context/content"
3
3
  import { type CompactOptions, capTurns, clearToolResults, compactMessages, countToolResults } from "../context/fold/strategies"
4
- import { splitTurns, turnFingerprint } from "../context/fold/turns"
4
+ import { type ReduceOptions, reduceTurn, splitTurns, turnFingerprint } from "../context/fold/turns"
5
5
  import { clearedOutput } from "../context/prompts"
6
6
  import { DEFAULT_RATIO, type Estimator, estimator, targetSize } from "./estimate"
7
7
  import { resetCut, type ThreadState } from "./state"
@@ -19,6 +19,24 @@ export interface CompactInput {
19
19
  reduce?: CompactOptions["reduce"]
20
20
  }
21
21
 
22
+ const FOLDED_TURNS = 4096
23
+
24
+ const folded = new Map<string, Message[]>()
25
+
26
+ function reduceRemembered(turn: Message[], options: ReduceOptions): Message[] {
27
+ const key = `${options.evidence ? 1 : 0}:${Bun.hash(JSON.stringify(turn))}`
28
+ const known = folded.get(key)
29
+ if (known) {
30
+ folded.delete(key)
31
+ folded.set(key, known)
32
+ return known
33
+ }
34
+ const reduced = reduceTurn(turn, options)
35
+ folded.set(key, reduced)
36
+ if (folded.size > FOLDED_TURNS) folded.delete(folded.keys().next().value as string)
37
+ return reduced
38
+ }
39
+
22
40
  export interface CompactResult {
23
41
  messages: Message[]
24
42
  stateChanged: boolean
@@ -89,7 +107,7 @@ export function compact({ body, config, state, window, guide, transcriptDir, may
89
107
 
90
108
  const configuredKeep = config.keepTurns === "all" ? Number.MAX_SAFE_INTEGER : config.keepTurns
91
109
  const ratio = state.ratio ?? DEFAULT_RATIO
92
- const options = { guide, omittedBefore, evidence: config.eval?.evidence ?? true, reduce, capChars: config.turnCap === "off" ? undefined : config.turnCap / ratio }
110
+ const options = { guide, omittedBefore, evidence: config.eval?.evidence ?? true, reduce: reduce ?? reduceRemembered, capChars: config.turnCap === "off" ? undefined : config.turnCap / ratio }
93
111
  const liveStart = (list: Message[]) =>
94
112
  list.length -
95
113
  splitTurns(list)