@chroxify/eunoe 0.0.0-stage → 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 (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +406 -2
  3. package/package.json +42 -4
  4. package/src/cli/args.ts +17 -0
  5. package/src/cli/claude-settings.ts +42 -0
  6. package/src/cli/commands/index.ts +23 -0
  7. package/src/cli/commands/mode.ts +66 -0
  8. package/src/cli/commands/serve.ts +20 -0
  9. package/src/cli/commands/settings.ts +100 -0
  10. package/src/cli/commands/start.ts +64 -0
  11. package/src/cli/commands/status.ts +82 -0
  12. package/src/cli/commands/stop.ts +38 -0
  13. package/src/cli/commands/uninstall.ts +27 -0
  14. package/src/cli/constants.ts +52 -0
  15. package/src/cli/help.ts +42 -0
  16. package/src/cli/inspect.ts +41 -0
  17. package/src/cli/launchd.ts +39 -0
  18. package/src/cli/logo/index.ts +89 -0
  19. package/src/cli/logo/pixels.ts +34 -0
  20. package/src/cli/main.ts +23 -0
  21. package/src/cli/prompt.ts +91 -0
  22. package/src/cli/service.ts +33 -0
  23. package/src/cli/sessions.ts +25 -0
  24. package/src/cli/stats.ts +73 -0
  25. package/src/cli/types.ts +14 -0
  26. package/src/cli/ui.ts +106 -0
  27. package/src/config/constants.ts +39 -0
  28. package/src/config/index.ts +98 -0
  29. package/src/config/types.ts +50 -0
  30. package/src/context/constants.ts +42 -0
  31. package/src/context/content.ts +46 -0
  32. package/src/context/evidence.ts +92 -0
  33. package/src/context/prompts.ts +66 -0
  34. package/src/context/recall.ts +155 -0
  35. package/src/context/rerank.ts +44 -0
  36. package/src/context/strategies.ts +89 -0
  37. package/src/context/turns.ts +87 -0
  38. package/src/context/types.ts +66 -0
  39. package/src/proxy/canonical.ts +32 -0
  40. package/src/proxy/compaction.ts +110 -0
  41. package/src/proxy/constants.ts +29 -0
  42. package/src/proxy/estimate.ts +26 -0
  43. package/src/proxy/fingerprint.ts +15 -0
  44. package/src/proxy/log.ts +9 -0
  45. package/src/proxy/rewrite.ts +146 -0
  46. package/src/proxy/server.ts +138 -0
  47. package/src/proxy/sessions.ts +46 -0
  48. package/src/proxy/state.ts +27 -0
  49. package/src/proxy/types.ts +82 -0
  50. package/src/proxy/usage.ts +44 -0
  51. package/src/proxy/window.ts +50 -0
  52. package/src/transcript/constants.ts +15 -0
  53. package/src/transcript/format.ts +68 -0
  54. package/src/transcript/locate.ts +28 -0
  55. package/src/transcript/messages.ts +29 -0
  56. package/src/transcript/parse.ts +93 -0
  57. package/src/transcript/render.ts +36 -0
  58. package/src/transcript/search.ts +80 -0
  59. package/src/transcript/types.ts +43 -0
  60. package/src/version.ts +3 -0
@@ -0,0 +1,92 @@
1
+ import { ERROR_CODE, ERROR_LINE, EVIDENCE_CALL_FLOOR, EVIDENCE_LABELS, EVIDENCE_LINE_CHARS, EVIDENCE_LINES_PER_CALL, EVIDENCE_MIN_SCORE, EVIDENCE_RANKED, EVIDENCE_SCAN_LINES, EVIDENCE_TURN_CHARS, ID_TOKEN, KEY_VALUE, REREADABLE_TOOLS, VALUE_LABEL, VALUE_TOKEN } from "./constants"
2
+ import { blocks, clip, describeToolCall, resultText } from "./content"
3
+ import type { Block, EvidenceOptions, Message } from "./types"
4
+
5
+ interface Scored {
6
+ index: number
7
+ line: string
8
+ score: number
9
+ }
10
+
11
+ export function isSalient(line: string): boolean {
12
+ return ERROR_LINE.test(line) || ERROR_CODE.test(line) || VALUE_TOKEN.test(line)
13
+ }
14
+
15
+ function scoreLines(text: string, options: EvidenceOptions): Scored[] {
16
+ const scan = options.scanLines ?? EVIDENCE_SCAN_LINES
17
+ const lineChars = options.lineChars ?? EVIDENCE_LINE_CHARS
18
+ const minScore = options.minScore ?? EVIDENCE_MIN_SCORE
19
+ const scored = text.split("\n").slice(0, scan).flatMap((raw, index) => {
20
+ const line = raw.trim()
21
+ if (line.length < 4) return []
22
+ const error = ERROR_LINE.test(line) || ERROR_CODE.test(line)
23
+ const value = VALUE_TOKEN.test(line)
24
+ if (!error && !value) return []
25
+ const score = (error ? 3 : 0) + (value ? (ID_TOKEN.test(line) ? 3 : 2) : 0) + (KEY_VALUE.test(line) ? 1 : 0) + (line.length < 120 ? 1 : 0) + ((options.labels ?? EVIDENCE_LABELS) && VALUE_LABEL.test(line) ? 1 : 0)
26
+ return score >= minScore ? [{ index, line: clip(line, lineChars), score }] : []
27
+ })
28
+ const seen = new Set<string>()
29
+ return scored.sort((a, b) => b.score - a.score || a.index - b.index).filter((s) => !seen.has(s.line) && seen.add(s.line))
30
+ }
31
+
32
+ export function salientLines(text: string, options: EvidenceOptions = {}): string[] {
33
+ return scoreLines(text, options)
34
+ .slice(0, options.linesPerCall ?? EVIDENCE_LINES_PER_CALL)
35
+ .sort((a, b) => a.index - b.index)
36
+ .map((s) => s.line)
37
+ }
38
+
39
+ export function toolPairs(turn: Message[]): Array<{ call: Block; result: Block }> {
40
+ const results = new Map<string, Block>()
41
+ for (const message of turn) {
42
+ if (message.role !== "user") continue
43
+ for (const block of blocks(message)) if (block.type === "tool_result") results.set(block.tool_use_id, block)
44
+ }
45
+ return turn
46
+ .filter((m) => m.role === "assistant")
47
+ .flatMap((m) => blocks(m).filter((b) => b.type === "tool_use"))
48
+ .flatMap((call) => (results.has(call.id) ? [{ call, result: results.get(call.id)! }] : []))
49
+ }
50
+
51
+ function render(entries: Array<{ head: string; lines: string[] }>): string | null {
52
+ const body = entries.filter((e) => e.lines.length).map((e) => `- ${e.head}:\n${e.lines.map((l) => ` ${l}`).join("\n")}`)
53
+ return body.length ? `<tool-evidence>\nKey lines of command output from this turn, kept verbatim (the full output is in the transcript):\n${body.join("\n")}\n</tool-evidence>` : null
54
+ }
55
+
56
+ export function turnEvidence(turn: Message[], options: EvidenceOptions = {}): string | null {
57
+ const turnChars = options.turnChars ?? EVIDENCE_TURN_CHARS
58
+ const perCall = options.linesPerCall ?? EVIDENCE_LINES_PER_CALL
59
+ const pairs = toolPairs(turn).filter(({ call }) => options.allTools || !REREADABLE_TOOLS.has(call.name))
60
+ const heads = pairs.map(({ call, result }) => `${describeToolCall(call)}${result.is_error ? " (failed)" : ""}`)
61
+ if (!(options.ranked ?? EVIDENCE_RANKED)) {
62
+ const entries: Array<{ head: string; lines: string[] }> = []
63
+ let used = 0
64
+ pairs.forEach(({ result }, i) => {
65
+ const lines = salientLines(resultText(result), options)
66
+ if (!lines.length) return
67
+ const size = heads[i].length + 4 + lines.reduce((s, l) => s + l.length + 5, 0)
68
+ if (used + size > turnChars) return
69
+ entries.push({ head: heads[i], lines })
70
+ used += size
71
+ })
72
+ return render(entries)
73
+ }
74
+ const floor = options.callFloor ?? EVIDENCE_CALL_FLOOR
75
+ const perCallLines = pairs.map(({ result }) => scoreLines(resultText(result), options))
76
+ const chosen = new Map<number, Scored[]>()
77
+ let used = 0
78
+ const take = (call: number, line: Scored) => {
79
+ const bucket = chosen.get(call) ?? []
80
+ const cost = line.line.length + 5 + (bucket.length ? 0 : heads[call].length + 4)
81
+ if (bucket.length >= perCall || used + cost > turnChars) return
82
+ bucket.push(line)
83
+ chosen.set(call, bucket)
84
+ used += cost
85
+ }
86
+ perCallLines.forEach((lines, call) => lines.slice(0, floor).forEach((line) => take(call, line)))
87
+ perCallLines
88
+ .flatMap((lines, call) => lines.slice(floor).map((line) => ({ line, call })))
89
+ .sort((a, b) => b.line.score - a.line.score || a.call - b.call || a.line.index - b.line.index)
90
+ .forEach(({ line, call }) => take(call, line))
91
+ return render(heads.map((head, i) => ({ head, lines: (chosen.get(i) ?? []).sort((a, b) => a.index - b.index).map((s) => s.line) })))
92
+ }
@@ -0,0 +1,66 @@
1
+ import type { NoticeCounts } from "./types"
2
+
3
+ export function compactionNotice(args: NoticeCounts) {
4
+ return [
5
+ "<context-compacted>",
6
+ "Your context was compacted here, so tool calls and their output from before this point were removed.",
7
+ args.omitted > 0
8
+ ? `${args.omitted} of the oldest turns didn't fit and were removed entirely. The ${args.kept} turn${args.kept === 1 ? "" : "s"} after them are shown as the user's message and your final reply.`
9
+ : args.kept > 0
10
+ ? "Every earlier turn is shown below as the user's message and your final reply."
11
+ : "",
12
+ "The turn that was in progress when the context filled up follows in full. Continue from it.",
13
+ "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.",
14
+ "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.",
15
+ args.guide ?? "",
16
+ "</context-compacted>",
17
+ ].filter(Boolean).join("\n\n")
18
+ }
19
+
20
+ export function truncationNotice(args: NoticeCounts) {
21
+ return [
22
+ "<context-truncated>",
23
+ `${args.omitted} earlier turn${args.omitted === 1 ? " was" : "s were"} dropped to fit the context window. What follows is the most recent ${args.kept} turn${args.kept === 1 ? "" : "s"}, unchanged.`,
24
+ "Instructions and preferences the user gave in earlier turns still apply.",
25
+ args.guide ?? "",
26
+ "</context-truncated>",
27
+ ].filter(Boolean).join("\n\n")
28
+ }
29
+
30
+ export function trimInstructions(guide: string): string {
31
+ return [
32
+ "# Context between turns",
33
+ "",
34
+ "This session trims its context between turns: of every earlier turn you keep only the user's message (with anything that arrived with it) and your final message. Everything in between is gone when the next turn starts: tool calls and their results, files you read, command output, your thinking, and anything you wrote along the way. Within a turn, nothing is trimmed.",
35
+ "",
36
+ "So your final message is the only memory you carry forward. End each turn with a final message that lets you pick the work back up cold:",
37
+ "- What changed: files with their paths, branches, commits, PRs, anything created, moved or deleted.",
38
+ "- What you found: facts you would otherwise have to dig up again. Where code lives, how something works, the cause of a bug, exact error text, IDs, URLs, the numbers that matter.",
39
+ "- Decisions and their reasons, including approaches you tried and ruled out.",
40
+ "- Where things stand: what is done, what is not, what you verified and how, and what is waiting on the user.",
41
+ "",
42
+ "It is still your reply to the user, so write it for them, but leave out nothing you will need. State things outright instead of pointing back at tool output: \"as shown above\" means nothing next turn. One exact line (a path, a command, a value) beats a paragraph describing it. A turn that ends on a question still needs the state written down. A trivial turn (a quick answer, small talk) needs no state.",
43
+ "",
44
+ "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.",
45
+ "",
46
+ "Nothing is lost. What you see of earlier turns is only their outcome. For an exact detail from an earlier turn (an ID, a path, a command you ran, what it printed, a file's earlier content, an error), look it up in the full transcript instead of going from memory or redoing the work. A lookup is cheap; a wrong detail is not.",
47
+ "",
48
+ guide,
49
+ "",
50
+ "What you read from it stays in context for this turn only. If you will need it again later, put it in your final message.",
51
+ ].join("\n")
52
+ }
53
+
54
+ export function searchOnlyInstructions(guide: string): string {
55
+ return [
56
+ "# The full transcript",
57
+ "",
58
+ "Earlier parts of this session may be summarised or missing from your context. The complete record is on disk, and for any exact detail from earlier (an ID, a path, a command you ran, what it printed, an error, a value) you should look it up there rather than answer 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.",
59
+ "",
60
+ guide,
61
+ ].join("\n")
62
+ }
63
+
64
+ export const CLEARED_OUTPUT = "[Output cleared to keep the context within its limit. It is in the full transcript: search there for this call.]"
65
+
66
+ export const TRANSCRIPT_FALLBACK = "The full transcript is your Claude Code session file under ~/.claude/projects."
@@ -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
+ }
@@ -0,0 +1,89 @@
1
+ import { CLEARED_INPUT_KEPT, CLEARED_INPUT_THRESHOLD } from "./constants"
2
+ import { blocks, truncate, withoutCacheControl } from "./content"
3
+ import { compactionNotice, truncationNotice } from "./prompts"
4
+ import { hasContent, isHarness, reduceTurn, splitTurns } from "./turns"
5
+ import type { Block, Body, CompactOptions, Message, ReduceOptions } from "./types"
6
+
7
+ export function trimMessages(messages: Message[], options: ReduceOptions = {}): Message[] {
8
+ const { preamble, turns } = splitTurns(messages)
9
+ if (turns.length < 2) return messages
10
+ return [...preamble, ...turns.slice(0, -1).flatMap((turn) => reduceTurn(turn, options)), ...turns.at(-1)!]
11
+ }
12
+
13
+ export function compactMessages(
14
+ messages: Message[],
15
+ cutIndex: number,
16
+ options: CompactOptions,
17
+ ): Message[] {
18
+ const { preamble, turns } = splitTurns(messages)
19
+ if (cutIndex <= 0 || cutIndex >= turns.length) return messages
20
+ const keptFrom = Math.max(1, cutIndex - options.keepTurns)
21
+ const notice = compactionNotice({ omitted: keptFrom - 1 + (options.omittedBefore ?? 0), kept: cutIndex - keptFrom, guide: options.guide })
22
+ const removed = [...turns[0].slice(1), ...turns.slice(1, keptFrom).flat()]
23
+ const carried = removed
24
+ .filter((m) => isHarness(m) && hasContent(m))
25
+ .flatMap((m) => blocks(m).filter((b) => b.type === "text"))
26
+ .map((b) => ({ type: "text", text: `<system-reminder>\n${b.text}\n</system-reminder>` }))
27
+ return [
28
+ ...preamble,
29
+ turns[0][0],
30
+ { role: "user", content: [{ type: "text", text: notice }, ...carried] },
31
+ ...turns.slice(keptFrom, cutIndex).flatMap((turn) => reduceTurn(turn, options)),
32
+ ...turns.slice(cutIndex).flat(),
33
+ ]
34
+ }
35
+
36
+ export function tailMessages(messages: Message[], from: number, guide: string | null): Message[] {
37
+ const { preamble, turns } = splitTurns(messages)
38
+ if (turns.length === 0 || from <= 1) return messages
39
+ const notice = truncationNotice({ omitted: from - 1, kept: turns.length - from, guide })
40
+ return [
41
+ ...preamble,
42
+ turns[0][0],
43
+ { role: "user", content: [{ type: "text", text: notice }] },
44
+ ...turns.slice(from).flat(),
45
+ ]
46
+ }
47
+
48
+ export function countToolResults(messages: Message[], from: number): number {
49
+ return messages.slice(from).reduce((total, m) => total + (m.role === "user" ? blocks(m).filter((b) => b.type === "tool_result").length : 0), 0)
50
+ }
51
+
52
+ export function clearToolResults(messages: Message[], from: number, count: number, placeholder: string): Message[] {
53
+ let left = count
54
+ const cleared = new Set<string>()
55
+ const results = messages.map((message, index) => {
56
+ if (left <= 0 || index < from || message.role !== "user" || typeof message.content === "string") return message
57
+ if (!message.content.some((b) => b.type === "tool_result")) return message
58
+ return {
59
+ ...message,
60
+ content: message.content.map((b) => {
61
+ if (b.type !== "tool_result" || left <= 0) return b
62
+ left -= 1
63
+ cleared.add(b.tool_use_id)
64
+ return { type: "tool_result", tool_use_id: b.tool_use_id, ...(b.is_error ? { is_error: true } : {}), content: placeholder }
65
+ }),
66
+ }
67
+ })
68
+ return results.map((message) => {
69
+ if (message.role !== "assistant" || typeof message.content === "string") return message
70
+ if (!message.content.some((b) => b.type === "tool_use" && cleared.has(b.id))) return message
71
+ return { ...message, content: message.content.map((b) => (b.type === "tool_use" && cleared.has(b.id) ? { ...b, input: shortenInput(b.input) } : b)) }
72
+ })
73
+ }
74
+
75
+ function shortenInput(input: unknown): unknown {
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
+ if (Array.isArray(input)) return input.map(shortenInput)
78
+ if (input && typeof input === "object") return Object.fromEntries(Object.entries(input).map(([k, v]) => [k, shortenInput(v)]))
79
+ return input
80
+ }
81
+
82
+ export function appendSystem(system: Body["system"], text: string): Block[] {
83
+ const list: Block[] = typeof system === "string" ? [{ type: "text", text: system }] : [...(system ?? [])]
84
+ const lastCached = list.findLastIndex((b) => b.cache_control)
85
+ const cacheControl = lastCached >= 0 ? list[lastCached].cache_control : undefined
86
+ if (lastCached >= 0) list[lastCached] = withoutCacheControl(list[lastCached])
87
+ list.push({ type: "text", text, ...(cacheControl ? { cache_control: cacheControl } : {}) })
88
+ return list
89
+ }
@@ -0,0 +1,87 @@
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"
5
+
6
+ export function isPrompt(message: Message) {
7
+ if (message.role !== "user") return false
8
+ const content = blocks(message)
9
+ return !content.some((block) => block.type === "tool_result")
10
+ && content.some((block) => block.type === "text" || block.type === "image" || block.type === "document")
11
+ }
12
+
13
+ export function splitTurns(messages: Message[]): { preamble: Message[]; turns: Message[][] } {
14
+ const preamble: Message[] = []
15
+ const turns: Message[][] = []
16
+ for (const message of messages) {
17
+ if (isPrompt(message) && !(turns.length > 0 && turns.at(-1)!.every((m) => m.role === "user"))) {
18
+ turns.push([message])
19
+ } else if (turns.length > 0) {
20
+ turns.at(-1)!.push(message)
21
+ } else {
22
+ preamble.push(message)
23
+ }
24
+ }
25
+ return { preamble, turns }
26
+ }
27
+
28
+ export function turnFingerprint(turn: Message[]): string {
29
+ const hasher = new Bun.CryptoHasher("sha256")
30
+ hasher.update(JSON.stringify(blocks(turn[0]).map(withoutCacheControl)))
31
+ return hasher.digest("hex").slice(0, 24)
32
+ }
33
+
34
+ export function isHarness(message: Message) {
35
+ return message.role === "system"
36
+ }
37
+
38
+ export function hasContent(message: Message) {
39
+ return blocks(message).some((block) => block.type !== "text" || String(block.text ?? "").trim())
40
+ }
41
+
42
+ function mergedHarness(turn: Message[]): Message[] {
43
+ const content = turn.filter((m) => isHarness(m) && hasContent(m)).flatMap((m) => blocks(m).map(withoutCacheControl))
44
+ return content.length > 0 ? [{ role: "system", content }] : []
45
+ }
46
+
47
+ function interruptedSummary(turn: Message[]): string {
48
+ const assistant = turn.filter((m) => m.role === "assistant")
49
+ const lastWords = [...assistant].reverse()
50
+ .map((m) => blocks(m).filter((b) => b.type === "text").map((b) => String(b.text ?? "")).join("\n").trim())
51
+ .find(Boolean)
52
+ const calls = assistant.flatMap((m) => blocks(m).filter((b) => b.type === "tool_use")).map(describeToolCall)
53
+ const shown = calls.slice(-SUMMARY_CALLS_SHOWN)
54
+ return [
55
+ "[This turn was interrupted before a final reply.]",
56
+ lastWords ? `Last thing you said: ${lastWords.length > SUMMARY_LAST_WORDS_CHARS ? `${truncate(lastWords, SUMMARY_LAST_WORDS_CHARS)}…` : lastWords}` : "",
57
+ calls.length > 0 ? `Tool calls (${calls.length}${calls.length > shown.length ? `, last ${shown.length} shown` : ""}): ${shown.join("; ")}` : "",
58
+ "Their output is in the full transcript.",
59
+ ].filter(Boolean).join("\n")
60
+ }
61
+
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
+ const opening: Message[] = []
71
+ let i = 0
72
+ for (; i < turn.length && turn[i].role !== "assistant"; i += 1) {
73
+ if (turn[i].role === "user") opening.push(clean(turn[i]))
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 }] })
79
+ opening.push(...mergedHarness(turn))
80
+ const last = [...turn].reverse().find((m) => m.role !== "system")!
81
+ const content = last.role === "assistant" ? blocks(last) : []
82
+ const text = content.filter((b) => b.type === "text" && String(b.text ?? "").trim())
83
+ const final = text.length > 0 && !content.some((b) => b.type === "tool_use")
84
+ ? text.map((b) => ({ type: "text", text: b.text }))
85
+ : [{ type: "text", text: interruptedSummary(turn) }]
86
+ return [...opening, { role: "assistant", content: final }]
87
+ }
@@ -0,0 +1,66 @@
1
+ export type Block = { type: string; [key: string]: any }
2
+
3
+ export type Message = { role: string; content: string | Block[] }
4
+
5
+ export type Body = {
6
+ model?: string
7
+ system?: string | Block[]
8
+ messages: Message[]
9
+ tools?: unknown[]
10
+ metadata?: { user_id?: string }
11
+ [key: string]: any
12
+ }
13
+
14
+ export interface ReduceOptions {
15
+ evidence?: boolean
16
+ }
17
+
18
+ export interface CompactOptions extends ReduceOptions {
19
+ keepTurns: number
20
+ guide: string | null
21
+ omittedBefore?: number
22
+ }
23
+
24
+ export interface NoticeCounts {
25
+ omitted: number
26
+ kept: number
27
+ guide: string | null
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
+ }