@wenbin_wb/dsh-bridge 2.11.0 → 2.11.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.
@@ -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 摘要
@@ -1,21 +1,21 @@
1
- // 流式打字机输出的公共切片工具
2
- //
3
- // 各平台网关(qq/feishu/telegram)的流式输出都是"增量前缀"模式:
4
- // 第 i 片内容 = 前 i 个基础分片的拼接(服务端逐片覆盖,消息逐渐变长)。
5
- // 基础分块器因平台而异(QQ markdown 感知分块 / 飞书换行窗口 / Telegram splitForIM),
6
- // 本模块只沉淀三者复制的"前缀累积"逻辑。
7
-
8
- /**
9
- * 把基础分片转成增量前缀切片。
10
- * @param {string[]} chunks 基础分片(非空内容应至少产生 1 片)
11
- * @returns {string[]} 第 i 个元素 = chunks[0..i] 拼接
12
- */
13
- export function cumulativeSlices(chunks) {
14
- const slices = []
15
- let acc = ''
16
- for (const s of chunks) {
17
- acc += s
18
- slices.push(acc)
19
- }
20
- return slices
21
- }
1
+ // 流式打字机输出的公共切片工具
2
+ //
3
+ // 各平台网关(qq/feishu/telegram)的流式输出都是"增量前缀"模式:
4
+ // 第 i 片内容 = 前 i 个基础分片的拼接(服务端逐片覆盖,消息逐渐变长)。
5
+ // 基础分块器因平台而异(QQ markdown 感知分块 / 飞书换行窗口 / Telegram splitForIM),
6
+ // 本模块只沉淀三者复制的"前缀累积"逻辑。
7
+
8
+ /**
9
+ * 把基础分片转成增量前缀切片。
10
+ * @param {string[]} chunks 基础分片(非空内容应至少产生 1 片)
11
+ * @returns {string[]} 第 i 个元素 = chunks[0..i] 拼接
12
+ */
13
+ export function cumulativeSlices(chunks) {
14
+ const slices = []
15
+ let acc = ''
16
+ for (const s of chunks) {
17
+ acc += s
18
+ slices.push(acc)
19
+ }
20
+ return slices
21
+ }
@@ -9,7 +9,8 @@
9
9
  //
10
10
  // 用法(由 lib/index.js 组装 payload):node restart-helper.mjs '<json>'
11
11
  import { spawn } from 'node:child_process';
12
- import { appendFileSync, openSync } from 'node:fs';
12
+ import { appendFileSync, openSync, mkdirSync } from 'node:fs';
13
+ import { dirname } from 'node:path';
13
14
  import { connect } from 'node:net';
14
15
 
15
16
  const payload = JSON.parse(process.argv[2] ?? '{}');
@@ -80,7 +81,10 @@ async function main() {
80
81
  }
81
82
 
82
83
  let stdio = 'ignore';
83
- try { stdio = openSync(childLog, 'a'); } catch { /* 打不开就用 ignore */ }
84
+ try {
85
+ mkdirSync(dirname(childLog), { recursive: true });
86
+ stdio = openSync(childLog, 'a');
87
+ } catch { /* 打不开就用 ignore */ }
84
88
 
85
89
  // spawn 的失败是异步 'error' 事件(例如 execPath 不存在 → ENOENT),只 try/catch 抓不到;
86
90
  // 不挂监听会变成未捕获异常,而且父进程给助手设的是 stdio:'ignore' → 失败细节全部丢失。
@@ -90,7 +94,17 @@ async function main() {
90
94
  const settle = (value) => { if (!settled) { settled = true; resolve(value); } };
91
95
  let child;
92
96
  try {
93
- child = spawn(execPath, argv, { cwd, env: process.env, detached: true, stdio: ['ignore', stdio, stdio] });
97
+ const childEnv = { ...process.env };
98
+ if (process.platform !== 'win32') {
99
+ const execDir = dirname(execPath);
100
+ const defaultPaths = [execDir, '/usr/local/bin', '/usr/bin', '/bin'].filter(Boolean);
101
+ const existingPath = childEnv.PATH || '';
102
+ const missingPaths = defaultPaths.filter((p) => !existingPath.split(':').includes(p));
103
+ if (missingPaths.length > 0) {
104
+ childEnv.PATH = existingPath ? `${existingPath}:${missingPaths.join(':')}` : missingPaths.join(':');
105
+ }
106
+ }
107
+ child = spawn(execPath, argv, { cwd, env: childEnv, detached: true, stdio: ['ignore', stdio, stdio] });
94
108
  } catch (err) {
95
109
  log(`派生新 dsh 失败(同步异常):${err?.message ?? err}`);
96
110
  settle(null);