bunnyquery 1.8.5 → 1.8.8
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 +1 -1
- package/bunnyquery.css +168 -26
- package/bunnyquery.js +1429 -118
- package/dist/engine.cjs +1019 -79
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +507 -164
- package/dist/engine.d.ts +507 -164
- package/dist/engine.mjs +1003 -80
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/budget.ts +207 -68
- package/src/engine/config.ts +48 -0
- package/src/engine/errors.ts +38 -0
- package/src/engine/history.ts +546 -6
- package/src/engine/host.ts +7 -0
- package/src/engine/index.ts +15 -1
- package/src/engine/indexing_groups.ts +248 -3
- package/src/engine/office.ts +24 -1
- package/src/engine/prompts/chat_system_prompt.ts +1 -1
- package/src/engine/requests.ts +165 -12
- package/src/engine/session.ts +544 -35
- package/src/widget.css +54 -18
- package/styles/chat.css +114 -8
package/src/engine/history.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* formatIndexingLabel callback) so the engine touches neither localStorage nor
|
|
5
5
|
* view-specific display formatting. projectId is passed for link sanitization.
|
|
6
6
|
*/
|
|
7
|
-
import { extractClaudeText, extractOpenAIText, INDEXING_COMPLETE_MARKER, EMPTY_INDEXING_REPLY } from './requests';
|
|
7
|
+
import { extractClaudeText, extractOpenAIText, INDEXING_COMPLETE_MARKER, EMPTY_INDEXING_REPLY, getChatHistory, bgIndexingQueueName } from './requests';
|
|
8
8
|
import { isErrorResponseBody, getErrorMessage } from './errors';
|
|
9
9
|
import { sanitizeAttachmentLinksForHistory } from './links';
|
|
10
10
|
|
|
@@ -80,6 +80,513 @@ export function parseIndexingRequestText(userText: any): IndexingRequestRef | nu
|
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// One page of the status probe below. Matches ChatSession's own
|
|
84
|
+
// WORKER_PASS_ADOPT_LIMIT: the queue holds one live pass per file (plus a
|
|
85
|
+
// handful pending), so a FULL page means the answer may be incomplete and the
|
|
86
|
+
// probe reports checked=false rather than letting a miss read as "finished".
|
|
87
|
+
const LIVE_INDEX_PROBE_LIMIT = 20;
|
|
88
|
+
|
|
89
|
+
// ─── Shared bg-queue probe (write-through memo) ──────────────────────────────
|
|
90
|
+
// The SAME two queries — getChatHistory({queue, status: 'pending'|'running'})
|
|
91
|
+
// — are fired by four independent callers: the session's live-index snapshot,
|
|
92
|
+
// its worker-pass adoption ladder, its drain wait, and fetchLiveIndexingKeys
|
|
93
|
+
// (the db-files page's badge probe). Navigating chat → files re-asked the queue
|
|
94
|
+
// the chat had answered seconds earlier.
|
|
95
|
+
//
|
|
96
|
+
// This memo dedupes them WITHOUT weakening anyone's evidence:
|
|
97
|
+
// • progress loops (adopt ladder, drain wait) call with maxAgeMs 0 — they
|
|
98
|
+
// always fetch fresh, because their whole point is to observe a CHANGE —
|
|
99
|
+
// but their answers write through into the memo;
|
|
100
|
+
// • snapshot readers pass a maxAge and reuse a young answer.
|
|
101
|
+
// Every entry carries `at`, the time the underlying fetch actually ran. A
|
|
102
|
+
// caller that needs two INDEPENDENT looks (dbfile's green-confirm ladder) must
|
|
103
|
+
// compare `at`, never its own receipt time: a memo re-serve has the same `at`
|
|
104
|
+
// and therefore counts as the single look it really is.
|
|
105
|
+
export const BG_PROBE_TTL_MS = 4000;
|
|
106
|
+
type BgProbeEntry = { result: any; at: number };
|
|
107
|
+
const bgProbeCache: { [key: string]: BgProbeEntry } = {};
|
|
108
|
+
const bgProbeInflight: { [key: string]: Promise<BgProbeEntry> } = {};
|
|
109
|
+
|
|
110
|
+
export function probeBgQueue(
|
|
111
|
+
params: {
|
|
112
|
+
service: string;
|
|
113
|
+
owner: string;
|
|
114
|
+
platform: 'claude' | 'openai';
|
|
115
|
+
queue: string;
|
|
116
|
+
status: 'pending' | 'running';
|
|
117
|
+
limit: number;
|
|
118
|
+
},
|
|
119
|
+
opts?: { maxAgeMs?: number },
|
|
120
|
+
): Promise<BgProbeEntry> {
|
|
121
|
+
const key = [params.service, params.owner, params.platform, params.queue, params.status, params.limit].join('|');
|
|
122
|
+
const maxAge = opts && typeof opts.maxAgeMs === 'number' ? opts.maxAgeMs : 0;
|
|
123
|
+
const cached = bgProbeCache[key];
|
|
124
|
+
if (maxAge > 0 && cached && Date.now() - cached.at < maxAge) {
|
|
125
|
+
return Promise.resolve(cached);
|
|
126
|
+
}
|
|
127
|
+
const inflight = bgProbeInflight[key];
|
|
128
|
+
if (inflight) return inflight;
|
|
129
|
+
const p = Promise.resolve(getChatHistory(
|
|
130
|
+
{ service: params.service, owner: params.owner, platform: params.platform, queue: params.queue, status: params.status },
|
|
131
|
+
{ limit: params.limit, fetchMore: false },
|
|
132
|
+
)).then(function (result: any) {
|
|
133
|
+
const entry: BgProbeEntry = { result: result, at: Date.now() };
|
|
134
|
+
bgProbeCache[key] = entry;
|
|
135
|
+
return entry;
|
|
136
|
+
});
|
|
137
|
+
bgProbeInflight[key] = p;
|
|
138
|
+
// Clear the in-flight slot on either outcome; failures are NOT cached.
|
|
139
|
+
p.then(function () { delete bgProbeInflight[key]; }, function () { delete bgProbeInflight[key]; });
|
|
140
|
+
return p;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* One bounded look at the background-indexing queue: which files still have a
|
|
145
|
+
* pass pending or running? This is the same negative signal ChatSession's
|
|
146
|
+
* display layer relies on - for a worker-driven (auto_continue) run, only the
|
|
147
|
+
* queue can say the run is over, because the worker enqueues continuation
|
|
148
|
+
* passes the client never dispatched.
|
|
149
|
+
*
|
|
150
|
+
* Returns every storage path AND file name found on live passes (both, because
|
|
151
|
+
* older prompts may lack the storage-path line), plus `checked`: false when a
|
|
152
|
+
* page came back full, in which case absence from `keys` proves nothing and
|
|
153
|
+
* the caller must keep whatever state it already had.
|
|
154
|
+
*
|
|
155
|
+
* SCOPE: the probed queue is "<userId>-bg" - THIS user's dispatches only. A
|
|
156
|
+
* chain launched by another collaborator or a widget end-user lives on their
|
|
157
|
+
* queue and is invisible here, so "idle" must never be read as "nobody is
|
|
158
|
+
* indexing this file", only as "this user's runs are over". The durable done::
|
|
159
|
+
* marker (indexDoneUniqueId) is the cross-user signal.
|
|
160
|
+
*/
|
|
161
|
+
export async function fetchLiveIndexingKeys(params: {
|
|
162
|
+
service: string;
|
|
163
|
+
owner: string;
|
|
164
|
+
platform: 'claude' | 'openai';
|
|
165
|
+
/** Same value the dispatch used - see bgIndexingQueueName. */
|
|
166
|
+
userId?: string;
|
|
167
|
+
}): Promise<{ keys: Set<string>; checked: boolean; at: number }> {
|
|
168
|
+
const queue = bgIndexingQueueName(params.userId, params.service);
|
|
169
|
+
const base = { service: params.service, owner: params.owner, platform: params.platform, queue };
|
|
170
|
+
const [pending, running] = await Promise.all([
|
|
171
|
+
probeBgQueue({ ...base, status: 'pending', limit: LIVE_INDEX_PROBE_LIMIT }, { maxAgeMs: BG_PROBE_TTL_MS }),
|
|
172
|
+
probeBgQueue({ ...base, status: 'running', limit: LIVE_INDEX_PROBE_LIMIT }, { maxAgeMs: BG_PROBE_TTL_MS }),
|
|
173
|
+
]);
|
|
174
|
+
const keys = new Set<string>();
|
|
175
|
+
let truncated = false;
|
|
176
|
+
for (const entry of [pending, running]) {
|
|
177
|
+
const res = entry.result;
|
|
178
|
+
const list: any[] = (res && Array.isArray((res as any).list)) ? (res as any).list : [];
|
|
179
|
+
if (list.length >= LIVE_INDEX_PROBE_LIMIT) truncated = true;
|
|
180
|
+
for (const item of list) {
|
|
181
|
+
const text = extractLastUserTextFromRequest(item && item.request_body);
|
|
182
|
+
if (!text || !isIndexingRequestText(text)) continue;
|
|
183
|
+
const ref = parseIndexingRequestText(text);
|
|
184
|
+
if (!ref) continue;
|
|
185
|
+
if (ref.path) keys.add(ref.path);
|
|
186
|
+
if (ref.name) keys.add(ref.name);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// `at` = when the OLDER of the two underlying fetches ran. Consumers that
|
|
190
|
+
// need two independent looks (dbfile's green ladder) key on this, so a
|
|
191
|
+
// memo re-serve is correctly seen as the same single look.
|
|
192
|
+
return { keys, checked: !truncated, at: Math.min(pending.at, running.at) };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ─── Split history fetch ─────────────────────────────────────────────────────
|
|
196
|
+
// One conversation, two queues: ordinary chat lives on the user's queue (or a
|
|
197
|
+
// legacy/random one), indexing passes on "<userId>-bg" — whose stored request
|
|
198
|
+
// bodies carry whole file windows and dominated every history page. The split
|
|
199
|
+
// fetches the SURFACE (id-prefix listing minus the bg queue, full bodies) and
|
|
200
|
+
// the BG queue (exact, compact stubs — the lambda stubs ONLY indexing-shaped
|
|
201
|
+
// items, ordinary chats deferred onto the bg queue keep full bodies) and
|
|
202
|
+
// returns a getChatHistory-shaped page.
|
|
203
|
+
//
|
|
204
|
+
// This is v2, redesigned around the 2026-08-11 review findings:
|
|
205
|
+
// • The SDK ignores explicit fetchOptions.startKeyHistory — paging rides its
|
|
206
|
+
// internal per-params-hash cursors. So ALL split state lives HERE, module-
|
|
207
|
+
// level, keyed by (service|owner|platform|userId): the bg overflow buffer,
|
|
208
|
+
// the undelivered surface page, end flags. Callers keep echoing
|
|
209
|
+
// startKeyHistory opaquely; it is bookkeeping only, on both sides.
|
|
210
|
+
// • Pages TILE exactly: each page emits only items at-or-newer-than the
|
|
211
|
+
// surface page's oldest item (the boundary); bg items older than that are
|
|
212
|
+
// BUFFERED for the next page. Consumers' raw prepend, retainedOlder
|
|
213
|
+
// pruning and clear-horizon early-end all rely on this invariant.
|
|
214
|
+
// • Retry-safe: the fetched surface page is held as pendingSurface until the
|
|
215
|
+
// merged page is actually returned, and every bg page lands in the buffer
|
|
216
|
+
// the moment it arrives — a mid-call failure + caller retry resumes with
|
|
217
|
+
// nothing skipped (the SDK's internal cursors have already advanced; the
|
|
218
|
+
// state carries what they advanced past).
|
|
219
|
+
// • Empty-but-not-end pages are eliminated SERVER-side (the lambda re-queries
|
|
220
|
+
// past fully-filtered windows). A defensive client cap remains.
|
|
221
|
+
//
|
|
222
|
+
// Backwards-safe: an old backend ignores queue_exclude/compact — the surface
|
|
223
|
+
// then includes bg items with bodies (exactly today's single fetch), the bg
|
|
224
|
+
// call returns duplicates, and dedup-by-id (surface wins) degrades to current
|
|
225
|
+
// behavior plus one redundant query, never to data loss.
|
|
226
|
+
// Per-call bg fetches. Depth is LAZY: the consumers merge prepended pages by
|
|
227
|
+
// timestamp, so older stubs can arrive on any later page and still land at
|
|
228
|
+
// their true position — there is no need to drain the bg queue eagerly, and
|
|
229
|
+
// doing so is exactly what made first paint slow (bg windows are ~1MB of RAW
|
|
230
|
+
// rows, i.e. a handful of stubs per round trip). Two fetches keep the nearby
|
|
231
|
+
// stubs in the same paint; scroll-up/viewport-fill pages pull the rest.
|
|
232
|
+
const BG_COVERAGE_MAX_PAGES = 2;
|
|
233
|
+
|
|
234
|
+
type SplitHistoryState = {
|
|
235
|
+
/** Fetched bg items not yet emitted (older than the last page's boundary). */
|
|
236
|
+
bgBuffer: any[];
|
|
237
|
+
bgEnd: boolean;
|
|
238
|
+
/** Whether the bg chain has fetched at least once (first call resets the
|
|
239
|
+
* SDK's internal bg cursor with fetchMore:false; later calls continue). */
|
|
240
|
+
bgStarted: boolean;
|
|
241
|
+
surfaceEnd: boolean;
|
|
242
|
+
/** Surface page fetched but not yet delivered in a returned merged page —
|
|
243
|
+
* reused on retry so the SDK's advanced cursor cannot skip it. Stamped
|
|
244
|
+
* with the fetchMore mode it was fetched under: a page-1 fetch abandoned
|
|
245
|
+
* mid-coverage must never be replayed as an OLDER page (or vice versa). */
|
|
246
|
+
pendingSurface: { list: any[]; endOfList: boolean; startKeyHistory: any[]; forFetchMore: boolean } | null;
|
|
247
|
+
/** Surface items fetched but HELD BACK because the bg coverage loop's hop
|
|
248
|
+
* cap fired before the bg side reached this page's boundary: emitting
|
|
249
|
+
* them would let the uncovered bg stubs land on a LATER page above
|
|
250
|
+
* strictly-newer surface turns. Prepended to the next page's surface. */
|
|
251
|
+
surfaceCarry: any[];
|
|
252
|
+
lastSurfaceKeys: any[];
|
|
253
|
+
/** Newest bg item id this chain has ever seen. The head refresh compares it
|
|
254
|
+
* against the fresh head page: a FULL head page that no longer contains it
|
|
255
|
+
* means more than one page of bg rows landed while nobody was polling, and
|
|
256
|
+
* the walk must reopen or the gap is unfetchable forever. */
|
|
257
|
+
newestBgId: string;
|
|
258
|
+
};
|
|
259
|
+
const splitHistoryStates: { [key: string]: SplitHistoryState } = {};
|
|
260
|
+
// Per-key serialization: a token-bumped reload can start a NEW split call
|
|
261
|
+
// while an old one is mid-flight, and both would interleave on the SDK's
|
|
262
|
+
// shared internal cursor chains (silently skipping pages). The newcomer waits
|
|
263
|
+
// for the incumbent to settle; its stale result is discarded by the callers'
|
|
264
|
+
// own token guards, and the newcomer's fetchMore:false reset then starts from
|
|
265
|
+
// a clean chain.
|
|
266
|
+
const splitHistoryLocks: { [key: string]: Promise<void> } = {};
|
|
267
|
+
|
|
268
|
+
function freshSplitState(): SplitHistoryState {
|
|
269
|
+
return { bgBuffer: [], bgEnd: false, bgStarted: false, surfaceEnd: false, pendingSurface: null, surfaceCarry: [], lastSurfaceKeys: [], newestBgId: '' };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Track the newest bg id the chain has seen (ids are creation-ordered). */
|
|
273
|
+
function noteBgIds(state: SplitHistoryState, list: any[]): void {
|
|
274
|
+
for (const it of list) {
|
|
275
|
+
const id = it && typeof it.id === 'string' ? it.id : '';
|
|
276
|
+
if (id && id > state.newestBgId) state.newestBgId = id;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Test hook: drop split-fetch state (all keys, or one). */
|
|
281
|
+
export function __resetSplitHistoryState(key?: string): void {
|
|
282
|
+
if (key !== undefined) { delete splitHistoryStates[key]; delete splitHistoryLocks[key]; return; }
|
|
283
|
+
for (const k in splitHistoryStates) delete splitHistoryStates[k];
|
|
284
|
+
for (const k in splitHistoryLocks) delete splitHistoryLocks[k];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const createdOf = (it: any): number => {
|
|
288
|
+
const c = Number(it && it.created);
|
|
289
|
+
return isFinite(c) && c > 0 ? c : NaN;
|
|
290
|
+
};
|
|
291
|
+
const oldestCreated = (lst: any[]): number => {
|
|
292
|
+
let m = Infinity;
|
|
293
|
+
for (const it of lst) {
|
|
294
|
+
const c = createdOf(it);
|
|
295
|
+
if (!isNaN(c) && c < m) m = c;
|
|
296
|
+
}
|
|
297
|
+
return m; // Infinity when no item carries a usable timestamp
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
// A pure-bg band can outlast the LAMBDA's own re-query cap (its raw windows
|
|
301
|
+
// are 1MB and inline indexing bodies run to ~300KB, so one lambda call may
|
|
302
|
+
// cross only a few dozen rows) — so the client must ALSO loop past
|
|
303
|
+
// empty-but-not-end surface pages, or the fill loops upstream read them as
|
|
304
|
+
// exhausted and strand older history (and an empty page 1 would blank the
|
|
305
|
+
// chat). Each hop here is one more lambda call.
|
|
306
|
+
const SURFACE_EMPTY_MAX_PAGES = 10;
|
|
307
|
+
|
|
308
|
+
export type SplitHistoryResult = {
|
|
309
|
+
list: any[];
|
|
310
|
+
endOfList: boolean;
|
|
311
|
+
startKeyHistory: any[];
|
|
312
|
+
/** True when this chat had never been walked in this session — the first
|
|
313
|
+
* paint. Consumers gate the "Loading indexing history" hint on it: a
|
|
314
|
+
* mid-walk tab return restarts the walk for cursor safety but must stay
|
|
315
|
+
* silent (flashing the hint on every return was the reported bug). */
|
|
316
|
+
firstLoad?: boolean;
|
|
317
|
+
/** Present only when `deferBg` was requested AND bg work remains: resolves
|
|
318
|
+
* with the stub batch fetched in the background (the per-key lock is held
|
|
319
|
+
* until it settles, so no other history call can interleave). The caller
|
|
320
|
+
* merges the batch by timestamp — the same path older pages use. */
|
|
321
|
+
bgPending?: Promise<{ list: any[]; endOfList: boolean }>;
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
export async function getSplitChatHistory(
|
|
325
|
+
params: { service: string; owner: string; platform: 'claude' | 'openai'; userId?: string },
|
|
326
|
+
fetchOptions: Record<string, any>,
|
|
327
|
+
/** Test seam: replaces getChatHistory. Not for production callers. */
|
|
328
|
+
_fetchImpl?: typeof getChatHistory,
|
|
329
|
+
): Promise<SplitHistoryResult> {
|
|
330
|
+
const key = [params.service, params.owner, params.platform, params.userId || ''].join('|');
|
|
331
|
+
const prev = splitHistoryLocks[key] || Promise.resolve();
|
|
332
|
+
// The lock resolves when the WHOLE call — including a deferred bg batch —
|
|
333
|
+
// has settled, so queued calls never interleave with background work. A
|
|
334
|
+
// call that returns WITHOUT a bgPending (or throws) releases immediately;
|
|
335
|
+
// a deferred call releases in the bg batch's own finally.
|
|
336
|
+
let releaseLock: () => void;
|
|
337
|
+
const lockTail = new Promise<void>((r) => { releaseLock = r; });
|
|
338
|
+
const run = () => _getSplitChatHistoryLocked(key, params, fetchOptions, releaseLock!, _fetchImpl);
|
|
339
|
+
const p = prev.then(run, run);
|
|
340
|
+
p.then((res: any) => { if (!res || !res.bgPending) releaseLock(); }, () => releaseLock());
|
|
341
|
+
splitHistoryLocks[key] = p.then(() => lockTail, () => lockTail);
|
|
342
|
+
return p;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function _getSplitChatHistoryLocked(
|
|
346
|
+
key: string,
|
|
347
|
+
params: { service: string; owner: string; platform: 'claude' | 'openai'; userId?: string },
|
|
348
|
+
fetchOptions: Record<string, any>,
|
|
349
|
+
releaseLock: () => void,
|
|
350
|
+
_fetchImpl?: typeof getChatHistory,
|
|
351
|
+
): Promise<SplitHistoryResult> {
|
|
352
|
+
const fetch = _fetchImpl || getChatHistory;
|
|
353
|
+
const bgQueue = bgIndexingQueueName(params.userId, params.service);
|
|
354
|
+
const base = { service: params.service, owner: params.owner, platform: params.platform };
|
|
355
|
+
const fetchMore = !!(fetchOptions && fetchOptions.fetchMore);
|
|
356
|
+
const limit = fetchOptions && fetchOptions.limit;
|
|
357
|
+
|
|
358
|
+
// HEAD refresh: a repeat first-page call on a chain that already walked BOTH
|
|
359
|
+
// queues to their end (tab return, poll-side page-1 refresh). Page 1 must be
|
|
360
|
+
// re-fetched for new settles, but what the chain LEARNED about the older
|
|
361
|
+
// tail — "all history fetched" — survives. Wiping it here was the tab-return
|
|
362
|
+
// bug: every return re-pulled the bg stub pages from scratch, reported
|
|
363
|
+
// endOfList false, and that un-gated the viewport fill into re-walking every
|
|
364
|
+
// older page the user had already exhausted. Only a FULLY-ended chain is
|
|
365
|
+
// safe to preserve: its fetchMore path short-circuits without touching the
|
|
366
|
+
// SDK cursors this page-1 refresh rewinds. A mid-walk chain still restarts.
|
|
367
|
+
// True when this chat has never been walked in this session at all — the
|
|
368
|
+
// consumers gate the "Loading indexing history" hint on it, so a mid-walk
|
|
369
|
+
// tab return (which restarts the walk for cursor safety) stays SILENT.
|
|
370
|
+
const firstLoad = !splitHistoryStates[key];
|
|
371
|
+
let headRefresh = false;
|
|
372
|
+
if (!splitHistoryStates[key]) {
|
|
373
|
+
splitHistoryStates[key] = freshSplitState();
|
|
374
|
+
} else if (!fetchMore) {
|
|
375
|
+
const prev = splitHistoryStates[key];
|
|
376
|
+
if (prev.surfaceEnd && prev.bgEnd) {
|
|
377
|
+
headRefresh = true;
|
|
378
|
+
prev.pendingSurface = null;
|
|
379
|
+
prev.surfaceCarry = [];
|
|
380
|
+
prev.bgBuffer = []; // fully drained by construction; defensive
|
|
381
|
+
} else {
|
|
382
|
+
// Mid-walk chain: the page-1 refresh genuinely restarts the walk
|
|
383
|
+
// (the underlying fetchMore:false calls below reset the SDK's own
|
|
384
|
+
// cursors to match), exactly as before.
|
|
385
|
+
splitHistoryStates[key] = freshSplitState();
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
const state = splitHistoryStates[key];
|
|
389
|
+
|
|
390
|
+
// A held page fetched under the OTHER mode is not this call's page —
|
|
391
|
+
// discard it rather than replay page 1 as an "older" page (or vice versa).
|
|
392
|
+
if (state.pendingSurface && state.pendingSurface.forFetchMore !== fetchMore) {
|
|
393
|
+
state.pendingSurface = null;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ── surface page ────────────────────────────────────────────────────────
|
|
397
|
+
if (!state.pendingSurface) {
|
|
398
|
+
// The ended-chain short-circuit serves FETCHMORE only: a head refresh
|
|
399
|
+
// re-fetches page 1 for real (new settles live there), and the
|
|
400
|
+
// preserved surfaceEnd keeps every later fetchMore off the wire.
|
|
401
|
+
if (state.surfaceEnd && !headRefresh) {
|
|
402
|
+
state.pendingSurface = { list: [], endOfList: true, startKeyHistory: state.lastSurfaceKeys, forFetchMore: fetchMore };
|
|
403
|
+
} else {
|
|
404
|
+
const sOpts: any = { fetchMore };
|
|
405
|
+
if (limit) sOpts.limit = limit;
|
|
406
|
+
let s = await fetch({ ...base, queue_exclude: bgQueue }, sOpts);
|
|
407
|
+
// Loop past empty-but-not-end pages (see SURFACE_EMPTY_MAX_PAGES).
|
|
408
|
+
let hops = 0;
|
|
409
|
+
while (s && !s.endOfList && !((s.list || []).length) && hops < SURFACE_EMPTY_MAX_PAGES) {
|
|
410
|
+
hops++;
|
|
411
|
+
const nOpts: any = { fetchMore: true };
|
|
412
|
+
if (limit) nOpts.limit = limit;
|
|
413
|
+
s = await fetch({ ...base, queue_exclude: bgQueue }, nOpts);
|
|
414
|
+
}
|
|
415
|
+
state.pendingSurface = {
|
|
416
|
+
list: (s && Array.isArray(s.list)) ? s.list : [],
|
|
417
|
+
endOfList: !!(s && s.endOfList),
|
|
418
|
+
startKeyHistory: (s && Array.isArray(s.startKeyHistory)) ? s.startKeyHistory : [],
|
|
419
|
+
forFetchMore: fetchMore,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const surface = state.pendingSurface;
|
|
424
|
+
|
|
425
|
+
// ── deferred bg (first-paint mode) ──────────────────────────────────────
|
|
426
|
+
// The caller paints the CONVERSATION from this return immediately; the
|
|
427
|
+
// stub fetch happens behind the resolved promise, under the same lock.
|
|
428
|
+
// Anything already buffered ships now (it costs nothing).
|
|
429
|
+
if (fetchOptions && fetchOptions.deferBg && (!state.bgEnd || headRefresh)) {
|
|
430
|
+
const surfaceList0 = state.surfaceCarry.length ? state.surfaceCarry.concat(surface.list) : surface.list.slice();
|
|
431
|
+
state.surfaceCarry = [];
|
|
432
|
+
const emitNow: any[] = surfaceList0.concat(state.bgBuffer);
|
|
433
|
+
state.bgBuffer = [];
|
|
434
|
+
// A head refresh must not let page 1's "more pages exist" clobber the
|
|
435
|
+
// preserved end-knowledge: those pages are the tail already walked.
|
|
436
|
+
if (!headRefresh) state.surfaceEnd = surface.endOfList;
|
|
437
|
+
state.lastSurfaceKeys = surface.startKeyHistory;
|
|
438
|
+
state.pendingSurface = null;
|
|
439
|
+
const bgPending = (async () => {
|
|
440
|
+
try {
|
|
441
|
+
const batch: any[] = [];
|
|
442
|
+
if (headRefresh) {
|
|
443
|
+
// One HEAD page only: the walked tail is complete; this
|
|
444
|
+
// catches bg rows that settled/minted while nobody was
|
|
445
|
+
// polling. bgEnd/bgStarted are normally left alone — the
|
|
446
|
+
// end-knowledge survives, and the coverage loop (gated on
|
|
447
|
+
// !bgEnd) never touches the cursor this rewinds. ONE
|
|
448
|
+
// exception: a FULL head page that no longer contains the
|
|
449
|
+
// newest bg id this chain had seen means more than a page
|
|
450
|
+
// of bg rows landed while hidden — the gap is unreachable
|
|
451
|
+
// unless the walk reopens (id-dedup makes re-walking the
|
|
452
|
+
// tail harmless).
|
|
453
|
+
const bOpts: any = { fetchMore: false };
|
|
454
|
+
if (limit) bOpts.limit = limit;
|
|
455
|
+
const b = await fetch({ ...base, queue: bgQueue, queue_exact: true, compact: true }, bOpts);
|
|
456
|
+
const bList: any[] = (b && Array.isArray(b.list)) ? b.list : [];
|
|
457
|
+
for (const it of bList) { if (it && typeof it === 'object') (it as any)._fromBgChain = true; batch.push(it); }
|
|
458
|
+
const prevNewest = state.newestBgId;
|
|
459
|
+
noteBgIds(state, bList);
|
|
460
|
+
if (prevNewest && !(b && b.endOfList) &&
|
|
461
|
+
!bList.some((it: any) => it && it.id === prevNewest)) {
|
|
462
|
+
state.bgEnd = false;
|
|
463
|
+
state.bgStarted = true;
|
|
464
|
+
}
|
|
465
|
+
} else {
|
|
466
|
+
let hops = 0;
|
|
467
|
+
while (!state.bgEnd && hops < BG_COVERAGE_MAX_PAGES) {
|
|
468
|
+
hops++;
|
|
469
|
+
const bOpts: any = { fetchMore: state.bgStarted };
|
|
470
|
+
if (limit) bOpts.limit = limit;
|
|
471
|
+
const b = await fetch({ ...base, queue: bgQueue, queue_exact: true, compact: true }, bOpts);
|
|
472
|
+
state.bgStarted = true;
|
|
473
|
+
const bList: any[] = (b && Array.isArray(b.list)) ? b.list : [];
|
|
474
|
+
for (const it of bList) { if (it && typeof it === 'object') (it as any)._fromBgChain = true; batch.push(it); }
|
|
475
|
+
noteBgIds(state, bList);
|
|
476
|
+
state.bgEnd = !!(b && b.endOfList);
|
|
477
|
+
if (!bList.length && !state.bgEnd) break;
|
|
478
|
+
if (state.bgEnd) break;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return { list: batch, endOfList: state.surfaceEnd && state.bgEnd };
|
|
482
|
+
} finally {
|
|
483
|
+
releaseLock();
|
|
484
|
+
}
|
|
485
|
+
})();
|
|
486
|
+
return {
|
|
487
|
+
list: emitNow,
|
|
488
|
+
// A head-refreshed ended chain KNOWS it is still ended — reporting
|
|
489
|
+
// the hardcoded false here was what un-gated the fill loop on every
|
|
490
|
+
// tab return. Mid-walk it computes to false exactly as before (this
|
|
491
|
+
// branch is only entered with bgEnd false then); the bg batch still
|
|
492
|
+
// carries the final word for that case.
|
|
493
|
+
endOfList: state.surfaceEnd && state.bgEnd,
|
|
494
|
+
startKeyHistory: surface.startKeyHistory,
|
|
495
|
+
firstLoad,
|
|
496
|
+
bgPending,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Carried-over surface items (held back by an uncovered boundary on the
|
|
501
|
+
// previous page) lead this page's surface list.
|
|
502
|
+
const surfaceList = state.surfaceCarry.length ? state.surfaceCarry.concat(surface.list) : surface.list.slice();
|
|
503
|
+
|
|
504
|
+
// The tiling line: everything at-or-newer than this is emitted now, older
|
|
505
|
+
// bg items wait in the buffer. A finished surface (endOfList) opens the
|
|
506
|
+
// line all the way (-Infinity) so the bg side drains over the next pages.
|
|
507
|
+
// An empty surface page past the loop above (a pathological pure-bg band
|
|
508
|
+
// beyond both the lambda's and our own hop caps) yields Infinity — no bg
|
|
509
|
+
// drain, an empty page, and the consumers' empty-page guards take over.
|
|
510
|
+
const boundary = surface.endOfList ? -Infinity : oldestCreated(surfaceList);
|
|
511
|
+
|
|
512
|
+
// ── bg coverage ─────────────────────────────────────────────────────────
|
|
513
|
+
if (headRefresh) {
|
|
514
|
+
// Non-deferred head refresh: the same single HEAD page the deferred
|
|
515
|
+
// branch fetches, under the same rules — new settles are caught, the
|
|
516
|
+
// end-knowledge (bgEnd/bgStarted) is left untouched, EXCEPT when a full
|
|
517
|
+
// head page no longer contains the newest bg id this chain had seen:
|
|
518
|
+
// more than a page landed while hidden, and the walk must reopen or the
|
|
519
|
+
// gap is unfetchable (id-dedup makes re-walking the tail harmless).
|
|
520
|
+
const hOpts: any = { fetchMore: false };
|
|
521
|
+
if (limit) hOpts.limit = limit;
|
|
522
|
+
const hb = await fetch({ ...base, queue: bgQueue, queue_exact: true, compact: true }, hOpts);
|
|
523
|
+
const hbList: any[] = (hb && Array.isArray(hb.list)) ? hb.list : [];
|
|
524
|
+
for (const it of hbList) { if (it && typeof it === 'object') (it as any)._fromBgChain = true; state.bgBuffer.push(it); }
|
|
525
|
+
const prevNewestH = state.newestBgId;
|
|
526
|
+
noteBgIds(state, hbList);
|
|
527
|
+
if (prevNewestH && !(hb && hb.endOfList) &&
|
|
528
|
+
!hbList.some((it: any) => it && it.id === prevNewestH)) {
|
|
529
|
+
state.bgEnd = false;
|
|
530
|
+
state.bgStarted = true;
|
|
531
|
+
}
|
|
532
|
+
} else if (boundary !== Infinity || surface.endOfList) {
|
|
533
|
+
let hops = 0;
|
|
534
|
+
while (!state.bgEnd && hops < BG_COVERAGE_MAX_PAGES) {
|
|
535
|
+
const bufOldest = state.bgBuffer.length ? oldestCreated(state.bgBuffer) : Infinity;
|
|
536
|
+
if (state.bgBuffer.length && bufOldest <= boundary) break;
|
|
537
|
+
hops++;
|
|
538
|
+
const bOpts: any = { fetchMore: state.bgStarted };
|
|
539
|
+
if (limit) bOpts.limit = limit;
|
|
540
|
+
const b = await fetch({ ...base, queue: bgQueue, queue_exact: true, compact: true }, bOpts);
|
|
541
|
+
state.bgStarted = true;
|
|
542
|
+
const bList: any[] = (b && Array.isArray(b.list)) ? b.list : [];
|
|
543
|
+
// Buffer IMMEDIATELY: the SDK cursor has advanced; this is what a
|
|
544
|
+
// retry resumes from. Tagged as bg-chain items so the consumers'
|
|
545
|
+
// surface-frontier logic (retention boundary, clear-horizon) can
|
|
546
|
+
// tell deep stubs from the conversation's own paging frontier.
|
|
547
|
+
for (const it of bList) { if (it && typeof it === 'object') (it as any)._fromBgChain = true; state.bgBuffer.push(it); }
|
|
548
|
+
noteBgIds(state, bList);
|
|
549
|
+
state.bgEnd = !!(b && b.endOfList);
|
|
550
|
+
if (!bList.length && !state.bgEnd) break; // defensive: server loops past empties
|
|
551
|
+
if (state.bgEnd) break;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ── emit ────────────────────────────────────────────────────────────────
|
|
556
|
+
// Everything fetched ships NOW — surface items are never withheld and the
|
|
557
|
+
// bg buffer drains into every page. Ordering across pages is the
|
|
558
|
+
// consumers' job (stable timestamp merge on prepend), which is what
|
|
559
|
+
// removed the old tiling/withholding machinery: it delayed the
|
|
560
|
+
// CONVERSATION behind a full bg drain on every first visit.
|
|
561
|
+
const emitSurface: any[] = surfaceList;
|
|
562
|
+
state.surfaceCarry = [];
|
|
563
|
+
const emitBg: any[] = state.bgBuffer;
|
|
564
|
+
state.bgBuffer = [];
|
|
565
|
+
|
|
566
|
+
// Dedup by item id, surface copy (full bodies) winning — the old-backend
|
|
567
|
+
// degradation path, and a guard against any range overlap.
|
|
568
|
+
const seen: { [id: string]: boolean } = {};
|
|
569
|
+
for (const it of emitSurface) { if (it && typeof it.id === 'string') seen[it.id] = true; }
|
|
570
|
+
const merged = emitSurface.concat(emitBg.filter((it: any) => !(it && typeof it.id === 'string' && seen[it.id])));
|
|
571
|
+
|
|
572
|
+
// ── deliver ─────────────────────────────────────────────────────────────
|
|
573
|
+
// Same head-refresh rule as the deferred branch: page 1's own "more pages
|
|
574
|
+
// exist" must not clobber a preserved end-knowledge.
|
|
575
|
+
if (!headRefresh) state.surfaceEnd = surface.endOfList;
|
|
576
|
+
state.lastSurfaceKeys = surface.startKeyHistory;
|
|
577
|
+
state.pendingSurface = null;
|
|
578
|
+
|
|
579
|
+
return {
|
|
580
|
+
list: merged,
|
|
581
|
+
endOfList: state.surfaceEnd && state.bgEnd && state.bgBuffer.length === 0 && state.surfaceCarry.length === 0,
|
|
582
|
+
// Bookkeeping only (both the consumers and the SDK treat it opaquely);
|
|
583
|
+
// the real cursors are the SDK's internal ones plus this module's state.
|
|
584
|
+
startKeyHistory: surface.startKeyHistory,
|
|
585
|
+
firstLoad,
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
|
|
83
590
|
export type MapHistoryOptions = {
|
|
84
591
|
clearedAt: number;
|
|
85
592
|
projectId: string;
|
|
@@ -100,9 +607,19 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
|
|
|
100
607
|
var isFailed = item && item.status === 'failed';
|
|
101
608
|
var response = isFailed ? (item.error != null ? item.error : item.response_body)
|
|
102
609
|
: (item && item.response_body != null ? item.response_body : item && item.error);
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
610
|
+
// COMPACT listing stubs (bg-queue pages fetched with `compact: true`):
|
|
611
|
+
// bodies never left the server; the label line, the response head, and
|
|
612
|
+
// the completion-marker bit arrive as dedicated fields. Everything the
|
|
613
|
+
// COLLAPSED row needs is here; expanding a row fetches the real bodies
|
|
614
|
+
// per item (buildHistoryItemFullId point lookups) and remaps.
|
|
615
|
+
var isCompact = !!(item && item.compact);
|
|
616
|
+
var userText = isCompact
|
|
617
|
+
? (typeof item.request_text === 'string' ? item.request_text : '')
|
|
618
|
+
: extractLastUserTextFromRequest(requestBody);
|
|
619
|
+
var assistantText = isPending ? '' : (isCompact
|
|
620
|
+
? ((typeof item.response_text === 'string' ? item.response_text : '').trim())
|
|
621
|
+
: ((extractAssistantText(response) || '').trim() || ''));
|
|
622
|
+
var isErrorResponse = !isPending && (isFailed || (!isCompact && isErrorResponseBody(response)));
|
|
106
623
|
// Record the completion marker, then STRIP it — both, and in that order.
|
|
107
624
|
// Recording gives the display layer a structured signal instead of a substring
|
|
108
625
|
// search over model prose. Stripping matches the live resolution path: without
|
|
@@ -112,8 +629,12 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
|
|
|
112
629
|
//
|
|
113
630
|
// Gated on _isBgTask: only an INDEXING pass has a protocol token to hide. An
|
|
114
631
|
// ordinary reply that merely mentions it keeps its own words.
|
|
115
|
-
var reportedComplete = !!(item && item._isBgTask) && !isErrorResponse &&
|
|
116
|
-
|
|
632
|
+
var reportedComplete = !!(item && item._isBgTask) && !isErrorResponse && (isCompact
|
|
633
|
+
? item.response_complete_marker === true
|
|
634
|
+
: (!!assistantText && assistantText.indexOf(INDEXING_COMPLETE_MARKER) !== -1));
|
|
635
|
+
// Still gated on the recorded marker (i.e. an INDEXING pass): an ordinary
|
|
636
|
+
// reply that merely mentions the token keeps its own words. A compact
|
|
637
|
+
// head can carry the literal token too, so the strip covers both forms.
|
|
117
638
|
if (reportedComplete) assistantText = assistantText.split(INDEXING_COMPLETE_MARKER).join('').trim();
|
|
118
639
|
var serverItemId = item && typeof item.id === 'string' && item.id ? item.id : undefined;
|
|
119
640
|
// A USER bubble shows when the request was made (`created`); an ASSISTANT
|
|
@@ -152,9 +673,11 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
|
|
|
152
673
|
displayContent = sanitizeAttachmentLinksForHistory(userText, opts.projectId);
|
|
153
674
|
}
|
|
154
675
|
var userMsg: any = { role: 'user', content: displayContent };
|
|
676
|
+
if (item._fromBgChain) userMsg._fromBgChain = true;
|
|
155
677
|
if (isInProcess) userMsg.isPendingInProcess = true;
|
|
156
678
|
if (isQueued) userMsg.isPendingQueued = true;
|
|
157
679
|
if (isCancelledItem) userMsg.isCancelled = true;
|
|
680
|
+
if (isCompact) userMsg._compact = true;
|
|
158
681
|
if (item._isBgTask) userMsg.isBackgroundTask = true;
|
|
159
682
|
if (indexFile) userMsg._indexFile = indexFile;
|
|
160
683
|
if (item._isOnBgQueue) userMsg._useBgQueue = true;
|
|
@@ -165,12 +688,17 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
|
|
|
165
688
|
if (isCancelledItem) { /* no assistant bubble */ }
|
|
166
689
|
else if (isInProcess) {
|
|
167
690
|
var ph: any = { role: 'assistant', content: '', isPending: true, isPendingInProcess: true };
|
|
691
|
+
// _ts matters even on a placeholder: the consumers' merge and any
|
|
692
|
+
// display fallback must be able to place it without its user bubble.
|
|
693
|
+
if (userTs !== undefined) ph._ts = userTs;
|
|
694
|
+
if (item._fromBgChain) ph._fromBgChain = true;
|
|
168
695
|
if (item._isBgTask) ph.isBackgroundTask = true;
|
|
169
696
|
if (serverItemId !== undefined) { ph._serverItemId = serverItemId; runningItemIds.push(serverItemId); }
|
|
170
697
|
mapped.push(ph);
|
|
171
698
|
} else if (isQueued) { /* no assistant placeholder */ }
|
|
172
699
|
else if (isErrorResponse) {
|
|
173
700
|
var em: any = { role: 'assistant', content: getErrorMessage(response), isError: true };
|
|
701
|
+
if (item._fromBgChain) em._fromBgChain = true;
|
|
174
702
|
if (item._isBgTask) em.isBackgroundTask = true;
|
|
175
703
|
if (serverItemId !== undefined) em._serverItemId = serverItemId;
|
|
176
704
|
if (replyTs !== undefined) em._ts = replyTs;
|
|
@@ -183,12 +711,24 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
|
|
|
183
711
|
// Safe db-only sanitize (forAssistant) so a volatile db url the model
|
|
184
712
|
// emitted renders as a re-mintable `_expired_.url` link, not a dead one.
|
|
185
713
|
var okm: any = { role: 'assistant', content: sanitizeAttachmentLinksForHistory(assistantText, opts.projectId, true) || EMPTY_INDEXING_REPLY };
|
|
714
|
+
if (item._fromBgChain) okm._fromBgChain = true;
|
|
186
715
|
if (item._isBgTask) okm.isBackgroundTask = true;
|
|
716
|
+
if (isCompact) okm._compact = true;
|
|
187
717
|
if (serverItemId !== undefined) okm._serverItemId = serverItemId;
|
|
188
718
|
if (replyTs !== undefined) okm._ts = replyTs;
|
|
189
719
|
if (reportedComplete) okm._indexComplete = true;
|
|
190
720
|
mapped.push(okm);
|
|
191
721
|
}
|
|
192
722
|
});
|
|
723
|
+
// Stamp the CHAT every bubble belongs to. Every cross-project guard in the
|
|
724
|
+
// consumers reads `_ownerKey` and is written "undefined means unknown, keep
|
|
725
|
+
// it" — and the mapper never set it, so SERVER-history bubbles (which is all
|
|
726
|
+
// of them, including every indexing row) passed every one of those filters
|
|
727
|
+
// unchallenged. That is what let one project's transcript survive on screen
|
|
728
|
+
// into another project and be persisted under its key.
|
|
729
|
+
if (opts.projectId) {
|
|
730
|
+
var ownerKey = opts.projectId + '#' + platform;
|
|
731
|
+
for (var oi = 0; oi < mapped.length; oi++) mapped[oi]._ownerKey = ownerKey;
|
|
732
|
+
}
|
|
193
733
|
return { messages: mapped, runningItemIds: runningItemIds };
|
|
194
734
|
}
|
package/src/engine/host.ts
CHANGED
|
@@ -97,6 +97,10 @@ export interface ChatMessage {
|
|
|
97
97
|
* which is how an 88-page file once "finished" at page 15. */
|
|
98
98
|
_indexComplete?: boolean;
|
|
99
99
|
_useBgQueue?: boolean;
|
|
100
|
+
/** Mapped from an item delivered by the bg chain of the split history fetch
|
|
101
|
+
* (stubs or deferred chats). Surface-frontier logic (retention boundary,
|
|
102
|
+
* clear-horizon) skips these — their ids reach arbitrarily deep. */
|
|
103
|
+
_fromBgChain?: boolean;
|
|
100
104
|
/** Local id of a turn STAGED at Send time while its attachments upload. The
|
|
101
105
|
* bubble exists before any server request does, so it is never matched by
|
|
102
106
|
* _serverItemId and is never promoted/cancelled by the queue machinery —
|
|
@@ -143,6 +147,9 @@ export interface ChatState {
|
|
|
143
147
|
typingAbort: boolean;
|
|
144
148
|
loadingHistory: boolean;
|
|
145
149
|
loadingOlderHistory: boolean;
|
|
150
|
+
/** A deferred bg stub batch (first-paint split fetch) is still in flight;
|
|
151
|
+
* views show a small 'loading indexing history' hint while true. */
|
|
152
|
+
bgHistoryLoading: boolean;
|
|
146
153
|
historyEndOfList: boolean;
|
|
147
154
|
historyStartKeyHistory: string[];
|
|
148
155
|
historyRequestToken: number;
|