@descix/cli 1.0.1 → 1.0.3
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/LICENSE.md +671 -0
- package/README.md +43 -4
- package/agent-assets/guides/README.md +2 -2
- package/agent-assets/guides/dynamic-site-pattern.md +29 -14
- package/agent-assets/guides/knowledge-only-pattern.md +5 -5
- package/agent-assets/guides/microservice-pattern.md +48 -4
- package/agent-assets/guides/static-site-pattern.md +8 -0
- package/agent-assets/instructions/app-asset-validation.md +5 -5
- package/agent-assets/instructions/onboarding-flow.md +4 -4
- package/agent-assets/instructions/standard-folder-structure.md +20 -11
- package/agent-assets/reference/app-creation.md +42 -20
- package/agent-assets/reference/architecture.md +24 -22
- package/agent-assets/reference/local-dev.md +230 -67
- package/agent-assets/reference/mcp-integration.md +9 -9
- package/agent-assets/reference/platform-api-consumer.md +244 -0
- package/agent-assets/reference/workspace-config.md +7 -7
- package/agent-assets/rules/descix_mcp.template.mdc +8 -8
- package/bin/descix.js +2559 -1435
- package/bin/kb-deny-lint.mjs +91 -0
- package/bin/mcp-server.js +175 -91
- package/lib/agent-files.js +37 -25
- package/lib/api-client.js +223 -56
- package/lib/auth-guard.js +13 -3
- package/lib/chat-session.js +187 -0
- package/lib/cli-version.js +59 -0
- package/lib/commands/airdrop.js +251 -0
- package/lib/commands/auth.js +254 -72
- package/lib/commands/briefer/errors.js +88 -0
- package/lib/commands/briefer/index.js +319 -0
- package/lib/commands/briefer/sources/canonical-sources.js +129 -0
- package/lib/commands/briefer/sources/entitlements.js +209 -0
- package/lib/commands/briefer/sources/environments.js +205 -0
- package/lib/commands/briefer/sources/identifiers.js +214 -0
- package/lib/commands/briefer/sources/microservice-deploy.js +267 -0
- package/lib/commands/briefer/sources/routing.js +275 -0
- package/lib/commands/briefer/sources/what-is-not.js +223 -0
- package/lib/commands/briefer/stitcher.js +134 -0
- package/lib/commands/briefer/util/source-reader.js +502 -0
- package/lib/commands/config.js +210 -90
- package/lib/commands/corpus.js +978 -0
- package/lib/commands/credits.js +122 -0
- package/lib/commands/health.js +627 -0
- package/lib/commands/kb-list-render.js +32 -0
- package/lib/commands/kb.js +190 -413
- package/lib/commands/mcp.js +5 -5
- package/lib/commands/microservice-restart.js +359 -0
- package/lib/commands/model-config.js +313 -0
- package/lib/commands/retired-kb-sync.js +134 -0
- package/lib/commands/serve.js +26 -1
- package/lib/commands/status.js +23 -51
- package/lib/commands/update.js +36 -107
- package/lib/core/Chunker.js +491 -17
- package/lib/core/CorpusDenyLint.js +326 -0
- package/lib/core/CorpusWalker.js +357 -0
- package/lib/core/Hydrator.js +75 -29
- package/lib/core/ManifestLoader.js +533 -0
- package/lib/core/SiteWalker.js +218 -0
- package/lib/core/Syncer.js +163 -11
- package/lib/diagnostic-log.js +41 -0
- package/lib/environment-report.js +100 -0
- package/lib/global-config.js +4 -1
- package/lib/oauth-client.js +164 -0
- package/lib/origin.js +143 -0
- package/lib/service-api-client.js +95 -0
- package/lib/wallet-environment.js +49 -0
- package/lib/wallet-file.js +16 -2
- package/lib/wizard/setup.js +10 -9
- package/lib/workspace-config.js +380 -235
- package/lib/workspace-identity.js +92 -0
- package/lib/workspace-utils.js +41 -61
- package/package.json +19 -6
- package/templates/agent-claude.md +157 -9
- package/templates/agent-cline.md +68 -8
- package/templates/agent-copilot.md +68 -8
- package/templates/agent-cursor.md +68 -8
- package/templates/alignment-schema.json +20 -0
- package/templates/cloud-brain-hook.sh +20 -0
- package/templates/drive/README.md +1 -1
- package/templates/drive_templates/README.md +2 -2
- package/templates/scaffolds/README.md +6 -10
- package/templates/scaffolds/microservice/SERVICE_README_sdk.md +30 -0
- package/templates/scaffolds/microservice/app.js +51 -10
- package/templates/scaffolds/microservice/app.yaml +2 -2
- package/templates/scaffolds/microservice/defaults-config-dev.json +4 -0
- package/templates/scaffolds/microservice/dev-overrides.example.json +5 -0
- package/templates/scaffolds/microservice/manifest.json +0 -1
- package/templates/scaffolds/microservice/package.json +3 -3
- package/templates/scaffolds/microservice/scripts/register.js +56 -41
- package/templates/scaffolds/microservice/services/mcpClient.js +55 -65
- package/templates/scaffolds/microservice/services/utils.js +20 -238
- package/templates/scaffolds/microservice/templates/SERVICE_README_TEMPLATE.md +24 -0
- package/lib/commands/app-wizard.js +0 -627
- package/lib/commands/folder.js +0 -291
- package/lib/core/PathContext.js +0 -335
- package/templates/scaffolds/microservice/dev-overrides.json.example +0 -5
- package/templates/scaffolds/site/DeSciXAppSDK.js +0 -62
- package/templates/scaffolds/site/README.md +0 -51
- package/templates/scaffolds/site/app.js +0 -22
- package/templates/scaffolds/site/index.html +0 -42
- package/templates/scaffolds/site/styles.css +0 -123
package/lib/core/Chunker.js
CHANGED
|
@@ -38,9 +38,14 @@ const DEFAULT_OVERLAP_SIZE = 500;
|
|
|
38
38
|
*/
|
|
39
39
|
export function categorizeFile(fileName, mimeType = null) {
|
|
40
40
|
const name = (fileName || '').toLowerCase();
|
|
41
|
-
|
|
41
|
+
|
|
42
|
+
// Structured data files — splits by top-level array/object element
|
|
43
|
+
if (name.endsWith('.json')) {
|
|
44
|
+
return 'structured-json';
|
|
45
|
+
}
|
|
46
|
+
|
|
42
47
|
// Check by extension
|
|
43
|
-
if (name.endsWith('.js') || name.endsWith('.py') || name.endsWith('.ts') ||
|
|
48
|
+
if (name.endsWith('.js') || name.endsWith('.py') || name.endsWith('.ts') ||
|
|
44
49
|
name.endsWith('.jsx') || name.endsWith('.tsx') || name.endsWith('.lean') ||
|
|
45
50
|
name.endsWith('.sol') || name.endsWith('.rs') || name.endsWith('.go')) {
|
|
46
51
|
return 'code';
|
|
@@ -51,17 +56,21 @@ export function categorizeFile(fileName, mimeType = null) {
|
|
|
51
56
|
if (name.endsWith('.tex')) {
|
|
52
57
|
return 'papers';
|
|
53
58
|
}
|
|
54
|
-
|
|
59
|
+
if (name.endsWith('.jsonl')) {
|
|
60
|
+
return 'training';
|
|
61
|
+
}
|
|
62
|
+
|
|
55
63
|
// Check by MIME type
|
|
56
64
|
if (mimeType === 'application/vnd.google-apps.document' ||
|
|
57
65
|
mimeType === 'text/markdown' ||
|
|
58
66
|
mimeType === 'text/plain') {
|
|
59
67
|
return 'docs';
|
|
60
68
|
}
|
|
61
|
-
|
|
69
|
+
|
|
62
70
|
return 'generic';
|
|
63
71
|
}
|
|
64
72
|
|
|
73
|
+
|
|
65
74
|
/**
|
|
66
75
|
* Chunk code files
|
|
67
76
|
* Optimizes for embedding by keeping logical units together
|
|
@@ -274,6 +283,55 @@ function chunkByMarkdownSections(content, fileMetadata, rules) {
|
|
|
274
283
|
return chunks;
|
|
275
284
|
}
|
|
276
285
|
|
|
286
|
+
/**
|
|
287
|
+
* Chunk JSONL training files — one chunk per record
|
|
288
|
+
* Each line is parsed as JSON; Q&A pairs are formatted as readable text for embedding.
|
|
289
|
+
* Non-reserved fields (domain, role, difficulty, verified_by, etc.) are preserved in
|
|
290
|
+
* chunk.metadata.jsonlFields for passthrough to Pinecone as filterable metadata.
|
|
291
|
+
*
|
|
292
|
+
* @param {string} content - File content (one JSON object per line)
|
|
293
|
+
* @param {Object} fileMetadata - { name }
|
|
294
|
+
* @returns {Array<{content, metadata}>}
|
|
295
|
+
*/
|
|
296
|
+
function chunkJsonl(content, fileMetadata) {
|
|
297
|
+
const lines = content.split('\n').filter(line => line.trim());
|
|
298
|
+
const chunks = [];
|
|
299
|
+
|
|
300
|
+
for (const line of lines) {
|
|
301
|
+
let parsed;
|
|
302
|
+
try {
|
|
303
|
+
parsed = JSON.parse(line);
|
|
304
|
+
} catch {
|
|
305
|
+
continue; // skip malformed lines
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Format as readable text for embedding
|
|
309
|
+
let text;
|
|
310
|
+
if (parsed.question && parsed.answer) {
|
|
311
|
+
text = `Q: ${parsed.question}\nA: ${parsed.answer}`;
|
|
312
|
+
} else {
|
|
313
|
+
text = JSON.stringify(parsed, null, 2);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Separate embedding content from metadata fields
|
|
317
|
+
// question/answer go into text; everything else becomes filterable Pinecone metadata
|
|
318
|
+
const { question, answer, ...jsonlFields } = parsed; // eslint-disable-line no-unused-vars
|
|
319
|
+
|
|
320
|
+
chunks.push({
|
|
321
|
+
content: text,
|
|
322
|
+
metadata: {
|
|
323
|
+
chunkIndex: chunks.length,
|
|
324
|
+
type: 'qa-pair',
|
|
325
|
+
fileName: fileMetadata.name,
|
|
326
|
+
jsonlFields // domain, role, difficulty, verified_by, id, status, etc.
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
chunks.forEach(chunk => { chunk.metadata.totalChunks = chunks.length; });
|
|
332
|
+
return chunks;
|
|
333
|
+
}
|
|
334
|
+
|
|
277
335
|
/**
|
|
278
336
|
* Generic chunking using sliding window
|
|
279
337
|
* Fallback for file types without semantic structure
|
|
@@ -319,6 +377,375 @@ function chunkGeneric(content, fileMetadata, rules) {
|
|
|
319
377
|
return chunks;
|
|
320
378
|
}
|
|
321
379
|
|
|
380
|
+
// ============ Content-Aware Structured Chunking (M1, 2026-04-20) ============
|
|
381
|
+
// Rationale: the pre-existing chunkCode/chunkGeneric paths silently produced
|
|
382
|
+
// 0 chunks for files like EGPT/www/data/concordance-data.js (588KB). The
|
|
383
|
+
// regex-driven logical-boundary walker never matched, and the downstream
|
|
384
|
+
// corpus.js had a 500KB hard cap that dropped such files entirely. These
|
|
385
|
+
// helpers split by structural boundaries first — top-level object keys,
|
|
386
|
+
// top-level array elements, top-level function/class/export/const-arrow
|
|
387
|
+
// declarations — before falling back to line-aware sliding windows.
|
|
388
|
+
// The cap in corpus.js is removed; large content is now safely chunked.
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Split a JSON object into chunks by walking its top-level structure.
|
|
392
|
+
*
|
|
393
|
+
* Strategy:
|
|
394
|
+
* - If root is an array: one chunk per element (merging small adjacent
|
|
395
|
+
* elements up to maxSize to avoid single-entry chunks when the array
|
|
396
|
+
* contains many tiny items).
|
|
397
|
+
* - If root is an object: one chunk per top-level key. If a single key's
|
|
398
|
+
* value is still larger than maxSize, recurse into it (array → per-
|
|
399
|
+
* element; object → per-sub-key).
|
|
400
|
+
* - If content is not valid JSON or produces no natural splits: fall back
|
|
401
|
+
* to chunkGeneric (line-aware sliding window).
|
|
402
|
+
*
|
|
403
|
+
* Each chunk is a pretty-printed JSON snippet with a header comment so
|
|
404
|
+
* the retrieval context tells Gemini what part of the original document
|
|
405
|
+
* it came from.
|
|
406
|
+
*
|
|
407
|
+
* @param {string} content - Full JSON text
|
|
408
|
+
* @param {Object} fileMetadata - { name }
|
|
409
|
+
* @param {Object} rules - { maxChunkSize, overlapSize }
|
|
410
|
+
* @returns {Array<{content, metadata}>}
|
|
411
|
+
*/
|
|
412
|
+
function chunkStructuredJson(content, fileMetadata, rules) {
|
|
413
|
+
// Structured data is denser than prose — default to 8KB per chunk so that a
|
|
414
|
+
// single rich array element (e.g. one debate exchange with multi-KB turns)
|
|
415
|
+
// stays intact. Callers can still override via rules.maxChunkSize.
|
|
416
|
+
const maxSize = rules.maxChunkSize || 8000;
|
|
417
|
+
|
|
418
|
+
let parsed;
|
|
419
|
+
try {
|
|
420
|
+
parsed = JSON.parse(content);
|
|
421
|
+
} catch {
|
|
422
|
+
// Not valid JSON (or contains trailing code like `window.X = {...};`).
|
|
423
|
+
// Fall back to the generic line-aware sliding window so we never
|
|
424
|
+
// produce zero chunks for a non-empty file.
|
|
425
|
+
return chunkGeneric(content, fileMetadata, rules);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// If whole document fits, keep as one chunk
|
|
429
|
+
if (content.length <= maxSize) {
|
|
430
|
+
return [{
|
|
431
|
+
content,
|
|
432
|
+
metadata: {
|
|
433
|
+
chunkIndex: 0,
|
|
434
|
+
totalChunks: 1,
|
|
435
|
+
type: 'full-json',
|
|
436
|
+
fileName: fileMetadata.name
|
|
437
|
+
}
|
|
438
|
+
}];
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const chunks = [];
|
|
442
|
+
const push = (obj, label, type) => {
|
|
443
|
+
const snippet = JSON.stringify(obj, null, 2);
|
|
444
|
+
// If a single logical unit itself overflows, recurse (but only one level)
|
|
445
|
+
// then fall back to line-aware windowing. Prior behaviour atomised
|
|
446
|
+
// elements into per-field shards which were useless for retrieval.
|
|
447
|
+
if (snippet.length > maxSize) {
|
|
448
|
+
if (Array.isArray(obj)) {
|
|
449
|
+
// Emit each element as its own chunk — do NOT recurse deeper into
|
|
450
|
+
// object fields. A 15KB exchange stays whole as one 15KB chunk.
|
|
451
|
+
for (let j = 0; j < obj.length; j++) {
|
|
452
|
+
const esnip = JSON.stringify(obj[j], null, 2);
|
|
453
|
+
const elemLabel = `${label}[${j}]`;
|
|
454
|
+
if (esnip.length <= maxSize * 2) {
|
|
455
|
+
chunks.push({
|
|
456
|
+
content: `// ${elemLabel}
|
|
457
|
+
${esnip}`,
|
|
458
|
+
metadata: {
|
|
459
|
+
chunkIndex: chunks.length,
|
|
460
|
+
type: 'array-element',
|
|
461
|
+
title: elemLabel,
|
|
462
|
+
fileName: fileMetadata.name
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
} else {
|
|
466
|
+
// Truly enormous element — line-window it
|
|
467
|
+
const sub = chunkGeneric(esnip, fileMetadata, rules);
|
|
468
|
+
for (const c of sub) {
|
|
469
|
+
chunks.push({
|
|
470
|
+
content: `// ${elemLabel} (part ${chunks.length + 1})
|
|
471
|
+
${c.content}`,
|
|
472
|
+
metadata: {
|
|
473
|
+
chunkIndex: chunks.length,
|
|
474
|
+
type: 'array-element-overflow',
|
|
475
|
+
title: elemLabel,
|
|
476
|
+
fileName: fileMetadata.name
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if (obj && typeof obj === 'object') {
|
|
485
|
+
// Object with a too-big value: recurse ONE level, then stop.
|
|
486
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
487
|
+
push(v, `${label}.${k}`, 'object-key-nested');
|
|
488
|
+
}
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
// Primitive that somehow exceeds maxSize → sliding window with line boundaries
|
|
492
|
+
const sub = chunkGeneric(snippet, fileMetadata, rules);
|
|
493
|
+
for (const c of sub) {
|
|
494
|
+
chunks.push({
|
|
495
|
+
content: `// ${label}\n${c.content}`,
|
|
496
|
+
metadata: {
|
|
497
|
+
chunkIndex: chunks.length,
|
|
498
|
+
type: 'json-overflow',
|
|
499
|
+
title: label,
|
|
500
|
+
fileName: fileMetadata.name
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
chunks.push({
|
|
508
|
+
content: `// ${label}\n${snippet}`,
|
|
509
|
+
metadata: {
|
|
510
|
+
chunkIndex: chunks.length,
|
|
511
|
+
type,
|
|
512
|
+
title: label,
|
|
513
|
+
fileName: fileMetadata.name
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
if (Array.isArray(parsed)) {
|
|
519
|
+
// Merge small adjacent elements up to maxSize to avoid many tiny chunks
|
|
520
|
+
let batch = [];
|
|
521
|
+
let batchSize = 0;
|
|
522
|
+
let startIdx = 0;
|
|
523
|
+
for (let i = 0; i < parsed.length; i++) {
|
|
524
|
+
const elem = parsed[i];
|
|
525
|
+
const elemSize = JSON.stringify(elem, null, 2).length;
|
|
526
|
+
if (batchSize + elemSize > maxSize && batch.length > 0) {
|
|
527
|
+
push(batch, `[${startIdx}..${i - 1}]`, 'array-batch');
|
|
528
|
+
batch = [];
|
|
529
|
+
batchSize = 0;
|
|
530
|
+
startIdx = i;
|
|
531
|
+
}
|
|
532
|
+
batch.push(elem);
|
|
533
|
+
batchSize += elemSize;
|
|
534
|
+
}
|
|
535
|
+
if (batch.length > 0) {
|
|
536
|
+
push(batch, `[${startIdx}..${parsed.length - 1}]`, 'array-batch');
|
|
537
|
+
}
|
|
538
|
+
} else if (parsed && typeof parsed === 'object') {
|
|
539
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
540
|
+
push(value, key, 'object-key');
|
|
541
|
+
}
|
|
542
|
+
} else {
|
|
543
|
+
// Primitive root — shouldn't normally happen in our KB content, but be safe
|
|
544
|
+
return chunkGeneric(content, fileMetadata, rules);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Safety net: if the walk produced nothing (empty object/array with headers
|
|
548
|
+
// still counts as empty), fall back so we never return 0 chunks from a
|
|
549
|
+
// non-empty file.
|
|
550
|
+
if (chunks.length === 0) {
|
|
551
|
+
return chunkGeneric(content, fileMetadata, rules);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
chunks.forEach(c => { c.metadata.totalChunks = chunks.length; });
|
|
555
|
+
return chunks;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Try to extract an embedded JSON object from JS content like:
|
|
560
|
+
* window.X = { ... };
|
|
561
|
+
* module.exports = { ... };
|
|
562
|
+
* const X = { ... };
|
|
563
|
+
* export const X = { ... };
|
|
564
|
+
* export default { ... };
|
|
565
|
+
*
|
|
566
|
+
* Walks braces/brackets respecting string literals. Returns the captured
|
|
567
|
+
* JSON-ish text (still may be JS — trailing commas, unquoted keys) or null.
|
|
568
|
+
*
|
|
569
|
+
* Caller must JSON.parse() and handle failure gracefully.
|
|
570
|
+
*/
|
|
571
|
+
function extractTopLevelAssignmentLiteral(content) {
|
|
572
|
+
// Patterns (ordered): we match the line up to the first `{` or `[` that
|
|
573
|
+
// starts the value and then do a bracket walk to find the matching close.
|
|
574
|
+
const patterns = [
|
|
575
|
+
/\b(?:window|globalThis)\.\w+\s*=\s*(?=[{[])/,
|
|
576
|
+
/\bmodule\.exports\s*=\s*(?=[{[])/,
|
|
577
|
+
/\bexport\s+default\s*(?=[{[])/,
|
|
578
|
+
/\bexport\s+const\s+\w+\s*=\s*(?=[{[])/,
|
|
579
|
+
/\b(?:const|let|var)\s+\w+\s*=\s*(?=[{[])/
|
|
580
|
+
];
|
|
581
|
+
|
|
582
|
+
let start = -1;
|
|
583
|
+
for (const p of patterns) {
|
|
584
|
+
const m = p.exec(content);
|
|
585
|
+
if (m && (start === -1 || m.index < start)) {
|
|
586
|
+
start = m.index + m[0].length;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
if (start === -1) return null;
|
|
590
|
+
|
|
591
|
+
const open = content[start];
|
|
592
|
+
const close = open === '{' ? '}' : ']';
|
|
593
|
+
let depth = 0;
|
|
594
|
+
let inStr = null;
|
|
595
|
+
let escape = false;
|
|
596
|
+
for (let i = start; i < content.length; i++) {
|
|
597
|
+
const ch = content[i];
|
|
598
|
+
if (inStr) {
|
|
599
|
+
if (escape) { escape = false; continue; }
|
|
600
|
+
if (ch === '\\') { escape = true; continue; }
|
|
601
|
+
if (ch === inStr) { inStr = null; }
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
if (ch === '"' || ch === "'" || ch === '`') { inStr = ch; continue; }
|
|
605
|
+
if (ch === open) depth++;
|
|
606
|
+
else if (ch === close) {
|
|
607
|
+
depth--;
|
|
608
|
+
if (depth === 0) {
|
|
609
|
+
return content.substring(start, i + 1);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return null;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Split JavaScript/TypeScript source on top-level declarations.
|
|
618
|
+
*
|
|
619
|
+
* Handles these top-level patterns line-anchored:
|
|
620
|
+
* function foo(...) export async function foo(...)
|
|
621
|
+
* class Foo { ... } export class Foo { ... }
|
|
622
|
+
* export const foo = (...) => { const foo = function (...) {
|
|
623
|
+
* export default ... module.exports = ...
|
|
624
|
+
*
|
|
625
|
+
* Unlike the legacy splitByLogicalBoundaries() this walker:
|
|
626
|
+
* - Requires boundaries to start at column 0 (skips indented matches that
|
|
627
|
+
* live inside classes/objects/functions).
|
|
628
|
+
* - Does not impose a minimum chunk size (the 1000-char gate in the
|
|
629
|
+
* legacy walker silently swallowed boundaries inside large objects).
|
|
630
|
+
* - Uses overlap between adjacent chunks so declarations keep surrounding
|
|
631
|
+
* context for retrieval.
|
|
632
|
+
* - Always falls back to chunkGeneric on no-match so we never return 0.
|
|
633
|
+
*/
|
|
634
|
+
function chunkJsStructured(content, fileMetadata, rules) {
|
|
635
|
+
const maxSize = rules.maxCodeFileSize || DEFAULT_MAX_CODE_FILE_SIZE;
|
|
636
|
+
const overlap = rules.overlapSize || DEFAULT_OVERLAP_SIZE;
|
|
637
|
+
|
|
638
|
+
// Fast path: if the whole file fits, keep as one chunk.
|
|
639
|
+
if (content.length <= maxSize) {
|
|
640
|
+
return [{
|
|
641
|
+
content,
|
|
642
|
+
metadata: {
|
|
643
|
+
chunkIndex: 0,
|
|
644
|
+
totalChunks: 1,
|
|
645
|
+
type: 'full-file',
|
|
646
|
+
fileName: fileMetadata.name
|
|
647
|
+
}
|
|
648
|
+
}];
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Attempt to extract a single top-level assignment object literal (e.g.
|
|
652
|
+
// window.CONCORDANCE_SOURCE = {...};). If present and parseable as JSON,
|
|
653
|
+
// chunk the object as structured JSON — this is the dominant shape for
|
|
654
|
+
// data-embedded JS files. (Strict JSON.parse is used — this is a
|
|
655
|
+
// conservative best-effort; any failure falls through to the boundary
|
|
656
|
+
// walker.)
|
|
657
|
+
const literal = extractTopLevelAssignmentLiteral(content);
|
|
658
|
+
if (literal) {
|
|
659
|
+
try {
|
|
660
|
+
JSON.parse(literal);
|
|
661
|
+
return chunkStructuredJson(literal, fileMetadata, rules);
|
|
662
|
+
} catch {
|
|
663
|
+
// Not strict JSON (trailing commas, comments, expressions). Fall through.
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Top-level declaration boundary walker.
|
|
668
|
+
const lines = content.split('\n');
|
|
669
|
+
const patterns = [
|
|
670
|
+
/^export\s+(?:async\s+)?function\s+(\w+)/,
|
|
671
|
+
/^export\s+default\s+(?:async\s+)?function(?:\s+(\w+))?/,
|
|
672
|
+
/^export\s+class\s+(\w+)/,
|
|
673
|
+
/^export\s+const\s+(\w+)/,
|
|
674
|
+
/^export\s+default\s+/,
|
|
675
|
+
/^(?:async\s+)?function\s+(\w+)/,
|
|
676
|
+
/^class\s+(\w+)/,
|
|
677
|
+
/^(?:const|let|var)\s+(\w+)\s*=/,
|
|
678
|
+
/^module\.exports\s*=/,
|
|
679
|
+
/^(?:window|globalThis)\.(\w+)\s*=/
|
|
680
|
+
];
|
|
681
|
+
|
|
682
|
+
// Collect boundary line indices and the name at each boundary.
|
|
683
|
+
const boundaries = [];
|
|
684
|
+
for (let i = 0; i < lines.length; i++) {
|
|
685
|
+
const line = lines[i];
|
|
686
|
+
for (const pat of patterns) {
|
|
687
|
+
const m = line.match(pat);
|
|
688
|
+
if (m) {
|
|
689
|
+
boundaries.push({ line: i, name: m[1] || 'anonymous' });
|
|
690
|
+
break;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (boundaries.length === 0) {
|
|
696
|
+
// No top-level boundaries found — fall back to a line-aware sliding
|
|
697
|
+
// window. This is the key fix: we never return 0 chunks.
|
|
698
|
+
return chunkGeneric(content, fileMetadata, rules);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const chunks = [];
|
|
702
|
+
for (let b = 0; b < boundaries.length; b++) {
|
|
703
|
+
const startLine = boundaries[b].line;
|
|
704
|
+
const endLine = (b + 1 < boundaries.length) ? boundaries[b + 1].line : lines.length;
|
|
705
|
+
const sliceLines = lines.slice(startLine, endLine);
|
|
706
|
+
let slice = sliceLines.join('\n');
|
|
707
|
+
|
|
708
|
+
// Very large single boundary (e.g. a 400KB object literal) → window it
|
|
709
|
+
if (slice.length > maxSize) {
|
|
710
|
+
const sub = chunkGeneric(slice, fileMetadata, rules);
|
|
711
|
+
for (const c of sub) {
|
|
712
|
+
chunks.push({
|
|
713
|
+
content: c.content,
|
|
714
|
+
metadata: {
|
|
715
|
+
chunkIndex: chunks.length,
|
|
716
|
+
type: 'code-block-overflow',
|
|
717
|
+
name: boundaries[b].name,
|
|
718
|
+
fileName: fileMetadata.name
|
|
719
|
+
}
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Prepend overlap from previous slice for retrieval continuity
|
|
726
|
+
if (chunks.length > 0) {
|
|
727
|
+
const prev = chunks[chunks.length - 1].content;
|
|
728
|
+
const carry = prev.slice(-overlap);
|
|
729
|
+
slice = `// ...prior context...\n${carry}\n// --- boundary: ${boundaries[b].name} ---\n${slice}`;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
chunks.push({
|
|
733
|
+
content: slice,
|
|
734
|
+
metadata: {
|
|
735
|
+
chunkIndex: chunks.length,
|
|
736
|
+
type: 'code-block',
|
|
737
|
+
name: boundaries[b].name,
|
|
738
|
+
fileName: fileMetadata.name
|
|
739
|
+
}
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
chunks.forEach(c => { c.metadata.totalChunks = chunks.length; });
|
|
744
|
+
return chunks;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// ============ End M1 additions ============
|
|
748
|
+
|
|
322
749
|
/**
|
|
323
750
|
* Chunk a file's content based on its type
|
|
324
751
|
*
|
|
@@ -336,12 +763,26 @@ export function chunkFile(fileInfo, options = {}) {
|
|
|
336
763
|
overlapSize: options.overlapSize || DEFAULT_OVERLAP_SIZE
|
|
337
764
|
};
|
|
338
765
|
|
|
766
|
+
// M1 (2026-04-20): route structured formats through content-aware chunkers.
|
|
767
|
+
// For .js/.ts, try the structured walker first (handles embedded JSON
|
|
768
|
+
// literals like window.X = {...} and top-level declaration boundaries).
|
|
769
|
+
// For .json, always use chunkStructuredJson.
|
|
339
770
|
switch (fileCategory) {
|
|
340
|
-
case '
|
|
771
|
+
case 'structured-json':
|
|
772
|
+
return chunkStructuredJson(content, { name: file_name }, rules);
|
|
773
|
+
case 'code': {
|
|
774
|
+
const nm = (file_name || '').toLowerCase();
|
|
775
|
+
if (nm.endsWith('.js') || nm.endsWith('.mjs') || nm.endsWith('.cjs') ||
|
|
776
|
+
nm.endsWith('.ts') || nm.endsWith('.jsx') || nm.endsWith('.tsx')) {
|
|
777
|
+
return chunkJsStructured(content, { name: file_name }, rules);
|
|
778
|
+
}
|
|
341
779
|
return chunkCode(content, { name: file_name }, rules);
|
|
780
|
+
}
|
|
342
781
|
case 'docs':
|
|
343
782
|
case 'papers':
|
|
344
783
|
return chunkDocument(content, { name: file_name }, rules);
|
|
784
|
+
case 'training':
|
|
785
|
+
return chunkJsonl(content, { name: file_name });
|
|
345
786
|
default:
|
|
346
787
|
return chunkGeneric(content, { name: file_name }, rules);
|
|
347
788
|
}
|
|
@@ -375,18 +816,18 @@ function generateLocalFileId(relativePath) {
|
|
|
375
816
|
* the same file appearing in multiple KBs/Apps.
|
|
376
817
|
*
|
|
377
818
|
* @param {Object} chunk - { content, metadata }
|
|
378
|
-
* @param {Object} context - { community_id, app_id, kb_id, file_id, file_name }
|
|
819
|
+
* @param {Object} context - { community_id, app_id, kb_id, file_id, file_name, customMetadata }
|
|
379
820
|
* @returns {Object} Pinecone-ready chunk record
|
|
380
821
|
*/
|
|
381
822
|
function createChunkRecord(chunk, context) {
|
|
382
|
-
const { community_id, app_id, kb_id, file_id, file_name } = context;
|
|
823
|
+
const { community_id, app_id, kb_id, file_id, file_name, customMetadata } = context;
|
|
383
824
|
const chunk_idx = chunk.metadata.chunkIndex;
|
|
384
825
|
const text = chunk.content.trim();
|
|
385
|
-
|
|
826
|
+
|
|
386
827
|
// Composite ID — app_id is globally unique, no community_id prefix needed
|
|
387
828
|
const id = `${app_id}:${kb_id}:${file_id}:${chunk_idx}`;
|
|
388
|
-
|
|
389
|
-
|
|
829
|
+
|
|
830
|
+
const record = {
|
|
390
831
|
id,
|
|
391
832
|
text,
|
|
392
833
|
entity_type: 'CHUNK',
|
|
@@ -405,6 +846,37 @@ function createChunkRecord(chunk, context) {
|
|
|
405
846
|
chunk_title: chunk.metadata.title || chunk.metadata.name || null,
|
|
406
847
|
content_hash: computeContentHash(text) // For change detection
|
|
407
848
|
};
|
|
849
|
+
|
|
850
|
+
// Merge file-level custom metadata (passthrough to Pinecone)
|
|
851
|
+
// Pinecone supports: string, number, boolean, string[] as metadata types.
|
|
852
|
+
// Custom fields must not collide with reserved record fields.
|
|
853
|
+
if (customMetadata && typeof customMetadata === 'object') {
|
|
854
|
+
for (const [key, value] of Object.entries(customMetadata)) {
|
|
855
|
+
if (key in record) continue; // Don't overwrite reserved fields
|
|
856
|
+
const type = typeof value;
|
|
857
|
+
if (type === 'string' || type === 'number' || type === 'boolean') {
|
|
858
|
+
record[key] = value;
|
|
859
|
+
} else if (Array.isArray(value) && value.every(v => typeof v === 'string')) {
|
|
860
|
+
record[key] = value;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// Merge per-chunk JSONL fields (domain, role, difficulty, verified_by, id, etc.)
|
|
866
|
+
// These override file-level customMetadata but cannot overwrite reserved record fields.
|
|
867
|
+
if (chunk.metadata.jsonlFields && typeof chunk.metadata.jsonlFields === 'object') {
|
|
868
|
+
for (const [key, value] of Object.entries(chunk.metadata.jsonlFields)) {
|
|
869
|
+
if (key in record) continue;
|
|
870
|
+
const type = typeof value;
|
|
871
|
+
if (type === 'string' || type === 'number' || type === 'boolean') {
|
|
872
|
+
record[key] = value;
|
|
873
|
+
} else if (Array.isArray(value) && value.every(v => typeof v === 'string')) {
|
|
874
|
+
record[key] = value;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
return record;
|
|
408
880
|
}
|
|
409
881
|
|
|
410
882
|
/**
|
|
@@ -449,12 +921,12 @@ function getFileId(fileName, fileMetadata, kbId) {
|
|
|
449
921
|
* Uses .descix_metadata.json to get Drive IDs for files.
|
|
450
922
|
* Files without Drive IDs get a local:hash ID for tracking.
|
|
451
923
|
*
|
|
452
|
-
* @param {Object} config - { workspaceRoot, communityId, appId, kbId, localPath }
|
|
924
|
+
* @param {Object} config - { workspaceRoot, communityId, appId, kbId, localPath, customMetadata }
|
|
453
925
|
* @param {Object} options - { maxChunkSize, overlapSize, verbose }
|
|
454
926
|
* @returns {Promise<{files: number, totalChunks: number}>}
|
|
455
927
|
*/
|
|
456
928
|
export async function processKb(config, options = {}) {
|
|
457
|
-
const { workspaceRoot, communityId, appId, kbId = 'General', localPath } = config;
|
|
929
|
+
const { workspaceRoot, communityId, appId, kbId = 'General', localPath, customMetadata } = config;
|
|
458
930
|
const { verbose = false } = options;
|
|
459
931
|
|
|
460
932
|
// Find source directory (kb/General for converted files)
|
|
@@ -476,9 +948,9 @@ export async function processKb(config, options = {}) {
|
|
|
476
948
|
|
|
477
949
|
// Read files (exclude metadata and hidden files)
|
|
478
950
|
const files = await fs.readdir(srcDir);
|
|
479
|
-
const textFiles = files.filter(f =>
|
|
480
|
-
!f.startsWith('.') &&
|
|
481
|
-
(f.endsWith('.md') || f.endsWith('.txt') || f.endsWith('.csv'))
|
|
951
|
+
const textFiles = files.filter(f =>
|
|
952
|
+
!f.startsWith('.') &&
|
|
953
|
+
(f.endsWith('.md') || f.endsWith('.txt') || f.endsWith('.csv') || f.endsWith('.jsonl'))
|
|
482
954
|
);
|
|
483
955
|
|
|
484
956
|
if (textFiles.length === 0) {
|
|
@@ -501,18 +973,20 @@ export async function processKb(config, options = {}) {
|
|
|
501
973
|
let mimeType = 'text/plain';
|
|
502
974
|
if (fileName.endsWith('.md')) mimeType = 'text/markdown';
|
|
503
975
|
else if (fileName.endsWith('.csv')) mimeType = 'text/csv';
|
|
976
|
+
else if (fileName.endsWith('.jsonl')) mimeType = 'application/jsonl';
|
|
504
977
|
|
|
505
978
|
// Generate chunks
|
|
506
979
|
const rawChunks = chunkFile({ file_name: fileName, mime_type: mimeType, content }, options);
|
|
507
980
|
|
|
508
981
|
// Convert to Pinecone-ready records
|
|
509
|
-
const chunks = rawChunks.map(chunk =>
|
|
982
|
+
const chunks = rawChunks.map(chunk =>
|
|
510
983
|
createChunkRecord(chunk, {
|
|
511
984
|
community_id: communityId,
|
|
512
985
|
app_id: appId,
|
|
513
986
|
kb_id: kbId,
|
|
514
987
|
file_id,
|
|
515
|
-
file_name: fileName
|
|
988
|
+
file_name: fileName,
|
|
989
|
+
customMetadata
|
|
516
990
|
})
|
|
517
991
|
);
|
|
518
992
|
|