toolgz 0.2.9 → 0.2.11

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  <p><strong>Your agent spends 30–70k tokens of context on tool definitions before the user types a word. toolgz gets up to ~85% of it back.</strong></p>
4
4
 
5
- <p><em>On a large tool set, at level 3. The default (level 1) reclaims 13–39% and gives up nothing. <code>recommendLevel()</code> tells you which one your tools want; you pass its answer in.</em></p>
5
+ <p><em>On a large tool set, at level 3. The default (level 1) reclaims 13–39% on real-world tool definitions and gives up nothing. <code>recommendLevel()</code> tells you which one your tools want; you pass its answer in.</em></p>
6
6
 
7
7
  <p>
8
8
  <a href="#measured-results">420-run cross-provider sweep</a> ·
@@ -15,6 +15,17 @@ zero runtime dependencies ·
15
15
  npm install toolgz
16
16
  ```
17
17
 
18
+ > ### 🤖 If a coding agent is reading this
19
+ >
20
+ > Point it at **[`llms.txt`](llms.txt)** — prescriptive integration instructions written
21
+ > for an agent, shipped inside the package at `node_modules/toolgz/llms.txt`. It covers
22
+ > the three-step contract, the rules that are not optional, per-model behaviour, and a
23
+ > `Do not` list of the mistakes we have actually fielded.
24
+ >
25
+ > ```
26
+ > Read node_modules/toolgz/llms.txt before integrating toolgz.
27
+ > ```
28
+
18
29
  ---
19
30
 
20
31
  ## The problem
@@ -33,7 +44,7 @@ Reclaiming the room is what this does.
33
44
  ```ts
34
45
  import { compress, recommendLevel, forAnthropic } from "toolgz";
35
46
 
36
- const { level } = recommendLevel(myTools); // advice: 1 for a small block, 3 for a big one
47
+ const { level } = recommendLevel(myTools); // advice: 0, 1 or 3 see below
37
48
  const c = compress(myTools, { level }); // your existing MCP/SDK tool array
38
49
  const { tools, system } = forAnthropic(c); // send these instead
39
50
  ```
@@ -43,6 +54,11 @@ provider schema enforcement intact, and 13–39% smaller (13–32% on the synthe
43
54
  **level 3**, which is what `recommendLevel` returns once a tool set is big enough to
44
55
  amortise the dispatcher.
45
56
 
57
+ Level 1 earns that by stripping prose, so it needs prose to strip: if your descriptions
58
+ are already one short sentence, it **adds** ~15% instead and `recommendLevel` returns
59
+ **0** — leave the tools alone. Real MCP catalogues are verbose, which is why they land at
60
+ the top of that range.
61
+
46
62
  **Note the shape of those three lines: `recommendLevel` only advises, and you pass its
47
63
  answer back in.** Nothing upgrades itself — `compress(myTools)` is level 1 whether you
48
64
  have 2 tools or 500. See [Which level to use](#which-level-to-use) for why.
@@ -67,8 +83,10 @@ committed in [`bench/results/`](bench/results/); recompute any figure with
67
83
 
68
84
  **The table below uses a synthetic catalogue** — 100 realistic-but-invented MCP-style
69
85
  tools across 9 namespaces — because it lets us build deliberately confusable clusters
70
- that a real catalogue may not contain. Two things follow from that, and the first is
71
- uncomfortable:
86
+ that a real catalogue may not contain. Four of the five scenarios draw a 30-tool
87
+ confusable subset from it; the fifth (`acc-haystack`) uses all 100.
88
+
89
+ Two things follow from that, and the first is uncomfortable:
72
90
 
73
91
  - Synthetic naming can flatter a compression style. One map style measured −21% on
74
92
  this fixture because every tool name carried a `namespace_op` prefix to factor out.
@@ -195,7 +213,7 @@ the tool name that compression takes away.
195
213
 
196
214
  <picture>
197
215
  <source media="(prefers-color-scheme: dark)" srcset="docs/img/reliability-dark.svg">
198
- <img src="docs/img/reliability-light.svg" alt="Task completion by level-3 map style and provider, showing bare names failing on grok-4.5">
216
+ <img src="docs/img/reliability-light.svg" alt="Task completion by level-3 map style and provider. Bare names fail on grok-4.5; that style and the terse-description style were removed in 0.2.0 and are marked as such">
199
217
  </picture>
200
218
 
201
219
  The model doesn't lose the ability to choose — it converts a recall problem into a retrieval
@@ -203,6 +221,10 @@ problem and looks up what it needs. The default map style exists because of the
203
221
  bare tool names failed on `grok-4.5` **deterministically**, 3 of 3 attempts on one scenario,
204
222
  answering with zero tool calls and no error raised. Naming the required arguments fixed it.
205
223
 
224
+ Two of the four rows are marked **(removed)**: those styles were deleted in 0.2.0 and you
225
+ cannot select them. They are charted anyway because they *are* the evidence for the
226
+ default — deleting the losing arms would delete the reason.
227
+
206
228
  ### How we found the cost story, and got it wrong twice
207
229
 
208
230
  <picture>
@@ -232,6 +254,11 @@ tried and removed in 0.2.0 because they were smaller and still worse.
232
254
 
233
255
  ### The whole idea, in plain English
234
256
 
257
+ <picture>
258
+ <source media="(prefers-color-scheme: dark)" srcset="docs/img/metaphor-dark.svg">
259
+ <img src="docs/img/metaphor-light.svg" alt="Three panels. Level 0: the full menu, 149 tools on the wire, nothing saved, kitchen checks the order. Level 1: the same menu with prose cut, 149 tools on the wire, 45.2% smaller, kitchen still checks the order. Level 3: a small numbered card and one waiter, 2 tools on the wire, 96.5% smaller, toolgz checks the order instead of the provider.">
260
+ </picture>
261
+
235
262
  Your tool definitions are a **menu** handed to the model at the start of every single
236
263
  conversation. It is long, and most of it is flowery prose about each dish.
237
264
 
@@ -256,7 +283,7 @@ So: **level 1 is smaller. Level 3 is much smaller and you take over order-checki
256
283
  compress(myTools) // level 1. ALWAYS — 2 tools or 500.
257
284
  compress(myTools, { level: 3 }) // level 3, because you asked for it
258
285
 
259
- const { level } = recommendLevel(myTools); // just advice: returns 1 or 3
286
+ const { level } = recommendLevel(myTools); // just advice: returns 0, 1 or 3
260
287
  compress(myTools, { level }); // now it's 3, because you passed it in
261
288
  ```
262
289
 
@@ -286,7 +313,7 @@ saving.
286
313
 
287
314
  ### The levels in full
288
315
 
289
- Ask the library. It returns 1 or 3, never 2, and explains itself:
316
+ Ask the library. It returns 0, 1 or 3 never 2 and explains itself:
290
317
 
291
318
  ```ts
292
319
  import { recommendLevel } from "toolgz";
@@ -421,6 +448,10 @@ than a bug report from you.
421
448
  npx tsx examples/01-minimal.ts
422
449
  ```
423
450
 
451
+ **[`examples/README.md`](examples/README.md) walks through all five** with their real
452
+ output and what each one is trying to teach — start there if you would rather read than
453
+ run.
454
+
424
455
  | File | Shows |
425
456
  |---|---|
426
457
  | [`01-minimal.ts`](examples/01-minimal.ts) | the smallest useful thing: `recommendLevel` → `compress` → `resolve` |
@@ -949,13 +980,21 @@ Returns:
949
980
  > Use it as a local signal. For anything you publish, measure with your provider's token
950
981
  > counter. `originalChars` and `compressedChars` are on `stats` for the raw counts.
951
982
  >
952
- > A negative value is possible and correct: on a very small tool set, level 1's signature
953
- > line can exceed the per-property descriptions it strips.
983
+ > A negative value is possible and correct. Level 1 pays for a signature line and gets
984
+ > paid by the prose it strips, so on tools whose descriptions are already terse it comes
985
+ > out **~15% larger** — `recommendLevel` returns 0 in that case rather than recommending
986
+ > it. Even level 0 reports **−0.6%**: `c.tools` is Anthropic-shaped, and `input_schema` is
987
+ > one character longer than the `inputSchema` most callers pass in. Structural, not waste.
954
988
  | `encodeCallForTest(name, args)` | `→ {name, args}` | build the raw call a model would emit; test aid |
955
989
 
956
990
  ### `recommendLevel(tools, namespaceOf?) → Recommendation`
957
991
 
958
- `{ level, reason, toolCount, namespaceCount, opsPerNamespace }`. Returns 1 or 3, never 2.
992
+ `{ level, reason, toolCount, namespaceCount, opsPerNamespace }`. Returns 0, 1 or 3, never 2.
993
+
994
+ **`level: 0` means this library has nothing to offer your tool set.** Level 1 would
995
+ inflate it — terse descriptions leave no prose to strip, so the signature line is pure
996
+ addition — and the block is too small for level 3's trade to be worth it. Telling you that
997
+ is more useful than recommending a transform that makes the payload bigger.
959
998
 
960
999
  **It advises; it does not act.** Pass the answer back in yourself —
961
1000
  `compress(tools, { level })`. Calling `compress(tools)` alone is level 1 regardless of
@@ -44,6 +44,11 @@ export type Recommendation = {
44
44
  * there unless the caller passes a level explicitly — level 3 trades away the
45
45
  * provider's own schema enforcement, and that is not a trade to make on a
46
46
  * caller's behalf because their tool array grew.
47
+ *
48
+ * Returns 0, 1 or 3 — never 2. **0 means "this library has nothing to offer
49
+ * here"**: level 1 would inflate the block, and it is too small for level 3's
50
+ * trade to be worth it. Saying so is more useful than recommending a transform
51
+ * that makes the payload bigger.
47
52
  */
48
53
  export declare function recommendLevel(tools: Tool[], namespaceOf?: (n: string) => {
49
54
  ns: string;
package/dist/recommend.js CHANGED
@@ -38,6 +38,11 @@ import { compress } from "./index.js";
38
38
  * there unless the caller passes a level explicitly — level 3 trades away the
39
39
  * provider's own schema enforcement, and that is not a trade to make on a
40
40
  * caller's behalf because their tool array grew.
41
+ *
42
+ * Returns 0, 1 or 3 — never 2. **0 means "this library has nothing to offer
43
+ * here"**: level 1 would inflate the block, and it is too small for level 3's
44
+ * trade to be worth it. Saying so is more useful than recommending a transform
45
+ * that makes the payload bigger.
41
46
  */
42
47
  export function recommendLevel(tools, namespaceOf = defaultNamespaceOf) {
43
48
  const namespaces = new Set(tools.map((t) => namespaceOf(t.name).ns));
@@ -53,7 +58,8 @@ export function recommendLevel(tools, namespaceOf = defaultNamespaceOf) {
53
58
  // real MCP tools the ratio is a stable ~2.1 chars/token (1.98 at 10 tools, 2.15 at
54
59
  // 149), so dividing gives a fair local estimate with no API call.
55
60
  const CHARS_PER_TOKEN = 2.1;
56
- const l1Tokens = Math.round(compress(tools, { level: 1 }).stats.compressedChars / CHARS_PER_TOKEN);
61
+ const l1 = compress(tools, { level: 1 });
62
+ const l1Tokens = Math.round(l1.stats.compressedChars / CHARS_PER_TOKEN);
57
63
  // The old heuristic gated level 3 on `opsPerNamespace >= 4`. That is a LEVEL-2
58
64
  // question: level 2 pays dispatcher overhead per namespace, so its shape matters
59
65
  // there. Level 3 uses one flat dispatcher and does not care about namespaces at all.
@@ -73,10 +79,30 @@ export function recommendLevel(tools, namespaceOf = defaultNamespaceOf) {
73
79
  // 4,000 threshold for pushing 14-tool sets into dispatcher mode; they were right.
74
80
  const THRESHOLD_TOKENS = 10000;
75
81
  if (l1Tokens < THRESHOLD_TOKENS) {
82
+ // Level 1 is not unconditionally free, and this function used to assume it was.
83
+ //
84
+ // Level 1 saves by truncating each description to its first sentence and stripping
85
+ // per-property prose from the schema, and it spends by prepending a signature line.
86
+ // On a real MCP catalogue the prose dominates and it nets ~45%. On tools whose
87
+ // descriptions are already one short sentence there is nothing to strip, the
88
+ // signature is pure addition, and level 1 comes out ~15% LARGER than doing nothing.
89
+ //
90
+ // Reported by an external reviewer at 25-1,000 generated tools; they read it as
91
+ // correct behaviour for level 1, which it is. What is not correct is *recommending*
92
+ // it: a recommendation that inflates the payload is worse than no recommendation.
93
+ // So say so, and name level 3 as the real alternative rather than implying the
94
+ // choice is between level 1 and nothing.
95
+ if (l1.stats.savedPct <= 0) {
96
+ return {
97
+ ...base,
98
+ level: 0,
99
+ reason: `Level 1 would make this block ${Math.abs(l1.stats.savedPct).toFixed(1)}% LARGER, not smaller — your descriptions are already terse, so there is no prose to strip and level 1's signature line is pure overhead. Leave the tools alone. If you want the room anyway, level 3 does compress this (${compress(tools, { level: 3 }).stats.savedPct.toFixed(1)}%), at the cost of the provider's own argument validation; at ~${l1Tokens.toLocaleString()} tokens that trade is usually not worth it.`,
100
+ };
101
+ }
76
102
  return {
77
103
  ...base,
78
104
  level: 1,
79
- reason: `The tool block is only ~${l1Tokens.toLocaleString()} tokens at level 1. Level 3 would be smaller, but not by enough to be worth giving up the provider's own argument validation, which level 1 keeps. Reach for level 3 when the block is large enough that reclaiming it changes what fits in your context.`,
105
+ reason: `The tool block is only ~${l1Tokens.toLocaleString()} tokens at level 1, which saves ${l1.stats.savedPct.toFixed(1)}%. Level 3 would be smaller, but not by enough to be worth giving up the provider's own argument validation, which level 1 keeps. Reach for level 3 when the block is large enough that reclaiming it changes what fits in your context.`,
80
106
  };
81
107
  }
82
108
  return {
package/llms.txt ADDED
@@ -0,0 +1,211 @@
1
+ # toolgz
2
+
3
+ > Compresses LLM tool definitions to reclaim context window. Zero runtime dependencies,
4
+ > TypeScript, Apache-2.0. Works with any model and any framework: it transforms a tool
5
+ > array before you send it, and translates the model's call back afterwards.
6
+
7
+ **This file is written for a coding agent integrating toolgz into a project.** It is
8
+ prescriptive on purpose. If you are contributing to toolgz itself, read `AGENTS.md` in the
9
+ repository instead — different audience, different rules.
10
+
11
+ Installed copy lives at `node_modules/toolgz/llms.txt`. Full prose docs: `README.md`.
12
+
13
+ ---
14
+
15
+ ## The whole integration, correctly
16
+
17
+ ```ts
18
+ import { compress, recommendLevel, forAnthropic } from "toolgz";
19
+
20
+ // 1. Decide the level ONCE, at startup — not per request.
21
+ const { level } = recommendLevel(myTools);
22
+ const c = compress(myTools, { level });
23
+
24
+ // 2. Send c.tools instead of your tool array, and append the preamble to your system prompt.
25
+ const { tools, system } = forAnthropic(c);
26
+
27
+ // 3. Translate every tool call back before you dispatch.
28
+ for (const block of res.content.filter((b) => b.type === "tool_use")) {
29
+ const r = c.resolve(block.name, block.input);
30
+ if (r.kind === "call") {
31
+ const out = await myDispatch(r.name, r.args); // real name, real args
32
+ results.push({ type: "tool_result", tool_use_id: block.id, content: JSON.stringify(out) });
33
+ } else if (r.kind === "meta") {
34
+ // toolgz answered a lookup itself. DO NOT dispatch. Return its text to the model.
35
+ results.push({ type: "tool_result", tool_use_id: block.id, content: r.result });
36
+ } else {
37
+ // Recoverable by design: hand the message back and the model retries.
38
+ results.push({ type: "tool_result", tool_use_id: block.id, content: `Error: ${r.message}`, is_error: true });
39
+ }
40
+ }
41
+ ```
42
+
43
+ Those three steps are the entire contract. `myDispatch` receives exactly what it received
44
+ before toolgz existed.
45
+
46
+ ## Rules that are not optional
47
+
48
+ 1. **`resolve()` every tool call.** At level 3 the model emits opaque codes (`t(f="a0")`),
49
+ so dispatching `block.name` directly will fail or dispatch the wrong tool. Route
50
+ everything through `resolve()` even at level 0, so the level becomes a one-line change.
51
+
52
+ 2. **Handle all three `kind` values.** `call` → dispatch. `meta` → toolgz already answered
53
+ a lookup; return `r.result` to the model and dispatch nothing. `error` → return
54
+ `r.message` to the model as an error tool_result; do **not** throw. The `error` path is
55
+ the recovery mechanism, not a failure.
56
+
57
+ 3. **Send the system preamble.** At level 3 the tool map lives in the system prompt. Drop
58
+ it and the model has no idea what any code means. It is `""` at levels 0–1, so
59
+ appending it unconditionally is always safe.
60
+
61
+ 4. **`compress()` never changes level on its own.** `compress(tools)` is level 1 forever,
62
+ 2 tools or 500. `recommendLevel()` *advises*; you pass its answer in as `{ level }`.
63
+ Deliberate: level 3 moves argument validation from the provider to toolgz, which is not
64
+ a change to make behind a caller's back.
65
+
66
+ 5. **Leave `validate` on** (the default). Levels 2–3 give up the provider's constrained
67
+ decoding, so toolgz validating against the original schema is what catches malformed
68
+ arguments. On a weak model, disabling it converts roughly half of all runs from a
69
+ recovered retry into a bad dispatch.
70
+
71
+ 6. **Compress once, reuse the result.** `compress()` is deterministic — same tools in,
72
+ byte-identical payload out — so the output is prompt-cache friendly. Recomputing per
73
+ request is wasted work; changing the tool list mid-conversation invalidates the cache
74
+ and strands codes the model has already seen.
75
+
76
+ ## Choosing a level
77
+
78
+ Ask, don't guess: `recommendLevel(myTools)` returns `{ level, reason }` — **0, 1 or 3**,
79
+ never 2.
80
+
81
+ `level: 0` means *leave the tools alone*: level 1 would make this block larger, and it is
82
+ too small for level 3 to be worth giving up provider-side validation. Level 1 is not
83
+ unconditionally free — it saves by stripping prose, so on tools whose descriptions are
84
+ already one short sentence it adds ~15% instead. Do not override a 0 without measuring.
85
+
86
+ | Level | Wire | Names | Provider schema enforcement | When |
87
+ |:-:|---|:-:|:-:|---|
88
+ | 0 | unchanged | real | yes | A/B control only |
89
+ | **1** | one native tool each, flattened schemas | real | **yes** | **default.** Reclaims 13–39% on verbose real-world schemas; **inflates ~15% on already-terse ones** |
90
+ | 2 | one tool per namespace | real | no | only if you need real op names on the wire |
91
+ | **3** | one dispatcher + one lookup | codes | no | **large tool sets.** Up to ~85–96% |
92
+
93
+ The switch is the **size** of the level-1 block, not the tool count — threshold 10,000
94
+ tokens (~5% of a 200k window). A tool is 20 to 460 tokens depending on schema verbosity,
95
+ so 40 real MCP tools cross it while 72 one-parameter tools do not.
96
+
97
+ Level 2 is dominated by level 3 on accuracy (six times the malformed arguments) and is
98
+ never recommended. It stays in the API for callers who need real operation names.
99
+
100
+ ## Provider adapters — pick the right one
101
+
102
+ ```ts
103
+ forAnthropic(c, { cache?, ttl? }) // → { tools, system } Messages API
104
+ forOpenAI(c) // → { tools, systemPreamble } /v1/chat/completions
105
+ forOpenAIResponses(c) // → { tools, systemPreamble } /v1/responses
106
+ forGemini(c) // → { tools, systemPreamble } generateContent
107
+ ```
108
+
109
+ Two mistakes that produce confusing provider errors:
110
+
111
+ - **OpenAI has two incompatible tool shapes.** `/v1/chat/completions` wants nested
112
+ `{type, function:{…}}`; `/v1/responses` wants flat `{type, name, …}`. If you set
113
+ reasoning effort on a GPT-5.x model you must use `/v1/responses`, therefore
114
+ `forOpenAIResponses`. Using the wrong adapter is rejected at the API.
115
+ - **Gemini returns ONE wrapper object,** not one entry per tool. Read
116
+ `tools[0].functionDeclarations.length`; `tools.length` is always 1.
117
+
118
+ `forGemini` also repairs three schema forms Gemini rejects with a 400: an array-valued
119
+ `type`, `type: "array"` with no `items`, and a non-string `enum`. Constraints stripped to
120
+ satisfy Gemini are still enforced by `resolve()` against your original schema — nothing is
121
+ silently dropped.
122
+
123
+ xAI is OpenAI-compatible: use `forOpenAI` with `baseURL: "https://api.x.ai/v1"`. **xAI
124
+ caps tool arrays at 350**; other providers accept 1,200+. Level 3 sends 2 either way.
125
+
126
+ ## Model-specific behaviour
127
+
128
+ Pass an exact model id and toolgz uses the best *measured* map style for it:
129
+
130
+ ```ts
131
+ compress(myTools, { level: 3, model: "gpt-5.6-sol", objective: "cost" });
132
+ ```
133
+
134
+ | Model | `objective: "cost"` | Measured vs default |
135
+ |---|---|---:|
136
+ | `gpt-5.6-sol` | `explicit` | −20.7% |
137
+ | `gemini-3.1-pro-preview` | `explicit` | −15.4% |
138
+ | `claude-opus-5` | `explicit` | −9.0% |
139
+ | `grok-4.5` | *(no entry)* | `explicit` is **+13.2%** there |
140
+
141
+ - Model ids are **exact, never families**. A result for `gpt-5.6-sol` says nothing about
142
+ `gpt-5.7`. An unknown model gets the conservative default — absence of evidence, not a
143
+ prediction.
144
+ - The default objective is `occupancy`, which has **no** entries: nothing beat the default
145
+ by more than 3.1% on that axis, under the 5% effect-size floor. Only `cost` has rows.
146
+ - This is the one thing the library picks for you, and only when you pass `model`. A map
147
+ style is a pure encoding choice, so a better one cannot change results. The *level*
148
+ can — which is why that stays explicit.
149
+ - Do not set `mapStyle` by hand unless you have measured your own workload. Prefer
150
+ `model` + `objective`.
151
+
152
+ ## MCP servers
153
+
154
+ `tools/list` returns `{ name, description, inputSchema }` per tool — exactly what
155
+ `compress()` accepts. **There is no adapter layer.** Merge the arrays from every server
156
+ and pass them in.
157
+
158
+ `input_schema` and `inputSchema` are both accepted, so Anthropic-shaped and MCP-shaped
159
+ tools can be mixed.
160
+
161
+ Watch for name collisions when merging catalogues from independent servers. If two tool
162
+ names normalise identically, toolgz refuses to alias either one rather than guess — the
163
+ map code still works.
164
+
165
+ ## API surface
166
+
167
+ ```ts
168
+ compress(tools, options?) → CompressResult
169
+ recommendLevel(tools, namespaceOf?) → { level, reason, toolCount, namespaceCount, opsPerNamespace }
170
+ selectMapStyle(options) → { mapStyle, requestedMapStyle?, fallbackReason? } // pure
171
+ ```
172
+
173
+ `CompressOptions`: `level`, `mapStyle`, `namespaceOf`, `aliasOf`, `searchLimit`,
174
+ `validate`, `model`, `objective`.
175
+
176
+ `CompressResult`: `tools`, `systemPreamble`, `cachePreamble`, `resolve()`, `codeFor()`,
177
+ `encodeCallForTest()`, `stats`.
178
+
179
+ `stats`: `level`, `mapStyle`, `requestedMapStyle`, `fallbackReason`, `toolCount`,
180
+ `wireToolCount`, `originalChars`, `compressedChars`, `savedPct`.
181
+
182
+ `namespaceOf` must return `{ ns, op }`, **not** a bare string. Returning a string throws
183
+ with a corrected example — it used to fail silently and produce a plausible-looking but
184
+ wrong map.
185
+
186
+ ## Things to tell the user, not assume
187
+
188
+ - **`savedPct` is a CHARACTER saving**, a few points optimistic against tokens. Providers
189
+ charge a fixed framing cost per tool definition that character counting cannot see. For
190
+ any published figure, measure with the provider's own token counter.
191
+ - **Never use `tiktoken` to count Claude tokens** — it is OpenAI's tokenizer and is wrong
192
+ for Claude by 15–20%+. Use Anthropic's `count_tokens`.
193
+ - **Token saving ≠ cost saving.** Prompt caching already makes tool tokens cheap; the claim
194
+ here is context-window *occupancy*. Cost follows by a variable amount, and on OpenAI —
195
+ where reasoning output dominates the bill — it was as little as 7%.
196
+ - **Level 3 costs turns.** Roughly 0.3–1.7 lookup calls per task, about half an extra turn.
197
+ If latency matters more than context, stay at level 1.
198
+ - **Below the frontier tier, argument formatting degrades at level 3.** On Haiku 4.5, 17 of
199
+ 30 runs produced a malformed argument — all caught by `validate` and recovered, no task
200
+ lost, but that is the known edge. Tool *choice* did not degrade.
201
+
202
+ ## Do not
203
+
204
+ - Do not dispatch `block.name` without `resolve()`.
205
+ - Do not throw on `kind: "error"` — return it to the model.
206
+ - Do not dispatch on `kind: "meta"`.
207
+ - Do not omit `systemPreamble` at level 3.
208
+ - Do not set `validate: false` to silence a malformed-argument error; fix the schema.
209
+ - Do not call `compress()` inside the request loop.
210
+ - Do not report `savedPct` as a token or cost saving.
211
+ - Do not assume a level was chosen for you.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolgz",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "description": "Compress LLM tool definitions to reclaim context window. Measured across Anthropic, OpenAI, Google and xAI.",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {
@@ -12,7 +12,8 @@
12
12
  "test:live": "vitest run tests/integration",
13
13
  "bench:tier1": "tsx -r dotenv/config bench/harness/run-multi.ts --provider=all --suite=real --variants --reps=1 --scenario=real-daily-vs-weekly,real-overwrite-vs-append,real-status-vs-result,real-rollup-vs-detail",
14
14
  "bench:tier2": "tsx -r dotenv/config bench/harness/run-multi.ts --provider=all --suite=real --variants --reps=2 --scenario=real-daily-vs-weekly,real-overwrite-vs-append,real-status-vs-result,real-rollup-vs-detail,real-two-step-scheduling,real-summary-vs-details",
15
- "bench:tier3": "tsx -r dotenv/config bench/harness/run-multi.ts --provider=all --suite=real --variants --reps=3"
15
+ "bench:tier3": "tsx -r dotenv/config bench/harness/run-multi.ts --provider=all --suite=real --variants --reps=3",
16
+ "img:metaphor": "tsx bench/metaphor.ts"
16
17
  },
17
18
  "keywords": [
18
19
  "llm",
@@ -45,6 +46,7 @@
45
46
  "files": [
46
47
  "dist",
47
48
  "README.md",
49
+ "llms.txt",
48
50
  "LICENSE",
49
51
  "NOTICE"
50
52
  ],