opencode-jev-compaction 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.ts CHANGED
@@ -1,97 +1,70 @@
1
- // jev-compaction — an opencode server plugin.
1
+ // jev-compaction v0.3 — opencode server plugin, deterministic-first.
2
2
  //
3
- // Strategy adapted from https://github.com/tamaratran/fast-jev-compaction (MIT):
4
- // never summarize on compaction. Instead ask a fast model, per tool call, whether the
5
- // call and whether its full output still need to be in context. Drop the ones that
6
- // don't, truncate the ones where only the fact of the call matters, and leave every
7
- // user and assistant message verbatim. See NOTICE for the attribution.
3
+ // HISTORY, because it explains the shape of this file:
4
+ // v0.1 asked Jev a judgement per tool call ("should this stay? knowing it was made
5
+ // still matters"). That produced mushy, drop-happy scores and once deleted a short file
6
+ // of hard constraints. Two independent findings agreed on why: with a `noul` primitive
7
+ // (calibrated P(true)), factual questions are reliable and judgement questions are not
8
+ // — measured elsewhere at 0.996 on an explicit fact versus 0.003-0.28 on judgements.
8
9
  //
9
- // Adapted to opencode's model: a `tool` part carries both the call (state.input) and
10
- // its result (state.output) together, so there is no orphaned-result case to guard
11
- // against the way the original has to.
10
+ // So v0.3 asks only facts, and computes what it can exactly:
12
11
  //
13
- // Safety: this runs before every model request. It never throws — any failure leaves
14
- // the messages exactly as they were.
12
+ // superseded a later call reads/writes the same target -> computed here
13
+ // errorResolved this call errored, a later call succeeded -> computed here
14
+ // referenced a later message or tool input mentions the target string
15
+ // -> computed here
16
+ // contentReferenced a later message quotes a value from the result body
17
+ // -> the one question left
18
+ // for the model
15
19
  //
16
- // TYPESAFE_API_KEY API key (required unless the keychain is configured)
17
- // JEV_KEYCHAIN_SERVICE macOS keychain service to read the key from
18
- // JEV_KEYCHAIN_ACCOUNT macOS keychain account to read the key from
19
- // JEV_COMPACTION=0 disable entirely
20
- // JEV_COMPACTION_THRESHOLD estimated tokens before it engages (default 60000)
21
- // JEV_KEEP_THRESHOLD minimum keep probability (default 0.35)
22
- // JEV_PRESERVE_RECENT newest messages never touched (default 6, minimum 1)
23
- // JEV_MAX_STATE_TOKENS ceiling for the state sent to Jev (default 25000)
24
- // JEV_MAX_REQUEST_TOKENS ceiling for state plus questions (default 30000)
25
- // JEV_TRUNCATE_HEAD chars of a dropped result retained (default 300)
26
- // JEV_SMALL_RESULT_CHARS results this size or smaller are shown to Jev in full (default 600)
27
- // JEV_TIMEOUT_MS per-request timeout (default 20000)
28
- // JEV_DAILY_REQUEST_CAP hard ceiling on Jev requests per day (default 200)
29
- // JEV_MODEL model name (default "jev-latest")
30
- // JEV_BASE_URL endpoint (default the System One endpoint)
31
- // JEV_DEBUG=1 append a trace to the debug log
32
-
33
- import { spawnSync } from "node:child_process"
20
+ // Deletion requires DETERMINISTIC evidence (superseded or error-resolved). The model can
21
+ // only ever justify a truncation, which keeps a head plus a "re-run if needed" note and
22
+ // is therefore recoverable. Nothing is ever dropped on a probabilistic answer.
23
+ //
24
+ // Payload: the old design resent a 25k-token state on every request, which cost about
25
+ // $1/day against a hosted model. A fact question needs only the target, a bounded
26
+ // excerpt of the result, and the messages that came after a few KB.
27
+
34
28
  import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
35
29
  import { homedir } from "node:os"
36
- import { join } from "node:path"
30
+ import { basename, join } from "node:path"
37
31
 
38
- const ENDPOINT = process.env.JEV_BASE_URL ?? "https://api.typesafe.ai/v1/systemone"
39
- const MODEL = process.env.JEV_MODEL ?? "jev-latest"
32
+ const ENDPOINT = process.env.LAYA_BASE_URL ?? process.env.JEV_BASE_URL ?? "http://127.0.0.1:8000/v1/systemone"
33
+ const MODEL = process.env.LAYA_MODEL ?? process.env.JEV_MODEL ?? "laya"
34
+ const API_KEY = process.env.LAYA_API_KEY ?? process.env.TYPESAFE_API_KEY ?? ""
40
35
 
41
- /** Parse a numeric setting, falling back rather than letting NaN disable a guard. */
42
36
  function num(value: string | undefined, fallback: number, min = 0): number {
43
37
  const parsed = Number(value)
44
38
  return Number.isFinite(parsed) && parsed >= min ? parsed : fallback
45
39
  }
46
40
 
47
- const ENABLED = process.env.JEV_COMPACTION !== "0"
48
- const THRESHOLD_TOKENS = num(process.env.JEV_COMPACTION_THRESHOLD, 60_000, 1)
49
- const MAX_STATE_TOKENS = num(process.env.JEV_MAX_STATE_TOKENS, 25_000, 1)
50
- const MAX_REQUEST_TOKENS = num(process.env.JEV_MAX_REQUEST_TOKENS, 30_000, 1)
51
- const KEEP_THRESHOLD = num(process.env.JEV_KEEP_THRESHOLD, 0.35)
52
- const PRESERVE_RECENT = Math.max(1, Math.floor(num(process.env.JEV_PRESERVE_RECENT, 6, 1)))
53
- const TRUNCATE_HEAD = Math.floor(num(process.env.JEV_TRUNCATE_HEAD, 300))
54
- const SMALL_RESULT_CHARS = Math.floor(num(process.env.JEV_SMALL_RESULT_CHARS, 600))
55
- const TIMEOUT_MS = num(process.env.JEV_TIMEOUT_MS, 20_000, 1)
56
- const DAILY_REQUEST_CAP = Math.floor(num(process.env.JEV_DAILY_REQUEST_CAP, 200))
41
+ const ENABLED = process.env.LAYA_COMPACTION !== "0" && process.env.JEV_COMPACTION !== "0"
42
+ const THRESHOLD_TOKENS = num(process.env.LAYA_COMPACTION_THRESHOLD, 60_000, 1)
43
+ const PRESERVE_RECENT = Math.max(1, Math.floor(num(process.env.LAYA_PRESERVE_RECENT, 6, 1)))
44
+ const SMALL_RESULT_CHARS = Math.floor(num(process.env.LAYA_SMALL_RESULT_CHARS, 600))
45
+ const TRUNCATE_HEAD = Math.floor(num(process.env.LAYA_TRUNCATE_HEAD, 300))
46
+ const EXCERPT_CHARS = Math.floor(num(process.env.LAYA_EXCERPT_CHARS, 400))
47
+ /** Laya's sequence budget is 512 tokens; the model sees only this much of what came after. */
48
+ const AFTER_CHARS = Math.floor(num(process.env.LAYA_AFTER_CHARS, 1000))
49
+ const REFERENCED_HIGH = num(process.env.LAYA_REFERENCED_HIGH, 0.7)
50
+ const REFERENCED_LOW = num(process.env.LAYA_REFERENCED_LOW, 0.3)
51
+ const TIMEOUT_MS = num(process.env.LAYA_TIMEOUT_MS, 8_000, 1)
52
+ const DAILY_REQUEST_CAP = Math.floor(num(process.env.LAYA_DAILY_REQUEST_CAP, 400))
53
+ const MAX_QUESTIONS = Math.floor(num(process.env.LAYA_MAX_QUESTIONS, 40))
54
+ const CONCURRENCY = Math.floor(num(process.env.LAYA_CONCURRENCY, 4, 1))
57
55
 
58
56
  const STATE_DIR = join(homedir(), ".local", "share", "opencode")
59
- const STATS_FILE = join(STATE_DIR, "jev-compaction.json")
60
- const CAP_FILE = join(STATE_DIR, "jev-compaction-usage.json")
61
- const DEBUG_FILE = join(STATE_DIR, "jev-compaction.log")
62
-
63
- const STATE_CONTEXT =
64
- "A coding assistant conversation is being compacted to free context. `history` is the whole " +
65
- "conversation so far, oldest first; tool outputs are replaced by a short `result` note and long " +
66
- "texts may be abridged. Each question asks whether one tool call, or the full output of that " +
67
- "call, still needs to stay in the history verbatim. Whatever is not kept is deleted permanently, " +
68
- "but the assistant can always re-run a tool or re-read a file."
69
-
70
- // Plugin modules are loaded once per server process, so this state persists across
71
- // the many transform calls a single session makes. Decisions are monotonic per call:
72
- // once dropped, always dropped.
73
- const decided = new Map<string, Action>()
74
- let cachedKey: string | undefined
75
- let counted: { day: string; requests: number } | undefined
76
-
77
- function trace(line: string, extra?: unknown) {
78
- if (process.env.JEV_DEBUG !== "1") return
79
- try {
80
- mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
81
- appendFileSync(
82
- DEBUG_FILE,
83
- `${new Date().toISOString()} ${line}${extra === undefined ? "" : " " + JSON.stringify(extra)}\n`,
84
- { mode: 0o600 },
85
- )
86
- } catch {}
87
- }
57
+ const STATS_FILE = join(STATE_DIR, "laya-compaction.json")
58
+ const LEDGER_FILE = join(STATE_DIR, "laya-compaction-ledger.jsonl")
59
+ const CAP_FILE = join(STATE_DIR, "laya-compaction-usage.json")
60
+ const DEBUG_FILE = join(STATE_DIR, "laya-compaction.log")
88
61
 
89
- // --- token estimate -----------------------------------------------------------
90
- // A word costs ~1 token per 6 letters, a digit half a token, any other symbol 0.9.
91
- // Lands slightly above the counts Jev reports, which is the safe direction.
62
+ const DECISION_FACT =
63
+ "Coding agent context pruning. `target` is what a tool call touched, `result_head` is the " +
64
+ "beginning of its output, and `after` is everything that came later in the conversation. " +
65
+ "Questions are answerable by inspection of `after`; answer only from what is present there."
92
66
 
93
67
  const TOKEN_PIECES = /[A-Za-z]+|\d+|[^\sA-Za-z\d]/g
94
-
95
68
  function estimateTokens(text: string): number {
96
69
  let tokens = 0
97
70
  for (const [piece] of text.matchAll(TOKEN_PIECES)) {
@@ -103,40 +76,23 @@ function estimateTokens(text: string): number {
103
76
  return Math.ceil(tokens)
104
77
  }
105
78
 
106
- // --- key ----------------------------------------------------------------------
107
-
108
- function apiKey(): string {
109
- if (cachedKey !== undefined) return cachedKey
110
- const env = process.env.TYPESAFE_API_KEY
111
- if (env && env.trim()) {
112
- cachedKey = env.trim()
113
- return cachedKey
114
- }
115
- const service = process.env.JEV_KEYCHAIN_SERVICE
116
- const account = process.env.JEV_KEYCHAIN_ACCOUNT
117
- if (service && account) {
118
- // Array args, no shell: env-derived values cannot be interpolated into a command.
119
- // Timeout so a locked keychain cannot block the pre-request path indefinitely.
120
- const result = spawnSync(
121
- "security",
122
- ["find-generic-password", "-s", service, "-a", account, "-w"],
123
- { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] },
124
- )
125
- cachedKey = result.status === 0 ? (result.stdout ?? "").trim() : ""
126
- trace("key resolved", { source: "keychain", found: cachedKey.length > 0 })
127
- return cachedKey
128
- }
129
- cachedKey = ""
130
- return cachedKey
79
+ function trace(line: string, extra?: unknown) {
80
+ if (process.env.LAYA_DEBUG !== "1" && process.env.JEV_DEBUG !== "1") return
81
+ try {
82
+ mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
83
+ appendFileSync(DEBUG_FILE, `${new Date().toISOString()} ${line}${extra === undefined ? "" : " " + JSON.stringify(extra)}\n`, { mode: 0o600 })
84
+ } catch {}
131
85
  }
132
86
 
133
87
  // --- spend ceiling -------------------------------------------------------------
134
88
 
135
- function today(): string {
136
- return new Date().toISOString().slice(0, 10)
137
- }
89
+ const counters = { transformCalls: 0, engaged: 0, belowThreshold: 0, capReached: 0, noBackend: 0 }
90
+ let lastFlush = 0
91
+ let counted: { day: string; requests: number } | undefined
92
+
93
+ const today = () => new Date().toISOString().slice(0, 10)
138
94
 
139
- function readUsage(): { day: string; requests: number } {
95
+ function readUsage() {
140
96
  try {
141
97
  const raw = JSON.parse(readFileSync(CAP_FILE, "utf8"))
142
98
  if (raw && raw.day === today()) return { day: raw.day, requests: Number(raw.requests) || 0 }
@@ -144,8 +100,7 @@ function readUsage(): { day: string; requests: number } {
144
100
  return { day: today(), requests: 0 }
145
101
  }
146
102
 
147
- /** Process-local counter so concurrent batches cannot lose increments. */
148
- function dayUsage(): { day: string; requests: number } {
103
+ function dayUsage() {
149
104
  if (!counted || counted.day !== today()) counted = readUsage()
150
105
  return counted
151
106
  }
@@ -157,64 +112,96 @@ function writeUsage(current: { day: string; requests: number }) {
157
112
  } catch {}
158
113
  }
159
114
 
160
- // --- asking -------------------------------------------------------------------
115
+ function updateStats(mutate: (stats: any) => void) {
116
+ try {
117
+ mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
118
+ let stats: any = {}
119
+ try {
120
+ stats = JSON.parse(readFileSync(STATS_FILE, "utf8"))
121
+ } catch {}
122
+ mutate(stats)
123
+ stats.updated = new Date().toISOString()
124
+ writeFileSync(STATS_FILE, JSON.stringify(stats, null, 2), { mode: 0o600 })
125
+ } catch {}
126
+ }
127
+
128
+ function flushCounters(force = false) {
129
+ const now = Date.now()
130
+ if (!force && now - lastFlush < 60_000) return
131
+ if (counters.transformCalls === 0 && counters.engaged === 0) return
132
+ lastFlush = now
133
+ const snapshot = { ...counters }
134
+ for (const key of Object.keys(counters) as Array<keyof typeof counters>) counters[key] = 0
135
+ updateStats((stats) => {
136
+ for (const [key, value] of Object.entries(snapshot)) stats[key] = (Number(stats[key]) || 0) + value
137
+ })
138
+ }
161
139
 
162
- type Question = { type: "noul"; instructions: string }
163
- type Answers = Record<string, { noul?: unknown }>
140
+ function appendLedger(entry: Record<string, unknown>) {
141
+ try {
142
+ mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
143
+ appendFileSync(LEDGER_FILE, JSON.stringify(entry) + "\n", { mode: 0o600 })
144
+ } catch {}
145
+ }
164
146
 
165
- async function ask(state: object, questions: Record<string, Question>): Promise<Answers> {
166
- const key = apiKey()
167
- if (!key) throw new Error("no Jev key configured (TYPESAFE_API_KEY or JEV_KEYCHAIN_SERVICE/JEV_KEYCHAIN_ACCOUNT)")
147
+ // --- backend -------------------------------------------------------------------
168
148
 
149
+ /**
150
+ * One residual question: does anything after this call depend on its output?
151
+ *
152
+ * Deliberately a `choice` rather than a `noul`. Measured against this same local model:
153
+ * a `noul` statement and its own negation both scored ~0.95, so it agreed with the shape
154
+ * of the question rather than reading it. As a two-option choice with explicit criteria
155
+ * the same cases separate cleanly (quotes 0.75-0.99 on a real quote, does-not 0.80-0.91
156
+ * on unrelated text). Do not "simplify" this back to a boolean statement.
157
+ */
158
+ async function askChoice(
159
+ state: object,
160
+ name: string,
161
+ instructions: string,
162
+ criteria: Record<string, string>,
163
+ ): Promise<{ choice?: string; probabilities?: Record<string, number> }> {
169
164
  const controller = new AbortController()
170
165
  const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
171
166
  try {
167
+ const headers: Record<string, string> = { "content-type": "application/json" }
168
+ if (API_KEY) headers.authorization = `Bearer ${API_KEY}`
172
169
  const response = await fetch(ENDPOINT, {
173
170
  method: "POST",
174
- headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
175
- body: JSON.stringify({ model: MODEL, state, questions }),
171
+ headers,
172
+ body: JSON.stringify({ model: MODEL, state, questions: { [name]: { type: "choice", instructions, criteria } } }),
176
173
  signal: controller.signal,
177
174
  })
178
- if (!response.ok) throw new Error(`jev request failed (${response.status})`)
175
+ if (!response.ok) throw new Error(`backend ${response.status}`)
179
176
  const parsed = JSON.parse(await response.text())
180
- if (!parsed || typeof parsed !== "object" || !("answers" in parsed) || !parsed.answers) {
181
- throw new Error("jev response missing answers")
182
- }
183
- trace("jev usage", {
184
- input: parsed.usage?.input_tokens,
185
- output: parsed.usage?.output_tokens,
186
- answers: Object.keys(parsed.answers ?? {}).length,
187
- })
188
- return parsed.answers as Answers
177
+ const answer = parsed?.answers?.[name]
178
+ if (!answer || typeof answer !== "object") throw new Error("no answer in response")
179
+ return { choice: answer.choice, probabilities: answer.probabilities }
189
180
  } finally {
190
181
  clearTimeout(timer)
191
182
  }
192
183
  }
193
184
 
194
- function noul(answers: Answers, name: string): number {
195
- const value = answers?.[name]?.noul
196
- if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`invalid jev answer for ${name}`)
197
- return value
198
- }
199
-
200
- // --- opencode part handling ---------------------------------------------------
185
+ // --- opencode parts ------------------------------------------------------------
201
186
 
202
187
  type Part = { id?: string; type?: string; tool?: string; callID?: string; state?: any; text?: string; [key: string]: any }
203
188
  type Message = { info?: any; parts?: Part[]; [key: string]: any }
204
- type Call = {
205
- id: string
189
+
190
+ type Candidate = {
206
191
  callID: string
207
192
  tool: string
208
193
  input: Record<string, unknown>
209
194
  output: string
210
195
  isError: boolean
211
196
  messageIndex: number
212
- /** The part itself, held by reference: dropping one part must not shift the others. */
213
197
  part: Part
214
- pinned: boolean
198
+ targets: string[]
199
+ key: string
215
200
  }
216
201
 
217
- function isFinishedToolPart(part: Part): boolean {
202
+ const TARGET_KEYS = /^(file_?path|filepath|path|file|filename|dir|directory|command|cmd|pattern|url|uri|query|name|target)$/i
203
+
204
+ function isFinished(part: Part): boolean {
218
205
  if (part?.type !== "tool") return false
219
206
  return part.state?.status === "completed" || part.state?.status === "error"
220
207
  }
@@ -232,242 +219,120 @@ function textOf(message: Message): string {
232
219
  .trim()
233
220
  }
234
221
 
222
+ /** Strings that identify what a call touched, for later-mention checks. */
223
+ function targetsOf(input: Record<string, unknown>): string[] {
224
+ const found = new Set<string>()
225
+ for (const [key, value] of Object.entries(input ?? {})) {
226
+ if (typeof value !== "string") continue
227
+ if (!TARGET_KEYS.test(key)) continue
228
+ const raw = value.trim()
229
+ if (raw.length < 3) continue
230
+ found.add(raw)
231
+ if (raw.includes("/")) {
232
+ const base = basename(raw)
233
+ if (base.length >= 3) found.add(base)
234
+ }
235
+ }
236
+ return [...found].slice(0, 4)
237
+ }
238
+
239
+ /** Comparable identity for supersession: same tool, same target. */
240
+ function identityOf(tool: string, targets: string[]): string {
241
+ const normalized = targets.map((t) => t.toLowerCase().replace(/\s+/g, " ").trim()).sort().join("|")
242
+ return `${tool}::${normalized}`
243
+ }
244
+
235
245
  function isPinned(index: number, total: number): boolean {
236
246
  return index === 0 || index >= total - PRESERVE_RECENT
237
247
  }
238
248
 
239
- function collectCalls(messages: Message[]): Call[] {
240
- const calls: Call[] = []
249
+ function candidatesOf(messages: Message[]): Candidate[] {
250
+ const list: Candidate[] = []
241
251
  messages.forEach((message, messageIndex) => {
242
252
  for (const part of message.parts ?? []) {
243
- if (!isFinishedToolPart(part)) continue
253
+ if (!isFinished(part)) continue
244
254
  const { text, isError } = outputOf(part)
245
- calls.push({
246
- id: `t${calls.length + 1}`,
247
- callID: String(part.callID ?? part.id ?? `p${calls.length + 1}`),
255
+ const input = (part.state?.input as Record<string, unknown>) ?? {}
256
+ const targets = targetsOf(input)
257
+ list.push({
258
+ callID: String(part.callID ?? part.id ?? `p${list.length + 1}`),
248
259
  tool: String(part.tool ?? "tool"),
249
- input: (part.state?.input as Record<string, unknown>) ?? {},
260
+ input,
250
261
  output: text,
251
262
  isError,
252
263
  messageIndex,
253
264
  part,
254
- pinned: isPinned(messageIndex, messages.length),
265
+ targets,
266
+ key: identityOf(String(part.tool ?? "tool"), targets.length ? targets : [JSON.stringify(input)]),
255
267
  })
256
268
  }
257
269
  })
258
- return calls
259
- }
260
-
261
- // --- state fitting ------------------------------------------------------------
262
-
263
- const INPUT_CHARS = [1000, 200, 60] as const
264
- const TEXT_HEAD = 400
265
- const TEXT_TAIL = 150
266
-
267
- function truncate(text: string, limit: number): string {
268
- return text.length <= limit ? text : `${text.slice(0, Math.max(0, limit - 1))}…`
270
+ return list
269
271
  }
270
272
 
271
- function abridge(text: string, head: number, tail: number): string {
272
- if (text.length <= head + tail + 40) return text
273
- return `${text.slice(0, head)}\n[… ${text.length - head - tail} chars omitted …]\n${text.slice(-tail)}`
274
- }
275
-
276
- function inputText(input: Record<string, unknown>, limit: number): string {
277
- try {
278
- return truncate(JSON.stringify(input), limit)
279
- } catch {
280
- return "[unserializable input]"
281
- }
282
- }
283
-
284
- function resultNote(call: Call): string {
285
- // Small results are sent in full. Replacing every result with a note hides the
286
- // evidence Jev needs: it cannot tell a throwaway file listing from a short file
287
- // of hard constraints, so it reasonably guesses "cheap to re-read" and drops
288
- // both. Showing what a small result actually says is what lets it tell them apart.
289
- if (call.output.length <= SMALL_RESULT_CHARS) return call.output
290
- return `${call.isError ? "error" : "ok"}, ${call.output.length} chars (omitted)`
291
- }
292
-
293
- function compactCall(call: Call): string {
294
- const input = Object.entries(call.input)
295
- .map(([key, value]) => {
296
- const text = typeof value === "string" ? value : inputText({ [key]: value }, 200)
297
- return `${key}=${text.replace(/\s+/g, " ")}`
298
- })
299
- .join(" ")
300
- return `${call.id} ${call.tool} ${truncate(input, INPUT_CHARS[2])} → ${call.isError ? "error" : "ok"} ${call.output.length}ch`
301
- }
302
-
303
- type Entry = { i: number; role: string; text: string; tool_calls?: Array<Record<string, string>> | string[] }
304
-
305
- function buildHistory(messages: Message[], calls: Call[], inputChars: number): Entry[] {
306
- const byMessage = new Map<number, Call[]>()
307
- for (const call of calls) {
308
- const list = byMessage.get(call.messageIndex) ?? []
309
- list.push(call)
310
- byMessage.set(call.messageIndex, list)
311
- }
312
- const entries: Entry[] = []
313
- messages.forEach((message, index) => {
314
- const toolCalls = (byMessage.get(index) ?? []).map((call) => ({
315
- id: call.id,
316
- tool: call.tool,
317
- input: inputText(call.input, inputChars),
318
- result: resultNote(call),
319
- }))
320
- const text = textOf(message)
321
- if (text.length === 0 && toolCalls.length === 0) return
322
- const entry: Entry = { i: index, role: String(message.info?.role ?? "user"), text }
323
- if (toolCalls.length > 0) entry.tool_calls = toolCalls
324
- entries.push(entry)
325
- })
326
- return entries
327
- }
328
-
329
- function goalFrom(messages: Message[]): string {
330
- return messages
331
- .filter((message) => message.info?.role === "user" && textOf(message).length > 0)
332
- .slice(-3)
333
- .map((message) => truncate(textOf(message), 500))
334
- .join("\n")
335
- }
336
-
337
- function fitState(messages: Message[], calls: Call[]): { state: object; tokens: number; stage: string } {
338
- const goal = goalFrom(messages)
339
- const stateOf = (history: Entry[]) => ({ context: STATE_CONTEXT, goal, history })
340
- const tokensOf = (history: Entry[]) =>
341
- estimateTokens(JSON.stringify(stateOf([]))) +
342
- history.reduce((sum, entry) => sum + estimateTokens(JSON.stringify(entry)) + 1, 0)
343
-
344
- for (const limit of INPUT_CHARS) {
345
- const history = buildHistory(messages, calls, limit)
346
- const tokens = tokensOf(history)
347
- if (tokens <= MAX_STATE_TOKENS) return { state: stateOf(history), tokens, stage: `inputs<=${limit}` }
348
- }
349
-
350
- const history = buildHistory(messages, calls, INPUT_CHARS[2])
351
- let tokens = tokensOf(history)
352
- const pinnedAt = (entry: Entry) => isPinned(entry.i, messages.length)
353
- const order = [
354
- ...history.map((_, i) => i).filter((i) => !pinnedAt(history[i]!)),
355
- ...history.map((_, i) => i).filter((i) => pinnedAt(history[i]!)),
356
- ]
357
-
358
- for (const index of order) {
359
- const entry = history[index]
360
- if (!entry || entry.text.length <= TEXT_HEAD + TEXT_TAIL + 40) continue
361
- entry.text = abridge(entry.text, TEXT_HEAD, TEXT_TAIL)
362
- tokens = tokensOf(history)
363
- if (tokens <= MAX_STATE_TOKENS) return { state: stateOf(history), tokens, stage: "texts abridged" }
364
- }
365
-
366
- for (const index of order) {
367
- const entry = history[index]
368
- if (!entry || pinnedAt(entry) || entry.text.length === 0) continue
369
- const original = textOf(messages[entry.i] ?? {}).length || entry.text.length
370
- entry.text = `[… ${original} chars omitted …]`
371
- tokens = tokensOf(history)
372
- if (tokens <= MAX_STATE_TOKENS) return { state: stateOf(history), tokens, stage: "old messages collapsed" }
373
- }
374
-
375
- const byMessage = new Map<number, Call[]>()
376
- for (const call of calls) {
377
- const list = byMessage.get(call.messageIndex) ?? []
378
- list.push(call)
379
- byMessage.set(call.messageIndex, list)
380
- }
381
- for (const index of order) {
382
- const entry = history[index]
383
- const own = entry ? byMessage.get(entry.i) : undefined
384
- if (!entry || pinnedAt(entry) || !own) continue
385
- entry.tool_calls = own.map(compactCall)
386
- tokens = tokensOf(history)
387
- if (tokens <= MAX_STATE_TOKENS) return { state: stateOf(history), tokens, stage: "old calls compacted" }
388
- }
389
-
390
- return { state: stateOf(history), tokens, stage: "overflow" }
391
- }
392
-
393
- // --- decisions -----------------------------------------------------------------
394
-
395
- type Action = "keep" | "drop_result" | "drop_call"
396
-
397
- function questionsFor(call: Call): Record<string, Question> {
398
- return {
399
- [`call_${call.id}`]: {
400
- type: "noul",
401
- instructions: `Tool call ${call.id} (${call.tool}) should stay in the history: knowing this call was made, with its input, still matters for what the assistant does next`,
402
- },
403
- [`result_${call.id}`]: {
404
- type: "noul",
405
- instructions: `The full output of tool call ${call.id} (${call.tool}, ${call.output.length} chars) should stay in the history verbatim: the assistant still needs its contents and re-running the tool would not do`,
406
- },
407
- }
408
- }
409
-
410
- const REQUEST_OVERHEAD_TOKENS = 20
411
-
412
- function batch(calls: Call[], stateTokens: number): Call[][] {
413
- const budget = MAX_REQUEST_TOKENS - stateTokens - REQUEST_OVERHEAD_TOKENS
414
- const batches: Call[][] = []
415
- let current: Call[] = []
416
- let currentTokens = 0
417
- for (const call of calls) {
418
- const tokens = estimateTokens(JSON.stringify(questionsFor(call)))
419
- if (current.length > 0 && currentTokens + tokens > budget) {
420
- batches.push(current)
421
- current = []
422
- currentTokens = 0
273
+ /**
274
+ * Everything after a position. `prose` is message text only: that is what the deterministic
275
+ * mention check uses, because a later call to the same target is supersession, not a
276
+ * reference, and counting its input as a mention would mask exactly that. `full` adds the
277
+ * later tool calls and is what the model sees.
278
+ */
279
+ function contextAfter(messages: Message[], index: number): { prose: string; full: string } {
280
+ const prose: string[] = []
281
+ const toolLines: string[] = []
282
+ for (let i = index + 1; i < messages.length && prose.length + toolLines.length < 40; i++) {
283
+ const text = textOf(messages[i]!)
284
+ if (text) prose.push(text.slice(0, 600))
285
+ for (const part of messages[i]?.parts ?? []) {
286
+ if (part?.type !== "tool") continue
287
+ toolLines.push(`called ${part.tool} with ${JSON.stringify(part.state?.input ?? {}).slice(0, 200)}`)
423
288
  }
424
- if (current.length === 0 && tokens > budget) throw new Error(`state leaves no room for questions (~${stateTokens} tokens)`)
425
- current.push(call)
426
- currentTokens += tokens
427
289
  }
428
- if (current.length > 0) batches.push(current)
429
- return batches
430
- }
290
+ return { prose: prose.join("\n"), full: [...prose, ...toolLines].join("\n") }
291
+ }
292
+
293
+ // --- policy --------------------------------------------------------------------
294
+
295
+ type Action = "keep" | "truncate" | "drop"
296
+ type Reason =
297
+ | "referenced"
298
+ | "superseded"
299
+ | "error-resolved"
300
+ | "small-result"
301
+ | "model-referenced"
302
+ | "model-unreferenced"
303
+ | "inconclusive"
304
+
305
+ function truncatedOutput(text: string, isError: boolean): string {
306
+ if (text.length <= TRUNCATE_HEAD + 120) return text
307
+ const head = TRUNCATE_HEAD > 0 ? `${text.slice(0, TRUNCATE_HEAD)}\n` : ""
308
+ return `${head}[laya-compaction truncated ${text.length - TRUNCATE_HEAD} chars of this tool result${isError ? " (error)" : ""}; re-run the tool if needed]`
309
+ }
310
+
311
+ function decide(candidate: Candidate, later: Candidate[], prose: string): { action: Action; reason: Reason } {
312
+ // 1. Something later names the target. Kept, no model needed.
313
+ const mentioned = candidate.targets.some((target) => prose.toLowerCase().includes(target.toLowerCase()))
314
+ if (mentioned) return { action: "keep", reason: "referenced" }
315
+
316
+ // 2. A later call with the same identity: this one is stale, and the answer is deterministic.
317
+ const sameKey = later.filter((other) => other.key === candidate.key)
318
+ if (sameKey.length > 0) {
319
+ return candidate.isError && sameKey.some((other) => !other.isError)
320
+ ? { action: "drop", reason: "error-resolved" }
321
+ : { action: "drop", reason: "superseded" }
322
+ }
431
323
 
432
- function decide(call: Call, keepCall: number, keepResult: number): Action {
433
- if (call.pinned) return "keep"
434
- if (keepResult >= KEEP_THRESHOLD) return "keep"
435
- if (keepCall >= KEEP_THRESHOLD) return "drop_result"
436
- return "drop_call"
437
- }
324
+ // 3. Short results are not worth touching, and this is the class v0.1 wrongly deleted.
325
+ if (candidate.output.length <= SMALL_RESULT_CHARS) return { action: "keep", reason: "small-result" }
438
326
 
439
- function truncatedOutput(call: Call): string {
440
- if (call.output.length <= TRUNCATE_HEAD + 120) return call.output
441
- const head = TRUNCATE_HEAD > 0 ? `${call.output.slice(0, TRUNCATE_HEAD)}\n` : ""
442
- return `${head}[jev-compaction truncated ${call.output.length - TRUNCATE_HEAD} chars of this tool result${call.isError ? " (error)" : ""}; re-run the tool if needed]`
327
+ // 4. Nothing deterministic either way. The model may only justify a truncation.
328
+ return { action: "truncate", reason: "inconclusive" }
443
329
  }
444
330
 
445
331
  // --- telemetry -----------------------------------------------------------------
446
- //
447
- // Savings alone tell you nothing about whether the decisions are good. The number
448
- // that matters is how often the model re-runs a tool whose result we dropped or
449
- // truncated: that is the direct, measurable cost of a wrong call. Everything here
450
- // exists so that trade-off is visible instead of assumed.
451
-
452
- const LEDGER_FILE = join(STATE_DIR, "jev-compaction-ledger.jsonl")
453
-
454
- /** Process-local counters, flushed on a throttle so hot paths stay cheap. */
455
- const counters = {
456
- transformCalls: 0,
457
- engaged: 0,
458
- belowThreshold: 0,
459
- capReached: 0,
460
- overflow: 0,
461
- noKey: 0,
462
- }
463
- let lastFlush = 0
464
- const FLUSH_MS = 60_000
465
332
 
466
- /** Per-session record of what we removed, so a later repeat can be attributed to us. */
467
- type SessionMemory = { dropped: Map<string, string>; truncated: Map<string, string> }
468
- const sessions = new Map<string, SessionMemory>()
333
+ const sessions = new Map<string, { dropped: Map<string, string>; truncated: Map<string, string> }>()
469
334
 
470
- function memoryFor(sessionID: string): SessionMemory {
335
+ function memoryFor(sessionID: string) {
471
336
  let entry = sessions.get(sessionID)
472
337
  if (!entry) {
473
338
  if (sessions.size > 200) sessions.clear()
@@ -477,51 +342,14 @@ function memoryFor(sessionID: string): SessionMemory {
477
342
  return entry
478
343
  }
479
344
 
480
- /**
481
- * Identifies "the same tool call" across steps regardless of its call id. A call
482
- * that reappears with a new id after we removed it is a re-run the model paid for.
483
- */
484
- function signature(call: Call): string {
345
+ function signatureOf(candidate: Candidate): string {
485
346
  let input = ""
486
347
  try {
487
- input = JSON.stringify(call.input)
348
+ input = JSON.stringify(candidate.input)
488
349
  } catch {
489
350
  input = "[unserializable]"
490
351
  }
491
- return `${call.tool}\u0000${input}`
492
- }
493
-
494
- function updateStats(mutate: (stats: any) => void) {
495
- try {
496
- mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
497
- let stats: any = {}
498
- try {
499
- stats = JSON.parse(readFileSync(STATS_FILE, "utf8"))
500
- } catch {}
501
- mutate(stats)
502
- stats.updated = new Date().toISOString()
503
- writeFileSync(STATS_FILE, JSON.stringify(stats, null, 2), { mode: 0o600 })
504
- } catch {}
505
- }
506
-
507
- /** Fold the in-memory counters into the stats file, at most once a minute. */
508
- function flushCounters(force = false) {
509
- const now = Date.now()
510
- if (!force && now - lastFlush < FLUSH_MS) return
511
- if (counters.transformCalls === 0 && counters.engaged === 0) return
512
- lastFlush = now
513
- const snapshot = { ...counters }
514
- for (const key of Object.keys(counters) as Array<keyof typeof counters>) counters[key] = 0
515
- updateStats((stats) => {
516
- for (const [key, value] of Object.entries(snapshot)) stats[key] = (Number(stats[key]) || 0) + value
517
- })
518
- }
519
-
520
- function appendLedger(entry: Record<string, unknown>) {
521
- try {
522
- mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
523
- appendFileSync(LEDGER_FILE, JSON.stringify(entry) + "\n", { mode: 0o600 })
524
- } catch {}
352
+ return `${candidate.tool}\u0000${input}`
525
353
  }
526
354
 
527
355
  // --- the pruner ----------------------------------------------------------------
@@ -532,8 +360,8 @@ async function prune(messages: Message[], reason: string): Promise<void> {
532
360
  counters.transformCalls += 1
533
361
  if (!Array.isArray(messages) || messages.length === 0) return
534
362
 
535
- const calls = collectCalls(messages)
536
- if (calls.length === 0) {
363
+ const all = candidatesOf(messages)
364
+ if (all.length === 0) {
537
365
  flushCounters()
538
366
  return
539
367
  }
@@ -541,21 +369,6 @@ async function prune(messages: Message[], reason: string): Promise<void> {
541
369
  const estimated = estimateTokens(JSON.stringify(messages))
542
370
  if (estimated < THRESHOLD_TOKENS) {
543
371
  counters.belowThreshold += 1
544
- trace("below threshold", { estimated, threshold: THRESHOLD_TOKENS })
545
- flushCounters()
546
- return
547
- }
548
-
549
- const allowed = Math.max(0, DAILY_REQUEST_CAP - dayUsage().requests)
550
- if (allowed === 0) {
551
- counters.capReached += 1
552
- trace("daily cap reached, skipping", { used: dayUsage().requests, cap: DAILY_REQUEST_CAP })
553
- flushCounters()
554
- return
555
- }
556
- if (!apiKey()) {
557
- counters.noKey += 1
558
- trace("no key, skipping")
559
372
  flushCounters()
560
373
  return
561
374
  }
@@ -563,99 +376,110 @@ async function prune(messages: Message[], reason: string): Promise<void> {
563
376
  const started = Date.now()
564
377
  const sessionID = String(messages[0]?.info?.sessionID ?? "unknown")
565
378
  const memory = memoryFor(sessionID)
566
- const candidates = calls.filter((call) => !call.pinned && !decided.has(call.callID))
567
- const tokensBefore = messages.reduce((sum, message) => sum + estimateTokens(JSON.stringify(message)), 0)
379
+ const tokensBefore = messages.reduce((total, message) => total + estimateTokens(JSON.stringify(message)), 0)
380
+
381
+ const candidates = all.filter((candidate) => !isPinned(candidate.messageIndex, messages.length))
382
+ const before = candidates.map((candidate) => {
383
+ const later = all.filter((other) => other.messageIndex > candidate.messageIndex)
384
+ const context = contextAfter(messages, candidate.messageIndex)
385
+ return { candidate, later, prose: context.prose, full: context.full }
386
+ })
568
387
 
569
- // A call that turns up again under a fresh id, after we removed or shortened the
570
- // original, is one the model had to pay for twice. Counted before this run's own
571
- // decisions so a re-run is never attributed to the decision that caused it.
388
+ // Count a call re-issued under a new id after we removed or shortened the original.
572
389
  let rerunAfterDrop = 0
573
390
  let rerunAfterTruncate = 0
574
- for (const call of calls) {
575
- const sig = signature(call)
576
- const droppedId = memory.dropped.get(sig)
577
- if (droppedId && droppedId !== call.callID) {
391
+ for (const candidate of all) {
392
+ const sig = signatureOf(candidate)
393
+ const dropped = memory.dropped.get(sig)
394
+ if (dropped && dropped !== candidate.callID) {
578
395
  rerunAfterDrop += 1
579
396
  memory.dropped.delete(sig)
580
397
  }
581
- const truncatedId = memory.truncated.get(sig)
582
- if (truncatedId && truncatedId !== call.callID) {
398
+ const truncated = memory.truncated.get(sig)
399
+ if (truncated && truncated !== candidate.callID) {
583
400
  rerunAfterTruncate += 1
584
401
  memory.truncated.delete(sig)
585
402
  }
586
403
  }
587
404
 
588
- let requests = 0
589
- let stage = "cache"
590
- if (candidates.length > 0) {
591
- const fitted = fitState(messages, calls)
592
- stage = fitted.stage
593
- if (fitted.stage === "overflow") {
594
- counters.overflow += 1
595
- trace("state overflow, skipping", { tokens: fitted.tokens })
596
- // Force the flush when something was pruned: the throttle exists to keep the
597
- // hot below-threshold path cheap, and a real prune is not on that path.
598
- flushCounters(counters.engaged > 0)
599
- return
600
- }
601
- // Reserve against the cap before firing: every request is already in flight by
602
- // the time the first answer returns, so checking only the total afterwards
603
- // would let a single run overshoot the ceiling.
604
- const send = batch(candidates, fitted.tokens).slice(0, allowed)
605
- if (send.length === 0) {
606
- trace("no request budget left for a batch", { stateTokens: fitted.tokens })
607
- return
608
- }
609
- dayUsage().requests += send.length
610
- writeUsage(dayUsage())
611
-
612
- const answered = await Promise.all(
613
- send.map(async (group) => {
614
- const questions = Object.assign({}, ...group.map(questionsFor))
615
- const answers = await ask(fitted.state, questions)
616
- requests += 1
617
- return group.map((call) => ({
618
- call,
619
- keepCall: noul(answers, `call_${call.id}`),
620
- keepResult: noul(answers, `result_${call.id}`),
621
- }))
622
- }),
623
- )
624
- if (decided.size > 5000) decided.clear()
625
- for (const group of answered) {
626
- for (const item of group) {
627
- const action = decide(item.call, item.keepCall, item.keepResult)
628
- trace("decision", {
629
- id: item.call.id,
630
- tool: item.call.tool,
631
- keepCall: item.keepCall,
632
- keepResult: item.keepResult,
633
- action,
634
- })
635
- decided.set(item.call.callID, action)
636
- }
405
+ const decisions = before.map(({ candidate, later, prose, full }) => ({
406
+ candidate,
407
+ full,
408
+ ...decide(candidate, later, prose),
409
+ }))
410
+
411
+ // Only the inconclusive, large-result cases go to the model, one small request each.
412
+ const uncertain = decisions.filter((entry) => entry.reason === "inconclusive").slice(0, MAX_QUESTIONS)
413
+ let asked = 0
414
+ let backendReachable = true
415
+
416
+ if (uncertain.length > 0) {
417
+ const allowed = Math.max(0, DAILY_REQUEST_CAP - dayUsage().requests)
418
+ const batch = uncertain.slice(0, allowed)
419
+ if (batch.length > 0) {
420
+ dayUsage().requests += batch.length
421
+ writeUsage(dayUsage())
422
+ const queue = [...batch]
423
+ const workers = Array.from({ length: Math.min(CONCURRENCY, queue.length) }, async () => {
424
+ while (queue.length > 0) {
425
+ const entry = queue.shift()
426
+ if (!entry) break
427
+ const name = `content_${entry.candidate.callID}`
428
+ try {
429
+ const answer = await askChoice(
430
+ {
431
+ context: DECISION_FACT,
432
+ target: entry.candidate.targets[0] ?? "",
433
+ result_head: entry.candidate.output.slice(0, EXCERPT_CHARS),
434
+ after: entry.full.slice(0, AFTER_CHARS),
435
+ },
436
+ name,
437
+ "Do the later messages quote or use any value that came from the earlier tool output?",
438
+ {
439
+ quotes: "a later message states a value that came from the earlier output",
440
+ "does-not": "no later message uses any value from the earlier output",
441
+ },
442
+ )
443
+ asked += 1
444
+ const quoted = Number(answer.probabilities?.quotes ?? 0)
445
+ entry.reason = quoted >= REFERENCED_HIGH ? "model-referenced" : "model-unreferenced"
446
+ entry.action = quoted >= REFERENCED_HIGH ? "keep" : "truncate"
447
+ entry.model = { choice: answer.choice, quotes: quoted }
448
+ } catch (error) {
449
+ backendReachable = false
450
+ trace("fact question failed", { id: entry.candidate.callID, error: String((error as Error)?.message ?? error) })
451
+ }
452
+ }
453
+ })
454
+ await Promise.all(workers)
455
+ } else {
456
+ counters.capReached += 1
637
457
  }
638
458
  }
639
459
 
640
- // Apply by part reference. Parts are held directly, so removing one cannot shift
641
- // the position of another in the same message.
460
+ if (!backendReachable && asked === 0 && uncertain.length > 0) counters.noBackend += 1
461
+
462
+ // Apply. Deletion only ever came from a deterministic reason; the model cannot cause one.
642
463
  const drop = new Set<Part>()
464
+ const reasonCounts: Record<string, number> = {}
643
465
  let dropped = 0
644
466
  let truncated = 0
645
- for (const call of calls) {
646
- const action = decided.get(call.callID)
647
- if (!action || action === "keep" || call.pinned) continue
648
- if (action === "drop_call") {
649
- drop.add(call.part)
650
- memory.dropped.set(signature(call), call.callID)
467
+
468
+ for (const entry of decisions) {
469
+ const { candidate, action, reason: why } = entry
470
+ reasonCounts[why] = (reasonCounts[why] ?? 0) + 1
471
+ if (action === "keep") continue
472
+ if (action === "drop") {
473
+ drop.add(candidate.part)
474
+ memory.dropped.set(signatureOf(candidate), candidate.callID)
651
475
  dropped += 1
652
476
  continue
653
477
  }
654
- const next = truncatedOutput(call)
655
- if (next === call.output) continue
656
- if (call.part.state?.status === "completed") call.part.state.output = next
657
- else if (call.part.state?.status === "error") call.part.state.error = next
658
- memory.truncated.set(signature(call), call.callID)
478
+ const next = truncatedOutput(candidate.output, candidate.isError)
479
+ if (next === candidate.output) continue
480
+ if (candidate.part.state?.status === "completed") candidate.part.state.output = next
481
+ else if (candidate.part.state?.status === "error") candidate.part.state.error = next
482
+ memory.truncated.set(signatureOf(candidate), candidate.callID)
659
483
  truncated += 1
660
484
  }
661
485
 
@@ -670,7 +494,7 @@ async function prune(messages: Message[], reason: string): Promise<void> {
670
494
  messages.length = 0
671
495
  messages.push(...kept)
672
496
 
673
- const tokensAfter = messages.reduce((sum, message) => sum + estimateTokens(JSON.stringify(message)), 0)
497
+ const tokensAfter = messages.reduce((total, message) => total + estimateTokens(JSON.stringify(message)), 0)
674
498
  const tokensSaved = Math.max(0, tokensBefore - tokensAfter)
675
499
  const ms = Date.now() - started
676
500
 
@@ -678,78 +502,53 @@ async function prune(messages: Message[], reason: string): Promise<void> {
678
502
  updateStats((stats) => {
679
503
  stats.runs = (Number(stats.runs) || 0) + 1
680
504
  stats.tokensSaved = (Number(stats.tokensSaved) || 0) + tokensSaved
681
- stats.callsSeen = (Number(stats.callsSeen) || 0) + calls.length
505
+ stats.callsSeen = (Number(stats.callsSeen) || 0) + all.length
682
506
  stats.dropped = (Number(stats.dropped) || 0) + dropped
683
507
  stats.truncated = (Number(stats.truncated) || 0) + truncated
508
+ stats.asked = (Number(stats.asked) || 0) + asked
684
509
  stats.rerunAfterDrop = (Number(stats.rerunAfterDrop) || 0) + rerunAfterDrop
685
510
  stats.rerunAfterTruncate = (Number(stats.rerunAfterTruncate) || 0) + rerunAfterTruncate
686
- stats.last = {
687
- tokensBefore,
688
- tokensAfter,
689
- tokensSaved,
690
- calls: calls.length,
691
- dropped,
692
- truncated,
693
- requests,
694
- ms,
695
- stage,
696
- rerunAfterDrop,
697
- rerunAfterTruncate,
698
- }
511
+ for (const [key, value] of Object.entries(reasonCounts)) stats[`reason_${key}`] = (Number(stats[`reason_${key}`]) || 0) + value
512
+ stats.last = { tokensBefore, tokensAfter, tokensSaved, calls: all.length, dropped, truncated, asked, ms, rerunAfterDrop, rerunAfterTruncate, reasons: reasonCounts }
699
513
  })
700
- flushCounters()
514
+ flushCounters(counters.engaged > 0)
701
515
 
702
- // One line per run that actually changed something, so the history can be
703
- // analysed later without having had debug logging on at the time.
704
516
  if (dropped > 0 || truncated > 0 || rerunAfterDrop > 0 || rerunAfterTruncate > 0) {
705
517
  appendLedger({
706
518
  at: new Date().toISOString(),
707
519
  session: sessionID,
708
520
  reason,
709
- stage,
710
521
  tokensBefore,
711
522
  tokensAfter,
712
523
  tokensSaved,
713
- calls: calls.length,
524
+ calls: all.length,
714
525
  dropped,
715
526
  truncated,
716
- requests,
527
+ asked,
717
528
  rerunAfterDrop,
718
529
  rerunAfterTruncate,
530
+ reasons: reasonCounts,
719
531
  ms,
720
532
  })
721
533
  }
722
- trace("pruned", {
723
- reason,
724
- session: sessionID,
725
- stage,
726
- requests,
727
- dropped,
728
- truncated,
729
- tokensSaved,
730
- rerunAfterDrop,
731
- rerunAfterTruncate,
732
- })
534
+ trace("pruned", { reason, session: sessionID, dropped, truncated, asked, tokensSaved, rerunAfterDrop, rerunAfterTruncate, reasons: reasonCounts })
733
535
  } catch (error) {
734
- trace("prune failed", { error: String((error as Error)?.message ?? error) })
536
+ trace("prune failed (messages untouched)", { error: String((error as Error)?.message ?? error) })
735
537
  }
736
538
  }
737
539
 
738
- // --- plugin --------------------------------------------------------------------
739
-
740
540
  async function server() {
741
541
  return {
742
542
  "experimental.chat.messages.transform": async (_input: unknown, output: { messages: Message[] }) => {
743
543
  await prune(output.messages, "step")
744
544
  },
745
-
746
545
  "experimental.session.compacting": async (_input: unknown, output: { context: string[]; prompt?: string }) => {
747
546
  output.context.push(
748
- "Tool results marked `[jev-compaction truncated …]` were shortened deliberately: the call is still " +
547
+ "Tool results marked `[laya-compaction truncated …]` were shortened deliberately: the call is still " +
749
548
  "historically accurate but the body was dropped as no longer needed. Do not treat them as tool failures.",
750
549
  )
751
550
  },
752
551
  }
753
552
  }
754
553
 
755
- export default { id: "jev-compaction", server }
554
+ export default { id: "laya-compaction", server }