@elliotsystems/eai 0.1.9 → 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 CHANGED
@@ -303,6 +303,13 @@ if (!env.OPENCODE_CONFIG_DIR) {
303
303
  // our own below. This is what lets OpenCode stay stock.
304
304
  env.OPENCODE_DISABLE_TERMINAL_TITLE = "1"
305
305
 
306
+ // The TUI owns the whole terminal, so anything the runtime or a dependency prints
307
+ // to stderr lands on top of the screen. Node's own runtime warnings are the common
308
+ // case: `DEP0169 url.parse()` from a dependency printed three lines over the prompt
309
+ // on every launch. Switched off unless the user set the variable themselves, so
310
+ // `NODE_NO_WARNINGS=0` still brings them back when debugging.
311
+ if (env.NODE_NO_WARNINGS === undefined) env.NODE_NO_WARNINGS = "1"
312
+
306
313
  // Claim the title before the child paints anything.
307
314
  if (process.stdout.isTTY) process.stdout.write(ELLIOT_TITLE)
308
315
 
package/lib/stage.js CHANGED
@@ -29,6 +29,62 @@ const fs = require("fs")
29
29
  const os = require("os")
30
30
  const path = require("path")
31
31
 
32
+ /**
33
+ * The JEV (fast-jev-compaction-opencode) plugin spec. NOT staged by default:
34
+ * pass it as `jevPlugin` to stage it alongside Elliot's own branding plugin.
35
+ * It is off because the plugin writes its own log lines onto the TUI (and its
36
+ * `github:` spec also triggers a Node `url.parse()` deprecation warning at
37
+ * load), with no option to silence either. Turn it on again once it can log to
38
+ * a file only.
39
+ *
40
+ * Registering it here does NOT mean compaction works: as of this writing its
41
+ * default backend ("typesafe") has no configured credential on a stock
42
+ * install, so its own compaction hook fails closed and OpenCode's native
43
+ * compaction runs instead -- confirmed by reading its own log output, not
44
+ * claimed from its source alone. A plugin that fails to load or fails inside
45
+ * its own hook does not block startup or touch Elliot's own plugin entry;
46
+ * OpenCode loads each plugin independently and logs a per-plugin failure
47
+ * rather than refusing to start (see opencode.log's own "failed to load
48
+ * plugin" entries for Elliot's branding plugin on a missing gatewayAlias,
49
+ * which never affected the rest of the session).
50
+ *
51
+ * A bare string, not a [spec, options] tuple: unlike Elliot's own plugin, it
52
+ * takes no required option here, so there is nothing to pass.
53
+ */
54
+ const DEFAULT_JEV_PLUGIN_SPEC = "github:overbit/fast-jev-compaction-opencode"
55
+
56
+ /**
57
+ * Credential-shaped keys this function refuses to ever write into a staged
58
+ * config file. Checked by NAME, not by guessing at a value's shape -- a
59
+ * deliberately conservative denylist, since the cost of missing a future
60
+ * secret-shaped option name is much higher than the cost of a false
61
+ * positive dropping a harmless key that happened to share a name.
62
+ */
63
+ const JEV_PLUGIN_SECRET_OPTION_KEYS = new Set(["apiKey", "localApiKey"])
64
+
65
+ /**
66
+ * `opts.jevPluginOptions`, minus any credential-shaped key -- see
67
+ * DEFAULT_JEV_PLUGIN_SPEC's own docstring above for why a secret should
68
+ * never reach here in the first place. `undefined`/non-object input
69
+ * returns `undefined` (no options tuple at all, same as omitting the
70
+ * field), never throws -- this must never be the reason staging fails.
71
+ */
72
+ function jevPluginOptionsExcludingSecrets(raw) {
73
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined
74
+ const clean = {}
75
+ for (const [key, value] of Object.entries(raw)) {
76
+ if (JEV_PLUGIN_SECRET_OPTION_KEYS.has(key)) {
77
+ process.stderr.write(
78
+ `eai: refusing to write "${key}" into the staged JEV plugin config -- ` +
79
+ `credentials must be set as an environment variable (e.g. TYPESAFE_API_KEY), never in a config file.\n`,
80
+ )
81
+ continue
82
+ }
83
+ clean[key] = value
84
+ }
85
+ return Object.keys(clean).length > 0 ? clean : undefined
86
+ }
87
+
32
88
  /**
33
89
  * The LiteLLM MCP gateway's alias, from `~/.elliot/config.json`'s
34
90
  * `gateway_alias` field -- `eai setup` writes it there straight out of
@@ -139,6 +195,27 @@ function stageOverlay(opts) {
139
195
  // from. See readGatewayAlias's own docstring for why `undefined` must not
140
196
  // become a guessed `"elliot"`.
141
197
  const gatewayAlias = opts.gatewayAlias
198
+ // OFF unless a caller passes a spec (the tests do; pass DEFAULT_JEV_PLUGIN_SPEC
199
+ // to turn it on). It was staged for every install, but the plugin copies each
200
+ // of its log lines to the console and has no option to stop it, so on every
201
+ // launch `[fast-jev-compaction-opencode] initialized {...}` was drawn into the
202
+ // TUI prompt box -- for a compaction hook that fails closed on a stock install
203
+ // anyway. See DEFAULT_JEV_PLUGIN_SPEC's docstring.
204
+ const jevPlugin = opts.jevPlugin || null
205
+ // Non-secret backend selection ONLY -- e.g. { backend: "systemone",
206
+ // localBaseUrl: "...", localModel: "..." } to point the plugin at a local
207
+ // decision model instead of its own TypeSafe-hosted default. NEVER a
208
+ // credential: the plugin already reads TYPESAFE_API_KEY straight from
209
+ // process.env on every call (jev-compaction-opencode's own client.ts),
210
+ // and the OpenCode child process already inherits this launcher's full
211
+ // environment (bin/eai's `childProcess.spawn(..., { env })`) -- there is
212
+ // no path by which a secret needs to pass through this function, through
213
+ // a JSON file this function writes, or through anything this launcher
214
+ // logs. `jevPluginOptionsExcludingSecrets` enforces that as a real
215
+ // invariant, not just an absence of code: an `apiKey`/`localApiKey` key
216
+ // here is dropped and a warning is written to stderr rather than ever
217
+ // reaching a file on disk.
218
+ const jevPluginOptions = jevPluginOptionsExcludingSecrets(opts.jevPluginOptions)
142
219
 
143
220
  const pluginSource = path.join(packageRoot, "plugin", "opencode-plugin")
144
221
  const themeSource = path.join(packageRoot, "config", "themes")
@@ -150,13 +227,13 @@ function stageOverlay(opts) {
150
227
 
151
228
  try {
152
229
  // `.staged` records the version, a content fingerprint of the source this
153
- // tree was built from, AND the gateway alias baked into the written
154
- // config -- re-staging on every launch would add hundreds of file copies
155
- // to startup, but skipping it on an alias change would leave a machine
156
- // running `eai setup` against a renamed gateway alias (or configuring it
157
- // for the first time) stuck on the stale tui.json/opencode.json until
158
- // something else invalidated the stamp.
159
- const stamp = `${version} ${fingerprint(pluginSource)} ${gatewayAlias || ""}`.trim()
230
+ // tree was built from, the gateway alias baked into the written config,
231
+ // AND the JEV plugin spec -- re-staging on every launch would add
232
+ // hundreds of file copies to startup, but skipping it on any of these
233
+ // changing would leave a machine stuck on a stale tui.json/opencode.json
234
+ // (the alias case is the incident this already protects against; the JEV
235
+ // spec is the same risk the moment that spec is ever overridden).
236
+ const stamp = `${version} ${fingerprint(pluginSource)} ${gatewayAlias || ""} ${jevPlugin || ""} ${JSON.stringify(jevPluginOptions || {})}`.trim()
160
237
  const done = fs.existsSync(marker) && fs.readFileSync(marker, "utf8").trim() === stamp
161
238
  if (!done) {
162
239
  fs.rmSync(root, { recursive: true, force: true })
@@ -176,9 +253,13 @@ function stageOverlay(opts) {
176
253
  // OUT of the array entirely rather than included in a form guaranteed
177
254
  // to fail — "plain OpenCode chrome" is the same visible outcome either
178
255
  // way, but without logging a load error on every single launch.
179
- const plugin = gatewayAlias
180
- ? [[pluginDir, { serverNames: ["elliot"], gatewayAlias }]]
181
- : []
256
+ // JEV is appended independently of gatewayAlias: it has no such
257
+ // required option (see DEFAULT_JEV_PLUGIN_SPEC), so there is nothing
258
+ // here that can fail validation the way a bare Elliot plugin path did.
259
+ const plugin = [
260
+ ...(gatewayAlias ? [[pluginDir, { serverNames: ["elliot"], gatewayAlias }]] : []),
261
+ ...(jevPlugin ? [jevPluginOptions ? [jevPlugin, jevPluginOptions] : jevPlugin] : []),
262
+ ]
182
263
  fs.writeFileSync(
183
264
  path.join(root, "tui.json"),
184
265
  JSON.stringify(
@@ -207,4 +288,4 @@ function stageOverlay(opts) {
207
288
  }
208
289
  }
209
290
 
210
- module.exports = { stageOverlay, stageRoot, readGatewayAlias }
291
+ module.exports = { stageOverlay, stageRoot, readGatewayAlias, DEFAULT_JEV_PLUGIN_SPEC }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elliotsystems/eai",
3
- "version": "0.1.9",
3
+ "version": "0.1.14",
4
4
  "description": "Elliot AI CLI entry point (eai) - launches the Elliot runtime with arguments forwarded verbatim.",
5
5
  "bin": {
6
6
  "eai": "./bin/eai"
@@ -27,6 +27,6 @@
27
27
  "access": "public"
28
28
  },
29
29
  "dependencies": {
30
- "opencode-ai": "1.19.4"
30
+ "opencode-ai": "1.19.5"
31
31
  }
32
32
  }
@@ -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
+ }
@@ -99,6 +99,10 @@ interface CacheEntry {
99
99
  jevPolicyEnabled: boolean
100
100
  /** Jev audit (2026-10, G2). */
101
101
  jevOutputValidationEnabled: boolean
102
+ /** Fast Jev Compaction pilot (spec: `specs/2026-10-29-fast-jev-
103
+ * compaction-pilot.md`, task T7) -- `Team.eai_compaction_enabled`, read
104
+ * from this SAME `GET /teams/{id}` response, never a second round trip. */
105
+ compactionEnabled: boolean
102
106
  teamId: string
103
107
  expiresAt: number
104
108
  }
@@ -159,10 +163,14 @@ export interface LiveCheckDeps {
159
163
  * all). Returns `null` under the exact same conditions `liveJevPolicyEnabled`
160
164
  * always has (see its own docstring, preserved below) — no team context, an
161
165
  * instance/request team-id mismatch, or a failed check with no usable cache.
166
+ *
167
+ * Fast Jev Compaction pilot (T7): also reads `eai_compaction_enabled` from
168
+ * this same response/cache entry -- one more field off a call this module
169
+ * already makes, not a second network round trip.
162
170
  */
163
171
  async function liveJevGuardrailFlags(
164
172
  deps: LiveCheckDeps = {},
165
- ): Promise<{ jevPolicyEnabled: boolean; jevOutputValidationEnabled: boolean } | null> {
173
+ ): Promise<{ jevPolicyEnabled: boolean; jevOutputValidationEnabled: boolean; compactionEnabled: boolean } | null> {
166
174
  const now = deps.now ?? Date.now
167
175
  const fetchImpl = deps.fetchImpl ?? fetch
168
176
  const readConfig = deps.readConfig ?? readElliotConfig
@@ -191,7 +199,11 @@ async function liveJevGuardrailFlags(
191
199
  }
192
200
 
193
201
  if (cache && cache.teamId === config.team_id && now() < cache.expiresAt) {
194
- return { jevPolicyEnabled: cache.jevPolicyEnabled, jevOutputValidationEnabled: cache.jevOutputValidationEnabled }
202
+ return {
203
+ jevPolicyEnabled: cache.jevPolicyEnabled,
204
+ jevOutputValidationEnabled: cache.jevOutputValidationEnabled,
205
+ compactionEnabled: cache.compactionEnabled,
206
+ }
195
207
  }
196
208
 
197
209
  const controller = new AbortController()
@@ -207,13 +219,15 @@ async function liveJevGuardrailFlags(
207
219
  const record = body && typeof body === "object" ? (body as Record<string, unknown>) : {}
208
220
  const jevPolicyEnabled = Boolean(record.jev_policy_enabled)
209
221
  const jevOutputValidationEnabled = Boolean(record.jev_output_validation_enabled)
222
+ const compactionEnabled = Boolean(record.eai_compaction_enabled)
210
223
  cache = {
211
224
  jevPolicyEnabled,
212
225
  jevOutputValidationEnabled,
226
+ compactionEnabled,
213
227
  teamId: config.team_id,
214
228
  expiresAt: now() + CACHE_TTL_MS,
215
229
  }
216
- return { jevPolicyEnabled, jevOutputValidationEnabled }
230
+ return { jevPolicyEnabled, jevOutputValidationEnabled, compactionEnabled }
217
231
  } catch {
218
232
  // Offline, backend unreachable, expired token, or a malformed response.
219
233
  // Never throw into the chat.params hook over this -- a CLI message must
@@ -221,7 +235,11 @@ async function liveJevGuardrailFlags(
221
235
  // to a still-fresh cached value for this same team if one exists;
222
236
  // otherwise genuinely unknown.
223
237
  return cache && cache.teamId === config.team_id && now() < cache.expiresAt
224
- ? { jevPolicyEnabled: cache.jevPolicyEnabled, jevOutputValidationEnabled: cache.jevOutputValidationEnabled }
238
+ ? {
239
+ jevPolicyEnabled: cache.jevPolicyEnabled,
240
+ jevOutputValidationEnabled: cache.jevOutputValidationEnabled,
241
+ compactionEnabled: cache.compactionEnabled,
242
+ }
225
243
  : null
226
244
  } finally {
227
245
  clearTimeout(timer)
@@ -253,6 +271,28 @@ export async function liveJevOutputValidationEnabled(deps: LiveCheckDeps = {}):
253
271
  return flags ? flags.jevOutputValidationEnabled : null
254
272
  }
255
273
 
274
+ /**
275
+ * Fast Jev Compaction pilot (spec: `specs/2026-10-29-fast-jev-compaction-
276
+ * pilot.md`, task T7) — the team's current `Team.eai_compaction_enabled`,
277
+ * live from the backend, with the exact same `null`-means-"leave it alone"
278
+ * contract as `liveJevPolicyEnabled`/`liveJevOutputValidationEnabled`.
279
+ * Shares the same cached `GET /teams/{id}` call under the hood, so calling
280
+ * this alongside either (or both) of the other two costs at most one
281
+ * network round trip, not two or three.
282
+ *
283
+ * Callers (index.ts's `experimental.chat.messages.transform` hook) must
284
+ * treat `null` and `false` identically here: skip compaction and leave
285
+ * `output.messages` exactly as `stripBlockedTurns` left them. Unlike the
286
+ * Jev guardrails, there is no security-load-bearing reason to fail closed
287
+ * on uncertainty -- this flag only decides whether a token-reduction pass
288
+ * runs at all, so "do nothing" is always the safe default when the answer
289
+ * is not confidently known.
290
+ */
291
+ export async function liveCompactionEnabled(deps: LiveCheckDeps = {}): Promise<boolean | null> {
292
+ const flags = await liveJevGuardrailFlags(deps)
293
+ return flags ? flags.compactionEnabled : null
294
+ }
295
+
256
296
  /**
257
297
  * Applies a live guardrail decision to the outgoing request's `options`,
258
298
  * mutating `metadata.guardrails` to match exactly — adding `guardrailName`
@@ -27,12 +27,22 @@ import { resolveEndUserId, type SessionClient } from "./identity-headers.js"
27
27
  import {
28
28
  applyGuardrailDecision,
29
29
  extractInstanceTeamId,
30
+ liveCompactionEnabled,
30
31
  liveJevOutputValidationEnabled,
31
32
  liveJevPolicyEnabled,
32
33
  OUTPUT_VALIDATION_GUARDRAIL_NAME,
33
34
  } from "./guardrail-sync.js"
34
- import { stampSessionAffinity } from "./session-affinity.js"
35
+ import { stampSessionAffinity, type CompactionTelemetry } from "./session-affinity.js"
35
36
  import { stripBlockedTurns } from "./blocked-turns.js"
37
+ import { readElliotConfig } from "./elliot-auth.js"
38
+ import {
39
+ compactMessages,
40
+ type AskerAnswer,
41
+ type AskerContext,
42
+ type CompactionMessage,
43
+ type ContentBlock,
44
+ type MessageRole,
45
+ } from "./compaction.js"
36
46
 
37
47
  // Structural mirrors of the host types (packages/plugin/src/index.ts @
38
48
  // v1.18.19). Declared locally so this package type-checks and unit-tests
@@ -125,6 +135,256 @@ export interface Hooks {
125
135
 
126
136
  export type Plugin = (input: PluginInput, options?: Record<string, unknown>) => Promise<Hooks>
127
137
 
138
+ // ---------------------------------------------------- compaction adapter --
139
+ //
140
+ // Fast Jev Compaction pilot (spec: `specs/2026-10-29-fast-jev-compaction-
141
+ // pilot.md`, task T7). `compaction.ts` (T6) is deliberately transport- and
142
+ // host-shape-agnostic: it only knows its own `CompactionMessage`/
143
+ // `ContentBlock` shape. Everything below adapts OpenCode's native history
144
+ // entry shape (`{ info, parts }`, the same one `blocked-turns.ts` reads) to
145
+ // and from that shape, and supplies the `asker` that calls the T3 backend
146
+ // proxy. None of this runs unless `liveCompactionEnabled` resolves `true`,
147
+ // and the whole attempt is wrapped in a single try/catch in the hook below
148
+ // so a bad response, a network failure, or a bug in this adapter can never
149
+ // break or delay a chat turn -- it only ever falls back to whatever
150
+ // `stripBlockedTurns` already produced.
151
+
152
+ /** Per-request timeout for one `keepCall`/`keepResult` judge call. Several
153
+ * of these may run per turn (one per candidate tool round trip); kept
154
+ * short so a hung backend cannot meaningfully delay a chat turn even
155
+ * before the outer try/catch gives up on compaction entirely. */
156
+ const COMPACTION_ASKER_TIMEOUT_MS = 5_000
157
+
158
+ interface NativeToolPartState {
159
+ status?: unknown
160
+ input?: unknown
161
+ output?: unknown
162
+ error?: unknown
163
+ [k: string]: unknown
164
+ }
165
+
166
+ interface NativeToolPart {
167
+ type: "tool"
168
+ tool?: unknown
169
+ callID?: unknown
170
+ state?: NativeToolPartState
171
+ [k: string]: unknown
172
+ }
173
+
174
+ type NativePart = { type?: unknown; text?: unknown } & Record<string, unknown>
175
+
176
+ interface NativeMessage {
177
+ info?: Record<string, unknown>
178
+ parts?: unknown[]
179
+ }
180
+
181
+ function nativeMessageRole(info: Record<string, unknown> | undefined): MessageRole {
182
+ if (info?.["role"] === "assistant") return "assistant"
183
+ if (info?.["role"] === "system") return "system"
184
+ return "user"
185
+ }
186
+
187
+ interface CompactionAdapterState {
188
+ readonly compactionMessages: CompactionMessage[]
189
+ /** Original native tool part per `callID`, used on the way back to
190
+ * restore everything compaction never needed to know about (metadata,
191
+ * attachments, timing) rather than rebuilding it from scratch. */
192
+ readonly toolPartsByCallId: Map<string, NativeToolPart>
193
+ /** Parts compaction has no vocabulary for (reasoning, step-start, etc.),
194
+ * keyed by the message's own `info` object reference -- `compaction.ts`
195
+ * carries `info` through unchanged on every surviving `CompactionMessage`
196
+ * (see its own module docstring), so looking it back up by identity is
197
+ * reliable regardless of what compaction dropped or kept. */
198
+ readonly passthroughPartsByInfo: Map<object, unknown[]>
199
+ }
200
+
201
+ /** Converts OpenCode's native `{ info, parts }` history entries into T6's
202
+ * `CompactionMessage` shape. A native "tool" part (OpenCode pairs the call
203
+ * and its result into ONE part, unlike Claude-style `tool_use`/
204
+ * `tool_result` content blocks) becomes a `tool_use` block immediately
205
+ * followed, in the SAME message, by a `tool_result` block once the call has
206
+ * actually completed or errored -- `compaction.ts`'s pairing logic matches
207
+ * blocks by id regardless of which message(s) they live in, so this is a
208
+ * faithful, simpler-than-splitting-messages encoding. */
209
+ function toCompactionMessages(nativeMessages: readonly NativeMessage[]): CompactionAdapterState {
210
+ const compactionMessages: CompactionMessage[] = []
211
+ const toolPartsByCallId = new Map<string, NativeToolPart>()
212
+ const passthroughPartsByInfo = new Map<object, unknown[]>()
213
+
214
+ for (const native of nativeMessages) {
215
+ const info = native.info
216
+ const role = nativeMessageRole(info)
217
+ const blocks: ContentBlock[] = []
218
+ const passthrough: unknown[] = []
219
+
220
+ for (const rawPart of native.parts ?? []) {
221
+ const part = rawPart as NativePart
222
+ if (part.type === "text" && typeof part.text === "string") {
223
+ blocks.push({ type: "text", text: part.text })
224
+ continue
225
+ }
226
+ if (part.type === "tool" && typeof part.callID === "string") {
227
+ const toolPart = part as NativeToolPart
228
+ const callId = part.callID
229
+ toolPartsByCallId.set(callId, toolPart)
230
+ blocks.push({
231
+ type: "tool_use",
232
+ id: callId,
233
+ name: typeof toolPart.tool === "string" ? toolPart.tool : "unknown",
234
+ input: toolPart.state?.input,
235
+ })
236
+ const status = toolPart.state?.status
237
+ if (status === "completed" || status === "error") {
238
+ const output = toolPart.state?.output
239
+ const errorValue = toolPart.state?.error
240
+ const content =
241
+ typeof output === "string"
242
+ ? output
243
+ : typeof errorValue === "string"
244
+ ? errorValue
245
+ : JSON.stringify(output ?? errorValue ?? "")
246
+ blocks.push({ type: "tool_result", tool_use_id: callId, content })
247
+ }
248
+ continue
249
+ }
250
+ passthrough.push(rawPart)
251
+ }
252
+
253
+ if (passthrough.length > 0 && info) passthroughPartsByInfo.set(info, passthrough)
254
+ compactionMessages.push({ role, content: blocks, info: info as CompactionMessage["info"] })
255
+ }
256
+
257
+ return { compactionMessages, toolPartsByCallId, passthroughPartsByInfo }
258
+ }
259
+
260
+ /** Reverses `toCompactionMessages`, rebuilding native `{ info, parts }`
261
+ * entries from T6's output. A kept pair's native tool part is reused
262
+ * verbatim (preserving fields compaction never touched); a truncated
263
+ * pair's part is cloned with only `state.output` replaced by the short
264
+ * "omitted" note `compaction.ts` already produced; a dropped pair simply
265
+ * never gets a part emitted. */
266
+ function fromCompactionMessages(
267
+ compactionMessages: readonly CompactionMessage[],
268
+ toolPartsByCallId: ReadonlyMap<string, NativeToolPart>,
269
+ passthroughPartsByInfo: ReadonlyMap<object, unknown[]>,
270
+ ): NativeMessage[] {
271
+ return compactionMessages.map((message) => {
272
+ const parts: unknown[] = []
273
+
274
+ if (typeof message.content === "string") {
275
+ if (message.content) parts.push({ type: "text", text: message.content })
276
+ } else {
277
+ for (const block of message.content) {
278
+ if (block.type === "text") {
279
+ parts.push({ type: "text", text: block.text })
280
+ continue
281
+ }
282
+ if (block.type === "tool_use") {
283
+ const original = toolPartsByCallId.get(block.id)
284
+ if (original) {
285
+ parts.push({
286
+ ...original,
287
+ state: original.state ? { ...original.state, input: block.input } : original.state,
288
+ })
289
+ }
290
+ continue
291
+ }
292
+ if (block.type === "tool_result") {
293
+ // Merge into the tool part this same loop just pushed for the
294
+ // matching `tool_use` -- `compaction.ts` always keeps a call's
295
+ // block immediately before its result's within one message (see
296
+ // `toCompactionMessages`), so it is always the last entry here.
297
+ const lastIndex = parts.length - 1
298
+ const last = parts[lastIndex] as NativeToolPart | undefined
299
+ if (last && last.type === "tool" && last.callID === block.tool_use_id) {
300
+ parts[lastIndex] = { ...last, state: { ...last.state, output: block.content } }
301
+ }
302
+ continue
303
+ }
304
+ }
305
+ }
306
+
307
+ const passthrough = message.info ? passthroughPartsByInfo.get(message.info) : undefined
308
+ if (passthrough) parts.push(...passthrough)
309
+
310
+ return { info: message.info as Record<string, unknown> | undefined, parts }
311
+ })
312
+ }
313
+
314
+ /**
315
+ * The T3 proxy's own request/response contract, independent of whatever
316
+ * OpenRouter's Decisions API itself returns -- T3 treats both `state` and
317
+ * `questions` as opaque and passes `body` straight through, so this plugin
318
+ * (the only side that understands the payload) defines the convention: one
319
+ * question per `asker` call, answered as `{ answers: [{ keepCall,
320
+ * keepResult }] }`. Any other shape is treated as "could not get an answer"
321
+ * and throws, which the outer try/catch in the hook below turns into
322
+ * "skip compaction entirely for this turn" -- never a partial, wrongly-
323
+ * judged compaction.
324
+ */
325
+ function parseDecisionAnswer(body: unknown): AskerAnswer {
326
+ const record = body && typeof body === "object" ? (body as Record<string, unknown>) : null
327
+ const answers = record && Array.isArray(record["answers"]) ? record["answers"] : null
328
+ const first = answers && answers.length > 0 ? answers[0] : null
329
+ const firstRecord = first && typeof first === "object" ? (first as Record<string, unknown>) : null
330
+ const keepCall = firstRecord ? Number(firstRecord["keepCall"]) : NaN
331
+ const keepResult = firstRecord ? Number(firstRecord["keepResult"]) : NaN
332
+ if (!Number.isFinite(keepCall) || !Number.isFinite(keepResult)) {
333
+ throw new Error("compaction decisions response did not contain a usable answer")
334
+ }
335
+ return { keepCall, keepResult }
336
+ }
337
+
338
+ /** Builds the `Asker` the T7 wiring passes to `compactMessages`: one POST to
339
+ * the T3 endpoint (`POST /opencode/teams/{team_id}/compaction/decisions`)
340
+ * per candidate tool round trip, reusing the exact fetch/auth pattern
341
+ * `guardrail-sync.ts` already uses for its own backend call (bearer JWT,
342
+ * `~/.elliot/config.json`'s `backend_url`/`team_id`, an `AbortController`-
343
+ * based timeout). Throws on ANY failure -- offline, non-2xx, `available:
344
+ * false`, or an unparseable body -- by design: the caller (the hook below)
345
+ * wraps the whole compaction attempt in one try/catch, so a single bad
346
+ * answer here safely aborts compaction for this turn rather than risking a
347
+ * wrongly-judged partial result. */
348
+ function makeCompactionAsker(config: {
349
+ backendUrl: string
350
+ teamId: string
351
+ jwtToken: string
352
+ fetchImpl?: typeof fetch
353
+ }): (context: AskerContext) => Promise<AskerAnswer> {
354
+ const fetchImpl = config.fetchImpl ?? fetch
355
+ const base = config.backendUrl.replace(/\/+$/, "")
356
+
357
+ return async (context: AskerContext): Promise<AskerAnswer> => {
358
+ const controller = new AbortController()
359
+ const timer = setTimeout(() => controller.abort(), COMPACTION_ASKER_TIMEOUT_MS)
360
+ try {
361
+ const res = await fetchImpl(`${base}/opencode/teams/${encodeURIComponent(config.teamId)}/compaction/decisions`, {
362
+ method: "POST",
363
+ headers: {
364
+ Authorization: `Bearer ${config.jwtToken}`,
365
+ "Content-Type": "application/json",
366
+ },
367
+ body: JSON.stringify({
368
+ state: context.state,
369
+ questions: [
370
+ { id: context.id, toolName: context.toolName, input: context.input, resultSummary: context.resultSummary },
371
+ ],
372
+ }),
373
+ signal: controller.signal,
374
+ })
375
+ if (!res.ok) throw new Error(`backend returned ${res.status}`)
376
+ const payload: unknown = await res.json()
377
+ const record = payload && typeof payload === "object" ? (payload as Record<string, unknown>) : {}
378
+ if (record["available"] !== true) {
379
+ throw new Error(typeof record["reason"] === "string" ? record["reason"] : "compaction decisions unavailable")
380
+ }
381
+ return parseDecisionAnswer(record["body"])
382
+ } finally {
383
+ clearTimeout(timer)
384
+ }
385
+ }
386
+ }
387
+
128
388
  export const ElliotPlugin: Plugin = async (input, rawOptions) => {
129
389
  // Fail loudly at load on bad options — a plugin that limps with a typo'd
130
390
  // config is worse than one that says why it refused to start.
@@ -134,6 +394,47 @@ export const ElliotPlugin: Plugin = async (input, rawOptions) => {
134
394
  // the one source the model cannot influence.
135
395
  const worktree = input.worktree || input.directory
136
396
 
397
+ // Fast Jev Compaction pilot (spec: `specs/2026-10-29-fast-jev-compaction-
398
+ // pilot.md`, task T9). `experimental.chat.messages.transform` is triggered
399
+ // with an EMPTY `hookInput` (session/prompt.ts), so the compaction outcome
400
+ // it produces can't be correlated to a session id from inside that hook.
401
+ // `chat.params` -- which DOES get `hookInput.sessionID` and already stamps
402
+ // `metadata.session_id` via `stampSessionAffinity` -- fires immediately
403
+ // after, for the SAME turn (session/llm/request.ts builds `chat.params`
404
+ // from the same `msgs` the transform hook just finished mutating).
405
+ //
406
+ // A FIFO QUEUE, not a single mutable slot (code-review finding, post-T9):
407
+ // this plugin instance is a process-wide singleton, and both hooks are
408
+ // `async` -- if two sessions' turns are ever in flight concurrently (very
409
+ // plausible once a turn's compaction attempt awaits a live-flag check plus
410
+ // one network round trip per stale tool-call candidate), a single shared
411
+ // slot lets a slower session A's `transform` overwrite what a faster
412
+ // session B's `transform` just set, before B's own `chat.params` reads it
413
+ // -- silently mislabeling B's `LiteLLM_SpendLogs` row with A's telemetry
414
+ // (or vice versa), which directly undermines the before/after cost
415
+ // comparison T9 exists for. A FIFO queue fixes this without needing a
416
+ // session id in the transform hook at all: each session's own
417
+ // `transform`-then-`params` pair is internally ordered (params always
418
+ // fires after that same turn's transform), so across interleaved
419
+ // sessions the order in which `transform` calls finish and push still
420
+ // matches the order in which the corresponding `chat.params` calls fire
421
+ // and shift -- unlike a single slot, this doesn't require the two calls
422
+ // to be adjacent in time, only in queue order.
423
+ const pendingCompactionTelemetryQueue: CompactionTelemetry[] = []
424
+
425
+ // Wall-clock budget for the ENTIRE compaction attempt (code-review
426
+ // finding, post-T7): `compactMessages` awaits one `asker` call per
427
+ // non-pinned tool-call candidate, sequentially, each already bounded by
428
+ // `COMPACTION_ASKER_TIMEOUT_MS` -- but nothing previously bounded the
429
+ // SUM across many candidates. A turn with N stale candidates could add up
430
+ // to N * COMPACTION_ASKER_TIMEOUT_MS of latency before the outer
431
+ // try/catch below ever got a chance to fail open (that catch only fires
432
+ // on a thrown error, not on cumulative slowness), undercutting "this
433
+ // feature must never be able to break or delay a chat turn." This budget
434
+ // is enforced by racing the whole `compactMessages` call against a timer
435
+ // below, not by changing `compactMessages` itself.
436
+ const COMPACTION_TURN_BUDGET_MS = 15_000
437
+
137
438
  return {
138
439
  /**
139
440
  * One handler, two jobs — a JS object literal may carry only one
@@ -216,9 +517,81 @@ export const ElliotPlugin: Plugin = async (input, rawOptions) => {
216
517
  */
217
518
  "experimental.chat.messages.transform": async (_hookInput, output) => {
218
519
  const filtered = stripBlockedTurns(output.messages)
219
- if (filtered === output.messages) return
220
- output.messages.length = 0
221
- output.messages.push(...filtered)
520
+ if (filtered !== output.messages) {
521
+ output.messages.length = 0
522
+ output.messages.push(...filtered)
523
+ }
524
+
525
+ // Fast Jev Compaction pilot (spec: `specs/2026-10-29-fast-jev-
526
+ // compaction-pilot.md`, task T7). Runs strictly AFTER `stripBlockedTurns`
527
+ // above -- compaction must never see a message that was already known
528
+ // to be a blocked turn `stripBlockedTurns` would otherwise have
529
+ // removed. Opt-in (`liveCompactionEnabled`) and entirely fail-open: a
530
+ // `null`/`false` decision, or ANY error/timeout from the live check,
531
+ // the adapter, or the backend call the `asker` makes, leaves
532
+ // `output.messages` exactly as `stripBlockedTurns` left them above --
533
+ // this feature must never be able to break or delay a chat turn,
534
+ // mirroring guardrail-sync.ts's own fail-open posture (see its module
535
+ // docstring) rather than the Jev guardrails' own fail-closed one,
536
+ // since this flag only decides whether a token-reduction pass runs at
537
+ // all, not a security boundary.
538
+ // This turn's own outcome, pushed onto the FIFO queue (see its
539
+ // declaration above) exactly once below -- every path (flag off,
540
+ // missing config, success, timeout, or the catch's fail-open) pushes
541
+ // before returning, so `chat.params` always has exactly one entry per
542
+ // turn to shift, in the same relative order these pushes happened.
543
+ let telemetry: CompactionTelemetry = { applied: false }
544
+ try {
545
+ const compactionDecision = await liveCompactionEnabled()
546
+ if (compactionDecision !== true) return
547
+
548
+ const config = readElliotConfig()
549
+ if (!config?.backend_url || !config.team_id || !config.jwt_token) return
550
+
551
+ const { compactionMessages, toolPartsByCallId, passthroughPartsByInfo } = toCompactionMessages(
552
+ output.messages as unknown as NativeMessage[],
553
+ )
554
+
555
+ const asker = makeCompactionAsker({
556
+ backendUrl: config.backend_url,
557
+ teamId: config.team_id,
558
+ jwtToken: config.jwt_token,
559
+ })
560
+
561
+ // Raced against COMPACTION_TURN_BUDGET_MS (see its declaration
562
+ // above) -- a turn with many stale candidates can't accumulate
563
+ // unbounded latency across sequential asker calls. A timeout here
564
+ // throws into the same catch below as any other failure, so the
565
+ // fail-open behavior is identical either way.
566
+ const result = await Promise.race([
567
+ compactMessages(compactionMessages, { asker }),
568
+ new Promise<never>((_resolve, reject) => {
569
+ setTimeout(() => reject(new Error("compaction turn budget exceeded")), COMPACTION_TURN_BUDGET_MS)
570
+ }),
571
+ ])
572
+ const rebuilt = fromCompactionMessages(result.messages, toolPartsByCallId, passthroughPartsByInfo)
573
+
574
+ output.messages.length = 0
575
+ output.messages.push(...(rebuilt as typeof output.messages))
576
+
577
+ // T9 telemetry: surface T6's own already-computed stats verbatim --
578
+ // no new computation -- for `chat.params` to stamp into
579
+ // `metadata.compaction_applied`/`compaction_state_tokens`/
580
+ // `compaction_reduction_ratio` via `stampSessionAffinity`.
581
+ telemetry = {
582
+ applied: true,
583
+ stateTokens: result.stats.stateTokens,
584
+ reductionRatio: result.stats.reductionRatio,
585
+ }
586
+ } catch {
587
+ // Any failure anywhere in the attempt above (live-flag check,
588
+ // adapter, the asker's HTTP call, compactMessages itself, or the
589
+ // turn-budget timeout) -- do nothing. `output.messages` already
590
+ // reflects whatever `stripBlockedTurns` produced; leave it exactly
591
+ // as is. `telemetry` stays `{ applied: false }` (T9).
592
+ } finally {
593
+ pendingCompactionTelemetryQueue.push(telemetry)
594
+ }
222
595
  },
223
596
 
224
597
  /**
@@ -264,7 +637,13 @@ export const ElliotPlugin: Plugin = async (input, rawOptions) => {
264
637
  * costs at most one network round trip, not two.
265
638
  */
266
639
  "chat.params": async (hookInput, output) => {
267
- stampSessionAffinity(output.options, hookInput.sessionID)
640
+ // T9: shift this turn's entry off the FIFO queue `experimental.chat.
641
+ // messages.transform` pushed for it (see the queue's declaration
642
+ // above). `shift()` on an empty queue (a turn that skipped the
643
+ // transform hook entirely) yields `undefined`, defaulted below --
644
+ // never a stale value from a different turn.
645
+ const compactionTelemetry = pendingCompactionTelemetryQueue.shift() ?? { applied: false }
646
+ stampSessionAffinity(output.options, hookInput.sessionID, compactionTelemetry)
268
647
  const instanceTeamId = extractInstanceTeamId(output.options)
269
648
  const preCallDecision = await liveJevPolicyEnabled({ instanceTeamId })
270
649
  applyGuardrailDecision(output.options, preCallDecision)
@@ -21,16 +21,39 @@
21
21
  * unchanged pre-existing request still goes out).
22
22
  */
23
23
 
24
+ /**
25
+ * Fast Jev Compaction pilot (spec: `specs/2026-10-29-fast-jev-compaction-
26
+ * pilot.md`, task T9) telemetry handed to `stampSessionAffinity` alongside
27
+ * the session id, so a before/after cost comparison can later be built
28
+ * purely from `LiteLLM_SpendLogs` metadata filtered by `session_id` and
29
+ * `compaction_applied` (see `litellm_spend.py`'s existing per-request
30
+ * `prompt_tokens`/`completion_tokens` fields) -- no new computation here,
31
+ * these are T6's own `CompactionStats` fields, surfaced as-is.
32
+ */
33
+ export interface CompactionTelemetry {
34
+ readonly applied: boolean
35
+ readonly stateTokens?: number
36
+ readonly reductionRatio?: number
37
+ }
38
+
24
39
  /**
25
40
  * Stamp the OpenCode session id into a request's `metadata.session_id` so
26
41
  * LiteLLM can route every turn of the same session to the same deployment.
27
42
  * `options` is the same merged options object `chat.params` hands the caller
28
43
  * (`opencode/.../session/llm/request.ts`), the one `metadata.guardrails`
29
44
  * already travels in.
45
+ *
46
+ * Also stamps `metadata.compaction_applied` (T9) -- `false`, with
47
+ * `compaction_state_tokens`/`compaction_reduction_ratio` absent, whenever
48
+ * `compaction` is omitted or `compaction.applied` is `false` (flag off, or
49
+ * the compaction attempt in `experimental.chat.messages.transform` failed
50
+ * open); `true` plus both numeric fields when it actually ran and produced
51
+ * a result.
30
52
  */
31
53
  export function stampSessionAffinity(
32
54
  options: Record<string, unknown> | null | undefined,
33
55
  sessionID: string | undefined,
56
+ compaction?: CompactionTelemetry,
34
57
  ): void {
35
58
  if (!options || typeof options !== "object" || !sessionID) return
36
59
  const metadata =
@@ -38,4 +61,13 @@ export function stampSessionAffinity(
38
61
  ? (options.metadata as Record<string, unknown>)
39
62
  : (options.metadata = {} as Record<string, unknown>)
40
63
  metadata["session_id"] = sessionID
64
+
65
+ metadata["compaction_applied"] = compaction?.applied ?? false
66
+ if (compaction?.applied) {
67
+ metadata["compaction_state_tokens"] = compaction.stateTokens
68
+ metadata["compaction_reduction_ratio"] = compaction.reductionRatio
69
+ } else {
70
+ delete metadata["compaction_state_tokens"]
71
+ delete metadata["compaction_reduction_ratio"]
72
+ }
41
73
  }