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.
- package/README.md +2 -0
- package/docs/configuration.md +9 -0
- package/docs/dashboard.md +1 -1
- package/docs/pipeline.md +8 -2
- package/examples/extensions/docs/index.ts +28 -0
- package/examples/extensions/docs/skill/SKILL.md +114 -0
- package/examples/extensions/pinchtab/index.ts +26 -3
- package/examples/extensions/pinchtab/skill/SKILL.md +21 -9
- package/package.json +1 -1
- package/resources/skills/spaces/SKILL.md +21 -1
- package/src/adapters/whatsapp-media.ts +31 -1
- package/src/adapters/whatsapp.ts +67 -6
- package/src/agent/container-entry.ts +130 -8
- package/src/agent/container-runner.ts +11 -0
- package/src/bridges/whatsapp.ts +23 -2
- package/src/cli/mrctl.ts +18 -2
- package/src/config-file.ts +8 -3
- package/src/config.ts +10 -0
- package/src/core/attachment-notes.ts +44 -0
- package/src/core/commands.ts +33 -8
- package/src/core/confirmation.ts +42 -0
- package/src/core/debounce.ts +9 -0
- package/src/core/handler.ts +41 -4
- package/src/core/permissions.ts +44 -2
- package/src/core/router.ts +72 -4
- package/src/core/routes/chat.ts +26 -1
- package/src/core/routes/config-builtin.ts +7 -0
- package/src/core/routes/dashboard.ts +29 -8
- package/src/core/routes/spaces.ts +31 -1
- package/src/core/routes/tasks.ts +11 -5
- package/src/core/runtime.ts +176 -47
- package/src/core/storage-cleanup.ts +17 -2
- package/src/core/task-scheduler.ts +115 -41
- package/src/core/trigger.ts +12 -0
- package/src/dashboard/index.html +64 -0
- package/src/main.ts +4 -0
- package/src/storage/db.ts +149 -43
- 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
|
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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 {
|
|
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
|
|
package/src/adapters/whatsapp.ts
CHANGED
|
@@ -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
|
-
|
|
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, """);
|
|
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) ||
|
|
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,
|