toolgz 0.1.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.
@@ -0,0 +1,143 @@
1
+ /** JSON Schema keys that carry no information the sampler can act on. */
2
+ const DROP_KEYS = new Set([
3
+ "description",
4
+ "$schema",
5
+ "title",
6
+ "examples",
7
+ "default",
8
+ "additionalProperties",
9
+ "$id",
10
+ "$comment",
11
+ ]);
12
+ /**
13
+ * Strip prose and boilerplate from a JSON Schema while preserving everything
14
+ * that constrains decoding: types, enums, required, item types, nesting.
15
+ *
16
+ * This is the single highest-leverage transform in the library — per-property
17
+ * `description` strings are the bulk of a real MCP tool definition.
18
+ */
19
+ export function flattenSchema(schema) {
20
+ if (!schema || typeof schema !== "object")
21
+ return { type: "object" };
22
+ const out = {};
23
+ for (const [k, v] of Object.entries(schema)) {
24
+ if (DROP_KEYS.has(k))
25
+ continue;
26
+ if (k === "properties" && v && typeof v === "object") {
27
+ out.properties = Object.fromEntries(Object.entries(v).map(([pk, pv]) => [
28
+ pk,
29
+ flattenProperty(pv),
30
+ ]));
31
+ }
32
+ else if (k === "items") {
33
+ out.items = flattenProperty(v);
34
+ }
35
+ else {
36
+ out[k] = v;
37
+ }
38
+ }
39
+ if (!out.type)
40
+ out.type = "object";
41
+ return out;
42
+ }
43
+ function flattenProperty(p) {
44
+ if (!p || typeof p !== "object")
45
+ return p;
46
+ if (Array.isArray(p))
47
+ return p.map(flattenProperty);
48
+ const out = {};
49
+ for (const [k, v] of Object.entries(p)) {
50
+ if (DROP_KEYS.has(k))
51
+ continue;
52
+ if (k === "properties" && v && typeof v === "object") {
53
+ out.properties = Object.fromEntries(Object.entries(v).map(([pk, pv]) => [
54
+ pk,
55
+ flattenProperty(pv),
56
+ ]));
57
+ }
58
+ else if (k === "items") {
59
+ out.items = flattenProperty(v);
60
+ }
61
+ else if (k === "anyOf" || k === "oneOf" || k === "allOf") {
62
+ out[k] = v.map(flattenProperty);
63
+ }
64
+ else {
65
+ out[k] = v;
66
+ }
67
+ }
68
+ return out;
69
+ }
70
+ /**
71
+ * `name(required, optional?:a|b, list?:string[])`
72
+ *
73
+ * A model reads this natively. A 400-token JSON Schema becomes one line.
74
+ */
75
+ export function signatureLine(tool, nameOverride) {
76
+ const schema = tool.schema ??
77
+ tool.inputSchema ??
78
+ tool.input_schema ??
79
+ {};
80
+ const props = schema.properties ?? {};
81
+ const required = new Set(schema.required ?? []);
82
+ const parts = Object.entries(props).map(([key, raw]) => {
83
+ const v = raw;
84
+ const opt = required.has(key) ? "" : "?";
85
+ let hint = "";
86
+ if (v?.enum)
87
+ hint = `:${v.enum.join("|")}`;
88
+ else if (v?.type === "array" && v.items?.type)
89
+ hint = `:${v.items.type}[]`;
90
+ return `${key}${opt}${hint}`;
91
+ });
92
+ return `${nameOverride ?? tool.name}(${parts.join(",")})`;
93
+ }
94
+ /**
95
+ * Aggressively shortened descriptor: first sentence, stop-words dropped,
96
+ * lowercased. Used by the `terse` map style, which trades legibility (and the
97
+ * real tool name) for a few tokens per line.
98
+ */
99
+ export function terseDescriptor(s) {
100
+ return firstSentence(s)
101
+ .replace(/\b(the|a|an|to|of|for|in|with|and|that|this)\b ?/gi, "")
102
+ .replace(/[.!?]+$/, "")
103
+ .replace(/\s+/g, " ")
104
+ .trim()
105
+ .toLowerCase();
106
+ }
107
+ /** First sentence only — the rest is almost always restatement. */
108
+ export function firstSentence(s) {
109
+ const m = s.match(/^(.*?[.!?])(\s|$)/s);
110
+ return (m ? m[1] : s).trim();
111
+ }
112
+ /** Cheap size proxy for stats. Not a token count — never used for billing. */
113
+ export function countSchemaTokensApprox(x) {
114
+ return JSON.stringify(x ?? "").length;
115
+ }
116
+ /** Default namespace split: `github_create_issue` → github / create_issue. */
117
+ export function defaultNamespaceOf(name) {
118
+ const i = name.search(/[_.]/);
119
+ if (i === -1)
120
+ return { ns: name, op: name };
121
+ return { ns: name.slice(0, i), op: name.slice(i + 1) };
122
+ }
123
+ export function normalize(tools, namespaceOf) {
124
+ const seen = new Set();
125
+ const out = tools.map((t) => {
126
+ if (!t?.name)
127
+ throw new Error("tool is missing a name");
128
+ if (seen.has(t.name))
129
+ throw new Error(`duplicate tool name: ${t.name}`);
130
+ seen.add(t.name);
131
+ const { ns, op } = namespaceOf(t.name);
132
+ return {
133
+ name: t.name,
134
+ description: t.description ?? "",
135
+ schema: t.inputSchema ?? t.input_schema ?? { type: "object" },
136
+ ns,
137
+ op,
138
+ };
139
+ });
140
+ // Deterministic ordering. Prefix stability is what makes prompt caching work;
141
+ // a caller iterating a Map or Set must not silently change the cache key.
142
+ return out.sort((a, b) => a.name.localeCompare(b.name));
143
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Nearest-name matching for argument-error messages.
3
+ *
4
+ * Motivation, from measurement: across 360 benchmark runs the single most
5
+ * common failure was a model passing `query` to a parameter named `q` — 14 of
6
+ * 18 rejections. Each one burned a turn, and on a reasoning model a wasted turn
7
+ * is a wasted round of thinking, so this is a cost problem as much as a
8
+ * correctness one.
9
+ *
10
+ * We deliberately do NOT auto-remap. Silently moving a value from `query` to
11
+ * `q` would be guessing about caller intent, and a wrong guess dispatches bad
12
+ * data instead of raising. Instead we name the likely fix so the retry lands
13
+ * first time.
14
+ */
15
+ /**
16
+ * Similarity in 0–100. Tuned so the cases we actually observed score high:
17
+ * `q`↔`query` (prefix), `pageSize`↔`per_page` (shared word), and unrelated
18
+ * names stay low enough to be filtered out.
19
+ */
20
+ export declare function similarity(a: string, b: string): number;
21
+ /**
22
+ * Closest candidate to `name`, or null when nothing is close enough to be worth
23
+ * suggesting. The floor matters: a bad suggestion is worse than none, because
24
+ * the model will act on it.
25
+ */
26
+ export declare function nearest(name: string, candidates: readonly string[], floor?: number): string | null;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Nearest-name matching for argument-error messages.
3
+ *
4
+ * Motivation, from measurement: across 360 benchmark runs the single most
5
+ * common failure was a model passing `query` to a parameter named `q` — 14 of
6
+ * 18 rejections. Each one burned a turn, and on a reasoning model a wasted turn
7
+ * is a wasted round of thinking, so this is a cost problem as much as a
8
+ * correctness one.
9
+ *
10
+ * We deliberately do NOT auto-remap. Silently moving a value from `query` to
11
+ * `q` would be guessing about caller intent, and a wrong guess dispatches bad
12
+ * data instead of raising. Instead we name the likely fix so the retry lands
13
+ * first time.
14
+ */
15
+ /** Levenshtein distance, iterative single-row. */
16
+ function editDistance(a, b) {
17
+ if (a === b)
18
+ return 0;
19
+ if (!a.length)
20
+ return b.length;
21
+ if (!b.length)
22
+ return a.length;
23
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
24
+ for (let i = 1; i <= a.length; i++) {
25
+ const row = [i];
26
+ for (let j = 1; j <= b.length; j++) {
27
+ row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
28
+ }
29
+ prev = row;
30
+ }
31
+ return prev[b.length];
32
+ }
33
+ /** `per_page` / `perPage` / `PerPage` all normalise to `perpage`. */
34
+ const norm = (s) => s.toLowerCase().replace(/[_\-\s]/g, "");
35
+ /** Split camelCase and snake_case into lowercase word parts. */
36
+ const words = (s) => s
37
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
38
+ .split(/[_\-\s]+/)
39
+ .filter(Boolean)
40
+ .map((w) => w.toLowerCase());
41
+ /**
42
+ * Similarity in 0–100. Tuned so the cases we actually observed score high:
43
+ * `q`↔`query` (prefix), `pageSize`↔`per_page` (shared word), and unrelated
44
+ * names stay low enough to be filtered out.
45
+ */
46
+ export function similarity(a, b) {
47
+ if (!a || !b)
48
+ return 0;
49
+ const na = norm(a);
50
+ const nb = norm(b);
51
+ if (na === nb)
52
+ return 100;
53
+ // A short name that prefixes a longer one is the `q`/`query` case.
54
+ const [short, long] = na.length <= nb.length ? [na, nb] : [nb, na];
55
+ if (long.startsWith(short) || long.endsWith(short))
56
+ return 85;
57
+ const wa = new Set(words(a));
58
+ const wb = new Set(words(b));
59
+ const shared = [...wa].filter((w) => wb.has(w) && w.length > 2).length;
60
+ if (shared) {
61
+ const union = new Set([...wa, ...wb]).size;
62
+ return 50 + Math.round((shared / union) * 30);
63
+ }
64
+ if (long.includes(short) && short.length >= 3)
65
+ return 60;
66
+ const dist = editDistance(na, nb);
67
+ const ratio = 1 - dist / Math.max(na.length, nb.length);
68
+ return Math.max(0, Math.round(ratio * 55));
69
+ }
70
+ /**
71
+ * Closest candidate to `name`, or null when nothing is close enough to be worth
72
+ * suggesting. The floor matters: a bad suggestion is worse than none, because
73
+ * the model will act on it.
74
+ */
75
+ export function nearest(name, candidates, floor = 55) {
76
+ let best = null;
77
+ let bestScore = 0;
78
+ for (const c of candidates) {
79
+ const s = similarity(name, c);
80
+ if (s > bestScore) {
81
+ bestScore = s;
82
+ best = c;
83
+ }
84
+ }
85
+ return bestScore >= floor ? best : null;
86
+ }
@@ -0,0 +1,11 @@
1
+ import type { NormalizedTool } from "../types.js";
2
+ /**
3
+ * Validate arguments against the *original* schema before dispatch.
4
+ *
5
+ * This matters most at levels 2 and 3: routing calls through a generic
6
+ * dispatcher moves argument checking out of the provider's constrained
7
+ * sampler and into here. Error strings are written for the model to read —
8
+ * they name the tool, the offending parameter, and where possible the exact
9
+ * fix, because a vague error costs another turn and another round of thinking.
10
+ */
11
+ export declare function validateArgs(tool: NormalizedTool, args: Record<string, any>): string | null;
@@ -0,0 +1,87 @@
1
+ import { nearest } from "./similar.js";
2
+ /**
3
+ * Validate arguments against the *original* schema before dispatch.
4
+ *
5
+ * This matters most at levels 2 and 3: routing calls through a generic
6
+ * dispatcher moves argument checking out of the provider's constrained
7
+ * sampler and into here. Error strings are written for the model to read —
8
+ * they name the tool, the offending parameter, and where possible the exact
9
+ * fix, because a vague error costs another turn and another round of thinking.
10
+ */
11
+ export function validateArgs(tool, args) {
12
+ const schema = tool.schema ?? {};
13
+ const props = schema.properties ?? {};
14
+ const required = schema.required ?? [];
15
+ const known = Object.keys(props);
16
+ const supplied = Object.keys(args);
17
+ // Keys the caller sent that the schema does not define. Computed first so a
18
+ // missing-required error can point at the likely rename.
19
+ const unknown = known.length ? supplied.filter((k) => !props[k]) : [];
20
+ for (const key of required) {
21
+ if (args[key] === undefined || args[key] === null || args[key] === "") {
22
+ // The `query` → `q` case: a required key is absent and an undefined key
23
+ // was supplied that looks like it. Suggest, never silently remap.
24
+ const guess = nearest(key, unknown);
25
+ const hint = guess
26
+ ? ` You passed "${guess}" — did you mean "${key}"? Rename it.`
27
+ : "";
28
+ return (`Missing required parameter "${key}" for ${tool.name}.` +
29
+ ` Required: ${required.join(", ")}.${hint}`);
30
+ }
31
+ }
32
+ if (unknown.length) {
33
+ const key = unknown[0];
34
+ const guess = nearest(key, known);
35
+ const hint = guess ? ` Did you mean "${guess}"?` : "";
36
+ return (`Unknown parameter "${key}" for ${tool.name}.` +
37
+ ` Accepted: ${known.join(", ")}.${hint}`);
38
+ }
39
+ for (const [key, value] of Object.entries(args)) {
40
+ const spec = props[key];
41
+ if (!spec)
42
+ continue;
43
+ const problem = checkType(tool.name, key, spec, value);
44
+ if (problem)
45
+ return problem;
46
+ }
47
+ return null;
48
+ }
49
+ function checkType(toolName, key, spec, value) {
50
+ if (spec.enum && !spec.enum.includes(value)) {
51
+ // Case drift ("approve" for "APPROVE") is common; show the exact spelling
52
+ // rather than only the list, so the retry is mechanical.
53
+ const ci = typeof value === "string"
54
+ ? spec.enum.find((e) => typeof e === "string" && e.toLowerCase() === value.toLowerCase())
55
+ : undefined;
56
+ const hint = ci ? ` Use exactly "${ci}".` : "";
57
+ return (`Invalid value for "${key}" on ${toolName}:` +
58
+ ` expected one of ${spec.enum.join(", ")}.${hint}`);
59
+ }
60
+ switch (spec.type) {
61
+ case "string":
62
+ if (typeof value !== "string")
63
+ return `Parameter "${key}" on ${toolName} must be a string.`;
64
+ break;
65
+ case "integer":
66
+ if (!Number.isInteger(value))
67
+ return `Parameter "${key}" on ${toolName} must be an integer.`;
68
+ break;
69
+ case "number":
70
+ if (typeof value !== "number")
71
+ return `Parameter "${key}" on ${toolName} must be a number.`;
72
+ break;
73
+ case "boolean":
74
+ if (typeof value !== "boolean")
75
+ return `Parameter "${key}" on ${toolName} must be a boolean.`;
76
+ break;
77
+ case "array":
78
+ if (!Array.isArray(value))
79
+ return `Parameter "${key}" on ${toolName} must be an array.`;
80
+ break;
81
+ case "object":
82
+ if (typeof value !== "object" || value === null || Array.isArray(value))
83
+ return `Parameter "${key}" on ${toolName} must be an object.`;
84
+ break;
85
+ }
86
+ return null;
87
+ }
@@ -0,0 +1,112 @@
1
+ /** Public types. Everything a consumer touches is declared here. */
2
+ export type JsonSchema = {
3
+ type?: string;
4
+ properties?: Record<string, any>;
5
+ required?: string[];
6
+ items?: any;
7
+ enum?: any[];
8
+ [k: string]: any;
9
+ };
10
+ /**
11
+ * A tool as you already have it — the same shape MCP servers and every major
12
+ * SDK produce. `inputSchema` and `input_schema` are both accepted.
13
+ */
14
+ export type Tool = {
15
+ name: string;
16
+ description?: string;
17
+ inputSchema?: JsonSchema;
18
+ input_schema?: JsonSchema;
19
+ };
20
+ /** Normalized internal form. */
21
+ export type NormalizedTool = {
22
+ name: string;
23
+ description: string;
24
+ schema: JsonSchema;
25
+ ns: string;
26
+ op: string;
27
+ };
28
+ /**
29
+ * Compression levels. Each is a superset of the previous.
30
+ *
31
+ * 0 passthrough — no change; useful as an A/B control in your own app
32
+ * 1 signature — flatten JSON Schema, keep native tools + real names
33
+ * 2 namespace — collapse related ops into one tool per namespace
34
+ * 3 minified — single dispatcher + opaque codes
35
+ */
36
+ export type Level = 0 | 1 | 2 | 3;
37
+ /**
38
+ * How each line of the level-3 `<toolmap>` is rendered.
39
+ *
40
+ * name `a0 github_create_issue`
41
+ * name+required `a0 github_create_issue owner,repo,title`
42
+ * signature `a0 github_create_issue(owner,repo,title,body?,labels?)`
43
+ * terse `a0 create new issue in repository`
44
+ *
45
+ * `name` is the default and the smallest. `name+required` costs a few tokens
46
+ * per tool and exists to cut malformed arguments on models that fill the
47
+ * generic argument bag badly — the dispatcher levels give up provider-side
48
+ * constrained decoding, and this buys some of it back cheaply. `signature` also
49
+ * names the optional parameters, which removes most remaining `q()` lookups —
50
+ * a bigger cached map traded for fewer turns, which matters on models where
51
+ * every turn pays for a fresh round of reasoning. `terse` drops the real name
52
+ * entirely; it is the most aggressive and the least legible.
53
+ */
54
+ export type MapStyle = "name" | "name+required" | "signature" | "terse";
55
+ export type CompressOptions = {
56
+ level?: Level;
57
+ /** Level 3 only. Ignored at levels 0–2, which emit no map. Default "name". */
58
+ mapStyle?: MapStyle;
59
+ /**
60
+ * Group tools into namespaces. Default splits on the first `_` or `.`,
61
+ * which matches MCP naming convention (`github_create_issue`).
62
+ */
63
+ namespaceOf?: (toolName: string) => {
64
+ ns: string;
65
+ op: string;
66
+ };
67
+ /** Override the short alias used for a namespace at level 2. */
68
+ aliasOf?: (ns: string) => string;
69
+ /** Cap how many results a search/query meta-call returns. Default 8. */
70
+ searchLimit?: number;
71
+ /** Validate arguments against the original schema before dispatch. Default true. */
72
+ validate?: boolean;
73
+ };
74
+ export type Resolution = {
75
+ kind: "call";
76
+ name: string;
77
+ args: Record<string, any>;
78
+ } | {
79
+ kind: "meta";
80
+ name: string;
81
+ result: string;
82
+ } | {
83
+ kind: "error";
84
+ message: string;
85
+ recoverable: boolean;
86
+ };
87
+ export type CompressStats = {
88
+ level: Level;
89
+ toolCount: number;
90
+ wireToolCount: number;
91
+ originalChars: number;
92
+ compressedChars: number;
93
+ savedPct: number;
94
+ };
95
+ export type CompressResult = {
96
+ /** Tool definitions to send on the wire, in Anthropic shape. */
97
+ tools: unknown[];
98
+ /** Text to append to your system prompt. Empty string at levels 0–1. */
99
+ systemPreamble: string;
100
+ /** Whether the preamble should sit behind a cache breakpoint. */
101
+ cachePreamble: boolean;
102
+ /** Translate a raw model tool call back to a real one. */
103
+ resolve(rawName: string, rawArgs: Record<string, any>): Resolution;
104
+ /** Map a real tool name to its level-3 code. Throws below level 3. */
105
+ codeFor(toolName: string): string;
106
+ /** Build the raw call a model would emit for a real tool. Test/debug aid. */
107
+ encodeCallForTest(toolName: string, args: Record<string, any>): {
108
+ name: string;
109
+ args: Record<string, any>;
110
+ };
111
+ stats: CompressStats;
112
+ };
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ /** Public types. Everything a consumer touches is declared here. */
2
+ export {};
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "toolgz",
3
+ "version": "0.1.0",
4
+ "description": "Compress LLM tool definitions to reclaim context window. Measured across Anthropic, OpenAI, Google and xAI.",
5
+ "main": "dist/index.js",
6
+ "scripts": {
7
+ "test": "vitest run --exclude '**/integration/**'",
8
+ "brain": "tsx --disable-warning=ExperimentalWarning brain/brain.ts",
9
+ "bench": "tsx bench/harness/run.ts",
10
+ "build": "tsc",
11
+ "prepublishOnly": "npm run build && npm test",
12
+ "test:live": "vitest run tests/integration"
13
+ },
14
+ "keywords": [
15
+ "llm",
16
+ "mcp",
17
+ "tools",
18
+ "tool-calling",
19
+ "context-window",
20
+ "tokens",
21
+ "anthropic",
22
+ "claude",
23
+ "openai",
24
+ "gemini",
25
+ "grok",
26
+ "prompt-caching"
27
+ ],
28
+ "author": "",
29
+ "license": "Apache-2.0",
30
+ "type": "module",
31
+ "dependencies": {
32
+ "@anthropic-ai/sdk": "^0.115.0",
33
+ "@google/genai": "^2.13.0",
34
+ "dotenv": "^17.4.2",
35
+ "openai": "^6.49.0"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^26.1.1",
39
+ "tsx": "^4.23.1",
40
+ "typescript": "^7.0.2",
41
+ "vitest": "^4.1.10"
42
+ },
43
+ "types": "dist/index.d.ts",
44
+ "files": [
45
+ "dist",
46
+ "README.md",
47
+ "LICENSE",
48
+ "NOTICE"
49
+ ],
50
+ "engines": {
51
+ "node": ">=20"
52
+ },
53
+ "exports": {
54
+ ".": {
55
+ "types": "./dist/index.d.ts",
56
+ "import": "./dist/index.js"
57
+ },
58
+ "./providers": {
59
+ "types": "./dist/providers/index.d.ts",
60
+ "import": "./dist/providers/index.js"
61
+ }
62
+ },
63
+ "repository": {
64
+ "type": "git",
65
+ "url": "git+https://github.com/dperussina/toolgz.git"
66
+ },
67
+ "homepage": "https://github.com/dperussina/toolgz#readme",
68
+ "bugs": {
69
+ "url": "https://github.com/dperussina/toolgz/issues"
70
+ }
71
+ }