open-classify 0.1.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.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +290 -0
  3. package/dist/src/aggregator.d.ts +18 -0
  4. package/dist/src/aggregator.js +267 -0
  5. package/dist/src/catalog.d.ts +7 -0
  6. package/dist/src/catalog.js +189 -0
  7. package/dist/src/classifiers/custom/conversation_diegest/manifest.json +28 -0
  8. package/dist/src/classifiers/custom/conversation_diegest/prompt.md +7 -0
  9. package/dist/src/classifiers/custom/memory_retrieval_queries/manifest.json +29 -0
  10. package/dist/src/classifiers/custom/memory_retrieval_queries/prompt.md +5 -0
  11. package/dist/src/classifiers/stock/model_specialization/manifest.json +8 -0
  12. package/dist/src/classifiers/stock/preflight/manifest.json +8 -0
  13. package/dist/src/classifiers/stock/prompts/base.md +1 -0
  14. package/dist/src/classifiers/stock/prompts/classifier-header.md +4 -0
  15. package/dist/src/classifiers/stock/prompts/confidence.md +3 -0
  16. package/dist/src/classifiers/stock/prompts/custom-output.md +1 -0
  17. package/dist/src/classifiers/stock/prompts/model_specialization.md +7 -0
  18. package/dist/src/classifiers/stock/prompts/preflight-output.md +10 -0
  19. package/dist/src/classifiers/stock/prompts/preflight.md +47 -0
  20. package/dist/src/classifiers/stock/prompts/reason.md +3 -0
  21. package/dist/src/classifiers/stock/prompts/routing-output.md +5 -0
  22. package/dist/src/classifiers/stock/prompts/routing.md +9 -0
  23. package/dist/src/classifiers/stock/prompts/security-output.md +8 -0
  24. package/dist/src/classifiers/stock/prompts/security.md +26 -0
  25. package/dist/src/classifiers/stock/prompts/specialty.md +10 -0
  26. package/dist/src/classifiers/stock/prompts/tier.md +7 -0
  27. package/dist/src/classifiers/stock/prompts/tools-output.md +7 -0
  28. package/dist/src/classifiers/stock/prompts/tools.md +10 -0
  29. package/dist/src/classifiers/stock/routing/manifest.json +8 -0
  30. package/dist/src/classifiers/stock/security/manifest.json +12 -0
  31. package/dist/src/classifiers/stock/tools/manifest.json +19 -0
  32. package/dist/src/classifiers.d.ts +14 -0
  33. package/dist/src/classifiers.js +87 -0
  34. package/dist/src/config.d.ts +29 -0
  35. package/dist/src/config.js +144 -0
  36. package/dist/src/enums.d.ts +10 -0
  37. package/dist/src/enums.js +62 -0
  38. package/dist/src/index.d.ts +13 -0
  39. package/dist/src/index.js +18 -0
  40. package/dist/src/input.d.ts +4 -0
  41. package/dist/src/input.js +192 -0
  42. package/dist/src/manifest.d.ts +115 -0
  43. package/dist/src/manifest.js +1 -0
  44. package/dist/src/ollama.d.ts +54 -0
  45. package/dist/src/ollama.js +293 -0
  46. package/dist/src/pipeline.d.ts +17 -0
  47. package/dist/src/pipeline.js +274 -0
  48. package/dist/src/stock-prompt.d.ts +2 -0
  49. package/dist/src/stock-prompt.js +63 -0
  50. package/dist/src/stock-validation.d.ts +22 -0
  51. package/dist/src/stock-validation.js +329 -0
  52. package/dist/src/stock.d.ts +101 -0
  53. package/dist/src/stock.js +14 -0
  54. package/dist/src/types.d.ts +34 -0
  55. package/dist/src/types.js +6 -0
  56. package/dist/src/ui-server.d.ts +1 -0
  57. package/dist/src/ui-server.js +250 -0
  58. package/dist/src/validation.d.ts +17 -0
  59. package/dist/src/validation.js +127 -0
  60. package/open-classify.config.example.json +24 -0
  61. package/package.json +56 -0
@@ -0,0 +1,293 @@
1
+ // Reference `RunClassifier` implementation backed by a local Ollama server.
2
+ // Three responsibilities live here:
3
+ // 1. Resource sanity check — refuse to run on undersized hardware.
4
+ // 2. Prompt packing — render the system+user prompts and drop older context
5
+ // messages until the rendered prompt fits the configured num_ctx budget.
6
+ // 3. Backend wiring — call Ollama, parse JSON, and delegate validation to
7
+ // each classifier module's `validate` function.
8
+ //
9
+ // Custom backend? Implement `RunClassifier` directly and pass it to
10
+ // `classifyOpenClassifyInput` — you don't have to use this module at all.
11
+ import { execFile } from "node:child_process";
12
+ import { promisify } from "node:util";
13
+ import { loadCatalog } from "./catalog.js";
14
+ import { CLASSIFIER_NAMES, MODULES_BY_NAME, validateClassifierOutput, } from "./classifiers.js";
15
+ import { classifierModelsFromConfig, loadOpenClassifyConfig, } from "./config.js";
16
+ import { classifyOpenClassifyInput } from "./pipeline.js";
17
+ import { ClassifierValidationError, isRecord, } from "./validation.js";
18
+ export const OLLAMA_DEFAULT_HOST = "http://localhost:11434";
19
+ export const OLLAMA_BASE_MODEL = "gemma4:e4b-it-q4_K_M";
20
+ export const OLLAMA_BASE_MODEL_NATIVE_CONTEXT_LENGTH = 131_072;
21
+ export const OLLAMA_REQUIRED_PARALLELISM = CLASSIFIER_NAMES.length;
22
+ export const OLLAMA_DEFAULT_CATALOG_PATH = "downstream-models.json";
23
+ /*
24
+ * Gemma 4 E4B's native context is 131,072 tokens (128K). The reference local
25
+ * runtime is deliberately much smaller because Open Classify sends multiple
26
+ * classifiers in parallel on one workstation-class Ollama server. This is the
27
+ * configured classifier runtime context, not the model's architectural maximum.
28
+ */
29
+ export const OLLAMA_CONTEXT_LENGTH = 4096;
30
+ export const OLLAMA_MIN_TOTAL_MEMORY_BYTES = 16 * 1024 * 1024 * 1024;
31
+ export const OLLAMA_MIN_AVAILABLE_MEMORY_BYTES = 16 * 1024 * 1024 * 1024;
32
+ const ESTIMATED_CHARS_PER_TOKEN = 3;
33
+ const execFileAsync = promisify(execFile);
34
+ export const OLLAMA_CLASSIFIER_MODELS = Object.fromEntries(CLASSIFIER_NAMES.map((name) => [name, null]));
35
+ export class OllamaClassifierError extends Error {
36
+ classifier;
37
+ model;
38
+ constructor(classifier, model, message, cause) {
39
+ super(message, { cause });
40
+ this.name = "OllamaClassifierError";
41
+ this.classifier = classifier;
42
+ this.model = model;
43
+ }
44
+ }
45
+ export class OllamaResourceError extends Error {
46
+ totalMemoryBytes;
47
+ availableMemoryBytes;
48
+ minTotalMemoryBytes;
49
+ minAvailableMemoryBytes;
50
+ constructor(totalMemoryBytes, availableMemoryBytes, minTotalMemoryBytes, minAvailableMemoryBytes) {
51
+ super(`Ollama resource check failed: ${formatBytes(totalMemoryBytes)} total and ${formatBytes(availableMemoryBytes)} available; ${formatBytes(minTotalMemoryBytes)} total and ${formatBytes(minAvailableMemoryBytes)} available required for ${OLLAMA_REQUIRED_PARALLELISM} parallel classifiers`);
52
+ this.name = "OllamaResourceError";
53
+ this.totalMemoryBytes = totalMemoryBytes;
54
+ this.availableMemoryBytes = availableMemoryBytes;
55
+ this.minTotalMemoryBytes = minTotalMemoryBytes;
56
+ this.minAvailableMemoryBytes = minAvailableMemoryBytes;
57
+ }
58
+ }
59
+ // Build a `RunClassifier` bound to a specific Ollama host + model selection.
60
+ // The resource check is lazy and runs once per runner — the first classifier
61
+ // invocation pays for it; subsequent ones reuse the same promise.
62
+ export function createOllamaClassifierRunner(config = {}) {
63
+ const host = trimTrailingSlash(config.host ?? OLLAMA_DEFAULT_HOST);
64
+ const fetchImpl = config.fetch ?? fetch;
65
+ const models = config.models ?? {};
66
+ const defaultModel = config.defaultModel ?? OLLAMA_BASE_MODEL;
67
+ const hasDefaultModelOverride = config.defaultModel !== undefined;
68
+ const options = {
69
+ temperature: 0,
70
+ num_ctx: OLLAMA_CONTEXT_LENGTH,
71
+ ...config.options,
72
+ };
73
+ let resourceCheck;
74
+ return async (name, input, signal) => {
75
+ if (!config.skipResourceCheck) {
76
+ resourceCheck ??= assertOllamaResources({
77
+ minTotalMemoryBytes: config.minTotalMemoryBytes ?? OLLAMA_MIN_TOTAL_MEMORY_BYTES,
78
+ minAvailableMemoryBytes: config.minAvailableMemoryBytes ?? OLLAMA_MIN_AVAILABLE_MEMORY_BYTES,
79
+ });
80
+ await resourceCheck;
81
+ }
82
+ const configuredModel = models[name];
83
+ const model = configuredModel ?? defaultModel;
84
+ return runOllamaClassifier(name, input, signal, fetchImpl, host, model, options, configuredModel === undefined && !hasDefaultModelOverride);
85
+ };
86
+ }
87
+ export async function assertOllamaResources(options = {}) {
88
+ const minTotalMemoryBytes = options.minTotalMemoryBytes ?? OLLAMA_MIN_TOTAL_MEMORY_BYTES;
89
+ const minAvailableMemoryBytes = options.minAvailableMemoryBytes ?? OLLAMA_MIN_AVAILABLE_MEMORY_BYTES;
90
+ const { totalMemoryBytes, availableMemoryBytes } = await getSystemMemoryBytes();
91
+ if (totalMemoryBytes < minTotalMemoryBytes ||
92
+ availableMemoryBytes < minAvailableMemoryBytes) {
93
+ throw new OllamaResourceError(totalMemoryBytes, availableMemoryBytes, minTotalMemoryBytes, minAvailableMemoryBytes);
94
+ }
95
+ }
96
+ export async function classifyWithOllama(input, config = {}) {
97
+ const fileConfig = config.openClassifyConfig ?? loadOpenClassifyConfig(config.configPath, {
98
+ optional: config.configPath === undefined && process.env.OPEN_CLASSIFY_CONFIG === undefined,
99
+ });
100
+ const runnerFileConfig = fileConfig?.runner;
101
+ const runnerConfig = {
102
+ ...config,
103
+ host: config.host ?? runnerFileConfig?.host,
104
+ defaultModel: config.defaultModel ?? runnerFileConfig?.defaultModel,
105
+ models: {
106
+ ...classifierModelsFromConfig(fileConfig),
107
+ ...config.models,
108
+ },
109
+ options: {
110
+ ...runnerFileConfig?.options,
111
+ ...config.options,
112
+ },
113
+ };
114
+ if (!runnerConfig.skipResourceCheck) {
115
+ await assertOllamaResources({
116
+ minTotalMemoryBytes: runnerConfig.minTotalMemoryBytes,
117
+ minAvailableMemoryBytes: runnerConfig.minAvailableMemoryBytes,
118
+ });
119
+ Object.assign(runnerConfig, {
120
+ skipResourceCheck: true,
121
+ });
122
+ }
123
+ const catalog = config.catalog ?? loadCatalog(config.catalogPath ?? fileConfig?.catalog ?? OLLAMA_DEFAULT_CATALOG_PATH);
124
+ return classifyOpenClassifyInput(input, {
125
+ runClassifier: createOllamaClassifierRunner(runnerConfig),
126
+ catalog,
127
+ });
128
+ }
129
+ async function runOllamaClassifier(name, input, signal, fetchImpl, host, model, options, allowManifestModel) {
130
+ const module_ = MODULES_BY_NAME[name];
131
+ const systemPrompt = module_.systemPrompt;
132
+ const configuredBaseModel = module_.backend?.ollama?.base_model;
133
+ if (allowManifestModel && configuredBaseModel) {
134
+ model = configuredBaseModel;
135
+ }
136
+ const userPrompt = buildPackedClassifierPrompt(name, input, systemPrompt, model, options);
137
+ const body = {
138
+ model,
139
+ stream: false,
140
+ format: "json",
141
+ think: false,
142
+ options,
143
+ messages: [
144
+ { role: "system", content: systemPrompt },
145
+ { role: "user", content: userPrompt },
146
+ ],
147
+ };
148
+ let response;
149
+ try {
150
+ response = await fetchImpl(`${host}/api/chat`, {
151
+ method: "POST",
152
+ headers: { "content-type": "application/json" },
153
+ body: JSON.stringify(body),
154
+ signal,
155
+ });
156
+ }
157
+ catch (error) {
158
+ throw new OllamaClassifierError(name, model, `${name} classifier request failed`, error);
159
+ }
160
+ let payload;
161
+ try {
162
+ payload = ((await response.json()) ?? {});
163
+ }
164
+ catch (error) {
165
+ throw new OllamaClassifierError(name, model, `${name} classifier returned invalid Ollama JSON`, error);
166
+ }
167
+ if (!response.ok || payload.error !== undefined) {
168
+ throw new OllamaClassifierError(name, model, `${name} classifier Ollama request failed: ${payload.error ?? response.statusText}`);
169
+ }
170
+ const content = payload.message?.content ?? payload.response;
171
+ if (typeof content !== "string") {
172
+ throw new OllamaClassifierError(name, model, `${name} classifier response did not include message content`);
173
+ }
174
+ const parsed = parseJsonObject(content, name, model);
175
+ try {
176
+ return validateClassifierOutput(name, parsed, model);
177
+ }
178
+ catch (error) {
179
+ if (error instanceof ClassifierValidationError) {
180
+ throw new OllamaClassifierError(name, model, error.message, error);
181
+ }
182
+ throw error;
183
+ }
184
+ }
185
+ async function getSystemMemoryBytes() {
186
+ const os = await import("node:os");
187
+ const totalMemoryBytes = os.totalmem();
188
+ if (process.platform === "darwin") {
189
+ const [{ stdout: pageSizeOutput }, { stdout: vmStatOutput }] = await Promise.all([
190
+ execFileAsync("pagesize"),
191
+ execFileAsync("vm_stat"),
192
+ ]);
193
+ const pageSize = Number(pageSizeOutput.trim());
194
+ const freePages = readVmStatPages(vmStatOutput, "Pages free");
195
+ const speculativePages = readVmStatPages(vmStatOutput, "Pages speculative");
196
+ const purgeablePages = readVmStatPages(vmStatOutput, "Pages purgeable");
197
+ return {
198
+ totalMemoryBytes,
199
+ availableMemoryBytes: (freePages + speculativePages + purgeablePages) * pageSize,
200
+ };
201
+ }
202
+ return {
203
+ totalMemoryBytes,
204
+ availableMemoryBytes: os.freemem(),
205
+ };
206
+ }
207
+ function readVmStatPages(output, label) {
208
+ const line = output
209
+ .split("\n")
210
+ .find((candidate) => candidate.trim().startsWith(`${label}:`));
211
+ if (line === undefined) {
212
+ return 0;
213
+ }
214
+ const match = line.match(/:\s+([0-9]+)\./);
215
+ return match === null ? 0 : Number(match[1]);
216
+ }
217
+ function buildPackedClassifierPrompt(name, input, systemPrompt, model, options) {
218
+ let messages = input.messages;
219
+ let prompt = buildClassifierPrompt({
220
+ ...input,
221
+ messages,
222
+ });
223
+ while (!fitsEstimatedContext(systemPrompt, prompt, options) &&
224
+ messages.length > 1) {
225
+ messages = messages.slice(1);
226
+ prompt = buildClassifierPrompt({
227
+ ...input,
228
+ messages,
229
+ });
230
+ }
231
+ if (!fitsEstimatedContext(systemPrompt, prompt, options)) {
232
+ throw new OllamaClassifierError(name, model, `${name} classifier prompt exceeds estimated ${contextLength(options)} token context length with only the target message`);
233
+ }
234
+ return prompt;
235
+ }
236
+ function fitsEstimatedContext(systemPrompt, userPrompt, options) {
237
+ return estimateTokens(`${systemPrompt}\n\n${userPrompt}`) <= contextLength(options);
238
+ }
239
+ function contextLength(options) {
240
+ return Number.isFinite(options.num_ctx)
241
+ ? Math.floor(options.num_ctx)
242
+ : OLLAMA_CONTEXT_LENGTH;
243
+ }
244
+ function estimateTokens(text) {
245
+ return Math.ceil(text.length / ESTIMATED_CHARS_PER_TOKEN);
246
+ }
247
+ function buildClassifierPrompt(input) {
248
+ const lines = [
249
+ "Classify the final message in the normalized conversation window below.",
250
+ "Earlier messages are context only; do not classify them as new requests.",
251
+ "The target user message is the final message in the window.",
252
+ "Return JSON only.",
253
+ "",
254
+ "Conversation window:",
255
+ ];
256
+ for (const [index, message] of input.messages.entries()) {
257
+ const label = index === input.messages.length - 1
258
+ ? `Message ${index + 1} (target)`
259
+ : `Message ${index + 1} (context)`;
260
+ lines.push(`${label}:`);
261
+ if (message.role !== undefined) {
262
+ lines.push(`role: ${message.role}`);
263
+ }
264
+ lines.push("text:");
265
+ lines.push(message.text);
266
+ lines.push("");
267
+ }
268
+ return lines.join("\n");
269
+ }
270
+ function parseJsonObject(content, classifier, model) {
271
+ const json = unwrapJsonFence(content);
272
+ try {
273
+ const parsed = JSON.parse(json);
274
+ if (isRecord(parsed)) {
275
+ return parsed;
276
+ }
277
+ }
278
+ catch (error) {
279
+ throw new OllamaClassifierError(classifier, model, `${classifier} classifier returned invalid JSON`, error);
280
+ }
281
+ throw new OllamaClassifierError(classifier, model, `${classifier} classifier returned JSON that is not an object`);
282
+ }
283
+ function unwrapJsonFence(content) {
284
+ const trimmed = content.trim();
285
+ const match = /^```(?:json)?\s*([\s\S]*?)\s*```$/i.exec(trimmed);
286
+ return match?.[1]?.trim() ?? trimmed;
287
+ }
288
+ function trimTrailingSlash(value) {
289
+ return value.replace(/\/+$/, "");
290
+ }
291
+ function formatBytes(value) {
292
+ return `${(value / (1024 * 1024 * 1024)).toFixed(1)} GiB`;
293
+ }
@@ -0,0 +1,17 @@
1
+ import { type RunClassifier } from "./classifiers.js";
2
+ import type { AggregatorConfig, Catalog, PipelineResult } from "./manifest.js";
3
+ import type { OpenClassifyInput } from "./types.js";
4
+ export declare const DEFAULT_CLASSIFIER_TIMEOUT_MS = 15000;
5
+ export declare const DEFAULT_CLASSIFIER_RETRY_COUNT = 1;
6
+ export declare class OpenClassifyNormalizationError extends Error {
7
+ constructor(cause: unknown);
8
+ }
9
+ export interface ClassifyOptions {
10
+ runClassifier: RunClassifier;
11
+ catalog: Catalog;
12
+ classifierTimeoutMs?: number;
13
+ classifierRetryCount?: number;
14
+ aggregator?: AggregatorConfig;
15
+ signal?: AbortSignal;
16
+ }
17
+ export declare function classifyOpenClassifyInput(input: OpenClassifyInput, options: ClassifyOptions): Promise<PipelineResult>;
@@ -0,0 +1,274 @@
1
+ import { composeEnvelope } from "./aggregator.js";
2
+ import { CLASSIFIER_NAMES, MODULES_BY_NAME, REGISTRY, } from "./classifiers.js";
3
+ import { normalizeOpenClassifyInput, toClassifierInput } from "./input.js";
4
+ import { isCustomManifest } from "./stock.js";
5
+ export const DEFAULT_CLASSIFIER_TIMEOUT_MS = 15_000;
6
+ export const DEFAULT_CLASSIFIER_RETRY_COUNT = 1;
7
+ export class OpenClassifyNormalizationError extends Error {
8
+ constructor(cause) {
9
+ super(errorMessage(cause), { cause });
10
+ this.name = "OpenClassifyNormalizationError";
11
+ }
12
+ }
13
+ // Short-circuit gates are intrinsic to specific stock signals — not configured
14
+ // per-manifest. preflight.final_reply ⇒ answer; security.decision in
15
+ // {block, needs_review} ⇒ block / needs_review. Order matters: preflight is
16
+ // cheaper to evaluate, so we check it first.
17
+ const SHORT_CIRCUIT_GATES = ["preflight", "security"];
18
+ export async function classifyOpenClassifyInput(input, options) {
19
+ let request;
20
+ try {
21
+ request = normalizeOpenClassifyInput(input);
22
+ }
23
+ catch (error) {
24
+ throw new OpenClassifyNormalizationError(error);
25
+ }
26
+ const controller = new AbortController();
27
+ const abortFromOptions = () => {
28
+ controller.abort(options.signal?.reason ?? new Error("classification aborted"));
29
+ };
30
+ if (options.signal?.aborted) {
31
+ abortFromOptions();
32
+ }
33
+ else {
34
+ options.signal?.addEventListener("abort", abortFromOptions, { once: true });
35
+ }
36
+ const classifierInput = toClassifierInput(request);
37
+ const classifierTimeoutMs = options.classifierTimeoutMs ?? DEFAULT_CLASSIFIER_TIMEOUT_MS;
38
+ const classifierRetryCount = options.classifierRetryCount ?? DEFAULT_CLASSIFIER_RETRY_COUNT;
39
+ const threshold = options.aggregator?.confidenceThreshold ?? 0.6;
40
+ const runs = new Map(CLASSIFIER_NAMES.map((name) => [
41
+ name,
42
+ runClassifierWithRetry(name, classifierInput, options.runClassifier, controller.signal, classifierTimeoutMs, classifierRetryCount),
43
+ ]));
44
+ try {
45
+ for (const gate of SHORT_CIRCUIT_GATES) {
46
+ const gateRun = runs.get(gate);
47
+ if (gateRun === undefined)
48
+ continue;
49
+ const settled = await gateRun;
50
+ if (!settled.ok)
51
+ continue;
52
+ const verdict = shortCircuitVerdict(gate, settled.value, threshold);
53
+ if (!verdict)
54
+ continue;
55
+ controller.abort();
56
+ await settleClassifierRunsExcept(runs, [gate]);
57
+ return buildShortCircuitResult(gate, verdict, settled, request.target_message_hash);
58
+ }
59
+ const settled = await Promise.all([...runs.values()]);
60
+ const { results, meta } = collectFullEntries(settled);
61
+ const envelope = composeEnvelope({
62
+ registry: REGISTRY,
63
+ results,
64
+ catalog: options.catalog,
65
+ input: classifierInput,
66
+ config: options.aggregator,
67
+ });
68
+ return buildRouteResult(request, envelope, results, meta);
69
+ }
70
+ finally {
71
+ options.signal?.removeEventListener("abort", abortFromOptions);
72
+ }
73
+ }
74
+ function shortCircuitVerdict(gate, result, threshold) {
75
+ const confidence = result.confidence ?? 0;
76
+ if (confidence < threshold)
77
+ return null;
78
+ if (gate === "preflight") {
79
+ const preflight = result;
80
+ if (preflight.final_reply !== undefined) {
81
+ return { kind: "answer", final_reply: preflight.final_reply };
82
+ }
83
+ return null;
84
+ }
85
+ if (gate === "security") {
86
+ const security = result;
87
+ if (security.decision === "block") {
88
+ return {
89
+ kind: "block",
90
+ safety: extractSafety(security),
91
+ };
92
+ }
93
+ if (security.decision === "needs_review") {
94
+ return {
95
+ kind: "needs_review",
96
+ safety: extractSafety(security),
97
+ };
98
+ }
99
+ }
100
+ return null;
101
+ }
102
+ function extractSafety(value) {
103
+ return {
104
+ ...(value.decision === undefined ? {} : { decision: value.decision }),
105
+ risk_level: value.risk_level,
106
+ signals: value.signals,
107
+ };
108
+ }
109
+ function buildShortCircuitResult(name, verdict, settled, target_message_hash) {
110
+ const manifest = MODULES_BY_NAME[name];
111
+ const value = settled.ok ? settled.value : manifest.fallback;
112
+ const entry = {
113
+ ...value,
114
+ status: classifierRunStatus(settled),
115
+ version: manifest.version,
116
+ };
117
+ const meta = { classifiers: { [name]: entry } };
118
+ const classifier_outputs = classifierCustomOutputs({ [name]: value });
119
+ if (verdict.kind === "answer") {
120
+ const preflight = value;
121
+ return {
122
+ action: "answer",
123
+ message_id: target_message_hash,
124
+ final_reply: verdict.final_reply,
125
+ reason: "already_answered",
126
+ classifier_outputs,
127
+ audit: {
128
+ fired_by: name,
129
+ ...(preflight.final_reply === undefined ? {} : { final_reply: preflight.final_reply }),
130
+ meta,
131
+ },
132
+ };
133
+ }
134
+ if (verdict.kind === "needs_review") {
135
+ return {
136
+ action: "needs_review",
137
+ message_id: target_message_hash,
138
+ fired_by: name,
139
+ reason: {
140
+ risk_level: verdict.safety.risk_level,
141
+ signals: verdict.safety.signals,
142
+ },
143
+ classifier_outputs,
144
+ audit: {
145
+ fired_by: name,
146
+ safety: verdict.safety,
147
+ meta,
148
+ },
149
+ };
150
+ }
151
+ return {
152
+ action: "block",
153
+ message_id: target_message_hash,
154
+ reason: {
155
+ risk_level: verdict.safety.risk_level,
156
+ signals: verdict.safety.signals,
157
+ },
158
+ classifier_outputs,
159
+ audit: {
160
+ fired_by: name,
161
+ safety: verdict.safety,
162
+ meta,
163
+ },
164
+ };
165
+ }
166
+ function collectFullEntries(settled) {
167
+ const results = {};
168
+ const classifiers = {};
169
+ for (const s of settled) {
170
+ const manifest = MODULES_BY_NAME[s.name];
171
+ const value = s.ok ? s.value : manifest.fallback;
172
+ results[s.name] = value;
173
+ classifiers[s.name] = {
174
+ ...value,
175
+ status: classifierRunStatus(s),
176
+ version: manifest.version,
177
+ };
178
+ }
179
+ return { results, meta: { classifiers } };
180
+ }
181
+ function buildRouteResult(request, envelope, results, meta) {
182
+ const downstream = {
183
+ model_id: envelope.model_recommendation.id,
184
+ target_message: {
185
+ role: "user",
186
+ text: request.text,
187
+ hash: request.target_message_hash,
188
+ },
189
+ tools: envelope.tools ?? { tools: [] },
190
+ };
191
+ return {
192
+ action: "route",
193
+ message_id: request.target_message_hash,
194
+ downstream,
195
+ classifier_outputs: classifierCustomOutputs(results),
196
+ audit: {
197
+ ...envelope,
198
+ meta,
199
+ },
200
+ };
201
+ }
202
+ function classifierCustomOutputs(results) {
203
+ const out = {};
204
+ for (const manifest of REGISTRY) {
205
+ if (!isCustomManifest(manifest))
206
+ continue;
207
+ const result = results[manifest.name];
208
+ if (result === undefined)
209
+ continue;
210
+ out[manifest.name] = result.output;
211
+ }
212
+ return out;
213
+ }
214
+ async function runClassifierWithRetry(name, input, runClassifier, rootSignal, timeoutMs, retryCount) {
215
+ let lastError = new Error(`${name} classifier did not run`);
216
+ let lastReason = "error";
217
+ for (let attempt = 0; attempt <= retryCount; attempt += 1) {
218
+ if (rootSignal.aborted)
219
+ break;
220
+ const result = await runClassifierAttempt(name, input, runClassifier, rootSignal, timeoutMs);
221
+ if (result.ok)
222
+ return { ok: true, name, value: result.value };
223
+ lastError = result.error;
224
+ lastReason = result.reason;
225
+ }
226
+ return { ok: false, name, error: lastError, reason: lastReason };
227
+ }
228
+ async function runClassifierAttempt(name, input, runClassifier, rootSignal, timeoutMs) {
229
+ const controller = new AbortController();
230
+ const timeoutError = new Error(`${name} classifier timed out after ${timeoutMs}ms`);
231
+ let timeout;
232
+ let abortAttempt;
233
+ try {
234
+ const run = runClassifier(name, input, controller.signal).then((value) => ({ ok: true, value }), (error) => ({ ok: false, error, reason: "error" }));
235
+ const timedOut = new Promise((resolve) => {
236
+ timeout = setTimeout(() => {
237
+ controller.abort(timeoutError);
238
+ resolve({ ok: false, error: timeoutError, reason: "timeout" });
239
+ }, timeoutMs);
240
+ });
241
+ const aborted = new Promise((resolve) => {
242
+ abortAttempt = () => {
243
+ const error = rootSignal.reason ?? new Error(`${name} classifier aborted`);
244
+ controller.abort(error);
245
+ resolve({ ok: false, error, reason: "error" });
246
+ };
247
+ rootSignal.addEventListener("abort", abortAttempt, { once: true });
248
+ });
249
+ return await Promise.race([run, timedOut, aborted]);
250
+ }
251
+ finally {
252
+ if (timeout !== undefined)
253
+ clearTimeout(timeout);
254
+ if (abortAttempt !== undefined)
255
+ rootSignal.removeEventListener("abort", abortAttempt);
256
+ }
257
+ }
258
+ async function settleClassifierRunsExcept(runs, keep) {
259
+ const keepSet = new Set(keep);
260
+ await Promise.all([...runs].filter(([name]) => !keepSet.has(name)).map(([, run]) => run.catch(() => undefined)));
261
+ }
262
+ function classifierRunStatus(settled) {
263
+ if (settled.ok)
264
+ return { ok: true, source: "model" };
265
+ return {
266
+ ok: false,
267
+ source: "fallback",
268
+ reason: settled.reason,
269
+ error: errorMessage(settled.error),
270
+ };
271
+ }
272
+ function errorMessage(error) {
273
+ return error instanceof Error ? error.message : String(error);
274
+ }
@@ -0,0 +1,2 @@
1
+ import type { JsonClassifierManifest } from "./stock.js";
2
+ export declare function buildStockClassifierPrompt(manifest: JsonClassifierManifest): string;
@@ -0,0 +1,63 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ const __dirname = dirname(fileURLToPath(import.meta.url));
5
+ const STOCK_PROMPTS_DIR = join(__dirname, "classifiers", "stock", "prompts");
6
+ export function buildStockClassifierPrompt(manifest) {
7
+ const sections = [
8
+ promptMarkdown("base.md"),
9
+ promptMarkdown("reason.md"),
10
+ promptMarkdown("confidence.md"),
11
+ renderTemplate(promptMarkdown("classifier-header.md"), {
12
+ classifier_name: manifest.name,
13
+ classifier_purpose: manifest.purpose,
14
+ }),
15
+ ];
16
+ if (manifest.kind === "stock") {
17
+ sections.push(stockSection(manifest));
18
+ }
19
+ else {
20
+ sections.push(promptMarkdown("custom-output.md"));
21
+ }
22
+ return sections.join("\n\n");
23
+ }
24
+ function stockSection(manifest) {
25
+ return renderTemplate(promptMarkdown(`${manifest.name}.md`), {
26
+ allowed_tools: renderAllowedTools(manifest.tools),
27
+ preflight_output: promptMarkdown("preflight-output.md"),
28
+ routing_output: promptMarkdown("routing-output.md"),
29
+ security_output: promptMarkdown("security-output.md"),
30
+ specialty: promptMarkdown("specialty.md"),
31
+ tier: promptMarkdown("tier.md"),
32
+ tools_output: promptMarkdown("tools-output.md"),
33
+ });
34
+ }
35
+ function renderAllowedTools(tools) {
36
+ if (!tools || tools.length === 0) {
37
+ return "No downstream tools are available.";
38
+ }
39
+ return [
40
+ "Allowed tool ids:",
41
+ "",
42
+ ...tools.map((tool) => `- ${tool.id}: ${tool.description}`),
43
+ ].join("\n");
44
+ }
45
+ function promptMarkdown(filename) {
46
+ return readFileSync(join(STOCK_PROMPTS_DIR, filename), "utf8").trim();
47
+ }
48
+ function renderTemplate(template, slots) {
49
+ let rendered = template;
50
+ for (let pass = 0; pass < 5; pass += 1) {
51
+ const next = rendered.replace(/\{\{([a-z_]+)\}\}/g, (match, name) => {
52
+ const value = slots[name];
53
+ if (value === undefined) {
54
+ throw new Error(`missing prompt slot: ${match}`);
55
+ }
56
+ return value;
57
+ });
58
+ if (next === rendered)
59
+ return rendered;
60
+ rendered = next;
61
+ }
62
+ throw new Error("prompt template slots are nested too deeply");
63
+ }
@@ -0,0 +1,22 @@
1
+ import type { JsonClassifierManifest, SafetySignal, ClassifierOutput } from "./stock.js";
2
+ export declare const STOCK_REASON_MAX_CHARS = 120;
3
+ export declare const STOCK_REPLY_MAX_CHARS = 200;
4
+ export declare const STOCK_TOOL_ID_MAX_CHARS = 64;
5
+ export declare const STOCK_TOOL_DESCRIPTION_MAX_CHARS = 240;
6
+ export declare const STOCK_MANIFEST_NAME_MAX_CHARS = 80;
7
+ export declare const STOCK_MANIFEST_VERSION_MAX_CHARS = 40;
8
+ export declare const STOCK_MANIFEST_PURPOSE_MAX_CHARS = 400;
9
+ export declare function validateJsonClassifierManifest(value: unknown, model?: string): JsonClassifierManifest;
10
+ export interface ValidateOutputContext {
11
+ readonly classifier: string;
12
+ readonly model: string;
13
+ }
14
+ export declare function validateOutputForManifest(manifest: JsonClassifierManifest, value: unknown, context: ValidateOutputContext): ClassifierOutput;
15
+ export declare function validateWithSchema(value: unknown, schema: unknown, classifier: string, model: string, path: string): void;
16
+ export interface LegacyValidateOptions {
17
+ readonly classifier: string;
18
+ readonly model: string;
19
+ readonly manifest: JsonClassifierManifest;
20
+ }
21
+ export declare function validateClassifierOutputWithManifest(value: unknown, options: LegacyValidateOptions): ClassifierOutput;
22
+ export type { SafetySignal };