@babav/knowledge-core-client 0.22.4 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -214,6 +214,16 @@ export interface UploadUrl {
214
214
  gcs_uri: string;
215
215
  expires_in: number;
216
216
  }
217
+ /** Batch of signed PUT URLs (corpora.uploadMany drives this). `items` is in request order. */
218
+ export interface BatchUploadUrls {
219
+ items: Array<{
220
+ filename: string;
221
+ document_id: UUID;
222
+ upload_url: string;
223
+ gcs_uri: string;
224
+ }>;
225
+ expires_in: number;
226
+ }
217
227
  export interface Conversation {
218
228
  id: UUID;
219
229
  title: string | null;
@@ -359,12 +369,41 @@ export interface StreamHandlers {
359
369
  /** Catch-all for any event (incl. unknown ones). */
360
370
  onEvent?: (event: string, data: unknown) => void;
361
371
  }
372
+ /** A live document status transition pushed over the ingestion-status SSE stream
373
+ * (documents.events / folders.events). Replaces polling GET /documents/count. */
374
+ export interface DocumentStatusEvent {
375
+ document_id: string;
376
+ corpus_id?: string | null;
377
+ folder_id?: string | null;
378
+ status: string;
379
+ filename?: string | null;
380
+ chunk_count?: number | null;
381
+ error?: string | null;
382
+ counts?: DocumentCount;
383
+ }
384
+ export interface DocumentEventsSnapshot {
385
+ documents: DocumentStatusEvent[];
386
+ counts: DocumentCount;
387
+ }
388
+ export interface DocumentEventHandlers {
389
+ /** Current state on connect (one query) — render the list from this. */
390
+ onSnapshot?: (s: DocumentEventsSnapshot) => void;
391
+ /** A single document's status changed (the moment it happened). Update that row + counts. */
392
+ onDocument?: (e: DocumentStatusEvent) => void;
393
+ /** Nothing is in-flight anymore — the stream is about to close. Authoritative final counts. */
394
+ onComplete?: (counts: DocumentCount) => void;
395
+ onError?: (err: unknown) => void;
396
+ }
362
397
  export declare class KnowledgeCoreClient extends HttpBase {
363
398
  /** @param opts.apiKey a TENANT key, supplied by the caller. */
364
399
  constructor(opts: ClientOptions);
365
400
  query(agentId: UUID, body: QueryRequest): Promise<QueryResponse>;
366
401
  /** Streaming query (SSE). Resolves when the stream ends. */
367
402
  queryStream(agentId: UUID, body: QueryRequest, handlers: StreamHandlers, signal?: AbortSignal): Promise<void>;
403
+ /** Shared SSE reader for the document-status streams (documents.events / folders.events).
404
+ * Resolves when the stream ends (server sends `complete` once nothing is in-flight, or the
405
+ * cap is hit). Abort via the signal to stop watching. Throws 501 if the env has no bus. */
406
+ _streamDocEvents(path: string, handlers: DocumentEventHandlers, signal?: AbortSignal): Promise<void>;
368
407
  retrieve(body: {
369
408
  query: string;
370
409
  corpus_ids: UUID[];
@@ -404,11 +443,10 @@ export declare class KnowledgeCoreClient extends HttpBase {
404
443
  limit?: number;
405
444
  cursor?: string;
406
445
  }) => Promise<Page<Document>>;
407
- /** Ingest a document ASYNCHRONOUSLY — resolves with the created document at
408
- * status `pending` (HTTP 202); track via documents.get() polling to
409
- * `indexed`/`failed`, or the tenant result webhook. Auto-routes by size: files
410
- * over ~30 MB go via the signed-URL upload path (Cloud Run caps requests at
411
- * ~32 MB), smaller files via one multipart request. One method, any size. */
446
+ /** Ingest ONE document ASYNCHRONOUSLY — resolves with the created document at status
447
+ * `pending` (HTTP 202); track via documents.events()/get() or the tenant webhook.
448
+ * ALWAYS uploads the bytes straight to GCS via a signed URL (uploadUrl → PUT → ingestFromUpload),
449
+ * regardless of size so there is NO request-size cap, ever. One method, any size. */
412
450
  ingestDocument: (id: UUID, a: {
413
451
  file: FileData;
414
452
  filename: string;
@@ -443,6 +481,21 @@ export declare class KnowledgeCoreClient extends HttpBase {
443
481
  folder_id?: UUID;
444
482
  custom_metadata?: Record<string, unknown>;
445
483
  }) => Promise<Document>;
484
+ /** Ingest MANY files in ONE go — any size, no 429, no client backoff. Mints all signed URLs in
485
+ * one request, PUTs the bytes straight to GCS (bounded concurrency; never touches the KC), then
486
+ * registers the whole batch in one request (N pending docs + N Cloud Tasks). `opts`
487
+ * (folder_id / visibility / custom_metadata — the latter carries `owner`) applies to ALL files.
488
+ * Resolves with the created `pending` documents. */
489
+ uploadMany: (id: UUID, files: Array<{
490
+ file: FileData;
491
+ filename: string;
492
+ content_type?: string;
493
+ }>, opts?: {
494
+ folder_id?: UUID;
495
+ visibility?: Visibility;
496
+ custom_metadata?: Record<string, unknown>;
497
+ concurrency?: number;
498
+ }) => Promise<Document[]>;
446
499
  };
447
500
  parse(a: {
448
501
  file: FileData;
@@ -466,6 +519,8 @@ export declare class KnowledgeCoreClient extends HttpBase {
466
519
  limit?: number;
467
520
  cursor?: string;
468
521
  }) => Promise<Page<Document>>;
522
+ /** Live ingestion-status stream for ONE folder (same contract as documents.events). */
523
+ events: (folderId: UUID, handlers: DocumentEventHandlers, signal?: AbortSignal) => Promise<void>;
469
524
  };
470
525
  documents: {
471
526
  get: (id: UUID) => Promise<Document>;
@@ -478,6 +533,10 @@ export declare class KnowledgeCoreClient extends HttpBase {
478
533
  }) => Promise<Document>;
479
534
  contentUrl: (id: UUID, disposition?: "inline" | "attachment") => Promise<ContentUrl>;
480
535
  delete: (id: UUID) => Promise<void>;
536
+ /** Live ingestion-status stream for a corpus (SSE PUSH — replaces polling count). Snapshot on
537
+ * connect, then a delta per transition, then `complete` when nothing is in-flight. Abort via
538
+ * the signal. 501 (onError) if the environment has no doc-status bus — fall back to polling. */
539
+ events: (corpusId: UUID, handlers: DocumentEventHandlers, signal?: AbortSignal) => Promise<void>;
481
540
  };
482
541
  conversations: {
483
542
  create: (b?: {
package/dist/index.js CHANGED
@@ -176,6 +176,38 @@ export class KnowledgeCoreClient extends HttpBase {
176
176
  if (buf.trim())
177
177
  dispatchSse(buf, handlers);
178
178
  }
179
+ /** Shared SSE reader for the document-status streams (documents.events / folders.events).
180
+ * Resolves when the stream ends (server sends `complete` once nothing is in-flight, or the
181
+ * cap is hit). Abort via the signal to stop watching. Throws 501 if the env has no bus. */
182
+ async _streamDocEvents(path, handlers, signal) {
183
+ const res = await this.raw("GET", path, { signal });
184
+ if (!res.ok || !res.body) {
185
+ const t = await res.text();
186
+ const err = new KnowledgeCoreError(res.status, safeJson(t), path);
187
+ if (handlers.onError) {
188
+ handlers.onError(err);
189
+ return;
190
+ }
191
+ throw err;
192
+ }
193
+ const reader = res.body.getReader();
194
+ const decoder = new TextDecoder();
195
+ let buf = "";
196
+ for (;;) {
197
+ const { value, done } = await reader.read();
198
+ if (done)
199
+ break;
200
+ buf += decoder.decode(value, { stream: true });
201
+ let idx;
202
+ while ((idx = buf.indexOf("\n\n")) !== -1) {
203
+ const frame = buf.slice(0, idx);
204
+ buf = buf.slice(idx + 2);
205
+ dispatchDocEvent(frame, handlers);
206
+ }
207
+ }
208
+ if (buf.trim())
209
+ dispatchDocEvent(buf, handlers);
210
+ }
179
211
  // --- retrieve (cross-corpus primitive, no generation) ---
180
212
  retrieve(body) {
181
213
  return this.request("POST", "/v1/retrieve", { json: body });
@@ -194,24 +226,11 @@ export class KnowledgeCoreClient extends HttpBase {
194
226
  countDocuments: (id) => this.request("GET", `/v1/corpora/${id}/documents/count`),
195
227
  /** Documents in the corpus whose filename contains `q` (case-insensitive), paginated. */
196
228
  searchDocuments: (id, q, opts) => this.request("GET", `/v1/corpora/${id}/documents/search`, { query: { q, ...opts } }),
197
- /** Ingest a document ASYNCHRONOUSLY — resolves with the created document at
198
- * status `pending` (HTTP 202); track via documents.get() polling to
199
- * `indexed`/`failed`, or the tenant result webhook. Auto-routes by size: files
200
- * over ~30 MB go via the signed-URL upload path (Cloud Run caps requests at
201
- * ~32 MB), smaller files via one multipart request. One method, any size. */
202
- ingestDocument: (id, a) => {
203
- if (fileSize(a.file) > MULTIPART_MAX_BYTES)
204
- return this.corpora.uploadDocument(id, a);
205
- const fd = new FormData();
206
- fd.append("file", toBlob(a.file, a.content_type), a.filename);
207
- if (a.custom_metadata)
208
- fd.append("custom_metadata", JSON.stringify(a.custom_metadata));
209
- if (a.visibility)
210
- fd.append("visibility", a.visibility);
211
- if (a.folder_id)
212
- fd.append("folder_id", a.folder_id);
213
- return this.request("POST", `/v1/corpora/${id}/documents`, { body: fd });
214
- },
229
+ /** Ingest ONE document ASYNCHRONOUSLY — resolves with the created document at status
230
+ * `pending` (HTTP 202); track via documents.events()/get() or the tenant webhook.
231
+ * ALWAYS uploads the bytes straight to GCS via a signed URL (uploadUrl → PUT → ingestFromUpload),
232
+ * regardless of size so there is NO request-size cap, ever. One method, any size. */
233
+ ingestDocument: (id, a) => this.corpora.uploadDocument(id, a),
215
234
  /** Mint a signed PUT URL to upload a large file straight to GCS (bypasses the
216
235
  * ~32 MB request limit). PUT the bytes to upload_url, then ingestFromUpload. */
217
236
  uploadUrl: (id, a) => this.request("POST", `/v1/corpora/${id}/documents/upload-url`, { json: a }),
@@ -235,6 +254,33 @@ export class KnowledgeCoreClient extends HttpBase {
235
254
  visibility: a.visibility, folder_id: a.folder_id, custom_metadata: a.custom_metadata,
236
255
  });
237
256
  },
257
+ /** Ingest MANY files in ONE go — any size, no 429, no client backoff. Mints all signed URLs in
258
+ * one request, PUTs the bytes straight to GCS (bounded concurrency; never touches the KC), then
259
+ * registers the whole batch in one request (N pending docs + N Cloud Tasks). `opts`
260
+ * (folder_id / visibility / custom_metadata — the latter carries `owner`) applies to ALL files.
261
+ * Resolves with the created `pending` documents. */
262
+ uploadMany: async (id, files, opts) => {
263
+ if (files.length === 0)
264
+ return [];
265
+ const urls = await this.request("POST", `/v1/corpora/${id}/documents/upload-urls`, { json: { items: files.map((f) => ({ filename: f.filename, content_type: f.content_type })) } });
266
+ const items = urls.items; // same order as the request
267
+ await poolMap(files.length, opts?.concurrency ?? 6, async (i) => {
268
+ const f = files[i];
269
+ const it = items[i];
270
+ const put = await this._fetch(it.upload_url, {
271
+ method: "PUT",
272
+ body: toBlob(f.file, f.content_type),
273
+ headers: f.content_type ? { "content-type": f.content_type } : {},
274
+ });
275
+ if (!put.ok)
276
+ throw new KnowledgeCoreError(put.status, safeJson(await put.text()), it.upload_url);
277
+ });
278
+ const res = await this.request("POST", `/v1/corpora/${id}/documents/batch`, { json: {
279
+ folder_id: opts?.folder_id, visibility: opts?.visibility, custom_metadata: opts?.custom_metadata,
280
+ items: items.map((it) => ({ document_id: it.document_id, filename: it.filename })),
281
+ } });
282
+ return res.documents;
283
+ },
238
284
  };
239
285
  // --- parsing (utility: file -> text, stores nothing) ---
240
286
  parse(a) {
@@ -255,6 +301,8 @@ export class KnowledgeCoreClient extends HttpBase {
255
301
  countDocuments: (folderId) => this.request("GET", `/v1/folders/${folderId}/documents/count`),
256
302
  /** Documents in the folder whose filename contains `q` (case-insensitive), paginated. */
257
303
  searchDocuments: (folderId, q, opts) => this.request("GET", `/v1/folders/${folderId}/documents/search`, { query: { q, ...opts } }),
304
+ /** Live ingestion-status stream for ONE folder (same contract as documents.events). */
305
+ events: (folderId, handlers, signal) => this._streamDocEvents(`/v1/folders/${folderId}/documents/events`, handlers, signal),
258
306
  };
259
307
  // --- documents ---
260
308
  documents = {
@@ -265,6 +313,10 @@ export class KnowledgeCoreClient extends HttpBase {
265
313
  update: (id, b) => this.request("PATCH", `/v1/documents/${id}`, { json: b }),
266
314
  contentUrl: (id, disposition = "inline") => this.request("GET", `/v1/documents/${id}/content-url`, { query: { disposition } }),
267
315
  delete: (id) => this.request("DELETE", `/v1/documents/${id}`),
316
+ /** Live ingestion-status stream for a corpus (SSE PUSH — replaces polling count). Snapshot on
317
+ * connect, then a delta per transition, then `complete` when nothing is in-flight. Abort via
318
+ * the signal. 501 (onError) if the environment has no doc-status bus — fall back to polling. */
319
+ events: (corpusId, handlers, signal) => this._streamDocEvents(`/v1/corpora/${corpusId}/documents/events`, handlers, signal),
268
320
  };
269
321
  // --- conversations ---
270
322
  conversations = {
@@ -338,6 +390,15 @@ export class AdminClient extends HttpBase {
338
390
  // ---------------------------------------------------------------------------
339
391
  // helpers
340
392
  // ---------------------------------------------------------------------------
393
+ /** Run fn(0..n-1) with bounded concurrency (used by uploadMany for the direct-to-GCS PUTs). */
394
+ async function poolMap(n, concurrency, fn) {
395
+ let next = 0;
396
+ const workers = Array.from({ length: Math.max(1, Math.min(concurrency, n)) }, async () => {
397
+ for (let i = next++; i < n; i = next++)
398
+ await fn(i);
399
+ });
400
+ await Promise.all(workers);
401
+ }
341
402
  function dispatchSse(frame, h) {
342
403
  let event = "message";
343
404
  const dataLines = [];
@@ -384,3 +445,27 @@ function dispatchSse(frame, h) {
384
445
  break;
385
446
  }
386
447
  }
448
+ function dispatchDocEvent(frame, h) {
449
+ let event = "message";
450
+ const dataLines = [];
451
+ for (const line of frame.split("\n")) {
452
+ if (line.startsWith("event:"))
453
+ event = line.slice(6).trim();
454
+ else if (line.startsWith("data:"))
455
+ dataLines.push(line.slice(5).trim());
456
+ }
457
+ if (dataLines.length === 0)
458
+ return; // heartbeat / comment frame
459
+ const data = safeJson(dataLines.join("\n"));
460
+ switch (event) {
461
+ case "snapshot":
462
+ h.onSnapshot?.(data);
463
+ break;
464
+ case "document":
465
+ h.onDocument?.(data);
466
+ break;
467
+ case "complete":
468
+ h.onComplete?.(data);
469
+ break;
470
+ }
471
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@babav/knowledge-core-client",
3
- "version": "0.22.4",
3
+ "version": "0.23.0",
4
4
  "description": "TypeScript client for the Babav Knowledge Core API (Deno + Node 18+, zero deps). Includes the babav.visual grammar TYPES at the ./visual subpath (types only; all visual rendering is server-side).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/index.ts CHANGED
@@ -235,6 +235,11 @@ export interface UploadUrl {
235
235
  gcs_uri: string;
236
236
  expires_in: number;
237
237
  }
238
+ /** Batch of signed PUT URLs (corpora.uploadMany drives this). `items` is in request order. */
239
+ export interface BatchUploadUrls {
240
+ items: Array<{ filename: string; document_id: UUID; upload_url: string; gcs_uri: string }>;
241
+ expires_in: number;
242
+ }
238
243
  export interface Conversation {
239
244
  id: UUID;
240
245
  title: string | null;
@@ -480,6 +485,32 @@ export interface StreamHandlers {
480
485
  onEvent?: (event: string, data: unknown) => void;
481
486
  }
482
487
 
488
+ /** A live document status transition pushed over the ingestion-status SSE stream
489
+ * (documents.events / folders.events). Replaces polling GET /documents/count. */
490
+ export interface DocumentStatusEvent {
491
+ document_id: string;
492
+ corpus_id?: string | null;
493
+ folder_id?: string | null;
494
+ status: string; // pending | ingesting | indexed | failed | deleting
495
+ filename?: string | null;
496
+ chunk_count?: number | null;
497
+ error?: string | null;
498
+ counts?: DocumentCount; // running aggregate, included on each `document` delta
499
+ }
500
+ export interface DocumentEventsSnapshot {
501
+ documents: DocumentStatusEvent[]; // current state of every doc in the corpus/folder
502
+ counts: DocumentCount;
503
+ }
504
+ export interface DocumentEventHandlers {
505
+ /** Current state on connect (one query) — render the list from this. */
506
+ onSnapshot?: (s: DocumentEventsSnapshot) => void;
507
+ /** A single document's status changed (the moment it happened). Update that row + counts. */
508
+ onDocument?: (e: DocumentStatusEvent) => void;
509
+ /** Nothing is in-flight anymore — the stream is about to close. Authoritative final counts. */
510
+ onComplete?: (counts: DocumentCount) => void;
511
+ onError?: (err: unknown) => void;
512
+ }
513
+
483
514
  // ---------------------------------------------------------------------------
484
515
  // Tenant client (data ops) — use a TENANT key
485
516
  // ---------------------------------------------------------------------------
@@ -518,6 +549,34 @@ export class KnowledgeCoreClient extends HttpBase {
518
549
  if (buf.trim()) dispatchSse(buf, handlers);
519
550
  }
520
551
 
552
+ /** Shared SSE reader for the document-status streams (documents.events / folders.events).
553
+ * Resolves when the stream ends (server sends `complete` once nothing is in-flight, or the
554
+ * cap is hit). Abort via the signal to stop watching. Throws 501 if the env has no bus. */
555
+ async _streamDocEvents(path: string, handlers: DocumentEventHandlers, signal?: AbortSignal): Promise<void> {
556
+ const res = await this.raw("GET", path, { signal });
557
+ if (!res.ok || !res.body) {
558
+ const t = await res.text();
559
+ const err = new KnowledgeCoreError(res.status, safeJson(t), path);
560
+ if (handlers.onError) { handlers.onError(err); return; }
561
+ throw err;
562
+ }
563
+ const reader = res.body.getReader();
564
+ const decoder = new TextDecoder();
565
+ let buf = "";
566
+ for (;;) {
567
+ const { value, done } = await reader.read();
568
+ if (done) break;
569
+ buf += decoder.decode(value, { stream: true });
570
+ let idx: number;
571
+ while ((idx = buf.indexOf("\n\n")) !== -1) {
572
+ const frame = buf.slice(0, idx);
573
+ buf = buf.slice(idx + 2);
574
+ dispatchDocEvent(frame, handlers);
575
+ }
576
+ }
577
+ if (buf.trim()) dispatchDocEvent(buf, handlers);
578
+ }
579
+
521
580
  // --- retrieve (cross-corpus primitive, no generation) ---
522
581
  retrieve(body: { query: string; corpus_ids: UUID[]; top_k_retrieved_chunks?: number; top_k_reranked_chunks?: number; rerank?: boolean; instruction?: string; filter?: MetadataFilter; }): Promise<{ retrieval_contents: RetrievalContent[] }> {
523
582
  return this.request("POST", "/v1/retrieve", { json: body });
@@ -542,20 +601,12 @@ export class KnowledgeCoreClient extends HttpBase {
542
601
  /** Documents in the corpus whose filename contains `q` (case-insensitive), paginated. */
543
602
  searchDocuments: (id: UUID, q: string, opts?: { limit?: number; cursor?: string }) =>
544
603
  this.request<Page<Document>>("GET", `/v1/corpora/${id}/documents/search`, { query: { q, ...opts } }),
545
- /** Ingest a document ASYNCHRONOUSLY — resolves with the created document at
546
- * status `pending` (HTTP 202); track via documents.get() polling to
547
- * `indexed`/`failed`, or the tenant result webhook. Auto-routes by size: files
548
- * over ~30 MB go via the signed-URL upload path (Cloud Run caps requests at
549
- * ~32 MB), smaller files via one multipart request. One method, any size. */
550
- ingestDocument: (id: UUID, a: { file: FileData; filename: string; content_type?: string; visibility?: Visibility; folder_id?: UUID; custom_metadata?: Record<string, unknown> }) => {
551
- if (fileSize(a.file) > MULTIPART_MAX_BYTES) return this.corpora.uploadDocument(id, a);
552
- const fd = new FormData();
553
- fd.append("file", toBlob(a.file, a.content_type), a.filename);
554
- if (a.custom_metadata) fd.append("custom_metadata", JSON.stringify(a.custom_metadata));
555
- if (a.visibility) fd.append("visibility", a.visibility);
556
- if (a.folder_id) fd.append("folder_id", a.folder_id);
557
- return this.request<Document>("POST", `/v1/corpora/${id}/documents`, { body: fd });
558
- },
604
+ /** Ingest ONE document ASYNCHRONOUSLY — resolves with the created document at status
605
+ * `pending` (HTTP 202); track via documents.events()/get() or the tenant webhook.
606
+ * ALWAYS uploads the bytes straight to GCS via a signed URL (uploadUrl → PUT → ingestFromUpload),
607
+ * regardless of size so there is NO request-size cap, ever. One method, any size. */
608
+ ingestDocument: (id: UUID, a: { file: FileData; filename: string; content_type?: string; visibility?: Visibility; folder_id?: UUID; custom_metadata?: Record<string, unknown> }) =>
609
+ this.corpora.uploadDocument(id, a),
559
610
  /** Mint a signed PUT URL to upload a large file straight to GCS (bypasses the
560
611
  * ~32 MB request limit). PUT the bytes to upload_url, then ingestFromUpload. */
561
612
  uploadUrl: (id: UUID, a: { filename: string; content_type?: string }) =>
@@ -581,6 +632,41 @@ export class KnowledgeCoreClient extends HttpBase {
581
632
  visibility: a.visibility, folder_id: a.folder_id, custom_metadata: a.custom_metadata,
582
633
  });
583
634
  },
635
+ /** Ingest MANY files in ONE go — any size, no 429, no client backoff. Mints all signed URLs in
636
+ * one request, PUTs the bytes straight to GCS (bounded concurrency; never touches the KC), then
637
+ * registers the whole batch in one request (N pending docs + N Cloud Tasks). `opts`
638
+ * (folder_id / visibility / custom_metadata — the latter carries `owner`) applies to ALL files.
639
+ * Resolves with the created `pending` documents. */
640
+ uploadMany: async (
641
+ id: UUID,
642
+ files: Array<{ file: FileData; filename: string; content_type?: string }>,
643
+ opts?: { folder_id?: UUID; visibility?: Visibility; custom_metadata?: Record<string, unknown>; concurrency?: number },
644
+ ): Promise<Document[]> => {
645
+ if (files.length === 0) return [];
646
+ const urls = await this.request<BatchUploadUrls>(
647
+ "POST", `/v1/corpora/${id}/documents/upload-urls`,
648
+ { json: { items: files.map((f) => ({ filename: f.filename, content_type: f.content_type })) } },
649
+ );
650
+ const items = urls.items; // same order as the request
651
+ await poolMap(files.length, opts?.concurrency ?? 6, async (i) => {
652
+ const f = files[i]!;
653
+ const it = items[i]!;
654
+ const put = await this._fetch(it.upload_url, {
655
+ method: "PUT",
656
+ body: toBlob(f.file, f.content_type),
657
+ headers: f.content_type ? { "content-type": f.content_type } : {},
658
+ });
659
+ if (!put.ok) throw new KnowledgeCoreError(put.status, safeJson(await put.text()), it.upload_url);
660
+ });
661
+ const res = await this.request<{ documents: Document[] }>(
662
+ "POST", `/v1/corpora/${id}/documents/batch`,
663
+ { json: {
664
+ folder_id: opts?.folder_id, visibility: opts?.visibility, custom_metadata: opts?.custom_metadata,
665
+ items: items.map((it) => ({ document_id: it.document_id, filename: it.filename })),
666
+ } },
667
+ );
668
+ return res.documents;
669
+ },
584
670
  };
585
671
 
586
672
  // --- parsing (utility: file -> text, stores nothing) ---
@@ -607,6 +693,9 @@ export class KnowledgeCoreClient extends HttpBase {
607
693
  /** Documents in the folder whose filename contains `q` (case-insensitive), paginated. */
608
694
  searchDocuments: (folderId: UUID, q: string, opts?: { limit?: number; cursor?: string }) =>
609
695
  this.request<Page<Document>>("GET", `/v1/folders/${folderId}/documents/search`, { query: { q, ...opts } }),
696
+ /** Live ingestion-status stream for ONE folder (same contract as documents.events). */
697
+ events: (folderId: UUID, handlers: DocumentEventHandlers, signal?: AbortSignal) =>
698
+ this._streamDocEvents(`/v1/folders/${folderId}/documents/events`, handlers, signal),
610
699
  };
611
700
 
612
701
  // --- documents ---
@@ -620,6 +709,11 @@ export class KnowledgeCoreClient extends HttpBase {
620
709
  contentUrl: (id: UUID, disposition: "inline" | "attachment" = "inline") =>
621
710
  this.request<ContentUrl>("GET", `/v1/documents/${id}/content-url`, { query: { disposition } }),
622
711
  delete: (id: UUID) => this.request<void>("DELETE", `/v1/documents/${id}`),
712
+ /** Live ingestion-status stream for a corpus (SSE PUSH — replaces polling count). Snapshot on
713
+ * connect, then a delta per transition, then `complete` when nothing is in-flight. Abort via
714
+ * the signal. 501 (onError) if the environment has no doc-status bus — fall back to polling. */
715
+ events: (corpusId: UUID, handlers: DocumentEventHandlers, signal?: AbortSignal) =>
716
+ this._streamDocEvents(`/v1/corpora/${corpusId}/documents/events`, handlers, signal),
623
717
  };
624
718
 
625
719
  // --- conversations ---
@@ -710,6 +804,15 @@ export class AdminClient extends HttpBase {
710
804
  // ---------------------------------------------------------------------------
711
805
  // helpers
712
806
  // ---------------------------------------------------------------------------
807
+ /** Run fn(0..n-1) with bounded concurrency (used by uploadMany for the direct-to-GCS PUTs). */
808
+ async function poolMap(n: number, concurrency: number, fn: (i: number) => Promise<void>): Promise<void> {
809
+ let next = 0;
810
+ const workers = Array.from({ length: Math.max(1, Math.min(concurrency, n)) }, async () => {
811
+ for (let i = next++; i < n; i = next++) await fn(i);
812
+ });
813
+ await Promise.all(workers);
814
+ }
815
+
713
816
  function dispatchSse(frame: string, h: StreamHandlers): void {
714
817
  let event = "message";
715
818
  const dataLines: string[] = [];
@@ -733,3 +836,19 @@ function dispatchSse(frame: string, h: StreamHandlers): void {
733
836
  case "done": h.onDone?.(); break;
734
837
  }
735
838
  }
839
+
840
+ function dispatchDocEvent(frame: string, h: DocumentEventHandlers): void {
841
+ let event = "message";
842
+ const dataLines: string[] = [];
843
+ for (const line of frame.split("\n")) {
844
+ if (line.startsWith("event:")) event = line.slice(6).trim();
845
+ else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
846
+ }
847
+ if (dataLines.length === 0) return; // heartbeat / comment frame
848
+ const data = safeJson(dataLines.join("\n"));
849
+ switch (event) {
850
+ case "snapshot": h.onSnapshot?.(data as DocumentEventsSnapshot); break;
851
+ case "document": h.onDocument?.(data as DocumentStatusEvent); break;
852
+ case "complete": h.onComplete?.(data as DocumentCount); break;
853
+ }
854
+ }