@kognai/tool-router 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +47 -2
  3. package/dist/adapters/awesome-list.d.ts +10 -0
  4. package/dist/adapters/awesome-list.js +68 -0
  5. package/dist/adapters/mcp-registry.d.ts +12 -0
  6. package/dist/adapters/mcp-registry.js +51 -0
  7. package/dist/adapters/mcp-tools.d.ts +49 -0
  8. package/dist/adapters/mcp-tools.js +60 -0
  9. package/dist/adapters/openapi.d.ts +25 -0
  10. package/dist/adapters/openapi.js +107 -0
  11. package/dist/adapters/pay-skills.d.ts +13 -0
  12. package/dist/adapters/pay-skills.js +119 -0
  13. package/dist/adapters/raw.d.ts +82 -0
  14. package/dist/adapters/raw.js +5 -0
  15. package/dist/adapters/x402-discovery.d.ts +6 -0
  16. package/dist/adapters/x402-discovery.js +100 -0
  17. package/dist/catalog/file-store.d.ts +27 -0
  18. package/dist/catalog/file-store.js +102 -0
  19. package/dist/catalog/ingest.d.ts +53 -0
  20. package/dist/catalog/ingest.js +276 -0
  21. package/dist/catalog/lifecycle.d.ts +24 -0
  22. package/dist/catalog/lifecycle.js +51 -0
  23. package/dist/catalog/memory-store.d.ts +49 -0
  24. package/dist/catalog/memory-store.js +92 -0
  25. package/dist/catalog/risk.d.ts +22 -0
  26. package/dist/catalog/risk.js +73 -0
  27. package/dist/catalog/sanitise.d.ts +17 -0
  28. package/dist/catalog/sanitise.js +109 -0
  29. package/dist/classify/classifier.d.ts +12 -0
  30. package/dist/classify/classifier.js +52 -0
  31. package/dist/classify/decompose.d.ts +10 -0
  32. package/dist/classify/decompose.js +107 -0
  33. package/dist/classify/rules.d.ts +46 -0
  34. package/dist/classify/rules.js +249 -0
  35. package/dist/classify/tagger.d.ts +5 -0
  36. package/dist/classify/tagger.js +29 -0
  37. package/dist/classify/taxonomy.d.ts +31 -0
  38. package/dist/classify/taxonomy.js +221 -0
  39. package/dist/classify/text.d.ts +9 -0
  40. package/dist/classify/text.js +38 -0
  41. package/dist/eligibility/engine.d.ts +11 -0
  42. package/dist/eligibility/engine.js +19 -0
  43. package/dist/eligibility/rules.d.ts +33 -0
  44. package/dist/eligibility/rules.js +121 -0
  45. package/dist/flags.d.ts +33 -0
  46. package/dist/flags.js +34 -0
  47. package/dist/hash.d.ts +8 -0
  48. package/dist/hash.js +30 -0
  49. package/dist/ids.d.ts +17 -0
  50. package/dist/ids.js +54 -0
  51. package/dist/index.d.ts +39 -0
  52. package/dist/index.js +95 -0
  53. package/dist/money.d.ts +10 -0
  54. package/dist/money.js +41 -0
  55. package/dist/rank/confidence.d.ts +15 -0
  56. package/dist/rank/confidence.js +48 -0
  57. package/dist/rank/ranker.d.ts +34 -0
  58. package/dist/rank/ranker.js +81 -0
  59. package/dist/rank/weights.d.ts +40 -0
  60. package/dist/rank/weights.js +46 -0
  61. package/dist/retrieve/bm25.d.ts +21 -0
  62. package/dist/retrieve/bm25.js +59 -0
  63. package/dist/retrieve/capability.d.ts +1 -0
  64. package/dist/retrieve/capability.js +13 -0
  65. package/dist/retrieve/hash-embed.d.ts +4 -0
  66. package/dist/retrieve/hash-embed.js +41 -0
  67. package/dist/retrieve/hybrid.d.ts +74 -0
  68. package/dist/retrieve/hybrid.js +125 -0
  69. package/dist/retrieve/schema-fit.d.ts +9 -0
  70. package/dist/retrieve/schema-fit.js +38 -0
  71. package/dist/retrieve/tokenise.d.ts +2 -0
  72. package/dist/retrieve/tokenise.js +51 -0
  73. package/dist/retrieve/vector.d.ts +18 -0
  74. package/dist/retrieve/vector.js +47 -0
  75. package/dist/router.d.ts +37 -0
  76. package/dist/router.js +114 -0
  77. package/dist/schema/tool.schema.json +117 -0
  78. package/dist/schema/validate.d.ts +9 -0
  79. package/dist/schema/validate.js +92 -0
  80. package/dist/shortlist/generate.d.ts +21 -0
  81. package/dist/shortlist/generate.js +75 -0
  82. package/dist/types/ports.d.ts +71 -0
  83. package/dist/types/ports.js +2 -0
  84. package/dist/types/route.d.ts +121 -0
  85. package/dist/types/route.js +5 -0
  86. package/dist/types/tool.d.ts +123 -0
  87. package/dist/types/tool.js +17 -0
  88. package/package.json +39 -4
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.hashEmbeddingPort = exports.HASH_MODEL = void 0;
4
+ exports.hashEmbed = hashEmbed;
5
+ /**
6
+ * A deterministic stand in for an embedding model: hashed tokens and character trigrams into 256 dimensions. Used by
7
+ * tests (the fake EmbeddingPort) and as the labelled offline fallback of the benchmark when the gateway's real model
8
+ * is not reachable. It is not semantic: it is named 'ktr-hash-256' everywhere so it can never pass for bge-m3.
9
+ */
10
+ const node_crypto_1 = require("node:crypto");
11
+ const tokenise_1 = require("./tokenise");
12
+ exports.HASH_MODEL = 'ktr-hash-256';
13
+ const DIMS = 256;
14
+ function bucket(s) {
15
+ const h = (0, node_crypto_1.createHash)('md5').update(s).digest();
16
+ return [h.readUInt16LE(0) % DIMS, h[2] & 1 ? 1 : -1];
17
+ }
18
+ function hashEmbed(text) {
19
+ const v = new Float32Array(DIMS);
20
+ for (const t of (0, tokenise_1.tokenise)(text)) {
21
+ const [i, s] = bucket(`w:${t}`);
22
+ v[i] += 2 * s;
23
+ const p = `#${t}#`;
24
+ for (let j = 0; j + 3 <= p.length; j++) {
25
+ const [k, sk] = bucket(`c:${p.slice(j, j + 3)}`);
26
+ v[k] += 0.5 * sk;
27
+ }
28
+ }
29
+ let n = 0;
30
+ for (const x of v)
31
+ n += x * x;
32
+ n = Math.sqrt(n) || 1;
33
+ for (let j = 0; j < DIMS; j++)
34
+ v[j] /= n;
35
+ return v;
36
+ }
37
+ exports.hashEmbeddingPort = {
38
+ model: exports.HASH_MODEL,
39
+ dims: DIMS,
40
+ async embed(texts) { return texts.map(hashEmbed); },
41
+ };
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Hybrid retrieval (plan.md S3). RouterIndex is built once per catalog snapshot: one document per operation of the
3
+ * newest version of each tool (name, sanitised description, summary, tags, capability words), a BM25 index, an
4
+ * inverted capability index and, when the catalog has vectors for the model, a normalised vector matrix.
5
+ *
6
+ * retrieve() takes the union of the top 50 by each signal plus every exact capability match (capped), then returns
7
+ * per candidate signals in 0..1: lexical, vector, capability, schema, health. History (success) is the ranker's.
8
+ */
9
+ import type { CatalogStore } from '../types/ports';
10
+ import type { PlanNode } from '../types/route';
11
+ import type { ToolOperation, ToolRecord } from '../types/tool';
12
+ import { Bm25Index } from './bm25';
13
+ export interface IndexedOp {
14
+ key: string;
15
+ rec: ToolRecord;
16
+ op: ToolOperation;
17
+ }
18
+ export interface VectorCalibration {
19
+ lo: number;
20
+ hi: number;
21
+ }
22
+ /** Cosine to 0..1: below lo is no semantic match, above hi a full one. Per model, measured (bench report). */
23
+ export declare const CALIBRATION: Record<string, VectorCalibration>;
24
+ export declare class RouterIndex {
25
+ readonly ops: IndexedOp[];
26
+ readonly byKey: Map<string, number>;
27
+ readonly byCap: Map<string, number[]>;
28
+ readonly bm25: Bm25Index;
29
+ vectors: {
30
+ model: string;
31
+ dims: number;
32
+ data: Float32Array;
33
+ rowOf: Int32Array;
34
+ } | null;
35
+ readonly snapshot: string;
36
+ constructor(store: CatalogStore, opts?: {
37
+ vectorModel?: string;
38
+ records?: Iterable<ToolRecord>;
39
+ });
40
+ /** Cosine of the query against every operation (NaN where an operation has no vector). */
41
+ cosines(model: string, q: Float32Array): Float32Array | null;
42
+ }
43
+ export declare function docText(rec: ToolRecord, op: ToolOperation): string;
44
+ /** The text embedded for an operation: provider facts only (sanitised), no KTR tags, so vectors survive a taxonomy change. */
45
+ export declare function embedDocText(rec: ToolRecord, op: ToolOperation): string;
46
+ /** Words for a node's lexical query: the request plus the capability's own label and first cues. */
47
+ export declare function nodeQueryTokens(text: string, capability: string): string[];
48
+ export interface Signals {
49
+ lexical: number;
50
+ vector: number;
51
+ capability: number;
52
+ schema: number;
53
+ health: number;
54
+ cosine: number | null;
55
+ }
56
+ export interface Retrieved {
57
+ index: number;
58
+ item: IndexedOp;
59
+ signals: Signals;
60
+ }
61
+ export interface RetrieveOpts {
62
+ topN?: number;
63
+ capCap?: number;
64
+ useLexical?: boolean;
65
+ useVector?: boolean;
66
+ useCapability?: boolean;
67
+ queryVector?: {
68
+ model: string;
69
+ vector: Float32Array;
70
+ } | null;
71
+ cosines?: Float32Array | null;
72
+ prevOutput?: (node: PlanNode) => Record<string, unknown> | null;
73
+ }
74
+ export declare function retrieve(ix: RouterIndex, node: PlanNode, text: string, o?: RetrieveOpts): Retrieved[];
@@ -0,0 +1,125 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RouterIndex = exports.CALIBRATION = void 0;
4
+ exports.docText = docText;
5
+ exports.embedDocText = embedDocText;
6
+ exports.nodeQueryTokens = nodeQueryTokens;
7
+ exports.retrieve = retrieve;
8
+ const taxonomy_1 = require("../classify/taxonomy");
9
+ const bm25_1 = require("./bm25");
10
+ const capability_1 = require("./capability");
11
+ const schema_fit_1 = require("./schema-fit");
12
+ const tokenise_1 = require("./tokenise");
13
+ const vector_1 = require("./vector");
14
+ /** Cosine to 0..1: below lo is no semantic match, above hi a full one. Per model, measured (bench report). */
15
+ exports.CALIBRATION = {
16
+ 'bge-m3': { lo: 0.35, hi: 0.7 },
17
+ 'ktr-hash-256': { lo: 0.05, hi: 0.6 },
18
+ };
19
+ class RouterIndex {
20
+ ops = [];
21
+ byKey = new Map();
22
+ byCap = new Map();
23
+ bm25;
24
+ vectors = null;
25
+ snapshot;
26
+ constructor(store, opts = {}) {
27
+ this.snapshot = store.snapshotId();
28
+ const docs = [];
29
+ for (const rec of opts.records ?? store.latest()) {
30
+ for (const op of rec.operations) {
31
+ const key = `${rec.id}#${op.op}`;
32
+ const i = this.ops.length;
33
+ this.ops.push({ key, rec, op });
34
+ this.byKey.set(key, i);
35
+ for (const c of op.capabilities)
36
+ (this.byCap.get(c) || this.byCap.set(c, []).get(c)).push(i);
37
+ docs.push({ key, text: docText(rec, op) });
38
+ }
39
+ }
40
+ this.bm25 = new bm25_1.Bm25Index(docs);
41
+ if (opts.vectorModel) {
42
+ const v = store.vectors(opts.vectorModel);
43
+ if (v) {
44
+ const data = (0, vector_1.normaliseRows)(v.data, v.dims);
45
+ const rowOf = new Int32Array(this.ops.length).fill(-1);
46
+ const pos = new Map(v.ids.map((id, r) => [id, r]));
47
+ for (let i = 0; i < this.ops.length; i++)
48
+ rowOf[i] = pos.get(this.ops[i].key) ?? -1;
49
+ this.vectors = { model: opts.vectorModel, dims: v.dims, data, rowOf };
50
+ }
51
+ }
52
+ }
53
+ /** Cosine of the query against every operation (NaN where an operation has no vector). */
54
+ cosines(model, q) {
55
+ if (!this.vectors)
56
+ return null;
57
+ (0, vector_1.checkQuery)(this.vectors, model, q);
58
+ const rows = (0, vector_1.cosineAll)(q, this.vectors.data, this.vectors.dims);
59
+ const out = new Float32Array(this.ops.length).fill(NaN);
60
+ for (let i = 0; i < this.ops.length; i++) {
61
+ const r = this.vectors.rowOf[i];
62
+ if (r >= 0)
63
+ out[i] = rows[r];
64
+ }
65
+ return out;
66
+ }
67
+ }
68
+ exports.RouterIndex = RouterIndex;
69
+ function docText(rec, op) {
70
+ const capWords = op.capabilities.map((c) => c.replace(/[._]/g, ' ')).join(' ');
71
+ return `${rec.name} ${rec.description_safe} ${op.op.replace(/[_.-]/g, ' ')} ${op.summary} ${rec.tags.join(' ')} ${capWords}`;
72
+ }
73
+ /** The text embedded for an operation: provider facts only (sanitised), no KTR tags, so vectors survive a taxonomy change. */
74
+ function embedDocText(rec, op) {
75
+ return `${rec.name}. ${op.op.replace(/[_.-]/g, ' ')}: ${op.summary}. ${rec.description_safe}`.slice(0, 2000);
76
+ }
77
+ /** Words for a node's lexical query: the request plus the capability's own label and first cues. */
78
+ function nodeQueryTokens(text, capability) {
79
+ const def = (0, taxonomy_1.capabilityById)(capability);
80
+ const label = capability.replace(/[._]/g, ' ');
81
+ const cueWords = def ? def.cues.en.slice(0, 3).map((c) => c.replace(/\\[a-z]|[()?|.*+\[\]{}^$\\]/g, ' ')).join(' ') : '';
82
+ return [...(0, tokenise_1.tokenise)(text), ...(0, tokenise_1.tokenise)(label), ...(0, tokenise_1.tokenise)(label), ...(0, tokenise_1.tokenise)(cueWords)];
83
+ }
84
+ const HEALTH = { up: 1, degraded: 0.5, unknown: 0.5, down: 0 };
85
+ function retrieve(ix, node, text, o = {}) {
86
+ const topN = o.topN ?? 50;
87
+ const useLex = o.useLexical !== false;
88
+ const useVec = o.useVector !== false && !!o.cosines;
89
+ const useCap = o.useCapability !== false;
90
+ const pool = new Set();
91
+ let lexScores = new Map();
92
+ if (useLex) {
93
+ lexScores = ix.bm25.scoreAll(nodeQueryTokens(text, node.capability));
94
+ [...lexScores].sort((a, b) => b[1] - a[1] || a[0] - b[0]).slice(0, topN).forEach(([i]) => pool.add(i));
95
+ }
96
+ const cos = useVec ? o.cosines : null;
97
+ if (cos) {
98
+ const idx = [];
99
+ for (let i = 0; i < cos.length; i++)
100
+ if (!Number.isNaN(cos[i]))
101
+ idx.push(i);
102
+ idx.sort((a, b) => cos[b] - cos[a] || a - b).slice(0, topN).forEach((i) => pool.add(i));
103
+ }
104
+ if (useCap)
105
+ (ix.byCap.get(node.capability) || []).slice(0, o.capCap ?? 300).forEach((i) => pool.add(i));
106
+ const maxLex = Math.max(1e-9, ...[...pool].map((i) => lexScores.get(i) || 0));
107
+ const cal = exports.CALIBRATION[o.queryVector?.model || ''] || { lo: 0.3, hi: 0.8 };
108
+ const out = [];
109
+ for (const i of pool) {
110
+ const item = ix.ops[i];
111
+ const c = cos && !Number.isNaN(cos[i]) ? cos[i] : null;
112
+ out.push({
113
+ index: i, item,
114
+ signals: {
115
+ lexical: useLex ? (lexScores.get(i) || 0) / maxLex : 0,
116
+ vector: c === null ? 0 : Math.max(0, Math.min(1, (c - cal.lo) / (cal.hi - cal.lo))),
117
+ capability: useCap ? (0, capability_1.capabilityScore)(node.capability, item.op.capabilities) : 0,
118
+ schema: (0, schema_fit_1.schemaFit)(node, item.op.input_schema, o.prevOutput?.(node) ?? null),
119
+ health: HEALTH[item.rec.health.state] ?? 0.5,
120
+ cosine: c,
121
+ },
122
+ });
123
+ }
124
+ return out;
125
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Schema fit: the share of an operation's required inputs the request can supply (the node's known arguments), or,
3
+ * for a chained node, whether the previous node's output can feed it. An unknown schema scores 0.3 and nothing that
4
+ * is unknown ever scores 1.
5
+ */
6
+ import type { JSONSchema } from '../types/tool';
7
+ import type { PlanNode } from '../types/route';
8
+ export declare const UNKNOWN_SCHEMA = 0.3;
9
+ export declare function schemaFit(node: Pick<PlanNode, 'args_known' | 'depends_on'>, input: JSONSchema | null | undefined, prevOutput?: JSONSchema | null): number;
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.UNKNOWN_SCHEMA = void 0;
4
+ exports.schemaFit = schemaFit;
5
+ /** Input property names each argument the classifier finds can fill. */
6
+ const FILLS = {
7
+ query: /^(q|query|term|terms|search|text|prompt|brief|topic|keywords?|description|goal|task|idea|request|input|instruction|message|title|question|content|subject|from|name|shop|material|channel)$/i,
8
+ location: /^(city|location|place|lat|latitude|lon|lng|longitude|address|country|region|near|q|query)$/i,
9
+ url: /^(url|uri|link|href|page_url|website)$/i,
10
+ address: /^(address|token|mint|contract|contract_address|wallet|token_address|ca)$/i,
11
+ token: /^(token|mint|symbol|ticker|address|coin|chain|contract|token_address|ids|vs_currencies)$/i,
12
+ amount: /^(amount|value|quantity|qty|sum)$/i,
13
+ file_ref: /^(file|file_id|image|image_ref|image_url|audio|audio_ref|document|pdf|attachment|video|id)$/i,
14
+ recipient: /^(to|recipient|email|phone|number|chat_id|user)$/i,
15
+ date: /^(date|day|from|to|start|end|time|since|until|days)$/i,
16
+ };
17
+ exports.UNKNOWN_SCHEMA = 0.3;
18
+ function schemaFit(node, input, prevOutput) {
19
+ const props = (input && typeof input === 'object' && input.properties && typeof input.properties === 'object') ? Object.keys(input.properties) : [];
20
+ const required = Array.isArray(input?.required) ? input.required.map(String) : [];
21
+ if (!props.length && !required.length)
22
+ return exports.UNKNOWN_SCHEMA;
23
+ if (node.depends_on.length) {
24
+ // chained: the previous node's output names, when known, should cover the required inputs
25
+ const outProps = prevOutput && prevOutput.properties ? Object.keys(prevOutput.properties) : [];
26
+ if (!required.length)
27
+ return 0.8;
28
+ if (!outProps.length)
29
+ return 0.6;
30
+ const covered = required.filter((r) => outProps.includes(r)).length;
31
+ return Math.min(0.95, 0.4 + 0.55 * covered / required.length);
32
+ }
33
+ if (!required.length)
34
+ return 0.8;
35
+ const fillable = (name) => node.args_known.some((a) => FILLS[a]?.test(name));
36
+ const ok = required.filter(fillable).length;
37
+ return Math.min(0.95, 0.15 + 0.8 * ok / required.length);
38
+ }
@@ -0,0 +1,2 @@
1
+ export declare const STOP: Set<string>;
2
+ export declare function tokenise(text: string, keepStop?: boolean): string[];
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.STOP = void 0;
4
+ exports.tokenise = tokenise;
5
+ /**
6
+ * Multilingual tokeniser (FR, EN, Tunisian derja in Latin and Arabic script): normalised text (classify/text.ts),
7
+ * split on anything that is not a letter or a digit, stop words dropped per language, a light plural strip for Latin
8
+ * words and the article "al" stripped from Arabic words. Derja digits inside words are kept (a3mel, ta9s).
9
+ */
10
+ const text_1 = require("../classify/text");
11
+ exports.STOP = new Set([
12
+ // en
13
+ 'the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'on', 'for', 'with', 'by', 'at', 'from', 'is', 'are', 'be', 'it', 'this',
14
+ 'that', 'these', 'those', 'my', 'me', 'i', 'you', 'your', 'we', 'our', 'can', 'could', 'please', 'what', 'which', 'how',
15
+ 'about', 'into', 'as', 'do', 'does', 'get', 'any', 'all', 'some', 'via', 'use', 'using', 'api', 'tool', 'tools', 'data',
16
+ 'will', 'would', 'should', 'want', 'need', 'make', 'give', 'show', 'tell', 'just', 'also', 'one', 'more', 'than', 'then',
17
+ // fr
18
+ 'le', 'la', 'les', 'l', 'de', 'des', 'du', 'd', 'un', 'une', 'et', 'ou', 'en', 'au', 'aux', 'pour', 'avec', 'dans', 'sur',
19
+ 'par', 'mes', 'mon', 'ma', 'moi', 'je', 'tu', 'vous', 'nous', 'est', 'sont', 'ce', 'cette', 'ces', 'qui', 'que', 'qu',
20
+ 'quel', 'quelle', 'fais', 'faire', 's', 'il', 'elle', 'pas', 'ne', 'plus', 'svp',
21
+ // derja (Latin) and Arabic function words
22
+ 'el', 'w', 'fi', 'f', 'l', 'mte3', 'mta3', 'mte3i', 'mta3i', 'chnowa', 'chnoua', 'kifech', 'n7eb', 'nheb', '3la', '3al', 'ya',
23
+ 'في', 'على', 'من', 'و', 'متاع', 'متاعي', 'شنوه', 'نحب', 'هذا', 'هذي', 'الي', 'ال',
24
+ ]);
25
+ const LATIN = /^[a-z0-9]+$/;
26
+ function stem(t) {
27
+ if (LATIN.test(t)) {
28
+ if (t.length > 4 && t.endsWith('ies'))
29
+ return t.slice(0, -3) + 'y';
30
+ if (t.length > 3 && (t.endsWith('s') || t.endsWith('x')) && !t.endsWith('ss'))
31
+ return t.slice(0, -1);
32
+ return t;
33
+ }
34
+ if (t.length > 3 && t.startsWith('ال'))
35
+ return t.slice(2);
36
+ return t;
37
+ }
38
+ function tokenise(text, keepStop = false) {
39
+ const out = [];
40
+ for (const raw of (0, text_1.normalise)(text).split(/[^\p{L}\p{N}]+/u)) {
41
+ if (!raw || raw.length < 2 && !/[؀-ۿ]/.test(raw))
42
+ continue;
43
+ if (!keepStop && exports.STOP.has(raw))
44
+ continue;
45
+ const s = stem(raw);
46
+ if (!keepStop && exports.STOP.has(s))
47
+ continue;
48
+ out.push(s);
49
+ }
50
+ return out;
51
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Cosine over precomputed vectors (one per operation), stamped with model and dimension (ADR-003 rule 2): a query
3
+ * vector from another model or of another dimension is refused, never silently compared.
4
+ */
5
+ export interface VectorSet {
6
+ model: string;
7
+ dims: number;
8
+ ids: string[];
9
+ data: Float32Array;
10
+ }
11
+ export declare function normaliseRows(data: Float32Array, dims: number): Float32Array;
12
+ export declare function checkQuery(set: Pick<VectorSet, 'model' | 'dims'>, model: string, q: Float32Array): void;
13
+ /** Cosine of a normalised query against every row (rows must be normalised). */
14
+ export declare function cosineAll(q: Float32Array, data: Float32Array, dims: number): Float32Array;
15
+ export declare function topK(q: Float32Array, data: Float32Array, dims: number, k: number): Array<{
16
+ row: number;
17
+ score: number;
18
+ }>;
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normaliseRows = normaliseRows;
4
+ exports.checkQuery = checkQuery;
5
+ exports.cosineAll = cosineAll;
6
+ exports.topK = topK;
7
+ function normaliseRows(data, dims) {
8
+ const out = new Float32Array(data.length);
9
+ for (let r = 0; r < data.length / dims; r++) {
10
+ let n = 0;
11
+ for (let j = 0; j < dims; j++)
12
+ n += data[r * dims + j] ** 2;
13
+ n = Math.sqrt(n) || 1;
14
+ for (let j = 0; j < dims; j++)
15
+ out[r * dims + j] = data[r * dims + j] / n;
16
+ }
17
+ return out;
18
+ }
19
+ function checkQuery(set, model, q) {
20
+ if (model !== set.model)
21
+ throw new Error(`query vector from ${model}, catalog vectors from ${set.model}: refused`);
22
+ if (q.length !== set.dims)
23
+ throw new Error(`query has ${q.length} dimensions, catalog vectors ${set.dims}: refused`);
24
+ }
25
+ /** Cosine of a normalised query against every row (rows must be normalised). */
26
+ function cosineAll(q, data, dims) {
27
+ let n = 0;
28
+ for (let j = 0; j < dims; j++)
29
+ n += q[j] * q[j];
30
+ n = Math.sqrt(n) || 1;
31
+ const rows = data.length / dims;
32
+ const out = new Float32Array(rows);
33
+ for (let r = 0; r < rows; r++) {
34
+ let s = 0;
35
+ const o = r * dims;
36
+ for (let j = 0; j < dims; j++)
37
+ s += q[j] * data[o + j];
38
+ out[r] = s / n;
39
+ }
40
+ return out;
41
+ }
42
+ function topK(q, data, dims, k) {
43
+ const sims = cosineAll(q, data, dims);
44
+ const idx = Array.from(sims.keys());
45
+ idx.sort((a, b) => sims[b] - sims[a] || a - b);
46
+ return idx.slice(0, k).map((row) => ({ row, score: sims[row] }));
47
+ }
@@ -0,0 +1,37 @@
1
+ import type { KtrFlags } from './flags';
2
+ import type { CatalogStore, ClassifierPort, Clock, EmbeddingPort, EvidenceStore, SkillIndex } from './types/ports';
3
+ import type { RouteRequest, RouteResponse } from './types/route';
4
+ import { RouterIndex } from './retrieve/hybrid';
5
+ import { type FitWeights, type Weights } from './rank/weights';
6
+ export interface RouterConfig {
7
+ weights?: Weights;
8
+ fitWeights?: FitWeights;
9
+ useLexical?: boolean;
10
+ useVector?: boolean;
11
+ useCapability?: boolean;
12
+ scoring?: 'full' | 'fit';
13
+ exploration?: boolean;
14
+ vectorModel?: string;
15
+ floor?: number;
16
+ /** The AIAX ready bonus (rank/weights.ts AIAX_READY_WEIGHT by default); 0 turns it off. */
17
+ aiaxWeight?: number;
18
+ }
19
+ export interface RouterDeps {
20
+ store: CatalogStore;
21
+ index?: RouterIndex;
22
+ flags: KtrFlags;
23
+ /** Offline CLIs and the benchmark (stage 0): the router answers with TOOL_ROUTER_ENABLED off; the stop file still wins. */
24
+ offline?: boolean;
25
+ classifier?: ClassifierPort | null;
26
+ embed?: EmbeddingPort | null;
27
+ queryVector?: {
28
+ model: string;
29
+ vector: Float32Array;
30
+ } | null;
31
+ evidence?: EvidenceStore | null;
32
+ skills?: SkillIndex | null;
33
+ clock?: Clock;
34
+ config?: RouterConfig;
35
+ log?: (entry: Record<string, unknown>) => void;
36
+ }
37
+ export declare function route(req: RouteRequest, deps: RouterDeps): Promise<RouteResponse>;
package/dist/router.js ADDED
@@ -0,0 +1,114 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.route = route;
4
+ /**
5
+ * route(): classify, decompose, then per node retrieve, filter (eligibility before any score), rank and shortlist
6
+ * (plan.md S4 of the spec, "router.ts"). Offline in milestone 1: it selects, it never executes (decision D2).
7
+ *
8
+ * The response names the catalog snapshot it read; every candidate is a row of that snapshot. Requirement:
9
+ * no_tool / clarify from the classifier (simple first: a shortlist is never forced, ADR-008)
10
+ * tool at least one tool step has an eligible candidate
11
+ * no_eligible_tool tool steps exist but every candidate was excluded, or none exists
12
+ * Routes are logged through deps.log with ids, scores and exclusion counts; a citizen's request text is replaced by
13
+ * its hash (privacy).
14
+ */
15
+ const node_crypto_1 = require("node:crypto");
16
+ const classifier_1 = require("./classify/classifier");
17
+ const decompose_1 = require("./classify/decompose");
18
+ const hybrid_1 = require("./retrieve/hybrid");
19
+ const engine_1 = require("./eligibility/engine");
20
+ const rules_1 = require("./eligibility/rules");
21
+ const ranker_1 = require("./rank/ranker");
22
+ const weights_1 = require("./rank/weights");
23
+ const generate_1 = require("./shortlist/generate");
24
+ const sha = (s) => (0, node_crypto_1.createHash)('sha256').update(s).digest('hex');
25
+ async function route(req, deps) {
26
+ const now = (deps.clock?.now() || new Date()).toISOString();
27
+ const mode = req.mode || 'execute_plan';
28
+ const route_id = `route_${sha(`${now}|${req.request_id || ''}|${req.context.caller_id}|${req.text}`).slice(0, 24)}`;
29
+ const cfg = deps.config || {};
30
+ const notes = [];
31
+ const base = { route_id, created_at: now, catalog_snapshot: deps.store.snapshotId(), mode, request_step: req.request_step };
32
+ if (deps.flags.stopped || (!deps.flags.enabled && !deps.offline)) {
33
+ return { ...base, task_complexity: 'SIMPLE', risk_classes: [], requirement: 'no_eligible_tool', classifier: { method: 'rules', confidence: 0 }, plan: [], shortlists: [],
34
+ notes: [deps.flags.stopped ? 'KTR is stopped (.swarm-state/ktr-stop): the caller continues without it' : 'router disabled (TOOL_ROUTER_ENABLED off): the caller continues with request routing as before'] };
35
+ }
36
+ if (cfg.weights)
37
+ (0, weights_1.checkWeights)(cfg.weights, cfg.fitWeights);
38
+ const c = await (0, classifier_1.classify)(req.text, req.context, deps.flags, deps.classifier || null);
39
+ const plan = (0, decompose_1.decompose)(c, req.text);
40
+ const resp = { ...base, task_complexity: c.task_complexity, risk_classes: c.risk_classes, requirement: c.requirement,
41
+ classifier: { method: c.method, confidence: c.confidence }, plan, shortlists: [], notes };
42
+ if (c.requirement === 'no_tool') {
43
+ notes.push('no tool needed: the model can answer this itself (request routing step 1)');
44
+ return finish(resp, req, deps);
45
+ }
46
+ if (c.requirement === 'clarify') {
47
+ notes.push('the request is too vague to plan: ask the person what they want');
48
+ return finish(resp, req, deps);
49
+ }
50
+ const ix = deps.index && deps.index.snapshot === resp.catalog_snapshot ? deps.index : new hybrid_1.RouterIndex(deps.store, { vectorModel: cfg.vectorModel || deps.queryVector?.model || deps.embed?.model });
51
+ let qv = deps.queryVector || null;
52
+ if (!qv && deps.embed && ix.vectors && cfg.useVector !== false) {
53
+ try {
54
+ qv = { model: deps.embed.model, vector: (await deps.embed.embed([req.text]))[0] };
55
+ }
56
+ catch {
57
+ notes.push('query embedding unavailable: lexical and capability signals only');
58
+ }
59
+ }
60
+ const cosines = qv && cfg.useVector !== false ? ix.cosines(qv.model, qv.vector) : null;
61
+ const use = { capability: cfg.useCapability !== false, vector: !!cosines, lexical: cfg.useLexical !== false };
62
+ if (!cosines && cfg.useVector !== false)
63
+ notes.push(ix.vectors ? 'no query vector (not cached, gateway not asked): fit uses capability and lexical signals' : 'no vectors for this catalog: fit uses capability and lexical signals');
64
+ const floor = cfg.floor ?? weights_1.RELEVANCE_FLOOR;
65
+ const maxC = req.constraints?.max_candidates;
66
+ let anyEligible = false;
67
+ let anyToolNode = false;
68
+ const excludedTotal = {};
69
+ for (const node of plan) {
70
+ if (!node.needs_tool)
71
+ continue;
72
+ anyToolNode = true;
73
+ const found = (0, hybrid_1.retrieve)(ix, node, req.text, { useLexical: use.lexical, useVector: use.vector, useCapability: use.capability, queryVector: qv, cosines });
74
+ const relevant = (x) => (0, weights_1.fitOf)(x.signals, use, cfg.fitWeights) >= floor;
75
+ const { eligible, excluded } = (0, engine_1.filter)(found, { node, ctx: req.context, mode, flags: deps.flags, offline: !!deps.offline, constraints: req.constraints }, relevant);
76
+ const ranked = (0, ranker_1.rank)(eligible, { weights: cfg.weights || weights_1.DEFAULT_WEIGHTS, fitWeights: cfg.fitWeights, use, scoring: cfg.scoring, seed: route_id, exploration: !!cfg.exploration, evidence: deps.evidence, skills: deps.skills, aiaxWeight: cfg.aiaxWeight });
77
+ const { candidates, kognaiFirst } = (0, generate_1.generate)(ranked, node, req.context, { max: maxC, floor, mode });
78
+ for (const [k, v] of Object.entries(excluded))
79
+ excludedTotal[k] = (excludedTotal[k] || 0) + (v || 0);
80
+ const sl = { node_id: node.node_id, candidates, excluded: excluded };
81
+ resp.shortlists.push(sl);
82
+ if (candidates.length)
83
+ anyEligible = true;
84
+ if (kognaiFirst)
85
+ notes.push(`${node.node_id}: a Kognai product covers ${node.capability}; outside tools are fallbacks only (ADR-008)`);
86
+ if (!candidates.length)
87
+ notes.push(explainEmpty(node, excluded));
88
+ }
89
+ if (anyToolNode && !anyEligible)
90
+ resp.requirement = 'no_eligible_tool';
91
+ if (resp.risk_classes.includes('payment'))
92
+ notes.push('payment steps are never autonomous: a transfer is the Founder\'s own act (policy/security.txt rule 1); the Kognai Wallet is a spec only');
93
+ if (mode === 'discover' && resp.shortlists.some((s) => s.candidates.some((x) => !x.executable)))
94
+ notes.push('discover mode: quarantined tools are listed for review and are not executable');
95
+ return finish(resp, req, deps);
96
+ }
97
+ function explainEmpty(node, excluded) {
98
+ const parts = rules_1.RULE_IDS.filter((r) => excluded[r]).map((r) => `${excluded[r]} ${rules_1.RULE_LABEL[r]} (${r})`);
99
+ return parts.length ? `${node.node_id} ${node.capability}: no eligible tool; excluded: ${parts.join('; ')}` : `${node.node_id} ${node.capability}: no tool in the catalog covers this step`;
100
+ }
101
+ function finish(resp, req, deps) {
102
+ if (deps.log) {
103
+ try {
104
+ deps.log({
105
+ route_id: resp.route_id, at: resp.created_at, caller_kind: req.context.kind, caller_id: req.context.caller_id,
106
+ ...(req.context.kind === 'citizen' ? { text_sha256: sha(req.text) } : { text: req.text.slice(0, 500) }),
107
+ snapshot: resp.catalog_snapshot, mode: resp.mode, requirement: resp.requirement, task_complexity: resp.task_complexity, risk: resp.risk_classes,
108
+ nodes: resp.shortlists.map((s) => ({ node_id: s.node_id, top: s.candidates.slice(0, 10).map((c) => [c.tool_id, c.score]), excluded: s.excluded })),
109
+ });
110
+ }
111
+ catch { /* logging never breaks routing */ }
112
+ }
113
+ return resp;
114
+ }