@chroxify/eunoe 1.2.2 → 1.3.1
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 +150 -6
- package/dist/core/index.js +2354 -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 +16 -0
- package/dist/types/context/prompts.d.ts +24 -0
- package/dist/types/context/recall/index.d.ts +47 -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 +34 -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 +9 -0
- package/dist/types/core/rewrite.d.ts +63 -0
- package/dist/types/core/state.d.ts +25 -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 +8 -0
- package/dist/types/core/window.d.ts +8 -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 +63 -10
- package/src/context/fold/turns.ts +34 -2
- package/src/context/prompts.ts +4 -3
- package/src/context/recall/index.ts +8 -4
- package/src/context/recall/rerank.ts +0 -8
- package/src/{proxy → core}/compaction.ts +85 -28
- package/src/core/config.ts +31 -0
- package/src/core/fold.ts +71 -0
- package/src/core/forward.ts +194 -0
- package/src/{proxy → core}/handoff.ts +23 -23
- package/src/core/index.ts +14 -0
- package/src/core/recall.ts +80 -0
- package/src/core/rewrite.ts +201 -0
- package/src/core/state.ts +50 -0
- package/src/core/subagents.ts +28 -0
- package/src/core/transcript.ts +44 -0
- package/src/{proxy → core}/usage.ts +43 -1
- package/src/core/window.ts +69 -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/recall.ts +0 -58
- /package/src/{proxy → core}/estimate.ts +0 -0
- /package/src/{proxy → core}/fingerprint.ts +0 -0
package/src/core/fold.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Message } from "../context/content"
|
|
2
|
+
import { splitTurns, turnKeys } from "../context/fold/turns"
|
|
3
|
+
import { threadKeyOf } from "./rewrite"
|
|
4
|
+
import { resetCut, retireRecalls, type ThreadState, type ThreadStore } from "./state"
|
|
5
|
+
|
|
6
|
+
export interface FoldTarget {
|
|
7
|
+
store: ThreadStore
|
|
8
|
+
threadId: string
|
|
9
|
+
messages: Message[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface FoldOptions {
|
|
13
|
+
at?: number | "latest"
|
|
14
|
+
pin?: number[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface Folded {
|
|
18
|
+
threadKey: string
|
|
19
|
+
cutTurn: number | null
|
|
20
|
+
pinned: number[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function located({ store, threadId, messages }: FoldTarget) {
|
|
24
|
+
const threadKey = threadKeyOf(threadId, messages)
|
|
25
|
+
if (!threadKey) throw new RangeError("eunoe can only fold a thread that has at least one user turn")
|
|
26
|
+
const turns = splitTurns(messages).turns
|
|
27
|
+
return { threadKey, turns, keys: turnKeys(turns), state: store.read(threadKey) }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function checkedTurn(turn: number, turns: Message[][], from: number): number {
|
|
31
|
+
if (turns.length <= from) throw new RangeError(`this thread has ${turns.length} turn${turns.length === 1 ? "" : "s"}, so there is no turn ${turn} to fold at`)
|
|
32
|
+
if (!Number.isInteger(turn) || turn < from || turn >= turns.length) throw new RangeError(`turn ${turn} is outside turns ${from}..${turns.length - 1}`)
|
|
33
|
+
return turn
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function folded(threadKey: string, keys: string[], state: ThreadState): Folded {
|
|
37
|
+
const cutTurn = state.cut ? keys.indexOf(state.cut) : -1
|
|
38
|
+
const pins = new Set(state.pins)
|
|
39
|
+
return { threadKey, cutTurn: cutTurn > 0 ? cutTurn : null, pinned: keys.flatMap((key, i) => (pins.has(key) ? [i] : [])) }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function withPins(state: ThreadState, turns: Message[][], keys: string[], indices: number[], pinned: boolean) {
|
|
43
|
+
const pins = new Set(state.pins)
|
|
44
|
+
for (const index of indices) {
|
|
45
|
+
const key = keys[checkedTurn(index, turns, 0)]
|
|
46
|
+
if (pinned) pins.add(key)
|
|
47
|
+
else pins.delete(key)
|
|
48
|
+
}
|
|
49
|
+
if (pins.size > 0) state.pins = [...pins].sort()
|
|
50
|
+
else delete state.pins
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function foldThread(target: FoldTarget, { at = "latest", pin = [] }: FoldOptions = {}): Folded {
|
|
54
|
+
const { threadKey, turns, keys, state } = located(target)
|
|
55
|
+
const cut = checkedTurn(at === "latest" ? turns.length - 1 : at, turns, 1)
|
|
56
|
+
withPins(state, turns, keys, pin, true)
|
|
57
|
+
resetCut(state)
|
|
58
|
+
state.cut = keys[cut]
|
|
59
|
+
retireRecalls(state, turns.slice(0, cut))
|
|
60
|
+
state.keep = cut - 1
|
|
61
|
+
state.host = true
|
|
62
|
+
target.store.save(threadKey, state)
|
|
63
|
+
return folded(threadKey, keys, state)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function pinTurns(target: FoldTarget, indices: number[], pinned = true): Folded {
|
|
67
|
+
const { threadKey, turns, keys, state } = located(target)
|
|
68
|
+
withPins(state, turns, keys, indices, pinned)
|
|
69
|
+
target.store.save(threadKey, state)
|
|
70
|
+
return folded(threadKey, keys, state)
|
|
71
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
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
|
+
model: unknown
|
|
30
|
+
threadKey: string | null
|
|
31
|
+
bodyText: string | undefined
|
|
32
|
+
sentSize: number
|
|
33
|
+
givenSize: number
|
|
34
|
+
fixedSize: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const COUNT_TOKENS_ATTEMPTS = 3
|
|
38
|
+
|
|
39
|
+
const DROPPED_REQUEST_HEADERS = ["host", "content-length", "accept-encoding"]
|
|
40
|
+
|
|
41
|
+
const clipped = (error: string) => error.slice(0, 500)
|
|
42
|
+
|
|
43
|
+
export function claudeCodeSessionId(headers: Headers, body: Body): string | null {
|
|
44
|
+
const header = headers.get("x-claude-code-session-id")
|
|
45
|
+
if (header) return header
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(body.metadata?.user_id ?? "{}").session_id ?? null
|
|
48
|
+
} catch {
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const defaultThreadId = (request: Request, body: Body) => request.headers.get("x-thread-id") ?? claudeCodeSessionId(request.headers, body)
|
|
54
|
+
|
|
55
|
+
function forwardableHeaders(headers: Headers) {
|
|
56
|
+
const out = new Headers(headers)
|
|
57
|
+
out.delete("content-encoding")
|
|
58
|
+
out.delete("content-length")
|
|
59
|
+
return out
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function defaultPrepare(options: ForwarderOptions, upstream: string): NonNullable<ForwarderOptions["prepare"]> {
|
|
63
|
+
const threadIdOf = options.threadIdOf ?? defaultThreadId
|
|
64
|
+
const given = options.window
|
|
65
|
+
const lookup = typeof given === "function" ? given : createWindowLookup()
|
|
66
|
+
const windowFor: (model: unknown, headers: Headers) => Promise<number | null> = typeof given === "number" || given === null ? () => Promise.resolve(given) : (model, headers) => lookup(model, headers, upstream)
|
|
67
|
+
return async (request, body, mayCompact) => {
|
|
68
|
+
const threadId = threadIdOf(request, body)
|
|
69
|
+
const window = await windowFor(body.model, request.headers)
|
|
70
|
+
const sources = threadId ? options.sources?.(threadId, request, body) : undefined
|
|
71
|
+
return rewriteAsync({ body, threadId, config: options.config ?? CORE_DEFAULTS, store: options.store, sources, window, mayCompact }, options.reranker)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const passthrough = (bodyText: string | undefined): Outgoing => ({ note: { action: "pass" }, model: undefined, threadKey: null, bodyText, sentSize: 0, givenSize: 0, fixedSize: 0 })
|
|
76
|
+
|
|
77
|
+
async function outgoing(prepare: NonNullable<ForwarderOptions["prepare"]>, request: Request, givenText: string, mayCompact: boolean): Promise<Outgoing> {
|
|
78
|
+
try {
|
|
79
|
+
const given = JSON.parse(givenText) as Body
|
|
80
|
+
const result = await prepare(request, given, mayCompact)
|
|
81
|
+
const sentSize = sizeOf(result.body)
|
|
82
|
+
return {
|
|
83
|
+
note: result.note,
|
|
84
|
+
model: given.model,
|
|
85
|
+
threadKey: result.threadKey,
|
|
86
|
+
bodyText: result.body !== given ? JSON.stringify(result.body, wellFormed) : givenText,
|
|
87
|
+
sentSize,
|
|
88
|
+
givenSize: result.body === given ? sentSize : sizeOf(given),
|
|
89
|
+
fixedSize: sizeOf({ ...result.body, messages: [] }),
|
|
90
|
+
}
|
|
91
|
+
} catch (error) {
|
|
92
|
+
return { ...passthrough(givenText), note: { action: "error-passthrough", error: String(error) } }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function createSender(request: Request, target: string, headers: Headers, timeout: number | null, stall: { count: number }) {
|
|
97
|
+
return async (body: string | undefined): Promise<Response> => {
|
|
98
|
+
if (timeout === null) return fetch(target, { method: request.method, headers, body })
|
|
99
|
+
for (;;) {
|
|
100
|
+
try {
|
|
101
|
+
return await fetch(target, { method: request.method, headers, body, signal: AbortSignal.timeout(timeout) })
|
|
102
|
+
} catch (error) {
|
|
103
|
+
const timedOut = error instanceof Error && error.name === "TimeoutError"
|
|
104
|
+
if (!timedOut || stall.count + 1 >= COUNT_TOKENS_ATTEMPTS) throw error
|
|
105
|
+
stall.count += 1
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function delivered(answer: Response, out: Outgoing, metered: boolean, path: string, log: (entry: Record<string, unknown>) => void, store: ThreadStore): Response {
|
|
112
|
+
const { note, threadKey, sentSize, givenSize, fixedSize } = out
|
|
113
|
+
const response = new Response(answer.body, { status: answer.status, statusText: answer.statusText, headers: forwardableHeaders(answer.headers) })
|
|
114
|
+
if (note.action === "pass" || !metered) {
|
|
115
|
+
if (note.action !== "pass" || Object.keys(note).length > 1) log({ path, status: answer.status, ...note })
|
|
116
|
+
return response
|
|
117
|
+
}
|
|
118
|
+
return watchUsage(response, (usage) => {
|
|
119
|
+
log({
|
|
120
|
+
path,
|
|
121
|
+
status: answer.status,
|
|
122
|
+
thread: threadKey,
|
|
123
|
+
...note,
|
|
124
|
+
input: inputTokens(usage),
|
|
125
|
+
cacheRead: usage.cache_read_input_tokens ?? 0,
|
|
126
|
+
cacheWrite: usage.cache_creation_input_tokens ?? 0,
|
|
127
|
+
uncached: usage.input_tokens ?? 0,
|
|
128
|
+
charsIn: givenSize,
|
|
129
|
+
charsOut: sentSize,
|
|
130
|
+
charsFixed: fixedSize,
|
|
131
|
+
})
|
|
132
|
+
if (threadKey) recordUsage(store, threadKey, sentSize, usage)
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function createForwarder(options: ForwarderOptions): (request: Request) => Promise<Response> {
|
|
137
|
+
const upstream = options.upstream ?? "https://api.anthropic.com"
|
|
138
|
+
const prepare = options.prepare ?? defaultPrepare(options, upstream)
|
|
139
|
+
const timeout = options.countTokensTimeoutMs ?? 10_000
|
|
140
|
+
|
|
141
|
+
async function route(request: Request, began: number): Promise<Response> {
|
|
142
|
+
const stall = { count: 0 }
|
|
143
|
+
let prep = 0
|
|
144
|
+
let upload = 0
|
|
145
|
+
const log = (entry: Record<string, unknown>) => options.record?.({ ...entry, ms: Date.now() - began, prep, upload, ...(stall.count > 0 && { stalled: stall.count }) })
|
|
146
|
+
const url = new URL(request.url)
|
|
147
|
+
const headers = new Headers(request.headers)
|
|
148
|
+
for (const name of DROPPED_REQUEST_HEADERS) headers.delete(name)
|
|
149
|
+
|
|
150
|
+
let givenText: string | undefined
|
|
151
|
+
if (request.method !== "GET" && request.method !== "HEAD") givenText = await request.text()
|
|
152
|
+
upload = Date.now() - began
|
|
153
|
+
const isMessages = request.method === "POST" && url.pathname.startsWith("/v1/messages")
|
|
154
|
+
const isCountTokens = url.pathname.includes("count_tokens")
|
|
155
|
+
|
|
156
|
+
const build = () => (givenText && isMessages ? outgoing(prepare, request, givenText, !isCountTokens) : Promise.resolve(passthrough(givenText)))
|
|
157
|
+
const send = createSender(request, upstream + url.pathname + url.search, headers, isCountTokens ? timeout : null, stall)
|
|
158
|
+
let out = await build()
|
|
159
|
+
prep = Date.now() - began - upload
|
|
160
|
+
let answer = await send(out.bodyText)
|
|
161
|
+
const errorResponse = (text: string) => new Response(text, { status: answer.status, statusText: answer.statusText, headers: forwardableHeaders(answer.headers) })
|
|
162
|
+
|
|
163
|
+
if (answer.status >= 400 && out.note.action !== "pass") {
|
|
164
|
+
const errorText = await answer.text()
|
|
165
|
+
const logged = { path: url.pathname, status: answer.status, thread: out.threadKey, ...out.note }
|
|
166
|
+
const overflow = out.threadKey && !isCountTokens ? recordOverflow(options.store, out.threadKey, out.sentSize, errorText, out.model) : null
|
|
167
|
+
if (!overflow) {
|
|
168
|
+
log({ ...logged, error: clipped(errorText) })
|
|
169
|
+
return errorResponse(errorText)
|
|
170
|
+
}
|
|
171
|
+
log({ ...logged, overflow, error: clipped(errorText) })
|
|
172
|
+
out = await build()
|
|
173
|
+
out = { ...out, note: { ...out.note, healed: overflow } }
|
|
174
|
+
answer = await send(out.bodyText)
|
|
175
|
+
if (answer.status >= 400) {
|
|
176
|
+
const retryError = await answer.text()
|
|
177
|
+
log({ path: url.pathname, status: answer.status, thread: out.threadKey, ...out.note, error: clipped(retryError) })
|
|
178
|
+
return errorResponse(retryError)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return delivered(answer, out, isMessages && !isCountTokens, url.pathname, log, options.store)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return async (request) => {
|
|
186
|
+
const began = Date.now()
|
|
187
|
+
try {
|
|
188
|
+
return await route(request, began)
|
|
189
|
+
} catch (error) {
|
|
190
|
+
options.record?.({ path: new URL(request.url).pathname, status: 502, action: "upstream-unreachable", error: clipped(String(error)), ms: Date.now() - began })
|
|
191
|
+
return Response.json({ type: "error", error: { type: "api_error", message: `eunoe could not reach the API: ${String(error)}` } }, { status: 502 })
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -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"
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { type Body, blocks, type Message, resultText } from "../context/content"
|
|
2
|
+
import { isPrompt } from "../context/fold/turns"
|
|
3
|
+
import { livePrompt, RECALL_CHARS_TOTAL, type RecallChunk, type RecallOptions, type Reranker, recallChunks, recallFor, recallForWith, withRecall } from "../context/recall"
|
|
4
|
+
import { messageFingerprint } from "./fingerprint"
|
|
5
|
+
import { activeHandoffs, handoffChunks } from "./handoff"
|
|
6
|
+
import type { Rewrite, RewriteInput } from "./rewrite"
|
|
7
|
+
|
|
8
|
+
const closingTurn: Message = { role: "user", content: "" }
|
|
9
|
+
|
|
10
|
+
export const wantsRecall = (input: RewriteInput) => (input.config.eval?.recall ?? true) && input.config.mode !== "off"
|
|
11
|
+
|
|
12
|
+
export const activeRecalls = (recalls: Record<string, string> = {}) => Object.fromEntries(Object.entries(recalls).filter(([, text]) => text))
|
|
13
|
+
|
|
14
|
+
const touched = (input: RewriteInput, result: Rewrite) => result.threadKey !== null && !(result.body === input.body && result.note.action === "pass")
|
|
15
|
+
|
|
16
|
+
function droppedChunks(input: RewriteInput, result: Rewrite, threadKey: string): RecallChunk[] {
|
|
17
|
+
const sources = input.sources ?? {}
|
|
18
|
+
const kept = new Map<string, string>()
|
|
19
|
+
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
|
+
const own = recallChunks(input.body.messages, kept)
|
|
21
|
+
const session = own.length > 0 || !sources.readHistory ? own : recallChunks([...sources.readHistory(), closingTurn], kept)
|
|
22
|
+
const withSubagents = sources.listSubagentChunks ? [...session, ...sources.listSubagentChunks()] : session
|
|
23
|
+
return [...handoffChunks(activeHandoffs(input.store.read(threadKey).handoffs), sources), ...withSubagents]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function recallTarget(original: Body): { key: string; text: string } | null {
|
|
27
|
+
const live = livePrompt(original.messages)
|
|
28
|
+
return live ? { key: messageFingerprint(original.messages[live.index]), text: live.text } : null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function charsInUse(messages: Message[], recalls: Record<string, string>): number {
|
|
32
|
+
return messages.reduce((sum, message) => sum + (isPrompt(message) ? (recalls[messageFingerprint(message)]?.length ?? 0) : 0), 0)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const RECALL_SHARE = 0.25
|
|
36
|
+
|
|
37
|
+
const RECALL_FLOOR = 6_000
|
|
38
|
+
|
|
39
|
+
function withinTotal(input: RewriteInput, result: Rewrite, recalls: Record<string, string>): RecallOptions {
|
|
40
|
+
const options = input.config.eval?.recallOptions
|
|
41
|
+
const share = Math.max(RECALL_FLOOR, RECALL_SHARE * JSON.stringify(result.body.messages).length)
|
|
42
|
+
return { ...options, maxCharsTotal: Math.min(options?.maxCharsTotal ?? RECALL_CHARS_TOTAL, share), charsInUse: charsInUse(result.body.messages, recalls) }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function dropLiveRecall(input: RewriteInput, threadKey: string) {
|
|
46
|
+
const target = recallTarget(input.body)
|
|
47
|
+
if (!target) return
|
|
48
|
+
const state = input.store.read(threadKey)
|
|
49
|
+
if (state.recalls?.[target.key] === "") return
|
|
50
|
+
state.recalls = { ...state.recalls, [target.key]: "" }
|
|
51
|
+
input.store.save(threadKey, state)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function applyRecall(input: RewriteInput, result: Rewrite): Rewrite {
|
|
55
|
+
if (!touched(input, result) || !result.threadKey) return result
|
|
56
|
+
const state = input.store.read(result.threadKey)
|
|
57
|
+
state.recalls ??= {}
|
|
58
|
+
const target = recallTarget(input.body)
|
|
59
|
+
let note = result.note
|
|
60
|
+
if (target && !(target.key in state.recalls)) {
|
|
61
|
+
state.recalls[target.key] = recallFor(target.text, droppedChunks(input, result, result.threadKey), withinTotal(input, result, state.recalls)) ?? ""
|
|
62
|
+
input.store.save(result.threadKey, state)
|
|
63
|
+
note = { ...note, recalled: state.recalls[target.key].length }
|
|
64
|
+
}
|
|
65
|
+
const messages = withRecall(result.body.messages, messageFingerprint, activeRecalls(state.recalls))
|
|
66
|
+
return messages === result.body.messages ? { ...result, note } : { ...result, body: { ...result.body, messages }, note }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function prefetchFor(input: RewriteInput, result: Rewrite, rerank: Reranker): Promise<number | null> {
|
|
70
|
+
if (!touched(input, result) || !result.threadKey) return null
|
|
71
|
+
const target = recallTarget(input.body)
|
|
72
|
+
if (!target) return null
|
|
73
|
+
const state = input.store.read(result.threadKey)
|
|
74
|
+
state.recalls ??= {}
|
|
75
|
+
if (target.key in state.recalls) return null
|
|
76
|
+
const options = { candidates: input.config.eval?.rerank?.candidates, ...withinTotal(input, result, state.recalls) }
|
|
77
|
+
state.recalls[target.key] = (await recallForWith(target.text, droppedChunks(input, result, result.threadKey), rerank, options)) ?? ""
|
|
78
|
+
input.store.save(result.threadKey, state)
|
|
79
|
+
return state.recalls[target.key].length
|
|
80
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
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, dropLiveRecall, prefetchFor, wantsRecall } from "./recall"
|
|
12
|
+
import type { ThreadStore } from "./state"
|
|
13
|
+
import { limitKey } from "./usage"
|
|
14
|
+
|
|
15
|
+
export interface ThreadSources {
|
|
16
|
+
guide?: string | null
|
|
17
|
+
transcriptDir?: string | null
|
|
18
|
+
readHistory?: () => Message[]
|
|
19
|
+
listSubagentChunks?: () => RecallChunk[]
|
|
20
|
+
findHandoff?: (id: string) => { label: string; source: string } | null
|
|
21
|
+
readHandoff?: (source: string) => Message[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RewriteInput {
|
|
25
|
+
body: Body
|
|
26
|
+
threadId: string | null
|
|
27
|
+
config: CoreConfig
|
|
28
|
+
store: ThreadStore
|
|
29
|
+
sources?: ThreadSources
|
|
30
|
+
window: number | null
|
|
31
|
+
mayCompact: boolean
|
|
32
|
+
omittedBefore?: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type RewriteNote = {
|
|
36
|
+
action: "pass" | "full" | "compact" | "guide" | "trim" | "tail"
|
|
37
|
+
compacted?: boolean
|
|
38
|
+
actions?: string[]
|
|
39
|
+
cutTurn?: number | null
|
|
40
|
+
omitted?: number
|
|
41
|
+
folded?: number
|
|
42
|
+
pinned?: number
|
|
43
|
+
host?: boolean
|
|
44
|
+
keep?: number
|
|
45
|
+
cleared?: number
|
|
46
|
+
stuck?: StuckSizes
|
|
47
|
+
turns?: number
|
|
48
|
+
keptTurns?: number
|
|
49
|
+
messagesIn?: number
|
|
50
|
+
messagesOut?: number
|
|
51
|
+
estimate?: number
|
|
52
|
+
window?: number | null
|
|
53
|
+
fit?: "recut" | "bare" | "over"
|
|
54
|
+
fitEstimate?: number
|
|
55
|
+
recalled?: number
|
|
56
|
+
handoff?: number
|
|
57
|
+
handoffFrom?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface Rewrite {
|
|
61
|
+
body: Body
|
|
62
|
+
threadKey: string | null
|
|
63
|
+
note: RewriteNote
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const pass = (body: Body): Rewrite => ({ body, threadKey: null, note: { action: "pass" } })
|
|
67
|
+
|
|
68
|
+
const isAgentThread = (body: Body) => Array.isArray(body.tools) && body.tools.length > 0
|
|
69
|
+
|
|
70
|
+
const withSystemPrompt = (system: Body["system"], extra?: string | null) => appendSystem(extra ? appendSystem(system, extra) : system, SYSTEM_PROMPT)
|
|
71
|
+
|
|
72
|
+
const ratioOf = (input: RewriteInput, threadKey: string) => input.store.read(threadKey).ratio ?? DEFAULT_RATIO
|
|
73
|
+
|
|
74
|
+
export function threadKeyOf(threadId: string, messages: Message[]): string | null {
|
|
75
|
+
const first = splitTurns(messages).turns[0]
|
|
76
|
+
return first ? `${threadId}-${turnFingerprint(first)}` : null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function evalStrategy(input: RewriteInput, turns: Message[][], threadKey: string, guide: string | null): Rewrite | null {
|
|
80
|
+
const { body, config } = input
|
|
81
|
+
const strategy = config.eval?.strategy
|
|
82
|
+
if (strategy !== "guide" && strategy !== "trim" && strategy !== "tail") return null
|
|
83
|
+
if (!isAgentThread(body)) return pass(body)
|
|
84
|
+
const counts = { turns: turns.length, messagesIn: body.messages.length }
|
|
85
|
+
if (strategy === "guide") {
|
|
86
|
+
return { body: { ...body, system: withSystemPrompt(body.system, guide ? searchOnlyInstructions(guide) : null) }, threadKey, note: { action: "guide", ...counts } }
|
|
87
|
+
}
|
|
88
|
+
if (strategy === "trim") {
|
|
89
|
+
const messages = trimMessages(body.messages, { evidence: config.eval?.evidence ?? true })
|
|
90
|
+
return { body: { ...body, system: withSystemPrompt(body.system, trimInstructions(guide)), messages }, threadKey, note: { action: "trim", ...counts, messagesOut: messages.length } }
|
|
91
|
+
}
|
|
92
|
+
const based = { ...body, system: withSystemPrompt(body.system) }
|
|
93
|
+
const size = estimator(based, ratioOf(input, threadKey))
|
|
94
|
+
let from = 1
|
|
95
|
+
if (input.window) {
|
|
96
|
+
const target = targetSize(size, input.window)
|
|
97
|
+
from = turns.length - 1
|
|
98
|
+
while (from > 1 && size.of(tailMessages(body.messages, from - 1, guide)) <= target) from -= 1
|
|
99
|
+
}
|
|
100
|
+
const messages = tailMessages(body.messages, from, guide)
|
|
101
|
+
return { body: { ...based, messages }, threadKey, note: { action: "tail", keptTurns: turns.length - from, ...counts, messagesOut: messages.length, estimate: size.of(messages) } }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function strategize(input: RewriteInput): Rewrite {
|
|
105
|
+
const { body, threadId, config, store } = input
|
|
106
|
+
const sources = input.sources ?? {}
|
|
107
|
+
if (!threadId || !Array.isArray(body.messages) || body.messages.length === 0) return pass(body)
|
|
108
|
+
const threadKey = threadKeyOf(threadId, body.messages)
|
|
109
|
+
if (!threadKey) return pass(body)
|
|
110
|
+
const off = config.mode === "off"
|
|
111
|
+
const state = store.read(threadKey)
|
|
112
|
+
if (off && !state.host) return pass(body)
|
|
113
|
+
const { turns } = splitTurns(body.messages)
|
|
114
|
+
const guide = sources.guide ?? null
|
|
115
|
+
if (!off && attachHandoffs(body.messages, sources, state)) store.save(threadKey, state)
|
|
116
|
+
|
|
117
|
+
const forced = off ? null : evalStrategy(input, turns, threadKey, guide)
|
|
118
|
+
if (forced) return forced
|
|
119
|
+
|
|
120
|
+
const based = isAgentThread(body) ? { ...body, system: withSystemPrompt(body.system) } : body
|
|
121
|
+
if (body.messages.length < 2) return { body: based, threadKey, note: { action: "full", turns: turns.length } }
|
|
122
|
+
const handoffs = activeHandoffs(state.handoffs)
|
|
123
|
+
const recalling = wantsRecall(input)
|
|
124
|
+
const result = compact({
|
|
125
|
+
body: based,
|
|
126
|
+
config,
|
|
127
|
+
state,
|
|
128
|
+
window: input.window,
|
|
129
|
+
guide,
|
|
130
|
+
transcriptDir: sources.transcriptDir ?? null,
|
|
131
|
+
mayCompact: input.mayCompact && !off,
|
|
132
|
+
omittedBefore: input.omittedBefore,
|
|
133
|
+
decorate: (messages) => withRecall(withHandoffs(messages, handoffs), messageFingerprint, recalling ? activeRecalls(state.recalls) : {}),
|
|
134
|
+
})
|
|
135
|
+
if (result.stateChanged) store.save(threadKey, state)
|
|
136
|
+
return { body: result.messages === body.messages ? based : { ...based, messages: result.messages }, threadKey, note: result.note }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function withAttachedHandoffs(input: RewriteInput, result: Rewrite): Rewrite {
|
|
140
|
+
if (!result.threadKey) return result
|
|
141
|
+
const handoffs = activeHandoffs(input.store.read(result.threadKey).handoffs)
|
|
142
|
+
const messages = withHandoffs(result.body.messages, handoffs)
|
|
143
|
+
if (messages === result.body.messages) return result
|
|
144
|
+
const attached = Object.values(handoffs)
|
|
145
|
+
const note = { ...result.note, handoff: attached.reduce((sum, h) => sum + h.text.length, 0), handoffFrom: attached.map((h) => h.label).join(", ") }
|
|
146
|
+
return { ...result, body: { ...result.body, messages }, note }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function once(input: RewriteInput): Rewrite {
|
|
150
|
+
const result = withAttachedHandoffs(input, strategize(input))
|
|
151
|
+
return wantsRecall(input) ? applyRecall(input, result) : result
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function sentEstimate(input: RewriteInput, result: Rewrite): number {
|
|
155
|
+
return Math.round(sizeOf(result.body) * (result.threadKey ? ratioOf(input, result.threadKey) : DEFAULT_RATIO))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function overLimit(input: RewriteInput, result: Rewrite): boolean {
|
|
159
|
+
if (!input.window || !result.threadKey || input.mayCompact === false || result.note.action === "pass") return false
|
|
160
|
+
return sentEstimate(input, result) > input.config.compactAt * input.window
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function carried(from: Rewrite, to: Rewrite): Rewrite {
|
|
164
|
+
const actions = [...(from.note.actions ?? []), ...(to.note.actions ?? [])]
|
|
165
|
+
if (!from.note.compacted && actions.length === 0) return to
|
|
166
|
+
return { ...to, note: { ...to.note, compacted: Boolean(to.note.compacted || from.note.compacted), actions } }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function withLearnedLimit(input: RewriteInput): RewriteInput {
|
|
170
|
+
const threadKey = input.threadId && Array.isArray(input.body.messages) ? threadKeyOf(input.threadId, input.body.messages) : null
|
|
171
|
+
const limit = threadKey ? input.store.read(threadKey).limits?.[limitKey(input.body.model)] : undefined
|
|
172
|
+
if (!limit || (input.window !== null && input.window <= limit)) return input
|
|
173
|
+
return { ...input, window: limit }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function rewrite(given: RewriteInput, before?: Rewrite): Rewrite {
|
|
177
|
+
const input = withLearnedLimit(given)
|
|
178
|
+
const result = before ? carried(before, once(input)) : once(input)
|
|
179
|
+
if (!overLimit(input, result)) return result
|
|
180
|
+
const recut = carried(result, once(input))
|
|
181
|
+
if (!overLimit(input, recut)) return { ...recut, note: { ...recut.note, fit: "recut" } }
|
|
182
|
+
if (recut.threadKey && wantsRecall(input)) dropLiveRecall(input, recut.threadKey)
|
|
183
|
+
const bare = carried(recut, once(input))
|
|
184
|
+
return { ...bare, note: { ...bare.note, fit: overLimit(input, bare) ? "over" : "bare", fitEstimate: sentEstimate(input, bare) } }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function prefetchRecall(given: RewriteInput, rerank: Reranker): Promise<{ reranked: boolean; rerankError: null; recalled: number } | null> {
|
|
188
|
+
const input = withLearnedLimit(given)
|
|
189
|
+
if (!wantsRecall(input)) return null
|
|
190
|
+
const recalled = await prefetchFor(input, strategize(input), rerank)
|
|
191
|
+
return recalled === null ? null : { reranked: rerank !== identityReranker, rerankError: null, recalled }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function rewriteAsync(given: RewriteInput, rerank: Reranker = identityReranker): Promise<Rewrite> {
|
|
195
|
+
const input = withLearnedLimit(given)
|
|
196
|
+
if (!wantsRecall(input) || rerank === identityReranker) return rewrite(input)
|
|
197
|
+
const first = strategize(input)
|
|
198
|
+
const recalled = await prefetchFor(input, first, rerank)
|
|
199
|
+
const final = rewrite(input, first)
|
|
200
|
+
return recalled === null ? final : { ...final, note: { ...final.note, recalled } }
|
|
201
|
+
}
|