dsh-speak 1.7.4 → 1.8.1

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/engine/speak.ps1 CHANGED
@@ -1,155 +1,214 @@
1
- # speak.ps1 — Harness-agnostic speech engine (Windows SAPI5 + NaturalVoiceSAPIAdapter)
2
- # ====================================================================================
3
- # Reads text (inline or from a UTF-8 file), cleans it for speech synthesis, and reads
4
- # it aloud through Windows SAPI5, preferring natural voices registered by
5
- # NaturalVoiceSAPIAdapter (https://github.com/gexgd0419/NaturalVoiceSAPIAdapter).
6
- #
7
- # This script knows NOTHING about any harness (DSH, Claude Code, ...). Any process
8
- # can call it:
9
- #
10
- # powershell.exe -NoProfile -ExecutionPolicy Bypass -File speak.ps1 -Text "hello"
11
- # powershell.exe -NoProfile -ExecutionPolicy Bypass -File speak.ps1 -File C:\tmp\msg.txt
12
- #
13
- # It is best-effort by design: it never throws, never blocks the caller for longer
14
- # than the utterance itself, and exits 0 even if something failed.
15
- #
16
- # Design notes (see docs/DESIGN.md for full rationale):
17
- # * Markdown symbols, URLs and emoji are stripped before speaking — SAPI5 Speak()
18
- # silently fails (produces no audio, no error) when it hits emoji/surrogates.
19
- # * NaturalVoiceSAPIAdapter has a per-Speak character ceiling (~375-470 chars);
20
- # beyond that it silently speaks nothing. Text longer than $MaxChars is replaced
21
- # with $LongTextMessage instead.
22
- # * Adapter-registered voices often have plain names ("Microsoft Xiaoxiao") that do
23
- # not contain the word "Natural", so matching checks Name + Description.
24
- # ====================================================================================
25
-
26
- param(
27
- [string]$Text = '',
28
- [string]$File = '',
29
- [int]$Volume = 50,
30
- [int]$Rate = 1,
31
- [int]$MaxChars = 300,
32
- [string]$LongTextMessage = '本次播报内容较长,请自行阅读。',
33
- [ValidateSet('message', 'heading')]
34
- [string]$LongTextMode = 'message',
35
- # 命令行参数一律是字符串(-File 模式不做类型转换),这里用 [string] 接收,
36
- # 在脚本内部再转布尔,兼容 '1'/'0'/'true'/'True'/'yes'/'on' 等写法。
37
- [string]$CleanMarkdownFormatting = 'true',
38
- [string]$ReadInlineCode = 'true',
39
- [ValidateSet('all', 'smart', 'replace')]
40
- [string]$CodeBlocks = 'smart',
41
- [int]$CodeBlockMaxChars = 300,
42
- [string]$CodeBlockReplacementText = 'You can see the code in our history.',
43
- # 手动重播完整朗读:跳过超长文本的 heading/message 截断,分段完整朗读
44
- [string]$FullRead = '0'
45
- )
46
-
47
- $cleanMarkdown = $CleanMarkdownFormatting -in @('1', 'true', 'yes', 'on')
48
- $readInlineCode = $ReadInlineCode -in @('1', 'true', 'yes', 'on')
49
- # 注意:PowerShell 变量大小写不敏感,内部变量名不能与参数名仅差大小写
50
- # (曾用 $fullRead 导致自赋值污染参数 $FullRead,使 -not 判断失效)
51
- $fullReadMode = $FullRead -in @('1', 'true', 'yes', 'on')
52
-
53
- # ---------- input: pick text source ----------
54
- if ($File) {
55
- if (-not (Test-Path $File)) { exit 0 }
56
- $text = [System.IO.File]::ReadAllText($File, [System.Text.Encoding]::UTF8)
57
- } else {
58
- $text = [string]$Text
59
- }
60
- if (-not $text -or -not $text.Trim()) { exit 0 }
61
-
62
- # ---------- length guard: adapter per-Speak ceiling ----------
63
- # 'message': fixed prompt. 'heading': speak the largest markdown heading instead
64
- # (fewest '#' wins, tie -> first; no heading -> first non-empty line; the
65
- # cleaned candidate is still subject to the ceiling below). FullRead 手动重播
66
- # 跳过该守卫(见文件底部"完整朗读"分支)。
67
- if (-not $fullReadMode -and $text.Length -gt $MaxChars -and $LongTextMode -eq 'heading') {
68
- $candidate = ''
69
- $bestLevel = 7
70
- $firstNonEmpty = ''
71
- # 代码块 fence 内的行跳过:其中的 "# 注释" 不是 markdown 标题,
72
- # 否则长回复里的代码注释会被误当成标题只念注释
73
- $inCodeBlock = $false
74
- foreach ($line in ($text -split "`n")) {
75
- if ($line -match '^\s*```') { $inCodeBlock = -not $inCodeBlock; continue }
76
- if ($inCodeBlock) { continue }
77
- if ($line -match '^\s*#{1,6}\s+') {
78
- $level = ([regex]::Match($line, '^(\s*)(#+)')).Groups[2].Value.Length
79
- if ($level -lt $bestLevel) {
80
- $bestLevel = $level
81
- $candidate = $line -replace '^\s*#+\s*', ''
82
- }
83
- } elseif (-not $firstNonEmpty -and $line.Trim()) {
84
- $firstNonEmpty = $line
85
- }
86
- }
87
- if (-not $candidate) { $candidate = $firstNonEmpty }
88
- if ($candidate) { $text = $candidate }
89
- }
90
-
91
- # ---------- clean: Markdown -> natural speech text ----------
92
- if ($cleanMarkdown) {
93
- $text = [regex]::Replace($text, '```[^\n]*\n?([\s\S]*?)```', {
94
- param($match)
95
- $code = $match.Groups[1].Value
96
- if ($CodeBlocks -eq 'all' -or ($CodeBlocks -eq 'smart' -and $code.Length -le $CodeBlockMaxChars)) { return " $code " }
97
- return " $CodeBlockReplacementText "
98
- })
99
- if ($readInlineCode) { $text = $text -replace '`([^`]*)`', '$1' } else { $text = $text -replace '`[^`]*`', ' ' }
100
- $text = $text -replace '\[([^\]]*)\]\([^\)]*\)', '$1'
101
- $text = $text -replace 'https?://\S+', ' '
102
- $text = $text -replace '(?m)^\s{0,3}(?:#{1,6}\s+|[-*+]\s+|\d+[.)]\s+|>\s?)', ' '
103
- $text = $text -replace '(\*\*|__|~~)(.*?)\1', '$2'
104
- $text = $text -replace '[*_~]+', ''
105
- }
106
- # Keep all Unicode letters, including Portuguese accents; remove unsafe symbols.
107
- $text = [regex]::Replace($text, '[^\p{L}\p{N}一-龥 -〿＀-￯ - -~]', '')
108
- $text = $text -replace '\s+', ' '
109
- $text = $text.Trim()
110
-
111
- # ---------- final ceiling (also catches over-long heading candidates) ----------
112
- if (-not $fullReadMode -and $text.Length -gt $MaxChars) { $text = $LongTextMessage }
113
-
114
- # ---------- speak ----------
115
- Add-Type -AssemblyName System.Speech
116
- $synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
117
- $synth.Volume = $Volume
118
-
119
- # prefer a zh natural voice (NaturalVoiceSAPIAdapter-registered), fall back to any zh
120
- $voices = $synth.GetInstalledVoices()
121
- $voice = $voices | Where-Object {
122
- $_.VoiceInfo.Culture.Name -like 'zh*' -and
123
- ($_.VoiceInfo.Name + ' ' + $_.VoiceInfo.Description) -match 'Natural|Online'
124
- } | Select-Object -First 1
125
- if (-not $voice) { $voice = $voices | Where-Object { $_.VoiceInfo.Culture.Name -like 'zh*' } | Select-Object -First 1 }
126
- if ($voice) { $synth.SelectVoice($voice.VoiceInfo.Name) }
127
-
128
- $synth.Rate = $Rate
129
-
130
- # ---------- 完整朗读(FullRead,手动重播) ----------
131
- # Windows SAPI 单次 Speak 有约 375-470 字上限,超长会静默失败;因此按句末
132
- # 标点切成不超过 450 字的段,逐段朗读(自动播报不经过这里,走上面的守卫)。
133
- $SPEAK_CHUNK = 400
134
- if ($fullReadMode -and $text.Length -gt $SPEAK_CHUNK) {
135
- $parts = [regex]::Split($text, '(?<=[。!?;.!?;])')
136
- $chunk = ''
137
- foreach ($part in $parts) {
138
- if ($part.Length -eq 0) { continue }
139
- if ($chunk.Length + $part.Length -gt $SPEAK_CHUNK) {
140
- if ($chunk) { $synth.Speak($chunk); $chunk = '' }
141
- # 单段仍超长:硬切
142
- while ($part.Length -gt $SPEAK_CHUNK) {
143
- $synth.Speak($part.Substring(0, $SPEAK_CHUNK))
144
- $part = $part.Substring($SPEAK_CHUNK)
145
- }
146
- $chunk = $part
147
- } else {
148
- $chunk += $part
149
- }
150
- }
151
- if ($chunk) { $synth.Speak($chunk) }
152
- } else {
153
- $synth.Speak($text)
154
- }
155
- exit 0
1
+ # speak.ps1 — Harness-agnostic speech engine (Windows SAPI5 + NaturalVoiceSAPIAdapter)
2
+ # ====================================================================================
3
+ # Reads text (inline or from a UTF-8 file), cleans it for speech synthesis, and reads
4
+ # it aloud through Windows SAPI5, preferring natural voices registered by
5
+ # NaturalVoiceSAPIAdapter (https://github.com/gexgd0419/NaturalVoiceSAPIAdapter).
6
+ #
7
+ # This script knows NOTHING about any harness (DSH, Claude Code, ...). Any process
8
+ # can call it:
9
+ #
10
+ # powershell.exe -NoProfile -ExecutionPolicy Bypass -File speak.ps1 -Text "hello"
11
+ # powershell.exe -NoProfile -ExecutionPolicy Bypass -File speak.ps1 -File C:\tmp\msg.txt
12
+ #
13
+ # It is best-effort by design: it never throws, never blocks the caller for longer
14
+ # than the utterance itself, and exits 0 even if something failed.
15
+ #
16
+ # Design notes (see docs/DESIGN.md for full rationale):
17
+ # * Markdown symbols, URLs and emoji are stripped before speaking — SAPI5 Speak()
18
+ # silently fails (produces no audio, no error) when it hits emoji/surrogates.
19
+ # * NaturalVoiceSAPIAdapter has a per-Speak character ceiling (~375-470 chars);
20
+ # beyond that it silently speaks nothing. Text longer than $MaxChars is replaced
21
+ # with $LongTextMessage instead.
22
+ # * Adapter-registered voices often have plain names ("Microsoft Xiaoxiao") that do
23
+ # not contain the word "Natural", so matching checks Name + Description.
24
+ # ====================================================================================
25
+
26
+ param(
27
+ [string]$Text = '',
28
+ [string]$File = '',
29
+ [int]$Volume = 50,
30
+ [int]$Rate = 1,
31
+ [int]$MaxChars = 300,
32
+ # 本脚本唯一的非 ASCII 代码字面量。丢了 UTF-8 BOM 时 PowerShell 5.1 会按 ANSI
33
+ # 代码页把它解码成乱码——DSH 插件总是显式传 -LongTextMessage,所以只影响手动
34
+ # CLI 调用;脚本逻辑(句末判定 / 字符过滤 / 分句)已全部改成纯 ASCII 源码。
35
+ [string]$LongTextMessage = '本次播报内容较长,请自行阅读。',
36
+ [ValidateSet('message', 'heading')]
37
+ [string]$LongTextMode = 'message',
38
+ # 命令行参数一律是字符串(-File 模式不做类型转换),这里用 [string] 接收,
39
+ # 在脚本内部再转布尔,兼容 '1'/'0'/'true'/'True'/'yes'/'on' 等写法。
40
+ [string]$CleanMarkdownFormatting = 'true',
41
+ [string]$ReadInlineCode = 'true',
42
+ [ValidateSet('all', 'smart', 'replace')]
43
+ [string]$CodeBlocks = 'smart',
44
+ [int]$CodeBlockMaxChars = 300,
45
+ [string]$CodeBlockReplacementText = 'You can see the code in our history.',
46
+ # 手动重播完整朗读:跳过超长文本的 heading/message 截断,分段完整朗读
47
+ [string]$FullRead = '0',
48
+ # 只把「将要朗读的文本」按 UTF-8 写到 stdout、完全不出声(调试清洗与长文
49
+ # 守卫用;正常调用无需传,插件不会传)
50
+ [string]$DryRun = '0'
51
+ )
52
+
53
+ $cleanMarkdown = $CleanMarkdownFormatting -in @('1', 'true', 'yes', 'on')
54
+ $readInlineCode = $ReadInlineCode -in @('1', 'true', 'yes', 'on')
55
+ $dryRunMode = $DryRun -in @('1', 'true', 'yes', 'on')
56
+ # 注意:PowerShell 变量大小写不敏感,内部变量名不能与参数名仅差大小写
57
+ # (曾用 $fullRead 导致自赋值污染参数 $FullRead,使 -not 判断失效)
58
+ $fullReadMode = $FullRead -in @('1', 'true', 'yes', 'on')
59
+
60
+ # ---------- input: pick text source ----------
61
+ if ($File) {
62
+ if (-not (Test-Path $File)) { exit 0 }
63
+ $text = [System.IO.File]::ReadAllText($File, [System.Text.Encoding]::UTF8)
64
+ } else {
65
+ $text = [string]$Text
66
+ }
67
+ if (-not $text -or -not $text.Trim()) { exit 0 }
68
+
69
+ # ---------- length guard: adapter per-Speak ceiling ----------
70
+ # 'message': fixed prompt. 'heading': speak the largest markdown heading instead
71
+ # (fewest '#' wins, tie -> first; with NO heading, speak a coherent opening of
72
+ # the text — see below; the cleaned candidate is still subject to the ceiling
73
+ # below). FullRead 手动重播跳过该守卫(见文件底部"完整朗读"分支)。
74
+ if (-not $fullReadMode -and $MaxChars -gt 0 -and $text.Length -gt $MaxChars -and $LongTextMode -eq 'heading') {
75
+ $candidate = ''
76
+ $bestLevel = 7
77
+ # 代码块 fence 内的行跳过:其中的 "# 注释" 不是 markdown 标题,
78
+ # 否则长回复里的代码注释会被误当成标题只念注释
79
+ $inCodeBlock = $false
80
+ foreach ($line in ($text -split "`n")) {
81
+ if ($line -match '^\s*```') { $inCodeBlock = -not $inCodeBlock; continue }
82
+ if ($inCodeBlock) { continue }
83
+ if ($line -match '^\s*#{1,6}\s+') {
84
+ $level = ([regex]::Match($line, '^(\s*)(#+)')).Groups[2].Value.Length
85
+ if ($level -lt $bestLevel) {
86
+ $bestLevel = $level
87
+ $candidate = $line -replace '^\s*#+\s*', ''
88
+ }
89
+ }
90
+ }
91
+ if ($candidate) {
92
+ # 有标题:只念最大的标题("长回复只报标题")
93
+ $text = $candidate
94
+ } else {
95
+ # 没有标题:**不能只念第一个非空行** —— 那会念出"……官方文档写明:"这类
96
+ # 断头句然后静默停住,听感上就是"从第二行开始不念了"(1.8.0 修复)。
97
+ # 改为取开头 MaxChars 长度的窗口,并在窗口内最后一个句末标点处收尾。
98
+ # 中英双语判据:
99
+ # * 全角 。!?; 与省略号 … 无条件算句末(中文标点不含歧义);
100
+ # * 半角 .!?; 只在后面跟空白、右引号/右括号时才算,否则 "0.1.2"、
101
+ # "file.txt"、"e.g." 里的小数点/扩展名会被当成句子结尾;
102
+ # * 半角标点不认"到窗口结尾"本身就结束:窗口末尾若是小数点,认了等于没
103
+ # 修剪。但会多读一位来判断(见下),所以"句号+空格"在窗口边缘照样成立。
104
+ # 收尾后若不足半个窗口,就保留整个窗口:一整句超长文本不该被砍成一个词。
105
+ #
106
+ # 标点类一律用 [char] 码位拼出来,让源码里**不出现非 ASCII 代码字面量**:
107
+ # 1) PowerShell 把 ’ ” ‘ “ 也当字符串引号,直接写进单引号串会提前截断
108
+ # (报 Missing ')' in method call);
109
+ # 2) 更要紧的是——脚本一旦丢掉 UTF-8 BOM,Windows PowerShell 5.1 会按
110
+ # ANSI 代码页解码,代码里的中文标点会变乱码,句末判定**静默失效**。
111
+ # 代码保持纯 ASCII 后,丢 BOM 只会让中文注释变乱码,不影响行为。
112
+ # 。 ! ? ; … = 0x3002 0xFF01 0xFF1F 0xFF1B 0x2026
113
+ # ) 】 」 』 = 0xFF09 0x3011 0x300D 0x300F
114
+ $fullWidthEnders = [string]([char]0x3002) + [char]0xFF01 + [char]0xFF1F + [char]0xFF1B + [char]0x2026
115
+ $closers = [string]([char]0x22) + [char]0x201D + [char]0x2019 + [char]0xFF09 + [char]0x3011 + [char]0x300D + [char]0x300F + [char]0x29 + '\' + [char]0x5D + [char]0x7D
116
+ $sentenceEndPattern = '(?:[' + $fullWidthEnders + ']|[.!?;](?=[\s' + $closers + ']))'
117
+ $window = $text.Substring(0, [Math]::Min($text.Length, $MaxChars))
118
+ # 多看一个字符再判定,但只在窗口内收尾:句号落在窗口最后一位时,它后面那个
119
+ # 空格在窗口之外,多看一位才能认出"句号+空格"确实是句末;而"句号+数字"
120
+ # (`Version 0.1.` 的第 300 位)依旧被拒。切点永远不超过窗口长度。
121
+ $scan = $text.Substring(0, [Math]::Min($text.Length, $MaxChars + 1))
122
+ # 取**落在窗口内**的最后一个句末标点:scan 多读的那一位会让最后一个匹配可能
123
+ # 落在窗口之外(窗口外正好是个全角句号时),那种匹配必须忽略——但也不能因此
124
+ # 丢掉窗口内更早的合法边界,所以逐个过滤而不是只看最后一个。
125
+ $cut = 0
126
+ foreach ($match in [regex]::Matches($scan, $sentenceEndPattern)) {
127
+ $end = $match.Index + $match.Length
128
+ if ($end -le $window.Length) { $cut = $end }
129
+ }
130
+ if ($cut -ge [Math]::Floor($window.Length / 2)) { $window = $window.Substring(0, $cut) }
131
+ $text = $window
132
+ }
133
+ }
134
+
135
+ # ---------- clean: Markdown -> natural speech text ----------
136
+ if ($cleanMarkdown) {
137
+ $text = [regex]::Replace($text, '```[^\n]*\n?([\s\S]*?)```', {
138
+ param($match)
139
+ $code = $match.Groups[1].Value
140
+ if ($CodeBlocks -eq 'all' -or ($CodeBlocks -eq 'smart' -and $code.Length -le $CodeBlockMaxChars)) { return " $code " }
141
+ return " $CodeBlockReplacementText "
142
+ })
143
+ if ($readInlineCode) { $text = $text -replace '`([^`]*)`', '$1' } else { $text = $text -replace '`[^`]*`', ' ' }
144
+ $text = $text -replace '\[([^\]]*)\]\([^\)]*\)', '$1'
145
+ $text = $text -replace 'https?://\S+', ' '
146
+ $text = $text -replace '(?m)^\s{0,3}(?:#{1,6}\s+|[-*+]\s+|\d+[.)]\s+|>\s?)', ' '
147
+ $text = $text -replace '(\*\*|__|~~)(.*?)\1', '$2'
148
+ $text = $text -replace '[*_~]+', ''
149
+ }
150
+ # Keep all Unicode letters, including Portuguese accents; remove unsafe symbols.
151
+ # 范围写成 \u 转义(纯 ASCII 源码,丢 BOM 也不会乱码):
152
+ # \u4e00-\u9fa5 汉字、\u3000-\u303f 中文标点、\uff00-\uffef 全角、
153
+ # \u2000-\u206f 通用标点(含 … 和 —)、\u0020-\u007e ASCII 可打印。
154
+ $text = [regex]::Replace($text, '[^\p{L}\p{N}\u4e00-\u9fa5\u3000-\u303f\uff00-\uffef\u2000-\u206f\u0020-\u007e]', '')
155
+ $text = $text -replace '\s+', ' '
156
+ $text = $text.Trim()
157
+
158
+ # ---------- final ceiling (also catches over-long heading candidates) ----------
159
+ if (-not $fullReadMode -and $text.Length -gt $MaxChars) { $text = $LongTextMessage }
160
+
161
+ # ---------- dry run: expose the text that would be spoken, silently ----------
162
+ # Maintainer aid: makes the cleaning pipeline and the long-text guard observable
163
+ # without a speaker, so a truncated candidate can be diffed against its source.
164
+ # Written as raw UTF-8 bytes so a redirected capture never depends on the
165
+ # console code page.
166
+ if ($dryRunMode) {
167
+ $bytes = [System.Text.Encoding]::UTF8.GetBytes($text)
168
+ [Console]::OpenStandardOutput().Write($bytes, 0, $bytes.Length)
169
+ exit 0
170
+ }
171
+
172
+ # ---------- speak ----------
173
+ Add-Type -AssemblyName System.Speech
174
+ $synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
175
+ $synth.Volume = $Volume
176
+
177
+ # prefer a zh natural voice (NaturalVoiceSAPIAdapter-registered), fall back to any zh
178
+ $voices = $synth.GetInstalledVoices()
179
+ $voice = $voices | Where-Object {
180
+ $_.VoiceInfo.Culture.Name -like 'zh*' -and
181
+ ($_.VoiceInfo.Name + ' ' + $_.VoiceInfo.Description) -match 'Natural|Online'
182
+ } | Select-Object -First 1
183
+ if (-not $voice) { $voice = $voices | Where-Object { $_.VoiceInfo.Culture.Name -like 'zh*' } | Select-Object -First 1 }
184
+ if ($voice) { $synth.SelectVoice($voice.VoiceInfo.Name) }
185
+
186
+ $synth.Rate = $Rate
187
+
188
+ # ---------- 完整朗读(FullRead,手动重播) ----------
189
+ # Windows SAPI 单次 Speak 有约 375-470 字上限,超长会静默失败;因此按句末
190
+ # 标点切成不超过 450 字的段,逐段朗读(自动播报不经过这里,走上面的守卫)。
191
+ $SPEAK_CHUNK = 400
192
+ if ($fullReadMode -and $text.Length -gt $SPEAK_CHUNK) {
193
+ # 分句标点同样用 \u 转义(纯 ASCII 源码)
194
+ $parts = [regex]::Split($text, '(?<=[\u3002\uff01\uff1f\uff1b.!?;])')
195
+ $chunk = ''
196
+ foreach ($part in $parts) {
197
+ if ($part.Length -eq 0) { continue }
198
+ if ($chunk.Length + $part.Length -gt $SPEAK_CHUNK) {
199
+ if ($chunk) { $synth.Speak($chunk); $chunk = '' }
200
+ # 单段仍超长:硬切
201
+ while ($part.Length -gt $SPEAK_CHUNK) {
202
+ $synth.Speak($part.Substring(0, $SPEAK_CHUNK))
203
+ $part = $part.Substring($SPEAK_CHUNK)
204
+ }
205
+ $chunk = $part
206
+ } else {
207
+ $chunk += $part
208
+ }
209
+ }
210
+ if ($chunk) { $synth.Speak($chunk) }
211
+ } else {
212
+ $synth.Speak($text)
213
+ }
214
+ exit 0
package/engine/speak.sh CHANGED
@@ -55,15 +55,45 @@ fi
55
55
  # `heading` is meaningful only when a positive long-text ceiling is configured.
56
56
  # -F (full read, manual replay) skips this guard entirely.
57
57
  if [ "$FULL_READ" != "1" ] && [ "$MAX_CHARS" -gt 0 ] && [ "${#TEXT}" -gt "$MAX_CHARS" ] && [ "$LONG_MODE" = "heading" ]; then
58
+ export DSH_SPEAK_MAX_CHARS="$MAX_CHARS"
58
59
  TEXT=$(printf '%s' "$TEXT" | /usr/bin/perl -CSD -e '
59
- my $best = 7; my $cand = ""; my $first = ""; my $inCode = 0;
60
- while (<STDIN>) {
61
- if (/^\s*```/) { $inCode = !$inCode; next; } # 跳过代码块内的 "# 注释"
60
+ my $text = do { local $/; <STDIN> };
61
+ my $best = 7; my $cand = ""; my $inCode = 0;
62
+ for my $line (split /\n/, $text, -1) {
63
+ $line =~ s/\r$//;
64
+ if ($line =~ /^\s*```/) { $inCode = !$inCode; next; } # 跳过代码块内的 "# 注释"
62
65
  next if $inCode;
63
- if (/^[ \t]*(\#{1,6})[ \t]+(.*)$/) { my $n = length($1); if ($n < $best) { $best = $n; $cand = $2; } }
64
- elsif ($first eq "" && /\S/) { $first = $_; }
66
+ if ($line =~ /^[ \t]*(\#{1,6})[ \t]+(.*)$/) { my $n = length($1); if ($n < $best) { $best = $n; $cand = $2; } }
65
67
  }
66
- print($cand eq "" ? $first : $cand);
68
+ if ($cand ne "") { print $cand; exit }
69
+ # 没有标题:**不能只念第一个非空行**(会念出"……写明:"这种断头句然后静默
70
+ # 停住,听感上就是"从第二行开始不念了")。改为取开头 MaxChars 窗口,并在窗口
71
+ # 内最后一个句末标点处收尾。中英双语判据与 speak.ps1 保持一致:
72
+ # * 全角 。!?;… 无条件算句末;
73
+ # * 半角 .!?; 只在后面跟空白、右引号/右括号时才算,否则
74
+ # "0.1.2"、"file.txt"、"e.g." 里的小数点/扩展名会被当成句末;
75
+ # * 半角标点不认"到窗口结尾"本身就结束:窗口末尾若是小数点,认了等于没修剪。
76
+ # 但会多读一位来判断,所以"句号+空格"在窗口边缘照样成立。
77
+ # 收尾后若不足半个窗口,就保留整个窗口,避免一整句超长文本被砍成一个词。
78
+ #
79
+ # 标点一律写成 \x{...} 码位转义,让这段 Perl 源码保持**纯 ASCII**:Perl 源码默认
80
+ # 按字节处理(这里没有 use utf8),直接写中文标点会被当成 Latin-1 单字节字符,
81
+ # 模式就再也匹配不到解码后的正文,表现为**完全不修剪**(1.8.0 实测踩到)。
82
+ # 码位与 speak.ps1 里 [char] 构造的集合一一对应:
83
+ # 。 ! ? ; … = \x{3002} \x{FF01} \x{FF1F} \x{FF1B} \x{2026}
84
+ # ) 】 」 』 = \x{FF09} \x{3011} \x{300D} \x{300F}
85
+ # " ” ’ ) ] } = " \x{201D} \x{2019} ) ] }
86
+ my $max = $ENV{DSH_SPEAK_MAX_CHARS} || 300;
87
+ my $window = length($text) > $max ? substr($text, 0, $max) : $text;
88
+ my $scan = length($text) > $max + 1 ? substr($text, 0, $max + 1) : $text;
89
+ my @ends;
90
+ while ($scan =~ /(?:[\x{3002}\x{FF01}\x{FF1F}\x{FF1B}\x{2026}]|[.!?;](?=[\s"\x{201D}\x{2019}\)\]\}\x{FF09}\x{3011}\x{300D}\x{300F}]))/g) { push @ends, pos($scan); }
91
+ # 只认落在窗口内的最后一个句末标点:多读的那一位可能让最后一个匹配落在窗口
92
+ # 之外,那种匹配要忽略,但不能因此丢掉窗口内更早的合法边界。
93
+ my $cut = 0;
94
+ foreach my $end (@ends) { $cut = $end if $end <= length($window); }
95
+ $window = substr($window, 0, $cut) if $cut >= int(length($window) / 2);
96
+ print $window;
67
97
  ')
68
98
  fi
69
99
 
@@ -1,22 +1,24 @@
1
- # speech-prompt.ps1 — Short prompt announcement (synchronous, blocking)
2
- # Use when a harness/agent needs the user's attention (a question, an approval
3
- # request). Reads the text through engine/speak.ps1 and waits until it finishes,
4
- # so the caller knows the announcement was actually spoken.
5
- #
6
- # powershell.exe -NoProfile -ExecutionPolicy Bypass -File speech-prompt.ps1 -Text "请做出选择"
7
-
8
- param([string]$Text = '请做出选择')
9
-
10
- if (-not $Text) { exit 0 }
11
-
12
- $speak = Join-Path $PSScriptRoot 'speak.ps1'
13
- if (-not (Test-Path $speak)) { exit 0 }
14
-
15
- $tmp = Join-Path $env:TEMP ('speech-prompt-' + [guid]::NewGuid().ToString('N') + '.txt')
16
- try {
17
- [System.IO.File]::WriteAllText($tmp, $Text, [System.Text.UTF8Encoding]::new($false))
18
- & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $speak -File $tmp
19
- exit $LASTEXITCODE
20
- } finally {
21
- if (Test-Path $tmp) { Remove-Item $tmp -Force -ErrorAction SilentlyContinue }
22
- }
1
+ # speech-prompt.ps1 — Short prompt announcement (synchronous, blocking)
2
+ # Use when a harness/agent needs the user's attention (a question, an approval
3
+ # request). Reads the text through engine/speak.ps1 and waits until it finishes,
4
+ # so the caller knows the announcement was actually spoken.
5
+ #
6
+ # powershell.exe -NoProfile -ExecutionPolicy Bypass -File speech-prompt.ps1 -Text "请做出选择"
7
+
8
+ # 唯一的非 ASCII 代码字面量:丢 UTF-8 BOM 时 PowerShell 5.1 会按 ANSI 代码页把它
9
+ # 解码成乱码——调用方通常显式传 -Text,所以只影响不带参数的调用。
10
+ param([string]$Text = '请做出选择')
11
+
12
+ if (-not $Text) { exit 0 }
13
+
14
+ $speak = Join-Path $PSScriptRoot 'speak.ps1'
15
+ if (-not (Test-Path $speak)) { exit 0 }
16
+
17
+ $tmp = Join-Path $env:TEMP ('speech-prompt-' + [guid]::NewGuid().ToString('N') + '.txt')
18
+ try {
19
+ [System.IO.File]::WriteAllText($tmp, $Text, [System.Text.UTF8Encoding]::new($false))
20
+ & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $speak -File $tmp
21
+ exit $LASTEXITCODE
22
+ } finally {
23
+ if (Test-Path $tmp) { Remove-Item $tmp -Force -ErrorAction SilentlyContinue }
24
+ }
@@ -1,27 +1,27 @@
1
- # speech-summary.ps1 — Reply summary announcement (synchronous, blocking)
2
- # For harnesses with no "reply finished" event (e.g. DSH has no Stop hook): the
3
- # agent calls this at the end of its final reply.
4
- #
5
- # powershell.exe -NoProfile -ExecutionPolicy Bypass -File speech-summary.ps1 -Text "总结文本"
6
- #
7
- # NOTE: keep this SYNCHRONOUS. An earlier version spawned the inner powershell
8
- # asynchronously with Start-Process; DSH's sandbox blocks nested sub-process
9
- # spawning, so it silently produced no audio. The synchronous call chain
10
- # (summary -> speak.ps1) is the reliable path (cost: caller waits for the
11
- # utterance to finish).
12
-
13
- param([string]$Text = '')
14
-
15
- if (-not $Text) { exit 0 }
16
-
17
- $speak = Join-Path $PSScriptRoot 'speak.ps1'
18
- if (-not (Test-Path $speak)) { exit 0 }
19
-
20
- $tmp = Join-Path $env:TEMP ('speech-summary-' + [guid]::NewGuid().ToString('N') + '.txt')
21
- try {
22
- [System.IO.File]::WriteAllText($tmp, $Text, [System.Text.UTF8Encoding]::new($false))
23
- & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $speak -File $tmp
24
- exit $LASTEXITCODE
25
- } finally {
26
- if (Test-Path $tmp) { Remove-Item $tmp -Force -ErrorAction SilentlyContinue }
27
- }
1
+ # speech-summary.ps1 — Reply summary announcement (synchronous, blocking)
2
+ # For harnesses with no "reply finished" event (e.g. DSH has no Stop hook): the
3
+ # agent calls this at the end of its final reply.
4
+ #
5
+ # powershell.exe -NoProfile -ExecutionPolicy Bypass -File speech-summary.ps1 -Text "总结文本"
6
+ #
7
+ # NOTE: keep this SYNCHRONOUS. An earlier version spawned the inner powershell
8
+ # asynchronously with Start-Process; DSH's sandbox blocks nested sub-process
9
+ # spawning, so it silently produced no audio. The synchronous call chain
10
+ # (summary -> speak.ps1) is the reliable path (cost: caller waits for the
11
+ # utterance to finish).
12
+
13
+ param([string]$Text = '')
14
+
15
+ if (-not $Text) { exit 0 }
16
+
17
+ $speak = Join-Path $PSScriptRoot 'speak.ps1'
18
+ if (-not (Test-Path $speak)) { exit 0 }
19
+
20
+ $tmp = Join-Path $env:TEMP ('speech-summary-' + [guid]::NewGuid().ToString('N') + '.txt')
21
+ try {
22
+ [System.IO.File]::WriteAllText($tmp, $Text, [System.Text.UTF8Encoding]::new($false))
23
+ & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $speak -File $tmp
24
+ exit $LASTEXITCODE
25
+ } finally {
26
+ if (Test-Path $tmp) { Remove-Item $tmp -Force -ErrorAction SilentlyContinue }
27
+ }