morpheus-cli 0.4.3 → 0.4.6

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.
@@ -1,22 +1,22 @@
1
1
  import { tool } from "langchain";
2
2
  import * as z from "zod";
3
- import path from "path";
4
3
  import Database from "better-sqlite3";
5
4
  import { homedir } from "os";
6
- // Tool for querying message counts from the database
5
+ import path from "path";
7
6
  const dbPath = path.join(homedir(), ".morpheus", "memory", "short-memory.db");
7
+ // Tool for querying message counts from the database
8
8
  export const MessageCountTool = tool(async ({ timeRange }) => {
9
9
  try {
10
- // Connect to database
11
10
  const db = new Database(dbPath);
11
+ // The messages table uses `created_at` (Unix ms integer), not `timestamp`
12
12
  let query = "SELECT COUNT(*) as count FROM messages";
13
13
  const params = [];
14
14
  if (timeRange) {
15
- query += " WHERE timestamp BETWEEN ? AND ?";
16
- params.push(timeRange.start);
17
- params.push(timeRange.end);
15
+ query += " WHERE created_at BETWEEN ? AND ?";
16
+ params.push(new Date(timeRange.start).getTime());
17
+ params.push(new Date(timeRange.end).getTime());
18
18
  }
19
- const result = db.prepare(query).get(params);
19
+ const result = db.prepare(query).get(...params);
20
20
  db.close();
21
21
  return JSON.stringify(result.count);
22
22
  }
@@ -26,11 +26,11 @@ export const MessageCountTool = tool(async ({ timeRange }) => {
26
26
  }
27
27
  }, {
28
28
  name: "message_count",
29
- description: "Returns count of stored messages. Accepts an optional 'timeRange' parameter with start and end timestamps for filtering.",
29
+ description: "Returns count of stored messages. Accepts an optional 'timeRange' parameter with ISO date strings (start/end) for filtering.",
30
30
  schema: z.object({
31
31
  timeRange: z.object({
32
- start: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/, "ISO date string"),
33
- end: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/, "ISO date string"),
32
+ start: z.string().describe("ISO date string, e.g. 2026-01-01T00:00:00Z"),
33
+ end: z.string().describe("ISO date string, e.g. 2026-12-31T23:59:59Z"),
34
34
  }).optional(),
35
35
  }),
36
36
  });
@@ -39,20 +39,44 @@ export const ProviderModelUsageTool = tool(async () => {
39
39
  try {
40
40
  const db = new Database(dbPath);
41
41
  const query = `
42
- SELECT
43
- provider,
44
- COALESCE(model, 'unknown') as model,
45
- SUM(input_tokens) as totalInputTokens,
46
- SUM(output_tokens) as totalOutputTokens,
47
- SUM(total_tokens) as totalTokens,
48
- COUNT(*) as messageCount
49
- FROM messages
50
- WHERE provider IS NOT NULL
51
- GROUP BY provider, COALESCE(model, 'unknown')
52
- ORDER BY provider, model
42
+ SELECT
43
+ m.provider,
44
+ COALESCE(m.model, 'unknown') as model,
45
+ SUM(m.input_tokens) as totalInputTokens,
46
+ SUM(m.output_tokens) as totalOutputTokens,
47
+ SUM(m.total_tokens) as totalTokens,
48
+ COUNT(*) as messageCount,
49
+ COALESCE(SUM(m.audio_duration_seconds), 0) as totalAudioSeconds,
50
+ p.input_price_per_1m,
51
+ p.output_price_per_1m
52
+ FROM messages m
53
+ LEFT JOIN model_pricing p ON p.provider = m.provider AND p.model = COALESCE(m.model, 'unknown')
54
+ WHERE m.provider IS NOT NULL
55
+ GROUP BY m.provider, COALESCE(m.model, 'unknown')
56
+ ORDER BY m.provider, m.model
53
57
  `;
54
- const results = db.prepare(query).all();
58
+ const rows = db.prepare(query).all();
55
59
  db.close();
60
+ const results = rows.map(row => {
61
+ const inputTokens = row.totalInputTokens || 0;
62
+ const outputTokens = row.totalOutputTokens || 0;
63
+ let estimatedCostUsd = null;
64
+ if (row.input_price_per_1m != null && row.output_price_per_1m != null) {
65
+ estimatedCostUsd =
66
+ (inputTokens / 1_000_000) * row.input_price_per_1m +
67
+ (outputTokens / 1_000_000) * row.output_price_per_1m;
68
+ }
69
+ return {
70
+ provider: row.provider,
71
+ model: row.model,
72
+ totalInputTokens: inputTokens,
73
+ totalOutputTokens: outputTokens,
74
+ totalTokens: row.totalTokens || 0,
75
+ messageCount: row.messageCount || 0,
76
+ totalAudioSeconds: row.totalAudioSeconds || 0,
77
+ estimatedCostUsd,
78
+ };
79
+ });
56
80
  return JSON.stringify(results);
57
81
  }
58
82
  catch (error) {
@@ -61,31 +85,43 @@ export const ProviderModelUsageTool = tool(async () => {
61
85
  }
62
86
  }, {
63
87
  name: "provider_model_usage",
64
- description: "Returns token usage statistics grouped by provider and model.",
88
+ description: "Returns token usage statistics grouped by provider and model, including audio duration and estimated cost in USD (when pricing is configured).",
65
89
  schema: z.object({}),
66
90
  });
67
- // Tool for querying token usage statistics from the database
91
+ // Tool for querying global token usage statistics from the database
68
92
  export const TokenUsageTool = tool(async ({ timeRange }) => {
69
93
  try {
70
- // Connect to database
71
94
  const db = new Database(dbPath);
72
- let query = "SELECT SUM(input_tokens) as inputTokens, SUM(output_tokens) as outputTokens, SUM(input_tokens + output_tokens) as totalTokens FROM messages";
95
+ // The messages table uses `created_at` (Unix ms integer), not `timestamp`
96
+ let whereClause = "";
73
97
  const params = [];
74
98
  if (timeRange) {
75
- query += " WHERE timestamp BETWEEN ? AND ?";
76
- params.push(timeRange.start);
77
- params.push(timeRange.end);
99
+ whereClause = " WHERE created_at BETWEEN ? AND ?";
100
+ params.push(new Date(timeRange.start).getTime());
101
+ params.push(new Date(timeRange.end).getTime());
78
102
  }
79
- const result = db.prepare(query).get(params);
103
+ const row = db.prepare(`SELECT
104
+ SUM(input_tokens) as inputTokens,
105
+ SUM(output_tokens) as outputTokens,
106
+ SUM(total_tokens) as totalTokens,
107
+ COALESCE(SUM(audio_duration_seconds), 0) as totalAudioSeconds
108
+ FROM messages${whereClause}`).get(...params);
109
+ // Estimated cost via model_pricing join
110
+ const costRow = db.prepare(`SELECT
111
+ SUM((COALESCE(m.input_tokens, 0) / 1000000.0) * p.input_price_per_1m
112
+ + (COALESCE(m.output_tokens, 0) / 1000000.0) * p.output_price_per_1m) as totalCost
113
+ FROM messages m
114
+ INNER JOIN model_pricing p ON p.provider = m.provider AND p.model = COALESCE(m.model, 'unknown')
115
+ WHERE m.provider IS NOT NULL${whereClause ? whereClause.replace("WHERE", "AND") : ""}`).get(...params);
80
116
  db.close();
81
- // Handle potential null values
82
- const tokenStats = {
83
- totalTokens: result.totalTokens || 0,
84
- inputTokens: result.inputTokens || 0,
85
- outputTokens: result.outputTokens || 0,
86
- timestamp: new Date().toISOString()
87
- };
88
- return JSON.stringify(tokenStats);
117
+ return JSON.stringify({
118
+ inputTokens: row.inputTokens || 0,
119
+ outputTokens: row.outputTokens || 0,
120
+ totalTokens: row.totalTokens || 0,
121
+ totalAudioSeconds: row.totalAudioSeconds || 0,
122
+ estimatedCostUsd: costRow.totalCost ?? null,
123
+ timestamp: new Date().toISOString(),
124
+ });
89
125
  }
90
126
  catch (error) {
91
127
  console.error("Error in TokenUsageTool:", error);
@@ -93,11 +129,11 @@ export const TokenUsageTool = tool(async ({ timeRange }) => {
93
129
  }
94
130
  }, {
95
131
  name: "token_usage",
96
- description: "Returns token usage statistics. Accepts an optional 'timeRange' parameter with start and end timestamps for filtering.",
132
+ description: "Returns global token usage statistics including input/output tokens, total tokens, audio duration in seconds, and estimated cost in USD (when pricing is configured). Accepts an optional 'timeRange' parameter with ISO date strings for filtering.",
97
133
  schema: z.object({
98
134
  timeRange: z.object({
99
- start: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/, "ISO date string"),
100
- end: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/, "ISO date string"),
135
+ start: z.string().describe("ISO date string, e.g. 2026-01-01T00:00:00Z"),
136
+ end: z.string().describe("ISO date string, e.g. 2026-12-31T23:59:59Z"),
101
137
  }).optional(),
102
138
  }),
103
139
  });
@@ -9,30 +9,38 @@ export const DiagnosticTool = tool(async () => {
9
9
  try {
10
10
  const timestamp = new Date().toISOString();
11
11
  const components = {};
12
- // Check configuration
12
+ const morpheusRoot = path.join(homedir(), ".morpheus");
13
+ // ── Configuration ──────────────────────────────────────────────
13
14
  try {
14
15
  const configManager = ConfigManager.getInstance();
15
16
  await configManager.load();
16
17
  const config = configManager.get();
17
- // Basic validation - check if required fields exist
18
- const requiredFields = ['llm', 'logging', 'ui'];
18
+ const requiredFields = ["llm", "logging", "ui"];
19
19
  const missingFields = requiredFields.filter(field => !(field in config));
20
20
  if (missingFields.length === 0) {
21
+ const sati = config.sati;
22
+ const apoc = config.apoc;
21
23
  components.config = {
22
24
  status: "healthy",
23
25
  message: "Configuration is valid and complete",
24
26
  details: {
25
- llmProvider: config.llm?.provider,
27
+ oracleProvider: config.llm?.provider,
28
+ oracleModel: config.llm?.model,
29
+ satiProvider: sati?.provider ?? `${config.llm?.provider} (inherited)`,
30
+ satiModel: sati?.model ?? `${config.llm?.model} (inherited)`,
31
+ apocProvider: apoc?.provider ?? `${config.llm?.provider} (inherited)`,
32
+ apocModel: apoc?.model ?? `${config.llm?.model} (inherited)`,
33
+ apocWorkingDir: apoc?.working_dir ?? "not set",
26
34
  uiEnabled: config.ui?.enabled,
27
- uiPort: config.ui?.port
28
- }
35
+ uiPort: config.ui?.port,
36
+ },
29
37
  };
30
38
  }
31
39
  else {
32
40
  components.config = {
33
41
  status: "warning",
34
- message: `Missing required configuration fields: ${missingFields.join(', ')}`,
35
- details: { missingFields }
42
+ message: `Missing required configuration fields: ${missingFields.join(", ")}`,
43
+ details: { missingFields },
36
44
  };
37
45
  }
38
46
  }
@@ -40,45 +48,62 @@ export const DiagnosticTool = tool(async () => {
40
48
  components.config = {
41
49
  status: "error",
42
50
  message: `Configuration error: ${error.message}`,
43
- details: {}
51
+ details: {},
44
52
  };
45
53
  }
46
- // Check storage/database
54
+ // ── Short-term memory DB ────────────────────────────────────────
47
55
  try {
48
- // For now, we'll check if the data directory exists
49
- const dbPath = path.join(homedir(), ".morpheus", "memory", "short-memory.db");
56
+ const dbPath = path.join(morpheusRoot, "memory", "short-memory.db");
50
57
  await fsPromises.access(dbPath);
51
- components.storage = {
58
+ const stat = await fsPromises.stat(dbPath);
59
+ components.shortMemoryDb = {
52
60
  status: "healthy",
53
- message: "Database file is accessible",
54
- details: { path: dbPath }
61
+ message: "Short-memory database is accessible",
62
+ details: { path: dbPath, sizeBytes: stat.size },
55
63
  };
56
64
  }
57
65
  catch (error) {
58
- components.storage = {
66
+ components.shortMemoryDb = {
59
67
  status: "error",
60
- message: `Storage error: ${error.message}`,
61
- details: {}
68
+ message: `Short-memory DB not accessible: ${error.message}`,
69
+ details: {},
62
70
  };
63
71
  }
64
- // Check network connectivity (basic check)
72
+ // ── Sati long-term memory DB ────────────────────────────────────
73
+ try {
74
+ const satiDbPath = path.join(morpheusRoot, "memory", "sati-memory.db");
75
+ await fsPromises.access(satiDbPath);
76
+ const stat = await fsPromises.stat(satiDbPath);
77
+ components.satiMemoryDb = {
78
+ status: "healthy",
79
+ message: "Sati memory database is accessible",
80
+ details: { path: satiDbPath, sizeBytes: stat.size },
81
+ };
82
+ }
83
+ catch {
84
+ // Sati DB may not exist yet if no memories have been stored — treat as warning
85
+ components.satiMemoryDb = {
86
+ status: "warning",
87
+ message: "Sati memory database does not exist yet (no memories stored yet)",
88
+ details: {},
89
+ };
90
+ }
91
+ // ── LLM provider configured ─────────────────────────────────────
65
92
  try {
66
- // For now, we'll just check if we can reach the LLM provider configuration
67
93
  const configManager = ConfigManager.getInstance();
68
- await configManager.load();
69
94
  const config = configManager.get();
70
- if (config.llm && config.llm.provider) {
95
+ if (config.llm?.provider) {
71
96
  components.network = {
72
97
  status: "healthy",
73
- message: `LLM provider configured: ${config.llm.provider}`,
74
- details: { provider: config.llm.provider }
98
+ message: `Oracle LLM provider configured: ${config.llm.provider}`,
99
+ details: { provider: config.llm.provider, model: config.llm.model },
75
100
  };
76
101
  }
77
102
  else {
78
103
  components.network = {
79
104
  status: "warning",
80
- message: "No LLM provider configured",
81
- details: {}
105
+ message: "No Oracle LLM provider configured",
106
+ details: {},
82
107
  };
83
108
  }
84
109
  }
@@ -86,39 +111,43 @@ export const DiagnosticTool = tool(async () => {
86
111
  components.network = {
87
112
  status: "error",
88
113
  message: `Network check error: ${error.message}`,
89
- details: {}
114
+ details: {},
90
115
  };
91
116
  }
92
- // Check if the agent is running
117
+ // ── Agent process ───────────────────────────────────────────────
118
+ components.agent = {
119
+ status: "healthy",
120
+ message: "Agent is running (this tool is executing inside the agent process)",
121
+ details: { pid: process.pid, uptime: `${Math.floor(process.uptime())}s` },
122
+ };
123
+ // ── Logs directory ──────────────────────────────────────────────
93
124
  try {
94
- // This is a basic check - in a real implementation, we might check if the agent process is running
95
- components.agent = {
125
+ const logsDir = path.join(morpheusRoot, "logs");
126
+ await fsPromises.access(logsDir);
127
+ components.logs = {
96
128
  status: "healthy",
97
- message: "Agent is running",
98
- details: { uptime: "N/A - runtime information not available in this context" }
129
+ message: "Logs directory is accessible",
130
+ details: { path: logsDir },
99
131
  };
100
132
  }
101
- catch (error) {
102
- components.agent = {
103
- status: "error",
104
- message: `Agent check error: ${error.message}`,
105
- details: {}
133
+ catch {
134
+ components.logs = {
135
+ status: "warning",
136
+ message: "Logs directory not found (will be created on first log write)",
137
+ details: {},
106
138
  };
107
139
  }
108
- return JSON.stringify({
109
- timestamp,
110
- components
111
- });
140
+ return JSON.stringify({ timestamp, components });
112
141
  }
113
142
  catch (error) {
114
143
  console.error("Error in DiagnosticTool:", error);
115
144
  return JSON.stringify({
116
145
  timestamp: new Date().toISOString(),
117
- error: "Failed to run diagnostics"
146
+ error: "Failed to run diagnostics",
118
147
  });
119
148
  }
120
149
  }, {
121
150
  name: "diagnostic_check",
122
- description: "Performs system health diagnostics and returns a comprehensive report on system components.",
151
+ description: "Performs system health diagnostics and returns a comprehensive report covering configuration (Oracle/Sati/Apoc), short-memory DB, Sati long-term memory DB, LLM provider, agent process, and logs directory.",
123
152
  schema: z.object({}),
124
153
  });
@@ -0,0 +1,95 @@
1
+ import { WebhookRepository } from './repository.js';
2
+ import { DisplayManager } from '../display.js';
3
+ export class WebhookDispatcher {
4
+ static telegramAdapter = null;
5
+ static oracle = null;
6
+ display = DisplayManager.getInstance();
7
+ /**
8
+ * Called at boot time after TelegramAdapter.connect() succeeds,
9
+ * so Telegram notifications can be dispatched from any trigger.
10
+ */
11
+ static setTelegramAdapter(adapter) {
12
+ WebhookDispatcher.telegramAdapter = adapter;
13
+ }
14
+ /**
15
+ * Called at boot time with the Oracle instance so webhooks can use
16
+ * the full Oracle (MCPs, apoc_delegate, memory, etc.).
17
+ */
18
+ static setOracle(oracle) {
19
+ WebhookDispatcher.oracle = oracle;
20
+ }
21
+ /**
22
+ * Main orchestration method — runs in background (fire-and-forget).
23
+ * 1. Builds the agent prompt from webhook.prompt + payload
24
+ * 2. Sends to Oracle (which can use MCPs or delegate to Apoc)
25
+ * 3. Persists result to DB
26
+ * 4. Dispatches to configured channels
27
+ */
28
+ async dispatch(webhook, payload, notificationId) {
29
+ const repo = WebhookRepository.getInstance();
30
+ const oracle = WebhookDispatcher.oracle;
31
+ if (!oracle) {
32
+ const errMsg = 'Oracle not available — webhook cannot be processed.';
33
+ this.display.log(errMsg, { source: 'Webhooks', level: 'error' });
34
+ repo.updateNotificationResult(notificationId, 'failed', errMsg);
35
+ return;
36
+ }
37
+ const message = this.buildPrompt(webhook.prompt, payload);
38
+ let result;
39
+ let status;
40
+ try {
41
+ result = await oracle.chat(message);
42
+ status = 'completed';
43
+ this.display.log(`Webhook "${webhook.name}" completed (notification: ${notificationId})`, { source: 'Webhooks', level: 'success' });
44
+ }
45
+ catch (err) {
46
+ result = `Execution error: ${err.message}`;
47
+ status = 'failed';
48
+ this.display.log(`Webhook "${webhook.name}" failed: ${err.message}`, { source: 'Webhooks', level: 'error' });
49
+ }
50
+ // Persist result
51
+ repo.updateNotificationResult(notificationId, status, result);
52
+ // Dispatch to configured channels
53
+ for (const channel of webhook.notification_channels) {
54
+ if (channel === 'telegram') {
55
+ await this.sendTelegram(webhook.name, result, status);
56
+ }
57
+ // 'ui' channel is handled by UI polling — nothing extra needed here
58
+ }
59
+ }
60
+ /**
61
+ * Combines the user-authored webhook prompt with the received payload.
62
+ */
63
+ buildPrompt(webhookPrompt, payload) {
64
+ const payloadStr = JSON.stringify(payload, null, 2);
65
+ return `${webhookPrompt}
66
+
67
+ ---
68
+ RECEIVED WEBHOOK PAYLOAD:
69
+ \`\`\`json
70
+ ${payloadStr}
71
+ \`\`\`
72
+
73
+ Analyze the payload above and follow the instructions provided. Be concise and actionable in your response.`;
74
+ }
75
+ /**
76
+ * Sends a formatted Telegram message to all allowed users.
77
+ * Silently skips if the adapter is not connected.
78
+ */
79
+ async sendTelegram(webhookName, result, status) {
80
+ const adapter = WebhookDispatcher.telegramAdapter;
81
+ if (!adapter) {
82
+ this.display.log('Telegram notification skipped — adapter not connected.', { source: 'Webhooks', level: 'warning' });
83
+ return;
84
+ }
85
+ try {
86
+ const icon = status === 'completed' ? '✅' : '❌';
87
+ const truncated = result.length > 3500 ? result.slice(0, 3500) + '…' : result;
88
+ const message = `${icon} *Webhook: ${webhookName}*\n\n${truncated}`;
89
+ await adapter.sendMessage(message);
90
+ }
91
+ catch (err) {
92
+ this.display.log(`Failed to send Telegram notification for webhook "${webhookName}": ${err.message}`, { source: 'Webhooks', level: 'error' });
93
+ }
94
+ }
95
+ }
@@ -0,0 +1,199 @@
1
+ import Database from 'better-sqlite3';
2
+ import path from 'path';
3
+ import { homedir } from 'os';
4
+ import fs from 'fs-extra';
5
+ import { randomUUID } from 'crypto';
6
+ export class WebhookRepository {
7
+ static instance = null;
8
+ db;
9
+ constructor() {
10
+ const dbPath = path.join(homedir(), '.morpheus', 'memory', 'short-memory.db');
11
+ fs.ensureDirSync(path.dirname(dbPath));
12
+ this.db = new Database(dbPath, { timeout: 5000 });
13
+ this.db.pragma('journal_mode = WAL');
14
+ this.db.pragma('foreign_keys = ON');
15
+ this.ensureTables();
16
+ }
17
+ static getInstance() {
18
+ if (!WebhookRepository.instance) {
19
+ WebhookRepository.instance = new WebhookRepository();
20
+ }
21
+ return WebhookRepository.instance;
22
+ }
23
+ ensureTables() {
24
+ this.db.exec(`
25
+ CREATE TABLE IF NOT EXISTS webhooks (
26
+ id TEXT PRIMARY KEY,
27
+ name TEXT NOT NULL UNIQUE,
28
+ api_key TEXT NOT NULL UNIQUE,
29
+ prompt TEXT NOT NULL,
30
+ enabled INTEGER NOT NULL DEFAULT 1,
31
+ notification_channels TEXT NOT NULL DEFAULT '["ui"]',
32
+ created_at INTEGER NOT NULL,
33
+ last_triggered_at INTEGER,
34
+ trigger_count INTEGER NOT NULL DEFAULT 0
35
+ );
36
+
37
+ CREATE INDEX IF NOT EXISTS idx_webhooks_name ON webhooks(name);
38
+ CREATE INDEX IF NOT EXISTS idx_webhooks_api_key ON webhooks(api_key);
39
+
40
+ CREATE TABLE IF NOT EXISTS webhook_notifications (
41
+ id TEXT PRIMARY KEY,
42
+ webhook_id TEXT NOT NULL,
43
+ webhook_name TEXT NOT NULL,
44
+ status TEXT NOT NULL DEFAULT 'pending',
45
+ payload TEXT NOT NULL,
46
+ result TEXT,
47
+ read INTEGER NOT NULL DEFAULT 0,
48
+ created_at INTEGER NOT NULL,
49
+ completed_at INTEGER,
50
+ FOREIGN KEY (webhook_id) REFERENCES webhooks(id) ON DELETE CASCADE
51
+ );
52
+
53
+ CREATE INDEX IF NOT EXISTS idx_webhook_notifications_webhook_id
54
+ ON webhook_notifications(webhook_id);
55
+ CREATE INDEX IF NOT EXISTS idx_webhook_notifications_read
56
+ ON webhook_notifications(read);
57
+ CREATE INDEX IF NOT EXISTS idx_webhook_notifications_created_at
58
+ ON webhook_notifications(created_at DESC);
59
+ `);
60
+ }
61
+ // ─── Webhook CRUD ────────────────────────────────────────────────────────────
62
+ createWebhook(data) {
63
+ const id = randomUUID();
64
+ const api_key = randomUUID();
65
+ const now = Date.now();
66
+ this.db.prepare(`
67
+ INSERT INTO webhooks (id, name, api_key, prompt, enabled, notification_channels, created_at)
68
+ VALUES (?, ?, ?, ?, 1, ?, ?)
69
+ `).run(id, data.name, api_key, data.prompt, JSON.stringify(data.notification_channels), now);
70
+ return this.getWebhookById(id);
71
+ }
72
+ listWebhooks() {
73
+ const rows = this.db.prepare('SELECT * FROM webhooks ORDER BY created_at DESC').all();
74
+ return rows.map(this.deserializeWebhook);
75
+ }
76
+ getWebhookById(id) {
77
+ const row = this.db.prepare('SELECT * FROM webhooks WHERE id = ?').get(id);
78
+ return row ? this.deserializeWebhook(row) : null;
79
+ }
80
+ getWebhookByName(name) {
81
+ const row = this.db.prepare('SELECT * FROM webhooks WHERE name = ?').get(name);
82
+ return row ? this.deserializeWebhook(row) : null;
83
+ }
84
+ /**
85
+ * Looks up a webhook by name, then validates the api_key and enabled status.
86
+ * Returns null if not found, disabled, or api_key mismatch (caller decides error code).
87
+ */
88
+ getAndValidateWebhook(name, api_key) {
89
+ const row = this.db.prepare('SELECT * FROM webhooks WHERE name = ? AND enabled = 1').get(name);
90
+ if (!row)
91
+ return null;
92
+ const wh = this.deserializeWebhook(row);
93
+ if (wh.api_key !== api_key)
94
+ return null;
95
+ return wh;
96
+ }
97
+ updateWebhook(id, data) {
98
+ const existing = this.getWebhookById(id);
99
+ if (!existing)
100
+ return null;
101
+ const name = data.name ?? existing.name;
102
+ const prompt = data.prompt ?? existing.prompt;
103
+ const enabled = data.enabled !== undefined ? (data.enabled ? 1 : 0) : (existing.enabled ? 1 : 0);
104
+ const notification_channels = JSON.stringify(data.notification_channels ?? existing.notification_channels);
105
+ this.db.prepare(`
106
+ UPDATE webhooks
107
+ SET name = ?, prompt = ?, enabled = ?, notification_channels = ?
108
+ WHERE id = ?
109
+ `).run(name, prompt, enabled, notification_channels, id);
110
+ return this.getWebhookById(id);
111
+ }
112
+ deleteWebhook(id) {
113
+ const result = this.db.prepare('DELETE FROM webhooks WHERE id = ?').run(id);
114
+ return result.changes > 0;
115
+ }
116
+ recordTrigger(webhookId) {
117
+ this.db.prepare(`
118
+ UPDATE webhooks
119
+ SET trigger_count = trigger_count + 1, last_triggered_at = ?
120
+ WHERE id = ?
121
+ `).run(Date.now(), webhookId);
122
+ }
123
+ deserializeWebhook(row) {
124
+ return {
125
+ id: row.id,
126
+ name: row.name,
127
+ api_key: row.api_key,
128
+ prompt: row.prompt,
129
+ enabled: Boolean(row.enabled),
130
+ notification_channels: JSON.parse(row.notification_channels || '["ui"]'),
131
+ created_at: row.created_at,
132
+ last_triggered_at: row.last_triggered_at ?? null,
133
+ trigger_count: row.trigger_count ?? 0,
134
+ };
135
+ }
136
+ // ─── Notification CRUD ───────────────────────────────────────────────────────
137
+ createNotification(data) {
138
+ const id = randomUUID();
139
+ this.db.prepare(`
140
+ INSERT INTO webhook_notifications
141
+ (id, webhook_id, webhook_name, status, payload, read, created_at)
142
+ VALUES (?, ?, ?, 'pending', ?, 0, ?)
143
+ `).run(id, data.webhook_id, data.webhook_name, data.payload, Date.now());
144
+ return this.getNotificationById(id);
145
+ }
146
+ updateNotificationResult(id, status, result) {
147
+ this.db.prepare(`
148
+ UPDATE webhook_notifications
149
+ SET status = ?, result = ?, completed_at = ?
150
+ WHERE id = ?
151
+ `).run(status, result, Date.now(), id);
152
+ }
153
+ listNotifications(filters) {
154
+ let query = 'SELECT * FROM webhook_notifications WHERE 1=1';
155
+ const params = [];
156
+ if (filters?.webhookId) {
157
+ query += ' AND webhook_id = ?';
158
+ params.push(filters.webhookId);
159
+ }
160
+ if (filters?.unreadOnly) {
161
+ query += ' AND read = 0';
162
+ }
163
+ query += ' ORDER BY created_at DESC LIMIT 500';
164
+ const rows = this.db.prepare(query).all(...params);
165
+ return rows.map(this.deserializeNotification);
166
+ }
167
+ getNotificationById(id) {
168
+ const row = this.db.prepare('SELECT * FROM webhook_notifications WHERE id = ?').get(id);
169
+ return row ? this.deserializeNotification(row) : null;
170
+ }
171
+ markNotificationsRead(ids) {
172
+ const stmt = this.db.prepare('UPDATE webhook_notifications SET read = 1 WHERE id = ?');
173
+ const tx = this.db.transaction((list) => {
174
+ for (const id of list)
175
+ stmt.run(id);
176
+ });
177
+ tx(ids);
178
+ }
179
+ countUnread() {
180
+ const row = this.db.prepare('SELECT COUNT(*) as cnt FROM webhook_notifications WHERE read = 0').get();
181
+ return row?.cnt ?? 0;
182
+ }
183
+ deserializeNotification(row) {
184
+ return {
185
+ id: row.id,
186
+ webhook_id: row.webhook_id,
187
+ webhook_name: row.webhook_name,
188
+ status: row.status,
189
+ payload: row.payload,
190
+ result: row.result ?? null,
191
+ read: Boolean(row.read),
192
+ created_at: row.created_at,
193
+ completed_at: row.completed_at ?? null,
194
+ };
195
+ }
196
+ close() {
197
+ this.db.close();
198
+ }
199
+ }
@@ -0,0 +1 @@
1
+ export {};