livetap 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.
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Types for the livetap stream service.
3
+ * Schema-agnostic — the service knows nothing about payload contents.
4
+ */
5
+
6
+ // --- Connection config (discriminated union) ---
7
+
8
+ export interface MqttConnectionConfig {
9
+ type: 'mqtt'
10
+ broker: string
11
+ port: number
12
+ tls: boolean
13
+ credentials: { username: string; password: string }
14
+ topics: string[]
15
+ }
16
+
17
+ export interface WebhookConnectionConfig {
18
+ type: 'webhook'
19
+ }
20
+
21
+ export interface WebSocketConnectionConfig {
22
+ type: 'websocket'
23
+ url: string
24
+ headers?: Record<string, string>
25
+ handshake?: string
26
+ reconnect?: {
27
+ enabled: boolean
28
+ maxRetries: number
29
+ initialDelayMs: number
30
+ maxDelayMs: number
31
+ }
32
+ pingIntervalMs?: number
33
+ binaryFormat?: 'json' | 'text' | 'base64'
34
+ }
35
+
36
+ export interface FileConnectionConfig {
37
+ type: 'file'
38
+ path: string
39
+ }
40
+
41
+ export type ConnectionConfig =
42
+ | MqttConnectionConfig
43
+ | WebhookConnectionConfig
44
+ | WebSocketConnectionConfig
45
+ | FileConnectionConfig
46
+
47
+ // --- Connection status ---
48
+
49
+ export interface ConnectionStatus {
50
+ connectionId: string
51
+ type: 'mqtt' | 'webhook' | 'websocket'
52
+ name?: string
53
+ summary: string
54
+ streamKey: string
55
+ createdAt: string
56
+ msgPerSec: number
57
+ bufferedCount: number
58
+ runtimeState: 'connected' | 'reconnecting' | 'disconnected' | 'error'
59
+ ingestUrl?: string
60
+ error?: string
61
+ }
62
+
63
+ // --- Stream entry ---
64
+
65
+ export interface StreamEntry {
66
+ id: string
67
+ fields: Record<string, string>
68
+ ts: number
69
+ }
70
+
71
+ // --- Subscriber interface ---
72
+
73
+ export interface Subscriber {
74
+ start(): Promise<void>
75
+ stop(): Promise<void>
76
+ getStatus(): { runtimeState: ConnectionStatus['runtimeState']; error?: string }
77
+ }
78
+
79
+ // --- Connection record (internal) ---
80
+
81
+ export interface ConnectionRecord {
82
+ id: string
83
+ config: ConnectionConfig
84
+ name?: string
85
+ streamKey: string
86
+ createdAt: string
87
+ subscriber: Subscriber
88
+ msgCount: number
89
+ msgCountAtLastSample: number
90
+ msgPerSec: number
91
+ bufferedCount: number
92
+ throughputInterval?: ReturnType<typeof setInterval>
93
+ bufferedInterval?: ReturnType<typeof setInterval>
94
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Expression evaluation engine for watchers.
3
+ * Resolves dot-paths, evaluates conditions, manages cooldown.
4
+ */
5
+
6
+ import type { WatcherCondition, WatcherDefinition } from './types.js'
7
+
8
+ /**
9
+ * Resolve a dot-separated path into a nested object.
10
+ * Returns undefined if any segment is missing.
11
+ */
12
+ export function resolveDotPath(obj: any, path: string): any {
13
+ return path.split('.').reduce((o, k) => o?.[k], obj)
14
+ }
15
+
16
+ /**
17
+ * Evaluate a single condition against a parsed payload.
18
+ * Returns false if the field doesn't exist (no crash).
19
+ */
20
+ export function evaluateCondition(payload: any, condition: WatcherCondition): boolean {
21
+ const value = resolveDotPath(payload, condition.field)
22
+ if (value === undefined) return false
23
+
24
+ switch (condition.op) {
25
+ case '>': return value > condition.value
26
+ case '<': return value < condition.value
27
+ case '>=': return value >= condition.value
28
+ case '<=': return value <= condition.value
29
+ case '==': return value == condition.value
30
+ case '!=': return value != condition.value
31
+ case 'contains': return String(value).includes(String(condition.value))
32
+ case 'matches':
33
+ try { return new RegExp(String(condition.value)).test(String(value)) }
34
+ catch { return false }
35
+ default: return false
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Evaluate all conditions for a watcher against a payload.
41
+ * match='all' → AND, match='any' → OR.
42
+ */
43
+ export function evaluateWatcher(payload: any, watcher: Pick<WatcherDefinition, 'conditions' | 'match'>): boolean {
44
+ const results = watcher.conditions.map((c) => evaluateCondition(payload, c))
45
+ return watcher.match === 'all' ? results.every(Boolean) : results.some(Boolean)
46
+ }
47
+
48
+ /**
49
+ * Extract the matched field values from a payload for a set of conditions.
50
+ * Used in alert payloads to show what triggered the match.
51
+ */
52
+ export function extractMatchedValues(payload: any, conditions: WatcherCondition[]): Record<string, unknown> {
53
+ const matched: Record<string, unknown> = {}
54
+ for (const c of conditions) {
55
+ const value = resolveDotPath(payload, c.field)
56
+ if (value !== undefined) {
57
+ matched[c.field] = value
58
+ }
59
+ }
60
+ return matched
61
+ }
62
+
63
+ /**
64
+ * Format conditions as a human-readable expression string.
65
+ */
66
+ export function formatExpression(conditions: WatcherCondition[], match: 'all' | 'any'): string {
67
+ const parts = conditions.map((c) => `${c.field} ${c.op} ${c.value}`)
68
+ const joiner = match === 'all' ? ' AND ' : ' OR '
69
+ return parts.join(joiner)
70
+ }
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Watcher Manager — CRUD + evaluation loops for expression watchers.
3
+ * Stores definitions in Redis hashes. Runs evaluator loops in-process.
4
+ */
5
+
6
+ import Redis from 'ioredis'
7
+ import { mkdirSync, appendFileSync, statSync, writeFileSync, readFileSync, unlinkSync } from 'fs'
8
+ import { resolve } from 'path'
9
+ import { homedir } from 'os'
10
+ import type { WatcherCondition, WatcherDefinition, WatcherInfo, WatcherAlert, WatcherAction } from './types.js'
11
+ import { VALID_OPS } from './types.js'
12
+ import { evaluateWatcher, extractMatchedValues, formatExpression } from './engine.js'
13
+
14
+ function generateId(): string {
15
+ const hex = Array.from(crypto.getRandomValues(new Uint8Array(4)))
16
+ .map((b) => b.toString(16).padStart(2, '0'))
17
+ .join('')
18
+ return `w_${hex}`
19
+ }
20
+
21
+ const LOG_DIR = resolve(homedir(), '.livetap', 'logs', 'watchers')
22
+ const MAX_LOG_SIZE = 512 * 1024 // 512KB
23
+
24
+ export type AlertCallback = (alert: WatcherAlert) => void
25
+
26
+ export class WatcherManager {
27
+ private redisUrl: string
28
+ private loops = new Map<string, { abort: AbortController; reader: Redis }>()
29
+ private info = new Map<string, WatcherInfo>()
30
+ private onAlert: AlertCallback
31
+ private redis: Redis
32
+
33
+ constructor(redis: Redis, redisUrl: string, onAlert: AlertCallback) {
34
+ this.redis = redis
35
+ this.redisUrl = redisUrl
36
+ this.onAlert = onAlert
37
+ mkdirSync(LOG_DIR, { recursive: true })
38
+ }
39
+
40
+ async create(
41
+ connectionId: string,
42
+ streamKey: string,
43
+ conditions: WatcherCondition[],
44
+ match: 'all' | 'any' = 'all',
45
+ action: WatcherAction = 'channel_alert',
46
+ cooldown = 60,
47
+ ): Promise<WatcherInfo> {
48
+ // Validate conditions
49
+ if (!conditions.length) throw new Error('At least one condition is required.')
50
+ for (const c of conditions) {
51
+ if (!VALID_OPS.includes(c.op as any)) {
52
+ throw new Error(`Invalid op '${c.op}'. Supported: ${VALID_OPS.join(', ')}`)
53
+ }
54
+ if (!c.field) throw new Error('Each condition must have a field.')
55
+ if (c.op === 'matches') {
56
+ try { new RegExp(String(c.value)) }
57
+ catch (err) { throw new Error(`Invalid regex '${c.value}': ${(err as Error).message}`) }
58
+ }
59
+ }
60
+
61
+ const id = generateId()
62
+ const now = new Date().toISOString()
63
+
64
+ const def: WatcherDefinition = {
65
+ id,
66
+ connectionId,
67
+ conditions,
68
+ match,
69
+ action,
70
+ cooldown,
71
+ status: 'running',
72
+ createdAt: now,
73
+ updatedAt: now,
74
+ }
75
+
76
+ // Store in flat hash (globally unique watcher IDs)
77
+ await this.redis.hset('livetap:watchers', id, JSON.stringify(def))
78
+
79
+ const info: WatcherInfo = { ...def, matchCount: 0, entriesChecked: 0 }
80
+ this.info.set(id, info)
81
+
82
+ this.writeLog(id, `STARTED conditions=${formatExpression(conditions, match)} cooldown=${cooldown}s`)
83
+ this.startLoop(id, streamKey, def)
84
+
85
+ return info
86
+ }
87
+
88
+ async list(connectionId?: string): Promise<WatcherInfo[]> {
89
+ const all = Array.from(this.info.values())
90
+ return connectionId ? all.filter((w) => w.connectionId === connectionId) : all
91
+ }
92
+
93
+ async get(watcherId: string): Promise<WatcherInfo | undefined> {
94
+ return this.info.get(watcherId)
95
+ }
96
+
97
+ async getLogs(watcherId: string, lines = 50): Promise<string[]> {
98
+ const logPath = resolve(LOG_DIR, `${watcherId}.log`)
99
+ try {
100
+ const content = readFileSync(logPath, 'utf-8')
101
+ return content.split('\n').filter(Boolean).slice(-lines)
102
+ } catch {
103
+ return []
104
+ }
105
+ }
106
+
107
+ async update(watcherId: string, streamKey: string | null, updates: {
108
+ conditions?: WatcherCondition[]
109
+ match?: 'all' | 'any'
110
+ action?: WatcherAction
111
+ cooldown?: number
112
+ }): Promise<WatcherInfo | null> {
113
+ const info = this.info.get(watcherId)
114
+ if (!info) return null
115
+
116
+ // Stop existing loop
117
+ this.stopLoop(watcherId)
118
+
119
+ // Apply updates
120
+ if (updates.conditions) {
121
+ for (const c of updates.conditions) {
122
+ if (!VALID_OPS.includes(c.op as any)) {
123
+ throw new Error(`Invalid op '${c.op}'. Supported: ${VALID_OPS.join(', ')}`)
124
+ }
125
+ if (c.op === 'matches') {
126
+ try { new RegExp(String(c.value)) }
127
+ catch (err) { throw new Error(`Invalid regex '${c.value}': ${(err as Error).message}`) }
128
+ }
129
+ }
130
+ info.conditions = updates.conditions
131
+ }
132
+ if (updates.match) info.match = updates.match
133
+ if (updates.action) info.action = updates.action
134
+ if (updates.cooldown !== undefined) info.cooldown = updates.cooldown
135
+ info.updatedAt = new Date().toISOString()
136
+ info.status = 'running'
137
+
138
+ // Persist
139
+ const def: WatcherDefinition = { ...info }
140
+ await this.redis.hset('livetap:watchers', watcherId, JSON.stringify(def))
141
+
142
+ this.writeLog(watcherId, `UPDATED conditions=${formatExpression(info.conditions, info.match)} cooldown=${info.cooldown}s`)
143
+ this.startLoop(watcherId, streamKey, info)
144
+
145
+ return info
146
+ }
147
+
148
+ async restart(watcherId: string, streamKey: string | null): Promise<boolean> {
149
+ const info = this.info.get(watcherId)
150
+ if (!info) return false
151
+
152
+ this.stopLoop(watcherId)
153
+ info.status = 'running'
154
+ this.writeLog(watcherId, 'RESTARTED')
155
+ this.startLoop(watcherId, streamKey, info)
156
+ return true
157
+ }
158
+
159
+ async delete(watcherId: string): Promise<boolean> {
160
+ const info = this.info.get(watcherId)
161
+ if (!info) return false
162
+
163
+ this.stopLoop(watcherId)
164
+ this.info.delete(watcherId)
165
+ await this.redis.hdel('livetap:watchers', watcherId)
166
+
167
+ // Delete log file
168
+ try { unlinkSync(resolve(LOG_DIR, `${watcherId}.log`)) } catch { /* ok */ }
169
+
170
+ return true
171
+ }
172
+
173
+ async deleteAllForConnection(connectionId: string): Promise<void> {
174
+ const watchers = await this.list(connectionId)
175
+ for (const w of watchers) {
176
+ await this.delete(w.id)
177
+ }
178
+ }
179
+
180
+ private startLoop(watcherId: string, streamKey: string, def: WatcherDefinition) {
181
+ const abort = new AbortController()
182
+ const reader = new Redis(this.redisUrl)
183
+ this.loops.set(watcherId, { abort, reader })
184
+
185
+ const info = this.info.get(watcherId)!
186
+ let lastAlertTime = 0
187
+ let checkpointTime = Date.now()
188
+ const fieldNotFoundThrottle = new Map<string, number>() // field → last logged time
189
+
190
+ const run = async () => {
191
+ let lastId = '$'
192
+ while (!abort.signal.aborted) {
193
+ try {
194
+ const result = await reader.xread('BLOCK', 2000, 'STREAMS', streamKey, lastId)
195
+ if (!result || abort.signal.aborted) continue
196
+
197
+ for (const [, entries] of result) {
198
+ for (const [id, fieldArray] of entries) {
199
+ lastId = id
200
+ info.entriesChecked++
201
+ info.lastChecked = new Date().toISOString()
202
+
203
+ // Parse fields
204
+ const fields: Record<string, string> = {}
205
+ for (let i = 0; i < fieldArray.length; i += 2) {
206
+ fields[fieldArray[i]] = fieldArray[i + 1]
207
+ }
208
+
209
+ // Parse payload: try JSON first, fall back to raw fields
210
+ let payload: any
211
+ try {
212
+ payload = JSON.parse(fields.payload ?? '{}')
213
+ } catch {
214
+ // Plain text — use raw Redis fields as the payload
215
+ // This allows conditions like { field: "payload", op: "contains", value: "ERROR" }
216
+ payload = fields
217
+ }
218
+
219
+ // Log missing fields (throttled: once per field per minute)
220
+ for (const c of def.conditions) {
221
+ const val = resolveDotPathImport(payload, c.field)
222
+ if (val === undefined) {
223
+ const last = fieldNotFoundThrottle.get(c.field) ?? 0
224
+ if (Date.now() - last > 60_000) {
225
+ this.writeLog(watcherId, `FIELD_NOT_FOUND ${c.field} in entry ${id}`)
226
+ fieldNotFoundThrottle.set(c.field, Date.now())
227
+ }
228
+ }
229
+ }
230
+
231
+ // Evaluate
232
+ const matched = evaluateWatcher(payload, def)
233
+ if (matched) {
234
+ const now = Date.now()
235
+ if (now - lastAlertTime >= def.cooldown * 1000) {
236
+ lastAlertTime = now
237
+ info.matchCount++
238
+ info.lastMatch = new Date().toISOString()
239
+
240
+ const matchedValues = extractMatchedValues(payload, def.conditions)
241
+ const expression = formatExpression(def.conditions, def.match)
242
+
243
+ this.writeLog(watcherId, `MATCH ${Object.entries(matchedValues).map(([k, v]) => `${k}=${v}`).join(' ')} action=${typeof def.action === 'string' ? def.action : JSON.stringify(def.action)}`)
244
+
245
+ const alert: WatcherAlert = {
246
+ watcherId,
247
+ connectionId: def.connectionId,
248
+ expression,
249
+ matchedValues,
250
+ entry: fields,
251
+ ts: now,
252
+ }
253
+
254
+ // Execute action
255
+ await this.executeAction(def.action, alert)
256
+ this.onAlert(alert)
257
+ } else {
258
+ const remaining = Math.round((def.cooldown * 1000 - (Date.now() - lastAlertTime)) / 1000)
259
+ this.writeLog(watcherId, `SUPPRESSED ${formatExpression(def.conditions, def.match)} (cooldown ${remaining}s remaining)`)
260
+ }
261
+ }
262
+
263
+ // Checkpoint every 5 minutes
264
+ if (Date.now() - checkpointTime > 5 * 60 * 1000) {
265
+ this.writeLog(watcherId, `CHECKPOINT entries_checked=${info.entriesChecked} matches=${info.matchCount}`)
266
+ checkpointTime = Date.now()
267
+ }
268
+ }
269
+ }
270
+ } catch (err) {
271
+ if (!abort.signal.aborted) {
272
+ this.writeLog(watcherId, `ERROR ${(err as Error).message}`)
273
+ await new Promise((r) => setTimeout(r, 1000))
274
+ }
275
+ }
276
+ }
277
+ reader.disconnect()
278
+ }
279
+
280
+ run()
281
+ }
282
+
283
+ private stopLoop(watcherId: string) {
284
+ const loop = this.loops.get(watcherId)
285
+ if (loop) {
286
+ loop.abort.abort()
287
+ loop.reader.disconnect()
288
+ this.loops.delete(watcherId)
289
+ }
290
+ const info = this.info.get(watcherId)
291
+ if (info) info.status = 'stopped'
292
+ }
293
+
294
+ private async executeAction(action: WatcherAction, alert: WatcherAlert) {
295
+ if (action === 'channel_alert') return // handled by onAlert callback
296
+
297
+ if (typeof action === 'object' && 'webhook' in action) {
298
+ try {
299
+ await fetch(action.webhook, {
300
+ method: 'POST',
301
+ headers: { 'Content-Type': 'application/json' },
302
+ body: JSON.stringify(alert),
303
+ })
304
+ } catch (err) {
305
+ this.writeLog(alert.watcherId, `WEBHOOK_ERROR ${(err as Error).message}`)
306
+ }
307
+ }
308
+
309
+ if (typeof action === 'object' && 'shell' in action) {
310
+ try {
311
+ Bun.spawn(['sh', '-c', action.shell], {
312
+ env: {
313
+ ...process.env,
314
+ LIVETAP_WATCHER: alert.watcherId,
315
+ LIVETAP_PAYLOAD: JSON.stringify(alert.entry),
316
+ LIVETAP_MATCHED: JSON.stringify(alert.matchedValues),
317
+ },
318
+ })
319
+ } catch (err) {
320
+ this.writeLog(alert.watcherId, `SHELL_ERROR ${(err as Error).message}`)
321
+ }
322
+ }
323
+ }
324
+
325
+ private writeLog(watcherId: string, msg: string) {
326
+ const logPath = resolve(LOG_DIR, `${watcherId}.log`)
327
+ const line = `[${new Date().toISOString()}] ${msg}\n`
328
+
329
+ appendFileSync(logPath, line)
330
+
331
+ // Size protection: truncate to last 256KB if over 512KB
332
+ try {
333
+ const stats = statSync(logPath)
334
+ if (stats.size > MAX_LOG_SIZE) {
335
+ const content = readFileSync(logPath, 'utf-8')
336
+ const truncated = content.slice(-MAX_LOG_SIZE / 2)
337
+ // Start from first complete line
338
+ const firstNewline = truncated.indexOf('\n')
339
+ writeFileSync(logPath, truncated.slice(firstNewline + 1))
340
+ }
341
+ } catch { /* ok */ }
342
+ }
343
+
344
+ async stopAll() {
345
+ for (const id of this.loops.keys()) {
346
+ this.stopLoop(id)
347
+ }
348
+ }
349
+ }
350
+
351
+ // Import from engine to avoid circular
352
+ function resolveDotPathImport(obj: any, path: string): any {
353
+ return path.split('.').reduce((o, k) => o?.[k], obj)
354
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Watcher types for expression-based alerting.
3
+ */
4
+
5
+ export interface WatcherCondition {
6
+ field: string
7
+ op: '>' | '<' | '>=' | '<=' | '==' | '!=' | 'contains' | 'matches'
8
+ value: number | string | boolean
9
+ }
10
+
11
+ export type WatcherAction =
12
+ | 'channel_alert'
13
+ | { webhook: string }
14
+ | { shell: string }
15
+
16
+ export interface WatcherDefinition {
17
+ id: string
18
+ connectionId: string
19
+ conditions: WatcherCondition[]
20
+ match: 'all' | 'any'
21
+ action: WatcherAction
22
+ cooldown: number
23
+ status: 'running' | 'stopped'
24
+ createdAt: string
25
+ updatedAt: string
26
+ }
27
+
28
+ export interface WatcherInfo extends WatcherDefinition {
29
+ lastChecked?: string
30
+ matchCount: number
31
+ lastMatch?: string
32
+ entriesChecked: number
33
+ }
34
+
35
+ export interface WatcherAlert {
36
+ watcherId: string
37
+ connectionId: string
38
+ expression: string
39
+ matchedValues: Record<string, unknown>
40
+ entry: Record<string, string>
41
+ ts: number
42
+ }
43
+
44
+ export const VALID_OPS = ['>', '<', '>=', '<=', '==', '!=', 'contains', 'matches'] as const
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Generates help text, --llm-help JSON, and MCP instructions from the command catalog.
3
+ */
4
+
5
+ import { CLI_COMMANDS, type CatalogCommand } from './command-catalog.js'
6
+ import { TOOLS } from '../mcp/tools.js'
7
+
8
+ /**
9
+ * Generate human-readable --help text.
10
+ */
11
+ export function generateHelpText(): string {
12
+ const lines: string[] = [
13
+ 'livetap — Push live data streams into your AI coding agent',
14
+ '',
15
+ 'Usage:',
16
+ ]
17
+
18
+ // Group: daemon, connections, sampling, watchers
19
+ const groups: { label: string; commands: string[] }[] = [
20
+ { label: '', commands: ['start', 'stop', 'status'] },
21
+ { label: '', commands: ['tap', 'untap', 'taps'] },
22
+ { label: '', commands: ['sip'] },
23
+ { label: '', commands: ['watch', 'unwatch', 'watchers'] },
24
+ ]
25
+
26
+ for (const group of groups) {
27
+ for (const name of group.commands) {
28
+ const cmd = CLI_COMMANDS.find((c) => c.name === name)
29
+ if (!cmd) continue
30
+ lines.push(` ${cmd.usage.padEnd(45)} ${cmd.description}`)
31
+ }
32
+ lines.push('')
33
+ }
34
+
35
+ lines.push('Options:')
36
+ lines.push(' --port <n> Daemon port (default 8788, env: LIVETAP_PORT)')
37
+ lines.push(' --foreground Run daemon in foreground (start only)')
38
+ lines.push(' --json Output as JSON (taps, watchers, sip)')
39
+ lines.push(' --help, -h Show this help')
40
+ lines.push(' --llm-help Machine-readable JSON for AI agents')
41
+ lines.push('')
42
+
43
+ return lines.join('\n')
44
+ }
45
+
46
+ /**
47
+ * Generate machine-readable JSON for --llm-help.
48
+ * Includes CLI commands AND MCP tool schemas from the same source.
49
+ */
50
+ export function generateLlmHelp(): object {
51
+ return {
52
+ name: 'livetap',
53
+ version: '0.1.0',
54
+ description: 'Push live data streams into your AI coding agent',
55
+ commands: CLI_COMMANDS,
56
+ mcp_tools: TOOLS,
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Generate MCP instructions string for the LLM.
62
+ * References actual tool names from the TOOLS array.
63
+ */
64
+ export function generateInstructions(): string {
65
+ const toolNames = TOOLS.map((t) => t.name)
66
+ const connectionTools = toolNames.filter((n) => n.includes('connection'))
67
+ const watcherTools = toolNames.filter((n) => n.includes('watcher') || n.includes('watch'))
68
+
69
+ return `
70
+ You have access to livetap, a live data streaming tool. Use it to connect to data sources, sample streams, and set up expression-based watchers that alert you when conditions match.
71
+
72
+ WORKFLOW:
73
+ 1. CONNECT: Use create_connection to tap into a data source.
74
+ - MQTT: create_connection({ type: "mqtt", broker: "hostname", port: 1883, tls: false, topics: ["topic/#"], username: "", password: "" })
75
+ - WebSocket: create_connection({ type: "websocket", url: "wss://..." })
76
+ - File: create_connection({ type: "file", path: "/var/log/app.log" }) → tails the file for new lines
77
+ Note: for MQTT, set tls: false and port: 1883 for unencrypted brokers.
78
+
79
+ 2. SAMPLE: Use read_stream to inspect what data is flowing.
80
+ - read_stream({ connectionId: "conn_xxx", backfillSeconds: 60, maxEntries: 10 })
81
+ - Study the JSON payload structure to find the correct dot-paths for watchers.
82
+ - ALWAYS sample before creating a watcher so you know the correct field paths.
83
+
84
+ 3. WATCH: Use create_watcher to set up expression-based alerts.
85
+ - create_watcher({ connectionId: "conn_xxx", conditions: [{ field: "sensors.temperature.value", op: ">", value: 50 }], match: "all", cooldown: 60 })
86
+ - Supported operators: >, <, >=, <=, ==, !=, contains, matches (regex)
87
+ - match: "all" = AND (all conditions must be true), "any" = OR (at least one)
88
+ - cooldown: seconds between repeated alerts. Use 0 for rare events, 30-60 for sensors, 300+ for high-frequency.
89
+ - Alerts arrive as <channel> events. When you see one, act on it as the user requested.
90
+
91
+ 4. MANAGE:
92
+ - ${connectionTools.join(', ')} — manage connections
93
+ - ${watcherTools.join(', ')} — manage watchers
94
+ - Watcher IDs (w_xxx) are globally unique. No connectionId needed for get/update/delete.
95
+
96
+ CHANNEL EVENTS:
97
+ - <channel source="livetap" type="alert"> = a watcher condition matched. Read the payload and act on it.
98
+ The payload contains: watcherId, expression, matched_values, and the full stream entry.
99
+
100
+ When the user asks to "monitor", "watch", or "alert on" something:
101
+ 1. First check list_connections — reuse an existing connection if possible
102
+ 2. If no connection exists, create one
103
+ 3. Sample the stream with read_stream to understand the data shape
104
+ 4. Create a watcher with the correct field paths from the sample
105
+ 5. Tell the user what you set up and what will trigger it
106
+
107
+ AVAILABLE TOOLS: ${toolNames.join(', ')}
108
+
109
+ DATA SHAPE BY SOURCE:
110
+ - MQTT/WebSocket: entries have { payload: "{...json...}", topic: "..." }. The payload is parsed as JSON.
111
+ Use dot-paths into the parsed JSON: "sensors.temperature.value", "metadata.device_name"
112
+ - File (plain text lines): entries have { payload: "the raw line", format: "text" }.
113
+ Use field "payload" with contains/matches: { field: "payload", op: "contains", value: "ERROR" }
114
+ or { field: "payload", op: "matches", value: "5[0-9]{2}" }
115
+ - File (JSON lines): entries have { payload: "{...json...}", format: "json" }. Parsed as JSON.
116
+ Use dot-paths like MQTT: "level", "msg", "status"
117
+ - IMPORTANT: always use read_stream first to see the actual field names. Do NOT guess — the field is "payload", not "line" or "message".
118
+
119
+ TIPS:
120
+ - Watcher IDs (w_xxx) are globally unique. You don't need the connectionId to get, update, or delete a watcher.
121
+ - Common MQTT brokers: broker.emqx.io (public demo), test.mosquitto.org (public test).
122
+ - For regex watchers, use the "matches" operator: { field: "payload", op: "matches", value: "ERROR|FATAL" }
123
+ - If a field path doesn't exist in the payload, the condition evaluates to false (no crash, no error).
124
+ `.trim()
125
+ }