@elliotsystems/eai 0.1.7 → 0.1.14

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.
@@ -0,0 +1,600 @@
1
+ /**
2
+ * Fast Jev Compaction — the ALGORITHM only (spec: `specs/2026-10-29-fast-
3
+ * jev-compaction-pilot.md`, task T6). Ported from the public `fast-jev-
4
+ * compaction` project's README (score each tool call/result via a yes/no
5
+ * judge, drop or truncate the ones no longer needed, keep everything else
6
+ * verbatim — never summarize/rewrite text) onto EAI's own message shape.
7
+ *
8
+ * This module is deliberately TRANSPORT-AGNOSTIC: no HTTP, no fetch, no
9
+ * knowledge of OpenRouter/Jev/the T3 backend endpoint. The two yes/no
10
+ * questions the algorithm needs answered for each candidate tool call are
11
+ * asked through the injected `asker` function parameter — the caller (T7)
12
+ * supplies an adapter that POSTs to the team-scoped backend proxy. Keeping
13
+ * the network entirely outside this file is what makes it unit-testable
14
+ * (T8) without a network mock framework, and keeps this module reusable if
15
+ * the transport ever changes.
16
+ *
17
+ * ## Shape this operates on
18
+ *
19
+ * A simplified, content-block message shape (`CompactionMessage`/
20
+ * `ContentBlock`) rather than OpenCode's own `{ info, parts }` wire shape —
21
+ * T7's adapter is responsible for converting one into the other before and
22
+ * after calling `compactMessages`. `tool_use`/`tool_result` blocks are
23
+ * paired by id, matching how Claude-style content blocks represent a tool
24
+ * round trip (an assistant message's `tool_use` block, answered by a later
25
+ * message's `tool_result` block carrying the same id).
26
+ *
27
+ * `info` is carried through unchanged on each `CompactionMessage` so this
28
+ * module can reuse `isBlockedAssistantTurn` (see "Guardrail interplay"
29
+ * below) without needing to know anything else about OpenCode's message
30
+ * shape.
31
+ *
32
+ * ## Algorithm, in order
33
+ *
34
+ * 1. Pin message 0 ("the first message"), the first `user`-role message
35
+ * (covers the case where index 0 is a separate system prompt), and the
36
+ * newest `preserveRecentMessages` messages (default 6).
37
+ * 2. Pair every `tool_use` block with its `tool_result` block by id. A pair
38
+ * is a CANDIDATE only when neither its call message nor its result
39
+ * message is pinned, blocked (see below), or still missing a result.
40
+ * 3. Build `state`: a clone of the full message list where every candidate
41
+ * pair's result content is replaced with a short `ok, N chars (omitted)`
42
+ * note. This is the cheap representation fed to the judge, never what a
43
+ * KEPT item ends up looking like in the final output.
44
+ * 4. Fit `state` under `maxStateTokens` via STAGED truncation, applied in
45
+ * this exact order, stopping as soon as the ceiling is met:
46
+ * a. candidate tool inputs truncated to 1000, then 200, then 60 chars;
47
+ * b. old (non-pinned, non-blocked) text blocks abridged head+tail;
48
+ * c. old messages collapsed to a single placeholder;
49
+ * d. old tool calls reduced to one line.
50
+ * 5. For every candidate pair, call `asker` once with enough context
51
+ * (drawn from the FITTED state, not the raw original) to get back
52
+ * `keepCall`/`keepResult` — each a 0..1 "yes" strength, not necessarily
53
+ * a strict boolean, so a threshold can be tuned without changing this
54
+ * module.
55
+ * 6. Apply `keepThreshold` (default 0.5) to the ORIGINAL (verbatim)
56
+ * messages, never the truncated `state`:
57
+ * - `keepResult >= threshold` -> keep both verbatim;
58
+ * - else `keepCall >= threshold` -> keep the call verbatim, replace the
59
+ * result with the same short omitted note `state` used;
60
+ * - else -> drop both blocks entirely.
61
+ * 7. Rebuild the message list: a message left with no content blocks is
62
+ * removed outright; a `tool_result` whose `tool_use` was dropped is
63
+ * removed too (never left orphaned), even though step 6 already drops
64
+ * both halves of a pair together — this is a second, defensive pass.
65
+ *
66
+ * ## Token estimation
67
+ *
68
+ * A calibrated heuristic, not a real tokenizer (no new dependency): ~1
69
+ * token per 6 letters, 0.5 token per digit, ~1 token per other (non-
70
+ * whitespace) symbol. Good enough for a staged-truncation ceiling; never
71
+ * presented as an exact count.
72
+ *
73
+ * ## Guardrail interplay (explicit, not incidental)
74
+ *
75
+ * `isBlockedAssistantTurn` (reused verbatim from `./blocked-turns.js`, not
76
+ * reimplemented) flags a Jev-blocked assistant turn. Any message it flags
77
+ * is treated exactly like a pinned message here: never a compaction
78
+ * candidate, never truncated, never dropped. In the normal running system
79
+ * `stripBlockedTurns` already removes these turns upstream before this
80
+ * module ever sees them — this check exists so that if `stripBlockedTurns`
81
+ * is ever disabled or reordered independently, this module cannot
82
+ * re-introduce or corrupt a blocked turn by treating it as ordinary
83
+ * history to compact.
84
+ */
85
+ import { isBlockedAssistantTurn } from "./blocked-turns.js"
86
+
87
+ // ------------------------------------------------------------------ types --
88
+
89
+ export type MessageRole = "system" | "user" | "assistant"
90
+
91
+ export interface TextBlock {
92
+ readonly type: "text"
93
+ readonly text: string
94
+ }
95
+
96
+ export interface ToolUseBlock {
97
+ readonly type: "tool_use"
98
+ readonly id: string
99
+ readonly name: string
100
+ readonly input: unknown
101
+ }
102
+
103
+ export interface ToolResultBlock {
104
+ readonly type: "tool_result"
105
+ readonly tool_use_id: string
106
+ readonly content: string
107
+ }
108
+
109
+ export type ContentBlock = TextBlock | ToolUseBlock | ToolResultBlock
110
+
111
+ /** The raw host `info` object a message carries, narrowed to exactly what
112
+ * `isBlockedAssistantTurn` reads -- so this module stays usable with
113
+ * OpenCode's real `{ info, parts }` shape without importing it. */
114
+ export type CompactionMessageInfo = Parameters<typeof isBlockedAssistantTurn>[0]
115
+
116
+ export interface CompactionMessage {
117
+ readonly role: MessageRole
118
+ readonly content: string | readonly ContentBlock[]
119
+ /** Passed through unchanged; only ever read by `isBlockedAssistantTurn`. */
120
+ readonly info?: CompactionMessageInfo
121
+ }
122
+
123
+ /** What the `asker` is given to answer `keepCall`/`keepResult` for one
124
+ * candidate tool round trip. Drawn from the FITTED `state`, never the raw
125
+ * original -- that is the entire point of fitting `state` under a ceiling
126
+ * first. */
127
+ export interface AskerContext {
128
+ readonly id: string
129
+ readonly toolName: string
130
+ readonly input: unknown
131
+ readonly resultSummary: string
132
+ /** The full fitted state, for a judge that wants broader context than
133
+ * just this one call/result pair. */
134
+ readonly state: readonly CompactionMessage[]
135
+ }
136
+
137
+ export interface AskerAnswer {
138
+ /** 0..1 strength of "yes, keep the call around." */
139
+ readonly keepCall: number
140
+ /** 0..1 strength of "yes, keep the result around." */
141
+ readonly keepResult: number
142
+ }
143
+
144
+ /** Transport-agnostic by design -- the caller supplies this; this module
145
+ * never makes a network call itself. */
146
+ export type Asker = (context: AskerContext) => Promise<AskerAnswer>
147
+
148
+ export interface CompactionOptions {
149
+ /** How many of the newest messages are always pinned, in addition to the
150
+ * first message and the first user message. */
151
+ readonly preserveRecentMessages?: number
152
+ /** `keepResult`/`keepCall` threshold (0..1) applied to the asker's
153
+ * answers. */
154
+ readonly keepThreshold?: number
155
+ /** Token-estimate ceiling `state` is fit under before any candidate is
156
+ * asked about. */
157
+ readonly maxStateTokens?: number
158
+ readonly asker: Asker
159
+ }
160
+
161
+ export interface CompactionStats {
162
+ readonly originalTokens: number
163
+ /** Token estimate of the fitted `state` sent to the judge -- T9 surfaces
164
+ * this directly in spend-log metadata. */
165
+ readonly stateTokens: number
166
+ readonly finalTokens: number
167
+ /** `1 - finalTokens / originalTokens`; `0` when there was nothing to
168
+ * reduce or `originalTokens` was `0`. */
169
+ readonly reductionRatio: number
170
+ readonly candidateCount: number
171
+ readonly droppedCount: number
172
+ readonly truncatedCount: number
173
+ readonly keptCount: number
174
+ }
175
+
176
+ export interface CompactionResult {
177
+ readonly messages: readonly CompactionMessage[]
178
+ readonly stats: CompactionStats
179
+ }
180
+
181
+ export const DEFAULT_PRESERVE_RECENT_MESSAGES = 6
182
+ export const DEFAULT_KEEP_THRESHOLD = 0.5
183
+ /** No ceiling is specified by the ported algorithm's own README; this is a
184
+ * conservative default a caller can override via `CompactionOptions.
185
+ * maxStateTokens` once real pilot data suggests a better number. */
186
+ export const DEFAULT_MAX_STATE_TOKENS = 12_000
187
+
188
+ // ------------------------------------------------------------ tokenizing --
189
+
190
+ /** Calibrated heuristic, not a real tokenizer: ~1 token per 6 letters, 0.5
191
+ * per digit, ~1 per other non-whitespace symbol. See module docstring. */
192
+ export function estimateTokens(text: string): number {
193
+ let tokens = 0
194
+ for (let i = 0; i < text.length; i++) {
195
+ const code = text.charCodeAt(i)
196
+ const isLetter = (code >= 65 && code <= 90) || (code >= 97 && code <= 122)
197
+ const isDigit = code >= 48 && code <= 57
198
+ const isWhitespace = code === 32 || code === 9 || code === 10 || code === 13
199
+ if (isLetter) tokens += 1 / 6
200
+ else if (isDigit) tokens += 0.5
201
+ else if (!isWhitespace) tokens += 1
202
+ }
203
+ return tokens
204
+ }
205
+
206
+ function blockText(block: ContentBlock): string {
207
+ if (block.type === "text") return block.text
208
+ if (block.type === "tool_use") return `${block.name} ${typeof block.input === "string" ? block.input : JSON.stringify(block.input)}`
209
+ return block.content
210
+ }
211
+
212
+ function messageText(message: CompactionMessage): string {
213
+ return typeof message.content === "string" ? message.content : message.content.map(blockText).join(" ")
214
+ }
215
+
216
+ export function estimateMessagesTokens(messages: readonly CompactionMessage[]): number {
217
+ let total = 0
218
+ for (const message of messages) total += estimateTokens(messageText(message))
219
+ return total
220
+ }
221
+
222
+ // --------------------------------------------------------------- pairing --
223
+
224
+ interface ToolPair {
225
+ readonly id: string
226
+ readonly callMessageIndex: number
227
+ readonly callBlockIndex: number
228
+ readonly resultMessageIndex: number | null
229
+ readonly resultBlockIndex: number | null
230
+ }
231
+
232
+ function collectPairs(messages: readonly CompactionMessage[]): ToolPair[] {
233
+ const pairs = new Map<string, ToolPair>()
234
+
235
+ messages.forEach((message, messageIndex) => {
236
+ if (typeof message.content === "string") return
237
+ message.content.forEach((block, blockIndex) => {
238
+ if (block.type !== "tool_use") return
239
+ pairs.set(block.id, {
240
+ id: block.id,
241
+ callMessageIndex: messageIndex,
242
+ callBlockIndex: blockIndex,
243
+ resultMessageIndex: null,
244
+ resultBlockIndex: null,
245
+ })
246
+ })
247
+ })
248
+
249
+ messages.forEach((message, messageIndex) => {
250
+ if (typeof message.content === "string") return
251
+ message.content.forEach((block, blockIndex) => {
252
+ if (block.type !== "tool_result") return
253
+ const existing = pairs.get(block.tool_use_id)
254
+ if (!existing) return
255
+ pairs.set(block.tool_use_id, { ...existing, resultMessageIndex: messageIndex, resultBlockIndex: blockIndex })
256
+ })
257
+ })
258
+
259
+ return [...pairs.values()]
260
+ }
261
+
262
+ // ---------------------------------------------------------- pin/guardrail --
263
+
264
+ function computePinnedIndices(messages: readonly CompactionMessage[], preserveRecentMessages: number): Set<number> {
265
+ const pinned = new Set<number>()
266
+ if (messages.length > 0) pinned.add(0)
267
+
268
+ const firstUserIndex = messages.findIndex((message) => message.role === "user")
269
+ if (firstUserIndex >= 0) pinned.add(firstUserIndex)
270
+
271
+ for (let i = Math.max(0, messages.length - preserveRecentMessages); i < messages.length; i++) pinned.add(i)
272
+ return pinned
273
+ }
274
+
275
+ /** Messages `isBlockedAssistantTurn` flags -- reused verbatim, never a
276
+ * compaction candidate regardless of `preserveRecentMessages`. See module
277
+ * docstring's "Guardrail interplay" section. */
278
+ function computeBlockedIndices(messages: readonly CompactionMessage[]): Set<number> {
279
+ const blocked = new Set<number>()
280
+ messages.forEach((message, index) => {
281
+ if (message.role === "assistant" && isBlockedAssistantTurn(message.info)) blocked.add(index)
282
+ })
283
+ return blocked
284
+ }
285
+
286
+ // ---------------------------------------------------------------- clones --
287
+
288
+ type MutableBlock = TextBlock | ToolUseBlock | ToolResultBlock
289
+ interface MutableMessage {
290
+ role: MessageRole
291
+ info: CompactionMessageInfo
292
+ content: string | MutableBlock[]
293
+ }
294
+
295
+ function deepCloneValue<T>(value: T): T {
296
+ if (value === undefined) return value
297
+ return JSON.parse(JSON.stringify(value)) as T
298
+ }
299
+
300
+ function cloneBlock(block: ContentBlock): MutableBlock {
301
+ if (block.type === "tool_use") return { type: "tool_use", id: block.id, name: block.name, input: deepCloneValue(block.input) }
302
+ if (block.type === "tool_result") return { type: "tool_result", tool_use_id: block.tool_use_id, content: block.content }
303
+ return { type: "text", text: block.text }
304
+ }
305
+
306
+ function cloneMessages(messages: readonly CompactionMessage[]): MutableMessage[] {
307
+ return messages.map((message) => ({
308
+ role: message.role,
309
+ info: message.info,
310
+ content: typeof message.content === "string" ? message.content : message.content.map(cloneBlock),
311
+ }))
312
+ }
313
+
314
+ function omittedNote(length: number): string {
315
+ return `ok, ${length} chars (omitted)`
316
+ }
317
+
318
+ // ----------------------------------------------------- staged truncation --
319
+
320
+ function truncateToolInputs(state: MutableMessage[], candidateCallIndices: ReadonlySet<number>, maxChars: number): void {
321
+ state.forEach((message, index) => {
322
+ if (!candidateCallIndices.has(index) || typeof message.content === "string") return
323
+ message.content = message.content.map((block) => {
324
+ if (block.type !== "tool_use") return block
325
+ const serialized = typeof block.input === "string" ? block.input : JSON.stringify(block.input)
326
+ if (serialized.length <= maxChars) return block
327
+ return { ...block, input: `${serialized.slice(0, maxChars)}... (truncated)` }
328
+ })
329
+ })
330
+ }
331
+
332
+ function abridgeText(text: string, headChars: number, tailChars: number): string {
333
+ if (text.length <= headChars + tailChars + 20) return text
334
+ const omitted = text.length - headChars - tailChars
335
+ return `${text.slice(0, headChars)}... [${omitted} chars omitted] ...${text.slice(text.length - tailChars)}`
336
+ }
337
+
338
+ function abridgeTexts(state: MutableMessage[], nonPinnedIndices: ReadonlySet<number>): void {
339
+ const headChars = 200
340
+ const tailChars = 200
341
+ state.forEach((message, index) => {
342
+ if (!nonPinnedIndices.has(index)) return
343
+ if (typeof message.content === "string") {
344
+ message.content = abridgeText(message.content, headChars, tailChars)
345
+ return
346
+ }
347
+ message.content = message.content.map((block) =>
348
+ block.type === "text" ? { ...block, text: abridgeText(block.text, headChars, tailChars) } : block,
349
+ )
350
+ })
351
+ }
352
+
353
+ function collapseMessages(state: MutableMessage[], nonPinnedIndices: ReadonlySet<number>): void {
354
+ state.forEach((message, index) => {
355
+ if (!nonPinnedIndices.has(index)) return
356
+ message.content = "[collapsed]"
357
+ })
358
+ }
359
+
360
+ function reduceToolCallsToOneLine(state: MutableMessage[], candidateCallIndices: ReadonlySet<number>): void {
361
+ state.forEach((message, index) => {
362
+ if (!candidateCallIndices.has(index) || typeof message.content === "string") return
363
+ message.content = message.content.map((block) =>
364
+ block.type === "tool_use" ? { ...block, input: "(call details omitted)" } : block,
365
+ )
366
+ })
367
+ }
368
+
369
+ function messagesTokensOf(state: readonly MutableMessage[]): number {
370
+ return estimateMessagesTokens(state as readonly CompactionMessage[])
371
+ }
372
+
373
+ /** Applies the four truncation stages, in the exact order the spec
374
+ * requires, stopping the moment `state` fits under `maxStateTokens`. */
375
+ function fitState(
376
+ state: MutableMessage[],
377
+ pinned: ReadonlySet<number>,
378
+ blocked: ReadonlySet<number>,
379
+ candidateCallIndices: ReadonlySet<number>,
380
+ maxStateTokens: number,
381
+ ): void {
382
+ if (messagesTokensOf(state) <= maxStateTokens) return
383
+
384
+ const nonPinned = new Set<number>()
385
+ state.forEach((_message, index) => {
386
+ if (!pinned.has(index) && !blocked.has(index)) nonPinned.add(index)
387
+ })
388
+
389
+ for (const maxChars of [1000, 200, 60]) {
390
+ truncateToolInputs(state, candidateCallIndices, maxChars)
391
+ if (messagesTokensOf(state) <= maxStateTokens) return
392
+ }
393
+
394
+ abridgeTexts(state, nonPinned)
395
+ if (messagesTokensOf(state) <= maxStateTokens) return
396
+
397
+ collapseMessages(state, nonPinned)
398
+ if (messagesTokensOf(state) <= maxStateTokens) return
399
+
400
+ reduceToolCallsToOneLine(state, candidateCallIndices)
401
+ }
402
+
403
+ // --------------------------------------------------------------- rebuild --
404
+
405
+ function zeroStats(originalTokens: number): CompactionStats {
406
+ return {
407
+ originalTokens,
408
+ stateTokens: originalTokens,
409
+ finalTokens: originalTokens,
410
+ reductionRatio: 0,
411
+ candidateCount: 0,
412
+ droppedCount: 0,
413
+ truncatedCount: 0,
414
+ keptCount: 0,
415
+ }
416
+ }
417
+
418
+ function removeOrphanToolResults(messages: readonly CompactionMessage[]): CompactionMessage[] {
419
+ const callIds = new Set<string>()
420
+ for (const message of messages) {
421
+ if (typeof message.content === "string") continue
422
+ for (const block of message.content) if (block.type === "tool_use") callIds.add(block.id)
423
+ }
424
+
425
+ const cleaned: CompactionMessage[] = []
426
+ for (const message of messages) {
427
+ if (typeof message.content === "string") {
428
+ cleaned.push(message)
429
+ continue
430
+ }
431
+ const content = message.content.filter((block) => block.type !== "tool_result" || callIds.has(block.tool_use_id))
432
+ if (content.length === 0) continue
433
+ cleaned.push({ role: message.role, info: message.info, content })
434
+ }
435
+ return cleaned
436
+ }
437
+
438
+ function rebuildMessages(
439
+ messages: readonly CompactionMessage[],
440
+ dropIds: ReadonlySet<string>,
441
+ truncateResultIds: ReadonlySet<string>,
442
+ pairsById: ReadonlyMap<string, ToolPair>,
443
+ ): CompactionMessage[] {
444
+ const working: { role: MessageRole; info: CompactionMessageInfo; content: string | (ContentBlock | null)[] }[] =
445
+ messages.map((message) => ({
446
+ role: message.role,
447
+ info: message.info,
448
+ content: typeof message.content === "string" ? message.content : [...message.content],
449
+ }))
450
+
451
+ const setNull = (messageIndex: number, blockIndex: number): void => {
452
+ const content = working[messageIndex]?.content
453
+ if (!content || typeof content === "string") return
454
+ content[blockIndex] = null
455
+ }
456
+
457
+ for (const id of dropIds) {
458
+ const pair = pairsById.get(id)
459
+ if (!pair) continue
460
+ setNull(pair.callMessageIndex, pair.callBlockIndex)
461
+ if (pair.resultMessageIndex !== null && pair.resultBlockIndex !== null) {
462
+ setNull(pair.resultMessageIndex, pair.resultBlockIndex)
463
+ }
464
+ }
465
+
466
+ for (const id of truncateResultIds) {
467
+ const pair = pairsById.get(id)
468
+ if (!pair || pair.resultMessageIndex === null || pair.resultBlockIndex === null) continue
469
+ const content = working[pair.resultMessageIndex]?.content
470
+ if (!content || typeof content === "string") continue
471
+ const block = content[pair.resultBlockIndex]
472
+ if (block && block.type === "tool_result") {
473
+ content[pair.resultBlockIndex] = { type: "tool_result", tool_use_id: block.tool_use_id, content: omittedNote(block.content.length) }
474
+ }
475
+ }
476
+
477
+ const rebuilt: CompactionMessage[] = []
478
+ for (const message of working) {
479
+ const content = typeof message.content === "string" ? message.content : message.content.filter((b): b is ContentBlock => b !== null)
480
+ if (typeof content !== "string" && content.length === 0) continue
481
+ rebuilt.push({ role: message.role, info: message.info, content })
482
+ }
483
+
484
+ return removeOrphanToolResults(rebuilt)
485
+ }
486
+
487
+ function findBlock<T extends ContentBlock["type"]>(
488
+ state: readonly MutableMessage[],
489
+ messageIndex: number,
490
+ blockIndex: number,
491
+ type: T,
492
+ ): Extract<ContentBlock, { type: T }> | undefined {
493
+ const message = state[messageIndex]
494
+ if (!message || typeof message.content === "string") return undefined
495
+ const block = message.content[blockIndex]
496
+ if (!block || block.type !== type) return undefined
497
+ return block as Extract<ContentBlock, { type: T }>
498
+ }
499
+
500
+ // ------------------------------------------------------------------ main --
501
+
502
+ /**
503
+ * Runs the Fast Jev Compaction algorithm over `messages`. See module
504
+ * docstring for the full step-by-step description. Never mutates the input
505
+ * array or its messages.
506
+ */
507
+ export async function compactMessages(
508
+ messages: readonly CompactionMessage[],
509
+ options: CompactionOptions,
510
+ ): Promise<CompactionResult> {
511
+ const preserveRecentMessages = options.preserveRecentMessages ?? DEFAULT_PRESERVE_RECENT_MESSAGES
512
+ const keepThreshold = options.keepThreshold ?? DEFAULT_KEEP_THRESHOLD
513
+ const maxStateTokens = options.maxStateTokens ?? DEFAULT_MAX_STATE_TOKENS
514
+ const { asker } = options
515
+
516
+ const originalTokens = estimateMessagesTokens(messages)
517
+ if (messages.length === 0) {
518
+ return { messages: [], stats: zeroStats(originalTokens) }
519
+ }
520
+
521
+ const pinned = computePinnedIndices(messages, preserveRecentMessages)
522
+ const blocked = computeBlockedIndices(messages)
523
+ const allPairs = collectPairs(messages)
524
+
525
+ const candidates = allPairs.filter((pair) => {
526
+ if (pair.resultMessageIndex === null) return false
527
+ if (pinned.has(pair.callMessageIndex) || blocked.has(pair.callMessageIndex)) return false
528
+ if (pinned.has(pair.resultMessageIndex) || blocked.has(pair.resultMessageIndex)) return false
529
+ return true
530
+ })
531
+
532
+ if (candidates.length === 0) {
533
+ return { messages: [...messages], stats: zeroStats(originalTokens) }
534
+ }
535
+
536
+ const candidateCallIndices = new Set(candidates.map((pair) => pair.callMessageIndex))
537
+ const pairsById = new Map(candidates.map((pair) => [pair.id, pair] as const))
538
+
539
+ const state = cloneMessages(messages)
540
+ for (const pair of candidates) {
541
+ if (pair.resultMessageIndex === null || pair.resultBlockIndex === null) continue
542
+ const message = state[pair.resultMessageIndex]
543
+ if (!message || typeof message.content === "string") continue
544
+ const block = message.content[pair.resultBlockIndex]
545
+ if (!block || block.type !== "tool_result") continue
546
+ message.content[pair.resultBlockIndex] = { type: "tool_result", tool_use_id: block.tool_use_id, content: omittedNote(block.content.length) }
547
+ }
548
+
549
+ fitState(state, pinned, blocked, candidateCallIndices, maxStateTokens)
550
+ const stateTokens = messagesTokensOf(state)
551
+
552
+ let droppedCount = 0
553
+ let truncatedCount = 0
554
+ let keptCount = 0
555
+ const dropIds = new Set<string>()
556
+ const truncateResultIds = new Set<string>()
557
+
558
+ for (const pair of candidates) {
559
+ const callBlock = findBlock(state, pair.callMessageIndex, pair.callBlockIndex, "tool_use")
560
+ const resultBlock =
561
+ pair.resultMessageIndex !== null && pair.resultBlockIndex !== null
562
+ ? findBlock(state, pair.resultMessageIndex, pair.resultBlockIndex, "tool_result")
563
+ : undefined
564
+
565
+ const answer = await asker({
566
+ id: pair.id,
567
+ toolName: callBlock?.name ?? "unknown",
568
+ input: callBlock?.input,
569
+ resultSummary: resultBlock?.content ?? "",
570
+ state: state as readonly CompactionMessage[],
571
+ })
572
+
573
+ if (answer.keepResult >= keepThreshold) {
574
+ keptCount += 1
575
+ } else if (answer.keepCall >= keepThreshold) {
576
+ truncateResultIds.add(pair.id)
577
+ truncatedCount += 1
578
+ } else {
579
+ dropIds.add(pair.id)
580
+ droppedCount += 1
581
+ }
582
+ }
583
+
584
+ const finalMessages = rebuildMessages(messages, dropIds, truncateResultIds, pairsById)
585
+ const finalTokens = estimateMessagesTokens(finalMessages)
586
+
587
+ return {
588
+ messages: finalMessages,
589
+ stats: {
590
+ originalTokens,
591
+ stateTokens,
592
+ finalTokens,
593
+ reductionRatio: originalTokens > 0 ? 1 - finalTokens / originalTokens : 0,
594
+ candidateCount: candidates.length,
595
+ droppedCount,
596
+ truncatedCount,
597
+ keptCount,
598
+ },
599
+ }
600
+ }
@@ -50,6 +50,16 @@ export interface ElliotConfig {
50
50
  backend_url?: string
51
51
  litellm_base_url?: string
52
52
  litellm_master_key?: string
53
+ /** The organization this config was written for — present since `eai
54
+ * setup` has always spread the onboarding response (which always
55
+ * includes it) into this file. Typed here (was previously only reachable
56
+ * through the index signature) because guardrail-sync.ts needs it to
57
+ * call `GET /teams/{team_id}` with the right tenant context. */
58
+ tenant_id?: string
59
+ /** The single team this config was scoped to at `eai setup` time — absent
60
+ * for a tenant-wide (owner/admin) setup, which has no one team to check
61
+ * a guardrail for. See guardrail-sync.ts. */
62
+ team_id?: string
53
63
  /**
54
64
  * Every credential this person can use, personal key first (Phase 4).
55
65
  * Absent on a config written by an older `eai setup` (or a backend that