@ganju/utils 0.0.10 → 0.0.12
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.d.ts +12 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +75 -0
- package/dist/index.d.ts +153 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -0
- package/dist/schema.d.ts +67 -2
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +30 -4
- package/dist/syncableResource.d.ts +5 -0
- package/dist/syncableResource.d.ts.map +1 -1
- package/dist/syncableResource.js +13 -1
- package/dist/toolCatalog.d.ts +69 -0
- package/dist/toolCatalog.d.ts.map +1 -1
- package/dist/toolCatalog.js +150 -62
- package/dist/toolConfirmation.d.ts +58 -0
- package/dist/toolConfirmation.d.ts.map +1 -0
- package/dist/toolConfirmation.js +239 -0
- package/dist/toolLint.d.ts +35 -0
- package/dist/toolLint.d.ts.map +1 -0
- package/dist/toolLint.js +211 -0
- package/package.json +6 -1
- package/src/constants.ts +81 -0
- package/src/index.ts +58 -1
- package/src/schema.ts +41 -6
- package/src/syncableResource.ts +20 -0
- package/src/toolCatalog.ts +169 -62
- package/src/toolConfirmation.ts +291 -0
- package/src/toolLint.ts +277 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// When an organization turns on "confirm sensitive actions", a channel bot that
|
|
3
|
+
// wants to run a sensitive tool asks first, and the call runs only if the
|
|
4
|
+
// participant's next message is a plain yes. These are the pieces of that rule
|
|
5
|
+
// that aren't the runner's own bookkeeping.
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.stableJson = exports.isSensitiveTool = exports.formatConfirmationSummary = exports.isConfirmationReply = exports.parseConfirmationButton = exports.confirmationButtonId = exports.confirmationButtonLabels = exports.languageFromPhoneNumber = exports.annotationsForEffect = exports.TOOL_EFFECTS = void 0;
|
|
8
|
+
// What an owner says a tool they wrote does, in the words the dashboard asks
|
|
9
|
+
// it in, and the MCP annotations each answer stands for: `read` only looks
|
|
10
|
+
// things up, `write` changes things that can be undone, `sensitive` sends,
|
|
11
|
+
// deletes, charges or can't be undone — the kind a confirming channel asks
|
|
12
|
+
// about.
|
|
13
|
+
exports.TOOL_EFFECTS = ['read', 'write', 'sensitive'];
|
|
14
|
+
const annotationsForEffect = (effect) => effect === 'read'
|
|
15
|
+
? { readOnlyHint: true, destructiveHint: false }
|
|
16
|
+
: effect === 'write'
|
|
17
|
+
? { readOnlyHint: false, destructiveHint: false }
|
|
18
|
+
: { readOnlyHint: false, destructiveHint: true };
|
|
19
|
+
exports.annotationsForEffect = annotationsForEffect;
|
|
20
|
+
// Whole replies that mean yes, lowercased with accents and punctuation
|
|
21
|
+
// removed. English, Spanish and Portuguese, the languages bots here answer in.
|
|
22
|
+
const AFFIRMATIVE = new Set([
|
|
23
|
+
'y',
|
|
24
|
+
'yes',
|
|
25
|
+
'yeah',
|
|
26
|
+
'yep',
|
|
27
|
+
'yup',
|
|
28
|
+
'sure',
|
|
29
|
+
'ok',
|
|
30
|
+
'okay',
|
|
31
|
+
'confirm',
|
|
32
|
+
'confirmed',
|
|
33
|
+
'approve',
|
|
34
|
+
'approved',
|
|
35
|
+
'go',
|
|
36
|
+
'go ahead',
|
|
37
|
+
'do it',
|
|
38
|
+
'send',
|
|
39
|
+
'send it',
|
|
40
|
+
'proceed',
|
|
41
|
+
'yes please',
|
|
42
|
+
'yes send it',
|
|
43
|
+
'yes do it',
|
|
44
|
+
'yes go ahead',
|
|
45
|
+
'yes confirm',
|
|
46
|
+
'sure thing',
|
|
47
|
+
'go for it',
|
|
48
|
+
'sounds good',
|
|
49
|
+
'looks good',
|
|
50
|
+
'all good',
|
|
51
|
+
'correct',
|
|
52
|
+
'si hazlo',
|
|
53
|
+
'si adelante',
|
|
54
|
+
'si envialo ya',
|
|
55
|
+
'perfecto',
|
|
56
|
+
'correcto',
|
|
57
|
+
'esta bien',
|
|
58
|
+
'ok dale',
|
|
59
|
+
'ok envialo',
|
|
60
|
+
'si',
|
|
61
|
+
'sip',
|
|
62
|
+
'claro',
|
|
63
|
+
'dale',
|
|
64
|
+
'va',
|
|
65
|
+
'vale',
|
|
66
|
+
'listo',
|
|
67
|
+
'confirmo',
|
|
68
|
+
'confirmado',
|
|
69
|
+
'confirmar',
|
|
70
|
+
'adelante',
|
|
71
|
+
'hazlo',
|
|
72
|
+
'envialo',
|
|
73
|
+
'enviar',
|
|
74
|
+
'de acuerdo',
|
|
75
|
+
'si por favor',
|
|
76
|
+
'si claro',
|
|
77
|
+
'si dale',
|
|
78
|
+
'si envialo',
|
|
79
|
+
'si confirmo',
|
|
80
|
+
'sim',
|
|
81
|
+
'pode',
|
|
82
|
+
'pode enviar'
|
|
83
|
+
]);
|
|
84
|
+
const AFFIRMATIVE_EMOJI = new Set(['👍', '✅', '👌', '🆗']);
|
|
85
|
+
// The Yes/No buttons offered under a confirmation question, in the
|
|
86
|
+
// participant's language when the platform reports it. The labels are also
|
|
87
|
+
// what a tap sends where a button is just a canned reply (Telegram's reply
|
|
88
|
+
// keyboard), so "yes" must read as a yes to isConfirmationReply — and it does,
|
|
89
|
+
// in every language here.
|
|
90
|
+
const BUTTON_LABELS = {
|
|
91
|
+
en: { yes: '✅ Yes', no: '❌ No' },
|
|
92
|
+
es: { yes: '✅ Sí', no: '❌ No' },
|
|
93
|
+
pt: { yes: '✅ Sim', no: '❌ Não' }
|
|
94
|
+
};
|
|
95
|
+
// WhatsApp reports no locale, so a button's language comes from the calling
|
|
96
|
+
// code of the participant's number: the Spanish- and Portuguese-speaking
|
|
97
|
+
// countries, English otherwise. Longest prefix wins.
|
|
98
|
+
const CALLING_CODE_LANGUAGE = {
|
|
99
|
+
'34': 'es',
|
|
100
|
+
'51': 'es',
|
|
101
|
+
'52': 'es',
|
|
102
|
+
'53': 'es',
|
|
103
|
+
'54': 'es',
|
|
104
|
+
'56': 'es',
|
|
105
|
+
'57': 'es',
|
|
106
|
+
'58': 'es',
|
|
107
|
+
'240': 'es',
|
|
108
|
+
'502': 'es',
|
|
109
|
+
'503': 'es',
|
|
110
|
+
'504': 'es',
|
|
111
|
+
'505': 'es',
|
|
112
|
+
'506': 'es',
|
|
113
|
+
'507': 'es',
|
|
114
|
+
'591': 'es',
|
|
115
|
+
'593': 'es',
|
|
116
|
+
'595': 'es',
|
|
117
|
+
'598': 'es',
|
|
118
|
+
'1787': 'es',
|
|
119
|
+
'1809': 'es',
|
|
120
|
+
'1829': 'es',
|
|
121
|
+
'1849': 'es',
|
|
122
|
+
'55': 'pt',
|
|
123
|
+
'238': 'pt',
|
|
124
|
+
'244': 'pt',
|
|
125
|
+
'245': 'pt',
|
|
126
|
+
'258': 'pt',
|
|
127
|
+
'351': 'pt'
|
|
128
|
+
};
|
|
129
|
+
const languageFromPhoneNumber = (phone) => {
|
|
130
|
+
const digits = phone.replace(/\D/g, '');
|
|
131
|
+
for (let length = 4; length >= 2; length--) {
|
|
132
|
+
const language = CALLING_CODE_LANGUAGE[digits.slice(0, length)];
|
|
133
|
+
if (language)
|
|
134
|
+
return language;
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
};
|
|
138
|
+
exports.languageFromPhoneNumber = languageFromPhoneNumber;
|
|
139
|
+
const confirmationButtonLabels = (locale) => {
|
|
140
|
+
const language = (locale || '').toLowerCase().split(/[-_]/)[0];
|
|
141
|
+
return BUTTON_LABELS[language] ?? BUTTON_LABELS.en;
|
|
142
|
+
};
|
|
143
|
+
exports.confirmationButtonLabels = confirmationButtonLabels;
|
|
144
|
+
// Where the platform sends back a button's id rather than its label (Discord,
|
|
145
|
+
// WhatsApp), the id says which answer it is and who was asked: in a shared
|
|
146
|
+
// channel everyone sees the buttons, and only that participant's tap counts.
|
|
147
|
+
// The runner never sees the id — a tap reaches it as the text "yes" or "no".
|
|
148
|
+
const CONFIRMATION_BUTTON_PREFIX = 'ganju-confirm';
|
|
149
|
+
const confirmationButtonId = (answer, externalParticipantId) => `${CONFIRMATION_BUTTON_PREFIX}:${answer}:${externalParticipantId}`;
|
|
150
|
+
exports.confirmationButtonId = confirmationButtonId;
|
|
151
|
+
const parseConfirmationButton = (id) => {
|
|
152
|
+
const [prefix, answer, ...rest] = id.split(':');
|
|
153
|
+
const externalParticipantId = rest.join(':');
|
|
154
|
+
if (prefix !== CONFIRMATION_BUTTON_PREFIX || !externalParticipantId) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
if (answer !== 'yes' && answer !== 'no')
|
|
158
|
+
return null;
|
|
159
|
+
return { text: answer, externalParticipantId };
|
|
160
|
+
};
|
|
161
|
+
exports.parseConfirmationButton = parseConfirmationButton;
|
|
162
|
+
const normalize = (text) => text
|
|
163
|
+
.toLowerCase()
|
|
164
|
+
.normalize('NFKD')
|
|
165
|
+
.replace(/[̀-ͯ]/g, '')
|
|
166
|
+
.replace(/[^a-z0-9\s]+/g, ' ')
|
|
167
|
+
.replace(/\s+/g, ' ')
|
|
168
|
+
.trim();
|
|
169
|
+
/**
|
|
170
|
+
* Whether a reply confirms the pending action. Deliberately narrow: the whole
|
|
171
|
+
* message has to be a yes. "Yes, but send it to Ana instead" is not one — it
|
|
172
|
+
* asks for a different action, and running the original would do exactly
|
|
173
|
+
* what the person just said not to. Anything that isn't a clear yes cancels,
|
|
174
|
+
* so the worst a misread costs is asking again.
|
|
175
|
+
*/
|
|
176
|
+
const isConfirmationReply = (text) => {
|
|
177
|
+
const trimmed = text.trim();
|
|
178
|
+
if (AFFIRMATIVE_EMOJI.has(trimmed))
|
|
179
|
+
return true;
|
|
180
|
+
return AFFIRMATIVE.has(normalize(trimmed));
|
|
181
|
+
};
|
|
182
|
+
exports.isConfirmationReply = isConfirmationReply;
|
|
183
|
+
// How much of each argument the summary shows, and how many arguments.
|
|
184
|
+
const SUMMARY_VALUE_MAX = 200;
|
|
185
|
+
const SUMMARY_ARGUMENTS_MAX = 10;
|
|
186
|
+
const summaryValue = (value) => {
|
|
187
|
+
const text = typeof value === 'string'
|
|
188
|
+
? value
|
|
189
|
+
: (JSON.stringify(value) ?? String(value));
|
|
190
|
+
// One line, and no backticks, so a value can neither fake another line of
|
|
191
|
+
// the summary nor break out of its code span into formatting or links.
|
|
192
|
+
const flat = text.replace(/\s+/g, ' ').replace(/`/g, "'").trim();
|
|
193
|
+
return flat.length > SUMMARY_VALUE_MAX
|
|
194
|
+
? `${flat.slice(0, SUMMARY_VALUE_MAX)}…`
|
|
195
|
+
: flat;
|
|
196
|
+
};
|
|
197
|
+
/**
|
|
198
|
+
* What the held calls will do, written from the calls themselves and appended
|
|
199
|
+
* under the model's question. The model words the question, but a yes runs the
|
|
200
|
+
* stored calls — so the participant has to see those, not only the model's
|
|
201
|
+
* account of them, which a prompt injection earlier in the turn could have
|
|
202
|
+
* bent.
|
|
203
|
+
*/
|
|
204
|
+
const formatConfirmationSummary = (calls, titleOf) => calls
|
|
205
|
+
.map(call => {
|
|
206
|
+
const entries = Object.entries(call.arguments ?? {}).filter(([, value]) => value !== undefined && value !== null && value !== '');
|
|
207
|
+
const lines = entries
|
|
208
|
+
.slice(0, SUMMARY_ARGUMENTS_MAX)
|
|
209
|
+
.map(([key, value]) => `- ${key}: \`${summaryValue(value)}\``);
|
|
210
|
+
if (entries.length > SUMMARY_ARGUMENTS_MAX)
|
|
211
|
+
lines.push('- …');
|
|
212
|
+
// The hourglass says "not done yet" in every language, whatever the
|
|
213
|
+
// model's own words above it claim.
|
|
214
|
+
return [`⏳ **${titleOf(call.name)}**`, ...lines].join('\n');
|
|
215
|
+
})
|
|
216
|
+
.join('\n\n');
|
|
217
|
+
exports.formatConfirmationSummary = formatConfirmationSummary;
|
|
218
|
+
/**
|
|
219
|
+
* Whether a tool needs a yes before it runs, by the MCP defaults: read-only
|
|
220
|
+
* only if it says so, and a tool that changes things is destructive unless it
|
|
221
|
+
* says it isn't. So an unannotated custom or proxied tool is confirmed — the
|
|
222
|
+
* safe reading for an organization that asked to be asked.
|
|
223
|
+
*/
|
|
224
|
+
const isSensitiveTool = (annotations) => annotations?.readOnlyHint !== true && annotations?.destructiveHint !== false;
|
|
225
|
+
exports.isSensitiveTool = isSensitiveTool;
|
|
226
|
+
// JSON with keys sorted at every depth, so two argument objects that say the
|
|
227
|
+
// same thing serialize the same whatever order the model emitted them in.
|
|
228
|
+
const stableJson = (value) => {
|
|
229
|
+
if (Array.isArray(value))
|
|
230
|
+
return `[${value.map(exports.stableJson).join(',')}]`;
|
|
231
|
+
if (value && typeof value === 'object') {
|
|
232
|
+
return `{${Object.keys(value)
|
|
233
|
+
.sort()
|
|
234
|
+
.map(key => `${JSON.stringify(key)}:${(0, exports.stableJson)(value[key])}`)
|
|
235
|
+
.join(',')}}`;
|
|
236
|
+
}
|
|
237
|
+
return JSON.stringify(value) ?? 'null';
|
|
238
|
+
};
|
|
239
|
+
exports.stableJson = stableJson;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface LintableTool {
|
|
2
|
+
name: string;
|
|
3
|
+
title?: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
inputSchema?: unknown;
|
|
6
|
+
annotations?: {
|
|
7
|
+
readOnlyHint?: boolean;
|
|
8
|
+
destructiveHint?: boolean;
|
|
9
|
+
idempotentHint?: boolean;
|
|
10
|
+
openWorldHint?: boolean;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export declare const TOOL_LINT_RULES: readonly ["missing-description", "short-description", "no-usage-guidance", "missing-annotations", "undescribed-input", "overlapping-tools", "too-many-tools"];
|
|
14
|
+
export type ToolLintRule = (typeof TOOL_LINT_RULES)[number];
|
|
15
|
+
export interface ToolLintFinding {
|
|
16
|
+
rule: ToolLintRule;
|
|
17
|
+
tool?: string;
|
|
18
|
+
other?: string;
|
|
19
|
+
paths?: string[];
|
|
20
|
+
count?: number;
|
|
21
|
+
limit?: number;
|
|
22
|
+
message: string;
|
|
23
|
+
}
|
|
24
|
+
export interface ToolLintOptions {
|
|
25
|
+
others?: LintableTool[];
|
|
26
|
+
otherCount?: number;
|
|
27
|
+
}
|
|
28
|
+
export declare const TOOL_LINT_MIN_DESCRIPTION_LENGTH = 40;
|
|
29
|
+
export declare const TOOL_LINT_MAX_TOOLS = 25;
|
|
30
|
+
/**
|
|
31
|
+
* Check a set of tools for what makes them hard for a model to use. Findings
|
|
32
|
+
* come back in the order of `tools`, then the server-wide one.
|
|
33
|
+
*/
|
|
34
|
+
export declare const lintTools: (tools: LintableTool[], options?: ToolLintOptions) => ToolLintFinding[];
|
|
35
|
+
//# sourceMappingURL=toolLint.d.ts.map
|
|
@@ -0,0 +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"}
|
package/dist/toolLint.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Checks on how a tool presents itself to a model: the things that make a
|
|
3
|
+
// model pick the wrong tool, pass the wrong arguments, or pay for tools it never
|
|
4
|
+
// uses. Every finding is a warning — a tool that fails one still works, it is
|
|
5
|
+
// just harder for a model to use well.
|
|
6
|
+
//
|
|
7
|
+
// One rule set, run in three places: `ganju build` (with `--strict` to fail on
|
|
8
|
+
// any finding), the API when a custom-code version is created (returned beside
|
|
9
|
+
// the version, with the artifact's other tools as context), and the dashboard's
|
|
10
|
+
// function dialog while it is being filled in.
|
|
11
|
+
//
|
|
12
|
+
// The rule for this file: it imports nothing. The published CLI bundles it, and
|
|
13
|
+
// anything it imported would ship inside the CLI with it (see cliConstants.ts).
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.lintTools = exports.TOOL_LINT_MAX_TOOLS = exports.TOOL_LINT_MIN_DESCRIPTION_LENGTH = exports.TOOL_LINT_RULES = void 0;
|
|
16
|
+
exports.TOOL_LINT_RULES = [
|
|
17
|
+
'missing-description',
|
|
18
|
+
'short-description',
|
|
19
|
+
'no-usage-guidance',
|
|
20
|
+
'missing-annotations',
|
|
21
|
+
'undescribed-input',
|
|
22
|
+
'overlapping-tools',
|
|
23
|
+
'too-many-tools'
|
|
24
|
+
];
|
|
25
|
+
// Shorter than this and a description rarely says both what a tool does and
|
|
26
|
+
// when to call it.
|
|
27
|
+
exports.TOOL_LINT_MIN_DESCRIPTION_LENGTH = 40;
|
|
28
|
+
// Past this many tools every call carries a long list of schemas, and models
|
|
29
|
+
// start choosing among them badly. A channel turn hard-caps its list at 40
|
|
30
|
+
// (CHANNEL_MAX_TOOLS); this warns well before that.
|
|
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.
|
|
34
|
+
const DESCRIPTION_OVERLAP = 0.6;
|
|
35
|
+
// Too few content words and two unrelated descriptions can share most of them.
|
|
36
|
+
const MIN_OVERLAP_WORDS = 5;
|
|
37
|
+
// Lowercased, accents off, so "Úsala cuando" and "usala cuando" are one text.
|
|
38
|
+
const fold = (text) => text.toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '');
|
|
39
|
+
// Wording that tells a model WHEN to call a tool rather than only what it does
|
|
40
|
+
// — English, Spanish and Portuguese, the languages descriptions here are
|
|
41
|
+
// 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/;
|
|
44
|
+
const STOPWORDS = new Set([
|
|
45
|
+
'the',
|
|
46
|
+
'and',
|
|
47
|
+
'for',
|
|
48
|
+
'with',
|
|
49
|
+
'from',
|
|
50
|
+
'this',
|
|
51
|
+
'that',
|
|
52
|
+
'into',
|
|
53
|
+
'your',
|
|
54
|
+
'you',
|
|
55
|
+
'use',
|
|
56
|
+
'when',
|
|
57
|
+
'call',
|
|
58
|
+
'tool',
|
|
59
|
+
'returns',
|
|
60
|
+
'return',
|
|
61
|
+
'given',
|
|
62
|
+
'user',
|
|
63
|
+
'about',
|
|
64
|
+
'una',
|
|
65
|
+
'uno',
|
|
66
|
+
'los',
|
|
67
|
+
'las',
|
|
68
|
+
'del',
|
|
69
|
+
'con',
|
|
70
|
+
'por',
|
|
71
|
+
'para',
|
|
72
|
+
'que',
|
|
73
|
+
'este',
|
|
74
|
+
'esta',
|
|
75
|
+
'como',
|
|
76
|
+
'cuando',
|
|
77
|
+
'usa',
|
|
78
|
+
'herramienta',
|
|
79
|
+
'devuelve',
|
|
80
|
+
'usuario'
|
|
81
|
+
]);
|
|
82
|
+
const contentWords = (text) => new Set(fold(text)
|
|
83
|
+
.split(/[^a-z0-9]+/)
|
|
84
|
+
.filter(word => word.length >= 3 && !STOPWORDS.has(word)));
|
|
85
|
+
// `lookupOrder`, `lookup-order` and `order_lookup` name the same thing.
|
|
86
|
+
const nameTokens = (name) => name
|
|
87
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
88
|
+
.toLowerCase()
|
|
89
|
+
.split(/[^a-z0-9]+/)
|
|
90
|
+
.filter(Boolean)
|
|
91
|
+
.sort()
|
|
92
|
+
.join(' ');
|
|
93
|
+
const jaccard = (a, b) => {
|
|
94
|
+
let shared = 0;
|
|
95
|
+
for (const word of a)
|
|
96
|
+
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;
|
|
108
|
+
}
|
|
109
|
+
return jaccard(aWords, bWords) >= DESCRIPTION_OVERLAP;
|
|
110
|
+
};
|
|
111
|
+
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
112
|
+
// Every property the model may fill in that doesn't say what it is for, down
|
|
113
|
+
// through nested objects and arrays of objects.
|
|
114
|
+
const undescribedPaths = (schema, prefix = '') => {
|
|
115
|
+
if (!isObject(schema))
|
|
116
|
+
return [];
|
|
117
|
+
const paths = [];
|
|
118
|
+
if (isObject(schema.properties)) {
|
|
119
|
+
for (const [key, property] of Object.entries(schema.properties)) {
|
|
120
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
121
|
+
if (!isObject(property))
|
|
122
|
+
continue;
|
|
123
|
+
const description = property.description;
|
|
124
|
+
if (typeof description !== 'string' || !description.trim()) {
|
|
125
|
+
paths.push(path);
|
|
126
|
+
}
|
|
127
|
+
paths.push(...undescribedPaths(property, path));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (isObject(schema.items)) {
|
|
131
|
+
paths.push(...undescribedPaths(schema.items, `${prefix}[]`));
|
|
132
|
+
}
|
|
133
|
+
return paths;
|
|
134
|
+
};
|
|
135
|
+
const quoted = (name) => `"${name}"`;
|
|
136
|
+
/**
|
|
137
|
+
* Check a set of tools for what makes them hard for a model to use. Findings
|
|
138
|
+
* come back in the order of `tools`, then the server-wide one.
|
|
139
|
+
*/
|
|
140
|
+
const lintTools = (tools, options = {}) => {
|
|
141
|
+
const others = options.others ?? [];
|
|
142
|
+
const findings = [];
|
|
143
|
+
tools.forEach((tool, index) => {
|
|
144
|
+
const description = (tool.description ?? '').trim();
|
|
145
|
+
if (!description) {
|
|
146
|
+
findings.push({
|
|
147
|
+
rule: 'missing-description',
|
|
148
|
+
tool: tool.name,
|
|
149
|
+
message: `${quoted(tool.name)} has no description, so a model sees only its name when deciding whether to call it.`
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
else if (description.length < exports.TOOL_LINT_MIN_DESCRIPTION_LENGTH) {
|
|
153
|
+
findings.push({
|
|
154
|
+
rule: 'short-description',
|
|
155
|
+
tool: tool.name,
|
|
156
|
+
message: `${quoted(tool.name)} has a very short description. Say what it does and when to call it, in at least ${exports.TOOL_LINT_MIN_DESCRIPTION_LENGTH} characters.`
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
else if (!USAGE_GUIDANCE.test(fold(description))) {
|
|
160
|
+
findings.push({
|
|
161
|
+
rule: 'no-usage-guidance',
|
|
162
|
+
tool: tool.name,
|
|
163
|
+
message: `${quoted(tool.name)}'s description says what it does but not when to use it (e.g. "Use when the user asks about…").`
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
// Undeclared means "may change things" by the MCP defaults, so with the
|
|
167
|
+
// organization confirming sensitive actions every call asks first — a
|
|
168
|
+
// lookup included.
|
|
169
|
+
const hints = tool.annotations;
|
|
170
|
+
if (hints?.readOnlyHint === undefined &&
|
|
171
|
+
hints?.destructiveHint === undefined) {
|
|
172
|
+
findings.push({
|
|
173
|
+
rule: 'missing-annotations',
|
|
174
|
+
tool: tool.name,
|
|
175
|
+
message: `${quoted(tool.name)} declares no annotations, so it is treated as one that may change things and is confirmed before every call when the organization confirms sensitive actions. Set readOnlyHint: true if it only reads, or destructiveHint: false if its changes can be undone.`
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
const paths = undescribedPaths(tool.inputSchema);
|
|
179
|
+
if (paths.length > 0) {
|
|
180
|
+
findings.push({
|
|
181
|
+
rule: 'undescribed-input',
|
|
182
|
+
tool: tool.name,
|
|
183
|
+
paths,
|
|
184
|
+
message: `${quoted(tool.name)} has input properties with no description: ${paths.join(', ')}. A model has to guess what to pass.`
|
|
185
|
+
});
|
|
186
|
+
}
|
|
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;
|
|
192
|
+
findings.push({
|
|
193
|
+
rule: 'overlapping-tools',
|
|
194
|
+
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.`
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
const count = tools.length + (options.otherCount ?? others.length);
|
|
201
|
+
if (count > exports.TOOL_LINT_MAX_TOOLS) {
|
|
202
|
+
findings.push({
|
|
203
|
+
rule: 'too-many-tools',
|
|
204
|
+
count,
|
|
205
|
+
limit: exports.TOOL_LINT_MAX_TOOLS,
|
|
206
|
+
message: `${count} tools are enabled on this server. Every one's schema is sent to the model on every call, and models choose worse past a few dozen — turn off the ones it doesn't need (more than ${exports.TOOL_LINT_MAX_TOOLS} is where this warns).`
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
return findings;
|
|
210
|
+
};
|
|
211
|
+
exports.lintTools = lintTools;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ganju/utils",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.12",
|
|
4
4
|
"description": "Shared constants, schemas and helpers for Ganju",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "MontoyaAndres <andresmontoyafcb@gmail.com>",
|
|
@@ -53,6 +53,11 @@
|
|
|
53
53
|
"import": "./dist/cliConstants.js",
|
|
54
54
|
"require": "./dist/cliConstants.js"
|
|
55
55
|
},
|
|
56
|
+
"./toolLint": {
|
|
57
|
+
"types": "./dist/toolLint.d.ts",
|
|
58
|
+
"import": "./dist/toolLint.js",
|
|
59
|
+
"require": "./dist/toolLint.js"
|
|
60
|
+
},
|
|
56
61
|
"./sdkConstants": {
|
|
57
62
|
"types": "./dist/sdkConstants.d.ts",
|
|
58
63
|
"import": "./dist/sdkConstants.js",
|
package/src/constants.ts
CHANGED
|
@@ -304,6 +304,16 @@ const RESOURCE_SYNC_BATCH_SIZE = 50;
|
|
|
304
304
|
// plan, and a website sync re-crawls every page, so it can't be a button to
|
|
305
305
|
// press in a loop.
|
|
306
306
|
const RESOURCE_SYNC_MANUAL_COOLDOWN_MS = 5 * 60 * 1000;
|
|
307
|
+
// How long a sync may stay PENDING before it counts as stuck rather than
|
|
308
|
+
// running. A lost queue message or a job that exhausted its retries never
|
|
309
|
+
// completes its root, and a root that stays PENDING would otherwise be skipped
|
|
310
|
+
// by the hourly job and refused by "Sync now" forever. Twelve hours outlasts
|
|
311
|
+
// any realistic wait: the crawl-page queue runs 5 at a time for every org, and
|
|
312
|
+
// one hourly run can queue 50 sites — thousands of pages, hours of backlog. A
|
|
313
|
+
// shorter window restarts syncs that are merely queued, re-fetching every page
|
|
314
|
+
// into the same backlog; a longer one leaves a stuck daily source missing more
|
|
315
|
+
// than one sync.
|
|
316
|
+
const RESOURCE_SYNC_STALE_MS = 12 * 60 * 60 * 1000;
|
|
307
317
|
|
|
308
318
|
const CRAWL_RENDERER_CHEERIO = 'cheerio' as 'cheerio';
|
|
309
319
|
const CRAWL_RENDERER_PLAYWRIGHT = 'playwright' as 'playwright';
|
|
@@ -758,6 +768,59 @@ const CHANNEL_DEBOUNCE_JOIN = '\n';
|
|
|
758
768
|
const CHANNEL_DEBOUNCE_RETRY_MS = 5000;
|
|
759
769
|
const CHANNEL_DEBOUNCE_MAX_ATTEMPTS = 3;
|
|
760
770
|
|
|
771
|
+
// Confirming sensitive actions, for organizations that turn it on. A channel
|
|
772
|
+
// bot that wants to run a tool annotated `destructiveHint` asks first, and the
|
|
773
|
+
// call runs only if the participant's next message is a plain yes.
|
|
774
|
+
//
|
|
775
|
+
// How long the question stays open. An answer after that is read as a new
|
|
776
|
+
// message, never as consent to something asked long ago.
|
|
777
|
+
const TOOL_CONFIRMATION_TTL_MS = 30 * 60 * 1000;
|
|
778
|
+
// The tool result the model gets instead of running the call. It is what makes
|
|
779
|
+
// the model write the question — in the participant's language, naming what
|
|
780
|
+
// the call would do — rather than claiming the action happened.
|
|
781
|
+
// The call's exact arguments are listed under the model's question by the
|
|
782
|
+
// runner, so the model only has to say what it is about to do.
|
|
783
|
+
const TOOL_CONFIRMATION_REQUIRED_RESULT =
|
|
784
|
+
'Not run yet: this action needs the user to confirm it first. In one or ' +
|
|
785
|
+
'two sentences, tell them what it will do (who it goes to, what will be ' +
|
|
786
|
+
'changed or deleted) and ask them to answer yes to go ahead or no to ' +
|
|
787
|
+
'cancel — in their own language, never quoting English words at someone ' +
|
|
788
|
+
'writing in another one. The exact details are listed for them under your ' +
|
|
789
|
+
'message. It runs only if their next message is yes. Do not say it was done.';
|
|
790
|
+
// Told to the model on every turn of an organization that confirms: the
|
|
791
|
+
// asking is done for it, so it calls the tool when asked instead of asking
|
|
792
|
+
// first itself — and calls every action a request needs in one step, since
|
|
793
|
+
// the turn stops at the first held call and each later one would be a
|
|
794
|
+
// question of its own.
|
|
795
|
+
const TOOL_CONFIRMATION_SYSTEM_NOTE =
|
|
796
|
+
'Actions that send, change or delete something are confirmed with the ' +
|
|
797
|
+
'user by the system before they run. Ask for any detail you are missing, ' +
|
|
798
|
+
'but once you have what the action needs, call the tool — do not ask the ' +
|
|
799
|
+
'user to confirm it yourself; the system asks them and runs it on their ' +
|
|
800
|
+
'yes. When a request needs several such actions (say, delete an event ' +
|
|
801
|
+
'and create its replacement), call them all in the same step, so the ' +
|
|
802
|
+
'user confirms them together once.';
|
|
803
|
+
// The system-prompt addition for the step that writes the question, with the
|
|
804
|
+
// held actions' names in place of {{actions}}.
|
|
805
|
+
const TOOL_CONFIRMATION_ASK_INSTRUCTION =
|
|
806
|
+
'IMPORTANT: these actions have NOT been done: {{actions}}. They are ' +
|
|
807
|
+
'waiting for the user to confirm them. Your reply must ask the user to ' +
|
|
808
|
+
'confirm, and must not say or suggest that they were done, nor describe ' +
|
|
809
|
+
'or make up any result from them.';
|
|
810
|
+
// For MCP clients, whose chat the server can't see: a sensitive call without a
|
|
811
|
+
// valid confirmation isn't run, and the model is told to ask the user and call
|
|
812
|
+
// again with a token that binds the second call to these exact arguments.
|
|
813
|
+
// How long that token stays good, and the argument that carries it.
|
|
814
|
+
const MCP_TOOL_CONFIRMATION_TTL_MS = 10 * 60 * 1000;
|
|
815
|
+
const MCP_TOOL_CONFIRMATION_ARG = 'confirmation';
|
|
816
|
+
const MCP_TOOL_CONFIRMATION_ARG_DESCRIPTION =
|
|
817
|
+
"Leave out on the first call. This action needs the user's yes: the " +
|
|
818
|
+
'first call returns a token; after the user says yes, call again with the ' +
|
|
819
|
+
'same arguments and that token here.';
|
|
820
|
+
// The question when the model wrote none, above the runner's own summary.
|
|
821
|
+
const TOOL_CONFIRMATION_FALLBACK_QUESTION =
|
|
822
|
+
'Before I do this, please confirm. Reply yes to go ahead, or no to cancel.';
|
|
823
|
+
|
|
761
824
|
const TELEGRAM_SECRET_HEADER = 'x-telegram-bot-api-secret-token';
|
|
762
825
|
const TELEGRAM_API_BASE = 'https://api.telegram.org';
|
|
763
826
|
const TELEGRAM_MESSAGE_LIMIT = 3500;
|
|
@@ -817,8 +880,12 @@ const DISCORD_TIMESTAMP_HEADER = 'x-signature-timestamp';
|
|
|
817
880
|
// Interaction request types and response types (Discord API).
|
|
818
881
|
const DISCORD_INTERACTION_TYPE_PING = 1;
|
|
819
882
|
const DISCORD_INTERACTION_TYPE_APPLICATION_COMMAND = 2;
|
|
883
|
+
// A tap on a message button — the Yes/No under a confirmation question.
|
|
884
|
+
const DISCORD_INTERACTION_TYPE_MESSAGE_COMPONENT = 3;
|
|
820
885
|
const DISCORD_INTERACTION_RESPONSE_PONG = 1;
|
|
821
886
|
const DISCORD_INTERACTION_RESPONSE_DEFERRED = 5;
|
|
887
|
+
// Edits the message the tapped button is on, in the same response.
|
|
888
|
+
const DISCORD_INTERACTION_RESPONSE_UPDATE_MESSAGE = 7;
|
|
822
889
|
// Channel types we map to conversation scopes: DM (1) / group DM (3) = private;
|
|
823
890
|
// everything else (guild text channels, threads) = channel.
|
|
824
891
|
const DISCORD_CHANNEL_TYPE_DM = 1;
|
|
@@ -847,6 +914,8 @@ const WHATSAPP_API_BASE = 'https://graph.facebook.com';
|
|
|
847
914
|
const WHATSAPP_API_VERSION = 'v25.0';
|
|
848
915
|
// A WhatsApp text message body caps at 4096 chars; we chunk a bit under that.
|
|
849
916
|
const WHATSAPP_MESSAGE_LIMIT = 4000;
|
|
917
|
+
// The body of an interactive (reply-button) message holds at most this much.
|
|
918
|
+
const WHATSAPP_INTERACTIVE_BODY_LIMIT = 1024;
|
|
850
919
|
const WHATSAPP_SIGNATURE_HEADER = 'x-hub-signature-256';
|
|
851
920
|
const WHATSAPP_SIGNATURE_PREFIX = 'sha256=';
|
|
852
921
|
// GET-handshake query params + the expected hub.mode value.
|
|
@@ -2546,6 +2615,7 @@ export const constants = {
|
|
|
2546
2615
|
RESOURCE_SYNC_INTERVAL_MS,
|
|
2547
2616
|
RESOURCE_SYNC_BATCH_SIZE,
|
|
2548
2617
|
RESOURCE_SYNC_MANUAL_COOLDOWN_MS,
|
|
2618
|
+
RESOURCE_SYNC_STALE_MS,
|
|
2549
2619
|
CRAWL_RENDERER_CHEERIO,
|
|
2550
2620
|
CRAWL_RENDERER_PLAYWRIGHT,
|
|
2551
2621
|
CRAWL_RENDERERS,
|
|
@@ -2604,6 +2674,14 @@ export const constants = {
|
|
|
2604
2674
|
SHARED_KEY_HISTORY_LIMIT,
|
|
2605
2675
|
SHARED_KEY_MAX_TOOL_LOOPS,
|
|
2606
2676
|
CHANNEL_MAX_TOOLS,
|
|
2677
|
+
TOOL_CONFIRMATION_TTL_MS,
|
|
2678
|
+
TOOL_CONFIRMATION_REQUIRED_RESULT,
|
|
2679
|
+
TOOL_CONFIRMATION_FALLBACK_QUESTION,
|
|
2680
|
+
TOOL_CONFIRMATION_ASK_INSTRUCTION,
|
|
2681
|
+
TOOL_CONFIRMATION_SYSTEM_NOTE,
|
|
2682
|
+
MCP_TOOL_CONFIRMATION_TTL_MS,
|
|
2683
|
+
MCP_TOOL_CONFIRMATION_ARG,
|
|
2684
|
+
MCP_TOOL_CONFIRMATION_ARG_DESCRIPTION,
|
|
2607
2685
|
CHANNEL_DEBOUNCE_DEFAULT_MS,
|
|
2608
2686
|
CHANNEL_DEBOUNCE_MIN_MS,
|
|
2609
2687
|
CHANNEL_DEBOUNCE_MAX_MS,
|
|
@@ -2636,8 +2714,10 @@ export const constants = {
|
|
|
2636
2714
|
DISCORD_TIMESTAMP_HEADER,
|
|
2637
2715
|
DISCORD_INTERACTION_TYPE_PING,
|
|
2638
2716
|
DISCORD_INTERACTION_TYPE_APPLICATION_COMMAND,
|
|
2717
|
+
DISCORD_INTERACTION_TYPE_MESSAGE_COMPONENT,
|
|
2639
2718
|
DISCORD_INTERACTION_RESPONSE_PONG,
|
|
2640
2719
|
DISCORD_INTERACTION_RESPONSE_DEFERRED,
|
|
2720
|
+
DISCORD_INTERACTION_RESPONSE_UPDATE_MESSAGE,
|
|
2641
2721
|
DISCORD_CHANNEL_TYPE_DM,
|
|
2642
2722
|
DISCORD_CHANNEL_TYPE_GROUP_DM,
|
|
2643
2723
|
DISCORD_GATEWAY_OP_DISPATCH,
|
|
@@ -2652,6 +2732,7 @@ export const constants = {
|
|
|
2652
2732
|
WHATSAPP_API_BASE,
|
|
2653
2733
|
WHATSAPP_API_VERSION,
|
|
2654
2734
|
WHATSAPP_MESSAGE_LIMIT,
|
|
2735
|
+
WHATSAPP_INTERACTIVE_BODY_LIMIT,
|
|
2655
2736
|
WHATSAPP_SIGNATURE_HEADER,
|
|
2656
2737
|
WHATSAPP_SIGNATURE_PREFIX,
|
|
2657
2738
|
WHATSAPP_HUB_MODE_PARAM,
|