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/NOTICE +26 -19
- package/README.md +101 -120
- package/package.json +6 -4
- package/scripts/laya-server.py +113 -0
- package/scripts/report.mjs +332 -0
- package/src/server.ts +314 -515
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
|
+
}
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
|
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,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
|
|
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))}…`
|
|
270
|
+
return list
|
|
269
271
|
}
|
|
270
272
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
429
|
-
|
|
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
|
-
|
|
433
|
-
if (
|
|
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
|
-
|
|
440
|
-
|
|
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
|
-
|
|
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)
|
|
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(
|
|
348
|
+
input = JSON.stringify(candidate.input)
|
|
488
349
|
} catch {
|
|
489
350
|
input = "[unserializable]"
|
|
490
351
|
}
|
|
491
|
-
return `${
|
|
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
|
|
536
|
-
if (
|
|
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
|
|
567
|
-
|
|
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
|
-
//
|
|
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
|
|
575
|
-
const sig =
|
|
576
|
-
const
|
|
577
|
-
if (
|
|
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
|
|
582
|
-
if (
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
-
|
|
641
|
-
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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(
|
|
655
|
-
if (next ===
|
|
656
|
-
if (
|
|
657
|
-
else if (
|
|
658
|
-
memory.truncated.set(
|
|
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((
|
|
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) +
|
|
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
|
-
|
|
687
|
-
|
|
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:
|
|
524
|
+
calls: all.length,
|
|
714
525
|
dropped,
|
|
715
526
|
truncated,
|
|
716
|
-
|
|
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 `[
|
|
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: "
|
|
554
|
+
export default { id: "laya-compaction", server }
|