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.
- package/README.md +127 -0
- package/dist/channels/telegram.js +133 -26
- package/dist/cli/commands/start.js +3 -0
- package/dist/config/schemas.js +4 -0
- package/dist/http/middleware/auth.js +1 -1
- package/dist/http/server.js +8 -0
- package/dist/http/webhooks-router.js +181 -0
- package/dist/runtime/tools/analytics-tools.js +77 -41
- package/dist/runtime/tools/diagnostic-tools.js +72 -43
- package/dist/runtime/webhooks/dispatcher.js +95 -0
- package/dist/runtime/webhooks/repository.js +199 -0
- package/dist/runtime/webhooks/types.js +1 -0
- package/dist/ui/assets/index-LemKVRjC.js +112 -0
- package/dist/ui/assets/index-TCQ7VNYO.css +1 -0
- package/dist/ui/index.html +2 -2
- package/dist/ui/sw.js +1 -1
- package/package.json +1 -1
- package/dist/ui/assets/index-CjlkpcsE.js +0 -109
- package/dist/ui/assets/index-LrqT6MpO.css +0 -1
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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().
|
|
33
|
-
end: z.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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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
|
-
|
|
95
|
+
// The messages table uses `created_at` (Unix ms integer), not `timestamp`
|
|
96
|
+
let whereClause = "";
|
|
73
97
|
const params = [];
|
|
74
98
|
if (timeRange) {
|
|
75
|
-
|
|
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
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
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().
|
|
100
|
-
end: z.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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
54
|
+
// ── Short-term memory DB ────────────────────────────────────────
|
|
47
55
|
try {
|
|
48
|
-
|
|
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
|
-
|
|
58
|
+
const stat = await fsPromises.stat(dbPath);
|
|
59
|
+
components.shortMemoryDb = {
|
|
52
60
|
status: "healthy",
|
|
53
|
-
message: "
|
|
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.
|
|
66
|
+
components.shortMemoryDb = {
|
|
59
67
|
status: "error",
|
|
60
|
-
message: `
|
|
61
|
-
details: {}
|
|
68
|
+
message: `Short-memory DB not accessible: ${error.message}`,
|
|
69
|
+
details: {},
|
|
62
70
|
};
|
|
63
71
|
}
|
|
64
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
95
|
-
|
|
125
|
+
const logsDir = path.join(morpheusRoot, "logs");
|
|
126
|
+
await fsPromises.access(logsDir);
|
|
127
|
+
components.logs = {
|
|
96
128
|
status: "healthy",
|
|
97
|
-
message: "
|
|
98
|
-
details: {
|
|
129
|
+
message: "Logs directory is accessible",
|
|
130
|
+
details: { path: logsDir },
|
|
99
131
|
};
|
|
100
132
|
}
|
|
101
|
-
catch
|
|
102
|
-
components.
|
|
103
|
-
status: "
|
|
104
|
-
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
|
|
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 {};
|