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.
- package/README.md +2 -0
- package/docs/configuration.md +107 -0
- package/docs/dashboard.md +1 -1
- package/docs/pipeline.md +8 -2
- package/docs/scheduler.md +81 -19
- 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 +7 -7
- 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 +138 -8
- package/src/agent/container-runner.ts +303 -1
- package/src/agent/model-capabilities.ts +6 -3
- package/src/agent/pi-jsonl-parser.ts +123 -11
- package/src/bridges/whatsapp.ts +23 -2
- package/src/cli/mercury.ts +87 -64
- package/src/cli/mrctl.ts +18 -2
- package/src/config-file.ts +34 -4
- package/src/config.ts +33 -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 +104 -7
- package/src/core/router.ts +72 -4
- package/src/core/routes/chat.ts +18 -0
- package/src/core/routes/config-builtin.ts +7 -0
- package/src/core/routes/dashboard.ts +31 -7
- package/src/core/routes/spaces.ts +31 -1
- package/src/core/runtime.ts +235 -62
- package/src/core/storage-cleanup.ts +17 -2
- package/src/core/system-messages.ts +5 -0
- package/src/core/task-scheduler.ts +223 -25
- package/src/core/trigger.ts +12 -0
- package/src/main.ts +4 -0
- package/src/storage/db.ts +206 -42
- package/src/storage/pi-auth.ts +48 -33
- 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
|
|
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`)
|
|
@@ -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
|
-
│ ├─►
|
|
67
|
-
│
|
|
68
|
-
│ │ ├─► Update next_run_at in DB
|
|
69
|
-
│ │ └─► Execute handler
|
|
66
|
+
│ ├─► Execute handler
|
|
67
|
+
│ ├─► Record the outcome on the task row
|
|
70
68
|
│ │
|
|
71
|
-
│
|
|
72
|
-
│
|
|
73
|
-
│
|
|
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
|
|
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);
|
|
247
|
-
scheduler.stop();
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mercury-agent",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
88
|
-
"@earendil-works/pi-ai": "~0.
|
|
89
|
-
"@earendil-works/pi-coding-agent": "~0.
|
|
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": "^
|
|
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": "^
|
|
103
|
+
"@types/node": "^26.1.2",
|
|
104
104
|
"@types/qrcode-terminal": "^0.12.2",
|
|
105
105
|
"bun-types": "^1.3.5",
|
|
106
|
-
"typescript": "^
|
|
106
|
+
"typescript": "^7.0.2"
|
|
107
107
|
},
|
|
108
108
|
"engines": {
|
|
109
109
|
"bun": ">=1.2.0"
|