mercury-agent 0.4.5 → 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.
@@ -3,6 +3,6 @@
3
3
  "private": true,
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@mariozechner/pi-coding-agent": "^0.67.2"
6
+ "@earendil-works/pi-coding-agent": "~0.79.6"
7
7
  }
8
8
  }
@@ -228,7 +228,7 @@ You can use custom Docker images via `MERCURY_AGENT_IMAGE`.
228
228
 
229
229
  Your image **must** have:
230
230
  - `bun` runtime
231
- - `pi` CLI (`@mariozechner/pi-coding-agent`)
231
+ - `pi` CLI (`@earendil-works/pi-coding-agent`)
232
232
  - `bubblewrap` (for agent sandboxing)
233
233
  - `mrctl` wrapper (copied during build)
234
234
  Extension CLIs (e.g. `pinchtab`, `napkin`, `gws`) are installed in derived images at runtime based on `.mercury/extensions/*` declarations.
@@ -279,7 +279,7 @@ RUN curl -fsSL https://bun.sh/install | bash
279
279
  ENV PATH="/home/mercury/.bun/bin:$PATH"
280
280
 
281
281
  # Install required CLIs
282
- RUN bun add -g @mariozechner/pi-coding-agent
282
+ RUN bun add -g @earendil-works/pi-coding-agent
283
283
 
284
284
  # Optional: install Playwright/Chromium if your extensions need browser automation
285
285
  RUN bunx playwright install chromium
@@ -17,7 +17,7 @@
17
17
  * directly and blocks them with a message to use `mrctl` instead.
18
18
  */
19
19
 
20
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
20
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
21
21
 
22
22
  export default function (pi: ExtensionAPI) {
23
23
  const extClisEnv = process.env.MERCURY_EXT_CLIS;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mercury-agent",
3
- "version": "0.4.5",
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",
@@ -64,9 +64,9 @@
64
64
  "@chat-adapter/slack": "^4.14.0",
65
65
  "@chat-adapter/teams": "^4.17.0",
66
66
  "@chat-adapter/telegram": "^4.26.0",
67
- "@mariozechner/pi-agent-core": "^0.65.2",
68
- "@mariozechner/pi-ai": "^0.67.6",
69
- "@mariozechner/pi-coding-agent": "^0.65.2",
67
+ "@earendil-works/pi-agent-core": "~0.79.6",
68
+ "@earendil-works/pi-ai": "~0.79.10",
69
+ "@earendil-works/pi-coding-agent": "~0.79.6",
70
70
  "@whiskeysockets/baileys": "^7.0.0-rc.9",
71
71
  "axios": "^1.15.1",
72
72
  "chat": "^4.14.0",
@@ -4,7 +4,7 @@
4
4
 
5
5
  import * as fs from "node:fs";
6
6
  import * as path from "node:path";
7
- import { getAgentDir, parseFrontmatter } from "@mariozechner/pi-coding-agent";
7
+ import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
8
8
 
9
9
  export type AgentScope = "user" | "project" | "both";
10
10
 
@@ -16,11 +16,11 @@ import { spawn } from "node:child_process";
16
16
  import * as fs from "node:fs";
17
17
  import * as os from "node:os";
18
18
  import * as path from "node:path";
19
- import type { AgentToolResult } from "@mariozechner/pi-agent-core";
20
- import type { Message } from "@mariozechner/pi-ai";
21
- import { StringEnum } from "@mariozechner/pi-ai";
22
- import { type ExtensionAPI, getMarkdownTheme } from "@mariozechner/pi-coding-agent";
23
- import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
19
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
20
+ import type { Message } from "@earendil-works/pi-ai";
21
+ import { StringEnum } from "@earendil-works/pi-ai";
22
+ import { type ExtensionAPI, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
23
+ import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
24
24
  import { Type } from "@sinclair/typebox";
25
25
  import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.js";
26
26
 
@@ -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;
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { existsSync, readFileSync } from "node:fs";
6
6
  import path from "node:path";
7
- import { getModels, type KnownProvider } from "@mariozechner/pi-ai";
7
+ import { getModels, type KnownProvider } from "@earendil-works/pi-ai";
8
8
  import { parse as parseYaml } from "yaml";
9
9
  import { z } from "zod";
10
10
  import type { ModelLeg } from "../config.js";
@@ -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) {
@@ -820,7 +825,7 @@ authCommand
820
825
  )
821
826
  .action(async (providerArg?: string) => {
822
827
  const { getOAuthProviders, getOAuthProvider } = await import(
823
- "@mariozechner/pi-ai/oauth"
828
+ "@earendil-works/pi-ai/oauth"
824
829
  );
825
830
  const readline = await import("node:readline");
826
831
  const { exec } = await import("node:child_process");
@@ -913,6 +918,12 @@ authCommand
913
918
  : "xdg-open";
914
919
  exec(`${openCmd} "${info.url}"`);
915
920
  },
921
+ onDeviceCode: (info: { userCode: string; verificationUri: string }) => {
922
+ console.log(
923
+ `\nOpen this URL in your browser:\n ${info.verificationUri}`,
924
+ );
925
+ console.log(`Enter code: ${info.userCode}\n`);
926
+ },
916
927
  onPrompt: async (prompt: { message: string; placeholder?: string }) => {
917
928
  const answer = await new Promise<string>((resolve) => {
918
929
  rl.question(
@@ -922,6 +933,23 @@ authCommand
922
933
  });
923
934
  return answer;
924
935
  },
936
+ onSelect: async (prompt: {
937
+ message: string;
938
+ options: Array<{ id: string; label: string }>;
939
+ }) => {
940
+ console.log(`\n${prompt.message}`);
941
+ for (let i = 0; i < prompt.options.length; i++) {
942
+ console.log(` ${i + 1}. ${prompt.options[i].label}`);
943
+ }
944
+ const answer = await new Promise<string>((resolve) => {
945
+ rl.question("Choose (number): ", resolve);
946
+ });
947
+ const idx = Number.parseInt(answer, 10) - 1;
948
+ if (idx >= 0 && idx < prompt.options.length) {
949
+ return prompt.options[idx].id;
950
+ }
951
+ return undefined;
952
+ },
925
953
  onProgress: (message: string) => {
926
954
  console.log(message);
927
955
  },
@@ -999,7 +1027,7 @@ authCommand
999
1027
  .command("status")
1000
1028
  .description("Show authentication status for all providers")
1001
1029
  .action(async () => {
1002
- const { getOAuthProviders } = await import("@mariozechner/pi-ai/oauth");
1030
+ const { getOAuthProviders } = await import("@earendil-works/pi-ai/oauth");
1003
1031
 
1004
1032
  const dataDir = getProjectDataDir(CWD);
1005
1033
  const authPath = join(CWD, dataDir, "global", "auth.json");
@@ -1724,7 +1752,7 @@ async function addAction(source: string): Promise<void> {
1724
1752
  }
1725
1753
 
1726
1754
  console.log("\nRestart mercury to activate:");
1727
- console.log(" mercury service restart");
1755
+ console.log(" mercury service uninstall && mercury service install");
1728
1756
  } finally {
1729
1757
  cleanup();
1730
1758
  }
@@ -1739,7 +1767,7 @@ function removeAction(name: string): void {
1739
1767
 
1740
1768
  console.log(`✓ Extension "${name}" removed`);
1741
1769
  console.log("\nRestart mercury to apply:");
1742
- console.log(" mercury service restart");
1770
+ console.log(" mercury service uninstall && mercury service install");
1743
1771
  }
1744
1772
 
1745
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) {
@@ -11,7 +11,7 @@
11
11
  * Set automatically by Mercury's runtime based on caller permissions.
12
12
  */
13
13
 
14
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
14
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
15
 
16
16
  export default function (pi: ExtensionAPI) {
17
17
  const deniedEnv = process.env.MERCURY_DENIED_CLIS;
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
 
@@ -4,7 +4,7 @@ import {
4
4
  getOAuthApiKey,
5
5
  type OAuthCredentials,
6
6
  type OAuthProviderId,
7
- } from "@mariozechner/pi-ai/oauth";
7
+ } from "@earendil-works/pi-ai/oauth";
8
8
  import { logger } from "../logger.js";
9
9
 
10
10
  type AuthEntry =