capman 0.5.4 → 0.6.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/CHANGELOG.md +46 -0
- package/CODEBASE.md +111 -66
- package/README.md +45 -4
- package/bin/lib/cmd-generate.js +200 -40
- package/bin/lib/cmd-help.js +3 -0
- package/dist/cjs/cache.d.ts.map +1 -1
- package/dist/cjs/cache.js +22 -5
- package/dist/cjs/cache.js.map +1 -1
- package/dist/cjs/engine.d.ts +53 -1
- package/dist/cjs/engine.d.ts.map +1 -1
- package/dist/cjs/engine.js +252 -17
- package/dist/cjs/engine.js.map +1 -1
- package/dist/cjs/generator.d.ts.map +1 -1
- package/dist/cjs/generator.js +7 -1
- package/dist/cjs/generator.js.map +1 -1
- package/dist/cjs/index.d.ts +1 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +3 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/learning.d.ts.map +1 -1
- package/dist/cjs/learning.js +51 -30
- package/dist/cjs/learning.js.map +1 -1
- package/dist/cjs/matcher.d.ts +69 -9
- package/dist/cjs/matcher.d.ts.map +1 -1
- package/dist/cjs/matcher.js +328 -43
- package/dist/cjs/matcher.js.map +1 -1
- package/dist/cjs/parser.d.ts.map +1 -1
- package/dist/cjs/parser.js +15 -8
- package/dist/cjs/parser.js.map +1 -1
- package/dist/cjs/resolver.d.ts +1 -0
- package/dist/cjs/resolver.d.ts.map +1 -1
- package/dist/cjs/resolver.js +16 -5
- package/dist/cjs/resolver.js.map +1 -1
- package/dist/cjs/schema.d.ts +64 -46
- package/dist/cjs/schema.d.ts.map +1 -1
- package/dist/cjs/schema.js +2 -1
- package/dist/cjs/schema.js.map +1 -1
- package/dist/cjs/types.d.ts +8 -2
- package/dist/cjs/types.d.ts.map +1 -1
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/cache.js +22 -5
- package/dist/esm/engine.d.ts +53 -1
- package/dist/esm/engine.js +255 -20
- package/dist/esm/generator.js +7 -1
- package/dist/esm/index.d.ts +1 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/learning.js +52 -31
- package/dist/esm/matcher.d.ts +69 -9
- package/dist/esm/matcher.js +321 -42
- package/dist/esm/parser.js +15 -8
- package/dist/esm/resolver.d.ts +1 -0
- package/dist/esm/resolver.js +16 -6
- package/dist/esm/schema.d.ts +64 -46
- package/dist/esm/schema.js +2 -1
- package/dist/esm/types.d.ts +8 -2
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +1 -1
package/dist/esm/matcher.d.ts
CHANGED
|
@@ -3,20 +3,80 @@ export declare class LLMParseError extends Error {
|
|
|
3
3
|
constructor(message: string);
|
|
4
4
|
}
|
|
5
5
|
export declare const STOPWORDS: Set<string>;
|
|
6
|
+
/**
|
|
7
|
+
* Regex patterns for common param types.
|
|
8
|
+
* Used when a CapabilityParam has `pattern` set to a named type.
|
|
9
|
+
*/
|
|
10
|
+
export declare const TYPE_PATTERNS: Record<string, RegExp>;
|
|
11
|
+
/**
|
|
12
|
+
* Simplified suffix-stripping stemmer — 10 most common English morphological
|
|
13
|
+
* patterns covering ~80% of benefit at ~25% the complexity of Porter stemmer.
|
|
14
|
+
* Applied symmetrically to both query words and capability index words.
|
|
15
|
+
*/
|
|
16
|
+
export declare function stem(word: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* Shared tokenizer — used by scorer, learning index, and boost system.
|
|
19
|
+
* Applies stopword filtering AND stemming symmetrically.
|
|
20
|
+
* Any site that tokenizes text for matching MUST use this function
|
|
21
|
+
* to avoid silent mismatches between query and index tokens.
|
|
22
|
+
*/
|
|
23
|
+
export declare function tokenize(text: string): string[];
|
|
24
|
+
export interface BM25Index {
|
|
25
|
+
/** Document frequency — how many capabilities contain each term */
|
|
26
|
+
df: Record<string, number>;
|
|
27
|
+
/** Average field length per field type */
|
|
28
|
+
avgdl: {
|
|
29
|
+
examples: number;
|
|
30
|
+
description: number;
|
|
31
|
+
name: number;
|
|
32
|
+
};
|
|
33
|
+
/** Total number of capabilities */
|
|
34
|
+
N: number;
|
|
35
|
+
/** Bigram sets per capability — post-stopword, post-stem, examples only */
|
|
36
|
+
bigrams: Record<string, Set<string>>;
|
|
37
|
+
}
|
|
38
|
+
/** Build a BM25 index over all capabilities. Call once at manifest load. */
|
|
39
|
+
export declare function buildBM25Index(capabilities: Capability[]): BM25Index;
|
|
40
|
+
/**
|
|
41
|
+
* BM25 scoring with field weights.
|
|
42
|
+
* k1 = 1.5 (TF saturation), b = 0.75 (length normalization)
|
|
43
|
+
* Field weights: examples 0.6, description 0.3, name 0.1
|
|
44
|
+
*/
|
|
45
|
+
export declare function scoreCapability(qWordSet: Set<string>, cap: Capability, index: BM25Index, k1?: number, b?: number): number;
|
|
46
|
+
/**
|
|
47
|
+
* Extracts bigrams from a token array as "token1__token2" strings.
|
|
48
|
+
* Input must already be post-stopword and post-stem (use tokenize() first).
|
|
49
|
+
*/
|
|
50
|
+
export declare function extractBigrams(tokens: string[]): Set<string>;
|
|
6
51
|
export declare function resolverToIntent(cap: Capability): MatchResult['intent'];
|
|
52
|
+
/**
|
|
53
|
+
* Strips characters that could break LLM prompt structure from
|
|
54
|
+
* capability field values before injection into the system prompt.
|
|
55
|
+
* Removes control characters, newlines, and delimiter-like sequences.
|
|
56
|
+
*/
|
|
57
|
+
export declare function sanitizeForPrompt(value: string, maxLen: number): string;
|
|
7
58
|
/**
|
|
8
59
|
* Extracts parameter values from a user query using keyword heuristics.
|
|
60
|
+
*
|
|
9
61
|
* Known limits:
|
|
10
62
|
* - Extracts single tokens only — "jane smith" would extract "jane"
|
|
11
63
|
* - Keyword matching is positional — "articles from authors I follow"
|
|
12
64
|
* may extract "authors" instead of nothing, since "from" is a keyword
|
|
13
|
-
* -
|
|
14
|
-
*
|
|
65
|
+
* - Required param fallback grabs the last meaningful word — "list all
|
|
66
|
+
* recent orders" may extract "orders" even with the denylist extended.
|
|
67
|
+
* For precise extraction of complex queries, use matchWithLLM() which
|
|
68
|
+
* handles param extraction via structured LLM prompt.
|
|
69
|
+
* - To support richer extraction patterns, add a `pattern` field to
|
|
70
|
+
* CapabilityParam in a future version.
|
|
15
71
|
*/
|
|
16
72
|
export declare function extractParams(query: string, cap: Capability): Record<string, string | null>;
|
|
17
73
|
export interface MatchOptions {
|
|
18
74
|
fuzzyMatch?: boolean;
|
|
19
75
|
fuzzyThreshold?: number;
|
|
76
|
+
bm25Index?: BM25Index;
|
|
77
|
+
bm25K1?: number;
|
|
78
|
+
bm25B?: number;
|
|
79
|
+
bm25Ceiling?: number;
|
|
20
80
|
}
|
|
21
81
|
export declare function match(query: string, manifest: Manifest, options?: MatchOptions): MatchResult;
|
|
22
82
|
export interface LLMMatcherOptions {
|
|
@@ -25,12 +85,12 @@ export interface LLMMatcherOptions {
|
|
|
25
85
|
/**
|
|
26
86
|
* Matches a query to a capability using an LLM.
|
|
27
87
|
*
|
|
28
|
-
* ⚠️ SECURITY NOTE: Capability
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
88
|
+
* ⚠️ SECURITY NOTE: Capability fields are sanitized before injection into
|
|
89
|
+
* the LLM prompt (newlines stripped, delimiters neutralized, length capped).
|
|
90
|
+
* However, the current interface passes a single prompt string — it cannot
|
|
91
|
+
* provide true system/user message separation that some LLM APIs support.
|
|
92
|
+
* For maximum injection resistance in high-security deployments, use an LLM
|
|
93
|
+
* wrapper that maps the prompt to a proper system message, keeping user query
|
|
94
|
+
* data in the user turn only.
|
|
35
95
|
*/
|
|
36
96
|
export declare function matchWithLLM(query: string, manifest: Manifest, options: LLMMatcherOptions): Promise<MatchResult>;
|
package/dist/esm/matcher.js
CHANGED
|
@@ -18,40 +18,226 @@ export const STOPWORDS = new Set([
|
|
|
18
18
|
'it', 'its', 'how', 'when', 'where', 'who', 'which', 'all',
|
|
19
19
|
'just', 'some', 'any', 'there', 'their', 'them', 'they',
|
|
20
20
|
]);
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
// ─── Type Patterns ────────────────────────────────────────────────────────────
|
|
22
|
+
/**
|
|
23
|
+
* Regex patterns for common param types.
|
|
24
|
+
* Used when a CapabilityParam has `pattern` set to a named type.
|
|
25
|
+
*/
|
|
26
|
+
export const TYPE_PATTERNS = {
|
|
27
|
+
email: /\b[\w.+-]+@[\w-]+\.[a-zA-Z]{2,}\b/,
|
|
28
|
+
date: /\b\d{4}-\d{2}-\d{2}\b|\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\w*\s+\d{1,2}\b/i,
|
|
29
|
+
orderId: /\b[A-Z]{2,}-?\d{4,}\b|\b\d{6,}\b/,
|
|
30
|
+
url: /https?:\/\/[^\s]+/,
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Extracts a value from a query using an example template pattern.
|
|
34
|
+
* e.g. template "order {orderId}", query "track order 12345" → "12345"
|
|
35
|
+
* e.g. template "booking {ref}", query "cancel booking ABC-001" → "ABC-001"
|
|
36
|
+
*/
|
|
37
|
+
function extractFromTemplate(query, template, paramName) {
|
|
38
|
+
// Split template on {paramName} to get prefix and suffix
|
|
39
|
+
const placeholder = `{${paramName}}`;
|
|
40
|
+
const idx = template.indexOf(placeholder);
|
|
41
|
+
if (idx === -1)
|
|
42
|
+
return null;
|
|
43
|
+
const prefix = template.slice(0, idx).trim().toLowerCase();
|
|
44
|
+
const suffix = template.slice(idx + placeholder.length).trim().toLowerCase();
|
|
25
45
|
const q = query.toLowerCase();
|
|
46
|
+
if (prefix) {
|
|
47
|
+
const prefixIdx = q.indexOf(prefix);
|
|
48
|
+
if (prefixIdx === -1)
|
|
49
|
+
return null;
|
|
50
|
+
const after = query.slice(prefixIdx + prefix.length).trim();
|
|
51
|
+
const tokens = after.split(/\s+/).filter(t => t.length > 0);
|
|
52
|
+
if (!tokens.length)
|
|
53
|
+
return null;
|
|
54
|
+
// If there's a suffix, find it and take what's between
|
|
55
|
+
if (suffix) {
|
|
56
|
+
const suffixIdx = after.toLowerCase().indexOf(suffix);
|
|
57
|
+
if (suffixIdx > 0) {
|
|
58
|
+
return after.slice(0, suffixIdx).trim().split(/\s+/)[0] ?? null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return tokens[0].replace(/[^a-zA-Z0-9\-_.@]/g, '') || null;
|
|
62
|
+
}
|
|
63
|
+
// Prefix is empty — placeholder is at start of template e.g. "{email} unsubscribe"
|
|
64
|
+
if (!prefix) {
|
|
65
|
+
if (suffix) {
|
|
66
|
+
// Find suffix in query — take what comes before it
|
|
67
|
+
const suffixIdx = query.toLowerCase().indexOf(suffix);
|
|
68
|
+
if (suffixIdx > 0) {
|
|
69
|
+
return query.slice(0, suffixIdx).trim().split(/\s+/).pop()
|
|
70
|
+
?.replace(/[^a-zA-Z0-9\-_.@]/g, '') || null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// No prefix, no suffix — template is just "{paramName}"; take last meaningful word
|
|
74
|
+
const words = query.trim().split(/\s+/);
|
|
75
|
+
return words[words.length - 1]?.replace(/[^a-zA-Z0-9\-_.@]/g, '') || null;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
// ─── Stem cache ───────────────────────────────────────────────────────────────
|
|
80
|
+
// Each word stemmed exactly once per process — O(1) on repeat lookups.
|
|
81
|
+
// Module-level — persists for the process lifetime. Vocabulary in production
|
|
82
|
+
// is finite (capability names + user query vocabulary) so growth is bounded
|
|
83
|
+
// in practice. In test environments with synthetic random strings, this may
|
|
84
|
+
// grow larger but remains functionally harmless.
|
|
85
|
+
const stemCache = new Map();
|
|
86
|
+
/**
|
|
87
|
+
* Simplified suffix-stripping stemmer — 10 most common English morphological
|
|
88
|
+
* patterns covering ~80% of benefit at ~25% the complexity of Porter stemmer.
|
|
89
|
+
* Applied symmetrically to both query words and capability index words.
|
|
90
|
+
*/
|
|
91
|
+
export function stem(word) {
|
|
92
|
+
const cached = stemCache.get(word);
|
|
93
|
+
if (cached !== undefined)
|
|
94
|
+
return cached;
|
|
95
|
+
let s = word;
|
|
96
|
+
if (s.length > 7 && s.endsWith('ation'))
|
|
97
|
+
s = s.slice(0, -5); // cancellation → cancell
|
|
98
|
+
else if (s.length > 6 && s.endsWith('tion'))
|
|
99
|
+
s = s.slice(0, -4); // completion → comple
|
|
100
|
+
else if (s.length > 6 && s.endsWith('ing'))
|
|
101
|
+
s = s.slice(0, -3); // tracking → track
|
|
102
|
+
else if (s.length > 6 && s.endsWith('ity'))
|
|
103
|
+
s = s.slice(0, -3); // availability → availabil
|
|
104
|
+
else if (s.length > 5 && s.endsWith('ion'))
|
|
105
|
+
s = s.slice(0, -3); // version → vers
|
|
106
|
+
else if (s.length > 6 && s.endsWith('est'))
|
|
107
|
+
s = s.slice(0, -3); // fastest → fast
|
|
108
|
+
else if (s.length > 4 && s.endsWith('er'))
|
|
109
|
+
s = s.slice(0, -2); // tracker → track
|
|
110
|
+
else if (s.length > 4 && s.endsWith('ed'))
|
|
111
|
+
s = s.slice(0, -2); // ordered → order
|
|
112
|
+
else if (s.length > 4 && s.endsWith('ly'))
|
|
113
|
+
s = s.slice(0, -2); // quickly → quick
|
|
114
|
+
else if (s.length > 4 && s.endsWith('es'))
|
|
115
|
+
s = s.slice(0, -2); // fetches → fetch
|
|
116
|
+
else if (s.length > 3 && s.endsWith('s') &&
|
|
117
|
+
!s.endsWith('ss'))
|
|
118
|
+
s = s.slice(0, -1); // orders → order
|
|
119
|
+
stemCache.set(word, s);
|
|
120
|
+
return s;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Shared tokenizer — used by scorer, learning index, and boost system.
|
|
124
|
+
* Applies stopword filtering AND stemming symmetrically.
|
|
125
|
+
* Any site that tokenizes text for matching MUST use this function
|
|
126
|
+
* to avoid silent mismatches between query and index tokens.
|
|
127
|
+
*/
|
|
128
|
+
export function tokenize(text) {
|
|
129
|
+
return text
|
|
130
|
+
.toLowerCase()
|
|
131
|
+
.split(/\W+/)
|
|
132
|
+
.filter(w => w.length > 2 && !STOPWORDS.has(w))
|
|
133
|
+
.map(stem);
|
|
134
|
+
}
|
|
135
|
+
/** Build a BM25 index over all capabilities. Call once at manifest load. */
|
|
136
|
+
export function buildBM25Index(capabilities) {
|
|
137
|
+
const N = capabilities.length;
|
|
138
|
+
if (N === 0)
|
|
139
|
+
return { df: {}, avgdl: { examples: 0, description: 0, name: 0 }, N: 0, bigrams: {}, };
|
|
140
|
+
const df = {};
|
|
141
|
+
let totalExLen = 0;
|
|
142
|
+
let totalDescLen = 0;
|
|
143
|
+
let totalNameLen = 0;
|
|
144
|
+
for (const cap of capabilities) {
|
|
145
|
+
const exTokens = tokenize((cap.examples ?? []).join(' '));
|
|
146
|
+
const descTokens = tokenize(cap.description);
|
|
147
|
+
const nameTokens = tokenize(cap.name);
|
|
148
|
+
totalExLen += exTokens.length;
|
|
149
|
+
totalDescLen += descTokens.length;
|
|
150
|
+
totalNameLen += nameTokens.length;
|
|
151
|
+
// Count document frequency — each term counted once per capability
|
|
152
|
+
const seen = new Set();
|
|
153
|
+
for (const t of [...exTokens, ...descTokens, ...nameTokens]) {
|
|
154
|
+
if (!seen.has(t)) {
|
|
155
|
+
df[t] = (df[t] ?? 0) + 1;
|
|
156
|
+
seen.add(t);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// Build bigram sets per capability — examples field only
|
|
161
|
+
// Clean bigrams only: post-stopword, post-stem tokens
|
|
162
|
+
const bigrams = {};
|
|
163
|
+
for (const cap of capabilities) {
|
|
164
|
+
const set = new Set();
|
|
165
|
+
for (const example of cap.examples ?? []) {
|
|
166
|
+
for (const bg of extractBigrams(tokenize(example)))
|
|
167
|
+
set.add(bg);
|
|
168
|
+
}
|
|
169
|
+
bigrams[cap.id] = set;
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
df,
|
|
173
|
+
avgdl: {
|
|
174
|
+
examples: totalExLen / N,
|
|
175
|
+
description: totalDescLen / N,
|
|
176
|
+
name: totalNameLen / N,
|
|
177
|
+
},
|
|
178
|
+
N,
|
|
179
|
+
bigrams,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* BM25 scoring with field weights.
|
|
184
|
+
* k1 = 1.5 (TF saturation), b = 0.75 (length normalization)
|
|
185
|
+
* Field weights: examples 0.6, description 0.3, name 0.1
|
|
186
|
+
*/
|
|
187
|
+
export function scoreCapability(qWordSet, cap, index, k1 = 1.5, b = 0.75) {
|
|
188
|
+
if (index.N === 0)
|
|
189
|
+
return 0;
|
|
190
|
+
const score = bm25Field(qWordSet, tokenize((cap.examples ?? []).join(' ')), index, 'examples', k1, b) * 0.6
|
|
191
|
+
+ bm25Field(qWordSet, tokenize(cap.description), index, 'description', k1, b) * 0.3
|
|
192
|
+
+ bm25Field(qWordSet, tokenize(cap.name), index, 'name', k1, b) * 0.1;
|
|
193
|
+
return score;
|
|
194
|
+
}
|
|
195
|
+
function bm25Field(queryTerms, fieldTokens, index, field, k1, b) {
|
|
196
|
+
if (fieldTokens.length === 0)
|
|
197
|
+
return 0;
|
|
198
|
+
const avgdl = index.avgdl[field] || 1;
|
|
199
|
+
const dl = fieldTokens.length;
|
|
200
|
+
const tf = new Map();
|
|
201
|
+
for (const t of fieldTokens) {
|
|
202
|
+
tf.set(t, (tf.get(t) ?? 0) + 1);
|
|
203
|
+
}
|
|
26
204
|
let score = 0;
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
// 10 examples at 50% overlap = 300 points (clamped to 60) beats 1 perfect example at 60.
|
|
31
|
-
// Taking Math.max means quality of examples matters, not quantity.
|
|
32
|
-
let bestExampleScore = 0;
|
|
33
|
-
for (const example of cap.examples ?? []) {
|
|
34
|
-
const exWords = filterStopwords(example.toLowerCase().split(/\s+/));
|
|
35
|
-
if (exWords.length === 0)
|
|
205
|
+
for (const term of queryTerms) {
|
|
206
|
+
const termTf = tf.get(term) ?? 0;
|
|
207
|
+
if (termTf === 0)
|
|
36
208
|
continue;
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
|
|
209
|
+
const df = index.df[term] ?? 0;
|
|
210
|
+
const idf = Math.log((index.N - df + 0.5) / (df + 0.5) + 1);
|
|
211
|
+
const tfNorm = (termTf * (k1 + 1)) / (termTf + k1 * (1 - b + b * (dl / avgdl)));
|
|
212
|
+
score += idf * tfNorm;
|
|
40
213
|
}
|
|
41
|
-
score
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
214
|
+
return score;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Extracts bigrams from a token array as "token1__token2" strings.
|
|
218
|
+
* Input must already be post-stopword and post-stem (use tokenize() first).
|
|
219
|
+
*/
|
|
220
|
+
export function extractBigrams(tokens) {
|
|
221
|
+
const bigrams = new Set();
|
|
222
|
+
for (let i = 0; i < tokens.length - 1; i++) {
|
|
223
|
+
bigrams.add(`${tokens[i]}__${tokens[i + 1]}`);
|
|
47
224
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
225
|
+
return bigrams;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Returns a fixed bonus in normalized points (0–15), applied after BM25 normalization.
|
|
229
|
+
* 5 points per matching bigram, saturates at 3 bigrams (15 points).
|
|
230
|
+
* Fixed point value regardless of manifest size — ceiling-independent.
|
|
231
|
+
*/
|
|
232
|
+
function bigramBonus(queryBigrams, capBigrams) {
|
|
233
|
+
if (queryBigrams.size === 0 || capBigrams.size === 0)
|
|
234
|
+
return 0;
|
|
235
|
+
let overlap = 0;
|
|
236
|
+
for (const bigram of queryBigrams) {
|
|
237
|
+
if (capBigrams.has(bigram))
|
|
238
|
+
overlap++;
|
|
53
239
|
}
|
|
54
|
-
return Math.min(
|
|
240
|
+
return Math.min(overlap * 5, 15); // normalized points — 3 bigrams saturate at 15
|
|
55
241
|
}
|
|
56
242
|
export function resolverToIntent(cap) {
|
|
57
243
|
const t = cap.resolver.type;
|
|
@@ -63,14 +249,33 @@ export function resolverToIntent(cap) {
|
|
|
63
249
|
return 'hybrid';
|
|
64
250
|
return 'out_of_scope';
|
|
65
251
|
}
|
|
252
|
+
/**
|
|
253
|
+
* Strips characters that could break LLM prompt structure from
|
|
254
|
+
* capability field values before injection into the system prompt.
|
|
255
|
+
* Removes control characters, newlines, and delimiter-like sequences.
|
|
256
|
+
*/
|
|
257
|
+
export function sanitizeForPrompt(value, maxLen) {
|
|
258
|
+
return value
|
|
259
|
+
.replace(/[\r\n\t]/g, ' ') // newlines → space
|
|
260
|
+
.replace(/---+/g, '—') // horizontal rules → em dash
|
|
261
|
+
.replace(/^\s*[{}\[\]]/gm, ' ') // leading braces/brackets → space
|
|
262
|
+
.replace(/\s+/g, ' ') // collapse whitespace
|
|
263
|
+
.trim()
|
|
264
|
+
.slice(0, maxLen);
|
|
265
|
+
}
|
|
66
266
|
/**
|
|
67
267
|
* Extracts parameter values from a user query using keyword heuristics.
|
|
268
|
+
*
|
|
68
269
|
* Known limits:
|
|
69
270
|
* - Extracts single tokens only — "jane smith" would extract "jane"
|
|
70
271
|
* - Keyword matching is positional — "articles from authors I follow"
|
|
71
272
|
* may extract "authors" instead of nothing, since "from" is a keyword
|
|
72
|
-
* -
|
|
73
|
-
*
|
|
273
|
+
* - Required param fallback grabs the last meaningful word — "list all
|
|
274
|
+
* recent orders" may extract "orders" even with the denylist extended.
|
|
275
|
+
* For precise extraction of complex queries, use matchWithLLM() which
|
|
276
|
+
* handles param extraction via structured LLM prompt.
|
|
277
|
+
* - To support richer extraction patterns, add a `pattern` field to
|
|
278
|
+
* CapabilityParam in a future version.
|
|
74
279
|
*/
|
|
75
280
|
export function extractParams(query, cap) {
|
|
76
281
|
const result = {};
|
|
@@ -85,6 +290,26 @@ export function extractParams(query, cap) {
|
|
|
85
290
|
result[param.name] = null;
|
|
86
291
|
continue;
|
|
87
292
|
}
|
|
293
|
+
// ── Pattern extraction (highest priority) ─────────────────────────────
|
|
294
|
+
if (param.pattern) {
|
|
295
|
+
const namedPattern = TYPE_PATTERNS[param.pattern];
|
|
296
|
+
if (namedPattern) {
|
|
297
|
+
// Named type pattern — match regex directly against full query
|
|
298
|
+
const match = query.match(namedPattern);
|
|
299
|
+
if (match) {
|
|
300
|
+
result[param.name] = match[0];
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
else if (param.pattern.includes(`{${param.name}}`)) {
|
|
305
|
+
// Example template — positional extraction
|
|
306
|
+
const extracted = extractFromTemplate(query, param.pattern, param.name);
|
|
307
|
+
if (extracted) {
|
|
308
|
+
result[param.name] = extracted;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
88
313
|
// Try to extract value after known keywords
|
|
89
314
|
// e.g. "profile for johndoe" → johndoe
|
|
90
315
|
// "articles by jane" → jane
|
|
@@ -205,9 +430,35 @@ export function match(query, manifest, options = {}) {
|
|
|
205
430
|
}
|
|
206
431
|
}
|
|
207
432
|
// ── Score all capabilities ────────────────────────────────────────────────
|
|
433
|
+
// Build qWordSet once — O(1) lookups instead of O(n) Array.includes per word
|
|
434
|
+
const qTokens = tokenize(query);
|
|
435
|
+
const qWordSet = new Set(qTokens);
|
|
436
|
+
// Build query bigrams for phrase bonus
|
|
437
|
+
const qBigrams = extractBigrams(qTokens);
|
|
438
|
+
// Build BM25 index for this manifest — O(capabilities × tokens)
|
|
439
|
+
// In CapmanEngine this is pre-built; for direct match() calls it's built per-call
|
|
440
|
+
const bm25Index = options.bm25Index ?? buildBM25Index(manifest.capabilities);
|
|
441
|
+
const k1 = options.bm25K1 ?? 1.5;
|
|
442
|
+
const b = options.bm25B ?? 0.75;
|
|
443
|
+
// Calibrate ceiling — max self-score for normalization
|
|
444
|
+
const ceiling = options.bm25Ceiling ?? (() => {
|
|
445
|
+
let max = 0;
|
|
446
|
+
for (const cap of manifest.capabilities) {
|
|
447
|
+
if (!cap.examples?.length)
|
|
448
|
+
continue;
|
|
449
|
+
const selfWords = new Set(tokenize(cap.examples[0]));
|
|
450
|
+
const raw = scoreCapability(selfWords, cap, bm25Index, k1, b);
|
|
451
|
+
if (raw > max)
|
|
452
|
+
max = raw;
|
|
453
|
+
}
|
|
454
|
+
return max > 0 ? max : 100;
|
|
455
|
+
})();
|
|
208
456
|
const allScores = [];
|
|
209
457
|
for (const cap of manifest.capabilities) {
|
|
210
|
-
const
|
|
458
|
+
const rawBM25 = scoreCapability(qWordSet, cap, bm25Index, k1, b);
|
|
459
|
+
const bm25Score = Math.min(100, Math.round((rawBM25 / ceiling) * 100));
|
|
460
|
+
const bonusPoints = bigramBonus(qBigrams, bm25Index.bigrams[cap.id] ?? new Set());
|
|
461
|
+
const keywordScore = Math.min(100, bm25Score + bonusPoints);
|
|
211
462
|
const fuzzyScore = fuzzyScoreMap.get(cap.id) ?? 0;
|
|
212
463
|
const via = fuzzyScore > keywordScore ? 'fuzzy' : 'keyword';
|
|
213
464
|
const score = Math.min(100, Math.round(Math.max(keywordScore, fuzzyScore)));
|
|
@@ -255,25 +506,28 @@ export function match(query, manifest, options = {}) {
|
|
|
255
506
|
/**
|
|
256
507
|
* Matches a query to a capability using an LLM.
|
|
257
508
|
*
|
|
258
|
-
* ⚠️ SECURITY NOTE: Capability
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
509
|
+
* ⚠️ SECURITY NOTE: Capability fields are sanitized before injection into
|
|
510
|
+
* the LLM prompt (newlines stripped, delimiters neutralized, length capped).
|
|
511
|
+
* However, the current interface passes a single prompt string — it cannot
|
|
512
|
+
* provide true system/user message separation that some LLM APIs support.
|
|
513
|
+
* For maximum injection resistance in high-security deployments, use an LLM
|
|
514
|
+
* wrapper that maps the prompt to a proper system message, keeping user query
|
|
515
|
+
* data in the user turn only.
|
|
265
516
|
*/
|
|
266
517
|
export async function matchWithLLM(query, manifest, options) {
|
|
267
518
|
// Truncate description and examples — prevents context window overflow and
|
|
268
519
|
// reduces prompt injection surface from third-party OpenAPI spec content.
|
|
269
520
|
const MAX_DESC_LEN = 200;
|
|
270
521
|
const MAX_EXAMPLE_LEN = 100;
|
|
271
|
-
const manifestSummary = manifest.capabilities.map(c => `- ${c.id} (${c.resolver.type}): ${c.description
|
|
272
|
-
? `\n examples: ${c.examples.slice(0, 2).map(e => e
|
|
522
|
+
const manifestSummary = manifest.capabilities.map(c => `- ${c.id} (${c.resolver.type}): ${sanitizeForPrompt(c.description, MAX_DESC_LEN)}${c.examples?.length
|
|
523
|
+
? `\n examples: ${c.examples.slice(0, 2).map(e => sanitizeForPrompt(e, MAX_EXAMPLE_LEN)).join(', ')}`
|
|
273
524
|
: ''}`).join('\n');
|
|
525
|
+
// Sanitize app name — strip newlines and control characters that could
|
|
526
|
+
// break the prompt structure or inject additional instructions.
|
|
527
|
+
const safeApp = sanitizeForPrompt(manifest.app, 100);
|
|
274
528
|
const prompt = `You are an intent matcher for an AI agent system.
|
|
275
529
|
|
|
276
|
-
App: ${
|
|
530
|
+
App: ${safeApp}
|
|
277
531
|
|
|
278
532
|
Available capabilities:
|
|
279
533
|
${manifestSummary}
|
|
@@ -331,7 +585,32 @@ ${JSON.stringify({ user_query: query })}
|
|
|
331
585
|
capability,
|
|
332
586
|
confidence: llmConfidence,
|
|
333
587
|
intent: effectivelyOOS ? 'out_of_scope' : parsed.intent,
|
|
334
|
-
extractedParams: (
|
|
588
|
+
extractedParams: (() => {
|
|
589
|
+
// Validate extracted params against declared capability params.
|
|
590
|
+
// Rejects nested objects ("[object Object]" in URLs), unknown keys,
|
|
591
|
+
// and non-scalar values. For OOS results (capability === null),
|
|
592
|
+
// drops all params — correct since there's no capability to match against.
|
|
593
|
+
const rawParams = (parsed.extracted_params ?? {});
|
|
594
|
+
const validParams = {};
|
|
595
|
+
for (const param of capability?.params ?? []) {
|
|
596
|
+
const val = rawParams[param.name];
|
|
597
|
+
if (val === null || val === undefined) {
|
|
598
|
+
validParams[param.name] = null;
|
|
599
|
+
}
|
|
600
|
+
else if (typeof val === 'string') {
|
|
601
|
+
validParams[param.name] = val;
|
|
602
|
+
}
|
|
603
|
+
else if (typeof val === 'number' || typeof val === 'boolean') {
|
|
604
|
+
validParams[param.name] = String(val);
|
|
605
|
+
}
|
|
606
|
+
else {
|
|
607
|
+
// Reject complex types (objects, arrays) — would produce "[object Object]" in URLs
|
|
608
|
+
logger.warn(`LLM returned non-scalar value for param "${param.name}" — dropping`);
|
|
609
|
+
validParams[param.name] = null;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return validParams;
|
|
613
|
+
})(),
|
|
335
614
|
reasoning: parsed.reasoning ?? 'No reasoning provided',
|
|
336
615
|
candidates: allCandidates,
|
|
337
616
|
};
|
package/dist/esm/parser.js
CHANGED
|
@@ -166,7 +166,7 @@ function extractParams(op) {
|
|
|
166
166
|
continue;
|
|
167
167
|
const source = p.in === 'path' ? 'user_query' :
|
|
168
168
|
p.in === 'query' ? 'user_query' :
|
|
169
|
-
'
|
|
169
|
+
'user_query'; // body/formData (Swagger 2.x) — treat as user_query
|
|
170
170
|
params.push({
|
|
171
171
|
name: toSnakeCase(p.name),
|
|
172
172
|
description: p.description ?? toHumanName(p.name),
|
|
@@ -201,13 +201,17 @@ function inferPrivacy(op, hasGlobalAuth, securitySchemes) {
|
|
|
201
201
|
// Explicitly no security on this operation
|
|
202
202
|
if (op.security !== undefined && op.security.length === 0)
|
|
203
203
|
return 'public';
|
|
204
|
-
// Check operation tags for admin hints
|
|
205
|
-
|
|
206
|
-
|
|
204
|
+
// Check operation tags for admin hints — word-boundary match only.
|
|
205
|
+
// Avoids false positives like 'manageWishlist', 'fileManager', 'managedService'
|
|
206
|
+
// being classified as admin when they are user-facing operations.
|
|
207
|
+
const ADMIN_PATTERN = /\b(admin|administrator|backoffice|back-office|internal|superuser)\b/i;
|
|
208
|
+
const tags = op.tags ?? [];
|
|
209
|
+
if (tags.some(t => ADMIN_PATTERN.test(t)))
|
|
207
210
|
return 'admin';
|
|
208
|
-
// Check operation ID / summary
|
|
211
|
+
// Check operation ID / summary — same word-boundary pattern.
|
|
212
|
+
// 'manage' alone is NOT an admin signal — too many user-facing ops use it.
|
|
209
213
|
const hint = `${op.operationId ?? ''} ${op.summary ?? ''}`.toLowerCase();
|
|
210
|
-
if (
|
|
214
|
+
if (ADMIN_PATTERN.test(hint)) {
|
|
211
215
|
return 'admin';
|
|
212
216
|
}
|
|
213
217
|
// If global auth exists or operation has security, it's user_owned
|
|
@@ -248,12 +252,15 @@ function extractBaseUrl(spec) {
|
|
|
248
252
|
if (spec.servers?.length) {
|
|
249
253
|
return spec.servers[0].url.replace(/\/$/, '');
|
|
250
254
|
}
|
|
251
|
-
// Swagger 2.x
|
|
255
|
+
// Swagger 2.x — respect declared schemes, prefer https over http
|
|
252
256
|
if (spec.host) {
|
|
253
|
-
const
|
|
257
|
+
const schemes = spec.schemes ?? ['https'];
|
|
258
|
+
const scheme = schemes.includes('https') ? 'https' : schemes[0] ?? 'https';
|
|
254
259
|
const base = spec.basePath ?? '';
|
|
255
260
|
return `${scheme}://${spec.host}${base}`.replace(/\/$/, '');
|
|
256
261
|
}
|
|
262
|
+
logger.warn(`No server URL found in spec — using placeholder "https://api.your-app.com". ` +
|
|
263
|
+
`Set baseUrl manually in the generated config before use.`);
|
|
257
264
|
return 'https://api.your-app.com';
|
|
258
265
|
}
|
|
259
266
|
function sanitizeAppName(title) {
|
package/dist/esm/resolver.d.ts
CHANGED
|
@@ -25,4 +25,5 @@ export interface ResolveOptions {
|
|
|
25
25
|
*/
|
|
26
26
|
retryAllMethods?: boolean;
|
|
27
27
|
}
|
|
28
|
+
export declare function checkPrivacy(capability: import('./types').Capability, auth?: AuthContext): string | null;
|
|
28
29
|
export declare function resolve(matchResult: MatchResult, params?: Record<string, unknown>, options?: ResolveOptions): Promise<ResolveResult>;
|
package/dist/esm/resolver.js
CHANGED
|
@@ -4,7 +4,7 @@ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
|
4
4
|
function redactParams(params) {
|
|
5
5
|
return Object.fromEntries(Object.entries(params).map(([k, v]) => [k, v != null ? '[REDACTED]' : 'null']));
|
|
6
6
|
}
|
|
7
|
-
function checkPrivacy(capability, auth) {
|
|
7
|
+
export function checkPrivacy(capability, auth) {
|
|
8
8
|
const level = capability.privacy.level;
|
|
9
9
|
if (level === 'public')
|
|
10
10
|
return null;
|
|
@@ -109,6 +109,12 @@ export async function resolve(matchResult, params = {}, options = {}) {
|
|
|
109
109
|
*
|
|
110
110
|
* For capabilities where ordering or rollback matters, define separate capabilities
|
|
111
111
|
* with single endpoints and orchestrate them at the application layer.
|
|
112
|
+
*
|
|
113
|
+
* Note: the current ResolveResult does not expose which endpoints succeeded and
|
|
114
|
+
* which failed in a partial failure scenario. If your use case requires this
|
|
115
|
+
* granularity, use separate single-endpoint capabilities and inspect each result.
|
|
116
|
+
* Full partial success reporting (partialSuccess, completedCalls, failedCalls)
|
|
117
|
+
* is planned for a future version.
|
|
112
118
|
*/
|
|
113
119
|
async function resolveApi(resolver, params, options, sessionParamNames = new Set()) {
|
|
114
120
|
const startTime = Date.now();
|
|
@@ -126,7 +132,7 @@ async function resolveApi(resolver, params, options, sessionParamNames = new Set
|
|
|
126
132
|
}
|
|
127
133
|
return {
|
|
128
134
|
method: endpoint.method,
|
|
129
|
-
url: buildUrl(options.baseUrl ?? '', endpoint.path, endpointParams),
|
|
135
|
+
url: buildUrl(options.baseUrl ?? '', endpoint.path, endpointParams, sessionParamNames),
|
|
130
136
|
params: Object.fromEntries(Object.entries(endpointParams).filter(([, v]) => v !== null && v !== undefined)),
|
|
131
137
|
};
|
|
132
138
|
});
|
|
@@ -210,9 +216,12 @@ async function resolveApi(resolver, params, options, sessionParamNames = new Set
|
|
|
210
216
|
}
|
|
211
217
|
}
|
|
212
218
|
function validateNavParam(key, value) {
|
|
213
|
-
|
|
219
|
+
// Allowlist aligned with validateApiPathParam — permits dots, colons, @ for
|
|
220
|
+
// deep links (myapp://path), domain-qualified values (auth.tokens), and
|
|
221
|
+
// versioned routes (v1:resource). Rejects path separators and shell metacharacters.
|
|
222
|
+
if (!/^[a-zA-Z0-9_\-.:@]+$/.test(value)) {
|
|
214
223
|
throw new Error(`Nav param "${key}" contains invalid characters: "${value}". ` +
|
|
215
|
-
`Only alphanumeric, hyphens, and
|
|
224
|
+
`Only alphanumeric, hyphens, underscores, dots, colons, and @ are allowed.`);
|
|
216
225
|
}
|
|
217
226
|
}
|
|
218
227
|
function resolveNav(resolver, params) {
|
|
@@ -237,7 +246,7 @@ function validateApiPathParam(key, value) {
|
|
|
237
246
|
}
|
|
238
247
|
// Both buildUrl (API) and resolveNav (nav) validate path param values against
|
|
239
248
|
// an allowlist before substitution — prevents path traversal via unencoded slashes.
|
|
240
|
-
function buildUrl(baseUrl, urlPath, params) {
|
|
249
|
+
function buildUrl(baseUrl, urlPath, params, blockedQsParams) {
|
|
241
250
|
let resolved = urlPath;
|
|
242
251
|
const unused = {};
|
|
243
252
|
for (const [key, value] of Object.entries(params)) {
|
|
@@ -254,7 +263,8 @@ function buildUrl(baseUrl, urlPath, params) {
|
|
|
254
263
|
}
|
|
255
264
|
const base = `${baseUrl.replace(/\/$/, '')}${resolved}`;
|
|
256
265
|
const qs = Object.entries(unused)
|
|
257
|
-
.filter(([, v]) => v !== null && v !== undefined
|
|
266
|
+
.filter(([k, v]) => v !== null && v !== undefined
|
|
267
|
+
&& (!blockedQsParams || !blockedQsParams.has(k)))
|
|
258
268
|
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
|
|
259
269
|
.join('&');
|
|
260
270
|
return qs ? `${base}?${qs}` : base;
|