---
name: saytext
description: Tune the SayText dictation app to how the user talks. Reads their local dictation history and recordings to find the words SayText keeps getting wrong, then proposes dictionary entries, snippets and Cleanup style changes. Use when the user runs /saytext, asks to improve SayText's dictionary, snippets or cleanup prompt, or wants to replay a dictation's recording or compare SayText's accuracy with another speech model.
metadata:
  opencode/slash: "true"
---

# SayText

SayText is a Mac dictation app: the user holds a key, speaks, and the text is pasted where they are typing. In Cleanup mode an LLM tidies the transcript first. Your job is to make it fit how this user talks:

1. Read their dictation history and find the words SayText keeps getting wrong.
2. Propose dictionary entries, snippets and Cleanup style changes that fix them.
3. Make only the changes the user agrees to, in ways that keep SayText's data valid.

Never change anything before the user has said yes to it.

## Where the data lives

Everything, each dictation's recording included, is in one SQLite database on this Mac:

- `~/.saytext/saytext.db` for the SayText app.
- `~/.saytext-dev/saytext.db` and `~/.saytext-staging/saytext.db` for development and staging builds. Use one of these only if the user says they run that build.

The database is in WAL mode and the app may be running. That is fine: SQLite handles both at once. Use the `sqlite3` command-line tool, which ships with macOS, and set a busy timeout so you wait rather than fail if the app is mid-write:

```sh
sqlite3 -readonly -cmd '.timeout 5000' ~/.saytext/saytext.db
```

Before reading anything else, check the schema matches what this skill describes:

```sql
PRAGMA user_version;   -- 8 when this skill was written
.schema dictionary_entries
.schema snippets
.schema styles
.schema settings
.schema recordings
```

If a table or column below is missing or different, stop and tell the user this skill may be out of date for their version of SayText.

Treat the history and the recordings as private. Read them on this Mac only, never upload a recording or send it to an online service, and quote back just the short phrases you need to explain a change.

## The tables you read

**`dictations`**: one row per dictation. Times are milliseconds since the Unix epoch.

| Column | What it holds |
| --- | --- |
| `mode` | `transcription` or `cleanup` |
| `outcome` | `inserted`, `fallback` (Cleanup failed, the plain transcript was pasted), `canceled` or `failed` |
| `started_at` | When the dictation started |
| `app_name`, `app_bundle_id`, `url` | The app (and page, in a browser) it went into |
| `style` | The kind of app Cleanup wrote for: `email`, `workMessages`, `personalMessages`, `code` or `other` |
| `raw_text` | What speech recognition heard |
| `cleaned_text` | What Cleanup returned, in Cleanup mode |
| `inserted_text` | What SayText pasted, after Cleanup, the dictionary and snippets |

**`dictation_edits`**: what the user changed afterwards. Each row is the text standing where the dictation was pasted once the user had edited it (`dictation_id`, `observed_at`, `edited_text`). The latest row per dictation is their final version. These are the strongest signal: every word the user fixed by hand is one SayText got wrong.

**`recordings`**: each dictation's recording, one row per dictation that has one (`dictation_id`, `wav`). `wav` holds a whole 16 kHz mono 16-bit WAV file's bytes: exactly the audio speech recognition heard. Dictations from before SayText kept recordings have none, and a recording is deleted with its dictation. Recordings are large, so never select `wav` itself except to export it (below).

**`dictionary_entries`**, **`snippets`**, **`styles`** and **`settings`** hold what you may change; see below.

## Find what SayText gets wrong

Start with the dictations the user corrected, newest first:

```sql
SELECT d.id,
       datetime(d.started_at / 1000, 'unixepoch', 'localtime') AS at,
       d.app_name,
       d.raw_text,
       d.inserted_text,
       e.edited_text
FROM dictations AS d
JOIN dictation_edits AS e ON e.dictation_id = d.id
WHERE e.id = (SELECT max(id) FROM dictation_edits WHERE dictation_id = d.id)
ORDER BY d.started_at DESC
LIMIT 200;
```

Then look wider, at dictations nobody edited:

```sql
SELECT id, mode, app_name, style, raw_text, cleaned_text, inserted_text
FROM dictations
WHERE outcome IN ('inserted', 'fallback')
ORDER BY started_at DESC
LIMIT 300;
```

What to look for:

- **Words the user replaces by hand.** Compare `inserted_text` with `edited_text` word by word. A name, product or term they fix more than once belongs in the dictionary.
- **Consistent mishearings.** The same wrong word or phrase in `raw_text` where the user meant something else, such as "clawed code" for "Claude Code". These need a replacement.
- **Spelling and capitalisation.** A word heard right but written the wrong way, such as "github" for "GitHub". These need an entry without a replacement.
- **Repeated text.** Sign-offs, addresses, links or stock replies the user dictates word for word again and again. These make good snippets.
- **Where they write.** Group by `style` and `app_name` to see which kinds of app they dictate into most, and whether Cleanup's output there gets edited for tone (too formal in chat, too casual in email).

Count how often each pattern happens. Propose only what recurs; one slip is not a pattern.

## Use the recordings

A recording settles what the user actually said when the text and their edits leave it unclear, and lets you compare SayText's speech recognition with another model on the same audio.

List recent dictations that have one, with each recording's length:

```sql
SELECT d.id,
       datetime(d.started_at / 1000, 'unixepoch', 'localtime') AS at,
       d.mode,
       round((length(r.wav) - 44) / 32000.0, 1) AS seconds,
       d.raw_text
FROM dictations AS d
JOIN recordings AS r ON r.dictation_id = d.id
ORDER BY d.started_at DESC
LIMIT 50;
```

**Export a recording** to a `.wav` file in a temporary folder before you play it or run anything on it. `writefile` is built into the `sqlite3` command-line tool and works on the read-only connection:

```sh
OUT="$(mktemp -d)"; echo "$OUT"
sqlite3 -readonly -cmd '.timeout 5000' ~/.saytext/saytext.db \
  "SELECT writefile('$OUT/' || dictation_id || '.wav', wav) FROM recordings WHERE dictation_id IN (42, 43);"
```

Each row prints the file's size in bytes; a dictation with no recording exports nothing. If your shell does not keep variables from one command to the next, use the folder's printed path in place of `$OUT` from here on. The exported files are copies of the user's voice: keep them in that folder, and delete it when you are done (`rm -r "$OUT"`).

**Replay one** for the user when they want to hear it, with macOS's own player: `afplay "$OUT/42.wav"`. It plays out loud, so ask first.

**Run another speech model** on the exported recordings to see what it hears, or to compare its accuracy with SayText's:

- Use only a model that runs on this Mac, such as Whisper through `mlx-whisper` on Apple silicon (which also needs `ffmpeg`) or `whisper.cpp`. Installing one downloads a large model: ask before you do. Never send recordings to a hosted transcription API.
- Write its output to the same temporary folder, for example `mlx_whisper "$OUT/42.wav" --model mlx-community/whisper-large-v3-turbo --output-format txt --output-dir "$OUT"`.
- Compare it with `raw_text`, which is SayText's speech recognition before any cleanup, dictionary or snippet. Do not compare with `cleaned_text` or `inserted_text`: Cleanup rewrites the wording on purpose.
- The best reference for what was said is the user's own final edit (`dictation_edits`) of a Transcription mode dictation, or what the user tells you after listening. Count word errors against it for each model, ignoring case and punctuation, and report both totals over the same dictations along with the words each one got wrong. The edit starts from `inserted_text`, after the dictionary, so a word SayText mishears but its dictionary already fixes still counts against `raw_text`: say which of SayText's errors the dictionary already fixes.
- A word both models mishear the same way is a good dictionary entry; a word only SayText mishears is worth telling the user about.

## What you can change

### The dictionary

Each entry is a `phrase` and an optional `replacement`. SayText matches the phrase as whole words, ignoring case, with any spacing between its words, and rewrites it in every dictation:

- **No replacement:** the phrase is a word to spell the user's way. Matching "saytext" writes it as `SayText`.
- **With a replacement:** the phrase is what SayText mishears, and the replacement is what it should write. Phrase `clawed code`, replacement `Claude Code`.

Cleanup is also told to spell each entry's word (its replacement, or its phrase) the user's way.

Rules the app keeps, which you must keep too:

- `phrase` is trimmed and never empty. A `replacement` is trimmed, and stored as `NULL` when it is empty or the same as the phrase.
- Phrases are unique ignoring case, including accented letters. The database's `NOCASE` only folds A to Z, so list the existing phrases first and compare them yourself. If one already matches, update that row instead of adding another.
- Be careful with a phrase that is also an ordinary word the user says. A replacement for `cloud` would rewrite every "cloud" they ever dictate. Prefer the longer misheard phrase.

### Snippets

A snippet is a `trigger` the user says and the `text` pasted in its place. Triggers match as whole phrases, ignoring case, after the dictionary has been applied.

- `trigger` is trimmed with inner runs of whitespace collapsed to one space, and never empty.
- `text` is kept exactly as written, and must not be blank.
- Triggers are unique ignoring case, including accented letters; check the existing ones first, as for the dictionary.
- Pick a trigger the user would never say by accident, such as "my work address" rather than "address".

### Cleanup style

How Cleanup writes is three things:

- **A tone per kind of app**, in the `styles` table: one row per `app_kind` (`email`, `workMessages`, `personalMessages`, `code`, `other`), with `style` set to exactly `{"tone":"natural"}`, `{"tone":"formal"}`, `{"tone":"casual"}` or `{"tone":"veryCasual"}`. Any other shape is ignored and reads as natural.
- **How much Cleanup changes**, in `settings` under the key `cleanupStrength`: the JSON string `"light"`, `"standard"` or `"thorough"`.
- **The custom prompt**, in `settings` under the key `cleanupCustomPrompt`: one JSON string, sent with every Cleanup request whatever the app. Write it as plain instructions to the model, and name the kind of app when a line applies to only one, such as "In code editors and terminals, keep file names, commands and identifiers exactly as spoken." It is trimmed, at most 4,000 characters, uses `\n` line breaks, and may not contain control characters other than line breaks and tabs, or invisible characters such as zero-width spaces. A prompt that breaks these rules is dropped without a word.

Read the current values first, and build on what is there:

```sql
SELECT app_kind, style FROM styles ORDER BY app_kind;
SELECT key, value FROM settings WHERE key IN ('cleanupStrength', 'cleanupCustomPrompt');
```

## Propose, then wait

Show the user one list of everything you would change, grouped as dictionary, snippets and Cleanup. For each item give the change and the evidence: a short quote of what SayText wrote, what the user meant, and how often it happened. For a new custom prompt, show the whole prompt as it would read, including any lines kept from the current one.

Then ask which changes to make. Make only those. If the user edits one, make it as they wrote it.

## Making the changes

### Back up first

Before the first write, back up the database next to it. Use SQLite's backup, not `cp`, so recent changes still in the write-ahead log are included:

```sh
sqlite3 -cmd '.timeout 5000' ~/.saytext/saytext.db ".backup '$HOME/.saytext/saytext-backup-$(date +%Y%m%d-%H%M%S).db'"
```

It includes the recordings, so it can be large. Tell the user where the backup is. To undo everything, they quit SayText and copy the backup over `saytext.db`.

### The custom prompt: through SayText

Set the custom prompt with SayText's own link, so the app checks it and asks the user before saving:

```sh
open "saytext://custom-prompt?text=$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.stdin.read(), safe=""))' < prompt.txt)"
```

Write the prompt to a file first (`prompt.txt` here) so quoting never mangles it. SayText opens its LLM cleanup page with the prompt and saves it only when the user accepts. The link replaces the whole prompt.

### Everything else: with sqlite3

Write in one transaction, so either every change lands or none does. `.bail on` makes `sqlite3` stop at the first error, which leaves the transaction uncommitted, and `BEGIN IMMEDIATE` takes the write lock up front, as the app itself does:

```sql
.bail on
.timeout 5000
BEGIN IMMEDIATE;

-- A word spelled the user's way.
INSERT INTO dictionary_entries (phrase, replacement, created_at, updated_at)
VALUES ('SayText', NULL,
        CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER),
        CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER));

-- A misheard phrase and what it should be.
INSERT INTO dictionary_entries (phrase, replacement, created_at, updated_at)
VALUES ('clawed code', 'Claude Code',
        CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER),
        CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER));

-- A snippet.
INSERT INTO snippets (trigger, text, created_at, updated_at)
VALUES ('my calendar link', 'https://cal.example.com/me',
        CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER),
        CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER));

-- A tone for one kind of app.
INSERT INTO styles (app_kind, style, updated_at)
VALUES ('personalMessages', '{"tone":"casual"}',
        CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER))
ON CONFLICT (app_kind) DO UPDATE SET style = excluded.style, updated_at = excluded.updated_at;

COMMIT;
```

- Every `created_at` and `updated_at` is milliseconds since the Unix epoch, as above. When you update a row, set `updated_at` and leave `created_at` alone.
- Quote text as SQL string literals, doubling any single quote: `'Sam''s'`.
- Write `settings` values as JSON. For `cleanupStrength`, the value is `'"light"'` with the double quotes inside. If the custom prompt link cannot be used, write the prompt with `json_quote('…')`, keeping to the rules above.
- If a statement fails, for example on a duplicate phrase, nothing from that run is written. Tell the user what clashed and ask how to go on.

Afterwards, read the rows back and show the user what is now stored.

### What you never change

- History: `dictations`, `dictation_edits`, `recordings` and `daily_stats`. Read them; never update or delete them.
- Any setting other than `cleanupStrength` and `cleanupCustomPrompt`.
- The schema, or `PRAGMA user_version`.
- Dictionary entries and snippets the user did not ask you to change.

## When the changes take effect

SayText reads the dictionary, snippets and Cleanup style from the database at every dictation, so the user's next dictation uses the changes. There is no need to restart the app.

Tell the user one thing: if SayText's window is open on the Dictionary, Snippets or LLM cleanup page, switch to another page and back to see the changes. Do this before changing anything on the LLM cleanup page, or the page saves the style it loaded earlier over yours.
