bunnyquery 1.5.7 → 1.6.2
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/bunnyquery.js +522 -65
- package/dist/engine.cjs +516 -63
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +160 -5
- package/dist/engine.d.ts +160 -5
- package/dist/engine.mjs +511 -64
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/config.ts +19 -0
- package/src/engine/host.ts +19 -0
- package/src/engine/index.ts +2 -1
- package/src/engine/office.ts +78 -5
- package/src/engine/prompts/index.ts +5 -0
- package/src/engine/prompts/indexing_system_prompt.ts +5 -3
- package/src/engine/prompts/indexing_user_message.ts +159 -0
- package/src/engine/requests.ts +170 -25
- package/src/engine/session.ts +402 -42
package/src/engine/requests.ts
CHANGED
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
* state that stays in the consumer, not here;
|
|
11
11
|
* only the BgTaskEntry TYPE lives here)
|
|
12
12
|
*/
|
|
13
|
-
import { buildIndexingSystemPrompt, buildIndexingUserMessage } from './prompts';
|
|
14
|
-
import { isServerExtractable, isPagedReadFile, makeExtractPlaceholder, type ExtractDirective, type FileUrlDirective } from './office';
|
|
15
|
-
import { chatEngineConfig, pollOpt } from './config';
|
|
13
|
+
import { buildIndexingSystemPrompt, buildIndexingUserMessage, buildIndexingContinueMessage, buildIndexingRenderMessage, buildIndexingRenderContinueTemplate, buildIndexingWindowMessage } from './prompts';
|
|
14
|
+
import { isServerExtractable, isPagedReadFile, isImageVisionFile, isWindowedReadFile, makeExtractPlaceholder, makeRenderPlaceholder, makeWindowPlaceholder, RENDER_PAGES_PER_WINDOW, type ExtractDirective, type FileUrlDirective } from './office';
|
|
15
|
+
import { chatEngineConfig, pollOpt, windowedIndexingEnabled } from './config';
|
|
16
16
|
|
|
17
17
|
export const ANTHROPIC_MESSAGES_API_URL = 'https://api.anthropic.com/v1/messages';
|
|
18
18
|
const ANTHROPIC_MODELS_API_URL = 'https://api.anthropic.com/v1/models';
|
|
@@ -38,25 +38,47 @@ export const DEFAULT_OPENAI_MODEL = 'gpt-5.4';
|
|
|
38
38
|
const mcpUrl = () => chatEngineConfig().mcpBaseUrl;
|
|
39
39
|
const clientSecretRequest = (opts: any) => chatEngineConfig().clientSecretRequest(opts);
|
|
40
40
|
|
|
41
|
+
// Resolve the per-image `detail` for OpenAI. The version match tolerates a
|
|
42
|
+
// trailing variant/date suffix (`gpt-5.4-nano`, `-mini`, `-2026-01-01`, …):
|
|
43
|
+
// previously the pattern was anchored with no suffix allowed, so EVERY suffixed
|
|
44
|
+
// model silently fell through to 'auto' — i.e. the cheap tiers that most need
|
|
45
|
+
// resolution were the ones getting downsampled images.
|
|
46
|
+
//
|
|
47
|
+
// Base models keep their exact previous behavior ('original'). A suffixed
|
|
48
|
+
// variant resolves to 'high' rather than 'original': 'high' is the universally
|
|
49
|
+
// supported value, and we have no way to confirm a given variant accepts
|
|
50
|
+
// 'original' — sending an unsupported value would fail the whole request, which
|
|
51
|
+
// is far worse than a slightly less detailed image.
|
|
41
52
|
const getOpenAIImageDetail = (model?: string) => {
|
|
42
53
|
const normalized = (model || DEFAULT_OPENAI_MODEL).trim().toLowerCase();
|
|
43
|
-
const match = normalized.match(/^gpt-(\d+)(?:\.(\d+))?$/);
|
|
54
|
+
const match = normalized.match(/^gpt-(\d+)(?:\.(\d+))?(-[a-z0-9.\-]+)?$/);
|
|
44
55
|
if (!match) {
|
|
45
56
|
return DEFAULT_OPENAI_IMAGE_DETAIL;
|
|
46
57
|
}
|
|
47
58
|
|
|
48
59
|
const major = Number(match[1]);
|
|
49
60
|
const minor = match[2] === undefined ? null : Number(match[2]);
|
|
61
|
+
const isVariant = !!match[3];
|
|
50
62
|
|
|
51
|
-
|
|
52
|
-
|
|
63
|
+
const supportsOriginal = major > 5 || (major === 5 && minor !== null && minor >= 4);
|
|
64
|
+
if (!supportsOriginal) {
|
|
65
|
+
return DEFAULT_OPENAI_IMAGE_DETAIL;
|
|
53
66
|
}
|
|
54
67
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
68
|
+
return isVariant ? 'high' : 'original';
|
|
69
|
+
};
|
|
58
70
|
|
|
59
|
-
|
|
71
|
+
// Per-image `detail` for WORKER-RENDERED document pages (the `_skapi_render`
|
|
72
|
+
// path). Same resolution as above with one difference: these are dense scans
|
|
73
|
+
// whose entire purpose is to be read, so 'auto' is never acceptable — it lets
|
|
74
|
+
// the API downsample exactly the pixels the model needs to OCR. Floor it at
|
|
75
|
+
// 'high'; models that support full-resolution 'original' still get it.
|
|
76
|
+
//
|
|
77
|
+
// Without this the worker falls back to its own model-blind default ('high'),
|
|
78
|
+
// which silently denies the strongest models the 'original' detail they support.
|
|
79
|
+
const getRenderImageDetail = (model?: string) => {
|
|
80
|
+
const detail = getOpenAIImageDetail(model);
|
|
81
|
+
return detail === DEFAULT_OPENAI_IMAGE_DETAIL ? 'high' : detail;
|
|
60
82
|
};
|
|
61
83
|
|
|
62
84
|
export type ClaudeRole = 'user' | 'assistant';
|
|
@@ -442,21 +464,102 @@ export type AttachmentSaveInfo = {
|
|
|
442
464
|
* takes precedence over server-side office extraction / web_fetch.
|
|
443
465
|
*/
|
|
444
466
|
parsedContent?: string;
|
|
467
|
+
/**
|
|
468
|
+
* True for a RESUME pass: a previous indexing pass could not finish this (large)
|
|
469
|
+
* file, so continue it - always via readFileContent paging, with a "continue"
|
|
470
|
+
* message telling the agent to resume from where the saved records leave off.
|
|
471
|
+
*/
|
|
472
|
+
continueIndexing?: boolean;
|
|
473
|
+
/**
|
|
474
|
+
* For an image-vision file (PDF), the 0-based PAGE the render window should start at.
|
|
475
|
+
* The worker renders [renderFrom, renderFrom+RENDER_PAGES_PER_WINDOW) and injects them
|
|
476
|
+
* as image blocks; the resume loop advances this by a window each pass.
|
|
477
|
+
*/
|
|
478
|
+
renderFrom?: number;
|
|
445
479
|
};
|
|
446
480
|
|
|
481
|
+
// RESUME pass: continue indexing a large file a previous pass could not finish. Same
|
|
482
|
+
// dispatch as notifyAgentSaveAttachment, but forced onto the paging path with a
|
|
483
|
+
// "continue from where the saved records leave off" message.
|
|
484
|
+
export async function notifyAgentContinueIndexing(info: AttachmentSaveInfo) {
|
|
485
|
+
return notifyAgentSaveAttachment({ ...info, continueIndexing: true });
|
|
486
|
+
}
|
|
487
|
+
|
|
447
488
|
// Background "save into knowledge" call (not a chat turn). A client-parsed file
|
|
448
489
|
// (parser plugin) is inlined directly; otherwise office files get the
|
|
449
490
|
// _skapi_extract directive + a placeholder, and everything else gets a URL.
|
|
450
491
|
export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
|
|
451
492
|
const { platform, service, owner, attachment, parsedContent } = info;
|
|
452
493
|
|
|
453
|
-
//
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
494
|
+
// A CONTINUE pass resumes a large file that a previous pass could not finish.
|
|
495
|
+
const continuing = !!info.continueIndexing;
|
|
496
|
+
|
|
497
|
+
// VISION files (PDFs) are delivered as rendered page IMAGES injected into the message by
|
|
498
|
+
// the worker (`_skapi_render`), because tool-result images render on neither provider.
|
|
499
|
+
// Both the first pass and every resume pass use this; renderFrom advances the page window.
|
|
500
|
+
const visionFile = !parsedContent && isImageVisionFile(attachment.name, attachment.mime);
|
|
501
|
+
const renderFrom = Math.max(0, info.renderFrom || 0);
|
|
502
|
+
const renderPlaceholder = visionFile ? makeRenderPlaceholder(attachment.storagePath) : undefined;
|
|
503
|
+
// Tell the worker what image `detail` to stamp on the injected page blocks.
|
|
504
|
+
// The worker is model-blind, so without this it applies a one-size default and
|
|
505
|
+
// the strongest models never get the 'original' detail they support. Only
|
|
506
|
+
// meaningful for OpenAI — Claude has no per-image detail knob and the worker
|
|
507
|
+
// ignores the field for it.
|
|
508
|
+
const renderDetail = platform === 'openai'
|
|
509
|
+
? getRenderImageDetail(info.model || DEFAULT_OPENAI_MODEL)
|
|
510
|
+
: undefined;
|
|
511
|
+
// `auto_continue` + `continue_text` hand the page loop to the WORKER: when its renderer
|
|
512
|
+
// reports pages left after this window, it builds the next pass from this template
|
|
513
|
+
// (substituting the window's 1-based start page for RENDER_FROM_TOKEN) and enqueues it
|
|
514
|
+
// itself. That is what makes a 500-page document index end-to-end — the loop no longer
|
|
515
|
+
// depends on the tab staying open, nor on the model correctly declaring itself unfinished.
|
|
516
|
+
const skapiRender = visionFile && renderPlaceholder
|
|
517
|
+
? {
|
|
518
|
+
_skapi_render: [
|
|
519
|
+
{
|
|
520
|
+
path: attachment.storagePath, from: renderFrom, count: RENDER_PAGES_PER_WINDOW,
|
|
521
|
+
placeholder: renderPlaceholder, name: attachment.name, mime: attachment.mime, detail: renderDetail,
|
|
522
|
+
auto_continue: true,
|
|
523
|
+
continue_text: buildIndexingRenderContinueTemplate(attachment, renderPlaceholder),
|
|
524
|
+
},
|
|
525
|
+
],
|
|
526
|
+
}
|
|
527
|
+
: {};
|
|
528
|
+
|
|
529
|
+
// SERVER-DRIVEN windowed read. When enabled, the worker reads one window of the file
|
|
530
|
+
// per request and continues from the reader's own cursor until the file is exhausted,
|
|
531
|
+
// so the traversal no longer lives inside the model's turn budget.
|
|
532
|
+
//
|
|
533
|
+
// Flag-gated because the BACKEND MUST SHIP FIRST: against a worker that does not strip
|
|
534
|
+
// `_skapi_window`, the directive reaches the provider as an unknown body field and the
|
|
535
|
+
// call fails terminally with no retry.
|
|
536
|
+
const windowedRead =
|
|
537
|
+
!visionFile && !parsedContent && windowedIndexingEnabled() &&
|
|
538
|
+
isWindowedReadFile(attachment.name, attachment.mime);
|
|
539
|
+
const windowPlaceholder = windowedRead ? makeWindowPlaceholder(attachment.storagePath) : undefined;
|
|
540
|
+
const skapiWindow = windowedRead && windowPlaceholder
|
|
541
|
+
? {
|
|
542
|
+
_skapi_window: [
|
|
543
|
+
{
|
|
544
|
+
path: attachment.storagePath,
|
|
545
|
+
cursor: null,
|
|
546
|
+
placeholder: windowPlaceholder,
|
|
547
|
+
name: attachment.name,
|
|
548
|
+
mime: attachment.mime,
|
|
549
|
+
kind: 'window',
|
|
550
|
+
auto_continue: true,
|
|
551
|
+
continue_text: buildIndexingWindowMessage(attachment, windowPlaceholder, true),
|
|
552
|
+
},
|
|
553
|
+
],
|
|
554
|
+
}
|
|
555
|
+
: {};
|
|
556
|
+
|
|
557
|
+
// Spreadsheets are read by PAGING through readFileContent (grid rows), NOT inlined - so
|
|
558
|
+
// they skip the inline server-extract and the agent is told to page the whole file.
|
|
559
|
+
const pagedRead = !visionFile && !windowedRead && (continuing || (!parsedContent && isPagedReadFile(attachment.name, attachment.mime)));
|
|
457
560
|
|
|
458
561
|
// Client-parsed content wins over server-side extraction.
|
|
459
|
-
const serverExtract = !parsedContent && !pagedRead && isServerExtractable(attachment.name, attachment.mime);
|
|
562
|
+
const serverExtract = !visionFile && !windowedRead && !continuing && !parsedContent && !pagedRead && isServerExtractable(attachment.name, attachment.mime);
|
|
460
563
|
const placeholder = serverExtract ? makeExtractPlaceholder(attachment.storagePath) : undefined;
|
|
461
564
|
const extractContent: ExtractDirective[] | undefined =
|
|
462
565
|
serverExtract && placeholder
|
|
@@ -465,16 +568,22 @@ export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
|
|
|
465
568
|
const skapiExtract =
|
|
466
569
|
extractContent && extractContent.length ? { _skapi_extract: extractContent } : {};
|
|
467
570
|
|
|
468
|
-
const userMessage =
|
|
469
|
-
attachment,
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
571
|
+
const userMessage = (visionFile && renderPlaceholder)
|
|
572
|
+
? buildIndexingRenderMessage(attachment, renderPlaceholder, renderFrom)
|
|
573
|
+
: (windowedRead && windowPlaceholder)
|
|
574
|
+
? buildIndexingWindowMessage(attachment, windowPlaceholder, false)
|
|
575
|
+
: continuing
|
|
576
|
+
? buildIndexingContinueMessage(attachment)
|
|
577
|
+
: buildIndexingUserMessage(
|
|
578
|
+
attachment,
|
|
579
|
+
parsedContent
|
|
580
|
+
? { inlineContent: parsedContent }
|
|
581
|
+
: placeholder
|
|
582
|
+
? { inlineContentPlaceholder: placeholder }
|
|
583
|
+
: pagedRead
|
|
584
|
+
? { pagedRead: true }
|
|
585
|
+
: undefined,
|
|
586
|
+
);
|
|
478
587
|
|
|
479
588
|
const systemPrompt = buildIndexingSystemPrompt({
|
|
480
589
|
service,
|
|
@@ -501,6 +610,8 @@ export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
|
|
|
501
610
|
model: resolvedModel,
|
|
502
611
|
max_output_tokens: MAX_TOKENS,
|
|
503
612
|
...skapiExtract,
|
|
613
|
+
...skapiRender,
|
|
614
|
+
...skapiWindow,
|
|
504
615
|
input: [
|
|
505
616
|
{ role: 'system', content: systemPrompt },
|
|
506
617
|
{
|
|
@@ -548,6 +659,8 @@ export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
|
|
|
548
659
|
model: resolvedModel,
|
|
549
660
|
max_tokens: MAX_TOKENS,
|
|
550
661
|
...skapiExtract,
|
|
662
|
+
...skapiRender,
|
|
663
|
+
...skapiWindow,
|
|
551
664
|
system: [
|
|
552
665
|
{
|
|
553
666
|
type: 'text',
|
|
@@ -672,6 +785,23 @@ export async function listOpenAIModels(service: string, owner: string) {
|
|
|
672
785
|
// so the chat-history BETWEEN query never includes bg-queue items. '-' (45) works.
|
|
673
786
|
export const BG_INDEXING_QUEUE_SUFFIX = '-bg';
|
|
674
787
|
|
|
788
|
+
/**
|
|
789
|
+
* True when a request belongs to the background-indexing queue.
|
|
790
|
+
*
|
|
791
|
+
* Accepts BOTH shapes this value arrives in: the bare queue name the client sends
|
|
792
|
+
* ("<userId>-bg"), and the server qid that comes back on history/poll responses
|
|
793
|
+
* ("<service>:<queue>|<seq>"). Testing the tail of the raw value only works for the
|
|
794
|
+
* first: a qid ends in "|<seq>", so `endsWith('-bg')` is always false for it — which
|
|
795
|
+
* silently meant history items were NEVER recognised as background tasks.
|
|
796
|
+
*/
|
|
797
|
+
export function isBgIndexingQueue(queueName?: string): boolean {
|
|
798
|
+
if (typeof queueName !== 'string' || !queueName) return false;
|
|
799
|
+
const prefix = queueName.split('|')[0];
|
|
800
|
+
const idx = prefix.lastIndexOf(':');
|
|
801
|
+
const name = idx === -1 ? prefix : prefix.slice(idx + 1);
|
|
802
|
+
return name.slice(-BG_INDEXING_QUEUE_SUFFIX.length) === BG_INDEXING_QUEUE_SUFFIX;
|
|
803
|
+
}
|
|
804
|
+
|
|
675
805
|
// Pending background-indexing task descriptor. NOTE: the live mutable queue
|
|
676
806
|
// (a Vue `reactive([])` in agent.vue, a plain array in bunnyquery) is app-level
|
|
677
807
|
// state owned by the consumer — only the TYPE lives in the engine.
|
|
@@ -686,8 +816,23 @@ export type BgTaskEntry = {
|
|
|
686
816
|
size?: number;
|
|
687
817
|
status: 'running' | 'pending';
|
|
688
818
|
poll: ((opts: { latency: number }) => Promise<any>) | undefined;
|
|
819
|
+
/** How many CONTINUE passes have already run for this file (resume-across-passes). */
|
|
820
|
+
resumePass?: number;
|
|
689
821
|
};
|
|
690
822
|
|
|
823
|
+
// Token the indexing agent appends to its final message ONLY when it has fully read and
|
|
824
|
+
// saved the whole file. Its ABSENCE is what tells the client to run another CONTINUE pass.
|
|
825
|
+
//
|
|
826
|
+
// Applies to the TEXT/GRID paging path only. The vision path (rendered PDF pages) no longer
|
|
827
|
+
// asks the model whether it is finished — the worker advances that loop off the renderer's
|
|
828
|
+
// page count — so this marker has no say in whether a PDF keeps going.
|
|
829
|
+
export const INDEXING_COMPLETE_MARKER = 'INDEXING_COMPLETE';
|
|
830
|
+
// Cap on CONTINUE passes per file, so a file the agent can never mark complete (or a
|
|
831
|
+
// pathological loop) stops instead of re-dispatching forever. The text/grid paging path
|
|
832
|
+
// reads MANY windows within a single pass (the agent loops readFileContent in one turn), so
|
|
833
|
+
// a small cap suffices.
|
|
834
|
+
export const MAX_INDEXING_RESUME_PASSES = 6;
|
|
835
|
+
|
|
691
836
|
export async function getChatHistory(
|
|
692
837
|
params: { service?: string; owner?: string; platform: 'claude' | 'openai'; queue?: string },
|
|
693
838
|
fetchOptions: Record<string, any>,
|