From f0cd2a9de27e78591a694c5897f66d7f65ef6a14 Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+catomean@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:18:51 +0200 Subject: [PATCH 1/4] chore: add a formatter, matching the style this repo already writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit singleQuote=false was chosen by counting this repo's own imports, not by fleet decree. The fleet is genuinely split and the two repos that already had a .prettierrc disagreed with each other, so there was no standard to restore. Quote style does not cross repo boundaries; having a gate does. Markdown is ignored for now — prettier rewraps prose, which would bury the real diff. Co-Authored-By: Claude Opus 5 --- .prettierignore | 31 +++++++++++++++++++++++++++++++ .prettierrc | 9 +++++++++ package-lock.json | 23 ++++++++++++++++++++--- package.json | 7 +++++-- 4 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..b698663 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,31 @@ +# Build output and vendored trees — formatting these is noise. +node_modules +.next +dist +build +out +coverage +.turbo +.vercel +*.min.js +*.min.css + +# Generated during a build, so it is absent locally and present in CI — which +# makes a clean local --check no evidence at all. Contentlayer's output also +# uses import assertions, which prettier's parser rejects outright. +.contentlayer +.astro +.svelte-kit +storybook-static +test-results +playwright-report + +# Lockfiles are generated; prettier would rewrite them wholesale. +package-lock.json +pnpm-lock.yaml +yarn.lock + +# Markdown is deliberately out of scope for now. Prettier rewraps prose, which +# is where it is most opinionated and least useful, and it would bury the real +# diff. Remove this line when you want docs formatted too. +*.md diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..a2f11f0 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "semi": true, + "singleQuote": false, + "printWidth": 100, + "tabWidth": 2, + "trailingComma": "all", + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/package-lock.json b/package-lock.json index e7a1cd5..9290ed3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ai-kit", - "version": "0.4.0", + "version": "0.6.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ai-kit", - "version": "0.4.0", + "version": "0.6.2", "license": "MIT", "dependencies": { "ai-forms": "^0.1.2" @@ -16,11 +16,12 @@ "@types/node": "^22.10.2", "eslint": "^9.39.5", "globals": "^15.15.0", + "prettier": "3.9.6", "typescript": "^5.8.2", "typescript-eslint": "^8.67.0" }, "engines": { - "node": ">=18" + "node": ">=20" }, "peerDependencies": { "react": ">=18" @@ -1336,6 +1337,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/package.json b/package.json index 2d85315..8d99dd1 100644 --- a/package.json +++ b/package.json @@ -74,14 +74,17 @@ "typecheck": "tsc -p tsconfig.json --noEmit", "test": "node --test test/*.test.js", "check:catalog": "npm run build && node scripts/check-catalog.mjs", - "verify": "npm run lint && npm run typecheck && npm run build && npm test", - "prepare": "npm run build" + "verify": "npm run format:check && npm run lint && npm run typecheck && npm run build && npm test", + "prepare": "npm run build", + "format": "prettier --write .", + "format:check": "prettier --check ." }, "devDependencies": { "@eslint/js": "^9.39.5", "@types/node": "^22.10.2", "eslint": "^9.39.5", "globals": "^15.15.0", + "prettier": "3.9.6", "typescript": "^5.8.2", "typescript-eslint": "^8.67.0" }, From 7d28c83b9e0527e874a5376eae0d95eb7c0eaedf Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+catomean@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:19:05 +0200 Subject: [PATCH 2/4] style: format with prettier (27 files) Mechanical. No behaviour change. This SHA is listed in .git-blame-ignore-revs so `git blame` skips it. Co-Authored-By: Claude Opus 5 --- .github/workflows/publish.yml | 6 +- dist-cjs/grounding/contract.js | 121 +++++----- dist-cjs/grounding/facts.js | 94 ++++---- dist-cjs/grounding/index.js | 130 ++++++++-- dist-cjs/grounding/verify.js | 417 +++++++++++++++++++-------------- dist-cjs/package.json | 4 +- dist-cjs/registry.js | 123 +++++----- eslint.config.mjs | 14 +- scripts/check-catalog.mjs | 8 +- src/catalog.ts | 12 +- src/chain.ts | 5 +- src/grounding/facts.ts | 4 +- src/grounding/verify.ts | 116 ++++++++- src/index.ts | 1 - src/limits.ts | 4 +- src/registry.ts | 10 +- test/attempt.test.js | 84 ++++--- test/catalog.test.js | 76 +++--- test/chain.test.js | 110 +++++---- test/cjs-condition.test.js | 36 +-- test/cost.test.js | 70 +++--- test/exports.test.js | 59 +++-- test/fair-share.test.js | 75 +++--- test/grounding.test.js | 46 ++-- test/health.test.js | 74 +++--- test/limits.test.js | 67 +++--- test/registry.test.js | 105 ++++++--- 27 files changed, 1117 insertions(+), 754 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c2e2d43..7e34106 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,7 +9,7 @@ name: Publish # next thing it ignores will be a real failure. on: push: - tags: ['v*'] + tags: ["v*"] jobs: publish: @@ -23,8 +23,8 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: '22' - registry-url: 'https://registry.npmjs.org' + node-version: "22" + registry-url: "https://registry.npmjs.org" - run: npm ci --ignore-scripts diff --git a/dist-cjs/grounding/contract.js b/dist-cjs/grounding/contract.js index 357f724..95dffcc 100644 --- a/dist-cjs/grounding/contract.js +++ b/dist-cjs/grounding/contract.js @@ -40,29 +40,32 @@ exports.NO_BASIS = "Not in your data."; * plainly beats hoping the model notices the context block is empty. */ function buildContract(facts, directives = []) { - const ids = [ - ...facts.map((f) => `[${f.id}]`), - ...directives.map((_, i) => `[${directiveId(i)}]`), - ].join(" "); - const gaps = (0, facts_js_1.unrecordedFields)(facts); - const rules = [ - "## Grounding contract — this overrides every formatting instruction below", - "", - "You are answering from a fixed set of records. They are the ONLY things you know about the operator.", - "", - facts.length === 0 && directives.length === 0 - ? `1. NO records were retrieved for this turn. You therefore cannot answer any question about the operator's projects, people, goals, habits, commitments or events. Reply "${exports.NO_BASIS}" and say what you would need.` - : `1. Every claim about the operator MUST cite a record id. Legal citations this turn, and no others: ${ids}`, - `2. A field shown as \`${facts_js_1.NOT_RECORDED}\` means you DO NOT KNOW it. Never supply a value for it — not from the record's own wording, not from a name that looks like a place or an organisation, not from general knowledge about a similarly-named person. A surname is not an employer.`, - `3. If any part of the request has no supporting record, answer that part with exactly "${exports.NO_BASIS}" and continue with the parts you can support. A requested format NEVER obliges you to invent an item. Returning three of five requested items, each cited, is a correct and complete answer.`, - "4. Do not describe a person's role, employer, seniority, or history unless a record field states it. Do not infer an organisation from a name.", - "5. You have not browsed the web this turn. If asked to research someone, say you cannot and report only what the records hold.", - "6. If you are correcting an earlier answer, the correction is subject to every rule above — cite the record, or say the record does not exist.", - ]; - if (gaps.length > 0) { - rules.push("", `Unrecorded in THIS turn's records — you have no value for any of these and must not state one: ${gaps.join(", ")}`); - } - return rules.join("\n"); + const ids = [ + ...facts.map((f) => `[${f.id}]`), + ...directives.map((_, i) => `[${directiveId(i)}]`), + ].join(" "); + const gaps = (0, facts_js_1.unrecordedFields)(facts); + const rules = [ + "## Grounding contract — this overrides every formatting instruction below", + "", + "You are answering from a fixed set of records. They are the ONLY things you know about the operator.", + "", + facts.length === 0 && directives.length === 0 + ? `1. NO records were retrieved for this turn. You therefore cannot answer any question about the operator's projects, people, goals, habits, commitments or events. Reply "${exports.NO_BASIS}" and say what you would need.` + : `1. Every claim about the operator MUST cite a record id. Legal citations this turn, and no others: ${ids}`, + `2. A field shown as \`${facts_js_1.NOT_RECORDED}\` means you DO NOT KNOW it. Never supply a value for it — not from the record's own wording, not from a name that looks like a place or an organisation, not from general knowledge about a similarly-named person. A surname is not an employer.`, + `3. If any part of the request has no supporting record, answer that part with exactly "${exports.NO_BASIS}" and continue with the parts you can support. A requested format NEVER obliges you to invent an item. Returning three of five requested items, each cited, is a correct and complete answer.`, + "4. Do not describe a person's role, employer, seniority, or history unless a record field states it. Do not infer an organisation from a name.", + "5. You have not browsed the web this turn. If asked to research someone, say you cannot and report only what the records hold.", + "6. If you are correcting an earlier answer, the correction is subject to every rule above — cite the record, or say the record does not exist.", + ]; + if (gaps.length > 0) { + rules.push( + "", + `Unrecorded in THIS turn's records — you have no value for any of these and must not state one: ${gaps.join(", ")}`, + ); + } + return rules.join("\n"); } /** * The subset of the contract that needs no fact ids — for an assistant whose @@ -79,16 +82,16 @@ function buildContract(facts, directives = []) { * layer; the destination is typed records here too. */ function buildAssistantRules(opts) { - return [ - "## Grounding rules — these override formatting instructions", - "", - `1. Everything you state about the user's own ${opts.subjectNoun} must come from the context above. Do not add an organisation, role, employer, history, or relationship that the context does not state.`, - "2. Do not infer an affiliation from a name. A word inside someone's name is not their employer or their city.", - "3. You have not browsed the web in this turn. If asked to research a person or company, say you cannot, and report only what the context holds.", - `4. If part of the request has no support in the context, answer that part with exactly "${exports.NO_BASIS}" and continue with the parts you can support. A requested format never obliges you to invent an item.`, - "5. General knowledge (how Bitcoin, Lightning, or a payment method works) is fine to use and is not covered by rules 1–2. The restriction is on facts about THIS user and the people and organisations in their data.", - "6. A correction is a claim too. If you are correcting yourself, it must be supported by the context or stated as unknown.", - ].join("\n"); + return [ + "## Grounding rules — these override formatting instructions", + "", + `1. Everything you state about the user's own ${opts.subjectNoun} must come from the context above. Do not add an organisation, role, employer, history, or relationship that the context does not state.`, + "2. Do not infer an affiliation from a name. A word inside someone's name is not their employer or their city.", + "3. You have not browsed the web in this turn. If asked to research a person or company, say you cannot, and report only what the context holds.", + `4. If part of the request has no support in the context, answer that part with exactly "${exports.NO_BASIS}" and continue with the parts you can support. A requested format never obliges you to invent an item.`, + "5. General knowledge (how Bitcoin, Lightning, or a payment method works) is fine to use and is not covered by rules 1–2. The restriction is on facts about THIS user and the people and organisations in their data.", + "6. A correction is a claim too. If you are correcting yourself, it must be supported by the context or stated as unknown.", + ].join("\n"); } /** * Citation handle for a computed answer, parallel to a Fact's [F1]. @@ -100,7 +103,7 @@ function buildAssistantRules(opts) { * ids and the sentence cites [D1] like anything else. */ function directiveId(index) { - return `D${index + 1}`; + return `D${index + 1}`; } /** * Render computed answers. These are stated as settled, because they are: the @@ -109,22 +112,22 @@ function directiveId(index) { * fact set. */ function renderDirectives(directives) { - if (directives.length === 0) - return ""; - const blocks = directives.map((d, i) => { - const body = d.answer.length > 0 - ? d.answer.map((a) => ` - ${a}`).join("\n") - : " (none — the query ran and matched nothing)"; - return ` [${directiveId(i)}] ${d.question} [${d.method}]\n${body}`; - }); - return [ - "## Computed answers — already resolved, do not re-derive", - "These were computed directly from the database for this turn. They are exact.", - "Report them as given and cite their id, exactly as you would a record.", - "Where the result is empty, say so plainly — do not substitute a plausible item from the records.", - "", - ...blocks, - ].join("\n"); + if (directives.length === 0) return ""; + const blocks = directives.map((d, i) => { + const body = + d.answer.length > 0 + ? d.answer.map((a) => ` - ${a}`).join("\n") + : " (none — the query ran and matched nothing)"; + return ` [${directiveId(i)}] ${d.question} [${d.method}]\n${body}`; + }); + return [ + "## Computed answers — already resolved, do not re-derive", + "These were computed directly from the database for this turn. They are exact.", + "Report them as given and cite their id, exactly as you would a record.", + "Where the result is empty, say so plainly — do not substitute a plausible item from the records.", + "", + ...blocks, + ].join("\n"); } /** * Assemble the full grounded context: contract, computed answers, then records. @@ -134,13 +137,13 @@ function renderDirectives(directives) { * the user's question — the position small models weight most heavily. */ function buildGroundedContext(input) { - return [ - buildContract(input.facts, input.directives ?? []), - renderDirectives(input.directives ?? []), - input.facts.length > 0 - ? ["## Records", "", input.renderedFacts].join("\n") - : "## Records\n\n(none retrieved)", - ] - .filter(Boolean) - .join("\n\n---\n\n"); + return [ + buildContract(input.facts, input.directives ?? []), + renderDirectives(input.directives ?? []), + input.facts.length > 0 + ? ["## Records", "", input.renderedFacts].join("\n") + : "## Records\n\n(none retrieved)", + ] + .filter(Boolean) + .join("\n\n---\n\n"); } diff --git a/dist-cjs/grounding/facts.js b/dist-cjs/grounding/facts.js index a292539..0cedb2b 100644 --- a/dist-cjs/grounding/facts.js +++ b/dist-cjs/grounding/facts.js @@ -49,24 +49,24 @@ exports.NOT_RECORDED = ""; * point. Do not "clean up" this list by deleting the empty ones. */ exports.FACT_KINDS = { - person: ["name", "affiliation", "role", "how_we_met", "last_interaction", "notes", "channels"], - project: ["name", "status", "stack", "description", "latest_dev_log", "repo"], - goal: ["title", "project", "progress", "target_date", "last_updated"], - habit: ["title", "frequency", "current_streak", "last_checked"], - commitment: ["title", "due", "counterparty", "status"], - event: ["name", "type", "deadline", "url", "status"], - // Humans the operator delegates to, and the work handed to them. Separate - // from `person`/`commitment` because the questions are different: a crew - // member is asked what they are good FOR, an assignment is asked who has it - // and whether they said yes. - crew_member: ["name", "role", "skills", "engagement", "rate", "availability", "open_assignments"], - assignment: ["title", "assignee", "status", "due", "fee", "why"], - document: ["title", "source", "excerpt"], - pending_action: ["title", "type", "reasoning", "proposed_on", "id"], + person: ["name", "affiliation", "role", "how_we_met", "last_interaction", "notes", "channels"], + project: ["name", "status", "stack", "description", "latest_dev_log", "repo"], + goal: ["title", "project", "progress", "target_date", "last_updated"], + habit: ["title", "frequency", "current_streak", "last_checked"], + commitment: ["title", "due", "counterparty", "status"], + event: ["name", "type", "deadline", "url", "status"], + // Humans the operator delegates to, and the work handed to them. Separate + // from `person`/`commitment` because the questions are different: a crew + // member is asked what they are good FOR, an assignment is asked who has it + // and whether they said yes. + crew_member: ["name", "role", "skills", "engagement", "rate", "availability", "open_assignments"], + assignment: ["title", "assignee", "status", "due", "fee", "why"], + document: ["title", "source", "excerpt"], + pending_action: ["title", "type", "reasoning", "proposed_on", "id"], }; /** Field list for a kind; unknown kinds fall back to whatever the fact carries. */ function declaredFields(kind, fallback = []) { - return exports.FACT_KINDS[kind] ?? fallback; + return exports.FACT_KINDS[kind] ?? fallback; } /** * Build a Fact with every declared field present. Values not supplied become @@ -75,29 +75,29 @@ function declaredFields(kind, fallback = []) { * FACT_KINDS, otherwise the registry stops describing what the model sees. */ function makeFact(input) { - const keys = declaredFields(input.kind, Object.keys(input.values ?? {})); - const fields = {}; - for (const key of keys) { - const raw = input.values?.[key]; - const trimmed = typeof raw === "string" ? raw.trim() : raw; - fields[key] = trimmed ? String(trimmed) : null; - } - return { - id: "", - kind: input.kind, - subject: input.subject, - source: input.source, - fields, - ...(input.similarity !== undefined ? { similarity: input.similarity } : {}), - }; + const keys = declaredFields(input.kind, Object.keys(input.values ?? {})); + const fields = {}; + for (const key of keys) { + const raw = input.values?.[key]; + const trimmed = typeof raw === "string" ? raw.trim() : raw; + fields[key] = trimmed ? String(trimmed) : null; + } + return { + id: "", + kind: input.kind, + subject: input.subject, + source: input.source, + fields, + ...(input.similarity !== undefined ? { similarity: input.similarity } : {}), + }; } /** Stamp sequential citation ids. Call once, after assembling the final set. */ function assignFactIds(facts) { - return facts.map((f, i) => ({ ...f, id: `F${i + 1}` })); + return facts.map((f, i) => ({ ...f, id: `F${i + 1}` })); } /** Every citation handle in a fact set — the only legal citations in an answer. */ function factIds(facts) { - return new Set(facts.map((f) => f.id)); + return new Set(facts.map((f) => f.id)); } /** * Render facts for the model. One block per record, every declared field on its @@ -115,15 +115,16 @@ function factIds(facts) { * hallucinates roles, over a field list it reports ``. */ function renderFacts(facts) { - if (facts.length === 0) - return ""; - return facts - .map((f) => { - const head = `[${f.id}] ${f.kind} — ${f.subject} (${f.source})`; - const body = Object.entries(f.fields).map(([k, v]) => ` ${k}: ${v ?? exports.NOT_RECORDED}`); - return [head, ...body].join("\n"); + if (facts.length === 0) return ""; + return facts + .map((f) => { + const head = `[${f.id}] ${f.kind} — ${f.subject} (${f.source})`; + const body = Object.entries(f.fields).map( + ([k, v]) => ` ${k}: ${v ?? exports.NOT_RECORDED}`, + ); + return [head, ...body].join("\n"); }) - .join("\n\n"); + .join("\n\n"); } /** * Which declared fields are unrecorded across the set, as @@ -132,12 +133,11 @@ function renderFacts(facts) { * context rather than being a standing abstraction the model may ignore. */ function unrecordedFields(facts) { - const gaps = new Set(); - for (const f of facts) { - for (const [k, v] of Object.entries(f.fields)) { - if (v === null) - gaps.add(`${f.kind}.${k}`); - } + const gaps = new Set(); + for (const f of facts) { + for (const [k, v] of Object.entries(f.fields)) { + if (v === null) gaps.add(`${f.kind}.${k}`); } - return [...gaps].sort(); + } + return [...gaps].sort(); } diff --git a/dist-cjs/grounding/index.js b/dist-cjs/grounding/index.js index 57a9c9d..9939a49 100644 --- a/dist-cjs/grounding/index.js +++ b/dist-cjs/grounding/index.js @@ -1,6 +1,22 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.buildRepairPrompt = exports.verifyAnswer = exports.buildGroundedContext = exports.renderDirectives = exports.directiveId = exports.buildAssistantRules = exports.buildContract = exports.NO_BASIS = exports.unrecordedFields = exports.renderFacts = exports.factIds = exports.assignFactIds = exports.makeFact = exports.declaredFields = exports.FACT_KINDS = exports.NOT_RECORDED = void 0; +exports.buildRepairPrompt = + exports.verifyAnswer = + exports.buildGroundedContext = + exports.renderDirectives = + exports.directiveId = + exports.buildAssistantRules = + exports.buildContract = + exports.NO_BASIS = + exports.unrecordedFields = + exports.renderFacts = + exports.factIds = + exports.assignFactIds = + exports.makeFact = + exports.declaredFields = + exports.FACT_KINDS = + exports.NOT_RECORDED = + void 0; /** * The grounding harness — imported, no longer mirrored. * @@ -23,21 +39,101 @@ exports.buildRepairPrompt = exports.verifyAnswer = exports.buildGroundedContext * data lives belongs in the app adapter that maps rows to `Fact`s, not here. */ var facts_js_1 = require("./facts.js"); -Object.defineProperty(exports, "NOT_RECORDED", { enumerable: true, get: function () { return facts_js_1.NOT_RECORDED; } }); -Object.defineProperty(exports, "FACT_KINDS", { enumerable: true, get: function () { return facts_js_1.FACT_KINDS; } }); -Object.defineProperty(exports, "declaredFields", { enumerable: true, get: function () { return facts_js_1.declaredFields; } }); -Object.defineProperty(exports, "makeFact", { enumerable: true, get: function () { return facts_js_1.makeFact; } }); -Object.defineProperty(exports, "assignFactIds", { enumerable: true, get: function () { return facts_js_1.assignFactIds; } }); -Object.defineProperty(exports, "factIds", { enumerable: true, get: function () { return facts_js_1.factIds; } }); -Object.defineProperty(exports, "renderFacts", { enumerable: true, get: function () { return facts_js_1.renderFacts; } }); -Object.defineProperty(exports, "unrecordedFields", { enumerable: true, get: function () { return facts_js_1.unrecordedFields; } }); +Object.defineProperty(exports, "NOT_RECORDED", { + enumerable: true, + get: function () { + return facts_js_1.NOT_RECORDED; + }, +}); +Object.defineProperty(exports, "FACT_KINDS", { + enumerable: true, + get: function () { + return facts_js_1.FACT_KINDS; + }, +}); +Object.defineProperty(exports, "declaredFields", { + enumerable: true, + get: function () { + return facts_js_1.declaredFields; + }, +}); +Object.defineProperty(exports, "makeFact", { + enumerable: true, + get: function () { + return facts_js_1.makeFact; + }, +}); +Object.defineProperty(exports, "assignFactIds", { + enumerable: true, + get: function () { + return facts_js_1.assignFactIds; + }, +}); +Object.defineProperty(exports, "factIds", { + enumerable: true, + get: function () { + return facts_js_1.factIds; + }, +}); +Object.defineProperty(exports, "renderFacts", { + enumerable: true, + get: function () { + return facts_js_1.renderFacts; + }, +}); +Object.defineProperty(exports, "unrecordedFields", { + enumerable: true, + get: function () { + return facts_js_1.unrecordedFields; + }, +}); var contract_js_1 = require("./contract.js"); -Object.defineProperty(exports, "NO_BASIS", { enumerable: true, get: function () { return contract_js_1.NO_BASIS; } }); -Object.defineProperty(exports, "buildContract", { enumerable: true, get: function () { return contract_js_1.buildContract; } }); -Object.defineProperty(exports, "buildAssistantRules", { enumerable: true, get: function () { return contract_js_1.buildAssistantRules; } }); -Object.defineProperty(exports, "directiveId", { enumerable: true, get: function () { return contract_js_1.directiveId; } }); -Object.defineProperty(exports, "renderDirectives", { enumerable: true, get: function () { return contract_js_1.renderDirectives; } }); -Object.defineProperty(exports, "buildGroundedContext", { enumerable: true, get: function () { return contract_js_1.buildGroundedContext; } }); +Object.defineProperty(exports, "NO_BASIS", { + enumerable: true, + get: function () { + return contract_js_1.NO_BASIS; + }, +}); +Object.defineProperty(exports, "buildContract", { + enumerable: true, + get: function () { + return contract_js_1.buildContract; + }, +}); +Object.defineProperty(exports, "buildAssistantRules", { + enumerable: true, + get: function () { + return contract_js_1.buildAssistantRules; + }, +}); +Object.defineProperty(exports, "directiveId", { + enumerable: true, + get: function () { + return contract_js_1.directiveId; + }, +}); +Object.defineProperty(exports, "renderDirectives", { + enumerable: true, + get: function () { + return contract_js_1.renderDirectives; + }, +}); +Object.defineProperty(exports, "buildGroundedContext", { + enumerable: true, + get: function () { + return contract_js_1.buildGroundedContext; + }, +}); var verify_js_1 = require("./verify.js"); -Object.defineProperty(exports, "verifyAnswer", { enumerable: true, get: function () { return verify_js_1.verifyAnswer; } }); -Object.defineProperty(exports, "buildRepairPrompt", { enumerable: true, get: function () { return verify_js_1.buildRepairPrompt; } }); +Object.defineProperty(exports, "verifyAnswer", { + enumerable: true, + get: function () { + return verify_js_1.verifyAnswer; + }, +}); +Object.defineProperty(exports, "buildRepairPrompt", { + enumerable: true, + get: function () { + return verify_js_1.buildRepairPrompt; + }, +}); diff --git a/dist-cjs/grounding/verify.js b/dist-cjs/grounding/verify.js index c5c2dd9..3d248b5 100644 --- a/dist-cjs/grounding/verify.js +++ b/dist-cjs/grounding/verify.js @@ -35,24 +35,99 @@ const facts_js_1 = require("./facts.js"); * every entry is a hole in the check, so add only what demonstrably causes * false positives, never to silence a true one. */ -const COMMON = new Set([ +const COMMON = new Set( + [ // Sentence/structural - "the", "a", "an", "and", "or", "but", "if", "then", "so", "because", "not", - "this", "that", "these", "those", "it", "its", "your", "you", "i", "we", - "there", "here", "what", "which", "who", "when", "where", "why", "how", - "no", "yes", "none", "nothing", "today", "tomorrow", "yesterday", "now", - "next", "last", "first", "one", "two", "three", "primary", "focus", "task", - "tasks", "outreach", "note", "notes", "summary", "status", "update", + "the", + "a", + "an", + "and", + "or", + "but", + "if", + "then", + "so", + "because", + "not", + "this", + "that", + "these", + "those", + "it", + "its", + "your", + "you", + "i", + "we", + "there", + "here", + "what", + "which", + "who", + "when", + "where", + "why", + "how", + "no", + "yes", + "none", + "nothing", + "today", + "tomorrow", + "yesterday", + "now", + "next", + "last", + "first", + "one", + "two", + "three", + "primary", + "focus", + "task", + "tasks", + "outreach", + "note", + "notes", + "summary", + "status", + "update", // Days / months — real words, never evidence of a fabricated entity - "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday", - "january", "february", "march", "april", "may", "june", "july", "august", - "september", "october", "november", "december", + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", + "january", + "february", + "march", + "april", + "may", + "june", + "july", + "august", + "september", + "october", + "november", + "december", // This system's own nouns - "loki", "cat", "fleetcrown", "orangecat", "not", "recorded", -].map((w) => w.toLowerCase())); + "loki", + "cat", + "fleetcrown", + "orangecat", + "not", + "recorded", + ].map((w) => w.toLowerCase()), +); /** Normalise for containment tests: casefold, collapse punctuation and space. */ function norm(s) { - return s.toLowerCase().replace(/[^a-z0-9+]+/g, " ").replace(/\s+/g, " ").trim(); + return s + .toLowerCase() + .replace(/[^a-z0-9+]+/g, " ") + .replace(/\s+/g, " ") + .trim(); } /** * Everything the model was legitimately given this turn: record values, record @@ -60,14 +135,12 @@ function norm(s) { * repeat). This is the corpus a claim must be traceable to. */ function buildEvidence(facts, userMessage, extra) { - const parts = [userMessage, ...extra]; - for (const f of facts) { - parts.push(f.subject, f.kind, f.source); - for (const v of Object.values(f.fields)) - if (v) - parts.push(v); - } - return norm(parts.join(" ")); + const parts = [userMessage, ...extra]; + for (const f of facts) { + parts.push(f.subject, f.kind, f.source); + for (const v of Object.values(f.fields)) if (v) parts.push(v); + } + return norm(parts.join(" ")); } /** * Lowercase words that legitimately sit INSIDE a proper name and must not break @@ -76,7 +149,23 @@ function buildEvidence(facts, userMessage, extra) { * only ever sees the harmless halves ("University", "Zurich") while the actual * fabricated entity slips through unnamed. */ -const NAME_CONNECTORS = new Set(["of", "the", "for", "and", "de", "der", "des", "van", "von", "du", "da", "di", "für", "el", "al"]); +const NAME_CONNECTORS = new Set([ + "of", + "the", + "for", + "and", + "de", + "der", + "des", + "van", + "von", + "du", + "da", + "di", + "für", + "el", + "al", +]); /** * Named-entity candidates: ALL-CAPS acronyms, capitalised words, and the * multi-word runs they form (connectors allowed strictly between two @@ -95,46 +184,45 @@ const NAME_CONNECTORS = new Set(["of", "the", "for", "and", "de", "der", "des", * still caught by its remaining tokens. */ function properNounRuns(text) { - const out = []; - // Strip fenced and inline code — quoted identifiers are usually the user's - // own or a literal under discussion, not a claim about the world. - const prose = text.replace(/```[\s\S]*?```/g, " ").replace(/`[^`]*`/g, " "); - for (const sentence of prose.split(/(?<=[.!?:\n])\s+/)) { - const tokens = sentence.match(/[A-Za-z][A-Za-z0-9&.'’-]*/g) ?? []; - let run = []; - const flush = () => { - // Trim trailing connectors so "University of" never stands as a run. - while (run.length > 0 && NAME_CONNECTORS.has((run[run.length - 1] ?? "").toLowerCase())) - run.pop(); - if (run.length > 1) - out.push(run.join(" ")); - run = []; - }; - tokens.forEach((tok, i) => { - const bare = tok.replace(/[.'’-]+$/, ""); - const isAcronym = /^[A-Z]{2,}$/.test(bare); - const isCapitalised = /^[A-Z][a-z]/.test(bare); - const isConnector = NAME_CONNECTORS.has(bare.toLowerCase()); - if (isAcronym || (isCapitalised && i > 0)) { - run.push(bare); - out.push(bare); // individually checkable - return; - } - // A connector only continues a run that has already started. - if (isConnector && run.length > 0) { - run.push(bare); - return; - } - flush(); - }); - flush(); - } - return out; + const out = []; + // Strip fenced and inline code — quoted identifiers are usually the user's + // own or a literal under discussion, not a claim about the world. + const prose = text.replace(/```[\s\S]*?```/g, " ").replace(/`[^`]*`/g, " "); + for (const sentence of prose.split(/(?<=[.!?:\n])\s+/)) { + const tokens = sentence.match(/[A-Za-z][A-Za-z0-9&.'’-]*/g) ?? []; + let run = []; + const flush = () => { + // Trim trailing connectors so "University of" never stands as a run. + while (run.length > 0 && NAME_CONNECTORS.has((run[run.length - 1] ?? "").toLowerCase())) + run.pop(); + if (run.length > 1) out.push(run.join(" ")); + run = []; + }; + tokens.forEach((tok, i) => { + const bare = tok.replace(/[.'’-]+$/, ""); + const isAcronym = /^[A-Z]{2,}$/.test(bare); + const isCapitalised = /^[A-Z][a-z]/.test(bare); + const isConnector = NAME_CONNECTORS.has(bare.toLowerCase()); + if (isAcronym || (isCapitalised && i > 0)) { + run.push(bare); + out.push(bare); // individually checkable + return; + } + // A connector only continues a run that has already started. + if (isConnector && run.length > 0) { + run.push(bare); + return; + } + flush(); + }); + flush(); + } + return out; } /** Digit groups worth checking: phone numbers, years, percentages, counts ≥ 2 digits. */ function numericClaims(text) { - const prose = text.replace(/```[\s\S]*?```/g, " ").replace(/`[^`]*`/g, " "); - return (prose.match(/\+?\d[\d\s().-]{3,}\d|\b\d{2,}%?\b/g) ?? []).map((s) => s.trim()); + const prose = text.replace(/```[\s\S]*?```/g, " ").replace(/`[^`]*`/g, " "); + return (prose.match(/\+?\d[\d\s().-]{3,}\d|\b\d{2,}%?\b/g) ?? []).map((s) => s.trim()); } /** * File and path references — a favourite fabrication, and an unusually @@ -149,26 +237,24 @@ function numericClaims(text) { * original error: it spends the credibility the user was trying to restore. */ function pathClaims(text) { - const patterns = [ - /(?:^|[\s("'`])(\/[A-Za-z0-9_.\-/]{4,})/g, // absolute - /(?:^|[\s("'`])([A-Za-z0-9_.-]+\/[A-Za-z0-9_.\-/]*[A-Za-z0-9_-]\.[a-z]{2,5})/g, // relative w/ extension - /(?:^|[\s("'`])([A-Za-z0-9_-]+\.(?:json|env|ya?ml|sql|toml|ini|conf|log))\b/g, // bare config filename - ]; - const out = new Set(); - for (const re of patterns) { - for (const m of text.matchAll(re)) - if (m[1]) - out.add(m[1]); - } - return [...out]; + const patterns = [ + /(?:^|[\s("'`])(\/[A-Za-z0-9_.\-/]{4,})/g, // absolute + /(?:^|[\s("'`])([A-Za-z0-9_.-]+\/[A-Za-z0-9_.\-/]*[A-Za-z0-9_-]\.[a-z]{2,5})/g, // relative w/ extension + /(?:^|[\s("'`])([A-Za-z0-9_-]+\.(?:json|env|ya?ml|sql|toml|ini|conf|log))\b/g, // bare config filename + ]; + const out = new Set(); + for (const re of patterns) { + for (const m of text.matchAll(re)) if (m[1]) out.add(m[1]); + } + return [...out]; } /** Does this sentence talk about one of the user's own records? */ function mentionsSubject(sentence, subjects) { - const s = norm(sentence); - return subjects.some((sub) => { - const n = norm(sub); - return n.length > 2 && s.includes(n); - }); + const s = norm(sentence); + return subjects.some((sub) => { + const n = norm(sub); + return n.length > 2 && s.includes(n); + }); } /** * Verify an answer against the facts it was supposed to come from. @@ -182,94 +268,85 @@ function mentionsSubject(sentence, subjects) { * check can tell "your contact Elena works at X" from "Lightning is instant". */ function verifyAnswer(input) { - const { answer, facts, userMessage } = input; - const mode = input.mode ?? "closed-world"; - const subjects = input.subjects ?? facts.map((f) => f.subject); - const evidence = buildEvidence(facts, userMessage, input.extraEvidence ?? []); - const legalIds = new Set([ - ...facts.map((f) => f.id.toUpperCase()), - ...(input.extraCitationIds ?? []).map((id) => id.toUpperCase()), - ]); - const violations = []; - /** - * In entity-attribution mode, only sentences about the user's own records are - * subject to the name check. Built once so the per-token loop stays cheap. - */ - const attributionScope = mode === "entity-attribution" - ? answer - .split(/(?<=[.!?:\n])\s+/) - .filter((s) => mentionsSubject(s, subjects)) - .join(" ") - : answer; - // 1. Citations must resolve. A citation to a record that does not exist is - // the strongest possible signal of fabrication — it invents its own proof. - for (const cite of answer.match(/\[[FD]\d+\]/g) ?? []) { - const id = cite.slice(1, -1).toUpperCase(); - if (!legalIds.has(id)) { - violations.push({ - kind: "unknown-citation", - text: cite, - detail: `${cite} is not a record in this turn's context. Cite only ids that were provided, or say there is no record.`, - }); - } - } - // 2. Named entities must be traceable. This is the anti-"UZH" rule. - const seen = new Set(); - for (const run of properNounRuns(attributionScope)) { - const n = norm(run); - if (!n || seen.has(n)) - continue; - seen.add(n); - // Single common words are noise; multi-word runs always checked. - const words = n.split(" "); - if (words.length === 1 && (COMMON.has(words[0] ?? "") || (words[0] ?? "").length < 2)) - continue; - if (words.every((w) => COMMON.has(w))) - continue; - if (evidence.includes(n)) - continue; - // A multi-word run whose every word is individually attested is fine — - // it is a rephrasing, not a new entity. - if (words.length > 1 && words.every((w) => COMMON.has(w) || evidence.includes(w))) - continue; - violations.push({ - kind: "novel-proper-noun", - text: run, - detail: `"${run}" does not appear in any record or in the operator's message. If it is an organisation, role, or place you associated with someone, the relevant field is ${facts_js_1.NOT_RECORDED} — remove the claim.`, - }); + const { answer, facts, userMessage } = input; + const mode = input.mode ?? "closed-world"; + const subjects = input.subjects ?? facts.map((f) => f.subject); + const evidence = buildEvidence(facts, userMessage, input.extraEvidence ?? []); + const legalIds = new Set([ + ...facts.map((f) => f.id.toUpperCase()), + ...(input.extraCitationIds ?? []).map((id) => id.toUpperCase()), + ]); + const violations = []; + /** + * In entity-attribution mode, only sentences about the user's own records are + * subject to the name check. Built once so the per-token loop stays cheap. + */ + const attributionScope = + mode === "entity-attribution" + ? answer + .split(/(?<=[.!?:\n])\s+/) + .filter((s) => mentionsSubject(s, subjects)) + .join(" ") + : answer; + // 1. Citations must resolve. A citation to a record that does not exist is + // the strongest possible signal of fabrication — it invents its own proof. + for (const cite of answer.match(/\[[FD]\d+\]/g) ?? []) { + const id = cite.slice(1, -1).toUpperCase(); + if (!legalIds.has(id)) { + violations.push({ + kind: "unknown-citation", + text: cite, + detail: `${cite} is not a record in this turn's context. Cite only ids that were provided, or say there is no record.`, + }); } - // 3. Numbers must be traceable — invented phone numbers and dates read as - // authoritative precisely because they are specific. - for (const num of numericClaims(answer)) { - const n = norm(num); - if (!n || n.length < 2) - continue; - if (evidence.includes(n)) - continue; - // Compare digits-only too: "+41 77 473 00 93" vs stored "+41774730093". - const digits = num.replace(/\D/g, ""); - if (digits.length >= 4 && evidence.replace(/\D/g, "").includes(digits)) - continue; - if (digits.length < 4) - continue; // small counts ("3 tasks") are rhetorical - violations.push({ - kind: "novel-number", - text: num, - detail: `The number "${num}" is not in any record. Do not state contact details, dates, or metrics that were not provided.`, - }); - } - // 4. Paths — "update the key in /opt/fleetcrown/runner/.env" was invented - // wholesale, and its specificity is what made it convincing. - for (const p of pathClaims(answer)) { - if (evidence.includes(norm(p))) - continue; - violations.push({ - kind: "novel-path", - text: p, - detail: `The path "${p}" is not in any record. Do not state file locations you were not given.`, - }); - } - return { ok: violations.length === 0, violations }; + } + // 2. Named entities must be traceable. This is the anti-"UZH" rule. + const seen = new Set(); + for (const run of properNounRuns(attributionScope)) { + const n = norm(run); + if (!n || seen.has(n)) continue; + seen.add(n); + // Single common words are noise; multi-word runs always checked. + const words = n.split(" "); + if (words.length === 1 && (COMMON.has(words[0] ?? "") || (words[0] ?? "").length < 2)) continue; + if (words.every((w) => COMMON.has(w))) continue; + if (evidence.includes(n)) continue; + // A multi-word run whose every word is individually attested is fine — + // it is a rephrasing, not a new entity. + if (words.length > 1 && words.every((w) => COMMON.has(w) || evidence.includes(w))) continue; + violations.push({ + kind: "novel-proper-noun", + text: run, + detail: `"${run}" does not appear in any record or in the operator's message. If it is an organisation, role, or place you associated with someone, the relevant field is ${facts_js_1.NOT_RECORDED} — remove the claim.`, + }); + } + // 3. Numbers must be traceable — invented phone numbers and dates read as + // authoritative precisely because they are specific. + for (const num of numericClaims(answer)) { + const n = norm(num); + if (!n || n.length < 2) continue; + if (evidence.includes(n)) continue; + // Compare digits-only too: "+41 77 473 00 93" vs stored "+41774730093". + const digits = num.replace(/\D/g, ""); + if (digits.length >= 4 && evidence.replace(/\D/g, "").includes(digits)) continue; + if (digits.length < 4) continue; // small counts ("3 tasks") are rhetorical + violations.push({ + kind: "novel-number", + text: num, + detail: `The number "${num}" is not in any record. Do not state contact details, dates, or metrics that were not provided.`, + }); + } + // 4. Paths — "update the key in /opt/fleetcrown/runner/.env" was invented + // wholesale, and its specificity is what made it convincing. + for (const p of pathClaims(answer)) { + if (evidence.includes(norm(p))) continue; + violations.push({ + kind: "novel-path", + text: p, + detail: `The path "${p}" is not in any record. Do not state file locations you were not given.`, + }); + } + return { ok: violations.length === 0, violations }; } /** * Turn violations into a repair instruction. One cheap retry with this appended @@ -277,11 +354,11 @@ function verifyAnswer(input) { * delete claims it cannot support. */ function buildRepairPrompt(violations, noBasisPhrase) { - return [ - "Your previous answer contained claims not supported by the records. Rewrite it.", - "", - ...violations.map((v) => `- ${v.detail}`), - "", - `Remove every unsupported claim. Where removing one empties a requested item, write "${noBasisPhrase}" for that item instead of substituting something else. Keep everything that was supported, unchanged.`, - ].join("\n"); + return [ + "Your previous answer contained claims not supported by the records. Rewrite it.", + "", + ...violations.map((v) => `- ${v.detail}`), + "", + `Remove every unsupported claim. Where removing one empties a requested item, write "${noBasisPhrase}" for that item instead of substituting something else. Keep everything that was supported, unchanged.`, + ].join("\n"); } diff --git a/dist-cjs/package.json b/dist-cjs/package.json index 729ac4d..5bbefff 100644 --- a/dist-cjs/package.json +++ b/dist-cjs/package.json @@ -1 +1,3 @@ -{"type":"commonjs"} +{ + "type": "commonjs" +} diff --git a/dist-cjs/registry.js b/dist-cjs/registry.js index 61acfbd..023d63c 100644 --- a/dist-cjs/registry.js +++ b/dist-cjs/registry.js @@ -39,53 +39,55 @@ exports.toolCapable = toolCapable; /** A `:free`-suffixed id claiming to be paid, or a "free" entry with a price — * each one is the 2026 billing incident waiting to recur. */ function validateEntry(e) { - if (!e.id.trim()) - return "entry has an empty id"; - if (!e.vendor.trim()) - return `"${e.id}": empty vendor`; - const cost = (e.inputCostPer1M ?? 0) + (e.outputCostPer1M ?? 0); - if (!e.paid && cost > 0) { - return `"${e.id}": declared free but carries a cost (${cost}/1M) — the flag or the price is lying`; - } - if (e.paid && e.id.endsWith(":free")) { - return `"${e.id}": declared paid but the id says :free — the flag or the id is lying`; - } - return null; + if (!e.id.trim()) return "entry has an empty id"; + if (!e.vendor.trim()) return `"${e.id}": empty vendor`; + const cost = (e.inputCostPer1M ?? 0) + (e.outputCostPer1M ?? 0); + if (!e.paid && cost > 0) { + return `"${e.id}": declared free but carries a cost (${cost}/1M) — the flag or the price is lying`; + } + if (e.paid && e.id.endsWith(":free")) { + return `"${e.id}": declared paid but the id says :free — the flag or the id is lying`; + } + return null; } /** * Build a registry from entries. Throws on the first contradiction — a * registry that loads is a registry whose billing boundary can be trusted. */ function defineRegistry(entries) { - const seen = new Set(); - for (const e of entries) { - const problem = validateEntry(e); - if (problem) - throw new Error(`ai-kit registry: ${problem}`); - const key = `${e.vendor}:${e.id}`; - if (seen.has(key)) { - throw new Error(`ai-kit registry: duplicate entry ${key} — two rows for one callable id is two sources of truth`); - } - seen.add(key); + const seen = new Set(); + for (const e of entries) { + const problem = validateEntry(e); + if (problem) throw new Error(`ai-kit registry: ${problem}`); + const key = `${e.vendor}:${e.id}`; + if (seen.has(key)) { + throw new Error( + `ai-kit registry: duplicate entry ${key} — two rows for one callable id is two sources of truth`, + ); } - const frozen = Object.freeze(entries.map((e) => ({ ...e }))); - const find = (id, vendor) => frozen.find((e) => e.id === id && (vendor === undefined || e.vendor === vendor)); - return { - entries: frozen, - find, - require(id, vendor) { - const hit = find(id, vendor); - if (!hit) { - const scope = vendor ? ` at ${vendor}` : ""; - throw new Error(`ai-kit registry: "${id}"${scope} is not registered — a model id is callable only if it appears in the registry (add it with its paid flag, or stop calling it)`); - } - return hit; - }, - idsForVendor: (vendor) => frozen.filter((e) => e.vendor === vendor).map((e) => e.id), - vendors: () => [...new Set(frozen.map((e) => e.vendor))], - freeEntries: () => frozen.filter((e) => !e.paid), - paidEntries: () => frozen.filter((e) => e.paid), - }; + seen.add(key); + } + const frozen = Object.freeze(entries.map((e) => ({ ...e }))); + const find = (id, vendor) => + frozen.find((e) => e.id === id && (vendor === undefined || e.vendor === vendor)); + return { + entries: frozen, + find, + require(id, vendor) { + const hit = find(id, vendor); + if (!hit) { + const scope = vendor ? ` at ${vendor}` : ""; + throw new Error( + `ai-kit registry: "${id}"${scope} is not registered — a model id is callable only if it appears in the registry (add it with its paid flag, or stop calling it)`, + ); + } + return hit; + }, + idsForVendor: (vendor) => frozen.filter((e) => e.vendor === vendor).map((e) => e.id), + vendors: () => [...new Set(frozen.map((e) => e.vendor))], + freeEntries: () => frozen.filter((e) => !e.paid), + paidEntries: () => frozen.filter((e) => e.paid), + }; } /** * The platform-key guard: the ids from `requested` that a platform-funded @@ -97,18 +99,15 @@ function defineRegistry(entries) { * as "covered everything" when it didn't. */ function freeOnly(registry, requested) { - const allowed = []; - const dropped = []; - for (const id of requested) { - const entry = registry.find(id); - if (!entry) - dropped.push({ id, why: "unregistered" }); - else if (entry.paid) - dropped.push({ id, why: "paid" }); - else - allowed.push(id); - } - return { allowed, dropped }; + const allowed = []; + const dropped = []; + for (const id of requested) { + const entry = registry.find(id); + if (!entry) dropped.push({ id, why: "unregistered" }); + else if (entry.paid) dropped.push({ id, why: "paid" }); + else allowed.push(id); + } + return { allowed, dropped }; } /** * A tool-driving chain may only contain models that can drive a tool loop. @@ -117,15 +116,13 @@ function freeOnly(registry, requested) { * exactly on the models most likely to serve free traffic. */ function toolCapable(registry, requested) { - const usable = []; - const refused = []; - for (const id of requested) { - const entry = registry.find(id); - const protocol = entry?.toolProtocol ?? "unprobed"; - if (protocol === "native" || protocol === "text") - usable.push(id); - else - refused.push({ id, protocol }); - } - return { usable, refused }; + const usable = []; + const refused = []; + for (const id of requested) { + const entry = registry.find(id); + const protocol = entry?.toolProtocol ?? "unprobed"; + if (protocol === "native" || protocol === "text") usable.push(id); + else refused.push({ id, protocol }); + } + return { usable, refused }; } diff --git a/eslint.config.mjs b/eslint.config.mjs index f8d2426..1f7994b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -2,24 +2,24 @@ // library code, and a bespoke rule set would be a second opinion to maintain // for no benefit. The floor is "lint runs and can fail", not "lint encodes // taste". -import js from '@eslint/js' -import globals from 'globals' -import tseslint from 'typescript-eslint' +import js from "@eslint/js"; +import globals from "globals"; +import tseslint from "typescript-eslint"; export default tseslint.config( { // dist/ is generated by `tsc`. - ignores: ['dist/**', 'dist-cjs/**', 'node_modules/**'], + ignores: ["dist/**", "dist-cjs/**", "node_modules/**"], }, js.configs.recommended, ...tseslint.configs.recommended, { - files: ['**/*.ts'], + files: ["**/*.ts"], languageOptions: { globals: globals.node }, }, { // Tests are plain Node ESM running under `node --test`. - files: ['test/**/*.js', 'scripts/**/*.{js,mjs}'], + files: ["test/**/*.js", "scripts/**/*.{js,mjs}"], languageOptions: { globals: { ...globals.node, ...globals.nodeBuiltin } }, }, -) +); diff --git a/scripts/check-catalog.mjs b/scripts/check-catalog.mjs index 0618a49..9610513 100644 --- a/scripts/check-catalog.mjs +++ b/scripts/check-catalog.mjs @@ -24,10 +24,14 @@ console.log(""); const dead = deadProviders(verdicts); if (dead.length) { - console.error(`A whole vendor is gone (${dead.join(", ")}) — the chain is back to a single point of failure.`); + console.error( + `A whole vendor is gone (${dead.join(", ")}) — the chain is back to a single point of failure.`, + ); } if (hasRot(verdicts)) { - console.error("Retired model ids are still pinned. Re-probe replacements and update freeChain()."); + console.error( + "Retired model ids are still pinned. Re-probe replacements and update freeChain().", + ); process.exit(1); } const unchecked = verdicts.reduce((n, v) => n + v.unchecked.length, 0); diff --git a/src/catalog.ts b/src/catalog.ts index 573d710..e6e98e2 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -134,7 +134,9 @@ export function catalogReport(verdicts: CatalogVerdict[]): string { const lines: string[] = []; for (const v of verdicts) { if (v.live === null) { - lines.push(`? ${v.provider}: catalogue unreadable (no key, or the request failed) — ${v.unchecked.length} id(s) UNCHECKED`); + lines.push( + `? ${v.provider}: catalogue unreadable (no key, or the request failed) — ${v.unchecked.length} id(s) UNCHECKED`, + ); for (const m of v.unchecked) lines.push(` ? ${m}`); continue; } @@ -142,8 +144,12 @@ export function catalogReport(verdicts: CatalogVerdict[]): string { for (const m of v.missing) lines.push(` GONE ${v.provider}/${m}`); } const dead = deadProviders(verdicts); - if (dead.length) lines.push(`\nEVERY model is gone at: ${dead.join(", ")} — the chain has lost that vendor entirely.`); + if (dead.length) + lines.push( + `\nEVERY model is gone at: ${dead.join(", ")} — the chain has lost that vendor entirely.`, + ); const unchecked = verdicts.reduce((n, v) => n + v.unchecked.length, 0); - if (unchecked) lines.push(`\n${unchecked} id(s) could not be checked. That is not a pass for them.`); + if (unchecked) + lines.push(`\n${unchecked} id(s) could not be checked. That is not a pass for them.`); return lines.join("\n"); } diff --git a/src/chain.ts b/src/chain.ts index 5edcca8..3fa4ee5 100644 --- a/src/chain.ts +++ b/src/chain.ts @@ -109,7 +109,10 @@ export function providerModels(provider: Provider, env: Env = process.env): stri * LOKI_GROQ_DAILY_TOKENS. The key env stays explicit because it is usually the * vendor's conventional name (GROQ_API_KEY), shared with other tools. */ -export function withEnvPrefix(prefix: string, provider: Omit): Provider { +export function withEnvPrefix( + prefix: string, + provider: Omit, +): Provider { const slug = provider.id.toUpperCase().replace(/[^A-Z0-9]+/g, "_"); return { ...provider, diff --git a/src/grounding/facts.ts b/src/grounding/facts.ts index c47e883..ea9c7da 100644 --- a/src/grounding/facts.ts +++ b/src/grounding/facts.ts @@ -147,9 +147,7 @@ export function renderFacts(facts: Fact[]): string { return facts .map((f) => { const head = `[${f.id}] ${f.kind} — ${f.subject} (${f.source})`; - const body = Object.entries(f.fields).map( - ([k, v]) => ` ${k}: ${v ?? NOT_RECORDED}`, - ); + const body = Object.entries(f.fields).map(([k, v]) => ` ${k}: ${v ?? NOT_RECORDED}`); return [head, ...body].join("\n"); }) .join("\n\n"); diff --git a/src/grounding/verify.ts b/src/grounding/verify.ts index e9f3c0a..638931f 100644 --- a/src/grounding/verify.ts +++ b/src/grounding/verify.ts @@ -48,24 +48,97 @@ export type VerifyResult = { const COMMON = new Set( [ // Sentence/structural - "the", "a", "an", "and", "or", "but", "if", "then", "so", "because", "not", - "this", "that", "these", "those", "it", "its", "your", "you", "i", "we", - "there", "here", "what", "which", "who", "when", "where", "why", "how", - "no", "yes", "none", "nothing", "today", "tomorrow", "yesterday", "now", - "next", "last", "first", "one", "two", "three", "primary", "focus", "task", - "tasks", "outreach", "note", "notes", "summary", "status", "update", + "the", + "a", + "an", + "and", + "or", + "but", + "if", + "then", + "so", + "because", + "not", + "this", + "that", + "these", + "those", + "it", + "its", + "your", + "you", + "i", + "we", + "there", + "here", + "what", + "which", + "who", + "when", + "where", + "why", + "how", + "no", + "yes", + "none", + "nothing", + "today", + "tomorrow", + "yesterday", + "now", + "next", + "last", + "first", + "one", + "two", + "three", + "primary", + "focus", + "task", + "tasks", + "outreach", + "note", + "notes", + "summary", + "status", + "update", // Days / months — real words, never evidence of a fabricated entity - "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday", - "january", "february", "march", "april", "may", "june", "july", "august", - "september", "october", "november", "december", + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", + "january", + "february", + "march", + "april", + "may", + "june", + "july", + "august", + "september", + "october", + "november", + "december", // This system's own nouns - "loki", "cat", "fleetcrown", "orangecat", "not", "recorded", + "loki", + "cat", + "fleetcrown", + "orangecat", + "not", + "recorded", ].map((w) => w.toLowerCase()), ); /** Normalise for containment tests: casefold, collapse punctuation and space. */ function norm(s: string): string { - return s.toLowerCase().replace(/[^a-z0-9+]+/g, " ").replace(/\s+/g, " ").trim(); + return s + .toLowerCase() + .replace(/[^a-z0-9+]+/g, " ") + .replace(/\s+/g, " ") + .trim(); } /** @@ -89,7 +162,23 @@ function buildEvidence(facts: Fact[], userMessage: string, extra: string[]): str * only ever sees the harmless halves ("University", "Zurich") while the actual * fabricated entity slips through unnamed. */ -const NAME_CONNECTORS = new Set(["of", "the", "for", "and", "de", "der", "des", "van", "von", "du", "da", "di", "für", "el", "al"]); +const NAME_CONNECTORS = new Set([ + "of", + "the", + "for", + "and", + "de", + "der", + "des", + "van", + "von", + "du", + "da", + "di", + "für", + "el", + "al", +]); /** * Named-entity candidates: ALL-CAPS acronyms, capitalised words, and the @@ -120,7 +209,8 @@ function properNounRuns(text: string): string[] { const flush = () => { // Trim trailing connectors so "University of" never stands as a run. - while (run.length > 0 && NAME_CONNECTORS.has((run[run.length - 1] ?? "").toLowerCase())) run.pop(); + while (run.length > 0 && NAME_CONNECTORS.has((run[run.length - 1] ?? "").toLowerCase())) + run.pop(); if (run.length > 1) out.push(run.join(" ")); run = []; }; diff --git a/src/index.ts b/src/index.ts index b757092..d354488 100644 --- a/src/index.ts +++ b/src/index.ts @@ -122,4 +122,3 @@ export { // Same dependency, same version, nothing extra to install — a consumer just // stops paying for the half it does not use. That is what subpath exports are // for, and collapsing them into the root threw the benefit away. - diff --git a/src/limits.ts b/src/limits.ts index 917b25e..048f341 100644 --- a/src/limits.ts +++ b/src/limits.ts @@ -67,7 +67,9 @@ export function classifyRateLimit(body: string): RateLimitKind { // Requests-per-day is the same situation as tokens-per-day: nothing the caller // does before the reset can help, so it gets the same treatment. if (/per day|\bTPD\b|\bRPD\b/i.test(body)) return "daily"; - return /request too large|reduce your message size|reduce the length/i.test(body) ? "size" : "capacity"; + return /request too large|reduce your message size|reduce the length/i.test(body) + ? "size" + : "capacity"; } /** diff --git a/src/registry.ts b/src/registry.ts index 368268d..24b91c4 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -44,12 +44,7 @@ export type ToolProtocol = "native" | "text" | "none" | "unprobed"; export type ModelTier = "free" | "economy" | "standard" | "premium"; export type ModelCapability = - | "text" - | "vision" - | "function_calling" - | "json_mode" - | "streaming" - | "transcribe"; + "text" | "vision" | "function_calling" | "json_mode" | "streaming" | "transcribe"; export type ModelEntry = { /** The id sent on the wire — exactly as the vendor expects it. */ @@ -159,8 +154,7 @@ export function defineRegistry(entries: ModelEntry[]): Registry { } return hit; }, - idsForVendor: (vendor: string) => - frozen.filter((e) => e.vendor === vendor).map((e) => e.id), + idsForVendor: (vendor: string) => frozen.filter((e) => e.vendor === vendor).map((e) => e.id), vendors: () => [...new Set(frozen.map((e) => e.vendor))], freeEntries: () => frozen.filter((e) => !e.paid), paidEntries: () => frozen.filter((e) => e.paid), diff --git a/test/attempt.test.js b/test/attempt.test.js index 6af8756..9366beb 100644 --- a/test/attempt.test.js +++ b/test/attempt.test.js @@ -2,43 +2,52 @@ * `tryChain`'s entire reason to exist: try the next link on failure, instead * of stopping at the first one — and never own the actual request. */ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; +import { test } from "node:test"; +import assert from "node:assert/strict"; -import { tryChain, ChainExhaustedError, createHealthTracker } from 'ai-kit'; +import { tryChain, ChainExhaustedError, createHealthTracker } from "ai-kit"; function link(providerId, model) { - return { provider: { id: providerId, baseUrl: 'https://example.invalid', keyEnv: 'X', models: [model], dailyTokens: 0 }, model }; + return { + provider: { + id: providerId, + baseUrl: "https://example.invalid", + keyEnv: "X", + models: [model], + dailyTokens: 0, + }, + model, + }; } -test('returns the first link\'s result without trying the rest', async () => { +test("returns the first link's result without trying the rest", async () => { const tried = []; - const result = await tryChain([link('groq', 'a'), link('openrouter', 'b')], { + const result = await tryChain([link("groq", "a"), link("openrouter", "b")], { attempt: async (l) => { tried.push(l.model); return `ok:${l.model}`; }, }); - assert.equal(result, 'ok:a'); - assert.deepEqual(tried, ['a']); + assert.equal(result, "ok:a"); + assert.deepEqual(tried, ["a"]); }); -test('demotes to the next link on failure — the whole point of a chain', async () => { +test("demotes to the next link on failure — the whole point of a chain", async () => { const tried = []; - const result = await tryChain([link('groq', 'dead'), link('openrouter', 'alive')], { + const result = await tryChain([link("groq", "dead"), link("openrouter", "alive")], { attempt: async (l) => { tried.push(l.model); - if (l.model === 'dead') throw new Error('401 unauthorized'); + if (l.model === "dead") throw new Error("401 unauthorized"); return `ok:${l.model}`; }, }); - assert.equal(result, 'ok:alive'); - assert.deepEqual(tried, ['dead', 'alive']); + assert.equal(result, "ok:alive"); + assert.deepEqual(tried, ["dead", "alive"]); }); -test('every link failing throws ChainExhaustedError naming every failure, not just the last', async () => { +test("every link failing throws ChainExhaustedError naming every failure, not just the last", async () => { await assert.rejects( - tryChain([link('groq', 'a'), link('openrouter', 'b')], { + tryChain([link("groq", "a"), link("openrouter", "b")], { attempt: async (l) => { throw new Error(`${l.model} refused`); }, @@ -54,50 +63,51 @@ test('every link failing throws ChainExhaustedError naming every failure, not ju }); test('an empty chain is a distinct, honest failure — not silently "succeeds with nothing"', async () => { - await assert.rejects( - tryChain([], { attempt: async () => 'unreachable' }), - (err) => { - assert.ok(err instanceof ChainExhaustedError); - assert.match(err.message, /no key|No usable link/i); - return true; - }, - ); + await assert.rejects(tryChain([], { attempt: async () => "unreachable" }), (err) => { + assert.ok(err instanceof ChainExhaustedError); + assert.match(err.message, /no key|No usable link/i); + return true; + }); }); -test('a success on link two still records a SUCCESS, not degraded — the fallback working is not a problem', async () => { +test("a success on link two still records a SUCCESS, not degraded — the fallback working is not a problem", async () => { const health = createHealthTracker(); - await tryChain([link('groq', 'dead'), link('openrouter', 'alive')], { + await tryChain([link("groq", "dead"), link("openrouter", "alive")], { health, attempt: async (l) => { - if (l.model === 'dead') throw new Error('401'); - return 'ok'; + if (l.model === "dead") throw new Error("401"); + return "ok"; }, }); - assert.equal(health.getHealth().status, 'ok'); + assert.equal(health.getHealth().status, "ok"); assert.equal(health.getHealth().consecutiveFailures, 0); }); -test('exhausting the chain records exactly ONE failure on the tracker, not one per link', async () => { +test("exhausting the chain records exactly ONE failure on the tracker, not one per link", async () => { const health = createHealthTracker({ downAfter: 2 }); await assert.rejects( - tryChain([link('groq', 'a'), link('openrouter', 'b')], { + tryChain([link("groq", "a"), link("openrouter", "b")], { health, attempt: async () => { - throw new Error('down'); + throw new Error("down"); }, }), ); - assert.equal(health.getHealth().consecutiveFailures, 1, 'one exhausted walk is one data point, not two'); + assert.equal( + health.getHealth().consecutiveFailures, + 1, + "one exhausted walk is one data point, not two", + ); }); -test('onLinkFailure fires per demoted link, for callers that want to log each attempt', async () => { +test("onLinkFailure fires per demoted link, for callers that want to log each attempt", async () => { const seen = []; - await tryChain([link('groq', 'a'), link('openrouter', 'b')], { + await tryChain([link("groq", "a"), link("openrouter", "b")], { onLinkFailure: (l, err) => seen.push(`${l.provider.id}:${err.message}`), attempt: async (l) => { - if (l.provider.id === 'groq') throw new Error('boom'); - return 'ok'; + if (l.provider.id === "groq") throw new Error("boom"); + return "ok"; }, }); - assert.deepEqual(seen, ['groq:boom']); + assert.deepEqual(seen, ["groq:boom"]); }); diff --git a/test/catalog.test.js b/test/catalog.test.js index c9ebf26..595261e 100644 --- a/test/catalog.test.js +++ b/test/catalog.test.js @@ -6,95 +6,89 @@ * * No network and no keys — fetch is injected. */ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; +import { test } from "node:test"; +import assert from "node:assert/strict"; -import { - checkCatalog, - hasRot, - deadProviders, - catalogReport, - withEnvPrefix, -} from 'ai-kit'; +import { checkCatalog, hasRot, deadProviders, catalogReport, withEnvPrefix } from "ai-kit"; const provider = (id, models, keyEnv) => - withEnvPrefix('T', { id, baseUrl: `https://${id}.test/v1`, keyEnv, models, dailyTokens: 1000 }); + withEnvPrefix("T", { id, baseUrl: `https://${id}.test/v1`, keyEnv, models, dailyTokens: 1000 }); /** A fetch that serves a fixed catalogue, or a status, per host. */ const fakeFetch = (byHost) => async (url) => { const host = new URL(url).host; const entry = byHost[host]; if (entry === undefined) return { ok: false, status: 404, json: async () => ({}) }; - if (typeof entry === 'number') return { ok: false, status: entry, json: async () => ({}) }; + if (typeof entry === "number") return { ok: false, status: entry, json: async () => ({}) }; return { ok: true, status: 200, json: async () => ({ data: entry.map((id) => ({ id })) }) }; }; -const ENV = { T_GROQ_API_KEY: 'k', GROQ_API_KEY: 'k', OPENROUTER_API_KEY: 'k' }; +const ENV = { T_GROQ_API_KEY: "k", GROQ_API_KEY: "k", OPENROUTER_API_KEY: "k" }; -test('a retired id is reported GONE, a live one is not', async () => { - const chain = [provider('groq', ['alive-1', 'retired-1'], 'GROQ_API_KEY')]; +test("a retired id is reported GONE, a live one is not", async () => { + const chain = [provider("groq", ["alive-1", "retired-1"], "GROQ_API_KEY")]; const v = await checkCatalog(chain, { env: ENV, - fetchImpl: fakeFetch({ 'groq.test': ['alive-1', 'something-else'] }), + fetchImpl: fakeFetch({ "groq.test": ["alive-1", "something-else"] }), }); - assert.deepEqual(v[0].present, ['alive-1']); - assert.deepEqual(v[0].missing, ['retired-1']); + assert.deepEqual(v[0].present, ["alive-1"]); + assert.deepEqual(v[0].missing, ["retired-1"]); assert.equal(hasRot(v), true); }); -test('an unreadable catalogue is UNCHECKED — never reported as rot', async () => { +test("an unreadable catalogue is UNCHECKED — never reported as rot", async () => { // This is the failure that matters most. A 500, a network blip or a missing // key must not make every pinned model look retired. - const chain = [provider('groq', ['a', 'b'], 'GROQ_API_KEY')]; - const v = await checkCatalog(chain, { env: ENV, fetchImpl: fakeFetch({ 'groq.test': 500 }) }); + const chain = [provider("groq", ["a", "b"], "GROQ_API_KEY")]; + const v = await checkCatalog(chain, { env: ENV, fetchImpl: fakeFetch({ "groq.test": 500 }) }); assert.equal(v[0].live, null); assert.deepEqual(v[0].missing, []); - assert.deepEqual(v[0].unchecked, ['a', 'b']); - assert.equal(hasRot(v), false, 'a failed lookup was reported as retired models'); + assert.deepEqual(v[0].unchecked, ["a", "b"]); + assert.equal(hasRot(v), false, "a failed lookup was reported as retired models"); }); -test('no API key is UNCHECKED, not a pass and not an outage', async () => { - const chain = [provider('groq', ['a'], 'MISSING_KEY_ENV')]; - const v = await checkCatalog(chain, { env: {}, fetchImpl: fakeFetch({ 'groq.test': ['a'] }) }); +test("no API key is UNCHECKED, not a pass and not an outage", async () => { + const chain = [provider("groq", ["a"], "MISSING_KEY_ENV")]; + const v = await checkCatalog(chain, { env: {}, fetchImpl: fakeFetch({ "groq.test": ["a"] }) }); assert.equal(v[0].live, null); - assert.deepEqual(v[0].unchecked, ['a']); + assert.deepEqual(v[0].unchecked, ["a"]); assert.equal(hasRot(v), false); assert.match(catalogReport(v), /UNCHECKED/); assert.match(catalogReport(v), /not a pass/); }); -test('a catalogue that parses but lists nothing is unreadable, not total rot', async () => { - const chain = [provider('groq', ['a'], 'GROQ_API_KEY')]; - const v = await checkCatalog(chain, { env: ENV, fetchImpl: fakeFetch({ 'groq.test': [] }) }); - assert.equal(v[0].live, null, 'an empty catalogue was believed'); +test("a catalogue that parses but lists nothing is unreadable, not total rot", async () => { + const chain = [provider("groq", ["a"], "GROQ_API_KEY")]; + const v = await checkCatalog(chain, { env: ENV, fetchImpl: fakeFetch({ "groq.test": [] }) }); + assert.equal(v[0].live, null, "an empty catalogue was believed"); assert.equal(hasRot(v), false); }); -test('a vendor whose every model is gone is named — the chain lost a link', async () => { +test("a vendor whose every model is gone is named — the chain lost a link", async () => { // The 2026-08-25 case: both Groq ids retired, so the "fallback chain" led // with a vendor that could never answer. const chain = [ - provider('groq', ['gone-1', 'gone-2'], 'GROQ_API_KEY'), - provider('openrouter', ['ok-1', 'gone-3'], 'OPENROUTER_API_KEY'), + provider("groq", ["gone-1", "gone-2"], "GROQ_API_KEY"), + provider("openrouter", ["ok-1", "gone-3"], "OPENROUTER_API_KEY"), ]; const v = await checkCatalog(chain, { env: ENV, - fetchImpl: fakeFetch({ 'groq.test': ['other'], 'openrouter.test': ['ok-1'] }), + fetchImpl: fakeFetch({ "groq.test": ["other"], "openrouter.test": ["ok-1"] }), }); - assert.deepEqual(deadProviders(v), ['groq']); + assert.deepEqual(deadProviders(v), ["groq"]); assert.match(catalogReport(v), /EVERY model is gone at: groq/); // openrouter is degraded, not dead — it must NOT be listed. - assert.ok(!deadProviders(v).includes('openrouter')); + assert.ok(!deadProviders(v).includes("openrouter")); }); -test('an env override is what gets checked — not the library default', async () => { +test("an env override is what gets checked — not the library default", async () => { // Checking the defaults would give a clean report on a box the operator has // already routed around, and miss the ids it actually calls. - const chain = [provider('groq', ['default-model'], 'GROQ_API_KEY')]; - const env = { ...ENV, T_GROQ_MODELS: 'override-model' }; + const chain = [provider("groq", ["default-model"], "GROQ_API_KEY")]; + const env = { ...ENV, T_GROQ_MODELS: "override-model" }; const v = await checkCatalog(chain, { env, - fetchImpl: fakeFetch({ 'groq.test': ['default-model'] }), + fetchImpl: fakeFetch({ "groq.test": ["default-model"] }), }); - assert.deepEqual(v[0].missing, ['override-model'], 'checked the default instead of the override'); + assert.deepEqual(v[0].missing, ["override-model"], "checked the default instead of the override"); }); diff --git a/test/chain.test.js b/test/chain.test.js index 1c8fe13..25623f5 100644 --- a/test/chain.test.js +++ b/test/chain.test.js @@ -2,8 +2,8 @@ * The chain's job is to never be a single point of failure, and to never ration * users against capacity that cannot be reached. Both are pinned here. */ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; +import { test } from "node:test"; +import assert from "node:assert/strict"; import { freeChain, @@ -12,80 +12,104 @@ import { dayCapacityTokens, usableChain, chainFrom, -} from 'ai-kit'; +} from "ai-kit"; -const CHAIN = freeChain('LOKI'); +const CHAIN = freeChain("LOKI"); -test('the default chain spans MORE THAN ONE VENDOR — the entire point', () => { +test("the default chain spans MORE THAN ONE VENDOR — the entire point", () => { const vendors = new Set(CHAIN.map((p) => p.id)); assert.ok(vendors.size >= 2, `a one-vendor chain is a pin with extra steps: ${[...vendors]}`); }); -test('every default model is free or explicitly a free-tier id', () => { +test("every default model is free or explicitly a free-tier id", () => { // A paid id sneaking into a chain named "free" is how a fallback quietly // starts billing. Groq's free tier is account-level (its ids carry no marker), // so the rule is applied where it is checkable: OpenRouter ids must be :free. - const openrouter = CHAIN.find((p) => p.id === 'openrouter'); + const openrouter = CHAIN.find((p) => p.id === "openrouter"); for (const model of openrouter.models) { assert.ok( - model.endsWith(':free') || model === 'openrouter/free', + model.endsWith(":free") || model === "openrouter/free", `paid OpenRouter model in the free chain: ${model}`, ); } }); -test('capacity counts ONLY vendors we hold a key for', () => { - assert.equal(dayCapacityTokens(CHAIN, {}), 0, 'unkeyed vendors contributed capacity'); +test("capacity counts ONLY vendors we hold a key for", () => { + assert.equal(dayCapacityTokens(CHAIN, {}), 0, "unkeyed vendors contributed capacity"); - const groqOnly = dayCapacityTokens(CHAIN, { GROQ_API_KEY: 'x' }); - assert.equal(groqOnly, CHAIN.find((p) => p.id === 'groq').dailyTokens); + const groqOnly = dayCapacityTokens(CHAIN, { GROQ_API_KEY: "x" }); + assert.equal(groqOnly, CHAIN.find((p) => p.id === "groq").dailyTokens); - const both = dayCapacityTokens(CHAIN, { GROQ_API_KEY: 'x', OPENROUTER_API_KEY: 'y' }); - assert.equal(both, CHAIN.reduce((n, p) => n + p.dailyTokens, 0), 'capacity must SUM across vendors'); + const both = dayCapacityTokens(CHAIN, { GROQ_API_KEY: "x", OPENROUTER_API_KEY: "y" }); + assert.equal( + both, + CHAIN.reduce((n, p) => n + p.dailyTokens, 0), + "capacity must SUM across vendors", + ); }); -test('an env override recalibrates a budget without a deploy, and junk falls back', () => { - assert.equal(dayCapacityTokens(CHAIN, { GROQ_API_KEY: 'x', LOKI_GROQ_DAILY_TOKENS: '12345' }), 12_345); +test("an env override recalibrates a budget without a deploy, and junk falls back", () => { + assert.equal( + dayCapacityTokens(CHAIN, { GROQ_API_KEY: "x", LOKI_GROQ_DAILY_TOKENS: "12345" }), + 12_345, + ); assert.equal( - dayCapacityTokens(CHAIN, { GROQ_API_KEY: 'x', LOKI_GROQ_DAILY_TOKENS: 'nonsense' }), - CHAIN.find((p) => p.id === 'groq').dailyTokens, - 'a junk override must fall back, not zero the budget', + dayCapacityTokens(CHAIN, { GROQ_API_KEY: "x", LOKI_GROQ_DAILY_TOKENS: "nonsense" }), + CHAIN.find((p) => p.id === "groq").dailyTokens, + "a junk override must fall back, not zero the budget", ); }); -test('a rotted model can be routed around by env alone', () => { - const groq = CHAIN.find((p) => p.id === 'groq'); - assert.deepEqual(providerModels(groq, { LOKI_GROQ_MODELS: 'a, b c' }), ['a', 'b', 'c']); - assert.deepEqual(providerModels(groq, {}), groq.models, 'no override must keep the shipped list'); - assert.deepEqual(providerModels(groq, { LOKI_GROQ_MODELS: ' ' }), groq.models, 'a blank override is not a wipe'); +test("a rotted model can be routed around by env alone", () => { + const groq = CHAIN.find((p) => p.id === "groq"); + assert.deepEqual(providerModels(groq, { LOKI_GROQ_MODELS: "a, b c" }), ["a", "b", "c"]); + assert.deepEqual(providerModels(groq, {}), groq.models, "no override must keep the shipped list"); + assert.deepEqual( + providerModels(groq, { LOKI_GROQ_MODELS: " " }), + groq.models, + "a blank override is not a wipe", + ); }); -test('usableChain silently drops vendors with no key', () => { - const links = usableChain(CHAIN, { GROQ_API_KEY: 'x' }); +test("usableChain silently drops vendors with no key", () => { + const links = usableChain(CHAIN, { GROQ_API_KEY: "x" }); assert.ok(links.length > 0); - assert.ok(links.every((l) => l.provider.id === 'groq'), 'an unkeyed vendor leaked into the chain'); + assert.ok( + links.every((l) => l.provider.id === "groq"), + "an unkeyed vendor leaked into the chain", + ); }); -test('a pinned model is a STARTING POINT, never a hard pin', () => { - const links = usableChain(CHAIN, { GROQ_API_KEY: 'x', OPENROUTER_API_KEY: 'y' }); - const pinned = chainFrom('openai/gpt-oss-20b:free', links); - assert.equal(pinned[0].model, 'openai/gpt-oss-20b:free'); - assert.ok(pinned.length > 1, 'pinning a model must not remove its fallbacks'); +test("a pinned model is a STARTING POINT, never a hard pin", () => { + const links = usableChain(CHAIN, { GROQ_API_KEY: "x", OPENROUTER_API_KEY: "y" }); + const pinned = chainFrom("openai/gpt-oss-20b:free", links); + assert.equal(pinned[0].model, "openai/gpt-oss-20b:free"); + assert.ok(pinned.length > 1, "pinning a model must not remove its fallbacks"); }); -test('an unknown model is tried, then falls through to the ordinary chain', () => { - const links = usableChain(CHAIN, { GROQ_API_KEY: 'x' }); - const out = chainFrom('some/just-released-id', links); - assert.equal(out[0].model, 'some/just-released-id', 'an unadvertised id is still a legitimate request'); - assert.equal(out.length, links.length + 1, 'it must not dead-end the chain'); +test("an unknown model is tried, then falls through to the ordinary chain", () => { + const links = usableChain(CHAIN, { GROQ_API_KEY: "x" }); + const out = chainFrom("some/just-released-id", links); + assert.equal( + out[0].model, + "some/just-released-id", + "an unadvertised id is still a legitimate request", + ); + assert.equal(out.length, links.length + 1, "it must not dead-end the chain"); }); -test('chainFrom on an empty chain returns empty rather than inventing a link', () => { - assert.deepEqual(chainFrom('anything', []), []); +test("chainFrom on an empty chain returns empty rather than inventing a link", () => { + assert.deepEqual(chainFrom("anything", []), []); }); -test('withEnvPrefix derives both override names from the provider id', () => { - const p = withEnvPrefix('APP', { id: 'my-vendor', baseUrl: 'https://x', keyEnv: 'K', models: ['m'], dailyTokens: 1 }); - assert.equal(p.modelsEnv, 'APP_MY_VENDOR_MODELS'); - assert.equal(p.dailyTokensEnv, 'APP_MY_VENDOR_DAILY_TOKENS'); +test("withEnvPrefix derives both override names from the provider id", () => { + const p = withEnvPrefix("APP", { + id: "my-vendor", + baseUrl: "https://x", + keyEnv: "K", + models: ["m"], + dailyTokens: 1, + }); + assert.equal(p.modelsEnv, "APP_MY_VENDOR_MODELS"); + assert.equal(p.dailyTokensEnv, "APP_MY_VENDOR_DAILY_TOKENS"); }); diff --git a/test/cjs-condition.test.js b/test/cjs-condition.test.js index 390d8b1..d3b120a 100644 --- a/test/cjs-condition.test.js +++ b/test/cjs-condition.test.js @@ -14,32 +14,32 @@ * package.json and the createRequire half fails; break the ESM build and the * import half fails. */ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { createRequire } from 'node:module'; +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; -import * as esmGrounding from 'ai-kit/grounding'; -import * as esmRegistry from 'ai-kit/registry'; +import * as esmGrounding from "ai-kit/grounding"; +import * as esmRegistry from "ai-kit/registry"; const require = createRequire(import.meta.url); -test('ESM import serves the grounding surface', () => { - assert.equal(typeof esmGrounding.verifyAnswer, 'function'); - assert.equal(esmGrounding.NOT_RECORDED, ''); - assert.equal(typeof esmRegistry.defineRegistry, 'function'); +test("ESM import serves the grounding surface", () => { + assert.equal(typeof esmGrounding.verifyAnswer, "function"); + assert.equal(esmGrounding.NOT_RECORDED, ""); + assert.equal(typeof esmRegistry.defineRegistry, "function"); }); -test('CJS require serves the SAME surface through the require condition', () => { - const g = require('ai-kit/grounding'); - const r = require('ai-kit/registry'); - assert.equal(typeof g.verifyAnswer, 'function'); - assert.equal(g.NOT_RECORDED, ''); - assert.equal(typeof r.defineRegistry, 'function'); +test("CJS require serves the SAME surface through the require condition", () => { + const g = require("ai-kit/grounding"); + const r = require("ai-kit/registry"); + assert.equal(typeof g.verifyAnswer, "function"); + assert.equal(g.NOT_RECORDED, ""); + assert.equal(typeof r.defineRegistry, "function"); // Same behavior, not merely same names: both loaders must agree on a verdict. - const answer = 'Your contact is Ilya Druzhnikov (UZH).'; - const viaEsm = esmGrounding.verifyAnswer({ answer, facts: [], userMessage: 'who?' }); - const viaCjs = g.verifyAnswer({ answer, facts: [], userMessage: 'who?' }); + const answer = "Your contact is Ilya Druzhnikov (UZH)."; + const viaEsm = esmGrounding.verifyAnswer({ answer, facts: [], userMessage: "who?" }); + const viaCjs = g.verifyAnswer({ answer, facts: [], userMessage: "who?" }); assert.equal(viaEsm.ok, viaCjs.ok); assert.equal(viaEsm.ok, false); }); diff --git a/test/cost.test.js b/test/cost.test.js index 2f55db0..1237dee 100644 --- a/test/cost.test.js +++ b/test/cost.test.js @@ -3,67 +3,77 @@ * a fallback that silently began spending the moment the free tier ran dry. * These cases are the real ids that were found in production config. */ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; +import { test } from "node:test"; +import assert from "node:assert/strict"; -import { modelCost, modelCostAt, paidModelsIn, freeChain } from 'ai-kit'; +import { modelCost, modelCostAt, paidModelsIn, freeChain } from "ai-kit"; -test('the three ids that were actually billing are all caught', () => { - assert.equal(modelCost('anthropic/claude-sonnet-5'), 'paid'); - assert.equal(modelCost('google/gemini-2.0-flash-001'), 'paid'); +test("the three ids that were actually billing are all caught", () => { + assert.equal(modelCost("anthropic/claude-sonnet-5"), "paid"); + assert.equal(modelCost("google/gemini-2.0-flash-001"), "paid"); // Reads free, bills at 1e-7/token, and its `:free` sibling has been retired. - assert.equal(modelCost('meta-llama/llama-3.3-70b-instruct'), 'paid'); + assert.equal(modelCost("meta-llama/llama-3.3-70b-instruct"), "paid"); }); -test('the `:free` suffix is the whole difference', () => { - assert.equal(modelCost('openai/gpt-oss-20b:free'), 'free'); - assert.equal(modelCost('openai/gpt-oss-20b'), 'paid'); - assert.equal(modelCost('openrouter/free'), 'free', 'the free auto-router'); +test("the `:free` suffix is the whole difference", () => { + assert.equal(modelCost("openai/gpt-oss-20b:free"), "free"); + assert.equal(modelCost("openai/gpt-oss-20b"), "paid"); + assert.equal(modelCost("openrouter/free"), "free", "the free auto-router"); }); -test('a bare vendor id is UNKNOWN, never assumed free', () => { +test("a bare vendor id is UNKNOWN, never assumed free", () => { // Whether `llama-3.1-8b-instant` costs depends on the account tier at Groq — // no string can answer that. Guessing "free" is the direction that let three // of these through review, so it must not be the default. - assert.equal(modelCost('llama-3.1-8b-instant'), 'unknown'); - assert.equal(modelCost('llama-3.3-70b-versatile'), 'unknown'); - assert.equal(modelCost(''), 'unknown'); - assert.equal(modelCost(' '), 'unknown'); + assert.equal(modelCost("llama-3.1-8b-instant"), "unknown"); + assert.equal(modelCost("llama-3.3-70b-versatile"), "unknown"); + assert.equal(modelCost(""), "unknown"); + assert.equal(modelCost(" "), "unknown"); }); -test('the shipped free chain contains no paid model', () => { +test("the shipped free chain contains no paid model", () => { // The package would have no standing to flag anyone else's chain otherwise. - assert.deepEqual(paidModelsIn(freeChain('TEST')), []); + assert.deepEqual(paidModelsIn(freeChain("TEST")), []); }); -const routed = { id: 'openrouter', baseUrl: 'x', keyEnv: 'K', models: [], dailyTokens: 1, routed: true }; -const direct = { id: 'groq', baseUrl: 'x', keyEnv: 'K', models: [], dailyTokens: 1 }; +const routed = { + id: "openrouter", + baseUrl: "x", + keyEnv: "K", + models: [], + dailyTokens: 1, + routed: true, +}; +const direct = { id: "groq", baseUrl: "x", keyEnv: "K", models: [], dailyTokens: 1 }; -test('the same id is PAID at a routed vendor and UNKNOWN at a direct one', () => { +test("the same id is PAID at a routed vendor and UNKNOWN at a direct one", () => { // Cost is not a property of the string. `openai/gpt-oss-20b` bills at // OpenRouter (routed, no `:free`); at Groq it is that vendor's own name for a // model whose cost is the account's tier. Judging by shape alone was safe // only while direct vendors used bare ids like `llama-3.1-8b-instant` — Groq // now ships vendor-prefixed ids, which is what broke this. - assert.equal(modelCostAt(routed, 'openai/gpt-oss-20b'), 'paid'); - assert.equal(modelCostAt(direct, 'openai/gpt-oss-20b'), 'unknown'); - assert.equal(modelCostAt(routed, 'openai/gpt-oss-20b:free'), 'free'); + assert.equal(modelCostAt(routed, "openai/gpt-oss-20b"), "paid"); + assert.equal(modelCostAt(direct, "openai/gpt-oss-20b"), "unknown"); + assert.equal(modelCostAt(routed, "openai/gpt-oss-20b:free"), "free"); }); test('a direct vendor never yields "free" from the id alone', () => { // "unknown" is the honest answer AND the safe direction. Returning "free" // here would reopen the exact hole modelCost was written to close. - for (const id of ['openai/gpt-oss-120b', 'llama-3.1-8b-instant', 'anything/at-all:free']) { - assert.notEqual(modelCostAt(direct, id), 'free', `${id} was assumed free at a direct vendor`); + for (const id of ["openai/gpt-oss-120b", "llama-3.1-8b-instant", "anything/at-all:free"]) { + assert.notEqual(modelCostAt(direct, id), "free", `${id} was assumed free at a direct vendor`); } }); -test('provider-awareness does NOT weaken the guard where the real incidents were', () => { +test("provider-awareness does NOT weaken the guard where the real incidents were", () => { // All three production incidents were routed ids missing `:free`. Those must // still be caught, or this change traded a false alarm for a real miss. const chain = [ - { ...direct, models: ['openai/gpt-oss-120b'] }, - { ...routed, models: ['anthropic/claude-sonnet-5', 'google/gemini-2.0-flash-001'] }, + { ...direct, models: ["openai/gpt-oss-120b"] }, + { ...routed, models: ["anthropic/claude-sonnet-5", "google/gemini-2.0-flash-001"] }, ]; - assert.deepEqual(paidModelsIn(chain), ['anthropic/claude-sonnet-5', 'google/gemini-2.0-flash-001']); + assert.deepEqual(paidModelsIn(chain), [ + "anthropic/claude-sonnet-5", + "google/gemini-2.0-flash-001", + ]); }); diff --git a/test/exports.test.js b/test/exports.test.js index 525bb55..f27668e 100644 --- a/test/exports.test.js +++ b/test/exports.test.js @@ -8,23 +8,36 @@ * package may import itself by name when it declares `exports`) is what makes * this checkable without publishing. */ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; +import { test } from "node:test"; +import assert from "node:assert/strict"; -import * as pkg from 'ai-kit'; +import * as pkg from "ai-kit"; -test('the package exports its public surface through the exports map', () => { +test("the package exports its public surface through the exports map", () => { const expected = [ // chain - 'providerModels', 'withEnvPrefix', 'freeChain', 'dayCapacityTokens', 'usableChain', 'chainFrom', + "providerModels", + "withEnvPrefix", + "freeChain", + "dayCapacityTokens", + "usableChain", + "chainFrom", // attempt - 'tryChain', 'ChainExhaustedError', + "tryChain", + "ChainExhaustedError", // health - 'createHealthTracker', + "createHealthTracker", // limits - 'classifyRateLimit', 'retryAfterSeconds', 'humanizeWait', 'rateLimitMessage', + "classifyRateLimit", + "retryAfterSeconds", + "humanizeWait", + "rateLimitMessage", // fair-share - 'fairShare', 'utcDayElapsed', 'utcDayKey', 'DAY_SECONDS', 'DEFAULT_BURST', + "fairShare", + "utcDayElapsed", + "utcDayKey", + "DAY_SECONDS", + "DEFAULT_BURST", ]; for (const name of expected) { assert.ok(name in pkg, `missing export: ${name}`); @@ -40,10 +53,10 @@ test('the package exports its public surface through the exports map', () => { * was taken down by one it skipped. Everything below still ships from this one * package at one version. What changed is WHERE from. */ -test('form filling is reachable from the package, so one install covers it', async () => { - const forms = await import('ai-kit/forms'); - for (const name of ['runFormAssist', 'defineFields', 'mergeValues', 'sanitizeValues']) { - assert.equal(typeof forms[name], 'function', `missing export: ${name}`); +test("form filling is reachable from the package, so one install covers it", async () => { + const forms = await import("ai-kit/forms"); + for (const name of ["runFormAssist", "defineFields", "mergeValues", "sanitizeValues"]) { + assert.equal(typeof forms[name], "function", `missing export: ${name}`); } }); @@ -61,10 +74,10 @@ test('form filling is reachable from the package, so one install covers it', asy * So the absence is the contract. A convenience re-export added back at the root * would look harmless in review and break the next consumer the same way. */ -test('the root does NOT drag the form layer in behind the chain', async () => { - const pkg = await import('ai-kit'); - assert.equal(typeof pkg.freeChain, 'function', 'the chain belongs at the root'); - for (const name of ['runFormAssist', 'defineFields']) { +test("the root does NOT drag the form layer in behind the chain", async () => { + const pkg = await import("ai-kit"); + assert.equal(typeof pkg.freeChain, "function", "the chain belongs at the root"); + for (const name of ["runFormAssist", "defineFields"]) { assert.equal( name in pkg, false, @@ -73,12 +86,12 @@ test('the root does NOT drag the form layer in behind the chain', async () => { } }); -test('./forms resolves through the exports map', async () => { - const forms = await import('ai-kit/forms'); - assert.equal(typeof forms.runFormAssist, 'function'); +test("./forms resolves through the exports map", async () => { + const forms = await import("ai-kit/forms"); + assert.equal(typeof forms.runFormAssist, "function"); }); -test('./server resolves, and is what the most-adopted package actually ships', async () => { - const server = await import('ai-kit/server'); - assert.equal(typeof server.createFormAssistHandler, 'function'); +test("./server resolves, and is what the most-adopted package actually ships", async () => { + const server = await import("ai-kit/server"); + assert.equal(typeof server.createFormAssistHandler, "function"); }); diff --git a/test/fair-share.test.js b/test/fair-share.test.js index 83a70b7..082960c 100644 --- a/test/fair-share.test.js +++ b/test/fair-share.test.js @@ -7,52 +7,59 @@ * turn costs cannot silently move a case into a different branch while its name * still claims the old one. */ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; +import { test } from "node:test"; +import assert from "node:assert/strict"; -import { fairShare, utcDayElapsed, utcDayKey, DAY_SECONDS, DEFAULT_BURST } from 'ai-kit'; +import { fairShare, utcDayElapsed, utcDayKey, DAY_SECONDS, DEFAULT_BURST } from "ai-kit"; const TURN = 20_000; const NOON = 0.5; const ask = (over) => - fairShare({ dayCapacityTokens: 8 * TURN, activeUsers: 2, userSpentTokens: 0, costTokens: TURN, dayElapsed: NOON, ...over }); + fairShare({ + dayCapacityTokens: 8 * TURN, + activeUsers: 2, + userSpentTokens: 0, + costTokens: TURN, + dayElapsed: NOON, + ...over, + }); -test('a user well inside their paced allowance is admitted', () => { +test("a user well inside their paced allowance is admitted", () => { const d = ask({}); assert.equal(d.allowed, true); - assert.equal(d.reason, 'ok'); + assert.equal(d.reason, "ok"); assert.equal(d.shareTokens, 4 * TURN); }); -test('no capacity refuses without pretending a wait helps', () => { +test("no capacity refuses without pretending a wait helps", () => { const d = ask({ dayCapacityTokens: 0 }); assert.equal(d.allowed, false); - assert.equal(d.reason, 'no-capacity'); + assert.equal(d.reason, "no-capacity"); assert.equal(d.retryAfterSeconds, undefined); }); -test('PACED: within the day share but ahead of the clock — a wait genuinely helps', () => { +test("PACED: within the day share but ahead of the clock — a wait genuinely helps", () => { // 2 users share 8·TURN → 4·TURN each. At noon the pace has unlocked // 0.5 + 0.25 burst = 0.75 of it, i.e. 3·TURN. Having spent 2.5·TURN, a further // turn wants 3.5·TURN: past the unlocked allowance, inside the day's share. const d = ask({ userSpentTokens: 2.5 * TURN }); assert.equal(d.allowed, false); - assert.equal(d.reason, 'paced'); - assert.equal(typeof d.retryAfterSeconds, 'number'); + assert.equal(d.reason, "paced"); + assert.equal(typeof d.retryAfterSeconds, "number"); assert.ok(d.retryAfterSeconds > 0); }); -test('SHARE-SPENT: no retry offered, because no wait can help today', () => { +test("SHARE-SPENT: no retry offered, because no wait can help today", () => { // Offering a retry here is the same lie as "try again shortly" on an exhausted // daily quota — it invites a request guaranteed to fail until midnight. const d = ask({ userSpentTokens: 4 * TURN }); assert.equal(d.allowed, false); - assert.equal(d.reason, 'share-spent'); + assert.equal(d.reason, "share-spent"); assert.equal(d.retryAfterSeconds, undefined); }); -test('THE ONE-TURN FLOOR: a newcomer is never refused their first turn', () => { +test("THE ONE-TURN FLOOR: a newcomer is never refused their first turn", () => { // Pure pacing refuses the opening question of the morning, and to someone // trying the product for the first time that is indistinguishable from broken. for (const dayElapsed of [0, 0.0001, 0.5, 0.99]) { @@ -67,21 +74,21 @@ test('THE ONE-TURN FLOOR: a newcomer is never refused their first turn', () => { } }); -test('the floor never hands out more than the share', () => { +test("the floor never hands out more than the share", () => { // A turn costing more than a whole share must still be refused, or the floor // becomes a hole in the ration rather than a courtesy. const d = fairShare({ dayCapacityTokens: 2 * TURN, - activeUsers: 4, // share = TURN/2 + activeUsers: 4, // share = TURN/2 userSpentTokens: 0, - costTokens: TURN, // one turn costs twice the share + costTokens: TURN, // one turn costs twice the share dayElapsed: 0.99, }); assert.equal(d.allowed, false); assert.ok(d.allowanceTokens <= d.shareTokens); }); -test('a quiet day is not rationed away — one active user gets the whole pool', () => { +test("a quiet day is not rationed away — one active user gets the whole pool", () => { const d = fairShare({ dayCapacityTokens: 8 * TURN, activeUsers: 1, @@ -89,11 +96,11 @@ test('a quiet day is not rationed away — one active user gets the whole pool', costTokens: TURN, dayElapsed: 0.1, }); - assert.equal(d.shareTokens, 8 * TURN, 'counting dormant accounts would waste a generous budget'); + assert.equal(d.shareTokens, 8 * TURN, "counting dormant accounts would waste a generous budget"); assert.equal(d.allowed, true); }); -test('NO CLAWBACK: a newly-arrived second user does not retroactively punish the first', () => { +test("NO CLAWBACK: a newly-arrived second user does not retroactively punish the first", () => { // The first user spent 5·TURN while alone and legitimately entitled to it. // A second user appearing drops the share to 4·TURN — below what is already // spent. The correct outcome is "your allowance stops growing", i.e. a plain @@ -106,30 +113,36 @@ test('NO CLAWBACK: a newly-arrived second user does not retroactively punish the dayElapsed: 0.5, }); assert.equal(d.allowed, false); - assert.equal(d.reason, 'share-spent'); + assert.equal(d.reason, "share-spent"); assert.ok(d.allowanceTokens >= 0); }); -test('hostile inputs cannot produce a division by zero or a negative allowance', () => { +test("hostile inputs cannot produce a division by zero or a negative allowance", () => { for (const activeUsers of [0, -3, NaN]) { const d = ask({ activeUsers }); - assert.ok(Number.isFinite(d.shareTokens), `shareTokens not finite for activeUsers=${activeUsers}`); + assert.ok( + Number.isFinite(d.shareTokens), + `shareTokens not finite for activeUsers=${activeUsers}`, + ); assert.ok(d.shareTokens > 0); } const skewed = ask({ dayElapsed: -5 }); - assert.ok(skewed.allowanceTokens >= 0, 'clock skew must not produce a negative allowance'); + assert.ok(skewed.allowanceTokens >= 0, "clock skew must not produce a negative allowance"); const future = ask({ dayElapsed: 99 }); - assert.ok(future.allowanceTokens <= future.shareTokens, 'a clamped day must not exceed the share'); + assert.ok( + future.allowanceTokens <= future.shareTokens, + "a clamped day must not exceed the share", + ); }); -test('the day is measured and bucketed in UTC, where providers meter it', () => { - assert.equal(utcDayElapsed(new Date('2026-08-15T00:00:00Z')), 0); - assert.equal(utcDayElapsed(new Date('2026-08-15T12:00:00Z')), 0.5); - assert.equal(utcDayKey(new Date('2026-08-15T23:59:59Z')), '2026-08-15'); - assert.equal(utcDayKey(new Date('2026-08-16T00:00:01Z')), '2026-08-16'); +test("the day is measured and bucketed in UTC, where providers meter it", () => { + assert.equal(utcDayElapsed(new Date("2026-08-15T00:00:00Z")), 0); + assert.equal(utcDayElapsed(new Date("2026-08-15T12:00:00Z")), 0.5); + assert.equal(utcDayKey(new Date("2026-08-15T23:59:59Z")), "2026-08-15"); + assert.equal(utcDayKey(new Date("2026-08-16T00:00:01Z")), "2026-08-16"); }); -test('the published constants are the ones the policy actually uses', () => { +test("the published constants are the ones the policy actually uses", () => { assert.equal(DAY_SECONDS, 86_400); assert.ok(DEFAULT_BURST > 0 && DEFAULT_BURST < 1); }); diff --git a/test/grounding.test.js b/test/grounding.test.js index b313596..db4b008 100644 --- a/test/grounding.test.js +++ b/test/grounding.test.js @@ -11,62 +11,56 @@ * from this version on, this copy is the only definition of "grounded" the * fleet has. */ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; +import { test } from "node:test"; +import assert from "node:assert/strict"; -import { - makeFact, - assignFactIds, - renderFacts, - verifyAnswer, - NOT_RECORDED, -} from 'ai-kit/grounding'; +import { makeFact, assignFactIds, renderFacts, verifyAnswer, NOT_RECORDED } from "ai-kit/grounding"; const elena = () => assignFactIds([ makeFact({ - kind: 'person', - subject: 'Elena Weber', - source: 'people', + kind: "person", + subject: "Elena Weber", + source: "people", // Only DECLARED fields survive makeFact — affiliation and channels are // in person's declared set; an undeclared key would be dropped, which is // itself part of the design (nothing reaches the model unregistered). - values: { name: 'Elena Weber', affiliation: 'SINGA Switzerland', channels: '+41774730093' }, + values: { name: "Elena Weber", affiliation: "SINGA Switzerland", channels: "+41774730093" }, }), ]); -test('absence renders as an explicit negative, not as silence', () => { +test("absence renders as an explicit negative, not as silence", () => { const rendered = renderFacts(elena()); - assert.ok(rendered.includes(NOT_RECORDED), 'undeclared fields must render as '); - assert.ok(rendered.includes('SINGA Switzerland')); + assert.ok(rendered.includes(NOT_RECORDED), "undeclared fields must render as "); + assert.ok(rendered.includes("SINGA Switzerland")); }); -test('the canonical fabrication is caught: a novel proper noun with no source', () => { +test("the canonical fabrication is caught: a novel proper noun with no source", () => { const facts = elena(); const { ok, violations } = verifyAnswer({ - answer: 'Your contact is Ilya Druzhnikov at the University of Liechtenstein.', + answer: "Your contact is Ilya Druzhnikov at the University of Liechtenstein.", facts, - userMessage: 'who should I contact?', + userMessage: "who should I contact?", }); assert.equal(ok, false); - assert.ok(violations.some((v) => v.kind === 'novel-proper-noun')); + assert.ok(violations.some((v) => v.kind === "novel-proper-noun")); }); -test('the true answer passes clean — names and numbers attested by the records', () => { +test("the true answer passes clean — names and numbers attested by the records", () => { const facts = elena(); const { ok, violations } = verifyAnswer({ - answer: 'Elena Weber (SINGA Switzerland) — +41774730093.', + answer: "Elena Weber (SINGA Switzerland) — +41774730093.", facts, - userMessage: 'who should I contact?', + userMessage: "who should I contact?", }); assert.equal(ok, true, JSON.stringify(violations)); }); -test('what the user themselves said is never a fabrication', () => { +test("what the user themselves said is never a fabrication", () => { const { ok } = verifyAnswer({ - answer: 'Noted — Bahnhofstrasse 12 is saved as the meeting point.', + answer: "Noted — Bahnhofstrasse 12 is saved as the meeting point.", facts: [], - userMessage: 'we meet at Bahnhofstrasse 12', + userMessage: "we meet at Bahnhofstrasse 12", }); assert.equal(ok, true); }); diff --git a/test/health.test.js b/test/health.test.js index a0d14ad..9ab2ede 100644 --- a/test/health.test.js +++ b/test/health.test.js @@ -2,68 +2,72 @@ * The tracker's whole job is turning a run of successes/failures into a * status a health route can trust — pinned here, transition by transition. */ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; +import { test } from "node:test"; +import assert from "node:assert/strict"; -import { createHealthTracker } from 'ai-kit'; +import { createHealthTracker } from "ai-kit"; -test('starts unknown — no evidence either way yet', () => { +test("starts unknown — no evidence either way yet", () => { const tracker = createHealthTracker(); - assert.equal(tracker.getHealth().status, 'unknown'); + assert.equal(tracker.getHealth().status, "unknown"); }); test('one success is ok, not merely "not down"', () => { const tracker = createHealthTracker(); tracker.recordSuccess(); const health = tracker.getHealth(); - assert.equal(health.status, 'ok'); + assert.equal(health.status, "ok"); assert.equal(health.consecutiveFailures, 0); assert.equal(health.lastError, null); }); -test('failures short of the threshold are degraded, not down', () => { +test("failures short of the threshold are degraded, not down", () => { const tracker = createHealthTracker({ downAfter: 3 }); - tracker.recordFailure(new Error('401')); - assert.equal(tracker.getHealth().status, 'degraded'); - tracker.recordFailure(new Error('401')); - assert.equal(tracker.getHealth().status, 'degraded'); + tracker.recordFailure(new Error("401")); + assert.equal(tracker.getHealth().status, "degraded"); + tracker.recordFailure(new Error("401")); + assert.equal(tracker.getHealth().status, "degraded"); }); -test('the Nth consecutive failure flips it down, exactly at the threshold', () => { +test("the Nth consecutive failure flips it down, exactly at the threshold", () => { const tracker = createHealthTracker({ downAfter: 3 }); - tracker.recordFailure(new Error('a')); - tracker.recordFailure(new Error('b')); - tracker.recordFailure(new Error('c')); + tracker.recordFailure(new Error("a")); + tracker.recordFailure(new Error("b")); + tracker.recordFailure(new Error("c")); const health = tracker.getHealth(); - assert.equal(health.status, 'down'); + assert.equal(health.status, "down"); assert.equal(health.consecutiveFailures, 3); - assert.equal(health.lastError, 'c'); + assert.equal(health.lastError, "c"); }); -test('a single success recovers a down tracker to ok, not degraded', () => { +test("a single success recovers a down tracker to ok, not degraded", () => { const tracker = createHealthTracker({ downAfter: 2 }); - tracker.recordFailure(new Error('x')); - tracker.recordFailure(new Error('y')); - assert.equal(tracker.getHealth().status, 'down'); + tracker.recordFailure(new Error("x")); + tracker.recordFailure(new Error("y")); + assert.equal(tracker.getHealth().status, "down"); tracker.recordSuccess(); const health = tracker.getHealth(); - assert.equal(health.status, 'ok'); - assert.equal(health.consecutiveFailures, 0, 'the streak must reset, not merely drop below threshold'); + assert.equal(health.status, "ok"); + assert.equal( + health.consecutiveFailures, + 0, + "the streak must reset, not merely drop below threshold", + ); }); -test('a non-Error failure still records a readable message', () => { +test("a non-Error failure still records a readable message", () => { const tracker = createHealthTracker(); - tracker.recordFailure('plain string failure'); - assert.equal(tracker.getHealth().lastError, 'plain string failure'); + tracker.recordFailure("plain string failure"); + assert.equal(tracker.getHealth().lastError, "plain string failure"); }); -test('reset returns to unknown, clearing every field', () => { +test("reset returns to unknown, clearing every field", () => { const tracker = createHealthTracker(); - tracker.recordFailure(new Error('boom')); + tracker.recordFailure(new Error("boom")); tracker.reset(); assert.deepEqual(tracker.getHealth(), { - status: 'unknown', + status: "unknown", consecutiveFailures: 0, lastError: null, lastSuccessAt: null, @@ -71,20 +75,20 @@ test('reset returns to unknown, clearing every field', () => { }); }); -test('the clock is injectable, not read from a global', () => { +test("the clock is injectable, not read from a global", () => { let now = 1000; const tracker = createHealthTracker({ now: () => now }); tracker.recordSuccess(); assert.equal(tracker.getHealth().lastSuccessAt, 1000); now = 2000; - tracker.recordFailure(new Error('later')); + tracker.recordFailure(new Error("later")); assert.equal(tracker.getHealth().lastFailureAt, 2000); }); -test('two independent trackers never share state — no hidden singleton', () => { +test("two independent trackers never share state — no hidden singleton", () => { const a = createHealthTracker(); const b = createHealthTracker(); - a.recordFailure(new Error('only a')); - assert.equal(a.getHealth().status, 'degraded'); - assert.equal(b.getHealth().status, 'unknown'); + a.recordFailure(new Error("only a")); + assert.equal(a.getHealth().status, "degraded"); + assert.equal(b.getHealth().status, "unknown"); }); diff --git a/test/limits.test.js b/test/limits.test.js index a072c31..531faff 100644 --- a/test/limits.test.js +++ b/test/limits.test.js @@ -3,67 +3,70 @@ * a cosmetic error — it is the difference between recovering and making things * worse. These are the real response bodies, not paraphrases. */ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; +import { test } from "node:test"; +import assert from "node:assert/strict"; -import { classifyRateLimit, retryAfterSeconds, humanizeWait, rateLimitMessage } from 'ai-kit'; +import { classifyRateLimit, retryAfterSeconds, humanizeWait, rateLimitMessage } from "ai-kit"; const CAPACITY = - 'Rate limit reached for model `llama-3.3-70b-versatile` on tokens per minute (TPM): Limit 12000, Used 11800, Requested 400. Please try again in 3.6s.'; + "Rate limit reached for model `llama-3.3-70b-versatile` on tokens per minute (TPM): Limit 12000, Used 11800, Requested 400. Please try again in 3.6s."; const SIZE = - 'Request too large for model `llama-3.1-8b-instant` on tokens per minute (TPM): Limit 6000, Requested 15041, please reduce your message size and try again.'; + "Request too large for model `llama-3.1-8b-instant` on tokens per minute (TPM): Limit 6000, Requested 15041, please reduce your message size and try again."; const DAILY = - 'Rate limit reached for model `llama-3.3-70b-versatile` on tokens per day (TPD): Limit 100000, Used 99331, Requested 4589. Please try again in 56m26.88s.'; + "Rate limit reached for model `llama-3.3-70b-versatile` on tokens per day (TPD): Limit 100000, Used 99331, Requested 4589. Please try again in 56m26.88s."; -test('the three kinds are told apart by BODY, not status', () => { - assert.equal(classifyRateLimit(CAPACITY), 'capacity'); - assert.equal(classifyRateLimit(SIZE), 'size'); - assert.equal(classifyRateLimit(DAILY), 'daily'); +test("the three kinds are told apart by BODY, not status", () => { + assert.equal(classifyRateLimit(CAPACITY), "capacity"); + assert.equal(classifyRateLimit(SIZE), "size"); + assert.equal(classifyRateLimit(DAILY), "daily"); }); -test('DAILY wins over CAPACITY — it matches both wordings', () => { +test("DAILY wins over CAPACITY — it matches both wordings", () => { // The daily body opens with the same "Rate limit reached" phrase as capacity, // so a classifier that tests capacity first silently swallows every daily cap // and then "helpfully" retries against an empty budget for the rest of the day. assert.match(DAILY, /Rate limit reached/); - assert.equal(classifyRateLimit(DAILY), 'daily'); + assert.equal(classifyRateLimit(DAILY), "daily"); }); -test('requests-per-day is treated as daily too', () => { - assert.equal(classifyRateLimit('Rate limit reached on requests per day (RPD): Limit 50'), 'daily'); +test("requests-per-day is treated as daily too", () => { + assert.equal( + classifyRateLimit("Rate limit reached on requests per day (RPD): Limit 50"), + "daily", + ); }); -test('an unrecognisable body degrades to CAPACITY, the safe guess', () => { +test("an unrecognisable body degrades to CAPACITY, the safe guess", () => { // Guessing "size" would shed context that was never the problem; guessing // "daily" would abandon a turn that might well have succeeded. - assert.equal(classifyRateLimit('some vendor phrasing nobody has seen'), 'capacity'); - assert.equal(classifyRateLimit(''), 'capacity'); + assert.equal(classifyRateLimit("some vendor phrasing nobody has seen"), "capacity"); + assert.equal(classifyRateLimit(""), "capacity"); }); -test('the wait the provider named is parsed, in both shapes it emits', () => { +test("the wait the provider named is parsed, in both shapes it emits", () => { assert.equal(retryAfterSeconds(CAPACITY), 4); // 3.6s → ceil assert.equal(retryAfterSeconds(DAILY), 3387); // 56m26.88s - assert.equal(retryAfterSeconds(SIZE), null, 'no stated wait must be null, not zero'); + assert.equal(retryAfterSeconds(SIZE), null, "no stated wait must be null, not zero"); }); -test('humanizeWait stays readable across the range', () => { - assert.equal(humanizeWait(4), '4s'); - assert.equal(humanizeWait(3387), '57 minutes'); - assert.equal(humanizeWait(7200), 'about 2 hours'); +test("humanizeWait stays readable across the range", () => { + assert.equal(humanizeWait(4), "4s"); + assert.equal(humanizeWait(3387), "57 minutes"); + assert.equal(humanizeWait(7200), "about 2 hours"); assert.equal(humanizeWait(null), null); - assert.equal(humanizeWait(0), null, 'a zero wait is no wait'); + assert.equal(humanizeWait(0), null, "a zero wait is no wait"); assert.equal(humanizeWait(-5), null); }); -test('the singular hour is REACHABLE — a dead branch is a lie about the output', () => { +test("the singular hour is REACHABLE — a dead branch is a lie about the output", () => { // With the boundary at 90 minutes this was impossible: anything reaching the // hours branch divided to >= 1.5, which rounds to 2. So "about 1 hour" could // never print, and an hour-long wait was announced as two. - assert.equal(humanizeWait(3600), 'about 1 hour'); - assert.equal(humanizeWait(4800), 'about 1 hour'); + assert.equal(humanizeWait(3600), "about 1 hour"); + assert.equal(humanizeWait(4800), "about 1 hour"); }); -test('the message tells the user whether waiting can possibly help', () => { +test("the message tells the user whether waiting can possibly help", () => { // This is the whole point: "try again shortly" on an exhausted DAY invites // exactly the retry that is guaranteed to fail for the next hour. assert.match(rateLimitMessage(DAILY), /daily model quota is used up/); @@ -74,13 +77,13 @@ test('the message tells the user whether waiting can possibly help', () => { const size = rateLimitMessage(SIZE); assert.match(size, /more context than the model allows/); - assert.doesNotMatch(size, /try again/i, 'retrying is not the fix for an oversized request'); + assert.doesNotMatch(size, /try again/i, "retrying is not the fix for an oversized request"); }); -test('the message is a CLAUSE the caller can embed', () => { +test("the message is a CLAUSE the caller can embed", () => { for (const body of [CAPACITY, SIZE, DAILY]) { const msg = rateLimitMessage(body); - assert.doesNotMatch(msg, /^[A-Z]/, 'a leading capital reads wrong mid-sentence'); - assert.doesNotMatch(msg, /\.$/, 'a trailing period double-punctuates the caller'); + assert.doesNotMatch(msg, /^[A-Z]/, "a leading capital reads wrong mid-sentence"); + assert.doesNotMatch(msg, /\.$/, "a trailing period double-punctuates the caller"); } }); diff --git a/test/registry.test.js b/test/registry.test.js index 4d46a5a..a3c38ec 100644 --- a/test/registry.test.js +++ b/test/registry.test.js @@ -9,83 +9,110 @@ * `:free` suffix convention (three apps silently billed on fallback because * the suffix was the whole difference). */ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; +import { test } from "node:test"; +import assert from "node:assert/strict"; -import { defineRegistry, freeOnly, toolCapable } from 'ai-kit/registry'; +import { defineRegistry, freeOnly, toolCapable } from "ai-kit/registry"; const ENTRIES = [ - { id: 'openai/gpt-oss-20b', vendor: 'groq', paid: false, toolProtocol: 'native', usedFor: 'default chat' }, - { id: 'nvidia/nemotron-3-super-120b-a12b:free', vendor: 'openrouter', paid: false, toolProtocol: 'text' }, - { id: 'moonshotai/kimi-k2', vendor: 'openrouter', author: 'Moonshot', paid: true, inputCostPer1M: 0.6, outputCostPer1M: 2.5 }, - { id: 'anthropic/claude-fable-5', vendor: 'openrouter', author: 'Anthropic', paid: true, inputCostPer1M: 15, outputCostPer1M: 75, supportsTemperature: false }, - { id: 'whisper-large-v3', vendor: 'groq', paid: false, kind: 'transcribe' }, + { + id: "openai/gpt-oss-20b", + vendor: "groq", + paid: false, + toolProtocol: "native", + usedFor: "default chat", + }, + { + id: "nvidia/nemotron-3-super-120b-a12b:free", + vendor: "openrouter", + paid: false, + toolProtocol: "text", + }, + { + id: "moonshotai/kimi-k2", + vendor: "openrouter", + author: "Moonshot", + paid: true, + inputCostPer1M: 0.6, + outputCostPer1M: 2.5, + }, + { + id: "anthropic/claude-fable-5", + vendor: "openrouter", + author: "Anthropic", + paid: true, + inputCostPer1M: 15, + outputCostPer1M: 75, + supportsTemperature: false, + }, + { id: "whisper-large-v3", vendor: "groq", paid: false, kind: "transcribe" }, ]; -test('require() throws loudly for an unregistered id — the enumeration rule', () => { +test("require() throws loudly for an unregistered id — the enumeration rule", () => { const reg = defineRegistry(ENTRIES); - assert.equal(reg.require('openai/gpt-oss-20b').vendor, 'groq'); - assert.throws(() => reg.require('llama-3.3-70b-versatile'), /not registered/); + assert.equal(reg.require("openai/gpt-oss-20b").vendor, "groq"); + assert.throws(() => reg.require("llama-3.3-70b-versatile"), /not registered/); }); -test('a free entry carrying a cost refuses to load — the flag or the price is lying', () => { +test("a free entry carrying a cost refuses to load — the flag or the price is lying", () => { assert.throws( - () => defineRegistry([{ id: 'x', vendor: 'v', paid: false, inputCostPer1M: 3 }]), + () => defineRegistry([{ id: "x", vendor: "v", paid: false, inputCostPer1M: 3 }]), /declared free but carries a cost/, ); }); -test('a paid entry with a :free id refuses to load — the flag or the id is lying', () => { +test("a paid entry with a :free id refuses to load — the flag or the id is lying", () => { assert.throws( - () => defineRegistry([{ id: 'model:free', vendor: 'v', paid: true }]), + () => defineRegistry([{ id: "model:free", vendor: "v", paid: true }]), /declared paid but the id says :free/, ); }); -test('duplicate (vendor, id) refuses to load — one callable id, one row', () => { +test("duplicate (vendor, id) refuses to load — one callable id, one row", () => { assert.throws( - () => defineRegistry([ - { id: 'a', vendor: 'v', paid: false }, - { id: 'a', vendor: 'v', paid: false }, - ]), + () => + defineRegistry([ + { id: "a", vendor: "v", paid: false }, + { id: "a", vendor: "v", paid: false }, + ]), /duplicate entry/, ); }); -test('freeOnly drops paid AND unregistered ids, and says which and why', () => { +test("freeOnly drops paid AND unregistered ids, and says which and why", () => { const reg = defineRegistry(ENTRIES); const { allowed, dropped } = freeOnly(reg, [ - 'openai/gpt-oss-20b', - 'anthropic/claude-fable-5', - 'model-nobody-registered', + "openai/gpt-oss-20b", + "anthropic/claude-fable-5", + "model-nobody-registered", ]); - assert.deepEqual(allowed, ['openai/gpt-oss-20b']); + assert.deepEqual(allowed, ["openai/gpt-oss-20b"]); assert.deepEqual(dropped, [ - { id: 'anthropic/claude-fable-5', why: 'paid' }, - { id: 'model-nobody-registered', why: 'unregistered' }, + { id: "anthropic/claude-fable-5", why: "paid" }, + { id: "model-nobody-registered", why: "unregistered" }, ]); }); -test('toolCapable accepts native AND text protocols, refuses none/unprobed', () => { +test("toolCapable accepts native AND text protocols, refuses none/unprobed", () => { const reg = defineRegistry([ ...ENTRIES, - { id: 'probed-toolless', vendor: 'v', paid: false, toolProtocol: 'none' }, + { id: "probed-toolless", vendor: "v", paid: false, toolProtocol: "none" }, ]); const { usable, refused } = toolCapable(reg, [ - 'openai/gpt-oss-20b', // native - 'nvidia/nemotron-3-super-120b-a12b:free', // text — 5 of 9 probed free models only speak this - 'probed-toolless', // probed, cannot - 'moonshotai/kimi-k2', // never probed + "openai/gpt-oss-20b", // native + "nvidia/nemotron-3-super-120b-a12b:free", // text — 5 of 9 probed free models only speak this + "probed-toolless", // probed, cannot + "moonshotai/kimi-k2", // never probed ]); - assert.deepEqual(usable, ['openai/gpt-oss-20b', 'nvidia/nemotron-3-super-120b-a12b:free']); + assert.deepEqual(usable, ["openai/gpt-oss-20b", "nvidia/nemotron-3-super-120b-a12b:free"]); assert.deepEqual(refused, [ - { id: 'probed-toolless', protocol: 'none' }, - { id: 'moonshotai/kimi-k2', protocol: 'unprobed' }, + { id: "probed-toolless", protocol: "none" }, + { id: "moonshotai/kimi-k2", protocol: "unprobed" }, ]); }); -test('idsForVendor is the enumeration a catalog check walks', () => { +test("idsForVendor is the enumeration a catalog check walks", () => { const reg = defineRegistry(ENTRIES); - assert.deepEqual(reg.idsForVendor('groq'), ['openai/gpt-oss-20b', 'whisper-large-v3']); - assert.deepEqual(reg.vendors().sort(), ['groq', 'openrouter']); + assert.deepEqual(reg.idsForVendor("groq"), ["openai/gpt-oss-20b", "whisper-large-v3"]); + assert.deepEqual(reg.vendors().sort(), ["groq", "openrouter"]); }); From c2913c79a2d53d2c41977dc45b7207483284cb2e Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+catomean@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:19:06 +0200 Subject: [PATCH 3/4] chore: teach git blame to skip the reformat Co-Authored-By: Claude Opus 5 --- .git-blame-ignore-revs | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .git-blame-ignore-revs diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..f2483d0 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# Bulk reformats. `git config blame.ignoreRevsFile .git-blame-ignore-revs` +7d28c83b9e0527e874a5376eae0d95eb7c0eaedf # prettier, 27 files From 807954538b5599e9e99a6d078ddf19459e01e332 Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+catomean@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:39:20 +0200 Subject: [PATCH 4/4] fix(ci): ignore dist-cjs, which only exists on the runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit format:check passed locally and failed in CI on six files under dist-cjs/. The ignore list covered 'dist' but not its siblings, and CI builds before verify — so those files exist on the runner and never locally. A clean local check is no evidence when a build precedes the gate. Widened to dist-* so dist-esm and friends cannot repeat this. --- .prettierignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.prettierignore b/.prettierignore index b698663..a7e506f 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,6 +2,7 @@ node_modules .next dist +dist-* build out coverage