@duckcodeailabs/dql-agent 1.6.32 → 1.6.34

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 (144) hide show
  1. package/dist/agent-run-engine.d.ts +26 -8
  2. package/dist/agent-run-engine.d.ts.map +1 -1
  3. package/dist/agent-run-engine.js +46 -7
  4. package/dist/agent-run-engine.js.map +1 -1
  5. package/dist/agent-run-gates.d.ts.map +1 -1
  6. package/dist/agent-run-gates.js +15 -3
  7. package/dist/agent-run-gates.js.map +1 -1
  8. package/dist/agent-run-planner.d.ts.map +1 -1
  9. package/dist/agent-run-planner.js +14 -3
  10. package/dist/agent-run-planner.js.map +1 -1
  11. package/dist/agentic/answer-contract.d.ts +37 -0
  12. package/dist/agentic/answer-contract.d.ts.map +1 -0
  13. package/dist/agentic/answer-contract.js +47 -0
  14. package/dist/agentic/answer-contract.js.map +1 -0
  15. package/dist/agentic/tool-loop.d.ts +48 -0
  16. package/dist/agentic/tool-loop.d.ts.map +1 -0
  17. package/dist/agentic/tool-loop.js +181 -0
  18. package/dist/agentic/tool-loop.js.map +1 -0
  19. package/dist/agentic/toolset.d.ts +40 -0
  20. package/dist/agentic/toolset.d.ts.map +1 -0
  21. package/dist/agentic/toolset.js +265 -0
  22. package/dist/agentic/toolset.js.map +1 -0
  23. package/dist/answer-loop.d.ts +25 -2
  24. package/dist/answer-loop.d.ts.map +1 -1
  25. package/dist/answer-loop.js +368 -72
  26. package/dist/answer-loop.js.map +1 -1
  27. package/dist/cascade/budgets.d.ts +20 -2
  28. package/dist/cascade/budgets.d.ts.map +1 -1
  29. package/dist/cascade/budgets.js +76 -43
  30. package/dist/cascade/budgets.js.map +1 -1
  31. package/dist/cascade/route-policy.d.ts +1 -1
  32. package/dist/cascade/route-policy.d.ts.map +1 -1
  33. package/dist/cascade/route-policy.js +1 -0
  34. package/dist/cascade/route-policy.js.map +1 -1
  35. package/dist/embeddings/provider.d.ts +67 -4
  36. package/dist/embeddings/provider.d.ts.map +1 -1
  37. package/dist/embeddings/provider.js +167 -6
  38. package/dist/embeddings/provider.js.map +1 -1
  39. package/dist/hints/correction-eval.d.ts +30 -0
  40. package/dist/hints/correction-eval.d.ts.map +1 -0
  41. package/dist/hints/correction-eval.js +63 -0
  42. package/dist/hints/correction-eval.js.map +1 -0
  43. package/dist/hints/staleness.d.ts +30 -0
  44. package/dist/hints/staleness.d.ts.map +1 -0
  45. package/dist/hints/staleness.js +38 -0
  46. package/dist/hints/staleness.js.map +1 -0
  47. package/dist/hints/store.d.ts.map +1 -1
  48. package/dist/hints/store.js +3 -32
  49. package/dist/hints/store.js.map +1 -1
  50. package/dist/index.d.ts +39 -6
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +143 -7
  53. package/dist/index.js.map +1 -1
  54. package/dist/intent-controller.d.ts.map +1 -1
  55. package/dist/intent-controller.js +15 -3
  56. package/dist/intent-controller.js.map +1 -1
  57. package/dist/kg/sqlite-fts.d.ts +13 -0
  58. package/dist/kg/sqlite-fts.d.ts.map +1 -1
  59. package/dist/kg/sqlite-fts.js +75 -47
  60. package/dist/kg/sqlite-fts.js.map +1 -1
  61. package/dist/memory/fts-query.d.ts +37 -4
  62. package/dist/memory/fts-query.d.ts.map +1 -1
  63. package/dist/memory/fts-query.js +53 -12
  64. package/dist/memory/fts-query.js.map +1 -1
  65. package/dist/metadata/analysis-planner.js +45 -10
  66. package/dist/metadata/analysis-planner.js.map +1 -1
  67. package/dist/metadata/block-adapt.d.ts +37 -0
  68. package/dist/metadata/block-adapt.d.ts.map +1 -0
  69. package/dist/metadata/block-adapt.js +89 -0
  70. package/dist/metadata/block-adapt.js.map +1 -0
  71. package/dist/metadata/catalog.d.ts +39 -0
  72. package/dist/metadata/catalog.d.ts.map +1 -1
  73. package/dist/metadata/catalog.js +361 -41
  74. package/dist/metadata/catalog.js.map +1 -1
  75. package/dist/metadata/example-match.d.ts +30 -0
  76. package/dist/metadata/example-match.d.ts.map +1 -0
  77. package/dist/metadata/example-match.js +69 -0
  78. package/dist/metadata/example-match.js.map +1 -0
  79. package/dist/metadata/grain-ledger.d.ts +51 -0
  80. package/dist/metadata/grain-ledger.d.ts.map +1 -0
  81. package/dist/metadata/grain-ledger.js +174 -0
  82. package/dist/metadata/grain-ledger.js.map +1 -0
  83. package/dist/metadata/metric-match.d.ts +21 -1
  84. package/dist/metadata/metric-match.d.ts.map +1 -1
  85. package/dist/metadata/metric-match.js +157 -21
  86. package/dist/metadata/metric-match.js.map +1 -1
  87. package/dist/metadata/sql-grounding.d.ts +4 -2
  88. package/dist/metadata/sql-grounding.d.ts.map +1 -1
  89. package/dist/metadata/sql-grounding.js +4 -2
  90. package/dist/metadata/sql-grounding.js.map +1 -1
  91. package/dist/metadata/sql-retrieval.d.ts +3 -2
  92. package/dist/metadata/sql-retrieval.d.ts.map +1 -1
  93. package/dist/metadata/sql-retrieval.js +23 -5
  94. package/dist/metadata/sql-retrieval.js.map +1 -1
  95. package/dist/propose/build-from-prompt.d.ts.map +1 -1
  96. package/dist/propose/build-from-prompt.js +10 -4
  97. package/dist/propose/build-from-prompt.js.map +1 -1
  98. package/dist/propose/failure-analysis.d.ts +48 -0
  99. package/dist/propose/failure-analysis.d.ts.map +1 -0
  100. package/dist/propose/failure-analysis.js +71 -0
  101. package/dist/propose/failure-analysis.js.map +1 -0
  102. package/dist/propose/join-mining.d.ts +18 -0
  103. package/dist/propose/join-mining.d.ts.map +1 -0
  104. package/dist/propose/join-mining.js +74 -0
  105. package/dist/propose/join-mining.js.map +1 -0
  106. package/dist/providers/claude.d.ts.map +1 -1
  107. package/dist/providers/claude.js +36 -1
  108. package/dist/providers/claude.js.map +1 -1
  109. package/dist/providers/index.d.ts +2 -2
  110. package/dist/providers/index.d.ts.map +1 -1
  111. package/dist/providers/index.js +1 -1
  112. package/dist/providers/index.js.map +1 -1
  113. package/dist/providers/openai.d.ts.map +1 -1
  114. package/dist/providers/openai.js +34 -1
  115. package/dist/providers/openai.js.map +1 -1
  116. package/dist/providers/reasoning-effort.d.ts +27 -0
  117. package/dist/providers/reasoning-effort.d.ts.map +1 -1
  118. package/dist/providers/reasoning-effort.js +32 -0
  119. package/dist/providers/reasoning-effort.js.map +1 -1
  120. package/dist/providers/types.d.ts +2 -1
  121. package/dist/providers/types.d.ts.map +1 -1
  122. package/dist/research-loop.d.ts.map +1 -1
  123. package/dist/research-loop.js +9 -2
  124. package/dist/research-loop.js.map +1 -1
  125. package/dist/semantic-bridge/compose.d.ts.map +1 -1
  126. package/dist/semantic-bridge/compose.js +147 -20
  127. package/dist/semantic-bridge/compose.js.map +1 -1
  128. package/dist/skills/bootstrap.d.ts +57 -0
  129. package/dist/skills/bootstrap.d.ts.map +1 -0
  130. package/dist/skills/bootstrap.js +260 -0
  131. package/dist/skills/bootstrap.js.map +1 -0
  132. package/dist/skills/defaults.d.ts +2 -2
  133. package/dist/skills/defaults.js +2 -2
  134. package/dist/skills/domain-skills.d.ts +24 -0
  135. package/dist/skills/domain-skills.d.ts.map +1 -0
  136. package/dist/skills/domain-skills.js +76 -0
  137. package/dist/skills/domain-skills.js.map +1 -0
  138. package/dist/skills/loader.d.ts +46 -8
  139. package/dist/skills/loader.d.ts.map +1 -1
  140. package/dist/skills/loader.js +186 -41
  141. package/dist/skills/loader.js.map +1 -1
  142. package/dist/tools/registry.js +12 -12
  143. package/dist/tools/registry.js.map +1 -1
  144. package/package.json +4 -4
@@ -19,6 +19,9 @@ import { buildRuntimeValueIndex, normalizeValueIndexText, } from '../grounding/v
19
19
  import { grainMatches, requestedGrainFromPlan, } from './grain-gate.js';
20
20
  import { certifiedFitAllowsTier1, evaluateCertifiedBlockFit, } from './block-fit.js';
21
21
  import { retrieveScopedHints } from '../hints/retrieval.js';
22
+ import { buildFtsMatch, sanitizeFtsQuery } from '../memory/fts-query.js';
23
+ import { envEmbeddingProvider } from '../embeddings/provider.js';
24
+ import { matchExampleParaphrase } from './example-match.js';
22
25
  const require = createRequire(import.meta.url);
23
26
  let databaseCtor = null;
24
27
  function loadDatabase() {
@@ -27,6 +30,39 @@ function loadDatabase() {
27
30
  }
28
31
  const SCHEMA_SHAPE_CACHE_LIMIT = 64;
29
32
  const schemaShapeCandidateCache = new Map();
33
+ /**
34
+ * Extract single-column `unique` dbt tests, keyed by model name (W5.3). A column
35
+ * a `unique` test guards is a grain key, which feeds the grain ledger's fan-out
36
+ * detection — the common way real dbt repos declare keys. Handles both the
37
+ * `attached_node` (newer dbt) and `depends_on` shapes, and top-level or kwargs
38
+ * column names.
39
+ */
40
+ export function extractDbtUniqueColumns(nodes) {
41
+ const byModel = new Map();
42
+ for (const node of Object.values(nodes)) {
43
+ if (node.resource_type !== 'test')
44
+ continue;
45
+ const testMetadata = node.test_metadata;
46
+ if (stringValue(testMetadata?.name) !== 'unique')
47
+ continue;
48
+ const column = stringValue(node.column_name) ?? stringValue(testMetadata?.kwargs?.column_name);
49
+ if (!column)
50
+ continue;
51
+ const dependsOn = node.depends_on?.nodes;
52
+ const attached = stringValue(node.attached_node)
53
+ ?? (Array.isArray(dependsOn) ? dependsOn.map(String).find((ref) => ref.startsWith('model.')) : undefined);
54
+ if (!attached)
55
+ continue;
56
+ const modelName = attached.split('.').at(-1);
57
+ if (!modelName)
58
+ continue;
59
+ const existing = byModel.get(modelName) ?? [];
60
+ if (!existing.includes(column))
61
+ existing.push(column);
62
+ byModel.set(modelName, existing);
63
+ }
64
+ return byModel;
65
+ }
30
66
  const OBJECT_PRIORITY = {
31
67
  dql_block: 1,
32
68
  dql_block_output: 1.5,
@@ -74,7 +110,15 @@ export async function ensureMetadataCatalogFresh(projectRoot, options = {}) {
74
110
  fingerprint: snapshot.fingerprint,
75
111
  };
76
112
  }
77
- catalog.rebuild(snapshot);
113
+ // W3.4 — incremental reindex when prior state exists (only re-tokenizes changed
114
+ // sources' FTS); `force` does a clean full rebuild. Proven equal to a full
115
+ // rebuild by incremental-reindex.test.ts.
116
+ if (options.force) {
117
+ catalog.rebuild(snapshot);
118
+ }
119
+ else {
120
+ catalog.rebuildIncremental(snapshot);
121
+ }
78
122
  return {
79
123
  path: defaultMetadataPath(projectRoot),
80
124
  refreshed: true,
@@ -221,7 +265,7 @@ export async function buildLocalContextPack(projectRoot, request) {
221
265
  const trustLabel = deriveTrust(objects);
222
266
  const citations = buildCitations(objects, contextEdges);
223
267
  const evidenceSummaries = buildEvidenceSummaries(objects, contextEdges, queryRuns, diagnostics);
224
- const allowedSqlContext = sortAllowedSqlContextForAnalysisPlan(buildAllowedSqlContext(objects, contextEdges), questionPlan);
268
+ const allowedSqlContext = sortAllowedSqlContextForAnalysisPlan(buildAllowedSqlContext(objects, contextEdges, tokenizeQuestionForColumns(request.question)), questionPlan);
225
269
  const selectedRelations = allowedSqlContext.relations.slice(0, 24).map((relation, index) => {
226
270
  const scored = scoreAllowedSqlRelationWithAnalysisPlan(relation, questionPlan);
227
271
  return {
@@ -362,6 +406,16 @@ export function recordRuntimeSchemaSnapshot(projectRoot, snapshot) {
362
406
  catalog.close();
363
407
  }
364
408
  }
409
+ /** The most-recent stored live-warehouse schema snapshot for a project, or null. */
410
+ export function latestRuntimeSchemaSnapshotForProject(projectRoot) {
411
+ const catalog = openMetadataCatalog(projectRoot);
412
+ try {
413
+ return catalog.latestRuntimeSchemaSnapshot();
414
+ }
415
+ finally {
416
+ catalog.close();
417
+ }
418
+ }
365
419
  export function buildMetadataSnapshot(projectRoot, manifest, semanticLayer) {
366
420
  const manifestGraph = buildKGFromManifest(manifest);
367
421
  const semanticGraph = buildKGFromSemanticLayer(semanticLayer);
@@ -643,23 +697,130 @@ export class MetadataCatalog {
643
697
  });
644
698
  txn();
645
699
  }
700
+ /**
701
+ * Incremental reindex (W3.4). Only re-inserts objects + FTS rows for sources
702
+ * whose per-source fingerprint changed (or vanished); unchanged sources' rows are
703
+ * left in place (skipping the expensive FTS re-tokenization at scale). Edges,
704
+ * diagnostics, domain shards, and sourceless objects are always rebuilt because
705
+ * they cross source boundaries. Provably equal to a full rebuild for the semantic
706
+ * content (object keys/types/names/domains/payloads + FTS + edges); only the
707
+ * `updated_at`/`built_at` timestamps of untouched rows differ. Falls back to a
708
+ * full rebuild when there is no prior fingerprint state.
709
+ */
710
+ rebuildIncremental(snapshot) {
711
+ const stored = new Map();
712
+ for (const row of this.db.prepare('SELECT source_path, fingerprint FROM metadata_source_fingerprints').all()) {
713
+ stored.set(row.source_path, row.fingerprint);
714
+ }
715
+ if (stored.size === 0) {
716
+ this.rebuild(snapshot);
717
+ return { mode: 'full', changedSources: 0 };
718
+ }
719
+ const now = new Date().toISOString();
720
+ const incoming = buildSourceFingerprints(snapshot.objects, now);
721
+ const incomingSources = new Set(incoming.map((f) => f.sourcePath));
722
+ const changedSources = new Set();
723
+ for (const fingerprint of incoming) {
724
+ if (stored.get(fingerprint.sourcePath) !== fingerprint.fingerprint)
725
+ changedSources.add(fingerprint.sourcePath);
726
+ }
727
+ const removedSources = [...stored.keys()].filter((source) => !incomingSources.has(source));
728
+ const sourceOf = (object) => object.sourcePath ?? object.sourceSystem ?? null;
729
+ const domainShards = buildDomainShards(snapshot.objects, now);
730
+ const insertObject = this.db.prepare(`
731
+ INSERT INTO metadata_objects (
732
+ object_key, object_type, name, full_name, domain, owner, status,
733
+ description, source_path, source_system, payload_json, updated_at
734
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
735
+ const insertFts = this.db.prepare(`
736
+ INSERT INTO metadata_fts (object_key, name, full_name, description, domain, owner, payload)
737
+ VALUES (?, ?, ?, ?, ?, ?, ?)`);
738
+ const insertEdge = this.db.prepare(`
739
+ INSERT OR IGNORE INTO metadata_edges (edge_type, from_key, to_key, confidence, payload_json, created_at)
740
+ VALUES (?, ?, ?, ?, ?, ?)`);
741
+ const insertDiagnostic = this.db.prepare(`
742
+ INSERT INTO metadata_diagnostics (id, kind, severity, message, object_key, file_path, created_at)
743
+ VALUES (?, ?, ?, ?, ?, ?, ?)`);
744
+ const insertSourceFingerprint = this.db.prepare(`
745
+ INSERT OR REPLACE INTO metadata_source_fingerprints (source_path, fingerprint, object_count, updated_at)
746
+ VALUES (?, ?, ?, ?)`);
747
+ const insertDomainShard = this.db.prepare(`
748
+ INSERT OR REPLACE INTO metadata_domain_shards (
749
+ domain, object_count, block_count, certified_block_count,
750
+ semantic_metric_count, dbt_object_count, updated_at
751
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)`);
752
+ const setState = this.db.prepare('INSERT OR REPLACE INTO metadata_state (key, value) VALUES (?, ?)');
753
+ const deleteFtsForSource = this.db.prepare('DELETE FROM metadata_fts WHERE object_key IN (SELECT object_key FROM metadata_objects WHERE COALESCE(source_path, source_system) = ?)');
754
+ const deleteObjectsForSource = this.db.prepare('DELETE FROM metadata_objects WHERE COALESCE(source_path, source_system) = ?');
755
+ const txn = this.db.transaction(() => {
756
+ // Drop changed + removed sources' rows, and ALL sourceless rows (never
757
+ // fingerprinted, so always refreshed).
758
+ for (const source of [...changedSources, ...removedSources]) {
759
+ deleteFtsForSource.run(source);
760
+ deleteObjectsForSource.run(source);
761
+ }
762
+ this.db.prepare('DELETE FROM metadata_fts WHERE object_key IN (SELECT object_key FROM metadata_objects WHERE source_path IS NULL AND source_system IS NULL)').run();
763
+ this.db.prepare('DELETE FROM metadata_objects WHERE source_path IS NULL AND source_system IS NULL').run();
764
+ // Re-insert only objects from changed sources or sourceless; unchanged
765
+ // sources' rows are left untouched.
766
+ for (const object of snapshot.objects) {
767
+ const source = sourceOf(object);
768
+ if (source !== null && !changedSources.has(source))
769
+ continue;
770
+ const payload = object.payload ?? {};
771
+ insertObject.run(object.objectKey, object.objectType, object.name, object.fullName ?? null, object.domain ?? null, object.owner ?? null, object.status ?? null, object.description ?? null, object.sourcePath ?? null, object.sourceSystem ?? null, JSON.stringify(payload), object.updatedAt ?? now);
772
+ insertFts.run(object.objectKey, object.name, object.fullName ?? '', object.description ?? '', object.domain ?? '', object.owner ?? '', JSON.stringify(payload));
773
+ }
774
+ // Cross-source tables: rebuild fully.
775
+ this.db.prepare('DELETE FROM metadata_edges').run();
776
+ for (const edge of snapshot.edges) {
777
+ insertEdge.run(edge.edgeType, edge.fromKey, edge.toKey, edge.confidence ?? 1, JSON.stringify(edge.payload ?? {}), now);
778
+ }
779
+ this.db.prepare('DELETE FROM metadata_diagnostics').run();
780
+ for (const diagnostic of snapshot.diagnostics) {
781
+ insertDiagnostic.run(diagnosticId(diagnostic), diagnostic.kind, diagnostic.severity, diagnostic.message, diagnostic.objectKey ?? null, diagnostic.filePath ?? null, now);
782
+ }
783
+ this.db.prepare('DELETE FROM metadata_domain_shards').run();
784
+ for (const shard of domainShards) {
785
+ insertDomainShard.run(shard.domain, shard.objectCount, shard.blockCount, shard.certifiedBlockCount, shard.semanticMetricCount, shard.dbtObjectCount, shard.updatedAt);
786
+ }
787
+ for (const source of removedSources) {
788
+ this.db.prepare('DELETE FROM metadata_source_fingerprints WHERE source_path = ?').run(source);
789
+ }
790
+ for (const fingerprint of incoming) {
791
+ insertSourceFingerprint.run(fingerprint.sourcePath, fingerprint.fingerprint, fingerprint.objectCount, fingerprint.updatedAt);
792
+ }
793
+ setState.run('built_at', now);
794
+ setState.run('fingerprint', snapshot.fingerprint);
795
+ setState.run('project_root', snapshot.projectRoot);
796
+ setState.run('object_count', String(snapshot.objects.length));
797
+ setState.run('edge_count', String(snapshot.edges.length));
798
+ setState.run('source_fingerprint_count', String(incoming.length));
799
+ setState.run('domain_shard_count', String(domainShards.length));
800
+ setState.run('diagnostics_json', JSON.stringify(snapshot.diagnostics));
801
+ setState.run('compile_conflicts_json', JSON.stringify(snapshot.compileConflicts ?? []));
802
+ setState.run('manifest_generated_at', snapshot.manifest.generatedAt);
803
+ });
804
+ txn();
805
+ return { mode: 'incremental', changedSources: changedSources.size };
806
+ }
646
807
  searchObjects(options) {
647
808
  const { query, objectTypes, domain, limit = 40 } = options;
648
- const sanitized = sanitizeFtsQuery(query);
649
- if (!sanitized)
809
+ const match = buildFtsMatch(query, { prefix: true });
810
+ if (!match.or)
650
811
  return this.listObjects({ objectTypes, domain, limit });
651
812
  const filters = [];
652
- const params = [sanitized];
813
+ const extraParams = [];
653
814
  if (objectTypes && objectTypes.length > 0) {
654
815
  filters.push(`o.object_type IN (${objectTypes.map(() => '?').join(', ')})`);
655
- params.push(...objectTypes);
816
+ extraParams.push(...objectTypes);
656
817
  }
657
818
  if (domain) {
658
819
  filters.push('o.domain = ?');
659
- params.push(domain);
820
+ extraParams.push(domain);
660
821
  }
661
822
  const whereExtra = filters.length > 0 ? ` AND ${filters.join(' AND ')}` : '';
662
- const rows = this.db.prepare(`
823
+ const runMatch = (matchExpr) => this.db.prepare(`
663
824
  SELECT o.*,
664
825
  bm25(metadata_fts) AS rank,
665
826
  snippet(metadata_fts, -1, '<mark>', '</mark>', '...', 12) AS snip
@@ -668,7 +829,15 @@ export class MetadataCatalog {
668
829
  WHERE metadata_fts MATCH ?${whereExtra}
669
830
  ORDER BY rank
670
831
  LIMIT ?
671
- `).all(...params, limit);
832
+ `).all(matchExpr, ...extraParams, limit);
833
+ // Precision-first, recall-preserving UNION: all-terms-co-occur (AND) matches
834
+ // lead, then OR-of-terms matches fill the remaining budget. A doc mentioning
835
+ // only one term (a relevant context column) still surfaces, while a doc
836
+ // containing every term ranks ahead of it. Dedup by object_key.
837
+ const andRows = match.and ? runMatch(match.and) : [];
838
+ const seen = new Set(andRows.map((row) => row.object_key));
839
+ const orRows = runMatch(match.or).filter((row) => !seen.has(row.object_key));
840
+ const rows = [...andRows, ...orRows].slice(0, limit);
672
841
  return rows.map((row) => ({
673
842
  ...rowToObject(row),
674
843
  score: row.rank ? 1 / (1 + Math.max(0, row.rank)) : 1,
@@ -839,6 +1008,14 @@ export class MetadataCatalog {
839
1008
  `);
840
1009
  const txn = this.db.transaction(() => {
841
1010
  insertSnapshot.run(id, cleanSnapshot.source ?? null, JSON.stringify(cleanSnapshot), capturedAt);
1011
+ // Only the most-recent snapshot is ever read; rows otherwise accumulate one per
1012
+ // question forever. Keep a small margin and prune the rest (P7).
1013
+ this.db.prepare(`
1014
+ DELETE FROM runtime_schema_snapshots
1015
+ WHERE id NOT IN (
1016
+ SELECT id FROM runtime_schema_snapshots ORDER BY captured_at DESC LIMIT 5
1017
+ )
1018
+ `).run();
842
1019
  this.db.prepare('DELETE FROM runtime_value_fts').run();
843
1020
  this.db.prepare('DELETE FROM runtime_value_index').run();
844
1021
  for (const entry of valueEntries) {
@@ -853,7 +1030,9 @@ export class MetadataCatalog {
853
1030
  }
854
1031
  searchRuntimeValues(terms, limit = 40) {
855
1032
  const cleanTerms = uniqueStrings(terms.map(normalizeValueIndexText).filter(Boolean)).slice(0, 8);
856
- const query = sanitizeFtsQuery(cleanTerms.join(' '));
1033
+ // Filter VALUES may legitimately be stop words (a category named "current");
1034
+ // fall back to raw tokens so value binding still fires rather than dropping to empty.
1035
+ const query = sanitizeFtsQuery(cleanTerms.join(' '), { prefix: true, fallbackToRawTokens: true });
857
1036
  if (!query)
858
1037
  return [];
859
1038
  const rows = this.db.prepare(`
@@ -1197,6 +1376,7 @@ function addRawDbtManifestCatalogObjects(projectRoot, manifest, objects, edges,
1197
1376
  return;
1198
1377
  }
1199
1378
  const catalogColumns = loadRawDbtCatalogColumns(join(dirname(manifestPath), 'catalog.json'));
1379
+ const uniqueColumnsByModel = extractDbtUniqueColumns(raw.nodes ?? {});
1200
1380
  const entries = [];
1201
1381
  for (const [uniqueId, node] of Object.entries(raw.nodes ?? {})) {
1202
1382
  if (node.resource_type !== 'model')
@@ -1216,6 +1396,7 @@ function addRawDbtManifestCatalogObjects(projectRoot, manifest, objects, edges,
1216
1396
  database,
1217
1397
  schema,
1218
1398
  catalogColumns: catalogColumns.get(uniqueId),
1399
+ uniqueColumns: uniqueColumnsByModel.get(name),
1219
1400
  };
1220
1401
  entries.push(entry);
1221
1402
  addRawDbtCatalogObject({
@@ -1285,6 +1466,8 @@ function addRawDbtCatalogObject(input) {
1285
1466
  catalogOnly: existing ? undefined : true,
1286
1467
  columnCompleteness,
1287
1468
  columns,
1469
+ // W5.3 — single-column `unique` dbt tests become grain-ledger keys.
1470
+ uniqueColumns: input.uniqueColumns && input.uniqueColumns.length > 0 ? input.uniqueColumns : undefined,
1288
1471
  }),
1289
1472
  }));
1290
1473
  for (const column of columns) {
@@ -1659,6 +1842,10 @@ function addDataLexManifestObjects(projectRoot, manifest, objects, edges, diagno
1659
1842
  payload: compactObject({
1660
1843
  tags: entity.tags ?? [],
1661
1844
  binding: entity.binding,
1845
+ // Entity-level grain + keys (W5.2) feed the grain ledger for fan-out safety.
1846
+ grain: entity.grain,
1847
+ candidateKeys: entity.candidate_keys,
1848
+ businessKeys: entity.business_keys,
1662
1849
  fields: (entity.fields ?? []).slice(0, 100).map((field) => compactObject({
1663
1850
  name: field.name,
1664
1851
  type: field.type,
@@ -1744,6 +1931,58 @@ function addDataLexManifestObjects(projectRoot, manifest, objects, edges, diagno
1744
1931
  }),
1745
1932
  });
1746
1933
  }
1934
+ // Concept-to-physical conformance (W5.1) → searchable `datalex_concept` nodes
1935
+ // carrying the canonical key + the physical relations that realize the concept,
1936
+ // plus `datalex_conforms` edges so retrieval can collapse several physical tables
1937
+ // into one business concept ("Customer" → dim_customer, stg_customers) and reason
1938
+ // at the concept grain. Modeling metadata only — no certification coupling.
1939
+ for (const conformance of raw.conformance ?? []) {
1940
+ if (!conformance?.concept)
1941
+ continue;
1942
+ const conceptDomain = conformance.domain ?? '';
1943
+ const conceptKey = `datalex:concept:${conceptDomain}.${conformance.concept}`;
1944
+ const physicalRefs = (conformance.physical ?? [])
1945
+ .map((physical) => (physical.binding ? objectKeyForDataLexBinding(physical.binding) : undefined))
1946
+ .filter((key) => Boolean(key));
1947
+ const canonicalKey = conformance.canonical_key ?? [];
1948
+ objects.set(conceptKey, mergeObject(objects.get(conceptKey), {
1949
+ objectKey: conceptKey,
1950
+ objectType: 'datalex_concept',
1951
+ name: conformance.concept,
1952
+ fullName: `${conceptDomain}.${conformance.concept}`,
1953
+ domain: conceptDomain || undefined,
1954
+ status: 'contract_evidence',
1955
+ description: `Business concept ${conformance.concept}`
1956
+ + (canonicalKey.length ? ` (canonical key: ${canonicalKey.join(', ')})` : '')
1957
+ + (physicalRefs.length ? `, realized by ${physicalRefs.length} physical relation(s).` : '.'),
1958
+ sourcePath: manifestPath,
1959
+ sourceSystem: 'DataLex manifest',
1960
+ payload: compactObject({
1961
+ canonicalKey,
1962
+ businessKey: conformance.business_key ?? [],
1963
+ implements: conformance.implements ?? [],
1964
+ physicalRefs,
1965
+ }),
1966
+ }));
1967
+ if (conceptDomain) {
1968
+ putEdge(edges, {
1969
+ edgeType: 'contains',
1970
+ fromKey: `datalex:domain:${conceptDomain}`,
1971
+ toKey: conceptKey,
1972
+ confidence: 1,
1973
+ payload: { source: 'datalex conformance' },
1974
+ });
1975
+ }
1976
+ for (const ref of physicalRefs) {
1977
+ putEdge(edges, {
1978
+ edgeType: 'datalex_conforms',
1979
+ fromKey: conceptKey,
1980
+ toKey: ref,
1981
+ confidence: 0.95,
1982
+ payload: compactObject({ source: 'datalex conformance', concept: conformance.concept, canonicalKey }),
1983
+ });
1984
+ }
1985
+ }
1747
1986
  for (const block of Object.values(manifest.blocks ?? {})) {
1748
1987
  if (!block.datalexContract)
1749
1988
  continue;
@@ -2056,15 +2295,30 @@ async function planContextPackRoute(input) {
2056
2295
  const exactByApplicability = [...applicabilityByKey.values()]
2057
2296
  .filter((item) => item.kind === 'exact_answer' || item.kind === 'safe_parameterized')
2058
2297
  .sort((a, b) => b.score - a.score)[0];
2059
- const exact = exactByApplicability
2298
+ let exact = exactByApplicability
2060
2299
  ? input.objects.find((object) => object.objectKey === exactByApplicability.objectKey)
2061
2300
  : findExactCertifiedObject(input.request.question, intent, input.objects);
2062
- const certifiedApplicability = exact
2063
- ? applicabilityByKey.get(exact.objectKey) ?? certifiedApplicabilityForObject(exact, input.questionPlan)
2064
- : undefined;
2065
2301
  const contextApplicability = [...applicabilityByKey.values()]
2066
2302
  .filter((item) => item.kind === 'context_only')
2067
2303
  .sort((a, b) => b.score - a.score)[0];
2304
+ // W2.1 — paraphrase promotion. When no block string/lexically matched, promote a
2305
+ // top context-only certified candidate whose example QUESTION the user paraphrased
2306
+ // (semantic cosine + direction) to the certified candidate. It still runs the full
2307
+ // shape/grain fit below (paraphraseExampleMatch is kept OUT of directCertifiedBypass,
2308
+ // the fit's exactExampleMatch, and the grain-gate skip) — so paraphrase never bypasses
2309
+ // grain the way a user naming the block directly does.
2310
+ let paraphraseExampleMatch = false;
2311
+ if (!exact && contextApplicability) {
2312
+ const candidate = input.objects.find((object) => object.objectKey === contextApplicability.objectKey);
2313
+ if (candidate && isCertifiedMetadataObject(candidate)
2314
+ && await matchExampleParaphrase(input.request.question, candidate, envEmbeddingProvider())) {
2315
+ exact = candidate;
2316
+ paraphraseExampleMatch = true;
2317
+ }
2318
+ }
2319
+ const certifiedApplicability = exact
2320
+ ? applicabilityByKey.get(exact.objectKey) ?? certifiedApplicabilityForObject(exact, input.questionPlan)
2321
+ : undefined;
2068
2322
  const exactExampleMatch = exact ? hasExactExampleQuestion(input.request.question, exact) : false;
2069
2323
  const directCertifiedBypass = Boolean(exact && (exactExampleMatch
2070
2324
  || intent === 'definition_lookup'
@@ -2127,6 +2381,7 @@ async function planContextPackRoute(input) {
2127
2381
  || intent === 'exact_certified_lookup'
2128
2382
  || intent === 'definition_lookup'
2129
2383
  || exactExampleMatch
2384
+ || paraphraseExampleMatch
2130
2385
  || (intent === 'ad_hoc_ranking' && objectNameInQuestion(input.request.question, exact)))) {
2131
2386
  return {
2132
2387
  route: 'certified',
@@ -3006,6 +3261,55 @@ function isSqlParentObject(object) {
3006
3261
  object.objectType === 'warehouse_table' ||
3007
3262
  object.objectType === 'runtime_table';
3008
3263
  }
3264
+ /** Column budget for a single relation in the prompt's allowed-SQL context. */
3265
+ const MAX_ALLOWED_SQL_COLUMNS = 120;
3266
+ /**
3267
+ * Relevance score for keeping a column when a wide relation must be truncated (W3.3).
3268
+ * Structural columns (join keys, time/grain) always score so joins and grain survive;
3269
+ * a column whose name overlaps the question scores highest so the needed column on a
3270
+ * 300-column table is not dropped just because it sits past the budget cut.
3271
+ */
3272
+ function columnBudgetScore(name, questionTokens) {
3273
+ const lower = name.toLowerCase();
3274
+ let score = 0;
3275
+ if (/(^|_)(id|key)$/.test(lower) || lower === 'id')
3276
+ score += 3;
3277
+ if (/(date|time|_at$|_ts$|day|month|year|week|quarter)/.test(lower))
3278
+ score += 2;
3279
+ if (questionTokens) {
3280
+ for (const token of questionTokens) {
3281
+ if (token.length > 2 && lower.includes(token)) {
3282
+ score += 5;
3283
+ break;
3284
+ }
3285
+ }
3286
+ }
3287
+ return score;
3288
+ }
3289
+ /**
3290
+ * Cap a relation's column list to the prompt budget and downgrade completeness to
3291
+ * 'partial' when columns are dropped. Keeping a truncated relation 'complete' would
3292
+ * let column validation false-positive a valid column past the cut as unknown_column
3293
+ * (the >120-column latent bug, W1.4). When truncating, keep the columns most relevant
3294
+ * to the question + structural keys (W3.3) rather than the arbitrary first-N, then
3295
+ * restore original order among the survivors so prompt ordering is unchanged.
3296
+ */
3297
+ function capAllowedSqlColumns(columns, completeness, questionTokens) {
3298
+ if (columns.length <= MAX_ALLOWED_SQL_COLUMNS) {
3299
+ return { columns, columnCompleteness: completeness };
3300
+ }
3301
+ const kept = columns
3302
+ .map((column, index) => ({ column, index, score: columnBudgetScore(column.name, questionTokens) }))
3303
+ .sort((a, b) => (b.score - a.score) || (a.index - b.index))
3304
+ .slice(0, MAX_ALLOWED_SQL_COLUMNS)
3305
+ .sort((a, b) => a.index - b.index)
3306
+ .map((entry) => entry.column);
3307
+ return { columns: kept, columnCompleteness: 'partial' };
3308
+ }
3309
+ /** Lowercased word tokens from the question, for column-relevance ranking. */
3310
+ function tokenizeQuestionForColumns(question) {
3311
+ return (question.toLowerCase().match(/[a-z0-9_]+/g) ?? []).filter((token) => token.length > 2);
3312
+ }
3009
3313
  const FULL_CATALOG_RELATION_TYPES = ['dbt_model', 'dbt_source', 'warehouse_table', 'runtime_table'];
3010
3314
  const FULL_CATALOG_COLUMN_TYPES = ['dbt_column', 'warehouse_column', 'runtime_column'];
3011
3315
  /** Deep mode over a catalog this small hands the model everything instead of top-k. */
@@ -3032,7 +3336,7 @@ function collectFullCatalogObjects(catalog) {
3032
3336
  return undefined;
3033
3337
  return mergeObjects([...relations, ...columns]);
3034
3338
  }
3035
- function buildAllowedSqlContext(objects, edges) {
3339
+ export function buildAllowedSqlContext(objects, edges, questionTokens) {
3036
3340
  const byRelation = new Map();
3037
3341
  const objectsByKey = new Map(objects.map((object) => [object.objectKey, object]));
3038
3342
  const addRelation = (relation) => {
@@ -3041,16 +3345,17 @@ function buildAllowedSqlContext(objects, edges) {
3041
3345
  return;
3042
3346
  const existing = byRelation.get(key);
3043
3347
  if (!existing) {
3044
- byRelation.set(key, { ...relation, columns: dedupeRuntimeColumns(relation.columns).slice(0, 120) });
3348
+ const capped = capAllowedSqlColumns(dedupeRuntimeColumns(relation.columns), relation.columnCompleteness, questionTokens);
3349
+ byRelation.set(key, { ...relation, columns: capped.columns, columnCompleteness: capped.columnCompleteness });
3045
3350
  return;
3046
3351
  }
3047
- const columnCompleteness = mergeRelationCompletenessForCatalog(existing, relation);
3352
+ const capped = capAllowedSqlColumns(mergeRelationColumns(existing, relation), mergeRelationCompletenessForCatalog(existing, relation), questionTokens);
3048
3353
  byRelation.set(key, {
3049
3354
  ...existing,
3050
3355
  objectKey: existing.objectKey ?? relation.objectKey,
3051
3356
  source: mergeRelationSources(existing.source, relation.source),
3052
- columnCompleteness,
3053
- columns: mergeRelationColumns(existing, relation).slice(0, 120),
3357
+ columnCompleteness: capped.columnCompleteness,
3358
+ columns: capped.columns,
3054
3359
  });
3055
3360
  };
3056
3361
  for (const object of objects) {
@@ -3500,12 +3805,13 @@ function coalesceRelationAliases(relations) {
3500
3805
  continue;
3501
3806
  const target = targets[0];
3502
3807
  const targetKey = normalizeRelationKey(target.relation);
3808
+ const capped = capAllowedSqlColumns(mergeRelationColumns(target, relation), mergeRelationCompletenessForCatalog(target, relation));
3503
3809
  mergedByRelation.set(targetKey, {
3504
3810
  ...target,
3505
3811
  objectKey: target.objectKey ?? relation.objectKey,
3506
3812
  source: mergeRelationSources(target.source, relation.source),
3507
- columnCompleteness: mergeRelationCompletenessForCatalog(target, relation),
3508
- columns: mergeRelationColumns(target, relation).slice(0, 120),
3813
+ columnCompleteness: capped.columnCompleteness,
3814
+ columns: capped.columns,
3509
3815
  });
3510
3816
  skipped.add(relationKey);
3511
3817
  }
@@ -4273,10 +4579,39 @@ function safeJson(raw, fallback) {
4273
4579
  return fallback;
4274
4580
  }
4275
4581
  }
4582
+ /**
4583
+ * W3.2 — domain-affinity ranking boost for MULTI-DOMAIN catalogs. At scale
4584
+ * (thousands of models across domains), a question about one domain can be crowded
4585
+ * out of the top-k by lexically-similar objects from other domains. This infers the
4586
+ * dominant domain from the highest-scoring candidates and gives same-domain objects
4587
+ * a small additive bonus. It is RECALL-PRESERVING — it only reorders; every
4588
+ * candidate stays in the ranking, and the bonus (10% of the top score) is small
4589
+ * enough that a much stronger cross-domain signal still wins. No-op for single-domain
4590
+ * catalogs (fewer than 2 domains carry positive score).
4591
+ */
4592
+ export function applyDomainAffinityBoost(scored) {
4593
+ const domainScore = new Map();
4594
+ for (const item of scored) {
4595
+ const domain = item.row.domain;
4596
+ if (domain && item.score > 0)
4597
+ domainScore.set(domain, (domainScore.get(domain) ?? 0) + item.score);
4598
+ }
4599
+ if (domainScore.size < 2)
4600
+ return undefined;
4601
+ const [dominant] = [...domainScore.entries()].sort((a, b) => b[1] - a[1])[0];
4602
+ const maxScore = Math.max(0, ...scored.map((item) => item.score));
4603
+ const bonus = Number((0.1 * maxScore).toFixed(3));
4604
+ if (bonus <= 0)
4605
+ return undefined;
4606
+ for (const item of scored) {
4607
+ if (item.row.domain === dominant)
4608
+ item.score = Number((item.score + bonus).toFixed(3));
4609
+ }
4610
+ return dominant;
4611
+ }
4276
4612
  function rankMetadataObjects(args) {
4277
4613
  const terms = tokenize(args.question).slice(0, 12);
4278
- const ranked = mergeObjects(args.rows)
4279
- .map((row) => {
4614
+ const scored = mergeObjects(args.rows).map((row) => {
4280
4615
  const baseScore = scoreMetadataObject(row, terms);
4281
4616
  const planScore = args.questionPlan ? scoreMetadataObjectWithAnalysisPlan(row, args.questionPlan) : { score: 0, reasons: [] };
4282
4617
  const score = Number((baseScore + planScore.score).toFixed(3));
@@ -4287,7 +4622,9 @@ function rankMetadataObjects(args) {
4287
4622
  reason: selectionReason(row, score, planScore.reasons),
4288
4623
  priorityTier: priorityTier(row),
4289
4624
  };
4290
- })
4625
+ });
4626
+ applyDomainAffinityBoost(scored);
4627
+ const ranked = scored
4291
4628
  .sort((a, b) => b.score - a.score || objectPriority(a.row) - objectPriority(b.row) || a.row.name.localeCompare(b.row.name))
4292
4629
  .map((item, index) => ({ ...item, rank: index + 1 }));
4293
4630
  const selectedRanked = selectRankedMetadataObjects(ranked, args.limit);
@@ -4633,21 +4970,4 @@ function scoreText(value, terms) {
4633
4970
  const lower = value.toLowerCase();
4634
4971
  return terms.reduce((score, term) => score + (lower.includes(term) ? 1 : 0), 0);
4635
4972
  }
4636
- const STOP_WORDS = new Set([
4637
- 'a', 'about', 'after', 'all', 'an', 'and', 'any', 'are', 'as', 'at', 'be', 'by',
4638
- 'can', 'could', 'did', 'do', 'does', 'down', 'for', 'from', 'give', 'had', 'has',
4639
- 'have', 'how', 'i', 'if', 'in', 'into', 'is', 'it', 'me', 'most', 'my', 'of',
4640
- 'on', 'or', 'please', 'query', 'show', 'sql', 'than', 'that', 'the', 'their',
4641
- 'them', 'this', 'to', 'up', 'using', 'was', 'we', 'were', 'what', 'when',
4642
- 'where', 'which', 'who', 'why', 'with', 'would', 'you', 'your',
4643
- ]);
4644
- function sanitizeFtsQuery(raw) {
4645
- return raw
4646
- .split(/\s+/)
4647
- .map((term) => term.replace(/[^\p{L}\p{N}_]/gu, ''))
4648
- .filter((term) => term.length > 1 && !STOP_WORDS.has(term.toLowerCase()))
4649
- .slice(0, 48)
4650
- .map((term) => `"${term}"*`)
4651
- .join(' OR ');
4652
- }
4653
4973
  //# sourceMappingURL=catalog.js.map