@goodandready/dsh-voice 0.9.30 → 0.9.32

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,25 +1,41 @@
1
- // lib/host-routes.js — Host HTTP routes for dsh-voice status, polish, and transcribe
2
- import { writeJson, readBody, isTrustedCaller } from './http-util.js'
1
+ // HTTP host routes registered on ctx.webServer.
2
+ import { readBody, isLoopback, isTrustedCaller } from './http-util.js'
3
3
  import { resolveSensevoiceProvider } from './sensevoice-installer.js'
4
+ import { applyVoiceMacros } from './macros.js'
4
5
 
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
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
17
32
 
18
33
  ctx.effect(() => ctx.webServer.register({
19
34
  kind: 'exact',
20
35
  path: '/dsh-voice/status',
21
36
  handler: async (req, res) => {
22
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 }
23
39
  const cfg = live()
24
40
  const whisperOk = await whisperAlive()
25
41
  const sensevoiceOk = await sensevoiceAlive()
@@ -47,7 +63,7 @@ export function registerHostRoutes(ctx, {
47
63
  message: {
48
64
  chain: cfg.message.chain, language: cfg.message.language,
49
65
  autoSendMs: cfg.message.autoSendMs, polish: cfg.message.polish,
50
- sessionCommands: cfg.message.sessionCommands, polishSend: cfg.message.polishSend,
66
+ sessionCommands: cfg.message.sessionCommands, polishSend: cfg.message.polishSend, editablePreview: cfg.message.editablePreview !== false,
51
67
  },
52
68
  },
53
69
  beep: cfg.beep,
@@ -76,6 +92,9 @@ export function registerHostRoutes(ctx, {
76
92
  jargonDictionary: Array.isArray(cfg.jargonDictionary) ? cfg.jargonDictionary : [],
77
93
  liveInterimPreview: cfg.liveInterimPreview !== false,
78
94
  structuredPromptVoice: cfg.structuredPromptVoice !== false,
95
+ dictationMode: cfg.dictationMode || 'clean',
96
+ dictationPrompts: cfg.dictationPrompts || {},
97
+ voiceMacros: Array.isArray(cfg.voiceMacros) ? cfg.voiceMacros : [],
79
98
  providerStats: statsTracker.get(),
80
99
  })
81
100
  },
@@ -101,11 +120,13 @@ export function registerHostRoutes(ctx, {
101
120
  try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /* empty */ }
102
121
  const text = typeof payload.text === 'string' ? payload.text.trim() : ''
103
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
104
125
  const controller = new AbortController()
105
126
  const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
106
127
  try {
107
- const out = await polishText(text, { polish: true }, controller.signal)
108
- writeJson(res, 200, { ok: true, text: out })
128
+ const out = await polishText(text, { polish: true, subMode, prompt }, controller.signal)
129
+ writeJson(res, 200, { ok: true, text: out, subMode })
109
130
  } catch (e) {
110
131
  writeJson(res, 502, { ok: false, error: { code: 'polish', message: String(e && e.message || e) } })
111
132
  } finally { clearTimeout(timer) }
@@ -165,7 +186,20 @@ export function registerHostRoutes(ctx, {
165
186
  const cmd = sessionCommand(out.text)
166
187
  if (cmd) { writeJson(res, 200, { ok: true, command: cmd, provider: out.provider, tookMs: out.tookMs }); return }
167
188
  }
168
- let text = await polishText(out.text, modeCfg, controller.signal)
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
+
169
203
  if (cfg.techJargonCorrection !== false) {
170
204
  text = applyJargonDictionary(text, cfg.jargonDictionary)
171
205
  }
@@ -177,7 +211,7 @@ export function registerHostRoutes(ctx, {
177
211
  action = actionRes.action
178
212
  }
179
213
  }
180
- writeJson(res, 200, { ok: true, text, action, provider: out.provider, tookMs: out.tookMs })
214
+ writeJson(res, 200, { ok: true, text, rawText, subMode, action, provider: out.provider, tookMs: out.tookMs })
181
215
  } catch (e) {
182
216
  writeJson(res, 502, { ok: false, error: { code: 'chain', message: String(e && e.message || e) } })
183
217
  } finally {
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 },
package/lib/schema.js CHANGED
@@ -31,6 +31,10 @@ export const CustomProvider = z.object({
31
31
  + 'Empty means the built-in one.'),
32
32
  })
33
33
 
34
+ export const VoiceMacro = z.object({
35
+ trigger: z.string().default(''),
36
+ template: z.string().default(''),
37
+ })
34
38
 
35
39
  export const BaseConfig = z.object({
36
40
  dictation: z.object({
@@ -62,7 +66,8 @@ export const BaseConfig = z.object({
62
66
  .description('Cancel window before the recognized text is sent to the agent.'),
63
67
  sessionCommands: z.boolean().default(false)
64
68
  .description('Voice session commands: a clean "send", "cancel", "stop", "continue" does not become text — it drives the composer/session.'),
65
- polishSend: z.boolean().default(false)
69
+ polishSend: z.boolean().default(false),
70
+ editablePreview: z.boolean().default(true)
66
71
  .description('Polish the whole composed draft through the model right before sending, not just single phrases.'),
67
72
  }).default({}),
68
73
  hotkey: z.string().default('Control')
@@ -155,6 +160,14 @@ export const BaseConfig = z.object({
155
160
  .description('Display real-time interim speech preview (ghost text) in the recording pill.'),
156
161
  structuredPromptVoice: z.boolean().default(true)
157
162
  .description('Target voice dictation and numeric choices to active DSH structured prompt modals.'),
163
+ dictationMode: z.union(['verbatim', 'clean', 'task', 'note']).default('clean')
164
+ .description('Dictation post-processing mode: "verbatim", "clean", "task", or "note".'),
165
+ dictationPrompts: z.object({
166
+ clean: z.string().default(''),
167
+ task: z.string().default(''),
168
+ note: z.string().default(''),
169
+ }).default({})
170
+ .description('Custom prompt templates for dictation modes.'),
171
+ voiceMacros: z.array(VoiceMacro).default([])
172
+ .description('User voice action macros (spoken trigger phrase -> structured prompt replacement).'),
158
173
  })
159
-
160
-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.9.30",
3
+ "version": "0.9.32",
4
4
  "description": "Voice input for DeepSeek Harness: dictation chunked by pauses and voice messages, each with its own provider fallback chain (Deepgram, Groq, HuggingFace, local whisper.cpp, plus any OpenAI-compatible API of your own).",
5
5
  "license": "MIT",
6
6
  "type": "module",