@ganju/utils 0.0.9 → 0.0.11

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,261 @@
1
+ // When an organization turns on "confirm sensitive actions", a channel bot that
2
+ // wants to run a sensitive tool asks first, and the call runs only if the
3
+ // participant's next message is a plain yes. These are the pieces of that rule
4
+ // that aren't the runner's own bookkeeping.
5
+
6
+ // A call waiting on the participant's answer, stored on the conversation. The
7
+ // exact calls the model made are kept, so what runs after a yes is what the
8
+ // question described — the model never gets to re-derive the arguments.
9
+ export interface PendingToolConfirmation {
10
+ // Claimed by id, so a re-sent batch can't run the same calls twice.
11
+ id: string;
12
+ // Only the participant who was asked can answer — in a group, anyone else's
13
+ // message leaves the question open.
14
+ participantId: string;
15
+ createdAt: string;
16
+ calls: Array<{
17
+ id: string;
18
+ name: string;
19
+ arguments: Record<string, unknown>;
20
+ }>;
21
+ }
22
+
23
+ // What a conversation holds: one open question per participant, keyed by
24
+ // participant id, so in a group one person's question never replaces
25
+ // another's.
26
+ export type PendingToolConfirmations = Record<string, PendingToolConfirmation>;
27
+
28
+ // What an owner says a tool they wrote does, in the words the dashboard asks
29
+ // it in, and the MCP annotations each answer stands for: `read` only looks
30
+ // things up, `write` changes things that can be undone, `sensitive` sends,
31
+ // deletes, charges or can't be undone — the kind a confirming channel asks
32
+ // about.
33
+ export const TOOL_EFFECTS = ['read', 'write', 'sensitive'] as const;
34
+
35
+ export type ToolEffect = (typeof TOOL_EFFECTS)[number];
36
+
37
+ export const annotationsForEffect = (
38
+ effect: ToolEffect
39
+ ): { readOnlyHint: boolean; destructiveHint: boolean } =>
40
+ effect === 'read'
41
+ ? { readOnlyHint: true, destructiveHint: false }
42
+ : effect === 'write'
43
+ ? { readOnlyHint: false, destructiveHint: false }
44
+ : { readOnlyHint: false, destructiveHint: true };
45
+
46
+ // Whole replies that mean yes, lowercased with accents and punctuation
47
+ // removed. English, Spanish and Portuguese, the languages bots here answer in.
48
+ const AFFIRMATIVE = new Set([
49
+ 'y',
50
+ 'yes',
51
+ 'yeah',
52
+ 'yep',
53
+ 'yup',
54
+ 'sure',
55
+ 'ok',
56
+ 'okay',
57
+ 'confirm',
58
+ 'confirmed',
59
+ 'approve',
60
+ 'approved',
61
+ 'go',
62
+ 'go ahead',
63
+ 'do it',
64
+ 'send',
65
+ 'send it',
66
+ 'proceed',
67
+ 'yes please',
68
+ 'yes send it',
69
+ 'yes do it',
70
+ 'yes go ahead',
71
+ 'yes confirm',
72
+ 'sure thing',
73
+ 'go for it',
74
+ 'sounds good',
75
+ 'looks good',
76
+ 'all good',
77
+ 'correct',
78
+ 'si hazlo',
79
+ 'si adelante',
80
+ 'si envialo ya',
81
+ 'perfecto',
82
+ 'correcto',
83
+ 'esta bien',
84
+ 'ok dale',
85
+ 'ok envialo',
86
+ 'si',
87
+ 'sip',
88
+ 'claro',
89
+ 'dale',
90
+ 'va',
91
+ 'vale',
92
+ 'listo',
93
+ 'confirmo',
94
+ 'confirmado',
95
+ 'confirmar',
96
+ 'adelante',
97
+ 'hazlo',
98
+ 'envialo',
99
+ 'enviar',
100
+ 'de acuerdo',
101
+ 'si por favor',
102
+ 'si claro',
103
+ 'si dale',
104
+ 'si envialo',
105
+ 'si confirmo',
106
+ 'sim',
107
+ 'pode',
108
+ 'pode enviar'
109
+ ]);
110
+
111
+ const AFFIRMATIVE_EMOJI = new Set(['👍', '✅', '👌', '🆗']);
112
+
113
+ // The Yes/No buttons offered under a confirmation question, in the
114
+ // participant's language when the platform reports it. The labels are also
115
+ // what a tap sends where a button is just a canned reply (Telegram's reply
116
+ // keyboard), so "yes" must read as a yes to isConfirmationReply — and it does,
117
+ // in every language here.
118
+ const BUTTON_LABELS: Record<string, { yes: string; no: string }> = {
119
+ en: { yes: '✅ Yes', no: '❌ No' },
120
+ es: { yes: '✅ Sí', no: '❌ No' },
121
+ pt: { yes: '✅ Sim', no: '❌ Não' }
122
+ };
123
+
124
+ // WhatsApp reports no locale, so a button's language comes from the calling
125
+ // code of the participant's number: the Spanish- and Portuguese-speaking
126
+ // countries, English otherwise. Longest prefix wins.
127
+ const CALLING_CODE_LANGUAGE: Record<string, string> = {
128
+ '34': 'es',
129
+ '51': 'es',
130
+ '52': 'es',
131
+ '53': 'es',
132
+ '54': 'es',
133
+ '56': 'es',
134
+ '57': 'es',
135
+ '58': 'es',
136
+ '240': 'es',
137
+ '502': 'es',
138
+ '503': 'es',
139
+ '504': 'es',
140
+ '505': 'es',
141
+ '506': 'es',
142
+ '507': 'es',
143
+ '591': 'es',
144
+ '593': 'es',
145
+ '595': 'es',
146
+ '598': 'es',
147
+ '1787': 'es',
148
+ '1809': 'es',
149
+ '1829': 'es',
150
+ '1849': 'es',
151
+ '55': 'pt',
152
+ '238': 'pt',
153
+ '244': 'pt',
154
+ '245': 'pt',
155
+ '258': 'pt',
156
+ '351': 'pt'
157
+ };
158
+
159
+ export const languageFromPhoneNumber = (phone: string): string | null => {
160
+ const digits = phone.replace(/\D/g, '');
161
+ for (let length = 4; length >= 2; length--) {
162
+ const language = CALLING_CODE_LANGUAGE[digits.slice(0, length)];
163
+ if (language) return language;
164
+ }
165
+ return null;
166
+ };
167
+
168
+ export const confirmationButtonLabels = (
169
+ locale?: string | null
170
+ ): { yes: string; no: string } => {
171
+ const language = (locale || '').toLowerCase().split(/[-_]/)[0];
172
+ return BUTTON_LABELS[language] ?? BUTTON_LABELS.en;
173
+ };
174
+
175
+ // Where the platform sends back a button's id rather than its label (Discord,
176
+ // WhatsApp), the id says which answer it is and who was asked: in a shared
177
+ // channel everyone sees the buttons, and only that participant's tap counts.
178
+ // The runner never sees the id — a tap reaches it as the text "yes" or "no".
179
+ const CONFIRMATION_BUTTON_PREFIX = 'ganju-confirm';
180
+
181
+ export const confirmationButtonId = (
182
+ answer: 'yes' | 'no',
183
+ externalParticipantId: string
184
+ ): string => `${CONFIRMATION_BUTTON_PREFIX}:${answer}:${externalParticipantId}`;
185
+
186
+ export const parseConfirmationButton = (
187
+ id: string
188
+ ): { text: 'yes' | 'no'; externalParticipantId: string } | null => {
189
+ const [prefix, answer, ...rest] = id.split(':');
190
+ const externalParticipantId = rest.join(':');
191
+ if (prefix !== CONFIRMATION_BUTTON_PREFIX || !externalParticipantId) {
192
+ return null;
193
+ }
194
+ if (answer !== 'yes' && answer !== 'no') return null;
195
+ return { text: answer, externalParticipantId };
196
+ };
197
+
198
+ const normalize = (text: string): string =>
199
+ text
200
+ .toLowerCase()
201
+ .normalize('NFKD')
202
+ .replace(/[̀-ͯ]/g, '')
203
+ .replace(/[^a-z0-9\s]+/g, ' ')
204
+ .replace(/\s+/g, ' ')
205
+ .trim();
206
+
207
+ /**
208
+ * Whether a reply confirms the pending action. Deliberately narrow: the whole
209
+ * message has to be a yes. "Yes, but send it to Ana instead" is not one — it
210
+ * asks for a different action, and running the original would do exactly
211
+ * what the person just said not to. Anything that isn't a clear yes cancels,
212
+ * so the worst a misread costs is asking again.
213
+ */
214
+ export const isConfirmationReply = (text: string): boolean => {
215
+ const trimmed = text.trim();
216
+ if (AFFIRMATIVE_EMOJI.has(trimmed)) return true;
217
+ return AFFIRMATIVE.has(normalize(trimmed));
218
+ };
219
+
220
+ // How much of each argument the summary shows, and how many arguments.
221
+ const SUMMARY_VALUE_MAX = 200;
222
+ const SUMMARY_ARGUMENTS_MAX = 10;
223
+
224
+ const summaryValue = (value: unknown): string => {
225
+ const text =
226
+ typeof value === 'string'
227
+ ? value
228
+ : (JSON.stringify(value) ?? String(value));
229
+ // One line, and no backticks, so a value can neither fake another line of
230
+ // the summary nor break out of its code span into formatting or links.
231
+ const flat = text.replace(/\s+/g, ' ').replace(/`/g, "'").trim();
232
+ return flat.length > SUMMARY_VALUE_MAX
233
+ ? `${flat.slice(0, SUMMARY_VALUE_MAX)}…`
234
+ : flat;
235
+ };
236
+
237
+ /**
238
+ * What the held calls will do, written from the calls themselves and appended
239
+ * under the model's question. The model words the question, but a yes runs the
240
+ * stored calls — so the participant has to see those, not only the model's
241
+ * account of them, which a prompt injection earlier in the turn could have
242
+ * bent.
243
+ */
244
+ export const formatConfirmationSummary = (
245
+ calls: Array<{ name: string; arguments: Record<string, unknown> }>,
246
+ titleOf: (name: string) => string
247
+ ): string =>
248
+ calls
249
+ .map(call => {
250
+ const entries = Object.entries(call.arguments ?? {}).filter(
251
+ ([, value]) => value !== undefined && value !== null && value !== ''
252
+ );
253
+ const lines = entries
254
+ .slice(0, SUMMARY_ARGUMENTS_MAX)
255
+ .map(([key, value]) => `- ${key}: \`${summaryValue(value)}\``);
256
+ if (entries.length > SUMMARY_ARGUMENTS_MAX) lines.push('- …');
257
+ // The hourglass says "not done yet" in every language, whatever the
258
+ // model's own words above it claim.
259
+ return [`⏳ **${titleOf(call.name)}**`, ...lines].join('\n');
260
+ })
261
+ .join('\n\n');