@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.
- package/README.md +3 -0
- package/README.ru.md +3 -0
- package/README.zh.md +3 -0
- package/lib/client.js +627 -93
- package/lib/dictation-modes.js +28 -0
- package/lib/host-routes.js +53 -19
- package/lib/macros.js +53 -0
- package/lib/polish.js +14 -6
- package/lib/providers.js +3 -1
- package/lib/schema.js +16 -3
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/lib/host-routes.js
CHANGED
|
@@ -1,25 +1,41 @@
|
|
|
1
|
-
//
|
|
2
|
-
import {
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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)}` : '')
|
|
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.
|
|
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",
|