@goodandready/dsh-voice 0.9.31 → 0.9.33

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.
@@ -0,0 +1,28 @@
1
+ // lib/dictation-modes.js — Dictation post-processing modes (#264)
2
+
3
+ export const DICTATION_MODES = ['verbatim', 'clean', 'task', 'note']
4
+
5
+ export const DEFAULT_DICTATION_PROMPTS = {
6
+ clean: 'Fix the punctuation and spelling of this dictated text and remove filler words ("um", "uh", etc.). Keep the original language, technical terms, and meaning intact without adding facts. Reply with the clean text only:\n\n{text}',
7
+ task: 'Convert the following dictated request into a clear, structured agent task with: Problem/Goal, Expected Outcome, Constraints, Acceptance Criteria. Do not invent details not mentioned in the dictation. Keep technical identifiers intact. Reply with the structured task only:\n\n{text}',
8
+ note: 'Extract key points, main ideas, and action items from the following dictation. Only include action items that were explicitly mentioned. Keep original terminology intact. Reply with the structured note only:\n\n{text}',
9
+ }
10
+
11
+ /**
12
+ * Resolve prompt instruction for a dictation sub-mode.
13
+ * @param {string} mode 'verbatim' | 'clean' | 'task' | 'note'
14
+ * @param {Record<string, string>} [customPrompts]
15
+ * @param {string} text Dictated text to format
16
+ * @returns {string|null} null for verbatim (0 LLM calls), prompt string for LLM modes
17
+ */
18
+ export function resolveDictationPrompt(mode, customPrompts, text) {
19
+ const m = DICTATION_MODES.includes(mode) ? mode : 'clean'
20
+ if (m === 'verbatim') return null
21
+ const template = (customPrompts && typeof customPrompts[m] === 'string' && customPrompts[m].trim())
22
+ ? customPrompts[m]
23
+ : DEFAULT_DICTATION_PROMPTS[m]
24
+ if (template.includes('{text}')) {
25
+ return template.replaceAll('{text}', text)
26
+ }
27
+ return template + '\n\n' + text
28
+ }
@@ -1,188 +1,222 @@
1
- // lib/host-routes.js — Host HTTP routes for dsh-voice status, polish, and transcribe
2
- import { writeJson, readBody, isTrustedCaller } from './http-util.js'
3
- import { resolveSensevoiceProvider } from './sensevoice-installer.js'
4
-
5
- export function registerHostRoutes(ctx, {
6
- live,
7
- daemons,
8
- statsTracker,
9
- transcribe,
10
- polishText,
11
- sessionCommand,
12
- extractVoiceActions,
13
- applyJargonDictionary,
14
- KNOWN_KEYS,
15
- }) {
16
- const { whisperAlive, sensevoiceAlive, getWhisperError, getSensevoiceError, startWhisper, startSensevoice } = daemons
17
-
18
- ctx.effect(() => ctx.webServer.register({
19
- kind: 'exact',
20
- path: '/dsh-voice/status',
21
- handler: async (req, res) => {
22
- if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
23
- const cfg = live()
24
- const whisperOk = await whisperAlive()
25
- const sensevoiceOk = await sensevoiceAlive()
26
- const whisperError = whisperOk ? null : getWhisperError()
27
- const sensevoiceError = sensevoiceOk ? null : getSensevoiceError()
28
- writeJson(res, 200, {
29
- ok: true,
30
- whisperRunning: whisperOk,
31
- whisperError: whisperOk ? null : whisperError,
32
- sensevoiceRunning: sensevoiceOk,
33
- sensevoiceError: sensevoiceOk ? null : sensevoiceError,
34
- hotkey: cfg.hotkey,
35
- providers: KNOWN_KEYS.concat(
36
- (Array.isArray(cfg.customProviders) ? cfg.customProviders : [])
37
- .map((c) => String(c && c.key || '').trim()).filter(Boolean),
38
- ),
39
- modes: {
40
- dictation: {
41
- chain: cfg.dictation.chain, language: cfg.dictation.language,
42
- vadSilenceMs: cfg.dictation.vadSilenceMs,
43
- sendDelayMs: cfg.dictation.sendDelayMs, polish: cfg.dictation.polish,
44
- stream: cfg.dictation.stream, streamChunkMs: cfg.dictation.streamChunkMs,
45
- vadAdapt: cfg.dictation.vadAdapt,
46
- },
47
- message: {
48
- chain: cfg.message.chain, language: cfg.message.language,
49
- autoSendMs: cfg.message.autoSendMs, polish: cfg.message.polish,
50
- sessionCommands: cfg.message.sessionCommands, polishSend: cfg.message.polishSend, editablePreview: cfg.message.editablePreview !== false,
51
- },
52
- },
53
- beep: cfg.beep,
54
- localOnly: cfg.localOnly,
55
- micDeviceId: cfg.micDeviceId,
56
- noiseGateDb: cfg.noiseGateDb !== undefined ? cfg.noiseGateDb : -45,
57
- historyLimit: cfg.historyLimit,
58
- voiceCommands: cfg.voiceCommands,
59
- wakeWord: String(cfg.wakeWord || ''),
60
- bargeIn: !!cfg.bargeIn,
61
- polishBaseUrl: String(cfg.polishBaseUrl || ''),
62
- noiseSuppression: cfg.noiseSuppression !== false,
63
- contextGlossary: cfg.contextGlossary !== false,
64
- sensevoiceUrl: cfg.sensevoiceUrl,
65
- sensevoiceAutostart: !!cfg.sensevoiceAutostart,
66
- sensevoiceProvider: cfg.sensevoiceProvider || 'cpu',
67
- effectiveSensevoiceProvider: resolveSensevoiceProvider(cfg),
68
- sensevoiceThreads: cfg.sensevoiceThreads || 4,
69
- webgpuModel: cfg.webgpuModel || 'onnx-community/whisper-tiny',
70
- deepgramBaseUrl: cfg.deepgramBaseUrl || 'https://api.deepgram.com',
71
- visualizerStyle: cfg.visualizerStyle || 'liquid-wave',
72
- gatedTurnTaking: cfg.gatedTurnTaking !== false,
73
- autoSendVisualRing: cfg.autoSendVisualRing !== false,
74
- voiceActions: cfg.voiceActions !== false,
75
- techJargonCorrection: cfg.techJargonCorrection !== false,
76
- jargonDictionary: Array.isArray(cfg.jargonDictionary) ? cfg.jargonDictionary : [],
77
- liveInterimPreview: cfg.liveInterimPreview !== false,
78
- structuredPromptVoice: cfg.structuredPromptVoice !== false,
79
- providerStats: statsTracker.get(),
80
- })
81
- },
82
- }), 'dsh-voice: /status route')
83
-
84
- ctx.effect(() => ctx.webServer.register({
85
- kind: 'exact',
86
- path: '/dsh-voice/polish',
87
- handler: async (req, res) => {
88
- if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
89
- if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
90
- const cfg = live()
91
- let raw
92
- try { raw = await readBody(req, 1024 * 1024) } catch (e) {
93
- if (e && (e.statusCode === 413 || e.message === 'body too large')) {
94
- writeJson(res, 413, { ok: false, error: { code: 'too-large', message: 'body exceeds limit of 1MB' } })
95
- return
96
- }
97
- writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
98
- return
99
- }
100
- let payload = {}
101
- try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /* empty */ }
102
- const text = typeof payload.text === 'string' ? payload.text.trim() : ''
103
- if (!text) { writeJson(res, 400, { ok: false, error: { code: 'empty', message: 'text required' } }); return }
104
- const controller = new AbortController()
105
- const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
106
- try {
107
- const out = await polishText(text, { polish: true }, controller.signal)
108
- writeJson(res, 200, { ok: true, text: out })
109
- } catch (e) {
110
- writeJson(res, 502, { ok: false, error: { code: 'polish', message: String(e && e.message || e) } })
111
- } finally { clearTimeout(timer) }
112
- },
113
- }), 'dsh-voice: /polish route')
114
-
115
- ctx.effect(() => ctx.webServer.register({
116
- kind: 'exact',
117
- path: '/dsh-voice/transcribe',
118
- handler: async (req, res) => {
119
- if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
120
- if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
121
- const cfg = live()
122
- const maxBodyBytes = Math.ceil((cfg.maxFileBytes * 4) / 3) + 64 * 1024
123
- let raw
124
- try {
125
- raw = await readBody(req, maxBodyBytes)
126
- } catch (e) {
127
- if (e && e.message === 'body too large') {
128
- writeJson(res, 413, { ok: false, error: { code: 'too-large', message: `audio body exceeds limit for max ${cfg.maxFileBytes} bytes` } })
129
- return
130
- }
131
- writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return
132
- }
133
- let payload
134
- try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { payload = {} }
135
-
136
- const dataBase64 = typeof payload.dataBase64 === 'string' ? payload.dataBase64 : ''
137
- if (!dataBase64) { writeJson(res, 400, { ok: false, error: { code: 'no-audio', message: 'no audio data' } }); return }
138
- const mime = typeof payload.mimeType === 'string' && payload.mimeType ? payload.mimeType : 'audio/webm'
139
- const baseModeCfg = payload.mode === 'message' ? cfg.message : cfg.dictation
140
- const modeCfg = Array.isArray(payload.chain)
141
- ? { ...baseModeCfg, chain: payload.chain }
142
- : baseModeCfg
143
-
144
- let bytes
145
- try { bytes = Buffer.from(dataBase64, 'base64') } catch { bytes = null }
146
- if (!bytes || bytes.length === 0) {
147
- writeJson(res, 400, { ok: false, error: { code: 'decode', message: 'failed to decode audio' } }); return
148
- }
149
- if (bytes.length > cfg.maxFileBytes) {
150
- writeJson(res, 413, { ok: false, error: { code: 'too-large', message: `audio is ${bytes.length} bytes, max ${cfg.maxFileBytes}` } }); return
151
- }
152
-
153
- if ((modeCfg.chain || []).some((e) => e.provider === 'local-whisper') && !(await whisperAlive())) {
154
- await startWhisper()
155
- }
156
- if ((modeCfg.chain || []).some((e) => e.provider === 'sensevoice') && !(await sensevoiceAlive())) {
157
- await startSensevoice()
158
- }
159
-
160
- const controller = new AbortController()
161
- const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
162
- try {
163
- const out = await transcribe(modeCfg, bytes, mime, controller.signal, payload.contextWords)
164
- if (payload.mode === 'message' && modeCfg.sessionCommands === true) {
165
- const cmd = sessionCommand(out.text)
166
- if (cmd) { writeJson(res, 200, { ok: true, command: cmd, provider: out.provider, tookMs: out.tookMs }); return }
167
- }
168
- let text = await polishText(out.text, modeCfg, controller.signal)
169
- if (cfg.techJargonCorrection !== false) {
170
- text = applyJargonDictionary(text, cfg.jargonDictionary)
171
- }
172
- let action = null
173
- if (cfg.voiceActions !== false) {
174
- const actionRes = extractVoiceActions(text)
175
- if (actionRes.action) {
176
- text = actionRes.text
177
- action = actionRes.action
178
- }
179
- }
180
- writeJson(res, 200, { ok: true, text, action, provider: out.provider, tookMs: out.tookMs })
181
- } catch (e) {
182
- writeJson(res, 502, { ok: false, error: { code: 'chain', message: String(e && e.message || e) } })
183
- } finally {
184
- clearTimeout(timer)
185
- }
186
- },
187
- }), 'dsh-voice: /transcribe route')
188
- }
1
+ // HTTP host routes registered on ctx.webServer.
2
+ import { readBody, isLoopback, isTrustedCaller } from './http-util.js'
3
+ import { resolveSensevoiceProvider } from './sensevoice-installer.js'
4
+ import { applyVoiceMacros } from './macros.js'
5
+
6
+ function writeJson(res, status, obj) {
7
+ res.writeHead(status, { 'content-type': 'application/json' })
8
+ res.end(JSON.stringify(obj))
9
+ }
10
+
11
+ export function registerHostRoutes(ctx, deps) {
12
+ const {
13
+ live,
14
+ daemons,
15
+ statsTracker,
16
+ transcribe,
17
+ polishText,
18
+ sessionCommand,
19
+ extractVoiceActions,
20
+ applyJargonDictionary,
21
+ KNOWN_KEYS,
22
+ } = deps
23
+
24
+ const {
25
+ whisperAlive,
26
+ sensevoiceAlive,
27
+ getWhisperError,
28
+ getSensevoiceError,
29
+ startWhisper,
30
+ startSensevoice,
31
+ } = daemons
32
+
33
+ ctx.effect(() => ctx.webServer.register({
34
+ kind: 'exact',
35
+ path: '/dsh-voice/status',
36
+ handler: async (req, res) => {
37
+ if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
38
+ if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
39
+ const cfg = live()
40
+ const whisperOk = await whisperAlive()
41
+ const sensevoiceOk = await sensevoiceAlive()
42
+ const whisperError = whisperOk ? null : getWhisperError()
43
+ const sensevoiceError = sensevoiceOk ? null : getSensevoiceError()
44
+ writeJson(res, 200, {
45
+ ok: true,
46
+ whisperRunning: whisperOk,
47
+ whisperError: whisperOk ? null : whisperError,
48
+ sensevoiceRunning: sensevoiceOk,
49
+ sensevoiceError: sensevoiceOk ? null : sensevoiceError,
50
+ hotkey: cfg.hotkey,
51
+ providers: KNOWN_KEYS.concat(
52
+ (Array.isArray(cfg.customProviders) ? cfg.customProviders : [])
53
+ .map((c) => String(c && c.key || '').trim()).filter(Boolean),
54
+ ),
55
+ modes: {
56
+ dictation: {
57
+ chain: cfg.dictation.chain, language: cfg.dictation.language,
58
+ vadSilenceMs: cfg.dictation.vadSilenceMs,
59
+ sendDelayMs: cfg.dictation.sendDelayMs, polish: cfg.dictation.polish,
60
+ stream: cfg.dictation.stream, streamChunkMs: cfg.dictation.streamChunkMs,
61
+ vadAdapt: cfg.dictation.vadAdapt,
62
+ },
63
+ message: {
64
+ chain: cfg.message.chain, language: cfg.message.language,
65
+ autoSendMs: cfg.message.autoSendMs, polish: cfg.message.polish,
66
+ sessionCommands: cfg.message.sessionCommands, polishSend: cfg.message.polishSend, editablePreview: cfg.message.editablePreview !== false,
67
+ },
68
+ },
69
+ beep: cfg.beep,
70
+ localOnly: cfg.localOnly,
71
+ micDeviceId: cfg.micDeviceId,
72
+ noiseGateDb: cfg.noiseGateDb !== undefined ? cfg.noiseGateDb : -45,
73
+ historyLimit: cfg.historyLimit,
74
+ voiceCommands: cfg.voiceCommands,
75
+ wakeWord: String(cfg.wakeWord || ''),
76
+ bargeIn: !!cfg.bargeIn,
77
+ polishBaseUrl: String(cfg.polishBaseUrl || ''),
78
+ noiseSuppression: cfg.noiseSuppression !== false,
79
+ contextGlossary: cfg.contextGlossary !== false,
80
+ sensevoiceUrl: cfg.sensevoiceUrl,
81
+ sensevoiceAutostart: !!cfg.sensevoiceAutostart,
82
+ sensevoiceProvider: cfg.sensevoiceProvider || 'cpu',
83
+ effectiveSensevoiceProvider: resolveSensevoiceProvider(cfg),
84
+ sensevoiceThreads: cfg.sensevoiceThreads || 4,
85
+ webgpuModel: cfg.webgpuModel || 'onnx-community/whisper-tiny',
86
+ deepgramBaseUrl: cfg.deepgramBaseUrl || 'https://api.deepgram.com',
87
+ visualizerStyle: cfg.visualizerStyle || 'liquid-wave',
88
+ gatedTurnTaking: cfg.gatedTurnTaking !== false,
89
+ autoSendVisualRing: cfg.autoSendVisualRing !== false,
90
+ voiceActions: cfg.voiceActions !== false,
91
+ techJargonCorrection: cfg.techJargonCorrection !== false,
92
+ jargonDictionary: Array.isArray(cfg.jargonDictionary) ? cfg.jargonDictionary : [],
93
+ liveInterimPreview: cfg.liveInterimPreview !== false,
94
+ structuredPromptVoice: cfg.structuredPromptVoice !== false,
95
+ dictationMode: cfg.dictationMode || 'clean',
96
+ dictationPrompts: cfg.dictationPrompts || {},
97
+ voiceMacros: Array.isArray(cfg.voiceMacros) ? cfg.voiceMacros : [],
98
+ providerStats: statsTracker.get(),
99
+ })
100
+ },
101
+ }), 'dsh-voice: /status route')
102
+
103
+ ctx.effect(() => ctx.webServer.register({
104
+ kind: 'exact',
105
+ path: '/dsh-voice/polish',
106
+ handler: async (req, res) => {
107
+ if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
108
+ if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
109
+ const cfg = live()
110
+ let raw
111
+ try { raw = await readBody(req, 1024 * 1024) } catch (e) {
112
+ if (e && (e.statusCode === 413 || e.message === 'body too large')) {
113
+ writeJson(res, 413, { ok: false, error: { code: 'too-large', message: 'body exceeds limit of 1MB' } })
114
+ return
115
+ }
116
+ writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
117
+ return
118
+ }
119
+ let payload = {}
120
+ try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /* empty */ }
121
+ const text = typeof payload.text === 'string' ? payload.text.trim() : ''
122
+ if (!text) { writeJson(res, 400, { ok: false, error: { code: 'empty', message: 'text required' } }); return }
123
+ const subMode = payload.subMode || payload.dictationMode || cfg.dictationMode || 'clean'
124
+ const prompt = typeof payload.prompt === 'string' ? payload.prompt : null
125
+ const controller = new AbortController()
126
+ const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
127
+ try {
128
+ const out = await polishText(text, { polish: true, subMode, prompt }, controller.signal)
129
+ writeJson(res, 200, { ok: true, text: out, subMode })
130
+ } catch (e) {
131
+ writeJson(res, 502, { ok: false, error: { code: 'polish', message: String(e && e.message || e) } })
132
+ } finally { clearTimeout(timer) }
133
+ },
134
+ }), 'dsh-voice: /polish route')
135
+
136
+ ctx.effect(() => ctx.webServer.register({
137
+ kind: 'exact',
138
+ path: '/dsh-voice/transcribe',
139
+ handler: async (req, res) => {
140
+ if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
141
+ if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
142
+ const cfg = live()
143
+ const maxBodyBytes = Math.ceil((cfg.maxFileBytes * 4) / 3) + 64 * 1024
144
+ let raw
145
+ try {
146
+ raw = await readBody(req, maxBodyBytes)
147
+ } catch (e) {
148
+ if (e && e.message === 'body too large') {
149
+ writeJson(res, 413, { ok: false, error: { code: 'too-large', message: `audio body exceeds limit for max ${cfg.maxFileBytes} bytes` } })
150
+ return
151
+ }
152
+ writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return
153
+ }
154
+ let payload
155
+ try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { payload = {} }
156
+
157
+ const dataBase64 = typeof payload.dataBase64 === 'string' ? payload.dataBase64 : ''
158
+ if (!dataBase64) { writeJson(res, 400, { ok: false, error: { code: 'no-audio', message: 'no audio data' } }); return }
159
+ const mime = typeof payload.mimeType === 'string' && payload.mimeType ? payload.mimeType : 'audio/webm'
160
+ const baseModeCfg = payload.mode === 'message' ? cfg.message : cfg.dictation
161
+ const modeCfg = Array.isArray(payload.chain)
162
+ ? { ...baseModeCfg, chain: payload.chain }
163
+ : baseModeCfg
164
+
165
+ let bytes
166
+ try { bytes = Buffer.from(dataBase64, 'base64') } catch { bytes = null }
167
+ if (!bytes || bytes.length === 0) {
168
+ writeJson(res, 400, { ok: false, error: { code: 'decode', message: 'failed to decode audio' } }); return
169
+ }
170
+ if (bytes.length > cfg.maxFileBytes) {
171
+ writeJson(res, 413, { ok: false, error: { code: 'too-large', message: `audio is ${bytes.length} bytes, max ${cfg.maxFileBytes}` } }); return
172
+ }
173
+
174
+ if ((modeCfg.chain || []).some((e) => e.provider === 'local-whisper') && !(await whisperAlive())) {
175
+ await startWhisper()
176
+ }
177
+ if ((modeCfg.chain || []).some((e) => e.provider === 'sensevoice') && !(await sensevoiceAlive())) {
178
+ await startSensevoice()
179
+ }
180
+
181
+ const controller = new AbortController()
182
+ const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
183
+ try {
184
+ const out = await transcribe(modeCfg, bytes, mime, controller.signal, payload.contextWords)
185
+ if (payload.mode === 'message' && modeCfg.sessionCommands === true) {
186
+ const cmd = sessionCommand(out.text)
187
+ if (cmd) { writeJson(res, 200, { ok: true, command: cmd, provider: out.provider, tookMs: out.tookMs }); return }
188
+ }
189
+ const subMode = payload.subMode || payload.dictationMode || cfg.dictationMode || 'clean'
190
+ const rawText = out.text
191
+ let text = out.text
192
+
193
+ // Dictation modes & polish (#264): verbatim skips LLM entirely (0 LLM calls)
194
+ if (subMode !== 'verbatim' && (modeCfg.polish === true || payload.polish === true)) {
195
+ text = await polishText(rawText, { ...modeCfg, subMode, prompt: payload.prompt }, controller.signal)
196
+ }
197
+
198
+ // Voice action macros (#167)
199
+ if (Array.isArray(cfg.voiceMacros) && cfg.voiceMacros.length > 0) {
200
+ text = applyVoiceMacros(text, cfg.voiceMacros)
201
+ }
202
+
203
+ if (cfg.techJargonCorrection !== false) {
204
+ text = applyJargonDictionary(text, cfg.jargonDictionary)
205
+ }
206
+ let action = null
207
+ if (cfg.voiceActions !== false) {
208
+ const actionRes = extractVoiceActions(text)
209
+ if (actionRes.action) {
210
+ text = actionRes.text
211
+ action = actionRes.action
212
+ }
213
+ }
214
+ writeJson(res, 200, { ok: true, text, rawText, subMode, action, provider: out.provider, tookMs: out.tookMs })
215
+ } catch (e) {
216
+ writeJson(res, 502, { ok: false, error: { code: 'chain', message: String(e && e.message || e) } })
217
+ } finally {
218
+ clearTimeout(timer)
219
+ }
220
+ },
221
+ }), 'dsh-voice: /transcribe route')
222
+ }
package/lib/macros.js ADDED
@@ -0,0 +1,53 @@
1
+ // lib/macros.js — Dynamic voice action macros parsing & substitution (#167)
2
+
3
+ function escapeRegex(string) {
4
+ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
5
+ }
6
+
7
+ export function normalizeMacroPhrase(str) {
8
+ return String(str || '')
9
+ .trim()
10
+ .toLowerCase()
11
+ .replace(/[.,!?:;]+$/g, '')
12
+ .replace(/^[.,!?:;]+/g, '')
13
+ .trim()
14
+ }
15
+
16
+ /**
17
+ * Apply configured voice macros to recognized text.
18
+ * @param {string} text Raw speech recognition output
19
+ * @param {Array<{trigger: string, template: string}>} macros Configured voice macros
20
+ * @returns {string} Substituted or original text
21
+ */
22
+ export function applyVoiceMacros(text, macros) {
23
+ if (!text || !Array.isArray(macros) || macros.length === 0) return text
24
+ const cleanText = String(text).trim()
25
+ const normText = normalizeMacroPhrase(cleanText)
26
+
27
+ for (const macro of macros) {
28
+ if (!macro || typeof macro.trigger !== 'string' || !macro.template) continue
29
+ const trig = normalizeMacroPhrase(macro.trigger)
30
+ if (!trig) continue
31
+
32
+ // 1. Exact match
33
+ if (normText === trig) {
34
+ return macro.template.trim()
35
+ }
36
+
37
+ // 2. Prefix match
38
+ const trigWordBoundary = new RegExp('^' + escapeRegex(trig) + '(?:\\s+|[:,\\-]\\s*|$)', 'i')
39
+ if (trigWordBoundary.test(normText)) {
40
+ const rest = cleanText.slice(macro.trigger.length).replace(/^[\s,:\-]+/, '').trim()
41
+ let template = String(macro.template).trim()
42
+ if (template.includes('{{text}}') || template.includes('{text}') || template.includes('{input}')) {
43
+ return template
44
+ .replaceAll('{{text}}', rest)
45
+ .replaceAll('{text}', rest)
46
+ .replaceAll('{input}', rest)
47
+ }
48
+ return rest ? (template + '\n' + rest) : template
49
+ }
50
+ }
51
+
52
+ return cleanText
53
+ }
package/lib/polish.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { isLoopback } from './http-util.js'
2
+ import { resolveDictationPrompt } from './dictation-modes.js'
2
3
  // Transcript polish. Pure factory: inject fetch, key resolver, LLM, config.
3
4
  // On any failure return the original text — polish must never block input.
4
5
 
@@ -14,6 +15,9 @@ export function createPolishText({ resolveKey, fetchImpl, getConfig, llm, getAge
14
15
  return async function polishText(text, modeCfg, signal) {
15
16
  const enable = modeCfg && (modeCfg.polish === true || modeCfg.polishSend === true)
16
17
  if (!enable || !text) return text
18
+ const subMode = (modeCfg && (modeCfg.subMode || modeCfg.dictationMode)) || 'clean'
19
+ if (subMode === 'verbatim') return text
20
+
17
21
  const cfg = getConfig() || {}
18
22
  if (cfg.localOnly) {
19
23
  if (!cfg.polishBaseUrl) return text
@@ -24,11 +28,15 @@ export function createPolishText({ resolveKey, fetchImpl, getConfig, llm, getAge
24
28
  return text
25
29
  }
26
30
  }
27
- const ask =
28
- 'Fix the punctuation and spelling of this dictated text and split it into '
29
- + 'paragraphs where the speaker changes topic. Remove filler words ("um", "uh", '
30
- + '"ээ", "ну", "как бы"). Keep the original language, wording and meaning. '
31
- + 'Reply with the polished text only:\n\n' + text
31
+
32
+ const customPrompts = (modeCfg && modeCfg.prompts) || cfg.dictationPrompts || {}
33
+ const customInstruction = (modeCfg && typeof modeCfg.prompt === 'string' && modeCfg.prompt.trim()) ? modeCfg.prompt : null
34
+ const ask = customInstruction
35
+ ? (customInstruction.includes('{text}') ? customInstruction.replaceAll('{text}', text) : `${customInstruction}\n\n${text}`)
36
+ : (resolveDictationPrompt(subMode, customPrompts, text) || text)
37
+
38
+ if (ask === text) return text
39
+
32
40
  try {
33
41
  // Local OpenAI-compatible endpoint when configured.
34
42
  if (cfg.polishBaseUrl) {
@@ -81,4 +89,4 @@ export function createPolishText({ resolveKey, fetchImpl, getConfig, llm, getAge
81
89
  return text
82
90
  }
83
91
  }
84
- }
92
+ }
package/lib/providers.js CHANGED
@@ -98,7 +98,9 @@ export function makeProviders(deps, req) {
98
98
  const model = pickModel(models, 'deepgram')
99
99
  const base = String(cfg.deepgramBaseUrl || 'https://api.deepgram.com').replace(/\/+$/, '')
100
100
  const url = `${base}/v1/listen?model=${encodeURIComponent(model)}`
101
- + (!isAutoLang(lang) ? `&language=${encodeURIComponent(lang)}` : '') + '&smart_format=true'
101
+ + (!isAutoLang(lang) ? `&language=${encodeURIComponent(lang)}` : '')
102
+ + (vocab ? `&keywords=${encodeURIComponent(vocab)}` : '')
103
+ + '&smart_format=true'
102
104
  const res = await fetchImpl(url, {
103
105
  method: 'POST',
104
106
  headers: { authorization: `Token ${key}`, 'content-type': mime },