@wenbin_wb/dsh-bridge 2.8.7 → 2.9.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.
@@ -0,0 +1,117 @@
1
+ // DSH 私有存储读取(workspace.json / session_projcache.json 兜底)
2
+ // 自 conversation-bridge.js 拆出。策略:内存服务(workspaceRegistry)优先,
3
+ // 仅当内存服务缺失时才落盘读 DSH 存储;文件不存在时安全返回空值。
4
+ // 注意:不再以 ctx._mock 作为跳过依据——测试通过提供内存服务或注入 DSH_HOME 保持隔离。
5
+ import { existsSync, readFileSync } from 'node:fs'
6
+ import { join, basename } from 'node:path'
7
+ import { homedir } from 'node:os'
8
+
9
+ export function getArchivedSessionIds(ctx) {
10
+ const archived = new Set()
11
+ // 1. 尝试从 ctx.workspaceRegistry 内存服务读取
12
+ try {
13
+ const list = ctx?.workspaceRegistry?.archivedSessionIds
14
+ if (Array.isArray(list)) {
15
+ for (const id of list) {
16
+ if (id) archived.add(String(id))
17
+ }
18
+ return archived
19
+ }
20
+ } catch { /* ignore */ }
21
+
22
+ // 2. 尝试从 DSH workspace 存储文件($DSH_HOME/storages/workspace.json)读取兜底
23
+ {
24
+ try {
25
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
26
+ const wsFile = join(home, 'storages', 'workspace.json')
27
+ if (existsSync(wsFile)) {
28
+ const data = JSON.parse(readFileSync(wsFile, 'utf8'))
29
+ const fileArchived = data?.global?.archivedSessionIds
30
+ if (Array.isArray(fileArchived)) {
31
+ for (const id of fileArchived) {
32
+ if (id) archived.add(String(id))
33
+ }
34
+ }
35
+ }
36
+ } catch { /* ignore */ }
37
+ }
38
+
39
+ return archived
40
+ }
41
+
42
+ /**
43
+ * 安全探测 ctx 上的非 inject 属性。
44
+ * 宿主 cordis 上下文对插件未在 inject 中声明的属性读取会直接抛错
45
+ * ('cannot get property "x" without inject'),因此内存注入点(测试夹具用)
46
+ * 必须经 try/catch 探测,绝不能让异常外溢到命令路径。
47
+ */
48
+ function peekCtxProperty(ctx, key) {
49
+ if (!ctx) return undefined
50
+ try { return ctx[key] } catch { return undefined }
51
+ }
52
+
53
+ /** 读取 DSH 官方持久化会话缓存元数据(标题、是否空白、创建时间等) */
54
+ export function getSessionProjCache(ctx) {
55
+ const injected = peekCtxProperty(ctx, 'sessionProjCache')
56
+ if (injected) return injected
57
+ try {
58
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
59
+ const cacheFile = join(home, 'storages', 'session_projcache.json')
60
+ if (existsSync(cacheFile)) {
61
+ const data = JSON.parse(readFileSync(cacheFile, 'utf8'))
62
+ return data?.tables?.sessions || {}
63
+ }
64
+ } catch { /* ignore */ }
65
+ return {}
66
+ }
67
+
68
+ /** 读取 DSH 官方注册的工作区列表及各自绑定的 sessionIds 列表 */
69
+ export async function getRegisteredWorkspaces(ctx) {
70
+ const workspaces = []
71
+
72
+ // 优先从内存服务获取
73
+ if (ctx?.workspaceRegistry) {
74
+ try {
75
+ const list = await ctx.workspaceRegistry.list?.()
76
+ if (Array.isArray(list)) {
77
+ for (const w of list) {
78
+ if (w && w.path) {
79
+ workspaces.push({
80
+ id: w.id || w.path,
81
+ path: w.path,
82
+ title: w.title || basename(w.path),
83
+ sessionIds: Array.isArray(w.sessionIds) ? [...w.sessionIds] : [],
84
+ })
85
+ }
86
+ }
87
+ return workspaces
88
+ }
89
+ } catch { /* ignore */ }
90
+ }
91
+
92
+ // 兜底从 workspace.json 存储文件读取
93
+ {
94
+ try {
95
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
96
+ const wsFile = join(home, 'storages', 'workspace.json')
97
+ if (existsSync(wsFile)) {
98
+ const data = JSON.parse(readFileSync(wsFile, 'utf8'))
99
+ const wsIds = data?.global?.workspaceIds || Object.keys(data?.tables?.workspaces || {})
100
+ const table = data?.tables?.workspaces || {}
101
+ for (const wId of wsIds) {
102
+ const ws = table[wId]
103
+ if (ws && ws.path) {
104
+ workspaces.push({
105
+ id: wId,
106
+ path: ws.path,
107
+ title: ws.title || basename(ws.path),
108
+ sessionIds: Array.isArray(ws.sessionIds) ? [...ws.sessionIds] : [],
109
+ })
110
+ }
111
+ }
112
+ }
113
+ } catch { /* ignore */ }
114
+ }
115
+
116
+ return workspaces
117
+ }
@@ -1,10 +1,10 @@
1
- // dsh-bridge 平台抽象层统一导出
2
- //
3
- // 提供多平台 IM 接入的基础设施:
4
- // Platform 平台适配器基类(协议/连接/登录/收发消息)
5
- // ConversationBridge 平台无关会话桥(白名单/会话/审批/命令/digest)
6
- // PlatformManager 多平台注册与状态聚合
7
-
8
- export { Platform } from './base.js'
9
- export { ConversationBridge, conversationBridgeHelpers, BRIDGE_MARK, textOfAssistantMessage } from './conversation-bridge.js'
10
- export { PlatformManager } from './manager.js'
1
+ // dsh-bridge 平台抽象层统一导出
2
+ //
3
+ // 提供多平台 IM 接入的基础设施:
4
+ // Platform 平台适配器基类(协议/连接/登录/收发消息)
5
+ // ConversationBridge 平台无关会话桥(白名单/会话/审批/命令/digest)
6
+ // PlatformManager 多平台注册与状态聚合
7
+
8
+ export { Platform } from './base.js'
9
+ export { ConversationBridge, conversationBridgeHelpers, textOfAssistantMessage } from './conversation-bridge.js'
10
+ export { PlatformManager } from './manager.js'
@@ -0,0 +1,191 @@
1
+ // 出站消息分块与 SEND_FILE 指令解析(平台无关纯函数)
2
+ // 自 conversation-bridge.js 拆出:按平台 maxMessageChars 分块、保留 fenced code block、
3
+ // [SEND_FILE: ...] 显式指令提取与路径解析。
4
+ import { statSync } from 'node:fs'
5
+ import { isAbsolute, normalize, 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: <path>] 显式发送指令
154
+ * 由 AI 根据用户意图显式决定何时向用户发送文件附件,杜绝底层盲目扫描与误发。
155
+ * @param {string} text - 原始助手回复文本
156
+ * @param {string} cwd - 会话当前工作目录
157
+ * @returns {{ cleanText: string, files: string[] }}
158
+ */
159
+ export function extractAndStripSendFileDirectives(text, cwd = process.cwd()) {
160
+ if (typeof text !== 'string' || !text.trim()) {
161
+ return { cleanText: text || '', files: [] }
162
+ }
163
+
164
+ const files = []
165
+ const directiveRegex = /\[(?:SEND_FILE|SEND-FILE|send_file|send-file|SEND_MEDIA|send_media):\s*[`"']?([^\]`"'\r\n]+?)[`"']?\s*\]/gi
166
+
167
+ let m
168
+ const re = new RegExp(directiveRegex)
169
+ while ((m = re.exec(text)) !== null) {
170
+ const rawPath = m[1].trim()
171
+ const resolved = resolveFilePath(rawPath, cwd)
172
+ if (resolved && !files.includes(resolved)) {
173
+ files.push(resolved)
174
+ }
175
+ }
176
+
177
+ // 从聊天正文中彻底剔除控制指令(保持 IM 聊天气泡的干净整洁)
178
+ const cleanText = text.replace(directiveRegex, '').replace(/\n{3,}/g, '\n\n').trim()
179
+
180
+ return { cleanText, files }
181
+ }
182
+
183
+ /**
184
+ * 提取文本中的产物文件路径(基于显式指令)
185
+ */
186
+ export function extractFilePathsFromText(text, cwd = process.cwd()) {
187
+ return extractAndStripSendFileDirectives(text, cwd).files
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // digest 摘要