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,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install/remove Mercury extensions from the host (CLI + dashboard).
|
|
3
|
+
* Uses the same layout as `mercury add` / `mercury remove`.
|
|
4
|
+
*/
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import {
|
|
7
|
+
cpSync,
|
|
8
|
+
existsSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
renameSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import type { Logger } from "../logger.js";
|
|
16
|
+
import type { ExtensionCatalogEntry } from "./catalog.js";
|
|
17
|
+
import { RESERVED_EXTENSION_NAMES } from "./reserved.js";
|
|
18
|
+
|
|
19
|
+
const VALID_EXT_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
20
|
+
|
|
21
|
+
function loadEnvFile(envPath: string): Record<string, string> {
|
|
22
|
+
const content = readFileSync(envPath, "utf-8");
|
|
23
|
+
const vars: Record<string, string> = {};
|
|
24
|
+
for (const line of content.split("\n")) {
|
|
25
|
+
const trimmed = line.trim();
|
|
26
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
27
|
+
const match = trimmed.match(/^([^=]+)=(.*)$/);
|
|
28
|
+
if (match) {
|
|
29
|
+
vars[match[1]] = match[2];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return vars;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Resolve MERCURY_DATA_DIR from project `.env` (default `.mercury`). */
|
|
36
|
+
export function getProjectDataDir(cwd: string): string {
|
|
37
|
+
const envPath = join(cwd, ".env");
|
|
38
|
+
if (existsSync(envPath)) {
|
|
39
|
+
const envVars = loadEnvFile(envPath);
|
|
40
|
+
if (envVars.MERCURY_DATA_DIR) return envVars.MERCURY_DATA_DIR;
|
|
41
|
+
}
|
|
42
|
+
return ".mercury";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function getUserExtensionsDir(cwd: string): string {
|
|
46
|
+
return join(cwd, getProjectDataDir(cwd), "extensions");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function getGlobalDir(cwd: string): string {
|
|
50
|
+
const envPath = join(cwd, ".env");
|
|
51
|
+
if (existsSync(envPath)) {
|
|
52
|
+
const envVars = loadEnvFile(envPath);
|
|
53
|
+
if (envVars.MERCURY_GLOBAL_DIR) return envVars.MERCURY_GLOBAL_DIR;
|
|
54
|
+
}
|
|
55
|
+
return join(cwd, getProjectDataDir(cwd), "global");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Path to `examples/extensions/<sourceDir>` inside the mercury-agent package. */
|
|
59
|
+
export function resolveExamplesExtensionDir(
|
|
60
|
+
packageRoot: string,
|
|
61
|
+
sourceDir: string,
|
|
62
|
+
): string {
|
|
63
|
+
return join(packageRoot, "examples", "extensions", sourceDir);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type ExtensionInstallResult =
|
|
67
|
+
| { ok: true }
|
|
68
|
+
| { ok: false; error: string };
|
|
69
|
+
|
|
70
|
+
function validateForInstall(
|
|
71
|
+
destName: string,
|
|
72
|
+
sourceDir: string,
|
|
73
|
+
extensionsDir: string,
|
|
74
|
+
): string | null {
|
|
75
|
+
if (!VALID_EXT_NAME_RE.test(destName)) {
|
|
76
|
+
return `Invalid extension name "${destName}" (lowercase letters, digits, hyphens only).`;
|
|
77
|
+
}
|
|
78
|
+
if (RESERVED_EXTENSION_NAMES.has(destName)) {
|
|
79
|
+
return `"${destName}" is a reserved built-in name.`;
|
|
80
|
+
}
|
|
81
|
+
if (!existsSync(join(sourceDir, "index.ts"))) {
|
|
82
|
+
return "Extension source has no index.ts.";
|
|
83
|
+
}
|
|
84
|
+
if (existsSync(join(extensionsDir, destName))) {
|
|
85
|
+
return `Extension "${destName}" is already installed. Remove it first.`;
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Validate that `index.ts` exists and default-exports a function (for CLI doctor). */
|
|
91
|
+
export async function checkExtensionIndexLoads(
|
|
92
|
+
extDir: string,
|
|
93
|
+
logicalName: string,
|
|
94
|
+
): Promise<string | null> {
|
|
95
|
+
const indexPath = join(extDir, "index.ts");
|
|
96
|
+
try {
|
|
97
|
+
const mod = await import(indexPath);
|
|
98
|
+
if (typeof mod.default !== "function") {
|
|
99
|
+
return `${logicalName}/index.ts must export a default function`;
|
|
100
|
+
}
|
|
101
|
+
} catch (err) {
|
|
102
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
103
|
+
return `Failed to load ${logicalName}/index.ts: ${msg}`;
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function installSkillIfPresent(
|
|
109
|
+
extDir: string,
|
|
110
|
+
name: string,
|
|
111
|
+
cwd: string,
|
|
112
|
+
): void {
|
|
113
|
+
const skillDir = join(extDir, "skill");
|
|
114
|
+
if (!existsSync(join(skillDir, "SKILL.md"))) return;
|
|
115
|
+
|
|
116
|
+
const globalDir = getGlobalDir(cwd);
|
|
117
|
+
const dst = join(globalDir, "skills", name);
|
|
118
|
+
mkdirSync(dirname(dst), { recursive: true });
|
|
119
|
+
rmSync(dst, { recursive: true, force: true });
|
|
120
|
+
cpSync(skillDir, dst, { recursive: true });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Copy an extension from a local directory into `.mercury/extensions/<destName>/`.
|
|
125
|
+
*/
|
|
126
|
+
export async function installExtensionFromDirectory(options: {
|
|
127
|
+
cwd: string;
|
|
128
|
+
sourceDir: string;
|
|
129
|
+
destName: string;
|
|
130
|
+
}): Promise<ExtensionInstallResult> {
|
|
131
|
+
const { cwd, sourceDir, destName } = options;
|
|
132
|
+
const extensionsDir = getUserExtensionsDir(cwd);
|
|
133
|
+
mkdirSync(extensionsDir, { recursive: true });
|
|
134
|
+
|
|
135
|
+
const err = validateForInstall(destName, sourceDir, extensionsDir);
|
|
136
|
+
if (err) return { ok: false, error: err };
|
|
137
|
+
|
|
138
|
+
const loadErr = await checkExtensionIndexLoads(sourceDir, destName);
|
|
139
|
+
if (loadErr) return { ok: false, error: loadErr };
|
|
140
|
+
|
|
141
|
+
const destDir = join(extensionsDir, destName);
|
|
142
|
+
try {
|
|
143
|
+
cpSync(sourceDir, destDir, { recursive: true });
|
|
144
|
+
|
|
145
|
+
if (existsSync(join(destDir, "package.json"))) {
|
|
146
|
+
const installResult = spawnSync("bun", ["install"], {
|
|
147
|
+
stdio: "pipe",
|
|
148
|
+
encoding: "utf-8",
|
|
149
|
+
cwd: destDir,
|
|
150
|
+
});
|
|
151
|
+
if (installResult.status !== 0) {
|
|
152
|
+
const stderr = installResult.stderr?.toString?.() ?? "";
|
|
153
|
+
rmSync(destDir, { recursive: true, force: true });
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
error: `bun install failed in extension: ${stderr || "unknown error"}`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
installSkillIfPresent(destDir, destName, cwd);
|
|
162
|
+
return { ok: true };
|
|
163
|
+
} catch (e) {
|
|
164
|
+
if (existsSync(destDir)) {
|
|
165
|
+
rmSync(destDir, { recursive: true, force: true });
|
|
166
|
+
}
|
|
167
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
168
|
+
return { ok: false, error: msg };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function removeInstalledExtension(options: {
|
|
173
|
+
cwd: string;
|
|
174
|
+
name: string;
|
|
175
|
+
}): ExtensionInstallResult {
|
|
176
|
+
const { cwd, name } = options;
|
|
177
|
+
if (!VALID_EXT_NAME_RE.test(name)) {
|
|
178
|
+
return { ok: false, error: `Invalid extension name "${name}".` };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const extensionsDir = getUserExtensionsDir(cwd);
|
|
182
|
+
const extDir = join(extensionsDir, name);
|
|
183
|
+
|
|
184
|
+
if (!existsSync(extDir)) {
|
|
185
|
+
return { ok: false, error: `Extension "${name}" is not installed.` };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const globalDir = getGlobalDir(cwd);
|
|
189
|
+
const skillDst = join(globalDir, "skills", name);
|
|
190
|
+
if (existsSync(skillDst)) {
|
|
191
|
+
rmSync(skillDst, { recursive: true, force: true });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
rmSync(extDir, { recursive: true, force: true });
|
|
195
|
+
return { ok: true };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* On startup, re-copy any installed catalog extension whose bundled source
|
|
200
|
+
* differs from the installed copy. Triggered when a new image ships a patched
|
|
201
|
+
* extension (e.g. MERCURY_BROWSER_SESSIONS fix) but running agents still have
|
|
202
|
+
* the old installed copy on their data volume.
|
|
203
|
+
*
|
|
204
|
+
* Uses an atomic temp-dir swap so a failed copy never leaves an extension
|
|
205
|
+
* partially installed or fully deleted.
|
|
206
|
+
*/
|
|
207
|
+
export async function syncBundledCatalogExtensions(options: {
|
|
208
|
+
packageRoot: string;
|
|
209
|
+
extensionsDir: string;
|
|
210
|
+
globalDir: string;
|
|
211
|
+
catalog: ExtensionCatalogEntry[];
|
|
212
|
+
logger: Logger;
|
|
213
|
+
}): Promise<void> {
|
|
214
|
+
const { packageRoot, extensionsDir, globalDir, catalog, logger } = options;
|
|
215
|
+
if (!existsSync(extensionsDir)) return;
|
|
216
|
+
|
|
217
|
+
for (const entry of catalog) {
|
|
218
|
+
const installedDir = join(extensionsDir, entry.name);
|
|
219
|
+
if (!existsSync(installedDir)) continue;
|
|
220
|
+
|
|
221
|
+
const bundledDir = resolveExamplesExtensionDir(
|
|
222
|
+
packageRoot,
|
|
223
|
+
entry.sourceDir,
|
|
224
|
+
);
|
|
225
|
+
if (!existsSync(bundledDir)) continue;
|
|
226
|
+
|
|
227
|
+
const bundledIndex = join(bundledDir, "index.ts");
|
|
228
|
+
if (!existsSync(bundledIndex)) continue;
|
|
229
|
+
|
|
230
|
+
let needsUpdate = false;
|
|
231
|
+
try {
|
|
232
|
+
for (const file of ["index.ts", "package.json"]) {
|
|
233
|
+
const bundledFile = join(bundledDir, file);
|
|
234
|
+
const installedFile = join(installedDir, file);
|
|
235
|
+
if (!existsSync(bundledFile)) continue;
|
|
236
|
+
const bundledContent = readFileSync(bundledFile, "utf-8");
|
|
237
|
+
const installedContent = existsSync(installedFile)
|
|
238
|
+
? readFileSync(installedFile, "utf-8")
|
|
239
|
+
: null;
|
|
240
|
+
if (bundledContent !== installedContent) {
|
|
241
|
+
needsUpdate = true;
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
} catch {
|
|
246
|
+
needsUpdate = true;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (!needsUpdate) continue;
|
|
250
|
+
|
|
251
|
+
logger.info("Bundled extension source updated — reinstalling", {
|
|
252
|
+
name: entry.name,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Atomic swap: copy to a temp sibling dir first, then replace.
|
|
256
|
+
// This ensures a copy failure never leaves the extension deleted.
|
|
257
|
+
const tmpDir = `${installedDir}.tmp`;
|
|
258
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
259
|
+
try {
|
|
260
|
+
cpSync(bundledDir, tmpDir, { recursive: true });
|
|
261
|
+
|
|
262
|
+
if (existsSync(join(tmpDir, "package.json"))) {
|
|
263
|
+
const installResult = spawnSync("bun", ["install"], {
|
|
264
|
+
stdio: "pipe",
|
|
265
|
+
encoding: "utf-8",
|
|
266
|
+
cwd: tmpDir,
|
|
267
|
+
});
|
|
268
|
+
if (installResult.status !== 0) {
|
|
269
|
+
// bun install failed — discard the temp dir; keep the old install.
|
|
270
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
271
|
+
logger.warn(
|
|
272
|
+
"Extension sync skipped: bun install failed in bundled source",
|
|
273
|
+
{
|
|
274
|
+
name: entry.name,
|
|
275
|
+
stderr: installResult.stderr?.toString?.() ?? "",
|
|
276
|
+
},
|
|
277
|
+
);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Swap: remove old, rename temp into place.
|
|
283
|
+
rmSync(installedDir, { recursive: true, force: true });
|
|
284
|
+
renameSync(tmpDir, installedDir);
|
|
285
|
+
|
|
286
|
+
// Sync skill dir to global skills.
|
|
287
|
+
const skillDir = join(installedDir, "skill");
|
|
288
|
+
if (existsSync(join(skillDir, "SKILL.md"))) {
|
|
289
|
+
const dst = join(globalDir, "skills", entry.name);
|
|
290
|
+
mkdirSync(dirname(dst), { recursive: true });
|
|
291
|
+
rmSync(dst, { recursive: true, force: true });
|
|
292
|
+
cpSync(skillDir, dst, { recursive: true });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
logger.info("Extension reinstalled from bundled source", {
|
|
296
|
+
name: entry.name,
|
|
297
|
+
});
|
|
298
|
+
} catch (e) {
|
|
299
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
300
|
+
logger.warn("Failed to reinstall extension from bundled source", {
|
|
301
|
+
name: entry.name,
|
|
302
|
+
error: e instanceof Error ? e.message : String(e),
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Job runner — executes background jobs registered by extensions.
|
|
3
|
+
*
|
|
4
|
+
* Supports interval-based and cron-based scheduling.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { CronExpressionParser } from "cron-parser";
|
|
8
|
+
import type { Logger } from "../logger.js";
|
|
9
|
+
import type {
|
|
10
|
+
ExtensionMeta,
|
|
11
|
+
JobDef,
|
|
12
|
+
MercuryExtensionContext,
|
|
13
|
+
} from "./types.js";
|
|
14
|
+
|
|
15
|
+
export class JobRunner {
|
|
16
|
+
private readonly timers = new Map<string, NodeJS.Timeout>();
|
|
17
|
+
private running = false;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Start all registered jobs from loaded extensions.
|
|
21
|
+
* Interval jobs run immediately, then repeat.
|
|
22
|
+
* Cron jobs schedule for the next matching time.
|
|
23
|
+
*/
|
|
24
|
+
start(extensions: ExtensionMeta[], ctx: MercuryExtensionContext): void {
|
|
25
|
+
if (this.running) return;
|
|
26
|
+
this.running = true;
|
|
27
|
+
|
|
28
|
+
for (const ext of extensions) {
|
|
29
|
+
for (const [jobName, jobDef] of ext.jobs) {
|
|
30
|
+
const fullName = `${ext.name}:${jobName}`;
|
|
31
|
+
const log = ctx.log.child({ job: fullName });
|
|
32
|
+
|
|
33
|
+
if (jobDef.interval) {
|
|
34
|
+
// Run immediately, then on interval
|
|
35
|
+
void this.runJob(fullName, jobDef, ctx, log);
|
|
36
|
+
const timer = setInterval(
|
|
37
|
+
() => void this.runJob(fullName, jobDef, ctx, log),
|
|
38
|
+
jobDef.interval,
|
|
39
|
+
);
|
|
40
|
+
this.timers.set(fullName, timer);
|
|
41
|
+
log.info("Started interval job", { intervalMs: jobDef.interval });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (jobDef.cron) {
|
|
45
|
+
this.scheduleCron(fullName, jobDef, ctx, log);
|
|
46
|
+
log.info("Started cron job", { cron: jobDef.cron });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Stop all running jobs and clear timers. */
|
|
53
|
+
stop(): void {
|
|
54
|
+
this.running = false;
|
|
55
|
+
for (const timer of this.timers.values()) {
|
|
56
|
+
clearInterval(timer);
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
}
|
|
59
|
+
this.timers.clear();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Number of active jobs. */
|
|
63
|
+
get activeCount(): number {
|
|
64
|
+
return this.timers.size;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private scheduleCron(
|
|
68
|
+
name: string,
|
|
69
|
+
def: JobDef,
|
|
70
|
+
ctx: MercuryExtensionContext,
|
|
71
|
+
log: Logger,
|
|
72
|
+
): void {
|
|
73
|
+
if (!this.running || !def.cron) return;
|
|
74
|
+
|
|
75
|
+
const delayMs = getNextCronDelay(def.cron);
|
|
76
|
+
if (delayMs === null) {
|
|
77
|
+
log.error("Invalid cron expression, job not scheduled", {
|
|
78
|
+
cron: def.cron,
|
|
79
|
+
});
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const timer = setTimeout(async () => {
|
|
84
|
+
if (!this.running) return;
|
|
85
|
+
await this.runJob(name, def, ctx, log);
|
|
86
|
+
// Reschedule for next tick
|
|
87
|
+
this.timers.delete(name);
|
|
88
|
+
this.scheduleCron(name, def, ctx, log);
|
|
89
|
+
}, delayMs);
|
|
90
|
+
|
|
91
|
+
this.timers.set(name, timer);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private async runJob(
|
|
95
|
+
_name: string,
|
|
96
|
+
def: JobDef,
|
|
97
|
+
ctx: MercuryExtensionContext,
|
|
98
|
+
log: Logger,
|
|
99
|
+
): Promise<void> {
|
|
100
|
+
log.info("Job starting");
|
|
101
|
+
try {
|
|
102
|
+
await def.run(ctx);
|
|
103
|
+
log.info("Job complete");
|
|
104
|
+
} catch (err) {
|
|
105
|
+
log.error("Job failed", err instanceof Error ? err : undefined);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Compute the delay in milliseconds until the next cron tick.
|
|
112
|
+
* Returns null if the expression is invalid.
|
|
113
|
+
*/
|
|
114
|
+
export function getNextCronDelay(cronExpr: string): number | null {
|
|
115
|
+
try {
|
|
116
|
+
const cron = CronExpressionParser.parse(cronExpr);
|
|
117
|
+
const next = cron.next().getTime();
|
|
118
|
+
return Math.max(0, next - Date.now());
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extension discovery, loading, and registry.
|
|
3
|
+
*
|
|
4
|
+
* Scans `.mercury/extensions/` for directories with index.ts,
|
|
5
|
+
* loads them, validates, and builds a registry.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import type { Logger } from "../logger.js";
|
|
12
|
+
import type { Db } from "../storage/db.js";
|
|
13
|
+
import { MercuryExtensionAPIImpl } from "./api.js";
|
|
14
|
+
import type { ConfigRegistry } from "./config-registry.js";
|
|
15
|
+
import { RESERVED_EXTENSION_NAMES } from "./reserved.js";
|
|
16
|
+
import type {
|
|
17
|
+
EventHandler,
|
|
18
|
+
ExtensionMeta,
|
|
19
|
+
JobDef,
|
|
20
|
+
MercuryEvents,
|
|
21
|
+
} from "./types.js";
|
|
22
|
+
|
|
23
|
+
/** Extension names must be alphanumeric + hyphens. */
|
|
24
|
+
const VALID_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
25
|
+
|
|
26
|
+
const __loaderDir = path.dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
/** Root of the mercury-agent package (parent of `src/`). */
|
|
28
|
+
const MERCURY_PACKAGE_ROOT = path.resolve(__loaderDir, "../..");
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Ensure `<extensionsDir>/node_modules/mercury-agent` resolves to this framework
|
|
32
|
+
* package so extensions can `import ... from "mercury-agent"` (e.g. `mercury-agent/tts`).
|
|
33
|
+
*/
|
|
34
|
+
function ensurePackageLink(extensionsDir: string): void {
|
|
35
|
+
const expectedRoot = path.resolve(MERCURY_PACKAGE_ROOT);
|
|
36
|
+
const nmDir = path.join(extensionsDir, "node_modules");
|
|
37
|
+
const linkPath = path.join(nmDir, "mercury-agent");
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
if (fs.existsSync(linkPath) && fs.realpathSync(linkPath) === expectedRoot) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
/* broken symlink or unreadable — replace */
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (fs.lstatSync(linkPath, { throwIfNoEntry: false }) !== undefined) {
|
|
48
|
+
fs.rmSync(linkPath, { recursive: true, force: true });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
fs.mkdirSync(nmDir, { recursive: true });
|
|
52
|
+
const linkType = process.platform === "win32" ? "junction" : "dir";
|
|
53
|
+
fs.symlinkSync(expectedRoot, linkPath, linkType);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class ExtensionRegistry {
|
|
57
|
+
private readonly extensions = new Map<string, ExtensionMeta>();
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Load all extensions from one or more directories.
|
|
61
|
+
* The first directory is the primary (user extensions),
|
|
62
|
+
* additional directories are for built-in extensions shipped with Mercury.
|
|
63
|
+
*
|
|
64
|
+
* `envOverride` replaces `process.env` for credential gate checks only —
|
|
65
|
+
* used by the pre-build endpoint to simulate the target container's env.
|
|
66
|
+
*/
|
|
67
|
+
async loadAll(
|
|
68
|
+
extensionsDir: string,
|
|
69
|
+
db: Db,
|
|
70
|
+
log: Logger,
|
|
71
|
+
configRegistry?: ConfigRegistry | null,
|
|
72
|
+
extraDirs: string[] = [],
|
|
73
|
+
envOverride?: Record<string, string>,
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
const dirs = [extensionsDir, ...extraDirs];
|
|
76
|
+
for (const dir of dirs) {
|
|
77
|
+
await this.loadFromDir(
|
|
78
|
+
dir,
|
|
79
|
+
db,
|
|
80
|
+
log,
|
|
81
|
+
configRegistry ?? undefined,
|
|
82
|
+
envOverride,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private async loadFromDir(
|
|
88
|
+
extensionsDir: string,
|
|
89
|
+
db: Db,
|
|
90
|
+
log: Logger,
|
|
91
|
+
configRegistry?: ConfigRegistry,
|
|
92
|
+
envOverride?: Record<string, string>,
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
if (!fs.existsSync(extensionsDir)) {
|
|
95
|
+
log.debug(`Extensions directory not found: ${extensionsDir}`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
ensurePackageLink(extensionsDir);
|
|
100
|
+
|
|
101
|
+
const entries = fs.readdirSync(extensionsDir, { withFileTypes: true });
|
|
102
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
103
|
+
|
|
104
|
+
for (const entry of entries) {
|
|
105
|
+
if (!entry.isDirectory()) continue;
|
|
106
|
+
|
|
107
|
+
const name = entry.name;
|
|
108
|
+
if (name === "node_modules") continue;
|
|
109
|
+
|
|
110
|
+
const extDir = path.join(extensionsDir, name);
|
|
111
|
+
|
|
112
|
+
// Validate name format
|
|
113
|
+
if (!VALID_NAME_RE.test(name)) {
|
|
114
|
+
log.warn(
|
|
115
|
+
`Skipping extension "${name}": invalid name (must be lowercase alphanumeric + hyphens)`,
|
|
116
|
+
);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Check reserved names
|
|
121
|
+
if (RESERVED_EXTENSION_NAMES.has(name)) {
|
|
122
|
+
throw new Error(`Extension "${name}" conflicts with built-in command`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Check for index.ts
|
|
126
|
+
const indexPath = path.join(extDir, "index.ts");
|
|
127
|
+
if (!fs.existsSync(indexPath)) {
|
|
128
|
+
log.warn(
|
|
129
|
+
`Skipping extension "${name}": no index.ts found in ${extDir}`,
|
|
130
|
+
);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Skip if already loaded (user extensions take precedence over built-in)
|
|
135
|
+
if (this.extensions.has(name)) {
|
|
136
|
+
log.debug(`Skipping duplicate extension "${name}" (already loaded)`);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const meta = await loadExtension(name, extDir, indexPath, db);
|
|
142
|
+
|
|
143
|
+
// Credential gate: skip connection extensions whose credential env var is unset.
|
|
144
|
+
// When envOverride is provided (pre-build simulation), check it instead of process.env.
|
|
145
|
+
const credVar = meta.connection?.credentialEnvVar;
|
|
146
|
+
const envToCheck = envOverride ?? process.env;
|
|
147
|
+
if (credVar && !envToCheck[credVar]) {
|
|
148
|
+
log.debug(
|
|
149
|
+
`Skipping extension "${name}": connection credential env var ${credVar} is not set`,
|
|
150
|
+
);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Register extension config keys in the config registry
|
|
155
|
+
if (configRegistry) {
|
|
156
|
+
for (const [key, def] of meta.configs) {
|
|
157
|
+
configRegistry.register(name, key, def);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
this.extensions.set(name, meta);
|
|
161
|
+
log.info(`Loaded extension: ${name}`);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
log.error(
|
|
164
|
+
`Failed to load extension "${name}": ${err instanceof Error ? err.message : String(err)}`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Get an extension by name. */
|
|
171
|
+
get(name: string): ExtensionMeta | undefined {
|
|
172
|
+
return this.extensions.get(name);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** List all loaded extensions. */
|
|
176
|
+
list(): ExtensionMeta[] {
|
|
177
|
+
return [...this.extensions.values()];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Get extensions that declare a CLI. */
|
|
181
|
+
getCliExtensions(): ExtensionMeta[] {
|
|
182
|
+
return this.list().filter((ext) => ext.clis.length > 0);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Get all env var source names claimed by extensions (for passthrough filtering). */
|
|
186
|
+
getClaimedEnvSources(): Set<string> {
|
|
187
|
+
const sources = new Set<string>();
|
|
188
|
+
for (const ext of this.extensions.values()) {
|
|
189
|
+
for (const envDef of ext.envVars) {
|
|
190
|
+
sources.add(envDef.from);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return sources;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Get all hook handlers for a specific event, across all extensions. */
|
|
197
|
+
getHookHandlers<E extends keyof MercuryEvents>(event: E): EventHandler<E>[] {
|
|
198
|
+
const handlers: EventHandler<E>[] = [];
|
|
199
|
+
for (const ext of this.extensions.values()) {
|
|
200
|
+
const extHandlers = ext.hooks.get(event);
|
|
201
|
+
if (extHandlers) {
|
|
202
|
+
handlers.push(...(extHandlers as EventHandler<E>[]));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return handlers;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Get all jobs across all extensions. */
|
|
209
|
+
getJobs(): Array<{ extension: string; name: string; def: JobDef }> {
|
|
210
|
+
const jobs: Array<{ extension: string; name: string; def: JobDef }> = [];
|
|
211
|
+
for (const ext of this.extensions.values()) {
|
|
212
|
+
for (const [name, def] of ext.jobs) {
|
|
213
|
+
jobs.push({ extension: ext.name, name, def });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return jobs;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Number of loaded extensions. */
|
|
220
|
+
get size(): number {
|
|
221
|
+
return this.extensions.size;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Load a single extension: import its index.ts, run the setup function,
|
|
227
|
+
* and return the collected metadata.
|
|
228
|
+
*/
|
|
229
|
+
async function loadExtension(
|
|
230
|
+
name: string,
|
|
231
|
+
extDir: string,
|
|
232
|
+
indexPath: string,
|
|
233
|
+
db: Db,
|
|
234
|
+
): Promise<ExtensionMeta> {
|
|
235
|
+
const mod = await import(indexPath);
|
|
236
|
+
const setup = mod.default;
|
|
237
|
+
|
|
238
|
+
if (typeof setup !== "function") {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`Extension "${name}": index.ts must export a default function`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const api = new MercuryExtensionAPIImpl(name, extDir, db);
|
|
245
|
+
setup(api);
|
|
246
|
+
const meta = api.getMeta();
|
|
247
|
+
|
|
248
|
+
if (meta.connection) {
|
|
249
|
+
if (!meta.permission) {
|
|
250
|
+
throw new Error(
|
|
251
|
+
`Extension "${name}": connection() requires permission() — credentials must be gated`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
const { credentialEnvVar, statusCheck } = meta.connection;
|
|
255
|
+
if (!credentialEnvVar && !statusCheck) {
|
|
256
|
+
throw new Error(
|
|
257
|
+
`Extension "${name}": connection requires at least one of credentialEnvVar or statusCheck`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
if (credentialEnvVar) {
|
|
261
|
+
const declared = meta.envVars.some((e) => e.from === credentialEnvVar);
|
|
262
|
+
if (!declared) {
|
|
263
|
+
throw new Error(
|
|
264
|
+
`Extension "${name}": connection.credentialEnvVar "${credentialEnvVar}" is not declared via mercury.env()`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return meta;
|
|
271
|
+
}
|