@bitbaum/ai-kit 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +216 -0
  3. package/dist/attempt.d.ts +48 -0
  4. package/dist/attempt.js +59 -0
  5. package/dist/catalog.d.ts +65 -0
  6. package/dist/catalog.js +115 -0
  7. package/dist/chain.d.ts +204 -0
  8. package/dist/chain.js +261 -0
  9. package/dist/fair-share.d.ts +120 -0
  10. package/dist/fair-share.js +127 -0
  11. package/dist/forms.d.ts +15 -0
  12. package/dist/forms.js +15 -0
  13. package/dist/grounding/contract.d.ts +101 -0
  14. package/dist/grounding/contract.js +138 -0
  15. package/dist/grounding/facts.d.ts +107 -0
  16. package/dist/grounding/facts.js +134 -0
  17. package/dist/grounding/index.d.ts +24 -0
  18. package/dist/grounding/index.js +24 -0
  19. package/dist/grounding/verify.d.ts +91 -0
  20. package/dist/grounding/verify.js +372 -0
  21. package/dist/health.d.ts +52 -0
  22. package/dist/health.js +64 -0
  23. package/dist/index.d.ts +50 -0
  24. package/dist/index.js +70 -0
  25. package/dist/limits.d.ts +102 -0
  26. package/dist/limits.js +136 -0
  27. package/dist/react.d.ts +8 -0
  28. package/dist/react.js +8 -0
  29. package/dist/registry.d.ts +133 -0
  30. package/dist/registry.js +126 -0
  31. package/dist/server.d.ts +10 -0
  32. package/dist/server.js +10 -0
  33. package/dist-cjs/grounding/contract.js +146 -0
  34. package/dist-cjs/grounding/facts.js +143 -0
  35. package/dist-cjs/grounding/index.js +43 -0
  36. package/dist-cjs/grounding/verify.js +376 -0
  37. package/dist-cjs/package.json +1 -0
  38. package/dist-cjs/registry.js +131 -0
  39. package/package.json +102 -0
  40. package/src/attempt.ts +82 -0
  41. package/src/catalog.ts +155 -0
  42. package/src/chain.ts +318 -0
  43. package/src/fair-share.ts +183 -0
  44. package/src/forms.ts +15 -0
  45. package/src/grounding/contract.ts +176 -0
  46. package/src/grounding/facts.ts +170 -0
  47. package/src/grounding/index.ts +50 -0
  48. package/src/grounding/verify.ts +429 -0
  49. package/src/health.ts +92 -0
  50. package/src/index.ts +124 -0
  51. package/src/limits.ts +137 -0
  52. package/src/react.ts +8 -0
  53. package/src/registry.ts +207 -0
  54. package/src/server.ts +10 -0
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The grounding harness — imported, no longer mirrored.
3
+ *
4
+ * These three modules were born in FleetCrown (`src/lib/agent/core/`) and
5
+ * lived as a byte-identical mirror in OrangeCat, guarded by a SHA-256 drift
6
+ * check, because both assistants had the same failure: a model asked to fill
7
+ * a rigid answer format against thin context invents the missing parts, and
8
+ * the invention is indistinguishable from truth because both arrive as
9
+ * confident prose.
10
+ *
11
+ * The mirror's own README called the duplication "deliberate and temporary"
12
+ * and named this extraction as the exit. This is that exit: both apps now
13
+ * import `ai-kit/grounding`, and the drift check retires — two
14
+ * silently-diverging definitions of "what counts as grounded" are no longer
15
+ * possible, because there is only one.
16
+ *
17
+ * The constraint that made the code mirrorable is the constraint that makes
18
+ * it packageable, and it still holds: pure TypeScript, no DB, no network, no
19
+ * framework, no imports outside this directory. Anything that knows where
20
+ * data lives belongs in the app adapter that maps rows to `Fact`s, not here.
21
+ */
22
+ export { NOT_RECORDED, FACT_KINDS, declaredFields, makeFact, assignFactIds, factIds, renderFacts, unrecordedFields, } from "./facts.js";
23
+ export { NO_BASIS, buildContract, buildAssistantRules, directiveId, renderDirectives, buildGroundedContext, } from "./contract.js";
24
+ export { verifyAnswer, buildRepairPrompt, } from "./verify.js";
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Groundedness verifier — MIRRORED MODULE (see core/README.md).
3
+ *
4
+ * Runs on the generated answer and reports claims the fact set does not support.
5
+ * Deliberately deterministic: no second model call, no embedding round-trip, no
6
+ * added cost or latency. That is a requirement, not a shortcut — this must run
7
+ * on every turn including the free-tier ones, and a verifier that costs a
8
+ * frontier call is one that gets disabled exactly where it is needed most.
9
+ *
10
+ * The insight that makes a cheap check work: fabrication is overwhelmingly
11
+ * NOMINAL. Models invent organisations, titles, people, file paths, phone
12
+ * numbers and dates — tokens that are mechanically recognisable and that must,
13
+ * if genuine, have appeared in the retrieved records or in what the user said.
14
+ * Grammar and hedging are hard to check; proper nouns and digits are easy.
15
+ *
16
+ * Scored against the real failure this was built from, every fabricated claim
17
+ * is caught by the proper-noun or numeric rule:
18
+ *
19
+ * "Ilya Druzhnikov (UZH)" → UZH: novel acronym
20
+ * "Accelerator & Bridge Program Manager" → novel proper-noun run
21
+ * "University of Liechtenstein", "START Summit" → novel proper-noun runs
22
+ * "/opt/fleetcrown/runner/.env" → novel path
23
+ *
24
+ * while the true parts ("Elena Weber SINGA Switzerland", "+41774730093") appear
25
+ * verbatim in the records and pass clean.
26
+ */
27
+ import { type Fact } from "./facts.js";
28
+ export type Violation = {
29
+ kind: "unknown-citation" | "novel-proper-noun" | "novel-number" | "novel-path" | "uncited-claim";
30
+ /** The offending text. */
31
+ text: string;
32
+ /** Why it is a problem, phrased for a repair prompt the model will read. */
33
+ detail: string;
34
+ };
35
+ export type VerifyResult = {
36
+ ok: boolean;
37
+ violations: Violation[];
38
+ };
39
+ /**
40
+ * How strictly to treat unattested names — the one real difference between the
41
+ * two assistants that use this harness.
42
+ *
43
+ * `closed-world` (Loki): the assistant's entire job is reporting the operator's
44
+ * records, so ANY unattested proper noun is a fabrication. One operator, one
45
+ * data set, no legitimate outside knowledge in scope.
46
+ *
47
+ * `entity-attribution` (Cat): the assistant also answers general questions —
48
+ * how Lightning works, which payment rails exist in Switzerland — where naming
49
+ * Twint or Bitcoin is correct and required. Flagging those would make Cat
50
+ * useless. So the check narrows to what actually goes wrong: attributes
51
+ * attached to one of the USER'S OWN records. A sentence naming a known subject
52
+ * is checked; a sentence of general explanation is not.
53
+ *
54
+ * The narrower mode is genuinely weaker, and that is a real trade, not a
55
+ * loophole: Cat can still invent a fact about the wider world. It can no longer
56
+ * invent an employer for someone in your contacts, which is the failure that
57
+ * actually destroys trust in a personal assistant.
58
+ */
59
+ export type VerifyMode = "closed-world" | "entity-attribution";
60
+ /**
61
+ * Verify an answer against the facts it was supposed to come from.
62
+ *
63
+ * `extraEvidence` lets a caller admit sources outside the fact set — computed
64
+ * directive output, a tool result the model legitimately saw this turn.
65
+ * Anything not in facts, the user's message, or extraEvidence is unsupported
66
+ * by construction.
67
+ *
68
+ * `subjects` (entity-attribution mode) names the user's own records, so the
69
+ * check can tell "your contact Elena works at X" from "Lightning is instant".
70
+ */
71
+ export declare function verifyAnswer(input: {
72
+ answer: string;
73
+ facts: Fact[];
74
+ userMessage: string;
75
+ extraEvidence?: string[];
76
+ mode?: VerifyMode;
77
+ subjects?: string[];
78
+ /**
79
+ * Extra legal citation handles beyond the fact ids — the [D1] series for
80
+ * computed answers. Without these a model that correctly cites a computed
81
+ * result gets flagged for citing something "that does not exist", which
82
+ * would train the repair pass to delete true statements.
83
+ */
84
+ extraCitationIds?: string[];
85
+ }): VerifyResult;
86
+ /**
87
+ * Turn violations into a repair instruction. One cheap retry with this appended
88
+ * fixes most turns, because the model is not being asked to know more — only to
89
+ * delete claims it cannot support.
90
+ */
91
+ export declare function buildRepairPrompt(violations: Violation[], noBasisPhrase: string): string;
@@ -0,0 +1,372 @@
1
+ /**
2
+ * Groundedness verifier — MIRRORED MODULE (see core/README.md).
3
+ *
4
+ * Runs on the generated answer and reports claims the fact set does not support.
5
+ * Deliberately deterministic: no second model call, no embedding round-trip, no
6
+ * added cost or latency. That is a requirement, not a shortcut — this must run
7
+ * on every turn including the free-tier ones, and a verifier that costs a
8
+ * frontier call is one that gets disabled exactly where it is needed most.
9
+ *
10
+ * The insight that makes a cheap check work: fabrication is overwhelmingly
11
+ * NOMINAL. Models invent organisations, titles, people, file paths, phone
12
+ * numbers and dates — tokens that are mechanically recognisable and that must,
13
+ * if genuine, have appeared in the retrieved records or in what the user said.
14
+ * Grammar and hedging are hard to check; proper nouns and digits are easy.
15
+ *
16
+ * Scored against the real failure this was built from, every fabricated claim
17
+ * is caught by the proper-noun or numeric rule:
18
+ *
19
+ * "Ilya Druzhnikov (UZH)" → UZH: novel acronym
20
+ * "Accelerator & Bridge Program Manager" → novel proper-noun run
21
+ * "University of Liechtenstein", "START Summit" → novel proper-noun runs
22
+ * "/opt/fleetcrown/runner/.env" → novel path
23
+ *
24
+ * while the true parts ("Elena Weber SINGA Switzerland", "+41774730093") appear
25
+ * verbatim in the records and pass clean.
26
+ */
27
+ import { NOT_RECORDED } from "./facts.js";
28
+ /**
29
+ * Words that are capitalised for reasons other than being a proper noun, or
30
+ * that are part of this system's own vocabulary. Kept deliberately small —
31
+ * every entry is a hole in the check, so add only what demonstrably causes
32
+ * false positives, never to silence a true one.
33
+ */
34
+ const COMMON = new Set([
35
+ // Sentence/structural
36
+ "the",
37
+ "a",
38
+ "an",
39
+ "and",
40
+ "or",
41
+ "but",
42
+ "if",
43
+ "then",
44
+ "so",
45
+ "because",
46
+ "not",
47
+ "this",
48
+ "that",
49
+ "these",
50
+ "those",
51
+ "it",
52
+ "its",
53
+ "your",
54
+ "you",
55
+ "i",
56
+ "we",
57
+ "there",
58
+ "here",
59
+ "what",
60
+ "which",
61
+ "who",
62
+ "when",
63
+ "where",
64
+ "why",
65
+ "how",
66
+ "no",
67
+ "yes",
68
+ "none",
69
+ "nothing",
70
+ "today",
71
+ "tomorrow",
72
+ "yesterday",
73
+ "now",
74
+ "next",
75
+ "last",
76
+ "first",
77
+ "one",
78
+ "two",
79
+ "three",
80
+ "primary",
81
+ "focus",
82
+ "task",
83
+ "tasks",
84
+ "outreach",
85
+ "note",
86
+ "notes",
87
+ "summary",
88
+ "status",
89
+ "update",
90
+ // Days / months — real words, never evidence of a fabricated entity
91
+ "monday",
92
+ "tuesday",
93
+ "wednesday",
94
+ "thursday",
95
+ "friday",
96
+ "saturday",
97
+ "sunday",
98
+ "january",
99
+ "february",
100
+ "march",
101
+ "april",
102
+ "may",
103
+ "june",
104
+ "july",
105
+ "august",
106
+ "september",
107
+ "october",
108
+ "november",
109
+ "december",
110
+ // This system's own nouns
111
+ "loki",
112
+ "cat",
113
+ "fleetcrown",
114
+ "orangecat",
115
+ "not",
116
+ "recorded",
117
+ ].map((w) => w.toLowerCase()));
118
+ /** Normalise for containment tests: casefold, collapse punctuation and space. */
119
+ function norm(s) {
120
+ return s
121
+ .toLowerCase()
122
+ .replace(/[^a-z0-9+]+/g, " ")
123
+ .replace(/\s+/g, " ")
124
+ .trim();
125
+ }
126
+ /**
127
+ * Everything the model was legitimately given this turn: record values, record
128
+ * subjects, and the user's own message (a name the user typed is fair to
129
+ * repeat). This is the corpus a claim must be traceable to.
130
+ */
131
+ function buildEvidence(facts, userMessage, extra) {
132
+ const parts = [userMessage, ...extra];
133
+ for (const f of facts) {
134
+ parts.push(f.subject, f.kind, f.source);
135
+ for (const v of Object.values(f.fields))
136
+ if (v)
137
+ parts.push(v);
138
+ }
139
+ return norm(parts.join(" "));
140
+ }
141
+ /**
142
+ * Lowercase words that legitimately sit INSIDE a proper name and must not break
143
+ * it up: "University of Zurich", "Bank für Handel", "Institute for the Study of
144
+ * Complexity". Without these, the run splits at the connector and the check
145
+ * only ever sees the harmless halves ("University", "Zurich") while the actual
146
+ * fabricated entity slips through unnamed.
147
+ */
148
+ const NAME_CONNECTORS = new Set([
149
+ "of",
150
+ "the",
151
+ "for",
152
+ "and",
153
+ "de",
154
+ "der",
155
+ "des",
156
+ "van",
157
+ "von",
158
+ "du",
159
+ "da",
160
+ "di",
161
+ "für",
162
+ "el",
163
+ "al",
164
+ ]);
165
+ /**
166
+ * Named-entity candidates: ALL-CAPS acronyms, capitalised words, and the
167
+ * multi-word runs they form (connectors allowed strictly between two
168
+ * capitalised tokens, never at an edge).
169
+ *
170
+ * Both the run AND its individual tokens are emitted, deliberately. The run
171
+ * catches composite inventions ("University of Zurich") that no single token
172
+ * reveals; the individual tokens catch an invented acronym sitting next to a
173
+ * real name ("Druzhnikov UZH"), where reporting only the run would name the
174
+ * real person in the violation and produce a repair prompt that deletes the
175
+ * true claim along with the false one.
176
+ *
177
+ * Sentence-initial single words are skipped — otherwise "Rotate the key" flags
178
+ * "Rotate". That costs a little recall at sentence starts and removes the
179
+ * dominant source of false positives; a fabricated name at a sentence start is
180
+ * still caught by its remaining tokens.
181
+ */
182
+ function properNounRuns(text) {
183
+ const out = [];
184
+ // Strip fenced and inline code — quoted identifiers are usually the user's
185
+ // own or a literal under discussion, not a claim about the world.
186
+ const prose = text.replace(/```[\s\S]*?```/g, " ").replace(/`[^`]*`/g, " ");
187
+ for (const sentence of prose.split(/(?<=[.!?:\n])\s+/)) {
188
+ const tokens = sentence.match(/[A-Za-z][A-Za-z0-9&.'’-]*/g) ?? [];
189
+ let run = [];
190
+ const flush = () => {
191
+ // Trim trailing connectors so "University of" never stands as a run.
192
+ while (run.length > 0 && NAME_CONNECTORS.has((run[run.length - 1] ?? "").toLowerCase()))
193
+ run.pop();
194
+ if (run.length > 1)
195
+ out.push(run.join(" "));
196
+ run = [];
197
+ };
198
+ tokens.forEach((tok, i) => {
199
+ const bare = tok.replace(/[.'’-]+$/, "");
200
+ const isAcronym = /^[A-Z]{2,}$/.test(bare);
201
+ const isCapitalised = /^[A-Z][a-z]/.test(bare);
202
+ const isConnector = NAME_CONNECTORS.has(bare.toLowerCase());
203
+ if (isAcronym || (isCapitalised && i > 0)) {
204
+ run.push(bare);
205
+ out.push(bare); // individually checkable
206
+ return;
207
+ }
208
+ // A connector only continues a run that has already started.
209
+ if (isConnector && run.length > 0) {
210
+ run.push(bare);
211
+ return;
212
+ }
213
+ flush();
214
+ });
215
+ flush();
216
+ }
217
+ return out;
218
+ }
219
+ /** Digit groups worth checking: phone numbers, years, percentages, counts ≥ 2 digits. */
220
+ function numericClaims(text) {
221
+ const prose = text.replace(/```[\s\S]*?```/g, " ").replace(/`[^`]*`/g, " ");
222
+ return (prose.match(/\+?\d[\d\s().-]{3,}\d|\b\d{2,}%?\b/g) ?? []).map((s) => s.trim());
223
+ }
224
+ /**
225
+ * File and path references — a favourite fabrication, and an unusually
226
+ * damaging one because naming a file implies the model READ it.
227
+ *
228
+ * Covers absolute paths (`/opt/fleetcrown/runner/.env`), relative paths
229
+ * (`data/contact-resolver.json`), and bare filenames with a data/config
230
+ * extension. The relative form matters: when challenged on the UZH claim, the
231
+ * model "corrected" itself by asserting what `data/contact-resolver.json`
232
+ * contained — a file it was never given. That reads as citing a source, which
233
+ * is precisely why an unverified correction is more corrosive than the
234
+ * original error: it spends the credibility the user was trying to restore.
235
+ */
236
+ function pathClaims(text) {
237
+ const patterns = [
238
+ /(?:^|[\s("'`])(\/[A-Za-z0-9_.\-/]{4,})/g, // absolute
239
+ /(?:^|[\s("'`])([A-Za-z0-9_.-]+\/[A-Za-z0-9_.\-/]*[A-Za-z0-9_-]\.[a-z]{2,5})/g, // relative w/ extension
240
+ /(?:^|[\s("'`])([A-Za-z0-9_-]+\.(?:json|env|ya?ml|sql|toml|ini|conf|log))\b/g, // bare config filename
241
+ ];
242
+ const out = new Set();
243
+ for (const re of patterns) {
244
+ for (const m of text.matchAll(re))
245
+ if (m[1])
246
+ out.add(m[1]);
247
+ }
248
+ return [...out];
249
+ }
250
+ /** Does this sentence talk about one of the user's own records? */
251
+ function mentionsSubject(sentence, subjects) {
252
+ const s = norm(sentence);
253
+ return subjects.some((sub) => {
254
+ const n = norm(sub);
255
+ return n.length > 2 && s.includes(n);
256
+ });
257
+ }
258
+ /**
259
+ * Verify an answer against the facts it was supposed to come from.
260
+ *
261
+ * `extraEvidence` lets a caller admit sources outside the fact set — computed
262
+ * directive output, a tool result the model legitimately saw this turn.
263
+ * Anything not in facts, the user's message, or extraEvidence is unsupported
264
+ * by construction.
265
+ *
266
+ * `subjects` (entity-attribution mode) names the user's own records, so the
267
+ * check can tell "your contact Elena works at X" from "Lightning is instant".
268
+ */
269
+ export function verifyAnswer(input) {
270
+ const { answer, facts, userMessage } = input;
271
+ const mode = input.mode ?? "closed-world";
272
+ const subjects = input.subjects ?? facts.map((f) => f.subject);
273
+ const evidence = buildEvidence(facts, userMessage, input.extraEvidence ?? []);
274
+ const legalIds = new Set([
275
+ ...facts.map((f) => f.id.toUpperCase()),
276
+ ...(input.extraCitationIds ?? []).map((id) => id.toUpperCase()),
277
+ ]);
278
+ const violations = [];
279
+ /**
280
+ * In entity-attribution mode, only sentences about the user's own records are
281
+ * subject to the name check. Built once so the per-token loop stays cheap.
282
+ */
283
+ const attributionScope = mode === "entity-attribution"
284
+ ? answer
285
+ .split(/(?<=[.!?:\n])\s+/)
286
+ .filter((s) => mentionsSubject(s, subjects))
287
+ .join(" ")
288
+ : answer;
289
+ // 1. Citations must resolve. A citation to a record that does not exist is
290
+ // the strongest possible signal of fabrication — it invents its own proof.
291
+ for (const cite of answer.match(/\[[FD]\d+\]/g) ?? []) {
292
+ const id = cite.slice(1, -1).toUpperCase();
293
+ if (!legalIds.has(id)) {
294
+ violations.push({
295
+ kind: "unknown-citation",
296
+ text: cite,
297
+ detail: `${cite} is not a record in this turn's context. Cite only ids that were provided, or say there is no record.`,
298
+ });
299
+ }
300
+ }
301
+ // 2. Named entities must be traceable. This is the anti-"UZH" rule.
302
+ const seen = new Set();
303
+ for (const run of properNounRuns(attributionScope)) {
304
+ const n = norm(run);
305
+ if (!n || seen.has(n))
306
+ continue;
307
+ seen.add(n);
308
+ // Single common words are noise; multi-word runs always checked.
309
+ const words = n.split(" ");
310
+ if (words.length === 1 && (COMMON.has(words[0] ?? "") || (words[0] ?? "").length < 2))
311
+ continue;
312
+ if (words.every((w) => COMMON.has(w)))
313
+ continue;
314
+ if (evidence.includes(n))
315
+ continue;
316
+ // A multi-word run whose every word is individually attested is fine —
317
+ // it is a rephrasing, not a new entity.
318
+ if (words.length > 1 && words.every((w) => COMMON.has(w) || evidence.includes(w)))
319
+ continue;
320
+ violations.push({
321
+ kind: "novel-proper-noun",
322
+ text: run,
323
+ 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.`,
324
+ });
325
+ }
326
+ // 3. Numbers must be traceable — invented phone numbers and dates read as
327
+ // authoritative precisely because they are specific.
328
+ for (const num of numericClaims(answer)) {
329
+ const n = norm(num);
330
+ if (!n || n.length < 2)
331
+ continue;
332
+ if (evidence.includes(n))
333
+ continue;
334
+ // Compare digits-only too: "+41 77 473 00 93" vs stored "+41774730093".
335
+ const digits = num.replace(/\D/g, "");
336
+ if (digits.length >= 4 && evidence.replace(/\D/g, "").includes(digits))
337
+ continue;
338
+ if (digits.length < 4)
339
+ continue; // small counts ("3 tasks") are rhetorical
340
+ violations.push({
341
+ kind: "novel-number",
342
+ text: num,
343
+ detail: `The number "${num}" is not in any record. Do not state contact details, dates, or metrics that were not provided.`,
344
+ });
345
+ }
346
+ // 4. Paths — "update the key in /opt/fleetcrown/runner/.env" was invented
347
+ // wholesale, and its specificity is what made it convincing.
348
+ for (const p of pathClaims(answer)) {
349
+ if (evidence.includes(norm(p)))
350
+ continue;
351
+ violations.push({
352
+ kind: "novel-path",
353
+ text: p,
354
+ detail: `The path "${p}" is not in any record. Do not state file locations you were not given.`,
355
+ });
356
+ }
357
+ return { ok: violations.length === 0, violations };
358
+ }
359
+ /**
360
+ * Turn violations into a repair instruction. One cheap retry with this appended
361
+ * fixes most turns, because the model is not being asked to know more — only to
362
+ * delete claims it cannot support.
363
+ */
364
+ export function buildRepairPrompt(violations, noBasisPhrase) {
365
+ return [
366
+ "Your previous answer contained claims not supported by the records. Rewrite it.",
367
+ "",
368
+ ...violations.map((v) => `- ${v.detail}`),
369
+ "",
370
+ `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.`,
371
+ ].join("\n");
372
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Observed health of an AI feature — was the last generation actually usable?
3
+ *
4
+ * This exists because of a failure that "the chain never fails" hides just as
5
+ * well as a single pin does. On 2026-08-28 an app's only configured key
6
+ * started returning 401 and the chain (correctly) had nowhere else to go — the
7
+ * routes caught the error and answered HTTP 200 with an apology, and the
8
+ * app's own `/health` reported "healthy" because it only ever checked the
9
+ * database. A total outage of the product's core feature was invisible to
10
+ * every automated check, for as long as nobody happened to try it by hand.
11
+ *
12
+ * A tracker records what actually happened, so a health route can report it
13
+ * and a strict check can refuse to call the app "up" while its AI is down.
14
+ *
15
+ * FACTORY, NOT A SINGLETON. A module-level global would force a shared state
16
+ * shape on every consumer and make the transitions untestable without mutating
17
+ * process-wide state between tests. `createHealthTracker()` returns an
18
+ * isolated instance — a single-process app gets the old singleton behaviour
19
+ * for free by creating exactly one and exporting it from its own module:
20
+ *
21
+ * // lib/llm-health.ts
22
+ * export const llmHealth = createHealthTracker();
23
+ *
24
+ * If the app scales horizontally, this state is per-instance and wants a
25
+ * shared store — that migration is app-specific and out of scope here.
26
+ */
27
+ export type HealthStatus = "unknown" | "ok" | "degraded" | "down";
28
+ export interface Health {
29
+ status: HealthStatus;
30
+ consecutiveFailures: number;
31
+ lastError: string | null;
32
+ /** Epoch milliseconds. Format at the API boundary, not here. */
33
+ lastSuccessAt: number | null;
34
+ /** Epoch milliseconds. Format at the API boundary, not here. */
35
+ lastFailureAt: number | null;
36
+ }
37
+ export interface HealthTrackerOptions {
38
+ /** Consecutive failures before status flips from "degraded" to "down". Default 3. */
39
+ downAfter?: number;
40
+ /** Clock injection point for tests. Default `Date.now`. */
41
+ now?: () => number;
42
+ }
43
+ export interface HealthTracker {
44
+ /** Call after a generation that produced usable content. */
45
+ recordSuccess(): void;
46
+ /** Call when generation threw, or returned nothing usable. */
47
+ recordFailure(error: unknown): void;
48
+ getHealth(): Health;
49
+ /** Test seam — also useful for an app-triggered "recheck now". */
50
+ reset(): void;
51
+ }
52
+ export declare function createHealthTracker(options?: HealthTrackerOptions): HealthTracker;
package/dist/health.js ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Observed health of an AI feature — was the last generation actually usable?
3
+ *
4
+ * This exists because of a failure that "the chain never fails" hides just as
5
+ * well as a single pin does. On 2026-08-28 an app's only configured key
6
+ * started returning 401 and the chain (correctly) had nowhere else to go — the
7
+ * routes caught the error and answered HTTP 200 with an apology, and the
8
+ * app's own `/health` reported "healthy" because it only ever checked the
9
+ * database. A total outage of the product's core feature was invisible to
10
+ * every automated check, for as long as nobody happened to try it by hand.
11
+ *
12
+ * A tracker records what actually happened, so a health route can report it
13
+ * and a strict check can refuse to call the app "up" while its AI is down.
14
+ *
15
+ * FACTORY, NOT A SINGLETON. A module-level global would force a shared state
16
+ * shape on every consumer and make the transitions untestable without mutating
17
+ * process-wide state between tests. `createHealthTracker()` returns an
18
+ * isolated instance — a single-process app gets the old singleton behaviour
19
+ * for free by creating exactly one and exporting it from its own module:
20
+ *
21
+ * // lib/llm-health.ts
22
+ * export const llmHealth = createHealthTracker();
23
+ *
24
+ * If the app scales horizontally, this state is per-instance and wants a
25
+ * shared store — that migration is app-specific and out of scope here.
26
+ */
27
+ export function createHealthTracker(options = {}) {
28
+ const downAfter = options.downAfter ?? 3;
29
+ const now = options.now ?? Date.now;
30
+ let consecutiveFailures = 0;
31
+ let lastError = null;
32
+ let lastSuccessAt = null;
33
+ let lastFailureAt = null;
34
+ return {
35
+ recordSuccess() {
36
+ consecutiveFailures = 0;
37
+ lastError = null;
38
+ lastSuccessAt = now();
39
+ },
40
+ recordFailure(error) {
41
+ consecutiveFailures += 1;
42
+ lastFailureAt = now();
43
+ lastError = error instanceof Error ? error.message : String(error ?? "unknown error");
44
+ },
45
+ getHealth() {
46
+ let status;
47
+ if (consecutiveFailures >= downAfter)
48
+ status = "down";
49
+ else if (consecutiveFailures > 0)
50
+ status = "degraded";
51
+ else if (lastSuccessAt !== null)
52
+ status = "ok";
53
+ else
54
+ status = "unknown";
55
+ return { status, consecutiveFailures, lastError, lastSuccessAt, lastFailureAt };
56
+ },
57
+ reset() {
58
+ consecutiveFailures = 0;
59
+ lastError = null;
60
+ lastSuccessAt = null;
61
+ lastFailureAt = null;
62
+ },
63
+ };
64
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * ai-kit — one install for the AI layer of an app.
3
+ *
4
+ * WHAT IT IS FOR
5
+ * --------------
6
+ * An app that wants an AI feature needs several unrelated-looking decisions to
7
+ * go right, and getting any one wrong looks identical from the outside: the
8
+ * assistant is broken. This package holds all of them, so adding AI is one
9
+ * decision instead of many.
10
+ *
11
+ * which model — a fallback list ACROSS VENDORS, because a single pinned free
12
+ * model is a scheduled outage, and a smaller model at the same
13
+ * vendor draws on the same exhausted daily budget.
14
+ * still there? — has the vendor retired an id we still ask for? The list is
15
+ * itself a list of pins, so it rots too. Zero tokens, so it can
16
+ * run on a schedule instead of being remembered.
17
+ * walk it — a chain nobody walks is a list, not a fallback. `tryChain`
18
+ * tries each link and stops at the first success; a
19
+ * `HealthTracker` records whether the WHOLE chain came back
20
+ * empty, so a health route can say so before a user does.
21
+ * too fast? — tell the three kinds of 429 apart. They share a status code
22
+ * and need opposite responses; only the body distinguishes them.
23
+ * who gets it — divide a fixed daily pool across active users, so the person
24
+ * who arrives at 4pm still gets a turn.
25
+ * filling forms— fill a form from prose and keep talking to it, re-exported
26
+ * from `ai-forms` (see ./forms.ts for why it stays separate).
27
+ *
28
+ * WHY IT WAS RENAMED FROM ai-ration
29
+ * ---------------------------------
30
+ * Because the owner of this fleet read the name and could not tell what it did.
31
+ * That is not a cosmetic complaint: an unreadable name is an adoption cost paid
32
+ * on every single install decision, and this package had ONE adopter while the
33
+ * five repos that skipped it were all taken down together on 2026-08-26 by a
34
+ * retired model id — the exact failure the `chain` and `catalog` modules exist
35
+ * to prevent. "Ration" described one of five modules and buried the other four.
36
+ *
37
+ * STILL NOT INCLUDED: an HTTP client. Every app has its own calling conventions,
38
+ * retries and logging, and replacing those is a rewrite rather than an adoption.
39
+ * This supplies the decisions; the caller keeps the fetch — `tryChain` is an
40
+ * orchestrator, not a client: the caller's own `attempt` function makes the
41
+ * actual request. That rule is under review — `ai-forms`, the most-adopted
42
+ * package in this fleet, is the one that broke it by shipping a route factory
43
+ * and a hook.
44
+ */
45
+ export { type Provider, type Env, type Link, type CostVerdict, providerModels, withEnvPrefix, freeChain, modelCost, modelCostAt, paidModelsIn, dayCapacityTokens, usableChain, chainFrom, } from "./chain.js";
46
+ export { type CatalogVerdict, type CheckCatalogOptions, checkCatalog, hasRot, deadProviders, catalogReport, } from "./catalog.js";
47
+ export { type ChainAttemptFailure, type TryChainOptions, ChainExhaustedError, tryChain, } from "./attempt.js";
48
+ export { type HealthStatus, type Health, type HealthTrackerOptions, type HealthTracker, createHealthTracker, } from "./health.js";
49
+ export { type RateLimitKind, classifyRateLimit, retryAfterSeconds, humanizeWait, rateLimitMessage, } from "./limits.js";
50
+ export { DAY_SECONDS, DEFAULT_BURST, type ShareInput, type ShareReason, type ShareDecision, fairShare, utcDayElapsed, utcDayKey, } from "./fair-share.js";