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.
- package/README.md +4 -2
- package/bunnyquery.css +31 -0
- package/bunnyquery.js +653 -147
- package/dist/engine.cjs +600 -157
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +309 -2
- package/dist/engine.d.ts +309 -2
- package/dist/engine.mjs +576 -158
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/budget.ts +43 -1
- package/src/engine/citations.ts +181 -0
- package/src/engine/config.ts +44 -0
- package/src/engine/history.ts +19 -1
- package/src/engine/host.ts +7 -0
- package/src/engine/index.ts +17 -0
- package/src/engine/project_settings.ts +40 -0
- package/src/engine/prompts/chat_system_prompt.ts +50 -1
- package/src/engine/requests.ts +64 -35
- package/src/engine/retrieval_memory.ts +348 -0
- package/src/engine/session.ts +71 -7
- package/styles/chat.css +31 -0
package/src/engine/requests.ts
CHANGED
|
@@ -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
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1248
|
-
|
|
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
|
+
}
|