mercury-agent 0.4.6 → 0.4.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mercury-agent",
3
- "version": "0.4.6",
3
+ "version": "0.4.7",
4
4
  "description": "Personal AI assistant for chat platforms (WhatsApp, Slack, Discord, Telegram)",
5
5
  "license": "MIT",
6
6
  "author": "Avishai Tsabari",
@@ -147,7 +147,7 @@ export class WhatsAppBaileysAdapter
147
147
  private readonly outgoingQueue: Array<{ jid: string; text: string }> = [];
148
148
  private flushing = false;
149
149
  private connectedAtMs = 0;
150
- private readonly seenMessageIds = new Set<string>();
150
+ private seenMessageIds = new Set<string>();
151
151
  private reconnectAttempt = 0;
152
152
  private readonly pushNames = new Map<string, string>();
153
153
  private currentQr: string | null = null;
@@ -474,7 +474,10 @@ export class WhatsAppBaileysAdapter
474
474
  if (messageId) {
475
475
  if (this.seenMessageIds.has(messageId)) return;
476
476
  this.seenMessageIds.add(messageId);
477
- if (this.seenMessageIds.size > 5000) this.seenMessageIds.clear();
477
+ if (this.seenMessageIds.size > 5000) {
478
+ const ids = [...this.seenMessageIds];
479
+ this.seenMessageIds = new Set(ids.slice(ids.length - 2500));
480
+ }
478
481
  }
479
482
 
480
483
  const tsMs = Number(msg.messageTimestamp ?? 0) * 1000;
@@ -62,18 +62,25 @@ export class DiscordBridge implements PlatformBridge {
62
62
  const inboxDir = path.join(workspace, "inbox");
63
63
  for (const att of rawAttachments) {
64
64
  if (!att.url) continue;
65
- const type = mimeToMediaType(
66
- att.mimeType || "application/octet-stream",
67
- );
68
- const result = await downloadMediaFromUrl(att.url, {
69
- type,
70
- mimeType: att.mimeType || "application/octet-stream",
71
- filename: att.name,
72
- expectedSizeBytes: att.size,
73
- maxSizeBytes: ctx.media.maxSizeBytes,
74
- outputDir: inboxDir,
75
- });
76
- if (result) attachments.push(result);
65
+ try {
66
+ const type = mimeToMediaType(
67
+ att.mimeType || "application/octet-stream",
68
+ );
69
+ const result = await downloadMediaFromUrl(att.url, {
70
+ type,
71
+ mimeType: att.mimeType || "application/octet-stream",
72
+ filename: att.name,
73
+ expectedSizeBytes: att.size,
74
+ maxSizeBytes: ctx.media.maxSizeBytes,
75
+ outputDir: inboxDir,
76
+ });
77
+ if (result) attachments.push(result);
78
+ } catch (err) {
79
+ logger.warn("Discord attachment download failed", {
80
+ filename: att.name,
81
+ error: err instanceof Error ? err.message : String(err),
82
+ });
83
+ }
77
84
  }
78
85
  }
79
86
  }
@@ -133,12 +133,14 @@ export class SlackBridge implements PlatformBridge {
133
133
  ): Promise<void> {
134
134
  const parts = threadId.split(":");
135
135
  const channelId = parts.length >= 2 ? parts[1] : threadId;
136
+ const threadTs = parts.length >= 3 ? parts[2] : undefined;
136
137
 
137
138
  for (const file of files) {
138
139
  try {
139
140
  const buffer = fs.readFileSync(file.path);
140
141
  const form = new FormData();
141
142
  form.append("channel_id", channelId);
143
+ if (threadTs) form.append("thread_ts", threadTs);
142
144
  form.append("filename", file.filename);
143
145
  form.append(
144
146
  "file",
@@ -73,9 +73,11 @@ export class TeamsBridge implements PlatformBridge {
73
73
 
74
74
  const { externalId, isDM } = this.parseThread(threadId);
75
75
 
76
- // Check reply-to-bot via raw activity
76
+ // Reply-to-bot detection: in DMs every reply is to the bot;
77
+ // in channels we cannot reliably determine the target, so default to false
78
+ // to avoid responding to conversations not directed at us.
77
79
  const raw = msg.raw as { replyToId?: string; id?: string } | undefined;
78
- const isReplyToBot = Boolean(raw?.replyToId);
80
+ const isReplyToBot = isDM && Boolean(raw?.replyToId);
79
81
 
80
82
  return {
81
83
  platform: "teams",
@@ -479,13 +479,21 @@ export class TelegramBridge implements PlatformBridge {
479
479
  const { chatId } = this.parseThreadId(threadId);
480
480
  const apiUrl = `${TELEGRAM_API_BASE}/bot${this.botToken}/editMessageText`;
481
481
  try {
482
+ let formatted: string;
483
+ try {
484
+ formatted = markdownToTelegramHtml(text);
485
+ } catch {
486
+ formatted = escapeHtml(text);
487
+ }
488
+ const truncated = truncateTelegramHtml(formatted, TELEGRAM_MESSAGE_LIMIT);
482
489
  const resp = await fetch(apiUrl, {
483
490
  method: "POST",
484
491
  headers: { "Content-Type": "application/json" },
485
492
  body: JSON.stringify({
486
493
  chat_id: chatId,
487
494
  message_id: Number(messageId),
488
- text: applyRtlDirection(normalizeChatMarkdown(text)),
495
+ text: applyRtlDirection(truncated),
496
+ parse_mode: "HTML",
489
497
  }),
490
498
  });
491
499
  if (!resp.ok) return false;
@@ -70,7 +70,14 @@ function loadEnvFile(envPath: string): Record<string, string> {
70
70
  if (!trimmed || trimmed.startsWith("#")) continue;
71
71
  const match = trimmed.match(/^([^=]+)=(.*)$/);
72
72
  if (match) {
73
- vars[match[1]] = match[2];
73
+ let value = match[2];
74
+ if (
75
+ (value.startsWith('"') && value.endsWith('"')) ||
76
+ (value.startsWith("'") && value.endsWith("'"))
77
+ ) {
78
+ value = value.slice(1, -1);
79
+ }
80
+ vars[match[1]] = value;
74
81
  }
75
82
  }
76
83
  return vars;
@@ -266,16 +273,14 @@ function statusAction(): void {
266
273
  `Configuration: ${hasEnv ? "✓ .env exists" : "✗ .env missing (run 'mercury init')"}`,
267
274
  );
268
275
 
269
- const imageCheck = spawnSync(
270
- "docker",
271
- ["image", "inspect", "mercury-agent:latest"],
272
- {
273
- stdio: "pipe",
274
- },
275
- );
276
+ const cfg = loadConfig();
277
+ const imageName = cfg.agentContainerImage;
278
+ const imageCheck = spawnSync("docker", ["image", "inspect", imageName], {
279
+ stdio: "pipe",
280
+ });
276
281
  const hasImage = imageCheck.status === 0;
277
282
  console.log(
278
- `Container image: ${hasImage ? "✓ mercury-agent:latest" : "✗ not built (run 'mercury build')"}`,
283
+ `Container image: ${hasImage ? `✓ ${imageName}` : `✗ not available (run 'mercury build' or pull '${imageName}')`}`,
279
284
  );
280
285
 
281
286
  if (hasEnv) {
@@ -1747,7 +1752,7 @@ async function addAction(source: string): Promise<void> {
1747
1752
  }
1748
1753
 
1749
1754
  console.log("\nRestart mercury to activate:");
1750
- console.log(" mercury service restart");
1755
+ console.log(" mercury service uninstall && mercury service install");
1751
1756
  } finally {
1752
1757
  cleanup();
1753
1758
  }
@@ -1762,7 +1767,7 @@ function removeAction(name: string): void {
1762
1767
 
1763
1768
  console.log(`✓ Extension "${name}" removed`);
1764
1769
  console.log("\nRestart mercury to apply:");
1765
- console.log(" mercury service restart");
1770
+ console.log(" mercury service uninstall && mercury service install");
1766
1771
  }
1767
1772
 
1768
1773
  function extensionsListAction(): void {
@@ -30,6 +30,10 @@ const BUILT_IN_PERMISSIONS = new Set([
30
30
  "media.purge",
31
31
  /** Host Text-to-Speech (/api/tts); admin-only by default. */
32
32
  "tts.synthesize",
33
+ /** Mute/unmute users and list mutes; admin-only by default. */
34
+ "mutes.list",
35
+ "mutes.mute",
36
+ "mutes.unmute",
33
37
  ]);
34
38
 
35
39
  // ---------------------------------------------------------------------------
@@ -106,7 +106,7 @@ export function routeInput(input: {
106
106
  .split(/\s+/);
107
107
  const category = rawCategory.toLowerCase();
108
108
  const verb = rawVerb?.toLowerCase() || undefined;
109
- const arg = argParts.join(" ").trim().toLowerCase() || undefined;
109
+ const arg = argParts.join(" ").trim() || undefined;
110
110
  if (SLASH_COMMANDS.some((c) => c.name === category)) {
111
111
  return gateSlashCommand(
112
112
  input.db,
@@ -62,9 +62,16 @@ export function createChatRoute(core: MercuryCoreRuntime): Hono {
62
62
  const authorName =
63
63
  typeof body.authorName === "string" ? body.authorName.trim() : undefined;
64
64
 
65
+ if (!core.db.getSpace(spaceId)) {
66
+ return c.json({ error: "Space not found" }, 404);
67
+ }
68
+
65
69
  // Save incoming files to inbox/
66
70
  const attachments: MessageAttachment[] = [];
67
71
  if (Array.isArray(body.files)) {
72
+ if (body.files.length > 20) {
73
+ return c.json({ error: "Too many files (max 20)" }, 400);
74
+ }
68
75
  if (await isOverQuota(core.config)) {
69
76
  return c.json({ error: "Storage quota exceeded" }, 413);
70
77
  }
@@ -100,10 +107,6 @@ export function createChatRoute(core: MercuryCoreRuntime): Hono {
100
107
  }
101
108
  }
102
109
 
103
- if (!core.db.getSpace(spaceId)) {
104
- return c.json({ error: "Space not found" }, 404);
105
- }
106
-
107
110
  if (authenticated) {
108
111
  core.db.seedAdmins(spaceId, [callerId]);
109
112
  }
@@ -22,7 +22,7 @@ messages.get("/search", (c) => {
22
22
  if (!Number.isFinite(n) || n < 1) {
23
23
  return c.json({ error: "Invalid limit" }, 400);
24
24
  }
25
- limit = n;
25
+ limit = Math.min(n, 200);
26
26
  }
27
27
 
28
28
  const found = db.searchMessages(spaceId, q, limit);
@@ -1,6 +1,6 @@
1
1
  import { Hono } from "hono";
2
2
  import type { Env } from "../api-types.js";
3
- import { getApiCtx, getAuth } from "../api-types.js";
3
+ import { checkPerm, getApiCtx, getAuth } from "../api-types.js";
4
4
  import { parseMuteDuration } from "../mute-duration.js";
5
5
 
6
6
  export const mutes = new Hono<Env>();
@@ -8,6 +8,8 @@ export const mutes = new Hono<Env>();
8
8
  // ─── List mutes ─────────────────────────────────────────────────────────
9
9
 
10
10
  mutes.get("/", (c) => {
11
+ const denied = checkPerm(c, "mutes.list");
12
+ if (denied) return denied;
11
13
  const { spaceId } = getAuth(c);
12
14
  const { db } = getApiCtx(c);
13
15
  return c.json({ mutes: db.listMutes(spaceId) });
@@ -16,6 +18,8 @@ mutes.get("/", (c) => {
16
18
  // ─── Mute a user ────────────────────────────────────────────────────────
17
19
 
18
20
  mutes.post("/", async (c) => {
21
+ const denied = checkPerm(c, "mutes.mute");
22
+ if (denied) return denied;
19
23
  const { spaceId, callerId } = getAuth(c);
20
24
  const { db } = getApiCtx(c);
21
25
  const body = await c.req.json<{
@@ -76,6 +80,8 @@ mutes.post("/", async (c) => {
76
80
  // ─── Unmute a user ──────────────────────────────────────────────────────
77
81
 
78
82
  mutes.delete("/:userId", (c) => {
83
+ const denied = checkPerm(c, "mutes.unmute");
84
+ if (denied) return denied;
79
85
  const { spaceId } = getAuth(c);
80
86
  const { db } = getApiCtx(c);
81
87
  const targetUserId = decodeURIComponent(c.req.param("userId"));
@@ -33,6 +33,16 @@ roles.post("/", async (c) => {
33
33
  }
34
34
 
35
35
  const targetRole = body.role ?? "admin";
36
+ const VALID_ROLES = /^[a-z][a-z0-9_-]{0,31}$/;
37
+ if (!VALID_ROLES.test(targetRole)) {
38
+ return c.json(
39
+ {
40
+ error:
41
+ "Invalid role name. Use lowercase alphanumeric, hyphens, underscores (max 32 chars).",
42
+ },
43
+ 400,
44
+ );
45
+ }
36
46
  db.setRole(spaceId, body.platformUserId, targetRole, callerId);
37
47
 
38
48
  return c.json({
@@ -101,10 +101,17 @@ export class TaskScheduler {
101
101
  let updated = 0;
102
102
  for (const task of tasks) {
103
103
  if (!task.active || !task.cron) continue;
104
- const correct = this.computeNextRun(
105
- task.cron,
106
- task.timezone ?? undefined,
107
- );
104
+ let correct: number;
105
+ try {
106
+ correct = this.computeNextRun(task.cron, task.timezone ?? undefined);
107
+ } catch (err) {
108
+ logger.warn("Skipping task with invalid cron expression", {
109
+ taskId: task.id,
110
+ cron: task.cron,
111
+ error: err instanceof Error ? err.message : String(err),
112
+ });
113
+ continue;
114
+ }
108
115
  if (correct !== task.nextRunAt) {
109
116
  this.db.updateTaskNextRun(task.id, correct);
110
117
  updated++;
@@ -86,6 +86,11 @@ export class HookDispatcher {
86
86
  this.log.error(
87
87
  `Hook "before_container" handler failed: ${err instanceof Error ? err.message : String(err)}`,
88
88
  );
89
+ return {
90
+ block: {
91
+ reason: `Extension hook failed: ${err instanceof Error ? err.message : String(err)}`,
92
+ },
93
+ };
89
94
  }
90
95
  }
91
96
 
@@ -242,7 +242,7 @@ async function loadExtension(
242
242
  }
243
243
 
244
244
  const api = new MercuryExtensionAPIImpl(name, extDir, db);
245
- setup(api);
245
+ await setup(api);
246
246
  const meta = api.getMeta();
247
247
 
248
248
  if (meta.connection) {
package/src/storage/db.ts CHANGED
@@ -55,6 +55,7 @@ export class Db {
55
55
  this.db = new Database(dbPath, { create: true });
56
56
  this.db.exec("PRAGMA journal_mode = WAL;");
57
57
  this.db.exec("PRAGMA foreign_keys = ON;");
58
+ this.db.exec("PRAGMA busy_timeout = 5000;");
58
59
  this.migrate();
59
60
  }
60
61