@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.
- package/README.md +68 -9
- package/package.json +18 -2
- package/src/cli/{types.ts → command.ts} +0 -4
- package/src/cli/commands/index.ts +13 -1
- package/src/cli/commands/load.ts +44 -0
- package/src/cli/commands/mode.ts +41 -33
- package/src/cli/commands/peek.ts +22 -0
- package/src/cli/commands/replay.ts +245 -0
- package/src/cli/commands/serve.ts +22 -7
- package/src/cli/commands/sessions.ts +70 -0
- package/src/cli/commands/settings.ts +79 -60
- package/src/cli/commands/start.ts +56 -21
- package/src/cli/commands/stats.ts +233 -0
- package/src/cli/commands/status.ts +98 -55
- package/src/cli/commands/stop.ts +8 -6
- package/src/cli/commands/uninstall.ts +10 -12
- package/src/cli/commands/watch.ts +16 -0
- package/src/cli/logo/art.ts +99 -0
- package/src/cli/logo/index.ts +12 -18
- package/src/cli/main.ts +3 -3
- package/src/cli/proxy/claude-settings.ts +69 -0
- package/src/cli/proxy/inspect.ts +67 -0
- package/src/cli/proxy/launchd.ts +53 -0
- package/src/cli/proxy/live.ts +57 -0
- package/src/cli/proxy/service.ts +53 -0
- package/src/cli/{stats.ts → proxy/stats.ts} +29 -16
- package/src/cli/proxy/watch.ts +60 -0
- package/src/cli/replay/index.ts +146 -0
- package/src/cli/replay/worker.ts +20 -0
- package/src/cli/sessions/browse.ts +88 -0
- package/src/cli/sessions/index.ts +76 -0
- package/src/cli/sessions/source.ts +74 -0
- package/src/cli/{help.ts → terminal/help.ts} +24 -8
- package/src/cli/terminal/prompt.ts +114 -0
- package/src/cli/{ui.ts → terminal/ui.ts} +55 -24
- package/src/cli/usage/index.ts +151 -0
- package/src/config/index.ts +99 -34
- package/src/config/paths.ts +17 -0
- package/src/context/content.ts +40 -14
- package/src/context/fold/evidence.ts +145 -0
- package/src/context/fold/index.ts +65 -0
- package/src/context/fold/strategies.ts +94 -0
- package/src/context/fold/turns.ts +76 -0
- package/src/context/prompts.ts +141 -22
- package/src/context/recall/index.ts +214 -0
- package/src/context/recall/rerank.ts +71 -0
- package/src/proxy/canonical.ts +17 -9
- package/src/proxy/compaction.ts +106 -53
- package/src/proxy/estimate.ts +14 -9
- package/src/proxy/fingerprint.ts +23 -9
- package/src/proxy/handoff.ts +71 -0
- package/src/proxy/log.ts +2 -2
- package/src/proxy/recall.ts +58 -0
- package/src/proxy/rewrite.ts +141 -119
- package/src/proxy/server.ts +108 -51
- package/src/proxy/sessions.ts +32 -14
- package/src/proxy/state.ts +17 -3
- package/src/proxy/usage.ts +50 -34
- package/src/proxy/window.ts +32 -34
- package/src/transcript/format.ts +88 -25
- package/src/transcript/locate.ts +38 -17
- package/src/transcript/messages.ts +3 -14
- package/src/transcript/parse.ts +137 -56
- package/src/transcript/render.ts +44 -17
- package/src/transcript/search.ts +20 -56
- package/src/transcript/subagents.ts +150 -0
- package/src/cli/claude-settings.ts +0 -42
- package/src/cli/constants.ts +0 -52
- package/src/cli/inspect.ts +0 -41
- package/src/cli/launchd.ts +0 -39
- package/src/cli/logo/pixels.ts +0 -34
- package/src/cli/prompt.ts +0 -91
- package/src/cli/service.ts +0 -33
- package/src/cli/sessions.ts +0 -25
- package/src/config/constants.ts +0 -39
- package/src/config/types.ts +0 -50
- package/src/context/constants.ts +0 -42
- package/src/context/evidence.ts +0 -92
- package/src/context/recall.ts +0 -155
- package/src/context/rerank.ts +0 -44
- package/src/context/strategies.ts +0 -89
- package/src/context/turns.ts +0 -87
- package/src/context/types.ts +0 -66
- package/src/proxy/constants.ts +0 -29
- package/src/proxy/types.ts +0 -82
- package/src/transcript/constants.ts +0 -15
- package/src/transcript/types.ts +0 -43
- /package/src/cli/{args.ts → terminal/args.ts} +0 -0
package/src/context/content.ts
CHANGED
|
@@ -1,14 +1,40 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
41
|
-
return `${block.name} ${clip(JSON.stringify(input),
|
|
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
|
+
}
|