toolgz 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/README.md +533 -11
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  <h1>toolgz</h1>
2
2
 
3
- <p><strong>Your agent spends 30–70k tokens of context on tool definitions before the user types a word. toolgz gets ~80% of it back.</strong></p>
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
+
5
+ <p><em>On a large tool set, at level 3. The safe default (level 1) reclaims 13–32% and gives up nothing — <code>recommendLevel()</code> picks for you.</em></p>
4
6
 
5
7
  <p>
6
8
  <a href="#measured-results">420-run cross-provider sweep</a> ·
@@ -29,12 +31,18 @@ Reclaiming the room is what this does.
29
31
  ## The fix, in three lines
30
32
 
31
33
  ```ts
32
- import { compress, forAnthropic } from "toolgz";
34
+ import { compress, recommendLevel, forAnthropic } from "toolgz";
33
35
 
34
- const c = compress(myTools); // your existing MCP/SDK tool array
36
+ const { level } = recommendLevel(myTools); // 1 for small sets, 3 for large ones
37
+ const c = compress(myTools, { level }); // your existing MCP/SDK tool array
35
38
  const { tools, system } = forAnthropic(c); // send these instead
36
39
  ```
37
40
 
41
+ `compress(myTools)` with no `level` gives you **level 1** — safe, native tool calling,
42
+ provider schema enforcement intact, and 13–32% smaller. The 71–85% figures below are
43
+ **level 3**, which is what `recommendLevel` returns once a tool set is big enough to
44
+ amortise the dispatcher. Ask for it explicitly with `{ level: 3 }` if you prefer.
45
+
38
46
  Then translate the model's call back before you dispatch:
39
47
 
40
48
  ```ts
@@ -75,6 +83,9 @@ claim about real deployments.
75
83
  <img src="docs/img/savings-light.svg" alt="Prompt tokens saved versus uncompressed tool definitions, by compression level, for each of four providers">
76
84
  </picture>
77
85
 
86
+ **All figures below are level 3** (`minified-plus`, the shipped default map style).
87
+ Level 1 on the same sweep saves 13–32%; level 2 is dominated by level 3.
88
+
78
89
  | Provider | Model | Tool block | Prompt tokens | Latency | Tasks |
79
90
  |---|---|---:|---:|---:|:-:|
80
91
  | Anthropic | `claude-opus-5` | 9,242 → **1,284** | 30,817 → **4,628** (**−85%**) | 15.0s → **12.1s** | 15/15 |
@@ -273,15 +284,526 @@ style in 0.2.0.
273
284
 
274
285
  ---
275
286
 
276
- ## Documentation
287
+ ## Using it: the full guide
288
+
289
+ Everything below was a separate `docs/GUIDE.md`. It is inline now, deliberately: the
290
+ same claims lived in both files and drifted apart — the README advertised level 3's
291
+ savings while its own quick-start example used level 1. One document cannot contradict
292
+ itself.
293
+
294
+ Reference material that is generated or historical stays separate, because it is not
295
+ hand-maintained prose:
296
+
297
+ - **[docs/RESULTS.md](docs/RESULTS.md)** — every benchmark round, the raw evidence log
298
+ - **[docs/BEFORE-AFTER.md](docs/BEFORE-AFTER.md)** — generated by executing the library
299
+ - **[docs/RELEASING.md](docs/RELEASING.md)** — release process
300
+
301
+ ---
302
+
303
+ ## 1. The agent loop, in full
304
+
305
+ A complete, working Anthropic loop. The only additions to a normal loop are the
306
+ `compress()` call at the top and the `resolve()` call before dispatch.
307
+
308
+ ```ts
309
+ import Anthropic from "@anthropic-ai/sdk";
310
+ import { compress, forAnthropic } from "toolgz";
311
+
312
+ const client = new Anthropic();
313
+ const SYSTEM = "You are a helpful operations agent. Use the tools available.";
314
+
315
+ // 1. Compress once, outside the loop. It is pure and deterministic.
316
+ const c = compress(myTools, { level: 3 });
317
+ const { tools, system } = forAnthropic(c);
318
+
319
+ export async function run(userMessage: string) {
320
+ const messages: any[] = [{ role: "user", content: userMessage }];
321
+
322
+ for (let turn = 0; turn < 12; turn++) {
323
+ const res = await client.messages.create({
324
+ model: "claude-opus-5",
325
+ max_tokens: 8000,
326
+ system: [{ type: "text", text: SYSTEM }, ...(system ?? [])],
327
+ tools: tools as any,
328
+ messages,
329
+ });
330
+
331
+ const calls = res.content.filter((b: any) => b.type === "tool_use");
332
+ if (!calls.length) return res; // model is done
333
+
334
+ // 2. Echo the assistant turn back verbatim. On Anthropic this matters:
335
+ // thinking blocks must be returned unchanged or the model re-reasons.
336
+ messages.push({ role: "assistant", content: res.content });
337
+
338
+ const results: any[] = [];
339
+ for (const call of calls) {
340
+ const r = c.resolve(call.name, (call as any).input);
341
+
342
+ if (r.kind === "call") {
343
+ const output = await myDispatch(r.name, r.args); // your real handler
344
+ results.push({
345
+ type: "tool_result",
346
+ tool_use_id: call.id,
347
+ content: JSON.stringify(output),
348
+ });
349
+ } else if (r.kind === "meta") {
350
+ // The model asked toolgz a lookup question. Answer it; don't dispatch.
351
+ results.push({
352
+ type: "tool_result",
353
+ tool_use_id: call.id,
354
+ content: r.result,
355
+ });
356
+ } else {
357
+ // Bad arguments or an unknown code. The message is written for the
358
+ // model to read — hand it straight back and let it retry.
359
+ results.push({
360
+ type: "tool_result",
361
+ tool_use_id: call.id,
362
+ content: `Error: ${r.message}`,
363
+ is_error: true,
364
+ });
365
+ }
366
+ }
367
+
368
+ messages.push({ role: "user", content: results });
369
+ }
370
+ }
371
+ ```
372
+
373
+ Three rules that matter:
374
+
375
+ 1. **Call `compress()` once**, outside the loop. Calling it per turn is wasteful
376
+ and, if you vary the options, breaks prompt caching.
377
+ 2. **Never dispatch on `meta` or `error`.** Only `kind === "call"` is a real
378
+ tool invocation.
379
+ 3. **Feed errors back rather than throwing.** Recovery is the design; the error
380
+ strings are written for the model.
381
+
382
+ ---
383
+
384
+ ## 2. Handling the three outcomes
385
+
386
+ `resolve()` returns a discriminated union. Handle all three.
387
+
388
+ ```ts
389
+ const r = c.resolve(rawName, rawArgs);
390
+
391
+ switch (r.kind) {
392
+ case "call":
393
+ // r.name → the original tool name, e.g. "github_create_issue"
394
+ // r.args → validated against your original schema
395
+ await myDispatch(r.name, r.args);
396
+ break;
397
+
398
+ case "meta":
399
+ // The model used a toolgz lookup tool (q / describe_op).
400
+ // r.result is text to return as the tool result. Do not dispatch.
401
+ reply(r.result);
402
+ break;
403
+
404
+ case "error":
405
+ // r.message → model-readable: names the tool, the parameter, the fix
406
+ // r.recoverable → true when retrying can plausibly succeed
407
+ reply(r.message, { isError: true });
408
+ break;
409
+ }
410
+ ```
411
+
412
+ What produces each:
413
+
414
+ | Outcome | Cause | You should |
415
+ |---|---|---|
416
+ | `call` | valid invocation | dispatch it |
417
+ | `meta` | model asked for a definition or searched the map | return `r.result` as the tool result |
418
+ | `error` | missing/unknown/wrong-typed argument, or an unknown code | return `r.message` with an error flag |
419
+
420
+ Argument validation runs against your **original** schema, not the compressed
421
+ one, so an `error` here means the call genuinely would not have worked.
422
+
423
+ ---
424
+
425
+ ## 3. Provider setup
426
+
427
+ The core is provider-neutral. Adapters handle wire shape and cache placement.
428
+ They are pure functions and never mutate what you pass them.
429
+
430
+ ### Anthropic
431
+
432
+ ```ts
433
+ import { compress, forAnthropic } from "toolgz";
434
+
435
+ const c = compress(myTools, { level: 3 });
436
+ const { tools, system } = forAnthropic(c); // or { ttl: "1h" }
437
+
438
+ await client.messages.create({
439
+ model: "claude-opus-5",
440
+ max_tokens: 8000,
441
+ system: [{ type: "text", text: SYSTEM }, ...(system ?? [])],
442
+ tools,
443
+ messages,
444
+ });
445
+ ```
446
+
447
+ `forAnthropic` places exactly one `cache_control` breakpoint on the last
448
+ eligible tool, and skips any tool carrying `defer_loading` because the API
449
+ rejects that combination.
450
+
451
+ ### OpenAI
452
+
453
+ OpenAI has **two endpoints with two different tool shapes**, and toolgz has an
454
+ adapter for each. Pick by whether you want reasoning.
455
+
456
+ **If you want tools *and* reasoning — use `/v1/responses`.** On the GPT-5.x
457
+ line, `/v1/chat/completions` refuses the combination outright:
458
+
459
+ ```
460
+ Function tools with reasoning_effort are not supported for gpt-5.6-sol in
461
+ /v1/chat/completions. To use function tools, use /v1/responses or set
462
+ reasoning_effort to 'none'.
463
+ ```
464
+
465
+ ```ts
466
+ import { compress, forOpenAIResponses } from "toolgz";
467
+
468
+ const c = compress(myTools, { level: 3 });
469
+ const { tools, systemPreamble } = forOpenAIResponses(c); // flat tool shape
470
+
471
+ const res = await client.responses.create({
472
+ model: "gpt-5.6-sol",
473
+ reasoning: { effort: "high" },
474
+ max_output_tokens: 8000,
475
+ tools,
476
+ input: [
477
+ { type: "message", role: "developer",
478
+ content: SYSTEM + (systemPreamble ? "\n\n" + systemPreamble : "") },
479
+ { type: "message", role: "user", content: userMessage },
480
+ ],
481
+ });
482
+ ```
483
+
484
+ Two things about `/v1/responses` that will bite you otherwise:
485
+
486
+ - History is a flat `input[]` list, not `messages`. A tool call round-trip is
487
+ `{type:"function_call", call_id, name, arguments}` followed by
488
+ `{type:"function_call_output", call_id, output}`.
489
+ - **Reasoning items must be echoed back** alongside tool outputs on the next
490
+ turn, or the model re-reasons from scratch every turn. Push the whole
491
+ `response.output` array back verbatim.
492
+
493
+ **If you don't need reasoning**, chat completions is fine and the tool shape is
494
+ the nested one:
495
+
496
+ ```ts
497
+ import { compress, forOpenAI } from "toolgz";
498
+
499
+ const { tools, systemPreamble } = forOpenAI(compress(myTools, { level: 3 }));
500
+
501
+ await client.chat.completions.create({
502
+ model: "gpt-5.6-sol",
503
+ messages: [
504
+ { role: "system", content: SYSTEM + (systemPreamble ? "\n\n" + systemPreamble : "") },
505
+ ...messages,
506
+ ],
507
+ tools,
508
+ });
509
+ ```
510
+
511
+ Either way, OpenAI's prefix caching is automatic with a ~1024-token floor, so
512
+ there is no breakpoint to place — just keep your prefix stable. Cached input
513
+ bills at roughly a tenth of the normal rate.
514
+
515
+ `forOpenAI` emits `{type:"function", function:{…}}`; `forOpenAIResponses` emits
516
+ the flat `{type:"function", name, …}`. Sending one shape to the other endpoint
517
+ is a validation error, so match them.
518
+
519
+ ### Google Gemini
520
+
521
+ ```ts
522
+ import { compress, forGemini } from "toolgz";
523
+
524
+ const c = compress(myTools, { level: 3 });
525
+ const { tools, systemPreamble } = forGemini(c);
526
+
527
+ await ai.models.generateContent({
528
+ model: "gemini-3.1-pro-preview",
529
+ contents,
530
+ config: {
531
+ systemInstruction: SYSTEM + (systemPreamble ? "\n\n" + systemPreamble : ""),
532
+ tools,
533
+ },
534
+ });
535
+ ```
536
+
537
+ `forGemini` strips the JSON Schema keywords Gemini rejects
538
+ (`additionalProperties`, `$schema`, `default`, `examples`) recursively.
539
+
540
+ ### xAI
541
+
542
+ xAI's API is OpenAI-compatible, so use `forOpenAI`:
543
+
544
+ ```ts
545
+ import OpenAI from "openai";
546
+ import { compress, forOpenAI } from "toolgz";
547
+
548
+ const client = new OpenAI({
549
+ apiKey: process.env.XAI_API_KEY,
550
+ baseURL: "https://api.x.ai/v1",
551
+ });
552
+ const { tools, systemPreamble } = forOpenAI(compress(myTools, { level: 3 }));
553
+ ```
554
+
555
+ > **Note:** xAI excludes reasoning tokens from `completion_tokens`. If you're
556
+ > tracking spend, add `usage.completion_tokens_details.reasoning_tokens`.
557
+
558
+ ---
559
+
560
+ ## 4. Prompt caching
561
+
562
+ **Read this even if you skip everything else.** Prompt caching is what makes the
563
+ per-turn cost of your tool block collapse, and it is easy to break by accident.
564
+
565
+ Caching is a **prefix match**. Any byte that changes invalidates everything after
566
+ it. Tool definitions render first, so a single reordered tool re-bills your whole
567
+ prompt.
568
+
569
+ `compress()` is built for this: it sorts tools by name and emits byte-identical
570
+ output for identical input. There is a test asserting it, and that test does not
571
+ get deleted.
277
572
 
278
- | | |
573
+ What breaks caching — check your own code for these:
574
+
575
+ | Mistake | Why it breaks |
279
576
  |---|---|
280
- | **[Complete guide](docs/GUIDE.md)** | Install working agent loop. Per-provider setup for all four, prompt caching, MCP aggregation, troubleshooting. Start here. |
281
- | **[Before / after](docs/BEFORE-AFTER.md)** | Generated, not illustrated. Both artifacts toolgz modifies, at every level. |
282
- | **[Full results](docs/RESULTS.md)** | Every number, the methodology, and what it does **not** establish. |
283
- | **[Announcement drafts](docs/ANNOUNCEMENT.md)** | Ready-to-post write-ups for HN and LinkedIn, plus claims *not* to make. |
284
- | **[Releasing](docs/RELEASING.md)** | Publishing to npm over OIDC, with no long-lived token. |
577
+ | `new Date().toISOString()` in the system prompt | prefix differs every request |
578
+ | a request id or UUID near the top | same |
579
+ | building tools from an unsorted `Set`/`Map` | serialization order drifts |
580
+ | calling `compress()` with different options per turn | different payload |
581
+ | adding or removing a tool mid-conversation | tool block is position 0 |
582
+
583
+ Verify it's working — don't assume:
584
+
585
+ ```ts
586
+ const res = await client.messages.create({ /* … */ });
587
+ console.log(res.usage.cache_read_input_tokens); // should be > 0 after turn 1
588
+ ```
589
+
590
+ If that stays `0` across turns with an identical prefix, something upstream is
591
+ varying. Diff two rendered request bodies to find it.
592
+
593
+ ---
594
+
595
+ ## 5. Composing with Anthropic's native tool search
596
+
597
+ Anthropic ships server-side tool search (`tool_search_tool_regex_20251119` plus
598
+ `defer_loading: true`). It defers *whole schemas*; toolgz shrinks *each schema*.
599
+ They're orthogonal and compose:
600
+
601
+ ```ts
602
+ const c = compress(myTools, { level: 1 });
603
+ const tools = [
604
+ { type: "tool_search_tool_regex_20251119", name: "tool_search_tool_regex" },
605
+ ...(c.tools as any[]).map((t, i) => (i < 5 ? t : { ...t, defer_loading: true })),
606
+ ];
607
+ ```
608
+
609
+ Two API constraints: at least one tool must stay non-deferred, and
610
+ `cache_control` cannot go on a deferred tool (`forAnthropic` handles the second).
611
+
612
+ **One measured caveat.** `defer_loading` hides tools until the model *elects to
613
+ search*. On Claude Opus 5 it elects reliably — 20/20 tasks. On Haiku 4.5 it
614
+ often does not: **6 of 30 tasks**, with four of five scenarios answered in a
615
+ single turn and zero tool calls. No error is raised; the request succeeds and the
616
+ answer is simply unaided.
617
+
618
+ A dispatcher doesn't share that failure mode, for a structural reason: `t` and
619
+ `q` are ordinary always-visible tools, so the model cannot forget to search —
620
+ searching is the only thing on offer. Deferred loading makes discovery
621
+ *optional*; a dispatcher makes it *the entry point*.
622
+
623
+ Rule of thumb: compose with native search on frontier models; prefer level 3
624
+ alone below that tier.
625
+
626
+ ---
627
+
628
+ ## 6. MCP servers
629
+
630
+ MCP tool definitions already match the input shape, so there's nothing to
631
+ convert:
632
+
633
+ ```ts
634
+ const { tools: mcpTools } = await mcpClient.listTools();
635
+ const c = compress(mcpTools, { level: 3 });
636
+ ```
637
+
638
+ Aggregating several servers is where the payoff is largest. Namespace the names
639
+ so the grouping is meaningful:
640
+
641
+ ```ts
642
+ const all = [];
643
+ for (const [serverName, client] of servers) {
644
+ const { tools } = await client.listTools();
645
+ all.push(...tools.map((t) => ({ ...t, name: `${serverName}_${t.name}` })));
646
+ }
647
+
648
+ const c = compress(all, { level: 3 });
649
+ ```
650
+
651
+ Default namespacing splits on the first `_` or `.`. Override it if your names
652
+ don't follow that convention:
653
+
654
+ ```ts
655
+ compress(all, {
656
+ level: 3,
657
+ namespaceOf: (name) => {
658
+ const [ns, ...rest] = name.split("::");
659
+ return { ns, op: rest.join("::") };
660
+ },
661
+ });
662
+ ```
663
+
664
+ ---
665
+
666
+ ## 7. Troubleshooting
667
+
668
+ **The model calls `t` or `q` and my dispatcher explodes.**
669
+ You're dispatching on every tool call. Only `kind === "call"` is real; `meta`
670
+ and `error` are toolgz's own tools and must be answered, not dispatched. See
671
+ [§5](#5-handling-the-three-outcomes).
672
+
673
+ **`resolve()` returns `error: unknown parameter "x"`.**
674
+ The model invented a parameter. Feed `r.message` back as an error tool result —
675
+ it names the accepted parameters and the model will retry. This is expected on
676
+ levels 2 and 3 and is exactly what validation is for.
677
+
678
+ **Lots of malformed arguments.**
679
+ First check you have not overridden `mapStyle` away from the default
680
+ `"name+required"` — on the current sweep that default produced **zero**
681
+ malformed arguments on all four providers, while the bare-name map still
682
+ produced them. If you are on the default and still seeing errors, try
683
+ `mapStyle: "signature"` (the model then has the optional parameters too), or
684
+ drop to level 1, which keeps provider-side schema enforcement.
685
+
686
+ The error messages are written to be actionable: a near-miss parameter name is
687
+ named explicitly ("You passed \"query\" — did you mean \"q\"? Rename it."), and a
688
+ wrong-case enum value shows the exact accepted spelling. Feed them straight back
689
+ and the retry usually lands first time.
690
+
691
+ **`cache_read_input_tokens` is always 0.**
692
+ Something in your prefix varies per request. See [§7](#7-prompt-caching).
693
+
694
+ **Gemini rejects my schema.**
695
+ Use `forGemini`, which strips the keywords Gemini won't accept. If a new one
696
+ appears, it's a one-line addition to the adapter.
697
+
698
+ **Savings look small.**
699
+ Check tool count and shape with `recommendLevel(myTools)`. Under ~15 tools
700
+ there's little to reclaim. Also check `c.stats`:
701
+
702
+ ```ts
703
+ console.log(c.stats);
704
+ // { level: 3, toolCount: 100, wireToolCount: 2,
705
+ // originalChars: 61461, compressedChars: 2211, savedPct: 96.4 }
706
+ ```
707
+
708
+ `savedPct` is a character-count proxy, not a token count — useful for a quick
709
+ sanity check, not for billing.
710
+
711
+ **I need the real token numbers.**
712
+ Count them with the provider's own endpoint. Never use `tiktoken` for Claude —
713
+ it's OpenAI's tokenizer and is wrong for Claude by 15–20%+.
714
+
715
+ ```ts
716
+ const { input_tokens } = await client.messages.countTokens({
717
+ model: "claude-opus-5",
718
+ tools: tools as any,
719
+ system: SYSTEM,
720
+ messages: [{ role: "user", content: "x" }],
721
+ });
722
+ ```
723
+
724
+ ---
725
+
726
+ ## 8. Benchmarking your own tools
727
+
728
+ Don't take our numbers for your workload — especially at level 3. The repo ships
729
+ the harness:
730
+
731
+ ```bash
732
+ git clone https://github.com/dperussina/toolgz
733
+ cd toolgz && npm install
734
+ cp .env.example .env # add your keys
735
+
736
+ npm test # 107 offline tests, no network, no cost
737
+
738
+ # one provider, one scenario, cheap
739
+ npx tsx bench/harness/run-multi.ts --provider=openai \
740
+ --scenario=acc-search-vs-list --reps=1
741
+
742
+ # the full comparison (costs money)
743
+ npx tsx bench/harness/run-multi.ts --provider=all --reps=3 --variants
744
+
745
+ npx tsx bench/analyze-multi.ts # per-arm table + ranking stability
746
+ ```
747
+
748
+ To use your own tools and tasks, edit `bench/fixtures/tools.ts` and
749
+ `bench/scenarios-accuracy.ts`. A scenario declares its tools, the prompt, and
750
+ the expected call, so grading is mechanical rather than model-judged.
751
+
752
+ Raw per-run JSONL lands in `bench/results/` and is committed, so every published
753
+ number can be recomputed rather than trusted.
754
+
755
+ ---
756
+
757
+ ## 9. API reference
758
+
759
+ ### `compress(tools, options?) → CompressResult`
760
+
761
+ | Option | Type | Default | Notes |
762
+ |---|---|---|---|
763
+ | `level` | `0 \| 1 \| 2 \| 3` | `1` | see [§3](#3-which-level-should-i-use) |
764
+ | `mapStyle` | `"name+required" \| "explicit" \| "signature"` | `"name+required"` | level 3 only |
765
+ | `namespaceOf` | `(name) => {ns, op}` | split on first `_`/`.` | levels 2–3 grouping |
766
+ | `aliasOf` | `(ns) => string` | identity | level 2 tool naming |
767
+ | `searchLimit` | `number` | `8` | max results from a `q` search |
768
+ | `validate` | `boolean` | `true` | **leave this on** |
769
+
770
+ Returns:
771
+
772
+ | Field | Type | Notes |
773
+ |---|---|---|
774
+ | `tools` | `unknown[]` | send these; Anthropic shape |
775
+ | `systemPreamble` | `string` | append to your system prompt; `""` at levels 0–1 |
776
+ | `cachePreamble` | `boolean` | whether the preamble should sit behind a breakpoint |
777
+ | `resolve(name, args)` | `→ Resolution` | translate a model call back |
778
+ | `codeFor(name)` | `→ string` | real name → level-3 code; throws below level 3 |
779
+ | `stats` | `CompressStats` | `level`, `toolCount`, `wireToolCount`, `savedPct`, … |
780
+
781
+ ### `recommendLevel(tools, namespaceOf?) → Recommendation`
782
+
783
+ `{ level, reason, toolCount, namespaceCount, opsPerNamespace }`. Returns 1 or 3.
784
+
785
+ ### Provider adapters
786
+
787
+ | Adapter | Endpoint | Tool shape |
788
+ |---|---|---|
789
+ | `forAnthropic(c, { cache?, ttl? })` → `{ tools, system }` | Messages API | Anthropic native; places one `cache_control` breakpoint |
790
+ | `forOpenAI(c)` → `{ tools, systemPreamble }` | `/v1/chat/completions` | nested `{type, function:{…}}` |
791
+ | `forOpenAIResponses(c)` → `{ tools, systemPreamble }` | `/v1/responses` | **flat** `{type, name, …}` — required for tools + reasoning |
792
+ | `forGemini(c)` → `{ tools, systemPreamble }` | `generateContent` | one `functionDeclarations` array |
793
+
794
+ ### Renderers (exported for tooling)
795
+
796
+ - `signatureLine(tool, nameOverride?)` → `"name(a,b?:x|y)"`
797
+ - `flattenSchema(schema)` → schema with prose and boilerplate removed
798
+ - `countSchemaTokensApprox(value)` → character-count proxy, **not** a token count
799
+
800
+ ---
801
+
802
+ Measured results and their limits: [RESULTS.md](RESULTS.md).
803
+ Methodology and repo conventions: [../AGENTS.md](../AGENTS.md).
804
+
805
+
806
+ ---
285
807
 
286
808
  ## Providers
287
809
 
@@ -329,7 +851,7 @@ and it does not get deleted.
329
851
  ## Development
330
852
 
331
853
  ```bash
332
- npm test # 131 tests, offline, no cost
854
+ npm test # 245 tests, offline, no cost
333
855
  npm run build # tsc → dist/ with .d.ts
334
856
 
335
857
  npx tsx bench/harness/run-multi.ts --provider=all --reps=3 --variants # costs money
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolgz",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
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": {