opencode-jev-compaction 0.1.1 → 0.2.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/README.md +32 -0
- package/package.json +1 -1
- package/src/server.ts +171 -33
package/README.md
CHANGED
|
@@ -76,6 +76,38 @@ which is the point.
|
|
|
76
76
|
|
|
77
77
|
Set `JEV_DAILY_REQUEST_CAP` lower if you want a tighter bound.
|
|
78
78
|
|
|
79
|
+
## Metrics
|
|
80
|
+
|
|
81
|
+
Savings alone do not tell you whether the decisions are good, so the plugin records
|
|
82
|
+
the cost of being wrong too. All of it lives in `~/.local/share/opencode/`:
|
|
83
|
+
|
|
84
|
+
| File | What it is |
|
|
85
|
+
| --- | --- |
|
|
86
|
+
| `jev-compaction.json` | Running totals. `tokensSaved` uses the same calibrated estimator as the threshold, not a characters-per-token guess. |
|
|
87
|
+
| `jev-compaction-ledger.jsonl` | One line per run that changed something, for analysis over time. |
|
|
88
|
+
| `jev-compaction-usage.json` | Requests made today, against the daily ceiling. |
|
|
89
|
+
| `jev-compaction.log` | Per-decision trace, only when `JEV_DEBUG=1`. |
|
|
90
|
+
|
|
91
|
+
The totals include:
|
|
92
|
+
|
|
93
|
+
- `runs`, `tokensSaved`, `callsSeen`, `dropped`, `truncated`
|
|
94
|
+
- **`rerunAfterDrop`** and **`rerunAfterTruncate`** — the metrics that matter. A
|
|
95
|
+
re-run is detected when the model issues the same tool call, with the same input,
|
|
96
|
+
under a new id, after we removed or shortened the original. That is a decision the
|
|
97
|
+
model had to pay to undo.
|
|
98
|
+
- `transformCalls`, `engaged`, `belowThreshold`, `capReached`, `overflow`, `noKey` —
|
|
99
|
+
so you can tell "working well" apart from "never ran".
|
|
100
|
+
|
|
101
|
+
How to read it: if `rerunAfterDrop` climbs alongside `dropped`, the keep threshold is
|
|
102
|
+
too high or the questions are being asked about content Jev cannot see. If `dropped`
|
|
103
|
+
stays near zero and `belowThreshold` dominates, the trigger is higher than your
|
|
104
|
+
sessions ever reach and the plugin is dormant — raise nothing, lower
|
|
105
|
+
`JEV_COMPACTION_THRESHOLD` if you want it to actually act.
|
|
106
|
+
|
|
107
|
+
Each ledger line carries `tokensBefore`, `tokensAfter`, `tokensSaved`, `dropped`,
|
|
108
|
+
`truncated`, `requests`, `stage`, the re-run counts, and the `session`, so savings and
|
|
109
|
+
mistakes can be attributed rather than averaged over everything.
|
|
110
|
+
|
|
79
111
|
## How it works
|
|
80
112
|
|
|
81
113
|
1. Every finished `tool` part is a candidate, except those in the first message
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-jev-compaction",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "opencode plugins that replace lossy compaction with Jev decisions: score every tool call and result, drop or truncate the stale ones, keep everything else verbatim.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/server.ts
CHANGED
|
@@ -442,33 +442,85 @@ function truncatedOutput(call: Call): string {
|
|
|
442
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
443
|
}
|
|
444
444
|
|
|
445
|
-
// ---
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
445
|
+
// --- 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
|
+
|
|
466
|
+
/** Per-session record of what we removed, so a later repeat can be attributed to us. */
|
|
467
|
+
type SessionMemory = { dropped: Map<string, string>; truncated: Map<string, string> }
|
|
468
|
+
const sessions = new Map<string, SessionMemory>()
|
|
469
|
+
|
|
470
|
+
function memoryFor(sessionID: string): SessionMemory {
|
|
471
|
+
let entry = sessions.get(sessionID)
|
|
472
|
+
if (!entry) {
|
|
473
|
+
if (sessions.size > 200) sessions.clear()
|
|
474
|
+
entry = { dropped: new Map(), truncated: new Map() }
|
|
475
|
+
sessions.set(sessionID, entry)
|
|
476
|
+
}
|
|
477
|
+
return entry
|
|
478
|
+
}
|
|
479
|
+
|
|
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 {
|
|
485
|
+
let input = ""
|
|
486
|
+
try {
|
|
487
|
+
input = JSON.stringify(call.input)
|
|
488
|
+
} catch {
|
|
489
|
+
input = "[unserializable]"
|
|
490
|
+
}
|
|
491
|
+
return `${call.tool}\u0000${input}`
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function updateStats(mutate: (stats: any) => void) {
|
|
456
495
|
try {
|
|
457
496
|
mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
|
|
458
|
-
let
|
|
497
|
+
let stats: any = {}
|
|
459
498
|
try {
|
|
460
|
-
|
|
499
|
+
stats = JSON.parse(readFileSync(STATS_FILE, "utf8"))
|
|
461
500
|
} catch {}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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 })
|
|
472
524
|
} catch {}
|
|
473
525
|
}
|
|
474
526
|
|
|
@@ -477,30 +529,61 @@ function writeStats(delta: {
|
|
|
477
529
|
async function prune(messages: Message[], reason: string): Promise<void> {
|
|
478
530
|
if (!ENABLED) return
|
|
479
531
|
try {
|
|
532
|
+
counters.transformCalls += 1
|
|
480
533
|
if (!Array.isArray(messages) || messages.length === 0) return
|
|
481
534
|
|
|
482
535
|
const calls = collectCalls(messages)
|
|
483
|
-
if (calls.length === 0)
|
|
536
|
+
if (calls.length === 0) {
|
|
537
|
+
flushCounters()
|
|
538
|
+
return
|
|
539
|
+
}
|
|
484
540
|
|
|
485
541
|
const estimated = estimateTokens(JSON.stringify(messages))
|
|
486
542
|
if (estimated < THRESHOLD_TOKENS) {
|
|
543
|
+
counters.belowThreshold += 1
|
|
487
544
|
trace("below threshold", { estimated, threshold: THRESHOLD_TOKENS })
|
|
545
|
+
flushCounters()
|
|
488
546
|
return
|
|
489
547
|
}
|
|
490
548
|
|
|
491
549
|
const allowed = Math.max(0, DAILY_REQUEST_CAP - dayUsage().requests)
|
|
492
550
|
if (allowed === 0) {
|
|
551
|
+
counters.capReached += 1
|
|
493
552
|
trace("daily cap reached, skipping", { used: dayUsage().requests, cap: DAILY_REQUEST_CAP })
|
|
553
|
+
flushCounters()
|
|
494
554
|
return
|
|
495
555
|
}
|
|
496
556
|
if (!apiKey()) {
|
|
557
|
+
counters.noKey += 1
|
|
497
558
|
trace("no key, skipping")
|
|
559
|
+
flushCounters()
|
|
498
560
|
return
|
|
499
561
|
}
|
|
500
562
|
|
|
501
563
|
const started = Date.now()
|
|
564
|
+
const sessionID = String(messages[0]?.info?.sessionID ?? "unknown")
|
|
565
|
+
const memory = memoryFor(sessionID)
|
|
502
566
|
const candidates = calls.filter((call) => !call.pinned && !decided.has(call.callID))
|
|
503
|
-
const
|
|
567
|
+
const tokensBefore = messages.reduce((sum, message) => sum + estimateTokens(JSON.stringify(message)), 0)
|
|
568
|
+
|
|
569
|
+
// A call that turns up again under a fresh id, after we removed or shortened the
|
|
570
|
+
// original, is one the model had to pay for twice. Counted before this run's own
|
|
571
|
+
// decisions so a re-run is never attributed to the decision that caused it.
|
|
572
|
+
let rerunAfterDrop = 0
|
|
573
|
+
let rerunAfterTruncate = 0
|
|
574
|
+
for (const call of calls) {
|
|
575
|
+
const sig = signature(call)
|
|
576
|
+
const droppedId = memory.dropped.get(sig)
|
|
577
|
+
if (droppedId && droppedId !== call.callID) {
|
|
578
|
+
rerunAfterDrop += 1
|
|
579
|
+
memory.dropped.delete(sig)
|
|
580
|
+
}
|
|
581
|
+
const truncatedId = memory.truncated.get(sig)
|
|
582
|
+
if (truncatedId && truncatedId !== call.callID) {
|
|
583
|
+
rerunAfterTruncate += 1
|
|
584
|
+
memory.truncated.delete(sig)
|
|
585
|
+
}
|
|
586
|
+
}
|
|
504
587
|
|
|
505
588
|
let requests = 0
|
|
506
589
|
let stage = "cache"
|
|
@@ -508,7 +591,11 @@ async function prune(messages: Message[], reason: string): Promise<void> {
|
|
|
508
591
|
const fitted = fitState(messages, calls)
|
|
509
592
|
stage = fitted.stage
|
|
510
593
|
if (fitted.stage === "overflow") {
|
|
594
|
+
counters.overflow += 1
|
|
511
595
|
trace("state overflow, skipping", { tokens: fitted.tokens })
|
|
596
|
+
// Force the flush when something was pruned: the throttle exists to keep the
|
|
597
|
+
// hot below-threshold path cheap, and a real prune is not on that path.
|
|
598
|
+
flushCounters(counters.engaged > 0)
|
|
512
599
|
return
|
|
513
600
|
}
|
|
514
601
|
// Reserve against the cap before firing: every request is already in flight by
|
|
@@ -560,6 +647,7 @@ async function prune(messages: Message[], reason: string): Promise<void> {
|
|
|
560
647
|
if (!action || action === "keep" || call.pinned) continue
|
|
561
648
|
if (action === "drop_call") {
|
|
562
649
|
drop.add(call.part)
|
|
650
|
+
memory.dropped.set(signature(call), call.callID)
|
|
563
651
|
dropped += 1
|
|
564
652
|
continue
|
|
565
653
|
}
|
|
@@ -567,6 +655,7 @@ async function prune(messages: Message[], reason: string): Promise<void> {
|
|
|
567
655
|
if (next === call.output) continue
|
|
568
656
|
if (call.part.state?.status === "completed") call.part.state.output = next
|
|
569
657
|
else if (call.part.state?.status === "error") call.part.state.error = next
|
|
658
|
+
memory.truncated.set(signature(call), call.callID)
|
|
570
659
|
truncated += 1
|
|
571
660
|
}
|
|
572
661
|
|
|
@@ -581,17 +670,66 @@ async function prune(messages: Message[], reason: string): Promise<void> {
|
|
|
581
670
|
messages.length = 0
|
|
582
671
|
messages.push(...kept)
|
|
583
672
|
|
|
584
|
-
const
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
673
|
+
const tokensAfter = messages.reduce((sum, message) => sum + estimateTokens(JSON.stringify(message)), 0)
|
|
674
|
+
const tokensSaved = Math.max(0, tokensBefore - tokensAfter)
|
|
675
|
+
const ms = Date.now() - started
|
|
676
|
+
|
|
677
|
+
counters.engaged += 1
|
|
678
|
+
updateStats((stats) => {
|
|
679
|
+
stats.runs = (Number(stats.runs) || 0) + 1
|
|
680
|
+
stats.tokensSaved = (Number(stats.tokensSaved) || 0) + tokensSaved
|
|
681
|
+
stats.callsSeen = (Number(stats.callsSeen) || 0) + calls.length
|
|
682
|
+
stats.dropped = (Number(stats.dropped) || 0) + dropped
|
|
683
|
+
stats.truncated = (Number(stats.truncated) || 0) + truncated
|
|
684
|
+
stats.rerunAfterDrop = (Number(stats.rerunAfterDrop) || 0) + rerunAfterDrop
|
|
685
|
+
stats.rerunAfterTruncate = (Number(stats.rerunAfterTruncate) || 0) + rerunAfterTruncate
|
|
686
|
+
stats.last = {
|
|
687
|
+
tokensBefore,
|
|
688
|
+
tokensAfter,
|
|
689
|
+
tokensSaved,
|
|
690
|
+
calls: calls.length,
|
|
691
|
+
dropped,
|
|
692
|
+
truncated,
|
|
693
|
+
requests,
|
|
694
|
+
ms,
|
|
695
|
+
stage,
|
|
696
|
+
rerunAfterDrop,
|
|
697
|
+
rerunAfterTruncate,
|
|
698
|
+
}
|
|
699
|
+
})
|
|
700
|
+
flushCounters()
|
|
701
|
+
|
|
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
|
+
if (dropped > 0 || truncated > 0 || rerunAfterDrop > 0 || rerunAfterTruncate > 0) {
|
|
705
|
+
appendLedger({
|
|
706
|
+
at: new Date().toISOString(),
|
|
707
|
+
session: sessionID,
|
|
708
|
+
reason,
|
|
709
|
+
stage,
|
|
710
|
+
tokensBefore,
|
|
711
|
+
tokensAfter,
|
|
712
|
+
tokensSaved,
|
|
713
|
+
calls: calls.length,
|
|
714
|
+
dropped,
|
|
715
|
+
truncated,
|
|
716
|
+
requests,
|
|
717
|
+
rerunAfterDrop,
|
|
718
|
+
rerunAfterTruncate,
|
|
719
|
+
ms,
|
|
720
|
+
})
|
|
721
|
+
}
|
|
722
|
+
trace("pruned", {
|
|
723
|
+
reason,
|
|
724
|
+
session: sessionID,
|
|
725
|
+
stage,
|
|
726
|
+
requests,
|
|
588
727
|
dropped,
|
|
589
728
|
truncated,
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
729
|
+
tokensSaved,
|
|
730
|
+
rerunAfterDrop,
|
|
731
|
+
rerunAfterTruncate,
|
|
593
732
|
})
|
|
594
|
-
trace("pruned", { reason, estimated, stage, requests, dropped, truncated, savedChars: totalBefore - totalAfter })
|
|
595
733
|
} catch (error) {
|
|
596
734
|
trace("prune failed", { error: String((error as Error)?.message ?? error) })
|
|
597
735
|
}
|