@kaelio/ktx 0.13.0 → 0.14.0

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 (243) hide show
  1. package/assets/python/{kaelio_ktx-0.13.0-py3-none-any.whl → kaelio_ktx-0.14.0-py3-none-any.whl} +0 -0
  2. package/assets/python/manifest.json +4 -4
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/cli-program.js +1 -1
  5. package/dist/commands/ingest-commands.d.ts +9 -0
  6. package/dist/commands/ingest-commands.js +37 -1
  7. package/dist/commands/knowledge-commands.js +3 -0
  8. package/dist/commands/setup-commands.js +14 -1
  9. package/dist/connection-drivers.d.ts +2 -0
  10. package/dist/connection-drivers.js +7 -3
  11. package/dist/connection.d.ts +3 -0
  12. package/dist/connection.js +27 -0
  13. package/dist/connectors/bigquery/connector.d.ts +17 -6
  14. package/dist/connectors/bigquery/connector.js +152 -76
  15. package/dist/connectors/bigquery/dialect.d.ts +2 -2
  16. package/dist/connectors/clickhouse/connector.d.ts +1 -0
  17. package/dist/connectors/clickhouse/connector.js +45 -16
  18. package/dist/connectors/clickhouse/dialect.d.ts +2 -2
  19. package/dist/connectors/mongodb/connector.d.ts +69 -0
  20. package/dist/connectors/mongodb/connector.js +307 -0
  21. package/dist/connectors/mongodb/dialect.d.ts +17 -0
  22. package/dist/connectors/mongodb/dialect.js +50 -0
  23. package/dist/connectors/mongodb/live-database-introspection.d.ts +10 -0
  24. package/dist/connectors/mongodb/live-database-introspection.js +24 -0
  25. package/dist/connectors/mongodb/schema-inference.d.ts +20 -0
  26. package/dist/connectors/mongodb/schema-inference.js +110 -0
  27. package/dist/connectors/mysql/connector.d.ts +1 -0
  28. package/dist/connectors/mysql/connector.js +18 -2
  29. package/dist/connectors/mysql/dialect.d.ts +2 -2
  30. package/dist/connectors/postgres/connector.d.ts +1 -0
  31. package/dist/connectors/postgres/connector.js +20 -3
  32. package/dist/connectors/postgres/dialect.d.ts +2 -2
  33. package/dist/connectors/snowflake/connector.d.ts +1 -0
  34. package/dist/connectors/snowflake/connector.js +27 -3
  35. package/dist/connectors/snowflake/dialect.d.ts +2 -2
  36. package/dist/connectors/sqlite/connector.d.ts +11 -1
  37. package/dist/connectors/sqlite/connector.js +120 -17
  38. package/dist/connectors/sqlite/dialect.d.ts +2 -2
  39. package/dist/connectors/sqlite/read-query-child.d.ts +1 -0
  40. package/dist/connectors/sqlite/read-query-child.js +23 -0
  41. package/dist/connectors/sqlserver/connector.d.ts +2 -0
  42. package/dist/connectors/sqlserver/connector.js +23 -5
  43. package/dist/connectors/sqlserver/dialect.d.ts +2 -2
  44. package/dist/context/cache/content-result-cache.d.ts +36 -0
  45. package/dist/context/cache/content-result-cache.js +14 -0
  46. package/dist/context/cache/sqlite-content-result-cache.d.ts +18 -0
  47. package/dist/context/cache/sqlite-content-result-cache.js +223 -0
  48. package/dist/context/connections/bigquery-identifiers.d.ts +1 -0
  49. package/dist/context/connections/bigquery-identifiers.js +7 -0
  50. package/dist/context/connections/configured-connections.d.ts +9 -0
  51. package/dist/context/connections/configured-connections.js +18 -0
  52. package/dist/context/connections/dialects.d.ts +30 -4
  53. package/dist/context/connections/dialects.js +38 -11
  54. package/dist/context/connections/drivers.js +21 -0
  55. package/dist/context/connections/gdrive-config.d.ts +19 -0
  56. package/dist/context/connections/gdrive-config.js +57 -0
  57. package/dist/context/connections/query-deadline.d.ts +31 -0
  58. package/dist/context/connections/query-deadline.js +37 -0
  59. package/dist/context/connections/read-only-sql.d.ts +5 -0
  60. package/dist/context/connections/read-only-sql.js +139 -1
  61. package/dist/context/ingest/adapters/gdrive/chunk.d.ts +3 -0
  62. package/dist/context/ingest/adapters/gdrive/chunk.js +74 -0
  63. package/dist/context/ingest/adapters/gdrive/detect.d.ts +1 -0
  64. package/dist/context/ingest/adapters/gdrive/detect.js +20 -0
  65. package/dist/context/ingest/adapters/gdrive/fetch.d.ts +6 -0
  66. package/dist/context/ingest/adapters/gdrive/fetch.js +95 -0
  67. package/dist/context/ingest/adapters/gdrive/gdrive-client.d.ts +30 -0
  68. package/dist/context/ingest/adapters/gdrive/gdrive-client.js +132 -0
  69. package/dist/context/ingest/adapters/gdrive/gdrive.adapter.d.ts +11 -0
  70. package/dist/context/ingest/adapters/gdrive/gdrive.adapter.js +27 -0
  71. package/dist/context/ingest/adapters/gdrive/normalize.d.ts +2 -0
  72. package/dist/context/ingest/adapters/gdrive/normalize.js +256 -0
  73. package/dist/context/ingest/adapters/gdrive/types.d.ts +153 -0
  74. package/dist/context/ingest/adapters/gdrive/types.js +36 -0
  75. package/dist/context/ingest/adapters/live-database/daemon-introspection.js +24 -0
  76. package/dist/context/ingest/adapters/live-database/fetch-report.d.ts +8 -0
  77. package/dist/context/ingest/adapters/live-database/fetch-report.js +37 -0
  78. package/dist/context/ingest/adapters/live-database/live-database.adapter.d.ts +2 -1
  79. package/dist/context/ingest/adapters/live-database/live-database.adapter.js +9 -2
  80. package/dist/context/ingest/adapters/live-database/manifest.d.ts +2 -0
  81. package/dist/context/ingest/adapters/live-database/manifest.js +8 -4
  82. package/dist/context/ingest/adapters/live-database/scan-outcome.d.ts +17 -0
  83. package/dist/context/ingest/adapters/live-database/scan-outcome.js +40 -0
  84. package/dist/context/ingest/adapters/live-database/stage.js +5 -5
  85. package/dist/context/ingest/adapters/metabase/types.d.ts +1 -1
  86. package/dist/context/ingest/adapters/metabase/types.js +1 -1
  87. package/dist/context/ingest/artifact-gates.d.ts +32 -1
  88. package/dist/context/ingest/artifact-gates.js +85 -18
  89. package/dist/context/ingest/final-gate-prune.d.ts +35 -0
  90. package/dist/context/ingest/final-gate-prune.js +229 -0
  91. package/dist/context/ingest/ingest-bundle.runner.d.ts +3 -0
  92. package/dist/context/ingest/ingest-bundle.runner.js +459 -240
  93. package/dist/context/ingest/isolated-diff/patch-integrator.d.ts +0 -11
  94. package/dist/context/ingest/isolated-diff/patch-integrator.js +0 -44
  95. package/dist/context/ingest/isolated-diff/work-unit-executor.d.ts +1 -0
  96. package/dist/context/ingest/isolated-diff/work-unit-executor.js +13 -4
  97. package/dist/context/ingest/local-adapters.js +6 -0
  98. package/dist/context/ingest/local-bundle-runtime.js +7 -2
  99. package/dist/context/ingest/local-ingest.js +1 -1
  100. package/dist/context/ingest/local-stage-ingest.d.ts +3 -1
  101. package/dist/context/ingest/local-stage-ingest.js +3 -1
  102. package/dist/context/ingest/ports.d.ts +3 -0
  103. package/dist/context/ingest/report-snapshot.js +13 -3
  104. package/dist/context/ingest/reports.d.ts +3 -3
  105. package/dist/context/ingest/reports.js +3 -1
  106. package/dist/context/ingest/stages/build-wu-context.d.ts +1 -0
  107. package/dist/context/ingest/stages/build-wu-context.js +2 -1
  108. package/dist/context/ingest/stages/stage-3-work-units.d.ts +2 -1
  109. package/dist/context/ingest/stages/stage-3-work-units.js +4 -10
  110. package/dist/context/ingest/stages/validate-wu-sources.d.ts +12 -0
  111. package/dist/context/ingest/stages/validate-wu-sources.js +23 -8
  112. package/dist/context/ingest/tools/read-raw-file.tool.js +10 -5
  113. package/dist/context/ingest/tools/read-raw-span.tool.js +10 -5
  114. package/dist/context/ingest/tools/warehouse-verification/discover-data.tool.js +1 -1
  115. package/dist/context/ingest/tools/warehouse-verification/entity-details.tool.js +1 -1
  116. package/dist/context/ingest/tools/warehouse-verification/sql-execution.tool.js +1 -1
  117. package/dist/context/ingest/types.d.ts +3 -0
  118. package/dist/context/ingest/wiki-body-refs.d.ts +28 -0
  119. package/dist/context/ingest/wiki-body-refs.js +37 -8
  120. package/dist/context/ingest/wiki-sl-ref-repair.d.ts +1 -0
  121. package/dist/context/ingest/wiki-sl-ref-repair.js +4 -0
  122. package/dist/context/ingest/work-unit-cache.d.ts +67 -0
  123. package/dist/context/ingest/work-unit-cache.js +230 -0
  124. package/dist/context/llm/ai-sdk-runtime.d.ts +1 -0
  125. package/dist/context/llm/ai-sdk-runtime.js +5 -0
  126. package/dist/context/llm/claude-code-runtime.d.ts +4 -1
  127. package/dist/context/llm/claude-code-runtime.js +38 -8
  128. package/dist/context/llm/codex-runtime.d.ts +4 -1
  129. package/dist/context/llm/codex-runtime.js +48 -35
  130. package/dist/context/llm/runtime-port.d.ts +26 -0
  131. package/dist/context/llm/subprocess-generate-object-child.d.ts +1 -0
  132. package/dist/context/llm/subprocess-generate-object-child.js +34 -0
  133. package/dist/context/llm/subprocess-generate-object.d.ts +42 -0
  134. package/dist/context/llm/subprocess-generate-object.js +122 -0
  135. package/dist/context/mcp/context-tools.d.ts +2 -0
  136. package/dist/context/mcp/context-tools.js +111 -17
  137. package/dist/context/mcp/local-project-ports.d.ts +7 -0
  138. package/dist/context/mcp/local-project-ports.js +29 -0
  139. package/dist/context/mcp/logger.d.ts +24 -0
  140. package/dist/context/mcp/logger.js +49 -0
  141. package/dist/context/mcp/server.js +2 -0
  142. package/dist/context/mcp/types.d.ts +17 -0
  143. package/dist/context/memory/memory-agent.service.js +1 -1
  144. package/dist/context/project/config.d.ts +42 -0
  145. package/dist/context/project/config.js +5 -0
  146. package/dist/context/project/driver-schemas.d.ts +20 -0
  147. package/dist/context/project/driver-schemas.js +50 -1
  148. package/dist/context/project/project.js +1 -1
  149. package/dist/context/project/setup-config.js +1 -0
  150. package/dist/context/scan/description-generation.d.ts +4 -0
  151. package/dist/context/scan/description-generation.js +100 -13
  152. package/dist/context/scan/enabled-tables.d.ts +5 -0
  153. package/dist/context/scan/enabled-tables.js +13 -1
  154. package/dist/context/scan/enrichment-state.d.ts +50 -7
  155. package/dist/context/scan/enrichment-state.js +30 -13
  156. package/dist/context/scan/local-enrichment-artifacts.d.ts +41 -0
  157. package/dist/context/scan/local-enrichment-artifacts.js +155 -32
  158. package/dist/context/scan/local-enrichment.d.ts +39 -4
  159. package/dist/context/scan/local-enrichment.js +345 -92
  160. package/dist/context/scan/local-scan.d.ts +4 -1
  161. package/dist/context/scan/local-scan.js +54 -13
  162. package/dist/context/scan/local-structural-artifacts.d.ts +3 -1
  163. package/dist/context/scan/local-structural-artifacts.js +5 -0
  164. package/dist/context/scan/object-introspection.d.ts +21 -0
  165. package/dist/context/scan/object-introspection.js +35 -0
  166. package/dist/context/scan/relationship-benchmarks.js +5 -3
  167. package/dist/context/scan/relationship-composite-candidates.d.ts +6 -3
  168. package/dist/context/scan/relationship-composite-candidates.js +13 -1
  169. package/dist/context/scan/relationship-detection-budget.d.ts +35 -0
  170. package/dist/context/scan/relationship-detection-budget.js +53 -0
  171. package/dist/context/scan/relationship-diagnostics.d.ts +5 -0
  172. package/dist/context/scan/relationship-diagnostics.js +2 -0
  173. package/dist/context/scan/relationship-discovery.d.ts +9 -3
  174. package/dist/context/scan/relationship-discovery.js +27 -4
  175. package/dist/context/scan/relationship-llm-proposal.js +36 -27
  176. package/dist/context/scan/relationship-profiling.d.ts +7 -3
  177. package/dist/context/scan/relationship-profiling.js +53 -41
  178. package/dist/context/scan/relationship-validation.d.ts +6 -4
  179. package/dist/context/scan/relationship-validation.js +46 -32
  180. package/dist/context/scan/sqlite-local-enrichment-state-store.d.ts +8 -1
  181. package/dist/context/scan/sqlite-local-enrichment-state-store.js +71 -159
  182. package/dist/context/scan/types.d.ts +5 -3
  183. package/dist/context/scan/types.js +2 -0
  184. package/dist/context/sl/local-query.js +5 -0
  185. package/dist/context/sl/pglite-sl-search-prototype.js +1 -1
  186. package/dist/context/sl/semantic-layer.service.js +1 -1
  187. package/dist/context/sl/source-files.d.ts +5 -0
  188. package/dist/context/sl/source-files.js +17 -11
  189. package/dist/context/sl/tools/connection-id-schema.js +1 -1
  190. package/dist/context/sql-analysis/dialect-notes.d.ts +9 -0
  191. package/dist/context/sql-analysis/dialect-notes.js +40 -0
  192. package/dist/context/sql-analysis/dialects/bigquery.md +13 -0
  193. package/dist/context/sql-analysis/dialects/clickhouse.md +9 -0
  194. package/dist/context/sql-analysis/dialects/mysql.md +9 -0
  195. package/dist/context/sql-analysis/dialects/postgres.md +10 -0
  196. package/dist/context/sql-analysis/dialects/snowflake.md +10 -0
  197. package/dist/context/sql-analysis/dialects/sqlite.md +11 -0
  198. package/dist/context/sql-analysis/dialects/tsql.md +10 -0
  199. package/dist/context/wiki/keys.js +1 -1
  200. package/dist/context/wiki/knowledge-wiki.service.d.ts +4 -4
  201. package/dist/context/wiki/knowledge-wiki.service.js +2 -1
  202. package/dist/context/wiki/local-knowledge.d.ts +13 -0
  203. package/dist/context/wiki/local-knowledge.js +50 -6
  204. package/dist/context/wiki/sqlite-knowledge-index.d.ts +2 -0
  205. package/dist/context/wiki/sqlite-knowledge-index.js +21 -5
  206. package/dist/context/wiki/tools/wiki-write.tool.d.ts +2 -0
  207. package/dist/context/wiki/tools/wiki-write.tool.js +27 -0
  208. package/dist/context/wiki/types.d.ts +6 -0
  209. package/dist/context-build-view.d.ts +3 -0
  210. package/dist/context-build-view.js +1 -0
  211. package/dist/knowledge.d.ts +2 -0
  212. package/dist/knowledge.js +12 -1
  213. package/dist/local-adapters.js +11 -0
  214. package/dist/mcp-http-server.js +15 -1
  215. package/dist/mcp-server-factory.d.ts +2 -0
  216. package/dist/mcp-server-factory.js +15 -1
  217. package/dist/mcp-stdio-server.js +10 -2
  218. package/dist/notion-page-picker.js +1 -1
  219. package/dist/prompts/memory_agent_external_ingest.md +2 -0
  220. package/dist/public-ingest.d.ts +3 -1
  221. package/dist/public-ingest.js +3 -0
  222. package/dist/scan.d.ts +3 -1
  223. package/dist/scan.js +7 -0
  224. package/dist/setup-databases.d.ts +1 -1
  225. package/dist/setup-databases.js +43 -1
  226. package/dist/setup-sources.d.ts +5 -1
  227. package/dist/setup-sources.js +124 -48
  228. package/dist/setup.d.ts +3 -0
  229. package/dist/setup.js +6 -1
  230. package/dist/skills/analytics/SKILL.md +200 -2
  231. package/dist/skills/gdrive_synthesize/SKILL.md +97 -0
  232. package/dist/skills/wiki_capture/SKILL.md +24 -0
  233. package/dist/sql.js +4 -0
  234. package/dist/status-project.d.ts +4 -0
  235. package/dist/status-project.js +54 -2
  236. package/dist/telemetry/events.d.ts +2 -2
  237. package/dist/text-ingest.d.ts +4 -0
  238. package/dist/text-ingest.js +58 -29
  239. package/dist/verbatim-ingest.d.ts +46 -0
  240. package/dist/verbatim-ingest.js +193 -0
  241. package/package.json +5 -1
  242. package/dist/context/ingest/final-gate-repair.d.ts +0 -28
  243. package/dist/context/ingest/final-gate-repair.js +0 -91
@@ -1,12 +1,19 @@
1
1
  import pLimit from 'p-limit';
2
- import { getDialectForDriver } from '../connections/dialects.js';
2
+ import { getSqlDialectForDriver } from '../connections/dialects.js';
3
3
  import { buildDefaultKtxProjectConfig } from '../project/config.js';
4
4
  import { KtxDescriptionGenerator } from './description-generation.js';
5
5
  import { buildKtxColumnEmbeddingText } from './embedding-text.js';
6
- import { completedKtxScanEnrichmentStateSummary, computeKtxScanEnrichmentInputHash, summarizeKtxScanEnrichmentState, } from './enrichment-state.js';
6
+ import { completedKtxScanEnrichmentStateSummary, computeKtxDescriptionsStageHash, computeKtxEmbeddingsStageHash, computeKtxRelationshipsStageHash, computeKtxScanDescriptionDigest, KTX_SCAN_ENRICHMENT_STAGES, summarizeKtxScanEnrichmentState, } from './enrichment-state.js';
7
7
  import { skippedKtxScanEnrichmentSummary } from './enrichment-summary.js';
8
+ import { tableRefKey } from './table-ref.js';
8
9
  import { discoverKtxRelationships } from './relationship-discovery.js';
9
- const DESCRIPTION_TABLE_CONCURRENCY = 4;
10
+ // Parallel per-table description generations. Default 4; raise via
11
+ // KTX_ENRICH_TABLE_CONCURRENCY for large schemas (the rate-limit governor still
12
+ // throttles if the provider pushes back, so a higher cap is safe headroom).
13
+ const DESCRIPTION_TABLE_CONCURRENCY = (() => {
14
+ const raw = Number(process.env.KTX_ENRICH_TABLE_CONCURRENCY);
15
+ return Number.isInteger(raw) && raw >= 1 && raw <= 64 ? raw : 4;
16
+ })();
10
17
  function tableId(table) {
11
18
  return [table.catalog, table.db, table.name].filter((value) => Boolean(value)).join('.');
12
19
  }
@@ -82,6 +89,16 @@ function providerlessEnrichedWarning(relationshipDetection) {
82
89
  },
83
90
  };
84
91
  }
92
+ function stagePrerequisiteReason(stage) {
93
+ switch (stage) {
94
+ case 'descriptions':
95
+ return 'LLM enrichment is not configured (set scan.enrichment.mode and an LLM provider)';
96
+ case 'embeddings':
97
+ return 'no embedding provider is configured (set scan.enrichment.embeddings)';
98
+ case 'relationships':
99
+ return 'relationship discovery is disabled (scan.relationships.enabled is false)';
100
+ }
101
+ }
85
102
  export function createDeterministicLocalScanEnrichmentProviders() {
86
103
  return {
87
104
  llmRuntime: deterministicLlmRuntime(),
@@ -108,14 +125,20 @@ function deterministicLlmRuntime() {
108
125
  async runAgentLoop() {
109
126
  return { stopReason: 'natural' };
110
127
  },
128
+ subprocessForkSpec() {
129
+ return null;
130
+ },
111
131
  };
112
132
  }
113
- export function snapshotToKtxEnrichedSchema(snapshot, embeddingsByColumnId = new Map()) {
133
+ export function snapshotToKtxEnrichedSchema(snapshot, embeddingsByColumnId = new Map(), descriptions = []) {
134
+ const descriptionByTable = new Map(descriptions.map((item) => [tableRefKey(item.table), item]));
114
135
  const tables = snapshot.tables.map((table) => {
115
136
  const id = tableId(table);
116
137
  const ref = tableRef(table);
138
+ const tableDescription = descriptionByTable.get(tableRefKey(ref));
117
139
  const columns = table.columns.map((column) => {
118
140
  const idForColumn = columnId(table, column);
141
+ const aiColumnDescription = tableDescription?.columnDescriptions[column.name] ?? null;
119
142
  return {
120
143
  id: idForColumn,
121
144
  tableId: id,
@@ -129,6 +152,7 @@ export function snapshotToKtxEnrichedSchema(snapshot, embeddingsByColumnId = new
129
152
  parentColumnId: null,
130
153
  descriptions: {
131
154
  ...(column.comment ? { db: column.comment } : {}),
155
+ ...(aiColumnDescription ? { ai: aiColumnDescription } : {}),
132
156
  },
133
157
  embedding: embeddingsByColumnId.get(idForColumn) ?? null,
134
158
  sampleValues: null,
@@ -141,6 +165,7 @@ export function snapshotToKtxEnrichedSchema(snapshot, embeddingsByColumnId = new
141
165
  enabled: true,
142
166
  descriptions: {
143
167
  ...(table.comment ? { db: table.comment } : {}),
168
+ ...(tableDescription?.tableDescription ? { ai: tableDescription.tableDescription } : {}),
144
169
  },
145
170
  columns,
146
171
  };
@@ -154,6 +179,19 @@ export function snapshotToKtxEnrichedSchema(snapshot, embeddingsByColumnId = new
154
179
  function embeddingBatchSize(maxBatchSize) {
155
180
  return Number.isInteger(maxBatchSize) && maxBatchSize > 0 ? maxBatchSize : 100;
156
181
  }
182
+ // Per-batch flush cadence: bounds the at-risk window (and the manifest-rewrite /
183
+ // git-commit cost) to a small number of tables.
184
+ const DESCRIPTION_FLUSH_EVERY = 10;
185
+ function isEnrichedDescriptionUpdate(update) {
186
+ return update.tableDescription !== null || Object.values(update.columnDescriptions).some((value) => value !== null);
187
+ }
188
+ function nullDescriptionUpdate(table) {
189
+ return {
190
+ table: tableRef(table),
191
+ tableDescription: null,
192
+ columnDescriptions: Object.fromEntries(table.columns.map((column) => [column.name, null])),
193
+ };
194
+ }
157
195
  async function generateDescriptions(input) {
158
196
  const warningSink = input.warnings;
159
197
  const generator = new KtxDescriptionGenerator({
@@ -173,52 +211,124 @@ async function generateDescriptions(input) {
173
211
  concurrencyLimit: 4,
174
212
  },
175
213
  });
176
- const updates = [];
177
214
  const totalTables = input.snapshot.tables.length;
178
215
  if (totalTables === 0) {
179
216
  await input.progress?.update(1, 'No tables to describe');
180
- return updates;
217
+ return [];
218
+ }
219
+ // Resume: recover already-enriched tables (inputHash-gated) and re-issue LLM
220
+ // calls only for the remainder. A failed/skipped table carries null descriptions
221
+ // and is not recovered, so it is retried.
222
+ const recovered = input.resumeStore ? ((await input.resumeStore.load(input.inputHash)) ?? []) : [];
223
+ const enrichedById = new Map();
224
+ for (const update of recovered) {
225
+ if (isEnrichedDescriptionUpdate(update)) {
226
+ enrichedById.set(tableRefKey(update.table), update);
227
+ }
181
228
  }
229
+ const remaining = input.snapshot.tables.filter((table) => !enrichedById.has(tableRefKey(tableRef(table))));
230
+ const recoveredCount = enrichedById.size;
231
+ if (recoveredCount > 0) {
232
+ input.context.logger?.info(`[enrich] resume: recovered ${recoveredCount}/${totalTables} descriptions, enriching ${remaining.length}`);
233
+ }
234
+ const pendingChanged = new Set();
235
+ let sinceFlush = 0;
236
+ let flushing = false;
237
+ const flush = async (force) => {
238
+ if (!input.resumeStore || flushing || pendingChanged.size === 0) {
239
+ return;
240
+ }
241
+ if (!force && sinceFlush < DESCRIPTION_FLUSH_EVERY) {
242
+ return;
243
+ }
244
+ flushing = true;
245
+ const changedTableNames = new Set(pendingChanged);
246
+ pendingChanged.clear();
247
+ sinceFlush = 0;
248
+ try {
249
+ await input.resumeStore.flush({
250
+ inputHash: input.inputHash,
251
+ snapshot: input.snapshot,
252
+ descriptionUpdates: [...enrichedById.values()],
253
+ changedTableNames,
254
+ });
255
+ }
256
+ finally {
257
+ flushing = false;
258
+ }
259
+ };
182
260
  const limitTable = pLimit(DESCRIPTION_TABLE_CONCURRENCY);
183
- const tableUpdates = await Promise.all(input.snapshot.tables.map((table, index) => limitTable(async () => {
184
- await input.progress?.update((index + 1) / totalTables, `Generating descriptions ${index + 1}/${totalTables} tables`, {
261
+ await Promise.all(remaining.map((table, index) => limitTable(async () => {
262
+ await input.progress?.update((recoveredCount + index + 1) / totalTables, `Generating descriptions ${recoveredCount + index + 1}/${totalTables} (${table.name}, ${table.columns.length} cols)`, {
185
263
  transient: true,
186
264
  });
187
- const batched = await generator.generateBatchedTableDescriptions({
188
- connectionId: input.snapshot.connectionId,
189
- connector: input.connector,
190
- context: input.context,
191
- dataSourceType: input.snapshot.driver,
192
- supportsNestedAnalysis: input.connector.capabilities.nestedAnalysis,
193
- table: {
194
- catalog: table.catalog,
195
- db: table.db,
196
- name: table.name,
197
- rawDescriptions: table.comment ? { db: table.comment } : {},
198
- columns: table.columns.map((column) => ({
199
- name: column.name,
200
- type: column.nativeType,
201
- ...(column.comment ? { rawDescriptions: { db: column.comment } } : {}),
202
- })),
203
- },
204
- });
205
- return {
206
- table: tableRef(table),
207
- tableDescription: batched.tableDescription,
208
- columnDescriptions: Object.fromEntries(batched.columnDescriptions),
209
- };
265
+ // Stage-level guarantee: a single table's failure costs one missing
266
+ // description, never the whole stage's output. (generateBatchedTableDescriptions
267
+ // already degrades its own failures to null descriptions; this backstop keeps
268
+ // the guarantee at the fan-out even if a future path throws.) A genuine
269
+ // cancellation still propagates so the stage fails and resumes.
270
+ let update;
271
+ try {
272
+ const batched = await generator.generateBatchedTableDescriptions({
273
+ connectionId: input.snapshot.connectionId,
274
+ connector: input.connector,
275
+ context: input.context,
276
+ dataSourceType: input.snapshot.driver,
277
+ supportsNestedAnalysis: input.connector.capabilities.nestedAnalysis,
278
+ table: {
279
+ catalog: table.catalog,
280
+ db: table.db,
281
+ name: table.name,
282
+ rawDescriptions: table.comment ? { db: table.comment } : {},
283
+ columns: table.columns.map((column) => ({
284
+ name: column.name,
285
+ type: column.nativeType,
286
+ ...(column.comment ? { rawDescriptions: { db: column.comment } } : {}),
287
+ })),
288
+ },
289
+ });
290
+ update = {
291
+ table: tableRef(table),
292
+ tableDescription: batched.tableDescription,
293
+ columnDescriptions: Object.fromEntries(batched.columnDescriptions),
294
+ };
295
+ }
296
+ catch (error) {
297
+ if (input.context.signal?.aborted) {
298
+ throw error;
299
+ }
300
+ const message = error instanceof Error ? error.message : String(error);
301
+ input.context.logger?.warn(`[enrich] table ${table.name} failed: ${message}`);
302
+ warningSink?.push({
303
+ code: 'enrichment_failed',
304
+ message: `Failed to generate description for ${table.name}: ${message}`,
305
+ table: table.name,
306
+ recoverable: true,
307
+ metadata: {},
308
+ });
309
+ update = nullDescriptionUpdate(table);
310
+ }
311
+ if (isEnrichedDescriptionUpdate(update)) {
312
+ enrichedById.set(tableRefKey(tableRef(table)), update);
313
+ pendingChanged.add(table.name);
314
+ sinceFlush += 1;
315
+ await flush(false);
316
+ }
210
317
  })));
211
- updates.push(...tableUpdates);
318
+ await flush(true);
212
319
  await input.progress?.update(1, `Generated descriptions for ${totalTables} tables`);
213
- return updates;
320
+ // Full set in snapshot order: recovered + freshly enriched, null for any still-failed.
321
+ return input.snapshot.tables.map((table) => enrichedById.get(tableRefKey(tableRef(table))) ?? nullDescriptionUpdate(table));
214
322
  }
215
- async function buildEmbeddings(input) {
216
- const descriptionByTable = new Map(input.descriptions.map((item) => [item.table.name, item]));
323
+ // The exact per-column text fed to the embedding model. Shared by the embeddings
324
+ // stage and the descriptionDigest so the embeddings hash content-addresses the
325
+ // real text the model sees (D4).
326
+ function buildKtxColumnEmbeddingTexts(snapshot, descriptions) {
327
+ const descriptionByTable = new Map(descriptions.map((item) => [tableRefKey(item.table), item]));
217
328
  const texts = [];
218
- for (const table of input.snapshot.tables) {
219
- const tableDescriptions = descriptionByTable.get(table.name);
329
+ for (const table of snapshot.tables) {
330
+ const tableDescriptions = descriptionByTable.get(tableRefKey(tableRef(table)));
220
331
  for (const column of table.columns) {
221
- const id = columnId(table, column);
222
332
  const text = buildKtxColumnEmbeddingText({
223
333
  tableName: table.name,
224
334
  columnName: column.name,
@@ -233,9 +343,13 @@ async function buildEmbeddings(input) {
233
343
  incoming: [],
234
344
  },
235
345
  });
236
- texts.push({ columnId: id, text });
346
+ texts.push({ columnId: columnId(table, column), text });
237
347
  }
238
348
  }
349
+ return texts;
350
+ }
351
+ async function buildEmbeddings(input) {
352
+ const texts = input.texts;
239
353
  const embeddings = [];
240
354
  const maxBatchSize = embeddingBatchSize(input.embedding.maxBatchSize);
241
355
  const embeddingTexts = texts.map((item) => item.text);
@@ -271,15 +385,17 @@ async function buildEmbeddings(input) {
271
385
  return { updates, byColumnId };
272
386
  }
273
387
  async function runEnrichmentStage(input) {
274
- const existing = await input.stateStore?.findCompletedStage({
275
- runId: input.runId,
276
- stage: input.stage,
277
- inputHash: input.inputHash,
278
- });
279
- if (existing) {
280
- input.resumedStages.push(input.stage);
281
- input.completedStages.push(input.stage);
282
- return existing.output;
388
+ if (!input.forceRecompute) {
389
+ const existing = await input.stateStore?.findCompletedStage({
390
+ connectionId: input.connectionId,
391
+ stage: input.stage,
392
+ inputHash: input.inputHash,
393
+ });
394
+ if (existing) {
395
+ input.resumedStages.push(input.stage);
396
+ input.completedStages.push(input.stage);
397
+ return existing.output;
398
+ }
283
399
  }
284
400
  try {
285
401
  const output = await input.compute();
@@ -330,22 +446,42 @@ export async function runLocalScanEnrichment(input) {
330
446
  snapshot,
331
447
  connectionId: input.connectionId,
332
448
  });
333
- const dialect = getDialectForDriver(snapshot.driver);
449
+ const dialect = input.connector.capabilities.readOnlySql
450
+ ? getSqlDialectForDriver(snapshot.driver)
451
+ : null;
334
452
  const now = input.now ?? (() => new Date());
335
453
  const state = completedKtxScanEnrichmentStateSummary();
336
454
  const syncId = input.syncId ?? input.context.runId;
337
455
  const relationshipSettings = input.relationshipSettings ?? buildDefaultKtxProjectConfig().scan.relationships;
338
- const inputHash = computeKtxScanEnrichmentInputHash({
339
- snapshot,
340
- mode: input.mode,
341
- detectRelationships: input.detectRelationships ?? false,
342
- providerIdentity: input.providerIdentity ?? {},
343
- relationshipSettings,
344
- });
456
+ const llmIdentity = input.llmIdentity ?? { model: null, baseUrlConfigured: false };
457
+ const embeddingIdentity = input.embeddingIdentity ?? {
458
+ model: null,
459
+ dimensions: null,
460
+ batchSize: null,
461
+ };
462
+ const descriptionsHash = computeKtxDescriptionsStageHash({ snapshot, llmIdentity });
463
+ const relationshipsHash = computeKtxRelationshipsStageHash({ snapshot, relationshipSettings, llmIdentity });
345
464
  const warnings = [];
465
+ const selectedStages = input.stages;
466
+ const runsStage = (stage) => selectedStages === undefined || selectedStages.includes(stage);
467
+ const forcesStage = (stage) => selectedStages !== undefined && selectedStages.includes(stage);
346
468
  let descriptions = [];
469
+ let descriptionsRanThisInvocation = false;
470
+ let priorDescriptions;
471
+ // Best-available descriptions for the downstream stages (embeddings,
472
+ // relationships): fresh ones when descriptions ran this invocation, else the
473
+ // descriptions persisted in the on-disk _schema. Behavior follows the input
474
+ // (did descriptions run?), not which stage subset the caller selected (D5).
475
+ const resolveDownstreamDescriptions = async () => {
476
+ if (descriptionsRanThisInvocation) {
477
+ return descriptions;
478
+ }
479
+ if (priorDescriptions === undefined) {
480
+ priorDescriptions = input.loadPriorDescriptions ? await input.loadPriorDescriptions(snapshot) : null;
481
+ }
482
+ return priorDescriptions ?? [];
483
+ };
347
484
  let embeddingUpdates = [];
348
- let schema = snapshotToKtxEnrichedSchema(snapshot);
349
485
  const summary = { ...skippedKtxScanEnrichmentSummary };
350
486
  const relationshipDetectionEnabled = relationshipSettings.enabled;
351
487
  const shouldDetectRelationships = relationshipDetectionEnabled &&
@@ -353,36 +489,67 @@ export async function runLocalScanEnrichment(input) {
353
489
  if (input.mode === 'enriched' && !input.providers) {
354
490
  warnings.push(providerlessEnrichedWarning(shouldDetectRelationships));
355
491
  }
492
+ // A stage explicitly named in --stages whose prerequisite is missing must be
493
+ // surfaced, never silently no-op (D2).
494
+ if (selectedStages !== undefined) {
495
+ const stageEligible = {
496
+ descriptions: input.mode === 'enriched' && input.providers != null,
497
+ embeddings: input.mode === 'enriched' && input.providers?.embedding != null,
498
+ relationships: shouldDetectRelationships,
499
+ };
500
+ for (const stage of selectedStages) {
501
+ if (!stageEligible[stage]) {
502
+ warnings.push({
503
+ code: 'enrichment_stage_skipped',
504
+ message: `Requested --stages ${stage}, but it cannot run: ${stagePrerequisiteReason(stage)}.`,
505
+ recoverable: true,
506
+ metadata: { stage },
507
+ });
508
+ }
509
+ }
510
+ }
356
511
  if (input.mode === 'enriched' && input.providers) {
357
512
  const providers = input.providers;
358
- const descriptionProgress = progress?.startPhase(0.45);
359
- descriptions = await runEnrichmentStage({
360
- stateStore: input.stateStore,
361
- runId: input.context.runId,
362
- connectionId: input.connectionId,
363
- syncId,
364
- mode: input.mode,
365
- stage: 'descriptions',
366
- inputHash,
367
- now,
368
- resumedStages: state.resumedStages,
369
- completedStages: state.completedStages,
370
- failedStages: state.failedStages,
371
- compute: () => generateDescriptions({
372
- snapshot,
373
- connector: input.connector,
374
- context: input.context,
375
- providers,
376
- progress: descriptionProgress,
377
- warnings,
378
- }),
379
- });
380
- summary.dataDictionary = input.connector.sampleColumn ? 'completed' : 'skipped';
381
- summary.tableDescriptions = 'completed';
382
- summary.columnDescriptions = 'completed';
383
- const embeddingProgress = progress?.startPhase(0.2);
513
+ if (runsStage('descriptions')) {
514
+ const descriptionProgress = progress?.startPhase(0.45);
515
+ descriptions = await runEnrichmentStage({
516
+ stateStore: input.stateStore,
517
+ runId: input.context.runId,
518
+ connectionId: input.connectionId,
519
+ syncId,
520
+ mode: input.mode,
521
+ stage: 'descriptions',
522
+ inputHash: descriptionsHash,
523
+ now,
524
+ forceRecompute: forcesStage('descriptions'),
525
+ resumedStages: state.resumedStages,
526
+ completedStages: state.completedStages,
527
+ failedStages: state.failedStages,
528
+ compute: () => generateDescriptions({
529
+ snapshot,
530
+ connector: input.connector,
531
+ context: input.context,
532
+ providers,
533
+ inputHash: descriptionsHash,
534
+ resumeStore: input.descriptionResumeStore,
535
+ progress: descriptionProgress,
536
+ warnings,
537
+ }),
538
+ });
539
+ descriptionsRanThisInvocation = true;
540
+ summary.dataDictionary = input.connector.sampleColumn ? 'completed' : 'skipped';
541
+ summary.tableDescriptions = 'completed';
542
+ summary.columnDescriptions = 'completed';
543
+ }
384
544
  const embedding = providers.embedding;
385
- if (embedding) {
545
+ if (embedding && runsStage('embeddings')) {
546
+ const embeddingProgress = progress?.startPhase(0.2);
547
+ const embeddingTexts = buildKtxColumnEmbeddingTexts(snapshot, await resolveDownstreamDescriptions());
548
+ const embeddingsHash = computeKtxEmbeddingsStageHash({
549
+ snapshot,
550
+ embeddingIdentity,
551
+ descriptionDigest: computeKtxScanDescriptionDigest(embeddingTexts.map((item) => item.text)),
552
+ });
386
553
  embeddingUpdates = await runEnrichmentStage({
387
554
  stateStore: input.stateStore,
388
555
  runId: input.context.runId,
@@ -390,22 +557,21 @@ export async function runLocalScanEnrichment(input) {
390
557
  syncId,
391
558
  mode: input.mode,
392
559
  stage: 'embeddings',
393
- inputHash,
560
+ inputHash: embeddingsHash,
394
561
  now,
562
+ forceRecompute: forcesStage('embeddings'),
395
563
  resumedStages: state.resumedStages,
396
564
  completedStages: state.completedStages,
397
565
  failedStages: state.failedStages,
398
566
  compute: async () => {
399
567
  const embeddings = await buildEmbeddings({
400
- snapshot,
401
568
  embedding,
402
- descriptions,
569
+ texts: embeddingTexts,
403
570
  progress: embeddingProgress,
404
571
  });
405
572
  return embeddings.updates;
406
573
  },
407
574
  });
408
- schema = snapshotToKtxEnrichedSchema(snapshot, embeddingsByColumnId(embeddingUpdates));
409
575
  summary.embeddings = 'completed';
410
576
  }
411
577
  }
@@ -413,9 +579,38 @@ export async function runLocalScanEnrichment(input) {
413
579
  let relationshipProfile = null;
414
580
  let resolvedRelationships = null;
415
581
  let compositeRelationships = null;
582
+ let relationshipPartial = null;
416
583
  let relationships = { accepted: 0, review: 0, rejected: 0, skipped: 0 };
417
- if (shouldDetectRelationships) {
584
+ // Promote any non-relationship stage that ran this invocation (descriptions or
585
+ // a `--stages embeddings,relationships` re-embed) before the slow, kill-prone
586
+ // relationship stage, so an interruption degrades to "no joins," never lost
587
+ // enrichment. descriptionUpdates uses the best-available set (D3): the manifest
588
+ // merge overwrites scan-managed descriptions, so the empty this-invocation set
589
+ // would delete prior on-disk ones.
590
+ const checkpointablePaidWork = summary.tableDescriptions === 'completed' || summary.embeddings === 'completed';
591
+ if (shouldDetectRelationships && checkpointablePaidWork && input.onCheckpoint) {
592
+ await input.onCheckpoint({
593
+ snapshot,
594
+ summary: { ...summary },
595
+ relationships,
596
+ state: summarizeKtxScanEnrichmentState(state),
597
+ warnings: [...warnings],
598
+ descriptionUpdates: await resolveDownstreamDescriptions(),
599
+ embeddingUpdates,
600
+ relationshipUpdate: null,
601
+ relationshipProfile: null,
602
+ resolvedRelationships: null,
603
+ compositeRelationships: null,
604
+ relationshipPartial: null,
605
+ });
606
+ }
607
+ if (shouldDetectRelationships && runsStage('relationships')) {
418
608
  const relationshipProgress = progress?.startPhase(0.25);
609
+ // Relationship detection (incl. llmProposals) runs against the
610
+ // best-available descriptions + this run's embeddings, so the join-proposal
611
+ // prompt carries descriptions on both the full-run and relationships-only
612
+ // paths (D5). Embeddings are this run's only — they are not re-hydrated.
613
+ const relationshipSchema = snapshotToKtxEnrichedSchema(snapshot, embeddingsByColumnId(embeddingUpdates), await resolveDownstreamDescriptions());
419
614
  const relationshipStage = await runEnrichmentStage({
420
615
  stateStore: input.stateStore,
421
616
  runId: input.context.runId,
@@ -423,8 +618,9 @@ export async function runLocalScanEnrichment(input) {
423
618
  syncId,
424
619
  mode: input.mode,
425
620
  stage: 'relationships',
426
- inputHash,
621
+ inputHash: relationshipsHash,
427
622
  now,
623
+ forceRecompute: forcesStage('relationships'),
428
624
  resumedStages: state.resumedStages,
429
625
  completedStages: state.completedStages,
430
626
  failedStages: state.failedStages,
@@ -434,10 +630,12 @@ export async function runLocalScanEnrichment(input) {
434
630
  connectionId: input.connectionId,
435
631
  dialect,
436
632
  connector: input.connector,
437
- schema,
633
+ schema: relationshipSchema,
438
634
  context: input.context,
439
635
  settings: relationshipSettings,
440
636
  llmRuntime: input.providers?.llmRuntime ?? null,
637
+ ...(relationshipProgress ? { progress: relationshipProgress } : {}),
638
+ ...(input.now ? { now: () => input.now().getTime() } : {}),
441
639
  });
442
640
  await relationshipProgress?.update(1, `Relationship detection found ${detection.relationships.accepted} accepted, ${detection.relationships.review} review`);
443
641
  return {
@@ -449,6 +647,7 @@ export async function runLocalScanEnrichment(input) {
449
647
  statisticalValidation: detection.statisticalValidation,
450
648
  llmRelationshipValidation: detection.llmRelationshipValidation,
451
649
  warnings: detection.warnings,
650
+ partial: detection.partial,
452
651
  };
453
652
  },
454
653
  });
@@ -460,20 +659,74 @@ export async function runLocalScanEnrichment(input) {
460
659
  resolvedRelationships = relationshipStage.resolvedRelationships;
461
660
  compositeRelationships = relationshipStage.compositeRelationships;
462
661
  relationships = relationshipStage.relationships;
662
+ relationshipPartial = relationshipStage.partial;
463
663
  warnings.push(...relationshipStage.warnings);
664
+ if (relationshipPartial) {
665
+ warnings.push({
666
+ code: 'relationship_detection_partial',
667
+ message: relationshipPartial.reason === 'aborted'
668
+ ? 'Relationship detection was cancelled before completing; the joins found so far are partial.'
669
+ : 'Relationship detection hit its wall-clock budget (scan.relationships.detectionBudgetMs) before completing; the joins found so far are partial. Raise the budget to run a fuller pass.',
670
+ recoverable: true,
671
+ metadata: { reason: relationshipPartial.reason },
672
+ });
673
+ }
674
+ }
675
+ // Derived staleness: after a selective run, surface (never silently leave) any
676
+ // unselected stage whose stored hash no longer matches its current inputs (D4).
677
+ // The embeddings hash includes the description digest, so a re-describe makes
678
+ // embeddings diverge here; relationships are deliberately decoupled (D5) and so
679
+ // never diverge from a description change.
680
+ if (selectedStages !== undefined && input.stateStore) {
681
+ const currentStageHash = {
682
+ descriptions: () => Promise.resolve(descriptionsHash),
683
+ relationships: () => Promise.resolve(relationshipsHash),
684
+ embeddings: async () => {
685
+ const embeddingTexts = buildKtxColumnEmbeddingTexts(snapshot, await resolveDownstreamDescriptions());
686
+ return computeKtxEmbeddingsStageHash({
687
+ snapshot,
688
+ embeddingIdentity,
689
+ descriptionDigest: computeKtxScanDescriptionDigest(embeddingTexts.map((item) => item.text)),
690
+ });
691
+ },
692
+ };
693
+ for (const stage of KTX_SCAN_ENRICHMENT_STAGES) {
694
+ if (selectedStages.includes(stage)) {
695
+ continue;
696
+ }
697
+ const completed = await input.stateStore.findLatestCompletedStage({ connectionId: input.connectionId, stage });
698
+ if (!completed) {
699
+ continue;
700
+ }
701
+ if (completed.inputHash !== (await currentStageHash[stage]())) {
702
+ warnings.push({
703
+ code: 'enrichment_stage_stale',
704
+ message: `The ${stage} enrichment stage is now stale: its inputs changed since it last ran. Refresh it with \`ktx ingest ${input.connectionId} --stages ${stage}\`.`,
705
+ recoverable: true,
706
+ metadata: { stage },
707
+ });
708
+ }
709
+ }
464
710
  }
465
711
  await progress?.update(1, 'Enrichment complete');
712
+ // The manifest merge treats ai/db descriptions as scan-managed and overwrites
713
+ // them with whatever this run emits, so a subset run that skips descriptions
714
+ // must still emit the prior on-disk ones — else the write deletes them (D3
715
+ // "unselected stages are left untouched on disk"). Fresh-this-run if descriptions
716
+ // ran, else loaded from the on-disk _schema.
717
+ const writtenDescriptionUpdates = await resolveDownstreamDescriptions();
466
718
  return {
467
719
  snapshot,
468
720
  summary,
469
721
  relationships,
470
722
  state: summarizeKtxScanEnrichmentState(state),
471
723
  warnings,
472
- descriptionUpdates: descriptions,
724
+ descriptionUpdates: writtenDescriptionUpdates,
473
725
  embeddingUpdates,
474
726
  relationshipUpdate,
475
727
  relationshipProfile,
476
728
  resolvedRelationships,
477
729
  compositeRelationships,
730
+ relationshipPartial,
478
731
  };
479
732
  }
@@ -4,12 +4,14 @@ import type { SourceAdapter } from '../../context/ingest/types.js';
4
4
  import type { KtxLocalProject } from '../../context/project/project.js';
5
5
  import { type KtxLocalScanEnrichmentProviders } from './local-enrichment.js';
6
6
  import { SqliteLocalScanEnrichmentStateStore } from './sqlite-local-enrichment-state-store.js';
7
- import type { KtxProgressPort, KtxScanConnector, KtxScanMode, KtxScanReport, KtxScanTrigger } from './types.js';
7
+ import type { KtxProgressPort, KtxScanConnector, KtxScanEnrichmentStage, KtxScanMode, KtxScanReport, KtxScanTrigger } from './types.js';
8
8
  export interface RunLocalScanOptions {
9
9
  project: KtxLocalProject;
10
10
  connectionId: string;
11
11
  mode?: KtxScanMode;
12
12
  detectRelationships?: boolean;
13
+ /** Enrichment stages to (re)run; omit to run all eligible stages. */
14
+ stages?: KtxScanEnrichmentStage[];
13
15
  dryRun?: boolean;
14
16
  trigger?: KtxScanTrigger;
15
17
  databaseIntrospectionUrl?: string;
@@ -22,6 +24,7 @@ export interface RunLocalScanOptions {
22
24
  enrichmentStateStore?: SqliteLocalScanEnrichmentStateStore | null;
23
25
  progress?: KtxProgressPort;
24
26
  embeddingProvider?: KtxEmbeddingProvider | null;
27
+ signal?: AbortSignal;
25
28
  }
26
29
  export interface LocalScanRunResult {
27
30
  runId: string;