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 CHANGED
@@ -1,5 +1,7 @@
1
1
  # TokenTrimmer
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/tokentrimmer)](https://www.npmjs.com/package/tokentrimmer) [![license: MIT](https://img.shields.io/npm/l/tokentrimmer)](LICENSE) ![dependencies: 0](https://img.shields.io/badge/dependencies-0-brightgreen)
4
+
3
5
  ![TokenTrimmer: a local proxy that losslessly compresses tool output, e.g. a JSON array becomes a table](docs/social-preview.png)
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 3% of input tokens with no loss in answer
39
- accuracy, and the lossy modes saved 10–27% at a measurable accuracy cost (see [Real sessions](#real-sessions)).
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
- npm start
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 npm start` first. Requests are measured and the dashboard
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
- Install (not on npm; nothing to `npm install`, there are no dependencies):
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
- git clone https://github.com/an21an21/tokentrimmer.git
61
- cd tokentrimmer
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
- cd tokentrimmer
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 (add to `~/.zshrc` or `~/.bashrc`,
94
- and change `~/tokentrimmer` to where you cloned it):
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
- (cd ~/tokentrimmer && nohup npm start >/tmp/tokentrimmer.log 2>&1 &)
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 npm start`)
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 one-line reference | safe | yes |
143
- | A run of 4+ lines already shown verbatim in an earlier tool result becomes `[tokentrimmer: lines A-B of the tool result in message M repeated here]` | safe | yes |
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 | 20% | 20% | 20% |
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 | 0% | 0% | 0% |
259
- | Verbose prose | 8% | 8% | 0% |
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 | 44% | 50% | 20% |
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 | 3.3% | 2.7% |
302
- | TokenTrimmer aggressive | 10.8% | 9.2% |
303
- | TokenTrimmer balanced + `TOKENTRIMMER_STALE_AFTER=10` | 27.4% | 14.7% |
304
- | TokenTrimmer balanced + stale 10 + `TOKENTRIMMER_RETRIEVE=1` | 26.5% | 13.6% (before retrieval round trips) |
305
- | Headroom library mode (default, coding, general, agent-90 profiles alike) | 1.0% | 1.0% |
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, including answers that had to be found by following a repeated-block reference.
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) | 3.7% | 2.6% | 12 ms |
345
- | TokenTrimmer, aggressive | 11.2% | 9.2% | 13 ms |
346
- | TokenTrimmer, balanced + stale 10 | 27.8% | 14.6% | 13 ms |
347
- | TokenTrimmer, balanced + stale 10 + retrieval | 26.9% | 13.5% | 15 ms |
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.4 s through TokenTrimmer. Where proxies add `cache_control` breakpoints they are ignored in the cost
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.1.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 shown verbatim in an earlier tool result is replaced by one reference line.
279
- // `message` is the index in the request's messages array; `result` (1-based) is only written when that
280
- // message holds more than one tool-result text. Lossless: decodeBlockRefs restores the exact text.
281
- const BLOCK_REF = /^\[tokentrimmer: lines (\d+)-(\d+) of (?:tool result (\d+)|the tool result) in message (\d+) repeated here\]$/;
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
- const where = result ? `tool result ${result}` : 'the tool result';
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((l) => BLOCK_REF.test(l));
300
+ return text.split('\n').some(isRefLine);
290
301
  }
291
302
 
292
- // getText(message, result) returns the referenced original text (result is 1-based, defaults to 1).
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
- for (const l of text.split('\n')) {
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
- continue;
320
+ return;
300
321
  }
301
- const src = getText(Number(m[4]), m[3] ? Number(m[3]) : 1);
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[1]), to = Number(m[2]);
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
- // to be worth a reference line.
16
- const BLOCK_WINDOW = 4;
17
- const BLOCK_MIN_CHARS = 100; // tuned on real sessions via compare/real (opts.blockWindow / opts.blockMinChars override)
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 that is sent unchanged, so later results can point at its lines.
68
- function indexSource(ctx, text, msgIndex, k) {
69
- const lines = text.split('\n');
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
- const key = windowKey(lines, i, ctx.blockWindow);
75
- if (!ctx.blocks.has(key)) ctx.blocks.set(key, { id, msgIndex, k, line: i });
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 sent verbatim earlier with one reference line each. Returns null
80
- // when nothing qualifies. The first earlier occurrence is always used, so output is deterministic.
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
- if (!ctx.blocks.size || formats.hasBlockRefs(text)) return null;
100
+ const w = ctx.blockWindow;
83
101
  const lines = text.split('\n');
84
- if (lines.length < ctx.blockWindow) return null;
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
- const hit = i + ctx.blockWindow <= lines.length && ctx.blocks.get(windowKey(lines, i, ctx.blockWindow));
90
- if (hit) {
91
- const src = ctx.sources.get(hit.id);
92
- let n = 0;
93
- let chars = 0;
94
- while (i + n < lines.length && hit.line + n < src.length && lines[i + n] === src[hit.line + n]) chars += lines[i + n++].length + 1;
95
- if (n >= ctx.blockWindow && chars >= ctx.blockMinChars) {
96
- const result = ctx.resultCounts.get(hit.msgIndex) > 1 ? hit.k : 0;
97
- out.push(formats.blockRef(hit.line + 1, hit.line + n, hit.msgIndex, result));
98
- replaced = true;
99
- i += n;
100
- continue;
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 `[tokentrimmer: identical to the tool result in message ${ctx.seen.get(hash)}]`;
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
- const blocks = dedupeBlocks(text, ctx);
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 = /^\[tokentrimmer: identical to the tool result in message (\d+)\]$/;
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
- // Decode against the original (nothing lost) and against the compressed body (the referenced
47
- // lines are really in front of the model, unchanged).
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
- for (const body of [originalBody, compressedBody]) {
51
- const decoded = formats.decodeBlockRefs(comp, (m, k) => toolTexts(body.messages[m])[k - 1]);
52
- if (decoded !== orig) return { status: 'FAILED', techniques: t, reason: 'referenced lines differ from the original text' };
53
- }
54
- return { status: 'exact', techniques: t };
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
  }