mercury-agent 0.14.0 → 0.16.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 +9 -0
- package/container/Dockerfile +24 -31
- package/container/Dockerfile.base +22 -30
- package/container/agent-package.json +1 -1
- package/docs/configuration.md +86 -0
- package/docs/context-architecture.md +20 -0
- package/docs/deployment.md +36 -0
- package/examples/extensions/README.md +1 -0
- package/examples/extensions/gws/index.ts +34 -1
- package/examples/extensions/gws/skill/SKILL.md +11 -7
- package/examples/extensions/longview/index.ts +13 -1
- package/examples/extensions/napkin/index.ts +13 -1
- package/examples/extensions/poster/index.ts +92 -0
- package/examples/extensions/poster/skill/SKILL.md +281 -0
- package/examples/extensions/poster/skill/assets/amatic-sc.woff2 +0 -0
- package/examples/extensions/poster/skill/assets/frank-ruhl.woff2 +0 -0
- package/examples/extensions/poster/skill/assets/rubik-hebrew.woff2 +0 -0
- package/examples/extensions/poster/skill/assets/rubik-latin.woff2 +0 -0
- package/examples/extensions/poster/skill/assets/suez-one.woff2 +0 -0
- package/examples/extensions/poster/skill/references/authoring-contract.md +230 -0
- package/examples/extensions/poster/skill/references/example-layout.html +54 -0
- package/examples/extensions/poster/skill/scripts/lib/audit.mjs +131 -0
- package/examples/extensions/poster/skill/scripts/lib/providers.mjs +192 -0
- package/examples/extensions/poster/skill/scripts/lib/render.mjs +231 -0
- package/examples/extensions/poster/skill/scripts/lib/shell.mjs +489 -0
- package/examples/extensions/poster/skill/scripts/lib/validate.mjs +275 -0
- package/examples/extensions/poster/skill/scripts/poster.mjs +580 -0
- package/examples/extensions/poster/skill/templates/feature-grid.mjs +332 -0
- package/examples/extensions/poster/skill/templates/schedule-day.mjs +299 -0
- package/examples/extensions/poster/tests/poster-compose.test.ts +443 -0
- package/package.json +4 -4
- package/resources/skills/tasks/SKILL.md +13 -0
- package/resources/templates/mercury.example.yaml +8 -0
- package/src/agent/container-entry.ts +189 -3
- package/src/agent/container-runner.ts +74 -8
- package/src/cli/mercury.ts +215 -37
- package/src/cli/mrctl-http.ts +32 -0
- package/src/cli/mrctl.ts +10 -12
- package/src/cli/upgrade.ts +137 -0
- package/src/config-file.ts +30 -0
- package/src/config.ts +48 -0
- package/src/core/debounce.ts +124 -13
- package/src/core/handler.ts +20 -1
- package/src/core/operator-alerts.ts +442 -0
- package/src/core/process-tree.ts +124 -0
- package/src/core/profiles.ts +123 -15
- package/src/core/router.ts +23 -2
- package/src/core/routes/tasks.ts +7 -2
- package/src/core/runtime.ts +67 -6
- package/src/core/storage-cleanup.ts +47 -1
- package/src/core/system-messages.ts +13 -2
- package/src/core/task-output.ts +129 -0
- package/src/core/task-time.ts +164 -0
- package/src/extensions/catalog.ts +13 -0
- package/src/server.ts +12 -0
- package/src/storage/pi-auth.ts +374 -49
package/README.md
CHANGED
|
@@ -279,8 +279,17 @@ mercury service install
|
|
|
279
279
|
mercury service uninstall
|
|
280
280
|
mercury service status
|
|
281
281
|
mercury service logs [-f]
|
|
282
|
+
|
|
283
|
+
# upgrade (stops the service, installs globally, restarts)
|
|
284
|
+
mercury upgrade # latest
|
|
285
|
+
mercury upgrade 0.14.0 # a specific version
|
|
282
286
|
```
|
|
283
287
|
|
|
288
|
+
> **Windows:** stop Mercury before upgrading. Windows locks the native
|
|
289
|
+
> `libvips` DLL that a running Mercury has mapped, so a global install fails
|
|
290
|
+
> with `EBUSY`. `mercury upgrade` detects the lock and stops before npm leaves a
|
|
291
|
+
> rolled-back install behind — see [docs/deployment.md](docs/deployment.md#windows-stop-mercury-before-upgrading).
|
|
292
|
+
|
|
284
293
|
### `mrctl` (in-container API CLI)
|
|
285
294
|
|
|
286
295
|
```bash
|
package/container/Dockerfile
CHANGED
|
@@ -60,45 +60,38 @@ RUN echo '{"args":["--no-sandbox"]}' > /home/mercury/.puppeteerrc.json
|
|
|
60
60
|
ENV CHROMIUM_FLAGS="--no-sandbox"
|
|
61
61
|
|
|
62
62
|
# Install CLIs
|
|
63
|
-
RUN bun add -g @earendil-works/pi-coding-agent@~0.
|
|
63
|
+
RUN bun add -g @earendil-works/pi-coding-agent@~0.84.1
|
|
64
64
|
|
|
65
65
|
WORKDIR /app
|
|
66
66
|
|
|
67
67
|
COPY container/agent-package.json /app/package.json
|
|
68
68
|
RUN bun install --production
|
|
69
69
|
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
} catch(e) {}
|
|
93
|
-
}
|
|
94
|
-
} catch(e) {}
|
|
95
|
-
}
|
|
96
|
-
patch('/home/mercury/.bun');
|
|
97
|
-
patch('/app/node_modules');
|
|
98
|
-
EOF
|
|
70
|
+
# pi >= 0.84 handles Gemma 4's thinking config itself, so the patch that used to
|
|
71
|
+
# live here is gone. It string-replaced two exact lines in pi-ai's
|
|
72
|
+
# dist/providers/google.js to stop `thinkingBudget: 0` being sent to Gemma
|
|
73
|
+
# models, which the Google API rejects. In 0.84 that file moved to
|
|
74
|
+
# dist/api/google-generative-ai.js and gained `isGemma4Model()`, whose
|
|
75
|
+
# `getDisabledThinkingConfig()` returns `{ thinkingLevel: "MINIMAL" }` instead.
|
|
76
|
+
# Neither string the patch matched still exists.
|
|
77
|
+
#
|
|
78
|
+
# Narrower than what it replaces, deliberately and worth knowing: the patch
|
|
79
|
+
# gated on `model.id.startsWith("gemma")` — every Gemma — while `isGemma4Model`
|
|
80
|
+
# is `/gemma-?4/`, so anything else still falls through to `thinkingBudget: 0`.
|
|
81
|
+
# pi's built-in Google catalog only ships gemma-4 ids, so reaching the gap needs
|
|
82
|
+
# a hand-authored models.json entry for e.g. gemma-3 marked `reasoning: true`.
|
|
83
|
+
# If that turns up, fix it upstream rather than reinstating a source patch.
|
|
84
|
+
#
|
|
85
|
+
# Deleted rather than updated for a second reason: the patch swallowed every
|
|
86
|
+
# error (`catch(e) {}`) and only wrote when its marker was absent, so once pi
|
|
87
|
+
# moved the file it silently did nothing while still reading as protection. A
|
|
88
|
+
# no-op that looks like a safeguard is worse than no safeguard. If a future pi
|
|
89
|
+
# regresses here, reinstate it as a build step that FAILS when its pattern does
|
|
90
|
+
# not match.
|
|
99
91
|
|
|
100
92
|
# Fix ownership of all mercury home dir artifacts before switching user.
|
|
101
|
-
# Placed here — after the last step that writes to /home/mercury (the
|
|
93
|
+
# Placed here — after the last step that writes to /home/mercury (the global
|
|
94
|
+
# `bun add -g` above),
|
|
102
95
|
# but before the volatile /app source COPYs below — so this expensive `chown -R`
|
|
103
96
|
# (it walks the whole Chromium + .bun tree) lands in a stable cached layer.
|
|
104
97
|
# Editing source files no longer invalidates it or forces the huge layer re-export.
|
|
@@ -42,42 +42,34 @@ RUN echo '{"args":["--no-sandbox"]}' > /home/mercury/.puppeteerrc.json
|
|
|
42
42
|
ENV CHROMIUM_FLAGS="--no-sandbox"
|
|
43
43
|
|
|
44
44
|
# Install CLIs
|
|
45
|
-
RUN bun add -g @earendil-works/pi-coding-agent@~0.
|
|
45
|
+
RUN bun add -g @earendil-works/pi-coding-agent@~0.84.1
|
|
46
46
|
|
|
47
47
|
WORKDIR /app
|
|
48
48
|
|
|
49
49
|
COPY container/agent-package.json /app/package.json
|
|
50
50
|
RUN bun install --production
|
|
51
51
|
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
} catch(e) {}
|
|
75
|
-
}
|
|
76
|
-
} catch(e) {}
|
|
77
|
-
}
|
|
78
|
-
patch('/home/mercury/.bun');
|
|
79
|
-
patch('/app/node_modules');
|
|
80
|
-
EOF
|
|
52
|
+
# pi >= 0.84 handles Gemma 4's thinking config itself, so the patch that used to
|
|
53
|
+
# live here is gone. It string-replaced two exact lines in pi-ai's
|
|
54
|
+
# dist/providers/google.js to stop `thinkingBudget: 0` being sent to Gemma
|
|
55
|
+
# models, which the Google API rejects. In 0.84 that file moved to
|
|
56
|
+
# dist/api/google-generative-ai.js and gained `isGemma4Model()`, whose
|
|
57
|
+
# `getDisabledThinkingConfig()` returns `{ thinkingLevel: "MINIMAL" }` instead.
|
|
58
|
+
# Neither string the patch matched still exists.
|
|
59
|
+
#
|
|
60
|
+
# Narrower than what it replaces, deliberately and worth knowing: the patch
|
|
61
|
+
# gated on `model.id.startsWith("gemma")` — every Gemma — while `isGemma4Model`
|
|
62
|
+
# is `/gemma-?4/`, so anything else still falls through to `thinkingBudget: 0`.
|
|
63
|
+
# pi's built-in Google catalog only ships gemma-4 ids, so reaching the gap needs
|
|
64
|
+
# a hand-authored models.json entry for e.g. gemma-3 marked `reasoning: true`.
|
|
65
|
+
# If that turns up, fix it upstream rather than reinstating a source patch.
|
|
66
|
+
#
|
|
67
|
+
# Deleted rather than updated for a second reason: the patch swallowed every
|
|
68
|
+
# error (`catch(e) {}`) and only wrote when its marker was absent, so once pi
|
|
69
|
+
# moved the file it silently did nothing while still reading as protection. A
|
|
70
|
+
# no-op that looks like a safeguard is worse than no safeguard. If a future pi
|
|
71
|
+
# regresses here, reinstate it as a build step that FAILS when its pattern does
|
|
72
|
+
# not match.
|
|
81
73
|
|
|
82
74
|
COPY src/agent/container-entry.ts /app/src/agent/container-entry.ts
|
|
83
75
|
COPY src/agent/model-capabilities-core.ts /app/src/agent/model-capabilities-core.ts
|
package/docs/configuration.md
CHANGED
|
@@ -39,6 +39,41 @@ 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
|
+
## Operator alerts (`alerts:`)
|
|
43
|
+
|
|
44
|
+
When a provider OAuth credential can no longer be refreshed, every space stops
|
|
45
|
+
answering until someone re-authenticates on the host. Mercury DMs the configured
|
|
46
|
+
operators so the outage does not wait for a human to read the logs.
|
|
47
|
+
|
|
48
|
+
```yaml
|
|
49
|
+
alerts:
|
|
50
|
+
enabled: true # default: true
|
|
51
|
+
cooldown_ms: 21600000 # 60000 – 604800000 (default: 6h)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Env equivalents: **`MERCURY_ALERTS_ENABLED`**, **`MERCURY_ALERTS_COOLDOWN_MS`**.
|
|
55
|
+
|
|
56
|
+
- **Recipients** are the ids already listed in `permissions.admins` (`MERCURY_ADMINS`)
|
|
57
|
+
and `dm_auto_space.admin_ids` (`MERCURY_DM_AUTO_SPACE_ADMIN_IDS`)
|
|
58
|
+
— there is no separate alert-recipient key. An id is alerted only when it
|
|
59
|
+
resolves to an existing space **with a linked conversation**, i.e. a chat
|
|
60
|
+
Mercury can actually send into; an alert never creates a space. With no
|
|
61
|
+
configured admin, or none that has ever messaged the bot, the alert has
|
|
62
|
+
nowhere to go — Mercury logs that fact at ERROR and leaves the cooldown
|
|
63
|
+
unclaimed, so the next failure tries again.
|
|
64
|
+
- **Channel** is the ordinary adapter outbox, the same path scheduled-task
|
|
65
|
+
failures use. It carries no agent run and no LLM, so it still works when the
|
|
66
|
+
agent's own credential is what died.
|
|
67
|
+
- **`cooldown_ms`** is the minimum gap between identical alerts, per credential
|
|
68
|
+
and per reason. It is deliberately long: the alert names a manual recovery
|
|
69
|
+
step, and repeating it while an operator performs it adds nothing. The state is
|
|
70
|
+
stored in the database, so a crash-looping service does not re-alert on every
|
|
71
|
+
restart. A successful credential load clears it — if the credential dies again
|
|
72
|
+
later, that is news and alerts immediately.
|
|
73
|
+
- **Transient failures** (HTTP 408/429/5xx from the token endpoint) alert only
|
|
74
|
+
after three in a row; a single blip heals itself. A rejected refresh token, or
|
|
75
|
+
a cause that could not be classified, alerts on the first occurrence.
|
|
76
|
+
|
|
42
77
|
## Ambient group context
|
|
43
78
|
|
|
44
79
|
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:
|
|
@@ -74,6 +109,57 @@ Container env passthrough: all — these vars reach every space's container and
|
|
|
74
109
|
|
|
75
110
|
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.
|
|
76
111
|
|
|
112
|
+
## Agent run traces (`agent.trace_runs`)
|
|
113
|
+
|
|
114
|
+
Captures pi's raw output from inside each agent container, with arrival timestamps, and copies it to `<dataDir>/traces/` before the container is reaped:
|
|
115
|
+
|
|
116
|
+
```yaml
|
|
117
|
+
agent:
|
|
118
|
+
trace_runs: false # false (default) | true
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Env: `MERCURY_CONTAINER_TRACE_RUNS`.
|
|
122
|
+
|
|
123
|
+
Agent containers run with `--rm`, so pi's session logs die with the container. That is fine until a run misbehaves — a turn that hits `container_timeout_ms` is killed and leaves **nothing** to show where its time went. Tracing exists for exactly that question.
|
|
124
|
+
|
|
125
|
+
Each harvested file is `<dataDir>/traces/<container-name>.jsonl`, one JSON object per line:
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{"atMs":0,"stream":"meta","data":"{\"piSpawnedAtIso\":\"2026-08-13T09:15:02.104Z\"}"}
|
|
129
|
+
{"atMs":812,"stream":"stdout","data":"{\"type\":\"message_start\"…"}
|
|
130
|
+
{"atMs":47310,"stream":"stdout","data":"{\"type\":\"tool_call\"…"}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
- `atMs` — milliseconds since pi was spawned. **A long gap between consecutive lines is the finding**: it says which call the run was sitting inside.
|
|
134
|
+
- `stream` — `stdout`, `stderr`, or `meta`.
|
|
135
|
+
- A trace with no closing `meta` line carrying `exitCode` means the container was **killed** rather than exiting — itself a diagnosis. A `meta` line carrying `spawnError` instead means pi never started, which looks similar but is a different fault.
|
|
136
|
+
|
|
137
|
+
> **One file can hold several segments.** A run with a model chain, or one that retries a retryable failure, invokes pi more than once, and every invocation appends to the same file. Each one opens with its own `piSpawnedAtIso` `meta` line and **restarts `atMs` from zero**.
|
|
138
|
+
>
|
|
139
|
+
> So: split the file on `meta` lines carrying `piSpawnedAtIso`, and treat each segment separately. Comparing `atMs` across a boundary yields a negative gap and hides however long the earlier leg took — which matters, because a retry fires on exactly the misbehaving runs a trace is opened for. Concatenating `stdout` reconstructs pi's raw output byte for byte **within a segment**; glued across segments it parses as neither.
|
|
140
|
+
|
|
141
|
+
Writes go through `appendFileSync` rather than a buffered stream, deliberately: a buffered write loses whatever is in userspace when the process is SIGKILLed, which is the case the trace is for.
|
|
142
|
+
|
|
143
|
+
To find where a slow run actually spent its time — the largest gap between consecutive lines, and what the run was doing when it stalled:
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
bun -e 'const ls=require("fs").readFileSync(process.argv[1],"utf8").trim().split("\n").map(l=>JSON.parse(l));
|
|
147
|
+
let prev=0,seg=0,w={gapMs:0};for(const l of ls){
|
|
148
|
+
if(l.stream==="meta"&&String(l.data).includes("piSpawnedAtIso")){seg++;prev=0;continue}
|
|
149
|
+
const g=l.atMs-prev;if(g>w.gapMs)w={gapMs:g,seg,atMs:l.atMs,after:String(l.data).slice(0,160)};prev=l.atMs}
|
|
150
|
+
console.log(w)' .mercury/traces/<container-name>.jsonl
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The `prev=0` reset at each `meta` line is what keeps a multi-leg run honest — without it the boundary produces a negative gap and the earlier leg's stall never wins.
|
|
154
|
+
|
|
155
|
+
`after` is the output that arrived *when the gap ended*, so the call that hung is the one immediately before it.
|
|
156
|
+
|
|
157
|
+
> ⚠️ **A trace contains the full prompt and the full reply in clear text** — real conversation content, including anything group members said. Treat it like `log_level: debug`: switch it on for a bounded window, capture the run you need, and switch it back.
|
|
158
|
+
|
|
159
|
+
`<dataDir>/traces/` is swept by the storage cleanup: a trace file is deleted once it is older than **`MERCURY_TRACE_TTL_DAYS`** (default `7`). The sweep runs whether or not `agent.trace_runs` is currently on — otherwise switching tracing off would strand that window's traces on disk permanently, which is the case the retention exists for. Shorten it if traces are being captured on a host where the conversation content is sensitive; deleting files by hand is still fine at any time.
|
|
160
|
+
|
|
161
|
+
> ⚠️ **Changing this setting needs an agent image rebuild, not just a restart.** The capture half lives in `src/agent/container-entry.ts`, which is baked into the image (`container/Dockerfile`). A restart alone deploys the host-side half and leaves tracing silently doing nothing.
|
|
162
|
+
|
|
77
163
|
## Extension config defaults (`extensions:`)
|
|
78
164
|
|
|
79
165
|
Deployment-wide defaults for extension config keys, applied to **every space** (including auto-created DM spaces) at read time:
|
|
@@ -68,6 +68,12 @@ The session boundary (`chat_state.min_message_id`) excludes messages older than
|
|
|
68
68
|
|
|
69
69
|
```
|
|
70
70
|
<caller id="…" name="…" role="…" space="…" />
|
|
71
|
+
<run_budget> ← wall-clock window for this run
|
|
72
|
+
<started_at>…</started_at>
|
|
73
|
+
<killed_at>…</killed_at> ← host kills the container here
|
|
74
|
+
<begin_final_answer_by>…</begin_final_answer_by>
|
|
75
|
+
<total_budget>…</total_budget>
|
|
76
|
+
</run_budget>
|
|
71
77
|
<episodic_memory>…</episodic_memory> ← MEMORY.md (if present)
|
|
72
78
|
<active_episodes>…</active_episodes> ← relevance-scored episode snippets
|
|
73
79
|
<history> ← sliding window from DB
|
|
@@ -85,6 +91,20 @@ The session boundary (`chat_state.min_message_id`) excludes messages older than
|
|
|
85
91
|
[user prompt text]
|
|
86
92
|
```
|
|
87
93
|
|
|
94
|
+
`<run_budget>` carries the wall clock the host enforces. `containerTimeoutMs`
|
|
95
|
+
kills the container at `killed_at`, and before this block existed the agent had
|
|
96
|
+
no way to learn that — the first and only signal was SIGKILL, too late to pace
|
|
97
|
+
the work or save partial findings. `begin_final_answer_by` reserves a
|
|
98
|
+
proportional slice of the budget (20%, floored at 30s, capped at 5min) for
|
|
99
|
+
composing and sending the answer. The block is omitted entirely when the host
|
|
100
|
+
supplies no window, or an incoherent one — a missing budget beats one claiming
|
|
101
|
+
the run is already over.
|
|
102
|
+
|
|
103
|
+
The matching `## Run budget` instructions live in the system prompt. They are
|
|
104
|
+
gated more tightly than the block itself: a toolless model gets the block (the
|
|
105
|
+
kill time is useful context regardless) but not the instructions, which all
|
|
106
|
+
assume a shell to run `date` in and a file tool to save findings with.
|
|
107
|
+
|
|
88
108
|
---
|
|
89
109
|
|
|
90
110
|
## Why Not a Pi Session File?
|
package/docs/deployment.md
CHANGED
|
@@ -99,6 +99,42 @@ Not currently supported via `mercury service`. Options:
|
|
|
99
99
|
2. **NSSM**: Use [NSSM](https://nssm.cc/) to wrap Mercury as a Windows service
|
|
100
100
|
3. **PM2**: Use `pm2 start "mercury run" --name mercury`
|
|
101
101
|
|
|
102
|
+
## Upgrading
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
mercury upgrade # to the latest published version
|
|
106
|
+
mercury upgrade 0.14.0 # to a specific version
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
On macOS and Linux this stops the managed service, installs the new version
|
|
110
|
+
globally, and restarts the service.
|
|
111
|
+
|
|
112
|
+
### Windows: stop Mercury before upgrading
|
|
113
|
+
|
|
114
|
+
Windows will not let a file be replaced while a running process has it mapped.
|
|
115
|
+
Mercury reaches `sharp` transitively (via `@whiskeysockets/baileys`, which loads
|
|
116
|
+
it on demand to build image thumbnails), and importing sharp maps
|
|
117
|
+
`libvips-*.dll` out of the global install tree. Once a running Mercury has
|
|
118
|
+
handled a single image, a plain `npm install -g mercury-agent@latest` fails:
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
npm error code EBUSY
|
|
122
|
+
npm error EBUSY: resource busy or locked
|
|
123
|
+
npm error path ...\node_modules\@img\sharp-win32-x64\lib\libvips-42.dll
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`mercury upgrade` detects this before npm does and stops with the list of locked
|
|
127
|
+
files instead of leaving a rolled-back install behind. The fix is to stop Mercury
|
|
128
|
+
first:
|
|
129
|
+
|
|
130
|
+
1. Stop every running Mercury — `Ctrl+C` in the `mercury run` terminal, or stop
|
|
131
|
+
the NSSM / Task Scheduler / PM2 entry wrapping it.
|
|
132
|
+
2. `mercury upgrade`
|
|
133
|
+
3. Start Mercury again.
|
|
134
|
+
|
|
135
|
+
Mercury's own CLI does not load the WhatsApp stack at startup, so running
|
|
136
|
+
`mercury upgrade` never locks the tree it is replacing.
|
|
137
|
+
|
|
102
138
|
## Auto-Restart Behavior
|
|
103
139
|
|
|
104
140
|
Both systemd and launchd are configured to automatically restart Mercury if it crashes:
|
|
@@ -9,4 +9,5 @@ Real-world Mercury extensions. Copy any of these into `.mercury/extensions/` to
|
|
|
9
9
|
| **gws** | Google Workspace (Drive/Gmail/Calendar/etc.) | cli, skill, permission (admin-only default) |
|
|
10
10
|
| **pinchtab** | Browser automation via Playwright | cli, skill, permission, `before_container` hook (env + system prompt) |
|
|
11
11
|
| **napkin** | Obsidian vault management + KB distillation | cli, skill, permission, `workspace_init` hook, `before_container` hook, job, config, widget, store |
|
|
12
|
+
| **poster** | Styled posters with correct Hebrew/RTL text — AI background art + deterministic HTML typography | cli, skill, permission, env, config, `requires`, `before_container` hook (config → env) |
|
|
12
13
|
|
|
@@ -56,9 +56,42 @@ const gwsEnv = {
|
|
|
56
56
|
const CREDENTIALS_FILE = "/tmp/gws-credentials.json";
|
|
57
57
|
|
|
58
58
|
export default function (mercury: MercuryExt) {
|
|
59
|
+
// The gws CLI can only take refresh-token credentials from a file, so someone
|
|
60
|
+
// has to materialize GWS_CREDENTIALS_JSON before the first command runs. The
|
|
61
|
+
// before_container hook can't (see the comment on that hook below), and asking
|
|
62
|
+
// the skill to do it makes credential delivery depend on the model remembering
|
|
63
|
+
// a prose instruction. Instead, /usr/local/bin/gws is a wrapper that does it on
|
|
64
|
+
// every invocation and execs the real binary.
|
|
65
|
+
//
|
|
66
|
+
// Ordering note: parseInstallCommand() keeps a command that mixes a package
|
|
67
|
+
// manager with shell parts intact as a single shell step, which is what makes
|
|
68
|
+
// the `mv` reliably run after the npm install. Do NOT split this into separate
|
|
69
|
+
// mercury.cli() declarations — mergeInstalls() would then reorder npm ahead of
|
|
70
|
+
// shell only by coincidence of grouping, and the wrapper would be clobbered.
|
|
59
71
|
mercury.cli({
|
|
60
72
|
name: "gws",
|
|
61
|
-
install:
|
|
73
|
+
install: [
|
|
74
|
+
"npm install -g @googleworkspace/cli",
|
|
75
|
+
'mv "$(command -v gws)" /usr/local/bin/gws-real',
|
|
76
|
+
// Written line-by-line via printf so the credentials never reach a traced
|
|
77
|
+
// shell; $GWS_CREDENTIALS_JSON is single-quoted here and expanded only at
|
|
78
|
+
// runtime, inside the wrapper.
|
|
79
|
+
"printf '%s\\n' " +
|
|
80
|
+
"'#!/bin/sh' " +
|
|
81
|
+
"'if [ -n \"$GWS_CREDENTIALS_JSON\" ]; then' " +
|
|
82
|
+
`' c="\${GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE:-${CREDENTIALS_FILE}}"' ` +
|
|
83
|
+
"' t=\"$c.$$\"' " +
|
|
84
|
+
"' (umask 077; printf %s \"$GWS_CREDENTIALS_JSON\" > \"$t\")' " +
|
|
85
|
+
// Credentials changed (rotation, or a different caller in a reused
|
|
86
|
+
// container) invalidates the cached access token alongside them.
|
|
87
|
+
"' cmp -s \"$t\" \"$c\" 2>/dev/null || rm -f \"${GOOGLE_WORKSPACE_CLI_CONFIG_DIR:-$HOME/.config/gws}/token_cache.json\"' " +
|
|
88
|
+
"' mv -f \"$t\" \"$c\"' " +
|
|
89
|
+
"' GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE=\"$c\"; export GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE' " +
|
|
90
|
+
"'fi' " +
|
|
91
|
+
"'exec /usr/local/bin/gws-real \"$@\"' " +
|
|
92
|
+
"> /usr/local/bin/gws",
|
|
93
|
+
"chmod 755 /usr/local/bin/gws",
|
|
94
|
+
].join(" && "),
|
|
62
95
|
});
|
|
63
96
|
|
|
64
97
|
mercury.permission({ defaultRoles: ["admin"] });
|
|
@@ -23,16 +23,20 @@ Use the `gws` CLI via Bash for all Google Workspace operations.
|
|
|
23
23
|
| JSON objects or arrays | Summarise in prose |
|
|
24
24
|
| Email `labelIds`, `threadId`, `messageId` | Never shown |
|
|
25
25
|
|
|
26
|
-
## Credentials
|
|
26
|
+
## Credentials
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
Credentials are set up automatically — there is **no** setup step to run. Never
|
|
29
|
+
write a credentials file yourself and never set
|
|
30
|
+
`GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE`; the `gws` command does both on every
|
|
31
|
+
invocation.
|
|
29
32
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
gws auth status
|
|
33
|
-
```
|
|
33
|
+
If a command fails on authentication, run `gws auth status` and report the cause
|
|
34
|
+
accurately:
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
| `gws auth status` says | What it actually means — report this |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `"auth_method": "none"` | The caller does not have the `gws` permission, or no Google Workspace connection has been set up for this deployment. Say that — do **not** say Google Workspace is broken or misconfigured. |
|
|
39
|
+
| `"auth_method": "oauth2"` but the command still fails | Credentials are present and delivered. The failure is API-side (revoked token, missing scope, or a per-service permission), not a Mercury configuration problem. Report the API's own error message. |
|
|
36
40
|
|
|
37
41
|
## Dispatch table
|
|
38
42
|
|
|
@@ -20,6 +20,7 @@ import { join, resolve } from "node:path";
|
|
|
20
20
|
import {
|
|
21
21
|
getPiAuthCredential,
|
|
22
22
|
parseOAuthTokenEnv,
|
|
23
|
+
providerCredentialEnvVar,
|
|
23
24
|
} from "mercury-agent/storage/pi-auth";
|
|
24
25
|
import {
|
|
25
26
|
DEFAULT_THRESHOLD,
|
|
@@ -130,12 +131,23 @@ export default function (mercury: {
|
|
|
130
131
|
const authPath = resolve(
|
|
131
132
|
config.authPath ?? join(config.globalDir, "auth.json"),
|
|
132
133
|
);
|
|
134
|
+
// Name the credential the way pi reads it for this provider — the same
|
|
135
|
+
// shared table container-runner injects from, so the two spawn paths can't
|
|
136
|
+
// drift apart again. Asked *before* resolving, in that same order: a
|
|
137
|
+
// provider pi reads no env var for (openai-codex) can never be served, and
|
|
138
|
+
// resolving would spend a single-use refresh token to learn that. Only
|
|
139
|
+
// non-anthropic providers can land here, so the fail-fast below is
|
|
140
|
+
// unaffected — anthropic always has an env var.
|
|
141
|
+
const credEnvVar = providerCredentialEnvVar(config.modelProvider);
|
|
142
|
+
if (!credEnvVar) {
|
|
143
|
+
return { ok: true, env };
|
|
144
|
+
}
|
|
133
145
|
const cred = await getPiAuthCredential({
|
|
134
146
|
provider: config.modelProvider,
|
|
135
147
|
authPath,
|
|
136
148
|
});
|
|
137
149
|
if (cred.status === "ok") {
|
|
138
|
-
env
|
|
150
|
+
env[credEnvVar] = cred.apiKey;
|
|
139
151
|
return { ok: true, env };
|
|
140
152
|
}
|
|
141
153
|
|
|
@@ -14,6 +14,7 @@ import { delimiter, dirname, join, resolve } from "node:path";
|
|
|
14
14
|
import {
|
|
15
15
|
getPiAuthCredential,
|
|
16
16
|
parseOAuthTokenEnv,
|
|
17
|
+
providerCredentialEnvVar,
|
|
17
18
|
} from "mercury-agent/storage/pi-auth";
|
|
18
19
|
|
|
19
20
|
const KNOWLEDGE_DIR = "knowledge";
|
|
@@ -458,12 +459,23 @@ export default function (mercury: {
|
|
|
458
459
|
const authPath = resolve(
|
|
459
460
|
config.authPath ?? join(config.globalDir, "auth.json"),
|
|
460
461
|
);
|
|
462
|
+
// Name the credential the way pi reads it for this provider — the same
|
|
463
|
+
// shared table container-runner injects from, so the two spawn paths can't
|
|
464
|
+
// drift apart again. Asked *before* resolving, in that same order: a
|
|
465
|
+
// provider pi reads no env var for (openai-codex) can never be served, and
|
|
466
|
+
// resolving would spend a single-use refresh token to learn that. Only
|
|
467
|
+
// non-anthropic providers can land here, so the fail-fast below is
|
|
468
|
+
// unaffected — anthropic always has an env var.
|
|
469
|
+
const credEnvVar = providerCredentialEnvVar(config.modelProvider);
|
|
470
|
+
if (!credEnvVar) {
|
|
471
|
+
return { ok: true, env };
|
|
472
|
+
}
|
|
461
473
|
const cred = await getPiAuthCredential({
|
|
462
474
|
provider: config.modelProvider,
|
|
463
475
|
authPath,
|
|
464
476
|
});
|
|
465
477
|
if (cred.status === "ok") {
|
|
466
|
-
env
|
|
478
|
+
env[credEnvVar] = cred.apiKey;
|
|
467
479
|
return { ok: true, env };
|
|
468
480
|
}
|
|
469
481
|
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* poster — styled poster generation with correct Hebrew (and other RTL) text.
|
|
3
|
+
*
|
|
4
|
+
* Two layers, deliberately separated:
|
|
5
|
+
*
|
|
6
|
+
* 1. art — an image model paints the background (photographic scene, textures,
|
|
7
|
+
* product plates). Prompted to render NO text at all.
|
|
8
|
+
* 2. render — Chromium composites the copy from an HTML template using a real
|
|
9
|
+
* embedded Hebrew font.
|
|
10
|
+
*
|
|
11
|
+
* The split exists because no image model renders Hebrew reliably. Nano Banana Pro
|
|
12
|
+
* is the best available (Gemini 3 backbone plans layout before rendering) but
|
|
13
|
+
* accuracy is length-dependent — ~100% on headlines, ~80% at 15-30 words, ~60%
|
|
14
|
+
* beyond — and Arabic, the closest published RTL analogue to Hebrew, sits at ~75%
|
|
15
|
+
* per short line. A poster with twenty text runs is never fully clean. Layer 2
|
|
16
|
+
* makes the typography deterministic: fonts, RTL, kerning and safe-area geometry
|
|
17
|
+
* are code, not a prompt.
|
|
18
|
+
*
|
|
19
|
+
* No CLI install step is needed. The container base image is the Microsoft
|
|
20
|
+
* Playwright image and already provides `/usr/local/bin/chromium` plus Bun, so
|
|
21
|
+
* `mercury.cli()` only drops a shim onto the read-only skill mount.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { MercuryExtensionAPI } from "mercury-agent/extensions/types";
|
|
25
|
+
|
|
26
|
+
const EXT = "poster";
|
|
27
|
+
|
|
28
|
+
/** Skill dir as mounted inside the container (read-only). */
|
|
29
|
+
const SKILL_DIR = `/home/mercury/.pi/agent/skills/${EXT}`;
|
|
30
|
+
|
|
31
|
+
export default function setup(mercury: MercuryExtensionAPI) {
|
|
32
|
+
// A shim rather than an npm package: the implementation ships in the skill
|
|
33
|
+
// directory, so there is nothing to publish and nothing to version separately.
|
|
34
|
+
mercury.cli({
|
|
35
|
+
name: EXT,
|
|
36
|
+
install: [
|
|
37
|
+
`printf '#!/bin/sh\\nexec bun ${SKILL_DIR}/scripts/poster.mjs "$@"\\n'`,
|
|
38
|
+
`> /usr/local/bin/${EXT}`,
|
|
39
|
+
`&& chmod +x /usr/local/bin/${EXT}`,
|
|
40
|
+
].join(" "),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
mercury.permission({ defaultRoles: ["admin", "member"] });
|
|
44
|
+
|
|
45
|
+
// Layer 2 is free; layer 1 needs whichever provider is configured. Keys are
|
|
46
|
+
// only injected for callers who hold the `poster` permission.
|
|
47
|
+
mercury.env({ from: "MERCURY_POSTER_GEMINI_KEY", as: "POSTER_GEMINI_KEY" });
|
|
48
|
+
mercury.env({ from: "MERCURY_POSTER_OPENAI_KEY", as: "POSTER_OPENAI_KEY" });
|
|
49
|
+
|
|
50
|
+
mercury.config("provider", {
|
|
51
|
+
description:
|
|
52
|
+
"Art-layer image provider: gemini (Nano Banana Pro), openai (GPT Image 2), or local (self-hosted HTTP endpoint).",
|
|
53
|
+
default: "gemini",
|
|
54
|
+
validate: (v) => v === "gemini" || v === "openai" || v === "local",
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
mercury.config("local_endpoint", {
|
|
58
|
+
description:
|
|
59
|
+
"HTTP endpoint for provider=local, e.g. http://host.docker.internal:8188/mercury-poster. POSTed {prompt,width,height}; must return {image_b64} or a raw PNG.",
|
|
60
|
+
default: "",
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
mercury.config("size", {
|
|
64
|
+
description:
|
|
65
|
+
"Default art-layer resolution: 1K, 2K or 4K. Gemini bills 2K and 1K identically, so 2K is the sensible floor.",
|
|
66
|
+
default: "2K",
|
|
67
|
+
validate: (v) => v === "1K" || v === "2K" || v === "4K",
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// The CLI runs inside the container and cannot read space config directly, so
|
|
71
|
+
// resolve it host-side and hand it over as env.
|
|
72
|
+
mercury.on("before_container", async (event, ctx) => {
|
|
73
|
+
return {
|
|
74
|
+
env: {
|
|
75
|
+
POSTER_PROVIDER:
|
|
76
|
+
ctx.getConfig(event.spaceId, `${EXT}.provider`) ?? "gemini",
|
|
77
|
+
POSTER_LOCAL_ENDPOINT:
|
|
78
|
+
ctx.getConfig(event.spaceId, `${EXT}.local_endpoint`) ?? "",
|
|
79
|
+
POSTER_SIZE: ctx.getConfig(event.spaceId, `${EXT}.size`) ?? "2K",
|
|
80
|
+
POSTER_SKILL_DIR: SKILL_DIR,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Both layers are driven from bash; without tool use the skill is inert.
|
|
86
|
+
// Note that `requires` gates the *skill* install, not the `cli()` install: on a
|
|
87
|
+
// chain leg with no tool use the `poster` shim still exists but points at a skill
|
|
88
|
+
// directory that was never copied. Harmless, but the failure reads as a bun
|
|
89
|
+
// stack trace rather than "command not found".
|
|
90
|
+
mercury.requires(["tools"]);
|
|
91
|
+
mercury.skill("./skill");
|
|
92
|
+
}
|