@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.
Files changed (100) hide show
  1. package/LICENSE.md +671 -0
  2. package/README.md +43 -4
  3. package/agent-assets/guides/README.md +2 -2
  4. package/agent-assets/guides/dynamic-site-pattern.md +29 -14
  5. package/agent-assets/guides/knowledge-only-pattern.md +5 -5
  6. package/agent-assets/guides/microservice-pattern.md +48 -4
  7. package/agent-assets/guides/static-site-pattern.md +8 -0
  8. package/agent-assets/instructions/app-asset-validation.md +5 -5
  9. package/agent-assets/instructions/onboarding-flow.md +4 -4
  10. package/agent-assets/instructions/standard-folder-structure.md +20 -11
  11. package/agent-assets/reference/app-creation.md +42 -20
  12. package/agent-assets/reference/architecture.md +24 -22
  13. package/agent-assets/reference/local-dev.md +230 -67
  14. package/agent-assets/reference/mcp-integration.md +9 -9
  15. package/agent-assets/reference/platform-api-consumer.md +244 -0
  16. package/agent-assets/reference/workspace-config.md +7 -7
  17. package/agent-assets/rules/descix_mcp.template.mdc +8 -8
  18. package/bin/descix.js +2559 -1435
  19. package/bin/kb-deny-lint.mjs +91 -0
  20. package/bin/mcp-server.js +175 -91
  21. package/lib/agent-files.js +37 -25
  22. package/lib/api-client.js +223 -56
  23. package/lib/auth-guard.js +13 -3
  24. package/lib/chat-session.js +187 -0
  25. package/lib/cli-version.js +59 -0
  26. package/lib/commands/airdrop.js +251 -0
  27. package/lib/commands/auth.js +254 -72
  28. package/lib/commands/briefer/errors.js +88 -0
  29. package/lib/commands/briefer/index.js +319 -0
  30. package/lib/commands/briefer/sources/canonical-sources.js +129 -0
  31. package/lib/commands/briefer/sources/entitlements.js +209 -0
  32. package/lib/commands/briefer/sources/environments.js +205 -0
  33. package/lib/commands/briefer/sources/identifiers.js +214 -0
  34. package/lib/commands/briefer/sources/microservice-deploy.js +267 -0
  35. package/lib/commands/briefer/sources/routing.js +275 -0
  36. package/lib/commands/briefer/sources/what-is-not.js +223 -0
  37. package/lib/commands/briefer/stitcher.js +134 -0
  38. package/lib/commands/briefer/util/source-reader.js +502 -0
  39. package/lib/commands/config.js +210 -90
  40. package/lib/commands/corpus.js +978 -0
  41. package/lib/commands/credits.js +122 -0
  42. package/lib/commands/health.js +627 -0
  43. package/lib/commands/kb-list-render.js +32 -0
  44. package/lib/commands/kb.js +190 -413
  45. package/lib/commands/mcp.js +5 -5
  46. package/lib/commands/microservice-restart.js +359 -0
  47. package/lib/commands/model-config.js +313 -0
  48. package/lib/commands/retired-kb-sync.js +134 -0
  49. package/lib/commands/serve.js +26 -1
  50. package/lib/commands/status.js +23 -51
  51. package/lib/commands/update.js +36 -107
  52. package/lib/core/Chunker.js +491 -17
  53. package/lib/core/CorpusDenyLint.js +326 -0
  54. package/lib/core/CorpusWalker.js +357 -0
  55. package/lib/core/Hydrator.js +75 -29
  56. package/lib/core/ManifestLoader.js +533 -0
  57. package/lib/core/SiteWalker.js +218 -0
  58. package/lib/core/Syncer.js +163 -11
  59. package/lib/diagnostic-log.js +41 -0
  60. package/lib/environment-report.js +100 -0
  61. package/lib/global-config.js +4 -1
  62. package/lib/oauth-client.js +164 -0
  63. package/lib/origin.js +143 -0
  64. package/lib/service-api-client.js +95 -0
  65. package/lib/wallet-environment.js +49 -0
  66. package/lib/wallet-file.js +16 -2
  67. package/lib/wizard/setup.js +10 -9
  68. package/lib/workspace-config.js +380 -235
  69. package/lib/workspace-identity.js +92 -0
  70. package/lib/workspace-utils.js +41 -61
  71. package/package.json +19 -6
  72. package/templates/agent-claude.md +157 -9
  73. package/templates/agent-cline.md +68 -8
  74. package/templates/agent-copilot.md +68 -8
  75. package/templates/agent-cursor.md +68 -8
  76. package/templates/alignment-schema.json +20 -0
  77. package/templates/cloud-brain-hook.sh +20 -0
  78. package/templates/drive/README.md +1 -1
  79. package/templates/drive_templates/README.md +2 -2
  80. package/templates/scaffolds/README.md +6 -10
  81. package/templates/scaffolds/microservice/SERVICE_README_sdk.md +30 -0
  82. package/templates/scaffolds/microservice/app.js +51 -10
  83. package/templates/scaffolds/microservice/app.yaml +2 -2
  84. package/templates/scaffolds/microservice/defaults-config-dev.json +4 -0
  85. package/templates/scaffolds/microservice/dev-overrides.example.json +5 -0
  86. package/templates/scaffolds/microservice/manifest.json +0 -1
  87. package/templates/scaffolds/microservice/package.json +3 -3
  88. package/templates/scaffolds/microservice/scripts/register.js +56 -41
  89. package/templates/scaffolds/microservice/services/mcpClient.js +55 -65
  90. package/templates/scaffolds/microservice/services/utils.js +20 -238
  91. package/templates/scaffolds/microservice/templates/SERVICE_README_TEMPLATE.md +24 -0
  92. package/lib/commands/app-wizard.js +0 -627
  93. package/lib/commands/folder.js +0 -291
  94. package/lib/core/PathContext.js +0 -335
  95. package/templates/scaffolds/microservice/dev-overrides.json.example +0 -5
  96. package/templates/scaffolds/site/DeSciXAppSDK.js +0 -62
  97. package/templates/scaffolds/site/README.md +0 -51
  98. package/templates/scaffolds/site/app.js +0 -22
  99. package/templates/scaffolds/site/index.html +0 -42
  100. package/templates/scaffolds/site/styles.css +0 -123
@@ -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 'code':
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
- return {
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