open-classify 0.9.0 → 0.9.1

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
@@ -64,9 +64,7 @@ This creates `open-classify.config.json` and a `classifiers/` directory in your
64
64
  ```ts
65
65
  import { createClassifier } from "open-classify";
66
66
 
67
- const { classify } = createClassifier({
68
- extraClassifierDirs: ["./classifiers"],
69
- });
67
+ const { classify } = createClassifier();
70
68
 
71
69
  const result = await classify({
72
70
  messages: [{ role: "user", text: "Can you review the attached contract?" }],
@@ -77,15 +75,29 @@ else if (result.action === "block") handleBlock(result.block_reason); // inj
77
75
  else callDownstream(result.model_id, result.tools, result.reply?.text); // route the real request
78
76
  ```
79
77
 
80
- **4. Activate or customize a classifier**
78
+ **4. Enable or customize optional classifiers**
79
+
80
+ `init` writes `open-classify.config.json`, which enables package-owned stock classifiers without copying them into your project. They default to `false` so the four mandatory base classifiers run automatically:
81
+
82
+ ```json
83
+ {
84
+ "classifiers": {
85
+ "stock": {
86
+ "tools": false
87
+ }
88
+ }
89
+ }
90
+ ```
91
+
92
+ Set `"tools": true` to run the package-owned stock `tools` classifier. Because it stays in `node_modules/open-classify`, `npm update open-classify` can improve its prompt later without touching your project files.
81
93
 
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:
94
+ Inside `classifiers/` you'll also find four `_<name>/` directories — editable copies of the stock classifiers, inactive because of the underscore prefix. To customize one, keep the matching `classifiers.stock.<name>` value `false`, edit the copy, then drop the underscore:
83
95
 
84
96
  ```sh
85
97
  mv classifiers/_tools classifiers/tools
86
98
  ```
87
99
 
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.
100
+ 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 copied/custom classifier out of the active set without deleting it.
89
101
 
90
102
  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).
91
103
 
@@ -152,7 +164,7 @@ Example result:
152
164
 
153
165
  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
166
 
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.
167
+ 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
168
 
157
169
  | Name | dispatch_order | Reserved fields | Bundled as | What the aggregator does with it |
158
170
  |---|---|---|---|---|
@@ -160,12 +172,27 @@ Open Classify ships eight built-in classifiers. **Four are mandatory** — they
160
172
  | `model_tier` | 20 | `model_tier` | mandatory | Feeds the catalog resolver as a soft constraint |
161
173
  | `model_specialization` | 30 | `model_specialization` | mandatory | Feeds the catalog resolver as a soft constraint |
162
174
  | `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 |
175
+ | `tools` | 40 | `tools` | optional stock | Sets `result.tools` |
176
+ | `memory_retrieval_queries` | 60 | — | optional stock | Passes through to `classifier_outputs` |
177
+ | `conversation_digest` | 70 | — | optional stock | Passes through |
178
+ | `context_shift` | 80 | — | optional stock | Passes through |
167
179
 
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.
180
+ For package-owned stock classifiers, `open-classify.config.json` is the on/off switch:
181
+
182
+ ```json
183
+ {
184
+ "classifiers": {
185
+ "stock": {
186
+ "tools": true,
187
+ "memory_retrieval_queries": false,
188
+ "conversation_digest": false,
189
+ "context_shift": false
190
+ }
191
+ }
192
+ }
193
+ ```
194
+
195
+ 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
196
 
170
197
  > 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
198
 
@@ -308,11 +335,20 @@ cp open-classify.config.example.json open-classify.config.json
308
335
  "memory_retrieval_queries": "qwen2.5:7b-instruct-q4_K_M"
309
336
  }
310
337
  },
311
- "catalog": "downstream-models.json"
338
+ "catalog": "downstream-models.json",
339
+ "classifiers": {
340
+ "dirs": ["classifiers"],
341
+ "stock": {
342
+ "tools": false,
343
+ "memory_retrieval_queries": false,
344
+ "conversation_digest": false,
345
+ "context_shift": false
346
+ }
347
+ }
312
348
  }
313
349
  ```
314
350
 
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.
351
+ `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. `classifiers.dirs` is for user-owned copied/custom classifiers; `classifiers.stock` toggles package-owned optional classifiers.
316
352
 
317
353
  ## Bring your own backend
318
354
 
@@ -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",
@@ -36,17 +45,35 @@ Drop a folder here per classifier. Each folder needs:
36
45
  \`\`\`js
37
46
  import { createClassifier } from "open-classify";
38
47
 
39
- const { classify } = createClassifier({
40
- extraClassifierDirs: ["./classifiers"],
41
- });
48
+ const { classify } = createClassifier();
42
49
  \`\`\`
43
50
 
44
51
  Place this in your server entry point. Call \`classify(input)\` for each user message.
45
- \`extraClassifierDirs\` is resolved relative to the current working directory.
52
+ \`open-classify.config.json\` wires in \`./classifiers\` automatically.
53
+
54
+ ## Stock classifiers
55
+
56
+ \`tools\`, \`memory_retrieval_queries\`, \`conversation_digest\`, and \`context_shift\`
57
+ ship with the package but are disabled by default in \`open-classify.config.json\`.
58
+ Enable a package-owned stock classifier by setting it to \`true\`:
59
+
60
+ \`\`\`json
61
+ {
62
+ "classifiers": {
63
+ "stock": {
64
+ "tools": true
65
+ }
66
+ }
67
+ }
68
+ \`\`\`
69
+
70
+ Package-owned stock classifiers are updated by \`npm update open-classify\`.
46
71
 
47
- ## Activating templates
72
+ ## Customizing a stock classifier
48
73
 
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:
74
+ The four \`_<name>/\` directories below are editable copies of the stock classifiers.
75
+ They are inactive because the loader skips any folder starting with \`_\`. To customize one,
76
+ keep the matching \`classifiers.stock.<name>\` value \`false\`, edit the files, then drop the underscore:
50
77
 
51
78
  \`\`\`sh
52
79
  mv _tools tools
@@ -65,9 +92,23 @@ const DEFAULT_CONFIG = {
65
92
  host: "http://127.0.0.1:11434",
66
93
  defaultModel: "gemma4:e4b-it-q4_K_M",
67
94
  },
68
- catalog: "downstream-models.json",
95
+ catalog: DOWNSTREAM_MODELS_FILENAME,
96
+ classifiers: {
97
+ dirs: ["classifiers"],
98
+ stock: STOCK_CONFIG,
99
+ },
69
100
  };
70
101
 
102
+ function configForInit({ minimal }) {
103
+ if (!minimal) return DEFAULT_CONFIG;
104
+ return {
105
+ ...DEFAULT_CONFIG,
106
+ classifiers: {
107
+ stock: STOCK_CONFIG,
108
+ },
109
+ };
110
+ }
111
+
71
112
  // ---------------------------------------------------------------------------
72
113
  // Entry point
73
114
  // ---------------------------------------------------------------------------
@@ -87,6 +128,12 @@ async function main() {
87
128
  return;
88
129
  }
89
130
 
131
+ if (subcommand === "uninstall") {
132
+ const flags = parseUninstallFlags(args.slice(1));
133
+ await runUninstall({ cwd: process.cwd(), ...flags });
134
+ return;
135
+ }
136
+
90
137
  if (subcommand === "doctor") {
91
138
  await runDoctor({ cwd: process.cwd() });
92
139
  return;
@@ -134,6 +181,26 @@ function parseInitFlags(args) {
134
181
  return flags;
135
182
  }
136
183
 
184
+ function parseUninstallFlags(args) {
185
+ const flags = {
186
+ yes: false,
187
+ dryRun: false,
188
+ force: false,
189
+ classifierDir: "classifiers",
190
+ };
191
+
192
+ for (let i = 0; i < args.length; i++) {
193
+ const arg = args[i];
194
+ if (arg === "--yes" || arg === "-y") flags.yes = true;
195
+ else if (arg === "--dry-run") flags.dryRun = true;
196
+ else if (arg === "--force") flags.force = true;
197
+ else if (arg === "--classifier-dir" && args[i + 1]) flags.classifierDir = args[++i];
198
+ else if (arg.startsWith("--classifier-dir=")) flags.classifierDir = arg.split("=")[1];
199
+ }
200
+
201
+ return flags;
202
+ }
203
+
137
204
  function printHelp() {
138
205
  process.stdout.write(`open-classify — runtime CLI
139
206
 
@@ -141,6 +208,10 @@ Commands:
141
208
  init [options] Scaffold open-classify.config.json and classifiers/ in the
142
209
  current directory. Re-run safe: existing files are skipped.
143
210
 
211
+ uninstall Remove open-classify scaffold files from the current
212
+ directory. Use --force to remove the whole classifiers/
213
+ directory, including active/custom classifiers.
214
+
144
215
  doctor Check that the install, config, Ollama, and classifiers are
145
216
  all working. Exits non-zero on failure.
146
217
 
@@ -149,7 +220,7 @@ Commands:
149
220
  application code.
150
221
 
151
222
  Options for init:
152
- --minimal Write only open-classify.config.json; skip classifiers/
223
+ --minimal Write runtime config/catalog only; skip classifiers/
153
224
  --dry-run Preview what would be created; don't write anything
154
225
  --force Overwrite existing files without prompting
155
226
  --no-install Skip the "add to package.json" prompt
@@ -157,6 +228,12 @@ Options for init:
157
228
  --classifier-dir <p> Directory for classifiers (default: ./classifiers)
158
229
  --yes, -y Accept all prompts (CI mode)
159
230
 
231
+ Options for uninstall:
232
+ --dry-run Preview what would be removed; don't delete anything
233
+ --force Remove the whole classifiers/ directory
234
+ --classifier-dir <p> Directory for classifiers (default: ./classifiers)
235
+ --yes, -y Accept all prompts (CI mode)
236
+
160
237
  `);
161
238
  }
162
239
 
@@ -231,8 +308,9 @@ async function runInit({ cwd, yes, minimal, dryRun, force, noInstall, packageMan
231
308
 
232
309
  // 3. Plan.
233
310
  const resolvedClassifierDir = resolve(cwd, classifierDir);
234
- const wrote = { config: false, readme: false, templateCount: 0 };
235
- let plan = planInit(cwd, { minimal, classifierDir: resolvedClassifierDir, force, wrote });
311
+ const config = configForInit({ minimal });
312
+ const wrote = { config: false, catalog: false, readme: false, templateCount: 0 };
313
+ let plan = planInit(cwd, { minimal, classifierDir: resolvedClassifierDir, force, wrote, config });
236
314
 
237
315
  // Nothing to do.
238
316
  if (plan.toCreate.length === 0) {
@@ -271,13 +349,13 @@ async function runInit({ cwd, yes, minimal, dryRun, force, noInstall, packageMan
271
349
  if (plan.toSkip.length > 0 && !yes && !force) {
272
350
  const choice = await promptConflict();
273
351
  if (choice === "diff") {
274
- showDiffs(plan.toSkip, cwd, resolvedClassifierDir);
352
+ showDiffs(plan.toSkip, cwd, resolvedClassifierDir, config);
275
353
  const choice2 = await promptConflict();
276
354
  if (choice2 === "y") {
277
- plan = planInit(cwd, { minimal, classifierDir: resolvedClassifierDir, force: true, wrote });
355
+ plan = planInit(cwd, { minimal, classifierDir: resolvedClassifierDir, force: true, wrote, config });
278
356
  }
279
357
  } else if (choice === "y") {
280
- plan = planInit(cwd, { minimal, classifierDir: resolvedClassifierDir, force: true, wrote });
358
+ plan = planInit(cwd, { minimal, classifierDir: resolvedClassifierDir, force: true, wrote, config });
281
359
  }
282
360
  }
283
361
 
@@ -301,6 +379,7 @@ async function runInit({ cwd, yes, minimal, dryRun, force, noInstall, packageMan
301
379
  process.stdout.write(`✓ open-classify installed${v ? ` (v${v})` : ""}\n`);
302
380
  }
303
381
  if (wrote.config) process.stdout.write("✓ wrote open-classify.config.json\n");
382
+ if (wrote.catalog) process.stdout.write(`✓ wrote ${DOWNSTREAM_MODELS_FILENAME}\n`);
304
383
  if (wrote.readme || wrote.templateCount > 0) {
305
384
  const classifierDirRel = relative(cwd, resolvedClassifierDir);
306
385
  if (wrote.templateCount > 0) {
@@ -315,7 +394,7 @@ async function runInit({ cwd, yes, minimal, dryRun, force, noInstall, packageMan
315
394
  Next steps:
316
395
 
317
396
  1. Pull the default model:
318
- ollama pull ${DEFAULT_CONFIG.runner.defaultModel}
397
+ ollama pull ${config.runner.defaultModel}
319
398
 
320
399
  2. Wire it into your server (example for a Node entrypoint):
321
400
  see ./${classifierDirRel}/README.md → "Quickstart"
@@ -330,7 +409,7 @@ Docs: https://github.com/taylorbayouth/open-classify#readme
330
409
  `);
331
410
  }
332
411
 
333
- function planInit(cwd, { minimal = false, classifierDir, force = false, wrote }) {
412
+ function planInit(cwd, { minimal = false, classifierDir, force = false, wrote, config }) {
334
413
  const toCreate = [];
335
414
  const toSkip = [];
336
415
  const actions = [];
@@ -343,14 +422,29 @@ function planInit(cwd, { minimal = false, classifierDir, force = false, wrote })
343
422
  toSkip.push(configRel);
344
423
  } else {
345
424
  toCreate.push(configRel);
346
- preview.push({ label: configRel, description: `(default Ollama setup, ${DEFAULT_CONFIG.runner.defaultModel})` });
425
+ preview.push({ label: configRel, description: `(default Ollama setup, ${config.runner.defaultModel})` });
347
426
  actions.push(() => {
348
- writeFileSync(configPath, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n");
427
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
349
428
  process.stdout.write(` wrote ${configRel}\n`);
350
429
  wrote.config = true;
351
430
  });
352
431
  }
353
432
 
433
+ // Downstream model catalog.
434
+ const catalogPath = join(cwd, DOWNSTREAM_MODELS_FILENAME);
435
+ const catalogRel = relative(cwd, catalogPath);
436
+ if (existsSync(catalogPath) && !force) {
437
+ toSkip.push(catalogRel);
438
+ } else {
439
+ toCreate.push(catalogRel);
440
+ preview.push({ label: catalogRel, description: "default downstream model catalog" });
441
+ actions.push(() => {
442
+ cpSync(DOWNSTREAM_MODELS_PATH, catalogPath);
443
+ process.stdout.write(` wrote ${catalogRel}\n`);
444
+ wrote.catalog = true;
445
+ });
446
+ }
447
+
354
448
  if (!minimal) {
355
449
  const classifierPreviewItems = [];
356
450
 
@@ -422,7 +516,7 @@ function planInit(cwd, { minimal = false, classifierDir, force = false, wrote })
422
516
  return { toCreate, toSkip, actions, preview };
423
517
  }
424
518
 
425
- function showDiffs(conflicts, cwd, classifierDir) {
519
+ function showDiffs(conflicts, cwd, classifierDir, config = DEFAULT_CONFIG) {
426
520
  for (const p of conflicts) {
427
521
  const isDir = p.endsWith("/");
428
522
  const relPath = isDir ? p.slice(0, -1) : p;
@@ -439,7 +533,11 @@ function showDiffs(conflicts, cwd, classifierDir) {
439
533
  process.stdout.write(" (could not read)\n");
440
534
  }
441
535
  process.stdout.write("\n would become:\n");
442
- for (const line of JSON.stringify(DEFAULT_CONFIG, null, 2).split("\n")) {
536
+ const replacement =
537
+ basename(relPath) === DOWNSTREAM_MODELS_FILENAME
538
+ ? readFileSync(DOWNSTREAM_MODELS_PATH, "utf8")
539
+ : JSON.stringify(config, null, 2);
540
+ for (const line of replacement.split("\n")) {
443
541
  process.stdout.write(` ${line}\n`);
444
542
  }
445
543
  } else {
@@ -460,6 +558,122 @@ function showDiffs(conflicts, cwd, classifierDir) {
460
558
  process.stdout.write("\n");
461
559
  }
462
560
 
561
+ // ---------------------------------------------------------------------------
562
+ // uninstall
563
+ // ---------------------------------------------------------------------------
564
+
565
+ async function runUninstall({ cwd, yes, dryRun, force, classifierDir }) {
566
+ const resolvedClassifierDir = resolve(cwd, classifierDir);
567
+ const plan = planUninstall(cwd, { classifierDir: resolvedClassifierDir, force });
568
+
569
+ if (plan.toRemove.length === 0) {
570
+ process.stdout.write("Nothing to remove — no open-classify scaffold found.\n");
571
+ if (plan.toSkip.length > 0) {
572
+ process.stdout.write("\nSkipped active/custom classifier dirs:\n");
573
+ for (const p of plan.toSkip) process.stdout.write(` ${p}\n`);
574
+ process.stdout.write("\nUse --force to remove the whole classifiers/ directory.\n");
575
+ }
576
+ return;
577
+ }
578
+
579
+ process.stdout.write(`\nThe following open-classify scaffold will be removed from ${cwd}:\n\n`);
580
+ for (const p of plan.toRemove) process.stdout.write(` ${p}\n`);
581
+
582
+ if (plan.toSkip.length > 0) {
583
+ process.stdout.write("\nSkipped active/custom classifier dirs:\n");
584
+ for (const p of plan.toSkip) process.stdout.write(` ${p}\n`);
585
+ process.stdout.write("\nUse --force to remove the whole classifiers/ directory.\n");
586
+ }
587
+
588
+ if (dryRun) {
589
+ process.stdout.write("\n(dry run — nothing removed)\n");
590
+ return;
591
+ }
592
+
593
+ if (!yes) {
594
+ const proceed = await confirm("\n? Continue? (Y/n) ", true);
595
+ if (!proceed) {
596
+ process.stdout.write("Aborted.\n");
597
+ process.exit(1);
598
+ }
599
+ }
600
+
601
+ process.stdout.write("\n");
602
+ for (const action of plan.actions) action();
603
+ process.stdout.write("\n✓ removed open-classify scaffold\n");
604
+ process.stdout.write("To remove the package dependency too, run: npm uninstall open-classify\n");
605
+ }
606
+
607
+ function planUninstall(cwd, { classifierDir, force }) {
608
+ const toRemove = [];
609
+ const toSkip = [];
610
+ const actions = [];
611
+
612
+ for (const filename of ["open-classify.config.json", DOWNSTREAM_MODELS_FILENAME]) {
613
+ const path = join(cwd, filename);
614
+ if (!existsSync(path)) continue;
615
+ toRemove.push(filename);
616
+ actions.push(() => {
617
+ rmSync(path, { force: true });
618
+ process.stdout.write(` removed ${filename}\n`);
619
+ });
620
+ }
621
+
622
+ const classifierRel = relative(cwd, classifierDir);
623
+ if (!existsSync(classifierDir)) {
624
+ return { toRemove, toSkip, actions };
625
+ }
626
+
627
+ if (force) {
628
+ toRemove.push(`${classifierRel}/`);
629
+ actions.push(() => {
630
+ rmSync(classifierDir, { recursive: true, force: true });
631
+ process.stdout.write(` removed ${classifierRel}/\n`);
632
+ });
633
+ return { toRemove, toSkip, actions };
634
+ }
635
+
636
+ const readmePath = join(classifierDir, "README.md");
637
+ const readmeRel = relative(cwd, readmePath);
638
+ if (existsSync(readmePath)) {
639
+ toRemove.push(readmeRel);
640
+ actions.push(() => {
641
+ rmSync(readmePath, { force: true });
642
+ process.stdout.write(` removed ${readmeRel}\n`);
643
+ });
644
+ }
645
+
646
+ for (const name of TEMPLATE_NAMES) {
647
+ const inactivePath = join(classifierDir, `_${name}`);
648
+ const inactiveRel = relative(cwd, inactivePath);
649
+ if (existsSync(inactivePath)) {
650
+ toRemove.push(`${inactiveRel}/`);
651
+ actions.push(() => {
652
+ rmSync(inactivePath, { recursive: true, force: true });
653
+ process.stdout.write(` removed ${inactiveRel}/\n`);
654
+ });
655
+ }
656
+
657
+ const activePath = join(classifierDir, name);
658
+ if (existsSync(activePath)) {
659
+ toSkip.push(`${relative(cwd, activePath)}/`);
660
+ }
661
+ }
662
+
663
+ actions.push(() => {
664
+ try {
665
+ if (readdirSync(classifierDir).length === 0) {
666
+ rmSync(classifierDir, { recursive: true, force: true });
667
+ process.stdout.write(` removed ${classifierRel}/\n`);
668
+ }
669
+ } catch {
670
+ // Non-empty: custom/active classifiers remain, which is intentional.
671
+ }
672
+ });
673
+
674
+ return { toRemove, toSkip, actions };
675
+ }
676
+
463
677
  // ---------------------------------------------------------------------------
464
678
  // doctor
465
679
  // ---------------------------------------------------------------------------
@@ -494,14 +708,24 @@ async function runDoctor({ cwd }) {
494
708
  config = JSON.parse(readFileSync(configPath, "utf8"));
495
709
  process.stdout.write("✓ open-classify.config.json parses OK\n");
496
710
 
497
- // 3. Ollama reachable.
711
+ // 3. Catalog exists.
712
+ const catalog = config.catalog || DEFAULT_CONFIG.catalog;
713
+ const catalogPath = resolve(cwd, catalog);
714
+ if (existsSync(catalogPath)) {
715
+ process.stdout.write(`✓ ${catalog} found\n`);
716
+ } else {
717
+ process.stdout.write(`✖ ${catalog} not found — run: npx open-classify init\n`);
718
+ allGood = false;
719
+ }
720
+
721
+ // 4. Ollama reachable.
498
722
  const host = config.runner?.host || DEFAULT_CONFIG.runner.host;
499
723
  try {
500
724
  const res = await fetch(`${host}/api/tags`, { signal: AbortSignal.timeout(3000) });
501
725
  if (res.ok) {
502
726
  process.stdout.write(`✓ Ollama reachable at ${host}\n`);
503
727
 
504
- // 4. Default model pulled.
728
+ // 5. Default model pulled.
505
729
  const data = await res.json();
506
730
  const model = config.runner?.defaultModel || DEFAULT_CONFIG.runner.defaultModel;
507
731
  const pulled = data.models?.some((m) => m.name === model || m.model === model);
@@ -525,9 +749,20 @@ async function runDoctor({ cwd }) {
525
749
  }
526
750
  }
527
751
 
528
- // 5. Classifiers directory.
529
- const classifiersDir = join(cwd, "classifiers");
530
- if (existsSync(classifiersDir)) {
752
+ // 6. Classifiers directories.
753
+ const doctorConfig = configFromFile(cwd);
754
+ const configuredClassifierDirs =
755
+ doctorConfig?.classifiers === undefined
756
+ ? ["classifiers"]
757
+ : doctorConfig.classifiers.dirs ?? [];
758
+ for (const configuredDir of configuredClassifierDirs) {
759
+ const classifiersDir = resolve(cwd, configuredDir);
760
+ const classifiersRel = relative(cwd, classifiersDir);
761
+ if (!existsSync(classifiersDir)) {
762
+ process.stdout.write(`ℹ No ${classifiersRel}/ directory — run: npx open-classify init\n`);
763
+ continue;
764
+ }
765
+
531
766
  let active = 0;
532
767
  let bad = 0;
533
768
  try {
@@ -538,7 +773,7 @@ async function runDoctor({ cwd }) {
538
773
  existsSync(join(dir, "manifest.json")) && existsSync(join(dir, "prompt.md"));
539
774
  if (ok) active++;
540
775
  else {
541
- process.stdout.write(`✖ classifiers/${entry.name}/ is missing manifest.json or prompt.md\n`);
776
+ process.stdout.write(`✖ ${classifiersRel}/${entry.name}/ is missing manifest.json or prompt.md\n`);
542
777
  bad++;
543
778
  allGood = false;
544
779
  }
@@ -547,17 +782,23 @@ async function runDoctor({ cwd }) {
547
782
  if (bad === 0) {
548
783
  process.stdout.write(
549
784
  active > 0
550
- ? `✓ ${active} active classifier(s) in classifiers/\n`
551
- : "ℹ No active classifiers in classifiers/ (activate a template with: mv _name name)\n",
785
+ ? `✓ ${active} active classifier(s) in ${classifiersRel}/\n`
786
+ : `ℹ No active classifiers in ${classifiersRel}/ (enable stock in config or customize a _name template)\n`,
552
787
  );
553
788
  }
554
- } else {
555
- process.stdout.write("ℹ No classifiers/ directory — run: npx open-classify init\n");
556
789
  }
557
790
 
558
791
  if (!allGood) process.exit(1);
559
792
  }
560
793
 
794
+ function configFromFile(cwd) {
795
+ try {
796
+ return JSON.parse(readFileSync(join(cwd, "open-classify.config.json"), "utf8"));
797
+ } catch {
798
+ return null;
799
+ }
800
+ }
801
+
561
802
  // ---------------------------------------------------------------------------
562
803
  // try
563
804
  // ---------------------------------------------------------------------------
@@ -601,9 +842,12 @@ async function runTry({ cwd, message }) {
601
842
  const classifiersDir = join(cwd, "classifiers");
602
843
  let classifier;
603
844
  try {
845
+ const config = configFromFile(cwd);
846
+ const hasConfiguredClassifierDirs = Array.isArray(config?.classifiers?.dirs);
604
847
  classifier = createClassifier({
605
848
  configPath,
606
- extraClassifierDirs: existsSync(classifiersDir) ? [classifiersDir] : [],
849
+ extraClassifierDirs:
850
+ hasConfiguredClassifierDirs || !existsSync(classifiersDir) ? [] : [classifiersDir],
607
851
  skipResourceCheck: false,
608
852
  });
609
853
  } 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,10 +1,10 @@
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.
3
+ Every classifier — package-owned or your own — uses the same two-file layout. The runtime only cares about which reserved fields a classifier opts into; ownership just decides whether `npm update open-classify` can replace the prompt.
4
4
 
5
5
  There are two places a classifier can live:
6
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.
7
+ - **In your own app**, in a directory listed in `open-classify.config.json` under `classifiers.dirs` (almost always `./classifiers/` after `npx open-classify init`). This is the right path when you've installed Open Classify as a dependency.
8
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
9
 
10
10
  Either way, the layout and contract are identical.
@@ -98,14 +98,12 @@ Don't paste enum values for reserved fields — the runtime injects them with ca
98
98
 
99
99
  ## 4. Use it
100
100
 
101
- After `npx open-classify init`, your `classifiers/` directory already exists. Drop your folder in and point `createClassifier` at the parent dir:
101
+ 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
102
 
103
103
  ```ts
104
104
  import { createClassifier } from "open-classify";
105
105
 
106
- const { classify } = createClassifier({
107
- extraClassifierDirs: ["./classifiers"],
108
- });
106
+ const { classify } = createClassifier();
109
107
 
110
108
  const result = await classify({
111
109
  messages: [{ role: "user", text: "Can you review the attached contract?" }],
@@ -114,19 +112,31 @@ const result = await classify({
114
112
  const tags = result.classifier_outputs.topic_tags?.tags ?? [];
115
113
  ```
116
114
 
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(...)`.
115
+ `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
116
 
119
117
  If the manifest is malformed, `createClassifier` throws `ClassifierManifestError` at startup with the path and a specific reason — typos fail loud.
120
118
 
121
- ## Activating one of the bundled templates
119
+ ## Enabling or customizing optional stock classifiers
120
+
121
+ `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:
122
+
123
+ ```json
124
+ {
125
+ "classifiers": {
126
+ "stock": {
127
+ "tools": true
128
+ }
129
+ }
130
+ }
131
+ ```
122
132
 
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:
133
+ `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
134
 
125
135
  ```sh
126
136
  mv classifiers/_tools classifiers/tools
127
137
  ```
128
138
 
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.
139
+ 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
140
 
131
141
  ## Targeting the assistant response
132
142
 
@@ -167,7 +177,7 @@ In `open-classify.config.json`:
167
177
  }
168
178
  ```
169
179
 
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.
180
+ `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
181
 
172
182
  Classifier manifests may also carry an Ollama hint:
173
183
 
@@ -16,5 +16,14 @@
16
16
  "prompt_injection": "gemma4:e4b-it-q4_K_M"
17
17
  }
18
18
  },
19
- "catalog": "downstream-models.json"
19
+ "catalog": "downstream-models.json",
20
+ "classifiers": {
21
+ "dirs": ["classifiers"],
22
+ "stock": {
23
+ "tools": false,
24
+ "memory_retrieval_queries": false,
25
+ "conversation_digest": false,
26
+ "context_shift": false
27
+ }
28
+ }
20
29
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-classify",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Manifest-driven classifier runtime for routing user messages to downstream AI models",
5
5
  "license": "MIT",
6
6
  "author": "Taylor Bayouth",