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,617 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derived image builder.
|
|
3
|
+
*
|
|
4
|
+
* When extensions declare CLI tools via `mercury.cli()`, this module
|
|
5
|
+
* generates a Dockerfile extending the base agent image with those
|
|
6
|
+
* CLIs installed, builds it, and caches the result by content hash.
|
|
7
|
+
*
|
|
8
|
+
* Install commands are grouped by package manager (apt, pip, npm, bun)
|
|
9
|
+
* into minimal RUN steps with BuildKit cache mounts for fast rebuilds.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { execFileSync, execSync, spawn } from "node:child_process";
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import type { Logger } from "../logger.js";
|
|
18
|
+
import type { ExtensionMeta } from "./types.js";
|
|
19
|
+
|
|
20
|
+
/** Parsed install command — either a known package manager or raw shell. */
|
|
21
|
+
export type ParsedInstall =
|
|
22
|
+
| { type: "apt"; packages: string[] }
|
|
23
|
+
| { type: "pip"; packages: string[] }
|
|
24
|
+
| { type: "npm"; packages: string[] }
|
|
25
|
+
| { type: "bun"; packages: string[] }
|
|
26
|
+
| { type: "shell"; command: string };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Split a command string on `&&` while respecting single and double quotes.
|
|
30
|
+
* `&&` inside quoted strings is not treated as a separator.
|
|
31
|
+
*/
|
|
32
|
+
function splitOnAnd(cmd: string): string[] {
|
|
33
|
+
const parts: string[] = [];
|
|
34
|
+
let current = "";
|
|
35
|
+
let inSingle = false;
|
|
36
|
+
let inDouble = false;
|
|
37
|
+
|
|
38
|
+
for (let i = 0; i < cmd.length; i++) {
|
|
39
|
+
const ch = cmd[i];
|
|
40
|
+
|
|
41
|
+
if (ch === "'" && !inDouble) {
|
|
42
|
+
inSingle = !inSingle;
|
|
43
|
+
current += ch;
|
|
44
|
+
} else if (ch === '"' && !inSingle) {
|
|
45
|
+
inDouble = !inDouble;
|
|
46
|
+
current += ch;
|
|
47
|
+
} else if (ch === "&" && cmd[i + 1] === "&" && !inSingle && !inDouble) {
|
|
48
|
+
parts.push(current.trim());
|
|
49
|
+
current = "";
|
|
50
|
+
i++; // skip second &
|
|
51
|
+
} else {
|
|
52
|
+
current += ch;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const last = current.trim();
|
|
57
|
+
if (last) parts.push(last);
|
|
58
|
+
|
|
59
|
+
return parts;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Parse a single install command string into a typed representation.
|
|
64
|
+
* Recognizes apt-get, pip, npm, and bun patterns. Falls back to shell.
|
|
65
|
+
*/
|
|
66
|
+
export function parseInstallCommand(cmd: string): ParsedInstall[] {
|
|
67
|
+
const results: ParsedInstall[] = [];
|
|
68
|
+
|
|
69
|
+
// Split on && respecting quotes
|
|
70
|
+
const parts = splitOnAnd(cmd);
|
|
71
|
+
|
|
72
|
+
// If the command mixes apt/pip/npm/bun with shell commands (e.g. repo setup
|
|
73
|
+
// via curl/echo before apt-get install), keep the whole thing as a single
|
|
74
|
+
// shell command to preserve ordering dependencies.
|
|
75
|
+
const hasShellParts = parts.some(
|
|
76
|
+
(p) =>
|
|
77
|
+
p &&
|
|
78
|
+
!p.match(/^apt-get\s/) &&
|
|
79
|
+
!p.match(/^(?:python3\s+-m\s+)?pip\s+install/) &&
|
|
80
|
+
!p.match(/^npm\s+install\s+-g/) &&
|
|
81
|
+
!p.match(/^bun\s+add\s+-g/) &&
|
|
82
|
+
!p.match(/^rm\s+-rf\s+\/var\/lib\/apt/),
|
|
83
|
+
);
|
|
84
|
+
const hasPackageManager = parts.some(
|
|
85
|
+
(p) =>
|
|
86
|
+
p &&
|
|
87
|
+
(p.match(/^apt-get\s+install/) ||
|
|
88
|
+
p.match(/^(?:python3\s+-m\s+)?pip\s+install/) ||
|
|
89
|
+
p.match(/^npm\s+install\s+-g/) ||
|
|
90
|
+
p.match(/^bun\s+add\s+-g/)),
|
|
91
|
+
);
|
|
92
|
+
if (hasShellParts && hasPackageManager) {
|
|
93
|
+
return [{ type: "shell", command: cmd }];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (const part of parts) {
|
|
97
|
+
// apt-get install
|
|
98
|
+
const aptMatch = part.match(
|
|
99
|
+
/^apt-get\s+(?:update\s*$|install\s+(?:-\S+\s+)*(.+))/,
|
|
100
|
+
);
|
|
101
|
+
if (aptMatch) {
|
|
102
|
+
if (aptMatch[1]) {
|
|
103
|
+
// Extract package names (skip flags like -y --no-install-recommends)
|
|
104
|
+
const packages = aptMatch[1]
|
|
105
|
+
.split(/\s+/)
|
|
106
|
+
.filter((s) => s && !s.startsWith("-"));
|
|
107
|
+
if (packages.length > 0) {
|
|
108
|
+
results.push({ type: "apt", packages });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Skip bare "apt-get update" and "rm -rf /var/lib/apt/lists/*"
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// rm -rf /var/lib/apt/lists/* (apt cleanup, skip)
|
|
116
|
+
if (/^rm\s+-rf\s+\/var\/lib\/apt\/lists/.test(part)) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// pip install
|
|
121
|
+
const pipMatch = part.match(
|
|
122
|
+
/^(?:python3\s+-m\s+)?pip\s+install\s+(?:-\S+\s+)*(.+)/,
|
|
123
|
+
);
|
|
124
|
+
if (pipMatch) {
|
|
125
|
+
const packages = pipMatch[1]
|
|
126
|
+
.split(/\s+/)
|
|
127
|
+
.filter((s) => s && !s.startsWith("-"));
|
|
128
|
+
if (packages.length > 0) {
|
|
129
|
+
results.push({ type: "pip", packages });
|
|
130
|
+
}
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// npm install -g
|
|
135
|
+
const npmMatch = part.match(/^npm\s+install\s+-g\s+(.+)/);
|
|
136
|
+
if (npmMatch) {
|
|
137
|
+
const packages = npmMatch[1]
|
|
138
|
+
.split(/\s+/)
|
|
139
|
+
.filter((s) => s && !s.startsWith("-"));
|
|
140
|
+
if (packages.length > 0) {
|
|
141
|
+
results.push({ type: "npm", packages });
|
|
142
|
+
}
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// bun add -g
|
|
147
|
+
const bunMatch = part.match(/^bun\s+add\s+-g\s+(.+)/);
|
|
148
|
+
if (bunMatch) {
|
|
149
|
+
const packages = bunMatch[1]
|
|
150
|
+
.split(/\s+/)
|
|
151
|
+
.filter((s) => s && !s.startsWith("-"));
|
|
152
|
+
if (packages.length > 0) {
|
|
153
|
+
results.push({ type: "bun", packages });
|
|
154
|
+
}
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Everything else is a shell command
|
|
159
|
+
if (part) {
|
|
160
|
+
results.push({ type: "shell", command: part });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return results;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Merge parsed install commands: group packages by manager, deduplicate.
|
|
169
|
+
* Shell commands are preserved in order.
|
|
170
|
+
*/
|
|
171
|
+
export function mergeInstalls(parsed: ParsedInstall[]): ParsedInstall[] {
|
|
172
|
+
const apt = new Set<string>();
|
|
173
|
+
const pip = new Set<string>();
|
|
174
|
+
const npm = new Set<string>();
|
|
175
|
+
const bun = new Set<string>();
|
|
176
|
+
const shell: string[] = [];
|
|
177
|
+
const shellSeen = new Set<string>();
|
|
178
|
+
|
|
179
|
+
for (const p of parsed) {
|
|
180
|
+
if (p.type === "shell") {
|
|
181
|
+
if (!shellSeen.has(p.command)) {
|
|
182
|
+
shellSeen.add(p.command);
|
|
183
|
+
shell.push(p.command);
|
|
184
|
+
}
|
|
185
|
+
} else {
|
|
186
|
+
const set =
|
|
187
|
+
p.type === "apt"
|
|
188
|
+
? apt
|
|
189
|
+
: p.type === "pip"
|
|
190
|
+
? pip
|
|
191
|
+
: p.type === "npm"
|
|
192
|
+
? npm
|
|
193
|
+
: bun;
|
|
194
|
+
for (const pkg of p.packages) set.add(pkg);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const result: ParsedInstall[] = [];
|
|
199
|
+
if (apt.size > 0) result.push({ type: "apt", packages: [...apt].sort() });
|
|
200
|
+
if (pip.size > 0) result.push({ type: "pip", packages: [...pip].sort() });
|
|
201
|
+
if (npm.size > 0) result.push({ type: "npm", packages: [...npm].sort() });
|
|
202
|
+
if (bun.size > 0) result.push({ type: "bun", packages: [...bun].sort() });
|
|
203
|
+
for (const cmd of shell) result.push({ type: "shell", command: cmd });
|
|
204
|
+
|
|
205
|
+
return result;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Convert merged installs into RUN lines with BuildKit cache mounts.
|
|
210
|
+
*/
|
|
211
|
+
export function toRunStatements(merged: ParsedInstall[]): string[] {
|
|
212
|
+
const lines: string[] = [];
|
|
213
|
+
|
|
214
|
+
for (const m of merged) {
|
|
215
|
+
switch (m.type) {
|
|
216
|
+
case "apt":
|
|
217
|
+
lines.push(
|
|
218
|
+
`RUN apt-get update && apt-get install -y --no-install-recommends ${m.packages.join(" ")} && ` +
|
|
219
|
+
`rm -rf /var/lib/apt/lists/*`,
|
|
220
|
+
);
|
|
221
|
+
break;
|
|
222
|
+
case "pip":
|
|
223
|
+
lines.push(
|
|
224
|
+
`RUN --mount=type=cache,target=/home/mercury/.cache/pip ` +
|
|
225
|
+
`pip install --break-system-packages ${m.packages.join(" ")}`,
|
|
226
|
+
);
|
|
227
|
+
break;
|
|
228
|
+
case "npm":
|
|
229
|
+
lines.push(
|
|
230
|
+
`RUN --mount=type=cache,target=/home/mercury/.npm ` +
|
|
231
|
+
`PUPPETEER_SKIP_DOWNLOAD=true npm install -g ${m.packages.join(" ")}`,
|
|
232
|
+
);
|
|
233
|
+
break;
|
|
234
|
+
case "bun":
|
|
235
|
+
lines.push(
|
|
236
|
+
`RUN --mount=type=cache,target=/home/mercury/.bun/install/cache ` +
|
|
237
|
+
`bun add -g ${m.packages.join(" ")}`,
|
|
238
|
+
);
|
|
239
|
+
break;
|
|
240
|
+
case "shell":
|
|
241
|
+
lines.push(`RUN ${m.command}`);
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return lines;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Generate a Dockerfile that extends the base image with extension CLI installs.
|
|
251
|
+
* Returns null if no extensions declare CLIs.
|
|
252
|
+
*
|
|
253
|
+
* Install commands are parsed, merged by package manager, deduplicated,
|
|
254
|
+
* and emitted as minimal RUN steps with BuildKit cache mounts.
|
|
255
|
+
*/
|
|
256
|
+
export function generateDockerfile(
|
|
257
|
+
baseImage: string,
|
|
258
|
+
extensions: ExtensionMeta[],
|
|
259
|
+
): string | null {
|
|
260
|
+
const allClis = extensions.flatMap((e) => e.clis);
|
|
261
|
+
if (allClis.length === 0) return null;
|
|
262
|
+
|
|
263
|
+
// Parse all install commands
|
|
264
|
+
const parsed = allClis.flatMap((cli) => parseInstallCommand(cli.install));
|
|
265
|
+
|
|
266
|
+
// Merge by package manager
|
|
267
|
+
const merged = mergeInstalls(parsed);
|
|
268
|
+
|
|
269
|
+
// Collect local bin scripts to COPY into the image
|
|
270
|
+
const binCopies = allClis
|
|
271
|
+
.filter((cli) => cli.bin && fs.existsSync(cli.bin))
|
|
272
|
+
.map((cli) => ({
|
|
273
|
+
contextName: `bin-${cli.name}`,
|
|
274
|
+
dest: `/usr/local/bin/${cli.name}`,
|
|
275
|
+
}));
|
|
276
|
+
|
|
277
|
+
// Generate Dockerfile
|
|
278
|
+
// Base image ends with USER mercury; switch to root for installs, restore after.
|
|
279
|
+
const lines = [
|
|
280
|
+
`# syntax=docker/dockerfile:1`,
|
|
281
|
+
`FROM ${baseImage}`,
|
|
282
|
+
`USER root`,
|
|
283
|
+
];
|
|
284
|
+
for (const bc of binCopies) {
|
|
285
|
+
lines.push(`COPY ${bc.contextName} ${bc.dest}`);
|
|
286
|
+
lines.push(`RUN chmod 755 ${bc.dest}`);
|
|
287
|
+
}
|
|
288
|
+
lines.push(...toRunStatements(merged));
|
|
289
|
+
if (binCopies.length > 0 && merged.some((m) => m.type === "npm")) {
|
|
290
|
+
lines.push(`RUN ln -sf $(npm root -g) /usr/local/bin/node_modules`);
|
|
291
|
+
}
|
|
292
|
+
lines.push(`RUN chown -R mercury:mercury /home/mercury`);
|
|
293
|
+
lines.push(`USER mercury`);
|
|
294
|
+
|
|
295
|
+
return lines.join("\n");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Compute a deterministic hash for cache invalidation.
|
|
300
|
+
* Based on the base image ref, resolved image id (content), and sorted install commands.
|
|
301
|
+
*/
|
|
302
|
+
export function computeImageHash(
|
|
303
|
+
baseImage: string,
|
|
304
|
+
baseImageId: string | null,
|
|
305
|
+
extensions: ExtensionMeta[],
|
|
306
|
+
): string {
|
|
307
|
+
const allClis = extensions.flatMap((e) => e.clis);
|
|
308
|
+
const installCommands = allClis
|
|
309
|
+
.map((c) => c.install)
|
|
310
|
+
.sort()
|
|
311
|
+
.join("\n");
|
|
312
|
+
|
|
313
|
+
const h = createHash("sha256");
|
|
314
|
+
h.update(`${baseImage}\n${baseImageId ?? ""}\n${installCommands}`);
|
|
315
|
+
for (const cli of allClis) {
|
|
316
|
+
if (cli.bin && fs.existsSync(cli.bin)) {
|
|
317
|
+
h.update(fs.readFileSync(cli.bin));
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return h.digest("hex").slice(0, 12);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Resolve the local Docker image id for a tag or name (e.g. `mercury-agent:latest`).
|
|
325
|
+
* Used so derived images invalidate when the base image is rebuilt.
|
|
326
|
+
*/
|
|
327
|
+
export function resolveBaseImageId(baseImage: string): string | null {
|
|
328
|
+
try {
|
|
329
|
+
const id = execFileSync(
|
|
330
|
+
"docker",
|
|
331
|
+
["image", "inspect", "-f", "{{.Id}}", baseImage],
|
|
332
|
+
{
|
|
333
|
+
encoding: "utf8",
|
|
334
|
+
timeout: 10_000,
|
|
335
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
336
|
+
},
|
|
337
|
+
).trim();
|
|
338
|
+
return id || null;
|
|
339
|
+
} catch {
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Run `docker build` asynchronously so the event loop is never blocked.
|
|
346
|
+
*
|
|
347
|
+
* `execSync` would stall the single-threaded runtime for the entire build
|
|
348
|
+
* (~4 min with Playwright), which silently defeats the fire-and-forget
|
|
349
|
+
* background rebuild in container-runner's `replyWithRetry`. Using `spawn`
|
|
350
|
+
* lets `ensureDerivedImage` actually yield at the build step.
|
|
351
|
+
*/
|
|
352
|
+
const DOCKER_BUILD_TIMEOUT_MS = 600_000;
|
|
353
|
+
|
|
354
|
+
function runDockerBuild(derivedTag: string, contextDir: string): Promise<void> {
|
|
355
|
+
return new Promise((resolve, reject) => {
|
|
356
|
+
const proc = spawn("docker", ["build", "-t", derivedTag, contextDir], {
|
|
357
|
+
env: { ...process.env, DOCKER_BUILDKIT: "1" },
|
|
358
|
+
});
|
|
359
|
+
let stderrTail = "";
|
|
360
|
+
// Drain stdout so the pipe buffer never fills and blocks docker.
|
|
361
|
+
proc.stdout?.on("data", () => {});
|
|
362
|
+
proc.stderr?.on("data", (chunk: Buffer) => {
|
|
363
|
+
stderrTail = (stderrTail + chunk.toString()).slice(-4000);
|
|
364
|
+
});
|
|
365
|
+
const timer = setTimeout(() => {
|
|
366
|
+
proc.kill("SIGKILL");
|
|
367
|
+
reject(
|
|
368
|
+
new Error(`docker build timed out after ${DOCKER_BUILD_TIMEOUT_MS}ms`),
|
|
369
|
+
);
|
|
370
|
+
}, DOCKER_BUILD_TIMEOUT_MS);
|
|
371
|
+
proc.on("error", (err) => {
|
|
372
|
+
clearTimeout(timer);
|
|
373
|
+
reject(err);
|
|
374
|
+
});
|
|
375
|
+
proc.on("close", (code) => {
|
|
376
|
+
clearTimeout(timer);
|
|
377
|
+
if (code === 0) {
|
|
378
|
+
resolve();
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
// code === null means the process never started or was killed — the
|
|
382
|
+
// "error" handler (or the timeout) already rejected, so do nothing.
|
|
383
|
+
if (code === null) return;
|
|
384
|
+
reject(
|
|
385
|
+
Object.assign(new Error(`docker build exited with code ${code}`), {
|
|
386
|
+
stderr: stderrTail,
|
|
387
|
+
}),
|
|
388
|
+
);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Check if a Docker image exists locally.
|
|
395
|
+
*/
|
|
396
|
+
function imageExists(tag: string): boolean {
|
|
397
|
+
try {
|
|
398
|
+
execSync(`docker image inspect ${tag}`, {
|
|
399
|
+
encoding: "utf8",
|
|
400
|
+
timeout: 10_000,
|
|
401
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
402
|
+
});
|
|
403
|
+
return true;
|
|
404
|
+
} catch {
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Derive the Docker repository name for an agent's derived image.
|
|
411
|
+
* Multi-tenant hosts use per-agent repos (`mercury-agent-ext-{id}`) so
|
|
412
|
+
* pruning one agent's stale images never touches another agent's images.
|
|
413
|
+
*/
|
|
414
|
+
function extImageRepo(agentId: string | undefined): string {
|
|
415
|
+
return agentId ? `mercury-agent-ext-${agentId}` : "mercury-agent-ext";
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Remove all derived images for this agent except the one with `keepHash`.
|
|
420
|
+
* Images still in use by a running container are skipped silently.
|
|
421
|
+
*/
|
|
422
|
+
function pruneStaleExtImages(
|
|
423
|
+
keepHash: string,
|
|
424
|
+
repo: string,
|
|
425
|
+
log: Logger,
|
|
426
|
+
): void {
|
|
427
|
+
try {
|
|
428
|
+
const out = execFileSync(
|
|
429
|
+
"docker",
|
|
430
|
+
["images", repo, "--format", "{{.Tag}}"],
|
|
431
|
+
{ encoding: "utf8", timeout: 30_000 },
|
|
432
|
+
);
|
|
433
|
+
const tags = out
|
|
434
|
+
.split("\n")
|
|
435
|
+
.map((t) => t.trim())
|
|
436
|
+
.filter((t) => t && t !== "<none>");
|
|
437
|
+
for (const tag of tags) {
|
|
438
|
+
if (tag === keepHash) continue;
|
|
439
|
+
try {
|
|
440
|
+
execFileSync("docker", ["rmi", `${repo}:${tag}`], {
|
|
441
|
+
encoding: "utf8",
|
|
442
|
+
timeout: 30_000,
|
|
443
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
444
|
+
});
|
|
445
|
+
log.info(`Pruned stale ext image ${repo}:${tag}`);
|
|
446
|
+
} catch {
|
|
447
|
+
// Image still in use by a container — skip silently
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
} catch (err) {
|
|
451
|
+
log.warn(
|
|
452
|
+
`Could not list ext images for pruning: ${err instanceof Error ? err.message : String(err)}`,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Manages a background ext image build.
|
|
459
|
+
* Fires ensureDerivedImage as a background task and exposes the best
|
|
460
|
+
* available image at any point via currentImage(). While the build is
|
|
461
|
+
* in progress (or if it fails), currentImage() returns baseImage.
|
|
462
|
+
*/
|
|
463
|
+
export class ExtImageBuildState {
|
|
464
|
+
private resolvedImage: string;
|
|
465
|
+
private _building = true;
|
|
466
|
+
private readonly extensions: ExtensionMeta[];
|
|
467
|
+
private readonly log: Logger;
|
|
468
|
+
private readonly agentId?: string;
|
|
469
|
+
|
|
470
|
+
constructor(
|
|
471
|
+
readonly baseImage: string,
|
|
472
|
+
extensions: ExtensionMeta[],
|
|
473
|
+
log: Logger,
|
|
474
|
+
agentId?: string,
|
|
475
|
+
) {
|
|
476
|
+
this.extensions = extensions;
|
|
477
|
+
this.log = log;
|
|
478
|
+
this.agentId = agentId;
|
|
479
|
+
this.resolvedImage = baseImage;
|
|
480
|
+
ensureDerivedImage(baseImage, extensions, log, agentId).then(
|
|
481
|
+
(image) => {
|
|
482
|
+
this.resolvedImage = image;
|
|
483
|
+
this._building = false;
|
|
484
|
+
},
|
|
485
|
+
(err) => {
|
|
486
|
+
// ensureDerivedImage catches all errors internally — this is a safety net.
|
|
487
|
+
log.warn(
|
|
488
|
+
`ExtImageBuildState: unexpected rejection: ${err instanceof Error ? err.message : String(err)}`,
|
|
489
|
+
);
|
|
490
|
+
this._building = false;
|
|
491
|
+
},
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
get building(): boolean {
|
|
496
|
+
return this._building;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
currentImage(): string {
|
|
500
|
+
return this.resolvedImage;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Re-trigger a full derived image build. Resets resolvedImage to baseImage
|
|
505
|
+
* immediately (so concurrent spawns fall back to base during the rebuild),
|
|
506
|
+
* then builds and updates resolvedImage when done.
|
|
507
|
+
* Returns the resulting image tag.
|
|
508
|
+
*/
|
|
509
|
+
async rebuild(): Promise<string> {
|
|
510
|
+
this.resolvedImage = this.baseImage;
|
|
511
|
+
this._building = true;
|
|
512
|
+
try {
|
|
513
|
+
const image = await ensureDerivedImage(
|
|
514
|
+
this.baseImage,
|
|
515
|
+
this.extensions,
|
|
516
|
+
this.log,
|
|
517
|
+
this.agentId,
|
|
518
|
+
);
|
|
519
|
+
this.resolvedImage = image;
|
|
520
|
+
return image;
|
|
521
|
+
} catch (err) {
|
|
522
|
+
// ensureDerivedImage catches build errors internally and falls back to
|
|
523
|
+
// baseImage — this branch is a safety net for unexpected rejections.
|
|
524
|
+
this.log.warn(
|
|
525
|
+
`ExtImageBuildState rebuild failed unexpectedly: ${err instanceof Error ? err.message : String(err)}`,
|
|
526
|
+
);
|
|
527
|
+
return this.baseImage;
|
|
528
|
+
} finally {
|
|
529
|
+
this._building = false;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Build the derived image if needed. Returns the image name to use.
|
|
536
|
+
*
|
|
537
|
+
* - If no extensions declare CLIs, returns the base image unchanged.
|
|
538
|
+
* - If a cached image exists (same hash), returns it.
|
|
539
|
+
* - Otherwise builds a new image and returns its tag.
|
|
540
|
+
* - On build failure, falls back to the base image with a warning.
|
|
541
|
+
*/
|
|
542
|
+
export async function ensureDerivedImage(
|
|
543
|
+
baseImage: string,
|
|
544
|
+
extensions: ExtensionMeta[],
|
|
545
|
+
log: Logger,
|
|
546
|
+
agentId?: string,
|
|
547
|
+
): Promise<string> {
|
|
548
|
+
const dockerfile = generateDockerfile(baseImage, extensions);
|
|
549
|
+
if (!dockerfile) {
|
|
550
|
+
log.debug("No extension CLIs declared, using base image");
|
|
551
|
+
return baseImage;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const cliCount = extensions.reduce((n, e) => n + e.clis.length, 0);
|
|
555
|
+
const baseId = resolveBaseImageId(baseImage);
|
|
556
|
+
if (!baseId) {
|
|
557
|
+
log.warn(
|
|
558
|
+
`Could not inspect Docker image ${baseImage}; derived image cache may not invalidate when the base image changes`,
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
const hash = computeImageHash(baseImage, baseId, extensions);
|
|
562
|
+
const repo = extImageRepo(agentId);
|
|
563
|
+
const derivedTag = `${repo}:${hash}`;
|
|
564
|
+
|
|
565
|
+
// Check cache
|
|
566
|
+
if (imageExists(derivedTag)) {
|
|
567
|
+
log.info(`Using cached agent image ${derivedTag}`);
|
|
568
|
+
return derivedTag;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Build
|
|
572
|
+
log.info(
|
|
573
|
+
`Building derived agent image (${cliCount} extension CLI${cliCount > 1 ? "s" : ""})...`,
|
|
574
|
+
);
|
|
575
|
+
for (const ext of extensions) {
|
|
576
|
+
for (const cli of ext.clis) {
|
|
577
|
+
log.info(` ${ext.name}: ${cli.install}`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mercury-ext-"));
|
|
582
|
+
try {
|
|
583
|
+
fs.writeFileSync(path.join(tmpDir, "Dockerfile"), dockerfile);
|
|
584
|
+
|
|
585
|
+
const allClis = extensions.flatMap((e) => e.clis);
|
|
586
|
+
for (const cli of allClis) {
|
|
587
|
+
if (cli.bin && fs.existsSync(cli.bin)) {
|
|
588
|
+
fs.copyFileSync(cli.bin, path.join(tmpDir, `bin-${cli.name}`));
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
log.debug(`Generated Dockerfile:\n${dockerfile}`);
|
|
593
|
+
|
|
594
|
+
const startTime = Date.now();
|
|
595
|
+
await runDockerBuild(derivedTag, tmpDir);
|
|
596
|
+
const durationMs = Date.now() - startTime;
|
|
597
|
+
|
|
598
|
+
log.info(`Built derived agent image ${derivedTag}`, { durationMs });
|
|
599
|
+
pruneStaleExtImages(hash, repo, log);
|
|
600
|
+
return derivedTag;
|
|
601
|
+
} catch (err: unknown) {
|
|
602
|
+
const stderr =
|
|
603
|
+
err && typeof err === "object" && "stderr" in err
|
|
604
|
+
? String((err as { stderr: unknown }).stderr).slice(-2000)
|
|
605
|
+
: "";
|
|
606
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
607
|
+
log.error(
|
|
608
|
+
`Failed to build derived image, falling back to base image: ${msg}`,
|
|
609
|
+
);
|
|
610
|
+
if (stderr) {
|
|
611
|
+
log.error(`Docker build stderr:\n${stderr}`);
|
|
612
|
+
}
|
|
613
|
+
return baseImage;
|
|
614
|
+
} finally {
|
|
615
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
616
|
+
}
|
|
617
|
+
}
|