typeclaw 0.30.1 → 0.31.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/package.json
CHANGED
package/src/channels/router.ts
CHANGED
|
@@ -183,6 +183,18 @@ export const MAX_POLICY_DENIED_CHANNEL_SENDS_PER_TURN = 3
|
|
|
183
183
|
// including reasoning). Deliberately NOT lowered in `providers.ts`, where
|
|
184
184
|
// `maxTokens` is the model's true capability that compaction math reads.
|
|
185
185
|
export const CHANNEL_MAX_OUTPUT_TOKENS = 4096
|
|
186
|
+
// Raised output-token budget threaded into the ONE re-prompt that follows a
|
|
187
|
+
// `stopReason:'length'` empty turn. The default 4096 backstop bounds kimi's
|
|
188
|
+
// degenerate repetition loop, but it is the same ceiling a *legitimate*
|
|
189
|
+
// reasoning-heavy turn hits when it spends the whole pool thinking and emits no
|
|
190
|
+
// prose — re-prompting under the identical cap reproduces the truncation. A
|
|
191
|
+
// `length` truncation that the byte-identical loop guard did NOT catch is
|
|
192
|
+
// evidence of genuine reasoning starved for room, not a repetition loop, so the
|
|
193
|
+
// retry grants 4x headroom for thinking + a reply. Bounded (not 32000) so a
|
|
194
|
+
// turn that IS looping still can't burn the full pi-ai default. Consumed
|
|
195
|
+
// one-shot via `LiveSession.nextPromptMaxTokens`, then reset at the next real
|
|
196
|
+
// user turn so the raised budget never leaks past the turn that needed it.
|
|
197
|
+
export const CHANNEL_EMPTY_TURN_RETRY_MAX_OUTPUT_TOKENS = 16384
|
|
186
198
|
// Ceiling on automatic re-prompts for a turn that ended with NO user-facing
|
|
187
199
|
// reply AND no attempted send — the pure "the model burned its budget thinking
|
|
188
200
|
// and produced nothing" failure. The canonical trigger is Fireworks'
|
|
@@ -200,18 +212,24 @@ export const CHANNEL_MAX_OUTPUT_TOKENS = 4096
|
|
|
200
212
|
export const MAX_EMPTY_TURN_RETRIES = 2
|
|
201
213
|
// Reminder-only nudge injected before an empty-turn retry. Uses the repo's
|
|
202
214
|
// SYSTEM MESSAGE framing (see composeTurnPrompt) so persona-rich models do not
|
|
203
|
-
// reply to the notice itself.
|
|
204
|
-
//
|
|
215
|
+
// reply to the notice itself. Names the actual failure (the prior turn ran out
|
|
216
|
+
// of its output budget mid-reasoning and produced no reply) and asks the model
|
|
217
|
+
// to keep its thinking short and answer directly — the empty turn was budget
|
|
218
|
+
// exhaustion, not a forgotten tool call, so a "reply directly" nudge alone
|
|
219
|
+
// would re-loop. The matching retry re-prompt also runs with a raised budget
|
|
220
|
+
// (CHANNEL_EMPTY_TURN_RETRY_MAX_OUTPUT_TOKENS) so the room actually exists.
|
|
205
221
|
export const EMPTY_TURN_RETRY_NUDGE = [
|
|
206
222
|
'---',
|
|
207
223
|
'**[SYSTEM MESSAGE — not from a human]**',
|
|
208
224
|
'',
|
|
209
|
-
'Your previous turn
|
|
225
|
+
'Your previous turn ran out of its output budget before sending a reply — it',
|
|
226
|
+
'spent the whole turn thinking and produced nothing for the channel. This is',
|
|
210
227
|
'an automated signal from the channel router, not a message from anyone in',
|
|
211
228
|
'the chat. **Do not acknowledge or reply to this notice itself.**',
|
|
212
229
|
'',
|
|
213
|
-
'
|
|
214
|
-
'reply tool. If you genuinely have nothing to say,
|
|
230
|
+
'Answer the last user message now: keep any reasoning brief and send a direct',
|
|
231
|
+
'reply via your channel reply tool. If you genuinely have nothing to say,',
|
|
232
|
+
'reply with `NO_REPLY`.',
|
|
215
233
|
'',
|
|
216
234
|
'---',
|
|
217
235
|
].join('\n')
|
|
@@ -532,6 +550,13 @@ type LiveSession = {
|
|
|
532
550
|
// increments it before injecting EMPTY_TURN_RETRY_NUDGE and reads it to decide
|
|
533
551
|
// retry-vs-fallback. See the candidate===null branch.
|
|
534
552
|
emptyTurnRetries: number
|
|
553
|
+
// One-shot output-token budget for the NEXT `session.prompt()` only.
|
|
554
|
+
// `installChannelOutputCap` reads and clears it per stream call, so it
|
|
555
|
+
// overrides the default backstop for exactly one re-prompt. Set by the
|
|
556
|
+
// empty-turn length-retry branch to CHANNEL_EMPTY_TURN_RETRY_MAX_OUTPUT_TOKENS
|
|
557
|
+
// and reset to undefined at each fresh user turn so the raised budget cannot
|
|
558
|
+
// leak past the turn that needed it.
|
|
559
|
+
nextPromptMaxTokens: number | undefined
|
|
535
560
|
// Stamped by `markTurnSkipped` (called from the `skip_response` tool)
|
|
536
561
|
// with the current `turnSeq`. Read at the top of `validateChannelTurn`:
|
|
537
562
|
// if it matches the just-completed turn, recovery is skipped entirely
|
|
@@ -1417,6 +1442,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1417
1442
|
inFlightToolSends: new Map(),
|
|
1418
1443
|
policyDeniedToolSendsThisTurn: new Map(),
|
|
1419
1444
|
emptyTurnRetries: 0,
|
|
1445
|
+
nextPromptMaxTokens: undefined,
|
|
1420
1446
|
skippedTurn: null,
|
|
1421
1447
|
skipLockedSendTurn: null,
|
|
1422
1448
|
pendingQuoteCandidate: null,
|
|
@@ -1704,14 +1730,22 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1704
1730
|
// Override pi-ai's hidden `Math.min(model.maxTokens, 32000)` output cap for
|
|
1705
1731
|
// channel sessions by threading an explicit `maxTokens` into every stream
|
|
1706
1732
|
// call. See CHANNEL_MAX_OUTPUT_TOKENS for why. Composes the existing streamFn
|
|
1707
|
-
// (pi's default `streamSimple` unless a proxy was installed)
|
|
1708
|
-
// `maxTokens`
|
|
1709
|
-
//
|
|
1733
|
+
// (pi's default `streamSimple` unless a proxy was installed). Precedence:
|
|
1734
|
+
// an explicit per-call `maxTokens` always wins; otherwise a one-shot
|
|
1735
|
+
// `live.nextPromptMaxTokens` (set by the empty-turn length-retry) is consumed
|
|
1736
|
+
// and cleared so the raised budget applies to exactly one stream call;
|
|
1737
|
+
// otherwise the default backstop.
|
|
1710
1738
|
const installChannelOutputCap = (live: LiveSession): void => {
|
|
1711
1739
|
const { agent } = live.session
|
|
1712
1740
|
const inner = agent.streamFn
|
|
1713
|
-
agent.streamFn = (model, context, options) =>
|
|
1714
|
-
|
|
1741
|
+
agent.streamFn = (model, context, options) => {
|
|
1742
|
+
let maxTokens = options?.maxTokens
|
|
1743
|
+
if (maxTokens === undefined && live.nextPromptMaxTokens !== undefined) {
|
|
1744
|
+
maxTokens = live.nextPromptMaxTokens
|
|
1745
|
+
live.nextPromptMaxTokens = undefined
|
|
1746
|
+
}
|
|
1747
|
+
return inner(model, context, { ...options, maxTokens: maxTokens ?? CHANNEL_MAX_OUTPUT_TOKENS })
|
|
1748
|
+
}
|
|
1715
1749
|
}
|
|
1716
1750
|
|
|
1717
1751
|
const startTypingHeartbeat = (live: LiveSession): void => {
|
|
@@ -1904,10 +1938,13 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1904
1938
|
live.lastSentText.clear()
|
|
1905
1939
|
live.pendingQuoteCandidate = captureQuoteCandidate(live.key.adapter, batch, observed)
|
|
1906
1940
|
// A real user batch starts a fresh logical turn → restore the full
|
|
1907
|
-
// empty-turn retry budget
|
|
1908
|
-
//
|
|
1909
|
-
//
|
|
1941
|
+
// empty-turn retry budget and drop any raised output-token budget left
|
|
1942
|
+
// over from a prior turn's length-retry. Reset here (batch.length > 0)
|
|
1943
|
+
// and NOT in the per-prompt block below, so the reminder-only
|
|
1944
|
+
// iterations the retry itself queues do not refill the budget and loop
|
|
1945
|
+
// forever (and the raised cap stays scoped to the turn that set it).
|
|
1910
1946
|
live.emptyTurnRetries = 0
|
|
1947
|
+
live.nextPromptMaxTokens = undefined
|
|
1911
1948
|
} else if (live.lastTurnAuthorId !== null) {
|
|
1912
1949
|
live.currentTurnEngageReactions = []
|
|
1913
1950
|
// Reminder-only turn (batch.length === 0, reminders.length > 0):
|
|
@@ -3037,8 +3074,18 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
3037
3074
|
}
|
|
3038
3075
|
if (!attemptedSendThisTurn && live.emptyTurnRetries < MAX_EMPTY_TURN_RETRIES) {
|
|
3039
3076
|
live.emptyTurnRetries++
|
|
3077
|
+
// Raise the re-prompt's budget ONLY for a `length` truncation: that is
|
|
3078
|
+
// the budget-exhaustion case (reasoning ate the whole pool before any
|
|
3079
|
+
// prose), so the retry needs room to finish thinking AND reply. `error`
|
|
3080
|
+
// and `aborted` are not budget exhaustion — an upstream failure or the
|
|
3081
|
+
// terminal-reply abort — so they retry under the default backstop.
|
|
3082
|
+
// Consumed one-shot by installChannelOutputCap on the next prompt().
|
|
3083
|
+
if (assistantLeafStopReason(live.session) === 'length') {
|
|
3084
|
+
live.nextPromptMaxTokens = CHANNEL_EMPTY_TURN_RETRY_MAX_OUTPUT_TOKENS
|
|
3085
|
+
}
|
|
3040
3086
|
logger.warn(
|
|
3041
|
-
`[channels] ${live.keyId} empty_turn_retry attempt=${live.emptyTurnRetries}/${MAX_EMPTY_TURN_RETRIES}
|
|
3087
|
+
`[channels] ${live.keyId} empty_turn_retry attempt=${live.emptyTurnRetries}/${MAX_EMPTY_TURN_RETRIES} ` +
|
|
3088
|
+
`max_tokens=${live.nextPromptMaxTokens ?? CHANNEL_MAX_OUTPUT_TOKENS}`,
|
|
3042
3089
|
)
|
|
3043
3090
|
live.pendingSystemReminders.push(EMPTY_TURN_RETRY_NUDGE)
|
|
3044
3091
|
return
|
|
@@ -4355,18 +4402,25 @@ function recoverableAssistantText(
|
|
|
4355
4402
|
return null
|
|
4356
4403
|
}
|
|
4357
4404
|
|
|
4358
|
-
//
|
|
4359
|
-
// `length` (hit the token cap
|
|
4360
|
-
// `aborted
|
|
4361
|
-
// truncated", as distinct from a turn that
|
|
4362
|
-
// (leaf undefined / a non-assistant
|
|
4363
|
-
//
|
|
4364
|
-
//
|
|
4365
|
-
|
|
4405
|
+
// The truncation stop reason when the leaf is an assistant message that was CUT
|
|
4406
|
+
// OFF mid-output — `length` (hit the token cap, the canonical kimi reasoning-
|
|
4407
|
+
// loop), `error`, or `aborted` — else undefined. This is the precise signature
|
|
4408
|
+
// of "the model was producing but got truncated", as distinct from a turn that
|
|
4409
|
+
// produced no assistant message at all (leaf undefined / a non-assistant
|
|
4410
|
+
// entry), which is a benign empty/cold turn. Callers that only need the boolean
|
|
4411
|
+
// use `assistantLeafTruncated`; the retry guard reads the reason itself because
|
|
4412
|
+
// the raised reasoning budget is justified ONLY for `length` (budget
|
|
4413
|
+
// exhaustion), not for `error`/`aborted`.
|
|
4414
|
+
function assistantLeafStopReason(session: AgentSession): 'length' | 'error' | 'aborted' | undefined {
|
|
4366
4415
|
const leaf = session.sessionManager.getLeafEntry()
|
|
4367
|
-
if (!leaf || leaf.type !== 'message' || leaf.message.role !== 'assistant') return
|
|
4416
|
+
if (!leaf || leaf.type !== 'message' || leaf.message.role !== 'assistant') return undefined
|
|
4368
4417
|
const stop = leaf.message.stopReason
|
|
4369
|
-
|
|
4418
|
+
if (stop === 'length' || stop === 'error' || stop === 'aborted') return stop
|
|
4419
|
+
return undefined
|
|
4420
|
+
}
|
|
4421
|
+
|
|
4422
|
+
function assistantLeafTruncated(session: AgentSession): boolean {
|
|
4423
|
+
return assistantLeafStopReason(session) !== undefined
|
|
4370
4424
|
}
|
|
4371
4425
|
|
|
4372
4426
|
function visibleAssistantText(message: AssistantMessage): string {
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: typeclaw-markdown-pdf
|
|
3
|
+
description: "Turn any Markdown into a polished, professional PDF and (optionally) attach it to a channel. Load this whenever you need to deliver a document as a PDF rather than raw markdown — reports, summaries, briefs, meeting notes, docs, anything a human would want to download, print, or forward. Triggers: 'make a PDF', 'export to PDF', 'markdown to PDF', 'PDF report', 'attach the report', 'send me a PDF', 'as a PDF', 'turn this into a document', a researcher/subagent result you want to ship as a file, 'PDF로', 'PDF로 만들어', 'PDF로 변환', 'PDF 첨부'. Also load before saying you cannot produce PDFs — you can: this skill installs a tiny Typst toolchain into workspace/ on first use, then renders. Covers the one-time setup, the styled wrapper, the render command, and how to attach the PDF to Slack/Discord/Telegram/KakaoTalk. For operating on EXISTING PDFs (merge, split, extract text, fill forms), this is not the skill — use pypdf/qpdf instead."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# typeclaw-markdown-pdf
|
|
7
|
+
|
|
8
|
+
You can produce professional PDFs from Markdown. This skill installs a small,
|
|
9
|
+
self-contained [Typst](https://typst.app) toolchain into your `workspace/` the
|
|
10
|
+
**first time** you need a PDF, then reuses it. No Pandoc, no LaTeX, no headless
|
|
11
|
+
browser — just an npm-installed Typst compiler plus the
|
|
12
|
+
[`cmarker`](https://typst.app/universe/package/cmarker/) package that reads your
|
|
13
|
+
Markdown.
|
|
14
|
+
|
|
15
|
+
The flow is: **(1)** run the one-time setup (`bun add` the Typst compiler +
|
|
16
|
+
vendor `cmarker` into `workspace/.tools/`), **(2)** write a styled `.typ` wrapper
|
|
17
|
+
that reads your Markdown, **(3)** run the render script. If a channel asked for
|
|
18
|
+
the PDF, attach the result with `channel_send`.
|
|
19
|
+
|
|
20
|
+
You do **not** need to learn Typst markup. `cmarker` renders your CommonMark
|
|
21
|
+
(headings, lists, tables, code, blockquotes, footnotes, links, images). The
|
|
22
|
+
wrapper only sets _styling_ — fonts, margins, headings, page numbers — so the
|
|
23
|
+
output looks deliberate, not like a default-template export.
|
|
24
|
+
|
|
25
|
+
## When to use this
|
|
26
|
+
|
|
27
|
+
- A research report, brief, or summary the user wants as a downloadable file.
|
|
28
|
+
- A subagent (e.g. the `researcher`) handed you a `research-<slug>.md` to ship as a PDF.
|
|
29
|
+
- Any channel message asking for "a PDF" / "the report attached" / "PDF로 보내줘".
|
|
30
|
+
|
|
31
|
+
When plain markdown in chat is fine, **don't** make a PDF. This is for when a
|
|
32
|
+
_file_ is the deliverable.
|
|
33
|
+
|
|
34
|
+
## Step 0 — one-time setup (install the toolchain)
|
|
35
|
+
|
|
36
|
+
Run this `bash` block once per container life. It is **idempotent** — if the
|
|
37
|
+
tools are already present it does nothing and exits fast. It `bun add`s the
|
|
38
|
+
version-pinned Typst compiler (npm pulls only this platform's prebuilt binary —
|
|
39
|
+
Linux x64/arm64, glibc or musl) and vendors the SHA256-verified `cmarker` package
|
|
40
|
+
into `workspace/.tools/` so `@preview/cmarker` resolves offline.
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
set -eu
|
|
44
|
+
cd workspace
|
|
45
|
+
mkdir -p .tools
|
|
46
|
+
cd .tools
|
|
47
|
+
|
|
48
|
+
# Pinned to the exact versions validated for this skill. COMPILER_VERSION is the
|
|
49
|
+
# npm package version of the Typst compiler; it embeds Typst 0.14.2. Bumping
|
|
50
|
+
# either is a deliberate edit — keep the embedded-Typst note below in sync.
|
|
51
|
+
COMPILER_VERSION="0.7.0" # @myriaddreamin/typst-ts-node-compiler (embeds Typst 0.14.2)
|
|
52
|
+
CMARKER_VERSION="0.1.8"
|
|
53
|
+
PKGDIR="typst-packages/preview/cmarker/$CMARKER_VERSION"
|
|
54
|
+
|
|
55
|
+
if [ -f "node_modules/@myriaddreamin/typst-ts-node-compiler/package.json" ] && [ -f "$PKGDIR/lib.typ" ]; then
|
|
56
|
+
echo "markdown-pdf toolchain already installed"
|
|
57
|
+
else
|
|
58
|
+
# The Typst compiler, version-pinned. `bun add` resolves the right prebuilt
|
|
59
|
+
# NAPI binary for this platform via optionalDependencies — no Rust toolchain,
|
|
60
|
+
# no manual download. The exact pin keeps the toolchain reproducible: a future
|
|
61
|
+
# npm release can't silently change the embedded Typst version or the API that
|
|
62
|
+
# Step 3 depends on.
|
|
63
|
+
[ -f package.json ] || echo '{"name":"typeclaw-markdown-pdf-tools","private":true}' > package.json
|
|
64
|
+
bun add "@myriaddreamin/typst-ts-node-compiler@$COMPILER_VERSION"
|
|
65
|
+
|
|
66
|
+
# cmarker (Markdown -> Typst), vendored so compilation needs no network.
|
|
67
|
+
mkdir -p "$PKGDIR"
|
|
68
|
+
curl -fsSL -o cmarker.tar.gz \
|
|
69
|
+
"https://packages.typst.org/preview/cmarker-$CMARKER_VERSION.tar.gz"
|
|
70
|
+
echo "157cc40db2716f12c7eabb95df1f60714a4d95ebfb1c6087cf4aec224e49392a cmarker.tar.gz" | sha256sum -c -
|
|
71
|
+
tar -xzf cmarker.tar.gz -C "$PKGDIR"
|
|
72
|
+
rm cmarker.tar.gz
|
|
73
|
+
echo "markdown-pdf toolchain installed"
|
|
74
|
+
fi
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Notes:
|
|
78
|
+
|
|
79
|
+
- It writes only under `workspace/`, the directory your `bash`/`write` tools can
|
|
80
|
+
write to. `workspace/.tools/` is gitignored scratch — it does not get committed.
|
|
81
|
+
- It needs network the first time (to `bun add` the compiler + fetch the package).
|
|
82
|
+
After that the tools persist for the life of the container.
|
|
83
|
+
- **Everything is version-pinned and reproducible.** The validated toolchain is
|
|
84
|
+
`@myriaddreamin/typst-ts-node-compiler@0.7.0` (which embeds Typst **0.14.2**) and
|
|
85
|
+
`cmarker@0.1.8` (SHA256-verified). The `bun add` uses the exact `@0.7.0` pin, so
|
|
86
|
+
a future npm release can't change the embedded Typst version or the API Step 3
|
|
87
|
+
uses. To upgrade, bump both `COMPILER_VERSION` and the embedded-Typst note
|
|
88
|
+
together after re-validating.
|
|
89
|
+
|
|
90
|
+
## Step 1 — have the markdown ready
|
|
91
|
+
|
|
92
|
+
Use an existing markdown file (yours or a subagent's), or `write` your content to
|
|
93
|
+
`workspace/<slug>.md`. Standard CommonMark plus tables and footnotes all work.
|
|
94
|
+
|
|
95
|
+
## Step 2 — write the styled wrapper
|
|
96
|
+
|
|
97
|
+
`write` this to `workspace/<slug>.typ`, changing only the `read("...")` filename
|
|
98
|
+
to match your markdown. The defaults are a clean, professional house style; adjust
|
|
99
|
+
fonts/margins only if the user asks.
|
|
100
|
+
|
|
101
|
+
```typst
|
|
102
|
+
#set document(title: "Report")
|
|
103
|
+
#set page(
|
|
104
|
+
paper: "a4",
|
|
105
|
+
margin: (x: 2.5cm, y: 2.75cm),
|
|
106
|
+
numbering: "1",
|
|
107
|
+
footer: context align(center, text(size: 9pt, fill: luma(120))[
|
|
108
|
+
#counter(page).display("1 / 1", both: true)
|
|
109
|
+
]),
|
|
110
|
+
)
|
|
111
|
+
#set text(font: ("Libertinus Serif", "New Computer Modern"), size: 11pt, lang: "en")
|
|
112
|
+
#set par(justify: true, leading: 0.68em, spacing: 1.1em)
|
|
113
|
+
|
|
114
|
+
#show heading: set text(weight: "semibold")
|
|
115
|
+
#show heading.where(level: 1): it => block(width: 100%, above: 1.4em, below: 0.9em)[
|
|
116
|
+
#text(size: 1.5em, it.body)
|
|
117
|
+
#v(-0.4em)
|
|
118
|
+
#line(length: 100%, stroke: 0.5pt + luma(200))
|
|
119
|
+
]
|
|
120
|
+
#show link: it => text(fill: rgb("#1a56db"), underline(it))
|
|
121
|
+
#show quote.where(block: true): it => block(
|
|
122
|
+
inset: (left: 1em), stroke: (left: 2pt + luma(200)),
|
|
123
|
+
text(style: "italic", fill: luma(80), it.body),
|
|
124
|
+
)
|
|
125
|
+
#show raw.where(block: true): it => block(
|
|
126
|
+
fill: luma(245), inset: 8pt, radius: 4pt, width: 100%, text(size: 9pt, it),
|
|
127
|
+
)
|
|
128
|
+
#show table: set table(stroke: 0.5pt + luma(200))
|
|
129
|
+
|
|
130
|
+
#import "@preview/cmarker:0.1.8"
|
|
131
|
+
#cmarker.render(read("report.md"), h1-level: 1, blockquote: quote.with(block: true))
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Notes:
|
|
135
|
+
|
|
136
|
+
- `read("report.md")` is **relative to the workspace** (the compiler's `workspace`
|
|
137
|
+
is set to `workspace/` — see Step 3). Keep the `.typ` and `.md` in `workspace/`.
|
|
138
|
+
- Fonts `Libertinus Serif` / `New Computer Modern` are bundled with Typst (no font
|
|
139
|
+
install). For Korean/CJK body text, add `"Noto Serif CJK KR"` to the `font:` list
|
|
140
|
+
and pass that font dir to `fontPaths` in Step 3 (the container's `cjkFonts` toggle
|
|
141
|
+
installs `fonts-noto-cjk` under `/usr/share/fonts`).
|
|
142
|
+
|
|
143
|
+
## Step 3 — render
|
|
144
|
+
|
|
145
|
+
`write` this tiny renderer to `workspace/.tools/render.ts`, then run it. It loads
|
|
146
|
+
the npm-installed compiler, points the package cache at the vendored `cmarker`, and
|
|
147
|
+
writes the PDF. Pass the wrapper and output paths as arguments.
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
// workspace/.tools/render.ts
|
|
151
|
+
import { NodeCompiler } from '@myriaddreamin/typst-ts-node-compiler'
|
|
152
|
+
import { writeFileSync } from 'node:fs'
|
|
153
|
+
|
|
154
|
+
const [, , mainFile, outFile] = process.argv
|
|
155
|
+
if (!mainFile || !outFile) throw new Error('usage: render.ts <main.typ> <out.pdf>')
|
|
156
|
+
|
|
157
|
+
const compiler = NodeCompiler.create({
|
|
158
|
+
workspace: '.', // run from workspace/, so read("report.md") resolves
|
|
159
|
+
// Add CJK / extra font dirs here if needed:
|
|
160
|
+
// fontArgs: [{ fontPaths: ["/usr/share/fonts"] }],
|
|
161
|
+
})
|
|
162
|
+
const pdf = compiler.pdf({ mainFilePath: mainFile })
|
|
163
|
+
writeFileSync(outFile, Buffer.from(pdf))
|
|
164
|
+
console.log(`wrote ${outFile} (${pdf.length} bytes)`)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Run it from `workspace/`, with the package cache pointed at the vendored packages:
|
|
168
|
+
|
|
169
|
+
```sh
|
|
170
|
+
cd workspace
|
|
171
|
+
TYPST_PACKAGE_CACHE_PATH="$PWD/.tools/typst-packages" \
|
|
172
|
+
TYPST_PACKAGE_PATH="$PWD/.tools/typst-packages" \
|
|
173
|
+
bun .tools/render.ts report.typ report.pdf
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Verify: the command prints `wrote report.pdf (...)` and `workspace/report.pdf`
|
|
177
|
+
exists. On a compile error the compiler throws with the offending Typst line —
|
|
178
|
+
usually raw HTML or a markdown extension `cmarker` doesn't support; simplify that
|
|
179
|
+
part and re-run.
|
|
180
|
+
|
|
181
|
+
## Rich elements (optional)
|
|
182
|
+
|
|
183
|
+
When plain markdown isn't enough — you want a cover banner, callout boxes,
|
|
184
|
+
multi-column sections, captioned figures — you don't switch to HTML (Typst
|
|
185
|
+
doesn't render HTML). Instead, drop **raw Typst** into the markdown via
|
|
186
|
+
`<!--raw-typst ... -->` comments. `cmarker` evaluates them as Typst (the
|
|
187
|
+
`raw-typst: true` option is already the default and is set in the wrapper above).
|
|
188
|
+
The rest of the document stays plain markdown.
|
|
189
|
+
|
|
190
|
+
Each snippet below is self-contained — paste it into your `.md` where you want the
|
|
191
|
+
element. They use Typst built-ins only (no extra packages).
|
|
192
|
+
|
|
193
|
+
**Cover banner** (top of a report):
|
|
194
|
+
|
|
195
|
+
```markdown
|
|
196
|
+
<!--raw-typst
|
|
197
|
+
#block(width: 100%, fill: rgb("#0f172a"), inset: 18pt, radius: 6pt)[
|
|
198
|
+
#text(fill: white, size: 1.6em, weight: "bold")[Quarterly Business Review]
|
|
199
|
+
#v(2pt)
|
|
200
|
+
#text(fill: rgb("#94a3b8"), size: 0.95em)[Acme Robotics · Q2 2026 · Confidential]
|
|
201
|
+
]
|
|
202
|
+
#v(1em)
|
|
203
|
+
-->
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
**Callout boxes** (info / warning — change the two colors for other variants):
|
|
207
|
+
|
|
208
|
+
```markdown
|
|
209
|
+
<!--raw-typst
|
|
210
|
+
#block(fill: rgb("#eff6ff"), stroke: (left: 3pt + rgb("#3b82f6")), inset: 12pt, radius: 4pt, width: 100%)[
|
|
211
|
+
#text(weight: "bold")[Note.] Revenue grew 31% YoY.
|
|
212
|
+
]
|
|
213
|
+
#v(0.6em)
|
|
214
|
+
#block(fill: rgb("#fef2f2"), stroke: (left: 3pt + rgb("#ef4444")), inset: 12pt, radius: 4pt, width: 100%)[
|
|
215
|
+
#text(weight: "bold")[Risk.] A single supplier covers 40% of NPUs.
|
|
216
|
+
]
|
|
217
|
+
-->
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
**Two-column section** (use `#colbreak()` to split):
|
|
221
|
+
|
|
222
|
+
```markdown
|
|
223
|
+
<!--raw-typst
|
|
224
|
+
#columns(2, gutter: 1.4em)[
|
|
225
|
+
#text(weight: "bold")[Strengths]
|
|
226
|
+
- Net retention 124%
|
|
227
|
+
- Margin +240bps
|
|
228
|
+
#colbreak()
|
|
229
|
+
#text(weight: "bold")[Risks]
|
|
230
|
+
- Supplier concentration
|
|
231
|
+
- Partial FX hedging
|
|
232
|
+
]
|
|
233
|
+
-->
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
**Figure with caption** (swap the `rect(...)` for `image("chart.png")` to embed an
|
|
237
|
+
image written to `workspace/`):
|
|
238
|
+
|
|
239
|
+
```markdown
|
|
240
|
+
<!--raw-typst
|
|
241
|
+
#figure(
|
|
242
|
+
rect(width: 60%, height: 48pt, fill: luma(245), stroke: 0.5pt + luma(180)),
|
|
243
|
+
caption: [Revenue trend, Q1–Q2 2026.],
|
|
244
|
+
)
|
|
245
|
+
-->
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
**Definition grid** (label column + description column):
|
|
249
|
+
|
|
250
|
+
```markdown
|
|
251
|
+
<!--raw-typst
|
|
252
|
+
#grid(columns: (auto, 1fr), row-gutter: 6pt, column-gutter: 12pt,
|
|
253
|
+
text(weight: "bold")[NPU], [Neural processing unit — on-device inference accelerator.],
|
|
254
|
+
text(weight: "bold")[Net retention], [Revenue from existing customers vs. a year ago.],
|
|
255
|
+
)
|
|
256
|
+
-->
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Keep it tasteful — a banner, a couple of callouts, and one good figure read as
|
|
260
|
+
deliberate; a wall of colored boxes reads as noise.
|
|
261
|
+
|
|
262
|
+
## Rendering an _existing_ web page or HTML to PDF
|
|
263
|
+
|
|
264
|
+
This skill renders **markdown you author**. If instead you need to capture an
|
|
265
|
+
**existing web page or a live URL** as a PDF — something Typst cannot do — use the
|
|
266
|
+
already-installed `agent-browser` (Chrome): `agent-browser --allow-file-access open
|
|
267
|
+
file:///agent/workspace/page.html` (or a URL), then `agent-browser pdf
|
|
268
|
+
/agent/workspace/out.pdf`. Note its output is fixed US-Letter with default margins
|
|
269
|
+
(no page-size flags), and launching the browser needs a trusted/owner session — so
|
|
270
|
+
it's the right tool for _archiving web content_, not for authoring styled reports.
|
|
271
|
+
For authored documents, stay on the Typst path above.
|
|
272
|
+
|
|
273
|
+
## Step 4 — deliver
|
|
274
|
+
|
|
275
|
+
- **Channel asked for the PDF** — attach it:
|
|
276
|
+
|
|
277
|
+
```
|
|
278
|
+
channel_send(text: "Here's the report.", attachments: [{ path: "/agent/workspace/report.pdf", filename: "Edge-AI-Brief.pdf" }])
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Use a human-friendly `filename` and an absolute `/agent/workspace/...` path. Slack,
|
|
282
|
+
Discord, Telegram, and KakaoTalk upload the file; the GitHub adapter has no
|
|
283
|
+
attachment support, so there post a link or paste the markdown.
|
|
284
|
+
|
|
285
|
+
- **Replying in a thread** — use `channel_reply` with the same `attachments` shape.
|
|
286
|
+
|
|
287
|
+
- **No channel** (TUI session) — just report the path: `workspace/report.pdf`.
|
|
288
|
+
|
|
289
|
+
## If you got the markdown from a subagent
|
|
290
|
+
|
|
291
|
+
The `researcher` subagent writes its report to `workspace/research-<slug>.md` and
|
|
292
|
+
returns a `<report>` block naming the file. Point the wrapper's `read(...)` at that
|
|
293
|
+
file, render, and attach. You do the PDF step — the researcher's `bash` is
|
|
294
|
+
read-only and it only emits markdown by design.
|
|
295
|
+
|
|
296
|
+
## Customizing this skill
|
|
297
|
+
|
|
298
|
+
This is a bundled default. Want a different house style, a different converter, or
|
|
299
|
+
a cover page with a logo? Copy this file to `.agents/skills/<your-name>/SKILL.md`
|
|
300
|
+
(use a **different** `name`; bundled skills win name collisions) and edit the setup
|
|
301
|
+
or the wrapper there. Because the whole pipeline — install + render — lives in the
|
|
302
|
+
skill, you can change either half without touching the container image.
|
|
303
|
+
|
|
304
|
+
## Known limitations
|
|
305
|
+
|
|
306
|
+
`cmarker` covers CommonMark well, but a few markdown features don't render as you
|
|
307
|
+
might expect:
|
|
308
|
+
|
|
309
|
+
- **Task-list checkboxes** (`- [ ]` / `- [x]`) render as literal `[ ]` text, not
|
|
310
|
+
checkboxes. Use a plain bullet list or a status column in a table instead.
|
|
311
|
+
- **Bold/italic directly adjacent to CJK + parenthetical Latin** (e.g.
|
|
312
|
+
`**로컬 우선(local-first)**`) may not be recognized as emphasis — CommonMark's
|
|
313
|
+
flanking rules treat that boundary as non-emphasis. Put a space inside, or bold a
|
|
314
|
+
pure run of text.
|
|
315
|
+
- **Raw HTML** in the markdown is mostly ignored. Express structure in markdown
|
|
316
|
+
(tables, lists) rather than HTML.
|
|
317
|
+
|
|
318
|
+
## Don'ts
|
|
319
|
+
|
|
320
|
+
- **Don't** hand-write Typst markup for the body. Let `cmarker` render the
|
|
321
|
+
markdown; only style via `#set` / `#show` rules in the wrapper.
|
|
322
|
+
- **Don't** write the `.typ`, `.md`, `.pdf`, or `.tools/` outside `workspace/` —
|
|
323
|
+
the sandbox blocks it.
|
|
324
|
+
- **Don't** re-run Step 0's install if the tools already exist — the guard at the
|
|
325
|
+
top skips it. Re-installing every time is wasteful.
|
|
326
|
+
- **Don't** attach a PDF to a GitHub channel — that adapter rejects attachments.
|
|
327
|
+
Link or inline instead.
|