@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,14 +1,40 @@
1
- import { SUMMARY_ARGUMENT_CHARS, SUMMARY_COMMAND_CHARS } from "./constants"
2
- 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
+ }
3
13
 
4
14
  export function contentBlocks(content: unknown): Block[] {
5
- 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 : []
6
17
  }
7
18
 
8
19
  export function blocks(message: Message): Block[] {
9
20
  return contentBlocks(message.content)
10
21
  }
11
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
+
12
38
  export function withoutCacheControl(block: Block): Block {
13
39
  if (!("cache_control" in block)) return block
14
40
  const { cache_control: _, ...rest } = block
@@ -19,28 +45,28 @@ export function clean(message: Message): Message {
19
45
  return { role: message.role, content: blocks(message).map(withoutCacheControl) }
20
46
  }
21
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
+
22
52
  export function truncate(text: string, max: number): string {
23
53
  const cut = text.slice(0, max)
24
54
  return /[\uD800-\uDBFF]$/.test(cut) ? cut.slice(0, -1) : cut
25
55
  }
26
56
 
27
- export function wellFormed(_key: string, value: unknown): unknown {
28
- return typeof value === "string" ? value.toWellFormed() : value
29
- }
30
-
31
57
  export function clip(value: unknown, max: number): string {
32
58
  const text = String(value).replace(/\s+/g, " ").trim()
33
59
  return text.length > max ? `${truncate(text, max)}…` : text
34
60
  }
35
61
 
62
+ export function wellFormed(_key: string, value: unknown): unknown {
63
+ return typeof value === "string" ? value.toWellFormed() : value
64
+ }
65
+
36
66
  export function describeToolCall(block: Block): string {
37
67
  const input = block.input ?? {}
38
- if (typeof input.command === "string") return `${block.name} \`${clip(input.command, SUMMARY_COMMAND_CHARS)}\``
68
+ if (typeof input.command === "string") return `${block.name} \`${clip(input.command, 100)}\``
39
69
  if (typeof input.file_path === "string") return `${block.name} ${input.file_path}`
40
- if (typeof input.pattern === "string") return `${block.name} "${clip(input.pattern, SUMMARY_ARGUMENT_CHARS)}"`
41
- return `${block.name} ${clip(JSON.stringify(input), SUMMARY_ARGUMENT_CHARS)}`
42
- }
43
-
44
- export function resultText(block: Block): string {
45
- return typeof block.content === "string" ? block.content : contentBlocks(block.content).filter((b) => b.type === "text").map((b) => String(b.text ?? "")).join("\n")
70
+ if (typeof input.pattern === "string") return `${block.name} "${clip(input.pattern, 60)}"`
71
+ return `${block.name} ${clip(JSON.stringify(input), 60)}`
46
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
+ }