mercury-agent 0.14.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.
Files changed (32) hide show
  1. package/README.md +9 -0
  2. package/docs/deployment.md +36 -0
  3. package/examples/extensions/README.md +1 -0
  4. package/examples/extensions/gws/index.ts +34 -1
  5. package/examples/extensions/gws/skill/SKILL.md +11 -7
  6. package/examples/extensions/longview/index.ts +13 -1
  7. package/examples/extensions/napkin/index.ts +13 -1
  8. package/examples/extensions/poster/index.ts +92 -0
  9. package/examples/extensions/poster/skill/SKILL.md +227 -0
  10. package/examples/extensions/poster/skill/assets/amatic-sc.woff2 +0 -0
  11. package/examples/extensions/poster/skill/assets/frank-ruhl.woff2 +0 -0
  12. package/examples/extensions/poster/skill/assets/rubik-hebrew.woff2 +0 -0
  13. package/examples/extensions/poster/skill/assets/rubik-latin.woff2 +0 -0
  14. package/examples/extensions/poster/skill/assets/suez-one.woff2 +0 -0
  15. package/examples/extensions/poster/skill/scripts/lib/providers.mjs +192 -0
  16. package/examples/extensions/poster/skill/scripts/lib/render.mjs +195 -0
  17. package/examples/extensions/poster/skill/scripts/poster.mjs +355 -0
  18. package/examples/extensions/poster/skill/templates/feature-grid.mjs +349 -0
  19. package/examples/extensions/poster/skill/templates/schedule-day.mjs +326 -0
  20. package/package.json +1 -1
  21. package/src/agent/container-runner.ts +15 -3
  22. package/src/cli/mercury.ts +206 -36
  23. package/src/cli/upgrade.ts +137 -0
  24. package/src/core/debounce.ts +124 -13
  25. package/src/core/handler.ts +20 -1
  26. package/src/core/process-tree.ts +124 -0
  27. package/src/core/profiles.ts +123 -15
  28. package/src/core/router.ts +23 -2
  29. package/src/core/runtime.ts +18 -2
  30. package/src/extensions/catalog.ts +13 -0
  31. package/src/server.ts +12 -0
  32. 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
@@ -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
+ }
@@ -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.