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.
Files changed (56) hide show
  1. package/README.md +9 -0
  2. package/container/Dockerfile +24 -31
  3. package/container/Dockerfile.base +22 -30
  4. package/container/agent-package.json +1 -1
  5. package/docs/configuration.md +86 -0
  6. package/docs/context-architecture.md +20 -0
  7. package/docs/deployment.md +36 -0
  8. package/examples/extensions/README.md +1 -0
  9. package/examples/extensions/gws/index.ts +34 -1
  10. package/examples/extensions/gws/skill/SKILL.md +11 -7
  11. package/examples/extensions/longview/index.ts +13 -1
  12. package/examples/extensions/napkin/index.ts +13 -1
  13. package/examples/extensions/poster/index.ts +92 -0
  14. package/examples/extensions/poster/skill/SKILL.md +281 -0
  15. package/examples/extensions/poster/skill/assets/amatic-sc.woff2 +0 -0
  16. package/examples/extensions/poster/skill/assets/frank-ruhl.woff2 +0 -0
  17. package/examples/extensions/poster/skill/assets/rubik-hebrew.woff2 +0 -0
  18. package/examples/extensions/poster/skill/assets/rubik-latin.woff2 +0 -0
  19. package/examples/extensions/poster/skill/assets/suez-one.woff2 +0 -0
  20. package/examples/extensions/poster/skill/references/authoring-contract.md +230 -0
  21. package/examples/extensions/poster/skill/references/example-layout.html +54 -0
  22. package/examples/extensions/poster/skill/scripts/lib/audit.mjs +131 -0
  23. package/examples/extensions/poster/skill/scripts/lib/providers.mjs +192 -0
  24. package/examples/extensions/poster/skill/scripts/lib/render.mjs +231 -0
  25. package/examples/extensions/poster/skill/scripts/lib/shell.mjs +489 -0
  26. package/examples/extensions/poster/skill/scripts/lib/validate.mjs +275 -0
  27. package/examples/extensions/poster/skill/scripts/poster.mjs +580 -0
  28. package/examples/extensions/poster/skill/templates/feature-grid.mjs +332 -0
  29. package/examples/extensions/poster/skill/templates/schedule-day.mjs +299 -0
  30. package/examples/extensions/poster/tests/poster-compose.test.ts +443 -0
  31. package/package.json +4 -4
  32. package/resources/skills/tasks/SKILL.md +13 -0
  33. package/resources/templates/mercury.example.yaml +8 -0
  34. package/src/agent/container-entry.ts +189 -3
  35. package/src/agent/container-runner.ts +74 -8
  36. package/src/cli/mercury.ts +215 -37
  37. package/src/cli/mrctl-http.ts +32 -0
  38. package/src/cli/mrctl.ts +10 -12
  39. package/src/cli/upgrade.ts +137 -0
  40. package/src/config-file.ts +30 -0
  41. package/src/config.ts +48 -0
  42. package/src/core/debounce.ts +124 -13
  43. package/src/core/handler.ts +20 -1
  44. package/src/core/operator-alerts.ts +442 -0
  45. package/src/core/process-tree.ts +124 -0
  46. package/src/core/profiles.ts +123 -15
  47. package/src/core/router.ts +23 -2
  48. package/src/core/routes/tasks.ts +7 -2
  49. package/src/core/runtime.ts +67 -6
  50. package/src/core/storage-cleanup.ts +47 -1
  51. package/src/core/system-messages.ts +13 -2
  52. package/src/core/task-output.ts +129 -0
  53. package/src/core/task-time.ts +164 -0
  54. package/src/extensions/catalog.ts +13 -0
  55. package/src/server.ts +12 -0
  56. 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
@@ -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.79.6
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
- # Patch pi: skip thinkingConfig for Gemma models (Google API rejects thinkingBudget for them,
71
- # but pi marks gemma-4 as reasoning:true and sends thinkingBudget:0 to disable it)
72
- RUN node <<'EOF'
73
- const fs = require('fs'), path = require('path');
74
- function patch(dir) {
75
- try {
76
- for (const f of fs.readdirSync(dir)) {
77
- const p = path.join(dir, f);
78
- try {
79
- if (fs.statSync(p).isDirectory()) patch(p);
80
- else if (f === 'google.js' && p.includes('@earendil-works/pi-ai')) {
81
- let c = fs.readFileSync(p, 'utf8');
82
- const noThinking = '&& !model.id.startsWith("gemma")';
83
- const p1 = 'if (options.thinking?.enabled && model.reasoning) {';
84
- const p2 = 'else if (model.reasoning && options.thinking && !options.thinking.enabled) {';
85
- if (!c.includes(noThinking)) {
86
- c = c.replace(p1, 'if (options.thinking?.enabled && model.reasoning ' + noThinking + ') {');
87
- c = c.replace(p2, 'else if (model.reasoning && options.thinking && !options.thinking.enabled ' + noThinking + ') {');
88
- fs.writeFileSync(p, c);
89
- console.log('Patched:', p);
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 pi patch),
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.79.6
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
- # Patch pi: skip thinkingConfig for Gemma models (Google API rejects thinkingBudget for them,
53
- # but pi marks gemma-4 as reasoning:true and sends thinkingBudget:0 to disable it)
54
- RUN node <<'EOF'
55
- const fs = require('fs'), path = require('path');
56
- function patch(dir) {
57
- try {
58
- for (const f of fs.readdirSync(dir)) {
59
- const p = path.join(dir, f);
60
- try {
61
- if (fs.statSync(p).isDirectory()) patch(p);
62
- else if (f === 'google.js' && p.includes('@earendil-works/pi-ai')) {
63
- let c = fs.readFileSync(p, 'utf8');
64
- const noThinking = '&& !model.id.startsWith("gemma")';
65
- const p1 = 'if (options.thinking?.enabled && model.reasoning) {';
66
- const p2 = 'else if (model.reasoning && options.thinking && !options.thinking.enabled) {';
67
- if (!c.includes(noThinking)) {
68
- c = c.replace(p1, 'if (options.thinking?.enabled && model.reasoning ' + noThinking + ') {');
69
- c = c.replace(p2, 'else if (model.reasoning && options.thinking && !options.thinking.enabled ' + noThinking + ') {');
70
- fs.writeFileSync(p, c);
71
- console.log('Patched:', p);
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
@@ -3,6 +3,6 @@
3
3
  "private": true,
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@earendil-works/pi-coding-agent": "~0.79.6"
6
+ "@earendil-works/pi-coding-agent": "~0.84.1"
7
7
  }
8
8
  }
@@ -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?
@@ -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: "npm install -g @googleworkspace/cli",
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 setup (run once per session before any gws command)
26
+ ## Credentials
27
27
 
28
- `GWS_CREDENTIALS_JSON` contains the credentials as a JSON string. Materialize it to the path gws expects, then verify auth:
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
- ```bash
31
- [ -n "$GWS_CREDENTIALS_JSON" ] && echo "$GWS_CREDENTIALS_JSON" > "${GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE:-/tmp/gws-credentials.json}" && export GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE="${GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE:-/tmp/gws-credentials.json}"
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
- If `auth_method` is not `none`, credentials are ready. Skip this step on subsequent calls in the same session (the file persists in /tmp for the container lifetime).
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.ANTHROPIC_API_KEY = cred.apiKey;
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.ANTHROPIC_API_KEY = cred.apiKey;
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
+ }