@ganju/utils 0.0.9 → 0.0.10

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/chunking.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { constants } from './constants';
2
+ import { decodeEntities } from './sanitize';
2
3
 
3
4
  import type {
4
5
  ExtractedDocument,
@@ -50,24 +51,140 @@ export const splitRecursive = (
50
51
  return chunks;
51
52
  };
52
53
 
53
- export const chunkText = (
54
+ // Each piece keeps its `raw` text — the slice of the input it covers, without
55
+ // the overlap borrowed from the piece before — so a caller can walk the input
56
+ // in order without reading any of it twice.
57
+ const chunkPieces = (
54
58
  text: string,
55
59
  options?: { targetChars?: number; overlapChars?: number }
56
- ): string[] => {
60
+ ): Array<{ raw: string; content: string }> => {
57
61
  const target = options?.targetChars ?? constants.CHUNK_TARGET_CHARS;
58
62
  const overlap = options?.overlapChars ?? constants.CHUNK_OVERLAP_CHARS;
59
63
 
60
64
  const trimmed = text.trim();
61
65
  if (!trimmed) return [];
62
- if (trimmed.length <= target) return [trimmed];
66
+ if (trimmed.length <= target) return [{ raw: trimmed, content: trimmed }];
63
67
 
64
68
  const rawChunks = splitRecursive(trimmed, target);
65
- const withOverlap: string[] = [];
69
+ const pieces: Array<{ raw: string; content: string }> = [];
66
70
  for (let i = 0; i < rawChunks.length; i++) {
67
71
  const prev = i > 0 ? rawChunks[i - 1].slice(-overlap) : '';
68
- withOverlap.push((prev ? prev + '\n' : '') + rawChunks[i]);
72
+ pieces.push({
73
+ raw: rawChunks[i],
74
+ content: (prev ? prev + '\n' : '') + rawChunks[i]
75
+ });
76
+ }
77
+ return pieces.filter(p => p.content.trim().length > 0);
78
+ };
79
+
80
+ export const chunkText = (
81
+ text: string,
82
+ options?: { targetChars?: number; overlapChars?: number }
83
+ ): string[] => chunkPieces(text, options).map(p => p.content);
84
+
85
+ export type HeadingSyntax = 'markdown' | 'html';
86
+
87
+ interface HeadingEvent {
88
+ offset: number;
89
+ level: number;
90
+ text: string;
91
+ }
92
+
93
+ const MARKDOWN_HEADING = /^ {0,3}(#{1,6})[ \t]+(.+?)(?:[ \t]+#+)?[ \t]*$/;
94
+ const MARKDOWN_FENCE = /^ {0,3}(```|~~~)/;
95
+ const HTML_HEADING = /<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1\s*>/gi;
96
+ const MAX_HEADING_CHARS = 120;
97
+
98
+ const MARKDOWN_FILE_NAME = /\.(md|markdown|mdx)$/i;
99
+
100
+ /**
101
+ * Which heading syntax a document is written in, or null when it has none we
102
+ * can trust. Plain text is left out on purpose: a line that happens to start
103
+ * with "# " there is as likely a comment or a list as a title.
104
+ */
105
+ export const headingSyntaxFor = (input: {
106
+ mimeType?: string | null;
107
+ fileName?: string | null;
108
+ isWebPage?: boolean;
109
+ }): HeadingSyntax | null => {
110
+ // Crawled pages are flattened to text with their headings written back as
111
+ // Markdown lines, whatever the page's own content type was.
112
+ if (input.isWebPage) return 'markdown';
113
+ if (input.mimeType === constants.MIMETYPE_TEXT_MARKDOWN) return 'markdown';
114
+ if (input.mimeType === constants.MIMETYPE_TEXT_HTML) return 'html';
115
+ if (input.fileName && MARKDOWN_FILE_NAME.test(input.fileName)) {
116
+ return 'markdown';
117
+ }
118
+ return null;
119
+ };
120
+
121
+ const cleanHeading = (text: string): string =>
122
+ text.replace(/\s+/g, ' ').trim().slice(0, MAX_HEADING_CHARS);
123
+
124
+ const scanMarkdownHeadings = (
125
+ text: string,
126
+ state: { inFence: boolean }
127
+ ): HeadingEvent[] => {
128
+ const events: HeadingEvent[] = [];
129
+ let offset = 0;
130
+ for (const line of text.split('\n')) {
131
+ // A `#` inside a fenced block is a shell comment or a preprocessor line,
132
+ // not a title — and a fence can open in one chunk and close in the next.
133
+ if (MARKDOWN_FENCE.test(line)) {
134
+ state.inFence = !state.inFence;
135
+ } else if (!state.inFence) {
136
+ const match = MARKDOWN_HEADING.exec(line);
137
+ const heading = match ? cleanHeading(match[2]) : '';
138
+ if (match && heading) {
139
+ events.push({ offset, level: match[1].length, text: heading });
140
+ }
141
+ }
142
+ offset += line.length + 1;
69
143
  }
70
- return withOverlap.filter(c => c.trim().length > 0);
144
+ return events;
145
+ };
146
+
147
+ const scanHtmlHeadings = (text: string): HeadingEvent[] =>
148
+ [...text.matchAll(HTML_HEADING)]
149
+ .map(match => ({
150
+ offset: match.index ?? 0,
151
+ level: Number(match[1]),
152
+ text: cleanHeading(decodeEntities(match[2].replace(/<[^>]*>/g, ' ')))
153
+ }))
154
+ .filter(event => event.text);
155
+
156
+ /**
157
+ * Follows the heading hierarchy across a document's chunks, fed in order.
158
+ * Returns, for each chunk, the path of headings it sits under — the section a
159
+ * reader would be pointed to. A chunk that opens with a heading belongs to that
160
+ * heading; one that starts mid-section belongs to the section it continues,
161
+ * even when a later heading begins inside it.
162
+ */
163
+ const createHeadingTracker = (syntax: HeadingSyntax) => {
164
+ let stack: Array<{ level: number; text: string }> = [];
165
+ const fence = { inFence: false };
166
+ const apply = (event: HeadingEvent) => {
167
+ stack = stack.filter(h => h.level < event.level);
168
+ stack.push({ level: event.level, text: event.text });
169
+ };
170
+
171
+ return (raw: string): string[] => {
172
+ const events =
173
+ syntax === 'markdown'
174
+ ? scanMarkdownHeadings(raw, fence)
175
+ : scanHtmlHeadings(raw);
176
+ const [first, ...rest] = events;
177
+ // Before a document's first heading there is no section to continue — only
178
+ // the metadata header, or the markup around an HTML body — so a chunk that
179
+ // holds the first heading belongs to it wherever in the chunk it falls.
180
+ const opensWithHeading =
181
+ first !== undefined &&
182
+ (stack.length === 0 || raw.slice(0, first.offset).trim() === '');
183
+ if (opensWithHeading) apply(first);
184
+ const path = stack.map(h => h.text);
185
+ for (const event of opensWithHeading ? rest : events) apply(event);
186
+ return path;
187
+ };
71
188
  };
72
189
 
73
190
  export const buildHeader = (input: {
@@ -87,9 +204,14 @@ export const buildHeader = (input: {
87
204
  .filter(Boolean)
88
205
  .join('\n');
89
206
 
90
- export interface ChunkMetadata extends ExtractedDocumentMetadata {
91
- chunkIndexInPage: number;
92
- chunksInPage: number;
207
+ // Loose on purpose: a chunk of inline text has no extracted document behind it,
208
+ // so only `headingPath` can be known about it.
209
+ export interface ChunkMetadata extends Partial<ExtractedDocumentMetadata> {
210
+ chunkIndexInPage?: number;
211
+ chunksInPage?: number;
212
+ // The headings this chunk sits under, outermost first. Present only for
213
+ // Markdown and HTML, and only when the chunk is under at least one heading.
214
+ headingPath?: string[];
93
215
  }
94
216
 
95
217
  export interface PreparedChunk {
@@ -97,28 +219,43 @@ export interface PreparedChunk {
97
219
  metadata: ChunkMetadata | null;
98
220
  }
99
221
 
222
+ const CONTROL_CHARS = /[\x00-\x08\x0B\x0C\x0E-\x1F]/g;
223
+
100
224
  export const prepareChunks = (
101
225
  header: string,
102
226
  documents: ExtractedDocument[] | null,
103
227
  fallbackContent: string | null,
104
- options?: { targetChars?: number; overlapChars?: number }
228
+ options?: {
229
+ targetChars?: number;
230
+ overlapChars?: number;
231
+ // The resource's own type and file name, which decide the heading syntax
232
+ // for inline content and for documents that don't carry their own.
233
+ mimeType?: string | null;
234
+ fileName?: string | null;
235
+ }
105
236
  ): PreparedChunk[] => {
106
237
  if (documents && documents.length > 0) {
107
238
  const out: PreparedChunk[] = [];
108
239
  documents.forEach((doc, docIndex) => {
109
- const body = (doc.pageContent || '')
110
- .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '')
111
- .trim();
240
+ const body = (doc.pageContent || '').replace(CONTROL_CHARS, '').trim();
112
241
  if (!body) return;
113
242
  const text = docIndex === 0 && header ? `${header}\n\n${body}` : body;
114
- const pieces = chunkText(text, options);
243
+ const syntax = headingSyntaxFor({
244
+ mimeType: doc.metadata?.source?.mimeType ?? options?.mimeType,
245
+ fileName: doc.metadata?.source?.fileName ?? options?.fileName,
246
+ isWebPage: !!doc.metadata?.web
247
+ });
248
+ const headingPathOf = syntax ? createHeadingTracker(syntax) : null;
249
+ const pieces = chunkPieces(text, options);
115
250
  pieces.forEach((piece, pieceIndex) => {
251
+ const headingPath = headingPathOf?.(piece.raw) ?? [];
116
252
  out.push({
117
- content: piece,
253
+ content: piece.content,
118
254
  metadata: {
119
255
  ...doc.metadata,
120
256
  chunkIndexInPage: pieceIndex,
121
- chunksInPage: pieces.length
257
+ chunksInPage: pieces.length,
258
+ ...(headingPath.length > 0 ? { headingPath } : {})
122
259
  }
123
260
  });
124
261
  });
@@ -126,12 +263,18 @@ export const prepareChunks = (
126
263
  return out;
127
264
  }
128
265
 
129
- const body = (fallbackContent || '')
130
- .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '')
131
- .trim();
266
+ const body = (fallbackContent || '').replace(CONTROL_CHARS, '').trim();
132
267
  const fullText = [header, body].filter(Boolean).join('\n\n');
133
- return chunkText(fullText, options).map(content => ({
134
- content,
135
- metadata: null
136
- }));
268
+ const syntax = headingSyntaxFor({
269
+ mimeType: options?.mimeType,
270
+ fileName: options?.fileName
271
+ });
272
+ const headingPathOf = syntax ? createHeadingTracker(syntax) : null;
273
+ return chunkPieces(fullText, options).map(piece => {
274
+ const headingPath = headingPathOf?.(piece.raw) ?? [];
275
+ return {
276
+ content: piece.content,
277
+ metadata: headingPath.length > 0 ? { headingPath } : null
278
+ };
279
+ });
137
280
  };
package/src/constants.ts CHANGED
@@ -278,6 +278,33 @@ 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
+
281
308
  const CRAWL_RENDERER_CHEERIO = 'cheerio' as 'cheerio';
282
309
  const CRAWL_RENDERER_PLAYWRIGHT = 'playwright' as 'playwright';
283
310
  const CRAWL_RENDERERS = [CRAWL_RENDERER_CHEERIO, CRAWL_RENDERER_PLAYWRIGHT];
@@ -850,6 +877,25 @@ const CHUNK_TARGET_CHARS = 2000;
850
877
  const CHUNK_OVERLAP_CHARS = 200;
851
878
  const EMBED_BATCH_SIZE = 96;
852
879
 
880
+ // Hybrid resource search. Each ranker contributes its top candidates, and the
881
+ // two lists are merged with reciprocal rank fusion: score = sum of 1/(k + rank).
882
+ // Fusion reads ranks, never raw scores, so a cosine distance and a ts_rank never
883
+ // have to be put on one scale. k = 60 is the constant from the original RRF
884
+ // paper; it keeps one list's #1 from outweighing a chunk both lists agree on.
885
+ const RESOURCE_SEARCH_CANDIDATES = 50;
886
+ const RESOURCE_SEARCH_RRF_K = 60;
887
+ // A query word found in more than 1 of every 10 chunks of the artifact is
888
+ // dropped from the lexical query — never below 5 chunks, so a small artifact
889
+ // keeps its words.
890
+ const RESOURCE_SEARCH_COMMON_TERM_DIVISOR = 10;
891
+ const RESOURCE_SEARCH_COMMON_TERM_MIN = 5;
892
+ // Second stage, on wherever the Worker has an AI binding: the top fused
893
+ // candidates are re-scored by a cross-encoder, which reads query and chunk
894
+ // together instead of comparing two independently computed vectors. One model
895
+ // call per search; a failure falls back to the fused order.
896
+ const RESOURCE_SEARCH_RERANK_CANDIDATES = 30;
897
+ const RESOURCE_SEARCH_RERANK_MODEL = '@cf/baai/bge-reranker-base';
898
+
853
899
  const RESOURCE_HANDLER_SLEEP_AFTER = '10m';
854
900
 
855
901
  const DOCS_URL = 'https://docs.ganju.ai';
@@ -1207,8 +1253,7 @@ const MCP_PROXY_TOOL_NAME_MAX = 64;
1207
1253
  //
1208
1254
  // The row's config holds only `activeVersionId` plus the egress/limit settings;
1209
1255
  // 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").
1256
+ // dispatcher — a slow or broken script must never break tools/list.
1212
1257
  const TOOL_DEFINITION_KEY_CUSTOM_CODE = 'custom-code';
1213
1258
 
1214
1259
  // A version is uploaded as `draft`, becomes `published` when it is made active,
@@ -1996,6 +2041,12 @@ interface PlanLimits {
1996
2041
  // against). Not used for blocking.
1997
2042
  includedMessages: number;
1998
2043
  includedEmbeddedBytes: number;
2044
+ // The intervals the hourly job will sync a source on. Empty = no automatic
2045
+ // sync: the org can still sync a source by hand. Refreshing costs crawl or
2046
+ // Drive fetches plus embeddings for whatever changed, all on our side.
2047
+ autoSyncIntervals: ReadonlyArray<
2048
+ typeof RESOURCE_SYNC_INTERVAL_DAILY | typeof RESOURCE_SYNC_INTERVAL_WEEKLY
2049
+ >;
1999
2050
  }
2000
2051
 
2001
2052
  const PLAN_LIMITS: Record<
@@ -2035,7 +2086,8 @@ const PLAN_LIMITS: Record<
2035
2086
  includedToolCalls: 0,
2036
2087
  toolCallHardCap: 10_000,
2037
2088
  includedMessages: 100,
2038
- includedEmbeddedBytes: 5 * MB
2089
+ includedEmbeddedBytes: 5 * MB,
2090
+ autoSyncIntervals: []
2039
2091
  },
2040
2092
  PRO: {
2041
2093
  maxProjects: null,
@@ -2058,7 +2110,11 @@ const PLAN_LIMITS: Record<
2058
2110
  includedToolCalls: PRICING_INCLUDED_TOOL_CALLS,
2059
2111
  toolCallHardCap: PRICING_TOOL_CALL_HARD_CAP,
2060
2112
  includedMessages: PRICING_INCLUDED_MESSAGES,
2061
- includedEmbeddedBytes: PRICING_INCLUDED_EMBEDDED_GB * GB
2113
+ includedEmbeddedBytes: PRICING_INCLUDED_EMBEDDED_GB * GB,
2114
+ autoSyncIntervals: [
2115
+ RESOURCE_SYNC_INTERVAL_DAILY,
2116
+ RESOURCE_SYNC_INTERVAL_WEEKLY
2117
+ ]
2062
2118
  },
2063
2119
  ENTERPRISE: {
2064
2120
  maxProjects: null,
@@ -2081,7 +2137,11 @@ const PLAN_LIMITS: Record<
2081
2137
  // contract; don't make one plan's abuse limit another plan's product limit.
2082
2138
  toolCallHardCap: null,
2083
2139
  includedMessages: PRICING_INCLUDED_MESSAGES,
2084
- includedEmbeddedBytes: PRICING_INCLUDED_EMBEDDED_GB * GB
2140
+ includedEmbeddedBytes: PRICING_INCLUDED_EMBEDDED_GB * GB,
2141
+ autoSyncIntervals: [
2142
+ RESOURCE_SYNC_INTERVAL_DAILY,
2143
+ RESOURCE_SYNC_INTERVAL_WEEKLY
2144
+ ]
2085
2145
  }
2086
2146
  };
2087
2147
 
@@ -2100,6 +2160,7 @@ const PLAN_FEATURE_RAW_STORAGE = 'rawStorage' as 'rawStorage';
2100
2160
  const PLAN_FEATURE_EMBEDDED_STORAGE = 'embeddedStorage' as 'embeddedStorage';
2101
2161
  const PLAN_FEATURE_MESSAGE = 'message' as 'message';
2102
2162
  const PLAN_FEATURE_TOOL_CALL = 'toolCall' as 'toolCall';
2163
+ const PLAN_FEATURE_RESOURCE_SYNC = 'resourceSync' as 'resourceSync';
2103
2164
 
2104
2165
  // Stable code returned on a quota block so clients can branch on it (402).
2105
2166
  const PLAN_LIMIT_ERROR_CODE = 'PLAN_LIMIT_EXCEEDED';
@@ -2282,6 +2343,7 @@ export const constants = {
2282
2343
  PLAN_FEATURE_EMBEDDED_STORAGE,
2283
2344
  PLAN_FEATURE_MESSAGE,
2284
2345
  PLAN_FEATURE_TOOL_CALL,
2346
+ PLAN_FEATURE_RESOURCE_SYNC,
2285
2347
  PLAN_LIMIT_ERROR_CODE,
2286
2348
  BILLING_METER_MESSAGES,
2287
2349
  BILLING_METER_SHARED_MESSAGES,
@@ -2476,6 +2538,14 @@ export const constants = {
2476
2538
  RESOURCE_SOURCE_TYPE_ONE_DRIVE_FOLDER,
2477
2539
  RESOURCE_SOURCE_TYPE_CUSTOM_CODE,
2478
2540
  RESOURCE_SOURCE_TYPES,
2541
+ RESOURCE_SYNC_INTERVAL_OFF,
2542
+ RESOURCE_SYNC_INTERVAL_DAILY,
2543
+ RESOURCE_SYNC_INTERVAL_WEEKLY,
2544
+ RESOURCE_SYNC_INTERVALS,
2545
+ RESOURCE_SYNC_INTERVAL_DEFAULT,
2546
+ RESOURCE_SYNC_INTERVAL_MS,
2547
+ RESOURCE_SYNC_BATCH_SIZE,
2548
+ RESOURCE_SYNC_MANUAL_COOLDOWN_MS,
2479
2549
  CRAWL_RENDERER_CHEERIO,
2480
2550
  CRAWL_RENDERER_PLAYWRIGHT,
2481
2551
  CRAWL_RENDERERS,
@@ -2597,6 +2667,12 @@ export const constants = {
2597
2667
  CHUNK_TARGET_CHARS,
2598
2668
  CHUNK_OVERLAP_CHARS,
2599
2669
  EMBED_BATCH_SIZE,
2670
+ RESOURCE_SEARCH_CANDIDATES,
2671
+ RESOURCE_SEARCH_RRF_K,
2672
+ RESOURCE_SEARCH_COMMON_TERM_DIVISOR,
2673
+ RESOURCE_SEARCH_COMMON_TERM_MIN,
2674
+ RESOURCE_SEARCH_RERANK_CANDIDATES,
2675
+ RESOURCE_SEARCH_RERANK_MODEL,
2600
2676
  RESOURCE_HANDLER_SLEEP_AFTER,
2601
2677
  DOCS_URL,
2602
2678
  BASE64_DATA_URI_RE,
package/src/index.ts CHANGED
@@ -117,6 +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 { isSyncableResource, resourceSyncProvider } from './syncableResource';
121
+ import type {
122
+ SyncableResource,
123
+ ResourceSyncProvider
124
+ } from './syncableResource';
120
125
  import { describeVendorError } from './vendorError';
121
126
  import { withDeadline, isDeadlineError } from './deadline';
122
127
  import {
@@ -186,6 +191,8 @@ import {
186
191
  import type { Separator, ChunkMetadata, PreparedChunk } from './chunking';
187
192
  import { isEmbeddableMimeType } from './embeddable';
188
193
  import { l2Normalize } from './l2Normalize';
194
+ import { createResourceReranker } from './resourceRerank';
195
+ import type { RerankAiBinding } from './resourceRerank';
189
196
  import { sleep, isRateLimitError, withRateLimitRetry } from './retry';
190
197
  import type { RateLimitRetryOptions } from './retry';
191
198
  import { processQueueBatch } from './processQueueBatch';
@@ -204,10 +211,12 @@ import type { ChannelNotifier, ToolStatusEvent } from './channelNotifier';
204
211
  import {
205
212
  resolveDebounceMs,
206
213
  channelBufferKey,
207
- joinBufferedMessages
214
+ joinBufferedMessages,
215
+ batchCommand
208
216
  } from './channelDebounce';
209
217
  import type {
210
218
  BufferedChannelMessage,
219
+ BufferedChannelCommand,
211
220
  ChannelBufferEnvelope,
212
221
  ChannelBufferFlush
213
222
  } from './channelDebounce';
@@ -291,6 +300,8 @@ export const utils = {
291
300
  oauthProviders,
292
301
  resolveAttachment,
293
302
  isExposedResource,
303
+ isSyncableResource,
304
+ resourceSyncProvider,
294
305
  describeVendorError,
295
306
  withDeadline,
296
307
  isDeadlineError,
@@ -337,6 +348,7 @@ export const utils = {
337
348
  prepareChunks,
338
349
  isEmbeddableMimeType,
339
350
  l2Normalize,
351
+ createResourceReranker,
340
352
  sleep,
341
353
  isRateLimitError,
342
354
  withRateLimitRetry,
@@ -344,6 +356,7 @@ export const utils = {
344
356
  getToolStatusMessage,
345
357
  resolveDebounceMs,
346
358
  channelBufferKey,
359
+ batchCommand,
347
360
  joinBufferedMessages,
348
361
  isResourceSourceEnabled,
349
362
  isDownloadableSource,
@@ -407,12 +420,14 @@ export type {
407
420
  ExtractedDocumentMetadata,
408
421
  ExtractedDocumentSource,
409
422
  RateLimitRetryOptions,
423
+ RerankAiBinding,
410
424
  QueueBatchLike,
411
425
  QueueMessageLike,
412
426
  ProcessQueueBatchHandlers,
413
427
  ChannelNotifier,
414
428
  ToolStatusEvent,
415
429
  BufferedChannelMessage,
430
+ BufferedChannelCommand,
416
431
  ChannelBufferEnvelope,
417
432
  ChannelBufferFlush,
418
433
  Source,
@@ -438,6 +453,8 @@ export type {
438
453
  ResolvedAttachment,
439
454
  ResolveAttachmentResult,
440
455
  ExposableResource,
456
+ SyncableResource,
457
+ ResourceSyncProvider,
441
458
  PlanLimitDetails,
442
459
  PlanLimits
443
460
  };
@@ -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
@@ -1449,6 +1449,21 @@ const ARTIFACT_UPDATE_RESOURCE_SHOW_SOURCE = z.object({
1449
1449
  organizationId: z.uuid()
1450
1450
  });
1451
1451
 
1452
+ const ARTIFACT_UPDATE_RESOURCE_SYNC_INTERVAL = z.object({
1453
+ resourceId: z.uuid(),
1454
+ syncInterval: z.enum(constants.RESOURCE_SYNC_INTERVALS),
1455
+ projectId: z.uuid(),
1456
+ userId: z.uuid(),
1457
+ organizationId: z.uuid()
1458
+ });
1459
+
1460
+ const ARTIFACT_SYNC_RESOURCE = z.object({
1461
+ resourceId: z.uuid(),
1462
+ projectId: z.uuid(),
1463
+ userId: z.uuid(),
1464
+ organizationId: z.uuid()
1465
+ });
1466
+
1452
1467
  const ARTIFACT_GET = z.object({
1453
1468
  projectId: z.uuid(),
1454
1469
  userId: z.uuid(),
@@ -1625,6 +1640,8 @@ export const Schema = {
1625
1640
  CHANNEL_LIST_CONVERSATIONS,
1626
1641
  CHANNEL_LIST_MESSAGES,
1627
1642
  ARTIFACT_UPDATE_RESOURCE_SHOW_SOURCE,
1643
+ ARTIFACT_UPDATE_RESOURCE_SYNC_INTERVAL,
1644
+ ARTIFACT_SYNC_RESOURCE,
1628
1645
  HTTP_ENDPOINT_CONFIG,
1629
1646
  HTTP_ENDPOINT_CONFIG_WRITE,
1630
1647
  MCP_PROXY_CONFIG,
@@ -0,0 +1,48 @@
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;
@@ -469,7 +469,7 @@ export const TOOL_CATALOG = [
469
469
  key: 'search-resources',
470
470
  title: 'Search Resources',
471
471
  description:
472
- 'Find the resources most relevant to a question using semantic search.',
472
+ 'Find the resources most relevant to a question, by meaning and by exact keywords.',
473
473
  requiredScopes: null
474
474
  },
475
475
  {