@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/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 +21 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +73 -5
- package/dist/index.d.ts +68 -25
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -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 +39 -22
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +15 -0
- package/dist/syncableResource.d.ts +9 -0
- package/dist/syncableResource.d.ts.map +1 -0
- package/dist/syncableResource.js +31 -0
- package/dist/toolCatalog.d.ts +1 -1
- package/dist/toolCatalog.js +1 -1
- package/package.json +1 -1
- package/src/channelDebounce.ts +36 -0
- package/src/chunking.ts +166 -23
- package/src/constants.ts +81 -5
- package/src/index.ts +18 -1
- package/src/resourceRerank.ts +54 -0
- package/src/schema.ts +17 -0
- package/src/syncableResource.ts +48 -0
- package/src/toolCatalog.ts +1 -1
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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?: {
|
|
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
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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.
|
|
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;
|
package/src/toolCatalog.ts
CHANGED
|
@@ -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
|
|
472
|
+
'Find the resources most relevant to a question, by meaning and by exact keywords.',
|
|
473
473
|
requiredScopes: null
|
|
474
474
|
},
|
|
475
475
|
{
|