distil-llm 1.41.0 → 1.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -60,3 +60,66 @@ const openai = new OpenAI({ baseURL: distilOpenAIBaseURL() }); // adds /v1
60
60
  publishes real, auditable numbers.
61
61
 
62
62
  Full docs: <https://dshakes.github.io/distil/> · License: Apache-2.0
63
+
64
+ ## In-process compression (lossless tier)
65
+
66
+ ```js
67
+ import { compress } from "distil-llm";
68
+
69
+ const r = compress(messages); // never mutates the input
70
+ console.log(`${r.savedPct.toFixed(1)}% smaller`);
71
+ const response = await client.messages.create({ model, messages: r.messages });
72
+ ```
73
+
74
+ Tool and user text get lossless transforms; **the model's own turns and
75
+ non-string content (image blocks, tool_use) are never rewritten**. Unchanged
76
+ messages are returned by identity.
77
+
78
+ This runs entirely in Node — no daemon, no network — and is **byte-identical to
79
+ the Python engine**, enforced by a conformance suite (`tests/test_ts_conformance.py`)
80
+ that runs both implementations over a shared corpus and fails on any divergence.
81
+ Where JS cannot reproduce Python's bytes (Python renders an integral float as
82
+ `1.0`, JS as `1`; JS objects hoist integer-like keys) the transform **declines**
83
+ rather than emitting output the certificate does not cover.
84
+
85
+ ### Getting the reversible digest tier
86
+
87
+ `compress()` is lossless-only, on purpose. The digest tier mints restore handles
88
+ whose originals must live in one store shared with the proxy and the MCP server,
89
+ and it is the tier the decision-equivalence certificate measures — so there is
90
+ deliberately no second implementation of it here.
91
+
92
+ Route through the proxy instead, which is the same engine and the same store:
93
+
94
+ ```js
95
+ import Anthropic from "@anthropic-ai/sdk";
96
+ import { distilBaseURL } from "distil-llm";
97
+
98
+ const client = new Anthropic({ baseURL: distilBaseURL() });
99
+ ```
100
+
101
+ ```bash
102
+ npx distil-llm proxy # or: distil default --always-on
103
+ ```
104
+
105
+ ## Vercel AI SDK middleware
106
+
107
+ ```js
108
+ import { wrapLanguageModel } from "ai";
109
+ import { distilMiddleware } from "distil-llm";
110
+
111
+ const model = wrapLanguageModel({
112
+ model: gateway("anthropic/claude-sonnet-5"),
113
+ middleware: distilMiddleware({
114
+ onSavings: (s) => console.log(`${s.savedPct.toFixed(1)}% smaller`),
115
+ }),
116
+ });
117
+ ```
118
+
119
+ Implements `transformParams` only — compression happens on the way **in**, and
120
+ wrapping the response would mean rewriting the model's own output, which distil
121
+ does not do. Tool results (both the v4 `result` and v5 `output.value` shapes),
122
+ user text parts, and system strings are compressed; assistant messages, files,
123
+ and tool calls are passed through untouched.
124
+
125
+ Lossless tier, like `compress()`. Route through the proxy for the digest tier.
package/index.d.ts CHANGED
@@ -1,15 +1,92 @@
1
- export interface DistilURLOptions {
2
- /** Proxy host. Default "127.0.0.1". */
1
+ // Type definitions for distil-llm.
2
+
3
+ export interface Message {
4
+ role?: string;
5
+ type?: string;
6
+ content?: unknown;
7
+ [k: string]: unknown;
8
+ }
9
+
10
+ export interface CompressionResult {
11
+ /** New array. Unchanged messages are returned by identity. */
12
+ messages: Message[];
13
+ tokensBefore: number;
14
+ tokensAfter: number;
15
+ tokensSaved: number;
16
+ /**
17
+ * Percent smaller. Legitimately near 0 on small payloads — savings come from
18
+ * LARGE tool output, and a request that is mostly system prompt has nothing
19
+ * to fold.
20
+ */
21
+ savedPct: number;
22
+ tier: "lossless";
23
+ }
24
+
25
+ export interface BaseURLOptions {
3
26
  host?: string;
4
- /** Proxy port. Default 8788 (or DISTIL_PORT). */
5
27
  port?: number;
6
28
  }
7
29
 
8
- /** Base URL of a local distil proxy (honors DISTIL_BASE_URL). */
9
- export function distilBaseURL(opts?: DistilURLOptions): string;
30
+ /**
31
+ * Compress a message list in-process, lossless tier only.
32
+ *
33
+ * Byte-identical to the Python engine (enforced by a conformance suite). Tool
34
+ * and user text get lossless transforms; the model's own turns and non-string
35
+ * content are never rewritten; the input array is never mutated.
36
+ *
37
+ * For the reversible DIGEST tier, route your SDK through a distil proxy with
38
+ * `distilBaseURL()` — that path mints restore handles into the shared store and
39
+ * is the path the decision-equivalence certificate covers.
40
+ */
41
+ export function compress(messages: Message[]): CompressionResult;
10
42
 
11
- /** distil proxy base URL with the OpenAI `/v1` segment appended. */
12
- export function distilOpenAIBaseURL(opts?: DistilURLOptions): string;
43
+ /** Base URL of a local distil proxy (honours DISTIL_BASE_URL / DISTIL_PORT). */
44
+ export function distilBaseURL(opts?: BaseURLOptions): string;
45
+ /** Same, with the `/v1` segment OpenAI-style clients expect. */
46
+ export function distilOpenAIBaseURL(opts?: BaseURLOptions): string;
13
47
 
14
48
  export const DEFAULT_PORT: number;
15
49
  export const DEFAULT_HOST: string;
50
+
51
+ /** Full Tier-0 pipeline: JSON minify, then run-collapse if it does not inflate. */
52
+ export function applyTier0(text: string): string;
53
+ /** Run-length-collapse consecutive identical lines to `L\n<<xN>>`. */
54
+ export function collapseRuns(text: string): string;
55
+ /**
56
+ * Textual inverse of `collapseRuns`.
57
+ * PRECONDITION: the original text contained no line matching /^<<x\d+>>$/ —
58
+ * a generated marker and content that looks like one are indistinguishable.
59
+ * distil's real recovery path is the restore store, not this function.
60
+ */
61
+ export function expandRuns(text: string): string;
62
+ /** Minify JSON, or null when a byte-identical round-trip is not provable. */
63
+ export function minifyJson(text: string): string | null;
64
+ /** Heuristic token count, matching the Python HeuristicTokenizer. */
65
+ export function countTokens(text: string): number;
66
+
67
+ export interface SavingsInfo {
68
+ tokensBefore: number;
69
+ tokensAfter: number;
70
+ tokensSaved: number;
71
+ savedPct: number;
72
+ }
73
+
74
+ export interface DistilMiddlewareOptions {
75
+ /** Called once per request with the measured delta. */
76
+ onSavings?: (info: SavingsInfo) => void;
77
+ }
78
+
79
+ /**
80
+ * Vercel AI SDK middleware that losslessly compresses the prompt in-process.
81
+ *
82
+ * Implements `transformParams` only — compression happens on the way IN, and
83
+ * wrapping the response would mean rewriting the model's own output, which
84
+ * distil does not do.
85
+ *
86
+ * const model = wrapLanguageModel({ model: ..., middleware: distilMiddleware() });
87
+ *
88
+ * For the reversible digest tier, route through a distil proxy instead.
89
+ */
90
+ export function distilMiddleware(
91
+ opts?: DistilMiddlewareOptions
92
+ ): { transformParams: (arg: { params: unknown }) => Promise<unknown> };
package/index.js CHANGED
@@ -17,22 +17,30 @@
17
17
 
18
18
  "use strict";
19
19
 
20
- const DEFAULT_PORT = 8788;
21
- const DEFAULT_HOST = "127.0.0.1";
20
+ const {
21
+ distilBaseURL,
22
+ distilOpenAIBaseURL,
23
+ DEFAULT_PORT,
24
+ DEFAULT_HOST,
25
+ } = require("./lib/baseurl");
26
+ const { compress } = require("./lib/compress");
27
+ const { distilMiddleware } = require("./lib/aisdk");
28
+ const { applyTier0, collapseRuns, expandRuns, minifyJson, countTokens } = require("./lib/tier0");
22
29
 
23
- /** Base URL of a local distil proxy. Reads DISTIL_BASE_URL if set, else builds
24
- * http://<host>:<port> (defaults 127.0.0.1:8788). */
25
- function distilBaseURL(opts) {
26
- opts = opts || {};
27
- if (process.env.DISTIL_BASE_URL) return process.env.DISTIL_BASE_URL;
28
- const host = opts.host || DEFAULT_HOST;
29
- const port = opts.port || Number(process.env.DISTIL_PORT) || DEFAULT_PORT;
30
- return `http://${host}:${port}`;
31
- }
32
-
33
- /** OpenAI-style clients expect the base URL to include the API version segment. */
34
- function distilOpenAIBaseURL(opts) {
35
- return distilBaseURL(opts).replace(/\/+$/, "") + "/v1";
36
- }
37
-
38
- module.exports = { distilBaseURL, distilOpenAIBaseURL, DEFAULT_PORT, DEFAULT_HOST };
30
+ module.exports = {
31
+ // in-process lossless compression (byte-identical to the Python engine)
32
+ compress,
33
+ // Vercel AI SDK middleware for wrapLanguageModel()
34
+ distilMiddleware,
35
+ // proxy routing helpers
36
+ distilBaseURL,
37
+ distilOpenAIBaseURL,
38
+ DEFAULT_PORT,
39
+ DEFAULT_HOST,
40
+ // lower-level transforms, exposed for verification and testing
41
+ applyTier0,
42
+ collapseRuns,
43
+ expandRuns,
44
+ minifyJson,
45
+ countTokens,
46
+ };
package/lib/aisdk.js ADDED
@@ -0,0 +1,132 @@
1
+ // Vercel AI SDK middleware.
2
+ //
3
+ // The proxy route (`createAnthropic({ baseURL: distilBaseURL() })`) is still the
4
+ // way to get the reversible digest tier. This is for the other case: you want
5
+ // lossless savings in-process, with no daemon to keep alive — a serverless
6
+ // function, an edge worker, a CI job that runs once.
7
+ //
8
+ // import { wrapLanguageModel } from "ai";
9
+ // import { distilMiddleware } from "distil-llm";
10
+ //
11
+ // const model = wrapLanguageModel({
12
+ // model: gateway("anthropic/claude-sonnet-5"),
13
+ // middleware: distilMiddleware(),
14
+ // });
15
+ //
16
+ // Implements `transformParams` only. `wrapGenerate`/`wrapStream` are not
17
+ // implemented because compression happens on the way IN — wrapping the response
18
+ // would let us rewrite the model's own output, which distil does not do.
19
+
20
+ "use strict";
21
+
22
+ const { applyTier0, countTokens } = require("./tier0");
23
+
24
+ /**
25
+ * A `LanguageModelV4Prompt` message carries `content` that is a plain STRING for
26
+ * `system`, and an ARRAY OF PARTS for `user`, `assistant`, and `tool`. Both are
27
+ * handled; anything unrecognised is passed through untouched, because a
28
+ * compressor that mangles a shape it does not understand is worse than one that
29
+ * saves nothing.
30
+ */
31
+
32
+ /** Compress a text-bearing part, preserving every sibling key. */
33
+ function compressPart(part) {
34
+ if (!part || typeof part !== "object") return part;
35
+
36
+ // { type: "text", text: "..." } — user and tool text.
37
+ if (part.type === "text" && typeof part.text === "string") {
38
+ const next = applyTier0(part.text);
39
+ return next === part.text ? part : { ...part, text: next };
40
+ }
41
+
42
+ // Tool results carry the payload under different keys across SDK versions:
43
+ // v5: { type: "tool-result", output: { type: "text", value: "..." } }
44
+ // v4: { type: "tool-result", result: "..." }
45
+ // Both are handled rather than pinning one, so an SDK bump does not silently
46
+ // stop compressing the single largest thing in an agent's context.
47
+ if (part.type === "tool-result") {
48
+ if (part.output && typeof part.output.value === "string") {
49
+ const next = applyTier0(part.output.value);
50
+ return next === part.output.value
51
+ ? part
52
+ : { ...part, output: { ...part.output, value: next } };
53
+ }
54
+ if (typeof part.result === "string") {
55
+ const next = applyTier0(part.result);
56
+ return next === part.result ? part : { ...part, result: next };
57
+ }
58
+ }
59
+ return part; // file / image / tool-call parts are never rewritten
60
+ }
61
+
62
+ function compressMessage(message) {
63
+ if (!message || typeof message !== "object") return message;
64
+
65
+ // The model's own words are not distil's to edit.
66
+ if (message.role === "assistant") return message;
67
+
68
+ if (typeof message.content === "string") {
69
+ const next = applyTier0(message.content);
70
+ return next === message.content ? message : { ...message, content: next };
71
+ }
72
+ if (Array.isArray(message.content)) {
73
+ const parts = message.content.map(compressPart);
74
+ const changed = parts.some((p, i) => p !== message.content[i]);
75
+ return changed ? { ...message, content: parts } : message;
76
+ }
77
+ return message;
78
+ }
79
+
80
+ function textOf(message) {
81
+ if (typeof message?.content === "string") return message.content;
82
+ if (Array.isArray(message?.content)) {
83
+ return message.content
84
+ .map((p) =>
85
+ typeof p?.text === "string"
86
+ ? p.text
87
+ : typeof p?.output?.value === "string"
88
+ ? p.output.value
89
+ : typeof p?.result === "string"
90
+ ? p.result
91
+ : ""
92
+ )
93
+ .join("");
94
+ }
95
+ return "";
96
+ }
97
+
98
+ /**
99
+ * Build AI SDK middleware that losslessly compresses the prompt before it is sent.
100
+ *
101
+ * @param {{onSavings?: (info: {tokensBefore: number, tokensAfter: number,
102
+ * tokensSaved: number, savedPct: number}) => void}} [opts]
103
+ * `onSavings` is called once per request with the measured delta. Provided
104
+ * because in-process compression has no proxy to report through, and savings
105
+ * you cannot see are savings you will not trust.
106
+ * @returns {{transformParams: (arg: {params: object}) => Promise<object>}}
107
+ */
108
+ function distilMiddleware(opts) {
109
+ const onSavings = opts && typeof opts.onSavings === "function" ? opts.onSavings : null;
110
+ return {
111
+ transformParams: async ({ params }) => {
112
+ if (!params || !Array.isArray(params.prompt)) return params;
113
+
114
+ const before = onSavings ? params.prompt.reduce((n, m) => n + countTokens(textOf(m)), 0) : 0;
115
+ const prompt = params.prompt.map(compressMessage);
116
+ const changed = prompt.some((m, i) => m !== params.prompt[i]);
117
+
118
+ if (onSavings) {
119
+ const after = prompt.reduce((n, m) => n + countTokens(textOf(m)), 0);
120
+ onSavings({
121
+ tokensBefore: before,
122
+ tokensAfter: after,
123
+ tokensSaved: before - after,
124
+ savedPct: before > 0 ? (100 * (before - after)) / before : 0,
125
+ });
126
+ }
127
+ return changed ? { ...params, prompt } : params;
128
+ },
129
+ };
130
+ }
131
+
132
+ module.exports = { distilMiddleware };
package/lib/baseurl.js ADDED
@@ -0,0 +1,23 @@
1
+ // Base-URL helpers, factored out of index.js so lib/ modules can use them
2
+ // without importing the package entry point (which imports them back).
3
+ "use strict";
4
+
5
+ const DEFAULT_PORT = 8788;
6
+ const DEFAULT_HOST = "127.0.0.1";
7
+
8
+ /** Base URL of a local distil proxy. Reads DISTIL_BASE_URL if set, else builds
9
+ * http://<host>:<port> (defaults 127.0.0.1:8788). */
10
+ function distilBaseURL(opts) {
11
+ opts = opts || {};
12
+ if (process.env.DISTIL_BASE_URL) return process.env.DISTIL_BASE_URL;
13
+ const host = opts.host || DEFAULT_HOST;
14
+ const port = opts.port || Number(process.env.DISTIL_PORT) || DEFAULT_PORT;
15
+ return `http://${host}:${port}`;
16
+ }
17
+
18
+ /** OpenAI-style clients expect the base URL to include the API version segment. */
19
+ function distilOpenAIBaseURL(opts) {
20
+ return distilBaseURL(opts).replace(/\/+$/, "") + "/v1";
21
+ }
22
+
23
+ module.exports = { distilBaseURL, distilOpenAIBaseURL, DEFAULT_PORT, DEFAULT_HOST };
@@ -0,0 +1,98 @@
1
+ // Public TypeScript/JavaScript compression API.
2
+ //
3
+ // Two tiers, two very different trust stories, and the difference is the whole
4
+ // design of this file:
5
+ //
6
+ // Tier-0 (lossless) — implemented natively in lib/tier0.js and proven
7
+ // BYTE-IDENTICAL to the Python engine by
8
+ // tests/test_ts_conformance.py. Safe to run in-process:
9
+ // no daemon, no network, no handles to keep alive.
10
+ //
11
+ // Tier-1 (digest) — NOT implemented here. It mints restore handles whose
12
+ // originals must live in ONE store shared with the proxy
13
+ // and the MCP server, or `expand` silently fails to
14
+ // resolve a handle the model can see. It is also the tier
15
+ // the decision-equivalence certificate actually measures.
16
+ // A second implementation would be a second thing to
17
+ // certify, and nobody would notice when they drifted.
18
+ //
19
+ // So `compress()` is lossless-only and always available in-process. The digest
20
+ // tier comes from routing your client through the proxy (see the note at the
21
+ // bottom of this file), which is the same engine, store, and certificate.
22
+
23
+ "use strict";
24
+
25
+ const { applyTier0, countTokens } = require("./tier0");
26
+
27
+ /** Roles whose content distil never rewrites. */
28
+ const NEVER_REWRITE = new Set(["assistant", "ai"]);
29
+
30
+ function roleOf(m) {
31
+ if (!m || typeof m !== "object") return "";
32
+ return String(m.role || m.type || "").toLowerCase();
33
+ }
34
+
35
+ /**
36
+ * Compress a message list in-process, lossless tier only.
37
+ *
38
+ * Mirrors the Python `distil.compress_messages` contract: the input is never
39
+ * mutated, unchanged messages pass through by identity, non-string content
40
+ * (image blocks, tool_use structures) is untouched, and the model's own turns
41
+ * are never rewritten.
42
+ *
43
+ * @param {Array<object>} messages OpenAI/Anthropic-style message objects
44
+ * @returns {{messages: Array<object>, tokensBefore: number, tokensAfter: number,
45
+ * tokensSaved: number, savedPct: number, tier: "lossless"}}
46
+ */
47
+ function compress(messages) {
48
+ if (!Array.isArray(messages)) {
49
+ throw new TypeError("compress(messages): expected an array of messages");
50
+ }
51
+ let before = 0;
52
+ let after = 0;
53
+ const out = messages.map((m) => {
54
+ const content = m && typeof m === "object" ? m.content : undefined;
55
+ if (typeof content !== "string") return m;
56
+ before += countTokens(content);
57
+ if (NEVER_REWRITE.has(roleOf(m))) {
58
+ after += countTokens(content);
59
+ return m;
60
+ }
61
+ const next = applyTier0(content);
62
+ after += countTokens(next);
63
+ return next === content ? m : Object.assign({}, m, { content: next });
64
+ });
65
+ return {
66
+ messages: out,
67
+ tokensBefore: before,
68
+ tokensAfter: after,
69
+ tokensSaved: before - after,
70
+ // Can legitimately be ~0: savings come from LARGE tool output, and a request
71
+ // that is mostly system prompt has nothing to fold.
72
+ savedPct: before > 0 ? (100 * (before - after)) / before : 0,
73
+ tier: "lossless",
74
+ };
75
+ }
76
+
77
+ /**
78
+ * How to get the reversible DIGEST tier from JS.
79
+ *
80
+ * There is deliberately no `compressViaProxy()` here. The distil proxy has no
81
+ * "compress this payload and hand it back" endpoint, and inventing one would add
82
+ * a new public API surface — accepting arbitrary content, needing its own
83
+ * authentication story — to get something the existing design already provides.
84
+ *
85
+ * The proxy's contract is transparent interception: point your SDK's baseURL at
86
+ * it and every request is compressed on the way through, by the same engine the
87
+ * decision-equivalence certificate was measured against, minting handles into the
88
+ * one restore store that `distil expand` and the MCP server read.
89
+ *
90
+ * import Anthropic from "@anthropic-ai/sdk";
91
+ * import { distilBaseURL } from "distil-llm";
92
+ * const client = new Anthropic({ baseURL: distilBaseURL() });
93
+ *
94
+ * Use `compress()` above when you hold the message list and want lossless
95
+ * savings with no daemon; route through the proxy when you want the digest.
96
+ */
97
+
98
+ module.exports = { compress };
package/lib/tier0.js ADDED
@@ -0,0 +1,323 @@
1
+ // Tier-0 lossless transforms — a faithful port of distil/compress/tier0.py.
2
+ //
3
+ // WHY THIS IS A PORT AND NOT A REIMPLEMENTATION
4
+ // ---------------------------------------------
5
+ // distil's whole claim is a decision-equivalence certificate, and that
6
+ // certificate is measured against the PYTHON compressor. A JS implementation
7
+ // that merely "compresses similarly" would sit outside it — users would get a
8
+ // guarantee that does not describe the code they are running.
9
+ //
10
+ // So this file targets BYTE-IDENTICAL output with tier0.py, and
11
+ // tests/test_ts_conformance.py runs both against a shared fixture corpus and
12
+ // fails on any divergence. Where byte-identity is not achievable (see
13
+ // `minifyJson`), the transform DECLINES rather than producing output the
14
+ // certificate does not cover. Declining costs a little compression; diverging
15
+ // silently would cost the guarantee.
16
+ //
17
+ // Tier-1 (the reversible digest) is deliberately NOT ported: it allocates
18
+ // restore handles and must share one store with the proxy and the MCP server.
19
+ // Use `compressViaProxy` in compress.js for that.
20
+
21
+ "use strict";
22
+
23
+ // Matches tier0.py's _RUN_MARKER: a generated marker, so a run of markers is
24
+ // left alone (collapsing it would make the new marker indistinguishable from
25
+ // original content when reversing).
26
+ const RUN_MARKER = /^<<x\d+>>$/;
27
+
28
+ /**
29
+ * Run-length-encode consecutive identical lines, reversibly.
30
+ * Two or more identical lines `L` collapse to `L\n<<xN>>\n`, where N is the
31
+ * exact original count, so the input is fully recoverable.
32
+ *
33
+ * Pure string manipulation, so this is exactly portable — no locale, no number
34
+ * formatting, no object ordering. It is the bulk of Tier-0's real-world win
35
+ * (repeated log lines, duplicated stack frames).
36
+ *
37
+ * @param {string} text
38
+ * @returns {string}
39
+ */
40
+ function collapseRuns(text) {
41
+ if (typeof text !== "string" || text.indexOf("\n") === -1) return text;
42
+
43
+ const lines = text.split("\n");
44
+ const out = [];
45
+ let i = 0;
46
+ // The Python side uses the regex /^(.*)\n(?:\1\n)+/m, which only ever matches
47
+ // runs that are newline-TERMINATED. A trailing final line with no newline
48
+ // after it therefore cannot participate in a run; splitting keeps that
49
+ // property because the last element has no terminator to consume.
50
+ const lastIndex = lines.length - 1;
51
+ while (i < lines.length) {
52
+ const line = lines[i];
53
+ let j = i;
54
+ // Count how many consecutive newline-terminated copies follow.
55
+ while (j < lastIndex && lines[j] === line) j++;
56
+ const runLen = j - i;
57
+ if (runLen >= 2 && !RUN_MARKER.test(line)) {
58
+ out.push(line, `<<x${runLen}>>`);
59
+ i = j;
60
+ } else {
61
+ out.push(line);
62
+ i += 1;
63
+ }
64
+ }
65
+ return out.join("\n");
66
+ }
67
+
68
+ /**
69
+ * Textual inverse of `collapseRuns`.
70
+ *
71
+ * PRECONDITION: the ORIGINAL text contained no line matching /^<<x\d+>>$/.
72
+ * A generated marker and original content that looks like one are
73
+ * indistinguishable after the fact, so no textual inverse can separate them.
74
+ * `collapseRuns` refuses to collapse such runs precisely so nothing is lost —
75
+ * but expanding text that already contained markers will invent lines.
76
+ *
77
+ * This is why distil's real reversibility guarantee comes from the restore
78
+ * store (the exact original bytes, keyed by handle) rather than from inverting
79
+ * the transform. Use this for local verification of ordinary text, not as the
80
+ * recovery path.
81
+ *
82
+ * @param {string} text
83
+ * @returns {string}
84
+ */
85
+ function expandRuns(text) {
86
+ if (typeof text !== "string") return text;
87
+ const lines = text.split("\n");
88
+ const out = [];
89
+ for (let i = 0; i < lines.length; i++) {
90
+ const m = /^<<x(\d+)>>$/.exec(lines[i]);
91
+ if (m && out.length > 0) {
92
+ const prev = out[out.length - 1];
93
+ const n = parseInt(m[1], 10);
94
+ for (let k = 1; k < n; k++) out.push(prev);
95
+ } else {
96
+ out.push(lines[i]);
97
+ }
98
+ }
99
+ return out.join("\n");
100
+ }
101
+
102
+ // --- JSON ------------------------------------------------------------------
103
+ //
104
+ // Python's json.dumps and JS's JSON.stringify disagree on two things that
105
+ // matter for byte-identity:
106
+ //
107
+ // 1. NUMBER FORMATTING. Python renders a float that happens to be integral as
108
+ // "1.0"; JS renders it "1". Nothing in JS can recover the distinction,
109
+ // because JSON.parse has already collapsed 1.0 and 1 to the same double.
110
+ // 2. KEY ORDER. Python dicts preserve insertion order for every key. JS
111
+ // objects hoist integer-like keys ("2" before "a", regardless of input).
112
+ //
113
+ // Both are detected below and cause the transform to DECLINE (return null), so
114
+ // the caller keeps the text byte-exact. That is the same posture tier0.py takes
115
+ // for duplicate keys and non-finite numbers: when the round trip is not
116
+ // provably value-preserving, do nothing.
117
+
118
+ /** True when a key would be reordered by a JS object (array-index-like). */
119
+ function isIntegerLikeKey(k) {
120
+ return /^(0|[1-9]\d*)$/.test(k) && Number(k) <= 4294967294;
121
+ }
122
+
123
+ /**
124
+ * Scan the raw JSON source for constructs we cannot round-trip byte-identically
125
+ * with Python. Returns a reason string, or null when it is safe.
126
+ */
127
+ function unsafeJsonReason(src) {
128
+ // Strip string contents FIRST. Scanning raw source treats `"line1"` as
129
+ // exponent notation ("e1") and `"v1.0"` as an integral float, which made this
130
+ // decline JSON that Python happily minifies — a conformance divergence in the
131
+ // safe direction, but a divergence.
132
+ const bare = stripJsonStrings(src);
133
+
134
+ // Python renders an integral float as "1.0"; JS renders it "1". After parsing
135
+ // the distinction is gone, so it has to be decided on the literal.
136
+ const nums = bare.match(/-?\d+\.\d+/g) || [];
137
+ for (const n of nums) {
138
+ if (Number.isInteger(Number(n))) {
139
+ return "integral float renders differently in Python and JS";
140
+ }
141
+ }
142
+ if (/\d[eE][+-]?\d+/.test(bare)) return "exponent notation renders differently";
143
+ if (/\b(Infinity|-Infinity|NaN)\b/.test(bare)) return "non-finite constant";
144
+ return null;
145
+ }
146
+
147
+ /** Replace every JSON string literal with "" so scans see structure only. */
148
+ function stripJsonStrings(src) {
149
+ let out = "";
150
+ let inStr = false;
151
+ let esc = false;
152
+ for (let i = 0; i < src.length; i++) {
153
+ const c = src[i];
154
+ if (inStr) {
155
+ if (esc) esc = false;
156
+ else if (c === "\\") esc = true;
157
+ else if (c === '"') {
158
+ inStr = false;
159
+ out += '""';
160
+ }
161
+ continue;
162
+ }
163
+ if (c === '"') inStr = true;
164
+ else out += c;
165
+ }
166
+ return out;
167
+ }
168
+
169
+ /** Depth-first check for duplicate or reorder-prone keys, on the raw source. */
170
+ function objectKeysAreSafe(obj, seenPath) {
171
+ if (Array.isArray(obj)) return obj.every((v) => objectKeysAreSafe(v, seenPath));
172
+ if (obj && typeof obj === "object") {
173
+ for (const k of Object.keys(obj)) {
174
+ if (isIntegerLikeKey(k)) return false; // JS would hoist it
175
+ if (!objectKeysAreSafe(obj[k], seenPath)) return false;
176
+ }
177
+ }
178
+ return true;
179
+ }
180
+
181
+ /**
182
+ * Re-encode JSON with no incidental whitespace, or null when that would not be
183
+ * provably value-preserving *and* byte-identical to the Python implementation.
184
+ *
185
+ * @param {string} text
186
+ * @returns {string|null}
187
+ */
188
+ function minifyJson(text) {
189
+ if (typeof text !== "string") return null;
190
+ const s = text.trim();
191
+ const first = s.charAt(0);
192
+ const last = s.charAt(s.length - 1);
193
+ // Same gate as tier0.py: only whole objects/arrays.
194
+ if (!((first === "{" || first === "[") && (last === "}" || last === "]"))) return null;
195
+
196
+ if (unsafeJsonReason(s) !== null) return null;
197
+
198
+ let obj;
199
+ try {
200
+ obj = JSON.parse(s);
201
+ } catch (_e) {
202
+ return null;
203
+ }
204
+ if (!objectKeysAreSafe(obj)) return null;
205
+
206
+ // Duplicate keys: JSON.parse silently keeps the last. tier0.py rejects such
207
+ // input outright, so detect it by counting keys in the source.
208
+ if (hasDuplicateKeys(s)) return null;
209
+
210
+ try {
211
+ return JSON.stringify(obj);
212
+ } catch (_e) {
213
+ return null; // circular refs cannot occur from JSON.parse, but never throw
214
+ }
215
+ }
216
+
217
+ /** Cheap duplicate-key detector over the raw source (string-aware). */
218
+ function hasDuplicateKeys(src) {
219
+ const stack = [];
220
+ let inStr = false;
221
+ let esc = false;
222
+ let cur = null;
223
+ let pendingKey = null;
224
+ for (let i = 0; i < src.length; i++) {
225
+ const c = src[i];
226
+ if (inStr) {
227
+ if (esc) {
228
+ esc = false;
229
+ } else if (c === "\\") {
230
+ esc = true;
231
+ } else if (c === '"') {
232
+ inStr = false;
233
+ pendingKey = src.slice(cur + 1, i);
234
+ }
235
+ continue;
236
+ }
237
+ if (c === '"') {
238
+ inStr = true;
239
+ cur = i;
240
+ } else if (c === "{") {
241
+ stack.push(new Set());
242
+ pendingKey = null;
243
+ } else if (c === "}") {
244
+ stack.pop();
245
+ pendingKey = null;
246
+ } else if (c === ":" && stack.length && pendingKey !== null) {
247
+ const top = stack[stack.length - 1];
248
+ if (top.has(pendingKey)) return true;
249
+ top.add(pendingKey);
250
+ pendingKey = null;
251
+ } else if (c === ",") {
252
+ pendingKey = null;
253
+ }
254
+ }
255
+ return false;
256
+ }
257
+
258
+ // --- heuristic tokenizer ----------------------------------------------------
259
+ // Port of distil/tokenizer.py:HeuristicTokenizer. Needed because the production
260
+ // Tier-0 path is reject-if-bigger *by tokens*, not by characters: collapsing a
261
+ // run of near-free blank lines into a "<<xN>>" marker can cost MORE tokens than
262
+ // it removes, and Tier-0 must never inflate.
263
+
264
+ // Python: re.compile(r"\w+|[^\w\s]", re.UNICODE). The `u` flag plus explicit
265
+ // Unicode property escapes reproduce Python's \w/\s semantics closely enough
266
+ // for counting; the conformance suite is what proves it on real inputs.
267
+ const PIECE = /[\p{L}\p{N}_]+|[^\p{L}\p{N}_\s]/gu;
268
+ const SUBWORD_FACTOR = 1.33;
269
+
270
+ /**
271
+ * Approximate token count, matching HeuristicTokenizer.count byte-for-byte.
272
+ * @param {string} text
273
+ * @returns {number}
274
+ */
275
+ function countTokens(text) {
276
+ if (!text) return 0;
277
+ const pieces = text.match(PIECE);
278
+ const n = pieces ? pieces.length : 0;
279
+ return Math.max(1, bankersRound(n * SUBWORD_FACTOR));
280
+ }
281
+
282
+ /**
283
+ * Python's round() is round-half-to-EVEN; JS Math.round is round-half-UP.
284
+ * They differ on exact .5, which n * 1.33 can land on, so the naive version
285
+ * would silently diverge from Python on specific input lengths.
286
+ */
287
+ function bankersRound(x) {
288
+ const floor = Math.floor(x);
289
+ const diff = x - floor;
290
+ if (diff > 0.5) return floor + 1;
291
+ if (diff < 0.5) return floor;
292
+ return floor % 2 === 0 ? floor : floor + 1;
293
+ }
294
+
295
+ /**
296
+ * Apply every Tier-0 transform in the same order as the production path
297
+ * (distil/adapters/anthropic.py:_apply_tier0): JSON minification first, then
298
+ * run-collapse on the RESULT, keeping the collapse only when it does not
299
+ * increase the token count.
300
+ *
301
+ * @param {string} text
302
+ * @returns {string}
303
+ */
304
+ function applyTier0(text) {
305
+ if (typeof text !== "string" || text.length === 0) return text;
306
+ const mj = minifyJson(text);
307
+ const base = mj !== null ? mj : text;
308
+ const collapsed = collapseRuns(base);
309
+ if (collapsed !== base && countTokens(collapsed) <= countTokens(base)) {
310
+ return collapsed;
311
+ }
312
+ return base;
313
+ }
314
+
315
+ module.exports = {
316
+ applyTier0,
317
+ countTokens,
318
+ collapseRuns,
319
+ expandRuns,
320
+ minifyJson,
321
+ // exported for the conformance harness
322
+ _internals: { hasDuplicateKeys, unsafeJsonReason, isIntegerLikeKey },
323
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "distil-llm",
3
- "version": "1.41.0",
3
+ "version": "1.42.0",
4
4
  "description": "Compress your AI agent's context and prove its decisions don't change \u2014 cache-aware, reversible, certified. JS/TS bridge to the distil CLI + proxy helpers.",
5
5
  "keywords": [
6
6
  "llm",
@@ -38,6 +38,6 @@
38
38
  "node": ">=18"
39
39
  },
40
40
  "scripts": {
41
- "test": "node --test test/"
41
+ "test": "node --test test/*.test.mjs"
42
42
  }
43
43
  }