opencode-jev-compaction 0.1.1
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/LICENSE +21 -0
- package/NOTICE +25 -0
- package/README.md +118 -0
- package/package.json +58 -0
- package/src/server.ts +617 -0
- package/src/tui.js +101 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 JLegends
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Attribution
|
|
2
|
+
|
|
3
|
+
The compaction strategy implemented here is adapted from
|
|
4
|
+
[fast-jev-compaction](https://github.com/tamaratran/fast-jev-compaction),
|
|
5
|
+
which is MIT licensed, Copyright (c) 2025.
|
|
6
|
+
|
|
7
|
+
Specifically derived from that project:
|
|
8
|
+
|
|
9
|
+
- the graded two-question decision protocol (`keepCall` / `keepResult` as
|
|
10
|
+
`noul` probabilities) and the keep / truncate-result / drop-call actions
|
|
11
|
+
- the staged state-fitting ladder and its constants (tool inputs truncated to
|
|
12
|
+
1000 / 200 / 60 characters, `TEXT_HEAD` 400, `TEXT_TAIL` 150)
|
|
13
|
+
- the calibrated token estimator (a word per six letters, half a token per
|
|
14
|
+
digit, 0.9 per other symbol)
|
|
15
|
+
- the pinning rule (first message plus the newest N)
|
|
16
|
+
- the state context wording and the batching-under-a-token-budget approach
|
|
17
|
+
|
|
18
|
+
This project is an independent reimplementation for
|
|
19
|
+
[opencode](https://opencode.ai), not a fork. It targets opencode's part model,
|
|
20
|
+
where a single `tool` part carries both the call and its result, so the
|
|
21
|
+
original's orphaned-result invariant is unnecessary. It also runs before every
|
|
22
|
+
model request rather than only at a compaction boundary, never throws (any
|
|
23
|
+
failure leaves the messages untouched), and adds a daily request ceiling.
|
|
24
|
+
|
|
25
|
+
All credit for the underlying idea and the decision protocol belongs upstream.
|
package/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# opencode-jev-compaction
|
|
2
|
+
|
|
3
|
+
Two [opencode](https://opencode.ai) plugins that replace lossy compaction with
|
|
4
|
+
decisions: ask a fast model which tool calls and results are still needed, drop
|
|
5
|
+
or truncate the ones that aren't, and leave every user and assistant message
|
|
6
|
+
verbatim.
|
|
7
|
+
|
|
8
|
+
- **`./server`** — the pruner. Runs before every model request, and adds a note
|
|
9
|
+
to the compaction prompt so shortened results aren't mistaken for failures.
|
|
10
|
+
- **`./tui`** — a sidebar widget showing how much context the pruner has removed.
|
|
11
|
+
|
|
12
|
+
Strategy adapted from [fast-jev-compaction](https://github.com/tamaratran/fast-jev-compaction) (MIT). See [NOTICE](./NOTICE).
|
|
13
|
+
|
|
14
|
+
## Why
|
|
15
|
+
|
|
16
|
+
When a context window fills, the usual answer is to summarize old turns. A
|
|
17
|
+
summary is lossy: a file path, an exact error, or a constraint can vanish even
|
|
18
|
+
when it matters later. This never rewrites anything. It only removes what a
|
|
19
|
+
model says is no longer needed, and everything kept stays byte-for-byte.
|
|
20
|
+
|
|
21
|
+
opencode already has a pruner, but its decision is purely recency and size — it
|
|
22
|
+
keeps a fixed window of recent tool output and erases the rest. This one decides
|
|
23
|
+
by relevance.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
opencode plugin opencode-jev-compaction --global
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
That detects both the `./server` and `./tui` entrypoints and writes each to the
|
|
32
|
+
right config (`opencode.json` for the server plugin, `tui.json` for the widget).
|
|
33
|
+
Restart opencode afterwards.
|
|
34
|
+
|
|
35
|
+
From a checkout instead:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
opencode plugin github:JLegends/opencode-jev-compaction --global
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Configure
|
|
42
|
+
|
|
43
|
+
The key comes from the environment, or from the macOS Keychain if you point it at
|
|
44
|
+
one:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
export TYPESAFE_API_KEY=... # or:
|
|
48
|
+
export JEV_KEYCHAIN_SERVICE=... # keychain service name
|
|
49
|
+
export JEV_KEYCHAIN_ACCOUNT=... # keychain account name
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
| Variable | Default | Purpose |
|
|
53
|
+
| --- | --- | --- |
|
|
54
|
+
| `TYPESAFE_API_KEY` | — | API key. Required unless the keychain is configured. |
|
|
55
|
+
| `JEV_KEYCHAIN_SERVICE` / `JEV_KEYCHAIN_ACCOUNT` | — | Read the key from the macOS Keychain instead of the environment. |
|
|
56
|
+
| `JEV_COMPACTION` | on | `0` disables everything. |
|
|
57
|
+
| `JEV_COMPACTION_THRESHOLD` | `60000` | Estimated tokens before it engages. Below this it does nothing and costs nothing. |
|
|
58
|
+
| `JEV_KEEP_THRESHOLD` | `0.35` | Minimum probability for a call or result to be kept. Lower keeps more; a call below it is deleted outright, which is irreversible, so this is deliberately conservative. |
|
|
59
|
+
| `JEV_PRESERVE_RECENT` | `6` | Newest messages never touched. Values below `1` are clamped to `1`; setting it to `0` drops the results the model is actively using and causes re-run loops. |
|
|
60
|
+
| `JEV_MAX_STATE_TOKENS` | `25000` | Ceiling for the state sent to Jev. |
|
|
61
|
+
| `JEV_MAX_REQUEST_TOKENS` | `30000` | Ceiling for state plus one batch of questions. |
|
|
62
|
+
| `JEV_TRUNCATE_HEAD` | `300` | Characters of a dropped result kept before its note. |
|
|
63
|
+
| `JEV_SMALL_RESULT_CHARS` | `600` | Results at or below this size are shown to Jev in full instead of as a note. |
|
|
64
|
+
| `JEV_TIMEOUT_MS` | `20000` | Per-request timeout. Failures are skipped silently. |
|
|
65
|
+
| `JEV_DAILY_REQUEST_CAP` | `200` | Hard ceiling on Jev requests per day. |
|
|
66
|
+
| `JEV_MODEL` | `jev-latest` | Model name. |
|
|
67
|
+
| `JEV_BASE_URL` | System One endpoint | Override the endpoint. |
|
|
68
|
+
| `JEV_DEBUG` | off | `1` appends a trace to `~/.local/share/opencode/jev-compaction.log`. |
|
|
69
|
+
|
|
70
|
+
## Cost
|
|
71
|
+
|
|
72
|
+
Jev is priced per input token with free output. At the default 25k state ceiling
|
|
73
|
+
and the 200-request daily cap, worst-case spend is about **$0.21/day**, and it
|
|
74
|
+
cannot exceed that. It also removes input tokens from every subsequent request,
|
|
75
|
+
which is the point.
|
|
76
|
+
|
|
77
|
+
Set `JEV_DAILY_REQUEST_CAP` lower if you want a tighter bound.
|
|
78
|
+
|
|
79
|
+
## How it works
|
|
80
|
+
|
|
81
|
+
1. Every finished `tool` part is a candidate, except those in the first message
|
|
82
|
+
or the newest `JEV_PRESERVE_RECENT` messages, which are pinned.
|
|
83
|
+
2. The whole conversation is sent as state, oldest first, with tool outputs
|
|
84
|
+
replaced by a short note (`ok, 4213 chars (omitted)`). Tool inputs and all
|
|
85
|
+
text are included. The state is shrunk in stages until it fits
|
|
86
|
+
`JEV_MAX_STATE_TOKENS`: inputs truncated to 1000, then 200, then 60
|
|
87
|
+
characters; long texts abridged head and tail; old messages collapsed;
|
|
88
|
+
old calls reduced to one line each. If it still doesn't fit, the run is
|
|
89
|
+
skipped.
|
|
90
|
+
3. Jev answers two graded questions per call: should the **call** stay, and
|
|
91
|
+
should the **result** stay verbatim. Questions are split into as many
|
|
92
|
+
requests as needed so state plus questions fits `JEV_MAX_REQUEST_TOKENS`, and
|
|
93
|
+
those requests run concurrently.
|
|
94
|
+
4. `keepResult >= threshold` keeps both. Otherwise `keepCall >= threshold` keeps
|
|
95
|
+
the call and truncates the result to its first `JEV_TRUNCATE_HEAD`
|
|
96
|
+
characters. Otherwise the call and its result go.
|
|
97
|
+
5. Decisions are cached per call for the life of the process and are monotonic:
|
|
98
|
+
once dropped, always dropped.
|
|
99
|
+
|
|
100
|
+
Nothing here throws. A missing key, a timeout, a malformed answer, or a history
|
|
101
|
+
too large to fit leaves the messages exactly as they were, so a Jev outage can
|
|
102
|
+
slow nothing down and break nothing.
|
|
103
|
+
|
|
104
|
+
## Requirements
|
|
105
|
+
|
|
106
|
+
- opencode `>= 1.18.31`
|
|
107
|
+
- A TypeSafe API key with access to Jev
|
|
108
|
+
|
|
109
|
+
## Not affiliated
|
|
110
|
+
|
|
111
|
+
Not built by, endorsed by, or affiliated with the opencode team or TypeSafe.
|
|
112
|
+
"opencode", "Jev", and "TypeSafe" are used only to describe what this plugs into.
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
MIT. The compaction strategy is adapted from
|
|
117
|
+
[fast-jev-compaction](https://github.com/tamaratran/fast-jev-compaction) (MIT) —
|
|
118
|
+
see [NOTICE](./NOTICE).
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-jev-compaction",
|
|
3
|
+
"version": "0.1.1",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "JLegends",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/JLegends/opencode-jev-compaction.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/JLegends/opencode-jev-compaction#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/JLegends/opencode-jev-compaction/issues"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
"./server": {
|
|
18
|
+
"import": "./src/server.ts"
|
|
19
|
+
},
|
|
20
|
+
"./tui": {
|
|
21
|
+
"import": "./src/tui.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"src",
|
|
26
|
+
"README.md",
|
|
27
|
+
"LICENSE",
|
|
28
|
+
"NOTICE"
|
|
29
|
+
],
|
|
30
|
+
"keywords": [
|
|
31
|
+
"opencode",
|
|
32
|
+
"opencode-plugin",
|
|
33
|
+
"compaction",
|
|
34
|
+
"context",
|
|
35
|
+
"tokens",
|
|
36
|
+
"jev",
|
|
37
|
+
"typesafe",
|
|
38
|
+
"cost"
|
|
39
|
+
],
|
|
40
|
+
"engines": {
|
|
41
|
+
"opencode": ">=1.18.31"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@opentui/solid": ">=0.4.5",
|
|
45
|
+
"solid-js": "^1.9.0"
|
|
46
|
+
},
|
|
47
|
+
"peerDependenciesMeta": {
|
|
48
|
+
"@opentui/solid": {
|
|
49
|
+
"optional": true
|
|
50
|
+
},
|
|
51
|
+
"solid-js": {
|
|
52
|
+
"optional": true
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"check": "node --check src/tui.js && bun build src/server.ts --target node --outfile .check.js && rm -f .check.js"
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
// jev-compaction — an opencode server plugin.
|
|
2
|
+
//
|
|
3
|
+
// Strategy adapted from https://github.com/tamaratran/fast-jev-compaction (MIT):
|
|
4
|
+
// never summarize on compaction. Instead ask a fast model, per tool call, whether the
|
|
5
|
+
// call and whether its full output still need to be in context. Drop the ones that
|
|
6
|
+
// don't, truncate the ones where only the fact of the call matters, and leave every
|
|
7
|
+
// user and assistant message verbatim. See NOTICE for the attribution.
|
|
8
|
+
//
|
|
9
|
+
// Adapted to opencode's model: a `tool` part carries both the call (state.input) and
|
|
10
|
+
// its result (state.output) together, so there is no orphaned-result case to guard
|
|
11
|
+
// against the way the original has to.
|
|
12
|
+
//
|
|
13
|
+
// Safety: this runs before every model request. It never throws — any failure leaves
|
|
14
|
+
// the messages exactly as they were.
|
|
15
|
+
//
|
|
16
|
+
// TYPESAFE_API_KEY API key (required unless the keychain is configured)
|
|
17
|
+
// JEV_KEYCHAIN_SERVICE macOS keychain service to read the key from
|
|
18
|
+
// JEV_KEYCHAIN_ACCOUNT macOS keychain account to read the key from
|
|
19
|
+
// JEV_COMPACTION=0 disable entirely
|
|
20
|
+
// JEV_COMPACTION_THRESHOLD estimated tokens before it engages (default 60000)
|
|
21
|
+
// JEV_KEEP_THRESHOLD minimum keep probability (default 0.35)
|
|
22
|
+
// JEV_PRESERVE_RECENT newest messages never touched (default 6, minimum 1)
|
|
23
|
+
// JEV_MAX_STATE_TOKENS ceiling for the state sent to Jev (default 25000)
|
|
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"
|
|
34
|
+
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
35
|
+
import { homedir } from "node:os"
|
|
36
|
+
import { join } from "node:path"
|
|
37
|
+
|
|
38
|
+
const ENDPOINT = process.env.JEV_BASE_URL ?? "https://api.typesafe.ai/v1/systemone"
|
|
39
|
+
const MODEL = process.env.JEV_MODEL ?? "jev-latest"
|
|
40
|
+
|
|
41
|
+
/** Parse a numeric setting, falling back rather than letting NaN disable a guard. */
|
|
42
|
+
function num(value: string | undefined, fallback: number, min = 0): number {
|
|
43
|
+
const parsed = Number(value)
|
|
44
|
+
return Number.isFinite(parsed) && parsed >= min ? parsed : fallback
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const ENABLED = process.env.JEV_COMPACTION !== "0"
|
|
48
|
+
const THRESHOLD_TOKENS = num(process.env.JEV_COMPACTION_THRESHOLD, 60_000, 1)
|
|
49
|
+
const MAX_STATE_TOKENS = num(process.env.JEV_MAX_STATE_TOKENS, 25_000, 1)
|
|
50
|
+
const MAX_REQUEST_TOKENS = num(process.env.JEV_MAX_REQUEST_TOKENS, 30_000, 1)
|
|
51
|
+
const KEEP_THRESHOLD = num(process.env.JEV_KEEP_THRESHOLD, 0.35)
|
|
52
|
+
const PRESERVE_RECENT = Math.max(1, Math.floor(num(process.env.JEV_PRESERVE_RECENT, 6, 1)))
|
|
53
|
+
const TRUNCATE_HEAD = Math.floor(num(process.env.JEV_TRUNCATE_HEAD, 300))
|
|
54
|
+
const SMALL_RESULT_CHARS = Math.floor(num(process.env.JEV_SMALL_RESULT_CHARS, 600))
|
|
55
|
+
const TIMEOUT_MS = num(process.env.JEV_TIMEOUT_MS, 20_000, 1)
|
|
56
|
+
const DAILY_REQUEST_CAP = Math.floor(num(process.env.JEV_DAILY_REQUEST_CAP, 200))
|
|
57
|
+
|
|
58
|
+
const STATE_DIR = join(homedir(), ".local", "share", "opencode")
|
|
59
|
+
const STATS_FILE = join(STATE_DIR, "jev-compaction.json")
|
|
60
|
+
const CAP_FILE = join(STATE_DIR, "jev-compaction-usage.json")
|
|
61
|
+
const DEBUG_FILE = join(STATE_DIR, "jev-compaction.log")
|
|
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
|
+
}
|
|
88
|
+
|
|
89
|
+
// --- token estimate -----------------------------------------------------------
|
|
90
|
+
// A word costs ~1 token per 6 letters, a digit half a token, any other symbol 0.9.
|
|
91
|
+
// Lands slightly above the counts Jev reports, which is the safe direction.
|
|
92
|
+
|
|
93
|
+
const TOKEN_PIECES = /[A-Za-z]+|\d+|[^\sA-Za-z\d]/g
|
|
94
|
+
|
|
95
|
+
function estimateTokens(text: string): number {
|
|
96
|
+
let tokens = 0
|
|
97
|
+
for (const [piece] of text.matchAll(TOKEN_PIECES)) {
|
|
98
|
+
const first = piece.charCodeAt(0)
|
|
99
|
+
if (first >= 48 && first <= 57) tokens += piece.length / 2
|
|
100
|
+
else if ((first >= 65 && first <= 90) || (first >= 97 && first <= 122)) tokens += 1 + Math.floor((piece.length - 1) / 6)
|
|
101
|
+
else tokens += 0.9
|
|
102
|
+
}
|
|
103
|
+
return Math.ceil(tokens)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// --- key ----------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
function apiKey(): string {
|
|
109
|
+
if (cachedKey !== undefined) return cachedKey
|
|
110
|
+
const env = process.env.TYPESAFE_API_KEY
|
|
111
|
+
if (env && env.trim()) {
|
|
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
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// --- spend ceiling -------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
function today(): string {
|
|
136
|
+
return new Date().toISOString().slice(0, 10)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function readUsage(): { day: string; requests: number } {
|
|
140
|
+
try {
|
|
141
|
+
const raw = JSON.parse(readFileSync(CAP_FILE, "utf8"))
|
|
142
|
+
if (raw && raw.day === today()) return { day: raw.day, requests: Number(raw.requests) || 0 }
|
|
143
|
+
} catch {}
|
|
144
|
+
return { day: today(), requests: 0 }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Process-local counter so concurrent batches cannot lose increments. */
|
|
148
|
+
function dayUsage(): { day: string; requests: number } {
|
|
149
|
+
if (!counted || counted.day !== today()) counted = readUsage()
|
|
150
|
+
return counted
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function writeUsage(current: { day: string; requests: number }) {
|
|
154
|
+
try {
|
|
155
|
+
mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
|
|
156
|
+
writeFileSync(CAP_FILE, JSON.stringify(current, null, 2), { mode: 0o600 })
|
|
157
|
+
} catch {}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// --- asking -------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
type Question = { type: "noul"; instructions: string }
|
|
163
|
+
type Answers = Record<string, { noul?: unknown }>
|
|
164
|
+
|
|
165
|
+
async function ask(state: object, questions: Record<string, Question>): Promise<Answers> {
|
|
166
|
+
const key = apiKey()
|
|
167
|
+
if (!key) throw new Error("no Jev key configured (TYPESAFE_API_KEY or JEV_KEYCHAIN_SERVICE/JEV_KEYCHAIN_ACCOUNT)")
|
|
168
|
+
|
|
169
|
+
const controller = new AbortController()
|
|
170
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
|
|
171
|
+
try {
|
|
172
|
+
const response = await fetch(ENDPOINT, {
|
|
173
|
+
method: "POST",
|
|
174
|
+
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
|
|
175
|
+
body: JSON.stringify({ model: MODEL, state, questions }),
|
|
176
|
+
signal: controller.signal,
|
|
177
|
+
})
|
|
178
|
+
if (!response.ok) throw new Error(`jev request failed (${response.status})`)
|
|
179
|
+
const parsed = JSON.parse(await response.text())
|
|
180
|
+
if (!parsed || typeof parsed !== "object" || !("answers" in parsed) || !parsed.answers) {
|
|
181
|
+
throw new Error("jev response missing answers")
|
|
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
|
|
189
|
+
} finally {
|
|
190
|
+
clearTimeout(timer)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function noul(answers: Answers, name: string): number {
|
|
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 ---------------------------------------------------
|
|
201
|
+
|
|
202
|
+
type Part = { id?: string; type?: string; tool?: string; callID?: string; state?: any; text?: string; [key: string]: any }
|
|
203
|
+
type Message = { info?: any; parts?: Part[]; [key: string]: any }
|
|
204
|
+
type Call = {
|
|
205
|
+
id: string
|
|
206
|
+
callID: string
|
|
207
|
+
tool: string
|
|
208
|
+
input: Record<string, unknown>
|
|
209
|
+
output: string
|
|
210
|
+
isError: boolean
|
|
211
|
+
messageIndex: number
|
|
212
|
+
/** The part itself, held by reference: dropping one part must not shift the others. */
|
|
213
|
+
part: Part
|
|
214
|
+
pinned: boolean
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function isFinishedToolPart(part: Part): boolean {
|
|
218
|
+
if (part?.type !== "tool") return false
|
|
219
|
+
return part.state?.status === "completed" || part.state?.status === "error"
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function outputOf(part: Part): { text: string; isError: boolean } {
|
|
223
|
+
if (part.state?.status === "completed") return { text: String(part.state.output ?? ""), isError: false }
|
|
224
|
+
return { text: String(part.state?.error ?? ""), isError: true }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function textOf(message: Message): string {
|
|
228
|
+
return (message.parts ?? [])
|
|
229
|
+
.filter((part) => part?.type === "text" && typeof part.text === "string")
|
|
230
|
+
.map((part) => part.text as string)
|
|
231
|
+
.join("\n")
|
|
232
|
+
.trim()
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function isPinned(index: number, total: number): boolean {
|
|
236
|
+
return index === 0 || index >= total - PRESERVE_RECENT
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function collectCalls(messages: Message[]): Call[] {
|
|
240
|
+
const calls: Call[] = []
|
|
241
|
+
messages.forEach((message, messageIndex) => {
|
|
242
|
+
for (const part of message.parts ?? []) {
|
|
243
|
+
if (!isFinishedToolPart(part)) continue
|
|
244
|
+
const { text, isError } = outputOf(part)
|
|
245
|
+
calls.push({
|
|
246
|
+
id: `t${calls.length + 1}`,
|
|
247
|
+
callID: String(part.callID ?? part.id ?? `p${calls.length + 1}`),
|
|
248
|
+
tool: String(part.tool ?? "tool"),
|
|
249
|
+
input: (part.state?.input as Record<string, unknown>) ?? {},
|
|
250
|
+
output: text,
|
|
251
|
+
isError,
|
|
252
|
+
messageIndex,
|
|
253
|
+
part,
|
|
254
|
+
pinned: isPinned(messageIndex, messages.length),
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
return calls
|
|
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
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function resultNote(call: Call): string {
|
|
285
|
+
// Small results are sent in full. Replacing every result with a note hides the
|
|
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
|
|
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
|
+
}) {
|
|
456
|
+
try {
|
|
457
|
+
mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
|
|
458
|
+
let previous: any = {}
|
|
459
|
+
try {
|
|
460
|
+
previous = JSON.parse(readFileSync(STATS_FILE, "utf8"))
|
|
461
|
+
} 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 })
|
|
472
|
+
} catch {}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// --- the pruner ----------------------------------------------------------------
|
|
476
|
+
|
|
477
|
+
async function prune(messages: Message[], reason: string): Promise<void> {
|
|
478
|
+
if (!ENABLED) return
|
|
479
|
+
try {
|
|
480
|
+
if (!Array.isArray(messages) || messages.length === 0) return
|
|
481
|
+
|
|
482
|
+
const calls = collectCalls(messages)
|
|
483
|
+
if (calls.length === 0) return
|
|
484
|
+
|
|
485
|
+
const estimated = estimateTokens(JSON.stringify(messages))
|
|
486
|
+
if (estimated < THRESHOLD_TOKENS) {
|
|
487
|
+
trace("below threshold", { estimated, threshold: THRESHOLD_TOKENS })
|
|
488
|
+
return
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const allowed = Math.max(0, DAILY_REQUEST_CAP - dayUsage().requests)
|
|
492
|
+
if (allowed === 0) {
|
|
493
|
+
trace("daily cap reached, skipping", { used: dayUsage().requests, cap: DAILY_REQUEST_CAP })
|
|
494
|
+
return
|
|
495
|
+
}
|
|
496
|
+
if (!apiKey()) {
|
|
497
|
+
trace("no key, skipping")
|
|
498
|
+
return
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const started = Date.now()
|
|
502
|
+
const candidates = calls.filter((call) => !call.pinned && !decided.has(call.callID))
|
|
503
|
+
const totalBefore = messages.reduce((sum, message) => sum + JSON.stringify(message).length, 0)
|
|
504
|
+
|
|
505
|
+
let requests = 0
|
|
506
|
+
let stage = "cache"
|
|
507
|
+
if (candidates.length > 0) {
|
|
508
|
+
const fitted = fitState(messages, calls)
|
|
509
|
+
stage = fitted.stage
|
|
510
|
+
if (fitted.stage === "overflow") {
|
|
511
|
+
trace("state overflow, skipping", { tokens: fitted.tokens })
|
|
512
|
+
return
|
|
513
|
+
}
|
|
514
|
+
// Reserve against the cap before firing: every request is already in flight by
|
|
515
|
+
// the time the first answer returns, so checking only the total afterwards
|
|
516
|
+
// would let a single run overshoot the ceiling.
|
|
517
|
+
const send = batch(candidates, fitted.tokens).slice(0, allowed)
|
|
518
|
+
if (send.length === 0) {
|
|
519
|
+
trace("no request budget left for a batch", { stateTokens: fitted.tokens })
|
|
520
|
+
return
|
|
521
|
+
}
|
|
522
|
+
dayUsage().requests += send.length
|
|
523
|
+
writeUsage(dayUsage())
|
|
524
|
+
|
|
525
|
+
const answered = await Promise.all(
|
|
526
|
+
send.map(async (group) => {
|
|
527
|
+
const questions = Object.assign({}, ...group.map(questionsFor))
|
|
528
|
+
const answers = await ask(fitted.state, questions)
|
|
529
|
+
requests += 1
|
|
530
|
+
return group.map((call) => ({
|
|
531
|
+
call,
|
|
532
|
+
keepCall: noul(answers, `call_${call.id}`),
|
|
533
|
+
keepResult: noul(answers, `result_${call.id}`),
|
|
534
|
+
}))
|
|
535
|
+
}),
|
|
536
|
+
)
|
|
537
|
+
if (decided.size > 5000) decided.clear()
|
|
538
|
+
for (const group of answered) {
|
|
539
|
+
for (const item of group) {
|
|
540
|
+
const action = decide(item.call, item.keepCall, item.keepResult)
|
|
541
|
+
trace("decision", {
|
|
542
|
+
id: item.call.id,
|
|
543
|
+
tool: item.call.tool,
|
|
544
|
+
keepCall: item.keepCall,
|
|
545
|
+
keepResult: item.keepResult,
|
|
546
|
+
action,
|
|
547
|
+
})
|
|
548
|
+
decided.set(item.call.callID, action)
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Apply by part reference. Parts are held directly, so removing one cannot shift
|
|
554
|
+
// the position of another in the same message.
|
|
555
|
+
const drop = new Set<Part>()
|
|
556
|
+
let dropped = 0
|
|
557
|
+
let truncated = 0
|
|
558
|
+
for (const call of calls) {
|
|
559
|
+
const action = decided.get(call.callID)
|
|
560
|
+
if (!action || action === "keep" || call.pinned) continue
|
|
561
|
+
if (action === "drop_call") {
|
|
562
|
+
drop.add(call.part)
|
|
563
|
+
dropped += 1
|
|
564
|
+
continue
|
|
565
|
+
}
|
|
566
|
+
const next = truncatedOutput(call)
|
|
567
|
+
if (next === call.output) continue
|
|
568
|
+
if (call.part.state?.status === "completed") call.part.state.output = next
|
|
569
|
+
else if (call.part.state?.status === "error") call.part.state.error = next
|
|
570
|
+
truncated += 1
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (drop.size > 0) {
|
|
574
|
+
for (const message of messages) {
|
|
575
|
+
if (!message.parts || !message.parts.some((part) => drop.has(part))) continue
|
|
576
|
+
message.parts = message.parts.filter((part) => !drop.has(part))
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const kept = messages.filter((message) => (message.parts ?? []).length > 0)
|
|
581
|
+
messages.length = 0
|
|
582
|
+
messages.push(...kept)
|
|
583
|
+
|
|
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,
|
|
588
|
+
dropped,
|
|
589
|
+
truncated,
|
|
590
|
+
requests,
|
|
591
|
+
ms: Date.now() - started,
|
|
592
|
+
stage,
|
|
593
|
+
})
|
|
594
|
+
trace("pruned", { reason, estimated, stage, requests, dropped, truncated, savedChars: totalBefore - totalAfter })
|
|
595
|
+
} catch (error) {
|
|
596
|
+
trace("prune failed", { error: String((error as Error)?.message ?? error) })
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// --- plugin --------------------------------------------------------------------
|
|
601
|
+
|
|
602
|
+
async function server() {
|
|
603
|
+
return {
|
|
604
|
+
"experimental.chat.messages.transform": async (_input: unknown, output: { messages: Message[] }) => {
|
|
605
|
+
await prune(output.messages, "step")
|
|
606
|
+
},
|
|
607
|
+
|
|
608
|
+
"experimental.session.compacting": async (_input: unknown, output: { context: string[]; prompt?: string }) => {
|
|
609
|
+
output.context.push(
|
|
610
|
+
"Tool results marked `[jev-compaction truncated …]` were shortened deliberately: the call is still " +
|
|
611
|
+
"historically accurate but the body was dropped as no longer needed. Do not treat them as tool failures.",
|
|
612
|
+
)
|
|
613
|
+
},
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
export default { id: "jev-compaction", server }
|
package/src/tui.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// jev-savings — TUI sidebar widget that reports what the jev-compaction server
|
|
2
|
+
// plugin has removed from context. Reads the stats file the server plugin writes.
|
|
3
|
+
//
|
|
4
|
+
// Plain JS with getters on purpose. opencode loads published plugins from inside
|
|
5
|
+
// node_modules, and its bundled Bun runtime does not apply the JSX transform to
|
|
6
|
+
// files there, so a shipped .tsx never executes. Getters are what a Solid-aware
|
|
7
|
+
// JSX compiler would emit for reactive props, so this is the compiled form.
|
|
8
|
+
|
|
9
|
+
import { createSignal, Show } from "solid-js"
|
|
10
|
+
import { jsx, jsxs } from "@opentui/solid/jsx-runtime"
|
|
11
|
+
import { readFileSync } from "node:fs"
|
|
12
|
+
import { homedir } from "node:os"
|
|
13
|
+
import { join } from "node:path"
|
|
14
|
+
|
|
15
|
+
const id = "jev-savings"
|
|
16
|
+
|
|
17
|
+
const POLL_MS = 3000
|
|
18
|
+
|
|
19
|
+
const STATS_FILE = join(homedir(), ".local", "share", "opencode", "jev-compaction.json")
|
|
20
|
+
|
|
21
|
+
const empty = () => ({ runs: 0, tokensSaved: 0, callsSeen: 0, dropped: 0, truncated: 0 })
|
|
22
|
+
|
|
23
|
+
function read() {
|
|
24
|
+
try {
|
|
25
|
+
const raw = JSON.parse(readFileSync(STATS_FILE, "utf8"))
|
|
26
|
+
return {
|
|
27
|
+
runs: Number(raw.runs) || 0,
|
|
28
|
+
tokensSaved: Number(raw.tokensSaved) || 0,
|
|
29
|
+
callsSeen: Number(raw.callsSeen) || 0,
|
|
30
|
+
dropped: Number(raw.dropped) || 0,
|
|
31
|
+
truncated: Number(raw.truncated) || 0,
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
return empty()
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function compact(n) {
|
|
39
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
|
40
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`
|
|
41
|
+
return String(n)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const tui = async (api) => {
|
|
45
|
+
const [stats, setStats] = createSignal(empty())
|
|
46
|
+
|
|
47
|
+
setStats(read())
|
|
48
|
+
const timer = setInterval(() => setStats(read()), POLL_MS)
|
|
49
|
+
api.lifecycle.onDispose(() => clearInterval(timer))
|
|
50
|
+
|
|
51
|
+
api.slots.register({
|
|
52
|
+
order: 91,
|
|
53
|
+
slots: {
|
|
54
|
+
sidebar_content() {
|
|
55
|
+
return jsxs(Show, {
|
|
56
|
+
get when() {
|
|
57
|
+
return stats().runs > 0 && stats().tokensSaved > 0
|
|
58
|
+
},
|
|
59
|
+
get children() {
|
|
60
|
+
return jsxs("box", {
|
|
61
|
+
children: [
|
|
62
|
+
jsx("text", {
|
|
63
|
+
get fg() {
|
|
64
|
+
return api.theme.current.text
|
|
65
|
+
},
|
|
66
|
+
get children() {
|
|
67
|
+
return jsx("b", {
|
|
68
|
+
get children() {
|
|
69
|
+
return "Jev savings"
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
jsx("text", {
|
|
75
|
+
get fg() {
|
|
76
|
+
return api.theme.current.textMuted
|
|
77
|
+
},
|
|
78
|
+
get children() {
|
|
79
|
+
return `~${compact(stats().tokensSaved)} tokens saved`
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
jsx("text", {
|
|
83
|
+
get fg() {
|
|
84
|
+
return api.theme.current.textMuted
|
|
85
|
+
},
|
|
86
|
+
get children() {
|
|
87
|
+
const current = stats()
|
|
88
|
+
const plural = current.runs === 1 ? "run" : "runs"
|
|
89
|
+
return `${current.dropped} dropped, ${current.truncated} truncated · ${current.runs} ${plural}`
|
|
90
|
+
},
|
|
91
|
+
}),
|
|
92
|
+
],
|
|
93
|
+
})
|
|
94
|
+
},
|
|
95
|
+
})
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export default { id, tui }
|