tokentrimmer 0.1.1 → 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 +33 -20
- 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
|
@@ -37,8 +37,9 @@ for over and over. TokenTrimmer rewrites tool results into a shorter form as eac
|
|
|
37
37
|
- **You can see what it does.** A local dashboard shows tokens before and after for every request, and dry-run
|
|
38
38
|
mode measures savings without changing anything.
|
|
39
39
|
|
|
40
|
-
What to expect: on real Claude Code sessions, safe mode saved about
|
|
41
|
-
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)).
|
|
42
43
|
Savings depend heavily on how much repetitive tool output your sessions have; run dry-run mode first to see
|
|
43
44
|
your own number.
|
|
44
45
|
|
|
@@ -147,8 +148,8 @@ is experimental.
|
|
|
147
148
|
| Log lines sharing a template: templates listed once, each line written as `~N|var|var`, original order kept | safe | yes |
|
|
148
149
|
| Tables nested inside a larger JSON object keep a JSON form (`{"_table":{cols,rows}}`) | safe | yes |
|
|
149
150
|
| grep-style `path/file:line:text` output: each directory written once as an `@ dir/` header | safe | yes |
|
|
150
|
-
| Identical tool results sent again later become a
|
|
151
|
-
| 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 |
|
|
152
153
|
| Drop null / empty JSON fields and all-empty table columns | balanced | no |
|
|
153
154
|
| Collapse runs of similar log lines (first and last kept, count shown) | balanced | no |
|
|
154
155
|
| Cap huge outputs: head and tail kept, middle omitted and labelled | aggressive | no |
|
|
@@ -260,18 +261,20 @@ message list. Reproduce with `compare/export.js` then `compare/run_headroom.py`.
|
|
|
260
261
|
|---|---|---|---|
|
|
261
262
|
| JSON API response, 150 records | 74% | 78% | 72% |
|
|
262
263
|
| Mixed-shape JSON events | 73% | 74% | 73% |
|
|
263
|
-
| grep results, 500 hits |
|
|
264
|
+
| grep results, 500 hits | 21% | 21% | 20% |
|
|
264
265
|
| Server log, retry storm | 61% | 98% | 10% |
|
|
265
266
|
| JavaScript source file | 6% | 6% | 0% |
|
|
266
|
-
| Python source with comments |
|
|
267
|
-
| Verbose prose |
|
|
267
|
+
| Python source with comments | 66% | 66% | 0% |
|
|
268
|
+
| Verbose prose | 64% | 64% | 0% |
|
|
268
269
|
| Agent conversation, 5 tool results | 59% | 65% | 17% |
|
|
269
|
-
| Total |
|
|
270
|
+
| Total | 51% | 57% | 20% |
|
|
270
271
|
|
|
271
272
|
How to read this:
|
|
272
273
|
- Safe mode is the like-for-like column: Headroom's output on JSON, logs and grep kept every
|
|
273
274
|
row. Safe mode is verified lossless by decoder round-trip tests (`src/formats.js`).
|
|
274
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.
|
|
275
278
|
- The counter is characters divided by four. Real tokenizers may count digits, punctuation and
|
|
276
279
|
CSV-style rows differently, so real savings can be lower or higher. Use dry run: the
|
|
277
280
|
dashboard's "billed input" column is the real figure.
|
|
@@ -306,18 +309,23 @@ tokens counted with tiktoken o200k_base, Headroom 0.42.0 in library mode. Saving
|
|
|
306
309
|
|
|
307
310
|
| | Final request | Cache-weighted cost, whole session |
|
|
308
311
|
|---|---|---|
|
|
309
|
-
| TokenTrimmer safe |
|
|
310
|
-
| TokenTrimmer aggressive |
|
|
311
|
-
| TokenTrimmer balanced + `TOKENTRIMMER_STALE_AFTER=10` |
|
|
312
|
-
| TokenTrimmer balanced + stale 10 + `TOKENTRIMMER_RETRIEVE=1` |
|
|
313
|
-
| 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.
|
|
314
321
|
|
|
315
322
|
Cache-weighted cost replays each session request by request, priced as prompt caching bills it (0.1x for the prefix
|
|
316
323
|
shared with the previous request, 1.25x for the rest). Rewriting old messages re-bills everything after them, which is
|
|
317
324
|
why stale mode's saving roughly halves there.
|
|
318
325
|
|
|
319
326
|
Accuracy: 140 questions over 8 of the sessions, each quoting a line from an old tool result and asking for the line
|
|
320
|
-
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:
|
|
321
329
|
|
|
322
330
|
| | Correct | Answered UNKNOWN | Wrong guess |
|
|
323
331
|
|---|---|---|---|
|
|
@@ -328,7 +336,9 @@ after it (exact match), answered by Claude Opus 5.5 through `claude -p` with eac
|
|
|
328
336
|
| TokenTrimmer balanced + stale 10 + retrieval | 99% | 0 | 1 |
|
|
329
337
|
| Headroom default | 95% | 3 | 4 |
|
|
330
338
|
|
|
331
|
-
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.
|
|
332
342
|
Stale mode alone loses most old detail. With retrieval the model read back what it needed and recovered almost
|
|
333
343
|
all of it, but every retrieval is an extra round trip that re-reads the context: in this test, where most
|
|
334
344
|
questions targeted cut content, stale + retrieval cost about 1.7x as much as sending the original. It pays off
|
|
@@ -349,16 +359,19 @@ send to the API. Tokens include any retrieval tool a proxy adds.
|
|
|
349
359
|
| Headroom, `cache` (default) | 1.5% | 0.2% | 104 ms |
|
|
350
360
|
| Headroom, `--lossless` | 1.6% | 0.9% | 66 ms |
|
|
351
361
|
| Headroom, `token` | 5.8% | **-11.4%** (costs more than the original) | 914 ms |
|
|
352
|
-
| TokenTrimmer, safe (default) |
|
|
353
|
-
| TokenTrimmer, aggressive |
|
|
354
|
-
| TokenTrimmer, balanced + stale 10 |
|
|
355
|
-
| 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.
|
|
356
369
|
|
|
357
370
|
Headroom's `token` mode re-compresses earlier turns as the conversation grows, so the cached prefix keeps
|
|
358
371
|
changing and most of each request is billed at the full rate. Headroom's `cache` mode avoids that but compresses
|
|
359
372
|
little of these sessions. Latency is the proxy's own time per request on an Apple Silicon laptop, with a
|
|
360
373
|
local upstream that answers instantly. The slowest single request took 17 s through Headroom `token` mode and
|
|
361
|
-
0.
|
|
374
|
+
0.13 s through TokenTrimmer. Where proxies add `cache_control` breakpoints they are ignored in the cost
|
|
362
375
|
column, because they mark the cache rather than being part of it.
|
|
363
376
|
|
|
364
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
|
}
|