@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/cli/sessions.ts
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { SESSION_ID_PATTERN } from "../config/constants"
|
|
2
|
-
import { seenSessions } from "../proxy/sessions"
|
|
3
|
-
import { flagValues } from "./args"
|
|
4
|
-
import { SHORT_ID_LENGTH } from "./constants"
|
|
5
|
-
import { fail } from "./ui"
|
|
6
|
-
|
|
7
|
-
export const shortId = (sessionId: string) => sessionId.slice(0, SHORT_ID_LENGTH)
|
|
8
|
-
|
|
9
|
-
function resolve(input: string): string {
|
|
10
|
-
if (!SESSION_ID_PATTERN.test(input)) fail(`Not a session id: ${input}`)
|
|
11
|
-
const seen = seenSessions().map(([id]) => id)
|
|
12
|
-
if (seen.includes(input)) return input
|
|
13
|
-
const matches = seen.filter((id) => id.startsWith(input))
|
|
14
|
-
if (matches.length === 1) return matches[0]
|
|
15
|
-
if (matches.length > 1) fail(`${input} matches ${matches.length} sessions`, "Use more of the id.")
|
|
16
|
-
return input
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function sessionArgs(args: string[]): string[] {
|
|
20
|
-
return [...new Set(flagValues(args, "--session").map(resolve))]
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function isKnown(sessionId: string) {
|
|
24
|
-
return seenSessions().some(([id]) => id === sessionId)
|
|
25
|
-
}
|
package/src/config/constants.ts
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { homedir } from "node:os"
|
|
2
|
-
import path from "node:path"
|
|
3
|
-
import type { Config, Mode, Scope, SearchMode, Tunable } from "./types"
|
|
4
|
-
|
|
5
|
-
export const EUNOE_DIR = process.env.EUNOE_DIR ?? path.join(homedir(), ".eunoe")
|
|
6
|
-
|
|
7
|
-
export const paths = {
|
|
8
|
-
config: path.join(EUNOE_DIR, "config.json"),
|
|
9
|
-
requests: path.join(EUNOE_DIR, "requests.jsonl"),
|
|
10
|
-
threads: path.join(EUNOE_DIR, "threads"),
|
|
11
|
-
transcripts: path.join(EUNOE_DIR, "transcripts"),
|
|
12
|
-
models: path.join(EUNOE_DIR, "models.json"),
|
|
13
|
-
sessions: path.join(EUNOE_DIR, "sessions.json"),
|
|
14
|
-
proxyLog: path.join(EUNOE_DIR, "proxy.log"),
|
|
15
|
-
typesafeKey: path.join(EUNOE_DIR, "typesafe-key"),
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export const RERANK_OFF = "off"
|
|
19
|
-
|
|
20
|
-
export const DEFAULTS: Config = {
|
|
21
|
-
mode: "default",
|
|
22
|
-
scope: "all",
|
|
23
|
-
sessions: {},
|
|
24
|
-
port: 8788,
|
|
25
|
-
compactAt: 0.9,
|
|
26
|
-
keepTurns: "all",
|
|
27
|
-
keep: 3,
|
|
28
|
-
budget: 100_000,
|
|
29
|
-
search: "markdown",
|
|
30
|
-
rerank: "jev-preview",
|
|
31
|
-
upstream: "https://api.anthropic.com",
|
|
32
|
-
claudeConfigDirs: [],
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export const MODES: Mode[] = ["default", "compact", "off"]
|
|
36
|
-
export const SEARCH_MODES: SearchMode[] = ["jsonl", "markdown", "xml", "qmd"]
|
|
37
|
-
export const TUNABLES: Tunable[] = ["keep", "budget", "compactAt", "keepTurns", "search", "rerank"]
|
|
38
|
-
export const SCOPES: Scope[] = ["all", "sessions"]
|
|
39
|
-
export const SESSION_ID_PATTERN = /^[a-zA-Z0-9-]+$/
|
package/src/config/types.ts
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
export type Mode = "off" | "compact" | "default"
|
|
2
|
-
export type SearchMode = "jsonl" | "markdown" | "xml" | "qmd"
|
|
3
|
-
export type Scope = "all" | "sessions"
|
|
4
|
-
|
|
5
|
-
export interface RollingConfig {
|
|
6
|
-
keep: number
|
|
7
|
-
budget: number
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export interface RerankConfig {
|
|
11
|
-
model: string
|
|
12
|
-
candidates?: number
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export interface EvalConfig {
|
|
16
|
-
strategy?: "tail" | "guide" | "trim"
|
|
17
|
-
rolling?: RollingConfig
|
|
18
|
-
tailTurns?: number
|
|
19
|
-
forceCut?: boolean
|
|
20
|
-
trustHeaders?: boolean
|
|
21
|
-
evidence?: boolean
|
|
22
|
-
recall?: boolean
|
|
23
|
-
rerank?: RerankConfig
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export interface Tunables {
|
|
27
|
-
compactAt: number
|
|
28
|
-
keepTurns: number | "all"
|
|
29
|
-
keep: number
|
|
30
|
-
budget: number
|
|
31
|
-
search: SearchMode
|
|
32
|
-
rerank: string
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export type Tunable = keyof Tunables
|
|
36
|
-
|
|
37
|
-
export interface SessionSettings extends Partial<Tunables> {
|
|
38
|
-
mode?: Mode
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export interface Config extends Tunables {
|
|
42
|
-
mode: Mode
|
|
43
|
-
scope: Scope
|
|
44
|
-
sessions: Record<string, SessionSettings>
|
|
45
|
-
port: number
|
|
46
|
-
upstream: string
|
|
47
|
-
claudeConfigDirs: string[]
|
|
48
|
-
window?: number
|
|
49
|
-
eval?: EvalConfig
|
|
50
|
-
}
|
package/src/context/constants.ts
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
export const SUMMARY_COMMAND_CHARS = 100
|
|
2
|
-
export const SUMMARY_ARGUMENT_CHARS = 60
|
|
3
|
-
export const SUMMARY_LAST_WORDS_CHARS = 600
|
|
4
|
-
export const SUMMARY_CALLS_SHOWN = 20
|
|
5
|
-
|
|
6
|
-
export const CLEARED_INPUT_THRESHOLD = 2_000
|
|
7
|
-
export const CLEARED_INPUT_KEPT = 500
|
|
8
|
-
|
|
9
|
-
export const REMINDER_ONLY = /^\s*(?:<system-reminder>[\s\S]*?<\/system-reminder>\s*)+$/
|
|
10
|
-
|
|
11
|
-
export const REREADABLE_TOOLS = new Set(["Read", "Grep", "Glob", "LS", "NotebookRead", "WebFetch", "WebSearch", "TodoWrite", "Edit", "Write", "MultiEdit", "NotebookEdit"])
|
|
12
|
-
export const EVIDENCE_LINES_PER_CALL = 12
|
|
13
|
-
export const EVIDENCE_LINE_CHARS = 220
|
|
14
|
-
export const EVIDENCE_TURN_CHARS = 4_000
|
|
15
|
-
export const EVIDENCE_SCAN_LINES = 1_000_000
|
|
16
|
-
export const EVIDENCE_MIN_SCORE = 2
|
|
17
|
-
export const EVIDENCE_RANKED = true
|
|
18
|
-
export const EVIDENCE_LABELS = true
|
|
19
|
-
export const EVIDENCE_CALL_FLOOR = 2
|
|
20
|
-
export const ERROR_LINE = /\b(?:error|errors|fail|fails|failed|failure|fatal|exception|panic|denied|refused|timed? ?out|traceback|cannot|unable to)\b/i
|
|
21
|
-
export const ERROR_CODE = /\b[A-Z][A-Z0-9]*_[A-Z0-9_]{2,}\b|\bSIG[A-Z]{2,}\d*\b|\b[A-Z]{2,}\d{2,}\b|\bE(?:NOENT|ACCES|PERM|EXIST|CONN(?:REFUSED|RESET|ABORTED)|PIPE|ADDRINUSE|TIMEDOUT|NOTFOUND|INVAL|BUSY|MFILE|AGAIN|HOSTUNREACH|NOTDIR|ISDIR|BADF|NOSPC|AI_[A-Z]+)\b/
|
|
22
|
-
export const VALUE_TOKEN = /\b[a-z]{2,}[_-](?=[a-z0-9]*\d)[a-z0-9]{6,}\b|\b(?=[0-9a-f]*\d)(?=[0-9a-f]*[a-f])[0-9a-f]{7,}\b|https?:\/\/[^\s)"']+|\b\d{4,}\b|\b\d{6,}_[a-z0-9_]{3,}\b|\bv?\d+\.\d+\.\d+\b|\b\d+(?:\.\d+)?\s?(?:ms|s|[KMGT]i?B|kB|%)(?![a-z])|\bsha\d*:[0-9a-f]+|@[a-z][\w-]+|--[a-z][\w-]*=\S+|\b\d+ (?:pass(?:ed|ing)?|fail(?:ed|ing)?|tests?|files? changed|insertions?|deletions?|rows?|errors?|warnings?)\b|[$€£]\d+(?:[.,]\d+)?|\b(?:status|HTTP\/\d(?:\.\d)?)\s*[:=]?\s*[45]\d{2}\b|\bexit(?: code| status)?\s*[:=]?\s*(?!0\b)\d{1,3}\b/i
|
|
23
|
-
export const ID_TOKEN = /\b[a-z]{2,}[_-](?=[a-z0-9]*\d)[a-z0-9]{6,}\b|\b(?=[0-9a-f]*\d)(?=[0-9a-f]*[a-f])[0-9a-f]{7,}\b|https?:\/\/[^\s)"']+|\b\d{6,}_[a-z0-9_]{3,}\b|\bv?\d+\.\d+\.\d+\b|\bsha\d*:[0-9a-f]+|@[a-z][\w-]+|--[a-z][\w-]*=\S+/i
|
|
24
|
-
export const KEY_VALUE =/^[\w .\-/()]{2,48}[:=]\s*\S/
|
|
25
|
-
export const VALUE_LABEL = /\b(?:ids?|hash|sha(?:256)?|checksum|trace|deploy(?:ment)?|preview|url|version|rows|p9[59]|exit(?: code)?|signal|request[_-]?id|job|migration|applied|bundle|build|status|took|updated|created)\b/i
|
|
26
|
-
|
|
27
|
-
export const RECALL_WINDOW_LINES = 6
|
|
28
|
-
export const RECALL_SHOWN_LINES = 8
|
|
29
|
-
export const RECALL_PER_QUERY = 12
|
|
30
|
-
export const RECALL_MAX_CHARS = 18_000
|
|
31
|
-
export const RECALL_MAX_CHARS_MULTI = 30_000
|
|
32
|
-
export const RECALL_MIN_TERMS = 2
|
|
33
|
-
export const RECALL_SALIENT_EXCERPT = true
|
|
34
|
-
export const RECALL_NARRATIVE = true
|
|
35
|
-
export const RECALL_LINE_CHARS = 240
|
|
36
|
-
export const RECALL_RERANK_CANDIDATES = 30
|
|
37
|
-
export const RERANK_ENDPOINT = "https://api.typesafe.ai/v1/systemone"
|
|
38
|
-
export const RERANK_TIMEOUT_MS = 4_000
|
|
39
|
-
export const RERANK_LINE_CHARS = 240
|
|
40
|
-
export const BM25_K1 = 1.2
|
|
41
|
-
export const BM25_B = 0.75
|
|
42
|
-
export const STOPWORDS = new Set("the and for with that this what which when where who how was were did does done from into onto have has had you your our out not but are can could would should will just then than them they there their its it's about after before again also any each other some such only own same very more most over under while because been being into".split(" "))
|
package/src/context/evidence.ts
DELETED
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
import { ERROR_CODE, ERROR_LINE, EVIDENCE_CALL_FLOOR, EVIDENCE_LABELS, EVIDENCE_LINE_CHARS, EVIDENCE_LINES_PER_CALL, EVIDENCE_MIN_SCORE, EVIDENCE_RANKED, EVIDENCE_SCAN_LINES, EVIDENCE_TURN_CHARS, ID_TOKEN, KEY_VALUE, REREADABLE_TOOLS, VALUE_LABEL, VALUE_TOKEN } from "./constants"
|
|
2
|
-
import { blocks, clip, describeToolCall, resultText } from "./content"
|
|
3
|
-
import type { Block, EvidenceOptions, Message } from "./types"
|
|
4
|
-
|
|
5
|
-
interface Scored {
|
|
6
|
-
index: number
|
|
7
|
-
line: string
|
|
8
|
-
score: number
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function isSalient(line: string): boolean {
|
|
12
|
-
return ERROR_LINE.test(line) || ERROR_CODE.test(line) || VALUE_TOKEN.test(line)
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function scoreLines(text: string, options: EvidenceOptions): Scored[] {
|
|
16
|
-
const scan = options.scanLines ?? EVIDENCE_SCAN_LINES
|
|
17
|
-
const lineChars = options.lineChars ?? EVIDENCE_LINE_CHARS
|
|
18
|
-
const minScore = options.minScore ?? EVIDENCE_MIN_SCORE
|
|
19
|
-
const scored = text.split("\n").slice(0, scan).flatMap((raw, index) => {
|
|
20
|
-
const line = raw.trim()
|
|
21
|
-
if (line.length < 4) return []
|
|
22
|
-
const error = ERROR_LINE.test(line) || ERROR_CODE.test(line)
|
|
23
|
-
const value = VALUE_TOKEN.test(line)
|
|
24
|
-
if (!error && !value) return []
|
|
25
|
-
const score = (error ? 3 : 0) + (value ? (ID_TOKEN.test(line) ? 3 : 2) : 0) + (KEY_VALUE.test(line) ? 1 : 0) + (line.length < 120 ? 1 : 0) + ((options.labels ?? EVIDENCE_LABELS) && VALUE_LABEL.test(line) ? 1 : 0)
|
|
26
|
-
return score >= minScore ? [{ index, line: clip(line, lineChars), score }] : []
|
|
27
|
-
})
|
|
28
|
-
const seen = new Set<string>()
|
|
29
|
-
return scored.sort((a, b) => b.score - a.score || a.index - b.index).filter((s) => !seen.has(s.line) && seen.add(s.line))
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function salientLines(text: string, options: EvidenceOptions = {}): string[] {
|
|
33
|
-
return scoreLines(text, options)
|
|
34
|
-
.slice(0, options.linesPerCall ?? EVIDENCE_LINES_PER_CALL)
|
|
35
|
-
.sort((a, b) => a.index - b.index)
|
|
36
|
-
.map((s) => s.line)
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function toolPairs(turn: Message[]): Array<{ call: Block; result: Block }> {
|
|
40
|
-
const results = new Map<string, Block>()
|
|
41
|
-
for (const message of turn) {
|
|
42
|
-
if (message.role !== "user") continue
|
|
43
|
-
for (const block of blocks(message)) if (block.type === "tool_result") results.set(block.tool_use_id, block)
|
|
44
|
-
}
|
|
45
|
-
return turn
|
|
46
|
-
.filter((m) => m.role === "assistant")
|
|
47
|
-
.flatMap((m) => blocks(m).filter((b) => b.type === "tool_use"))
|
|
48
|
-
.flatMap((call) => (results.has(call.id) ? [{ call, result: results.get(call.id)! }] : []))
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function render(entries: Array<{ head: string; lines: string[] }>): string | null {
|
|
52
|
-
const body = entries.filter((e) => e.lines.length).map((e) => `- ${e.head}:\n${e.lines.map((l) => ` ${l}`).join("\n")}`)
|
|
53
|
-
return body.length ? `<tool-evidence>\nKey lines of command output from this turn, kept verbatim (the full output is in the transcript):\n${body.join("\n")}\n</tool-evidence>` : null
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function turnEvidence(turn: Message[], options: EvidenceOptions = {}): string | null {
|
|
57
|
-
const turnChars = options.turnChars ?? EVIDENCE_TURN_CHARS
|
|
58
|
-
const perCall = options.linesPerCall ?? EVIDENCE_LINES_PER_CALL
|
|
59
|
-
const pairs = toolPairs(turn).filter(({ call }) => options.allTools || !REREADABLE_TOOLS.has(call.name))
|
|
60
|
-
const heads = pairs.map(({ call, result }) => `${describeToolCall(call)}${result.is_error ? " (failed)" : ""}`)
|
|
61
|
-
if (!(options.ranked ?? EVIDENCE_RANKED)) {
|
|
62
|
-
const entries: Array<{ head: string; lines: string[] }> = []
|
|
63
|
-
let used = 0
|
|
64
|
-
pairs.forEach(({ result }, i) => {
|
|
65
|
-
const lines = salientLines(resultText(result), options)
|
|
66
|
-
if (!lines.length) return
|
|
67
|
-
const size = heads[i].length + 4 + lines.reduce((s, l) => s + l.length + 5, 0)
|
|
68
|
-
if (used + size > turnChars) return
|
|
69
|
-
entries.push({ head: heads[i], lines })
|
|
70
|
-
used += size
|
|
71
|
-
})
|
|
72
|
-
return render(entries)
|
|
73
|
-
}
|
|
74
|
-
const floor = options.callFloor ?? EVIDENCE_CALL_FLOOR
|
|
75
|
-
const perCallLines = pairs.map(({ result }) => scoreLines(resultText(result), options))
|
|
76
|
-
const chosen = new Map<number, Scored[]>()
|
|
77
|
-
let used = 0
|
|
78
|
-
const take = (call: number, line: Scored) => {
|
|
79
|
-
const bucket = chosen.get(call) ?? []
|
|
80
|
-
const cost = line.line.length + 5 + (bucket.length ? 0 : heads[call].length + 4)
|
|
81
|
-
if (bucket.length >= perCall || used + cost > turnChars) return
|
|
82
|
-
bucket.push(line)
|
|
83
|
-
chosen.set(call, bucket)
|
|
84
|
-
used += cost
|
|
85
|
-
}
|
|
86
|
-
perCallLines.forEach((lines, call) => lines.slice(0, floor).forEach((line) => take(call, line)))
|
|
87
|
-
perCallLines
|
|
88
|
-
.flatMap((lines, call) => lines.slice(floor).map((line) => ({ line, call })))
|
|
89
|
-
.sort((a, b) => b.line.score - a.line.score || a.call - b.call || a.line.index - b.line.index)
|
|
90
|
-
.forEach(({ line, call }) => take(call, line))
|
|
91
|
-
return render(heads.map((head, i) => ({ head, lines: (chosen.get(i) ?? []).sort((a, b) => a.index - b.index).map((s) => s.line) })))
|
|
92
|
-
}
|
package/src/context/recall.ts
DELETED
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
import { BM25_B, BM25_K1, RECALL_LINE_CHARS, RECALL_MAX_CHARS, RECALL_MAX_CHARS_MULTI, RECALL_MIN_TERMS, RECALL_NARRATIVE, RECALL_PER_QUERY, RECALL_RERANK_CANDIDATES, RECALL_SALIENT_EXCERPT, RECALL_SHOWN_LINES, RECALL_WINDOW_LINES, STOPWORDS } from "./constants"
|
|
2
|
-
import { blocks, clip, describeToolCall, resultText } from "./content"
|
|
3
|
-
import { isSalient, toolPairs } from "./evidence"
|
|
4
|
-
import { isPrompt, splitTurns } from "./turns"
|
|
5
|
-
import type { Message, RecallChunk, RecallOptions, RecallPick, Reranker } from "./types"
|
|
6
|
-
|
|
7
|
-
const REMINDERS = /<(system-reminder|system-message|recalled-context|tool-evidence)>[\s\S]*?<\/\1>/g
|
|
8
|
-
|
|
9
|
-
export function terms(text: string): string[] {
|
|
10
|
-
return (text.toLowerCase().match(/[a-z0-9_@$][a-z0-9_.:/@$-]*[a-z0-9_]|[a-z0-9]/g) ?? [])
|
|
11
|
-
.flatMap((term) => (/[._:/-]/.test(term) ? [term, ...term.split(/[._:/-]+/)] : [term]))
|
|
12
|
-
.filter((term) => term.length >= 3 && !STOPWORDS.has(term))
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function promptText(message: Message): string {
|
|
16
|
-
return blocks(message).filter((b) => b.type === "text").map((b) => String(b.text ?? "")).join("\n").replace(REMINDERS, "").trim()
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function windows(lines: string[], size: number): string[][] {
|
|
20
|
-
const out: string[][] = []
|
|
21
|
-
for (let start = 0; start < lines.length; start += size) out.push(lines.slice(start, start + size))
|
|
22
|
-
return out
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function recallChunks(messages: Message[], keptIds: Map<string, string>, options: RecallOptions = {}): RecallChunk[] {
|
|
26
|
-
const { turns } = splitTurns(messages)
|
|
27
|
-
const size = options.windowLines ?? RECALL_WINDOW_LINES
|
|
28
|
-
const weight = Math.max(1, options.callWeight ?? 1)
|
|
29
|
-
const chunks: RecallChunk[] = []
|
|
30
|
-
turns.slice(0, -1).forEach((turn, turnIndex) => {
|
|
31
|
-
for (const { call, result } of toolPairs(turn)) {
|
|
32
|
-
const text = resultText(result)
|
|
33
|
-
if (keptIds.get(call.id) === text || !text.trim()) continue
|
|
34
|
-
const head = describeToolCall(call)
|
|
35
|
-
const headTerms = Array.from({ length: weight }, () => terms(head)).flat()
|
|
36
|
-
for (const window of windows(text.split("\n"), size)) {
|
|
37
|
-
chunks.push({ turn: turnIndex + 1, call: head, lines: window, terms: [...headTerms, ...terms(window.join("\n"))] })
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
if (!(options.narrative ?? RECALL_NARRATIVE)) return
|
|
41
|
-
const said = turn.slice(1, -1).filter((m) => m.role === "assistant").flatMap((m) => blocks(m).filter((b) => b.type === "text").map((b) => String(b.text ?? "").trim())).filter(Boolean)
|
|
42
|
-
for (const window of windows(said.join("\n").split("\n"), size)) {
|
|
43
|
-
chunks.push({ turn: turnIndex + 1, call: "agent", lines: window, terms: terms(window.join("\n")) })
|
|
44
|
-
}
|
|
45
|
-
})
|
|
46
|
-
return chunks
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function rank(query: string[], chunks: RecallChunk[], minTerms = RECALL_MIN_TERMS) {
|
|
50
|
-
const df = new Map<string, number>()
|
|
51
|
-
for (const chunk of chunks) for (const term of new Set(chunk.terms)) df.set(term, (df.get(term) ?? 0) + 1)
|
|
52
|
-
const average = chunks.reduce((s, c) => s + c.terms.length, 0) / Math.max(1, chunks.length)
|
|
53
|
-
const wanted = [...new Set(query)].filter((t) => df.has(t))
|
|
54
|
-
return chunks.map((chunk, index) => {
|
|
55
|
-
const tf = new Map<string, number>()
|
|
56
|
-
for (const term of chunk.terms) tf.set(term, (tf.get(term) ?? 0) + 1)
|
|
57
|
-
let score = 0
|
|
58
|
-
let matched = 0
|
|
59
|
-
for (const term of wanted) {
|
|
60
|
-
const f = tf.get(term) ?? 0
|
|
61
|
-
if (!f) continue
|
|
62
|
-
matched += 1
|
|
63
|
-
const idf = Math.log(1 + (chunks.length - df.get(term)! + 0.5) / (df.get(term)! + 0.5))
|
|
64
|
-
score += idf * (f * (BM25_K1 + 1)) / (f + BM25_K1 * (1 - BM25_B + BM25_B * chunk.terms.length / average))
|
|
65
|
-
}
|
|
66
|
-
return { index, score, matched }
|
|
67
|
-
}).filter((r) => r.matched >= Math.min(minTerms, wanted.length) && r.score > 0).sort((a, b) => b.score - a.score)
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function excerpt(chunk: RecallChunk, query: Set<string>, salient: boolean): string[] {
|
|
71
|
-
const hits = chunk.lines.map((line, i) => (terms(line).some((t) => query.has(t)) ? i : -1)).filter((i) => i >= 0)
|
|
72
|
-
const shown = new Set<number>()
|
|
73
|
-
for (const i of hits) for (const j of [i - 1, i, i + 1]) if (j >= 0 && j < chunk.lines.length) shown.add(j)
|
|
74
|
-
if (salient) for (let i = 0; i < chunk.lines.length && shown.size < RECALL_SHOWN_LINES + 2; i += 1) if (isSalient(chunk.lines[i])) shown.add(i)
|
|
75
|
-
const ordered = [...shown].sort((a, b) => a - b).slice(0, salient ? RECALL_SHOWN_LINES + 2 : RECALL_SHOWN_LINES)
|
|
76
|
-
return (ordered.length ? ordered : [0]).map((i) => clip(chunk.lines[i], RECALL_LINE_CHARS)).filter(Boolean)
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export function queriesOf(prompt: string): string[] {
|
|
80
|
-
const lines = prompt.split("\n").map((l) => l.trim()).filter((l) => terms(l).length >= 2)
|
|
81
|
-
return lines.length > 1 ? lines : [prompt]
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export function pickRecall(prompt: string, chunks: RecallChunk[], options: RecallOptions = {}): RecallPick[] {
|
|
85
|
-
const perQuery = options.perQuery ?? RECALL_PER_QUERY
|
|
86
|
-
const picked = new Map<number, Set<string>>()
|
|
87
|
-
for (const query of queriesOf(prompt)) {
|
|
88
|
-
const q = terms(query)
|
|
89
|
-
for (const hit of rank(q, chunks, options.minTerms).slice(0, perQuery)) {
|
|
90
|
-
if (!picked.has(hit.index)) picked.set(hit.index, new Set())
|
|
91
|
-
for (const t of q) picked.get(hit.index)!.add(t)
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
return [...picked.entries()].map(([index, query]) => ({ index, query }))
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export async function pickRecallWith(prompt: string, chunks: RecallChunk[], rerank: Reranker, options: RecallOptions = {}): Promise<RecallPick[]> {
|
|
98
|
-
const perQuery = options.perQuery ?? RECALL_PER_QUERY
|
|
99
|
-
const width = options.candidates ?? RECALL_RERANK_CANDIDATES
|
|
100
|
-
const picked = new Map<number, Set<string>>()
|
|
101
|
-
for (const query of queriesOf(prompt)) {
|
|
102
|
-
const q = terms(query)
|
|
103
|
-
const candidates = rank(q, chunks, options.minTerms).slice(0, width).map((hit) => hit.index)
|
|
104
|
-
for (const index of (await rerank(query, chunks, candidates)).slice(0, perQuery)) {
|
|
105
|
-
if (!picked.has(index)) picked.set(index, new Set())
|
|
106
|
-
for (const t of q) picked.get(index)!.add(t)
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
return [...picked.entries()].map(([index, query]) => ({ index, query }))
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
export function renderRecall(prompt: string, chunks: RecallChunk[], picks: RecallPick[], options: RecallOptions = {}): string | null {
|
|
113
|
-
if (!picks.length) return null
|
|
114
|
-
const budget = queriesOf(prompt).length > 1 ? options.maxCharsMulti ?? RECALL_MAX_CHARS_MULTI : options.maxChars ?? RECALL_MAX_CHARS
|
|
115
|
-
const entries: string[] = []
|
|
116
|
-
let used = 0
|
|
117
|
-
for (const pick of [...picks].sort((a, b) => chunks[a.index].turn - chunks[b.index].turn || a.index - b.index)) {
|
|
118
|
-
const chunk = chunks[pick.index]
|
|
119
|
-
const entry = `[turn ${chunk.turn}] ${chunk.call}:\n${excerpt(chunk, pick.query, options.salientExcerpt ?? RECALL_SALIENT_EXCERPT).map((l) => ` ${l}`).join("\n")}`
|
|
120
|
-
if (used + entry.length > budget) continue
|
|
121
|
-
entries.push(entry)
|
|
122
|
-
used += entry.length
|
|
123
|
-
}
|
|
124
|
-
return entries.length
|
|
125
|
-
? `<recalled-context>\nExcerpts from earlier in this session that match this message: tool output that is no longer in your context, quoted exactly, oldest first. Where the same thing appears twice, the later turn is current. Use them if they answer what was asked; ignore them otherwise.\n\n${entries.join("\n")}\n</recalled-context>`
|
|
126
|
-
: null
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
export function recallFor(prompt: string, chunks: RecallChunk[], options: RecallOptions = {}): string | null {
|
|
130
|
-
if (!chunks.length) return null
|
|
131
|
-
return renderRecall(prompt, chunks, pickRecall(prompt, chunks, options), options)
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
export async function recallForWith(prompt: string, chunks: RecallChunk[], rerank: Reranker, options: RecallOptions = {}): Promise<string | null> {
|
|
135
|
-
if (!chunks.length) return null
|
|
136
|
-
return renderRecall(prompt, chunks, await pickRecallWith(prompt, chunks, rerank, options), options)
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
export function livePrompt(messages: Message[]): { index: number; text: string } | null {
|
|
140
|
-
const { preamble, turns } = splitTurns(messages)
|
|
141
|
-
if (turns.length < 2) return null
|
|
142
|
-
const first = turns.at(-1)![0]
|
|
143
|
-
if (!isPrompt(first)) return null
|
|
144
|
-
const index = preamble.length + turns.slice(0, -1).reduce((s, t) => s + t.length, 0)
|
|
145
|
-
return { index, text: promptText(first) }
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
export function withRecall(messages: Message[], fingerprint: (message: Message) => string, recalls: Record<string, string>): Message[] {
|
|
149
|
-
if (!Object.keys(recalls).length) return messages
|
|
150
|
-
return messages.map((message) => {
|
|
151
|
-
if (!isPrompt(message)) return message
|
|
152
|
-
const text = recalls[fingerprint(message)]
|
|
153
|
-
return text ? { ...message, content: [...blocks(message), { type: "text", text }] } : message
|
|
154
|
-
})
|
|
155
|
-
}
|
package/src/context/rerank.ts
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
-
import { paths } from "../config/constants"
|
|
3
|
-
import { RERANK_ENDPOINT, RERANK_LINE_CHARS, RERANK_TIMEOUT_MS } from "./constants"
|
|
4
|
-
import type { RecallChunk, Reranker } from "./types"
|
|
5
|
-
|
|
6
|
-
export function rerankKey(): string | null {
|
|
7
|
-
const env = process.env.TYPESAFE_API_KEY?.trim()
|
|
8
|
-
if (env) return env
|
|
9
|
-
return existsSync(paths.typesafeKey) ? readFileSync(paths.typesafeKey, "utf8").trim() : null
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function rerankState(chunks: RecallChunk[], candidates: number[]): string {
|
|
13
|
-
return candidates.map((index, n) => `### ${n}\n[turn ${chunks[index].turn}] ${chunks[index].call}\n${chunks[index].lines.map((l) => l.slice(0, RERANK_LINE_CHARS)).join("\n")}`).join("\n\n")
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function orderByProbability(candidates: number[], probabilities: Record<string, number>): number[] {
|
|
17
|
-
return candidates.map((_, n) => n).sort((a, b) => (probabilities[String(b)] ?? 0) - (probabilities[String(a)] ?? 0) || a - b).map((n) => candidates[n])
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function jevReranker(model: string, key: string, endpoint = RERANK_ENDPOINT): Reranker {
|
|
21
|
-
return async (query, chunks, candidates) => {
|
|
22
|
-
if (candidates.length < 2) return candidates
|
|
23
|
-
try {
|
|
24
|
-
const response = await fetch(endpoint, {
|
|
25
|
-
method: "POST",
|
|
26
|
-
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
|
|
27
|
-
signal: AbortSignal.timeout(RERANK_TIMEOUT_MS),
|
|
28
|
-
body: JSON.stringify({
|
|
29
|
-
model,
|
|
30
|
-
state: rerankState(chunks, candidates),
|
|
31
|
-
questions: {
|
|
32
|
-
where: { type: "choice", instructions: `Which excerpt contains the exact answer to the developer's question about this coding session: "${query}"?`, criteria: Object.fromEntries(candidates.map((_, n) => [String(n), null])) },
|
|
33
|
-
},
|
|
34
|
-
}),
|
|
35
|
-
})
|
|
36
|
-
if (!response.ok) return candidates
|
|
37
|
-
const json: any = await response.json()
|
|
38
|
-
const probabilities = json?.answers?.where?.probabilities
|
|
39
|
-
return probabilities && typeof probabilities === "object" ? orderByProbability(candidates, probabilities) : candidates
|
|
40
|
-
} catch {
|
|
41
|
-
return candidates
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
}
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import { CLEARED_INPUT_KEPT, CLEARED_INPUT_THRESHOLD } from "./constants"
|
|
2
|
-
import { blocks, truncate, withoutCacheControl } from "./content"
|
|
3
|
-
import { compactionNotice, truncationNotice } from "./prompts"
|
|
4
|
-
import { hasContent, isHarness, reduceTurn, splitTurns } from "./turns"
|
|
5
|
-
import type { Block, Body, CompactOptions, Message, ReduceOptions } from "./types"
|
|
6
|
-
|
|
7
|
-
export function trimMessages(messages: Message[], options: ReduceOptions = {}): Message[] {
|
|
8
|
-
const { preamble, turns } = splitTurns(messages)
|
|
9
|
-
if (turns.length < 2) return messages
|
|
10
|
-
return [...preamble, ...turns.slice(0, -1).flatMap((turn) => reduceTurn(turn, options)), ...turns.at(-1)!]
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function compactMessages(
|
|
14
|
-
messages: Message[],
|
|
15
|
-
cutIndex: number,
|
|
16
|
-
options: CompactOptions,
|
|
17
|
-
): Message[] {
|
|
18
|
-
const { preamble, turns } = splitTurns(messages)
|
|
19
|
-
if (cutIndex <= 0 || cutIndex >= turns.length) return messages
|
|
20
|
-
const keptFrom = Math.max(1, cutIndex - options.keepTurns)
|
|
21
|
-
const notice = compactionNotice({ omitted: keptFrom - 1 + (options.omittedBefore ?? 0), kept: cutIndex - keptFrom, guide: options.guide })
|
|
22
|
-
const removed = [...turns[0].slice(1), ...turns.slice(1, keptFrom).flat()]
|
|
23
|
-
const carried = removed
|
|
24
|
-
.filter((m) => isHarness(m) && hasContent(m))
|
|
25
|
-
.flatMap((m) => blocks(m).filter((b) => b.type === "text"))
|
|
26
|
-
.map((b) => ({ type: "text", text: `<system-reminder>\n${b.text}\n</system-reminder>` }))
|
|
27
|
-
return [
|
|
28
|
-
...preamble,
|
|
29
|
-
turns[0][0],
|
|
30
|
-
{ role: "user", content: [{ type: "text", text: notice }, ...carried] },
|
|
31
|
-
...turns.slice(keptFrom, cutIndex).flatMap((turn) => reduceTurn(turn, options)),
|
|
32
|
-
...turns.slice(cutIndex).flat(),
|
|
33
|
-
]
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function tailMessages(messages: Message[], from: number, guide: string | null): Message[] {
|
|
37
|
-
const { preamble, turns } = splitTurns(messages)
|
|
38
|
-
if (turns.length === 0 || from <= 1) return messages
|
|
39
|
-
const notice = truncationNotice({ omitted: from - 1, kept: turns.length - from, guide })
|
|
40
|
-
return [
|
|
41
|
-
...preamble,
|
|
42
|
-
turns[0][0],
|
|
43
|
-
{ role: "user", content: [{ type: "text", text: notice }] },
|
|
44
|
-
...turns.slice(from).flat(),
|
|
45
|
-
]
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function countToolResults(messages: Message[], from: number): number {
|
|
49
|
-
return messages.slice(from).reduce((total, m) => total + (m.role === "user" ? blocks(m).filter((b) => b.type === "tool_result").length : 0), 0)
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export function clearToolResults(messages: Message[], from: number, count: number, placeholder: string): Message[] {
|
|
53
|
-
let left = count
|
|
54
|
-
const cleared = new Set<string>()
|
|
55
|
-
const results = messages.map((message, index) => {
|
|
56
|
-
if (left <= 0 || index < from || message.role !== "user" || typeof message.content === "string") return message
|
|
57
|
-
if (!message.content.some((b) => b.type === "tool_result")) return message
|
|
58
|
-
return {
|
|
59
|
-
...message,
|
|
60
|
-
content: message.content.map((b) => {
|
|
61
|
-
if (b.type !== "tool_result" || left <= 0) return b
|
|
62
|
-
left -= 1
|
|
63
|
-
cleared.add(b.tool_use_id)
|
|
64
|
-
return { type: "tool_result", tool_use_id: b.tool_use_id, ...(b.is_error ? { is_error: true } : {}), content: placeholder }
|
|
65
|
-
}),
|
|
66
|
-
}
|
|
67
|
-
})
|
|
68
|
-
return results.map((message) => {
|
|
69
|
-
if (message.role !== "assistant" || typeof message.content === "string") return message
|
|
70
|
-
if (!message.content.some((b) => b.type === "tool_use" && cleared.has(b.id))) return message
|
|
71
|
-
return { ...message, content: message.content.map((b) => (b.type === "tool_use" && cleared.has(b.id) ? { ...b, input: shortenInput(b.input) } : b)) }
|
|
72
|
-
})
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function shortenInput(input: unknown): unknown {
|
|
76
|
-
if (typeof input === "string") return input.length > CLEARED_INPUT_THRESHOLD ? `${truncate(input, CLEARED_INPUT_KEPT)}… [${input.length - CLEARED_INPUT_KEPT} chars cleared; in the transcript]` : input
|
|
77
|
-
if (Array.isArray(input)) return input.map(shortenInput)
|
|
78
|
-
if (input && typeof input === "object") return Object.fromEntries(Object.entries(input).map(([k, v]) => [k, shortenInput(v)]))
|
|
79
|
-
return input
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export function appendSystem(system: Body["system"], text: string): Block[] {
|
|
83
|
-
const list: Block[] = typeof system === "string" ? [{ type: "text", text: system }] : [...(system ?? [])]
|
|
84
|
-
const lastCached = list.findLastIndex((b) => b.cache_control)
|
|
85
|
-
const cacheControl = lastCached >= 0 ? list[lastCached].cache_control : undefined
|
|
86
|
-
if (lastCached >= 0) list[lastCached] = withoutCacheControl(list[lastCached])
|
|
87
|
-
list.push({ type: "text", text, ...(cacheControl ? { cache_control: cacheControl } : {}) })
|
|
88
|
-
return list
|
|
89
|
-
}
|
package/src/context/turns.ts
DELETED
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
import { REMINDER_ONLY, SUMMARY_CALLS_SHOWN, SUMMARY_LAST_WORDS_CHARS } from "./constants"
|
|
2
|
-
import { blocks, clean, describeToolCall, truncate, withoutCacheControl } from "./content"
|
|
3
|
-
import { turnEvidence } from "./evidence"
|
|
4
|
-
import type { Block, Message, ReduceOptions } from "./types"
|
|
5
|
-
|
|
6
|
-
export function isPrompt(message: Message) {
|
|
7
|
-
if (message.role !== "user") return false
|
|
8
|
-
const content = blocks(message)
|
|
9
|
-
return !content.some((block) => block.type === "tool_result")
|
|
10
|
-
&& content.some((block) => block.type === "text" || block.type === "image" || block.type === "document")
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function splitTurns(messages: Message[]): { preamble: Message[]; turns: Message[][] } {
|
|
14
|
-
const preamble: Message[] = []
|
|
15
|
-
const turns: Message[][] = []
|
|
16
|
-
for (const message of messages) {
|
|
17
|
-
if (isPrompt(message) && !(turns.length > 0 && turns.at(-1)!.every((m) => m.role === "user"))) {
|
|
18
|
-
turns.push([message])
|
|
19
|
-
} else if (turns.length > 0) {
|
|
20
|
-
turns.at(-1)!.push(message)
|
|
21
|
-
} else {
|
|
22
|
-
preamble.push(message)
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
return { preamble, turns }
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function turnFingerprint(turn: Message[]): string {
|
|
29
|
-
const hasher = new Bun.CryptoHasher("sha256")
|
|
30
|
-
hasher.update(JSON.stringify(blocks(turn[0]).map(withoutCacheControl)))
|
|
31
|
-
return hasher.digest("hex").slice(0, 24)
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function isHarness(message: Message) {
|
|
35
|
-
return message.role === "system"
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function hasContent(message: Message) {
|
|
39
|
-
return blocks(message).some((block) => block.type !== "text" || String(block.text ?? "").trim())
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function mergedHarness(turn: Message[]): Message[] {
|
|
43
|
-
const content = turn.filter((m) => isHarness(m) && hasContent(m)).flatMap((m) => blocks(m).map(withoutCacheControl))
|
|
44
|
-
return content.length > 0 ? [{ role: "system", content }] : []
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function interruptedSummary(turn: Message[]): string {
|
|
48
|
-
const assistant = turn.filter((m) => m.role === "assistant")
|
|
49
|
-
const lastWords = [...assistant].reverse()
|
|
50
|
-
.map((m) => blocks(m).filter((b) => b.type === "text").map((b) => String(b.text ?? "")).join("\n").trim())
|
|
51
|
-
.find(Boolean)
|
|
52
|
-
const calls = assistant.flatMap((m) => blocks(m).filter((b) => b.type === "tool_use")).map(describeToolCall)
|
|
53
|
-
const shown = calls.slice(-SUMMARY_CALLS_SHOWN)
|
|
54
|
-
return [
|
|
55
|
-
"[This turn was interrupted before a final reply.]",
|
|
56
|
-
lastWords ? `Last thing you said: ${lastWords.length > SUMMARY_LAST_WORDS_CHARS ? `${truncate(lastWords, SUMMARY_LAST_WORDS_CHARS)}…` : lastWords}` : "",
|
|
57
|
-
calls.length > 0 ? `Tool calls (${calls.length}${calls.length > shown.length ? `, last ${shown.length} shown` : ""}): ${shown.join("; ")}` : "",
|
|
58
|
-
"Their output is in the full transcript.",
|
|
59
|
-
].filter(Boolean).join("\n")
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function carriedText(messages: Message[]): Block[] {
|
|
63
|
-
return messages
|
|
64
|
-
.filter((m) => m.role === "user")
|
|
65
|
-
.flatMap((m) => blocks(m).filter((b) => b.type === "text" && String(b.text ?? "").trim() && !REMINDER_ONLY.test(String(b.text))))
|
|
66
|
-
.map((b) => ({ type: "text", text: b.text }))
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export function reduceTurn(turn: Message[], options: ReduceOptions = {}): Message[] {
|
|
70
|
-
const opening: Message[] = []
|
|
71
|
-
let i = 0
|
|
72
|
-
for (; i < turn.length && turn[i].role !== "assistant"; i += 1) {
|
|
73
|
-
if (turn[i].role === "user") opening.push(clean(turn[i]))
|
|
74
|
-
}
|
|
75
|
-
const carried = carriedText(turn.slice(i))
|
|
76
|
-
if (carried.length > 0) opening.push({ role: "user", content: carried })
|
|
77
|
-
const evidence = options.evidence ? turnEvidence(turn) : null
|
|
78
|
-
if (evidence) opening.push({ role: "user", content: [{ type: "text", text: evidence }] })
|
|
79
|
-
opening.push(...mergedHarness(turn))
|
|
80
|
-
const last = [...turn].reverse().find((m) => m.role !== "system")!
|
|
81
|
-
const content = last.role === "assistant" ? blocks(last) : []
|
|
82
|
-
const text = content.filter((b) => b.type === "text" && String(b.text ?? "").trim())
|
|
83
|
-
const final = text.length > 0 && !content.some((b) => b.type === "tool_use")
|
|
84
|
-
? text.map((b) => ({ type: "text", text: b.text }))
|
|
85
|
-
: [{ type: "text", text: interruptedSummary(turn) }]
|
|
86
|
-
return [...opening, { role: "assistant", content: final }]
|
|
87
|
-
}
|