bunnyquery 1.13.0 → 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 +567 -144
- package/dist/engine.cjs +566 -154
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +295 -2
- package/dist/engine.d.ts +295 -2
- package/dist/engine.mjs +542 -155
- 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 +9 -0
- 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 +27 -1
- package/src/engine/requests.ts +64 -35
- package/src/engine/retrieval_memory.ts +348 -0
- package/src/engine/session.ts +33 -4
- 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
|
+
}
|
package/src/engine/session.ts
CHANGED
|
@@ -48,6 +48,7 @@ import { windowedIndexingEnabled, liveStreamingEnabled, streamRecoveryEnabled, c
|
|
|
48
48
|
import { createSseParser, type SseParser } from './sse';
|
|
49
49
|
import { isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError, getErrorMessage, isCsrStatusEnvelope } from './errors';
|
|
50
50
|
import { buildBoundedChatMessages } from './budget';
|
|
51
|
+
import { extractRetrievalNote } from './retrieval_memory';
|
|
51
52
|
import { createInlineLinkRegex, sanitizeAttachmentLinksForHistory } from './links';
|
|
52
53
|
import { markImagePreviewStale } from './image_preview';
|
|
53
54
|
import { chatCacheKey, indexScopeKey, mapHistoryListToMessages, extractLastUserTextFromRequest, isIndexingRequestText, parseIndexingRequestText, probeBgQueue, BG_PROBE_TTL_MS, getSplitChatHistory, shouldRescueInFlightMessage, adoptLocalAnswerIntoPage } from './history';
|
|
@@ -2109,6 +2110,10 @@ export class ChatSession {
|
|
|
2109
2110
|
var reply: ChatMessage = isErr
|
|
2110
2111
|
? { role: 'assistant', content: getErrorMessage(body), isError: true, _serverItemId: itemId }
|
|
2111
2112
|
: { role: 'assistant', content: text, _serverItemId: itemId };
|
|
2113
|
+
if (!isErr) {
|
|
2114
|
+
var rNote = extractRetrievalNote(body, text);
|
|
2115
|
+
if (rNote) reply._retrieval = rNote;
|
|
2116
|
+
}
|
|
2112
2117
|
if (ownerKey && this.getHistoryCacheKey() !== ownerKey) {
|
|
2113
2118
|
// The reader is in another chat. Settle it where it belongs and touch
|
|
2114
2119
|
// nothing on screen - state.messages is a different conversation's list.
|
|
@@ -2616,7 +2621,9 @@ export class ChatSession {
|
|
|
2616
2621
|
if (isErrorResponseBody(response)) return { content: getErrorMessage(response), isError: true };
|
|
2617
2622
|
var answer = (params.aiPlatform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response));
|
|
2618
2623
|
answer = (answer || '').trim();
|
|
2619
|
-
|
|
2624
|
+
// Retrieval memory: what this turn read, off the body's tool blocks (a
|
|
2625
|
+
// streamed turn's `response` is already the assembled body here).
|
|
2626
|
+
return { content: answer || 'No text response received from AI provider.', isError: false, retrieval: extractRetrievalNote(response, answer) };
|
|
2620
2627
|
})
|
|
2621
2628
|
.catch(function (err: any) { return { content: getErrorMessage(err), isError: true }; })
|
|
2622
2629
|
.then(function (result: any) {
|
|
@@ -2635,6 +2642,7 @@ export class ChatSession {
|
|
|
2635
2642
|
}
|
|
2636
2643
|
var reply: ChatMessage = { role: 'assistant', content: result.content, isError: result.isError };
|
|
2637
2644
|
if (dispatchItemId) reply._serverItemId = dispatchItemId;
|
|
2645
|
+
if (result.retrieval) reply._retrieval = result.retrieval;
|
|
2638
2646
|
|
|
2639
2647
|
// REPLACE the trailing pending "Thinking..." bubble in the cache with
|
|
2640
2648
|
// the answer, regardless of whether this chat is the currently-visible
|
|
@@ -3470,6 +3478,10 @@ export class ChatSession {
|
|
|
3470
3478
|
var offReply: ChatMessage = isErrorResponseBody(response)
|
|
3471
3479
|
? { role: 'assistant', content: getErrorMessage(response), isError: true }
|
|
3472
3480
|
: { role: 'assistant', content: ((platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response)) || '').trim() || 'No text response received from AI provider.' };
|
|
3481
|
+
if (!offReply.isError) {
|
|
3482
|
+
var offNote = extractRetrievalNote(response, offReply.content);
|
|
3483
|
+
if (offNote) offReply._retrieval = offNote;
|
|
3484
|
+
}
|
|
3473
3485
|
this._applyReplyToCache(ownerKey, offReply, serverId);
|
|
3474
3486
|
if (serverId) this.cancelledServerIds.delete(serverId);
|
|
3475
3487
|
return;
|
|
@@ -3485,6 +3497,9 @@ export class ChatSession {
|
|
|
3485
3497
|
} else {
|
|
3486
3498
|
var answer = (platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response));
|
|
3487
3499
|
answer = (answer || '').trim() || 'No text response received from AI provider.';
|
|
3500
|
+
// Retrieval memory for this turn, attached to whichever bubble below ends up
|
|
3501
|
+
// holding the answer.
|
|
3502
|
+
var qNote = extractRetrievalNote(response, answer);
|
|
3488
3503
|
var lid = this._newLocalId();
|
|
3489
3504
|
if (targetIdx >= 0 && this.state.messages[targetIdx] && this.state.messages[targetIdx].isPending) {
|
|
3490
3505
|
// Same resume as the immediate-send path: keep what the stream painted
|
|
@@ -3498,14 +3513,19 @@ export class ChatSession {
|
|
|
3498
3513
|
var qSettled: any = { role: 'assistant', content: qPainted, _localId: lid };
|
|
3499
3514
|
if (prevQ._serverItemId) qSettled._serverItemId = prevQ._serverItemId;
|
|
3500
3515
|
if (prevQ._ownerKey) qSettled._ownerKey = prevQ._ownerKey;
|
|
3516
|
+
if (qNote) qSettled._retrieval = qNote;
|
|
3501
3517
|
this.state.messages[targetIdx] = qSettled;
|
|
3502
3518
|
this.host.notify(); this.enqueueTypewrite(targetIdx, answer, lid, qPainted);
|
|
3503
3519
|
} else if (targetIdx >= 0) {
|
|
3504
|
-
|
|
3520
|
+
var qInsert: ChatMessage = { role: 'assistant', content: '', _localId: lid };
|
|
3521
|
+
if (qNote) qInsert._retrieval = qNote;
|
|
3522
|
+
this.state.messages.splice(targetIdx, 0, qInsert);
|
|
3505
3523
|
this.host.notify(); this.enqueueTypewrite(targetIdx, answer, lid);
|
|
3506
3524
|
} else {
|
|
3507
3525
|
var aiIdx = this.state.messages.length;
|
|
3508
|
-
|
|
3526
|
+
var qAppend: ChatMessage = { role: 'assistant', content: '', _localId: lid };
|
|
3527
|
+
if (qNote) qAppend._retrieval = qNote;
|
|
3528
|
+
this.state.messages.push(qAppend);
|
|
3509
3529
|
this.host.notify(); this.enqueueTypewrite(aiIdx, answer, lid);
|
|
3510
3530
|
}
|
|
3511
3531
|
}
|
|
@@ -4165,6 +4185,9 @@ export class ChatSession {
|
|
|
4165
4185
|
var settled: any = { role: 'assistant', content: painted, isPending: false, _localId: lid };
|
|
4166
4186
|
if (prevSettled._serverItemId) settled._serverItemId = prevSettled._serverItemId;
|
|
4167
4187
|
if (prevSettled._ownerKey) settled._ownerKey = prevSettled._ownerKey;
|
|
4188
|
+
// The retrieval note rides with the cached reply; the typewriter only
|
|
4189
|
+
// writes `content`, so it survives the reveal.
|
|
4190
|
+
if (latest._retrieval) settled._retrieval = latest._retrieval;
|
|
4168
4191
|
this.state.messages[pendingIdx] = settled;
|
|
4169
4192
|
this._removeStrayPendingAssistants();
|
|
4170
4193
|
this.host.notify();
|
|
@@ -4458,6 +4481,9 @@ export class ChatSession {
|
|
|
4458
4481
|
// before and after a reload. Only an INDEXING pass has a protocol token to
|
|
4459
4482
|
// hide.
|
|
4460
4483
|
var reportedComplete = !isErr && !!answer && answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1;
|
|
4484
|
+
// Retrieval memory for a CHAT turn that settled through a re-attached poll.
|
|
4485
|
+
// Read once here; the background branches below never use it.
|
|
4486
|
+
var hNote = isErr ? null : extractRetrievalNote(response, answer);
|
|
4461
4487
|
var stripMarker = function (t: string): string {
|
|
4462
4488
|
return reportedComplete ? t.split(INDEXING_COMPLETE_MARKER).join('').trim() : t;
|
|
4463
4489
|
};
|
|
@@ -4499,7 +4525,9 @@ export class ChatSession {
|
|
|
4499
4525
|
// the reader watched arrive.
|
|
4500
4526
|
var hPainted = this._paintedTextAt(idx);
|
|
4501
4527
|
var lid = this._newLocalId();
|
|
4502
|
-
|
|
4528
|
+
var hReply: ChatMessage = { role: 'assistant', content: hPainted, _localId: lid, _serverItemId: itemId };
|
|
4529
|
+
if (hNote) hReply._retrieval = hNote;
|
|
4530
|
+
this.state.messages[idx] = hReply;
|
|
4503
4531
|
this.host.notify(); this.enqueueTypewrite(idx, text, lid, hPainted);
|
|
4504
4532
|
this.updateHistoryCache(); return;
|
|
4505
4533
|
}
|
|
@@ -4532,6 +4560,7 @@ export class ChatSession {
|
|
|
4532
4560
|
}
|
|
4533
4561
|
var lid2 = this._newLocalId();
|
|
4534
4562
|
var reply: ChatMessage = { role: 'assistant', content: '', _localId: lid2, _serverItemId: itemId };
|
|
4563
|
+
if (hNote) reply._retrieval = hNote;
|
|
4535
4564
|
this.state.messages.splice(userIdx + 1, 0, reply);
|
|
4536
4565
|
this.host.notify(); this.enqueueTypewrite(userIdx + 1, text2, lid2);
|
|
4537
4566
|
this.updateHistoryCache();
|