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.
Files changed (3) hide show
  1. package/README.md +32 -0
  2. package/package.json +1 -1
  3. 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.1.1",
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
- // --- 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
- }) {
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 previous: any = {}
497
+ let stats: any = {}
459
498
  try {
460
- previous = JSON.parse(readFileSync(STATS_FILE, "utf8"))
499
+ stats = JSON.parse(readFileSync(STATS_FILE, "utf8"))
461
500
  } catch {}
462
- const next = {
463
- updated: new Date().toISOString(),
464
- runs: (Number(previous.runs) || 0) + 1,
465
- tokensSaved: (Number(previous.tokensSaved) || 0) + Math.round(delta.savedChars / 4),
466
- callsSeen: (Number(previous.callsSeen) || 0) + delta.calls,
467
- dropped: (Number(previous.dropped) || 0) + delta.dropped,
468
- truncated: (Number(previous.truncated) || 0) + delta.truncated,
469
- last: delta,
470
- }
471
- writeFileSync(STATS_FILE, JSON.stringify(next, null, 2), { mode: 0o600 })
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) return
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 totalBefore = messages.reduce((sum, message) => sum + JSON.stringify(message).length, 0)
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 totalAfter = messages.reduce((sum, message) => sum + JSON.stringify(message).length, 0)
585
- writeStats({
586
- savedChars: Math.max(0, totalBefore - totalAfter),
587
- calls: calls.length,
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
- requests,
591
- ms: Date.now() - started,
592
- stage,
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
  }