bunnyquery 1.7.0 → 1.8.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunnyquery",
3
- "version": "1.7.0",
3
+ "version": "1.8.1",
4
4
  "description": "Embeddable BunnyQuery AI chat widget + its framework-agnostic chat engine",
5
5
  "main": "bunnyquery.js",
6
6
  "exports": {
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The `ai_agent` service option, parsed and serialized in ONE place.
3
+ *
4
+ * Stored on the skapi service record as up to three '#'-delimited segments:
5
+ *
6
+ * none AI chat disabled
7
+ * claude platform chosen, no model saved yet
8
+ * claude#claude-sonnet-4-6 platform + model
9
+ * claude#claude-sonnet-4-6#400000 platform + model + context-window override
10
+ *
11
+ * The third segment is new and optional, so every value written before it
12
+ * existed parses unchanged with `contextWindow: null`. Nothing writes a third
13
+ * segment without a model, because the window is meaningless without one.
14
+ *
15
+ * This lived as four separate copies (agent.vue, dbfile.vue, service.vue, and
16
+ * the widget's index.js), which is how the format drifts. New callers should
17
+ * import from here rather than re-deriving the split.
18
+ */
19
+
20
+ export type AiAgentPlatform = 'claude' | 'openai' | null;
21
+
22
+ export type ParsedAiAgent = {
23
+ /** null when unset or explicitly 'none'. */
24
+ platform: AiAgentPlatform;
25
+ /** '' when no model has been saved. */
26
+ model: string;
27
+ /** Per-project context-window override in tokens, or null to use the model's. */
28
+ contextWindow: number | null;
29
+ /** True when a real platform is configured (i.e. not unset and not 'none'). */
30
+ hasPlatform: boolean;
31
+ };
32
+
33
+ function normalizePlatform(raw: string): AiAgentPlatform {
34
+ var p = (raw || '').trim().toLowerCase();
35
+ return p === 'claude' || p === 'openai' ? p : null;
36
+ }
37
+
38
+ export function parseAiAgentValue(value: string | null | undefined): ParsedAiAgent {
39
+ var raw = (value || '').trim();
40
+ if (!raw || raw.toLowerCase() === 'none') {
41
+ return { platform: null, model: '', contextWindow: null, hasPlatform: false };
42
+ }
43
+
44
+ // Split into at most three parts so a '#' inside a model id (none today, but
45
+ // the delimiter should not be load-bearing on the tail) cannot shift fields.
46
+ var firstHash = raw.indexOf('#');
47
+ if (firstHash === -1) {
48
+ var only = normalizePlatform(raw);
49
+ return { platform: only, model: '', contextWindow: null, hasPlatform: !!only };
50
+ }
51
+
52
+ var platform = normalizePlatform(raw.slice(0, firstHash));
53
+ var rest = raw.slice(firstHash + 1);
54
+ var secondHash = rest.indexOf('#');
55
+ var model = (secondHash === -1 ? rest : rest.slice(0, secondHash)).trim();
56
+ var windowRaw = secondHash === -1 ? '' : rest.slice(secondHash + 1).trim();
57
+
58
+ var parsedWindow = windowRaw ? Number(windowRaw) : NaN;
59
+ var contextWindow = Number.isFinite(parsedWindow) && parsedWindow > 0
60
+ ? Math.floor(parsedWindow)
61
+ : null;
62
+
63
+ return { platform: platform, model: model, contextWindow: contextWindow, hasPlatform: !!platform };
64
+ }
65
+
66
+ export function buildAiAgentValue(
67
+ platform: string | null | undefined,
68
+ model?: string | null,
69
+ contextWindow?: number | null,
70
+ ): string {
71
+ var p = (platform || '').trim().toLowerCase();
72
+ if (!p || p === 'none') return 'none';
73
+
74
+ var m = (model || '').trim();
75
+ if (!m) return p;
76
+
77
+ var n = Number(contextWindow);
78
+ if (!Number.isFinite(n) || n <= 0) return p + '#' + m;
79
+ return p + '#' + m + '#' + Math.floor(n);
80
+ }
@@ -7,15 +7,76 @@
7
7
  import { sanitizeAttachmentLinksForHistory } from './links';
8
8
 
9
9
  export var CONTEXT_WINDOW_DEFAULT: Record<string, number> = { claude: 200000, openai: 128000 };
10
+ // Exact model ids first, then family keys (see getContextWindow: a suffixed id
11
+ // falls back to its family rather than to the platform default). Claude figures
12
+ // come from Anthropic's published model table; `max_input_tokens` from
13
+ // /v1/models overrides all of this at runtime when a listing has been seen.
14
+ // 'claude-opus-4-7' read 200000 here, which was wrong by 5x, and the default
15
+ // Claude model 'claude-sonnet-4-6' was absent entirely so it fell through to
16
+ // CONTEXT_WINDOW_DEFAULT.claude. Neither was observable, because the history
17
+ // budget and the Claude per-request cap below bind long before the window does.
10
18
  export var CONTEXT_WINDOW_BY_MODEL: Record<string, number> = {
11
- 'claude-opus-4-7': 200000, 'claude-sonnet-4': 200000, 'gpt-5.4': 128000,
19
+ // exact ids
20
+ 'claude-opus-5': 1000000, 'claude-opus-4-8': 1000000, 'claude-opus-4-7': 1000000,
21
+ 'claude-sonnet-5': 1000000, 'claude-sonnet-4-6': 1000000, 'claude-sonnet-4': 200000,
22
+ 'claude-haiku-4-5': 200000, 'gpt-5.4': 128000, 'gpt-5.6-luna': 128000,
23
+ // family keys
24
+ 'claude-opus': 1000000, 'claude-sonnet': 1000000, 'claude-haiku': 200000,
25
+ 'gpt-5.6': 128000, 'gpt-5': 128000,
12
26
  };
27
+
28
+ // Context windows reported by a provider's own models listing, keyed by model id.
29
+ // Anthropic's GET /v1/models returns `max_input_tokens` per model, which is the
30
+ // authoritative context window; OpenAI's listing carries no equivalent field, so
31
+ // OpenAI models resolve from the table above. Populated by
32
+ // registerModelContextWindows() when a client fetches its model list.
33
+ var apiReportedContextWindows: Record<string, number> = {};
34
+
35
+ /**
36
+ * Record context windows from a provider models listing. Accepts the raw list
37
+ * items and reads `max_input_tokens` (Anthropic); items without it are skipped,
38
+ * so passing an OpenAI listing is a no-op rather than an error.
39
+ */
40
+ export function registerModelContextWindows(models: Array<{ id?: string; max_input_tokens?: number }> | null | undefined): void {
41
+ if (!Array.isArray(models)) return;
42
+ for (var i = 0; i < models.length; i++) {
43
+ var m = models[i];
44
+ var id = (m && m.id ? String(m.id) : '').trim().toLowerCase();
45
+ var reported = m ? Number(m.max_input_tokens) : NaN;
46
+ if (id && Number.isFinite(reported) && reported > 0) {
47
+ apiReportedContextWindows[id] = Math.floor(reported);
48
+ }
49
+ }
50
+ }
51
+
52
+ /** Per-project override, keyed by service id. Set from the project settings. */
53
+ var projectContextWindows: Record<string, number> = {};
54
+
55
+ export function setProjectContextWindow(serviceId: string, tokens: number | null | undefined): void {
56
+ var key = (serviceId || '').trim();
57
+ if (!key) return;
58
+ var n = Number(tokens);
59
+ if (Number.isFinite(n) && n > 0) projectContextWindows[key] = Math.floor(n);
60
+ else delete projectContextWindows[key];
61
+ }
62
+
63
+ export function getProjectContextWindow(serviceId: string): number | null {
64
+ var key = (serviceId || '').trim();
65
+ return key && projectContextWindows[key] ? projectContextWindows[key] : null;
66
+ }
13
67
  export var OUTPUT_TOKEN_RESERVE = 22000;
14
68
  export var TOOL_AND_RESPONSE_BUFFER = 4000;
15
69
  export var MIN_INPUT_TOKEN_BUDGET = 8000;
16
70
  export var CLAUDE_PER_REQUEST_INPUT_CAP = 28000;
17
71
  export var MAX_HISTORY_MESSAGES = 20;
18
72
  export var HISTORY_TOKEN_BUDGET = 8000;
73
+ // Ratios that scale the two ceilings above off the resolved context window.
74
+ // Calibrated to reproduce the previous fixed values at the default windows:
75
+ // claude 200000 -> cap 27840 (was 28000, so the 28000 floor holds), and
76
+ // openai 128000 -> history 8160 (~the previous 8000). Both are floored at the
77
+ // old constants, so these can only ever raise a budget, never lower one.
78
+ export var CLAUDE_INPUT_CAP_RATIO = 0.16;
79
+ export var HISTORY_BUDGET_RATIO = 0.08;
19
80
 
20
81
  export function estimateTextTokens(text: string): number {
21
82
  return Math.ceil((text || '').length / 3);
@@ -25,9 +86,34 @@ export function estimateMessageTokens(msg: { role: string; content: string }): n
25
86
  return estimateTextTokens(msg.content) + estimateTextTokens(msg.role) + 6;
26
87
  }
27
88
 
28
- export function getContextWindow(platform: string, model?: string): number {
89
+ /**
90
+ * Resolve a model's context window, most specific source first:
91
+ * 1. per-project override (project settings)
92
+ * 2. the provider's own models listing (Anthropic `max_input_tokens`)
93
+ * 3. an exact entry in CONTEXT_WINDOW_BY_MODEL
94
+ * 4. a family entry, by dropping trailing '-' segments off the id
95
+ * 5. the platform default
96
+ *
97
+ * Step 4 is why a new or suffixed id no longer drops straight to the platform
98
+ * default: 'gpt-5.6-luna' resolves via 'gpt-5.6', and a dated Claude snapshot
99
+ * such as 'claude-opus-4-7-20260101' resolves via 'claude-opus-4-7'. The walk
100
+ * stops at the first hit, so a more specific entry always wins over its family.
101
+ */
102
+ export function getContextWindow(platform: string, model?: string, serviceId?: string): number {
103
+ var override = serviceId ? getProjectContextWindow(serviceId) : null;
104
+ if (override) return override;
105
+
29
106
  var normalized = (model || '').trim().toLowerCase();
30
- if (normalized && CONTEXT_WINDOW_BY_MODEL[normalized]) return CONTEXT_WINDOW_BY_MODEL[normalized];
107
+ if (normalized) {
108
+ if (apiReportedContextWindows[normalized]) return apiReportedContextWindows[normalized];
109
+ if (CONTEXT_WINDOW_BY_MODEL[normalized]) return CONTEXT_WINDOW_BY_MODEL[normalized];
110
+
111
+ var parts = normalized.split('-');
112
+ for (var end = parts.length - 1; end > 0; end--) {
113
+ var family = parts.slice(0, end).join('-');
114
+ if (CONTEXT_WINDOW_BY_MODEL[family]) return CONTEXT_WINDOW_BY_MODEL[family];
115
+ }
116
+ }
31
117
  return CONTEXT_WINDOW_DEFAULT[platform];
32
118
  }
33
119
 
@@ -46,14 +132,34 @@ export type BoundedChatOptions = {
46
132
  };
47
133
 
48
134
  export function buildBoundedChatMessages(options: BoundedChatOptions) {
49
- var contextWindow = getContextWindow(options.platform, options.model);
135
+ var contextWindow = getContextWindow(options.platform, options.model, options.serviceId);
50
136
  var contextBasedBudget = Math.max(MIN_INPUT_TOKEN_BUDGET,
51
137
  contextWindow - OUTPUT_TOKEN_RESERVE - TOOL_AND_RESPONSE_BUFFER);
138
+ // Scaling is gated on an EXPLICIT per-project window set from project
139
+ // settings. Without one, the fixed ceilings apply exactly as before, so every
140
+ // existing project keeps byte-identical behavior and nobody's token spend
141
+ // moves because a table value was corrected. With one, the Claude per-request
142
+ // cap and the history budget scale off the window, so raising it genuinely
143
+ // sends more history instead of being absorbed by a hardcoded ceiling.
144
+ // Both derive from contextBasedBudget (pre-Claude-cap) so the two platforms
145
+ // scale symmetrically rather than the Claude cap compounding the ratio down.
146
+ var scaled = !!(options.serviceId && getProjectContextWindow(options.serviceId));
147
+ var claudeInputCap = scaled
148
+ ? Math.max(CLAUDE_PER_REQUEST_INPUT_CAP, Math.round(contextBasedBudget * CLAUDE_INPUT_CAP_RATIO))
149
+ : CLAUDE_PER_REQUEST_INPUT_CAP;
52
150
  var availableInputBudget = options.platform === 'claude'
53
- ? Math.min(contextBasedBudget, CLAUDE_PER_REQUEST_INPUT_CAP) : contextBasedBudget;
151
+ ? Math.min(contextBasedBudget, claudeInputCap) : contextBasedBudget;
54
152
  var systemCost = estimateTextTokens(options.systemPrompt) + 12;
55
- var budgetForHistory = Math.max(1000, Math.min(HISTORY_TOKEN_BUDGET, availableInputBudget - systemCost));
56
- var windowed = options.history.slice(-MAX_HISTORY_MESSAGES);
153
+ var historyAllowance = scaled
154
+ ? Math.max(HISTORY_TOKEN_BUDGET, Math.round(contextBasedBudget * HISTORY_BUDGET_RATIO))
155
+ : HISTORY_TOKEN_BUDGET;
156
+ var budgetForHistory = Math.max(1000, Math.min(historyAllowance, availableInputBudget - systemCost));
157
+ // The message count scales alongside the token budget; otherwise 20 messages
158
+ // is a second ceiling that swallows the extra budget on a raised window.
159
+ var maxHistoryMessages = scaled
160
+ ? Math.max(MAX_HISTORY_MESSAGES, Math.round(MAX_HISTORY_MESSAGES * (budgetForHistory / HISTORY_TOKEN_BUDGET)))
161
+ : MAX_HISTORY_MESSAGES;
162
+ var windowed = options.history.slice(-maxHistoryMessages);
57
163
  var latestIndex = windowed.length - 1;
58
164
  var trimmed = windowed.map(function (m, i) {
59
165
  if (i === latestIndex) return m;
@@ -40,6 +40,46 @@ export function extractLastUserTextFromRequest(requestBody: any): string {
40
40
  return '';
41
41
  }
42
42
 
43
+ /** The two openings an indexing prompt can have. A bg-queue item that starts with
44
+ * neither is an ordinary chat that happened to be routed onto that queue. */
45
+ export function isIndexingRequestText(userText: any): boolean {
46
+ if (typeof userText !== 'string') return false;
47
+ return userText.indexOf('A new file has just been uploaded') === 0 || userText.indexOf('CONTINUE indexing') === 0;
48
+ }
49
+
50
+ export type IndexingRequestRef = {
51
+ name: string;
52
+ path?: string;
53
+ mime?: string;
54
+ size?: number;
55
+ /** A CONTINUE pass rather than the run's first. */
56
+ continued: boolean;
57
+ };
58
+
59
+ /**
60
+ * The file an indexing prompt is about, read back out of the prompt itself.
61
+ *
62
+ * The prompt is the only description of the pass that survives on the server, so
63
+ * this is how BOTH a history rebuild and a worker-minted pass the client never
64
+ * dispatched (ChatSession._adoptWorkerIndexingPasses) recover the file. Shared so
65
+ * the two produce the same `_indexFile`, which is what makes them group together.
66
+ */
67
+ export function parseIndexingRequestText(userText: any): IndexingRequestRef | null {
68
+ if (typeof userText !== 'string' || !userText) return null;
69
+ var nameMatch = userText.match(/^- name: (.+)$/m);
70
+ if (!nameMatch) return null;
71
+ var mimeMatch = userText.match(/^- mime type: (.+)$/m);
72
+ var sizeMatch = userText.match(/^- size \(bytes\): (\d+)$/m);
73
+ var pathMatch = userText.match(/^- storage path: (.+)$/m);
74
+ return {
75
+ name: nameMatch[1].trim(),
76
+ path: pathMatch ? pathMatch[1].trim() : undefined,
77
+ mime: mimeMatch ? mimeMatch[1].trim() : undefined,
78
+ size: sizeMatch ? Number(sizeMatch[1]) : undefined,
79
+ continued: userText.indexOf('CONTINUE indexing') === 0,
80
+ };
81
+ }
82
+
43
83
  export type MapHistoryOptions = {
44
84
  clearedAt: number;
45
85
  serviceId: string;
@@ -64,6 +104,13 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
64
104
  var assistantText = isPending ? '' : ((extractAssistantText(response) || '').trim() || '');
65
105
  var isErrorResponse = !isPending && (isFailed || isErrorResponseBody(response));
66
106
  var serverItemId = item && typeof item.id === 'string' && item.id ? item.id : undefined;
107
+ // A USER bubble shows when the request was made (`created`); an ASSISTANT
108
+ // bubble shows when its response landed (`updated`). Fall back to the other
109
+ // if one is missing (older records only carried `updated`).
110
+ var createdTs = Number(item && item.created);
111
+ var updatedTs = Number(item && item.updated);
112
+ var userTs = isFinite(createdTs) && createdTs > 0 ? createdTs : (isFinite(updatedTs) && updatedTs > 0 ? updatedTs : undefined);
113
+ var replyTs = isFinite(updatedTs) && updatedTs > 0 ? updatedTs : (isFinite(createdTs) && createdTs > 0 ? createdTs : undefined);
67
114
 
68
115
  if (userText) {
69
116
  var displayContent;
@@ -71,31 +118,21 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
71
118
  // alongside the formatted label so grouping never has to parse it back.
72
119
  var indexFile: any = undefined;
73
120
  if (item._isBgTask) {
74
- var nameMatch = userText.match(/^- name: (.+)$/m);
75
- if (nameMatch) {
76
- var mimeMatch = userText.match(/^- mime type: (.+)$/m);
77
- var sizeMatch = userText.match(/^- size \(bytes\): (\d+)$/m);
78
- var pathMatch = userText.match(/^- storage path: (.+)$/m);
121
+ var ref = parseIndexingRequestText(userText);
122
+ if (ref) {
79
123
  // A CONTINUE pass ("CONTINUE indexing …") gets the compact
80
124
  // continuation label; a first pass ("A new file …") gets the full
81
125
  // one. Mirrors agent.vue's mapHistoryListToMessages so a big file's
82
126
  // windows read as progress, not the same task repeating.
83
- var isContinuePass = userText.indexOf('CONTINUE indexing') === 0;
84
127
  displayContent = opts.formatIndexingLabel(
85
- nameMatch[1].trim(),
86
- mimeMatch ? mimeMatch[1].trim() : '',
87
- sizeMatch ? Number(sizeMatch[1]) : null,
88
- pathMatch ? pathMatch[1].trim() : undefined,
128
+ ref.name,
129
+ ref.mime || '',
130
+ typeof ref.size === 'number' ? ref.size : null,
131
+ ref.path,
89
132
  false,
90
- isContinuePass
133
+ ref.continued
91
134
  );
92
- indexFile = {
93
- name: nameMatch[1].trim(),
94
- path: pathMatch ? pathMatch[1].trim() : undefined,
95
- mime: mimeMatch ? mimeMatch[1].trim() : undefined,
96
- size: sizeMatch ? Number(sizeMatch[1]) : undefined,
97
- continued: isContinuePass,
98
- };
135
+ indexFile = ref;
99
136
  } else {
100
137
  displayContent = userText;
101
138
  }
@@ -110,6 +147,7 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
110
147
  if (indexFile) userMsg._indexFile = indexFile;
111
148
  if (item._isOnBgQueue) userMsg._useBgQueue = true;
112
149
  if (serverItemId !== undefined) userMsg._serverItemId = serverItemId;
150
+ if (userTs !== undefined) userMsg._ts = userTs;
113
151
  mapped.push(userMsg);
114
152
  }
115
153
  if (isCancelledItem) { /* no assistant bubble */ }
@@ -123,6 +161,7 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
123
161
  var em: any = { role: 'assistant', content: getErrorMessage(response), isError: true };
124
162
  if (item._isBgTask) em.isBackgroundTask = true;
125
163
  if (serverItemId !== undefined) em._serverItemId = serverItemId;
164
+ if (replyTs !== undefined) em._ts = replyTs;
126
165
  mapped.push(em);
127
166
  } else if (assistantText) {
128
167
  // Safe db-only sanitize (forAssistant) so a volatile db url the model
@@ -130,6 +169,7 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
130
169
  var okm: any = { role: 'assistant', content: sanitizeAttachmentLinksForHistory(assistantText, opts.serviceId, true) };
131
170
  if (item._isBgTask) okm.isBackgroundTask = true;
132
171
  if (serverItemId !== undefined) okm._serverItemId = serverItemId;
172
+ if (replyTs !== undefined) okm._ts = replyTs;
133
173
  mapped.push(okm);
134
174
  }
135
175
  });
@@ -67,6 +67,12 @@ export interface ChatMessage {
67
67
  _localId?: string;
68
68
  _cancelling?: boolean;
69
69
  _cancelError?: string;
70
+ /** Epoch ms shown as small text under the bubble. From the request history a
71
+ * USER bubble carries the request's `created` time and an ASSISTANT bubble the
72
+ * `updated` (response) time; a live bubble is stamped with the wall clock when
73
+ * it is created, then reconciled to the server value on the next history load.
74
+ * Absent while a turn is still pending, so no time shows on a "Thinking" bubble. */
75
+ _ts?: number;
70
76
  // History cache key (`serviceId#platform`) this bubble was created under.
71
77
  // Stamped on LOCALLY-created bubbles only (the optimistic user message and
72
78
  // its "Thinking..." placeholder); server-mapped bubbles are identified by
@@ -46,11 +46,20 @@ export * from './prompts';
46
46
  export { getErrorMessage, isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError } from './errors';
47
47
  export * from './budget';
48
48
  export * from './links';
49
+ export * from './time';
50
+ export * from './ai_agent';
49
51
  export {
50
52
  filterListByClearHorizon,
51
53
  normalizeTextContent,
52
54
  extractLastUserTextFromRequest,
53
55
  mapHistoryListToMessages,
56
+ // The indexing-prompt reader. Exported because the prompt is the only record
57
+ // of what a pass is about, so anything that meets a pass without a bubble
58
+ // (a history rebuild, an adopted worker pass, agent.vue's own mapper) has to
59
+ // read it the same way or the two will not group together.
60
+ isIndexingRequestText,
61
+ parseIndexingRequestText,
62
+ type IndexingRequestRef,
54
63
  type MapHistoryOptions,
55
64
  } from './history';
56
65
 
@@ -7,12 +7,34 @@
7
7
  * repeating forever, and any real question the user asks in between gets buried.
8
8
  *
9
9
  * buildChatDisplayList turns the flat message array into a DISPLAY list in which
10
- * every message belonging to one file (however far apart they sit, and whatever
11
- * else is interleaved between them) is represented by a single group entry,
12
- * rendered at the position of that file's NEWEST turn. Newest, not oldest, so:
13
- * - a running index sits at the bottom, where the activity is, and
14
- * - paging older history in never moves a row that is already on screen
15
- * (older passes simply join the group they already belong to).
10
+ * every message belonging to one run of one file (however far apart the passes
11
+ * sit, and whatever else is interleaved between them) is represented by a single
12
+ * group entry, rendered at the position of that run's FIRST loaded pass.
13
+ *
14
+ * First, not newest. The message array is ordered by request CREATION time, and
15
+ * a run's later passes are created one at a time as the previous one resolves
16
+ * by the client for the paged text path, and by the WORKER itself for the
17
+ * rendered-page (PDF) path, which the client only learns about on its next
18
+ * first-page history fetch. So a pass is routinely created minutes after the
19
+ * upload, on a queue that runs in parallel with the foreground chat. Anchoring
20
+ * at the newest pass meant one such late pass dragged the whole run — every
21
+ * earlier pass with it — below any question the user had asked in the meantime,
22
+ * and made a run that had visibly finished before the question render after it.
23
+ * Anchoring at the first pass puts the row where the run actually began, which
24
+ * is a position no later pass can change:
25
+ * - a new pass never relocates the row, so nothing under the reader shifts;
26
+ * - a question asked after the upload always renders below the row, which is
27
+ * the order it happened in.
28
+ * The cost is that a long-running index does not follow the conversation to the
29
+ * bottom: once the user has chatted past the upload, the spinner and the Stop
30
+ * button sit above their newest turns. That is a scroll away, and the row no
31
+ * longer lies about when the work started.
32
+ *
33
+ * Paging older history is the one thing that CAN move the row: an older page
34
+ * carrying earlier passes of a run already on screen moves it up into that page.
35
+ * That happens at most once per run, only for a run whose start was never
36
+ * loaded (`mayHaveOlder`), and it is the same event that already re-derives
37
+ * `runKey` — so the view treats it as a new row either way.
16
38
  *
17
39
  * The group deliberately reports no authoritative pass TOTAL. History is paged
18
40
  * newest-first, so any total computed from loaded messages is a lower bound that
@@ -37,9 +59,10 @@ export type IndexingGroup = {
37
59
  * Monday and re-indexed on Wednesday is two runs, and collapsing them into
38
60
  * one row erased Monday's from Monday's place in the conversation, claimed
39
61
  * its passes for Wednesday, and let Monday's failure be overwritten by
40
- * Wednesday's success. Numbered from the NEWEST run backwards (`#0` is the
41
- * newest) so paging in older history never renames a row already on screen.
42
- * This is the render key and the expansion key. */
62
+ * Wednesday's success. Named after the run's FIRST loaded pass (see where it
63
+ * is assigned below), so passes appended to the run and other runs appearing
64
+ * on either side of it never rename a row already on screen. This is the
65
+ * render key and the expansion key. */
43
66
  runKey: string;
44
67
  name: string;
45
68
  path?: string;
@@ -65,8 +88,16 @@ export type IndexingGroup = {
65
88
  /** The file's first pass is not among the loaded messages, so earlier passes
66
89
  * exist in history that has not been paged in yet. */
67
90
  mayHaveOlder: boolean;
68
- /** Position in the source array this collapsed row renders at. */
91
+ /** Position in the source array this collapsed row renders at: the index of
92
+ * the run's FIRST loaded pass (see the file docstring for why not the last). */
69
93
  anchorIndex: number;
94
+ /** Identity of the turn at `anchorIndex` — its server item id, or its local id
95
+ * while it has none, or `''` when it has neither. The views stamp this on the
96
+ * row (`data-row-pos`) so the scroll anchor can tell a row that RELOCATED (an
97
+ * older page moved the run's start) from one that merely gained a pass. They
98
+ * must not re-derive it from `members`: which member the row renders at is
99
+ * this module's decision, and the two silently disagreed once already. */
100
+ anchorId: string;
70
101
  };
71
102
 
72
103
  export type DisplayEntry =
@@ -230,7 +261,10 @@ export function buildChatDisplayList(
230
261
  cancellableIds: [],
231
262
  cancelling: false,
232
263
  mayHaveOlder: false,
264
+ // The run's first loaded pass, and never re-stamped: see the file
265
+ // docstring. `anchorId` is filled in once every member is known.
233
266
  anchorIndex: i,
267
+ anchorId: '',
234
268
  };
235
269
  order.push(runId);
236
270
  }
@@ -246,7 +280,6 @@ export function buildChatDisplayList(
246
280
  g.passCount++;
247
281
  }
248
282
  g.members.push({ msg: msg, index: i });
249
- g.anchorIndex = i;
250
283
  runOfIndex[i] = runId;
251
284
  if (msg._serverItemId) runByItemId[msg._serverItemId] = runId;
252
285
  if (ref && ref.name) keyByName[ref.name] = g.key;
@@ -334,6 +367,12 @@ export function buildChatDisplayList(
334
367
  if (pref && !pref.continued) { sawFirstPass = true; break; }
335
368
  }
336
369
  grp.mayHaveOlder = !sawFirstPass && hasMoreHistory;
370
+ // Where the row renders, and WHICH turn that is. Derived from the same
371
+ // member so the two can never drift apart; a group always has a member
372
+ // (it is created by the first one).
373
+ var anchor = grp.members[0];
374
+ grp.anchorIndex = anchor.index;
375
+ grp.anchorId = anchor.msg._serverItemId || anchor.msg._localId || '';
337
376
  }
338
377
 
339
378
  var out: DisplayEntry[] = [];
@@ -183,6 +183,33 @@ export function repairUrlWhitespace(href: string): string {
183
183
  return href.trim().replace(/\s/g, '%20');
184
184
  }
185
185
 
186
+ /**
187
+ * A model reproducing a URL sometimes HTML-escapes its `&` query separators as
188
+ * `&amp;` (or the numeric `&#38;` / `&#x26;`). Left in the href that escaping
189
+ * survives the client's own escapeAttr -> v-html/innerHTML decode round-trip and
190
+ * reaches the browser LITERALLY, so a presigned S3 URL is navigated with its
191
+ * parameters named `amp;Signature`, `amp;Expires`, `amp;response-content-type`,
192
+ * ... — the real params vanish, the signature can't be located, and S3 rejects
193
+ * it (the "링크가 안되" dead export link). Undo just that entity escaping.
194
+ *
195
+ * This is a no-op on a clean URL: a valid link carries a raw `&` between params
196
+ * and percent-encodes (`%26`) any literal ampersand that is data, so a real URL
197
+ * never contains `&amp;` to begin with. Mirrors repairUrlWhitespace: it repairs
198
+ * model damage, not the URL. The loop collapses a doubly-escaped `&amp;amp;` too.
199
+ */
200
+ export function repairUrlEntities(href: string): string {
201
+ if (!href || href.indexOf('&') === -1) return href;
202
+ var out = href, prev = '';
203
+ while (out !== prev) {
204
+ prev = out;
205
+ out = out
206
+ .replace(/&amp;/gi, '&')
207
+ .replace(/&#0*38;/g, '&')
208
+ .replace(/&#x0*26;/gi, '&');
209
+ }
210
+ return out;
211
+ }
212
+
186
213
  /**
187
214
  * Trim punctuation and unmatched wrappers that cling to a token in prose.
188
215
  * `src::a/b.pdf).` -> `src::a/b.pdf`, while a balanced `file (v2).pdf` is kept.
@@ -280,8 +307,11 @@ export function classifyInlineLink(
280
307
  // source into the storage-path branch, where it became a chip pointing at
281
308
  // this project for someone else's file.
282
309
  if (srcIsUrl && !isDbHost(rawPath) && !readExpiredAttachmentHref(rawPath)) {
310
+ // decode any model-introduced `&amp;` in the URL (tail stays keyed on
311
+ // the raw match length, so it is left untouched)
312
+ var srcUrl = repairUrlEntities(rawPath);
283
313
  return {
284
- part: { type: 'link', label: truncateLabelForDisplay(rawPath), fullLabel: rawPath, href: rawPath, expired: false },
314
+ part: { type: 'link', label: truncateLabelForDisplay(srcUrl), fullLabel: srcUrl, href: srcUrl, expired: false },
285
315
  tail: tail,
286
316
  };
287
317
  }
@@ -337,6 +367,10 @@ export function classifyInlineLink(
337
367
  // [label](url) and bare urls.
338
368
  var originalHref = g3 || g6 || '';
339
369
  if (!originalHref) return null;
370
+ // A model that reproduces the URL may have HTML-escaped its `&` separators;
371
+ // decode them now so every downstream check and the final href see a clean
372
+ // URL (otherwise a presigned link navigates with `&amp;` and 403s).
373
+ originalHref = repairUrlEntities(originalHref);
340
374
  // A bare url swallows the punctuation that ends the sentence it sits in, so
341
375
  // `see https://host/a.pdf.` linked to `a.pdf.` and 404'd. Trim it and hand the
342
376
  // trimmed text back as `tail`, exactly as the src:: branch does.
@@ -26,6 +26,9 @@ export function buildChatSystemPrompt(params: ChatSystemPromptParams): string {
26
26
  You are a dedicated assistant for the project ID: "${formattedServiceId}".
27
27
  Scope: Only answer questions about this project and its data. Do not answer questions about other projects or topics unrelated to this project. When the user refers to "my database", "my data", or "my files", treat those as references to this project's database and file storage.
28
28
  Knowledge lookup: Before saying you don't know or that something isn't in the chat history, ALWAYS query this project's database through the available MCP tools to look for the answer. The user's data is the source of truth - the chat transcript is not. Only respond with "I don't know" or "I couldn't find that" after you have actually searched the project's data and come back empty.
29
+ Complete answers over stored data: The database holds one record per spreadsheet row, and each uploaded file becomes many records that usually share a table. So a question about the data almost always spans many records across several files. For any request that counts, sums, totals, lists every match, compares across records, finds which one, or asks whether something is present or ABSENT (for example "how many", "total spent", "which card", "is there any", "없어?", "하나도 없나?"), you MUST read the COMPLETE matching set before answering. Query with fetch_all set to true, or page through getToolResponsePage until pagination.complete is true, across EVERY relevant table and EVERY relevant file. A single default query returns only the first page (about 50 records). That is a SAMPLE. Never treat it as the whole dataset.
30
+ Never assert absence from a partial read. Do not say "there is no X", "none", "not found", or "아니요, 없습니다" until a complete scan has come back empty. If you have not finished scanning every relevant table and file, keep querying instead of guessing. A confident "no" that later turns out wrong is worse than telling the user you are still checking.
31
+ Embedded values: a search term is often stored inside a larger string. A merchant "GODADDY" appears as "DNH*GODADDY#4070277042", and a card as "4140****2941". Server-side index and tag filters match only exact values or leading prefixes, not substrings, so filtering on such a field silently drops rows. When the value you are looking for may be embedded, do not trust a narrow filter to be complete. Fetch the full set with fetch_all and match the substring yourself.
29
32
  File attachments: When a user message contains an "Attached files:" section with markdown links, those links point to short-lived signed URLs in this project's db storage and will expire.
30
33
  - Image files (.jpg, .jpeg, .png, .gif, .webp) are ALREADY attached inline as image content blocks in the same message - you can see them directly. Do NOT call web_fetch on image URLs; that will fail or return garbage. Just look at the image block and answer.
31
34
  - Most attached files (office documents like .docx/.xlsx/.pptx/.hwp/.hwpx/.ods, and text/data/code files like .csv/.tsv/.json/.xml/.txt/.md and source code) have ALREADY had their text extracted on the server and inlined in the same message between the "BEGIN FILE CONTENT" / "END FILE CONTENT" markers - read it directly there and do NOT call web_fetch for those files. A "[skapi: ...]" note in that block means the file could not be extracted.
@@ -833,8 +833,16 @@ export const INDEXING_COMPLETE_MARKER = 'INDEXING_COMPLETE';
833
833
  // a small cap suffices.
834
834
  export const MAX_INDEXING_RESUME_PASSES = 6;
835
835
 
836
+ /**
837
+ * `queue` narrows the fetch to one processing chain; `status` narrows it to items
838
+ * in one state. Passing both is how the client asks "is there still unresolved
839
+ * work on the background-indexing queue?" without pulling a page of chat history
840
+ * (see ChatSession._adoptWorkerIndexingPasses) — the server answers that from a
841
+ * status-keyed index, so the reply carries only the live items, not the bodies of
842
+ * everything already finished.
843
+ */
836
844
  export async function getChatHistory(
837
- params: { service?: string; owner?: string; platform: 'claude' | 'openai'; queue?: string },
845
+ params: { service?: string; owner?: string; platform: 'claude' | 'openai'; queue?: string; status?: 'pending' | 'running' | 'resolved' | 'failed' },
838
846
  fetchOptions: Record<string, any>,
839
847
  ) {
840
848
  const url =
@@ -848,10 +856,11 @@ export async function getChatHistory(
848
856
  },
849
857
  { service: params.service, owner: params.owner },
850
858
  params.queue ? { queue: params.queue } : {},
859
+ params.status ? { status: params.status } : {},
851
860
  );
852
861
 
853
862
  return chatEngineConfig().clientSecretRequestHistory(
854
- p as { url: string; method: 'POST'; queue?: string },
863
+ p as { url: string; method: 'POST'; queue?: string; status?: string },
855
864
  Object.assign({ ascending: false }, fetchOptions),
856
865
  );
857
866
  }