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 +57 -52
- package/bin/open-classify.mjs +286 -58
- package/dist/src/classifiers.d.ts +4 -0
- package/dist/src/classifiers.js +23 -0
- package/dist/src/classify.d.ts +1 -0
- package/dist/src/classify.js +24 -3
- package/dist/src/config.d.ts +8 -1
- package/dist/src/config.js +48 -1
- package/docs/adding-a-classifier.md +20 -17
- package/docs/manifests.md +4 -5
- package/open-classify.config.example.json +13 -7
- package/package.json +1 -1
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
|
|
47
|
-
|
|
48
|
-
**1. Install**
|
|
46
|
+
Prerequisites: Node 18+, [Ollama](https://ollama.com), and the default classifier model:
|
|
49
47
|
|
|
50
48
|
```sh
|
|
51
|
-
|
|
49
|
+
ollama pull gemma4:e4b-it-q4_K_M
|
|
52
50
|
```
|
|
53
51
|
|
|
54
|
-
**
|
|
52
|
+
**1. Scaffold (from your project root)**
|
|
55
53
|
|
|
56
54
|
```sh
|
|
57
55
|
npx open-classify init
|
|
58
56
|
```
|
|
59
57
|
|
|
60
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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` |
|
|
164
|
-
| `memory_retrieval_queries` | 60 | — |
|
|
165
|
-
| `conversation_digest` | 70 | — |
|
|
166
|
-
| `context_shift` | 80 | — |
|
|
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
|
-
|
|
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
|
-
##
|
|
309
|
+
## Configuration
|
|
287
310
|
|
|
288
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
##
|
|
357
|
+
## Contributing
|
|
351
358
|
|
|
352
|
-
|
|
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.
|
package/bin/open-classify.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
40
|
+
- \`manifest.json\` — declares the output shape and fallback
|
|
41
|
+
- \`prompt.md\` — the classification instructions
|
|
50
42
|
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
47
|
+
Each template mirrors a package-owned stock classifier. You have two ways
|
|
48
|
+
to use them:
|
|
56
49
|
|
|
57
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
235
|
-
|
|
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
|
-
|
|
369
|
+
1. Pull the default classifier model:
|
|
370
|
+
|
|
371
|
+
ollama pull ${config.runner.defaultModel}
|
|
319
372
|
|
|
320
|
-
2.
|
|
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
|
-
|
|
327
|
-
|
|
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, ${
|
|
409
|
+
preview.push({ label: configRel, description: `(default Ollama setup, ${config.runner.defaultModel})` });
|
|
347
410
|
actions.push(() => {
|
|
348
|
-
writeFileSync(configPath, JSON.stringify(
|
|
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
|
-
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
529
|
-
const
|
|
530
|
-
|
|
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(`✖
|
|
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
|
|
551
|
-
:
|
|
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:
|
|
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[];
|
package/dist/src/classifiers.js
CHANGED
|
@@ -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");
|
package/dist/src/classify.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/classify.js
CHANGED
|
@@ -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:
|
|
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 ??
|
|
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 };
|
package/dist/src/config.d.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import type
|
|
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;
|
package/dist/src/config.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
|
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
|
|
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
|
|
3
|
+
Every classifier is a directory with exactly two files:
|
|
4
4
|
|
|
5
5
|
```
|
|
6
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
"
|
|
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
|
}
|