@chroxify/eunoe 1.0.0 → 1.2.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 (88) hide show
  1. package/README.md +68 -9
  2. package/package.json +18 -2
  3. package/src/cli/{types.ts → command.ts} +0 -4
  4. package/src/cli/commands/index.ts +13 -1
  5. package/src/cli/commands/load.ts +44 -0
  6. package/src/cli/commands/mode.ts +41 -33
  7. package/src/cli/commands/peek.ts +22 -0
  8. package/src/cli/commands/replay.ts +245 -0
  9. package/src/cli/commands/serve.ts +22 -7
  10. package/src/cli/commands/sessions.ts +70 -0
  11. package/src/cli/commands/settings.ts +79 -60
  12. package/src/cli/commands/start.ts +56 -21
  13. package/src/cli/commands/stats.ts +233 -0
  14. package/src/cli/commands/status.ts +98 -55
  15. package/src/cli/commands/stop.ts +8 -6
  16. package/src/cli/commands/uninstall.ts +10 -12
  17. package/src/cli/commands/watch.ts +16 -0
  18. package/src/cli/logo/art.ts +99 -0
  19. package/src/cli/logo/index.ts +12 -18
  20. package/src/cli/main.ts +3 -3
  21. package/src/cli/proxy/claude-settings.ts +69 -0
  22. package/src/cli/proxy/inspect.ts +67 -0
  23. package/src/cli/proxy/launchd.ts +53 -0
  24. package/src/cli/proxy/live.ts +57 -0
  25. package/src/cli/proxy/service.ts +53 -0
  26. package/src/cli/{stats.ts → proxy/stats.ts} +29 -16
  27. package/src/cli/proxy/watch.ts +60 -0
  28. package/src/cli/replay/index.ts +146 -0
  29. package/src/cli/replay/worker.ts +20 -0
  30. package/src/cli/sessions/browse.ts +88 -0
  31. package/src/cli/sessions/index.ts +76 -0
  32. package/src/cli/sessions/source.ts +74 -0
  33. package/src/cli/{help.ts → terminal/help.ts} +24 -8
  34. package/src/cli/terminal/prompt.ts +114 -0
  35. package/src/cli/{ui.ts → terminal/ui.ts} +55 -24
  36. package/src/cli/usage/index.ts +151 -0
  37. package/src/config/index.ts +99 -34
  38. package/src/config/paths.ts +17 -0
  39. package/src/context/content.ts +40 -14
  40. package/src/context/fold/evidence.ts +145 -0
  41. package/src/context/fold/index.ts +65 -0
  42. package/src/context/fold/strategies.ts +94 -0
  43. package/src/context/fold/turns.ts +76 -0
  44. package/src/context/prompts.ts +141 -22
  45. package/src/context/recall/index.ts +214 -0
  46. package/src/context/recall/rerank.ts +71 -0
  47. package/src/proxy/canonical.ts +17 -9
  48. package/src/proxy/compaction.ts +106 -53
  49. package/src/proxy/estimate.ts +14 -9
  50. package/src/proxy/fingerprint.ts +23 -9
  51. package/src/proxy/handoff.ts +71 -0
  52. package/src/proxy/log.ts +2 -2
  53. package/src/proxy/recall.ts +58 -0
  54. package/src/proxy/rewrite.ts +141 -119
  55. package/src/proxy/server.ts +108 -51
  56. package/src/proxy/sessions.ts +32 -14
  57. package/src/proxy/state.ts +17 -3
  58. package/src/proxy/usage.ts +50 -34
  59. package/src/proxy/window.ts +32 -34
  60. package/src/transcript/format.ts +88 -25
  61. package/src/transcript/locate.ts +38 -17
  62. package/src/transcript/messages.ts +3 -14
  63. package/src/transcript/parse.ts +137 -56
  64. package/src/transcript/render.ts +44 -17
  65. package/src/transcript/search.ts +20 -56
  66. package/src/transcript/subagents.ts +150 -0
  67. package/src/cli/claude-settings.ts +0 -42
  68. package/src/cli/constants.ts +0 -52
  69. package/src/cli/inspect.ts +0 -41
  70. package/src/cli/launchd.ts +0 -39
  71. package/src/cli/logo/pixels.ts +0 -34
  72. package/src/cli/prompt.ts +0 -91
  73. package/src/cli/service.ts +0 -33
  74. package/src/cli/sessions.ts +0 -25
  75. package/src/config/constants.ts +0 -39
  76. package/src/config/types.ts +0 -50
  77. package/src/context/constants.ts +0 -42
  78. package/src/context/evidence.ts +0 -92
  79. package/src/context/recall.ts +0 -155
  80. package/src/context/rerank.ts +0 -44
  81. package/src/context/strategies.ts +0 -89
  82. package/src/context/turns.ts +0 -87
  83. package/src/context/types.ts +0 -66
  84. package/src/proxy/constants.ts +0 -29
  85. package/src/proxy/types.ts +0 -82
  86. package/src/transcript/constants.ts +0 -15
  87. package/src/transcript/types.ts +0 -43
  88. /package/src/cli/{args.ts → terminal/args.ts} +0 -0
@@ -1,11 +1,36 @@
1
- import { CLEARED_OUTPUT } from "../context/prompts"
2
- import { clearToolResults, compactMessages, countToolResults } from "../context/strategies"
3
- import { splitTurns, turnFingerprint } from "../context/turns"
4
- import { DEFAULT_RATIO, PROTECTED_OUTPUTS } from "./constants"
5
- import { estimator, targetSize } from "./estimate"
6
- import { resetCut } from "./state"
7
- import type { Message } from "../context/types"
8
- import type { CompactInput, CompactResult } from "./types"
1
+ import type { Config } from "../config"
2
+ import type { Body, Message } from "../context/content"
3
+ import { type CompactOptions, capTurns, clearToolResults, compactMessages, countToolResults } from "../context/fold/strategies"
4
+ import { splitTurns, turnFingerprint } from "../context/fold/turns"
5
+ import { clearedOutput } from "../context/prompts"
6
+ import { DEFAULT_RATIO, type Estimator, estimator, targetSize } from "./estimate"
7
+ import { resetCut, type ThreadState } from "./state"
8
+
9
+ export interface CompactInput {
10
+ body: Body
11
+ config: Config
12
+ state: ThreadState
13
+ window: number | null
14
+ guide: string | null
15
+ transcriptDir?: string | null
16
+ mayCompact: boolean
17
+ omittedBefore?: number
18
+ decorate?: (messages: Message[]) => Message[]
19
+ reduce?: CompactOptions["reduce"]
20
+ }
21
+
22
+ export interface CompactResult {
23
+ messages: Message[]
24
+ stateChanged: boolean
25
+ note: Record<string, unknown>
26
+ }
27
+
28
+ interface Rolling {
29
+ keep: number
30
+ budget: number
31
+ }
32
+
33
+ const PROTECTED_OUTPUTS = 5
9
34
 
10
35
  function settledCut(turns: Message[][], index: number) {
11
36
  let cut = index
@@ -13,80 +38,107 @@ function settledCut(turns: Message[][], index: number) {
13
38
  return cut
14
39
  }
15
40
 
16
- export function compact({ body, config, state, window, guide, mayCompact, omittedBefore = 0 }: CompactInput): CompactResult {
17
- const { turns } = splitTurns(body.messages)
18
- let stateChanged = false
19
-
41
+ function resolveCut(turns: Message[][], state: ThreadState, config: Config, rollingKeep: number | undefined) {
20
42
  let cutIndex = state.cut ? turns.findIndex((turn) => turnFingerprint(turn) === state.cut) : -1
43
+ let stateChanged = false
21
44
  if (state.cut && cutIndex === -1) {
22
45
  resetCut(state)
23
46
  stateChanged = true
24
47
  }
25
- const rolling = config.eval?.rolling ?? (config.mode === "default" ? { keep: config.keep, budget: config.budget } : undefined)
26
48
  if (cutIndex <= 0 && config.eval?.forceCut && turns.length >= 3) {
27
- const forced = settledCut(turns, Math.max(1, turns.length - 1 - (rolling?.keep ?? 1)))
28
- cutIndex = forced
29
- state.cut = turnFingerprint(turns[forced])
49
+ cutIndex = settledCut(turns, Math.max(1, turns.length - 1 - (rollingKeep ?? 1)))
50
+ state.cut = turnFingerprint(turns[cutIndex])
30
51
  stateChanged = true
31
52
  }
53
+ return { cutIndex, stateChanged }
54
+ }
55
+
56
+ function shrinkOnce(state: ThreadState, cutIndex: number, configuredKeep: number, liveResults: () => number): string {
57
+ const keep = Math.min(state.keep ?? configuredKeep, cutIndex - 1)
58
+ if (cutIndex > 0 && keep > 0) {
59
+ state.keep = keep - 1
60
+ return `keep=${state.keep}`
61
+ }
62
+ const remaining = liveResults() - (state.cleared ?? 0) - PROTECTED_OUTPUTS
63
+ if (remaining <= 0) return "stuck"
64
+ state.cleared = (state.cleared ?? 0) + Math.ceil(remaining / 2)
65
+ return `cleared=${state.cleared}`
66
+ }
67
+
68
+ function dueRoll(turns: Message[][], cutIndex: number, rolling: Rolling, size: Estimator): number | null {
69
+ const next = settledCut(turns, turns.length - 1 - rolling.keep)
70
+ const backlog = size.of(turns.slice(Math.max(cutIndex, 0), Math.max(next, 0)).flat()) - size.fixed
71
+ return next >= 1 && next > cutIndex && backlog > rolling.budget ? next : null
72
+ }
73
+
74
+ function shrinkToTarget(over: () => boolean, step: () => string, actions: string[], rebuild: () => void) {
75
+ while (over()) {
76
+ const action = step()
77
+ actions.push(action)
78
+ if (action === "stuck") return
79
+ rebuild()
80
+ }
81
+ }
82
+
83
+ export function compact({ body, config, state, window, guide, transcriptDir, mayCompact, omittedBefore = 0, decorate = (m) => m, reduce }: CompactInput): CompactResult {
84
+ const { turns } = splitTurns(body.messages)
85
+ const rolling = config.eval?.rolling ?? (config.mode === "default" ? { keep: config.keep, budget: config.budget } : undefined)
86
+ const resolved = resolveCut(turns, state, config, rolling?.keep)
87
+ let cutIndex = resolved.cutIndex
88
+ let stateChanged = resolved.stateChanged
32
89
 
33
90
  const configuredKeep = config.keepTurns === "all" ? Number.MAX_SAFE_INTEGER : config.keepTurns
34
- const liveLength = () => turns.slice(Math.max(cutIndex, 0)).flat().length
35
- const compacted = () => cutIndex > 0
36
- ? compactMessages(body.messages, cutIndex, { keepTurns: state.keep ?? configuredKeep, guide, omittedBefore, evidence: config.eval?.evidence ?? true })
37
- : body.messages
91
+ 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 }
93
+ const liveStart = (list: Message[]) =>
94
+ list.length -
95
+ splitTurns(list)
96
+ .turns.slice(-(turns.length - Math.max(cutIndex, 0)))
97
+ .flat().length
98
+ const compacted = () => capTurns(cutIndex > 0 ? compactMessages(body.messages, cutIndex, { ...options, keepTurns: state.keep ?? configuredKeep }) : body.messages, { ...options, keepTurns: 0 })
38
99
  const build = () => {
39
100
  const messages = compacted()
40
- return state.cleared ? clearToolResults(messages, messages.length - liveLength(), state.cleared, CLEARED_OUTPUT) : messages
101
+ return state.cleared ? clearToolResults(messages, liveStart(messages), state.cleared, clearedOutput(transcriptDir ?? null)) : messages
102
+ }
103
+ const liveResults = () => {
104
+ const uncleared = compacted()
105
+ return countToolResults(uncleared, liveStart(uncleared))
41
106
  }
42
107
 
43
- const size = estimator(body, state.ratio ?? DEFAULT_RATIO)
108
+ const size = estimator(body, ratio)
44
109
  const limit = window ? config.compactAt * window : Number.POSITIVE_INFINITY
45
110
  const target = window ? targetSize(size, window) : Number.POSITIVE_INFINITY
46
111
 
47
112
  let messages = build()
48
- let estimate = size.of(messages)
113
+ let estimate = size.of(decorate(messages))
49
114
  const actions: string[] = []
50
- const current = turns.length - 1
115
+ const rebuild = () => {
116
+ messages = build()
117
+ estimate = size.of(decorate(messages))
118
+ }
51
119
  const moveCut = (index: number, action: string) => {
52
120
  cutIndex = index
53
121
  resetCut(state)
54
122
  state.cut = turnFingerprint(turns[index])
55
123
  actions.push(action)
56
- messages = build()
57
- estimate = size.of(messages)
124
+ rebuild()
58
125
  }
59
126
 
60
- if (mayCompact && rolling) {
61
- const next = settledCut(turns, current - rolling.keep)
62
- const backlog = size.of(turns.slice(Math.max(cutIndex, 0), Math.max(next, 0)).flat()) - size.fixed
63
- if (next >= 1 && next > cutIndex && backlog > rolling.budget) {
64
- moveCut(next, "roll")
65
- stateChanged = true
66
- }
127
+ const roll = mayCompact && rolling ? dueRoll(turns, cutIndex, rolling, size) : null
128
+ if (roll !== null) {
129
+ moveCut(roll, "roll")
130
+ stateChanged = true
67
131
  }
68
132
 
69
133
  if (mayCompact && estimate > limit) {
134
+ const current = turns.length - 1
70
135
  if (current > 0 && current > cutIndex) moveCut(current, "cut")
71
- while (estimate > target) {
72
- const keep = Math.min(state.keep ?? configuredKeep, cutIndex - 1)
73
- if (cutIndex > 0 && keep > 0) {
74
- state.keep = keep - 1
75
- actions.push(`keep=${state.keep}`)
76
- } else {
77
- const unclearedLive = compacted()
78
- const total = countToolResults(unclearedLive, unclearedLive.length - liveLength())
79
- const remaining = total - (state.cleared ?? 0) - PROTECTED_OUTPUTS
80
- if (remaining <= 0) {
81
- actions.push("stuck")
82
- break
83
- }
84
- state.cleared = (state.cleared ?? 0) + Math.ceil(remaining / 2)
85
- actions.push(`cleared=${state.cleared}`)
86
- }
87
- messages = build()
88
- estimate = size.of(messages)
89
- }
136
+ shrinkToTarget(
137
+ () => estimate > target,
138
+ () => shrinkOnce(state, cutIndex, configuredKeep, liveResults),
139
+ actions,
140
+ rebuild,
141
+ )
90
142
  stateChanged = true
91
143
  }
92
144
 
@@ -96,8 +148,9 @@ export function compact({ body, config, state, window, guide, mayCompact, omitte
96
148
  note: {
97
149
  action: cutIndex > 0 ? "compact" : "full",
98
150
  compacted: actions.includes("cut") || actions.includes("roll"),
99
- actions: actions.length ? actions : undefined,
151
+ actions: actions.length > 0 ? actions : undefined,
100
152
  cutTurn: cutIndex > 0 ? cutIndex : null,
153
+ omitted: cutIndex > 0 ? Math.max(1, cutIndex - (state.keep ?? configuredKeep)) - 1 : 0,
101
154
  keep: state.keep,
102
155
  cleared: state.cleared,
103
156
  turns: turns.length,
@@ -1,15 +1,20 @@
1
- import type { Body, Message } from "../context/types"
2
- import { MEDIA_CHARS, MEDIA_MIN_LENGTH, TARGET_SHARE } from "./constants"
3
- import type { Estimator } from "./types"
1
+ import type { Body, Message } from "../context/content"
2
+
3
+ export interface Estimator {
4
+ of: (messages: Message[]) => number
5
+ fixed: number
6
+ }
7
+
8
+ export const DEFAULT_RATIO = 1 / 3.6
9
+
10
+ const MEDIA_CHARS = 5_800
4
11
 
5
12
  export function sizeOf(body: Pick<Body, "system" | "tools" | "messages">): number {
6
13
  let media = 0
7
14
  const json = JSON.stringify({ system: body.system, tools: body.tools, messages: body.messages }, (key, value) => {
8
- if (key === "data" && typeof value === "string" && value.length > MEDIA_MIN_LENGTH) {
9
- media += 1
10
- return ""
11
- }
12
- return value
15
+ if (key !== "data" || typeof value !== "string" || value.length <= 2_000) return value
16
+ media += 1
17
+ return ""
13
18
  })
14
19
  return json.length + media * MEDIA_CHARS
15
20
  }
@@ -22,5 +27,5 @@ export function estimator(body: Body, ratio: number): Estimator {
22
27
  }
23
28
 
24
29
  export function targetSize(size: Estimator, window: number): number {
25
- return size.fixed + TARGET_SHARE * Math.max(0, window - size.fixed)
30
+ return size.fixed + 0.3 * Math.max(0, window - size.fixed)
26
31
  }
@@ -1,15 +1,29 @@
1
- import type { Body } from "../context/types"
2
- import { FINGERPRINT_LENGTH, SKILL_MARKER } from "./constants"
3
- import type { ContextFingerprint } from "./types"
4
-
5
- const hash = (value: unknown) => {
6
- const hasher = new Bun.CryptoHasher("sha256")
7
- hasher.update(JSON.stringify(value ?? null, (key, inner) => (key === "cache_control" ? undefined : inner)))
8
- return hasher.digest("hex").slice(0, FINGERPRINT_LENGTH)
1
+ import { type Body, blocks, type Message, shortHash, withoutCacheControl } from "../context/content"
2
+
3
+ export interface ContextFingerprint {
4
+ system: string
5
+ tools: string
6
+ skills: string[]
7
+ given?: ContextFingerprint
9
8
  }
10
9
 
10
+ export const isInjected = (text: unknown) => /^<(?:recalled-context|handoff)[\s>]/.test(String(text ?? ""))
11
+
12
+ export function messageFingerprint(message: Message) {
13
+ const own = blocks(message)
14
+ .filter((b) => !(b.type === "text" && isInjected(b.text)))
15
+ .map(withoutCacheControl)
16
+ return shortHash(JSON.stringify(own), 24)
17
+ }
18
+
19
+ const hash = (value: unknown) =>
20
+ shortHash(
21
+ JSON.stringify(value ?? null, (key, inner) => (key === "cache_control" ? undefined : inner)),
22
+ 12,
23
+ )
24
+
11
25
  export function contextFingerprint(body: Body): ContextFingerprint {
12
26
  const skills = new Set<string>()
13
- for (const match of JSON.stringify(body.messages ?? []).matchAll(SKILL_MARKER)) skills.add(match[1].split("/").at(-1)!)
27
+ for (const match of JSON.stringify(body.messages ?? []).matchAll(/Base directory for this skill: ([^\s"\\]+)/g)) skills.add(match[1].split("/").at(-1) ?? match[1])
14
28
  return { system: hash(body.system), tools: hash(body.tools), skills: [...skills].sort() }
15
29
  }
@@ -0,0 +1,71 @@
1
+ import { statSync } from "node:fs"
2
+ import { type Config, claudeConfigDirs } from "../config"
3
+ import { blocks, type Message } from "../context/content"
4
+ import { FOLD_CAP_CHARS, foldedView, sourceChunks } from "../context/fold"
5
+ import { isPrompt } from "../context/fold/turns"
6
+ import { handoffBlock } from "../context/prompts"
7
+ import type { RecallChunk } from "../context/recall"
8
+ import { findSessionByPrefix } from "../transcript/locate"
9
+ import { sessionMessages } from "../transcript/messages"
10
+ import { isInjected, messageFingerprint } from "./fingerprint"
11
+ import { titleOf } from "./sessions"
12
+ import type { Handoff, ThreadState } from "./state"
13
+
14
+ const sourceCache = new Map<string, { mtime: number; chunks: RecallChunk[] }>()
15
+
16
+ export const continueToken = (id: string) => `{ee:continue:${id}}`
17
+
18
+ export function continueIdOf(message: Message): string | null {
19
+ if (!isPrompt(message)) return null
20
+ const own = blocks(message)
21
+ .filter((b) => b.type === "text" && !isInjected(b.text))
22
+ .map((b) => String(b.text ?? ""))
23
+ .join("\n")
24
+ return own.match(/\{ee:continue:([A-Za-z0-9-]{8,})\}/)?.[1] ?? null
25
+ }
26
+
27
+ export function activeHandoffs(handoffs: Record<string, Handoff | null> = {}): Record<string, Handoff> {
28
+ return Object.fromEntries(Object.entries(handoffs).filter((entry): entry is [string, Handoff] => Boolean(entry[1])))
29
+ }
30
+
31
+ export function attachHandoffs(messages: Message[], config: Config, sessionId: string | null, state: ThreadState): boolean {
32
+ let changed = false
33
+ for (const message of messages) {
34
+ const id = continueIdOf(message)
35
+ if (!id) continue
36
+ const key = messageFingerprint(message)
37
+ if (state.handoffs && key in state.handoffs) continue
38
+ state.handoffs ??= {}
39
+ const file = findSessionByPrefix(id, claudeConfigDirs(config), sessionId ?? undefined)
40
+ const already = Object.values(state.handoffs).some((h) => h?.source === file)
41
+ state.handoffs[key] = file && !already ? { label: titleOf(file) ?? id, source: file, text: foldedView(sessionMessages(file), FOLD_CAP_CHARS) } : null
42
+ changed = true
43
+ }
44
+ return changed
45
+ }
46
+
47
+ export function withHandoffs(messages: Message[], handoffs: Record<string, Handoff>): Message[] {
48
+ if (Object.keys(handoffs).length === 0) return messages
49
+ return messages.map((message) => {
50
+ if (!isPrompt(message)) return message
51
+ const handoff = handoffs[messageFingerprint(message)]
52
+ return handoff ? { ...message, content: [{ type: "text", text: handoffBlock(handoff.label, handoff.text) }, ...blocks(message)] } : message
53
+ })
54
+ }
55
+
56
+ export function handoffChunks(handoffs: Record<string, Handoff>): RecallChunk[] {
57
+ const sources = new Map<string, string>()
58
+ for (const handoff of Object.values(handoffs)) if (!sources.has(handoff.source)) sources.set(handoff.source, handoff.label)
59
+ return [...sources].flatMap(([source, label]) => {
60
+ let mtime: number
61
+ try {
62
+ mtime = statSync(source).mtimeMs
63
+ } catch {
64
+ return []
65
+ }
66
+ const cached = sourceCache.get(source)
67
+ const chunks = cached?.mtime === mtime ? cached.chunks : sourceChunks(sessionMessages(source), label, true)
68
+ sourceCache.set(source, { mtime, chunks })
69
+ return chunks
70
+ })
71
+ }
package/src/proxy/log.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { appendFileSync, mkdirSync } from "node:fs"
2
- import { EUNOE_DIR, paths } from "../config/constants"
2
+ import { EUNOE_DIR, paths } from "../config/paths"
3
3
 
4
4
  export function logRequest(record: Record<string, unknown>) {
5
5
  try {
6
6
  mkdirSync(EUNOE_DIR, { recursive: true })
7
- appendFileSync(paths.requests, JSON.stringify({ ts: new Date().toISOString(), ...record }) + "\n")
7
+ appendFileSync(paths.requests, `${JSON.stringify({ ts: new Date().toISOString(), ...record })}\n`)
8
8
  } catch {}
9
9
  }
@@ -0,0 +1,58 @@
1
+ import { type Body, blocks, resultText } from "../context/content"
2
+ import { livePrompt, type RecallChunk, type Reranker, recallChunks, recallFor, recallForWith, withRecall } from "../context/recall"
3
+ import { sessionMessages } from "../transcript/messages"
4
+ import { subagentChunks } from "../transcript/subagents"
5
+ import { messageFingerprint } from "./fingerprint"
6
+ import { activeHandoffs, handoffChunks } from "./handoff"
7
+ import type { Rewrite, RewriteInput } from "./rewrite"
8
+ import { readState, saveState } from "./state"
9
+
10
+ export const wantsRecall = (input: RewriteInput) => (input.config.eval?.recall ?? true) && input.config.mode !== "off"
11
+
12
+ export const activeRecalls = (recalls: Record<string, string> = {}) => Object.fromEntries(Object.entries(recalls).filter(([, text]) => text))
13
+
14
+ const touched = (input: RewriteInput, result: Rewrite) => result.threadKey !== null && !(result.body === input.body && result.note.action === "pass")
15
+
16
+ function droppedChunks(input: RewriteInput, result: Rewrite): RecallChunk[] {
17
+ const transcript = input.transcript?.jsonl ?? null
18
+ const kept = new Map<string, string>()
19
+ for (const message of result.body.messages) for (const b of blocks(message)) if (b.type === "tool_result") kept.set(b.tool_use_id, resultText(b))
20
+ const own = recallChunks(input.body.messages, kept)
21
+ const session = own.length > 0 || !transcript ? own : recallChunks([...sessionMessages(transcript), { role: "user", content: "" }], kept)
22
+ const withSubagents = transcript ? [...session, ...subagentChunks(transcript)] : session
23
+ const handoffs = result.threadKey ? activeHandoffs(readState(result.threadKey).handoffs) : {}
24
+ return [...handoffChunks(handoffs), ...withSubagents]
25
+ }
26
+
27
+ function recallTarget(original: Body, result: Rewrite): { key: string; text: string } | null {
28
+ const live = livePrompt(original.messages)
29
+ return live && result.threadKey ? { key: messageFingerprint(original.messages[live.index]), text: live.text } : null
30
+ }
31
+
32
+ export function applyRecall(input: RewriteInput, result: Rewrite): Rewrite {
33
+ if (!touched(input, result) || !result.threadKey) return result
34
+ const state = readState(result.threadKey)
35
+ state.recalls ??= {}
36
+ const target = recallTarget(input.body, result)
37
+ let note = result.note
38
+ if (target && !(target.key in state.recalls)) {
39
+ state.recalls[target.key] = recallFor(target.text, droppedChunks(input, result), input.config.eval?.recallOptions) ?? ""
40
+ saveState(result.threadKey, state)
41
+ note = { ...note, recalled: state.recalls[target.key].length }
42
+ }
43
+ const messages = withRecall(result.body.messages, messageFingerprint, activeRecalls(state.recalls))
44
+ return messages === result.body.messages ? { ...result, note } : { ...result, body: { ...result.body, messages }, note }
45
+ }
46
+
47
+ export async function prefetchFor(input: RewriteInput, result: Rewrite, rerank: Reranker): Promise<number | null> {
48
+ if (!touched(input, result) || !result.threadKey) return null
49
+ const target = recallTarget(input.body, result)
50
+ if (!target) return null
51
+ const state = readState(result.threadKey)
52
+ state.recalls ??= {}
53
+ if (target.key in state.recalls) return null
54
+ const options = { candidates: input.config.eval?.rerank?.candidates, ...input.config.eval?.recallOptions }
55
+ state.recalls[target.key] = (await recallForWith(target.text, droppedChunks(input, result), rerank, options)) ?? ""
56
+ saveState(result.threadKey, state)
57
+ return state.recalls[target.key].length
58
+ }