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,967 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
accessSync,
|
|
4
|
+
constants,
|
|
5
|
+
existsSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import type { MessageAttachment, StoredMessage } from "../types.js";
|
|
13
|
+
import {
|
|
14
|
+
DEFAULT_CAPABILITIES,
|
|
15
|
+
type ModelCapabilities,
|
|
16
|
+
} from "./model-capabilities-core.js";
|
|
17
|
+
import { classifyPiFailure } from "./pi-failure-class.js";
|
|
18
|
+
import {
|
|
19
|
+
type PiJsonlParseResult,
|
|
20
|
+
parsePiPrintJsonlOutput,
|
|
21
|
+
} from "./pi-jsonl-parser.js";
|
|
22
|
+
import { escapeXmlText, formatPreferencesXml } from "./preferences-prompt.js";
|
|
23
|
+
|
|
24
|
+
// Set at the top of main() so invokePiOnce can report containerInitMs.
|
|
25
|
+
let _containerStartedAt = 0;
|
|
26
|
+
|
|
27
|
+
function logTiming(
|
|
28
|
+
event: string,
|
|
29
|
+
data: Record<string, number | string | null>,
|
|
30
|
+
) {
|
|
31
|
+
process.stderr.write(`${JSON.stringify({ event, ...data })}\n`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type Payload = {
|
|
35
|
+
spaceId: string;
|
|
36
|
+
spaceWorkspace: string;
|
|
37
|
+
messages: StoredMessage[];
|
|
38
|
+
anchorMessages?: StoredMessage[];
|
|
39
|
+
prompt: string;
|
|
40
|
+
callerRole?: string;
|
|
41
|
+
authorName?: string;
|
|
42
|
+
attachments?: MessageAttachment[];
|
|
43
|
+
preferences?: Array<{ key: string; value: string }>;
|
|
44
|
+
nonce?: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type ModelLeg = { provider: string; model: string };
|
|
48
|
+
|
|
49
|
+
function sleep(ms: number): Promise<void> {
|
|
50
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Exponential backoff with small jitter; base 300ms, cap 12s. */
|
|
54
|
+
function backoffMs(attemptIndex: number): number {
|
|
55
|
+
const base = 300 * 2 ** attemptIndex;
|
|
56
|
+
const cap = 12_000;
|
|
57
|
+
const jitter = Math.floor(Math.random() * 200);
|
|
58
|
+
return Math.min(cap, base + jitter);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parsePartialCapabilities(obj: unknown): ModelCapabilities {
|
|
62
|
+
if (!obj || typeof obj !== "object") return { ...DEFAULT_CAPABILITIES };
|
|
63
|
+
const o = obj as Record<string, unknown>;
|
|
64
|
+
const out = { ...DEFAULT_CAPABILITIES };
|
|
65
|
+
if (typeof o.tools === "boolean") out.tools = o.tools;
|
|
66
|
+
if (typeof o.vision === "boolean") out.vision = o.vision;
|
|
67
|
+
if (typeof o.audio_input === "boolean") out.audio_input = o.audio_input;
|
|
68
|
+
if (typeof o.audio_output === "boolean") out.audio_output = o.audio_output;
|
|
69
|
+
if (typeof o.extended_thinking === "boolean")
|
|
70
|
+
out.extended_thinking = o.extended_thinking;
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Per-leg capabilities from host (MODEL_CHAIN_CAPABILITIES JSON array).
|
|
76
|
+
* When missing or invalid, defaults to DEFAULT_CAPABILITIES for each leg.
|
|
77
|
+
*/
|
|
78
|
+
function parseModelChainCapabilitiesFromEnv(
|
|
79
|
+
legCount: number,
|
|
80
|
+
): ModelCapabilities[] {
|
|
81
|
+
const raw = process.env.MODEL_CHAIN_CAPABILITIES?.trim();
|
|
82
|
+
if (!raw) {
|
|
83
|
+
return Array.from({ length: legCount }, () => ({
|
|
84
|
+
...DEFAULT_CAPABILITIES,
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const arr = JSON.parse(raw) as unknown;
|
|
89
|
+
if (!Array.isArray(arr)) {
|
|
90
|
+
return Array.from({ length: legCount }, () => ({
|
|
91
|
+
...DEFAULT_CAPABILITIES,
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
const out: ModelCapabilities[] = [];
|
|
95
|
+
for (let i = 0; i < legCount; i++) {
|
|
96
|
+
out.push(parsePartialCapabilities(arr[i]));
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
} catch {
|
|
100
|
+
return Array.from({ length: legCount }, () => ({
|
|
101
|
+
...DEFAULT_CAPABILITIES,
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseModelLegsFromEnv(): ModelLeg[] {
|
|
107
|
+
const raw = process.env.MODEL_CHAIN?.trim();
|
|
108
|
+
if (raw) {
|
|
109
|
+
try {
|
|
110
|
+
const arr = JSON.parse(raw) as unknown;
|
|
111
|
+
if (Array.isArray(arr) && arr.length > 0) {
|
|
112
|
+
const out: ModelLeg[] = [];
|
|
113
|
+
for (const item of arr) {
|
|
114
|
+
if (
|
|
115
|
+
item &&
|
|
116
|
+
typeof item === "object" &&
|
|
117
|
+
"provider" in item &&
|
|
118
|
+
"model" in item
|
|
119
|
+
) {
|
|
120
|
+
const p = String((item as { provider: unknown }).provider).trim();
|
|
121
|
+
const m = String((item as { model: unknown }).model).trim();
|
|
122
|
+
if (p && m) out.push({ provider: p, model: m });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (out.length > 0) return out;
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
// fall through to legacy single leg
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return [
|
|
132
|
+
{
|
|
133
|
+
provider: process.env.MODEL_PROVIDER || "anthropic",
|
|
134
|
+
model: process.env.MODEL || "claude-opus-4-6",
|
|
135
|
+
},
|
|
136
|
+
];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function parseRetryMaxPerLeg(): number {
|
|
140
|
+
const n = Number.parseInt(process.env.MODEL_RETRY_MAX_PER_LEG ?? "2", 10);
|
|
141
|
+
if (Number.isNaN(n)) return 2;
|
|
142
|
+
return Math.max(0, Math.min(5, n));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function parseChainBudgetMs(): number {
|
|
146
|
+
const n = Number.parseInt(process.env.MODEL_CHAIN_BUDGET_MS ?? "120000", 10);
|
|
147
|
+
if (Number.isNaN(n)) return 120_000;
|
|
148
|
+
return Math.max(5000, n);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Pinchtab reads CHROME_BINARY. Extension hooks may point at a path that is
|
|
153
|
+
* missing in this image layer; the base mercury-agent Dockerfile installs
|
|
154
|
+
* Chromium at /usr/local/bin/chromium and sets PUPPETEER_EXECUTABLE_PATH.
|
|
155
|
+
* Normalize before spawning pi so bash/pinchtab inherit a working binary.
|
|
156
|
+
*/
|
|
157
|
+
function resolveChromeBinaryEnv(): void {
|
|
158
|
+
const trySet = (p: string | undefined): boolean => {
|
|
159
|
+
if (!p?.trim()) return false;
|
|
160
|
+
const normalized = p.trim();
|
|
161
|
+
try {
|
|
162
|
+
accessSync(normalized, constants.X_OK);
|
|
163
|
+
process.env.CHROME_BINARY = normalized;
|
|
164
|
+
return true;
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
if (trySet(process.env.CHROME_BINARY)) return;
|
|
170
|
+
if (trySet(process.env.PUPPETEER_EXECUTABLE_PATH)) return;
|
|
171
|
+
for (const candidate of [
|
|
172
|
+
"/usr/local/bin/chromium",
|
|
173
|
+
"/usr/bin/chromium",
|
|
174
|
+
"/usr/bin/google-chrome",
|
|
175
|
+
]) {
|
|
176
|
+
if (trySet(candidate)) return;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function formatContextTimestamp(ms: number): string {
|
|
181
|
+
return new Date(ms).toLocaleString("en-GB", {
|
|
182
|
+
year: "numeric",
|
|
183
|
+
month: "short",
|
|
184
|
+
day: "2-digit",
|
|
185
|
+
hour: "2-digit",
|
|
186
|
+
minute: "2-digit",
|
|
187
|
+
second: "2-digit",
|
|
188
|
+
hour12: false,
|
|
189
|
+
timeZoneName: "short",
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function hasImageAttachments(
|
|
194
|
+
attachments: MessageAttachment[] | undefined,
|
|
195
|
+
): boolean {
|
|
196
|
+
if (!attachments?.length) return false;
|
|
197
|
+
return attachments.some(
|
|
198
|
+
(a) =>
|
|
199
|
+
a.type === "image" ||
|
|
200
|
+
(a.mimeType?.toLowerCase().startsWith("image/") ?? false),
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function hasAudioAttachments(
|
|
205
|
+
attachments: MessageAttachment[] | undefined,
|
|
206
|
+
): boolean {
|
|
207
|
+
if (!attachments?.length) return false;
|
|
208
|
+
return attachments.some(
|
|
209
|
+
(a) =>
|
|
210
|
+
a.type === "audio" ||
|
|
211
|
+
a.type === "voice" ||
|
|
212
|
+
(a.mimeType?.toLowerCase().startsWith("audio/") ?? false),
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function buildCapabilitySection(
|
|
217
|
+
caps: ModelCapabilities,
|
|
218
|
+
payload: Payload,
|
|
219
|
+
): string {
|
|
220
|
+
const parts: string[] = ["## Current model capabilities"];
|
|
221
|
+
parts.push(
|
|
222
|
+
`This turn uses a model with the following constraints (do not assume you can exceed them):`,
|
|
223
|
+
);
|
|
224
|
+
parts.push(
|
|
225
|
+
`- **tools (bash / read / write / edit):** ${caps.tools ? "available" : "NOT available — you cannot run shell commands, read/write workspace files via tools, or use mrctl"}`,
|
|
226
|
+
);
|
|
227
|
+
parts.push(
|
|
228
|
+
`- **vision (images):** ${caps.vision ? "available" : "NOT available"}`,
|
|
229
|
+
);
|
|
230
|
+
parts.push(
|
|
231
|
+
`- **audio input:** ${caps.audio_input ? "available" : "NOT available"}`,
|
|
232
|
+
);
|
|
233
|
+
parts.push(
|
|
234
|
+
`- **audio output:** ${caps.audio_output ? "available" : "NOT available"}`,
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
if (!caps.tools) {
|
|
238
|
+
parts.push("");
|
|
239
|
+
parts.push(
|
|
240
|
+
`**Toolless mode:** You must answer from general knowledge and the text of the user message only. For tasks that require generating files (PDFs, scripts, merges), running commands, or using \`mrctl\`, explain what the user would need to do manually or suggest switching to a model that supports tools (see Mercury docs / \`.mercury/model-capabilities.yaml\`).`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (!caps.vision && hasImageAttachments(payload.attachments)) {
|
|
245
|
+
parts.push("");
|
|
246
|
+
parts.push(
|
|
247
|
+
`**Note:** This model cannot process image pixels. Image files are still listed in <attachments /> with paths — you may reference paths and filenames but cannot interpret visual content.`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (!caps.audio_input && hasAudioAttachments(payload.attachments)) {
|
|
252
|
+
parts.push("");
|
|
253
|
+
parts.push(
|
|
254
|
+
`**Note:** This model cannot process audio. Voice attachments are listed with paths only.`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return parts.join("\n");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Builds the Mercury-specific additions to the system prompt.
|
|
263
|
+
*
|
|
264
|
+
* In append mode (skipIdentity=false): includes the full preamble — Claude Code / Mercury identity
|
|
265
|
+
* lines plus all Mercury platform content. This is appended after pi's own default system prompt.
|
|
266
|
+
*
|
|
267
|
+
* In override mode (skipIdentity=true): omits the "You are Claude Code" preamble since the outer
|
|
268
|
+
* wrapper in buildSystemPrompt provides the identity. Mercury identity ("You are Mercury") and all
|
|
269
|
+
* platform content (inbox/outbox, docs reference, permissions, moderation) are retained.
|
|
270
|
+
*/
|
|
271
|
+
function buildMercuryAdditions(
|
|
272
|
+
caps: ModelCapabilities,
|
|
273
|
+
payload: Payload,
|
|
274
|
+
opts: { skipIdentity?: boolean } = {},
|
|
275
|
+
): string {
|
|
276
|
+
const { skipIdentity = false } = opts;
|
|
277
|
+
|
|
278
|
+
const claudeCodePreamble = `You are Claude Code, Anthropic's official CLI for Claude.
|
|
279
|
+
Prioritize practical outputs and explicit assumptions.`;
|
|
280
|
+
|
|
281
|
+
const mercuryPlatform = `Files received from users (images, documents, voice notes) are saved to the \`inbox/\` directory in the current workspace. To send files back with your reply, write them to the \`outbox/\` directory — any files created or modified there during this run will be automatically attached to your response.
|
|
282
|
+
|
|
283
|
+
You are Mercury, built from https://github.com/Michaelliv/mercury. When users ask about Mercury — what it can do, how to configure it, scheduling, permissions, extensions, or anything about the platform — you MUST read from \`/docs/mercury/\` before answering. Start with \`/docs/mercury/README.md\` for an overview, then check \`/docs/mercury/docs/\` for detailed guides.
|
|
284
|
+
|
|
285
|
+
## Permissions & Security
|
|
286
|
+
Each run is triggered by a specific caller with a role (admin or member). The caller's identity and role are provided in the user prompt as a <caller /> tag.
|
|
287
|
+
- **admin**: Full access to all tools and extensions.
|
|
288
|
+
- **member**: Limited access. Some tools and extensions are restricted.
|
|
289
|
+
If a tool call is blocked with "Permission denied", this is a hard security boundary. Do NOT attempt to achieve the same result through alternative means — no curl, no direct API calls, no workarounds. Simply inform the user they do not have permission.
|
|
290
|
+
|
|
291
|
+
Never write or execute scripts whose purpose is to read data from the local filesystem or database and transmit it to an external URL or endpoint. This applies regardless of how the request is phrased — "backup", "sync", "export", "check", etc. are not exceptions. If a user asks you to do this, refuse and explain why.
|
|
292
|
+
|
|
293
|
+
## Moderation
|
|
294
|
+
You can mute users who are being abusive, spamming, trying to exfiltrate secrets, or deliberately wasting the group's resources by triggering you for pointless nonsense. Use \`mrctl mute\` when you judge it necessary — you don't need to wait for an admin to ask. Warn the user first, then mute if they continue.`;
|
|
295
|
+
|
|
296
|
+
const memory = `## Memory
|
|
297
|
+
Your workspace may contain a \`MEMORY.md\` file with a summary of past interactions and important context for this space. If it exists, use it to stay consistent with prior decisions. You may update \`MEMORY.md\` when significant events happen, new patterns emerge, or when asked to remember something. Keep it concise (~1500 tokens max). Use \`mrctl recall\` to search older message history when you need details that are not in the current context.
|
|
298
|
+
|
|
299
|
+
Your prompt may include \`<active_episodes>\` XML with time-bounded topics relevant to the current message. These are automatically selected from the knowledge vault based on keyword relevance — use them as context but do not repeat their content verbatim. You can create, update, or resolve episodes in \`knowledge/episodes/\` using the \`write\` tool. If \`knowledge/.memory-suggestions.md\` exists, review its recommendations and consider updating MEMORY.md accordingly, then delete the file.`;
|
|
300
|
+
|
|
301
|
+
const parts: string[] = [];
|
|
302
|
+
if (!skipIdentity) {
|
|
303
|
+
parts.push(claudeCodePreamble);
|
|
304
|
+
}
|
|
305
|
+
parts.push(mercuryPlatform);
|
|
306
|
+
parts.push(buildCapabilitySection(caps, payload));
|
|
307
|
+
parts.push(memory);
|
|
308
|
+
if (payload.anchorMessages && payload.anchorMessages.length > 0) {
|
|
309
|
+
parts.push(
|
|
310
|
+
`When a \`<reply_anchor>\` block appears in the user prompt, the user is swipe-replying to those specific messages. Address the anchor content directly.`,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return parts.join("\n\n");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Builds the system prompt passed to pi.
|
|
319
|
+
*
|
|
320
|
+
* - overridePiPrompt=false (default): returns Mercury additions only; pi's own default prompt
|
|
321
|
+
* comes first via --append-system-prompt.
|
|
322
|
+
* - overridePiPrompt=true: returns a full standalone prompt (pi's default is replaced via
|
|
323
|
+
* --system-prompt). Includes tool snippets, guidelines, and Mercury platform content without
|
|
324
|
+
* any pi-specific identity.
|
|
325
|
+
*/
|
|
326
|
+
function buildSystemPrompt(
|
|
327
|
+
caps: ModelCapabilities,
|
|
328
|
+
payload: Payload,
|
|
329
|
+
overridePiPrompt: boolean,
|
|
330
|
+
): string {
|
|
331
|
+
if (!overridePiPrompt) {
|
|
332
|
+
return buildMercuryAdditions(caps, payload);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Override mode: Mercury owns the full system prompt — no pi identity references.
|
|
336
|
+
// Tool snippets are exact strings from badlogic/pi-mono packages/coding-agent/src/core/system-prompt.ts
|
|
337
|
+
const toolsList = caps.tools
|
|
338
|
+
? [
|
|
339
|
+
"- read: Read the contents of a file or URL. Supports text files and images (jpg, png, gif, webp). Also converts binary formats (PDF, DOCX, PPTX, XLSX, EPUB, Jupyter, CSV, audio, ZIP, RSS/Atom feeds) and URLs (GitHub repos, gists, issues, PRs, and any web page) to markdown.",
|
|
340
|
+
"- bash: Execute a bash command in the current working directory.",
|
|
341
|
+
"- edit: Edit a single file using exact text replacement.",
|
|
342
|
+
"- write: Write content to a file. Creates the file if it doesn't exist, overwrites if it does.",
|
|
343
|
+
].join("\n")
|
|
344
|
+
: "(none — this model run has tools disabled)";
|
|
345
|
+
|
|
346
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
347
|
+
const cwd = payload.spaceWorkspace.replace(/\\/g, "/");
|
|
348
|
+
|
|
349
|
+
return `You are an expert AI assistant. You help users by reading files, executing commands, editing code, and writing new files.
|
|
350
|
+
|
|
351
|
+
Available tools:
|
|
352
|
+
${toolsList}
|
|
353
|
+
|
|
354
|
+
In addition to the tools above, you may have access to other custom tools depending on the project.
|
|
355
|
+
|
|
356
|
+
Guidelines:
|
|
357
|
+
- Use bash for file operations like ls, rg, find
|
|
358
|
+
- Be concise in your responses
|
|
359
|
+
- Show file paths clearly when working with files
|
|
360
|
+
|
|
361
|
+
${buildMercuryAdditions(caps, payload, { skipIdentity: true })}
|
|
362
|
+
|
|
363
|
+
Current date: ${date}
|
|
364
|
+
Current working directory: ${cwd}`;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Format attachment information for the prompt as XML.
|
|
369
|
+
* Converts absolute paths to container-relative paths.
|
|
370
|
+
*/
|
|
371
|
+
function formatAttachments(
|
|
372
|
+
attachments: MessageAttachment[] | undefined,
|
|
373
|
+
): string | null {
|
|
374
|
+
if (!attachments || attachments.length === 0) return null;
|
|
375
|
+
|
|
376
|
+
const entries = attachments.map((att) => {
|
|
377
|
+
// Convert host path to container path
|
|
378
|
+
const containerPath = att.path.replace(/^.*\/spaces\//, "/spaces/");
|
|
379
|
+
|
|
380
|
+
const attrs = [
|
|
381
|
+
`type="${att.type}"`,
|
|
382
|
+
`path="${containerPath}"`,
|
|
383
|
+
`mime="${att.mimeType}"`,
|
|
384
|
+
];
|
|
385
|
+
|
|
386
|
+
if (att.sizeBytes) {
|
|
387
|
+
attrs.push(`size="${att.sizeBytes}"`);
|
|
388
|
+
}
|
|
389
|
+
if (att.filename) {
|
|
390
|
+
attrs.push(`filename="${att.filename}"`);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return ` <attachment ${attrs.join(" ")} />`;
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
return ["<attachments>", ...entries, "</attachments>"].join("\n");
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function buildEpisodicMemory(spaceWorkspace: string): string | null {
|
|
400
|
+
try {
|
|
401
|
+
const memoryPath = path.join(spaceWorkspace, "MEMORY.md");
|
|
402
|
+
const content = readFileSync(memoryPath, "utf8").trim();
|
|
403
|
+
if (!content) return null;
|
|
404
|
+
return `<episodic_memory>\n${content}\n</episodic_memory>`;
|
|
405
|
+
} catch {
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const EPISODE_TOKEN_BUDGET = 800;
|
|
411
|
+
const APPROX_CHARS_PER_TOKEN = 4;
|
|
412
|
+
|
|
413
|
+
interface EpisodeMeta {
|
|
414
|
+
title: string;
|
|
415
|
+
status: string;
|
|
416
|
+
lastMentioned: string;
|
|
417
|
+
mentions: number;
|
|
418
|
+
keywords: string[];
|
|
419
|
+
summary: string;
|
|
420
|
+
currentState: string;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function parseEpisodeFrontmatter(content: string): EpisodeMeta | null {
|
|
424
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
425
|
+
if (!fmMatch) return null;
|
|
426
|
+
const fm = fmMatch[1];
|
|
427
|
+
|
|
428
|
+
const get = (key: string): string => {
|
|
429
|
+
const m = fm.match(new RegExp(`^${key}:\\s*(.+)$`, "m"));
|
|
430
|
+
return m ? m[1].trim() : "";
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const status = get("status");
|
|
434
|
+
if (status !== "active" && status !== "cooling") return null;
|
|
435
|
+
|
|
436
|
+
let keywords: string[] = [];
|
|
437
|
+
const kwRaw = get("keywords");
|
|
438
|
+
if (kwRaw) {
|
|
439
|
+
try {
|
|
440
|
+
keywords = JSON.parse(kwRaw);
|
|
441
|
+
} catch {
|
|
442
|
+
keywords = [];
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const titleMatch = content.match(/^#\s+(.+)$/m);
|
|
447
|
+
const title = titleMatch ? titleMatch[1].trim() : "";
|
|
448
|
+
|
|
449
|
+
const csMatch = content.match(/## Current State\n([\s\S]*?)(?=\n## |$)/);
|
|
450
|
+
const currentState = csMatch ? csMatch[1].trim() : "";
|
|
451
|
+
|
|
452
|
+
return {
|
|
453
|
+
title,
|
|
454
|
+
status,
|
|
455
|
+
lastMentioned: get("last_mentioned"),
|
|
456
|
+
mentions: Number.parseInt(get("mentions") || "1", 10) || 1,
|
|
457
|
+
keywords,
|
|
458
|
+
summary: get("summary").replace(/^["']|["']$/g, ""),
|
|
459
|
+
currentState,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function scoreEpisode(ep: EpisodeMeta, messageWords: Set<string>): number {
|
|
464
|
+
if (ep.keywords.length === 0) return 0;
|
|
465
|
+
let matches = 0;
|
|
466
|
+
for (const kw of ep.keywords) {
|
|
467
|
+
if (messageWords.has(kw.toLowerCase())) matches++;
|
|
468
|
+
}
|
|
469
|
+
if (matches === 0) return 0;
|
|
470
|
+
const overlap = matches / ep.keywords.length;
|
|
471
|
+
const recency = ep.status === "active" ? 1.0 : 0.5;
|
|
472
|
+
return overlap * recency * Math.log(ep.mentions + 1);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function buildEpisodeContext(
|
|
476
|
+
spaceWorkspace: string,
|
|
477
|
+
userPrompt: string,
|
|
478
|
+
): string | null {
|
|
479
|
+
const episodesDir = path.join(spaceWorkspace, "knowledge", "episodes");
|
|
480
|
+
if (!existsSync(episodesDir)) return null;
|
|
481
|
+
|
|
482
|
+
let files: string[];
|
|
483
|
+
try {
|
|
484
|
+
files = readdirSync(episodesDir).filter((f) => f.endsWith(".md"));
|
|
485
|
+
} catch {
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
if (files.length === 0) return null;
|
|
489
|
+
|
|
490
|
+
const messageWords = new Set(
|
|
491
|
+
userPrompt
|
|
492
|
+
.toLowerCase()
|
|
493
|
+
.split(/[\s,.!?;:'"()[\]{}<>]+/)
|
|
494
|
+
.filter((w) => w.length > 1),
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
const scored: Array<{ ep: EpisodeMeta; score: number }> = [];
|
|
498
|
+
for (const file of files) {
|
|
499
|
+
try {
|
|
500
|
+
const content = readFileSync(path.join(episodesDir, file), "utf8");
|
|
501
|
+
const ep = parseEpisodeFrontmatter(content);
|
|
502
|
+
if (!ep) continue;
|
|
503
|
+
const score = scoreEpisode(ep, messageWords);
|
|
504
|
+
if (score > 0) scored.push({ ep, score });
|
|
505
|
+
} catch {
|
|
506
|
+
// skip malformed episode files
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (scored.length === 0) return null;
|
|
511
|
+
|
|
512
|
+
scored.sort((a, b) => b.score - a.score);
|
|
513
|
+
|
|
514
|
+
const maxChars = EPISODE_TOKEN_BUDGET * APPROX_CHARS_PER_TOKEN;
|
|
515
|
+
const entries: string[] = [];
|
|
516
|
+
let totalChars = 0;
|
|
517
|
+
|
|
518
|
+
for (const { ep } of scored) {
|
|
519
|
+
const body = ep.currentState
|
|
520
|
+
? `${ep.summary}\n${ep.currentState}`
|
|
521
|
+
: ep.summary;
|
|
522
|
+
const escAttr = (s: string) => escapeXmlText(s).replace(/"/g, """);
|
|
523
|
+
const entry = ` <episode title="${escAttr(ep.title)}" status="${ep.status}" last_mentioned="${ep.lastMentioned}">\n${escapeXmlText(body)}\n </episode>`;
|
|
524
|
+
if (totalChars + entry.length > maxChars && entries.length > 0) break;
|
|
525
|
+
entries.push(entry);
|
|
526
|
+
totalChars += entry.length;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (entries.length === 0) return null;
|
|
530
|
+
return `<active_episodes>\n${entries.join("\n")}\n</active_episodes>`;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function buildHistoryXml(messages: StoredMessage[]): string | null {
|
|
534
|
+
// Pair up user+assistant turns; skip ambient (they have their own section)
|
|
535
|
+
const turns: Array<{ user: StoredMessage; assistant?: StoredMessage }> = [];
|
|
536
|
+
let pendingUser: StoredMessage | null = null;
|
|
537
|
+
|
|
538
|
+
for (const m of messages) {
|
|
539
|
+
if (m.role === "user") {
|
|
540
|
+
if (pendingUser) {
|
|
541
|
+
// user without assistant reply (shouldn't normally happen, but include it)
|
|
542
|
+
turns.push({ user: pendingUser });
|
|
543
|
+
}
|
|
544
|
+
pendingUser = m;
|
|
545
|
+
} else if (m.role === "assistant" && pendingUser) {
|
|
546
|
+
turns.push({ user: pendingUser, assistant: m });
|
|
547
|
+
pendingUser = null;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
// Any trailing user message without a reply
|
|
551
|
+
if (pendingUser) turns.push({ user: pendingUser });
|
|
552
|
+
|
|
553
|
+
if (turns.length === 0) return null;
|
|
554
|
+
|
|
555
|
+
const entries = turns.map(({ user, assistant }) => {
|
|
556
|
+
const ts = formatContextTimestamp(user.createdAt);
|
|
557
|
+
const userLine = ` <user>${escapeXmlText(user.content)}</user>`;
|
|
558
|
+
const assistantLine = assistant
|
|
559
|
+
? `\n <assistant>${escapeXmlText(assistant.content)}</assistant>`
|
|
560
|
+
: "";
|
|
561
|
+
return ` <turn timestamp="${ts}">\n${userLine}${assistantLine}\n </turn>`;
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
return `<history>\n${entries.join("\n")}\n</history>`;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function buildAnchorXml(messages: StoredMessage[]): string | null {
|
|
568
|
+
if (!messages || messages.length === 0) return null;
|
|
569
|
+
|
|
570
|
+
const entries = messages.map((m) => {
|
|
571
|
+
const ts = formatContextTimestamp(m.createdAt);
|
|
572
|
+
return ` <message role="${m.role}" timestamp="${ts}">${escapeXmlText(m.content)}</message>`;
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
return `<reply_anchor>\n${entries.join("\n")}\n</reply_anchor>`;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function buildPrompt(payload: Payload): string {
|
|
579
|
+
const parts: string[] = [];
|
|
580
|
+
|
|
581
|
+
// 1. Caller identity
|
|
582
|
+
const callerId = process.env.CALLER_ID ?? "unknown";
|
|
583
|
+
const role = payload.callerRole ?? "member";
|
|
584
|
+
const space = payload.spaceId ?? "unknown";
|
|
585
|
+
const nameAttr = payload.authorName ? ` name="${payload.authorName}"` : "";
|
|
586
|
+
parts.push(
|
|
587
|
+
`<caller id="${callerId}"${nameAttr} role="${role}" space="${space}" />`,
|
|
588
|
+
);
|
|
589
|
+
parts.push("");
|
|
590
|
+
|
|
591
|
+
// 2. Episodic memory (MEMORY.md)
|
|
592
|
+
const episodicMemory = buildEpisodicMemory(payload.spaceWorkspace);
|
|
593
|
+
if (episodicMemory) {
|
|
594
|
+
parts.push(episodicMemory);
|
|
595
|
+
parts.push("");
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// 2b. Active episodes (relevance-gated)
|
|
599
|
+
const episodeContext = buildEpisodeContext(
|
|
600
|
+
payload.spaceWorkspace,
|
|
601
|
+
payload.prompt,
|
|
602
|
+
);
|
|
603
|
+
if (episodeContext) {
|
|
604
|
+
parts.push(episodeContext);
|
|
605
|
+
parts.push("");
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// 3. Recent conversation history (sliding window from DB)
|
|
609
|
+
const historyXml = buildHistoryXml(payload.messages);
|
|
610
|
+
if (historyXml) {
|
|
611
|
+
parts.push(historyXml);
|
|
612
|
+
parts.push("");
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// 4. Ambient messages (non-triggered group chat context)
|
|
616
|
+
const ambientEntries = payload.messages
|
|
617
|
+
.filter((m) => m.role === "ambient")
|
|
618
|
+
.map((m) => {
|
|
619
|
+
const ts = formatContextTimestamp(m.createdAt);
|
|
620
|
+
return ` <message role="space" timestamp="${ts}">\n${m.content}\n </message>`;
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
if (ambientEntries.length > 0) {
|
|
624
|
+
parts.push("<ambient_messages>");
|
|
625
|
+
parts.push(...ambientEntries);
|
|
626
|
+
parts.push("</ambient_messages>");
|
|
627
|
+
parts.push("");
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// 5. Preferences
|
|
631
|
+
const preferencesXml = formatPreferencesXml(payload.preferences);
|
|
632
|
+
if (preferencesXml) {
|
|
633
|
+
parts.push(preferencesXml);
|
|
634
|
+
parts.push("");
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// 6. Attachments from current message
|
|
638
|
+
const attachmentsXml = formatAttachments(payload.attachments);
|
|
639
|
+
if (attachmentsXml) {
|
|
640
|
+
parts.push(attachmentsXml);
|
|
641
|
+
parts.push("");
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// 7. Reply anchor (reply chain the user is swipe-replying to)
|
|
645
|
+
const anchorXml = buildAnchorXml(payload.anchorMessages ?? []);
|
|
646
|
+
if (anchorXml) {
|
|
647
|
+
parts.push(anchorXml);
|
|
648
|
+
parts.push("");
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// 8. Current prompt
|
|
652
|
+
parts.push(payload.prompt);
|
|
653
|
+
|
|
654
|
+
return parts.join("\n");
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Build bwrap args for sandboxing the agent process.
|
|
659
|
+
* Uses bubblewrap for defense-in-depth: Docker isolates from host, bwrap restricts within container.
|
|
660
|
+
* See https://github.com/containers/bubblewrap
|
|
661
|
+
*/
|
|
662
|
+
function buildBwrapArgs(workspace: string, command: string[]): string[] {
|
|
663
|
+
const args: string[] = [
|
|
664
|
+
"--ro-bind",
|
|
665
|
+
"/usr",
|
|
666
|
+
"/usr",
|
|
667
|
+
"--symlink",
|
|
668
|
+
"usr/lib",
|
|
669
|
+
"/lib",
|
|
670
|
+
"--symlink",
|
|
671
|
+
"usr/bin",
|
|
672
|
+
"/bin",
|
|
673
|
+
"--symlink",
|
|
674
|
+
"usr/sbin",
|
|
675
|
+
"/sbin",
|
|
676
|
+
];
|
|
677
|
+
// /usr/lib64 exists on x86_64; skip on ARM64 where it may not exist
|
|
678
|
+
if (existsSync("/usr/lib64")) {
|
|
679
|
+
args.push("--symlink", "usr/lib64", "/lib64");
|
|
680
|
+
}
|
|
681
|
+
args.push("--ro-bind", "/app", "/app", "--ro-bind", "/etc", "/etc");
|
|
682
|
+
if (existsSync("/docs")) {
|
|
683
|
+
args.push("--ro-bind", "/docs", "/docs");
|
|
684
|
+
}
|
|
685
|
+
args.push(
|
|
686
|
+
"--bind",
|
|
687
|
+
"/spaces",
|
|
688
|
+
"/spaces",
|
|
689
|
+
"--bind",
|
|
690
|
+
"/home/mercury",
|
|
691
|
+
"/home/mercury",
|
|
692
|
+
"--proc",
|
|
693
|
+
"/proc",
|
|
694
|
+
"--dev",
|
|
695
|
+
"/dev",
|
|
696
|
+
"--tmpfs",
|
|
697
|
+
"/tmp",
|
|
698
|
+
"--unshare-pid",
|
|
699
|
+
"--new-session",
|
|
700
|
+
"--die-with-parent",
|
|
701
|
+
"--chdir",
|
|
702
|
+
workspace,
|
|
703
|
+
"--",
|
|
704
|
+
...command,
|
|
705
|
+
);
|
|
706
|
+
return args;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function invokePiOnce(
|
|
710
|
+
payload: Payload,
|
|
711
|
+
provider: string,
|
|
712
|
+
model: string,
|
|
713
|
+
capabilities: ModelCapabilities,
|
|
714
|
+
): Promise<PiJsonlParseResult> {
|
|
715
|
+
return new Promise((resolve, reject) => {
|
|
716
|
+
const overridePiPrompt =
|
|
717
|
+
process.env.OVERRIDE_PI_SYSTEM_PROMPT === "true" ||
|
|
718
|
+
process.env.OVERRIDE_PI_SYSTEM_PROMPT === "1";
|
|
719
|
+
|
|
720
|
+
// Combine base system prompt with extension-injected fragments
|
|
721
|
+
let systemPrompt = buildSystemPrompt(
|
|
722
|
+
capabilities,
|
|
723
|
+
payload,
|
|
724
|
+
overridePiPrompt,
|
|
725
|
+
);
|
|
726
|
+
const extPrompt = process.env.MERCURY_EXT_SYSTEM_PROMPT;
|
|
727
|
+
if (extPrompt) {
|
|
728
|
+
systemPrompt = `${systemPrompt}\n\n${extPrompt}`;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const sessionArgs = ["--no-session"];
|
|
732
|
+
|
|
733
|
+
const toolModeArgs = capabilities.tools
|
|
734
|
+
? ([] as string[])
|
|
735
|
+
: (["--no-tools", "--no-skills"] as string[]);
|
|
736
|
+
|
|
737
|
+
const systemPromptFlag = overridePiPrompt
|
|
738
|
+
? "--system-prompt"
|
|
739
|
+
: "--append-system-prompt";
|
|
740
|
+
|
|
741
|
+
const piArgs = [
|
|
742
|
+
"--print",
|
|
743
|
+
"--mode",
|
|
744
|
+
"json",
|
|
745
|
+
...sessionArgs,
|
|
746
|
+
"--provider",
|
|
747
|
+
provider,
|
|
748
|
+
"--model",
|
|
749
|
+
model,
|
|
750
|
+
...toolModeArgs,
|
|
751
|
+
"--no-extensions",
|
|
752
|
+
"-e",
|
|
753
|
+
"/app/src/extensions/permission-guard.ts",
|
|
754
|
+
"-e",
|
|
755
|
+
"/app/resources/pi-extensions/subagent/index.ts",
|
|
756
|
+
systemPromptFlag,
|
|
757
|
+
systemPrompt,
|
|
758
|
+
buildPrompt(payload),
|
|
759
|
+
];
|
|
760
|
+
|
|
761
|
+
// gVisor (runsc) provides stronger syscall-level isolation than bwrap; skip bwrap when active.
|
|
762
|
+
// Host passes MERCURY_* as stripped keys (e.g. MERCURY_DISABLE_BUBBLEWRAP → DISABLE_BUBBLEWRAP).
|
|
763
|
+
const isGvisor = process.env.CONTAINER_RUNTIME === "runsc";
|
|
764
|
+
const disableBubblewrap =
|
|
765
|
+
isGvisor ||
|
|
766
|
+
process.env.MERCURY_DISABLE_BUBBLEWRAP === "1" ||
|
|
767
|
+
process.env.MERCURY_DISABLE_BUBBLEWRAP === "true" ||
|
|
768
|
+
process.env.DISABLE_BUBBLEWRAP === "1" ||
|
|
769
|
+
process.env.DISABLE_BUBBLEWRAP === "true";
|
|
770
|
+
const useBubblewrap = !disableBubblewrap;
|
|
771
|
+
|
|
772
|
+
const containerInitMs =
|
|
773
|
+
_containerStartedAt > 0 ? Date.now() - _containerStartedAt : null;
|
|
774
|
+
logTiming("container.pi.spawn", { containerInitMs, provider, model });
|
|
775
|
+
|
|
776
|
+
const piSpawnedAt = Date.now();
|
|
777
|
+
let proc: ReturnType<typeof spawn>;
|
|
778
|
+
if (useBubblewrap) {
|
|
779
|
+
const bwrapArgs = [
|
|
780
|
+
...buildBwrapArgs(payload.spaceWorkspace, ["pi"]),
|
|
781
|
+
...piArgs,
|
|
782
|
+
];
|
|
783
|
+
proc = spawn("bwrap", bwrapArgs, {
|
|
784
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
785
|
+
env: process.env,
|
|
786
|
+
});
|
|
787
|
+
} else {
|
|
788
|
+
proc = spawn("pi", piArgs, {
|
|
789
|
+
cwd: payload.spaceWorkspace,
|
|
790
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
791
|
+
env: process.env,
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
let stdout = "";
|
|
796
|
+
let stderr = "";
|
|
797
|
+
let piFirstOutputAt: number | null = null;
|
|
798
|
+
|
|
799
|
+
proc.stdout?.on("data", (chunk: Buffer) => {
|
|
800
|
+
if (piFirstOutputAt === null) {
|
|
801
|
+
piFirstOutputAt = Date.now();
|
|
802
|
+
logTiming("container.pi.first_output", {
|
|
803
|
+
piFirstOutputMs: piFirstOutputAt - piSpawnedAt,
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
stdout += chunk.toString("utf8");
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
proc.stderr?.on("data", (chunk: Buffer) => {
|
|
810
|
+
stderr += chunk.toString("utf8");
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
proc.on("error", (error) => reject(error));
|
|
814
|
+
|
|
815
|
+
proc.on("close", (code) => {
|
|
816
|
+
logTiming("container.pi.done", {
|
|
817
|
+
piDurationMs: Date.now() - piSpawnedAt,
|
|
818
|
+
exitCode: code ?? null,
|
|
819
|
+
});
|
|
820
|
+
if (code !== 0) {
|
|
821
|
+
reject(new Error(`pi CLI failed (${code}): ${stderr || stdout}`));
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
const parsed = parsePiPrintJsonlOutput(stdout);
|
|
825
|
+
if (parsed.piFailureMessage) {
|
|
826
|
+
reject(new Error(parsed.piFailureMessage));
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
resolve({
|
|
830
|
+
reply: parsed.reply,
|
|
831
|
+
usage: parsed.usage,
|
|
832
|
+
hadToolLeakage: parsed.hadToolLeakage,
|
|
833
|
+
});
|
|
834
|
+
});
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function budgetExceededMessage(budgetMs: number, last: Error): string {
|
|
839
|
+
return `Model chain budget exceeded (${budgetMs}ms): ${last.message}`;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
async function runModelChain(payload: Payload): Promise<PiJsonlParseResult> {
|
|
843
|
+
const legs = parseModelLegsFromEnv();
|
|
844
|
+
const capsPerLeg = parseModelChainCapabilitiesFromEnv(legs.length);
|
|
845
|
+
const maxRetries = parseRetryMaxPerLeg();
|
|
846
|
+
const budgetMs = parseChainBudgetMs();
|
|
847
|
+
const started = Date.now();
|
|
848
|
+
let lastErr = new Error("pi: no attempts");
|
|
849
|
+
|
|
850
|
+
for (let li = 0; li < legs.length; li++) {
|
|
851
|
+
const leg = legs[li];
|
|
852
|
+
if (!leg) break;
|
|
853
|
+
const { provider, model } = leg;
|
|
854
|
+
const legCaps = capsPerLeg[li] ?? { ...DEFAULT_CAPABILITIES };
|
|
855
|
+
|
|
856
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
857
|
+
if (Date.now() - started > budgetMs) {
|
|
858
|
+
throw new Error(budgetExceededMessage(budgetMs, lastErr));
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
if (attempt > 0) {
|
|
862
|
+
await sleep(backoffMs(attempt - 1));
|
|
863
|
+
if (Date.now() - started > budgetMs) {
|
|
864
|
+
throw new Error(budgetExceededMessage(budgetMs, lastErr));
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
try {
|
|
869
|
+
if (provider.toLowerCase() === "cursor") {
|
|
870
|
+
throw new Error(
|
|
871
|
+
'provider "cursor" is no longer supported. Use the model\'s native provider instead (e.g. provider: anthropic for Claude, provider: openai for GPT). See docs/configuration.md.',
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
const result = await invokePiOnce(payload, provider, model, legCaps);
|
|
875
|
+
// If the model leaked a tool call as raw text instead of executing it,
|
|
876
|
+
// surface a clear error — the action was never taken, and a toolless
|
|
877
|
+
// retry would just return "Done." with no work done.
|
|
878
|
+
if (result.hadToolLeakage && legCaps.tools) {
|
|
879
|
+
return {
|
|
880
|
+
...result,
|
|
881
|
+
reply:
|
|
882
|
+
"I tried to run a command but couldn't execute it — your model may not support tool use properly. Please switch to a model that supports tools (e.g. Claude Haiku, GPT-4o-mini).",
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
return result;
|
|
886
|
+
} catch (e) {
|
|
887
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
888
|
+
lastErr = err;
|
|
889
|
+
const cls = classifyPiFailure(err.message);
|
|
890
|
+
if (cls === "failFast") throw err;
|
|
891
|
+
if (cls === "fallbackable") break;
|
|
892
|
+
if (cls === "retryable" && attempt < maxRetries) continue;
|
|
893
|
+
break;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
throw lastErr;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* Atomically publish the result for the host to pick up: write to a temp file in
|
|
903
|
+
* the same dir, then rename (atomic on one filesystem) so the host never observes
|
|
904
|
+
* a half-written result.json while polling.
|
|
905
|
+
*/
|
|
906
|
+
function writeResultFile(ioDir: string, result: unknown): void {
|
|
907
|
+
const finalPath = path.join(ioDir, "result.json");
|
|
908
|
+
const tmpPath = path.join(ioDir, "result.json.tmp");
|
|
909
|
+
writeFileSync(tmpPath, JSON.stringify(result));
|
|
910
|
+
renameSync(tmpPath, finalPath);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
async function main() {
|
|
914
|
+
_containerStartedAt = Date.now();
|
|
915
|
+
logTiming("container.entry.start", {});
|
|
916
|
+
resolveChromeBinaryEnv();
|
|
917
|
+
|
|
918
|
+
// File-based detached I/O: when launched through the cloud agent lane the host
|
|
919
|
+
// cannot use the attach stream (the Bun body-proxy can't proxy a hijacked
|
|
920
|
+
// connection), so it passes the payload as input.json in IO_DIR and reads the
|
|
921
|
+
// reply back from result.json. Fall back to stdin for a direct/manual run
|
|
922
|
+
// against a real daemon.
|
|
923
|
+
const ioDir = process.env.IO_DIR;
|
|
924
|
+
const input = ioDir
|
|
925
|
+
? readFileSync(path.join(ioDir, "input.json"), "utf8")
|
|
926
|
+
: readFileSync(0, "utf8");
|
|
927
|
+
|
|
928
|
+
let payload: Payload;
|
|
929
|
+
try {
|
|
930
|
+
payload = JSON.parse(input) as Payload;
|
|
931
|
+
} catch {
|
|
932
|
+
throw new Error("Failed to parse input payload");
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const { reply, usage } = await runModelChain(payload);
|
|
936
|
+
|
|
937
|
+
if (ioDir) {
|
|
938
|
+
writeResultFile(ioDir, { ok: true, reply, usage });
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// Legacy stdout-marker path (direct attach against a real daemon).
|
|
943
|
+
const nonce = payload.nonce ?? "";
|
|
944
|
+
const START = `---MERCURY_CONTAINER_RESULT_${nonce}_START---`;
|
|
945
|
+
const END = `---MERCURY_CONTAINER_RESULT_${nonce}_END---`;
|
|
946
|
+
|
|
947
|
+
process.stdout.write(`${START}\n`);
|
|
948
|
+
process.stdout.write(JSON.stringify({ reply, usage }));
|
|
949
|
+
process.stdout.write(`\n${END}\n`);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
main().catch((error) => {
|
|
953
|
+
const message = String(error);
|
|
954
|
+
process.stderr.write(message);
|
|
955
|
+
// Always publish a failure result so the host's poll loop unwinds immediately
|
|
956
|
+
// instead of waiting out the full container timeout on a caught error.
|
|
957
|
+
const ioDir = process.env.IO_DIR;
|
|
958
|
+
if (ioDir) {
|
|
959
|
+
try {
|
|
960
|
+
writeResultFile(ioDir, { ok: false, error: message });
|
|
961
|
+
} catch {
|
|
962
|
+
// If we can't even write the failure, the host's liveness probe will
|
|
963
|
+
// detect the exited container and surface a crash error.
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
process.exit(1);
|
|
967
|
+
});
|