@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,25 +0,0 @@
1
- import { SESSION_ID_PATTERN } from "../config/constants"
2
- import { seenSessions } from "../proxy/sessions"
3
- import { flagValues } from "./args"
4
- import { SHORT_ID_LENGTH } from "./constants"
5
- import { fail } from "./ui"
6
-
7
- export const shortId = (sessionId: string) => sessionId.slice(0, SHORT_ID_LENGTH)
8
-
9
- function resolve(input: string): string {
10
- if (!SESSION_ID_PATTERN.test(input)) fail(`Not a session id: ${input}`)
11
- const seen = seenSessions().map(([id]) => id)
12
- if (seen.includes(input)) return input
13
- const matches = seen.filter((id) => id.startsWith(input))
14
- if (matches.length === 1) return matches[0]
15
- if (matches.length > 1) fail(`${input} matches ${matches.length} sessions`, "Use more of the id.")
16
- return input
17
- }
18
-
19
- export function sessionArgs(args: string[]): string[] {
20
- return [...new Set(flagValues(args, "--session").map(resolve))]
21
- }
22
-
23
- export function isKnown(sessionId: string) {
24
- return seenSessions().some(([id]) => id === sessionId)
25
- }
@@ -1,39 +0,0 @@
1
- import { homedir } from "node:os"
2
- import path from "node:path"
3
- import type { Config, Mode, Scope, SearchMode, Tunable } from "./types"
4
-
5
- export const EUNOE_DIR = process.env.EUNOE_DIR ?? path.join(homedir(), ".eunoe")
6
-
7
- export const paths = {
8
- config: path.join(EUNOE_DIR, "config.json"),
9
- requests: path.join(EUNOE_DIR, "requests.jsonl"),
10
- threads: path.join(EUNOE_DIR, "threads"),
11
- transcripts: path.join(EUNOE_DIR, "transcripts"),
12
- models: path.join(EUNOE_DIR, "models.json"),
13
- sessions: path.join(EUNOE_DIR, "sessions.json"),
14
- proxyLog: path.join(EUNOE_DIR, "proxy.log"),
15
- typesafeKey: path.join(EUNOE_DIR, "typesafe-key"),
16
- }
17
-
18
- export const RERANK_OFF = "off"
19
-
20
- export const DEFAULTS: Config = {
21
- mode: "default",
22
- scope: "all",
23
- sessions: {},
24
- port: 8788,
25
- compactAt: 0.9,
26
- keepTurns: "all",
27
- keep: 3,
28
- budget: 100_000,
29
- search: "markdown",
30
- rerank: "jev-preview",
31
- upstream: "https://api.anthropic.com",
32
- claudeConfigDirs: [],
33
- }
34
-
35
- export const MODES: Mode[] = ["default", "compact", "off"]
36
- export const SEARCH_MODES: SearchMode[] = ["jsonl", "markdown", "xml", "qmd"]
37
- export const TUNABLES: Tunable[] = ["keep", "budget", "compactAt", "keepTurns", "search", "rerank"]
38
- export const SCOPES: Scope[] = ["all", "sessions"]
39
- export const SESSION_ID_PATTERN = /^[a-zA-Z0-9-]+$/
@@ -1,50 +0,0 @@
1
- export type Mode = "off" | "compact" | "default"
2
- export type SearchMode = "jsonl" | "markdown" | "xml" | "qmd"
3
- export type Scope = "all" | "sessions"
4
-
5
- export interface RollingConfig {
6
- keep: number
7
- budget: number
8
- }
9
-
10
- export interface RerankConfig {
11
- model: string
12
- candidates?: number
13
- }
14
-
15
- export interface EvalConfig {
16
- strategy?: "tail" | "guide" | "trim"
17
- rolling?: RollingConfig
18
- tailTurns?: number
19
- forceCut?: boolean
20
- trustHeaders?: boolean
21
- evidence?: boolean
22
- recall?: boolean
23
- rerank?: RerankConfig
24
- }
25
-
26
- export interface Tunables {
27
- compactAt: number
28
- keepTurns: number | "all"
29
- keep: number
30
- budget: number
31
- search: SearchMode
32
- rerank: string
33
- }
34
-
35
- export type Tunable = keyof Tunables
36
-
37
- export interface SessionSettings extends Partial<Tunables> {
38
- mode?: Mode
39
- }
40
-
41
- export interface Config extends Tunables {
42
- mode: Mode
43
- scope: Scope
44
- sessions: Record<string, SessionSettings>
45
- port: number
46
- upstream: string
47
- claudeConfigDirs: string[]
48
- window?: number
49
- eval?: EvalConfig
50
- }
@@ -1,42 +0,0 @@
1
- export const SUMMARY_COMMAND_CHARS = 100
2
- export const SUMMARY_ARGUMENT_CHARS = 60
3
- export const SUMMARY_LAST_WORDS_CHARS = 600
4
- export const SUMMARY_CALLS_SHOWN = 20
5
-
6
- export const CLEARED_INPUT_THRESHOLD = 2_000
7
- export const CLEARED_INPUT_KEPT = 500
8
-
9
- export const REMINDER_ONLY = /^\s*(?:<system-reminder>[\s\S]*?<\/system-reminder>\s*)+$/
10
-
11
- export const REREADABLE_TOOLS = new Set(["Read", "Grep", "Glob", "LS", "NotebookRead", "WebFetch", "WebSearch", "TodoWrite", "Edit", "Write", "MultiEdit", "NotebookEdit"])
12
- export const EVIDENCE_LINES_PER_CALL = 12
13
- export const EVIDENCE_LINE_CHARS = 220
14
- export const EVIDENCE_TURN_CHARS = 4_000
15
- export const EVIDENCE_SCAN_LINES = 1_000_000
16
- export const EVIDENCE_MIN_SCORE = 2
17
- export const EVIDENCE_RANKED = true
18
- export const EVIDENCE_LABELS = true
19
- export const EVIDENCE_CALL_FLOOR = 2
20
- export const ERROR_LINE = /\b(?:error|errors|fail|fails|failed|failure|fatal|exception|panic|denied|refused|timed? ?out|traceback|cannot|unable to)\b/i
21
- export const ERROR_CODE = /\b[A-Z][A-Z0-9]*_[A-Z0-9_]{2,}\b|\bSIG[A-Z]{2,}\d*\b|\b[A-Z]{2,}\d{2,}\b|\bE(?:NOENT|ACCES|PERM|EXIST|CONN(?:REFUSED|RESET|ABORTED)|PIPE|ADDRINUSE|TIMEDOUT|NOTFOUND|INVAL|BUSY|MFILE|AGAIN|HOSTUNREACH|NOTDIR|ISDIR|BADF|NOSPC|AI_[A-Z]+)\b/
22
- export const VALUE_TOKEN = /\b[a-z]{2,}[_-](?=[a-z0-9]*\d)[a-z0-9]{6,}\b|\b(?=[0-9a-f]*\d)(?=[0-9a-f]*[a-f])[0-9a-f]{7,}\b|https?:\/\/[^\s)"']+|\b\d{4,}\b|\b\d{6,}_[a-z0-9_]{3,}\b|\bv?\d+\.\d+\.\d+\b|\b\d+(?:\.\d+)?\s?(?:ms|s|[KMGT]i?B|kB|%)(?![a-z])|\bsha\d*:[0-9a-f]+|@[a-z][\w-]+|--[a-z][\w-]*=\S+|\b\d+ (?:pass(?:ed|ing)?|fail(?:ed|ing)?|tests?|files? changed|insertions?|deletions?|rows?|errors?|warnings?)\b|[$€£]\d+(?:[.,]\d+)?|\b(?:status|HTTP\/\d(?:\.\d)?)\s*[:=]?\s*[45]\d{2}\b|\bexit(?: code| status)?\s*[:=]?\s*(?!0\b)\d{1,3}\b/i
23
- export const ID_TOKEN = /\b[a-z]{2,}[_-](?=[a-z0-9]*\d)[a-z0-9]{6,}\b|\b(?=[0-9a-f]*\d)(?=[0-9a-f]*[a-f])[0-9a-f]{7,}\b|https?:\/\/[^\s)"']+|\b\d{6,}_[a-z0-9_]{3,}\b|\bv?\d+\.\d+\.\d+\b|\bsha\d*:[0-9a-f]+|@[a-z][\w-]+|--[a-z][\w-]*=\S+/i
24
- export const KEY_VALUE =/^[\w .\-/()]{2,48}[:=]\s*\S/
25
- export const VALUE_LABEL = /\b(?:ids?|hash|sha(?:256)?|checksum|trace|deploy(?:ment)?|preview|url|version|rows|p9[59]|exit(?: code)?|signal|request[_-]?id|job|migration|applied|bundle|build|status|took|updated|created)\b/i
26
-
27
- export const RECALL_WINDOW_LINES = 6
28
- export const RECALL_SHOWN_LINES = 8
29
- export const RECALL_PER_QUERY = 12
30
- export const RECALL_MAX_CHARS = 18_000
31
- export const RECALL_MAX_CHARS_MULTI = 30_000
32
- export const RECALL_MIN_TERMS = 2
33
- export const RECALL_SALIENT_EXCERPT = true
34
- export const RECALL_NARRATIVE = true
35
- export const RECALL_LINE_CHARS = 240
36
- export const RECALL_RERANK_CANDIDATES = 30
37
- export const RERANK_ENDPOINT = "https://api.typesafe.ai/v1/systemone"
38
- export const RERANK_TIMEOUT_MS = 4_000
39
- export const RERANK_LINE_CHARS = 240
40
- export const BM25_K1 = 1.2
41
- export const BM25_B = 0.75
42
- export const STOPWORDS = new Set("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(" "))
@@ -1,92 +0,0 @@
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
- }
@@ -1,155 +0,0 @@
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
- }
@@ -1,44 +0,0 @@
1
- import { existsSync, readFileSync } from "node:fs"
2
- import { paths } from "../config/constants"
3
- import { RERANK_ENDPOINT, RERANK_LINE_CHARS, RERANK_TIMEOUT_MS } from "./constants"
4
- import type { RecallChunk, Reranker } from "./types"
5
-
6
- export function rerankKey(): string | null {
7
- const env = process.env.TYPESAFE_API_KEY?.trim()
8
- if (env) return env
9
- return existsSync(paths.typesafeKey) ? readFileSync(paths.typesafeKey, "utf8").trim() : null
10
- }
11
-
12
- export function rerankState(chunks: RecallChunk[], candidates: number[]): string {
13
- return candidates.map((index, n) => `### ${n}\n[turn ${chunks[index].turn}] ${chunks[index].call}\n${chunks[index].lines.map((l) => l.slice(0, RERANK_LINE_CHARS)).join("\n")}`).join("\n\n")
14
- }
15
-
16
- export function orderByProbability(candidates: number[], probabilities: Record<string, number>): number[] {
17
- return candidates.map((_, n) => n).sort((a, b) => (probabilities[String(b)] ?? 0) - (probabilities[String(a)] ?? 0) || a - b).map((n) => candidates[n])
18
- }
19
-
20
- export function jevReranker(model: string, key: string, endpoint = RERANK_ENDPOINT): Reranker {
21
- return async (query, chunks, candidates) => {
22
- if (candidates.length < 2) return candidates
23
- try {
24
- const response = await fetch(endpoint, {
25
- method: "POST",
26
- headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
27
- signal: AbortSignal.timeout(RERANK_TIMEOUT_MS),
28
- body: JSON.stringify({
29
- model,
30
- state: rerankState(chunks, candidates),
31
- questions: {
32
- where: { type: "choice", instructions: `Which excerpt contains the exact answer to the developer's question about this coding session: "${query}"?`, criteria: Object.fromEntries(candidates.map((_, n) => [String(n), null])) },
33
- },
34
- }),
35
- })
36
- if (!response.ok) return candidates
37
- const json: any = await response.json()
38
- const probabilities = json?.answers?.where?.probabilities
39
- return probabilities && typeof probabilities === "object" ? orderByProbability(candidates, probabilities) : candidates
40
- } catch {
41
- return candidates
42
- }
43
- }
44
- }
@@ -1,89 +0,0 @@
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
- }
@@ -1,87 +0,0 @@
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
- }