@chroxify/eunoe 1.2.2 → 1.3.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 +139 -2
- package/dist/core/index.js +2235 -0
- package/dist/types/context/content.d.ts +31 -0
- package/dist/types/context/fold/evidence.d.ts +20 -0
- package/dist/types/context/fold/index.d.ts +11 -0
- package/dist/types/context/fold/strategies.d.ts +20 -0
- package/dist/types/context/fold/turns.d.ts +14 -0
- package/dist/types/context/prompts.d.ts +24 -0
- package/dist/types/context/recall/index.d.ts +44 -0
- package/dist/types/context/recall/rerank.d.ts +3 -0
- package/dist/types/core/compaction.d.ts +32 -0
- package/dist/types/core/config.d.ts +33 -0
- package/dist/types/core/estimate.d.ts +9 -0
- package/dist/types/core/fingerprint.d.ts +10 -0
- package/dist/types/core/fold.d.ts +18 -0
- package/dist/types/core/forward.d.ts +24 -0
- package/dist/types/core/handoff.d.ts +10 -0
- package/dist/types/core/index.d.ts +14 -0
- package/dist/types/core/recall.d.ts +8 -0
- package/dist/types/core/rewrite.d.ts +63 -0
- package/dist/types/core/state.d.ts +22 -0
- package/dist/types/core/subagents.d.ts +10 -0
- package/dist/types/core/transcript.d.ts +9 -0
- package/dist/types/core/usage.d.ts +7 -0
- package/dist/types/core/window.d.ts +5 -0
- package/dist/types/transcript/format.d.ts +16 -0
- package/dist/types/transcript/parse.d.ts +45 -0
- package/package.json +15 -2
- package/src/cli/commands/mode.ts +2 -1
- package/src/cli/commands/status.ts +1 -1
- package/src/cli/proxy/stats.ts +1 -1
- package/src/cli/replay/index.ts +3 -3
- package/src/cli/sessions/index.ts +2 -2
- package/src/config/index.ts +4 -27
- package/src/context/content.ts +64 -1
- package/src/context/fold/strategies.ts +55 -10
- package/src/context/fold/turns.ts +22 -2
- package/src/context/prompts.ts +4 -3
- package/src/context/recall/index.ts +2 -3
- package/src/context/recall/rerank.ts +0 -8
- package/src/{proxy → core}/compaction.ts +56 -24
- package/src/core/config.ts +31 -0
- package/src/core/fold.ts +69 -0
- package/src/core/forward.ts +192 -0
- package/src/{proxy → core}/handoff.ts +23 -23
- package/src/core/index.ts +14 -0
- package/src/{proxy → core}/recall.ts +18 -20
- package/src/core/rewrite.ts +189 -0
- package/src/core/state.ts +40 -0
- package/src/core/subagents.ts +28 -0
- package/src/core/transcript.ts +44 -0
- package/src/{proxy → core}/usage.ts +34 -1
- package/src/core/window.ts +39 -0
- package/src/proxy/rewrite.ts +22 -139
- package/src/proxy/server.ts +21 -159
- package/src/proxy/sessions.ts +1 -1
- package/src/proxy/sources.ts +35 -0
- package/src/proxy/state.ts +2 -20
- package/src/proxy/window.ts +3 -33
- package/src/transcript/format.ts +1 -2
- package/src/transcript/subagents.ts +3 -3
- /package/src/{proxy → core}/estimate.ts +0 -0
- /package/src/{proxy → core}/fingerprint.ts +0 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { type Body, wellFormed } from "../context/content"
|
|
2
|
+
import type { Reranker } from "../context/recall"
|
|
3
|
+
import { CORE_DEFAULTS, type CoreConfig } from "./config"
|
|
4
|
+
import { sizeOf } from "./estimate"
|
|
5
|
+
import { type Rewrite, type RewriteNote, rewriteAsync, type ThreadSources } from "./rewrite"
|
|
6
|
+
import type { ThreadStore } from "./state"
|
|
7
|
+
import { inputTokens, recordOverflow, recordUsage, watchUsage } from "./usage"
|
|
8
|
+
import { createWindowLookup, type WindowLookup } from "./window"
|
|
9
|
+
|
|
10
|
+
export type ForwardNote = RewriteNote | Record<string, unknown>
|
|
11
|
+
|
|
12
|
+
export type Prepared = Omit<Rewrite, "note"> & { note: ForwardNote }
|
|
13
|
+
|
|
14
|
+
export interface ForwarderOptions {
|
|
15
|
+
store: ThreadStore
|
|
16
|
+
upstream?: string
|
|
17
|
+
config?: CoreConfig
|
|
18
|
+
window?: number | null | WindowLookup
|
|
19
|
+
threadIdOf?: (request: Request, body: Body) => string | null
|
|
20
|
+
sources?: (threadId: string, request: Request, body: Body) => ThreadSources | undefined
|
|
21
|
+
reranker?: Reranker
|
|
22
|
+
prepare?: (request: Request, body: Body, mayCompact: boolean) => Promise<Prepared>
|
|
23
|
+
record?: (entry: Record<string, unknown>) => void
|
|
24
|
+
countTokensTimeoutMs?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface Outgoing {
|
|
28
|
+
note: ForwardNote
|
|
29
|
+
threadKey: string | null
|
|
30
|
+
bodyText: string | undefined
|
|
31
|
+
sentSize: number
|
|
32
|
+
givenSize: number
|
|
33
|
+
fixedSize: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const COUNT_TOKENS_ATTEMPTS = 3
|
|
37
|
+
|
|
38
|
+
const DROPPED_REQUEST_HEADERS = ["host", "content-length", "accept-encoding"]
|
|
39
|
+
|
|
40
|
+
const clipped = (error: string) => error.slice(0, 500)
|
|
41
|
+
|
|
42
|
+
export function claudeCodeSessionId(headers: Headers, body: Body): string | null {
|
|
43
|
+
const header = headers.get("x-claude-code-session-id")
|
|
44
|
+
if (header) return header
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(body.metadata?.user_id ?? "{}").session_id ?? null
|
|
47
|
+
} catch {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const defaultThreadId = (request: Request, body: Body) => request.headers.get("x-thread-id") ?? claudeCodeSessionId(request.headers, body)
|
|
53
|
+
|
|
54
|
+
function forwardableHeaders(headers: Headers) {
|
|
55
|
+
const out = new Headers(headers)
|
|
56
|
+
out.delete("content-encoding")
|
|
57
|
+
out.delete("content-length")
|
|
58
|
+
return out
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function defaultPrepare(options: ForwarderOptions, upstream: string): NonNullable<ForwarderOptions["prepare"]> {
|
|
62
|
+
const threadIdOf = options.threadIdOf ?? defaultThreadId
|
|
63
|
+
const given = options.window
|
|
64
|
+
const lookup = typeof given === "function" ? given : createWindowLookup()
|
|
65
|
+
const windowFor: (model: unknown, headers: Headers) => Promise<number | null> = typeof given === "number" || given === null ? () => Promise.resolve(given) : (model, headers) => lookup(model, headers, upstream)
|
|
66
|
+
return async (request, body, mayCompact) => {
|
|
67
|
+
const threadId = threadIdOf(request, body)
|
|
68
|
+
const window = await windowFor(body.model, request.headers)
|
|
69
|
+
const sources = threadId ? options.sources?.(threadId, request, body) : undefined
|
|
70
|
+
return rewriteAsync({ body, threadId, config: options.config ?? CORE_DEFAULTS, store: options.store, sources, window, mayCompact }, options.reranker)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const passthrough = (bodyText: string | undefined): Outgoing => ({ note: { action: "pass" }, threadKey: null, bodyText, sentSize: 0, givenSize: 0, fixedSize: 0 })
|
|
75
|
+
|
|
76
|
+
async function outgoing(prepare: NonNullable<ForwarderOptions["prepare"]>, request: Request, givenText: string, mayCompact: boolean): Promise<Outgoing> {
|
|
77
|
+
try {
|
|
78
|
+
const given = JSON.parse(givenText) as Body
|
|
79
|
+
const result = await prepare(request, given, mayCompact)
|
|
80
|
+
const sentSize = sizeOf(result.body)
|
|
81
|
+
return {
|
|
82
|
+
note: result.note,
|
|
83
|
+
threadKey: result.threadKey,
|
|
84
|
+
bodyText: result.body !== given ? JSON.stringify(result.body, wellFormed) : givenText,
|
|
85
|
+
sentSize,
|
|
86
|
+
givenSize: result.body === given ? sentSize : sizeOf(given),
|
|
87
|
+
fixedSize: sizeOf({ ...result.body, messages: [] }),
|
|
88
|
+
}
|
|
89
|
+
} catch (error) {
|
|
90
|
+
return { ...passthrough(givenText), note: { action: "error-passthrough", error: String(error) } }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function createSender(request: Request, target: string, headers: Headers, timeout: number | null, stall: { count: number }) {
|
|
95
|
+
return async (body: string | undefined): Promise<Response> => {
|
|
96
|
+
if (timeout === null) return fetch(target, { method: request.method, headers, body })
|
|
97
|
+
for (;;) {
|
|
98
|
+
try {
|
|
99
|
+
return await fetch(target, { method: request.method, headers, body, signal: AbortSignal.timeout(timeout) })
|
|
100
|
+
} catch (error) {
|
|
101
|
+
const timedOut = error instanceof Error && error.name === "TimeoutError"
|
|
102
|
+
if (!timedOut || stall.count + 1 >= COUNT_TOKENS_ATTEMPTS) throw error
|
|
103
|
+
stall.count += 1
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function delivered(answer: Response, out: Outgoing, metered: boolean, path: string, log: (entry: Record<string, unknown>) => void, store: ThreadStore): Response {
|
|
110
|
+
const { note, threadKey, sentSize, givenSize, fixedSize } = out
|
|
111
|
+
const response = new Response(answer.body, { status: answer.status, statusText: answer.statusText, headers: forwardableHeaders(answer.headers) })
|
|
112
|
+
if (note.action === "pass" || !metered) {
|
|
113
|
+
if (note.action !== "pass" || Object.keys(note).length > 1) log({ path, status: answer.status, ...note })
|
|
114
|
+
return response
|
|
115
|
+
}
|
|
116
|
+
return watchUsage(response, (usage) => {
|
|
117
|
+
log({
|
|
118
|
+
path,
|
|
119
|
+
status: answer.status,
|
|
120
|
+
thread: threadKey,
|
|
121
|
+
...note,
|
|
122
|
+
input: inputTokens(usage),
|
|
123
|
+
cacheRead: usage.cache_read_input_tokens ?? 0,
|
|
124
|
+
cacheWrite: usage.cache_creation_input_tokens ?? 0,
|
|
125
|
+
uncached: usage.input_tokens ?? 0,
|
|
126
|
+
charsIn: givenSize,
|
|
127
|
+
charsOut: sentSize,
|
|
128
|
+
charsFixed: fixedSize,
|
|
129
|
+
})
|
|
130
|
+
if (threadKey) recordUsage(store, threadKey, sentSize, usage)
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function createForwarder(options: ForwarderOptions): (request: Request) => Promise<Response> {
|
|
135
|
+
const upstream = options.upstream ?? "https://api.anthropic.com"
|
|
136
|
+
const prepare = options.prepare ?? defaultPrepare(options, upstream)
|
|
137
|
+
const timeout = options.countTokensTimeoutMs ?? 10_000
|
|
138
|
+
|
|
139
|
+
async function route(request: Request, began: number): Promise<Response> {
|
|
140
|
+
const stall = { count: 0 }
|
|
141
|
+
let prep = 0
|
|
142
|
+
let upload = 0
|
|
143
|
+
const log = (entry: Record<string, unknown>) => options.record?.({ ...entry, ms: Date.now() - began, prep, upload, ...(stall.count > 0 && { stalled: stall.count }) })
|
|
144
|
+
const url = new URL(request.url)
|
|
145
|
+
const headers = new Headers(request.headers)
|
|
146
|
+
for (const name of DROPPED_REQUEST_HEADERS) headers.delete(name)
|
|
147
|
+
|
|
148
|
+
let givenText: string | undefined
|
|
149
|
+
if (request.method !== "GET" && request.method !== "HEAD") givenText = await request.text()
|
|
150
|
+
upload = Date.now() - began
|
|
151
|
+
const isMessages = request.method === "POST" && url.pathname.startsWith("/v1/messages")
|
|
152
|
+
const isCountTokens = url.pathname.includes("count_tokens")
|
|
153
|
+
|
|
154
|
+
const build = () => (givenText && isMessages ? outgoing(prepare, request, givenText, !isCountTokens) : Promise.resolve(passthrough(givenText)))
|
|
155
|
+
const send = createSender(request, upstream + url.pathname + url.search, headers, isCountTokens ? timeout : null, stall)
|
|
156
|
+
let out = await build()
|
|
157
|
+
prep = Date.now() - began - upload
|
|
158
|
+
let answer = await send(out.bodyText)
|
|
159
|
+
const errorResponse = (text: string) => new Response(text, { status: answer.status, statusText: answer.statusText, headers: forwardableHeaders(answer.headers) })
|
|
160
|
+
|
|
161
|
+
if (answer.status >= 400 && out.note.action !== "pass") {
|
|
162
|
+
const errorText = await answer.text()
|
|
163
|
+
const logged = { path: url.pathname, status: answer.status, thread: out.threadKey, ...out.note }
|
|
164
|
+
const overflow = out.threadKey && !isCountTokens ? recordOverflow(options.store, out.threadKey, out.sentSize, errorText) : null
|
|
165
|
+
if (!overflow) {
|
|
166
|
+
log({ ...logged, error: clipped(errorText) })
|
|
167
|
+
return errorResponse(errorText)
|
|
168
|
+
}
|
|
169
|
+
log({ ...logged, overflow, error: clipped(errorText) })
|
|
170
|
+
out = await build()
|
|
171
|
+
out = { ...out, note: { ...out.note, healed: overflow } }
|
|
172
|
+
answer = await send(out.bodyText)
|
|
173
|
+
if (answer.status >= 400) {
|
|
174
|
+
const retryError = await answer.text()
|
|
175
|
+
log({ path: url.pathname, status: answer.status, thread: out.threadKey, ...out.note, error: clipped(retryError) })
|
|
176
|
+
return errorResponse(retryError)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return delivered(answer, out, isMessages && !isCountTokens, url.pathname, log, options.store)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return async (request) => {
|
|
184
|
+
const began = Date.now()
|
|
185
|
+
try {
|
|
186
|
+
return await route(request, began)
|
|
187
|
+
} catch (error) {
|
|
188
|
+
options.record?.({ path: new URL(request.url).pathname, status: 502, action: "upstream-unreachable", error: clipped(String(error)), ms: Date.now() - began })
|
|
189
|
+
return Response.json({ type: "error", error: { type: "api_error", message: `eunoe could not reach the API: ${String(error)}` } }, { status: 502 })
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -1,17 +1,13 @@
|
|
|
1
|
-
import { statSync } from "node:fs"
|
|
2
|
-
import { type Config, claudeConfigDirs } from "../config"
|
|
3
1
|
import { blocks, type Message } from "../context/content"
|
|
4
2
|
import { FOLD_CAP_CHARS, foldedView, sourceChunks } from "../context/fold"
|
|
5
3
|
import { isPrompt } from "../context/fold/turns"
|
|
6
4
|
import { handoffBlock } from "../context/prompts"
|
|
7
5
|
import type { RecallChunk } from "../context/recall"
|
|
8
|
-
import { findSessionByPrefix } from "../transcript/locate"
|
|
9
|
-
import { sessionMessages } from "../transcript/messages"
|
|
10
6
|
import { isInjected, messageFingerprint } from "./fingerprint"
|
|
11
|
-
import {
|
|
7
|
+
import type { ThreadSources } from "./rewrite"
|
|
12
8
|
import type { Handoff, ThreadState } from "./state"
|
|
13
9
|
|
|
14
|
-
const
|
|
10
|
+
const chunksOf = new WeakMap<Message[], { label: string; chunks: RecallChunk[] }>()
|
|
15
11
|
|
|
16
12
|
export const continueToken = (id: string) => `{ee:continue:${id}}`
|
|
17
13
|
|
|
@@ -28,17 +24,23 @@ export function activeHandoffs(handoffs: Record<string, Handoff | null> = {}): R
|
|
|
28
24
|
return Object.fromEntries(Object.entries(handoffs).filter((entry): entry is [string, Handoff] => Boolean(entry[1])))
|
|
29
25
|
}
|
|
30
26
|
|
|
31
|
-
|
|
27
|
+
function resolveHandoff(id: string, sources: ThreadSources, state: ThreadState): Handoff | null {
|
|
28
|
+
const found = sources.findHandoff?.(id) ?? null
|
|
29
|
+
if (!found || !sources.readHandoff) return null
|
|
30
|
+
const already = Object.values(state.handoffs ?? {}).some((h) => h?.source === found.source)
|
|
31
|
+
return already ? null : { label: found.label, source: found.source, text: foldedView(sources.readHandoff(found.source), FOLD_CAP_CHARS) }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function attachHandoffs(messages: Message[], sources: ThreadSources, state: ThreadState): boolean {
|
|
32
35
|
let changed = false
|
|
33
36
|
for (const message of messages) {
|
|
34
37
|
const id = continueIdOf(message)
|
|
35
38
|
if (!id) continue
|
|
36
39
|
const key = messageFingerprint(message)
|
|
37
40
|
if (state.handoffs && key in state.handoffs) continue
|
|
41
|
+
const handoff = resolveHandoff(id, sources, state)
|
|
38
42
|
state.handoffs ??= {}
|
|
39
|
-
|
|
40
|
-
const already = Object.values(state.handoffs).some((h) => h?.source === file)
|
|
41
|
-
state.handoffs[key] = file && !already ? { label: titleOf(file) ?? id, source: file, text: foldedView(sessionMessages(file), FOLD_CAP_CHARS) } : null
|
|
43
|
+
state.handoffs[key] = handoff
|
|
42
44
|
changed = true
|
|
43
45
|
}
|
|
44
46
|
return changed
|
|
@@ -53,19 +55,17 @@ export function withHandoffs(messages: Message[], handoffs: Record<string, Hando
|
|
|
53
55
|
})
|
|
54
56
|
}
|
|
55
57
|
|
|
56
|
-
export function handoffChunks(handoffs: Record<string, Handoff
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const chunks = cached?.mtime === mtime ? cached.chunks : sourceChunks(sessionMessages(source), label, true)
|
|
68
|
-
sourceCache.set(source, { mtime, chunks })
|
|
58
|
+
export function handoffChunks(handoffs: Record<string, Handoff>, sources: ThreadSources): RecallChunk[] {
|
|
59
|
+
const read = sources.readHandoff
|
|
60
|
+
if (!read) return []
|
|
61
|
+
const labels = new Map<string, string>()
|
|
62
|
+
for (const handoff of Object.values(handoffs)) if (!labels.has(handoff.source)) labels.set(handoff.source, handoff.label)
|
|
63
|
+
return [...labels].flatMap(([source, label]) => {
|
|
64
|
+
const messages = read(source)
|
|
65
|
+
const known = chunksOf.get(messages)
|
|
66
|
+
if (known?.label === label) return known.chunks
|
|
67
|
+
const chunks = sourceChunks(messages, label, true)
|
|
68
|
+
chunksOf.set(messages, { label, chunks })
|
|
69
69
|
return chunks
|
|
70
70
|
})
|
|
71
71
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type { Block, Body, Message } from "../context/content"
|
|
2
|
+
export type { RecallChunk, Reranker } from "../context/recall"
|
|
3
|
+
export { jevReranker } from "../context/recall/rerank"
|
|
4
|
+
export type { StuckSizes } from "./compaction"
|
|
5
|
+
export { CORE_DEFAULTS, type CoreConfig, type Mode } from "./config"
|
|
6
|
+
export { type Folded, type FoldOptions, type FoldTarget, foldThread, pinTurns } from "./fold"
|
|
7
|
+
export { claudeCodeSessionId, createForwarder, type ForwarderOptions, type ForwardNote, type Prepared } from "./forward"
|
|
8
|
+
export { continueToken } from "./handoff"
|
|
9
|
+
export { type Rewrite, type RewriteInput, type RewriteNote, rewrite, rewriteAsync, type ThreadSources, threadKeyOf } from "./rewrite"
|
|
10
|
+
export { memoryStore, type ThreadState, type ThreadStore } from "./state"
|
|
11
|
+
export { listSubagentRecall, type Subagent } from "./subagents"
|
|
12
|
+
export { renderTranscriptFiles, type TranscriptOptions, transcriptGuide } from "./transcript"
|
|
13
|
+
export { inputTokens, recordOverflow, recordUsage, type Usage, watchUsage } from "./usage"
|
|
14
|
+
export { createWindowLookup, type WindowLookup } from "./window"
|
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import { type Body, blocks, resultText } from "../context/content"
|
|
1
|
+
import { type Body, blocks, type Message, resultText } from "../context/content"
|
|
2
2
|
import { livePrompt, type RecallChunk, type Reranker, recallChunks, recallFor, recallForWith, withRecall } from "../context/recall"
|
|
3
|
-
import { sessionMessages } from "../transcript/messages"
|
|
4
|
-
import { subagentChunks } from "../transcript/subagents"
|
|
5
3
|
import { messageFingerprint } from "./fingerprint"
|
|
6
4
|
import { activeHandoffs, handoffChunks } from "./handoff"
|
|
7
5
|
import type { Rewrite, RewriteInput } from "./rewrite"
|
|
8
|
-
|
|
6
|
+
|
|
7
|
+
const closingTurn: Message = { role: "user", content: "" }
|
|
9
8
|
|
|
10
9
|
export const wantsRecall = (input: RewriteInput) => (input.config.eval?.recall ?? true) && input.config.mode !== "off"
|
|
11
10
|
|
|
@@ -13,31 +12,30 @@ export const activeRecalls = (recalls: Record<string, string> = {}) => Object.fr
|
|
|
13
12
|
|
|
14
13
|
const touched = (input: RewriteInput, result: Rewrite) => result.threadKey !== null && !(result.body === input.body && result.note.action === "pass")
|
|
15
14
|
|
|
16
|
-
function droppedChunks(input: RewriteInput, result: Rewrite): RecallChunk[] {
|
|
17
|
-
const
|
|
15
|
+
function droppedChunks(input: RewriteInput, result: Rewrite, threadKey: string): RecallChunk[] {
|
|
16
|
+
const sources = input.sources ?? {}
|
|
18
17
|
const kept = new Map<string, string>()
|
|
19
18
|
for (const message of result.body.messages) for (const b of blocks(message)) if (b.type === "tool_result") kept.set(b.tool_use_id, resultText(b))
|
|
20
19
|
const own = recallChunks(input.body.messages, kept)
|
|
21
|
-
const session = own.length > 0 || !
|
|
22
|
-
const withSubagents =
|
|
23
|
-
|
|
24
|
-
return [...handoffChunks(handoffs), ...withSubagents]
|
|
20
|
+
const session = own.length > 0 || !sources.readHistory ? own : recallChunks([...sources.readHistory(), closingTurn], kept)
|
|
21
|
+
const withSubagents = sources.listSubagentChunks ? [...session, ...sources.listSubagentChunks()] : session
|
|
22
|
+
return [...handoffChunks(activeHandoffs(input.store.read(threadKey).handoffs), sources), ...withSubagents]
|
|
25
23
|
}
|
|
26
24
|
|
|
27
|
-
function recallTarget(original: Body
|
|
25
|
+
function recallTarget(original: Body): { key: string; text: string } | null {
|
|
28
26
|
const live = livePrompt(original.messages)
|
|
29
|
-
return live
|
|
27
|
+
return live ? { key: messageFingerprint(original.messages[live.index]), text: live.text } : null
|
|
30
28
|
}
|
|
31
29
|
|
|
32
30
|
export function applyRecall(input: RewriteInput, result: Rewrite): Rewrite {
|
|
33
31
|
if (!touched(input, result) || !result.threadKey) return result
|
|
34
|
-
const state =
|
|
32
|
+
const state = input.store.read(result.threadKey)
|
|
35
33
|
state.recalls ??= {}
|
|
36
|
-
const target = recallTarget(input.body
|
|
34
|
+
const target = recallTarget(input.body)
|
|
37
35
|
let note = result.note
|
|
38
36
|
if (target && !(target.key in state.recalls)) {
|
|
39
|
-
state.recalls[target.key] = recallFor(target.text, droppedChunks(input, result), input.config.eval?.recallOptions) ?? ""
|
|
40
|
-
|
|
37
|
+
state.recalls[target.key] = recallFor(target.text, droppedChunks(input, result, result.threadKey), input.config.eval?.recallOptions) ?? ""
|
|
38
|
+
input.store.save(result.threadKey, state)
|
|
41
39
|
note = { ...note, recalled: state.recalls[target.key].length }
|
|
42
40
|
}
|
|
43
41
|
const messages = withRecall(result.body.messages, messageFingerprint, activeRecalls(state.recalls))
|
|
@@ -46,13 +44,13 @@ export function applyRecall(input: RewriteInput, result: Rewrite): Rewrite {
|
|
|
46
44
|
|
|
47
45
|
export async function prefetchFor(input: RewriteInput, result: Rewrite, rerank: Reranker): Promise<number | null> {
|
|
48
46
|
if (!touched(input, result) || !result.threadKey) return null
|
|
49
|
-
const target = recallTarget(input.body
|
|
47
|
+
const target = recallTarget(input.body)
|
|
50
48
|
if (!target) return null
|
|
51
|
-
const state =
|
|
49
|
+
const state = input.store.read(result.threadKey)
|
|
52
50
|
state.recalls ??= {}
|
|
53
51
|
if (target.key in state.recalls) return null
|
|
54
52
|
const options = { candidates: input.config.eval?.rerank?.candidates, ...input.config.eval?.recallOptions }
|
|
55
|
-
state.recalls[target.key] = (await recallForWith(target.text, droppedChunks(input, result), rerank, options)) ?? ""
|
|
56
|
-
|
|
53
|
+
state.recalls[target.key] = (await recallForWith(target.text, droppedChunks(input, result, result.threadKey), rerank, options)) ?? ""
|
|
54
|
+
input.store.save(result.threadKey, state)
|
|
57
55
|
return state.recalls[target.key].length
|
|
58
56
|
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { Body, Message } from "../context/content"
|
|
2
|
+
import { appendSystem, tailMessages, trimMessages } from "../context/fold/strategies"
|
|
3
|
+
import { splitTurns, turnFingerprint } from "../context/fold/turns"
|
|
4
|
+
import { SYSTEM_PROMPT, searchOnlyInstructions, trimInstructions } from "../context/prompts"
|
|
5
|
+
import { identityReranker, type RecallChunk, type Reranker, withRecall } from "../context/recall"
|
|
6
|
+
import { compact, type StuckSizes } from "./compaction"
|
|
7
|
+
import type { CoreConfig } from "./config"
|
|
8
|
+
import { DEFAULT_RATIO, estimator, sizeOf, targetSize } from "./estimate"
|
|
9
|
+
import { messageFingerprint } from "./fingerprint"
|
|
10
|
+
import { activeHandoffs, attachHandoffs, withHandoffs } from "./handoff"
|
|
11
|
+
import { activeRecalls, applyRecall, prefetchFor, wantsRecall } from "./recall"
|
|
12
|
+
import type { ThreadStore } from "./state"
|
|
13
|
+
|
|
14
|
+
export interface ThreadSources {
|
|
15
|
+
guide?: string | null
|
|
16
|
+
transcriptDir?: string | null
|
|
17
|
+
readHistory?: () => Message[]
|
|
18
|
+
listSubagentChunks?: () => RecallChunk[]
|
|
19
|
+
findHandoff?: (id: string) => { label: string; source: string } | null
|
|
20
|
+
readHandoff?: (source: string) => Message[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface RewriteInput {
|
|
24
|
+
body: Body
|
|
25
|
+
threadId: string | null
|
|
26
|
+
config: CoreConfig
|
|
27
|
+
store: ThreadStore
|
|
28
|
+
sources?: ThreadSources
|
|
29
|
+
window: number | null
|
|
30
|
+
mayCompact: boolean
|
|
31
|
+
omittedBefore?: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type RewriteNote = {
|
|
35
|
+
action: "pass" | "full" | "compact" | "guide" | "trim" | "tail"
|
|
36
|
+
compacted?: boolean
|
|
37
|
+
actions?: string[]
|
|
38
|
+
cutTurn?: number | null
|
|
39
|
+
omitted?: number
|
|
40
|
+
folded?: number
|
|
41
|
+
pinned?: number
|
|
42
|
+
host?: boolean
|
|
43
|
+
keep?: number
|
|
44
|
+
cleared?: number
|
|
45
|
+
stuck?: StuckSizes
|
|
46
|
+
turns?: number
|
|
47
|
+
keptTurns?: number
|
|
48
|
+
messagesIn?: number
|
|
49
|
+
messagesOut?: number
|
|
50
|
+
estimate?: number
|
|
51
|
+
window?: number | null
|
|
52
|
+
fit?: "recut" | "bare" | "over"
|
|
53
|
+
fitEstimate?: number
|
|
54
|
+
recalled?: number
|
|
55
|
+
handoff?: number
|
|
56
|
+
handoffFrom?: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface Rewrite {
|
|
60
|
+
body: Body
|
|
61
|
+
threadKey: string | null
|
|
62
|
+
note: RewriteNote
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const pass = (body: Body): Rewrite => ({ body, threadKey: null, note: { action: "pass" } })
|
|
66
|
+
|
|
67
|
+
const isAgentThread = (body: Body) => Array.isArray(body.tools) && body.tools.length > 0
|
|
68
|
+
|
|
69
|
+
const withSystemPrompt = (system: Body["system"], extra?: string | null) => appendSystem(extra ? appendSystem(system, extra) : system, SYSTEM_PROMPT)
|
|
70
|
+
|
|
71
|
+
const ratioOf = (input: RewriteInput, threadKey: string) => input.store.read(threadKey).ratio ?? DEFAULT_RATIO
|
|
72
|
+
|
|
73
|
+
export function threadKeyOf(threadId: string, messages: Message[]): string | null {
|
|
74
|
+
const first = splitTurns(messages).turns[0]
|
|
75
|
+
return first ? `${threadId}-${turnFingerprint(first)}` : null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function evalStrategy(input: RewriteInput, turns: Message[][], threadKey: string, guide: string | null): Rewrite | null {
|
|
79
|
+
const { body, config } = input
|
|
80
|
+
const strategy = config.eval?.strategy
|
|
81
|
+
if (strategy !== "guide" && strategy !== "trim" && strategy !== "tail") return null
|
|
82
|
+
if (!isAgentThread(body)) return pass(body)
|
|
83
|
+
const counts = { turns: turns.length, messagesIn: body.messages.length }
|
|
84
|
+
if (strategy === "guide") {
|
|
85
|
+
return { body: { ...body, system: withSystemPrompt(body.system, guide ? searchOnlyInstructions(guide) : null) }, threadKey, note: { action: "guide", ...counts } }
|
|
86
|
+
}
|
|
87
|
+
if (strategy === "trim") {
|
|
88
|
+
const messages = trimMessages(body.messages, { evidence: config.eval?.evidence ?? true })
|
|
89
|
+
return { body: { ...body, system: withSystemPrompt(body.system, trimInstructions(guide)), messages }, threadKey, note: { action: "trim", ...counts, messagesOut: messages.length } }
|
|
90
|
+
}
|
|
91
|
+
const based = { ...body, system: withSystemPrompt(body.system) }
|
|
92
|
+
const size = estimator(based, ratioOf(input, threadKey))
|
|
93
|
+
let from = 1
|
|
94
|
+
if (input.window) {
|
|
95
|
+
const target = targetSize(size, input.window)
|
|
96
|
+
from = turns.length - 1
|
|
97
|
+
while (from > 1 && size.of(tailMessages(body.messages, from - 1, guide)) <= target) from -= 1
|
|
98
|
+
}
|
|
99
|
+
const messages = tailMessages(body.messages, from, guide)
|
|
100
|
+
return { body: { ...based, messages }, threadKey, note: { action: "tail", keptTurns: turns.length - from, ...counts, messagesOut: messages.length, estimate: size.of(messages) } }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function strategize(input: RewriteInput): Rewrite {
|
|
104
|
+
const { body, threadId, config, store } = input
|
|
105
|
+
const sources = input.sources ?? {}
|
|
106
|
+
if (!threadId || !Array.isArray(body.messages) || body.messages.length === 0) return pass(body)
|
|
107
|
+
const threadKey = threadKeyOf(threadId, body.messages)
|
|
108
|
+
if (!threadKey) return pass(body)
|
|
109
|
+
const off = config.mode === "off"
|
|
110
|
+
const state = store.read(threadKey)
|
|
111
|
+
if (off && !state.host) return pass(body)
|
|
112
|
+
const { turns } = splitTurns(body.messages)
|
|
113
|
+
const guide = sources.guide ?? null
|
|
114
|
+
if (!off && attachHandoffs(body.messages, sources, state)) store.save(threadKey, state)
|
|
115
|
+
|
|
116
|
+
const forced = off ? null : evalStrategy(input, turns, threadKey, guide)
|
|
117
|
+
if (forced) return forced
|
|
118
|
+
|
|
119
|
+
const based = isAgentThread(body) ? { ...body, system: withSystemPrompt(body.system) } : body
|
|
120
|
+
if (body.messages.length < 2) return { body: based, threadKey, note: { action: "full", turns: turns.length } }
|
|
121
|
+
const handoffs = activeHandoffs(state.handoffs)
|
|
122
|
+
const recalls = wantsRecall(input) ? activeRecalls(state.recalls) : {}
|
|
123
|
+
const result = compact({
|
|
124
|
+
body: based,
|
|
125
|
+
config,
|
|
126
|
+
state,
|
|
127
|
+
window: input.window,
|
|
128
|
+
guide,
|
|
129
|
+
transcriptDir: sources.transcriptDir ?? null,
|
|
130
|
+
mayCompact: input.mayCompact && !off,
|
|
131
|
+
omittedBefore: input.omittedBefore,
|
|
132
|
+
decorate: (messages) => withRecall(withHandoffs(messages, handoffs), messageFingerprint, recalls),
|
|
133
|
+
})
|
|
134
|
+
if (result.stateChanged) store.save(threadKey, state)
|
|
135
|
+
return { body: result.messages === body.messages ? based : { ...based, messages: result.messages }, threadKey, note: result.note }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function withAttachedHandoffs(input: RewriteInput, result: Rewrite): Rewrite {
|
|
139
|
+
if (!result.threadKey) return result
|
|
140
|
+
const handoffs = activeHandoffs(input.store.read(result.threadKey).handoffs)
|
|
141
|
+
const messages = withHandoffs(result.body.messages, handoffs)
|
|
142
|
+
if (messages === result.body.messages) return result
|
|
143
|
+
const attached = Object.values(handoffs)
|
|
144
|
+
const note = { ...result.note, handoff: attached.reduce((sum, h) => sum + h.text.length, 0), handoffFrom: attached.map((h) => h.label).join(", ") }
|
|
145
|
+
return { ...result, body: { ...result.body, messages }, note }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function once(input: RewriteInput): Rewrite {
|
|
149
|
+
const result = withAttachedHandoffs(input, strategize(input))
|
|
150
|
+
return wantsRecall(input) ? applyRecall(input, result) : result
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function sentEstimate(input: RewriteInput, result: Rewrite): number {
|
|
154
|
+
return Math.round(sizeOf(result.body) * (result.threadKey ? ratioOf(input, result.threadKey) : DEFAULT_RATIO))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function overLimit(input: RewriteInput, result: Rewrite): boolean {
|
|
158
|
+
if (!input.window || !result.threadKey || input.mayCompact === false || result.note.action === "pass") return false
|
|
159
|
+
return sentEstimate(input, result) > input.config.compactAt * input.window
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function carried(from: Rewrite, to: Rewrite): Rewrite {
|
|
163
|
+
const actions = [...(from.note.actions ?? []), ...(to.note.actions ?? [])]
|
|
164
|
+
if (!from.note.compacted && actions.length === 0) return to
|
|
165
|
+
return { ...to, note: { ...to.note, compacted: Boolean(to.note.compacted || from.note.compacted), actions } }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function rewrite(input: RewriteInput, before?: Rewrite): Rewrite {
|
|
169
|
+
const result = before ? carried(before, once(input)) : once(input)
|
|
170
|
+
if (!overLimit(input, result)) return result
|
|
171
|
+
const recut = carried(result, once(input))
|
|
172
|
+
if (!overLimit(input, recut)) return { ...recut, note: { ...recut.note, fit: "recut" } }
|
|
173
|
+
const bare = carried(recut, once({ ...input, config: { ...input.config, eval: { ...input.config.eval, recall: false } } }))
|
|
174
|
+
return { ...bare, note: { ...bare.note, fit: overLimit(input, bare) ? "over" : "bare", fitEstimate: sentEstimate(input, bare) } }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function prefetchRecall(input: RewriteInput, rerank: Reranker): Promise<{ reranked: boolean; rerankError: null; recalled: number } | null> {
|
|
178
|
+
if (!wantsRecall(input)) return null
|
|
179
|
+
const recalled = await prefetchFor(input, strategize(input), rerank)
|
|
180
|
+
return recalled === null ? null : { reranked: rerank !== identityReranker, rerankError: null, recalled }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function rewriteAsync(input: RewriteInput, rerank: Reranker = identityReranker): Promise<Rewrite> {
|
|
184
|
+
if (!wantsRecall(input) || rerank === identityReranker) return rewrite(input)
|
|
185
|
+
const first = strategize(input)
|
|
186
|
+
const recalled = await prefetchFor(input, first, rerank)
|
|
187
|
+
const final = rewrite(input, first)
|
|
188
|
+
return recalled === null ? final : { ...final, note: { ...final.note, recalled } }
|
|
189
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface Handoff {
|
|
2
|
+
label: string
|
|
3
|
+
source: string
|
|
4
|
+
text: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface ThreadState {
|
|
8
|
+
handoffs?: Record<string, Handoff | null>
|
|
9
|
+
cut?: string
|
|
10
|
+
keep?: number
|
|
11
|
+
cleared?: number
|
|
12
|
+
stuck?: number
|
|
13
|
+
host?: boolean
|
|
14
|
+
pins?: string[]
|
|
15
|
+
ratio?: number
|
|
16
|
+
recalls?: Record<string, string>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ThreadStore {
|
|
20
|
+
read: (key: string) => ThreadState
|
|
21
|
+
save: (key: string, state: ThreadState) => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function memoryStore(initial: Record<string, ThreadState> = {}): ThreadStore {
|
|
25
|
+
const threads = new Map(Object.entries(initial).map(([key, state]) => [key, structuredClone(state)]))
|
|
26
|
+
return {
|
|
27
|
+
read: (key) => structuredClone(threads.get(key) ?? {}),
|
|
28
|
+
save: (key, state) => {
|
|
29
|
+
threads.set(key, structuredClone(state))
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function resetCut(state: ThreadState) {
|
|
35
|
+
delete state.cut
|
|
36
|
+
delete state.keep
|
|
37
|
+
delete state.cleared
|
|
38
|
+
delete state.stuck
|
|
39
|
+
delete state.host
|
|
40
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { blocks, type Message } from "../context/content"
|
|
2
|
+
import { splitTurns } from "../context/fold/turns"
|
|
3
|
+
import { type RecallChunk, recallChunks } from "../context/recall"
|
|
4
|
+
|
|
5
|
+
export interface Subagent {
|
|
6
|
+
description: string
|
|
7
|
+
messages: Message[]
|
|
8
|
+
toolUseId?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const closingTurn: Message = { role: "user", content: "" }
|
|
12
|
+
|
|
13
|
+
export function launchTurnOf(parentMessages: Message[], toolUseId: string | undefined): number {
|
|
14
|
+
if (!toolUseId) return 0
|
|
15
|
+
const index = splitTurns(parentMessages).turns.findIndex((turn) => turn.some((message) => message.role === "assistant" && blocks(message).some((b) => b.type === "tool_use" && b.id === toolUseId)))
|
|
16
|
+
return index + 1
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const subagentLabel = (description: string) => `subagent "${description}"`
|
|
20
|
+
|
|
21
|
+
export function subagentRecall(messages: Message[], description: string, parentTurn: number): RecallChunk[] {
|
|
22
|
+
const from = subagentLabel(description)
|
|
23
|
+
return recallChunks([...messages, closingTurn], new Map()).map((chunk) => ({ ...chunk, from, turn: parentTurn }))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function listSubagentRecall(parentMessages: Message[], subagents: Subagent[]): RecallChunk[] {
|
|
27
|
+
return subagents.flatMap((subagent) => subagentRecall(subagent.messages, subagent.description, launchTurnOf(parentMessages, subagent.toolUseId)))
|
|
28
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { blocks, type Message } from "../context/content"
|
|
2
|
+
import { isPrompt } from "../context/fold/turns"
|
|
3
|
+
import { searchGuide } from "../context/prompts"
|
|
4
|
+
import { renderIndex, renderMarkdown, renderSubagentMarkdown, renderSubagentXml, renderXml, type SubagentRecord, subagentFile, turnFile } from "../transcript/format"
|
|
5
|
+
import { parseTurns, type SessionEntry, type TranscriptFormat, type Turn } from "../transcript/parse"
|
|
6
|
+
import { launchTurnOf, type Subagent } from "./subagents"
|
|
7
|
+
|
|
8
|
+
export interface TranscriptOptions {
|
|
9
|
+
format?: TranscriptFormat
|
|
10
|
+
subagents?: Subagent[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const isNote = (message: Message) => message.role === "user" && !isPrompt(message) && !blocks(message).some((b) => b.type === "tool_result")
|
|
14
|
+
|
|
15
|
+
const turnsOf = (messages: Message[], sidechain: boolean | "any" = false): Turn[] =>
|
|
16
|
+
parseTurns(
|
|
17
|
+
messages.map((message): SessionEntry => ({ type: message.role, isMeta: isNote(message), message })),
|
|
18
|
+
sidechain,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
function subagentRecords(messages: Message[], turns: Turn[], subagents: Subagent[], format: TranscriptFormat): SubagentRecord[] {
|
|
22
|
+
const perTurn = new Map<number, number>()
|
|
23
|
+
return subagents.map((subagent) => {
|
|
24
|
+
const parentTurn = launchTurnOf(messages, subagent.toolUseId)
|
|
25
|
+
const k = (perTurn.get(parentTurn) ?? 0) + 1
|
|
26
|
+
perTurn.set(parentTurn, k)
|
|
27
|
+
const file = subagentFile(parentTurn, k, format)
|
|
28
|
+
const launcher = turns[parentTurn - 1]?.parts.find((part) => part.kind === "tool" && part.id === subagent.toolUseId)
|
|
29
|
+
if (launcher?.kind === "tool") launcher.record = file
|
|
30
|
+
return { run: { description: subagent.description }, turns: turnsOf(subagent.messages, "any"), parentTurn, file }
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function renderTranscriptFiles(messages: Message[], { format = "markdown", subagents = [] }: TranscriptOptions = {}): Record<string, string> {
|
|
35
|
+
const turns = turnsOf(messages)
|
|
36
|
+
const records = subagentRecords(messages, turns, subagents, format)
|
|
37
|
+
const files: Record<string, string> = {}
|
|
38
|
+
for (const turn of turns) files[turnFile(turn, format)] = format === "xml" ? renderXml(turn) : renderMarkdown(turn)
|
|
39
|
+
for (const record of records) files[record.file] = format === "xml" ? renderSubagentXml(record) : renderSubagentMarkdown(record)
|
|
40
|
+
files["index.md"] = renderIndex(turns, format, records)
|
|
41
|
+
return files
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const transcriptGuide = (dir: string, format: TranscriptFormat = "markdown") => searchGuide({ mode: format, dir, jsonl: "" })
|