bunnyquery 1.12.1 → 1.14.0

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.
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { buildIndexingSystemPrompt, buildIndexingUserMessage, buildIndexingContinueMessage, buildIndexingRenderMessage, buildIndexingRenderContinueTemplate, buildIndexingWindowMessage } from './prompts';
18
18
  import { isServerExtractable, isPagedReadFile, isImageVisionFile, isWindowedReadFile, makeExtractPlaceholder, makeRenderPlaceholder, makeWindowPlaceholder, RENDER_PAGES_PER_WINDOW, type ExtractDirective, type FileUrlDirective } from './office';
19
- import { chatEngineConfig, resolveForwardRequest, resolveForwardRequestHistory, pollOpt, windowedIndexingEnabled, liveStreamingEnabled, liveStreamingRealtimeEnabled } from './config';
19
+ import { chatEngineConfig, resolveForwardRequest, resolveForwardRequestHistory, pollOpt, windowedIndexingEnabled, liveStreamingEnabled, liveStreamingRealtimeEnabled, resolveWebAccess } from './config';
20
20
  // Output sizing lives in budget.ts so the request cap and the reserve the input
21
21
  // budget subtracts cannot drift; getMaxOutputTokens also clamps per model.
22
22
  import { getMaxOutputTokens, getModelContextWindow } from './budget';
@@ -30,12 +30,40 @@ const ANTHROPIC_PROMPT_CACHING_BETA = 'prompt-caching-2024-07-31';
30
30
  const ANTHROPIC_BETA_HEADER = `${ANTHROPIC_MCP_BETA},${ANTHROPIC_WEB_FETCH_BETA},${ANTHROPIC_PROMPT_CACHING_BETA}`;
31
31
  const WEB_FETCH_MAX_USES = 40;
32
32
  const WEB_FETCH_MAX_CONTENT_TOKENS = 200000;
33
+ // The two web tools, built in one place so a chat turn and an indexing pass
34
+ // cannot drift in how they spell them. A CHAT turn carries them only when the
35
+ // project's web switch is on (see webToolsForChat); an INDEXING pass keeps them
36
+ // unconditionally, as it always has.
37
+ const claudeWebFetchTool = () => ({
38
+ type: 'web_fetch_20250910',
39
+ name: 'web_fetch',
40
+ max_uses: WEB_FETCH_MAX_USES,
41
+ citations: { enabled: true },
42
+ max_content_tokens: WEB_FETCH_MAX_CONTENT_TOKENS,
43
+ });
33
44
 
34
45
  export const OPENAI_RESPONSES_API_URL = 'https://api.openai.com/v1/responses';
35
46
  const OPENAI_MODELS_API_URL = 'https://api.openai.com/v1/models';
36
47
  const DEFAULT_OPENAI_IMAGE_DETAIL = 'auto';
37
48
  const OPENAI_WEB_SEARCH_ENABLED = true;
38
49
  const OPENAI_WEB_SEARCH_EXTERNAL_WEB_ACCESS = true;
50
+ const openaiWebSearchTool = () => ({
51
+ type: 'web_search',
52
+ external_web_access: OPENAI_WEB_SEARCH_EXTERNAL_WEB_ACCESS,
53
+ });
54
+ /**
55
+ * The web tool list of a CHAT turn: the provider's tool when the switch is on,
56
+ * nothing when it is off. Off is the default (project_settings.ts
57
+ * DEFAULT_WEB_ACCESS): the answer then comes from the project's files and
58
+ * database alone, which is what the grounding rules promise. The switch is
59
+ * resolved by the caller (resolveWebAccess: host option, then the project's
60
+ * `web_access` setting, then false) so this stays a pure function of it.
61
+ */
62
+ const webToolsForChat = (platform: 'claude' | 'openai', webAccess: boolean | undefined): any[] => {
63
+ if (webAccess !== true) return [];
64
+ if (platform === 'claude') return [claudeWebFetchTool()];
65
+ return OPENAI_WEB_SEARCH_ENABLED ? [openaiWebSearchTool()] : [];
66
+ };
39
67
  export const MCP_NAME = 'BunnyQuery';
40
68
 
41
69
  export const DEFAULT_CLAUDE_MODEL = 'claude-sonnet-5';
@@ -106,6 +134,13 @@ const mcpContextParam = (platform: 'claude' | 'openai', model?: string) =>
106
134
 
107
135
  const mcpIndexingUrl = (platform: 'claude' | 'openai' = 'openai', model?: string) =>
108
136
  withMcpParams(mcpUrl(), { profile: 'index', ctx: mcpContextParam(platform, model) });
137
+ // A CHAT turn asks the server for its `chat` profile: the data tools only (reads and the
138
+ // record writes a user may ask for), with readFileContent worded for reading rather than
139
+ // indexing, and none of the owner, hosting, ticket, secret-key or user-management tools. The
140
+ // server treats a profile as a narrowing of what the caller may see, so it is safe on the
141
+ // anonymous /p/ route too. Reliability plan 4.2 (2026-10-12).
142
+ const mcpChatUrl = (endpointUrl: string, platform: 'claude' | 'openai', model?: string) =>
143
+ withMcpParams(endpointUrl, { profile: 'chat', ctx: mcpContextParam(platform, model) });
109
144
 
110
145
  /**
111
146
  * Where a chat turn's MCP tools point, and what they authenticate with.
@@ -601,6 +636,12 @@ export type CallClaudeWithMcpParams = {
601
636
  fileUrls?: FileUrlDirective[];
602
637
  onResponse?: (res: any) => void;
603
638
  onError?: (err: any) => void;
639
+ /**
640
+ * Attach Claude's `web_fetch` to this turn. Omitted means "ask the engine":
641
+ * the host's `webAccess` config option, then the project's `web_access`
642
+ * setting, then off. See config.ts resolveWebAccess.
643
+ */
644
+ webAccess?: boolean;
604
645
  };
605
646
  // Poll cadence for every client-secret request the chat waits on. Shared by the
606
647
  // engine's own poll sites and imported by agent.vue; the widget carries its own
@@ -651,7 +692,12 @@ export async function callClaudeWithMcp({
651
692
  mcpServer,
652
693
  extractContent,
653
694
  fileUrls,
695
+ webAccess,
654
696
  }: CallClaudeWithMcpParams) {
697
+ // Resolved HERE and not only in callClaudeWithPublicMcp, so a host calling this
698
+ // builder directly gets the same switch: an explicit boolean wins, otherwise
699
+ // the project's setting, otherwise off.
700
+ const web = typeof webAccess === 'boolean' ? webAccess : await resolveWebAccess(service);
655
701
  const mcpServerDefinition: Record<string, any> = {
656
702
  type: 'url',
657
703
  name: mcpServer.name,
@@ -729,13 +775,10 @@ export async function callClaudeWithMcp({
729
775
  : {}),
730
776
  ...(mcpServer.configs ? { configs: mcpServer.configs } : {}),
731
777
  },
732
- {
733
- type: 'web_fetch_20250910',
734
- name: 'web_fetch',
735
- max_uses: WEB_FETCH_MAX_USES,
736
- citations: { enabled: true },
737
- max_content_tokens: WEB_FETCH_MAX_CONTENT_TOKENS,
738
- },
778
+ // `web_fetch` rides here only when the project's web switch is on. The
779
+ // beta header above is left as it is: it only makes the tool TYPE
780
+ // available, and a request that does not list the tool cannot use it.
781
+ ...webToolsForChat('claude', web),
739
782
  ],
740
783
  },
741
784
  });
@@ -769,9 +812,7 @@ export async function callClaudeWithPublicMcp(
769
812
  fileUrls,
770
813
  mcpServer: {
771
814
  name: MCP_NAME,
772
- url: withMcpParams(endpoint.url, {
773
- ctx: mcpContextParam('claude', model || DEFAULT_CLAUDE_MODEL),
774
- }),
815
+ url: mcpChatUrl(endpoint.url, 'claude', model || DEFAULT_CLAUDE_MODEL),
775
816
  // Omitted entirely for an anonymous turn; the `if (mcpServer.authorizationToken)`
776
817
  // guard below drops the key rather than sending an empty one.
777
818
  authorizationToken: endpoint.token,
@@ -798,6 +839,9 @@ export async function callOpenAIWithPublicMcp(
798
839
  const endpoint = mcpEndpointFor(mcpScope?.anonymous, mcpScope?.publicProjectId, service);
799
840
  const resolvedModel = model || DEFAULT_OPENAI_MODEL;
800
841
  const imageDetail = getOpenAIImageDetail(resolvedModel);
842
+ // The project's web switch: host option, then the `web_access` setting, then
843
+ // off. Awaited so the first turn after a page load reads the stored value.
844
+ const web = await resolveWebAccess(service);
801
845
  const messageList =
802
846
  messages && messages.length
803
847
  ? prepareOpenAIMessages(messages, imageDetail)
@@ -856,7 +900,7 @@ export async function callOpenAIWithPublicMcp(
856
900
  {
857
901
  type: 'mcp',
858
902
  server_label: MCP_NAME,
859
- server_url: withMcpParams(endpoint.url, { ctx: mcpContextParam('openai', resolvedModel) }),
903
+ server_url: mcpChatUrl(endpoint.url, 'openai', resolvedModel),
860
904
  require_approval: 'never',
861
905
  // No `headers` at all for an anonymous turn: `Bearer ` with an
862
906
  // empty token is a credential the MCP server rejects, and the
@@ -865,14 +909,8 @@ export async function callOpenAIWithPublicMcp(
865
909
  ? { headers: { Authorization: 'Bearer ' + endpoint.token } }
866
910
  : {}),
867
911
  },
868
- ...(OPENAI_WEB_SEARCH_ENABLED
869
- ? [
870
- {
871
- type: 'web_search',
872
- external_web_access: OPENAI_WEB_SEARCH_EXTERNAL_WEB_ACCESS,
873
- },
874
- ]
875
- : []),
912
+ // `web_search` rides here only when the project's web switch is on.
913
+ ...webToolsForChat('openai', web),
876
914
  ],
877
915
  },
878
916
  });
@@ -1182,14 +1220,9 @@ export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
1182
1220
  require_approval: 'never',
1183
1221
  headers: { Authorization: 'Bearer $ACCESS_TOKEN' },
1184
1222
  },
1185
- ...(OPENAI_WEB_SEARCH_ENABLED
1186
- ? [
1187
- {
1188
- type: 'web_search',
1189
- external_web_access: OPENAI_WEB_SEARCH_EXTERNAL_WEB_ACCESS,
1190
- },
1191
- ]
1192
- : []),
1223
+ // UNCONDITIONAL, unlike a chat turn: the project's web switch
1224
+ // governs answers, not indexing. Kept exactly as it was.
1225
+ ...(OPENAI_WEB_SEARCH_ENABLED ? [openaiWebSearchTool()] : []),
1193
1226
  ],
1194
1227
  },
1195
1228
  }));
@@ -1243,13 +1276,9 @@ export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
1243
1276
  type: 'mcp_toolset',
1244
1277
  mcp_server_name: MCP_NAME,
1245
1278
  },
1246
- {
1247
- type: 'web_fetch_20250910',
1248
- name: 'web_fetch',
1249
- max_uses: WEB_FETCH_MAX_USES,
1250
- citations: { enabled: true },
1251
- max_content_tokens: WEB_FETCH_MAX_CONTENT_TOKENS,
1252
- },
1279
+ // UNCONDITIONAL, unlike a chat turn: the project's web switch governs
1280
+ // answers, not indexing. Kept exactly as it was.
1281
+ claudeWebFetchTool(),
1253
1282
  ],
1254
1283
  },
1255
1284
  }));
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Retrieval memory: a compact note of what a chat turn RETRIEVED, kept with the
3
+ * turn so a follow-up question skips discovery.
4
+ *
5
+ * THE PROBLEM. The provider runs the tool loop on its side and history keeps
6
+ * only the answer text (budget.ts), so on "and what does page 3 say?" the model
7
+ * starts from nothing: it looks the file up again, guesses a table and an access
8
+ * group again, and routinely lands somewhere else, which is how an answer flips
9
+ * between two turns about the same document.
10
+ *
11
+ * WHAT THE ENGINE CAN SEE. The provider's final body carries the tool loop:
12
+ * - Anthropic Messages: content[] blocks `mcp_tool_use` (name, input) and
13
+ * `mcp_tool_result` (content), plus `server_tool_use` / `web_*_tool_result`
14
+ * for the built-in web tools;
15
+ * - OpenAI Responses: output[] items `mcp_call` (name, arguments as a JSON
16
+ * string, output as a string) and `web_search_call`.
17
+ * The polling worker stores that body on the row (spilling to S3 past 300 KB,
18
+ * never truncating), and a streamed turn's assembled body keeps the same blocks
19
+ * (sse.ts copies each block's skeleton and its JSON input verbatim), so the
20
+ * same reader works live, after a stream and after a reload. When a body has no
21
+ * tool blocks at all (a host that strips them, a provider shape this does not
22
+ * know), the note falls back to what the ANSWER names: db: file links and src::
23
+ * paths. Nothing is ever invented: an empty note is null, not a guess.
24
+ *
25
+ * WHAT IS KEPT, per turn: files (storage paths), tables, access groups,
26
+ * searchText hits as path + page, the tool names, and whether a web tool ran.
27
+ * Arguments are read by KEY, never by position, and only the keys the
28
+ * BunnyQuery tools use for those four things; a query's filter values, a
29
+ * record's data and a tool result's text are never copied, so the note cannot
30
+ * leak a page of a document into every later turn.
31
+ *
32
+ * HOW IT REACHES THE MODEL. buildBoundedChatMessages appends the formatted note
33
+ * to the LLM copy of the assistant turn it belongs to (never the displayed
34
+ * bubble), for the newest RETRIEVAL_NOTES_MAX turns and within
35
+ * RETRIEVAL_NOTES_TOKEN_BUDGET tokens, so the memory is bounded however long
36
+ * the chat gets. A host that never attaches `_retrieval` sees byte-identical
37
+ * requests to today.
38
+ */
39
+
40
+ export type RetrievalHit = { path: string; page: number };
41
+
42
+ export type RetrievalNote = {
43
+ /** Storage paths (the part after "src::") the turn read, queried or linked. */
44
+ files: string[];
45
+ /** Table names the turn queried. */
46
+ tables: string[];
47
+ /** Access groups the turn queried at, as written in the call. */
48
+ accessGroups: string[];
49
+ /** searchText hits: the page a term was found on, per file. */
50
+ hits: RetrievalHit[];
51
+ /** Tool names, in order of first use. */
52
+ tools: string[];
53
+ /** A web tool ran in this turn (web_search / web_fetch). */
54
+ web: boolean;
55
+ /** 'tools' when read off the body's tool blocks; 'answer' when only the
56
+ * answer text was available to read. */
57
+ source: 'tools' | 'answer' | 'body';
58
+ };
59
+
60
+ /** How many of the newest turns carry their note into the request. */
61
+ export const RETRIEVAL_NOTES_MAX = 6;
62
+ /** Ceiling on the estimated tokens all carried notes may add to one request. */
63
+ export const RETRIEVAL_NOTES_TOKEN_BUDGET = 1200;
64
+ /** Per-list cap inside one note (files, tables, groups, hits, tools). */
65
+ export const RETRIEVAL_NOTE_MAX_ITEMS = 8;
66
+ /** Hard cap on one formatted note. */
67
+ export const RETRIEVAL_NOTE_MAX_CHARS = 600;
68
+ /** The marker a carried note opens with; also what keeps it out of the model's own voice. */
69
+ export const RETRIEVAL_NOTE_PREFIX = '[Retrieval note, not shown to the user';
70
+
71
+ // Argument keys the BunnyQuery tools spell a file, a table and a group with.
72
+ // Read by key only: a value under any other key is not looked at.
73
+ const FILE_KEYS = ['path', 'storage_path', 'storagePath', 'file', 'file_path', 'filePath'];
74
+ const TABLE_KEYS = ['table_name', 'tableName'];
75
+ const GROUP_KEYS = ['access_group', 'accessGroup'];
76
+ const SRC_PREFIX = 'src::';
77
+ // Bounded walk over a tool RESULT looking for { path, page } hits. The caps keep a
78
+ // 60,000-character page listing from being walked node by node.
79
+ const RESULT_WALK_MAX_NODES = 400;
80
+ const RESULT_WALK_MAX_DEPTH = 5;
81
+
82
+ function isWebToolName(name: string | undefined): boolean {
83
+ return typeof name === 'string' && /^web_(search|fetch)/.test(name);
84
+ }
85
+
86
+ function pushUnique(list: string[], value: unknown): void {
87
+ if (typeof value !== 'string') return;
88
+ var v = value.trim();
89
+ if (!v || list.indexOf(v) !== -1 || list.length >= RETRIEVAL_NOTE_MAX_ITEMS) return;
90
+ list.push(v);
91
+ }
92
+
93
+ function pushGroup(list: string[], value: unknown): void {
94
+ if (typeof value === 'number' && isFinite(value)) pushUnique(list, String(value));
95
+ else pushUnique(list, value);
96
+ }
97
+
98
+ function pushHit(list: RetrievalHit[], path: unknown, page: unknown): void {
99
+ if (typeof path !== 'string' || !path.trim()) return;
100
+ var n = typeof page === 'number' ? page : Number(page);
101
+ if (!isFinite(n) || n <= 0) return;
102
+ n = Math.floor(n);
103
+ for (var i = 0; i < list.length; i++) {
104
+ if (list[i].path === path && list[i].page === n) return;
105
+ }
106
+ if (list.length >= RETRIEVAL_NOTE_MAX_ITEMS) return;
107
+ list.push({ path: path.trim(), page: n });
108
+ }
109
+
110
+ /** A storage path out of a src:: id, or null when the value is not one. */
111
+ function pathFromSrcId(value: unknown): string | null {
112
+ if (typeof value !== 'string') return null;
113
+ if (value.indexOf(SRC_PREFIX) !== 0) return null;
114
+ var rest = value.slice(SRC_PREFIX.length).trim();
115
+ return rest ? rest : null;
116
+ }
117
+
118
+ function parseJsonLoose(value: unknown): any {
119
+ if (typeof value !== 'string') return value;
120
+ var t = value.trim();
121
+ if (!t || (t[0] !== '{' && t[0] !== '[')) return null;
122
+ try { return JSON.parse(t); } catch (e) { return null; }
123
+ }
124
+
125
+ /** Read what a tool CALL names: its file, table and access group arguments. */
126
+ function readArguments(note: RetrievalNote, args: any): void {
127
+ if (!args || typeof args !== 'object') return;
128
+ var i: number;
129
+ for (i = 0; i < FILE_KEYS.length; i++) pushUnique(note.files, args[FILE_KEYS[i]]);
130
+ for (i = 0; i < TABLE_KEYS.length; i++) pushUnique(note.tables, args[TABLE_KEYS[i]]);
131
+ for (i = 0; i < GROUP_KEYS.length; i++) pushGroup(note.accessGroups, args[GROUP_KEYS[i]]);
132
+ // `table` is either a name or { name, access_group }.
133
+ if (typeof args.table === 'string') pushUnique(note.tables, args.table);
134
+ else if (args.table && typeof args.table === 'object') {
135
+ pushUnique(note.tables, args.table.name);
136
+ pushGroup(note.accessGroups, args.table.access_group);
137
+ }
138
+ // A reference or unique_id of the form src::<path> names a file. A bare
139
+ // "src::" (the file-listing prefix query) names none.
140
+ var ref = pathFromSrcId(args.reference);
141
+ if (ref) pushUnique(note.files, ref);
142
+ var uid = pathFromSrcId(args.unique_id);
143
+ if (uid) pushUnique(note.files, uid);
144
+ }
145
+
146
+ /** Collect { path, page } pairs out of a tool RESULT, bounded. */
147
+ function readHits(note: RetrievalNote, result: any): void {
148
+ var parsed = parseJsonLoose(result);
149
+ if (!parsed || typeof parsed !== 'object') return;
150
+ var visited = 0;
151
+ var walk = function (node: any, depth: number): void {
152
+ if (!node || typeof node !== 'object' || depth > RESULT_WALK_MAX_DEPTH) return;
153
+ if (++visited > RESULT_WALK_MAX_NODES) return;
154
+ if (Array.isArray(node)) {
155
+ for (var i = 0; i < node.length && visited <= RESULT_WALK_MAX_NODES; i++) walk(node[i], depth + 1);
156
+ return;
157
+ }
158
+ if (typeof node.path === 'string' && node.page !== undefined) {
159
+ pushHit(note.hits, node.path, node.page);
160
+ }
161
+ for (var k in node) {
162
+ if (!Object.prototype.hasOwnProperty.call(node, k)) continue;
163
+ var v = node[k];
164
+ if (v && typeof v === 'object') walk(v, depth + 1);
165
+ }
166
+ };
167
+ walk(parsed, 0);
168
+ }
169
+
170
+ /** The text of an Anthropic tool result block's content. */
171
+ function claudeResultText(block: any): string {
172
+ var c = block && block.content;
173
+ if (typeof c === 'string') return c;
174
+ if (Array.isArray(c)) {
175
+ return c.map(function (part: any) { return part && typeof part.text === 'string' ? part.text : ''; }).join('\n');
176
+ }
177
+ return '';
178
+ }
179
+
180
+ function emptyNote(source: 'tools' | 'answer' | 'body'): RetrievalNote {
181
+ return { files: [], tables: [], accessGroups: [], hits: [], tools: [], web: false, source: source };
182
+ }
183
+
184
+ function noteHasContent(note: RetrievalNote): boolean {
185
+ return note.files.length > 0 || note.tables.length > 0 || note.accessGroups.length > 0
186
+ || note.hits.length > 0 || note.tools.length > 0 || note.web;
187
+ }
188
+
189
+ /**
190
+ * Read the tool loop off a provider body. Returns a note with `source: 'tools'`
191
+ * when the body carried tool blocks (even failed ones), or null when it carried
192
+ * none, so the caller can fall back to the answer text.
193
+ */
194
+ function noteFromToolBlocks(body: any): RetrievalNote | null {
195
+ if (!body || typeof body !== 'object') return null;
196
+ var note = emptyNote('tools');
197
+ var sawTool = false;
198
+ var i: number;
199
+
200
+ // Anthropic Messages: content[] blocks.
201
+ var content = body.content;
202
+ if (Array.isArray(content)) {
203
+ // Pair each result with its call by tool_use_id so a searchText RESULT is
204
+ // read for hits only when it answered a searchText CALL.
205
+ var callNames: Record<string, string> = {};
206
+ for (i = 0; i < content.length; i++) {
207
+ var block = content[i];
208
+ if (!block || typeof block !== 'object') continue;
209
+ var type = block.type;
210
+ if (type === 'mcp_tool_use' || type === 'tool_use') {
211
+ sawTool = true;
212
+ pushUnique(note.tools, block.name);
213
+ if (typeof block.id === 'string' && typeof block.name === 'string') callNames[block.id] = block.name;
214
+ readArguments(note, block.input);
215
+ } else if (type === 'server_tool_use' || type === 'web_search_tool_use') {
216
+ sawTool = true;
217
+ if (isWebToolName(block.name) || type === 'web_search_tool_use') note.web = true;
218
+ pushUnique(note.tools, block.name || type);
219
+ } else if (type === 'mcp_tool_result' || type === 'tool_result') {
220
+ sawTool = true;
221
+ var name = typeof block.tool_use_id === 'string' ? callNames[block.tool_use_id] : undefined;
222
+ if (!block.is_error && (name === undefined || name === 'searchText')) readHits(note, claudeResultText(block));
223
+ } else if (type === 'web_search_tool_result' || type === 'web_fetch_tool_result') {
224
+ sawTool = true;
225
+ note.web = true;
226
+ }
227
+ }
228
+ }
229
+
230
+ // OpenAI Responses: output[] items.
231
+ var output = body.output;
232
+ if (Array.isArray(output)) {
233
+ for (i = 0; i < output.length; i++) {
234
+ var item = output[i];
235
+ if (!item || typeof item !== 'object') continue;
236
+ var kind = typeof item.type === 'string' ? item.type : '';
237
+ if (kind === 'mcp_call' || kind === 'function_call') {
238
+ sawTool = true;
239
+ pushUnique(note.tools, item.name);
240
+ readArguments(note, parseJsonLoose(item.arguments) || item.arguments);
241
+ if (!item.error && item.name === 'searchText') readHits(note, item.output);
242
+ } else if (kind.indexOf('web_search') === 0 || kind.indexOf('web_fetch') === 0) {
243
+ sawTool = true;
244
+ note.web = true;
245
+ pushUnique(note.tools, kind);
246
+ }
247
+ }
248
+ }
249
+
250
+ return sawTool ? note : null;
251
+ }
252
+
253
+ /**
254
+ * What an ANSWER names: db: file links ("[name](db:path)") and src:: ids. The
255
+ * fallback for a body with no tool blocks, and merged into a tool note too so a
256
+ * file the model linked but never queried (it knew the path from history) is
257
+ * still remembered.
258
+ */
259
+ export function retrievalNoteFromText(text: string | null | undefined): RetrievalNote | null {
260
+ if (typeof text !== 'string' || !text) return null;
261
+ var note = emptyNote('answer');
262
+ // [label](db:path) and [label](db:path "title"); the path ends at the closing
263
+ // paren or at the title's opening quote. Bounded character class, no nested
264
+ // quantifiers, so a long answer cannot make this slow.
265
+ var link = /\]\(db:([^)\s"]+)/g;
266
+ var m: RegExpExecArray | null;
267
+ while ((m = link.exec(text)) !== null) pushUnique(note.files, m[1]);
268
+ var src = /src::([^\s"'`<>)\]]+)/g;
269
+ while ((m = src.exec(text)) !== null) {
270
+ // Trailing sentence punctuation is prose, not path.
271
+ pushUnique(note.files, m[1].replace(/[.,;:!?]+$/, ''));
272
+ }
273
+ return noteHasContent(note) ? note : null;
274
+ }
275
+
276
+ /**
277
+ * The note for one assistant turn: the body's tool loop when it has one, else
278
+ * what the answer names, else null. `answerText` is the extracted answer, read
279
+ * for file links in both cases.
280
+ */
281
+ export function extractRetrievalNote(body: any, answerText?: string | null): RetrievalNote | null {
282
+ var fromTools = noteFromToolBlocks(body);
283
+ var fromText = retrievalNoteFromText(answerText);
284
+ if (!fromTools) {
285
+ if (fromText) return fromText;
286
+ // A provider body that was read and holds NO tool use at all is a fact worth
287
+ // keeping, not an absence: it is exactly the turn the "no project data was
288
+ // read" mark exists for (citations.ts answerUsedNoDataTool). An empty note
289
+ // formats to '' (formatRetrievalNote), so it costs the history nothing. A body
290
+ // that is not a provider body (null, a bare status row) still yields null:
291
+ // nothing was read, so nothing is claimed.
292
+ return providerBodyWasRead(body) ? emptyNote('body') : null;
293
+ }
294
+ if (fromText) {
295
+ for (var i = 0; i < fromText.files.length; i++) pushUnique(fromTools.files, fromText.files[i]);
296
+ }
297
+ return noteHasContent(fromTools) ? fromTools : emptyNote('body');
298
+ }
299
+
300
+ /**
301
+ * True when `body` is a provider response this module can read: an Anthropic
302
+ * Messages body (`content` array) or an OpenAI Responses body (`output` array).
303
+ */
304
+ export function providerBodyWasRead(body: any): boolean {
305
+ if (!body || typeof body !== 'object') return false;
306
+ return Array.isArray(body.content) || Array.isArray(body.output);
307
+ }
308
+
309
+ /** True when the value is a note this module produced (or an equivalent shape). */
310
+ export function isRetrievalNote(value: any): value is RetrievalNote {
311
+ return !!value && typeof value === 'object'
312
+ && Array.isArray(value.files) && Array.isArray(value.tables)
313
+ && Array.isArray(value.accessGroups) && Array.isArray(value.hits)
314
+ && Array.isArray(value.tools);
315
+ }
316
+
317
+ /**
318
+ * One line the model reads. Sections are omitted when empty, the whole thing is
319
+ * capped at RETRIEVAL_NOTE_MAX_CHARS, and it opens with RETRIEVAL_NOTE_PREFIX so
320
+ * the model can tell it from its own earlier words. Returns '' for an empty note.
321
+ */
322
+ export function formatRetrievalNote(note: RetrievalNote | null | undefined): string {
323
+ if (!note || !noteHasContent(note)) return '';
324
+ var parts: string[] = [];
325
+ if (note.files.length) parts.push('files ' + note.files.join(', '));
326
+ if (note.tables.length) parts.push('tables ' + note.tables.join(', '));
327
+ if (note.accessGroups.length) parts.push('access_group ' + note.accessGroups.join(', '));
328
+ if (note.hits.length) {
329
+ // Grouped per file: "report.pdf p3, p7".
330
+ var byFile: Record<string, number[]> = {};
331
+ var order: string[] = [];
332
+ for (var i = 0; i < note.hits.length; i++) {
333
+ var h = note.hits[i];
334
+ if (!byFile[h.path]) { byFile[h.path] = []; order.push(h.path); }
335
+ byFile[h.path].push(h.page);
336
+ }
337
+ parts.push('searchText hits ' + order.map(function (p) {
338
+ return p + ' p' + byFile[p].join(', p');
339
+ }).join('; '));
340
+ }
341
+ if (note.tools.length) parts.push('tools ' + note.tools.join(', '));
342
+ if (note.web) parts.push('web tool used');
343
+ var line = RETRIEVAL_NOTE_PREFIX + ': this turn used ' + parts.join(' | ') + ']';
344
+ if (line.length > RETRIEVAL_NOTE_MAX_CHARS) {
345
+ line = line.slice(0, RETRIEVAL_NOTE_MAX_CHARS - 4) + '...]';
346
+ }
347
+ return line;
348
+ }