@goodandready/dsh-voice 0.9.0 → 0.9.2

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 CHANGED
@@ -211,7 +211,7 @@ Open **Settings → Plugins → Plugin settings → Voice** in the Web UI:
211
211
  ## 🤖 Agent Tool & HTTP API
212
212
 
213
213
  ### Agent Tool (`transcribe_audio`)
214
- Registers `transcribe_audio(file_path, language?)` in `ctx.tools`, allowing agents to analyze audio files, interview recordings, and voice notes directly from disk.
214
+ Registers `transcribe_audio(file_path, language?)` in `ctx.tools`, allowing agents to analyze audio files, interview recordings, and voice notes directly from disk. Path containment is strictly enforced against allowed directories (`allowedAudioDirs`, `~/.dsh`, `tmpdir`, `cwd`) with symlink traversal escape prevention and magic-byte audio format validation.
215
215
 
216
216
  ### Internal HTTP Endpoints
217
217
  * `POST /dsh-voice/transcribe` — `{ dataBase64, mimeType, mode }` → `{ ok, text, provider, tookMs }`
@@ -219,6 +219,7 @@ Registers `transcribe_audio(file_path, language?)` in `ctx.tools`, allowing agen
219
219
  * `GET /dsh-voice/status` — Returns daemon status, active chains, SenseVoice.
220
220
  * `GET /dsh-voice/config` — Returns live plugin configuration snapshot.
221
221
  * `PUT /dsh-voice/config` — Updates and persists plugin configuration across network.
222
+ * `GET/POST /dsh-voice/sensevoice-installer` — SenseVoice 1-click model installation status and trigger (protected by `isTrustedCaller`, model paths redacted).
222
223
 
223
224
  ---
224
225
 
package/README.ru.md CHANGED
@@ -211,7 +211,7 @@ dsh plugin --profile web add @goodandready/dsh-voice
211
211
  ## 🤖 Инструмент агента и HTTP API
212
212
 
213
213
  ### Инструмент агента (`transcribe_audio`)
214
- Регистрирует `transcribe_audio(file_path, language?)` в `ctx.tools`, позволяя агентам распознавать аудиофайлы, интервью и записи с диска.
214
+ Регистрирует `transcribe_audio(file_path, language?)` в `ctx.tools` (с проверкой границ разрешённых каталогов `allowedAudioDirs` / `~/.dsh` / `tmpdir` / `cwd`, защитой от симлинков и валидацией сигнатур аудиофайлов), позволяя агентам распознавать аудиофайлы, интервью и записи с диска.
215
215
 
216
216
  ### Внутренние HTTP эндпоинты
217
217
  * `POST /dsh-voice/transcribe` — `{ dataBase64, mimeType, mode }` → `{ ok, text, provider, tookMs }`
package/README.zh.md CHANGED
@@ -131,11 +131,17 @@ v0.8.18 质量审查后的修复批次(Gitea #79–#87,PR #88):
131
131
 
132
132
  ## 🤖 智能体工具与 HTTP 端点
133
133
 
134
+ ### 智能体工具 (`transcribe_audio`)
135
+ 在 `ctx.tools` 中注册 `transcribe_audio(file_path, language?)`,允许智能体直接读取和转写本地音频文件。包含严格的目录边界检查(`allowedAudioDirs`、`~/.dsh`、`tmpdir`、`cwd`)、符号链接越界防护以及音频特征码(magic bytes)校验。
136
+
137
+ ### 内部 HTTP 端点
138
+
134
139
  * `POST /dsh-voice/transcribe` — 音频转写:`{ dataBase64, mimeType, mode }` → `{ ok, text, provider, tookMs }`
135
140
  * `POST /dsh-voice/polish` — 文本模型润色:`{ text }` → `{ ok, text }`
136
141
  * `GET /dsh-voice/status` — 查询后端状态、服务商健康度与 SenseVoice
137
142
  * `GET /dsh-voice/config` — 获取插件运行时配置快照
138
143
  * `PUT /dsh-voice/config` — 跨网络安全持久化更新插件配置
144
+ * `GET/POST /dsh-voice/sensevoice-installer` — SenseVoice 一键模型安装状态与触发接口(受 `isTrustedCaller` 保护,绝对路径脱敏)
139
145
 
140
146
  ---
141
147
 
@@ -0,0 +1,133 @@
1
+ import path from 'node:path'
2
+ import os from 'node:os'
3
+ import { realpath } from 'node:fs/promises'
4
+
5
+ /**
6
+ * Check whether a target path is strictly located within any of the allowed root directories.
7
+ * Boundary-aware prefix match: /tmp does not allow /tmp-other.
8
+ * @param {string} targetPath
9
+ * @param {string[]} allowedRoots
10
+ * @returns {boolean}
11
+ */
12
+ export function isPathUnderRoots(targetPath, allowedRoots) {
13
+ if (!targetPath || !Array.isArray(allowedRoots) || allowedRoots.length === 0) return false
14
+ const normalized = path.resolve(targetPath)
15
+
16
+ return allowedRoots.some((root) => {
17
+ if (!root) return false
18
+ const normRoot = path.resolve(root)
19
+ if (normalized === normRoot) return true
20
+ const prefix = normRoot.endsWith(path.sep) ? normRoot : normRoot + path.sep
21
+ return normalized.startsWith(prefix)
22
+ })
23
+ }
24
+
25
+ /**
26
+ * Return allowed root directories for the transcribe_audio tool.
27
+ * Combines configured audio directories with standard safe locations (.dsh, tmpdir, cwd).
28
+ * @param {object} [cfg]
29
+ * @returns {string[]}
30
+ */
31
+ export function getAllowedAudioRoots(cfg) {
32
+ const custom = Array.isArray(cfg?.allowedAudioDirs) ? cfg.allowedAudioDirs : []
33
+ const defaults = [
34
+ os.tmpdir(),
35
+ path.join(os.homedir(), '.dsh'),
36
+ process.cwd(),
37
+ ]
38
+ return [...custom, ...defaults].map((d) => path.resolve(String(d)))
39
+ }
40
+
41
+ /**
42
+ * Validate audio path before access:
43
+ * 1. Checks lexical path boundary before calling stat or read.
44
+ * 2. Checks realpath to prevent symlink traversal escapes.
45
+ * @param {string} filePath
46
+ * @param {string[]} allowedRoots
47
+ * @returns {Promise<{ resolved: string, real: string }>}
48
+ */
49
+ export async function validateAudioPath(filePath, allowedRoots) {
50
+ const raw = String(filePath || '').trim()
51
+ if (!raw) {
52
+ throw new Error('transcribe_audio: file_path is required')
53
+ }
54
+
55
+ const resolved = path.resolve(raw)
56
+ if (!isPathUnderRoots(resolved, allowedRoots)) {
57
+ throw new Error(`transcribe_audio: path is outside allowed audio directories: ${raw}`)
58
+ }
59
+
60
+ let real = ''
61
+ try {
62
+ real = await realpath(resolved)
63
+ } catch (err) {
64
+ throw new Error(`transcribe_audio: file not found: ${raw}`)
65
+ }
66
+
67
+ if (!isPathUnderRoots(real, allowedRoots)) {
68
+ throw new Error(`transcribe_audio: resolved symlink is outside allowed audio directories: ${raw}`)
69
+ }
70
+
71
+ return { resolved, real }
72
+ }
73
+
74
+ /**
75
+ * Sniff audio format from magic bytes.
76
+ * Supports WAV, MP3, OGG, FLAC, WebM, and M4A/MP4.
77
+ * @param {Buffer|Uint8Array} buffer
78
+ * @returns {string|null} Detected MIME type or null
79
+ */
80
+ export function sniffAudioFormat(buffer) {
81
+ if (!buffer || buffer.length < 4) return null
82
+
83
+ // WAV: 'RIFF' .... 'WAVE'
84
+ if (buffer.length >= 12) {
85
+ const riff = String.fromCharCode(buffer[0], buffer[1], buffer[2], buffer[3])
86
+ const wave = String.fromCharCode(buffer[8], buffer[9], buffer[10], buffer[11])
87
+ if (riff === 'RIFF' && wave === 'WAVE') return 'audio/wav'
88
+ }
89
+
90
+ // OGG: 'OggS'
91
+ if (String.fromCharCode(buffer[0], buffer[1], buffer[2], buffer[3]) === 'OggS') {
92
+ return 'audio/ogg'
93
+ }
94
+
95
+ // FLAC: 'fLaC'
96
+ if (String.fromCharCode(buffer[0], buffer[1], buffer[2], buffer[3]) === 'fLaC') {
97
+ return 'audio/flac'
98
+ }
99
+
100
+ // WebM / Matroska: 0x1A 0x45 0xDF 0xA3 (EBML)
101
+ if (buffer[0] === 0x1a && buffer[1] === 0x45 && buffer[2] === 0xdf && buffer[3] === 0xa3) {
102
+ return 'audio/webm'
103
+ }
104
+
105
+ // MP3: ID3 header or frame sync 0xFF 0xFB/F3/F2
106
+ if (buffer.length >= 3 && String.fromCharCode(buffer[0], buffer[1], buffer[2]) === 'ID3') {
107
+ return 'audio/mpeg'
108
+ }
109
+ if (buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0) {
110
+ return 'audio/mpeg'
111
+ }
112
+
113
+ // M4A / MP4: '....ftyp'
114
+ if (buffer.length >= 8) {
115
+ const ftyp = String.fromCharCode(buffer[4], buffer[5], buffer[6], buffer[7])
116
+ if (ftyp === 'ftyp') return 'audio/mp4'
117
+ }
118
+
119
+ return null
120
+ }
121
+
122
+ /**
123
+ * Enforce audio MIME/content validation independently of file extension.
124
+ * @param {Buffer|Uint8Array} buffer
125
+ * @returns {string} Detected audio MIME type
126
+ */
127
+ export function validateAudioContent(buffer) {
128
+ const mime = sniffAudioFormat(buffer)
129
+ if (!mime) {
130
+ throw new Error('transcribe_audio: unsupported or invalid audio file format (magic bytes check failed)')
131
+ }
132
+ return mime
133
+ }
package/lib/client.js CHANGED
@@ -225,6 +225,7 @@ window.__ModuleLoader__.load({
225
225
  'jargonAdd': 'Add replacement',
226
226
  'jargonFrom': 'Spoken slang',
227
227
  'jargonTo': 'Correct spelling',
228
+ 'jargonPlaceholder': 'k8s -> kubernetes\npr -> pull request',
228
229
  }
229
230
 
230
231
  const zh = {
@@ -418,6 +419,7 @@ window.__ModuleLoader__.load({
418
419
  'jargonAdd': '添加替换项',
419
420
  'jargonFrom': '口语词汇',
420
421
  'jargonTo': '标准拼写',
422
+ 'jargonPlaceholder': 'k8s -> kubernetes\npr -> pull request',
421
423
  }
422
424
 
423
425
  // Strings are also needed outside components — in recording handlers and
@@ -545,15 +547,15 @@ window.__ModuleLoader__.load({
545
547
  const trimmed = String(text || '').trim().toLowerCase()
546
548
  const { buttons, input } = promptInfo
547
549
  if (buttons && buttons.length > 0) {
548
- if (/^(1|первый|first)$/i.test(trimmed) && buttons[0]) { buttons[0].click(); return true }
549
- if (/^(2|второй|second)$/i.test(trimmed) && buttons[1]) { buttons[1].click(); return true }
550
- if (/^(3|третий|third)$/i.test(trimmed) && buttons[2]) { buttons[2].click(); return true }
551
- if (/^(да|yes|ok|confirm|согласен)$/i.test(trimmed)) {
552
- const yesBtn = buttons.find((b) => /да|yes|ok|confirm/i.test(b.textContent || ''))
550
+ if (/^(1|первый|first|第一)$/i.test(trimmed) && buttons[0]) { buttons[0].click(); return true }
551
+ if (/^(2|второй|second|第二)$/i.test(trimmed) && buttons[1]) { buttons[1].click(); return true }
552
+ if (/^(3|третий|third|第三)$/i.test(trimmed) && buttons[2]) { buttons[2].click(); return true }
553
+ if (/^(да|yes|ok|confirm|согласен|是|好|确认|同意)$/i.test(trimmed)) {
554
+ const yesBtn = buttons.find((b) => /да|yes|ok|confirm|是|好|确认|同意/i.test(b.textContent || ''))
553
555
  if (yesBtn) { yesBtn.click(); return true }
554
556
  }
555
- if (/^(нет|no|отмена|cancel)$/i.test(trimmed)) {
556
- const noBtn = buttons.find((b) => /нет|no|cancel|отмена/i.test(b.textContent || ''))
557
+ if (/^(нет|no|отмена|cancel|不|否|取消)$/i.test(trimmed)) {
558
+ const noBtn = buttons.find((b) => /нет|no|cancel|отмена|不|否|取消/i.test(b.textContent || ''))
557
559
  if (noBtn) { noBtn.click(); return true }
558
560
  }
559
561
  }
@@ -695,8 +697,8 @@ window.__ModuleLoader__.load({
695
697
  if (!res.ok || !parsed || !parsed.ok) {
696
698
  throw new Error((parsed && parsed.error && parsed.error.message) || `HTTP ${res.status}`)
697
699
  }
698
- if (parsed.command) return { command: parsed.command, text: '' }
699
- return { text: String(parsed.text || '').trim(), command: null }
700
+ if (parsed.command) return { command: parsed.command, text: '', action: null }
701
+ return { text: String(parsed.text || '').trim(), command: null, action: parsed.action || null }
700
702
  }
701
703
 
702
704
  function tidyPhrase(text) {
@@ -891,7 +893,10 @@ window.__ModuleLoader__.load({
891
893
  chainsPromise = fetch('/dsh-voice/status', { cache: 'no-store' })
892
894
  .then((res) => res.json())
893
895
  .then((data) => (data && data.modes) || {})
894
- .catch(() => ({}))
896
+ .catch(() => {
897
+ chainsPromise = null
898
+ return {}
899
+ })
895
900
  }
896
901
  return chainsPromise.then((modes) => {
897
902
  const row = modes[mode] || {}
@@ -2185,6 +2190,7 @@ window.__ModuleLoader__.load({
2185
2190
  const [err, setErr] = React.useState('')
2186
2191
  const [serverConfig, setServerConfig] = React.useState(null)
2187
2192
  const [configLoading, setConfigLoading] = React.useState(true)
2193
+ const [jargonInput, setJargonInput] = React.useState(null)
2188
2194
 
2189
2195
  React.useEffect(() => {
2190
2196
  let alive = true
@@ -2503,6 +2509,21 @@ window.__ModuleLoader__.load({
2503
2509
  const save = async () => {
2504
2510
  setErr(''); setSaved(false)
2505
2511
  if (!draft) return
2512
+ if (jargonInput !== null) {
2513
+ const lines = jargonInput.split('\n')
2514
+ const parsed = lines
2515
+ .map((line) => {
2516
+ const parts = line.split(/->|=|:/)
2517
+ if (parts.length >= 2) {
2518
+ const from = parts[0].trim()
2519
+ const to = parts.slice(1).join(':').trim()
2520
+ if (from && to) return { from, to }
2521
+ }
2522
+ return null
2523
+ })
2524
+ .filter(Boolean)
2525
+ draft.jargonDictionary = parsed
2526
+ }
2506
2527
 
2507
2528
  let httpOk = false
2508
2529
  let httpError = null
@@ -3025,17 +3046,25 @@ window.__ModuleLoader__.load({
3025
3046
  React.createElement('textarea', {
3026
3047
  rows: 3,
3027
3048
  disabled: !writable,
3028
- placeholder: 'кубик -> k8s\nворктри -> worktree',
3029
- value: Array.isArray(draft && draft.jargonDictionary)
3030
- ? draft.jargonDictionary.map((item) => item && item.from ? `${item.from} -> ${item.to}` : '').filter(Boolean).join('\n')
3031
- : '',
3049
+ placeholder: t('jargonPlaceholder'),
3050
+ value: jargonInput !== null
3051
+ ? jargonInput
3052
+ : (Array.isArray(draft && draft.jargonDictionary)
3053
+ ? draft.jargonDictionary.map((item) => item && item.from ? `${item.from} -> ${item.to}` : '').filter(Boolean).join('\n')
3054
+ : ''),
3032
3055
  onChange: (e) => {
3033
- const lines = e.target.value.split('\n')
3056
+ setJargonInput(e.target.value)
3057
+ },
3058
+ onBlur: () => {
3059
+ if (jargonInput === null) return
3060
+ const lines = jargonInput.split('\n')
3034
3061
  const parsed = lines
3035
3062
  .map((line) => {
3036
3063
  const parts = line.split(/->|=|:/)
3037
3064
  if (parts.length >= 2) {
3038
- return { from: parts[0].trim(), to: parts.slice(1).join(':').trim() }
3065
+ const from = parts[0].trim()
3066
+ const to = parts.slice(1).join(':').trim()
3067
+ if (from && to) return { from, to }
3039
3068
  }
3040
3069
  return null
3041
3070
  })
@@ -3108,7 +3137,7 @@ window.__ModuleLoader__.load({
3108
3137
  React.createElement('div', { className: 'cb-section-desc' }, t('versionHint')),
3109
3138
  React.createElement('div', { className: 'cb-row', style: { alignItems: 'center', gap: '12px' } },
3110
3139
  React.createElement('span', { className: 'cb-badge cb-badge-ok' },
3111
- 'v' + ((updaterStatus && updaterStatus.currentVersion) || '0.9.0')
3140
+ 'v' + ((updaterStatus && updaterStatus.currentVersion) || '0.9.2')
3112
3141
  ),
3113
3142
  updaterStatus && updaterStatus.updateAvailable
3114
3143
  ? React.createElement('span', { className: 'cb-badge cb-badge-warn' },
package/lib/http-util.js CHANGED
@@ -50,8 +50,7 @@ export function isTrustedCaller(req) {
50
50
  if (isLoopback(remote)) return true
51
51
 
52
52
  const secSite = String(req.headers?.['sec-fetch-site'] || '').toLowerCase()
53
- if (secSite === 'same-origin' || secSite === 'same-site') return true
54
- if (secSite === 'cross-site') return false
53
+ if (secSite === 'cross-site' || secSite === 'same-site') return false
55
54
 
56
55
  const host = String(req.headers?.host || '').toLowerCase()
57
56
  const origin = String(req.headers?.origin || '').trim().toLowerCase()
@@ -63,6 +62,8 @@ export function isTrustedCaller(req) {
63
62
  return false
64
63
  }
65
64
 
65
+ if (secSite === 'same-origin') return true
66
+
66
67
  const referer = String(req.headers?.referer || '').trim().toLowerCase()
67
68
  if (referer) {
68
69
  try {
package/lib/index.js CHANGED
@@ -27,13 +27,11 @@ import { createPolishText } from './polish.js'
27
27
  import { createUserMessage } from '@deepseek-ai/dsh-llm'
28
28
  import { buildProviderOrder, sessionCommand } from './transcribe-core.js'
29
29
  import { registerPluginUpdater } from './updater.js'
30
- import { registerSensevoiceInstaller } from './sensevoice-installer.js'
30
+ import { registerSensevoiceInstaller, findTokensFile } from './sensevoice-installer.js'
31
31
  import { createConfigReader } from './config-compat.js'
32
32
  import { registerConfigRoutes } from './config-routes.js'
33
-
34
- function isAutoLang(lang) {
35
- return !lang || lang === 'auto' || String(lang).includes(',')
36
- }
33
+ import { getAllowedAudioRoots, validateAudioPath, validateAudioContent } from './audio-guard.js'
34
+ import { isAutoLang } from './provider-http.js'
37
35
 
38
36
  export const name = '@goodandready/dsh-voice'
39
37
  export const NS = 'dsh-voice'
@@ -136,6 +134,7 @@ const BaseConfig = z.object({
136
134
  ffmpegBin: z.string().default('ffmpeg')
137
135
  .description('ffmpeg used to convert browser webm/opus into the WAV that whisper.cpp requires.'),
138
136
  timeoutMs: z.number().default(120000),
137
+ allowedAudioDirs: z.array(z.string()).default([]),
139
138
  maxFileBytes: z.number().default(25 * 1024 * 1024),
140
139
  normalizeTranscript: z.boolean().default(false)
141
140
  .description('transcribe_audio: convert spoken numbers to digits and tidy punctuation.'),
@@ -322,9 +321,12 @@ export function apply(ctx, baseConfig) {
322
321
  try {
323
322
  let port = '6006'
324
323
  try { port = new URL(cfg.sensevoiceUrl).port || '6006' } catch { /* invalid URL */ }
324
+ const tokensPath = findTokensFile(path.dirname(cfg.sensevoiceModel))
325
+ const tokensArg = tokensPath ? ` --tokens=${JSON.stringify(tokensPath)}` : ''
325
326
  const spec = ctx.shell.resolve({
326
327
  command: `${JSON.stringify(cfg.sensevoiceBin)}`
327
328
  + ` --sense-voice-model=${JSON.stringify(cfg.sensevoiceModel)}`
329
+ + tokensArg
328
330
  + ` --port=${port} --num-threads=4`,
329
331
  timeoutMs: 0,
330
332
  stdoutMaxBytes: 4 * 1024 * 1024,
@@ -593,7 +595,7 @@ export function apply(ctx, baseConfig) {
593
595
  },
594
596
  }), 'dsh-voice: /transcribe route')
595
597
 
596
- ctx.tools.register(
598
+ ctx.effect(() => ctx.tools.register(
597
599
  defineTool({
598
600
  name: 'transcribe_audio',
599
601
  description:
@@ -624,14 +626,17 @@ export function apply(ctx, baseConfig) {
624
626
  const cfg = live()
625
627
  const filePath = String(args.file_path || '').trim()
626
628
  if (!filePath) throw new Error('transcribe_audio: file_path is required')
627
- const info = await stat(filePath).catch(() => null)
629
+ const allowedRoots = getAllowedAudioRoots(cfg)
630
+ const { real } = await validateAudioPath(filePath, allowedRoots)
631
+ const info = await stat(real).catch(() => null)
628
632
  if (!info) throw new Error(`transcribe_audio: file not found: ${filePath}`)
629
633
  if (info.size > cfg.maxFileBytes) {
630
634
  throw new Error(`transcribe_audio: file too large (${info.size} bytes, max ${cfg.maxFileBytes})`)
631
635
  }
632
636
  if (info.size < 44) throw new Error('transcribe_audio: file is empty or too small')
633
- const mime = MIME_BY_EXT[path.extname(filePath).toLowerCase()] || 'audio/wav'
634
- const bytes = await readFile(filePath)
637
+ const bytes = await readFile(real)
638
+ const sniffedMime = validateAudioContent(bytes)
639
+ const mime = sniffedMime || MIME_BY_EXT[path.extname(real).toLowerCase()] || 'audio/wav'
635
640
  const modeCfg = { ...cfg.message, language: String(args.language || cfg.message.language) }
636
641
  let raw = await transcribe(modeCfg, bytes, mime, exec.signal)
637
642
  if (raw && raw.text) raw = { ...raw, text: await polishText(raw.text, modeCfg, exec.signal) }
@@ -644,7 +649,7 @@ export function apply(ctx, baseConfig) {
644
649
  return out
645
650
  },
646
651
  }),
647
- )
652
+ ), 'dsh-voice: transcribe_audio tool')
648
653
 
649
654
  ctx.effect(() => () => {
650
655
  if (child) { try { child.kill && child.kill() } catch { /* already dead */ } }
package/lib/normalize.js CHANGED
@@ -128,28 +128,28 @@ export function extractVoiceActions(text) {
128
128
  if (!raw) return { text: '', action: null }
129
129
 
130
130
  const lower = raw.toLowerCase().replace(/[.!?…]+$/, '').trim()
131
- if (/^(отправь|отправить|send|send it|send message)$/i.test(lower)) {
131
+ if (/^(отправь|отправить|send|send it|send message|发送|发出)$/i.test(lower)) {
132
132
  return { text: '', action: 'send' }
133
133
  }
134
- if (/^(отмена|cancel|discard)$/i.test(lower)) {
134
+ if (/^(отмена|cancel|discard|取消|算了)$/i.test(lower)) {
135
135
  return { text: '', action: 'cancel' }
136
136
  }
137
- if (/^(очисти|очистить|clear|clear composer)$/i.test(lower)) {
137
+ if (/^(очисти|очистить|clear|clear composer|清空|清除)$/i.test(lower)) {
138
138
  return { text: '', action: 'clear' }
139
139
  }
140
- if (/^(новая строка|с новой строки|new line|newline)$/i.test(lower)) {
140
+ if (/^(новая строка|с новой строки|new line|newline|换行|新行)$/i.test(lower)) {
141
141
  return { text: '', action: 'newline' }
142
142
  }
143
143
 
144
- const trailingSend = /[\s,]+(отправь|отправить|send|send it)[.!?…]*$/i
144
+ const trailingSend = /[\s,]+(отправь|отправить|send|send it|发送|发出)[.!?…]*$/i
145
145
  if (trailingSend.test(raw)) {
146
146
  return { text: raw.replace(trailingSend, '').trim(), action: 'send' }
147
147
  }
148
- const trailingCancel = /[\s,]+(отмена|cancel)[.!?…]*$/i
148
+ const trailingCancel = /[\s,]+(отмена|cancel|取消|算了)[.!?…]*$/i
149
149
  if (trailingCancel.test(raw)) {
150
150
  return { text: raw.replace(trailingCancel, '').trim(), action: 'cancel' }
151
151
  }
152
- const trailingNewline = /[\s,]+(новая строка|с новой строки|new line)[.!?…]*$/i
152
+ const trailingNewline = /[\s,]+(новая строка|с новой строки|new line|换行|新行)[.!?…]*$/i
153
153
  if (trailingNewline.test(raw)) {
154
154
  return { text: raw.replace(trailingNewline, '').trim() + '\n', action: 'newline' }
155
155
  }
@@ -2,7 +2,7 @@
2
2
  // Downloads pre-quantized ONNX model and configures autostart on the host.
3
3
 
4
4
  import { createWriteStream, existsSync, readdirSync } from 'node:fs'
5
- import { mkdir, stat } from 'node:fs/promises'
5
+ import { mkdir, stat, unlink } from 'node:fs/promises'
6
6
  import { homedir } from 'node:os'
7
7
  import path from 'node:path'
8
8
  import { spawn } from 'node:child_process'
@@ -41,6 +41,13 @@ export function findModelFile(dir) {
41
41
  return null
42
42
  }
43
43
 
44
+ export function redactModelPath(filePath) {
45
+ if (!filePath) return ""
46
+ const str = String(filePath).replace(/\\/g, "/")
47
+ const idx = str.indexOf(".dsh/models/")
48
+ if (idx !== -1) return "~/" + str.slice(idx)
49
+ return path.basename(str)
50
+ }
44
51
  export function findTokensFile(dir) {
45
52
  if (!dir || !existsSync(dir)) return null
46
53
  try {
@@ -68,8 +75,9 @@ export async function getSensevoiceStatus(cfg, isAliveFn) {
68
75
  return {
69
76
  installed,
70
77
  running,
71
- modelPath: modelFile || '',
72
- tokensPath: tokensFile || '',
78
+ modelPath: redactModelPath(modelFile),
79
+ tokensPath: redactModelPath(tokensFile),
80
+ path: installed ? '~/.dsh/models/sensevoice' : '',
73
81
  installing: installerState.installing,
74
82
  progress: installerState.progress,
75
83
  step: installerState.step,
@@ -153,6 +161,7 @@ export async function startSensevoiceInstall(ctx, cfg, updateConfigFn, startSens
153
161
  installerState.step = 'extracting'
154
162
  installerState.progress = 95
155
163
  await extractTarBz2(archivePath, modelDir)
164
+ await unlink(archivePath).catch(() => {})
156
165
 
157
166
  const modelFile = findModelFile(modelDir)
158
167
  if (!modelFile) throw new Error('model.int8.onnx not found after extraction')
@@ -175,6 +184,7 @@ export async function startSensevoiceInstall(ctx, cfg, updateConfigFn, startSens
175
184
 
176
185
  return { ok: true, modelPath: modelFile }
177
186
  } catch (err) {
187
+ await unlink(archivePath).catch(() => {})
178
188
  installerState.installing = false
179
189
  installerState.error = String(err?.message || err)
180
190
  installerState.step = 'failed'
@@ -189,6 +199,11 @@ export function registerSensevoiceInstaller(ctx, { liveConfig, isAlive, updateCo
189
199
  kind: 'exact',
190
200
  path: '/dsh-voice/sensevoice-installer',
191
201
  handler: async (req, res) => {
202
+ if (!isTrustedCaller(req)) {
203
+ writeJson(res, 403, { ok: false, error: 'Forbidden: untrusted caller' })
204
+ return
205
+ }
206
+
192
207
  if (req.method === 'GET') {
193
208
  const status = await getSensevoiceStatus(liveConfig(), isAlive)
194
209
  writeJson(res, 200, status)
@@ -196,13 +211,9 @@ export function registerSensevoiceInstaller(ctx, { liveConfig, isAlive, updateCo
196
211
  }
197
212
 
198
213
  if (req.method === 'POST') {
199
- if (!isTrustedCaller(req)) {
200
- writeJson(res, 403, { ok: false, error: 'Forbidden: untrusted caller' })
201
- return
202
- }
203
-
214
+ const safeState = { ...installerState, modelPath: redactModelPath(installerState.modelPath) }
204
215
  if (installerState.installing) {
205
- writeJson(res, 409, { ok: false, error: 'Installation already in progress', state: installerState })
216
+ writeJson(res, 409, { ok: false, error: 'Installation already in progress', state: safeState })
206
217
  return
207
218
  }
208
219
 
@@ -211,7 +222,7 @@ export function registerSensevoiceInstaller(ctx, { liveConfig, isAlive, updateCo
211
222
  ctx.logger?.warn?.(`SenseVoice 1-click install error: ${String(err)}`)
212
223
  })
213
224
 
214
- writeJson(res, 202, { ok: true, message: 'SenseVoice installation started', state: installerState })
225
+ writeJson(res, 202, { ok: true, message: 'SenseVoice installation started', state: safeState })
215
226
  return
216
227
  }
217
228
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
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",
@@ -43,6 +43,7 @@
43
43
  "scripts": {
44
44
  "test": "node --test test/*.test.mjs",
45
45
  "build:client": "node scripts/build-client.mjs",
46
+ "build:check": "node scripts/build-client.mjs --check",
46
47
  "pretest": "node scripts/build-client.mjs",
47
48
  "lint": "node --check lib/*.js scripts/*.mjs",
48
49
  "pack:check": "node scripts/pack-check.mjs"