diff --git a/dist-cjs/grounding/contract.js b/dist-cjs/grounding/contract.js index 95dffcc..357f724 100644 --- a/dist-cjs/grounding/contract.js +++ b/dist-cjs/grounding/contract.js @@ -40,32 +40,29 @@ 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 @@ -82,16 +79,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]. @@ -103,7 +100,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 @@ -112,22 +109,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. @@ -137,13 +134,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 0cedb2b..a292539 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,16 +115,15 @@ 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 @@ -133,11 +132,12 @@ 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 9939a49..57a9c9d 100644 --- a/dist-cjs/grounding/index.js +++ b/dist-cjs/grounding/index.js @@ -1,22 +1,6 @@ "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. * @@ -39,101 +23,21 @@ exports.buildRepairPrompt = * 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 3d248b5..3effbb0 100644 --- a/dist-cjs/grounding/verify.js +++ b/dist-cjs/grounding/verify.js @@ -35,8 +35,7 @@ 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", @@ -119,15 +118,14 @@ const COMMON = new Set( "orangecat", "not", "recorded", - ].map((w) => w.toLowerCase()), -); +].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 @@ -135,12 +133,14 @@ 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 @@ -150,21 +150,21 @@ function buildEvidence(facts, userMessage, extra) { * 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", + "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 @@ -184,45 +184,46 @@ const NAME_CONNECTORS = new Set([ * 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 @@ -237,24 +238,26 @@ 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. @@ -268,85 +271,94 @@ 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.`, - }); + 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 }; + // 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 @@ -354,11 +366,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 5bbefff..729ac4d 100644 --- a/dist-cjs/package.json +++ b/dist-cjs/package.json @@ -1,3 +1 @@ -{ - "type": "commonjs" -} +{"type":"commonjs"} diff --git a/dist-cjs/registry.js b/dist-cjs/registry.js index 023d63c..61acfbd 100644 --- a/dist-cjs/registry.js +++ b/dist-cjs/registry.js @@ -39,55 +39,53 @@ 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`, - ); + 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); } - 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), - }; + 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 @@ -99,15 +97,18 @@ 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. @@ -116,13 +117,15 @@ 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/package-lock.json b/package-lock.json index 902d379..50f59bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "eslint": "^10.9.1", "globals": "^17.11.0", "prettier": "3.9.6", - "typescript": "^5.8.2", + "typescript": "^6.0.3", "typescript-eslint": "^8.68.0" }, "engines": { @@ -1231,9 +1231,9 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index 6c0c688..8026b4f 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ "eslint": "^10.9.1", "globals": "^17.11.0", "prettier": "3.9.6", - "typescript": "^5.8.2", + "typescript": "^6.0.3", "typescript-eslint": "^8.68.0" }, "dependencies": { diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json index 52c240b..3c15c56 100644 --- a/tsconfig.cjs.json +++ b/tsconfig.cjs.json @@ -3,6 +3,7 @@ "compilerOptions": { "module": "CommonJS", "moduleResolution": "node10", + "ignoreDeprecations": "6.0", "verbatimModuleSyntax": false, "declaration": false, "outDir": "dist-cjs" diff --git a/tsconfig.json b/tsconfig.json index 0525cb1..3b93c6d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,7 @@ "compilerOptions": { "target": "ES2022", "lib": ["ES2022"], + "types": ["node"], "module": "ESNext", "moduleResolution": "bundler", "declaration": true,