@tiangong-ai/cli 0.0.6 → 0.0.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/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { createReadStream, openAsBlob, readFileSync } from "node:fs";
3
- import { appendFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
3
+ import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
4
4
  import { homedir, platform } from "node:os";
5
5
  import { basename, dirname, extname, join, posix as pathPosix, relative, resolve, sep, } from "node:path";
6
6
  import { setTimeout as sleep } from "node:timers/promises";
@@ -9,19 +9,18 @@ import { deflateRawSync, inflateRawSync } from "node:zlib";
9
9
  import sharp from "sharp";
10
10
  export const DEFAULT_API_BASE_URL = "https://thuenv.tiangong.world:7300";
11
11
  export const DEFAULT_API_PATH_PREFIX = "/api/v1/kb";
12
- const DEFAULT_CONCURRENCY = 3;
13
12
  const DEFAULT_RETRIES = 3;
14
- const DEFAULT_MANIFEST = ".tiangong-kb-ingest-manifest.jsonl";
15
13
  const DEFAULT_BULK_WINDOW_SIZE = 100;
16
14
  const DEFAULT_BULK_TOP_UP_MAX = 50;
17
15
  const DEFAULT_BULK_UPLOAD_CONCURRENCY = 4;
18
16
  const DEFAULT_BULK_POLL_INTERVAL_SECONDS = 30;
17
+ const DEFAULT_BULK_MAX_POLLS = 120;
19
18
  const DEFAULT_BULK_MAX_UPLOAD_BYTES = 200 * 1024 * 1024;
20
19
  const DEFAULT_BULK_DERIVED_DIR = ".tiangong-kb-ingest-derived";
21
20
  const DOCX_TARGET_IMAGE_DPI = 300;
21
+ const DOCX_NORMALIZE_MIN_BYTES = 10 * 1024 * 1024;
22
22
  const EMUS_PER_INCH = 914400;
23
23
  const RETRYABLE_HTTP_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
24
- const TERMINAL_STATUSES = new Set(["completed", "failed", "deleted"]);
25
24
  const BULK_FAILED_STATUSES = new Set(["failed", "deleted", "dead", "timeout"]);
26
25
  const BULK_IN_FLIGHT_STATUSES = new Set(["uploaded", "waiting_for_index_flags", "uploading"]);
27
26
  const BULK_SUPPORTED_EXTENSIONS = new Set([
@@ -40,8 +39,6 @@ const BULK_SUPPORTED_EXTENSIONS = new Set([
40
39
  ".xls",
41
40
  ".xlsx",
42
41
  ]);
43
- const DEFAULT_POLL_INTERVAL_SECONDS = 2;
44
- const DEFAULT_WAIT_TIMEOUT_SECONDS = 300;
45
42
  class CliError extends Error {
46
43
  exitCode;
47
44
  constructor(message, exitCode = 1) {
@@ -249,6 +246,7 @@ async function kbIngestStatus(argv, io) {
249
246
  `status=${job.status}`,
250
247
  `state=${job.statePath}`,
251
248
  `root=${job.rootPath}`,
249
+ `pressure=${job.pipelineHealth?.pressure ?? "unknown"} action=${job.pipelineHealth?.recommendedAction ?? "continue"}`,
252
250
  `files=${summary.total} pending=${summary.pending} inflight=${summary.inflight} completed=${summary.completed} failed=${summary.failed}`,
253
251
  "",
254
252
  ].join("\n"));
@@ -258,64 +256,10 @@ async function kbIngestStatus(argv, io) {
258
256
  }
259
257
  async function kbIngest(argv, io) {
260
258
  const args = parseArgs(argv);
261
- const config = resolveConfig(args, io.env);
262
- const targetPath = args.positionals[0];
263
- if (!targetPath)
264
- throw new CliError("Usage: tiangong-ai kb ingest <file-or-folder>");
265
- const selector = resolveCollectionSelector(args, io.env);
266
- const selectorFields = await resolveSelectorFields(config, selector);
267
- const recursive = getBoolean(args, "recursive");
268
- const force = getBoolean(args, "force");
269
- const waitForTerminal = getBoolean(args, "wait");
270
- const concurrency = positiveIntegerValue(getString(args, "concurrency") ?? firstEnv(io.env, "TIANGONG_KB_UPLOAD_CONCURRENCY"), DEFAULT_CONCURRENCY, "--concurrency");
271
- const retries = nonNegativeIntegerValue(getString(args, "retries") ?? firstEnv(io.env, "TIANGONG_KB_UPLOAD_RETRIES"), DEFAULT_RETRIES, "--retries");
272
- const manifestPath = resolve(getString(args, "manifest") ??
273
- firstEnv(io.env, "TIANGONG_KB_MANIFEST_PATH") ??
274
- DEFAULT_MANIFEST);
275
- const metadata = await loadMetadata(args);
276
- const files = await collectFiles(resolve(targetPath), recursive);
277
- const manifest = await loadManifest(manifestPath);
278
- const plans = await Promise.all(files.map((file) => fingerprintFile(file)));
279
- const uploadPlans = plans.filter((plan) => force || manifest.get(plan.manifestKey)?.status !== "succeeded");
280
- await mkdir(dirname(manifestPath), { recursive: true });
281
- if (uploadPlans.length === 0) {
282
- const summary = { total: plans.length, uploaded: 0, skipped: plans.length, failed: 0 };
283
- writeJsonOrText(io.stdout, args, summary, () => `No files to upload; ${plans.length} already succeeded.\n`);
284
- return 0;
285
- }
286
- const results = await runPool(uploadPlans, concurrency, async (plan) => {
287
- const result = await uploadWithRetries({
288
- args,
289
- config,
290
- selectorFields,
291
- plan,
292
- metadata,
293
- retries,
294
- waitForTerminal,
295
- env: io.env,
296
- });
297
- await appendManifest(manifestPath, result);
298
- if (!getBoolean(args, "json")) {
299
- write(io.stdout, formatUploadLine(result));
300
- }
301
- return result;
302
- });
303
- const failed = results.filter((item) => item.status === "failed").length;
304
- const summary = {
305
- total: plans.length,
306
- uploaded: results.filter((item) => item.status === "succeeded").length,
307
- skipped: plans.length - uploadPlans.length,
308
- failed,
309
- manifest: manifestPath,
310
- results,
311
- };
312
- if (getBoolean(args, "json")) {
313
- write(io.stdout, `${JSON.stringify(summary, null, 2)}\n`);
314
- }
315
- else {
316
- write(io.stdout, `Summary: uploaded=${summary.uploaded} skipped=${summary.skipped} failed=${failed}\n`);
259
+ if (args.flags.has("manifest")) {
260
+ throw new CliError("The --manifest option was removed. Use kb ingest bulk --state for checkpoints.");
317
261
  }
318
- return failed > 0 ? 1 : 0;
262
+ return await kbIngestBulkRun(argv, io);
319
263
  }
320
264
  async function kbIngestBulk(argv, io) {
321
265
  const [verb, ...rest] = argv;
@@ -390,7 +334,7 @@ async function kbIngestBulkRun(argv, io) {
390
334
  const args = parseArgs(argv);
391
335
  const rootPath = args.positionals[0];
392
336
  if (!rootPath)
393
- throw new CliError("Usage: tiangong-ai kb ingest bulk <folder>");
337
+ throw new CliError("Usage: tiangong-ai kb ingest bulk <file-or-folder>");
394
338
  const config = resolveConfig(args, io.env);
395
339
  const root = resolve(rootPath);
396
340
  const selector = resolveCollectionSelector(args, io.env);
@@ -400,7 +344,8 @@ async function kbIngestBulkRun(argv, io) {
400
344
  const metadataMap = await loadMetadataMap(args);
401
345
  const files = await collectBulkFilePlans(root, true, args);
402
346
  const schemaSnapshot = await loadSchemaSnapshot(args, io.env, config, selector);
403
- const preflightPlans = await prepareBulkPreflightPlans(files, preflightOptionsFromArgs(args, schemaSnapshot, root, true));
347
+ const preflightOptions = preflightOptionsFromArgs(args, schemaSnapshot, root, false);
348
+ const preflightPlans = await prepareBulkPreflightPlans(files, preflightOptions);
404
349
  const dryRunSummary = metadataDryRun(preflightPlans.allPlans, metadataMap, schemaSnapshot);
405
350
  dryRunSummary.preflight = buildPreflightSummary(preflightPlans.allPlans, preflightPlans.maxUploadBytes);
406
351
  await initializeBulkJob({
@@ -421,6 +366,7 @@ async function kbIngestBulkRun(argv, io) {
421
366
  metadataMap,
422
367
  env: io.env,
423
368
  stdout: io.stdout,
369
+ preflightOptions,
424
370
  });
425
371
  writeJsonOrText(io.stdout, args, result, () => formatBulkRunSummary(result));
426
372
  return result.failed > 0 || result.blocked > 0 ? 1 : 0;
@@ -449,7 +395,7 @@ async function kbIngestJobs(argv, io) {
449
395
  }
450
396
  }))).filter((job) => Boolean(job));
451
397
  writeJsonOrText(io.stdout, args, { jobs }, () => jobs
452
- .map((job) => `${job.jobId}\t${job.status}\t${job.summary.completed}/${job.summary.total}\t${job.statePath}`)
398
+ .map((job) => `${job.jobId}\t${job.status}\t${job.summary.completed}/${job.summary.total}\tpressure=${job.pipelineHealth?.pressure ?? "unknown"}\taction=${job.pipelineHealth?.recommendedAction ?? "continue"}\t${job.statePath}`)
453
399
  .join("\n")
454
400
  .concat(jobs.length ? "\n" : ""));
455
401
  return 0;
@@ -465,6 +411,7 @@ async function kbIngestResume(argv, io) {
465
411
  const job = await readBulkJob(statePath);
466
412
  const config = resolveConfig(args, io.env);
467
413
  const selectorFields = await resolveSelectorFields(config, job.collectionSelector);
414
+ const preflightOptions = preflightOptionsFromArgs(args, job.schemaSnapshot, job.rootPath, false);
468
415
  const result = await runBulkLoop({
469
416
  args,
470
417
  config,
@@ -473,6 +420,7 @@ async function kbIngestResume(argv, io) {
473
420
  metadataMap: job.metadataMap,
474
421
  env: io.env,
475
422
  stdout: io.stdout,
423
+ preflightOptions,
476
424
  });
477
425
  writeJsonOrText(io.stdout, args, result, () => formatBulkRunSummary(result));
478
426
  return result.failed > 0 || result.blocked > 0 ? 1 : 0;
@@ -487,6 +435,7 @@ async function kbIngestExport(argv, io) {
487
435
  throw new CliError(`Bulk job not found: ${jobId}`);
488
436
  const format = getString(args, "format") ?? "jsonl";
489
437
  const rows = await readBulkFiles(statePath);
438
+ const job = await readBulkJob(statePath);
490
439
  if (format === "csv") {
491
440
  write(io.stdout, [
492
441
  csvLine([
@@ -534,7 +483,7 @@ async function kbIngestExport(argv, io) {
534
483
  throw new CliError("--format must be jsonl, json, or csv.");
535
484
  }
536
485
  if (format === "json") {
537
- write(io.stdout, `${JSON.stringify({ files: rows }, null, 2)}\n`);
486
+ write(io.stdout, `${JSON.stringify({ job, files: rows }, null, 2)}\n`);
538
487
  }
539
488
  else {
540
489
  write(io.stdout, rows
@@ -547,37 +496,51 @@ async function kbIngestExport(argv, io) {
547
496
  async function runBulkLoop(input) {
548
497
  const windowSize = getPositiveInteger(input.args, "window-size", DEFAULT_BULK_WINDOW_SIZE);
549
498
  const topUpMax = getPositiveInteger(input.args, "top-up-max", DEFAULT_BULK_TOP_UP_MAX);
550
- const uploadConcurrency = getPositiveInteger(input.args, "upload-concurrency", getPositiveInteger(input.args, "concurrency", DEFAULT_BULK_UPLOAD_CONCURRENCY));
551
- const pollInterval = getPositiveNumber(input.args, "poll-interval", DEFAULT_BULK_POLL_INTERVAL_SECONDS);
552
- const retries = getNonNegativeInteger(input.args, "retries", DEFAULT_RETRIES);
553
- const maxPolls = getNonNegativeInteger(input.args, "max-polls", 0);
499
+ const uploadConcurrency = positiveIntegerValue(getString(input.args, "upload-concurrency") ??
500
+ getString(input.args, "concurrency") ??
501
+ firstEnv(input.env, "TIANGONG_KB_UPLOAD_CONCURRENCY"), DEFAULT_BULK_UPLOAD_CONCURRENCY, "--upload-concurrency");
502
+ const pollInterval = positiveNumberValue(getString(input.args, "poll-interval") ?? firstEnv(input.env, "TIANGONG_KB_BULK_POLL_INTERVAL"), DEFAULT_BULK_POLL_INTERVAL_SECONDS, "--poll-interval");
503
+ const retries = nonNegativeIntegerValue(getString(input.args, "retries") ?? firstEnv(input.env, "TIANGONG_KB_UPLOAD_RETRIES"), DEFAULT_RETRIES, "--retries");
504
+ const maxPolls = getNonNegativeInteger(input.args, "max-polls", nonNegativeIntegerValue(firstEnv(input.env, "TIANGONG_KB_BULK_MAX_POLLS"), DEFAULT_BULK_MAX_POLLS, "TIANGONG_KB_BULK_MAX_POLLS"));
554
505
  let polls = 0;
555
506
  await resetInterruptedBulkUploads(input.statePath);
556
507
  await updateJobStatus(input.statePath, "running");
508
+ let lastPipelineHealth;
557
509
  while (true) {
558
510
  polls += 1;
559
511
  await pollBulkStatuses(input.statePath, input.config);
560
512
  const summary = await bulkJobSummary(input.statePath);
561
513
  const capacity = Math.max(0, windowSize - summary.inflight);
562
- const uploadLimit = Math.min(capacity, topUpMax);
514
+ lastPipelineHealth = await readBulkPipelineHealth(input.config, pollInterval);
515
+ await saveBulkPipelineHealth(input.statePath, lastPipelineHealth);
516
+ const uploadLimit = bulkUploadLimitForHealth(Math.min(capacity, topUpMax), lastPipelineHealth);
517
+ const effectiveUploadConcurrency = lastPipelineHealth.recommendedAction === "slow_down" ? 1 : uploadConcurrency;
563
518
  if (uploadLimit > 0) {
564
519
  const pending = await claimPendingBulkFiles(input.statePath, uploadLimit);
565
- await runPool(pending, uploadConcurrency, async (row) => {
566
- const result = await uploadBulkFile({
567
- args: input.args,
568
- config: input.config,
569
- selectorFields: input.selectorFields,
520
+ await runPool(pending, effectiveUploadConcurrency, async (row) => {
521
+ const materialized = await materializeBulkFileForUpload({
522
+ statePath: input.statePath,
523
+ metadataMap: input.metadataMap,
524
+ preflightOptions: input.preflightOptions,
570
525
  row,
571
- retries,
572
- env: input.env,
573
526
  });
574
- await saveBulkUploadResult(input.statePath, row.relativePath, result);
575
- return result;
527
+ return await runPool(materialized.uploadRows, 1, async (uploadRow) => {
528
+ const result = await uploadBulkFile({
529
+ args: input.args,
530
+ config: input.config,
531
+ selectorFields: input.selectorFields,
532
+ row: uploadRow,
533
+ retries,
534
+ env: input.env,
535
+ });
536
+ await saveBulkUploadResult(input.statePath, uploadRow.relativePath, result);
537
+ return result;
538
+ });
576
539
  });
577
540
  }
578
541
  const nextSummary = await bulkJobSummary(input.statePath);
579
542
  if (getBoolean(input.args, "verbose")) {
580
- write(input.stdout, `poll=${polls} pending=${nextSummary.pending} inflight=${nextSummary.inflight} completed=${nextSummary.completed} failed=${nextSummary.failed} waiting_for_index_flags=${nextSummary.waitingForIndexFlags}\n`);
543
+ write(input.stdout, `poll=${polls} pending=${nextSummary.pending} inflight=${nextSummary.inflight} completed=${nextSummary.completed} failed=${nextSummary.failed} waiting_for_index_flags=${nextSummary.waitingForIndexFlags} pressure=${lastPipelineHealth.pressure} action=${lastPipelineHealth.recommendedAction}\n`);
581
544
  }
582
545
  if (nextSummary.pending === 0 && nextSummary.inflight === 0) {
583
546
  await updateJobStatus(input.statePath, nextSummary.failed > 0 || nextSummary.blocked > 0 ? "failed" : "completed");
@@ -586,6 +549,7 @@ async function runBulkLoop(input) {
586
549
  jobId: jobIdFromStatePath(input.statePath),
587
550
  statePath: input.statePath,
588
551
  polls,
552
+ pipelineHealth: lastPipelineHealth,
589
553
  };
590
554
  }
591
555
  if (maxPolls > 0 && polls >= maxPolls) {
@@ -595,11 +559,28 @@ async function runBulkLoop(input) {
595
559
  jobId: jobIdFromStatePath(input.statePath),
596
560
  statePath: input.statePath,
597
561
  polls,
562
+ pipelineHealth: lastPipelineHealth,
598
563
  };
599
564
  }
600
- await sleep(pollInterval * 1000);
565
+ await sleep(bulkPollIntervalForHealth(pollInterval, lastPipelineHealth) * 1000);
601
566
  }
602
567
  }
568
+ function bulkUploadLimitForHealth(limit, health) {
569
+ if (limit <= 0)
570
+ return 0;
571
+ if (health.recommendedAction === "pause_top_up")
572
+ return 0;
573
+ if (health.recommendedAction === "slow_down")
574
+ return Math.max(1, Math.ceil(limit / 2));
575
+ return limit;
576
+ }
577
+ function bulkPollIntervalForHealth(baseSeconds, health) {
578
+ if (!health)
579
+ return baseSeconds;
580
+ if (health.recommendedAction === "continue")
581
+ return baseSeconds;
582
+ return Math.max(baseSeconds, health.recommendedPollAfterSeconds);
583
+ }
603
584
  async function uploadBulkFile(input) {
604
585
  const plan = {
605
586
  path: input.row.path,
@@ -607,7 +588,7 @@ async function uploadBulkFile(input) {
607
588
  size: input.row.size,
608
589
  mtimeMs: input.row.mtimeMs,
609
590
  sha256: input.row.sha256,
610
- manifestKey: `${input.row.sha256}:${input.row.size}`,
591
+ uploadKey: `${input.row.sha256}:${input.row.size}`,
611
592
  };
612
593
  return uploadWithRetries({
613
594
  args: input.args,
@@ -616,10 +597,146 @@ async function uploadBulkFile(input) {
616
597
  plan,
617
598
  metadata: input.row.metadataJson,
618
599
  retries: input.retries,
619
- waitForTerminal: false,
620
600
  env: input.env,
621
601
  });
622
602
  }
603
+ async function materializeBulkFileForUpload(input) {
604
+ if (input.row.ingestVariant === "direct_upload") {
605
+ return { uploadRows: [input.row] };
606
+ }
607
+ try {
608
+ const preflightOptions = materializeOptionsForRow(input.preflightOptions, input.row);
609
+ if (input.row.ingestVariant === "compressed_docx") {
610
+ const docxRow = await materializeDocxBulkRow(input.statePath, input.metadataMap, preflightOptions, input.row);
611
+ return { uploadRows: docxRow ? [docxRow] : [] };
612
+ }
613
+ if (input.row.ingestVariant === "page_split_pdf") {
614
+ const pdfRows = await materializePdfBulkRow(input.statePath, input.metadataMap, preflightOptions, input.row);
615
+ return { uploadRows: pdfRows };
616
+ }
617
+ await markBulkFileFailed(input.statePath, input.row.relativePath, "UNSUPPORTED_INGEST_VARIANT");
618
+ return { uploadRows: [] };
619
+ }
620
+ catch (error) {
621
+ await markBulkFileFailed(input.statePath, input.row.relativePath, error instanceof Error ? error.message : String(error));
622
+ return { uploadRows: [] };
623
+ }
624
+ }
625
+ function materializeOptionsForRow(options, row) {
626
+ const rowMaxUploadBytes = Number(row.preflight.maxUploadBytes);
627
+ return {
628
+ ...options,
629
+ maxUploadBytes: Number.isFinite(rowMaxUploadBytes) && rowMaxUploadBytes > 0
630
+ ? rowMaxUploadBytes
631
+ : options.maxUploadBytes,
632
+ };
633
+ }
634
+ async function materializeDocxBulkRow(statePath, metadataMap, options, row) {
635
+ if (row.derivedPath && row.derivedSize !== undefined && row.derivedSha256) {
636
+ const existing = await stat(row.derivedPath).catch(() => undefined);
637
+ if (existing?.isFile() && existing.size === row.derivedSize)
638
+ return row;
639
+ }
640
+ const source = bulkRecordToOriginalPlan(row);
641
+ const docx = await analyzeDocx(source.originalPath);
642
+ if (isEmptyDocxAnalysis(docx)) {
643
+ await markBulkFileSkipped(statePath, row.relativePath, "empty_docx");
644
+ return undefined;
645
+ }
646
+ const materialized = await createDocxIngestCopy(source, options, docx, row.classification);
647
+ return await updateMaterializedBulkRow(statePath, metadataMap, row.relativePath, materialized);
648
+ }
649
+ async function materializePdfBulkRow(statePath, metadataMap, options, row) {
650
+ if (row.partIndex !== undefined && row.derivedPath) {
651
+ const existing = await stat(row.derivedPath).catch(() => undefined);
652
+ if (existing?.isFile() && existing.size === row.derivedSize)
653
+ return [row];
654
+ }
655
+ const source = bulkRecordToOriginalPlan(row);
656
+ const pdf = (await analyzePdf(source.originalPath).catch((error) => ({
657
+ error: error instanceof Error ? error.message : String(error),
658
+ pageCount: 0,
659
+ imageCount: 0,
660
+ imageHeavy: false,
661
+ })));
662
+ const partPlans = await createPdfPartPlans(source, options, pdf, row.classification);
663
+ if (row.partIndex !== undefined) {
664
+ const currentPart = partPlans.find((part) => part.relativePath === row.relativePath);
665
+ if (!currentPart) {
666
+ await markBulkFileFailed(statePath, row.relativePath, "PDF_SPLIT_PART_REGENERATION_MISMATCH");
667
+ return [];
668
+ }
669
+ return [await updateMaterializedBulkRow(statePath, metadataMap, row.relativePath, currentPart)];
670
+ }
671
+ const now = new Date().toISOString();
672
+ const db = await openSqlite(statePath);
673
+ try {
674
+ createBulkSchema(db);
675
+ db.exec("BEGIN");
676
+ try {
677
+ for (const part of partPlans) {
678
+ insertBulkFilePlan(db, metadataMap, part, bulkInitialStatus(part), now);
679
+ }
680
+ db.prepare("DELETE FROM files WHERE relative_path = ?").run(row.relativePath);
681
+ touchJob(db, now);
682
+ db.exec("COMMIT");
683
+ }
684
+ catch (error) {
685
+ db.exec("ROLLBACK");
686
+ throw error;
687
+ }
688
+ }
689
+ finally {
690
+ db.close();
691
+ }
692
+ return [];
693
+ }
694
+ async function updateMaterializedBulkRow(statePath, metadataMap, relativePath, file) {
695
+ const evaluated = evaluateMetadata(metadataMap, file);
696
+ const now = new Date().toISOString();
697
+ const db = await openSqlite(statePath);
698
+ try {
699
+ createBulkSchema(db);
700
+ db.prepare(`UPDATE files
701
+ SET path = ?, size = ?, mtime_ms = ?, sha256 = ?, ext = ?, path_segments_json = ?, path_depth = ?,
702
+ metadata_json = ?, matched_rules_json = ?, classification = ?, ingest_variant = ?, source_document_key = ?,
703
+ derived_path = ?, derived_size = ?, derived_sha256 = ?, normalize_strategy = ?, generated_metadata_json = ?,
704
+ preflight_json = ?, updated_at = ?
705
+ WHERE relative_path = ?`).run(file.path, file.size, file.mtimeMs, file.sha256, file.ext, JSON.stringify(file.pathSegments), file.pathDepth, JSON.stringify(evaluated.metadata), JSON.stringify(evaluated.matchedRules), file.classification, file.ingestVariant, file.sourceDocumentKey, file.derivedPath ?? null, file.derivedSize ?? null, file.derivedSha256 ?? null, file.normalizeStrategy ?? null, JSON.stringify(file.generatedMetadata), JSON.stringify(file.preflight), now, relativePath);
706
+ touchJob(db, now);
707
+ const updated = db.prepare("SELECT * FROM files WHERE relative_path = ?").get(relativePath);
708
+ if (!updated)
709
+ throw new CliError(`Bulk row disappeared during materialization: ${relativePath}`);
710
+ return rowToBulkFile(updated);
711
+ }
712
+ finally {
713
+ db.close();
714
+ }
715
+ }
716
+ async function markBulkFileFailed(statePath, relativePath, error) {
717
+ const db = await openSqlite(statePath);
718
+ const now = new Date().toISOString();
719
+ try {
720
+ createBulkSchema(db);
721
+ db.prepare("UPDATE files SET status = 'failed', last_error = ?, updated_at = ? WHERE relative_path = ?").run(error, now, relativePath);
722
+ touchJob(db, now);
723
+ }
724
+ finally {
725
+ db.close();
726
+ }
727
+ }
728
+ async function markBulkFileSkipped(statePath, relativePath, reason) {
729
+ const db = await openSqlite(statePath);
730
+ const now = new Date().toISOString();
731
+ try {
732
+ createBulkSchema(db);
733
+ db.prepare("UPDATE files SET status = 'skipped', last_error = ?, updated_at = ? WHERE relative_path = ?").run(reason, now, relativePath);
734
+ touchJob(db, now);
735
+ }
736
+ finally {
737
+ db.close();
738
+ }
739
+ }
623
740
  async function pollBulkStatuses(statePath, config) {
624
741
  const inflight = await readInflightBulkFiles(statePath);
625
742
  const documentIds = inflight.map((row) => row.documentId).filter(Boolean);
@@ -645,6 +762,12 @@ async function pollBulkStatuses(statePath, config) {
645
762
  }
646
763
  }
647
764
  function judgeBulkStatus(item) {
765
+ if (item.itemError) {
766
+ const message = `${item.itemError.code}: ${item.itemError.message}`;
767
+ return item.itemError.retryable
768
+ ? { status: "uploaded", lastError: message }
769
+ : { status: "failed", lastError: message };
770
+ }
648
771
  const status = item.status ?? "";
649
772
  if (BULK_FAILED_STATUSES.has(status)) {
650
773
  return {
@@ -698,9 +821,7 @@ function batchStatusItems(payload) {
698
821
  : Array.isArray(data)
699
822
  ? data
700
823
  : [];
701
- return items
702
- .filter(isObject)
703
- .map((item) => statusItemFromPayload(item, stringField(item, "documentId") ?? stringField(item, "document_id") ?? ""));
824
+ return items.filter(isObject).map((item) => batchStatusItemFromPayload(item));
704
825
  }
705
826
  function statusItemFromPayload(payload, fallbackDocumentId = "") {
706
827
  const data = responseData(payload);
@@ -717,6 +838,90 @@ function statusItemFromPayload(payload, fallbackDocumentId = "") {
717
838
  raw: payload,
718
839
  };
719
840
  }
841
+ async function readBulkPipelineHealth(config, fallbackPollAfterSeconds) {
842
+ try {
843
+ const payload = await jsonRequest(config, "pipeline/health");
844
+ return pipelineHealthFromPayload(payload, fallbackPollAfterSeconds);
845
+ }
846
+ catch (error) {
847
+ if (error instanceof HttpError && error.status && [404, 405, 501].includes(error.status)) {
848
+ return {
849
+ healthy: true,
850
+ pressure: "unknown",
851
+ recommendedAction: "continue",
852
+ recommendedPollAfterSeconds: fallbackPollAfterSeconds,
853
+ message: "Pipeline health endpoint is unavailable; continuing without backpressure.",
854
+ };
855
+ }
856
+ if (error instanceof HttpError) {
857
+ return {
858
+ healthy: false,
859
+ pressure: "paused",
860
+ recommendedAction: "pause_top_up",
861
+ recommendedPollAfterSeconds: error.retryAfterSeconds ?? Math.max(fallbackPollAfterSeconds, 30),
862
+ message: error.message,
863
+ };
864
+ }
865
+ return {
866
+ healthy: false,
867
+ pressure: "paused",
868
+ recommendedAction: "pause_top_up",
869
+ recommendedPollAfterSeconds: Math.max(fallbackPollAfterSeconds, 30),
870
+ message: error instanceof Error ? error.message : String(error),
871
+ };
872
+ }
873
+ }
874
+ function pipelineHealthFromPayload(payload, fallbackPollAfterSeconds) {
875
+ const data = responseData(payload);
876
+ if (!isObject(data)) {
877
+ throw new CliError("Pipeline health response did not contain an object payload.");
878
+ }
879
+ const action = stringField(data, "recommendedAction");
880
+ if (action !== "continue" && action !== "slow_down" && action !== "pause_top_up") {
881
+ throw new CliError("Pipeline health response did not contain a valid recommendedAction.");
882
+ }
883
+ const pressure = stringField(data, "pressure");
884
+ const pollAfter = Number(data.recommendedPollAfterSeconds);
885
+ return {
886
+ healthy: typeof data.healthy === "boolean" ? data.healthy : action === "continue",
887
+ pressure: pressure === "ok" || pressure === "degraded" || pressure === "paused" ? pressure : "unknown",
888
+ recommendedAction: action,
889
+ recommendedPollAfterSeconds: Number.isFinite(pollAfter) && pollAfter > 0 ? pollAfter : fallbackPollAfterSeconds,
890
+ checkedAt: stringField(data, "checkedAt"),
891
+ message: stringField(data, "message") ??
892
+ (isObject(data.indexPreflight) ? stringField(data.indexPreflight, "message") : undefined),
893
+ };
894
+ }
895
+ async function saveBulkPipelineHealth(statePath, health) {
896
+ const db = await openSqlite(statePath);
897
+ const now = new Date().toISOString();
898
+ try {
899
+ createBulkSchema(db);
900
+ db.prepare("UPDATE jobs SET pipeline_health_json = ?, updated_at = ?").run(JSON.stringify(health), now);
901
+ }
902
+ finally {
903
+ db.close();
904
+ }
905
+ }
906
+ function batchStatusItemFromPayload(item) {
907
+ const documentId = stringField(item, "documentId") ?? stringField(item, "document_id") ?? "";
908
+ if (item.ok === true && isObject(item.status)) {
909
+ return statusItemFromPayload(item.status, documentId);
910
+ }
911
+ if (item.ok === false && isObject(item.error)) {
912
+ const error = item.error;
913
+ return {
914
+ documentId,
915
+ itemError: {
916
+ code: stringField(error, "code") ?? "STATUS_ITEM_ERROR",
917
+ message: stringField(error, "message") ?? "Document status lookup failed.",
918
+ retryable: typeof error.retryable === "boolean" ? error.retryable : false,
919
+ },
920
+ raw: item,
921
+ };
922
+ }
923
+ return statusItemFromPayload(item, documentId);
924
+ }
720
925
  async function initializeBulkJob(input) {
721
926
  await mkdir(dirname(input.statePath), { recursive: true });
722
927
  const db = await openSqlite(input.statePath);
@@ -728,15 +933,9 @@ async function initializeBulkJob(input) {
728
933
  db.prepare(`INSERT INTO jobs (job_id, root_path, collection_selector_json, schema_snapshot_json, metadata_map_json, dry_run_summary_json, status, created_at, updated_at)
729
934
  VALUES (?, ?, ?, ?, ?, ?, 'created', ?, ?)`).run(input.jobId, input.rootPath, JSON.stringify(input.selector), JSON.stringify(input.schemaSnapshot ?? null), JSON.stringify(input.metadataMap), JSON.stringify(input.dryRunSummary), now, now);
730
935
  }
731
- const insert = db.prepare(`INSERT OR IGNORE INTO files
732
- (path, relative_path, size, mtime_ms, sha256, ext, path_segments_json, path_depth, metadata_json, matched_rules_json, status, attempts, created_at, updated_at,
733
- original_path, original_relative_path, original_size, original_mtime_ms, original_sha256, original_ext, classification, ingest_variant, source_document_key,
734
- derived_path, derived_size, derived_sha256, normalize_strategy, part_index, part_count, page_start, page_end, generated_metadata_json, preflight_json)
735
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
736
936
  for (const file of input.files) {
737
- const evaluated = evaluateMetadata(input.metadataMap, file);
738
937
  const status = bulkInitialStatus(file);
739
- insert.run(file.path, file.relativePath, file.size, file.mtimeMs, file.sha256, file.ext, JSON.stringify(file.pathSegments), file.pathDepth, JSON.stringify(evaluated.metadata), JSON.stringify(evaluated.matchedRules), status, now, now, file.originalPath, file.originalRelativePath, file.originalSize, file.originalMtimeMs, file.originalSha256, file.originalExt, file.classification, file.ingestVariant, file.sourceDocumentKey, file.derivedPath ?? null, file.derivedSize ?? null, file.derivedSha256 ?? null, file.normalizeStrategy ?? null, file.partIndex ?? null, file.partCount ?? null, file.pageStart ?? null, file.pageEnd ?? null, JSON.stringify(file.generatedMetadata), JSON.stringify(file.preflight));
938
+ insertBulkFilePlan(db, input.metadataMap, file, status, now);
740
939
  }
741
940
  touchJob(db, now);
742
941
  }
@@ -751,6 +950,14 @@ async function openSqlite(path) {
751
950
  db.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;");
752
951
  return db;
753
952
  }
953
+ function insertBulkFilePlan(db, metadataMap, file, status, now) {
954
+ const evaluated = evaluateMetadata(metadataMap, file);
955
+ db.prepare(`INSERT OR IGNORE INTO files
956
+ (path, relative_path, size, mtime_ms, sha256, ext, path_segments_json, path_depth, metadata_json, matched_rules_json, status, attempts, created_at, updated_at,
957
+ original_path, original_relative_path, original_size, original_mtime_ms, original_sha256, original_ext, classification, ingest_variant, source_document_key,
958
+ derived_path, derived_size, derived_sha256, normalize_strategy, part_index, part_count, page_start, page_end, generated_metadata_json, preflight_json)
959
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(file.path, file.relativePath, file.size, file.mtimeMs, file.sha256, file.ext, JSON.stringify(file.pathSegments), file.pathDepth, JSON.stringify(evaluated.metadata), JSON.stringify(evaluated.matchedRules), status, now, now, file.originalPath, file.originalRelativePath, file.originalSize, file.originalMtimeMs, file.originalSha256, file.originalExt, file.classification, file.ingestVariant, file.sourceDocumentKey, file.derivedPath ?? null, file.derivedSize ?? null, file.derivedSha256 ?? null, file.normalizeStrategy ?? null, file.partIndex ?? null, file.partCount ?? null, file.pageStart ?? null, file.pageEnd ?? null, JSON.stringify(file.generatedMetadata), JSON.stringify(file.preflight));
960
+ }
754
961
  function createBulkSchema(db) {
755
962
  db.exec(`
756
963
  CREATE TABLE IF NOT EXISTS jobs (
@@ -760,6 +967,7 @@ function createBulkSchema(db) {
760
967
  schema_snapshot_json TEXT,
761
968
  metadata_map_json TEXT NOT NULL,
762
969
  dry_run_summary_json TEXT,
970
+ pipeline_health_json TEXT,
763
971
  status TEXT NOT NULL,
764
972
  created_at TEXT NOT NULL,
765
973
  updated_at TEXT NOT NULL
@@ -804,8 +1012,16 @@ function createBulkSchema(db) {
804
1012
  CREATE INDEX IF NOT EXISTS idx_files_status ON files(status);
805
1013
  CREATE INDEX IF NOT EXISTS idx_files_document_id ON files(document_id);
806
1014
  `);
1015
+ ensureBulkJobColumns(db);
807
1016
  ensureBulkFileColumns(db);
808
1017
  }
1018
+ function ensureBulkJobColumns(db) {
1019
+ const rows = db.prepare("PRAGMA table_info(jobs)").all();
1020
+ const columns = new Set(rows.map((row) => String(row.name)));
1021
+ if (!columns.has("pipeline_health_json")) {
1022
+ db.exec("ALTER TABLE jobs ADD COLUMN pipeline_health_json TEXT;");
1023
+ }
1024
+ }
809
1025
  function ensureBulkFileColumns(db) {
810
1026
  const rows = db.prepare("PRAGMA table_info(files)").all();
811
1027
  const columns = new Set(rows.map((row) => String(row.name)));
@@ -855,6 +1071,9 @@ async function readBulkJob(statePath) {
855
1071
  dryRunSummary: row.dry_run_summary_json
856
1072
  ? JSON.parse(String(row.dry_run_summary_json))
857
1073
  : undefined,
1074
+ pipelineHealth: row.pipeline_health_json
1075
+ ? JSON.parse(String(row.pipeline_health_json))
1076
+ : undefined,
858
1077
  createdAt: String(row.created_at),
859
1078
  updatedAt: String(row.updated_at),
860
1079
  };
@@ -944,7 +1163,8 @@ async function resetInterruptedBulkUploads(statePath) {
944
1163
  const db = await openSqlite(statePath);
945
1164
  const now = new Date().toISOString();
946
1165
  try {
947
- db.prepare("UPDATE files SET status = 'pending', last_error = ?, updated_at = ? WHERE status = 'uploading' AND document_id IS NULL").run("Upload was interrupted before a document id was recorded.", now);
1166
+ createBulkSchema(db);
1167
+ db.prepare("UPDATE files SET status = 'pending', last_error = ?, updated_at = ? WHERE status IN ('uploading', 'planned') AND document_id IS NULL").run("Upload was interrupted before a document id was recorded.", now);
948
1168
  touchJob(db, now);
949
1169
  }
950
1170
  finally {
@@ -1014,6 +1234,32 @@ function rowToBulkFile(row) {
1014
1234
  : {},
1015
1235
  };
1016
1236
  }
1237
+ function bulkRecordToOriginalPlan(row) {
1238
+ const pathSegments = row.originalRelativePath.split("/").filter(Boolean);
1239
+ return {
1240
+ path: row.originalPath,
1241
+ filename: basename(row.originalPath),
1242
+ size: row.originalSize,
1243
+ mtimeMs: row.originalMtimeMs,
1244
+ sha256: row.originalSha256,
1245
+ uploadKey: row.originalRelativePath,
1246
+ relativePath: row.originalRelativePath,
1247
+ ext: row.originalExt,
1248
+ pathSegments,
1249
+ pathDepth: pathSegments.length,
1250
+ originalPath: row.originalPath,
1251
+ originalRelativePath: row.originalRelativePath,
1252
+ originalSize: row.originalSize,
1253
+ originalMtimeMs: row.originalMtimeMs,
1254
+ originalSha256: row.originalSha256,
1255
+ originalExt: row.originalExt,
1256
+ classification: row.classification,
1257
+ ingestVariant: row.ingestVariant,
1258
+ sourceDocumentKey: row.sourceDocumentKey,
1259
+ generatedMetadata: row.generatedMetadata,
1260
+ preflight: row.preflight,
1261
+ };
1262
+ }
1017
1263
  async function resolveBulkStatePath(args, jobIdInput) {
1018
1264
  const explicit = getString(args, "state");
1019
1265
  if (explicit)
@@ -1048,10 +1294,16 @@ function jobIdFromStatePath(statePath) {
1048
1294
  return basename(statePath).replace(/\.sqlite$/i, "");
1049
1295
  }
1050
1296
  async function collectBulkFilePlans(root, recursive, args) {
1051
- const files = await collectFiles(root, recursive, {
1052
- excludeDirectories: bulkScanExcludeDirs(root, args),
1053
- });
1054
- return Promise.all(files.map((file) => fingerprintBulkFile(root, file)));
1297
+ const item = await stat(root).catch(() => undefined);
1298
+ if (!item)
1299
+ throw new CliError(`Path not found: ${root}`);
1300
+ const relativeRoot = item.isFile() ? dirname(root) : root;
1301
+ const files = item.isFile()
1302
+ ? [root]
1303
+ : await collectFiles(root, recursive, {
1304
+ excludeDirectories: bulkScanExcludeDirs(root, args),
1305
+ });
1306
+ return Promise.all(files.map((file) => fingerprintBulkFile(relativeRoot, file)));
1055
1307
  }
1056
1308
  function bulkScanExcludeDirs(root, args) {
1057
1309
  const excludeDirectories = new Set([resolve(root, DEFAULT_BULK_DERIVED_DIR)]);
@@ -1121,12 +1373,31 @@ async function preflightOneBulkFile(file, options) {
1121
1373
  const docx = await analyzeDocx(file.path).catch((error) => ({
1122
1374
  error: error instanceof Error ? error.message : String(error),
1123
1375
  }));
1376
+ if (isEmptyDocxAnalysis(docx)) {
1377
+ return [
1378
+ decorateBulkPlan(file, "empty", "skipped", {
1379
+ ...docx,
1380
+ reason: "empty_docx",
1381
+ maxUploadBytes: options.maxUploadBytes,
1382
+ }),
1383
+ ];
1384
+ }
1385
+ if (file.size <= DOCX_NORMALIZE_MIN_BYTES) {
1386
+ return [
1387
+ decorateBulkPlan(file, "direct_upload", "direct_upload", {
1388
+ ...docx,
1389
+ maxUploadBytes: options.maxUploadBytes,
1390
+ normalizeThresholdBytes: DOCX_NORMALIZE_MIN_BYTES,
1391
+ }),
1392
+ ];
1393
+ }
1124
1394
  const classification = file.size > options.maxUploadBytes ? "oversize_docx_image_heavy" : "direct_upload";
1125
1395
  if (!options.generateDerived) {
1126
1396
  return [
1127
1397
  decorateBulkPlan(file, classification, "compressed_docx", {
1128
1398
  ...docx,
1129
1399
  maxUploadBytes: options.maxUploadBytes,
1400
+ normalizeThresholdBytes: DOCX_NORMALIZE_MIN_BYTES,
1130
1401
  normalizeStrategy: "docx_image_300dpi_normalize",
1131
1402
  }),
1132
1403
  ];
@@ -1254,15 +1525,16 @@ function bulkInitialStatus(file) {
1254
1525
  return "skipped";
1255
1526
  if (file.ingestVariant === "skipped")
1256
1527
  return "blocked";
1257
- if ((file.ingestVariant === "compressed_docx" || file.ingestVariant === "page_split_pdf") &&
1258
- file.derivedSize === undefined) {
1259
- return "planned";
1260
- }
1261
- if (file.ingestVariant === "compressed_docx")
1528
+ if (file.ingestVariant === "compressed_docx" && file.derivedSize === undefined)
1262
1529
  return "pending";
1263
- if (file.size > 0 && file.size <= Number(file.preflight.maxUploadBytes ?? Infinity))
1530
+ if (file.ingestVariant === "page_split_pdf" && file.derivedSize === undefined)
1264
1531
  return "pending";
1265
- if (file.classification === "direct_upload")
1532
+ if (file.ingestVariant === "compressed_docx") {
1533
+ if (file.derivedSize !== undefined && file.derivedSize <= Number(file.preflight.maxUploadBytes))
1534
+ return "pending";
1535
+ return "blocked";
1536
+ }
1537
+ if (file.size > 0 && file.size <= Number(file.preflight.maxUploadBytes ?? Infinity))
1266
1538
  return "pending";
1267
1539
  if (file.derivedSize !== undefined && file.derivedSize <= Number(file.preflight.maxUploadBytes))
1268
1540
  return "pending";
@@ -1378,13 +1650,61 @@ async function analyzeDocx(path) {
1378
1650
  const entries = readZipEntries(await readFile(path));
1379
1651
  const media = entries.filter((entry) => entry.name.startsWith("word/media/") && !entry.name.endsWith("/"));
1380
1652
  const mediaSizes = media.map((entry) => entry.uncompressedSize);
1653
+ const documentXml = zipEntryText(entries, "word/document.xml") ?? "";
1654
+ const bodyText = extractDocxBodyText(documentXml);
1655
+ const appXml = zipEntryText(entries, "docProps/app.xml") ?? "";
1381
1656
  return {
1382
1657
  mediaCount: media.length,
1383
1658
  mediaTotalBytes: mediaSizes.reduce((sum, size) => sum + size, 0),
1384
1659
  mediaMaxBytes: mediaSizes.reduce((max, size) => Math.max(max, size), 0),
1660
+ textCharacterCount: bodyText.length,
1661
+ paragraphCount: countRegex(documentXml, /<w:p(?:\s|>)/g),
1662
+ drawingCount: countRegex(documentXml, /<w:drawing(?:\s|>)/g),
1663
+ pictCount: countRegex(documentXml, /<w:pict(?:\s|>)/g),
1664
+ appWords: xmlElementNumber(appXml, "Words"),
1665
+ appCharacters: xmlElementNumber(appXml, "Characters"),
1666
+ appParagraphs: xmlElementNumber(appXml, "Paragraphs"),
1385
1667
  zipEntryCount: entries.length,
1386
1668
  };
1387
1669
  }
1670
+ function isEmptyDocxAnalysis(docx) {
1671
+ if (docx.error)
1672
+ return false;
1673
+ const mediaCount = Number(docx.mediaCount ?? 0);
1674
+ const textCharacterCount = Number(docx.textCharacterCount ?? 0);
1675
+ const appWords = Number(docx.appWords ?? 0);
1676
+ return mediaCount === 0 && textCharacterCount === 0 && appWords === 0;
1677
+ }
1678
+ function zipEntryText(entries, name) {
1679
+ const entry = entries.find((candidate) => candidate.name === name);
1680
+ return entry?.data.toString("utf8");
1681
+ }
1682
+ function extractDocxBodyText(xml) {
1683
+ const text = [...xml.matchAll(/<w:t\b[^>]*>([\s\S]*?)<\/w:t>/g)]
1684
+ .map((match) => decodeXmlText(match[1] ?? ""))
1685
+ .join("");
1686
+ return text.trim();
1687
+ }
1688
+ function countRegex(input, regex) {
1689
+ return (input.match(regex) ?? []).length;
1690
+ }
1691
+ function xmlElementNumber(xml, name) {
1692
+ const match = new RegExp(`<${name}>(\\d+)</${name}>`).exec(xml);
1693
+ if (!match)
1694
+ return undefined;
1695
+ const value = Number(match[1]);
1696
+ return Number.isFinite(value) ? value : undefined;
1697
+ }
1698
+ function decodeXmlText(input) {
1699
+ return input
1700
+ .replace(/&lt;/g, "<")
1701
+ .replace(/&gt;/g, ">")
1702
+ .replace(/&quot;/g, '"')
1703
+ .replace(/&apos;/g, "'")
1704
+ .replace(/&amp;/g, "&")
1705
+ .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
1706
+ .replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(parseInt(code, 16)));
1707
+ }
1388
1708
  async function createDocxIngestCopy(file, options, docx, classification) {
1389
1709
  const derivedPath = join(options.workDir, "docx", `${safeDerivedName(file.originalRelativePath)}-${file.sha256.slice(0, 12)}.docx`);
1390
1710
  await mkdir(dirname(derivedPath), { recursive: true });
@@ -1408,7 +1728,7 @@ async function createDocxIngestCopy(file, options, docx, classification) {
1408
1728
  size: derivedPlan.size,
1409
1729
  mtimeMs: derivedPlan.mtimeMs,
1410
1730
  sha256: derivedPlan.sha256,
1411
- manifestKey: derivedPlan.manifestKey,
1731
+ uploadKey: derivedPlan.uploadKey,
1412
1732
  relativePath: file.originalRelativePath,
1413
1733
  pathSegments: file.pathSegments,
1414
1734
  pathDepth: file.pathDepth,
@@ -1486,7 +1806,7 @@ async function createPdfPartPlans(file, options, pdf, classification) {
1486
1806
  size: derivedPlan.size,
1487
1807
  mtimeMs: derivedPlan.mtimeMs,
1488
1808
  sha256: derivedPlan.sha256,
1489
- manifestKey: derivedPlan.manifestKey,
1809
+ uploadKey: derivedPlan.uploadKey,
1490
1810
  relativePath: logicalRelativePath,
1491
1811
  pathSegments: logicalPathSegments,
1492
1812
  pathDepth: logicalPathSegments.length,
@@ -2249,7 +2569,9 @@ function csvLine(values) {
2249
2569
  return values.map((value) => `"${value.replace(/"/g, '""')}"`).join(",");
2250
2570
  }
2251
2571
  function formatBulkRunSummary(summary) {
2252
- return `Bulk job ${summary.jobId}: completed=${summary.completed} failed=${summary.failed} skipped=${summary.skipped} blocked=${summary.blocked} pending=${summary.pending} inflight=${summary.inflight} waiting_for_index_flags=${summary.waitingForIndexFlags} state=${summary.statePath}\n`;
2572
+ const pressure = summary.pipelineHealth?.pressure ?? "unknown";
2573
+ const action = summary.pipelineHealth?.recommendedAction ?? "continue";
2574
+ return `Bulk job ${summary.jobId}: completed=${summary.completed} failed=${summary.failed} skipped=${summary.skipped} blocked=${summary.blocked} pending=${summary.pending} inflight=${summary.inflight} waiting_for_index_flags=${summary.waitingForIndexFlags} pressure=${pressure} action=${action} state=${summary.statePath}\n`;
2253
2575
  }
2254
2576
  async function uploadWithRetries(input) {
2255
2577
  let lastError;
@@ -2264,17 +2586,13 @@ async function uploadWithRetries(input) {
2264
2586
  });
2265
2587
  const documentId = documentIdFromUpload(response);
2266
2588
  const existingDocumentId = existingDocumentIdFromUpload(response);
2267
- let finalResponse = response;
2268
- if (input.waitForTerminal && documentId) {
2269
- finalResponse = await waitForStatus(input.config, documentId, input.args, input.env);
2270
- }
2271
2589
  const result = {
2272
- key: input.plan.manifestKey,
2590
+ key: input.plan.uploadKey,
2273
2591
  path: input.plan.path,
2274
2592
  sha256: input.plan.sha256,
2275
2593
  status: "succeeded",
2276
2594
  attempts: attempt,
2277
- response: finalResponse,
2595
+ response,
2278
2596
  requestId,
2279
2597
  idempotencyKey,
2280
2598
  updatedAt: new Date().toISOString(),
@@ -2297,7 +2615,7 @@ async function uploadWithRetries(input) {
2297
2615
  }
2298
2616
  }
2299
2617
  return {
2300
- key: input.plan.manifestKey,
2618
+ key: input.plan.uploadKey,
2301
2619
  path: input.plan.path,
2302
2620
  sha256: input.plan.sha256,
2303
2621
  status: "failed",
@@ -2373,19 +2691,6 @@ async function listCollections(config, capability, limit) {
2373
2691
  offset += limit;
2374
2692
  }
2375
2693
  }
2376
- async function waitForStatus(config, documentId, args, env) {
2377
- const timeoutSeconds = positiveNumberValue(getString(args, "wait-timeout") ?? firstEnv(env, "TIANGONG_KB_WAIT_TIMEOUT"), DEFAULT_WAIT_TIMEOUT_SECONDS, "--wait-timeout");
2378
- const intervalSeconds = positiveNumberValue(getString(args, "poll-interval") ?? firstEnv(env, "TIANGONG_KB_POLL_INTERVAL"), DEFAULT_POLL_INTERVAL_SECONDS, "--poll-interval");
2379
- const startedAt = Date.now();
2380
- while (Date.now() - startedAt < timeoutSeconds * 1000) {
2381
- const payload = await getDocumentStatus(config, documentId);
2382
- const status = statusFromPayload(payload);
2383
- if (status && TERMINAL_STATUSES.has(status))
2384
- return payload;
2385
- await sleep(intervalSeconds * 1000);
2386
- }
2387
- throw new CliError(`Timed out waiting for document ${documentId}.`);
2388
- }
2389
2694
  async function jsonRequest(config, path, init = {}) {
2390
2695
  const url = `${config.apiBaseUrl}${config.apiPathPrefix}/${path.replace(/^\/+/, "")}`;
2391
2696
  const headers = new Headers(init.headers);
@@ -2444,7 +2749,7 @@ async function collectFiles(path, recursive, options = {}) {
2444
2749
  if (!item.isDirectory())
2445
2750
  throw new CliError(`Path is not a file or directory: ${path}`);
2446
2751
  if (!recursive)
2447
- throw new CliError(`Path is a directory. Pass --recursive to upload its files: ${path}`);
2752
+ throw new CliError(`Path is a directory and recursive traversal is disabled: ${path}`);
2448
2753
  const { readdir } = await import("node:fs/promises");
2449
2754
  const files = [];
2450
2755
  async function walk(dir) {
@@ -2481,41 +2786,9 @@ async function fingerprintFile(path) {
2481
2786
  size: info.size,
2482
2787
  mtimeMs: info.mtimeMs,
2483
2788
  sha256,
2484
- manifestKey: `${sha256}:${info.size}`,
2789
+ uploadKey: `${sha256}:${info.size}`,
2485
2790
  };
2486
2791
  }
2487
- async function loadManifest(path) {
2488
- const records = new Map();
2489
- const content = await readFile(path, "utf8").catch((error) => {
2490
- if (error.code === "ENOENT")
2491
- return "";
2492
- throw error;
2493
- });
2494
- for (const line of content.split(/\r?\n/)) {
2495
- if (!line.trim())
2496
- continue;
2497
- const record = JSON.parse(line);
2498
- records.set(record.key, record);
2499
- }
2500
- return records;
2501
- }
2502
- async function appendManifest(path, record) {
2503
- await appendFile(path, `${JSON.stringify(record)}\n`, "utf8");
2504
- }
2505
- async function loadMetadata(args) {
2506
- const metadataFile = getString(args, "metadata-file");
2507
- const metadataJson = getString(args, "metadata-json");
2508
- if (metadataFile && metadataJson)
2509
- throw new CliError("Use only one of --metadata-file or --metadata-json.");
2510
- if (!metadataFile && !metadataJson)
2511
- return {};
2512
- const raw = metadataFile ? await readFile(resolve(metadataFile), "utf8") : metadataJson;
2513
- const parsed = JSON.parse(raw ?? "{}");
2514
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2515
- throw new CliError("Metadata must be a JSON object.");
2516
- }
2517
- return parsed;
2518
- }
2519
2792
  async function runPool(items, concurrency, worker) {
2520
2793
  const results = [];
2521
2794
  let index = 0;
@@ -2580,12 +2853,6 @@ function duplicateFromUpload(payload) {
2580
2853
  const value = data.duplicate ?? data.isDuplicate;
2581
2854
  return typeof value === "boolean" ? value : undefined;
2582
2855
  }
2583
- function statusFromPayload(payload) {
2584
- const data = responseData(payload);
2585
- if (!isObject(data))
2586
- return undefined;
2587
- return stringField(data, "status");
2588
- }
2589
2856
  function writeJsonOrText(stdout, args, payload, text) {
2590
2857
  write(stdout, getBoolean(args, "json") ? `${JSON.stringify(payload, null, 2)}\n` : text());
2591
2858
  }
@@ -2733,8 +3000,7 @@ function topHelp() {
2733
3000
 
2734
3001
  Usage:
2735
3002
  tiangong-ai doctor [--json]
2736
- tiangong-ai kb ingest upload <file-or-folder> [--recursive] [--concurrency 3]
2737
- tiangong-ai kb ingest bulk <folder> [--window-size 100] [--top-up-max 50]
3003
+ tiangong-ai kb ingest bulk <file-or-folder> [--window-size 100] [--top-up-max 50]
2738
3004
  tiangong-ai kb ingest jobs
2739
3005
  tiangong-ai kb ingest status <document-id>
2740
3006
  tiangong-ai kb collections list [--capability upload]
@@ -2746,13 +3012,12 @@ function kbHelp() {
2746
3012
  return `Tiangong KB commands
2747
3013
 
2748
3014
  Usage:
2749
- tiangong-ai kb ingest upload <file-or-folder> [options]
2750
- tiangong-ai kb ingest bulk <folder> [options]
2751
- tiangong-ai kb ingest bulk scan <folder> [--json]
2752
- tiangong-ai kb ingest bulk preflight <folder> [--json]
2753
- tiangong-ai kb ingest bulk dry-run <folder> --metadata-map <path> [--json]
2754
- tiangong-ai kb ingest normalize dry-run <folder> [--json]
2755
- tiangong-ai kb ingest metadata dry-run <folder> --metadata-map <path> [--json]
3015
+ tiangong-ai kb ingest bulk <file-or-folder> [options]
3016
+ tiangong-ai kb ingest bulk scan <folder> [--json]
3017
+ tiangong-ai kb ingest bulk preflight <folder> [--json]
3018
+ tiangong-ai kb ingest bulk dry-run <folder> --metadata-map <path> [--json]
3019
+ tiangong-ai kb ingest normalize dry-run <folder> [--json]
3020
+ tiangong-ai kb ingest metadata dry-run <folder> --metadata-map <path> [--json]
2756
3021
  tiangong-ai kb ingest jobs [--json]
2757
3022
  tiangong-ai kb ingest status <job-id-or-document-id> [--json]
2758
3023
  tiangong-ai kb ingest resume <job-id> [options]
@@ -2771,22 +3036,23 @@ Common options:
2771
3036
  --json
2772
3037
 
2773
3038
  Ingest options:
2774
- --recursive
2775
- --manifest <path>
2776
- --concurrency <n>
3039
+ --state <path>
3040
+ --metadata-map <path>
3041
+ --schema-file <path>
3042
+ --window-size <n>
3043
+ --top-up-max <n>
3044
+ --upload-concurrency <n>
2777
3045
  --retries <n>
2778
- --force
2779
- --wait
2780
- --metadata-json <json>
2781
- --metadata-file <path>
3046
+ --poll-interval <seconds>
3047
+ --max-polls <n> (default 120, use 0 for no limit)
2782
3048
  `;
2783
3049
  }
2784
3050
  function bulkHelp() {
2785
3051
  return `Tiangong KB bulk ingest
2786
3052
 
2787
3053
  Usage:
2788
- tiangong-ai kb ingest bulk <folder> --collection-path <path> [options]
2789
- tiangong-ai kb ingest bulk run <folder> --collection-path <path> [options]
3054
+ tiangong-ai kb ingest bulk <file-or-folder> --collection-path <path> [options]
3055
+ tiangong-ai kb ingest bulk run <file-or-folder> --collection-path <path> [options]
2790
3056
  tiangong-ai kb ingest bulk scan <folder> --json
2791
3057
  tiangong-ai kb ingest bulk preflight <folder> --json
2792
3058
  tiangong-ai kb ingest bulk dry-run <folder> --metadata-map metadata-map.yaml --json
@@ -2801,7 +3067,7 @@ Bulk options:
2801
3067
  --top-up-max <n>
2802
3068
  --upload-concurrency <n>
2803
3069
  --poll-interval <seconds>
2804
- --max-polls <n>
3070
+ --max-polls <n> (default 120, use 0 for no limit)
2805
3071
  --json
2806
3072
  `;
2807
3073
  }