memos
how we author
Colophon
The harness that drafts and narrates these memos, read from the repo at build: an agent-driven authoring skill, a voice spec, in-body components, a narration toolchain, and a rendering pipeline. Reproducible steps are code; judgment stays prose.
substrate as of 0627dc0 · 21 files · build-time snapshot
.agents/skills/memo/SKILL.md
---
name: memo
description: Use when researching, drafting, or shipping an engineering memo through a conversational workflow. Covers the full lifecycle from a rough idea to a published memo, both the text and its narrated audio.
argument-hint: '"rough idea" | <id>'
disable-model-invocation: true
---
# Memo Skill
Manage the research -> draft -> ship lifecycle for engineering memos. Each command is one
conversational cycle: detect state, show current working state, present workflow options based on
available artifacts. Respects manual edits. This file is the concise entry point; detailed
per-action procedures, the audio toolchain, and shipping live in `references/` (see Additional
resources).
## Artifacts
Memos live in `memos/wip/<id>-<slug>/`, at the repo root, outside the Astro collection glob
(`site/src/content/memos/**/*.md`), so WIP work never leaks into the built site. A memo that is done,
shipped or abandoned, moves as-is to `memos/archived/<id>-<slug>/`; both directories are the author's
local record and neither is committed. Each memo can carry up to eight artifacts:
- `meta.yaml`: the verbatim rough idea, plus the author byline and GitHub handle, captured at
create (`/memo "idea"`). The idea is the memo's immutable seed (Rough Idea in `research.md` is its
living interpretation); `author` and `handle` drive the promote frontmatter author and the
per-author voice used by every render. See `references/lifecycle.md` and `references/audio.md`.
- `research.md`: exploration, notes, references. Rendered from `references/research.yaml` on
create; template contract and per-action detail in `references/lifecycle.md`.
- `draft.md`: memo with H1 title plus body. Generated by the Move to Draft action; detail in
`references/lifecycle.md`.
- `speech.md`, `adaptations.yml`, `<slug>.mp3`, `.speech-hash`, `.audio.json`: the spoken-form
projection, its per-memo pronunciation and pacing overrides, the rendered audio, and their
integrity records (`.audio.json` is the rendered audio's provenance record: hash plus measured
duration). Full grammar, listen loop, and staleness formulas in `references/audio.md`.
All artifact filenames and paths are defined here. Other sections refer to them by name.
## Workflow States
The skill shows where a memo is and offers the moves for that point. A filled dot (●) marks a stage
that exists, a hollow dot (○) one that does not; the stages are the research file, the draft file,
and the promoted site file. Position is the rightmost filled dot, the next move the first hollow one.
Always present the current state's full option set, verbatim from the list below. Recommending one is
fine; omitting the rest is not. The user chooses from the whole menu, never from a subset curated to
the move you happen to favour.
```
● research --- ○ draft --- ○ promoted
revise research, web research, move to draft, or archive
● research --- ● draft --- ○ promoted
revise research, web research, revise draft, integrate research, corroborate, listen, promote,
or archive
● research --- ● draft --- ● promoted
listen, preview (npm run dev), or ship
```
**Research input comes from 2 sources:** user input (focus areas, ideas, direction, captured via
Revise Research) and web research around the focus areas and adjacent topics (captured via Web
Research). Both feed into the research artifact during exploration.
## Interaction Cycle
Each cycle follows the same pattern:
1. User inputs command `/memo "idea"` or `/memo <id>`
2. Skill creates/detects state: folder, files, content
3. Skill shows current state (the notation above)
4. Skill presents options based on artifacts
5. User responds, either picking one of the listed actions, or continuing the conversation.
6. If an action: process and update the file. If conversation: respond in chat only; artifacts
stay untouched.
7. Skill shows result: preview and confirmation for actions; just the reply for conversation.
8. Loop returns to step 3 after actions. After a conversation turn, stay in conversation; no need
to re-render state until the next action.
**Artifacts are only written when the user picks a listed action.** Free-form follow-ups,
questions, reactions, pushback, thinking out loud, are conversation, not instructions to revise.
Treat a follow-up as a revise only when the user either explicitly picks a revise option or uses
imperative capture language ("add...", "note that...", "put in research that...", "write
down..."). When in doubt, stay in conversation and ask before writing. **Don't auto-capture.**
## Commands
### `/memo` (no arguments)
Glob `memos/wip/*/*` to find all files inside WIP memo folders, then deduplicate by parent folder
to get the list of memos. The parent folder name is `<id>-<slug>`. For each memo, check which
artifacts exist to determine its state, then display it using the state notation above.
This lists `wip/` only, which is the point: a shipped memo has been retired to `memos/archived/` and is
no longer work in progress. Count the folders there and close with a pointer, so a published memo stays
discoverable without a second table competing with the working set.
**What user sees:**
```
WIP memos:
ab12 webhook-retry-storm ● research --- ○ draft --- ○ promoted
cd34 queue-backpressure-incident ● research --- ● draft --- ○ promoted
3 archived (shipped or abandoned). /memo <id> opens any of them.
Continue with /memo <id>, or start a new one with /memo "your rough idea".
```
If no memos exist: `No WIP memos. Start one with /memo "your rough idea".`, keeping the archived line
above it when `memos/archived/` is not empty.
### `/memo "Your rough idea"`
Derive a slug from the idea (via `slug.py`, see Implementation Notes), generate a 4-char id, render `references/research.yaml` into
`memos/wip/<id>-<slug>/research.md`, populating only the section the idea directly addresses and
leaving the rest for later exploration. Also create `meta.yaml`, recording the verbatim rough idea
as the memo's immutable seed and capturing the author byline and GitHub handle by prompting the
driver, defaulting `handle` from `gh api user -q .login` and `author` from `gh api user -q .name`
(falling back to `git config user.name`); each default is overridable. Full procedure in
`references/lifecycle.md`.
User responds with a choice (revise research, web research, move to draft, archive) or with
free-form conversation. Skill processes the choice (and updates state) or replies in chat only,
then presents options again.
### `/memo <id>`
Resolve the id against `memos/wip/*` first, then `memos/archived/*`, and work from whichever folder
holds it. An id resolves to exactly one folder; the two directories never both hold the same id. A
memo found under `archived/` has been shipped or abandoned, and its artifacts read the same either
way, so nothing about the options depends on which directory it came from.
Detect state (research exists? draft exists?), show content preview (first 100 words of each),
present available options for that state. A memo is in the Promoted state once its canonical site
file `site/src/content/memos/<slug>.md` exists; in that state the options are listen, preview
(`npm run dev`), and ship. Promote leaves the WIP folder in place as the paper trail and ship retires
it to `memos/archived/`, so a promoted memo resolves from `wip/` and a shipped one from `archived/`.
Shipping again after editing the canonical body is expected and stays a valid move: ship works from
the archived folder and leaves it there (see the Archive procedure in `references/lifecycle.md`).
User responds with a choice (action) or with free-form conversation. Skill processes the action or
replies in chat only, then shows updated state on actions.
## Implementation Notes
**Slug generation:** run `uv run tools/memo/slug.py "<title-or-idea>"` (which calls
`lib.slugify`); never hand-roll it. One tested computation (ASCII-fold, lowercase, non-alphanumeric
runs collapsed to single hyphens, ends trimmed) keeps the published slug identical across the memo
file, its `/memos/<slug>` route, and its mp3, and stays stable on awkward titles (punctuation,
accents, doubled spaces).
**ID:** 4 random alphanumeric characters (a-z, 0-9). Ensures uniqueness even if slugs collide.
**Two slugs, by design:** the WIP folder slug is derived from the idea at create, as a human label
(the 4-char id is the stable key). The published URL slug is derived from the memo's final title at
promote (see `references/shipping.md`). They can differ, because the title is only known once the memo
exists; the published render works off the title slug, and the WIP mp3 is a preview only.
**Toolchain:** the deterministic scripts (narration: derive speech, render mp3, verify provenance;
identity: the published slug) are first-class repo tooling at `tools/memo/`, not skill-internal. The
skill, CI, and a human at a terminal all invoke them the same way, `uv run tools/memo/<script>.py`.
References name them by bare filename (`project.py`, `render.py`, `slug.py`, `verify_audio.py`); each
is `tools/memo/<name>`. See `references/audio.md`.
**Voice alignment:** content follows this repo's voice guidelines: the shared spine
(`.agents/rules/voice.md`) and the memo register (`.agents/rules/voice.memo.md`), both path-scoped
to memo files so they load automatically. Rules in context shape writing without enforcing it, so
Move to Draft and Revise Draft end with a separate voice pass that checks the prose against them
before the author sees it (see `references/lifecycle.md`). Voice quality stays collaborative
feedback, not a hard gate: the pass protects the author's read, it does not replace it.
**Manual edits:** skill always reads current file state. Manual edits are respected. Next
invocation sees them.
**Error recovery:** all errors are recoverable. Show the problem clearly, show what's needed, offer
an immediate fix path inline. No abort; continue the workflow to keep the user in flow.
## Additional resources
- [references/lifecycle.md](references/lifecycle.md): the template contract for rendered
artifacts, the full create-time author-capture procedure (`meta.yaml`, gh/git defaults, prompts),
and the detailed per-action behaviour for revise research, web research, move to draft, revise
draft, integrate research, and archive.
- [references/devices.md](references/devices.md): the in-body device catalogue for authoring
(callout, exchange, table, code, footnote): syntax, when to reach for each, and its audio
behaviour. The device vocabulary (callout types and exchange roles) is single-sourced in
`site/src/lib/memo-devices.json`.
- [references/audio.md](references/audio.md): adaptations format and grammar, deriving speech,
the listen loop (including passing `--handle` and `--config` to `render.py`), per-author voice
resolution, propagation, how to change how a memo sounds, and staleness hash formulas.
- [references/shipping.md](references/shipping.md): corroborate, promote (site file, frontmatter
author read from `meta.yaml`), and the ship-gate checks.
- [references/architecture.md](references/architecture.md): the rule that decides which memo
operations are deterministic scripts and which stay agent prose, and the corpus mapped against it..agents/skills/memo/references/lifecycle.md
# Memo Lifecycle: Template Contract, Create, and Per-Action Behaviour
Detail for the actions listed in `SKILL.md`'s Workflow States and Commands sections.
## Contents
- Template contract for rendered artifacts
- Create: author capture (`/memo "idea"`)
- Per-action behaviour: revise research, web research, move to draft, revise draft, integrate
research, archive
## Template contract (for rendered artifacts)
Some artifacts are rendered from a YAML template on create. Each such artifact has a matching
`references/<artifact>.yaml` with this shape:
```yaml
rules: [] # cross-cutting procedural guidance (list of strings; may be empty)
sections: # ordered, non-empty
- heading: ... # rendered as `## {heading}` in the memo file
description: ... # rendered inside `[...]` as the user-facing purpose placeholder
instructions: # list of strings, consulted when filling/revising; never written to the memo
- ...
```
For every rendered artifact:
1. **Required template.** `references/<artifact>.yaml` must exist. If missing or malformed,
surface a user-facing error and stop.
2. **Create by rendering.** Parse the YAML and write each section as `## {heading}` followed by a
blank line and `[{description}]`. Do not write `rules` or `instructions` into the memo file.
3. **Fill and revise by consulting.** On any fill or revise, read the YAML, match sections by
heading text, and follow the matching section's `instructions` plus the top-level `rules`. Do
not copy either into the memo file.
Currently `research.md` is the only rendered artifact. Artifacts generated by an action (for
example `draft.md` via Move to Draft) are governed by that action's specification below, not by
this contract.
## Create: author capture (`/memo "idea"`)
Creating a WIP memo does two things: render `research.md` (per the template contract above) and
write `meta.yaml`, the per-memo seed and author/voice identity:
```yaml
idea: "the rough idea, verbatim" # the create argument, recorded as the memo's immutable seed; Rough Idea in research.md is its living interpretation
author: "John Doe" # display name captured at create; the canonical byline lives in authors.yml
handle: john-doe-unipaas # GitHub handle -> frontmatter `author` reference at promote, and per-author voice
```
`idea` is recorded verbatim from the create argument: the immutable seed the memo keeps even as
Rough Idea in `research.md` evolves. Capture `author` and `handle` by prompting the driver, with
these defaults:
- `handle`: `gh api user -q .login`
- `author` (the byline): `gh api user -q .name`; if that is empty, fall back to
`git config user.name`
Show the resolved defaults and let the driver override either before writing `meta.yaml`. Once
written, `meta.yaml` is not regenerated on later `/memo <id>` invocations of the same memo; it is
respected like any other manually-edited artifact. `meta.yaml` is what promote reads the author
`handle` from (written verbatim as the frontmatter `author` reference, which must be a key in
`authors.yml`; see `references/shipping.md`) and what every render passes as `--handle` (see
`references/audio.md`); it exists independent of research/draft state and does not appear in the
Workflow States notation.
## Per-Action Behaviour
**Revise Research:**
Prompt "What would you like to revise or explore?" then append to `research.md` in the section(s)
the user named. Do not proactively modify other sections, even if the new material feels connected
to them. Follow each touched section's `instructions` in `research.yaml` plus the top-level
`rules`.
**Web Research:**
- Prompt: "What should I search for?" (default: the focus areas already named in `research.md` if
the user doesn't specify)
- Run web research around those focus areas and adjacent topics
- Show the user what turned up: findings and their sources
- Prompt: "Which of these should land in research, and where: Observations, Sources & Links?"
- User responds. Skill appends sources to Sources & Links and findings to Observations, following
the Template contract's fill instructions plus the top-level `rules`.
**Move to Draft:**
- Prompt: "Do you have a title?"
- If the user provides a title: use it
- If the user says no, or has none: use the placeholder `Untitled memo`
- Read `research.md` in full
- Write a memo body informed by the research, not a summary of it. The emerging argument drives
the shape; specific observations do the grounding.
- Reach for a body device (callout, exchange, table, code, footnote) only where it earns its place;
prose is the default. See `references/devices.md` for the vocabulary, syntax, and audio behaviour.
- Write `draft.md` with H1 plus generated body
- Run the Voice pass (below) before showing the draft
**Revise Draft:**
- Show current draft excerpt
- Prompt: "What would you like to revise or add?"
- Before editing the target passage, read the paragraphs immediately before and after it.
Revisions must preserve the flow (sentence rhythm, motif callbacks, the argument's local arc),
not just satisfy the request in isolation.
- User responds; skill updates `draft.md`
- Run the Voice pass (below) before showing the revised draft
**Voice pass (before a draft is shown):**
Move to Draft and Revise Draft both end here: the new or revised prose is checked against the voice
rules before the author sees it. Run it as a step of its own after writing, because rules in context
shape generation without enforcing it, and a separate critique catches what generation rationalises
past. Re-read the draft adversarially against `voice.md` and `voice.memo.md`, which stay the single
source of the rules (do not restate them here); apply the clear fixes and surface the judgment calls.
Voice stays collaborative feedback, not a hard gate: the pass protects the author's read, it does not
replace it.
**Integrate Research -> Draft:**
- Read both `research.md` and `draft.md` in full
- Identify findings in research not yet reflected in the draft
- Show the user what's missing: "These research findings aren't in the draft yet: [list]"
- Prompt: "Which of these should land in the draft, and where?"
- User responds; skill integrates at the specified location
- For revised or additional research after the initial draft, not for first-time population
**Archive:**
Two callers, one procedure: the author abandoning a memo before promote, and ship retiring a memo that
has been published (`references/shipping.md`, gate step 6). Both mean the same thing, that the folder
is no longer work in progress.
- Move `memos/wip/<id>-<slug>/` as-is to `memos/archived/<id>-<slug>/` (create `memos/archived/`
if it doesn't exist). As-is means every artifact, dotfiles included; nothing is dropped or deleted.
- Idempotent: if `memos/archived/<id>-<slug>/` already exists and `wip/` has no folder for the id,
the memo is already archived. Say so and carry on rather than treating it as an error. Never copy an
archived folder back into `wip/`, and never merge two folders for one id.
- Show: "Archived <id>-<slug> to memos/archived/"
- When the caller is the author abandoning the memo, there are no further options: the memo is no
longer WIP. When the caller is ship, the memo stays reachable by id and its options are unchanged
(see the state resolution in SKILL.md)..agents/skills/memo/references/devices.md
# Memo Body Devices (authoring catalogue)
The in-body devices an author can use in a memo body. They are authored as plain, portable markdown,
not components you invoke: you type a documented convention and the site's build transforms it (the
callout mdast plugin, GFM, Shiki). Everything here renders on GitHub too, so a draft stays readable
before it ships. Reach for a device when it earns its place; prose is the default.
Each device also has a spoken form. This catalogue covers when and how to author them; the full
per-device audio projection policy lives in `references/audio.md` (Device projection).
## Contents
- Callout
- Exchange (prompt / response)
- Table
- Code
- Footnote
## Callout (the one custom device)
A GitHub-style alert blockquote, styled to the memo's callout aside. Use for a caveat, a gotcha, or a
short aside that must stand out from the prose. Do not use it as a section; keep it to a few lines.
```
> [!WARNING]
> A retry loop with no backoff is a load generator. Point it carefully.
```
Types and how they fold to the three built states:
| You write | Renders/speaks as |
|---|---|
| `[!NOTE]` | note |
| `[!TIP]` | tip |
| `[!IMPORTANT]` | note |
| `[!WARNING]` | warning |
| `[!CAUTION]` | warning |
This fold is the single source at `site/src/lib/memo-devices.json` (`callouts`), read by both the
site's render plugin and the audio projector, so a type always renders and narrates the same way. To
add or change a type, edit that file; do not hardcode a type here or in either runtime.
Audio: the state is spoken as a lead ("Warning."), then the blockquote markers drop so the body reads
as prose. A plain `>` blockquote (no `[!TYPE]`) is a pull-quote and reads as its text.
## Exchange: prompt / response (custom)
An agent turn shown as its own receipt: the prompt you typed and the model's response, the response
carrying its model id as provenance. Use it when a real prompt/response exchange is the evidence, an
agentic-engineering memo showing its own transcript. Do not use it to paraphrase a conversation;
quote the real turn or leave it in prose.
```
> [!PROMPT]
> Refactor settle so it is idempotent on the event id.
> [!RESPONSE claude-opus-4-8]
> Check the ledger first, return the existing receipt on a hit, post only when the id is unseen.
> A response carries rich markdown, including its own code:
>
> ```typescript
> if (existing) return existing; // idempotent on the event id
> ```
```
A prompt directly followed by a response renders as one framed turn split by a single rule. Either can
stand alone. The prompt reads as mono input; the response is the reading face and holds prose, code,
and lists. The model id on `[!RESPONSE <model>]` is optional and must be a plain slug with no brackets
(write `claude-opus-4-8`, not a bracketed context suffix), since the `]` closes the marker. The role
words (`prompt`, `response`) are single-sourced at `site/src/lib/memo-devices.json` (`exchange`), read
by both the render plugin and the audio projector.
Audio: a transcript is a slog read verbatim, so each turn becomes a cue (a lone prompt speaks "A prompt
follows in the memo.", a lone response "A response follows in the memo."; an adjacent pair merges to the
single "A prompt and response follow in the memo."). Put a `<!-- speech: ... -->` line above the prompt
to speak a one-line summary of the whole exchange instead, and the following response adds nothing, the
same override the table uses.
## Table (standard GFM)
A GFM pipe table. Use for a genuine comparison or a small matrix the prose cannot carry cleanly; omit
it when the prose already states the same thing.
```
| change | what it bounds |
| --- | --- |
| Full-jitter backoff | when the herd fires |
```
Audio: a listener cannot follow a grid. If the table *is* the argument, put a one-line spoken summary
on the line immediately above it and that sentence is spoken in place of the table:
```
<!-- speech: three changes bounded the storm: jitter, a queue, and a breaker. -->
| change | what it bounds |
```
Without the cue, the projection falls back to "A table follows in the memo, comparing <columns>."
## Code (standard, Shiki-highlighted)
A fenced block with a language. Use for real code the reader should see; do not paste long dumps.
````
```typescript
await enqueue(event, { jitter: true });
```
````
Audio: code cannot be read aloud usefully, so it becomes the single cue "Code sample follows in the
memo." (an adaptation can override the cue per block; see `references/audio.md`).
## Footnote (standard GFM)
An inline marker plus a definition. Use for a genuine aside a reader can skip.
```
The dead-letter path is not a graveyard.[^dlq]
[^dlq]: We replay from it once the breaker closes.
```
Audio: footnotes are dropped entirely (a flat narration has no way to skip and return). So anything a
listener must hear belongs in the body, not a footnote..agents/skills/memo/references/audio.md
# Memo Audio & Listen
Detail for the audio artifacts named in `SKILL.md`'s Artifacts section: `speech.md`,
`adaptations.yml`, `<slug>.mp3`, `.speech-hash`, `.audio.json`.
## Contents
- Adaptations format
- Deriving speech
- Device projection
- Listen
- Per-author voice resolution
- Changing how a memo sounds
- Propagation
- Staleness
## Adaptations format
Two YAML layers, applied in order: the project-level dictionary (`memos/dictionary.yml`, global
spoken forms applied to every memo) and the per-memo adaptations (`<wip>/adaptations.yml`, this
memo's own pronunciations and pacing breaks). Both share one schema:
```yaml
pronunciations: # spoken forms, applied case-insensitively as replaces
Unipaas: you-nee-pass
breaks: # a <break> inserted after the first occurrence of `after`
- { after: "the phrase to pause after", time: 0.7 }
```
All pronunciations (dictionary, then per-memo) apply before any breaks (dictionary, then per-memo).
Pronunciations match case-insensitively, so a single entry catches every casing a term takes in
prose and URLs (`Unipaas`, `unipaas`); break anchors match exactly, and `time` is a bare number in
seconds (`0.7`), normalised to the SSML seconds form when the `<break>` is emitted. A pronunciation find-string or break anchor missing from the text is surfaced as a flag,
never silently dropped.
**Keep a break anchor inside one source line.** The projection preserves the body's hard line wraps,
so an anchor is matched against wrapped text: a phrase that reads as one sentence but straddles a
newline in the `.md` never matches, and comes back as a not-found flag. Anchor the shortest run of
words that sits on a single line, and prefer the end of a paragraph or a standalone line, which is
where a pause usually belongs anyway. Adaptations are recorded here, never hand-typed into `speech.md`: that
separation is what lets pronunciation and pacing tuning survive a later body edit. The markup these
compile into (`<break>`) is the SSML break element, the standard vocabulary an SSML-native engine
reads directly; the `render.py` kokoro backend interprets it as exact silence.
## Deriving speech
`speech.md` is never authored directly. It is a build artifact, fully regenerable from
`body + dictionary + adaptations`, and the body is the single source of truth for the spoken text.
`project.py --wip <dir> --body <body>` produces it: strip the body's markdown to prose (headings,
emphasis, links, inline code markers, raw HTML tags, and YAML frontmatter are removed), project the
house devices (see Device projection below), auto-pace at section breaks, then replay the dictionary
and per-memo adaptations over the projected text. A frontmatter `title` is the exception to the strip:
it is spoken first, so a promoted memo (whose H1 is lifted into frontmatter) opens its audio with the
title, matching the WIP preview whose in-body H1 is read as prose. The result is written to
`<wip>/speech.md`;
`<wip>/.speech-hash` is written alongside it (see Staleness).
For a **published** memo the body is the canonical `site/src/content/memos/<slug>.md` and the
per-memo adaptations are its co-located sidecar `site/src/content/memos/<slug>.audio.yml` (a memo
with no tuning ships none). At ship the skill re-derives speech from those tracked sources
(`uv run tools/memo/project.py --body site/src/content/memos/<slug>.md --adaptations site/src/content/memos/<slug>.audio.yml --dictionary memos/dictionary.yml --out <tmp>`)
and renders the approved mp3 once, locally, to `site/public/memos/<slug>.mp3`, which is committed and
served as a static asset. The audio that ships is the exact take the author approved; it is not
re-rendered elsewhere.
## Device projection
A memo reads with rich devices; the audio is a faithful projection of the prose with a per-device
policy for what the ear cannot follow. Prose narrates verbatim; the structured devices are handled
as follows (all deterministic, in `lib.project_markdown`):
- **Callout** (`> [!WARNING]`): the type is spoken as a lead ("Warning.") and the blockquote
markers are dropped, so the body reads as prose. A plain `>` pull-quote reads as its text.
- **Table**: a reader gets the grid; a listener gets either an author one-liner or a column-naming
cue. Put `<!-- speech: your one sentence -->` on the line immediately before a table whose content
*is* the argument (the count that climbed, the numbers that matter), and that sentence is spoken
in place of the table. Omit it for a reference grid the prose already restates, and the projection
falls back to "A table follows in the memo, comparing <columns>." Header-associated cell reading
is the accessible screen-reader convention but a slog heard straight through, so it is not used.
- **Code** (fenced block): the single cue "Code sample follows in the memo." Code cannot be read
aloud usefully.
- **Exchange** (`> [!PROMPT]` / `> [!RESPONSE <model>]`): a transcript is a slog read verbatim, so
each turn becomes a cue ("A prompt and response follow in the memo."; an adjacent pair merges to
one, a lone turn speaks "A prompt/response follows in the memo."). Put a `<!-- speech: ... -->`
line above the prompt to speak a one-line summary of the whole exchange instead, and the following
response adds nothing, the same override the table uses. The response's model id is never spoken.
- **Footnotes**: the inline reference marker and the definition block are dropped. Footnotes are
skippable secondary content (DAISY 2.02); a flat narration has no toggle to un-skip them, so a
point that must be heard belongs in the body.
**Auto-pacing:** a 0.7s `<break>` is inserted before each section break (h2/h3) so sections do not
run together. Finer pacing is per-memo, via `break:` lines in `adaptations.yml`.
`project.py` also accepts `--dictionary` (default `memos/dictionary.yml`), `--adaptations <path>`
(the per-memo layer; defaults to `<wip>/adaptations.yml` when `--wip` is set, and is pointed at
`<slug>.audio.yml` for a published memo), `--target-min` (default `5.0`), and `--out <path>` (write
the projected speech to `<path>` instead of `<wip>/speech.md`, and skip the `.speech-hash` write;
used by the CI render leg to project without touching the canonical files). It reports the spoken
word count and estimated minutes (~138 wpm), any flags, and, when the estimate exceeds
`--target-min`, the longest paragraphs as cut candidates.
## Listen
Available from DRAFT onward; mandatory in the back-and-forth whenever the author wants to hear the
memo, and after any body or adaptations change. `<body>` is `draft.md` before promote; from the
promoted stage onward it is the canonical `site/src/content/memos/<slug>.md` (`draft.md` is no
longer read for this, it remains only the paper trail).
- Run `uv run tools/memo/project.py --wip <dir> --body <body>` to (re)derive `speech.md` and
`.speech-hash`; surface any flags (missing replace or break targets) to the author before continuing.
- Read `handle` from `<wip>/meta.yaml`. Run
`uv run tools/memo/render.py --wip <dir> --slug <slug> --handle <handle>`
to (re)generate `<slug>.mp3` and `.audio.json`. `render.py` resolves its config defaults (`memo.yml`
for the house voice/model, `authors.yml` for the per-author voice keyed by handle) from the repo
root on its own (`lib.repo_root`), so per-author voice resolves the same from any CWD; pass
`--config` or `--authors` only to point at non-default files. This applies to every render, not only
promote: the drafting listen loop is where per-author voice is heard first.
- Present the mp3 to the user to play. Report the estimated minutes (from `project.py`) alongside
the measured duration `render.py` prints, and show cut candidates when the estimate is over the
~5-minute target.
- Nothing auto-publishes: listen produces local artifacts for the author to react to, no more.
## Per-author voice resolution
Voice is a house default with per-author overrides, keyed by GitHub handle, across two files. The
repo-root `memo.yml` holds only the house defaults; the per-author overrides live in the repo-root
`authors.yml`, the same declarative registry the site reads for bylines:
```yaml
# memo.yml (house defaults only)
defaults:
voice: af_heart
model: kokoro-onnx
```
```yaml
# authors.yml (per-author, keyed by handle; also the byline source)
john-doe-unipaas:
name: John Doe
voice: am_michael
```
`render.py` loads `memo.yml` as the config, merges `authors.yml` in under an `authors` key, then
resolves the voice (`lib.resolve_voice(config, handle)`): if `--handle` is a key in `authors.yml`
and that entry carries a `voice`, use it; otherwise fall back to `defaults.voice`. An unlisted
handle uses the default. An explicit `--voice` flag on the command line always wins over resolution.
`render.py` finds `memo.yml` and `authors.yml` at the repo root on its own (`lib.repo_root`), so the
skill's job is just to pass the right `--handle` (from the memo's `meta.yaml`); `--config` and
`--authors` override those defaults only when pointing at non-default files.
## Changing how a memo sounds
`speech.md` is a build artifact, not a committed source and not an edit surface, so there is nothing
to reconcile: it is always regenerated, never patched. To change the spoken form, change an input:
- a term's pronunciation or a pause: record it in the adaptations file (dictionary for a global
spoken form, `<slug>.audio.yml` / `adaptations.yml` for a per-memo one), then re-derive;
- a whole block that should read differently for the ear, or a spoken-only sentence: edit the body
(a future inline `<!-- speech: ... -->` override, generalised from the table device, is the
in-body escape hatch);
- the written text itself: edit the body.
Hand-editing `speech.md` directly is not a supported workflow; the next derivation overwrites it.
Every real spoken-form edit reduces to a pronunciation, a break, or a device projection, so the
structured inputs are sufficient.
## Propagation
Body -> speech -> audio, one direction; nothing propagates upward.
- Change the body: re-run `project.py` (re-derive speech) and `render.py` (regenerate audio). A
now-missing adaptation (its find-string or break-anchor no longer in the body) is surfaced as a
flag, never dropped.
- Change how a line sounds: record it as a replace or break in `adaptations.yml`, then re-derive.
Never hand-edit `speech.md` for this; see Changing how a memo sounds above.
- Sounding wrong can send the author back to edit the body, but that is a human decision: nothing
in the tooling pushes a speech- or audio-side change back into the body.
## Staleness
`.speech-hash` is `lib.speech_digest(body, dictionary, adaptations)`: the sha256 of the body file,
the global `dictionary.yml`, and the per-memo adaptations, newline-joined (`project.py` writes it in
`--wip` mode). It moves whenever any projection input moves, so an adaptations- or dictionary-only
edit is detected, not just a body edit.
The hash inside `.audio.json` is `lib.audio_digest(speech_bytes, voice, model)`, the sha256 of
`speech.md`'s raw bytes, the voice name, and the model identifier, each separated by a literal
newline. That one function is the single source of the formula: `render.py` writes the record
(`{"hash": ..., "durationSeconds": ...}`) beside the rendered mp3 (`<wip>/.audio.json` in WIP mode,
or wherever `--provenance-out` points at ship), and `render.py --hash-only` resolves the voice and
model and prints just the digest without rendering, for a cheap staleness check. Because the digest
keys on the speech text (not the audio bytes), it is deterministic and platform-independent; the
record's `durationSeconds` is the measured duration of the rendered mp3, the total time the reader
shows.
The hash changes whenever the speech text, voice, or model does. On any listen, if it disagrees
with its inputs, regenerate the downstream artifact. At ship the record is frozen as provenance
beside the canonical source (`site/src/content/memos/<slug>.audio.json`), and CI
(`verify_audio.py`) recomputes the hash end-to-end from the committed body and fails on a mismatch,
so a published mp3 cannot drift from its text. See `references/shipping.md`..agents/skills/memo/references/shipping.md
# Memo Corroborate, Promote, and Ship
Detail for the draft-and-later actions named in `SKILL.md`'s Workflow States (the draft and promoted stages).
## Contents
- Corroborate
- Promote
- Ship gate
- Published audio artifact
## Corroborate
A DRAFT-loop action, available from DRAFT onward alongside revise draft and integrate research.
Read `draft.md` and `research.md` in full. Check load-bearing claims and phrasing against named
external sources: a number, a quote, a named work, anything the draft states as settled fact.
Cross-reference against `research.md`'s Observations and Sources & Links. Surface any draft passage
that reads as sourced but carries no inline link, and any research observation whose finding
appears in the draft without its source attached. Guidance, not a hard block: show the list, then
prompt "Link inline via Revise Draft, mark as direct experience, or proceed"; the author decides.
## Promote
DRAFT -> the canonical Astro file. Copy this checklist into your reply and check off each item as
you complete it; the detail for each is below:
```
Promote:
- [ ] Title check: H1 present and not the `Untitled memo` placeholder
- [ ] Content check: title and body both present
- [ ] Description: author's one-liner, or drawn from the body's opening
- [ ] Slug: generated from the final title via slug.py
- [ ] Author: handle read from the memo's meta.yaml
- [ ] Author registry: authors.yml entry exists, or captured now for a first-time author
- [ ] Author photo: headshot, GitHub avatar, or skipped
- [ ] Write: site/src/content/memos/<slug>.md created with frontmatter and body
- [ ] Audio sidecar: adaptations.yml copied to <slug>.audio.yml if present
```
- **Title check.** Read the H1 in `draft.md`. If missing or equal to the placeholder `Untitled
memo`, show the draft, prompt "What's your memo title?", the user provides one, update
`draft.md`'s H1, continue. Otherwise continue.
- **Content check.** Confirm both title and body are present. If only a title exists, offer to add
content now, before promoting.
- **Description.** Prompt "What's the one-line description?" If the author provides one, use it.
If they decline, draw it from the draft's opening (the first sentence of the body).
- **Slug.** Generate the published URL slug from the (possibly just-updated) title by running
`uv run tools/memo/slug.py "<title>"` (the shared, tested `lib.slugify`; see `SKILL.md`'s
Implementation Notes). Never hand-roll it. This slug is the memo's public identity
(`site/src/content/memos/<slug>.md`, the `/memos/<slug>` route, `site/public/memos/<slug>.mp3`) and
can differ from the WIP folder's slug, which was derived from the idea at create (the 4-char id, not
that slug, is the WIP key). Show the author the slug being published under, so the shift from the
WIP label is visible.
- **Author.** Read the `handle` field from the memo's own `meta.yaml` (the GitHub handle captured
at create; see `references/lifecycle.md`). This handle is the frontmatter `author` value: the memo
schema declares `author: reference('authors')`, so the value must be a key in the repo-root
`authors.yml`, where the human byline lives (`meta.yaml`'s `author` field is that display name).
`memo.yml` holds only voice/model config (see `references/audio.md`).
- **Author registry.** If the handle is already a key in `authors.yml` and its entry has a `photo`,
continue. If the entry exists but has no `photo`, gently re-offer one (a single skippable line): a
byline photo is expected, never required. If the handle is not a key (a first-time author's first
promote), capture a complete entry now: take `name` from `meta.yaml`'s `author`, prompt for `role`
and narration `voice` (offer the house default from `memo.yml`), optionally `bio` and `links`, and
offer a byline photo (below), then append the entry to `authors.yml` keyed by the handle. A
first-time author's drafting previews rendered in the house voice, because their entry did not yet
exist; once capture sets their voice, prompt a final listen at the promoted route so the take they
approve is the take that ships.
- **Author photo.** Author-level identity, captured once and reused across the author's memos. Every
option that yields a photo yields a committed file under `site/src/assets/authors/`, so `photo` is
always a filename and never a URL: the site is static and nothing it builds should depend on a third
party being reachable. Offer one enumerated choice (rendered as a picker where the harness supports
it):
- *Supply a headshot:* the author gives a path to a real image on their machine; copy it to
`site/src/assets/authors/<handle>.<ext>` (extension from the source) and set `photo` to that
filename. The file is committed with the memo. Only record what the author supplies; never
generate or edit a face.
- *Use my GitHub avatar:* fetch `https://github.com/<handle>.png` once (the handle from
`meta.yaml`), write it to `site/src/assets/authors/<handle>.<ext>`, and set `photo` to that
filename. Two details: follow the redirect, since that URL always redirects to
`avatars.githubusercontent.com`, and take `<ext>` from the response's `content-type` rather than
from the URL, which says `.png` while commonly serving JPEG. On a failed fetch, say so and fall
back to the skip option; a photo never blocks a promote.
- *Skip for now:* leave `photo` unset; the byline renders the silhouette. It can be added later by
editing `authors.yml` and dropping a file in `site/src/assets/authors/`.
- **Write.** Create `site/src/content/memos/<slug>.md` with frontmatter `title` (the H1),
`description`, `publishDate` (today, ISO `YYYY-MM-DD`), `author` (the `handle` from the memo's
`meta.yaml`, an `authors.yml` key), `draft: false`, followed by the draft body below the H1,
verbatim. Quote frontmatter string values.
- **Audio sidecar.** If the WIP folder has an `adaptations.yml`, copy it to
`site/src/content/memos/<slug>.audio.yml`, the tracked, slug-keyed sidecar that pairs with the
memo (see `references/audio.md`). A memo with no adaptations ships no sidecar.
- Show: "Promoted to site/src/content/memos/<slug>.md"
From promote onward, the site file `site/src/content/memos/<slug>.md` is canonical; the WIP folder
(`memos/wip/<id>-<slug>/`) is the paper trail, and promote leaves it in place (ship retires it to
`memos/archived/`, see the Ship gate below). Nothing is ever deleted. This
also means the body source for `listen` and for the ship-gate freshness check switches: both now
run `uv run tools/memo/project.py --body site/src/content/memos/<slug>.md` (not `draft.md`). The memo now renders at
the existing bare route (`/memos/<slug>`) under `npm run dev`, for a final look and listen. Promote
does NOT publish: the memo reaches readers when the PR merges to `main` and the deploy runs. The
surfaces it lands on (the `/memos` index, the reader's audio player, the nav entry) are already live,
so a promoted memo joins them with no further work.
## Ship gate
Once a memo is promoted, `ship` renders the published audio artifact and writes its provenance record
(see Published audio artifact below), runs the readiness checks, and retires the WIP folder. Opening a
PR, merging, and deploying stay manual for now; the audio provenance is enforced on the PR by CI
(`verify_audio.py`). Checks 1-4 run in order, each surfacing issues with an inline fix path; none
hard-blocks locally, the author can proceed. Copy this checklist into your reply and check off each
item as you complete it; the detail for each is below:
```
Ship gate:
- [ ] 1. Title, description, and body present
- [ ] 2. Every markdown link HEAD-checked (GET fallback); failures surfaced, never auto-rewritten
- [ ] 3. Citation coverage vs research Observations and Sources & Links
- [ ] 4. Voice self-check vs the spine and the memo register
- [ ] 5. Audio freshness: re-render mp3 from canonical body, write provenance, run verify_audio
- [ ] 6. Retire the WIP folder to memos/archived/
```
1. Title and description present; body present.
2. Every markdown link HEAD-checked (GET fallback); non-2xx/3xx status, timeouts, and DNS failures
surfaced with the label and URL. Never auto-rewrite a URL.
3. Citation coverage: draft passages that read as sourced but carry no inline link, cross-
referenced against `research.md`'s Observations and Sources & Links (the same check corroborate
runs during drafting, re-run here against the promoted file).
4. Voice self-check against the spine (`.agents/rules/voice.md`) and the memo register
(`.agents/rules/voice.memo.md`). Guidance, not a hard block.
5. Audio freshness: ship re-renders the mp3 from the canonical `site/src/content/memos/<slug>.md`
(plus its `<slug>.audio.yml` sidecar and the dictionary), so the shipped audio matches the current
text by construction, and writes the provenance `.audio.json` record beside the source. Ship then runs
`verify_audio.py` (the same check CI enforces on the PR) to confirm the committed mp3's provenance
matches. Length is guidance, not a gate: the listen loop's `project.py` reports the estimated
minutes against the ~5-min target and surfaces the longest paragraphs as cut candidates when over;
the author decides, and there is no hard duration cap.
6. Retire the WIP folder: run the Archive procedure in `references/lifecycle.md`, moving
`memos/wip/<id>-<slug>/` to `memos/archived/<id>-<slug>/`. This is the only write among the gate
steps, and it runs last, after the checks have had their say. Ship is re-runnable (a later edit to
the canonical body means shipping again), so the move is idempotent: a folder already under
`archived/` is left where it is and worked from there, never duplicated back into `wip/`.
Ship is what ends the memo's WIP life, not promote. Promote copies the body into the collection and
leaves the folder in place on purpose, because the author is still doing a final look and listen at
the promoted route. Only once ship has rendered the published take does the folder stop being work in
progress, which is why nothing before this step moves it.
## Published audio artifact
The published mp3 is the exact take the author approved, not a re-render. At ship, the skill derives
speech from the canonical body plus `<slug>.audio.yml`, renders once (locally today, via the same
`render.py` the listen loop uses) to `site/public/memos/<slug>.mp3`, and writes the provenance
record (hash plus measured duration) to `site/src/content/memos/<slug>.audio.json` (via `render.py
--provenance-out`), a tracked-but-not-served file (Astro copies only `public/` and rendered routes
to `dist`). Both the mp3 and the provenance go in the PR. Astro copies `site/public/` into `dist`,
so the committed mp3 ships as a static asset served at `/memos/<slug>.mp3`; the deploy needs no
audio step. Rendering the artifact once and shipping it (rather than re-rendering at deploy) is
what guarantees readers hear what the author signed off on, and it stays correct if the house
engine is later swapped for a non-deterministic one.
CI enforces the match: `verify_audio.py` recomputes each published memo's hash from the committed
body, voice, and model, and fails if it does not match the committed `audio.json` provenance
record, so a body edited without a re-render cannot ship audio that narrates the old text. The
check never renders (the hash keys on the speech text, so it is deterministic and
platform-independent). A memo with neither an mp3 nor a provenance record has no audio and is
skipped; one without the other fails.
When the author set grows beyond CLI engineers, a shared hosted renderer replaces the per-author
local render (one `kokoro-http` backend entry); see `references/audio.md`..agents/skills/memo/references/architecture.md
# How this skill is built: the script/prose boundary
This skill is agent-first and mostly prose: an agent reads it and drives the memo lifecycle in
conversation. A few operations are deterministic scripts the agent calls. This note states the rule
that decides which is which, so the boundary reads as a deliberate design choice.
## Contents
- The bar
- What that yields here
- Why the boundary matters
## The bar
> A memo operation earns code only if it must be identical every time and the agent cannot hand-do it
> reliably (binary, crypto, exact rendering), or it is a verification gate the agent runs and reacts
> to. Everything that must be smart, and the control flow itself, stays agent prose. Scripts are gates
> and helpers the agent calls, never the driver.
This is the degrees-of-freedom principle applied to one skill: high freedom (prose) where many paths
lead to a good memo, low freedom (a fixed script) on the narrow ledges where one wrong step is a
silent defect. The shape is a sandwich: deterministic layers around the judgment, not instead of it.
## What that yields here
| Operation | Script or prose | Why |
|---|---|---|
| Derive speech, render the mp3 | script (`project.py`, `render.py`, `lib.py`) | deterministic and token-expensive; token-by-token narration would be slow and drift |
| Audio provenance | script gate (`verify_audio.py`, run locally and in CI) | a sha256 the agent cannot compute by eye; a published mp3 must match its text |
| Published slug | script (`slug.py`, `lib.slugify`) | the memo's permanent URL identity; one tested computation so file, route, and mp3 never disagree |
| Device vocabulary (callout and exchange folds) | data single-source (`memo-devices.json`) | read by both the site renderer and the audio projector, so a device always looks and sounds the same |
| State detect, scaffold a WIP, assemble frontmatter, archive | prose | the agent does this reliably; a script would earn nothing |
| Research, draft, revise, integrate, corroborate, voice, length | prose | judgment; scripting these would be scripting taste |
The tests beside the scripts (`test_lib.py` and the CLI tests) are part of the point: the
deterministic layer is small enough to pin down, so it is pinned down.
## Why the boundary matters
The output is agent-leveraged writing about moving money, so the parts a reader must be able to trust,
that the audio narrates the published text, that a URL is stable, are the parts held by code and a
gate. The writing itself, where the value is, stays a conversation..agents/skills/memo/references/research.yaml
rules:
- Every claim or finding drawn from an external source must be traceable to that source.
- Do not fabricate observations, sources, or findings. Include only what the user provides, what verified research surfaces, or what direct experience contributes.
- When filling or revising a section, first read the sections above it. Later sections build on earlier ones: Questions sharpen the Rough Idea, Observations ground the Questions, Patterns draw on Observations, Refined Direction reflects what shifted across the whole entry.
sections:
- heading: Rough Idea
description: |
Seed of the entry: what you want to explore and why it's worth it. Anchors direction for everything downstream.
instructions:
- Expand the user's input only enough to make the direction legible.
- Do not commit to a thesis; the thesis emerges later, from observations.
- If the input already reads as a clear direction, leave it as-is.
- heading: Questions
description: |
Core inquiries driving the exploration: what you need to probe or resolve to turn the rough idea into a claim. Expected to evolve as patterns emerge.
instructions:
- Phrase as actual questions, not topic labels.
- New questions can appear on any revise as the direction sharpens; existing ones stay.
- heading: Sources & Links
description: |
References gathered during exploration: articles, docs, conversations, prior work.
instructions:
- One reference per line, with a title and a link where one exists.
- This section holds the reference itself, not the findings or claims drawn from it.
- Prefer current sources; older ones earn a place when they are historically meaningful (origin of a concept, a documented moment).
- heading: Observations
description: |
Concrete material from sources or direct experience: scenes, quotes, claims, findings, moments. The specific grounding the thesis will rest on.
instructions:
- One observation per bullet.
- Quote or paraphrase tightly; do not summarise or interpret.
- heading: Patterns & Emerging Thesis
description: |
Recurring threads, tensions, and the shape of an argument forming. Where raw material turns into a point of view.
instructions:
- Write in prose, not bullets.
- A pattern should be one sentence you could argue for.
- Early entries are allowed to be wrong; revise freely as the thesis sharpens.
- heading: Refined Direction
description: |
The sharper version of what the entry is actually about, after research caught up with the rough idea. Captures what shifted, what strengthened, what collapsed.
instructions:
- Keep it short: a paragraph restating direction, not a rewrite of the whole research..agents/rules/voice.md
---
paths:
- site/src/pages/**
- site/src/content/memos/**
- memos/wip/**
---
# Unipaas Engineering site voice
How the Unipaas Engineering site writes: the shared spine every page and memo edits content against.
It stays true to the Unipaas brand: warm, confident, clear over clever, and human about a domain
that moves real money. Two registers layer on this spine, each in its own file. The house register
(`voice.house.md`) carries the standing pages (the home, principles, hiring), the org speaking
in its own institutional voice. The memo register (`voice.memo.md`) carries the memos, a named
engineer writing to peers, deeper and more specific.
## The spine (everywhere)
Confident, specific, and direct. Plain words, with the receipts. Opinionated without being harsh: it
takes a position and is generous about it. Clear over clever, never hyped. Warmth and sharpness
coexist: it still says exactly what it thinks.
- **Ground every claim.** Name the specific thing: the system, the commit, the number, the run that
failed. If a sentence could apply to any company, cut it. Authority is earned through specificity,
not credentials or adjectives.
- **Take a position.** No hedging, no both-sides. Land a take, a claim or a reframing, not a to-do.
An open question is fine only when it is a genuine next problem.
- **Flips earn their contrast.** The house move is "X, not Y", but the "not Y" must name a real
alternative the reader would otherwise assume ("standard kit, not something you once tried";
"harder problems, not bigger teams"). When Y is only the antonym of X it carries no information;
cut it, or name the actual contrast.
- **Substantiate, do not hype.** Benefit and consequence first, then the mechanism. A real outcome
or number carries the punch. No superlatives, no growth-deck words ("growth engine", "unlock",
"leverage" as a noun), no "frontier/agentic" buzzword stacking. The one sanctioned exception is the
established term "agent leverage" / "agent-leveraged", which is load-bearing brand language.
- **Dry wit, sparingly; sincerity by default.** No irony as a pose, no performing.
- **Critique work and patterns, never people.** Keep the position, drop the dunking.
- **British English** (colour, optimise, behaviour, centre). Write the name as **Unipaas** in prose
(capital U, rest lowercase); the lowercase wordmark is the logo only. Titles and headings in
sentence case.
- **No typographic tells.** Straight quotes, not curly. `->`, not arrows. No em-dashes: rewrite with
a comma, colon, period, or parentheses, and never paper one over with `--`. En-dashes, diacritics in
names and loanwords (café, résumé), and accurate technical notation (×, ≤, µ) are fine: the rule
bans machine-set punctuation, the characters that vary across editors and shells, not letters or
meaningful symbols.
- **No label-colon telegraphs** in prose ("Argument:", "Key insight:").
- **Emphasis is rationed.** Reserve bold for the load-bearing claim, never to decorate. Use italic
sparingly and for a different job: a term named as an object, or a title, not a second tier of
emphasis competing with bold. Narration strips both to plain text, so a sentence must carry its
stress in the words, not the markup.
- **Word repetition.** Avoid repeating the same word or phrase across nearby sentences unless the
repetition is thematic. Recurring motifs and word habits are a deliberate technique: the opening
image returning at the end, a term doing extra work because it earned it. Accidental repetition is
different; it flattens the prose and signals the writer ran out of register. If a word appears three
times in a paragraph and the third use is not carrying a callback, change it.
- **The wince test.** If a sentence makes you cringe or sounds pleased with itself, cut it.
## Do and don't
- Hype cadence. Don't: "Dispatches from the edge of agentic engineering, where a wrong move moves
real money." Do: "When an agent touches our payments code, a mistake moves real money, so we show
the work: the commit, the diff, the run that failed."
- Adjective triplets. Don't: "Battle-tested engineering, AI-accelerated, enterprise-grade." Do: "We
build payments that halt and page the moment correctness is in doubt."
- Empty opposites. Don't: "We build payments that fail loudly, not quietly." Do: "We build payments
that fail loudly, not silently at reconciliation." If the "not Y" is just the antonym of X, drop it.
- Label-colon telegraphs. Don't: "Pass between stages: the recruiter calls you." Do: "When you pass
a stage, the recruiter calls you."
- Dunking. Don't: "Code-typers optimise for LOC, not judgment." Do: "We hire for judgment, not
lines of code."
- Em-dashes. Don't join two clauses with an em-dash. Do: "It is not accidental; it is design."
## What this voice is not
- Not marketing: no funnel language, no superlatives, no selling a future.
- Not a literary essay: no ring composition, no ironic sign-offs, no reference-dropping for colour.
References appear only when load-bearing, linked inline.
- Not harsh, not snarky, not pleased with itself.
- Not casual: warm, not chatty. No "Hey!"..agents/rules/voice.memo.md
---
paths:
- site/src/content/memos/**
- memos/wip/**
---
# Memo register: the memos
Deltas on the shared spine (`voice.md`) for the memos: a named engineer writing up real work for
peers and candidates. The byline names who is accountable for the memo; the prose is team "we".
- Default to "we": the work, the decisions, and the judgment belong to the team ("we chose", "we
got it wrong"). The byline, not the pronoun, carries the individual.
- Reserve "I" for a judgment the author is putting their own name behind, and use it rarely. Most
memos never need it.
- Written for peers: assumes the reader's competence; technically deep.
- Begin from a concrete moment; no throat-clearing. Vary sentence length; short lines for the turn.
- Lead with the usable read: what changed, why it matters to how we build, where judgment now moves.
- Show the work, because the work is the argument. Pure observation is allowed when it earns its
place.
## Do and don't (memos)
- Anonymous belief. Don't: "Unipaas Engineering believes in ownership." Do: "[Name] on why we put
the author of the code on the pager."tools/memo/lib.py
"""Pure helpers for the memo toolchain.
Mostly the narration pipeline (markdown-to-speech projection, adaptations, provenance digests), plus
the identity helpers `slugify` (the slug CLI's logic) and `frontmatter`/`repo_root` used across it.
Standard library at import time (one helper, `frontmatter`, lazily imports PyYAML), so importing this
module needs no kokoro-onnx or model download and the tests stay fast. project.py, render.py,
slug.py, and verify_audio.py import these; test_lib.py covers them.
"""
from __future__ import annotations
import functools
import hashlib
import json
import pathlib
import re
import unicodedata
CODE_CUE = "Code sample follows in the memo."
PROMPT_CUE = "A prompt follows in the memo."
RESPONSE_CUE = "A response follows in the memo."
EXCHANGE_CUE = "A prompt and response follow in the memo."
@functools.lru_cache(maxsize=1)
def _devices() -> dict:
"""The shared device vocabulary (site/src/lib/memo-devices.json) the site's mdast plugin also
reads, so a device narrates and renders from one source and the two cannot drift."""
path = repo_root() / "site" / "src" / "lib" / "memo-devices.json"
return json.loads(path.read_text(encoding="utf-8"))
def _callout_types() -> dict[str, str]:
"""GitHub-alert types folded onto the three built callout states (IMPORTANT -> note,
CAUTION -> warning)."""
return _devices()["callouts"]
def _exchange_types() -> dict[str, str]:
"""The prompt/response transcript roles."""
return _devices()["exchange"]
def _callout_lead(match: re.Match) -> str:
folded = _callout_types().get(match.group(1).lower())
return f"{folded.capitalize()}." if folded else match.group(0)
def _table_cells(line: str) -> list[str]:
return [c.strip() for c in line.strip().strip("|").split("|")]
def _join_headers(headers: list[str]) -> str:
hs = [h for h in headers if h]
if not hs:
return "the rows"
if len(hs) == 1:
return hs[0]
if len(hs) == 2:
return f"{hs[0]} and {hs[1]}"
return ", ".join(hs[:-1]) + f", and {hs[-1]}"
_TABLE_SEP = re.compile(r"^\s*\|?[\s:|-]+\|?\s*$")
_SPEECH_COMMENT = re.compile(r"<!--\s*speech:\s*(.*?)\s*-->\s*$", re.DOTALL)
_EXCHANGE_MARKER = re.compile(r"^[ \t]*>[ \t]*\[!(\w+)\b", re.IGNORECASE)
_BLOCKQUOTE_LINE = re.compile(r"^[ \t]*>")
def _project_exchanges(text: str) -> str:
"""Project a prompt/response transcript to a spoken form. The exchange blockquotes carry rich
content (a response can hold code and lists) that is a slog read verbatim, so each turn collapses
to a cue, the same treatment code and tables get. An adjacent prompt+response pair merges to one
cue. An author `<!-- speech: ... -->` on the line above a turn replaces its cue with a summary; a
summary above the prompt covers the whole exchange, so the following response adds nothing.
"""
roles = _exchange_types()
lines = text.split("\n")
out: list[str] = []
i = 0
covered_by_prompt = False # the preceding prompt carried a summary that covers this response
while i < len(lines):
m = _EXCHANGE_MARKER.match(lines[i])
role = roles.get(m.group(1).lower()) if m else None
if not role:
if lines[i].strip():
covered_by_prompt = False
out.append(lines[i])
i += 1
continue
# Consume the whole blockquote (its consecutive `>` lines).
j = i + 1
while j < len(lines) and _BLOCKQUOTE_LINE.match(lines[j]):
j += 1
i = j
# An author summary on the immediately preceding non-blank line wins (as tables do).
override = None
k = len(out) - 1
while k >= 0 and out[k].strip() == "":
k -= 1
if k >= 0:
sm = _SPEECH_COMMENT.match(out[k].strip())
if sm:
override = sm.group(1).strip()
del out[k:]
if override is not None:
out.append(override)
covered_by_prompt = role == "prompt"
elif role == "response" and covered_by_prompt:
covered_by_prompt = False # the prompt's summary already spoke for this turn
elif role == "response":
# Merge with an immediately preceding default prompt cue into one exchange cue.
p = len(out) - 1
while p >= 0 and out[p].strip() == "":
p -= 1
if p >= 0 and out[p].strip() == PROMPT_CUE:
del out[p:]
out.append(EXCHANGE_CUE)
else:
out.append(RESPONSE_CUE)
else:
out.append(PROMPT_CUE)
covered_by_prompt = False
return "\n".join(out)
def _project_tables(text: str) -> str:
"""Project a GFM table to a spoken form. A reader gets the grid; a listener gets either an
author one-liner (an adjacent `<!-- speech: ... -->`, for a table whose content is the
argument) or a cue naming the columns (for a reference grid the prose already restates).
Header-associated cell reading is right for a screen reader but a slog heard straight through.
"""
lines = text.split("\n")
out: list[str] = []
i = 0
while i < len(lines):
nxt = lines[i + 1] if i + 1 < len(lines) else ""
if "|" in lines[i] and "---" in nxt and _TABLE_SEP.match(nxt):
headers = _table_cells(lines[i])
j = i + 2
while j < len(lines) and "|" in lines[j] and lines[j].strip():
j += 1
# An author one-liner in an adjacent speech comment wins; else a column-naming cue.
spoken = None
k = len(out) - 1
while k >= 0 and out[k].strip() == "":
k -= 1
if k >= 0:
m = _SPEECH_COMMENT.match(out[k].strip())
if m:
spoken = m.group(1).strip()
del out[k:]
out.append(spoken or f"A table follows in the memo, comparing {_join_headers(headers)}.")
i = j
else:
out.append(lines[i])
i += 1
return "\n".join(out)
def _drop_footnotes(text: str) -> str:
"""Drop GFM footnotes: the definition block, including lazy (soft-wrapped) continuation lines,
and the inline reference markers. The markdown renderer folds an unindented line directly after
the definition into the footnote (lazy continuation), so the projection consumes it too; dropping
only the first line would orphan the continuation into the narration while the memo keeps it in
the footnote. A footnote block runs to the next blank line, the next footnote definition, or end
of text. Footnotes are skippable secondary content (DAISY 2.02); a flat narration has no
per-session toggle to un-skip them, so they are omitted rather than read detached."""
text = re.sub(
r"^\[\^[^\]]+\]:[^\n]*(?:\n(?![ \t]*$)(?!\[\^[^\]]+\]:)[^\n]*)*\n?",
"",
text,
flags=re.MULTILINE,
)
return re.sub(r"\[\^[^\]]+\]", "", text)
def project_markdown(body: str, code_cue: str = CODE_CUE) -> str:
"""Turn memo body markdown into plain speech prose.
Strips the body's markdown to prose for TTS, plus the house devices: a fenced code block
becomes a single cue (an adaptation can override the cue per block), and a GitHub-alert callout
speaks its type before its body.
"""
text = body
# Drop YAML frontmatter: it is metadata, never spoken. Real memos always carry it.
text = re.sub(r"\A---\n.*?\n---\n", "", text, flags=re.DOTALL)
# Collapse prompt/response transcript turns to cues before generic blockquote/code handling, so a
# response's own code and lists go with it rather than leaking into the narration.
text = _project_exchanges(text)
text = re.sub(r"```[^\n]*\n.*?\n```", code_cue, text, flags=re.DOTALL)
# GitHub-alert callouts: speak the type as a lead ("Warning."), then drop the blockquote
# markers so the body (and any plain-blockquote pull-quote) reads as prose, not "greater-than".
text = re.sub(r"^[ \t]*>[ \t]*\[!(\w+)\][ \t]*$", _callout_lead, text, flags=re.MULTILINE)
text = re.sub(r"^[ \t]*>[ \t]?", "", text, flags=re.MULTILINE)
text = _drop_footnotes(text)
text = _project_tables(text)
text = re.sub(r"<[^>]+>", "", text)
text = re.sub(r"!?\[([^\]]+)\]\([^)]+\)", r"\1", text)
text = re.sub(r"\*{1,3}([^*]+)\*{1,3}", r"\1", text)
text = re.sub(r"_{1,3}([^_]+)_{1,3}", r"\1", text)
# List markers are not spoken: strip leading unordered (-, *, +) and ordered (1.) markers so the
# item reads as prose, not "dash item". Runs after emphasis so a `*bold*` list item is unwrapped
# first. A `---` divider has no marker-plus-space, so it is untouched.
text = re.sub(r"(?m)^[ \t]*(?:[-*+]|\d+\.)[ \t]+", "", text)
# Auto-pace: a pause before each section break (h2/h3) so sections do not run together in the
# audio. Inserted after the HTML strip above (which would otherwise eat the break tag) and
# before the heading markers are removed.
text = re.sub(r"(?m)^(#{2,3}\s+.*)$", r'<break time="0.7s"/>\n\1', text)
# The h1 title takes no leading break but always a trailing one, so a spoken title never runs
# straight into the first line of the body. (A promoted memo's frontmatter title, prepended in
# project.py rather than present as an h1, gets the same break there.)
text = re.sub(r"(?m)^(#\s+.+)$", r'\1\n<break time="0.7s"/>', text)
text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE)
text = re.sub(r"`([^`]+)`", r"\1", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def _break_ssml_time(value) -> str:
"""Normalise an authored break duration to an SSML time string.
Authored in seconds as a bare number (`0.7`), the documented form. A string is tolerated (a
bare `"0.7"` or a unit-suffixed `"0.7s"`) so an author who reaches for quotes or the old unit
form still renders valid SSML rather than a silent break.
"""
s = str(value).strip()
return s if s.endswith("s") else f"{s}s"
def adaptations_from_config(config: dict) -> tuple[list[tuple[str, str]], list[tuple[str, str]]]:
"""Build (replaces, breaks) from a parsed dictionary/adaptations config.
Schema (authored as YAML; the caller loads it, so this stays stdlib-only and testable):
pronunciations: # spoken forms, applied case-insensitively as replaces
Unipaas: you-nee-pass
breaks: # pacing pauses, one <break> inserted after `after`
- { after: "some phrase", time: 0.7 } # seconds, a bare number
A missing or empty section yields no items. Order: pronunciations as written, then breaks. Each
break's `time` is normalised to the SSML seconds form for the emitted `<break>` tag.
"""
replaces = [(str(k), str(v)) for k, v in (config.get("pronunciations") or {}).items()]
breaks: list[tuple[str, str]] = []
for item in config.get("breaks") or []:
after, time = item.get("after"), item.get("time")
if after and time is not None:
breaks.append((str(after), _break_ssml_time(time)))
return replaces, breaks
def replay_adaptations(
speech: str,
replaces: list[tuple[str, str]],
breaks: list[tuple[str, str]],
flag_finds: set[str] | None = None,
flag_anchors: set[str] | None = None,
) -> tuple[str, list[str]]:
"""Apply replaces, then break insertions, deterministically and in order.
A find-string absent from the text is returned in `flags` (surfaced to the author), never
silently dropped. Break insertion places <break time="Ns"/> after the first occurrence of the
anchor phrase.
`flag_finds` / `flag_anchors` scope which misses are worth flagging: a miss is reported only when
its find-string (respectively anchor) is in the given set. This lets the caller flag per-memo
adaptation misses (a real problem: an adaptation targeting text not in the body) while staying
quiet about global-dictionary terms, which legitimately do not appear in every memo. Left None
(the default), every miss is flagged.
"""
flags: list[str] = []
out = speech
for find, repl in replaces:
# Case-insensitive: a pronunciation entry ("Unipaas") must catch every casing the brand
# takes in prose and URLs ("UNIPaaS", "unipaas"). A lambda replacement avoids re.sub
# interpreting backslashes or group references in the author's replacement text.
if not re.search(re.escape(find), out, flags=re.IGNORECASE):
if flag_finds is None or find in flag_finds:
flags.append(f'replace target not found: "{find}"')
continue
out = re.sub(re.escape(find), lambda _m: repl, out, flags=re.IGNORECASE)
for phrase, dur in breaks:
if phrase not in out:
if flag_anchors is None or phrase in flag_anchors:
flags.append(f'break anchor not found: "{phrase}"')
continue
out = out.replace(phrase, f'{phrase}<break time="{dur}"/>', 1)
return out, flags
def _strip_break_tags(text: str) -> str:
return re.sub(r'<break time="[^"]+"/>', " ", text)
def word_count(text: str) -> int:
"""Spoken word count, ignoring <break> tags."""
return len(re.findall(r"\S+", _strip_break_tags(text)))
def estimate_minutes(text: str, wpm: int = 138) -> float:
"""Estimated spoken minutes from word count (Kokoro ~130-145 wpm; default 138)."""
return word_count(text) / wpm
def split_breaks(text: str) -> list[tuple[str, float]]:
"""Split speech text on <break time="Ns"/> into (segment_text, trailing_silence_seconds).
The final segment has 0.0 trailing silence unless the text ends with a break.
"""
parts = re.split(r'<break time="([0-9.]+)s"/>', text)
segments: list[tuple[str, float]] = []
i = 0
while i < len(parts):
seg = parts[i]
dur = float(parts[i + 1]) if i + 1 < len(parts) else 0.0
segments.append((seg, dur))
i += 2
return segments
def cut_candidates(text: str, top: int = 3) -> list[tuple[int, str]]:
"""Longest paragraphs by word count, as (word_count, preview); shown when over budget."""
paras = [p.strip() for p in re.split(r"\n\s*\n", _strip_break_tags(text)) if p.strip()]
scored = sorted(((len(re.findall(r"\S+", p)), p) for p in paras), reverse=True)
return [(wc, (p[:70] + "...") if len(p) > 70 else p) for wc, p in scored[:top]]
def resolve_voice(config: dict, handle: str | None) -> str:
"""The author's voice override (keyed by GitHub handle) if present, else the default house voice."""
authors = config.get("authors") or {}
entry = authors.get(handle) if handle else None
if isinstance(entry, dict) and entry.get("voice"):
return entry["voice"]
return config["defaults"]["voice"]
def audio_digest(speech_bytes: bytes, voice: str, model: str) -> str:
"""The audio staleness hash: sha256 of the speech bytes, the voice, and the model, newline-joined.
Rendering and the CI cache key both derive from this one formula."""
return hashlib.sha256(
speech_bytes + b"\n" + voice.encode("utf-8") + b"\n" + model.encode("utf-8")
).hexdigest()
def provenance_record(digest: str, duration_seconds: float) -> str:
"""The rendered audio's provenance sidecar (<slug>.audio.json): the staleness hash the CI gate
checks, plus the exact measured duration the reader shows as the total time. One record, so a memo
with audio always carries a correct duration, enforced by the same presence gate as the hash."""
return json.dumps({"hash": digest, "durationSeconds": round(duration_seconds, 3)})
def speech_digest(body_bytes: bytes, dictionary_bytes: bytes, adaptations_bytes: bytes) -> str:
"""Staleness key for the derived speech: sha256 of speech's full input set (the body, the global
dictionary, and the per-memo adaptations), newline-joined. It moves whenever any projection input
moves, so an adaptations- or dictionary-only edit is caught too; the body alone is not the full
input set. Missing dictionary/adaptations contribute empty bytes."""
return hashlib.sha256(
body_bytes + b"\n" + dictionary_bytes + b"\n" + adaptations_bytes
).hexdigest()
def repo_root(start: pathlib.Path | None = None) -> pathlib.Path:
"""The repo root carrying the memo house config (`memo.yml`), found by walking up from this
module. Lets the CLIs resolve their config defaults (memo.yml, authors.yml, memos/dictionary.yml)
from the repo root regardless of the caller's CWD, so per-author voice resolves the same way from
any directory. Falls back to the CWD when no marker is found (a relocated skill), which preserves
the old CWD-relative default behaviour."""
base = (start or pathlib.Path(__file__)).resolve()
for d in base.parents:
if (d / "memo.yml").exists():
return d
return pathlib.Path.cwd()
def slugify(title: str) -> str:
"""The canonical published slug for a memo title: the one computation behind a memo's permanent
identity (the `site/src/content/memos/<slug>.md` filename, the `/memos/<slug>` route, and
`<slug>.mp3`). ASCII-folded (café -> cafe), lowercased, every non-alphanumeric run collapsed to a
single hyphen, ends trimmed. Shared and tested so the slug never drifts on an awkward title
(punctuation, unicode, doubled spaces); `slug.py` exposes it to the authoring flow. Returns "" for
a title with no sluggable characters, which the caller surfaces rather than publishing a blank id."""
folded = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode("ascii")
return re.sub(r"[^a-z0-9]+", "-", folded.lower()).strip("-")
def frontmatter(text: str) -> dict:
"""The document's leading YAML frontmatter as a dict, or {} when there is no `---` block or it is
malformed. The toolchain's one frontmatter parser: `project.py` reads the `title`, `verify_audio.py`
the author handle. PyYAML is imported lazily so importing this module stays dependency-free for the
light paths (e.g. `render.py --hash-only`) that never parse a body."""
if not text.startswith("---\n"):
return {}
end = text.find("\n---", 4)
if end == -1:
return {}
import yaml
try:
front = yaml.safe_load(text[4:end])
except yaml.YAMLError:
return {}
return front if isinstance(front, dict) else {}tools/memo/project.py
# /// script
# requires-python = ">=3.11"
# dependencies = ["pyyaml"]
# ///
"""Derive speech.md from a memo body: project markdown, replay dictionary + adaptations.
Usage:
uv run tools/memo/project.py --wip memos/wip/ab12-slug --body memos/wip/ab12-slug/draft.md
# Published mode (CI): explicit body + sidecar adaptations, no WIP, projection written to --out:
uv run tools/memo/project.py --body site/src/content/memos/slug.md --adaptations site/src/content/memos/slug.audio.yml --out /tmp/slug.speech
"""
from __future__ import annotations
import argparse
import pathlib
import sys
import yaml
import lib
def _load_config(path: pathlib.Path) -> dict:
"""Parse a YAML dictionary/adaptations file; empty ({}) when absent."""
if not path.exists():
return {}
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
def _frontmatter_title(body: str) -> str | None:
"""The YAML frontmatter `title`, if the body opens with a frontmatter block, else None.
A promoted memo carries its title in frontmatter (the site renders it from there), so
project_markdown, which strips frontmatter, drops it from the spoken form. The WIP draft instead
keeps the title as an H1, which is spoken. Projecting the frontmatter title keeps the published
audio in step with the preview the author approved, rather than starting cold at the first line."""
title = lib.frontmatter(body).get("title")
return title if isinstance(title, str) and title.strip() else None
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--wip", type=pathlib.Path, default=None)
ap.add_argument("--body", required=True, type=pathlib.Path)
ap.add_argument("--adaptations", type=pathlib.Path, default=None)
ap.add_argument("--dictionary", type=pathlib.Path, default=None)
ap.add_argument("--target-min", type=float, default=5.0)
ap.add_argument("--out", type=pathlib.Path, default=None)
args = ap.parse_args()
if args.wip is None and args.out is None:
ap.error("need --wip (writes <wip>/speech.md) or --out <path>")
body = args.body.read_text(encoding="utf-8")
projected = lib.project_markdown(body)
# A promoted body carries its title in frontmatter (stripped by projection); speak it so the
# published audio opens with the title, matching the WIP preview whose H1 is spoken. Prepend
# before adaptations so pronunciations/breaks apply to the title too.
title = _frontmatter_title(body)
if title and not projected.startswith(title):
# Trailing break so the spoken title does not run into the body, matching the h1 title break
# project_markdown adds for a WIP draft (lib.project_markdown).
projected = f'{title}\n<break time="0.7s"/>\n\n{projected}'
# Global dictionary first, then this memo's own adaptations (both YAML; see lib schema). The
# dictionary defaults to the repo root (found by lib.repo_root), so it resolves from any CWD.
dictionary_path = args.dictionary or (lib.repo_root() / "memos" / "dictionary.yml")
d_repl, d_brk = lib.adaptations_from_config(_load_config(dictionary_path))
# Per-memo layer: explicit --adaptations wins; else the WIP sidecar when --wip is set; else none.
adapt_path = args.adaptations if args.adaptations is not None else (
args.wip / "adaptations.yml" if args.wip is not None else None
)
a_repl, a_brk = ([], [])
if adapt_path is not None:
a_repl, a_brk = lib.adaptations_from_config(_load_config(adapt_path))
replaces = d_repl + a_repl
breaks = d_brk + a_brk
# Flag only per-memo adaptation misses (an adaptation targeting text not in the body). A global
# dictionary term absent from this memo is expected, so it stays quiet and does not train authors
# to ignore flags.
memo_finds = {find for find, _ in a_repl}
memo_anchors = {anchor for anchor, _ in a_brk}
speech, flags = lib.replay_adaptations(
projected, replaces, breaks, flag_finds=memo_finds, flag_anchors=memo_anchors
)
out_path = args.out if args.out is not None else (args.wip / "speech.md")
out_path.write_text(speech + "\n", encoding="utf-8")
if args.out is None:
# The speech staleness key covers the full projection input set (body + dictionary +
# adaptations), so an adaptations- or dictionary-only edit is detected, not just a body edit.
dict_bytes = dictionary_path.read_bytes() if dictionary_path.exists() else b""
adapt_bytes = adapt_path.read_bytes() if (adapt_path and adapt_path.exists()) else b""
digest = lib.speech_digest(args.body.read_bytes(), dict_bytes, adapt_bytes)
(args.wip / ".speech-hash").write_text(digest, encoding="utf-8")
minutes = lib.estimate_minutes(speech)
print(f"{out_path} written ({lib.word_count(speech)} words, ~{minutes:.1f} min estimated)")
for f in flags:
print(f" flag: {f}")
if minutes > args.target_min:
print(f" over the {args.target_min:.0f}-min target; cut candidates (longest paragraphs):")
for wc, preview in lib.cut_candidates(speech):
print(f" {wc}w {preview}")
return 0
if __name__ == "__main__":
sys.exit(main())tools/memo/render.py
# /// script
# requires-python = ">=3.11"
# dependencies = ["kokoro-onnx", "numpy", "lameenc", "certifi", "pyyaml"]
# ///
"""Render speech.md to an mp3, honouring <break> tags as exact silence.
The TTS engine is chosen by the `model` in memo.yml (per-author override in authors.yml) and
resolved to a backend in BACKENDS; kokoro-onnx (local, offline) is the only one today. The engine
is the sole coupling point: everything upstream (project.py, lib.py, the speech.md artifact) is
engine-agnostic, so adding a backend is one function here, not a change spread across the toolchain.
Usage:
uv run tools/memo/render.py --wip memos/wip/ab12-slug --slug reading-the-rails --handle <gh-handle>
"""
from __future__ import annotations
import argparse
import pathlib
import shutil
import ssl
import sys
import urllib.request
import yaml
import lib
# numpy, lameenc and certifi are render-only (heavy PEP 723 deps): imported inside the functions that
# use them so the light paths (arg/voice resolution and --hash-only) run under a plain interpreter.
CACHE = pathlib.Path.home() / ".cache" / "memo-kokoro"
RELEASE = "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0"
MODEL_URL = f"{RELEASE}/kokoro-v1.0.onnx"
VOICES_URL = f"{RELEASE}/voices-v1.0.bin"
# Published mp3 encode settings in one place. 128 kbps CBR is transparent for single-voice speech at
# kokoro's 24 kHz mono source; kept over ~96 for headroom on a permanent asset. LAME quality: 0 best, 9 fastest.
MP3_BITRATE_KBPS = 128
MP3_LAME_QUALITY = 2
KOKORO_SAMPLE_RATE = 24000 # kokoro-onnx native output rate; the value kokoro.create returns wins
def _asset(url: str) -> pathlib.Path:
import certifi
CACHE.mkdir(parents=True, exist_ok=True)
dest = CACHE / url.rsplit("/", 1)[-1]
if not dest.exists():
print(f"fetching {dest.name} ...")
# Explicit certifi CA bundle: some interpreters (notably python.org's macOS
# installer, pre "Install Certificates.command") ship without a populated
# default trust store, which fails urlopen's TLS handshake against GitHub.
ctx = ssl.create_default_context(cafile=certifi.where())
tmp = dest.with_suffix(dest.suffix + ".part")
with urllib.request.urlopen(url, context=ctx) as resp, tmp.open("wb") as f:
shutil.copyfileobj(resp, f)
tmp.rename(dest)
return dest
def _pcm_to_mp3(samples: "np.ndarray", sample_rate: int) -> bytes:
"""Encode float PCM to mp3 bytes. Internal to the kokoro adapter (kokoro emits raw PCM); a
cloud engine that already returns encoded audio would not use this."""
import lameenc
import numpy as np
pcm16 = np.clip(samples, -1.0, 1.0)
pcm16 = (pcm16 * 32767).astype(np.int16)
enc = lameenc.Encoder()
enc.set_bit_rate(MP3_BITRATE_KBPS)
enc.set_in_sample_rate(sample_rate)
enc.set_channels(1) # mono: a single narrated voice
enc.set_quality(MP3_LAME_QUALITY)
return enc.encode(pcm16.tobytes()) + enc.flush()
def synth_kokoro(speech: str, voice: str) -> tuple[bytes, float]:
"""Local kokoro-onnx backend. Kokoro emits raw PCM and has no SSML, so this adapter honours
<break> tags by splitting the speech and inserting exact silence between segments, then encodes
to mp3 itself. An SSML-native engine (e.g. ElevenLabs) would instead pass the marked-up text
through, get mp3 back, and skip the PCM path entirely."""
import numpy as np
from kokoro_onnx import Kokoro # backend-local: only this backend needs the model runtime
kokoro = Kokoro(str(_asset(MODEL_URL)), str(_asset(VOICES_URL)))
sample_rate = KOKORO_SAMPLE_RATE
chunks: list[np.ndarray] = []
for segment, silence in lib.split_breaks(speech):
seg = segment.strip()
if seg:
samples, sample_rate = kokoro.create(seg, voice=voice, speed=1.0, lang="en-us")
chunks.append(np.asarray(samples, dtype=np.float32))
if silence > 0:
chunks.append(np.zeros(int(silence * sample_rate), dtype=np.float32))
audio = np.concatenate(chunks) if chunks else np.zeros(1, dtype=np.float32)
return _pcm_to_mp3(audio, sample_rate), len(audio) / sample_rate
# TTS backends keyed by the `model` config value. THE PORT: each adapter takes
# (speech-with-<break>-tags, voice) and returns (mp3 bytes, duration in seconds). The adapter owns
# everything engine-specific (SSML vs silence-splitting, PCM vs already-encoded audio); the caller
# only writes the bytes. Adding an engine is one entry here, no change upstream or downstream.
BACKENDS = {"kokoro-onnx": synth_kokoro}
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--wip", type=pathlib.Path, default=None)
ap.add_argument("--slug", default=None)
ap.add_argument("--speech", type=pathlib.Path, default=None)
ap.add_argument("--out", type=pathlib.Path, default=None)
ap.add_argument("--hash-only", action="store_true")
ap.add_argument("--voice", default=None)
ap.add_argument("--model", default=None)
ap.add_argument("--handle", default=None)
ap.add_argument("--config", type=pathlib.Path, default=None)
ap.add_argument("--authors", type=pathlib.Path, default=None)
ap.add_argument("--provenance-out", type=pathlib.Path, default=None)
args = ap.parse_args()
# memo.yml carries the house defaults (voice, model); per-author voice lives in the declarative
# authors.yml (keyed by GitHub handle), the same source the site reads for bylines. Both default
# to the repo root (found by lib.repo_root), so voice resolves the same from any CWD; the flags
# override only to point at non-default files.
config_path = args.config or (lib.repo_root() / "memo.yml")
authors_path = args.authors or (lib.repo_root() / "authors.yml")
config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
if authors_path.exists():
config["authors"] = yaml.safe_load(authors_path.read_text(encoding="utf-8")) or {}
voice = args.voice or lib.resolve_voice(config, args.handle)
model = args.model or (config.get("defaults") or {}).get("model") or "kokoro-onnx"
speech_path = args.speech or (args.wip / "speech.md" if args.wip else None)
if speech_path is None:
ap.error("need --speech <path> or --wip <dir>")
speech_bytes = speech_path.read_bytes()
speech = speech_path.read_text(encoding="utf-8")
digest = lib.audio_digest(speech_bytes, voice, model)
if args.hash_only:
print(digest)
return 0
backend = BACKENDS.get(model)
if backend is None:
print(f"unknown TTS model {model!r}; known: {', '.join(BACKENDS)}", file=sys.stderr)
return 1
dest = args.out or (args.wip / f"{args.slug}.mp3" if args.wip and args.slug else None)
if dest is None:
ap.error("need --out <path> or both --wip and --slug")
dest.parent.mkdir(parents=True, exist_ok=True)
mp3_bytes, duration = backend(speech, voice)
dest.write_bytes(mp3_bytes)
# Persist the rendered audio's provenance record (<slug>.audio.json): the staleness hash the CI
# gate checks, plus the exact measured duration the reader shows as the total time. In WIP mode it
# sits beside the preview (<wip>/.audio.json). At ship, the skill passes --provenance-out to a
# tracked-but-not-served path beside the canonical source; the CI gate recomputes the hash and
# compares it, catching a published mp3 that has drifted from its body/voice/model.
# --provenance-out overrides the WIP default.
provenance_out = args.provenance_out or (args.wip / ".audio.json" if args.wip is not None else None)
if provenance_out is not None:
provenance_out.parent.mkdir(parents=True, exist_ok=True)
provenance_out.write_text(lib.provenance_record(digest, duration), encoding="utf-8")
print(f"{dest} written ({duration / 60:.1f} min measured)")
return 0
if __name__ == "__main__":
sys.exit(main())tools/memo/slug.py
# /// script
# requires-python = ">=3.11"
# ///
"""Print the canonical published slug for a memo title.
The authoring flow (the /memo skill's promote step) runs this instead of hand-rolling the slug, so a
memo's URL identity (the `<slug>.md` filename, the `/memos/<slug>` route, and `<slug>.mp3`) is
computed one way. The slug logic itself is `lib.slugify`.
Usage:
uv run tools/memo/slug.py "First, the receipts" # -> first-the-receipts
"""
from __future__ import annotations
import argparse
import sys
import lib
def main() -> int:
ap = argparse.ArgumentParser(description="Print the canonical published slug for a memo title.")
ap.add_argument("title", help="the memo title, usually the draft H1")
args = ap.parse_args()
slug = lib.slugify(args.title)
if not slug:
ap.error(f"title {args.title!r} has no sluggable characters; give the memo a real title first")
print(slug)
return 0
if __name__ == "__main__":
sys.exit(main())tools/memo/verify_audio.py
# /// script
# requires-python = ">=3.11"
# dependencies = ["pyyaml"]
# ///
"""Verify each published memo's committed audio matches its committed text, voice, and model.
The publish model ships the mp3 the author approved (rendered once, committed). This gate proves the
committed mp3 still corresponds to the committed inputs: it re-derives speech from the canonical body
(plus the dictionary and the per-memo adaptations sidecar) with project.py, recomputes the hash
with render.py --hash-only, and compares it to the hash field in the provenance record written at
ship. It never renders: audio_digest keys on the speech text, voice, and model, so the check is
deterministic and platform independent. A mismatch means the body, voice, or model changed without
a re-render, so the memo would narrate different words than the audio.
Reusing the two scripts (rather than reimplementing the derivation) keeps a single source of truth:
what this verifies is byte-identical to what ship produces.
Opt-in: a memo with neither an mp3 nor a provenance record has no audio and is skipped. A memo with one
but not the other is an inconsistency and fails.
Usage (CI): uv run tools/memo/verify_audio.py
"""
from __future__ import annotations
import argparse
import json
import pathlib
import subprocess
import sys
import tempfile
import lib
HERE = pathlib.Path(__file__).resolve().parent
PROJECT = HERE / "project.py"
RENDER = HERE / "render.py"
def expected_hash(md_path: pathlib.Path, sidecar: pathlib.Path) -> str:
"""Recompute a memo's end-to-end hash via the same scripts ship uses (no render)."""
handle = str(lib.frontmatter(md_path.read_text(encoding="utf-8")).get("author") or "")
with tempfile.TemporaryDirectory() as td:
speech = pathlib.Path(td) / "speech"
proj = [sys.executable, str(PROJECT), "--body", str(md_path), "--out", str(speech)]
if sidecar.exists():
proj += ["--adaptations", str(sidecar)]
r = subprocess.run(proj, capture_output=True, text=True)
if r.returncode != 0:
raise RuntimeError(f"project.py failed for {md_path.name}: {r.stderr.strip()}")
r = subprocess.run(
[sys.executable, str(RENDER), "--speech", str(speech), "--handle", handle, "--hash-only"],
capture_output=True, text=True,
)
if r.returncode != 0:
raise RuntimeError(f"render.py --hash-only failed for {md_path.name}: {r.stderr.strip()}")
return r.stdout.strip()
def main() -> int:
root = lib.repo_root()
ap = argparse.ArgumentParser()
ap.add_argument("--memos", type=pathlib.Path, default=root / "site" / "src" / "content" / "memos")
ap.add_argument("--public", type=pathlib.Path, default=root / "site" / "public" / "memos")
args = ap.parse_args()
failures: list[str] = []
checked = 0
for md_path in sorted(args.memos.glob("*.md")):
if lib.frontmatter(md_path.read_text(encoding="utf-8")).get("draft"):
continue
slug = md_path.stem
mp3 = args.public / f"{slug}.mp3"
prov = args.memos / f"{slug}.audio.json"
if not mp3.exists() and not prov.exists():
continue # opt-in: this memo has no audio
if mp3.exists() != prov.exists():
have, missing = ("mp3", "provenance record") if mp3.exists() else ("provenance record", "mp3")
failures.append(f"{slug}: has {have} but no {missing}")
continue
try:
record = json.loads(prov.read_text(encoding="utf-8"))
except json.JSONDecodeError:
failures.append(f"{slug}: provenance record is not valid JSON")
continue
if not isinstance(record, dict):
failures.append(f"{slug}: provenance record is not a JSON object")
continue
duration = record.get("durationSeconds")
if not isinstance(duration, (int, float)) or duration <= 0:
failures.append(f"{slug}: provenance record has no positive durationSeconds")
continue
expected = expected_hash(md_path, args.memos / f"{slug}.audio.yml")
checked += 1
if expected != record.get("hash"):
failures.append(
f"{slug}: audio is stale (committed mp3 does not match the body/voice/model); re-run ship"
)
if failures:
print("Audio verification failed:", file=sys.stderr)
for f in failures:
print(f" - {f}", file=sys.stderr)
return 1
print(f"Audio verification passed ({checked} memo(s) with audio checked).")
return 0
if __name__ == "__main__":
sys.exit(main())tools/memo/test_lib.py
import lib
def test_project_strips_markdown_and_keeps_prose():
body = "# Title\n\nWe move **real** money and _show_ the [work](https://x.com).\n\nUse `psp` here."
out = lib.project_markdown(body)
assert "Title" in out and "#" not in out
assert "real money" in out and "*" not in out
assert "show" in out and "_" not in out
assert "work" in out and "https://x.com" not in out
assert "psp" in out and "`" not in out
def test_project_replaces_fenced_code_with_cue():
body = "Intro.\n\n```ts\nconst x = 1;\n```\n\nOutro."
out = lib.project_markdown(body)
assert "const x" not in out
assert lib.CODE_CUE in out
assert "Intro." in out and "Outro." in out
def test_project_strips_yaml_frontmatter():
body = "---\ntitle: A Memo\ndraft: true\n---\n\nThe first spoken sentence."
out = lib.project_markdown(body)
assert out.startswith("The first spoken sentence.")
assert "title" not in out and "draft" not in out
def test_project_strips_list_markers():
body = "Lead in:\n\n- first item\n- second item\n\n1. step one\n2. step two"
out = lib.project_markdown(body)
assert "first item" in out and "second item" in out
assert "step one" in out and "step two" in out
assert "- first item" not in out and "1. step one" not in out
def test_project_callout_speaks_type_and_drops_blockquote_markers():
body = "> [!WARNING]\n> A retry loop with no backoff is a load generator.\n> Point it carefully."
out = lib.project_markdown(body)
assert out.startswith("Warning.")
assert ">" not in out and "[!" not in out
assert "A retry loop with no backoff is a load generator." in out
assert "Point it carefully." in out
def test_project_callout_folds_types_like_the_site():
# [!IMPORTANT] renders as a "note" on the site and [!CAUTION] as "warning"; speech must agree.
assert "Note." in lib.project_markdown("> [!IMPORTANT]\n> Body here.")
assert "Important." not in lib.project_markdown("> [!IMPORTANT]\n> Body here.")
assert "Warning." in lib.project_markdown("> [!CAUTION]\n> Body here.")
def test_callout_fold_is_single_sourced_from_the_shared_json():
# The fold the projector speaks must be exactly the shared vocabulary the site renders from,
# so a callout can never narrate one state and render another.
import json
shared = json.loads(
(lib.repo_root() / "site" / "src" / "lib" / "memo-devices.json").read_text(encoding="utf-8")
)["callouts"]
assert lib._callout_types() == shared
def test_project_plain_blockquote_reads_as_prose():
body = "A lead.\n\n> A retry is a small load test you schedule.\n\nA close."
out = lib.project_markdown(body)
assert ">" not in out
assert "A retry is a small load test you schedule." in out
def test_project_table_without_one_liner_becomes_column_cue():
body = (
"Lead.\n\n"
"| change | what it bounds |\n"
"| --- | --- |\n"
"| Full-jitter backoff | when the herd fires |\n\n"
"Close."
)
out = lib.project_markdown(body)
assert "|" not in out and "---" not in out
assert "A table follows in the memo, comparing change and what it bounds." in out
assert "Full-jitter backoff" not in out # the grid stays in the memo
assert "Lead." in out and "Close." in out
def test_project_table_with_speech_comment_uses_the_one_liner():
body = (
"Lead.\n\n"
"<!-- speech: The count climbed: 44, then 71, then 270. -->\n"
"| source | count |\n"
"| --- | --- |\n"
"| llms.txt | 44 |\n"
"| re-enumeration | 270 |\n\n"
"Close."
)
out = lib.project_markdown(body)
assert "The count climbed: 44, then 71, then 270." in out
assert "|" not in out and "<!--" not in out
assert "A table follows in the memo" not in out # the one-liner replaces the cue
def test_project_exchange_pair_becomes_one_cue():
# A prompt directly followed by a response reads as one spoken cue; neither transcript, nor the
# response's model id, leaks into the narration.
body = (
"Lead.\n\n"
"> [!PROMPT]\n> Write the callout plugin.\n\n"
"> [!RESPONSE claude-opus-4-8]\n> Here is the plugin.\n> It handles the marker.\n\n"
"Close."
)
out = lib.project_markdown(body)
assert lib.EXCHANGE_CUE in out
assert lib.PROMPT_CUE not in out and lib.RESPONSE_CUE not in out
assert "Write the callout plugin" not in out and "Here is the plugin" not in out
assert "claude-opus-4-8" not in out
assert ">" not in out and "[!" not in out
assert "Lead." in out and "Close." in out
def test_project_standalone_prompt_and_response_get_their_own_cue():
assert lib.PROMPT_CUE in lib.project_markdown("> [!PROMPT]\n> Ask the thing.")
out = lib.project_markdown("> [!RESPONSE claude-opus-4-8]\n> The answer.")
assert lib.RESPONSE_CUE in out and "The answer" not in out
def test_project_exchange_summary_above_prompt_covers_the_pair():
# One author summary above the prompt speaks for the whole exchange; the response adds no cue.
body = (
"<!-- speech: I asked it to write the plugin; it returned a working draft. -->\n"
"> [!PROMPT]\n> Write the callout plugin.\n\n"
"> [!RESPONSE claude-opus-4-8]\n> Here is the plugin."
)
out = lib.project_markdown(body)
assert "I asked it to write the plugin; it returned a working draft." in out
assert lib.PROMPT_CUE not in out and lib.RESPONSE_CUE not in out and lib.EXCHANGE_CUE not in out
assert "Here is the plugin" not in out and "<!--" not in out
def test_project_exchange_response_code_does_not_leak():
# A response's fenced code is collapsed with the turn, not narrated as a stray code cue.
body = "> [!RESPONSE claude-opus-4-8]\n> Here it is:\n>\n> ```ts\n> const x = 1;\n> ```"
out = lib.project_markdown(body)
assert lib.RESPONSE_CUE in out
assert "const x" not in out and lib.CODE_CUE not in out
def test_exchange_roles_are_single_sourced_from_the_shared_json():
import json
shared = json.loads(
(lib.repo_root() / "site" / "src" / "lib" / "memo-devices.json").read_text(encoding="utf-8")
)["exchange"]
assert lib._exchange_types() == shared
def test_project_auto_paces_at_section_breaks():
body = "# Title\n\nIntro line.\n\n## First section\n\nBody.\n\n### A sub\n\nMore."
out = lib.project_markdown(body)
assert out.count('<break time="0.7s"/>') == 3 # a trailing break after the h1 title, one per h2/h3
# the title never runs straight into the body
assert out.startswith('Title\n<break time="0.7s"/>')
assert "Title" in out and "#" not in out
def test_project_drops_footnote_ref_and_definition():
body = (
"The queue applies backpressure.[^dlq]\n\n"
"[^dlq]: The dead-letter path is not a graveyard. We replay from it once the breaker\n"
" closes, so an outage costs latency, not delivery."
)
out = lib.project_markdown(body)
assert "[^dlq]" not in out and "dead-letter path" not in out
assert "The queue applies backpressure." in out
def test_project_drops_footnote_with_unindented_soft_wrap():
# The renderer folds an unindented continuation into the footnote (lazy continuation), so the
# projection must drop it too rather than orphan it into the narration; a real paragraph after
# the blank line survives.
body = (
"Body sentence.[^1]\n\n"
"[^1]: First line of the note\n"
"second line unindented soft wrap.\n\n"
"A real paragraph after the blank line."
)
out = lib.project_markdown(body)
assert "First line of the note" not in out
assert "second line unindented soft wrap" not in out
assert "A real paragraph after the blank line." in out
assert "Body sentence." in out
def test_adaptations_from_config_reads_pronunciations_and_breaks():
config = {
"pronunciations": {"PSP": "P S P", "78%": "seventy-eight percent"},
"breaks": [{"after": "We move real money.", "time": 2}],
}
replaces, breaks = lib.adaptations_from_config(config)
assert ("PSP", "P S P") in replaces
assert ("78%", "seventy-eight percent") in replaces
assert breaks == [("We move real money.", "2s")]
def test_adaptations_normalises_break_time_to_ssml_seconds():
config = {
"breaks": [
{"after": "a", "time": 0.7}, # documented form: a bare number
{"after": "b", "time": "0.5"}, # tolerated: quoted, no unit
{"after": "c", "time": "1s"}, # tolerated: legacy unit-suffixed string
]
}
_replaces, breaks = lib.adaptations_from_config(config)
assert breaks == [("a", "0.7s"), ("b", "0.5s"), ("c", "1s")]
def test_adaptations_from_config_tolerates_empty_and_missing_sections():
assert lib.adaptations_from_config({}) == ([], [])
assert lib.adaptations_from_config({"pronunciations": None, "breaks": None}) == ([], [])
def test_replay_applies_replaces_then_breaks_in_order():
speech = "We move real money. PSP fees hit 78% of margin."
replaces = [("PSP", "P S P"), ("78%", "seventy-eight percent")]
breaks = [("We move real money.", "2s")]
out, flags = lib.replay_adaptations(speech, replaces, breaks)
assert flags == []
assert "P S P" in out and "78%" not in out
assert 'We move real money.<break time="2s"/>' in out
def test_replay_break_inserts_after_first_occurrence_only():
speech = "Stop. Stop. Stop."
out, flags = lib.replay_adaptations(speech, [], [("Stop.", "1s")])
assert flags == []
assert out == 'Stop.<break time="1s"/> Stop. Stop.'
assert out.count("<break") == 1
def test_replay_flags_missing_targets_never_silently_drops():
out, flags = lib.replay_adaptations("hello world", [("nope", "x")], [("gone", "1s")])
assert out == "hello world"
assert any("replace target not found" in f and "nope" in f for f in flags)
assert any("break anchor not found" in f and "gone" in f for f in flags)
def test_replay_replace_is_case_insensitive():
# A single pronunciation entry must catch every casing the brand takes, in prose and lowercased.
speech = "UNIPaaS, unipaas, and Unipaas are one brand, one entry."
out, flags = lib.replay_adaptations(speech, [("Unipaas", "you-nee-pass")], [])
assert flags == []
assert out == "you-nee-pass, you-nee-pass, and you-nee-pass are one brand, one entry."
def test_estimate_minutes_ignores_break_tags():
text = 'one two three<break time="2s"/> four five six'
assert abs(lib.estimate_minutes(text, wpm=6) - 1.0) < 1e-9
def test_word_count_ignores_break_tags():
assert lib.word_count('one two<break time="2s"/> three') == 3
def test_split_breaks_pairs_segments_with_silence():
text = 'a<break time="2s"/>b<break time="0.5s"/>c'
segs = lib.split_breaks(text)
assert segs == [("a", 2.0), ("b", 0.5), ("c", 0.0)]
def test_cut_candidates_returns_longest_first():
text = "short one.\n\n" + ("word " * 40).strip() + "\n\nmid mid mid."
cands = lib.cut_candidates(text, top=2)
assert cands[0][0] == 40
assert len(cands) == 2
def test_resolve_voice_returns_default_when_handle_none():
cfg = {"defaults": {"voice": "af_heart"}, "authors": {}}
assert lib.resolve_voice(cfg, None) == "af_heart"
def test_resolve_voice_returns_default_when_handle_unlisted():
cfg = {"defaults": {"voice": "af_heart"}, "authors": {"someone": {"voice": "x"}}}
assert lib.resolve_voice(cfg, "nobody") == "af_heart"
def test_resolve_voice_returns_override_for_listed_handle():
cfg = {"defaults": {"voice": "af_heart"}, "authors": {"john-doe-unipaas": {"voice": "am_michael"}}}
assert lib.resolve_voice(cfg, "john-doe-unipaas") == "am_michael"
def test_resolve_voice_falls_back_when_listed_without_voice():
cfg = {"defaults": {"voice": "af_heart"}, "authors": {"john-doe-unipaas": {}}}
assert lib.resolve_voice(cfg, "john-doe-unipaas") == "af_heart"
def test_resolve_voice_handles_missing_authors_key():
cfg = {"defaults": {"voice": "af_heart"}}
assert lib.resolve_voice(cfg, "anyone") == "af_heart"
def test_audio_digest_is_stable_and_matches_formula():
import hashlib
speech = b"We move real money."
expected = hashlib.sha256(speech + b"\n" + b"am_michael" + b"\n" + b"kokoro-onnx").hexdigest()
assert lib.audio_digest(speech, "am_michael", "kokoro-onnx") == expected
# A different voice or model changes the digest.
assert lib.audio_digest(speech, "af_heart", "kokoro-onnx") != expected
def test_speech_digest_covers_body_dictionary_and_adaptations():
import hashlib
b, d, a = b"body", b"dict", b"adapt"
expected = hashlib.sha256(b + b"\n" + d + b"\n" + a).hexdigest()
assert lib.speech_digest(b, d, a) == expected
# A dictionary- or adaptations-only change moves the digest; the body alone is not the key.
assert lib.speech_digest(b, b"dict2", a) != expected
assert lib.speech_digest(b, d, b"adapt2") != expected
def test_repo_root_walks_up_to_the_house_config(tmp_path):
root = tmp_path / "repo"
(root / "a" / "b").mkdir(parents=True)
(root / "memo.yml").write_text("defaults: {}\n", encoding="utf-8")
assert lib.repo_root(start=root / "a" / "b" / "render.py") == root
def test_repo_root_finds_this_repo_from_the_module():
# The real skill lives inside the repo, so repo_root() locates the house config with no args.
assert (lib.repo_root() / "memo.yml").exists()
def test_replay_flags_only_scoped_finds():
# A dictionary term absent from the memo stays quiet; a per-memo adaptation miss is flagged.
_out, flags = lib.replay_adaptations(
"We move money.",
[("Unipaas", "you-nee-pass"), ("PSP", "P S P")],
[],
flag_finds={"PSP"},
)
assert flags == ['replace target not found: "PSP"']
def test_replay_flags_every_miss_by_default():
_out, flags = lib.replay_adaptations("hi", [("X", "y")], [("Z", "0.5s")])
assert flags == ['replace target not found: "X"', 'break anchor not found: "Z"']
def test_provenance_record_carries_hash_and_rounded_duration():
import json
rec = json.loads(lib.provenance_record("abc123", 137.44449))
assert rec == {"hash": "abc123", "durationSeconds": 137.444}
def test_slugify_lowercases_and_hyphenates_words():
assert lib.slugify("The Retry Storm") == "the-retry-storm"
def test_slugify_folds_accents_to_ascii():
assert lib.slugify("Café Résumé") == "cafe-resume"
def test_slugify_collapses_punctuation_and_doubled_spaces():
assert lib.slugify("First, the receipts!") == "first-the-receipts"
assert lib.slugify("agents & money: what broke?") == "agents-money-what-broke"
def test_slugify_keeps_digits_and_trims_ends():
assert lib.slugify(" 99 problems -- ") == "99-problems"
def test_slugify_returns_empty_for_unsluggable_title():
assert lib.slugify("!!! ??? ...") == ""
assert lib.slugify("") == ""
def test_frontmatter_parses_the_leading_block():
md = "---\ntitle: A Memo\nauthor: john-doe-unipaas\ndraft: false\n---\n\nBody."
assert lib.frontmatter(md) == {"title": "A Memo", "author": "john-doe-unipaas", "draft": False}
def test_frontmatter_is_empty_when_absent_unterminated_malformed_or_not_a_map():
assert lib.frontmatter("No frontmatter here.") == {}
assert lib.frontmatter("---\ntitle: A Memo\n") == {} # no closing ---
assert lib.frontmatter("---\nkey: [unclosed\n---\n") == {} # invalid YAML
assert lib.frontmatter("---\njust a scalar\n---\n") == {} # parses, but not a mappingsite/src/lib/memo-devices.json
{
"callouts": {
"note": "note",
"tip": "tip",
"important": "note",
"warning": "warning",
"caution": "warning"
},
"exchange": {
"prompt": "prompt",
"response": "response"
}
}site/src/lib/memo-callouts.mjs
// Sätteri mdast plugin: map GitHub-style alert blockquotes to the site's in-body memo devices, so an
// author writes portable markdown (`> [!WARNING]`, which GitHub itself renders as an alert) and the
// reader styles it through the built CSS. Two device families share the marker syntax and this hook:
// callouts `> [!NOTE|TIP|WARNING|...]` -> <aside class="callout TYPE"> (a single-voice aside)
// exchange `> [!PROMPT]` / `> [!RESPONSE claude-opus-4-8]` -> <aside class="exchange ROLE">
// a prompt/response transcript turn; a response carries the model id as its provenance.
// The blockquote is retagged via hName/hProperties and a label span is prepended; the inner markdown
// renders normally, so a response can hold prose, code, and lists. No third-party dependency.
//
// Text-tier of the memo component vocabulary. Styling is owned by
// site/src/pages/memos/[...slug].astro (its source of truth); this only maps the authoring syntax.
// The device vocabulary is single-sourced in memo-devices.json, also read by the audio projector
// (tools/memo/lib.py) so the rendered label and the spoken form cannot drift. esbuild inlines
// this JSON when it bundles the Astro config that imports this plugin.
import devices from './memo-devices.json' with { type: 'json' };
const CALLOUTS = devices.callouts; // GitHub alert types folded onto the built states (IMPORTANT->note, CAUTION->warning)
const EXCHANGE = devices.exchange;
// The marker is `[!TYPE]` with an optional argument (the response's model id): `[!RESPONSE model]`.
const MARKER = /^\[!(\w+)(?:[ \t]+([^\]\n]+?))?[ \t]*\]\s*\n?/;
const escapeHtml = (s) =>
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
export default function memoCallouts() {
return {
name: 'memo-callouts',
blockquote(node, ctx) {
const para = node.children && node.children[0];
if (!para || para.type !== 'paragraph') return;
const lead = para.children && para.children[0];
if (!lead || lead.type !== 'text') return;
const marker = MARKER.exec(lead.value);
if (!marker) return;
const key = marker[1].toLowerCase();
const arg = marker[2] && marker[2].trim();
const callout = CALLOUTS[key];
const role = EXCHANGE[key];
if (!callout && !role) return;
// Strip the marker from the lead text (nodes are read-only; mutate via the context).
ctx.setProperty(lead, 'value', lead.value.slice(marker[0].length));
if (callout) {
ctx.setProperty(node, 'data', { hName: 'aside', hProperties: { className: ['callout', callout] } });
ctx.prependChild(node, { rawHtml: `<span class="co-label">${callout}</span>` });
return;
}
// Exchange turn. The response's model id (if given) rides the label as a provenance chip.
ctx.setProperty(node, 'data', { hName: 'aside', hProperties: { className: ['exchange', role] } });
const model = arg ? `<span class="ex-model">${escapeHtml(arg)}</span>` : '';
ctx.prependChild(node, { rawHtml: `<span class="ex-label">${role}</span>${model}` });
},
};
}site/src/pages/memos/[...slug].astro
---
import { render, getEntry } from 'astro:content';
import PageLayout from '../../layouts/PageLayout.astro';
import ReadingProgress from '../../components/ReadingProgress.astro';
import TableOfContents from '../../components/TableOfContents.astro';
import { getPublishedMemos, audioFor } from '../../lib/memos';
import { memoCardUrl } from '../../lib/ogCards';
import Byline from '../../components/Byline.astro';
import MemoHeader from '../../components/MemoHeader.astro';
import MemoTransport from '../../components/MemoTransport.astro';
import AudioControls from '../../components/AudioControls.astro';
import Narration from '../../components/Narration.astro';
import HiringClose from '../../components/HiringClose.astro';
export async function getStaticPaths() {
const memos = await getPublishedMemos();
return memos.map((memo) => ({ params: { slug: memo.id }, props: { memo } }));
}
const { memo } = Astro.props;
const { Content, headings } = await render(memo);
// Resolve the author record from the authors collection (authors.yml), keyed by the memo's handle.
const authorEntry = await getEntry(memo.data.author);
if (!authorEntry) {
throw new Error(
`Memo "${memo.id}" references author "${memo.data.author.id}", which is not a key in authors.yml. ` +
`Promote captures new authors automatically, so a memo reaching this was likely hand-edited; ` +
`add the author to authors.yml, keyed by their GitHub handle.`,
);
}
const author = authorEntry.data;
// Narration ships only when the mp3 and its provenance record exist; presence is the source of truth
// (no frontmatter flag to desync), and the record carries the exact duration shown as the total time.
const audio = audioFor(memo.id);
// BlogPosting plus a breadcrumb trail, so a memo is attributed correctly in search and unfurls. The
// home and the standing pages emit their own shapes through the same JsonLd component; @graph carries
// the two types this page needs. Absolutised against Astro.site rather than a literal origin.
const memoUrl = new URL(`/memos/${memo.id}`, Astro.site).href;
const jsonLd = {
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'BlogPosting',
headline: memo.data.title,
description: memo.data.description,
// Full ISO with the zone, not a bare date: a zoneless date is read in the crawler's own
// timezone, which can shift the published day by one. Frontmatter carries a date, so the
// instant is that date's UTC midnight, which is how a date-only value is normalised anyway.
datePublished: memo.data.publishDate.toISOString(),
url: memoUrl,
image: new URL(memoCardUrl(memo.id), Astro.site).href,
author: { '@type': 'Person', name: author.name },
publisher: {
'@type': 'Organization',
name: 'Unipaas Engineering',
url: new URL('/', Astro.site).href,
},
},
{
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: new URL('/', Astro.site).href },
{ '@type': 'ListItem', position: 2, name: 'Memos', item: new URL('/memos', Astro.site).href },
{ '@type': 'ListItem', position: 3, name: memo.data.title, item: memoUrl },
],
},
],
};
---
<PageLayout
title={`${memo.data.title} | unipaas $engineering`}
description={memo.data.description}
eyebrow={memo.ref}
heading={memo.data.title}
field="braid"
seed={memo.id}
ogType="article"
ogImage={memoCardUrl(memo.id)}
ogImageAlt={`${memo.data.title}. ${memo.data.description}`}
jsonLd={jsonLd}
>
<a slot="lead" class="back" href="/memos">memos</a>
<Fragment slot="lede">
<Byline author={author} date={memo.data.publishDate} readingMinutes={memo.readingMinutes} />
{audio && <AudioControls variant="hero" duration={audio.durationSeconds} />}
{audio && <Narration src={audio.src} duration={audio.durationSeconds} title={memo.data.title} author={author.name} />}
</Fragment>
<div data-hero-end aria-hidden="true"></div>
<MemoHeader
title={memo.data.title}
author={author}
date={memo.data.publishDate}
readingMinutes={memo.readingMinutes}
/>
{audio && <MemoTransport duration={audio.durationSeconds} />}
<ReadingProgress />
<TableOfContents headings={headings} />
<div class="memo-body">
<Content />
</div>
<HiringClose />
</PageLayout>
<script>
// Append a hover-revealed "#" link to each heading, reusing the id Sätteri already emits (so it
// matches the TOC rail and can't drift). Progressive enhancement: no JS, no anchors, rail still works.
for (const h of [...document.querySelectorAll<HTMLElement>('.memo-body h2[id], .memo-body h3[id]')]) {
const a = document.createElement('a');
a.className = 'heading-anchor';
a.href = '#' + h.id;
a.textContent = '#';
a.setAttribute('aria-label', `Link to the section "${h.textContent}"`);
h.appendChild(a);
}
</script>
<style>
/* Return to the register, in the dash grammar: the eyebrow leads with "-- ", forward links trail
"->", so a return leads with "<- ". */
.back {
display: inline-block;
margin-bottom: var(--ds-space-xl);
font-family: var(--mono);
font-size: var(--eng-text-xs);
letter-spacing: .04em;
color: var(--text-subtle);
text-decoration: none;
}
.back::before { content: "<- "; }
.back:hover { color: var(--accent); }
.memo-body {
font-family: var(--sans);
font-size: var(--eng-text-xl);
line-height: 1.75;
color: var(--text);
margin-top: var(--ds-space-2xl);
}
.memo-body :global(h2) {
font-size: var(--eng-text-h2);
line-height: 1.2;
letter-spacing: -.01em;
margin: var(--ds-space-3xl) 0 var(--ds-space-md);
}
.memo-body :global(h3) {
font-size: var(--eng-text-h3);
margin: var(--ds-space-2xl) 0 var(--ds-space-sm);
}
/* Heading anchors (appended by the page script): the mono "#" fragment mark. */
.memo-body :global(h2 .heading-anchor),
.memo-body :global(h3 .heading-anchor) {
margin-left: .35em;
font-family: var(--mono);
font-size: .7em;
font-weight: 400;
color: var(--accent);
text-decoration: none;
opacity: 0;
transition: opacity .15s ease;
}
.memo-body :global(h2:hover .heading-anchor),
.memo-body :global(h3:hover .heading-anchor),
.memo-body :global(.heading-anchor:focus-visible) { opacity: 1; }
.memo-body :global(p) { margin-block: var(--ds-space-lg); }
.memo-body :global(strong) { font-weight: 650; }
.memo-body :global(a) {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 2px;
}
.memo-body :global(ul), .memo-body :global(ol) {
margin-block: var(--ds-space-lg);
padding-left: 1.4em;
}
.memo-body :global(li) { margin-block: var(--ds-space-xs); }
.memo-body :global(li)::marker { color: var(--text-subtle); }
.memo-body :global(:not(pre) > code) {
font-family: var(--mono);
font-size: .85em;
background: var(--surface);
border: 1px solid var(--border-subtle);
padding: .1em .35em;
border-radius: var(--ds-radius-sm, 4px);
}
.memo-body :global(pre) {
font-family: var(--mono);
font-size: var(--eng-text-sm);
line-height: 1.6;
border: 1px solid var(--border);
border-radius: var(--ds-radius-md);
padding: var(--ds-space-lg);
overflow-x: auto;
margin-block: var(--ds-space-xl);
}
/* Pull-quote: the author's voice amplified, limited from below by the payment-journey curve in
--accent (the one pink per quote). The curve is a mask filled by --accent, so no brand hex is
hardcoded. */
.memo-body :global(blockquote) {
margin-block: var(--ds-space-2xl);
max-width: 32rem;
font-size: 1.6rem;
line-height: 1.35;
color: var(--text);
}
.memo-body :global(blockquote p) { margin: 0; font-size: inherit; }
.memo-body :global(blockquote p + p) { margin-top: var(--ds-space-md); }
.memo-body :global(blockquote)::after {
content: "";
display: block;
margin-top: var(--ds-space-lg);
height: 34px;
background: var(--accent);
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 220 36'%3E%3Cpath d='M4 24 C 52 24 56 10 106 10 S 172 26 216 14' fill='none' stroke='white' stroke-width='2.5' stroke-linecap='butt' stroke-dasharray='6 18'/%3E%3C/svg%3E") no-repeat left center / contain;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 220 36'%3E%3Cpath d='M4 24 C 52 24 56 10 106 10 S 172 26 216 14' fill='none' stroke='white' stroke-width='2.5' stroke-linecap='butt' stroke-dasharray='6 18'/%3E%3C/svg%3E") no-repeat left center / contain;
}
/* Topic-break divider: a route line (dashed), but short and centred, a pause on the journey rather
than a full-width structural rule. */
.memo-body :global(hr) {
width: 4rem;
height: 1px;
margin: var(--ds-space-3xl) auto;
border: 0;
background: var(--dash-h);
}
/* Callout: the bar and the "-- " label carry palette-as-state (neutral note, semantic warning /
tip); --accent is reserved for true emphasis, so it stays scarce here. */
.memo-body :global(.callout) {
margin-block: var(--ds-space-xl);
padding: var(--ds-space-md) var(--ds-space-lg);
border-left: 2px solid var(--co-bar, var(--border));
background: var(--co-tint, color-mix(in srgb, var(--surface) 45%, transparent));
}
.memo-body :global(.callout p) {
margin: 0;
font-size: var(--eng-text-md);
line-height: 1.6;
color: var(--text-muted);
}
/* the comment-route label; its colour carries the state */
.memo-body :global(.co-label) {
display: block;
margin-bottom: var(--ds-space-xs);
font-family: var(--mono);
font-size: var(--eng-text-2xs);
letter-spacing: .02em;
color: var(--co-label, var(--text-subtle));
}
.memo-body :global(.co-label)::before { content: "-- "; }
/* tint is a low-alpha wash of the status hue (theme-safe on both faces), not the light-only -tint token */
.memo-body :global(.callout.warning) {
--co-bar: var(--ds-status-warning);
--co-label: var(--ds-status-warning);
--co-tint: color-mix(in srgb, var(--ds-status-warning) 12%, transparent);
}
.memo-body :global(.callout.tip) {
--co-bar: var(--ds-status-success);
--co-label: var(--ds-status-success);
--co-tint: color-mix(in srgb, var(--ds-status-success) 12%, transparent);
}
/* Prompt / response transcript (the exchange device): an agent turn as its own receipt, an enclosed
object (solid edge, per the line grammar). The prompt is mono input, the response the reading face
(it carries prose, code, lists). An adjacent prompt+response join into one frame split by a single
rule, so the pair reads as a turn; the model id rides the response label as a provenance chip. */
.memo-body :global(.exchange) {
margin-block: var(--ds-space-xl);
padding: var(--ds-space-md) var(--ds-space-lg);
border: 1px solid var(--border);
border-radius: var(--ds-radius-md);
}
.memo-body :global(.exchange.prompt:has(+ .exchange.response)) {
margin-bottom: 0;
border-bottom: 0;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.memo-body :global(.exchange.prompt + .exchange.response) {
margin-top: 0;
border-top: 1px solid var(--border);
border-top-left-radius: 0;
border-top-right-radius: 0;
}
/* The role label: the mono "-- " comment-route label the callout also uses, with the model chip. */
.memo-body :global(.ex-label) {
font-family: var(--mono);
font-size: var(--eng-text-2xs);
letter-spacing: .02em;
color: var(--text-subtle);
}
.memo-body :global(.ex-label)::before { content: "-- "; }
.memo-body :global(.ex-model) {
margin-left: .6em;
font-family: var(--mono);
font-size: var(--eng-text-2xs);
letter-spacing: .02em;
color: var(--accent);
}
.memo-body :global(.exchange p:first-of-type) { margin-top: var(--ds-space-sm); }
.memo-body :global(.exchange.prompt p) {
font-family: var(--mono);
font-size: var(--eng-text-sm);
line-height: 1.6;
color: var(--text-muted);
}
/* Anchor jumps (from the TOC rail) clear the sticky header. */
.memo-body :global(h2),
.memo-body :global(h3) { scroll-margin-top: 6rem; }
/* Tables: a ledger of like records, so route-line (dashed) row dividers and mono uppercase column
heads. Cells wrap, so the table holds the reading measure without scrolling the page sideways. */
.memo-body :global(table) {
width: 100%;
border-collapse: collapse;
margin-block: var(--ds-space-xl);
font-size: var(--eng-text-md);
}
.memo-body :global(th),
.memo-body :global(td) {
text-align: left;
padding: var(--ds-space-sm) var(--ds-space-md);
background: var(--dash-h) no-repeat left bottom / 100% 1px;
vertical-align: top;
}
.memo-body :global(th) {
font-family: var(--mono);
font-size: var(--eng-text-2xs);
letter-spacing: .04em;
text-transform: uppercase;
font-weight: 500;
color: var(--text-subtle);
}
.memo-body :global(tbody tr:last-child td) { background: none; }
/* Footnotes (GFM). A route line closes the body; the section is quiet mono under a "-- notes" label.
The reference marks carry the accent. */
.memo-body :global(.footnotes) {
margin-top: var(--ds-space-3xl);
padding-top: var(--ds-space-lg);
background: var(--dash-h) no-repeat left top / 100% 1px;
font-size: var(--eng-text-sm);
color: var(--text-muted);
}
.memo-body :global(.footnotes)::before {
content: "-- notes";
display: block;
margin-bottom: var(--ds-space-md);
font-family: var(--mono);
font-size: var(--eng-text-2xs);
letter-spacing: .04em;
color: var(--text-subtle);
}
/* hide the default screen-reader label; the mono one above replaces it visually */
.memo-body :global(.footnotes h2) {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.memo-body :global(.footnotes ol) { margin: 0; padding-left: 1.3em; }
.memo-body :global(.footnotes li) { margin-block: var(--ds-space-xs); }
.memo-body :global([data-footnote-ref]) { color: var(--accent); text-decoration: none; font-size: .8em; }
.memo-body :global([data-footnote-backref]) { color: var(--text-subtle); text-decoration: none; }
/* Collapse / toggle: native <details>, a container edge (solid, per the line grammar). */
.memo-body :global(details) {
margin-block: var(--ds-space-lg);
border: 1px solid var(--border);
border-radius: var(--ds-radius-md);
padding: 0 var(--ds-space-lg);
}
.memo-body :global(details[open]) { padding-bottom: var(--ds-space-md); }
.memo-body :global(summary) {
list-style: none;
cursor: pointer;
padding: var(--ds-space-md) 0;
font-family: var(--mono);
font-size: var(--eng-text-sm);
color: var(--text-muted);
}
.memo-body :global(summary)::-webkit-details-marker { display: none; }
.memo-body :global(summary)::before { content: "+ "; color: var(--accent); }
.memo-body :global(details[open] summary)::before { content: "- "; }
</style>site/src/content.config.ts
import { defineCollection, reference } from 'astro:content';
import { file, glob } from 'astro/loaders';
// `astro/zod` rather than the deprecated re-export from `astro:content` (removed in Astro 8), and
// rather than a direct `zod` dependency: this is astro's own pinned copy, so a schema here and the
// validator that runs it can never be two different zod versions.
import { z } from 'astro/zod';
// Author identity is declarative and shared: the repo-root authors.yml, keyed by GitHub handle, is the
// single source (byline + narration voice). See authors.yml. The audio toolchain reads the same file.
const authors = defineCollection({
loader: file('../authors.yml'),
schema: z.object({
name: z.string(),
role: z.string().optional(),
bio: z.string().optional(),
links: z.array(z.object({ label: z.string(), href: z.string() })).optional(),
// A filename under site/src/assets/authors/. A URL was valid here until the avatar was baked at
// promote instead of linked; it would now resolve to no asset and quietly render initials, so the
// schema rejects it rather than letting a byline lose its photo without saying why.
photo: z
.string()
.refine((v) => !/^https?:\/\//i.test(v), {
message:
'photo must be a filename under site/src/assets/authors/, not a URL. The /memo skill bakes a GitHub avatar into a committed file at promote.',
})
.optional(),
voice: z.string().optional(), // narration voice for the audio toolchain (not read by the site)
model: z.string().optional(),
}),
});
const memos = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/memos' }),
schema: z.object({
// Bounded because the OG card cannot reflow: satori overlaps siblings instead of pushing them, so
// past ~95 characters the card's title draws over its lede. Rendered clean at 94, broken at 102.
title: z.string().max(95),
description: z.string(),
publishDate: z.coerce.date(),
author: reference('authors'), // the author's handle, resolved against the authors collection
draft: z.boolean().default(false),
}),
});
export const collections = { memos, authors };memo.yml
defaults:
voice: af_heart
model: kokoro-onnx
# Per-author narration voice lives in the declarative author registry (repo-root authors.yml), keyed
# by GitHub handle, alongside the byline (name, role, bio, links). An unlisted author uses the default
# voice above. render.py reads authors.yml for the per-author `voice`.memos/dictionary.yml
# Project-level spoken forms, applied to every memo during projection (before a memo's own
# adaptations.yml). Pronunciations match case-insensitively, so one entry catches every casing a
# term takes in prose and URLs. Schema: see adaptations_from_config in tools/memo/lib.py.
pronunciations:
Unipaas: you-nee-pass