claude-threads 1.33.0 → 1.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -110,6 +110,39 @@ stickyMessage:
110
110
  | `description` | Line shown below the sticky title | none |
111
111
  | `footer` | Content shown before the default "Mention me to start a session" line | none |
112
112
 
113
+ ### Transcription (`transcription`)
114
+
115
+ Speech-to-text for inbound audio attachments, so a voice note is a message and not just a `.webm` on disk. Applies to every platform. Without this block, audio files are saved and listed like any other attachment.
116
+
117
+ ```yaml
118
+ transcription:
119
+ provider: elevenlabs
120
+ apiKey: your-elevenlabs-key
121
+ model: scribe_v2
122
+ languageCode: hrv
123
+ ```
124
+
125
+ | Setting | Description | Default |
126
+ |---------|-------------|---------|
127
+ | `provider` | Speech-to-text provider. Only `elevenlabs` (Scribe) today; the field is the seam for others. | required |
128
+ | `apiKey` | Provider API key. Keep it in this `0600` file, never in a repo. | required |
129
+ | `model` | Provider model id | `scribe_v2` |
130
+ | `languageCode` | Language hint, passed through verbatim (ElevenLabs accepts ISO-639-1 and ISO-639-3). Omit to auto-detect. | auto-detect |
131
+
132
+ Per-platform opt-out:
133
+
134
+ ```yaml
135
+ platforms:
136
+ - id: legal
137
+ transcription: false # this channel's audio never leaves the box
138
+ ```
139
+
140
+ The provider and its key are a property of the **deployment** — one vendor account per daemon — so they live at the top level. Whether a given channel's audio should be sent to that vendor is a property of the **channel**, which is what `transcription: false` answers. It gates every path that could reach the vendor, including watch evaluation.
141
+
142
+ ⚠️ **A transcript is user input, not narration.** It reaches Claude as message text and is evaluated by watches exactly as a typed message is — deliberately, so a watch on "deploy" fires when somebody says "deploy" out loud rather than only when they type it. The usual prompt-injection caution therefore applies in full: a voice note can say "ignore your instructions" as easily as a typed message can, and anyone who can post audio in the channel can attempt it. Treat the audio surface as exactly as trusted as the text one, because it is.
143
+
144
+ What happens: every `audio/*` attachment (or a file with an audio extension when the platform only reported a generic type) is transcribed after it is saved. Claude receives the usual file list **and** a `[Transcript of voice.webm (elevenlabs):]` block, and the bot posts the transcript back into the thread as a quote so everyone can see what Claude heard. A transcription failure is reported like a skipped file — the audio file itself still reaches Claude. A bad `provider` or missing `apiKey` fails the boot. Details: [`docs/audio-transcription-spec.md`](audio-transcription-spec.md).
145
+
113
146
  ## Platform Settings
114
147
 
115
148
  ### Mattermost
@@ -554,6 +587,14 @@ claudeAccounts:
554
587
  | `apiKey` | One of | Anthropic API key. Billed against that key; session history stays under the bot's default `HOME`. |
555
588
  | `displayName` | No | Human-readable label in UI (defaults to `id`) |
556
589
 
590
+ ⚠️ An account with `home` owns the **whole** Claude profile, not just its
591
+ credentials: user settings, hooks and global MCP configuration are read from
592
+ that home too. Anything the daemon inherited that could point elsewhere — an
593
+ API key, a bearer token, `CLAUDE_CONFIG_DIR` — is cleared for that child, or
594
+ the account you selected would be silently overridden by the one the daemon
595
+ runs as. Daemon-level hooks and settings therefore do **not** carry into a
596
+ pooled account; put them in the account's own home.
597
+
557
598
  Exactly one of `home` or `apiKey` should be set per account. Persisted sessions record which account they ran under and resume on the same one.
558
599
 
559
600
  ## Environment Variables
@@ -0,0 +1,151 @@
1
+ # Audio transcription: voice notes → text before Claude sees them
2
+
3
+ Upstream discussion: anneschuth/claude-threads#519.
4
+
5
+ ## What it does
6
+
7
+ When an inbound attachment has an `audio/*` MIME type and `transcription` is
8
+ configured, the bot transcribes it and puts the text into the message Claude
9
+ receives. Today the attachment is only saved to disk and listed by path — a
10
+ `.webm` Claude cannot hear. With this feature a Slack audio clip (or any
11
+ uploaded audio file) becomes a spoken message.
12
+
13
+ Behaviour, in order:
14
+
15
+ 1. `saveFilesToUploadDir` runs unchanged — the file lands on disk and its
16
+ path is still listed in the `[Attached files from chat …]` header.
17
+ 2. Every saved file whose `mimeType` starts with `audio/` — or whose
18
+ extension is a known audio one (`m4a mp3 ogg opus wav aac flac webm`) when
19
+ the platform reported only a generic type — is sent to the configured
20
+ transcriber. `webm` is on the list because Slack's own clips are
21
+ `voice.webm`; a WebM *video* that reaches this fallback gets its soundtrack
22
+ transcribed, which costs a fraction of a cent and is harmless. Properly
23
+ typed `video/*` is still excluded. Files are transcribed sequentially.
24
+ 3. The prompt gains one block per transcript, after the file list and before
25
+ the user's own text:
26
+
27
+ ```
28
+ [Transcript of voice.webm (elevenlabs):]
29
+ <text>
30
+ ```
31
+
32
+ 4. The bot posts each transcript back into the thread/channel as a quote
33
+ (`🎙️ Transcript of voice.webm:` + every line blockquoted — a bare `>` on
34
+ the first line only breaks at the first pause in speech). Teammates and
35
+ the audit log see what Claude heard; a bad transcript is visible before
36
+ Claude acts on it.
37
+ 5. A transcription failure is reported through the existing skipped-files
38
+ feedback (`⚠️ Some files could not be processed`) with reason
39
+ `Transcription failed: <message>` and the note that the raw file was still
40
+ handed to Claude. Never silent, never fatal to the message.
41
+ 6. No `transcription` block in config → nothing changes. Non-audio files are
42
+ untouched.
43
+
44
+ Out of scope for round 1: starting a session from a voice note (the first
45
+ message still needs the typed @-mention), `video/*`, Slack's own async
46
+ transcript, streaming/realtime.
47
+
48
+ ## Config (top-level)
49
+
50
+ ```yaml
51
+ transcription:
52
+ provider: elevenlabs # the only provider in round 1
53
+ apiKey: ... # ElevenLabs key; 0600 file, never in a repo
54
+ model: scribe_v2 # optional, default scribe_v2
55
+ languageCode: hrv # optional; passed through verbatim (ElevenLabs
56
+ # accepts ISO-639-1 and -3); omitted = auto-detect
57
+ ```
58
+
59
+ Validation happens at boot: an unknown `provider` or a missing `apiKey` is a
60
+ config error and the daemon does not start. One key per daemon — the provider
61
+ is a property of the deployment, not of a chat platform, and it applies to
62
+ Slack and Mattermost alike.
63
+
64
+ ## Provider interface
65
+
66
+ ```ts
67
+ // src/transcription/types.ts
68
+ interface Transcriber {
69
+ readonly provider: string;
70
+ transcribe(input: { path: string; mimeType: string; name: string }): Promise<string>;
71
+ }
72
+ createTranscriber(config: TranscriptionConfig): Transcriber // src/transcription/index.ts
73
+ ```
74
+
75
+ `ElevenLabsTranscriber` (`src/transcription/elevenlabs.ts`) POSTs multipart
76
+ `file` + `model_id` (+ `language_code` when set) to
77
+ `https://api.elevenlabs.io/v1/speech-to-text` with header `xi-api-key`, 120 s
78
+ timeout, and returns `text` from the JSON body. A non-2xx response or an
79
+ empty `text` is an error with the HTTP status and the first 200 chars of the
80
+ body. No SDK: `fetch` + `FormData` on Bun/Node 20. `fetch` is injectable for
81
+ tests.
82
+
83
+ ## Wiring
84
+
85
+ - `Config.transcription?: TranscriptionConfig` (`src/config/types.ts`).
86
+ - `index.ts`: `createTranscriber(config.transcription)` once at boot, then
87
+ `sessionManager.setTranscriber(t)`.
88
+ - `SessionManager` passes it as the new last parameter of
89
+ `streaming.buildMessageContent(text, platform, uploadDir, files, debug, transcriber?)`.
90
+ - `BuiltMessageContent.transcripts?: Transcript[]` (`{ name, text }`).
91
+ - `postTranscriptFeedback(platform, threadId, transcripts)` next to
92
+ `postSkippedFilesFeedback`, called at the same three places that post
93
+ skipped-file feedback for a user message with files: follow-up
94
+ (`message-manager.ts`), session start (`lifecycle.ts`), context-prompt
95
+ resolution (`context-prompt/handler.ts`).
96
+ - **Build once.** The session-start path used to build the message content
97
+ (downloading every attachment) and then hand the *built* text plus the
98
+ same files to `offerContextPrompt`, whose send paths build again — double
99
+ download, duplicated file header, and now a double transcription and echo.
100
+ `startSession` now passes the raw prompt and files into
101
+ `offerContextPrompt` and builds only on the fallback path. Behaviour for
102
+ direct-channel-mode sessions (the fallback path) is unchanged.
103
+
104
+ - **The deferred context prompt keeps its files.** When the user answers
105
+ the "include thread context?" prompt with a reaction, the completion event
106
+ carries only simplified file refs; the original `PlatformFile[]` were
107
+ parked in the context-prompt module. The lifecycle listener now takes them
108
+ from there (`takeContextPromptFiles`) and builds with them, posting the
109
+ usual skipped-file and transcript feedback. Before, attachments on that
110
+ path survived only because the pre-built file header rode along in the
111
+ queued prompt text, which the once-only build removed. Regression test in
112
+ `lifecycle.test.ts`.
113
+
114
+ Known, pre-existing, out of scope: the worktree-prompt skip adapters
115
+ (`reaction-router.ts`, `manager.ts` worktree skip) still drop queued files.
116
+ Direct-channel-mode task channels never take that path.
117
+
118
+ ## Tests
119
+
120
+ - `elevenlabs.test.ts`: sends the file and model as multipart with the key
121
+ header; includes `language_code` only when configured; returns `text`;
122
+ throws on non-2xx with status and body excerpt; throws on empty text.
123
+ - `index.test.ts`: factory returns an ElevenLabs transcriber; rejects unknown
124
+ provider; rejects missing key.
125
+ - `handler.test.ts` (streaming): audio attachment produces a transcript block
126
+ and a `transcripts` entry; non-audio attachment is not transcribed; no
127
+ transcriber means no transcript; transcriber failure surfaces in `skipped`
128
+ and the file path stays in the header; `postTranscriptFeedback` posts one
129
+ quote per transcript and nothing when empty.
130
+
131
+ ## Decisions
132
+
133
+ | Decision | Why |
134
+ |---|---|
135
+ | Top-level config, not per-platform | one vendor key per daemon; the question is open upstream (#519) and the shape can move if Anne prefers per-platform |
136
+ | Transcript echoed to the thread by default | the whole point of the Slack surface is that teammates see the task; a silent transcript hides the one step most likely to be wrong |
137
+ | Keep the raw file in the prompt | Claude may want the audio itself later (e.g. to re-transcribe with a hint), and it costs nothing |
138
+ | Failure = warning, message still sent | fail loud, but a bad vendor day must not eat a teammate's message |
139
+ | `audio/*` only, plus an audio-extension fallback for generic MIME types | Slack audio clips are `audio/webm`; a client that reports `application/octet-stream` for a `.m4a` still gets transcribed; video is a separate question asked upstream |
140
+ | Flat config keys (`apiKey`, `model` beside `provider`) rather than nested per provider | one provider today; #519 asks the maintainer which shape they want and the move is mechanical |
141
+ | Transcript is framed with a bracket header like the file list, not XML tags | it is the sender's own message, exactly as trusted as typed text from the same user; a reviewer suggested XML "boundaries", but the file-list header sets the house style and Claude already treats the block as user content |
142
+ | ElevenLabs error bodies are parsed for `detail.message` | the raw JSON in a thread warning is noise; the status code and a body excerpt remain the fallback |
143
+
144
+ ## Review notes (2026-09-02)
145
+
146
+ Gemini flagged `scribe_v2` as non-existent and `language_code` as ISO-639-1
147
+ only; a grounded web search the same day listed `scribe_v2` as current, and
148
+ the Telegram bot on the same box has run `hrv` (ISO-639-3) in production for
149
+ weeks. Both settled against the live API at deploy time; both are
150
+ configurable if the default is ever wrong. Codex found no design flaw beyond
151
+ the once-only build and the pre-existing file-loss paths recorded above.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-threads",
3
- "version": "1.33.0",
3
+ "version": "1.34.0",
4
4
  "description": "Run Claude Code from Slack or Mattermost. Sessions stream live into threads where your whole team can watch and steer.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",