open-classify 0.1.2 → 0.2.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
@@ -54,16 +54,15 @@ Node 18+. The packaged runner is local Ollama and ships with `gemma4:e4b-it-q4_K
54
54
  ## Hello World
55
55
 
56
56
  ```ts
57
- import { classifyWithOllama, loadCatalog } from "open-classify";
57
+ import { createClassifier } from "open-classify";
58
58
 
59
- const result = await classifyWithOllama(
60
- {
61
- messages: [
62
- { role: "user", text: "Can you review the attached contract?" },
63
- ],
64
- },
65
- { catalog: loadCatalog("downstream-models.json") },
66
- );
59
+ const classify = createClassifier();
60
+
61
+ const result = await classify({
62
+ messages: [
63
+ { role: "user", text: "Can you review the attached contract?" },
64
+ ],
65
+ });
67
66
 
68
67
  if (result.action === "route") {
69
68
  // result.downstream.model_id is a concrete model from your catalog.
@@ -72,6 +71,8 @@ if (result.action === "route") {
72
71
  }
73
72
  ```
74
73
 
74
+ `createClassifier` builds the runner and loads the model catalog once. Reuse the returned `classify` function across your app — every call is a plain function invocation, no re-initialization.
75
+
75
76
  ## What you get back
76
77
 
77
78
  Every call returns a `PipelineResult` with one of three `action` values:
@@ -217,7 +218,7 @@ The resolver picks the cheapest model matching `specialization` and `tier`, rela
217
218
 
218
219
  ## Input contract
219
220
 
220
- `classifyWithOllama({ messages })` — that's the whole input.
221
+ `classify({ messages })` — that's the whole input.
221
222
 
222
223
  - `messages` is chronological, oldest to newest, and must end with the user message you want classified.
223
224
  - Open Classify keeps whole messages only, drops oldest first to fit a 5,000-char budget, and caps history at 20 messages.
@@ -275,7 +276,19 @@ type RunClassifier = (
275
276
  ) => Promise<ClassifierOutput>;
276
277
  ```
277
278
 
278
- Pass any `RunClassifier` to `classifyOpenClassifyInput(input, { runClassifier, catalog })` to back classifiers with OpenAI, Anthropic, a remote service, or anything else. This is a code-level extension point, separate from the Ollama-only config file runner.
279
+ Pass any `RunClassifier` to `createClassifier` to back classifiers with OpenAI, Anthropic, a remote service, or anything else. The factory takes care of catalog loading and pipeline wiring; you only own the per-classifier call.
280
+
281
+ ```ts
282
+ import { createClassifier, type RunClassifier } from "open-classify";
283
+
284
+ const runClassifier: RunClassifier = async (name, input, signal) => {
285
+ // call your provider of choice, return a ClassifierOutput
286
+ };
287
+
288
+ const classify = createClassifier({ runClassifier });
289
+ ```
290
+
291
+ For the lowest-level entry point, `classifyOpenClassifyInput(input, { runClassifier, catalog })` skips the factory entirely.
279
292
 
280
293
  ## Further reading
281
294
 
@@ -0,0 +1,31 @@
1
+ {
2
+ "kind": "custom",
3
+ "name": "context_shift",
4
+ "version": "1.0.0",
5
+ "purpose": "Classify whether the latest message continues, branches from, returns to, or starts a conversation thread.",
6
+ "order": 80,
7
+ "fallback": {
8
+ "reason": "Classifier failed; context relationship is ambiguous.",
9
+ "certainty": "no_signal",
10
+ "output": {
11
+ "decision": "ambiguous"
12
+ }
13
+ },
14
+ "output_schema": {
15
+ "type": "object",
16
+ "additionalProperties": false,
17
+ "required": ["decision"],
18
+ "properties": {
19
+ "decision": {
20
+ "type": "string",
21
+ "enum": [
22
+ "same_active_thread",
23
+ "related_branch",
24
+ "return_to_prior_thread",
25
+ "new_thread",
26
+ "ambiguous"
27
+ ]
28
+ }
29
+ }
30
+ }
31
+ }
@@ -0,0 +1,12 @@
1
+ You are the context_shift classifier for an AI assistant routing system.
2
+
3
+ `output.decision` describes how the final user message relates to the visible conversation history.
4
+
5
+ Use `same_active_thread` when the final message directly continues, clarifies, corrects, or asks for the next step on the active topic.
6
+ Use `related_branch` when it starts a distinct subtask or angle that still depends on the active topic.
7
+ Use `return_to_prior_thread` when it resumes an earlier visible topic after the active topic changed.
8
+ Use `new_thread` when it starts a materially independent topic that does not rely on the visible conversation history.
9
+ Use `ambiguous` when the visible history is insufficient to choose one of the other labels.
10
+
11
+ Do not infer hidden conversations, saved memories, external thread ids, or user intent that is not visible in the provided messages.
12
+ Certainty should reflect confidence in the chosen label; `ambiguous` may have high certainty when ambiguity is the correct judgment.
@@ -1,7 +1,11 @@
1
1
  Emit the tools verdict as top-level fields:
2
2
 
3
+ - reason: required compressed justification, 120 characters or fewer
4
+ - certainty: required certainty tag from the shared certainty enum
3
5
  - tools: array of allowed tool ids
4
6
 
5
7
  {{allowed_tools}}
6
8
 
7
9
  An empty tools array means no downstream tools are required.
10
+
11
+ Shape: {"reason":"...","certainty":"strong","tools":["workspace"]}.
@@ -0,0 +1,22 @@
1
+ import { type RunClassifier } from "./classifiers.js";
2
+ import { type OpenClassifyConfig } from "./config.js";
3
+ import type { AggregatorConfig, Catalog, PipelineResult } from "./manifest.js";
4
+ import type { OpenClassifyInput } from "./types.js";
5
+ export type Classifier = (input: OpenClassifyInput, options?: {
6
+ signal?: AbortSignal;
7
+ }) => Promise<PipelineResult>;
8
+ export interface CreateClassifierOptions {
9
+ runClassifier?: RunClassifier;
10
+ catalog?: Catalog;
11
+ config?: OpenClassifyConfig;
12
+ configPath?: string;
13
+ catalogPath?: string;
14
+ skipResourceCheck?: boolean;
15
+ minAvailableMemoryBytes?: number;
16
+ minTotalMemoryBytes?: number;
17
+ fetch?: typeof fetch;
18
+ classifierTimeoutMs?: number;
19
+ classifierRetryCount?: number;
20
+ aggregator?: AggregatorConfig;
21
+ }
22
+ export declare function createClassifier(options?: CreateClassifierOptions): Classifier;
@@ -0,0 +1,50 @@
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.
5
+ import { loadCatalog } from "./catalog.js";
6
+ import { classifierModelsFromConfig, loadOpenClassifyConfig, } from "./config.js";
7
+ import { assertOllamaResources, createOllamaClassifierRunner, OLLAMA_DEFAULT_CATALOG_PATH, } from "./ollama.js";
8
+ import { classifyOpenClassifyInput } from "./pipeline.js";
9
+ export function createClassifier(options = {}) {
10
+ const fileConfig = options.config ??
11
+ loadOpenClassifyConfig(options.configPath, {
12
+ optional: options.configPath === undefined &&
13
+ process.env.OPEN_CLASSIFY_CONFIG === undefined,
14
+ });
15
+ // When we own the runner, hoist the resource check to the wrapper so a
16
+ // failure surfaces as a top-level rejection — the per-classifier fallback
17
+ // path would otherwise mask it as five "classifier failed" entries.
18
+ const ownsRunner = options.runClassifier === undefined;
19
+ const needsResourceCheck = ownsRunner && !options.skipResourceCheck;
20
+ const runClassifier = options.runClassifier ??
21
+ createOllamaClassifierRunner({
22
+ host: fileConfig?.runner?.host,
23
+ defaultModel: fileConfig?.runner?.defaultModel,
24
+ models: classifierModelsFromConfig(fileConfig),
25
+ options: fileConfig?.runner?.options,
26
+ skipResourceCheck: needsResourceCheck ? true : options.skipResourceCheck,
27
+ fetch: options.fetch,
28
+ });
29
+ const catalog = options.catalog ??
30
+ loadCatalog(options.catalogPath ?? fileConfig?.catalog ?? OLLAMA_DEFAULT_CATALOG_PATH);
31
+ const aggregator = options.aggregator ?? fileConfig?.aggregator;
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
+ }
41
+ return classifyOpenClassifyInput(input, {
42
+ runClassifier,
43
+ catalog,
44
+ classifierTimeoutMs: options.classifierTimeoutMs,
45
+ classifierRetryCount: options.classifierRetryCount,
46
+ aggregator,
47
+ signal: callOptions?.signal,
48
+ });
49
+ };
50
+ }
@@ -1,6 +1,7 @@
1
1
  export * from "./aggregator.js";
2
2
  export * from "./catalog.js";
3
3
  export * from "./classifiers.js";
4
+ export * from "./classify.js";
4
5
  export * from "./config.js";
5
6
  export * from "./enums.js";
6
7
  export * from "./input.js";
package/dist/src/index.js CHANGED
@@ -6,6 +6,7 @@
6
6
  export * from "./aggregator.js";
7
7
  export * from "./catalog.js";
8
8
  export * from "./classifiers.js";
9
+ export * from "./classify.js";
9
10
  export * from "./config.js";
10
11
  export * from "./enums.js";
11
12
  export * from "./input.js";
@@ -1,8 +1,4 @@
1
1
  import { type ClassifierName, type RunClassifier } from "./classifiers.js";
2
- import { type OpenClassifyConfig } from "./config.js";
3
- import { classifyOpenClassifyInput } from "./pipeline.js";
4
- import type { AggregatorConfig, Catalog } from "./manifest.js";
5
- import type { OpenClassifyInput } from "./types.js";
6
2
  export declare const OLLAMA_DEFAULT_HOST = "http://localhost:11434";
7
3
  export declare const OLLAMA_BASE_MODEL = "gemma4:e4b-it-q4_K_M";
8
4
  export declare const OLLAMA_BASE_MODEL_NATIVE_CONTEXT_LENGTH = 131072;
@@ -28,13 +24,6 @@ export interface OllamaClassifierRunnerConfig {
28
24
  minAvailableMemoryBytes?: number;
29
25
  minTotalMemoryBytes?: number;
30
26
  }
31
- export interface ClassifyWithOllamaConfig extends OllamaClassifierRunnerConfig {
32
- catalog?: Catalog;
33
- catalogPath?: string;
34
- configPath?: string;
35
- openClassifyConfig?: OpenClassifyConfig;
36
- aggregator?: AggregatorConfig;
37
- }
38
27
  export declare class OllamaClassifierError extends Error {
39
28
  readonly classifier: ClassifierName;
40
29
  readonly model: string;
@@ -52,4 +41,3 @@ export declare function assertOllamaResources(options?: {
52
41
  minTotalMemoryBytes?: number;
53
42
  minAvailableMemoryBytes?: number;
54
43
  }): Promise<void>;
55
- export declare function classifyWithOllama(input: OpenClassifyInput, config?: ClassifyWithOllamaConfig): ReturnType<typeof classifyOpenClassifyInput>;
@@ -10,10 +10,7 @@
10
10
  // `classifyOpenClassifyInput` — you don't have to use this module at all.
11
11
  import { execFile } from "node:child_process";
12
12
  import { promisify } from "node:util";
13
- import { loadCatalog } from "./catalog.js";
14
13
  import { CLASSIFIER_NAMES, MODULES_BY_NAME, validateClassifierOutput, } from "./classifiers.js";
15
- import { classifierModelsFromConfig, loadOpenClassifyConfig, } from "./config.js";
16
- import { classifyOpenClassifyInput } from "./pipeline.js";
17
14
  import { ClassifierValidationError, isRecord, } from "./validation.js";
18
15
  export const OLLAMA_DEFAULT_HOST = "http://localhost:11434";
19
16
  export const OLLAMA_BASE_MODEL = "gemma4:e4b-it-q4_K_M";
@@ -93,40 +90,6 @@ export async function assertOllamaResources(options = {}) {
93
90
  throw new OllamaResourceError(totalMemoryBytes, availableMemoryBytes, minTotalMemoryBytes, minAvailableMemoryBytes);
94
91
  }
95
92
  }
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
- aggregator: config.aggregator ?? fileConfig?.aggregator,
128
- });
129
- }
130
93
  async function runOllamaClassifier(name, input, signal, fetchImpl, host, model, options, allowManifestModel) {
131
94
  const module_ = MODULES_BY_NAME[name];
132
95
  const systemPrompt = module_.systemPrompt;
@@ -77,7 +77,8 @@ If the manifest is malformed, the loader throws `ClassifierManifestError` with t
77
77
  ## 5. Consume the output
78
78
 
79
79
  ```ts
80
- const result = await classifyWithOllama(input, { catalog });
80
+ const classify = createClassifier({ catalog });
81
+ const result = await classify(input);
81
82
  if (result.action === "route") {
82
83
  const tags = result.classifier_outputs.topic_tags?.tags ?? [];
83
84
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-classify",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Manifest-driven classifier runtime for routing user messages to downstream AI models",
5
5
  "license": "MIT",
6
6
  "author": "Taylor Bayouth",