dsh-speak 1.0.0 → 1.2.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.
- package/LICENSE +21 -21
- package/README.md +289 -186
- package/README.zh-CN.md +274 -177
- package/adapters/dsh/install.ps1 +81 -81
- package/adapters/dsh/speech-hook.js +142 -133
- package/docs/DESIGN.md +270 -245
- package/docs/DESIGN.zh-CN.md +253 -232
- package/engine/speak.ps1 +76 -76
- package/engine/speak.sh +89 -0
- package/engine/speech-prompt.ps1 +22 -22
- package/engine/speech-summary.ps1 +27 -27
- package/package.json +46 -43
|
@@ -1,133 +1,142 @@
|
|
|
1
|
-
// speech-hook.js — DSH web adapter: auto voice-announce the final assistant reply
|
|
2
|
-
// ==============================================================================
|
|
3
|
-
// Listens to the session event stream (session/event), watches for
|
|
4
|
-
// assistant/message append events, extracts the final reply text, and hands it to
|
|
5
|
-
// engine/speak.ps1
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// name: '
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if (
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
1
|
+
// speech-hook.js — DSH web adapter: auto voice-announce the final assistant reply
|
|
2
|
+
// ==============================================================================
|
|
3
|
+
// Listens to the session event stream (session/event), watches for
|
|
4
|
+
// assistant/message append events, extracts the final reply text, and hands it to
|
|
5
|
+
// the speech engine (engine/speak.ps1 on Windows, engine/speak.sh on macOS)
|
|
6
|
+
// through a hidden, non-blocking child process.
|
|
7
|
+
//
|
|
8
|
+
// Trigger semantics:
|
|
9
|
+
// * only events with a `text` block are announced (reasoning / tool_use blocks
|
|
10
|
+
// are skipped)
|
|
11
|
+
// * when a tool/call event arrives, that round's assistant text is treated as
|
|
12
|
+
// process narration, so any pending announcement is cancelled
|
|
13
|
+
// * a final reply with no following tool/call is announced after a throttle
|
|
14
|
+
// delay (merges multi-step messages from the same reply)
|
|
15
|
+
//
|
|
16
|
+
// Registration: add an insert entry in ~/.dsh/profiles/web/cordis.patch.yml —
|
|
17
|
+
// - insert:
|
|
18
|
+
// - id: speech-hook
|
|
19
|
+
// name: 'dsh-speak' # npm package (preferred)
|
|
20
|
+
// name: 'file:///C:/Users/<your-username>/.../speech-hook.js' # repo/file install (replace <your-username>)
|
|
21
|
+
// (run adapters/dsh/install.ps1 to do this automatically for the file install)
|
|
22
|
+
//
|
|
23
|
+
// Configuration (environment variables, optional):
|
|
24
|
+
// DSH_SPEAK_ENGINE path to the engine script (speak.ps1 / speak.sh)
|
|
25
|
+
// (default: <package>/engine/<platform script>, then
|
|
26
|
+
// ~/.dsh/hooks/<platform script>)
|
|
27
|
+
// DSH_SPEAK_THROTTLE_MS throttle delay before announcing (default: 1500)
|
|
28
|
+
'use strict'
|
|
29
|
+
const { spawn } = require('child_process')
|
|
30
|
+
const fs = require('fs')
|
|
31
|
+
const os = require('os')
|
|
32
|
+
const path = require('path')
|
|
33
|
+
|
|
34
|
+
// diagnostic log (for troubleshooting; safe to remove once stable)
|
|
35
|
+
const LOG = path.join(os.tmpdir(), 'dsh-speech-hook.log')
|
|
36
|
+
function log(...args) {
|
|
37
|
+
try {
|
|
38
|
+
fs.appendFileSync(LOG, `[${new Date().toISOString()}] ${args.join(' ')}\n`)
|
|
39
|
+
} catch (e) { /* ignore */ }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const THROTTLE_MS = Number(process.env.DSH_SPEAK_THROTTLE_MS) || 1500
|
|
43
|
+
const ENGINE_NAME = process.platform === 'darwin' ? 'speak.sh' : 'speak.ps1'
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Locate the engine script:
|
|
47
|
+
* 1. explicit DSH_SPEAK_ENGINE override
|
|
48
|
+
* 2. <this package>/engine/<speak.ps1|speak.sh> — works both when running from
|
|
49
|
+
* a repo checkout and when installed into a profile's node_modules
|
|
50
|
+
* 3. legacy file-copy location (~/.dsh/hooks/<speak.ps1|speak.sh>)
|
|
51
|
+
*/
|
|
52
|
+
function resolveEngine() {
|
|
53
|
+
if (process.env.DSH_SPEAK_ENGINE) return process.env.DSH_SPEAK_ENGINE
|
|
54
|
+
const bundled = path.join(__dirname, '..', '..', 'engine', ENGINE_NAME)
|
|
55
|
+
if (fs.existsSync(bundled)) return bundled
|
|
56
|
+
return path.join(os.homedir(), '.dsh', 'hooks', ENGINE_NAME)
|
|
57
|
+
}
|
|
58
|
+
const SPEAK_ENGINE = resolveEngine()
|
|
59
|
+
|
|
60
|
+
module.exports = {
|
|
61
|
+
apply(ctx) {
|
|
62
|
+
log('plugin apply 执行(加载成功); engine=', SPEAK_ENGINE, '; throttle=', THROTTLE_MS)
|
|
63
|
+
let timer = null
|
|
64
|
+
let pendingText = ''
|
|
65
|
+
|
|
66
|
+
/** cancel a pending announcement (called when a tool-call round arrives) */
|
|
67
|
+
function cancelPending() {
|
|
68
|
+
if (timer) { clearTimeout(timer); timer = null }
|
|
69
|
+
pendingText = ''
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function speak(text) {
|
|
73
|
+
log('speak 调用, 文本长度:', text ? text.length : 0)
|
|
74
|
+
if (!text || !text.trim()) return
|
|
75
|
+
const tmp = path.join(os.tmpdir(), `dsh-speech-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.txt`)
|
|
76
|
+
try {
|
|
77
|
+
fs.writeFileSync(tmp, text, 'utf8')
|
|
78
|
+
} catch (e) {
|
|
79
|
+
log('写临时文件失败:', e.message)
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
let ps
|
|
83
|
+
if (process.platform === 'darwin') {
|
|
84
|
+
// macOS: run the say-based engine through bash
|
|
85
|
+
ps = spawn('/bin/bash', [SPEAK_ENGINE, '-f', tmp], { stdio: 'ignore' })
|
|
86
|
+
log('spawn bash (macOS engine) 已发起')
|
|
87
|
+
} else {
|
|
88
|
+
ps = spawn('powershell.exe',
|
|
89
|
+
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', SPEAK_ENGINE, '-File', tmp],
|
|
90
|
+
{ windowsHide: true, stdio: 'ignore' })
|
|
91
|
+
log('spawn powershell 已发起')
|
|
92
|
+
}
|
|
93
|
+
ps.on('exit', (code) => { log('播报进程退出 code=', code); try { fs.unlinkSync(tmp) } catch (e) { /* 清理 */ } })
|
|
94
|
+
ps.on('error', (e) => { log('播报进程 error:', e.message); try { fs.unlinkSync(tmp) } catch (e2) { /* 清理 */ } })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
ctx.on('session/event', (session, event) => {
|
|
98
|
+
try {
|
|
99
|
+
const type = event && event.type
|
|
100
|
+
// noise filter: assistant/chunk (streaming chunks) is not recorded
|
|
101
|
+
if (type !== 'assistant/chunk') {
|
|
102
|
+
log('事件 type=', type, 'surfaceOp=', event && event.surfaceOp, 'seq=', event && event.seq)
|
|
103
|
+
}
|
|
104
|
+
// tool-call round: cancel pending announcement (that round's assistant
|
|
105
|
+
// text is process narration, not the final reply)
|
|
106
|
+
if (type === 'tool/call') {
|
|
107
|
+
cancelPending()
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
if (!event || type !== 'assistant/message') return
|
|
111
|
+
if (event.surfaceOp && event.surfaceOp !== 'append') return
|
|
112
|
+
// the message object lives at event.data.message (event.data wraps { turn, step, message })
|
|
113
|
+
const msg = event.data && (event.data.message || event.data)
|
|
114
|
+
if (!msg) return
|
|
115
|
+
let text = ''
|
|
116
|
+
const c = msg.content
|
|
117
|
+
if (typeof c === 'string') {
|
|
118
|
+
text = c
|
|
119
|
+
} else if (Array.isArray(c)) {
|
|
120
|
+
// only text blocks: reasoning / tool_use blocks are not announced
|
|
121
|
+
text = c
|
|
122
|
+
.filter(b => b && b.type === 'text' && typeof b.text === 'string')
|
|
123
|
+
.map(b => b.text)
|
|
124
|
+
.join('')
|
|
125
|
+
}
|
|
126
|
+
if (!text.trim()) return
|
|
127
|
+
log('缓存待播报文本长度:', text.length, '前 60:', text.slice(0, 60))
|
|
128
|
+
pendingText = text
|
|
129
|
+
if (timer) clearTimeout(timer)
|
|
130
|
+
// throttle: merge multi-step messages of one reply; a tool/call in
|
|
131
|
+
// between cancels the announcement
|
|
132
|
+
timer = setTimeout(() => {
|
|
133
|
+
speak(pendingText)
|
|
134
|
+
pendingText = ''
|
|
135
|
+
timer = null
|
|
136
|
+
}, THROTTLE_MS)
|
|
137
|
+
} catch (e) {
|
|
138
|
+
log('事件处理异常:', e.message)
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
},
|
|
142
|
+
}
|