mercury-agent 0.13.0 → 0.15.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/docs/container-lifecycle.md +4 -3
- package/docs/deployment.md +36 -0
- package/docs/permissions.md +3 -1
- package/examples/extensions/README.md +1 -0
- package/examples/extensions/gws/README.md +25 -6
- 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 +227 -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/scripts/lib/providers.mjs +192 -0
- package/examples/extensions/poster/skill/scripts/lib/render.mjs +195 -0
- package/examples/extensions/poster/skill/scripts/poster.mjs +355 -0
- package/examples/extensions/poster/skill/templates/feature-grid.mjs +349 -0
- package/examples/extensions/poster/skill/templates/schedule-day.mjs +326 -0
- package/package.json +1 -1
- package/resources/templates/AGENTS.md +8 -0
- package/src/adapters/whatsapp.ts +4 -1
- package/src/agent/container-runner.ts +15 -3
- package/src/bridges/whatsapp.ts +6 -14
- package/src/cli/mercury.ts +206 -36
- package/src/cli/mrctl.ts +44 -0
- package/src/cli/upgrade.ts +137 -0
- package/src/core/api.ts +2 -0
- package/src/core/debounce.ts +124 -13
- package/src/core/handler.ts +20 -1
- package/src/core/model-command.ts +55 -0
- package/src/core/permissions.ts +8 -1
- 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/index.ts +1 -0
- package/src/core/routes/model.ts +68 -0
- package/src/core/runtime.ts +49 -20
- package/src/core/system-messages.ts +6 -4
- package/src/extensions/catalog.ts +13 -0
- package/src/server.ts +12 -0
- package/src/storage/pi-auth.ts +316 -47
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
|
|
@@ -96,8 +96,9 @@ Containers have a maximum runtime to prevent runaway processes.
|
|
|
96
96
|
When a container exceeds the timeout:
|
|
97
97
|
1. Container is killed via `docker kill`
|
|
98
98
|
2. `ContainerError` thrown with `reason: "timeout"`
|
|
99
|
-
3. User sees: "
|
|
100
|
-
4.
|
|
99
|
+
3. User sees: "I ran out of time before finishing. Partial work may be saved in the workspace — ask me to continue."
|
|
100
|
+
4. A marker assistant message ("[System: this run was killed at the container time limit before replying. …]") is recorded in chat history, so the next run's agent knows the previous run was cut off and can check the workspace for partial work
|
|
101
|
+
5. Queue unblocks, next message can proceed
|
|
101
102
|
|
|
102
103
|
The host always injects a resolved **model chain** into the container (after `MERCURY_*` passthrough) so retries and fallbacks use the same policy Mercury loaded at startup:
|
|
103
104
|
|
|
@@ -113,7 +114,7 @@ Container failures are classified by `ContainerError`:
|
|
|
113
114
|
|
|
114
115
|
| Reason | Exit Code | Cause | User Message |
|
|
115
116
|
|--------|-----------|-------|--------------|
|
|
116
|
-
| `timeout` | — | Exceeded `containerTimeoutMs` | "
|
|
117
|
+
| `timeout` | — | Exceeded `containerTimeoutMs` | "I ran out of time before finishing. Partial work may be saved in the workspace — ask me to continue." |
|
|
117
118
|
| `oom` | 137 | SIGKILL (OOM, resource limits, or manual kill) | "Container was killed (possibly out of memory)." |
|
|
118
119
|
| `aborted` | — | User sent `stop` command | "Stopped current run." |
|
|
119
120
|
| `error` | non-zero | Agent crashed or failed | *(error thrown, logged)* |
|
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:
|
package/docs/permissions.md
CHANGED
|
@@ -28,7 +28,7 @@ Message arrives
|
|
|
28
28
|
|------|---------------------|-------------|
|
|
29
29
|
| `system` | All | Internal system caller (scheduler, etc.) — not assignable |
|
|
30
30
|
| `admin` | All | Full control over the space |
|
|
31
|
-
| `member` | `prompt`, `prefs.get`, `media.receive`, `media.send` | Can chat, read space preferences,
|
|
31
|
+
| `member` | `prompt`, `prefs.get`, `media.receive`, `media.send`, `model.list` | Can chat, read space preferences, exchange files, and list models (default for new users) |
|
|
32
32
|
|
|
33
33
|
Custom roles can be created by assigning permissions to any role name.
|
|
34
34
|
|
|
@@ -56,6 +56,8 @@ Custom roles can be created by assigning permissions to any role name.
|
|
|
56
56
|
| `spaces.list` | View all spaces |
|
|
57
57
|
| `spaces.rename` | Rename a space and link/unlink conversations |
|
|
58
58
|
| `spaces.delete` | Delete current space and all related DB data |
|
|
59
|
+
| `model.list` | List the configured model chain (`mrctl model list`, chat `/model list`) |
|
|
60
|
+
| `model.switch` | Switch the active model for the space (`mrctl model switch`, chat `/model switch`) — takes effect on the next agent run |
|
|
59
61
|
| `media.receive` | Incoming attachments are saved to `inbox/` and shown to the agent |
|
|
60
62
|
| `media.send` | Outbox files produced on this caller's turn are delivered back to the chat |
|
|
61
63
|
|
|
@@ -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
|
|
|
@@ -28,19 +28,38 @@ npm i -g @googleworkspace/cli
|
|
|
28
28
|
gws auth login
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
2. Export credentials
|
|
31
|
+
2. Export the credentials and put the JSON (as a single line) into `.env`:
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
|
-
gws auth export --unmasked
|
|
34
|
+
gws auth export --unmasked
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
3. Add to `.env`:
|
|
38
|
-
|
|
39
37
|
```bash
|
|
40
|
-
|
|
38
|
+
MERCURY_GWS_CREDENTIALS_JSON={"client_id":"...","client_secret":"...","refresh_token":"...","type":"authorized_user"}
|
|
41
39
|
```
|
|
42
40
|
|
|
43
|
-
|
|
41
|
+
3. Restart Mercury. The extension's `before_container` hook passes the JSON
|
|
42
|
+
into the inner container, and the skill materializes it as a credentials
|
|
43
|
+
file at runtime.
|
|
44
|
+
|
|
45
|
+
> **Do not use the file-based path.** Older versions of this README said to
|
|
46
|
+
> export to `.mercury/global/gws-credentials.json` and set
|
|
47
|
+
> `MERCURY_GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE`. That no longer works: the
|
|
48
|
+
> runtime mounts the global dir into the inner container entry-by-entry via an
|
|
49
|
+
> allowlist (`PI_AGENT_RESOURCE_ENTRIES` in `container-runner.ts`), and the
|
|
50
|
+
> credentials file is not on it — the container never sees it.
|
|
51
|
+
>
|
|
52
|
+
> **Windows/PowerShell note:** if you do redirect `gws auth export` output to a
|
|
53
|
+
> file, PowerShell's `>` writes UTF-16 — invalid for JSON readers. Use
|
|
54
|
+
> `gws auth export --unmasked | Out-File -Encoding utf8 <path>` instead.
|
|
55
|
+
>
|
|
56
|
+
> (`MERCURY_GOOGLE_WORKSPACE_CLI_TOKEN` also exists, but it expires quickly.)
|
|
57
|
+
|
|
58
|
+
## Refreshing credentials
|
|
59
|
+
|
|
60
|
+
Re-run `gws auth login` + `gws auth export --unmasked`, replace the
|
|
61
|
+
`MERCURY_GWS_CREDENTIALS_JSON` value in `.env` (the refresh token changes),
|
|
62
|
+
and restart Mercury.
|
|
44
63
|
|
|
45
64
|
## Verify
|
|
46
65
|
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: poster
|
|
3
|
+
description: Generate styled marketing posters, flyers and day-programme graphics with correct Hebrew (or other RTL) text — AI-generated background art composited with deterministic HTML typography
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Poster CLI
|
|
7
|
+
|
|
8
|
+
Two commands, two layers. Keep them separate — that separation is the whole point.
|
|
9
|
+
|
|
10
|
+
| Layer | Command | Nature | Cost |
|
|
11
|
+
|---|---|---|---|
|
|
12
|
+
| 1 — art | `poster art` | probabilistic, calls an image model | per image |
|
|
13
|
+
| 2 — typography | `poster render` | deterministic, Chromium + HTML | free |
|
|
14
|
+
|
|
15
|
+
## The rule that matters
|
|
16
|
+
|
|
17
|
+
**Never ask the image model to draw the text.** No image model renders Hebrew reliably:
|
|
18
|
+
short headlines land ~95–100% of the time, but 15–30 word blocks drop to ~80% and RTL
|
|
19
|
+
scripts sit near ~75% *per line*. A poster has fifteen to twenty text runs, so "mostly
|
|
20
|
+
correct" means "one broken word per poster, every time".
|
|
21
|
+
|
|
22
|
+
`poster art` already appends a suppression clause to every prompt. Do not undo it by
|
|
23
|
+
asking for a title in the scene — you will get the model's mangled Hebrew *underneath*
|
|
24
|
+
the correct composited text.
|
|
25
|
+
|
|
26
|
+
## Workflow
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# 1. Background plate. Describe the scene only; leave signs and boards blank.
|
|
30
|
+
poster art \
|
|
31
|
+
--prompt "Golan Heights vineyard at golden hour, rustic wooden signpost, blank hanging
|
|
32
|
+
wooden sign, wine bottle and glass on a weathered table, grapes, warm lantern,
|
|
33
|
+
string lights in the trees, lush green foliage, cinematic depth of field" \
|
|
34
|
+
--aspect 3:2 --size 2K \
|
|
35
|
+
-o work/summer-bg.png
|
|
36
|
+
|
|
37
|
+
# 2. Copy. Write the config, then composite.
|
|
38
|
+
poster render --template schedule-day --config work/summer.json -o outbox/summer.png
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Anything written to `outbox/` is delivered to the chat automatically.
|
|
42
|
+
|
|
43
|
+
## `poster art`
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
poster art --prompt "<scene>" -o <file>
|
|
47
|
+
[--aspect 1:1|4:5|3:4|2:3|3:2|16:9|9:16] default 3:2
|
|
48
|
+
[--size 1K|2K|4K] default from poster.size (2K)
|
|
49
|
+
[--provider gemini|openai|local] default from poster.provider
|
|
50
|
+
[--reserve "<where>"] e.g. "left third", "lower band"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`--reserve` asks the model to leave negative space where the copy will land. Use it —
|
|
54
|
+
a background with the subject dead-centre forces you to cover it.
|
|
55
|
+
|
|
56
|
+
Providers:
|
|
57
|
+
|
|
58
|
+
| Provider | Model | Notes |
|
|
59
|
+
|---|---|---|
|
|
60
|
+
| `gemini` | `gemini-3-pro-image-preview` (Nano Banana Pro) | Best scene reasoning. Needs `POSTER_GEMINI_KEY`. |
|
|
61
|
+
| `openai` | `gpt-image-2` | Cheaper per image. Needs `POSTER_OPENAI_KEY`. |
|
|
62
|
+
| `local` | self-hosted | POSTs to `poster.local_endpoint`. No API cost. |
|
|
63
|
+
|
|
64
|
+
Prompt the art layer for **surfaces**, not text: "blank wooden sign", "empty chalkboard",
|
|
65
|
+
"plain kraft-paper label". The template then lays copy onto those surfaces.
|
|
66
|
+
|
|
67
|
+
## `poster render`
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
poster render [--template <name>] (--config <file> | < stdin) -o <file>
|
|
71
|
+
[--aspect ...] [--size ...] [--dump-html <file>]
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Deterministic, so iterate freely — fix a typo and re-render, no new generation.
|
|
75
|
+
`--dump-html` writes the intermediate page when a layout needs debugging.
|
|
76
|
+
|
|
77
|
+
Discover the config shape from the template itself:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
poster templates # list
|
|
81
|
+
poster schema schedule-day # fields
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Choosing a template
|
|
85
|
+
|
|
86
|
+
| Template | Shape | Use when |
|
|
87
|
+
|---|---|---|
|
|
88
|
+
| `schedule-day` | hero + taped card of timed rows + footer | the content is **time-ordered** — an itinerary, a programme, a day plan |
|
|
89
|
+
| `feature-grid` | dark hero + torn-paper split + icon columns + checklist/callout cards + CTA | the content is **feature-ordered** — a challenge, a workshop, a launch, a visual newsletter |
|
|
90
|
+
|
|
91
|
+
Both default to RTL. `schedule-day` defaults to 3:2 (landscape), `feature-grid` to 3:4 (portrait).
|
|
92
|
+
|
|
93
|
+
### Example config — `schedule-day`
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
{
|
|
97
|
+
"template": "schedule-day",
|
|
98
|
+
"background": "work/summer-bg.png",
|
|
99
|
+
"aspect": "3:2",
|
|
100
|
+
"title": "חופשת",
|
|
101
|
+
"title2": "קיץ",
|
|
102
|
+
"ribbon": "בכפר קצרין",
|
|
103
|
+
"tagline": "אווירת צפון. רגעים של יחד.",
|
|
104
|
+
"date_label": "חמישי 20.8",
|
|
105
|
+
"accent": "#7d9a3f",
|
|
106
|
+
"rows": [
|
|
107
|
+
{
|
|
108
|
+
"time": "12:30",
|
|
109
|
+
"heading": "יקב תל שיפון",
|
|
110
|
+
"body": "פותחים את החופשה בטעמים של הגולן",
|
|
111
|
+
"icon": "🍷",
|
|
112
|
+
"photo": "work/vineyard.png"
|
|
113
|
+
},
|
|
114
|
+
{ "time": "15:15", "heading": "סדנת שוקולד", "body": "מתוק, יצירתי וכיפי לכל המשפחה", "icon": "🍫" },
|
|
115
|
+
{ "time": "19:45", "heading": "סיור עששיות", "body": "מסיימים את היום בהרפתקה לילית", "icon": "🏮" }
|
|
116
|
+
],
|
|
117
|
+
"credits": "קקון | צברי | יעל",
|
|
118
|
+
"footer": "צפון. טבע. חברים. זכרונות טובים."
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Making text sit *on* a surface
|
|
123
|
+
|
|
124
|
+
Flat text boxes render perfect Hebrew and still look pasted on. Each hero run takes an
|
|
125
|
+
optional surface hint so it can be laid onto the art:
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{
|
|
129
|
+
"title_surface": { "transform": "rotate(-2.5deg)" },
|
|
130
|
+
"ribbon_surface": { "transform": "rotate(1.5deg) skewX(-3deg)" },
|
|
131
|
+
"tagline_surface": { "blend": "multiply", "shadow": "0 2px 6px rgba(0,0,0,.5)" }
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Keys: `transform` (any CSS transform), `blend` (`mix-blend-mode`, `multiply` for
|
|
136
|
+
chalkboards and paper), `shadow` (`text-shadow`), `color`.
|
|
137
|
+
|
|
138
|
+
Match the hint to the surface in the art — if the generated sign tilts left, tilt the
|
|
139
|
+
title to match. This is the step that separates a designed poster from a caption on a photo.
|
|
140
|
+
|
|
141
|
+
`transform` also **moves** a block, which is how you land a title on a surface the model
|
|
142
|
+
put somewhere unexpected: `"title_surface": { "transform": "translateY(120px) rotate(-1deg)" }`
|
|
143
|
+
drops the hero down onto a sign hanging in the middle of the frame. Re-render until it sits
|
|
144
|
+
right — layer 2 is free.
|
|
145
|
+
|
|
146
|
+
### When the art puts the blank surface on the wrong side
|
|
147
|
+
|
|
148
|
+
`schedule-day` accepts `"flip": true`, which swaps the hero and card sides. The art layer
|
|
149
|
+
does not reliably obey `--reserve`, and flipping the composition costs nothing where
|
|
150
|
+
re-rolling a generation costs money and may come back worse.
|
|
151
|
+
|
|
152
|
+
## Typography rules
|
|
153
|
+
|
|
154
|
+
Apply these unless asked otherwise.
|
|
155
|
+
|
|
156
|
+
- **Hebrew is RTL by default.** The template sets `dir="rtl"`; pass `"dir": "ltr"` only for
|
|
157
|
+
a Latin-only poster. Never reorder Hebrew strings by hand — the browser handles it.
|
|
158
|
+
- **Times and numerals stay LTR.** Already pinned in the template so `12:30` cannot
|
|
159
|
+
become `30:12`. If you add a numeric run elsewhere, pin `direction: ltr` on it.
|
|
160
|
+
- **Headlines short.** 8–14 characters for `title`/`title2`; they render at 11–17% of
|
|
161
|
+
poster height. Longer strings overflow rather than shrink.
|
|
162
|
+
- **Body blurbs ≤ 8 words.** Two lines at most per row.
|
|
163
|
+
- **Contrast, not decoration.** Cream `#f6efdd` / `#fdf6e6` on dark art, ink `#2f2a22` on
|
|
164
|
+
light cards. A drop shadow on light text over photography is mandatory, not optional.
|
|
165
|
+
- **Fonts are bundled.** All faces are OFL, base64-inlined at render time. Never reference
|
|
166
|
+
Google Fonts or a system font: the container has no Hebrew system face, and a render must
|
|
167
|
+
not depend on a network fetch that can fail silently into tofu boxes.
|
|
168
|
+
- **Mix faces deliberately.** A single family across a whole poster reads as a slide, not a
|
|
169
|
+
design. Heavy display for the headline, hand-drawn for the aside, neutral sans for the
|
|
170
|
+
body is the combination that makes these look designed.
|
|
171
|
+
- **Emoji icons work.** The base image ships Noto Color Emoji, so `"icon": "🍷"` renders in
|
|
172
|
+
colour. Verified in the container, not assumed.
|
|
173
|
+
|
|
174
|
+
## Type palette
|
|
175
|
+
|
|
176
|
+
Four bundled Hebrew families. Latin characters and digits always fall through to Rubik, so
|
|
177
|
+
mixed strings like `יקב Tel Shifon 12:30` stay coherent instead of breaking mid-line.
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
poster fonts --list
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
| Family | Face | Use for |
|
|
184
|
+
|---|---|---|
|
|
185
|
+
| `PosterSans` | Rubik | body copy, rows, anything neutral — the default |
|
|
186
|
+
| `PosterDisplay` | Suez One | big headlines; very heavy, the default for hero titles |
|
|
187
|
+
| `PosterHand` | Amatic SC | taglines, asides, annotations — hand-drawn and thin |
|
|
188
|
+
| `PosterSerif` | Frank Ruhl Libre | editorial or formal posters |
|
|
189
|
+
|
|
190
|
+
Set one per template key (`title_font`, `tagline_font`, `tag_font`, `body_font`) or per run
|
|
191
|
+
via a surface hint:
|
|
192
|
+
|
|
193
|
+
```json
|
|
194
|
+
{ "title_font": "PosterSerif", "tagline_surface": { "font": "PosterHand" } }
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
`PosterHand` has a much smaller x-height than the others; the templates already size it up
|
|
198
|
+
where they use it. If you apply it somewhere new, expect to raise the font size by ~40%.
|
|
199
|
+
|
|
200
|
+
## When Hebrew comes out as boxes
|
|
201
|
+
|
|
202
|
+
That is a font failure, not a layout failure. Diagnose it directly:
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
poster fonts --check
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
It renders the Hebrew alphabet once per bundled family plus one row in a deliberately
|
|
209
|
+
missing family, then prints a per-family verdict into the image. Every family row must show
|
|
210
|
+
real letters and the block must read `ALL PASS`. A single `FAIL` line names the family whose
|
|
211
|
+
`.woff2` did not load — check `assets/` is present in the skill mount.
|
|
212
|
+
|
|
213
|
+
Run this after adding any font. A face advertised as "supporting Hebrew" often ships a
|
|
214
|
+
Latin-only subset, and nothing else in the pipeline notices.
|
|
215
|
+
|
|
216
|
+
## Configuration
|
|
217
|
+
|
|
218
|
+
Space admins set these via `mrctl config set`:
|
|
219
|
+
|
|
220
|
+
| Key | Default | Meaning |
|
|
221
|
+
|---|---|---|
|
|
222
|
+
| `poster.provider` | `gemini` | Art-layer provider |
|
|
223
|
+
| `poster.size` | `2K` | Default resolution (Gemini bills 1K and 2K the same) |
|
|
224
|
+
| `poster.local_endpoint` | — | Required when `provider=local` |
|
|
225
|
+
|
|
226
|
+
Host `.env`: `MERCURY_POSTER_GEMINI_KEY`, `MERCURY_POSTER_OPENAI_KEY`. Keys are only
|
|
227
|
+
injected for callers holding the `poster` permission.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|