@ganju/utils 0.0.10 → 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/constants.d.ts +9 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +63 -0
- package/dist/index.d.ts +140 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -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 +47 -0
- package/dist/toolConfirmation.d.ts.map +1 -0
- package/dist/toolConfirmation.js +217 -0
- package/package.json +1 -1
- package/src/constants.ts +68 -0
- package/src/index.ts +34 -1
- package/src/schema.ts +41 -6
- package/src/syncableResource.ts +20 -0
- package/src/toolCatalog.ts +169 -62
- package/src/toolConfirmation.ts +261 -0
|
@@ -0,0 +1,217 @@
|
|
|
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.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;
|
package/package.json
CHANGED
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,49 @@ 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
|
+
// The question when the model wrote none, above the runner's own summary.
|
|
811
|
+
const TOOL_CONFIRMATION_FALLBACK_QUESTION =
|
|
812
|
+
'Before I do this, please confirm. Reply yes to go ahead, or no to cancel.';
|
|
813
|
+
|
|
761
814
|
const TELEGRAM_SECRET_HEADER = 'x-telegram-bot-api-secret-token';
|
|
762
815
|
const TELEGRAM_API_BASE = 'https://api.telegram.org';
|
|
763
816
|
const TELEGRAM_MESSAGE_LIMIT = 3500;
|
|
@@ -817,8 +870,12 @@ const DISCORD_TIMESTAMP_HEADER = 'x-signature-timestamp';
|
|
|
817
870
|
// Interaction request types and response types (Discord API).
|
|
818
871
|
const DISCORD_INTERACTION_TYPE_PING = 1;
|
|
819
872
|
const DISCORD_INTERACTION_TYPE_APPLICATION_COMMAND = 2;
|
|
873
|
+
// A tap on a message button — the Yes/No under a confirmation question.
|
|
874
|
+
const DISCORD_INTERACTION_TYPE_MESSAGE_COMPONENT = 3;
|
|
820
875
|
const DISCORD_INTERACTION_RESPONSE_PONG = 1;
|
|
821
876
|
const DISCORD_INTERACTION_RESPONSE_DEFERRED = 5;
|
|
877
|
+
// Edits the message the tapped button is on, in the same response.
|
|
878
|
+
const DISCORD_INTERACTION_RESPONSE_UPDATE_MESSAGE = 7;
|
|
822
879
|
// Channel types we map to conversation scopes: DM (1) / group DM (3) = private;
|
|
823
880
|
// everything else (guild text channels, threads) = channel.
|
|
824
881
|
const DISCORD_CHANNEL_TYPE_DM = 1;
|
|
@@ -847,6 +904,8 @@ const WHATSAPP_API_BASE = 'https://graph.facebook.com';
|
|
|
847
904
|
const WHATSAPP_API_VERSION = 'v25.0';
|
|
848
905
|
// A WhatsApp text message body caps at 4096 chars; we chunk a bit under that.
|
|
849
906
|
const WHATSAPP_MESSAGE_LIMIT = 4000;
|
|
907
|
+
// The body of an interactive (reply-button) message holds at most this much.
|
|
908
|
+
const WHATSAPP_INTERACTIVE_BODY_LIMIT = 1024;
|
|
850
909
|
const WHATSAPP_SIGNATURE_HEADER = 'x-hub-signature-256';
|
|
851
910
|
const WHATSAPP_SIGNATURE_PREFIX = 'sha256=';
|
|
852
911
|
// GET-handshake query params + the expected hub.mode value.
|
|
@@ -2546,6 +2605,7 @@ export const constants = {
|
|
|
2546
2605
|
RESOURCE_SYNC_INTERVAL_MS,
|
|
2547
2606
|
RESOURCE_SYNC_BATCH_SIZE,
|
|
2548
2607
|
RESOURCE_SYNC_MANUAL_COOLDOWN_MS,
|
|
2608
|
+
RESOURCE_SYNC_STALE_MS,
|
|
2549
2609
|
CRAWL_RENDERER_CHEERIO,
|
|
2550
2610
|
CRAWL_RENDERER_PLAYWRIGHT,
|
|
2551
2611
|
CRAWL_RENDERERS,
|
|
@@ -2604,6 +2664,11 @@ export const constants = {
|
|
|
2604
2664
|
SHARED_KEY_HISTORY_LIMIT,
|
|
2605
2665
|
SHARED_KEY_MAX_TOOL_LOOPS,
|
|
2606
2666
|
CHANNEL_MAX_TOOLS,
|
|
2667
|
+
TOOL_CONFIRMATION_TTL_MS,
|
|
2668
|
+
TOOL_CONFIRMATION_REQUIRED_RESULT,
|
|
2669
|
+
TOOL_CONFIRMATION_FALLBACK_QUESTION,
|
|
2670
|
+
TOOL_CONFIRMATION_ASK_INSTRUCTION,
|
|
2671
|
+
TOOL_CONFIRMATION_SYSTEM_NOTE,
|
|
2607
2672
|
CHANNEL_DEBOUNCE_DEFAULT_MS,
|
|
2608
2673
|
CHANNEL_DEBOUNCE_MIN_MS,
|
|
2609
2674
|
CHANNEL_DEBOUNCE_MAX_MS,
|
|
@@ -2636,8 +2701,10 @@ export const constants = {
|
|
|
2636
2701
|
DISCORD_TIMESTAMP_HEADER,
|
|
2637
2702
|
DISCORD_INTERACTION_TYPE_PING,
|
|
2638
2703
|
DISCORD_INTERACTION_TYPE_APPLICATION_COMMAND,
|
|
2704
|
+
DISCORD_INTERACTION_TYPE_MESSAGE_COMPONENT,
|
|
2639
2705
|
DISCORD_INTERACTION_RESPONSE_PONG,
|
|
2640
2706
|
DISCORD_INTERACTION_RESPONSE_DEFERRED,
|
|
2707
|
+
DISCORD_INTERACTION_RESPONSE_UPDATE_MESSAGE,
|
|
2641
2708
|
DISCORD_CHANNEL_TYPE_DM,
|
|
2642
2709
|
DISCORD_CHANNEL_TYPE_GROUP_DM,
|
|
2643
2710
|
DISCORD_GATEWAY_OP_DISPATCH,
|
|
@@ -2652,6 +2719,7 @@ export const constants = {
|
|
|
2652
2719
|
WHATSAPP_API_BASE,
|
|
2653
2720
|
WHATSAPP_API_VERSION,
|
|
2654
2721
|
WHATSAPP_MESSAGE_LIMIT,
|
|
2722
|
+
WHATSAPP_INTERACTIVE_BODY_LIMIT,
|
|
2655
2723
|
WHATSAPP_SIGNATURE_HEADER,
|
|
2656
2724
|
WHATSAPP_SIGNATURE_PREFIX,
|
|
2657
2725
|
WHATSAPP_HUB_MODE_PARAM,
|
package/src/index.ts
CHANGED
|
@@ -117,7 +117,11 @@ import type { OAuthProviderConfig } from './oauthProviders';
|
|
|
117
117
|
import { resolveAttachment } from './attachment';
|
|
118
118
|
import { isExposedResource } from './exposedResource';
|
|
119
119
|
import type { ExposableResource } from './exposedResource';
|
|
120
|
-
import {
|
|
120
|
+
import {
|
|
121
|
+
isSyncableResource,
|
|
122
|
+
isResourceSyncInProgress,
|
|
123
|
+
resourceSyncProvider
|
|
124
|
+
} from './syncableResource';
|
|
121
125
|
import type {
|
|
122
126
|
SyncableResource,
|
|
123
127
|
ResourceSyncProvider
|
|
@@ -169,6 +173,7 @@ import type {
|
|
|
169
173
|
CatalogGroup,
|
|
170
174
|
CatalogTool,
|
|
171
175
|
CatalogToolDescriptor,
|
|
176
|
+
ToolAnnotations,
|
|
172
177
|
ToolGroupKey,
|
|
173
178
|
ToolKey
|
|
174
179
|
} from './toolCatalog';
|
|
@@ -220,6 +225,21 @@ import type {
|
|
|
220
225
|
ChannelBufferEnvelope,
|
|
221
226
|
ChannelBufferFlush
|
|
222
227
|
} from './channelDebounce';
|
|
228
|
+
import {
|
|
229
|
+
isConfirmationReply,
|
|
230
|
+
confirmationButtonLabels,
|
|
231
|
+
confirmationButtonId,
|
|
232
|
+
parseConfirmationButton,
|
|
233
|
+
languageFromPhoneNumber,
|
|
234
|
+
formatConfirmationSummary,
|
|
235
|
+
annotationsForEffect,
|
|
236
|
+
TOOL_EFFECTS
|
|
237
|
+
} from './toolConfirmation';
|
|
238
|
+
import type {
|
|
239
|
+
PendingToolConfirmation,
|
|
240
|
+
PendingToolConfirmations,
|
|
241
|
+
ToolEffect
|
|
242
|
+
} from './toolConfirmation';
|
|
223
243
|
import {
|
|
224
244
|
isResourceSourceEnabled,
|
|
225
245
|
isDownloadableSource,
|
|
@@ -301,6 +321,7 @@ export const utils = {
|
|
|
301
321
|
resolveAttachment,
|
|
302
322
|
isExposedResource,
|
|
303
323
|
isSyncableResource,
|
|
324
|
+
isResourceSyncInProgress,
|
|
304
325
|
resourceSyncProvider,
|
|
305
326
|
describeVendorError,
|
|
306
327
|
withDeadline,
|
|
@@ -358,6 +379,14 @@ export const utils = {
|
|
|
358
379
|
channelBufferKey,
|
|
359
380
|
batchCommand,
|
|
360
381
|
joinBufferedMessages,
|
|
382
|
+
isConfirmationReply,
|
|
383
|
+
confirmationButtonLabels,
|
|
384
|
+
confirmationButtonId,
|
|
385
|
+
parseConfirmationButton,
|
|
386
|
+
languageFromPhoneNumber,
|
|
387
|
+
formatConfirmationSummary,
|
|
388
|
+
annotationsForEffect,
|
|
389
|
+
TOOL_EFFECTS,
|
|
361
390
|
isResourceSourceEnabled,
|
|
362
391
|
isDownloadableSource,
|
|
363
392
|
safeHostname,
|
|
@@ -386,6 +415,10 @@ export type {
|
|
|
386
415
|
CatalogGroup,
|
|
387
416
|
CatalogTool,
|
|
388
417
|
CatalogToolDescriptor,
|
|
418
|
+
ToolAnnotations,
|
|
419
|
+
PendingToolConfirmation,
|
|
420
|
+
PendingToolConfirmations,
|
|
421
|
+
ToolEffect,
|
|
389
422
|
ToolGroupKey,
|
|
390
423
|
ToolKey,
|
|
391
424
|
CalendarConfigField,
|
package/src/schema.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { isAllowedLlmBaseUrl, requiresLlmBaseUrl } from './llmBaseUrl';
|
|
|
5
5
|
import { isReservedSlug, isValidSlugFormat } from './slug';
|
|
6
6
|
import { isReservedToolName } from './reservedToolName';
|
|
7
7
|
import { slugifyTitle } from './slugifyTitle';
|
|
8
|
+
import { TOOL_EFFECTS } from './toolConfirmation';
|
|
8
9
|
|
|
9
10
|
// A prompt title becomes a slash command; it must not collide with a command
|
|
10
11
|
// the channel runner handles itself (e.g. `/link`).
|
|
@@ -43,11 +44,20 @@ const ORGANIZATION_CREATE_VIEW = z.object({
|
|
|
43
44
|
projectDescription: z.string().max(500)
|
|
44
45
|
});
|
|
45
46
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
// Each setting is saved on its own — the name from its form, the switches the
|
|
48
|
+
// moment they're flipped — so every field is optional, but not all at once.
|
|
49
|
+
const ORGANIZATION_UPDATE = z
|
|
50
|
+
.object({
|
|
51
|
+
id: z.uuid(),
|
|
52
|
+
userId: z.uuid(),
|
|
53
|
+
name: z.string().min(3).max(100).optional(),
|
|
54
|
+
requireToolConfirmation: z.boolean().optional()
|
|
55
|
+
})
|
|
56
|
+
.refine(
|
|
57
|
+
value =>
|
|
58
|
+
value.name !== undefined || value.requireToolConfirmation !== undefined,
|
|
59
|
+
{ message: 'Nothing to update' }
|
|
60
|
+
);
|
|
51
61
|
|
|
52
62
|
const ORGANIZATION_GET = z.object({
|
|
53
63
|
id: z.uuid(),
|
|
@@ -851,6 +861,12 @@ const HTTP_ENDPOINT_CONFIG = z
|
|
|
851
861
|
contentType: constants.HTTP_ENDPOINT_RESPONSE_CONTENT_TYPE_AUTO,
|
|
852
862
|
maxBytes: constants.HTTP_ENDPOINT_DEFAULT_MAX_BYTES
|
|
853
863
|
}),
|
|
864
|
+
// What the endpoint does, as its owner says (`read`, `write` or
|
|
865
|
+
// `sensitive`). Absent means the method decides: GET and HEAD read,
|
|
866
|
+
// anything else is sensitive — right for most, but a POST that only
|
|
867
|
+
// searches needs its owner to say so, or a confirming channel asks
|
|
868
|
+
// before every search.
|
|
869
|
+
effect: z.enum(TOOL_EFFECTS).optional(),
|
|
854
870
|
// Auth — credentials referenced by id, never inlined.
|
|
855
871
|
auth: HTTP_ENDPOINT_AUTH.default({
|
|
856
872
|
kind: constants.HTTP_ENDPOINT_AUTH_KIND_NONE
|
|
@@ -1056,6 +1072,19 @@ const CUSTOM_CODE_CONFIG_WRITE = CUSTOM_CODE_CONFIG.superRefine((cfg, ctx) => {
|
|
|
1056
1072
|
// that version registering, and the owner would see tools quietly disappear with
|
|
1057
1073
|
// only a log line to explain it. Reservation belongs to the manifest below,
|
|
1058
1074
|
// which is the write path.
|
|
1075
|
+
// MCP tool annotations as a tool's author declares them — every hint optional,
|
|
1076
|
+
// with the spec's own defaults for what is left out. The channel runner reads
|
|
1077
|
+
// them to decide what to confirm, so a tool that doesn't say it is read-only
|
|
1078
|
+
// is treated as one that may change things.
|
|
1079
|
+
const TOOL_ANNOTATIONS = z
|
|
1080
|
+
.object({
|
|
1081
|
+
readOnlyHint: z.boolean().optional(),
|
|
1082
|
+
destructiveHint: z.boolean().optional(),
|
|
1083
|
+
idempotentHint: z.boolean().optional(),
|
|
1084
|
+
openWorldHint: z.boolean().optional()
|
|
1085
|
+
})
|
|
1086
|
+
.strict();
|
|
1087
|
+
|
|
1059
1088
|
const CUSTOM_CODE_TOOL = z.object({
|
|
1060
1089
|
name: z
|
|
1061
1090
|
.string()
|
|
@@ -1068,7 +1097,8 @@ const CUSTOM_CODE_TOOL = z.object({
|
|
|
1068
1097
|
title: z.string().min(1).max(200).optional(),
|
|
1069
1098
|
description: z.string().max(2000).optional(),
|
|
1070
1099
|
inputSchema: SCHEMA_DEFINITION.default({ type: 'object', properties: {} }),
|
|
1071
|
-
outputSchema: SCHEMA_DEFINITION.optional()
|
|
1100
|
+
outputSchema: SCHEMA_DEFINITION.optional(),
|
|
1101
|
+
annotations: TOOL_ANNOTATIONS.optional()
|
|
1072
1102
|
});
|
|
1073
1103
|
|
|
1074
1104
|
// The manifest a `ganju deploy` (or the dashboard) uploads alongside a bundle.
|
|
@@ -1648,6 +1678,7 @@ export const Schema = {
|
|
|
1648
1678
|
CUSTOM_CODE_CONFIG,
|
|
1649
1679
|
CUSTOM_CODE_CONFIG_WRITE,
|
|
1650
1680
|
CUSTOM_CODE_TOOL,
|
|
1681
|
+
TOOL_ANNOTATIONS,
|
|
1651
1682
|
CUSTOM_CODE_MANIFEST,
|
|
1652
1683
|
ARTIFACT_CUSTOM_CODE_CREATE_VERSION,
|
|
1653
1684
|
ARTIFACT_CUSTOM_CODE_UPLOAD_BUNDLE,
|
|
@@ -1707,6 +1738,10 @@ export interface McpProxyDiscoveredTool {
|
|
|
1707
1738
|
title?: string;
|
|
1708
1739
|
description?: string;
|
|
1709
1740
|
inputSchema: unknown;
|
|
1741
|
+
// The remote's own annotations, passed through at registration. Absent on
|
|
1742
|
+
// discoveries made before they were recorded — the tool then counts as one
|
|
1743
|
+
// that may change things until the server is discovered again.
|
|
1744
|
+
annotations?: z.infer<typeof TOOL_ANNOTATIONS>;
|
|
1710
1745
|
}
|
|
1711
1746
|
|
|
1712
1747
|
// One remote resource discovered from a proxied MCP server. Identified by uri;
|
package/src/syncableResource.ts
CHANGED
|
@@ -46,3 +46,23 @@ export const resourceSyncProvider = (
|
|
|
46
46
|
|
|
47
47
|
export const isSyncableResource = (resource: SyncableResource): boolean =>
|
|
48
48
|
resourceSyncProvider(resource) !== null;
|
|
49
|
+
|
|
50
|
+
// Whether a sync root is still genuinely syncing: PENDING, and started within
|
|
51
|
+
// RESOURCE_SYNC_STALE_MS. Past that, a PENDING root is stuck — a job was lost
|
|
52
|
+
// or gave up — and may be synced again, which re-queues whatever it left
|
|
53
|
+
// unfinished. A root that never synced counts from its creation, so a first
|
|
54
|
+
// import that stalled recovers too.
|
|
55
|
+
export const isResourceSyncInProgress = (
|
|
56
|
+
resource: {
|
|
57
|
+
status: string;
|
|
58
|
+
syncStartedAt?: Date | string | null;
|
|
59
|
+
createdAt: Date | string;
|
|
60
|
+
},
|
|
61
|
+
now: number = Date.now()
|
|
62
|
+
): boolean => {
|
|
63
|
+
if (resource.status !== constants.STATUS_PENDING) return false;
|
|
64
|
+
const startedAt = new Date(
|
|
65
|
+
resource.syncStartedAt ?? resource.createdAt
|
|
66
|
+
).getTime();
|
|
67
|
+
return now - startedAt < constants.RESOURCE_SYNC_STALE_MS;
|
|
68
|
+
};
|