@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.
- package/LICENSE +21 -0
- package/README.md +216 -0
- package/dist/attempt.d.ts +48 -0
- package/dist/attempt.js +59 -0
- package/dist/catalog.d.ts +65 -0
- package/dist/catalog.js +115 -0
- package/dist/chain.d.ts +204 -0
- package/dist/chain.js +261 -0
- package/dist/fair-share.d.ts +120 -0
- package/dist/fair-share.js +127 -0
- package/dist/forms.d.ts +15 -0
- package/dist/forms.js +15 -0
- package/dist/grounding/contract.d.ts +101 -0
- package/dist/grounding/contract.js +138 -0
- package/dist/grounding/facts.d.ts +107 -0
- package/dist/grounding/facts.js +134 -0
- package/dist/grounding/index.d.ts +24 -0
- package/dist/grounding/index.js +24 -0
- package/dist/grounding/verify.d.ts +91 -0
- package/dist/grounding/verify.js +372 -0
- package/dist/health.d.ts +52 -0
- package/dist/health.js +64 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.js +70 -0
- package/dist/limits.d.ts +102 -0
- package/dist/limits.js +136 -0
- package/dist/react.d.ts +8 -0
- package/dist/react.js +8 -0
- package/dist/registry.d.ts +133 -0
- package/dist/registry.js +126 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.js +10 -0
- package/dist-cjs/grounding/contract.js +146 -0
- package/dist-cjs/grounding/facts.js +143 -0
- package/dist-cjs/grounding/index.js +43 -0
- package/dist-cjs/grounding/verify.js +376 -0
- package/dist-cjs/package.json +1 -0
- package/dist-cjs/registry.js +131 -0
- package/package.json +102 -0
- package/src/attempt.ts +82 -0
- package/src/catalog.ts +155 -0
- package/src/chain.ts +318 -0
- package/src/fair-share.ts +183 -0
- package/src/forms.ts +15 -0
- package/src/grounding/contract.ts +176 -0
- package/src/grounding/facts.ts +170 -0
- package/src/grounding/index.ts +50 -0
- package/src/grounding/verify.ts +429 -0
- package/src/health.ts +92 -0
- package/src/index.ts +124 -0
- package/src/limits.ts +137 -0
- package/src/react.ts +8 -0
- package/src/registry.ts +207 -0
- package/src/server.ts +10 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fair-share rationing of a FIXED daily AI budget across users.
|
|
3
|
+
*
|
|
4
|
+
* Pure by design — no DB, no clock, no provider. Everything it needs arrives as
|
|
5
|
+
* arguments, so the same policy drops into any project without dragging one
|
|
6
|
+
* app's storage or model plumbing along. The caller owns "what has this user
|
|
7
|
+
* spent today"; this file owns "may they spend more".
|
|
8
|
+
*
|
|
9
|
+
* ── The problem it exists to solve ───────────────────────────────────────────
|
|
10
|
+
* Free tiers are metered per DAY and shared by everyone holding the key: one
|
|
11
|
+
* vendor grants ~100k tokens/day across a whole org, and one measured chat turn
|
|
12
|
+
* cost ~16k (one tool call, on a free model). That is about six turns a day per
|
|
13
|
+
* vendor — fewer than anyone assumes. Divided badly, the first
|
|
14
|
+
* enthusiastic user spends the entire day's budget before lunch and everyone
|
|
15
|
+
* who arrives after them meets a wall — including the person trying the product
|
|
16
|
+
* for the first time, who concludes it is broken and never returns.
|
|
17
|
+
*
|
|
18
|
+
* So the goal is not "maximise throughput", it is "every ACTIVE user gets a
|
|
19
|
+
* usable amount, every day". Those are different objectives and they favour
|
|
20
|
+
* different designs.
|
|
21
|
+
*
|
|
22
|
+
* ── Two ideas, and both are load-bearing ─────────────────────────────────────
|
|
23
|
+
*
|
|
24
|
+
* 1. SHARE — capacity / active users. Recomputed per request, so the split
|
|
25
|
+
* tracks reality instead of a number set once in a config file. Crucially
|
|
26
|
+
* "active" means users who actually drew today, not everyone registered:
|
|
27
|
+
* counting dormant accounts would ration a quiet day down to nothing and
|
|
28
|
+
* waste the budget that was meant to be generous. One user on a quiet day
|
|
29
|
+
* correctly gets the whole thing.
|
|
30
|
+
*
|
|
31
|
+
* 2. PACING — a share alone is not enough, because a share is a whole-DAY
|
|
32
|
+
* allowance and the day is consumed in order. Without pacing, three users
|
|
33
|
+
* can each legitimately spend their full share by 09:00 and the fourth to
|
|
34
|
+
* arrive finds the capacity gone even though nobody exceeded their split.
|
|
35
|
+
* So the allowance unlocks gradually: by mid-afternoon you may have spent
|
|
36
|
+
* about half your share, by end of day all of it. That is what actually
|
|
37
|
+
* keeps capacity available for whoever shows up later.
|
|
38
|
+
*
|
|
39
|
+
* `burst` exists so this does not become its own wall: with pure pacing, a user
|
|
40
|
+
* at one minute past midnight would have an allowance of nearly zero. A burst
|
|
41
|
+
* makes the first turns immediate, which is the difference between "paced" and
|
|
42
|
+
* "unusable".
|
|
43
|
+
*
|
|
44
|
+
* ── What it deliberately does NOT do ─────────────────────────────────────────
|
|
45
|
+
* No clawback. A user who spent under an older, larger share when they were the
|
|
46
|
+
* only one active is not punished when a second user appears — their allowance
|
|
47
|
+
* simply stops growing until the day catches up. Taking budget back from
|
|
48
|
+
* someone who already used it is impossible anyway (the tokens are spent) and
|
|
49
|
+
* pretending otherwise would only produce confusing refusals.
|
|
50
|
+
*/
|
|
51
|
+
/** Seconds in a budget day. Provider quotas reset daily, so the day is the unit. */
|
|
52
|
+
export const DAY_SECONDS = 86_400;
|
|
53
|
+
/**
|
|
54
|
+
* Fraction of a user's share spendable immediately, before pacing has unlocked
|
|
55
|
+
* anything. Set so the first couple of turns never wait: the failure mode this
|
|
56
|
+
* guards against ("I typed one question at 9am and it refused me") is far worse
|
|
57
|
+
* than the one it risks (a slightly front-loaded day).
|
|
58
|
+
*/
|
|
59
|
+
export const DEFAULT_BURST = 0.25;
|
|
60
|
+
/** Clamp to [0, 1]; a caller's clock skew must not produce a negative allowance. */
|
|
61
|
+
function clamp01(n) {
|
|
62
|
+
if (!Number.isFinite(n))
|
|
63
|
+
return 0;
|
|
64
|
+
return Math.min(1, Math.max(0, n));
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* May this user spend `costTokens` right now?
|
|
68
|
+
*
|
|
69
|
+
* Recomputed per request rather than cached: `activeUsers` is the input most
|
|
70
|
+
* likely to change between two turns, and a stale divisor is exactly how a new
|
|
71
|
+
* user gets locked out of a budget that was supposed to include them.
|
|
72
|
+
*/
|
|
73
|
+
export function fairShare(input) {
|
|
74
|
+
const capacity = Math.max(0, input.dayCapacityTokens);
|
|
75
|
+
// A user is always at least one user. Guarding here rather than trusting the
|
|
76
|
+
// caller keeps a bad count from becoming a division by zero at the worst
|
|
77
|
+
// possible moment.
|
|
78
|
+
const users = Math.max(1, Math.floor(input.activeUsers) || 1);
|
|
79
|
+
const spent = Math.max(0, input.userSpentTokens);
|
|
80
|
+
const cost = Math.max(0, input.costTokens);
|
|
81
|
+
const burst = clamp01(input.burst ?? DEFAULT_BURST);
|
|
82
|
+
const elapsed = clamp01(input.dayElapsed);
|
|
83
|
+
const shareTokens = capacity / users;
|
|
84
|
+
const pace = clamp01(elapsed + burst);
|
|
85
|
+
// A share that cannot buy a SINGLE turn is a wall wearing a ration's clothes.
|
|
86
|
+
// With four active users on a 100k day and a ~10k turn, pure pacing refuses
|
|
87
|
+
// the first question of the morning and tells the user to come back in three
|
|
88
|
+
// hours — which, for someone trying the product for the first time, is
|
|
89
|
+
// indistinguishable from broken. So the allowance never sits below the cost
|
|
90
|
+
// of one turn, capped by the share: everyone gets at least one, then pacing
|
|
91
|
+
// governs the rest. Capped by `shareTokens` so this floor can never hand out
|
|
92
|
+
// more than the user's actual split.
|
|
93
|
+
const oneTurn = Math.min(shareTokens, cost);
|
|
94
|
+
const allowanceTokens = Math.min(shareTokens, Math.max(shareTokens * pace, oneTurn));
|
|
95
|
+
if (capacity <= 0) {
|
|
96
|
+
return { allowed: false, shareTokens: 0, allowanceTokens: 0, reason: "no-capacity" };
|
|
97
|
+
}
|
|
98
|
+
const wanted = spent + cost;
|
|
99
|
+
if (wanted <= allowanceTokens) {
|
|
100
|
+
return { allowed: true, shareTokens, allowanceTokens, reason: "ok" };
|
|
101
|
+
}
|
|
102
|
+
// Past the whole-day share: no amount of waiting unlocks more today.
|
|
103
|
+
if (wanted > shareTokens) {
|
|
104
|
+
return { allowed: false, shareTokens, allowanceTokens, reason: "share-spent" };
|
|
105
|
+
}
|
|
106
|
+
// Within the share but ahead of the pace — the one case a wait fixes. Solve
|
|
107
|
+
// for the elapsed fraction at which the allowance covers `wanted`.
|
|
108
|
+
const neededPace = wanted / shareTokens;
|
|
109
|
+
const neededElapsed = neededPace - burst;
|
|
110
|
+
const retryAfterSeconds = Math.max(1, Math.ceil((neededElapsed - elapsed) * DAY_SECONDS));
|
|
111
|
+
return { allowed: false, shareTokens, allowanceTokens, reason: "paced", retryAfterSeconds };
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* How far through the UTC day `now` is, 0..1.
|
|
115
|
+
*
|
|
116
|
+
* UTC because that is what the providers meter on; deriving it from the
|
|
117
|
+
* operator's local midnight would drift the reset away from the vendor's and
|
|
118
|
+
* hand out budget that is not there.
|
|
119
|
+
*/
|
|
120
|
+
export function utcDayElapsed(now) {
|
|
121
|
+
const ms = now.getTime() - Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
|
122
|
+
return clamp01(ms / (DAY_SECONDS * 1000));
|
|
123
|
+
}
|
|
124
|
+
/** The UTC day key (YYYY-MM-DD) a spend belongs to — the accounting bucket. */
|
|
125
|
+
export function utcDayKey(now) {
|
|
126
|
+
return now.toISOString().slice(0, 10);
|
|
127
|
+
}
|
package/dist/forms.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Form filling, re-exported from `ai-forms`.
|
|
3
|
+
*
|
|
4
|
+
* `ai-forms` is NOT absorbed. It stays its own package: it works, four apps run
|
|
5
|
+
* it, and it is a genuinely general-purpose thing that people outside this fleet
|
|
6
|
+
* can use. Swallowing it would break four repos and delete a good name off the
|
|
7
|
+
* registry to satisfy a filing system.
|
|
8
|
+
*
|
|
9
|
+
* What this subpath buys is that an app adding AI installs ONE thing. AOZ is
|
|
10
|
+
* the argument: it adopted `ai-forms`, then hand-rolled a provider layer and a
|
|
11
|
+
* chat loop, because those were two further decisions nobody made. Filling a
|
|
12
|
+
* form from prose and choosing which model fills it are the same feature to the
|
|
13
|
+
* app, so they should be one install.
|
|
14
|
+
*/
|
|
15
|
+
export * from "ai-forms";
|
package/dist/forms.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Form filling, re-exported from `ai-forms`.
|
|
3
|
+
*
|
|
4
|
+
* `ai-forms` is NOT absorbed. It stays its own package: it works, four apps run
|
|
5
|
+
* it, and it is a genuinely general-purpose thing that people outside this fleet
|
|
6
|
+
* can use. Swallowing it would break four repos and delete a good name off the
|
|
7
|
+
* registry to satisfy a filing system.
|
|
8
|
+
*
|
|
9
|
+
* What this subpath buys is that an app adding AI installs ONE thing. AOZ is
|
|
10
|
+
* the argument: it adopted `ai-forms`, then hand-rolled a provider layer and a
|
|
11
|
+
* chat loop, because those were two further decisions nobody made. Filling a
|
|
12
|
+
* form from prose and choosing which model fills it are the same feature to the
|
|
13
|
+
* app, so they should be one install.
|
|
14
|
+
*/
|
|
15
|
+
export * from "ai-forms";
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The grounding contract — the rules block that ships with every turn's facts.
|
|
3
|
+
* MIRRORED MODULE (see core/README.md).
|
|
4
|
+
*
|
|
5
|
+
* Why this is generated rather than a hand-written constant: a standing prose
|
|
6
|
+
* rule ("only use provided context") is a weak signal that models trade away
|
|
7
|
+
* under format pressure. The failure that motivated this harness was exactly
|
|
8
|
+
* that — a prompt demanding "1 focus, 3 tasks, 1 person, under 150 words, no
|
|
9
|
+
* hedging" got four confidently-formatted answers, three of them invented,
|
|
10
|
+
* against a context block that already said "if a question falls outside this
|
|
11
|
+
* context, say so rather than guessing".
|
|
12
|
+
*
|
|
13
|
+
* The lesson: the model did not disobey a rule it forgot. It obeyed the
|
|
14
|
+
* STRONGER of two conflicting instructions — fill five slots — because nothing
|
|
15
|
+
* made the empty slot expressible. So this block does three things a static
|
|
16
|
+
* prompt cannot:
|
|
17
|
+
*
|
|
18
|
+
* 1. Names the exact citation handles that exist this turn, so "cite a fact"
|
|
19
|
+
* is a closed-set choice rather than free text.
|
|
20
|
+
* 2. Names the exact fields that are unrecorded THIS TURN, so the prohibition
|
|
21
|
+
* is concrete ("you have no affiliation for any person here") instead of
|
|
22
|
+
* abstract.
|
|
23
|
+
* 3. Supplies the escape hatch verbatim, so refusing a slot is a cheaper
|
|
24
|
+
* token path than inventing one.
|
|
25
|
+
*/
|
|
26
|
+
import { type Fact } from "./facts.js";
|
|
27
|
+
/** The exact string the model must emit when a slot cannot be filled. */
|
|
28
|
+
export declare const NO_BASIS = "Not in your data.";
|
|
29
|
+
/**
|
|
30
|
+
* Build the contract for a specific fact set. Empty fact sets get the strictest
|
|
31
|
+
* form — with nothing retrieved, EVERY answer must be a refusal, and saying so
|
|
32
|
+
* plainly beats hoping the model notices the context block is empty.
|
|
33
|
+
*/
|
|
34
|
+
export declare function buildContract(facts: Fact[], directives?: Directive[]): string;
|
|
35
|
+
/**
|
|
36
|
+
* The subset of the contract that needs no fact ids — for an assistant whose
|
|
37
|
+
* context is still prose (Cat) rather than typed records.
|
|
38
|
+
*
|
|
39
|
+
* Weaker than `buildContract` by construction: without ids there is nothing to
|
|
40
|
+
* cite, so rule 1 cannot exist and the verifier runs in entity-attribution
|
|
41
|
+
* mode. What survives is the part that stopped the worst failure — never state
|
|
42
|
+
* an attribute for someone in the user's data that their record does not carry,
|
|
43
|
+
* and never imply research you did not perform.
|
|
44
|
+
*
|
|
45
|
+
* This is a stepping stone, not the destination. It exists so a live product
|
|
46
|
+
* gets the protection now, without a same-day rewrite of its whole context
|
|
47
|
+
* layer; the destination is typed records here too.
|
|
48
|
+
*/
|
|
49
|
+
export declare function buildAssistantRules(opts: {
|
|
50
|
+
subjectNoun: string;
|
|
51
|
+
}): string;
|
|
52
|
+
/**
|
|
53
|
+
* A deterministic answer computed by the app, not the model.
|
|
54
|
+
*
|
|
55
|
+
* Some questions are not judgment calls at all. "Which goals are stuck at 0%
|
|
56
|
+
* for 30+ days", "what is due in the next 3 days", "which habit is at risk"
|
|
57
|
+
* are SQL predicates with exact answers, and asking a language model to derive
|
|
58
|
+
* them from injected prose is strictly worse than computing them: it can only
|
|
59
|
+
* introduce error. The model's job is to PHRASE the result, not to derive it.
|
|
60
|
+
*
|
|
61
|
+
* `answer` is empty when the query ran and found nothing — which is itself a
|
|
62
|
+
* real, citable answer ("nothing is due"), and crucially different from the
|
|
63
|
+
* query never having run.
|
|
64
|
+
*/
|
|
65
|
+
export type Directive = {
|
|
66
|
+
/** What was asked, in the app's words: "goals stuck 30+ days". */
|
|
67
|
+
question: string;
|
|
68
|
+
/** Computed result lines. Empty array = ran, found nothing. */
|
|
69
|
+
answer: string[];
|
|
70
|
+
/** How it was computed, shown to the model so it can be honest about method. */
|
|
71
|
+
method: string;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Citation handle for a computed answer, parallel to a Fact's [F1].
|
|
75
|
+
*
|
|
76
|
+
* Directives used to be uncitable, and the contract demands a citation for
|
|
77
|
+
* every claim — so a model reporting a computed result had nothing legal to
|
|
78
|
+
* point at and wrote "[no record id]" into the user's answer. That is the
|
|
79
|
+
* harness leaking its own plumbing onto the screen. Give computed answers real
|
|
80
|
+
* ids and the sentence cites [D1] like anything else.
|
|
81
|
+
*/
|
|
82
|
+
export declare function directiveId(index: number): string;
|
|
83
|
+
/**
|
|
84
|
+
* Render computed answers. These are stated as settled, because they are: the
|
|
85
|
+
* model must not re-derive, second-guess, or "improve" them, and an empty
|
|
86
|
+
* result must be reported as an empty result rather than backfilled from the
|
|
87
|
+
* fact set.
|
|
88
|
+
*/
|
|
89
|
+
export declare function renderDirectives(directives: Directive[]): string;
|
|
90
|
+
/**
|
|
91
|
+
* Assemble the full grounded context: contract, computed answers, then records.
|
|
92
|
+
*
|
|
93
|
+
* Order is deliberate and load-bearing. The contract comes FIRST so it frames
|
|
94
|
+
* everything read afterwards, and the records come LAST so they sit closest to
|
|
95
|
+
* the user's question — the position small models weight most heavily.
|
|
96
|
+
*/
|
|
97
|
+
export declare function buildGroundedContext(input: {
|
|
98
|
+
facts: Fact[];
|
|
99
|
+
directives?: Directive[];
|
|
100
|
+
renderedFacts: string;
|
|
101
|
+
}): string;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The grounding contract — the rules block that ships with every turn's facts.
|
|
3
|
+
* MIRRORED MODULE (see core/README.md).
|
|
4
|
+
*
|
|
5
|
+
* Why this is generated rather than a hand-written constant: a standing prose
|
|
6
|
+
* rule ("only use provided context") is a weak signal that models trade away
|
|
7
|
+
* under format pressure. The failure that motivated this harness was exactly
|
|
8
|
+
* that — a prompt demanding "1 focus, 3 tasks, 1 person, under 150 words, no
|
|
9
|
+
* hedging" got four confidently-formatted answers, three of them invented,
|
|
10
|
+
* against a context block that already said "if a question falls outside this
|
|
11
|
+
* context, say so rather than guessing".
|
|
12
|
+
*
|
|
13
|
+
* The lesson: the model did not disobey a rule it forgot. It obeyed the
|
|
14
|
+
* STRONGER of two conflicting instructions — fill five slots — because nothing
|
|
15
|
+
* made the empty slot expressible. So this block does three things a static
|
|
16
|
+
* prompt cannot:
|
|
17
|
+
*
|
|
18
|
+
* 1. Names the exact citation handles that exist this turn, so "cite a fact"
|
|
19
|
+
* is a closed-set choice rather than free text.
|
|
20
|
+
* 2. Names the exact fields that are unrecorded THIS TURN, so the prohibition
|
|
21
|
+
* is concrete ("you have no affiliation for any person here") instead of
|
|
22
|
+
* abstract.
|
|
23
|
+
* 3. Supplies the escape hatch verbatim, so refusing a slot is a cheaper
|
|
24
|
+
* token path than inventing one.
|
|
25
|
+
*/
|
|
26
|
+
import { NOT_RECORDED, unrecordedFields } from "./facts.js";
|
|
27
|
+
/** The exact string the model must emit when a slot cannot be filled. */
|
|
28
|
+
export const NO_BASIS = "Not in your data.";
|
|
29
|
+
/**
|
|
30
|
+
* Build the contract for a specific fact set. Empty fact sets get the strictest
|
|
31
|
+
* form — with nothing retrieved, EVERY answer must be a refusal, and saying so
|
|
32
|
+
* plainly beats hoping the model notices the context block is empty.
|
|
33
|
+
*/
|
|
34
|
+
export function buildContract(facts, directives = []) {
|
|
35
|
+
const ids = [
|
|
36
|
+
...facts.map((f) => `[${f.id}]`),
|
|
37
|
+
...directives.map((_, i) => `[${directiveId(i)}]`),
|
|
38
|
+
].join(" ");
|
|
39
|
+
const gaps = unrecordedFields(facts);
|
|
40
|
+
const rules = [
|
|
41
|
+
"## Grounding contract — this overrides every formatting instruction below",
|
|
42
|
+
"",
|
|
43
|
+
"You are answering from a fixed set of records. They are the ONLY things you know about the operator.",
|
|
44
|
+
"",
|
|
45
|
+
facts.length === 0 && directives.length === 0
|
|
46
|
+
? `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.`
|
|
47
|
+
: `1. Every claim about the operator MUST cite a record id. Legal citations this turn, and no others: ${ids}`,
|
|
48
|
+
`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.`,
|
|
49
|
+
`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.`,
|
|
50
|
+
"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.",
|
|
51
|
+
"5. You have not browsed the web this turn. If asked to research someone, say you cannot and report only what the records hold.",
|
|
52
|
+
"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.",
|
|
53
|
+
];
|
|
54
|
+
if (gaps.length > 0) {
|
|
55
|
+
rules.push("", `Unrecorded in THIS turn's records — you have no value for any of these and must not state one: ${gaps.join(", ")}`);
|
|
56
|
+
}
|
|
57
|
+
return rules.join("\n");
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The subset of the contract that needs no fact ids — for an assistant whose
|
|
61
|
+
* context is still prose (Cat) rather than typed records.
|
|
62
|
+
*
|
|
63
|
+
* Weaker than `buildContract` by construction: without ids there is nothing to
|
|
64
|
+
* cite, so rule 1 cannot exist and the verifier runs in entity-attribution
|
|
65
|
+
* mode. What survives is the part that stopped the worst failure — never state
|
|
66
|
+
* an attribute for someone in the user's data that their record does not carry,
|
|
67
|
+
* and never imply research you did not perform.
|
|
68
|
+
*
|
|
69
|
+
* This is a stepping stone, not the destination. It exists so a live product
|
|
70
|
+
* gets the protection now, without a same-day rewrite of its whole context
|
|
71
|
+
* layer; the destination is typed records here too.
|
|
72
|
+
*/
|
|
73
|
+
export function buildAssistantRules(opts) {
|
|
74
|
+
return [
|
|
75
|
+
"## Grounding rules — these override formatting instructions",
|
|
76
|
+
"",
|
|
77
|
+
`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.`,
|
|
78
|
+
"2. Do not infer an affiliation from a name. A word inside someone's name is not their employer or their city.",
|
|
79
|
+
"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.",
|
|
80
|
+
`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.`,
|
|
81
|
+
"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.",
|
|
82
|
+
"6. A correction is a claim too. If you are correcting yourself, it must be supported by the context or stated as unknown.",
|
|
83
|
+
].join("\n");
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Citation handle for a computed answer, parallel to a Fact's [F1].
|
|
87
|
+
*
|
|
88
|
+
* Directives used to be uncitable, and the contract demands a citation for
|
|
89
|
+
* every claim — so a model reporting a computed result had nothing legal to
|
|
90
|
+
* point at and wrote "[no record id]" into the user's answer. That is the
|
|
91
|
+
* harness leaking its own plumbing onto the screen. Give computed answers real
|
|
92
|
+
* ids and the sentence cites [D1] like anything else.
|
|
93
|
+
*/
|
|
94
|
+
export function directiveId(index) {
|
|
95
|
+
return `D${index + 1}`;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Render computed answers. These are stated as settled, because they are: the
|
|
99
|
+
* model must not re-derive, second-guess, or "improve" them, and an empty
|
|
100
|
+
* result must be reported as an empty result rather than backfilled from the
|
|
101
|
+
* fact set.
|
|
102
|
+
*/
|
|
103
|
+
export function renderDirectives(directives) {
|
|
104
|
+
if (directives.length === 0)
|
|
105
|
+
return "";
|
|
106
|
+
const blocks = directives.map((d, i) => {
|
|
107
|
+
const body = d.answer.length > 0
|
|
108
|
+
? d.answer.map((a) => ` - ${a}`).join("\n")
|
|
109
|
+
: " (none — the query ran and matched nothing)";
|
|
110
|
+
return ` [${directiveId(i)}] ${d.question} [${d.method}]\n${body}`;
|
|
111
|
+
});
|
|
112
|
+
return [
|
|
113
|
+
"## Computed answers — already resolved, do not re-derive",
|
|
114
|
+
"These were computed directly from the database for this turn. They are exact.",
|
|
115
|
+
"Report them as given and cite their id, exactly as you would a record.",
|
|
116
|
+
"Where the result is empty, say so plainly — do not substitute a plausible item from the records.",
|
|
117
|
+
"",
|
|
118
|
+
...blocks,
|
|
119
|
+
].join("\n");
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Assemble the full grounded context: contract, computed answers, then records.
|
|
123
|
+
*
|
|
124
|
+
* Order is deliberate and load-bearing. The contract comes FIRST so it frames
|
|
125
|
+
* everything read afterwards, and the records come LAST so they sit closest to
|
|
126
|
+
* the user's question — the position small models weight most heavily.
|
|
127
|
+
*/
|
|
128
|
+
export function buildGroundedContext(input) {
|
|
129
|
+
return [
|
|
130
|
+
buildContract(input.facts, input.directives ?? []),
|
|
131
|
+
renderDirectives(input.directives ?? []),
|
|
132
|
+
input.facts.length > 0
|
|
133
|
+
? ["## Records", "", input.renderedFacts].join("\n")
|
|
134
|
+
: "## Records\n\n(none retrieved)",
|
|
135
|
+
]
|
|
136
|
+
.filter(Boolean)
|
|
137
|
+
.join("\n\n---\n\n");
|
|
138
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Facts — the unit of grounded context. MIRRORED MODULE (see core/README.md).
|
|
3
|
+
*
|
|
4
|
+
* The problem this solves, concretely. Loki was asked who to contact and
|
|
5
|
+
* answered "Ilya Druzhnikov (UZH)". The stored record is:
|
|
6
|
+
*
|
|
7
|
+
* { displayName: "Ilya Druzhnikov", channels: { whatsapp: "+1650…" } }
|
|
8
|
+
*
|
|
9
|
+
* There is no org field, and the string "UZH" appears nowhere in the operator's
|
|
10
|
+
* data — it is the substring inside dr-UZH-nikov. A keyword match produced an
|
|
11
|
+
* affiliation out of a surname, and prose context gave the model no way to tell
|
|
12
|
+
* that "affiliation" was a field it had never been shown.
|
|
13
|
+
*
|
|
14
|
+
* The fix is representational, not a prompt instruction. A Fact is a RECORD with
|
|
15
|
+
* a DECLARED field set, and every declared field is rendered — including the ones
|
|
16
|
+
* with no value, which render as an explicit `<not recorded>`. A model that reads
|
|
17
|
+
*
|
|
18
|
+
* affiliation: <not recorded>
|
|
19
|
+
*
|
|
20
|
+
* is being told a specific negative, which is far harder to overwrite than the
|
|
21
|
+
* silence of a field that simply wasn't mentioned. Absence becomes evidence.
|
|
22
|
+
*
|
|
23
|
+
* Every fact also carries a short stable id ([F3]) so the answer can cite spans
|
|
24
|
+
* and `verify.ts` can check citations mechanically rather than by vibes.
|
|
25
|
+
*
|
|
26
|
+
* Pure: no DB, no network, no framework. Apps map their rows into Facts via
|
|
27
|
+
* their own adapters (FleetCrown: src/lib/agent/sources; OrangeCat: services/cat/sources).
|
|
28
|
+
*/
|
|
29
|
+
/** A field that is declared for a record kind but has no stored value. */
|
|
30
|
+
export declare const NOT_RECORDED = "<not recorded>";
|
|
31
|
+
/**
|
|
32
|
+
* One grounded record. `fields` must contain an entry for EVERY key in the
|
|
33
|
+
* kind's declared field list — `null` where nothing is stored. Builders should
|
|
34
|
+
* go through `makeFact`, which enforces that against the registry.
|
|
35
|
+
*/
|
|
36
|
+
export type Fact = {
|
|
37
|
+
/** Short citation handle, assigned by `assignFactIds` (F1, F2, …). */
|
|
38
|
+
id: string;
|
|
39
|
+
/** Record kind — must be a key of the FACT_KINDS registry. */
|
|
40
|
+
kind: string;
|
|
41
|
+
/** Human label for the record (a name, a title). Never invented. */
|
|
42
|
+
subject: string;
|
|
43
|
+
/** Declared field → stored value, or null for "nothing stored". */
|
|
44
|
+
fields: Record<string, string | null>;
|
|
45
|
+
/** Where this came from, shown to the model: "people table", "goals table". */
|
|
46
|
+
source: string;
|
|
47
|
+
/**
|
|
48
|
+
* Relevance score when the fact came from similarity search. Absent for facts
|
|
49
|
+
* fetched deterministically (a SQL filter) — those are not ranked, they are
|
|
50
|
+
* simply true, and the distinction matters to the reader.
|
|
51
|
+
*/
|
|
52
|
+
similarity?: number;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* The declared field set per record kind — the SSOT for "what could be known
|
|
56
|
+
* about this kind of thing". Adding a field here makes it render as
|
|
57
|
+
* `<not recorded>` everywhere it is missing, which is the entire anti-invention
|
|
58
|
+
* mechanism: the model can only ever see fields we chose to declare.
|
|
59
|
+
*
|
|
60
|
+
* Deliberately includes fields we do NOT store (a person's `affiliation`,
|
|
61
|
+
* `role`, `employer`). That is not an oversight — those are exactly the
|
|
62
|
+
* attributes models invent, so naming them and marking them unrecorded is the
|
|
63
|
+
* point. Do not "clean up" this list by deleting the empty ones.
|
|
64
|
+
*/
|
|
65
|
+
export declare const FACT_KINDS: Record<string, readonly string[]>;
|
|
66
|
+
/** Field list for a kind; unknown kinds fall back to whatever the fact carries. */
|
|
67
|
+
export declare function declaredFields(kind: string, fallback?: string[]): readonly string[];
|
|
68
|
+
/**
|
|
69
|
+
* Build a Fact with every declared field present. Values not supplied become
|
|
70
|
+
* null (→ `<not recorded>`). Undeclared keys are DROPPED rather than passed
|
|
71
|
+
* through: if a field is worth showing the model it is worth declaring in
|
|
72
|
+
* FACT_KINDS, otherwise the registry stops describing what the model sees.
|
|
73
|
+
*/
|
|
74
|
+
export declare function makeFact(input: {
|
|
75
|
+
kind: string;
|
|
76
|
+
subject: string;
|
|
77
|
+
source: string;
|
|
78
|
+
values?: Record<string, string | null | undefined>;
|
|
79
|
+
similarity?: number;
|
|
80
|
+
}): Fact;
|
|
81
|
+
/** Stamp sequential citation ids. Call once, after assembling the final set. */
|
|
82
|
+
export declare function assignFactIds(facts: Fact[]): Fact[];
|
|
83
|
+
/** Every citation handle in a fact set — the only legal citations in an answer. */
|
|
84
|
+
export declare function factIds(facts: Fact[]): Set<string>;
|
|
85
|
+
/**
|
|
86
|
+
* Render facts for the model. One block per record, every declared field on its
|
|
87
|
+
* own line, unrecorded fields stated explicitly.
|
|
88
|
+
*
|
|
89
|
+
* [F3] person — Elena Weber SINGA Switzerland (people table)
|
|
90
|
+
* name: Elena Weber SINGA Switzerland
|
|
91
|
+
* affiliation: <not recorded>
|
|
92
|
+
* role: <not recorded>
|
|
93
|
+
* channels: whatsapp +41774730093
|
|
94
|
+
*
|
|
95
|
+
* The line-per-field shape matters for small models: a flat prose blob invites
|
|
96
|
+
* summarising (and summarising is where invention creeps in), whereas a field
|
|
97
|
+
* list invites lookup. Observed with 8B models — the same prompt over a blob
|
|
98
|
+
* hallucinates roles, over a field list it reports `<not recorded>`.
|
|
99
|
+
*/
|
|
100
|
+
export declare function renderFacts(facts: Fact[]): string;
|
|
101
|
+
/**
|
|
102
|
+
* Which declared fields are unrecorded across the set, as
|
|
103
|
+
* `kind.field` keys. The contract block names these explicitly so the rule
|
|
104
|
+
* "do not state an affiliation" is anchored to a concrete gap in THIS turn's
|
|
105
|
+
* context rather than being a standing abstraction the model may ignore.
|
|
106
|
+
*/
|
|
107
|
+
export declare function unrecordedFields(facts: Fact[]): string[];
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Facts — the unit of grounded context. MIRRORED MODULE (see core/README.md).
|
|
3
|
+
*
|
|
4
|
+
* The problem this solves, concretely. Loki was asked who to contact and
|
|
5
|
+
* answered "Ilya Druzhnikov (UZH)". The stored record is:
|
|
6
|
+
*
|
|
7
|
+
* { displayName: "Ilya Druzhnikov", channels: { whatsapp: "+1650…" } }
|
|
8
|
+
*
|
|
9
|
+
* There is no org field, and the string "UZH" appears nowhere in the operator's
|
|
10
|
+
* data — it is the substring inside dr-UZH-nikov. A keyword match produced an
|
|
11
|
+
* affiliation out of a surname, and prose context gave the model no way to tell
|
|
12
|
+
* that "affiliation" was a field it had never been shown.
|
|
13
|
+
*
|
|
14
|
+
* The fix is representational, not a prompt instruction. A Fact is a RECORD with
|
|
15
|
+
* a DECLARED field set, and every declared field is rendered — including the ones
|
|
16
|
+
* with no value, which render as an explicit `<not recorded>`. A model that reads
|
|
17
|
+
*
|
|
18
|
+
* affiliation: <not recorded>
|
|
19
|
+
*
|
|
20
|
+
* is being told a specific negative, which is far harder to overwrite than the
|
|
21
|
+
* silence of a field that simply wasn't mentioned. Absence becomes evidence.
|
|
22
|
+
*
|
|
23
|
+
* Every fact also carries a short stable id ([F3]) so the answer can cite spans
|
|
24
|
+
* and `verify.ts` can check citations mechanically rather than by vibes.
|
|
25
|
+
*
|
|
26
|
+
* Pure: no DB, no network, no framework. Apps map their rows into Facts via
|
|
27
|
+
* their own adapters (FleetCrown: src/lib/agent/sources; OrangeCat: services/cat/sources).
|
|
28
|
+
*/
|
|
29
|
+
/** A field that is declared for a record kind but has no stored value. */
|
|
30
|
+
export const NOT_RECORDED = "<not recorded>";
|
|
31
|
+
/**
|
|
32
|
+
* The declared field set per record kind — the SSOT for "what could be known
|
|
33
|
+
* about this kind of thing". Adding a field here makes it render as
|
|
34
|
+
* `<not recorded>` everywhere it is missing, which is the entire anti-invention
|
|
35
|
+
* mechanism: the model can only ever see fields we chose to declare.
|
|
36
|
+
*
|
|
37
|
+
* Deliberately includes fields we do NOT store (a person's `affiliation`,
|
|
38
|
+
* `role`, `employer`). That is not an oversight — those are exactly the
|
|
39
|
+
* attributes models invent, so naming them and marking them unrecorded is the
|
|
40
|
+
* point. Do not "clean up" this list by deleting the empty ones.
|
|
41
|
+
*/
|
|
42
|
+
export const FACT_KINDS = {
|
|
43
|
+
person: ["name", "affiliation", "role", "how_we_met", "last_interaction", "notes", "channels"],
|
|
44
|
+
project: ["name", "status", "stack", "description", "latest_dev_log", "repo"],
|
|
45
|
+
goal: ["title", "project", "progress", "target_date", "last_updated"],
|
|
46
|
+
habit: ["title", "frequency", "current_streak", "last_checked"],
|
|
47
|
+
commitment: ["title", "due", "counterparty", "status"],
|
|
48
|
+
event: ["name", "type", "deadline", "url", "status"],
|
|
49
|
+
// Humans the operator delegates to, and the work handed to them. Separate
|
|
50
|
+
// from `person`/`commitment` because the questions are different: a crew
|
|
51
|
+
// member is asked what they are good FOR, an assignment is asked who has it
|
|
52
|
+
// and whether they said yes.
|
|
53
|
+
crew_member: ["name", "role", "skills", "engagement", "rate", "availability", "open_assignments"],
|
|
54
|
+
assignment: ["title", "assignee", "status", "due", "fee", "why"],
|
|
55
|
+
document: ["title", "source", "excerpt"],
|
|
56
|
+
pending_action: ["title", "type", "reasoning", "proposed_on", "id"],
|
|
57
|
+
};
|
|
58
|
+
/** Field list for a kind; unknown kinds fall back to whatever the fact carries. */
|
|
59
|
+
export function declaredFields(kind, fallback = []) {
|
|
60
|
+
return FACT_KINDS[kind] ?? fallback;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Build a Fact with every declared field present. Values not supplied become
|
|
64
|
+
* null (→ `<not recorded>`). Undeclared keys are DROPPED rather than passed
|
|
65
|
+
* through: if a field is worth showing the model it is worth declaring in
|
|
66
|
+
* FACT_KINDS, otherwise the registry stops describing what the model sees.
|
|
67
|
+
*/
|
|
68
|
+
export function makeFact(input) {
|
|
69
|
+
const keys = declaredFields(input.kind, Object.keys(input.values ?? {}));
|
|
70
|
+
const fields = {};
|
|
71
|
+
for (const key of keys) {
|
|
72
|
+
const raw = input.values?.[key];
|
|
73
|
+
const trimmed = typeof raw === "string" ? raw.trim() : raw;
|
|
74
|
+
fields[key] = trimmed ? String(trimmed) : null;
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
id: "",
|
|
78
|
+
kind: input.kind,
|
|
79
|
+
subject: input.subject,
|
|
80
|
+
source: input.source,
|
|
81
|
+
fields,
|
|
82
|
+
...(input.similarity !== undefined ? { similarity: input.similarity } : {}),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/** Stamp sequential citation ids. Call once, after assembling the final set. */
|
|
86
|
+
export function assignFactIds(facts) {
|
|
87
|
+
return facts.map((f, i) => ({ ...f, id: `F${i + 1}` }));
|
|
88
|
+
}
|
|
89
|
+
/** Every citation handle in a fact set — the only legal citations in an answer. */
|
|
90
|
+
export function factIds(facts) {
|
|
91
|
+
return new Set(facts.map((f) => f.id));
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Render facts for the model. One block per record, every declared field on its
|
|
95
|
+
* own line, unrecorded fields stated explicitly.
|
|
96
|
+
*
|
|
97
|
+
* [F3] person — Elena Weber SINGA Switzerland (people table)
|
|
98
|
+
* name: Elena Weber SINGA Switzerland
|
|
99
|
+
* affiliation: <not recorded>
|
|
100
|
+
* role: <not recorded>
|
|
101
|
+
* channels: whatsapp +41774730093
|
|
102
|
+
*
|
|
103
|
+
* The line-per-field shape matters for small models: a flat prose blob invites
|
|
104
|
+
* summarising (and summarising is where invention creeps in), whereas a field
|
|
105
|
+
* list invites lookup. Observed with 8B models — the same prompt over a blob
|
|
106
|
+
* hallucinates roles, over a field list it reports `<not recorded>`.
|
|
107
|
+
*/
|
|
108
|
+
export function renderFacts(facts) {
|
|
109
|
+
if (facts.length === 0)
|
|
110
|
+
return "";
|
|
111
|
+
return facts
|
|
112
|
+
.map((f) => {
|
|
113
|
+
const head = `[${f.id}] ${f.kind} — ${f.subject} (${f.source})`;
|
|
114
|
+
const body = Object.entries(f.fields).map(([k, v]) => ` ${k}: ${v ?? NOT_RECORDED}`);
|
|
115
|
+
return [head, ...body].join("\n");
|
|
116
|
+
})
|
|
117
|
+
.join("\n\n");
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Which declared fields are unrecorded across the set, as
|
|
121
|
+
* `kind.field` keys. The contract block names these explicitly so the rule
|
|
122
|
+
* "do not state an affiliation" is anchored to a concrete gap in THIS turn's
|
|
123
|
+
* context rather than being a standing abstraction the model may ignore.
|
|
124
|
+
*/
|
|
125
|
+
export function unrecordedFields(facts) {
|
|
126
|
+
const gaps = new Set();
|
|
127
|
+
for (const f of facts) {
|
|
128
|
+
for (const [k, v] of Object.entries(f.fields)) {
|
|
129
|
+
if (v === null)
|
|
130
|
+
gaps.add(`${f.kind}.${k}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return [...gaps].sort();
|
|
134
|
+
}
|
|
@@ -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, type Fact, } from "./facts.js";
|
|
23
|
+
export { NO_BASIS, buildContract, buildAssistantRules, directiveId, renderDirectives, buildGroundedContext, type Directive, } from "./contract.js";
|
|
24
|
+
export { verifyAnswer, buildRepairPrompt, type Violation, type VerifyResult, type VerifyMode, } from "./verify.js";
|