open-classify 0.2.0 → 0.5.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 (65) hide show
  1. package/README.md +134 -97
  2. package/dist/src/aggregator.d.ts +11 -4
  3. package/dist/src/aggregator.js +108 -121
  4. package/dist/src/classifiers/{custom/context_shift → context_shift}/manifest.json +6 -11
  5. package/dist/src/classifiers/{custom/context_shift → context_shift}/prompt.md +1 -1
  6. package/dist/src/classifiers/{custom/conversation_digest → conversation_digest}/manifest.json +7 -12
  7. package/dist/src/classifiers/{custom/conversation_digest → conversation_digest}/prompt.md +2 -2
  8. package/dist/src/classifiers/{custom/memory_retrieval_queries → memory_retrieval_queries}/manifest.json +6 -11
  9. package/dist/src/classifiers/{custom/memory_retrieval_queries → memory_retrieval_queries}/prompt.md +2 -2
  10. package/dist/src/classifiers/{stock/model_specialization → model_specialization}/manifest.json +2 -2
  11. package/dist/src/classifiers/model_specialization/prompt.md +5 -0
  12. package/dist/src/classifiers/preflight/manifest.json +34 -0
  13. package/dist/src/classifiers/preflight/prompt.md +10 -0
  14. package/dist/src/classifiers/{stock/prompt_injection → prompt_injection}/manifest.json +6 -2
  15. package/dist/src/classifiers/prompt_injection/prompt.md +14 -0
  16. package/dist/src/classifiers/{stock/routing → routing}/manifest.json +2 -2
  17. package/dist/src/classifiers/routing/prompt.md +5 -0
  18. package/dist/src/classifiers/{stock/tools → tools}/manifest.json +3 -3
  19. package/dist/src/classifiers/tools/prompt.md +5 -0
  20. package/dist/src/classifiers.js +31 -32
  21. package/dist/src/classify.d.ts +10 -2
  22. package/dist/src/classify.js +27 -12
  23. package/dist/src/config.d.ts +1 -4
  24. package/dist/src/config.js +7 -45
  25. package/dist/src/index.d.ts +1 -0
  26. package/dist/src/index.js +1 -0
  27. package/dist/src/input.d.ts +4 -1
  28. package/dist/src/input.js +12 -10
  29. package/dist/src/manifest.d.ts +18 -46
  30. package/dist/src/manifest.js +1 -5
  31. package/dist/src/pipeline.d.ts +11 -2
  32. package/dist/src/pipeline.js +98 -168
  33. package/dist/src/reserved-fields.d.ts +18 -0
  34. package/dist/src/reserved-fields.js +175 -0
  35. package/dist/src/stock-prompt.d.ts +9 -2
  36. package/dist/src/stock-prompt.js +165 -45
  37. package/dist/src/stock-validation.d.ts +16 -17
  38. package/dist/src/stock-validation.js +263 -236
  39. package/dist/src/stock.d.ts +26 -62
  40. package/dist/src/stock.js +7 -14
  41. package/docs/adding-a-classifier.md +74 -32
  42. package/docs/manifests.md +112 -71
  43. package/docs/resolver.md +25 -34
  44. package/docs/signals.md +39 -58
  45. package/open-classify.config.example.json +10 -13
  46. package/package.json +1 -3
  47. package/dist/src/classifiers/stock/preflight/manifest.json +0 -11
  48. package/dist/src/classifiers/stock/prompts/classifier-header.md +0 -4
  49. package/dist/src/classifiers/stock/prompts/custom-output.md +0 -7
  50. package/dist/src/classifiers/stock/prompts/model_specialization.md +0 -7
  51. package/dist/src/classifiers/stock/prompts/preflight-output.md +0 -10
  52. package/dist/src/classifiers/stock/prompts/preflight.md +0 -47
  53. package/dist/src/classifiers/stock/prompts/prompt-injection-output.md +0 -5
  54. package/dist/src/classifiers/stock/prompts/prompt_injection.md +0 -24
  55. package/dist/src/classifiers/stock/prompts/routing-output.md +0 -5
  56. package/dist/src/classifiers/stock/prompts/routing.md +0 -9
  57. package/dist/src/classifiers/stock/prompts/specialty.md +0 -12
  58. package/dist/src/classifiers/stock/prompts/tier.md +0 -7
  59. package/dist/src/classifiers/stock/prompts/tools-output.md +0 -11
  60. package/dist/src/classifiers/stock/prompts/tools.md +0 -10
  61. package/dist/src/ui-server.d.ts +0 -1
  62. package/dist/src/ui-server.js +0 -257
  63. /package/dist/src/classifiers/{stock/prompts → _prompts}/base.md +0 -0
  64. /package/dist/src/classifiers/{stock/prompts → _prompts}/confidence.md +0 -0
  65. /package/dist/src/classifiers/{stock/prompts → _prompts}/reason.md +0 -0
@@ -1,11 +1,13 @@
1
1
  import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { basename, dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- import { buildStockClassifierPrompt } from "./stock-prompt.js";
4
+ import { buildClassifierPrompt } from "./stock-prompt.js";
5
5
  import { validateJsonClassifierManifest, validateOutputForManifest, } from "./stock-validation.js";
6
6
  const __dirname = dirname(fileURLToPath(import.meta.url));
7
7
  const CLASSIFIERS_DIR = join(__dirname, "classifiers");
8
- const KIND_DIRS = ["stock", "custom"];
8
+ // Directories whose names start with "_" are reserved for shared assets
9
+ // (e.g. `_prompts/`) and are not loaded as classifiers.
10
+ const SHARED_DIRECTORY_PREFIX = "_";
9
11
  export class ClassifierManifestError extends Error {
10
12
  constructor(message) {
11
13
  super(message);
@@ -17,62 +19,59 @@ export function loadClassifierRegistry(classifiersDir = CLASSIFIERS_DIR) {
17
19
  throw new ClassifierManifestError(`classifier directory not found: ${classifiersDir}`);
18
20
  }
19
21
  const manifests = [];
20
- for (const kind of KIND_DIRS) {
21
- const kindDir = join(classifiersDir, kind);
22
- if (!existsSync(kindDir))
22
+ for (const entry of readdirSync(classifiersDir, { withFileTypes: true })) {
23
+ if (!entry.isDirectory())
23
24
  continue;
24
- for (const entry of readdirSync(kindDir, { withFileTypes: true })) {
25
- if (!entry.isDirectory())
26
- continue;
27
- if (kind === "stock" && entry.name === "prompts")
28
- continue;
29
- manifests.push(loadClassifierManifest(join(kindDir, entry.name), kind));
30
- }
25
+ if (entry.name.startsWith(SHARED_DIRECTORY_PREFIX))
26
+ continue;
27
+ manifests.push(loadClassifierManifest(join(classifiersDir, entry.name)));
31
28
  }
32
- manifests.sort((a, b) => a.order - b.order);
29
+ // Lower dispatch_order runs first. Classifiers without dispatch_order sort
30
+ // last (treated as +Infinity) — useful for "run me whenever there's a slot".
31
+ manifests.sort((a, b) => (a.dispatch_order ?? Infinity) - (b.dispatch_order ?? Infinity));
33
32
  validateRegistry(manifests);
34
33
  return manifests;
35
34
  }
36
- function loadClassifierManifest(classifierDir, expectedKind) {
35
+ function loadClassifierManifest(classifierDir) {
37
36
  const manifestPath = join(classifierDir, "manifest.json");
38
37
  const promptPath = join(classifierDir, "prompt.md");
39
38
  if (!existsSync(manifestPath)) {
40
39
  throw new ClassifierManifestError(`missing manifest.json in ${classifierDir}`);
41
40
  }
42
- if (expectedKind === "custom" && !existsSync(promptPath)) {
41
+ if (!existsSync(promptPath)) {
43
42
  throw new ClassifierManifestError(`missing prompt.md in ${classifierDir}`);
44
43
  }
45
44
  const parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
46
- const manifest = validateJsonClassifierManifest(parsed, manifestPath);
47
- if (manifest.kind !== expectedKind) {
48
- throw new ClassifierManifestError(`${manifestPath}: manifest kind "${manifest.kind}" does not match parent directory "${expectedKind}"`);
49
- }
45
+ const { manifest, reservedFields, composedOutputSchema, appliesTo } = validateJsonClassifierManifest(parsed, manifestPath);
50
46
  const directoryName = basename(classifierDir);
51
47
  if (manifest.name !== directoryName) {
52
48
  throw new ClassifierManifestError(`${manifestPath}: manifest name "${manifest.name}" does not match directory "${directoryName}"`);
53
49
  }
54
- let systemPrompt = buildStockClassifierPrompt(manifest);
55
- if (manifest.kind === "custom") {
56
- const classifierPrompt = readFileSync(promptPath, "utf8").trim();
57
- if (classifierPrompt.length === 0) {
58
- throw new ClassifierManifestError(`prompt.md must not be empty: ${promptPath}`);
59
- }
60
- systemPrompt = `${systemPrompt}\n\nClassifier guidance:\n${classifierPrompt}`;
50
+ const classifierPromptText = readFileSync(promptPath, "utf8");
51
+ if (classifierPromptText.trim().length === 0) {
52
+ throw new ClassifierManifestError(`prompt.md must not be empty: ${promptPath}`);
61
53
  }
62
- return { ...manifest, systemPrompt };
54
+ const systemPrompt = buildClassifierPrompt({
55
+ manifest,
56
+ reservedFields,
57
+ appliesTo,
58
+ classifierPromptText,
59
+ });
60
+ return {
61
+ ...manifest,
62
+ systemPrompt,
63
+ composedOutputSchema,
64
+ reservedFields,
65
+ appliesTo,
66
+ };
63
67
  }
64
68
  function validateRegistry(manifests) {
65
69
  const names = new Set();
66
- const orders = new Set();
67
70
  for (const manifest of manifests) {
68
71
  if (names.has(manifest.name)) {
69
72
  throw new ClassifierManifestError(`duplicate classifier name: ${manifest.name}`);
70
73
  }
71
74
  names.add(manifest.name);
72
- if (orders.has(manifest.order)) {
73
- throw new ClassifierManifestError(`duplicate classifier order: ${manifest.order}`);
74
- }
75
- orders.add(manifest.order);
76
75
  }
77
76
  }
78
77
  export const REGISTRY = loadClassifierRegistry();
@@ -1,10 +1,17 @@
1
1
  import { type RunClassifier } from "./classifiers.js";
2
2
  import { type OpenClassifyConfig } from "./config.js";
3
- import type { AggregatorConfig, Catalog, PipelineResult } from "./manifest.js";
3
+ import type { AggregatorConfig, Catalog, InspectResult, PipelineResult } from "./manifest.js";
4
4
  import type { OpenClassifyInput } from "./types.js";
5
5
  export type Classifier = (input: OpenClassifyInput, options?: {
6
6
  signal?: AbortSignal;
7
7
  }) => Promise<PipelineResult>;
8
+ export type Inspector = (input: OpenClassifyInput, options?: {
9
+ signal?: AbortSignal;
10
+ }) => Promise<InspectResult>;
11
+ export interface OpenClassify {
12
+ readonly classify: Classifier;
13
+ readonly inspect: Inspector;
14
+ }
8
15
  export interface CreateClassifierOptions {
9
16
  runClassifier?: RunClassifier;
10
17
  catalog?: Catalog;
@@ -17,6 +24,7 @@ export interface CreateClassifierOptions {
17
24
  fetch?: typeof fetch;
18
25
  classifierTimeoutMs?: number;
19
26
  classifierRetryCount?: number;
27
+ maxConcurrency?: number;
20
28
  aggregator?: AggregatorConfig;
21
29
  }
22
- export declare function createClassifier(options?: CreateClassifierOptions): Classifier;
30
+ export declare function createClassifier(options?: CreateClassifierOptions): OpenClassify;
@@ -1,11 +1,11 @@
1
1
  // High-level facade for the pipeline. Builds the runner and catalog once,
2
- // then returns a closure callers can invoke many times without re-loading
3
- // config or the catalog from disk. Backend-agnostic: pass a custom
4
- // `runClassifier` to bypass the bundled Ollama runner entirely.
2
+ // then returns two functions classify() for the user-input/routing pass
3
+ // and inspect() for the assistant-output lean pass. Backend-agnostic: pass a
4
+ // custom `runClassifier` to bypass the bundled Ollama runner entirely.
5
5
  import { loadCatalog } from "./catalog.js";
6
6
  import { classifierModelsFromConfig, loadOpenClassifyConfig, } from "./config.js";
7
7
  import { assertOllamaResources, createOllamaClassifierRunner, OLLAMA_DEFAULT_CATALOG_PATH, } from "./ollama.js";
8
- import { classifyOpenClassifyInput } from "./pipeline.js";
8
+ import { classifyOpenClassifyInput, inspectOpenClassifyInput, } from "./pipeline.js";
9
9
  export function createClassifier(options = {}) {
10
10
  const fileConfig = options.config ??
11
11
  loadOpenClassifyConfig(options.configPath, {
@@ -30,21 +30,36 @@ export function createClassifier(options = {}) {
30
30
  loadCatalog(options.catalogPath ?? fileConfig?.catalog ?? OLLAMA_DEFAULT_CATALOG_PATH);
31
31
  const aggregator = options.aggregator ?? fileConfig?.aggregator;
32
32
  let resourceCheck;
33
- return async (input, callOptions) => {
34
- if (needsResourceCheck) {
35
- resourceCheck ??= assertOllamaResources({
36
- minTotalMemoryBytes: options.minTotalMemoryBytes,
37
- minAvailableMemoryBytes: options.minAvailableMemoryBytes,
38
- });
39
- await resourceCheck;
40
- }
33
+ const ensureResources = async () => {
34
+ if (!needsResourceCheck)
35
+ return;
36
+ resourceCheck ??= assertOllamaResources({
37
+ minTotalMemoryBytes: options.minTotalMemoryBytes,
38
+ minAvailableMemoryBytes: options.minAvailableMemoryBytes,
39
+ });
40
+ await resourceCheck;
41
+ };
42
+ const classify = async (input, callOptions) => {
43
+ await ensureResources();
41
44
  return classifyOpenClassifyInput(input, {
42
45
  runClassifier,
43
46
  catalog,
44
47
  classifierTimeoutMs: options.classifierTimeoutMs,
45
48
  classifierRetryCount: options.classifierRetryCount,
49
+ maxConcurrency: options.maxConcurrency,
46
50
  aggregator,
47
51
  signal: callOptions?.signal,
48
52
  });
49
53
  };
54
+ const inspect = async (input, callOptions) => {
55
+ await ensureResources();
56
+ return inspectOpenClassifyInput(input, {
57
+ runClassifier,
58
+ classifierTimeoutMs: options.classifierTimeoutMs,
59
+ classifierRetryCount: options.classifierRetryCount,
60
+ maxConcurrency: options.maxConcurrency,
61
+ signal: callOptions?.signal,
62
+ });
63
+ };
64
+ return { classify, inspect };
50
65
  }
@@ -16,10 +16,7 @@ export interface OllamaRunnerConfig {
16
16
  readonly seed?: number;
17
17
  readonly num_ctx?: number;
18
18
  };
19
- readonly models?: {
20
- readonly stock?: Readonly<Record<string, string>>;
21
- readonly custom?: Readonly<Record<string, string>>;
22
- };
19
+ readonly models?: Readonly<Record<string, string>>;
23
20
  }
24
21
  export declare class OpenClassifyConfigError extends Error {
25
22
  constructor(message: string);
@@ -1,7 +1,5 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { REGISTRY } from "./classifiers.js";
3
- import { CERTAINTY_GATE_MODES, } from "./manifest.js";
4
- import { STOCK_CLASSIFIER_NAMES } from "./stock.js";
2
+ import { CLASSIFIER_NAMES } from "./classifiers.js";
5
3
  import { isRecord } from "./validation.js";
6
4
  export const DEFAULT_OPEN_CLASSIFY_CONFIG_PATH = "open-classify.config.json";
7
5
  export class OpenClassifyConfigError extends Error {
@@ -26,13 +24,7 @@ export function loadOpenClassifyConfig(path = process.env.OPEN_CLASSIFY_CONFIG ?
26
24
  return validateOpenClassifyConfig(parsed, path);
27
25
  }
28
26
  export function classifierModelsFromConfig(config) {
29
- const models = config?.runner?.models;
30
- if (!models)
31
- return {};
32
- return {
33
- ...models.stock,
34
- ...models.custom,
35
- };
27
+ return { ...config?.runner?.models };
36
28
  }
37
29
  export function validateOpenClassifyConfig(value, path = "open-classify config") {
38
30
  if (!isRecord(value)) {
@@ -49,7 +41,7 @@ function validateAggregator(value, path) {
49
41
  if (!isRecord(value)) {
50
42
  throwConfig(path, "aggregator must be an object");
51
43
  }
52
- ensureAllowedKeys(value, ["certaintyThreshold", "confidenceThreshold", "certaintyGate"], path, "aggregator");
44
+ ensureAllowedKeys(value, ["certaintyThreshold", "confidenceThreshold"], path, "aggregator");
53
45
  return {
54
46
  ...(value.certaintyThreshold === undefined
55
47
  ? {}
@@ -57,9 +49,6 @@ function validateAggregator(value, path) {
57
49
  ...(value.confidenceThreshold === undefined
58
50
  ? {}
59
51
  : { confidenceThreshold: requireUnitFloat(value.confidenceThreshold, path, "aggregator.confidenceThreshold") }),
60
- ...(value.certaintyGate === undefined
61
- ? {}
62
- : { certaintyGate: requireCertaintyGateMode(value.certaintyGate, path, "aggregator.certaintyGate") }),
63
52
  };
64
53
  }
65
54
  function validateRunner(value, path) {
@@ -107,37 +96,16 @@ function validateModels(value, path) {
107
96
  if (!isRecord(value)) {
108
97
  throwConfig(path, "runner.models must be an object");
109
98
  }
110
- ensureAllowedKeys(value, ["stock", "custom"], path, "runner.models");
111
- return {
112
- ...(value.stock === undefined
113
- ? {}
114
- : { stock: validateModelMap(value.stock, path, "runner.models.stock", stockClassifierNames()) }),
115
- ...(value.custom === undefined
116
- ? {}
117
- : { custom: validateModelMap(value.custom, path, "runner.models.custom", customClassifierNames()) }),
118
- };
119
- }
120
- function validateModelMap(value, path, field, allowedNames) {
121
- if (!isRecord(value)) {
122
- throwConfig(path, `${field} must be an object`);
123
- }
99
+ const allowed = new Set(CLASSIFIER_NAMES);
124
100
  const out = {};
125
101
  for (const [name, model] of Object.entries(value)) {
126
- if (!allowedNames.has(name)) {
127
- throwConfig(path, `${field}.${name} is not a known classifier`);
102
+ if (!allowed.has(name)) {
103
+ throwConfig(path, `runner.models.${name} is not a known classifier`);
128
104
  }
129
- out[name] = requireString(model, path, `${field}.${name}`);
105
+ out[name] = requireString(model, path, `runner.models.${name}`);
130
106
  }
131
107
  return out;
132
108
  }
133
- function stockClassifierNames() {
134
- return new Set(STOCK_CLASSIFIER_NAMES);
135
- }
136
- function customClassifierNames() {
137
- return new Set(REGISTRY
138
- .filter((classifier) => classifier.kind === "custom")
139
- .map((classifier) => classifier.name));
140
- }
141
109
  function requireString(value, path, field) {
142
110
  if (typeof value !== "string" || value.trim().length === 0) {
143
111
  throwConfig(path, `${field} must be a non-empty string`);
@@ -157,12 +125,6 @@ function requireUnitFloat(value, path, field) {
157
125
  }
158
126
  return number;
159
127
  }
160
- function requireCertaintyGateMode(value, path, field) {
161
- if (typeof value !== "string" || !CERTAINTY_GATE_MODES.includes(value)) {
162
- throwConfig(path, `${field} must be one of ${CERTAINTY_GATE_MODES.join(", ")}`);
163
- }
164
- return value;
165
- }
166
128
  function ensureAllowedKeys(value, allowedKeys, path, field) {
167
129
  const allowed = new Set(allowedKeys);
168
130
  for (const key of Object.keys(value)) {
@@ -8,6 +8,7 @@ export * from "./input.js";
8
8
  export * from "./manifest.js";
9
9
  export * from "./ollama.js";
10
10
  export * from "./pipeline.js";
11
+ export * from "./reserved-fields.js";
11
12
  export * from "./stock.js";
12
13
  export * from "./stock-prompt.js";
13
14
  export * from "./stock-validation.js";
package/dist/src/index.js CHANGED
@@ -13,6 +13,7 @@ export * from "./input.js";
13
13
  export * from "./manifest.js";
14
14
  export * from "./ollama.js";
15
15
  export * from "./pipeline.js";
16
+ export * from "./reserved-fields.js";
16
17
  export * from "./stock.js";
17
18
  export * from "./stock-prompt.js";
18
19
  export * from "./stock-validation.js";
@@ -1,4 +1,7 @@
1
1
  import type { ClassifierInput, NormalizedOpenClassifyInput, OpenClassifyInput } from "./types.js";
2
2
  export declare function sanitizeText(raw: string): string;
3
- export declare function normalizeOpenClassifyInput(input: OpenClassifyInput): NormalizedOpenClassifyInput;
3
+ export interface NormalizeOptions {
4
+ readonly expectedRole?: "user" | "assistant";
5
+ }
6
+ export declare function normalizeOpenClassifyInput(input: OpenClassifyInput, options?: NormalizeOptions): NormalizedOpenClassifyInput;
4
7
  export declare function toClassifierInput(normalized: NormalizedOpenClassifyInput): ClassifierInput;
package/dist/src/input.js CHANGED
@@ -51,10 +51,11 @@ export function sanitizeText(raw) {
51
51
  function hashCanonicalValue(value) {
52
52
  return createHash("sha256").update(canonicalJson(value)).digest("hex").slice(0, 8);
53
53
  }
54
- export function normalizeOpenClassifyInput(input) {
54
+ export function normalizeOpenClassifyInput(input, options = {}) {
55
55
  assertPlainObject(input, "input");
56
56
  rejectUnknownFields(input, INPUT_FIELDS, "input");
57
- const messages = normalizeMessages(input.messages);
57
+ const expectedRole = options.expectedRole ?? "user";
58
+ const messages = normalizeMessages(input.messages, expectedRole);
58
59
  const target = messages[messages.length - 1];
59
60
  const text = target.text;
60
61
  const normalized = {
@@ -63,7 +64,7 @@ export function normalizeOpenClassifyInput(input) {
63
64
  target_message_hash: "",
64
65
  };
65
66
  normalized.target_message_hash = hashCanonicalValue({
66
- role: target.role ?? "user",
67
+ role: target.role ?? expectedRole,
67
68
  text,
68
69
  });
69
70
  return normalized;
@@ -75,14 +76,14 @@ export function toClassifierInput(normalized) {
75
76
  target_message_hash: normalized.target_message_hash,
76
77
  };
77
78
  }
78
- function normalizeMessages(messages) {
79
+ function normalizeMessages(messages, expectedRole) {
79
80
  if (!Array.isArray(messages)) {
80
81
  throw new TypeError("input.messages must be an array");
81
82
  }
82
83
  if (messages.length === 0) {
83
84
  throw new Error("input.messages must contain at least one message");
84
85
  }
85
- return takeNewestWholeMessagesThatFit(messages);
86
+ return takeNewestWholeMessagesThatFit(messages, expectedRole);
86
87
  }
87
88
  function normalizeConversationMessage(message, path) {
88
89
  assertPlainObject(message, path);
@@ -104,12 +105,13 @@ function normalizeConversationMessage(message, path) {
104
105
  // Walk the message history newest → oldest, keeping whole messages while
105
106
  // we're under both the count cap and the character budget. The final
106
107
  // message is non-negotiable (it's what we're classifying) — we validate it
107
- // stricter, force role=user, and never drop it on length grounds.
108
+ // stricter, force role to the expected one, and never drop it on length
109
+ // grounds.
108
110
  //
109
111
  // We never slice text inside a message: classifiers depend on the full
110
112
  // final message, and slicing earlier ones at arbitrary boundaries tends to
111
113
  // confuse models more than it helps.
112
- function takeNewestWholeMessagesThatFit(messages) {
114
+ function takeNewestWholeMessagesThatFit(messages, expectedRole) {
113
115
  const selected = [];
114
116
  let totalChars = 0;
115
117
  for (let index = messages.length - 1; index >= 0; index -= 1) {
@@ -119,10 +121,10 @@ function takeNewestWholeMessagesThatFit(messages) {
119
121
  if (normalized.text.length === 0) {
120
122
  throw new Error("final message is empty after sanitization");
121
123
  }
122
- if (normalized.role !== undefined && normalized.role !== "user") {
123
- throw new Error("final message must have role user");
124
+ if (normalized.role !== undefined && normalized.role !== expectedRole) {
125
+ throw new Error(`final message must have role ${expectedRole}`);
124
126
  }
125
- normalized.role = "user";
127
+ normalized.role = expectedRole;
126
128
  if (normalized.text.length > CONVERSATION_TEXT_MAX_CHARS) {
127
129
  throw new RangeError(`final message must be ${CONVERSATION_TEXT_MAX_CHARS} characters or fewer`);
128
130
  }
@@ -1,10 +1,8 @@
1
- import type { AckReplySignal, ClassifierOutput, CustomClassifierOutput, FinalReplySignal, PromptInjectionSignal, RoutingSignal, RuntimeClassifierManifest, ToolsSignal } from "./stock.js";
1
+ import type { AckReplySignal, ClassifierAuditOutput, ClassifierOutput, FinalReplySignal, PromptInjectionSignal, RoutingSignal, RuntimeClassifierManifest, ToolsSignal } from "./stock.js";
2
2
  import type { ClassifierInput, ClassifierRunStatus } from "./types.js";
3
3
  import type { DownstreamModelTier, ModelSpecialization } from "./enums.js";
4
4
  export type ClassifierName = string;
5
5
  export type ClassifierResults = Record<ClassifierName, ClassifierOutput>;
6
- export declare const CERTAINTY_GATE_MODES: readonly ["min_score", "avg_score", "off"];
7
- export type CertaintyGateMode = (typeof CERTAINTY_GATE_MODES)[number];
8
6
  export type RunClassifier = (name: ClassifierName, input: ClassifierInput, signal: AbortSignal) => Promise<ClassifierOutput>;
9
7
  export interface CatalogEntry {
10
8
  readonly id: string;
@@ -22,11 +20,11 @@ export interface Catalog {
22
20
  }
23
21
  export interface ModelRecommendationResolution {
24
22
  readonly constraints_used: Partial<{
25
- specialization: ModelSpecialization;
26
- tier: DownstreamModelTier;
23
+ model_specialization: ModelSpecialization;
24
+ model_tier: DownstreamModelTier;
27
25
  }>;
28
26
  readonly constraints_dropped: ReadonlyArray<{
29
- readonly axis: "specialization" | "tier";
27
+ readonly axis: "model_specialization" | "model_tier";
30
28
  readonly reason: "low_confidence" | "no_match_relaxed" | "default_fallback";
31
29
  }>;
32
30
  readonly confidences: Partial<{
@@ -49,10 +47,10 @@ export interface Envelope {
49
47
  readonly routing?: RoutingSignal;
50
48
  readonly tools?: ToolsSignal;
51
49
  readonly prompt_injection?: PromptInjectionSignal;
52
- readonly custom_outputs: ReadonlyArray<CustomClassifierOutput>;
50
+ readonly classifier_outputs: ReadonlyArray<ClassifierAuditOutput>;
53
51
  readonly model_recommendation: ModelRecommendation;
54
52
  }
55
- export type ClassifierCustomOutputs = Record<string, unknown>;
53
+ export type ClassifierPublicOutputs = Record<string, Record<string, unknown>>;
56
54
  export interface DownstreamTargetMessage {
57
55
  readonly role: "user";
58
56
  readonly text: string;
@@ -67,57 +65,31 @@ export type ClassifierEntry = ClassifierOutput & {
67
65
  readonly status: ClassifierRunStatus;
68
66
  readonly version: string;
69
67
  };
68
+ export interface CertaintySummary {
69
+ readonly min: number;
70
+ readonly avg: number;
71
+ }
70
72
  export interface PipelineMeta {
71
73
  readonly classifiers: Record<string, ClassifierEntry>;
74
+ readonly certainty: CertaintySummary;
72
75
  }
73
76
  export interface PipelineAudit extends Envelope {
74
77
  readonly meta: PipelineMeta;
75
- readonly fired_by?: string;
76
- readonly certainty_gate?: LowCertaintyBlockReason;
77
- }
78
- export type BlockReason = PromptInjectionBlockReason | LowCertaintyBlockReason;
79
- export interface PromptInjectionBlockReason {
80
- readonly kind: "prompt_injection";
81
- readonly risk_level: PromptInjectionSignal["risk_level"];
82
78
  }
83
- export interface LowCertaintyBlockReason {
84
- readonly kind: "low_certainty";
85
- readonly mode: Exclude<CertaintyGateMode, "off">;
86
- readonly threshold: number;
87
- readonly score: number;
88
- readonly classifier_scores: Readonly<Record<string, number>>;
89
- readonly low_classifiers: ReadonlyArray<string>;
79
+ export interface InspectResult {
80
+ readonly target_message_hash: string;
81
+ readonly classifier_outputs: ClassifierPublicOutputs;
90
82
  }
91
- export type ReplyPipelineResult = {
92
- readonly action: "reply";
93
- readonly message_id: string;
94
- readonly reply: {
95
- readonly text: string;
96
- };
97
- readonly reason: "preflight_reply";
98
- readonly classifier_outputs: ClassifierCustomOutputs;
99
- readonly audit: Pick<PipelineAudit, "final_reply" | "meta" | "fired_by">;
100
- };
101
- export type BlockPipelineResult = {
102
- readonly action: "block";
103
- readonly message_id: string;
104
- readonly fired_by?: string;
105
- readonly reason: BlockReason;
106
- readonly classifier_outputs: ClassifierCustomOutputs;
107
- readonly audit: Pick<PipelineAudit, "prompt_injection" | "meta" | "fired_by" | "certainty_gate">;
108
- };
109
- export type RoutePipelineResult = {
83
+ export interface PipelineResult {
110
84
  readonly action: "route";
111
- readonly message_id: string;
85
+ readonly target_message_hash: string;
112
86
  readonly downstream: DownstreamPayload;
113
- readonly classifier_outputs: ClassifierCustomOutputs;
87
+ readonly classifier_outputs: ClassifierPublicOutputs;
114
88
  readonly audit: PipelineAudit;
115
- };
116
- export type PipelineResult = ReplyPipelineResult | BlockPipelineResult | RoutePipelineResult;
89
+ }
117
90
  export interface AggregatorConfig {
118
91
  readonly certaintyThreshold?: number;
119
92
  /** @deprecated Use certaintyThreshold. */
120
93
  readonly confidenceThreshold?: number;
121
- readonly certaintyGate?: CertaintyGateMode;
122
94
  }
123
95
  export type ClassifierRegistry = ReadonlyArray<RuntimeClassifierManifest>;
@@ -1,5 +1 @@
1
- export const CERTAINTY_GATE_MODES = [
2
- "min_score",
3
- "avg_score",
4
- "off",
5
- ];
1
+ export {};
@@ -1,9 +1,9 @@
1
1
  import { type RunClassifier } from "./classifiers.js";
2
- import type { AggregatorConfig, Catalog, PipelineResult } from "./manifest.js";
2
+ import type { AggregatorConfig, Catalog, InspectResult, PipelineResult } from "./manifest.js";
3
3
  import type { OpenClassifyInput } from "./types.js";
4
4
  export declare const DEFAULT_CLASSIFIER_TIMEOUT_MS = 15000;
5
5
  export declare const DEFAULT_CLASSIFIER_RETRY_COUNT = 1;
6
- export declare const DEFAULT_CERTAINTY_GATE = "min_score";
6
+ export declare const DEFAULT_MAX_CONCURRENCY = 7;
7
7
  export declare class OpenClassifyNormalizationError extends Error {
8
8
  constructor(cause: unknown);
9
9
  }
@@ -12,7 +12,16 @@ export interface ClassifyOptions {
12
12
  catalog: Catalog;
13
13
  classifierTimeoutMs?: number;
14
14
  classifierRetryCount?: number;
15
+ maxConcurrency?: number;
15
16
  aggregator?: AggregatorConfig;
16
17
  signal?: AbortSignal;
17
18
  }
19
+ export interface InspectOptions {
20
+ runClassifier: RunClassifier;
21
+ classifierTimeoutMs?: number;
22
+ classifierRetryCount?: number;
23
+ maxConcurrency?: number;
24
+ signal?: AbortSignal;
25
+ }
18
26
  export declare function classifyOpenClassifyInput(input: OpenClassifyInput, options: ClassifyOptions): Promise<PipelineResult>;
27
+ export declare function inspectOpenClassifyInput(input: OpenClassifyInput, options: InspectOptions): Promise<InspectResult>;