@chroxify/eunoe 1.3.0 → 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.
@@ -8,7 +8,7 @@ export interface CompactOptions extends ReduceOptions {
8
8
  reduce?: (turn: Message[], options: ReduceOptions) => Message[];
9
9
  pinned?: ReadonlySet<string>;
10
10
  }
11
- export declare const countPinned: (turns: Message[][], pinned: ReadonlySet<string>) => number;
11
+ export declare const countPinned: (keys: string[], pinned: ReadonlySet<string>) => number;
12
12
  export declare function trimMessages(messages: Message[], options?: ReduceOptions): Message[];
13
13
  export declare const CARRIED_CHARS = 80000;
14
14
  export declare const foldsOpening: (opening: Message[], keptFrom: number) => boolean;
@@ -9,6 +9,8 @@ export declare function splitTurns(messages: Message[]): {
9
9
  turns: Message[][];
10
10
  };
11
11
  export declare function turnFingerprint(turn: Message[]): string;
12
+ export declare function turnKeys(turns: Message[][]): string[];
13
+ export declare const fingerprintOfKey: (key: string) => string;
12
14
  export declare function isHarness(message: Message): boolean;
13
15
  export declare function hasContent(message: Message): boolean;
14
16
  export declare function reduceTurn(turn: Message[], options?: ReduceOptions): Message[];
@@ -12,6 +12,8 @@ export interface RecallOptions {
12
12
  minTerms?: number;
13
13
  maxChars?: number;
14
14
  maxCharsMulti?: number;
15
+ maxCharsTotal?: number;
16
+ charsInUse?: number;
15
17
  narrative?: boolean;
16
18
  callWeight?: number;
17
19
  salientExcerpt?: boolean;
@@ -22,6 +24,7 @@ export interface RecallPick {
22
24
  query: Set<string>;
23
25
  }
24
26
  export type Reranker = (query: string, chunks: RecallChunk[], candidates: number[]) => Promise<number[]>;
27
+ export declare const RECALL_CHARS_TOTAL = 30000;
25
28
  export declare function terms(text: string): string[];
26
29
  export declare function recallChunks(messages: Message[], keptIds: Map<string, string>, options?: RecallOptions): RecallChunk[];
27
30
  interface RankedChunk {
@@ -3,6 +3,7 @@ interface EvalConfig {
3
3
  recallOptions?: {
4
4
  maxChars?: number;
5
5
  maxCharsMulti?: number;
6
+ maxCharsTotal?: number;
6
7
  perQuery?: number;
7
8
  };
8
9
  strategy?: "tail" | "guide" | "trim";
@@ -4,5 +4,6 @@ export declare const wantsRecall: (input: RewriteInput) => boolean;
4
4
  export declare const activeRecalls: (recalls?: Record<string, string>) => {
5
5
  [k: string]: string;
6
6
  };
7
+ export declare function dropLiveRecall(input: RewriteInput, threadKey: string): void;
7
8
  export declare function applyRecall(input: RewriteInput, result: Rewrite): Rewrite;
8
9
  export declare function prefetchFor(input: RewriteInput, result: Rewrite, rerank: Reranker): Promise<number | null>;
@@ -54,10 +54,10 @@ export interface Rewrite {
54
54
  note: RewriteNote;
55
55
  }
56
56
  export declare function threadKeyOf(threadId: string, messages: Message[]): string | null;
57
- export declare function rewrite(input: RewriteInput, before?: Rewrite): Rewrite;
58
- export declare function prefetchRecall(input: RewriteInput, rerank: Reranker): Promise<{
57
+ export declare function rewrite(given: RewriteInput, before?: Rewrite): Rewrite;
58
+ export declare function prefetchRecall(given: RewriteInput, rerank: Reranker): Promise<{
59
59
  reranked: boolean;
60
60
  rerankError: null;
61
61
  recalled: number;
62
62
  } | null>;
63
- export declare function rewriteAsync(input: RewriteInput, rerank?: Reranker): Promise<Rewrite>;
63
+ export declare function rewriteAsync(given: RewriteInput, rerank?: Reranker): Promise<Rewrite>;
@@ -1,3 +1,4 @@
1
+ import type { Message } from "../context/content.js";
1
2
  export interface Handoff {
2
3
  label: string;
3
4
  source: string;
@@ -12,6 +13,7 @@ export interface ThreadState {
12
13
  host?: boolean;
13
14
  pins?: string[];
14
15
  ratio?: number;
16
+ limits?: Record<string, number>;
15
17
  recalls?: Record<string, string>;
16
18
  }
17
19
  export interface ThreadStore {
@@ -19,4 +21,5 @@ export interface ThreadStore {
19
21
  save: (key: string, state: ThreadState) => void;
20
22
  }
21
23
  export declare function memoryStore(initial?: Record<string, ThreadState>): ThreadStore;
24
+ export declare function retireRecalls(state: ThreadState, folded: Message[][]): void;
22
25
  export declare function resetCut(state: ThreadState): void;
@@ -4,4 +4,5 @@ export type Usage = Record<string, number>;
4
4
  export declare function watchUsage(response: Response, onUsage: (usage: Usage) => void): Response;
5
5
  export declare function inputTokens(usage: Usage): number;
6
6
  export declare function recordUsage(store: ThreadStore, threadKey: string, sent: Body | number, usage: Usage): boolean;
7
- export declare function recordOverflow(store: ThreadStore, threadKey: string, sent: Body | number, errorText: string): number | null;
7
+ export declare const limitKey: (model: unknown) => string;
8
+ export declare function recordOverflow(store: ThreadStore, threadKey: string, sent: Body | number, errorText: string, model?: unknown): number | null;
@@ -1,5 +1,8 @@
1
1
  export type WindowLookup = (model: unknown, requestHeaders: Headers, upstream?: string) => Promise<number | null>;
2
- export declare function createWindowLookup({ known, onLearn }?: {
2
+ export interface WindowLookupOptions {
3
3
  known?: Map<string, number>;
4
4
  onLearn?: (windows: Map<string, number>) => void;
5
- }): WindowLookup;
5
+ timeoutMs?: number;
6
+ retryAfterMs?: number;
7
+ }
8
+ export declare function createWindowLookup({ known, onLearn, timeoutMs, retryAfterMs }?: WindowLookupOptions): WindowLookup;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chroxify/eunoe",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "Context management for Claude Code that never forgets what matters.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  import { type Block, type Body, blocks, clean, type Message, truncate, withoutCacheControl } from "../content"
2
2
  import { clearedInput, compactionNotice, truncationNotice } from "../prompts"
3
- import { hasContent, isHarness, isStaleNote, type ReduceOptions, reduceTurn, splitTurns, turnFingerprint } from "./turns"
3
+ import { fingerprintOfKey, hasContent, isHarness, isStaleNote, type ReduceOptions, reduceTurn, splitTurns, turnFingerprint, turnKeys } from "./turns"
4
4
 
5
5
  export interface CompactOptions extends ReduceOptions {
6
6
  keepTurns: number
@@ -11,12 +11,19 @@ export interface CompactOptions extends ReduceOptions {
11
11
  pinned?: ReadonlySet<string>
12
12
  }
13
13
 
14
- const pinnedBy =
15
- ({ pinned }: CompactOptions) =>
16
- (turn: Message[]) =>
17
- pinned !== undefined && pinned.size > 0 && pinned.has(turnFingerprint(turn))
14
+ function pinnedAt(turns: Message[][], { pinned }: CompactOptions): (index: number) => boolean {
15
+ if (pinned === undefined || pinned.size === 0) return () => false
16
+ const keys = turnKeys(turns)
17
+ return (index) => pinned.has(keys[index])
18
+ }
19
+
20
+ function pinnedPrompt({ pinned }: CompactOptions): (turn: Message[]) => boolean {
21
+ if (pinned === undefined || pinned.size === 0) return () => false
22
+ const fingerprints = new Set([...pinned].map(fingerprintOfKey))
23
+ return (turn) => fingerprints.has(turnFingerprint(turn))
24
+ }
18
25
 
19
- export const countPinned = (turns: Message[][], pinned: ReadonlySet<string>) => (pinned.size > 0 ? turns.filter((turn) => pinned.has(turnFingerprint(turn))).length : 0)
26
+ export const countPinned = (keys: string[], pinned: ReadonlySet<string>) => (pinned.size > 0 ? keys.filter((key) => pinned.has(key)).length : 0)
20
27
 
21
28
  export function trimMessages(messages: Message[], options: ReduceOptions = {}): Message[] {
22
29
  const { preamble, turns } = splitTurns(messages)
@@ -50,18 +57,19 @@ export function compactMessages(messages: Message[], cutIndex: number, options:
50
57
  const { preamble, turns } = splitTurns(messages)
51
58
  if (cutIndex <= 0 || cutIndex >= turns.length) return messages
52
59
  const reduce = options.reduce ?? reduceTurn
53
- const isPinned = pinnedBy(options)
60
+ const isPinned = pinnedAt(turns, options)
61
+ const indexed = turns.map((turn, index) => ({ turn, pinned: isPinned(index) }))
54
62
  const keptFrom = Math.max(1, cutIndex - options.keepTurns)
55
63
  const [first, ...rest] = turns[0]
56
- const openingPinned = isPinned(turns[0])
64
+ const openingPinned = indexed[0].pinned
57
65
  const openingFolded = !openingPinned && foldsOpening(turns[0], keptFrom)
58
- const dropped = turns.slice(1, keptFrom)
59
- const whole = dropped.filter(isPinned)
60
- const pinned = whole.length + turns.slice(keptFrom, cutIndex).filter(isPinned).length + (openingPinned ? 1 : 0)
66
+ const dropped = indexed.slice(1, keptFrom)
67
+ const whole = dropped.filter((t) => t.pinned).map((t) => t.turn)
68
+ const pinned = whole.length + indexed.slice(keptFrom, cutIndex).filter((t) => t.pinned).length + (openingPinned ? 1 : 0)
61
69
  const kept = cutIndex - keptFrom + (openingFolded ? 1 : 0)
62
70
  const notice = compactionNotice({ omitted: dropped.length - whole.length + (options.omittedBefore ?? 0), kept, pinned, guide: options.guide })
63
- const removed = [...(openingPinned || openingFolded ? [] : rest), ...dropped.filter((turn) => !isPinned(turn)).flat()]
64
- const shown = (turn: Message[]) => (isPinned(turn) ? turn.map(clean) : reduce(turn, options))
71
+ const removed = [...(openingPinned || openingFolded ? [] : rest), ...dropped.filter((t) => !t.pinned).flatMap((t) => t.turn)]
72
+ const shown = (t: { turn: Message[]; pinned: boolean }) => (t.pinned ? t.turn.map(clean) : reduce(t.turn, options))
65
73
  return [
66
74
  ...preamble,
67
75
  first,
@@ -69,7 +77,7 @@ export function compactMessages(messages: Message[], cutIndex: number, options:
69
77
  { role: "user", content: [{ type: "text", text: notice }, ...carriedHarness(removed)] },
70
78
  ...(openingFolded ? reduce(turns[0], options).slice(1) : []),
71
79
  ...whole.flatMap((turn) => turn.map(clean)),
72
- ...turns.slice(keptFrom, cutIndex).flatMap(shown),
80
+ ...indexed.slice(keptFrom, cutIndex).flatMap(shown),
73
81
  ...turns.slice(cutIndex).flat(),
74
82
  ]
75
83
  }
@@ -80,7 +88,7 @@ export function capTurns(messages: Message[], options: CompactOptions): Message[
80
88
  const { preamble, turns } = splitTurns(messages)
81
89
  if (turns.length < 3) return messages
82
90
  const live = turns.length - 1
83
- const isPinned = pinnedBy(options)
91
+ const isPinned = pinnedPrompt(options)
84
92
  const over = (turn: Message[], i: number) => i > 0 && i < live && !isPinned(turn) && JSON.stringify(turn).length > capChars
85
93
  if (!turns.some(over)) return messages
86
94
  const reduce = options.reduce ?? reduceTurn
@@ -37,6 +37,18 @@ export function turnFingerprint(turn: Message[]): string {
37
37
  return shortHash(JSON.stringify(blocks(turn[0]).map(withoutCacheControl)), 24)
38
38
  }
39
39
 
40
+ export function turnKeys(turns: Message[][]): string[] {
41
+ const copies = new Map<string, number>()
42
+ return turns.map((turn) => {
43
+ const fingerprint = turnFingerprint(turn)
44
+ const earlier = copies.get(fingerprint) ?? 0
45
+ copies.set(fingerprint, earlier + 1)
46
+ return earlier === 0 ? fingerprint : `${fingerprint}~${earlier}`
47
+ })
48
+ }
49
+
50
+ export const fingerprintOfKey = (key: string) => key.split("~")[0]
51
+
40
52
  export function isHarness(message: Message) {
41
53
  return message.role === "system"
42
54
  }
@@ -17,6 +17,8 @@ export interface RecallOptions {
17
17
  minTerms?: number
18
18
  maxChars?: number
19
19
  maxCharsMulti?: number
20
+ maxCharsTotal?: number
21
+ charsInUse?: number
20
22
  narrative?: boolean
21
23
  callWeight?: number
22
24
  salientExcerpt?: boolean
@@ -30,6 +32,8 @@ export interface RecallPick {
30
32
 
31
33
  export type Reranker = (query: string, chunks: RecallChunk[], candidates: number[]) => Promise<number[]>
32
34
 
35
+ export const RECALL_CHARS_TOTAL = 30_000
36
+
33
37
  const SHOWN_LINES = 8
34
38
  const BM25_K1 = 1.2
35
39
  const BM25_B = 0.75
@@ -388,7 +392,8 @@ export async function pickRecallWith(prompt: string, chunks: RecallChunk[], rera
388
392
 
389
393
  export function renderRecall(prompt: string, chunks: RecallChunk[], picks: RecallPick[], options: RecallOptions = {}): string | null {
390
394
  if (picks.length === 0) return null
391
- const budget = queriesOf(prompt).length > 1 ? (options.maxCharsMulti ?? 30_000) : (options.maxChars ?? 18_000)
395
+ const own = queriesOf(prompt).length > 1 ? (options.maxCharsMulti ?? 30_000) : (options.maxChars ?? 18_000)
396
+ const budget = Math.min(own, (options.maxCharsTotal ?? RECALL_CHARS_TOTAL) - (options.charsInUse ?? 0))
392
397
  const entries: string[] = []
393
398
  let used = 0
394
399
  for (const { index, query } of [...picks].sort((a, b) => chunks[a.index].turn - chunks[b.index].turn || a.index - b.index)) {
@@ -1,11 +1,11 @@
1
1
  import { type Body, type Message, quickHash } from "../context/content"
2
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"
3
+ import { type ReduceOptions, reduceTurn, splitTurns, turnKeys } from "../context/fold/turns"
4
4
  import { clearedOutput } from "../context/prompts"
5
5
  import type { CoreConfig } from "./config"
6
6
  import { DEFAULT_RATIO, type Estimator, estimator, targetSize } from "./estimate"
7
7
  import type { RewriteNote } from "./rewrite"
8
- import { resetCut, type ThreadState } from "./state"
8
+ import { resetCut, retireRecalls, type ThreadState } from "./state"
9
9
 
10
10
  export interface CompactInput {
11
11
  body: Body
@@ -51,16 +51,14 @@ interface Rolling {
51
51
 
52
52
  const PROTECTED_OUTPUTS = 5
53
53
 
54
- const STUCK_STEP = 0.1
55
-
56
54
  function settledCut(turns: Message[][], index: number) {
57
55
  let cut = index
58
56
  while (cut > 1 && JSON.stringify(turns[cut][0].content).includes("[Request interrupted")) cut -= 1
59
57
  return cut
60
58
  }
61
59
 
62
- function resolveCut(turns: Message[][], state: ThreadState, config: CoreConfig, rollingKeep: number | undefined) {
63
- let cutIndex = state.cut ? turns.findIndex((turn) => turnFingerprint(turn) === state.cut) : -1
60
+ function resolveCut(turns: Message[][], keys: string[], state: ThreadState, config: CoreConfig, rollingKeep: number | undefined) {
61
+ let cutIndex = state.cut ? keys.indexOf(state.cut) : -1
64
62
  let stateChanged = false
65
63
  if (state.cut && cutIndex === -1) {
66
64
  resetCut(state)
@@ -68,7 +66,8 @@ function resolveCut(turns: Message[][], state: ThreadState, config: CoreConfig,
68
66
  }
69
67
  if (cutIndex <= 0 && config.eval?.forceCut && turns.length >= 3) {
70
68
  cutIndex = settledCut(turns, Math.max(1, turns.length - 1 - (rollingKeep ?? 1)))
71
- state.cut = turnFingerprint(turns[cutIndex])
69
+ state.cut = keys[cutIndex]
70
+ retireRecalls(state, turns.slice(0, cutIndex))
72
71
  stateChanged = true
73
72
  }
74
73
  return { cutIndex, stateChanged }
@@ -92,39 +91,69 @@ function dueRoll(turns: Message[][], cutIndex: number, rolling: Rolling, size: E
92
91
  return next >= 1 && next > cutIndex && backlog > rolling.budget ? next : null
93
92
  }
94
93
 
95
- function shrinkToTarget(over: () => boolean, step: () => string, actions: string[], rebuild: () => void): boolean {
94
+ function shrinkToTarget(over: () => boolean, step: () => string, actions: string[], rebuild: () => void) {
96
95
  while (over()) {
97
96
  const action = step()
98
97
  actions.push(action)
99
- if (action === "stuck") return true
98
+ if (action === "stuck") return
100
99
  rebuild()
101
100
  }
102
- return false
103
101
  }
104
102
 
105
- const holdsStuck = (state: ThreadState, estimate: number, window: number | null) => window !== null && state.stuck !== undefined && estimate <= state.stuck + STUCK_STEP * window
103
+ interface Shrink {
104
+ estimate: () => number
105
+ step: () => string
106
+ rebuild: () => void
107
+ target: number
108
+ limit: number
109
+ window: number | null
110
+ }
111
+
112
+ function shrinkOrHold(state: ThreadState, actions: string[], shrink: Shrink) {
113
+ const overWindow = (tokens: number) => shrink.window !== null && tokens > shrink.window
114
+ const held = state.stuck === undefined ? null : { keep: state.keep, cleared: state.cleared, actions: actions.length, overWindow: overWindow(shrink.estimate()) }
115
+ shrinkToTarget(() => shrink.estimate() > shrink.target, shrink.step, actions, shrink.rebuild)
116
+ const helped = (estimate: number) => estimate <= shrink.limit || (held?.overWindow === true && !overWindow(estimate))
117
+ if (held && !helped(shrink.estimate())) {
118
+ restoreShrink(state, held)
119
+ actions.length = held.actions
120
+ shrink.rebuild()
121
+ }
122
+ if (shrink.estimate() > shrink.limit) state.stuck = shrink.estimate()
123
+ else {
124
+ delete state.stuck
125
+ if (actions.at(-1) === "stuck") actions.pop()
126
+ }
127
+ }
128
+
129
+ function restoreShrink(state: ThreadState, held: Pick<ThreadState, "keep" | "cleared">) {
130
+ if (held.keep === undefined) delete state.keep
131
+ else state.keep = held.keep
132
+ if (held.cleared === undefined) delete state.cleared
133
+ else state.cleared = held.cleared
134
+ }
106
135
 
107
136
  export type StuckSizes = ReturnType<typeof stuckSizes>
108
137
 
109
- const headLength = (preamble: Message[], first: Message[], state: ThreadState) => preamble.length + 1 + (countPinned([first], new Set(state.pins)) > 0 ? first.length : 1)
138
+ const headLength = (preamble: Message[], first: Message[], firstKey: string, state: ThreadState) => preamble.length + 1 + (countPinned([firstKey], new Set(state.pins)) > 0 ? first.length : 1)
110
139
 
111
140
  function stuckSizes(messages: Message[], headEnd: number, liveFrom: number, size: Estimator) {
112
141
  const of = (list: Message[]) => size.of(list) - size.fixed
113
142
  return { fixed: size.fixed, head: of(messages.slice(0, headEnd)), folded: of(messages.slice(headEnd, liveFrom)), live: of(messages.slice(liveFrom)) }
114
143
  }
115
144
 
116
- function cutNote(state: ThreadState, turns: Message[][], cutIndex: number, configuredKeep: number, actions: string[]) {
145
+ function cutNote(state: ThreadState, turns: Message[][], keys: string[], cutIndex: number, configuredKeep: number, actions: string[]) {
117
146
  const pins = new Set(state.pins)
118
147
  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
148
+ const omitted = cutIndex > 0 ? keptFrom - 1 - countPinned(keys.slice(1, keptFrom), pins) : 0
120
149
  return {
121
150
  action: cutIndex > 0 ? ("compact" as const) : ("full" as const),
122
151
  compacted: actions.includes("cut") || actions.includes("roll"),
123
152
  actions: actions.length > 0 ? actions : undefined,
124
153
  cutTurn: cutIndex > 0 ? cutIndex : null,
125
154
  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,
155
+ folded: cutIndex > 0 ? cutIndex - keptFrom - countPinned(keys.slice(keptFrom, cutIndex), pins) + (foldsOpening(turns[0], keptFrom) && countPinned([keys[0]], pins) === 0 ? 1 : 0) : 0,
156
+ pinned: cutIndex > 0 ? countPinned(keys.slice(0, cutIndex), pins) : 0,
128
157
  host: state.host,
129
158
  keep: state.keep,
130
159
  cleared: state.cleared,
@@ -134,7 +163,8 @@ function cutNote(state: ThreadState, turns: Message[][], cutIndex: number, confi
134
163
  export function compact({ body, config, state, window, guide, transcriptDir, mayCompact, omittedBefore = 0, decorate = (m) => m, reduce }: CompactInput): CompactResult {
135
164
  const { preamble, turns } = splitTurns(body.messages)
136
165
  const rolling = config.eval?.rolling ?? (config.mode === "default" ? { keep: config.keep, budget: config.budget } : undefined)
137
- const resolved = resolveCut(turns, state, config, rolling?.keep)
166
+ const keys = turnKeys(turns)
167
+ const resolved = resolveCut(turns, keys, state, config, rolling?.keep)
138
168
  let cutIndex = resolved.cutIndex
139
169
  let stateChanged = resolved.stateChanged
140
170
 
@@ -170,7 +200,8 @@ export function compact({ body, config, state, window, guide, transcriptDir, may
170
200
  const moveCut = (index: number, action: string) => {
171
201
  cutIndex = index
172
202
  resetCut(state)
173
- state.cut = turnFingerprint(turns[index])
203
+ state.cut = keys[index]
204
+ retireRecalls(state, turns.slice(0, index))
174
205
  actions.push(action)
175
206
  rebuild()
176
207
  }
@@ -184,16 +215,10 @@ export function compact({ body, config, state, window, guide, transcriptDir, may
184
215
  if (mayCompact && estimate > limit) {
185
216
  const current = turns.length - 1
186
217
  if (current > 0 && current > cutIndex) moveCut(current, "cut")
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
- }
218
+ shrinkOrHold(state, actions, { estimate: () => estimate, step: () => shrinkOnce(state, cutIndex, configuredKeep, liveResults), rebuild, target, limit, window })
219
+ stateChanged = true
220
+ } else if (state.stuck !== undefined) {
221
+ delete state.stuck
197
222
  stateChanged = true
198
223
  }
199
224
 
@@ -201,8 +226,8 @@ export function compact({ body, config, state, window, guide, transcriptDir, may
201
226
  messages,
202
227
  stateChanged,
203
228
  note: {
204
- ...cutNote(state, turns, cutIndex, configuredKeep, actions),
205
- stuck: state.stuck === undefined ? undefined : stuckSizes(messages, headLength(preamble, turns[0], state), liveStart(messages), size),
229
+ ...cutNote(state, turns, keys, cutIndex, configuredKeep, actions),
230
+ stuck: state.stuck === undefined ? undefined : stuckSizes(messages, headLength(preamble, turns[0], keys[0], state), liveStart(messages), size),
206
231
  turns: turns.length,
207
232
  messagesIn: body.messages.length,
208
233
  messagesOut: messages.length,
@@ -1,7 +1,7 @@
1
1
  export type Mode = "off" | "compact" | "default"
2
2
 
3
3
  interface EvalConfig {
4
- recallOptions?: { maxChars?: number; maxCharsMulti?: number; perQuery?: number }
4
+ recallOptions?: { maxChars?: number; maxCharsMulti?: number; maxCharsTotal?: number; perQuery?: number }
5
5
  strategy?: "tail" | "guide" | "trim"
6
6
  rolling?: { keep: number; budget: number }
7
7
  forceCut?: boolean
package/src/core/fold.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { Message } from "../context/content"
2
- import { splitTurns, turnFingerprint } from "../context/fold/turns"
2
+ import { splitTurns, turnKeys } from "../context/fold/turns"
3
3
  import { threadKeyOf } from "./rewrite"
4
- import { resetCut, type ThreadState, type ThreadStore } from "./state"
4
+ import { resetCut, retireRecalls, type ThreadState, type ThreadStore } from "./state"
5
5
 
6
6
  export interface FoldTarget {
7
7
  store: ThreadStore
@@ -23,7 +23,8 @@ export interface Folded {
23
23
  function located({ store, threadId, messages }: FoldTarget) {
24
24
  const threadKey = threadKeyOf(threadId, messages)
25
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) }
26
+ const turns = splitTurns(messages).turns
27
+ return { threadKey, turns, keys: turnKeys(turns), state: store.read(threadKey) }
27
28
  }
28
29
 
29
30
  function checkedTurn(turn: number, turns: Message[][], from: number): number {
@@ -32,38 +33,39 @@ function checkedTurn(turn: number, turns: Message[][], from: number): number {
32
33
  return turn
33
34
  }
34
35
 
35
- function folded(threadKey: string, turns: Message[][], state: ThreadState): Folded {
36
- const cutTurn = state.cut ? turns.findIndex((turn) => turnFingerprint(turn) === state.cut) : -1
36
+ function folded(threadKey: string, keys: string[], state: ThreadState): Folded {
37
+ const cutTurn = state.cut ? keys.indexOf(state.cut) : -1
37
38
  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
+ return { threadKey, cutTurn: cutTurn > 0 ? cutTurn : null, pinned: keys.flatMap((key, i) => (pins.has(key) ? [i] : [])) }
39
40
  }
40
41
 
41
- function withPins(state: ThreadState, turns: Message[][], indices: number[], pinned: boolean) {
42
+ function withPins(state: ThreadState, turns: Message[][], keys: string[], indices: number[], pinned: boolean) {
42
43
  const pins = new Set(state.pins)
43
44
  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)
45
+ const key = keys[checkedTurn(index, turns, 0)]
46
+ if (pinned) pins.add(key)
47
+ else pins.delete(key)
47
48
  }
48
49
  if (pins.size > 0) state.pins = [...pins].sort()
49
50
  else delete state.pins
50
51
  }
51
52
 
52
53
  export function foldThread(target: FoldTarget, { at = "latest", pin = [] }: FoldOptions = {}): Folded {
53
- const { threadKey, turns, state } = located(target)
54
+ const { threadKey, turns, keys, state } = located(target)
54
55
  const cut = checkedTurn(at === "latest" ? turns.length - 1 : at, turns, 1)
55
- withPins(state, turns, pin, true)
56
+ withPins(state, turns, keys, pin, true)
56
57
  resetCut(state)
57
- state.cut = turnFingerprint(turns[cut])
58
+ state.cut = keys[cut]
59
+ retireRecalls(state, turns.slice(0, cut))
58
60
  state.keep = cut - 1
59
61
  state.host = true
60
62
  target.store.save(threadKey, state)
61
- return folded(threadKey, turns, state)
63
+ return folded(threadKey, keys, state)
62
64
  }
63
65
 
64
66
  export function pinTurns(target: FoldTarget, indices: number[], pinned = true): Folded {
65
- const { threadKey, turns, state } = located(target)
66
- withPins(state, turns, indices, pinned)
67
+ const { threadKey, turns, keys, state } = located(target)
68
+ withPins(state, turns, keys, indices, pinned)
67
69
  target.store.save(threadKey, state)
68
- return folded(threadKey, turns, state)
70
+ return folded(threadKey, keys, state)
69
71
  }
@@ -26,6 +26,7 @@ export interface ForwarderOptions {
26
26
 
27
27
  interface Outgoing {
28
28
  note: ForwardNote
29
+ model: unknown
29
30
  threadKey: string | null
30
31
  bodyText: string | undefined
31
32
  sentSize: number
@@ -71,7 +72,7 @@ function defaultPrepare(options: ForwarderOptions, upstream: string): NonNullabl
71
72
  }
72
73
  }
73
74
 
74
- const passthrough = (bodyText: string | undefined): Outgoing => ({ note: { action: "pass" }, threadKey: null, bodyText, sentSize: 0, givenSize: 0, fixedSize: 0 })
75
+ const passthrough = (bodyText: string | undefined): Outgoing => ({ note: { action: "pass" }, model: undefined, threadKey: null, bodyText, sentSize: 0, givenSize: 0, fixedSize: 0 })
75
76
 
76
77
  async function outgoing(prepare: NonNullable<ForwarderOptions["prepare"]>, request: Request, givenText: string, mayCompact: boolean): Promise<Outgoing> {
77
78
  try {
@@ -80,6 +81,7 @@ async function outgoing(prepare: NonNullable<ForwarderOptions["prepare"]>, reque
80
81
  const sentSize = sizeOf(result.body)
81
82
  return {
82
83
  note: result.note,
84
+ model: given.model,
83
85
  threadKey: result.threadKey,
84
86
  bodyText: result.body !== given ? JSON.stringify(result.body, wellFormed) : givenText,
85
87
  sentSize,
@@ -161,7 +163,7 @@ export function createForwarder(options: ForwarderOptions): (request: Request) =
161
163
  if (answer.status >= 400 && out.note.action !== "pass") {
162
164
  const errorText = await answer.text()
163
165
  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
166
+ const overflow = out.threadKey && !isCountTokens ? recordOverflow(options.store, out.threadKey, out.sentSize, errorText, out.model) : null
165
167
  if (!overflow) {
166
168
  log({ ...logged, error: clipped(errorText) })
167
169
  return errorResponse(errorText)
@@ -1,5 +1,6 @@
1
1
  import { type Body, blocks, type Message, resultText } from "../context/content"
2
- import { livePrompt, type RecallChunk, type Reranker, recallChunks, recallFor, recallForWith, withRecall } from "../context/recall"
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"
3
4
  import { messageFingerprint } from "./fingerprint"
4
5
  import { activeHandoffs, handoffChunks } from "./handoff"
5
6
  import type { Rewrite, RewriteInput } from "./rewrite"
@@ -27,6 +28,29 @@ function recallTarget(original: Body): { key: string; text: string } | null {
27
28
  return live ? { key: messageFingerprint(original.messages[live.index]), text: live.text } : null
28
29
  }
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
+
30
54
  export function applyRecall(input: RewriteInput, result: Rewrite): Rewrite {
31
55
  if (!touched(input, result) || !result.threadKey) return result
32
56
  const state = input.store.read(result.threadKey)
@@ -34,7 +58,7 @@ export function applyRecall(input: RewriteInput, result: Rewrite): Rewrite {
34
58
  const target = recallTarget(input.body)
35
59
  let note = result.note
36
60
  if (target && !(target.key in state.recalls)) {
37
- state.recalls[target.key] = recallFor(target.text, droppedChunks(input, result, result.threadKey), input.config.eval?.recallOptions) ?? ""
61
+ state.recalls[target.key] = recallFor(target.text, droppedChunks(input, result, result.threadKey), withinTotal(input, result, state.recalls)) ?? ""
38
62
  input.store.save(result.threadKey, state)
39
63
  note = { ...note, recalled: state.recalls[target.key].length }
40
64
  }
@@ -49,7 +73,7 @@ export async function prefetchFor(input: RewriteInput, result: Rewrite, rerank:
49
73
  const state = input.store.read(result.threadKey)
50
74
  state.recalls ??= {}
51
75
  if (target.key in state.recalls) return null
52
- const options = { candidates: input.config.eval?.rerank?.candidates, ...input.config.eval?.recallOptions }
76
+ const options = { candidates: input.config.eval?.rerank?.candidates, ...withinTotal(input, result, state.recalls) }
53
77
  state.recalls[target.key] = (await recallForWith(target.text, droppedChunks(input, result, result.threadKey), rerank, options)) ?? ""
54
78
  input.store.save(result.threadKey, state)
55
79
  return state.recalls[target.key].length