tokentrimmer 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 +53 -32
- package/package.json +1 -1
- package/src/formats.js +35 -14
- package/src/messages.js +93 -32
- package/src/verify.js +10 -8
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# TokenTrimmer
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/tokentrimmer) [](LICENSE) 
|
|
4
|
+
|
|
3
5
|

|
|
4
6
|
|
|
5
7
|
**Reduce Claude Code token usage and LLM API cost, without losing information.** TokenTrimmer compresses tool
|
|
@@ -35,33 +37,41 @@ for over and over. TokenTrimmer rewrites tool results into a shorter form as eac
|
|
|
35
37
|
- **You can see what it does.** A local dashboard shows tokens before and after for every request, and dry-run
|
|
36
38
|
mode measures savings without changing anything.
|
|
37
39
|
|
|
38
|
-
What to expect: on real Claude Code sessions, safe mode saved about
|
|
39
|
-
accuracy, and the lossy modes saved
|
|
40
|
+
What to expect: on real Claude Code sessions, safe mode saved about 7% of input tokens (5% of the bill with prompt
|
|
41
|
+
caching) with no loss in answer accuracy, and the lossy modes saved 14–30% at a measurable accuracy cost (see
|
|
42
|
+
[Real sessions](#real-sessions)).
|
|
40
43
|
Savings depend heavily on how much repetitive tool output your sessions have; run dry-run mode first to see
|
|
41
44
|
your own number.
|
|
42
45
|
|
|
43
46
|
## Run
|
|
44
47
|
|
|
45
48
|
```
|
|
46
|
-
|
|
49
|
+
npx tokentrimmer
|
|
47
50
|
# Anthropic SDK: ANTHROPIC_BASE_URL=http://127.0.0.1:8787
|
|
48
51
|
# OpenAI SDK: OPENAI_BASE_URL=http://127.0.0.1:8787/v1
|
|
49
52
|
# Dashboard: http://127.0.0.1:8787/
|
|
50
53
|
```
|
|
51
54
|
|
|
52
|
-
Start with `DRY_RUN=1
|
|
55
|
+
Start with `DRY_RUN=1 npx tokentrimmer` first. Requests are measured and the dashboard
|
|
53
56
|
shows what you would save, but the original request is forwarded unchanged.
|
|
54
57
|
|
|
55
58
|
## Use with Claude
|
|
56
59
|
|
|
57
|
-
|
|
60
|
+
Run it straight from npm (Node 18+, no dependencies):
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
npx tokentrimmer # safe (lossless) mode; leave this running
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Or install it once, so it starts instantly and works offline:
|
|
58
67
|
|
|
59
68
|
```
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
npm start # safe (lossless) mode; leave this running
|
|
69
|
+
npm install -g tokentrimmer
|
|
70
|
+
tokentrimmer
|
|
63
71
|
```
|
|
64
72
|
|
|
73
|
+
From source: `git clone https://github.com/an21an21/tokentrimmer.git && cd tokentrimmer && npm start`.
|
|
74
|
+
|
|
65
75
|
### Claude Code in the terminal
|
|
66
76
|
|
|
67
77
|
Works with both an API key and a Claude subscription login; the proxy forwards your auth headers unchanged.
|
|
@@ -69,8 +79,7 @@ Works with both an API key and a Claude subscription login; the proxy forwards y
|
|
|
69
79
|
1. Terminal 1, start the proxy and leave it running:
|
|
70
80
|
|
|
71
81
|
```
|
|
72
|
-
|
|
73
|
-
npm start
|
|
82
|
+
npx tokentrimmer
|
|
74
83
|
```
|
|
75
84
|
|
|
76
85
|
2. Terminal 2, start Claude Code through it (same for `claude -p` and `claude --resume`):
|
|
@@ -90,13 +99,13 @@ leaves your machine):
|
|
|
90
99
|
ANTHROPIC_BASE_URL=http://127.0.0.1:8787 ANTHROPIC_CUSTOM_HEADERS="x-tokentrimmer-mode: balanced" claude
|
|
91
100
|
```
|
|
92
101
|
|
|
93
|
-
A shell shortcut that starts the proxy in the background when needed
|
|
94
|
-
|
|
102
|
+
A shell shortcut that starts the proxy in the background when needed. Install it once with
|
|
103
|
+
`npm install -g tokentrimmer`, then add this to `~/.zshrc` or `~/.bashrc`:
|
|
95
104
|
|
|
96
105
|
```sh
|
|
97
106
|
claude-trim() {
|
|
98
107
|
curl -sf http://127.0.0.1:8787/health >/dev/null ||
|
|
99
|
-
(
|
|
108
|
+
(nohup tokentrimmer >/tmp/tokentrimmer.log 2>&1 &)
|
|
100
109
|
until curl -sf http://127.0.0.1:8787/health >/dev/null; do sleep 0.2; done
|
|
101
110
|
ANTHROPIC_BASE_URL=http://127.0.0.1:8787 claude "$@"
|
|
102
111
|
}
|
|
@@ -123,7 +132,7 @@ client = anthropic.Anthropic(base_url="http://127.0.0.1:8787")
|
|
|
123
132
|
const client = new Anthropic({ baseURL: 'http://127.0.0.1:8787' });
|
|
124
133
|
```
|
|
125
134
|
|
|
126
|
-
Which mode: the default, `safe`, is lossless. `balanced` and `aggressive` (`TOKENTRIMMER_MODE=balanced
|
|
135
|
+
Which mode: the default, `safe`, is lossless. `balanced` and `aggressive` (`TOKENTRIMMER_MODE=balanced npx tokentrimmer`)
|
|
127
136
|
drop content; see the accuracy results below before turning them on for coding work. `TOKENTRIMMER_MODE=balanced
|
|
128
137
|
TOKENTRIMMER_STALE_AFTER=10 TOKENTRIMMER_RETRIEVE=1` (stale results need a lossy mode) saves the most on long sessions and lets Claude fetch shortened results back; it
|
|
129
138
|
is experimental.
|
|
@@ -139,8 +148,8 @@ is experimental.
|
|
|
139
148
|
| Log lines sharing a template: templates listed once, each line written as `~N|var|var`, original order kept | safe | yes |
|
|
140
149
|
| Tables nested inside a larger JSON object keep a JSON form (`{"_table":{cols,rows}}`) | safe | yes |
|
|
141
150
|
| grep-style `path/file:line:text` output: each directory written once as an `@ dir/` header | safe | yes |
|
|
142
|
-
| Identical tool results sent again later become a
|
|
143
|
-
| A run of
|
|
151
|
+
| Identical tool results sent again later become `[same as a tool result in message M]` | safe | yes |
|
|
152
|
+
| A line or run of lines the model already has verbatim becomes a short reference: `[same as message M lines A-B]` (an earlier tool result, as sent) or `[same as lines A-B above]` (earlier in the same result). Only used when the run is at least 60 characters and twice as long as the reference | safe | yes |
|
|
144
153
|
| Drop null / empty JSON fields and all-empty table columns | balanced | no |
|
|
145
154
|
| Collapse runs of similar log lines (first and last kept, count shown) | balanced | no |
|
|
146
155
|
| Cap huge outputs: head and tail kept, middle omitted and labelled | aggressive | no |
|
|
@@ -252,18 +261,20 @@ message list. Reproduce with `compare/export.js` then `compare/run_headroom.py`.
|
|
|
252
261
|
|---|---|---|---|
|
|
253
262
|
| JSON API response, 150 records | 74% | 78% | 72% |
|
|
254
263
|
| Mixed-shape JSON events | 73% | 74% | 73% |
|
|
255
|
-
| grep results, 500 hits |
|
|
264
|
+
| grep results, 500 hits | 21% | 21% | 20% |
|
|
256
265
|
| Server log, retry storm | 61% | 98% | 10% |
|
|
257
266
|
| JavaScript source file | 6% | 6% | 0% |
|
|
258
|
-
| Python source with comments |
|
|
259
|
-
| Verbose prose |
|
|
267
|
+
| Python source with comments | 66% | 66% | 0% |
|
|
268
|
+
| Verbose prose | 64% | 64% | 0% |
|
|
260
269
|
| Agent conversation, 5 tool results | 59% | 65% | 17% |
|
|
261
|
-
| Total |
|
|
270
|
+
| Total | 51% | 57% | 20% |
|
|
262
271
|
|
|
263
272
|
How to read this:
|
|
264
273
|
- Safe mode is the like-for-like column: Headroom's output on JSON, logs and grep kept every
|
|
265
274
|
row. Safe mode is verified lossless by decoder round-trip tests (`src/formats.js`).
|
|
266
275
|
- Balanced and aggressive win by omitting content and are not comparable.
|
|
276
|
+
- The Python and prose payloads are one template repeated 60 times, which line references collapse. Real files
|
|
277
|
+
rarely repeat like that, so those two rows overstate what to expect.
|
|
267
278
|
- The counter is characters divided by four. Real tokenizers may count digits, punctuation and
|
|
268
279
|
CSV-style rows differently, so real savings can be lower or higher. Use dry run: the
|
|
269
280
|
dashboard's "billed input" column is the real figure.
|
|
@@ -298,18 +309,23 @@ tokens counted with tiktoken o200k_base, Headroom 0.42.0 in library mode. Saving
|
|
|
298
309
|
|
|
299
310
|
| | Final request | Cache-weighted cost, whole session |
|
|
300
311
|
|---|---|---|
|
|
301
|
-
| TokenTrimmer safe |
|
|
302
|
-
| TokenTrimmer aggressive |
|
|
303
|
-
| TokenTrimmer balanced + `TOKENTRIMMER_STALE_AFTER=10` |
|
|
304
|
-
| TokenTrimmer balanced + stale 10 + `TOKENTRIMMER_RETRIEVE=1` |
|
|
305
|
-
| Headroom library mode
|
|
312
|
+
| TokenTrimmer safe | 6.9% | 5.3% |
|
|
313
|
+
| TokenTrimmer aggressive | 13.9% | 11.6% |
|
|
314
|
+
| TokenTrimmer balanced + `TOKENTRIMMER_STALE_AFTER=10` | 29.4% | 16.4% |
|
|
315
|
+
| TokenTrimmer balanced + stale 10 + `TOKENTRIMMER_RETRIEVE=1` | 28.7% | 15.8% (before retrieval round trips) |
|
|
316
|
+
| Headroom library mode, default and coding profiles | 1.2% | 1.3% |
|
|
317
|
+
| Headroom library mode, agent-90 profile | 5.6% | not measured |
|
|
318
|
+
|
|
319
|
+
Most of safe mode's saving on these sessions comes from line references: browser page trees and command output
|
|
320
|
+
repeat the same lines within a result and across results, and a 12-token reference replaces each run.
|
|
306
321
|
|
|
307
322
|
Cache-weighted cost replays each session request by request, priced as prompt caching bills it (0.1x for the prefix
|
|
308
323
|
shared with the previous request, 1.25x for the rest). Rewriting old messages re-bills everything after them, which is
|
|
309
324
|
why stale mode's saving roughly halves there.
|
|
310
325
|
|
|
311
326
|
Accuracy: 140 questions over 8 of the sessions, each quoting a line from an old tool result and asking for the line
|
|
312
|
-
after it (exact match), answered by Claude Opus 5.5 through `claude -p` with each compressed conversation
|
|
327
|
+
after it (exact match), answered by Claude Opus 5.5 through `claude -p` with each compressed conversation. Safe mode
|
|
328
|
+
was measured on the current version; the other TokenTrimmer rows on v0.1.1, which used fewer, longer references:
|
|
313
329
|
|
|
314
330
|
| | Correct | Answered UNKNOWN | Wrong guess |
|
|
315
331
|
|---|---|---|---|
|
|
@@ -320,7 +336,9 @@ after it (exact match), answered by Claude Opus 5.5 through `claude -p` with eac
|
|
|
320
336
|
| TokenTrimmer balanced + stale 10 + retrieval | 99% | 0 | 1 |
|
|
321
337
|
| Headroom default | 95% | 3 | 4 |
|
|
322
338
|
|
|
323
|
-
Safe mode matched the original
|
|
339
|
+
Safe mode matched the original. Because few of those 140 answers sit behind a reference, 38 more questions were
|
|
340
|
+
added whose answer, in the safe output, is hidden behind a `[same as …]` reference (both kinds); the model answered
|
|
341
|
+
38/38, the same as on the original conversation.
|
|
324
342
|
Stale mode alone loses most old detail. With retrieval the model read back what it needed and recovered almost
|
|
325
343
|
all of it, but every retrieval is an extra round trip that re-reads the context: in this test, where most
|
|
326
344
|
questions targeted cut content, stale + retrieval cost about 1.7x as much as sending the original. It pays off
|
|
@@ -341,16 +359,19 @@ send to the API. Tokens include any retrieval tool a proxy adds.
|
|
|
341
359
|
| Headroom, `cache` (default) | 1.5% | 0.2% | 104 ms |
|
|
342
360
|
| Headroom, `--lossless` | 1.6% | 0.9% | 66 ms |
|
|
343
361
|
| Headroom, `token` | 5.8% | **-11.4%** (costs more than the original) | 914 ms |
|
|
344
|
-
| TokenTrimmer, safe (default) |
|
|
345
|
-
| TokenTrimmer, aggressive |
|
|
346
|
-
| TokenTrimmer, balanced + stale 10 |
|
|
347
|
-
| TokenTrimmer, balanced + stale 10 + retrieval |
|
|
362
|
+
| TokenTrimmer, safe (default) | 7.2% | 5.3% | 9 ms |
|
|
363
|
+
| TokenTrimmer, aggressive | 14.3% | 11.6% | 9 ms |
|
|
364
|
+
| TokenTrimmer, balanced + stale 10 | 29.8% | 16.4% | 9 ms |
|
|
365
|
+
| TokenTrimmer, balanced + stale 10 + retrieval | 28.8% | 15.3% | 10 ms |
|
|
366
|
+
|
|
367
|
+
Safe mode is lossless (every request verified) and still smaller than Headroom's `token` mode, which rewrites
|
|
368
|
+
content.
|
|
348
369
|
|
|
349
370
|
Headroom's `token` mode re-compresses earlier turns as the conversation grows, so the cached prefix keeps
|
|
350
371
|
changing and most of each request is billed at the full rate. Headroom's `cache` mode avoids that but compresses
|
|
351
372
|
little of these sessions. Latency is the proxy's own time per request on an Apple Silicon laptop, with a
|
|
352
373
|
local upstream that answers instantly. The slowest single request took 17 s through Headroom `token` mode and
|
|
353
|
-
0.
|
|
374
|
+
0.13 s through TokenTrimmer. Where proxies add `cache_control` breakpoints they are ignored in the cost
|
|
354
375
|
column, because they mark the cache rather than being part of it.
|
|
355
376
|
|
|
356
377
|
Not measured here: Headroom's own retrieval (CCR) under a real model. In these runs its proxies added at most
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokentrimmer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Cut Claude Code, Anthropic and OpenAI token usage and API cost: a local proxy that losslessly compresses tool output (JSON, logs, grep) before it reaches the API. Prompt-cache safe, zero dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/src/formats.js
CHANGED
|
@@ -275,36 +275,57 @@ function decodeLogTemplates(text) {
|
|
|
275
275
|
|
|
276
276
|
// ---------- repeated line blocks ----------
|
|
277
277
|
|
|
278
|
-
// A run of lines already
|
|
279
|
-
// `message` is the index in the request's
|
|
280
|
-
// message holds more than one tool-result
|
|
281
|
-
|
|
278
|
+
// A run of lines the model already has, verbatim, in an earlier tool result is replaced by one short
|
|
279
|
+
// reference line, e.g. "[same as message 175 lines 16-19]". `message` is the index in the request's
|
|
280
|
+
// messages array; `result` (1-based) is only written when that message holds more than one tool-result
|
|
281
|
+
// text. Line numbers count lines of that result *as sent*, which is what the model reads. Lossless:
|
|
282
|
+
// decodeBlockRefs against the sent request restores the exact text.
|
|
283
|
+
const BLOCK_REF = /^\[same as message (\d+)(?: result (\d+))? lines (\d+)-(\d+)\]$/;
|
|
282
284
|
|
|
283
285
|
function blockRef(from, to, message, result) {
|
|
284
|
-
|
|
285
|
-
return `[tokentrimmer: lines ${from}-${to} of ${where} in message ${message} repeated here]`;
|
|
286
|
+
return `[same as message ${message}${result ? ` result ${result}` : ''} lines ${from}-${to}]`;
|
|
286
287
|
}
|
|
287
288
|
|
|
289
|
+
// A run repeating lines earlier in the same tool result: "[same as lines 3-5 above]", counting lines of
|
|
290
|
+
// this result as sent.
|
|
291
|
+
const LOCAL_REF = /^\[same as lines (\d+)-(\d+) above\]$/;
|
|
292
|
+
const localRef = (from, to) => `[same as lines ${from}-${to} above]`;
|
|
293
|
+
const isRefLine = (l) => BLOCK_REF.test(l) || LOCAL_REF.test(l);
|
|
294
|
+
|
|
295
|
+
// An identical repeated tool result becomes one line naming the message that already holds it.
|
|
296
|
+
const IDENTICAL_REF = /^\[same as a tool result in message (\d+)\]$/;
|
|
297
|
+
const identicalRef = (message) => `[same as a tool result in message ${message}]`;
|
|
298
|
+
|
|
288
299
|
function hasBlockRefs(text) {
|
|
289
|
-
return text.split('\n').some(
|
|
300
|
+
return text.split('\n').some(isRefLine);
|
|
290
301
|
}
|
|
291
302
|
|
|
292
|
-
// getText(message, result) returns the referenced
|
|
303
|
+
// getText(message, result) returns the referenced text as sent (result is 1-based, defaults to 1).
|
|
293
304
|
function decodeBlockRefs(text, getText) {
|
|
294
305
|
const out = [];
|
|
295
|
-
|
|
306
|
+
const sent = text.split('\n');
|
|
307
|
+
sent.forEach((l, at) => {
|
|
308
|
+
const local = LOCAL_REF.exec(l);
|
|
309
|
+
if (local) {
|
|
310
|
+
const from = Number(local[1]), to = Number(local[2]);
|
|
311
|
+
if (from < 1 || to < from || to > at) throw new Error(`reference to lines not above it: ${l}`);
|
|
312
|
+
const lines = sent.slice(from - 1, to);
|
|
313
|
+
if (lines.some(isRefLine)) throw new Error(`reference to a reference line: ${l}`);
|
|
314
|
+
out.push(...lines);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
296
317
|
const m = BLOCK_REF.exec(l);
|
|
297
318
|
if (!m) {
|
|
298
319
|
out.push(l);
|
|
299
|
-
|
|
320
|
+
return;
|
|
300
321
|
}
|
|
301
|
-
const src = getText(Number(m[
|
|
322
|
+
const src = getText(Number(m[1]), m[2] ? Number(m[2]) : 1);
|
|
302
323
|
if (typeof src !== 'string') throw new Error(`block reference to a missing tool result: ${l}`);
|
|
303
324
|
const lines = src.split('\n');
|
|
304
|
-
const from = Number(m[
|
|
325
|
+
const from = Number(m[3]), to = Number(m[4]);
|
|
305
326
|
if (from < 1 || to < from || to > lines.length) throw new Error(`block reference out of range: ${l}`);
|
|
306
327
|
out.push(...lines.slice(from - 1, to));
|
|
307
|
-
}
|
|
328
|
+
});
|
|
308
329
|
return out.join('\n');
|
|
309
330
|
}
|
|
310
331
|
|
|
@@ -333,6 +354,6 @@ function splitRetrieveNote(text) {
|
|
|
333
354
|
module.exports = {
|
|
334
355
|
encCell, splitCells, parseCell, encodeTable, encodeGroups, decodeTables, decodeJSONOutput, expand,
|
|
335
356
|
templateLogs, decodeLogTemplates, ungroupGrep, STRIPPED_ROW,
|
|
336
|
-
BLOCK_REF, blockRef, hasBlockRefs, decodeBlockRefs,
|
|
357
|
+
BLOCK_REF, blockRef, LOCAL_REF, localRef, isRefLine, hasBlockRefs, decodeBlockRefs, IDENTICAL_REF, identicalRef,
|
|
337
358
|
RETRIEVE_TOOL, retrieveId, retrieveNote, splitRetrieveNote,
|
|
338
359
|
};
|
package/src/messages.js
CHANGED
|
@@ -11,10 +11,13 @@ const formats = require('./formats');
|
|
|
11
11
|
|
|
12
12
|
const DEDUPE_MIN_CHARS = 400;
|
|
13
13
|
const LOSSY = new Set(['json-drop-empty', 'log-collapse', 'cap-large-output']);
|
|
14
|
-
// Repeated line blocks: a run must span at least BLOCK_WINDOW lines and BLOCK_MIN_CHARS characters
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
const
|
|
14
|
+
// Repeated line blocks: a run must span at least BLOCK_WINDOW lines and BLOCK_MIN_CHARS characters, and be
|
|
15
|
+
// BLOCK_RATIO times longer than the reference replacing it. Tuned on real sessions (compare/real): with
|
|
16
|
+
// short references even a single long repeated line pays off.
|
|
17
|
+
const BLOCK_WINDOW = 1;
|
|
18
|
+
const BLOCK_MIN_CHARS = 60; // tuned on real sessions via compare/real (opts.blockWindow / opts.blockMinChars override)
|
|
19
|
+
const MAX_SITES = 16; // earlier positions remembered per repeated window
|
|
20
|
+
const BLOCK_RATIO = 2; // the run must be at least this many times longer than its reference line
|
|
18
21
|
|
|
19
22
|
// Tools whose output is file content the model may later quote back exactly (edit tools match
|
|
20
23
|
// text verbatim). Their results are never rewritten, but identical repeats are still deduplicated.
|
|
@@ -40,6 +43,7 @@ function makeContext(opts) {
|
|
|
40
43
|
// Retrieval: id -> original text of every tool result that lost content (opts.retrieve).
|
|
41
44
|
store: new Map(),
|
|
42
45
|
blockWindow: opts.blockWindow || BLOCK_WINDOW, blockMinChars: opts.blockMinChars ?? BLOCK_MIN_CHARS,
|
|
46
|
+
blockRatio: opts.blockRatio ?? BLOCK_RATIO,
|
|
43
47
|
};
|
|
44
48
|
}
|
|
45
49
|
|
|
@@ -64,43 +68,83 @@ function note(ctx, list) {
|
|
|
64
68
|
|
|
65
69
|
const windowKey = (lines, i, w) => crypto.createHash('sha1').update(lines.slice(i, i + w).join('\n')).digest('base64');
|
|
66
70
|
|
|
67
|
-
// Remember a tool result
|
|
68
|
-
|
|
69
|
-
|
|
71
|
+
// Remember a tool result as it is sent, so later results can point at its lines. Line numbers are
|
|
72
|
+
// those of the sent text (what the model reads). Lines that are themselves references are never
|
|
73
|
+
// pointed at, so a reference always resolves to text the model has verbatim.
|
|
74
|
+
function indexSource(ctx, sent, msgIndex, k) {
|
|
75
|
+
const lines = sent.split('\n');
|
|
70
76
|
if (lines.length < ctx.blockWindow) return;
|
|
71
77
|
const id = `${msgIndex}:${k}`;
|
|
72
78
|
ctx.sources.set(id, lines);
|
|
79
|
+
const isRef = lines.map(formats.isRefLine);
|
|
80
|
+
let refsAhead = 0; // reference lines inside the current window
|
|
81
|
+
for (let i = 0; i < ctx.blockWindow - 1 && i < lines.length; i++) refsAhead += isRef[i];
|
|
73
82
|
for (let i = 0; i + ctx.blockWindow <= lines.length; i++) {
|
|
74
|
-
|
|
75
|
-
if (!
|
|
83
|
+
refsAhead += isRef[i + ctx.blockWindow - 1];
|
|
84
|
+
if (!refsAhead) {
|
|
85
|
+
const key = windowKey(lines, i, ctx.blockWindow);
|
|
86
|
+
const at = ctx.blocks.get(key);
|
|
87
|
+
const hit = { id, msgIndex, k, line: i };
|
|
88
|
+
if (!at) ctx.blocks.set(key, [hit]);
|
|
89
|
+
else if (at.length < MAX_SITES) at.push(hit);
|
|
90
|
+
}
|
|
91
|
+
refsAhead -= isRef[i];
|
|
76
92
|
}
|
|
77
93
|
}
|
|
78
94
|
|
|
79
|
-
// Replace runs of lines already
|
|
80
|
-
//
|
|
95
|
+
// Replace runs of lines the model already has verbatim with one reference line each: to an earlier tool
|
|
96
|
+
// result ("[same as message M lines A-B]") or to lines above in this one ("[same as lines A-B above]").
|
|
97
|
+
// Returns null when nothing qualifies. Sites are tried in send order and only a strictly longer run wins,
|
|
98
|
+
// so the output is deterministic and depends only on this text and earlier messages.
|
|
81
99
|
function dedupeBlocks(text, ctx) {
|
|
82
|
-
|
|
100
|
+
const w = ctx.blockWindow;
|
|
83
101
|
const lines = text.split('\n');
|
|
84
|
-
if (lines.length < ctx.
|
|
85
|
-
const out = [];
|
|
102
|
+
if (lines.length < w + 1 && !ctx.blocks.size) return null;
|
|
103
|
+
const out = []; // lines as sent
|
|
104
|
+
const local = new Map(); // window key -> sites in `out` (verbatim lines only)
|
|
105
|
+
let verbatimRun = 0; // consecutive verbatim lines at the end of `out`
|
|
86
106
|
let replaced = false;
|
|
107
|
+
const run = (src, from, i) => {
|
|
108
|
+
let n = 0;
|
|
109
|
+
let chars = 0;
|
|
110
|
+
while (i + n < lines.length && from + n < src.length && lines[i + n] === src[from + n] && !formats.isRefLine(src[from + n])) {
|
|
111
|
+
chars += lines[i + n++].length + 1;
|
|
112
|
+
}
|
|
113
|
+
return [n, chars];
|
|
114
|
+
};
|
|
87
115
|
let i = 0;
|
|
88
116
|
while (i < lines.length) {
|
|
89
|
-
|
|
90
|
-
if (
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
117
|
+
let best = null;
|
|
118
|
+
if (i + w <= lines.length) {
|
|
119
|
+
const key = windowKey(lines, i, w);
|
|
120
|
+
for (const site of ctx.blocks.get(key) || []) {
|
|
121
|
+
const [n, chars] = run(ctx.sources.get(site.id), site.line, i);
|
|
122
|
+
if (!best || chars > best.chars) {
|
|
123
|
+
const ref = formats.blockRef(site.line + 1, site.line + n, site.msgIndex, ctx.resultCounts.get(site.msgIndex) > 1 ? site.k : 0);
|
|
124
|
+
best = { n, chars, ref };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
for (const at of local.get(key) || []) {
|
|
128
|
+
const [n, chars] = run(out, at, i);
|
|
129
|
+
if (!best || chars > best.chars) best = { n, chars, ref: formats.localRef(at + 1, at + n) };
|
|
101
130
|
}
|
|
102
131
|
}
|
|
132
|
+
// Only worth it when the run is clearly longer than the reference that replaces it.
|
|
133
|
+
if (best && best.n >= w && best.chars >= ctx.blockMinChars && best.chars >= best.ref.length * ctx.blockRatio) {
|
|
134
|
+
out.push(best.ref);
|
|
135
|
+
verbatimRun = 0;
|
|
136
|
+
replaced = true;
|
|
137
|
+
i += best.n;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
103
140
|
out.push(lines[i++]);
|
|
141
|
+
if (++verbatimRun >= w) {
|
|
142
|
+
const at = out.length - w;
|
|
143
|
+
const key = windowKey(out, at, w);
|
|
144
|
+
const sites = local.get(key);
|
|
145
|
+
if (!sites) local.set(key, [at]);
|
|
146
|
+
else if (sites.length < MAX_SITES) sites.push(at);
|
|
147
|
+
}
|
|
104
148
|
}
|
|
105
149
|
return replaced ? out.join('\n') : null;
|
|
106
150
|
}
|
|
@@ -133,28 +177,45 @@ function shrinkToolText(text, ctx, msgIndex, excluded, k, stale) {
|
|
|
133
177
|
if (ctx.seen.has(hash)) {
|
|
134
178
|
note(ctx, ['dedupe-identical']);
|
|
135
179
|
ctx.fieldsChanged++;
|
|
136
|
-
return
|
|
180
|
+
return formats.identicalRef(ctx.seen.get(hash));
|
|
137
181
|
}
|
|
138
182
|
ctx.seen.set(hash, msgIndex);
|
|
139
183
|
}
|
|
184
|
+
const sent = shrinkUnique(text, ctx, excluded, stale);
|
|
185
|
+
indexSource(ctx, sent, msgIndex, k);
|
|
186
|
+
return sent;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function shrinkUnique(text, ctx, excluded, stale) {
|
|
140
190
|
if (excluded) {
|
|
141
191
|
// File content stays verbatim (edit tools match it exactly), and so can be referenced later.
|
|
142
192
|
ctx.skipped++;
|
|
143
|
-
indexSource(ctx, text, msgIndex, k);
|
|
144
193
|
return text;
|
|
145
194
|
}
|
|
146
195
|
const r = compressText(text, (stale && staleOptions(ctx.opts)) || ctx.opts);
|
|
147
|
-
|
|
196
|
+
// References can replace lines of the raw text or of its compressed form; keep the shortest.
|
|
197
|
+
// Never when the original already has lines that look like references: they would be ambiguous.
|
|
198
|
+
const canRef = !formats.hasBlockRefs(text);
|
|
199
|
+
let blocks = canRef ? dedupeBlocks(text, ctx) : null;
|
|
200
|
+
let onCompressed = false;
|
|
201
|
+
if (canRef && r.techniques.length) {
|
|
202
|
+
const b2 = dedupeBlocks(r.text, ctx);
|
|
203
|
+
if (b2 !== null && (blocks === null || b2.length < blocks.length)) [blocks, onCompressed] = [b2, true];
|
|
204
|
+
}
|
|
148
205
|
if (blocks !== null && blocks.length < r.text.length) {
|
|
149
|
-
note(ctx, ['dedupe-block']);
|
|
206
|
+
note(ctx, onCompressed ? [...r.techniques, 'dedupe-block'] : ['dedupe-block']);
|
|
150
207
|
ctx.fieldsChanged++;
|
|
208
|
+
if (onCompressed && ctx.opts.retrieve && r.techniques.some((t) => LOSSY.has(t))) {
|
|
209
|
+
const id = formats.retrieveId(text);
|
|
210
|
+
ctx.store.set(id, text);
|
|
211
|
+
note(ctx, ['retrievable']);
|
|
212
|
+
return blocks + formats.retrieveNote(id);
|
|
213
|
+
}
|
|
151
214
|
return blocks;
|
|
152
215
|
}
|
|
153
216
|
if (r.techniques.length) {
|
|
154
217
|
note(ctx, r.techniques);
|
|
155
218
|
ctx.fieldsChanged++;
|
|
156
|
-
} else {
|
|
157
|
-
indexSource(ctx, text, msgIndex, k);
|
|
158
219
|
}
|
|
159
220
|
if (ctx.opts.retrieve && r.techniques.some((t) => LOSSY.has(t))) {
|
|
160
221
|
// Content was dropped: keep the original for the retrieval tool and tell the model how to get it.
|
package/src/verify.js
CHANGED
|
@@ -14,7 +14,7 @@ const formats = require('./formats');
|
|
|
14
14
|
const { toolTexts, staleOptions } = require('./messages');
|
|
15
15
|
|
|
16
16
|
const LOSSY = new Set(['json-drop-empty', 'log-collapse', 'cap-large-output']);
|
|
17
|
-
const DEDUPE_REF =
|
|
17
|
+
const DEDUPE_REF = formats.IDENTICAL_REF;
|
|
18
18
|
|
|
19
19
|
function containsString(node, str) {
|
|
20
20
|
if (typeof node === 'string') return node === str;
|
|
@@ -43,15 +43,17 @@ function classify(orig, comp, originalBody, compressedBody, opts) {
|
|
|
43
43
|
: { status: 'FAILED', techniques: ['dedupe-identical'], reason: 'referenced message does not contain this text' };
|
|
44
44
|
}
|
|
45
45
|
if (formats.hasBlockRefs(comp) && !formats.hasBlockRefs(orig)) {
|
|
46
|
-
//
|
|
47
|
-
// lines are
|
|
46
|
+
// References point at lines as sent, so decode against the compressed body: an exact match proves
|
|
47
|
+
// both that nothing was lost and that the referenced lines really are in front of the model.
|
|
48
48
|
const t = ['dedupe-block'];
|
|
49
49
|
try {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
|
|
50
|
+
const decoded = formats.decodeBlockRefs(comp, (m, k) => toolTexts(compressedBody.messages[m])[k - 1]);
|
|
51
|
+
if (decoded === orig) return { status: 'exact', techniques: t };
|
|
52
|
+
// References inside a compressed form: what they expand to must be what the compressor produces.
|
|
53
|
+
if (formats.hasBlockRefs(decoded)) return { status: 'FAILED', techniques: t, reason: 'a reference points at another reference' };
|
|
54
|
+
const inner = classify(orig, decoded, originalBody, compressedBody, opts);
|
|
55
|
+
if (inner.status === 'FAILED') return { ...inner, reason: `after expanding references: ${inner.reason}` };
|
|
56
|
+
return { ...inner, techniques: [...inner.techniques, ...t] };
|
|
55
57
|
} catch (e) {
|
|
56
58
|
return { status: 'FAILED', techniques: t, reason: `decode error: ${e.message}` };
|
|
57
59
|
}
|