@rasmusengelbrecht/pi-semantic-query 0.1.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/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # pi-semantic-query
2
+
3
+ Pi extension for working with governed semantic metrics from `semantic-query-compiler`.
4
+
5
+ It gives pi tools to discover metrics, inspect definitions, validate a model, and compile metric requests to SQL. It does **not** execute warehouse queries.
6
+
7
+ ## Requirements
8
+
9
+ - pi coding agent
10
+ - Node/npm for installing the pi package
11
+ - `semantic-query-compiler` installed so the `semantic` CLI is on `PATH`
12
+ - a semantic model in the project, usually `semantic.yml`
13
+
14
+ Install the Python compiler first:
15
+
16
+ ```bash
17
+ uv tool install 'semantic-query-compiler @ git+https://github.com/rasmusengelbrecht/semantic-query-compiler.git'
18
+ semantic --help
19
+ ```
20
+
21
+ ## Install in pi
22
+
23
+ From npm, after publishing:
24
+
25
+ ```bash
26
+ pi install npm:@rasmusengelbrecht/pi-semantic-query
27
+ ```
28
+
29
+ For local development:
30
+
31
+ ```bash
32
+ pi install /absolute/path/to/pi-semantic-query
33
+ ```
34
+
35
+ Or try without installing:
36
+
37
+ ```bash
38
+ pi -e /absolute/path/to/pi-semantic-query
39
+ ```
40
+
41
+ ## Tools
42
+
43
+ The package registers these pi tools:
44
+
45
+ - `semantic_metrics` — list metrics with discovery metadata
46
+ - `semantic_search_metrics` — search metrics by id, name, description, synonyms, and teams
47
+ - `semantic_describe` — describe one metric definition
48
+ - `semantic_validate` — validate a semantic model
49
+ - `semantic_compile` — compile a metric request to SQL
50
+
51
+ All tools shell out to the local `semantic` CLI. By default they look for one of:
52
+
53
+ - `semantic.yml`
54
+ - `semantic.yaml`
55
+ - `model.yml`
56
+ - `model.yaml`
57
+ - `semantic_layer.yml`
58
+ - `semantic_layer.yaml`
59
+
60
+ Pass `model` explicitly when your file lives somewhere else.
61
+
62
+ ## Example prompt
63
+
64
+ > List available semantic metrics in this repo, find the revenue metric, then compile revenue by country for the last 12 complete months.
65
+
66
+ The agent should:
67
+
68
+ 1. call `semantic_search_metrics`
69
+ 2. call `semantic_describe` for the chosen metric
70
+ 3. call `semantic_compile` with an inline request shape and `period`
71
+
72
+ Example request shape for `semantic_compile`:
73
+
74
+ ```json
75
+ {
76
+ "metricId": "revenue",
77
+ "period": "last 12 complete months",
78
+ "dialect": "bigquery",
79
+ "request": {
80
+ "timeGrain": "monthly",
81
+ "breakdownDimensionIds": ["country"]
82
+ }
83
+ }
84
+ ```
85
+
86
+ ## Safety boundary
87
+
88
+ This package is compile-only by design. `semantic_compile` returns SQL; it does not run the SQL. Query execution touches credentials, cost, and data access, so execution should stay in the caller's existing warehouse tooling or a separate explicitly configured integration.
89
+
90
+ ## Publishing
91
+
92
+ ```bash
93
+ npm pack --dry-run
94
+ npm publish --access public
95
+ ```
96
+
97
+ The `pi-package` keyword makes the package discoverable by pi package indexes.
@@ -0,0 +1,263 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Type, type Static } from "typebox";
3
+ import { execFile } from "node:child_process";
4
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
5
+ import { existsSync } from "node:fs";
6
+ import { join, resolve } from "node:path";
7
+ import { tmpdir } from "node:os";
8
+
9
+ const MODEL_CANDIDATES = [
10
+ "semantic.yml",
11
+ "semantic.yaml",
12
+ "model.yml",
13
+ "model.yaml",
14
+ "semantic_layer.yml",
15
+ "semantic_layer.yaml",
16
+ ];
17
+
18
+ const BaseParams = Type.Object({
19
+ model: Type.Optional(
20
+ Type.String({ description: "Path to semantic model YAML. Defaults to semantic.yml/model.yml candidates in the current project." }),
21
+ ),
22
+ semanticBinary: Type.Optional(
23
+ Type.String({ description: "Semantic CLI binary to run. Defaults to semantic." }),
24
+ ),
25
+ });
26
+
27
+ const MetricsParams = BaseParams;
28
+
29
+ const SearchParams = Type.Intersect([
30
+ BaseParams,
31
+ Type.Object({
32
+ query: Type.String({ description: "Metric search query. Matches id, name, description, synonyms, and teams." }),
33
+ limit: Type.Optional(Type.Number({ description: "Maximum number of matches. Defaults to 10." })),
34
+ }),
35
+ ]);
36
+
37
+ const DescribeParams = Type.Intersect([
38
+ BaseParams,
39
+ Type.Object({
40
+ metricId: Type.String({ description: "Metric identifier to describe." }),
41
+ }),
42
+ ]);
43
+
44
+ const ValidateParams = Type.Intersect([
45
+ BaseParams,
46
+ Type.Object({
47
+ dialect: Type.Optional(Type.String({ description: "Dialect for compile checks. Defaults to bigquery." })),
48
+ checkCompilable: Type.Optional(Type.Boolean({ description: "Compile every metric as part of validation." })),
49
+ columnRegistry: Type.Optional(Type.String({ description: "Optional column registry JSON path." })),
50
+ maxRegistryAgeHours: Type.Optional(Type.Number({ description: "Warn when registry metadata is older than this many hours." })),
51
+ requireJoinUniqueness: Type.Optional(Type.Boolean({ description: "Require join target uniqueness metadata from the registry." })),
52
+ }),
53
+ ]);
54
+
55
+ const CompileParams = Type.Intersect([
56
+ BaseParams,
57
+ Type.Object({
58
+ metricId: Type.String({ description: "Metric identifier to compile." }),
59
+ dialect: Type.Optional(Type.String({ description: "Target SQL dialect. Defaults to bigquery." })),
60
+ period: Type.Optional(Type.String({ description: "Named period, e.g. current year or last 12 complete months." })),
61
+ requestPath: Type.Optional(Type.String({ description: "Path to request JSON shape." })),
62
+ request: Type.Optional(Type.Any({ description: "Inline request JSON shape. Use this instead of requestPath for agent-created requests." })),
63
+ format: Type.Optional(Type.Union([
64
+ Type.Literal("sql"),
65
+ Type.Literal("json"),
66
+ Type.Literal("explain"),
67
+ ], { description: "Output format. Defaults to sql." })),
68
+ targetTable: Type.Optional(Type.String({ description: "Optional target table for targetSeries requests." })),
69
+ targetMetricColumn: Type.Optional(Type.String({ description: "Target metric id column. Defaults to metric_id." })),
70
+ targetSeriesColumn: Type.Optional(Type.String({ description: "Target series column. Defaults to target_series." })),
71
+ targetTimeColumn: Type.Optional(Type.String({ description: "Target time column. Defaults to metric_time." })),
72
+ targetValueColumn: Type.Optional(Type.String({ description: "Target value column. Defaults to target_value." })),
73
+ targetDimensionColumns: Type.Optional(Type.Array(Type.String(), { description: "Target dimension columns used to infer the lowest matching target grain." })),
74
+ targetNullColumns: Type.Optional(Type.Array(Type.String(), { description: "Target columns that must be NULL for this request." })),
75
+ }),
76
+ ]);
77
+
78
+ type BaseParamsType = Static<typeof BaseParams>;
79
+ type SearchParamsType = Static<typeof SearchParams>;
80
+ type DescribeParamsType = Static<typeof DescribeParams>;
81
+ type ValidateParamsType = Static<typeof ValidateParams>;
82
+ type CompileParamsType = Static<typeof CompileParams>;
83
+
84
+ type SemanticResult = {
85
+ stdout: string;
86
+ stderr: string;
87
+ command: string[];
88
+ };
89
+
90
+ function semanticBinary(params: BaseParamsType): string {
91
+ return params.semanticBinary || "semantic";
92
+ }
93
+
94
+ function resolveModelPath(ctx: ExtensionContext, model?: string): string {
95
+ if (model) return resolve(ctx.cwd, model);
96
+ for (const candidate of MODEL_CANDIDATES) {
97
+ const path = resolve(ctx.cwd, candidate);
98
+ if (existsSync(path)) return path;
99
+ }
100
+ throw new Error(
101
+ `No semantic model found. Pass model explicitly or add one of: ${MODEL_CANDIDATES.join(", ")}`,
102
+ );
103
+ }
104
+
105
+ function runSemantic(
106
+ ctx: ExtensionContext,
107
+ params: BaseParamsType,
108
+ args: string[],
109
+ signal: AbortSignal | undefined,
110
+ ): Promise<SemanticResult> {
111
+ const command = semanticBinary(params);
112
+ return new Promise((resolvePromise, reject) => {
113
+ execFile(command, args, { cwd: ctx.cwd, signal, maxBuffer: 20 * 1024 * 1024 }, (error, stdout, stderr) => {
114
+ const result = { stdout, stderr, command: [command, ...args] };
115
+ if (error) {
116
+ const message = stderr.trim() || error.message;
117
+ reject(new Error(`${command} failed: ${message}`));
118
+ return;
119
+ }
120
+ resolvePromise(result);
121
+ });
122
+ });
123
+ }
124
+
125
+ function jsonContent(result: SemanticResult) {
126
+ const text = result.stdout.trim();
127
+ let parsed: unknown;
128
+ try {
129
+ parsed = text ? JSON.parse(text) : null;
130
+ } catch {
131
+ parsed = text;
132
+ }
133
+ return {
134
+ content: [{ type: "text" as const, text: text || "{}" }],
135
+ details: { command: result.command, result: parsed, stderr: result.stderr || undefined },
136
+ };
137
+ }
138
+
139
+ function textContent(result: SemanticResult) {
140
+ return {
141
+ content: [{ type: "text" as const, text: result.stdout }],
142
+ details: { command: result.command, stderr: result.stderr || undefined },
143
+ };
144
+ }
145
+
146
+ async function withInlineRequest<T>(
147
+ ctx: ExtensionContext,
148
+ request: unknown,
149
+ callback: (path: string | undefined) => Promise<T>,
150
+ ): Promise<T> {
151
+ if (request === undefined) return callback(undefined);
152
+ const dir = await mkdtemp(join(tmpdir(), "pi-semantic-query-"));
153
+ const path = join(dir, "request.json");
154
+ try {
155
+ await writeFile(path, JSON.stringify(request, null, 2), "utf8");
156
+ return await callback(path);
157
+ } finally {
158
+ await rm(dir, { recursive: true, force: true });
159
+ }
160
+ }
161
+
162
+ export default function semanticQueryExtension(pi: ExtensionAPI) {
163
+ pi.registerTool({
164
+ name: "semantic_metrics",
165
+ label: "Semantic Metrics",
166
+ description: "List semantic metrics with discovery metadata from the current project model.",
167
+ promptSnippet: "Use semantic_metrics to inspect available governed metrics before compiling SQL.",
168
+ promptGuidelines: [
169
+ "Use this before guessing metric identifiers.",
170
+ "Prefer semantic_search_metrics when the user describes a metric in natural language.",
171
+ ],
172
+ parameters: MetricsParams,
173
+ async execute(_toolCallId, params: BaseParamsType, signal, _onUpdate, ctx) {
174
+ const model = resolveModelPath(ctx, params.model);
175
+ return jsonContent(await runSemantic(ctx, params, ["metrics", "--model", model, "--format", "json"], signal));
176
+ },
177
+ });
178
+
179
+ pi.registerTool({
180
+ name: "semantic_search_metrics",
181
+ label: "Semantic Search Metrics",
182
+ description: "Search semantic metrics by id, name, description, synonyms, and teams.",
183
+ promptSnippet: "Use semantic_search_metrics to find the right governed metric from a natural-language phrase.",
184
+ promptGuidelines: ["Use this when a user asks for a metric by business wording rather than exact id."],
185
+ parameters: SearchParams,
186
+ async execute(_toolCallId, params: SearchParamsType, signal, _onUpdate, ctx) {
187
+ const model = resolveModelPath(ctx, params.model);
188
+ const args = [
189
+ "search-metrics",
190
+ params.query,
191
+ "--model",
192
+ model,
193
+ "--format",
194
+ "json",
195
+ ];
196
+ if (params.limit !== undefined) args.push("--limit", String(params.limit));
197
+ return jsonContent(await runSemantic(ctx, params, args, signal));
198
+ },
199
+ });
200
+
201
+ pi.registerTool({
202
+ name: "semantic_describe",
203
+ label: "Semantic Describe",
204
+ description: "Describe one semantic metric, including filters, dimensions, synonyms, and formula notes.",
205
+ promptSnippet: "Use semantic_describe before compiling a metric if the grain, filters, or semantics are unclear.",
206
+ parameters: DescribeParams,
207
+ async execute(_toolCallId, params: DescribeParamsType, signal, _onUpdate, ctx) {
208
+ const model = resolveModelPath(ctx, params.model);
209
+ return jsonContent(await runSemantic(ctx, params, ["describe", params.metricId, "--model", model, "--format", "json"], signal));
210
+ },
211
+ });
212
+
213
+ pi.registerTool({
214
+ name: "semantic_validate",
215
+ label: "Semantic Validate",
216
+ description: "Validate a semantic model and optionally compile-check every metric.",
217
+ promptSnippet: "Use semantic_validate before relying on model changes or generated SQL.",
218
+ parameters: ValidateParams,
219
+ async execute(_toolCallId, params: ValidateParamsType, signal, _onUpdate, ctx) {
220
+ const model = resolveModelPath(ctx, params.model);
221
+ const args = ["validate", "--model", model, "--format", "json"];
222
+ if (params.dialect) args.push("--dialect", params.dialect);
223
+ if (params.checkCompilable) args.push("--check-compilable");
224
+ if (params.columnRegistry) args.push("--column-registry", resolve(ctx.cwd, params.columnRegistry));
225
+ if (params.maxRegistryAgeHours !== undefined) args.push("--max-registry-age-hours", String(params.maxRegistryAgeHours));
226
+ if (params.requireJoinUniqueness) args.push("--require-join-uniqueness");
227
+ return jsonContent(await runSemantic(ctx, params, args, signal));
228
+ },
229
+ });
230
+
231
+ pi.registerTool({
232
+ name: "semantic_compile",
233
+ label: "Semantic Compile",
234
+ description: "Compile a governed semantic metric request to SQL. Does not execute warehouse queries.",
235
+ promptSnippet: "Use semantic_compile to produce inspectable SQL for a governed metric. It does not execute the query.",
236
+ promptGuidelines: [
237
+ "Prefer period over hard-coded fromDate/toDate for CLI use.",
238
+ "Use inline request for agent-created request shapes.",
239
+ "Do not present compiled SQL as executed results.",
240
+ ],
241
+ parameters: CompileParams,
242
+ async execute(_toolCallId, params: CompileParamsType, signal, _onUpdate, ctx) {
243
+ const model = resolveModelPath(ctx, params.model);
244
+ return withInlineRequest(ctx, params.request, async (inlineRequestPath) => {
245
+ const requestPath = params.requestPath ? resolve(ctx.cwd, params.requestPath) : inlineRequestPath;
246
+ const args = ["compile", params.metricId, "--model", model];
247
+ if (requestPath) args.push("--request", requestPath);
248
+ if (params.period) args.push("--period", params.period);
249
+ if (params.dialect) args.push("--dialect", params.dialect);
250
+ if (params.format) args.push("--format", params.format);
251
+ if (params.targetTable) args.push("--target-table", params.targetTable);
252
+ if (params.targetMetricColumn) args.push("--target-metric-column", params.targetMetricColumn);
253
+ if (params.targetSeriesColumn) args.push("--target-series-column", params.targetSeriesColumn);
254
+ if (params.targetTimeColumn) args.push("--target-time-column", params.targetTimeColumn);
255
+ if (params.targetValueColumn) args.push("--target-value-column", params.targetValueColumn);
256
+ for (const column of params.targetDimensionColumns || []) args.push("--target-dimension-column", column);
257
+ for (const column of params.targetNullColumns || []) args.push("--target-null-column", column);
258
+ const result = await runSemantic(ctx, params, args, signal);
259
+ return params.format === "json" || params.format === "explain" ? jsonContent(result) : textContent(result);
260
+ });
261
+ },
262
+ });
263
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@rasmusengelbrecht/pi-semantic-query",
3
+ "version": "0.1.0",
4
+ "description": "Compile and inspect governed semantic metrics from pi coding agent",
5
+ "keywords": [
6
+ "pi-package",
7
+ "semantic-layer",
8
+ "analytics",
9
+ "metrics",
10
+ "sql"
11
+ ],
12
+ "license": "MIT",
13
+ "type": "module",
14
+ "pi": {
15
+ "extensions": [
16
+ "./extensions"
17
+ ]
18
+ },
19
+ "files": [
20
+ "extensions",
21
+ "README.md",
22
+ "package.json"
23
+ ],
24
+ "peerDependencies": {
25
+ "@earendil-works/pi-coding-agent": "*",
26
+ "typebox": "*"
27
+ },
28
+ "devDependencies": {
29
+ "@earendil-works/pi-coding-agent": "^0.77.0",
30
+ "@types/node": "^25.9.3",
31
+ "jiti": "^2.7.0",
32
+ "typebox": "^1.0.55",
33
+ "typescript": "^5.9.3"
34
+ },
35
+ "scripts": {
36
+ "typecheck": "tsc --noEmit"
37
+ }
38
+ }