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