@chroxify/eunoe 0.1.0 → 1.0.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 (43) hide show
  1. package/README.md +315 -236
  2. package/package.json +3 -2
  3. package/src/cli/args.ts +1 -1
  4. package/src/cli/commands/index.ts +16 -2
  5. package/src/cli/commands/mode.ts +53 -17
  6. package/src/cli/commands/serve.ts +2 -1
  7. package/src/cli/commands/settings.ts +100 -0
  8. package/src/cli/commands/start.ts +64 -0
  9. package/src/cli/commands/status.ts +50 -16
  10. package/src/cli/commands/stop.ts +33 -11
  11. package/src/cli/commands/uninstall.ts +11 -9
  12. package/src/cli/constants.ts +28 -5
  13. package/src/cli/help.ts +2 -2
  14. package/src/cli/launchd.ts +8 -3
  15. package/src/cli/prompt.ts +91 -0
  16. package/src/cli/service.ts +33 -0
  17. package/src/cli/sessions.ts +25 -0
  18. package/src/cli/stats.ts +73 -0
  19. package/src/cli/types.ts +1 -0
  20. package/src/cli/ui.ts +15 -0
  21. package/src/config/constants.ts +15 -3
  22. package/src/config/index.ts +55 -6
  23. package/src/config/types.ts +34 -5
  24. package/src/context/constants.ts +35 -0
  25. package/src/context/content.ts +23 -1
  26. package/src/context/evidence.ts +92 -0
  27. package/src/context/recall.ts +155 -0
  28. package/src/context/rerank.ts +44 -0
  29. package/src/context/strategies.ts +6 -6
  30. package/src/context/turns.ts +19 -14
  31. package/src/context/types.ts +43 -1
  32. package/src/proxy/canonical.ts +32 -0
  33. package/src/proxy/compaction.ts +29 -12
  34. package/src/proxy/constants.ts +10 -0
  35. package/src/proxy/fingerprint.ts +15 -0
  36. package/src/proxy/rewrite.ts +84 -10
  37. package/src/proxy/server.ts +30 -12
  38. package/src/proxy/sessions.ts +46 -0
  39. package/src/proxy/types.ts +24 -0
  40. package/src/transcript/constants.ts +0 -1
  41. package/src/transcript/locate.ts +1 -1
  42. package/src/transcript/messages.ts +29 -0
  43. package/src/cli/commands/install.ts +0 -44
@@ -0,0 +1,155 @@
1
+ import { BM25_B, BM25_K1, RECALL_LINE_CHARS, RECALL_MAX_CHARS, RECALL_MAX_CHARS_MULTI, RECALL_MIN_TERMS, RECALL_NARRATIVE, RECALL_PER_QUERY, RECALL_RERANK_CANDIDATES, RECALL_SALIENT_EXCERPT, RECALL_SHOWN_LINES, RECALL_WINDOW_LINES, STOPWORDS } from "./constants"
2
+ import { blocks, clip, describeToolCall, resultText } from "./content"
3
+ import { isSalient, toolPairs } from "./evidence"
4
+ import { isPrompt, splitTurns } from "./turns"
5
+ import type { Message, RecallChunk, RecallOptions, RecallPick, Reranker } from "./types"
6
+
7
+ const REMINDERS = /<(system-reminder|system-message|recalled-context|tool-evidence)>[\s\S]*?<\/\1>/g
8
+
9
+ export function terms(text: string): string[] {
10
+ return (text.toLowerCase().match(/[a-z0-9_@$][a-z0-9_.:/@$-]*[a-z0-9_]|[a-z0-9]/g) ?? [])
11
+ .flatMap((term) => (/[._:/-]/.test(term) ? [term, ...term.split(/[._:/-]+/)] : [term]))
12
+ .filter((term) => term.length >= 3 && !STOPWORDS.has(term))
13
+ }
14
+
15
+ function promptText(message: Message): string {
16
+ return blocks(message).filter((b) => b.type === "text").map((b) => String(b.text ?? "")).join("\n").replace(REMINDERS, "").trim()
17
+ }
18
+
19
+ function windows(lines: string[], size: number): string[][] {
20
+ const out: string[][] = []
21
+ for (let start = 0; start < lines.length; start += size) out.push(lines.slice(start, start + size))
22
+ return out
23
+ }
24
+
25
+ export function recallChunks(messages: Message[], keptIds: Map<string, string>, options: RecallOptions = {}): RecallChunk[] {
26
+ const { turns } = splitTurns(messages)
27
+ const size = options.windowLines ?? RECALL_WINDOW_LINES
28
+ const weight = Math.max(1, options.callWeight ?? 1)
29
+ const chunks: RecallChunk[] = []
30
+ turns.slice(0, -1).forEach((turn, turnIndex) => {
31
+ for (const { call, result } of toolPairs(turn)) {
32
+ const text = resultText(result)
33
+ if (keptIds.get(call.id) === text || !text.trim()) continue
34
+ const head = describeToolCall(call)
35
+ const headTerms = Array.from({ length: weight }, () => terms(head)).flat()
36
+ for (const window of windows(text.split("\n"), size)) {
37
+ chunks.push({ turn: turnIndex + 1, call: head, lines: window, terms: [...headTerms, ...terms(window.join("\n"))] })
38
+ }
39
+ }
40
+ if (!(options.narrative ?? RECALL_NARRATIVE)) return
41
+ const said = turn.slice(1, -1).filter((m) => m.role === "assistant").flatMap((m) => blocks(m).filter((b) => b.type === "text").map((b) => String(b.text ?? "").trim())).filter(Boolean)
42
+ for (const window of windows(said.join("\n").split("\n"), size)) {
43
+ chunks.push({ turn: turnIndex + 1, call: "agent", lines: window, terms: terms(window.join("\n")) })
44
+ }
45
+ })
46
+ return chunks
47
+ }
48
+
49
+ export function rank(query: string[], chunks: RecallChunk[], minTerms = RECALL_MIN_TERMS) {
50
+ const df = new Map<string, number>()
51
+ for (const chunk of chunks) for (const term of new Set(chunk.terms)) df.set(term, (df.get(term) ?? 0) + 1)
52
+ const average = chunks.reduce((s, c) => s + c.terms.length, 0) / Math.max(1, chunks.length)
53
+ const wanted = [...new Set(query)].filter((t) => df.has(t))
54
+ return chunks.map((chunk, index) => {
55
+ const tf = new Map<string, number>()
56
+ for (const term of chunk.terms) tf.set(term, (tf.get(term) ?? 0) + 1)
57
+ let score = 0
58
+ let matched = 0
59
+ for (const term of wanted) {
60
+ const f = tf.get(term) ?? 0
61
+ if (!f) continue
62
+ matched += 1
63
+ const idf = Math.log(1 + (chunks.length - df.get(term)! + 0.5) / (df.get(term)! + 0.5))
64
+ score += idf * (f * (BM25_K1 + 1)) / (f + BM25_K1 * (1 - BM25_B + BM25_B * chunk.terms.length / average))
65
+ }
66
+ return { index, score, matched }
67
+ }).filter((r) => r.matched >= Math.min(minTerms, wanted.length) && r.score > 0).sort((a, b) => b.score - a.score)
68
+ }
69
+
70
+ function excerpt(chunk: RecallChunk, query: Set<string>, salient: boolean): string[] {
71
+ const hits = chunk.lines.map((line, i) => (terms(line).some((t) => query.has(t)) ? i : -1)).filter((i) => i >= 0)
72
+ const shown = new Set<number>()
73
+ for (const i of hits) for (const j of [i - 1, i, i + 1]) if (j >= 0 && j < chunk.lines.length) shown.add(j)
74
+ if (salient) for (let i = 0; i < chunk.lines.length && shown.size < RECALL_SHOWN_LINES + 2; i += 1) if (isSalient(chunk.lines[i])) shown.add(i)
75
+ const ordered = [...shown].sort((a, b) => a - b).slice(0, salient ? RECALL_SHOWN_LINES + 2 : RECALL_SHOWN_LINES)
76
+ return (ordered.length ? ordered : [0]).map((i) => clip(chunk.lines[i], RECALL_LINE_CHARS)).filter(Boolean)
77
+ }
78
+
79
+ export function queriesOf(prompt: string): string[] {
80
+ const lines = prompt.split("\n").map((l) => l.trim()).filter((l) => terms(l).length >= 2)
81
+ return lines.length > 1 ? lines : [prompt]
82
+ }
83
+
84
+ export function pickRecall(prompt: string, chunks: RecallChunk[], options: RecallOptions = {}): RecallPick[] {
85
+ const perQuery = options.perQuery ?? RECALL_PER_QUERY
86
+ const picked = new Map<number, Set<string>>()
87
+ for (const query of queriesOf(prompt)) {
88
+ const q = terms(query)
89
+ for (const hit of rank(q, chunks, options.minTerms).slice(0, perQuery)) {
90
+ if (!picked.has(hit.index)) picked.set(hit.index, new Set())
91
+ for (const t of q) picked.get(hit.index)!.add(t)
92
+ }
93
+ }
94
+ return [...picked.entries()].map(([index, query]) => ({ index, query }))
95
+ }
96
+
97
+ export async function pickRecallWith(prompt: string, chunks: RecallChunk[], rerank: Reranker, options: RecallOptions = {}): Promise<RecallPick[]> {
98
+ const perQuery = options.perQuery ?? RECALL_PER_QUERY
99
+ const width = options.candidates ?? RECALL_RERANK_CANDIDATES
100
+ const picked = new Map<number, Set<string>>()
101
+ for (const query of queriesOf(prompt)) {
102
+ const q = terms(query)
103
+ const candidates = rank(q, chunks, options.minTerms).slice(0, width).map((hit) => hit.index)
104
+ for (const index of (await rerank(query, chunks, candidates)).slice(0, perQuery)) {
105
+ if (!picked.has(index)) picked.set(index, new Set())
106
+ for (const t of q) picked.get(index)!.add(t)
107
+ }
108
+ }
109
+ return [...picked.entries()].map(([index, query]) => ({ index, query }))
110
+ }
111
+
112
+ export function renderRecall(prompt: string, chunks: RecallChunk[], picks: RecallPick[], options: RecallOptions = {}): string | null {
113
+ if (!picks.length) return null
114
+ const budget = queriesOf(prompt).length > 1 ? options.maxCharsMulti ?? RECALL_MAX_CHARS_MULTI : options.maxChars ?? RECALL_MAX_CHARS
115
+ const entries: string[] = []
116
+ let used = 0
117
+ for (const pick of [...picks].sort((a, b) => chunks[a.index].turn - chunks[b.index].turn || a.index - b.index)) {
118
+ const chunk = chunks[pick.index]
119
+ const entry = `[turn ${chunk.turn}] ${chunk.call}:\n${excerpt(chunk, pick.query, options.salientExcerpt ?? RECALL_SALIENT_EXCERPT).map((l) => ` ${l}`).join("\n")}`
120
+ if (used + entry.length > budget) continue
121
+ entries.push(entry)
122
+ used += entry.length
123
+ }
124
+ return entries.length
125
+ ? `<recalled-context>\nExcerpts from earlier in this session that match this message: tool output that is no longer in your context, quoted exactly, oldest first. Where the same thing appears twice, the later turn is current. Use them if they answer what was asked; ignore them otherwise.\n\n${entries.join("\n")}\n</recalled-context>`
126
+ : null
127
+ }
128
+
129
+ export function recallFor(prompt: string, chunks: RecallChunk[], options: RecallOptions = {}): string | null {
130
+ if (!chunks.length) return null
131
+ return renderRecall(prompt, chunks, pickRecall(prompt, chunks, options), options)
132
+ }
133
+
134
+ export async function recallForWith(prompt: string, chunks: RecallChunk[], rerank: Reranker, options: RecallOptions = {}): Promise<string | null> {
135
+ if (!chunks.length) return null
136
+ return renderRecall(prompt, chunks, await pickRecallWith(prompt, chunks, rerank, options), options)
137
+ }
138
+
139
+ export function livePrompt(messages: Message[]): { index: number; text: string } | null {
140
+ const { preamble, turns } = splitTurns(messages)
141
+ if (turns.length < 2) return null
142
+ const first = turns.at(-1)![0]
143
+ if (!isPrompt(first)) return null
144
+ const index = preamble.length + turns.slice(0, -1).reduce((s, t) => s + t.length, 0)
145
+ return { index, text: promptText(first) }
146
+ }
147
+
148
+ export function withRecall(messages: Message[], fingerprint: (message: Message) => string, recalls: Record<string, string>): Message[] {
149
+ if (!Object.keys(recalls).length) return messages
150
+ return messages.map((message) => {
151
+ if (!isPrompt(message)) return message
152
+ const text = recalls[fingerprint(message)]
153
+ return text ? { ...message, content: [...blocks(message), { type: "text", text }] } : message
154
+ })
155
+ }
@@ -0,0 +1,44 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import { paths } from "../config/constants"
3
+ import { RERANK_ENDPOINT, RERANK_LINE_CHARS, RERANK_TIMEOUT_MS } from "./constants"
4
+ import type { RecallChunk, Reranker } from "./types"
5
+
6
+ export function rerankKey(): string | null {
7
+ const env = process.env.TYPESAFE_API_KEY?.trim()
8
+ if (env) return env
9
+ return existsSync(paths.typesafeKey) ? readFileSync(paths.typesafeKey, "utf8").trim() : null
10
+ }
11
+
12
+ export function rerankState(chunks: RecallChunk[], candidates: number[]): string {
13
+ return candidates.map((index, n) => `### ${n}\n[turn ${chunks[index].turn}] ${chunks[index].call}\n${chunks[index].lines.map((l) => l.slice(0, RERANK_LINE_CHARS)).join("\n")}`).join("\n\n")
14
+ }
15
+
16
+ export function orderByProbability(candidates: number[], probabilities: Record<string, number>): number[] {
17
+ return candidates.map((_, n) => n).sort((a, b) => (probabilities[String(b)] ?? 0) - (probabilities[String(a)] ?? 0) || a - b).map((n) => candidates[n])
18
+ }
19
+
20
+ export function jevReranker(model: string, key: string, endpoint = RERANK_ENDPOINT): Reranker {
21
+ return async (query, chunks, candidates) => {
22
+ if (candidates.length < 2) return candidates
23
+ try {
24
+ const response = await fetch(endpoint, {
25
+ method: "POST",
26
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
27
+ signal: AbortSignal.timeout(RERANK_TIMEOUT_MS),
28
+ body: JSON.stringify({
29
+ model,
30
+ state: rerankState(chunks, candidates),
31
+ questions: {
32
+ where: { type: "choice", instructions: `Which excerpt contains the exact answer to the developer's question about this coding session: "${query}"?`, criteria: Object.fromEntries(candidates.map((_, n) => [String(n), null])) },
33
+ },
34
+ }),
35
+ })
36
+ if (!response.ok) return candidates
37
+ const json: any = await response.json()
38
+ const probabilities = json?.answers?.where?.probabilities
39
+ return probabilities && typeof probabilities === "object" ? orderByProbability(candidates, probabilities) : candidates
40
+ } catch {
41
+ return candidates
42
+ }
43
+ }
44
+ }
@@ -1,13 +1,13 @@
1
1
  import { CLEARED_INPUT_KEPT, CLEARED_INPUT_THRESHOLD } from "./constants"
2
- import { blocks, withoutCacheControl } from "./content"
2
+ import { blocks, truncate, withoutCacheControl } from "./content"
3
3
  import { compactionNotice, truncationNotice } from "./prompts"
4
4
  import { hasContent, isHarness, reduceTurn, splitTurns } from "./turns"
5
- import type { Block, Body, CompactOptions, Message } from "./types"
5
+ import type { Block, Body, CompactOptions, Message, ReduceOptions } from "./types"
6
6
 
7
- export function trimMessages(messages: Message[]): Message[] {
7
+ export function trimMessages(messages: Message[], options: ReduceOptions = {}): Message[] {
8
8
  const { preamble, turns } = splitTurns(messages)
9
9
  if (turns.length < 2) return messages
10
- return [...preamble, ...turns.slice(0, -1).flatMap(reduceTurn), ...turns.at(-1)!]
10
+ return [...preamble, ...turns.slice(0, -1).flatMap((turn) => reduceTurn(turn, options)), ...turns.at(-1)!]
11
11
  }
12
12
 
13
13
  export function compactMessages(
@@ -28,7 +28,7 @@ export function compactMessages(
28
28
  ...preamble,
29
29
  turns[0][0],
30
30
  { role: "user", content: [{ type: "text", text: notice }, ...carried] },
31
- ...turns.slice(keptFrom, cutIndex).flatMap(reduceTurn),
31
+ ...turns.slice(keptFrom, cutIndex).flatMap((turn) => reduceTurn(turn, options)),
32
32
  ...turns.slice(cutIndex).flat(),
33
33
  ]
34
34
  }
@@ -73,7 +73,7 @@ export function clearToolResults(messages: Message[], from: number, count: numbe
73
73
  }
74
74
 
75
75
  function shortenInput(input: unknown): unknown {
76
- if (typeof input === "string") return input.length > CLEARED_INPUT_THRESHOLD ? `${input.slice(0, CLEARED_INPUT_KEPT)}… [${input.length - CLEARED_INPUT_KEPT} chars cleared; in the transcript]` : input
76
+ if (typeof input === "string") return input.length > CLEARED_INPUT_THRESHOLD ? `${truncate(input, CLEARED_INPUT_KEPT)}… [${input.length - CLEARED_INPUT_KEPT} chars cleared; in the transcript]` : input
77
77
  if (Array.isArray(input)) return input.map(shortenInput)
78
78
  if (input && typeof input === "object") return Object.fromEntries(Object.entries(input).map(([k, v]) => [k, shortenInput(v)]))
79
79
  return input
@@ -1,6 +1,7 @@
1
- import { SUMMARY_ARGUMENT_CHARS, SUMMARY_CALLS_SHOWN, SUMMARY_COMMAND_CHARS, SUMMARY_LAST_WORDS_CHARS } from "./constants"
2
- import { blocks, clean, clip, withoutCacheControl } from "./content"
3
- import type { Block, Message } from "./types"
1
+ import { REMINDER_ONLY, SUMMARY_CALLS_SHOWN, SUMMARY_LAST_WORDS_CHARS } from "./constants"
2
+ import { blocks, clean, describeToolCall, truncate, withoutCacheControl } from "./content"
3
+ import { turnEvidence } from "./evidence"
4
+ import type { Block, Message, ReduceOptions } from "./types"
4
5
 
5
6
  export function isPrompt(message: Message) {
6
7
  if (message.role !== "user") return false
@@ -43,14 +44,6 @@ function mergedHarness(turn: Message[]): Message[] {
43
44
  return content.length > 0 ? [{ role: "system", content }] : []
44
45
  }
45
46
 
46
- function describeToolCall(block: Block): string {
47
- const input = block.input ?? {}
48
- if (typeof input.command === "string") return `${block.name} \`${clip(input.command, SUMMARY_COMMAND_CHARS)}\``
49
- if (typeof input.file_path === "string") return `${block.name} ${input.file_path}`
50
- if (typeof input.pattern === "string") return `${block.name} "${clip(input.pattern, SUMMARY_ARGUMENT_CHARS)}"`
51
- return `${block.name} ${clip(JSON.stringify(input), SUMMARY_ARGUMENT_CHARS)}`
52
- }
53
-
54
47
  function interruptedSummary(turn: Message[]): string {
55
48
  const assistant = turn.filter((m) => m.role === "assistant")
56
49
  const lastWords = [...assistant].reverse()
@@ -60,17 +53,29 @@ function interruptedSummary(turn: Message[]): string {
60
53
  const shown = calls.slice(-SUMMARY_CALLS_SHOWN)
61
54
  return [
62
55
  "[This turn was interrupted before a final reply.]",
63
- lastWords ? `Last thing you said: ${lastWords.length > SUMMARY_LAST_WORDS_CHARS ? `${lastWords.slice(0, SUMMARY_LAST_WORDS_CHARS)}…` : lastWords}` : "",
56
+ lastWords ? `Last thing you said: ${lastWords.length > SUMMARY_LAST_WORDS_CHARS ? `${truncate(lastWords, SUMMARY_LAST_WORDS_CHARS)}…` : lastWords}` : "",
64
57
  calls.length > 0 ? `Tool calls (${calls.length}${calls.length > shown.length ? `, last ${shown.length} shown` : ""}): ${shown.join("; ")}` : "",
65
58
  "Their output is in the full transcript.",
66
59
  ].filter(Boolean).join("\n")
67
60
  }
68
61
 
69
- export function reduceTurn(turn: Message[]): Message[] {
62
+ function carriedText(messages: Message[]): Block[] {
63
+ return messages
64
+ .filter((m) => m.role === "user")
65
+ .flatMap((m) => blocks(m).filter((b) => b.type === "text" && String(b.text ?? "").trim() && !REMINDER_ONLY.test(String(b.text))))
66
+ .map((b) => ({ type: "text", text: b.text }))
67
+ }
68
+
69
+ export function reduceTurn(turn: Message[], options: ReduceOptions = {}): Message[] {
70
70
  const opening: Message[] = []
71
- for (let i = 0; i < turn.length && turn[i].role !== "assistant"; i += 1) {
71
+ let i = 0
72
+ for (; i < turn.length && turn[i].role !== "assistant"; i += 1) {
72
73
  if (turn[i].role === "user") opening.push(clean(turn[i]))
73
74
  }
75
+ const carried = carriedText(turn.slice(i))
76
+ if (carried.length > 0) opening.push({ role: "user", content: carried })
77
+ const evidence = options.evidence ? turnEvidence(turn) : null
78
+ if (evidence) opening.push({ role: "user", content: [{ type: "text", text: evidence }] })
74
79
  opening.push(...mergedHarness(turn))
75
80
  const last = [...turn].reverse().find((m) => m.role !== "system")!
76
81
  const content = last.role === "assistant" ? blocks(last) : []
@@ -11,7 +11,11 @@ export type Body = {
11
11
  [key: string]: any
12
12
  }
13
13
 
14
- export interface CompactOptions {
14
+ export interface ReduceOptions {
15
+ evidence?: boolean
16
+ }
17
+
18
+ export interface CompactOptions extends ReduceOptions {
15
19
  keepTurns: number
16
20
  guide: string | null
17
21
  omittedBefore?: number
@@ -22,3 +26,41 @@ export interface NoticeCounts {
22
26
  kept: number
23
27
  guide: string | null
24
28
  }
29
+
30
+ export interface RecallChunk {
31
+ turn: number
32
+ call: string
33
+ lines: string[]
34
+ terms: string[]
35
+ }
36
+
37
+ export interface EvidenceOptions {
38
+ linesPerCall?: number
39
+ lineChars?: number
40
+ turnChars?: number
41
+ scanLines?: number
42
+ minScore?: number
43
+ allTools?: boolean
44
+ ranked?: boolean
45
+ labels?: boolean
46
+ callFloor?: number
47
+ }
48
+
49
+ export interface RecallOptions {
50
+ windowLines?: number
51
+ perQuery?: number
52
+ minTerms?: number
53
+ maxChars?: number
54
+ maxCharsMulti?: number
55
+ narrative?: boolean
56
+ callWeight?: number
57
+ salientExcerpt?: boolean
58
+ candidates?: number
59
+ }
60
+
61
+ export type Reranker = (query: string, chunks: RecallChunk[], candidates: number[]) => Promise<number[]>
62
+
63
+ export interface RecallPick {
64
+ index: number
65
+ query: Set<string>
66
+ }
@@ -0,0 +1,32 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+ import type { Body } from "../context/types"
4
+ import { BILLING_LINE } from "./constants"
5
+ import type { Canonical, CanonicalResult } from "./types"
6
+
7
+ const billingLine = (system: Body["system"]) => {
8
+ const first = Array.isArray(system) ? String(system[0]?.text ?? "") : String(system ?? "")
9
+ return first.match(BILLING_LINE)?.[0] ?? null
10
+ }
11
+
12
+ function withBilling(system: Body["system"], line: string | null): Body["system"] {
13
+ if (!line) return system
14
+ if (Array.isArray(system)) {
15
+ const [first, ...rest] = system
16
+ return [{ ...first, text: String(first?.text ?? "").replace(BILLING_LINE, line) }, ...rest]
17
+ }
18
+ return String(system ?? "").replace(BILLING_LINE, line)
19
+ }
20
+
21
+ export function applyCanonical(body: Body, file: string): CanonicalResult {
22
+ if (!Array.isArray(body.tools) || body.tools.length === 0) return { body, status: "skipped" }
23
+ if (existsSync(file)) {
24
+ const canonical = JSON.parse(readFileSync(file, "utf8")) as Canonical
25
+ return { body: { ...body, system: withBilling(canonical.system, billingLine(body.system)), tools: canonical.tools }, status: "applied" }
26
+ }
27
+ mkdirSync(path.dirname(file), { recursive: true })
28
+ const partial = `${file}.${process.pid}.${Date.now()}`
29
+ writeFileSync(partial, JSON.stringify({ system: body.system, tools: body.tools } satisfies Canonical))
30
+ renameSync(partial, file)
31
+ return { body, status: "captured" }
32
+ }
@@ -4,8 +4,15 @@ import { splitTurns, turnFingerprint } from "../context/turns"
4
4
  import { DEFAULT_RATIO, PROTECTED_OUTPUTS } from "./constants"
5
5
  import { estimator, targetSize } from "./estimate"
6
6
  import { resetCut } from "./state"
7
+ import type { Message } from "../context/types"
7
8
  import type { CompactInput, CompactResult } from "./types"
8
9
 
10
+ function settledCut(turns: Message[][], index: number) {
11
+ let cut = index
12
+ while (cut > 1 && JSON.stringify(turns[cut][0].content).includes("[Request interrupted")) cut -= 1
13
+ return cut
14
+ }
15
+
9
16
  export function compact({ body, config, state, window, guide, mayCompact, omittedBefore = 0 }: CompactInput): CompactResult {
10
17
  const { turns } = splitTurns(body.messages)
11
18
  let stateChanged = false
@@ -15,9 +22,9 @@ export function compact({ body, config, state, window, guide, mayCompact, omitte
15
22
  resetCut(state)
16
23
  stateChanged = true
17
24
  }
25
+ const rolling = config.eval?.rolling ?? (config.mode === "default" ? { keep: config.keep, budget: config.budget } : undefined)
18
26
  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
27
+ const forced = settledCut(turns, Math.max(1, turns.length - 1 - (rolling?.keep ?? 1)))
21
28
  cutIndex = forced
22
29
  state.cut = turnFingerprint(turns[forced])
23
30
  stateChanged = true
@@ -26,7 +33,7 @@ export function compact({ body, config, state, window, guide, mayCompact, omitte
26
33
  const configuredKeep = config.keepTurns === "all" ? Number.MAX_SAFE_INTEGER : config.keepTurns
27
34
  const liveLength = () => turns.slice(Math.max(cutIndex, 0)).flat().length
28
35
  const compacted = () => cutIndex > 0
29
- ? compactMessages(body.messages, cutIndex, { keepTurns: state.keep ?? configuredKeep, guide, omittedBefore })
36
+ ? compactMessages(body.messages, cutIndex, { keepTurns: state.keep ?? configuredKeep, guide, omittedBefore, evidence: config.eval?.evidence ?? true })
30
37
  : body.messages
31
38
  const build = () => {
32
39
  const messages = compacted()
@@ -41,16 +48,26 @@ export function compact({ body, config, state, window, guide, mayCompact, omitte
41
48
  let estimate = size.of(messages)
42
49
  const actions: string[] = []
43
50
  const current = turns.length - 1
51
+ const moveCut = (index: number, action: string) => {
52
+ cutIndex = index
53
+ resetCut(state)
54
+ state.cut = turnFingerprint(turns[index])
55
+ actions.push(action)
56
+ messages = build()
57
+ estimate = size.of(messages)
58
+ }
44
59
 
45
- 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)
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
53
66
  }
67
+ }
68
+
69
+ if (mayCompact && estimate > limit) {
70
+ if (current > 0 && current > cutIndex) moveCut(current, "cut")
54
71
  while (estimate > target) {
55
72
  const keep = Math.min(state.keep ?? configuredKeep, cutIndex - 1)
56
73
  if (cutIndex > 0 && keep > 0) {
@@ -78,7 +95,7 @@ export function compact({ body, config, state, window, guide, mayCompact, omitte
78
95
  stateChanged,
79
96
  note: {
80
97
  action: cutIndex > 0 ? "compact" : "full",
81
- compacted: actions.includes("cut"),
98
+ compacted: actions.includes("cut") || actions.includes("roll"),
82
99
  actions: actions.length ? actions : undefined,
83
100
  cutTurn: cutIndex > 0 ? cutIndex : null,
84
101
  keep: state.keep,
@@ -2,6 +2,9 @@ export const HEALTH_PATH = "/_eunoe/health"
2
2
  export const SESSION_HEADER = "x-claude-code-session-id"
3
3
  export const TRANSCRIPT_HEADER = "x-eunoe-transcript"
4
4
  export const OMITTED_BEFORE_HEADER = "x-eunoe-omitted-before"
5
+ export const CANONICAL_HEADER = "x-eunoe-canonical"
6
+ export const WINDOW_HEADER = "x-eunoe-window"
7
+ export const BILLING_LINE = /^x-anthropic-billing-header:[^\n]*/
5
8
 
6
9
  export const TARGET_SHARE = 0.3
7
10
  export const PROTECTED_OUTPUTS = 5
@@ -17,3 +20,10 @@ export const MODEL_LOOKUP_TIMEOUT_MS = 5_000
17
20
  export const MAX_EVENT_BUFFER = 4_000_000
18
21
  export const LOGGED_ERROR_CHARS = 500
19
22
  export const IDLE_TIMEOUT_SECONDS = 255
23
+
24
+ export const SESSION_SEEN_INTERVAL_MS = 30_000
25
+ export const SESSION_HEAD_BYTES = 65_536
26
+ export const SESSIONS_KEPT = 200
27
+
28
+ export const FINGERPRINT_LENGTH = 12
29
+ export const SKILL_MARKER = /Base directory for this skill: ([^\s"\\]+)/g
@@ -0,0 +1,15 @@
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)
9
+ }
10
+
11
+ export function contextFingerprint(body: Body): ContextFingerprint {
12
+ const skills = new Set<string>()
13
+ for (const match of JSON.stringify(body.messages ?? []).matchAll(SKILL_MARKER)) skills.add(match[1].split("/").at(-1)!)
14
+ return { system: hash(body.system), tools: hash(body.tools), skills: [...skills].sort() }
15
+ }
@@ -1,8 +1,14 @@
1
1
  import { searchOnlyInstructions, trimInstructions, TRANSCRIPT_FALLBACK } from "../context/prompts"
2
+ import { recallChunks, recallFor, recallForWith, livePrompt, withRecall } from "../context/recall"
3
+ import { jevReranker, rerankKey } from "../context/rerank"
2
4
  import { appendSystem, tailMessages, trimMessages } from "../context/strategies"
5
+ import { blocks, resultText, withoutCacheControl } from "../context/content"
3
6
  import { splitTurns, turnFingerprint } from "../context/turns"
4
- import type { Body } from "../context/types"
7
+ import type { Body, Message, Reranker } from "../context/types"
8
+ import { sessionMessages } from "../transcript/messages"
5
9
  import { searchGuide } from "../transcript/search"
10
+ import { RERANK_OFF } from "../config/constants"
11
+ import type { Config } from "../config/types"
6
12
  import { compact } from "./compaction"
7
13
  import { DEFAULT_RATIO } from "./constants"
8
14
  import { estimator, targetSize } from "./estimate"
@@ -13,7 +19,76 @@ const pass = (body: Body): Rewrite => ({ body, threadKey: null, note: { action:
13
19
 
14
20
  const isAgentThread = (body: Body) => Array.isArray(body.tools) && body.tools.length > 0
15
21
 
22
+ function messageFingerprint(message: Message) {
23
+ const hasher = new Bun.CryptoHasher("sha256")
24
+ hasher.update(JSON.stringify(blocks(message).filter((b) => !(b.type === "text" && String(b.text ?? "").startsWith("<recalled-context>"))).map(withoutCacheControl)))
25
+ return hasher.digest("hex").slice(0, 24)
26
+ }
27
+
28
+ const wantsRecall = (input: RewriteInput) => (input.config.eval?.recall ?? true) && input.config.mode !== "off"
29
+
30
+ function rerankModel(config: Config): string | null {
31
+ const model = config.eval?.rerank?.model ?? config.rerank
32
+ return model && model !== RERANK_OFF ? model : null
33
+ }
34
+
35
+ function droppedChunks(original: Body, result: Rewrite, transcript: string | null) {
36
+ const kept = new Map<string, string>()
37
+ 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))
38
+ const chunks = recallChunks(original.messages, kept)
39
+ return chunks.length || !transcript ? chunks : recallChunks([...sessionMessages(transcript), { role: "user", content: "" }], kept)
40
+ }
41
+
42
+ function recallTarget(original: Body, result: Rewrite): { key: string; text: string } | null {
43
+ if (!result.threadKey || result.body === original && result.note.action === "pass") return null
44
+ const live = livePrompt(original.messages)
45
+ return live ? { key: messageFingerprint(original.messages[live.index]), text: live.text } : null
46
+ }
47
+
48
+ function recalled(original: Body, result: Rewrite, transcript: string | null): Rewrite {
49
+ if (!result.threadKey || result.body === original && result.note.action === "pass") return result
50
+ const state = loadState(result.threadKey)
51
+ state.recalls ??= {}
52
+ const target = recallTarget(original, result)
53
+ let note = result.note
54
+ if (target && !(target.key in state.recalls)) {
55
+ state.recalls[target.key] = recallFor(target.text, droppedChunks(original, result, transcript)) ?? ""
56
+ saveState(result.threadKey, state)
57
+ note = { ...note, recalled: state.recalls[target.key].length }
58
+ }
59
+ const recalls = Object.fromEntries(Object.entries(state.recalls).filter(([, text]) => text))
60
+ const messages = withRecall(result.body.messages, messageFingerprint, recalls)
61
+ return messages === result.body.messages ? { ...result, note } : { ...result, body: { ...result.body, messages }, note }
62
+ }
63
+
64
+ export async function prefetchRecall(input: RewriteInput, rerank: Reranker): Promise<boolean> {
65
+ if (!wantsRecall(input)) return false
66
+ const result = strategize(input)
67
+ const target = recallTarget(input.body, result)
68
+ if (!target) return false
69
+ const state = loadState(result.threadKey!)
70
+ state.recalls ??= {}
71
+ if (target.key in state.recalls) return false
72
+ const chunks = droppedChunks(input.body, result, input.transcript?.jsonl ?? null)
73
+ state.recalls[target.key] = (await recallForWith(target.text, chunks, rerank, { candidates: input.config.eval?.rerank?.candidates })) ?? ""
74
+ saveState(result.threadKey!, state)
75
+ return true
76
+ }
77
+
16
78
  export function rewrite(input: RewriteInput): Rewrite {
79
+ const result = strategize(input)
80
+ return wantsRecall(input) ? recalled(input.body, result, input.transcript?.jsonl ?? null) : result
81
+ }
82
+
83
+ export async function rewriteAsync(input: RewriteInput): Promise<Rewrite> {
84
+ const model = rerankModel(input.config)
85
+ const key = model ? rerankKey() : null
86
+ const reranked = model && key ? await prefetchRecall(input, jevReranker(model, key)) : false
87
+ const result = rewrite(input)
88
+ return reranked ? { ...result, note: { ...result.note, reranked: model } } : result
89
+ }
90
+
91
+ function strategize(input: RewriteInput): Rewrite {
17
92
  const { body, sessionId, config, transcript } = input
18
93
  if (config.mode === "off" || !sessionId || !Array.isArray(body.messages) || body.messages.length === 0) return pass(body)
19
94
  const { turns } = splitTurns(body.messages)
@@ -48,15 +123,14 @@ export function rewrite(input: RewriteInput): Rewrite {
48
123
  note: { action: "tail", keptTurns: turns.length - from, turns: turns.length, messagesIn: body.messages.length, messagesOut: messages.length, estimate: size.of(messages) },
49
124
  }
50
125
  }
51
- }
52
-
53
- if (config.mode === "trim") {
54
- if (!isAgentThread(body)) return pass(body)
55
- const messages = trimMessages(body.messages)
56
- return {
57
- body: { ...body, system: appendSystem(body.system, trimInstructions(guide ?? TRANSCRIPT_FALLBACK)), messages },
58
- threadKey,
59
- note: { action: "trim", turns: turns.length, messagesIn: body.messages.length, messagesOut: messages.length },
126
+ case "trim": {
127
+ if (!isAgentThread(body)) return pass(body)
128
+ const messages = trimMessages(body.messages, { evidence: config.eval?.evidence ?? true })
129
+ return {
130
+ body: { ...body, system: appendSystem(body.system, trimInstructions(guide ?? TRANSCRIPT_FALLBACK)), messages },
131
+ threadKey,
132
+ note: { action: "trim", turns: turns.length, messagesIn: body.messages.length, messagesOut: messages.length },
133
+ }
60
134
  }
61
135
  }
62
136