mercury-agent 0.4.5
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 +22 -0
- package/README.md +438 -0
- package/container/Dockerfile +127 -0
- package/container/Dockerfile.base +109 -0
- package/container/Dockerfile.power +17 -0
- package/container/agent-package.json +8 -0
- package/container/build.sh +54 -0
- package/docs/TODOS.md +147 -0
- package/docs/auth/dashboard.md +28 -0
- package/docs/auth/overview.md +109 -0
- package/docs/auth/whatsapp.md +173 -0
- package/docs/configuration.md +54 -0
- package/docs/container-lifecycle.md +349 -0
- package/docs/context-architecture.md +87 -0
- package/docs/deployment.md +199 -0
- package/docs/extensions.md +375 -0
- package/docs/graceful-shutdown.md +62 -0
- package/docs/kb-distillation.md +77 -0
- package/docs/media/overview.md +140 -0
- package/docs/media/whatsapp.md +171 -0
- package/docs/memory.md +137 -0
- package/docs/permissions.md +217 -0
- package/docs/pipeline.md +228 -0
- package/docs/prd-chat-memory.md +76 -0
- package/docs/prd-config-load.md +82 -0
- package/docs/rate-limiting.md +166 -0
- package/docs/scheduler.md +288 -0
- package/docs/setup-discord.md +100 -0
- package/docs/setup-slack.md +119 -0
- package/docs/setup-whatsapp.md +94 -0
- package/docs/subagents.md +166 -0
- package/docs/web-search.md +62 -0
- package/examples/extensions/README.md +12 -0
- package/examples/extensions/charts/index.ts +13 -0
- package/examples/extensions/charts/skill/SKILL.md +98 -0
- package/examples/extensions/gws/README.md +52 -0
- package/examples/extensions/gws/index.ts +106 -0
- package/examples/extensions/gws/skill/SKILL.md +57 -0
- package/examples/extensions/gws/skill/references/calendar.md +101 -0
- package/examples/extensions/gws/skill/references/docs.md +65 -0
- package/examples/extensions/gws/skill/references/drive.md +79 -0
- package/examples/extensions/gws/skill/references/gmail.md +85 -0
- package/examples/extensions/gws/skill/references/sheets.md +60 -0
- package/examples/extensions/napkin/index.ts +821 -0
- package/examples/extensions/napkin/prompts/consolidation-monthly.md +73 -0
- package/examples/extensions/napkin/prompts/consolidation-weekly.md +67 -0
- package/examples/extensions/napkin/prompts/kb-distillation.md +176 -0
- package/examples/extensions/napkin/skill/SKILL.md +728 -0
- package/examples/extensions/pdf/index.ts +23 -0
- package/examples/extensions/pdf/skill/LICENSE.txt +30 -0
- package/examples/extensions/pdf/skill/SKILL.md +314 -0
- package/examples/extensions/pdf/skill/forms.md +294 -0
- package/examples/extensions/pdf/skill/reference.md +612 -0
- package/examples/extensions/pdf/skill/scripts/check_bounding_boxes.py +65 -0
- package/examples/extensions/pdf/skill/scripts/check_fillable_fields.py +11 -0
- package/examples/extensions/pdf/skill/scripts/convert_pdf_to_images.py +33 -0
- package/examples/extensions/pdf/skill/scripts/create_validation_image.py +37 -0
- package/examples/extensions/pdf/skill/scripts/extract_form_field_info.py +122 -0
- package/examples/extensions/pdf/skill/scripts/extract_form_structure.py +115 -0
- package/examples/extensions/pdf/skill/scripts/fill_fillable_fields.py +98 -0
- package/examples/extensions/pdf/skill/scripts/fill_pdf_form_with_annotations.py +107 -0
- package/examples/extensions/permission-guard/index.ts +65 -0
- package/examples/extensions/pinchtab/index.ts +199 -0
- package/examples/extensions/pinchtab/lib/session-injector.ts +144 -0
- package/examples/extensions/pinchtab/skill/SKILL.md +224 -0
- package/examples/extensions/pinchtab/skill/TRUST.md +69 -0
- package/examples/extensions/pinchtab/skill/references/api.md +297 -0
- package/examples/extensions/pinchtab/skill/references/env.md +45 -0
- package/examples/extensions/pinchtab/skill/references/profiles.md +107 -0
- package/examples/extensions/tradestation/host/refresh.ts +102 -0
- package/examples/extensions/tradestation/index.ts +153 -0
- package/examples/extensions/tradestation/skill/SKILL.md +67 -0
- package/examples/extensions/tradestation/skill/scripts/ts-cli.ts +111 -0
- package/examples/extensions/voice-synth/index.ts +94 -0
- package/examples/extensions/voice-synth/skill/SKILL.md +38 -0
- package/examples/extensions/voice-transcribe/index.ts +381 -0
- package/examples/extensions/voice-transcribe/requirements.txt +8 -0
- package/examples/extensions/voice-transcribe/scripts/transcribe.py +179 -0
- package/examples/extensions/voice-transcribe/skill/SKILL.md +53 -0
- package/examples/extensions/web-search/index.ts +22 -0
- package/examples/extensions/web-search/skill/SKILL.md +114 -0
- package/examples/extensions/web-search/skill/references/apartments.md +178 -0
- package/examples/extensions/web-search/skill/references/car-purchase.md +132 -0
- package/examples/extensions/web-search/skill/references/car-rental.md +113 -0
- package/examples/extensions/web-search/skill/references/flights.md +133 -0
- package/examples/extensions/web-search/skill/references/hotels.md +148 -0
- package/examples/extensions/yahoo-mail/cli/bun.lock +66 -0
- package/examples/extensions/yahoo-mail/cli/package.json +13 -0
- package/examples/extensions/yahoo-mail/cli/ymail.mjs +353 -0
- package/examples/extensions/yahoo-mail/index.ts +57 -0
- package/examples/extensions/yahoo-mail/skill/SKILL.md +78 -0
- package/package.json +106 -0
- package/resources/agents/explore.md +50 -0
- package/resources/agents/worker.md +24 -0
- package/resources/builtin-extensions.txt +3 -0
- package/resources/connection-env-vars.json +25 -0
- package/resources/extensions/.gitkeep +0 -0
- package/resources/pi-extensions/subagent/agents.ts +126 -0
- package/resources/pi-extensions/subagent/index.ts +964 -0
- package/resources/profiles/coding/AGENTS.md +43 -0
- package/resources/profiles/coding/mercury-profile.yaml +15 -0
- package/resources/profiles/general/AGENTS.md +31 -0
- package/resources/profiles/general/mercury-profile.yaml +15 -0
- package/resources/profiles/research/AGENTS.md +40 -0
- package/resources/profiles/research/mercury-profile.yaml +15 -0
- package/resources/skills/config/SKILL.md +25 -0
- package/resources/skills/context/SKILL.md +33 -0
- package/resources/skills/conversation-recap/SKILL.md +19 -0
- package/resources/skills/media/SKILL.md +27 -0
- package/resources/skills/mutes/SKILL.md +31 -0
- package/resources/skills/permissions/SKILL.md +19 -0
- package/resources/skills/preferences/SKILL.md +31 -0
- package/resources/skills/recall/SKILL.md +24 -0
- package/resources/skills/roles/SKILL.md +18 -0
- package/resources/skills/spaces/SKILL.md +18 -0
- package/resources/skills/tasks/SKILL.md +45 -0
- package/resources/templates/AGENTS.md +157 -0
- package/resources/templates/env.template +34 -0
- package/resources/templates/mercury.example.yaml +75 -0
- package/src/adapters/discord-native.ts +534 -0
- package/src/adapters/discord.ts +38 -0
- package/src/adapters/setup.ts +89 -0
- package/src/adapters/slack.ts +9 -0
- package/src/adapters/whatsapp-media.ts +337 -0
- package/src/adapters/whatsapp.ts +629 -0
- package/src/agent/api-socket.ts +127 -0
- package/src/agent/container-entry.ts +967 -0
- package/src/agent/container-error.ts +49 -0
- package/src/agent/container-runner.ts +1272 -0
- package/src/agent/model-capabilities-core.ts +23 -0
- package/src/agent/model-capabilities.ts +231 -0
- package/src/agent/pi-failure-class.ts +83 -0
- package/src/agent/pi-jsonl-parser.ts +306 -0
- package/src/agent/preferences-prompt.ts +20 -0
- package/src/agent/user-error-messages.ts +78 -0
- package/src/bridges/discord.ts +171 -0
- package/src/bridges/slack.ts +177 -0
- package/src/bridges/teams.ts +160 -0
- package/src/bridges/telegram.ts +571 -0
- package/src/bridges/whatsapp.ts +290 -0
- package/src/chat-shim.ts +259 -0
- package/src/cli/mercury.ts +2508 -0
- package/src/cli/mrctl-http.ts +27 -0
- package/src/cli/mrctl.ts +611 -0
- package/src/cli/whatsapp-auth.ts +260 -0
- package/src/config-file.ts +397 -0
- package/src/config-model-chain.ts +30 -0
- package/src/config.ts +316 -0
- package/src/core/api-types.ts +58 -0
- package/src/core/api.ts +105 -0
- package/src/core/commands.ts +76 -0
- package/src/core/conversation.ts +47 -0
- package/src/core/handler.ts +206 -0
- package/src/core/media.ts +200 -0
- package/src/core/mute-duration.ts +22 -0
- package/src/core/outbox.ts +76 -0
- package/src/core/permissions.ts +192 -0
- package/src/core/profiles.ts +245 -0
- package/src/core/rate-limiter.ts +127 -0
- package/src/core/router.ts +191 -0
- package/src/core/routes/chat.ts +172 -0
- package/src/core/routes/config-builtin.ts +107 -0
- package/src/core/routes/config.ts +81 -0
- package/src/core/routes/connections.ts +190 -0
- package/src/core/routes/console.ts +668 -0
- package/src/core/routes/control.ts +46 -0
- package/src/core/routes/conversations.ts +66 -0
- package/src/core/routes/dashboard.ts +2491 -0
- package/src/core/routes/extensions.ts +37 -0
- package/src/core/routes/index.ts +14 -0
- package/src/core/routes/media.ts +72 -0
- package/src/core/routes/messages.ts +37 -0
- package/src/core/routes/mutes.ts +89 -0
- package/src/core/routes/prefs.ts +95 -0
- package/src/core/routes/roles.ts +125 -0
- package/src/core/routes/spaces.ts +60 -0
- package/src/core/routes/storage.ts +126 -0
- package/src/core/routes/tasks.ts +189 -0
- package/src/core/routes/tradestation.ts +268 -0
- package/src/core/routes/tts.ts +51 -0
- package/src/core/runtime.ts +1140 -0
- package/src/core/space-queue.ts +103 -0
- package/src/core/storage-cleanup.ts +140 -0
- package/src/core/storage-guard.ts +24 -0
- package/src/core/task-scheduler.ts +132 -0
- package/src/core/telegram-format.ts +178 -0
- package/src/core/trigger.ts +142 -0
- package/src/dashboard/index.html +729 -0
- package/src/dashboard/tokens.css +53 -0
- package/src/extensions/api.ts +252 -0
- package/src/extensions/catalog.ts +117 -0
- package/src/extensions/config-registry.ts +83 -0
- package/src/extensions/context.ts +36 -0
- package/src/extensions/hooks.ts +156 -0
- package/src/extensions/image-builder.ts +617 -0
- package/src/extensions/installer.ts +306 -0
- package/src/extensions/jobs.ts +122 -0
- package/src/extensions/loader.ts +271 -0
- package/src/extensions/permission-guard.ts +52 -0
- package/src/extensions/reserved.ts +28 -0
- package/src/extensions/skills.ts +123 -0
- package/src/extensions/types.ts +462 -0
- package/src/logger.ts +174 -0
- package/src/main.ts +586 -0
- package/src/server.ts +391 -0
- package/src/storage/db.ts +1624 -0
- package/src/storage/memory.ts +45 -0
- package/src/storage/pi-auth.ts +95 -0
- package/src/text/markdown.ts +117 -0
- package/src/text/rtl.ts +38 -0
- package/src/tradestation/host-api.ts +77 -0
- package/src/tradestation/pending-orders.ts +69 -0
- package/src/tts/azure.ts +52 -0
- package/src/tts/google.ts +128 -0
- package/src/tts/index.ts +8 -0
- package/src/tts/language.ts +20 -0
- package/src/tts/synthesize.ts +133 -0
- package/src/types.ts +295 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { Hono } from "hono";
|
|
5
|
+
import { logger } from "../../logger.js";
|
|
6
|
+
import { ensureSpaceWorkspace } from "../../storage/memory.js";
|
|
7
|
+
import type { IngressMessage, MessageAttachment } from "../../types.js";
|
|
8
|
+
import { extToMime, mimeToMediaType } from "../media.js";
|
|
9
|
+
import type { MercuryCoreRuntime } from "../runtime.js";
|
|
10
|
+
import { isOverQuota } from "../storage-guard.js";
|
|
11
|
+
|
|
12
|
+
interface ChatFileInput {
|
|
13
|
+
name: string;
|
|
14
|
+
data: string; // base64
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ChatFileOutput {
|
|
18
|
+
filename: string;
|
|
19
|
+
mimeType: string;
|
|
20
|
+
sizeBytes: number;
|
|
21
|
+
data: string; // base64
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createChatRoute(core: MercuryCoreRuntime): Hono {
|
|
25
|
+
const app = new Hono();
|
|
26
|
+
|
|
27
|
+
app.post("/", async (c) => {
|
|
28
|
+
// Validate chat API key when configured
|
|
29
|
+
const chatApiKey = core.config.chatApiKey;
|
|
30
|
+
let authenticated = false;
|
|
31
|
+
if (chatApiKey) {
|
|
32
|
+
const authHeader = c.req.header("authorization");
|
|
33
|
+
const token = authHeader?.startsWith("Bearer ")
|
|
34
|
+
? authHeader.slice(7)
|
|
35
|
+
: undefined;
|
|
36
|
+
|
|
37
|
+
if (
|
|
38
|
+
!token ||
|
|
39
|
+
token.length !== chatApiKey.length ||
|
|
40
|
+
!timingSafeEqual(Buffer.from(token), Buffer.from(chatApiKey))
|
|
41
|
+
) {
|
|
42
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
43
|
+
}
|
|
44
|
+
authenticated = true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const body = await c.req.json().catch(() => null);
|
|
48
|
+
if (!body || typeof body.text !== "string" || !body.text.trim()) {
|
|
49
|
+
return c.json({ error: "Missing or empty 'text' field" }, 400);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const callerId =
|
|
53
|
+
typeof body.callerId === "string" && body.callerId.trim()
|
|
54
|
+
? body.callerId.trim()
|
|
55
|
+
: "api:anonymous";
|
|
56
|
+
|
|
57
|
+
const spaceId =
|
|
58
|
+
typeof body.spaceId === "string" && body.spaceId.trim()
|
|
59
|
+
? body.spaceId.trim()
|
|
60
|
+
: "main";
|
|
61
|
+
|
|
62
|
+
const authorName =
|
|
63
|
+
typeof body.authorName === "string" ? body.authorName.trim() : undefined;
|
|
64
|
+
|
|
65
|
+
// Save incoming files to inbox/
|
|
66
|
+
const attachments: MessageAttachment[] = [];
|
|
67
|
+
if (Array.isArray(body.files)) {
|
|
68
|
+
if (await isOverQuota(core.config)) {
|
|
69
|
+
return c.json({ error: "Storage quota exceeded" }, 413);
|
|
70
|
+
}
|
|
71
|
+
const workspace = ensureSpaceWorkspace(core.config.spacesDir, spaceId);
|
|
72
|
+
const inboxDir = path.join(workspace, "inbox");
|
|
73
|
+
fs.mkdirSync(inboxDir, { recursive: true });
|
|
74
|
+
|
|
75
|
+
for (const file of body.files as ChatFileInput[]) {
|
|
76
|
+
if (!file.name || !file.data) continue;
|
|
77
|
+
try {
|
|
78
|
+
const buffer = Buffer.from(file.data, "base64");
|
|
79
|
+
const safeName = path
|
|
80
|
+
.basename(file.name)
|
|
81
|
+
.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
82
|
+
const filename = `${Date.now()}-${safeName || "file"}`;
|
|
83
|
+
const filePath = path.join(inboxDir, filename);
|
|
84
|
+
fs.writeFileSync(filePath, buffer);
|
|
85
|
+
|
|
86
|
+
const mimeType = extToMime(file.name);
|
|
87
|
+
attachments.push({
|
|
88
|
+
path: filePath,
|
|
89
|
+
type: mimeToMediaType(mimeType),
|
|
90
|
+
mimeType,
|
|
91
|
+
filename: file.name,
|
|
92
|
+
sizeBytes: buffer.length,
|
|
93
|
+
});
|
|
94
|
+
} catch (err) {
|
|
95
|
+
logger.warn("Failed to save chat file", {
|
|
96
|
+
name: file.name,
|
|
97
|
+
error: err instanceof Error ? err.message : String(err),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!core.db.getSpace(spaceId)) {
|
|
104
|
+
return c.json({ error: "Space not found" }, 404);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (authenticated) {
|
|
108
|
+
core.db.seedAdmins(spaceId, [callerId]);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const ingress: IngressMessage = {
|
|
112
|
+
platform: "api",
|
|
113
|
+
spaceId,
|
|
114
|
+
conversationExternalId: `api:${callerId}`,
|
|
115
|
+
callerId,
|
|
116
|
+
authorName,
|
|
117
|
+
text: body.text.trim(),
|
|
118
|
+
isDM: true,
|
|
119
|
+
isReplyToBot: false,
|
|
120
|
+
attachments,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
logger.info("API chat inbound", {
|
|
124
|
+
callerId,
|
|
125
|
+
spaceId,
|
|
126
|
+
preview: ingress.text.slice(0, 80),
|
|
127
|
+
fileCount: attachments.length,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const result = await core.handleRawInput(ingress, "cli");
|
|
131
|
+
|
|
132
|
+
if (result.type === "ignore") {
|
|
133
|
+
return c.json({ reply: "", files: [] });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (result.type === "denied") {
|
|
137
|
+
return c.json({ error: result.reason }, 403);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const reply = result.result?.reply ?? "";
|
|
141
|
+
const egressFiles = result.result?.files ?? [];
|
|
142
|
+
|
|
143
|
+
// Encode outbox files as base64
|
|
144
|
+
const outputFiles: ChatFileOutput[] = [];
|
|
145
|
+
for (const f of egressFiles) {
|
|
146
|
+
try {
|
|
147
|
+
const buffer = fs.readFileSync(f.path);
|
|
148
|
+
outputFiles.push({
|
|
149
|
+
filename: f.filename,
|
|
150
|
+
mimeType: f.mimeType,
|
|
151
|
+
sizeBytes: f.sizeBytes,
|
|
152
|
+
data: buffer.toString("base64"),
|
|
153
|
+
});
|
|
154
|
+
} catch (err) {
|
|
155
|
+
logger.warn("Failed to read outbox file for chat response", {
|
|
156
|
+
path: f.path,
|
|
157
|
+
error: err instanceof Error ? err.message : String(err),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
logger.info("API chat outbound", {
|
|
163
|
+
spaceId,
|
|
164
|
+
preview: reply.slice(0, 80),
|
|
165
|
+
fileCount: outputFiles.length,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
return c.json({ reply, files: outputFiles });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
return app;
|
|
172
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/** Built-in space config keys (trigger + ambient). */
|
|
2
|
+
|
|
3
|
+
export const BUILTIN_CONFIG_KEYS = new Set([
|
|
4
|
+
"trigger.match",
|
|
5
|
+
"trigger.patterns",
|
|
6
|
+
"trigger.case_sensitive",
|
|
7
|
+
"trigger.media_in_groups",
|
|
8
|
+
"ambient.enabled",
|
|
9
|
+
"context.mode",
|
|
10
|
+
"context.window_size",
|
|
11
|
+
"context.reply_chain_depth",
|
|
12
|
+
"security.sensitive_connections_allowed",
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* One-line plain-English descriptions for each builtin config key, surfaced as
|
|
17
|
+
* native browser tooltips on the agent dashboard space-settings rows. The key
|
|
18
|
+
* set MUST match BUILTIN_CONFIG_KEYS exactly — enforced by
|
|
19
|
+
* tests/builtin-config-descriptions.test.ts.
|
|
20
|
+
*/
|
|
21
|
+
export const BUILTIN_CONFIG_DESCRIPTIONS: Record<string, string> = {
|
|
22
|
+
"trigger.match":
|
|
23
|
+
"How a message triggers the agent: 'mention' (name appears as a word), 'prefix' (message starts with the name), or 'always' (every message).",
|
|
24
|
+
"trigger.patterns":
|
|
25
|
+
"Comma-separated list of names/aliases the agent responds to (e.g. '@Mercury, Mercury').",
|
|
26
|
+
"trigger.case_sensitive":
|
|
27
|
+
"When true, trigger pattern matching is case-sensitive. When false, case is ignored.",
|
|
28
|
+
"trigger.media_in_groups":
|
|
29
|
+
"When true, media-only messages (image/audio with no text) trigger the agent in group chats.",
|
|
30
|
+
"ambient.enabled":
|
|
31
|
+
"When true, non-triggered group messages are still stored as ambient context. Set false for strict tag-only mode.",
|
|
32
|
+
"context.mode":
|
|
33
|
+
"'clear' = each message starts fresh (reply to bot for chain context). 'context' = include a sliding window of recent turns automatically.",
|
|
34
|
+
"context.window_size":
|
|
35
|
+
"Number of recent turns included as context when context.mode=context. Integer 1-50.",
|
|
36
|
+
"context.reply_chain_depth":
|
|
37
|
+
"Max number of turns walked back when following a reply chain. Integer 1-50.",
|
|
38
|
+
"security.sensitive_connections_allowed":
|
|
39
|
+
"When true, sensitive integrations (e.g. payments, identity) are usable in this group space. False blocks them by default.",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const BUILTIN_VALIDATORS: Record<string, (v: string) => string | null> = {
|
|
43
|
+
"trigger.match": (v) =>
|
|
44
|
+
["prefix", "mention", "always"].includes(v)
|
|
45
|
+
? null
|
|
46
|
+
: "Invalid trigger.match value. Valid: prefix, mention, always",
|
|
47
|
+
"trigger.case_sensitive": (v) =>
|
|
48
|
+
["true", "false"].includes(v)
|
|
49
|
+
? null
|
|
50
|
+
: "Invalid trigger.case_sensitive value. Valid: true, false",
|
|
51
|
+
"trigger.media_in_groups": (v) =>
|
|
52
|
+
["true", "false"].includes(v)
|
|
53
|
+
? null
|
|
54
|
+
: "Invalid trigger.media_in_groups value. Valid: true, false",
|
|
55
|
+
"ambient.enabled": (v) =>
|
|
56
|
+
["true", "false"].includes(v)
|
|
57
|
+
? null
|
|
58
|
+
: "Invalid ambient.enabled value. Valid: true, false",
|
|
59
|
+
"context.mode": (v) =>
|
|
60
|
+
["clear", "context"].includes(v)
|
|
61
|
+
? null
|
|
62
|
+
: "Invalid context.mode value. Valid: clear, context",
|
|
63
|
+
"context.window_size": (v) => {
|
|
64
|
+
const n = Number.parseInt(v, 10);
|
|
65
|
+
return Number.isInteger(n) && n >= 1 && n <= 50
|
|
66
|
+
? null
|
|
67
|
+
: "Invalid context.window_size value. Must be an integer between 1 and 50";
|
|
68
|
+
},
|
|
69
|
+
"context.reply_chain_depth": (v) => {
|
|
70
|
+
const n = Number.parseInt(v, 10);
|
|
71
|
+
return Number.isInteger(n) && n >= 1 && n <= 50
|
|
72
|
+
? null
|
|
73
|
+
: "Invalid context.reply_chain_depth value. Must be an integer between 1 and 50";
|
|
74
|
+
},
|
|
75
|
+
"security.sensitive_connections_allowed": (v) =>
|
|
76
|
+
["true", "false"].includes(v)
|
|
77
|
+
? null
|
|
78
|
+
: "Invalid security.sensitive_connections_allowed value. Valid: true, false",
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export function isBuiltinConfigKey(key: string): boolean {
|
|
82
|
+
return BUILTIN_CONFIG_KEYS.has(key);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Validate a built-in space config key/value. Returns an error message or null if valid.
|
|
87
|
+
* Call only when the key is known to be built-in, or use {@link validateDashboardBuiltinConfig}.
|
|
88
|
+
*/
|
|
89
|
+
export function validateBuiltinConfigValue(
|
|
90
|
+
key: string,
|
|
91
|
+
value: string,
|
|
92
|
+
): string | null {
|
|
93
|
+
const validator = BUILTIN_VALIDATORS[key];
|
|
94
|
+
if (validator) return validator(value);
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** For dashboard-only updates: key must be built-in. */
|
|
99
|
+
export function validateDashboardBuiltinConfig(
|
|
100
|
+
key: string,
|
|
101
|
+
value: string,
|
|
102
|
+
): string | null {
|
|
103
|
+
if (!isBuiltinConfigKey(key)) {
|
|
104
|
+
return "Invalid config key for dashboard";
|
|
105
|
+
}
|
|
106
|
+
return validateBuiltinConfigValue(key, value);
|
|
107
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { checkPerm, type Env, getApiCtx, getAuth } from "../api-types.js";
|
|
3
|
+
import {
|
|
4
|
+
isBuiltinConfigKey,
|
|
5
|
+
validateBuiltinConfigValue,
|
|
6
|
+
} from "./config-builtin.js";
|
|
7
|
+
|
|
8
|
+
export const config = new Hono<Env>();
|
|
9
|
+
|
|
10
|
+
config.get("/", (c) => {
|
|
11
|
+
const { spaceId } = getAuth(c);
|
|
12
|
+
const denied = checkPerm(c, "config.get");
|
|
13
|
+
if (denied) return denied;
|
|
14
|
+
|
|
15
|
+
const { db, configRegistry } = getApiCtx(c);
|
|
16
|
+
const entries = db.listSpaceConfig(spaceId);
|
|
17
|
+
const configMap: Record<string, string> = {};
|
|
18
|
+
for (const e of entries) configMap[e.key] = e.value;
|
|
19
|
+
|
|
20
|
+
// Include registered extension config keys with descriptions and defaults
|
|
21
|
+
const available = configRegistry.getAll().map((rc) => ({
|
|
22
|
+
key: rc.key,
|
|
23
|
+
description: rc.description,
|
|
24
|
+
default: rc.default,
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
return c.json({ spaceId, config: configMap, available });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
config.put("/", async (c) => {
|
|
31
|
+
const { spaceId, callerId } = getAuth(c);
|
|
32
|
+
const denied = checkPerm(c, "config.set");
|
|
33
|
+
if (denied) return denied;
|
|
34
|
+
|
|
35
|
+
const { db, configRegistry } = getApiCtx(c);
|
|
36
|
+
const body = await c.req.json<{ key?: string; value?: string }>();
|
|
37
|
+
|
|
38
|
+
if (!body.key || body.value === undefined) {
|
|
39
|
+
return c.json({ error: "Missing key or value" }, 400);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const isBuiltin = isBuiltinConfigKey(body.key);
|
|
43
|
+
const isExtension = configRegistry.isValidKey(body.key);
|
|
44
|
+
|
|
45
|
+
if (!isBuiltin && !isExtension) {
|
|
46
|
+
return c.json(
|
|
47
|
+
{
|
|
48
|
+
error: `Invalid config key. Run mrctl config get for valid keys.`,
|
|
49
|
+
},
|
|
50
|
+
400,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (isBuiltin) {
|
|
55
|
+
const error = validateBuiltinConfigValue(body.key, body.value);
|
|
56
|
+
if (error) return c.json({ error }, 400);
|
|
57
|
+
|
|
58
|
+
// Extra guard: enabling sensitive connections on a DM-only space has no effect
|
|
59
|
+
if (
|
|
60
|
+
body.key === "security.sensitive_connections_allowed" &&
|
|
61
|
+
body.value === "true" &&
|
|
62
|
+
!db.hasGroupLinkedConversation(spaceId)
|
|
63
|
+
) {
|
|
64
|
+
return c.json(
|
|
65
|
+
{
|
|
66
|
+
error:
|
|
67
|
+
"This space has no group-linked conversations; the sensitive connections guard has no effect here.",
|
|
68
|
+
},
|
|
69
|
+
400,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Extension config validation
|
|
75
|
+
if (isExtension && !configRegistry.validate(body.key, body.value)) {
|
|
76
|
+
return c.json({ error: `Invalid value for ${body.key}` }, 400);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
db.setSpaceConfig(spaceId, body.key, body.value, callerId);
|
|
80
|
+
return c.json({ spaceId, key: body.key, value: body.value });
|
|
81
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { AppConfig } from "../../config.js";
|
|
3
|
+
import { createMercuryExtensionContext } from "../../extensions/context.js";
|
|
4
|
+
import type { ExtensionRegistry } from "../../extensions/loader.js";
|
|
5
|
+
import type {
|
|
6
|
+
ConnectionDef,
|
|
7
|
+
ConnectionStatus,
|
|
8
|
+
ExtensionMeta,
|
|
9
|
+
MercuryExtensionContext,
|
|
10
|
+
} from "../../extensions/types.js";
|
|
11
|
+
import { logger } from "../../logger.js";
|
|
12
|
+
import type { Db } from "../../storage/db.js";
|
|
13
|
+
import { type Env, getApiCtx } from "../api-types.js";
|
|
14
|
+
|
|
15
|
+
export const connections = new Hono<Env>();
|
|
16
|
+
|
|
17
|
+
const STATUS_CHECK_TIMEOUT_MS = 5000;
|
|
18
|
+
|
|
19
|
+
const VALID_STATUSES: ReadonlySet<ConnectionStatus> = new Set<ConnectionStatus>(
|
|
20
|
+
["connected", "needs-reauth", "broken", "unknown"],
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
interface ResolvedStatus {
|
|
24
|
+
status: ConnectionStatus;
|
|
25
|
+
detail: string | null;
|
|
26
|
+
error: string | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function resolveStatus(
|
|
30
|
+
ext: ExtensionMeta,
|
|
31
|
+
conn: ConnectionDef,
|
|
32
|
+
ctx: MercuryExtensionContext,
|
|
33
|
+
): Promise<ResolvedStatus> {
|
|
34
|
+
if (conn.statusCheck) {
|
|
35
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
36
|
+
try {
|
|
37
|
+
const result = await Promise.race([
|
|
38
|
+
conn.statusCheck(ctx),
|
|
39
|
+
new Promise<never>((_, reject) => {
|
|
40
|
+
timeoutHandle = setTimeout(
|
|
41
|
+
() =>
|
|
42
|
+
reject(
|
|
43
|
+
new Error(
|
|
44
|
+
`statusCheck timed out after ${STATUS_CHECK_TIMEOUT_MS}ms`,
|
|
45
|
+
),
|
|
46
|
+
),
|
|
47
|
+
STATUS_CHECK_TIMEOUT_MS,
|
|
48
|
+
);
|
|
49
|
+
}),
|
|
50
|
+
]);
|
|
51
|
+
if (!VALID_STATUSES.has(result.status)) {
|
|
52
|
+
ctx.log.error(
|
|
53
|
+
`Extension "${ext.name}": statusCheck returned invalid status`,
|
|
54
|
+
{ status: result.status },
|
|
55
|
+
);
|
|
56
|
+
return {
|
|
57
|
+
status: "unknown",
|
|
58
|
+
detail: null,
|
|
59
|
+
error: "statusCheck returned invalid status",
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
status: result.status,
|
|
64
|
+
detail: result.detail ?? null,
|
|
65
|
+
error: null,
|
|
66
|
+
};
|
|
67
|
+
} catch (err) {
|
|
68
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
69
|
+
ctx.log.error(`Extension "${ext.name}": statusCheck failed`, { err });
|
|
70
|
+
return { status: "unknown", detail: null, error: message };
|
|
71
|
+
} finally {
|
|
72
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (conn.credentialEnvVar) {
|
|
77
|
+
const present = !!process.env[conn.credentialEnvVar];
|
|
78
|
+
return {
|
|
79
|
+
status: present ? "connected" : "unknown",
|
|
80
|
+
detail: null,
|
|
81
|
+
error: null,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Loader validation guarantees at least one signal, so this is defensive.
|
|
86
|
+
return {
|
|
87
|
+
status: "unknown",
|
|
88
|
+
detail: null,
|
|
89
|
+
error: "connection declares no signal",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ResolvedConnection {
|
|
94
|
+
name: string;
|
|
95
|
+
displayName: string;
|
|
96
|
+
iconUrl: string | null;
|
|
97
|
+
category: string;
|
|
98
|
+
authType: string;
|
|
99
|
+
scopes: string[];
|
|
100
|
+
status: ConnectionStatus;
|
|
101
|
+
detail: string | null;
|
|
102
|
+
error: string | null;
|
|
103
|
+
sensitive: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Resolve the merged connection list for an agent. Shared between the internal
|
|
108
|
+
* `/api/connections` route (caller-authed) and the `/api/console/connections`
|
|
109
|
+
* route (Bearer-authed). Same shape — so the console merges runtime status
|
|
110
|
+
* against its per-user `user_connections` rows without caring which endpoint
|
|
111
|
+
* it hit.
|
|
112
|
+
*/
|
|
113
|
+
export async function resolveConnectionList(opts: {
|
|
114
|
+
registry: ExtensionRegistry;
|
|
115
|
+
db: Db;
|
|
116
|
+
config: AppConfig;
|
|
117
|
+
}): Promise<ResolvedConnection[]> {
|
|
118
|
+
const entries = opts.registry
|
|
119
|
+
.list()
|
|
120
|
+
.filter(
|
|
121
|
+
(ext): ext is ExtensionMeta & { connection: ConnectionDef } =>
|
|
122
|
+
!!ext.connection,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
// Promise.allSettled is belt-and-suspenders: resolveStatus already catches
|
|
126
|
+
// every internal failure, but if a future change regresses that we still
|
|
127
|
+
// isolate one extension's crash from the rest of the response.
|
|
128
|
+
const settled = await Promise.allSettled(
|
|
129
|
+
entries.map(async (ext) => {
|
|
130
|
+
const conn = ext.connection;
|
|
131
|
+
const extCtx = createMercuryExtensionContext({
|
|
132
|
+
db: opts.db,
|
|
133
|
+
config: opts.config,
|
|
134
|
+
log: logger.child({ extension: ext.name }),
|
|
135
|
+
});
|
|
136
|
+
const resolvedStatus = await resolveStatus(ext, conn, extCtx);
|
|
137
|
+
return {
|
|
138
|
+
name: ext.name,
|
|
139
|
+
displayName: conn.displayName,
|
|
140
|
+
iconUrl: conn.iconUrl ?? null,
|
|
141
|
+
category: conn.category,
|
|
142
|
+
authType: conn.authType,
|
|
143
|
+
scopes: conn.scopes ?? [],
|
|
144
|
+
status: resolvedStatus.status,
|
|
145
|
+
detail: resolvedStatus.detail,
|
|
146
|
+
error: resolvedStatus.error,
|
|
147
|
+
sensitive: conn.sensitive ?? false,
|
|
148
|
+
} satisfies ResolvedConnection;
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
return settled.map((result, i) => {
|
|
153
|
+
if (result.status === "fulfilled") return result.value;
|
|
154
|
+
const ext = entries[i];
|
|
155
|
+
const message =
|
|
156
|
+
result.reason instanceof Error
|
|
157
|
+
? result.reason.message
|
|
158
|
+
: String(result.reason);
|
|
159
|
+
logger.error(`connections route: resolver threw for "${ext.name}"`, {
|
|
160
|
+
err: result.reason,
|
|
161
|
+
});
|
|
162
|
+
return {
|
|
163
|
+
name: ext.name,
|
|
164
|
+
displayName: ext.connection.displayName,
|
|
165
|
+
iconUrl: ext.connection.iconUrl ?? null,
|
|
166
|
+
category: ext.connection.category,
|
|
167
|
+
authType: ext.connection.authType,
|
|
168
|
+
scopes: ext.connection.scopes ?? [],
|
|
169
|
+
status: "unknown" as ConnectionStatus,
|
|
170
|
+
detail: null,
|
|
171
|
+
error: message,
|
|
172
|
+
sensitive: ext.connection.sensitive ?? false,
|
|
173
|
+
};
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** GET /connections — list all connection-enabled extensions with live status. */
|
|
178
|
+
connections.get("/", async (c) => {
|
|
179
|
+
const apiCtx = getApiCtx(c);
|
|
180
|
+
if (!apiCtx.registry) {
|
|
181
|
+
logger.error("connections route: registry missing from apiCtx");
|
|
182
|
+
return c.json({ error: "Extension registry not initialized" }, 500);
|
|
183
|
+
}
|
|
184
|
+
const resolved = await resolveConnectionList({
|
|
185
|
+
registry: apiCtx.registry,
|
|
186
|
+
db: apiCtx.db,
|
|
187
|
+
config: apiCtx.config,
|
|
188
|
+
});
|
|
189
|
+
return c.json({ connections: resolved });
|
|
190
|
+
});
|