mercury-agent 0.5.18 → 0.6.0

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/README.md CHANGED
@@ -445,6 +445,45 @@ runtime:
445
445
 
446
446
  Auto-created spaces are seeded with `trigger.match=always`, `context.mode=context`, and the configured member permissions. Changing `rate_limit_daily_member` in `mercury.yaml` propagates to all auto-created spaces immediately — no per-space update needed. Explicit per-space overrides (set via dashboard or API) still take precedence.
447
447
 
448
+ #### Broadcast
449
+
450
+ Send a message to all auto-created DM spaces at once (e.g. maintenance notices, promotions):
451
+
452
+ ```bash
453
+ curl -X POST http://localhost:8787/api/broadcast \
454
+ -H "Authorization: Bearer $API_SECRET" \
455
+ -H "X-Mercury-Caller: <your-admin-id>" \
456
+ -H "X-Mercury-Space: main" \
457
+ -H "Content-Type: application/json" \
458
+ -d '{"text": "Maintenance tonight 10pm-2am."}'
459
+ ```
460
+
461
+ Returns `{ total, delivered, failed, errors }`. Only global admins (`admins` or `dm_auto_space.admin_ids`) may broadcast. Messages are sent as literal text — no LLM processing.
462
+
463
+ #### Bot Character
464
+
465
+ Global admins can set a bot-wide character (tone, greeting style, personality) that applies to all spaces — including every DM auto-space. Stored in the database; survives profile deploys.
466
+
467
+ The recommended flow is conversational: tell the bot to change its character, it drafts the update, confirms with you, then saves via `mrctl character set --file <path>`. Programmatic access:
468
+
469
+ ```bash
470
+ # Set
471
+ curl -X PUT http://localhost:8787/api/character \
472
+ -H "Authorization: Bearer $API_SECRET" \
473
+ -H "X-Mercury-Caller: <admin-id>" \
474
+ -H "X-Mercury-Space: main" \
475
+ -H "Content-Type: application/json" \
476
+ -d '{"text": "Be warm and professional. Greet with שלום וברכה."}'
477
+
478
+ # Get current character
479
+ curl http://localhost:8787/api/character ...
480
+
481
+ # Clear
482
+ curl -X DELETE http://localhost:8787/api/character ...
483
+ ```
484
+
485
+ Only global admins may set the character (same gate as broadcast). Per-space tone adjustments use the existing per-space `system_prompt` in dashboard settings.
486
+
448
487
  ### Per-space Config
449
488
 
450
489
  Conversations are discovered from incoming traffic. Unlinked conversations stay idle until you attach them to a space via `mercury link <conversation-id> <space-id>` or the dashboard.
@@ -44,7 +44,7 @@ env:
44
44
  default: "09:00-18:00"
45
45
 
46
46
  # Project-wide agent persona, injected into every container's system prompt.
47
- system_prompt: |
47
+ profile_prompt: |
48
48
  You are a meeting room booking assistant. Help each user book, view, and
49
49
  cancel ONLY their own reservations. Never reveal other users' bookings.
50
50
 
@@ -45,15 +45,31 @@ Every request runs with `--no-session` (no pi session file). Continuity across r
45
45
 
46
46
  The session boundary (`chat_state.min_message_id`) excludes messages older than the last `compact` call from the sliding window. Run `mrctl compact` to reset the boundary and start fresh.
47
47
 
48
- ### Prompt structure (inside container)
48
+ ### System prompt layering (broadest → most specific)
49
49
 
50
50
  ```
51
- <system>
52
- [identity: AGENTS.md + capabilities + memory guidance]
53
- </system>
51
+ 1. pi built-in prompt (Claude Code identity, tool definitions)
52
+ or Mercury's override when OVERRIDE_PI_SYSTEM_PROMPT is set
54
53
 
55
- <caller>…</caller>
54
+ 2. Mercury platform additions (buildMercuryAdditions)
55
+ — identity, inbox/outbox, permissions, moderation, capabilities, memory guidance
56
+
57
+ 3. MERCURY_EXT_SYSTEM_PROMPT (assembled on host, passed as env var):
58
+ a. Extension before_container fragments
59
+ b. profile_prompt (capability playbook — deploy-owned)
60
+ c. Bot Character (global voice — owner-owned, via project_config DB)
61
+ d. Per-space system_prompt (space refinement — space-admin-owned)
62
+
63
+ 4. AGENTS.md files (pi auto-discovers from PI_CODING_AGENT_DIR + workspace cwd)
64
+ — global (:ro) + per-space
65
+ ```
66
+
67
+ ### User prompt structure (inside container)
68
+
69
+ ```
70
+ <caller id="…" name="…" role="…" space="…" />
56
71
  <episodic_memory>…</episodic_memory> ← MEMORY.md (if present)
72
+ <active_episodes>…</active_episodes> ← relevance-scored episode snippets
57
73
  <history> ← sliding window from DB
58
74
  <turn timestamp="…">
59
75
  <user>…</user>
@@ -64,6 +80,7 @@ The session boundary (`chat_state.min_message_id`) excludes messages older than
64
80
  <ambient_messages>…</ambient_messages>
65
81
  <preferences>…</preferences>
66
82
  <attachments>…</attachments>
83
+ <reply_anchor>…</reply_anchor> ← only on replies
67
84
 
68
85
  [user prompt text]
69
86
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mercury-agent",
3
- "version": "0.5.18",
3
+ "version": "0.6.0",
4
4
  "description": "Personal AI assistant for chat platforms (WhatsApp, Slack, Discord, Telegram)",
5
5
  "license": "MIT",
6
6
  "author": "Avishai Tsabari",
@@ -155,3 +155,22 @@ You can delegate tasks to specialized sub-agents:
155
155
 
156
156
  ### Chained Workflow
157
157
  "Use a chain: first have explore find the code, then have worker implement the fix"
158
+
159
+ ## Character
160
+
161
+ The system prompt may include a "Bot Character" section — the owner-defined voice for
162
+ all conversations. Always follow it.
163
+
164
+ When a user asks you to change your personality, tone, greeting style, or character:
165
+ 1. Read the current character: `mrctl character get`
166
+ 2. Draft the FULL updated character text — merge their request with the existing
167
+ character into one coherent text. Do not append contradictory fragments.
168
+ 3. Show the draft and ask for explicit confirmation.
169
+ 4. On confirmation, write the draft to a temp file and run:
170
+ `mrctl character set --file <path>`
171
+ 5. Relay the result. If the API returns 403, tell the user that only the bot owner
172
+ can change the global character.
173
+
174
+ Only global admins (configured on the host) can change the character — the API
175
+ enforces this. Per-space tone adjustments go in this space's `system_prompt`,
176
+ which is set from the dashboard (Spaces settings).
@@ -266,7 +266,7 @@ function buildCapabilitySection(
266
266
  * lines plus all Mercury platform content. This is appended after pi's own default system prompt.
267
267
  *
268
268
  * In override mode (skipIdentity=true): omits the "You are Claude Code" preamble since the outer
269
- * wrapper in buildSystemPrompt provides the identity. Mercury identity ("You are Mercury") and all
269
+ * wrapper in buildSystemPrompt provides the identity. Bot identity (from BOT_USERNAME env) and all
270
270
  * platform content (inbox/outbox, docs reference, permissions, moderation) are retained.
271
271
  */
272
272
  function buildMercuryAdditions(
@@ -279,9 +279,12 @@ function buildMercuryAdditions(
279
279
  const claudeCodePreamble = `You are Claude Code, Anthropic's official CLI for Claude.
280
280
  Prioritize practical outputs and explicit assumptions.`;
281
281
 
282
+ const raw = process.env.BOT_USERNAME || "Mercury";
283
+ const botName = raw.charAt(0).toUpperCase() + raw.slice(1);
284
+
282
285
  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.
283
286
 
284
- You are Mercury, built from https://github.com/Avishai-Tsabari/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.
287
+ You are ${botName}. You run on the Mercury platform (https://github.com/Avishai-Tsabari/mercury). When users ask about the platform — what it can do, how to configure it, scheduling, permissions, extensions, or anything about Mercury — 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.
285
288
 
286
289
  ## Permissions & Security
287
290
  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.
@@ -707,6 +710,9 @@ function buildBwrapArgs(
707
710
  "--bind",
708
711
  "/home/mercury",
709
712
  "/home/mercury",
713
+ "--ro-bind",
714
+ "/home/mercury/.pi/agent",
715
+ "/home/mercury/.pi/agent",
710
716
  "--proc",
711
717
  "/proc",
712
718
  "--dev",
@@ -871,6 +871,7 @@ export class AgentContainerRunner {
871
871
  key: "OVERRIDE_PI_SYSTEM_PROMPT",
872
872
  value: this.config.overridePiSystemPrompt ? "true" : "false",
873
873
  },
874
+ { key: "BOT_USERNAME", value: this.config.botUsername },
874
875
  ].filter((x): x is { key: string; value: string } => Boolean(x.value));
875
876
 
876
877
  const containerName = this.generateContainerName();
@@ -965,7 +966,7 @@ export class AgentContainerRunner {
965
966
  "-v",
966
967
  `${innerSpaceDir}:/spaces/${input.spaceId}`,
967
968
  "-v",
968
- `${innerGlobalDir}:/home/mercury/.pi/agent`,
969
+ `${innerGlobalDir}:/home/mercury/.pi/agent:ro`,
969
970
  "-v",
970
971
  `${readmePath}:/docs/mercury/README.md:ro`,
971
972
  "-v",
package/src/cli/mrctl.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import { mkdirSync, writeFileSync } from "node:fs";
3
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import path from "node:path";
5
5
  import type { StorageResponse } from "../core/routes/storage.js";
6
6
  import { buildRequestInit } from "./mrctl-http.js";
@@ -96,6 +96,7 @@ Built-in commands:
96
96
  mrctl compact
97
97
  mrctl clear
98
98
  mrctl recall <search text> [--limit N]
99
+ mrctl character get|set|clear
99
100
  mrctl capability <name> <action> [json-body]
100
101
  mrctl tts synthesize --text "Hello" --out outbox/reply.mp3 \\
101
102
  [--language auto|he-IL|en-US] [--provider google|azure|auto]
@@ -133,6 +134,46 @@ async function main() {
133
134
  break;
134
135
  }
135
136
 
137
+ case "character": {
138
+ if (!sub) usage();
139
+ switch (sub) {
140
+ case "get": {
141
+ const data = (await api("GET", "/api/character")) as {
142
+ character: string | null;
143
+ };
144
+ if (data.character === null) {
145
+ process.stdout.write("(not set)\n");
146
+ } else {
147
+ process.stdout.write(`${data.character}\n`);
148
+ }
149
+ break;
150
+ }
151
+ case "set": {
152
+ const filePath = parseFlag(args, "--file");
153
+ let text: string;
154
+ if (filePath) {
155
+ text = readFileSync(filePath, "utf-8");
156
+ } else {
157
+ const fileIdx = args.indexOf("--file");
158
+ const words = args.slice(2).filter((_, i) => {
159
+ const absIdx = i + 2;
160
+ return absIdx !== fileIdx && absIdx !== fileIdx + 1;
161
+ });
162
+ if (words.length === 0) fatal("Missing text or --file <path>");
163
+ text = words.join(" ");
164
+ }
165
+ print(await api("PUT", "/api/character", { text }));
166
+ break;
167
+ }
168
+ case "clear":
169
+ print(await api("DELETE", "/api/character"));
170
+ break;
171
+ default:
172
+ fatal(`Unknown character subcommand: ${sub}`);
173
+ }
174
+ break;
175
+ }
176
+
136
177
  case "capability": {
137
178
  // mrctl capability <name> <action> [json-body]
138
179
  const name = requireArg(args, 1, "capability name");
@@ -7,6 +7,7 @@ import { logger } from "../logger.js";
7
7
  import type { Db } from "../storage/db.js";
8
8
  import type { TradeStationFetch } from "../tradestation/host-api.js";
9
9
  import { hasPermission } from "./permissions.js";
10
+ import type { MercuryCoreRuntime } from "./runtime.js";
10
11
  import type { SpaceQueue } from "./space-queue.js";
11
12
  import type { TaskScheduler } from "./task-scheduler.js";
12
13
 
@@ -22,6 +23,8 @@ export interface ApiContext {
22
23
  configRegistry: ConfigRegistry;
23
24
  /** Optional mock for TradeStation HTTP in tests */
24
25
  tradeStationFetch?: TradeStationFetch;
26
+ /** Runtime reference for cross-space operations (broadcast) */
27
+ runtime?: MercuryCoreRuntime;
25
28
  }
26
29
 
27
30
  export interface AuthContext {
package/src/core/api.ts CHANGED
@@ -4,7 +4,9 @@ import type { ApiContext, AuthContext, Env } from "./api-types.js";
4
4
  import { verifyCallerToken } from "./caller-token.js";
5
5
  import { resolveRole } from "./permissions.js";
6
6
  import {
7
+ broadcast,
7
8
  capability,
9
+ character,
8
10
  config,
9
11
  connections,
10
12
  control,
@@ -111,6 +113,8 @@ export function createApiApp(apiCtx: ApiContext): Hono<Env> {
111
113
  app.route("/tradestation", tradestation);
112
114
  app.route("/tts", tts);
113
115
  app.route("/capability", capability);
116
+ app.route("/broadcast", broadcast);
117
+ app.route("/character", character);
114
118
 
115
119
  // ─── Fallback ───────────────────────────────────────────────────────────
116
120
 
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Check if a caller is a global admin (configured in mercury.yaml / env).
3
+ * Global admins are identified by `config.admins` and `config.dmAutoSpaceAdminIds`.
4
+ * Platform-specific ID prefixes, + signs, and @domain suffixes are normalized.
5
+ */
6
+ export function isGlobalAdmin(
7
+ callerId: string,
8
+ config: { admins?: string; dmAutoSpaceAdminIds?: string },
9
+ ): boolean {
10
+ const globalAdmins = [
11
+ ...(config.admins
12
+ ? config.admins
13
+ .split(",")
14
+ .map((s) => s.trim())
15
+ .filter(Boolean)
16
+ : []),
17
+ ...(config.dmAutoSpaceAdminIds
18
+ ? config.dmAutoSpaceAdminIds
19
+ .split(",")
20
+ .map((s) => s.trim())
21
+ .filter(Boolean)
22
+ : []),
23
+ ];
24
+
25
+ const normalize = (s: string) =>
26
+ s
27
+ .replace(/^[^:]+:/, "")
28
+ .replace(/^[+]+/, "")
29
+ .replace(/@.*$/, "");
30
+
31
+ const callerNormalized = normalize(callerId);
32
+
33
+ return globalAdmins.some(
34
+ (id) => id === callerId || normalize(id) === callerNormalized,
35
+ );
36
+ }
@@ -68,7 +68,7 @@ export const profileSchema = z.object({
68
68
  */
69
69
  member_permissions: z.array(z.string()).optional(),
70
70
  /** Profile-specific agent persona, injected into the container. */
71
- system_prompt: z.string().optional(),
71
+ profile_prompt: z.string().optional(),
72
72
  });
73
73
 
74
74
  export type MercuryProfile = z.infer<typeof profileSchema>;
@@ -178,7 +178,7 @@ export interface ActiveProfile {
178
178
  /** Exhaustive member permission set, or null when the profile doesn't scope members. */
179
179
  memberPermissions: string[] | null;
180
180
  /** Profile agent persona, or null. */
181
- systemPrompt: string | null;
181
+ profilePrompt: string | null;
182
182
  }
183
183
 
184
184
  const ACTIVE_PROFILE_FILE = "active-profile.json";
@@ -191,7 +191,7 @@ export function persistActiveProfile(
191
191
  const activation: ActiveProfile = {
192
192
  name: profile.name,
193
193
  memberPermissions: profile.member_permissions ?? null,
194
- systemPrompt: profile.system_prompt ?? null,
194
+ profilePrompt: profile.profile_prompt ?? null,
195
195
  };
196
196
  mkdirSync(dataDir, { recursive: true });
197
197
  writeFileSync(
@@ -205,7 +205,12 @@ export function loadActiveProfile(dataDir: string): ActiveProfile | null {
205
205
  const file = join(dataDir, ACTIVE_PROFILE_FILE);
206
206
  if (!existsSync(file)) return null;
207
207
  try {
208
- return JSON.parse(readFileSync(file, "utf-8")) as ActiveProfile;
208
+ const raw = JSON.parse(readFileSync(file, "utf-8"));
209
+ return {
210
+ name: raw.name,
211
+ memberPermissions: raw.memberPermissions ?? null,
212
+ profilePrompt: raw.profilePrompt ?? raw.systemPrompt ?? null,
213
+ };
209
214
  } catch {
210
215
  return null;
211
216
  }
@@ -216,16 +221,16 @@ export function loadActiveProfile(dataDir: string): ActiveProfile | null {
216
221
  * every container's system prompt. Held here so the runtime can read it without
217
222
  * re-reading the manifest per message.
218
223
  */
219
- let activeProfileSystemPrompt: string | null = null;
224
+ let activeProfilePromptValue: string | null = null;
220
225
 
221
- /** Set (or clear, with null) the active profile's system prompt. */
222
- export function setActiveProfileSystemPrompt(prompt: string | null): void {
223
- activeProfileSystemPrompt = prompt;
226
+ /** Set (or clear, with null) the active profile's prompt. */
227
+ export function setActiveProfilePrompt(prompt: string | null): void {
228
+ activeProfilePromptValue = prompt;
224
229
  }
225
230
 
226
- /** The active profile's system prompt, or null when no profile scopes it. */
227
- export function getActiveProfileSystemPrompt(): string | null {
228
- return activeProfileSystemPrompt;
231
+ /** The active profile's prompt, or null when no profile scopes it. */
232
+ export function getActiveProfilePrompt(): string | null {
233
+ return activeProfilePromptValue;
229
234
  }
230
235
 
231
236
  /**
@@ -0,0 +1,52 @@
1
+ import { Hono } from "hono";
2
+ import { logger } from "../../logger.js";
3
+ import { type Env, getApiCtx, getAuth } from "../api-types.js";
4
+ import { isGlobalAdmin } from "../global-admin.js";
5
+
6
+ export const broadcast = new Hono<Env>();
7
+
8
+ broadcast.post("/", async (c) => {
9
+ const { callerId } = getAuth(c);
10
+ const { config, runtime } = getApiCtx(c);
11
+
12
+ if (!config.dmAutoSpaceEnabled) {
13
+ return c.json({ error: "dm_auto_space is not enabled" }, 503);
14
+ }
15
+
16
+ if (!isGlobalAdmin(callerId, config)) {
17
+ logger.warn("Broadcast denied — caller is not a global admin", {
18
+ callerId,
19
+ });
20
+ return c.json({ error: "Forbidden: requires global admin" }, 403);
21
+ }
22
+
23
+ if (!runtime) {
24
+ return c.json({ error: "Runtime not available" }, 503);
25
+ }
26
+
27
+ const body = (await c.req.json().catch(() => ({}))) as { text?: string };
28
+ const text = typeof body.text === "string" ? body.text.trim() : "";
29
+
30
+ if (!text) {
31
+ return c.json({ error: "Missing or empty 'text' field" }, 400);
32
+ }
33
+
34
+ if (text.length > 4096) {
35
+ return c.json({ error: "Text exceeds 4096 character limit" }, 400);
36
+ }
37
+
38
+ try {
39
+ const result = await runtime.broadcastToAutoSpaces(text);
40
+ return c.json(result);
41
+ } catch (err) {
42
+ const message = err instanceof Error ? err.message : String(err);
43
+ if (message === "Broadcast already in progress") {
44
+ return c.json({ error: message }, 409);
45
+ }
46
+ if (message === "MessageSender not initialized") {
47
+ return c.json({ error: "Message sender not ready" }, 503);
48
+ }
49
+ logger.error("Broadcast failed", { error: message });
50
+ return c.json({ error: "Broadcast failed" }, 500);
51
+ }
52
+ });
@@ -0,0 +1,69 @@
1
+ import { Hono } from "hono";
2
+ import { logger } from "../../logger.js";
3
+ import { type Env, getApiCtx, getAuth } from "../api-types.js";
4
+ import { isGlobalAdmin } from "../global-admin.js";
5
+
6
+ const MAX_CHARACTER_LENGTH = 4000;
7
+
8
+ export const character = new Hono<Env>();
9
+
10
+ character.get("/", (c) => {
11
+ const { callerId } = getAuth(c);
12
+ const { config, db } = getApiCtx(c);
13
+
14
+ if (!isGlobalAdmin(callerId, config)) {
15
+ logger.warn("Character get denied — caller is not a global admin", {
16
+ callerId,
17
+ });
18
+ return c.json({ error: "Forbidden: requires global admin" }, 403);
19
+ }
20
+
21
+ const value = db.getProjectConfig("character");
22
+ const updatedBy = db.getProjectConfigUpdatedBy("character");
23
+
24
+ return c.json({ character: value, updatedBy });
25
+ });
26
+
27
+ character.put("/", async (c) => {
28
+ const { callerId } = getAuth(c);
29
+ const { config, db } = getApiCtx(c);
30
+
31
+ if (!isGlobalAdmin(callerId, config)) {
32
+ logger.warn("Character set denied — caller is not a global admin", {
33
+ callerId,
34
+ });
35
+ return c.json({ error: "Forbidden: requires global admin" }, 403);
36
+ }
37
+
38
+ const body = (await c.req.json().catch(() => ({}))) as { text?: string };
39
+ const text = typeof body.text === "string" ? body.text.trim() : "";
40
+
41
+ if (!text) {
42
+ return c.json({ error: "Missing or empty 'text' field" }, 400);
43
+ }
44
+
45
+ if (text.length > MAX_CHARACTER_LENGTH) {
46
+ return c.json(
47
+ { error: `Text exceeds ${MAX_CHARACTER_LENGTH} character limit` },
48
+ 400,
49
+ );
50
+ }
51
+
52
+ db.setProjectConfig("character", text, callerId);
53
+ return c.json({ ok: true });
54
+ });
55
+
56
+ character.delete("/", (c) => {
57
+ const { callerId } = getAuth(c);
58
+ const { config, db } = getApiCtx(c);
59
+
60
+ if (!isGlobalAdmin(callerId, config)) {
61
+ logger.warn("Character clear denied — caller is not a global admin", {
62
+ callerId,
63
+ });
64
+ return c.json({ error: "Forbidden: requires global admin" }, 403);
65
+ }
66
+
67
+ db.deleteProjectConfig("character");
68
+ return c.json({ ok: true });
69
+ });
@@ -1030,6 +1030,14 @@ export function createDashboardRoutes(ctx: DashboardContext) {
1030
1030
  </div>
1031
1031
  </div>
1032
1032
 
1033
+ ${raw(
1034
+ (() => {
1035
+ const charText = core.db.getProjectConfig("character");
1036
+ if (!charText) return "";
1037
+ return `<div class="panel"><div class="panel-header">Bot Character</div><div class="panel-body"><pre style="white-space:pre-wrap;word-break:break-word;margin:0;font-family:inherit;font-size:13px;color:var(--fg-muted)">${escapeHtml(charText)}</pre></div></div>`;
1038
+ })(),
1039
+ )}
1040
+
1033
1041
  ${raw(renderExtensionWidgets())}
1034
1042
  `);
1035
1043
  });
@@ -1,4 +1,6 @@
1
+ export { broadcast } from "./broadcast.js";
1
2
  export { capability } from "./capability.js";
3
+ export { character } from "./character.js";
2
4
  export { config } from "./config.js";
3
5
  export { connections } from "./connections.js";
4
6
  export { control } from "./control.js";
@@ -25,7 +25,7 @@ import type {
25
25
  } from "../types.js";
26
26
  import { formatCategoryHelp, formatHelp } from "./commands.js";
27
27
  import { hasPermission, resolveRole } from "./permissions.js";
28
- import { getActiveProfileSystemPrompt } from "./profiles.js";
28
+ import { getActiveProfilePrompt } from "./profiles.js";
29
29
  import { RateLimiter } from "./rate-limiter.js";
30
30
  import { type RouteResult, routeInput } from "./router.js";
31
31
  import { SpaceQueue } from "./space-queue.js";
@@ -72,6 +72,7 @@ export class MercuryCoreRuntime {
72
72
  private readonly shutdownHooks: ShutdownHook[] = [];
73
73
  private readonly pauseTimers = new Map<string, NodeJS.Timeout>();
74
74
  private messageSender: MessageSender | undefined;
75
+ private broadcastInProgress = false;
75
76
  private shuttingDown = false;
76
77
  private signalHandlersInstalled = false;
77
78
 
@@ -293,6 +294,63 @@ export class MercuryCoreRuntime {
293
294
  });
294
295
  }
295
296
 
297
+ getMessageSender(): MessageSender | undefined {
298
+ return this.messageSender;
299
+ }
300
+
301
+ async broadcastToAutoSpaces(text: string): Promise<{
302
+ total: number;
303
+ delivered: number;
304
+ failed: number;
305
+ errors: Array<{ spaceId: string; error: string }>;
306
+ }> {
307
+ if (!this.messageSender) {
308
+ throw new Error("MessageSender not initialized");
309
+ }
310
+ if (this.broadcastInProgress) {
311
+ throw new Error("Broadcast already in progress");
312
+ }
313
+
314
+ this.broadcastInProgress = true;
315
+ try {
316
+ const spaces = this.db.listSpaces().filter((s) => s.id.startsWith("dm-"));
317
+
318
+ const errors: Array<{ spaceId: string; error: string }> = [];
319
+ let delivered = 0;
320
+
321
+ for (let i = 0; i < spaces.length; i++) {
322
+ const space = spaces[i];
323
+ try {
324
+ await this.messageSender.send(space.id, text);
325
+ delivered++;
326
+ } catch (err) {
327
+ errors.push({
328
+ spaceId: space.id,
329
+ error: err instanceof Error ? err.message : String(err),
330
+ });
331
+ }
332
+ if (i < spaces.length - 1) {
333
+ await new Promise((r) => setTimeout(r, 100));
334
+ }
335
+ }
336
+
337
+ logger.info("Broadcast complete", {
338
+ total: spaces.length,
339
+ delivered,
340
+ failed: errors.length,
341
+ });
342
+
343
+ return {
344
+ total: spaces.length,
345
+ delivered,
346
+ failed: errors.length,
347
+ errors,
348
+ };
349
+ } finally {
350
+ this.broadcastInProgress = false;
351
+ }
352
+ }
353
+
296
354
  private deliverTaskOutput(spaceId: string, text: string): void {
297
355
  const consoleUrl = process.env.MERCURY_CONSOLE_URL;
298
356
  const secret = process.env.MERCURY_CONSOLE_INTERNAL_SECRET;
@@ -1232,7 +1290,7 @@ export class MercuryCoreRuntime {
1232
1290
 
1233
1291
  // Inject the active applicative profile persona (project-wide), ahead of
1234
1292
  // any per-space system prompt so a space-specific prompt refines it.
1235
- const profilePrompt = getActiveProfileSystemPrompt();
1293
+ const profilePrompt = getActiveProfilePrompt();
1236
1294
  if (profilePrompt) {
1237
1295
  const existing = extraEnv?.MERCURY_EXT_SYSTEM_PROMPT;
1238
1296
  extraEnv = {
@@ -1243,6 +1301,19 @@ export class MercuryCoreRuntime {
1243
1301
  };
1244
1302
  }
1245
1303
 
1304
+ // Inject global character (owner-set voice, deploy-safe, global-admin gated).
1305
+ const characterPrompt = this.db.getProjectConfig("character");
1306
+ if (characterPrompt) {
1307
+ const existing = extraEnv?.MERCURY_EXT_SYSTEM_PROMPT;
1308
+ const block = `## Bot Character (set by the owner — applies to all conversations)\n${characterPrompt}`;
1309
+ extraEnv = {
1310
+ ...extraEnv,
1311
+ MERCURY_EXT_SYSTEM_PROMPT: existing
1312
+ ? `${existing}\n\n${block}`
1313
+ : block,
1314
+ };
1315
+ }
1316
+
1246
1317
  // Inject per-space system prompt (set via console Spaces settings or at provision time).
1247
1318
  const spacePrompt = this.db.getSpaceConfig(spaceId, "system_prompt");
1248
1319
  if (spacePrompt) {
package/src/main.ts CHANGED
@@ -35,10 +35,7 @@ import { createChatShim } from "./chat-shim.js";
35
35
  import { loadConfig, resolveProjectPath } from "./config.js";
36
36
  import { createMessageHandler } from "./core/handler.js";
37
37
  import { setActiveProfileMemberPermissions } from "./core/permissions.js";
38
- import {
39
- loadActiveProfile,
40
- setActiveProfileSystemPrompt,
41
- } from "./core/profiles.js";
38
+ import { loadActiveProfile, setActiveProfilePrompt } from "./core/profiles.js";
42
39
  import { MercuryCoreRuntime } from "./core/runtime.js";
43
40
  import { runStorageCleanup } from "./core/storage-cleanup.js";
44
41
  import { isOverQuota } from "./core/storage-guard.js";
@@ -225,7 +222,7 @@ async function main() {
225
222
  const activeProfile = loadActiveProfile(resolveProjectPath(config.dataDir));
226
223
  if (activeProfile) {
227
224
  setActiveProfileMemberPermissions(activeProfile.memberPermissions ?? null);
228
- setActiveProfileSystemPrompt(activeProfile.systemPrompt ?? null);
225
+ setActiveProfilePrompt(activeProfile.profilePrompt ?? null);
229
226
  logger.info("Applicative profile active", {
230
227
  profile: activeProfile.name,
231
228
  memberPermissions:
package/src/server.ts CHANGED
@@ -362,6 +362,7 @@ export function createApp(ctx: ServerContext): Hono {
362
362
  scheduler: core.scheduler,
363
363
  registry: ctx.registry,
364
364
  configRegistry: ctx.configRegistry,
365
+ runtime: core,
365
366
  });
366
367
 
367
368
  app.route("/api", apiApp);
package/src/storage/db.ts CHANGED
@@ -206,6 +206,14 @@ export class Db {
206
206
  updated_at INTEGER NOT NULL,
207
207
  PRIMARY KEY (space_id, platform_user_id, date)
208
208
  );
209
+
210
+ CREATE TABLE IF NOT EXISTS project_config (
211
+ key TEXT PRIMARY KEY,
212
+ value TEXT NOT NULL,
213
+ updated_by TEXT NOT NULL,
214
+ created_at INTEGER NOT NULL,
215
+ updated_at INTEGER NOT NULL
216
+ );
209
217
  `);
210
218
  this.ensureMessagesRunMetaColumn();
211
219
  this.ensureChatStateClearBoundaryColumn();
@@ -1327,6 +1335,41 @@ export class Db {
1327
1335
  return res.changes > 0;
1328
1336
  }
1329
1337
 
1338
+ // --- Project Config (global, not tied to any space) ---
1339
+
1340
+ getProjectConfig(key: string): string | null {
1341
+ const row = this.db
1342
+ .query("SELECT value FROM project_config WHERE key = ?")
1343
+ .get(key) as { value: string } | null;
1344
+ return row?.value ?? null;
1345
+ }
1346
+
1347
+ getProjectConfigUpdatedBy(key: string): string | null {
1348
+ const row = this.db
1349
+ .query("SELECT updated_by FROM project_config WHERE key = ?")
1350
+ .get(key) as { updated_by: string } | null;
1351
+ return row?.updated_by ?? null;
1352
+ }
1353
+
1354
+ setProjectConfig(key: string, value: string, updatedBy: string): void {
1355
+ const now = Date.now();
1356
+ this.db
1357
+ .query(
1358
+ `INSERT INTO project_config(key, value, updated_by, created_at, updated_at)
1359
+ VALUES (?, ?, ?, ?, ?)
1360
+ ON CONFLICT(key)
1361
+ DO UPDATE SET value = excluded.value, updated_by = excluded.updated_by, updated_at = excluded.updated_at`,
1362
+ )
1363
+ .run(key, value, updatedBy, now, now);
1364
+ }
1365
+
1366
+ deleteProjectConfig(key: string): boolean {
1367
+ const res = this.db
1368
+ .query("DELETE FROM project_config WHERE key = ?")
1369
+ .run(key);
1370
+ return res.changes > 0;
1371
+ }
1372
+
1330
1373
  // --- Space preferences (chat-managed) ---
1331
1374
 
1332
1375
  getSpacePreference(spaceId: string, key: string): string | null {