@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.
@@ -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"}
@@ -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.10",
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,