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.
@@ -0,0 +1,332 @@
1
+ #!/usr/bin/env node
2
+ // Measurement report for the jev-compaction plugin.
3
+ //
4
+ // Joins the plugin's own telemetry (what it removed, and whether the model had to
5
+ // re-run anything) to opencode's session records (what each session actually cost,
6
+ // how many tools it ran, how long it took). Neither half answers anything alone:
7
+ // savings without outcomes, or outcomes without knowing whether the plugin ran.
8
+ //
9
+ // node scripts/report.mjs # markdown to stdout
10
+ // node scripts/report.mjs --days 14 # limit the window
11
+ // node scripts/report.mjs --json # raw aggregates
12
+ // node scripts/report.mjs --exclude ses_a,ses_b
13
+ //
14
+ // Reads: ~/.local/share/opencode/jev-compaction{,-ledger.jsonl,-usage.json}
15
+ // and the session database via `opencode db ... --format json`
16
+
17
+ import { execFileSync } from "node:child_process"
18
+ import { readFileSync } from "node:fs"
19
+ import { homedir } from "node:os"
20
+ import { join } from "node:path"
21
+
22
+ const STATE = join(homedir(), ".local", "share", "opencode")
23
+ const STATS_FILE = join(STATE, "laya-compaction.json")
24
+ const LEDGER_FILE = join(STATE, "laya-compaction-ledger.jsonl")
25
+ const USAGE_FILE = join(STATE, "laya-compaction-usage.json")
26
+
27
+ function arg(name, fallback) {
28
+ const index = process.argv.indexOf(`--${name}`)
29
+ return index > -1 && process.argv[index + 1] ? process.argv[index + 1] : fallback
30
+ }
31
+
32
+ const DAYS = Number(arg("days", "0")) || 0
33
+ const EXCLUDE = new Set(String(arg("exclude", "")).split(",").map((s) => s.trim()).filter(Boolean))
34
+ const AS_JSON = process.argv.includes("--json")
35
+
36
+ // `opencode db` truncates its output at 64KB, which a full session table exceeds, so
37
+ // resolve the database path through opencode and query it directly.
38
+ const DB = execFileSync("opencode", ["db", "path"], { encoding: "utf8" }).trim()
39
+
40
+ function query(sql) {
41
+ const out = execFileSync("sqlite3", ["-json", DB, sql], { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 })
42
+ return out.trim() ? JSON.parse(out) : []
43
+ }
44
+
45
+ /** The model column is a JSON blob. */
46
+ function modelId(raw) {
47
+ try {
48
+ return JSON.parse(raw).id ?? "unknown"
49
+ } catch {
50
+ return raw ?? "unknown"
51
+ }
52
+ }
53
+
54
+ function ledger() {
55
+ try {
56
+ return readFileSync(LEDGER_FILE, "utf8")
57
+ .split("\n")
58
+ .filter(Boolean)
59
+ .map((line) => JSON.parse(line))
60
+ } catch {
61
+ return []
62
+ }
63
+ }
64
+
65
+ function stats() {
66
+ try {
67
+ return JSON.parse(readFileSync(STATS_FILE, "utf8"))
68
+ } catch {
69
+ return {}
70
+ }
71
+ }
72
+
73
+ function usage() {
74
+ try {
75
+ return JSON.parse(readFileSync(USAGE_FILE, "utf8"))
76
+ } catch {
77
+ return {}
78
+ }
79
+ }
80
+
81
+ const median = (values) => {
82
+ if (!values.length) return null
83
+ const sorted = [...values].sort((a, b) => a - b)
84
+ const mid = Math.floor(sorted.length / 2)
85
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
86
+ }
87
+ const sum = (values) => values.reduce((total, value) => total + value, 0)
88
+ const money = (value) => (value === null ? "-" : `$${value.toFixed(2)}`)
89
+ const num = (value) => (value === null ? "-" : Math.round(value).toLocaleString())
90
+ const pct = (a, b) => (b > 0 ? `${((a / b) * 100).toFixed(1)}%` : "-")
91
+
92
+ // --- gather -------------------------------------------------------------------
93
+
94
+ const sessions = query(`
95
+ select id, parent_id, model, cost,
96
+ tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
97
+ time_created, time_updated
98
+ from session
99
+ `)
100
+
101
+ const toolCounts = new Map(
102
+ query(`select session_id, count(*) as tools from part where json_extract(data,'$.type')='tool' group by session_id`)
103
+ .map((row) => [row.session_id, Number(row.tools)]),
104
+ )
105
+
106
+ const events = ledger()
107
+ const pruneBySession = new Map()
108
+ for (const event of events) {
109
+ if (!event.session) continue
110
+ const current = pruneBySession.get(event.session) ?? { tokensSaved: 0, dropped: 0, truncated: 0, requests: 0, rerunDrop: 0, rerunTruncate: 0, runs: 0 }
111
+ current.tokensSaved += event.tokensSaved ?? 0
112
+ current.dropped += event.dropped ?? 0
113
+ current.truncated += event.truncated ?? 0
114
+ current.requests += event.requests ?? 0
115
+ current.rerunDrop += event.rerunAfterDrop ?? 0
116
+ current.rerunTruncate += event.rerunAfterTruncate ?? 0
117
+ current.runs += 1
118
+ pruneBySession.set(event.session, current)
119
+ }
120
+
121
+ const cutoff = DAYS > 0 ? Date.now() - DAYS * 86_400_000 : 0
122
+
123
+ const decorated = sessions
124
+ .filter((row) => !EXCLUDE.has(row.id))
125
+ .filter((row) => !row.parent_id)
126
+ .filter((row) => (Number(row.time_created) || 0) >= cutoff)
127
+ .map((row) => {
128
+ const prune = pruneBySession.get(row.id)
129
+ const tokens =
130
+ (Number(row.tokens_input) || 0) + (Number(row.tokens_output) || 0) + (Number(row.tokens_cache_read) || 0)
131
+ return {
132
+ id: row.id,
133
+ model: modelId(row.model),
134
+ cost: Number(row.cost) || 0,
135
+ tokens,
136
+ tools: toolCounts.get(row.id) ?? 0,
137
+ minutes: Math.max(0, ((Number(row.time_updated) || 0) - (Number(row.time_created) || 0)) / 60_000),
138
+ created: Number(row.time_created) || 0,
139
+ pruned: Boolean(prune),
140
+ prune,
141
+ }
142
+ })
143
+
144
+ // Subagent share: the phenomenon the sidebar widget surfaces, measured directly.
145
+ const childCost = new Map()
146
+ for (const row of sessions) {
147
+ if (!row.parent_id) continue
148
+ childCost.set(row.parent_id, (childCost.get(row.parent_id) ?? 0) + (Number(row.cost) || 0))
149
+ }
150
+ const withChildren = decorated.filter((session) => childCost.has(session.id))
151
+ const rootCost = sum(withChildren.map((session) => session.cost))
152
+ const childrenCost = sum(withChildren.map((session) => childCost.get(session.id) ?? 0))
153
+
154
+ const cumulative = stats()
155
+ const today = usage()
156
+
157
+ // --- cohorts ------------------------------------------------------------------
158
+
159
+ function cohort(rows) {
160
+ const costs = rows.map((row) => row.cost).filter((value) => value > 0)
161
+ const tokens = rows.map((row) => row.tokens).filter((value) => value > 0)
162
+ const tools = rows.map((row) => row.tools).filter((value) => value > 0)
163
+ return {
164
+ n: rows.length,
165
+ costMedian: median(costs),
166
+ costTotal: sum(costs),
167
+ tokensMedian: median(tokens),
168
+ toolsMedian: median(tools),
169
+ minutesMedian: median(rows.map((row) => row.minutes)),
170
+ }
171
+ }
172
+
173
+ const prunedRows = decorated.filter((row) => row.pruned)
174
+ const unprunedRows = decorated.filter((row) => !row.pruned)
175
+
176
+ const earliestPrune = events.length ? Math.min(...events.map((event) => Date.parse(event.at) || Infinity)) : null
177
+ const before = earliestPrune ? decorated.filter((row) => row.created < earliestPrune) : []
178
+ const after = earliestPrune ? decorated.filter((row) => row.created >= earliestPrune) : []
179
+
180
+ const aggregate = {
181
+ window: {
182
+ sessions: decorated.length,
183
+ from: decorated.length ? new Date(Math.min(...decorated.map((row) => row.created))).toISOString().slice(0, 10) : null,
184
+ to: decorated.length ? new Date(Math.max(...decorated.map((row) => row.created))).toISOString().slice(0, 10) : null,
185
+ },
186
+ plugin: {
187
+ // 0.2.0 introduced these counters. An older install prunes but records none of
188
+ // them, and a naive read of that would look like a perfect re-run rate.
189
+ metricsAvailable: (cumulative.transformCalls ?? 0) > 0,
190
+ runs: cumulative.runs ?? 0,
191
+ tokensSaved: cumulative.tokensSaved ?? 0,
192
+ dropped: cumulative.dropped ?? 0,
193
+ truncated: cumulative.truncated ?? 0,
194
+ rerunAfterDrop: cumulative.rerunAfterDrop ?? 0,
195
+ rerunAfterTruncate: cumulative.rerunAfterTruncate ?? 0,
196
+ transformCalls: cumulative.transformCalls ?? 0,
197
+ engaged: cumulative.engaged ?? 0,
198
+ belowThreshold: cumulative.belowThreshold ?? 0,
199
+ capReached: cumulative.capReached ?? 0,
200
+ overflow: cumulative.overflow ?? 0,
201
+ noBackend: cumulative.noBackend ?? 0,
202
+ requestsToday: today.requests ?? 0,
203
+ },
204
+ reasons: Object.fromEntries(
205
+ Object.entries(cumulative)
206
+ .filter(([key]) => key.startsWith("reason_"))
207
+ .map(([key, value]) => [key.replace("reason_", ""), Number(value) || 0]),
208
+ ),
209
+ cohorts: { pruned: cohort(prunedRows), unpruned: cohort(unprunedRows) },
210
+ beforeAfter: { before: cohort(before), after: cohort(after) },
211
+ subagents: { rootsWithChildren: withChildren.length, rootCost, childrenCost },
212
+ }
213
+
214
+ if (AS_JSON) {
215
+ console.log(JSON.stringify(aggregate, null, 2))
216
+ process.exit(0)
217
+ }
218
+
219
+ // --- report -------------------------------------------------------------------
220
+
221
+ const lines = []
222
+ const push = (line = "") => lines.push(line)
223
+
224
+ push(`# laya-compaction report`)
225
+ push()
226
+ push(`Window: ${aggregate.window.from} to ${aggregate.window.to} · ${aggregate.window.sessions} root sessions${EXCLUDE.size ? ` · ${EXCLUDE.size} excluded` : ""}`)
227
+ push()
228
+
229
+ push(`## Is it doing anything?`)
230
+ push()
231
+ push(`| | |`)
232
+ push(`| --- | --- |`)
233
+ push(`| prunes that changed context | ${num(aggregate.plugin.runs)} |`)
234
+ push(`| transforms seen | ${num(aggregate.plugin.transformCalls)} |`)
235
+ push(`| dormant (below threshold) | ${num(aggregate.plugin.belowThreshold)} |`)
236
+ push(`| engaged | ${num(aggregate.plugin.engaged)} |`)
237
+ push(`| state overflow (skipped) | ${num(aggregate.plugin.overflow)} |`)
238
+ push(`| daily cap hit | ${num(aggregate.plugin.capReached)} |`)
239
+ push(`| backend unreachable | ${num(aggregate.plugin.noBackend)} |`)
240
+ push(`| requests today | ${aggregate.plugin.requestsToday} |`)
241
+ push()
242
+ if (!aggregate.plugin.metricsAvailable) {
243
+ push(`**Counters unavailable.** The installed version predates the telemetry, so engagement and re-run numbers are not being recorded at all. Everything below them is blank for that reason, not because nothing happened. Install 0.2.0 or later to start measuring.`)
244
+ push()
245
+ } else {
246
+ push(`Engagement rate: **${pct(aggregate.plugin.engaged, aggregate.plugin.transformCalls)}** of transforms did something.`)
247
+ if (aggregate.plugin.engaged === 0) push(`Nothing has been pruned yet — the plugin is dormant on this workload.`)
248
+ push()
249
+ }
250
+
251
+ push(`## Are the decisions good?`)
252
+ push()
253
+ push(`| | |`)
254
+ push(`| --- | --- |`)
255
+ push(`| tokens saved (estimated) | ${num(aggregate.plugin.tokensSaved)} |`)
256
+ push(`| calls dropped | ${num(aggregate.plugin.dropped)} |`)
257
+ push(`| results truncated | ${num(aggregate.plugin.truncated)} |`)
258
+ push(`| **re-run after drop** | **${num(aggregate.plugin.rerunAfterDrop)}** |`)
259
+ push(`| **re-run after truncate** | **${num(aggregate.plugin.rerunAfterTruncate)}** |`)
260
+ push()
261
+ push(`Re-run rate: **${pct(aggregate.plugin.rerunAfterDrop, aggregate.plugin.dropped)}** of drops and **${pct(aggregate.plugin.rerunAfterTruncate, aggregate.plugin.truncated)}** of truncations were undone by the model.`)
262
+ push()
263
+ if (!aggregate.plugin.metricsAvailable) {
264
+ push(`No verdict available: this install does not record re-runs, so a 0 here means "not measured", not "none happened".`)
265
+ push()
266
+ } else if (aggregate.plugin.dropped > 0) {
267
+ const rate = aggregate.plugin.rerunAfterDrop / aggregate.plugin.dropped
268
+ push(rate > 0.25
269
+ ? `A quarter or more of drops are being re-run. That is expensive: raise JEV_KEEP_THRESHOLD, or stop truncating results.`
270
+ : rate > 0.1
271
+ ? `Some drops come back. Worth watching, not yet alarming.`
272
+ : `Few drops come back. The judgement is holding up so far.`)
273
+ push()
274
+ }
275
+
276
+ if (Object.keys(aggregate.reasons).length > 0) {
277
+ push(`## Why decisions were made`)
278
+ push()
279
+ for (const [reason, count] of Object.entries(aggregate.reasons).sort((a, b) => b[1] - a[1])) {
280
+ push(`- \`${reason}\`: ${num(count)}`)
281
+ }
282
+ push()
283
+ push(`\`superseded\` and \`error-resolved\` are computed exactly; \`referenced\` and \`small-result\` are exact too. Only \`model-*\` involved the model, and a model answer can only ever cause a truncation.`)
284
+ push()
285
+ }
286
+
287
+ push(`## Pruned vs unpruned sessions`)
288
+ push()
289
+ push(`Cohorts, not causation: a session is only pruned once it is large, so the pruned cohort is longer by construction. Read the medians, not the totals.`)
290
+ push()
291
+ push(`| | pruned | unpruned |`)
292
+ push(`| --- | --- | --- |`)
293
+ push(`| sessions | ${prunedRows.length} | ${unprunedRows.length} |`)
294
+ push(`| median cost | ${money(aggregate.cohorts.pruned.costMedian)} | ${money(aggregate.cohorts.unpruned.costMedian)} |`)
295
+ push(`| total cost | ${money(aggregate.cohorts.pruned.costTotal)} | ${money(aggregate.cohorts.unpruned.costTotal)} |`)
296
+ push(`| median tokens | ${num(aggregate.cohorts.pruned.tokensMedian)} | ${num(aggregate.cohorts.unpruned.tokensMedian)} |`)
297
+ push(`| median tool calls | ${num(aggregate.cohorts.pruned.toolsMedian)} | ${num(aggregate.cohorts.unpruned.toolsMedian)} |`)
298
+ push(`| median minutes | ${num(aggregate.cohorts.pruned.minutesMedian)} | ${num(aggregate.cohorts.unpruned.minutesMedian)} |`)
299
+ push()
300
+
301
+ push(`## Before vs after the plugin was installed`)
302
+ push()
303
+ if (!earliestPrune) {
304
+ push(`No prunes recorded yet, so there is no boundary to split on.`)
305
+ } else {
306
+ push(`Split at the first recorded prune: **${new Date(earliestPrune).toISOString().slice(0, 16).replace("T", " ")}**`)
307
+ push()
308
+ push(`| | before | after |`)
309
+ push(`| --- | --- | --- |`)
310
+ push(`| sessions | ${before.length} | ${after.length} |`)
311
+ push(`| median cost | ${money(aggregate.beforeAfter.before.costMedian)} | ${money(aggregate.beforeAfter.after.costMedian)} |`)
312
+ push(`| median tokens | ${num(aggregate.beforeAfter.before.tokensMedian)} | ${num(aggregate.beforeAfter.after.tokensMedian)} |`)
313
+ push(`| median tool calls | ${num(aggregate.beforeAfter.before.toolsMedian)} | ${num(aggregate.beforeAfter.after.toolsMedian)} |`)
314
+ push()
315
+ if (after.length < 10) push(`Only ${after.length} sessions after the boundary — too few to read anything into yet.`)
316
+ push(`This is confounded: the workload changed, the models changed, and all plugins landed together. Treat it as a sanity check, not a result.`)
317
+ }
318
+ push()
319
+
320
+ push(`## The subagent blind spot`)
321
+ push()
322
+ if (withChildren.length === 0) {
323
+ push(`No sessions with child sessions in this window.`)
324
+ } else {
325
+ push(`${withChildren.length} sessions spawned subagents. Those children cost **${money(childrenCost)}** against **${money(rootCost)}** for their parents — **${pct(childrenCost, rootCost + childrenCost)}** of the total was invisible in the built-in sidebar before this work.`)
326
+ }
327
+ push()
328
+ push(`## Attribution`)
329
+ push()
330
+ push(`Three things were installed together, so no change here can be credited to one of them. For per-element attribution you would have to disable one at a time for a period — e.g. run a week with JEV_COMPACTION=0 and compare the same tables.`)
331
+
332
+ console.log(lines.join("\n"))