@chroxify/eunoe 1.2.1 → 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 +143 -5
- 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/serve.ts +2 -0
- 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 +259 -35
- package/src/context/recall/rerank.ts +0 -8
- package/src/{proxy → core}/compaction.ts +74 -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 +24 -137
- package/src/proxy/sessions.ts +1 -1
- package/src/proxy/sources.ts +35 -0
- package/src/proxy/state.ts +32 -21
- package/src/proxy/warm.ts +68 -0
- package/src/proxy/window.ts +3 -33
- package/src/transcript/entries.ts +114 -0
- package/src/transcript/format.ts +1 -2
- package/src/transcript/messages.ts +14 -11
- package/src/transcript/render-worker.ts +24 -0
- package/src/transcript/render.ts +12 -3
- package/src/transcript/search.ts +46 -5
- package/src/transcript/subagents.ts +55 -34
- /package/src/{proxy → core}/estimate.ts +0 -0
- /package/src/{proxy → core}/fingerprint.ts +0 -0
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
-
import { paths } from "../../config/paths"
|
|
3
1
|
import { rerankQuestion } from "../prompts"
|
|
4
2
|
import type { RecallChunk, Reranker } from "."
|
|
5
3
|
|
|
@@ -9,12 +7,6 @@ interface RerankAnswer {
|
|
|
9
7
|
|
|
10
8
|
const TIMEOUT_MS = 4_000
|
|
11
9
|
|
|
12
|
-
export function rerankKey(): string | null {
|
|
13
|
-
const env = process.env.TYPESAFE_API_KEY?.trim()
|
|
14
|
-
if (env) return env
|
|
15
|
-
return existsSync(paths.typesafeKey) ? readFileSync(paths.typesafeKey, "utf8").trim() : null
|
|
16
|
-
}
|
|
17
|
-
|
|
18
10
|
function rerankState(chunks: RecallChunk[], candidates: number[]): string {
|
|
19
11
|
return candidates
|
|
20
12
|
.map((index, n) => {
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import type
|
|
3
|
-
import { type
|
|
4
|
-
import { splitTurns, turnFingerprint } from "../context/fold/turns"
|
|
1
|
+
import { type Body, type Message, quickHash } from "../context/content"
|
|
2
|
+
import { type CompactOptions, capTurns, clearToolResults, compactMessages, countPinned, countToolResults, foldsOpening } from "../context/fold/strategies"
|
|
3
|
+
import { type ReduceOptions, reduceTurn, splitTurns, turnFingerprint } from "../context/fold/turns"
|
|
5
4
|
import { clearedOutput } from "../context/prompts"
|
|
5
|
+
import type { CoreConfig } from "./config"
|
|
6
6
|
import { DEFAULT_RATIO, type Estimator, estimator, targetSize } from "./estimate"
|
|
7
|
+
import type { RewriteNote } from "./rewrite"
|
|
7
8
|
import { resetCut, type ThreadState } from "./state"
|
|
8
9
|
|
|
9
10
|
export interface CompactInput {
|
|
10
11
|
body: Body
|
|
11
|
-
config:
|
|
12
|
+
config: CoreConfig
|
|
12
13
|
state: ThreadState
|
|
13
14
|
window: number | null
|
|
14
15
|
guide: string | null
|
|
@@ -19,10 +20,28 @@ export interface CompactInput {
|
|
|
19
20
|
reduce?: CompactOptions["reduce"]
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
const FOLDED_TURNS = 4096
|
|
24
|
+
|
|
25
|
+
const folded = new Map<string, Message[]>()
|
|
26
|
+
|
|
27
|
+
function reduceRemembered(turn: Message[], options: ReduceOptions): Message[] {
|
|
28
|
+
const key = `${options.evidence ? 1 : 0}:${quickHash(JSON.stringify(turn))}`
|
|
29
|
+
const known = folded.get(key)
|
|
30
|
+
if (known) {
|
|
31
|
+
folded.delete(key)
|
|
32
|
+
folded.set(key, known)
|
|
33
|
+
return known
|
|
34
|
+
}
|
|
35
|
+
const reduced = reduceTurn(turn, options)
|
|
36
|
+
folded.set(key, reduced)
|
|
37
|
+
if (folded.size > FOLDED_TURNS) folded.delete(folded.keys().next().value as string)
|
|
38
|
+
return reduced
|
|
39
|
+
}
|
|
40
|
+
|
|
22
41
|
export interface CompactResult {
|
|
23
42
|
messages: Message[]
|
|
24
43
|
stateChanged: boolean
|
|
25
|
-
note:
|
|
44
|
+
note: RewriteNote
|
|
26
45
|
}
|
|
27
46
|
|
|
28
47
|
interface Rolling {
|
|
@@ -32,13 +51,15 @@ interface Rolling {
|
|
|
32
51
|
|
|
33
52
|
const PROTECTED_OUTPUTS = 5
|
|
34
53
|
|
|
54
|
+
const STUCK_STEP = 0.1
|
|
55
|
+
|
|
35
56
|
function settledCut(turns: Message[][], index: number) {
|
|
36
57
|
let cut = index
|
|
37
58
|
while (cut > 1 && JSON.stringify(turns[cut][0].content).includes("[Request interrupted")) cut -= 1
|
|
38
59
|
return cut
|
|
39
60
|
}
|
|
40
61
|
|
|
41
|
-
function resolveCut(turns: Message[][], state: ThreadState, config:
|
|
62
|
+
function resolveCut(turns: Message[][], state: ThreadState, config: CoreConfig, rollingKeep: number | undefined) {
|
|
42
63
|
let cutIndex = state.cut ? turns.findIndex((turn) => turnFingerprint(turn) === state.cut) : -1
|
|
43
64
|
let stateChanged = false
|
|
44
65
|
if (state.cut && cutIndex === -1) {
|
|
@@ -71,17 +92,47 @@ function dueRoll(turns: Message[][], cutIndex: number, rolling: Rolling, size: E
|
|
|
71
92
|
return next >= 1 && next > cutIndex && backlog > rolling.budget ? next : null
|
|
72
93
|
}
|
|
73
94
|
|
|
74
|
-
function shrinkToTarget(over: () => boolean, step: () => string, actions: string[], rebuild: () => void) {
|
|
95
|
+
function shrinkToTarget(over: () => boolean, step: () => string, actions: string[], rebuild: () => void): boolean {
|
|
75
96
|
while (over()) {
|
|
76
97
|
const action = step()
|
|
77
98
|
actions.push(action)
|
|
78
|
-
if (action === "stuck") return
|
|
99
|
+
if (action === "stuck") return true
|
|
79
100
|
rebuild()
|
|
80
101
|
}
|
|
102
|
+
return false
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const holdsStuck = (state: ThreadState, estimate: number, window: number | null) => window !== null && state.stuck !== undefined && estimate <= state.stuck + STUCK_STEP * window
|
|
106
|
+
|
|
107
|
+
export type StuckSizes = ReturnType<typeof stuckSizes>
|
|
108
|
+
|
|
109
|
+
const headLength = (preamble: Message[], first: Message[], state: ThreadState) => preamble.length + 1 + (countPinned([first], new Set(state.pins)) > 0 ? first.length : 1)
|
|
110
|
+
|
|
111
|
+
function stuckSizes(messages: Message[], headEnd: number, liveFrom: number, size: Estimator) {
|
|
112
|
+
const of = (list: Message[]) => size.of(list) - size.fixed
|
|
113
|
+
return { fixed: size.fixed, head: of(messages.slice(0, headEnd)), folded: of(messages.slice(headEnd, liveFrom)), live: of(messages.slice(liveFrom)) }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function cutNote(state: ThreadState, turns: Message[][], cutIndex: number, configuredKeep: number, actions: string[]) {
|
|
117
|
+
const pins = new Set(state.pins)
|
|
118
|
+
const keptFrom = cutIndex > 0 ? Math.max(1, cutIndex - (state.keep ?? configuredKeep)) : 0
|
|
119
|
+
const omitted = cutIndex > 0 ? keptFrom - 1 - countPinned(turns.slice(1, keptFrom), pins) : 0
|
|
120
|
+
return {
|
|
121
|
+
action: cutIndex > 0 ? ("compact" as const) : ("full" as const),
|
|
122
|
+
compacted: actions.includes("cut") || actions.includes("roll"),
|
|
123
|
+
actions: actions.length > 0 ? actions : undefined,
|
|
124
|
+
cutTurn: cutIndex > 0 ? cutIndex : null,
|
|
125
|
+
omitted,
|
|
126
|
+
folded: cutIndex > 0 ? cutIndex - keptFrom - countPinned(turns.slice(keptFrom, cutIndex), pins) + (foldsOpening(turns[0], keptFrom) && countPinned([turns[0]], pins) === 0 ? 1 : 0) : 0,
|
|
127
|
+
pinned: cutIndex > 0 ? countPinned(turns.slice(0, cutIndex), pins) : 0,
|
|
128
|
+
host: state.host,
|
|
129
|
+
keep: state.keep,
|
|
130
|
+
cleared: state.cleared,
|
|
131
|
+
}
|
|
81
132
|
}
|
|
82
133
|
|
|
83
134
|
export function compact({ body, config, state, window, guide, transcriptDir, mayCompact, omittedBefore = 0, decorate = (m) => m, reduce }: CompactInput): CompactResult {
|
|
84
|
-
const { turns } = splitTurns(body.messages)
|
|
135
|
+
const { preamble, turns } = splitTurns(body.messages)
|
|
85
136
|
const rolling = config.eval?.rolling ?? (config.mode === "default" ? { keep: config.keep, budget: config.budget } : undefined)
|
|
86
137
|
const resolved = resolveCut(turns, state, config, rolling?.keep)
|
|
87
138
|
let cutIndex = resolved.cutIndex
|
|
@@ -89,7 +140,7 @@ export function compact({ body, config, state, window, guide, transcriptDir, may
|
|
|
89
140
|
|
|
90
141
|
const configuredKeep = config.keepTurns === "all" ? Number.MAX_SAFE_INTEGER : config.keepTurns
|
|
91
142
|
const ratio = state.ratio ?? DEFAULT_RATIO
|
|
92
|
-
const options = { guide, omittedBefore, evidence: config.eval?.evidence ?? true, reduce, capChars: config.turnCap === "off" ? undefined : config.turnCap / ratio }
|
|
143
|
+
const options = { guide, omittedBefore, evidence: config.eval?.evidence ?? true, reduce: reduce ?? reduceRemembered, capChars: config.turnCap === "off" ? undefined : config.turnCap / ratio, pinned: new Set(state.pins) }
|
|
93
144
|
const liveStart = (list: Message[]) =>
|
|
94
145
|
list.length -
|
|
95
146
|
splitTurns(list)
|
|
@@ -133,12 +184,16 @@ export function compact({ body, config, state, window, guide, transcriptDir, may
|
|
|
133
184
|
if (mayCompact && estimate > limit) {
|
|
134
185
|
const current = turns.length - 1
|
|
135
186
|
if (current > 0 && current > cutIndex) moveCut(current, "cut")
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
187
|
+
if (!holdsStuck(state, estimate, window)) {
|
|
188
|
+
const stuck = shrinkToTarget(
|
|
189
|
+
() => estimate > target,
|
|
190
|
+
() => shrinkOnce(state, cutIndex, configuredKeep, liveResults),
|
|
191
|
+
actions,
|
|
192
|
+
rebuild,
|
|
193
|
+
)
|
|
194
|
+
if (stuck) state.stuck = estimate
|
|
195
|
+
else delete state.stuck
|
|
196
|
+
}
|
|
142
197
|
stateChanged = true
|
|
143
198
|
}
|
|
144
199
|
|
|
@@ -146,13 +201,8 @@ export function compact({ body, config, state, window, guide, transcriptDir, may
|
|
|
146
201
|
messages,
|
|
147
202
|
stateChanged,
|
|
148
203
|
note: {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
actions: actions.length > 0 ? actions : undefined,
|
|
152
|
-
cutTurn: cutIndex > 0 ? cutIndex : null,
|
|
153
|
-
omitted: cutIndex > 0 ? Math.max(1, cutIndex - (state.keep ?? configuredKeep)) - 1 : 0,
|
|
154
|
-
keep: state.keep,
|
|
155
|
-
cleared: state.cleared,
|
|
204
|
+
...cutNote(state, turns, cutIndex, configuredKeep, actions),
|
|
205
|
+
stuck: state.stuck === undefined ? undefined : stuckSizes(messages, headLength(preamble, turns[0], state), liveStart(messages), size),
|
|
156
206
|
turns: turns.length,
|
|
157
207
|
messagesIn: body.messages.length,
|
|
158
208
|
messagesOut: messages.length,
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type Mode = "off" | "compact" | "default"
|
|
2
|
+
|
|
3
|
+
interface EvalConfig {
|
|
4
|
+
recallOptions?: { maxChars?: number; maxCharsMulti?: number; perQuery?: number }
|
|
5
|
+
strategy?: "tail" | "guide" | "trim"
|
|
6
|
+
rolling?: { keep: number; budget: number }
|
|
7
|
+
forceCut?: boolean
|
|
8
|
+
trustHeaders?: boolean
|
|
9
|
+
evidence?: boolean
|
|
10
|
+
recall?: boolean
|
|
11
|
+
rerank?: { model: string; candidates?: number; endpoint?: string }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CoreConfig {
|
|
15
|
+
mode: Mode
|
|
16
|
+
compactAt: number
|
|
17
|
+
keepTurns: number | "all"
|
|
18
|
+
keep: number
|
|
19
|
+
budget: number
|
|
20
|
+
turnCap: number | "off"
|
|
21
|
+
eval?: EvalConfig
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const CORE_DEFAULTS: CoreConfig = {
|
|
25
|
+
mode: "default",
|
|
26
|
+
compactAt: 0.9,
|
|
27
|
+
keepTurns: "all",
|
|
28
|
+
keep: 3,
|
|
29
|
+
budget: 100_000,
|
|
30
|
+
turnCap: 100_000,
|
|
31
|
+
}
|
package/src/core/fold.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { Message } from "../context/content"
|
|
2
|
+
import { splitTurns, turnFingerprint } from "../context/fold/turns"
|
|
3
|
+
import { threadKeyOf } from "./rewrite"
|
|
4
|
+
import { resetCut, 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
|
+
return { threadKey, turns: splitTurns(messages).turns, state: store.read(threadKey) }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function checkedTurn(turn: number, turns: Message[][], from: number): number {
|
|
30
|
+
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`)
|
|
31
|
+
if (!Number.isInteger(turn) || turn < from || turn >= turns.length) throw new RangeError(`turn ${turn} is outside turns ${from}..${turns.length - 1}`)
|
|
32
|
+
return turn
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function folded(threadKey: string, turns: Message[][], state: ThreadState): Folded {
|
|
36
|
+
const cutTurn = state.cut ? turns.findIndex((turn) => turnFingerprint(turn) === state.cut) : -1
|
|
37
|
+
const pins = new Set(state.pins)
|
|
38
|
+
return { threadKey, cutTurn: cutTurn > 0 ? cutTurn : null, pinned: turns.flatMap((turn, i) => (pins.has(turnFingerprint(turn)) ? [i] : [])) }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function withPins(state: ThreadState, turns: Message[][], indices: number[], pinned: boolean) {
|
|
42
|
+
const pins = new Set(state.pins)
|
|
43
|
+
for (const index of indices) {
|
|
44
|
+
const fingerprint = turnFingerprint(turns[checkedTurn(index, turns, 0)])
|
|
45
|
+
if (pinned) pins.add(fingerprint)
|
|
46
|
+
else pins.delete(fingerprint)
|
|
47
|
+
}
|
|
48
|
+
if (pins.size > 0) state.pins = [...pins].sort()
|
|
49
|
+
else delete state.pins
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function foldThread(target: FoldTarget, { at = "latest", pin = [] }: FoldOptions = {}): Folded {
|
|
53
|
+
const { threadKey, turns, state } = located(target)
|
|
54
|
+
const cut = checkedTurn(at === "latest" ? turns.length - 1 : at, turns, 1)
|
|
55
|
+
withPins(state, turns, pin, true)
|
|
56
|
+
resetCut(state)
|
|
57
|
+
state.cut = turnFingerprint(turns[cut])
|
|
58
|
+
state.keep = cut - 1
|
|
59
|
+
state.host = true
|
|
60
|
+
target.store.save(threadKey, state)
|
|
61
|
+
return folded(threadKey, turns, state)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function pinTurns(target: FoldTarget, indices: number[], pinned = true): Folded {
|
|
65
|
+
const { threadKey, turns, state } = located(target)
|
|
66
|
+
withPins(state, turns, indices, pinned)
|
|
67
|
+
target.store.save(threadKey, state)
|
|
68
|
+
return folded(threadKey, turns, state)
|
|
69
|
+
}
|
|
@@ -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
|
}
|