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.
- package/LICENSE +21 -0
- package/README.md +223 -0
- package/bin/livetap.ts +33 -0
- package/package.json +43 -0
- package/scripts/postinstall.ts +75 -0
- package/src/cli/daemon-client.ts +43 -0
- package/src/cli/help.ts +9 -0
- package/src/cli/sip.ts +63 -0
- package/src/cli/start.ts +75 -0
- package/src/cli/status.ts +45 -0
- package/src/cli/stop.ts +64 -0
- package/src/cli/tap.ts +94 -0
- package/src/cli/taps.ts +32 -0
- package/src/cli/untap.ts +23 -0
- package/src/cli/unwatch.ts +23 -0
- package/src/cli/watch.ts +91 -0
- package/src/cli/watchers.ts +116 -0
- package/src/mcp/channel.ts +121 -0
- package/src/mcp/tools.ts +314 -0
- package/src/server/connection-manager.ts +171 -0
- package/src/server/connections/file.ts +123 -0
- package/src/server/connections/mqtt.ts +104 -0
- package/src/server/connections/webhook.ts +54 -0
- package/src/server/connections/websocket.ts +154 -0
- package/src/server/index.ts +255 -0
- package/src/server/redis.ts +62 -0
- package/src/server/types.ts +94 -0
- package/src/server/watchers/engine.ts +70 -0
- package/src/server/watchers/manager.ts +354 -0
- package/src/server/watchers/types.ts +44 -0
- package/src/shared/catalog-generators.ts +125 -0
- package/src/shared/command-catalog.ts +143 -0
|
@@ -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
|
+
}
|