From 8acf9bc90a54177f3b4915e875404be2550f1765 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 17:51:16 +0000 Subject: [PATCH 1/2] Add a WisprFlow-clone example: Hammerspoon + assembly dictate Adds examples/wisprflow-hammerspoon, a push-to-talk dictation recipe that drives `assembly dictate` from a Hammerspoon hotkey: hold to record, release to SIGTERM the task and paste/type the transcript at the cursor in any app. Includes the ~100-line Lua script and a setup README, and links it from the main README's "Things you can do with it" section. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JFNUnmwzsAr7VL5NnaYwUj --- README.md | 2 + examples/wisprflow-hammerspoon/README.md | 81 +++++++++++ examples/wisprflow-hammerspoon/wisprflow.lua | 142 +++++++++++++++++++ 3 files changed, 225 insertions(+) create mode 100644 examples/wisprflow-hammerspoon/README.md create mode 100644 examples/wisprflow-hammerspoon/wisprflow.lua diff --git a/README.md b/README.md index cc2979d..bf2ea9f 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,8 @@ assembly stream -o text | assembly llm -f "summarize my to-dos as I talk" assembly stream --system-audio --speaker-labels -o text ``` +**Build a WisprFlow clone** — `assembly dictate` records on launch and prints the transcript on SIGTERM, so a hotkey tool can drive push-to-talk dictation that types into any app. The [`examples/wisprflow-hammerspoon`](examples/wisprflow-hammerspoon) recipe wires it up with [Hammerspoon](https://www.hammerspoon.org): hold a hotkey, speak, release, and the text lands at your cursor. + **Get pinged when your name comes up** in a live meeting: ```sh diff --git a/examples/wisprflow-hammerspoon/README.md b/examples/wisprflow-hammerspoon/README.md new file mode 100644 index 0000000..4a2fe41 --- /dev/null +++ b/examples/wisprflow-hammerspoon/README.md @@ -0,0 +1,81 @@ +# WisprFlow clone with Hammerspoon + `assembly dictate` + +A ~100-line [Hammerspoon](https://www.hammerspoon.org) script that turns the +AssemblyAI CLI into a [WisprFlow](https://wisprflow.ai)-style dictation tool: +**hold a hotkey, speak, release, and the transcript is typed in at your +cursor** — in any app, no terminal in sight. + +It works because `assembly dictate` is built for exactly this. It starts +recording immediately and prints the transcript to stdout when it receives +`SIGTERM`, so a hotkey tool can drive it as a background task: + +```text +hotkey down -> launch `assembly dictate` (records the mic) +hotkey up -> task:terminate() (SIGTERM) -> dictate prints the transcript + -> Hammerspoon reads stdout and pastes it at the cursor +``` + +## Setup + +1. **Install the AssemblyAI CLI** and sign in (the transcript needs an API key): + + ```sh + brew tap assemblyai/cli https://github.com/AssemblyAI/cli + brew install assembly + assembly login # or export ASSEMBLYAI_API_KEY=... + ``` + + Confirm dictation works from the terminal first — say a few words and press + `Ctrl-C`'s gentler sibling, `kill -TERM`, from another shell, or just: + + ```sh + assembly dictate + # speak, then in another terminal: kill -TERM $(pgrep -f 'assembly dictate') + ``` + +2. **Install Hammerspoon** and grant it Accessibility permission (System + Settings → Privacy & Security → Accessibility) so it can send keystrokes: + + ```sh + brew install --cask hammerspoon + ``` + +3. **Drop the script in place.** Copy `wisprflow.lua` to `~/.hammerspoon/` and + load it from your `~/.hammerspoon/init.lua`: + + ```lua + require("wisprflow") + ``` + + Then reload the config (Hammerspoon menubar → *Reload Config*). + +## Use it + +Hold **⌃⌥D** (control + option + D), speak, and release. A 🔴 appears in the +menubar while recording; on release the text lands at your cursor. macOS will +prompt once for microphone access. + +## Customize + +Edit the config block at the top of `wisprflow.lua`: + +| Setting | What it does | +| --- | --- | +| `M.hotkey` | The push-to-talk chord (default `⌃⌥D`). Hammerspoon can't bind a bare `fn` key, so use a modifier+letter. | +| `M.dictateArgs` | Extra `assembly dictate` flags, e.g. `{ "--language", "es" }` to dictate in Spanish, or repeated `--word-boost` to bias tricky terms. | +| `M.insertMode` | `"paste"` (clipboard + ⌘V, fast, restores your clipboard) or `"type"` (simulated keystrokes, works everywhere). | +| `M.sounds` | Play the mic-open / done chimes for audible feedback. | + +## Notes + +- **PATH**: Hammerspoon launches with a minimal `PATH`, so the script resolves + `assembly` through a login shell (`command -v assembly`). If you installed the + CLI somewhere unusual, hard-code the path in `resolveAssembly()`. +- **Limits**: each utterance is capped at 120 s (the Sync STT API limit); pass + `--max-seconds` to stop sooner. +- **Toggle instead of hold**: prefer tap-to-start / tap-to-stop? Bind only the + pressed callback to a function that calls `startDictation()` when idle and + `stopDictation()` when a recording is in flight. +- **Pipe it somewhere else**: the same SIGTERM trick powers shell pipelines — + `assembly dictate | assembly llm "write a conventional commit"`. See + `assembly dictate --help` for more. diff --git a/examples/wisprflow-hammerspoon/wisprflow.lua b/examples/wisprflow-hammerspoon/wisprflow.lua new file mode 100644 index 0000000..3f59db4 --- /dev/null +++ b/examples/wisprflow-hammerspoon/wisprflow.lua @@ -0,0 +1,142 @@ +-- WisprFlow clone for macOS: push-to-talk dictation anywhere, powered by +-- Hammerspoon (https://www.hammerspoon.org) and `assembly dictate`. +-- +-- Hold the hotkey to record, release to transcribe. The text is inserted at the +-- cursor in whatever app has focus — a chat box, your editor, a search field. +-- `assembly dictate` records immediately and prints the transcript when it +-- receives SIGTERM (which `hs.task:terminate()` sends), so the whole flow is: +-- +-- hotkey down -> launch `assembly dictate` as a background task +-- hotkey up -> terminate it (SIGTERM) -> read stdout -> type it at the cursor +-- +-- Load it from your ~/.hammerspoon/init.lua with: +-- +-- require("wisprflow") -- if this file is at ~/.hammerspoon/wisprflow.lua +-- +-- then reload the Hammerspoon config (menubar -> Reload Config). + +local M = {} + +-- --------------------------------------------------------------------------- +-- Configuration — tweak these to taste. +-- --------------------------------------------------------------------------- + +-- Push-to-talk hotkey. Default: hold ⌃⌥ (control+option) + D. +-- Hammerspoon can't bind a bare modifier like WisprFlow's fn key, so we use a +-- modifier+letter chord; pick anything that doesn't clash with your apps. +M.hotkey = { mods = { "ctrl", "alt" }, key = "d" } + +-- Extra arguments passed to every `assembly dictate` run, e.g.: +-- { "--language", "es" } -- dictate in Spanish +-- { "--word-boost", "AssemblyAI", "--word-boost", "LeMUR" } -- bias terms +M.dictateArgs = {} + +-- How to insert the transcript: +-- "type" — simulate keystrokes (works everywhere, slower for long text) +-- "paste" — stash it on the clipboard and ⌘V (fast, restores your clipboard) +M.insertMode = "paste" + +-- Play the system mic-open / done sounds for audible feedback. +M.sounds = true + +-- --------------------------------------------------------------------------- +-- Internals. +-- --------------------------------------------------------------------------- + +-- Resolve the `assembly` binary through a login shell so Homebrew's PATH (e.g. +-- /opt/homebrew/bin) is on it — Hammerspoon itself starts with a minimal PATH. +local function resolveAssembly() + local path = hs.execute("command -v assembly", true) + path = path and path:gsub("%s+$", "") or "" + if path == "" then + hs.notify + .new({ + title = "WisprFlow (Hammerspoon)", + informativeText = "`assembly` not found on PATH. Install the AssemblyAI CLI first.", + }) + :send() + return nil + end + return path +end + +local assemblyPath = resolveAssembly() +local task = nil -- the in-flight `assembly dictate` hs.task, or nil +local indicator = nil -- a menubar dot shown while recording + +local function showRecording() + if not indicator then + indicator = hs.menubar.new() + end + if indicator then + indicator:setTitle("🔴") + indicator:setTooltip("Dictating… release the hotkey to transcribe") + end + if M.sounds then + hs.sound.getByName("Tink"):play() + end +end + +local function hideRecording() + if indicator then + indicator:delete() + indicator = nil + end +end + +local function insert(text) + text = text:gsub("%s+$", "") -- drop the trailing newline `dictate` prints + if text == "" then + return + end + if M.insertMode == "paste" then + local saved = hs.pasteboard.getContents() + hs.pasteboard.setContents(text) + hs.eventtap.keyStroke({ "cmd" }, "v") + -- Restore the previous clipboard after the paste lands. + hs.timer.doAfter(0.2, function() + hs.pasteboard.setContents(saved) + end) + else + hs.eventtap.keyStrokes(text) + end +end + +-- Hotkey pressed: start recording (no-op if a run is already in flight). +local function startDictation() + if task or not assemblyPath then + return + end + local args = { "dictate" } + for _, a in ipairs(M.dictateArgs) do + args[#args + 1] = a + end + task = hs.task.new(assemblyPath, function(_, stdOut, stdErr) + task = nil + hideRecording() + if M.sounds then + hs.sound.getByName("Pop"):play() + end + if stdOut and stdOut ~= "" then + insert(stdOut) + elseif stdErr and stdErr ~= "" then + hs.notify.new({ title = "WisprFlow (Hammerspoon)", informativeText = stdErr }):send() + end + end, args) + if task:start() then + showRecording() + else + task = nil + end +end + +-- Hotkey released: SIGTERM the recording so `dictate` transcribes and exits. +local function stopDictation() + if task and task:isRunning() then + task:terminate() -- sends SIGTERM, which `assembly dictate` treats as "done" + end +end + +hs.hotkey.bind(M.hotkey.mods, M.hotkey.key, startDictation, stopDictation) + +return M From 3107890bbf30b4b0af5c2cc99832481068a3680f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 18:01:19 +0000 Subject: [PATCH 2/2] Rename dictate example + add a meeting-notes Hammerspoon recipe Renames examples/wisprflow-hammerspoon to examples/dictation-hammerspoon (script -> dictation.lua) and adds examples/meeting-notes-hammerspoon: a one-hotkey meeting recorder built on `assembly stream` (system audio + mic, speaker labels, --auto-name, live --llm notes), with the companion justfile (record/list/search) it mirrors. Both link from the README's examples section. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JFNUnmwzsAr7VL5NnaYwUj --- README.md | 9 +- .../README.md | 8 +- .../dictation.lua} | 10 +- examples/meeting-notes-hammerspoon/README.md | 86 +++++++++ examples/meeting-notes-hammerspoon/justfile | 37 ++++ .../meeting_notes.lua | 164 ++++++++++++++++++ 6 files changed, 304 insertions(+), 10 deletions(-) rename examples/{wisprflow-hammerspoon => dictation-hammerspoon}/README.md (93%) rename examples/{wisprflow-hammerspoon/wisprflow.lua => dictation-hammerspoon/dictation.lua} (92%) create mode 100644 examples/meeting-notes-hammerspoon/README.md create mode 100644 examples/meeting-notes-hammerspoon/justfile create mode 100644 examples/meeting-notes-hammerspoon/meeting_notes.lua diff --git a/README.md b/README.md index bf2ea9f..ff40273 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,14 @@ assembly stream -o text | assembly llm -f "summarize my to-dos as I talk" assembly stream --system-audio --speaker-labels -o text ``` -**Build a WisprFlow clone** — `assembly dictate` records on launch and prints the transcript on SIGTERM, so a hotkey tool can drive push-to-talk dictation that types into any app. The [`examples/wisprflow-hammerspoon`](examples/wisprflow-hammerspoon) recipe wires it up with [Hammerspoon](https://www.hammerspoon.org): hold a hotkey, speak, release, and the text lands at your cursor. +**Record meeting notes from one hotkey** — capture system audio + mic with speaker labels and have an LLM write a live, auto-named Markdown note. The [`examples/meeting-notes-hammerspoon`](examples/meeting-notes-hammerspoon) recipe pairs a [Hammerspoon](https://www.hammerspoon.org) toggle with a `just record`/`list`/`search` workflow: + +```sh +assembly stream --system-audio --speaker-labels --auto-name \ + --save-dir ~/meeting-notes --llm "summary, decisions, action items, open questions" +``` + +**Build a WisprFlow clone** — `assembly dictate` records on launch and prints the transcript on SIGTERM, so a hotkey tool can drive push-to-talk dictation that types into any app. The [`examples/dictation-hammerspoon`](examples/dictation-hammerspoon) recipe wires it up with [Hammerspoon](https://www.hammerspoon.org): hold a hotkey, speak, release, and the text lands at your cursor. **Get pinged when your name comes up** in a live meeting: diff --git a/examples/wisprflow-hammerspoon/README.md b/examples/dictation-hammerspoon/README.md similarity index 93% rename from examples/wisprflow-hammerspoon/README.md rename to examples/dictation-hammerspoon/README.md index 4a2fe41..b21bafa 100644 --- a/examples/wisprflow-hammerspoon/README.md +++ b/examples/dictation-hammerspoon/README.md @@ -1,4 +1,4 @@ -# WisprFlow clone with Hammerspoon + `assembly dictate` +# Push-to-talk dictation with Hammerspoon + `assembly dictate` A ~100-line [Hammerspoon](https://www.hammerspoon.org) script that turns the AssemblyAI CLI into a [WisprFlow](https://wisprflow.ai)-style dictation tool: @@ -40,11 +40,11 @@ hotkey up -> task:terminate() (SIGTERM) -> dictate prints the transcript brew install --cask hammerspoon ``` -3. **Drop the script in place.** Copy `wisprflow.lua` to `~/.hammerspoon/` and +3. **Drop the script in place.** Copy `dictation.lua` to `~/.hammerspoon/` and load it from your `~/.hammerspoon/init.lua`: ```lua - require("wisprflow") + require("dictation") ``` Then reload the config (Hammerspoon menubar → *Reload Config*). @@ -57,7 +57,7 @@ prompt once for microphone access. ## Customize -Edit the config block at the top of `wisprflow.lua`: +Edit the config block at the top of `dictation.lua`: | Setting | What it does | | --- | --- | diff --git a/examples/wisprflow-hammerspoon/wisprflow.lua b/examples/dictation-hammerspoon/dictation.lua similarity index 92% rename from examples/wisprflow-hammerspoon/wisprflow.lua rename to examples/dictation-hammerspoon/dictation.lua index 3f59db4..d0a4be0 100644 --- a/examples/wisprflow-hammerspoon/wisprflow.lua +++ b/examples/dictation-hammerspoon/dictation.lua @@ -1,5 +1,5 @@ --- WisprFlow clone for macOS: push-to-talk dictation anywhere, powered by --- Hammerspoon (https://www.hammerspoon.org) and `assembly dictate`. +-- Push-to-talk dictation anywhere on macOS (a WisprFlow-style clone), powered +-- by Hammerspoon (https://www.hammerspoon.org) and `assembly dictate`. -- -- Hold the hotkey to record, release to transcribe. The text is inserted at the -- cursor in whatever app has focus — a chat box, your editor, a search field. @@ -11,7 +11,7 @@ -- -- Load it from your ~/.hammerspoon/init.lua with: -- --- require("wisprflow") -- if this file is at ~/.hammerspoon/wisprflow.lua +-- require("dictation") -- if this file is at ~/.hammerspoon/dictation.lua -- -- then reload the Hammerspoon config (menubar -> Reload Config). @@ -51,7 +51,7 @@ local function resolveAssembly() if path == "" then hs.notify .new({ - title = "WisprFlow (Hammerspoon)", + title = "Dictation (Hammerspoon)", informativeText = "`assembly` not found on PATH. Install the AssemblyAI CLI first.", }) :send() @@ -120,7 +120,7 @@ local function startDictation() if stdOut and stdOut ~= "" then insert(stdOut) elseif stdErr and stdErr ~= "" then - hs.notify.new({ title = "WisprFlow (Hammerspoon)", informativeText = stdErr }):send() + hs.notify.new({ title = "Dictation (Hammerspoon)", informativeText = stdErr }):send() end end, args) if task:start() then diff --git a/examples/meeting-notes-hammerspoon/README.md b/examples/meeting-notes-hammerspoon/README.md new file mode 100644 index 0000000..3b4616a --- /dev/null +++ b/examples/meeting-notes-hammerspoon/README.md @@ -0,0 +1,86 @@ +# Meeting notes with Hammerspoon + `assembly stream` + +Record a meeting with **one hotkey** and get a live, auto-named Markdown note +out the other end — system audio *and* your mic, diarized by speaker, summarized +by an LLM as you talk. This is the [Hammerspoon](https://www.hammerspoon.org) +front-end to the [`justfile`](justfile) in this directory, so you can drive the +same workflow from a global hotkey or from the terminal. + +It leans on one command: + +```sh +assembly stream --system-audio --speaker-labels --auto-name \ + --save-dir ~/meeting-notes --model claude-sonnet-4-6 \ + --llm "Keep running meeting notes: summary, decisions, action items, open questions" +``` + +`--system-audio` mixes the meeting app's output with your mic as separate +diarized speakers; `--auto-name` names the file from its content and buckets it +under `/YYYY-MM-DD/`; `--llm` re-runs the prompt over the growing +transcript so the `.md` note updates live. The note is written on a clean stop — +Ctrl-C from the terminal, or `SIGTERM` from a hotkey tool (`stream` routes both +to the same save path). + +## The justfile + +If you live in the terminal, the `justfile` gives you three recipes (needs +[`just`](https://github.com/casey/just); `list` also wants `fzf` + `glow`): + +```sh +just record # record a meeting + its note +just list # browse notes, preview rendered +just search "what did we decide on pricing?" # ask across all notes (LLM cites them) +``` + +## The Hammerspoon hotkey + +For a global, no-terminal version, use `meeting_notes.lua`: + +1. **Install the CLI** and sign in, then **install Hammerspoon** and grant it + Accessibility + Screen Recording permission (System Settings → Privacy & + Security) — system-audio capture needs Screen Recording: + + ```sh + brew tap assemblyai/cli https://github.com/AssemblyAI/cli + brew install assembly + assembly login + brew install --cask hammerspoon + ``` + +2. **Drop the script in place.** Copy `meeting_notes.lua` to `~/.hammerspoon/` + and load it from your `~/.hammerspoon/init.lua`: + + ```lua + require("meeting_notes") + ``` + + Then reload the config (Hammerspoon menubar → *Reload Config*). + +3. **Use it.** Press **⌃⌥M** to start recording (a `🔴 REC` badge appears in the + menubar); press it again to stop and save the note. **⌃⌥⇧M** opens the notes + folder in Finder. + +## Customize + +Edit the config block at the top of `meeting_notes.lua` (it mirrors the +justfile's variables): + +| Setting | What it does | +| --- | --- | +| `M.hotkey` | The toggle-recording chord (default `⌃⌥M`). | +| `M.openHotkey` | The "open notes folder" chord (default `⌃⌥⇧M`). | +| `M.transcriptsDir` | Where notes are saved (default `~/meeting-notes`). | +| `M.model` | The model used for naming and the live note. | +| `M.notePrompt` | The `--llm` instruction that shapes each note. | +| `M.sounds` | Play the start/stop chimes for audible feedback. | + +## Notes + +- **PATH**: Hammerspoon launches with a minimal `PATH`, so the script resolves + `assembly` through a login shell. Hard-code the path in `resolveAssembly()` if + the CLI lives somewhere unusual. +- **Permissions**: capturing system audio requires the Screen Recording + permission in addition to Accessibility; macOS prompts on first use. +- **Searching later**: the justfile's `search` recipe points `assembly llm` at + the notes directory — it recurses for `.md`/`.txt` files and answers with + citations, so your meeting history becomes queryable. diff --git a/examples/meeting-notes-hammerspoon/justfile b/examples/meeting-notes-hammerspoon/justfile new file mode 100644 index 0000000..39a443d --- /dev/null +++ b/examples/meeting-notes-hammerspoon/justfile @@ -0,0 +1,37 @@ +# Meeting notes powered by `assembly stream` — record, browse, and search. +# +# Run `just` (or `just default`) to list recipes. Needs the AssemblyAI CLI; +# `list` also needs fzf + glow, `record` uses an LLM for the live note. + +# Where notes land. The CLI buckets each note under /YYYY-MM-DD/. +transcripts_dir := env_var_or_default("MEETING_NOTES_DIR", "~/meeting-notes") + +# The live-note instruction handed to `assembly stream --llm`. +note_prompt := "Keep running meeting notes: a one-line summary, decisions, action items (with owners), and open questions. Update as we talk." + +# List available recipes. +default: + @just --list + +# The CLI auto-names the file from its content, buckets it under +# transcripts/YYYY-MM-DD/, and writes the .md note live via --llm (saved on +# Ctrl-C); [no-exit-message] hides the exit-130 that SIGINT returns. +# Record a meeting (system audio + mic, speaker labels) and its note in one pass. +[no-exit-message] +record: + assembly stream --system-audio --speaker-labels --auto-name --save-dir "{{transcripts_dir}}" --model claude-sonnet-4-6 --llm "{{note_prompt}}" + +# Browse notes newest-first and read the selected one rendered (needs fzf + glow). +list: + #!/usr/bin/env zsh + setopt err_exit no_unset pipe_fail + notes=( {{transcripts_dir}}/**/*.md(.NOn) ) + (( $#notes )) || { echo "No recordings yet — run 'just record'." >&2; exit 0; } + sel=$(print -l -- $notes | fzf --preview 'glow -s dark {}' --preview-window 'right,70%') || true + [[ -z "$sel" ]] || glow -s dark -p "$sel" + +# Ask a question across all notes via the LLM Gateway; it cites the notes it used. +# `assembly llm` recurses the directory for .md/.txt files as context. +# Usage: just search "what did we decide about pricing?" +search query: + assembly llm --model claude-sonnet-4-6 "Answer using only the notes provided. Cite the relevant note name(s). Question: {{query}}" "{{transcripts_dir}}" -o text diff --git a/examples/meeting-notes-hammerspoon/meeting_notes.lua b/examples/meeting-notes-hammerspoon/meeting_notes.lua new file mode 100644 index 0000000..e5e2bf9 --- /dev/null +++ b/examples/meeting-notes-hammerspoon/meeting_notes.lua @@ -0,0 +1,164 @@ +-- One-hotkey meeting notes on macOS, powered by Hammerspoon +-- (https://www.hammerspoon.org) and `assembly stream`. +-- +-- This is the Hammerspoon companion to the justfile in this directory: a hotkey +-- toggles a recording that captures system audio + your mic with speaker labels, +-- auto-names the file from its content, and writes a live Markdown note via an +-- LLM. `assembly stream` saves the note on a clean stop, and SIGTERM (which +-- `hs.task:terminate()` sends) is routed to that same stop path, so: +-- +-- hotkey -> start `assembly stream …` recording the meeting (note builds live) +-- hotkey -> task:terminate() (SIGTERM) -> stream flushes + saves the .md note +-- +-- A second hotkey opens the notes folder in Finder. Load it from your +-- ~/.hammerspoon/init.lua with: +-- +-- require("meeting_notes") -- if this file is at ~/.hammerspoon/meeting_notes.lua +-- +-- then reload the Hammerspoon config (menubar -> Reload Config). + +local M = {} + +-- --------------------------------------------------------------------------- +-- Configuration — tweak these to taste. The defaults mirror the justfile. +-- --------------------------------------------------------------------------- + +-- Toggle-recording hotkey. Default: ⌃⌥M (control+option+M). Tap to start, tap +-- again to stop and save the note. +M.hotkey = { mods = { "ctrl", "alt" }, key = "m" } + +-- "Open notes folder" hotkey. Default: ⌃⌥⇧M. +M.openHotkey = { mods = { "ctrl", "alt", "shift" }, key = "m" } + +-- Where notes land. The CLI buckets each note under /YYYY-MM-DD/ and names +-- the file from the meeting's content (--auto-name). "~" is expanded for you. +M.transcriptsDir = "~/meeting-notes" + +-- Model used both for naming and for the live note. +M.model = "claude-sonnet-4-6" + +-- The live-note instruction handed to --llm. `assembly stream` re-runs it over +-- the growing transcript, so the note updates in place as the meeting goes. +M.notePrompt = "Keep running meeting notes: a one-line summary, decisions, " + .. "action items (with owners), and open questions. Update as we talk." + +-- Play the system start/stop chimes for audible feedback. +M.sounds = true + +-- --------------------------------------------------------------------------- +-- Internals. +-- --------------------------------------------------------------------------- + +-- Resolve the `assembly` binary through a login shell so Homebrew's PATH (e.g. +-- /opt/homebrew/bin) is on it — Hammerspoon itself starts with a minimal PATH. +local function resolveAssembly() + local path = hs.execute("command -v assembly", true) + path = path and path:gsub("%s+$", "") or "" + if path == "" then + hs.notify + .new({ + title = "Meeting notes (Hammerspoon)", + informativeText = "`assembly` not found on PATH. Install the AssemblyAI CLI first.", + }) + :send() + return nil + end + return path +end + +-- Expand a leading "~" to the user's home directory. +local function expanduser(p) + if p:sub(1, 1) == "~" then + return os.getenv("HOME") .. p:sub(2) + end + return p +end + +local assemblyPath = resolveAssembly() +local notesDir = expanduser(M.transcriptsDir) +local task = nil -- the in-flight `assembly stream` hs.task, or nil +local indicator = nil -- a menubar dot shown while recording + +local function notify(text) + hs.notify.new({ title = "Meeting notes (Hammerspoon)", informativeText = text }):send() +end + +local function showRecording() + if not indicator then + indicator = hs.menubar.new() + end + if indicator then + indicator:setTitle("🔴 REC") + indicator:setTooltip("Recording the meeting… press the hotkey again to save the note") + end + if M.sounds then + hs.sound.getByName("Tink"):play() + end +end + +local function hideRecording() + if indicator then + indicator:delete() + indicator = nil + end + if M.sounds then + hs.sound.getByName("Pop"):play() + end +end + +local function startRecording() + if not assemblyPath then + return + end + local args = { + "stream", + "--system-audio", + "--speaker-labels", + "--auto-name", + "--save-dir", + notesDir, + "--model", + M.model, + "--llm", + M.notePrompt, + } + task = hs.task.new(assemblyPath, function(_, _, stdErr) + -- stream exits 130 on a clean (SIGTERM/Ctrl-C) stop, with the note saved. + local wasRecording = indicator ~= nil + task = nil + hideRecording() + if wasRecording then + notify("Saved a meeting note to " .. M.transcriptsDir) + elseif stdErr and stdErr ~= "" then + notify(stdErr) + end + end, args) + if task:start() then + showRecording() + else + task = nil + notify("Couldn't start `assembly stream`.") + end +end + +local function stopRecording() + if task and task:isRunning() then + task:terminate() -- SIGTERM -> stream's clean-stop path -> the note is saved + end +end + +-- One hotkey toggles recording: start when idle, stop+save when in flight. +hs.hotkey.bind(M.hotkey.mods, M.hotkey.key, function() + if task and task:isRunning() then + stopRecording() + else + startRecording() + end +end) + +-- A second hotkey reveals the notes folder in Finder. +hs.hotkey.bind(M.openHotkey.mods, M.openHotkey.key, function() + hs.execute("mkdir -p '" .. notesDir .. "' && open '" .. notesDir .. "'", true) +end) + +return M