mercury-agent 0.9.1 → 0.11.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 (41) hide show
  1. package/README.md +2 -0
  2. package/docs/configuration.md +107 -0
  3. package/docs/dashboard.md +1 -1
  4. package/docs/pipeline.md +8 -2
  5. package/docs/scheduler.md +81 -19
  6. package/examples/extensions/docs/index.ts +28 -0
  7. package/examples/extensions/docs/skill/SKILL.md +114 -0
  8. package/examples/extensions/pinchtab/index.ts +26 -3
  9. package/examples/extensions/pinchtab/skill/SKILL.md +21 -9
  10. package/package.json +7 -7
  11. package/resources/skills/spaces/SKILL.md +21 -1
  12. package/src/adapters/whatsapp-media.ts +31 -1
  13. package/src/adapters/whatsapp.ts +67 -6
  14. package/src/agent/container-entry.ts +138 -8
  15. package/src/agent/container-runner.ts +303 -1
  16. package/src/agent/model-capabilities.ts +6 -3
  17. package/src/agent/pi-jsonl-parser.ts +123 -11
  18. package/src/bridges/whatsapp.ts +23 -2
  19. package/src/cli/mercury.ts +87 -64
  20. package/src/cli/mrctl.ts +18 -2
  21. package/src/config-file.ts +34 -4
  22. package/src/config.ts +33 -0
  23. package/src/core/attachment-notes.ts +44 -0
  24. package/src/core/commands.ts +33 -8
  25. package/src/core/confirmation.ts +42 -0
  26. package/src/core/debounce.ts +9 -0
  27. package/src/core/handler.ts +104 -7
  28. package/src/core/router.ts +72 -4
  29. package/src/core/routes/chat.ts +18 -0
  30. package/src/core/routes/config-builtin.ts +7 -0
  31. package/src/core/routes/dashboard.ts +31 -7
  32. package/src/core/routes/spaces.ts +31 -1
  33. package/src/core/runtime.ts +235 -62
  34. package/src/core/storage-cleanup.ts +17 -2
  35. package/src/core/system-messages.ts +5 -0
  36. package/src/core/task-scheduler.ts +223 -25
  37. package/src/core/trigger.ts +12 -0
  38. package/src/main.ts +4 -0
  39. package/src/storage/db.ts +206 -42
  40. package/src/storage/pi-auth.ts +48 -33
  41. package/src/types.ts +57 -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`)
@@ -86,6 +95,104 @@ In YAML, use a list of `{ provider, model }` objects under `model.chain` (max 4
86
95
 
87
96
  Optional **`model.capabilities`** may be a mapping; it is applied like `MERCURY_MODEL_CAPABILITIES` JSON.
88
97
 
98
+ ### Custom endpoints (`models.json`)
99
+
100
+ A model leg is `{ provider, model }` and carries no host, so it cannot on its own
101
+ reach an endpoint pi does not already know about — a metering gateway, a local
102
+ model server (Ollama, LM Studio, vLLM), or a private/regional deployment.
103
+
104
+ pi solves this with its own config file, and Mercury mounts it. Drop a
105
+ `models.json` into the **global dir** (`<dataDir>/global/`, alongside
106
+ `AGENTS.md`); it is bind-mounted read-only at `PI_CODING_AGENT_DIR` and pi reads
107
+ it natively. No Mercury config key is involved, and the model-chain schema is
108
+ unchanged.
109
+
110
+ **Repoint an existing provider** — keeps pi's model metadata, costs and
111
+ capabilities, and every `provider: openai` leg goes to the new host:
112
+
113
+ ```json
114
+ { "providers": { "openai": { "baseUrl": "https://gateway.example.com/v1" } } }
115
+ ```
116
+
117
+ **Define custom models** — here pi requires `baseUrl`, and `api` is effectively
118
+ mandatory too: pi silently skips any model whose wire format it cannot resolve,
119
+ so omitting it makes the model vanish without an error.
120
+
121
+ ```json
122
+ {
123
+ "providers": {
124
+ "mygateway": {
125
+ "baseUrl": "https://gateway.example.com/v1",
126
+ "api": "openai-completions",
127
+ "apiKey": "$MYGATEWAY_API_KEY",
128
+ "models": [{ "id": "openai/gpt-4o-mini", "contextWindow": 128000 }]
129
+ }
130
+ }
131
+ }
132
+ ```
133
+
134
+ ### Credentials in `models.json`
135
+
136
+ Which credential path applies depends on which shape you used:
137
+
138
+ - **Repointing a built-in provider** (`openai`, `anthropic`, …) changes nothing:
139
+ the key keeps travelling as `MERCURY_<PROVIDER>_API_KEY`, exactly as before.
140
+ - **A provider that exists only in `models.json`** is unknown to pi's env-var
141
+ map, so no `*_API_KEY` variable is read for it. Its key must come from the
142
+ provider entry — written as a **reference**, never a literal:
143
+ `"apiKey": "$MYGATEWAY_API_KEY"` (`${VAR}` and `!some-command` also work).
144
+ Pass the secret itself as `MERCURY_MYGATEWAY_API_KEY` and it reaches the
145
+ container through the usual passthrough. This assumes the default
146
+ `agent.env_passthrough: all`; under `claimed`, only the provider variables
147
+ Mercury knows about are forwarded, and a custom provider's key is not among
148
+ them, so pi fails to resolve it.
149
+
150
+ > ⚠️ **Never store a credential literally.** Mercury refuses to mount a
151
+ > `models.json` that has a literal value in `apiKey`, in a credential-shaped
152
+ > field or header (`Authorization`, `x-api-key`, anything containing
153
+ > key/token/secret/auth/cookie/credential/passw), or as userinfo or a
154
+ > credential-shaped query parameter in a `baseUrl` — provider-level and
155
+ > per-model alike — and logs an error naming the fields. Every other entry
156
+ > mounted into the agent container is a resource kind that cannot hold a secret
157
+ > (`auth.json` is excluded precisely so the OAuth refresh token never reaches
158
+ > the least-trusted part of the system), and `models.json` is held to the same
159
+ > rule.
160
+ >
161
+ > **References are fine**, and so is a value that merely *contains* one:
162
+ > `"Bearer $GW_KEY"` is the ordinary spelling of a bearer header, because pi
163
+ > interpolates rather than matching whole values. What is left over once the
164
+ > references are removed still has to look like configuration — stapling an
165
+ > unused `$VAR` onto a pasted key does not buy passage.
166
+ >
167
+ > This is a guardrail against accident, not a control against a hostile writer:
168
+ > the bind source is live, so anyone able to write the global dir could change
169
+ > the file after the check. Anyone who can do that can already do worse.
170
+ >
171
+ > A file that cannot be read or parsed is refused for the same reason — its
172
+ > contents are unverifiable. The check parses exactly what pi parses: `//` line
173
+ > comments and trailing commas are fine, **`/* … */` block comments are not** —
174
+ > pi cannot read those either, so a file containing one is refused rather than
175
+ > mounted and then silently ignored by pi.
176
+ >
177
+ > **What refusal costs depends on the shape.** A repointed built-in provider
178
+ > falls back to its default endpoint, so traffic quietly leaves your gateway; a
179
+ > provider defined only in `models.json` stops resolving altogether. Either way,
180
+ > grep the logs for `Ignoring models.json`.
181
+
182
+ **Capabilities for a custom provider.** Capability detection is host-side and
183
+ registry-based, so a provider that exists only in `models.json` is invisible to
184
+ it and the leg resolves to `DEFAULT_CAPABILITIES` (`tools: true`, everything
185
+ else false). This degrades gracefully: tool use — the one that matters — stays
186
+ on, and a `false` from an unresolved model is treated as *unknown* rather than
187
+ *unsupported*, so capability-gated extensions and skills still install. To state
188
+ capabilities explicitly, add the model to `<dataDir>/model-capabilities.yaml`,
189
+ which is consulted before the registry.
190
+
191
+ The **override-only** shape mostly avoids the question, since the provider stays
192
+ a known one — but only for model ids that are themselves in pi's registry.
193
+ Repointing `openai`'s `baseUrl` and then naming a model pi has never heard of
194
+ lands back in `default`.
195
+
89
196
  ### Removed: `provider: cursor`
90
197
 
91
198
  The **Cursor Agent CLI** integration has been removed. All model legs use **pi** with standard providers (`anthropic`, `openai`, `google`, `mistral`, `groq`, `openrouter`, etc.).
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
package/docs/scheduler.md CHANGED
@@ -61,27 +61,31 @@ TaskScheduler.start()
61
61
 
62
62
  ├─► Query DB for due tasks (active=1, next_run_at <= now)
63
63
 
64
- ├─► For each due task:
64
+ ├─► For each due task (skipped if a run is already in flight):
65
65
  │ │
66
- │ ├─► [Cron task]
67
- ├─► Compute next run time from cron expression
68
- │ │ ├─► Update next_run_at in DB
69
- │ │ └─► Execute handler
66
+ │ ├─► Execute handler
67
+ │ ├─► Record the outcome on the task row
70
68
  │ │
71
- └─► [At task]
72
- ├─► Execute handler
73
- └─► Delete task from DB
69
+ ├─► [Failed, retries left]
70
+ │ └─► Set next_run_at to the retry time — schedule untouched
71
+
72
+ │ └─► [Succeeded, or out of retries]
73
+ │ ├─► Report the failure to the space (failures only)
74
+ │ ├─► [Cron task] Compute and store the next run time
75
+ │ └─► [At task] Delete task from DB
74
76
 
75
77
  └─► Schedule next poll
76
78
  ```
77
79
 
78
80
  Tasks are processed sequentially within a poll cycle. Each task runs as if the `createdBy` user sent the prompt.
79
81
 
82
+ The schedule is consumed **after** the attempt, not before. A task therefore stays "due" for the whole time it is running; the in-flight guard is what stops the poll starting a second run, and it is also why a long run shows as `now` in the dashboard's Next Run column until it finishes.
83
+
80
84
  **At-task lifecycle:**
81
85
  1. Created with a future timestamp
82
86
  2. Waits until scheduled time
83
- 3. Executes once
84
- 4. Auto-deletes (regardless of success/failure)
87
+ 3. Executes once, plus any retries
88
+ 4. Auto-deletes once it succeeds or runs out of retries
85
89
 
86
90
  ## Creating Tasks
87
91
 
@@ -185,6 +189,9 @@ CREATE TABLE tasks (
185
189
  silent INTEGER NOT NULL DEFAULT 0,
186
190
  next_run_at INTEGER NOT NULL,
187
191
  created_by TEXT NOT NULL,
192
+ last_run_at INTEGER, -- Epoch ms of the last completed attempt
193
+ last_status TEXT, -- 'ok' | 'error'
194
+ last_error TEXT, -- Failure message, capped at 500 chars
188
195
  created_at INTEGER NOT NULL,
189
196
  updated_at INTEGER NOT NULL
190
197
  );
@@ -197,6 +204,11 @@ CREATE INDEX idx_tasks_next ON tasks(active, next_run_at);
197
204
  | `cron` | Cron expression for recurring tasks (null for at-tasks) |
198
205
  | `at` | ISO 8601 timestamp for one-shot tasks (null for cron-tasks) |
199
206
  | `silent` | If 1, task runs but doesn't post results to chat |
207
+ | `last_run_at` | When the last attempt finished. Null until the task has run |
208
+ | `last_status` | `ok` or `error`. Written for every attempt, retries included |
209
+ | `last_error` | Why the last attempt failed; null after a success |
210
+
211
+ The last-run columns are shown in the dashboard's task table (**Last Run**), with the error text as the cell's tooltip. Existing rows are not backfilled — a task that has never run since the upgrade reads as `never`.
200
212
 
201
213
  ## Permissions
202
214
 
@@ -241,13 +253,29 @@ The scheduler stops cleanly on shutdown — no orphaned timers.
241
253
  ### `TaskScheduler`
242
254
 
243
255
  ```typescript
244
- const scheduler = new TaskScheduler(db, pollIntervalMs);
256
+ const scheduler = new TaskScheduler(db, pollIntervalMs, {
257
+ retryAttempts, // extra attempts after the first failure
258
+ retryDelayMs, // how long to wait before one
259
+ });
245
260
 
246
- scheduler.start(handler); // Begin polling
247
- scheduler.stop(); // Stop polling
261
+ scheduler.start(handler, onFailure); // Begin polling
262
+ scheduler.stop(); // Stop polling
248
263
  scheduler.computeNextRun(cron, from); // Get next run time for cron tasks
249
264
  ```
250
265
 
266
+ `onFailure` is optional and is called **once per due cycle**, only after the retries are spent — never once per attempt.
267
+
268
+ ```typescript
269
+ type TaskFailure = {
270
+ id: number;
271
+ spaceId: string;
272
+ name: string | null;
273
+ silent: boolean; // the task's flag; the reporter decides whether to honour it
274
+ error: string; // capped at 500 chars
275
+ attempts: number; // attempts made this cycle, including the first
276
+ };
277
+ ```
278
+
251
279
  ### Handler Signature
252
280
 
253
281
  ```typescript
@@ -276,13 +304,47 @@ db.setTaskActive(id, active); // Pause/resume
276
304
  db.deleteTask(id, spaceId); // Delete task (with space check)
277
305
  db.deleteTaskById(id); // Delete task (no space check, for scheduler)
278
306
  db.updateTaskNextRun(id, nextRunAt); // Update next execution time
307
+ db.recordTaskRun(id, status, error?); // Record one attempt's outcome
279
308
  ```
280
309
 
281
310
  ## Error Handling
282
311
 
283
- If a task handler fails:
284
- - Error is logged
285
- - Task is not retried in the same cycle
286
- - **Cron tasks:** `next_run_at` is already updated, so it will run again at the next scheduled time
287
- - **At tasks:** Still deleted after execution (one-shot behavior preserved)
288
- - Other tasks in the cycle continue to execute
312
+ A task that fails is retried, then reported. Nothing about a failure is silent.
313
+
314
+ When a handler throws:
315
+
316
+ 1. The error is logged and written to the task row (`last_status = 'error'`, `last_error`).
317
+ 2. If attempts remain, `next_run_at` is set to the retry time and the poll picks the task up again. Nothing is posted to the space — a pending retry is not news yet.
318
+ 3. Once the attempts are spent, the space is told (see below) and only **then** is the schedule consumed: a cron task advances to its next occurrence, a one-shot task is deleted.
319
+ 4. Other tasks in the cycle are unaffected.
320
+
321
+ The retry time is `now + retry_delay_ms`, **clamped to the task's own next occurrence** — a 5-minute delay on an every-minute task must not swallow four scheduled runs.
322
+
323
+ ### The failure notice
324
+
325
+ On a final failure Mercury posts one message into the task's space:
326
+
327
+ > ⚠️ Scheduled task "Daily football" failed and produced nothing (attempts: 2). Reason: Container timed out for group 19
328
+
329
+ `silent` is **not** honoured here. It suppresses a task's routine output, and a silent task that fails is exactly the one whose failure nobody would otherwise discover. Set `scheduling.notify_on_failure: false` to turn the notices off entirely.
330
+
331
+ The message is locale-aware (`messages.locale`, `en` and `he`), like every other host-generated system message.
332
+
333
+ ### Configuration
334
+
335
+ | YAML | Env | Default | What |
336
+ |------|-----|---------|------|
337
+ | `scheduling.retry_attempts` | `MERCURY_TASK_RETRY_ATTEMPTS` | `1` | Extra attempts after the first failure. `0` disables retrying |
338
+ | `scheduling.retry_delay_ms` | `MERCURY_TASK_RETRY_DELAY_MS` | `300000` (5 min) | Wait before a retry. Minimum 60 000 — an agent run costs tokens and a container slot, so retrying a systematic failure fast is worse than not retrying |
339
+ | `scheduling.notify_on_failure` | `MERCURY_TASK_NOTIFY_ON_FAILURE` | `true` | Post the notice above |
340
+
341
+ ```yaml
342
+ scheduling:
343
+ retry_attempts: 1
344
+ retry_delay_ms: 300000
345
+ notify_on_failure: true
346
+ ```
347
+
348
+ ### Unparseable cron expressions
349
+
350
+ A task whose cron cannot be parsed has no next occurrence to advance to. Rather than leave `next_run_at` in the past — which would make the poll re-run it, and spend a full agent turn, every few seconds — the scheduler **pauses the task** and logs why. Fix the expression and resume it.
@@ -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.1",
3
+ "version": "0.11.0",
4
4
  "description": "Personal AI assistant for chat platforms (WhatsApp, Slack, Discord, Telegram)",
5
5
  "license": "MIT",
6
6
  "author": "Avishai Tsabari",
@@ -84,13 +84,13 @@
84
84
  "@chat-adapter/slack": "^4.14.0",
85
85
  "@chat-adapter/teams": "^4.17.0",
86
86
  "@chat-adapter/telegram": "^4.26.0",
87
- "@earendil-works/pi-agent-core": "~0.79.6",
88
- "@earendil-works/pi-ai": "~0.79.10",
89
- "@earendil-works/pi-coding-agent": "~0.79.6",
87
+ "@earendil-works/pi-agent-core": "~0.83.0",
88
+ "@earendil-works/pi-ai": "~0.83.0",
89
+ "@earendil-works/pi-coding-agent": "~0.83.0",
90
90
  "@whiskeysockets/baileys": "^7.0.0-rc.9",
91
91
  "axios": "^1.15.1",
92
92
  "chat": "^4.14.0",
93
- "commander": "^14.0.3",
93
+ "commander": "^15.0.0",
94
94
  "cron-parser": "^5.5.0",
95
95
  "discord.js": "^14.26.3",
96
96
  "hono": "^4.12.34",
@@ -100,10 +100,10 @@
100
100
  },
101
101
  "devDependencies": {
102
102
  "@biomejs/biome": "^2.4.12",
103
- "@types/node": "^25.6.0",
103
+ "@types/node": "^26.1.2",
104
104
  "@types/qrcode-terminal": "^0.12.2",
105
105
  "bun-types": "^1.3.5",
106
- "typescript": "^5.8.0"
106
+ "typescript": "^7.0.2"
107
107
  },
108
108
  "engines": {
109
109
  "bun": ">=1.2.0"