open-classify 0.9.0 → 0.9.2

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
@@ -43,30 +43,26 @@ Every classifier uses the same manifest shape and emits the same output envelope
43
43
 
44
44
  ## Getting started
45
45
 
46
- Node 18+. The packaged runner uses local Ollama with `gemma4:e4b-it-q4_K_M` as the zero-config classifier model. Pluggable via `open-classify.config.json` or a custom `RunClassifier`.
47
-
48
- **1. Install**
46
+ Prerequisites: Node 18+, [Ollama](https://ollama.com), and the default classifier model:
49
47
 
50
48
  ```sh
51
- npm install open-classify
49
+ ollama pull gemma4:e4b-it-q4_K_M
52
50
  ```
53
51
 
54
- **2. Scaffold**
52
+ **1. Scaffold (from your project root)**
55
53
 
56
54
  ```sh
57
55
  npx open-classify init
58
56
  ```
59
57
 
60
- This creates `open-classify.config.json` and a `classifiers/` directory in your project root. You'll see exactly what will be written and asked to confirm. Re-run safe: existing files are skipped.
58
+ If the package isn't installed yet, `init` offers to add it. It writes `open-classify.config.json`, `downstream-models.json`, and a `classifiers/` directory. Re-run safe: existing files are skipped. Verify the install at any time with `npx open-classify doctor`.
61
59
 
62
- **3. Use it**
60
+ **2. Use it**
63
61
 
64
62
  ```ts
65
63
  import { createClassifier } from "open-classify";
66
64
 
67
- const { classify } = createClassifier({
68
- extraClassifierDirs: ["./classifiers"],
69
- });
65
+ const { classify } = createClassifier();
70
66
 
71
67
  const result = await classify({
72
68
  messages: [{ role: "user", text: "Can you review the attached contract?" }],
@@ -77,17 +73,29 @@ else if (result.action === "block") handleBlock(result.block_reason); // inj
77
73
  else callDownstream(result.model_id, result.tools, result.reply?.text); // route the real request
78
74
  ```
79
75
 
80
- **4. Activate or customize a classifier**
76
+ `createClassifier()` looks for `open-classify.config.json` in the working directory, so the scaffolded layout works with no further wiring.
77
+
78
+ **3. Enable or customize optional classifiers**
79
+
80
+ Four mandatory base classifiers (`preflight`, `model_tier`, `model_specialization`, `prompt_injection`) always run from the package. Four more (`tools`, `memory_retrieval_queries`, `conversation_digest`, `context_shift`) are optional and default to off.
81
+
82
+ You have two ways to use the optional ones:
83
+
84
+ ```json
85
+ { "classifiers": { "stock": { "tools": true } } }
86
+ ```
87
+
88
+ Set the toggle to `true` to run the package-owned version. `npm update open-classify` keeps the prompt current.
81
89
 
82
- Inside `classifiers/` you'll find four `_<name>/` directories templates copied from the package, inactive because of the underscore prefix. To turn one on, drop the underscore:
90
+ Or customize a local copy. `init` scaffolds editable templates in `classifiers/_<name>/` (inactive because of the underscore prefix). To take one over, keep the stock toggle off and rename the folder:
83
91
 
84
92
  ```sh
85
93
  mv classifiers/_tools classifiers/tools
86
94
  ```
87
95
 
88
- Edit `manifest.json` first if you need to (e.g. trim `allowed_tools` for your app). The same underscore convention works the other way too: rename `my_classifier/` `_my_classifier/` to take any classifier out of the active set without deleting it.
96
+ The same convention works in reverse: rename any active classifier `<name>/` `_<name>/` to deactivate without deleting.
89
97
 
90
- To write a new classifier from scratch, drop a `<name>/manifest.json` + `<name>/prompt.md` in `classifiers/`. See [docs/adding-a-classifier.md](docs/adding-a-classifier.md).
98
+ To write a new classifier, drop a `<name>/manifest.json` + `<name>/prompt.md` in `classifiers/`. See [docs/adding-a-classifier.md](docs/adding-a-classifier.md).
91
99
 
92
100
  ### Classifying assistant output
93
101
 
@@ -152,7 +160,7 @@ Example result:
152
160
 
153
161
  Every classifier — bundled or your own — uses the same two-file shape (`manifest.json` + `prompt.md`) and emits the same envelope: `{ reason, certainty, ...payload }`. Some payload fields are **reserved** (like `model_tier`, `final_reply`, `risk_level`); the aggregator knows how to consume them into the routing decision. Everything else passes through to the caller.
154
162
 
155
- Open Classify ships eight built-in classifiers. **Four are mandatory** they always load, they can't be turned off, and extras can't override them. The other four ship as **templates** that `init` copies into your project as inactive (`_<name>/`); rename to activate.
163
+ Open Classify ships four mandatory base classifiers and four optional stock classifiers. The mandatory base classifiers always load from the package, can't be turned off, and are updated by `npm update open-classify`. Optional stock classifiers also live in the package, but are enabled by `open-classify.config.json`.
156
164
 
157
165
  | Name | dispatch_order | Reserved fields | Bundled as | What the aggregator does with it |
158
166
  |---|---|---|---|---|
@@ -160,12 +168,27 @@ Open Classify ships eight built-in classifiers. **Four are mandatory** — they
160
168
  | `model_tier` | 20 | `model_tier` | mandatory | Feeds the catalog resolver as a soft constraint |
161
169
  | `model_specialization` | 30 | `model_specialization` | mandatory | Feeds the catalog resolver as a soft constraint |
162
170
  | `prompt_injection` | 50 | `risk_level` | mandatory | High-risk/unknown → `action: "block"`; suspicious → advisory |
163
- | `tools` | 40 | `tools` | template | Sets `result.tools` |
164
- | `memory_retrieval_queries` | 60 | — | template | Passes through to `classifier_outputs` |
165
- | `conversation_digest` | 70 | — | template | Passes through |
166
- | `context_shift` | 80 | — | template | Passes through |
171
+ | `tools` | 40 | `tools` | optional stock | Sets `result.tools` |
172
+ | `memory_retrieval_queries` | 60 | — | optional stock | Passes through to `classifier_outputs` |
173
+ | `conversation_digest` | 70 | — | optional stock | Passes through |
174
+ | `context_shift` | 80 | — | optional stock | Passes through |
175
+
176
+ For package-owned stock classifiers, `open-classify.config.json` is the on/off switch:
177
+
178
+ ```json
179
+ {
180
+ "classifiers": {
181
+ "stock": {
182
+ "tools": true,
183
+ "memory_retrieval_queries": false,
184
+ "conversation_digest": false,
185
+ "context_shift": false
186
+ }
187
+ }
188
+ }
189
+ ```
167
190
 
168
- The directory-naming convention (`_<name>/` = inactive) is the only on/off mechanism, and it applies equally to bundled templates and your own classifiers. No `disabled` config, no allow-lists, no flags. If a folder is in `classifiers/` without a leading underscore, it runs.
191
+ For copied/custom classifiers in `classifiers/`, the directory-naming convention still applies: `_<name>/` is inactive; `<name>/` runs. Root project files are user-owned, so `init` skips existing config/classifier files unless you pass `--force`.
169
192
 
170
193
  > Need to customize `preflight`'s prompt or any other mandatory built-in? Use a custom `RunClassifier` (see [Bring your own backend](#bring-your-own-backend)) to intercept it, or fork the package.
171
194
 
@@ -283,36 +306,20 @@ The resolver picks the cheapest model matching `model_specialization` and `model
283
306
  - Open Classify keeps whole messages only, drops oldest first to fit a 5,000-char budget, and caps history at 20 messages.
284
307
  - Unknown fields are rejected, not passed through.
285
308
 
286
- ## Local setup
309
+ ## Configuration
287
310
 
288
- ```sh
289
- npm run setup
290
- ```
291
-
292
- Checks prerequisites (Node, npm, Ollama), confirms the base model is pulled, installs dependencies, and builds. Idempotent — safe to re-run.
311
+ `npx open-classify init` writes a working `open-classify.config.json` for you. To customize, edit it directly — the full set of supported fields (with realistic example values) lives in [open-classify.config.example.json](open-classify.config.example.json).
293
312
 
294
- Optional Ollama runtime config:
295
-
296
- ```sh
297
- cp open-classify.config.example.json open-classify.config.json
298
- ```
299
-
300
- ```json
301
- {
302
- "runner": {
303
- "provider": "ollama",
304
- "defaultModel": "gemma4:e4b-it-q4_K_M",
305
- "models": {
306
- "model_tier": "qwen2.5:7b-instruct-q4_K_M",
307
- "prompt_injection": "llama-guard3:8b",
308
- "memory_retrieval_queries": "qwen2.5:7b-instruct-q4_K_M"
309
- }
310
- },
311
- "catalog": "downstream-models.json"
312
- }
313
- ```
314
-
315
- `runner.provider` currently supports `"ollama"` only. `runner.defaultModel` applies to any classifier without an explicit `runner.models` entry. `runner.models` is a flat map keyed by classifier name.
313
+ | Field | What it controls |
314
+ |---|---|
315
+ | `runner.provider` | Backend. Currently `"ollama"` only. |
316
+ | `runner.host` | Ollama host URL. Defaults to `http://127.0.0.1:11434`. |
317
+ | `runner.defaultModel` | Classifier model used when there is no per-classifier override. |
318
+ | `runner.options` | Ollama generation options: `temperature`, `top_p`, `seed`, `num_ctx`. |
319
+ | `runner.models` | Per-classifier model overrides. Flat map keyed by classifier name. |
320
+ | `catalog` | Path to the downstream model catalog (relative to the config file). |
321
+ | `classifiers.dirs` | Directories of user-owned classifiers to load. |
322
+ | `classifiers.stock` | Toggles for package-owned optional stock classifiers. |
316
323
 
317
324
  ## Bring your own backend
318
325
 
@@ -347,8 +354,6 @@ For the lowest-level entry points, `classifyOpenClassifyInput(input, { runClassi
347
354
  - [docs/resolver.md](docs/resolver.md) — aggregation and model resolution
348
355
  - [docs/adding-a-classifier.md](docs/adding-a-classifier.md) — author guide
349
356
 
350
- ## Development
357
+ ## Contributing
351
358
 
352
- ```sh
353
- npm test # build + run the Node test runner suite
354
- ```
359
+ Clone the repo, then `npm run setup` (checks Node/Ollama, pulls the base model, installs and builds) and `npm test` (build + Node test runner). PRs welcome.
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
- // open-classify CLI. Subcommands: init, doctor, try.
2
+ // open-classify CLI. Subcommands: init, uninstall, doctor, try.
3
3
  //
4
4
  // init: scaffold the standard project layout for a consumer.
5
+ // uninstall: remove the files created by init.
5
6
  // doctor: verify the install, config, Ollama, and classifiers are all working.
6
7
  // try: run the pipeline against a single message and print the result.
7
8
 
@@ -14,8 +15,16 @@ import { spawnSync } from "node:child_process";
14
15
  const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
15
16
  const PACKAGE_ROOT = resolve(SCRIPT_DIR, "..");
16
17
  const TEMPLATES_DIR = join(PACKAGE_ROOT, "templates");
18
+ const DOWNSTREAM_MODELS_FILENAME = "downstream-models.json";
19
+ const DOWNSTREAM_MODELS_PATH = join(PACKAGE_ROOT, DOWNSTREAM_MODELS_FILENAME);
17
20
 
18
21
  const TEMPLATE_NAMES = ["conversation_digest", "context_shift", "memory_retrieval_queries", "tools"];
22
+ const STOCK_CONFIG = {
23
+ tools: false,
24
+ memory_retrieval_queries: false,
25
+ conversation_digest: false,
26
+ context_shift: false,
27
+ };
19
28
 
20
29
  const TEMPLATE_DESCRIPTIONS = {
21
30
  conversation_digest: "rolling summary of recent turns",
@@ -26,37 +35,29 @@ const TEMPLATE_DESCRIPTIONS = {
26
35
 
27
36
  const CLASSIFIERS_README = `# classifiers/
28
37
 
29
- Drop a folder here per classifier. Each folder needs:
30
-
31
- - \`manifest.json\` — see [open-classify docs](https://github.com/taylorbayouth/open-classify/blob/main/docs/adding-a-classifier.md)
32
- - \`prompt.md\` — the classifier-specific instructions
33
-
34
- ## Quickstart
35
-
36
- \`\`\`js
37
- import { createClassifier } from "open-classify";
38
-
39
- const { classify } = createClassifier({
40
- extraClassifierDirs: ["./classifiers"],
41
- });
42
- \`\`\`
43
-
44
- Place this in your server entry point. Call \`classify(input)\` for each user message.
45
- \`extraClassifierDirs\` is resolved relative to the current working directory.
46
-
47
- ## Activating templates
38
+ Each classifier is a folder with two files:
48
39
 
49
- The four \`_<name>/\` directories below are templates copied from the package they ship inactive (the loader skips any folder starting with \`_\`). Activate one by dropping the underscore:
40
+ - \`manifest.json\`declares the output shape and fallback
41
+ - \`prompt.md\` — the classification instructions
50
42
 
51
- \`\`\`sh
52
- mv _tools tools
53
- \`\`\`
43
+ The loader skips any folder whose name starts with \`_\`. That's how the
44
+ four \`_<name>/\` templates here stay inactive until you opt in: drop the
45
+ underscore (\`mv _tools tools\`) and the classifier runs on the next start.
54
46
 
55
- You probably also want to edit its \`manifest.json\` first to fit your app (e.g. trim the \`allowed_tools\` list).
47
+ Each template mirrors a package-owned stock classifier. You have two ways
48
+ to use them:
56
49
 
57
- ## Deactivating without deleting
50
+ 1. **Enable in place** — set \`classifiers.stock.<name>: true\` in
51
+ \`open-classify.config.json\`. The package-owned version runs and is
52
+ updated by \`npm update open-classify\`.
53
+ 2. **Customize a local copy** — keep the stock toggle off, drop the
54
+ underscore on the template here, and edit \`prompt.md\` /
55
+ \`manifest.json\` to taste.
58
56
 
59
- Same trick in reverse rename \`my_classifier\` \`_my_classifier\` to take it out of the active set without losing your work.
57
+ To write your own classifier, drop a new \`<name>/\` folder here with its
58
+ own \`manifest.json\` and \`prompt.md\`. The folder name must match the
59
+ manifest's \`name\` field. See the
60
+ [author guide](https://github.com/taylorbayouth/open-classify/blob/main/docs/adding-a-classifier.md).
60
61
  `;
61
62
 
62
63
  const DEFAULT_CONFIG = {
@@ -65,9 +66,23 @@ const DEFAULT_CONFIG = {
65
66
  host: "http://127.0.0.1:11434",
66
67
  defaultModel: "gemma4:e4b-it-q4_K_M",
67
68
  },
68
- catalog: "downstream-models.json",
69
+ catalog: DOWNSTREAM_MODELS_FILENAME,
70
+ classifiers: {
71
+ dirs: ["classifiers"],
72
+ stock: STOCK_CONFIG,
73
+ },
69
74
  };
70
75
 
76
+ function configForInit({ minimal }) {
77
+ if (!minimal) return DEFAULT_CONFIG;
78
+ return {
79
+ ...DEFAULT_CONFIG,
80
+ classifiers: {
81
+ stock: STOCK_CONFIG,
82
+ },
83
+ };
84
+ }
85
+
71
86
  // ---------------------------------------------------------------------------
72
87
  // Entry point
73
88
  // ---------------------------------------------------------------------------
@@ -87,6 +102,12 @@ async function main() {
87
102
  return;
88
103
  }
89
104
 
105
+ if (subcommand === "uninstall") {
106
+ const flags = parseUninstallFlags(args.slice(1));
107
+ await runUninstall({ cwd: process.cwd(), ...flags });
108
+ return;
109
+ }
110
+
90
111
  if (subcommand === "doctor") {
91
112
  await runDoctor({ cwd: process.cwd() });
92
113
  return;
@@ -134,6 +155,26 @@ function parseInitFlags(args) {
134
155
  return flags;
135
156
  }
136
157
 
158
+ function parseUninstallFlags(args) {
159
+ const flags = {
160
+ yes: false,
161
+ dryRun: false,
162
+ force: false,
163
+ classifierDir: "classifiers",
164
+ };
165
+
166
+ for (let i = 0; i < args.length; i++) {
167
+ const arg = args[i];
168
+ if (arg === "--yes" || arg === "-y") flags.yes = true;
169
+ else if (arg === "--dry-run") flags.dryRun = true;
170
+ else if (arg === "--force") flags.force = true;
171
+ else if (arg === "--classifier-dir" && args[i + 1]) flags.classifierDir = args[++i];
172
+ else if (arg.startsWith("--classifier-dir=")) flags.classifierDir = arg.split("=")[1];
173
+ }
174
+
175
+ return flags;
176
+ }
177
+
137
178
  function printHelp() {
138
179
  process.stdout.write(`open-classify — runtime CLI
139
180
 
@@ -141,6 +182,10 @@ Commands:
141
182
  init [options] Scaffold open-classify.config.json and classifiers/ in the
142
183
  current directory. Re-run safe: existing files are skipped.
143
184
 
185
+ uninstall Remove open-classify scaffold files from the current
186
+ directory. Use --force to remove the whole classifiers/
187
+ directory, including active/custom classifiers.
188
+
144
189
  doctor Check that the install, config, Ollama, and classifiers are
145
190
  all working. Exits non-zero on failure.
146
191
 
@@ -149,7 +194,7 @@ Commands:
149
194
  application code.
150
195
 
151
196
  Options for init:
152
- --minimal Write only open-classify.config.json; skip classifiers/
197
+ --minimal Write runtime config/catalog only; skip classifiers/
153
198
  --dry-run Preview what would be created; don't write anything
154
199
  --force Overwrite existing files without prompting
155
200
  --no-install Skip the "add to package.json" prompt
@@ -157,6 +202,12 @@ Options for init:
157
202
  --classifier-dir <p> Directory for classifiers (default: ./classifiers)
158
203
  --yes, -y Accept all prompts (CI mode)
159
204
 
205
+ Options for uninstall:
206
+ --dry-run Preview what would be removed; don't delete anything
207
+ --force Remove the whole classifiers/ directory
208
+ --classifier-dir <p> Directory for classifiers (default: ./classifiers)
209
+ --yes, -y Accept all prompts (CI mode)
210
+
160
211
  `);
161
212
  }
162
213
 
@@ -231,8 +282,9 @@ async function runInit({ cwd, yes, minimal, dryRun, force, noInstall, packageMan
231
282
 
232
283
  // 3. Plan.
233
284
  const resolvedClassifierDir = resolve(cwd, classifierDir);
234
- const wrote = { config: false, readme: false, templateCount: 0 };
235
- let plan = planInit(cwd, { minimal, classifierDir: resolvedClassifierDir, force, wrote });
285
+ const config = configForInit({ minimal });
286
+ const wrote = { config: false, catalog: false, readme: false, templateCount: 0 };
287
+ let plan = planInit(cwd, { minimal, classifierDir: resolvedClassifierDir, force, wrote, config });
236
288
 
237
289
  // Nothing to do.
238
290
  if (plan.toCreate.length === 0) {
@@ -271,13 +323,13 @@ async function runInit({ cwd, yes, minimal, dryRun, force, noInstall, packageMan
271
323
  if (plan.toSkip.length > 0 && !yes && !force) {
272
324
  const choice = await promptConflict();
273
325
  if (choice === "diff") {
274
- showDiffs(plan.toSkip, cwd, resolvedClassifierDir);
326
+ showDiffs(plan.toSkip, cwd, resolvedClassifierDir, config);
275
327
  const choice2 = await promptConflict();
276
328
  if (choice2 === "y") {
277
- plan = planInit(cwd, { minimal, classifierDir: resolvedClassifierDir, force: true, wrote });
329
+ plan = planInit(cwd, { minimal, classifierDir: resolvedClassifierDir, force: true, wrote, config });
278
330
  }
279
331
  } else if (choice === "y") {
280
- plan = planInit(cwd, { minimal, classifierDir: resolvedClassifierDir, force: true, wrote });
332
+ plan = planInit(cwd, { minimal, classifierDir: resolvedClassifierDir, force: true, wrote, config });
281
333
  }
282
334
  }
283
335
 
@@ -301,6 +353,7 @@ async function runInit({ cwd, yes, minimal, dryRun, force, noInstall, packageMan
301
353
  process.stdout.write(`✓ open-classify installed${v ? ` (v${v})` : ""}\n`);
302
354
  }
303
355
  if (wrote.config) process.stdout.write("✓ wrote open-classify.config.json\n");
356
+ if (wrote.catalog) process.stdout.write(`✓ wrote ${DOWNSTREAM_MODELS_FILENAME}\n`);
304
357
  if (wrote.readme || wrote.templateCount > 0) {
305
358
  const classifierDirRel = relative(cwd, resolvedClassifierDir);
306
359
  if (wrote.templateCount > 0) {
@@ -310,27 +363,37 @@ async function runInit({ cwd, yes, minimal, dryRun, force, noInstall, packageMan
310
363
  }
311
364
  }
312
365
 
313
- const classifierDirRel = relative(cwd, resolvedClassifierDir);
314
366
  process.stdout.write(`
315
367
  Next steps:
316
368
 
317
- 1. Pull the default model:
318
- ollama pull ${DEFAULT_CONFIG.runner.defaultModel}
369
+ 1. Pull the default classifier model:
370
+
371
+ ollama pull ${config.runner.defaultModel}
319
372
 
320
- 2. Wire it into your server (example for a Node entrypoint):
321
- see ./${classifierDirRel}/README.md → "Quickstart"
373
+ 2. Verify everything is wired up:
322
374
 
323
- 3. Verify the install:
324
375
  npx open-classify doctor
325
376
 
326
- 4. Run a one-shot classification against your config:
327
- npx open-classify try "hello world"
377
+ 3. Try it without writing any code:
378
+
379
+ npx open-classify try "hello"
380
+
381
+ 4. Use it from your code:
382
+
383
+ import { createClassifier } from "open-classify";
384
+ const { classify } = createClassifier();
385
+ const result = await classify({
386
+ messages: [{ role: "user", text: "hello" }],
387
+ });
388
+
389
+ The factory finds open-classify.config.json in your working
390
+ directory and wires in the classifiers/ folder automatically.
328
391
 
329
392
  Docs: https://github.com/taylorbayouth/open-classify#readme
330
393
  `);
331
394
  }
332
395
 
333
- function planInit(cwd, { minimal = false, classifierDir, force = false, wrote }) {
396
+ function planInit(cwd, { minimal = false, classifierDir, force = false, wrote, config }) {
334
397
  const toCreate = [];
335
398
  const toSkip = [];
336
399
  const actions = [];
@@ -343,14 +406,29 @@ function planInit(cwd, { minimal = false, classifierDir, force = false, wrote })
343
406
  toSkip.push(configRel);
344
407
  } else {
345
408
  toCreate.push(configRel);
346
- preview.push({ label: configRel, description: `(default Ollama setup, ${DEFAULT_CONFIG.runner.defaultModel})` });
409
+ preview.push({ label: configRel, description: `(default Ollama setup, ${config.runner.defaultModel})` });
347
410
  actions.push(() => {
348
- writeFileSync(configPath, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n");
411
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
349
412
  process.stdout.write(` wrote ${configRel}\n`);
350
413
  wrote.config = true;
351
414
  });
352
415
  }
353
416
 
417
+ // Downstream model catalog.
418
+ const catalogPath = join(cwd, DOWNSTREAM_MODELS_FILENAME);
419
+ const catalogRel = relative(cwd, catalogPath);
420
+ if (existsSync(catalogPath) && !force) {
421
+ toSkip.push(catalogRel);
422
+ } else {
423
+ toCreate.push(catalogRel);
424
+ preview.push({ label: catalogRel, description: "default downstream model catalog" });
425
+ actions.push(() => {
426
+ cpSync(DOWNSTREAM_MODELS_PATH, catalogPath);
427
+ process.stdout.write(` wrote ${catalogRel}\n`);
428
+ wrote.catalog = true;
429
+ });
430
+ }
431
+
354
432
  if (!minimal) {
355
433
  const classifierPreviewItems = [];
356
434
 
@@ -422,7 +500,7 @@ function planInit(cwd, { minimal = false, classifierDir, force = false, wrote })
422
500
  return { toCreate, toSkip, actions, preview };
423
501
  }
424
502
 
425
- function showDiffs(conflicts, cwd, classifierDir) {
503
+ function showDiffs(conflicts, cwd, classifierDir, config = DEFAULT_CONFIG) {
426
504
  for (const p of conflicts) {
427
505
  const isDir = p.endsWith("/");
428
506
  const relPath = isDir ? p.slice(0, -1) : p;
@@ -439,7 +517,11 @@ function showDiffs(conflicts, cwd, classifierDir) {
439
517
  process.stdout.write(" (could not read)\n");
440
518
  }
441
519
  process.stdout.write("\n would become:\n");
442
- for (const line of JSON.stringify(DEFAULT_CONFIG, null, 2).split("\n")) {
520
+ const replacement =
521
+ basename(relPath) === DOWNSTREAM_MODELS_FILENAME
522
+ ? readFileSync(DOWNSTREAM_MODELS_PATH, "utf8")
523
+ : JSON.stringify(config, null, 2);
524
+ for (const line of replacement.split("\n")) {
443
525
  process.stdout.write(` ${line}\n`);
444
526
  }
445
527
  } else {
@@ -460,6 +542,122 @@ function showDiffs(conflicts, cwd, classifierDir) {
460
542
  process.stdout.write("\n");
461
543
  }
462
544
 
545
+ // ---------------------------------------------------------------------------
546
+ // uninstall
547
+ // ---------------------------------------------------------------------------
548
+
549
+ async function runUninstall({ cwd, yes, dryRun, force, classifierDir }) {
550
+ const resolvedClassifierDir = resolve(cwd, classifierDir);
551
+ const plan = planUninstall(cwd, { classifierDir: resolvedClassifierDir, force });
552
+
553
+ if (plan.toRemove.length === 0) {
554
+ process.stdout.write("Nothing to remove — no open-classify scaffold found.\n");
555
+ if (plan.toSkip.length > 0) {
556
+ process.stdout.write("\nSkipped active/custom classifier dirs:\n");
557
+ for (const p of plan.toSkip) process.stdout.write(` ${p}\n`);
558
+ process.stdout.write("\nUse --force to remove the whole classifiers/ directory.\n");
559
+ }
560
+ return;
561
+ }
562
+
563
+ process.stdout.write(`\nThe following open-classify scaffold will be removed from ${cwd}:\n\n`);
564
+ for (const p of plan.toRemove) process.stdout.write(` ${p}\n`);
565
+
566
+ if (plan.toSkip.length > 0) {
567
+ process.stdout.write("\nSkipped active/custom classifier dirs:\n");
568
+ for (const p of plan.toSkip) process.stdout.write(` ${p}\n`);
569
+ process.stdout.write("\nUse --force to remove the whole classifiers/ directory.\n");
570
+ }
571
+
572
+ if (dryRun) {
573
+ process.stdout.write("\n(dry run — nothing removed)\n");
574
+ return;
575
+ }
576
+
577
+ if (!yes) {
578
+ const proceed = await confirm("\n? Continue? (Y/n) ", true);
579
+ if (!proceed) {
580
+ process.stdout.write("Aborted.\n");
581
+ process.exit(1);
582
+ }
583
+ }
584
+
585
+ process.stdout.write("\n");
586
+ for (const action of plan.actions) action();
587
+ process.stdout.write("\n✓ removed open-classify scaffold\n");
588
+ process.stdout.write("To remove the package dependency too, run: npm uninstall open-classify\n");
589
+ }
590
+
591
+ function planUninstall(cwd, { classifierDir, force }) {
592
+ const toRemove = [];
593
+ const toSkip = [];
594
+ const actions = [];
595
+
596
+ for (const filename of ["open-classify.config.json", DOWNSTREAM_MODELS_FILENAME]) {
597
+ const path = join(cwd, filename);
598
+ if (!existsSync(path)) continue;
599
+ toRemove.push(filename);
600
+ actions.push(() => {
601
+ rmSync(path, { force: true });
602
+ process.stdout.write(` removed ${filename}\n`);
603
+ });
604
+ }
605
+
606
+ const classifierRel = relative(cwd, classifierDir);
607
+ if (!existsSync(classifierDir)) {
608
+ return { toRemove, toSkip, actions };
609
+ }
610
+
611
+ if (force) {
612
+ toRemove.push(`${classifierRel}/`);
613
+ actions.push(() => {
614
+ rmSync(classifierDir, { recursive: true, force: true });
615
+ process.stdout.write(` removed ${classifierRel}/\n`);
616
+ });
617
+ return { toRemove, toSkip, actions };
618
+ }
619
+
620
+ const readmePath = join(classifierDir, "README.md");
621
+ const readmeRel = relative(cwd, readmePath);
622
+ if (existsSync(readmePath)) {
623
+ toRemove.push(readmeRel);
624
+ actions.push(() => {
625
+ rmSync(readmePath, { force: true });
626
+ process.stdout.write(` removed ${readmeRel}\n`);
627
+ });
628
+ }
629
+
630
+ for (const name of TEMPLATE_NAMES) {
631
+ const inactivePath = join(classifierDir, `_${name}`);
632
+ const inactiveRel = relative(cwd, inactivePath);
633
+ if (existsSync(inactivePath)) {
634
+ toRemove.push(`${inactiveRel}/`);
635
+ actions.push(() => {
636
+ rmSync(inactivePath, { recursive: true, force: true });
637
+ process.stdout.write(` removed ${inactiveRel}/\n`);
638
+ });
639
+ }
640
+
641
+ const activePath = join(classifierDir, name);
642
+ if (existsSync(activePath)) {
643
+ toSkip.push(`${relative(cwd, activePath)}/`);
644
+ }
645
+ }
646
+
647
+ actions.push(() => {
648
+ try {
649
+ if (readdirSync(classifierDir).length === 0) {
650
+ rmSync(classifierDir, { recursive: true, force: true });
651
+ process.stdout.write(` removed ${classifierRel}/\n`);
652
+ }
653
+ } catch {
654
+ // Non-empty: custom/active classifiers remain, which is intentional.
655
+ }
656
+ });
657
+
658
+ return { toRemove, toSkip, actions };
659
+ }
660
+
463
661
  // ---------------------------------------------------------------------------
464
662
  // doctor
465
663
  // ---------------------------------------------------------------------------
@@ -494,14 +692,24 @@ async function runDoctor({ cwd }) {
494
692
  config = JSON.parse(readFileSync(configPath, "utf8"));
495
693
  process.stdout.write("✓ open-classify.config.json parses OK\n");
496
694
 
497
- // 3. Ollama reachable.
695
+ // 3. Catalog exists.
696
+ const catalog = config.catalog || DEFAULT_CONFIG.catalog;
697
+ const catalogPath = resolve(cwd, catalog);
698
+ if (existsSync(catalogPath)) {
699
+ process.stdout.write(`✓ ${catalog} found\n`);
700
+ } else {
701
+ process.stdout.write(`✖ ${catalog} not found — run: npx open-classify init\n`);
702
+ allGood = false;
703
+ }
704
+
705
+ // 4. Ollama reachable.
498
706
  const host = config.runner?.host || DEFAULT_CONFIG.runner.host;
499
707
  try {
500
708
  const res = await fetch(`${host}/api/tags`, { signal: AbortSignal.timeout(3000) });
501
709
  if (res.ok) {
502
710
  process.stdout.write(`✓ Ollama reachable at ${host}\n`);
503
711
 
504
- // 4. Default model pulled.
712
+ // 5. Default model pulled.
505
713
  const data = await res.json();
506
714
  const model = config.runner?.defaultModel || DEFAULT_CONFIG.runner.defaultModel;
507
715
  const pulled = data.models?.some((m) => m.name === model || m.model === model);
@@ -525,9 +733,20 @@ async function runDoctor({ cwd }) {
525
733
  }
526
734
  }
527
735
 
528
- // 5. Classifiers directory.
529
- const classifiersDir = join(cwd, "classifiers");
530
- if (existsSync(classifiersDir)) {
736
+ // 6. Classifiers directories.
737
+ const doctorConfig = configFromFile(cwd);
738
+ const configuredClassifierDirs =
739
+ doctorConfig?.classifiers === undefined
740
+ ? ["classifiers"]
741
+ : doctorConfig.classifiers.dirs ?? [];
742
+ for (const configuredDir of configuredClassifierDirs) {
743
+ const classifiersDir = resolve(cwd, configuredDir);
744
+ const classifiersRel = relative(cwd, classifiersDir);
745
+ if (!existsSync(classifiersDir)) {
746
+ process.stdout.write(`ℹ No ${classifiersRel}/ directory — run: npx open-classify init\n`);
747
+ continue;
748
+ }
749
+
531
750
  let active = 0;
532
751
  let bad = 0;
533
752
  try {
@@ -538,7 +757,7 @@ async function runDoctor({ cwd }) {
538
757
  existsSync(join(dir, "manifest.json")) && existsSync(join(dir, "prompt.md"));
539
758
  if (ok) active++;
540
759
  else {
541
- process.stdout.write(`✖ classifiers/${entry.name}/ is missing manifest.json or prompt.md\n`);
760
+ process.stdout.write(`✖ ${classifiersRel}/${entry.name}/ is missing manifest.json or prompt.md\n`);
542
761
  bad++;
543
762
  allGood = false;
544
763
  }
@@ -547,17 +766,23 @@ async function runDoctor({ cwd }) {
547
766
  if (bad === 0) {
548
767
  process.stdout.write(
549
768
  active > 0
550
- ? `✓ ${active} active classifier(s) in classifiers/\n`
551
- : "ℹ No active classifiers in classifiers/ (activate a template with: mv _name name)\n",
769
+ ? `✓ ${active} active classifier(s) in ${classifiersRel}/\n`
770
+ : `ℹ No active classifiers in ${classifiersRel}/ (enable stock in config or customize a _name template)\n`,
552
771
  );
553
772
  }
554
- } else {
555
- process.stdout.write("ℹ No classifiers/ directory — run: npx open-classify init\n");
556
773
  }
557
774
 
558
775
  if (!allGood) process.exit(1);
559
776
  }
560
777
 
778
+ function configFromFile(cwd) {
779
+ try {
780
+ return JSON.parse(readFileSync(join(cwd, "open-classify.config.json"), "utf8"));
781
+ } catch {
782
+ return null;
783
+ }
784
+ }
785
+
561
786
  // ---------------------------------------------------------------------------
562
787
  // try
563
788
  // ---------------------------------------------------------------------------
@@ -601,9 +826,12 @@ async function runTry({ cwd, message }) {
601
826
  const classifiersDir = join(cwd, "classifiers");
602
827
  let classifier;
603
828
  try {
829
+ const config = configFromFile(cwd);
830
+ const hasConfiguredClassifierDirs = Array.isArray(config?.classifiers?.dirs);
604
831
  classifier = createClassifier({
605
832
  configPath,
606
- extraClassifierDirs: existsSync(classifiersDir) ? [classifiersDir] : [],
833
+ extraClassifierDirs:
834
+ hasConfiguredClassifierDirs || !existsSync(classifiersDir) ? [] : [classifiersDir],
607
835
  skipResourceCheck: false,
608
836
  });
609
837
  } catch (err) {
@@ -2,6 +2,9 @@ import type { ClassifierInput } from "./types.js";
2
2
  import type { ClassifierName, ClassifierRegistry, RunClassifier } from "./manifest.js";
3
3
  import type { ClassifierOutput, RuntimeClassifierManifest } from "./stock.js";
4
4
  export declare const BUILTIN_CLASSIFIERS_DIR: string;
5
+ export declare const STOCK_CLASSIFIER_NAMES: readonly ["tools", "memory_retrieval_queries", "conversation_digest", "context_shift"];
6
+ export type StockClassifierName = (typeof STOCK_CLASSIFIER_NAMES)[number];
7
+ export declare const STOCK_CLASSIFIERS_DIR: string;
5
8
  export declare class ClassifierManifestError extends Error {
6
9
  constructor(message: string);
7
10
  }
@@ -12,6 +15,7 @@ export interface ClassifierRegistryBundle {
12
15
  readonly names: ReadonlyArray<string>;
13
16
  }
14
17
  export interface BuildRegistryOptions {
18
+ readonly stockClassifierNames?: ReadonlyArray<string>;
15
19
  readonly extraDirs?: ReadonlyArray<string>;
16
20
  }
17
21
  export declare function loadClassifierRegistry(classifiersDir?: string): RuntimeClassifierManifest[];
@@ -5,6 +5,22 @@ 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
  export const BUILTIN_CLASSIFIERS_DIR = join(__dirname, "classifiers");
8
+ export const STOCK_CLASSIFIER_NAMES = [
9
+ "tools",
10
+ "memory_retrieval_queries",
11
+ "conversation_digest",
12
+ "context_shift",
13
+ ];
14
+ export const STOCK_CLASSIFIERS_DIR = findStockClassifiersDir();
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.
18
+ const candidates = [
19
+ join(__dirname, "..", "templates"),
20
+ join(__dirname, "..", "..", "templates"),
21
+ ];
22
+ return candidates.find((dir) => existsSync(dir)) ?? candidates[0];
23
+ }
8
24
  // Directories whose names start with "_" are reserved for shared assets
9
25
  // (e.g. `_prompts/`) and are not loaded as classifiers. Consumers can use
10
26
  // the same convention in their own classifier directories: rename a
@@ -46,6 +62,7 @@ export function loadClassifierRegistry(classifiersDir = BUILTIN_CLASSIFIERS_DIR)
46
62
  export function buildClassifierRegistry(options = {}) {
47
63
  const manifests = [
48
64
  ...loadClassifierRegistry(BUILTIN_CLASSIFIERS_DIR),
65
+ ...(options.stockClassifierNames ?? []).map((name) => loadStockClassifier(name)),
49
66
  ...(options.extraDirs ?? []).flatMap((dir) => loadClassifierRegistry(dir)),
50
67
  ];
51
68
  manifests.sort((a, b) => (a.dispatch_order ?? Infinity) - (b.dispatch_order ?? Infinity));
@@ -55,6 +72,12 @@ export function buildClassifierRegistry(options = {}) {
55
72
  const names = manifests.map((m) => m.name);
56
73
  return { registry, modulesByName, names };
57
74
  }
75
+ function loadStockClassifier(name) {
76
+ if (!STOCK_CLASSIFIER_NAMES.includes(name)) {
77
+ throw new ClassifierManifestError(`unknown stock classifier: ${name} (available: ${STOCK_CLASSIFIER_NAMES.join(", ")})`);
78
+ }
79
+ return loadClassifierManifest(join(STOCK_CLASSIFIERS_DIR, name));
80
+ }
58
81
  function loadClassifierManifest(classifierDir) {
59
82
  const manifestPath = join(classifierDir, "manifest.json");
60
83
  const promptPath = join(classifierDir, "prompt.md");
@@ -17,6 +17,7 @@ export interface CreateClassifierOptions {
17
17
  runClassifier?: RunClassifier;
18
18
  catalog?: Catalog;
19
19
  extraClassifierDirs?: ReadonlyArray<string>;
20
+ stockClassifierNames?: ReadonlyArray<string>;
20
21
  config?: OpenClassifyConfig;
21
22
  configPath?: string;
22
23
  catalogPath?: string;
@@ -3,19 +3,31 @@
3
3
  // user-input/routing pass and inspect() for the assistant-output lean pass.
4
4
  // Backend-agnostic: pass a custom `runClassifier` to bypass the bundled
5
5
  // Ollama runner entirely.
6
+ import { dirname, isAbsolute, resolve } from "node:path";
6
7
  import { loadCatalog } from "./catalog.js";
7
8
  import { buildClassifierRegistry, ClassifierManifestError, } from "./classifiers.js";
8
- import { classifierModelsFromConfig, loadOpenClassifyConfig, OpenClassifyConfigError, } from "./config.js";
9
+ import { classifierDirsFromConfig, classifierModelsFromConfig, DEFAULT_OPEN_CLASSIFY_CONFIG_PATH, loadOpenClassifyConfig, OpenClassifyConfigError, stockClassifierNamesFromConfig, } from "./config.js";
9
10
  import { assertOllamaResources, createOllamaClassifierRunner, OLLAMA_DEFAULT_CATALOG_PATH, } from "./ollama.js";
10
11
  import { classifyOpenClassifyInput, inspectOpenClassifyInput, } from "./pipeline.js";
11
12
  export function createClassifier(options = {}) {
13
+ const configPath = options.config === undefined
14
+ ? options.configPath ?? process.env.OPEN_CLASSIFY_CONFIG ?? DEFAULT_OPEN_CLASSIFY_CONFIG_PATH
15
+ : undefined;
16
+ const configBaseDir = configPath === undefined ? process.cwd() : dirname(resolve(configPath));
12
17
  const fileConfig = options.config ??
13
18
  loadOpenClassifyConfig(options.configPath, {
14
19
  optional: options.configPath === undefined &&
15
20
  process.env.OPEN_CLASSIFY_CONFIG === undefined,
16
21
  });
17
22
  const registryBundle = buildClassifierRegistry({
18
- extraDirs: options.extraClassifierDirs,
23
+ extraDirs: uniqueStrings([
24
+ ...classifierDirsFromConfig(fileConfig).map((dir) => resolveFromConfigBase(dir, configBaseDir)),
25
+ ...(options.extraClassifierDirs ?? []),
26
+ ]),
27
+ stockClassifierNames: uniqueStrings([
28
+ ...stockClassifierNamesFromConfig(fileConfig),
29
+ ...(options.stockClassifierNames ?? []),
30
+ ]),
19
31
  });
20
32
  // Cross-check `runner.models` keys against the loaded registry so a typo
21
33
  // or stale reference fails fast at construction time instead of being
@@ -44,7 +56,10 @@ export function createClassifier(options = {}) {
44
56
  fetch: options.fetch,
45
57
  });
46
58
  const catalog = options.catalog ??
47
- loadCatalog(options.catalogPath ?? fileConfig?.catalog ?? OLLAMA_DEFAULT_CATALOG_PATH);
59
+ loadCatalog(options.catalogPath ??
60
+ (fileConfig?.catalog === undefined
61
+ ? OLLAMA_DEFAULT_CATALOG_PATH
62
+ : resolveFromConfigBase(fileConfig.catalog, configBaseDir)));
48
63
  let resourceCheck;
49
64
  const ensureResources = async () => {
50
65
  if (!needsResourceCheck)
@@ -80,6 +95,12 @@ export function createClassifier(options = {}) {
80
95
  };
81
96
  return { classify, inspect, registry: registryBundle };
82
97
  }
98
+ function uniqueStrings(values) {
99
+ return [...new Set(values)];
100
+ }
101
+ function resolveFromConfigBase(path, configBaseDir) {
102
+ return isAbsolute(path) ? path : resolve(configBaseDir, path);
103
+ }
83
104
  // Re-export so callers can `import { ClassifierManifestError } from "open-classify"`
84
105
  // and catch directory/name collision errors from createClassifier().
85
106
  export { ClassifierManifestError };
@@ -1,8 +1,13 @@
1
- import type { ClassifierName } from "./classifiers.js";
1
+ import { type ClassifierName } from "./classifiers.js";
2
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;
6
+ readonly classifiers?: OpenClassifyClassifierConfig;
7
+ }
8
+ export interface OpenClassifyClassifierConfig {
9
+ readonly dirs?: ReadonlyArray<string>;
10
+ readonly stock?: Readonly<Record<string, boolean>>;
6
11
  }
7
12
  export interface OllamaRunnerConfig {
8
13
  readonly provider: "ollama";
@@ -23,4 +28,6 @@ export declare function loadOpenClassifyConfig(path?: string, options?: {
23
28
  optional?: boolean;
24
29
  }): OpenClassifyConfig | undefined;
25
30
  export declare function classifierModelsFromConfig(config: OpenClassifyConfig | undefined): Partial<Record<ClassifierName, string>>;
31
+ export declare function classifierDirsFromConfig(config: OpenClassifyConfig | undefined): ReadonlyArray<string>;
32
+ export declare function stockClassifierNamesFromConfig(config: OpenClassifyConfig | undefined): ReadonlyArray<string>;
26
33
  export declare function validateOpenClassifyConfig(value: unknown, path?: string): OpenClassifyConfig;
@@ -1,4 +1,5 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
+ import { STOCK_CLASSIFIER_NAMES } from "./classifiers.js";
2
3
  import { isRecord } from "./validation.js";
3
4
  export const DEFAULT_OPEN_CLASSIFY_CONFIG_PATH = "open-classify.config.json";
4
5
  export class OpenClassifyConfigError extends Error {
@@ -25,14 +26,37 @@ export function loadOpenClassifyConfig(path = process.env.OPEN_CLASSIFY_CONFIG ?
25
26
  export function classifierModelsFromConfig(config) {
26
27
  return { ...config?.runner?.models };
27
28
  }
29
+ export function classifierDirsFromConfig(config) {
30
+ return config?.classifiers?.dirs ?? [];
31
+ }
32
+ export function stockClassifierNamesFromConfig(config) {
33
+ return Object.entries(config?.classifiers?.stock ?? {})
34
+ .filter(([, enabled]) => enabled)
35
+ .map(([name]) => name);
36
+ }
28
37
  export function validateOpenClassifyConfig(value, path = "open-classify config") {
29
38
  if (!isRecord(value)) {
30
39
  throwConfig(path, "config must be a JSON object");
31
40
  }
32
- ensureAllowedKeys(value, ["runner", "catalog"], path, "<root>");
41
+ ensureAllowedKeys(value, ["runner", "catalog", "classifiers"], path, "<root>");
33
42
  return {
34
43
  ...(value.runner === undefined ? {} : { runner: validateRunner(value.runner, path) }),
35
44
  ...(value.catalog === undefined ? {} : { catalog: requireString(value.catalog, path, "catalog") }),
45
+ ...(value.classifiers === undefined
46
+ ? {}
47
+ : { classifiers: validateClassifiers(value.classifiers, path) }),
48
+ };
49
+ }
50
+ function validateClassifiers(value, path) {
51
+ if (!isRecord(value)) {
52
+ throwConfig(path, "classifiers must be an object");
53
+ }
54
+ ensureAllowedKeys(value, ["dirs", "stock"], path, "classifiers");
55
+ return {
56
+ ...(value.dirs === undefined ? {} : { dirs: validateStringArray(value.dirs, path, "classifiers.dirs") }),
57
+ ...(value.stock === undefined
58
+ ? {}
59
+ : { stock: validateBooleanMap(value.stock, path, "classifiers.stock", STOCK_CLASSIFIER_NAMES) }),
36
60
  };
37
61
  }
38
62
  function validateRunner(value, path) {
@@ -86,6 +110,29 @@ function validateModels(value, path) {
86
110
  }
87
111
  return out;
88
112
  }
113
+ function validateStringArray(value, path, field) {
114
+ if (!Array.isArray(value)) {
115
+ throwConfig(path, `${field} must be an array`);
116
+ }
117
+ return value.map((item, index) => requireString(item, path, `${field}[${index}]`));
118
+ }
119
+ function validateBooleanMap(value, path, field, allowedKeys) {
120
+ if (!isRecord(value)) {
121
+ throwConfig(path, `${field} must be an object`);
122
+ }
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(", ")})`);
128
+ }
129
+ if (typeof enabled !== "boolean") {
130
+ throwConfig(path, `${field}.${name} must be a boolean`);
131
+ }
132
+ out[name] = enabled;
133
+ }
134
+ return out;
135
+ }
89
136
  function requireString(value, path, field) {
90
137
  if (typeof value !== "string" || value.trim().length === 0) {
91
138
  throwConfig(path, `${field} must be a non-empty string`);
@@ -1,13 +1,6 @@
1
1
  # Adding a classifier
2
2
 
3
- Every classifier — bundled or your own — uses the same two-file layout. There is no separate "stock" vs "custom" distinction; the runtime only cares about which reserved fields a classifier opts into.
4
-
5
- There are two places a classifier can live:
6
-
7
- - **In your own app**, in a directory you pass to `extraClassifierDirs` (almost always `./classifiers/` after `npx open-classify init`). This is the right path when you've installed Open Classify as a dependency.
8
- - **In this repo**, under `src/classifiers/<name>/`. Only do this when you're contributing a new mandatory built-in back to Open Classify.
9
-
10
- Either way, the layout and contract are identical.
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.
11
4
 
12
5
  ## 1. Create the directory
13
6
 
@@ -98,14 +91,12 @@ Don't paste enum values for reserved fields — the runtime injects them with ca
98
91
 
99
92
  ## 4. Use it
100
93
 
101
- After `npx open-classify init`, your `classifiers/` directory already exists. Drop your folder in and point `createClassifier` at the parent dir:
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()`:
102
95
 
103
96
  ```ts
104
97
  import { createClassifier } from "open-classify";
105
98
 
106
- const { classify } = createClassifier({
107
- extraClassifierDirs: ["./classifiers"],
108
- });
99
+ const { classify } = createClassifier();
109
100
 
110
101
  const result = await classify({
111
102
  messages: [{ role: "user", text: "Can you review the attached contract?" }],
@@ -114,19 +105,31 @@ const result = await classify({
114
105
  const tags = result.classifier_outputs.topic_tags?.tags ?? [];
115
106
  ```
116
107
 
117
- > Production tip: `"./classifiers"` resolves against `process.cwd()`, which is fine for `npm start` but breaks if the process launches from a different directory. For long-running services, resolve absolutely via `fileURLToPath(import.meta.url) + path.resolve(...)`.
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.
118
109
 
119
110
  If the manifest is malformed, `createClassifier` throws `ClassifierManifestError` at startup with the path and a specific reason — typos fail loud.
120
111
 
121
- ## Activating one of the bundled templates
112
+ ## Enabling or customizing optional stock classifiers
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:
115
+
116
+ ```json
117
+ {
118
+ "classifiers": {
119
+ "stock": {
120
+ "tools": true
121
+ }
122
+ }
123
+ }
124
+ ```
122
125
 
123
- `npx open-classify init` copies four templates (`tools`, `memory_retrieval_queries`, `conversation_digest`, `context_shift`) into your `classifiers/` directory as `_<name>/` — inactive because of the underscore prefix. To turn one on:
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:
124
127
 
125
128
  ```sh
126
129
  mv classifiers/_tools classifiers/tools
127
130
  ```
128
131
 
129
- 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 classifier: rename `<name>/` → `_<name>/` to deactivate without deleting.
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.
130
133
 
131
134
  ## Targeting the assistant response
132
135
 
@@ -167,7 +170,7 @@ In `open-classify.config.json`:
167
170
  }
168
171
  ```
169
172
 
170
- `runner.defaultModel` applies to every classifier without an override. `runner.models` is a flat map keyed by classifier name — works for built-ins, templates, and your own.
173
+ `runner.defaultModel` applies to every classifier without an override. `runner.models` is a flat map keyed by classifier name — works for mandatory base classifiers, optional stock classifiers, and your own.
171
174
 
172
175
  Classifier manifests may also carry an Ollama hint:
173
176
 
package/docs/manifests.md CHANGED
@@ -1,16 +1,15 @@
1
1
  # Manifest reference
2
2
 
3
- Every classifier lives in `src/classifiers/<name>/` and contains exactly two files:
3
+ Every classifier is a directory with exactly two files:
4
4
 
5
5
  ```
6
- src/classifiers/
7
- _prompts/ # shared base markdown (base.md, reason.md, confidence.md)
6
+ classifiers/
8
7
  <classifier_name>/
9
8
  manifest.json
10
9
  prompt.md
11
10
  ```
12
11
 
13
- The loader skips any top-level directory whose name starts with `_` (those are shared assets, not classifiers).
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.
14
13
 
15
14
  ## Fields
16
15
 
@@ -145,7 +144,7 @@ A manifest may declare both reserved fields and custom properties; they sit alon
145
144
 
146
145
  `prompt.md` is the classifier-specific instruction text. The runtime composes the system prompt at load time from:
147
146
 
148
- 1. Shared base sections (JSON-only contract, `reason` + `certainty` rules) from `src/classifiers/_prompts/`
147
+ 1. Shared base sections (JSON-only contract, `reason` + `certainty` rules)
149
148
  2. The classifier header (name and purpose, with the purpose stated as a hard scope boundary)
150
149
  3. Auto-injected fragments for each declared reserved field (canonical enum values included, so you can't drift)
151
150
  4. Your `prompt.md`
@@ -4,17 +4,23 @@
4
4
  "host": "http://127.0.0.1:11434",
5
5
  "defaultModel": "gemma4:e4b-it-q4_K_M",
6
6
  "options": {
7
- "num_ctx": 4096,
8
7
  "temperature": 0,
9
8
  "top_p": 1,
10
- "seed": 0
9
+ "seed": 0,
10
+ "num_ctx": 4096
11
11
  },
12
12
  "models": {
13
- "preflight": "gemma4:e4b-it-q4_K_M",
14
- "model_tier": "gemma4:e4b-it-q4_K_M",
15
- "model_specialization": "gemma4:e4b-it-q4_K_M",
16
- "prompt_injection": "gemma4:e4b-it-q4_K_M"
13
+ "prompt_injection": "llama-guard3:8b"
17
14
  }
18
15
  },
19
- "catalog": "downstream-models.json"
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
+ }
20
26
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-classify",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "Manifest-driven classifier runtime for routing user messages to downstream AI models",
5
5
  "license": "MIT",
6
6
  "author": "Taylor Bayouth",