cs-bots 0.9.0 → 0.9.3
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/CHANGELOG.md +12 -0
- package/docs/01-/346/236/266/346/236/204/346/226/207/346/241/243.md +5 -5
- package/docs/02-/351/234/200/346/261/202/350/247/204/346/240/274/344/271/246.md +1 -1
- package/docs/03-/350/257/246/347/273/206/350/256/276/350/256/241.md +1 -1
- package/docs/04-/346/223/215/344/275/234/346/211/213/345/206/214.md +1 -1
- package/package.json +9 -1
- package/src/api.js +267 -20
- package/src/board.js +94 -9
- package/src/client.js +86 -8
- package/src/room.js +111 -37
- package/src/roster.js +130 -24
- package/src/suggest.js +110 -0
- package/src/tools.js +75 -2
package/src/client.js
CHANGED
|
@@ -2464,6 +2464,8 @@ window.__ModuleLoader__.load({
|
|
|
2464
2464
|
// 后端在「没有执行者」时返回 ok:false + code NO_EXECUTOR,call() 会抛错 ——
|
|
2465
2465
|
// 那不是故障,是正常空态,要按空态呈现。
|
|
2466
2466
|
const noExecutor = /NO_EXECUTOR/.test(convQ.error ?? '') || /没有执行者/.test(convQ.error ?? '')
|
|
2467
|
+
// t-40:有执行者但一条记录都没有(既没对话也没补充说明)——也是一种正常空态
|
|
2468
|
+
const noConversation = /NO_CONVERSATION/.test(convQ.error ?? '') || /还没有对话记录/.test(convQ.error ?? '')
|
|
2467
2469
|
|
|
2468
2470
|
/** 跳到执行者的会话(宿主支持就直接开,不支持则复制 ID 兜底)。 */
|
|
2469
2471
|
const gotoSession = async () => {
|
|
@@ -2482,8 +2484,13 @@ window.__ModuleLoader__.load({
|
|
|
2482
2484
|
}
|
|
2483
2485
|
|
|
2484
2486
|
const roleLabel = (m) => {
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
+
// t-40:消息来自两条来源 —— executor(执行者会话)/ note(用户在任务里的补充说明)。
|
|
2488
|
+
// 用户说的话必须一眼可辨,所以 note 来源的 user 消息额外标「任务里」。
|
|
2489
|
+
if (m.role === 'user') {
|
|
2490
|
+
if (m.source === 'note') return `${HUMAN_SELF_LABEL}(任务里)`
|
|
2491
|
+
return HUMAN_SELF_LABEL
|
|
2492
|
+
}
|
|
2493
|
+
if (m.role === 'assistant') return m.speaker && m.speaker !== bot?.name ? m.speaker : (bot?.name ?? '执行者')
|
|
2487
2494
|
if (m.role === 'tool') return m.toolName ? `工具 · ${m.toolName}` : '工具'
|
|
2488
2495
|
return m.role ?? '?'
|
|
2489
2496
|
}
|
|
@@ -2496,9 +2503,10 @@ window.__ModuleLoader__.load({
|
|
|
2496
2503
|
h(Section, {
|
|
2497
2504
|
title: '任务对话',
|
|
2498
2505
|
desc: bot
|
|
2499
|
-
? `执行者「${bot.name}
|
|
2506
|
+
? `执行者「${bot.name}」的会话 + 你在任务里说的话,按时间合并(共 ${messages.length} 条`
|
|
2507
|
+
+ `${data?.counts ? `:执行者 ${data.counts.executor} 条 / 任务说明 ${data.counts.note} 条` : ''})。`
|
|
2500
2508
|
+ '这里是只读的:要看全部或继续聊,点「去对话」。'
|
|
2501
|
-
: '任务派给 Bot
|
|
2509
|
+
: '任务派给 Bot 之后,它的执行过程和你下的指令都会出现在这里。',
|
|
2502
2510
|
}, null),
|
|
2503
2511
|
|
|
2504
2512
|
convQ.loading ? h(Skeleton, { n: 2 }) : null,
|
|
@@ -2509,7 +2517,13 @@ window.__ModuleLoader__.load({
|
|
|
2509
2517
|
title: '还没有执行者',
|
|
2510
2518
|
hint: '任务派给 Bot 之后才会有对话记录。先在「可以做什么」里派活,或者用 Bot 页的「派发」把任务交给它。',
|
|
2511
2519
|
})
|
|
2512
|
-
:
|
|
2520
|
+
: noConversation
|
|
2521
|
+
? h(Empty, {
|
|
2522
|
+
icon: '◌',
|
|
2523
|
+
title: '还没有对话记录',
|
|
2524
|
+
hint: '这个任务既没派活、也没有补充说明。派活之后执行过程会出现在这里;你也可以在下面写一条补充说明,它同样会显示在这里。',
|
|
2525
|
+
})
|
|
2526
|
+
: convQ.error
|
|
2513
2527
|
? h('div', { className: 'csb-err' }, `读不到对话:${convQ.error}`)
|
|
2514
2528
|
: null,
|
|
2515
2529
|
|
|
@@ -6411,6 +6425,38 @@ window.__ModuleLoader__.load({
|
|
|
6411
6425
|
}
|
|
6412
6426
|
}
|
|
6413
6427
|
|
|
6428
|
+
// ── 建议编辑(t-40)──
|
|
6429
|
+
// Boss:「用户应该可以修改完善并确定等级范围」。
|
|
6430
|
+
// 只给「采纳/驳回」时,措辞不合意就只能驳回重等 —— 越推越远。
|
|
6431
|
+
const [sgEdit, setSgEdit] = useState(null)
|
|
6432
|
+
const openSgEdit = (sg) => setSgEdit({
|
|
6433
|
+
id: sg.id,
|
|
6434
|
+
rev: sg.rev,
|
|
6435
|
+
kind: sg.kind,
|
|
6436
|
+
layer: sg.layer ?? sg.payload?.layer ?? '',
|
|
6437
|
+
content: sg.payload?.content ?? sg.payload?.name ?? sg.payload?.title ?? '',
|
|
6438
|
+
})
|
|
6439
|
+
const saveSgEdit = async () => {
|
|
6440
|
+
if (!sgEdit) return
|
|
6441
|
+
setErr(null)
|
|
6442
|
+
try {
|
|
6443
|
+
await call('/suggestions/edit', {
|
|
6444
|
+
method: 'POST',
|
|
6445
|
+
body: {
|
|
6446
|
+
id: sgEdit.id,
|
|
6447
|
+
expectedRev: sgEdit.rev,
|
|
6448
|
+
content: sgEdit.content,
|
|
6449
|
+
...(sgEdit.kind === 'memory' && sgEdit.layer ? { layer: sgEdit.layer } : {}),
|
|
6450
|
+
},
|
|
6451
|
+
})
|
|
6452
|
+
setOk('已保存修改,它仍是待确认状态')
|
|
6453
|
+
setSgEdit(null)
|
|
6454
|
+
reloadSug()
|
|
6455
|
+
} catch (e) {
|
|
6456
|
+
setErr(e.message)
|
|
6457
|
+
}
|
|
6458
|
+
}
|
|
6459
|
+
|
|
6414
6460
|
const decideSuggestion = async (sg, accept) => {
|
|
6415
6461
|
setErr(null)
|
|
6416
6462
|
try {
|
|
@@ -6643,10 +6689,11 @@ window.__ModuleLoader__.load({
|
|
|
6643
6689
|
footer: [h('button', { key: 'c', className: 'csb-btn', onClick: () => setShowSug(false) }, '关闭')],
|
|
6644
6690
|
},
|
|
6645
6691
|
h('p', { style: { color: 'var(--dsw-alias-label-secondary)', fontSize: 12 } },
|
|
6646
|
-
'
|
|
6692
|
+
'这些是模型整理的「它认为该记住/该改」的条目。不点采纳不会生效;措辞或等级不合意,点「编辑」改完再采纳。'),
|
|
6647
6693
|
suggestions.length === 0
|
|
6648
6694
|
? h(Empty, { icon: '✓', title: '没有待确认的建议', hint: '有建议时这里会出现。' })
|
|
6649
|
-
: h('div',
|
|
6695
|
+
: h('div', null,
|
|
6696
|
+
h('div', { className: 'csb-list' }, suggestions.map((sg) => h('div', { key: sg.id, className: 'csb-card' },
|
|
6650
6697
|
h('div', { className: 'csb-card-main' },
|
|
6651
6698
|
h('div', { className: 'csb-card-t2' }, sg.payload?.content ?? sg.payload?.name ?? sg.payload?.title ?? '(无内容)'),
|
|
6652
6699
|
h('div', { className: 'csb-card-m' },
|
|
@@ -6655,8 +6702,39 @@ window.__ModuleLoader__.load({
|
|
|
6655
6702
|
sg.reason ? h('span', null, sg.reason) : null,
|
|
6656
6703
|
sg.confidence ? h('span', null, `把握 ${Math.round(sg.confidence * 100)}%`) : null)),
|
|
6657
6704
|
h('div', { style: { display: 'flex', gap: 6, flex: 'none' } },
|
|
6705
|
+
h('button', { className: 'csb-btn csb-btn-s', onClick: () => (sgEdit?.id === sg.id ? setSgEdit(null) : openSgEdit(sg)) }, sgEdit?.id === sg.id ? '收起' : '编辑'),
|
|
6658
6706
|
h('button', { className: 'csb-btn csb-btn-s csb-btn-p', onClick: () => decideSuggestion(sg, true) }, '采纳'),
|
|
6659
|
-
h('button', { className: 'csb-btn csb-btn-s', onClick: () => decideSuggestion(sg, false) }, '驳回'))))))
|
|
6707
|
+
h('button', { className: 'csb-btn csb-btn-s', onClick: () => decideSuggestion(sg, false) }, '驳回')))))),
|
|
6708
|
+
// 编辑面板:与列表平级,不嵌进卡片 —— 改动更小、也不会打乱既有括号结构
|
|
6709
|
+
sgEdit
|
|
6710
|
+
? h('div', { className: 'csb-card', style: { marginTop: 10, padding: 12 } },
|
|
6711
|
+
h('div', { style: { fontWeight: 600, marginBottom: 6 } }, `编辑建议 ${sgEdit.id}`),
|
|
6712
|
+
h('textarea', {
|
|
6713
|
+
className: 'csb-input',
|
|
6714
|
+
rows: 4,
|
|
6715
|
+
value: sgEdit.content,
|
|
6716
|
+
onInput: (e) => setSgEdit({ ...sgEdit, content: e.target.value }),
|
|
6717
|
+
style: { width: '100%', fontFamily: 'inherit', marginBottom: 8 },
|
|
6718
|
+
}),
|
|
6719
|
+
sgEdit.kind === 'memory'
|
|
6720
|
+
? h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 } },
|
|
6721
|
+
h('span', { style: { fontSize: 12 } }, '等级'),
|
|
6722
|
+
h('select', {
|
|
6723
|
+
className: 'csb-input',
|
|
6724
|
+
value: sgEdit.layer,
|
|
6725
|
+
onChange: (e) => setSgEdit({ ...sgEdit, layer: e.target.value }),
|
|
6726
|
+
},
|
|
6727
|
+
h('option', { value: '' }, '(不指定)'),
|
|
6728
|
+
h('option', { value: 'L1' }, 'L1 用户偏好'),
|
|
6729
|
+
h('option', { value: 'L2' }, 'L2 团队全局'),
|
|
6730
|
+
h('option', { value: 'L3' }, 'L3 项目 key'),
|
|
6731
|
+
h('option', { value: 'L4' }, 'L4 任务笔记'),
|
|
6732
|
+
h('option', { value: 'L5' }, 'L5 日札')))
|
|
6733
|
+
: h('div', { style: { fontSize: 12, opacity: 0.7, marginBottom: 8 } }, '(该类型没有等级可选)'),
|
|
6734
|
+
h('div', { style: { display: 'flex', gap: 6 } },
|
|
6735
|
+
h('button', { className: 'csb-btn csb-btn-s csb-btn-p', onClick: saveSgEdit }, '保存修改'),
|
|
6736
|
+
h('button', { className: 'csb-btn csb-btn-s', onClick: () => setSgEdit(null) }, '取消')))
|
|
6737
|
+
: null)
|
|
6660
6738
|
: null,
|
|
6661
6739
|
|
|
6662
6740
|
// ── 注入效果 ──
|
package/src/room.js
CHANGED
|
@@ -162,10 +162,30 @@ const USER = 'user'
|
|
|
162
162
|
* 缺省广播把 Boss 自己也算成唤醒目标,每次必失败一条。
|
|
163
163
|
*/
|
|
164
164
|
const HUMAN_IDS = Object.freeze(['ui', 'user', 'person'])
|
|
165
|
-
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* **规范用户 id**(2026-11-12 t-40 需求⑤)。
|
|
168
|
+
*
|
|
169
|
+
* Boss:「以后要统一一下名称……人=用户=boss=user=我」。
|
|
170
|
+
* 历史数据里同一个人有三种写法(`ui` 来自页面层、`user` 来自 API 层、`person` 来自早期约定),
|
|
171
|
+
* 于是「这是谁」在每一处都要判三遍,迟早漏一处。
|
|
172
|
+
*
|
|
173
|
+
* 采用**读兼容 + 写统一**,而不是一刀切改写历史:
|
|
174
|
+
* - 读:`ui`/`person` 一律视为 `user`(`HUMAN_IDS` 仍然认它们,老数据不会失效);
|
|
175
|
+
* - 写:新写入一律用 `user`。
|
|
176
|
+
* 这样不破坏历史事件流(append-only,改历史等于伪造),又能让新数据只有一个写法。
|
|
177
|
+
*/
|
|
178
|
+
const CANONICAL_USER = 'user'
|
|
179
|
+
|
|
180
|
+
/** 是否为人类身份(`ui`/`user`/`person` 都认 —— 读侧兼容)。 */
|
|
166
181
|
function isHumanId(id) {
|
|
167
182
|
return typeof id === 'string' && HUMAN_IDS.includes(id)
|
|
168
183
|
}
|
|
184
|
+
|
|
185
|
+
/** 把任意人类写法归一成规范 id `user`;非人类身份原样返回。 */
|
|
186
|
+
function normalizeHumanId(id) {
|
|
187
|
+
return isHumanId(id) ? CANONICAL_USER : id
|
|
188
|
+
}
|
|
169
189
|
/** 房间事件类型集合(审计流可枚举)。 */
|
|
170
190
|
const EVENTS = [
|
|
171
191
|
'room_created',
|
|
@@ -352,6 +372,64 @@ function collectMentions(message, members) {
|
|
|
352
372
|
return out
|
|
353
373
|
}
|
|
354
374
|
|
|
375
|
+
/**
|
|
376
|
+
* 计算「一条房间消息该唤醒谁」—— **唤醒风暴的唯一真相源**。
|
|
377
|
+
*
|
|
378
|
+
* 为什么抽成独立函数(2026-11-12 t-40-B 修复):
|
|
379
|
+
* 房间有**两条**唤醒路径,过去各写一份目标计算,于是必然漂移:
|
|
380
|
+
* - `room.relay()`(`POST /rooms/relay`)实现了四档规则;
|
|
381
|
+
* - `POST /rooms/post`(Bot 用 `bot_room_post`、Boss 用 UI **都走这条**)却是
|
|
382
|
+
* 裸的 `members.filter((m) => m !== skipSelf)` —— 不看 `mentionOnly`、
|
|
383
|
+
* 不排除人类 id。实测 6 个房间全设了 `mentionOnly:true`,**一次都没生效**;
|
|
384
|
+
* 8 成员房间 ⇒ 一条消息 8 次模型调用,其中打给人类 id 的必然失败。
|
|
385
|
+
*
|
|
386
|
+
* 所以这里只留**一份**规则,两个调用点都用它。改规则只改这里。
|
|
387
|
+
*
|
|
388
|
+
* 四档,**顺序即优先级**(与 relay 原注释一致):
|
|
389
|
+
* ① 显式 targets → 原样采信(剔除人类,并 warn:那是调用方的错)
|
|
390
|
+
* ② mentionOnly=true 且 @ 了某人 → 只打给被 @ 的成员(不含发送者/人类)
|
|
391
|
+
* ③ mentionOnly=true 但没 @ → **空**(只落消息不唤醒,合法结果,不是错误)
|
|
392
|
+
* ④ mentionOnly=false(显式关掉定向)→ 除发送者外的全部成员,**仍排除人类**
|
|
393
|
+
*
|
|
394
|
+
* @param {{ members?: string[], settings?: object, from?: string }} room 房间快照
|
|
395
|
+
* @param {{ from?: string, text?: string, mentions?: string[] }} message 消息
|
|
396
|
+
* @param {{ targets?: unknown, warn?: (m: string) => void }} [options]
|
|
397
|
+
* 显式目标(仅 relay 用);`warn` 注入调用方所在作用域的日志器
|
|
398
|
+
* (工厂内部有 `warn`,模块顶层没有 —— 这里显式注入,不靠隐式作用域)
|
|
399
|
+
* @returns {{ targets: string[], source: 'explicit'|'mention'|'mention-only-no-target'|'broadcast' }}
|
|
400
|
+
*/
|
|
401
|
+
function computeWakeTargets(room, message, options = {}) {
|
|
402
|
+
const members = Array.isArray(room?.members) ? room.members : []
|
|
403
|
+
const settings = room?.settings ?? DEFAULT_SETTINGS
|
|
404
|
+
const mentionOnly = settings.mentionOnly !== false
|
|
405
|
+
// 发送者自己永远不唤醒(发消息的人不需要被自己的消息叫醒)
|
|
406
|
+
const sender = message?.from ?? room?.from
|
|
407
|
+
/** 唤醒排除表:人类一律不算唤醒目标(人没有会话,唤醒必然 agent-not-live)。 */
|
|
408
|
+
const wakeable = (id) => id !== sender && !isHumanId(id)
|
|
409
|
+
|
|
410
|
+
if (options?.targets !== undefined && options?.targets !== null) {
|
|
411
|
+
let list = stringList(options.targets, 'targets', 'relay')
|
|
412
|
+
const humans = list.filter((t) => isHumanId(t))
|
|
413
|
+
if (humans.length > 0) {
|
|
414
|
+
const say = typeof options.warn === 'function' ? options.warn : () => {}
|
|
415
|
+
say(`[cs-bots/room] 显式 targets 含人类身份 ${humans.join(',')} —— 已剔除(人不可被唤醒)`)
|
|
416
|
+
list = list.filter((t) => !isHumanId(t))
|
|
417
|
+
}
|
|
418
|
+
return { targets: list, source: 'explicit' }
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (mentionOnly) {
|
|
422
|
+
const mentioned = collectMentions(message, members)
|
|
423
|
+
const targets = mentioned.filter(wakeable)
|
|
424
|
+
return targets.length > 0
|
|
425
|
+
? { targets, source: 'mention' }
|
|
426
|
+
: { targets: [], source: 'mention-only-no-target' }
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ④ 显式关掉定向才广播 —— 即便如此也不唤醒人类、不唤醒发送者自己
|
|
430
|
+
return { targets: members.filter(wakeable), source: 'broadcast' }
|
|
431
|
+
}
|
|
432
|
+
|
|
355
433
|
/** 时间参数归一:缺省 now;支持 ms 数字与 Date。 */
|
|
356
434
|
function normalizeNow(value, where) {
|
|
357
435
|
if (value === undefined || value === null) return Date.now()
|
|
@@ -1966,42 +2044,29 @@ export function createRoom(ctx, config = {}) {
|
|
|
1966
2044
|
let targetSource
|
|
1967
2045
|
let noTargetReason
|
|
1968
2046
|
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
noTargetReason = 'mention-only-no-target'
|
|
1993
|
-
}
|
|
1994
|
-
} else {
|
|
1995
|
-
targetList = room.members.filter(wakeable)
|
|
1996
|
-
targetSource = 'broadcast'
|
|
1997
|
-
if (targetList.length === 0) {
|
|
1998
|
-
throw new RoomError(
|
|
1999
|
-
`room.relay:房间 ${room.id} 除发送者(${message.from})外没有其他可唤醒成员,且未显式指定 targets`
|
|
2000
|
-
+ ' —— 拒绝空转发(静默无操作)',
|
|
2001
|
-
'ROOM_NO_TARGETS',
|
|
2002
|
-
{ roomId: room.id, messageId: mid, from: message.from },
|
|
2003
|
-
)
|
|
2004
|
-
}
|
|
2047
|
+
// 规则统一在 computeWakeTargets(t-40-B):与 `POST /rooms/post` 共用同一份,
|
|
2048
|
+
// 避免两条唤醒路径各写一套而再度漂移。
|
|
2049
|
+
const decided = computeWakeTargets(room, message, { targets, warn })
|
|
2050
|
+
targetList = decided.targets
|
|
2051
|
+
targetSource = decided.source
|
|
2052
|
+
if (targetSource === 'mention-only-no-target') noTargetReason = 'mention-only-no-target'
|
|
2053
|
+
// 显式 targets 给了空数组 ⇒ 调用方契约违反,抛错(computeWakeTargets 只管剔除人类)
|
|
2054
|
+
if (targetSource === 'explicit' && Array.isArray(targets) && targets.length === 0) {
|
|
2055
|
+
throw new RoomError(
|
|
2056
|
+
`room.relay:targets 是空数组 —— 拒绝空转发(静默无操作),请显式给出目标或省略该字段走缺省`,
|
|
2057
|
+
'ROOM_NO_TARGETS',
|
|
2058
|
+
{ roomId: room.id, messageId: mid },
|
|
2059
|
+
)
|
|
2060
|
+
}
|
|
2061
|
+
// 广播档但「除发送者外全是人类」⇒ 这是**配置错误**(没有 bot 可广播),抛错比静默诚实。
|
|
2062
|
+
// 注意:mention 档的空结果不算错(③「只记录不打扰」是合法结果),二者不能混。
|
|
2063
|
+
if (targetSource === 'broadcast' && targetList.length === 0) {
|
|
2064
|
+
throw new RoomError(
|
|
2065
|
+
`room.relay:房间 ${room.id} 除发送者(${message.from})外没有其他可唤醒成员,且未显式指定 targets`
|
|
2066
|
+
+ ' —— 拒绝空转发(静默无操作)',
|
|
2067
|
+
'ROOM_NO_TARGETS',
|
|
2068
|
+
{ roomId: room.id, messageId: mid, from: message.from },
|
|
2069
|
+
)
|
|
2005
2070
|
}
|
|
2006
2071
|
|
|
2007
2072
|
// ── ③「没 @ 到谁」:合法空转发 —— 记事件、返回 relayed:0,**不抛错** ──────
|
|
@@ -2304,6 +2369,14 @@ export function createRoom(ctx, config = {}) {
|
|
|
2304
2369
|
leave,
|
|
2305
2370
|
post,
|
|
2306
2371
|
relay,
|
|
2372
|
+
/**
|
|
2373
|
+
* 计算「这条消息该唤醒谁」(t-40-B)。
|
|
2374
|
+
* 暴露给 `POST /rooms/post` 复用,保证两条唤醒路径共用同一份规则;
|
|
2375
|
+
* 纯函数、不落盘、不唤醒,调用方自己决定要不要投递。
|
|
2376
|
+
* 绑定本实例的 `warn`,这样 HTTP 路径的告警与 relay 走同一个 logger。
|
|
2377
|
+
*/
|
|
2378
|
+
computeWakeTargets: (room, message, options = {}) =>
|
|
2379
|
+
computeWakeTargets(room, message, { ...options, warn }),
|
|
2307
2380
|
readBody,
|
|
2308
2381
|
listMessages,
|
|
2309
2382
|
// DESIGN-v2 ①:房间配置 / 归档 / 硬删 / 成员角色(签名见 DESIGN 契约,勿改名)
|
|
@@ -2338,5 +2411,6 @@ export const ROOM_DEFAULT_SETTINGS = Object.freeze({ ...DEFAULT_SETTINGS })
|
|
|
2338
2411
|
|
|
2339
2412
|
/** 用户(人类)发送者 id(`from` 允许的特殊值)。 */
|
|
2340
2413
|
export const ROOM_USER = USER
|
|
2414
|
+
export const ROOM_CANONICAL_USER = CANONICAL_USER
|
|
2341
2415
|
|
|
2342
2416
|
export default createRoom
|
package/src/roster.js
CHANGED
|
@@ -100,6 +100,7 @@ const ACTIONS = [
|
|
|
100
100
|
'create',
|
|
101
101
|
'rename',
|
|
102
102
|
'retire',
|
|
103
|
+
'purge',
|
|
103
104
|
'set_cwd',
|
|
104
105
|
'claim',
|
|
105
106
|
'release',
|
|
@@ -517,6 +518,8 @@ export function createRoster(ctx, config = {}) {
|
|
|
517
518
|
skipped += 1
|
|
518
519
|
continue
|
|
519
520
|
}
|
|
521
|
+
// purge 是终态:重放时把它从内存表移除,而不是复活成一条记录
|
|
522
|
+
if (ev.after.state === 'purged') { bots.delete(ev.botId); events.push(clone(ev)); continue }
|
|
520
523
|
bots.set(ev.botId, clone(ev.after))
|
|
521
524
|
events.push(clone(ev))
|
|
522
525
|
}
|
|
@@ -694,7 +697,7 @@ export function createRoster(ctx, config = {}) {
|
|
|
694
697
|
* @throws {RosterError} 缺字段 / 非法 cwd / 重名 / 会话创建失败 / 事件落盘失败
|
|
695
698
|
*/
|
|
696
699
|
async function create(input = {}) {
|
|
697
|
-
const { name, cwd, label, model, instructions, preset, permission, profile, prepareCommand, byId, now } = input ?? {}
|
|
700
|
+
const { name, cwd, label, model, instructions, preset, permission, profile, prepareCommand, byId, now, adoptSession } = input ?? {}
|
|
698
701
|
const by = requireString(byId, 'byId', 'create')
|
|
699
702
|
const displayName = requireString(name, 'name', 'create')
|
|
700
703
|
const dir = assertBotCwd(cwd, 'create')
|
|
@@ -707,6 +710,28 @@ export function createRoster(ctx, config = {}) {
|
|
|
707
710
|
// ── 唯一性(校验通过后才动会话,避免「建了会话才发现重名」的资源泄漏)──
|
|
708
711
|
assertNameFree(displayName, undefined, 'create')
|
|
709
712
|
|
|
713
|
+
// ── adoptSession:把**已存在的会话**注册成 Bot(2026-11-11 Boss 需求)──
|
|
714
|
+
// 为什么需要:`create()` 原本只会**新建**会话(`bot-${randomUUID()}`),
|
|
715
|
+
// 无法接管一个已经在跑、已有上下文的会话。Boss 要把当前会话(带着全部上下文)
|
|
716
|
+
// 变成可在协作平台派活的 Bot ⇒ 会话 id 必须保持不变。
|
|
717
|
+
// 语义边界:
|
|
718
|
+
// - `adoptSession` 给出时**不建新会话**,注册表直接引用它;
|
|
719
|
+
// - 该 sessionId 不能已被别的活跃 Bot 占用(`hasSession` 查重);
|
|
720
|
+
// - 退役 Bot 里的同名 session 不阻止接管(终态已释放名额)。
|
|
721
|
+
const adopt = optionalString(adoptSession, 'adoptSession', 'create')
|
|
722
|
+
if (adopt !== undefined) {
|
|
723
|
+
for (const other of bots.values()) {
|
|
724
|
+
if (other.state !== 'retired' && other.sessionId === adopt) {
|
|
725
|
+
throw new RosterError(
|
|
726
|
+
`roster.create:会话 ${adopt} 已被 Bot「${other.name}」(${other.id})占用——`
|
|
727
|
+
+ '同一会话不能注册成两个 Bot',
|
|
728
|
+
'ROSTER_SESSION_TAKEN',
|
|
729
|
+
{ sessionId: adopt, occupiedBy: other.id },
|
|
730
|
+
)
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
710
735
|
// ── 同 cwd 提示(非阻塞):两个活 Bot 共用一个目录会撞 fs 写入边界 ──
|
|
711
736
|
for (const other of bots.values()) {
|
|
712
737
|
if (other.state !== 'retired' && other.cwd === dir) {
|
|
@@ -717,29 +742,41 @@ export function createRoster(ctx, config = {}) {
|
|
|
717
742
|
}
|
|
718
743
|
}
|
|
719
744
|
|
|
720
|
-
// ──
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
745
|
+
// ── 会话:要么新建,要么接管已有(adoptSession)──
|
|
746
|
+
//
|
|
747
|
+
// 新建时这是**唯一能指定 cwd 的路径**(kernel.js:249-287)。
|
|
748
|
+
// 接管时**不碰会话**:那个会话已经在跑、有自己的 cwd 与上下文,
|
|
749
|
+
// 我们只是把它登记进注册表 —— 因此 `id` 直接沿用该 sessionId
|
|
750
|
+
// (不生成 `bot-xxx`),这样 `wake`/任务派活的目标 id 与真实会话对得上。
|
|
751
|
+
let id
|
|
752
|
+
let sessionId
|
|
753
|
+
if (adopt !== undefined) {
|
|
754
|
+
id = adopt
|
|
755
|
+
sessionId = adopt
|
|
756
|
+
} else {
|
|
757
|
+
id = `bot-${randomUUID()}`
|
|
758
|
+
let handle
|
|
759
|
+
try {
|
|
760
|
+
handle = await kernel.createSession(ctx, {
|
|
761
|
+
sessionId: id,
|
|
762
|
+
cwd: dir,
|
|
763
|
+
warn,
|
|
764
|
+
...(preset !== undefined && preset !== null && preset !== '' ? { agentPreset: String(preset) } : {}),
|
|
765
|
+
})
|
|
766
|
+
} catch (error) {
|
|
767
|
+
throw new RosterError(
|
|
768
|
+
`roster.create:Bot「${displayName}」的会话创建失败(cwd=${dir})→ ${error?.message ?? error}`
|
|
769
|
+
+ '——已中止注册(不产生半成品 Bot)',
|
|
770
|
+
'ROSTER_SESSION_CREATE_FAILED',
|
|
771
|
+
{ name: displayName, cwd: dir, cause: String(error?.message ?? error) },
|
|
772
|
+
)
|
|
773
|
+
}
|
|
774
|
+
sessionId = pickSessionId(handle, id)
|
|
775
|
+
if (sessionId !== id) {
|
|
776
|
+
warn(
|
|
777
|
+
`[cs-bots/roster] 宿主返回的会话 id(${sessionId})与请求的(${id})不同——以宿主返回为准并记入注册表`,
|
|
778
|
+
)
|
|
779
|
+
}
|
|
743
780
|
}
|
|
744
781
|
|
|
745
782
|
/** @type {Record<string, unknown>} */
|
|
@@ -929,6 +966,74 @@ export function createRoster(ctx, config = {}) {
|
|
|
929
966
|
return committed
|
|
930
967
|
}
|
|
931
968
|
|
|
969
|
+
/**
|
|
970
|
+
* 清理(purge)已退役的 Bot —— **把记录从注册表里真正移走**(2026-11-12 t-40,Boss 要求)。
|
|
971
|
+
*
|
|
972
|
+
* 为什么 retire 不够:`retire` 只是把 `state` 置为 `retired`,**记录永远留在注册表里**
|
|
973
|
+
* (`listRetired()` 能查到、`roster.jsonl` 里一堆测试残留),时间一长注册表就被
|
|
974
|
+
* 一堆没用的测试 Bot 撑满,管理页看不清到底有哪些在役 Bot。
|
|
975
|
+
*
|
|
976
|
+
* 语义边界(必须守住的四条):
|
|
977
|
+
* 1. **只能清已退役的** —— 在役 Bot 一律拒绝(`ROSTER_PURGE_ACTIVE`)。
|
|
978
|
+
* 退役是"我不用它了",清理是"把这条记录抹掉";两步分开,避免手滑删在役 Bot。
|
|
979
|
+
* 2. **只动注册表,绝不动 cwd 目录、绝不动会话** —— Bot 的工作目录里可能有真实产物
|
|
980
|
+
* (实测 `/home/ubuntu/output/开发测试001` 里存着 t36 调研报告),
|
|
981
|
+
* 删目录是不可逆且越权的,本函数**不碰文件系统**。
|
|
982
|
+
* 3. **事件流仍留档**:往 `roster.jsonl` 追加一条 `purge` 事件(`after` = 清理后的状态),
|
|
983
|
+
* 重放时据此把该 Bot 从内存表里移除 —— 这样「谁在什么时候清了谁」可审计,
|
|
984
|
+
* 而不是把历史抹得干干净净。
|
|
985
|
+
* 4. **幂等**:已清理的再清一次直接跳过(计入 `skipped`),不抛错。
|
|
986
|
+
*
|
|
987
|
+
* @param {{ ids?: string[], allRetired?: boolean, reason?: string, byId: string, now?: number }} input
|
|
988
|
+
* `ids` 指定要清的 Bot;`allRetired: true` 清全部已退役的(与 `ids` 二选一)
|
|
989
|
+
* @returns {{ purged: string[], skipped: string[], refused: Array<{id: string, reason: string}> }}
|
|
990
|
+
*/
|
|
991
|
+
function purge(input = {}) {
|
|
992
|
+
const { ids, allRetired, reason, byId, now } = input ?? {}
|
|
993
|
+
const by = requireString(byId, 'byId', 'purge')
|
|
994
|
+
const note = optionalString(reason, 'reason', 'purge')
|
|
995
|
+
const at = normalizeNow(now, 'purge')
|
|
996
|
+
|
|
997
|
+
let targets = []
|
|
998
|
+
if (allRetired === true) {
|
|
999
|
+
for (const b of bots.values()) if (b.state === 'retired') targets.push(b.id)
|
|
1000
|
+
} else if (Array.isArray(ids)) {
|
|
1001
|
+
targets = ids.map((x, i) => requireString(x, `ids[${i}]`, 'purge'))
|
|
1002
|
+
} else {
|
|
1003
|
+
throw new RosterError(
|
|
1004
|
+
'roster.purge:必须给 ids(数组)或 allRetired:true(二选一)—— 拒绝无目标清理',
|
|
1005
|
+
'ROSTER_PURGE_NO_TARGET',
|
|
1006
|
+
{},
|
|
1007
|
+
)
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
const purged = []
|
|
1011
|
+
const skipped = []
|
|
1012
|
+
const refused = []
|
|
1013
|
+
for (const botId of targets) {
|
|
1014
|
+
const current = bots.get(botId)
|
|
1015
|
+
if (!current) { skipped.push(botId); continue }
|
|
1016
|
+
if (current.state !== 'retired') {
|
|
1017
|
+
// 在役 Bot 绝不清理:这是本条 API 最重要的护栏
|
|
1018
|
+
refused.push({ id: botId, reason: `在役(state=${current.state})—— 先 retire 再 purge` })
|
|
1019
|
+
continue
|
|
1020
|
+
}
|
|
1021
|
+
const before = clone(current)
|
|
1022
|
+
const next = clone(current)
|
|
1023
|
+
next.rev = current.rev + 1
|
|
1024
|
+
next.state = 'purged'
|
|
1025
|
+
next.purgedAt = at
|
|
1026
|
+
next.purgedBy = by
|
|
1027
|
+
if (note !== undefined) next.purgeReason = note
|
|
1028
|
+
// 写事件而不是把它从 jsonl 里抠掉:留档「谁清的、何时清的」
|
|
1029
|
+
commit(botId, 'purge', by, at, before, next)
|
|
1030
|
+
bots.delete(botId)
|
|
1031
|
+
purged.push(botId)
|
|
1032
|
+
info(`[cs-bots/roster] Bot ${botId}(「${current.name}」)已清理出注册表`)
|
|
1033
|
+
}
|
|
1034
|
+
return { purged, skipped, refused }
|
|
1035
|
+
}
|
|
1036
|
+
|
|
932
1037
|
/**
|
|
933
1038
|
* 换工作目录(**官方完全没有的语义**:teammate 强制继承父 cwd)。
|
|
934
1039
|
*
|
|
@@ -1461,6 +1566,7 @@ export function createRoster(ctx, config = {}) {
|
|
|
1461
1566
|
hasSession,
|
|
1462
1567
|
rename,
|
|
1463
1568
|
retire,
|
|
1569
|
+
purge,
|
|
1464
1570
|
setPreset,
|
|
1465
1571
|
setCwd,
|
|
1466
1572
|
setPrepareCommand,
|
package/src/suggest.js
CHANGED
|
@@ -1040,6 +1040,115 @@ export function createSuggest(ctx, config = {}) {
|
|
|
1040
1040
|
return decided
|
|
1041
1041
|
}
|
|
1042
1042
|
|
|
1043
|
+
/**
|
|
1044
|
+
* 修改一条**待决**建议:内容 / 等级(layer)/ 标题标签都可以改(2026-11-12 t-40)。
|
|
1045
|
+
*
|
|
1046
|
+
* Boss 原话:「自动整理建议中,用户只能接纳和驳回,等级没办法选择、内容没办法修改,
|
|
1047
|
+
* 用户应该可以修改完善并确定等级范围」
|
|
1048
|
+
*
|
|
1049
|
+
* 为什么必须有这个口子:模型归纳出来的文字几乎总是「意思是对的、措辞不是我的」。
|
|
1050
|
+
* 只给「采纳/驳回」两个按钮,用户遇到措辞不合意的建议只能驳回再等它重新提一遍 ——
|
|
1051
|
+
* 而驳回会让同类建议进冷却,等于把「差一点就对」的东西越推越远。
|
|
1052
|
+
*
|
|
1053
|
+
* 语义边界:
|
|
1054
|
+
* - **只能改 pending**:已采纳/已驳回是终态,改它等于篡改历史(要提新的就 propose)。
|
|
1055
|
+
* - **改完仍是 pending**:编辑不等于采纳,用户改完可以再想想。
|
|
1056
|
+
* - **等级仍走同一套白名单**:`layer` 只能是 MEMORY_LAYERS(L0 仍不可写)。
|
|
1057
|
+
* - **改类型(kind)时 payload 必须一起给**:记忆与技能的 payload 形状不同。
|
|
1058
|
+
* - **必须 CAS**:`expectedRev` 必填,防两人同时改互相覆盖。
|
|
1059
|
+
* - **审计留痕**:写 `edit` 事件,before/after 都在事件流里可查。
|
|
1060
|
+
*
|
|
1061
|
+
* @param {{ id: string, expectedRev: number, payload?: object, kind?: string,
|
|
1062
|
+
* layer?: string, title?: string, content?: string, by: string, now?: number }} input
|
|
1063
|
+
* @returns {object} 修改后的建议(rev 已 +1)
|
|
1064
|
+
*/
|
|
1065
|
+
function edit(input = {}) {
|
|
1066
|
+
assertAlive('edit')
|
|
1067
|
+
const { id, expectedRev, kind, title, content, by, now } = input ?? {}
|
|
1068
|
+
let { payload } = input ?? {}
|
|
1069
|
+
const suggestionId = requireString(id, 'id', 'edit')
|
|
1070
|
+
const author = requireString(by, 'by', 'edit')
|
|
1071
|
+
const at = normalizeNow(now, 'edit')
|
|
1072
|
+
const current = requireSuggestion(suggestionId, 'edit')
|
|
1073
|
+
|
|
1074
|
+
if (current.status !== 'pending') {
|
|
1075
|
+
throw new SuggestError(
|
|
1076
|
+
`suggest.edit:建议 [id:${suggestionId}] 已是 ${current.status}(终态不可改)—— `
|
|
1077
|
+
+ '要改请重新提一条(propose)',
|
|
1078
|
+
'SUGGEST_NOT_PENDING',
|
|
1079
|
+
{ id: suggestionId, status: current.status },
|
|
1080
|
+
)
|
|
1081
|
+
}
|
|
1082
|
+
const rev = Number(expectedRev)
|
|
1083
|
+
if (!Number.isFinite(rev)) {
|
|
1084
|
+
throw new SuggestError(
|
|
1085
|
+
'suggest.edit:expectedRev 必填(修改是覆盖性操作,必须带版本做 CAS)',
|
|
1086
|
+
'SUGGEST_BAD_FIELD',
|
|
1087
|
+
{ id: suggestionId, field: 'expectedRev' },
|
|
1088
|
+
)
|
|
1089
|
+
}
|
|
1090
|
+
if (rev !== current.rev) {
|
|
1091
|
+
throw new SuggestError(
|
|
1092
|
+
`suggest.edit:CAS 冲突(期望 rev=${rev},实际 rev=${current.rev})—— 有人先改过了,请刷新后重试`,
|
|
1093
|
+
'SUGGEST_REV_CONFLICT',
|
|
1094
|
+
{ id: suggestionId, expectedRev: rev, actualRev: current.rev },
|
|
1095
|
+
)
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
const before = clone(current)
|
|
1099
|
+
const next = clone(current)
|
|
1100
|
+
next.rev = current.rev + 1
|
|
1101
|
+
|
|
1102
|
+
const nextKind = kind === undefined || kind === null ? current.kind : requireString(kind, 'kind', 'edit')
|
|
1103
|
+
if (!KINDS.includes(nextKind)) {
|
|
1104
|
+
throw new SuggestError(
|
|
1105
|
+
`suggest.edit:kind 只能是 ${KINDS.join(' | ')},收到 ${JSON.stringify(nextKind)}`,
|
|
1106
|
+
'SUGGEST_BAD_KIND',
|
|
1107
|
+
{ id: suggestionId, kind: nextKind },
|
|
1108
|
+
)
|
|
1109
|
+
}
|
|
1110
|
+
if (nextKind !== current.kind && payload === undefined) {
|
|
1111
|
+
throw new SuggestError(
|
|
1112
|
+
`suggest.edit:改 kind(${current.kind} → ${nextKind})必须同时给 payload —— `
|
|
1113
|
+
+ '记忆与技能的 payload 形状不同,只换类型会留下形状不匹配的脏数据',
|
|
1114
|
+
'SUGGEST_KIND_REQUIRES_PAYLOAD',
|
|
1115
|
+
{ id: suggestionId, from: current.kind, to: nextKind },
|
|
1116
|
+
)
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
if (payload === undefined && (title !== undefined || content !== undefined)) {
|
|
1120
|
+
payload = { ...(current.payload ?? {}) }
|
|
1121
|
+
if (title !== undefined) payload.title = title
|
|
1122
|
+
if (content !== undefined) payload.content = content
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
if (payload !== undefined) {
|
|
1126
|
+
const targetLayerForPayload = payload.layer ?? current.payload?.layer ?? null
|
|
1127
|
+
next.payload = normalizePayload(nextKind, payload, 'edit', targetLayerForPayload)
|
|
1128
|
+
}
|
|
1129
|
+
next.kind = nextKind
|
|
1130
|
+
|
|
1131
|
+
if (input.layer !== undefined && input.layer !== null && input.layer !== '') {
|
|
1132
|
+
if (!MEMORY_LAYERS.includes(input.layer)) {
|
|
1133
|
+
throw new SuggestError(
|
|
1134
|
+
`suggest.edit:layer 只能是 ${MEMORY_LAYERS.join(' | ')}(L0 人格仅人可写,不在此列),`
|
|
1135
|
+
+ `收到 ${JSON.stringify(input.layer)}`,
|
|
1136
|
+
'SUGGEST_BAD_LAYER',
|
|
1137
|
+
{ id: suggestionId, layer: input.layer },
|
|
1138
|
+
)
|
|
1139
|
+
}
|
|
1140
|
+
next.payload = { ...(next.payload ?? {}), layer: input.layer }
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
next.updatedAt = at
|
|
1144
|
+
next.updatedBy = author
|
|
1145
|
+
next.editedAt = at
|
|
1146
|
+
next.editedBy = author
|
|
1147
|
+
next.editCount = (Number(current.editCount) || 0) + 1
|
|
1148
|
+
|
|
1149
|
+
return commit('edit', at, author, before, next)
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1043
1152
|
/**
|
|
1044
1153
|
* 批量采纳:**部分失败不中断**(人点「全部采纳」时,一条撞错不该让其余全不处理),
|
|
1045
1154
|
* 但失败必须逐条回报(`failed[]` 带 code + reason),绝不吞掉。
|
|
@@ -1187,6 +1296,7 @@ export function createSuggest(ctx, config = {}) {
|
|
|
1187
1296
|
getSuggestion,
|
|
1188
1297
|
accept,
|
|
1189
1298
|
reject,
|
|
1299
|
+
edit,
|
|
1190
1300
|
bulkAccept,
|
|
1191
1301
|
stats,
|
|
1192
1302
|
replay,
|