mercury-agent 0.9.0 → 0.10.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.
Files changed (38) hide show
  1. package/README.md +2 -0
  2. package/docs/configuration.md +9 -0
  3. package/docs/dashboard.md +1 -1
  4. package/docs/pipeline.md +8 -2
  5. package/examples/extensions/docs/index.ts +28 -0
  6. package/examples/extensions/docs/skill/SKILL.md +114 -0
  7. package/examples/extensions/pinchtab/index.ts +26 -3
  8. package/examples/extensions/pinchtab/skill/SKILL.md +21 -9
  9. package/package.json +1 -1
  10. package/resources/skills/spaces/SKILL.md +21 -1
  11. package/src/adapters/whatsapp-media.ts +31 -1
  12. package/src/adapters/whatsapp.ts +67 -6
  13. package/src/agent/container-entry.ts +130 -8
  14. package/src/agent/container-runner.ts +11 -0
  15. package/src/bridges/whatsapp.ts +23 -2
  16. package/src/cli/mrctl.ts +18 -2
  17. package/src/config-file.ts +8 -3
  18. package/src/config.ts +10 -0
  19. package/src/core/attachment-notes.ts +44 -0
  20. package/src/core/commands.ts +33 -8
  21. package/src/core/confirmation.ts +42 -0
  22. package/src/core/debounce.ts +9 -0
  23. package/src/core/handler.ts +41 -4
  24. package/src/core/permissions.ts +44 -2
  25. package/src/core/router.ts +72 -4
  26. package/src/core/routes/chat.ts +26 -1
  27. package/src/core/routes/config-builtin.ts +7 -0
  28. package/src/core/routes/dashboard.ts +29 -8
  29. package/src/core/routes/spaces.ts +31 -1
  30. package/src/core/routes/tasks.ts +11 -5
  31. package/src/core/runtime.ts +176 -47
  32. package/src/core/storage-cleanup.ts +17 -2
  33. package/src/core/task-scheduler.ts +115 -41
  34. package/src/core/trigger.ts +12 -0
  35. package/src/dashboard/index.html +64 -0
  36. package/src/main.ts +4 -0
  37. package/src/storage/db.ts +149 -43
  38. package/src/types.ts +44 -0
package/README.md CHANGED
@@ -422,6 +422,8 @@ Supported OAuth providers: Anthropic, GitHub Copilot, Google Gemini CLI, Antigra
422
422
  | `MERCURY_TRIGGER_MATCH` | `mention` | `mention`, `prefix`, `always` |
423
423
  | `MERCURY_TRIGGER_PATTERNS` | `@Mercury,Mercury` | Trigger patterns |
424
424
  | `MERCURY_ADMINS` | — | Pre-seeded admin user IDs |
425
+ | `MERCURY_AMBIENT_TTL_DAYS` | `14` | Days before overheard (ambient) group messages are aged out |
426
+ | `MERCURY_AMBIENT_CONTEXT_ROWS` | `30` | Max ambient rows injected into a single prompt |
425
427
 
426
428
  ### DM Auto-Space
427
429
 
@@ -39,6 +39,15 @@ context:
39
39
 
40
40
  Per-space overrides via `mrctl config set context.<key> <value>` always win over YAML defaults; YAML re-reads on restart do not overwrite an existing space row.
41
41
 
42
+ ## Ambient group context
43
+
44
+ In linked group chats, messages that don't trigger the bot are stored as **ambient context** (author-attributed) so it can answer questions about conversation it overheard. Every message in a linked group becomes a row, bounded on both ends:
45
+
46
+ - **`MERCURY_AMBIENT_TTL_DAYS`** (default `14`) — days before ambient rows are aged out by the storage cleanup. Real user/assistant turns are never touched.
47
+ - **`MERCURY_AMBIENT_CONTEXT_ROWS`** (default `30`) — max ambient rows injected into a single prompt. Ambient has its own budget, so overheard chatter can't crowd real turns out of the context window.
48
+
49
+ Per-space: `mrctl config set ambient.enabled false` disables capture entirely (tag-only mode).
50
+
42
51
  You may also set a top-level **`model_chain`** array as an alias for `model.chain`.
43
52
 
44
53
  ## Container env passthrough (`agent.env_passthrough`)
package/docs/dashboard.md CHANGED
@@ -29,7 +29,7 @@ Lists all memory spaces with platform badges, conversation count, message count,
29
29
  | **Conversations** | View linked conversations, unlink them |
30
30
  | **Roles** | Promote members to admin, demote admins |
31
31
  | **Mutes** | Mute/unmute users with optional duration and reason |
32
- | **Triggers & Ambient** | Set `trigger.match` (mention/prefix/always), patterns, case sensitivity, media-in-groups, and ambient mode |
32
+ | **Triggers & Ambient** | Set `trigger.match` (mention/prefix/always), patterns, case sensitivity, media-in-groups, `trigger.mention_always` (verified @-mentions trigger regardless of patterns), and ambient mode |
33
33
  | **Context** | Choose context mode (clear/context), window size, reply-chain depth |
34
34
  | **Rate limits** | Set burst-per-minute and daily caps for members and admins |
35
35
  | **Voice** | Configure per-space STT (transcription preset or custom model) and TTS (on-demand vs auto) |
package/docs/pipeline.md CHANGED
@@ -20,7 +20,8 @@ Platform (WhatsApp / Discord / Telegram / Slack)
20
20
  ├─► Unified handler (src/core/handler.ts)
21
21
  │ • Parse platform thread into an external conversation ID
22
22
  │ • Resolve/create conversation in DB
23
- │ • Ignore unlinked conversations
23
+ │ • Ignore unlinked conversations (seeded admins may `/spaces list`
24
+ │ and `/spaces switch <id>` to re-link; everything else is dropped)
24
25
  │ • Pre-route trigger check (cheap, sync)
25
26
  │ • Start typing indicator if matched
26
27
  │ • Call bridge.normalize(..., spaceId) → IngressMessage
@@ -29,7 +30,8 @@ Platform (WhatsApp / Discord / Telegram / Slack)
29
30
  ├─► core.handleRawInput(IngressMessage)
30
31
  │ • Route: trigger match, permissions, command detection
31
32
  │ • If triggered → queue → container run → ContainerResult
32
- │ • If not triggered → store as ambient context
33
+ │ • If not triggered → store as ambient context (author-attributed,
34
+ │ aged out after `ambientTtlDays`)
33
35
  │ • If command → execute immediately (stop, compact)
34
36
  │ • If denied → return reason
35
37
 
@@ -196,6 +198,10 @@ All platforms share the same trigger engine. A pre-route check runs before `norm
196
198
 
197
199
  DMs always match regardless of mode.
198
200
 
201
+ **Verified @-mentions always trigger.** When a platform confirms the bot was tagged (matched on the mention's JID, not on text), the message triggers regardless of `trigger.patterns` — a space that defines its own patterns replaces the default list, which used to silently drop the auto-injected `@<botUsername>` and ignore genuine tags. Set per-space `trigger.mention_always=false` to require the trigger word even when tagged. Wired for WhatsApp; Discord's structured mention is not yet carried through.
202
+
203
+ **Slash commands bypass the trigger gate.** A `/`-prefixed message naming a real command (e.g. `/spaces list`, `/stop`) executes untagged in groups. Unknown slash words (`/shrug`) still fall through to ambient.
204
+
199
205
  **Attachment-only in groups** (voice note, image with no caption): by default these do **not** match `mention` or `prefix` — use `trigger.match=always`, reply to the bot’s message, or set per-space `trigger.media_in_groups=true` so voice/media alone can trigger without spamming text-only noise.
200
206
 
201
207
  ### Reply-to-Bot
@@ -0,0 +1,28 @@
1
+ export default function (mercury: {
2
+ cli(opts: { name: string; install: string }): void;
3
+ skill(relativePath: string): void;
4
+ permission(opts: { defaultRoles: string[] }): void;
5
+ requires(
6
+ capabilities: (
7
+ | "tools"
8
+ | "vision"
9
+ | "audio_input"
10
+ | "audio_output"
11
+ | "extended_thinking"
12
+ )[],
13
+ ): void;
14
+ }) {
15
+ // NOTE: never put a backtick anywhere in this install string. It sits inside
16
+ // a template literal when the image builder assembles the Dockerfile, so a
17
+ // stray backtick terminates the literal and the whole extension stops
18
+ // loading — which surfaces in the dashboard as "uninstalled", not as a
19
+ // syntax error. Run "mercury extensions validate docs" after editing.
20
+ mercury.cli({
21
+ name: "docs",
22
+ install:
23
+ "apt-get update && apt-get install -y --no-install-recommends pandoc && python3 -m pip install --break-system-packages python-docx openpyxl odfpy && echo '#!/bin/sh' > /usr/local/bin/docs && echo 'echo \"docs extension dependencies installed. Use the docs skill from the agent.\"' >> /usr/local/bin/docs && chmod +x /usr/local/bin/docs && rm -rf /var/lib/apt/lists/*",
24
+ });
25
+ mercury.permission({ defaultRoles: ["admin", "member"] });
26
+ mercury.requires(["tools"]);
27
+ mercury.skill("./skill");
28
+ }
@@ -0,0 +1,114 @@
1
+ ---
2
+ name: docs
3
+ description: Use this skill whenever the user asks for a document, a Word file, a .docx, a spreadsheet, an .xlsx, an ODF file, or asks you to "write up", "put together", or "send me" something they will open in Word, Google Docs, Excel, or LibreOffice. Activate when document output is requested, not when merely discussing text.
4
+ ---
5
+
6
+ # Documents Skill
7
+
8
+ Produce real office files — `.docx`, `.xlsx`, `.odt` — and deliver them in chat.
9
+
10
+ Without this skill the honest answer to "make me a doc" is markdown in a chat
11
+ bubble, or an `.html` attachment the user has to convert themselves. Do not do
12
+ that any more. Produce the actual file.
13
+
14
+ ## Deliver by writing to `outbox/`
15
+
16
+ Anything written to `outbox/` is sent to the user as an attachment when the
17
+ turn ends. That is the whole delivery mechanism — there is nothing to call.
18
+
19
+ ```python
20
+ from docx import Document
21
+
22
+ doc = Document()
23
+ doc.add_heading("Trip plan", level=1)
24
+ doc.add_paragraph("Flights land Thursday morning.")
25
+ doc.save("outbox/trip-plan.docx")
26
+ ```
27
+
28
+ Use a filename the user would recognise. `outbox/trip-plan.docx` beats
29
+ `outbox/output.docx`.
30
+
31
+ ## Pick the right tool
32
+
33
+ | Ask | Use |
34
+ |---|---|
35
+ | A document, letter, report, notes | `python-docx` → `.docx` |
36
+ | Existing markdown you already wrote | `pandoc` → `.docx` |
37
+ | A table of numbers, a budget, a list to sort | `openpyxl` → `.xlsx` |
38
+ | LibreOffice/OpenOffice specifically | `odfpy` → `.odt` |
39
+
40
+ `.docx` is the default. It opens in Word, Google Docs, LibreOffice, and the
41
+ WhatsApp document viewer.
42
+
43
+ ### Markdown you already have → .docx
44
+
45
+ Fastest path when you have composed the content as markdown already:
46
+
47
+ ```bash
48
+ pandoc notes.md -o outbox/notes.docx
49
+ ```
50
+
51
+ Pandoc handles headings, lists, tables, bold/italic, and links. Reach for
52
+ `python-docx` instead when you need control it does not give you.
53
+
54
+ ### Structured document
55
+
56
+ ```python
57
+ from docx import Document
58
+ from docx.shared import Pt
59
+
60
+ doc = Document()
61
+ doc.add_heading("Quarterly summary", level=1)
62
+ doc.add_paragraph("Prepared for the team.")
63
+
64
+ doc.add_heading("Numbers", level=2)
65
+ table = doc.add_table(rows=1, cols=2)
66
+ table.style = "Light Grid Accent 1"
67
+ head = table.rows[0].cells
68
+ head[0].text, head[1].text = "Item", "Amount"
69
+ for item, amount in [("Flights", "1,200"), ("Hotel", "900")]:
70
+ row = table.add_row().cells
71
+ row[0].text, row[1].text = item, amount
72
+
73
+ doc.save("outbox/quarterly-summary.docx")
74
+ ```
75
+
76
+ ### Spreadsheet
77
+
78
+ ```python
79
+ from openpyxl import Workbook
80
+
81
+ wb = Workbook()
82
+ ws = wb.active
83
+ ws.title = "Budget"
84
+ ws.append(["Item", "Amount"])
85
+ for row in [("Flights", 1200), ("Hotel", 900)]:
86
+ ws.append(row)
87
+ wb.save("outbox/budget.xlsx")
88
+ ```
89
+
90
+ ## Right-to-left text
91
+
92
+ Hebrew and Arabic need the paragraph marked RTL, or Word renders the alignment
93
+ wrong even though the characters are correct:
94
+
95
+ ```python
96
+ from docx import Document
97
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
98
+
99
+ doc = Document()
100
+ p = doc.add_paragraph("שלום, זה מסמך לדוגמה")
101
+ p.alignment = WD_ALIGN_PARAGRAPH.RIGHT
102
+ doc.save("outbox/hebrew.docx")
103
+ ```
104
+
105
+ ## Rules
106
+
107
+ 1. **Never claim you produced a file you did not write.** Save it to `outbox/`,
108
+ confirm the file exists, and only then say you have sent it.
109
+ 2. **Do not hand over markdown or HTML as a substitute** for a requested
110
+ document and ask the user to convert it. Produce the real format.
111
+ 3. **Do not promise a Google Docs link.** You produce files, not Drive
112
+ documents. A `.docx` opens in Google Docs, so offer that instead.
113
+ 4. Keep documents short unless asked otherwise — these are usually read on a
114
+ phone.
@@ -8,7 +8,7 @@ export default function (mercury: {
8
8
  mercury.cli({
9
9
  name: "pinchtab",
10
10
  install:
11
- 'npm install -g pinchtab@0.13.2 playwright && npx playwright install --with-deps chromium && CHROMIUM=$(NODE_PATH="$(npm root -g)" node -e "try{process.stdout.write(require(\'playwright\').chromium.executablePath())}catch(e){}" 2>/dev/null) && { test -x "$CHROMIUM" || CHROMIUM=$(find /home/mercury/.cache/ms-playwright -type f -path \'*/chrome-linux/chrome\' ! -path \'*headless_shell*\' 2>/dev/null | head -1); } && test -n "$CHROMIUM" && test -x "$CHROMIUM" && ln -sf "$CHROMIUM" /usr/local/bin/chromium && ln -sf "$CHROMIUM" /usr/bin/chromium && rm -rf /var/lib/apt/lists/*',
11
+ 'npm install -g pinchtab@0.13.2 playwright && npx playwright install --with-deps chromium && CHROMIUM=$(NODE_PATH="$(npm root -g)" node -e "try{process.stdout.write(require(\'playwright\').chromium.executablePath())}catch(e){}" 2>/dev/null) && { test -x "$CHROMIUM" || CHROMIUM=$(find /home/mercury/.cache/ms-playwright -type f -path \'*/chrome-linux/chrome\' ! -path \'*headless_shell*\' 2>/dev/null | head -1); } && test -n "$CHROMIUM" && test -x "$CHROMIUM" && ln -sf "$CHROMIUM" /usr/local/bin/chromium && ln -sf "$CHROMIUM" /usr/bin/chromium && HOME=/home/mercury pinchtab config set security.allowedDomains "*" && chown -R mercury:mercury /home/mercury/.pinchtab && rm -rf /var/lib/apt/lists/*',
12
12
  });
13
13
  mercury.permission({ defaultRoles: ["admin", "member"] });
14
14
  mercury.skill("./skill");
@@ -23,6 +23,10 @@ export default function (mercury: {
23
23
  local log="\${PINCHTAB_LOG:-/tmp/pinchtab.log}"
24
24
  local max_wait="\${1:-120}"
25
25
  mkdir -p "$(dirname "$log")" 2>/dev/null || true
26
+ # Belt and braces alongside the container-level "--ulimit core=0": Chromium
27
+ # helper processes segfault under the sandbox and each dump is ~300 MB landing
28
+ # in the agent workspace. Navigation succeeds regardless.
29
+ ulimit -c 0 2>/dev/null || true
26
30
  : >"$log"
27
31
  if [ ! -x "\${CHROME_BINARY:-}" ]; then
28
32
  for _c in /usr/local/bin/chromium /usr/bin/chromium; do
@@ -33,12 +37,31 @@ export default function (mercury: {
33
37
  echo "No executable Chromium (CHROME_BINARY=\${CHROME_BINARY:-}; tried /usr/local/bin/chromium, /usr/bin/chromium). Rebuild mercury-agent-ext (restart Mercury)." | tee -a "$log"
34
38
  return 1
35
39
  fi
36
- _pinchtab_port_open() { (echo >/dev/tcp/$bind/$port) 2>/dev/null; }
40
+ # /dev/tcp is a bash builtin that dash does not implement. pi currently runs
41
+ # the agent shell as bash, so this is portability insurance rather than a live
42
+ # fix: under dash the redirect always fails and the caller would burn the full
43
+ # max_wait against an already-READY daemon. Any HTTP response, 404 included,
44
+ # proves the port is listening.
45
+ _pinchtab_port_open() {
46
+ if command -v curl >/dev/null 2>&1; then
47
+ curl -s -o /dev/null --max-time 2 "http://$bind:$port/" >/dev/null 2>&1
48
+ return $?
49
+ fi
50
+ (echo >/dev/tcp/$bind/$port) 2>/dev/null
51
+ }
37
52
  if command -v pinchtab >/dev/null 2>&1 && _pinchtab_port_open; then
38
53
  return 0
39
54
  fi
40
55
  pkill -f '[p]inchtab' 2>/dev/null || true
41
- nohup pinchtab >>"$log" 2>&1 &
56
+ # pinchtab >=0.13 ships IDPI enforcing with a loopback-only website
57
+ # whitelist, so every public URL is refused with 403 idpi_domain_blocked --
58
+ # including the Brave search this skill tells the agent to run. Widen the
59
+ # allowlist but keep IDPI strict mode and the content guard ON, so fetched
60
+ # pages still arrive wrapped in untrusted_web_content tags. Do NOT use
61
+ # "pinchtab security down" -- that turns the prompt-injection guard off too.
62
+ # NOTE: no backticks in this comment. It is inside a TS template literal.
63
+ pinchtab config set security.allowedDomains "*" >/dev/null 2>&1 || true
64
+ nohup pinchtab server >>"$log" 2>&1 &
42
65
  local pid=$!
43
66
  sleep 2
44
67
  if ! kill -0 "$pid" 2>/dev/null; then
@@ -17,7 +17,7 @@ The 30-second pattern for browser tasks:
17
17
 
18
18
  ```bash
19
19
  # 1. Start Pinchtab (runs forever, local on :9867)
20
- pinchtab &
20
+ pinchtab server &
21
21
 
22
22
  # 2. In your agent, follow this loop:
23
23
  # a) Navigate to a URL
@@ -31,7 +31,7 @@ pinchtab &
31
31
 
32
32
  ## Mercury / Docker (required)
33
33
 
34
- In the Mercury agent container, `pinchtab &` plus a short `sleep` often races the HTTP bridge: the CLI then hits `127.0.0.1:9867` before the daemon listens (`connection refused`). The host injects `CHROME_BINARY` and `CHROME_FLAGS` (`--no-sandbox` as root). **Always** wait until the port is open and capture daemon logs.
34
+ In the Mercury agent container, `pinchtab server &` plus a short `sleep` often races the HTTP bridge: the CLI then hits `127.0.0.1:9867` before the daemon listens (`connection refused`). The host injects `CHROME_BINARY` and `CHROME_FLAGS` (`--no-sandbox` as root). **Always** wait until the port is open and capture daemon logs.
35
35
 
36
36
  ```bash
37
37
  pinchtab_ensure() {
@@ -40,6 +40,9 @@ pinchtab_ensure() {
40
40
  local log="${PINCHTAB_LOG:-/tmp/pinchtab.log}"
41
41
  local max_wait="${1:-120}"
42
42
  mkdir -p "$(dirname "$log")" 2>/dev/null || true
43
+ # Chromium helper processes segfault under the container sandbox; each dump is
44
+ # ~300 MB landing in the agent workspace. Navigation succeeds regardless.
45
+ ulimit -c 0 2>/dev/null || true
43
46
  : >"$log"
44
47
  if [ ! -x "${CHROME_BINARY:-}" ]; then
45
48
  for _c in /usr/local/bin/chromium /usr/bin/chromium; do
@@ -50,12 +53,21 @@ pinchtab_ensure() {
50
53
  echo "No executable Chromium (CHROME_BINARY=${CHROME_BINARY:-}; tried /usr/local/bin/chromium, /usr/bin/chromium). Rebuild mercury-agent-ext (restart Mercury)." | tee -a "$log"
51
54
  return 1
52
55
  fi
53
- _pinchtab_port_open() { (echo >/dev/tcp/$bind/$port) 2>/dev/null; }
56
+ # /dev/tcp is bash-only. pi runs the agent shell as bash today, so this is
57
+ # portability insurance. Any HTTP response (404 included) proves the port is
58
+ # listening.
59
+ _pinchtab_port_open() {
60
+ if command -v curl >/dev/null 2>&1; then
61
+ curl -s -o /dev/null --max-time 2 "http://$bind:$port/" >/dev/null 2>&1
62
+ return $?
63
+ fi
64
+ (echo >/dev/tcp/$bind/$port) 2>/dev/null
65
+ }
54
66
  if command -v pinchtab >/dev/null 2>&1 && _pinchtab_port_open; then
55
67
  return 0
56
68
  fi
57
69
  pkill -f '[p]inchtab' 2>/dev/null || true
58
- nohup pinchtab >>"$log" 2>&1 &
70
+ nohup pinchtab server >>"$log" 2>&1 &
59
71
  local pid=$!
60
72
  sleep 2
61
73
  if ! kill -0 "$pid" 2>/dev/null; then
@@ -100,7 +112,7 @@ If `pinchtab_ensure` fails, show the user the tail of `/tmp/pinchtab.log`; do no
100
112
  BRIDGE_BIND=127.0.0.1 \
101
113
  BRIDGE_TOKEN="your-strong-secret" \
102
114
  BRIDGE_PROFILE=~/.pinchtab/automation-profile \
103
- pinchtab &
115
+ pinchtab server &
104
116
  ```
105
117
 
106
118
  **Never expose to 0.0.0.0 without a token. Never point at your daily Chrome profile.**
@@ -109,16 +121,16 @@ pinchtab &
109
121
 
110
122
  ```bash
111
123
  # Headless (default) — no visible window
112
- pinchtab &
124
+ pinchtab server &
113
125
 
114
126
  # Headed — visible Chrome window for human debugging
115
- BRIDGE_HEADLESS=false pinchtab &
127
+ BRIDGE_HEADLESS=false pinchtab server &
116
128
 
117
129
  # With auth token
118
- BRIDGE_TOKEN="your-secret-token" pinchtab &
130
+ BRIDGE_TOKEN="your-secret-token" pinchtab server &
119
131
 
120
132
  # Custom port
121
- BRIDGE_PORT=8080 pinchtab &
133
+ BRIDGE_PORT=8080 pinchtab server &
122
134
  ```
123
135
 
124
136
  Default: **port 9867**, no auth required (local). Set `BRIDGE_TOKEN` for remote access.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mercury-agent",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Personal AI assistant for chat platforms (WhatsApp, Slack, Discord, Telegram)",
5
5
  "license": "MIT",
6
6
  "author": "Avishai Tsabari",
@@ -15,4 +15,24 @@ mrctl spaces delete
15
15
 
16
16
  - `list` — shows all spaces with display names (admin-only; members get 403)
17
17
  - `name` — with no argument, shows current space name; with argument, renames
18
- - `delete` — deletes the current space and all its data (irreversible)
18
+ - `delete` — deletes the **current** space and all its data (irreversible)
19
+
20
+ ## `mrctl` can only ever act on the current space
21
+
22
+ Every `mrctl spaces` command targets the space you are running in. There is no
23
+ way to name a different space, and none of these commands accept a space id.
24
+
25
+ **`mrctl spaces delete` takes no argument.** Do not pass one. Listing a space
26
+ id does not target that space — the command refuses outright rather than
27
+ deleting the wrong thing, because it previously ignored the argument and
28
+ destroyed the current space instead of the one named.
29
+
30
+ Cross-space deletion is a **chat** action for a seeded admin only:
31
+
32
+ ```
33
+ /spaces delete <id>
34
+ ```
35
+
36
+ If a user asks you to delete a space other than this one, do not reach for
37
+ `mrctl`. Tell them to run that chat command themselves, and that it asks for a
38
+ `yes` confirmation before anything is destroyed.
@@ -17,7 +17,11 @@ import {
17
17
  } from "@whiskeysockets/baileys";
18
18
  import { mimeToExt } from "../core/media.js";
19
19
  import { logger } from "../logger.js";
20
- import type { MediaType, MessageAttachment } from "../types.js";
20
+ import type {
21
+ DroppedAttachment,
22
+ MediaType,
23
+ MessageAttachment,
24
+ } from "../types.js";
21
25
 
22
26
  const silentBaileysLogger: {
23
27
  level: string;
@@ -58,6 +62,12 @@ export interface MediaDownloadOptions {
58
62
  maxSizeBytes: number;
59
63
  /** Base directory for media storage (group workspace) */
60
64
  outputDir: string;
65
+ /**
66
+ * Called when media was detected but is not being delivered. Returning null
67
+ * told the caller only that nothing arrived, never why — so the agent got a
68
+ * caption with no file and no explanation, and invented one.
69
+ */
70
+ onSkipped?: (info: DroppedAttachment) => void;
61
71
  }
62
72
 
63
73
  /**
@@ -168,6 +178,12 @@ export async function downloadWhatsAppMedia(
168
178
  sizeBytes: mediaInfo.fileLength,
169
179
  maxBytes: options.maxSizeBytes,
170
180
  });
181
+ options.onSkipped?.({
182
+ type: mediaInfo.type,
183
+ reason: "too_large",
184
+ sizeBytes: Number(mediaInfo.fileLength),
185
+ maxBytes: options.maxSizeBytes,
186
+ });
171
187
  return null;
172
188
  }
173
189
 
@@ -185,6 +201,7 @@ export async function downloadWhatsAppMedia(
185
201
 
186
202
  if (!buffer || buffer.length === 0) {
187
203
  logger.error("Failed to download media: empty buffer", { messageId });
204
+ options.onSkipped?.({ type: mediaInfo.type, reason: "download_failed" });
188
205
  return null;
189
206
  }
190
207
 
@@ -196,6 +213,12 @@ export async function downloadWhatsAppMedia(
196
213
  sizeBytes: buffer.length,
197
214
  maxBytes: options.maxSizeBytes,
198
215
  });
216
+ options.onSkipped?.({
217
+ type: mediaInfo.type,
218
+ reason: "too_large",
219
+ sizeBytes: buffer.length,
220
+ maxBytes: options.maxSizeBytes,
221
+ });
199
222
  return null;
200
223
  }
201
224
 
@@ -238,6 +261,7 @@ export async function downloadWhatsAppMedia(
238
261
  type: mediaInfo.type,
239
262
  error: error instanceof Error ? error.message : String(error),
240
263
  });
264
+ options.onSkipped?.({ type: mediaInfo.type, reason: "download_failed" });
241
265
  return null;
242
266
  }
243
267
  }
@@ -287,6 +311,12 @@ export async function downloadQuotedMedia(
287
311
  sizeBytes: mediaInfo.fileLength,
288
312
  maxBytes: options.maxSizeBytes,
289
313
  });
314
+ options.onSkipped?.({
315
+ type: mediaInfo.type,
316
+ reason: "too_large",
317
+ sizeBytes: Number(mediaInfo.fileLength),
318
+ maxBytes: options.maxSizeBytes,
319
+ });
290
320
  return null;
291
321
  }
292
322
 
@@ -33,6 +33,7 @@ import { normalizeChatMarkdown } from "../text/markdown.js";
33
33
  import { applyRtlDirection } from "../text/rtl.js";
34
34
  import { installLibsignalConsoleFilter } from "./whatsapp-console-filter.js";
35
35
  import {
36
+ canonicalizeJid,
36
37
  canonicalizeJidSync,
37
38
  resolveKeyIdentities,
38
39
  resolveKeyIdentitiesSync,
@@ -79,10 +80,20 @@ function getContextInfo(
79
80
  return contextInfo ?? undefined;
80
81
  }
81
82
 
82
- function buildReplyContext(
83
+ /**
84
+ * These attribute values are user-controlled — a push name is whatever the
85
+ * sender typed — and the block is handed to the model as markup. A stray
86
+ * double quote would close the attribute and let a sender inject their own.
87
+ */
88
+ function escapeAttr(value: string): string {
89
+ return value.replace(/"/g, "&quot;");
90
+ }
91
+
92
+ export function buildReplyContext(
83
93
  message?: proto.IMessage | null,
84
94
  pushNames?: Map<string, string>,
85
95
  canonicalize?: (jid: string) => string,
96
+ nameLookup?: (jid: string) => string | undefined,
86
97
  ): string | undefined {
87
98
  const contextInfo = getContextInfo(message);
88
99
  if (!contextInfo?.quotedMessage) return undefined;
@@ -91,17 +102,23 @@ function buildReplyContext(
91
102
  const rawQuotedJid = contextInfo.participant || "unknown";
92
103
  // pushNames is keyed by canonical jid — resolve before lookup.
93
104
  const quotedJid = canonicalize ? canonicalize(rawQuotedJid) : rawQuotedJid;
105
+ // pushNames lives only in memory and is empty after every restart;
106
+ // nameLookup is the durable fallback (space_roles.display_name), so a quoted
107
+ // sender keeps their name instead of degrading to a bare LID.
94
108
  const quotedName =
95
- pushNames?.get(quotedJid) || quotedJid.split("@")[0] || "unknown";
109
+ pushNames?.get(quotedJid) ||
110
+ nameLookup?.(quotedJid) ||
111
+ quotedJid.split("@")[0] ||
112
+ "unknown";
96
113
  const quotedMessageId = contextInfo.stanzaId || "unknown";
97
114
 
98
115
  // Check if quoted message has media
99
116
  const quotedMedia = detectWhatsAppMedia(contextInfo.quotedMessage);
100
117
 
101
118
  const attrs = [
102
- `name="${quotedName}"`,
103
- `jid="${quotedJid}"`,
104
- `message_id="${quotedMessageId}"`,
119
+ `name="${escapeAttr(quotedName)}"`,
120
+ `jid="${escapeAttr(quotedJid)}"`,
121
+ `message_id="${escapeAttr(quotedMessageId)}"`,
105
122
  ];
106
123
 
107
124
  if (quotedMedia) {
@@ -175,6 +192,13 @@ export class WhatsAppBaileysAdapter
175
192
  /** Persistent LID↔phone alias store — assigned by main.ts after core init. */
176
193
  aliasStore?: WaAliasStore;
177
194
 
195
+ /**
196
+ * Durable display-name lookup by canonical jid — assigned by main.ts after
197
+ * core init. Backs `pushNames`, which is memory-only and starts empty on
198
+ * every restart.
199
+ */
200
+ displayNameLookup?: (canonicalJid: string) => string | undefined;
201
+
178
202
  constructor(options?: WhatsAppAdapterOptions) {
179
203
  this.userName = options?.userName ?? "mercury";
180
204
  this.authDir =
@@ -193,6 +217,28 @@ export class WhatsAppBaileysAdapter
193
217
  private canonicalizeQuoted = (jid: string): string =>
194
218
  canonicalizeJidSync(jid, undefined, this.aliasStore).canonical;
195
219
 
220
+ /**
221
+ * Resolve a quoted message's participant through Baileys' async LID store.
222
+ *
223
+ * The reply-context builder is synchronous, so it can only see mappings the
224
+ * alias store already knows. Running the async resolution first *learns* the
225
+ * pair into that store, after which the sync path finds it — which is how an
226
+ * otherwise-unknown LID ends up rendered as a name. Failures are non-fatal:
227
+ * canonicalizeJid swallows lookup errors and degrades to the LID.
228
+ */
229
+ private async learnQuotedParticipant(
230
+ message?: proto.IMessage | null,
231
+ ): Promise<void> {
232
+ const participant = getContextInfo(message)?.participant;
233
+ if (!participant) return;
234
+ await canonicalizeJid(
235
+ participant,
236
+ undefined,
237
+ this.aliasStore,
238
+ this.lidLookup,
239
+ );
240
+ }
241
+
196
242
  /**
197
243
  * Get current QR status for API endpoint
198
244
  */
@@ -460,6 +506,7 @@ export class WhatsAppBaileysAdapter
460
506
  raw.message,
461
507
  this.pushNames,
462
508
  this.canonicalizeQuoted,
509
+ this.displayNameLookup,
463
510
  );
464
511
  const text = [baseText, replyContext].filter(Boolean).join("\n\n").trim();
465
512
  const threadId = this.encodeThreadId({
@@ -560,10 +607,13 @@ export class WhatsAppBaileysAdapter
560
607
  }
561
608
 
562
609
  let baseText = extractText(msg.message).trim();
610
+ // Learn the quoted sender's LID→phone mapping before the sync builder runs.
611
+ await this.learnQuotedParticipant(msg.message);
563
612
  const replyContext = buildReplyContext(
564
613
  msg.message,
565
614
  this.pushNames,
566
615
  this.canonicalizeQuoted,
616
+ this.displayNameLookup,
567
617
  );
568
618
 
569
619
  // WhatsApp @-mentions embed JIDs in text (e.g. "@52669955764381").
@@ -582,9 +632,19 @@ export class WhatsAppBaileysAdapter
582
632
  ? isBotJid(quotedParticipant)
583
633
  : false;
584
634
 
585
- // Replace bot's JID mention with configured userName so trigger patterns match
635
+ // Replace bot's JID mention with configured userName so trigger patterns match.
636
+ //
637
+ // isBotMentioned is set from the JID comparison alone and travels onward as
638
+ // a flag. The rewrite below is only cosmetic — it makes the trigger word
639
+ // strippable and the text readable — and it can legitimately find nothing:
640
+ // WhatsApp puts the mentioned user's *LID* digits in the text for
641
+ // LID-addressed accounts, so any divergence between that string and the jid
642
+ // we decode leaves the text untouched. Deriving "was I addressed" from the
643
+ // result of this replace is what made a correct tag look like idle chatter.
644
+ let isBotMentioned = false;
586
645
  for (const jid of mentionedJids) {
587
646
  if (isBotJid(jid)) {
647
+ isBotMentioned = true;
588
648
  const user = jidDecode(jid)?.user;
589
649
  if (user) {
590
650
  baseText = baseText.replace(
@@ -666,6 +726,7 @@ export class WhatsAppBaileysAdapter
666
726
  // Using spread to add custom properties (not in MessageMetadata type)
667
727
  ...({
668
728
  isReplyToBot,
729
+ isBotMentioned,
669
730
  replyToMessageId: contextInfo?.stanzaId ?? undefined,
670
731
  platformMessageId: msg.key.id ?? undefined,
671
732
  aliasThreadId,