toolnexus 0.25.0 → 0.27.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.
package/README.md CHANGED
@@ -136,6 +136,41 @@ const out = await gate(c, state, questions, [
136
136
  filter, content and model batteries into `hooks`. A `beforeLLM` hook may return `{ model }` to
137
137
  send another model for that turn only.
138
138
 
139
+ ### Backends, images, cache and helpers
140
+
141
+ Every default below is a **proposed default, owner to confirm**
142
+ (`openspec/changes/add-classifier-backends`).
143
+
144
+ ```ts
145
+ import { createClassifier, judge, MemoryCache, validateDecision, thresholdBand, chunkQuestions } from "toolnexus"
146
+
147
+ const c = createClassifier({ backend: "openrouter", cache: new MemoryCache(3_600_000, 1000) })
148
+ const d = await c.state({ note: "listing", photo: { type: "image", mimeType: "image/png", data: b64 } })
149
+ .evaluate(judge.noul("red", "Is the object in `photo` red?")) // fails closed on an invalid reply
150
+ const rs = await c.states("a", "b").evaluate(judge.noul("q", "…")) // [{ decision } | { error }], in order
151
+ ```
152
+
153
+ - `backend`: `typesafe` (default) · `openrouter` (`typesafe/jev-1.14`) · `cloudflare` (`/ai/run`
154
+ envelope; `${CLOUDFLARE_ACCOUNT_ID}` in the URL expands at call time, errors name it unexpanded)
155
+ · `vercel` · `custom` (requires `baseUrl` and `supports`). The table is data:
156
+ `CLASSIFIER_BACKENDS`; `supports: { images }` overrides it per classifier.
157
+ - Images (style `systemone` only): image parts in the state are lifted into `assets` (cloudflare:
158
+ data URLs). PNG/JPEG, 280–1568 px a side, ≤ 16 MiB each and 32 MiB per request — never resized.
159
+ A backend without image support throws `ClassifierUnsupported` before any request. A state with
160
+ no content part is sent byte-identically to before. JS has no distinct part type, so **any**
161
+ object shaped like a `ContentPart` (`{type:"text",text}`, `{type:"image",mimeType,…}`) inside a
162
+ systemone state is treated as a part.
163
+ - `cache: { get(key), set(key, value) }` (sync or async, string values) — off when absent; only
164
+ valid replies are stored; keys follow the served model. A hit sets `decision.cached` and the
165
+ `classifier.evaluate` event's `cached`. Every such event also carries `questions` and
166
+ `questionsHash`.
167
+ - Helpers: `validateDecision(questions, decision)` → `Error | undefined`; `thresholdBand(answer,
168
+ thresholds?)` with `DEFAULT_THRESHOLDS` 0.8 / 0.2 / 0.6 (inclusive; `Bands` stays the `ask`/`gate`
169
+ default); `chunkQuestions(questions, size)`.
170
+ - Open union: `judge.raw(name, type, instructions, criteria?)` / `raw(...)`; an answer of an
171
+ unmodelled type decodes to `{ type, value }` (`decision.raw(key)`).
172
+ - `style: "llm"` is **deprecated** — it still works unchanged, with no runtime warning.
173
+
139
174
  ## Media generation
140
175
 
141
176
  Generate images, video and speech (plus transcription, voice cloning and talking-head video)
package/dist/acp.d.ts CHANGED
@@ -48,15 +48,23 @@ export interface ACPOptions {
48
48
  /** One warm ACP connection: one child process, one session, alive across many
49
49
  * turns. `.generate` has exactly the shape {@link InProcessOptions.generate}
50
50
  * wants, so it plugs straight into {@link createInProcessClient}. */
51
+ /** Per-call options for {@link ACPClient.generate} / {@link ACPClient.generateStream}. */
52
+ export interface ACPGenerateOptions {
53
+ /** Cancels the turn: when it aborts mid-turn the `session/cancel` notification is sent,
54
+ * the turn's reply is awaited (bounded by `requestTimeoutMs`, default 30s) so the next
55
+ * turn never lands on a busy session, and the call rejects with an `AbortError`. The
56
+ * process and session stay warm. */
57
+ signal?: AbortSignal;
58
+ }
51
59
  export interface ACPClient {
52
- generate: (request: InProcessRequest) => Promise<InProcessResponse>;
60
+ generate: (request: InProcessRequest, opts?: ACPGenerateOptions) => Promise<InProcessResponse>;
53
61
  /** {@link generate}, streaming: `emit` fires as each `session/update` arrives —
54
62
  * agent_message_chunk → `text`, agent_thought_chunk → `thought` (via "acp"),
55
63
  * tool_call → `tool_call` and a completed/failed tool_call_update → `tool_result`
56
64
  * (both via "acp": the agent's own tools, reported, never executed by the loop).
57
65
  * Shaped for {@link InProcessOptions.generateStream}. The returned content is the
58
66
  * message text only. */
59
- generateStream: (request: InProcessRequest, emit: (event: StreamEvent) => void) => Promise<InProcessResponse>;
67
+ generateStream: (request: InProcessRequest, emit: (event: StreamEvent) => void, opts?: ACPGenerateOptions) => Promise<InProcessResponse>;
60
68
  /** Terminates the agent's process group. Idempotent — a second call is a no-op. */
61
69
  close: () => Promise<void>;
62
70
  /** The agent's raw `initialize` result. */
package/dist/acp.js CHANGED
@@ -298,7 +298,9 @@ export async function loadACP(opts) {
298
298
  // each generate() call attaches after whatever is already queued, and the
299
299
  // queue itself never rejects (a failed turn does not poison later ones).
300
300
  let turnQueue = Promise.resolve();
301
- async function promptOnce(request, emit) {
301
+ async function promptOnce(request, emit, signal) {
302
+ if (signal?.aborted)
303
+ throw acpAbortError();
302
304
  const text = renderPrompt(request);
303
305
  const id = `c-${++nextId}`;
304
306
  let content = "";
@@ -361,8 +363,30 @@ export async function loadACP(opts) {
361
363
  const timeout = new Promise((_, reject) => {
362
364
  timer = setTimeout(() => reject(new Error(`toolnexus: ACP turn timed out after ${permissionTimeoutMs}ms`)), permissionTimeoutMs);
363
365
  });
366
+ let onAbort;
367
+ const cancelled = new Promise((resolve) => {
368
+ if (!signal)
369
+ return;
370
+ onAbort = () => resolve("cancelled");
371
+ signal.addEventListener("abort", onAbort, { once: true });
372
+ });
364
373
  try {
365
- const msg = await Promise.race([replyPromise, timeout]);
374
+ const msg = await Promise.race([replyPromise, timeout, cancelled]);
375
+ if (msg === "cancelled") {
376
+ // ACP: session/cancel is a notification (no id); the agent then ends the turn with
377
+ // stopReason "cancelled". Wait (bounded) for that reply so the session is idle again.
378
+ write({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId } });
379
+ const waitMs = opts.requestTimeoutMs && opts.requestTimeoutMs > 0 ? opts.requestTimeoutMs : 30000;
380
+ let wait;
381
+ await Promise.race([
382
+ replyPromise,
383
+ new Promise((r) => {
384
+ wait = setTimeout(r, waitMs);
385
+ }),
386
+ ]);
387
+ clearTimeout(wait);
388
+ throw acpAbortError();
389
+ }
366
390
  if (msg.error && msg.error.code === 0)
367
391
  throw new Error(msg.error.message);
368
392
  if (msg.error)
@@ -371,14 +395,16 @@ export async function loadACP(opts) {
371
395
  }
372
396
  finally {
373
397
  clearTimeout(timer);
398
+ if (onAbort)
399
+ signal.removeEventListener("abort", onAbort);
374
400
  pending.delete(id);
375
401
  updateSink = null;
376
402
  }
377
403
  }
378
- const generateStream = (request, emit) => {
404
+ const generateStream = (request, emit, callOpts) => {
379
405
  if (closed)
380
406
  return Promise.reject(new Error("toolnexus: ACP client is closed"));
381
- const turn = turnQueue.then(() => promptOnce(request, emit));
407
+ const turn = turnQueue.then(() => promptOnce(request, emit, callOpts?.signal));
382
408
  turnQueue = turn.then(() => undefined, () => undefined);
383
409
  return turn;
384
410
  };
@@ -400,13 +426,18 @@ export async function loadACP(opts) {
400
426
  if (t === "late")
401
427
  await Promise.race([childGone, new Promise((r) => setTimeout(r, 1000).unref())]);
402
428
  }
403
- const generate = (request) => generateStream(request);
429
+ const generate = (request, callOpts) => generateStream(request, undefined, callOpts);
404
430
  const client = { generate, generateStream, close, initializeResult };
405
431
  // Non-enumerable, undocumented — internal test hook only, so the public
406
432
  // ACPClient surface stays exactly {generate, generateStream, close}.
407
433
  Object.defineProperty(client, "_pid", { value: child.pid, enumerable: false });
408
434
  return client;
409
435
  }
436
+ function acpAbortError() {
437
+ const e = new Error("toolnexus: ACP turn cancelled");
438
+ e.name = "AbortError";
439
+ return e;
440
+ }
410
441
  /** Signals the child's whole process group (POSIX), else the child itself. Never throws. */
411
442
  function killGroup(pid, signal) {
412
443
  if (process.platform !== "win32") {
@@ -1,14 +1,3 @@
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
1
  import type { Client } from "./client.js";
13
2
  import { type ErrorInfo, type ErrorTier, type MetricEvent } from "./client.js";
14
3
  /** The System One endpoint base. */
@@ -24,15 +13,57 @@ export declare const DEFAULT_BATCH_CONCURRENCY = 16;
24
13
  /**
25
14
  * A named BACKEND: `baseUrl`, `model` and `apiKeyEnv` are only jointly valid, so they travel as
26
15
  * a UNIT (ADR 0027 D1). `jev-latest` is TypeSafe's spelling; the gateway spells the same model
27
- * `typesafe/jev-1.13`, and mixing the two is issue #91.
16
+ * `typesafe/jev-1.14`, and mixing the two is issue #91. `custom` is a self-hosted System One
17
+ * endpoint and requires `baseUrl` + `supports` (SPEC §8B "Backends, images, cache and validation").
28
18
  */
29
- export type ClassifierBackend = "typesafe" | "openrouter";
30
- /** The two servable pairings, as configurations rather than six independently-choosable cells. */
31
- export declare const CLASSIFIER_BACKENDS: Readonly<Record<ClassifierBackend, {
19
+ export type ClassifierBackend = "typesafe" | "openrouter" | "cloudflare" | "vercel" | "custom";
20
+ /** What a backend+model can take. DATA: a fail-fast for what the table says, never a claim about
21
+ * the vendor. Override per classifier with `ClassifierOptions.supports`. */
22
+ export interface ClassifierSupports {
23
+ images: boolean;
24
+ }
25
+ /** One row of the backend table. */
26
+ export interface ClassifierBackendPreset {
27
+ /** `""` on `custom`, which requires `baseUrl`. May carry `${NAME}`, expanded at call time. */
32
28
  baseUrl: string;
33
29
  model: string;
34
30
  apiKeyEnv: string;
35
- }>>;
31
+ /** `systemone`: POST {baseUrl}/systemone. `cloudflare`: POST {baseUrl}, `/ai/run` envelope. */
32
+ wire: "systemone" | "cloudflare";
33
+ /** Send `TypeSafe-Danger-Zone-Images: true` when a request carries images. */
34
+ imagesHeader: boolean;
35
+ /** The default support for a model on this backend; absent on `custom`. */
36
+ supports?: (model: string) => ClassifierSupports;
37
+ }
38
+ /** TypeSafe's required opt-in header for image ("danger zone") input. */
39
+ export declare const CLASSIFIER_IMAGES_HEADER = "TypeSafe-Danger-Zone-Images";
40
+ /** Image limits (docs.typesafe.ai/images): checked client-side, never resized. */
41
+ export declare const CLASSIFIER_IMAGE_MIN_SIDE = 280;
42
+ export declare const CLASSIFIER_IMAGE_MAX_SIDE = 1568;
43
+ export declare const CLASSIFIER_IMAGE_MAX_BYTES: number;
44
+ export declare const CLASSIFIER_REQUEST_IMAGE_BYTES: number;
45
+ /**
46
+ * The backend table (PROPOSED defaults, owner to confirm — design.md PD-1..PD-4). Vercel and
47
+ * Cloudflare-Jev images stay false until a live probe proves them.
48
+ */
49
+ export declare const CLASSIFIER_BACKENDS: Readonly<Record<ClassifierBackend, Readonly<ClassifierBackendPreset>>>;
50
+ /**
51
+ * The env var whose key may be sent to `baseUrl` (already `${NAME}`-expanded): the backend's own
52
+ * variable, and only when `baseUrl` is that backend's own host over https (OpenRouter also its
53
+ * subdomains). `""` when none may — `custom` has no preset key at all. A key is never sent to a
54
+ * host the caller did not name (#155, SPEC §8B).
55
+ */
56
+ export declare function classifierKeyEnv(backend: ClassifierBackend, baseUrl: string): string;
57
+ /**
58
+ * Raised BEFORE any network call when the backend table says the backend cannot take what the
59
+ * request carries. Message, exact: `classifier: <backend> (<model>) does not support <feature>`.
60
+ */
61
+ export declare class ClassifierUnsupported extends Error {
62
+ readonly backend: string;
63
+ readonly model: string;
64
+ readonly feature: string;
65
+ constructor(backend: string, model: string, feature: string);
66
+ }
36
67
  /** Client-side cap on a choice's named options (§8B). */
37
68
  export declare const MAX_CHOICE_OPTIONS = 255;
38
69
  /** Bounds of a score rubric (§8B). */
@@ -83,8 +114,25 @@ export interface ScoreQuestion {
83
114
  instructions: string;
84
115
  criteria: string[];
85
116
  }
86
- /** The closed set of question types, discriminated by `type`. */
117
+ /** The modelled question types, discriminated by `type`. */
87
118
  export type Question = NoulQuestion | ChoiceQuestion | ScoreQuestion;
119
+ /**
120
+ * The OPEN end of the question union (§8B): a System One type the library does not model (e.g.
121
+ * Jev 1.14 `box`), sent as written with no client-side limits. `type` must be non-empty.
122
+ */
123
+ export interface RawQuestion {
124
+ type: string;
125
+ instructions: string;
126
+ /** Sent verbatim when present, absent otherwise. */
127
+ criteria?: unknown;
128
+ }
129
+ /** Every question `evaluate` accepts: the modelled three plus a raw one. */
130
+ export type AnyQuestion = Question | RawQuestion;
131
+ /** True when `q` is a raw question (its type is not noul/choice/score). Not a type guard: every
132
+ * modelled question is structurally a `RawQuestion` too. */
133
+ export declare function isRawQuestion(q: AnyQuestion): boolean;
134
+ /** A raw question, sent as written. */
135
+ export declare function raw(type: string, instructions: string, criteria?: unknown): RawQuestion;
88
136
  /** A noul question. Pass `criteria` only when you mean it — absent ≠ empty on the wire. */
89
137
  export declare function noul(instructions: string, criteria?: NoulCriteria): NoulQuestion;
90
138
  /** A choice question. Every description must say what picking that option would MEAN. */
@@ -98,7 +146,7 @@ export declare function score(instructions: string, criteria: string[]): ScoreQu
98
146
  */
99
147
  export declare function choiceOver(instructions: string, items: Record<string, string>): ChoiceQuestion;
100
148
  /** The wire form of one question — the object itself, minus an absent `criteria`. */
101
- export declare function questionWire(q: Question): Record<string, unknown>;
149
+ export declare function questionWire(question: AnyQuestion): Record<string, unknown>;
102
150
  /**
103
151
  * Canonical JSON: object keys sorted recursively in CODE-UNIT (ASCII) order, arrays NEVER
104
152
  * reordered, compact separators, and `<>&'"` plus non-ASCII transmitted raw.
@@ -120,7 +168,7 @@ export declare function canonicalJson(value: unknown): string;
120
168
  * ways across our own seven runtimes). Do not re-widen this: a caller who needs their state
121
169
  * pinned canonicalises it themselves before handing it over.
122
170
  */
123
- export declare function canonicalRequest(model: string, questions: Record<string, Question>): string;
171
+ export declare function canonicalRequest(model: string, questions: Record<string, AnyQuestion>): string;
124
172
  /** The probability that a statement holds. NO confidence: the number is the answer. */
125
173
  export interface NoulAnswer {
126
174
  type: "noul";
@@ -155,6 +203,18 @@ export interface ScoreAnswer {
155
203
  * `Answer` (the suspension resolution) — same idea, different seam.
156
204
  */
157
205
  export type DecisionAnswer = NoulAnswer | ChoiceAnswer | ScoreAnswer;
206
+ /**
207
+ * An answer of a type the library does not model (the open union, §8B). It may appear in
208
+ * `Decision.answers` at runtime (read it with `decision.raw(key)` or `isRawAnswer`); `value` is the
209
+ * whole answer object as decoded, `type` included. It is kept out of `DecisionAnswer` so existing
210
+ * `switch (a.type)` narrowing keeps compiling.
211
+ */
212
+ export interface RawAnswer {
213
+ type: string;
214
+ value: Record<string, unknown>;
215
+ }
216
+ /** True when an answer is a raw (unmodelled-type) answer. */
217
+ export declare function isRawAnswer(a: unknown): a is RawAnswer;
158
218
  /** The wire's usage block. `cost` is absent on some backends. */
159
219
  export interface ClassifierUsage {
160
220
  inputTokens: number;
@@ -178,6 +238,8 @@ export declare class Decision {
178
238
  * BACKEND DOES NOT TRANSFER TO ANOTHER.
179
239
  */
180
240
  readonly calibrated: boolean;
241
+ /** True when the decision came from `ClassifierOptions.cache` and no request was sent. */
242
+ readonly cached: boolean;
181
243
  constructor(
182
244
  /** What actually answered, which may be more specific than the model that was asked for. */
183
245
  model: string, answers: Record<string, DecisionAnswer>, usage: ClassifierUsage,
@@ -186,7 +248,11 @@ export declare class Decision {
186
248
  * unless it derived them from provider token probabilities. A THRESHOLD TUNED AGAINST ONE
187
249
  * BACKEND DOES NOT TRANSFER TO ANOTHER.
188
250
  */
189
- calibrated: boolean);
251
+ calibrated: boolean,
252
+ /** True when the decision came from `ClassifierOptions.cache` and no request was sent. */
253
+ cached?: boolean);
254
+ /** Typed read of a raw (unmodelled-type) answer. Throws on an absent key or a modelled type. */
255
+ raw(key: string): RawAnswer;
190
256
  /** Typed read. Throws (never returns undefined) on a wrong-type or absent key. */
191
257
  noul(key: string): NoulAnswer;
192
258
  choice(key: string): ChoiceAnswer;
@@ -210,7 +276,12 @@ export declare function nearUniform(probabilities: Record<string, number>): bool
210
276
  * `nearUniform` is derived. A malformed answer is an error, never a repaired one (ADR 0020).
211
277
  */
212
278
  export declare function decodeDecision(raw: unknown, fallbackModel: string): Decision;
213
- /** `"systemone" | "llm" | "custom" | "static"` — the same slot as the client's `style`. */
279
+ /**
280
+ * `"systemone" | "llm" | "custom" | "static"` — the same slot as the client's `style`.
281
+ *
282
+ * `"llm"` is DEPRECATED (ADR 0038 D3: System One is the single product wire). It keeps working
283
+ * byte-identically, as an alias, for at least one minor release; there is no runtime warning.
284
+ */
214
285
  export type ClassifierStyle = "systemone" | "llm" | "custom" | "static";
215
286
  /**
216
287
  * One entry of the `static` corpus. It is keyed on the canonical request AND the state: several
@@ -220,12 +291,12 @@ export type ClassifierStyle = "systemone" | "llm" | "custom" | "static";
220
291
  */
221
292
  export interface RecordedDecision {
222
293
  state: unknown;
223
- questions: Record<string, Question>;
294
+ questions: Record<string, AnyQuestion>;
224
295
  /** The recorded backend response body, verbatim. */
225
296
  response: unknown;
226
297
  }
227
298
  /** The host's own backend (`style: "custom"`). Every wire option is ignored. */
228
- export type EvaluateFn = (state: unknown, questions: Record<string, Question>, signal?: AbortSignal) => Promise<Decision> | Decision;
299
+ export type EvaluateFn = (state: unknown, questions: Record<string, AnyQuestion>, signal?: AbortSignal) => Promise<Decision> | Decision;
229
300
  /**
230
301
  * Mirrors §8 `ClientOptions` field-for-field wherever a field makes sense, so a host that has
231
302
  * configured one has configured the other.
@@ -233,7 +304,7 @@ export type EvaluateFn = (state: unknown, questions: Record<string, Question>, s
233
304
  export interface ClassifierOptions {
234
305
  /** Default `"systemone"`. */
235
306
  style?: ClassifierStyle;
236
- /** A named `baseUrl` + `model` + `apiKeyEnv` pairing (`"typesafe"` | `"openrouter"`), set as a
307
+ /** A named backend row (`"typesafe"` | `"openrouter"` | `"cloudflare"` | `"vercel"` | `"custom"`), set as a
237
308
  * UNIT. Default `"typesafe"`. The three individual options still win where given, for a
238
309
  * self-hosted origin — but a known-bad mixture is refused at construction, not 700 ms later
239
310
  * as an "Unknown model" 400. */
@@ -277,12 +348,34 @@ export interface ClassifierOptions {
277
348
  /** Emits `classifier.evaluate` events into the SAME §8 sink, and carries the
278
349
  * degenerate-criteria warning as `classifier.warning`. */
279
350
  onMetric?: (ev: MetricEvent) => void;
280
- /** `style: "llm"` only — the §8 Client to emulate over. */
351
+ /** `style: "llm"` only — the §8 Client to emulate over.
352
+ * @deprecated `style: "llm"` is deprecated (System One is the single wire); it still works. */
281
353
  client?: Client;
282
354
  /** `style: "custom"` only — the host's own function. */
283
355
  evaluate?: EvaluateFn;
284
356
  /** `style: "static"` only — the recorded corpus. */
285
357
  decisions?: RecordedDecision[];
358
+ /** Overrides the backend table's support row for this classifier. Required with `backend: "custom"`. */
359
+ supports?: ClassifierSupports;
360
+ /** Opt-in answer cache (§8B). Absent ⇒ no caching. Consulted by style `systemone` only; only
361
+ * replies that pass `validateDecision` are stored. Methods may return values or Promises. */
362
+ cache?: ClassifierCache;
363
+ }
364
+ /** An injected answer cache. Values are opaque strings: the served model under a model key, the
365
+ * System One reply body under a decision key. */
366
+ export interface ClassifierCache {
367
+ get(key: string): string | undefined | null | Promise<string | undefined | null>;
368
+ set(key: string, value: string): void | Promise<void>;
369
+ }
370
+ /** One state's outcome from `states(...).evaluate`: exactly one of `decision` / `error` is set. */
371
+ export interface BatchResult {
372
+ decision?: Decision;
373
+ error?: Error;
374
+ }
375
+ /** A named question (`judge.noul(...)` etc. build these). */
376
+ export interface NamedClassifierQuestion {
377
+ name: string;
378
+ question: AnyQuestion;
286
379
  }
287
380
  /** The whole seam: one verb. */
288
381
  export declare class Classifier {
@@ -291,7 +384,13 @@ export declare class Classifier {
291
384
  private readonly baseUrl;
292
385
  private readonly model;
293
386
  private readonly apiKeyEnv;
387
+ /** The caller NAMED apiKeyEnv: sent wherever baseUrl points. Otherwise the preset's own key,
388
+ * and only to its own host (classifierKeyEnv, #155). */
389
+ private readonly explicitKeyEnv;
294
390
  private readonly timeoutMs;
391
+ private readonly backend;
392
+ private readonly preset;
393
+ private readonly supports;
295
394
  private readonly corpus;
296
395
  /** The once-per-question-key set for the degenerate warning, so a per-turn judge does not
297
396
  * flood the sink. */
@@ -301,14 +400,33 @@ export declare class Classifier {
301
400
  * The whole contract: a state plus typed questions in, a Decision out. Questions are
302
401
  * INDEPENDENT — one answer is never context for another.
303
402
  */
304
- evaluate(state: unknown, questions: Record<string, Question>, signal?: AbortSignal): Promise<Decision>;
403
+ evaluate(state: unknown, questions: Record<string, AnyQuestion>, signal?: AbortSignal): Promise<Decision>;
404
+ /**
405
+ * Binds one state (a VALUE, not a session: nothing is sent until `evaluate`). Its `evaluate`
406
+ * runs the same path as `evaluate` PLUS fail-closed validation (`validateDecision`).
407
+ */
408
+ state(value: unknown, opts?: {
409
+ signal?: AbortSignal;
410
+ }): {
411
+ evaluate(...questions: NamedClassifierQuestion[]): Promise<Decision>;
412
+ };
413
+ /**
414
+ * Binds a batch of states. Its `evaluate` runs `state(v).evaluate` per state, at most 16 in
415
+ * flight, and returns one `{decision | error}` per state IN ORDER. One state's failure never
416
+ * fails the batch; only a question-set error (duplicate name) throws, before any request.
417
+ */
418
+ states(...values: unknown[]): {
419
+ evaluate(...questions: NamedClassifierQuestion[]): Promise<BatchResult[]>;
420
+ };
421
+ /** The one path. `validate` = fail closed on `validateDecision` (the value builders). */
422
+ private run;
305
423
  /**
306
424
  * The same questions over many states (SPEC §8B "Batch"). Each state goes through
307
425
  * `evaluate`, at most `concurrency` (default 16) in flight; decisions come back in STATE
308
426
  * order. Fails closed: the first failing state's index is named and no decisions return.
309
427
  * No states is an error and sends nothing.
310
428
  */
311
- evaluateBatch(states: readonly unknown[], questions: Record<string, Question>, opts?: {
429
+ evaluateBatch(states: readonly unknown[], questions: Record<string, AnyQuestion>, opts?: {
312
430
  signal?: AbortSignal;
313
431
  concurrency?: number;
314
432
  }): Promise<Decision[]>;
@@ -319,15 +437,18 @@ export declare class Classifier {
319
437
  */
320
438
  private reportDegenerate;
321
439
  /**
322
- * Assembles the request: the canonical model + questions, plus state VERBATIM as the host
323
- * supplied it, then the §8 Gap 1 pipeline in §8 order (base → requestParams merge →
324
- * bodyTransform → marshal).
440
+ * Assembles the request for the backend's wire: the canonical model + questions, plus the state
441
+ * (VERBATIM unless it carried content parts) and, on systemone, `assets`; then the §8 Gap 1
442
+ * pipeline in §8 order (base → requestParams merge → bodyTransform → marshal).
325
443
  */
326
444
  private body;
327
445
  /** Identifies a recorded decision by the canonical request AND the state. */
328
446
  private staticKey;
329
447
  private evaluateStatic;
330
- private evaluateSystemOne;
448
+ /** Lift images, apply the support table and the limits — all before any request. */
449
+ private prepare;
450
+ /** The systemone style on every backend: lift, check, cache, post, decode. */
451
+ private evaluateWire;
331
452
  /**
332
453
  * The one POST, with the §8 retry budget, reusing the client's `ErrorInfo`/`ErrorTier`
333
454
  * classifier and the `Retry-After` delay-seconds rule verbatim.
@@ -369,3 +490,50 @@ export declare function degenerateCriteria(criteria: Record<string, string>): st
369
490
  * with, or `""` when the pairing is fine.
370
491
  */
371
492
  export declare function backendMismatch(baseUrl: string, model: string): string;
493
+ /** §8B model key: which model an endpoint + requested model (alias) served last. */
494
+ export declare function classifierModelCacheKey(endpoint: string, model: string): string;
495
+ /** §8B decision key, on the SERVED model. No assets ⇒ the `assets` member is omitted. */
496
+ export declare function classifierDecisionCacheKey(endpoint: string, servedModel: string, state: unknown, assets: Record<string, unknown> | undefined, questions: Record<string, AnyQuestion>): string;
497
+ /** The canonical JSON a decision key hashes (exported for conformance tests). */
498
+ export declare function classifierDecisionCacheKeyBytes(endpoint: string, servedModel: string, state: unknown, assets: Record<string, unknown> | undefined, questions: Record<string, AnyQuestion>): string;
499
+ /**
500
+ * In-process TTL cache with a size cap (oldest-inserted out first). `ttlMs` 0 = no expiry; `max`
501
+ * 0 = unbounded. toolnexus persists nothing itself: implement `ClassifierCache` to persist.
502
+ */
503
+ export declare class MemoryCache implements ClassifierCache {
504
+ readonly ttlMs: number;
505
+ readonly max: number;
506
+ private readonly now;
507
+ private readonly m;
508
+ constructor(ttlMs?: number, max?: number, now?: () => number);
509
+ get(key: string): string | undefined;
510
+ set(key: string, value: string): void;
511
+ get size(): number;
512
+ }
513
+ /**
514
+ * V1: fail closed on a reply that does not answer exactly the questions asked, within their
515
+ * schema. First failure wins, in the §8B order. Returns the error, or undefined when valid.
516
+ */
517
+ export declare function validateDecision(questions: Record<string, AnyQuestion>, decision: {
518
+ answers: Record<string, unknown>;
519
+ }): Error | undefined;
520
+ /** jevx's INCLUSIVE cut-points (T1). They sit alongside `Bands` (exclusive), which stays the
521
+ * default of `ask` / `gate`. */
522
+ export interface Thresholds {
523
+ yes: number;
524
+ no: number;
525
+ minConfidence: number;
526
+ }
527
+ /** 0.8 / 0.2 / 0.6 (jevx's builtin profile). */
528
+ export declare const DEFAULT_THRESHOLDS: Readonly<Thresholds>;
529
+ /**
530
+ * The band of one answer under `Thresholds`: noul `p >= yes` ⇒ yes, `p <= no` ⇒ no, else
531
+ * uncertain; choice/score `confidence >= minConfidence` ⇒ yes, else uncertain. No near-uniform
532
+ * demotion (that is `Bands`' rule). A raw answer is uncertain.
533
+ */
534
+ export declare function thresholdBand(answer: DecisionAnswer | RawAnswer, t?: Thresholds): "yes" | "no" | "uncertain";
535
+ /**
536
+ * Split a question set into chunks of `size`, keys in ASCII order. A helper, not automatic:
537
+ * joint calibration across a split is unproven, so the host decides to split and merges.
538
+ */
539
+ export declare function chunkQuestions<Q extends AnyQuestion>(questions: Record<string, Q>, size: number): Record<string, Q>[];