@chroxify/eunoe 0.1.0 → 1.1.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 (80) hide show
  1. package/README.md +364 -239
  2. package/package.json +20 -3
  3. package/src/cli/{types.ts → command.ts} +1 -4
  4. package/src/cli/commands/index.ts +27 -3
  5. package/src/cli/commands/load.ts +44 -0
  6. package/src/cli/commands/mode.ts +68 -24
  7. package/src/cli/commands/peek.ts +22 -0
  8. package/src/cli/commands/replay.ts +194 -0
  9. package/src/cli/commands/serve.ts +24 -8
  10. package/src/cli/commands/sessions.ts +70 -0
  11. package/src/cli/commands/settings.ts +119 -0
  12. package/src/cli/commands/start.ts +73 -0
  13. package/src/cli/commands/status.ts +107 -30
  14. package/src/cli/commands/stop.ts +36 -12
  15. package/src/cli/commands/uninstall.ts +14 -14
  16. package/src/cli/commands/watch.ts +16 -0
  17. package/src/cli/logo/art.ts +99 -0
  18. package/src/cli/logo/index.ts +12 -18
  19. package/src/cli/main.ts +3 -3
  20. package/src/cli/proxy/claude-settings.ts +59 -0
  21. package/src/cli/proxy/inspect.ts +67 -0
  22. package/src/cli/proxy/launchd.ts +53 -0
  23. package/src/cli/proxy/service.ts +53 -0
  24. package/src/cli/proxy/stats.ts +81 -0
  25. package/src/cli/proxy/watch.ts +60 -0
  26. package/src/cli/replay/index.ts +141 -0
  27. package/src/cli/replay/worker.ts +15 -0
  28. package/src/cli/sessions/browse.ts +88 -0
  29. package/src/cli/sessions/index.ts +76 -0
  30. package/src/cli/sessions/source.ts +74 -0
  31. package/src/cli/{args.ts → terminal/args.ts} +1 -1
  32. package/src/cli/{help.ts → terminal/help.ts} +25 -9
  33. package/src/cli/terminal/prompt.ts +114 -0
  34. package/src/cli/terminal/ui.ts +137 -0
  35. package/src/config/index.ts +128 -14
  36. package/src/config/{constants.ts → paths.ts} +4 -14
  37. package/src/context/content.ts +51 -3
  38. package/src/context/fold/evidence.ts +145 -0
  39. package/src/context/fold/index.ts +65 -0
  40. package/src/context/fold/strategies.ts +94 -0
  41. package/src/context/fold/turns.ts +76 -0
  42. package/src/context/prompts.ts +141 -22
  43. package/src/context/recall/index.ts +214 -0
  44. package/src/context/recall/rerank.ts +71 -0
  45. package/src/proxy/canonical.ts +40 -0
  46. package/src/proxy/compaction.ts +122 -52
  47. package/src/proxy/estimate.ts +14 -9
  48. package/src/proxy/fingerprint.ts +29 -0
  49. package/src/proxy/handoff.ts +71 -0
  50. package/src/proxy/log.ts +2 -2
  51. package/src/proxy/recall.ts +58 -0
  52. package/src/proxy/rewrite.ts +152 -56
  53. package/src/proxy/server.ts +122 -47
  54. package/src/proxy/sessions.ts +64 -0
  55. package/src/proxy/state.ts +17 -3
  56. package/src/proxy/usage.ts +50 -34
  57. package/src/proxy/window.ts +32 -34
  58. package/src/transcript/format.ts +88 -25
  59. package/src/transcript/locate.ts +38 -17
  60. package/src/transcript/messages.ts +18 -0
  61. package/src/transcript/parse.ts +137 -56
  62. package/src/transcript/render.ts +44 -17
  63. package/src/transcript/search.ts +20 -56
  64. package/src/transcript/subagents.ts +150 -0
  65. package/src/cli/claude-settings.ts +0 -42
  66. package/src/cli/commands/install.ts +0 -44
  67. package/src/cli/constants.ts +0 -29
  68. package/src/cli/inspect.ts +0 -41
  69. package/src/cli/launchd.ts +0 -34
  70. package/src/cli/logo/pixels.ts +0 -34
  71. package/src/cli/ui.ts +0 -91
  72. package/src/config/types.ts +0 -21
  73. package/src/context/constants.ts +0 -7
  74. package/src/context/strategies.ts +0 -89
  75. package/src/context/turns.ts +0 -82
  76. package/src/context/types.ts +0 -24
  77. package/src/proxy/constants.ts +0 -19
  78. package/src/proxy/types.ts +0 -58
  79. package/src/transcript/constants.ts +0 -16
  80. package/src/transcript/types.ts +0 -43
@@ -1,13 +1,40 @@
1
- import type { Block, Message } from "./types"
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
+ }
2
13
 
3
14
  export function contentBlocks(content: unknown): Block[] {
4
- return typeof content === "string" ? [{ type: "text", text: content }] : Array.isArray(content) ? content : []
15
+ if (typeof content === "string") return [{ type: "text", text: content }]
16
+ return Array.isArray(content) ? content : []
5
17
  }
6
18
 
7
19
  export function blocks(message: Message): Block[] {
8
20
  return contentBlocks(message.content)
9
21
  }
10
22
 
23
+ function joinedText(list: Block[]): string {
24
+ return list
25
+ .filter((b) => b.type === "text")
26
+ .map((b) => String(b.text ?? ""))
27
+ .join("\n")
28
+ }
29
+
30
+ export function textOf(message: Message): string {
31
+ return joinedText(blocks(message))
32
+ }
33
+
34
+ export function resultText(block: Block): string {
35
+ return typeof block.content === "string" ? block.content : joinedText(contentBlocks(block.content))
36
+ }
37
+
11
38
  export function withoutCacheControl(block: Block): Block {
12
39
  if (!("cache_control" in block)) return block
13
40
  const { cache_control: _, ...rest } = block
@@ -18,7 +45,28 @@ export function clean(message: Message): Message {
18
45
  return { role: message.role, content: blocks(message).map(withoutCacheControl) }
19
46
  }
20
47
 
48
+ export function shortHash(json: string, chars: number): string {
49
+ return new Bun.CryptoHasher("sha256").update(json).digest("hex").slice(0, chars)
50
+ }
51
+
52
+ export function truncate(text: string, max: number): string {
53
+ const cut = text.slice(0, max)
54
+ return /[\uD800-\uDBFF]$/.test(cut) ? cut.slice(0, -1) : cut
55
+ }
56
+
21
57
  export function clip(value: unknown, max: number): string {
22
58
  const text = String(value).replace(/\s+/g, " ").trim()
23
- return text.length > max ? `${text.slice(0, max)}…` : text
59
+ return text.length > max ? `${truncate(text, max)}…` : text
60
+ }
61
+
62
+ export function wellFormed(_key: string, value: unknown): unknown {
63
+ return typeof value === "string" ? value.toWellFormed() : value
64
+ }
65
+
66
+ export function describeToolCall(block: Block): string {
67
+ const input = block.input ?? {}
68
+ if (typeof input.command === "string") return `${block.name} \`${clip(input.command, 100)}\``
69
+ if (typeof input.file_path === "string") return `${block.name} ${input.file_path}`
70
+ if (typeof input.pattern === "string") return `${block.name} "${clip(input.pattern, 60)}"`
71
+ return `${block.name} ${clip(JSON.stringify(input), 60)}`
24
72
  }
@@ -0,0 +1,145 @@
1
+ import { type Block, blocks, clip, describeToolCall, type Message, resultText } from "../content"
2
+ import { toolEvidence } from "../prompts"
3
+
4
+ export interface EvidenceOptions {
5
+ linesPerCall?: number
6
+ lineChars?: number
7
+ turnChars?: number
8
+ scanLines?: number
9
+ minScore?: number
10
+ allTools?: boolean
11
+ ranked?: boolean
12
+ labels?: boolean
13
+ callFloor?: number
14
+ }
15
+
16
+ interface Scored {
17
+ index: number
18
+ line: string
19
+ score: number
20
+ }
21
+
22
+ interface Entry {
23
+ head: string
24
+ lines: string[]
25
+ }
26
+
27
+ export const REREADABLE_TOOLS = new Set(["Read", "Grep", "Glob", "LS", "NotebookRead", "WebFetch", "WebSearch", "TodoWrite", "Edit", "Write", "MultiEdit", "NotebookEdit"])
28
+
29
+ const ERROR_LINE = /\b(?:error|errors|fail|fails|failed|failure|fatal|exception|panic|denied|refused|timed? ?out|traceback|cannot|unable to)\b/i
30
+
31
+ const ERROR_CODE =
32
+ /\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/
33
+
34
+ const VALUE_TOKEN =
35
+ /\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
36
+
37
+ 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
38
+
39
+ const KEY_VALUE = /^[\w .\-/()]{2,48}[:=]\s*\S/
40
+
41
+ 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
42
+
43
+ function resolved(options: EvidenceOptions) {
44
+ return {
45
+ linesPerCall: options.linesPerCall ?? 12,
46
+ lineChars: options.lineChars ?? 220,
47
+ turnChars: options.turnChars ?? 4_000,
48
+ scanLines: options.scanLines ?? 1_000_000,
49
+ minScore: options.minScore ?? 2,
50
+ ranked: options.ranked ?? true,
51
+ labels: options.labels ?? true,
52
+ callFloor: options.callFloor ?? 2,
53
+ }
54
+ }
55
+
56
+ const isError = (line: string) => ERROR_LINE.test(line) || ERROR_CODE.test(line)
57
+
58
+ export function isSalient(line: string): boolean {
59
+ return isError(line) || VALUE_TOKEN.test(line)
60
+ }
61
+
62
+ function scoreLines(text: string, options: EvidenceOptions): Scored[] {
63
+ const { scanLines, lineChars, minScore, labels } = resolved(options)
64
+ const scored = text
65
+ .split("\n")
66
+ .slice(0, scanLines)
67
+ .flatMap((raw, index) => {
68
+ const line = raw.trim()
69
+ if (line.length < 4) return []
70
+ const error = isError(line)
71
+ const value = VALUE_TOKEN.test(line)
72
+ if (!error && !value) return []
73
+ const score = (error ? 3 : 0) + (value ? (ID_TOKEN.test(line) ? 3 : 2) : 0) + (KEY_VALUE.test(line) ? 1 : 0) + (line.length < 120 ? 1 : 0) + (labels && VALUE_LABEL.test(line) ? 1 : 0)
74
+ return score >= minScore ? [{ index, line: clip(line, lineChars), score }] : []
75
+ })
76
+ const seen = new Set<string>()
77
+ return scored.sort((a, b) => b.score - a.score || a.index - b.index).filter((s) => !seen.has(s.line) && seen.add(s.line))
78
+ }
79
+
80
+ export function salientLines(text: string, options: EvidenceOptions = {}): string[] {
81
+ return scoreLines(text, options)
82
+ .slice(0, resolved(options).linesPerCall)
83
+ .sort((a, b) => a.index - b.index)
84
+ .map((s) => s.line)
85
+ }
86
+
87
+ export function toolPairs(turn: Message[]): Array<{ call: Block; result: Block }> {
88
+ const results = new Map<string, Block>()
89
+ for (const message of turn) {
90
+ if (message.role !== "user") continue
91
+ for (const block of blocks(message)) if (block.type === "tool_result") results.set(block.tool_use_id, block)
92
+ }
93
+ return turn
94
+ .filter((m) => m.role === "assistant")
95
+ .flatMap((m) => blocks(m).filter((b) => b.type === "tool_use"))
96
+ .flatMap((call) => {
97
+ const result = results.get(call.id)
98
+ return result ? [{ call, result }] : []
99
+ })
100
+ }
101
+
102
+ function render(entries: Entry[]): string | null {
103
+ const shown = entries.filter((e) => e.lines.length > 0).map((e) => `- ${e.head}:\n${e.lines.map((l) => ` ${l}`).join("\n")}`)
104
+ return shown.length > 0 ? toolEvidence(shown) : null
105
+ }
106
+
107
+ function inCallOrder(heads: string[], pairs: Array<{ result: Block }>, options: EvidenceOptions): Entry[] {
108
+ const { turnChars } = resolved(options)
109
+ const entries: Entry[] = []
110
+ let used = 0
111
+ pairs.forEach(({ result }, i) => {
112
+ const lines = salientLines(resultText(result), options)
113
+ if (lines.length === 0) return
114
+ const size = heads[i].length + 4 + lines.reduce((s, l) => s + l.length + 5, 0)
115
+ if (used + size > turnChars) return
116
+ entries.push({ head: heads[i], lines })
117
+ used += size
118
+ })
119
+ return entries
120
+ }
121
+
122
+ function byScore(heads: string[], pairs: Array<{ result: Block }>, options: EvidenceOptions): Entry[] {
123
+ const { turnChars, linesPerCall, callFloor } = resolved(options)
124
+ const perCall = pairs.map(({ result }) => scoreLines(resultText(result), options))
125
+ const chosen = new Map<number, Scored[]>()
126
+ let used = 0
127
+ const take = (call: number, line: Scored) => {
128
+ const bucket = chosen.get(call) ?? []
129
+ const cost = line.line.length + 5 + (bucket.length > 0 ? 0 : heads[call].length + 4)
130
+ if (bucket.length >= linesPerCall || used + cost > turnChars) return
131
+ bucket.push(line)
132
+ chosen.set(call, bucket)
133
+ used += cost
134
+ }
135
+ for (const [call, lines] of perCall.entries()) for (const line of lines.slice(0, callFloor)) take(call, line)
136
+ const rest = perCall.flatMap((lines, call) => lines.slice(callFloor).map((line) => ({ line, call }))).sort((a, b) => b.line.score - a.line.score || a.call - b.call || a.line.index - b.line.index)
137
+ for (const { line, call } of rest) take(call, line)
138
+ return heads.map((head, i) => ({ head, lines: (chosen.get(i) ?? []).sort((a, b) => a.index - b.index).map((s) => s.line) }))
139
+ }
140
+
141
+ export function turnEvidence(turn: Message[], options: EvidenceOptions = {}): string | null {
142
+ const pairs = toolPairs(turn).filter(({ call }) => options.allTools || !REREADABLE_TOOLS.has(call.name))
143
+ const heads = pairs.map(({ call, result }) => `${describeToolCall(call)}${result.is_error ? " (failed)" : ""}`)
144
+ return render(resolved(options).ranked ? byScore(heads, pairs, options) : inCallOrder(heads, pairs, options))
145
+ }
@@ -0,0 +1,65 @@
1
+ import { type Message, textOf } from "../content"
2
+ import { type RecallChunk, recallChunks, terms } from "../recall"
3
+ import { isPrompt, reduceTurn, splitTurns } from "./turns"
4
+
5
+ export interface FoldedTurn {
6
+ user: string
7
+ evidence: string
8
+ reply: string
9
+ }
10
+
11
+ export const FOLD_CAP_CHARS = 400_000
12
+
13
+ const spoken = (message: Message) =>
14
+ textOf(message)
15
+ .replace(/<(system-reminder|system-message|recalled-context|handoff)(?:\s[^>]*)?>[\s\S]*?<\/\1>/g, "")
16
+ .trim()
17
+
18
+ const isEvidence = (text: string) => text.startsWith("<tool-evidence>")
19
+
20
+ export function foldedTurns(messages: Message[]): FoldedTurn[] {
21
+ return splitTurns(messages)
22
+ .turns.filter((turn) => isPrompt(turn[0]))
23
+ .map((turn) => {
24
+ const reduced = reduceTurn(turn, { evidence: true })
25
+ const users = reduced.filter((m) => m.role === "user").map(spoken)
26
+ return {
27
+ user: users.filter((t) => t && !isEvidence(t)).join("\n\n"),
28
+ evidence: users.filter(isEvidence).join("\n"),
29
+ reply: reduced
30
+ .filter((m) => m.role === "assistant")
31
+ .map(spoken)
32
+ .join("\n"),
33
+ }
34
+ })
35
+ }
36
+
37
+ function renderTurn(turn: FoldedTurn, index: number, full: boolean): string {
38
+ const parts = [`### Turn ${index + 1}`, `**You:** ${turn.user}`]
39
+ if (full && turn.evidence) parts.push(turn.evidence)
40
+ if (full && turn.reply) parts.push(`**Agent:** ${turn.reply}`)
41
+ return parts.join("\n\n")
42
+ }
43
+
44
+ export function foldedView(messages: Message[], cap = Number.POSITIVE_INFINITY): string {
45
+ const turns = foldedTurns(messages)
46
+ const full = new Set<number>()
47
+ let used = 0
48
+ for (let i = turns.length - 1; i >= 0; i -= 1) {
49
+ const size = renderTurn(turns[i], i, true).length
50
+ if (used + size > cap) break
51
+ full.add(i)
52
+ used += size
53
+ }
54
+ return turns.map((turn, i) => renderTurn(turn, i, full.has(i))).join("\n\n")
55
+ }
56
+
57
+ export function sourceChunks(messages: Message[], from: string, users = false): RecallChunk[] {
58
+ const chunks = recallChunks([...messages, { role: "user", content: "" }], new Map()).map((chunk) => ({ ...chunk, from }))
59
+ if (!users) return chunks
60
+ const said = splitTurns(messages)
61
+ .turns.filter((turn) => isPrompt(turn[0]))
62
+ .map((turn, i) => ({ turn: i + 1, text: spoken(turn[0]) }))
63
+ .filter((t) => t.text)
64
+ return [...said.map((t) => ({ turn: t.turn, from, call: "you", lines: t.text.split("\n"), terms: terms(t.text) })), ...chunks]
65
+ }
@@ -0,0 +1,94 @@
1
+ import { type Block, type Body, blocks, type Message, truncate, withoutCacheControl } from "../content"
2
+ import { clearedInput, compactionNotice, truncationNotice } from "../prompts"
3
+ import { hasContent, isHarness, type ReduceOptions, reduceTurn, splitTurns } from "./turns"
4
+
5
+ export interface CompactOptions extends ReduceOptions {
6
+ keepTurns: number
7
+ capChars?: number
8
+ guide: string | null
9
+ omittedBefore?: number
10
+ reduce?: (turn: Message[], options: ReduceOptions) => Message[]
11
+ }
12
+
13
+ export function trimMessages(messages: Message[], options: ReduceOptions = {}): Message[] {
14
+ const { preamble, turns } = splitTurns(messages)
15
+ if (turns.length < 2) return messages
16
+ return [...preamble, ...turns.slice(0, -1).flatMap((turn) => reduceTurn(turn, options)), ...(turns.at(-1) ?? [])]
17
+ }
18
+
19
+ export function compactMessages(messages: Message[], cutIndex: number, options: CompactOptions): Message[] {
20
+ const { preamble, turns } = splitTurns(messages)
21
+ if (cutIndex <= 0 || cutIndex >= turns.length) return messages
22
+ const reduce = options.reduce ?? reduceTurn
23
+ const keptFrom = Math.max(1, cutIndex - options.keepTurns)
24
+ const notice = compactionNotice({ omitted: keptFrom - 1 + (options.omittedBefore ?? 0), kept: cutIndex - keptFrom, guide: options.guide })
25
+ const removed = [...turns[0].slice(1), ...turns.slice(1, keptFrom).flat()]
26
+ const carried = removed
27
+ .filter((m) => isHarness(m) && hasContent(m))
28
+ .flatMap((m) => blocks(m).filter((b) => b.type === "text"))
29
+ .map((b) => ({ type: "text", text: `<system-reminder>\n${b.text}\n</system-reminder>` }))
30
+ return [...preamble, turns[0][0], { role: "user", content: [{ type: "text", text: notice }, ...carried] }, ...turns.slice(keptFrom, cutIndex).flatMap((turn) => reduce(turn, options)), ...turns.slice(cutIndex).flat()]
31
+ }
32
+
33
+ export function capTurns(messages: Message[], options: CompactOptions): Message[] {
34
+ const { capChars } = options
35
+ if (!capChars) return messages
36
+ const { preamble, turns } = splitTurns(messages)
37
+ if (turns.length < 3) return messages
38
+ const live = turns.length - 1
39
+ const over = (turn: Message[], i: number) => i > 0 && i < live && JSON.stringify(turn).length > capChars
40
+ if (!turns.some(over)) return messages
41
+ const reduce = options.reduce ?? reduceTurn
42
+ return [...preamble, ...turns.flatMap((turn, i) => (over(turn, i) ? reduce(turn, options) : turn))]
43
+ }
44
+
45
+ export function tailMessages(messages: Message[], from: number, guide: string | null): Message[] {
46
+ const { preamble, turns } = splitTurns(messages)
47
+ if (turns.length === 0 || from <= 1) return messages
48
+ const notice = truncationNotice({ omitted: from - 1, kept: turns.length - from, guide })
49
+ return [...preamble, turns[0][0], { role: "user", content: [{ type: "text", text: notice }] }, ...turns.slice(from).flat()]
50
+ }
51
+
52
+ export function countToolResults(messages: Message[], from: number): number {
53
+ return messages
54
+ .slice(from)
55
+ .filter((m) => m.role === "user")
56
+ .reduce((total, m) => total + blocks(m).filter((b) => b.type === "tool_result").length, 0)
57
+ }
58
+
59
+ function shortenInput(input: unknown): unknown {
60
+ if (typeof input === "string") return input.length > 2_000 ? clearedInput(truncate(input, 500), input.length - 500) : input
61
+ if (Array.isArray(input)) return input.map(shortenInput)
62
+ if (input && typeof input === "object") return Object.fromEntries(Object.entries(input).map(([k, v]) => [k, shortenInput(v)]))
63
+ return input
64
+ }
65
+
66
+ export function clearToolResults(messages: Message[], from: number, count: number, placeholder: string): Message[] {
67
+ let left = count
68
+ const cleared = new Set<string>()
69
+ const clearResult = (b: Block): Block => {
70
+ if (b.type !== "tool_result" || left <= 0) return b
71
+ left -= 1
72
+ cleared.add(b.tool_use_id)
73
+ return { type: "tool_result", tool_use_id: b.tool_use_id, ...(b.is_error ? { is_error: true } : {}), content: placeholder }
74
+ }
75
+ const withResultsCleared = messages.map((message, index) => {
76
+ if (left <= 0 || index < from || message.role !== "user" || typeof message.content === "string") return message
77
+ if (!message.content.some((b) => b.type === "tool_result")) return message
78
+ return { ...message, content: message.content.map(clearResult) }
79
+ })
80
+ const isClearedCall = (b: Block) => b.type === "tool_use" && cleared.has(b.id)
81
+ return withResultsCleared.map((message) => {
82
+ if (message.role !== "assistant" || typeof message.content === "string" || !message.content.some(isClearedCall)) return message
83
+ return { ...message, content: message.content.map((b) => (isClearedCall(b) ? { ...b, input: shortenInput(b.input) } : b)) }
84
+ })
85
+ }
86
+
87
+ export function appendSystem(system: Body["system"], text: string): Block[] {
88
+ const list: Block[] = typeof system === "string" ? [{ type: "text", text: system }] : [...(system ?? [])]
89
+ const lastCached = list.findLastIndex((b) => b.cache_control)
90
+ const cacheControl = lastCached >= 0 ? list[lastCached].cache_control : undefined
91
+ if (lastCached >= 0) list[lastCached] = withoutCacheControl(list[lastCached])
92
+ list.push({ type: "text", text, ...(cacheControl ? { cache_control: cacheControl } : {}) })
93
+ return list
94
+ }
@@ -0,0 +1,76 @@
1
+ import { type Block, blocks, clean, describeToolCall, type Message, shortHash, textOf, withoutCacheControl } from "../content"
2
+ import { interruptedReply } from "../prompts"
3
+ import { turnEvidence } from "./evidence"
4
+
5
+ export interface ReduceOptions {
6
+ evidence?: boolean
7
+ }
8
+
9
+ export function isPrompt(message: Message) {
10
+ if (message.role !== "user") return false
11
+ const content = blocks(message)
12
+ return !content.some((block) => block.type === "tool_result") && content.some((block) => block.type === "text" || block.type === "image" || block.type === "document")
13
+ }
14
+
15
+ export function splitTurns(messages: Message[]): { preamble: Message[]; turns: Message[][] } {
16
+ const preamble: Message[] = []
17
+ const turns: Message[][] = []
18
+ for (const message of messages) {
19
+ const current = turns.at(-1)
20
+ if (isPrompt(message) && !current?.every((m) => m.role === "user")) turns.push([message])
21
+ else if (current) current.push(message)
22
+ else preamble.push(message)
23
+ }
24
+ return { preamble, turns }
25
+ }
26
+
27
+ export function turnFingerprint(turn: Message[]): string {
28
+ return shortHash(JSON.stringify(blocks(turn[0]).map(withoutCacheControl)), 24)
29
+ }
30
+
31
+ export function isHarness(message: Message) {
32
+ return message.role === "system"
33
+ }
34
+
35
+ export function hasContent(message: Message) {
36
+ return blocks(message).some((block) => block.type !== "text" || String(block.text ?? "").trim())
37
+ }
38
+
39
+ const hasText = (block: Block) => block.type === "text" && String(block.text ?? "").trim() !== ""
40
+
41
+ function mergedHarness(turn: Message[]): Message[] {
42
+ const content = turn.filter((m) => isHarness(m) && hasContent(m)).flatMap((m) => blocks(m).map(withoutCacheControl))
43
+ return content.length > 0 ? [{ role: "system", content }] : []
44
+ }
45
+
46
+ function interrupted(turn: Message[]): string {
47
+ const assistant = turn.filter((m) => m.role === "assistant")
48
+ const lastWords = assistant
49
+ .map((m) => textOf(m).trim())
50
+ .filter(Boolean)
51
+ .at(-1)
52
+ const calls = assistant.flatMap((m) => blocks(m).filter((b) => b.type === "tool_use")).map(describeToolCall)
53
+ return interruptedReply(lastWords, calls)
54
+ }
55
+
56
+ function carriedText(messages: Message[]): Block[] {
57
+ return messages
58
+ .filter((m) => m.role === "user")
59
+ .flatMap((m) => blocks(m).filter((b) => hasText(b) && !/^\s*(?:<system-reminder>[\s\S]*?<\/system-reminder>\s*)+$/.test(String(b.text))))
60
+ .map((b) => ({ type: "text", text: b.text }))
61
+ }
62
+
63
+ export function reduceTurn(turn: Message[], options: ReduceOptions = {}): Message[] {
64
+ const firstReply = turn.findIndex((m) => m.role === "assistant")
65
+ const opening = (firstReply === -1 ? turn : turn.slice(0, firstReply)).filter((m) => m.role === "user").map(clean)
66
+ const carried = carriedText(firstReply === -1 ? [] : turn.slice(firstReply))
67
+ if (carried.length > 0) opening.push({ role: "user", content: carried })
68
+ const evidence = options.evidence ? turnEvidence(turn) : null
69
+ if (evidence) opening.push({ role: "user", content: [{ type: "text", text: evidence }] })
70
+ opening.push(...mergedHarness(turn))
71
+ const last = turn.findLast((m) => m.role !== "system")
72
+ const content = last?.role === "assistant" ? blocks(last) : []
73
+ const text = content.filter(hasText)
74
+ const final = text.length > 0 && !content.some((b) => b.type === "tool_use") ? text.map((b) => ({ type: "text", text: b.text })) : [{ type: "text", text: interrupted(turn) }]
75
+ return [...opening, { role: "assistant", content: final }]
76
+ }
@@ -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
+ }