toolnexus 0.16.0 → 0.18.0

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.
@@ -0,0 +1,334 @@
1
+ /**
2
+ * Classifier (SPEC.md §8B) — the contract for a JUDGMENT, as `Tool` is the contract for an
3
+ * ACTION. A System One model takes a state plus pre-declared, typed questions and returns
4
+ * calibrated answers with no free text. It has no messages, no tool calling and no streaming,
5
+ * so it never enters the §8 client loop.
6
+ *
7
+ * A classifier INTERPRETS; it never AUTHORISES. Schema validity is not correctness: a decision
8
+ * can be confidently wrong, and "cannot hallucinate" means only that the returned value is in
9
+ * the declared schema. Numeric limits, permission checks and allowlists stay in code. Nothing
10
+ * here is a security control.
11
+ */
12
+ import type { Client } from "./client.js";
13
+ import { type ErrorInfo, type ErrorTier, type MetricEvent } from "./client.js";
14
+ /** The System One endpoint base. */
15
+ export declare const DEFAULT_CLASSIFIER_BASE_URL = "https://api.typesafe.ai/v1";
16
+ /** The floating model alias. Pin it (e.g. `jev-1.13.0`) once thresholds are tuned. */
17
+ export declare const DEFAULT_CLASSIFIER_MODEL = "jev-latest";
18
+ /** The NAME of the env var holding the credential — never the value. */
19
+ export declare const DEFAULT_CLASSIFIER_API_KEY_ENV = "TYPESAFE_API_KEY";
20
+ /** Bounds ONE request; a classifier has no loop to bound. */
21
+ export declare const DEFAULT_CLASSIFIER_TIMEOUT_MS = 10000;
22
+ /** Client-side cap on a choice's named options (§8B). */
23
+ export declare const MAX_CHOICE_OPTIONS = 255;
24
+ /** Bounds of a score rubric (§8B). */
25
+ export declare const MIN_SCORE_LEVELS = 2;
26
+ export declare const MAX_SCORE_LEVELS = 10;
27
+ /**
28
+ * The ABSOLUTE tolerance on `max|p - 1/n|`, compared INCLUSIVELY (§8B). Pinned across every
29
+ * port; `examples/judge/near-uniform.json` pins both sides at 0.0499 / 0.0501.
30
+ */
31
+ export declare const NEAR_UNIFORM_TOLERANCE = 0.05;
32
+ /**
33
+ * Labels for the true and false cases of a noul question. Absent and empty are DIFFERENT values
34
+ * and both are preserved on the wire: omit `criteria` entirely and the field is absent; pass
35
+ * `{ true: "", false: "" }` and it is emitted with empty values.
36
+ */
37
+ export interface NoulCriteria {
38
+ true: string;
39
+ false: string;
40
+ }
41
+ /** The probability that a statement holds, one number in 0..1. It carries NO confidence. */
42
+ export interface NoulQuestion {
43
+ type: "noul";
44
+ instructions: string;
45
+ /** Optional. Omitted ⇒ the field is ABSENT from the request, not null and not {}. */
46
+ criteria?: NoulCriteria;
47
+ }
48
+ /**
49
+ * One option from a named set, 1..255 options.
50
+ *
51
+ * THE ENCODING OBLIGATION IS THE CALLER'S (§8B, docs/adr/0021 D1): `criteria[id]` is the only
52
+ * thing that differentiates one option from another to the model. Passing the id itself, an
53
+ * empty string, or one value repeated is schema-valid, returns HTTP 200 and a well-formed
54
+ * distribution — and ranks at chance (measured: 17 apples described by consequence, 0/1/0
55
+ * described by id).
56
+ */
57
+ export interface ChoiceQuestion {
58
+ type: "choice";
59
+ instructions: string;
60
+ /** option id -> what picking it would MEAN. */
61
+ criteria: Record<string, string>;
62
+ }
63
+ /**
64
+ * A rating against an ORDERED rubric of 2..10 levels. The array order IS the level numbering,
65
+ * so it is never sorted — a "sort everything" canonicaliser silently renumbers the rubric.
66
+ */
67
+ export interface ScoreQuestion {
68
+ type: "score";
69
+ instructions: string;
70
+ criteria: string[];
71
+ }
72
+ /** The closed set of question types, discriminated by `type`. */
73
+ export type Question = NoulQuestion | ChoiceQuestion | ScoreQuestion;
74
+ /** A noul question. Pass `criteria` only when you mean it — absent ≠ empty on the wire. */
75
+ export declare function noul(instructions: string, criteria?: NoulCriteria): NoulQuestion;
76
+ /** A choice question. Every description must say what picking that option would MEAN. */
77
+ export declare function choice(instructions: string, criteria: Record<string, string>): ChoiceQuestion;
78
+ /** A score question. The array order IS the level numbering and is never reordered. */
79
+ export declare function score(instructions: string, criteria: string[]): ScoreQuestion;
80
+ /**
81
+ * Build a choice from any (name, description) pairs — a Tool, a skill, an agent, an A2A card
82
+ * skill. The description must say what picking that option would MEAN; see the encoding
83
+ * obligation on `ChoiceQuestion`.
84
+ */
85
+ export declare function choiceOver(instructions: string, items: Record<string, string>): ChoiceQuestion;
86
+ /**
87
+ * Canonical JSON: object keys sorted recursively in CODE-UNIT (ASCII) order, arrays NEVER
88
+ * reordered, compact separators, and `<>&'"` plus non-ASCII transmitted raw.
89
+ *
90
+ * Hand-rolled because `JSON.stringify` has no recursive key sort, and its `replacer` ARRAY form
91
+ * applies one key list at EVERY depth — it cannot express "sort whatever keys are here". The
92
+ * per-value escaping and number formatting ARE delegated to `JSON.stringify`, which emits the
93
+ * shortest round-tripping decimal (so 1.21 is "1.21" and integer 0 is "0", not "0.0") and does
94
+ * not escape `<`, `>`, `&` or non-ASCII.
95
+ *
96
+ * `undefined` is ABSENT, never `null`: an object key whose value is undefined is dropped.
97
+ */
98
+ export declare function canonicalJson(value: unknown): string;
99
+ /**
100
+ * The bytes the byte-identity claim covers: `model` + `questions`.
101
+ *
102
+ * `state` is deliberately NOT here. It is transmitted verbatim as the host supplied it and is
103
+ * OUTSIDE the claim, because numbers do not canonicalise across languages (`-0.0` renders four
104
+ * ways across our own seven runtimes). Do not re-widen this: a caller who needs their state
105
+ * pinned canonicalises it themselves before handing it over.
106
+ */
107
+ export declare function canonicalRequest(model: string, questions: Record<string, Question>): string;
108
+ /** The probability that a statement holds. NO confidence: the number is the answer. */
109
+ export interface NoulAnswer {
110
+ type: "noul";
111
+ noul: number;
112
+ }
113
+ /** One option from the offered set, with a probability for EVERY offered option. */
114
+ export interface ChoiceAnswer {
115
+ type: "choice";
116
+ choice: string;
117
+ probabilities: Record<string, number>;
118
+ confidence: number;
119
+ /**
120
+ * DERIVED from `probabilities` on decode and never read from the wire (§8B):
121
+ * `max|p - 1/n| <= 0.05`, inclusive, `n` = the number of entries in the map.
122
+ *
123
+ * ADVISORY, NOT a correctness signal. It detects an encoding that gave the model nothing to
124
+ * rank on — it cannot distinguish a good encoding from a subtly wrong one. `calibrated`
125
+ * carries the same caveat.
126
+ */
127
+ nearUniform: boolean;
128
+ }
129
+ /** A rating against the ordered rubric. `score` MAY fall between levels (1.21 is a real answer). */
130
+ export interface ScoreAnswer {
131
+ type: "score";
132
+ score: number;
133
+ legend: Record<string, string>;
134
+ probabilities: Record<string, number>;
135
+ confidence: number;
136
+ }
137
+ /**
138
+ * One typed answer, discriminated by `type`. Named `DecisionAnswer` because §10 already owns
139
+ * `Answer` (the suspension resolution) — same idea, different seam.
140
+ */
141
+ export type DecisionAnswer = NoulAnswer | ChoiceAnswer | ScoreAnswer;
142
+ /** The wire's usage block. `cost` is absent on some backends. */
143
+ export interface ClassifierUsage {
144
+ inputTokens: number;
145
+ outputTokens: number;
146
+ cost?: number;
147
+ }
148
+ /** The legend in LEVEL order, which the map itself loses ("2" sorts before "10" as a level). */
149
+ export declare function levels(answer: ScoreAnswer): string[];
150
+ /**
151
+ * One answer per question, keyed by the CALLER's keys. The keys are addressing, not content:
152
+ * they are never transmitted, so a key may be a tool, skill or agent name verbatim.
153
+ */
154
+ export declare class Decision {
155
+ /** What actually answered, which may be more specific than the model that was asked for. */
156
+ readonly model: string;
157
+ readonly answers: Record<string, DecisionAnswer>;
158
+ readonly usage: ClassifierUsage;
159
+ /**
160
+ * Whether the probabilities are calibrated. `systemone` reports true; `llm` reports false
161
+ * unless it derived them from provider token probabilities. A THRESHOLD TUNED AGAINST ONE
162
+ * BACKEND DOES NOT TRANSFER TO ANOTHER.
163
+ */
164
+ readonly calibrated: boolean;
165
+ constructor(
166
+ /** What actually answered, which may be more specific than the model that was asked for. */
167
+ model: string, answers: Record<string, DecisionAnswer>, usage: ClassifierUsage,
168
+ /**
169
+ * Whether the probabilities are calibrated. `systemone` reports true; `llm` reports false
170
+ * unless it derived them from provider token probabilities. A THRESHOLD TUNED AGAINST ONE
171
+ * BACKEND DOES NOT TRANSFER TO ANOTHER.
172
+ */
173
+ calibrated: boolean);
174
+ /** Typed read. Throws (never returns undefined) on a wrong-type or absent key. */
175
+ noul(key: string): NoulAnswer;
176
+ choice(key: string): ChoiceAnswer;
177
+ score(key: string): ScoreAnswer;
178
+ private typed;
179
+ }
180
+ /**
181
+ * Whether a choice answer's probability map is indistinguishable from flat (§8B):
182
+ *
183
+ * nearUniform ⇔ max over i of |p_i - 1/n| <= 0.05
184
+ *
185
+ * `n` is the number of ENTRIES IN THE MAP and the values are taken AS RETURNED — not
186
+ * renormalised, not sorted, not rounded; an offered option absent from the map counts as 0 by
187
+ * not being an entry. The tolerance is ABSOLUTE (a relative band collapses below the wire's
188
+ * two-decimal rounding on a 255-option roster) and the comparison is INCLUSIVE. `n === 1` is
189
+ * trivially uniform. An empty map has no distribution at all and is reported false.
190
+ */
191
+ export declare function nearUniform(probabilities: Record<string, number>): boolean;
192
+ /**
193
+ * Decode a backend response into a Decision. The discriminated decode is the single place
194
+ * `nearUniform` is derived. A malformed answer is an error, never a repaired one (ADR 0020).
195
+ */
196
+ export declare function decodeDecision(raw: unknown, fallbackModel: string): Decision;
197
+ /** `"systemone" | "llm" | "custom" | "static"` — the same slot as the client's `style`. */
198
+ export type ClassifierStyle = "systemone" | "llm" | "custom" | "static";
199
+ /**
200
+ * One entry of the `static` corpus. It is keyed on the canonical request AND the state: several
201
+ * recorded entries legitimately share one questions payload and differ only in state (the three
202
+ * guard bands of `examples/judge/decisions.json` do exactly that), so a corpus keyed on the
203
+ * canonical request alone cannot tell them apart.
204
+ */
205
+ export interface RecordedDecision {
206
+ state: unknown;
207
+ questions: Record<string, Question>;
208
+ /** The recorded backend response body, verbatim. */
209
+ response: unknown;
210
+ }
211
+ /** The host's own backend (`style: "custom"`). Every wire option is ignored. */
212
+ export type EvaluateFn = (state: unknown, questions: Record<string, Question>, signal?: AbortSignal) => Promise<Decision> | Decision;
213
+ /**
214
+ * Mirrors §8 `ClientOptions` field-for-field wherever a field makes sense, so a host that has
215
+ * configured one has configured the other.
216
+ */
217
+ export interface ClassifierOptions {
218
+ /** Default `"systemone"`. */
219
+ style?: ClassifierStyle;
220
+ /** Default `https://api.typesafe.ai/v1`. OpenRouter serves this wire today. */
221
+ baseUrl?: string;
222
+ /** Default `"jev-latest"`. Pin it once thresholds are tuned. */
223
+ model?: string;
224
+ /** The NAME of the env var holding the credential, never the value — read at call time and
225
+ * never logged. §8's `apiKey` takes a value; this option deliberately does not. */
226
+ apiKeyEnv?: string;
227
+ /** Extra headers. Values expand `${ENV_VAR}` from the environment AT CALL TIME and are NEVER
228
+ * logged, identically to remote-MCP headers (§2). */
229
+ headers?: Record<string, string>;
230
+ /** Bounds ONE request (a classifier has no loop to bound). Default 10 s. */
231
+ timeoutMs?: number;
232
+ /** §8 Gap 2 injectable transport. Scope is the classifier path only. */
233
+ fetch?: typeof fetch;
234
+ /** Retries on transient errors (`408`/`429`/`500`/`502`/`503`/`504`/`529` + network). Default 2.
235
+ * Widen the status set with `retryableStatuses`. */
236
+ retries?: number;
237
+ /** Base backoff in ms (exponential). Default 500. */
238
+ retryBaseMs?: number;
239
+ /**
240
+ * Extra HTTP statuses to treat as retryable, ADDED to the default set
241
+ * (`429`/`500`/`502`/`503`/`504`/`529` plus `408` here). It can only widen: a host cannot remove `429` and
242
+ * lose `Retry-After` handling with it. This sets the DEFAULT classification; `onError` still
243
+ * runs per attempt and has the final say, so `onError` returning `"fail"` overrides a status
244
+ * listed here. Example: a Cloudflare-fronted origin that answers `520`–`527`.
245
+ */
246
+ retryableStatuses?: readonly number[];
247
+ /** §8 Resilience. REUSES the client's `ErrorInfo`/`ErrorTier` and the `Retry-After`
248
+ * delay-seconds rule verbatim — there is no second retry policy, and no `"suspend"` tier. */
249
+ onError?: (info: ErrorInfo) => ErrorTier;
250
+ /** §8 Gap 1. Extra top-level keys shallow-merged into the body after the classifier builds its
251
+ * own; a `requestParams` key WINS on collision. Omit ⇒ body byte-identical. */
252
+ requestParams?: Record<string, unknown>;
253
+ /** §8 Gap 1. Receives the assembled body after the `requestParams` merge and returns the body
254
+ * to send. Order: base body → requestParams merge → bodyTransform → marshal, exactly as §8. */
255
+ bodyTransform?: (body: Record<string, any>) => Record<string, any> | undefined | void;
256
+ /** Emits `classifier.evaluate` events into the SAME §8 sink, and carries the
257
+ * degenerate-criteria warning as `classifier.warning`. */
258
+ onMetric?: (ev: MetricEvent) => void;
259
+ /** `style: "llm"` only — the §8 Client to emulate over. */
260
+ client?: Client;
261
+ /** `style: "custom"` only — the host's own function. */
262
+ evaluate?: EvaluateFn;
263
+ /** `style: "static"` only — the recorded corpus. */
264
+ decisions?: RecordedDecision[];
265
+ }
266
+ /** The whole seam: one verb. */
267
+ export declare class Classifier {
268
+ private readonly opts;
269
+ private readonly style;
270
+ private readonly baseUrl;
271
+ private readonly model;
272
+ private readonly apiKeyEnv;
273
+ private readonly timeoutMs;
274
+ private readonly corpus;
275
+ /** The once-per-question-key set for the degenerate warning, so a per-turn judge does not
276
+ * flood the sink. */
277
+ private readonly warned;
278
+ constructor(opts: ClassifierOptions);
279
+ /**
280
+ * The whole contract: a state plus typed questions in, a Decision out. Questions are
281
+ * INDEPENDENT — one answer is never context for another.
282
+ */
283
+ evaluate(state: unknown, questions: Record<string, Question>, signal?: AbortSignal): Promise<Decision>;
284
+ /**
285
+ * Emits ONE warning per degenerate question key per classifier, naming the key, and changes
286
+ * nothing about the request. Repairing would invent option descriptions the caller did not
287
+ * write, and the library has no way to know what the options mean.
288
+ */
289
+ private reportDegenerate;
290
+ /**
291
+ * Assembles the request: the canonical model + questions, plus state VERBATIM as the host
292
+ * supplied it, then the §8 Gap 1 pipeline in §8 order (base → requestParams merge →
293
+ * bodyTransform → marshal).
294
+ */
295
+ private body;
296
+ /** Identifies a recorded decision by the canonical request AND the state. */
297
+ private staticKey;
298
+ private evaluateStatic;
299
+ private evaluateSystemOne;
300
+ /**
301
+ * The one POST, with the §8 retry budget, reusing the client's `ErrorInfo`/`ErrorTier`
302
+ * classifier and the `Retry-After` delay-seconds rule verbatim.
303
+ *
304
+ * NO CREDENTIAL VALUE AND NO EXPANDED HEADER VALUE APPEARS ON ANY PATH OUT OF HERE: an
305
+ * authentication failure names the status and the endpoint, nothing else, and a 401/403 body
306
+ * is never echoed back (a gateway happily reflects a bad Authorization header into its own
307
+ * 401 text).
308
+ */
309
+ private post;
310
+ /** Credential and `${ENV_VAR}` headers resolve AT CALL TIME. Never logged, never returned. */
311
+ private requestHeaders;
312
+ /**
313
+ * The three question types rendered as ONE structured-output call on any §8 Client — the
314
+ * vendor-neutral fallback, so a host with no System One credential runs the same questions on
315
+ * a cheap chat model. `calibrated` is FALSE: the numbers are the model's self-report, not
316
+ * token probabilities.
317
+ */
318
+ private evaluateLLM;
319
+ private emit;
320
+ private emitError;
321
+ }
322
+ /** Builds a Classifier, applying the §8B defaults and rejecting a style whose required option
323
+ * is missing before any call is made. */
324
+ export declare function createClassifier(opts?: ClassifierOptions): Classifier;
325
+ /**
326
+ * The §8B predicate. Degenerate ⇔ ANY of:
327
+ * 1. every value is empty (empty string or absent), or
328
+ * 2. every value equals its own key, or
329
+ * 3. every value is identical to every other value (n >= 2).
330
+ *
331
+ * A single-option choice (n === 1) is NEVER reported: there is nothing to differentiate. Returns
332
+ * the reason, or "" when the criteria are fine.
333
+ */
334
+ export declare function degenerateCriteria(criteria: Record<string, string>): string;