@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.
- package/bin/eai +55 -0
- package/lib/auth.js +173 -0
- package/lib/context.js +52 -0
- package/lib/login.js +114 -0
- package/lib/setup.js +152 -23
- package/lib/stage.js +92 -11
- package/lib/status.js +2 -0
- package/lib/teams.js +233 -0
- package/package.json +2 -2
- package/plugin/opencode-plugin/src/blocked-turns.ts +145 -0
- package/plugin/opencode-plugin/src/compaction.ts +600 -0
- package/plugin/opencode-plugin/src/elliot-auth.ts +10 -0
- package/plugin/opencode-plugin/src/guardrail-sync.ts +370 -0
- package/plugin/opencode-plugin/src/index.ts +476 -0
- package/plugin/opencode-plugin/src/session-affinity.ts +73 -0
|
@@ -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
|