@vaultgradient/pipequery-cli 0.3.3 → 0.6.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.
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Natural-language → pipequery translator for the Telegram bot.
3
+ *
4
+ * Strategy:
5
+ * 1. Build a comprehensive, stable system prompt describing the DSL grammar
6
+ * and the available operators. Big enough to clear Haiku 4.5's 4096-token
7
+ * cacheable-prefix minimum so subsequent calls hit the cache.
8
+ * 2. Build a per-tenant "schema preamble" from the live source list +
9
+ * sample fields. Stable across requests until the yaml changes, so it
10
+ * gets its own cache breakpoint.
11
+ * 3. The user's question is the only volatile piece; it goes after both
12
+ * cache breakpoints.
13
+ *
14
+ * The model is instructed to emit a single JSON object so we don't have to
15
+ * parse free-form prose. A retry-once-on-bad-json wrapper keeps the bot from
16
+ * dying when the model adds preamble.
17
+ */
18
+ import Anthropic from '@anthropic-ai/sdk';
19
+ import type { Provider } from '../mcp/provider.js';
20
+
21
+ export interface TranslationResult {
22
+ /** Pipequery expression to execute. */
23
+ expression: string;
24
+ /** Short human-readable explanation of what the expression does. */
25
+ explanation: string;
26
+ }
27
+
28
+ export interface NLTranslator {
29
+ translate(question: string): Promise<TranslationResult>;
30
+ /** Force the schema preamble to be rebuilt on the next translate(). */
31
+ invalidateSchema(): void;
32
+ }
33
+
34
+ const MODEL = 'claude-haiku-4-5';
35
+ const MAX_TOKENS = 1024;
36
+ const SCHEMA_TTL_MS = 60_000; // refresh schema preamble every minute
37
+
38
+ export interface TranslatorOptions {
39
+ apiKey: string;
40
+ /** Override the model for tests. */
41
+ model?: string;
42
+ /** Inject a pre-built Anthropic client (used in tests). */
43
+ client?: Anthropic;
44
+ }
45
+
46
+ export function createNLTranslator(provider: Provider, opts: TranslatorOptions): NLTranslator {
47
+ const client = opts.client ?? new Anthropic({ apiKey: opts.apiKey });
48
+ const model = opts.model ?? MODEL;
49
+
50
+ let cachedSchema: { text: string; builtAt: number } | null = null;
51
+
52
+ async function getSchemaPreamble(): Promise<string> {
53
+ const now = Date.now();
54
+ if (cachedSchema && now - cachedSchema.builtAt < SCHEMA_TTL_MS) {
55
+ return cachedSchema.text;
56
+ }
57
+ const text = await buildSchemaPreamble(provider);
58
+ cachedSchema = { text, builtAt: now };
59
+ return text;
60
+ }
61
+
62
+ async function translate(question: string): Promise<TranslationResult> {
63
+ const schema = await getSchemaPreamble();
64
+ const response = await client.messages.create({
65
+ model,
66
+ max_tokens: MAX_TOKENS,
67
+ system: [
68
+ // Cache breakpoint #1 — the always-stable DSL grammar + instructions.
69
+ // This is the largest block and the one that benefits most from being
70
+ // cached across every translation request the bot ever serves.
71
+ {
72
+ type: 'text',
73
+ text: SYSTEM_PROMPT,
74
+ cache_control: { type: 'ephemeral' },
75
+ },
76
+ // Cache breakpoint #2 — the per-tenant source schema. Stable until
77
+ // the yaml changes (or SCHEMA_TTL_MS elapses).
78
+ {
79
+ type: 'text',
80
+ text: schema,
81
+ cache_control: { type: 'ephemeral' },
82
+ },
83
+ ],
84
+ messages: [
85
+ {
86
+ role: 'user',
87
+ content: question,
88
+ },
89
+ ],
90
+ });
91
+
92
+ const text = extractText(response);
93
+ return parseTranslation(text);
94
+ }
95
+
96
+ return {
97
+ translate,
98
+ invalidateSchema: () => {
99
+ cachedSchema = null;
100
+ },
101
+ };
102
+ }
103
+
104
+ // ─── Schema preamble ────────────────────────────────────────────────────────
105
+
106
+ async function buildSchemaPreamble(provider: Provider): Promise<string> {
107
+ const sources = await provider.listSources();
108
+ const endpoints = await provider.listEndpoints();
109
+
110
+ const sourceLines: string[] = [];
111
+ for (const s of sources) {
112
+ let fields: string[] = [];
113
+ try {
114
+ const desc = await provider.describeSource(s.name, 3);
115
+ if (desc) fields = desc.fields;
116
+ } catch {
117
+ // describeSource can fail for unhealthy sources; skip the field list.
118
+ }
119
+ const fieldStr = fields.length ? ` — fields: ${fields.join(', ')}` : '';
120
+ sourceLines.push(`- ${s.name} (${s.status.rowCount} rows)${fieldStr}`);
121
+ }
122
+ const endpointLines = endpoints.map((e) => `- ${e.path} → ${e.config.query}`);
123
+
124
+ return [
125
+ '## Available data sources',
126
+ sourceLines.length ? sourceLines.join('\n') : '(no sources configured)',
127
+ '',
128
+ '## Pre-defined endpoints',
129
+ endpointLines.length ? endpointLines.join('\n') : '(no endpoints configured)',
130
+ ].join('\n');
131
+ }
132
+
133
+ // ─── Response parsing ───────────────────────────────────────────────────────
134
+
135
+ function extractText(response: Anthropic.Message): string {
136
+ const parts: string[] = [];
137
+ for (const block of response.content) {
138
+ if (block.type === 'text') parts.push(block.text);
139
+ }
140
+ return parts.join('').trim();
141
+ }
142
+
143
+ function parseTranslation(raw: string): TranslationResult {
144
+ // Strip code fences if the model wrapped its JSON in ```json ... ```
145
+ const cleaned = raw
146
+ .replace(/^```json\s*/i, '')
147
+ .replace(/^```\s*/i, '')
148
+ .replace(/```\s*$/i, '')
149
+ .trim();
150
+
151
+ // Find the first JSON object in the response.
152
+ const start = cleaned.indexOf('{');
153
+ const end = cleaned.lastIndexOf('}');
154
+ if (start === -1 || end === -1 || end < start) {
155
+ throw new Error(`Translator returned non-JSON: ${truncate(raw, 200)}`);
156
+ }
157
+ const slice = cleaned.slice(start, end + 1);
158
+ let parsed: unknown;
159
+ try {
160
+ parsed = JSON.parse(slice);
161
+ } catch (err) {
162
+ throw new Error(`Translator returned invalid JSON: ${truncate(raw, 200)}`);
163
+ }
164
+ if (
165
+ typeof parsed !== 'object' ||
166
+ parsed === null ||
167
+ typeof (parsed as Record<string, unknown>).expression !== 'string'
168
+ ) {
169
+ throw new Error(`Translator response missing "expression" field: ${truncate(raw, 200)}`);
170
+ }
171
+ const obj = parsed as { expression: string; explanation?: string };
172
+ return {
173
+ expression: obj.expression.trim(),
174
+ explanation: typeof obj.explanation === 'string' ? obj.explanation.trim() : '',
175
+ };
176
+ }
177
+
178
+ function truncate(s: string, n: number): string {
179
+ return s.length > n ? `${s.slice(0, n)}…` : s;
180
+ }
181
+
182
+ // ─── System prompt ──────────────────────────────────────────────────────────
183
+ //
184
+ // Kept inline as a single string so it's trivially stable across calls (any
185
+ // byte change invalidates the cache). Sized to clear Haiku 4.5's 4096-token
186
+ // minimum cacheable prefix — examples are intentionally generous so the model
187
+ // has rich few-shot grounding *and* the cache fires.
188
+
189
+ const SYSTEM_PROMPT = `You are a translator that converts natural-language questions into PipeQuery expressions.
190
+
191
+ PipeQuery is a small, pipe-style query DSL. Every expression starts with a source name (or a literal array) and pipes into one or more operations using \`|\`. PipeQuery is read-only by construction — there is no DDL, DML, INSERT, UPDATE, DELETE, or DROP. You will never produce SQL.
192
+
193
+ # Output contract
194
+
195
+ You MUST respond with a SINGLE JSON object and nothing else. No prose, no markdown, no code fences.
196
+
197
+ The object has exactly two fields:
198
+ - "expression": a string containing the PipeQuery expression to execute.
199
+ - "explanation": a one-sentence plain-English description of what the expression does.
200
+
201
+ Example:
202
+ {"expression": "orders | where(status == \\"paid\\") | sort(total desc) | first(5)", "explanation": "Top 5 paid orders by total."}
203
+
204
+ If the user's question cannot be answered with the available sources, return:
205
+ {"expression": "", "explanation": "<brief reason why no expression was produced>"}
206
+
207
+ # Grammar
208
+
209
+ Pipeline:
210
+ <source_name> | <operation> | <operation> | ...
211
+
212
+ Sources are referenced by their bare name (no quotes). The available source names are listed in the schema preamble that follows this prompt.
213
+
214
+ # Operations
215
+
216
+ ## where(<expression>)
217
+ Filter rows. The expression is evaluated per row and must return a boolean.
218
+ - Comparison operators: == != > >= < <=
219
+ - Logical operators: && || !
220
+ - Arithmetic: + - * / %
221
+ - Field reference: bare identifier (e.g., \`price\`, \`status\`)
222
+ - Strings: double-quoted ("paid")
223
+ - Numbers: 1, 1.5, -42
224
+ - Booleans: true, false
225
+ - Null: null
226
+
227
+ Examples:
228
+ events | where(severity == "high")
229
+ trades | where(price > 100 && symbol == "BTC")
230
+ users | where(!banned)
231
+
232
+ ## sort(<field> asc|desc, ...)
233
+ Sort by one or more fields. Direction is optional, defaults to asc.
234
+ orders | sort(total desc)
235
+ orders | sort(country asc, total desc)
236
+
237
+ ## first(N) / last(N)
238
+ Take the first / last N rows. N must be a positive integer literal.
239
+ trades | sort(ts desc) | first(10)
240
+
241
+ ## distinct / distinct(field, ...)
242
+ Deduplicate rows. With no args, dedupes on the full row; with args, dedupes on the listed fields.
243
+ users | distinct(email)
244
+
245
+ ## select(<expression>, ...)
246
+ Project fields. Each expression can be a bare field, a computed expression, or an alias (\`expr as name\`).
247
+ orders | select(id, total, total * 0.1 as tax)
248
+
249
+ ## groupBy(<field>, ...) and rollup
250
+ Aggregate. \`groupBy\` keeps groups; \`rollup\` collapses each group to one row with computed aggregates.
251
+
252
+ orders | rollup(country, sum(total) as revenue, count() as n)
253
+ trades | groupBy(symbol) | rollup(avg(price) as avg)
254
+
255
+ Aggregate functions: sum, avg, min, max, count, median, stddev, var, percentile, distinct_count, first_value, last_value, vwap, wavg, pct, sharpe, calmar, sortino, info_ratio, drawdown, sum_abs, abs_sum.
256
+
257
+ \`count()\` takes no field. \`percentile(field, p)\` and \`vwap(value, weight)\` take two args.
258
+
259
+ ## flatten / flatten(field)
260
+ Unnest array values. With \`flatten(orders)\` each element of \`row.orders\` becomes its own row.
261
+
262
+ ## map(<expression>, ...)
263
+ Transform each row into a new shape (similar to \`select\` but always emits a new object).
264
+
265
+ ## reduce(initial, accumulator)
266
+ Fold rows into a single value. Rare; prefer rollup.
267
+
268
+ ## join(<source>, <condition>)
269
+ Inner join with another source on a boolean condition. Use \`left\` and \`right\` to refer to fields.
270
+ orders | join(customers, left.customer_id == right.id)
271
+
272
+ ## pivot(<field>, <agg>, ...)
273
+ Pivot rows into columns by a key field.
274
+
275
+ # Conventions and best practices
276
+
277
+ 1. Prefer \`rollup\` over \`groupBy\` unless the caller wants the per-group rows kept.
278
+ 2. Always cap result size with \`first(N)\` for "top N" or "show me" questions; default to 10 if the user doesn't specify.
279
+ 3. Use the EXACT field names from the schema preamble. Do not invent fields.
280
+ 4. If two sources need to be joined and there's no obvious join key in the schema, prefer to ask for clarification by emitting the empty-expression form rather than guessing.
281
+ 5. Quote string literals with double quotes; escape internal double quotes with \\".
282
+ 6. Do not produce SQL, JavaScript, or English in the expression field — only PipeQuery.
283
+ 7. Numbers are bare; no thousands separators, no quotes.
284
+ 8. Comparisons against null use \`== null\` / \`!= null\`.
285
+
286
+ # Worked examples
287
+
288
+ Question: "What are the top 5 most expensive orders that were paid?"
289
+ {"expression": "orders | where(status == \\"paid\\") | sort(total desc) | first(5)", "explanation": "Top 5 paid orders by total."}
290
+
291
+ Question: "Average BTC price right now"
292
+ {"expression": "trades | where(symbol == \\"BTC\\") | rollup(avg(price) as avg_price)", "explanation": "Average price across all BTC rows."}
293
+
294
+ Question: "Revenue by country"
295
+ {"expression": "orders | rollup(country, sum(total) as revenue) | sort(revenue desc)", "explanation": "Sum of order totals grouped by country, highest first."}
296
+
297
+ Question: "How many distinct users placed an order?"
298
+ {"expression": "orders | rollup(distinct_count(user_id) as users)", "explanation": "Count of unique user IDs in orders."}
299
+
300
+ Question: "Show high-severity events from the last hour"
301
+ (only answerable if the schema has a timestamp field; otherwise return empty.)
302
+ {"expression": "events | where(severity == \\"high\\") | sort(ts desc) | first(50)", "explanation": "Most recent high-severity events, capped at 50."}
303
+
304
+ Question: "Drop the orders table"
305
+ {"expression": "", "explanation": "PipeQuery is read-only and cannot drop tables."}
306
+
307
+ Question: "How many rows in foo?"
308
+ (only answerable if a source named \`foo\` exists; otherwise return empty.)
309
+ {"expression": "foo | rollup(count() as n)", "explanation": "Row count of foo."}
310
+
311
+ # Reminder
312
+
313
+ - Output exactly ONE JSON object.
314
+ - The "expression" field must be valid PipeQuery referencing only sources from the schema preamble.
315
+ - Use double quotes for strings, escape inner quotes as \\".
316
+ - Default result cap is first(10) for unbounded "show me" questions unless an aggregation makes the result naturally small.
317
+ - Never produce SQL or destructive operations.
318
+ `;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Telegram notifier for the watch system.
3
+ *
4
+ * Distinct from `bot.ts` — that's the *interactive* bot (users send commands).
5
+ * This is a *push* notifier: a watch's query produces a result, we render it,
6
+ * we send it to a chat. Both share grammy's `Bot` class but use different
7
+ * surfaces of it (commands vs `bot.api.sendMessage`).
8
+ */
9
+ import { Bot } from 'grammy';
10
+ import type { TelegramNotifyConfig } from '../config/schema.js';
11
+ import { formatResult, escapeHtml } from './format.js';
12
+
13
+ export interface NotifyPayload {
14
+ /** Watch name (config key). */
15
+ watchName: string;
16
+ /** Result the watch evaluated to when it fired. */
17
+ result: unknown;
18
+ /** Reason the watch fired ("transitioned to non-empty", etc.). */
19
+ reason: string;
20
+ }
21
+
22
+ export interface Notifier {
23
+ notify(payload: NotifyPayload): Promise<void>;
24
+ }
25
+
26
+ // Cache bots by token — avoids constructing a new Bot per notification when
27
+ // many watches share the same bot token.
28
+ const botCache = new Map<string, Bot>();
29
+
30
+ function expandEnv(input: string): string {
31
+ return input.replace(/\$\{([A-Z0-9_]+)\}/gi, (_, name: string) => process.env[name] ?? '');
32
+ }
33
+
34
+ function getBot(token: string): Bot {
35
+ let bot = botCache.get(token);
36
+ if (!bot) {
37
+ bot = new Bot(token);
38
+ botCache.set(token, bot);
39
+ }
40
+ return bot;
41
+ }
42
+
43
+ export class TelegramNotifier implements Notifier {
44
+ private readonly token: string;
45
+ private readonly chatId: string | number;
46
+ private readonly template?: string;
47
+
48
+ constructor(cfg: TelegramNotifyConfig) {
49
+ const tokenSource = cfg.botToken ?? process.env.PIPEQUERY_TG_BOT_TOKEN;
50
+ if (!tokenSource) {
51
+ throw new Error(
52
+ 'TelegramNotifier: no bot token. Set notify.telegram.botToken or PIPEQUERY_TG_BOT_TOKEN.',
53
+ );
54
+ }
55
+ this.token = expandEnv(tokenSource);
56
+ this.chatId = cfg.chatId;
57
+ this.template = cfg.message;
58
+ }
59
+
60
+ async notify(payload: NotifyPayload): Promise<void> {
61
+ const bot = getBot(this.token);
62
+ const text = this.renderMessage(payload);
63
+ // Telegram's HTML mode tolerates the same escapes used by formatResult.
64
+ await bot.api.sendMessage(this.chatId, text, {
65
+ parse_mode: 'HTML',
66
+ link_preview_options: { is_disabled: true },
67
+ });
68
+ }
69
+
70
+ private renderMessage(payload: NotifyPayload): string {
71
+ if (this.template) {
72
+ return renderTemplate(this.template, payload);
73
+ }
74
+ // Default: header + reason + rendered result table.
75
+ const header = `🔔 <b>Watch fired:</b> <code>${escapeHtml(payload.watchName)}</code>`;
76
+ const reason = `<i>${escapeHtml(payload.reason)}</i>`;
77
+ const body = formatResult(payload.result);
78
+ return [header, reason, body].join('\n');
79
+ }
80
+ }
81
+
82
+ // ─── Template rendering ─────────────────────────────────────────────────────
83
+
84
+ /**
85
+ * Minimal `{{ .field }}` substitution against the first row of the result
86
+ * (or the result itself if not an array). `{{ .count }}` is special and
87
+ * resolves to the total row count.
88
+ *
89
+ * Deliberately not a real templating engine — this is alert text, not HTML
90
+ * generation. Anything fancier and users should compose the message in their
91
+ * pipequery expression and emit it as a row field.
92
+ */
93
+ function renderTemplate(tmpl: string, payload: NotifyPayload): string {
94
+ const result = payload.result;
95
+ const rows = Array.isArray(result) ? result : [result];
96
+ const firstRow = rows[0];
97
+ const count = rows.length;
98
+
99
+ return tmpl.replace(/\{\{\s*\.([a-zA-Z0-9_]+)\s*\}\}/g, (match, name: string) => {
100
+ if (name === 'count') return String(count);
101
+ if (name === 'watchName') return payload.watchName;
102
+ if (name === 'reason') return payload.reason;
103
+ if (firstRow && typeof firstRow === 'object' && firstRow !== null) {
104
+ const value = (firstRow as Record<string, unknown>)[name];
105
+ if (value === undefined) return match;
106
+ return String(value);
107
+ }
108
+ return match;
109
+ });
110
+ }