@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.
- package/LICENSE +21 -0
- package/README.md +406 -2
- package/package.json +42 -4
- package/src/cli/args.ts +17 -0
- package/src/cli/claude-settings.ts +42 -0
- package/src/cli/commands/index.ts +23 -0
- package/src/cli/commands/mode.ts +66 -0
- package/src/cli/commands/serve.ts +20 -0
- package/src/cli/commands/settings.ts +100 -0
- package/src/cli/commands/start.ts +64 -0
- package/src/cli/commands/status.ts +82 -0
- package/src/cli/commands/stop.ts +38 -0
- package/src/cli/commands/uninstall.ts +27 -0
- package/src/cli/constants.ts +52 -0
- package/src/cli/help.ts +42 -0
- package/src/cli/inspect.ts +41 -0
- package/src/cli/launchd.ts +39 -0
- package/src/cli/logo/index.ts +89 -0
- package/src/cli/logo/pixels.ts +34 -0
- package/src/cli/main.ts +23 -0
- package/src/cli/prompt.ts +91 -0
- package/src/cli/service.ts +33 -0
- package/src/cli/sessions.ts +25 -0
- package/src/cli/stats.ts +73 -0
- package/src/cli/types.ts +14 -0
- package/src/cli/ui.ts +106 -0
- package/src/config/constants.ts +39 -0
- package/src/config/index.ts +98 -0
- package/src/config/types.ts +50 -0
- package/src/context/constants.ts +42 -0
- package/src/context/content.ts +46 -0
- package/src/context/evidence.ts +92 -0
- package/src/context/prompts.ts +66 -0
- package/src/context/recall.ts +155 -0
- package/src/context/rerank.ts +44 -0
- package/src/context/strategies.ts +89 -0
- package/src/context/turns.ts +87 -0
- package/src/context/types.ts +66 -0
- package/src/proxy/canonical.ts +32 -0
- package/src/proxy/compaction.ts +110 -0
- package/src/proxy/constants.ts +29 -0
- package/src/proxy/estimate.ts +26 -0
- package/src/proxy/fingerprint.ts +15 -0
- package/src/proxy/log.ts +9 -0
- package/src/proxy/rewrite.ts +146 -0
- package/src/proxy/server.ts +138 -0
- package/src/proxy/sessions.ts +46 -0
- package/src/proxy/state.ts +27 -0
- package/src/proxy/types.ts +82 -0
- package/src/proxy/usage.ts +44 -0
- package/src/proxy/window.ts +50 -0
- package/src/transcript/constants.ts +15 -0
- package/src/transcript/format.ts +68 -0
- package/src/transcript/locate.ts +28 -0
- package/src/transcript/messages.ts +29 -0
- package/src/transcript/parse.ts +93 -0
- package/src/transcript/render.ts +36 -0
- package/src/transcript/search.ts +80 -0
- package/src/transcript/types.ts +43 -0
- 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
|
+
}
|