open-classify 0.8.2 → 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 +50 -14
- package/bin/open-classify.mjs +275 -31
- 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 +21 -11
- package/open-classify.config.example.json +10 -1
- package/package.json +1 -1
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.
|
|
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 —
|
|
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
|
|
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` |
|
|
164
|
-
| `memory_retrieval_queries` | 60 | — |
|
|
165
|
-
| `conversation_digest` | 70 | — |
|
|
166
|
-
| `context_shift` | 80 | — |
|
|
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
|
-
|
|
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
|
|
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",
|
|
@@ -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
|
-
\`
|
|
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
|
-
##
|
|
72
|
+
## Customizing a stock classifier
|
|
48
73
|
|
|
49
|
-
The four \`_<name>/\` directories below are
|
|
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:
|
|
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
|
|
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
|
|
235
|
-
|
|
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 ${
|
|
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, ${
|
|
425
|
+
preview.push({ label: configRel, description: `(default Ollama setup, ${config.runner.defaultModel})` });
|
|
347
426
|
actions.push(() => {
|
|
348
|
-
writeFileSync(configPath, JSON.stringify(
|
|
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
|
-
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
529
|
-
const
|
|
530
|
-
|
|
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(`✖
|
|
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
|
|
551
|
-
:
|
|
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:
|
|
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[];
|
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,10 +1,10 @@
|
|
|
1
1
|
# Adding a classifier
|
|
2
2
|
|
|
3
|
-
Every classifier —
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
|
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
|
|
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
|
}
|