@wenbin_wb/dsh-bridge 2.10.8 → 2.10.9

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,229 +1,229 @@
1
- // 出站消息分块与 SEND_FILE 指令解析(平台无关纯函数)
2
- // 自 conversation-bridge.js 拆出:按平台 maxMessageChars 分块、保留 fenced code block、
3
- // [SEND_FILE: ...] 显式指令提取与路径解析。
4
- import { statSync } from 'node:fs'
5
- import { isAbsolute, normalize, relative, resolve } from 'node:path'
6
-
7
- const FENCE_RE = /^```([^\n`]*)\s*$/
8
-
9
- function normalizeMarkdownBlocks(content) {
10
- const lines = content.split('\n')
11
- const out = []
12
- let blankRun = 0
13
- let inCode = false
14
- for (const raw of lines) {
15
- const line = raw.replace(/\s+$/, '')
16
- if (FENCE_RE.test(line.trim())) {
17
- inCode = !inCode
18
- out.push(line)
19
- blankRun = 0
20
- continue
21
- }
22
- if (inCode) {
23
- out.push(line)
24
- continue
25
- }
26
- if (!line.trim()) {
27
- blankRun += 1
28
- if (blankRun <= 1) out.push('')
29
- continue
30
- }
31
- blankRun = 0
32
- out.push(line)
33
- }
34
- return out.join('\n').trim()
35
- }
36
-
37
- function splitMarkdownBlocks(content) {
38
- const blocks = []
39
- let current = []
40
- let inCode = false
41
- const flush = () => {
42
- const block = current.join('\n').trim()
43
- if (block) blocks.push(block)
44
- current = []
45
- }
46
- for (const raw of content.split('\n')) {
47
- const line = raw.replace(/\s+$/, '')
48
- if (FENCE_RE.test(line.trim())) {
49
- if (!inCode && current.length) flush()
50
- current.push(line)
51
- inCode = !inCode
52
- if (!inCode) flush()
53
- continue
54
- }
55
- if (inCode) {
56
- current.push(line)
57
- continue
58
- }
59
- if (!line.trim()) {
60
- flush()
61
- continue
62
- }
63
- current.push(line)
64
- }
65
- flush()
66
- return blocks
67
- }
68
-
69
- function hardSplit(text, max) {
70
- const chunks = []
71
- let rest = text
72
- while (rest.length > max) {
73
- chunks.push(rest.slice(0, max))
74
- rest = rest.slice(max)
75
- }
76
- if (rest) chunks.push(rest)
77
- return chunks
78
- }
79
-
80
- function packBlocks(blocks, max) {
81
- const units = []
82
- let current = ''
83
- for (const block of blocks) {
84
- const candidate = current ? `${current}\n\n${block}` : block
85
- if (candidate.length <= max) {
86
- current = candidate
87
- continue
88
- }
89
- if (current) units.push(current)
90
- if (block.length <= max) {
91
- current = block
92
- } else {
93
- units.push(...hardSplit(block, max))
94
- current = ''
95
- }
96
- }
97
- if (current) units.push(current)
98
- return units
99
- }
100
-
101
- export function splitForIM(content, max = 2000) {
102
- // 安全检查:防止畸形输入导致无限循环或崩溃
103
- if (typeof content !== 'string' || content.length === 0) return []
104
- if (content.length > 1_000_000) {
105
- content = content.slice(0, 1_000_000) + '\n\n[已截断:内容过长]'
106
- }
107
- const normalized = normalizeMarkdownBlocks(content)
108
- if (!normalized) return []
109
- if (normalized.length <= max) return [normalized]
110
- return packBlocks(splitMarkdownBlocks(normalized), max)
111
- }
112
-
113
- // 协议标记过滤:模型偶尔会把工具调用语法(如 <||DSML||tool_calls>…)当正文
114
- // 输出,这些是协议内容,转发到 IM 只会是乱码噪音。整块移除 tool_calls 段落,
115
- // 再剥掉残余的 DSML 标签;剩余正文照常发送。
116
- export function stripProtocolMarkup(text) {
117
- if (!text || typeof text !== 'string' || !text.includes('DSML')) return text
118
- let out = text.replace(/<[^<>]*DSML[^<>]*tool_calls[^<>]*>[\s\S]*?<[^<>]*DSML[^<>]*tool_calls[^<>]*>/g, '')
119
- out = out.replace(/<[^<>]*DSML[^<>]*>/g, '')
120
- return out
121
- }
122
-
123
- export function textOfAssistantMessage(message) {
124
- const raw = (message.content ?? [])
125
- .filter((block) => block?.type === 'text')
126
- .map((block) => block.text)
127
- .join('\n')
128
- return stripProtocolMarkup(raw)
129
- }
130
-
131
- /**
132
- * 尝试将任意路径(绝对或相对当前工作区)解析为真实存在的本地文件绝对路径
133
- */
134
- export function resolveFilePath(rawPath, cwd = process.cwd()) {
135
- if (typeof rawPath !== 'string') return null
136
- let p = rawPath.trim()
137
- .replace(/^["'`]|["'`]$/g, '')
138
- .replace(/^file:\/\/\/?/, '')
139
- .replace(/^[📁📄📦\s]+/, '')
140
- if (!p) return null
141
- // 排除 HTTP/HTTPS 网址
142
- if (/^https?:\/\//i.test(p)) return null
143
- const resolved = isAbsolute(p) ? normalize(p) : resolve(cwd, p)
144
- try {
145
- if (statSync(resolved).isFile()) {
146
- return resolved
147
- }
148
- } catch {}
149
- return null
150
- }
151
-
152
- /**
153
- * 判断解析后的文件路径是否允许经 [SEND_FILE] 发送给 IM。
154
- * 安全约束(防止模型被诱导后外发任意本地文件):
155
- * 1. 必须位于 allowedRoots 中的某个根目录(含子目录)内 —— 默认仅会话 cwd;
156
- * 2. 路径任何一段不得命中敏感名单(.ssh/.gnupg/.aws/.git/.env/.credentials 等)。
157
- * @param {string} resolvedPath 已解析的绝对路径
158
- * @param {string|string[]} allowedRoots 允许的根目录(绝对路径);默认 process.cwd()
159
- * @returns {boolean}
160
- */
161
- export function isPathAllowedForSend(resolvedPath, allowedRoots = process.cwd()) {
162
- if (typeof resolvedPath !== 'string' || !resolvedPath) return false
163
- const roots = Array.isArray(allowedRoots) ? allowedRoots : [allowedRoots]
164
- if (roots.length === 0) return false
165
-
166
- const normalized = resolve(resolvedPath)
167
- const pathParts = normalized.split(/[\\/]/).filter(Boolean)
168
- const SENSITIVE_PARTS = new Set([
169
- '.ssh', '.gnupg', '.aws', '.azure', '.kube', '.git', '.svn', '.hg',
170
- '.bash_history', '.zsh_history', '.profile', '.bash_profile', '.bashrc',
171
- '.zshrc', '.netrc', '.env', '.npmrc', '.credentials', 'id_rsa', 'id_ed25519',
172
- 'id_ecdsa', 'id_dsa', 'shadow', 'passwd',
173
- ])
174
- for (const part of pathParts) {
175
- if (SENSITIVE_PARTS.has(part.toLowerCase())) return false
176
- }
177
-
178
- for (const root of roots) {
179
- if (typeof root !== 'string' || !root) continue
180
- const normRoot = resolve(root)
181
- if (normalized === normRoot) return true
182
- // 用 relative 判断是否位于根内:越界时 rel 为 '..' 或以 '../' 开头
183
- // (Windows 跨盘则 rel 是绝对路径,isAbsolute 拦截)。跨平台正确处理 \ 与 / 差异。
184
- const rel = relative(normRoot, normalized)
185
- if (rel && !rel.startsWith('..') && !isAbsolute(rel)) return true
186
- }
187
- return false
188
- }
189
-
190
- /**
191
- * 提取并过滤文本中的 [SEND_FILE: <path>] 显式发送指令
192
- * 由 AI 根据用户意图显式决定何时向用户发送文件附件,杜绝底层盲目扫描与误发。
193
- * @param {string} text - 原始助手回复文本
194
- * @param {string} cwd - 会话当前工作目录
195
- * @returns {{ cleanText: string, files: string[] }}
196
- */
197
- export function extractAndStripSendFileDirectives(text, cwd = process.cwd()) {
198
- if (typeof text !== 'string' || !text.trim()) {
199
- return { cleanText: text || '', files: [] }
200
- }
201
-
202
- const files = []
203
- const directiveRegex = /\[(?:SEND_FILE|SEND-FILE|send_file|send-file|SEND_MEDIA|send_media):\s*[`"']?([^\]`"'\r\n]+?)[`"']?\s*\]/gi
204
-
205
- let m
206
- const re = new RegExp(directiveRegex)
207
- while ((m = re.exec(text)) !== null) {
208
- const rawPath = m[1].trim()
209
- const resolved = resolveFilePath(rawPath, cwd)
210
- if (resolved && !files.includes(resolved)) {
211
- files.push(resolved)
212
- }
213
- }
214
-
215
- // 从聊天正文中彻底剔除控制指令(保持 IM 聊天气泡的干净整洁)
216
- const cleanText = text.replace(directiveRegex, '').replace(/\n{3,}/g, '\n\n').trim()
217
-
218
- return { cleanText, files }
219
- }
220
-
221
- /**
222
- * 提取文本中的产物文件路径(基于显式指令)
223
- */
224
- export function extractFilePathsFromText(text, cwd = process.cwd()) {
225
- return extractAndStripSendFileDirectives(text, cwd).files
226
- }
227
-
228
- // ---------------------------------------------------------------------------
229
- // digest 摘要
1
+ // 出站消息分块与 SEND_FILE 指令解析(平台无关纯函数)
2
+ // 自 conversation-bridge.js 拆出:按平台 maxMessageChars 分块、保留 fenced code block、
3
+ // [SEND_FILE: ...] 显式指令提取与路径解析。
4
+ import { statSync } from 'node:fs'
5
+ import { isAbsolute, normalize, relative, resolve } from 'node:path'
6
+
7
+ const FENCE_RE = /^```([^\n`]*)\s*$/
8
+
9
+ function normalizeMarkdownBlocks(content) {
10
+ const lines = content.split('\n')
11
+ const out = []
12
+ let blankRun = 0
13
+ let inCode = false
14
+ for (const raw of lines) {
15
+ const line = raw.replace(/\s+$/, '')
16
+ if (FENCE_RE.test(line.trim())) {
17
+ inCode = !inCode
18
+ out.push(line)
19
+ blankRun = 0
20
+ continue
21
+ }
22
+ if (inCode) {
23
+ out.push(line)
24
+ continue
25
+ }
26
+ if (!line.trim()) {
27
+ blankRun += 1
28
+ if (blankRun <= 1) out.push('')
29
+ continue
30
+ }
31
+ blankRun = 0
32
+ out.push(line)
33
+ }
34
+ return out.join('\n').trim()
35
+ }
36
+
37
+ function splitMarkdownBlocks(content) {
38
+ const blocks = []
39
+ let current = []
40
+ let inCode = false
41
+ const flush = () => {
42
+ const block = current.join('\n').trim()
43
+ if (block) blocks.push(block)
44
+ current = []
45
+ }
46
+ for (const raw of content.split('\n')) {
47
+ const line = raw.replace(/\s+$/, '')
48
+ if (FENCE_RE.test(line.trim())) {
49
+ if (!inCode && current.length) flush()
50
+ current.push(line)
51
+ inCode = !inCode
52
+ if (!inCode) flush()
53
+ continue
54
+ }
55
+ if (inCode) {
56
+ current.push(line)
57
+ continue
58
+ }
59
+ if (!line.trim()) {
60
+ flush()
61
+ continue
62
+ }
63
+ current.push(line)
64
+ }
65
+ flush()
66
+ return blocks
67
+ }
68
+
69
+ function hardSplit(text, max) {
70
+ const chunks = []
71
+ let rest = text
72
+ while (rest.length > max) {
73
+ chunks.push(rest.slice(0, max))
74
+ rest = rest.slice(max)
75
+ }
76
+ if (rest) chunks.push(rest)
77
+ return chunks
78
+ }
79
+
80
+ function packBlocks(blocks, max) {
81
+ const units = []
82
+ let current = ''
83
+ for (const block of blocks) {
84
+ const candidate = current ? `${current}\n\n${block}` : block
85
+ if (candidate.length <= max) {
86
+ current = candidate
87
+ continue
88
+ }
89
+ if (current) units.push(current)
90
+ if (block.length <= max) {
91
+ current = block
92
+ } else {
93
+ units.push(...hardSplit(block, max))
94
+ current = ''
95
+ }
96
+ }
97
+ if (current) units.push(current)
98
+ return units
99
+ }
100
+
101
+ export function splitForIM(content, max = 2000) {
102
+ // 安全检查:防止畸形输入导致无限循环或崩溃
103
+ if (typeof content !== 'string' || content.length === 0) return []
104
+ if (content.length > 1_000_000) {
105
+ content = content.slice(0, 1_000_000) + '\n\n[已截断:内容过长]'
106
+ }
107
+ const normalized = normalizeMarkdownBlocks(content)
108
+ if (!normalized) return []
109
+ if (normalized.length <= max) return [normalized]
110
+ return packBlocks(splitMarkdownBlocks(normalized), max)
111
+ }
112
+
113
+ // 协议标记过滤:模型偶尔会把工具调用语法(如 <||DSML||tool_calls>…)当正文
114
+ // 输出,这些是协议内容,转发到 IM 只会是乱码噪音。整块移除 tool_calls 段落,
115
+ // 再剥掉残余的 DSML 标签;剩余正文照常发送。
116
+ export function stripProtocolMarkup(text) {
117
+ if (!text || typeof text !== 'string' || !text.includes('DSML')) return text
118
+ let out = text.replace(/<[^<>]*DSML[^<>]*tool_calls[^<>]*>[\s\S]*?<[^<>]*DSML[^<>]*tool_calls[^<>]*>/g, '')
119
+ out = out.replace(/<[^<>]*DSML[^<>]*>/g, '')
120
+ return out
121
+ }
122
+
123
+ export function textOfAssistantMessage(message) {
124
+ const raw = (message.content ?? [])
125
+ .filter((block) => block?.type === 'text')
126
+ .map((block) => block.text)
127
+ .join('\n')
128
+ return stripProtocolMarkup(raw)
129
+ }
130
+
131
+ /**
132
+ * 尝试将任意路径(绝对或相对当前工作区)解析为真实存在的本地文件绝对路径
133
+ */
134
+ export function resolveFilePath(rawPath, cwd = process.cwd()) {
135
+ if (typeof rawPath !== 'string') return null
136
+ let p = rawPath.trim()
137
+ .replace(/^["'`]|["'`]$/g, '')
138
+ .replace(/^file:\/\/\/?/, '')
139
+ .replace(/^[📁📄📦\s]+/, '')
140
+ if (!p) return null
141
+ // 排除 HTTP/HTTPS 网址
142
+ if (/^https?:\/\//i.test(p)) return null
143
+ const resolved = isAbsolute(p) ? normalize(p) : resolve(cwd, p)
144
+ try {
145
+ if (statSync(resolved).isFile()) {
146
+ return resolved
147
+ }
148
+ } catch {}
149
+ return null
150
+ }
151
+
152
+ /**
153
+ * 判断解析后的文件路径是否允许经 [SEND_FILE] 发送给 IM。
154
+ * 安全约束(防止模型被诱导后外发任意本地文件):
155
+ * 1. 必须位于 allowedRoots 中的某个根目录(含子目录)内 —— 默认仅会话 cwd;
156
+ * 2. 路径任何一段不得命中敏感名单(.ssh/.gnupg/.aws/.git/.env/.credentials 等)。
157
+ * @param {string} resolvedPath 已解析的绝对路径
158
+ * @param {string|string[]} allowedRoots 允许的根目录(绝对路径);默认 process.cwd()
159
+ * @returns {boolean}
160
+ */
161
+ export function isPathAllowedForSend(resolvedPath, allowedRoots = process.cwd()) {
162
+ if (typeof resolvedPath !== 'string' || !resolvedPath) return false
163
+ const roots = Array.isArray(allowedRoots) ? allowedRoots : [allowedRoots]
164
+ if (roots.length === 0) return false
165
+
166
+ const normalized = resolve(resolvedPath)
167
+ const pathParts = normalized.split(/[\\/]/).filter(Boolean)
168
+ const SENSITIVE_PARTS = new Set([
169
+ '.ssh', '.gnupg', '.aws', '.azure', '.kube', '.git', '.svn', '.hg',
170
+ '.bash_history', '.zsh_history', '.profile', '.bash_profile', '.bashrc',
171
+ '.zshrc', '.netrc', '.env', '.npmrc', '.credentials', 'id_rsa', 'id_ed25519',
172
+ 'id_ecdsa', 'id_dsa', 'shadow', 'passwd',
173
+ ])
174
+ for (const part of pathParts) {
175
+ if (SENSITIVE_PARTS.has(part.toLowerCase())) return false
176
+ }
177
+
178
+ for (const root of roots) {
179
+ if (typeof root !== 'string' || !root) continue
180
+ const normRoot = resolve(root)
181
+ if (normalized === normRoot) return true
182
+ // 用 relative 判断是否位于根内:越界时 rel 为 '..' 或以 '../' 开头
183
+ // (Windows 跨盘则 rel 是绝对路径,isAbsolute 拦截)。跨平台正确处理 \ 与 / 差异。
184
+ const rel = relative(normRoot, normalized)
185
+ if (rel && !rel.startsWith('..') && !isAbsolute(rel)) return true
186
+ }
187
+ return false
188
+ }
189
+
190
+ /**
191
+ * 提取并过滤文本中的 [SEND_FILE: <path>] 显式发送指令
192
+ * 由 AI 根据用户意图显式决定何时向用户发送文件附件,杜绝底层盲目扫描与误发。
193
+ * @param {string} text - 原始助手回复文本
194
+ * @param {string} cwd - 会话当前工作目录
195
+ * @returns {{ cleanText: string, files: string[] }}
196
+ */
197
+ export function extractAndStripSendFileDirectives(text, cwd = process.cwd()) {
198
+ if (typeof text !== 'string' || !text.trim()) {
199
+ return { cleanText: text || '', files: [] }
200
+ }
201
+
202
+ const files = []
203
+ const directiveRegex = /\[(?:SEND_FILE|SEND-FILE|send_file|send-file|SEND_MEDIA|send_media):\s*[`"']?([^\]`"'\r\n]+?)[`"']?\s*\]/gi
204
+
205
+ let m
206
+ const re = new RegExp(directiveRegex)
207
+ while ((m = re.exec(text)) !== null) {
208
+ const rawPath = m[1].trim()
209
+ const resolved = resolveFilePath(rawPath, cwd)
210
+ if (resolved && !files.includes(resolved)) {
211
+ files.push(resolved)
212
+ }
213
+ }
214
+
215
+ // 从聊天正文中彻底剔除控制指令(保持 IM 聊天气泡的干净整洁)
216
+ const cleanText = text.replace(directiveRegex, '').replace(/\n{3,}/g, '\n\n').trim()
217
+
218
+ return { cleanText, files }
219
+ }
220
+
221
+ /**
222
+ * 提取文本中的产物文件路径(基于显式指令)
223
+ */
224
+ export function extractFilePathsFromText(text, cwd = process.cwd()) {
225
+ return extractAndStripSendFileDirectives(text, cwd).files
226
+ }
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // digest 摘要