diff --git a/__tests__/unit/cat/agent-core-grounding.test.ts b/__tests__/unit/cat/agent-core-grounding.test.ts index 929ffdcde..7a5ba520f 100644 --- a/__tests__/unit/cat/agent-core-grounding.test.ts +++ b/__tests__/unit/cat/agent-core-grounding.test.ts @@ -1,51 +1,20 @@ /** - * Cat groundedness + agent-core mirror integrity. + * Cat groundedness rules, served from the package. * - * Two jobs: + * This file used to have a second job: asserting the src/services/agent-core + * mirror was byte-identical to FleetCrown's canonical copy. That mirror is + * deleted — both apps import `ai-kit/grounding` now, so there are no longer + * two copies of "what counts as grounded" to diverge. (The mirror check also + * self-skipped in CI, which clones one repo at a time; it only ever ran on a + * laptop with both checkouts side by side. A gate that runs where nobody is + * looking and skips where everybody is was part of why the extraction won.) * - * 1. Assert the mirror in src/services/agent-core/ is byte-identical to - * FleetCrown's canonical copy. The harness is duplicated rather than - * packaged (see agent-core/README.md); duplication is only safe if - * divergence cannot be committed by accident. Two copies of "what counts as - * grounded", quietly disagreeing, would make the two assistants wrong in - * different ways — worse than the single failure this all exists to fix. - * Skipped when FleetCrown is not checked out beside this repo (CI clones one - * repo at a time), which is why the same gate exists on the FleetCrown side. - * - * 2. Assert Cat's grounding mode does the two things it must do at once: catch - * an invented attribute about one of the USER'S OWN records, while leaving - * general economic knowledge alone. A check that flags "Lightning" gets - * switched off within a week, and then protects nothing. + * What remains is the half that was always about Cat: grounding mode must + * catch an invented attribute about one of the USER'S OWN records while + * leaving general economic knowledge alone. A check that flags "Lightning" + * gets switched off within a week, and then protects nothing. */ -import { createHash } from 'node:crypto'; -import { readdirSync, readFileSync, existsSync } from 'node:fs'; -import { join } from 'node:path'; -import { verifyAnswer } from '@/services/agent-core/verify'; -import { buildAssistantRules, NO_BASIS } from '@/services/agent-core/contract'; - -const MIRROR = join(process.cwd(), 'src', 'services', 'agent-core'); -const CANONICAL = - process.env.FLEETCROWN_DIR ?? - join(process.cwd(), '..', 'fleetcrown', 'src', 'lib', 'agent', 'core'); - -const sha = (s: string) => createHash('sha256').update(s).digest('hex'); - -describe('agent-core mirror', () => { - const canonicalPresent = existsSync(CANONICAL); - const testIfPresent = canonicalPresent ? it : it.skip; - - testIfPresent('is byte-identical to FleetCrown’s canonical copy', () => { - const mirrorFiles = readdirSync(MIRROR).sort(); - const canonicalFiles = readdirSync(CANONICAL).sort(); - expect(mirrorFiles).toEqual(canonicalFiles); - for (const f of canonicalFiles) { - expect({ file: f, hash: sha(readFileSync(join(MIRROR, f), 'utf8')) }).toEqual({ - file: f, - hash: sha(readFileSync(join(CANONICAL, f), 'utf8')), - }); - } - }); -}); +import { verifyAnswer, buildAssistantRules, NO_BASIS } from 'ai-kit/grounding'; describe('Cat grounding rules', () => { it('forbids inferring an affiliation and claiming research', () => { diff --git a/package-lock.json b/package-lock.json index 45882f523..c2aafca18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "@types/mdx": "^2.0.13", "@upstash/ratelimit": "^2.0.7", "@upstash/redis": "^1.38.2", + "ai-kit": "github:bitbaum/ai-kit#v0.6.2", "autoprefixer": "^10.5.4", "bech32": "^2.0.0", "bip32": "^5.0.0", @@ -132,7 +133,7 @@ "node": ">=20" }, "optionalDependencies": { - "sharp": "^0.35.3" + "sharp": "^0.35.4" } }, "node_modules/@adobe/css-tools": { @@ -1548,9 +1549,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", - "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", "cpu": [ "arm64" ], @@ -1566,13 +1567,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.2" + "@img/sharp-libvips-darwin-arm64": "1.3.3" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", - "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", "cpu": [ "x64" ], @@ -1588,20 +1589,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.2" + "@img/sharp-libvips-darwin-x64": "1.3.3" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", - "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", "license": "Apache-2.0", "optional": true, "os": [ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.3" + "@img/sharp-wasm32": "0.35.4" }, "engines": { "node": ">=20.9.0" @@ -1611,9 +1612,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", - "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", "cpu": [ "arm64" ], @@ -1627,9 +1628,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", - "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", "cpu": [ "x64" ], @@ -1643,12 +1644,15 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", - "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1659,12 +1663,15 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", - "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1675,12 +1682,15 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", - "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1691,12 +1701,15 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", - "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1707,12 +1720,15 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", - "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1723,12 +1739,15 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", - "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1739,12 +1758,15 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", - "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1755,12 +1777,15 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", - "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1771,12 +1796,15 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", - "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1789,16 +1817,19 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.2" + "@img/sharp-libvips-linux-arm": "1.3.3" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", - "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1811,16 +1842,19 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.2" + "@img/sharp-libvips-linux-arm64": "1.3.3" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", - "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1833,16 +1867,19 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.2" + "@img/sharp-libvips-linux-ppc64": "1.3.3" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", - "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1855,16 +1892,19 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.2" + "@img/sharp-libvips-linux-riscv64": "1.3.3" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", - "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1877,16 +1917,19 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.2" + "@img/sharp-libvips-linux-s390x": "1.3.3" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", - "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1899,16 +1942,19 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.2" + "@img/sharp-libvips-linux-x64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", - "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1921,16 +1967,19 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", - "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1943,17 +1992,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", - "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.11.1" + "@emnapi/runtime": "^1.11.3" }, "engines": { "node": ">=20.9.0" @@ -1963,16 +2012,16 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", - "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", "cpu": [ "wasm32" ], "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.3" + "@img/sharp-wasm32": "0.35.4" }, "engines": { "node": ">=20.9.0" @@ -1982,9 +2031,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", - "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", "cpu": [ "arm64" ], @@ -2001,9 +2050,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", - "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", "cpu": [ "ia32" ], @@ -2020,9 +2069,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", - "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", "cpu": [ "x64" ], @@ -8041,6 +8090,42 @@ "node": ">= 14" } }, + "node_modules/ai-forms": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ai-forms/-/ai-forms-0.1.2.tgz", + "integrity": "sha512-agadca4pN0iGlZTlNy1Vo2SmnQB+0dNkrQSDE3wCJYeaVxKDs5KaxiwGj5+GL6mVYcI1xZ8pwnT9kL9WcxnBHA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": ">=18" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, + "node_modules/ai-kit": { + "version": "0.6.2", + "resolved": "git+ssh://git@github.com/bitbaum/ai-kit.git#ace11f14d817079ae2bf4cdc010b6daa9faacbec", + "license": "MIT", + "dependencies": { + "ai-forms": "^0.1.2" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "react": ">=18" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, "node_modules/ajv": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", @@ -20244,9 +20329,9 @@ } }, "node_modules/sharp": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", - "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -20261,31 +20346,31 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.3", - "@img/sharp-darwin-x64": "0.35.3", - "@img/sharp-freebsd-wasm32": "0.35.3", - "@img/sharp-libvips-darwin-arm64": "1.3.2", - "@img/sharp-libvips-darwin-x64": "1.3.2", - "@img/sharp-libvips-linux-arm": "1.3.2", - "@img/sharp-libvips-linux-arm64": "1.3.2", - "@img/sharp-libvips-linux-ppc64": "1.3.2", - "@img/sharp-libvips-linux-riscv64": "1.3.2", - "@img/sharp-libvips-linux-s390x": "1.3.2", - "@img/sharp-libvips-linux-x64": "1.3.2", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", - "@img/sharp-libvips-linuxmusl-x64": "1.3.2", - "@img/sharp-linux-arm": "0.35.3", - "@img/sharp-linux-arm64": "0.35.3", - "@img/sharp-linux-ppc64": "0.35.3", - "@img/sharp-linux-riscv64": "0.35.3", - "@img/sharp-linux-s390x": "0.35.3", - "@img/sharp-linux-x64": "0.35.3", - "@img/sharp-linuxmusl-arm64": "0.35.3", - "@img/sharp-linuxmusl-x64": "0.35.3", - "@img/sharp-webcontainers-wasm32": "0.35.3", - "@img/sharp-win32-arm64": "0.35.3", - "@img/sharp-win32-ia32": "0.35.3", - "@img/sharp-win32-x64": "0.35.3" + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" }, "peerDependenciesMeta": { "@types/node": { diff --git a/package.json b/package.json index 69b4f0c70..a02d4e207 100644 --- a/package.json +++ b/package.json @@ -144,6 +144,7 @@ "@types/mdx": "^2.0.13", "@upstash/ratelimit": "^2.0.7", "@upstash/redis": "^1.38.2", + "ai-kit": "github:bitbaum/ai-kit#v0.6.2", "autoprefixer": "^10.5.4", "bech32": "^2.0.0", "bip32": "^5.0.0", @@ -227,7 +228,7 @@ "ws": "^8.21.3" }, "optionalDependencies": { - "sharp": "^0.35.3" + "sharp": "^0.35.4" }, "lint-staged": { "*.{js,jsx,ts,tsx}": [ diff --git a/src/services/agent-core/README.md b/src/services/agent-core/README.md deleted file mode 100644 index 1689bd4dd..000000000 --- a/src/services/agent-core/README.md +++ /dev/null @@ -1,74 +0,0 @@ -# agent/core — the grounding harness - -Pure, dependency-free TypeScript shared by **Loki** (FleetCrown) and **Cat** -(OrangeCat). No DB, no network, no framework, no imports outside this directory. -That constraint is what makes it mirrorable, and it is enforced by the drift -check — do not relax it. - -## What it is for - -Both assistants had the same class of failure: a model asked to fill a rigid -answer format against thin context invents the missing parts, and the invention -is indistinguishable from the truth because both arrive as confident prose. - -The harness makes unsupported claims **hard to express** rather than merely -discouraged: - -| Module | Mechanism | -|---|---| -| `facts.ts` | Records with a **declared field set**. Fields with no stored value render as an explicit ``, so absence is a stated negative rather than silence. Each record gets a citation id. | -| `contract.ts` | A rules block **generated from this turn's facts** — enumerating the legal citation ids and the concrete gaps — plus `Directive`, for answers the app computed in SQL and the model may only phrase. | -| `verify.ts` | A deterministic post-generation check. Flags citations that resolve to nothing, and proper nouns / numbers / paths with no source in the records or the user's message. No extra model call. | - -## Why absence must be explicit - -Loki once reported a contact as *"Ilya Druzhnikov (UZH)"*. The stored record had -no organisation field, and the string `UZH` appears nowhere in the operator's -data — it is the substring inside dr**UZH**nikov, surfaced by a keyword match and -then narrated as an affiliation. - -A field the model was never shown is easy to invent. A field it was shown as -`affiliation: ` is a specific negative it has to actively -contradict. That is the whole design. - -## Why the verifier is deterministic - -It runs on **every** turn, including free-tier ones on small models — which is -exactly where fabrication is most likely. A verifier that costs a frontier call -is one that gets disabled where it matters most. - -It works because fabrication is overwhelmingly *nominal*: models invent -organisations, titles, file paths, phone numbers and dates. Those are -mechanically recognisable and, if genuine, must appear in the retrieved records. - -## Mirroring — read before editing - -This directory is **duplicated verbatim** in two repos: - -``` -fleetcrown/src/lib/agent/core/ ← canonical -orangecat/src/services/agent-core/ ← mirror -``` - -`scripts/test/agent-core-drift.ts` in **both** repos compares SHA-256 per file -and fails CI on any difference. So: - -1. Edit the FleetCrown copy. -2. Run `npm run sync:agent-core` (FleetCrown) to push the mirror. -3. Commit both repos. - -This duplication is deliberate and temporary. The two apps have incompatible -data layers (Drizzle/Postgres vs Supabase), so a shared package was not worth -blocking on — but two silently-diverging copies of "what counts as grounded" -would be worse than either. The drift check buys SSOT-in-practice now; the exit -is extraction to `@fleet/agent-core` (the `@fleet/ai-forms` pattern), after -which both repos import instead of mirroring. - -## What belongs here vs in the app - -**Here:** anything that defines what grounding *means*. -**In the app:** anything that knows where data lives — the adapters that map -rows to `Fact`s (`fleetcrown/src/lib/agent/sources.ts`, -`orangecat/src/services/cat/sources.ts`) and the SQL behind `Directive`s. - -If you find yourself importing a DB client here, the code belongs in an adapter. diff --git a/src/services/agent-core/contract.ts b/src/services/agent-core/contract.ts deleted file mode 100644 index b277293a1..000000000 --- a/src/services/agent-core/contract.ts +++ /dev/null @@ -1,176 +0,0 @@ -/** - * 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. - */ -import { NOT_RECORDED, unrecordedFields, type Fact } from "./facts"; - -/** The exact string the model must emit when a slot cannot be filled. */ -export const 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. - */ -export function buildContract(facts: Fact[], directives: Directive[] = []): string { - const ids = [ - ...facts.map((f) => `[${f.id}]`), - ...directives.map((_, i) => `[${directiveId(i)}]`), - ].join(" "); - const gaps = 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 "${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 \`${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 "${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. - */ -export function buildAssistantRules(opts: { subjectNoun: string }): string { - 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 "${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"); -} - -/** - * A deterministic answer computed by the app, not the model. - * - * Some questions are not judgment calls at all. "Which goals are stuck at 0% - * for 30+ days", "what is due in the next 3 days", "which habit is at risk" - * are SQL predicates with exact answers, and asking a language model to derive - * them from injected prose is strictly worse than computing them: it can only - * introduce error. The model's job is to PHRASE the result, not to derive it. - * - * `answer` is empty when the query ran and found nothing — which is itself a - * real, citable answer ("nothing is due"), and crucially different from the - * query never having run. - */ -export type Directive = { - /** What was asked, in the app's words: "goals stuck 30+ days". */ - question: string; - /** Computed result lines. Empty array = ran, found nothing. */ - answer: string[]; - /** How it was computed, shown to the model so it can be honest about method. */ - method: string; -}; - -/** - * 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. - */ -export function directiveId(index: number): string { - 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. - */ -export function renderDirectives(directives: Directive[]): string { - 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. - */ -export function buildGroundedContext(input: { - facts: Fact[]; - directives?: Directive[]; - renderedFacts: string; -}): string { - 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/src/services/agent-core/facts.ts b/src/services/agent-core/facts.ts deleted file mode 100644 index c47e883de..000000000 --- a/src/services/agent-core/facts.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * 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). - */ - -/** A field that is declared for a record kind but has no stored value. */ -export const NOT_RECORDED = ""; - -/** - * One grounded record. `fields` must contain an entry for EVERY key in the - * kind's declared field list — `null` where nothing is stored. Builders should - * go through `makeFact`, which enforces that against the registry. - */ -export type Fact = { - /** Short citation handle, assigned by `assignFactIds` (F1, F2, …). */ - id: string; - /** Record kind — must be a key of the FACT_KINDS registry. */ - kind: string; - /** Human label for the record (a name, a title). Never invented. */ - subject: string; - /** Declared field → stored value, or null for "nothing stored". */ - fields: Record; - /** Where this came from, shown to the model: "people table", "goals table". */ - source: string; - /** - * Relevance score when the fact came from similarity search. Absent for facts - * fetched deterministically (a SQL filter) — those are not ranked, they are - * simply true, and the distinction matters to the reader. - */ - similarity?: number; -}; - -/** - * 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. - */ -export const FACT_KINDS: Record = { - 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. */ -export function declaredFields(kind: string, fallback: string[] = []): readonly string[] { - return 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. - */ -export function makeFact(input: { - kind: string; - subject: string; - source: string; - values?: Record; - similarity?: number; -}): Fact { - const keys = declaredFields(input.kind, Object.keys(input.values ?? {})); - const fields: Record = {}; - 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. */ -export function assignFactIds(facts: Fact[]): Fact[] { - return facts.map((f, i) => ({ ...f, id: `F${i + 1}` })); -} - -/** Every citation handle in a fact set — the only legal citations in an answer. */ -export function factIds(facts: Fact[]): Set { - 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 ``. - */ -export function renderFacts(facts: Fact[]): string { - 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 ?? 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. - */ -export function unrecordedFields(facts: Fact[]): string[] { - 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/src/services/agent-core/verify.ts b/src/services/agent-core/verify.ts deleted file mode 100644 index 182c17516..000000000 --- a/src/services/agent-core/verify.ts +++ /dev/null @@ -1,339 +0,0 @@ -/** - * 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. - */ -import { NOT_RECORDED, type Fact } from "./facts"; - -export type Violation = { - kind: "unknown-citation" | "novel-proper-noun" | "novel-number" | "novel-path" | "uncited-claim"; - /** The offending text. */ - text: string; - /** Why it is a problem, phrased for a repair prompt the model will read. */ - detail: string; -}; - -export type VerifyResult = { - ok: boolean; - violations: Violation[]; -}; - -/** - * 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: string): string { - 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: Fact[], userMessage: string, extra: string[]): string { - const parts: string[] = [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: string): string[] { - const out: string[] = []; - // 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: 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(); - 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: string): string[] { - 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: string): string[] { - 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]; -} - -/** - * How strictly to treat unattested names — the one real difference between the - * two assistants that use this harness. - * - * `closed-world` (Loki): the assistant's entire job is reporting the operator's - * records, so ANY unattested proper noun is a fabrication. One operator, one - * data set, no legitimate outside knowledge in scope. - * - * `entity-attribution` (Cat): the assistant also answers general questions — - * how Lightning works, which payment rails exist in Switzerland — where naming - * Twint or Bitcoin is correct and required. Flagging those would make Cat - * useless. So the check narrows to what actually goes wrong: attributes - * attached to one of the USER'S OWN records. A sentence naming a known subject - * is checked; a sentence of general explanation is not. - * - * The narrower mode is genuinely weaker, and that is a real trade, not a - * loophole: Cat can still invent a fact about the wider world. It can no longer - * invent an employer for someone in your contacts, which is the failure that - * actually destroys trust in a personal assistant. - */ -export type VerifyMode = "closed-world" | "entity-attribution"; - -/** Does this sentence talk about one of the user's own records? */ -function mentionsSubject(sentence: string, subjects: string[]): boolean { - 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". - */ -export function verifyAnswer(input: { - answer: string; - facts: Fact[]; - userMessage: string; - extraEvidence?: string[]; - mode?: VerifyMode; - subjects?: string[]; - /** - * Extra legal citation handles beyond the fact ids — the [D1] series for - * computed answers. Without these a model that correctly cites a computed - * result gets flagged for citing something "that does not exist", which - * would train the repair pass to delete true statements. - */ - extraCitationIds?: string[]; -}): VerifyResult { - const { answer, facts, userMessage } = input; - const mode: VerifyMode = 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: Violation[] = []; - - /** - * 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 ${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. - */ -export function buildRepairPrompt(violations: Violation[], noBasisPhrase: string): string { - 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/src/services/cat/chat-orchestrator.ts b/src/services/cat/chat-orchestrator.ts index 2b81226e3..bec620638 100644 --- a/src/services/cat/chat-orchestrator.ts +++ b/src/services/cat/chat-orchestrator.ts @@ -370,7 +370,7 @@ export async function orchestrateCatChat( // own invention back as history next turn. // // Scoped to entity-attribution: Cat must stay free to name Lightning, - // Twint or PayPal in general advice. See agent-core/verify.ts. + // Twint or PayPal in general advice. See ai-kit/grounding verify. const groundingCheck = await enforceGrounding({ content: fullContent, message, diff --git a/src/services/cat/chat-prepare.ts b/src/services/cat/chat-prepare.ts index 73ec7be35..e43f140a6 100644 --- a/src/services/cat/chat-prepare.ts +++ b/src/services/cat/chat-prepare.ts @@ -20,7 +20,7 @@ import { } from '@/services/cat/conversation-history'; import { recallMemories } from '@/services/cat/memory'; import { fetchFullContextForCat, buildFullContextString } from '@/services/ai/document-context'; -import { buildAssistantRules } from '@/services/agent-core/contract'; +import { buildAssistantRules } from 'ai-kit/grounding'; import type { AnySupabaseClient } from '@/lib/supabase/types'; export interface CatChatPrepareOpts { @@ -121,7 +121,7 @@ export async function prepareCatChat( // Rule 5 explicitly protects general economic knowledge — Cat must still be // able to explain Lightning or Twint. The restriction is on inventing facts // about THIS user's people and entities, which is the failure that actually - // costs trust. See services/agent-core/README.md. + // costs trust. See ai-kit/grounding (the packaged harness). const groundingRules = contextString ? `\n\n${buildAssistantRules({ subjectNoun: 'profile, entities, contacts and transactions' })}` : ''; diff --git a/src/services/cat/grounding.ts b/src/services/cat/grounding.ts index f726b2969..dd1a29094 100644 --- a/src/services/cat/grounding.ts +++ b/src/services/cat/grounding.ts @@ -1,7 +1,7 @@ /** * Cat's groundedness enforcement — check, then one repair pass. * - * The check itself lives in agent-core/verify.ts (mirrored from FleetCrown). + * The check itself lives in ai-kit/grounding (the packaged harness). * This module owns the Cat-specific policy around it: when a repair is worth * attempting, and — more importantly — when a repair must be REFUSED. * @@ -14,8 +14,7 @@ * violations, nothing newly invented, no dropped action — and otherwise the * original stands with its violations reported. */ -import { verifyAnswer, buildRepairPrompt, type Violation } from '@/services/agent-core/verify'; -import { NO_BASIS } from '@/services/agent-core/contract'; +import { verifyAnswer, buildRepairPrompt, type Violation, NO_BASIS } from 'ai-kit/grounding'; import { parseActionsFromResponse } from '@/services/cat/response-parser'; import type { AiService } from '@/services/ai/types';