@chroxify/eunoe 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +68 -9
  2. package/package.json +18 -2
  3. package/src/cli/{types.ts → command.ts} +0 -4
  4. package/src/cli/commands/index.ts +13 -1
  5. package/src/cli/commands/load.ts +44 -0
  6. package/src/cli/commands/mode.ts +41 -33
  7. package/src/cli/commands/peek.ts +22 -0
  8. package/src/cli/commands/replay.ts +245 -0
  9. package/src/cli/commands/serve.ts +22 -7
  10. package/src/cli/commands/sessions.ts +70 -0
  11. package/src/cli/commands/settings.ts +79 -60
  12. package/src/cli/commands/start.ts +56 -21
  13. package/src/cli/commands/stats.ts +233 -0
  14. package/src/cli/commands/status.ts +98 -55
  15. package/src/cli/commands/stop.ts +8 -6
  16. package/src/cli/commands/uninstall.ts +10 -12
  17. package/src/cli/commands/watch.ts +16 -0
  18. package/src/cli/logo/art.ts +99 -0
  19. package/src/cli/logo/index.ts +12 -18
  20. package/src/cli/main.ts +3 -3
  21. package/src/cli/proxy/claude-settings.ts +69 -0
  22. package/src/cli/proxy/inspect.ts +67 -0
  23. package/src/cli/proxy/launchd.ts +53 -0
  24. package/src/cli/proxy/live.ts +57 -0
  25. package/src/cli/proxy/service.ts +53 -0
  26. package/src/cli/{stats.ts → proxy/stats.ts} +29 -16
  27. package/src/cli/proxy/watch.ts +60 -0
  28. package/src/cli/replay/index.ts +146 -0
  29. package/src/cli/replay/worker.ts +20 -0
  30. package/src/cli/sessions/browse.ts +88 -0
  31. package/src/cli/sessions/index.ts +76 -0
  32. package/src/cli/sessions/source.ts +74 -0
  33. package/src/cli/{help.ts → terminal/help.ts} +24 -8
  34. package/src/cli/terminal/prompt.ts +114 -0
  35. package/src/cli/{ui.ts → terminal/ui.ts} +55 -24
  36. package/src/cli/usage/index.ts +151 -0
  37. package/src/config/index.ts +99 -34
  38. package/src/config/paths.ts +17 -0
  39. package/src/context/content.ts +40 -14
  40. package/src/context/fold/evidence.ts +145 -0
  41. package/src/context/fold/index.ts +65 -0
  42. package/src/context/fold/strategies.ts +94 -0
  43. package/src/context/fold/turns.ts +76 -0
  44. package/src/context/prompts.ts +141 -22
  45. package/src/context/recall/index.ts +214 -0
  46. package/src/context/recall/rerank.ts +71 -0
  47. package/src/proxy/canonical.ts +17 -9
  48. package/src/proxy/compaction.ts +106 -53
  49. package/src/proxy/estimate.ts +14 -9
  50. package/src/proxy/fingerprint.ts +23 -9
  51. package/src/proxy/handoff.ts +71 -0
  52. package/src/proxy/log.ts +2 -2
  53. package/src/proxy/recall.ts +58 -0
  54. package/src/proxy/rewrite.ts +141 -119
  55. package/src/proxy/server.ts +108 -51
  56. package/src/proxy/sessions.ts +32 -14
  57. package/src/proxy/state.ts +17 -3
  58. package/src/proxy/usage.ts +50 -34
  59. package/src/proxy/window.ts +32 -34
  60. package/src/transcript/format.ts +88 -25
  61. package/src/transcript/locate.ts +38 -17
  62. package/src/transcript/messages.ts +3 -14
  63. package/src/transcript/parse.ts +137 -56
  64. package/src/transcript/render.ts +44 -17
  65. package/src/transcript/search.ts +20 -56
  66. package/src/transcript/subagents.ts +150 -0
  67. package/src/cli/claude-settings.ts +0 -42
  68. package/src/cli/constants.ts +0 -52
  69. package/src/cli/inspect.ts +0 -41
  70. package/src/cli/launchd.ts +0 -39
  71. package/src/cli/logo/pixels.ts +0 -34
  72. package/src/cli/prompt.ts +0 -91
  73. package/src/cli/service.ts +0 -33
  74. package/src/cli/sessions.ts +0 -25
  75. package/src/config/constants.ts +0 -39
  76. package/src/config/types.ts +0 -50
  77. package/src/context/constants.ts +0 -42
  78. package/src/context/evidence.ts +0 -92
  79. package/src/context/recall.ts +0 -155
  80. package/src/context/rerank.ts +0 -44
  81. package/src/context/strategies.ts +0 -89
  82. package/src/context/turns.ts +0 -87
  83. package/src/context/types.ts +0 -66
  84. package/src/proxy/constants.ts +0 -29
  85. package/src/proxy/types.ts +0 -82
  86. package/src/transcript/constants.ts +0 -15
  87. package/src/transcript/types.ts +0 -43
  88. /package/src/cli/{args.ts → terminal/args.ts} +0 -0
@@ -1,33 +1,105 @@
1
- import type { NoticeCounts } from "./types"
1
+ import type { TranscriptRef } from "../transcript/search"
2
+ import { truncate } from "./content"
2
3
 
3
- export function compactionNotice(args: NoticeCounts) {
4
+ interface NoticeCounts {
5
+ omitted: number
6
+ kept: number
7
+ guide: string | null
8
+ }
9
+
10
+ const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? "" : "s"}`
11
+
12
+ export const SYSTEM_PROMPT = [
13
+ "# Earlier sessions in this project",
14
+ "",
15
+ "eunoe keeps the full record of this project's earlier Claude Code sessions, and the user expects you to use it the way a colleague who was there would. When they point you at one, skipping it is the failure to avoid: you will redo finished work, break rules the user already set, and ask for things they already told you.",
16
+ "",
17
+ "## When",
18
+ "",
19
+ 'A message carrying `{ee:continue:<id>}` already has that session attached to it as a `<handoff>` block: read it as part of the message, nothing to run. Use the commands below when the user names a session without the marker (`eunoe load <id>`), or when they refer to earlier work you have no context for: a plan, a todo, "the thing we talked about", "where we left off". `eunoe sessions <key words>` lists the closest earlier sessions, newest first, each with its first and last request; if one clearly continues into this message, load it, otherwise ask which they mean. Do not go looking for work that is clearly new and unrelated.',
20
+ "",
21
+ "## How",
22
+ "",
23
+ "1. `eunoe load <id>` writes that whole session to a file and prints its path. Read the file with the Read tool from the first line to the last, in as many Read calls as its length needs, before you act. Do not grep, head, tail or sed it, and do not stop partway: the rules, values and open todos you need are spread across all of it.",
24
+ "2. Only then start the work. Follow what the record establishes: the user's rules and preferences, decisions made, what was finished and what was left for later. Its values (ids, versions, names, commands) are exact.",
25
+ "",
26
+ "`eunoe peek <id> <words>` returns just the excerpts of a session that match, for a single fact such as an error message or a value. It is not a substitute for loading when the message continues that session's work.",
27
+ "",
28
+ "Loading or peeking needs no permission and no announcement: run it and carry on with the request.",
29
+ ].join("\n")
30
+
31
+ const HANDOFF_PREAMBLE =
32
+ "What follows is carried over from an earlier session on this project so you can pick the work up without being told again: the user's messages, the key output lines and the agent's replies, in order. Treat the rules and preferences in it as still in force, and the values in it (ids, versions, names, commands) as exact. Its full tool output is not here but stays reachable two ways: when a later message touches it, the matching excerpts are attached to that message as recalled context, labelled with this session's title; and for a specific value you can run `eunoe peek <id> <words>` yourself."
33
+
34
+ export function handoffBlock(label: string, text: string): string {
35
+ return `<handoff from="${label}">\n${HANDOFF_PREAMBLE}\n\n${text}\n</handoff>`
36
+ }
37
+
38
+ export function recalledContext(entries: string[], fromEarlierSessions: boolean): string {
39
+ const where = fromEarlierSessions ? "earlier in this session or in an earlier session on this project" : "earlier in this session"
40
+ return `<recalled-context>\nExcerpts from ${where} 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>`
41
+ }
42
+
43
+ export function rerankQuestion(query: string): string {
44
+ return `Which excerpt contains the exact answer to the developer's question about this coding session: "${query}"?`
45
+ }
46
+
47
+ export function toolEvidence(entries: string[]): string {
48
+ return `<tool-evidence>\nKey lines of command output from this turn, kept verbatim (the full output is in the transcript):\n${entries.join("\n")}\n</tool-evidence>`
49
+ }
50
+
51
+ export function interruptedReply(lastWords: string | undefined, calls: string[]): string {
52
+ const shown = calls.slice(-20)
53
+ return [
54
+ "[This turn was interrupted before a final reply.]",
55
+ lastWords ? `Last thing you said: ${lastWords.length > 600 ? `${truncate(lastWords, 600)}…` : lastWords}` : "",
56
+ calls.length > 0 ? `Tool calls (${calls.length}${calls.length > shown.length ? `, last ${shown.length} shown` : ""}): ${shown.join("; ")}` : "",
57
+ "Their output is in the full transcript.",
58
+ ]
59
+ .filter(Boolean)
60
+ .join("\n")
61
+ }
62
+
63
+ export function clearedOutput(dir: string | null): string {
64
+ return `[Output cleared to keep the context within its limit. It is in the full transcript${dir ? ` at ${dir}` : ""}: search there for this call.]`
65
+ }
66
+
67
+ export function clearedInput(kept: string, cleared: number): string {
68
+ return `${kept}… [${cleared} chars cleared; in the transcript]`
69
+ }
70
+
71
+ export function compactionNotice({ omitted, kept, guide }: NoticeCounts): string {
4
72
  return [
5
73
  "<context-compacted>",
6
74
  "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
75
+ omitted > 0
76
+ ? `${omitted} of the oldest turns didn't fit and were removed entirely. The ${plural(kept, "turn")} after them are shown as the user's message and your final reply.`
77
+ : kept > 0
10
78
  ? "Every earlier turn is shown below as the user's message and your final reply."
11
79
  : "",
12
80
  "The turn that was in progress when the context filled up follows in full. Continue from it.",
13
81
  "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
82
  "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 ?? "",
83
+ guide ?? "",
16
84
  "</context-compacted>",
17
- ].filter(Boolean).join("\n\n")
85
+ ]
86
+ .filter(Boolean)
87
+ .join("\n\n")
18
88
  }
19
89
 
20
- export function truncationNotice(args: NoticeCounts) {
90
+ export function truncationNotice({ omitted, kept, guide }: NoticeCounts): string {
21
91
  return [
22
92
  "<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.`,
93
+ `${omitted} earlier turn${omitted === 1 ? " was" : "s were"} dropped to fit the context window. What follows is the most recent ${plural(kept, "turn")}, unchanged.`,
24
94
  "Instructions and preferences the user gave in earlier turns still apply.",
25
- args.guide ?? "",
95
+ guide ?? "",
26
96
  "</context-truncated>",
27
- ].filter(Boolean).join("\n\n")
97
+ ]
98
+ .filter(Boolean)
99
+ .join("\n\n")
28
100
  }
29
101
 
30
- export function trimInstructions(guide: string): string {
102
+ export function trimInstructions(guide: string | null): string {
31
103
  return [
32
104
  "# Context between turns",
33
105
  "",
@@ -39,15 +111,19 @@ export function trimInstructions(guide: string): string {
39
111
  "- Decisions and their reasons, including approaches you tried and ruled out.",
40
112
  "- Where things stand: what is done, what is not, what you verified and how, and what is waiting on the user.",
41
113
  "",
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.",
114
+ '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
115
  "",
44
116
  "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.",
117
+ ...(guide
118
+ ? [
119
+ "",
120
+ "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.",
121
+ "",
122
+ guide,
123
+ "",
124
+ "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.",
125
+ ]
126
+ : []),
51
127
  ].join("\n")
52
128
  }
53
129
 
@@ -61,6 +137,49 @@ export function searchOnlyInstructions(guide: string): string {
61
137
  ].join("\n")
62
138
  }
63
139
 
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."
140
+ export function searchGuide(ref: TranscriptRef): string {
141
+ if (ref.mode === "jsonl") {
142
+ return [
143
+ `The full transcript is Claude Code's session file: ${ref.jsonl}`,
144
+ "It is JSONL, one entry per line. Your tool calls are `tool_use` blocks in `assistant` entries; their output is in the `tool_result` blocks of the `user` entries that follow. It is large: search it narrowly and never read it whole. For example:",
145
+ ` grep -n 'distinctive text' ${ref.jsonl} | cut -c1-400`,
146
+ ` jq -r 'select(.type=="assistant") | .message.content[]? | select(.type=="tool_use") | "\\(.name) \\(.input | tostring | .[0:200])"' ${ref.jsonl}`,
147
+ ].join("\n")
148
+ }
149
+ const layout =
150
+ ref.mode === "xml"
151
+ ? [
152
+ `The full transcript is a folder with one XML file per turn: ${ref.dir}`,
153
+ "- `index.md`: one line per turn, what the user asked and how the turn ended. Skim it to find the turn you need.",
154
+ "- `turn-0001.xml`, …: one turn each, `<turn n>` with `<user>` (the message), `<said>` (what you wrote while working), `<tool n name>` holding `<call>`, `<input>` and `<output>`, and `<final_reply>`.",
155
+ "- `turn-0001-subagent-1.xml`, …: the whole run of a subagent launched in that turn, same layout. The turn itself only has the subagent's report; what the subagent saw and ran is here.",
156
+ ]
157
+ : [
158
+ `The full transcript is a folder of markdown files: ${ref.dir}`,
159
+ "- `index.md`: one line per turn, what the user asked and how the turn ended. Skim it to find the turn you need.",
160
+ "- `turn-0001.md`, …: one turn each. `## User` (the message), `### [tool 3/12] Bash: …` for every tool call with `[output of tool 3]` below it, and `## Final reply`.",
161
+ "- `turn-0001-subagent-1.md`, …: the whole run of a subagent launched in that turn, same layout. The turn itself only has the subagent's report; what the subagent saw and ran is here.",
162
+ ]
163
+ const habits =
164
+ "Search for more than one phrasing (the exact term, a synonym, a file or function name, an error string) before you conclude something isn't there. Read the whole turn file around a hit: the answer is often a few calls before or after it."
165
+ if (ref.mode === "qmd") {
166
+ return [
167
+ ...layout,
168
+ "It is indexed with qmd. Run qmd from inside that folder:",
169
+ ` cd ${ref.dir} && qmd query "a natural-language question" # semantic + keyword, best when you don't know the exact words (5–20s)`,
170
+ ` cd ${ref.dir} && qmd search "exact words" # keyword only, instant`,
171
+ ` then Read ${ref.dir}/turn-NNNN.md (or qmd get turn-NNNN.md)`,
172
+ "If qmd isn't ready yet (the index builds in the background), grep the folder instead.",
173
+ habits,
174
+ ].join("\n")
175
+ }
176
+ const ext = ref.mode === "xml" ? "xml" : "md"
177
+ return [
178
+ ...layout,
179
+ "To find something:",
180
+ ` grep -ril 'distinctive words' ${ref.dir} # which turns mention it`,
181
+ ` grep -n -i 'distinctive words' ${ref.dir}/*.${ext} | cut -c1-300`,
182
+ ` then Read ${ref.dir}/turn-NNNN.${ext}`,
183
+ habits,
184
+ ].join("\n")
185
+ }
@@ -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
+ }
@@ -1,21 +1,29 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
2
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"
3
+ import type { Body } from "../context/content"
6
4
 
7
- const billingLine = (system: Body["system"]) => {
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 {
8
18
  const first = Array.isArray(system) ? String(system[0]?.text ?? "") : String(system ?? "")
9
19
  return first.match(BILLING_LINE)?.[0] ?? null
10
20
  }
11
21
 
12
22
  function withBilling(system: Body["system"], line: string | null): Body["system"] {
13
23
  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)
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]
19
27
  }
20
28
 
21
29
  export function applyCanonical(body: Body, file: string): CanonicalResult {