open-classify 0.9.2 → 1.0.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.
@@ -13,11 +13,11 @@ export const STOCK_CLASSIFIER_NAMES = [
13
13
  ];
14
14
  export const STOCK_CLASSIFIERS_DIR = findStockClassifiersDir();
15
15
  function findStockClassifiersDir() {
16
- // Source runs use ../templates; built package runs use ../../templates from
17
- // dist/src. Keep both so tests and the published package agree.
16
+ // Source runs use ../templates/stock; built package runs use ../../templates/stock
17
+ // from dist/src. Keep both so tests and the published package agree.
18
18
  const candidates = [
19
- join(__dirname, "..", "templates"),
20
- join(__dirname, "..", "..", "templates"),
19
+ join(__dirname, "..", "templates", "stock"),
20
+ join(__dirname, "..", "..", "templates", "stock"),
21
21
  ];
22
22
  return candidates.find((dir) => existsSync(dir)) ?? candidates[0];
23
23
  }
@@ -50,21 +50,28 @@ export function loadClassifierRegistry(classifiersDir = BUILTIN_CLASSIFIERS_DIR)
50
50
  return manifests;
51
51
  }
52
52
  // Build a complete classifier registry from the bundled built-ins plus any
53
- // extra directories supplied by the caller. Sorts by dispatch_order
54
- // ascending (manifests without dispatch_order sort last). Rejects duplicate
55
- // names.
53
+ // extras supplied by the caller. Sorted by dispatch_order ascending
54
+ // (manifests without dispatch_order sort last).
56
55
  //
57
- // Mandatory built-ins (preflight, model_tier, model_specialization,
58
- // prompt_injection) always load. Extras with the same name as a built-in
59
- // throw there's no override mechanism. Customise by editing the bundled
60
- // manifest in your own fork, or replace behaviour entirely with a custom
61
- // `runClassifier`.
56
+ // Precedence rules:
57
+ // - Mandatory built-ins (preflight, model_tier, model_specialization,
58
+ // prompt_injection) always load. A user classifier with the same name
59
+ // as a built-in throws to replace behaviour, use a custom RunClassifier.
60
+ // - User classifiers in `extraDirs` override stock classifiers of the
61
+ // same name. This is the "eject" pattern: `npx open-classify eject
62
+ // tools` copies the stock files into your project, and the runtime
63
+ // transparently switches to your copy.
64
+ // - Two user classifiers with the same name (across different extraDirs)
65
+ // throw — ambiguous ownership.
62
66
  export function buildClassifierRegistry(options = {}) {
63
- const manifests = [
64
- ...loadClassifierRegistry(BUILTIN_CLASSIFIERS_DIR),
65
- ...(options.stockClassifierNames ?? []).map((name) => loadStockClassifier(name)),
66
- ...(options.extraDirs ?? []).flatMap((dir) => loadClassifierRegistry(dir)),
67
- ];
67
+ const builtIns = loadClassifierRegistry(BUILTIN_CLASSIFIERS_DIR);
68
+ const userClassifiers = (options.extraDirs ?? []).flatMap((dir) => loadClassifierRegistry(dir));
69
+ const userNames = new Set(userClassifiers.map((m) => m.name));
70
+ // Skip any stock classifier the user has ejected (matched by name).
71
+ const stockManifests = (options.stockClassifierNames ?? [])
72
+ .filter((name) => !userNames.has(name))
73
+ .map((name) => loadStockClassifier(name));
74
+ const manifests = [...builtIns, ...stockManifests, ...userClassifiers];
68
75
  manifests.sort((a, b) => (a.dispatch_order ?? Infinity) - (b.dispatch_order ?? Infinity));
69
76
  validateRegistry(manifests);
70
77
  const registry = manifests;
@@ -115,7 +122,7 @@ function validateRegistry(manifests) {
115
122
  const names = new Set();
116
123
  for (const manifest of manifests) {
117
124
  if (names.has(manifest.name)) {
118
- throw new ClassifierManifestError(`duplicate classifier name: ${manifest.name} extras cannot override built-ins or other extras. Rename your classifier or run it under a different name.`);
125
+ throw new ClassifierManifestError(`duplicate classifier name: "${manifest.name}". A user classifier cannot override a mandatory built-in (preflight, model_tier, model_specialization, prompt_injection), and no two user classifiers may share a name. To replace a built-in's behaviour, pass a custom RunClassifier.`);
119
126
  }
120
127
  names.add(manifest.name);
121
128
  }
@@ -1,5 +1,5 @@
1
1
  import { type ClassifierName } from "./classifiers.js";
2
- export declare const DEFAULT_OPEN_CLASSIFY_CONFIG_PATH = "open-classify.config.json";
2
+ export declare const DEFAULT_OPEN_CLASSIFY_CONFIG_PATH = "open-classify/config.json";
3
3
  export interface OpenClassifyConfig {
4
4
  readonly runner?: OllamaRunnerConfig;
5
5
  readonly catalog?: string;
@@ -7,7 +7,7 @@ export interface OpenClassifyConfig {
7
7
  }
8
8
  export interface OpenClassifyClassifierConfig {
9
9
  readonly dirs?: ReadonlyArray<string>;
10
- readonly stock?: Readonly<Record<string, boolean>>;
10
+ readonly stock?: ReadonlyArray<string>;
11
11
  }
12
12
  export interface OllamaRunnerConfig {
13
13
  readonly provider: "ollama";
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { STOCK_CLASSIFIER_NAMES } from "./classifiers.js";
3
3
  import { isRecord } from "./validation.js";
4
- export const DEFAULT_OPEN_CLASSIFY_CONFIG_PATH = "open-classify.config.json";
4
+ export const DEFAULT_OPEN_CLASSIFY_CONFIG_PATH = "open-classify/config.json";
5
5
  export class OpenClassifyConfigError extends Error {
6
6
  constructor(message) {
7
7
  super(message);
@@ -30,9 +30,7 @@ export function classifierDirsFromConfig(config) {
30
30
  return config?.classifiers?.dirs ?? [];
31
31
  }
32
32
  export function stockClassifierNamesFromConfig(config) {
33
- return Object.entries(config?.classifiers?.stock ?? {})
34
- .filter(([, enabled]) => enabled)
35
- .map(([name]) => name);
33
+ return config?.classifiers?.stock ?? [];
36
34
  }
37
35
  export function validateOpenClassifyConfig(value, path = "open-classify config") {
38
36
  if (!isRecord(value)) {
@@ -56,7 +54,7 @@ function validateClassifiers(value, path) {
56
54
  ...(value.dirs === undefined ? {} : { dirs: validateStringArray(value.dirs, path, "classifiers.dirs") }),
57
55
  ...(value.stock === undefined
58
56
  ? {}
59
- : { stock: validateBooleanMap(value.stock, path, "classifiers.stock", STOCK_CLASSIFIER_NAMES) }),
57
+ : { stock: validateEnumArray(value.stock, path, "classifiers.stock", STOCK_CLASSIFIER_NAMES) }),
60
58
  };
61
59
  }
62
60
  function validateRunner(value, path) {
@@ -116,20 +114,24 @@ function validateStringArray(value, path, field) {
116
114
  }
117
115
  return value.map((item, index) => requireString(item, path, `${field}[${index}]`));
118
116
  }
119
- function validateBooleanMap(value, path, field, allowedKeys) {
120
- if (!isRecord(value)) {
121
- throwConfig(path, `${field} must be an object`);
117
+ function validateEnumArray(value, path, field, allowedValues) {
118
+ if (!Array.isArray(value)) {
119
+ throwConfig(path, `${field} must be an array`);
122
120
  }
123
- const allowed = allowedKeys === undefined ? undefined : new Set(allowedKeys);
124
- const out = {};
125
- for (const [name, enabled] of Object.entries(value)) {
126
- if (allowed !== undefined && !allowed.has(name)) {
127
- throwConfig(path, `${field}.${name} is not supported (available: ${[...allowed].join(", ")})`);
121
+ const allowed = new Set(allowedValues);
122
+ const seen = new Set();
123
+ const out = [];
124
+ for (let i = 0; i < value.length; i++) {
125
+ const item = value[i];
126
+ const name = requireString(item, path, `${field}[${i}]`);
127
+ if (!allowed.has(name)) {
128
+ throwConfig(path, `${field}[${i}] "${name}" is not supported (available: ${[...allowed].join(", ")})`);
128
129
  }
129
- if (typeof enabled !== "boolean") {
130
- throwConfig(path, `${field}.${name} must be a boolean`);
130
+ if (seen.has(name)) {
131
+ throwConfig(path, `${field}[${i}] "${name}" is listed more than once`);
131
132
  }
132
- out[name] = enabled;
133
+ seen.add(name);
134
+ out.push(name);
133
135
  }
134
136
  return out;
135
137
  }
@@ -1,11 +1,11 @@
1
1
  # Adding a classifier
2
2
 
3
- Every classifier uses the same two-file layout. Drop a folder into a directory listed under `classifiers.dirs` in `open-classify.config.json` (defaults to `./classifiers/` after `npx open-classify init`) and the runtime picks it up on the next start.
3
+ Every classifier uses the same two-file layout. Drop a folder into a directory listed under `classifiers.dirs` in `open-classify/config.json` (defaults to `open-classify/classifiers/` after `npx open-classify init`) and the runtime picks it up on the next start.
4
4
 
5
5
  ## 1. Create the directory
6
6
 
7
7
  ```
8
- classifiers/<name>/
8
+ open-classify/classifiers/<name>/
9
9
  ├── manifest.json
10
10
  └── prompt.md
11
11
  ```
@@ -71,7 +71,7 @@ Rules:
71
71
  - `reason` and `certainty` are added to the composed schema by the runtime — don't declare them.
72
72
  - `fallback` must validate against the composed schema. Only `reason` and `certainty` are required in fallback; reserved fields and `output_schema.required` fields are exempt (a "no signal" fallback usually omits them).
73
73
  - `output_schema.examples` (JSON Schema standard) must validate against the composed schema at load time, so a broken example fails the build, not the model call.
74
- - **Name collisions throw.** Extras cannot override the mandatory built-ins (`preflight`, `model_tier`, `model_specialization`, `prompt_injection`). To customize one of those, use a custom `RunClassifier` to intercept it (see "Replacing the backend" below).
74
+ - **Name collisions throw.** A user classifier cannot override a mandatory built-in (`preflight`, `model_tier`, `model_specialization`, `prompt_injection`). To customize one of those, use a custom `RunClassifier` to intercept it (see "Replacing the backend" below).
75
75
 
76
76
  See [manifests.md](manifests.md) for the full field list.
77
77
 
@@ -87,11 +87,11 @@ Return an empty array when no clear topic applies.
87
87
  Do not invent tags for vague or ambiguous messages.
88
88
  ```
89
89
 
90
- Don't paste enum values for reserved fields — the runtime injects them with canonical wording so they never drift from `src/enums.ts`.
90
+ Don't paste enum values for reserved fields — the runtime injects them with canonical wording so they never drift from the source enums.
91
91
 
92
92
  ## 4. Use it
93
93
 
94
- After `npx open-classify init`, your `classifiers/` directory already exists and `open-classify.config.json` points at it. Drop your folder there and call `createClassifier()`:
94
+ After `npx open-classify init`, `open-classify/classifiers/` exists and `open-classify/config.json` points at it. Drop your folder there and call `createClassifier()`:
95
95
 
96
96
  ```ts
97
97
  import { createClassifier } from "open-classify";
@@ -105,31 +105,32 @@ const result = await classify({
105
105
  const tags = result.classifier_outputs.topic_tags?.tags ?? [];
106
106
  ```
107
107
 
108
- `classifiers.dirs` entries resolve relative to the config file, so the scaffold keeps working even if your server starts from a different current working directory.
108
+ `classifiers.dirs` entries resolve relative to the config file, so the scaffold keeps working even when your server starts from a different working directory.
109
109
 
110
110
  If the manifest is malformed, `createClassifier` throws `ClassifierManifestError` at startup with the path and a specific reason — typos fail loud.
111
111
 
112
112
  ## Enabling or customizing optional stock classifiers
113
113
 
114
- `tools`, `memory_retrieval_queries`, `conversation_digest`, and `context_shift` ship as package-owned optional stock classifiers. Enable one in `open-classify.config.json` if you want package updates to keep improving its prompt:
114
+ `tools`, `memory_retrieval_queries`, `conversation_digest`, and `context_shift` ship as package-owned optional stock classifiers. They're off by default. To enable one, list it in `open-classify/config.json`:
115
115
 
116
116
  ```json
117
117
  {
118
118
  "classifiers": {
119
- "stock": {
120
- "tools": true
121
- }
119
+ "dirs": ["classifiers"],
120
+ "stock": ["tools"]
122
121
  }
123
122
  }
124
123
  ```
125
124
 
126
- `npx open-classify init` also copies editable templates into your `classifiers/` directory as `_<name>/` — inactive because of the underscore prefix. To customize one, keep the matching `classifiers.stock.<name>` value `false`, edit the copy, then turn it on:
125
+ The package-owned prompt is used, and `npm update open-classify` keeps it current.
126
+
127
+ When you want to take a stock classifier over and edit it:
127
128
 
128
129
  ```sh
129
- mv classifiers/_tools classifiers/tools
130
+ npx open-classify eject tools
130
131
  ```
131
132
 
132
- Edit `manifest.json` first if you need to (`tools` in particular ships with an opinionated `allowed_tools` list you'll almost certainly want to tailor). The reverse works on any copied/custom classifier: rename `<name>/` `_<name>/` to deactivate without deleting.
133
+ That copies the stock files into `open-classify/classifiers/tools/`. The runtime transparently switches to your local copy (no config change needed; a local classifier with the same name as a stock classifier always wins). `npm update` won't touch the files. To revert, delete the folder.
133
134
 
134
135
  ## Targeting the assistant response
135
136
 
@@ -156,7 +157,7 @@ The built-in `prompt_injection` ships tagged `"both"` so it runs on both sides.
156
157
 
157
158
  ## Choosing the classifier model
158
159
 
159
- In `open-classify.config.json`:
160
+ In `open-classify/config.json`:
160
161
 
161
162
  ```json
162
163
  {
package/docs/manifests.md CHANGED
@@ -9,7 +9,7 @@ classifiers/
9
9
  prompt.md
10
10
  ```
11
11
 
12
- Folders whose names start with `_` are skipped by the loader — that's how the scaffolded `_<name>/` templates stay inactive until you drop the underscore.
12
+ Folders whose names start with `_` are skipped by the loader — handy if you want to deactivate a classifier without deleting it.
13
13
 
14
14
  ## Fields
15
15
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-classify",
3
- "version": "0.9.2",
3
+ "version": "1.0.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",
@@ -36,9 +36,8 @@
36
36
  "bin",
37
37
  "dist/src",
38
38
  "docs",
39
- "downstream-models.json",
40
- "open-classify.config.example.json",
41
39
  "templates",
40
+ "CHANGELOG.md",
42
41
  "LICENSE",
43
42
  "README.md"
44
43
  ],
@@ -0,0 +1,46 @@
1
+ # open-classify/
2
+
3
+ Everything Open Classify reads at runtime lives in this folder:
4
+
5
+ - `config.json` — runtime configuration (Ollama host, model, classifier dirs)
6
+ - `downstream-models.json` — catalog of models the aggregator can route to
7
+ - `classifiers/` — your own classifiers, plus any stock classifiers you've
8
+ ejected for customization
9
+
10
+ To remove Open Classify entirely:
11
+
12
+ ```sh
13
+ rm -rf open-classify/
14
+ npm uninstall open-classify
15
+ ```
16
+
17
+ ## Stock classifiers
18
+
19
+ Open Classify ships four optional stock classifiers (`tools`,
20
+ `memory_retrieval_queries`, `conversation_digest`, `context_shift`) that
21
+ live inside the `open-classify` package. Enable one by listing its name
22
+ in `config.json`:
23
+
24
+ ```json
25
+ {
26
+ "classifiers": {
27
+ "dirs": ["classifiers"],
28
+ "stock": ["tools"]
29
+ }
30
+ }
31
+ ```
32
+
33
+ The package-owned prompt is used, and `npm update open-classify` keeps it
34
+ current. When you need to take a stock classifier over and edit it:
35
+
36
+ ```sh
37
+ npx open-classify eject tools
38
+ ```
39
+
40
+ That copies the stock files into `classifiers/tools/`. From that point on,
41
+ the runtime uses your local copy and `npm update` leaves it alone. A local
42
+ classifier always wins on name, so eject works whether or not `tools` is
43
+ listed in `classifiers.stock`. Delete the folder to revert.
44
+
45
+ See the [author guide](https://github.com/taylorbayouth/open-classify/blob/main/docs/adding-a-classifier.md)
46
+ for writing your own classifier from scratch.
@@ -0,0 +1,22 @@
1
+ # classifiers/
2
+
3
+ Drop a folder here per classifier. Each folder needs two files:
4
+
5
+ - `manifest.json` — declares the output shape and a fallback
6
+ - `prompt.md` — the classification instructions
7
+
8
+ The folder name must match the manifest's `name` field. The runtime picks
9
+ up every classifier here on the next start.
10
+
11
+ To customize one of the four stock classifiers (`tools`,
12
+ `memory_retrieval_queries`, `conversation_digest`, `context_shift`):
13
+
14
+ ```sh
15
+ npx open-classify eject tools
16
+ ```
17
+
18
+ That copies the stock files into `classifiers/tools/`. You own them from
19
+ then on — `npm update open-classify` won't touch them.
20
+
21
+ See the [author guide](https://github.com/taylorbayouth/open-classify/blob/main/docs/adding-a-classifier.md)
22
+ for the full manifest reference.
@@ -0,0 +1,12 @@
1
+ {
2
+ "runner": {
3
+ "provider": "ollama",
4
+ "host": "http://127.0.0.1:11434",
5
+ "defaultModel": "gemma4:e4b-it-q4_K_M"
6
+ },
7
+ "catalog": "downstream-models.json",
8
+ "classifiers": {
9
+ "dirs": ["classifiers"],
10
+ "stock": []
11
+ }
12
+ }
@@ -1,26 +0,0 @@
1
- {
2
- "runner": {
3
- "provider": "ollama",
4
- "host": "http://127.0.0.1:11434",
5
- "defaultModel": "gemma4:e4b-it-q4_K_M",
6
- "options": {
7
- "temperature": 0,
8
- "top_p": 1,
9
- "seed": 0,
10
- "num_ctx": 4096
11
- },
12
- "models": {
13
- "prompt_injection": "llama-guard3:8b"
14
- }
15
- },
16
- "catalog": "downstream-models.json",
17
- "classifiers": {
18
- "dirs": ["classifiers"],
19
- "stock": {
20
- "tools": false,
21
- "memory_retrieval_queries": false,
22
- "conversation_digest": false,
23
- "context_shift": false
24
- }
25
- }
26
- }
File without changes
File without changes