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/NOTICE +26 -19
- package/README.md +101 -88
- package/package.json +6 -4
- package/scripts/laya-server.py +113 -0
- package/scripts/report.mjs +332 -0
- package/src/server.ts +365 -428
package/src/server.ts
CHANGED
|
@@ -1,97 +1,70 @@
|
|
|
1
|
-
// jev-compaction —
|
|
1
|
+
// jev-compaction v0.3 — opencode server plugin, deterministic-first.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
14
|
-
//
|
|
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
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
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 ?? "
|
|
39
|
-
const MODEL = process.env.JEV_MODEL ?? "
|
|
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.
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
const
|
|
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, "
|
|
60
|
-
const
|
|
61
|
-
const
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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()
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
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(`
|
|
175
|
+
if (!response.ok) throw new Error(`backend ${response.status}`)
|
|
179
176
|
const parsed = JSON.parse(await response.text())
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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
|
-
|
|
198
|
+
targets: string[]
|
|
199
|
+
key: string
|
|
215
200
|
}
|
|
216
201
|
|
|
217
|
-
|
|
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
|
|
240
|
-
const
|
|
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 (!
|
|
253
|
+
if (!isFinished(part)) continue
|
|
244
254
|
const { text, isError } = outputOf(part)
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
260
|
+
input,
|
|
250
261
|
output: text,
|
|
251
262
|
isError,
|
|
252
263
|
messageIndex,
|
|
253
264
|
part,
|
|
254
|
-
|
|
265
|
+
targets,
|
|
266
|
+
key: identityOf(String(part.tool ?? "tool"), targets.length ? targets : [JSON.stringify(input)]),
|
|
255
267
|
})
|
|
256
268
|
}
|
|
257
269
|
})
|
|
258
|
-
return
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
.
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
|
338
|
-
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
351
|
-
|
|
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
|
-
|
|
367
|
-
|
|
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
|
-
// ---
|
|
331
|
+
// --- telemetry -----------------------------------------------------------------
|
|
394
332
|
|
|
395
|
-
|
|
333
|
+
const sessions = new Map<string, { dropped: Map<string, string>; truncated: Map<string, string> }>()
|
|
396
334
|
|
|
397
|
-
function
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
|
483
|
-
if (
|
|
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
|
|
492
|
-
if (
|
|
493
|
-
|
|
494
|
-
|
|
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
|
|
503
|
-
const
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
const
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
)
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
554
|
-
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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(
|
|
567
|
-
if (next ===
|
|
568
|
-
if (
|
|
569
|
-
else if (
|
|
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
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
-
|
|
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 `[
|
|
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: "
|
|
554
|
+
export default { id: "laya-compaction", server }
|