@squidcloud/cli 1.0.485 → 1.0.487
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.js
CHANGED
|
@@ -32200,6 +32200,7 @@ exports.AI_PROVIDER_TYPES = [
|
|
|
32200
32200
|
'voyage',
|
|
32201
32201
|
'mistral',
|
|
32202
32202
|
'textract',
|
|
32203
|
+
'azure_document_intelligence',
|
|
32203
32204
|
'vertex',
|
|
32204
32205
|
'external', // This is a technicality, referring to user-defined providers.
|
|
32205
32206
|
];
|
|
@@ -32225,12 +32226,17 @@ exports.oL = ['gemini-3.1-pro', 'gemini-3.6-flash', 'gemini-3.5-flash-lite'];
|
|
|
32225
32226
|
* Public Grok chat model names (active models only).
|
|
32226
32227
|
* @category AI
|
|
32227
32228
|
*/
|
|
32228
|
-
exports.Kq = ['grok-4.
|
|
32229
|
+
exports.Kq = ['grok-4.5', 'grok-4-1-fast-reasoning', 'grok-4-1-fast-non-reasoning'];
|
|
32229
32230
|
/**
|
|
32230
32231
|
* Public Anthropic chat model names (active models only).
|
|
32231
32232
|
* @category AI
|
|
32232
32233
|
*/
|
|
32233
|
-
exports.xV = [
|
|
32234
|
+
exports.xV = [
|
|
32235
|
+
'claude-fable-5',
|
|
32236
|
+
'claude-haiku-4-5-20251001',
|
|
32237
|
+
'claude-opus-5',
|
|
32238
|
+
'claude-sonnet-5',
|
|
32239
|
+
];
|
|
32234
32240
|
/**
|
|
32235
32241
|
* The supported AI model names.
|
|
32236
32242
|
* @category AI
|
|
@@ -32737,7 +32743,7 @@ function getConsoleAppRegionByStage(stage) {
|
|
|
32737
32743
|
var __webpack_unused_export__;
|
|
32738
32744
|
|
|
32739
32745
|
__webpack_unused_export__ = ({ value: true });
|
|
32740
|
-
__webpack_unused_export__ = __webpack_unused_export__ = exports.MILLIS_PER_DAY = __webpack_unused_export__ =
|
|
32746
|
+
__webpack_unused_export__ = __webpack_unused_export__ = exports.MILLIS_PER_DAY = __webpack_unused_export__ = exports.MILLIS_PER_MINUTE = exports.MILLIS_PER_SECOND = exports.LW = exports.FN = exports.xz = exports.NG = exports.fA = void 0;
|
|
32741
32747
|
/** @internal */
|
|
32742
32748
|
exports.fA = 60;
|
|
32743
32749
|
/** @internal */
|
|
@@ -32751,7 +32757,7 @@ exports.LW = 30 * exports.xz;
|
|
|
32751
32757
|
/** @internal */
|
|
32752
32758
|
exports.MILLIS_PER_SECOND = 1000;
|
|
32753
32759
|
/** @internal */
|
|
32754
|
-
|
|
32760
|
+
exports.MILLIS_PER_MINUTE = exports.fA * exports.MILLIS_PER_SECOND;
|
|
32755
32761
|
/** @internal */
|
|
32756
32762
|
__webpack_unused_export__ = exports.NG * exports.MILLIS_PER_SECOND;
|
|
32757
32763
|
/** @internal */
|
|
@@ -33049,7 +33055,6 @@ exports.sQ = [
|
|
|
33049
33055
|
'mutation',
|
|
33050
33056
|
'native-query',
|
|
33051
33057
|
'query',
|
|
33052
|
-
'queue',
|
|
33053
33058
|
// Note: every `/ws/*` WebSocket path is served by the TypeScript core (port 8000), never Kotlin.
|
|
33054
33059
|
];
|
|
33055
33060
|
/**
|
|
@@ -34367,6 +34372,726 @@ async function initWebpack() {
|
|
|
34367
34372
|
}
|
|
34368
34373
|
|
|
34369
34374
|
|
|
34375
|
+
/***/ },
|
|
34376
|
+
|
|
34377
|
+
/***/ 2713
|
|
34378
|
+
(__unused_webpack_module, exports, __webpack_require__) {
|
|
34379
|
+
|
|
34380
|
+
"use strict";
|
|
34381
|
+
|
|
34382
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
34383
|
+
if (k2 === undefined) k2 = k;
|
|
34384
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
34385
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
34386
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
34387
|
+
}
|
|
34388
|
+
Object.defineProperty(o, k2, desc);
|
|
34389
|
+
}) : (function(o, m, k, k2) {
|
|
34390
|
+
if (k2 === undefined) k2 = k;
|
|
34391
|
+
o[k2] = m[k];
|
|
34392
|
+
}));
|
|
34393
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
34394
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
34395
|
+
}) : function(o, v) {
|
|
34396
|
+
o["default"] = v;
|
|
34397
|
+
});
|
|
34398
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
34399
|
+
var ownKeys = function(o) {
|
|
34400
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
34401
|
+
var ar = [];
|
|
34402
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
34403
|
+
return ar;
|
|
34404
|
+
};
|
|
34405
|
+
return ownKeys(o);
|
|
34406
|
+
};
|
|
34407
|
+
return function (mod) {
|
|
34408
|
+
if (mod && mod.__esModule) return mod;
|
|
34409
|
+
var result = {};
|
|
34410
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
34411
|
+
__setModuleDefault(result, mod);
|
|
34412
|
+
return result;
|
|
34413
|
+
};
|
|
34414
|
+
})();
|
|
34415
|
+
Object.defineProperty(exports, "__esModule", ({ value: true }));
|
|
34416
|
+
exports.setupKbUploadCommand = setupKbUploadCommand;
|
|
34417
|
+
const assertic_1 = __webpack_require__(3205);
|
|
34418
|
+
const dotenv = __importStar(__webpack_require__(9650));
|
|
34419
|
+
const fs_1 = __webpack_require__(9896);
|
|
34420
|
+
const fs = __importStar(__webpack_require__(1943));
|
|
34421
|
+
const path = __importStar(__webpack_require__(6928));
|
|
34422
|
+
const stream_1 = __webpack_require__(2203);
|
|
34423
|
+
const communication_types_1 = __webpack_require__(3443);
|
|
34424
|
+
const time_units_1 = __webpack_require__(1929);
|
|
34425
|
+
const http_1 = __webpack_require__(866);
|
|
34426
|
+
const process_utils_1 = __webpack_require__(8251);
|
|
34427
|
+
/** CLI-side pacing default: files staged per bulk-ingestion job (not a server-enforced cap). */
|
|
34428
|
+
const DEFAULT_BATCH_SIZE = 200;
|
|
34429
|
+
/** Upper bound accepted for `--batchSize`; keeps a single job's blast radius reasonable. */
|
|
34430
|
+
const MAX_BATCH_SIZE = 1000;
|
|
34431
|
+
/**
|
|
34432
|
+
* Byte budget for one batch's files on disk. Mirrors the server's `BULK_INGESTION_MAX_STAGED_BYTES`
|
|
34433
|
+
* (256 MiB, ai-knowledge-base-management.service.ts) with headroom, since that ceiling applies to
|
|
34434
|
+
* EXTRACTED content and extraction can produce more text than the source file holds. Deliberately a
|
|
34435
|
+
* separate constant rather than an import: the CLI does not depend on core.
|
|
34436
|
+
*/
|
|
34437
|
+
const MAX_BATCH_BYTES = 192 * 1024 * 1024;
|
|
34438
|
+
/** Server-enforced cap on `bulk/createUploadUrls`'s `files` array (`BULK_INGESTION_MAX_UPLOAD_URLS_PER_CALL`). */
|
|
34439
|
+
const MAX_UPLOAD_URLS_PER_CALL = 500;
|
|
34440
|
+
/** Number of concurrent presigned-URL PUTs. */
|
|
34441
|
+
const PUT_CONCURRENCY = 8;
|
|
34442
|
+
/**
|
|
34443
|
+
* Files whose upload URLs are minted together. Kept a small multiple of {@link PUT_CONCURRENCY} so a wave's
|
|
34444
|
+
* URLs are always used well inside their 15-minute lifetime, while still amortizing the mint round-trip.
|
|
34445
|
+
*/
|
|
34446
|
+
const UPLOAD_WAVE_SIZE = 40;
|
|
34447
|
+
/**
|
|
34448
|
+
* How long a SIGINT waits for an in-flight `upsertContexts` to yield its job id before cancelling without it.
|
|
34449
|
+
* Bounded because that call has no request timeout of its own: an unbounded wait would leave the first Ctrl-C
|
|
34450
|
+
* hanging behind a wedged server, and a job whose id never arrives is better left to the 7-day deadline than
|
|
34451
|
+
* a CLI that will not quit.
|
|
34452
|
+
*/
|
|
34453
|
+
const SIGINT_STAGING_WAIT_MILLIS = 10 * time_units_1.MILLIS_PER_SECOND;
|
|
34454
|
+
/** Default per-job wait budget before falling back to "still running server-side" and moving on. */
|
|
34455
|
+
const DEFAULT_TIMEOUT_MINUTES = 120;
|
|
34456
|
+
/** Interval between `bulk/getJob` polls while waiting for a job to reach a terminal state. */
|
|
34457
|
+
const POLL_INTERVAL_MILLIS = 10 * time_units_1.MILLIS_PER_SECOND;
|
|
34458
|
+
/** Extensions ingested by default when `--extensions` is not provided. */
|
|
34459
|
+
const DEFAULT_EXTENSIONS = ['pdf', 'docx', 'txt', 'md', 'html', 'csv', 'xlsx', 'xls', 'xlsm', 'xlsb', 'pptx'];
|
|
34460
|
+
/** Job states that end a bulk-ingestion job's lifecycle (`BulkIngestionJobState` in core). */
|
|
34461
|
+
const TERMINAL_JOB_STATES = new Set(['completed', 'failed', 'cancelled']);
|
|
34462
|
+
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
|
34463
|
+
/** Placeholder status when a job's real status could never be retrieved before the timeout. */
|
|
34464
|
+
const UNKNOWN_JOB_STATUS = {
|
|
34465
|
+
state: 'unknown',
|
|
34466
|
+
counts: { files: 0, finalized: 0, failed: 0, requestsPending: 0, requestsSubmitted: 0 },
|
|
34467
|
+
providerBatchIds: [],
|
|
34468
|
+
files: [],
|
|
34469
|
+
};
|
|
34470
|
+
/**
|
|
34471
|
+
* Fixed line width the progress line pads to, so a shorter update fully overwrites a longer one. 140 because
|
|
34472
|
+
* at Pritzker scale a real line (`batch N/M job <uuid>: X/Y finalized, Z failed, state=...`) exceeds 100 chars.
|
|
34473
|
+
*/
|
|
34474
|
+
const PROGRESS_LINE_WIDTH = 140;
|
|
34475
|
+
/** Small extension → MIME map so the presigned PUT always carries an explicit Content-Type. */
|
|
34476
|
+
const EXTENSION_MIME_TYPES = {
|
|
34477
|
+
pdf: 'application/pdf',
|
|
34478
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
34479
|
+
doc: 'application/msword',
|
|
34480
|
+
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
34481
|
+
ppt: 'application/vnd.ms-powerpoint',
|
|
34482
|
+
txt: 'text/plain',
|
|
34483
|
+
md: 'text/markdown',
|
|
34484
|
+
html: 'text/html',
|
|
34485
|
+
htm: 'text/html',
|
|
34486
|
+
csv: 'text/csv',
|
|
34487
|
+
tsv: 'text/tab-separated-values',
|
|
34488
|
+
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
34489
|
+
xls: 'application/vnd.ms-excel',
|
|
34490
|
+
xlsm: 'application/vnd.ms-excel.sheet.macroEnabled.12',
|
|
34491
|
+
xlsb: 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',
|
|
34492
|
+
eml: 'message/rfc822',
|
|
34493
|
+
msg: 'application/vnd.ms-outlook',
|
|
34494
|
+
json: 'application/json',
|
|
34495
|
+
};
|
|
34496
|
+
/** Registers the `kb-upload` command: bulk-ingests a local directory tree into a knowledge base. */
|
|
34497
|
+
function setupKbUploadCommand(yargs) {
|
|
34498
|
+
yargs.command('kb-upload', 'Bulk-ingests a local directory tree into a knowledge base via the direct-to-storage bulk-ingestion API', yargs => {
|
|
34499
|
+
yargs.option('dir', {
|
|
34500
|
+
type: 'string',
|
|
34501
|
+
demandOption: true,
|
|
34502
|
+
describe: 'The local directory to walk recursively for files to ingest',
|
|
34503
|
+
});
|
|
34504
|
+
yargs.option('knowledgeBase', {
|
|
34505
|
+
type: 'string',
|
|
34506
|
+
demandOption: true,
|
|
34507
|
+
describe: 'The id of the knowledge base to ingest into',
|
|
34508
|
+
});
|
|
34509
|
+
yargs.option('appId', {
|
|
34510
|
+
type: 'string',
|
|
34511
|
+
describe: 'The application ID (Can be retrieved from the Squid Console). Falls back to SQUID_APP_ID',
|
|
34512
|
+
});
|
|
34513
|
+
yargs.option('apiKey', {
|
|
34514
|
+
type: 'string',
|
|
34515
|
+
describe: 'The application API key (Can be retrieved from the Squid Console). Falls back to SQUID_API_KEY',
|
|
34516
|
+
});
|
|
34517
|
+
yargs.option('internalApiKey', {
|
|
34518
|
+
type: 'string',
|
|
34519
|
+
describe: 'Internal API key for local/on-prem use instead of --apiKey. Falls back to SQUID_INTERNAL_API_KEY',
|
|
34520
|
+
});
|
|
34521
|
+
yargs.option('region', {
|
|
34522
|
+
type: 'string',
|
|
34523
|
+
describe: 'The Squid region the application lives in. Falls back to SQUID_REGION',
|
|
34524
|
+
});
|
|
34525
|
+
yargs.option('environmentId', {
|
|
34526
|
+
type: 'string',
|
|
34527
|
+
describe: "The environment to ingest into ('dev' or 'prod'). Falls back to SQUID_ENVIRONMENT_ID",
|
|
34528
|
+
});
|
|
34529
|
+
yargs.option('batchSize', {
|
|
34530
|
+
type: 'number',
|
|
34531
|
+
default: DEFAULT_BATCH_SIZE,
|
|
34532
|
+
describe: `Files staged per bulk-ingestion job (max ${MAX_BATCH_SIZE})`,
|
|
34533
|
+
});
|
|
34534
|
+
yargs.option('extensions', {
|
|
34535
|
+
type: 'string',
|
|
34536
|
+
default: DEFAULT_EXTENSIONS.join(','),
|
|
34537
|
+
describe: 'Comma-separated allow-list of file extensions to ingest',
|
|
34538
|
+
});
|
|
34539
|
+
yargs.option('dryRun', {
|
|
34540
|
+
type: 'boolean',
|
|
34541
|
+
default: false,
|
|
34542
|
+
describe: 'List the files that would be uploaded and exit without contacting the server',
|
|
34543
|
+
});
|
|
34544
|
+
yargs.option('timeoutMinutes', {
|
|
34545
|
+
type: 'number',
|
|
34546
|
+
default: DEFAULT_TIMEOUT_MINUTES,
|
|
34547
|
+
describe: 'Minutes to wait for each job to finish before moving on and reporting it as still running',
|
|
34548
|
+
});
|
|
34549
|
+
}, async (argv) => {
|
|
34550
|
+
// Mirrors what `deploy`/`undeploy` do in main.ts: an initialized project keeps its appId, region,
|
|
34551
|
+
// environment and developer id in a local `.env`, and nothing else loads it for this command — so
|
|
34552
|
+
// without this every normal project fails on "Missing application ID" until the user exports the
|
|
34553
|
+
// variables by hand.
|
|
34554
|
+
dotenv.config({ path: path.resolve('./', '.env') });
|
|
34555
|
+
await kbUpload(argv);
|
|
34556
|
+
});
|
|
34557
|
+
}
|
|
34558
|
+
/** Drives the full walk → stage → wait → summarize flow and sets `process.exitCode` accordingly. */
|
|
34559
|
+
async function kbUpload(argv) {
|
|
34560
|
+
const extensions = parseExtensions(argv.extensions);
|
|
34561
|
+
const batchSize = validateBatchSize(argv.batchSize);
|
|
34562
|
+
const files = await collectFiles(argv.dir, extensions);
|
|
34563
|
+
if (argv.dryRun) {
|
|
34564
|
+
await printDryRun(argv.dir, files, batchSize);
|
|
34565
|
+
return;
|
|
34566
|
+
}
|
|
34567
|
+
if (files.length === 0) {
|
|
34568
|
+
console.log(`No files with extensions [${Array.from(extensions).join(', ')}] found under ${argv.dir}`);
|
|
34569
|
+
return;
|
|
34570
|
+
}
|
|
34571
|
+
const appId = getDataPlaneAppId(argv);
|
|
34572
|
+
const region = getRegion(argv);
|
|
34573
|
+
const { apiKey, internalApiKey } = getAuth(argv);
|
|
34574
|
+
const timeoutMillis = argv.timeoutMinutes * time_units_1.MILLIS_PER_MINUTE;
|
|
34575
|
+
const client = new BulkIngestionClient(region, appId, apiKey, internalApiKey);
|
|
34576
|
+
const batches = await groupFilesIntoBatches(files, batchSize);
|
|
34577
|
+
console.log(`Found ${files.length} file(s) under ${argv.dir}; staging in ${batches.length} batch(es) of up to ${batchSize}.`);
|
|
34578
|
+
const activeJobIds = new Set();
|
|
34579
|
+
// The job id of a batch whose `upsertContexts` call is CURRENTLY in flight. That call runs extraction
|
|
34580
|
+
// inline server-side for a whole batch, so it is a long window in which a job exists on the server but has
|
|
34581
|
+
// no id here yet — and a SIGINT landing in it would otherwise snapshot an empty `activeJobIds`, exit, and
|
|
34582
|
+
// leave that job running and billing. The handler waits on this before cancelling.
|
|
34583
|
+
let pendingStaging;
|
|
34584
|
+
let interrupted = false;
|
|
34585
|
+
const onSigint = () => {
|
|
34586
|
+
if (interrupted) {
|
|
34587
|
+
process.exit(130);
|
|
34588
|
+
}
|
|
34589
|
+
interrupted = true;
|
|
34590
|
+
process.stdout.write('\n');
|
|
34591
|
+
console.log('Received interrupt; cancelling in-flight bulk-ingestion job(s)... (press Ctrl-C again to exit immediately)');
|
|
34592
|
+
// Resolve any in-flight staging FIRST so its job id can be cancelled too; a staging that itself fails
|
|
34593
|
+
// created no job, so an undefined result simply adds nothing. Never rejects — a staging error is already
|
|
34594
|
+
// reported by runBatch — so cancellation always runs.
|
|
34595
|
+
//
|
|
34596
|
+
// Raced against a deadline: `upsertContexts` runs extraction inline server-side for a whole batch and
|
|
34597
|
+
// carries no request timeout, so waiting on it unconditionally would hang the FIRST Ctrl-C behind a slow
|
|
34598
|
+
// or wedged server. On timeout we fall back to cancelling what we already know about, which is strictly
|
|
34599
|
+
// better than not exiting.
|
|
34600
|
+
const stagingOrTimeout = Promise.race([
|
|
34601
|
+
// Belt and braces: the published promise is already non-rejecting (see onStagingStarted).
|
|
34602
|
+
Promise.resolve(pendingStaging).catch(() => undefined),
|
|
34603
|
+
new Promise(resolve => setTimeout(() => resolve(undefined), SIGINT_STAGING_WAIT_MILLIS)),
|
|
34604
|
+
]);
|
|
34605
|
+
void stagingOrTimeout
|
|
34606
|
+
.then(stagingJobId => cancelActiveJobs(client, stagingJobId ? new Set([...activeJobIds, stagingJobId]) : activeJobIds))
|
|
34607
|
+
.finally(() => process.exit(130));
|
|
34608
|
+
};
|
|
34609
|
+
process.on('SIGINT', onSigint);
|
|
34610
|
+
const results = [];
|
|
34611
|
+
for (let batchIndex = 0; batchIndex < batches.length && !interrupted; batchIndex++) {
|
|
34612
|
+
// runBatch handles the expected failure modes internally (upload failures, and a staging/job-creation
|
|
34613
|
+
// failure that happens after uploads already succeeded) and always returns a BatchResult for those. This
|
|
34614
|
+
// catch is a last resort for something genuinely unexpected happening before any of that is known (e.g.
|
|
34615
|
+
// createUploadUrls itself throwing), so one batch's crash doesn't abort the whole run.
|
|
34616
|
+
try {
|
|
34617
|
+
const result = await runBatch(client, argv.knowledgeBase, batches[batchIndex], batchIndex, batches.length, timeoutMillis, activeJobIds, () => interrupted, staging => {
|
|
34618
|
+
pendingStaging = staging;
|
|
34619
|
+
});
|
|
34620
|
+
results.push(result);
|
|
34621
|
+
// Cleared once the batch is done: its job id is in `activeJobIds` now (or the job was never created),
|
|
34622
|
+
// so a later SIGINT must not re-resolve this settled promise and issue a pointless cancel.
|
|
34623
|
+
pendingStaging = undefined;
|
|
34624
|
+
}
|
|
34625
|
+
catch (error) {
|
|
34626
|
+
const message = (0, assertic_1.getMessageFromError)(error);
|
|
34627
|
+
console.error(`batch ${batchIndex + 1}/${batches.length}: unexpected error before staging: ${message}`);
|
|
34628
|
+
results.push({
|
|
34629
|
+
batchIndex,
|
|
34630
|
+
batchCount: batches.length,
|
|
34631
|
+
uploadFailures: [],
|
|
34632
|
+
duplicates: [],
|
|
34633
|
+
timedOut: false,
|
|
34634
|
+
batchError: message,
|
|
34635
|
+
});
|
|
34636
|
+
}
|
|
34637
|
+
}
|
|
34638
|
+
process.off('SIGINT', onSigint);
|
|
34639
|
+
printSummary(files.length, results);
|
|
34640
|
+
process.exitCode = interrupted || results.some(hasBatchFailed) ? 1 : 0;
|
|
34641
|
+
}
|
|
34642
|
+
/** Stages one batch (upload + upsertContexts) and waits for its job to finish or time out. */
|
|
34643
|
+
async function runBatch(client, knowledgeBaseId, filePaths, batchIndex, batchCount, timeoutMillis, activeJobIds, isInterrupted,
|
|
34644
|
+
/** Publishes this batch's in-flight `upsertContexts` promise so a SIGINT can cancel the job it creates. */
|
|
34645
|
+
onStagingStarted) {
|
|
34646
|
+
const batchLabel = `batch ${batchIndex + 1}/${batchCount}`;
|
|
34647
|
+
// Minted wave by wave, immediately before the PUTs that use them, rather than all at once up front. The
|
|
34648
|
+
// server gives a presigned URL BULK_INGESTION_UPLOAD_URL_EXPIRATION_SECONDS (15 min) of life, and a large
|
|
34649
|
+
// batch on a slow link uploads at only PUT_CONCURRENCY at a time — so URLs minted for the tail of the
|
|
34650
|
+
// batch could expire before their turn arrived, and the single retry would reuse the same dead URL.
|
|
34651
|
+
const uploadResults = [];
|
|
34652
|
+
for (const wave of chunk(filePaths, UPLOAD_WAVE_SIZE)) {
|
|
34653
|
+
// SIGINT is observed between waves, not only after the whole batch has uploaded.
|
|
34654
|
+
if (isInterrupted())
|
|
34655
|
+
break;
|
|
34656
|
+
try {
|
|
34657
|
+
const waveUrls = await createUploadUrlsForBatch(client, wave.map(filePath => path.basename(filePath)));
|
|
34658
|
+
(0, assertic_1.assertTruthy)(waveUrls.length === wave.length, `${batchLabel}: createUploadUrls returned ${waveUrls.length} entries for ${wave.length} files`);
|
|
34659
|
+
uploadResults.push(...(await runWithConcurrency(wave, PUT_CONCURRENCY, async (filePath, index) => uploadFile(filePath, waveUrls[index], waveUrls[index].stagedObjectKey))));
|
|
34660
|
+
}
|
|
34661
|
+
catch (error) {
|
|
34662
|
+
// A mint failure on a LATER wave must not discard the earlier waves' successful uploads: those objects
|
|
34663
|
+
// are already staged, and throwing here would leave them orphaned until the 24h expiry with no job
|
|
34664
|
+
// referencing them. Record this wave's files as upload failures and stage whatever did land — the
|
|
34665
|
+
// summary machinery below already reports per-file failures honestly.
|
|
34666
|
+
const message = (0, assertic_1.getMessageFromError)(error);
|
|
34667
|
+
console.warn(`${batchLabel}: failed to mint or upload a wave of ${wave.length} file(s): ${message}`);
|
|
34668
|
+
uploadResults.push(...wave.map(filePath => ({ fileName: path.basename(filePath), error: message })));
|
|
34669
|
+
}
|
|
34670
|
+
}
|
|
34671
|
+
const uploadFailures = uploadResults.filter(result => !isUploaded(result));
|
|
34672
|
+
for (const failure of uploadFailures) {
|
|
34673
|
+
console.warn(`${batchLabel}: failed to upload ${failure.fileName}: ${failure.error}`);
|
|
34674
|
+
}
|
|
34675
|
+
// BulkIngestionContext.metadata (ENG-2527 Phase-7) is available here for a future path-rule feature —
|
|
34676
|
+
// e.g. tagging each staged file with its source folder/glob-derived metadata — but this command has no
|
|
34677
|
+
// such flag yet, so no metadata is stamped today.
|
|
34678
|
+
const stagedContexts = uploadResults
|
|
34679
|
+
.filter(isUploaded)
|
|
34680
|
+
.map(result => ({ type: 'file', stagedObjectKey: result.stagedObjectKey }));
|
|
34681
|
+
if (stagedContexts.length === 0) {
|
|
34682
|
+
console.warn(`${batchLabel}: every file failed to upload; no job was created.`);
|
|
34683
|
+
return { batchIndex, batchCount, uploadFailures, duplicates: [], timedOut: false };
|
|
34684
|
+
}
|
|
34685
|
+
// Re-checked immediately before staging, not only between waves: the wave loop's `break` lands right here,
|
|
34686
|
+
// so without this Ctrl-C would SKIP the remaining uploads and then promptly create a job anyway. Worse, its
|
|
34687
|
+
// id reaches `activeJobIds` only after `cancelActiveJobs` has already snapshotted the set, so the job would
|
|
34688
|
+
// survive the CLI and keep billing. Uploaded objects with no job simply expire on their own 24h clock.
|
|
34689
|
+
if (isInterrupted()) {
|
|
34690
|
+
console.warn(`${batchLabel}: interrupted before job creation; ${stagedContexts.length} uploaded object(s) remain ` +
|
|
34691
|
+
`staged (auto-expire in 24h) and no job was created.`);
|
|
34692
|
+
return {
|
|
34693
|
+
batchIndex,
|
|
34694
|
+
batchCount,
|
|
34695
|
+
uploadFailures,
|
|
34696
|
+
duplicates: [],
|
|
34697
|
+
timedOut: false,
|
|
34698
|
+
stagedCount: stagedContexts.length,
|
|
34699
|
+
};
|
|
34700
|
+
}
|
|
34701
|
+
// The uploads above already succeeded, so a failure from here on is NOT an upload failure: the objects are
|
|
34702
|
+
// already sitting in staging. Catch this call specifically so that outcome is reported honestly instead of
|
|
34703
|
+
// being conflated with "no job created (all uploads failed)".
|
|
34704
|
+
let jobId;
|
|
34705
|
+
let duplicates;
|
|
34706
|
+
try {
|
|
34707
|
+
const staging = client.upsertContexts(knowledgeBaseId, stagedContexts);
|
|
34708
|
+
// The published promise must never REJECT. It is a derived branch of `staging`, and the only rejection
|
|
34709
|
+
// handler on it lives inside the SIGINT path — which usually never runs, so a staging failure would
|
|
34710
|
+
// otherwise surface as an unhandled rejection and take the whole CLI down AFTER it had already reported
|
|
34711
|
+
// the failure honestly. `undefined` is exactly what the handler treats as "no job to cancel".
|
|
34712
|
+
onStagingStarted(staging.then(response => response.jobId).catch(() => undefined));
|
|
34713
|
+
const response = await staging;
|
|
34714
|
+
jobId = response.jobId;
|
|
34715
|
+
// Named by the title the server rejected them under, which for this command is always the file name.
|
|
34716
|
+
duplicates = (response.duplicates ?? []).map(duplicate => duplicate.name);
|
|
34717
|
+
}
|
|
34718
|
+
catch (error) {
|
|
34719
|
+
const message = (0, assertic_1.getMessageFromError)(error);
|
|
34720
|
+
console.error(`${batchLabel}: staged ${stagedContexts.length} upload(s) but job creation failed: ${message}; ` +
|
|
34721
|
+
`${stagedContexts.length} uploaded object(s) remain staged (auto-expire in 24h).`);
|
|
34722
|
+
return {
|
|
34723
|
+
batchIndex,
|
|
34724
|
+
batchCount,
|
|
34725
|
+
uploadFailures,
|
|
34726
|
+
duplicates: [],
|
|
34727
|
+
timedOut: false,
|
|
34728
|
+
stagingError: message,
|
|
34729
|
+
stagedCount: stagedContexts.length,
|
|
34730
|
+
};
|
|
34731
|
+
}
|
|
34732
|
+
if (duplicates.length > 0) {
|
|
34733
|
+
console.log(`${batchLabel}: ${duplicates.length} file(s) skipped as content this knowledge base already holds.`);
|
|
34734
|
+
}
|
|
34735
|
+
activeJobIds.add(jobId);
|
|
34736
|
+
const { finalStatus, timedOut } = await waitForJobCompletion(client, jobId, batchLabel, timeoutMillis, isInterrupted);
|
|
34737
|
+
activeJobIds.delete(jobId);
|
|
34738
|
+
return { batchIndex, batchCount, jobId, uploadFailures, duplicates, finalStatus, timedOut };
|
|
34739
|
+
}
|
|
34740
|
+
/** Mints presigned upload URLs for a batch, chunking requests to the server's per-call cap. */
|
|
34741
|
+
async function createUploadUrlsForBatch(client, fileNames) {
|
|
34742
|
+
const uploads = [];
|
|
34743
|
+
for (const group of chunk(fileNames, MAX_UPLOAD_URLS_PER_CALL)) {
|
|
34744
|
+
const response = await client.createUploadUrls(group);
|
|
34745
|
+
uploads.push(...response.uploads);
|
|
34746
|
+
}
|
|
34747
|
+
return uploads;
|
|
34748
|
+
}
|
|
34749
|
+
/**
|
|
34750
|
+
* PUTs one file's bytes to its presigned URL, retrying once on a network error or non-2xx response.
|
|
34751
|
+
* `upload.requiredHeaders` carries whatever the storage backend mandates beyond `Content-Type` (Azure Blob
|
|
34752
|
+
* rejects a PUT without `x-ms-blob-type`), so it must be sent as given rather than assumed empty.
|
|
34753
|
+
*/
|
|
34754
|
+
async function uploadFile(filePath, upload, stagedObjectKey) {
|
|
34755
|
+
const fileName = path.basename(filePath);
|
|
34756
|
+
const mimeType = EXTENSION_MIME_TYPES[extensionOf(fileName)] || DEFAULT_MIME_TYPE;
|
|
34757
|
+
// Sized once, up front: a presigned PUT needs an explicit Content-Length because a stream body is not
|
|
34758
|
+
// measurable, and knowing it also lets the retry below reopen the file rather than hold it in memory.
|
|
34759
|
+
const { size } = await fs.stat(filePath);
|
|
34760
|
+
let lastError = 'Unknown upload error';
|
|
34761
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
34762
|
+
try {
|
|
34763
|
+
// Streamed from disk, NOT buffered: PUT_CONCURRENCY workers each reading a whole file into memory
|
|
34764
|
+
// allocates their combined size, and the direct-upload contract admits files far larger than this
|
|
34765
|
+
// process should ever hold. A fresh stream per attempt — a consumed one cannot be replayed.
|
|
34766
|
+
const response = await fetch(upload.uploadUrl, {
|
|
34767
|
+
method: 'PUT',
|
|
34768
|
+
headers: {
|
|
34769
|
+
'Content-Type': mimeType,
|
|
34770
|
+
'Content-Length': String(size),
|
|
34771
|
+
...(upload.requiredHeaders ?? {}),
|
|
34772
|
+
},
|
|
34773
|
+
body: stream_1.Readable.toWeb((0, fs_1.createReadStream)(filePath)),
|
|
34774
|
+
// Node requires this for a streaming request body; without it fetch rejects the stream outright.
|
|
34775
|
+
duplex: 'half',
|
|
34776
|
+
});
|
|
34777
|
+
if (response.ok) {
|
|
34778
|
+
return { fileName, stagedObjectKey };
|
|
34779
|
+
}
|
|
34780
|
+
lastError = `HTTP ${response.status}: ${await response.text()}`;
|
|
34781
|
+
}
|
|
34782
|
+
catch (error) {
|
|
34783
|
+
lastError = (0, assertic_1.getMessageFromError)(error);
|
|
34784
|
+
}
|
|
34785
|
+
}
|
|
34786
|
+
return { fileName, error: lastError };
|
|
34787
|
+
}
|
|
34788
|
+
/**
|
|
34789
|
+
* Polls `bulk/getJob` until the job reaches a terminal state or the timeout elapses. A run can wait up to
|
|
34790
|
+
* `--timeoutMinutes` (default two hours), so a single transient poll error must not crash the whole CLI —
|
|
34791
|
+
* it's logged and retried on the next tick instead.
|
|
34792
|
+
*/
|
|
34793
|
+
async function waitForJobCompletion(client, jobId, batchLabel, timeoutMillis, isInterrupted) {
|
|
34794
|
+
const deadline = Date.now() + timeoutMillis;
|
|
34795
|
+
let status;
|
|
34796
|
+
let isFirstPoll = true;
|
|
34797
|
+
while (!isInterrupted() && Date.now() < deadline && (!status || !TERMINAL_JOB_STATES.has(status.state))) {
|
|
34798
|
+
if (!isFirstPoll) {
|
|
34799
|
+
await sleep(POLL_INTERVAL_MILLIS);
|
|
34800
|
+
}
|
|
34801
|
+
isFirstPoll = false;
|
|
34802
|
+
try {
|
|
34803
|
+
status = await client.getJob(jobId);
|
|
34804
|
+
printProgress(batchLabel, jobId, status);
|
|
34805
|
+
}
|
|
34806
|
+
catch (error) {
|
|
34807
|
+
console.warn(`\n${batchLabel}: transient error polling job ${jobId}, retrying: ${(0, assertic_1.getMessageFromError)(error)}`);
|
|
34808
|
+
}
|
|
34809
|
+
}
|
|
34810
|
+
process.stdout.write('\n');
|
|
34811
|
+
if (!status) {
|
|
34812
|
+
console.warn(`${batchLabel}: could not retrieve job ${jobId}'s status before the timeout.`);
|
|
34813
|
+
return { finalStatus: UNKNOWN_JOB_STATUS, timedOut: true };
|
|
34814
|
+
}
|
|
34815
|
+
const timedOut = !TERMINAL_JOB_STATES.has(status.state) && !isInterrupted();
|
|
34816
|
+
if (timedOut) {
|
|
34817
|
+
console.warn(`${batchLabel}: job ${jobId} did not finish within the timeout; it may still be running server-side.`);
|
|
34818
|
+
}
|
|
34819
|
+
return { finalStatus: status, timedOut };
|
|
34820
|
+
}
|
|
34821
|
+
/** Best-effort cancellation of every job still active when the process is interrupted. */
|
|
34822
|
+
async function cancelActiveJobs(client, activeJobIds) {
|
|
34823
|
+
await Promise.allSettled(Array.from(activeJobIds).map(async (jobId) => {
|
|
34824
|
+
try {
|
|
34825
|
+
await client.cancelJob(jobId);
|
|
34826
|
+
console.log(`Cancelled job ${jobId}.`);
|
|
34827
|
+
}
|
|
34828
|
+
catch (error) {
|
|
34829
|
+
console.warn(`Failed to cancel job ${jobId}: ${(0, assertic_1.getMessageFromError)(error)}`);
|
|
34830
|
+
}
|
|
34831
|
+
}));
|
|
34832
|
+
}
|
|
34833
|
+
function printProgress(batchLabel, jobId, status) {
|
|
34834
|
+
const { counts, state } = status;
|
|
34835
|
+
const line = `${batchLabel} job ${jobId}: ${counts.finalized}/${counts.files} finalized, ${counts.failed} failed, state=${state}`;
|
|
34836
|
+
process.stdout.write(`\r${line}${' '.repeat(Math.max(0, PROGRESS_LINE_WIDTH - line.length))}`);
|
|
34837
|
+
}
|
|
34838
|
+
/** Narrows a `FileUploadResult` to one that uploaded successfully (has a `stagedObjectKey`, no `error`). */
|
|
34839
|
+
function isUploaded(result) {
|
|
34840
|
+
return result.stagedObjectKey !== undefined;
|
|
34841
|
+
}
|
|
34842
|
+
async function printDryRun(dir, files, batchSize) {
|
|
34843
|
+
const preview = files.slice(0, 20);
|
|
34844
|
+
for (const filePath of preview) {
|
|
34845
|
+
console.log(path.relative(dir, filePath));
|
|
34846
|
+
}
|
|
34847
|
+
if (files.length > preview.length) {
|
|
34848
|
+
console.log(`... and ${files.length - preview.length} more`);
|
|
34849
|
+
}
|
|
34850
|
+
// Uses the real grouping rather than a count division: batches are bounded by bytes as well, so a
|
|
34851
|
+
// division would under-report the batch count for exactly the large-corpus runs this flag is used to plan.
|
|
34852
|
+
const batchCount = (await groupFilesIntoBatches(files, batchSize)).length;
|
|
34853
|
+
console.log(`\n${files.length} file(s) would be uploaded in ${batchCount} batch(es) of up to ${batchSize} file(s) ` +
|
|
34854
|
+
`and ${Math.floor(MAX_BATCH_BYTES / (1024 * 1024))} MiB. Dry run: no files were uploaded.`);
|
|
34855
|
+
}
|
|
34856
|
+
function printSummary(totalFiles, results) {
|
|
34857
|
+
const totalFinalized = results.reduce((sum, result) => sum + (result.finalStatus?.counts.finalized ?? 0), 0);
|
|
34858
|
+
const totalServerFailed = results.reduce((sum, result) => sum + (result.finalStatus?.counts.failed ?? 0), 0);
|
|
34859
|
+
const totalUploadFailed = results.reduce((sum, result) => sum + result.uploadFailures.length, 0);
|
|
34860
|
+
const totalDuplicates = results.reduce((sum, result) => sum + result.duplicates.length, 0);
|
|
34861
|
+
const totalStagedButFailed = results.reduce((sum, result) => sum + (result.stagingError ? (result.stagedCount ?? 0) : 0), 0);
|
|
34862
|
+
console.log('\n=== Bulk KB upload summary ===');
|
|
34863
|
+
console.log(`Files scanned: ${totalFiles}`);
|
|
34864
|
+
console.log(`Finalized (server): ${totalFinalized}`);
|
|
34865
|
+
console.log(`Failed (server-side): ${totalServerFailed}`);
|
|
34866
|
+
console.log(`Failed to upload: ${totalUploadFailed}`);
|
|
34867
|
+
if (totalDuplicates > 0) {
|
|
34868
|
+
// Between "scanned" and "finalized" so the two reconcile: a duplicate is neither finalized nor failed,
|
|
34869
|
+
// and reading the summary without this line the difference looks like files that silently disappeared.
|
|
34870
|
+
console.log(`Skipped as duplicate: ${totalDuplicates}`);
|
|
34871
|
+
}
|
|
34872
|
+
if (totalStagedButFailed > 0) {
|
|
34873
|
+
console.log(`Staged but job failed: ${totalStagedButFailed} (uploaded objects remain; auto-expire in 24h)`);
|
|
34874
|
+
}
|
|
34875
|
+
for (const result of results) {
|
|
34876
|
+
const label = `batch ${result.batchIndex + 1}/${result.batchCount}`;
|
|
34877
|
+
if (result.stagingError) {
|
|
34878
|
+
console.log(` ${label}: staged ${result.stagedCount ?? 0} upload(s) but job creation failed: ${result.stagingError}; ` +
|
|
34879
|
+
`uploaded object(s) remain (auto-expire in 24h)`);
|
|
34880
|
+
continue;
|
|
34881
|
+
}
|
|
34882
|
+
if (result.batchError) {
|
|
34883
|
+
console.log(` ${label}: unexpected error before staging: ${result.batchError}`);
|
|
34884
|
+
continue;
|
|
34885
|
+
}
|
|
34886
|
+
if (!result.jobId) {
|
|
34887
|
+
console.log(` ${label}: no job created (all uploads failed)`);
|
|
34888
|
+
continue;
|
|
34889
|
+
}
|
|
34890
|
+
const state = result.timedOut
|
|
34891
|
+
? `${result.finalStatus?.state ?? 'unknown'} (timed out waiting)`
|
|
34892
|
+
: result.finalStatus?.state;
|
|
34893
|
+
console.log(` ${label}: job ${result.jobId} — ${result.finalStatus?.counts.finalized ?? 0}/${result.finalStatus?.counts.files ?? 0} finalized, ${result.finalStatus?.counts.failed ?? 0} failed [${state}]`);
|
|
34894
|
+
// Per-file failure reasons, straight from the job-status response (BulkIngestionFileStatus).
|
|
34895
|
+
const failedFiles = result.finalStatus?.files.filter(file => file.status === 'failed') ?? [];
|
|
34896
|
+
for (const failedFile of failedFiles) {
|
|
34897
|
+
console.log(` - ${failedFile.title}: ${failedFile.errorMessage ?? 'unknown error'}`);
|
|
34898
|
+
}
|
|
34899
|
+
// Named individually for the same reason failures are: "20 skipped" does not tell an operator whether
|
|
34900
|
+
// the right 20 were skipped, and these files never reach the job, so nothing else will ever name them.
|
|
34901
|
+
for (const duplicate of result.duplicates) {
|
|
34902
|
+
console.log(` - ${duplicate}: skipped, already in this knowledge base`);
|
|
34903
|
+
}
|
|
34904
|
+
}
|
|
34905
|
+
}
|
|
34906
|
+
function hasBatchFailed(result) {
|
|
34907
|
+
if (result.uploadFailures.length > 0 || result.timedOut)
|
|
34908
|
+
return true;
|
|
34909
|
+
if (!result.finalStatus)
|
|
34910
|
+
return true;
|
|
34911
|
+
return result.finalStatus.state !== 'completed' || result.finalStatus.counts.failed > 0;
|
|
34912
|
+
}
|
|
34913
|
+
/** Recursively walks `dir`, skipping symlinks and dotfiles/dot-directories, filtered to `extensions`. */
|
|
34914
|
+
async function collectFiles(dir, extensions) {
|
|
34915
|
+
const stat = await fs.stat(dir).catch(() => undefined);
|
|
34916
|
+
if (!stat || !stat.isDirectory()) {
|
|
34917
|
+
(0, process_utils_1.exitWithError)(`--dir must point to an existing directory: ${dir}`);
|
|
34918
|
+
}
|
|
34919
|
+
const files = [];
|
|
34920
|
+
await walk(dir, files, extensions);
|
|
34921
|
+
files.sort();
|
|
34922
|
+
return files;
|
|
34923
|
+
}
|
|
34924
|
+
async function walk(dir, files, extensions) {
|
|
34925
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
34926
|
+
for (const entry of entries) {
|
|
34927
|
+
if (entry.name.startsWith('.') || entry.isSymbolicLink()) {
|
|
34928
|
+
continue;
|
|
34929
|
+
}
|
|
34930
|
+
const fullPath = path.join(dir, entry.name);
|
|
34931
|
+
if (entry.isDirectory()) {
|
|
34932
|
+
await walk(fullPath, files, extensions);
|
|
34933
|
+
}
|
|
34934
|
+
else if (entry.isFile() && extensions.has(extensionOf(entry.name))) {
|
|
34935
|
+
files.push(fullPath);
|
|
34936
|
+
}
|
|
34937
|
+
}
|
|
34938
|
+
}
|
|
34939
|
+
function extensionOf(fileName) {
|
|
34940
|
+
return path.extname(fileName).slice(1).toLowerCase();
|
|
34941
|
+
}
|
|
34942
|
+
function parseExtensions(raw) {
|
|
34943
|
+
const extensions = raw
|
|
34944
|
+
.split(',')
|
|
34945
|
+
.map(extension => extension.trim().replace(/^\./, '').toLowerCase())
|
|
34946
|
+
.filter(extension => extension.length > 0);
|
|
34947
|
+
if (extensions.length === 0) {
|
|
34948
|
+
(0, process_utils_1.exitWithError)('--extensions must list at least one extension');
|
|
34949
|
+
}
|
|
34950
|
+
return new Set(extensions);
|
|
34951
|
+
}
|
|
34952
|
+
function validateBatchSize(batchSize) {
|
|
34953
|
+
if (!Number.isInteger(batchSize) || batchSize < 1 || batchSize > MAX_BATCH_SIZE) {
|
|
34954
|
+
(0, process_utils_1.exitWithError)(`--batchSize must be an integer between 1 and ${MAX_BATCH_SIZE}`);
|
|
34955
|
+
}
|
|
34956
|
+
return batchSize;
|
|
34957
|
+
}
|
|
34958
|
+
/**
|
|
34959
|
+
* The app id the DATA PLANE is addressed by: base id plus environment plus developer id, exactly as
|
|
34960
|
+
* `deploy` (appIdWithEnvironmentId) and `sample` (appIdWithEnvironmentIdAndDevId) build it.
|
|
34961
|
+
*
|
|
34962
|
+
* The base id alone is not a synonym for it. `appIdWithEnvironmentId` renders `prod` as the bare id, so
|
|
34963
|
+
* uploading with an unsuffixed id targets PRODUCTION; and omitting the developer id targets the shared
|
|
34964
|
+
* dev environment instead of the developer's own sandbox — which the API key does not catch, because key
|
|
34965
|
+
* lookup strips the developer id (`omitSquidDevId`), so both authenticate with the same key and the
|
|
34966
|
+
* upload silently lands in the wrong place.
|
|
34967
|
+
*/
|
|
34968
|
+
function getDataPlaneAppId(argv) {
|
|
34969
|
+
const appId = argv.appId || process.env['SQUID_APP_ID'];
|
|
34970
|
+
if (!appId) {
|
|
34971
|
+
(0, process_utils_1.exitWithError)('Missing application ID: pass --appId or set SQUID_APP_ID');
|
|
34972
|
+
}
|
|
34973
|
+
const environmentId = (argv.environmentId || process.env['SQUID_ENVIRONMENT_ID']);
|
|
34974
|
+
const developerId = process.env['SQUID_DEVELOPER_ID'];
|
|
34975
|
+
return (0, communication_types_1.appIdWithEnvironmentIdAndDevId)(appId, environmentId, developerId);
|
|
34976
|
+
}
|
|
34977
|
+
function getRegion(argv) {
|
|
34978
|
+
const region = argv.region || process.env['SQUID_REGION'];
|
|
34979
|
+
if (!region) {
|
|
34980
|
+
(0, process_utils_1.exitWithError)('Missing Squid region: pass --region or set SQUID_REGION');
|
|
34981
|
+
}
|
|
34982
|
+
return region;
|
|
34983
|
+
}
|
|
34984
|
+
function getAuth(argv) {
|
|
34985
|
+
const apiKey = argv.apiKey || process.env['SQUID_API_KEY'];
|
|
34986
|
+
const internalApiKey = argv.internalApiKey || process.env['SQUID_INTERNAL_API_KEY'];
|
|
34987
|
+
if (!apiKey && !internalApiKey) {
|
|
34988
|
+
(0, process_utils_1.exitWithError)('Missing credentials: pass --apiKey (or SQUID_API_KEY) or --internalApiKey (or SQUID_INTERNAL_API_KEY)');
|
|
34989
|
+
}
|
|
34990
|
+
return { apiKey, internalApiKey };
|
|
34991
|
+
}
|
|
34992
|
+
function chunk(items, size) {
|
|
34993
|
+
const chunks = [];
|
|
34994
|
+
for (let i = 0; i < items.length; i += size) {
|
|
34995
|
+
chunks.push(items.slice(i, i + size));
|
|
34996
|
+
}
|
|
34997
|
+
return chunks;
|
|
34998
|
+
}
|
|
34999
|
+
/**
|
|
35000
|
+
* Groups files into batches bounded by BOTH count and total bytes.
|
|
35001
|
+
*
|
|
35002
|
+
* Count alone is not a bound on what the server will accept: it caps a job's staged CONTENT at
|
|
35003
|
+
* BULK_INGESTION_MAX_STAGED_BYTES, so a default 200-file batch of individually legal 2 MiB documents adds
|
|
35004
|
+
* up to roughly 400 MiB and the whole job is rejected with BULK_INGESTION_TOO_LARGE — every file in it,
|
|
35005
|
+
* including the 199 that were fine.
|
|
35006
|
+
*
|
|
35007
|
+
* On-disk size is a proxy for extracted content, not an equality: plain text is about 1:1, while a PDF or
|
|
35008
|
+
* a workbook can extract to more text than it occupies. {@link MAX_BATCH_BYTES} is set well under the
|
|
35009
|
+
* server ceiling to absorb that, and a file bigger than the budget still gets its own batch — splitting a
|
|
35010
|
+
* single document is not something this tool can do.
|
|
35011
|
+
*/
|
|
35012
|
+
async function groupFilesIntoBatches(files, batchSize) {
|
|
35013
|
+
const batches = [];
|
|
35014
|
+
let current = [];
|
|
35015
|
+
let currentBytes = 0;
|
|
35016
|
+
for (const filePath of files) {
|
|
35017
|
+
// A file that cannot be stat'ed is counted as weightless rather than skipped: it still belongs in a
|
|
35018
|
+
// batch, and the upload itself is what should report its failure.
|
|
35019
|
+
const size = await fs
|
|
35020
|
+
.stat(filePath)
|
|
35021
|
+
.then(stat => stat.size)
|
|
35022
|
+
.catch(() => 0);
|
|
35023
|
+
if (current.length > 0 && (current.length >= batchSize || currentBytes + size > MAX_BATCH_BYTES)) {
|
|
35024
|
+
batches.push(current);
|
|
35025
|
+
current = [];
|
|
35026
|
+
currentBytes = 0;
|
|
35027
|
+
}
|
|
35028
|
+
current.push(filePath);
|
|
35029
|
+
currentBytes += size;
|
|
35030
|
+
}
|
|
35031
|
+
if (current.length > 0)
|
|
35032
|
+
batches.push(current);
|
|
35033
|
+
return batches;
|
|
35034
|
+
}
|
|
35035
|
+
/** Runs `worker` over `items` with at most `concurrency` in flight at once, preserving result order. */
|
|
35036
|
+
async function runWithConcurrency(items, concurrency, worker) {
|
|
35037
|
+
const results = new Array(items.length);
|
|
35038
|
+
let nextIndex = 0;
|
|
35039
|
+
const runNext = async () => {
|
|
35040
|
+
while (nextIndex < items.length) {
|
|
35041
|
+
const currentIndex = nextIndex;
|
|
35042
|
+
nextIndex += 1;
|
|
35043
|
+
results[currentIndex] = await worker(items[currentIndex], currentIndex);
|
|
35044
|
+
}
|
|
35045
|
+
};
|
|
35046
|
+
const workerCount = Math.min(concurrency, items.length);
|
|
35047
|
+
await Promise.all(Array.from({ length: workerCount }, runNext));
|
|
35048
|
+
return results;
|
|
35049
|
+
}
|
|
35050
|
+
function sleep(millis) {
|
|
35051
|
+
return new Promise(resolve => setTimeout(resolve, millis));
|
|
35052
|
+
}
|
|
35053
|
+
/** Thin raw-fetch client for the Phase-5 bulk-ingestion HTTP surface (no SDK dependency — see report). */
|
|
35054
|
+
class BulkIngestionClient {
|
|
35055
|
+
constructor(region, appId, apiKey, internalApiKey) {
|
|
35056
|
+
this.region = region;
|
|
35057
|
+
this.appId = appId;
|
|
35058
|
+
this.apiKey = apiKey;
|
|
35059
|
+
this.internalApiKey = internalApiKey;
|
|
35060
|
+
this.lp = 'BulkIngestionClient';
|
|
35061
|
+
}
|
|
35062
|
+
async createUploadUrls(fileNames) {
|
|
35063
|
+
return this.post('ai/knowledge-base/bulk/createUploadUrls', { files: fileNames.map(fileName => ({ fileName })) });
|
|
35064
|
+
}
|
|
35065
|
+
async upsertContexts(knowledgeBaseId, contexts) {
|
|
35066
|
+
return this.post('ai/knowledge-base/bulk/upsertContexts', { knowledgeBaseId, contexts });
|
|
35067
|
+
}
|
|
35068
|
+
async getJob(jobId) {
|
|
35069
|
+
return this.post('ai/knowledge-base/bulk/getJob', { jobId });
|
|
35070
|
+
}
|
|
35071
|
+
async cancelJob(jobId) {
|
|
35072
|
+
await this.post('ai/knowledge-base/bulk/cancelJob', { jobId });
|
|
35073
|
+
}
|
|
35074
|
+
async post(urlPath, body) {
|
|
35075
|
+
const lp = `${this.lp}.post:`;
|
|
35076
|
+
const url = (0, http_1.getApplicationUrl)(this.region, this.appId, urlPath);
|
|
35077
|
+
console.debug(`${lp} POST ${url}`);
|
|
35078
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
35079
|
+
if (this.apiKey) {
|
|
35080
|
+
headers['Authorization'] = `ApiKey ${this.apiKey}`;
|
|
35081
|
+
}
|
|
35082
|
+
if (this.internalApiKey) {
|
|
35083
|
+
headers['x-squid-secret'] = this.internalApiKey;
|
|
35084
|
+
}
|
|
35085
|
+
const response = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body) });
|
|
35086
|
+
const text = await response.text();
|
|
35087
|
+
if (!response.ok) {
|
|
35088
|
+
throw new Error(`${urlPath} failed: ${response.status} ${text}`);
|
|
35089
|
+
}
|
|
35090
|
+
return (text ? JSON.parse(text) : undefined);
|
|
35091
|
+
}
|
|
35092
|
+
}
|
|
35093
|
+
|
|
35094
|
+
|
|
34370
35095
|
/***/ },
|
|
34371
35096
|
|
|
34372
35097
|
/***/ 4291
|
|
@@ -34422,6 +35147,7 @@ const build_1 = __webpack_require__(8584);
|
|
|
34422
35147
|
const deploy_1 = __webpack_require__(8705);
|
|
34423
35148
|
const init_env_1 = __webpack_require__(1838);
|
|
34424
35149
|
const init_webpack_1 = __webpack_require__(1134);
|
|
35150
|
+
const kb_upload_1 = __webpack_require__(2713);
|
|
34425
35151
|
const sample_1 = __webpack_require__(4328);
|
|
34426
35152
|
const start_1 = __webpack_require__(496);
|
|
34427
35153
|
const undeploy_1 = __webpack_require__(7097);
|
|
@@ -34449,6 +35175,7 @@ function run() {
|
|
|
34449
35175
|
setupInitEnvCommand(yargs_1.default);
|
|
34450
35176
|
setupInitSampleCommand(yargs_1.default);
|
|
34451
35177
|
setupInitWebpackCommand(yargs_1.default);
|
|
35178
|
+
(0, kb_upload_1.setupKbUploadCommand)(yargs_1.default);
|
|
34452
35179
|
setupStartCommand(yargs_1.default);
|
|
34453
35180
|
setupUndeployCommand(yargs_1.default);
|
|
34454
35181
|
setupUpdateSkillsCommand(yargs_1.default);
|
|
@@ -40002,7 +40729,7 @@ module.exports = /*#__PURE__*/JSON.parse('{"name":"seek-bzip","version":"1.0.6",
|
|
|
40002
40729
|
(module) {
|
|
40003
40730
|
|
|
40004
40731
|
"use strict";
|
|
40005
|
-
module.exports = /*#__PURE__*/JSON.parse('{"name":"@squidcloud/cli","version":"1.0.
|
|
40732
|
+
module.exports = /*#__PURE__*/JSON.parse('{"name":"@squidcloud/cli","version":"1.0.487","description":"The Squid CLI","main":"dist/index.js","scripts":{"start":"node dist/index.js","start-ts":"ts-node -r tsconfig-paths/register src/index.ts","prebuild":"rimraf dist","build":"webpack --mode=production","build:dev":"webpack --mode=development","lint":"eslint","link":"npm run build && chmod 755 dist/index.js && npm link","watch":"webpack --watch","deploy":"npm run build && npm pack --silent | xargs -I {} mv {} package.tgz && npm install -g package.tgz && rm -rf package.tgz","publish:public":"npm run build && npm publish --access public"},"files":["dist/**/*"],"bin":{"squid":"dist/index.js"},"keywords":[],"author":"","license":"ISC","engines":{"node":">=18.0.0"},"dependencies":{"@squidcloud/local-backend":"^1.0.487","adm-zip":"^0.5.16","copy-webpack-plugin":"^14.0.0","decompress":"^4.2.1","logpipes":"^1.11.0","nodemon":"^3.1.9","terser-webpack-plugin":"^5.5.0","ts-loader":"^9.5.1","ts-node":"^10.9.2","tsconfig-paths":"^4.2.0","tsconfig-paths-webpack-plugin":"^4.1.0","webpack":"^5.106.2","zip-webpack-plugin":"^4.0.1"},"devDependencies":{"@types/adm-zip":"^0.5.7","@types/decompress":"^4.2.7","terminal-link":"^3.0.0"}}');
|
|
40006
40733
|
|
|
40007
40734
|
/***/ }
|
|
40008
40735
|
|
|
@@ -23,7 +23,7 @@ Squid is a backend-as-a-service platform that provides:
|
|
|
23
23
|
|
|
24
24
|
- **[client.md](reference/client.md)** → client SDK, initialization, setup, auth, login, tokens, OAuth, appId, region, environmentId, apiKey, authProvider, getToken, setAuthProvider, Squid client, frontend, collection, executeFunction, executeFunctionWithHeaders, getWebhookUrl, externalAuth, saveAuthCode, getAccessToken, storage, uploadFile, downloadUrl, queues, produce, consume, distributed locks, acquireLock, withLock, web, aiSearch, getUrlContent, createShortUrl, jobs, getJob, awaitJob, observability, metrics, reportMetric, queryMetrics, notifications, publishNotification, observeNotifications
|
|
25
25
|
- **[console.md](reference/console.md)** → Squid Console, web UI, organizations, applications, AI Studio, knowledge bases, integrations, monitoring, logs, API keys, secrets, testing, debugging, profile settings, management API keys
|
|
26
|
-
- **[ai.md](reference/ai.md)** → AI agents, chat, ask, askWithAnnotations, askAsync, askWithVoiceResponse, transcribeAndChat, transcribeAndAsk, knowledge bases, RAG, embeddings, image generation, audio, transcription, text-to-speech, TTS, connectedAgents, connectedIntegrations, connectedKnowledgeBases, @aiFunction, @secureAiAgent, @secureAiQuery, memory, memoryOptions, voiceOptions, OpenAI, Anthropic, Gemini, Grok, DALL-E, Whisper, MCP, @mcpServer, @mcpTool, executeAiQuery, executeAiApiCall, extraction, createPdf, upsert agent, listAgents
|
|
26
|
+
- **[ai.md](reference/ai.md)** → AI agents, chat, ask, askWithAnnotations, askAsync, askWithVoiceResponse, transcribeAndChat, transcribeAndAsk, knowledge bases, RAG, embeddings, image generation, audio, transcription, text-to-speech, TTS, connectedAgents, connectedIntegrations, connectedKnowledgeBases, @aiFunction, @secureAiAgent, @secureAiQuery, memory, memoryOptions, voiceOptions, OpenAI, Anthropic, Gemini, Grok, DALL-E, Whisper, MCP, @mcpServer, @mcpTool, executeAiQuery, executeAiApiCall, extraction, createPdf, upsert agent, listAgents, spreadsheet understanding, querySpreadsheetsWithAi, searchMode, keyword search, xlsx, csv
|
|
27
27
|
- **[chat-widget.md](reference/chat-widget.md)** → AI chat widget, @squidcloud/react-chat-widget, squid-chat-widget, squid-chat-widget-with-fab-button, embeddable chat, custom API webhook, AI query mode, chain-of-thought, status updates, session management, agentContext, memoryOptions, squid-ai-custom-api-url, squid-ai-agent-chat-options, FAB button, theming, CSS variables, localization, RTL, menu items slots, suggested prompts, voice transcription, error formatting, authentication, squid-auth-provider, onChange events
|
|
28
28
|
- **[databases.md](reference/databases.md)** → collections, documents, queries, subscriptions, snapshots, insert, update, delete, CRUD, real-time, dereference, pagination, transactions, query operators, eq, neq, gt, gte, lt, lte, like, in, nin, arrayIncludesSome, arrayIncludesAll, sortBy, limit, join queries, OR queries, @trigger, native queries, SQL, MongoDB, Elasticsearch, incrementInPath, decrementInPath, watch changes, doc(), projectFields, field projection, __docId__, __id
|
|
29
29
|
- **[backend.md](reference/backend.md)** → SquidService, @executable, @webhook, @trigger, TriggerRequest, @scheduler, @limits, rate limiting, quotas, decorators, backend functions, WebhookRequest, CronExpression, cron, file handling, SquidFile, getUserAuth, isAuthenticated, assertIsAuthenticated, createWebhookResponse, this.squid, this.secrets, @clientConnectionStateHandler, @onQueueMessage, QueueMessageRequest, queue message handler, server-side queue consumer, CLI, squid init, squid start, squid deploy, squid build, project structure, multiple services, service architecture, squidInject, cross-service communication
|
|
@@ -436,6 +436,19 @@ const searchResults = await kb.search({
|
|
|
436
436
|
limit: 10
|
|
437
437
|
});
|
|
438
438
|
|
|
439
|
+
// Keyword (lexical, embedding-free) search — best for exact tokens: IDs, error codes, SKUs, file names.
|
|
440
|
+
// searchMode: 'vector' | 'hybrid' | 'keyword' | 'graph'. Default: 'hybrid' ('graph' on GraphRAG-enabled
|
|
441
|
+
// KBs; setting 'graph' on other KBs throws). Keyword mechanics differ by the KB's vectorDbType:
|
|
442
|
+
// mongoAtlas = ranked BM25, term-optional (partial matches still rank); postgres = unranked boolean
|
|
443
|
+
// filter where EVERY whitespace-separated term must appear as a literal substring — a multi-word
|
|
444
|
+
// prompt returns zero chunks when any one term is absent, so pass few, specific tokens.
|
|
445
|
+
// On postgres 'hybrid' degrades to dense-only (no lexical channel; hybridWeights is ignored off
|
|
446
|
+
// Mongo), so exact-token retrieval there REQUIRES explicitly setting searchMode: 'keyword'.
|
|
447
|
+
const keywordResults = await kb.search({
|
|
448
|
+
prompt: 'ERR_0000_4F2A',
|
|
449
|
+
searchMode: 'keyword'
|
|
450
|
+
});
|
|
451
|
+
|
|
439
452
|
// Get context
|
|
440
453
|
const context = await kb.getContext('doc-123');
|
|
441
454
|
|
|
@@ -451,6 +464,14 @@ await kb.deleteContext('doc-123');
|
|
|
451
464
|
await kb.deleteContexts(['doc-1', 'doc-2']);
|
|
452
465
|
```
|
|
453
466
|
|
|
467
|
+
### Spreadsheet Understanding
|
|
468
|
+
|
|
469
|
+
Spreadsheet files (`.csv`, `.tsv`, `.xlsx`, `.xlsm`, `.xls`, `.xlsb`) uploaded as file context take a dedicated ingestion lane: instead of chunking raw cell text, Squid extracts the workbook structure (sheets, headers, hidden sheets; charts and pivot tables where the format exposes them — absent for CSV/TSV and `.xls`, pivots also absent for `.xlsb`) and embeds a generated whole-workbook summary, so search results describe what a workbook contains.
|
|
470
|
+
|
|
471
|
+
When a connected knowledge base holds spreadsheet contexts, the agent automatically gets a `querySpreadsheetsWithAi` tool that runs Python in a sandbox against the actual uploaded files — for exact values (counts, sums, lookups, filtering, sorting), joins/comparisons across several workbooks in one call, and structure/provenance questions (which sheets feed live calculations, formulas vs hardcoded cells; formula/dependency inspection is fullest for `.xlsx`/`.xlsm`, partial for `.xls`, values-only for `.xlsb`, absent for CSV/TSV). No configuration needed, but the lane and the tool depend on the retained original file: with `discardOriginalFile: true` the spreadsheet ingests as plain extracted text and gets no query tool.
|
|
472
|
+
|
|
473
|
+
Spreadsheets attached to a chat via `fileUrls` similarly get a per-file Python query tool. Detection is by file extension from `fileName` (or the URL path when `fileName` is absent; query strings are stripped, so a signed URL ending in `.xlsx?sig=...` is detected) — set `fileName` only when the URL path itself lacks the extension, e.g. opaque download routes.
|
|
474
|
+
|
|
454
475
|
## AI Files
|
|
455
476
|
|
|
456
477
|
Manage files stored with AI providers (OpenAI, etc.) for use with agents.
|
|
@@ -20,7 +20,8 @@ import {
|
|
|
20
20
|
|
|
21
21
|
### Verbosity Control
|
|
22
22
|
|
|
23
|
-
Controls response length. OpenAI
|
|
23
|
+
Controls response length. OpenAI only (ignored for other providers), and applies to every response
|
|
24
|
+
format — `text`, `json_object` and `json_schema` alike.
|
|
24
25
|
|
|
25
26
|
```typescript
|
|
26
27
|
const response = await agent.ask('Explain quantum computing', {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@squidcloud/cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.487",
|
|
4
4
|
"description": "The Squid CLI",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"node": ">=18.0.0"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@squidcloud/local-backend": "^1.0.
|
|
31
|
+
"@squidcloud/local-backend": "^1.0.487",
|
|
32
32
|
"adm-zip": "^0.5.16",
|
|
33
33
|
"copy-webpack-plugin": "^14.0.0",
|
|
34
34
|
"decompress": "^4.2.1",
|
|
@@ -45,7 +45,6 @@
|
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@types/adm-zip": "^0.5.7",
|
|
47
47
|
"@types/decompress": "^4.2.7",
|
|
48
|
-
"@types/node": "^20.19.9",
|
|
49
48
|
"terminal-link": "^3.0.0"
|
|
50
49
|
}
|
|
51
50
|
}
|