pi-jev-lens 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,116 +1,79 @@
1
1
  # pi-jev-lens
2
2
 
3
- A [pi](https://github.com/earendil-works/pi-mono) extension that **compresses large tool results before they reach the
4
- model**, using [jev](https://docs.typesafe.ai) (TypeSafe's System One model) to select useful views and code blocks.
5
- The agent gets a smaller result now and can use `recall` to retrieve omitted text when needed.
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.
6
5
 
7
- **Pre-send compression is the main cost lever.** Avoiding the first send saves uncached input tokens without rewriting
8
- an already-cached message. Pruning later can free context, but may cost more by invalidating part of the prompt cache.
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.
9
12
 
10
- The extension has three complementary layers, all enabled by default:
13
+ What the model sees instead of a 1.5k-token file:
11
14
 
12
- 1. **Pre-send compression:** send outlines, relevant code blocks or filtered output instead of the entire result.
13
- 2. **Cache-aware post-send pruning:** after the agent reacts, keep, trim or stub results when the pruning policy permits.
14
- 3. **Durable notes:** retain selected project facts and preferences for future sessions.
15
-
16
- ## Pre-send compression (the cost lever)
17
-
18
- Large text tool results (default: at least 1200 estimated tokens, estimated as characters / 4) are considered for
19
- compression in pi's `tool_result` hook. Results containing images and calls to `recall` are excluded. Code builds
20
- candidate **views** from the output, with line numbers and omission markers. Views of code and prose preserve
21
- retained lines exactly, so edits copied from a view still match the file; views of command output, listings and
22
- data shorten decorative bars, long runs of spaces and very long lines. Full text is still sent when no suitable reduced view
23
- is available or classification fails. A conservative subset of bash file displays (`cat a.py b.py`,
24
- `sed -n '1,80p' x.ts`, line-limited `head`/`tail`, brace groups and globs) gets code or prose views when all displayed
25
- files have that type. Pipelines may only filter stdin with recognized options. Redirections, substitutions, modifying
26
- `sed` scripts, mixed code/non-code files and unsupported syntax retain ordinary command handling:
27
-
28
- | view | for | keeps |
29
- |---|---|---|
30
- | `outline` | code, prose | imports, exports, signatures, headings, doc comments |
31
- | `relevant` | code | outline plus the full bodies of the blocks jev says the agent will need (second jev step) |
32
- | `focus` | anything | lines mentioning identifiers from the task and the tool call, with context |
33
- | `signals` | command output | errors, warnings, failing tests, summary lines, the tail |
34
- | `sample` | tabular or log-like data | header, a dozen rows, the count |
35
- | `head_tail` | anything | first and last lines |
36
- | `testlog` | test output | failures, assertions, tracebacks and summaries |
37
- | `tree` | directory listings | a sample of entries per directory, with omission counts |
38
- | `matches` | search output | first matches per file, with omission counts |
39
- | `log` | repetitive output | representative repeated lines, errors and the tail |
40
- | `sections` | command output | the first line of every section (grep match groups, JSON keys, headings, `COMMAND:`-style markers, paragraphs); a second jev step puts back the sections the agent needs, giving `relevant` |
41
-
42
- Code structure comes from tree-sitter (grammars from `@vscode/tree-sitter-wasm` plus `@binclusive/tree-sitter-kotlin-wasm`):
43
- TypeScript, TSX, JavaScript, Kotlin, Java, Rust, Python, Go, C, C++, C#, Ruby, PHP, Bash, CSS. Large classes and impl blocks
44
- are split into their members. Other languages fall back to regex heuristics that know the common declaration keywords.
45
-
46
- jev answers two questions over the task, the assistant's text before the call (not hidden thinking), and a preview of
47
- each view: *which view is the smallest that still suffices* (Choice) and *will the next step need the exact full text*
48
- (Noul). Thresholds decide when to send full text. For code, when a reduced view is chosen (or with the `outline`
49
- policy, always), a second step asks jev which block bodies to expand. If the expanded view reaches 90 % of the
50
- original character count, full text is sent instead.
51
-
52
- When a result is compressed, its full output is kept in `details` (persisted in the session, not included in the model
53
- prompt) and served by a `recall` tool using an id, a line range or a pattern. This storage covers pre-send compression,
54
- not results that were only pruned post-send; those must be obtained by re-running the original tool. Every recall is
55
- logged as feedback on the reduced view. Set `JEV_LENS_PRESEND=0` to turn pre-send compression off.
15
+ ```
16
+ 1│ import { parse } from "./parse.js";
17
+ 14 lines omitted
18
+ 16│ export function normalizeCategory(raw) {
19
+ 17│ const key = raw.trim().toLowerCase();
20
+ 18│ return ALIASES[key] ?? key;
21
+ 19│ }
22
+ 61 lines omitted
23
+ 81│ export function categoryReport(entries) {
24
+ 20 lines omitted
25
+
26
+ [jev-lens: showing the "relevant" view, 9 of 102 lines. Omitted lines are marked ⋯. Call recall(id: "…") for the
27
+ full output, or recall(id, lines: "a-b") / recall(id, pattern: "...") for a slice.]
28
+ ```
56
29
 
57
- ## Post-send pruning (the context-budget layer)
30
+ ## What the numbers say
58
31
 
59
- This is a secondary pass, not the source of the initial pre-send savings. It classifies the result the agent saw
60
- (which may already be compressed) after the agent has reacted to it:
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:
61
34
 
62
- | decision | what happens in later prompts |
35
+ | | |
63
36
  |---|---|
64
- | **keep** | leave the result unchanged, including any pre-send compression |
65
- | **trim** | keep the head and tail; drop the middle |
66
- | **forget** | replace the result with a one-line stub; re-run the tool if needed |
67
-
68
- Prompt caches match on an exact prefix. Any edit to an already-sent message invalidates the cache from that point on.
69
- The extension limits repeated rewrites using persisted decisions:
70
-
71
- 1. **Classify after reaction.** Text-only tool results of at least `JEV_LENS_MIN_TOKENS` are queued for classification after
72
- the next assistant message supplies evidence of what happened next. Results still buffered at agent end are
73
- classified without that reaction. Results with an existing decision or an in-flight classification are skipped.
74
- 2. **Apply, then freeze the decision.** The `context` hook waits briefly for in-flight classifications and applies
75
- pending decisions when the selected mode permits. Decisions are persisted and restored at session start; applied
76
- decisions are re-applied on later calls. Transforms remain identical for unchanged input and configuration
77
- (changing trim settings can change the rendered text).
78
- 3. **Choose when to rewrite.** `budget` (default) applies pending prunes when their savings meet both the configured
79
- minimum and a fraction of the tail they would rewrite. `rolling` applies them at the next context hook; `batch`
80
- waits for a cold cache. All modes allow application after the configured idle TTL, and compaction marks pending
81
- decisions as applied.
82
-
83
- There is no monotonic cut boundary: a late classification can still rewrite an older result after a newer decision
84
- has been applied. Persisted decisions prevent repeated reclassification, but do not guarantee an unchanged cache prefix.
85
-
86
- Tool results are never removed, only rewritten, because every `function_call` must keep a matching output.
87
-
88
- ## Durable notes (cross-session memory)
89
-
90
- Separately from compression and pruning, jev assesses whether content is worth remembering across sessions.
91
- Selected notes are written to `<project>/.pi/jev-lens.md` as classifications complete and injected into the system
92
- prompt from a snapshot taken at the next session start. Agent end and session shutdown wait up to
93
- `JEV_LENS_CLASSIFY_WAIT_MS` for outstanding classifications. Requests still unfinished at shutdown are aborted and
94
- late responses discarded, so a slow request may not produce a note. This is not a fourth pruning bucket: saving a
95
- note does not remove its source from the prompt.
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.
96
59
 
97
60
  ## Install
98
61
 
99
62
  ```sh
100
- pi install npm:pi-jev-lens # from npm
101
- pi install git:github.com/dizk/pi-jev-lens # or straight from GitHub
63
+ pi install npm:pi-jev-lens # from npm
64
+ pi install git:github.com/dizk/pi-jev-lens # or straight from GitHub
102
65
  ```
103
66
 
104
- The classifier is [jev](https://typesafe.ai), TypeSafe's System One model, so it needs a TypeSafe API key
105
- (get one at [console.typesafe.ai](https://console.typesafe.ai)). Three ways to provide it, in the order they are tried:
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:
106
69
 
107
70
  1. `TYPESAFE_API_KEY` in the environment.
108
71
  2. `/jev-lens key` inside pi: prompts for the key (or `/jev-lens key ts_...`) and stores it in
109
72
  `~/.pi/agent/jev-lens.json`, readable only by you. jev is active from the next tool result, no restart.
110
73
  3. A `.env` file next to the installed package (development).
111
74
 
112
- Without a key the extension warns at startup and runs a mock classifier that compresses nothing.
113
- For development, clone the repo and load it directly:
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:
114
77
 
115
78
  ```sh
116
79
  git clone https://github.com/dizk/pi-jev-lens.git && cd pi-jev-lens && npm install
@@ -118,94 +81,130 @@ echo 'TYPESAFE_API_KEY=...' > .env
118
81
  pi -e ./index.ts
119
82
  ```
120
83
 
121
- Inside pi: `/jev-lens` shows stats (and where the key comes from), `/jev-lens key` stores the key, `/jev-lens list` lists the latest 200 pre-send-compressed tool results with tokens
122
- before and after, `/jev-lens diff [n]` opens an overlay for the n-th latest one showing the original output with the
123
- lines the model did not get marked `−` (press `t` to switch to exactly what was sent, `Esc` to close),
124
- `/jev-lens decisions` lists post-send decisions with probabilities, `/jev-lens file` prints the memory file.
125
- In the transcript, a compressed `read`/`bash`/`grep`/`find`/`ls` result shows a header line
126
- `⌁ jev-lens outline · 179 of 1524 tokens (−88 %)` and, expanded (ctrl+e), the text the model saw. The footer shows
127
- session totals, leading with the share of the session's input tokens jev kept out of the prompt:
128
- `jev-lens −38% of input (presend −12.3k · 5/8 · 1 recalls, pruned −4.0k · 3, 2 notes)`. The share is
129
- cut / (sent + cut), where sent is the provider's own input plus cache-read counts over all calls and cut is what every
130
- compressed or pruned result saved on every call it was part of, so a result compressed early counts on each later call. Set `JEV_LENS_UI=0` to keep pi's own tool rendering. Every call is logged to
131
- `<project>/.pi/jev-lens.log` (JSON lines).
84
+ ## How it works
85
+
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.
89
+
90
+ | view | for | keeps |
91
+ |---|---|---|
92
+ | `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 |
95
+ | `signals` | command output | errors, warnings, failing tests, summary lines, the tail |
96
+ | `testlog` | test output | failures, assertions, tracebacks and summaries |
97
+ | `matches` | search output | first matches per file, with omission counts |
98
+ | `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.
126
+
127
+ ## In pi
128
+
129
+ The footer shows the share of the session's input tokens jev kept out of the prompt, and what it did:
130
+
131
+ ```
132
+ jev-lens −38% of input (presend −12.3k · 5/8 · 1 recalls)
133
+ ```
134
+
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.
137
+
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:
140
+
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
146
+
147
+ Every decision is logged to `<project>/.pi/jev-lens.log` (JSON lines). `JEV_LENS_UI=0` keeps pi's own tool rendering.
132
148
 
133
149
  ### Configuration (environment)
134
150
 
135
151
  | variable | default | meaning |
136
152
  |---|---|---|
137
- | `JEV_LENS_MODE` | `budget` | `rolling`, `batch` or `budget` (see above) |
138
- | `JEV_LENS_BUDGET_FRACTION` / `_BUDGET_MIN_TOKENS` | `0.5` / `1000` | budget mode: apply when pending prunes remove at least this share of the tail they rewrite, and at least this many tokens |
139
- | `JEV_LENS_FORGET_BELOW` | `0.25` | P(needed) below this → forget |
140
- | `JEV_LENS_TRIM_BELOW` / `_TRIM_ABOVE` | `0.5` / `0.6` | P(needed) below the first and P(outcome only) above the second → trim |
141
- | `JEV_LENS_DURABLE_ABOVE` | `0.7` | text notes require P(durable) above this; tool pointers require P(durable) above `max(this, 0.85)` |
142
- | `JEV_LENS_MIN_TOKENS` | `150` | smaller tool results are never touched |
143
- | `JEV_LENS_CLASSIFY_WAIT_MS` | `2500` | maximum wait for in-flight classification at context, agent end and shutdown |
144
- | `JEV_LENS_CACHE_TTL_MS` | `300000` | idle longer than this counts as a cold cache |
145
- | `JEV_LENS_DISABLED` | unset | `1` skips pre-send compression and makes new post-send decisions `keep`; classification, logging and memory notes remain active. Previously applied decisions are still replayed. |
146
- | `JEV_LENS_PRESEND` | `1` | `0` turns pre-send compression off |
153
+ | `JEV_LENS_PRESEND` | `1` | `0` turns compression off |
147
154
  | `JEV_LENS_PRESEND_MIN_TOKENS` | `1200` | smaller results are always sent in full |
148
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 |
149
158
  | `JEV_LENS_PRESEND_EXPAND_ABOVE` | `0.5` | expand a code block's body when P(needed) exceeds this |
150
- | `JEV_LENS_PRESEND_COMMAND_NEEDS_FULL_ABOVE` | `0.65` | needs-full threshold for command output; the question is phrased for edits, and test runs rarely need exact full text (+3.3 points on the benchmark, no extra misses) |
151
- | `JEV_LENS_PRESEND_COMMAND_POLICY` | `sections` | when jev picks full for command output but needs-full is under the command threshold, send the section headers and let the second step expand the needed sections (full again if that reaches 90 %). `gate`: jev's view choice stands. |
152
- | `JEV_LENS_PRESEND_SECTION_EXPAND_ABOVE` | `0.5` | expand a section of command output when P(needed) exceeds this |
153
- | `JEV_LENS_PRESEND_SECTION_FLOOR` | `0.3` | send full when no section of command output reaches this probability (the expansion step could not tell, typical for docs read for orientation); `0` allows headers alone |
154
- | `JEV_LENS_PRESEND_CODE_POLICY` | `gate` | jev's needs-full and full-mass gates decide between full and a view; when a view is chosen, selected block bodies are expanded. `outline`: always send an outline plus expanded bodies (saves more, but 17 % of later edits missed their block on 500 real trajectories). |
155
- | `JEV_LENS_PRESEND_CODE_NEEDS_FULL_ABOVE` | `0.5` | code gate uses the minimum of this and the general needs-full threshold |
156
- | `JEV_LENS_PRESEND_MIN_CONFIDENCE` | `0` | send full below this choice confidence (0 disables the check); bypassed by outline-first code selection |
157
- | `JEV_LENS_TRIM_HEAD` / `_TRIM_TAIL` | `15` / `15` | lines retained at each end for post-send trimming |
158
- | `JEV_LENS_STATE_HEAD` / `_STATE_TAIL` | `2500` / `800` | maximum output characters in post-send classifier excerpts |
159
- | `JEV_LENS_MODEL` | `jev-latest` | classifier model |
160
- | `JEV_LENS_CLASSIFIER` | unset | `mock` forces deterministic classifiers without API calls |
161
- | `JEV_LENS_LOG` | `1` | `0` disables JSON-lines logging |
162
- | `JEV_LENS_UI` | `1` | `0` disables custom built-in tool rendering |
163
- | `JEV_LENS_VARIANT` | unset | JSON file with `config`, `prompts` and `views` overrides (also accepts autoresearch's `{ variant }` wrapper); config overrides take precedence over environment settings |
164
-
165
- `TYPESAFE_API_KEY` enables the real classifier. The extension loads `.env` from its own directory (and `src/`), not
166
- from the target project; existing nonempty environment values take precedence.
167
-
168
- ## How jev is used
169
-
170
- Post-send classification uses one request per eligible tool result, with three yes/no questions over the same state
171
- (`src/classifier.ts`): *needed*, *outcome only*, *durable*. The state holds the task (first and latest user message),
172
- the tool call and a head/tail excerpt of its output, and what the agent said and called next. User messages and assistant
173
- text between 40 and 6000 characters get a single *durable* question (disabled with the mock classifier). jev returns
174
- probabilities, not generated prose: stubs, trims and memory notes are assembled by code. Tool memory notes store only
175
- a call summary and success/failure marker; user and assistant notes are truncated excerpts. Notes are deduplicated,
176
- capped at 150 bullets / 8000 characters of bullet text, and loaded as a stable snapshot at session start.
177
-
178
- Pre-send selection and optional block expansion use separate requests, in addition to post-send classification.
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 |
169
+ | `JEV_LENS_MODE` | `off` | optional post-send pruning, see below |
179
170
 
180
171
  ## Evaluation
181
172
 
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:
178
+
179
+ | slice | large results | saved | edit-miss | quote-miss | ref-miss |
180
+ |---|---|---|---|---|---|
181
+ | 100 trajectories (rows 200-299) | 681 | 77.8 % | 0/7 | 0.6 % | 2.3 % |
182
+ | 500 trajectories (rows 300-799) | 3296 | 79.0 % | 2/26 | 0.3 % | 2.2 % |
183
+
182
184
  ```sh
183
- npm test # unit tests for the policy, ledger and memory file
184
- node --import tsx eval/replay.ts <session.jsonl|dir> # offline: classify a recorded session, simulate post-send pruning
185
- node --import tsx eval/presend-replay.ts <dir> # offline: pre-send views vs what the agent did next (edit/quote misses)
186
- node --import tsx eval/action-graph.ts # procedural graph mined from runs, jev as guidance model
187
- node --import tsx eval/bench/run.ts --from 200 --to 300 # pre-send benchmark on 100 real OpenHands trajectories (holdout)
188
- node --import tsx eval/bench/run.ts --from 300 --to 800 # the 500-trajectory slice (46 editable code results; use it for anything that touches code views)
189
- node --import tsx eval/bench/autoresearch.ts --iterations 8 # let a researcher model tune prompts/thresholds on the train slice
190
- node --import tsx eval/generate.ts --cond baseline # run the fixture tasks with pi headless
191
- node --import tsx eval/generate.ts --cond jev
192
- node --import tsx eval/report.ts # compare conditions
185
+ 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
188
+ 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
193
190
  ```
194
191
 
195
- `eval/fixture` is a small dependency-free JavaScript project with planted bugs; `eval/tasks/tasks.json` holds ten
196
- tasks (eight short, a five-part compound and an eight-part marathon), each scored by a hidden test.
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.
197
195
 
198
- Benchmark on 100 real OpenHands trajectories (685 large tool results, 2.26M tokens, `eval/bench/`): the default
199
- pre-send configuration sends 78.6 % fewer tokens for large results with 0 of 15 later edits missing their old text,
200
- 0.6 % quote-misses and 2.3 % ref-misses (an identifier the agent then used that only existed in the dropped part).
201
- Details, the metric definitions and the autoresearch loop are in `STATUS.md`.
196
+ ## Optional: post-send pruning
202
197
 
203
- Headline from the first night of runs (details and caveats in `STATUS.md`): decisions are sensible and the mechanism
204
- holds (frozen decisions, stable prefix), but under a 10× prompt-cache discount pruning after first send is a
205
- **context-budget** tool, not a cost tool. Rolling mode cut input tokens 19 % on long sessions and still cost 17 % more
206
- because each prune rewrites the cached prefix; budget mode keeps the cache (65 % hit vs 70 % baseline) and passed
207
- 12/13 tasks (baseline 13/13). The pre-send compression implemented here targets those costs by avoiding the initial
208
- send of unnecessary output.
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.
209
208
 
210
209
  ## Using this as a reference
211
210
 
@@ -213,26 +212,23 @@ The pieces are independent of pi and can be lifted into another agent:
213
212
 
214
213
  | piece | file | depends on |
215
214
  |---|---|---|
216
- | candidate views (outline, focus, signals, testlog, tree, matches, log, sample, head/tail) | `src/views.ts` | regex views need no external packages; async code views optionally load `src/treesitter.ts` |
215
+ | candidate views | `src/views.ts` | nothing; async code views optionally load `src/treesitter.ts` |
217
216
  | tree-sitter blocks and signatures | `src/treesitter.ts` | `web-tree-sitter`, `@vscode/tree-sitter-wasm`, `@binclusive/tree-sitter-kotlin-wasm` |
218
217
  | the jev questions, state shape, decision rule, block expansion | `src/presend.ts` | `@typesafe-ai/sdk` |
219
- | post-send decisions and the frozen, cache-aware ledger | `src/classifier.ts`, `src/policy.ts`, `src/ledger.ts` | `@typesafe-ai/sdk` |
220
- | the hook wiring for pi (tool_result, context, recall tool, UI) | `index.ts`, `src/ui.ts` | pi |
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 |
221
220
  | benchmark and metrics on real trajectories | `eval/presend-score.ts`, `eval/bench/` | run `eval/bench/fetch.sh` first |
221
+ | post-send decisions and the frozen ledger | `src/classifier.ts`, `src/policy.ts`, `src/ledger.ts` | `@typesafe-ai/sdk` |
222
222
 
223
- The order of operations that matters, in one paragraph: when a tool result arrives and is large, build views from the
224
- text (code, no model), ask jev which view suffices and whether exact text is needed, apply the configured selection
225
- policy, and optionally expand code blocks in a second request. If a reduced view wins, replace the content with that
226
- view plus a footer naming `recall`, and keep the full text in result details. Post-send classification then decides
227
- whether to keep, trim or stub eligible results. Persist and re-apply those decisions to avoid repeated changes to
228
- already-transformed messages. Never remove a tool result, only rewrite it.
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.
229
227
 
230
228
  ## Contributing and license
231
229
 
232
230
  Issues and pull requests are welcome at [github.com/dizk/pi-jev-lens](https://github.com/dizk/pi-jev-lens).
233
231
  `npm test` runs the unit tests with the mock classifier; `npm run typecheck` runs tsc. Changes to how views are built
234
- or chosen should come with benchmark numbers (see [Evaluation](#evaluation)); anything that touches code views must be
235
- scored on the 500-trajectory slice, not only the 100-trajectory holdout, because edit-misses are rare and expensive.
236
- STATUS.md is the research log: what was tried, what the numbers said, and why the defaults are what they are.
232
+ or chosen should come with benchmark numbers, on the 500-trajectory slice when they touch code.
237
233
 
238
234
  MIT, see LICENSE.
package/STATUS.md CHANGED
@@ -383,6 +383,10 @@ The excluded edit results also shrink the editable count from 46 to 26, which is
383
383
 
384
384
  **Lesson for the research loop.** Every conclusion about code views drawn from the 100-trajectory holdout was drawn from 13 to 15 editable results, and the one that mattered was wrong. Editable results are the scarce evidence; the large slice has 26 after excluding edit echoes, the reserve 800-1299 should have a similar number, and autoresearch should be scored on the large slice for any variant that touches code, even at 20 minutes per evaluation.
385
385
 
386
+ ## 0.2.0: pre-send only (2026-09-19)
387
+
388
+ Published as `pi-jev-lens` on npm (the name `pi-jev-context` belongs to an unrelated post-send pruning extension). With the release the extension was cut down to the one layer with evidence behind it: durable notes are removed (never measured, one jev call per user and assistant message, and a surprising thing for a compressor to do), and post-send pruning is off by default (`JEV_LENS_MODE=off`), kept in the code for long sessions where the context budget might matter. Everything above about post-send and notes stays as the record of why.
389
+
386
390
  ## What to try next
387
391
 
388
392
  1. **Pre-send judgment**: built, see above. Next: let the autoresearch researcher write view builders (one per content kind, sandboxed, verified as strict line subsets) instead of only prompt text and thresholds; three rounds of the latter transferred nothing, every code-built view did. And a structural rule for the second step: expand blocks referenced by an expanded block or named in the task.
package/index.ts CHANGED
@@ -1,17 +1,13 @@
1
1
  /**
2
- * pi-jev-lens: cache-aware memory routing for pi.
2
+ * pi-jev-lens: jev picks what the model gets to see of large tool results.
3
3
  *
4
- * Every tool result is classified once by jev (TypeSafe System One) after the agent has
5
- * seen it and acted on it. The decision (keep / trim / forget, plus durable yes/no) is
6
- * persisted and, once applied to an outgoing prompt, never changes again, so the prompt
7
- * prefix stays byte-identical across calls and the provider cache keeps hitting.
4
+ * Pre-send: before a large tool result is stored or sent, code builds candidate views (strict
5
+ * subsets of the output with line numbers), jev (TypeSafe System One) chooses one and, for code
6
+ * and sectioned command output, which blocks to put back. The full text stays in the result's
7
+ * details and the `recall` tool serves it on request.
8
8
  *
9
- * Buckets:
10
- * context (keep) – sent verbatim
11
- * trim – head + tail only
12
- * forget – replaced by a one-line stub (tool results are never removed:
13
- * every function_call needs a matching output)
14
- * file (durable) – appended to <project>/.pi/jev-lens.md, loaded at session start
9
+ * Post-send (off by default, JEV_LENS_MODE=rolling|batch|budget): tool results the agent has
10
+ * already acted on are classified once and trimmed or stubbed behind a frozen, cache-aware ledger.
15
11
  */
16
12
  import { appendFileSync, mkdirSync } from "node:fs";
17
13
  import { join } from "node:path";
@@ -28,10 +24,9 @@ import { buildPresendState, decideView, DEFAULT_PROMPTS, expandRelevantBlocks, J
28
24
  import { buildCandidatesAsync, extractTerms, footer } from "./src/views.ts";
29
25
  import { keyFilePath, loadConfigWithVariant, storeKey, type Config } from "./src/config.ts";
30
26
  import { ENTRY_TYPE, rebuildLedger } from "./src/ledger.ts";
31
- import { appendNotes, memoryPromptSection, readMemoryFile } from "./src/memory-file.ts";
32
27
  import { applyLedger, decideBucket, pendingPrunable, shouldApplyPending } from "./src/policy.ts";
33
28
  import { contentText, describeToolCall, estimateTokensOfText, toolCallsOf, truncate } from "./src/text.ts";
34
- import type { CallStats, Decision, DurableNote } from "./src/types.ts";
29
+ import type { CallStats, Decision } from "./src/types.ts";
35
30
 
36
31
  interface PendingResult {
37
32
  message: AgentMessage & { role: "toolResult" };
@@ -64,7 +59,6 @@ export default function (pi: ExtensionAPI) {
64
59
  let ledger = new Map<string, Decision>();
65
60
  /** Classifications launched but not yet resolved, keyed by toolCallId. */
66
61
  const inflight = new Map<string, Promise<void>>();
67
- const textInflight = new Set<Promise<void>>();
68
62
  let generation = 0;
69
63
  let sessionAbort = new AbortController();
70
64
  const workSignal = (signal?: AbortSignal) => signal ? AbortSignal.any([signal, sessionAbort.signal]) : sessionAbort.signal;
@@ -81,13 +75,10 @@ export default function (pi: ExtensionAPI) {
81
75
  const argsById = new Map<string, unknown>();
82
76
  let callIndex = 0;
83
77
  let lastCallAt = 0;
84
- let memorySnapshot = "";
85
- let memoryPath = "";
86
78
  let logPath = "";
87
- let totals = { pruned: 0, applied: 0, notes: 0, calls: 0, cacheRead: 0, input: 0 };
79
+ let totals = { pruned: 0, applied: 0, calls: 0, cacheRead: 0, input: 0 };
88
80
  /** Tokens kept out of the prompt, summed over every LLM call of the session (a compressed result saves on each later call too). */
89
81
  let cut = { presend: 0, pruned: 0 };
90
- let durableQueue: DurableNote[] = [];
91
82
  let firstUser = "";
92
83
  let latestUser = "";
93
84
 
@@ -108,7 +99,8 @@ export default function (pi: ExtensionAPI) {
108
99
  const tag = usingMock ? "jev-lens(mock)" : "jev-lens";
109
100
  const pct = cutShare();
110
101
  const lead = pct === undefined ? tag : `${tag} −${pct}% of input`;
111
- return `${lead} (presend −${(presendTotals.tokensSaved / 1000).toFixed(1)}k · ${presendTotals.compressed}/${presendTotals.considered} · ${presendTotals.recalls} recalls, pruned −${(totals.pruned / 1000).toFixed(1)}k · ${totals.applied}, ${totals.notes} notes)`;
102
+ 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})`;
112
104
  };
113
105
  const status = (ctx: ExtensionContext) => {
114
106
  if (!ctx.hasUI) return;
@@ -123,8 +115,6 @@ export default function (pi: ExtensionAPI) {
123
115
  generation++;
124
116
  sessionAbort.abort();
125
117
  sessionAbort = new AbortController();
126
- textInflight.clear();
127
- durableQueue = [];
128
118
  lastAssistantText = "";
129
119
  ledger = rebuildLedger(ctx.sessionManager.getEntries());
130
120
  buffer = [];
@@ -132,7 +122,7 @@ export default function (pi: ExtensionAPI) {
132
122
  argsById.clear();
133
123
  callIndex = 0;
134
124
  lastCallAt = 0;
135
- totals = { pruned: 0, applied: 0, notes: 0, calls: 0, cacheRead: 0, input: 0 };
125
+ totals = { pruned: 0, applied: 0, calls: 0, cacheRead: 0, input: 0 };
136
126
  cut = { presend: 0, pruned: 0 };
137
127
  firstUser = "";
138
128
  latestUser = "";
@@ -140,8 +130,6 @@ export default function (pi: ExtensionAPI) {
140
130
  records.length = 0;
141
131
  recordById.clear();
142
132
  presendTotals = { considered: 0, compressed: 0, tokensSaved: 0, recalls: 0 };
143
- memoryPath = join(ctx.cwd, CONFIG_DIR_NAME, "jev-lens.md");
144
- memorySnapshot = readMemoryFile(memoryPath);
145
133
  try {
146
134
  mkdirSync(join(ctx.cwd, CONFIG_DIR_NAME), { recursive: true });
147
135
  logPath = join(ctx.cwd, CONFIG_DIR_NAME, "jev-lens.log");
@@ -170,25 +158,17 @@ export default function (pi: ExtensionAPI) {
170
158
 
171
159
  pi.on("session_shutdown", async () => {
172
160
  const epoch = generation;
173
- await waitForWork([...inflight.values(), ...textInflight]);
161
+ await waitForWork([...inflight.values()]);
174
162
  if (epoch !== generation) return;
175
- flushDurable();
176
- if (inflight.size || textInflight.size) log({ event: "shutdown_timeout", pending: inflight.size + textInflight.size });
163
+ if (inflight.size) log({ event: "shutdown_timeout", pending: inflight.size });
177
164
  generation++;
178
165
  sessionAbort.abort();
179
166
  inflight.clear();
180
- textInflight.clear();
181
- durableQueue = [];
182
167
  });
183
168
 
184
- // ---- memory file → system prompt (snapshot taken at session start, stable within the session)
185
-
186
169
  pi.on("before_agent_start", async (event) => {
187
170
  if (!firstUser) firstUser = event.prompt;
188
171
  latestUser = event.prompt;
189
- const section = memoryPromptSection(memorySnapshot);
190
- if (!section) return;
191
- return { systemPrompt: event.systemPrompt + section };
192
172
  });
193
173
 
194
174
  // ---- classification ------------------------------------------------------------------
@@ -199,7 +179,6 @@ export default function (pi: ExtensionAPI) {
199
179
  const text = contentText(m.content);
200
180
  if (!firstUser) firstUser = text;
201
181
  latestUser = text;
202
- queueText("user", text, ctx);
203
182
  return;
204
183
  }
205
184
  if (m.role === "toolResult") {
@@ -214,7 +193,6 @@ export default function (pi: ExtensionAPI) {
214
193
  const toClassify = buffer;
215
194
  buffer = [];
216
195
  for (const item of toClassify) launchClassification(item, afterText, afterCalls, ctx);
217
- if (afterText.trim()) queueText("agent", afterText, ctx);
218
196
  });
219
197
 
220
198
  pi.on("tool_execution_end", async (event) => {
@@ -234,13 +212,13 @@ export default function (pi: ExtensionAPI) {
234
212
  const toClassify = buffer;
235
213
  buffer = [];
236
214
  for (const item of toClassify) launchClassification(item, "", [], undefined);
237
- await waitForWork([...inflight.values(), ...textInflight]);
238
- if (epoch === generation) flushDurable();
215
+ await waitForWork([...inflight.values()]);
216
+ void epoch;
239
217
  });
240
218
 
241
219
  function launchClassification(item: PendingResult, afterText: string, afterCalls: { name: string; arguments: unknown }[], ctx?: ExtensionContext) {
242
220
  const m = item.message;
243
- if (sessionAbort.signal.aborted || m.content.some((c) => c.type !== "text")) return;
221
+ if (cfg.mode === "off" || sessionAbort.signal.aborted || m.content.some((c) => c.type !== "text")) return;
244
222
  const epoch = generation;
245
223
  if (ledger.has(m.toolCallId) || inflight.has(m.toolCallId)) return;
246
224
  const output = contentText(m.content);
@@ -267,7 +245,6 @@ export default function (pi: ExtensionAPI) {
267
245
  id: m.toolCallId,
268
246
  toolName: m.toolName,
269
247
  bucket: cfg.enabled ? decideBucket(probs, cfg) : "keep",
270
- durable: probs.durable > cfg.durableAbove,
271
248
  p: probs,
272
249
  summary,
273
250
  tokensBefore: tokens,
@@ -277,11 +254,6 @@ export default function (pi: ExtensionAPI) {
277
254
  ledger.set(decision.id, decision);
278
255
  persist(decision);
279
256
  log({ event: "decision", id: decision.id, tool: m.toolName, bucket: decision.bucket, p: probs, tokens, ms: Date.now() - started, summary });
280
- // Tool output is rarely a durable fact by itself; only keep a pointer, and only when jev is very sure.
281
- if (probs.durable > Math.max(cfg.durableAbove, 0.85)) {
282
- durableQueue.push({ source: "tool", text: `${summary}${m.isError ? " failed" : " succeeded"}`, p: probs.durable, at: Date.now() });
283
- flushDurable();
284
- }
285
257
  })
286
258
  .catch((err) => {
287
259
  if (epoch === generation) log({ event: "classify_error", id: m.toolCallId, error: String(err?.message ?? err) });
@@ -290,37 +262,6 @@ export default function (pi: ExtensionAPI) {
290
262
  inflight.set(m.toolCallId, p);
291
263
  }
292
264
 
293
- function queueText(role: "user" | "agent", text: string, ctx?: ExtensionContext) {
294
- if (sessionAbort.signal.aborted || usingMock || text.length < 40 || text.length > 6000) return;
295
- const epoch = generation;
296
- const work = classifier
297
- .classifyText({ task: { first_user_request: truncate(firstUser, 600) }, message: truncate(text, 3000), role }, workSignal(ctx?.signal))
298
- .then((p) => {
299
- if (epoch !== generation) return;
300
- log({ event: "text", role, p, chars: text.length });
301
- if (p > cfg.durableAbove) {
302
- durableQueue.push({ source: role, text: truncate(text, 400), p, at: Date.now() });
303
- flushDurable();
304
- }
305
- })
306
- .catch((err) => { if (epoch === generation) log({ event: "classify_error", role, error: String(err?.message ?? err) }); })
307
- .finally(() => { if (epoch === generation) textInflight.delete(work); });
308
- textInflight.add(work);
309
- }
310
-
311
- function flushDurable() {
312
- if (durableQueue.length === 0 || !memoryPath) return;
313
- const notes = durableQueue;
314
- durableQueue = [];
315
- try {
316
- const added = appendNotes(memoryPath, notes);
317
- totals.notes += added;
318
- log({ event: "memory_file", added, path: memoryPath });
319
- } catch (err) {
320
- log({ event: "memory_file_error", error: String((err as Error)?.message ?? err) });
321
- }
322
- }
323
-
324
265
  // ---- the cache-aware cut: right before each LLM call --------------------------------
325
266
 
326
267
  pi.on("context", async (event, ctx) => {
@@ -392,7 +333,6 @@ export default function (pi: ExtensionAPI) {
392
333
  persist(d);
393
334
  }
394
335
  }
395
- flushDurable();
396
336
  });
397
337
 
398
338
  // ---- pre-send compression: pick a view of a large tool result before it is ever sent ----
@@ -535,7 +475,7 @@ export default function (pi: ExtensionAPI) {
535
475
  // ---- commands ----------------------------------------------------------------------
536
476
 
537
477
  pi.registerCommand("jev-lens", {
538
- description: "jev-lens: stats | list (compressed results) | diff [n] (original vs sent, overlay) | decisions | file | key [api-key] (store your TypeSafe key)",
478
+ description: "jev-lens: stats | list (compressed results) | diff [n] (original vs sent, overlay) | decisions | key [api-key] (store your TypeSafe key)",
539
479
  handler: async (args, ctx) => {
540
480
  const sub = (args ?? "").trim();
541
481
  if (sub === "key" || sub.startsWith("key ")) {
@@ -548,11 +488,6 @@ export default function (pi: ExtensionAPI) {
548
488
  status(ctx);
549
489
  return;
550
490
  }
551
- if (sub === "file") {
552
- const text = readMemoryFile(memoryPath) || "(memory file is empty)";
553
- ctx.ui.notify(text, "info");
554
- return;
555
- }
556
491
  if (sub.startsWith("diff")) {
557
492
  const n = Number(sub.slice(4).trim() || "1");
558
493
  const rec = records[records.length - (Number.isFinite(n) && n >= 1 ? n : 1)];
@@ -570,7 +505,7 @@ export default function (pi: ExtensionAPI) {
570
505
  return;
571
506
  }
572
507
  if (sub === "decisions") {
573
- 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=${d.p.durable.toFixed(2)} ${d.tokensBefore}t ${d.summary}`);
508
+ 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}`);
574
509
  ctx.ui.notify(rows.join("\n") || "(no decisions yet)", "info");
575
510
  return;
576
511
  }
@@ -582,7 +517,6 @@ export default function (pi: ExtensionAPI) {
582
517
  `post-send: calls=${totals.calls} decisions=${ledger.size} applied=${totals.applied} pruned≈${totals.pruned} tokens`,
583
518
  `cache: read=${totals.cacheRead} uncached=${totals.input} hit=${hit}%`,
584
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)`,
585
- `memory file: ${memoryPath} (+${totals.notes} notes this session)`,
586
520
  ].join("\n"),
587
521
  "info",
588
522
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-jev-lens",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",
package/src/classifier.ts CHANGED
@@ -28,7 +28,6 @@ export interface TextState {
28
28
 
29
29
  export interface Classifier {
30
30
  classifyToolResult(state: ItemState, signal?: AbortSignal): Promise<Probabilities>;
31
- classifyText(state: TextState, signal?: AbortSignal): Promise<number>;
32
31
  }
33
32
 
34
33
  export function buildItemState(
@@ -83,36 +82,6 @@ export const TOOL_RESULT_QUESTIONS = {
83
82
  false: "Source code, file contents, search results, directory listings, or any output where specific lines in the middle carry the information.",
84
83
  },
85
84
  },
86
- durable: {
87
- type: "noul" as const,
88
- instructions:
89
- "Does `item` reveal a stable fact about this project (its structure, conventions, how to build, test or run it, a known pitfall) or about the user's preferences, that would still be true and useful in a future, unrelated session?",
90
- criteria: {
91
- true: "Build or test commands that work, project layout, conventions, configuration quirks, recurring gotchas.",
92
- false: "Task-specific content, transient state, one-off command output, file contents that change with every edit.",
93
- },
94
- },
95
- };
96
-
97
- export const TEXT_QUESTIONS = {
98
- durable_user: {
99
- type: "noul" as const,
100
- instructions:
101
- "`message` was written by the user to a coding agent. Does it state a preference, standing instruction, or fact about the user or the project that should be remembered in future sessions, rather than a one-off task instruction?",
102
- criteria: {
103
- true: "Coding style preferences, tools or workflows the user wants used, facts about the project's purpose, constraints that will keep applying.",
104
- false: "A task for right now, a question, feedback about one specific change, small talk.",
105
- },
106
- },
107
- durable_agent: {
108
- type: "noul" as const,
109
- instructions:
110
- "`message` was written by a coding agent. Does it state a conclusion, decision, or discovered fact about the project that will remain true and be useful in future unrelated sessions?",
111
- criteria: {
112
- true: "How the project is structured, where things live, what command runs the tests, a root cause that explains recurring behaviour, a design decision that was made.",
113
- false: "Progress narration, a plan for the current task, a question to the user, a summary of edits just made.",
114
- },
115
- },
116
85
  };
117
86
 
118
87
  export class JevClassifier implements Classifier {
@@ -124,12 +93,7 @@ export class JevClassifier implements Classifier {
124
93
  }
125
94
  async classifyToolResult(state: ItemState, signal?: AbortSignal): Promise<Probabilities> {
126
95
  const r = await this.client.systemOne({ state: state as never, questions: TOOL_RESULT_QUESTIONS, model: this.model }, { signal, timeout: 15000 });
127
- return { needed: r.answers.needed.noul, outcomeOnly: r.answers.outcome_only.noul, durable: r.answers.durable.noul };
128
- }
129
- async classifyText(state: TextState, signal?: AbortSignal): Promise<number> {
130
- const q = state.role === "user" ? { durable: TEXT_QUESTIONS.durable_user } : { durable: TEXT_QUESTIONS.durable_agent };
131
- const r = await this.client.systemOne({ state: { task: state.task, message: state.message }, questions: q, model: this.model }, { signal, timeout: 15000 });
132
- return r.answers.durable.noul;
96
+ return { needed: r.answers.needed.noul, outcomeOnly: r.answers.outcome_only.noul };
133
97
  }
134
98
  }
135
99
 
@@ -139,13 +103,10 @@ export class MockClassifier implements Classifier {
139
103
  async classifyToolResult(state: ItemState): Promise<Probabilities> {
140
104
  return this.rule(state);
141
105
  }
142
- async classifyText(): Promise<number> {
143
- return 0;
144
- }
145
106
  }
146
107
 
147
108
  export function defaultMockRule(state: ItemState): Probabilities {
148
109
  const big = state.item.total_chars > 2000;
149
110
  const cmd = state.item.tool === "bash";
150
- return { needed: big ? 0.1 : 0.9, outcomeOnly: cmd ? 0.9 : 0.1, durable: 0 };
111
+ return { needed: big ? 0.1 : 0.9, outcomeOnly: cmd ? 0.9 : 0.1 };
151
112
  }
package/src/config.ts CHANGED
@@ -3,7 +3,7 @@ import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
6
- export type SealMode = "rolling" | "batch" | "budget";
6
+ export type SealMode = "off" | "rolling" | "batch" | "budget";
7
7
 
8
8
  export interface Config {
9
9
  /** Disable all pruning (classification still runs and logs). */
@@ -23,8 +23,6 @@ export interface Config {
23
23
  /** P(needed) below this and P(outcomeOnly) above trimAbove → trim to head+tail. */
24
24
  trimBelow: number;
25
25
  trimAbove: number;
26
- /** P(durable) above this → written to the memory file. */
27
- durableAbove: number;
28
26
  /** Tool results smaller than this (estimated tokens) are never touched. */
29
27
  minTokens: number;
30
28
  /** Maximum wait for in-flight classifications at context, agent end and shutdown. */
@@ -125,7 +123,7 @@ function num(name: string, fallback: number): number {
125
123
  export function loadConfig(): Config {
126
124
  loadDotEnv();
127
125
  const envMode = process.env.JEV_LENS_MODE;
128
- const mode: SealMode = envMode === "batch" || envMode === "budget" || envMode === "rolling" ? envMode : "budget";
126
+ const mode: SealMode = envMode === "batch" || envMode === "budget" || envMode === "rolling" ? envMode : "off";
129
127
  return {
130
128
  enabled: process.env.JEV_LENS_DISABLED !== "1",
131
129
  mode,
@@ -134,7 +132,6 @@ export function loadConfig(): Config {
134
132
  forgetBelow: num("JEV_LENS_FORGET_BELOW", 0.25),
135
133
  trimBelow: num("JEV_LENS_TRIM_BELOW", 0.5),
136
134
  trimAbove: num("JEV_LENS_TRIM_ABOVE", 0.6),
137
- durableAbove: num("JEV_LENS_DURABLE_ABOVE", 0.7),
138
135
  minTokens: num("JEV_LENS_MIN_TOKENS", 150),
139
136
  classifyWaitMs: num("JEV_LENS_CLASSIFY_WAIT_MS", 2500),
140
137
  cacheTtlMs: num("JEV_LENS_CACHE_TTL_MS", 5 * 60 * 1000),
package/src/types.ts CHANGED
@@ -3,7 +3,6 @@ export type Bucket = "keep" | "trim" | "forget";
3
3
  export interface Probabilities {
4
4
  needed: number;
5
5
  outcomeOnly: number;
6
- durable: number;
7
6
  }
8
7
 
9
8
  export interface Decision {
@@ -11,7 +10,6 @@ export interface Decision {
11
10
  id: string;
12
11
  toolName: string;
13
12
  bucket: Bucket;
14
- durable: boolean;
15
13
  p: Probabilities;
16
14
  /** One-line description used in the stub, fixed at decision time so the stub never changes. */
17
15
  summary: string;
@@ -24,13 +22,6 @@ export interface Decision {
24
22
  appliedReason?: string;
25
23
  }
26
24
 
27
- export interface DurableNote {
28
- source: "user" | "agent" | "tool";
29
- text: string;
30
- p: number;
31
- at: number;
32
- }
33
-
34
25
  export interface CallStats {
35
26
  call: number;
36
27
  at: number;
@@ -1,51 +0,0 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { dirname } from "node:path";
3
- import type { DurableNote } from "./types.ts";
4
-
5
- export const MEMORY_MAX_LINES = 150;
6
- export const MEMORY_MAX_CHARS = 8000;
7
-
8
- function hash(s: string): string {
9
- let h = 0;
10
- for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
11
- return (h >>> 0).toString(16);
12
- }
13
-
14
- export function readMemoryFile(path: string): string {
15
- return existsSync(path) ? readFileSync(path, "utf8") : "";
16
- }
17
-
18
- /** Append durable notes as bullet lines, dedup by normalized text, cap size keeping the newest. */
19
- export function appendNotes(path: string, notes: DurableNote[]): number {
20
- if (notes.length === 0) return 0;
21
- const existing = readMemoryFile(path);
22
- const lines = existing.split("\n").filter((l) => l.startsWith("- "));
23
- const seen = new Set(lines.map((l) => hash(l.replace(/^- \(\S+, \w+\) /, "").trim().toLowerCase())));
24
- let added = 0;
25
- for (const n of notes) {
26
- const text = n.text.replace(/\s+/g, " ").trim();
27
- if (!text) continue;
28
- const key = hash(text.toLowerCase());
29
- if (seen.has(key)) continue;
30
- seen.add(key);
31
- const date = new Date(n.at).toISOString().slice(0, 10);
32
- lines.push(`- (${date}, ${n.source}) ${text.length > 400 ? `${text.slice(0, 400)}…` : text}`);
33
- added++;
34
- }
35
- if (added === 0) return 0;
36
- let kept = lines.slice(-MEMORY_MAX_LINES);
37
- while (kept.join("\n").length > MEMORY_MAX_CHARS && kept.length > 1) kept = kept.slice(1);
38
- const header = "# jev-lens\n\nDurable notes selected by jev from earlier sessions. Newest last.\n\n";
39
- mkdirSync(dirname(path), { recursive: true });
40
- writeFileSync(path, `${header}${kept.join("\n")}\n`, "utf8");
41
- return added;
42
- }
43
-
44
- export function memoryPromptSection(snapshot: string): string {
45
- const body = snapshot
46
- .split("\n")
47
- .filter((l) => l.startsWith("- "))
48
- .join("\n");
49
- if (!body) return "";
50
- return `\n\n# Memory from earlier sessions (jev-lens)\nThese notes were kept from previous sessions in this project. Treat them as likely but verify before relying on details.\n${body}\n`;
51
- }