pi-jev-lens 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/README.md CHANGED
@@ -1,16 +1,17 @@
1
1
  # pi-jev-lens
2
2
 
3
- **Your coding agent reads a 600-line file to change one function. jev-lens sends the model the outline and that
4
- function.** The rest is one `recall` away, and the agent knows it.
3
+ Your coding agent reads a 600-line file to change one function. jev-lens sends the model the outline of the file and
4
+ that one function. The agent can ask for the rest with the `recall` tool, and the footer of the result tells it so.
5
5
 
6
- A [pi](https://github.com/earendil-works/pi-mono) extension. Tool output is most of what a coding agent pays for:
7
- every `cat`, every test run, every `grep` lands in the prompt in full and stays there, cached, for the rest of the
8
- session. jev-lens steps in before that first send. Code builds a handful of candidate views of the output, and
9
- [jev](https://docs.typesafe.ai), TypeSafe's System One judgment model, picks the smallest one that still lets the agent
10
- do its next step. Nothing is generated or summarized: every view is lines of the original, with line numbers, so the
11
- agent can ask for exactly the part it is missing.
6
+ jev-lens is an extension for [pi](https://github.com/earendil-works/pi-mono), the coding agent. Tool output is most
7
+ of what a coding agent pays for. Every `cat`, every test run and every `grep` goes into the prompt in full and stays
8
+ there for the rest of the session. jev-lens acts before that first send. Code builds a small set of candidate views
9
+ of the output. A view is a subset of the lines of the output, with line numbers. Then [jev](https://docs.typesafe.ai),
10
+ the judgment model from TypeSafe, picks the smallest view that still lets the agent do its next step. Nothing is
11
+ generated or summarized. Every view is made of lines from the original, so the agent can ask for exactly the part
12
+ that it does not have.
12
13
 
13
- What the model sees instead of a 1.5k-token file:
14
+ This is what the model sees instead of a file of 1.5k tokens:
14
15
 
15
16
  ```
16
17
  1│ import { parse } from "./parse.js";
@@ -29,51 +30,69 @@ full output, or recall(id, lines: "a-b") / recall(id, pattern: "...") for a slic
29
30
 
30
31
  ## What the numbers say
31
32
 
32
- We did not guess the defaults; we measured them on 500 real agent trajectories (OpenHands on SWE-rebench, 3300 large
33
- tool results, 11.6 million tokens) and on our own day-to-day pi sessions. The research log is STATUS.md; the headlines:
33
+ We measured the defaults on 500 real agent trajectories (OpenHands on SWE-rebench: 3300 large tool results, 11.6
34
+ million tokens) and on our own daily pi sessions. A token is the unit that a model provider bills. The research log
35
+ is STATUS.md. These are the main results:
34
36
 
35
- | | |
37
+ | result | source |
36
38
  |---|---|
37
- | **79 % fewer tokens** sent for large tool results across the 500 benchmark trajectories | 11.6M → 2.4M |
38
- | **88 % on command output** (test runs, grep, build logs), 58 % on docs, 47 % on listings, 31 % on code | per kind |
39
- | **31 % of large-result tokens** in our own sessions with gpt-6-astra, which reads code through `cat` and runs few tests | real use |
40
- | **2 of 26 later edits** missed their block; 0.3 % of results had a dropped line quoted; 2.2 % had a dropped identifier used | the harm side |
41
- | **8 % lower cost, 17 % smaller final prompt, same pass rate, zero recalls** on live end-to-end runs where big files get read | pi headless, 3 runs each |
42
-
43
- Read the two savings numbers together. The benchmark agent spends its output budget on pytest runs and grep, which
44
- compress to almost nothing; an agent that mostly reads source code sits closer to the code number, because code is
45
- the one thing we refuse to compress unless jev is confident. And both are shares of *large tool results*: over a
46
- whole session, with the system prompt, the conversation and every small result counted, the footer will show a lower
47
- percentage. What you save depends on what your agent reads.
48
-
49
- Three things we learned that shaped the design:
50
-
51
- - **Compress before the first send, not after.** Pruning old results later looks great on token counts (−19 %) and costs
52
- *more* money (+17 %), because every rewrite breaks the prompt cache. Post-send pruning is still in the code, off by default.
53
- - **Code is different.** Test logs and grep output can lose 90 % and nobody misses it. Code is edited from, and an edit
54
- whose old text the model never saw fails. So code views keep every retained line byte-exact, and code is sent full
55
- unless jev is confident. The tempting always-outline policy saved more and missed 17 % of later edits; it is opt-in.
56
- - **Views built in code beat prompt tuning.** Four rounds of letting a researcher model rewrite jev's prompts and
57
- thresholds moved nothing that held up on held-out data. Every gain that lasted was a new kind of view: failing tests
58
- only, grep match groups, JSON keys, the file the agent `cat`-ed through bash.
39
+ | 79 % fewer tokens sent for large tool results (11.6M became 2.4M) | the 500 benchmark trajectories |
40
+ | 88 % fewer on command output (test runs, grep, build logs), 58 % on docs, 47 % on listings, 31 % on code | the same, per kind of output |
41
+ | 31 % fewer tokens for large tool results | our own sessions with gpt-6-astra, which reads code with `cat` and runs few tests |
42
+ | 2 of 26 later edits missed their block, 0.3 % of results had a dropped line quoted, 2.2 % had a dropped identifier used | the harm side, same 500 trajectories |
43
+ | 8 % lower cost, 17 % smaller final prompt, the same pass rate, zero recalls | live end-to-end runs in pi where the agent reads big files, 3 runs each |
44
+
45
+ Read the two savings numbers together. The benchmark agent spends its output budget on pytest runs and grep, and
46
+ those compress to almost nothing. An agent that mostly reads source code sits closer to the code number, because
47
+ code is the one thing that jev-lens does not compress unless jev is confident. Both numbers are shares of large tool
48
+ results only. Over a whole session, the footer counts the system prompt, the conversation and every small result too,
49
+ so it will show a lower percentage. What you save depends on what your agent reads.
50
+
51
+ Three results shaped the design:
52
+
53
+ 1. Compress before the first send, not after. Pruning old results later removes 19 % of the tokens and costs 17 %
54
+ more money, because every rewrite of an old message breaks the prompt cache. The prompt cache is the provider's
55
+ discount for a prompt prefix that did not change since the last call. Post-send pruning is still in the code, but
56
+ it is off by default.
57
+ 2. Code is different. Test logs and grep output can lose 90 % of their lines and the agent does not miss them. The
58
+ agent edits from code, and an edit fails when its old text is a line that the model never saw. So views of code
59
+ keep every retained line byte for byte, and code is sent in full unless jev is confident. The policy that always
60
+ sends an outline saved more and missed 17 % of later edits. It is available, but you must turn it on.
61
+ 3. Views built in code beat prompt tuning. In four rounds, a researcher model rewrote the questions to jev and the
62
+ thresholds. None of its changes held up on data that it had not seen. Every gain that held was a new kind of view:
63
+ only the failing tests, grep match groups, JSON keys, the file that the agent read with `cat`.
59
64
 
60
65
  ## Install
61
66
 
67
+ Use pi 0.84.3 or newer. The extension uses pi's built-in tool renderers for results that it does not compress.
68
+
62
69
  ```sh
63
70
  pi install npm:pi-jev-lens # from npm
64
- pi install git:github.com/dizk/pi-jev-lens # or straight from GitHub
71
+ pi install git:github.com/dizk/pi-jev-lens # or from GitHub
65
72
  ```
66
73
 
67
- jev needs a TypeSafe API key (get one at [console.typesafe.ai](https://console.typesafe.ai)). Three ways to provide it,
68
- in the order they are tried:
74
+ jev needs a TypeSafe API key. You can get one at [console.typesafe.ai](https://console.typesafe.ai). jev-lens looks
75
+ for the key in this order:
69
76
 
70
77
  1. `TYPESAFE_API_KEY` in the environment.
71
- 2. `/jev-lens key` inside pi: prompts for the key (or `/jev-lens key ts_...`) and stores it in
72
- `~/.pi/agent/jev-lens.json`, readable only by you. jev is active from the next tool result, no restart.
73
- 3. A `.env` file next to the installed package (development).
78
+ 2. A `.env` file next to the installed package. This is for development and supplies environment values that are not already set.
79
+ 3. The key that you stored with `/jev-lens key` inside pi. In terminal mode, the command opens a masked input field.
80
+ The key is stored in `~/.pi/agent/jev-lens.json`. New files are readable only by you.
81
+
82
+ Use `/jev-lens key` without an argument to keep the key out of command history. The field displays only `*` characters.
83
+ Type or paste the key, then press Enter to save. Press Esc to cancel without changing the stored key.
84
+ In RPC or noninteractive mode, set `TYPESAFE_API_KEY`. These modes do not fall back to a visible input field.
85
+ The key file still stores the key as plain text. Masking protects the terminal display, not the file.
86
+
87
+ You can still use `/jev-lens key ts_...`, but that exposes the key in the editor and can retain it in command history.
88
+ A new key takes effect without a restart, but the command does not validate it.
89
+ If `TYPESAFE_API_KEY` is set, that value takes priority again after reload.
90
+ If mock mode is forced or compression is disabled, storing a key does not change those settings.
74
91
 
75
- Without a key the extension warns at startup and runs a mock classifier that compresses nothing. For development,
76
- clone the repo and load it directly:
92
+ If no key is found, the extension shows a warning at startup and uses a deterministic mock classifier.
93
+ The mock can compress results without API calls. It is not the jev model.
94
+
95
+ For development, clone the repository and load it directly:
77
96
 
78
97
  ```sh
79
98
  git clone https://github.com/dizk/pi-jev-lens.git && cd pi-jev-lens && npm install
@@ -83,68 +102,88 @@ pi -e ./index.ts
83
102
 
84
103
  ## How it works
85
104
 
86
- Every text tool result of at least 1200 estimated tokens (about 5 kB) passes through pi's `tool_result` hook before
87
- it is stored or sent. Small results are never touched. Code builds candidate **views**: strict subsets of the output,
88
- with line numbers and omission markers, never generated text.
105
+ Every text tool result of at least 1200 estimated tokens (about 5 kB) goes through pi's `tool_result` hook before
106
+ pi stores it or sends it. jev-lens never touches smaller results. Code builds the candidate views. Each view is a
107
+ subset of the lines of the output, with line numbers and markers for the omitted lines. No text is generated.
89
108
 
90
109
  | view | for | keeps |
91
110
  |---|---|---|
92
111
  | `outline` | code, prose | imports, exports, signatures, headings, doc comments |
93
- | `relevant` | code, command output | outline or section headers plus the full bodies jev says the agent will need (second jev step) |
94
- | `sections` | command output | the first line of every section: grep match groups, JSON keys, headings, `COMMAND:`-style markers, paragraphs |
112
+ | `relevant` | code, command output | the outline or the section headers, plus the full bodies that jev says the agent will need (second jev step) |
113
+ | `sections` | command output | the first line of every section: grep match groups, JSON keys, headings, markers like `COMMAND:`, paragraphs |
95
114
  | `signals` | command output | errors, warnings, failing tests, summary lines, the tail |
96
115
  | `testlog` | test output | failures, assertions, tracebacks and summaries |
97
- | `matches` | search output | first matches per file, with omission counts |
116
+ | `matches` | search output | the first matches per file, with a count of the omitted ones |
98
117
  | `log` | repetitive output | representative repeated lines, errors and the tail |
99
- | `tree` | directory listings | a sample of entries per directory, with omission counts |
100
- | `focus` | anything | lines mentioning identifiers from the task and the tool call, with context |
101
- | `sample` | tabular or log-like data | header, a dozen rows, the count |
102
- | `head_tail` | anything | first and last lines |
103
-
104
- jev then answers two questions over the task, the assistant's text before the call and a preview of each view:
105
- *which view is the smallest that still suffices* (a Choice) and *will the next step need the exact full text* (a
106
- yes/no). When an outline or `sections` view is chosen, a second request asks, per block or section, whether the agent
107
- will need its body, and those bodies are put back. If that reaches 90 % of the original, full text is sent instead.
108
-
109
- What makes it safe to edit from a view:
110
-
111
- - Views of code and prose keep every retained line exactly, so an edit whose old text was copied from the view still
112
- matches the file. Views of command output shorten decorative bars and very long lines.
113
- - Files the agent reads through bash (`cat a.py b.py`, `sed -n '1,80p' x.ts`, `head`, brace groups, globs) are typed as
114
- code or prose and get the same views as `read`. Anything mixed with other commands stays command output.
115
- - The agent's own `edit` and `write` results are never reduced.
116
- - Code is sent full unless jev is confident a view suffices (`gate` policy). The always-outline policy saves more but
117
- missed 17 % of later edits on real trajectories, so it is opt-in.
118
-
119
- Code structure comes from tree-sitter (grammars from `@vscode/tree-sitter-wasm` plus `@binclusive/tree-sitter-kotlin-wasm`):
120
- TypeScript, TSX, JavaScript, Kotlin, Java, Rust, Python, Go, C, C++, C#, Ruby, PHP, Bash, CSS. Large classes are split
121
- into their members. Other languages fall back to regex heuristics.
122
-
123
- **Recall.** When a result is compressed, its full output is kept in the result's `details` (persisted in the session,
124
- never sent to the model). The footer names a `recall` tool that serves it back by id, line range or pattern. Every
125
- recall is logged as feedback that a view was too small.
118
+ | `tree` | directory listings | a sample of entries per directory, with a count of the omitted ones |
119
+ | `focus` | anything | the lines that mention identifiers from the task and the tool call, with context |
120
+ | `sample` | tabular or log-like data | the header, a dozen rows, the count |
121
+ | `head_tail` | anything | the first and the last lines |
122
+
123
+ jev then answers two questions. It sees the task, the text that the assistant wrote before the call, and a preview of
124
+ each view. The questions are: which view is the smallest one that is still enough (a choice), and will the next step
125
+ need the exact full text (yes or no). If jev chose an outline or a `sections` view, a second request asks, for each
126
+ block or section, whether the agent will need its body. Those bodies go back into the view. If the result reaches
127
+ 90 % of the original size, jev-lens sends the full text instead.
128
+
129
+ These rules make it safe for the agent to edit from a view:
130
+
131
+ - Views of code and prose keep every retained line exactly as it is. An edit whose old text was copied from the view
132
+ still matches the file. Views of command output shorten decorative bars and very long lines.
133
+ - Files that the agent reads through bash (`cat a.py b.py`, `sed -n '1,80p' x.ts`, `head`, brace groups, globs) count
134
+ as code or prose and get the same views as the `read` tool. If the command also does something else, the output
135
+ stays command output.
136
+ - jev-lens never reduces the results of the agent's own `edit` and `write` tools.
137
+ - jev-lens sends code in full unless jev is confident that a view is enough. This is the `gate` policy. The
138
+ `outline` policy always sends an outline plus expanded bodies. It saves more, but it missed 17 % of later edits on
139
+ real trajectories, so you must turn it on yourself.
140
+
141
+ The code structure comes from tree-sitter (grammars from `@vscode/tree-sitter-wasm` and
142
+ `@binclusive/tree-sitter-kotlin-wasm`): TypeScript, TSX, JavaScript, Kotlin, Java, Rust, Python, Go, C, C++, C#,
143
+ Ruby, PHP, Bash, CSS. Large classes are split into their members. For other languages, jev-lens uses regular
144
+ expressions that know the common declaration keywords.
145
+
146
+ When jev-lens compresses a result, it keeps the full output in the result's `details`. pi persists that in the
147
+ session but never sends it to the model. The footer names the `recall` tool, which serves the full output back by
148
+ id, by line range or by pattern. jev-lens logs every recall as a signal that a view was too small.
126
149
 
127
150
  ## In pi
128
151
 
129
- The footer shows the share of the session's input tokens jev kept out of the prompt, and what it did:
152
+ The footer shows the share of the session's input tokens that jev kept out of the prompt, and what it did:
130
153
 
131
154
  ```
132
155
  jev-lens −38% of input (presend −12.3k · 5/8 · 1 recalls)
133
156
  ```
134
157
 
135
- The share is cut / (sent + cut): sent is the provider's own input plus cache-read counts over all calls, cut is what
136
- every compressed result saved on every call it was part of.
158
+ The share is cut divided by sent plus cut. Sent counts input and cache-read tokens reported by the provider since the last load.
159
+ Cut estimates what compressed results saved on those calls, including results restored from the session.
160
+ The percentage counts repeated savings when the same result appears in later prompts. The `presend` total counts each result once.
161
+
162
+ After `/reload` or resume, the footer includes saved tokens from restored compressed results.
163
+ For example, `0/3 new · 5 restored` means no new compressions among three candidates, plus five restored compressed results.
164
+ New-result counts and recall counts start at zero after loading. `/jev-lens stats` shows new and restored savings separately.
137
165
 
138
- In the transcript a compressed result shows a header like `⌁ jev-lens outline · 179 of 1524 tokens (−88 %)` and,
139
- expanded (ctrl+e), exactly what the model saw. Commands:
166
+ In the transcript, a compressed result shows a header like `⌁ jev-lens outline · 179 of 1524 tokens (−88 %)`. When
167
+ you expand it with ctrl+e, you see exactly what the model saw. These commands are available:
140
168
 
141
- - `/jev-lens` stats, and where the key comes from
142
- - `/jev-lens list` the latest 200 compressed results with tokens before and after
143
- - `/jev-lens diff [n]` overlay of the n-th latest: the original with the lines the model did not get marked `−`
144
- (`t` switches to what was sent, `Esc` closes)
145
- - `/jev-lens key` store the API key
169
+ Type `/jev-lens ` and press Tab to complete subcommands. After `diff `, completion offers available result numbers.
170
+ Use `/reload` after installing the package in a running pi session.
146
171
 
147
- Every decision is logged to `<project>/.pi/jev-lens.log` (JSON lines). `JEV_LENS_UI=0` keeps pi's own tool rendering.
172
+ - `/jev-lens` or `/jev-lens stats` shows the statistics, active configuration, and key source.
173
+ - `/jev-lens help` shows command usage.
174
+ - `/jev-lens decisions` shows post-send pruning decisions. Post-send pruning is off by default.
175
+ - `/jev-lens list` lists the latest 200 compressed results with the tokens before and after.
176
+ - `/jev-lens diff [n]` opens an overlay for the n-th latest result. It shows the original with the lines that the
177
+ model did not get marked with `−`. Press `t` to see what was sent, and `Esc` to close.
178
+ - `/jev-lens key` stores the API key.
179
+
180
+ If compression fails, jev-lens keeps the full output and shows a warning. A failed post-send classification leaves that result unchanged.
181
+ Warnings appear at most once per stage per session. The footer shows `degraded` until a later attempt in that stage succeeds.
182
+ Use `/jev-lens stats` to see failure counts and recovery status. Cancellation does not count as a failure.
183
+
184
+ Uncompressed results, errors, and streaming updates use pi's built-in tool renderers.
185
+ jev-lens logs every decision to `<project>/.pi/jev-lens.log` as JSON lines. Set `JEV_LENS_UI=0` to disable the
186
+ custom savings headers and tool overrides.
148
187
 
149
188
  ### Configuration (environment)
150
189
 
@@ -152,29 +191,34 @@ Every decision is logged to `<project>/.pi/jev-lens.log` (JSON lines). `JEV_LENS
152
191
  |---|---|---|
153
192
  | `JEV_LENS_PRESEND` | `1` | `0` turns compression off |
154
193
  | `JEV_LENS_PRESEND_MIN_TOKENS` | `1200` | smaller results are always sent in full |
155
- | `JEV_LENS_PRESEND_NEEDS_FULL_ABOVE` / `_FULL_MASS_ABOVE` | `0.5` / `0.5` | send full when P(needs full) or P(full view) exceeds these |
156
- | `JEV_LENS_PRESEND_CODE_POLICY` | `gate` | `outline`: always send an outline plus expanded bodies (more savings, more edit-misses) |
157
- | `JEV_LENS_PRESEND_CODE_NEEDS_FULL_ABOVE` | `0.5` | code uses the minimum of this and the general needs-full threshold |
158
- | `JEV_LENS_PRESEND_EXPAND_ABOVE` | `0.5` | expand a code block's body when P(needed) exceeds this |
159
- | `JEV_LENS_PRESEND_COMMAND_NEEDS_FULL_ABOVE` | `0.65` | needs-full threshold for command output |
160
- | `JEV_LENS_PRESEND_COMMAND_POLICY` | `sections` | when jev picks full for command output but needs-full is low, send section headers and expand the needed sections. `gate`: jev's choice stands |
161
- | `JEV_LENS_PRESEND_SECTION_EXPAND_ABOVE` | `0.5` | expand a section when P(needed) exceeds this |
162
- | `JEV_LENS_PRESEND_SECTION_FLOOR` | `0.3` | send full when no section reaches this probability (jev could not tell); `0` allows headers alone |
163
- | `JEV_LENS_PRESEND_MIN_CONFIDENCE` | `0` | send full below this choice confidence (0 = off) |
164
- | `JEV_LENS_MODEL` | `jev-latest` | jev model |
165
- | `JEV_LENS_CLASSIFIER` | unset | `mock` forces the deterministic classifier, no API calls |
166
- | `JEV_LENS_LOG` | `1` | `0` disables logging |
167
- | `JEV_LENS_UI` | `1` | `0` disables the custom tool rendering |
168
- | `JEV_LENS_VARIANT` | unset | JSON file with `config`, `prompts` and `views` overrides, as produced by the autoresearch loop |
194
+ | `JEV_LENS_PRESEND_NEEDS_FULL_ABOVE` / `_FULL_MASS_ABOVE` | `0.5` / `0.5` | send full when P(needs full) or P(full view) is above these |
195
+ | `JEV_LENS_PRESEND_CODE_POLICY` | `gate` | `outline`: always send an outline plus expanded bodies (more savings, more missed edits) |
196
+ | `JEV_LENS_PRESEND_CODE_NEEDS_FULL_ABOVE` | `0.5` | code uses the lower of this and the general needs-full threshold |
197
+ | `JEV_LENS_PRESEND_EXPAND_ABOVE` | `0.5` | expand the body of a code block when P(needed) is above this |
198
+ | `JEV_LENS_PRESEND_COMMAND_NEEDS_FULL_ABOVE` | `0.65` | the needs-full threshold for command output |
199
+ | `JEV_LENS_PRESEND_COMMAND_POLICY` | `sections` | when jev picks full for command output but needs-full is low, send the section headers and expand the needed sections. `gate`: keep jev's choice |
200
+ | `JEV_LENS_PRESEND_SECTION_EXPAND_ABOVE` | `0.5` | expand a section when P(needed) is above this |
201
+ | `JEV_LENS_PRESEND_SECTION_FLOOR` | `0.3` | send full when no section reaches this probability, because jev could not tell. `0` allows headers alone |
202
+ | `JEV_LENS_PRESEND_MIN_CONFIDENCE` | `0` | send full below this choice confidence. `0` turns the check off |
203
+ | `JEV_LENS_MODEL` | `jev-latest` | the jev model |
204
+ | `JEV_LENS_CLASSIFIER` | unset | `mock` forces the deterministic classifier, with no API calls |
205
+ | `JEV_LENS_LOG` | `1` | `0` turns logging off |
206
+ | `JEV_LENS_UI` | `1` | `0` turns the custom tool rendering off |
207
+ | `JEV_LENS_VARIANT` | unset | a JSON file with `config`, `prompts` and `views` overrides, as the autoresearch loop writes it |
169
208
  | `JEV_LENS_MODE` | `off` | optional post-send pruning, see below |
170
209
 
171
210
  ## Evaluation
172
211
 
173
- Every change here is scored against what the agent actually did next in a recorded trajectory, which is the only
174
- honest judge of "did it need that text". The benchmark is real OpenHands trajectories (`eval/bench/`, data fetched by
175
- `eval/bench/fetch.sh`), and the metrics are: **edit-miss** (it edited a line the view had dropped), **quote-miss** (it quoted dropped text),
176
- **ref-miss** (it used an identifier that only existed in the dropped part). Edit-misses are weighted five times in
177
- the objective, and they are rare, so anything that touches code views must be scored on the 500-trajectory slice:
212
+ Every change is scored against what the agent did next in a recorded trajectory. That is the only honest judge of
213
+ whether the agent needed the text. The benchmark uses real OpenHands trajectories in `eval/bench/`. The script
214
+ `eval/bench/fetch.sh` downloads the data. The metrics are:
215
+
216
+ - edit-miss: the agent later edited a line that the view had dropped.
217
+ - quote-miss: the agent quoted text that the view had dropped.
218
+ - ref-miss: the agent used an identifier that only existed in the dropped part.
219
+
220
+ An edit-miss counts five times in the objective, and edit-misses are rare. So you must score every change that
221
+ touches code views on the 500-trajectory slice, not only on the 100-trajectory holdout.
178
222
 
179
223
  | slice | large results | saved | edit-miss | quote-miss | ref-miss |
180
224
  |---|---|---|---|---|---|
@@ -183,52 +227,56 @@ the objective, and they are rare, so anything that touches code views must be sc
183
227
 
184
228
  ```sh
185
229
  npm test # unit tests, mock classifier
186
- node --import tsx eval/bench/run.ts --from 200 --to 300 # holdout, ~4 min
187
- node --import tsx eval/bench/run.ts --from 300 --to 800 # the 500-trajectory slice, ~20 min
230
+ node --import tsx eval/bench/run.ts --from 200 --to 300 # the holdout, about 4 minutes
231
+ node --import tsx eval/bench/run.ts --from 300 --to 800 # the 500-trajectory slice, about 20 minutes
188
232
  node --import tsx eval/presend-replay.ts <session dir> # replay your own pi sessions from ~/.pi/agent/sessions
189
- node --import tsx eval/bench/autoresearch.ts --iterations 8 # a researcher model tunes prompts and thresholds
233
+ node --import tsx eval/bench/autoresearch.ts --iterations 8 # a researcher model tunes the prompts and thresholds
190
234
  ```
191
235
 
192
- STATUS.md is the research log: every variant tried, its numbers, and why the defaults are what they are. The short
193
- version: new code-built views moved the numbers, prompt wording did not, and the small holdout was wrong about code
194
- until the slice was five times larger.
236
+ STATUS.md is the research log. It lists every variant that we tried, its numbers, and why the defaults are what they
237
+ are. In short: new code-built views moved the numbers, prompt wording did not, and the small holdout was wrong about
238
+ code until the slice was five times larger.
195
239
 
196
240
  ## Optional: post-send pruning
197
241
 
198
- `JEV_LENS_MODE=budget` (or `rolling`, `batch`) turns on a second layer: after the agent has reacted to a tool result,
199
- jev judges whether it is still needed, and the result is trimmed to head and tail or replaced by a one-line stub in
200
- later prompts. Decisions are persisted and frozen once applied, so the cached prefix is rewritten as rarely as
201
- possible; `budget` mode only rewrites when the pending prunes remove at least half of the tail they would touch.
202
- Measured on real sessions this frees context but does not save money under prompt-cache pricing, which is why it is
203
- off by default. Its settings: `JEV_LENS_BUDGET_FRACTION` / `_BUDGET_MIN_TOKENS` (`0.5` / `1000`), `JEV_LENS_FORGET_BELOW`
204
- (`0.25`), `JEV_LENS_TRIM_BELOW` / `_TRIM_ABOVE` (`0.5` / `0.6`), `JEV_LENS_MIN_TOKENS` (`150`), `JEV_LENS_TRIM_HEAD` /
205
- `_TRIM_TAIL` (`15` / `15`), `JEV_LENS_CLASSIFY_WAIT_MS` (`2500`), `JEV_LENS_CACHE_TTL_MS` (`300000`),
206
- `JEV_LENS_STATE_HEAD` / `_STATE_TAIL` (`2500` / `800`), `JEV_LENS_DISABLED=1` (new decisions become `keep`).
207
- `/jev-lens decisions` lists them. Tool results are never removed, only rewritten.
242
+ `JEV_LENS_MODE=budget` (or `rolling`, or `batch`) turns on a second layer. After the agent reacted to a tool result,
243
+ jev judges whether the result is still needed. In later prompts, a result that is not needed is cut to its head and
244
+ tail, or replaced by a stub of one line. jev-lens persists each decision and freezes it after the first use, so the
245
+ cached prefix is rewritten as rarely as possible. The `budget` mode only rewrites when the pending cuts remove at least
246
+ half of the tail that they would touch. On real sessions, this layer frees context but does not save money under
247
+ prompt-cache pricing. That is why it is off by default.
248
+
249
+ Its settings are `JEV_LENS_BUDGET_FRACTION` and `JEV_LENS_BUDGET_MIN_TOKENS` (`0.5` and `1000`), `JEV_LENS_FORGET_BELOW`
250
+ (`0.25`), `JEV_LENS_TRIM_BELOW` and `JEV_LENS_TRIM_ABOVE` (`0.5` and `0.6`), `JEV_LENS_MIN_TOKENS` (`150`),
251
+ `JEV_LENS_TRIM_HEAD` and `JEV_LENS_TRIM_TAIL` (`15` and `15`), `JEV_LENS_CLASSIFY_WAIT_MS` (`2500`),
252
+ `JEV_LENS_CACHE_TTL_MS` (`300000`), `JEV_LENS_STATE_HEAD` and `JEV_LENS_STATE_TAIL` (`2500` and `800`), and
253
+ `JEV_LENS_DISABLED=1` (new decisions become `keep`). The command `/jev-lens decisions` lists the decisions. jev-lens
254
+ never removes a tool result. It only rewrites it.
208
255
 
209
256
  ## Using this as a reference
210
257
 
211
- The pieces are independent of pi and can be lifted into another agent:
258
+ The pieces do not depend on pi. You can lift them into another agent:
212
259
 
213
260
  | piece | file | depends on |
214
261
  |---|---|---|
215
- | candidate views | `src/views.ts` | nothing; async code views optionally load `src/treesitter.ts` |
262
+ | candidate views | `src/views.ts` | nothing. The async code views can load `src/treesitter.ts` |
216
263
  | tree-sitter blocks and signatures | `src/treesitter.ts` | `web-tree-sitter`, `@vscode/tree-sitter-wasm`, `@binclusive/tree-sitter-kotlin-wasm` |
217
- | the jev questions, state shape, decision rule, block expansion | `src/presend.ts` | `@typesafe-ai/sdk` |
218
- | bash display-command parser | `src/shell-display.ts` | nothing |
219
- | the hook wiring for pi (tool_result, recall tool, UI) | `index.ts`, `src/ui.ts` | pi |
220
- | benchmark and metrics on real trajectories | `eval/presend-score.ts`, `eval/bench/` | run `eval/bench/fetch.sh` first |
264
+ | the jev questions, the state shape, the decision rule, block expansion | `src/presend.ts` | `@typesafe-ai/sdk` |
265
+ | the parser for bash display commands | `src/shell-display.ts` | nothing |
266
+ | the hook wiring for pi (tool_result, the recall tool, the UI) | `index.ts`, `src/ui.ts` | pi |
267
+ | the benchmark and the metrics on real trajectories | `eval/presend-score.ts`, `eval/bench/` | run `eval/bench/fetch.sh` first |
221
268
  | post-send decisions and the frozen ledger | `src/classifier.ts`, `src/policy.ts`, `src/ledger.ts` | `@typesafe-ai/sdk` |
222
269
 
223
- In one paragraph: when a large tool result arrives, build views from the text (code, no model), ask jev which view
224
- suffices and whether exact text is needed, apply the selection policy, and optionally expand blocks or sections in a
225
- second request. If a reduced view wins, replace the content with that view plus a footer naming `recall`, and keep the
226
- full text in the result's details.
270
+ The sequence is this. When a large tool result arrives, code builds the views from the text. jev says which view is
271
+ enough and whether the exact text is needed. Code applies the selection policy. If needed, a second request expands
272
+ blocks or sections. If a reduced view wins, the content becomes that view plus a footer that names `recall`, and the
273
+ full text stays in the details of the result.
227
274
 
228
275
  ## Contributing and license
229
276
 
230
277
  Issues and pull requests are welcome at [github.com/dizk/pi-jev-lens](https://github.com/dizk/pi-jev-lens).
231
- `npm test` runs the unit tests with the mock classifier; `npm run typecheck` runs tsc. Changes to how views are built
232
- or chosen should come with benchmark numbers, on the 500-trajectory slice when they touch code.
278
+ `npm test` runs the unit tests with the mock classifier. `npm run typecheck` runs tsc. A change to how views are
279
+ built or chosen must come with benchmark numbers. If the change touches code views, you must use the 500-trajectory
280
+ slice.
233
281
 
234
282
  MIT, see LICENSE.
package/index.ts CHANGED
@@ -11,11 +11,11 @@
11
11
  */
12
12
  import { appendFileSync, mkdirSync } from "node:fs";
13
13
  import { join } from "node:path";
14
- import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
14
+ import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
15
15
  import type { AgentMessage } from "./src/pi-types.ts";
16
16
  import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
17
17
  import { Type } from "typebox";
18
- import { createBashTool, createFindTool, createGrepTool, createLsTool, createReadTool } from "@earendil-works/pi-coding-agent";
18
+ import { createBashToolDefinition, createFindToolDefinition, createGrepToolDefinition, createLsToolDefinition, createReadToolDefinition } from "@earendil-works/pi-coding-agent";
19
19
  import { Text } from "@earendil-works/pi-tui";
20
20
  import { DiffOverlay, listLines, savingsLine, type CompressedRecord } from "./src/ui.ts";
21
21
  import { TypeSafeClient } from "@typesafe-ai/sdk";
@@ -23,6 +23,9 @@ import { buildItemState, JevClassifier, MockClassifier, type Classifier } from "
23
23
  import { buildPresendState, decideView, DEFAULT_PROMPTS, expandRelevantBlocks, JevPresend, MockPresend, type PresendClassifier, type PromptVariant } from "./src/presend.ts";
24
24
  import { buildCandidatesAsync, extractTerms, footer } from "./src/views.ts";
25
25
  import { keyFilePath, loadConfigWithVariant, storeKey, type Config } from "./src/config.ts";
26
+ import { Health } from "./src/health.ts";
27
+ import { SecretInput } from "./src/secret-input.ts";
28
+ import { commandCompletions, commandHelp } from "./src/commands.ts";
26
29
  import { ENTRY_TYPE, rebuildLedger } from "./src/ledger.ts";
27
30
  import { applyLedger, decideBucket, pendingPrunable, shouldApplyPending } from "./src/policy.ts";
28
31
  import { contentText, describeToolCall, estimateTokensOfText, toolCallsOf, truncate } from "./src/text.ts";
@@ -37,6 +40,7 @@ export default function (pi: ExtensionAPI) {
37
40
  const { cfg, variant } = loadConfigWithVariant();
38
41
  const prompts: PromptVariant = { ...DEFAULT_PROMPTS, ...((variant.prompts ?? {}) as Partial<PromptVariant>), viewDescriptions: { ...DEFAULT_PROMPTS.viewDescriptions, ...(((variant.prompts ?? {}) as Partial<PromptVariant>).viewDescriptions ?? {}) } };
39
42
  const viewParams = variant.views ?? {};
43
+ let keySource = process.env.TYPESAFE_API_KEY === cfg.apiKey && cfg.apiKey ? "env" : cfg.apiKey ? keyFilePath() : "none";
40
44
  let usingMock = cfg.forceMock || !cfg.apiKey;
41
45
  let classifier: Classifier = usingMock ? new MockClassifier() : new JevClassifier(cfg);
42
46
  let presend: PresendClassifier = usingMock ? new MockPresend() : new JevPresend(new TypeSafeClient({ apiKey: cfg.apiKey }), cfg.model, prompts);
@@ -54,7 +58,9 @@ export default function (pi: ExtensionAPI) {
54
58
  const recordById = new Map<string, CompressedRecord>();
55
59
  const remember = (r: CompressedRecord) => { records.push(r); recordById.set(r.id, r); if (records.length > 200) { const old = records.shift(); if (old) recordById.delete(old.id); } };
56
60
  let lastAssistantText = "";
61
+ let health = new Health();
57
62
  let presendTotals = { considered: 0, compressed: 0, tokensSaved: 0, recalls: 0 };
63
+ let restored = { compressed: 0, tokensSaved: 0 };
58
64
 
59
65
  let ledger = new Map<string, Decision>();
60
66
  /** Classifications launched but not yet resolved, keyed by toolCallId. */
@@ -96,14 +102,18 @@ export default function (pi: ExtensionAPI) {
96
102
  return sent > 0 ? Math.round((100 * kept) / (sent + kept)) : undefined;
97
103
  };
98
104
  const statusText = () => {
99
- const tag = usingMock ? "jev-lens(mock)" : "jev-lens";
105
+ const tag = !cfg.enabled ? "jev-lens(disabled)" : usingMock ? "jev-lens(mock)" : "jev-lens";
100
106
  const pct = cutShare();
101
- const lead = pct === undefined ? tag : `${tag} −${pct}% of input`;
107
+ const label = health.failing ? `${tag}(degraded)` : tag;
108
+ const lead = pct === undefined ? label : `${label} −${pct}% of input`;
102
109
  const pruned = cfg.mode === "off" ? "" : `, pruned −${(totals.pruned / 1000).toFixed(1)}k · ${totals.applied}`;
103
- return `${lead} (presend −${(presendTotals.tokensSaved / 1000).toFixed(1)}k · ${presendTotals.compressed}/${presendTotals.considered} · ${presendTotals.recalls} recalls${pruned})`;
110
+ const saved = presendTotals.tokensSaved + restored.tokensSaved;
111
+ const counts = `${presendTotals.compressed}/${presendTotals.considered}${restored.compressed ? ` new · ${restored.compressed} restored` : ""}`;
112
+ return `${lead} (presend −${(saved / 1000).toFixed(1)}k · ${counts} · ${presendTotals.recalls} recalls${pruned})`;
104
113
  };
105
114
  const status = (ctx: ExtensionContext) => {
106
115
  if (!ctx.hasUI) return;
116
+ for (const warning of health.warnings()) ctx.ui.notify(warning, "warning");
107
117
  ctx.ui.setStatus("jev-lens", statusText());
108
118
  };
109
119
 
@@ -116,6 +126,7 @@ export default function (pi: ExtensionAPI) {
116
126
  sessionAbort.abort();
117
127
  sessionAbort = new AbortController();
118
128
  lastAssistantText = "";
129
+ health = new Health();
119
130
  ledger = rebuildLedger(ctx.sessionManager.getEntries());
120
131
  buffer = [];
121
132
  inflight.clear();
@@ -130,6 +141,7 @@ export default function (pi: ExtensionAPI) {
130
141
  records.length = 0;
131
142
  recordById.clear();
132
143
  presendTotals = { considered: 0, compressed: 0, tokensSaved: 0, recalls: 0 };
144
+ restored = { compressed: 0, tokensSaved: 0 };
133
145
  try {
134
146
  mkdirSync(join(ctx.cwd, CONFIG_DIR_NAME), { recursive: true });
135
147
  logPath = join(ctx.cwd, CONFIG_DIR_NAME, "jev-lens.log");
@@ -147,6 +159,8 @@ export default function (pi: ExtensionAPI) {
147
159
  if (d?.full) {
148
160
  fullOutputs.set(entry.message.toolCallId, { text: d.full, toolName: entry.message.toolName, args: d.args, view: d.view ?? "?" });
149
161
  const sent = contentText(entry.message.content).replace(/\n\n\[jev-lens:[\s\S]*$/, "");
162
+ restored.compressed++;
163
+ restored.tokensSaved += Math.max(0, estimateTokensOfText(d.full) - estimateTokensOfText(sent));
150
164
  remember({ id: entry.message.toolCallId, toolName: entry.message.toolName, args: d.args, kind: d.kind ?? "?", view: d.view ?? "?", tokensBefore: estimateTokensOfText(d.full), tokensAfter: estimateTokensOfText(sent), full: d.full, sent, included: d.included ?? [], needsFull: d.needsFull, pFull: d.p?.full, recalls: 0, at: entry.message.timestamp });
151
165
  }
152
166
  }
@@ -206,20 +220,19 @@ export default function (pi: ExtensionAPI) {
206
220
  }
207
221
  });
208
222
 
209
- pi.on("agent_end", async () => {
210
- const epoch = generation;
223
+ pi.on("agent_end", async (_event, ctx) => {
211
224
  // No further assistant reaction is coming for the last results; classify with what we have.
212
225
  const toClassify = buffer;
213
226
  buffer = [];
214
- for (const item of toClassify) launchClassification(item, "", [], undefined);
227
+ for (const item of toClassify) launchClassification(item, "", [], ctx);
215
228
  await waitForWork([...inflight.values()]);
216
- void epoch;
217
229
  });
218
230
 
219
231
  function launchClassification(item: PendingResult, afterText: string, afterCalls: { name: string; arguments: unknown }[], ctx?: ExtensionContext) {
220
232
  const m = item.message;
221
233
  if (cfg.mode === "off" || sessionAbort.signal.aborted || m.content.some((c) => c.type !== "text")) return;
222
234
  const epoch = generation;
235
+ const signal = workSignal(ctx?.signal);
223
236
  if (ledger.has(m.toolCallId) || inflight.has(m.toolCallId)) return;
224
237
  const output = contentText(m.content);
225
238
  const tokens = estimateTokensOfText(output);
@@ -238,9 +251,11 @@ export default function (pi: ExtensionAPI) {
238
251
  const summary = describeToolCall(m.toolName, item.args, output.length, lines);
239
252
  const started = Date.now();
240
253
  const p = classifier
241
- .classifyToolResult(state, workSignal(ctx?.signal))
254
+ .classifyToolResult(state, signal)
242
255
  .then((probs) => {
243
- if (epoch !== generation) return;
256
+ if (epoch !== generation || signal.aborted) return;
257
+ health.success("postsend");
258
+ if (ctx) status(ctx);
244
259
  const decision: Decision = {
245
260
  id: m.toolCallId,
246
261
  toolName: m.toolName,
@@ -256,7 +271,10 @@ export default function (pi: ExtensionAPI) {
256
271
  log({ event: "decision", id: decision.id, tool: m.toolName, bucket: decision.bucket, p: probs, tokens, ms: Date.now() - started, summary });
257
272
  })
258
273
  .catch((err) => {
259
- if (epoch === generation) log({ event: "classify_error", id: m.toolCallId, error: String(err?.message ?? err) });
274
+ if (epoch !== generation || signal.aborted) return;
275
+ health.failure("postsend", err);
276
+ log({ event: "classify_error", id: m.toolCallId, error: health.lines()[1] });
277
+ if (ctx) status(ctx);
260
278
  })
261
279
  .finally(() => { if (epoch === generation) inflight.delete(m.toolCallId); });
262
280
  inflight.set(m.toolCallId, p);
@@ -348,16 +366,16 @@ export default function (pi: ExtensionAPI) {
348
366
  if (event.content.some((c) => c.type === "image")) return;
349
367
  presendTotals.considered++;
350
368
  const started = Date.now();
351
- const terms = extractTerms(latestUser, lastAssistantText, JSON.stringify(event.input ?? {}));
352
- const cands = await buildCandidatesAsync(event.toolName, event.input, text, terms, viewParams);
353
- if (epoch !== generation || signal.aborted) return;
354
- if (cands.views.length < 2) {
355
- log({ event: "presend", id: event.toolCallId, tool: event.toolName, tokens, view: "full", reason: "no-candidates" });
356
- return;
357
- }
358
- const totalLines = text.split("\n").length;
359
- const state = buildPresendState(cfg, { firstUser, latestUser, agentText: lastAssistantText, toolName: event.toolName, args: event.input, isError: event.isError, cands, totalLines, totalChars: text.length });
360
369
  try {
370
+ const terms = extractTerms(latestUser, lastAssistantText, JSON.stringify(event.input ?? {}));
371
+ const cands = await buildCandidatesAsync(event.toolName, event.input, text, terms, viewParams);
372
+ if (epoch !== generation || signal.aborted) return;
373
+ if (cands.views.length < 2) {
374
+ log({ event: "presend", id: event.toolCallId, tool: event.toolName, tokens, view: "full", reason: "no-candidates" });
375
+ return;
376
+ }
377
+ const totalLines = text.split("\n").length;
378
+ const state = buildPresendState(cfg, { firstUser, latestUser, agentText: lastAssistantText, toolName: event.toolName, args: event.input, isError: event.isError, cands, totalLines, totalChars: text.length });
361
379
  const answer = await presend.choose(state, cands.views.map((v) => v.kind), signal);
362
380
  if (epoch !== generation || signal.aborted) return;
363
381
  let view = decideView(answer, cands, cfg);
@@ -368,6 +386,8 @@ export default function (pi: ExtensionAPI) {
368
386
  if (epoch !== generation || signal.aborted) return;
369
387
  if (ex) { view = ex.view; expanded = ex.probs.map((p, i) => (p > above ? i : -1)).filter((i) => i >= 0); }
370
388
  }
389
+ health.success("presend");
390
+ status(ctx);
371
391
  log({ event: "presend", id: event.toolCallId, tool: event.toolName, kind: cands.kind, tokens, view: view.kind, viewTokens: estimateTokensOfText(view.text), chosen: answer.choice, needsFull: answer.needsFull, p: answer.probabilities, confidence: answer.confidence, expanded, candidates: cands.views.map((v) => `${v.kind}:${v.chars}`), ms: Date.now() - started });
372
392
  if (view.kind === "full") return;
373
393
  presendTotals.compressed++;
@@ -378,7 +398,10 @@ export default function (pi: ExtensionAPI) {
378
398
  status(ctx);
379
399
  return { content: [{ type: "text", text: view.text + footer(view, event.toolCallId, totalLines) }], details };
380
400
  } catch (err) {
381
- if (epoch === generation) log({ event: "presend_error", id: event.toolCallId, error: String((err as Error)?.message ?? err) });
401
+ if (epoch !== generation || signal.aborted) return;
402
+ health.failure("presend", err);
403
+ log({ event: "presend_error", id: event.toolCallId, error: health.lines()[0] });
404
+ status(ctx);
382
405
  return;
383
406
  }
384
407
  });
@@ -427,71 +450,69 @@ export default function (pi: ExtensionAPI) {
427
450
 
428
451
  if (process.env.JEV_LENS_UI !== "0") {
429
452
  const cwd = process.cwd();
430
- const originals: Record<string, ReturnType<typeof createReadTool>> = {
431
- read: createReadTool(cwd) as ReturnType<typeof createReadTool>,
432
- bash: createBashTool(cwd) as unknown as ReturnType<typeof createReadTool>,
433
- grep: createGrepTool(cwd) as unknown as ReturnType<typeof createReadTool>,
434
- find: createFindTool(cwd) as unknown as ReturnType<typeof createReadTool>,
435
- ls: createLsTool(cwd) as unknown as ReturnType<typeof createReadTool>,
436
- };
437
- for (const [name, original] of Object.entries(originals)) {
438
- const o = original as unknown as { description: string; parameters: never; execute: (...a: unknown[]) => Promise<unknown>; renderCall?: (...a: unknown[]) => unknown; renderResult?: (...a: unknown[]) => unknown; promptSnippet?: string; promptGuidelines?: string[] };
453
+ // Tool definitions include pi's renderers; create*Tool() strips them.
454
+ const originals: ToolDefinition<any, any>[] = [
455
+ createReadToolDefinition(cwd), createBashToolDefinition(cwd),
456
+ createGrepToolDefinition(cwd), createFindToolDefinition(cwd), createLsToolDefinition(cwd),
457
+ ];
458
+ for (const original of originals) {
439
459
  pi.registerTool({
440
- name,
441
- label: name,
442
- description: o.description,
443
- parameters: o.parameters,
444
- promptSnippet: o.promptSnippet,
445
- promptGuidelines: o.promptGuidelines,
446
- async execute(toolCallId: string, params: unknown, signal: AbortSignal | undefined, onUpdate: unknown, ctx: unknown) {
447
- return (o.execute as (id: string, p: unknown, s: unknown, u: unknown, c: unknown) => Promise<never>)(toolCallId, params, signal, onUpdate, ctx);
448
- },
449
- renderCall(args: unknown, theme: Theme, context: unknown) {
450
- if (o.renderCall) return (o.renderCall as (a: unknown, t: unknown, c: unknown) => never)(args, theme, context);
451
- const a = args as Record<string, unknown>;
452
- const what = typeof a.path === "string" ? a.path : typeof a.command === "string" ? a.command : typeof a.pattern === "string" ? a.pattern : "";
453
- return new Text(theme.fg("toolTitle", theme.bold(`${name} `)) + theme.fg("accent", String(what)), 0, 0);
454
- },
455
- renderResult(result: { content: unknown }, options: { expanded: boolean }, theme: Theme, context: { toolCallId: string }) {
460
+ ...original,
461
+ renderResult(result, options, theme, context) {
456
462
  const rec = recordById.get(context.toolCallId);
457
- if (!rec) {
458
- if (o.renderResult) return (o.renderResult as (r: unknown, op: unknown, t: unknown, c: unknown) => never)(result, options, theme, context);
459
- const text = contentText(result.content);
460
- const lines = text.split("\n");
461
- let out = theme.fg("success", `${lines.length} lines`);
462
- if (options.expanded) out += "\n" + lines.slice(0, 200).join("\n");
463
- else out += theme.fg("dim", " " + lines[0]?.slice(0, 80));
464
- return new Text(out, 0, 0);
463
+ if (!rec || options.isPartial || context.isError) {
464
+ return original.renderResult!(result, options, theme, context);
465
465
  }
466
466
  let out = savingsLine(rec, theme);
467
467
  if (options.expanded) out += "\n" + rec.sent;
468
468
  else out += "\n" + theme.fg("dim", rec.sent.split("\n").slice(0, 3).join("\n"));
469
469
  return new Text(out, 0, 0);
470
470
  },
471
- } as never);
471
+ });
472
472
  }
473
473
  }
474
474
 
475
475
  // ---- commands ----------------------------------------------------------------------
476
476
 
477
477
  pi.registerCommand("jev-lens", {
478
- description: "jev-lens: stats | list (compressed results) | diff [n] (original vs sent, overlay) | decisions | key [api-key] (store your TypeSafe key)",
478
+ description: "Inspect compression and setup: stats | list | diff [n] | decisions | key | help",
479
+ getArgumentCompletions: (prefix) => commandCompletions(prefix, records),
479
480
  handler: async (args, ctx) => {
480
481
  const sub = (args ?? "").trim();
481
- if (sub === "key" || sub.startsWith("key ")) {
482
+ if (sub === "help" || sub === "--help" || sub === "-h") {
483
+ ctx.ui.notify(commandHelp, "info");
484
+ return;
485
+ }
486
+ if (/^key(?:\s|$)/.test(sub)) {
482
487
  let key = sub.slice(3).trim();
483
- if (!key) key = ((await ctx.ui.input("TypeSafe API key (from console.typesafe.ai):", "ts_...")) ?? "").trim();
484
- if (!key) { ctx.ui.notify("no key entered", "info"); return; }
485
- const where = storeKey(key);
488
+ if (!key && (!ctx.hasUI || ctx.mode !== "tui")) { ctx.ui.notify("Masked key input requires terminal mode. Set TYPESAFE_API_KEY or run /jev-lens key in interactive pi.", "warning"); return; }
489
+ if (!key) key = ((await ctx.ui.custom<string | undefined>((tui, theme, keys, done) =>
490
+ new SecretInput(theme, keys, done, () => tui.requestRender()),
491
+ )) ?? "").trim();
492
+ if (!key) { ctx.ui.notify("Key setup cancelled. The current key is unchanged.", "info"); return; }
493
+ let where: string;
494
+ try { where = storeKey(key); }
495
+ catch {
496
+ ctx.ui.notify(`Could not store the key in ${keyFilePath()}. Check directory permissions or set TYPESAFE_API_KEY.`, "error");
497
+ return;
498
+ }
486
499
  useKey(key);
487
- ctx.ui.notify(`jev-lens: key stored in ${where}; jev is active from the next tool result`, "info");
500
+ keySource = where;
501
+ const next = cfg.forceMock ? "Mock mode remains active. Unset JEV_LENS_CLASSIFIER and reload pi to use jev." : !cfg.enabled || !cfg.presend ? "Pre-send compression is disabled. See /jev-lens stats." : "jev will use this key from the next tool result. The key has not been validated.";
502
+ ctx.ui.notify(`jev-lens: key stored in ${where}. ${next}${process.env.TYPESAFE_API_KEY ? " TYPESAFE_API_KEY takes priority again after reload." : ""}`, "info");
488
503
  status(ctx);
489
504
  return;
490
505
  }
491
- if (sub.startsWith("diff")) {
492
- const n = Number(sub.slice(4).trim() || "1");
493
- const rec = records[records.length - (Number.isFinite(n) && n >= 1 ? n : 1)];
494
- if (!rec) { ctx.ui.notify("no compressed tool result to show yet", "info"); return; }
506
+ if (/^diff(?:\s|$)/.test(sub)) {
507
+ const arg = sub.slice(4).trim();
508
+ const n = Number(arg || "1");
509
+ if ((arg && !/^\d+$/.test(arg)) || !Number.isSafeInteger(n) || n < 1) {
510
+ ctx.ui.notify("Usage: /jev-lens diff [n]. Use a positive whole number. 1 is the newest result.", "warning");
511
+ return;
512
+ }
513
+ if (!records.length) { ctx.ui.notify("No compressed results yet. Use /jev-lens stats to inspect compression settings.", "info"); return; }
514
+ const rec = records[records.length - n];
515
+ if (!rec) { ctx.ui.notify(`Result ${n} is not available. Choose 1-${records.length} from /jev-lens list.`, "warning"); return; }
495
516
  if (!ctx.hasUI || ctx.mode !== "tui") { ctx.ui.notify(listLines([rec], { fg: (_c, t) => t, bold: (t) => t }).join("\n"), "info"); return; }
496
517
  await ctx.ui.custom<void>((tui, theme, _kb, done) => {
497
518
  const height = Math.max(12, Math.floor(((tui as { terminalHeight?: number }).terminalHeight ?? process.stdout.rows ?? 40) * 0.85));
@@ -506,17 +527,23 @@ export default function (pi: ExtensionAPI) {
506
527
  }
507
528
  if (sub === "decisions") {
508
529
  const rows = [...ledger.values()].map((d) => `${d.status === "applied" ? "●" : "○"} ${d.bucket.padEnd(6)} n=${d.p.needed.toFixed(2)} o=${d.p.outcomeOnly.toFixed(2)} ${d.tokensBefore}t ${d.summary}`);
509
- ctx.ui.notify(rows.join("\n") || "(no decisions yet)", "info");
530
+ ctx.ui.notify(rows.join("\n") || (cfg.mode === "off" ? "Post-send pruning is off (the default). Pre-send compression is separate: see /jev-lens stats." : "No post-send decisions yet."), "info");
531
+ return;
532
+ }
533
+ if (sub && sub !== "stats") {
534
+ ctx.ui.notify("Unknown subcommand or extra arguments. Run /jev-lens help for usage.", "warning");
510
535
  return;
511
536
  }
512
537
  const hit = totals.input + totals.cacheRead > 0 ? Math.round((100 * totals.cacheRead) / (totals.input + totals.cacheRead)) : 0;
513
538
  ctx.ui.notify(
514
539
  [
515
- `mode=${cfg.mode} enabled=${cfg.enabled} classifier=${usingMock ? "mock (no key: /jev-lens key)" : cfg.model} key=${process.env.TYPESAFE_API_KEY ? "env" : cfg.apiKey ? keyFilePath() : "none"}`,
516
- `presend: ${presendTotals.compressed}/${presendTotals.considered} large results compressed, ≈${presendTotals.tokensSaved} tokens saved, ${presendTotals.recalls} recalls`,
540
+ `mode=${cfg.mode} enabled=${cfg.enabled} presend=${cfg.presend} classifier=${usingMock ? cfg.forceMock ? "mock (forced by JEV_LENS_CLASSIFIER)" : "mock (no key: /jev-lens key)" : cfg.model} key=${keySource}`,
541
+ `presend since load: ${presendTotals.compressed}/${presendTotals.considered} large results compressed, ≈${presendTotals.tokensSaved} tokens saved, ${presendTotals.recalls} recalls`,
542
+ `restored from session: ${restored.compressed} compressed results, ≈${restored.tokensSaved} tokens saved (included in footer savings)`,
517
543
  `post-send: calls=${totals.calls} decisions=${ledger.size} applied=${totals.applied} pruned≈${totals.pruned} tokens`,
544
+ ...health.lines(),
518
545
  `cache: read=${totals.cacheRead} uncached=${totals.input} hit=${hit}%`,
519
- `input cut: ${cutShare() ?? 0}% of the session's input tokens (≈${cut.presend + cut.pruned} of ${totals.input + totals.cacheRead + cut.presend + cut.pruned}: presend ${cut.presend}, pruned ${cut.pruned}, summed over ${totals.calls} calls)`,
546
+ `input cut: ${cutShare() ?? 0}% of input tokens counted since load (≈${cut.presend + cut.pruned} of ${totals.input + totals.cacheRead + cut.presend + cut.pruned}: presend ${cut.presend}, pruned ${cut.pruned}, summed over ${totals.calls} calls)`,
520
547
  ].join("\n"),
521
548
  "info",
522
549
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-jev-lens",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "pi extension that compresses large tool results before they reach the model: jev picks the view (outline, relevant blocks, sections, signals, testlog), full text stays recallable",
5
5
  "author": "Didrik Rognstad",
6
6
  "license": "MIT",
@@ -51,7 +51,7 @@
51
51
  "web-tree-sitter": "^0.27.0"
52
52
  },
53
53
  "peerDependencies": {
54
- "@earendil-works/pi-coding-agent": "*",
54
+ "@earendil-works/pi-coding-agent": ">=0.84.3",
55
55
  "@earendil-works/pi-tui": "*",
56
56
  "typebox": "*"
57
57
  },
@@ -0,0 +1,35 @@
1
+ import type { AutocompleteItem } from "@earendil-works/pi-tui";
2
+ import type { CompressedRecord } from "./ui.ts";
3
+
4
+ const commands = [
5
+ { value: "stats", label: "stats", description: "Show statistics and active configuration" },
6
+ { value: "list", label: "list", description: "List recent compressed results" },
7
+ { value: "diff", label: "diff", description: "Compare original and sent output: diff [n], newest = 1" },
8
+ { value: "decisions", label: "decisions", description: "Show post-send pruning decisions" },
9
+ { value: "key", label: "key", description: "Store a TypeSafe API key" },
10
+ { value: "help", label: "help", description: "Show commands and usage" },
11
+ ];
12
+
13
+ export const commandHelp = [
14
+ "/jev-lens [stats] — Show statistics and active configuration.",
15
+ ...commands.slice(1).map((c) => `/jev-lens ${c.value === "diff" ? "diff [n]" : c.value} — ${c.description}.`),
16
+ "For diff, 1 is the newest result. Use /jev-lens list to find a number.",
17
+ "Press Tab after /jev-lens to complete a subcommand.",
18
+ ].join("\n");
19
+
20
+ /** Pi replaces the entire argument prefix, so diff values include the subcommand. */
21
+ export function commandCompletions(prefix: string, records: CompressedRecord[]): AutocompleteItem[] | null {
22
+ const input = prefix.trimStart();
23
+ const diff = /^diff\s+(\d*)$/.exec(input);
24
+ let items: AutocompleteItem[];
25
+ if (diff) {
26
+ items = [...records].reverse().map((r, i) => ({
27
+ value: `diff ${i + 1}`,
28
+ label: `diff ${i + 1}`,
29
+ description: `${r.toolName} · ${r.view} · ${r.tokensBefore} → ${r.tokensAfter} tokens`,
30
+ })).filter((_, i) => String(i + 1).startsWith(diff[1]));
31
+ } else {
32
+ items = commands.filter((c) => c.value.startsWith(input));
33
+ }
34
+ return items.length ? items : null;
35
+ }
package/src/health.ts ADDED
@@ -0,0 +1,38 @@
1
+ export type Stage = "presend" | "postsend";
2
+
3
+ type StageHealth = { failures: number; failing: boolean; reason: string; notified: boolean };
4
+
5
+ /** Session-local failure counters. Never expose provider error messages or credentials. */
6
+ export class Health {
7
+ private stages: Record<Stage, StageHealth> = {
8
+ presend: { failures: 0, failing: false, reason: "", notified: false },
9
+ postsend: { failures: 0, failing: false, reason: "", notified: false },
10
+ };
11
+
12
+ failure(stage: Stage, error: unknown): void {
13
+ const status = error && typeof error === "object" && "status" in error ? error.status : undefined;
14
+ const reason = status === 401 || status === 403 ? "Check your TypeSafe API key."
15
+ : status === 429 ? "TypeSafe rejected the request because of a usage limit."
16
+ : "Check your connection and TypeSafe service availability.";
17
+ Object.assign(this.stages[stage], { failures: this.stages[stage].failures + 1, failing: true, reason });
18
+ }
19
+
20
+ success(stage: Stage): void { this.stages[stage].failing = false; }
21
+
22
+ get failing(): boolean { return Object.values(this.stages).some((s) => s.failing); }
23
+
24
+ /** At most one warning per stage per session, including work completed without a UI context. */
25
+ warnings(): string[] {
26
+ return (Object.entries(this.stages) as [Stage, StageHealth][]).flatMap(([stage, state]) => {
27
+ if (!state.failures || state.notified) return [];
28
+ state.notified = true;
29
+ const effect = stage === "presend" ? "Full output was kept." : "The affected result was not pruned.";
30
+ return [`jev-lens: ${stage === "presend" ? "Pre-send compression" : "Post-send classification"} failed. ${effect} ${state.reason} See /jev-lens stats. Further failures appear there without repeated warnings.`];
31
+ });
32
+ }
33
+
34
+ lines(): string[] {
35
+ return (Object.entries(this.stages) as [Stage, StageHealth][]).map(([stage, state]) =>
36
+ `${stage} failures: ${state.failures}${state.failures ? state.failing ? ` (last attempt failed). ${state.reason}` : " (a later attempt succeeded)" : ""}`);
37
+ }
38
+ }
@@ -0,0 +1,124 @@
1
+ import { CURSOR_MARKER, decodeKittyPrintable, truncateToWidth, type Component, type Focusable, type KeybindingsManager } from "@earendil-works/pi-tui";
2
+ import type { ThemeLike } from "./ui.ts";
3
+
4
+ const PASTE_START = "\x1b[200~", PASTE_END = "\x1b[201~";
5
+ const MAX_LENGTH = 4096;
6
+
7
+ /** A secret-only editor: no plaintext rendering, history, clipboard, undo, or reveal action. */
8
+ export class SecretInput implements Component, Focusable {
9
+ focused = false;
10
+ private value: string[] = [];
11
+ private cursor = 0;
12
+ private paste: string | undefined;
13
+ private pasteTooLong = false;
14
+ private error = "";
15
+ private closed = false;
16
+
17
+ constructor(
18
+ private theme: ThemeLike,
19
+ private keys: Pick<KeybindingsManager, "matches" | "getKeys">,
20
+ private done: (value: string | undefined) => void,
21
+ private requestRender: () => void,
22
+ ) {}
23
+
24
+ private insert(text: string): void {
25
+ const chars = Array.from(text);
26
+ if (/[\x00-\x1f\x7f-\x9f]/.test(text)) {
27
+ this.error = "Paste a single-line API key. Control characters are not allowed.";
28
+ } else if (this.value.length + chars.length > MAX_LENGTH) {
29
+ this.error = `The key is too long. Maximum: ${MAX_LENGTH} characters.`;
30
+ } else {
31
+ this.value.splice(this.cursor, 0, ...chars);
32
+ this.cursor += chars.length;
33
+ this.error = "";
34
+ }
35
+ }
36
+
37
+ private finish(value?: string): void {
38
+ this.dispose();
39
+ this.done(value);
40
+ }
41
+
42
+ /** Drop references on submit, cancel, or external teardown. JS cannot guarantee memory erasure. */
43
+ dispose(): void {
44
+ this.value.fill("");
45
+ this.value = [];
46
+ this.cursor = 0;
47
+ this.paste = undefined;
48
+ this.error = "";
49
+ this.closed = true;
50
+ }
51
+
52
+ handleInput(data: string): void {
53
+ if (this.closed) return;
54
+ if (this.paste === undefined && data.startsWith(PASTE_START)) {
55
+ this.paste = "";
56
+ this.pasteTooLong = false;
57
+ data = data.slice(PASTE_START.length);
58
+ }
59
+ if (this.paste !== undefined) {
60
+ this.paste += data;
61
+ const end = this.paste.indexOf(PASTE_END);
62
+ if (end >= 0) {
63
+ const text = this.paste.slice(0, end);
64
+ const remaining = this.paste.slice(end + PASTE_END.length);
65
+ this.paste = undefined;
66
+ if (this.pasteTooLong) this.error = "The pasted key is too long. Paste only the API key.";
67
+ else this.insert(text.trim());
68
+ if (remaining) this.handleInput(remaining);
69
+ } else if (this.paste.length > MAX_LENGTH + PASTE_END.length) {
70
+ this.pasteTooLong = true;
71
+ this.paste = this.paste.slice(-PASTE_END.length); // retain a possible split end marker
72
+ }
73
+ } else if (this.keys.matches(data, "tui.select.cancel")) {
74
+ this.finish();
75
+ } else if (this.keys.matches(data, "tui.input.submit")) {
76
+ if (!this.error) this.finish(this.value.join("").trim() || undefined);
77
+ } else if (this.keys.matches(data, "tui.editor.cursorLeft")) {
78
+ this.cursor = Math.max(0, this.cursor - 1);
79
+ } else if (this.keys.matches(data, "tui.editor.cursorRight")) {
80
+ this.cursor = Math.min(this.value.length, this.cursor + 1);
81
+ } else if (this.keys.matches(data, "tui.editor.cursorLineStart")) {
82
+ this.cursor = 0;
83
+ } else if (this.keys.matches(data, "tui.editor.cursorLineEnd")) {
84
+ this.cursor = this.value.length;
85
+ } else if (this.keys.matches(data, "tui.editor.deleteCharBackward")) {
86
+ if (this.cursor) this.value.splice(--this.cursor, 1);
87
+ this.error = "";
88
+ } else if (this.keys.matches(data, "tui.editor.deleteCharForward")) {
89
+ this.value.splice(this.cursor, 1);
90
+ this.error = "";
91
+ } else if (this.keys.matches(data, "tui.editor.deleteToLineStart")) {
92
+ this.value.splice(0, this.cursor);
93
+ this.cursor = 0;
94
+ this.error = "";
95
+ } else if (this.keys.matches(data, "tui.editor.deleteToLineEnd")) {
96
+ this.value.splice(this.cursor);
97
+ this.error = "";
98
+ } else {
99
+ const text = decodeKittyPrintable(data) ?? data;
100
+ if (!/[\x00-\x1f\x7f-\x9f]/.test(text)) this.insert(text);
101
+ }
102
+ this.requestRender();
103
+ }
104
+
105
+ invalidate(): void {}
106
+
107
+ render(width: number): string[] {
108
+ if (width < 1) return [""];
109
+ const room = Math.max(1, width - 2);
110
+ const start = Math.max(0, this.cursor - room + 1);
111
+ const before = "*".repeat(this.cursor - start);
112
+ const after = "*".repeat(Math.min(this.value.length - this.cursor, room - before.length - 1));
113
+ const cursor = this.cursor < this.value.length ? "*" : " ";
114
+ const marker = this.focused ? CURSOR_MARKER : "";
115
+ const field = `${width > 2 ? "> " : ""}${before}${marker}${this.focused ? `\x1b[7m${cursor}\x1b[27m` : cursor}${after}`;
116
+ return [
117
+ this.theme.fg("accent", "TypeSafe API key (masked)"),
118
+ this.theme.fg("dim", "Get a key at console.typesafe.ai. Type or paste it below."),
119
+ field,
120
+ this.theme.fg("dim", `${this.keys.getKeys("tui.input.submit").join("/")}: save · ${this.keys.getKeys("tui.select.cancel").join("/")}: cancel`),
121
+ ...(this.error ? [this.theme.fg("warning", this.error)] : []),
122
+ ].map((line) => truncateToWidth(line, width));
123
+ }
124
+ }