@chroxify/eunoe 1.2.1 → 1.3.0

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.
Files changed (70) hide show
  1. package/README.md +143 -5
  2. package/dist/core/index.js +2235 -0
  3. package/dist/types/context/content.d.ts +31 -0
  4. package/dist/types/context/fold/evidence.d.ts +20 -0
  5. package/dist/types/context/fold/index.d.ts +11 -0
  6. package/dist/types/context/fold/strategies.d.ts +20 -0
  7. package/dist/types/context/fold/turns.d.ts +14 -0
  8. package/dist/types/context/prompts.d.ts +24 -0
  9. package/dist/types/context/recall/index.d.ts +44 -0
  10. package/dist/types/context/recall/rerank.d.ts +3 -0
  11. package/dist/types/core/compaction.d.ts +32 -0
  12. package/dist/types/core/config.d.ts +33 -0
  13. package/dist/types/core/estimate.d.ts +9 -0
  14. package/dist/types/core/fingerprint.d.ts +10 -0
  15. package/dist/types/core/fold.d.ts +18 -0
  16. package/dist/types/core/forward.d.ts +24 -0
  17. package/dist/types/core/handoff.d.ts +10 -0
  18. package/dist/types/core/index.d.ts +14 -0
  19. package/dist/types/core/recall.d.ts +8 -0
  20. package/dist/types/core/rewrite.d.ts +63 -0
  21. package/dist/types/core/state.d.ts +22 -0
  22. package/dist/types/core/subagents.d.ts +10 -0
  23. package/dist/types/core/transcript.d.ts +9 -0
  24. package/dist/types/core/usage.d.ts +7 -0
  25. package/dist/types/core/window.d.ts +5 -0
  26. package/dist/types/transcript/format.d.ts +16 -0
  27. package/dist/types/transcript/parse.d.ts +45 -0
  28. package/package.json +15 -2
  29. package/src/cli/commands/mode.ts +2 -1
  30. package/src/cli/commands/serve.ts +2 -0
  31. package/src/cli/commands/status.ts +1 -1
  32. package/src/cli/proxy/stats.ts +1 -1
  33. package/src/cli/replay/index.ts +3 -3
  34. package/src/cli/sessions/index.ts +2 -2
  35. package/src/config/index.ts +4 -27
  36. package/src/context/content.ts +64 -1
  37. package/src/context/fold/strategies.ts +55 -10
  38. package/src/context/fold/turns.ts +22 -2
  39. package/src/context/prompts.ts +4 -3
  40. package/src/context/recall/index.ts +259 -35
  41. package/src/context/recall/rerank.ts +0 -8
  42. package/src/{proxy → core}/compaction.ts +74 -24
  43. package/src/core/config.ts +31 -0
  44. package/src/core/fold.ts +69 -0
  45. package/src/core/forward.ts +192 -0
  46. package/src/{proxy → core}/handoff.ts +23 -23
  47. package/src/core/index.ts +14 -0
  48. package/src/{proxy → core}/recall.ts +18 -20
  49. package/src/core/rewrite.ts +189 -0
  50. package/src/core/state.ts +40 -0
  51. package/src/core/subagents.ts +28 -0
  52. package/src/core/transcript.ts +44 -0
  53. package/src/{proxy → core}/usage.ts +34 -1
  54. package/src/core/window.ts +39 -0
  55. package/src/proxy/rewrite.ts +22 -139
  56. package/src/proxy/server.ts +24 -137
  57. package/src/proxy/sessions.ts +1 -1
  58. package/src/proxy/sources.ts +35 -0
  59. package/src/proxy/state.ts +32 -21
  60. package/src/proxy/warm.ts +68 -0
  61. package/src/proxy/window.ts +3 -33
  62. package/src/transcript/entries.ts +114 -0
  63. package/src/transcript/format.ts +1 -2
  64. package/src/transcript/messages.ts +14 -11
  65. package/src/transcript/render-worker.ts +24 -0
  66. package/src/transcript/render.ts +12 -3
  67. package/src/transcript/search.ts +46 -5
  68. package/src/transcript/subagents.ts +55 -34
  69. /package/src/{proxy → core}/estimate.ts +0 -0
  70. /package/src/{proxy → core}/fingerprint.ts +0 -0
@@ -2,8 +2,8 @@ import { spawnSync } from "node:child_process"
2
2
  import { claudeConfigDirs, readConfig } from "../../config"
3
3
  import { FOLD_CAP_CHARS, foldedView, sourceChunks } from "../../context/fold"
4
4
  import { recallFor } from "../../context/recall"
5
- import { DEFAULT_RATIO } from "../../proxy/estimate"
6
- import { continueToken } from "../../proxy/handoff"
5
+ import { DEFAULT_RATIO } from "../../core/estimate"
6
+ import { continueToken } from "../../core/handoff"
7
7
  import { seenSessions } from "../../proxy/sessions"
8
8
  import { isSessionId } from "../../transcript/locate"
9
9
  import { subagentChunks } from "../../transcript/subagents"
@@ -1,29 +1,13 @@
1
1
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
2
2
  import { homedir } from "node:os"
3
3
  import path from "node:path"
4
+ import { CORE_DEFAULTS, type CoreConfig, type Mode } from "../core/config"
4
5
  import { EUNOE_DIR, paths } from "./paths"
5
6
 
6
- export type Mode = "off" | "compact" | "default"
7
7
  export type SearchMode = "jsonl" | "markdown" | "xml" | "qmd"
8
8
  type Scope = "all" | "sessions"
9
9
 
10
- interface EvalConfig {
11
- recallOptions?: { maxChars?: number; maxCharsMulti?: number; perQuery?: number }
12
- strategy?: "tail" | "guide" | "trim"
13
- rolling?: { keep: number; budget: number }
14
- forceCut?: boolean
15
- trustHeaders?: boolean
16
- evidence?: boolean
17
- recall?: boolean
18
- rerank?: { model: string; candidates?: number; endpoint?: string }
19
- }
20
-
21
- export interface Tunables {
22
- compactAt: number
23
- keepTurns: number | "all"
24
- keep: number
25
- budget: number
26
- turnCap: number | "off"
10
+ export interface Tunables extends Pick<CoreConfig, "compactAt" | "keepTurns" | "keep" | "budget" | "turnCap"> {
27
11
  search: SearchMode
28
12
  rerank: string
29
13
  }
@@ -34,27 +18,20 @@ interface SessionSettings extends Partial<Tunables> {
34
18
  mode?: Mode
35
19
  }
36
20
 
37
- export interface Config extends Tunables {
38
- mode: Mode
21
+ export interface Config extends CoreConfig, Tunables {
39
22
  scope: Scope
40
23
  sessions: Record<string, SessionSettings>
41
24
  port: number
42
25
  upstream: string
43
26
  claudeConfigDirs: string[]
44
27
  window?: number
45
- eval?: EvalConfig
46
28
  }
47
29
 
48
30
  export const DEFAULTS: Config = {
49
- mode: "default",
31
+ ...CORE_DEFAULTS,
50
32
  scope: "all",
51
33
  sessions: {},
52
34
  port: 38663,
53
- compactAt: 0.9,
54
- keepTurns: "all",
55
- keep: 3,
56
- budget: 100_000,
57
- turnCap: 100_000,
58
35
  search: "markdown",
59
36
  rerank: "jev-preview",
60
37
  upstream: "https://api.anthropic.com",
@@ -45,8 +45,71 @@ export function clean(message: Message): Message {
45
45
  return { role: message.role, content: blocks(message).map(withoutCacheControl) }
46
46
  }
47
47
 
48
+ const SHA256_ROUNDS = new Uint32Array([
49
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6,
50
+ 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb,
51
+ 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee,
52
+ 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
53
+ ])
54
+
55
+ const utf8 = new TextEncoder()
56
+
57
+ const rotate = (x: number, n: number) => (x >>> n) | (x << (32 - n))
58
+
59
+ export function sha256(text: string): string {
60
+ const data = utf8.encode(text)
61
+ const size = ((data.length + 72) >> 6) << 6
62
+ const bytes = new Uint8Array(size)
63
+ bytes.set(data)
64
+ bytes[data.length] = 0x80
65
+ const view = new DataView(bytes.buffer)
66
+ view.setUint32(size - 8, Math.floor(data.length / 0x20000000))
67
+ view.setUint32(size - 4, (data.length << 3) >>> 0)
68
+ const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19])
69
+ const w = new Uint32Array(64)
70
+ for (let offset = 0; offset < size; offset += 64) {
71
+ for (let i = 0; i < 16; i += 1) w[i] = view.getUint32(offset + i * 4)
72
+ for (let i = 16; i < 64; i += 1) w[i] = (w[i - 16] + (rotate(w[i - 15], 7) ^ rotate(w[i - 15], 18) ^ (w[i - 15] >>> 3)) + w[i - 7] + (rotate(w[i - 2], 17) ^ rotate(w[i - 2], 19) ^ (w[i - 2] >>> 10))) | 0
73
+ let [a, b, c, d, e, f, g, k] = h
74
+ for (let i = 0; i < 64; i += 1) {
75
+ const t1 = (k + (rotate(e, 6) ^ rotate(e, 11) ^ rotate(e, 25)) + ((e & f) ^ (~e & g)) + SHA256_ROUNDS[i] + w[i]) | 0
76
+ const t2 = ((rotate(a, 2) ^ rotate(a, 13) ^ rotate(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) | 0
77
+ k = g
78
+ g = f
79
+ f = e
80
+ e = (d + t1) | 0
81
+ d = c
82
+ c = b
83
+ b = a
84
+ a = (t1 + t2) | 0
85
+ }
86
+ h[0] += a
87
+ h[1] += b
88
+ h[2] += c
89
+ h[3] += d
90
+ h[4] += e
91
+ h[5] += f
92
+ h[6] += g
93
+ h[7] += k
94
+ }
95
+ return Array.from(h, (x) => x.toString(16).padStart(8, "0")).join("")
96
+ }
97
+
48
98
  export function shortHash(json: string, chars: number): string {
49
- return new Bun.CryptoHasher("sha256").update(json).digest("hex").slice(0, chars)
99
+ return sha256(json).slice(0, chars)
100
+ }
101
+
102
+ export function quickHash(text: string): string {
103
+ let a = 0xdeadbeef ^ text.length
104
+ let b = 0x41c6ce57 ^ text.length
105
+ for (let i = 0; i < text.length; i += 1) {
106
+ const code = text.charCodeAt(i)
107
+ a = Math.imul(a ^ code, 2654435761)
108
+ b = Math.imul(b ^ code, 1597334677)
109
+ }
110
+ const high = Math.imul(a ^ (a >>> 16), 2246822507) ^ Math.imul(b ^ (b >>> 13), 3266489909)
111
+ const low = Math.imul(b ^ (b >>> 16), 2246822507) ^ Math.imul(high ^ (high >>> 13), 3266489909)
112
+ return `${(low >>> 0).toString(36)}${(high >>> 0).toString(36)}`
50
113
  }
51
114
 
52
115
  export function truncate(text: string, max: number): string {
@@ -1,6 +1,6 @@
1
- import { type Block, type Body, blocks, type Message, truncate, withoutCacheControl } from "../content"
1
+ import { type Block, type Body, blocks, clean, type Message, truncate, withoutCacheControl } from "../content"
2
2
  import { clearedInput, compactionNotice, truncationNotice } from "../prompts"
3
- import { hasContent, isHarness, type ReduceOptions, reduceTurn, splitTurns } from "./turns"
3
+ import { hasContent, isHarness, isStaleNote, type ReduceOptions, reduceTurn, splitTurns, turnFingerprint } from "./turns"
4
4
 
5
5
  export interface CompactOptions extends ReduceOptions {
6
6
  keepTurns: number
@@ -8,26 +8,70 @@ export interface CompactOptions extends ReduceOptions {
8
8
  guide: string | null
9
9
  omittedBefore?: number
10
10
  reduce?: (turn: Message[], options: ReduceOptions) => Message[]
11
+ pinned?: ReadonlySet<string>
11
12
  }
12
13
 
14
+ const pinnedBy =
15
+ ({ pinned }: CompactOptions) =>
16
+ (turn: Message[]) =>
17
+ pinned !== undefined && pinned.size > 0 && pinned.has(turnFingerprint(turn))
18
+
19
+ export const countPinned = (turns: Message[][], pinned: ReadonlySet<string>) => (pinned.size > 0 ? turns.filter((turn) => pinned.has(turnFingerprint(turn))).length : 0)
20
+
13
21
  export function trimMessages(messages: Message[], options: ReduceOptions = {}): Message[] {
14
22
  const { preamble, turns } = splitTurns(messages)
15
23
  if (turns.length < 2) return messages
16
24
  return [...preamble, ...turns.slice(0, -1).flatMap((turn) => reduceTurn(turn, options)), ...(turns.at(-1) ?? [])]
17
25
  }
18
26
 
27
+ export const CARRIED_CHARS = 80_000
28
+
29
+ function carriedHarness(removed: Message[]): Block[] {
30
+ const texts = removed
31
+ .filter((m) => isHarness(m) && hasContent(m))
32
+ .flatMap((m) => blocks(m).filter((b) => b.type === "text"))
33
+ .map((b) => String(b.text ?? ""))
34
+ .filter((text) => text.trim() && !isStaleNote(text))
35
+ const kept: string[] = []
36
+ const seen = new Set<string>()
37
+ let used = 0
38
+ for (const text of texts.toReversed()) {
39
+ if (seen.has(text) || used + text.length > CARRIED_CHARS) continue
40
+ seen.add(text)
41
+ kept.push(text)
42
+ used += text.length
43
+ }
44
+ return kept.toReversed().map((text) => ({ type: "text", text: `<system-reminder>\n${text}\n</system-reminder>` }))
45
+ }
46
+
47
+ export const foldsOpening = (opening: Message[], keptFrom: number) => keptFrom === 1 && opening.length > 1
48
+
19
49
  export function compactMessages(messages: Message[], cutIndex: number, options: CompactOptions): Message[] {
20
50
  const { preamble, turns } = splitTurns(messages)
21
51
  if (cutIndex <= 0 || cutIndex >= turns.length) return messages
22
52
  const reduce = options.reduce ?? reduceTurn
53
+ const isPinned = pinnedBy(options)
23
54
  const keptFrom = Math.max(1, cutIndex - options.keepTurns)
24
- const notice = compactionNotice({ omitted: keptFrom - 1 + (options.omittedBefore ?? 0), kept: cutIndex - keptFrom, guide: options.guide })
25
- const removed = [...turns[0].slice(1), ...turns.slice(1, keptFrom).flat()]
26
- const carried = removed
27
- .filter((m) => isHarness(m) && hasContent(m))
28
- .flatMap((m) => blocks(m).filter((b) => b.type === "text"))
29
- .map((b) => ({ type: "text", text: `<system-reminder>\n${b.text}\n</system-reminder>` }))
30
- return [...preamble, turns[0][0], { role: "user", content: [{ type: "text", text: notice }, ...carried] }, ...turns.slice(keptFrom, cutIndex).flatMap((turn) => reduce(turn, options)), ...turns.slice(cutIndex).flat()]
55
+ const [first, ...rest] = turns[0]
56
+ const openingPinned = isPinned(turns[0])
57
+ const openingFolded = !openingPinned && foldsOpening(turns[0], keptFrom)
58
+ const dropped = turns.slice(1, keptFrom)
59
+ const whole = dropped.filter(isPinned)
60
+ const pinned = whole.length + turns.slice(keptFrom, cutIndex).filter(isPinned).length + (openingPinned ? 1 : 0)
61
+ const kept = cutIndex - keptFrom + (openingFolded ? 1 : 0)
62
+ const notice = compactionNotice({ omitted: dropped.length - whole.length + (options.omittedBefore ?? 0), kept, pinned, guide: options.guide })
63
+ const removed = [...(openingPinned || openingFolded ? [] : rest), ...dropped.filter((turn) => !isPinned(turn)).flat()]
64
+ const shown = (turn: Message[]) => (isPinned(turn) ? turn.map(clean) : reduce(turn, options))
65
+ return [
66
+ ...preamble,
67
+ first,
68
+ ...(openingPinned ? rest.map(clean) : []),
69
+ { role: "user", content: [{ type: "text", text: notice }, ...carriedHarness(removed)] },
70
+ ...(openingFolded ? reduce(turns[0], options).slice(1) : []),
71
+ ...whole.flatMap((turn) => turn.map(clean)),
72
+ ...turns.slice(keptFrom, cutIndex).flatMap(shown),
73
+ ...turns.slice(cutIndex).flat(),
74
+ ]
31
75
  }
32
76
 
33
77
  export function capTurns(messages: Message[], options: CompactOptions): Message[] {
@@ -36,7 +80,8 @@ export function capTurns(messages: Message[], options: CompactOptions): Message[
36
80
  const { preamble, turns } = splitTurns(messages)
37
81
  if (turns.length < 3) return messages
38
82
  const live = turns.length - 1
39
- const over = (turn: Message[], i: number) => i > 0 && i < live && JSON.stringify(turn).length > capChars
83
+ const isPinned = pinnedBy(options)
84
+ const over = (turn: Message[], i: number) => i > 0 && i < live && !isPinned(turn) && JSON.stringify(turn).length > capChars
40
85
  if (!turns.some(over)) return messages
41
86
  const reduce = options.reduce ?? reduceTurn
42
87
  return [...preamble, ...turns.flatMap((turn, i) => (over(turn, i) ? reduce(turn, options) : turn))]
@@ -6,9 +6,18 @@ export interface ReduceOptions {
6
6
  evidence?: boolean
7
7
  }
8
8
 
9
+ const IMAGE_NOTE = /^\s*\[Image: original \d+x\d+, displayed at \d+x\d+\.[^\]]*\]\s*$/
10
+
11
+ const STALE_NOTE = /changed on disk since you last read it|was modified, either by the user or by a linter/
12
+
13
+ export const isStaleNote = (text: unknown) => STALE_NOTE.test(String(text ?? ""))
14
+
15
+ const isImageNote = (content: Block[]) => content.length > 0 && content.every((block) => block.type === "text" && IMAGE_NOTE.test(String(block.text ?? "")))
16
+
9
17
  export function isPrompt(message: Message) {
10
18
  if (message.role !== "user") return false
11
19
  const content = blocks(message)
20
+ if (isImageNote(content)) return false
12
21
  return !content.some((block) => block.type === "tool_result") && content.some((block) => block.type === "text" || block.type === "image" || block.type === "document")
13
22
  }
14
23
 
@@ -39,7 +48,18 @@ export function hasContent(message: Message) {
39
48
  const hasText = (block: Block) => block.type === "text" && String(block.text ?? "").trim() !== ""
40
49
 
41
50
  function mergedHarness(turn: Message[]): Message[] {
42
- const content = turn.filter((m) => isHarness(m) && hasContent(m)).flatMap((m) => blocks(m).map(withoutCacheControl))
51
+ const seen = new Set<string>()
52
+ const fresh = (block: Block) => {
53
+ if (block.type !== "text") return true
54
+ const text = String(block.text ?? "")
55
+ if (isStaleNote(text) || seen.has(text)) return false
56
+ seen.add(text)
57
+ return true
58
+ }
59
+ const content = turn
60
+ .filter((m) => isHarness(m) && hasContent(m))
61
+ .flatMap((m) => blocks(m).map(withoutCacheControl))
62
+ .filter(fresh)
43
63
  return content.length > 0 ? [{ role: "system", content }] : []
44
64
  }
45
65
 
@@ -56,7 +76,7 @@ function interrupted(turn: Message[]): string {
56
76
  function carriedText(messages: Message[]): Block[] {
57
77
  return messages
58
78
  .filter((m) => m.role === "user")
59
- .flatMap((m) => blocks(m).filter((b) => hasText(b) && !/^\s*(?:<system-reminder>[\s\S]*?<\/system-reminder>\s*)+$/.test(String(b.text))))
79
+ .flatMap((m) => blocks(m).filter((b) => hasText(b) && !IMAGE_NOTE.test(String(b.text)) && !/^\s*(?:<system-reminder>[\s\S]*?<\/system-reminder>\s*)+$/.test(String(b.text))))
60
80
  .map((b) => ({ type: "text", text: b.text }))
61
81
  }
62
82
 
@@ -1,9 +1,9 @@
1
- import type { TranscriptRef } from "../transcript/search"
2
1
  import { truncate } from "./content"
3
2
 
4
3
  interface NoticeCounts {
5
4
  omitted: number
6
5
  kept: number
6
+ pinned?: number
7
7
  guide: string | null
8
8
  }
9
9
 
@@ -68,7 +68,7 @@ export function clearedInput(kept: string, cleared: number): string {
68
68
  return `${kept}… [${cleared} chars cleared; in the transcript]`
69
69
  }
70
70
 
71
- export function compactionNotice({ omitted, kept, guide }: NoticeCounts): string {
71
+ export function compactionNotice({ omitted, kept, pinned = 0, guide }: NoticeCounts): string {
72
72
  return [
73
73
  "<context-compacted>",
74
74
  "Your context was compacted here, so tool calls and their output from before this point were removed.",
@@ -77,6 +77,7 @@ export function compactionNotice({ omitted, kept, guide }: NoticeCounts): string
77
77
  : kept > 0
78
78
  ? "Every earlier turn is shown below as the user's message and your final reply."
79
79
  : "",
80
+ pinned > 0 ? `${pinned === 1 ? "One earlier turn was" : `${pinned} earlier turns were`} kept whole, with their tool calls and output, because they matter to what comes next.` : "",
80
81
  "The turn that was in progress when the context filled up follows in full. Continue from it.",
81
82
  "Instructions and preferences the user gave in earlier turns still apply: how they want things done, and what to do or not do on your own. Look back over their messages before you act.",
82
83
  "For an exact detail from before this point (an ID, a path, a command, an error, a value, what a tool printed), look it up instead of going from memory. A lookup is cheap; a wrong detail is not. Never say you don't know about something earlier in this session without searching for it first, and if what you remember contradicts what the user says, search before telling them they are wrong.",
@@ -137,7 +138,7 @@ export function searchOnlyInstructions(guide: string): string {
137
138
  ].join("\n")
138
139
  }
139
140
 
140
- export function searchGuide(ref: TranscriptRef): string {
141
+ export function searchGuide(ref: { mode: string; jsonl: string; dir: string }): string {
141
142
  if (ref.mode === "jsonl") {
142
143
  return [
143
144
  `The full transcript is Claude Code's session file: ${ref.jsonl}`,
@@ -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,214 @@ 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
+ if (!said) return
127
+ const built = chunksOf(`${size}|said|${turnIndex}|${said}`, said, "agent", turnIndex + 1, () => windows(said.split("\n"), size).map((window) => ({ turn: turnIndex + 1, call: "agent", lines: window, terms: terms(window.join("\n")) })))
128
+ for (const chunk of built) chunks.push(chunk)
78
129
  })
79
130
  return chunks
80
131
  }
81
132
 
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)
133
+ interface RankedChunk {
134
+ index: number
135
+ score: number
136
+ matched: number
137
+ }
138
+
139
+ interface Postings {
140
+ chunks: RecallChunk[]
141
+ counts: number[]
142
+ }
143
+
144
+ interface CorpusIndex {
145
+ members: Set<RecallChunk>
146
+ postings: Map<string, Postings>
147
+ }
148
+
149
+ const INDEXED_FROM = 2_000
150
+ const KEPT_INDEXES = 6
151
+ const SAMPLED = 32
152
+
153
+ const indexes: CorpusIndex[] = []
154
+
155
+ function addToIndex(index: CorpusIndex, chunk: RecallChunk) {
156
+ index.members.add(chunk)
157
+ const counts = new Map<string, number>()
158
+ for (const term of chunk.terms) counts.set(term, (counts.get(term) ?? 0) + 1)
159
+ for (const [term, count] of counts) {
160
+ const postings = index.postings.get(term)
161
+ if (postings) {
162
+ postings.chunks.push(chunk)
163
+ postings.counts.push(count)
164
+ } else index.postings.set(term, { chunks: [chunk], counts: [count] })
165
+ }
166
+ }
167
+
168
+ function overlap(index: CorpusIndex, chunks: RecallChunk[]): number {
169
+ const step = Math.max(1, Math.floor(chunks.length / SAMPLED))
170
+ let found = 0
171
+ let sampled = 0
172
+ for (let i = 0; i < chunks.length; i += step) {
173
+ sampled += 1
174
+ if (index.members.has(chunks[i])) found += 1
175
+ }
176
+ return found / Math.max(1, sampled)
177
+ }
178
+
179
+ function indexFor(chunks: RecallChunk[]): CorpusIndex {
180
+ let best: CorpusIndex | null = null
181
+ let bestOverlap = 0.5
182
+ for (const index of indexes) {
183
+ const shared = overlap(index, chunks)
184
+ if (shared >= bestOverlap) {
185
+ best = index
186
+ bestOverlap = shared
187
+ }
188
+ }
189
+ let present = 0
190
+ if (best) for (const chunk of chunks) if (best.members.has(chunk)) present += 1
191
+ if (!best || best.members.size - present > chunks.length / 2) {
192
+ if (best) indexes.splice(indexes.indexOf(best), 1)
193
+ best = { members: new Set(), postings: new Map() }
194
+ present = 0
195
+ } else indexes.splice(indexes.indexOf(best), 1)
196
+ indexes.push(best)
197
+ if (indexes.length > KEPT_INDEXES) indexes.shift()
198
+ if (present < chunks.length) for (const chunk of chunks) if (!best.members.has(chunk)) addToIndex(best, chunk)
199
+ return best
200
+ }
201
+
202
+ let warming: { chunks: RecallChunk[]; index: CorpusIndex; next: number } | null = null
203
+
204
+ export function warmIndex(chunks: RecallChunk[], until: number): boolean {
205
+ if (chunks.length < INDEXED_FROM) return true
206
+ if (warming?.chunks !== chunks) {
207
+ const index: CorpusIndex = { members: new Set(), postings: new Map() }
208
+ indexes.push(index)
209
+ if (indexes.length > KEPT_INDEXES) indexes.shift()
210
+ warming = { chunks, index, next: 0 }
211
+ }
212
+ const { index } = warming
213
+ while (warming.next < chunks.length) {
214
+ const chunk = chunks[warming.next]
215
+ warming.next += 1
216
+ if (!index.members.has(chunk)) addToIndex(index, chunk)
217
+ if (warming.next < chunks.length && performance.now() > until) return false
218
+ }
219
+ warming = null
220
+ return true
221
+ }
222
+
223
+ function indexedAll(queries: string[][], chunks: RecallChunk[], minTerms: number): RankedChunk[][] | null {
224
+ const position = new Map<RecallChunk, number>()
225
+ let total = 0
226
+ chunks.forEach((chunk, i) => {
227
+ position.set(chunk, i)
228
+ total += chunk.terms.length
229
+ })
230
+ if (position.size !== chunks.length) return null
231
+ const index = indexFor(chunks)
232
+ const average = total / Math.max(1, chunks.length)
233
+ const hitsByTerm = new Map<string, Array<[number, number]>>()
234
+ const hitsOf = (term: string) => {
235
+ const known = hitsByTerm.get(term)
236
+ if (known) return known
237
+ const hits: Array<[number, number]> = []
238
+ const postings = index.postings.get(term)
239
+ if (postings)
240
+ postings.chunks.forEach((chunk, k) => {
241
+ const at = position.get(chunk)
242
+ if (at !== undefined) hits.push([at, postings.counts[k]])
243
+ })
244
+ hitsByTerm.set(term, hits)
245
+ return hits
246
+ }
247
+ return queries.map((query) => {
248
+ const lists = [...new Set(query)].map(hitsOf)
249
+ const needed = Math.max(1, Math.min(minTerms, lists.filter((hits) => hits.length > 0).length))
250
+ const found = new Map<number, Array<[number, number]>>()
251
+ lists.forEach((hits, k) => {
252
+ for (const [at, f] of hits) {
253
+ const mine = found.get(at)
254
+ if (mine) mine.push([k, f])
255
+ else found.set(at, [[k, f]])
256
+ }
257
+ })
258
+ const scored: RankedChunk[] = []
259
+ for (const at of [...found.keys()].sort((a, b) => a - b)) {
260
+ const mine = found.get(at) as Array<[number, number]>
261
+ if (mine.length < needed) continue
262
+ const length = chunks[at].terms.length
91
263
  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
264
+ for (const [k, f] of mine) {
265
+ const n = lists[k].length
98
266
  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))
267
+ score += (idf * (f * (BM25_K1 + 1))) / (f + BM25_K1 * (1 - BM25_B + (BM25_B * length) / average))
100
268
  }
101
- return { index, score, matched }
269
+ if (score > 0) scored.push({ index: at, score, matched: mine.length })
270
+ }
271
+ return scored.sort((a, b) => b.score - a.score)
272
+ })
273
+ }
274
+
275
+ function rankAll(queries: string[][], chunks: RecallChunk[], minTerms = 2): RankedChunk[][] {
276
+ return (chunks.length >= INDEXED_FROM && indexedAll(queries, chunks, minTerms)) || scanAll(queries, chunks, minTerms)
277
+ }
278
+
279
+ function scanAll(queries: string[][], chunks: RecallChunk[], minTerms: number): RankedChunk[][] {
280
+ const ids = new Map<string, number>()
281
+ for (const query of queries) for (const term of query) if (!ids.has(term)) ids.set(term, ids.size)
282
+ const df = new Int32Array(ids.size)
283
+ const counts = new Int32Array(ids.size)
284
+ const touched: number[] = []
285
+ const hits: Array<{ index: number; found: Array<[number, number]> }> = []
286
+ let total = 0
287
+ chunks.forEach((chunk, index) => {
288
+ total += chunk.terms.length
289
+ touched.length = 0
290
+ for (const term of chunk.terms) {
291
+ const id = ids.get(term)
292
+ if (id === undefined) continue
293
+ if (counts[id] === 0) touched.push(id)
294
+ counts[id] += 1
295
+ }
296
+ if (touched.length === 0) return
297
+ const found = touched.map((id): [number, number] => {
298
+ const f = counts[id]
299
+ counts[id] = 0
300
+ df[id] += 1
301
+ return [id, f]
102
302
  })
103
- .filter((r) => r.matched >= Math.min(minTerms, wanted.length) && r.score > 0)
104
- .sort((a, b) => b.score - a.score)
303
+ hits.push({ index, found })
304
+ })
305
+ const average = total / Math.max(1, chunks.length)
306
+ return queries.map((query) => {
307
+ const asked = [...new Set(query)].map((term) => ids.get(term) ?? -1)
308
+ const position = new Int32Array(ids.size).fill(-1)
309
+ asked.forEach((id, i) => {
310
+ position[id] = i
311
+ })
312
+ const needed = Math.max(1, Math.min(minTerms, asked.filter((id) => df[id] > 0).length))
313
+ const scored: RankedChunk[] = []
314
+ for (const { index, found } of hits) {
315
+ const mine = found.filter(([id]) => position[id] >= 0)
316
+ if (mine.length < needed) continue
317
+ if (mine.length > 1) mine.sort((a, b) => position[a[0]] - position[b[0]])
318
+ const length = chunks[index].terms.length
319
+ let score = 0
320
+ for (const [id, f] of mine) {
321
+ const n = df[id]
322
+ const idf = Math.log(1 + (chunks.length - n + 0.5) / (n + 0.5))
323
+ score += (idf * (f * (BM25_K1 + 1))) / (f + BM25_K1 * (1 - BM25_B + (BM25_B * length) / average))
324
+ }
325
+ if (score > 0) scored.push({ index, score, matched: mine.length })
326
+ }
327
+ return scored.sort((a, b) => b.score - a.score)
328
+ })
329
+ }
330
+
331
+ export function rank(query: string[], chunks: RecallChunk[], minTerms = 2): RankedChunk[] {
332
+ return rankAll([query], chunks, minTerms)[0]
105
333
  }
106
334
 
107
335
  function excerpt(chunk: RecallChunk, query: Set<string>, salient: boolean): string[] {
@@ -136,27 +364,23 @@ function merged(hits: Array<{ query: string[]; indices: number[] }>): RecallPick
136
364
  return [...picked.entries()].map(([index, query]) => ({ index, query }))
137
365
  }
138
366
 
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)
367
+ function rankedLines(prompt: string, chunks: RecallChunk[], options: RecallOptions, width: number) {
368
+ const lines = queriesOf(prompt)
369
+ const queries = lines.map(terms)
370
+ const results = rankAll(queries, chunks, options.minTerms)
371
+ return lines.map((line, i) => ({ line, query: queries[i], indices: results[i].slice(0, width).map((hit) => hit.index) }))
372
+ }
143
373
 
144
374
  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
- )
375
+ return merged(rankedLines(prompt, chunks, options, options.perQuery ?? 12))
151
376
  }
152
377
 
153
378
  export const identityReranker: Reranker = async (_query, _chunks, candidates) => candidates
154
379
 
155
380
  export async function pickRecallWith(prompt: string, chunks: RecallChunk[], rerank: Reranker, options: RecallOptions = {}): Promise<RecallPick[]> {
156
381
  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))
382
+ for (const { line, query, indices } of rankedLines(prompt, chunks, options, options.candidates ?? 30)) {
383
+ const reordered = await rerank(line, chunks, indices)
160
384
  hits.push({ query, indices: reordered.slice(0, options.perQuery ?? 12) })
161
385
  }
162
386
  return merged(hits)