pi-jev-lens 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +203 -186
- package/STATUS.md +4 -0
- package/index.ts +19 -85
- package/package.json +1 -1
- package/src/classifier.ts +2 -41
- package/src/config.ts +2 -5
- package/src/types.ts +0 -9
- package/src/memory-file.ts +0 -51
package/README.md
CHANGED
|
@@ -1,116 +1,86 @@
|
|
|
1
1
|
# pi-jev-lens
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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 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.
|
|
6
5
|
|
|
7
|
-
|
|
8
|
-
|
|
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.
|
|
9
13
|
|
|
10
|
-
|
|
14
|
+
This is what the model sees instead of a file of 1.5k tokens:
|
|
11
15
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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.
|
|
16
|
+
```
|
|
17
|
+
1│ import { parse } from "./parse.js";
|
|
18
|
+
⋯ 14 lines omitted
|
|
19
|
+
16│ export function normalizeCategory(raw) {
|
|
20
|
+
17│ const key = raw.trim().toLowerCase();
|
|
21
|
+
18│ return ALIASES[key] ?? key;
|
|
22
|
+
19│ }
|
|
23
|
+
⋯ 61 lines omitted
|
|
24
|
+
81│ export function categoryReport(entries) {
|
|
25
|
+
⋯ 20 lines omitted
|
|
26
|
+
|
|
27
|
+
[jev-lens: showing the "relevant" view, 9 of 102 lines. Omitted lines are marked ⋯. Call recall(id: "…") for the
|
|
28
|
+
full output, or recall(id, lines: "a-b") / recall(id, pattern: "...") for a slice.]
|
|
29
|
+
```
|
|
56
30
|
|
|
57
|
-
##
|
|
31
|
+
## What the numbers say
|
|
58
32
|
|
|
59
|
-
|
|
60
|
-
|
|
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:
|
|
61
36
|
|
|
62
|
-
|
|
|
37
|
+
| result | source |
|
|
63
38
|
|---|---|
|
|
64
|
-
|
|
|
65
|
-
|
|
|
66
|
-
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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.
|
|
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`.
|
|
96
64
|
|
|
97
65
|
## Install
|
|
98
66
|
|
|
99
67
|
```sh
|
|
100
|
-
pi install npm:pi-jev-lens
|
|
101
|
-
pi install git:github.com/dizk/pi-jev-lens
|
|
68
|
+
pi install npm:pi-jev-lens # from npm
|
|
69
|
+
pi install git:github.com/dizk/pi-jev-lens # or from GitHub
|
|
102
70
|
```
|
|
103
71
|
|
|
104
|
-
|
|
105
|
-
|
|
72
|
+
jev needs a TypeSafe API key. You can get one at [console.typesafe.ai](https://console.typesafe.ai). jev-lens looks
|
|
73
|
+
for the key in this order:
|
|
106
74
|
|
|
107
75
|
1. `TYPESAFE_API_KEY` in the environment.
|
|
108
|
-
2. `/jev-lens key` inside pi
|
|
109
|
-
`~/.pi/agent/jev-lens.json`, readable only by you. jev is active from
|
|
110
|
-
|
|
76
|
+
2. The key that you stored with `/jev-lens key` inside pi. The command prompts for the key, or you can give it as
|
|
77
|
+
`/jev-lens key ts_...`. The key is stored in `~/.pi/agent/jev-lens.json`, readable only by you. jev is active from
|
|
78
|
+
the next tool result. You do not have to restart pi.
|
|
79
|
+
3. A `.env` file next to the installed package. This is for development.
|
|
111
80
|
|
|
112
|
-
|
|
113
|
-
|
|
81
|
+
If no key is found, the extension shows a warning at startup and runs a mock classifier that compresses nothing.
|
|
82
|
+
|
|
83
|
+
For development, clone the repository and load it directly:
|
|
114
84
|
|
|
115
85
|
```sh
|
|
116
86
|
git clone https://github.com/dizk/pi-jev-lens.git && cd pi-jev-lens && npm install
|
|
@@ -118,121 +88,168 @@ echo 'TYPESAFE_API_KEY=...' > .env
|
|
|
118
88
|
pi -e ./index.ts
|
|
119
89
|
```
|
|
120
90
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
91
|
+
## How it works
|
|
92
|
+
|
|
93
|
+
Every text tool result of at least 1200 estimated tokens (about 5 kB) goes through pi's `tool_result` hook before
|
|
94
|
+
pi stores it or sends it. jev-lens never touches smaller results. Code builds the candidate views. Each view is a
|
|
95
|
+
subset of the lines of the output, with line numbers and markers for the omitted lines. No text is generated.
|
|
96
|
+
|
|
97
|
+
| view | for | keeps |
|
|
98
|
+
|---|---|---|
|
|
99
|
+
| `outline` | code, prose | imports, exports, signatures, headings, doc comments |
|
|
100
|
+
| `relevant` | code, command output | the outline or the section headers, plus the full bodies that jev says the agent will need (second jev step) |
|
|
101
|
+
| `sections` | command output | the first line of every section: grep match groups, JSON keys, headings, markers like `COMMAND:`, paragraphs |
|
|
102
|
+
| `signals` | command output | errors, warnings, failing tests, summary lines, the tail |
|
|
103
|
+
| `testlog` | test output | failures, assertions, tracebacks and summaries |
|
|
104
|
+
| `matches` | search output | the first matches per file, with a count of the omitted ones |
|
|
105
|
+
| `log` | repetitive output | representative repeated lines, errors and the tail |
|
|
106
|
+
| `tree` | directory listings | a sample of entries per directory, with a count of the omitted ones |
|
|
107
|
+
| `focus` | anything | the lines that mention identifiers from the task and the tool call, with context |
|
|
108
|
+
| `sample` | tabular or log-like data | the header, a dozen rows, the count |
|
|
109
|
+
| `head_tail` | anything | the first and the last lines |
|
|
110
|
+
|
|
111
|
+
jev then answers two questions. It sees the task, the text that the assistant wrote before the call, and a preview of
|
|
112
|
+
each view. The questions are: which view is the smallest one that is still enough (a choice), and will the next step
|
|
113
|
+
need the exact full text (yes or no). If jev chose an outline or a `sections` view, a second request asks, for each
|
|
114
|
+
block or section, whether the agent will need its body. Those bodies go back into the view. If the result reaches
|
|
115
|
+
90 % of the original size, jev-lens sends the full text instead.
|
|
116
|
+
|
|
117
|
+
These rules make it safe for the agent to edit from a view:
|
|
118
|
+
|
|
119
|
+
- Views of code and prose keep every retained line exactly as it is. An edit whose old text was copied from the view
|
|
120
|
+
still matches the file. Views of command output shorten decorative bars and very long lines.
|
|
121
|
+
- Files that the agent reads through bash (`cat a.py b.py`, `sed -n '1,80p' x.ts`, `head`, brace groups, globs) count
|
|
122
|
+
as code or prose and get the same views as the `read` tool. If the command also does something else, the output
|
|
123
|
+
stays command output.
|
|
124
|
+
- jev-lens never reduces the results of the agent's own `edit` and `write` tools.
|
|
125
|
+
- jev-lens sends code in full unless jev is confident that a view is enough. This is the `gate` policy. The
|
|
126
|
+
`outline` policy always sends an outline plus expanded bodies. It saves more, but it missed 17 % of later edits on
|
|
127
|
+
real trajectories, so you must turn it on yourself.
|
|
128
|
+
|
|
129
|
+
The code structure comes from tree-sitter (grammars from `@vscode/tree-sitter-wasm` and
|
|
130
|
+
`@binclusive/tree-sitter-kotlin-wasm`): TypeScript, TSX, JavaScript, Kotlin, Java, Rust, Python, Go, C, C++, C#,
|
|
131
|
+
Ruby, PHP, Bash, CSS. Large classes are split into their members. For other languages, jev-lens uses regular
|
|
132
|
+
expressions that know the common declaration keywords.
|
|
133
|
+
|
|
134
|
+
When jev-lens compresses a result, it keeps the full output in the result's `details`. pi persists that in the
|
|
135
|
+
session but never sends it to the model. The footer names the `recall` tool, which serves the full output back by
|
|
136
|
+
id, by line range or by pattern. jev-lens logs every recall as a signal that a view was too small.
|
|
137
|
+
|
|
138
|
+
## In pi
|
|
139
|
+
|
|
140
|
+
The footer shows the share of the session's input tokens that jev kept out of the prompt, and what it did:
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
jev-lens −38% of input (presend −12.3k · 5/8 · 1 recalls)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The share is cut divided by sent plus cut. Sent is the provider's own count of input and cache-read tokens over all
|
|
147
|
+
calls. Cut is what every compressed result saved on every call that it was part of.
|
|
148
|
+
|
|
149
|
+
In the transcript, a compressed result shows a header like `⌁ jev-lens outline · 179 of 1524 tokens (−88 %)`. When
|
|
150
|
+
you expand it with ctrl+e, you see exactly what the model saw. These commands are available:
|
|
151
|
+
|
|
152
|
+
- `/jev-lens` shows the statistics and where the key comes from.
|
|
153
|
+
- `/jev-lens list` lists the latest 200 compressed results with the tokens before and after.
|
|
154
|
+
- `/jev-lens diff [n]` opens an overlay for the n-th latest result. It shows the original with the lines that the
|
|
155
|
+
model did not get marked with `−`. Press `t` to see what was sent, and `Esc` to close.
|
|
156
|
+
- `/jev-lens key` stores the API key.
|
|
157
|
+
|
|
158
|
+
jev-lens logs every decision to `<project>/.pi/jev-lens.log` as JSON lines. Set `JEV_LENS_UI=0` to keep pi's own
|
|
159
|
+
tool rendering.
|
|
132
160
|
|
|
133
161
|
### Configuration (environment)
|
|
134
162
|
|
|
135
163
|
| variable | default | meaning |
|
|
136
164
|
|---|---|---|
|
|
137
|
-
| `
|
|
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 |
|
|
165
|
+
| `JEV_LENS_PRESEND` | `1` | `0` turns compression off |
|
|
147
166
|
| `JEV_LENS_PRESEND_MIN_TOKENS` | `1200` | smaller results are always sent in full |
|
|
148
|
-
| `JEV_LENS_PRESEND_NEEDS_FULL_ABOVE` / `_FULL_MASS_ABOVE` | `0.5` / `0.5` | send full when P(needs full) or P(full view)
|
|
149
|
-
| `
|
|
150
|
-
| `
|
|
151
|
-
| `
|
|
152
|
-
| `
|
|
153
|
-
| `
|
|
154
|
-
| `
|
|
155
|
-
| `
|
|
156
|
-
| `JEV_LENS_PRESEND_MIN_CONFIDENCE` | `0` | send full below this choice confidence
|
|
157
|
-
| `
|
|
158
|
-
| `
|
|
159
|
-
| `
|
|
160
|
-
| `
|
|
161
|
-
| `
|
|
162
|
-
| `
|
|
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.
|
|
167
|
+
| `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 |
|
|
168
|
+
| `JEV_LENS_PRESEND_CODE_POLICY` | `gate` | `outline`: always send an outline plus expanded bodies (more savings, more missed edits) |
|
|
169
|
+
| `JEV_LENS_PRESEND_CODE_NEEDS_FULL_ABOVE` | `0.5` | code uses the lower of this and the general needs-full threshold |
|
|
170
|
+
| `JEV_LENS_PRESEND_EXPAND_ABOVE` | `0.5` | expand the body of a code block when P(needed) is above this |
|
|
171
|
+
| `JEV_LENS_PRESEND_COMMAND_NEEDS_FULL_ABOVE` | `0.65` | the needs-full threshold for command output |
|
|
172
|
+
| `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 |
|
|
173
|
+
| `JEV_LENS_PRESEND_SECTION_EXPAND_ABOVE` | `0.5` | expand a section when P(needed) is above this |
|
|
174
|
+
| `JEV_LENS_PRESEND_SECTION_FLOOR` | `0.3` | send full when no section reaches this probability, because jev could not tell. `0` allows headers alone |
|
|
175
|
+
| `JEV_LENS_PRESEND_MIN_CONFIDENCE` | `0` | send full below this choice confidence. `0` turns the check off |
|
|
176
|
+
| `JEV_LENS_MODEL` | `jev-latest` | the jev model |
|
|
177
|
+
| `JEV_LENS_CLASSIFIER` | unset | `mock` forces the deterministic classifier, with no API calls |
|
|
178
|
+
| `JEV_LENS_LOG` | `1` | `0` turns logging off |
|
|
179
|
+
| `JEV_LENS_UI` | `1` | `0` turns the custom tool rendering off |
|
|
180
|
+
| `JEV_LENS_VARIANT` | unset | a JSON file with `config`, `prompts` and `views` overrides, as the autoresearch loop writes it |
|
|
181
|
+
| `JEV_LENS_MODE` | `off` | optional post-send pruning, see below |
|
|
179
182
|
|
|
180
183
|
## Evaluation
|
|
181
184
|
|
|
185
|
+
Every change is scored against what the agent did next in a recorded trajectory. That is the only honest judge of
|
|
186
|
+
whether the agent needed the text. The benchmark uses real OpenHands trajectories in `eval/bench/`. The script
|
|
187
|
+
`eval/bench/fetch.sh` downloads the data. The metrics are:
|
|
188
|
+
|
|
189
|
+
- edit-miss: the agent later edited a line that the view had dropped.
|
|
190
|
+
- quote-miss: the agent quoted text that the view had dropped.
|
|
191
|
+
- ref-miss: the agent used an identifier that only existed in the dropped part.
|
|
192
|
+
|
|
193
|
+
An edit-miss counts five times in the objective, and edit-misses are rare. So you must score every change that
|
|
194
|
+
touches code views on the 500-trajectory slice, not only on the 100-trajectory holdout.
|
|
195
|
+
|
|
196
|
+
| slice | large results | saved | edit-miss | quote-miss | ref-miss |
|
|
197
|
+
|---|---|---|---|---|---|
|
|
198
|
+
| 100 trajectories (rows 200-299) | 681 | 77.8 % | 0/7 | 0.6 % | 2.3 % |
|
|
199
|
+
| 500 trajectories (rows 300-799) | 3296 | 79.0 % | 2/26 | 0.3 % | 2.2 % |
|
|
200
|
+
|
|
182
201
|
```sh
|
|
183
|
-
npm test
|
|
184
|
-
node --import tsx eval/
|
|
185
|
-
node --import tsx eval/
|
|
186
|
-
node --import tsx eval/
|
|
187
|
-
node --import tsx eval/bench/
|
|
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
|
|
202
|
+
npm test # unit tests, mock classifier
|
|
203
|
+
node --import tsx eval/bench/run.ts --from 200 --to 300 # the holdout, about 4 minutes
|
|
204
|
+
node --import tsx eval/bench/run.ts --from 300 --to 800 # the 500-trajectory slice, about 20 minutes
|
|
205
|
+
node --import tsx eval/presend-replay.ts <session dir> # replay your own pi sessions from ~/.pi/agent/sessions
|
|
206
|
+
node --import tsx eval/bench/autoresearch.ts --iterations 8 # a researcher model tunes the prompts and thresholds
|
|
193
207
|
```
|
|
194
208
|
|
|
195
|
-
|
|
196
|
-
|
|
209
|
+
STATUS.md is the research log. It lists every variant that we tried, its numbers, and why the defaults are what they
|
|
210
|
+
are. In short: new code-built views moved the numbers, prompt wording did not, and the small holdout was wrong about
|
|
211
|
+
code until the slice was five times larger.
|
|
197
212
|
|
|
198
|
-
|
|
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`.
|
|
213
|
+
## Optional: post-send pruning
|
|
202
214
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
215
|
+
`JEV_LENS_MODE=budget` (or `rolling`, or `batch`) turns on a second layer. After the agent reacted to a tool result,
|
|
216
|
+
jev judges whether the result is still needed. In later prompts, a result that is not needed is cut to its head and
|
|
217
|
+
tail, or replaced by a stub of one line. jev-lens persists each decision and freezes it after the first use, so the
|
|
218
|
+
cached prefix is rewritten as rarely as possible. The `budget` mode only rewrites when the pending cuts remove at least
|
|
219
|
+
half of the tail that they would touch. On real sessions, this layer frees context but does not save money under
|
|
220
|
+
prompt-cache pricing. That is why it is off by default.
|
|
221
|
+
|
|
222
|
+
Its settings are `JEV_LENS_BUDGET_FRACTION` and `JEV_LENS_BUDGET_MIN_TOKENS` (`0.5` and `1000`), `JEV_LENS_FORGET_BELOW`
|
|
223
|
+
(`0.25`), `JEV_LENS_TRIM_BELOW` and `JEV_LENS_TRIM_ABOVE` (`0.5` and `0.6`), `JEV_LENS_MIN_TOKENS` (`150`),
|
|
224
|
+
`JEV_LENS_TRIM_HEAD` and `JEV_LENS_TRIM_TAIL` (`15` and `15`), `JEV_LENS_CLASSIFY_WAIT_MS` (`2500`),
|
|
225
|
+
`JEV_LENS_CACHE_TTL_MS` (`300000`), `JEV_LENS_STATE_HEAD` and `JEV_LENS_STATE_TAIL` (`2500` and `800`), and
|
|
226
|
+
`JEV_LENS_DISABLED=1` (new decisions become `keep`). The command `/jev-lens decisions` lists the decisions. jev-lens
|
|
227
|
+
never removes a tool result. It only rewrites it.
|
|
209
228
|
|
|
210
229
|
## Using this as a reference
|
|
211
230
|
|
|
212
|
-
The pieces
|
|
231
|
+
The pieces do not depend on pi. You can lift them into another agent:
|
|
213
232
|
|
|
214
233
|
| piece | file | depends on |
|
|
215
234
|
|---|---|---|
|
|
216
|
-
| candidate views
|
|
235
|
+
| candidate views | `src/views.ts` | nothing. The async code views can load `src/treesitter.ts` |
|
|
217
236
|
| tree-sitter blocks and signatures | `src/treesitter.ts` | `web-tree-sitter`, `@vscode/tree-sitter-wasm`, `@binclusive/tree-sitter-kotlin-wasm` |
|
|
218
|
-
| the jev questions, state shape, decision rule, block expansion | `src/presend.ts` | `@typesafe-ai/sdk` |
|
|
219
|
-
|
|
|
220
|
-
| the hook wiring for pi (tool_result,
|
|
221
|
-
| benchmark and metrics on real trajectories | `eval/presend-score.ts`, `eval/bench/` | run `eval/bench/fetch.sh` first |
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
already-transformed messages. Never remove a tool result, only rewrite it.
|
|
237
|
+
| the jev questions, the state shape, the decision rule, block expansion | `src/presend.ts` | `@typesafe-ai/sdk` |
|
|
238
|
+
| the parser for bash display commands | `src/shell-display.ts` | nothing |
|
|
239
|
+
| the hook wiring for pi (tool_result, the recall tool, the UI) | `index.ts`, `src/ui.ts` | pi |
|
|
240
|
+
| the benchmark and the metrics on real trajectories | `eval/presend-score.ts`, `eval/bench/` | run `eval/bench/fetch.sh` first |
|
|
241
|
+
| post-send decisions and the frozen ledger | `src/classifier.ts`, `src/policy.ts`, `src/ledger.ts` | `@typesafe-ai/sdk` |
|
|
242
|
+
|
|
243
|
+
The sequence is this. When a large tool result arrives, code builds the views from the text. jev says which view is
|
|
244
|
+
enough and whether the exact text is needed. Code applies the selection policy. If needed, a second request expands
|
|
245
|
+
blocks or sections. If a reduced view wins, the content becomes that view plus a footer that names `recall`, and the
|
|
246
|
+
full text stays in the details of the result.
|
|
229
247
|
|
|
230
248
|
## Contributing and license
|
|
231
249
|
|
|
232
250
|
Issues and pull requests are welcome at [github.com/dizk/pi-jev-lens](https://github.com/dizk/pi-jev-lens).
|
|
233
|
-
`npm test` runs the unit tests with the mock classifier
|
|
234
|
-
or chosen
|
|
235
|
-
|
|
236
|
-
STATUS.md is the research log: what was tried, what the numbers said, and why the defaults are what they are.
|
|
251
|
+
`npm test` runs the unit tests with the mock classifier. `npm run typecheck` runs tsc. A change to how views are
|
|
252
|
+
built or chosen must come with benchmark numbers. If the change touches code views, you must use the 500-trajectory
|
|
253
|
+
slice.
|
|
237
254
|
|
|
238
255
|
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:
|
|
2
|
+
* pi-jev-lens: jev picks what the model gets to see of large tool results.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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()
|
|
161
|
+
await waitForWork([...inflight.values()]);
|
|
174
162
|
if (epoch !== generation) return;
|
|
175
|
-
|
|
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()
|
|
238
|
-
|
|
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 |
|
|
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)}
|
|
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
|
|
3
|
+
"version": "0.2.1",
|
|
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
|
|
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
|
|
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 : "
|
|
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;
|
package/src/memory-file.ts
DELETED
|
@@ -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
|
-
}
|