opencode-jev-compaction 0.1.1 → 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
+ }
161
127
 
162
- type Question = { type: "noul"; instructions: string }
163
- type Answers = Record<string, { noul?: unknown }>
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
+ }
164
139
 
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)")
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
+ }
168
146
 
147
+ // --- backend -------------------------------------------------------------------
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,244 +219,137 @@ 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))}…`
269
- }
270
-
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
- }
270
+ return list
282
271
  }
283
272
 
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)
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)}`)
288
+ }
311
289
  }
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
290
+ return { prose: prose.join("\n"), full: [...prose, ...toolLines].join("\n") }
327
291
  }
328
292
 
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")
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]`
335
309
  }
336
310
 
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}` }
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" }
348
322
  }
349
323
 
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
- }
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" }
365
326
 
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" }
327
+ // 4. Nothing deterministic either way. The model may only justify a truncation.
328
+ return { action: "truncate", reason: "inconclusive" }
391
329
  }
392
330
 
393
- // --- decisions -----------------------------------------------------------------
331
+ // --- telemetry -----------------------------------------------------------------
394
332
 
395
- type Action = "keep" | "drop_result" | "drop_call"
333
+ const sessions = new Map<string, { dropped: Map<string, string>; truncated: Map<string, string> }>()
396
334
 
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
- },
335
+ function memoryFor(sessionID: string) {
336
+ let entry = sessions.get(sessionID)
337
+ if (!entry) {
338
+ if (sessions.size > 200) sessions.clear()
339
+ entry = { dropped: new Map(), truncated: new Map() }
340
+ sessions.set(sessionID, entry)
407
341
  }
342
+ return entry
408
343
  }
409
344
 
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
423
- }
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
- }
428
- if (current.length > 0) batches.push(current)
429
- return batches
430
- }
431
-
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
- }
438
-
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]`
443
- }
444
-
445
- // --- stats ---------------------------------------------------------------------
446
-
447
- function writeStats(delta: {
448
- savedChars: number
449
- calls: number
450
- dropped: number
451
- truncated: number
452
- requests: number
453
- ms: number
454
- stage: string
455
- }) {
345
+ function signatureOf(candidate: Candidate): string {
346
+ let input = ""
456
347
  try {
457
- mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
458
- let previous: any = {}
459
- try {
460
- previous = JSON.parse(readFileSync(STATS_FILE, "utf8"))
461
- } catch {}
462
- const next = {
463
- updated: new Date().toISOString(),
464
- runs: (Number(previous.runs) || 0) + 1,
465
- tokensSaved: (Number(previous.tokensSaved) || 0) + Math.round(delta.savedChars / 4),
466
- callsSeen: (Number(previous.callsSeen) || 0) + delta.calls,
467
- dropped: (Number(previous.dropped) || 0) + delta.dropped,
468
- truncated: (Number(previous.truncated) || 0) + delta.truncated,
469
- last: delta,
470
- }
471
- writeFileSync(STATS_FILE, JSON.stringify(next, null, 2), { mode: 0o600 })
472
- } catch {}
348
+ input = JSON.stringify(candidate.input)
349
+ } catch {
350
+ input = "[unserializable]"
351
+ }
352
+ return `${candidate.tool}\u0000${input}`
473
353
  }
474
354
 
475
355
  // --- the pruner ----------------------------------------------------------------
@@ -477,96 +357,129 @@ function writeStats(delta: {
477
357
  async function prune(messages: Message[], reason: string): Promise<void> {
478
358
  if (!ENABLED) return
479
359
  try {
360
+ counters.transformCalls += 1
480
361
  if (!Array.isArray(messages) || messages.length === 0) return
481
362
 
482
- const calls = collectCalls(messages)
483
- if (calls.length === 0) return
484
-
485
- const estimated = estimateTokens(JSON.stringify(messages))
486
- if (estimated < THRESHOLD_TOKENS) {
487
- trace("below threshold", { estimated, threshold: THRESHOLD_TOKENS })
363
+ const all = candidatesOf(messages)
364
+ if (all.length === 0) {
365
+ flushCounters()
488
366
  return
489
367
  }
490
368
 
491
- const allowed = Math.max(0, DAILY_REQUEST_CAP - dayUsage().requests)
492
- if (allowed === 0) {
493
- trace("daily cap reached, skipping", { used: dayUsage().requests, cap: DAILY_REQUEST_CAP })
494
- return
495
- }
496
- if (!apiKey()) {
497
- trace("no key, skipping")
369
+ const estimated = estimateTokens(JSON.stringify(messages))
370
+ if (estimated < THRESHOLD_TOKENS) {
371
+ counters.belowThreshold += 1
372
+ flushCounters()
498
373
  return
499
374
  }
500
375
 
501
376
  const started = Date.now()
502
- const candidates = calls.filter((call) => !call.pinned && !decided.has(call.callID))
503
- const totalBefore = messages.reduce((sum, message) => sum + JSON.stringify(message).length, 0)
504
-
505
- let requests = 0
506
- let stage = "cache"
507
- if (candidates.length > 0) {
508
- const fitted = fitState(messages, calls)
509
- stage = fitted.stage
510
- if (fitted.stage === "overflow") {
511
- trace("state overflow, skipping", { tokens: fitted.tokens })
512
- return
377
+ const sessionID = String(messages[0]?.info?.sessionID ?? "unknown")
378
+ const memory = memoryFor(sessionID)
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
+ })
387
+
388
+ // Count a call re-issued under a new id after we removed or shortened the original.
389
+ let rerunAfterDrop = 0
390
+ let rerunAfterTruncate = 0
391
+ for (const candidate of all) {
392
+ const sig = signatureOf(candidate)
393
+ const dropped = memory.dropped.get(sig)
394
+ if (dropped && dropped !== candidate.callID) {
395
+ rerunAfterDrop += 1
396
+ memory.dropped.delete(sig)
513
397
  }
514
- // Reserve against the cap before firing: every request is already in flight by
515
- // the time the first answer returns, so checking only the total afterwards
516
- // would let a single run overshoot the ceiling.
517
- const send = batch(candidates, fitted.tokens).slice(0, allowed)
518
- if (send.length === 0) {
519
- trace("no request budget left for a batch", { stateTokens: fitted.tokens })
520
- return
398
+ const truncated = memory.truncated.get(sig)
399
+ if (truncated && truncated !== candidate.callID) {
400
+ rerunAfterTruncate += 1
401
+ memory.truncated.delete(sig)
521
402
  }
522
- dayUsage().requests += send.length
523
- writeUsage(dayUsage())
524
-
525
- const answered = await Promise.all(
526
- send.map(async (group) => {
527
- const questions = Object.assign({}, ...group.map(questionsFor))
528
- const answers = await ask(fitted.state, questions)
529
- requests += 1
530
- return group.map((call) => ({
531
- call,
532
- keepCall: noul(answers, `call_${call.id}`),
533
- keepResult: noul(answers, `result_${call.id}`),
534
- }))
535
- }),
536
- )
537
- if (decided.size > 5000) decided.clear()
538
- for (const group of answered) {
539
- for (const item of group) {
540
- const action = decide(item.call, item.keepCall, item.keepResult)
541
- trace("decision", {
542
- id: item.call.id,
543
- tool: item.call.tool,
544
- keepCall: item.keepCall,
545
- keepResult: item.keepResult,
546
- action,
547
- })
548
- decided.set(item.call.callID, action)
549
- }
403
+ }
404
+
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
550
457
  }
551
458
  }
552
459
 
553
- // Apply by part reference. Parts are held directly, so removing one cannot shift
554
- // 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.
555
463
  const drop = new Set<Part>()
464
+ const reasonCounts: Record<string, number> = {}
556
465
  let dropped = 0
557
466
  let truncated = 0
558
- for (const call of calls) {
559
- const action = decided.get(call.callID)
560
- if (!action || action === "keep" || call.pinned) continue
561
- if (action === "drop_call") {
562
- drop.add(call.part)
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)
563
475
  dropped += 1
564
476
  continue
565
477
  }
566
- const next = truncatedOutput(call)
567
- if (next === call.output) continue
568
- if (call.part.state?.status === "completed") call.part.state.output = next
569
- else if (call.part.state?.status === "error") call.part.state.error = next
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)
570
483
  truncated += 1
571
484
  }
572
485
 
@@ -581,37 +494,61 @@ async function prune(messages: Message[], reason: string): Promise<void> {
581
494
  messages.length = 0
582
495
  messages.push(...kept)
583
496
 
584
- const totalAfter = messages.reduce((sum, message) => sum + JSON.stringify(message).length, 0)
585
- writeStats({
586
- savedChars: Math.max(0, totalBefore - totalAfter),
587
- calls: calls.length,
588
- dropped,
589
- truncated,
590
- requests,
591
- ms: Date.now() - started,
592
- stage,
497
+ const tokensAfter = messages.reduce((total, message) => total + estimateTokens(JSON.stringify(message)), 0)
498
+ const tokensSaved = Math.max(0, tokensBefore - tokensAfter)
499
+ const ms = Date.now() - started
500
+
501
+ counters.engaged += 1
502
+ updateStats((stats) => {
503
+ stats.runs = (Number(stats.runs) || 0) + 1
504
+ stats.tokensSaved = (Number(stats.tokensSaved) || 0) + tokensSaved
505
+ stats.callsSeen = (Number(stats.callsSeen) || 0) + all.length
506
+ stats.dropped = (Number(stats.dropped) || 0) + dropped
507
+ stats.truncated = (Number(stats.truncated) || 0) + truncated
508
+ stats.asked = (Number(stats.asked) || 0) + asked
509
+ stats.rerunAfterDrop = (Number(stats.rerunAfterDrop) || 0) + rerunAfterDrop
510
+ stats.rerunAfterTruncate = (Number(stats.rerunAfterTruncate) || 0) + rerunAfterTruncate
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 }
593
513
  })
594
- trace("pruned", { reason, estimated, stage, requests, dropped, truncated, savedChars: totalBefore - totalAfter })
514
+ flushCounters(counters.engaged > 0)
515
+
516
+ if (dropped > 0 || truncated > 0 || rerunAfterDrop > 0 || rerunAfterTruncate > 0) {
517
+ appendLedger({
518
+ at: new Date().toISOString(),
519
+ session: sessionID,
520
+ reason,
521
+ tokensBefore,
522
+ tokensAfter,
523
+ tokensSaved,
524
+ calls: all.length,
525
+ dropped,
526
+ truncated,
527
+ asked,
528
+ rerunAfterDrop,
529
+ rerunAfterTruncate,
530
+ reasons: reasonCounts,
531
+ ms,
532
+ })
533
+ }
534
+ trace("pruned", { reason, session: sessionID, dropped, truncated, asked, tokensSaved, rerunAfterDrop, rerunAfterTruncate, reasons: reasonCounts })
595
535
  } catch (error) {
596
- trace("prune failed", { error: String((error as Error)?.message ?? error) })
536
+ trace("prune failed (messages untouched)", { error: String((error as Error)?.message ?? error) })
597
537
  }
598
538
  }
599
539
 
600
- // --- plugin --------------------------------------------------------------------
601
-
602
540
  async function server() {
603
541
  return {
604
542
  "experimental.chat.messages.transform": async (_input: unknown, output: { messages: Message[] }) => {
605
543
  await prune(output.messages, "step")
606
544
  },
607
-
608
545
  "experimental.session.compacting": async (_input: unknown, output: { context: string[]; prompt?: string }) => {
609
546
  output.context.push(
610
- "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 " +
611
548
  "historically accurate but the body was dropped as no longer needed. Do not treat them as tool failures.",
612
549
  )
613
550
  },
614
551
  }
615
552
  }
616
553
 
617
- export default { id: "jev-compaction", server }
554
+ export default { id: "laya-compaction", server }