dsh-collab-workbench 0.0.0-stage → 0.1.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.
Potentially problematic release.
This version of dsh-collab-workbench might be problematic. Click here for more details.
- package/LICENSE +202 -0
- package/README.md +112 -2
- package/client.js +2136 -0
- package/cordis.patch.yml +7 -0
- package/icon.svg +1 -0
- package/index.js +86 -0
- package/locale/en.json +1 -0
- package/locale/zh.json +1 -0
- package/package.json +60 -3
- package/scripts/build-approvals.mjs +15 -0
- package/scripts/check.mjs +17 -0
- package/src/client/approval-ui.js +117 -0
- package/src/client/update-ui.js +73 -0
- package/src/host/agents.js +235 -0
- package/src/host/api.js +264 -0
- package/src/host/approvals-fold.js +34 -0
- package/src/host/approvals.js +36 -0
- package/src/host/channels.js +509 -0
- package/src/host/delivery.js +95 -0
- package/src/host/execution-engine.js +417 -0
- package/src/host/execution-tools.js +65 -0
- package/src/host/execution.js +274 -0
- package/src/host/files.js +89 -0
- package/src/host/groups.js +501 -0
- package/src/host/ledger.js +177 -0
- package/src/host/memory.js +175 -0
- package/src/host/policy.js +43 -0
- package/src/host/tasks.js +117 -0
- package/src/host/tools.js +491 -0
- package/src/host/update.js +135 -0
- package/src/host/verify.js +44 -0
- package/src/host/work-item-sync.js +101 -0
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
// channels.js —— 群消息流与投递(V5:效率重构——消息去重、确认抑制、任务通知不唤醒)。
|
|
2
|
+
//
|
|
3
|
+
// 核心设计变更(解决 68% Token 浪费问题):
|
|
4
|
+
// 1. 消息去重:投递前检查 entry.seen,已处理过的消息不再投递
|
|
5
|
+
// 2. 确认抑制:"收到/确认/无需动作"类回复不触发下一轮投递
|
|
6
|
+
// 3. 任务通知不唤醒:任务创建/更新的自动发帖只写 log 不 followup
|
|
7
|
+
// 4. 管理员选择性路由:非 @ 且非任务的消息只投给管理员中未处理的
|
|
8
|
+
// 5. 会话轮换时归档旧会话(DSH 侧栏不残留)
|
|
9
|
+
//
|
|
10
|
+
// 每群成员会话 + 工作目录绑定 + 运行态/停止等机制保持不变。
|
|
11
|
+
import { MESSAGE_POLICY } from './policy.js'
|
|
12
|
+
import { randomUUID } from 'node:crypto'
|
|
13
|
+
import { resolve as resolvePath, relative as relativePath, sep as pathSep } from 'node:path'
|
|
14
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
15
|
+
|
|
16
|
+
export const SOURCE_KIND = 'dsh-workbench'
|
|
17
|
+
export const ROOM_LOG_MAX = 500
|
|
18
|
+
export const SESSION_ROTATE_AT = 60
|
|
19
|
+
const DIGEST_MAX = 20
|
|
20
|
+
|
|
21
|
+
// 确认类消息判定(不触发下一轮投递)。
|
|
22
|
+
const ACK_PATTERNS = [
|
|
23
|
+
/已处理[过完]/, /无需重复/, /无需动作/, /无待办/, /延迟送达/, /延迟重投/,
|
|
24
|
+
/历史消息/, /收到[,,]?确?认?$/, /确认收到[,,]?$/, /确认无误[,,]?$/,
|
|
25
|
+
/^收到[。.]?$/, /^确认[。.]?$/, /回执[。.]?$/, /口径[一]?致[。.]?$/,
|
|
26
|
+
/本条为.*延迟/, /无新增动作/, /无异议[。.]$/, /就绪[,,]?等/,
|
|
27
|
+
/对时.*无?需?要?动作/, /状态确认.*无/, /与.*交叉/, /消息交叉/,
|
|
28
|
+
/已核.*与.*一致/, /状态.*一致.*无需/, /时点对齐/, /终态.*一致/,
|
|
29
|
+
/对齐确认/, /收悉/, /定稿口径/, /无其他影响/,
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
function isAckMessage(text) {
|
|
33
|
+
const t = String(text ?? '').trim()
|
|
34
|
+
if (t.length > 200) return false // 长消息不太可能只是确认
|
|
35
|
+
return ACK_PATTERNS.some(p => p.test(t))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// @ 提及解析
|
|
39
|
+
export function mentionsIn(text, roleNames) {
|
|
40
|
+
const found = new Set()
|
|
41
|
+
const names = roleNames.filter(Boolean)
|
|
42
|
+
if (names.length === 0) return []
|
|
43
|
+
const sorted = [...names].sort((a, b) => b.length - a.length)
|
|
44
|
+
let rest = String(text ?? '')
|
|
45
|
+
for (const name of sorted) {
|
|
46
|
+
const pattern = new RegExp(`(^|[^\\w])@${escapeRegExp(name)}(?=$|[\\s,。!?,.!?;;::])`, 'g')
|
|
47
|
+
let m
|
|
48
|
+
while ((m = pattern.exec(rest)) !== null) found.add(name)
|
|
49
|
+
}
|
|
50
|
+
return [...found]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function escapeRegExp(text) {
|
|
54
|
+
return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ④ 从消息文本中提取关联的任务信息(优先级 + 状态),供人员判断是否需要跳过。
|
|
58
|
+
// 匹配任务通知格式(【新任务】【任务更新】)和任务 id(task_N)。
|
|
59
|
+
function taskContextFor(rt, text) {
|
|
60
|
+
const t = String(text ?? '')
|
|
61
|
+
// 任务通知消息(【新任务】/【任务更新】):解析任务标题和状态。
|
|
62
|
+
const taskMatch = t.match(/【新任务】(.+)|【任务更新】(.+)/)
|
|
63
|
+
if (taskMatch) {
|
|
64
|
+
const title = (taskMatch[1] ?? taskMatch[2] ?? '').split('\n')[0].trim()
|
|
65
|
+
const task = (rt.listTasks() ?? []).find(tk => tk.title.includes(title.slice(0, 20)) || title.includes(tk.title.slice(0, 20)))
|
|
66
|
+
if (task) {
|
|
67
|
+
return `[关联任务 ${task.id}:${task.priority}优先级 · ${task.status} · 指派给${(task.assignees ?? []).map(id => rt.getRole(id)?.name ?? id).join('、')}]`
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// 普通 @ 消息中包含 task_N 引用。
|
|
71
|
+
const idMatch = t.match(/\b(task_\d+)\b/)
|
|
72
|
+
if (idMatch) {
|
|
73
|
+
const task = rt.getTask?.(idMatch[1])
|
|
74
|
+
if (task) {
|
|
75
|
+
return `[关联任务 ${task.id}:${task.priority}优先级 · ${task.status}]`
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function install(rt) {
|
|
82
|
+
const { ctx } = rt
|
|
83
|
+
|
|
84
|
+
// 运行态(内存态)。
|
|
85
|
+
// 🔑 工作状态:使用 DSH 原生 SessionSummary.running(与侧栏转圈动画同源)。
|
|
86
|
+
// 每 2 秒缓存一次,避免频繁调用 sessionController.list()。
|
|
87
|
+
let sessionStatusCache = { data: new Map(), fetchedAt: 0 }
|
|
88
|
+
const STATUS_CACHE_MS = 2000
|
|
89
|
+
|
|
90
|
+
const getSessionStatuses = async () => {
|
|
91
|
+
const now = Date.now()
|
|
92
|
+
if (now - sessionStatusCache.fetchedAt < STATUS_CACHE_MS) {
|
|
93
|
+
return sessionStatusCache.data
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const controller = ctx.get?.('sessionController') ?? ctx.sessionController
|
|
97
|
+
const result = await controller.list({}, AbortSignal.timeout(3000))
|
|
98
|
+
const map = new Map()
|
|
99
|
+
for (const item of result?.items ?? []) {
|
|
100
|
+
map.set(item.sessionId, item)
|
|
101
|
+
}
|
|
102
|
+
sessionStatusCache = { data: map, fetchedAt: now }
|
|
103
|
+
return map
|
|
104
|
+
} catch {
|
|
105
|
+
return sessionStatusCache.data // 失败时用缓存
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
rt.activityList = async (groupId) => {
|
|
110
|
+
const group = rt.getGroup(groupId)
|
|
111
|
+
if (!group) return []
|
|
112
|
+
const statuses = await getSessionStatuses()
|
|
113
|
+
const active = []
|
|
114
|
+
for (const [roleId, entry] of Object.entries(group.sessions ?? {})) {
|
|
115
|
+
const status = statuses.get(entry.id)
|
|
116
|
+
if (status?.running) {
|
|
117
|
+
active.push({
|
|
118
|
+
key: `${groupId}:${roleId}`,
|
|
119
|
+
groupId,
|
|
120
|
+
roleId,
|
|
121
|
+
startedAt: status.updatedAt ?? Date.now(),
|
|
122
|
+
text: '',
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
for (const run of rt.executionRuns?.() ?? []) {
|
|
127
|
+
if (run.groupId !== group.id || !statuses.get(run.sessionId)?.running) continue
|
|
128
|
+
if (!active.some(a => a.roleId === run.roleId)) active.push({key:run.id,groupId:group.id,roleId:run.roleId,startedAt:run.updatedAt,text:run.taskId})
|
|
129
|
+
}
|
|
130
|
+
return active
|
|
131
|
+
}
|
|
132
|
+
// 兼容旧接口(同步调用场景)
|
|
133
|
+
rt.touchRunning = () => {}
|
|
134
|
+
rt.stopPerson = async (groupId, roleId) => {
|
|
135
|
+
const group = rt.getGroup(groupId)
|
|
136
|
+
const taskRuns = (rt.executionRuns?.() ?? []).filter(r => r.groupId === group?.id && r.roleId === roleId)
|
|
137
|
+
await rt.pauseRoleExecution?.(group?.id, roleId)
|
|
138
|
+
const entry = group?.sessions?.[roleId]
|
|
139
|
+
if (!entry?.id) return { stopped: taskRuns.length > 0, reason: taskRuns.length ? '任务执行已暂停,需显式重试' : '该人员当前没有会话' }
|
|
140
|
+
try {
|
|
141
|
+
const { agent } = await ctx.sessionController.resolveAgent(entry.id)
|
|
142
|
+
if (agent) {
|
|
143
|
+
agent.cancel?.({ kind: 'user' })
|
|
144
|
+
return { stopped: true }
|
|
145
|
+
}
|
|
146
|
+
return { stopped: false, reason: '该人员当前没有进行中的工作' }
|
|
147
|
+
} catch (error) {
|
|
148
|
+
return { stopped: false, reason: error?.message ?? String(error) }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// 按群组+sessionId 停止运行中的 agent(供删除群组时清内存用)。
|
|
152
|
+
rt.stopSessionAgent = async (groupId, sessionId) => {
|
|
153
|
+
try {
|
|
154
|
+
const { agent } = await ctx.sessionController.resolveAgent(sessionId)
|
|
155
|
+
if (agent) {
|
|
156
|
+
agent.cancel?.({ kind: 'user' })
|
|
157
|
+
return { stopped: true }
|
|
158
|
+
}
|
|
159
|
+
} catch (error) {
|
|
160
|
+
return { stopped: false, reason: error?.message ?? String(error) }
|
|
161
|
+
}
|
|
162
|
+
return { stopped: false, reason: '未找到该会话' }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const ensureLog = (group) => {
|
|
166
|
+
if (!Array.isArray(group.log)) group.log = []
|
|
167
|
+
if (typeof group.seq !== 'number') group.seq = group.log.length
|
|
168
|
+
if (!group.seen) group.seen = {}
|
|
169
|
+
if (!group.sessions) group.sessions = {}
|
|
170
|
+
return group
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const appendLine = (group, line) => {
|
|
174
|
+
ensureLog(group)
|
|
175
|
+
group.seq += 1
|
|
176
|
+
line.seq = group.seq
|
|
177
|
+
group.log = [...group.log, { ...line }].slice(-ROOM_LOG_MAX)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---- 每群成员会话管理 ----
|
|
181
|
+
rt.ensureGroupSession = async (group, role) => {
|
|
182
|
+
ensureLog(group)
|
|
183
|
+
let entry = group.sessions[role.id]
|
|
184
|
+
const rotate = entry && (entry.count ?? 0) >= SESSION_ROTATE_AT
|
|
185
|
+
if (!entry || rotate) {
|
|
186
|
+
// 轮换时归档旧会话(DSH 侧栏不再显示)。
|
|
187
|
+
if (rotate && entry?.id) {
|
|
188
|
+
try { await ctx.workspaceRegistry.archiveSession(entry.id) } catch { /* 忽略 */ }
|
|
189
|
+
// 轮换前的会话 ID 记入台账:删群时按台账精确清理会话数据,
|
|
190
|
+
// 不再依赖目录名/路径扫描兜底(曾因此误删用户会话——2026-10-16 事故)。
|
|
191
|
+
group.archivedSessions = [...new Set([...(group.archivedSessions ?? []), entry.id])]
|
|
192
|
+
}
|
|
193
|
+
const workspaceId = await rt.ensureGroupWorkspace(group) ?? await rt.ensureWorkspace()
|
|
194
|
+
const { sessionId } = await ctx.sessionController.create({ workspaceId })
|
|
195
|
+
if (role.model) await rt.applySessionModel?.(sessionId, role.model)
|
|
196
|
+
try {
|
|
197
|
+
const titleAgent = await rt.resolveAgent(sessionId)
|
|
198
|
+
await ctx.sessionTitle.rename(titleAgent.session, `${role.name} · ${group.name}`)
|
|
199
|
+
} catch { /* 忽略 */ }
|
|
200
|
+
entry = { id: sessionId, seen: rotate ? (entry.seen ?? 0) : 0, count: 0 }
|
|
201
|
+
group.sessions[role.id] = entry
|
|
202
|
+
rt.touch()
|
|
203
|
+
// 种子消息(精简版——减少初始化 token + 效率纪律)。
|
|
204
|
+
const isAdmin = (group.admins ?? []).includes(role.id)
|
|
205
|
+
const lines = [
|
|
206
|
+
`你是「${role.name}」,属于群「${group.name}」。roleId=${role.id}(发言用 dwb_post_group_message 传 fromRoleId)`,
|
|
207
|
+
role.instructions ? `职责:${role.instructions}` : '',
|
|
208
|
+
`成员:${(group.members ?? []).map(id => rt.getRole(id)?.name ?? id).join('、')}`,
|
|
209
|
+
group.notice ? `公告:${group.notice}` : '',
|
|
210
|
+
group.dir ? `工作目录:${group.dir}` : '',
|
|
211
|
+
'规范:@人员名指定回答者;需要用户拍板只能用 dwb_ask_user(决策卡显示在群聊,用户点击按钮回复——调用后立即结束你的回复,绝不要在会话中直接向用户提问,用户不看你的会话只看群聊);管理员阶段性汇报用 dwb_report_progress;任务完成通知用 dwb_notify_user;查历史用 dwb_group_transcript;记忆用 dwb_remember/dwb_recall。不要发"收到/确认"类无实质内容的回复。',
|
|
212
|
+
// 任务执行纪律
|
|
213
|
+
'任务纪律:正式任务由系统独立执行会话调度;普通群会话只做协调,不认领或重复执行。执行成员用 dwb_report_execution 汇报个人状态与完成证据,不用 dwb_update_task 认领或关闭多人任务。',
|
|
214
|
+
// 📢 群消息核心原则 + 内容纪律
|
|
215
|
+
'消息原则:群消息的读者是人类用户——让用户能看懂每个成员在做什么、做完了什么、成果在哪。只发关键总结性内容,非必要细节写进文档。',
|
|
216
|
+
MESSAGE_POLICY,
|
|
217
|
+
// 📄 文档引用纪律(防止技术细节涌入群聊)
|
|
218
|
+
'文档纪律:技术细节(字段规则/API行为/校验逻辑/契约条款)只写入文档,群聊只引用文档位置——说"按设计.md v1.1 §6"而不是把 §6 的内容复述一遍。@成员派发时同样只说"按 XX 文档的 YY 章节做",成员会自己去读文档。裁定结论可以在群里说(如"3 处裁决已写入基线"),但裁决的具体内容不重复。',
|
|
219
|
+
// ⚡ 效率纪律
|
|
220
|
+
'效率纪律:群消息控制在 150 字以内,用要点式表达;不写过程叙述("我现在正在..."、"接下来我会...");不交叉验证或重复审查别人的工作;有分歧直接 @对方 说结论。评审意见写入被评审的文档,不进群聊。',
|
|
221
|
+
// 🛑 决策阻塞纪律
|
|
222
|
+
'决策纪律:发出决策卡后必须等待用户回复,绝不能代用户做决策(代决)——"暂定""可推翻"也不行。用户未回复期间:不得创建依赖该决策的任务、不得派发依赖该决策的工作。只有与决策完全无关的环境检测可以继续。用户长时间不回复时,用 dwb_ask_user 再次提醒,不要自行推进。',
|
|
223
|
+
// 管理员调度纪律
|
|
224
|
+
isAdmin ? '调度纪律(你是管理员):信任团队成员,授权后不逐条审查中间产出;只在两个节点做核验——①基线确认 ②最终验收;派任务时用一句话说清目标/范围/交付标准;派发后放手让成员自主完成。发出决策卡时明确哪些工作可继续、哪些暂停;宣布完成前确认所有任务已关闭。' : '',
|
|
225
|
+
rotate ? '(会话已轮换,之前内容用 dwb_group_transcript 查。)' : '',
|
|
226
|
+
].filter(Boolean)
|
|
227
|
+
// 🔑 不单独发送种子消息——agent 会把它当作任务执行。
|
|
228
|
+
// 改为存储种子文本,与第一条真实消息合并发送(deliverToMember 处理)。
|
|
229
|
+
entry.pendingSeed = lines.join('\n')
|
|
230
|
+
group.sessions[role.id] = entry
|
|
231
|
+
rt.touch()
|
|
232
|
+
}
|
|
233
|
+
return entry
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const digestFor = (group, roleId, currentSeq) => {
|
|
237
|
+
ensureLog(group)
|
|
238
|
+
const entry = group.sessions[roleId]
|
|
239
|
+
const seen = entry?.seen ?? 0
|
|
240
|
+
if (currentSeq - seen <= 1) return []
|
|
241
|
+
return (group.log ?? [])
|
|
242
|
+
.filter(l => l.seq > seen && l.seq < currentSeq)
|
|
243
|
+
.slice(-DIGEST_MAX)
|
|
244
|
+
.map(l => {
|
|
245
|
+
const preview = String(l.text ?? '').replace(/\s+/g, ' ').slice(0, 120)
|
|
246
|
+
|| (l.attachments?.length ? `📎 ${l.attachments[0].name}${l.attachments.length > 1 ? ` 等 ${l.attachments.length} 个文件` : ''}` : '')
|
|
247
|
+
return `${l.fromName}:${preview}`
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// 附件记录净化:只保留落在群工作目录内的文件(防伪造路径投递给成员),
|
|
252
|
+
// 并补 relPath(成员会话 cwd = 群目录,相对路径可直接 read/glob)。
|
|
253
|
+
const sanitizeAttachments = (group, input) => {
|
|
254
|
+
if (!group.dir || !Array.isArray(input)) return []
|
|
255
|
+
const groupDir = resolvePath(group.dir)
|
|
256
|
+
return input
|
|
257
|
+
.filter(a => a && typeof a.path === 'string' && typeof a.name === 'string')
|
|
258
|
+
.slice(0, 9)
|
|
259
|
+
.map(a => {
|
|
260
|
+
const abs = resolvePath(a.path)
|
|
261
|
+
const rel = relativePath(groupDir, abs)
|
|
262
|
+
if (!rel || rel.startsWith('..') || rel.includes(`..${pathSep}`)) return null
|
|
263
|
+
return {
|
|
264
|
+
name: String(a.name).replace(/[\u0000-\u001f\u007f]/g, '').slice(0, 200) || '未命名文件',
|
|
265
|
+
size: Number(a.size) > 0 ? Number(a.size) : 0,
|
|
266
|
+
path: abs,
|
|
267
|
+
relPath: rel.split(pathSep).join('/'),
|
|
268
|
+
}
|
|
269
|
+
})
|
|
270
|
+
.filter(Boolean)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Internal notification capability is deliberately absent from public tool/RPC inputs.
|
|
274
|
+
const systemToken = Symbol('system notification')
|
|
275
|
+
rt.resolveGroupActor = (group, explicitRoleId) => {
|
|
276
|
+
const initiator = ctx.get?.('agents')?.currentInitiator?.()
|
|
277
|
+
const run = rt.currentExecution?.()
|
|
278
|
+
const sessionRoleId = initiator && Object.entries(group.sessions ?? {}).find(([, entry]) => entry.id === initiator.id)?.[0]
|
|
279
|
+
const trustedRoleId = run?.groupId === group.id ? run.roleId : sessionRoleId
|
|
280
|
+
if (trustedRoleId && explicitRoleId && trustedRoleId !== explicitRoleId) throw new Error('fromRoleId 不能冒充其他成员')
|
|
281
|
+
if (initiator && !trustedRoleId && explicitRoleId) throw new Error('当前会话不属于该群组,不能署名成员')
|
|
282
|
+
const roleId = trustedRoleId ?? explicitRoleId
|
|
283
|
+
if (roleId && (!(group.members ?? []).includes(roleId) || !rt.getRole(roleId))) throw new Error('发言角色必须是当前群组成员')
|
|
284
|
+
return roleId ?? null
|
|
285
|
+
}
|
|
286
|
+
rt.postSystemGroupMessage = args => rt.postGroupMessage({ ...args, system: systemToken })
|
|
287
|
+
|
|
288
|
+
// ---- 群消息投递(核心回路·V5 效率版) ----
|
|
289
|
+
rt.postGroupMessage = async ({ groupId, text, fromRoleId, noWake, attachments, taskId, replyToSeq, decisionSeq, messageId, system }) => {
|
|
290
|
+
await rt.loadAll()
|
|
291
|
+
await rt.executionReady
|
|
292
|
+
const group = rt.getGroup(groupId)
|
|
293
|
+
if (!group) throw new Error(`群组 ${groupId} 不存在`)
|
|
294
|
+
if (group.status !== 'active') throw new Error(`群组「${group.name}」已归档,不能发言`)
|
|
295
|
+
if (system !== undefined && system !== systemToken) throw new Error('普通调用不能冒充系统通知')
|
|
296
|
+
fromRoleId = rt.resolveGroupActor(group, fromRoleId)
|
|
297
|
+
const prior = messageId ? (group.log ?? []).find(line => line.id === messageId) : null
|
|
298
|
+
if (prior) return {seq:prior.seq,mentions:prior.mentions??[],delivered:[],duplicate:true}
|
|
299
|
+
|
|
300
|
+
const files = sanitizeAttachments(group, attachments)
|
|
301
|
+
if (!String(text ?? '').trim() && files.length === 0) throw new Error('消息内容不能为空')
|
|
302
|
+
|
|
303
|
+
const fromRole = fromRoleId ? rt.getRole(fromRoleId) : undefined
|
|
304
|
+
const memberIds = group.members ?? []
|
|
305
|
+
const memberRoles = memberIds.map(id => rt.getRole(id)).filter(Boolean)
|
|
306
|
+
const mentions = mentionsIn(text, memberRoles.map(r => r.name))
|
|
307
|
+
const caller = rt.currentExecution?.()
|
|
308
|
+
if (!system && caller && (caller.groupId !== group.id || fromRoleId !== caller.roleId)) throw new Error('执行成员只能用自己的角色在所属群发言')
|
|
309
|
+
const reply = replyToSeq === undefined ? null : (group.log ?? []).find(l => l.seq === Number(replyToSeq))
|
|
310
|
+
const refs = [...new Set(String(text ?? '').match(/\btask_\d+\b/g) ?? [])]
|
|
311
|
+
const boundTaskId = taskId || reply?.taskId || caller?.taskId || (refs.length === 1 ? refs[0] : null)
|
|
312
|
+
if (boundTaskId && rt.getTask?.(boundTaskId)?.groupId !== group.id) throw new Error('关联任务必须属于当前群组')
|
|
313
|
+
if (taskId && caller && caller.taskId !== taskId) throw new Error('执行成员不能切换到其他任务')
|
|
314
|
+
|
|
315
|
+
const line = {
|
|
316
|
+
id: messageId ?? randomUUID(),
|
|
317
|
+
seq: 0,
|
|
318
|
+
text: String(text ?? ''),
|
|
319
|
+
from: fromRoleId ?? null,
|
|
320
|
+
fromName: fromRole?.name ?? (system === systemToken ? '系统' : '用户'),
|
|
321
|
+
...(system === systemToken ? { system: true } : {}),
|
|
322
|
+
time: Date.now(),
|
|
323
|
+
mentions,
|
|
324
|
+
...(boundTaskId ? { taskId: boundTaskId } : {}),
|
|
325
|
+
...(reply ? { replyToSeq: reply.seq } : {}),
|
|
326
|
+
...(files.length > 0 ? { attachments: files } : {}),
|
|
327
|
+
}
|
|
328
|
+
appendLine(group, line)
|
|
329
|
+
|
|
330
|
+
// A plain user message does not implicitly answer an outstanding decision.
|
|
331
|
+
if (fromRoleId && String(text).startsWith('⚠️ 需要您的决策') && !group.pendingDecision) {
|
|
332
|
+
group.pendingDecision = { seq: line.seq, time: Date.now(), taskId: boundTaskId, runId: caller?.id, roleId: fromRoleId,
|
|
333
|
+
options: String(text).match(/可选项:([\s\S]*)$/)?.[1] ?? '' }
|
|
334
|
+
rt.touch()
|
|
335
|
+
} else if (!fromRoleId && !system && group.pendingDecision) {
|
|
336
|
+
const pending = group.pendingDecision
|
|
337
|
+
const answer = String(text).trim()
|
|
338
|
+
const choices = String(pending.options ?? '').split('|').map(s => s.trim().match(/^([A-Za-z0-9]+)/)?.[1]).filter(Boolean)
|
|
339
|
+
if (decisionSeq !== undefined || /^决策回复[::]/.test(answer) || choices.includes(answer)) {
|
|
340
|
+
if (rt.resolveDecision) await rt.resolveDecision(group.id, answer, decisionSeq ?? pending.seq)
|
|
341
|
+
else { group.pendingDecision = null; rt.touch() }
|
|
342
|
+
line.taskId ||= pending.taskId
|
|
343
|
+
if (pending.taskId) return { seq: line.seq, mentions, delivered: [], decisionResolved: true }
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// 发言者本人视为已读 + 刷新活跃时间(发言 = agent 仍在活跃,
|
|
348
|
+
// 延长指示器显示窗口 2 分钟)。
|
|
349
|
+
if (fromRoleId && group.sessions?.[fromRoleId]) {
|
|
350
|
+
group.sessions[fromRoleId].seen = group.seq
|
|
351
|
+
rt.touchRunning?.(group.id, fromRoleId)
|
|
352
|
+
}
|
|
353
|
+
rt.touch()
|
|
354
|
+
|
|
355
|
+
// ===== V5 效率核心 =====
|
|
356
|
+
// 1. noWake 模式(任务通知等):只写 log,不唤醒任何人。
|
|
357
|
+
// 相关人员下次被 @ 时,digest 自然带上这些消息。
|
|
358
|
+
if (noWake) return { seq: group.seq, mentions, delivered: [] }
|
|
359
|
+
|
|
360
|
+
// 2. 确认类消息抑制:仅对 AI 成员的"收到/确认/已处理"类回复生效。
|
|
361
|
+
// 用户消息永远投递(用户的"确认"是对决策卡的关键回复,绝不能被拦截)。
|
|
362
|
+
if (fromRoleId && isAckMessage(text)) {
|
|
363
|
+
return { seq: group.seq, mentions, delivered: [] }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (line.taskId && rt.routeTaskMessage) {
|
|
367
|
+
const roleIds = mentions.length ? memberRoles.filter(r => mentions.includes(r.name) && r.id !== fromRoleId).map(r => r.id) : undefined
|
|
368
|
+
// Untargeted member progress belongs in the group, not back in its own inbox.
|
|
369
|
+
if (fromRoleId && !mentions.length) return { seq: line.seq, mentions, delivered: [] }
|
|
370
|
+
const delivered = await rt.routeTaskMessage(group, line, line.taskId, roleIds)
|
|
371
|
+
const task = rt.getTask(line.taskId)
|
|
372
|
+
const errors = []
|
|
373
|
+
for (const role of memberRoles.filter(r => mentions.includes(r.name) && !(task.assignees ?? []).includes(r.id) && r.id !== fromRoleId && r.status === 'active')) {
|
|
374
|
+
try { assertOrdinaryRoute(group, role, refs); if (await deliverToMember(group, role, line, {fromRole,toAdmins:false,mentions})) delivered.push(role.id) }
|
|
375
|
+
catch(error) {errors.push({roleId:role.id,error:String(error?.message??error)})}
|
|
376
|
+
}
|
|
377
|
+
return { seq: line.seq, mentions, delivered, queued: true, errors }
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// 3. 正常投递路由。
|
|
381
|
+
const adminIds = group.admins ?? []
|
|
382
|
+
const noMention = mentions.length === 0
|
|
383
|
+
const toAdmins = noMention && adminIds.length > 0
|
|
384
|
+
const targets = mentions.length > 0
|
|
385
|
+
? memberRoles.filter(r => mentions.includes(r.name))
|
|
386
|
+
: (toAdmins ? memberRoles.filter(r => adminIds.includes(r.id)) : memberRoles)
|
|
387
|
+
|
|
388
|
+
const delivered = [], errors = []
|
|
389
|
+
for (const role of targets) {
|
|
390
|
+
if (role.status !== 'active' || role.id === fromRoleId) continue
|
|
391
|
+
const entry = group.sessions?.[role.id]
|
|
392
|
+
if (entry && entry.seen >= line.seq) continue
|
|
393
|
+
try {
|
|
394
|
+
if (!toAdmins || refs.length) assertOrdinaryRoute(group, role, refs)
|
|
395
|
+
const ok = await deliverToMember(group, role, line, { fromRole, toAdmins, mentions })
|
|
396
|
+
if (ok) delivered.push(role.id)
|
|
397
|
+
} catch (error) { errors.push({roleId: role.id, error: String(error?.message ?? error)}) }
|
|
398
|
+
}
|
|
399
|
+
return { seq: line.seq, mentions, delivered, errors }
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function assertOrdinaryRoute(group, role, refs) {
|
|
403
|
+
if (refs.length > 1) throw new Error('消息引用多个任务,路由有歧义;请显式指定 taskId')
|
|
404
|
+
const unfinished = (rt.listTasks?.(group.id) ?? []).filter(t => t.groupId === group.id && t.schedulerVersion && t.status !== '已完成' && (t.assignees ?? []).includes(role.id))
|
|
405
|
+
if (unfinished.length) throw new Error(`成员 ${role.name} 有未完成正式执行任务 ${unfinished.map(t => t.id).join('、')};请显式指定 taskId 投递原执行会话,不能启动普通会话`)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
rt.deliverAdminHandoff = async (group, line, roleId) => {
|
|
409
|
+
if (!(group.admins ?? []).includes(roleId)) throw new Error('交接只能投递群管理员')
|
|
410
|
+
const role = rt.getRole(roleId)
|
|
411
|
+
if (!role || role.status !== 'active' || !line.id) throw new Error('交接目标或稳定消息身份无效')
|
|
412
|
+
return deliverToMember(group, role, line, { toAdmins: true, mentions: [], handoff: true })
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function deliverToMember(group, role, line, { fromRole, toAdmins, mentions, handoff = false }) {
|
|
416
|
+
const fixedTarget = handoff ? line.targetSessionId : null
|
|
417
|
+
const entry = fixedTarget
|
|
418
|
+
? (group.sessions?.[role.id]?.id === fixedTarget ? group.sessions[role.id] : { id: fixedTarget })
|
|
419
|
+
: await rt.ensureGroupSession(group, role)
|
|
420
|
+
const agent = await rt.resolveAgent(entry.id)
|
|
421
|
+
const stableId = handoff ? `dwb-handoff-${line.id}-${role.id}` : null
|
|
422
|
+
if (handoff) {
|
|
423
|
+
const events = agent.session.snapshotEvents()
|
|
424
|
+
const inbox = ctx.sessionProjections.stateOf(agent.session, 'inbox')
|
|
425
|
+
if (!inbox) throw new Error('当前宿主缺少 durable inbox 投影')
|
|
426
|
+
if (Object.values(inbox).flat().some(m => m.id === stableId) || events.some(e => e.type === 'user/message' && e.data?.id === stableId)) {
|
|
427
|
+
await ctx.sessions.flush?.(agent.session)
|
|
428
|
+
return true
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// 精简消息头(减少每次投递的 token)。
|
|
433
|
+
const header = ['发言规则:只报必要结论、需行动的协调或阻塞;不逐人报开工、不重复回执。负责人统一启动/最终交付,进度仅报重要变化,普通消息最多150字。']
|
|
434
|
+
if (mentions.includes(role.name)) header.push(`@${role.name}`)
|
|
435
|
+
else if (toAdmins) header.push(`(管理员)`)
|
|
436
|
+
|
|
437
|
+
// 🔑 种子合并:首次投递时,把种子(身份/职责/规范)与真实消息合并为一次投递。
|
|
438
|
+
// 这样 agent 在第一轮就同时看到"你的上下文"和"用户的请求",直接响应用户请求。
|
|
439
|
+
if (entry.pendingSeed) {
|
|
440
|
+
header.push(`【你的上下文(环境信息,非任务——不要单独响应此部分,直接处理最下方的「本轮消息」)】\n${entry.pendingSeed}`)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// 🔒 决策阻塞强制提醒:有未回复决策卡时,给 AI 成员注入系统级指令。
|
|
444
|
+
if (group.pendingDecision) {
|
|
445
|
+
header.push(`🛑【系统强制】当前有未回复的决策卡(seq ${group.pendingDecision.seq}),用户尚未拍板。规则:①不得代用户做决策(代决)②不得派发依赖该决策的任务③不得推进依赖该决策的工作④只有与决策完全无关的准备(如环境检测)可以继续。等待用户回复决策卡后再行动。`)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ④ 任务优先级/状态上下文:如果消息关联了任务,附上任务状态与优先级。
|
|
449
|
+
const linkedTask = line.taskId ? rt.getTask?.(line.taskId) : null
|
|
450
|
+
const taskContext = linkedTask?.groupId === group.id ? `[关联任务 ${linkedTask.id}:${linkedTask.priority} · ${linkedTask.status}]` : null
|
|
451
|
+
if (taskContext) header.push(taskContext)
|
|
452
|
+
|
|
453
|
+
const digest = digestFor(group, role.id, line.seq)
|
|
454
|
+
if (digest.length > 0) header.push(`[此前动态]\n${digest.join('\n')}`)
|
|
455
|
+
|
|
456
|
+
// 🔗 衔接层:header 全部是环境上下文(职责/规范/digest),本轮消息必须用
|
|
457
|
+
// 显式标注隔开并署名,避免裸贴在上下文末尾被误认为其中一部分。
|
|
458
|
+
// 📎 附件是本轮消息的一部分:放进【本轮消息】区块与正文并列,
|
|
459
|
+
// 不混入环境上下文(曾与职责说明黏在一起,成员分不清哪些是用户本轮发的)。
|
|
460
|
+
const parts = []
|
|
461
|
+
const body = String(line.text ?? '').trim()
|
|
462
|
+
if (body) parts.push(body)
|
|
463
|
+
if (line.attachments?.length) {
|
|
464
|
+
const list = line.attachments.map(f => `- ${f.name}(${f.size} 字节):${f.relPath}`).join('\n')
|
|
465
|
+
parts.push(`📎 附件(用户随本条消息共享,已存入群工作目录;文本/代码用 read 读取,图片用 read_image 查看):\n${list}`)
|
|
466
|
+
}
|
|
467
|
+
if (parts.length === 0) parts.push('(空消息)') // 防御:postGroupMessage 已保证非空
|
|
468
|
+
const text = header.length > 0
|
|
469
|
+
? `${header.join('\n')}\n\n【本轮消息|来自:${line.fromName}】\n${parts.join('\n\n')}`
|
|
470
|
+
: parts.join('\n\n')
|
|
471
|
+
const message = createUserMessage({
|
|
472
|
+
content: [{ type: 'text', text }],
|
|
473
|
+
source: {
|
|
474
|
+
kind: SOURCE_KIND, form: 'group',
|
|
475
|
+
groupId: group.id, groupName: group.name,
|
|
476
|
+
lineId: line.id, seq: line.seq, senderName: line.fromName,
|
|
477
|
+
},
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
if (group.status !== 'active') return false
|
|
481
|
+
agent.followup(stableId ? { ...message, id: stableId } : message)
|
|
482
|
+
await ctx.sessions.flush?.(agent.session)
|
|
483
|
+
// A durable handoff may retry an archived target; never resurrect it as the current session.
|
|
484
|
+
if (group.sessions?.[role.id]?.id === entry.id) {
|
|
485
|
+
entry.pendingSeed = null
|
|
486
|
+
entry.count = (entry.count ?? 0) + 1
|
|
487
|
+
entry.seen = Math.max(entry.seen ?? 0, line.seq ?? 0)
|
|
488
|
+
group.sessions[role.id] = entry
|
|
489
|
+
rt.touch()
|
|
490
|
+
}
|
|
491
|
+
// 工作状态由 DSH 原生 SessionSummary.running 提供(activityList 查询),
|
|
492
|
+
// 不再维护自定义 running Map。
|
|
493
|
+
return true
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
rt.groupTranscript = (groupId, sinceSeq = 0) => {
|
|
497
|
+
const group = rt.getGroup(groupId)
|
|
498
|
+
if (!group) return []
|
|
499
|
+
ensureLog(group)
|
|
500
|
+
return (group.log ?? []).filter(line => line.seq > sinceSeq)
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
rt.groupMessageCount = (groupId) => {
|
|
504
|
+
const group = rt.getGroup(groupId)
|
|
505
|
+
if (!group) return 0
|
|
506
|
+
ensureLog(group)
|
|
507
|
+
return group.seq ?? 0
|
|
508
|
+
}
|
|
509
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { snapshotSignature } from './work-item-sync.js'
|
|
2
|
+
|
|
3
|
+
/** Durable review handoffs; execution completion never implies user delivery. */
|
|
4
|
+
export function install(rt) {
|
|
5
|
+
let tail = Promise.resolve()
|
|
6
|
+
const mutations = new Map()
|
|
7
|
+
const mutate = (groupId, operation) => {
|
|
8
|
+
const previous = mutations.get(groupId) ?? Promise.resolve()
|
|
9
|
+
const result = previous.catch(() => {}).then(operation)
|
|
10
|
+
const settled = result.catch(() => {})
|
|
11
|
+
mutations.set(groupId, settled)
|
|
12
|
+
settled.finally(() => { if (mutations.get(groupId) === settled) mutations.delete(groupId) })
|
|
13
|
+
return result
|
|
14
|
+
}
|
|
15
|
+
// Final notification and scope changes share one project commit boundary.
|
|
16
|
+
const createTask = rt.createTask, updateTask = rt.updateTask
|
|
17
|
+
rt.createTask = args => mutate(args.groupId, () => createTask(args))
|
|
18
|
+
rt.updateTask = (id, patch, options) => mutate(rt.getTask(id)?.groupId ?? id, () => updateTask(id, patch, options))
|
|
19
|
+
const tasksFor = group => rt.listTasks().filter(t => t.groupId === group.id)
|
|
20
|
+
const runsFor = group => (rt.executionRuns?.() ?? []).filter(r => r.groupId === group.id)
|
|
21
|
+
const signatureFor = group => snapshotSignature(tasksFor(group), runsFor(group))
|
|
22
|
+
const save = async () => { rt.touch(); await rt.saveAll() }
|
|
23
|
+
const outside = fn => {
|
|
24
|
+
const agents = rt.ctx.get?.('agents')
|
|
25
|
+
return agents?.withoutInitiator ? agents.withoutInitiator(fn) : fn()
|
|
26
|
+
}
|
|
27
|
+
const process = async ({ deliver = true } = {}) => {
|
|
28
|
+
const outbox = (rt.state.execution.reviewOutbox ??= [])
|
|
29
|
+
let changed = false
|
|
30
|
+
for (const group of rt.listGroups().filter(g => g.status === 'active')) {
|
|
31
|
+
const tasks = tasksFor(group), signature = signatureFor(group)
|
|
32
|
+
const complete = tasks.length > 0 && tasks.every(t => t.status === '已完成')
|
|
33
|
+
const previous = group.delivery
|
|
34
|
+
if (!previous || previous.signature !== signature) {
|
|
35
|
+
group.delivery = { signature, status: complete ? 'awaiting-review' : 'in-progress', updatedAt: Date.now() }
|
|
36
|
+
changed = true
|
|
37
|
+
// Initial recovery records historical facts without unexpectedly waking old projects.
|
|
38
|
+
if (complete && previous) {
|
|
39
|
+
const id = `review:${group.id}:${signature}`
|
|
40
|
+
if (!outbox.some(n => n.id === id)) outbox.push({ id, groupId: group.id, signature, status: 'pending', createdAt: Date.now() })
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
for (const notice of outbox.filter(n => n.groupId === group.id && n.status === 'pending' && (n.signature !== signature || group.delivery.status === 'delivered'))) {
|
|
44
|
+
notice.status = 'superseded'; changed = true
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (changed) await save()
|
|
48
|
+
if (!deliver) return
|
|
49
|
+
for (const notice of outbox.filter(n => n.status === 'pending')) {
|
|
50
|
+
const group = rt.getGroup(notice.groupId)
|
|
51
|
+
if (!group || group.status !== 'active') { notice.status = 'discarded'; await save(); continue }
|
|
52
|
+
const roleId = notice.targetSessionId ? notice.roleId : (group.admins ?? []).find(id => group.members?.includes(id) && rt.getRole(id)?.status === 'active')
|
|
53
|
+
if (!roleId) {
|
|
54
|
+
if (notice.error !== '无可用群管理员,等待管理员恢复') { notice.error = '无可用群管理员,等待管理员恢复'; await save() }
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
if (!notice.targetSessionId) {
|
|
59
|
+
const entry = await rt.ensureGroupSession(group, rt.getRole(roleId))
|
|
60
|
+
notice.roleId = roleId; notice.targetSessionId = entry.id
|
|
61
|
+
await save() // Pin recipient before admission; rotation cannot duplicate a retry.
|
|
62
|
+
}
|
|
63
|
+
await outside(async () => {
|
|
64
|
+
await rt.deliverAdminHandoff(group, { id: notice.id, targetSessionId: notice.targetSessionId, seq: 0, fromName: '系统',
|
|
65
|
+
text: `【最终复核交接】当前任务版本已全部完成。请读取任务执行结果与证据,核对使用入口、使用说明及未验证范围;必要时安排返工,不得把测试完成直接视为交付。复核完成后用 dwb_notify_user 向用户统一交付一次。当前交接:${notice.id}` }, roleId)
|
|
66
|
+
})
|
|
67
|
+
notice.status = 'sent'; notice.roleId = roleId; notice.sentAt = Date.now(); delete notice.error
|
|
68
|
+
group.delivery.reviewRequestedAt = notice.sentAt
|
|
69
|
+
await save()
|
|
70
|
+
} catch (error) {
|
|
71
|
+
const message = String(error?.message ?? error)
|
|
72
|
+
if (notice.error !== message) { notice.error = message; await save() }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
rt.syncDelivery = options => { const job = tail.then(() => process(options)); tail = job.catch(() => {}); return job }
|
|
77
|
+
rt.completeDelivery = ({ groupId, fromRoleId, summary }) => mutate(groupId, async () => {
|
|
78
|
+
await rt.loadAll(); await rt.executionReady
|
|
79
|
+
const group = rt.getGroup(groupId)
|
|
80
|
+
if (!group || group.status !== 'active') throw new Error('群组不存在或不可用')
|
|
81
|
+
const roleId = rt.resolveGroupActor(group, fromRoleId)
|
|
82
|
+
if (!roleId || !group.admins?.includes(roleId)) throw new Error('仅群管理员可以完成最终交付')
|
|
83
|
+
const tasks = tasksFor(group)
|
|
84
|
+
if (!tasks.length || tasks.some(t => t.status !== '已完成')) throw new Error('仍有未完成任务,不能宣布最终交付')
|
|
85
|
+
const signature = signatureFor(group)
|
|
86
|
+
if (!String(summary ?? '').trim()) throw new Error('交付摘要不能为空')
|
|
87
|
+
if (group.delivery?.status === 'delivered' && group.delivery.signature === signature) return { duplicate: true }
|
|
88
|
+
await rt.postGroupMessage({ groupId, fromRoleId: roleId, noWake: true,
|
|
89
|
+
messageId: `delivery:${group.id}:${signature}`, text: `✅ 任务完成(${rt.getRole(roleId)?.name ?? '管理员'}):\n${summary}` })
|
|
90
|
+
group.delivery = { signature, status: 'delivered', notifiedAt: Date.now(), summary: String(summary), roleId }
|
|
91
|
+
await save()
|
|
92
|
+
await rt.syncWorkItems?.()
|
|
93
|
+
return { duplicate: false }
|
|
94
|
+
})
|
|
95
|
+
}
|