mercury-agent 0.15.0 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/container/Dockerfile +30 -32
- package/container/Dockerfile.base +28 -31
- package/container/agent-package.json +1 -1
- package/docs/configuration.md +86 -0
- package/docs/context-architecture.md +20 -0
- package/examples/extensions/poster/skill/SKILL.md +56 -2
- package/examples/extensions/poster/skill/references/authoring-contract.md +230 -0
- package/examples/extensions/poster/skill/references/example-layout.html +54 -0
- package/examples/extensions/poster/skill/scripts/lib/audit.mjs +131 -0
- package/examples/extensions/poster/skill/scripts/lib/render.mjs +45 -9
- package/examples/extensions/poster/skill/scripts/lib/shell.mjs +489 -0
- package/examples/extensions/poster/skill/scripts/lib/validate.mjs +275 -0
- package/examples/extensions/poster/skill/scripts/poster.mjs +225 -0
- package/examples/extensions/poster/skill/templates/feature-grid.mjs +8 -25
- package/examples/extensions/poster/skill/templates/schedule-day.mjs +5 -32
- package/examples/extensions/poster/tests/poster-compose.test.ts +443 -0
- package/package.json +4 -4
- package/resources/skills/tasks/SKILL.md +13 -0
- package/resources/templates/mercury.example.yaml +8 -0
- package/src/adapters/whatsapp.ts +55 -12
- package/src/agent/container-entry.ts +189 -3
- package/src/agent/container-runner.ts +59 -5
- package/src/bridges/whatsapp.ts +6 -4
- package/src/cli/mercury.ts +9 -1
- package/src/cli/mrctl-http.ts +32 -0
- package/src/cli/mrctl.ts +10 -12
- package/src/config-file.ts +30 -0
- package/src/config.ts +48 -0
- package/src/core/handler.ts +38 -5
- package/src/core/operator-alerts.ts +442 -0
- package/src/core/routes/tasks.ts +7 -2
- package/src/core/runtime.ts +50 -5
- package/src/core/storage-cleanup.ts +47 -1
- package/src/core/system-messages.ts +13 -2
- package/src/core/task-output.ts +129 -0
- package/src/core/task-time.ts +164 -0
- package/src/storage/pi-auth.ts +59 -3
package/container/Dockerfile
CHANGED
|
@@ -60,49 +60,47 @@ RUN echo '{"args":["--no-sandbox"]}' > /home/mercury/.puppeteerrc.json
|
|
|
60
60
|
ENV CHROMIUM_FLAGS="--no-sandbox"
|
|
61
61
|
|
|
62
62
|
# Install CLIs
|
|
63
|
-
RUN bun add -g @earendil-works/pi-coding-agent@~0.
|
|
63
|
+
RUN bun add -g @earendil-works/pi-coding-agent@~0.84.1
|
|
64
64
|
|
|
65
65
|
WORKDIR /app
|
|
66
66
|
|
|
67
67
|
COPY container/agent-package.json /app/package.json
|
|
68
68
|
RUN bun install --production
|
|
69
69
|
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
} catch(e) {}
|
|
93
|
-
}
|
|
94
|
-
} catch(e) {}
|
|
95
|
-
}
|
|
96
|
-
patch('/home/mercury/.bun');
|
|
97
|
-
patch('/app/node_modules');
|
|
98
|
-
EOF
|
|
70
|
+
# pi >= 0.84 handles Gemma 4's thinking config itself, so the patch that used to
|
|
71
|
+
# live here is gone. It string-replaced two exact lines in pi-ai's
|
|
72
|
+
# dist/providers/google.js to stop `thinkingBudget: 0` being sent to Gemma
|
|
73
|
+
# models, which the Google API rejects. In 0.84 that file moved to
|
|
74
|
+
# dist/api/google-generative-ai.js and gained `isGemma4Model()`, whose
|
|
75
|
+
# `getDisabledThinkingConfig()` returns `{ thinkingLevel: "MINIMAL" }` instead.
|
|
76
|
+
# Neither string the patch matched still exists.
|
|
77
|
+
#
|
|
78
|
+
# Narrower than what it replaces, deliberately and worth knowing: the patch
|
|
79
|
+
# gated on `model.id.startsWith("gemma")` — every Gemma — while `isGemma4Model`
|
|
80
|
+
# is `/gemma-?4/`, so anything else still falls through to `thinkingBudget: 0`.
|
|
81
|
+
# pi's built-in Google catalog only ships gemma-4 ids, so reaching the gap needs
|
|
82
|
+
# a hand-authored models.json entry for e.g. gemma-3 marked `reasoning: true`.
|
|
83
|
+
# If that turns up, fix it upstream rather than reinstating a source patch.
|
|
84
|
+
#
|
|
85
|
+
# Deleted rather than updated for a second reason: the patch swallowed every
|
|
86
|
+
# error (`catch(e) {}`) and only wrote when its marker was absent, so once pi
|
|
87
|
+
# moved the file it silently did nothing while still reading as protection. A
|
|
88
|
+
# no-op that looks like a safeguard is worse than no safeguard. If a future pi
|
|
89
|
+
# regresses here, reinstate it as a build step that FAILS when its pattern does
|
|
90
|
+
# not match.
|
|
99
91
|
|
|
100
92
|
# Fix ownership of all mercury home dir artifacts before switching user.
|
|
101
|
-
# Placed here — after the last step that writes to /home/mercury (the
|
|
93
|
+
# Placed here — after the last step that writes to /home/mercury (the global
|
|
94
|
+
# `bun add -g` above),
|
|
102
95
|
# but before the volatile /app source COPYs below — so this expensive `chown -R`
|
|
103
96
|
# (it walks the whole Chromium + .bun tree) lands in a stable cached layer.
|
|
104
97
|
# Editing source files no longer invalidates it or forces the huge layer re-export.
|
|
105
|
-
|
|
98
|
+
# `mkdir` here rather than leaving it to Docker: the host mounts the global dir
|
|
99
|
+
# into PI_CODING_AGENT_DIR entry by entry, so Docker would create this dir as
|
|
100
|
+
# the mount parent and own it as root — and pi 0.84's credential store writes
|
|
101
|
+
# an empty auth.json on its first *read*, which then fails EACCES and takes
|
|
102
|
+
# every model leg down with it. Folded into the chown so it shares the layer.
|
|
103
|
+
RUN mkdir -p /home/mercury/.pi/agent && chown -R mercury:mercury /home/mercury
|
|
106
104
|
|
|
107
105
|
COPY src/agent/container-entry.ts /app/src/agent/container-entry.ts
|
|
108
106
|
COPY src/agent/model-capabilities-core.ts /app/src/agent/model-capabilities-core.ts
|
|
@@ -42,42 +42,34 @@ RUN echo '{"args":["--no-sandbox"]}' > /home/mercury/.puppeteerrc.json
|
|
|
42
42
|
ENV CHROMIUM_FLAGS="--no-sandbox"
|
|
43
43
|
|
|
44
44
|
# Install CLIs
|
|
45
|
-
RUN bun add -g @earendil-works/pi-coding-agent@~0.
|
|
45
|
+
RUN bun add -g @earendil-works/pi-coding-agent@~0.84.1
|
|
46
46
|
|
|
47
47
|
WORKDIR /app
|
|
48
48
|
|
|
49
49
|
COPY container/agent-package.json /app/package.json
|
|
50
50
|
RUN bun install --production
|
|
51
51
|
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
} catch(e) {}
|
|
75
|
-
}
|
|
76
|
-
} catch(e) {}
|
|
77
|
-
}
|
|
78
|
-
patch('/home/mercury/.bun');
|
|
79
|
-
patch('/app/node_modules');
|
|
80
|
-
EOF
|
|
52
|
+
# pi >= 0.84 handles Gemma 4's thinking config itself, so the patch that used to
|
|
53
|
+
# live here is gone. It string-replaced two exact lines in pi-ai's
|
|
54
|
+
# dist/providers/google.js to stop `thinkingBudget: 0` being sent to Gemma
|
|
55
|
+
# models, which the Google API rejects. In 0.84 that file moved to
|
|
56
|
+
# dist/api/google-generative-ai.js and gained `isGemma4Model()`, whose
|
|
57
|
+
# `getDisabledThinkingConfig()` returns `{ thinkingLevel: "MINIMAL" }` instead.
|
|
58
|
+
# Neither string the patch matched still exists.
|
|
59
|
+
#
|
|
60
|
+
# Narrower than what it replaces, deliberately and worth knowing: the patch
|
|
61
|
+
# gated on `model.id.startsWith("gemma")` — every Gemma — while `isGemma4Model`
|
|
62
|
+
# is `/gemma-?4/`, so anything else still falls through to `thinkingBudget: 0`.
|
|
63
|
+
# pi's built-in Google catalog only ships gemma-4 ids, so reaching the gap needs
|
|
64
|
+
# a hand-authored models.json entry for e.g. gemma-3 marked `reasoning: true`.
|
|
65
|
+
# If that turns up, fix it upstream rather than reinstating a source patch.
|
|
66
|
+
#
|
|
67
|
+
# Deleted rather than updated for a second reason: the patch swallowed every
|
|
68
|
+
# error (`catch(e) {}`) and only wrote when its marker was absent, so once pi
|
|
69
|
+
# moved the file it silently did nothing while still reading as protection. A
|
|
70
|
+
# no-op that looks like a safeguard is worse than no safeguard. If a future pi
|
|
71
|
+
# regresses here, reinstate it as a build step that FAILS when its pattern does
|
|
72
|
+
# not match.
|
|
81
73
|
|
|
82
74
|
COPY src/agent/container-entry.ts /app/src/agent/container-entry.ts
|
|
83
75
|
COPY src/agent/model-capabilities-core.ts /app/src/agent/model-capabilities-core.ts
|
|
@@ -102,7 +94,12 @@ RUN echo '#!/bin/sh\nbun run /app/src/cli/mrctl.ts "$@"' > /usr/local/bin/mrctl
|
|
|
102
94
|
chmod +x /usr/local/bin/mrctl
|
|
103
95
|
|
|
104
96
|
# Fix ownership of all mercury home dir artifacts before switching user
|
|
105
|
-
|
|
97
|
+
# `mkdir` here rather than leaving it to Docker: the host mounts the global dir
|
|
98
|
+
# into PI_CODING_AGENT_DIR entry by entry, so Docker would create this dir as
|
|
99
|
+
# the mount parent and own it as root — and pi 0.84's credential store writes
|
|
100
|
+
# an empty auth.json on its first *read*, which then fails EACCES and takes
|
|
101
|
+
# every model leg down with it. Folded into the chown so it shares the layer.
|
|
102
|
+
RUN mkdir -p /home/mercury/.pi/agent && chown -R mercury:mercury /home/mercury
|
|
106
103
|
|
|
107
104
|
USER mercury
|
|
108
105
|
|
package/docs/configuration.md
CHANGED
|
@@ -39,6 +39,41 @@ context:
|
|
|
39
39
|
|
|
40
40
|
Per-space overrides via `mrctl config set context.<key> <value>` always win over YAML defaults; YAML re-reads on restart do not overwrite an existing space row.
|
|
41
41
|
|
|
42
|
+
## Operator alerts (`alerts:`)
|
|
43
|
+
|
|
44
|
+
When a provider OAuth credential can no longer be refreshed, every space stops
|
|
45
|
+
answering until someone re-authenticates on the host. Mercury DMs the configured
|
|
46
|
+
operators so the outage does not wait for a human to read the logs.
|
|
47
|
+
|
|
48
|
+
```yaml
|
|
49
|
+
alerts:
|
|
50
|
+
enabled: true # default: true
|
|
51
|
+
cooldown_ms: 21600000 # 60000 – 604800000 (default: 6h)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Env equivalents: **`MERCURY_ALERTS_ENABLED`**, **`MERCURY_ALERTS_COOLDOWN_MS`**.
|
|
55
|
+
|
|
56
|
+
- **Recipients** are the ids already listed in `permissions.admins` (`MERCURY_ADMINS`)
|
|
57
|
+
and `dm_auto_space.admin_ids` (`MERCURY_DM_AUTO_SPACE_ADMIN_IDS`)
|
|
58
|
+
— there is no separate alert-recipient key. An id is alerted only when it
|
|
59
|
+
resolves to an existing space **with a linked conversation**, i.e. a chat
|
|
60
|
+
Mercury can actually send into; an alert never creates a space. With no
|
|
61
|
+
configured admin, or none that has ever messaged the bot, the alert has
|
|
62
|
+
nowhere to go — Mercury logs that fact at ERROR and leaves the cooldown
|
|
63
|
+
unclaimed, so the next failure tries again.
|
|
64
|
+
- **Channel** is the ordinary adapter outbox, the same path scheduled-task
|
|
65
|
+
failures use. It carries no agent run and no LLM, so it still works when the
|
|
66
|
+
agent's own credential is what died.
|
|
67
|
+
- **`cooldown_ms`** is the minimum gap between identical alerts, per credential
|
|
68
|
+
and per reason. It is deliberately long: the alert names a manual recovery
|
|
69
|
+
step, and repeating it while an operator performs it adds nothing. The state is
|
|
70
|
+
stored in the database, so a crash-looping service does not re-alert on every
|
|
71
|
+
restart. A successful credential load clears it — if the credential dies again
|
|
72
|
+
later, that is news and alerts immediately.
|
|
73
|
+
- **Transient failures** (HTTP 408/429/5xx from the token endpoint) alert only
|
|
74
|
+
after three in a row; a single blip heals itself. A rejected refresh token, or
|
|
75
|
+
a cause that could not be classified, alerts on the first occurrence.
|
|
76
|
+
|
|
42
77
|
## Ambient group context
|
|
43
78
|
|
|
44
79
|
In linked group chats, messages that don't trigger the bot are stored as **ambient context** (author-attributed) so it can answer questions about conversation it overheard. Every message in a linked group becomes a row, bounded on both ends:
|
|
@@ -74,6 +109,57 @@ Container env passthrough: all — these vars reach every space's container and
|
|
|
74
109
|
|
|
75
110
|
For secrets that only host-side hooks and jobs need, prefer `mercury.env({ from: "…", hostOnly: true })`, which keeps them out of containers in either mode. For credentials the agent should never hold at all, use a host-side capability handler (`mercury.capability()`), which runs the privileged call on the host and returns only the result.
|
|
76
111
|
|
|
112
|
+
## Agent run traces (`agent.trace_runs`)
|
|
113
|
+
|
|
114
|
+
Captures pi's raw output from inside each agent container, with arrival timestamps, and copies it to `<dataDir>/traces/` before the container is reaped:
|
|
115
|
+
|
|
116
|
+
```yaml
|
|
117
|
+
agent:
|
|
118
|
+
trace_runs: false # false (default) | true
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Env: `MERCURY_CONTAINER_TRACE_RUNS`.
|
|
122
|
+
|
|
123
|
+
Agent containers run with `--rm`, so pi's session logs die with the container. That is fine until a run misbehaves — a turn that hits `container_timeout_ms` is killed and leaves **nothing** to show where its time went. Tracing exists for exactly that question.
|
|
124
|
+
|
|
125
|
+
Each harvested file is `<dataDir>/traces/<container-name>.jsonl`, one JSON object per line:
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{"atMs":0,"stream":"meta","data":"{\"piSpawnedAtIso\":\"2026-08-13T09:15:02.104Z\"}"}
|
|
129
|
+
{"atMs":812,"stream":"stdout","data":"{\"type\":\"message_start\"…"}
|
|
130
|
+
{"atMs":47310,"stream":"stdout","data":"{\"type\":\"tool_call\"…"}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
- `atMs` — milliseconds since pi was spawned. **A long gap between consecutive lines is the finding**: it says which call the run was sitting inside.
|
|
134
|
+
- `stream` — `stdout`, `stderr`, or `meta`.
|
|
135
|
+
- A trace with no closing `meta` line carrying `exitCode` means the container was **killed** rather than exiting — itself a diagnosis. A `meta` line carrying `spawnError` instead means pi never started, which looks similar but is a different fault.
|
|
136
|
+
|
|
137
|
+
> **One file can hold several segments.** A run with a model chain, or one that retries a retryable failure, invokes pi more than once, and every invocation appends to the same file. Each one opens with its own `piSpawnedAtIso` `meta` line and **restarts `atMs` from zero**.
|
|
138
|
+
>
|
|
139
|
+
> So: split the file on `meta` lines carrying `piSpawnedAtIso`, and treat each segment separately. Comparing `atMs` across a boundary yields a negative gap and hides however long the earlier leg took — which matters, because a retry fires on exactly the misbehaving runs a trace is opened for. Concatenating `stdout` reconstructs pi's raw output byte for byte **within a segment**; glued across segments it parses as neither.
|
|
140
|
+
|
|
141
|
+
Writes go through `appendFileSync` rather than a buffered stream, deliberately: a buffered write loses whatever is in userspace when the process is SIGKILLed, which is the case the trace is for.
|
|
142
|
+
|
|
143
|
+
To find where a slow run actually spent its time — the largest gap between consecutive lines, and what the run was doing when it stalled:
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
bun -e 'const ls=require("fs").readFileSync(process.argv[1],"utf8").trim().split("\n").map(l=>JSON.parse(l));
|
|
147
|
+
let prev=0,seg=0,w={gapMs:0};for(const l of ls){
|
|
148
|
+
if(l.stream==="meta"&&String(l.data).includes("piSpawnedAtIso")){seg++;prev=0;continue}
|
|
149
|
+
const g=l.atMs-prev;if(g>w.gapMs)w={gapMs:g,seg,atMs:l.atMs,after:String(l.data).slice(0,160)};prev=l.atMs}
|
|
150
|
+
console.log(w)' .mercury/traces/<container-name>.jsonl
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The `prev=0` reset at each `meta` line is what keeps a multi-leg run honest — without it the boundary produces a negative gap and the earlier leg's stall never wins.
|
|
154
|
+
|
|
155
|
+
`after` is the output that arrived *when the gap ended*, so the call that hung is the one immediately before it.
|
|
156
|
+
|
|
157
|
+
> ⚠️ **A trace contains the full prompt and the full reply in clear text** — real conversation content, including anything group members said. Treat it like `log_level: debug`: switch it on for a bounded window, capture the run you need, and switch it back.
|
|
158
|
+
|
|
159
|
+
`<dataDir>/traces/` is swept by the storage cleanup: a trace file is deleted once it is older than **`MERCURY_TRACE_TTL_DAYS`** (default `7`). The sweep runs whether or not `agent.trace_runs` is currently on — otherwise switching tracing off would strand that window's traces on disk permanently, which is the case the retention exists for. Shorten it if traces are being captured on a host where the conversation content is sensitive; deleting files by hand is still fine at any time.
|
|
160
|
+
|
|
161
|
+
> ⚠️ **Changing this setting needs an agent image rebuild, not just a restart.** The capture half lives in `src/agent/container-entry.ts`, which is baked into the image (`container/Dockerfile`). A restart alone deploys the host-side half and leaves tracing silently doing nothing.
|
|
162
|
+
|
|
77
163
|
## Extension config defaults (`extensions:`)
|
|
78
164
|
|
|
79
165
|
Deployment-wide defaults for extension config keys, applied to **every space** (including auto-created DM spaces) at read time:
|
|
@@ -68,6 +68,12 @@ The session boundary (`chat_state.min_message_id`) excludes messages older than
|
|
|
68
68
|
|
|
69
69
|
```
|
|
70
70
|
<caller id="…" name="…" role="…" space="…" />
|
|
71
|
+
<run_budget> ← wall-clock window for this run
|
|
72
|
+
<started_at>…</started_at>
|
|
73
|
+
<killed_at>…</killed_at> ← host kills the container here
|
|
74
|
+
<begin_final_answer_by>…</begin_final_answer_by>
|
|
75
|
+
<total_budget>…</total_budget>
|
|
76
|
+
</run_budget>
|
|
71
77
|
<episodic_memory>…</episodic_memory> ← MEMORY.md (if present)
|
|
72
78
|
<active_episodes>…</active_episodes> ← relevance-scored episode snippets
|
|
73
79
|
<history> ← sliding window from DB
|
|
@@ -85,6 +91,20 @@ The session boundary (`chat_state.min_message_id`) excludes messages older than
|
|
|
85
91
|
[user prompt text]
|
|
86
92
|
```
|
|
87
93
|
|
|
94
|
+
`<run_budget>` carries the wall clock the host enforces. `containerTimeoutMs`
|
|
95
|
+
kills the container at `killed_at`, and before this block existed the agent had
|
|
96
|
+
no way to learn that — the first and only signal was SIGKILL, too late to pace
|
|
97
|
+
the work or save partial findings. `begin_final_answer_by` reserves a
|
|
98
|
+
proportional slice of the budget (20%, floored at 30s, capped at 5min) for
|
|
99
|
+
composing and sending the answer. The block is omitted entirely when the host
|
|
100
|
+
supplies no window, or an incoherent one — a missing budget beats one claiming
|
|
101
|
+
the run is already over.
|
|
102
|
+
|
|
103
|
+
The matching `## Run budget` instructions live in the system prompt. They are
|
|
104
|
+
gated more tightly than the block itself: a toolless model gets the block (the
|
|
105
|
+
kill time is useful context regardless) but not the instructions, which all
|
|
106
|
+
assume a shell to run `date` in and a file tool to save findings with.
|
|
107
|
+
|
|
88
108
|
---
|
|
89
109
|
|
|
90
110
|
## Why Not a Pi Session File?
|
|
@@ -5,12 +5,16 @@ description: Generate styled marketing posters, flyers and day-programme graphic
|
|
|
5
5
|
|
|
6
6
|
# Poster CLI
|
|
7
7
|
|
|
8
|
-
Two
|
|
8
|
+
Two layers. Keep them separate — that separation is the whole point.
|
|
9
9
|
|
|
10
10
|
| Layer | Command | Nature | Cost |
|
|
11
11
|
|---|---|---|---|
|
|
12
12
|
| 1 — art | `poster art` | probabilistic, calls an image model | per image |
|
|
13
|
-
| 2 — typography | `poster render` | deterministic, Chromium + HTML | free |
|
|
13
|
+
| 2 — typography | `poster render` / `poster compose` | deterministic, Chromium + HTML | free |
|
|
14
|
+
|
|
15
|
+
Layer 2 has two paths. `render` fills a fixed template; `compose` renders a
|
|
16
|
+
layout you author yourself. Both go through the same document shell, so both
|
|
17
|
+
carry the same Hebrew guarantee.
|
|
14
18
|
|
|
15
19
|
## The rule that matters
|
|
16
20
|
|
|
@@ -149,6 +153,56 @@ right — layer 2 is free.
|
|
|
149
153
|
does not reliably obey `--reserve`, and flipping the composition costs nothing where
|
|
150
154
|
re-rolling a generation costs money and may come back worse.
|
|
151
155
|
|
|
156
|
+
## `poster compose` — author the layout yourself
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
poster compose --layout <file.html> --assets <file.json> -o <file>
|
|
160
|
+
[--aspect ...] [--size ...] [--dir rtl|ltr] [--workspace <dir>]
|
|
161
|
+
[--audit-json <file>] [--strict-warn] [--dump-html <file>]
|
|
162
|
+
|
|
163
|
+
poster contract # the authoring rules, printed
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
You write a **fragment** — `<style>` blocks plus markup — and the pipeline wraps
|
|
167
|
+
it in the shell it owns: doctype, `dir="rtl"`, the embedded font palette, the
|
|
168
|
+
canvas, and `#poster-stage` (a box at exactly the poster's size that your markup
|
|
169
|
+
sits inside). Then it validates the fragment, measures the rendered result, and
|
|
170
|
+
**refuses to write a poster that is clipped or off-stage**.
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
poster contract # read this first — it lists the families and rules
|
|
174
|
+
poster compose --layout work/layout.html --assets work/assets.json \
|
|
175
|
+
--aspect 3:2 -o outbox/poster.png
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
The skill directory is read-only. Write `layout.html` and `assets.json` into the
|
|
179
|
+
**workspace** (`work/…`), not next to the templates, or the write fails.
|
|
180
|
+
|
|
181
|
+
Images are referenced by token, never by path — `<img src="asset:background">`,
|
|
182
|
+
resolved from `{ "background": "work/bg.png" }`. A path or URL in your markup is
|
|
183
|
+
rejected.
|
|
184
|
+
|
|
185
|
+
Exit codes: `0` written · `1` the command was wrong · `2` the layout was
|
|
186
|
+
rejected. On `2`, read the report, fix the layout, re-run — it costs nothing.
|
|
187
|
+
After two failed attempts, fall back to `poster render` with the nearest
|
|
188
|
+
template and say that you did.
|
|
189
|
+
|
|
190
|
+
Full rules, the checked-automatically table, and the surface-landing techniques:
|
|
191
|
+
`references/authoring-contract.md`.
|
|
192
|
+
|
|
193
|
+
### Which path to use
|
|
194
|
+
|
|
195
|
+
| Use | When |
|
|
196
|
+
|---|---|
|
|
197
|
+
| `render --template schedule-day` | the content is time-ordered and a hero + timed-rows card fits it |
|
|
198
|
+
| `render --template feature-grid` | the content is feature-ordered — a challenge, a workshop, a launch |
|
|
199
|
+
| `compose` | the content does not fit either shape, or the art has a specific surface (a hanging sign, a chalkboard, a ribbon) that the copy should sit on, or the caller asked for a particular look |
|
|
200
|
+
|
|
201
|
+
Default to a template when one genuinely fits — it is one Chromium pass instead
|
|
202
|
+
of two, and it is already known to hold its content. Reach for `compose` when
|
|
203
|
+
the template would fight the content or waste the art. "Make it look designed,
|
|
204
|
+
not filled in" is a `compose` request.
|
|
205
|
+
|
|
152
206
|
## Typography rules
|
|
153
207
|
|
|
154
208
|
Apply these unless asked otherwise.
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# Authoring contract — `poster compose`
|
|
2
|
+
|
|
3
|
+
Read this before authoring a layout. `poster contract` prints the short version
|
|
4
|
+
at the terminal; this is the long one, with the reasoning.
|
|
5
|
+
|
|
6
|
+
## What you write, and what you must not
|
|
7
|
+
|
|
8
|
+
You write a **fragment**: one or more `<style>` blocks plus the markup that goes
|
|
9
|
+
inside the poster. You do not write a document.
|
|
10
|
+
|
|
11
|
+
The pipeline supplies — and you cannot override:
|
|
12
|
+
|
|
13
|
+
- `<!doctype html>`, `<html dir="rtl" lang="he">`, `<meta charset="utf-8">`
|
|
14
|
+
- every `@font-face` in the bundled palette, base64-inlined
|
|
15
|
+
- `html, body` sized to the exact canvas, `overflow: hidden`
|
|
16
|
+
- `#poster-stage` — a `position: relative` box at exactly the poster's
|
|
17
|
+
dimensions. Your markup goes inside it. Position against it.
|
|
18
|
+
- a `.ltr` utility class
|
|
19
|
+
- the audit script (present only in the measuring pass, never in the shot one)
|
|
20
|
+
|
|
21
|
+
This split is not bureaucracy. It is the reason the extension exists: a layout
|
|
22
|
+
**cannot** forget to embed the font or set the wrong text direction, because
|
|
23
|
+
neither is in the part you write. Get the composition wrong and you get an ugly
|
|
24
|
+
poster; get font embedding wrong and you get a poster full of empty boxes where
|
|
25
|
+
the Hebrew should be, which is the failure this pipeline was built to make
|
|
26
|
+
impossible.
|
|
27
|
+
|
|
28
|
+
## Fonts
|
|
29
|
+
|
|
30
|
+
Four Hebrew faces, plus the Latin fallback. Run `poster fonts --list` for what
|
|
31
|
+
each is for.
|
|
32
|
+
|
|
33
|
+
| Family | Face | Use for |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| `PosterSans` | Rubik | body copy, rows, anything neutral |
|
|
36
|
+
| `PosterDisplay` | Suez One | big headlines; very heavy |
|
|
37
|
+
| `PosterHand` | Amatic SC | taglines, asides — hand-drawn and thin |
|
|
38
|
+
| `PosterSerif` | Frank Ruhl Libre | editorial or formal |
|
|
39
|
+
| `PosterLatin` | Rubik Latin | digits and Latin words fall through to it already |
|
|
40
|
+
|
|
41
|
+
**Naming any other family is an error**, checked before anything renders. Not a
|
|
42
|
+
style preference — a face that is not embedded renders as tofu boxes or silently
|
|
43
|
+
drops the glyphs, and nothing downstream would notice.
|
|
44
|
+
|
|
45
|
+
Use `font-family`. The `font` shorthand is refused, because its family position
|
|
46
|
+
cannot be extracted reliably enough to check, and a check that is sometimes
|
|
47
|
+
wrong is worse than a rule that is always clear.
|
|
48
|
+
|
|
49
|
+
`PosterHand` has a much smaller x-height than the others. If you use it, raise
|
|
50
|
+
the font size ~40% over what you would give `PosterSans`.
|
|
51
|
+
|
|
52
|
+
**Mix faces deliberately.** One family across a whole poster reads as a slide,
|
|
53
|
+
not a design. Heavy display headline + hand-drawn aside + neutral sans body is
|
|
54
|
+
the combination that makes these look designed.
|
|
55
|
+
|
|
56
|
+
## Images
|
|
57
|
+
|
|
58
|
+
Reference every image by **token**, never by path:
|
|
59
|
+
|
|
60
|
+
```html
|
|
61
|
+
<img src="asset:background" alt="">
|
|
62
|
+
<div style="background: url(asset:dish) center/cover"></div>
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Tokens resolve from the `--assets` JSON map, which the caller writes:
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{ "background": "work/bg.png", "dish": "work/dish.png" }
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
This applies wherever an image can appear, `image-set("…")` and `image("…")`
|
|
72
|
+
included — those take a bare quoted string with no `url()` wrapper, and they are
|
|
73
|
+
checked the same way.
|
|
74
|
+
|
|
75
|
+
A filesystem path, a URL, or a `data:` URI in your markup is an error. The
|
|
76
|
+
indirection is deliberate: it means a layout has no way to name a file, so
|
|
77
|
+
`<img src="../../.env">` fails as an unresolvable token rather than being read
|
|
78
|
+
and base64-inlined into an image someone then shares. Paths come from the
|
|
79
|
+
caller and are confined to the workspace.
|
|
80
|
+
|
|
81
|
+
## Hebrew and numerals
|
|
82
|
+
|
|
83
|
+
The page is RTL. Never reorder Hebrew strings by hand — the browser's bidi
|
|
84
|
+
algorithm handles it, and hand-reordering is how text ends up backwards.
|
|
85
|
+
|
|
86
|
+
**Wrap every digit run in `class="ltr"`:**
|
|
87
|
+
|
|
88
|
+
```html
|
|
89
|
+
<div>יציאה <span class="ltr">12:30</span> מהמלון</div>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Without it `12:30` can render as `30:12`. This is checked and warns; the warning
|
|
93
|
+
is printed but does not block output; pass `--strict-warn` if you want it to.
|
|
94
|
+
|
|
95
|
+
One nuance worth knowing if you are tempted to write your own isolation: setting
|
|
96
|
+
`unicode-bidi: isolate` does *not* satisfy this. Chromium's own stylesheet
|
|
97
|
+
already computes `isolate` for every block element, so it distinguishes nothing.
|
|
98
|
+
What matters is `direction: ltr`, which is what `.ltr` sets.
|
|
99
|
+
|
|
100
|
+
## Composition — physical sides, not logical ones
|
|
101
|
+
|
|
102
|
+
Use `left` / `right`, **not** `inset-inline-start` / `inset-inline-end`.
|
|
103
|
+
|
|
104
|
+
Under `dir="rtl"` the logical properties mirror the entire composition, which
|
|
105
|
+
desyncs the layout from wherever the art layer actually left its blank space.
|
|
106
|
+
Which side holds the hero is a *design* decision about the specific background
|
|
107
|
+
you were given; it is not a consequence of the text direction. Text inside each
|
|
108
|
+
block still flows RTL regardless.
|
|
109
|
+
|
|
110
|
+
## Landing text on a surface
|
|
111
|
+
|
|
112
|
+
A flat absolutely-positioned box renders perfect Hebrew and still looks pasted
|
|
113
|
+
on. What makes a poster look designed is copy that sits *on* something in the
|
|
114
|
+
art:
|
|
115
|
+
|
|
116
|
+
```css
|
|
117
|
+
.title { transform: rotate(-2.5deg); } /* match a tilted sign */
|
|
118
|
+
.ribbon { transform: rotate(1.5deg) skewX(-3deg); } /* follow a ribbon */
|
|
119
|
+
.chalk { mix-blend-mode: multiply; } /* sink into a chalkboard */
|
|
120
|
+
.on-photo { text-shadow: 0 2px 6px rgba(0,0,0,.5); } /* mandatory over photography */
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`transform` also **moves** a block, which is how you land a headline on a
|
|
124
|
+
surface the model put somewhere unexpected:
|
|
125
|
+
`transform: translateY(120px) rotate(-1deg)`.
|
|
126
|
+
|
|
127
|
+
Iterate freely. Layer 2 is deterministic and free — only the art call costs
|
|
128
|
+
money, and re-rendering never touches it.
|
|
129
|
+
|
|
130
|
+
## What is checked automatically
|
|
131
|
+
|
|
132
|
+
Static, before any render:
|
|
133
|
+
|
|
134
|
+
| Rule | Rejects |
|
|
135
|
+
|---|---|
|
|
136
|
+
| `forbidden-element` | `script` `iframe` `object` `embed` `link` `base` `form` `input` `meta` `html` `head` `body`; also `id="poster-stage"`, which is the pipeline's |
|
|
137
|
+
| `forbidden-at-rule` | `@import`, `@font-face`, `@charset`, `@namespace` |
|
|
138
|
+
| `event-handler` | any `on*` attribute |
|
|
139
|
+
| `external-url` | any URL that is not `asset:<token>` or `#fragment` |
|
|
140
|
+
| `unknown-font` | a family outside the palette; the `font` shorthand |
|
|
141
|
+
| `unresolved-asset` | a token with no entry in the assets map |
|
|
142
|
+
| `asset-escape` | an assets path resolving outside the workspace |
|
|
143
|
+
|
|
144
|
+
Measured in a real browser, after fonts have loaded:
|
|
145
|
+
|
|
146
|
+
| Rule | Level | Means |
|
|
147
|
+
|---|---|---|
|
|
148
|
+
| `clipped` | error | text overflows an element whose `overflow` is hidden |
|
|
149
|
+
| `offstage` | error | an element's box falls outside `#poster-stage` |
|
|
150
|
+
| `tofu` | error | a Hebrew character no bundled face covers — it renders as an empty box |
|
|
151
|
+
| `numerals-not-isolated` | warning | a digit run that could reorder |
|
|
152
|
+
| `audit-failed` | error | the check could not run — never treated as a pass |
|
|
153
|
+
|
|
154
|
+
**Errors block output entirely.** No PNG is written, not even a partial one.
|
|
155
|
+
Warnings print and proceed — `--strict-warn` promotes them to blocking. Nothing overrides an error.
|
|
156
|
+
|
|
157
|
+
`clipped` only fires where overflow is actually hidden — text that spills
|
|
158
|
+
visibly is a design choice, and the words are still there.
|
|
159
|
+
|
|
160
|
+
`tofu` is the one that protects the whole premise. It draws each Hebrew character
|
|
161
|
+
twice — once in your font stack, once with the bundled faces stripped out — and
|
|
162
|
+
flags any character where the two are pixel-identical, meaning no bundled face
|
|
163
|
+
contributed and a fallback drew it. On this image no fallback covers Hebrew at
|
|
164
|
+
all, so that is an empty box in the poster. Known gap: standalone combining marks
|
|
165
|
+
(nikud) are not reliably caught, because the dotted-circle placeholder is itself
|
|
166
|
+
in the subset. Proofread vowelised text by eye.
|
|
167
|
+
|
|
168
|
+
> **A clean audit does not mean the poster is good.** These checks catch *broken* —
|
|
169
|
+
> text that is cut off, boxes off the canvas, glyphs that did not render. They say
|
|
170
|
+
> nothing about whether the composition is attractive, whether the copy is legible
|
|
171
|
+
> against the art behind it, or whether the type sits convincingly on a surface.
|
|
172
|
+
> Look at the PNG before you send it, and never report a green audit as "the
|
|
173
|
+
> poster looks good" — that is not what was measured.
|
|
174
|
+
|
|
175
|
+
## When it rejects your layout
|
|
176
|
+
|
|
177
|
+
The report names the element. Fix the layout and re-run: exit code **2** means
|
|
178
|
+
the layout was rejected, **1** means the command itself was wrong (retrying
|
|
179
|
+
that unchanged will fail identically).
|
|
180
|
+
|
|
181
|
+
For `clipped`, cut content or grow the box — do not shrink type below
|
|
182
|
+
legibility. For `offstage`, the report gives both rects; the element is outside
|
|
183
|
+
a stage of the size it names.
|
|
184
|
+
|
|
185
|
+
Two authoring attempts. If the second is still rejected, fall back to
|
|
186
|
+
`poster render` with the nearest template and say that you did — a template
|
|
187
|
+
poster that ships beats a bespoke one that does not.
|
|
188
|
+
|
|
189
|
+
## Where files go
|
|
190
|
+
|
|
191
|
+
The skill directory is mounted **read-only**. Write `layout.html` and
|
|
192
|
+
`assets.json` into your workspace:
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
poster compose --layout work/layout.html --assets work/assets.json \
|
|
196
|
+
--aspect 3:2 -o outbox/poster.png
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Writing next to the templates fails with `EROFS`.
|
|
200
|
+
|
|
201
|
+
## Worked examples
|
|
202
|
+
|
|
203
|
+
The two templates are the reference for what good looks like in this system —
|
|
204
|
+
read one before authoring your own:
|
|
205
|
+
|
|
206
|
+
- `skill/templates/schedule-day.mjs` — time-ordered: hero, taped card of timed
|
|
207
|
+
rows, footer. Note the LTR pin on `.row-time` and the physical-sides comment.
|
|
208
|
+
- `skill/templates/feature-grid.mjs` — feature-ordered: dark hero band with a
|
|
209
|
+
contrast scrim, torn-paper split, icon columns, cards, CTA.
|
|
210
|
+
|
|
211
|
+
They emit fragments through the same shell you do, so anything they do is
|
|
212
|
+
available to you.
|
|
213
|
+
|
|
214
|
+
A worked **freeform** layout ships alongside this file:
|
|
215
|
+
|
|
216
|
+
- `references/example-layout.html` — an itinerary whose title is painted into
|
|
217
|
+
the grain of a wooden sign in the plate (`mix-blend-mode: multiply`, rotated to
|
|
218
|
+
match its hang), with the timed rows in the open corner. It renders clean, and
|
|
219
|
+
it is the shape of thing no fixed template can produce: nothing in a template
|
|
220
|
+
knows there is a sign in the art, let alone where.
|
|
221
|
+
|
|
222
|
+
Run it against your own background to see the mechanics:
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
poster compose --layout references/example-layout.html \
|
|
226
|
+
--assets work/assets.json --aspect 3:2 -o work/try.png
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
(Copy it into your workspace first if you want to edit it — the skill directory
|
|
230
|
+
is read-only.)
|