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.
@@ -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
- if (major > 5) {
52
- return 'original';
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
- if (major === 5 && minor !== null && minor >= 4) {
56
- return 'original';
57
- }
68
+ return isVariant ? 'high' : 'original';
69
+ };
58
70
 
59
- return DEFAULT_OPENAI_IMAGE_DETAIL;
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
- // Spreadsheets and PDFs are read by PAGING through readFileContent (grid rows +
454
- // embedded photos / scanned page images), NOT inlined once - so they skip the inline
455
- // server-extract and the agent is told to page the whole file (see pagedRead below).
456
- const pagedRead = !parsedContent && isPagedReadFile(attachment.name, attachment.mime);
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 = buildIndexingUserMessage(
469
- attachment,
470
- parsedContent
471
- ? { inlineContent: parsedContent }
472
- : placeholder
473
- ? { inlineContentPlaceholder: placeholder }
474
- : pagedRead
475
- ? { pagedRead: true }
476
- : undefined,
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>,