@ganju/utils 0.0.14 → 0.0.16

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.
Files changed (111) hide show
  1. package/dist/channelCustomer.d.ts +99 -0
  2. package/dist/channelCustomer.d.ts.map +1 -0
  3. package/dist/channelCustomer.js +533 -0
  4. package/dist/channelDebounce.d.ts +7 -0
  5. package/dist/channelDebounce.d.ts.map +1 -1
  6. package/dist/chunkOverlap.d.ts +2 -0
  7. package/dist/chunkOverlap.d.ts.map +1 -0
  8. package/dist/chunkOverlap.js +9 -0
  9. package/dist/chunking.d.ts.map +1 -1
  10. package/dist/chunking.js +24 -4
  11. package/dist/citations.d.ts +28 -0
  12. package/dist/citations.d.ts.map +1 -0
  13. package/dist/citations.js +111 -0
  14. package/dist/constants.d.ts +49 -0
  15. package/dist/constants.d.ts.map +1 -1
  16. package/dist/constants.js +202 -17
  17. package/dist/data.d.ts +9 -0
  18. package/dist/data.d.ts.map +1 -0
  19. package/dist/data.js +78 -0
  20. package/dist/dataApi.d.ts +511 -0
  21. package/dist/dataApi.d.ts.map +1 -0
  22. package/dist/dataApi.js +355 -0
  23. package/dist/dataCompiler.d.ts +68 -0
  24. package/dist/dataCompiler.d.ts.map +1 -0
  25. package/dist/dataCompiler.js +726 -0
  26. package/dist/dataDdl.d.ts +29 -0
  27. package/dist/dataDdl.d.ts.map +1 -0
  28. package/dist/dataDdl.js +354 -0
  29. package/dist/dataEngine.d.ts +143 -0
  30. package/dist/dataEngine.d.ts.map +1 -0
  31. package/dist/dataEngine.js +2022 -0
  32. package/dist/dataIdentifiers.d.ts +8 -0
  33. package/dist/dataIdentifiers.d.ts.map +1 -0
  34. package/dist/dataIdentifiers.js +55 -0
  35. package/dist/dataImport.d.ts +77 -0
  36. package/dist/dataImport.d.ts.map +1 -0
  37. package/dist/dataImport.js +450 -0
  38. package/dist/dataRpc.d.ts +131 -0
  39. package/dist/dataRpc.d.ts.map +1 -0
  40. package/dist/dataRpc.js +2 -0
  41. package/dist/dataTypes.d.ts +157 -0
  42. package/dist/dataTypes.d.ts.map +1 -0
  43. package/dist/dataTypes.js +160 -0
  44. package/dist/index.d.ts +570 -36
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +72 -0
  47. package/dist/jsonSchemaToZodShape.d.ts +5 -1
  48. package/dist/jsonSchemaToZodShape.d.ts.map +1 -1
  49. package/dist/jsonSchemaToZodShape.js +21 -9
  50. package/dist/oauth.d.ts +14 -0
  51. package/dist/oauth.d.ts.map +1 -1
  52. package/dist/oauth.js +42 -2
  53. package/dist/pagination.d.ts +47 -0
  54. package/dist/pagination.d.ts.map +1 -0
  55. package/dist/pagination.js +160 -0
  56. package/dist/processQueueBatch.d.ts +6 -1
  57. package/dist/processQueueBatch.d.ts.map +1 -1
  58. package/dist/processQueueBatch.js +23 -10
  59. package/dist/queueFailure.d.ts +14 -0
  60. package/dist/queueFailure.d.ts.map +1 -0
  61. package/dist/queueFailure.js +43 -0
  62. package/dist/resourceRerank.d.ts +3 -2
  63. package/dist/resourceRerank.d.ts.map +1 -1
  64. package/dist/resourceRerank.js +8 -3
  65. package/dist/schema.d.ts +374 -29
  66. package/dist/schema.d.ts.map +1 -1
  67. package/dist/schema.js +290 -6
  68. package/dist/ssrf.d.ts +1 -0
  69. package/dist/ssrf.d.ts.map +1 -1
  70. package/dist/ssrf.js +48 -12
  71. package/dist/toolConfirmation.d.ts +48 -0
  72. package/dist/toolConfirmation.d.ts.map +1 -1
  73. package/dist/toolConfirmation.js +75 -1
  74. package/dist/toolCount.d.ts +13 -0
  75. package/dist/toolCount.d.ts.map +1 -0
  76. package/dist/toolCount.js +45 -0
  77. package/dist/toolLint.d.ts +1 -1
  78. package/dist/toolLint.d.ts.map +1 -1
  79. package/dist/toolLint.js +20 -0
  80. package/dist/untrustedContent.d.ts +135 -0
  81. package/dist/untrustedContent.d.ts.map +1 -0
  82. package/dist/untrustedContent.js +402 -0
  83. package/package.json +17 -1
  84. package/src/channelCustomer.ts +604 -0
  85. package/src/channelDebounce.ts +17 -0
  86. package/src/chunkOverlap.ts +6 -0
  87. package/src/chunking.ts +25 -4
  88. package/src/citations.ts +127 -0
  89. package/src/constants.ts +219 -17
  90. package/src/data.ts +74 -0
  91. package/src/dataApi.ts +428 -0
  92. package/src/dataCompiler.ts +948 -0
  93. package/src/dataDdl.ts +419 -0
  94. package/src/dataEngine.ts +2753 -0
  95. package/src/dataIdentifiers.ts +63 -0
  96. package/src/dataImport.ts +554 -0
  97. package/src/dataRpc.ts +121 -0
  98. package/src/dataTypes.ts +312 -0
  99. package/src/index.ts +156 -2
  100. package/src/jsonSchemaToZodShape.ts +31 -13
  101. package/src/oauth.ts +42 -1
  102. package/src/pagination.ts +193 -0
  103. package/src/processQueueBatch.ts +42 -12
  104. package/src/queueFailure.ts +63 -0
  105. package/src/resourceRerank.ts +10 -3
  106. package/src/schema.ts +346 -4
  107. package/src/ssrf.ts +40 -11
  108. package/src/toolConfirmation.ts +123 -0
  109. package/src/toolCount.ts +53 -0
  110. package/src/toolLint.ts +27 -0
  111. package/src/untrustedContent.ts +476 -0
package/src/chunking.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { constants } from './constants';
2
+ import { overlapTail } from './chunkOverlap';
2
3
  import { decodeEntities } from './sanitize';
3
4
 
4
5
  import type {
@@ -68,7 +69,7 @@ const chunkPieces = (
68
69
  const rawChunks = splitRecursive(trimmed, target);
69
70
  const pieces: Array<{ raw: string; content: string }> = [];
70
71
  for (let i = 0; i < rawChunks.length; i++) {
71
- const prev = i > 0 ? rawChunks[i - 1].slice(-overlap) : '';
72
+ const prev = i > 0 ? overlapTail(rawChunks[i - 1], overlap) : '';
72
73
  pieces.push({
73
74
  raw: rawChunks[i],
74
75
  content: (prev ? prev + '\n' : '') + rawChunks[i]
@@ -93,6 +94,12 @@ interface HeadingEvent {
93
94
  const MARKDOWN_HEADING = /^ {0,3}(#{1,6})[ \t]+(.+?)(?:[ \t]+#+)?[ \t]*$/;
94
95
  const MARKDOWN_FENCE = /^ {0,3}(```|~~~)/;
95
96
  const HTML_HEADING = /<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1\s*>/gi;
97
+ // A heading tag still open at the end of a piece, from its opening tag on: the
98
+ // chunker split inside it, and the rest of it opens the next piece.
99
+ const OPEN_HTML_HEADING = /<h([1-6])\b[^>]*>(?![\s\S]*<\/h\1\s*>)[\s\S]*$/i;
100
+ // Longer than any heading worth carrying: an opening tag this far back with no
101
+ // close is broken markup, not a heading that happened to be split.
102
+ const MAX_OPEN_HEADING_CHARS = 1000;
96
103
  const MAX_HEADING_CHARS = 120;
97
104
 
98
105
  const MARKDOWN_FILE_NAME = /\.(md|markdown|mdx)$/i;
@@ -163,16 +170,30 @@ const scanHtmlHeadings = (text: string): HeadingEvent[] =>
163
170
  const createHeadingTracker = (syntax: HeadingSyntax) => {
164
171
  let stack: Array<{ level: number; text: string }> = [];
165
172
  const fence = { inFence: false };
173
+ // The unfinished HTML heading the previous piece ended in, if any.
174
+ let carry = '';
166
175
  const apply = (event: HeadingEvent) => {
167
176
  stack = stack.filter(h => h.level < event.level);
168
177
  stack.push({ level: event.level, text: event.text });
169
178
  };
170
179
 
180
+ // An HTML heading split between two pieces is read whole from the second,
181
+ // where it ends, as if that piece opened with it: it does, mid-heading. The
182
+ // separator the chunker dropped at the split is restored as a space.
183
+ const scanHtml = (raw: string): HeadingEvent[] => {
184
+ const text = carry ? `${carry} ${raw}` : raw;
185
+ const shift = text.length - raw.length;
186
+ const open = OPEN_HTML_HEADING.exec(text)?.[0] ?? '';
187
+ carry = open.length <= MAX_OPEN_HEADING_CHARS ? open : '';
188
+ return scanHtmlHeadings(text).map(event => ({
189
+ ...event,
190
+ offset: Math.max(0, event.offset - shift)
191
+ }));
192
+ };
193
+
171
194
  return (raw: string): string[] => {
172
195
  const events =
173
- syntax === 'markdown'
174
- ? scanMarkdownHeadings(raw, fence)
175
- : scanHtmlHeadings(raw);
196
+ syntax === 'markdown' ? scanMarkdownHeadings(raw, fence) : scanHtml(raw);
176
197
  const [first, ...rest] = events;
177
198
  // Before a document's first heading there is no section to continue — only
178
199
  // the metadata header, or the markup around an HTML body — so a chunk that
@@ -0,0 +1,127 @@
1
+ import type { Source } from './sources';
2
+
3
+ // "p. 125", "pp. 123–126", "page 12", "págs. 4-5", "páginas 3 a 5". An
4
+ // abbreviation counts only with its dot and a number has to stand alone, so
5
+ // "p2p", "mp3" and "pp 2026" are not page references. The lookarounds, not \b,
6
+ // mark the edges: \b sees "á" as a word break.
7
+ const PAGE_REFERENCE =
8
+ /(?<![\p{L}\p{N}])(?:(?:pp?|p[aá]gs?)\.|pages?|p[aá]ginas?)\s*(\d{1,5})(?![\p{L}\p{N}])(?:(?:\s*[-–—]\s*|\s+(?:to|a)\s+)(\d{1,5})(?![\p{L}\p{N}]))?/giu;
9
+ const MAX_PAGE_RANGE = 50;
10
+
11
+ // Lowercased words only, so "ETag header — HTTP | MDN" in an answer matches the
12
+ // title "ETag header - HTTP | MDN", and "mml-book.pdf" matches "mml-book".
13
+ const normalize = (text: string): string =>
14
+ ` ${text
15
+ .toLowerCase()
16
+ .normalize('NFKD')
17
+ .replace(/[̀-ͯ]/g, '')
18
+ .replace(/[^a-z0-9]+/g, ' ')
19
+ .trim()} `;
20
+
21
+ // The ways an answer refers to a source: its whole title, the title without a
22
+ // parenthetical or a site suffix ("Northwind Handbook (draft)", "ETag header
23
+ // - HTTP | MDN"), its file name with and without the extension, and its URL.
24
+ const namesFor = (source: Source): string[] => {
25
+ const names = [source.title, source.title.replace(/\s*\([^)]*\)\s*/g, ' ')];
26
+ const lead = source.title.split(/\s+[|\-–—:]\s+/)[0];
27
+ // A one-word lead ("Introduction", "Pricing") names half the corpus.
28
+ if (lead.trim().split(/\s+/).length >= 2) names.push(lead);
29
+ if (source.fileName) {
30
+ names.push(source.fileName, source.fileName.replace(/\.[a-z0-9]+$/i, ''));
31
+ }
32
+ return names.map(normalize).filter(name => name.trim().length >= 4);
33
+ };
34
+
35
+ // The characters that can carry a URL on past the point we matched. Brackets,
36
+ // quotes and whitespace end it — that is how a URL sits inside Markdown links,
37
+ // parentheses and prose.
38
+ const URL_CONTINUATION = /^[^\s<>()[\]{}"'`|]*/;
39
+ // Punctuation that closes a sentence around a URL rather than belonging to it.
40
+ const TRAILING_PUNCTUATION = /[.,;:!?*_]+$/;
41
+
42
+ /**
43
+ * Whether the answer cites this exact URL, not merely one that starts with it:
44
+ * on a crawled docs site the index page is a prefix of every page beneath it,
45
+ * so a plain substring test kept "/docs" and the site root in the footer of an
46
+ * answer that cited "/docs/api". A trailing slash and a #fragment still count
47
+ * as the same page.
48
+ */
49
+ export const citesUrl = (answer: string, url: string): boolean => {
50
+ const target = url.replace(/\/+$/, '');
51
+ if (!target) return false;
52
+ for (
53
+ let at = answer.indexOf(target);
54
+ at !== -1;
55
+ at = answer.indexOf(target, at + 1)
56
+ ) {
57
+ const rest = answer
58
+ .slice(at + target.length)
59
+ .match(URL_CONTINUATION)![0]
60
+ .replace(TRAILING_PUNCTUATION, '');
61
+ if (/^\/*(#.*)?$/.test(rest)) return true;
62
+ }
63
+ return false;
64
+ };
65
+
66
+ /** Every page number the answer cites, ranges expanded. */
67
+ export const citedPages = (answer: string): Set<number> => {
68
+ const pages = new Set<number>();
69
+ for (const match of answer.matchAll(PAGE_REFERENCE)) {
70
+ const from = Number(match[1]);
71
+ const to = match[2] ? Number(match[2]) : from;
72
+ if (to < from || to - from > MAX_PAGE_RANGE) {
73
+ pages.add(from);
74
+ continue;
75
+ }
76
+ for (let page = from; page <= to; page++) pages.add(page);
77
+ }
78
+ return pages;
79
+ };
80
+
81
+ /**
82
+ * Narrow a turn's sources to the ones its answer actually cites.
83
+ *
84
+ * Every search hit used to become a footer line, so an answer drawn from one
85
+ * handbook section was followed by eight unrelated pages the search had also
86
+ * returned. A search hit is kept when the answer names its resource — by title,
87
+ * file name or URL — and, for a paged file, only the pages the answer cites
88
+ * (or the best-ranked one, when it cites none). Resources the model read whole
89
+ * are always kept: reading one is itself the citation.
90
+ *
91
+ * An answer that names nothing gets no search sources at all. The model is told
92
+ * to cite what it uses, so a reply that names nothing is a greeting or "I
93
+ * couldn't find that" — and a footer under it would claim sources it never
94
+ * used, just because search runs on every message.
95
+ */
96
+ export const selectCitedSources = (
97
+ sources: Source[],
98
+ answerText: string
99
+ ): Source[] => {
100
+ const searchEntries = sources.filter(s => s.score !== undefined);
101
+ const readEntries = sources.filter(s => s.score === undefined);
102
+ if (searchEntries.length === 0) return sources;
103
+
104
+ const answer = normalize(answerText);
105
+ const pages = citedPages(answerText);
106
+ const citedResources = new Set(
107
+ searchEntries
108
+ .filter(
109
+ s =>
110
+ citesUrl(answerText, s.uri) ||
111
+ namesFor(s).some(name => answer.includes(name))
112
+ )
113
+ .map(s => s.resourceId)
114
+ );
115
+
116
+ const kept: Source[] = [];
117
+ for (const resourceId of citedResources) {
118
+ const entries = searchEntries.filter(s => s.resourceId === resourceId);
119
+ const onCitedPage = entries.filter(
120
+ s => s.pageNumber !== undefined && pages.has(s.pageNumber)
121
+ );
122
+ kept.push(...(onCitedPage.length > 0 ? onCitedPage : entries.slice(0, 1)));
123
+ }
124
+
125
+ const cited = searchEntries.filter(s => kept.includes(s));
126
+ return [...cited, ...readEntries];
127
+ };
package/src/constants.ts CHANGED
@@ -144,6 +144,11 @@ const OAUTH_TOKEN_URLS: Record<string, string> = {
144
144
  // MCP-proxy OAuth, in both apps/api and apps/mcp).
145
145
  const CREDENTIAL_REFRESH_BUFFER_MS = 60 * 1000;
146
146
 
147
+ // A refresh the token endpoint rejects (4xx) without a reauth code still flags
148
+ // the credential once its access token has been expired this long, so the
149
+ // owner sees it as expired instead of every call failing behind "Connected".
150
+ const CREDENTIAL_REFRESH_GIVE_UP_MS = 60 * 60 * 1000;
151
+
147
152
  const GOOGLE_CALENDAR_API_BASE = 'https://www.googleapis.com/calendar/v3';
148
153
 
149
154
  const GOOGLE_DRIVE_API_BASE = 'https://www.googleapis.com/drive/v3';
@@ -228,7 +233,18 @@ const ONE_DRIVE_TAB_LABELS: ReadonlyArray<{
228
233
  { value: ONE_DRIVE_TAB_DRIVES, label: ONE_DRIVE_TAB_LABEL_DRIVES }
229
234
  ];
230
235
 
231
- const REAUTH_ERROR_CODES = ['invalid_grant', 'invalid_token'];
236
+ // Token-endpoint error codes that mean the stored refresh token is dead and
237
+ // only a reconnect brings it back. `unauthorized_client` is Google's answer
238
+ // when a token no longer belongs to the app it is refreshed with.
239
+ const REAUTH_ERROR_CODES = [
240
+ 'invalid_grant',
241
+ 'invalid_token',
242
+ 'unauthorized_client'
243
+ ];
244
+
245
+ // Token-endpoint error codes that are about our own client id / secret, not
246
+ // the user's token: a reconnect can't fix them, so they never flag one.
247
+ const OAUTH_CLIENT_ERROR_CODES = ['invalid_client'];
232
248
 
233
249
  const RESOURCE_TYPE_TOOLTIP_MIN_WIDTH = 300;
234
250
 
@@ -324,6 +340,9 @@ const CRAWL_MAX_PAGES_LIMIT = 1000;
324
340
  const CRAWL_DEFAULT_MAX_DEPTH = 3;
325
341
  const CRAWL_MAX_DEPTH_LIMIT = 10;
326
342
  const CRAWL_PAGE_FETCH_TIMEOUT_MS = 30000;
343
+ // Redirects one crawl fetch may follow. Each hop is screened on its own, so a
344
+ // public URL can't bounce the crawler onto a private address.
345
+ const CRAWL_MAX_REDIRECTS = 5;
327
346
  const CRAWL_USER_AGENT =
328
347
  'Mozilla/5.0 (compatible; GanjuCrawler/1.0; +https://ganju.ai)';
329
348
  const CRAWL_PAGE_QUEUE_BATCH_SIZE = 100;
@@ -597,6 +616,11 @@ const RATE_LIMIT_BACKOFF_SECONDS = 60;
597
616
  // / Container reset) so the retried job lands after the object has recovered.
598
617
  const TRANSIENT_BACKOFF_SECONDS = 5;
599
618
 
619
+ // Retries every resource queue consumer is configured with (`max_retries` in
620
+ // the API worker's wrangler config). A job's last delivery is attempt
621
+ // QUEUE_MAX_RETRIES + 1; only then is its work marked failed.
622
+ const QUEUE_MAX_RETRIES = 3;
623
+
600
624
  const CHANNEL_PLATFORM_TELEGRAM = 'telegram' as 'telegram';
601
625
  const CHANNEL_PLATFORM_SLACK = 'slack' as 'slack';
602
626
  const CHANNEL_PLATFORM_WHATSAPP = 'whatsapp' as 'whatsapp';
@@ -808,15 +832,39 @@ const TOOL_CONFIRMATION_ASK_INSTRUCTION =
808
832
  'confirm, and must not say or suggest that they were done, nor describe ' +
809
833
  'or make up any result from them.';
810
834
  // For MCP clients, whose chat the server can't see: a sensitive call without a
811
- // valid confirmation isn't run, and the model is told to ask the user and call
812
- // again with a token that binds the second call to these exact arguments.
813
- // How long that token stays good, and the argument that carries it.
835
+ // valid confirmation isn't run. Ordinarily the model is told to ask the user
836
+ // and call again with a token that binds the second call to these exact
837
+ // arguments; once the session has read outside content, the user approves it
838
+ // on a dashboard page instead, and the second call carries that approval's
839
+ // id. How long either stays good, and the argument that carries it.
814
840
  const MCP_TOOL_CONFIRMATION_TTL_MS = 10 * 60 * 1000;
815
841
  const MCP_TOOL_CONFIRMATION_ARG = 'confirmation';
816
842
  const MCP_TOOL_CONFIRMATION_ARG_DESCRIPTION =
817
843
  "Leave out on the first call. This action needs the user's yes: the " +
818
- 'first call returns a token; after the user says yes, call again with the ' +
819
- 'same arguments and that token here.';
844
+ 'first call returns a code and says how to get that yes; once the user ' +
845
+ 'has given it, call again with the same arguments and that code here.';
846
+ // The same argument where only some calls ask: those made after the session
847
+ // read outside content.
848
+ const MCP_TOOL_CONFIRMATION_ARG_DESCRIPTION_CONDITIONAL =
849
+ "Leave out on the first call. If this action needs the user's yes, the " +
850
+ 'first call returns a code instead of running and says how to get that ' +
851
+ 'yes; once the user has given it, call again with the same arguments and ' +
852
+ 'that code here.';
853
+ // The dashboard page where a signed-in user approves or denies one such call,
854
+ // as `${TOOL_APPROVAL_PATH}/<approval id>`, and the two answers it sends.
855
+ const TOOL_APPROVAL_PATH = '/tool-approval';
856
+ const TOOL_APPROVAL_DECISION_APPROVE = 'approve';
857
+ const TOOL_APPROVAL_DECISION_DENY = 'deny';
858
+ const TOOL_APPROVAL_DECISIONS = [
859
+ TOOL_APPROVAL_DECISION_APPROVE,
860
+ TOOL_APPROVAL_DECISION_DENY
861
+ ] as const;
862
+ // Where an approval stands, as the page shows it.
863
+ const TOOL_APPROVAL_STATUS_PENDING = 'pending';
864
+ const TOOL_APPROVAL_STATUS_APPROVED = 'approved';
865
+ const TOOL_APPROVAL_STATUS_DENIED = 'denied';
866
+ const TOOL_APPROVAL_STATUS_USED = 'used';
867
+ const TOOL_APPROVAL_STATUS_EXPIRED = 'expired';
820
868
  // The question when the model wrote none, above the runner's own summary.
821
869
  const TOOL_CONFIRMATION_FALLBACK_QUESTION =
822
870
  'Before I do this, please confirm. Reply yes to go ahead, or no to cancel.';
@@ -841,13 +889,15 @@ const SLACK_MAX_SOURCE_BUTTONS = 10;
841
889
 
842
890
  // The Slack app configuration Ganju needs, surfaced in the UI when connecting a
843
891
  // Slack channel. Required scopes back the runner's calls (mentions, DMs,
844
- // posting, file upload); recommended scopes only enrich names/titles and
845
- // degrade gracefully; bot events are what the webhook subscribes to.
892
+ // posting, file upload, downloading a voice clip to transcribe it);
893
+ // recommended scopes only enrich names/titles and degrade gracefully; bot
894
+ // events are what the webhook subscribes to.
846
895
  const SLACK_REQUIRED_SCOPES = [
847
896
  'app_mentions:read',
848
897
  'im:history',
849
898
  'chat:write',
850
- 'files:write'
899
+ 'files:write',
900
+ 'files:read'
851
901
  ];
852
902
  const SLACK_RECOMMENDED_SCOPES = ['users:read', 'channels:read', 'groups:read'];
853
903
  const SLACK_BOT_EVENTS = ['app_mention', 'message.im'];
@@ -958,12 +1008,29 @@ const RESOURCE_SEARCH_RRF_K = 60;
958
1008
  // keeps its words.
959
1009
  const RESOURCE_SEARCH_COMMON_TERM_DIVISOR = 10;
960
1010
  const RESOURCE_SEARCH_COMMON_TERM_MIN = 5;
961
- // Second stage, on wherever the Worker has an AI binding: the top fused
962
- // candidates are re-scored by a cross-encoder, which reads query and chunk
963
- // together instead of comparing two independently computed vectors. One model
964
- // call per search; a failure falls back to the fused order.
1011
+ // Second stage: the top fused candidates re-scored by a cross-encoder, which
1012
+ // reads query and chunk together instead of comparing two independently
1013
+ // computed vectors. One model call per search; a failure falls back to the
1014
+ // fused order. Off by default: on the golden sets (Spanish and English) the
1015
+ // fused order matched or beat it on every set — 10 cases better, 5 worse — and
1016
+ // it added 0.5–0.9 s a search plus a ~6 s cold start. The search eval can
1017
+ // still turn it on per request to re-measure.
1018
+ const RESOURCE_SEARCH_RERANK_ENABLED = false;
965
1019
  const RESOURCE_SEARCH_RERANK_CANDIDATES = 30;
966
1020
  const RESOURCE_SEARCH_RERANK_MODEL = '@cf/baai/bge-reranker-base';
1021
+ // An identifier in the query (a code mixing letters and digits, a snake_case
1022
+ // name, or a number, dotted or hyphenated name typed on its own) found in at
1023
+ // most this many documents is what the user is after: its chunks go first,
1024
+ // whatever the reranker thinks. A cross-encoder reads "86400" or "PGRST116" as noise and
1025
+ // would bury the one chunk that contains it under pages that merely look
1026
+ // related. Past EXACT_MAX_CHUNKS matching chunks the identifier is common
1027
+ // enough that the usual ranking is left to decide, and the scan stops there.
1028
+ const RESOURCE_SEARCH_EXACT_MAX_RESOURCES = 5;
1029
+ const RESOURCE_SEARCH_EXACT_MAX_CHUNKS = 30;
1030
+ // An identifier the index never saw is looked for as plain text, which reads
1031
+ // every chunk of the artifact when nothing matches. Past this many chunks that
1032
+ // costs more than the rare hit is worth, so a large artifact skips it.
1033
+ const RESOURCE_SEARCH_SUBSTRING_MAX_CHUNKS = 20000;
967
1034
 
968
1035
  const RESOURCE_HANDLER_SLEEP_AFTER = '10m';
969
1036
 
@@ -983,6 +1050,12 @@ const CHUNK_SEPARATORS: Array<{ split: string | RegExp; join: string }> = [
983
1050
 
984
1051
  const DEFAULT_MAX_TOKENS = 4096;
985
1052
 
1053
+ // One page of `list-resources`. A knowledge base with a crawled site runs to
1054
+ // thousands of resources; listed whole, that is one result of several hundred
1055
+ // KB in the model's context.
1056
+ const LIST_RESOURCES_DEFAULT_LIMIT = 100;
1057
+ const LIST_RESOURCES_MAX_LIMIT = 500;
1058
+
986
1059
  const RESOURCE_TOOL_KEY_LIST_RESOURCES = 'list-resources';
987
1060
  const RESOURCE_TOOL_KEY_SEARCH_RESOURCES = 'search-resources';
988
1061
  const RESOURCE_TOOL_KEY_READ_RESOURCE = 'read-resource';
@@ -1514,7 +1587,9 @@ const RESERVED_TOOL_NAMES = [
1514
1587
  TOOL_DEFINITION_KEY_HTTP_ENDPOINT,
1515
1588
  TOOL_DEFINITION_KEY_MCP_PROXY,
1516
1589
  TOOL_DEFINITION_KEY_CUSTOM_CODE,
1517
- CUSTOM_CODE_HEALTH_TOOL
1590
+ CUSTOM_CODE_HEALTH_TOOL,
1591
+ // The channel runner's own memory tool (PARTICIPANT_MEMORY_TOOL_NAME).
1592
+ 'remember-customer'
1518
1593
  ];
1519
1594
 
1520
1595
  // Shared by both write paths so there is one string to translate. It has to
@@ -1779,8 +1854,16 @@ const MCP_AUTH_KINDS = [MCP_AUTH_KIND_JWT, MCP_AUTH_KIND_INTERNAL];
1779
1854
  const MCP_SESSION_HEADER = 'mcp-session-id';
1780
1855
 
1781
1856
  const MCP_INTERNAL_HEADER = 'x-ganju-internal-secret';
1857
+ // Sent with the internal secret by the search eval to measure the reranker:
1858
+ // `on` or `off` overrides RESOURCE_SEARCH_RERANK_ENABLED for that request.
1859
+ // Ignored on any other request.
1860
+ const MCP_EVAL_RERANK_HEADER = 'x-ganju-eval-rerank';
1782
1861
  const MCP_CHANNEL_ID_HEADER = 'x-ganju-channel-id';
1783
1862
  const MCP_CHANNEL_PLATFORM_HEADER = 'x-ganju-channel-platform';
1863
+ // The conversation a channel turn belongs to. The MCP worker files that turn's
1864
+ // calls under one session per conversation, so a call can be traced back to
1865
+ // the chat it ran in.
1866
+ const MCP_CHANNEL_CONVERSATION_HEADER = 'x-ganju-channel-conversation-id';
1784
1867
  const MCP_CHANNEL_CLIENT_USER_AGENT = 'ganju-channel/0.0.1';
1785
1868
  const JWKS_KV_KEY = 'jwks:v1';
1786
1869
  const JWKS_TTL_SECONDS = 600;
@@ -1882,6 +1965,23 @@ const ACCESS_TOKEN_SCOPE_MESSAGE =
1882
1965
  const CUSTOM_CODE_LOGS_DEFAULT_LIMIT = 20;
1883
1966
  const CUSTOM_CODE_LOGS_MAX_LIMIT = 100;
1884
1967
 
1968
+ // Tool observability on the project home. The windows match the activity
1969
+ // chart's, and none reaches past `RETENTION_DAYS.mcpRequest`.
1970
+ const OBSERVABILITY_RANGE_DAYS = [7, 30, 90];
1971
+ // An enabled tool with no call in this many days is offered for disabling.
1972
+ const OBSERVABILITY_UNUSED_DAYS = 30;
1973
+ // Two calls in one session this close together are one attempt followed by
1974
+ // the next: a retry when it's the same tool, a switch when it's another tool
1975
+ // after an error.
1976
+ const OBSERVABILITY_FOLLOW_UP_SECONDS = 120;
1977
+ // How a call rejected by its tool's input schema reads in `error_message`.
1978
+ const OBSERVABILITY_SCHEMA_REJECTION_PREFIX = 'Input validation error';
1979
+ const OBSERVABILITY_TOP_ERRORS = 8;
1980
+ const OBSERVABILITY_CALLS_DEFAULT_LIMIT = 25;
1981
+ const OBSERVABILITY_CALLS_MAX_LIMIT = 100;
1982
+ // A session timeline is read whole up to this many calls, newest kept.
1983
+ const OBSERVABILITY_SESSION_MAX_CALLS = 500;
1984
+
1885
1985
  const BOT_GRANT_TYPE = 'urn:ganju:bot-on-behalf-of';
1886
1986
  const EXTERNAL_LINK_VERIFICATION_PREFIX = 'external_link:';
1887
1987
  const EXTERNAL_LINK_TTL_SECONDS = 600;
@@ -2051,6 +2151,40 @@ const PRICING_TOOL_CALL_PER_M_USD = 5;
2051
2151
  const PRICING_TOOL_CALL_HARD_CAP = 20_000_000;
2052
2152
  const PRICING_CUSTOM_DOMAIN_USD = 15;
2053
2153
 
2154
+ // Project data (one SQLite Durable Object per artifact). Cloudflare caps a
2155
+ // database at 10 GB; we stop writes that add data at 9 GB so there is room to
2156
+ // delete, archive and export.
2157
+ const DATA_MAX_DATABASE_BYTES = 9 * GB;
2158
+ const DATA_MAX_TABLES_PER_DATABASE = 50;
2159
+ const DATA_TRIGGER_RUN_HARD_CAP = 5_000_000;
2160
+ // A deleted database can be restored for this long before its storage is
2161
+ // freed.
2162
+ const DATA_DELETE_GRACE_DAYS = 7;
2163
+ const ARTIFACT_DATABASE_STATUS_CREATING = 'CREATING' as 'CREATING';
2164
+ const ARTIFACT_DATABASE_STATUS_READY = 'READY' as 'READY';
2165
+ const ARTIFACT_DATABASE_STATUS_FAILED = 'FAILED' as 'FAILED';
2166
+ const ARTIFACT_DATABASE_STATUS_DELETING = 'DELETING' as 'DELETING';
2167
+ // The hourly purge has claimed the row and is emptying the object; a restore
2168
+ // can no longer take it back.
2169
+ const ARTIFACT_DATABASE_STATUS_PURGING = 'PURGING' as 'PURGING';
2170
+ const ARTIFACT_DATABASE_STATUS_DELETED = 'DELETED' as 'DELETED';
2171
+ const ARTIFACT_DATABASE_STATUSES = [
2172
+ ARTIFACT_DATABASE_STATUS_CREATING,
2173
+ ARTIFACT_DATABASE_STATUS_READY,
2174
+ ARTIFACT_DATABASE_STATUS_FAILED,
2175
+ ARTIFACT_DATABASE_STATUS_DELETING,
2176
+ ARTIFACT_DATABASE_STATUS_PURGING,
2177
+ ARTIFACT_DATABASE_STATUS_DELETED
2178
+ ];
2179
+ // A purge that claimed a row and never finished (the Worker died) is picked
2180
+ // up again after this long.
2181
+ const DATA_PURGE_STALE_MS = 60 * 60 * 1000;
2182
+ // Rows purged per hourly run: each is a call to its database object.
2183
+ const DATA_PURGE_BATCH = 20;
2184
+ // last_read_at / last_write_at move at most this often, so they aren't a
2185
+ // Postgres write per query.
2186
+ const DATA_ACTIVITY_THROTTLE_MS = 60 * 60 * 1000;
2187
+
2054
2188
  interface PlanLimits {
2055
2189
  // null on any field means "no hard limit" for that plan.
2056
2190
  maxProjects: number | null;
@@ -2116,6 +2250,16 @@ interface PlanLimits {
2116
2250
  autoSyncIntervals: ReadonlyArray<
2117
2251
  typeof RESOURCE_SYNC_INTERVAL_DAILY | typeof RESOURCE_SYNC_INTERVAL_WEEKLY
2118
2252
  >;
2253
+ // Whether a project may create a database (tables and rows). Paid-only: the
2254
+ // rows live in a Durable Object we pay storage and rows for. An org that
2255
+ // downgrades keeps its databases, read-only.
2256
+ canUseData: boolean;
2257
+ // Writes that add data stop here, under Cloudflare's 10 GB per database, so
2258
+ // the owner meets a readable "database full" rather than SQLITE_FULL.
2259
+ maxDataBytes: number | null;
2260
+ maxDataTablesPerDatabase: number | null;
2261
+ // Abuse backstop on data trigger runs a month. `null` = no backstop.
2262
+ triggerRunHardCap: number | null;
2119
2263
  }
2120
2264
 
2121
2265
  const PLAN_LIMITS: Record<
@@ -2156,7 +2300,11 @@ const PLAN_LIMITS: Record<
2156
2300
  toolCallHardCap: 10_000,
2157
2301
  includedMessages: 100,
2158
2302
  includedEmbeddedBytes: 5 * MB,
2159
- autoSyncIntervals: []
2303
+ autoSyncIntervals: [],
2304
+ canUseData: false,
2305
+ maxDataBytes: null,
2306
+ maxDataTablesPerDatabase: null,
2307
+ triggerRunHardCap: 0
2160
2308
  },
2161
2309
  PRO: {
2162
2310
  maxProjects: null,
@@ -2183,7 +2331,11 @@ const PLAN_LIMITS: Record<
2183
2331
  autoSyncIntervals: [
2184
2332
  RESOURCE_SYNC_INTERVAL_DAILY,
2185
2333
  RESOURCE_SYNC_INTERVAL_WEEKLY
2186
- ]
2334
+ ],
2335
+ canUseData: true,
2336
+ maxDataBytes: DATA_MAX_DATABASE_BYTES,
2337
+ maxDataTablesPerDatabase: DATA_MAX_TABLES_PER_DATABASE,
2338
+ triggerRunHardCap: DATA_TRIGGER_RUN_HARD_CAP
2187
2339
  },
2188
2340
  ENTERPRISE: {
2189
2341
  maxProjects: null,
@@ -2210,7 +2362,11 @@ const PLAN_LIMITS: Record<
2210
2362
  autoSyncIntervals: [
2211
2363
  RESOURCE_SYNC_INTERVAL_DAILY,
2212
2364
  RESOURCE_SYNC_INTERVAL_WEEKLY
2213
- ]
2365
+ ],
2366
+ canUseData: true,
2367
+ maxDataBytes: DATA_MAX_DATABASE_BYTES,
2368
+ maxDataTablesPerDatabase: DATA_MAX_TABLES_PER_DATABASE,
2369
+ triggerRunHardCap: null
2214
2370
  }
2215
2371
  };
2216
2372
 
@@ -2230,6 +2386,7 @@ const PLAN_FEATURE_EMBEDDED_STORAGE = 'embeddedStorage' as 'embeddedStorage';
2230
2386
  const PLAN_FEATURE_MESSAGE = 'message' as 'message';
2231
2387
  const PLAN_FEATURE_TOOL_CALL = 'toolCall' as 'toolCall';
2232
2388
  const PLAN_FEATURE_RESOURCE_SYNC = 'resourceSync' as 'resourceSync';
2389
+ const PLAN_FEATURE_DATA = 'data' as 'data';
2233
2390
 
2234
2391
  // Stable code returned on a quota block so clients can branch on it (402).
2235
2392
  const PLAN_LIMIT_ERROR_CODE = 'PLAN_LIMIT_EXCEEDED';
@@ -2399,6 +2556,20 @@ export const constants = {
2399
2556
  PRICING_TOOL_CALL_PER_M_USD,
2400
2557
  PRICING_TOOL_CALL_HARD_CAP,
2401
2558
  PRICING_CUSTOM_DOMAIN_USD,
2559
+ DATA_MAX_DATABASE_BYTES,
2560
+ DATA_MAX_TABLES_PER_DATABASE,
2561
+ DATA_TRIGGER_RUN_HARD_CAP,
2562
+ DATA_DELETE_GRACE_DAYS,
2563
+ DATA_PURGE_BATCH,
2564
+ DATA_ACTIVITY_THROTTLE_MS,
2565
+ ARTIFACT_DATABASE_STATUS_CREATING,
2566
+ ARTIFACT_DATABASE_STATUS_READY,
2567
+ ARTIFACT_DATABASE_STATUS_FAILED,
2568
+ ARTIFACT_DATABASE_STATUS_DELETING,
2569
+ ARTIFACT_DATABASE_STATUS_PURGING,
2570
+ ARTIFACT_DATABASE_STATUS_DELETED,
2571
+ DATA_PURGE_STALE_MS,
2572
+ ARTIFACT_DATABASE_STATUSES,
2402
2573
  PLAN_FEATURE_ORGANIZATION,
2403
2574
  PLAN_FEATURE_PROJECT,
2404
2575
  PLAN_FEATURE_TOOL,
@@ -2413,6 +2584,7 @@ export const constants = {
2413
2584
  PLAN_FEATURE_MESSAGE,
2414
2585
  PLAN_FEATURE_TOOL_CALL,
2415
2586
  PLAN_FEATURE_RESOURCE_SYNC,
2587
+ PLAN_FEATURE_DATA,
2416
2588
  PLAN_LIMIT_ERROR_CODE,
2417
2589
  BILLING_METER_MESSAGES,
2418
2590
  BILLING_METER_SHARED_MESSAGES,
@@ -2501,6 +2673,7 @@ export const constants = {
2501
2673
  SLACK_OAUTH_TOKEN_URL,
2502
2674
  OAUTH_TOKEN_URLS,
2503
2675
  CREDENTIAL_REFRESH_BUFFER_MS,
2676
+ CREDENTIAL_REFRESH_GIVE_UP_MS,
2504
2677
  GOOGLE_CALENDAR_API_BASE,
2505
2678
  GOOGLE_DRIVE_API_BASE,
2506
2679
  GOOGLE_DRIVE_DEFAULT_PAGE_SIZE,
@@ -2536,6 +2709,7 @@ export const constants = {
2536
2709
  ONE_DRIVE_TAB_LABEL_DRIVES,
2537
2710
  ONE_DRIVE_TAB_LABELS,
2538
2711
  REAUTH_ERROR_CODES,
2712
+ OAUTH_CLIENT_ERROR_CODES,
2539
2713
  RESOURCE_TYPE_TOOLTIP_MIN_WIDTH,
2540
2714
  SEARCH_DEBOUNCE_MS,
2541
2715
  GOOGLE_DRIVE_EXPORT_MIME_TYPES,
@@ -2624,6 +2798,7 @@ export const constants = {
2624
2798
  CRAWL_DEFAULT_MAX_DEPTH,
2625
2799
  CRAWL_MAX_DEPTH_LIMIT,
2626
2800
  CRAWL_PAGE_FETCH_TIMEOUT_MS,
2801
+ CRAWL_MAX_REDIRECTS,
2627
2802
  CRAWL_PAGE_QUEUE_BATCH_SIZE,
2628
2803
  CRAWL_USER_AGENT,
2629
2804
  RESOURCE_ICON_THEME_DARK,
@@ -2641,6 +2816,7 @@ export const constants = {
2641
2816
  TELEGRAM_MAX_FILE_BYTES,
2642
2817
  RATE_LIMIT_BACKOFF_SECONDS,
2643
2818
  TRANSIENT_BACKOFF_SECONDS,
2819
+ QUEUE_MAX_RETRIES,
2644
2820
  ENCODINGS,
2645
2821
  ENCODING_UTF8,
2646
2822
  CHANNEL_PLATFORM_TELEGRAM,
@@ -2682,6 +2858,16 @@ export const constants = {
2682
2858
  MCP_TOOL_CONFIRMATION_TTL_MS,
2683
2859
  MCP_TOOL_CONFIRMATION_ARG,
2684
2860
  MCP_TOOL_CONFIRMATION_ARG_DESCRIPTION,
2861
+ MCP_TOOL_CONFIRMATION_ARG_DESCRIPTION_CONDITIONAL,
2862
+ TOOL_APPROVAL_PATH,
2863
+ TOOL_APPROVAL_DECISION_APPROVE,
2864
+ TOOL_APPROVAL_DECISION_DENY,
2865
+ TOOL_APPROVAL_DECISIONS,
2866
+ TOOL_APPROVAL_STATUS_PENDING,
2867
+ TOOL_APPROVAL_STATUS_APPROVED,
2868
+ TOOL_APPROVAL_STATUS_DENIED,
2869
+ TOOL_APPROVAL_STATUS_USED,
2870
+ TOOL_APPROVAL_STATUS_EXPIRED,
2685
2871
  CHANNEL_DEBOUNCE_DEFAULT_MS,
2686
2872
  CHANNEL_DEBOUNCE_MIN_MS,
2687
2873
  CHANNEL_DEBOUNCE_MAX_MS,
@@ -2752,8 +2938,12 @@ export const constants = {
2752
2938
  RESOURCE_SEARCH_RRF_K,
2753
2939
  RESOURCE_SEARCH_COMMON_TERM_DIVISOR,
2754
2940
  RESOURCE_SEARCH_COMMON_TERM_MIN,
2941
+ RESOURCE_SEARCH_RERANK_ENABLED,
2755
2942
  RESOURCE_SEARCH_RERANK_CANDIDATES,
2756
2943
  RESOURCE_SEARCH_RERANK_MODEL,
2944
+ RESOURCE_SEARCH_EXACT_MAX_RESOURCES,
2945
+ RESOURCE_SEARCH_EXACT_MAX_CHUNKS,
2946
+ RESOURCE_SEARCH_SUBSTRING_MAX_CHUNKS,
2757
2947
  RESOURCE_HANDLER_SLEEP_AFTER,
2758
2948
  DOCS_URL,
2759
2949
  BASE64_DATA_URI_RE,
@@ -2761,6 +2951,8 @@ export const constants = {
2761
2951
  CHUNK_SEPARATORS,
2762
2952
  DEFAULT_MAX_TOKENS,
2763
2953
  RESOURCE_TOOL_KEY_LIST_RESOURCES,
2954
+ LIST_RESOURCES_DEFAULT_LIMIT,
2955
+ LIST_RESOURCES_MAX_LIMIT,
2764
2956
  RESOURCE_TOOL_KEY_SEARCH_RESOURCES,
2765
2957
  RESOURCE_TOOL_KEY_READ_RESOURCE,
2766
2958
  RESOURCE_TOOL_KEY_SEND_RESOURCE,
@@ -2932,8 +3124,10 @@ export const constants = {
2932
3124
  CUSTOM_CODE_RESOURCE_FILE_TOO_LARGE_MESSAGE,
2933
3125
  RESERVED_SLUGS,
2934
3126
  MCP_INTERNAL_HEADER,
3127
+ MCP_EVAL_RERANK_HEADER,
2935
3128
  MCP_CHANNEL_ID_HEADER,
2936
3129
  MCP_CHANNEL_PLATFORM_HEADER,
3130
+ MCP_CHANNEL_CONVERSATION_HEADER,
2937
3131
  MCP_CHANNEL_CLIENT_USER_AGENT,
2938
3132
  MCP_SESSION_HEADER,
2939
3133
  MCP_REQUEST_METHOD_INITIALIZE,
@@ -2971,6 +3165,14 @@ export const constants = {
2971
3165
  ACCESS_TOKEN_SCOPE_MESSAGE,
2972
3166
  CUSTOM_CODE_LOGS_DEFAULT_LIMIT,
2973
3167
  CUSTOM_CODE_LOGS_MAX_LIMIT,
3168
+ OBSERVABILITY_RANGE_DAYS,
3169
+ OBSERVABILITY_UNUSED_DAYS,
3170
+ OBSERVABILITY_FOLLOW_UP_SECONDS,
3171
+ OBSERVABILITY_SCHEMA_REJECTION_PREFIX,
3172
+ OBSERVABILITY_TOP_ERRORS,
3173
+ OBSERVABILITY_CALLS_DEFAULT_LIMIT,
3174
+ OBSERVABILITY_CALLS_MAX_LIMIT,
3175
+ OBSERVABILITY_SESSION_MAX_CALLS,
2974
3176
  BOT_GRANT_TYPE,
2975
3177
  EXTERNAL_LINK_VERIFICATION_PREFIX,
2976
3178
  EXTERNAL_LINK_TTL_SECONDS,