@chroxify/eunoe 0.1.0 → 1.1.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 (80) hide show
  1. package/README.md +364 -239
  2. package/package.json +20 -3
  3. package/src/cli/{types.ts → command.ts} +1 -4
  4. package/src/cli/commands/index.ts +27 -3
  5. package/src/cli/commands/load.ts +44 -0
  6. package/src/cli/commands/mode.ts +68 -24
  7. package/src/cli/commands/peek.ts +22 -0
  8. package/src/cli/commands/replay.ts +194 -0
  9. package/src/cli/commands/serve.ts +24 -8
  10. package/src/cli/commands/sessions.ts +70 -0
  11. package/src/cli/commands/settings.ts +119 -0
  12. package/src/cli/commands/start.ts +73 -0
  13. package/src/cli/commands/status.ts +107 -30
  14. package/src/cli/commands/stop.ts +36 -12
  15. package/src/cli/commands/uninstall.ts +14 -14
  16. package/src/cli/commands/watch.ts +16 -0
  17. package/src/cli/logo/art.ts +99 -0
  18. package/src/cli/logo/index.ts +12 -18
  19. package/src/cli/main.ts +3 -3
  20. package/src/cli/proxy/claude-settings.ts +59 -0
  21. package/src/cli/proxy/inspect.ts +67 -0
  22. package/src/cli/proxy/launchd.ts +53 -0
  23. package/src/cli/proxy/service.ts +53 -0
  24. package/src/cli/proxy/stats.ts +81 -0
  25. package/src/cli/proxy/watch.ts +60 -0
  26. package/src/cli/replay/index.ts +141 -0
  27. package/src/cli/replay/worker.ts +15 -0
  28. package/src/cli/sessions/browse.ts +88 -0
  29. package/src/cli/sessions/index.ts +76 -0
  30. package/src/cli/sessions/source.ts +74 -0
  31. package/src/cli/{args.ts → terminal/args.ts} +1 -1
  32. package/src/cli/{help.ts → terminal/help.ts} +25 -9
  33. package/src/cli/terminal/prompt.ts +114 -0
  34. package/src/cli/terminal/ui.ts +137 -0
  35. package/src/config/index.ts +128 -14
  36. package/src/config/{constants.ts → paths.ts} +4 -14
  37. package/src/context/content.ts +51 -3
  38. package/src/context/fold/evidence.ts +145 -0
  39. package/src/context/fold/index.ts +65 -0
  40. package/src/context/fold/strategies.ts +94 -0
  41. package/src/context/fold/turns.ts +76 -0
  42. package/src/context/prompts.ts +141 -22
  43. package/src/context/recall/index.ts +214 -0
  44. package/src/context/recall/rerank.ts +71 -0
  45. package/src/proxy/canonical.ts +40 -0
  46. package/src/proxy/compaction.ts +122 -52
  47. package/src/proxy/estimate.ts +14 -9
  48. package/src/proxy/fingerprint.ts +29 -0
  49. package/src/proxy/handoff.ts +71 -0
  50. package/src/proxy/log.ts +2 -2
  51. package/src/proxy/recall.ts +58 -0
  52. package/src/proxy/rewrite.ts +152 -56
  53. package/src/proxy/server.ts +122 -47
  54. package/src/proxy/sessions.ts +64 -0
  55. package/src/proxy/state.ts +17 -3
  56. package/src/proxy/usage.ts +50 -34
  57. package/src/proxy/window.ts +32 -34
  58. package/src/transcript/format.ts +88 -25
  59. package/src/transcript/locate.ts +38 -17
  60. package/src/transcript/messages.ts +18 -0
  61. package/src/transcript/parse.ts +137 -56
  62. package/src/transcript/render.ts +44 -17
  63. package/src/transcript/search.ts +20 -56
  64. package/src/transcript/subagents.ts +150 -0
  65. package/src/cli/claude-settings.ts +0 -42
  66. package/src/cli/commands/install.ts +0 -44
  67. package/src/cli/constants.ts +0 -29
  68. package/src/cli/inspect.ts +0 -41
  69. package/src/cli/launchd.ts +0 -34
  70. package/src/cli/logo/pixels.ts +0 -34
  71. package/src/cli/ui.ts +0 -91
  72. package/src/config/types.ts +0 -21
  73. package/src/context/constants.ts +0 -7
  74. package/src/context/strategies.ts +0 -89
  75. package/src/context/turns.ts +0 -82
  76. package/src/context/types.ts +0 -24
  77. package/src/proxy/constants.ts +0 -19
  78. package/src/proxy/types.ts +0 -58
  79. package/src/transcript/constants.ts +0 -16
  80. package/src/transcript/types.ts +0 -43
@@ -0,0 +1,214 @@
1
+ import { blocks, clip, describeToolCall, type Message, resultText, textOf } from "../content"
2
+ import { isSalient, toolPairs } from "../fold/evidence"
3
+ import { isPrompt, splitTurns } from "../fold/turns"
4
+ import { recalledContext } from "../prompts"
5
+
6
+ export interface RecallChunk {
7
+ turn: number
8
+ from?: string
9
+ call: string
10
+ lines: string[]
11
+ terms: string[]
12
+ }
13
+
14
+ export interface RecallOptions {
15
+ windowLines?: number
16
+ perQuery?: number
17
+ minTerms?: number
18
+ maxChars?: number
19
+ maxCharsMulti?: number
20
+ narrative?: boolean
21
+ callWeight?: number
22
+ salientExcerpt?: boolean
23
+ candidates?: number
24
+ }
25
+
26
+ export interface RecallPick {
27
+ index: number
28
+ query: Set<string>
29
+ }
30
+
31
+ export type Reranker = (query: string, chunks: RecallChunk[], candidates: number[]) => Promise<number[]>
32
+
33
+ const SHOWN_LINES = 8
34
+ const BM25_K1 = 1.2
35
+ const BM25_B = 0.75
36
+
37
+ const STOPWORDS = new Set(
38
+ "the and for with that this what which when where who how was were did does done from into onto have has had you your our out not but are can could would should will just then than them they there their its it's about after before again also any each other some such only own same very more most over under while because been being into".split(
39
+ " ",
40
+ ),
41
+ )
42
+
43
+ export function terms(text: string): string[] {
44
+ return (text.toLowerCase().match(/[a-z0-9_@$][a-z0-9_.:/@$-]*[a-z0-9_]|[a-z0-9]/g) ?? []).flatMap((term) => (/[._:/-]/.test(term) ? [term, ...term.split(/[._:/-]+/)] : [term])).filter((term) => term.length >= 3 && !STOPWORDS.has(term))
45
+ }
46
+
47
+ function windows(lines: string[], size: number): string[][] {
48
+ const out: string[][] = []
49
+ for (let start = 0; start < lines.length; start += size) out.push(lines.slice(start, start + size))
50
+ return out
51
+ }
52
+
53
+ export function recallChunks(messages: Message[], keptIds: Map<string, string>, options: RecallOptions = {}): RecallChunk[] {
54
+ const size = options.windowLines ?? 6
55
+ const weight = Math.max(1, options.callWeight ?? 1)
56
+ const chunks: RecallChunk[] = []
57
+ splitTurns(messages)
58
+ .turns.slice(0, -1)
59
+ .forEach((turn, turnIndex) => {
60
+ for (const { call, result } of toolPairs(turn)) {
61
+ const text = resultText(result)
62
+ if (keptIds.get(call.id) === text || !text.trim()) continue
63
+ 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"))] })
66
+ }
67
+ if (!(options.narrative ?? true)) return
68
+ const said = turn
69
+ .slice(1, -1)
70
+ .filter((m) => m.role === "assistant")
71
+ .flatMap((m) =>
72
+ blocks(m)
73
+ .filter((b) => b.type === "text")
74
+ .map((b) => String(b.text ?? "").trim()),
75
+ )
76
+ .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")) })
78
+ })
79
+ return chunks
80
+ }
81
+
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)
91
+ 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
98
+ 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))
100
+ }
101
+ return { index, score, matched }
102
+ })
103
+ .filter((r) => r.matched >= Math.min(minTerms, wanted.length) && r.score > 0)
104
+ .sort((a, b) => b.score - a.score)
105
+ }
106
+
107
+ function excerpt(chunk: RecallChunk, query: Set<string>, salient: boolean): string[] {
108
+ const limit = salient ? SHOWN_LINES + 2 : SHOWN_LINES
109
+ const shown = new Set<number>()
110
+ chunk.lines.forEach((line, i) => {
111
+ if (!terms(line).some((t) => query.has(t))) return
112
+ for (const j of [i - 1, i, i + 1]) if (j >= 0 && j < chunk.lines.length) shown.add(j)
113
+ })
114
+ if (salient) for (let i = 0; i < chunk.lines.length && shown.size < limit; i += 1) if (isSalient(chunk.lines[i])) shown.add(i)
115
+ const ordered = [...shown].sort((a, b) => a - b).slice(0, limit)
116
+ return (ordered.length > 0 ? ordered : [0]).map((i) => clip(chunk.lines[i], 240)).filter(Boolean)
117
+ }
118
+
119
+ function queriesOf(prompt: string): string[] {
120
+ const lines = prompt
121
+ .split("\n")
122
+ .map((l) => l.trim())
123
+ .filter((l) => terms(l).length >= 2)
124
+ return lines.length > 1 ? lines : [prompt]
125
+ }
126
+
127
+ function merged(hits: Array<{ query: string[]; indices: number[] }>): RecallPick[] {
128
+ const picked = new Map<number, Set<string>>()
129
+ for (const { query, indices } of hits) {
130
+ for (const index of indices) {
131
+ const matched = picked.get(index) ?? new Set()
132
+ for (const term of query) matched.add(term)
133
+ picked.set(index, matched)
134
+ }
135
+ }
136
+ return [...picked.entries()].map(([index, query]) => ({ index, query }))
137
+ }
138
+
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)
143
+
144
+ 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
+ )
151
+ }
152
+
153
+ export const identityReranker: Reranker = async (_query, _chunks, candidates) => candidates
154
+
155
+ export async function pickRecallWith(prompt: string, chunks: RecallChunk[], rerank: Reranker, options: RecallOptions = {}): Promise<RecallPick[]> {
156
+ 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))
160
+ hits.push({ query, indices: reordered.slice(0, options.perQuery ?? 12) })
161
+ }
162
+ return merged(hits)
163
+ }
164
+
165
+ export function renderRecall(prompt: string, chunks: RecallChunk[], picks: RecallPick[], options: RecallOptions = {}): string | null {
166
+ if (picks.length === 0) return null
167
+ const budget = queriesOf(prompt).length > 1 ? (options.maxCharsMulti ?? 30_000) : (options.maxChars ?? 18_000)
168
+ const entries: string[] = []
169
+ let used = 0
170
+ for (const { index, query } of [...picks].sort((a, b) => chunks[a.index].turn - chunks[b.index].turn || a.index - b.index)) {
171
+ const chunk = chunks[index]
172
+ const where = chunk.from ? `${chunk.from}, turn ${chunk.turn}` : `turn ${chunk.turn}`
173
+ const lines = excerpt(chunk, query, options.salientExcerpt ?? true).map((l) => ` ${l}`)
174
+ const entry = `[${where}] ${chunk.call}:\n${lines.join("\n")}`
175
+ if (used + entry.length > budget) continue
176
+ entries.push(entry)
177
+ used += entry.length
178
+ }
179
+ if (entries.length === 0) return null
180
+ return recalledContext(
181
+ entries,
182
+ picks.some((p) => Boolean(chunks[p.index].from)),
183
+ )
184
+ }
185
+
186
+ export function recallFor(prompt: string, chunks: RecallChunk[], options: RecallOptions = {}): string | null {
187
+ if (chunks.length === 0) return null
188
+ return renderRecall(prompt, chunks, pickRecall(prompt, chunks, options), options)
189
+ }
190
+
191
+ export async function recallForWith(prompt: string, chunks: RecallChunk[], rerank: Reranker, options: RecallOptions = {}): Promise<string | null> {
192
+ if (chunks.length === 0) return null
193
+ return renderRecall(prompt, chunks, await pickRecallWith(prompt, chunks, rerank, options), options)
194
+ }
195
+
196
+ export function livePrompt(messages: Message[]): { index: number; text: string } | null {
197
+ const { preamble, turns } = splitTurns(messages)
198
+ const live = turns.at(-1)
199
+ if (!live || !isPrompt(live[0])) return null
200
+ const index = preamble.length + turns.slice(0, -1).reduce((s, t) => s + t.length, 0)
201
+ const text = textOf(live[0])
202
+ .replace(/<(system-reminder|system-message|recalled-context|tool-evidence|handoff)(?:\s[^>]*)?>[\s\S]*?<\/\1>/g, "")
203
+ .trim()
204
+ return { index, text }
205
+ }
206
+
207
+ export function withRecall(messages: Message[], fingerprint: (message: Message) => string, recalls: Record<string, string>): Message[] {
208
+ if (Object.keys(recalls).length === 0) return messages
209
+ return messages.map((message) => {
210
+ if (!isPrompt(message)) return message
211
+ const text = recalls[fingerprint(message)]
212
+ return text ? { ...message, content: [...blocks(message), { type: "text", text }] } : message
213
+ })
214
+ }
@@ -0,0 +1,71 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import { paths } from "../../config/paths"
3
+ import { rerankQuestion } from "../prompts"
4
+ import type { RecallChunk, Reranker } from "."
5
+
6
+ interface RerankAnswer {
7
+ answers?: { where?: { probabilities?: Record<string, number> } }
8
+ }
9
+
10
+ const TIMEOUT_MS = 4_000
11
+
12
+ export function rerankKey(): string | null {
13
+ const env = process.env.TYPESAFE_API_KEY?.trim()
14
+ if (env) return env
15
+ return existsSync(paths.typesafeKey) ? readFileSync(paths.typesafeKey, "utf8").trim() : null
16
+ }
17
+
18
+ function rerankState(chunks: RecallChunk[], candidates: number[]): string {
19
+ return candidates
20
+ .map((index, n) => {
21
+ const chunk = chunks[index]
22
+ return `### ${n}\n[turn ${chunk.turn}] ${chunk.call}\n${chunk.lines.map((l) => l.slice(0, 240)).join("\n")}`
23
+ })
24
+ .join("\n\n")
25
+ }
26
+
27
+ export function orderByProbability(candidates: number[], probabilities: Record<string, number>): number[] {
28
+ return candidates
29
+ .map((_, n) => n)
30
+ .sort((a, b) => (probabilities[String(b)] ?? 0) - (probabilities[String(a)] ?? 0) || a - b)
31
+ .map((n) => candidates[n])
32
+ }
33
+
34
+ function failureReason(error: unknown): string {
35
+ if (!(error instanceof Error)) return String(error)
36
+ return error.name === "TimeoutError" ? `timeout after ${TIMEOUT_MS}ms` : error.message
37
+ }
38
+
39
+ export function jevReranker(model: string, key: string, endpoint = "https://api.typesafe.ai/v1/systemone", onError?: (reason: string) => void): Reranker {
40
+ return async (query, chunks, candidates) => {
41
+ if (candidates.length < 2) return candidates
42
+ try {
43
+ const response = await fetch(endpoint, {
44
+ method: "POST",
45
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
46
+ signal: AbortSignal.timeout(TIMEOUT_MS),
47
+ body: JSON.stringify({
48
+ model,
49
+ state: rerankState(chunks, candidates),
50
+ questions: {
51
+ where: {
52
+ type: "choice",
53
+ instructions: rerankQuestion(query),
54
+ criteria: Object.fromEntries(candidates.map((_, n) => [String(n), null])),
55
+ },
56
+ },
57
+ }),
58
+ })
59
+ if (!response.ok) {
60
+ const body = await response.text().catch(() => "")
61
+ onError?.(`${response.status} ${/error_type"\s*:\s*"([^"]+)/.exec(body)?.[1] ?? body.slice(0, 60)}`.trim())
62
+ return candidates
63
+ }
64
+ const probabilities = ((await response.json()) as RerankAnswer).answers?.where?.probabilities
65
+ return probabilities && typeof probabilities === "object" ? orderByProbability(candidates, probabilities) : candidates
66
+ } catch (error) {
67
+ onError?.(failureReason(error))
68
+ return candidates
69
+ }
70
+ }
71
+ }
@@ -0,0 +1,40 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+ import type { Body } from "../context/content"
4
+
5
+ interface Canonical {
6
+ system: Body["system"]
7
+ tools: Body["tools"]
8
+ }
9
+
10
+ export interface CanonicalResult {
11
+ body: Body
12
+ status: "applied" | "captured" | "skipped"
13
+ }
14
+
15
+ const BILLING_LINE = /^x-anthropic-billing-header:[^\n]*/
16
+
17
+ function billingLine(system: Body["system"]): string | null {
18
+ const first = Array.isArray(system) ? String(system[0]?.text ?? "") : String(system ?? "")
19
+ return first.match(BILLING_LINE)?.[0] ?? null
20
+ }
21
+
22
+ function withBilling(system: Body["system"], line: string | null): Body["system"] {
23
+ if (!line) return system
24
+ if (!Array.isArray(system)) return String(system ?? "").replace(BILLING_LINE, line)
25
+ const [first, ...rest] = system
26
+ return [{ ...first, text: String(first?.text ?? "").replace(BILLING_LINE, line) }, ...rest]
27
+ }
28
+
29
+ export function applyCanonical(body: Body, file: string): CanonicalResult {
30
+ if (!Array.isArray(body.tools) || body.tools.length === 0) return { body, status: "skipped" }
31
+ if (existsSync(file)) {
32
+ const canonical = JSON.parse(readFileSync(file, "utf8")) as Canonical
33
+ return { body: { ...body, system: withBilling(canonical.system, billingLine(body.system)), tools: canonical.tools }, status: "applied" }
34
+ }
35
+ mkdirSync(path.dirname(file), { recursive: true })
36
+ const partial = `${file}.${process.pid}.${Date.now()}`
37
+ writeFileSync(partial, JSON.stringify({ system: body.system, tools: body.tools } satisfies Canonical))
38
+ renameSync(partial, file)
39
+ return { body, status: "captured" }
40
+ }
@@ -1,75 +1,144 @@
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 { CompactInput, CompactResult } from "./types"
8
-
9
- export function compact({ body, config, state, window, guide, mayCompact, omittedBefore = 0 }: CompactInput): CompactResult {
10
- const { turns } = splitTurns(body.messages)
11
- let stateChanged = false
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
+ }
12
32
 
33
+ const PROTECTED_OUTPUTS = 5
34
+
35
+ function settledCut(turns: Message[][], index: number) {
36
+ let cut = index
37
+ while (cut > 1 && JSON.stringify(turns[cut][0].content).includes("[Request interrupted")) cut -= 1
38
+ return cut
39
+ }
40
+
41
+ function resolveCut(turns: Message[][], state: ThreadState, config: Config, rollingKeep: number | undefined) {
13
42
  let cutIndex = state.cut ? turns.findIndex((turn) => turnFingerprint(turn) === state.cut) : -1
43
+ let stateChanged = false
14
44
  if (state.cut && cutIndex === -1) {
15
45
  resetCut(state)
16
46
  stateChanged = true
17
47
  }
18
48
  if (cutIndex <= 0 && config.eval?.forceCut && turns.length >= 3) {
19
- let forced = turns.length - 2
20
- while (forced > 1 && JSON.stringify(turns[forced][0].content).includes("[Request interrupted")) forced -= 1
21
- cutIndex = forced
22
- state.cut = turnFingerprint(turns[forced])
49
+ cutIndex = settledCut(turns, Math.max(1, turns.length - 1 - (rollingKeep ?? 1)))
50
+ state.cut = turnFingerprint(turns[cutIndex])
23
51
  stateChanged = true
24
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
25
89
 
26
90
  const configuredKeep = config.keepTurns === "all" ? Number.MAX_SAFE_INTEGER : config.keepTurns
27
- const liveLength = () => turns.slice(Math.max(cutIndex, 0)).flat().length
28
- const compacted = () => cutIndex > 0
29
- ? compactMessages(body.messages, cutIndex, { keepTurns: state.keep ?? configuredKeep, guide, omittedBefore })
30
- : 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 })
31
99
  const build = () => {
32
100
  const messages = compacted()
33
- 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))
34
106
  }
35
107
 
36
- const size = estimator(body, state.ratio ?? DEFAULT_RATIO)
108
+ const size = estimator(body, ratio)
37
109
  const limit = window ? config.compactAt * window : Number.POSITIVE_INFINITY
38
110
  const target = window ? targetSize(size, window) : Number.POSITIVE_INFINITY
39
111
 
40
112
  let messages = build()
41
- let estimate = size.of(messages)
113
+ let estimate = size.of(decorate(messages))
42
114
  const actions: string[] = []
43
- const current = turns.length - 1
115
+ const rebuild = () => {
116
+ messages = build()
117
+ estimate = size.of(decorate(messages))
118
+ }
119
+ const moveCut = (index: number, action: string) => {
120
+ cutIndex = index
121
+ resetCut(state)
122
+ state.cut = turnFingerprint(turns[index])
123
+ actions.push(action)
124
+ rebuild()
125
+ }
126
+
127
+ const roll = mayCompact && rolling ? dueRoll(turns, cutIndex, rolling, size) : null
128
+ if (roll !== null) {
129
+ moveCut(roll, "roll")
130
+ stateChanged = true
131
+ }
44
132
 
45
133
  if (mayCompact && estimate > limit) {
46
- if (current > 0 && current > cutIndex) {
47
- cutIndex = current
48
- resetCut(state)
49
- state.cut = turnFingerprint(turns[current])
50
- actions.push("cut")
51
- messages = build()
52
- estimate = size.of(messages)
53
- }
54
- while (estimate > target) {
55
- const keep = Math.min(state.keep ?? configuredKeep, cutIndex - 1)
56
- if (cutIndex > 0 && keep > 0) {
57
- state.keep = keep - 1
58
- actions.push(`keep=${state.keep}`)
59
- } else {
60
- const unclearedLive = compacted()
61
- const total = countToolResults(unclearedLive, unclearedLive.length - liveLength())
62
- const remaining = total - (state.cleared ?? 0) - PROTECTED_OUTPUTS
63
- if (remaining <= 0) {
64
- actions.push("stuck")
65
- break
66
- }
67
- state.cleared = (state.cleared ?? 0) + Math.ceil(remaining / 2)
68
- actions.push(`cleared=${state.cleared}`)
69
- }
70
- messages = build()
71
- estimate = size.of(messages)
72
- }
134
+ const current = turns.length - 1
135
+ if (current > 0 && current > cutIndex) moveCut(current, "cut")
136
+ shrinkToTarget(
137
+ () => estimate > target,
138
+ () => shrinkOnce(state, cutIndex, configuredKeep, liveResults),
139
+ actions,
140
+ rebuild,
141
+ )
73
142
  stateChanged = true
74
143
  }
75
144
 
@@ -78,9 +147,10 @@ export function compact({ body, config, state, window, guide, mayCompact, omitte
78
147
  stateChanged,
79
148
  note: {
80
149
  action: cutIndex > 0 ? "compact" : "full",
81
- compacted: actions.includes("cut"),
82
- actions: actions.length ? actions : undefined,
150
+ compacted: actions.includes("cut") || actions.includes("roll"),
151
+ actions: actions.length > 0 ? actions : undefined,
83
152
  cutTurn: cutIndex > 0 ? cutIndex : null,
153
+ omitted: cutIndex > 0 ? Math.max(1, cutIndex - (state.keep ?? configuredKeep)) - 1 : 0,
84
154
  keep: state.keep,
85
155
  cleared: state.cleared,
86
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
  }
@@ -0,0 +1,29 @@
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
8
+ }
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
+
25
+ export function contextFingerprint(body: Body): ContextFingerprint {
26
+ const skills = new Set<string>()
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])
28
+ return { system: hash(body.system), tools: hash(body.tools), skills: [...skills].sort() }
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
+ }