@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/src/constants.ts CHANGED
@@ -278,6 +278,43 @@ const RESOURCE_SOURCE_TYPES = [
278
278
  RESOURCE_SOURCE_TYPE_CUSTOM_CODE
279
279
  ];
280
280
 
281
+ // How often a source that lives somewhere else — a crawled website, a Drive or
282
+ // OneDrive folder, a file imported from either — is refreshed from it. Every
283
+ // source is stored with an interval, weekly unless its owner changes it; the
284
+ // hourly sync job only acts on it for a plan that includes automatic sync, so a
285
+ // Free org's sources start syncing the moment it upgrades.
286
+ const RESOURCE_SYNC_INTERVAL_OFF = 'off' as 'off';
287
+ const RESOURCE_SYNC_INTERVAL_DAILY = 'daily' as 'daily';
288
+ const RESOURCE_SYNC_INTERVAL_WEEKLY = 'weekly' as 'weekly';
289
+ const RESOURCE_SYNC_INTERVALS = [
290
+ RESOURCE_SYNC_INTERVAL_OFF,
291
+ RESOURCE_SYNC_INTERVAL_DAILY,
292
+ RESOURCE_SYNC_INTERVAL_WEEKLY
293
+ ] as const;
294
+ const RESOURCE_SYNC_INTERVAL_DEFAULT = RESOURCE_SYNC_INTERVAL_WEEKLY;
295
+ const RESOURCE_SYNC_INTERVAL_MS = {
296
+ [RESOURCE_SYNC_INTERVAL_DAILY]: 24 * 60 * 60 * 1000,
297
+ [RESOURCE_SYNC_INTERVAL_WEEKLY]: 7 * 24 * 60 * 60 * 1000
298
+ } as const;
299
+ // Sources started per hourly run. A sync is cheap to start and expensive to
300
+ // run — a website re-crawls every page — so the job works through a backlog
301
+ // over several hours rather than launching all of it at once.
302
+ const RESOURCE_SYNC_BATCH_SIZE = 50;
303
+ // How soon a source can be synced by hand again. Manual sync is open to every
304
+ // plan, and a website sync re-crawls every page, so it can't be a button to
305
+ // press in a loop.
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;
317
+
281
318
  const CRAWL_RENDERER_CHEERIO = 'cheerio' as 'cheerio';
282
319
  const CRAWL_RENDERER_PLAYWRIGHT = 'playwright' as 'playwright';
283
320
  const CRAWL_RENDERERS = [CRAWL_RENDERER_CHEERIO, CRAWL_RENDERER_PLAYWRIGHT];
@@ -731,6 +768,49 @@ const CHANNEL_DEBOUNCE_JOIN = '\n';
731
768
  const CHANNEL_DEBOUNCE_RETRY_MS = 5000;
732
769
  const CHANNEL_DEBOUNCE_MAX_ATTEMPTS = 3;
733
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
+
734
814
  const TELEGRAM_SECRET_HEADER = 'x-telegram-bot-api-secret-token';
735
815
  const TELEGRAM_API_BASE = 'https://api.telegram.org';
736
816
  const TELEGRAM_MESSAGE_LIMIT = 3500;
@@ -790,8 +870,12 @@ const DISCORD_TIMESTAMP_HEADER = 'x-signature-timestamp';
790
870
  // Interaction request types and response types (Discord API).
791
871
  const DISCORD_INTERACTION_TYPE_PING = 1;
792
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;
793
875
  const DISCORD_INTERACTION_RESPONSE_PONG = 1;
794
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;
795
879
  // Channel types we map to conversation scopes: DM (1) / group DM (3) = private;
796
880
  // everything else (guild text channels, threads) = channel.
797
881
  const DISCORD_CHANNEL_TYPE_DM = 1;
@@ -820,6 +904,8 @@ const WHATSAPP_API_BASE = 'https://graph.facebook.com';
820
904
  const WHATSAPP_API_VERSION = 'v25.0';
821
905
  // A WhatsApp text message body caps at 4096 chars; we chunk a bit under that.
822
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;
823
909
  const WHATSAPP_SIGNATURE_HEADER = 'x-hub-signature-256';
824
910
  const WHATSAPP_SIGNATURE_PREFIX = 'sha256=';
825
911
  // GET-handshake query params + the expected hub.mode value.
@@ -850,6 +936,25 @@ const CHUNK_TARGET_CHARS = 2000;
850
936
  const CHUNK_OVERLAP_CHARS = 200;
851
937
  const EMBED_BATCH_SIZE = 96;
852
938
 
939
+ // Hybrid resource search. Each ranker contributes its top candidates, and the
940
+ // two lists are merged with reciprocal rank fusion: score = sum of 1/(k + rank).
941
+ // Fusion reads ranks, never raw scores, so a cosine distance and a ts_rank never
942
+ // have to be put on one scale. k = 60 is the constant from the original RRF
943
+ // paper; it keeps one list's #1 from outweighing a chunk both lists agree on.
944
+ const RESOURCE_SEARCH_CANDIDATES = 50;
945
+ const RESOURCE_SEARCH_RRF_K = 60;
946
+ // A query word found in more than 1 of every 10 chunks of the artifact is
947
+ // dropped from the lexical query — never below 5 chunks, so a small artifact
948
+ // keeps its words.
949
+ const RESOURCE_SEARCH_COMMON_TERM_DIVISOR = 10;
950
+ const RESOURCE_SEARCH_COMMON_TERM_MIN = 5;
951
+ // Second stage, on wherever the Worker has an AI binding: the top fused
952
+ // candidates are re-scored by a cross-encoder, which reads query and chunk
953
+ // together instead of comparing two independently computed vectors. One model
954
+ // call per search; a failure falls back to the fused order.
955
+ const RESOURCE_SEARCH_RERANK_CANDIDATES = 30;
956
+ const RESOURCE_SEARCH_RERANK_MODEL = '@cf/baai/bge-reranker-base';
957
+
853
958
  const RESOURCE_HANDLER_SLEEP_AFTER = '10m';
854
959
 
855
960
  const DOCS_URL = 'https://docs.ganju.ai';
@@ -1207,8 +1312,7 @@ const MCP_PROXY_TOOL_NAME_MAX = 64;
1207
1312
  //
1208
1313
  // The row's config holds only `activeVersionId` plus the egress/limit settings;
1209
1314
  // names and schemas live on the version. That's what keeps MCP boot off the
1210
- // dispatcher — a slow or broken script must never break tools/list. See
1211
- // docs/CUSTOM_TOOLS.md ("Boot contract").
1315
+ // dispatcher — a slow or broken script must never break tools/list.
1212
1316
  const TOOL_DEFINITION_KEY_CUSTOM_CODE = 'custom-code';
1213
1317
 
1214
1318
  // A version is uploaded as `draft`, becomes `published` when it is made active,
@@ -1996,6 +2100,12 @@ interface PlanLimits {
1996
2100
  // against). Not used for blocking.
1997
2101
  includedMessages: number;
1998
2102
  includedEmbeddedBytes: number;
2103
+ // The intervals the hourly job will sync a source on. Empty = no automatic
2104
+ // sync: the org can still sync a source by hand. Refreshing costs crawl or
2105
+ // Drive fetches plus embeddings for whatever changed, all on our side.
2106
+ autoSyncIntervals: ReadonlyArray<
2107
+ typeof RESOURCE_SYNC_INTERVAL_DAILY | typeof RESOURCE_SYNC_INTERVAL_WEEKLY
2108
+ >;
1999
2109
  }
2000
2110
 
2001
2111
  const PLAN_LIMITS: Record<
@@ -2035,7 +2145,8 @@ const PLAN_LIMITS: Record<
2035
2145
  includedToolCalls: 0,
2036
2146
  toolCallHardCap: 10_000,
2037
2147
  includedMessages: 100,
2038
- includedEmbeddedBytes: 5 * MB
2148
+ includedEmbeddedBytes: 5 * MB,
2149
+ autoSyncIntervals: []
2039
2150
  },
2040
2151
  PRO: {
2041
2152
  maxProjects: null,
@@ -2058,7 +2169,11 @@ const PLAN_LIMITS: Record<
2058
2169
  includedToolCalls: PRICING_INCLUDED_TOOL_CALLS,
2059
2170
  toolCallHardCap: PRICING_TOOL_CALL_HARD_CAP,
2060
2171
  includedMessages: PRICING_INCLUDED_MESSAGES,
2061
- includedEmbeddedBytes: PRICING_INCLUDED_EMBEDDED_GB * GB
2172
+ includedEmbeddedBytes: PRICING_INCLUDED_EMBEDDED_GB * GB,
2173
+ autoSyncIntervals: [
2174
+ RESOURCE_SYNC_INTERVAL_DAILY,
2175
+ RESOURCE_SYNC_INTERVAL_WEEKLY
2176
+ ]
2062
2177
  },
2063
2178
  ENTERPRISE: {
2064
2179
  maxProjects: null,
@@ -2081,7 +2196,11 @@ const PLAN_LIMITS: Record<
2081
2196
  // contract; don't make one plan's abuse limit another plan's product limit.
2082
2197
  toolCallHardCap: null,
2083
2198
  includedMessages: PRICING_INCLUDED_MESSAGES,
2084
- includedEmbeddedBytes: PRICING_INCLUDED_EMBEDDED_GB * GB
2199
+ includedEmbeddedBytes: PRICING_INCLUDED_EMBEDDED_GB * GB,
2200
+ autoSyncIntervals: [
2201
+ RESOURCE_SYNC_INTERVAL_DAILY,
2202
+ RESOURCE_SYNC_INTERVAL_WEEKLY
2203
+ ]
2085
2204
  }
2086
2205
  };
2087
2206
 
@@ -2100,6 +2219,7 @@ const PLAN_FEATURE_RAW_STORAGE = 'rawStorage' as 'rawStorage';
2100
2219
  const PLAN_FEATURE_EMBEDDED_STORAGE = 'embeddedStorage' as 'embeddedStorage';
2101
2220
  const PLAN_FEATURE_MESSAGE = 'message' as 'message';
2102
2221
  const PLAN_FEATURE_TOOL_CALL = 'toolCall' as 'toolCall';
2222
+ const PLAN_FEATURE_RESOURCE_SYNC = 'resourceSync' as 'resourceSync';
2103
2223
 
2104
2224
  // Stable code returned on a quota block so clients can branch on it (402).
2105
2225
  const PLAN_LIMIT_ERROR_CODE = 'PLAN_LIMIT_EXCEEDED';
@@ -2282,6 +2402,7 @@ export const constants = {
2282
2402
  PLAN_FEATURE_EMBEDDED_STORAGE,
2283
2403
  PLAN_FEATURE_MESSAGE,
2284
2404
  PLAN_FEATURE_TOOL_CALL,
2405
+ PLAN_FEATURE_RESOURCE_SYNC,
2285
2406
  PLAN_LIMIT_ERROR_CODE,
2286
2407
  BILLING_METER_MESSAGES,
2287
2408
  BILLING_METER_SHARED_MESSAGES,
@@ -2476,6 +2597,15 @@ export const constants = {
2476
2597
  RESOURCE_SOURCE_TYPE_ONE_DRIVE_FOLDER,
2477
2598
  RESOURCE_SOURCE_TYPE_CUSTOM_CODE,
2478
2599
  RESOURCE_SOURCE_TYPES,
2600
+ RESOURCE_SYNC_INTERVAL_OFF,
2601
+ RESOURCE_SYNC_INTERVAL_DAILY,
2602
+ RESOURCE_SYNC_INTERVAL_WEEKLY,
2603
+ RESOURCE_SYNC_INTERVALS,
2604
+ RESOURCE_SYNC_INTERVAL_DEFAULT,
2605
+ RESOURCE_SYNC_INTERVAL_MS,
2606
+ RESOURCE_SYNC_BATCH_SIZE,
2607
+ RESOURCE_SYNC_MANUAL_COOLDOWN_MS,
2608
+ RESOURCE_SYNC_STALE_MS,
2479
2609
  CRAWL_RENDERER_CHEERIO,
2480
2610
  CRAWL_RENDERER_PLAYWRIGHT,
2481
2611
  CRAWL_RENDERERS,
@@ -2534,6 +2664,11 @@ export const constants = {
2534
2664
  SHARED_KEY_HISTORY_LIMIT,
2535
2665
  SHARED_KEY_MAX_TOOL_LOOPS,
2536
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,
2537
2672
  CHANNEL_DEBOUNCE_DEFAULT_MS,
2538
2673
  CHANNEL_DEBOUNCE_MIN_MS,
2539
2674
  CHANNEL_DEBOUNCE_MAX_MS,
@@ -2566,8 +2701,10 @@ export const constants = {
2566
2701
  DISCORD_TIMESTAMP_HEADER,
2567
2702
  DISCORD_INTERACTION_TYPE_PING,
2568
2703
  DISCORD_INTERACTION_TYPE_APPLICATION_COMMAND,
2704
+ DISCORD_INTERACTION_TYPE_MESSAGE_COMPONENT,
2569
2705
  DISCORD_INTERACTION_RESPONSE_PONG,
2570
2706
  DISCORD_INTERACTION_RESPONSE_DEFERRED,
2707
+ DISCORD_INTERACTION_RESPONSE_UPDATE_MESSAGE,
2571
2708
  DISCORD_CHANNEL_TYPE_DM,
2572
2709
  DISCORD_CHANNEL_TYPE_GROUP_DM,
2573
2710
  DISCORD_GATEWAY_OP_DISPATCH,
@@ -2582,6 +2719,7 @@ export const constants = {
2582
2719
  WHATSAPP_API_BASE,
2583
2720
  WHATSAPP_API_VERSION,
2584
2721
  WHATSAPP_MESSAGE_LIMIT,
2722
+ WHATSAPP_INTERACTIVE_BODY_LIMIT,
2585
2723
  WHATSAPP_SIGNATURE_HEADER,
2586
2724
  WHATSAPP_SIGNATURE_PREFIX,
2587
2725
  WHATSAPP_HUB_MODE_PARAM,
@@ -2597,6 +2735,12 @@ export const constants = {
2597
2735
  CHUNK_TARGET_CHARS,
2598
2736
  CHUNK_OVERLAP_CHARS,
2599
2737
  EMBED_BATCH_SIZE,
2738
+ RESOURCE_SEARCH_CANDIDATES,
2739
+ RESOURCE_SEARCH_RRF_K,
2740
+ RESOURCE_SEARCH_COMMON_TERM_DIVISOR,
2741
+ RESOURCE_SEARCH_COMMON_TERM_MIN,
2742
+ RESOURCE_SEARCH_RERANK_CANDIDATES,
2743
+ RESOURCE_SEARCH_RERANK_MODEL,
2600
2744
  RESOURCE_HANDLER_SLEEP_AFTER,
2601
2745
  DOCS_URL,
2602
2746
  BASE64_DATA_URI_RE,
package/src/index.ts CHANGED
@@ -117,6 +117,15 @@ 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 {
121
+ isSyncableResource,
122
+ isResourceSyncInProgress,
123
+ resourceSyncProvider
124
+ } from './syncableResource';
125
+ import type {
126
+ SyncableResource,
127
+ ResourceSyncProvider
128
+ } from './syncableResource';
120
129
  import { describeVendorError } from './vendorError';
121
130
  import { withDeadline, isDeadlineError } from './deadline';
122
131
  import {
@@ -164,6 +173,7 @@ import type {
164
173
  CatalogGroup,
165
174
  CatalogTool,
166
175
  CatalogToolDescriptor,
176
+ ToolAnnotations,
167
177
  ToolGroupKey,
168
178
  ToolKey
169
179
  } from './toolCatalog';
@@ -186,6 +196,8 @@ import {
186
196
  import type { Separator, ChunkMetadata, PreparedChunk } from './chunking';
187
197
  import { isEmbeddableMimeType } from './embeddable';
188
198
  import { l2Normalize } from './l2Normalize';
199
+ import { createResourceReranker } from './resourceRerank';
200
+ import type { RerankAiBinding } from './resourceRerank';
189
201
  import { sleep, isRateLimitError, withRateLimitRetry } from './retry';
190
202
  import type { RateLimitRetryOptions } from './retry';
191
203
  import { processQueueBatch } from './processQueueBatch';
@@ -204,13 +216,30 @@ import type { ChannelNotifier, ToolStatusEvent } from './channelNotifier';
204
216
  import {
205
217
  resolveDebounceMs,
206
218
  channelBufferKey,
207
- joinBufferedMessages
219
+ joinBufferedMessages,
220
+ batchCommand
208
221
  } from './channelDebounce';
209
222
  import type {
210
223
  BufferedChannelMessage,
224
+ BufferedChannelCommand,
211
225
  ChannelBufferEnvelope,
212
226
  ChannelBufferFlush
213
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';
214
243
  import {
215
244
  isResourceSourceEnabled,
216
245
  isDownloadableSource,
@@ -291,6 +320,9 @@ export const utils = {
291
320
  oauthProviders,
292
321
  resolveAttachment,
293
322
  isExposedResource,
323
+ isSyncableResource,
324
+ isResourceSyncInProgress,
325
+ resourceSyncProvider,
294
326
  describeVendorError,
295
327
  withDeadline,
296
328
  isDeadlineError,
@@ -337,6 +369,7 @@ export const utils = {
337
369
  prepareChunks,
338
370
  isEmbeddableMimeType,
339
371
  l2Normalize,
372
+ createResourceReranker,
340
373
  sleep,
341
374
  isRateLimitError,
342
375
  withRateLimitRetry,
@@ -344,7 +377,16 @@ export const utils = {
344
377
  getToolStatusMessage,
345
378
  resolveDebounceMs,
346
379
  channelBufferKey,
380
+ batchCommand,
347
381
  joinBufferedMessages,
382
+ isConfirmationReply,
383
+ confirmationButtonLabels,
384
+ confirmationButtonId,
385
+ parseConfirmationButton,
386
+ languageFromPhoneNumber,
387
+ formatConfirmationSummary,
388
+ annotationsForEffect,
389
+ TOOL_EFFECTS,
348
390
  isResourceSourceEnabled,
349
391
  isDownloadableSource,
350
392
  safeHostname,
@@ -373,6 +415,10 @@ export type {
373
415
  CatalogGroup,
374
416
  CatalogTool,
375
417
  CatalogToolDescriptor,
418
+ ToolAnnotations,
419
+ PendingToolConfirmation,
420
+ PendingToolConfirmations,
421
+ ToolEffect,
376
422
  ToolGroupKey,
377
423
  ToolKey,
378
424
  CalendarConfigField,
@@ -407,12 +453,14 @@ export type {
407
453
  ExtractedDocumentMetadata,
408
454
  ExtractedDocumentSource,
409
455
  RateLimitRetryOptions,
456
+ RerankAiBinding,
410
457
  QueueBatchLike,
411
458
  QueueMessageLike,
412
459
  ProcessQueueBatchHandlers,
413
460
  ChannelNotifier,
414
461
  ToolStatusEvent,
415
462
  BufferedChannelMessage,
463
+ BufferedChannelCommand,
416
464
  ChannelBufferEnvelope,
417
465
  ChannelBufferFlush,
418
466
  Source,
@@ -438,6 +486,8 @@ export type {
438
486
  ResolvedAttachment,
439
487
  ResolveAttachmentResult,
440
488
  ExposableResource,
489
+ SyncableResource,
490
+ ResourceSyncProvider,
441
491
  PlanLimitDetails,
442
492
  PlanLimits
443
493
  };
@@ -0,0 +1,54 @@
1
+ import { constants } from './constants';
2
+ import type { EnvSource } from './getEnv';
3
+
4
+ // The slice of the Workers AI binding the reranker calls. Declared here rather
5
+ // than imported so this package doesn't take a dependency on workers-types.
6
+ export interface RerankAiBinding {
7
+ run(
8
+ model: string,
9
+ input: { query: string; contexts: Array<{ text: string }>; top_k?: number }
10
+ ): Promise<unknown>;
11
+ }
12
+
13
+ type RerankResponse = {
14
+ response?: Array<{ id?: number; score?: number }>;
15
+ };
16
+
17
+ /**
18
+ * The cross-encoder second stage for resource search, or undefined when the
19
+ * Worker has no `AI` binding — local dev without Workers AI keeps plain fusion.
20
+ *
21
+ * Returns one score per document, in input order. A response that doesn't
22
+ * cover every document returns null, which keeps the fused order — a partial
23
+ * ranking would silently drop the chunks it left out.
24
+ */
25
+ export const createResourceReranker = (
26
+ source: EnvSource
27
+ ):
28
+ | ((query: string, documents: string[]) => Promise<number[] | null>)
29
+ | undefined => {
30
+ const ai = (source.env as { AI?: RerankAiBinding } | undefined)?.AI;
31
+ if (!ai) return undefined;
32
+
33
+ return async (query, documents) => {
34
+ const result = (await ai.run(constants.RESOURCE_SEARCH_RERANK_MODEL, {
35
+ query,
36
+ contexts: documents.map(text => ({ text })),
37
+ top_k: documents.length
38
+ })) as RerankResponse;
39
+
40
+ const scores: Array<number | undefined> = new Array(documents.length);
41
+ for (const item of result?.response ?? []) {
42
+ if (
43
+ typeof item.id === 'number' &&
44
+ typeof item.score === 'number' &&
45
+ item.id >= 0 &&
46
+ item.id < documents.length
47
+ ) {
48
+ scores[item.id] = item.score;
49
+ }
50
+ }
51
+ if (scores.some(score => score === undefined)) return null;
52
+ return scores as number[];
53
+ };
54
+ };
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
- const ORGANIZATION_UPDATE = z.object({
47
- id: z.uuid(),
48
- userId: z.uuid(),
49
- name: z.string().min(3).max(100)
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.
@@ -1449,6 +1479,21 @@ const ARTIFACT_UPDATE_RESOURCE_SHOW_SOURCE = z.object({
1449
1479
  organizationId: z.uuid()
1450
1480
  });
1451
1481
 
1482
+ const ARTIFACT_UPDATE_RESOURCE_SYNC_INTERVAL = z.object({
1483
+ resourceId: z.uuid(),
1484
+ syncInterval: z.enum(constants.RESOURCE_SYNC_INTERVALS),
1485
+ projectId: z.uuid(),
1486
+ userId: z.uuid(),
1487
+ organizationId: z.uuid()
1488
+ });
1489
+
1490
+ const ARTIFACT_SYNC_RESOURCE = z.object({
1491
+ resourceId: z.uuid(),
1492
+ projectId: z.uuid(),
1493
+ userId: z.uuid(),
1494
+ organizationId: z.uuid()
1495
+ });
1496
+
1452
1497
  const ARTIFACT_GET = z.object({
1453
1498
  projectId: z.uuid(),
1454
1499
  userId: z.uuid(),
@@ -1625,12 +1670,15 @@ export const Schema = {
1625
1670
  CHANNEL_LIST_CONVERSATIONS,
1626
1671
  CHANNEL_LIST_MESSAGES,
1627
1672
  ARTIFACT_UPDATE_RESOURCE_SHOW_SOURCE,
1673
+ ARTIFACT_UPDATE_RESOURCE_SYNC_INTERVAL,
1674
+ ARTIFACT_SYNC_RESOURCE,
1628
1675
  HTTP_ENDPOINT_CONFIG,
1629
1676
  HTTP_ENDPOINT_CONFIG_WRITE,
1630
1677
  MCP_PROXY_CONFIG,
1631
1678
  CUSTOM_CODE_CONFIG,
1632
1679
  CUSTOM_CODE_CONFIG_WRITE,
1633
1680
  CUSTOM_CODE_TOOL,
1681
+ TOOL_ANNOTATIONS,
1634
1682
  CUSTOM_CODE_MANIFEST,
1635
1683
  ARTIFACT_CUSTOM_CODE_CREATE_VERSION,
1636
1684
  ARTIFACT_CUSTOM_CODE_UPLOAD_BUNDLE,
@@ -1690,6 +1738,10 @@ export interface McpProxyDiscoveredTool {
1690
1738
  title?: string;
1691
1739
  description?: string;
1692
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>;
1693
1745
  }
1694
1746
 
1695
1747
  // One remote resource discovered from a proxied MCP server. Identified by uri;
@@ -0,0 +1,68 @@
1
+ import { constants } from './constants';
2
+
3
+ // Which resources are sync roots: the rows a sync starts from and records its
4
+ // schedule on.
5
+ //
6
+ // - a WEBSITE seed (the row with no parent) — a sync re-crawls the site;
7
+ // - a Drive/OneDrive folder — a sync walks it for new, changed and removed
8
+ // files;
9
+ // - a Drive/OneDrive file imported on its own — a sync re-fetches it when it
10
+ // changed.
11
+ //
12
+ // A page under a website or a file under a folder is not a root: its parent's
13
+ // sync covers it. An uploaded file has nothing to sync from.
14
+ export interface SyncableResource {
15
+ sourceType?: string | null;
16
+ parentResourceId?: string | null;
17
+ metadata?: unknown;
18
+ }
19
+
20
+ export type ResourceSyncProvider = 'website' | 'google-drive' | 'one-drive';
21
+
22
+ export const resourceSyncProvider = (
23
+ resource: SyncableResource
24
+ ): ResourceSyncProvider | null => {
25
+ if (resource.parentResourceId) return null;
26
+ switch (resource.sourceType) {
27
+ case constants.RESOURCE_SOURCE_TYPE_WEBSITE:
28
+ return 'website';
29
+ case constants.RESOURCE_SOURCE_TYPE_GOOGLE_DRIVE_FOLDER:
30
+ return 'google-drive';
31
+ case constants.RESOURCE_SOURCE_TYPE_ONE_DRIVE_FOLDER:
32
+ return 'one-drive';
33
+ case constants.RESOURCE_SOURCE_TYPE_FILE: {
34
+ const meta =
35
+ resource.metadata && typeof resource.metadata === 'object'
36
+ ? (resource.metadata as Record<string, unknown>)
37
+ : null;
38
+ if (typeof meta?.driveFileId === 'string') return 'google-drive';
39
+ if (typeof meta?.oneDriveItemId === 'string') return 'one-drive';
40
+ return null;
41
+ }
42
+ default:
43
+ return null;
44
+ }
45
+ };
46
+
47
+ export const isSyncableResource = (resource: SyncableResource): boolean =>
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
+ };