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,1140 @@
|
|
|
1
|
+
import { ContainerError } from "../agent/container-error.js";
|
|
2
|
+
import { AgentContainerRunner } from "../agent/container-runner.js";
|
|
3
|
+
import {
|
|
4
|
+
classifyUserError,
|
|
5
|
+
friendlyErrorMessage,
|
|
6
|
+
} from "../agent/user-error-messages.js";
|
|
7
|
+
import { type AppConfig, resolveProjectPath } from "../config.js";
|
|
8
|
+
import { createMercuryExtensionContext } from "../extensions/context.js";
|
|
9
|
+
import { HookDispatcher } from "../extensions/hooks.js";
|
|
10
|
+
import type { ExtensionRegistry } from "../extensions/loader.js";
|
|
11
|
+
import type { MercuryExtensionContext } from "../extensions/types.js";
|
|
12
|
+
import { logger } from "../logger.js";
|
|
13
|
+
import { Db } from "../storage/db.js";
|
|
14
|
+
import {
|
|
15
|
+
ensurePiResourceDir,
|
|
16
|
+
ensureSpaceWorkspace,
|
|
17
|
+
} from "../storage/memory.js";
|
|
18
|
+
import type {
|
|
19
|
+
ContainerResult,
|
|
20
|
+
IngressMessage,
|
|
21
|
+
MessageAttachment,
|
|
22
|
+
MessageRunMeta,
|
|
23
|
+
MessageSender,
|
|
24
|
+
TokenUsage,
|
|
25
|
+
} from "../types.js";
|
|
26
|
+
import { formatCategoryHelp, formatHelp } from "./commands.js";
|
|
27
|
+
import { hasPermission, resolveRole } from "./permissions.js";
|
|
28
|
+
import { RateLimiter } from "./rate-limiter.js";
|
|
29
|
+
import { type RouteResult, routeInput } from "./router.js";
|
|
30
|
+
import { SpaceQueue } from "./space-queue.js";
|
|
31
|
+
import { TaskScheduler } from "./task-scheduler.js";
|
|
32
|
+
|
|
33
|
+
export type InputSource = "cli" | "scheduler" | "chat-sdk";
|
|
34
|
+
|
|
35
|
+
export type ShutdownHook = () => Promise<void> | void;
|
|
36
|
+
|
|
37
|
+
function agentMetaFromUsage(
|
|
38
|
+
usage: TokenUsage | undefined,
|
|
39
|
+
): MessageRunMeta["agent"] {
|
|
40
|
+
if (!usage) return undefined;
|
|
41
|
+
const a: NonNullable<MessageRunMeta["agent"]> = {};
|
|
42
|
+
if (usage.inputTokens != null) a.inputTokens = usage.inputTokens;
|
|
43
|
+
if (usage.outputTokens != null) a.outputTokens = usage.outputTokens;
|
|
44
|
+
if (usage.totalTokens != null) a.totalTokens = usage.totalTokens;
|
|
45
|
+
if (usage.cacheReadTokens != null) a.cacheReadTokens = usage.cacheReadTokens;
|
|
46
|
+
if (usage.cacheWriteTokens != null)
|
|
47
|
+
a.cacheWriteTokens = usage.cacheWriteTokens;
|
|
48
|
+
if (usage.cost != null) a.cost = usage.cost;
|
|
49
|
+
if (usage.model != null) a.model = usage.model;
|
|
50
|
+
if (usage.provider != null) a.provider = usage.provider;
|
|
51
|
+
if (Object.keys(a).length === 0) return undefined;
|
|
52
|
+
return a;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function userTurnRunMeta(agentUsage?: TokenUsage): MessageRunMeta {
|
|
56
|
+
const meta: MessageRunMeta = {};
|
|
57
|
+
const agent = agentMetaFromUsage(agentUsage);
|
|
58
|
+
if (agent) meta.agent = agent;
|
|
59
|
+
return meta;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class MercuryCoreRuntime {
|
|
63
|
+
readonly db: Db;
|
|
64
|
+
readonly scheduler: TaskScheduler;
|
|
65
|
+
readonly queue: SpaceQueue;
|
|
66
|
+
readonly containerRunner: AgentContainerRunner;
|
|
67
|
+
readonly rateLimiter: RateLimiter;
|
|
68
|
+
hooks: HookDispatcher | null = null;
|
|
69
|
+
private extensionCtx: MercuryExtensionContext | null = null;
|
|
70
|
+
private extensionRegistry: ExtensionRegistry | null = null;
|
|
71
|
+
private readonly shutdownHooks: ShutdownHook[] = [];
|
|
72
|
+
private shuttingDown = false;
|
|
73
|
+
private signalHandlersInstalled = false;
|
|
74
|
+
|
|
75
|
+
constructor(readonly config: AppConfig) {
|
|
76
|
+
this.db = new Db(resolveProjectPath(config.dbPath));
|
|
77
|
+
this.queue = new SpaceQueue(config.maxConcurrency);
|
|
78
|
+
this.scheduler = new TaskScheduler(this.db);
|
|
79
|
+
this.containerRunner = new AgentContainerRunner(config);
|
|
80
|
+
this.rateLimiter = new RateLimiter(
|
|
81
|
+
config.rateLimitPerUser,
|
|
82
|
+
config.rateLimitWindowMs,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// Scaffold global (pi agent dir) and "main" (default space)
|
|
86
|
+
ensurePiResourceDir(resolveProjectPath(config.globalDir));
|
|
87
|
+
ensureSpaceWorkspace(resolveProjectPath(config.spacesDir), "main");
|
|
88
|
+
this.db.ensureSpace("main");
|
|
89
|
+
|
|
90
|
+
// Seed context defaults for main space from AppConfig (idempotent per key).
|
|
91
|
+
// YAML/env values feed the seeded defaults; existing rows are never overwritten.
|
|
92
|
+
// Literal fallbacks mirror the Zod defaults in src/config.ts — tests that
|
|
93
|
+
// build AppConfig via `as AppConfig` cast (bypassing Zod) would otherwise
|
|
94
|
+
// pass undefined here and fail the NOT NULL constraint on space_config.value.
|
|
95
|
+
if (this.db.getSpaceConfig("main", "context.mode") === null) {
|
|
96
|
+
this.db.setSpaceConfig(
|
|
97
|
+
"main",
|
|
98
|
+
"context.mode",
|
|
99
|
+
config.contextMode ?? "context",
|
|
100
|
+
"system",
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (this.db.getSpaceConfig("main", "context.window_size") === null) {
|
|
104
|
+
this.db.setSpaceConfig(
|
|
105
|
+
"main",
|
|
106
|
+
"context.window_size",
|
|
107
|
+
String(config.contextWindowSize ?? 10),
|
|
108
|
+
"system",
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (this.db.getSpaceConfig("main", "context.reply_chain_depth") === null) {
|
|
112
|
+
this.db.setSpaceConfig(
|
|
113
|
+
"main",
|
|
114
|
+
"context.reply_chain_depth",
|
|
115
|
+
String(config.contextReplyChainDepth ?? 10),
|
|
116
|
+
"system",
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Initialize the runtime — must be called before accepting work.
|
|
123
|
+
* Cleans up any orphaned containers from previous runs.
|
|
124
|
+
*/
|
|
125
|
+
async initialize(): Promise<void> {
|
|
126
|
+
await this.containerRunner.cleanupOrphans();
|
|
127
|
+
this.rateLimiter.startCleanup();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Wire extension system into the runtime.
|
|
132
|
+
* Must be called after extensions are loaded and before accepting messages.
|
|
133
|
+
*/
|
|
134
|
+
initExtensions(registry: ExtensionRegistry): void {
|
|
135
|
+
this.hooks = new HookDispatcher(registry, logger);
|
|
136
|
+
this.extensionRegistry = registry;
|
|
137
|
+
this.extensionCtx = createMercuryExtensionContext({
|
|
138
|
+
db: this.db,
|
|
139
|
+
config: this.config,
|
|
140
|
+
log: logger,
|
|
141
|
+
});
|
|
142
|
+
this.warnUncoveredSensitiveSpaces();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Returns a comma-joined display name of active sensitive connections,
|
|
147
|
+
* or null if none are active. Active means: credential env var set (for
|
|
148
|
+
* env-var-based connections) or any extension state stored (for OAuth connections).
|
|
149
|
+
*/
|
|
150
|
+
private getActiveSensitiveConnectionName(): string | null {
|
|
151
|
+
if (!this.extensionRegistry) return null;
|
|
152
|
+
const names: string[] = [];
|
|
153
|
+
for (const ext of this.extensionRegistry.list()) {
|
|
154
|
+
if (!ext.connection?.sensitive) continue;
|
|
155
|
+
const conn = ext.connection;
|
|
156
|
+
let active = false;
|
|
157
|
+
if (conn.credentialEnvVar) {
|
|
158
|
+
active = !!process.env[conn.credentialEnvVar];
|
|
159
|
+
} else if (conn.statusCheck) {
|
|
160
|
+
// Proxy: if any state has been stored, the OAuth flow was completed.
|
|
161
|
+
active = this.db.hasAnyExtensionState(ext.name);
|
|
162
|
+
}
|
|
163
|
+
if (active) names.push(conn.displayName);
|
|
164
|
+
}
|
|
165
|
+
return names.length > 0 ? names.join(", ") : null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Sensitive connection guard — fires before the container for assistant turns.
|
|
170
|
+
* Returns { action: "proceed" } (proceed, optionally replaying a stored prompt)
|
|
171
|
+
* or { action: "block", reason } (send reason as reply, skip container).
|
|
172
|
+
*/
|
|
173
|
+
private async checkSensitiveConnectionGuard(
|
|
174
|
+
spaceId: string,
|
|
175
|
+
prompt: string,
|
|
176
|
+
): Promise<
|
|
177
|
+
| { action: "proceed"; replayPrompt?: string }
|
|
178
|
+
| { action: "block"; reason: string }
|
|
179
|
+
> {
|
|
180
|
+
// No registry = no extensions loaded = no sensitive connections possible
|
|
181
|
+
if (!this.extensionRegistry) return { action: "proceed" };
|
|
182
|
+
|
|
183
|
+
if (!this.db.hasGroupLinkedConversation(spaceId)) {
|
|
184
|
+
return { action: "proceed" };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const sensitiveName = this.getActiveSensitiveConnectionName();
|
|
188
|
+
if (!sensitiveName) return { action: "proceed" };
|
|
189
|
+
|
|
190
|
+
const allowed = this.db.getSpaceConfig(
|
|
191
|
+
spaceId,
|
|
192
|
+
"security.sensitive_connections_allowed",
|
|
193
|
+
);
|
|
194
|
+
if (allowed !== "true") {
|
|
195
|
+
return {
|
|
196
|
+
action: "block",
|
|
197
|
+
reason: `⛔ Sensitive integrations (${sensitiveName}) are disabled for this group space. A space admin must enable them first with: mrctl config set security.sensitive_connections_allowed true`,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const pendingAt = this.db.getSpaceConfig(
|
|
202
|
+
spaceId,
|
|
203
|
+
"security.pending_sensitive_at",
|
|
204
|
+
);
|
|
205
|
+
if (pendingAt) {
|
|
206
|
+
const ageMs = Date.now() - new Date(pendingAt).getTime();
|
|
207
|
+
const expired = Number.isNaN(ageMs) || ageMs > 5 * 60 * 1000;
|
|
208
|
+
const text = prompt.trim().toLowerCase();
|
|
209
|
+
|
|
210
|
+
if (expired || text === "no") {
|
|
211
|
+
this.db.deleteSpaceConfig(spaceId, "security.pending_sensitive_prompt");
|
|
212
|
+
this.db.deleteSpaceConfig(spaceId, "security.pending_sensitive_at");
|
|
213
|
+
if (expired) {
|
|
214
|
+
// Treat next message as fresh — fall through to new warning below
|
|
215
|
+
} else {
|
|
216
|
+
return { action: "block", reason: "Cancelled." };
|
|
217
|
+
}
|
|
218
|
+
} else if (text === "yes") {
|
|
219
|
+
const storedPrompt = this.db.getSpaceConfig(
|
|
220
|
+
spaceId,
|
|
221
|
+
"security.pending_sensitive_prompt",
|
|
222
|
+
);
|
|
223
|
+
this.db.deleteSpaceConfig(spaceId, "security.pending_sensitive_prompt");
|
|
224
|
+
this.db.deleteSpaceConfig(spaceId, "security.pending_sensitive_at");
|
|
225
|
+
return { action: "proceed", replayPrompt: storedPrompt ?? undefined };
|
|
226
|
+
} else {
|
|
227
|
+
// New message arrived mid-confirmation — replace pending with new prompt
|
|
228
|
+
// fall through to emit new warning below
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Emit warning and store the original prompt for replay on confirmation
|
|
233
|
+
this.db.setSpaceConfig(
|
|
234
|
+
spaceId,
|
|
235
|
+
"security.pending_sensitive_prompt",
|
|
236
|
+
prompt,
|
|
237
|
+
"system",
|
|
238
|
+
);
|
|
239
|
+
this.db.setSpaceConfig(
|
|
240
|
+
spaceId,
|
|
241
|
+
"security.pending_sensitive_at",
|
|
242
|
+
new Date().toISOString(),
|
|
243
|
+
"system",
|
|
244
|
+
);
|
|
245
|
+
return {
|
|
246
|
+
action: "block",
|
|
247
|
+
reason: `⚠️ This response may contain data from ${sensitiveName} and will be visible to all members of this group. Reply *yes* to proceed or *no* to cancel.`,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Log a startup warning for group spaces with active sensitive connections but no admin enable. */
|
|
252
|
+
private warnUncoveredSensitiveSpaces(): void {
|
|
253
|
+
const sensitiveName = this.getActiveSensitiveConnectionName();
|
|
254
|
+
if (!sensitiveName) return;
|
|
255
|
+
|
|
256
|
+
const affected: string[] = [];
|
|
257
|
+
for (const space of this.db.listSpaces()) {
|
|
258
|
+
if (!this.db.hasGroupLinkedConversation(space.id)) continue;
|
|
259
|
+
const allowed = this.db.getSpaceConfig(
|
|
260
|
+
space.id,
|
|
261
|
+
"security.sensitive_connections_allowed",
|
|
262
|
+
);
|
|
263
|
+
if (allowed !== "true") affected.push(space.id);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (affected.length > 0) {
|
|
267
|
+
logger.warn(
|
|
268
|
+
"Sensitive connections active but not admin-enabled for group spaces — messages will be blocked until enabled",
|
|
269
|
+
{ spaces: affected, connections: sensitiveName },
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
startScheduler(sender?: MessageSender): void {
|
|
275
|
+
this.scheduler.start(async (task) => {
|
|
276
|
+
const result = await this.executePrompt(
|
|
277
|
+
task.spaceId,
|
|
278
|
+
task.prompt,
|
|
279
|
+
"scheduler",
|
|
280
|
+
task.createdBy,
|
|
281
|
+
);
|
|
282
|
+
if (!task.silent && sender) {
|
|
283
|
+
await sender.send(task.spaceId, result.reply, result.files);
|
|
284
|
+
}
|
|
285
|
+
if (!task.silent && result.reply) {
|
|
286
|
+
this.deliverTaskOutput(task.spaceId, result.reply);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private deliverTaskOutput(spaceId: string, text: string): void {
|
|
292
|
+
const consoleUrl = process.env.MERCURY_CONSOLE_URL;
|
|
293
|
+
const secret = process.env.MERCURY_CONSOLE_INTERNAL_SECRET;
|
|
294
|
+
if (!consoleUrl || !secret) return;
|
|
295
|
+
const url = `${consoleUrl}/api/internal/whatsapp/deliver`;
|
|
296
|
+
|
|
297
|
+
fetch(url, {
|
|
298
|
+
method: "POST",
|
|
299
|
+
headers: {
|
|
300
|
+
"Content-Type": "application/json",
|
|
301
|
+
Authorization: `Bearer ${secret}`,
|
|
302
|
+
},
|
|
303
|
+
body: JSON.stringify({
|
|
304
|
+
agentId: process.env.MERCURY_AGENT_ID ?? "unknown",
|
|
305
|
+
spaceId,
|
|
306
|
+
text,
|
|
307
|
+
}),
|
|
308
|
+
}).catch((err) => {
|
|
309
|
+
const cause = (err as Error & { cause?: Error }).cause;
|
|
310
|
+
logger.warn("Task output delivery failed", {
|
|
311
|
+
error: err instanceof Error ? err.message : String(err),
|
|
312
|
+
cause: cause?.message,
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
stopScheduler(): void {
|
|
318
|
+
this.scheduler.stop();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async handleRawInput(
|
|
322
|
+
message: IngressMessage,
|
|
323
|
+
source: Exclude<InputSource, "scheduler">,
|
|
324
|
+
): Promise<RouteResult & { result?: ContainerResult }> {
|
|
325
|
+
const route = routeInput({
|
|
326
|
+
text: message.text,
|
|
327
|
+
spaceId: message.spaceId,
|
|
328
|
+
callerId: message.callerId,
|
|
329
|
+
isDM: message.isDM,
|
|
330
|
+
isReplyToBot: message.isReplyToBot,
|
|
331
|
+
db: this.db,
|
|
332
|
+
config: this.config,
|
|
333
|
+
attachments: message.attachments,
|
|
334
|
+
hadIncomingAttachments: message.hadIncomingAttachments,
|
|
335
|
+
authorName: message.authorName,
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
if (route.type === "command") {
|
|
339
|
+
const reply = await this.executeCommand(
|
|
340
|
+
message.spaceId,
|
|
341
|
+
route.command,
|
|
342
|
+
route.callerId,
|
|
343
|
+
route.verb,
|
|
344
|
+
route.arg,
|
|
345
|
+
);
|
|
346
|
+
return { ...route, result: { reply, files: [] } };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Check mute — silently drop messages from muted users
|
|
350
|
+
if (
|
|
351
|
+
route.type === "assistant" &&
|
|
352
|
+
this.db.isMuted(message.spaceId, message.callerId)
|
|
353
|
+
) {
|
|
354
|
+
return { type: "ignore" };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Check rate limit for assistant requests (not commands, not ignored messages)
|
|
358
|
+
if (route.type === "assistant") {
|
|
359
|
+
// Check per-group override first
|
|
360
|
+
const groupLimit = this.db.getSpaceConfig(message.spaceId, "rate_limit");
|
|
361
|
+
const effectiveLimit = groupLimit
|
|
362
|
+
? Number.parseInt(groupLimit, 10)
|
|
363
|
+
: this.config.rateLimitPerUser;
|
|
364
|
+
|
|
365
|
+
if (
|
|
366
|
+
effectiveLimit > 0 &&
|
|
367
|
+
!this.checkRateLimit(message.spaceId, message.callerId, effectiveLimit)
|
|
368
|
+
) {
|
|
369
|
+
return {
|
|
370
|
+
type: "denied",
|
|
371
|
+
reason: "Rate limit exceeded. Try again shortly.",
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (route.type !== "assistant") {
|
|
377
|
+
// Store ambient messages in group chats (non-triggered, non-DM)
|
|
378
|
+
// Default: enabled. Set ambient.enabled=false for tag-only mode.
|
|
379
|
+
const ambientEnabled =
|
|
380
|
+
this.db.getSpaceConfig(message.spaceId, "ambient.enabled") !== "false";
|
|
381
|
+
if (
|
|
382
|
+
route.type === "ignore" &&
|
|
383
|
+
source === "chat-sdk" &&
|
|
384
|
+
!message.isDM &&
|
|
385
|
+
ambientEnabled
|
|
386
|
+
) {
|
|
387
|
+
const ambientText = message.authorName
|
|
388
|
+
? `${message.authorName}: ${message.text.trim()}`
|
|
389
|
+
: message.text.trim();
|
|
390
|
+
|
|
391
|
+
if (ambientText) {
|
|
392
|
+
this.db.ensureSpace(message.spaceId);
|
|
393
|
+
this.db.addMessage(message.spaceId, "ambient", ambientText);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return route;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const noPromptText = !message.text.trim();
|
|
401
|
+
const noSavedFiles = (message.attachments?.length ?? 0) === 0;
|
|
402
|
+
if (
|
|
403
|
+
noPromptText &&
|
|
404
|
+
noSavedFiles &&
|
|
405
|
+
(message.hadIncomingAttachments ?? false)
|
|
406
|
+
) {
|
|
407
|
+
return {
|
|
408
|
+
type: "denied",
|
|
409
|
+
reason:
|
|
410
|
+
"Could not use your attachment (media disabled, over the size limit, or download failed). Check MERCURY_MEDIA_ENABLED and logs.",
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const guardResult = await this.checkSensitiveConnectionGuard(
|
|
415
|
+
message.spaceId,
|
|
416
|
+
route.prompt,
|
|
417
|
+
);
|
|
418
|
+
if (guardResult.action === "block") {
|
|
419
|
+
return { type: "denied", reason: guardResult.reason };
|
|
420
|
+
}
|
|
421
|
+
const effectivePrompt = guardResult.replayPrompt ?? route.prompt;
|
|
422
|
+
|
|
423
|
+
try {
|
|
424
|
+
const result = await this.executePrompt(
|
|
425
|
+
message.spaceId,
|
|
426
|
+
effectivePrompt,
|
|
427
|
+
source,
|
|
428
|
+
message.callerId,
|
|
429
|
+
message.attachments,
|
|
430
|
+
message.authorName,
|
|
431
|
+
{
|
|
432
|
+
platform: message.platform,
|
|
433
|
+
conversationExternalId: message.conversationExternalId,
|
|
434
|
+
replyToPlatformMessageId: message.replyToPlatformMessageId,
|
|
435
|
+
platformMessageId: message.platformMessageId,
|
|
436
|
+
},
|
|
437
|
+
{ isReplyToBot: route.isReplyToBot, isDM: route.isDM },
|
|
438
|
+
);
|
|
439
|
+
return { ...route, result };
|
|
440
|
+
} catch (error) {
|
|
441
|
+
if (error instanceof ContainerError) {
|
|
442
|
+
switch (error.reason) {
|
|
443
|
+
case "aborted":
|
|
444
|
+
return { type: "denied", reason: "Stopped current run." };
|
|
445
|
+
case "timeout":
|
|
446
|
+
return { type: "denied", reason: "Container timed out." };
|
|
447
|
+
case "oom":
|
|
448
|
+
return {
|
|
449
|
+
type: "denied",
|
|
450
|
+
reason: "Container was killed (possibly out of memory).",
|
|
451
|
+
};
|
|
452
|
+
case "error": {
|
|
453
|
+
logger.error(
|
|
454
|
+
"Container error",
|
|
455
|
+
error instanceof Error ? error : undefined,
|
|
456
|
+
);
|
|
457
|
+
const category = classifyUserError(error.message);
|
|
458
|
+
const reason = friendlyErrorMessage(
|
|
459
|
+
category,
|
|
460
|
+
this.config.apiKeyMode,
|
|
461
|
+
this.config.consoleUrl,
|
|
462
|
+
);
|
|
463
|
+
return { type: "denied", reason };
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
throw error;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Check if a request is allowed under rate limiting.
|
|
473
|
+
* Uses per-group override if set, otherwise uses the default limit.
|
|
474
|
+
*/
|
|
475
|
+
private checkRateLimit(
|
|
476
|
+
spaceId: string,
|
|
477
|
+
userId: string,
|
|
478
|
+
effectiveLimit: number,
|
|
479
|
+
): boolean {
|
|
480
|
+
return this.rateLimiter.isAllowed(spaceId, userId, effectiveLimit);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
private async executeCommand(
|
|
484
|
+
spaceId: string,
|
|
485
|
+
command: string,
|
|
486
|
+
callerId: string,
|
|
487
|
+
verb?: string,
|
|
488
|
+
arg?: string,
|
|
489
|
+
): Promise<string> {
|
|
490
|
+
switch (command) {
|
|
491
|
+
case "stop": {
|
|
492
|
+
const stopped = this.containerRunner.abort(spaceId);
|
|
493
|
+
const dropped = this.queue.cancelPending(spaceId);
|
|
494
|
+
if (stopped)
|
|
495
|
+
return `Stopped.${dropped > 0 ? ` Dropped ${dropped} queued request(s).` : ""}`;
|
|
496
|
+
if (dropped > 0) return `Dropped ${dropped} queued request(s).`;
|
|
497
|
+
return "No active run.";
|
|
498
|
+
}
|
|
499
|
+
case "compact": {
|
|
500
|
+
this.db.setSessionBoundaryToLatest(spaceId);
|
|
501
|
+
return "Compacted.";
|
|
502
|
+
}
|
|
503
|
+
case "clear": {
|
|
504
|
+
this.db.setClearBoundary(spaceId);
|
|
505
|
+
return "Cleared.";
|
|
506
|
+
}
|
|
507
|
+
case "help": {
|
|
508
|
+
if (verb) {
|
|
509
|
+
return (
|
|
510
|
+
formatCategoryHelp(verb) ?? `No help available for '/${verb}'.`
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
return formatHelp();
|
|
514
|
+
}
|
|
515
|
+
case "model":
|
|
516
|
+
return this.executeModelsCommand(spaceId, callerId, verb, arg);
|
|
517
|
+
default:
|
|
518
|
+
return `Unknown command: ${command}`;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private executeModelsCommand(
|
|
523
|
+
spaceId: string,
|
|
524
|
+
callerId: string,
|
|
525
|
+
verb?: string,
|
|
526
|
+
arg?: string,
|
|
527
|
+
): string {
|
|
528
|
+
const chain = this.config.resolvedModelChain;
|
|
529
|
+
|
|
530
|
+
if (!verb) {
|
|
531
|
+
return formatCategoryHelp("model") ?? "/model — model management";
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
switch (verb) {
|
|
535
|
+
case "list": {
|
|
536
|
+
if (chain.length === 0) return "No models configured.";
|
|
537
|
+
const activeRaw = this.db.getSpaceConfig(spaceId, "model.active");
|
|
538
|
+
const activeFound = activeRaw
|
|
539
|
+
? chain.some((l) => `${l.provider}:${l.model}` === activeRaw)
|
|
540
|
+
: false;
|
|
541
|
+
const lines = ["Configured models:"];
|
|
542
|
+
for (let i = 0; i < chain.length; i++) {
|
|
543
|
+
const leg = chain[i];
|
|
544
|
+
const isActive = activeFound
|
|
545
|
+
? activeRaw === `${leg.provider}:${leg.model}`
|
|
546
|
+
: i === 0;
|
|
547
|
+
lines.push(
|
|
548
|
+
` [${i + 1}] ${leg.provider} / ${leg.model}${isActive ? " ← active" : ""}`,
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
return lines.join("\n");
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
case "active": {
|
|
555
|
+
const activeRaw = this.db.getSpaceConfig(spaceId, "model.active");
|
|
556
|
+
const leg =
|
|
557
|
+
(activeRaw
|
|
558
|
+
? chain.find((l) => `${l.provider}:${l.model}` === activeRaw)
|
|
559
|
+
: undefined) ?? chain[0];
|
|
560
|
+
if (!leg) return "No models configured.";
|
|
561
|
+
return `Active model: ${leg.provider} / ${leg.model}`;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
case "switch": {
|
|
565
|
+
if (!arg)
|
|
566
|
+
return "Usage: /model switch <N|MODEL_ID>. Use /model list to see options.";
|
|
567
|
+
if (chain.length === 0) return "No models configured.";
|
|
568
|
+
|
|
569
|
+
let leg: (typeof chain)[number] | undefined;
|
|
570
|
+
const num = Number.parseInt(arg, 10);
|
|
571
|
+
if (!Number.isNaN(num) && num >= 1 && num <= chain.length) {
|
|
572
|
+
leg = chain[num - 1];
|
|
573
|
+
} else {
|
|
574
|
+
leg = chain.find(
|
|
575
|
+
(l) => l.model === arg || `${l.provider}:${l.model}` === arg,
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (!leg)
|
|
580
|
+
return "Model not found. Use /model list to see your options.";
|
|
581
|
+
|
|
582
|
+
this.db.setSpaceConfig(
|
|
583
|
+
spaceId,
|
|
584
|
+
"model.active",
|
|
585
|
+
`${leg.provider}:${leg.model}`,
|
|
586
|
+
callerId,
|
|
587
|
+
);
|
|
588
|
+
return `Switched to ${leg.provider} / ${leg.model}.`;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
default:
|
|
592
|
+
return `/model: unknown verb '${verb}'. Use /model for help.`;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
onShutdown(hook: ShutdownHook): void {
|
|
597
|
+
this.shutdownHooks.push(hook);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
get isShuttingDown(): boolean {
|
|
601
|
+
return this.shuttingDown;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
installSignalHandlers(): void {
|
|
605
|
+
if (this.signalHandlersInstalled) return;
|
|
606
|
+
this.signalHandlersInstalled = true;
|
|
607
|
+
|
|
608
|
+
let forceCount = 0;
|
|
609
|
+
|
|
610
|
+
const handler = (signal: string) => {
|
|
611
|
+
if (this.shuttingDown) {
|
|
612
|
+
forceCount++;
|
|
613
|
+
if (forceCount >= 1) {
|
|
614
|
+
logger.warn("Second signal received, forcing exit");
|
|
615
|
+
process.exit(1);
|
|
616
|
+
}
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
logger.info("Received signal, starting graceful shutdown", { signal });
|
|
620
|
+
void this.shutdown().then(
|
|
621
|
+
() => process.exit(0),
|
|
622
|
+
(err) => {
|
|
623
|
+
logger.error(
|
|
624
|
+
"Shutdown failed",
|
|
625
|
+
err instanceof Error ? err : undefined,
|
|
626
|
+
);
|
|
627
|
+
process.exit(1);
|
|
628
|
+
},
|
|
629
|
+
);
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
process.on("SIGTERM", () => handler("SIGTERM"));
|
|
633
|
+
process.on("SIGINT", () => handler("SIGINT"));
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
async shutdown(timeoutMs = 10_000): Promise<void> {
|
|
637
|
+
if (this.shuttingDown) return;
|
|
638
|
+
this.shuttingDown = true;
|
|
639
|
+
|
|
640
|
+
const forceTimer = setTimeout(() => {
|
|
641
|
+
logger.error("Shutdown timed out, forcing exit");
|
|
642
|
+
process.exit(1);
|
|
643
|
+
}, timeoutMs);
|
|
644
|
+
// Don't keep the process alive just for this timer
|
|
645
|
+
if (forceTimer.unref) forceTimer.unref();
|
|
646
|
+
|
|
647
|
+
try {
|
|
648
|
+
// 1. Stop schedulers
|
|
649
|
+
logger.info("Shutdown: stopping task scheduler");
|
|
650
|
+
this.scheduler.stop();
|
|
651
|
+
|
|
652
|
+
// 2. Drain queue — cancel pending, wait for active
|
|
653
|
+
logger.info("Shutdown: draining group queue");
|
|
654
|
+
const dropped = this.queue.cancelAll();
|
|
655
|
+
if (dropped > 0)
|
|
656
|
+
logger.info("Shutdown: cancelled pending queue entries", {
|
|
657
|
+
count: dropped,
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
// 3. Kill running containers
|
|
661
|
+
logger.info("Shutdown: stopping running containers");
|
|
662
|
+
this.containerRunner.killAll();
|
|
663
|
+
|
|
664
|
+
// 4. Wait for active work to finish (with a shorter timeout)
|
|
665
|
+
const drainTimeout = Math.max(timeoutMs - 2000, 1000);
|
|
666
|
+
const drained = await this.queue.waitForActive(drainTimeout);
|
|
667
|
+
if (!drained) {
|
|
668
|
+
logger.warn("Shutdown: active work did not finish in time");
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// 5. Emit extension shutdown hooks
|
|
672
|
+
if (this.hooks && this.extensionCtx) {
|
|
673
|
+
logger.info("Shutdown: notifying extensions");
|
|
674
|
+
await this.hooks.emit("shutdown", {}, this.extensionCtx);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// 6. Run registered shutdown hooks (adapters, server, etc.)
|
|
678
|
+
for (const hook of this.shutdownHooks) {
|
|
679
|
+
try {
|
|
680
|
+
await hook();
|
|
681
|
+
} catch (err) {
|
|
682
|
+
logger.error(
|
|
683
|
+
"Shutdown hook failed",
|
|
684
|
+
err instanceof Error ? err : undefined,
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// 6. Stop rate limiter cleanup
|
|
690
|
+
this.rateLimiter.stopCleanup();
|
|
691
|
+
|
|
692
|
+
// 7. Close database
|
|
693
|
+
logger.info("Shutdown: closing database");
|
|
694
|
+
this.db.close();
|
|
695
|
+
|
|
696
|
+
logger.info("Shutdown: complete");
|
|
697
|
+
} finally {
|
|
698
|
+
clearTimeout(forceTimer);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
private async executePrompt(
|
|
703
|
+
spaceId: string,
|
|
704
|
+
prompt: string,
|
|
705
|
+
_source: InputSource,
|
|
706
|
+
callerId: string,
|
|
707
|
+
attachments?: MessageAttachment[],
|
|
708
|
+
authorName?: string,
|
|
709
|
+
replyMeta?: {
|
|
710
|
+
platform?: string;
|
|
711
|
+
conversationExternalId?: string;
|
|
712
|
+
replyToPlatformMessageId?: string;
|
|
713
|
+
platformMessageId?: string;
|
|
714
|
+
},
|
|
715
|
+
replyFlags?: { isReplyToBot: boolean; isDM: boolean },
|
|
716
|
+
): Promise<ContainerResult> {
|
|
717
|
+
this.db.ensureSpace(spaceId);
|
|
718
|
+
|
|
719
|
+
return this.queue.enqueue(spaceId, async () => {
|
|
720
|
+
// ── Daily message quota check ────────────────────────────────────────
|
|
721
|
+
// Calls the console API to record the message and verify the user hasn't
|
|
722
|
+
// exceeded their plan's daily limit. Fails open: if the API is unreachable,
|
|
723
|
+
// the message is allowed through (billing is best-effort, not a hard gate).
|
|
724
|
+
if (this.config.consoleUrl && this.config.consoleUserId) {
|
|
725
|
+
try {
|
|
726
|
+
const quotaRes = await fetch(
|
|
727
|
+
`${this.config.consoleUrl}/api/user/billing/message-used`,
|
|
728
|
+
{
|
|
729
|
+
method: "POST",
|
|
730
|
+
headers: {
|
|
731
|
+
"Content-Type": "application/json",
|
|
732
|
+
...(this.config.consoleInternalSecret
|
|
733
|
+
? {
|
|
734
|
+
Authorization: `Bearer ${this.config.consoleInternalSecret}`,
|
|
735
|
+
}
|
|
736
|
+
: {}),
|
|
737
|
+
},
|
|
738
|
+
body: JSON.stringify({
|
|
739
|
+
userId: this.config.consoleUserId,
|
|
740
|
+
agentId: process.env.MERCURY_AGENT_ID ?? spaceId,
|
|
741
|
+
isByok: false,
|
|
742
|
+
}),
|
|
743
|
+
signal: AbortSignal.timeout(5000),
|
|
744
|
+
},
|
|
745
|
+
);
|
|
746
|
+
if (quotaRes.ok) {
|
|
747
|
+
const quotaData = (await quotaRes.json()) as {
|
|
748
|
+
allowed: boolean;
|
|
749
|
+
remaining: number | null;
|
|
750
|
+
};
|
|
751
|
+
if (!quotaData.allowed) {
|
|
752
|
+
return {
|
|
753
|
+
reply:
|
|
754
|
+
"You've reached your daily message limit. Upgrade your plan at the Mercury Console to continue chatting.",
|
|
755
|
+
files: [],
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
// Non-OK response → fail open (log but don't block)
|
|
760
|
+
else {
|
|
761
|
+
logger.warn(
|
|
762
|
+
"Quota check returned non-OK status — allowing message",
|
|
763
|
+
{ status: quotaRes.status },
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
} catch (err) {
|
|
767
|
+
logger.warn("Quota check failed (unreachable?) — allowing message", {
|
|
768
|
+
err,
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
// ────────────────────────────────────────────────────────────────────
|
|
773
|
+
|
|
774
|
+
const workspace = ensureSpaceWorkspace(
|
|
775
|
+
resolveProjectPath(this.config.spacesDir),
|
|
776
|
+
spaceId,
|
|
777
|
+
);
|
|
778
|
+
|
|
779
|
+
// Container-relative workspace path
|
|
780
|
+
const containerWorkspace = `/spaces/${spaceId}`;
|
|
781
|
+
|
|
782
|
+
// ── Reply-chain isolation ──────────────────────────────────────────
|
|
783
|
+
// Strip quoted bot output from unprivileged group replies-to-bot
|
|
784
|
+
// BEFORE hooks see the prompt (defense in depth).
|
|
785
|
+
let replyIsolated = false;
|
|
786
|
+
let finalPrompt = prompt;
|
|
787
|
+
if (replyFlags?.isReplyToBot && !replyFlags.isDM) {
|
|
788
|
+
const seededAdmins = this.config.admins
|
|
789
|
+
? this.config.admins
|
|
790
|
+
.split(",")
|
|
791
|
+
.map((s) => s.trim())
|
|
792
|
+
.filter(Boolean)
|
|
793
|
+
: [];
|
|
794
|
+
const earlyRole = resolveRole(this.db, spaceId, callerId, seededAdmins);
|
|
795
|
+
if (earlyRole !== "admin" && earlyRole !== "system") {
|
|
796
|
+
replyIsolated = true;
|
|
797
|
+
finalPrompt = finalPrompt.replace(
|
|
798
|
+
/\n*<reply_to[^>]*>[\s\S]*?<\/reply_to>/g,
|
|
799
|
+
"",
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
// ────────────────────────────────────────────────────────────────────
|
|
804
|
+
|
|
805
|
+
// Emit workspace_init hook (extensions should be idempotent)
|
|
806
|
+
if (this.hooks && this.extensionCtx) {
|
|
807
|
+
await this.hooks.emit(
|
|
808
|
+
"workspace_init",
|
|
809
|
+
{ spaceId, workspace, containerWorkspace },
|
|
810
|
+
this.extensionCtx,
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// Emit before_container hook
|
|
815
|
+
let extraEnv: Record<string, string> | undefined;
|
|
816
|
+
if (this.hooks && this.extensionCtx) {
|
|
817
|
+
const result = await this.hooks.emitBeforeContainer(
|
|
818
|
+
{
|
|
819
|
+
spaceId,
|
|
820
|
+
prompt: finalPrompt,
|
|
821
|
+
callerId,
|
|
822
|
+
workspace,
|
|
823
|
+
containerWorkspace,
|
|
824
|
+
attachments,
|
|
825
|
+
},
|
|
826
|
+
this.extensionCtx,
|
|
827
|
+
);
|
|
828
|
+
if (result?.block) {
|
|
829
|
+
return { reply: result.block.reason, files: [] };
|
|
830
|
+
}
|
|
831
|
+
if (result) {
|
|
832
|
+
if (result.env) {
|
|
833
|
+
extraEnv = { ...extraEnv, ...result.env };
|
|
834
|
+
}
|
|
835
|
+
if (result.systemPrompt) {
|
|
836
|
+
extraEnv = {
|
|
837
|
+
...extraEnv,
|
|
838
|
+
MERCURY_EXT_SYSTEM_PROMPT: result.systemPrompt,
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
if (result.promptAppend) {
|
|
842
|
+
finalPrompt = [finalPrompt, result.promptAppend]
|
|
843
|
+
.filter(Boolean)
|
|
844
|
+
.join("\n\n");
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// Inject per-space system prompt (set via console Spaces settings or at provision time).
|
|
850
|
+
const spacePrompt = this.db.getSpaceConfig(spaceId, "system_prompt");
|
|
851
|
+
if (spacePrompt) {
|
|
852
|
+
const existing = extraEnv?.MERCURY_EXT_SYSTEM_PROMPT;
|
|
853
|
+
extraEnv = {
|
|
854
|
+
...extraEnv,
|
|
855
|
+
MERCURY_EXT_SYSTEM_PROMPT: existing
|
|
856
|
+
? `${existing}\n\n${spacePrompt}`
|
|
857
|
+
: spacePrompt,
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Resolve reply target once — reused for context assembly and DB linkage.
|
|
862
|
+
let replyMercuryMsgId: number | null = null;
|
|
863
|
+
if (
|
|
864
|
+
replyMeta?.replyToPlatformMessageId &&
|
|
865
|
+
replyMeta.platform &&
|
|
866
|
+
replyMeta.conversationExternalId
|
|
867
|
+
) {
|
|
868
|
+
replyMercuryMsgId = this.db.lookupMercuryMessageId(
|
|
869
|
+
replyMeta.platform,
|
|
870
|
+
replyMeta.conversationExternalId,
|
|
871
|
+
replyMeta.replyToPlatformMessageId,
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
const userReplyToId = replyMercuryMsgId ?? undefined;
|
|
875
|
+
|
|
876
|
+
const replyChainDepthStr = this.db.getSpaceConfig(
|
|
877
|
+
spaceId,
|
|
878
|
+
"context.reply_chain_depth",
|
|
879
|
+
);
|
|
880
|
+
const replyChainDepth = replyChainDepthStr
|
|
881
|
+
? Number.parseInt(replyChainDepthStr, 10)
|
|
882
|
+
: 10;
|
|
883
|
+
|
|
884
|
+
// Fetch prior turns based on context mode.
|
|
885
|
+
// When reply-isolated, skip all history to prevent context leakage.
|
|
886
|
+
let history: import("../types.js").StoredMessage[];
|
|
887
|
+
let anchorMessages: import("../types.js").StoredMessage[] | undefined;
|
|
888
|
+
if (replyIsolated) {
|
|
889
|
+
history = [];
|
|
890
|
+
extraEnv = { ...extraEnv, MERCURY_REPLY_ISOLATED: "1" };
|
|
891
|
+
} else {
|
|
892
|
+
const contextMode =
|
|
893
|
+
this.db.getSpaceConfig(spaceId, "context.mode") ?? "clear";
|
|
894
|
+
|
|
895
|
+
if (contextMode === "context") {
|
|
896
|
+
const windowSizeStr = this.db.getSpaceConfig(
|
|
897
|
+
spaceId,
|
|
898
|
+
"context.window_size",
|
|
899
|
+
);
|
|
900
|
+
const windowSize = windowSizeStr
|
|
901
|
+
? Number.parseInt(windowSizeStr, 10)
|
|
902
|
+
: (this.config.contextWindowSize ?? 10);
|
|
903
|
+
|
|
904
|
+
if (replyMercuryMsgId !== null) {
|
|
905
|
+
const trimmedWindow = Math.floor(windowSize / 2);
|
|
906
|
+
const anchored = this.db.getAnchoredContext(
|
|
907
|
+
spaceId,
|
|
908
|
+
replyMercuryMsgId,
|
|
909
|
+
replyChainDepth,
|
|
910
|
+
trimmedWindow,
|
|
911
|
+
);
|
|
912
|
+
anchorMessages = anchored.anchor;
|
|
913
|
+
history = anchored.recent;
|
|
914
|
+
} else {
|
|
915
|
+
history = this.db.getRecentTurns(spaceId, windowSize);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// One-shot clear: reset temporary boundary immediately after reading history.
|
|
919
|
+
this.db.resetClearBoundary(spaceId);
|
|
920
|
+
} else {
|
|
921
|
+
// Clear mode: only include reply chain if this message is a reply
|
|
922
|
+
if (replyMercuryMsgId !== null) {
|
|
923
|
+
history = this.db.getReplyChain(
|
|
924
|
+
replyMercuryMsgId,
|
|
925
|
+
replyChainDepth,
|
|
926
|
+
spaceId,
|
|
927
|
+
);
|
|
928
|
+
} else {
|
|
929
|
+
history = [];
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const userMessageId = this.db.addMessage(
|
|
935
|
+
spaceId,
|
|
936
|
+
"user",
|
|
937
|
+
finalPrompt,
|
|
938
|
+
attachments,
|
|
939
|
+
userReplyToId,
|
|
940
|
+
);
|
|
941
|
+
|
|
942
|
+
// Record platform message ID mapping for the inbound user message
|
|
943
|
+
if (
|
|
944
|
+
replyMeta?.platformMessageId &&
|
|
945
|
+
replyMeta.platform &&
|
|
946
|
+
replyMeta.conversationExternalId
|
|
947
|
+
) {
|
|
948
|
+
this.db.addPlatformMessageId(
|
|
949
|
+
userMessageId,
|
|
950
|
+
replyMeta.platform,
|
|
951
|
+
replyMeta.conversationExternalId,
|
|
952
|
+
replyMeta.platformMessageId,
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// Compute caller role, denied CLIs, and permitted env vars
|
|
957
|
+
let callerRole = "member";
|
|
958
|
+
if (this.extensionRegistry) {
|
|
959
|
+
const seededAdmins = this.config.admins
|
|
960
|
+
? this.config.admins
|
|
961
|
+
.split(",")
|
|
962
|
+
.map((s) => s.trim())
|
|
963
|
+
.filter(Boolean)
|
|
964
|
+
: [];
|
|
965
|
+
callerRole = resolveRole(this.db, spaceId, callerId, seededAdmins);
|
|
966
|
+
|
|
967
|
+
const cliExtensions = this.extensionRegistry.getCliExtensions();
|
|
968
|
+
if (cliExtensions.length > 0) {
|
|
969
|
+
const denied = cliExtensions
|
|
970
|
+
.filter(
|
|
971
|
+
(ext) =>
|
|
972
|
+
ext.clis.length > 0 &&
|
|
973
|
+
!hasPermission(this.db, spaceId, callerRole, ext.name),
|
|
974
|
+
)
|
|
975
|
+
.flatMap((ext) => ext.clis.map((c) => c.name));
|
|
976
|
+
if (denied.length > 0) {
|
|
977
|
+
extraEnv = {
|
|
978
|
+
...extraEnv,
|
|
979
|
+
MERCURY_DENIED_CLIS: denied.join(","),
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// Inject extension env vars only when caller has permission
|
|
985
|
+
for (const ext of this.extensionRegistry.list()) {
|
|
986
|
+
if (ext.envVars.length === 0) continue;
|
|
987
|
+
if (
|
|
988
|
+
ext.permission &&
|
|
989
|
+
!hasPermission(this.db, spaceId, callerRole, ext.name)
|
|
990
|
+
)
|
|
991
|
+
continue;
|
|
992
|
+
for (const envDef of ext.envVars) {
|
|
993
|
+
const value = process.env[envDef.from];
|
|
994
|
+
if (value) {
|
|
995
|
+
const containerKey =
|
|
996
|
+
envDef.as ?? envDef.from.replace(/^MERCURY_/, "");
|
|
997
|
+
extraEnv = { ...extraEnv, [containerKey]: value };
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// Inject active model as single-leg override (eliminates automatic fallback)
|
|
1004
|
+
const activeModelRaw = this.db.getSpaceConfig(spaceId, "model.active");
|
|
1005
|
+
if (activeModelRaw) {
|
|
1006
|
+
const colonIdx = activeModelRaw.indexOf(":");
|
|
1007
|
+
if (colonIdx > 0) {
|
|
1008
|
+
const provider = activeModelRaw.slice(0, colonIdx);
|
|
1009
|
+
const model = activeModelRaw.slice(colonIdx + 1);
|
|
1010
|
+
const legIdx = this.config.resolvedModelChain.findIndex(
|
|
1011
|
+
(l) => l.provider === provider && l.model === model,
|
|
1012
|
+
);
|
|
1013
|
+
if (legIdx >= 0) {
|
|
1014
|
+
extraEnv = {
|
|
1015
|
+
...extraEnv,
|
|
1016
|
+
MODEL_CHAIN: JSON.stringify([
|
|
1017
|
+
this.config.resolvedModelChain[legIdx],
|
|
1018
|
+
]),
|
|
1019
|
+
MODEL_CHAIN_CAPABILITIES: JSON.stringify([
|
|
1020
|
+
this.config.resolvedModelChainCapabilities[legIdx],
|
|
1021
|
+
]),
|
|
1022
|
+
};
|
|
1023
|
+
} else {
|
|
1024
|
+
logger.warn(
|
|
1025
|
+
"model.active references unknown leg, ignoring override",
|
|
1026
|
+
{
|
|
1027
|
+
spaceId,
|
|
1028
|
+
activeModelRaw,
|
|
1029
|
+
},
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
const startTime = Date.now();
|
|
1036
|
+
|
|
1037
|
+
const preferences = this.db.listSpacePreferences(spaceId).map((p) => ({
|
|
1038
|
+
key: p.key,
|
|
1039
|
+
value: p.value,
|
|
1040
|
+
}));
|
|
1041
|
+
|
|
1042
|
+
let containerResult: ContainerResult;
|
|
1043
|
+
try {
|
|
1044
|
+
containerResult = await this.containerRunner.replyWithRetry({
|
|
1045
|
+
spaceId,
|
|
1046
|
+
spaceWorkspace: workspace,
|
|
1047
|
+
messages: history,
|
|
1048
|
+
anchorMessages,
|
|
1049
|
+
prompt: finalPrompt,
|
|
1050
|
+
callerId,
|
|
1051
|
+
callerRole,
|
|
1052
|
+
authorName,
|
|
1053
|
+
attachments,
|
|
1054
|
+
preferences,
|
|
1055
|
+
extraEnv,
|
|
1056
|
+
claimedEnvSources: this.extensionRegistry?.getClaimedEnvSources(),
|
|
1057
|
+
});
|
|
1058
|
+
} catch (err) {
|
|
1059
|
+
this.db.updateMessageRunMeta(userMessageId, userTurnRunMeta(undefined));
|
|
1060
|
+
throw err;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
const durationMs = Date.now() - startTime;
|
|
1064
|
+
|
|
1065
|
+
// Emit after_container hook
|
|
1066
|
+
if (this.hooks && this.extensionCtx) {
|
|
1067
|
+
const hookResult = await this.hooks.emitAfterContainer(
|
|
1068
|
+
{
|
|
1069
|
+
spaceId,
|
|
1070
|
+
workspace,
|
|
1071
|
+
callerId,
|
|
1072
|
+
prompt: finalPrompt,
|
|
1073
|
+
reply: containerResult.reply,
|
|
1074
|
+
durationMs,
|
|
1075
|
+
},
|
|
1076
|
+
this.extensionCtx,
|
|
1077
|
+
);
|
|
1078
|
+
if (hookResult?.suppress) {
|
|
1079
|
+
this.db.updateMessageRunMeta(
|
|
1080
|
+
userMessageId,
|
|
1081
|
+
userTurnRunMeta(containerResult.usage),
|
|
1082
|
+
);
|
|
1083
|
+
return { reply: "", files: [] };
|
|
1084
|
+
}
|
|
1085
|
+
if (hookResult?.reply !== undefined) {
|
|
1086
|
+
containerResult.reply = hookResult.reply;
|
|
1087
|
+
}
|
|
1088
|
+
if (hookResult?.files?.length) {
|
|
1089
|
+
containerResult.files = [
|
|
1090
|
+
...containerResult.files,
|
|
1091
|
+
...hookResult.files,
|
|
1092
|
+
];
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
const assistantMessageId = this.db.addMessage(
|
|
1097
|
+
spaceId,
|
|
1098
|
+
"assistant",
|
|
1099
|
+
containerResult.reply,
|
|
1100
|
+
undefined,
|
|
1101
|
+
userMessageId, // reply chain: assistant replies to user message
|
|
1102
|
+
);
|
|
1103
|
+
|
|
1104
|
+
if (containerResult.usage) {
|
|
1105
|
+
this.db.recordUsage(spaceId, containerResult.usage);
|
|
1106
|
+
} else {
|
|
1107
|
+
logger.debug(
|
|
1108
|
+
"Container run finished without token usage (old agent image, non-JSON pi output, or zero reported usage)",
|
|
1109
|
+
{ spaceId },
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
this.db.updateMessageRunMeta(
|
|
1114
|
+
userMessageId,
|
|
1115
|
+
userTurnRunMeta(containerResult.usage),
|
|
1116
|
+
);
|
|
1117
|
+
|
|
1118
|
+
containerResult.assistantMessageId = assistantMessageId;
|
|
1119
|
+
return containerResult;
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* Record the platform message ID for an outbound assistant message.
|
|
1125
|
+
* Called by the handler after sendReply() returns the platform ID.
|
|
1126
|
+
*/
|
|
1127
|
+
recordOutboundPlatformId(
|
|
1128
|
+
assistantMessageId: number,
|
|
1129
|
+
platform: string,
|
|
1130
|
+
conversationExternalId: string,
|
|
1131
|
+
platformMessageId: string,
|
|
1132
|
+
): void {
|
|
1133
|
+
this.db.addPlatformMessageId(
|
|
1134
|
+
assistantMessageId,
|
|
1135
|
+
platform,
|
|
1136
|
+
conversationExternalId,
|
|
1137
|
+
platformMessageId,
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
}
|