@skill-tools/router 0.2.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.
- package/LICENSE +190 -0
- package/README.md +61 -0
- package/dist/index.cjs +956 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +426 -0
- package/dist/index.d.ts +426 -0
- package/dist/index.js +950 -0
- package/dist/index.js.map +1 -0
- package/package.json +68 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Okapi BM25 — Zero-dependency, optimized text search index.
|
|
3
|
+
*
|
|
4
|
+
* Builds an inverted index from document text and scores queries
|
|
5
|
+
* using the BM25 ranking function. Designed for fast skill routing
|
|
6
|
+
* with catalogs up to ~10,000 entries.
|
|
7
|
+
*
|
|
8
|
+
* Performance:
|
|
9
|
+
* - Index build: O(n * avg_doc_len)
|
|
10
|
+
* - Query: O(q * avg_posting_len) — only visits docs containing query terms
|
|
11
|
+
* - Memory: O(vocabulary_size * avg_posting_len)
|
|
12
|
+
*
|
|
13
|
+
* @packageDocumentation
|
|
14
|
+
*/
|
|
15
|
+
/** A posting list entry: document index + term frequency */
|
|
16
|
+
interface Posting {
|
|
17
|
+
readonly docIdx: number;
|
|
18
|
+
readonly tf: number;
|
|
19
|
+
}
|
|
20
|
+
/** Serialized snapshot of BM25Index state */
|
|
21
|
+
interface BM25Snapshot {
|
|
22
|
+
readonly version: 2;
|
|
23
|
+
readonly documents: ReadonlyArray<{
|
|
24
|
+
readonly id: string;
|
|
25
|
+
readonly length: number;
|
|
26
|
+
readonly metadata: Record<string, unknown>;
|
|
27
|
+
}>;
|
|
28
|
+
readonly invertedIndex: ReadonlyArray<[string, Posting[]]>;
|
|
29
|
+
readonly idf: ReadonlyArray<[string, number]>;
|
|
30
|
+
readonly avgdl: number;
|
|
31
|
+
readonly k1: number;
|
|
32
|
+
readonly b: number;
|
|
33
|
+
}
|
|
34
|
+
/** Options for creating a BM25Index */
|
|
35
|
+
interface BM25Options {
|
|
36
|
+
/** Term frequency saturation parameter (default: 1.2) */
|
|
37
|
+
readonly k1?: number;
|
|
38
|
+
/** Document length normalization parameter (default: 0.75) */
|
|
39
|
+
readonly b?: number;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* BM25Index — Fast, zero-dependency full-text search using Okapi BM25.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```ts
|
|
46
|
+
* const idx = new BM25Index();
|
|
47
|
+
* idx.add([
|
|
48
|
+
* { id: 'deploy', text: 'Deploy apps to Vercel production', metadata: {} },
|
|
49
|
+
* { id: 'test', text: 'Run unit tests with coverage', metadata: {} },
|
|
50
|
+
* ]);
|
|
51
|
+
* const results = idx.search('deploy production', 5);
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
declare class BM25Index {
|
|
55
|
+
private readonly k1;
|
|
56
|
+
private readonly b;
|
|
57
|
+
private documents;
|
|
58
|
+
private invertedIndex;
|
|
59
|
+
private idfCache;
|
|
60
|
+
private avgdl;
|
|
61
|
+
private totalDocLength;
|
|
62
|
+
constructor(options?: BM25Options);
|
|
63
|
+
/**
|
|
64
|
+
* Add documents to the index.
|
|
65
|
+
* Batch operation — IDF is recomputed once after all documents are added.
|
|
66
|
+
*/
|
|
67
|
+
add(entries: ReadonlyArray<{
|
|
68
|
+
readonly id: string;
|
|
69
|
+
readonly text: string;
|
|
70
|
+
readonly metadata: Record<string, unknown>;
|
|
71
|
+
}>): void;
|
|
72
|
+
/**
|
|
73
|
+
* Remove documents by ID.
|
|
74
|
+
* Rebuilds internal index mappings after removal.
|
|
75
|
+
*/
|
|
76
|
+
remove(ids: readonly string[]): void;
|
|
77
|
+
/**
|
|
78
|
+
* Search the index with a query string.
|
|
79
|
+
*
|
|
80
|
+
* Returns results sorted by BM25 score (highest first).
|
|
81
|
+
* Scores are normalized to [0, 1] — the top result gets 1.0.
|
|
82
|
+
*
|
|
83
|
+
* Only documents containing at least one query term are scored,
|
|
84
|
+
* making queries fast even on large indexes.
|
|
85
|
+
*/
|
|
86
|
+
search(query: string, topK: number, threshold?: number): Array<{
|
|
87
|
+
readonly id: string;
|
|
88
|
+
readonly score: number;
|
|
89
|
+
readonly metadata: Record<string, unknown>;
|
|
90
|
+
}>;
|
|
91
|
+
/** Number of indexed documents */
|
|
92
|
+
size(): number;
|
|
93
|
+
/** Serialize to a JSON-compatible snapshot */
|
|
94
|
+
serialize(): BM25Snapshot;
|
|
95
|
+
/** Restore from a serialized snapshot */
|
|
96
|
+
deserialize(data: unknown): void;
|
|
97
|
+
/** Recompute IDF values for all terms in the inverted index */
|
|
98
|
+
private recomputeIDF;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Context extractor for contextual retrieval.
|
|
103
|
+
*
|
|
104
|
+
* Extracts supplementary terms from a skill's body and sections
|
|
105
|
+
* to enrich the description before BM25 indexing. This is a
|
|
106
|
+
* deterministic, zero-dependency alternative to LLM-generated
|
|
107
|
+
* chunk context (see: Anthropic's contextual retrieval paper).
|
|
108
|
+
*
|
|
109
|
+
* @packageDocumentation
|
|
110
|
+
*/
|
|
111
|
+
/** Minimal skill shape required for context extraction */
|
|
112
|
+
interface ContextInput {
|
|
113
|
+
readonly name: string;
|
|
114
|
+
readonly description: string;
|
|
115
|
+
readonly body?: string;
|
|
116
|
+
readonly sections?: ReadonlyArray<{
|
|
117
|
+
readonly heading: string;
|
|
118
|
+
readonly depth: number;
|
|
119
|
+
readonly content: string;
|
|
120
|
+
}>;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Extract supplementary context from a skill's body and structure.
|
|
124
|
+
*
|
|
125
|
+
* Returns a space-separated string of unique terms derived from:
|
|
126
|
+
* 1. Skill name parts (split on `-` and `_`)
|
|
127
|
+
* 2. Section headings
|
|
128
|
+
* 3. Inline code references (backtick-wrapped)
|
|
129
|
+
* 4. Key terms from body text
|
|
130
|
+
*
|
|
131
|
+
* Terms already present in the description are omitted.
|
|
132
|
+
* Result is truncated to ~80 tokens.
|
|
133
|
+
*
|
|
134
|
+
* Returns empty string if no useful context can be extracted.
|
|
135
|
+
*/
|
|
136
|
+
declare function extractContext(skill: ContextInput): string;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Interface for embedding providers.
|
|
140
|
+
* Implementations convert text into dense vector representations
|
|
141
|
+
* for semantic similarity comparison.
|
|
142
|
+
*/
|
|
143
|
+
interface EmbeddingProvider {
|
|
144
|
+
/** Human-readable name of the provider */
|
|
145
|
+
readonly name: string;
|
|
146
|
+
/** Dimensionality of the output vectors */
|
|
147
|
+
readonly dimensions: number;
|
|
148
|
+
/**
|
|
149
|
+
* Generate embeddings for a batch of texts.
|
|
150
|
+
* @param texts - Array of text strings to embed
|
|
151
|
+
* @returns Array of embedding vectors (same order as input)
|
|
152
|
+
*/
|
|
153
|
+
embed(texts: string[]): Promise<number[][]>;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Configuration for embedding providers.
|
|
157
|
+
*/
|
|
158
|
+
type EmbeddingConfig = 'local' | {
|
|
159
|
+
provider: 'openai';
|
|
160
|
+
model?: string;
|
|
161
|
+
apiKey?: string;
|
|
162
|
+
} | {
|
|
163
|
+
provider: 'ollama';
|
|
164
|
+
model?: string;
|
|
165
|
+
baseUrl?: string;
|
|
166
|
+
} | {
|
|
167
|
+
provider: 'custom';
|
|
168
|
+
embed: (texts: string[]) => Promise<number[][]>;
|
|
169
|
+
dimensions: number;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Local TF-IDF based embedding provider.
|
|
174
|
+
*
|
|
175
|
+
* Uses a deterministic hash-based approach to create sparse-then-dense
|
|
176
|
+
* embeddings from text. No external API calls or model downloads needed.
|
|
177
|
+
*
|
|
178
|
+
* Quality is lower than neural embedding models, but sufficient for
|
|
179
|
+
* keyword-heavy skill descriptions where exact word matching matters.
|
|
180
|
+
* Ideal for catalogs under 500 skills.
|
|
181
|
+
*/
|
|
182
|
+
declare class LocalEmbeddingProvider implements EmbeddingProvider {
|
|
183
|
+
readonly name = "local-tfidf";
|
|
184
|
+
readonly dimensions: number;
|
|
185
|
+
private vocabulary;
|
|
186
|
+
private idfValues;
|
|
187
|
+
private isBuilt;
|
|
188
|
+
constructor(dimensions?: number);
|
|
189
|
+
/**
|
|
190
|
+
* Build the vocabulary and IDF values from a corpus.
|
|
191
|
+
* Call this once after indexing all skill descriptions.
|
|
192
|
+
*/
|
|
193
|
+
buildVocabulary(texts: string[]): void;
|
|
194
|
+
embed(texts: string[]): Promise<number[][]>;
|
|
195
|
+
private embedSingle;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* A skill entry prepared for indexing.
|
|
200
|
+
*/
|
|
201
|
+
interface SkillEntry {
|
|
202
|
+
/** Unique identifier (typically the skill name) */
|
|
203
|
+
readonly name: string;
|
|
204
|
+
/** The description text to embed */
|
|
205
|
+
readonly description: string;
|
|
206
|
+
/** Path to the SKILL.md file */
|
|
207
|
+
readonly path?: string;
|
|
208
|
+
/** Additional metadata to store alongside the embedding */
|
|
209
|
+
readonly metadata?: Record<string, unknown>;
|
|
210
|
+
/** Raw markdown body (used for contextual retrieval) */
|
|
211
|
+
readonly body?: string;
|
|
212
|
+
/** Parsed sections from the SKILL.md (used for contextual retrieval) */
|
|
213
|
+
readonly sections?: ReadonlyArray<{
|
|
214
|
+
readonly heading: string;
|
|
215
|
+
readonly depth: number;
|
|
216
|
+
readonly content: string;
|
|
217
|
+
}>;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Result of selecting a skill for a query.
|
|
221
|
+
*/
|
|
222
|
+
interface SelectionResult {
|
|
223
|
+
/** Skill name/ID */
|
|
224
|
+
readonly skill: string;
|
|
225
|
+
/** Similarity score (0-1) */
|
|
226
|
+
readonly score: number;
|
|
227
|
+
/** Metadata from the indexed skill */
|
|
228
|
+
readonly metadata: Record<string, unknown>;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Options for skill selection queries.
|
|
232
|
+
*/
|
|
233
|
+
interface SelectOptions {
|
|
234
|
+
/** Number of results to return (default: 5) */
|
|
235
|
+
readonly topK?: number;
|
|
236
|
+
/** Minimum similarity threshold (default: 0.0) */
|
|
237
|
+
readonly threshold?: number;
|
|
238
|
+
/** Skill names to boost in ranking */
|
|
239
|
+
readonly boost?: string[];
|
|
240
|
+
/** Skill name patterns to exclude */
|
|
241
|
+
readonly exclude?: string[];
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Options for the SkillRouter constructor.
|
|
245
|
+
*/
|
|
246
|
+
interface SkillRouterOptions {
|
|
247
|
+
/** Embedding provider configuration. Defaults to BM25 ('local'). */
|
|
248
|
+
readonly embedding?: EmbeddingConfig;
|
|
249
|
+
/** BM25 tuning parameters (only used with the default BM25 engine) */
|
|
250
|
+
readonly bm25?: BM25Options;
|
|
251
|
+
/**
|
|
252
|
+
* Enable contextual retrieval. When true (default), skills with
|
|
253
|
+
* body or sections will have supplementary context extracted and
|
|
254
|
+
* prepended to their description before indexing.
|
|
255
|
+
* Only affects indexing — result descriptions stay unchanged.
|
|
256
|
+
*/
|
|
257
|
+
readonly context?: boolean;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* SkillRouter — Skill selection middleware using BM25 full-text search.
|
|
261
|
+
*
|
|
262
|
+
* Indexes skill descriptions and enables fast, ranked search to find
|
|
263
|
+
* the most relevant skills for a given query. Uses Okapi BM25 by default
|
|
264
|
+
* with zero external dependencies.
|
|
265
|
+
*
|
|
266
|
+
* For neural/semantic embeddings, pass a custom embedding provider
|
|
267
|
+
* via the `embedding` option.
|
|
268
|
+
*
|
|
269
|
+
* @example
|
|
270
|
+
* ```ts
|
|
271
|
+
* const router = new SkillRouter();
|
|
272
|
+
* await router.indexSkills([
|
|
273
|
+
* { name: 'deploy-vercel', description: 'Deploy apps to Vercel...' },
|
|
274
|
+
* { name: 'run-tests', description: 'Execute test suites...' },
|
|
275
|
+
* ]);
|
|
276
|
+
*
|
|
277
|
+
* const results = await router.select('deploy my app');
|
|
278
|
+
* // => [{ skill: 'deploy-vercel', score: 0.89, ... }]
|
|
279
|
+
* ```
|
|
280
|
+
*/
|
|
281
|
+
declare class SkillRouter {
|
|
282
|
+
/** BM25 index — used when no external embedding provider is configured */
|
|
283
|
+
private readonly bm25;
|
|
284
|
+
/** Embedding provider — used with custom/openai/ollama providers */
|
|
285
|
+
private readonly embedding;
|
|
286
|
+
/** Vector store — used alongside embedding provider */
|
|
287
|
+
private readonly store;
|
|
288
|
+
/** Whether the router uses the BM25 engine (true) or embedding+store (false) */
|
|
289
|
+
private readonly usesBM25;
|
|
290
|
+
/** Whether contextual retrieval is enabled */
|
|
291
|
+
private readonly contextEnabled;
|
|
292
|
+
private skillNames;
|
|
293
|
+
constructor(options?: SkillRouterOptions);
|
|
294
|
+
/**
|
|
295
|
+
* Index a list of skill entries.
|
|
296
|
+
* With BM25 (default): indexes description text directly.
|
|
297
|
+
* With embeddings: embeds descriptions and stores vectors.
|
|
298
|
+
*/
|
|
299
|
+
indexSkills(skills: SkillEntry[]): Promise<void>;
|
|
300
|
+
/**
|
|
301
|
+
* Index all SKILL.md files in a directory.
|
|
302
|
+
* Parses each file and indexes its description.
|
|
303
|
+
*/
|
|
304
|
+
indexDirectory(dirPath: string): Promise<number>;
|
|
305
|
+
/**
|
|
306
|
+
* Select the most relevant skills for a query.
|
|
307
|
+
*/
|
|
308
|
+
select(query: string, options?: SelectOptions): Promise<SelectionResult[]>;
|
|
309
|
+
/**
|
|
310
|
+
* Detect skills with overlapping descriptions.
|
|
311
|
+
*/
|
|
312
|
+
detectConflicts(threshold?: number): Promise<ConflictGroup[]>;
|
|
313
|
+
/**
|
|
314
|
+
* Build the text to index for a skill entry.
|
|
315
|
+
* When contextual retrieval is enabled and the skill has body/sections,
|
|
316
|
+
* prepends extracted context to the description.
|
|
317
|
+
*/
|
|
318
|
+
private enrichText;
|
|
319
|
+
/**
|
|
320
|
+
* Get the number of indexed skills.
|
|
321
|
+
*/
|
|
322
|
+
get count(): number;
|
|
323
|
+
/**
|
|
324
|
+
* Save the index to a JSON-serializable object.
|
|
325
|
+
*/
|
|
326
|
+
save(): SkillRouterSnapshot;
|
|
327
|
+
/**
|
|
328
|
+
* Load a previously saved index.
|
|
329
|
+
* Validates that the snapshot format matches the current engine.
|
|
330
|
+
*/
|
|
331
|
+
load(snapshot: SkillRouterSnapshot): void;
|
|
332
|
+
/**
|
|
333
|
+
* Create a SkillRouter from a saved snapshot.
|
|
334
|
+
*/
|
|
335
|
+
static fromSnapshot(snapshot: SkillRouterSnapshot, options?: SkillRouterOptions): SkillRouter;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* A group of conflicting (highly similar) skills.
|
|
339
|
+
*/
|
|
340
|
+
interface ConflictGroup {
|
|
341
|
+
readonly skills: string[];
|
|
342
|
+
readonly similarity: number;
|
|
343
|
+
readonly suggestion: string;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Serialized snapshot of a SkillRouter state.
|
|
347
|
+
*/
|
|
348
|
+
interface SkillRouterSnapshot {
|
|
349
|
+
readonly version: number;
|
|
350
|
+
readonly embeddingProvider: string;
|
|
351
|
+
readonly dimensions: number;
|
|
352
|
+
readonly store: unknown;
|
|
353
|
+
readonly skillNames: string[];
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* A single entry in the vector store.
|
|
358
|
+
*/
|
|
359
|
+
interface VectorEntry {
|
|
360
|
+
/** Unique identifier (skill name or path) */
|
|
361
|
+
readonly id: string;
|
|
362
|
+
/** The embedding vector */
|
|
363
|
+
readonly vector: number[];
|
|
364
|
+
/** Metadata associated with this entry */
|
|
365
|
+
readonly metadata: Record<string, unknown>;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Result of a similarity search.
|
|
369
|
+
*/
|
|
370
|
+
interface SearchResult {
|
|
371
|
+
/** Identifier of the matched entry */
|
|
372
|
+
readonly id: string;
|
|
373
|
+
/** Cosine similarity score (0-1, higher is more similar) */
|
|
374
|
+
readonly score: number;
|
|
375
|
+
/** Metadata from the matched entry */
|
|
376
|
+
readonly metadata: Record<string, unknown>;
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Interface for vector storage backends.
|
|
380
|
+
*/
|
|
381
|
+
interface VectorStore {
|
|
382
|
+
/**
|
|
383
|
+
* Add entries to the store.
|
|
384
|
+
*/
|
|
385
|
+
add(entries: VectorEntry[]): Promise<void>;
|
|
386
|
+
/**
|
|
387
|
+
* Search for the top-K most similar entries to a query vector.
|
|
388
|
+
*/
|
|
389
|
+
search(queryVector: number[], topK: number, threshold?: number): Promise<SearchResult[]>;
|
|
390
|
+
/**
|
|
391
|
+
* Remove entries by ID.
|
|
392
|
+
*/
|
|
393
|
+
remove(ids: string[]): Promise<void>;
|
|
394
|
+
/**
|
|
395
|
+
* Get the number of entries in the store.
|
|
396
|
+
*/
|
|
397
|
+
size(): number;
|
|
398
|
+
/**
|
|
399
|
+
* Serialize the store to a JSON-compatible object.
|
|
400
|
+
*/
|
|
401
|
+
serialize(): unknown;
|
|
402
|
+
/**
|
|
403
|
+
* Load from a serialized object.
|
|
404
|
+
*/
|
|
405
|
+
deserialize(data: unknown): void;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* In-memory vector store using brute-force cosine similarity search.
|
|
410
|
+
*
|
|
411
|
+
* Suitable for catalogs of up to ~1,000 skills. For larger catalogs,
|
|
412
|
+
* use the SQLite backend.
|
|
413
|
+
*
|
|
414
|
+
* At 1,000 entries with 256-dimensional vectors, search takes <5ms.
|
|
415
|
+
*/
|
|
416
|
+
declare class MemoryVectorStore implements VectorStore {
|
|
417
|
+
private entries;
|
|
418
|
+
add(entries: VectorEntry[]): Promise<void>;
|
|
419
|
+
search(queryVector: number[], topK: number, threshold?: number): Promise<SearchResult[]>;
|
|
420
|
+
remove(ids: string[]): Promise<void>;
|
|
421
|
+
size(): number;
|
|
422
|
+
serialize(): unknown;
|
|
423
|
+
deserialize(data: unknown): void;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export { BM25Index, type BM25Options, type BM25Snapshot, type ConflictGroup, type ContextInput, type EmbeddingConfig, type EmbeddingProvider, LocalEmbeddingProvider, MemoryVectorStore, type SearchResult, type SelectOptions, type SelectionResult, type SkillEntry, SkillRouter, type SkillRouterOptions, type SkillRouterSnapshot, type VectorEntry, type VectorStore, extractContext };
|