mercury-agent 0.5.18 → 0.5.19

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,21 @@ 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
+
448
463
  ### Per-space Config
449
464
 
450
465
  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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mercury-agent",
3
- "version": "0.5.18",
3
+ "version": "0.5.19",
4
4
  "description": "Personal AI assistant for chat platforms (WhatsApp, Slack, Discord, Telegram)",
5
5
  "license": "MIT",
6
6
  "author": "Avishai Tsabari",
@@ -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,6 +4,7 @@ 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,
8
9
  config,
9
10
  connections,
@@ -111,6 +112,7 @@ export function createApiApp(apiCtx: ApiContext): Hono<Env> {
111
112
  app.route("/tradestation", tradestation);
112
113
  app.route("/tts", tts);
113
114
  app.route("/capability", capability);
115
+ app.route("/broadcast", broadcast);
114
116
 
115
117
  // ─── Fallback ───────────────────────────────────────────────────────────
116
118
 
@@ -0,0 +1,78 @@
1
+ import { Hono } from "hono";
2
+ import { logger } from "../../logger.js";
3
+ import { type Env, getApiCtx, getAuth } from "../api-types.js";
4
+
5
+ export const broadcast = new Hono<Env>();
6
+
7
+ broadcast.post("/", async (c) => {
8
+ const { callerId } = getAuth(c);
9
+ const { config, runtime } = getApiCtx(c);
10
+
11
+ if (!config.dmAutoSpaceEnabled) {
12
+ return c.json({ error: "dm_auto_space is not enabled" }, 503);
13
+ }
14
+
15
+ const globalAdmins = [
16
+ ...(config.admins
17
+ ? config.admins
18
+ .split(",")
19
+ .map((s) => s.trim())
20
+ .filter(Boolean)
21
+ : []),
22
+ ...(config.dmAutoSpaceAdminIds
23
+ ? config.dmAutoSpaceAdminIds
24
+ .split(",")
25
+ .map((s) => s.trim())
26
+ .filter(Boolean)
27
+ : []),
28
+ ];
29
+
30
+ const normalize = (s: string) =>
31
+ s
32
+ .replace(/^[^:]+:/, "")
33
+ .replace(/^[+]+/, "")
34
+ .replace(/@.*$/, "");
35
+
36
+ const callerNormalized = normalize(callerId);
37
+
38
+ const isAdmin = globalAdmins.some(
39
+ (id) => id === callerId || normalize(id) === callerNormalized,
40
+ );
41
+
42
+ if (!isAdmin) {
43
+ logger.warn("Broadcast denied — caller is not a global admin", {
44
+ callerId,
45
+ });
46
+ return c.json({ error: "Forbidden: requires global admin" }, 403);
47
+ }
48
+
49
+ if (!runtime) {
50
+ return c.json({ error: "Runtime not available" }, 503);
51
+ }
52
+
53
+ const body = (await c.req.json().catch(() => ({}))) as { text?: string };
54
+ const text = typeof body.text === "string" ? body.text.trim() : "";
55
+
56
+ if (!text) {
57
+ return c.json({ error: "Missing or empty 'text' field" }, 400);
58
+ }
59
+
60
+ if (text.length > 4096) {
61
+ return c.json({ error: "Text exceeds 4096 character limit" }, 400);
62
+ }
63
+
64
+ try {
65
+ const result = await runtime.broadcastToAutoSpaces(text);
66
+ return c.json(result);
67
+ } catch (err) {
68
+ const message = err instanceof Error ? err.message : String(err);
69
+ if (message === "Broadcast already in progress") {
70
+ return c.json({ error: message }, 409);
71
+ }
72
+ if (message === "MessageSender not initialized") {
73
+ return c.json({ error: "Message sender not ready" }, 503);
74
+ }
75
+ logger.error("Broadcast failed", { error: message });
76
+ return c.json({ error: "Broadcast failed" }, 500);
77
+ }
78
+ });
@@ -1,3 +1,4 @@
1
+ export { broadcast } from "./broadcast.js";
1
2
  export { capability } from "./capability.js";
2
3
  export { config } from "./config.js";
3
4
  export { connections } from "./connections.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;
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);