From 773c0ded4c2fba56c9b079424a883bba728bde04 Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Sat, 22 Aug 2026 13:31:22 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20salvageParseRanges=20=E2=80=94=20le?= =?UTF-8?q?nient=20compress-arg=20parser=20(5=20layers:=20json/fenced/repa?= =?UTF-8?q?ired/array-prefix/field-regex)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Weak/local models (vLLM qwen etc.) emit compress tool arguments that fail strict JSON.parse ~50% of the time (see billion-context-omp#121): truncated output caps, raw newlines inside summary strings, trailing commas, or plain prose. Hosts currently swallow the parse failure as {} with no evidence. This kernel module gives every billion-context host one shared parser: layer 1 strict JSON.parse layer 2 strip ```json fences, parse layer 3 repair trailing commas + raw newlines in string literals, parse layer 4 truncated content array: salvage complete prefix entries (brace-stack scanner that works even inside an unclosed wrapper) layer 5 field-regex extraction (startId/endId/summary/topic, plus 'from m00150 to m00220' prose shape) Never throws; returns ranges + deepest layer reached + log note so hosts can surface WHY parsing degraded. extractRanges() also normalizes JSON-string (double-encoded) content arrays. tests: 15 new cases in tests/salvage-parse.test.ts; full suite 408/408. --- package-lock.json | 4 +- src/index.ts | 2 + src/salvage-parse.ts | 259 ++++++++++++++++++++++++++++++++++++ tests/salvage-parse.test.ts | 127 ++++++++++++++++++ 4 files changed, 390 insertions(+), 2 deletions(-) create mode 100644 src/salvage-parse.ts create mode 100644 tests/salvage-parse.test.ts diff --git a/package-lock.json b/package-lock.json index 54c2129..ee0a932 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "acp-kernel", - "version": "0.0.28", + "version": "0.0.33", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "acp-kernel", - "version": "0.0.28", + "version": "0.0.33", "license": "MIT", "devDependencies": { "@types/node": "^22.0.0", diff --git a/src/index.ts b/src/index.ts index c3772b9..4711104 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,8 @@ export { advanceSurvival, } from "./state.js"; export { defaultConfig, validateConfig } from "./config.js"; +export { salvageParseRanges, extractRanges } from "./salvage-parse.js"; +export type { SalvageResult, SalvageLayer } from "./salvage-parse.js"; export { assignRefs, highestUsedIndex, diff --git a/src/salvage-parse.ts b/src/salvage-parse.ts new file mode 100644 index 0000000..ed3f0a2 --- /dev/null +++ b/src/salvage-parse.ts @@ -0,0 +1,259 @@ +import type { CompressRangeSpec } from "./types.js"; + +/** Why a salvage/parse path was taken. Surfaced in logs so operators can see + * how often weak models fall off the strict-JSON path — the ~50% arg-failure + * class of issues (see billion-context-omp#121) becomes measurable instead of + * a silent `{}`. */ +export type SalvageLayer = + | "json" // strict JSON.parse succeeded + | "json-fenced" // stripped ```json fences, then parsed + | "json-repaired" // fixed trailing commas / raw newlines inside strings, then parsed + | "array-prefix" // truncated content array: salvaged complete prefix entries + | "field-regex"; // last resort: per-field regex extraction + +export interface SalvageResult { + ranges: CompressRangeSpec[]; + layer: SalvageLayer; + /** Human-readable note for logs: what was wrong with the raw input. */ + note: string; +} + +const REF_RE = /(?:startId|startRef)["']?\s*[:=]?\s*["']?(m\d{4,7})["']?/i; +const END_RE = /(?:endId|endRef)["']?\s*[:=]?\s*["']?(m\d{4,7})["']?/i; +/** Prose shape: "compress from m00150 to m00220". */ +const FROMTO_RE = /from\s+(m\d{4,7})\s+(?:to|through|thru|-|–)\s+(m\d{4,7})/i; +const TOPIC_RE = + /topic["']?\s*[:=]\s*(?:"([^"'\n]{1,80})"|([^\n"']{1,80}))/i; +/** Summary value: quoted JSON string, or a `key: value` line running to EOL/next key. */ +const SUMMARY_RE = + /summary["']?\s*[:=]?\s*(?:"((?:[^"\\]|\\.)*)"|((?:[^"\n])[^\n]*))/i; + +function stripFences(s: string): string { + return s + .replace(/^\uFEFF/, "") + .replace(/^```(?:json)?\s*/i, "") + .replace(/\s*```\s*$/, "") + .trim(); +} + +/** Escape raw control characters that appear inside JSON string literals when + * a model emits unescaped newlines/tabs in a summary. Outside-of-string + * newlines (structural whitespace) are legal JSON and left untouched. */ +function repairStringLiterals(s: string): string { + let out = ""; + let inStr = false; + for (let i = 0; i < s.length; i++) { + const c = s[i]!; + if (inStr && c === "\\") { + out += c + (s[i + 1] ?? ""); + i++; + continue; + } + if (c === '"') inStr = !inStr; + if (inStr && (c === "\n" || c === "\r" || c === "\t")) { + out += c === "\n" ? "\\n" : c === "\r" ? "\\r" : "\\t"; + continue; + } + out += c; + } + return out; +} + +function stripTrailingCommas(s: string): string { + return s.replace(/,(\s*[}\]])/g, "$1"); +} + +interface RawRange { + startId?: unknown; + startRef?: unknown; + endId?: unknown; + endRef?: unknown; + summary?: unknown; + topic?: unknown; +} + +function toSpec(r: RawRange): CompressRangeSpec | null { + const startRef = typeof r.startId === "string" ? r.startId : typeof r.startRef === "string" ? r.startRef : undefined; + const endRef = typeof r.endId === "string" ? r.endId : typeof r.endRef === "string" ? r.endRef : undefined; + const summary = typeof r.summary === "string" ? r.summary : undefined; + if (!startRef || !endRef || !summary || !summary.trim()) return null; + const topic = typeof r.topic === "string" && r.topic.trim() ? r.topic : undefined; + return { startRef, endRef, summary, ...(topic ? { topic } : {}) }; +} + +/** Find the longest prefix of `content`-array entries that each parse as + * complete objects. Stops at the first entry that is truncated/malformed. */ +function salvageArrayPrefix(arr: unknown[]): CompressRangeSpec[] { + const out: CompressRangeSpec[] = []; + for (const item of arr) { + if (!item || typeof item !== "object") break; + const spec = toSpec(item as RawRange); + if (!spec) break; // incomplete entry — everything after it is suspect + out.push(spec); + } + return out; +} + +/** Salvage complete entries from a truncated payload by scanning for + * balanced `{...}` objects and parsing each individually. Works whether the + * truncation happened inside the `content` array or inside a wrapper object + * (`{"content":[...]}` cut mid-way — the wrapper never closes, so the whole + * payload can't parse; the inner complete entries still can). Recovered + * entries must still carry all required fields (a half-written summary is + * rejected by the summary-min-length gate downstream, so no data-loss risk). */ +function salvageTruncatedArray(raw: string): CompressRangeSpec[] { + const out: CompressRangeSpec[] = []; + let depth = 0; + let inStr = false; + /** Stack of `{` offsets (with the depth they opened at). Popping on `}` + * gives the matching open for every closed object — the innermost-object + * slice is parsed even when the surrounding wrapper never closes. */ + const entryStarts: Array<{ depth: number; i: number }> = []; + for (let i = 0; i < raw.length; i++) { + const c = raw[i]!; + if (inStr) { + if (c === "\\") i++; + else if (c === '"') inStr = false; + continue; + } + if (c === '"') inStr = true; + else if (c === "{") { + depth++; + entryStarts.push({ depth, i }); + } else if (c === "}") { + depth--; + const opened = entryStarts.pop(); + if (opened && opened.depth - 1 === depth) { + try { + const spec = toSpec(JSON.parse(raw.slice(opened.i, i + 1)) as RawRange); + if (spec) out.push(spec); + } catch { + /* skip unparseable entry */ + } + } + } + } + return out; +} + +function fieldRegexExtract(s: string): CompressRangeSpec[] { + const out: CompressRangeSpec[] = []; + // Split on entry boundaries: a startId occurrence begins a new range. + const parts = s.split(/(?=(?:startId|startRef))/i).filter((p) => /startId|startRef/i.test(p)); + for (const p of parts) { + const startRef = REF_RE.exec(p)?.[1]; + const endRef = END_RE.exec(p)?.[1]; + const sm = SUMMARY_RE.exec(p); + const summary = sm?.[1] !== undefined ? unescapeJson(sm[1]) : sm?.[2]?.trim(); + const topic = TOPIC_RE.exec(p)?.[1] ?? TOPIC_RE.exec(p)?.[2]; + if (startRef && endRef && summary && summary.trim().length >= 50) { + out.push({ startRef, endRef, summary, ...(topic ? { topic } : {}) }); + } + } + if (out.length > 0) return out; + // Prose shape: "compress from m00150 to m00220. summary = ..." with no + // explicit startId/endId keys at all. + const ft = FROMTO_RE.exec(s); + if (ft) { + const sm = SUMMARY_RE.exec(s); + const summary = sm?.[1] !== undefined ? unescapeJson(sm[1]) : sm?.[2]?.trim(); + const topic = TOPIC_RE.exec(s)?.[1] ?? TOPIC_RE.exec(s)?.[2]; + if (summary && summary.trim().length >= 50) { + out.push({ startRef: ft[1]!, endRef: ft[2]!, summary, ...(topic ? { topic } : {}) }); + } + } + return out; +} + +function unescapeJson(s: string): string { + try { + return JSON.parse(`"${s}"`) as string; + } catch { + return s; + } +} + +/** Lenient compress-arguments parser shared by all billion-context hosts. + * Layers (each only runs if the previous one failed): + * 1. strict JSON.parse of the whole payload + * 2. strip ``` fences, parse + * 3. repair trailing commas / raw newlines in string literals, parse + * 4. truncated `content` array → salvage complete prefix entries + * 5. per-field regex extraction (startId/endId/summary/topic) + * Returns ranges + the deepest layer reached + a log note. Never throws. */ +export function salvageParseRanges(raw: string): SalvageResult { + const trimmed = raw.trim(); + if (!trimmed) return { ranges: [], layer: "json", note: "empty arguments" }; + + // Layer 1-3: whole-payload JSON (with repairs). Also accepts the + // JSON-stringified-content double-encoding some providers emit. + const attempts: Array<{ s: string; layer: SalvageLayer; desc: string }> = [ + { s: trimmed, layer: "json", desc: "strict JSON" }, + { s: stripFences(trimmed), layer: "json-fenced", desc: "fenced JSON" }, + { + s: stripTrailingCommas(repairStringLiterals(stripFences(trimmed))), + layer: "json-repaired", + desc: "repaired JSON", + }, + ]; + for (const a of attempts) { + if (!a.s) continue; + let parsed: unknown; + try { + parsed = JSON.parse(a.s); + } catch { + continue; + } + const ranges = extractRanges(parsed); + if (ranges.length > 0) { + return { ranges, layer: a.layer, note: `parsed as ${a.desc}` }; + } + } + + // Layer 4: truncated content array — salvage complete entries. + const salvaged = salvageTruncatedArray(trimmed); + if (salvaged.length > 0) { + return { + ranges: salvaged, + layer: "array-prefix", + note: `truncated JSON; salvaged ${salvaged.length} complete range(s)`, + }; + } + + // Layer 5: field-regex fallback. + const regexRanges = fieldRegexExtract(trimmed); + if (regexRanges.length > 0) { + return { + ranges: regexRanges, + layer: "field-regex", + note: `non-JSON text; regex-extracted ${regexRanges.length} range(s)`, + }; + } + + return { + ranges: [], + layer: "field-regex", + note: "unparseable: no layer produced ranges", + }; +} + +/** Normalize a parsed JSON value into range specs. Handles: + * - {content: [...]} / {content: "JSON-string"} (double-encoded args) + * - bare [...] array + * - single {startId,...} object */ +export function extractRanges(parsed: unknown): CompressRangeSpec[] { + if (Array.isArray(parsed)) return salvageArrayPrefix(parsed); + if (!parsed || typeof parsed !== "object") return []; + const obj = parsed as Record; + let content: unknown = obj.content ?? obj.ranges; + if (typeof content === "string") { + try { + content = JSON.parse(content); + } catch { + return []; + } + } + if (Array.isArray(content)) return salvageArrayPrefix(content); + const single = toSpec(obj); + return single ? [single] : []; +} diff --git a/tests/salvage-parse.test.ts b/tests/salvage-parse.test.ts new file mode 100644 index 0000000..59373c5 --- /dev/null +++ b/tests/salvage-parse.test.ts @@ -0,0 +1,127 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { salvageParseRanges, extractRanges } from "../src/salvage-parse.js"; + +const LONG_SUMMARY = + "Analyzed the billing export pipeline: root cause was a timezone drift in the scheduler (UTC vs local) duplicating 3% of rows nightly since Aug 12. Fixed by pinning TZ in cron and adding a dedup migration at /srv/billing/migrations/0042_dedup.sql. Verified 14 days of data."; + +describe("salvageParseRanges — layers 1-3 (valid-ish JSON)", () => { + it("strict JSON parses at layer json", () => { + const raw = JSON.stringify({ + content: [{ startId: "m00004", endId: "m00018", summary: LONG_SUMMARY, topic: "billing" }], + }); + const r = salvageParseRanges(raw); + assert.equal(r.layer, "json"); + assert.equal(r.ranges.length, 1); + assert.equal(r.ranges[0]!.startRef, "m00004"); + assert.equal(r.ranges[0]!.topic, "billing"); + }); + + it("fenced JSON parses at layer json-fenced", () => { + const raw = "```json\n" + JSON.stringify({ content: [{ startId: "m00150", endId: "m00220", summary: LONG_SUMMARY }] }) + "\n```"; + const r = salvageParseRanges(raw); + assert.equal(r.layer, "json-fenced"); + assert.equal(r.ranges.length, 1); + }); + + it("trailing comma + raw newline inside summary repaired at json-repaired", () => { + const raw = + '{"content":[{"startId":"m00150","endId":"m00220","summary":"line one\nline two ' + + LONG_SUMMARY + + '",},],}'; + const r = salvageParseRanges(raw); + assert.equal(r.layer, "json-repaired"); + assert.equal(r.ranges.length, 1); + assert.ok(r.ranges[0]!.summary.includes("line one\nline two")); + }); + + it("JSON-string content (double-encoded) is unwrapped", () => { + const inner = JSON.stringify([{ startId: "m00010", endId: "m00012", summary: LONG_SUMMARY }]); + const r = salvageParseRanges(JSON.stringify({ content: inner })); + assert.equal(r.ranges.length, 1); + assert.equal(r.ranges[0]!.endRef, "m00012"); + }); + + it("bare array payload works", () => { + const r = salvageParseRanges(JSON.stringify([{ startId: "m00001", endId: "m00005", summary: LONG_SUMMARY }])); + assert.equal(r.ranges.length, 1); + }); + + it("single top-level range object works", () => { + const r = salvageParseRanges(JSON.stringify({ startId: "m00001", endId: "m00005", summary: LONG_SUMMARY })); + assert.equal(r.ranges.length, 1); + }); +}); + +describe("salvageParseRanges — layer 4 (truncated array salvage)", () => { + it("truncated content array salvages the complete prefix entry", () => { + const full = + '{"content":[{"startId":"m00150","endId":"m00220","summary":"' + LONG_SUMMARY + '"},{"startId":"m00300","endId":"m0'; + const r = salvageParseRanges(full); + assert.equal(r.layer, "array-prefix"); + assert.equal(r.ranges.length, 1); + assert.equal(r.ranges[0]!.startRef, "m00150"); + }); + + it("all entries truncated to nothing returns 0 ranges", () => { + const r = salvageParseRanges('{"content":[{"startId":"m0030'); + assert.equal(r.ranges.length, 0); + }); +}); + +describe("salvageParseRanges — layer 5 (non-JSON prose fallback)", () => { + it("field-per-line text yields a range", () => { + const raw = `startId: m00150 +endId: m00220 +topic: billing fix +summary: ${LONG_SUMMARY}`; + const r = salvageParseRanges(raw); + assert.equal(r.layer, "field-regex"); + assert.equal(r.ranges.length, 1); + assert.equal(r.ranges[0]!.startRef, "m00150"); + assert.equal(r.ranges[0]!.topic, "billing fix"); + }); + + it("prose with startId/endId markers inline", () => { + const raw = `Compress from m00010 to m00025. summary = "${LONG_SUMMARY}" — everything else is noise.`; + const r = salvageParseRanges(raw); + assert.equal(r.ranges.length, 1); + assert.equal(r.ranges[0]!.startRef, "m00010"); + assert.equal(r.ranges[0]!.endRef, "m00025"); + }); + + it("short summaries are rejected (< 50 chars) in regex layer", () => { + const raw = `startId: m00150\nendId: m00220\nsummary: too short`; + const r = salvageParseRanges(raw); + assert.equal(r.ranges.length, 0); + }); +}); + +describe("salvageParseRanges — total garbage", () => { + it("empty input", () => { + const r = salvageParseRanges(" "); + assert.equal(r.ranges.length, 0); + }); + + it("unrelated prose returns 0 ranges and never throws", () => { + const r = salvageParseRanges("The quick brown fox jumps over the lazy dog. " + LONG_SUMMARY); + assert.equal(r.ranges.length, 0); + }); +}); + +describe("extractRanges", () => { + it("rejects non-object/non-array", () => { + assert.deepEqual(extractRanges(null), []); + assert.deepEqual(extractRanges(42), []); + assert.deepEqual(extractRanges("string"), []); + }); + + it("drops incomplete entries at the end of arrays", () => { + const r = extractRanges([ + { startId: "m00001", endId: "m00005", summary: LONG_SUMMARY }, + { startId: "m00006" }, // missing endId/summary + { startId: "m00007", endId: "m00008", summary: LONG_SUMMARY }, // after a gap — dropped + ]); + assert.equal(r.length, 1); + }); +}); From f564ffeee5b98bb8272bf7abd7cb2788442be0e2 Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Sat, 22 Aug 2026 13:43:07 +0800 Subject: [PATCH 2/3] fix: salvage double-encoded content truncated mid-array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrapper JSON parses fine but its content string is truncated — layers 1-3 succeeded as JSON yet produced 0 ranges, and the raw brace scanner can't see through escaped quotes. Recurse salvageParseRanges into the inner string (bounded: one encoding layer per level). test: double-encoded content truncated mid-array --- src/salvage-parse.ts | 22 ++++++++++++++++++++++ tests/salvage-parse.test.ts | 13 +++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/salvage-parse.ts b/src/salvage-parse.ts index ed3f0a2..0140db9 100644 --- a/src/salvage-parse.ts +++ b/src/salvage-parse.ts @@ -187,6 +187,7 @@ export function salvageParseRanges(raw: string): SalvageResult { // Layer 1-3: whole-payload JSON (with repairs). Also accepts the // JSON-stringified-content double-encoding some providers emit. + let parsedObj: Record | undefined; const attempts: Array<{ s: string; layer: SalvageLayer; desc: string }> = [ { s: trimmed, layer: "json", desc: "strict JSON" }, { s: stripFences(trimmed), layer: "json-fenced", desc: "fenced JSON" }, @@ -208,6 +209,27 @@ export function salvageParseRanges(raw: string): SalvageResult { if (ranges.length > 0) { return { ranges, layer: a.layer, note: `parsed as ${a.desc}` }; } + if (!parsedObj && parsed && typeof parsed === "object") { + parsedObj = parsed as Record; + } + } + + // Double-encoded + truncated: the wrapper parses as JSON but its `content` + // string does not (cut mid-way). Salvage inside the inner string — the brace + // scanner on the raw payload can't see through the escaped quotes. + // Recursion is bounded: each level strips one encoding layer. + if (parsedObj) { + const inner = parsedObj.content ?? parsedObj.ranges; + if (typeof inner === "string" && inner.trim().startsWith("[")) { + const innerRes = salvageParseRanges(inner); + if (innerRes.ranges.length > 0) { + return { + ranges: innerRes.ranges, + layer: "array-prefix", + note: `double-encoded content; inner: ${innerRes.note}`, + }; + } + } } // Layer 4: truncated content array — salvage complete entries. diff --git a/tests/salvage-parse.test.ts b/tests/salvage-parse.test.ts index 59373c5..60f63d5 100644 --- a/tests/salvage-parse.test.ts +++ b/tests/salvage-parse.test.ts @@ -125,3 +125,16 @@ describe("extractRanges", () => { assert.equal(r.length, 1); }); }); + +describe("salvageParseRanges — double-encoded content", () => { + it("salvages a JSON-string content truncated mid-array", () => { + const raw = JSON.stringify({ + content: '[{"startId":"m00005","endId":"m00006","summary":"ok"},{"startId":"m0', + }); + const res = salvageParseRanges(raw); + assert.equal(res.layer, "array-prefix"); + assert.equal(res.ranges.length, 1); + assert.equal(res.ranges[0]?.startRef, "m00005"); + assert.equal(res.ranges[0]?.summary, "ok"); + }); +}); From 5892169b1adc03cc035454fa262e78421a61aeaf Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Sat, 22 Aug 2026 13:55:34 +0800 Subject: [PATCH 3/3] release v0.0.34 (0.0.33 publish failed on npm 404) --- package.json | 120 +++++++++++++++++++++++++-------------------------- 1 file changed, 60 insertions(+), 60 deletions(-) diff --git a/package.json b/package.json index 2709f95..a8379ec 100644 --- a/package.json +++ b/package.json @@ -1,64 +1,64 @@ { - "name": "acp-kernel", - "version": "0.0.33", - "description": "Framework-agnostic context-compression engine (model-driven, 3-tier LSM). Pure core: no host dependency.", - "license": "MIT", - "author": "ranxianglei", - "type": "module", - "homepage": "https://github.com/ranxianglei/acp-kernel#readme", - "repository": { - "type": "git", - "url": "git+https://github.com/ranxianglei/acp-kernel.git" + "name": "acp-kernel", + "version": "0.0.34", + "description": "Framework-agnostic context-compression engine (model-driven, 3-tier LSM). Pure core: no host dependency.", + "license": "MIT", + "author": "ranxianglei", + "type": "module", + "homepage": "https://github.com/ranxianglei/acp-kernel#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/ranxianglei/acp-kernel.git" + }, + "bugs": { + "url": "https://github.com/ranxianglei/acp-kernel/issues" + }, + "keywords": [ + "acp", + "context", + "compression", + "context-management", + "llm", + "context-window", + "summarization" + ], + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" }, - "bugs": { - "url": "https://github.com/ranxianglei/acp-kernel/issues" - }, - "keywords": [ - "acp", - "context", - "compression", - "context-management", - "llm", - "context-window", - "summarization" - ], - "main": "./dist/index.js", - "module": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - }, - "./wire": { - "types": "./dist/wire/index.d.ts", - "import": "./dist/wire/index.js" - } - }, - "files": [ - "dist", - "README.md", - "LICENSE", - "DESIGN.md", - "PROVENANCE.md" - ], - "sideEffects": false, - "scripts": { - "clean": "rm -rf dist", - "build": "npm run clean && tsup && tsc --emitDeclarationOnly", - "typecheck": "tsc --noEmit", - "test": "node --import tsx --test tests/*.test.ts", - "format": "prettier --write .", - "format:check": "prettier --check ." - }, - "devDependencies": { - "@types/node": "^22.0.0", - "prettier": "^3.3.0", - "tsup": "^8.3.0", - "tsx": "^4.19.0", - "typescript": "^5.6.0" - }, - "engines": { - "node": ">=20" + "./wire": { + "types": "./dist/wire/index.d.ts", + "import": "./dist/wire/index.js" } + }, + "files": [ + "dist", + "README.md", + "LICENSE", + "DESIGN.md", + "PROVENANCE.md" + ], + "sideEffects": false, + "scripts": { + "clean": "rm -rf dist", + "build": "npm run clean && tsup && tsc --emitDeclarationOnly", + "typecheck": "tsc --noEmit", + "test": "node --import tsx --test tests/*.test.ts", + "format": "prettier --write .", + "format:check": "prettier --check ." + }, + "devDependencies": { + "@types/node": "^22.0.0", + "prettier": "^3.3.0", + "tsup": "^8.3.0", + "tsx": "^4.19.0", + "typescript": "^5.6.0" + }, + "engines": { + "node": ">=20" + } }