@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.
- package/dist/channelDebounce.d.ts +11 -0
- package/dist/channelDebounce.d.ts.map +1 -1
- package/dist/channelDebounce.js +12 -1
- package/dist/chunking.d.ts +17 -3
- package/dist/chunking.d.ts.map +1 -1
- package/dist/chunking.js +125 -19
- package/dist/constants.d.ts +30 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +136 -5
- package/dist/index.d.ts +207 -28
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -0
- package/dist/resourceRerank.d.ts +20 -0
- package/dist/resourceRerank.d.ts.map +1 -0
- package/dist/resourceRerank.js +37 -0
- package/dist/schema.d.ts +106 -24
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +45 -4
- package/dist/syncableResource.d.ts +14 -0
- package/dist/syncableResource.d.ts.map +1 -0
- package/dist/syncableResource.js +43 -0
- package/dist/toolCatalog.d.ts +70 -1
- package/dist/toolCatalog.d.ts.map +1 -1
- package/dist/toolCatalog.js +151 -63
- package/dist/toolConfirmation.d.ts +47 -0
- package/dist/toolConfirmation.d.ts.map +1 -0
- package/dist/toolConfirmation.js +217 -0
- package/package.json +1 -1
- package/src/channelDebounce.ts +36 -0
- package/src/chunking.ts +166 -23
- package/src/constants.ts +149 -5
- package/src/index.ts +51 -1
- package/src/resourceRerank.ts +54 -0
- package/src/schema.ts +58 -6
- package/src/syncableResource.ts +68 -0
- package/src/toolCatalog.ts +170 -63
- package/src/toolConfirmation.ts +261 -0
|
@@ -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');
|