@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.
- package/assets/python/{kaelio_ktx-0.13.0-py3-none-any.whl → kaelio_ktx-0.14.0-py3-none-any.whl} +0 -0
- package/assets/python/manifest.json +4 -4
- package/dist/.tsbuildinfo +1 -1
- package/dist/cli-program.js +1 -1
- package/dist/commands/ingest-commands.d.ts +9 -0
- package/dist/commands/ingest-commands.js +37 -1
- package/dist/commands/knowledge-commands.js +3 -0
- package/dist/commands/setup-commands.js +14 -1
- package/dist/connection-drivers.d.ts +2 -0
- package/dist/connection-drivers.js +7 -3
- package/dist/connection.d.ts +3 -0
- package/dist/connection.js +27 -0
- package/dist/connectors/bigquery/connector.d.ts +17 -6
- package/dist/connectors/bigquery/connector.js +152 -76
- package/dist/connectors/bigquery/dialect.d.ts +2 -2
- package/dist/connectors/clickhouse/connector.d.ts +1 -0
- package/dist/connectors/clickhouse/connector.js +45 -16
- package/dist/connectors/clickhouse/dialect.d.ts +2 -2
- package/dist/connectors/mongodb/connector.d.ts +69 -0
- package/dist/connectors/mongodb/connector.js +307 -0
- package/dist/connectors/mongodb/dialect.d.ts +17 -0
- package/dist/connectors/mongodb/dialect.js +50 -0
- package/dist/connectors/mongodb/live-database-introspection.d.ts +10 -0
- package/dist/connectors/mongodb/live-database-introspection.js +24 -0
- package/dist/connectors/mongodb/schema-inference.d.ts +20 -0
- package/dist/connectors/mongodb/schema-inference.js +110 -0
- package/dist/connectors/mysql/connector.d.ts +1 -0
- package/dist/connectors/mysql/connector.js +18 -2
- package/dist/connectors/mysql/dialect.d.ts +2 -2
- package/dist/connectors/postgres/connector.d.ts +1 -0
- package/dist/connectors/postgres/connector.js +20 -3
- package/dist/connectors/postgres/dialect.d.ts +2 -2
- package/dist/connectors/snowflake/connector.d.ts +1 -0
- package/dist/connectors/snowflake/connector.js +27 -3
- package/dist/connectors/snowflake/dialect.d.ts +2 -2
- package/dist/connectors/sqlite/connector.d.ts +11 -1
- package/dist/connectors/sqlite/connector.js +120 -17
- package/dist/connectors/sqlite/dialect.d.ts +2 -2
- package/dist/connectors/sqlite/read-query-child.d.ts +1 -0
- package/dist/connectors/sqlite/read-query-child.js +23 -0
- package/dist/connectors/sqlserver/connector.d.ts +2 -0
- package/dist/connectors/sqlserver/connector.js +23 -5
- package/dist/connectors/sqlserver/dialect.d.ts +2 -2
- package/dist/context/cache/content-result-cache.d.ts +36 -0
- package/dist/context/cache/content-result-cache.js +14 -0
- package/dist/context/cache/sqlite-content-result-cache.d.ts +18 -0
- package/dist/context/cache/sqlite-content-result-cache.js +223 -0
- package/dist/context/connections/bigquery-identifiers.d.ts +1 -0
- package/dist/context/connections/bigquery-identifiers.js +7 -0
- package/dist/context/connections/configured-connections.d.ts +9 -0
- package/dist/context/connections/configured-connections.js +18 -0
- package/dist/context/connections/dialects.d.ts +30 -4
- package/dist/context/connections/dialects.js +38 -11
- package/dist/context/connections/drivers.js +21 -0
- package/dist/context/connections/gdrive-config.d.ts +19 -0
- package/dist/context/connections/gdrive-config.js +57 -0
- package/dist/context/connections/query-deadline.d.ts +31 -0
- package/dist/context/connections/query-deadline.js +37 -0
- package/dist/context/connections/read-only-sql.d.ts +5 -0
- package/dist/context/connections/read-only-sql.js +139 -1
- package/dist/context/ingest/adapters/gdrive/chunk.d.ts +3 -0
- package/dist/context/ingest/adapters/gdrive/chunk.js +74 -0
- package/dist/context/ingest/adapters/gdrive/detect.d.ts +1 -0
- package/dist/context/ingest/adapters/gdrive/detect.js +20 -0
- package/dist/context/ingest/adapters/gdrive/fetch.d.ts +6 -0
- package/dist/context/ingest/adapters/gdrive/fetch.js +95 -0
- package/dist/context/ingest/adapters/gdrive/gdrive-client.d.ts +30 -0
- package/dist/context/ingest/adapters/gdrive/gdrive-client.js +132 -0
- package/dist/context/ingest/adapters/gdrive/gdrive.adapter.d.ts +11 -0
- package/dist/context/ingest/adapters/gdrive/gdrive.adapter.js +27 -0
- package/dist/context/ingest/adapters/gdrive/normalize.d.ts +2 -0
- package/dist/context/ingest/adapters/gdrive/normalize.js +256 -0
- package/dist/context/ingest/adapters/gdrive/types.d.ts +153 -0
- package/dist/context/ingest/adapters/gdrive/types.js +36 -0
- package/dist/context/ingest/adapters/live-database/daemon-introspection.js +24 -0
- package/dist/context/ingest/adapters/live-database/fetch-report.d.ts +8 -0
- package/dist/context/ingest/adapters/live-database/fetch-report.js +37 -0
- package/dist/context/ingest/adapters/live-database/live-database.adapter.d.ts +2 -1
- package/dist/context/ingest/adapters/live-database/live-database.adapter.js +9 -2
- package/dist/context/ingest/adapters/live-database/manifest.d.ts +2 -0
- package/dist/context/ingest/adapters/live-database/manifest.js +8 -4
- package/dist/context/ingest/adapters/live-database/scan-outcome.d.ts +17 -0
- package/dist/context/ingest/adapters/live-database/scan-outcome.js +40 -0
- package/dist/context/ingest/adapters/live-database/stage.js +5 -5
- package/dist/context/ingest/adapters/metabase/types.d.ts +1 -1
- package/dist/context/ingest/adapters/metabase/types.js +1 -1
- package/dist/context/ingest/artifact-gates.d.ts +32 -1
- package/dist/context/ingest/artifact-gates.js +85 -18
- package/dist/context/ingest/final-gate-prune.d.ts +35 -0
- package/dist/context/ingest/final-gate-prune.js +229 -0
- package/dist/context/ingest/ingest-bundle.runner.d.ts +3 -0
- package/dist/context/ingest/ingest-bundle.runner.js +459 -240
- package/dist/context/ingest/isolated-diff/patch-integrator.d.ts +0 -11
- package/dist/context/ingest/isolated-diff/patch-integrator.js +0 -44
- package/dist/context/ingest/isolated-diff/work-unit-executor.d.ts +1 -0
- package/dist/context/ingest/isolated-diff/work-unit-executor.js +13 -4
- package/dist/context/ingest/local-adapters.js +6 -0
- package/dist/context/ingest/local-bundle-runtime.js +7 -2
- package/dist/context/ingest/local-ingest.js +1 -1
- package/dist/context/ingest/local-stage-ingest.d.ts +3 -1
- package/dist/context/ingest/local-stage-ingest.js +3 -1
- package/dist/context/ingest/ports.d.ts +3 -0
- package/dist/context/ingest/report-snapshot.js +13 -3
- package/dist/context/ingest/reports.d.ts +3 -3
- package/dist/context/ingest/reports.js +3 -1
- package/dist/context/ingest/stages/build-wu-context.d.ts +1 -0
- package/dist/context/ingest/stages/build-wu-context.js +2 -1
- package/dist/context/ingest/stages/stage-3-work-units.d.ts +2 -1
- package/dist/context/ingest/stages/stage-3-work-units.js +4 -10
- package/dist/context/ingest/stages/validate-wu-sources.d.ts +12 -0
- package/dist/context/ingest/stages/validate-wu-sources.js +23 -8
- package/dist/context/ingest/tools/read-raw-file.tool.js +10 -5
- package/dist/context/ingest/tools/read-raw-span.tool.js +10 -5
- package/dist/context/ingest/tools/warehouse-verification/discover-data.tool.js +1 -1
- package/dist/context/ingest/tools/warehouse-verification/entity-details.tool.js +1 -1
- package/dist/context/ingest/tools/warehouse-verification/sql-execution.tool.js +1 -1
- package/dist/context/ingest/types.d.ts +3 -0
- package/dist/context/ingest/wiki-body-refs.d.ts +28 -0
- package/dist/context/ingest/wiki-body-refs.js +37 -8
- package/dist/context/ingest/wiki-sl-ref-repair.d.ts +1 -0
- package/dist/context/ingest/wiki-sl-ref-repair.js +4 -0
- package/dist/context/ingest/work-unit-cache.d.ts +67 -0
- package/dist/context/ingest/work-unit-cache.js +230 -0
- package/dist/context/llm/ai-sdk-runtime.d.ts +1 -0
- package/dist/context/llm/ai-sdk-runtime.js +5 -0
- package/dist/context/llm/claude-code-runtime.d.ts +4 -1
- package/dist/context/llm/claude-code-runtime.js +38 -8
- package/dist/context/llm/codex-runtime.d.ts +4 -1
- package/dist/context/llm/codex-runtime.js +48 -35
- package/dist/context/llm/runtime-port.d.ts +26 -0
- package/dist/context/llm/subprocess-generate-object-child.d.ts +1 -0
- package/dist/context/llm/subprocess-generate-object-child.js +34 -0
- package/dist/context/llm/subprocess-generate-object.d.ts +42 -0
- package/dist/context/llm/subprocess-generate-object.js +122 -0
- package/dist/context/mcp/context-tools.d.ts +2 -0
- package/dist/context/mcp/context-tools.js +111 -17
- package/dist/context/mcp/local-project-ports.d.ts +7 -0
- package/dist/context/mcp/local-project-ports.js +29 -0
- package/dist/context/mcp/logger.d.ts +24 -0
- package/dist/context/mcp/logger.js +49 -0
- package/dist/context/mcp/server.js +2 -0
- package/dist/context/mcp/types.d.ts +17 -0
- package/dist/context/memory/memory-agent.service.js +1 -1
- package/dist/context/project/config.d.ts +42 -0
- package/dist/context/project/config.js +5 -0
- package/dist/context/project/driver-schemas.d.ts +20 -0
- package/dist/context/project/driver-schemas.js +50 -1
- package/dist/context/project/project.js +1 -1
- package/dist/context/project/setup-config.js +1 -0
- package/dist/context/scan/description-generation.d.ts +4 -0
- package/dist/context/scan/description-generation.js +100 -13
- package/dist/context/scan/enabled-tables.d.ts +5 -0
- package/dist/context/scan/enabled-tables.js +13 -1
- package/dist/context/scan/enrichment-state.d.ts +50 -7
- package/dist/context/scan/enrichment-state.js +30 -13
- package/dist/context/scan/local-enrichment-artifacts.d.ts +41 -0
- package/dist/context/scan/local-enrichment-artifacts.js +155 -32
- package/dist/context/scan/local-enrichment.d.ts +39 -4
- package/dist/context/scan/local-enrichment.js +345 -92
- package/dist/context/scan/local-scan.d.ts +4 -1
- package/dist/context/scan/local-scan.js +54 -13
- package/dist/context/scan/local-structural-artifacts.d.ts +3 -1
- package/dist/context/scan/local-structural-artifacts.js +5 -0
- package/dist/context/scan/object-introspection.d.ts +21 -0
- package/dist/context/scan/object-introspection.js +35 -0
- package/dist/context/scan/relationship-benchmarks.js +5 -3
- package/dist/context/scan/relationship-composite-candidates.d.ts +6 -3
- package/dist/context/scan/relationship-composite-candidates.js +13 -1
- package/dist/context/scan/relationship-detection-budget.d.ts +35 -0
- package/dist/context/scan/relationship-detection-budget.js +53 -0
- package/dist/context/scan/relationship-diagnostics.d.ts +5 -0
- package/dist/context/scan/relationship-diagnostics.js +2 -0
- package/dist/context/scan/relationship-discovery.d.ts +9 -3
- package/dist/context/scan/relationship-discovery.js +27 -4
- package/dist/context/scan/relationship-llm-proposal.js +36 -27
- package/dist/context/scan/relationship-profiling.d.ts +7 -3
- package/dist/context/scan/relationship-profiling.js +53 -41
- package/dist/context/scan/relationship-validation.d.ts +6 -4
- package/dist/context/scan/relationship-validation.js +46 -32
- package/dist/context/scan/sqlite-local-enrichment-state-store.d.ts +8 -1
- package/dist/context/scan/sqlite-local-enrichment-state-store.js +71 -159
- package/dist/context/scan/types.d.ts +5 -3
- package/dist/context/scan/types.js +2 -0
- package/dist/context/sl/local-query.js +5 -0
- package/dist/context/sl/pglite-sl-search-prototype.js +1 -1
- package/dist/context/sl/semantic-layer.service.js +1 -1
- package/dist/context/sl/source-files.d.ts +5 -0
- package/dist/context/sl/source-files.js +17 -11
- package/dist/context/sl/tools/connection-id-schema.js +1 -1
- package/dist/context/sql-analysis/dialect-notes.d.ts +9 -0
- package/dist/context/sql-analysis/dialect-notes.js +40 -0
- package/dist/context/sql-analysis/dialects/bigquery.md +13 -0
- package/dist/context/sql-analysis/dialects/clickhouse.md +9 -0
- package/dist/context/sql-analysis/dialects/mysql.md +9 -0
- package/dist/context/sql-analysis/dialects/postgres.md +10 -0
- package/dist/context/sql-analysis/dialects/snowflake.md +10 -0
- package/dist/context/sql-analysis/dialects/sqlite.md +11 -0
- package/dist/context/sql-analysis/dialects/tsql.md +10 -0
- package/dist/context/wiki/keys.js +1 -1
- package/dist/context/wiki/knowledge-wiki.service.d.ts +4 -4
- package/dist/context/wiki/knowledge-wiki.service.js +2 -1
- package/dist/context/wiki/local-knowledge.d.ts +13 -0
- package/dist/context/wiki/local-knowledge.js +50 -6
- package/dist/context/wiki/sqlite-knowledge-index.d.ts +2 -0
- package/dist/context/wiki/sqlite-knowledge-index.js +21 -5
- package/dist/context/wiki/tools/wiki-write.tool.d.ts +2 -0
- package/dist/context/wiki/tools/wiki-write.tool.js +27 -0
- package/dist/context/wiki/types.d.ts +6 -0
- package/dist/context-build-view.d.ts +3 -0
- package/dist/context-build-view.js +1 -0
- package/dist/knowledge.d.ts +2 -0
- package/dist/knowledge.js +12 -1
- package/dist/local-adapters.js +11 -0
- package/dist/mcp-http-server.js +15 -1
- package/dist/mcp-server-factory.d.ts +2 -0
- package/dist/mcp-server-factory.js +15 -1
- package/dist/mcp-stdio-server.js +10 -2
- package/dist/notion-page-picker.js +1 -1
- package/dist/prompts/memory_agent_external_ingest.md +2 -0
- package/dist/public-ingest.d.ts +3 -1
- package/dist/public-ingest.js +3 -0
- package/dist/scan.d.ts +3 -1
- package/dist/scan.js +7 -0
- package/dist/setup-databases.d.ts +1 -1
- package/dist/setup-databases.js +43 -1
- package/dist/setup-sources.d.ts +5 -1
- package/dist/setup-sources.js +124 -48
- package/dist/setup.d.ts +3 -0
- package/dist/setup.js +6 -1
- package/dist/skills/analytics/SKILL.md +200 -2
- package/dist/skills/gdrive_synthesize/SKILL.md +97 -0
- package/dist/skills/wiki_capture/SKILL.md +24 -0
- package/dist/sql.js +4 -0
- package/dist/status-project.d.ts +4 -0
- package/dist/status-project.js +54 -2
- package/dist/telemetry/events.d.ts +2 -2
- package/dist/text-ingest.d.ts +4 -0
- package/dist/text-ingest.js +58 -29
- package/dist/verbatim-ingest.d.ts +46 -0
- package/dist/verbatim-ingest.js +193 -0
- package/package.json +5 -1
- package/dist/context/ingest/final-gate-repair.d.ts +0 -28
- package/dist/context/ingest/final-gate-repair.js +0 -91
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { KtxTableRefKey } from './table-ref.js';
|
|
2
|
-
export type KtxConnectionDriver = 'sqlite' | 'postgres' | 'sqlserver' | 'bigquery' | 'snowflake' | 'mysql' | 'clickhouse';
|
|
3
|
-
|
|
2
|
+
export type KtxConnectionDriver = 'sqlite' | 'postgres' | 'sqlserver' | 'bigquery' | 'snowflake' | 'mysql' | 'clickhouse' | 'mongodb';
|
|
3
|
+
/** Canonical scan-mode registry. Runtime validation derives its allowlist here. */
|
|
4
|
+
export declare const KTX_SCAN_MODES: readonly ["structural", "relationships", "enriched"];
|
|
5
|
+
export type KtxScanMode = (typeof KTX_SCAN_MODES)[number];
|
|
4
6
|
export type KtxScanTrigger = 'cli' | 'mcp' | 'schema_scan' | 'scheduled' | 'manual';
|
|
5
7
|
export interface KtxConnectorCapabilities {
|
|
6
8
|
structuralIntrospection: true;
|
|
@@ -293,7 +295,7 @@ interface KtxScanArtifactPaths {
|
|
|
293
295
|
manifestShards: string[];
|
|
294
296
|
enrichmentArtifacts: string[];
|
|
295
297
|
}
|
|
296
|
-
type KtxScanWarningCode = 'connector_capability_missing' | 'sampling_failed' | 'statistics_failed' | 'llm_unavailable' | 'embedding_unavailable' | 'scan_enrichment_backend_not_configured' | 'relationship_validation_failed' | 'relationship_llm_invalid_reference' | 'relationship_llm_proposal_failed' | 'credential_redacted' | 'enrichment_failed' | 'description_fallback_used' | 'constraint_discovery_unauthorized';
|
|
298
|
+
type KtxScanWarningCode = 'connector_capability_missing' | 'sampling_failed' | 'statistics_failed' | 'llm_unavailable' | 'embedding_unavailable' | 'scan_enrichment_backend_not_configured' | 'relationship_validation_failed' | 'relationship_detection_partial' | 'enrichment_stage_skipped' | 'enrichment_stage_stale' | 'relationship_llm_invalid_reference' | 'relationship_llm_proposal_failed' | 'credential_redacted' | 'enrichment_failed' | 'enrichment_timeout' | 'description_fallback_used' | 'constraint_discovery_unauthorized' | 'object_introspection_failed';
|
|
297
299
|
export interface KtxScanWarning {
|
|
298
300
|
code: KtxScanWarningCode;
|
|
299
301
|
message: string;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** Canonical scan-mode registry. Runtime validation derives its allowlist here. */
|
|
2
|
+
export const KTX_SCAN_MODES = ['structural', 'relationships', 'enriched'];
|
|
1
3
|
export function createKtxConnectorCapabilities(capabilities = {}) {
|
|
2
4
|
return {
|
|
3
5
|
structuralIntrospection: true,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isSqlQueryableDriver } from '../connections/dialects.js';
|
|
1
2
|
import { FEDERATED_CONNECTION_ID } from '../connections/federation.js';
|
|
2
3
|
import { resolveRequiredConnectionId } from '../connections/resolve-connection.js';
|
|
3
4
|
import { sqlAnalysisDialectForDriver } from '../sql-analysis/dialect.js';
|
|
@@ -43,6 +44,10 @@ export async function compileLocalSlQuery(project, options) {
|
|
|
43
44
|
await options.onProgress?.({ progress: 0, message: 'Compiling query' });
|
|
44
45
|
const connectionId = resolveLocalConnectionId(project, options.connectionId);
|
|
45
46
|
const driver = project.config.connections[connectionId]?.driver;
|
|
47
|
+
if (!isSqlQueryableDriver(driver)) {
|
|
48
|
+
throw new Error(`Semantic-layer queries require a SQL warehouse connection; '${connectionId}' uses the non-SQL driver ` +
|
|
49
|
+
`'${driver ?? 'unknown'}'. MongoDB and other context-only sources are searchable and ingestable, not SL-queryable.`);
|
|
50
|
+
}
|
|
46
51
|
const dialect = sqlAnalysisDialectForDriver(driver);
|
|
47
52
|
const sources = await loadComputableSources(project, connectionId);
|
|
48
53
|
await options.onProgress?.({ progress: 0.3, message: 'Generating SQL' });
|
|
@@ -41,7 +41,7 @@ async function loadCandidates(project, input = {}) {
|
|
|
41
41
|
const connectionIds = [
|
|
42
42
|
...new Set(listed.files
|
|
43
43
|
.map((path) => path.split('/')[1])
|
|
44
|
-
.filter((connectionId) => typeof connectionId === 'string' && /^[a-zA-Z0-
|
|
44
|
+
.filter((connectionId) => typeof connectionId === 'string' && /^[a-zA-Z0-9_][a-zA-Z0-9_-]*$/.test(connectionId))),
|
|
45
45
|
].sort();
|
|
46
46
|
const candidates = [];
|
|
47
47
|
for (const connectionId of connectionIds) {
|
|
@@ -4,7 +4,7 @@ import { normalizeSemanticLayerDescriptions } from './description-normalization.
|
|
|
4
4
|
import { isOverlaySource, resolvedSourceSchema, sourceDefinitionSchema, sourceOverlaySchema } from './schemas.js';
|
|
5
5
|
import { isSlYamlPath, resolveSlSourceFile, slDeclaredSourceName, slSourceFilePath } from './source-files.js';
|
|
6
6
|
const SL_DIR_PREFIX = 'semantic-layer';
|
|
7
|
-
const CONNECTION_ID_PATTERN = /^[a-zA-Z0-
|
|
7
|
+
const CONNECTION_ID_PATTERN = /^[a-zA-Z0-9_][a-zA-Z0-9_-]*$/;
|
|
8
8
|
/** @internal */
|
|
9
9
|
export class UnknownColumnOverrideError extends Error {
|
|
10
10
|
}
|
|
@@ -38,6 +38,11 @@ export declare function slSourceNameForFile(path: string, content: string): stri
|
|
|
38
38
|
* filename — wrong for human-renamed files, whose filename is not the name.
|
|
39
39
|
*/
|
|
40
40
|
export declare function slDeclaredSourceName(content: string): string | null;
|
|
41
|
+
/**
|
|
42
|
+
* Every standalone/overlay source file for a connection (excludes the `_schema/`
|
|
43
|
+
* manifest). The one listing entry points share so a file is visible to all.
|
|
44
|
+
*/
|
|
45
|
+
export declare function listSlSourceFiles(fileStore: Pick<KtxFileStorePort, 'listFiles' | 'readFile'>, connectionId: string): Promise<SlSourceFile[]>;
|
|
41
46
|
/**
|
|
42
47
|
* Find the standalone/overlay file that defines `sourceName` for a connection.
|
|
43
48
|
* Returns null when no file declares the name (the source may still exist as a
|
|
@@ -31,7 +31,7 @@ export function assertSafeConnectionId(connectionId) {
|
|
|
31
31
|
return assertSafePathToken('connection id', connectionId);
|
|
32
32
|
}
|
|
33
33
|
export function isSafeConnectionId(connectionId) {
|
|
34
|
-
return typeof connectionId === 'string' && /^[a-zA-Z0-
|
|
34
|
+
return typeof connectionId === 'string' && /^[a-zA-Z0-9_][a-zA-Z0-9_-]*$/.test(connectionId);
|
|
35
35
|
}
|
|
36
36
|
export function sourceNameFromPath(path) {
|
|
37
37
|
return (path
|
|
@@ -113,24 +113,30 @@ export function slDeclaredSourceName(content) {
|
|
|
113
113
|
return typeof name === 'string' && name.length > 0 ? name : null;
|
|
114
114
|
}
|
|
115
115
|
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* manifest entry under `_schema/`). Throws when more than one file declares the
|
|
119
|
-
* same name — that breaks the one-file-per-name invariant and must be repaired
|
|
120
|
-
* by hand rather than silently picking one.
|
|
116
|
+
* Every standalone/overlay source file for a connection (excludes the `_schema/`
|
|
117
|
+
* manifest). The one listing entry points share so a file is visible to all.
|
|
121
118
|
*/
|
|
122
|
-
export async function
|
|
119
|
+
export async function listSlSourceFiles(fileStore, connectionId) {
|
|
123
120
|
const dir = `semantic-layer/${assertSafeConnectionId(connectionId)}`;
|
|
124
121
|
const schemaDir = `${dir}/_schema`;
|
|
125
122
|
const listed = await fileStore.listFiles(dir);
|
|
126
123
|
const paths = listed.files.filter((file) => isSlYamlPath(file) && !file.startsWith(`${schemaDir}/`)).sort();
|
|
127
|
-
const
|
|
124
|
+
const files = [];
|
|
128
125
|
for (const path of paths) {
|
|
129
126
|
const raw = await fileStore.readFile(path);
|
|
130
|
-
|
|
131
|
-
matches.push({ path, content: raw.content });
|
|
132
|
-
}
|
|
127
|
+
files.push({ path, content: raw.content });
|
|
133
128
|
}
|
|
129
|
+
return files;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Find the standalone/overlay file that defines `sourceName` for a connection.
|
|
133
|
+
* Returns null when no file declares the name (the source may still exist as a
|
|
134
|
+
* manifest entry under `_schema/`). Throws when more than one file declares the
|
|
135
|
+
* same name — that breaks the one-file-per-name invariant and must be repaired
|
|
136
|
+
* by hand rather than silently picking one.
|
|
137
|
+
*/
|
|
138
|
+
export async function resolveSlSourceFile(fileStore, connectionId, sourceName) {
|
|
139
|
+
const matches = (await listSlSourceFiles(fileStore, connectionId)).filter((file) => slSourceNameForFile(file.path, file.content) === sourceName);
|
|
134
140
|
if (matches.length > 1) {
|
|
135
141
|
throw new Error(`Multiple semantic-layer files declare source "${sourceName}": ${matches.map((match) => match.path).join(', ')}`);
|
|
136
142
|
}
|
|
@@ -2,4 +2,4 @@ import { z } from 'zod';
|
|
|
2
2
|
export const slToolConnectionIdSchema = z
|
|
3
3
|
.string()
|
|
4
4
|
.min(1)
|
|
5
|
-
.regex(/^[a-zA-Z0-
|
|
5
|
+
.regex(/^[a-zA-Z0-9_][a-zA-Z0-9_-]*$/, 'Connection id must be alphanumeric and may contain _ or -');
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { SqlAnalysisDialect } from './ports.js';
|
|
2
|
+
/** @internal Dialects with an authored ./dialects/<dialect>.md file. */
|
|
3
|
+
export declare const DIALECTS_WITH_NOTES: readonly ["postgres", "mysql", "snowflake", "bigquery", "sqlite", "clickhouse", "tsql"];
|
|
4
|
+
/**
|
|
5
|
+
* SQL syntax notes for a resolved dialect. Falls back to `postgres` — the
|
|
6
|
+
* resolver's own default for unrecognized drivers — so any SQL connection yields
|
|
7
|
+
* usable guidance rather than an empty string.
|
|
8
|
+
*/
|
|
9
|
+
export declare function sqlDialectNotes(dialect: SqlAnalysisDialect): string;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
// Per-engine SQL syntax notes live as markdown files under ./dialects (one per
|
|
4
|
+
// dialect), served by the sql_dialect_notes MCP tool. They are package-internal:
|
|
5
|
+
// copy-runtime-assets.mjs ships them to dist, and they are never installed onto an
|
|
6
|
+
// agent target. The set covers every dialect reachable from a configured warehouse
|
|
7
|
+
// driver; duckdb/databricks are intentionally absent because no connector produces
|
|
8
|
+
// them.
|
|
9
|
+
/** @internal Dialects with an authored ./dialects/<dialect>.md file. */
|
|
10
|
+
export const DIALECTS_WITH_NOTES = [
|
|
11
|
+
'postgres',
|
|
12
|
+
'mysql',
|
|
13
|
+
'snowflake',
|
|
14
|
+
'bigquery',
|
|
15
|
+
'sqlite',
|
|
16
|
+
'clickhouse',
|
|
17
|
+
'tsql',
|
|
18
|
+
];
|
|
19
|
+
const notesCache = new Map();
|
|
20
|
+
function readDialectNotes(dialect) {
|
|
21
|
+
const cached = notesCache.get(dialect);
|
|
22
|
+
if (cached !== undefined) {
|
|
23
|
+
return cached;
|
|
24
|
+
}
|
|
25
|
+
const path = fileURLToPath(new URL(`./dialects/${dialect}.md`, import.meta.url));
|
|
26
|
+
const content = readFileSync(path, 'utf-8').trimEnd();
|
|
27
|
+
notesCache.set(dialect, content);
|
|
28
|
+
return content;
|
|
29
|
+
}
|
|
30
|
+
function hasNotes(dialect) {
|
|
31
|
+
return DIALECTS_WITH_NOTES.includes(dialect);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* SQL syntax notes for a resolved dialect. Falls back to `postgres` — the
|
|
35
|
+
* resolver's own default for unrecognized drivers — so any SQL connection yields
|
|
36
|
+
* usable guidance rather than an empty string.
|
|
37
|
+
*/
|
|
38
|
+
export function sqlDialectNotes(dialect) {
|
|
39
|
+
return readDialectNotes(hasNotes(dialect) ? dialect : 'postgres');
|
|
40
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
**bigquery** SQL conventions:
|
|
2
|
+
- **FQTN:** backtick-quoted `` `project.dataset.table` `` (e.g. `` `my-proj.analytics.orders` ``); backticks are required when a name contains a dash.
|
|
3
|
+
- **Identifiers:** backtick to quote; column and field names are case-insensitive, dataset and table names are case-sensitive.
|
|
4
|
+
- **Date/time:** `DATE_TRUNC(d, MONTH)`, `EXTRACT(YEAR FROM ts)`, `PARSE_DATE('%Y-%m-%d', s)`, `FORMAT_DATE('%Y-%m', d)`, `CURRENT_DATE()`.
|
|
5
|
+
- **Series:** build a spine with `UNNEST(GENERATE_DATE_ARRAY('2023-01-01', '2023-12-01', INTERVAL 1 MONTH))` for dates (or `GENERATE_ARRAY(1, n)` for integers), then `LEFT JOIN` the aggregated facts onto it so empty periods still appear.
|
|
6
|
+
- **Rolling window over time:** `RANGE` frames are numeric, so range over an integer day key — `AVG(amount) OVER (ORDER BY UNIX_DATE(day) RANGE BETWEEN 29 PRECEDING AND CURRENT ROW)` is a trailing 30-day average that tolerates gaps; or build a spine (see **Series**) and use a `ROWS` frame.
|
|
7
|
+
- **Safe cast:** `SAFE_CAST(x AS FLOAT64)` (or `SAFE_CAST(x AS NUMERIC)`) returns `NULL` instead of erroring on a value that does not parse, so counting residual `NULL`s among non-sentinel rows catches an encoding the sample missed.
|
|
8
|
+
- **Safe divide:** `SAFE_DIVIDE(num, den)` returns `NULL` instead of erroring when the denominator is `0`, so a rate/ratio/share is one expression with no `CASE den = 0` guard; multiply by `100` for a percentage. Prefer it over `num / den` for any computed measure whose denominator can be zero.
|
|
9
|
+
- **Top-N / windows:** `QUALIFY` filters on a window result, e.g. `QUALIFY ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...) = 1`.
|
|
10
|
+
- **JSON:** `JSON_VALUE(col, '$.k')` returns a scalar STRING, `JSON_QUERY(col, '$.k')` returns a subtree.
|
|
11
|
+
- **Nested & repeated data (ARRAY / STRUCT):** the defining BigQuery shape (e.g. GA360 `ga_sessions.hits`, GA4 `event_params`/`user_properties`). Flatten a repeated column by cross-joining `UNNEST` correlated to its row — `FROM t, UNNEST(t.hits) AS h, UNNEST(h.product) AS p` — and read STRUCT fields with dot notation (`h.page.pagePath`, `p.productRevenue`). Pull one value out of a key-value parameter array with a scalar subquery: `(SELECT ep.value.int_value FROM UNNEST(event_params) AS ep WHERE ep.key = 'page_view')`. An `UNNEST` multiplies the parent row by the array's length, so a `COUNT(*)`/`SUM` after it double-counts the parent — count the parent key with `COUNT(DISTINCT visitId)` (or aggregate *inside* the unnest); use `LEFT JOIN UNNEST(arr)` to keep rows whose array is empty.
|
|
12
|
+
- **Geospatial (GEOGRAPHY):** build a point with `ST_GEOGPOINT(longitude, latitude)` — **longitude first** — or parse text with `ST_GEOGFROMTEXT(wkt)` / `ST_GEOGFROMGEOJSON(s)`. Predicates: containment `ST_CONTAINS(area, pt)` / `ST_WITHIN(pt, area)` (`ST_WITHIN(a,b)=ST_CONTAINS(b,a)`); proximity `ST_DWITHIN(g1, g2, meters)` (geodesic); distance `ST_DISTANCE(g1, g2)` (meters); overlap `ST_INTERSECTS`. For areal allocation use `ST_AREA(g)` (m²) and `ST_AREA(ST_INTERSECTION(a, b))` for the overlapping area. Prefer these predicates over hand-rolled lat/lon `BETWEEN` boxes.
|
|
13
|
+
- **Sharded tables:** query a wildcard table `` `dataset.events_*` `` and filter the shard with the `_TABLE_SUFFIX` pseudo-column, e.g. `WHERE _TABLE_SUFFIX BETWEEN '20240101' AND '20240131'`. The wildcard spans only the shards that exist — before a measure that pins specific dates/periods, confirm the matching shards are actually present (an absent endpoint silently yields no rows, not an error).
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
**clickhouse** SQL conventions:
|
|
2
|
+
- **FQTN:** `database.table` (e.g. `analytics.orders`).
|
|
3
|
+
- **Identifiers:** quote with backticks (`` `Order` ``) or double quotes; identifiers are case-sensitive.
|
|
4
|
+
- **Date/time:** native `Date`/`DateTime` types. Bucket with `toStartOfMonth(ts)`, `toStartOfDay(ts)`, `toYYYYMM(ts)`; parse with `toDate(s)` / `parseDateTimeBestEffort(s)`; format with `formatDateTime(ts, '%Y-%m')`.
|
|
5
|
+
- **Series:** `numbers(n)` / `range(n)` generate an integer sequence; offset a start date with `addMonths(toDate('2023-01-01'), number)` (or `arrayJoin`) to form a spine, then `LEFT JOIN` the aggregated facts onto it so empty periods still appear.
|
|
6
|
+
- **Rolling window over time:** a numeric range frame over a `Date` column counts in days and tolerates gaps — `AVG(amount) OVER (ORDER BY day RANGE BETWEEN 29 PRECEDING AND CURRENT ROW)` is a trailing 30-day average (use seconds for a `DateTime` key; the `INTERVAL` form is unsupported); or build a spine (see **Series**) and use a `ROWS` frame.
|
|
7
|
+
- **Safe cast:** `toFloat64OrNull(x)` / `toDecimal64OrNull(x, s)` returns `NULL` on a value that does not parse (the `...OrZero` variants return `0` instead), so counting residual `NULL`s among non-sentinel rows catches an encoding the sample missed.
|
|
8
|
+
- **Top-N / windows:** use the `LIMIT n BY key` clause for n rows per key, or rank in a CTE with `ROW_NUMBER() OVER (...)` and filter outside it.
|
|
9
|
+
- **JSON:** extract from a String column with `JSONExtractString(col, 'k')`, `JSONExtractInt(col, 'k')`, etc.; a native `JSON`-typed column is traversed by dot path `col.k`.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
**mysql** SQL conventions:
|
|
2
|
+
- **FQTN:** `database.table` (MySQL has no separate schema layer — a schema is a database).
|
|
3
|
+
- **Identifiers:** quote with backticks (`` `order` ``); table-name case-sensitivity follows the server filesystem, while column names are case-insensitive.
|
|
4
|
+
- **Date/time:** `DATE_FORMAT(ts, '%Y-%m')`, `STR_TO_DATE(s, fmt)`, `YEAR(ts)`/`MONTH(ts)`, `CURDATE()`, `NOW()`.
|
|
5
|
+
- **Series:** no series function — build a spine with a recursive CTE, e.g. `WITH RECURSIVE months(d) AS (SELECT '2023-01-01' UNION ALL SELECT DATE_ADD(d, INTERVAL 1 MONTH) FROM months WHERE d < '2023-12-01')`, then `LEFT JOIN` the aggregated facts onto it so empty periods still appear.
|
|
6
|
+
- **Rolling window over time:** a native interval range frame over a temporal order key tolerates gaps — `AVG(amount) OVER (ORDER BY day RANGE BETWEEN INTERVAL 29 DAY PRECEDING AND CURRENT ROW)` is a trailing 30-day average without a spine; guard minimum periods with `COUNT(*) OVER (<same frame>)`.
|
|
7
|
+
- **Safe cast:** MySQL has no `TRY_CAST`, and `CAST('abc' AS DECIMAL)` returns `0` with a warning rather than erroring — guard with a pattern test first: `CASE WHEN x REGEXP '^-?[0-9.]+$' THEN CAST(x AS DECIMAL(18,4)) END` makes a value that does not parse `NULL`, so a residual-`NULL` count catches an encoding the sample missed (`REGEXP_REPLACE` can strip symbols).
|
|
8
|
+
- **Top-N / windows:** rank in a CTE with `ROW_NUMBER() OVER (...)` and filter outside it; use `ORDER BY ... LIMIT n` for a global top-N.
|
|
9
|
+
- **JSON:** `JSON_EXTRACT(col, '$.k')`, or the `col->'$.k'` / `col->>'$.k'` shortcuts (`->>` unquotes to text).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
**postgres** SQL conventions:
|
|
2
|
+
- **FQTN:** `schema.table` (e.g. `public.orders`); one query targets a single database, so qualify by schema, not by database.
|
|
3
|
+
- **Identifiers:** unquoted names fold to lower-case; double-quote (`"Name"`) only to keep case or use a reserved word.
|
|
4
|
+
- **Date/time:** `date_trunc('month', ts)`, `EXTRACT(YEAR FROM ts)`, `to_char(ts, 'YYYY-MM')`, `CURRENT_DATE`; cast text to a date with `col::date`.
|
|
5
|
+
- **Series:** build a date/number spine with `generate_series('2023-01-01'::date, '2023-12-01'::date, interval '1 month')` (or `generate_series(1, n)` for integers), then `LEFT JOIN` the aggregated facts onto it so empty periods still appear.
|
|
6
|
+
- **Rolling window over time:** a native calendar-range frame spans real dates and tolerates gaps — `AVG(amount) OVER (ORDER BY day RANGE BETWEEN INTERVAL '29 days' PRECEDING AND CURRENT ROW)` is a trailing 30-day average without a spine; guard minimum periods with `COUNT(*) OVER (<same frame>)`.
|
|
7
|
+
- **Integer division:** `/` between two integers truncates (`5 / 2` → `2`), so a rate or `SUM(a) / COUNT(*)` silently floors to an integer; cast one operand first — `a::numeric / b` or `a * 1.0 / b` — and round only in the final projection.
|
|
8
|
+
- **Safe cast:** postgres has no `TRY_CAST`; guard a text-encoded number with a pattern test before casting — `CASE WHEN x ~ '^-?[0-9.]+$' THEN x::numeric END` yields `NULL` for a value that does not parse, so counting residual `NULL`s among non-sentinel rows catches an encoding the sample missed (`regexp_replace` can strip symbols, but chained `REPLACE` is the portable default).
|
|
9
|
+
- **Top-N / windows:** rank in a CTE with `ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...)` and filter in the outer query, or use `DISTINCT ON (key) ... ORDER BY key, ...` for one row per key.
|
|
10
|
+
- **JSON:** `col->'k'` returns json, `col->>'k'` returns text, deep path `col#>>'{a,b}'`; prefer `jsonb` operators on `jsonb` columns.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
**snowflake** SQL conventions:
|
|
2
|
+
- **FQTN:** three-part `DATABASE.SCHEMA.TABLE` (e.g. `analytics.public.orders`).
|
|
3
|
+
- **Identifiers:** unquoted names fold to UPPER-case; double-quote for a case-sensitive or reserved name — `orders` resolves to `"ORDERS"`, which is a different object from `"orders"`.
|
|
4
|
+
- **Date/time:** `DATE_TRUNC('month', ts)`, `TO_DATE(s[, fmt])`, `DATEADD(day, -7, CURRENT_DATE)`, `CURRENT_DATE`.
|
|
5
|
+
- **Series:** generate rows with `TABLE(GENERATOR(ROWCOUNT => n))` and offset a start date via `DATEADD('month', SEQ4(), '2023-01-01')` (or a recursive CTE) to form a spine, then `LEFT JOIN` the aggregated facts onto it so empty periods still appear.
|
|
6
|
+
- **Rolling window over time:** a native interval range frame over a date/timestamp order key tolerates gaps — `AVG(amount) OVER (ORDER BY day RANGE BETWEEN INTERVAL '29 days' PRECEDING AND CURRENT ROW)` is a trailing 30-day average without a spine; guard minimum periods with `COUNT(*) OVER (<same frame>)`.
|
|
7
|
+
- **Safe cast:** `TRY_TO_NUMBER(x)` / `TRY_TO_DECIMAL(x, p, s)` (or `TRY_CAST(x AS NUMBER)`) returns `NULL` instead of erroring on a value that does not parse, so a residual-`NULL` count among non-sentinel rows catches an encoding the sample missed.
|
|
8
|
+
- **Top-N / windows:** `QUALIFY` filters on a window result without a subquery, e.g. `QUALIFY ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...) = 1`.
|
|
9
|
+
- **Semi-structured (VARIANT):** traverse with a colon path and cast with `::`, e.g. `src:vehicle[0].make::string`, `payload:events.date::date`; expand arrays with `LATERAL FLATTEN`.
|
|
10
|
+
- **Geospatial (GEOGRAPHY):** build a point with `ST_MAKEPOINT(longitude, latitude)` — **longitude first** — or `TO_GEOGRAPHY(wkt_or_geojson)`; an area polygon from a closed ring of corner points with `ST_MAKEPOLYGON(ST_MAKELINE(ARRAY_CONSTRUCT(p1, p2, …, p1)))` (repeat the first point last to close). Predicates: proximity `ST_DWITHIN(g1, g2, meters)` (geodesic) and distance `ST_DISTANCE(g1, g2)` (meters); containment `ST_CONTAINS(area, pt)` / `ST_WITHIN(pt, area)` where `ST_WITHIN(a,b)=ST_CONTAINS(b,a)`; overlap `ST_INTERSECTS`. Prefer these predicates over hand-rolled lat/lon `BETWEEN` boxes.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
**sqlite** SQL conventions:
|
|
2
|
+
- **FQTN:** usually the bare `table`; `main.table` to be explicit, `attached.table` for an attached database.
|
|
3
|
+
- **Identifiers:** case-insensitive; double-quote (`"Name"`) to preserve a name with spaces or a keyword.
|
|
4
|
+
- **Date/time:** there is no native date type — values are TEXT, INTEGER, or REAL. Format and bucket with `strftime('%Y-%m', col)`, `date(col)`, `datetime(col)`, and take day differences with `julianday(a) - julianday(b)`. Confirm the stored encoding (ISO text vs Unix epoch) before comparing.
|
|
5
|
+
- **Series:** no series function — build a spine with a recursive CTE, e.g. `WITH RECURSIVE months(d) AS (SELECT '2023-01-01' UNION ALL SELECT date(d, '+1 month') FROM months WHERE d < '2023-12-01')`, then `LEFT JOIN` the aggregated facts onto it so empty periods still appear.
|
|
6
|
+
- **Rolling window over time:** there is no date-interval range frame (a `RANGE` offset needs a single numeric order key, and dates are TEXT), so build a gap-free date spine (see **Series**) and use a row frame — `AVG(amount) OVER (ORDER BY day ROWS BETWEEN 29 PRECEDING AND CURRENT ROW)` then equals a trailing 30-day average; guard minimum periods with `COUNT(*) OVER (<same frame>)`.
|
|
7
|
+
- **Integer division:** `/` between two integers truncates (`5 / 2` → `2`), so a rate or `SUM(a) / COUNT(*)` silently floors to an integer; force real division with `a * 1.0 / b` (or `CAST(a AS REAL) / b`) and round only in the final projection.
|
|
8
|
+
- **Safe cast:** sqlite has no failure-signaling cast — `CAST('abc' AS REAL)` returns `0.0` and `CAST('12abc' AS REAL)` returns `12.0` (no error, no `NULL`), so an `IS NULL` coverage check silently passes. Detect a value that did not parse with a pattern guard before casting, e.g. `CASE WHEN cleaned NOT GLOB '*[^0-9.]*' THEN CAST(cleaned AS REAL) END` (strip any leading sign first), then count the residual `NULL`s.
|
|
9
|
+
- **Rounding (exact half-up at `.5` boundaries):** `ROUND(x, n)` rounds half-away-from-zero, but binary floating-point stores an exact half-way value just *below* it, so the round goes the wrong way — `ROUND(6.475, 2)` returns `6.47`, not `6.48`. When a rounded measure must match exact half-up (a displayed average, rate, or price), nudge by a tiny epsilon below display precision before rounding: `ROUND(x + 1e-9, n)` lifts `6.4749999…` back to `6.475` so it rounds to `6.48` (it leaves non-boundary values unchanged). Round once, at full precision, in the final projection — never in intermediate CTEs.
|
|
10
|
+
- **Top-N / windows:** rank in a CTE with `ROW_NUMBER() OVER (...)` and filter in the outer query; use `ORDER BY ... LIMIT n` for a global top-N.
|
|
11
|
+
- **JSON:** `json_extract(col, '$.k')`, or the `col->'$.k'` / `col->>'$.k'` operators (`->>` returns text).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
**tsql** (SQL Server) SQL conventions:
|
|
2
|
+
- **FQTN:** `schema.table` (e.g. `dbo.orders`), or `database.schema.table` across databases.
|
|
3
|
+
- **Identifiers:** quote with square brackets (`[Order]`), or double quotes when `QUOTED_IDENTIFIER` is on; case-sensitivity is set by the database collation (commonly case-insensitive).
|
|
4
|
+
- **Date/time:** `DATEPART(year, ts)`, `DATEADD(day, -7, ts)`, `DATEDIFF(day, a, b)`, `CONVERT(date, ts)`, `FORMAT(ts, 'yyyy-MM')`, `GETDATE()`.
|
|
5
|
+
- **Series:** no series function — build a spine with a recursive CTE, e.g. `WITH months AS (SELECT CAST('2023-01-01' AS date) AS d UNION ALL SELECT DATEADD(month, 1, d) FROM months WHERE d < '2023-12-01')` (cap with `OPTION (MAXRECURSION 0)`), or a numbers/tally table, then `LEFT JOIN` the aggregated facts onto it so empty periods still appear.
|
|
6
|
+
- **Rolling window over time:** `RANGE` supports only `UNBOUNDED`/`CURRENT ROW` (no offset frame), so build a gap-free date spine (see **Series**) and use a row frame — `AVG(amount) OVER (ORDER BY day ROWS BETWEEN 29 PRECEDING AND CURRENT ROW)` — or a date-keyed self-join on `f.day BETWEEN DATEADD(day, -29, d.day) AND d.day`; guard minimum periods with `COUNT(*) OVER (<same frame>)`.
|
|
7
|
+
- **Integer division:** `/` between two `int`s truncates (`5 / 2` → `2`), so a rate or `SUM(a) / COUNT(*)` silently floors to an integer; cast one operand first — `CAST(a AS decimal(18,4)) / b` or `a * 1.0 / b` — and round only in the final projection.
|
|
8
|
+
- **Safe cast:** `TRY_CAST(x AS DECIMAL(18,4))` (or `TRY_CONVERT(decimal(18,4), x)`) returns `NULL` instead of erroring on a value that does not parse, so a residual-`NULL` count among non-sentinel rows catches an encoding the sample missed.
|
|
9
|
+
- **Top-N / windows:** `SELECT TOP (n) ... ORDER BY ...` for a global top-N; for per-group, rank in a CTE with `ROW_NUMBER() OVER (...)` and filter in the outer query.
|
|
10
|
+
- **JSON:** `JSON_VALUE(col, '$.k')` returns a scalar, `JSON_QUERY(col, '$.k')` returns an object/array, and `OPENJSON(col)` shreds JSON into rows.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { KtxEmbeddingPort } from '../../context/core/embedding.js';
|
|
2
|
-
import type { KtxFileStorePort } from '../../context/core/file-store.js';
|
|
2
|
+
import type { KtxFileStorePort, KtxFileWriteResult } from '../../context/core/file-store.js';
|
|
3
3
|
import type { KtxLogger } from '../../context/core/config.js';
|
|
4
4
|
import type { ReindexWorkResult } from '../index-sync/types.js';
|
|
5
5
|
import type { KnowledgeGitDiffPort, KnowledgeIndexPort } from './ports.js';
|
|
@@ -31,9 +31,9 @@ export declare class KnowledgeWikiService {
|
|
|
31
31
|
serializePage(frontmatter: WikiFrontmatter, content: string): string;
|
|
32
32
|
writePage(scope: string, scopeId: string | null | undefined, pageKey: string, frontmatter: WikiFrontmatter, content: string, author: string, authorEmail: string, commitMessage?: string, options?: {
|
|
33
33
|
skipLock?: boolean;
|
|
34
|
-
}): Promise<
|
|
34
|
+
}): Promise<KtxFileWriteResult>;
|
|
35
35
|
readPage(scope: string, scopeId: string | null | undefined, pageKey: string): Promise<WikiPage | null>;
|
|
36
|
-
deletePage(scope: string, scopeId: string | null | undefined, pageKey: string, author: string, authorEmail: string): Promise<
|
|
36
|
+
deletePage(scope: string, scopeId: string | null | undefined, pageKey: string, author: string, authorEmail: string): Promise<KtxFileWriteResult | null>;
|
|
37
37
|
listPageKeys(scope: string, scopeId?: string | null): Promise<string[]>;
|
|
38
38
|
getPageHistory(scope: string, scopeId: string | null | undefined, pageKey: string): Promise<unknown>;
|
|
39
39
|
readPageForUser(userId: string, pageKey: string): Promise<WikiPageWithScope | null>;
|
|
@@ -49,7 +49,7 @@ export declare class KnowledgeWikiService {
|
|
|
49
49
|
* Write a wiki page and then sync it to the DB search index.
|
|
50
50
|
* Chains the two operations so the index is only updated after the file write succeeds.
|
|
51
51
|
*/
|
|
52
|
-
writePageAndSync(scope: string, scopeId: string | null | undefined, pageKey: string, frontmatter: WikiFrontmatter, content: string, author: string, authorEmail: string, commitMessage?: string): Promise<
|
|
52
|
+
writePageAndSync(scope: string, scopeId: string | null | undefined, pageKey: string, frontmatter: WikiFrontmatter, content: string, author: string, authorEmail: string, commitMessage?: string): Promise<KtxFileWriteResult>;
|
|
53
53
|
/**
|
|
54
54
|
* Sync a single page to the DB search index after a write.
|
|
55
55
|
* Computes search_text and embedding, then upserts to knowledge index.
|
|
@@ -166,10 +166,11 @@ export class KnowledgeWikiService {
|
|
|
166
166
|
* Chains the two operations so the index is only updated after the file write succeeds.
|
|
167
167
|
*/
|
|
168
168
|
async writePageAndSync(scope, scopeId, pageKey, frontmatter, content, author, authorEmail, commitMessage) {
|
|
169
|
-
await this.writePage(scope, scopeId, pageKey, frontmatter, content, author, authorEmail, commitMessage);
|
|
169
|
+
const writeResult = await this.writePage(scope, scopeId, pageKey, frontmatter, content, author, authorEmail, commitMessage);
|
|
170
170
|
const serialized = this.serializePage(frontmatter, content);
|
|
171
171
|
const contentHash = createHash('sha256').update(serialized).digest('hex');
|
|
172
172
|
await this.syncSinglePage(scope, scopeId, pageKey, frontmatter, content, contentHash);
|
|
173
|
+
return writeResult;
|
|
173
174
|
}
|
|
174
175
|
// ── Index sync (files → DB) ───────────────────────────────────
|
|
175
176
|
/**
|
|
@@ -12,6 +12,7 @@ export interface LocalKnowledgePage {
|
|
|
12
12
|
tags: string[];
|
|
13
13
|
refs: string[];
|
|
14
14
|
slRefs: string[];
|
|
15
|
+
connections: string[];
|
|
15
16
|
}
|
|
16
17
|
export interface LocalKnowledgeSummary {
|
|
17
18
|
key: string;
|
|
@@ -40,6 +41,7 @@ export interface WriteLocalKnowledgePageInput {
|
|
|
40
41
|
representativeSql?: string;
|
|
41
42
|
usage?: HistoricSqlWikiUsageFrontmatter;
|
|
42
43
|
fingerprints?: string[];
|
|
44
|
+
connections?: string[];
|
|
43
45
|
}
|
|
44
46
|
/** @internal */
|
|
45
47
|
export declare function writeLocalKnowledgePage(project: KtxLocalProject, input: WriteLocalKnowledgePageInput): Promise<KtxFileWriteResult>;
|
|
@@ -49,6 +51,7 @@ export declare function readLocalKnowledgePage(project: KtxLocalProject, input:
|
|
|
49
51
|
}): Promise<LocalKnowledgePage | null>;
|
|
50
52
|
export declare function listLocalKnowledgePages(project: KtxLocalProject, input?: {
|
|
51
53
|
userId?: string;
|
|
54
|
+
connectionId?: string;
|
|
52
55
|
}): Promise<LocalKnowledgeSummary[]>;
|
|
53
56
|
/**
|
|
54
57
|
* List wiki page keys without reading or parsing file contents.
|
|
@@ -60,9 +63,19 @@ export declare function listLocalKnowledgePages(project: KtxLocalProject, input?
|
|
|
60
63
|
export declare function listLocalKnowledgePageKeys(project: KtxLocalProject, input?: {
|
|
61
64
|
userId?: string;
|
|
62
65
|
}): Promise<string[]>;
|
|
66
|
+
/**
|
|
67
|
+
* Connection ids referenced by any stored page's `connections` frontmatter,
|
|
68
|
+
* sorted and deduped. Derived from files; an id here that is not configured in
|
|
69
|
+
* `ktx.yaml` is a warn-only condition (config and content evolve independently)
|
|
70
|
+
* and never blocks loading, searching, or reading.
|
|
71
|
+
*/
|
|
72
|
+
export declare function listReferencedConnectionIds(project: KtxLocalProject, input?: {
|
|
73
|
+
userId?: string;
|
|
74
|
+
}): Promise<string[]>;
|
|
63
75
|
export declare function searchLocalKnowledgePages(project: KtxLocalProject, input: {
|
|
64
76
|
query: string;
|
|
65
77
|
userId?: string;
|
|
78
|
+
connectionId?: string;
|
|
66
79
|
embeddingService?: KtxEmbeddingPort | null;
|
|
67
80
|
limit?: number;
|
|
68
81
|
}): Promise<LocalKnowledgeSearchResult[]>;
|
|
@@ -20,6 +20,17 @@ function assertSafePathToken(kind, value) {
|
|
|
20
20
|
function stringArray(value) {
|
|
21
21
|
return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
|
|
22
22
|
}
|
|
23
|
+
/** Coerce a YAML scalar or list into a string list — `connections` accepts a single id or a list. */
|
|
24
|
+
function stringList(value) {
|
|
25
|
+
if (typeof value === 'string') {
|
|
26
|
+
return value.trim().length > 0 ? [value] : [];
|
|
27
|
+
}
|
|
28
|
+
return stringArray(value);
|
|
29
|
+
}
|
|
30
|
+
/** A page applies to `connectionId` when it is unscoped (empty) or lists that id. */
|
|
31
|
+
function pageMatchesConnection(connections, connectionId) {
|
|
32
|
+
return connectionId === undefined || connections.length === 0 || connections.includes(connectionId);
|
|
33
|
+
}
|
|
23
34
|
function knowledgePath(scope, userId, key) {
|
|
24
35
|
const safeKey = assertFlatWikiKey(key);
|
|
25
36
|
if (scope === 'GLOBAL') {
|
|
@@ -47,6 +58,7 @@ function parseKnowledgePage(key, path, scope, raw) {
|
|
|
47
58
|
tags: [],
|
|
48
59
|
refs: [],
|
|
49
60
|
slRefs: [],
|
|
61
|
+
connections: [],
|
|
50
62
|
};
|
|
51
63
|
}
|
|
52
64
|
const frontmatter = (YAML.parse(match[1]) ?? {});
|
|
@@ -59,6 +71,7 @@ function parseKnowledgePage(key, path, scope, raw) {
|
|
|
59
71
|
tags: stringArray(frontmatter.tags),
|
|
60
72
|
refs: stringArray(frontmatter.refs),
|
|
61
73
|
slRefs: stringArray(frontmatter.sl_refs),
|
|
74
|
+
connections: stringList(frontmatter.connections),
|
|
62
75
|
};
|
|
63
76
|
}
|
|
64
77
|
function serializeKnowledgePage(input) {
|
|
@@ -74,6 +87,7 @@ function serializeKnowledgePage(input) {
|
|
|
74
87
|
...(input.representativeSql === undefined ? {} : { representative_sql: input.representativeSql }),
|
|
75
88
|
...(input.usage === undefined ? {} : { usage: input.usage }),
|
|
76
89
|
...(input.fingerprints === undefined ? {} : { fingerprints: input.fingerprints }),
|
|
90
|
+
...(input.connections === undefined ? {} : { connections: input.connections }),
|
|
77
91
|
};
|
|
78
92
|
return `---\n${YAML.stringify(frontmatter, { indent: 2, lineWidth: 0 }).trimEnd()}\n---\n\n${input.content.trim()}\n`;
|
|
79
93
|
}
|
|
@@ -111,7 +125,7 @@ export async function listLocalKnowledgePages(project, input = {}) {
|
|
|
111
125
|
continue;
|
|
112
126
|
}
|
|
113
127
|
const page = await readPageAtPath(project, key, path, scope);
|
|
114
|
-
if (page) {
|
|
128
|
+
if (page && pageMatchesConnection(page.connections, input.connectionId)) {
|
|
115
129
|
pages.push({ key, path, scope, summary: page.summary });
|
|
116
130
|
}
|
|
117
131
|
}
|
|
@@ -140,6 +154,22 @@ export async function listLocalKnowledgePageKeys(project, input = {}) {
|
|
|
140
154
|
}
|
|
141
155
|
return [...keys].sort();
|
|
142
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* Connection ids referenced by any stored page's `connections` frontmatter,
|
|
159
|
+
* sorted and deduped. Derived from files; an id here that is not configured in
|
|
160
|
+
* `ktx.yaml` is a warn-only condition (config and content evolve independently)
|
|
161
|
+
* and never blocks loading, searching, or reading.
|
|
162
|
+
*/
|
|
163
|
+
export async function listReferencedConnectionIds(project, input = {}) {
|
|
164
|
+
const pages = await loadAllKnowledgePages(project, { userId: input.userId });
|
|
165
|
+
const ids = new Set();
|
|
166
|
+
for (const page of pages) {
|
|
167
|
+
for (const id of page.connections) {
|
|
168
|
+
ids.add(id);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return [...ids].sort();
|
|
172
|
+
}
|
|
143
173
|
function scorePage(page, terms) {
|
|
144
174
|
const haystack = buildKnowledgeSearchText(page.key, page.summary, page.content, page.tags).toLowerCase();
|
|
145
175
|
return terms.some((term) => haystack.includes(term)) ? 3 : 0;
|
|
@@ -170,7 +200,10 @@ function tokenLaneCandidates(pages, terms) {
|
|
|
170
200
|
.sort((left, right) => right.score - left.score || left.page.path.localeCompare(right.page.path));
|
|
171
201
|
}
|
|
172
202
|
async function loadAllKnowledgePages(project, input = {}) {
|
|
173
|
-
const summaries = await listLocalKnowledgePages(project, {
|
|
203
|
+
const summaries = await listLocalKnowledgePages(project, {
|
|
204
|
+
userId: input.userId,
|
|
205
|
+
connectionId: input.connectionId,
|
|
206
|
+
});
|
|
174
207
|
const pages = [];
|
|
175
208
|
for (const summary of summaries) {
|
|
176
209
|
const page = await readPageAtPath(project, summary.key, summary.path, summary.scope);
|
|
@@ -181,8 +214,17 @@ async function loadAllKnowledgePages(project, input = {}) {
|
|
|
181
214
|
return pages;
|
|
182
215
|
}
|
|
183
216
|
async function searchLocalKnowledgePagesWithSqlite(project, input) {
|
|
217
|
+
// The sqlite index is shared across connections and `index.sync` deletes any
|
|
218
|
+
// page not in its input, so sync the FULL corpus and apply the connection
|
|
219
|
+
// filter only to the candidate/result set (`allowedPaths`), never to sync.
|
|
184
220
|
const pages = await loadAllKnowledgePages(project, { userId: input.userId });
|
|
185
|
-
const
|
|
221
|
+
const allowedPaths = new Set(pages.filter((page) => pageMatchesConnection(page.connections, input.connectionId)).map((page) => page.path));
|
|
222
|
+
const allowedPages = pages.filter((page) => allowedPaths.has(page.path));
|
|
223
|
+
// Scope the lexical/semantic lanes inside the query so their LIMIT applies to
|
|
224
|
+
// in-scope rows; only narrow when a connection is requested (otherwise every
|
|
225
|
+
// path is allowed and the filter is a no-op).
|
|
226
|
+
const scopedPaths = input.connectionId === undefined ? undefined : [...allowedPaths];
|
|
227
|
+
const byPath = new Map(allowedPages.map((page) => [page.path, page]));
|
|
186
228
|
const embeddingService = input.embeddingService ?? null;
|
|
187
229
|
const index = new SqliteKnowledgeIndex({ dbPath: sqliteKnowledgeDbPath(project) });
|
|
188
230
|
const existingPages = index.getExistingPages();
|
|
@@ -204,7 +246,7 @@ async function searchLocalKnowledgePagesWithSqlite(project, input) {
|
|
|
204
246
|
});
|
|
205
247
|
}
|
|
206
248
|
index.sync(indexPages);
|
|
207
|
-
const finalLimit = input.limit ?? Math.max(1,
|
|
249
|
+
const finalLimit = input.limit ?? Math.max(1, allowedPages.length);
|
|
208
250
|
const core = new HybridSearchCore();
|
|
209
251
|
const generators = [
|
|
210
252
|
{
|
|
@@ -213,6 +255,7 @@ async function searchLocalKnowledgePagesWithSqlite(project, input) {
|
|
|
213
255
|
const rows = index.searchLexicalCandidates({
|
|
214
256
|
queryText: args.queryText,
|
|
215
257
|
limit: args.laneCandidatePoolLimit,
|
|
258
|
+
allowedPaths: scopedPaths,
|
|
216
259
|
});
|
|
217
260
|
return {
|
|
218
261
|
candidates: rows.map((row) => ({ id: row.id, rank: row.rank, rawScore: row.rawScore })),
|
|
@@ -222,7 +265,7 @@ async function searchLocalKnowledgePagesWithSqlite(project, input) {
|
|
|
222
265
|
{
|
|
223
266
|
lane: 'token',
|
|
224
267
|
async generate(args) {
|
|
225
|
-
const rows = tokenLaneCandidates(
|
|
268
|
+
const rows = tokenLaneCandidates(allowedPages, args.normalizedQuery.terms).slice(0, args.laneCandidatePoolLimit);
|
|
226
269
|
return {
|
|
227
270
|
candidates: rows.map((row, index) => ({
|
|
228
271
|
id: row.page.path,
|
|
@@ -244,6 +287,7 @@ async function searchLocalKnowledgePagesWithSqlite(project, input) {
|
|
|
244
287
|
const rows = index.searchSemanticCandidates({
|
|
245
288
|
queryEmbedding,
|
|
246
289
|
limit: args.laneCandidatePoolLimit,
|
|
290
|
+
allowedPaths: scopedPaths,
|
|
247
291
|
});
|
|
248
292
|
return {
|
|
249
293
|
candidates: rows
|
|
@@ -285,7 +329,7 @@ async function searchLocalKnowledgePagesWithScan(project, input) {
|
|
|
285
329
|
.split(/\s+/)
|
|
286
330
|
.map((term) => term.trim())
|
|
287
331
|
.filter(Boolean);
|
|
288
|
-
const pages = await loadAllKnowledgePages(project, { userId: input.userId });
|
|
332
|
+
const pages = await loadAllKnowledgePages(project, { userId: input.userId, connectionId: input.connectionId });
|
|
289
333
|
const results = [];
|
|
290
334
|
for (const page of pages) {
|
|
291
335
|
const score = scorePage(page, terms);
|
|
@@ -36,10 +36,12 @@ export declare class SqliteKnowledgeIndex {
|
|
|
36
36
|
searchLexicalCandidates(input: {
|
|
37
37
|
queryText: string;
|
|
38
38
|
limit: number;
|
|
39
|
+
allowedPaths?: readonly string[];
|
|
39
40
|
}): WikiSqliteLaneCandidate[];
|
|
40
41
|
searchSemanticCandidates(input: {
|
|
41
42
|
queryEmbedding: number[];
|
|
42
43
|
limit: number;
|
|
44
|
+
allowedPaths?: readonly string[];
|
|
43
45
|
}): WikiSqliteLaneCandidate[];
|
|
44
46
|
search(query: string, limit: number): SqliteKnowledgeIndexSearchResult[];
|
|
45
47
|
private pathForPage;
|
|
@@ -38,6 +38,17 @@ function parseEmbedding(raw) {
|
|
|
38
38
|
return null;
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
|
+
/** A provided-but-empty allowlist means "no page is in scope", distinct from an absent (unfiltered) one. */
|
|
42
|
+
function isEmptyAllowlist(allowedPaths) {
|
|
43
|
+
return allowedPaths !== undefined && allowedPaths.length === 0;
|
|
44
|
+
}
|
|
45
|
+
/** Build a `<keyword> path IN (?, …)` fragment so the scope filter applies inside the query, before any LIMIT. */
|
|
46
|
+
function pathInClause(keyword, allowedPaths) {
|
|
47
|
+
if (allowedPaths === undefined || allowedPaths.length === 0) {
|
|
48
|
+
return { sql: '', params: [] };
|
|
49
|
+
}
|
|
50
|
+
return { sql: ` ${keyword} path IN (${allowedPaths.map(() => '?').join(', ')})`, params: [...allowedPaths] };
|
|
51
|
+
}
|
|
41
52
|
function normalizeFtsQuery(query) {
|
|
42
53
|
const terms = query
|
|
43
54
|
.toLowerCase()
|
|
@@ -156,18 +167,19 @@ export class SqliteKnowledgeIndex {
|
|
|
156
167
|
}
|
|
157
168
|
searchLexicalCandidates(input) {
|
|
158
169
|
const ftsQuery = normalizeFtsQuery(input.queryText);
|
|
159
|
-
if (!ftsQuery) {
|
|
170
|
+
if (!ftsQuery || isEmptyAllowlist(input.allowedPaths)) {
|
|
160
171
|
return [];
|
|
161
172
|
}
|
|
173
|
+
const pathFilter = pathInClause('AND', input.allowedPaths);
|
|
162
174
|
const rows = this.db
|
|
163
175
|
.prepare(`
|
|
164
176
|
SELECT path, bm25(knowledge_pages_fts) AS rank
|
|
165
177
|
FROM knowledge_pages_fts
|
|
166
|
-
WHERE knowledge_pages_fts MATCH
|
|
178
|
+
WHERE knowledge_pages_fts MATCH ?${pathFilter.sql}
|
|
167
179
|
ORDER BY rank ASC, path ASC
|
|
168
180
|
LIMIT ?
|
|
169
181
|
`)
|
|
170
|
-
.all(ftsQuery, Math.max(1, input.limit));
|
|
182
|
+
.all(ftsQuery, ...pathFilter.params, Math.max(1, input.limit));
|
|
171
183
|
return rows.map((row, index) => ({
|
|
172
184
|
id: row.path,
|
|
173
185
|
path: row.path,
|
|
@@ -176,13 +188,17 @@ export class SqliteKnowledgeIndex {
|
|
|
176
188
|
}));
|
|
177
189
|
}
|
|
178
190
|
searchSemanticCandidates(input) {
|
|
191
|
+
if (isEmptyAllowlist(input.allowedPaths)) {
|
|
192
|
+
return [];
|
|
193
|
+
}
|
|
194
|
+
const pathFilter = pathInClause('WHERE', input.allowedPaths);
|
|
179
195
|
const rows = this.db
|
|
180
196
|
.prepare(`
|
|
181
197
|
SELECT path, embedding_json
|
|
182
|
-
FROM knowledge_pages
|
|
198
|
+
FROM knowledge_pages${pathFilter.sql}
|
|
183
199
|
ORDER BY path ASC
|
|
184
200
|
`)
|
|
185
|
-
.all();
|
|
201
|
+
.all(...pathFilter.params);
|
|
186
202
|
return rows
|
|
187
203
|
.flatMap((row) => {
|
|
188
204
|
if (!row.embedding_json) {
|
|
@@ -15,6 +15,7 @@ declare const wikiWriteInputSchema: z.ZodObject<{
|
|
|
15
15
|
tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
16
16
|
refs: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
17
17
|
sl_refs: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
18
|
+
connections: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
|
18
19
|
source: z.ZodOptional<z.ZodString>;
|
|
19
20
|
intent: z.ZodOptional<z.ZodString>;
|
|
20
21
|
tables: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
@@ -58,6 +59,7 @@ export declare class WikiWriteTool extends BaseTool<typeof wikiWriteInputSchema>
|
|
|
58
59
|
tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
59
60
|
refs: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
60
61
|
sl_refs: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
62
|
+
connections: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
|
61
63
|
source: z.ZodOptional<z.ZodString>;
|
|
62
64
|
intent: z.ZodOptional<z.ZodString>;
|
|
63
65
|
tables: z.ZodOptional<z.ZodArray<z.ZodString>>;
|