mercury-agent 0.18.0-beta.0 → 0.18.1

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 (36) hide show
  1. package/README.md +1 -0
  2. package/docs/configuration.md +2 -2
  3. package/docs/extensions.md +73 -0
  4. package/docs/profile-guide.md +35 -0
  5. package/docs/skills-guide.md +31 -0
  6. package/docs/web-search.md +3 -1
  7. package/examples/extensions/archive/README.md +146 -0
  8. package/examples/extensions/archive/backends/drive.ts +486 -0
  9. package/examples/extensions/archive/backends/local.ts +180 -0
  10. package/examples/extensions/archive/backends/types.ts +82 -0
  11. package/examples/extensions/archive/capability.ts +252 -0
  12. package/examples/extensions/archive/gc.ts +225 -0
  13. package/examples/extensions/archive/index.ts +324 -0
  14. package/examples/extensions/archive/inflight.ts +65 -0
  15. package/examples/extensions/archive/manifest.ts +310 -0
  16. package/examples/extensions/archive/queue.ts +435 -0
  17. package/examples/extensions/archive/skill/SKILL.md +59 -0
  18. package/examples/extensions/morning/README.md +126 -0
  19. package/examples/extensions/morning/index.ts +174 -0
  20. package/examples/extensions/morning/lib/hosts.ts +75 -0
  21. package/examples/extensions/morning/lib/issue-token.ts +298 -0
  22. package/examples/extensions/morning/lib/morning.ts +515 -0
  23. package/examples/extensions/morning/lib/token.ts +282 -0
  24. package/examples/extensions/morning/skill/SKILL.md +144 -0
  25. package/examples/extensions/morning/skill/references/document-types.md +97 -0
  26. package/examples/extensions/morning/skill/references/errors.md +255 -0
  27. package/examples/extensions/web-search/brave.ts +324 -0
  28. package/examples/extensions/web-search/index.ts +53 -1
  29. package/examples/extensions/web-search/skill/SKILL.md +50 -12
  30. package/package.json +3 -3
  31. package/src/agent/container-env.ts +10 -0
  32. package/src/core/routes/capability.ts +4 -1
  33. package/src/core/runtime.ts +36 -0
  34. package/src/extensions/catalog.ts +18 -0
  35. package/src/extensions/hooks.ts +8 -2
  36. package/src/extensions/types.ts +46 -1
package/README.md CHANGED
@@ -167,6 +167,7 @@ Install extensions from the dashboard (**Features** page), the CLI, or during se
167
167
  ```bash
168
168
  # From the CLI
169
169
  mercury add web-browser # Web browsing & search (uses Brave Search, no API key needed)
170
+ mercury add web-search # Brave Search API + site search skill (key stays on the host)
170
171
  mercury add napkin # Obsidian-style knowledge vault
171
172
 
172
173
  # Or pick a profile that bundles what you need
@@ -146,7 +146,7 @@ agent:
146
146
  env_passthrough: all # all (default) | claimed
147
147
  ```
148
148
 
149
- - **`all`** — every `MERCURY_*` var except a fixed blocklist is passed into the container with the prefix stripped (`MERCURY_BRAVE_API_KEY` → `BRAVE_API_KEY`). Convenient, but blunt: a secret added to `.env` for one purpose reaches **every space's container**, regardless of who triggered the turn or whether that space has anything to do with it.
149
+ - **`all`** — every `MERCURY_*` var except a fixed blocklist is passed into the container with the prefix stripped (`MERCURY_BILLING_API_KEY` → `BILLING_API_KEY`). Convenient, but blunt: a secret added to `.env` for one purpose reaches **every space's container**, regardless of who triggered the turn or whether that space has anything to do with it. Vars an extension declares `hostOnly` are excluded even here — `MERCURY_BRAVE_API_KEY` stays on the host once `web-search` is installed, which brokers Brave queries through `mrctl capability web-search`.
150
150
  - **`claimed`** — only variables an extension declared via `mercury.env()` are passed, and only when the triggering caller holds that extension's permission. Undeclared variables stay on the host.
151
151
 
152
152
  **Model-provider credentials are exempt** and pass in both modes (`MERCURY_ANTHROPIC_API_KEY`, `MERCURY_ANTHROPIC_OAUTH_TOKEN`, `MERCURY_GEMINI_API_KEY`, `MERCURY_GROQ_API_KEY`, and the rest of the provider list). pi reads them inside the container, and no extension declares them — without the exemption, `claimed` would leave the agent unable to reach any model. They remain subject to the blocklist.
@@ -156,7 +156,7 @@ Env: `MERCURY_CONTAINER_ENV_PASSTHROUGH`.
156
156
  `claimed` is opt-in because it breaks setups that rely on blind passthrough for anything other than provider keys — API keys consumed by skills (search, TTS, scrapers) and any credential you added by hand. To migrate, declare those in an extension (see [extensions.md](extensions.md)) before switching. At startup with `all`, Mercury logs the vars it is passing that are neither declared nor provider credentials — names only, so a genuine outlier stands out:
157
157
 
158
158
  ```
159
- Container env passthrough: all — these vars reach every space's container and are scoped to nothing. […] vars=MERCURY_BRAVE_API_KEY, MERCURY_BILLING_API_KEY
159
+ Container env passthrough: all — these vars reach every space's container and are scoped to nothing. […] vars=MERCURY_SCRAPER_API_KEY, MERCURY_BILLING_API_KEY
160
160
  ```
161
161
 
162
162
  For secrets that only host-side hooks and jobs need, prefer `mercury.env({ from: "…", hostOnly: true })`, which keeps them out of containers in either mode. For credentials the agent should never hold at all, use a host-side capability handler (`mercury.capability()`), which runs the privileged call on the host and returns only the result.
@@ -160,6 +160,7 @@ mercury.on("workspace_init", async (event, ctx) => {
160
160
  | `workspace_init` | Space workspace created/ensured | No |
161
161
  | `before_container` | About to spawn container | Yes |
162
162
  | `after_container` | Container finished | Yes |
163
+ | `delivery` | Reply stored, attachments final | No |
163
164
 
164
165
  Both `workspace_init` and `before_container` events include:
165
166
  - `workspace` — absolute host path (for file operations on the host)
@@ -198,6 +199,34 @@ mercury.on("after_container", async (event, ctx) => {
198
199
  });
199
200
  ```
200
201
 
202
+ #### `delivery` — what the user actually received
203
+
204
+ ```typescript
205
+ mercury.on("delivery", async (event, ctx) => {
206
+ // event.files — exactly what the adapter will attach
207
+ // event.withheld — what the media gate stripped
208
+ // event.userMessageId / event.assistantMessageId — both rows exist
209
+ });
210
+ ```
211
+
212
+ Use this, not `after_container`, whenever a hook needs to know **what was
213
+ delivered**. `after_container` fires *before* the media send gate and can still
214
+ rewrite the reply, so its file list is a proposal: a handler reading it cannot
215
+ tell whether the recipient was actually shown those files. `delivery` fires
216
+ after the gate and after the assistant row is written.
217
+
218
+ It is non-mutating — by the time it runs, the reply has been decided — and it
219
+ does not fire on paths where nothing reaches the user: a suppressed reply, or a
220
+ scheduler no-op. Handler errors are logged and isolated, so a failing handler
221
+ costs a log line and never the reply.
222
+
223
+ `event.withheld` exists so a handler can tell "withheld" from "never produced".
224
+ Never treat a withheld file as delivered: the gate replaces the file array
225
+ rather than annotating it, so this is the only place that distinction survives.
226
+
227
+ The `archive` extension is the worked example
228
+ ([`examples/extensions/archive/`](../examples/extensions/archive/)).
229
+
201
230
  ### `mercury.job(name, def)`
202
231
 
203
232
  Register a background job that runs on the host.
@@ -440,6 +469,50 @@ The skill is installed into the management space's own workspace on
440
469
  dir mounted into *every* container, which would advertise the capability to
441
470
  spaces that cannot use it.
442
471
 
472
+ ## Morning / Green Invoice invoicing (`morning`)
473
+
474
+ `examples/extensions/morning/` issues tax documents through the
475
+ [Morning](https://morning.co) API. Second `mercury.capability()` adopter after
476
+ yahoo-mail, and the case that justifies rung 4: these credentials create legally
477
+ binding documents against a real business, so they never enter a container.
478
+
479
+ ```bash
480
+ mercury add ./examples/extensions/morning
481
+ MERCURY_MORNING_CLIENT_ID=... # hostOnly
482
+ MERCURY_MORNING_CLIENT_SECRET=... # hostOnly
483
+ mrctl config set morning.environment sandbox # or production; sandbox is the default
484
+ ```
485
+
486
+ The agent calls it with `mrctl capability morning <action> '<json>'`:
487
+
488
+ | Action | Body | Returns |
489
+ |--------|------|---------|
490
+ | `client-search` | `{name?, email?, page?, pageSize?}` | paginated clients |
491
+ | `client-create` | `{name, taxId?, emails?, …}` | the new client |
492
+ | `document-types` | `{lang?}` | `[{id, name}]`, localised |
493
+ | `document-preview` | the document body | `{file, issueToken}` — base64 PDF, nothing created |
494
+ | `document-create` | the same body **plus `issueToken`** | `{id, number, url}` |
495
+ | `document-search` | `{fromDate?, toDate?, type?, …}` | paginated documents |
496
+ | `document-links` | `{id}` | `{origin, he, en}` |
497
+
498
+ Two properties are enforced in the broker rather than requested in the skill:
499
+
500
+ - **Preview before issue.** `document-create` refuses without a single-use
501
+ `issueToken` minted by `document-preview` of the *identical* payload. The
502
+ token is bound to the payload hash, the environment, the space and the caller,
503
+ so a changed amount, a sandbox-previewed document presented against
504
+ production, another person's preview, or a retry after a successful create are
505
+ each refused. A prompt instruction is not a control when the model may be the
506
+ thing under attack.
507
+ - **`sensitive: true`.** In a group-linked space the runtime then requires
508
+ `security.sensitive_connections_allowed` plus per-caller confirmation. Set that
509
+ key or the space refuses every turn — the symptom otherwise reads as a broken
510
+ extension.
511
+
512
+ Slice 1 is invoices. Expenses are deferred: `POST /expenses` requires an
513
+ `accountingClassification` whose lookup endpoint is absent from the published
514
+ spec, and an uploaded file becomes a draft with no API to approve it.
515
+
443
516
  ## Examples
444
517
 
445
518
  See [`examples/extensions/`](../examples/extensions/) for complete, working extensions ranging from minimal (charts — CLI + skill) to full-featured (napkin — hooks, jobs, config, widgets, KB distillation).
@@ -134,6 +134,7 @@ reads top-down and the first sections frame the rest.
134
134
  ## Silence when to say nothing and how (NO_UPDATE, "אין חדש")
135
135
  ## Integrity about what was done "I wrote it" only after a successful write; who can change what
136
136
  ## Dedupe / memory routine which file to read before posting and append after
137
+ ## Incoming files which uploads to keep, and where (see 4.7)
137
138
  ## Privacy "you may see it, you may not keep it" — never a denial of what is visible
138
139
  ## What this space does NOT do one line each; the reader's expectations, closed explicitly
139
140
  ## Overrides of platform text the explicit sentences (see 4.4)
@@ -248,6 +249,40 @@ is argued from memory. The football profile keeps the dates in
248
249
  `decisions.md` instead (every `D-0NN` names the day and its `Revisit if:`),
249
250
  which works as long as the file's section names match the decision ids.
250
251
 
252
+ ### 4.7 Incoming files: say which uploads to keep
253
+
254
+ `inbox/` is a transit directory. It is swept after `MERCURY_INBOX_TTL_DAYS`
255
+ (7 by default), and the sweep also nulls the attachment reference in
256
+ `state.db` — so a week later the bot cannot produce the file *or* name it. No
257
+ runtime rule can decide which uploads matter, because only the conversation
258
+ knows. That makes it a profile rule, and a profile without one silently loses
259
+ customer material.
260
+
261
+ The rule that works, in one section:
262
+
263
+ ```
264
+ ## Incoming files
265
+ A file sent with no request attached, or with "keep this" / "use this from now
266
+ on", is reference material: move it to `knowledge/assets/<name>`, write a short
267
+ note at `knowledge/references/<slug>.md` saying what it is and when to use it,
268
+ and tell the person where you put it.
269
+ A file sent as part of a request (edit this, summarise this) is working
270
+ material: use it and leave it. It disappears after a week and that is fine.
271
+ Never say you have kept a file unless you moved it.
272
+ ```
273
+
274
+ Two things that rule gets right and the obvious version does not. **Silence
275
+ defaults to keeping**: most uploads arriving with no question are meant to be
276
+ kept, so an unclassified file should be shelved, not discarded. And the last
277
+ line closes the integrity gap — the same one §"Integrity about what was done"
278
+ exists for, applied to storage rather than to writes.
279
+
280
+ Files the bot **sends** are the mirror problem and are *not* a profile rule:
281
+ `outbox/` is swept after three days, and the `archive` extension is what keeps
282
+ them. Install it and enable `archive.enabled` per space; the agent then has
283
+ `mrctl capability archive list|get`. See
284
+ [`../examples/extensions/archive/README.md`](../examples/extensions/archive/README.md).
285
+
251
286
  ---
252
287
 
253
288
  ## 5. Accuracy: put the computation in code
@@ -155,6 +155,37 @@ never be blanket-copied over from the source: the copy reverts the paths to
155
155
  placeholders and the agent's commands break at runtime. Copy, substitute,
156
156
  then `diff` and confirm the only difference is the placeholder.
157
157
 
158
+ ### Reference material is not a skill, and does not live in `inbox/`
159
+
160
+ A logo, a brand guide, a price list, a corpus a customer uploads "so you answer
161
+ better" — none of these is a skill, and none of them survives where they land.
162
+ `inbox/` is swept after `MERCURY_INBOX_TTL_DAYS` (7 by default), and the sweep
163
+ also nulls the attachment reference in `state.db`, so the agent later cannot
164
+ even name the file it was sent.
165
+
166
+ Put reference material in the space workspace, which is never swept:
167
+
168
+ | What | Where | Why there |
169
+ |---|---|---|
170
+ | the bytes (logo, PDF, spreadsheet) | `knowledge/assets/<name>` | outside every TTL; mounted every run |
171
+ | a note describing it | `knowledge/references/<slug>.md` | the vault map indexes it, so the agent finds it the same way it finds everything else |
172
+
173
+ `knowledge/assets/` is a plain directory, deliberately not a skill directory —
174
+ pi scans only the two roots in the table above, and a binary in a skill dir
175
+ would be listed in the system prompt as though it were an instruction. The note
176
+ in `references/` is what makes the asset discoverable; write it the way you
177
+ would write a skill description, naming the situation the file is for.
178
+
179
+ Deciding *which* uploads become reference material is a **profile rule**, not
180
+ something the runtime can infer — see `profile-guide.md`. The rule that works in
181
+ practice is "a file sent with no request attached, or with 'keep this', is
182
+ reference material": most uploads carrying no question are meant to be kept, so
183
+ silence should default to keeping rather than to discarding.
184
+
185
+ Files the assistant **sends** are a separate problem with a separate answer: the
186
+ `archive` extension keeps them past the `outbox/` TTL and gives the agent
187
+ `mrctl capability archive list|get`. It does not cover inbox uploads, by design.
188
+
158
189
  ## 7. Checklist before you ship a skill
159
190
 
160
191
  - [ ] `name` is lowercase-kebab, ≤64 chars; `description` ≤1024 chars, says
@@ -10,7 +10,9 @@ mercury add web-browser
10
10
 
11
11
  Or install from the dashboard's **Features** page (one click).
12
12
 
13
- No API key is required. The extension uses [pinchtab](https://www.npmjs.com/package/pinchtab) to control a local headless Chromium and navigates to the public [Brave Search](https://search.brave.com) website — the same way a human would.
13
+ No API key is required for the browser path. The extension uses [pinchtab](https://www.npmjs.com/package/pinchtab) to control a local headless Chromium and navigates to the public [Brave Search](https://search.brave.com) website — the same way a human would.
14
+
15
+ The separate `web-search` extension (`mercury add web-search`) adds structured site search (flights, hotels, cars, apartments) on top of pinchtab and, for fast keyword and news lookups, exposes the Brave Search **API** as a host-side capability: set `MERCURY_BRAVE_API_KEY` in `.env` and the agent can run `mrctl capability web-search news '{"q":"..."}'` (or `web`). The key is declared `hostOnly`, so it never enters a container; the host makes the call, applies a per-space hourly cap (`brave_hourly_cap`, default 60), and returns trimmed results.
14
16
 
15
17
  ## What It Provides
16
18
 
@@ -0,0 +1,146 @@
1
+ # archive
2
+
3
+ Keeps every file the assistant delivers, after `outbox/` is swept.
4
+
5
+ ## The problem it solves
6
+
7
+ `outbox/` is a delivery pipe that gets used as a memory. The storage sweep
8
+ removes its files after `MERCURY_OUTBOX_TTL_DAYS` (3 by default), and in a space
9
+ with no repo behind it — every customer space — the deliverable is then gone for
10
+ good. A customer asking for last month's report cannot be answered.
11
+
12
+ Raising the TTL only postpones that and costs disk on a box that does not have
13
+ it. This extension adds the missing tier instead: a permanent, per-space,
14
+ content-addressed store, with an index the agent can search and a delete path
15
+ for a single item or a whole customer.
16
+
17
+ ## How it decides what to keep
18
+
19
+ **Attachment is the classification.** The extension listens on `delivery`, which
20
+ fires once the reply is stored and the media gate has run, so it sees what was
21
+ committed for delivery. There is no tag for the agent to remember and no sweep
22
+ racing the TTL cleanup.
23
+
24
+ The event fires before the adapter's send returns, so a send that fails
25
+ afterwards — a dropped socket — leaves a file archived that never arrived.
26
+ That is deliberate: the alternative is archiving nothing until an
27
+ acknowledgement that some platforms never give, and over-keeping is the
28
+ recoverable direction.
29
+
30
+ What is therefore *not* archived, by design:
31
+
32
+ - files the media gate withheld — they were never delivered;
33
+ - files in `inbox/` that no run ever turned into a deliverable. Reference
34
+ material a customer wants kept (a logo, a brand guide, a price list) belongs
35
+ in `knowledge/assets/` with a note in `knowledge/references/`, which is a
36
+ profile rule, not this extension's job. See `docs/skills-guide.md`.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ mercury add ./examples/extensions/archive
42
+ ```
43
+
44
+ Then enable it per space — it is **off by default**, so installing it changes
45
+ nothing until you say so:
46
+
47
+ ```bash
48
+ mrctl config set archive.enabled true
49
+ ```
50
+
51
+ To turn it on for every space, set `archive.enabled` in the `@global` scope on
52
+ the dashboard, or in `mercury.yaml` under `extensions:`.
53
+
54
+ > On a deployment running an applicative profile, an extension is only reachable
55
+ > once its permission is in the profile's `member_permissions` (both the source
56
+ > YAML and the deployed `active-profile.json`). Without that the capability is
57
+ > silently unavailable to members.
58
+
59
+ ## Configuration
60
+
61
+ All host-side and `hostOnly` — none of it enters a container.
62
+
63
+ | Variable | Default | Meaning |
64
+ |---|---|---|
65
+ | `MERCURY_ARCHIVE_BACKEND` | `local` | `local` or `drive` |
66
+ | `MERCURY_ARCHIVE_LOCAL_DIR` | `<dataDir>/archive/blobs` | Where the local backend stores blobs |
67
+ | `MERCURY_ARCHIVE_DRIVE_FOLDER` | `Mercury Archive` | Root folder name in the connected Google account |
68
+ | `MERCURY_GWS_CREDENTIALS_JSON` | — | Required for `drive`; the same credential `gws` uses |
69
+
70
+ A backend that cannot be built disables the extension with an error in the log
71
+ and **never** breaks delivery. If you see `[archive] disabled — …` at startup,
72
+ that is this, and the reason is on the same line.
73
+
74
+ ### Which backend
75
+
76
+ **`local`** works with nothing configured. Point `MERCURY_ARCHIVE_LOCAL_DIR` at
77
+ a mounted Storage Box and the bytes leave the VPS without any further change.
78
+ On a bare VPS disk it does *not* survive that disk — that is the backup
79
+ runbook's job (`docs/runbooks/backup-offbox.md`), and archive and backup are
80
+ deliberately different systems with different retention.
81
+
82
+ **`drive`** reuses the Google credential the `gws` extension already needs, so a
83
+ deployment with Google connected gains an off-box archive with no new vendor and
84
+ no new account. On a customer's own VPS with their own Google account this also
85
+ settles ownership: the files are in the customer's Drive, so if they leave they
86
+ keep their deliverables.
87
+
88
+ Two things to check before enabling `drive`:
89
+
90
+ - **Is the OAuth app published?** An app still in "Testing" issues refresh
91
+ tokens that expire after 7 days. The archive will keep retrying and logging
92
+ `invalid_grant`. Publish the app in the GCP console, or expect to re-consent
93
+ weekly.
94
+ - **Whose Drive is it?** On a shared deployment the credential is the operator's,
95
+ so every customer's deliverables land in the operator's Drive. That is a
96
+ contractual question, not a technical one.
97
+
98
+ ## Erasure
99
+
100
+ One item, by an admin, from chat:
101
+
102
+ ```
103
+ mrctl capability archive forget '{"id":"<id>"}'
104
+ ```
105
+
106
+ Appends a tombstone and deletes the bytes unless another archived entry
107
+ references the same file. Permanent — on Drive this is `files.delete`, not the
108
+ trash.
109
+
110
+ A whole customer, by an operator, from the host:
111
+
112
+ ```bash
113
+ bun run scripts/archive-erase-space.ts <space-id> # dry run
114
+ bun run scripts/archive-erase-space.ts <space-id> --yes # do it
115
+ ```
116
+
117
+ Removes the backend prefix, the spool, the manifest and `ARCHIVE.md`. Dry-run by
118
+ default because it cannot be undone.
119
+
120
+ **After restoring from a backup**, re-run the drain and reconcile so tombstones
121
+ written after the snapshot are re-applied. Both are idempotent. Without this
122
+ step a restore silently resurrects erased files — the failure the EDPB's 2026
123
+ enforcement report singles out.
124
+
125
+ ## Operational notes
126
+
127
+ - **Never enable object lock or bucket versioning** on an archive store. They
128
+ make per-item erasure impossible, which is the one thing this must be able to
129
+ do.
130
+ - Uploads are queued: the delivery hook only hashes and copies to a host-side
131
+ spool, and a job every 60s does the upload with retries. A backend outage
132
+ grows the spool and logs; it never delays or breaks a reply.
133
+ - A weekly reconcile compares the store against the manifest. Orphaned blobs are
134
+ deleted. A live entry with no bytes is logged at **error** level and cannot be
135
+ repaired automatically — it means an archived file can no longer be delivered.
136
+
137
+ ## Layout
138
+
139
+ | File | What it is |
140
+ |---|---|
141
+ | `index.ts` | Wiring: permission, config, `delivery` hook, jobs, capability |
142
+ | `manifest.ts` | The append-only JSONL log and `ARCHIVE.md` rendering |
143
+ | `queue.ts` | The spool and the drain, including the blob-before-manifest rule |
144
+ | `gc.ts` | Erasure and the reconcile job |
145
+ | `backends/` | `local` and `drive`, behind one interface |
146
+ | `capability.ts` | `list` / `get` / `forget`, and the admin gate on `forget` |