From ed3d5e25e3bcd518c0699d27fa6e77a854d21d03 Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+maonakamoto@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:27:24 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20a=20require=20condition=20for=20gro?= =?UTF-8?q?unding=20and=20registry=20=E2=80=94=20jest=20consumers=20need?= =?UTF-8?q?=20no=20workaround?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OrangeCat's jest (ts-jest, CJS mode) cannot transform an ESM-only .js dependency — its config documents this in its own words, and it is the failure class that broke AOZ's Jest inside ai-forms in v0.3. The v0.4 lesson applies unchanged: fix it in the package, not with a per-adopter mapper. The two subpaths a test suite actually loads (grounding, registry) now ship a CommonJS twin in dist-cjs/ behind an exports require condition. A CJS loader resolves it automatically; every ESM consumer keeps resolving dist/ exactly as before. Scoped to those two subpaths on purpose: forms re-exports ai-forms, which is ESM-only, so a CJS twin there would promise a load it cannot deliver. cjs-condition.test.js pins both loaders serving the same surface AND the same verdict, and was mutation-proven: deleting the dist-cjs type marker fails it, rebuilding greens it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WqKqMnHQHSmkGFfc5t7Rxn --- dist-cjs/grounding/contract.js | 146 +++++++++++++++++ dist-cjs/grounding/facts.js | 143 ++++++++++++++++ dist-cjs/grounding/index.js | 43 +++++ dist-cjs/grounding/verify.js | 287 +++++++++++++++++++++++++++++++++ dist-cjs/package.json | 1 + dist-cjs/registry.js | 131 +++++++++++++++ package.json | 7 +- test/cjs-condition.test.js | 45 ++++++ tsconfig.cjs.json | 11 ++ 9 files changed, 812 insertions(+), 2 deletions(-) create mode 100644 dist-cjs/grounding/contract.js create mode 100644 dist-cjs/grounding/facts.js create mode 100644 dist-cjs/grounding/index.js create mode 100644 dist-cjs/grounding/verify.js create mode 100644 dist-cjs/package.json create mode 100644 dist-cjs/registry.js create mode 100644 test/cjs-condition.test.js create mode 100644 tsconfig.cjs.json diff --git a/dist-cjs/grounding/contract.js b/dist-cjs/grounding/contract.js new file mode 100644 index 0000000..357f724 --- /dev/null +++ b/dist-cjs/grounding/contract.js @@ -0,0 +1,146 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.NO_BASIS = void 0; +exports.buildContract = buildContract; +exports.buildAssistantRules = buildAssistantRules; +exports.directiveId = directiveId; +exports.renderDirectives = renderDirectives; +exports.buildGroundedContext = buildGroundedContext; +/** + * The grounding contract — the rules block that ships with every turn's facts. + * MIRRORED MODULE (see core/README.md). + * + * Why this is generated rather than a hand-written constant: a standing prose + * rule ("only use provided context") is a weak signal that models trade away + * under format pressure. The failure that motivated this harness was exactly + * that — a prompt demanding "1 focus, 3 tasks, 1 person, under 150 words, no + * hedging" got four confidently-formatted answers, three of them invented, + * against a context block that already said "if a question falls outside this + * context, say so rather than guessing". + * + * The lesson: the model did not disobey a rule it forgot. It obeyed the + * STRONGER of two conflicting instructions — fill five slots — because nothing + * made the empty slot expressible. So this block does three things a static + * prompt cannot: + * + * 1. Names the exact citation handles that exist this turn, so "cite a fact" + * is a closed-set choice rather than free text. + * 2. Names the exact fields that are unrecorded THIS TURN, so the prohibition + * is concrete ("you have no affiliation for any person here") instead of + * abstract. + * 3. Supplies the escape hatch verbatim, so refusing a slot is a cheaper + * token path than inventing one. + */ +const facts_js_1 = require("./facts.js"); +/** The exact string the model must emit when a slot cannot be filled. */ +exports.NO_BASIS = "Not in your data."; +/** + * Build the contract for a specific fact set. Empty fact sets get the strictest + * form — with nothing retrieved, EVERY answer must be a refusal, and saying so + * 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"); +} +/** + * The subset of the contract that needs no fact ids — for an assistant whose + * context is still prose (Cat) rather than typed records. + * + * Weaker than `buildContract` by construction: without ids there is nothing to + * cite, so rule 1 cannot exist and the verifier runs in entity-attribution + * mode. What survives is the part that stopped the worst failure — never state + * an attribute for someone in the user's data that their record does not carry, + * and never imply research you did not perform. + * + * This is a stepping stone, not the destination. It exists so a live product + * gets the protection now, without a same-day rewrite of its whole context + * 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"); +} +/** + * Citation handle for a computed answer, parallel to a Fact's [F1]. + * + * Directives used to be uncitable, and the contract demands a citation for + * every claim — so a model reporting a computed result had nothing legal to + * point at and wrote "[no record id]" into the user's answer. That is the + * harness leaking its own plumbing onto the screen. Give computed answers real + * ids and the sentence cites [D1] like anything else. + */ +function directiveId(index) { + return `D${index + 1}`; +} +/** + * Render computed answers. These are stated as settled, because they are: the + * model must not re-derive, second-guess, or "improve" them, and an empty + * result must be reported as an empty result rather than backfilled from the + * 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"); +} +/** + * Assemble the full grounded context: contract, computed answers, then records. + * + * Order is deliberate and load-bearing. The contract comes FIRST so it frames + * everything read afterwards, and the records come LAST so they sit closest to + * 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"); +} diff --git a/dist-cjs/grounding/facts.js b/dist-cjs/grounding/facts.js new file mode 100644 index 0000000..a292539 --- /dev/null +++ b/dist-cjs/grounding/facts.js @@ -0,0 +1,143 @@ +"use strict"; +/** + * Facts — the unit of grounded context. MIRRORED MODULE (see core/README.md). + * + * The problem this solves, concretely. Loki was asked who to contact and + * answered "Ilya Druzhnikov (UZH)". The stored record is: + * + * { displayName: "Ilya Druzhnikov", channels: { whatsapp: "+1650…" } } + * + * There is no org field, and the string "UZH" appears nowhere in the operator's + * data — it is the substring inside dr-UZH-nikov. A keyword match produced an + * affiliation out of a surname, and prose context gave the model no way to tell + * that "affiliation" was a field it had never been shown. + * + * The fix is representational, not a prompt instruction. A Fact is a RECORD with + * a DECLARED field set, and every declared field is rendered — including the ones + * with no value, which render as an explicit ``. A model that reads + * + * affiliation: + * + * is being told a specific negative, which is far harder to overwrite than the + * silence of a field that simply wasn't mentioned. Absence becomes evidence. + * + * Every fact also carries a short stable id ([F3]) so the answer can cite spans + * and `verify.ts` can check citations mechanically rather than by vibes. + * + * Pure: no DB, no network, no framework. Apps map their rows into Facts via + * their own adapters (FleetCrown: src/lib/agent/sources; OrangeCat: services/cat/sources). + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.FACT_KINDS = exports.NOT_RECORDED = void 0; +exports.declaredFields = declaredFields; +exports.makeFact = makeFact; +exports.assignFactIds = assignFactIds; +exports.factIds = factIds; +exports.renderFacts = renderFacts; +exports.unrecordedFields = unrecordedFields; +/** A field that is declared for a record kind but has no stored value. */ +exports.NOT_RECORDED = ""; +/** + * The declared field set per record kind — the SSOT for "what could be known + * about this kind of thing". Adding a field here makes it render as + * `` everywhere it is missing, which is the entire anti-invention + * mechanism: the model can only ever see fields we chose to declare. + * + * Deliberately includes fields we do NOT store (a person's `affiliation`, + * `role`, `employer`). That is not an oversight — those are exactly the + * attributes models invent, so naming them and marking them unrecorded is the + * 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"], +}; +/** Field list for a kind; unknown kinds fall back to whatever the fact carries. */ +function declaredFields(kind, fallback = []) { + return exports.FACT_KINDS[kind] ?? fallback; +} +/** + * Build a Fact with every declared field present. Values not supplied become + * null (→ ``). Undeclared keys are DROPPED rather than passed + * through: if a field is worth showing the model it is worth declaring in + * 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 } : {}), + }; +} +/** Stamp sequential citation ids. Call once, after assembling the final set. */ +function assignFactIds(facts) { + 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)); +} +/** + * Render facts for the model. One block per record, every declared field on its + * own line, unrecorded fields stated explicitly. + * + * [F3] person — Elena Weber SINGA Switzerland (people table) + * name: Elena Weber SINGA Switzerland + * affiliation: + * role: + * channels: whatsapp +41774730093 + * + * The line-per-field shape matters for small models: a flat prose blob invites + * summarising (and summarising is where invention creeps in), whereas a field + * list invites lookup. Observed with 8B models — the same prompt over a blob + * 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"); + }) + .join("\n\n"); +} +/** + * Which declared fields are unrecorded across the set, as + * `kind.field` keys. The contract block names these explicitly so the rule + * "do not state an affiliation" is anchored to a concrete gap in THIS turn's + * 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}`); + } + } + return [...gaps].sort(); +} diff --git a/dist-cjs/grounding/index.js b/dist-cjs/grounding/index.js new file mode 100644 index 0000000..57a9c9d --- /dev/null +++ b/dist-cjs/grounding/index.js @@ -0,0 +1,43 @@ +"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; +/** + * The grounding harness — imported, no longer mirrored. + * + * These three modules were born in FleetCrown (`src/lib/agent/core/`) and + * lived as a byte-identical mirror in OrangeCat, guarded by a SHA-256 drift + * check, because both assistants had the same failure: a model asked to fill + * a rigid answer format against thin context invents the missing parts, and + * the invention is indistinguishable from truth because both arrive as + * confident prose. + * + * The mirror's own README called the duplication "deliberate and temporary" + * and named this extraction as the exit. This is that exit: both apps now + * import `ai-kit/grounding`, and the drift check retires — two + * silently-diverging definitions of "what counts as grounded" are no longer + * possible, because there is only one. + * + * The constraint that made the code mirrorable is the constraint that makes + * it packageable, and it still holds: pure TypeScript, no DB, no network, no + * framework, no imports outside this directory. Anything that knows where + * 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; } }); +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; } }); +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; } }); diff --git a/dist-cjs/grounding/verify.js b/dist-cjs/grounding/verify.js new file mode 100644 index 0000000..c5c2dd9 --- /dev/null +++ b/dist-cjs/grounding/verify.js @@ -0,0 +1,287 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.verifyAnswer = verifyAnswer; +exports.buildRepairPrompt = buildRepairPrompt; +/** + * Groundedness verifier — MIRRORED MODULE (see core/README.md). + * + * Runs on the generated answer and reports claims the fact set does not support. + * Deliberately deterministic: no second model call, no embedding round-trip, no + * added cost or latency. That is a requirement, not a shortcut — this must run + * on every turn including the free-tier ones, and a verifier that costs a + * frontier call is one that gets disabled exactly where it is needed most. + * + * The insight that makes a cheap check work: fabrication is overwhelmingly + * NOMINAL. Models invent organisations, titles, people, file paths, phone + * numbers and dates — tokens that are mechanically recognisable and that must, + * if genuine, have appeared in the retrieved records or in what the user said. + * Grammar and hedging are hard to check; proper nouns and digits are easy. + * + * Scored against the real failure this was built from, every fabricated claim + * is caught by the proper-noun or numeric rule: + * + * "Ilya Druzhnikov (UZH)" → UZH: novel acronym + * "Accelerator & Bridge Program Manager" → novel proper-noun run + * "University of Liechtenstein", "START Summit" → novel proper-noun runs + * "/opt/fleetcrown/runner/.env" → novel path + * + * while the true parts ("Elena Weber SINGA Switzerland", "+41774730093") appear + * verbatim in the records and pass clean. + */ +const facts_js_1 = require("./facts.js"); +/** + * Words that are capitalised for reasons other than being a proper noun, or + * that are part of this system's own vocabulary. Kept deliberately small — + * 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([ + // 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", + // 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", + // This system's own nouns + "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(); +} +/** + * Everything the model was legitimately given this turn: record values, record + * subjects, and the user's own message (a name the user typed is fair to + * 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(" ")); +} +/** + * Lowercase words that legitimately sit INSIDE a proper name and must not break + * it up: "University of Zurich", "Bank für Handel", "Institute for the Study of + * Complexity". Without these, the run splits at the connector and the check + * 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"]); +/** + * Named-entity candidates: ALL-CAPS acronyms, capitalised words, and the + * multi-word runs they form (connectors allowed strictly between two + * capitalised tokens, never at an edge). + * + * Both the run AND its individual tokens are emitted, deliberately. The run + * catches composite inventions ("University of Zurich") that no single token + * reveals; the individual tokens catch an invented acronym sitting next to a + * real name ("Druzhnikov UZH"), where reporting only the run would name the + * real person in the violation and produce a repair prompt that deletes the + * true claim along with the false one. + * + * Sentence-initial single words are skipped — otherwise "Rotate the key" flags + * "Rotate". That costs a little recall at sentence starts and removes the + * dominant source of false positives; a fabricated name at a sentence start is + * 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; +} +/** 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()); +} +/** + * File and path references — a favourite fabrication, and an unusually + * damaging one because naming a file implies the model READ it. + * + * Covers absolute paths (`/opt/fleetcrown/runner/.env`), relative paths + * (`data/contact-resolver.json`), and bare filenames with a data/config + * extension. The relative form matters: when challenged on the UZH claim, the + * model "corrected" itself by asserting what `data/contact-resolver.json` + * contained — a file it was never given. That reads as citing a source, which + * is precisely why an unverified correction is more corrosive than the + * 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]; +} +/** 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); + }); +} +/** + * Verify an answer against the facts it was supposed to come from. + * + * `extraEvidence` lets a caller admit sources outside the fact set — computed + * directive output, a tool result the model legitimately saw this turn. + * Anything not in facts, the user's message, or extraEvidence is unsupported + * by construction. + * + * `subjects` (entity-attribution mode) names the user's own records, so the + * 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.`, + }); + } + // 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 + * fixes most turns, because the model is not being asked to know more — only to + * 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"); +} diff --git a/dist-cjs/package.json b/dist-cjs/package.json new file mode 100644 index 0000000..729ac4d --- /dev/null +++ b/dist-cjs/package.json @@ -0,0 +1 @@ +{"type":"commonjs"} diff --git a/dist-cjs/registry.js b/dist-cjs/registry.js new file mode 100644 index 0000000..61acfbd --- /dev/null +++ b/dist-cjs/registry.js @@ -0,0 +1,131 @@ +"use strict"; +/** + * The model REGISTRY — one SSOT for every model id an app may call. + * + * This module exists because the fleet paid for its absence twice, in two + * different currencies: + * + * OUTAGE — on 2026-08-18 Groq removed `llama-3.3-70b-versatile` and one app + * kept asking for it for eight days. A rot checker already existed, but it + * probed only the chains it knew about; the id that died was pinned + * elsewhere. A checker that does not enumerate its subjects cannot report + * the one it never knew about. The registry IS the enumeration: a model id + * is callable only if it appears here, and the catalog check walks exactly + * this list. + * + * MONEY — three apps silently billed real money on fallback, because the + * only thing separating the free variant from the paid one was a `:free` + * suffix on the id string. A billing boundary that lives in a naming + * convention is one typo away from a paid call. Here it is a FIELD, and the + * validator refuses an entry whose flag contradicts its own cost or suffix — + * so the contradiction is a build failure, not an invoice. + * + * What deliberately does NOT live here: which model to PREFER (that is the + * chain's job), UI presentation (labels, badges — app concern), and anything + * that knows where data lives. Same boundary as the rest of this package: + * meaning in core, adapters in the app. + * + * ── Vendor vs author ───────────────────────────────────────────────────────── + * A registry row is a CALLABLE id at a VENDOR — the place a request goes — + * because that is the unit that rots, meters, and bills. The AUTHOR (who + * trained it) is metadata. The two were conflated in one app's registry + * ("provider: Anthropic" on a row served by OpenRouter), which made "who do we + * pay" unanswerable by query. Here they are separate fields. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.defineRegistry = defineRegistry; +exports.freeOnly = freeOnly; +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; +} +/** + * 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 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 + * call may serve. Registered-and-free passes; paid is dropped; an UNKNOWN id + * is dropped too — an id nobody registered has an unknown price, and "unknown" + * spends someone's money only when a person decides it does. + * + * Returns the dropped ids alongside, because a silently narrowed chain reads + * 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 }; +} +/** + * A tool-driving chain may only contain models that can drive a tool loop. + * "unprobed" entries are reported, not silently trusted — the probe table is + * one `npm run probe:models` away, and a chain built on guesses loses turns + * 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 }; +} diff --git a/package.json b/package.json index 4be669a..2d85315 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ai-kit", - "version": "0.6.1", + "version": "0.6.2", "description": "One install for the AI layer of an app: which model to call, what to do when the vendor retires it, how to walk the fallback chain and know when none of it worked, how to read the three kinds of 429, a fair daily budget across users, headless AI form filling — and now the model registry (one SSOT for every callable id, with the paid/free boundary as a field) and the grounding harness (facts, contract, deterministic fabrication check).", "license": "MIT", "author": "Mao Nakamoto", @@ -37,6 +37,7 @@ }, "files": [ "dist", + "dist-cjs", "src" ], "exports": { @@ -58,15 +59,17 @@ }, "./registry": { "types": "./dist/registry.d.ts", + "require": "./dist-cjs/registry.js", "default": "./dist/registry.js" }, "./grounding": { "types": "./dist/grounding/index.d.ts", + "require": "./dist-cjs/grounding/index.js", "default": "./dist/grounding/index.js" } }, "scripts": { - "build": "tsc -p tsconfig.json", + "build": "tsc -p tsconfig.json && tsc -p tsconfig.cjs.json && node -e \"require('fs').writeFileSync('dist-cjs/package.json', JSON.stringify({type:'commonjs'})+'\\n')\"", "lint": "eslint .", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "node --test test/*.test.js", diff --git a/test/cjs-condition.test.js b/test/cjs-condition.test.js new file mode 100644 index 0000000..390d8b1 --- /dev/null +++ b/test/cjs-condition.test.js @@ -0,0 +1,45 @@ +/** + * The grounding and registry subpaths must load under BOTH loaders. + * + * Why a require condition exists at all: OrangeCat's jest runs ts-jest in CJS + * mode, and its config documents the trap in its own words — the transform + * compiles only .ts/.tsx, so "an ESM-only .js dependency cannot be whitelisted + * into working via transformIgnorePatterns alone". That is the failure class + * that broke AOZ's Jest inside ai-forms in v0.3, and the v0.4 lesson applies + * unchanged: fix it in the package, not with per-adopter workarounds. A CJS + * consumer resolves `require` and gets dist-cjs; everything else resolves + * `default` and gets ESM dist, exactly as before. + * + * This test is the mutation-proof: delete the dist-cjs build or the marker + * 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 * 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('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?' }); + assert.equal(viaEsm.ok, viaCjs.ok); + assert.equal(viaEsm.ok, false); +}); diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json new file mode 100644 index 0000000..52c240b --- /dev/null +++ b/tsconfig.cjs.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "node10", + "verbatimModuleSyntax": false, + "declaration": false, + "outDir": "dist-cjs" + }, + "include": ["src/grounding", "src/registry.ts"] +} From dc685f014687c11155e71ae31a3c7ada1b597b30 Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+maonakamoto@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:32:51 +0200 Subject: [PATCH 2/2] =?UTF-8?q?chore:=20dist-cjs=20is=20generated=20output?= =?UTF-8?q?=20too=20=E2=80=94=20eslint=20must=20not=20read=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WqKqMnHQHSmkGFfc5t7Rxn --- eslint.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 9d7ae36..f8d2426 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -9,7 +9,7 @@ import tseslint from 'typescript-eslint' export default tseslint.config( { // dist/ is generated by `tsc`. - ignores: ['dist/**', 'node_modules/**'], + ignores: ['dist/**', 'dist-cjs/**', 'node_modules/**'], }, js.configs.recommended, ...tseslint.configs.recommended,