@ganju/utils 0.0.12 → 0.0.14

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/dist/constants.js CHANGED
@@ -815,7 +815,7 @@ const RESOURCE_SEARCH_COMMON_TERM_MIN = 5;
815
815
  const RESOURCE_SEARCH_RERANK_CANDIDATES = 30;
816
816
  const RESOURCE_SEARCH_RERANK_MODEL = '@cf/baai/bge-reranker-base';
817
817
  const RESOURCE_HANDLER_SLEEP_AFTER = '10m';
818
- const DOCS_URL = 'https://docs.ganju.ai';
818
+ const DOCS_URL = 'https://ganju.ai/docs/';
819
819
  const BASE64_DATA_URI_RE = /data:image\/[a-zA-Z0-9+\-.]+;base64,[A-Za-z0-9+/=\s]+/g;
820
820
  const RAW_BASE64_BLOB_RE = /[A-Za-z0-9+/]{512,}={0,2}/g;
821
821
  const CHUNK_SEPARATORS = [
@@ -15,7 +15,7 @@ export type ToolLintRule = (typeof TOOL_LINT_RULES)[number];
15
15
  export interface ToolLintFinding {
16
16
  rule: ToolLintRule;
17
17
  tool?: string;
18
- other?: string;
18
+ alike?: string[];
19
19
  paths?: string[];
20
20
  count?: number;
21
21
  limit?: number;
@@ -1 +1 @@
1
- {"version":3,"file":"toolLint.d.ts","sourceRoot":"","sources":["../src/toolLint.ts"],"names":[],"mappings":"AAeA,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,WAAW,CAAC,EAAE;QACZ,YAAY,CAAC,EAAE,OAAO,CAAC;QACvB,eAAe,CAAC,EAAE,OAAO,CAAC;QAC1B,cAAc,CAAC,EAAE,OAAO,CAAC;QACzB,aAAa,CAAC,EAAE,OAAO,CAAC;KACzB,CAAC;CACH;AAED,eAAO,MAAM,eAAe,+JAQlB,CAAC;AAEX,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,eAAe,CAAC,CAAC,MAAM,CAAC,CAAC;AAE5D,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,YAAY,CAAC;IAGnB,IAAI,CAAC,EAAE,MAAM,CAAC;IAEd,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IAEjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IAGf,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,eAAe;IAI9B,MAAM,CAAC,EAAE,YAAY,EAAE,CAAC;IAGxB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAID,eAAO,MAAM,gCAAgC,KAAK,CAAC;AAKnD,eAAO,MAAM,mBAAmB,KAAK,CAAC;AAuHtC;;;GAGG;AACH,eAAO,MAAM,SAAS,GACpB,OAAO,YAAY,EAAE,EACrB,UAAS,eAAoB,KAC5B,eAAe,EA4EjB,CAAC"}
1
+ {"version":3,"file":"toolLint.d.ts","sourceRoot":"","sources":["../src/toolLint.ts"],"names":[],"mappings":"AAeA,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,WAAW,CAAC,EAAE;QACZ,YAAY,CAAC,EAAE,OAAO,CAAC;QACvB,eAAe,CAAC,EAAE,OAAO,CAAC;QAC1B,cAAc,CAAC,EAAE,OAAO,CAAC;QACzB,aAAa,CAAC,EAAE,OAAO,CAAC;KACzB,CAAC;CACH;AAED,eAAO,MAAM,eAAe,+JAQlB,CAAC;AAEX,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,eAAe,CAAC,CAAC,MAAM,CAAC,CAAC;AAE5D,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,YAAY,CAAC;IAGnB,IAAI,CAAC,EAAE,MAAM,CAAC;IAGd,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IAEjB,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IAEjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IAGf,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,eAAe;IAI9B,MAAM,CAAC,EAAE,YAAY,EAAE,CAAC;IAGxB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAID,eAAO,MAAM,gCAAgC,KAAK,CAAC;AAKnD,eAAO,MAAM,mBAAmB,KAAK,CAAC;AA2JtC;;;GAGG;AACH,eAAO,MAAM,SAAS,GACpB,OAAO,YAAY,EAAE,EACrB,UAAS,eAAoB,KAC5B,eAAe,EA8FjB,CAAC"}
package/dist/toolLint.js CHANGED
@@ -29,18 +29,26 @@ exports.TOOL_LINT_MIN_DESCRIPTION_LENGTH = 40;
29
29
  // start choosing among them badly. A channel turn hard-caps its list at 40
30
30
  // (CHANNEL_MAX_TOOLS); this warns well before that.
31
31
  exports.TOOL_LINT_MAX_TOOLS = 25;
32
- // Two descriptions sharing at least this much of their vocabulary (Jaccard,
33
- // over content words) read as the same tool to a model.
32
+ // Two descriptions sharing at least this much of their vocabulary read as the
33
+ // same tool to a model. Words are weighted by how rare they are among the
34
+ // server's tools (see descriptionWeights), so tools written from one template
35
+ // — "Use when someone asks about X" — are told apart by their X.
34
36
  const DESCRIPTION_OVERLAP = 0.6;
35
37
  // Too few content words and two unrelated descriptions can share most of them.
36
38
  const MIN_OVERLAP_WORDS = 5;
37
39
  // Lowercased, accents off, so "Úsala cuando" and "usala cuando" are one text.
38
- const fold = (text) => text.toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '');
40
+ const fold = (text) => text
41
+ .toLowerCase()
42
+ .normalize('NFKD')
43
+ .replace(/[\u0300-\u036f]/g, '');
39
44
  // Wording that tells a model WHEN to call a tool rather than only what it does
40
45
  // — English, Spanish and Portuguese, the languages descriptions here are
41
46
  // written in. Loose on purpose: a false "says when" costs nothing, a false
42
- // "doesn't" nags someone whose description is fine.
43
- const USAGE_GUIDANCE = /\b(use|uses|using|call|calls|when|whenever|before|after|instead|only if|if the user|if you|for questions|usa|usala|usalo|usar|llama|llamala|llamar|cuando|antes de|despues de|en lugar de|si el usuario|quando|use-a|utilize|chame)\b/;
47
+ // "doesn't" nags someone whose description is fine. But not so loose that
48
+ // words about how a tool works count: "uses the Stripe API" and "makes an API
49
+ // call" say nothing about when to reach for it, so "call" counts only as an
50
+ // instruction ("call this…", "call it…").
51
+ const USAGE_GUIDANCE = /\b(use|call (?:this|it)|when|whenever|before|after|instead|only if|if the user|if you|for questions|usa|usala|usalo|usar|llama|llamala|llamar|cuando|antes de|despues de|en lugar de|si el usuario|quando|use-a|utilize|chame)\b/;
44
52
  const STOPWORDS = new Set([
45
53
  'the',
46
54
  'and',
@@ -82,31 +90,51 @@ const STOPWORDS = new Set([
82
90
  const contentWords = (text) => new Set(fold(text)
83
91
  .split(/[^a-z0-9]+/)
84
92
  .filter(word => word.length >= 3 && !STOPWORDS.has(word)));
93
+ // A proxied tool is named `<server prefix>__<remote name>`. The prefix says
94
+ // which connection it came from, not what it does, so `search_repositories`
95
+ // and `github__search_repositories` are the same tool to a model. Kept here
96
+ // rather than imported from constants (MCP_PROXY_TOOL_NAME_SEP), since this
97
+ // file imports nothing.
98
+ const PROXY_NAME_SEP = '__';
99
+ const withoutProxyPrefix = (name) => {
100
+ const at = name.lastIndexOf(PROXY_NAME_SEP);
101
+ const bare = at === -1 ? name : name.slice(at + PROXY_NAME_SEP.length);
102
+ return bare || name;
103
+ };
85
104
  // `lookupOrder`, `lookup-order` and `order_lookup` name the same thing.
86
- const nameTokens = (name) => name
105
+ const nameTokens = (name) => withoutProxyPrefix(name)
87
106
  .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
88
107
  .toLowerCase()
89
108
  .split(/[^a-z0-9]+/)
90
109
  .filter(Boolean)
91
110
  .sort()
92
111
  .join(' ');
93
- const jaccard = (a, b) => {
112
+ // How much each word says about which tool a description belongs to: a word
113
+ // in every description (the template, the domain) next to nothing, a word in
114
+ // one of them a lot. Smoothed, so with two tools a shared word still counts.
115
+ const descriptionWeights = (descriptions) => {
116
+ const frequency = new Map();
117
+ for (const words of descriptions) {
118
+ for (const word of words) {
119
+ frequency.set(word, (frequency.get(word) ?? 0) + 1);
120
+ }
121
+ }
122
+ const total = descriptions.length;
123
+ return word => Math.log(1 + total / (frequency.get(word) ?? 1));
124
+ };
125
+ // Jaccard, with each word counted by its weight.
126
+ const weightedJaccard = (a, b, weight) => {
94
127
  let shared = 0;
95
- for (const word of a)
128
+ let union = 0;
129
+ for (const word of a) {
130
+ union += weight(word);
96
131
  if (b.has(word))
97
- shared++;
98
- const union = a.size + b.size - shared;
99
- return union === 0 ? 0 : shared / union;
100
- };
101
- const overlaps = (a, b) => {
102
- if (nameTokens(a.name) === nameTokens(b.name))
103
- return true;
104
- const aWords = contentWords(a.description ?? '');
105
- const bWords = contentWords(b.description ?? '');
106
- if (aWords.size < MIN_OVERLAP_WORDS || bWords.size < MIN_OVERLAP_WORDS) {
107
- return false;
132
+ shared += weight(word);
108
133
  }
109
- return jaccard(aWords, bWords) >= DESCRIPTION_OVERLAP;
134
+ for (const word of b)
135
+ if (!a.has(word))
136
+ union += weight(word);
137
+ return union === 0 ? 0 : shared / union;
110
138
  };
111
139
  const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
112
140
  // Every property the model may fill in that doesn't say what it is for, down
@@ -140,6 +168,19 @@ const quoted = (name) => `"${name}"`;
140
168
  const lintTools = (tools, options = {}) => {
141
169
  const others = options.others ?? [];
142
170
  const findings = [];
171
+ const everyTool = [...tools, ...others];
172
+ const wordsOf = new Map(everyTool.map(tool => [tool, contentWords(tool.description ?? '')]));
173
+ const weight = descriptionWeights([...wordsOf.values()]);
174
+ const overlaps = (a, b) => {
175
+ if (nameTokens(a.name) === nameTokens(b.name))
176
+ return true;
177
+ const aWords = wordsOf.get(a);
178
+ const bWords = wordsOf.get(b);
179
+ if (aWords.size < MIN_OVERLAP_WORDS || bWords.size < MIN_OVERLAP_WORDS) {
180
+ return false;
181
+ }
182
+ return weightedJaccard(aWords, bWords, weight) >= DESCRIPTION_OVERLAP;
183
+ };
143
184
  tools.forEach((tool, index) => {
144
185
  const description = (tool.description ?? '').trim();
145
186
  if (!description) {
@@ -184,16 +225,18 @@ const lintTools = (tools, options = {}) => {
184
225
  message: `${quoted(tool.name)} has input properties with no description: ${paths.join(', ')}. A model has to guess what to pass.`
185
226
  });
186
227
  }
187
- // Each pair once: against the tools after this one, then the rest of the
188
- // server.
189
- for (const other of [...tools.slice(index + 1), ...others]) {
190
- if (other.name === tool.name || !overlaps(tool, other))
191
- continue;
228
+ // Each pair once — against the tools after this one, then the rest of the
229
+ // server — and one finding per tool, so twenty look-alikes are nineteen
230
+ // lines rather than a hundred and ninety.
231
+ const alike = [...tools.slice(index + 1), ...others]
232
+ .filter(other => other.name !== tool.name && overlaps(tool, other))
233
+ .map(other => other.name);
234
+ if (alike.length > 0) {
192
235
  findings.push({
193
236
  rule: 'overlapping-tools',
194
237
  tool: tool.name,
195
- other: other.name,
196
- message: `${quoted(tool.name)} and ${quoted(other.name)} look alike in name or description, so a model may call one for the other. Make each description say what sets it apart.`
238
+ alike,
239
+ message: `${quoted(tool.name)} looks like ${alike.map(quoted).join(', ')} in name or description, so a model may call one for another. Make each description say what sets it apart.`
197
240
  });
198
241
  }
199
242
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ganju/utils",
3
- "version": "0.0.12",
3
+ "version": "0.0.14",
4
4
  "description": "Shared constants, schemas and helpers for Ganju",
5
5
  "license": "Apache-2.0",
6
6
  "author": "MontoyaAndres <andresmontoyafcb@gmail.com>",
package/src/constants.ts CHANGED
@@ -967,7 +967,7 @@ const RESOURCE_SEARCH_RERANK_MODEL = '@cf/baai/bge-reranker-base';
967
967
 
968
968
  const RESOURCE_HANDLER_SLEEP_AFTER = '10m';
969
969
 
970
- const DOCS_URL = 'https://docs.ganju.ai';
970
+ const DOCS_URL = 'https://ganju.ai/docs/';
971
971
 
972
972
  const BASE64_DATA_URI_RE =
973
973
  /data:image\/[a-zA-Z0-9+\-.]+;base64,[A-Za-z0-9+/=\s]+/g;
package/src/toolLint.ts CHANGED
@@ -43,8 +43,9 @@ export interface ToolLintFinding {
43
43
  // The tool the finding is about. Absent only for `too-many-tools`, which is
44
44
  // about the server as a whole.
45
45
  tool?: string;
46
- // `overlapping-tools`: the tool it is confused with.
47
- other?: string;
46
+ // `overlapping-tools`: the tools it may be confused with. Each look-alike
47
+ // pair is reported once, on the first of the two.
48
+ alike?: string[];
48
49
  // `undescribed-input`: the properties with no description, as dotted paths.
49
50
  paths?: string[];
50
51
  // `too-many-tools`: how many are enabled, and the number past which it warns.
@@ -74,22 +75,30 @@ export const TOOL_LINT_MIN_DESCRIPTION_LENGTH = 40;
74
75
  // (CHANNEL_MAX_TOOLS); this warns well before that.
75
76
  export const TOOL_LINT_MAX_TOOLS = 25;
76
77
 
77
- // Two descriptions sharing at least this much of their vocabulary (Jaccard,
78
- // over content words) read as the same tool to a model.
78
+ // Two descriptions sharing at least this much of their vocabulary read as the
79
+ // same tool to a model. Words are weighted by how rare they are among the
80
+ // server's tools (see descriptionWeights), so tools written from one template
81
+ // — "Use when someone asks about X" — are told apart by their X.
79
82
  const DESCRIPTION_OVERLAP = 0.6;
80
83
  // Too few content words and two unrelated descriptions can share most of them.
81
84
  const MIN_OVERLAP_WORDS = 5;
82
85
 
83
86
  // Lowercased, accents off, so "Úsala cuando" and "usala cuando" are one text.
84
87
  const fold = (text: string): string =>
85
- text.toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '');
88
+ text
89
+ .toLowerCase()
90
+ .normalize('NFKD')
91
+ .replace(/[\u0300-\u036f]/g, '');
86
92
 
87
93
  // Wording that tells a model WHEN to call a tool rather than only what it does
88
94
  // — English, Spanish and Portuguese, the languages descriptions here are
89
95
  // written in. Loose on purpose: a false "says when" costs nothing, a false
90
- // "doesn't" nags someone whose description is fine.
96
+ // "doesn't" nags someone whose description is fine. But not so loose that
97
+ // words about how a tool works count: "uses the Stripe API" and "makes an API
98
+ // call" say nothing about when to reach for it, so "call" counts only as an
99
+ // instruction ("call this…", "call it…").
91
100
  const USAGE_GUIDANCE =
92
- /\b(use|uses|using|call|calls|when|whenever|before|after|instead|only if|if the user|if you|for questions|usa|usala|usalo|usar|llama|llamala|llamar|cuando|antes de|despues de|en lugar de|si el usuario|quando|use-a|utilize|chame)\b/;
101
+ /\b(use|call (?:this|it)|when|whenever|before|after|instead|only if|if the user|if you|for questions|usa|usala|usalo|usar|llama|llamala|llamar|cuando|antes de|despues de|en lugar de|si el usuario|quando|use-a|utilize|chame)\b/;
93
102
 
94
103
  const STOPWORDS = new Set([
95
104
  'the',
@@ -137,9 +146,22 @@ const contentWords = (text: string): Set<string> =>
137
146
  .filter(word => word.length >= 3 && !STOPWORDS.has(word))
138
147
  );
139
148
 
149
+ // A proxied tool is named `<server prefix>__<remote name>`. The prefix says
150
+ // which connection it came from, not what it does, so `search_repositories`
151
+ // and `github__search_repositories` are the same tool to a model. Kept here
152
+ // rather than imported from constants (MCP_PROXY_TOOL_NAME_SEP), since this
153
+ // file imports nothing.
154
+ const PROXY_NAME_SEP = '__';
155
+
156
+ const withoutProxyPrefix = (name: string): string => {
157
+ const at = name.lastIndexOf(PROXY_NAME_SEP);
158
+ const bare = at === -1 ? name : name.slice(at + PROXY_NAME_SEP.length);
159
+ return bare || name;
160
+ };
161
+
140
162
  // `lookupOrder`, `lookup-order` and `order_lookup` name the same thing.
141
163
  const nameTokens = (name: string): string =>
142
- name
164
+ withoutProxyPrefix(name)
143
165
  .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
144
166
  .toLowerCase()
145
167
  .split(/[^a-z0-9]+/)
@@ -147,21 +169,36 @@ const nameTokens = (name: string): string =>
147
169
  .sort()
148
170
  .join(' ');
149
171
 
150
- const jaccard = (a: Set<string>, b: Set<string>): number => {
151
- let shared = 0;
152
- for (const word of a) if (b.has(word)) shared++;
153
- const union = a.size + b.size - shared;
154
- return union === 0 ? 0 : shared / union;
172
+ // How much each word says about which tool a description belongs to: a word
173
+ // in every description (the template, the domain) next to nothing, a word in
174
+ // one of them a lot. Smoothed, so with two tools a shared word still counts.
175
+ const descriptionWeights = (
176
+ descriptions: Set<string>[]
177
+ ): ((word: string) => number) => {
178
+ const frequency = new Map<string, number>();
179
+ for (const words of descriptions) {
180
+ for (const word of words) {
181
+ frequency.set(word, (frequency.get(word) ?? 0) + 1);
182
+ }
183
+ }
184
+ const total = descriptions.length;
185
+ return word => Math.log(1 + total / (frequency.get(word) ?? 1));
155
186
  };
156
187
 
157
- const overlaps = (a: LintableTool, b: LintableTool): boolean => {
158
- if (nameTokens(a.name) === nameTokens(b.name)) return true;
159
- const aWords = contentWords(a.description ?? '');
160
- const bWords = contentWords(b.description ?? '');
161
- if (aWords.size < MIN_OVERLAP_WORDS || bWords.size < MIN_OVERLAP_WORDS) {
162
- return false;
188
+ // Jaccard, with each word counted by its weight.
189
+ const weightedJaccard = (
190
+ a: Set<string>,
191
+ b: Set<string>,
192
+ weight: (word: string) => number
193
+ ): number => {
194
+ let shared = 0;
195
+ let union = 0;
196
+ for (const word of a) {
197
+ union += weight(word);
198
+ if (b.has(word)) shared += weight(word);
163
199
  }
164
- return jaccard(aWords, bWords) >= DESCRIPTION_OVERLAP;
200
+ for (const word of b) if (!a.has(word)) union += weight(word);
201
+ return union === 0 ? 0 : shared / union;
165
202
  };
166
203
 
167
204
  const isObject = (value: unknown): value is Record<string, unknown> =>
@@ -202,6 +239,21 @@ export const lintTools = (
202
239
  const others = options.others ?? [];
203
240
  const findings: ToolLintFinding[] = [];
204
241
 
242
+ const everyTool = [...tools, ...others];
243
+ const wordsOf = new Map(
244
+ everyTool.map(tool => [tool, contentWords(tool.description ?? '')])
245
+ );
246
+ const weight = descriptionWeights([...wordsOf.values()]);
247
+ const overlaps = (a: LintableTool, b: LintableTool): boolean => {
248
+ if (nameTokens(a.name) === nameTokens(b.name)) return true;
249
+ const aWords = wordsOf.get(a)!;
250
+ const bWords = wordsOf.get(b)!;
251
+ if (aWords.size < MIN_OVERLAP_WORDS || bWords.size < MIN_OVERLAP_WORDS) {
252
+ return false;
253
+ }
254
+ return weightedJaccard(aWords, bWords, weight) >= DESCRIPTION_OVERLAP;
255
+ };
256
+
205
257
  tools.forEach((tool, index) => {
206
258
  const description = (tool.description ?? '').trim();
207
259
 
@@ -250,15 +302,18 @@ export const lintTools = (
250
302
  });
251
303
  }
252
304
 
253
- // Each pair once: against the tools after this one, then the rest of the
254
- // server.
255
- for (const other of [...tools.slice(index + 1), ...others]) {
256
- if (other.name === tool.name || !overlaps(tool, other)) continue;
305
+ // Each pair once — against the tools after this one, then the rest of the
306
+ // server — and one finding per tool, so twenty look-alikes are nineteen
307
+ // lines rather than a hundred and ninety.
308
+ const alike = [...tools.slice(index + 1), ...others]
309
+ .filter(other => other.name !== tool.name && overlaps(tool, other))
310
+ .map(other => other.name);
311
+ if (alike.length > 0) {
257
312
  findings.push({
258
313
  rule: 'overlapping-tools',
259
314
  tool: tool.name,
260
- other: other.name,
261
- message: `${quoted(tool.name)} and ${quoted(other.name)} look alike in name or description, so a model may call one for the other. Make each description say what sets it apart.`
315
+ alike,
316
+ message: `${quoted(tool.name)} looks like ${alike.map(quoted).join(', ')} in name or description, so a model may call one for another. Make each description say what sets it apart.`
262
317
  });
263
318
  }
264
319
  });