cs-bots 0.7.5 → 0.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.
- package/CHANGELOG.md +294 -0
- package/README.md +47 -174
- package/docs/01-/346/236/266/346/236/204/346/226/207/346/241/243.md +14 -13
- package/docs/02-/351/234/200/346/261/202/350/247/204/346/240/274/344/271/246.md +3 -3
- package/docs/03-/350/257/246/347/273/206/350/256/276/350/256/241.md +3 -3
- package/docs/04-/346/223/215/344/275/234/346/211/213/345/206/214.md +2 -2
- package/docs/CONTRACT-/345/215/207/347/272/24709-Boss/344/275/223/351/252/214.md +280 -0
- package/docs//345/215/207/347/272/24708-/346/216/245/347/273/255/344/270/216/345/233/236/346/273/232.md +298 -0
- package/package.json +2 -2
- package/src/api.js +1105 -8
- package/src/board.js +104 -11
- package/src/client.js +731 -96
- package/src/dispatcher.js +70 -11
- package/src/index.js +8 -1
- package/src/kernel.js +15 -0
- package/src/room.js +160 -9
- package/src/roster.js +54 -0
- package/src/settings.js +2 -2
- package/src/teams.js +386 -4
- package/src/tools.js +124 -3
package/src/dispatcher.js
CHANGED
|
@@ -336,6 +336,8 @@ export function createDispatcher(ctx, config = {}) {
|
|
|
336
336
|
result,
|
|
337
337
|
// ── 平台扩展字段(§3.5 未列,但对「可读原因」与可审计是必需的)──
|
|
338
338
|
text: draft.text,
|
|
339
|
+
// t-39 第2批 D:唤醒成本可见。无团队归属时是 null(不猜)。
|
|
340
|
+
teamId: draft.teamId ?? null,
|
|
339
341
|
updatedAt: at,
|
|
340
342
|
seq: takeSeq(),
|
|
341
343
|
}
|
|
@@ -345,14 +347,22 @@ export function createDispatcher(ctx, config = {}) {
|
|
|
345
347
|
return { wake, durable }
|
|
346
348
|
}
|
|
347
349
|
|
|
348
|
-
/**
|
|
349
|
-
|
|
350
|
+
/**
|
|
351
|
+
* 构造 wake 草稿(此刻尚无终态;`result` 只在 `commit` 时赋值)。
|
|
352
|
+
*
|
|
353
|
+
* `teamId`:唤醒成本可见(t-39 第2批 D)。**不是所有唤醒都属于某个团队**
|
|
354
|
+
* (例如任务派活、房间广播都可能与团队无关)—— 那种情况一律写 `null`,
|
|
355
|
+
* 绝不按 target 猜一个团队塞进去:猜出来的数字比没有更糟,它会让
|
|
356
|
+
* 「每团队唤醒成本」的统计凭空多出一份不属于它的开销。
|
|
357
|
+
*/
|
|
358
|
+
function draftWake({ target, reason, dedupKey, text, at, teamId }) {
|
|
350
359
|
return {
|
|
351
360
|
id: `wake-${at}-${process.pid.toString(36)}-${takeSeq().toString(36)}`,
|
|
352
361
|
target,
|
|
353
362
|
reason,
|
|
354
363
|
dedupKey,
|
|
355
364
|
text,
|
|
365
|
+
teamId: teamId ?? null,
|
|
356
366
|
scheduledAt: at,
|
|
357
367
|
}
|
|
358
368
|
}
|
|
@@ -449,7 +459,7 @@ export function createDispatcher(ctx, config = {}) {
|
|
|
449
459
|
*/
|
|
450
460
|
function notify(request = {}) {
|
|
451
461
|
const at = now()
|
|
452
|
-
const { target, reason, dedupKey, text, delayMs } = request
|
|
462
|
+
const { target, reason, dedupKey, text, delayMs, teamId } = request
|
|
453
463
|
const wakeText = text ?? `[cs-bots 唤醒] ${REASON_TEXT[reason] ?? String(reason)}`
|
|
454
464
|
|
|
455
465
|
// 每调用一次 = 一次决策,先计数(恒等式 total === sent + skipped + failed + pending 由此成立)。
|
|
@@ -462,7 +472,7 @@ export function createDispatcher(ctx, config = {}) {
|
|
|
462
472
|
}
|
|
463
473
|
if (!WAKE_REASONS.includes(reason)) {
|
|
464
474
|
counters.failed += 1
|
|
465
|
-
const draft = draftWake({ target, reason: String(reason), dedupKey, text: wakeText, at })
|
|
475
|
+
const draft = draftWake({ target, reason: String(reason), dedupKey, text: wakeText, at, teamId })
|
|
466
476
|
const { wake, durable } = commit(draft, 'failed', { at, detail: `invalid-reason:${reason}` })
|
|
467
477
|
return { ok: false, result: 'failed', wakeId: wake.id, reason: 'invalid-reason', durable }
|
|
468
478
|
}
|
|
@@ -474,7 +484,7 @@ export function createDispatcher(ctx, config = {}) {
|
|
|
474
484
|
let wakeId
|
|
475
485
|
let durable = probe.writable
|
|
476
486
|
if (last === undefined || at - last >= SKIP_RECORD_INTERVAL_MS) {
|
|
477
|
-
const draft = draftWake({ target, reason, dedupKey, text: wakeText, at })
|
|
487
|
+
const draft = draftWake({ target, reason, dedupKey, text: wakeText, at, teamId })
|
|
478
488
|
const committed = commit(draft, 'skipped', { at })
|
|
479
489
|
wakeId = committed.wake.id
|
|
480
490
|
durable = committed.durable
|
|
@@ -490,7 +500,7 @@ export function createDispatcher(ctx, config = {}) {
|
|
|
490
500
|
if (queue.sizeOf(target) >= maxPerTarget) {
|
|
491
501
|
counters.backpressured += 1
|
|
492
502
|
counters.failed += 1
|
|
493
|
-
const draft = draftWake({ target, reason, dedupKey, text: wakeText, at })
|
|
503
|
+
const draft = draftWake({ target, reason, dedupKey, text: wakeText, at, teamId })
|
|
494
504
|
// 请求被**拒绝**(而非「受理后没送成」)⇒ 该 dedupKey 尚未被消费,释放它,
|
|
495
505
|
// 队列疏通后上游仍可用同一个 dedupKey 重发。
|
|
496
506
|
if (dedupKey) dedup.keys.delete(dedupKey)
|
|
@@ -516,7 +526,7 @@ export function createDispatcher(ctx, config = {}) {
|
|
|
516
526
|
deferred = notBefore > at
|
|
517
527
|
}
|
|
518
528
|
|
|
519
|
-
const draft = draftWake({ target, reason, dedupKey, text: wakeText, at })
|
|
529
|
+
const draft = draftWake({ target, reason, dedupKey, text: wakeText, at, teamId })
|
|
520
530
|
const entry = { draft, notBefore, keyHeld: Boolean(dedupKey) }
|
|
521
531
|
queue.add(entry)
|
|
522
532
|
if (deferred) counters.deferred += 1
|
|
@@ -614,25 +624,74 @@ export function createDispatcher(ctx, config = {}) {
|
|
|
614
624
|
/**
|
|
615
625
|
* 从 `wakes.jsonl` 读唤醒记录(**读盘,不是读内存** —— 进程重启后仍可审计)。
|
|
616
626
|
*
|
|
617
|
-
* @param {{ target?: string, limit?: number }} [options]
|
|
627
|
+
* @param {{ target?: string, teamId?: string, limit?: number }} [options]
|
|
628
|
+
* `teamId` 是 t-39 第2批 D 加的:唤醒成本可见(按团队过滤)。
|
|
618
629
|
* @returns {object[]} 终态 Wake 记录(落盘顺序;有 `limit` 时取**最后** limit 条)
|
|
619
630
|
*/
|
|
620
631
|
function list(options = {}) {
|
|
621
|
-
const { target, limit } = options
|
|
622
|
-
// 先按 limit 取尾,再按 target 过滤 —— 语义是「最近 limit 条里属于该 target 的」。
|
|
632
|
+
const { target, teamId, limit } = options
|
|
633
|
+
// 先按 limit 取尾,再按 target / teamId 过滤 —— 语义是「最近 limit 条里属于该 target/team 的」。
|
|
623
634
|
const { rows, bad, total } = readJsonl(wakesFile, limit ? { limit } : {})
|
|
624
|
-
|
|
635
|
+
let filtered = rows
|
|
636
|
+
if (target) filtered = filtered.filter((row) => row?.target === target)
|
|
637
|
+
if (teamId) filtered = filtered.filter((row) => row?.teamId === teamId)
|
|
625
638
|
// 坏行**不静默**:作为非枚举属性挂上,调用方可用 `Object.keys` 忽略、也可显式读取。
|
|
626
639
|
Object.defineProperty(filtered, 'bad', { value: bad, enumerable: false })
|
|
627
640
|
Object.defineProperty(filtered, 'total', { value: total, enumerable: false })
|
|
628
641
|
return filtered
|
|
629
642
|
}
|
|
630
643
|
|
|
644
|
+
/**
|
|
645
|
+
* 按团队聚合唤醒成本(t-39 第2批 D)。
|
|
646
|
+
*
|
|
647
|
+
* 为什么需要:Boss 要看「哪个团队在消耗唤醒配额」。单看 target 看不出团队归属
|
|
648
|
+
* (target 是会话 id,团队归属在 `teamId` 上)。
|
|
649
|
+
*
|
|
650
|
+
* 只聚合**真正有 teamId 的记录**;`teamId === null` 的(任务派活、房间广播等
|
|
651
|
+
* 与团队无关的唤醒)单独归入 `teamId: null` 桶,不混进任何团队数字里。
|
|
652
|
+
*
|
|
653
|
+
* @param {{ since?: number }} [options] `since` = epoch ms,只统计该时刻之后的记录
|
|
654
|
+
* @returns {{ since?: number, teams: object[], unassigned: object, total: number }}
|
|
655
|
+
*/
|
|
656
|
+
function byTeam(options = {}) {
|
|
657
|
+
const { since } = options
|
|
658
|
+
const { rows } = readJsonl(wakesFile, { limit: 100_000 })
|
|
659
|
+
const buckets = new Map()
|
|
660
|
+
let total = 0
|
|
661
|
+
for (const row of rows) {
|
|
662
|
+
const at = row?.scheduledAt ?? row?.at
|
|
663
|
+
if (since !== undefined && Number.isFinite(since) && Number.isFinite(at) && at < since) continue
|
|
664
|
+
total += 1
|
|
665
|
+
const key = row?.teamId ?? null
|
|
666
|
+
if (!buckets.has(key)) {
|
|
667
|
+
buckets.set(key, { teamId: key, count: 0, sent: 0, skipped: 0, failed: 0, pending: 0, targets: new Set() })
|
|
668
|
+
}
|
|
669
|
+
const b = buckets.get(key)
|
|
670
|
+
b.count += 1
|
|
671
|
+
const r = row?.result
|
|
672
|
+
if (r === 'sent') b.sent += 1
|
|
673
|
+
else if (r === 'skipped') b.skipped += 1
|
|
674
|
+
else if (r === 'failed') b.failed += 1
|
|
675
|
+
else if (r === 'pending') b.pending += 1
|
|
676
|
+
if (row?.target) b.targets.add(row.target)
|
|
677
|
+
}
|
|
678
|
+
const teams = [...buckets.entries()]
|
|
679
|
+
.filter(([key]) => key !== null)
|
|
680
|
+
.map(([, b]) => ({ ...b, targets: b.targets.size, targetsList: [...b.targets] }))
|
|
681
|
+
.sort((a, b) => b.count - a.count)
|
|
682
|
+
const unassignedEntry = buckets.get(null)
|
|
683
|
+
const unassigned = unassignedEntry
|
|
684
|
+
? { teamId: null, count: unassignedEntry.count, sent: unassignedEntry.sent, skipped: unassignedEntry.skipped, failed: unassignedEntry.failed, pending: unassignedEntry.pending, targets: unassignedEntry.targets.size, targetsList: [...unassignedEntry.targets] }
|
|
685
|
+
: { teamId: null, count: 0, sent: 0, skipped: 0, failed: 0, pending: 0, targets: 0, targetsList: [] }
|
|
686
|
+
return { since: since ?? null, teams, unassigned, total }
|
|
687
|
+
}
|
|
688
|
+
|
|
631
689
|
return {
|
|
632
690
|
notify,
|
|
633
691
|
flush,
|
|
634
692
|
stats,
|
|
635
693
|
list,
|
|
694
|
+
byTeam,
|
|
636
695
|
/** 只读诊断:状态目录 / 文件路径 / 是否可写。 */
|
|
637
696
|
paths: { stateDir: stateDirectory, wakesFile },
|
|
638
697
|
/** 状态目录可否持久化;`false` ⇒ 所有返回值都会带 `durable:false`。 */
|
package/src/index.js
CHANGED
|
@@ -127,7 +127,14 @@ function applyInner(ctx, config, { log, warn }) {
|
|
|
127
127
|
// ⚠️ 2026-11-05(P0):createBoard 必须拿到 settings —— wipLimitPerBot(WIP 上限)
|
|
128
128
|
// 与 requireTriageForReady(Ready 闸门)都从 config.settings 读。只传 stateDir 会让
|
|
129
129
|
// 平台设置对任务板静默失效(永远退回代码内默认值),且用户改了设置看不到任何变化。
|
|
130
|
-
services.board = createBoard(ctx, {
|
|
130
|
+
services.board = createBoard(ctx, {
|
|
131
|
+
stateDir: config.stateDir,
|
|
132
|
+
leaseMs: settings.defaultLeaseMs,
|
|
133
|
+
settings,
|
|
134
|
+
// 指标旁路(bots04 欠账2):传**取值函数**而不是实例 ——
|
|
135
|
+
// metrics 在 L276 才装配,此刻还是 undefined。传函数可规避装配顺序耦合。
|
|
136
|
+
metrics: () => services.metrics,
|
|
137
|
+
})
|
|
131
138
|
log(`[cs-bots] BoardService 就绪(stateDir=${services.board.paths?.stateDir ?? '?'})`)
|
|
132
139
|
// ⚠️ 修复(2026-11-04 测试,P1-4):expireLeases 之前无任何调用者,
|
|
133
140
|
// owner 崩溃/失联后任务永久停在 in_progress(连"重启时回收"都不做)。
|
package/src/kernel.js
CHANGED
|
@@ -297,6 +297,21 @@ export function hostLlmAvailable() {
|
|
|
297
297
|
* sole ordinary message of its own turn."
|
|
298
298
|
* ⇒ 适合「有新活要干」的唤醒。
|
|
299
299
|
*
|
|
300
|
+
* ⚠️ **`followup` 在 `agent` 对象上,不在 `agents` 服务上** —— 这里极易搞错。
|
|
301
|
+
*
|
|
302
|
+
* 写测试替身时,很多人会自然地把 `followup` 挂到假 `agents` 服务上
|
|
303
|
+
* (像 `create` / `get` / `list` 那样),结果永远拿到 `followup-unsupported`,
|
|
304
|
+
* 而且报错信息不指向真正的原因。2026-11-11 实测踩过一次,
|
|
305
|
+
* 排查时还一度以为是 `assertDeliverable` 拦的。
|
|
306
|
+
*
|
|
307
|
+
* 正确形状:
|
|
308
|
+
* ```js
|
|
309
|
+
* get: (key) => key === 'agents' ? {
|
|
310
|
+
* get: (id) => ({ id, sessionId: id, followup(msg) { ... } }), // ← 挂在这里
|
|
311
|
+
* // 不是这里:followup(msg) {}
|
|
312
|
+
* } : undefined
|
|
313
|
+
* ```
|
|
314
|
+
*
|
|
300
315
|
* @param {object} ctx
|
|
301
316
|
* @param {string} sessionId
|
|
302
317
|
* @param {string} text
|
package/src/room.js
CHANGED
|
@@ -154,6 +154,18 @@ const RELAY_MULTIPLIER = 3
|
|
|
154
154
|
const RELAY_FLOOR = 3
|
|
155
155
|
/** 人类发送者 id(§3.2:`from` 为 sessionId 或 `'user'`)。 */
|
|
156
156
|
const USER = 'user'
|
|
157
|
+
/**
|
|
158
|
+
* 人类身份全集 —— 与 `teams.js` 的 `HUMAN_IDS` 对齐(`ui` / `user` / `person`)。
|
|
159
|
+
*
|
|
160
|
+
* **绝不唤醒人**:人是「看页面的人」,不是会话;`kernel.followup` 对着这些 id 必然失败。
|
|
161
|
+
* 实测证据(2026-11-11):`wakes.jsonl` 里 `room-msg:m-1:ui → failed` ——
|
|
162
|
+
* 缺省广播把 Boss 自己也算成唤醒目标,每次必失败一条。
|
|
163
|
+
*/
|
|
164
|
+
const HUMAN_IDS = Object.freeze(['ui', 'user', 'person'])
|
|
165
|
+
/** 是否为人类身份(房间转发一律跳过)。 */
|
|
166
|
+
function isHumanId(id) {
|
|
167
|
+
return typeof id === 'string' && HUMAN_IDS.includes(id)
|
|
168
|
+
}
|
|
157
169
|
/** 房间事件类型集合(审计流可枚举)。 */
|
|
158
170
|
const EVENTS = [
|
|
159
171
|
'room_created',
|
|
@@ -186,6 +198,14 @@ const DEFAULT_SETTINGS = Object.freeze({
|
|
|
186
198
|
allowBotAutoPost: true,
|
|
187
199
|
maxBotsPerRound: 3,
|
|
188
200
|
relayEnabled: true,
|
|
201
|
+
// ── 定向唤醒(2026-11-11「唤醒风暴」止血)────────────────────────────────
|
|
202
|
+
// 缺省 **true**:房间消息**只在被 @ 时**才唤醒成员。
|
|
203
|
+
//
|
|
204
|
+
// 为什么缺省要改成「安静」:实测 `wakes.jsonl` 里一条消息产生 3 次唤醒尝试、
|
|
205
|
+
// 3 次全失败(`room-msg:m-1:ui` + 两个 bot 全 failed)—— 缺省广播「除自己外全部成员」
|
|
206
|
+
// 既吵又必然失败(`ui` 根本没有会话可唤醒)。改缺省 = 把最吵、最容易失败的路径关掉;
|
|
207
|
+
// 需要广播的房间显式 `mentionOnly:false` 打开。
|
|
208
|
+
mentionOnly: true,
|
|
189
209
|
})
|
|
190
210
|
/** `bodyRef` 的合法形态(防路径穿越:只允许 `bodies/<安全文件名>.txt`)。 */
|
|
191
211
|
const BODY_REF_RE = /^bodies\/[A-Za-z0-9._-]+\.txt$/
|
|
@@ -283,6 +303,55 @@ function stringList(value, field, where) {
|
|
|
283
303
|
return out
|
|
284
304
|
}
|
|
285
305
|
|
|
306
|
+
/**
|
|
307
|
+
* 从消息里收集「被 @ 的成员 id」(与 `admission.isAddressed` 的 `@id` 解析同口径)。
|
|
308
|
+
*
|
|
309
|
+
* 两条来源(取并集):
|
|
310
|
+
* 1. `message.mentions` —— 结构化数组,若有则优先
|
|
311
|
+
* 2. `message.text` 里的 `@id` —— 兜底解析(必须是 word-boundary 后的 `@`,避免误伤 `@bot-2` 当 `@bot`)
|
|
312
|
+
*
|
|
313
|
+
* 仅保留 **room.members 里实际存在的** id(非成员被 @ 了也不算唤醒目标)。
|
|
314
|
+
*
|
|
315
|
+
* ⚠️ 注意:`message.from` 即使被 @ 了也不进唤醒名单(自己不唤醒自己)。
|
|
316
|
+
* `HUMAN_IDS`(人类身份)也在 `wakeable()` 里统一过滤,这里不再二次过滤。
|
|
317
|
+
* @param {object} message
|
|
318
|
+
* @param {string[]} members
|
|
319
|
+
* @returns {string[]} 被 @ 的可唤醒成员 id(去重、保序)
|
|
320
|
+
*/
|
|
321
|
+
function collectMentions(message, members) {
|
|
322
|
+
if (!message || typeof message !== 'object') return []
|
|
323
|
+
const memberSet = new Set(members)
|
|
324
|
+
const out = []
|
|
325
|
+
const seen = new Set()
|
|
326
|
+
|
|
327
|
+
// ① 结构化 mentions 数组
|
|
328
|
+
if (Array.isArray(message.mentions)) {
|
|
329
|
+
for (const m of message.mentions) {
|
|
330
|
+
if (typeof m === 'string' && memberSet.has(m) && !seen.has(m)) {
|
|
331
|
+
seen.add(m)
|
|
332
|
+
out.push(m)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ② 文本里的 @id(与 admission.js 同口径:`@id` 后必须跟非标识符字符或结尾)
|
|
338
|
+
const text = typeof message.text === 'string' ? message.text : ''
|
|
339
|
+
if (text) {
|
|
340
|
+
for (const m of members) {
|
|
341
|
+
if (seen.has(m)) continue
|
|
342
|
+
const idx = text.indexOf(`@${m}`)
|
|
343
|
+
if (idx >= 0) {
|
|
344
|
+
const after = text.slice(idx + 1 + m.length)
|
|
345
|
+
if (after === '' || !/[A-Za-z0-9_-]/.test(after[0])) {
|
|
346
|
+
seen.add(m)
|
|
347
|
+
out.push(m)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return out
|
|
353
|
+
}
|
|
354
|
+
|
|
286
355
|
/** 时间参数归一:缺省 now;支持 ms 数字与 Date。 */
|
|
287
356
|
function normalizeNow(value, where) {
|
|
288
357
|
if (value === undefined || value === null) return Date.now()
|
|
@@ -373,7 +442,7 @@ function normalizePurpose(value, where = 'createRoom') {
|
|
|
373
442
|
|
|
374
443
|
/** `settings` 单个开关校验(按键分派;未知键抛错 —— 拒绝写入「怎么写都不会生效」的配置)。 */
|
|
375
444
|
function normalizeSettingValue(key, value, where) {
|
|
376
|
-
if (key === 'allowBotAutoPost' || key === 'relayEnabled') {
|
|
445
|
+
if (key === 'allowBotAutoPost' || key === 'relayEnabled' || key === 'mentionOnly') {
|
|
377
446
|
if (typeof value !== 'boolean') {
|
|
378
447
|
throw new RoomError(
|
|
379
448
|
`room.${where}:settings.${key} 必须是布尔值,收到 ${JSON.stringify(value)}`,
|
|
@@ -1880,28 +1949,109 @@ export function createRoom(ctx, config = {}) {
|
|
|
1880
1949
|
const message = requireMessage(room.id, mid, 'relay')
|
|
1881
1950
|
const actor = optionalString(byId, 'byId', 'relay')
|
|
1882
1951
|
|
|
1952
|
+
// ══ 目标计算(2026-11-11「唤醒风暴」止血)═══════════════════════════════
|
|
1953
|
+
// 四档,**顺序即优先级**:
|
|
1954
|
+
// ① 显式给了 targets → 原样采信(调用方说了算;仍强制排除人类 id)
|
|
1955
|
+
// ② mentionOnly=true 且消息 @ 了某人 → 只打给被 @ 的成员(不含发送者/人类)
|
|
1956
|
+
// ③ mentionOnly=true 但没 @ → **只落消息,不唤醒任何人**(relayed:0)
|
|
1957
|
+
// ④ mentionOnly=false(显式关掉定向)→ 回退「除自己外全部成员」,但仍排除人类
|
|
1958
|
+
//
|
|
1959
|
+
// 为什么③不抛错:「只记录不打扰」是合法结果(Boss 只是同步信息,不需要谁被叫醒)。
|
|
1960
|
+
// 抛错会把「安静」变成「失败」,页面反而要处理异常 —— 那是更差的设计。
|
|
1961
|
+
const settings = room.settings ?? DEFAULT_SETTINGS
|
|
1962
|
+
const mentionOnly = settings.mentionOnly !== false
|
|
1963
|
+
/** 唤醒排除表:人类一律不算唤醒目标(见 HUMAN_IDS 注释的实测证据)。 */
|
|
1964
|
+
const wakeable = (id) => id !== message.from && !isHumanId(id)
|
|
1883
1965
|
let targetList
|
|
1884
|
-
|
|
1885
|
-
|
|
1966
|
+
let targetSource
|
|
1967
|
+
let noTargetReason
|
|
1968
|
+
|
|
1969
|
+
if (targets !== undefined && targets !== null) {
|
|
1970
|
+
targetList = stringList(targets, 'targets', 'relay')
|
|
1886
1971
|
if (targetList.length === 0) {
|
|
1887
1972
|
throw new RoomError(
|
|
1888
|
-
`room.relay
|
|
1889
|
-
+ ' —— 拒绝空转发(静默无操作)',
|
|
1973
|
+
`room.relay:targets 是空数组 —— 拒绝空转发(静默无操作),请显式给出目标或省略该字段走缺省`,
|
|
1890
1974
|
'ROOM_NO_TARGETS',
|
|
1891
|
-
{ roomId: room.id, messageId: mid
|
|
1975
|
+
{ roomId: room.id, messageId: mid },
|
|
1892
1976
|
)
|
|
1893
1977
|
}
|
|
1978
|
+
targetSource = 'explicit'
|
|
1979
|
+
// 显式 targets 可能混进人类 —— 剔除,但这是**调用方的错**,如实 warn 不静默
|
|
1980
|
+
const humans = targetList.filter((t) => isHumanId(t))
|
|
1981
|
+
if (humans.length > 0) {
|
|
1982
|
+
warn(`[cs-bots/room] relay 显式 targets 含人类身份 ${humans.join(',')} —— 已剔除(人不可被唤醒)`)
|
|
1983
|
+
targetList = targetList.filter((t) => !isHumanId(t))
|
|
1984
|
+
}
|
|
1985
|
+
} else if (mentionOnly) {
|
|
1986
|
+
// 从消息里解析「被 @ 的人」:mentions 字段 + text 里的 `@id`(与 admission.isAddressed 同口径)
|
|
1987
|
+
const mentioned = collectMentions(message, room.members)
|
|
1988
|
+
targetList = mentioned.filter(wakeable)
|
|
1989
|
+
targetSource = 'mention'
|
|
1990
|
+
if (targetList.length === 0) {
|
|
1991
|
+
targetSource = 'mention-only-no-target'
|
|
1992
|
+
noTargetReason = 'mention-only-no-target'
|
|
1993
|
+
}
|
|
1894
1994
|
} else {
|
|
1895
|
-
targetList =
|
|
1995
|
+
targetList = room.members.filter(wakeable)
|
|
1996
|
+
targetSource = 'broadcast'
|
|
1896
1997
|
if (targetList.length === 0) {
|
|
1897
1998
|
throw new RoomError(
|
|
1898
|
-
`room.relay
|
|
1999
|
+
`room.relay:房间 ${room.id} 除发送者(${message.from})外没有其他可唤醒成员,且未显式指定 targets`
|
|
2000
|
+
+ ' —— 拒绝空转发(静默无操作)',
|
|
1899
2001
|
'ROOM_NO_TARGETS',
|
|
1900
|
-
{ roomId: room.id, messageId: mid },
|
|
2002
|
+
{ roomId: room.id, messageId: mid, from: message.from },
|
|
1901
2003
|
)
|
|
1902
2004
|
}
|
|
1903
2005
|
}
|
|
1904
2006
|
|
|
2007
|
+
// ── ③「没 @ 到谁」:合法空转发 —— 记事件、返回 relayed:0,**不抛错** ──────
|
|
2008
|
+
// 必须在后台账之前分流:否则每次安静发言都吃掉一次 relayLimit 配额。
|
|
2009
|
+
if (targetList.length === 0) {
|
|
2010
|
+
const at0 = normalizeNow(now, 'relay')
|
|
2011
|
+
appendRoomEvent(room.id, {
|
|
2012
|
+
type: 'relay',
|
|
2013
|
+
roomId: room.id,
|
|
2014
|
+
rev: room.rev,
|
|
2015
|
+
byId: actor ?? null,
|
|
2016
|
+
at: at0,
|
|
2017
|
+
messageId: mid,
|
|
2018
|
+
targets: [],
|
|
2019
|
+
attempted: 0,
|
|
2020
|
+
limit: room.relayLimit,
|
|
2021
|
+
targetSource,
|
|
2022
|
+
reason: noTargetReason ?? 'no-wakeable-target',
|
|
2023
|
+
})
|
|
2024
|
+
let audit0 = { written: true }
|
|
2025
|
+
try {
|
|
2026
|
+
appendRoomEvent(room.id, {
|
|
2027
|
+
type: 'relay_result',
|
|
2028
|
+
roomId: room.id,
|
|
2029
|
+
rev: room.rev,
|
|
2030
|
+
byId: actor ?? null,
|
|
2031
|
+
at: at0,
|
|
2032
|
+
messageId: mid,
|
|
2033
|
+
deliveries: [],
|
|
2034
|
+
})
|
|
2035
|
+
} catch (error) {
|
|
2036
|
+
audit0 = { written: false, reason: `relay-result-append-failed:${error?.message ?? error}` }
|
|
2037
|
+
diagnostics.relayResultAppendFailures += 1
|
|
2038
|
+
warn(`[cs-bots/room] 转发结果事件写入失败(投递已发生):${audit0.reason}`)
|
|
2039
|
+
}
|
|
2040
|
+
return {
|
|
2041
|
+
ok: true,
|
|
2042
|
+
roomId: room.id,
|
|
2043
|
+
messageId: mid,
|
|
2044
|
+
relayed: 0,
|
|
2045
|
+
attempted: 0,
|
|
2046
|
+
limit: room.relayLimit,
|
|
2047
|
+
remaining: Math.max(0, room.relayLimit - (relayCounts.get(relayKey(room.id, mid)) ?? 0)),
|
|
2048
|
+
deliveries: [],
|
|
2049
|
+
audit: audit0,
|
|
2050
|
+
targetSource,
|
|
2051
|
+
reason: noTargetReason ?? 'no-wakeable-target',
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
|
|
1905
2055
|
for (const t of targetList) {
|
|
1906
2056
|
if (!room.members.includes(t)) {
|
|
1907
2057
|
throw new RoomError(
|
|
@@ -1992,6 +2142,7 @@ export function createRoom(ctx, config = {}) {
|
|
|
1992
2142
|
remaining: Math.max(0, limit - (relayCounts.get(rkey) ?? 0)),
|
|
1993
2143
|
deliveries,
|
|
1994
2144
|
audit,
|
|
2145
|
+
targetSource,
|
|
1995
2146
|
}
|
|
1996
2147
|
if (failed.length > 0) {
|
|
1997
2148
|
result.reason = failed
|
package/src/roster.js
CHANGED
|
@@ -831,6 +831,59 @@ export function createRoster(ctx, config = {}) {
|
|
|
831
831
|
return commit(current.id, 'rename', by, at, before, next)
|
|
832
832
|
}
|
|
833
833
|
|
|
834
|
+
/**
|
|
835
|
+
* 改预制体(preset)—— 2026-11-11 bots04 补的能力缺口。
|
|
836
|
+
*
|
|
837
|
+
* ## 为什么需要它
|
|
838
|
+
*
|
|
839
|
+
* `preset` 此前**只能**在 `create()` 时设置,之后没有任何接口能改。
|
|
840
|
+
* 实测后果:`myfree调研员`(t-36 挂 `agent-not-live` 的那个)preset 为空,
|
|
841
|
+
* 而空 preset = 走宿主默认预设 = **工具面没有保证**(连 `bot_*` 工具都不一定有)。
|
|
842
|
+
* 想修只能删掉重建 —— 那会丢掉 sessionId 与全部历史关联,代价过高。
|
|
843
|
+
*
|
|
844
|
+
* ## 语义边界
|
|
845
|
+
*
|
|
846
|
+
* - **只改 preset,不重建会话**:已经起来的会话不会被换预设。
|
|
847
|
+
* 这是诚实的边界 —— 换预设真正生效要等该会话下次启动。
|
|
848
|
+
* 调用方要能看见这一点,所以返回值里带 `appliesOnNextSession: true`。
|
|
849
|
+
* - 退役 Bot 不可改(与 `rename`/`setCwd` 同口径)。
|
|
850
|
+
* - CAS:必须带 `expectedRev`。
|
|
851
|
+
* - 空串 / null ⇒ 清除 preset(回到宿主默认)。这与 `create` 里
|
|
852
|
+
* 「不传/空串则不写 preset 字段」是同一口径,避免出现 `preset: ''` 这种半状态。
|
|
853
|
+
*
|
|
854
|
+
* @param {{ id: string, preset: string|null, byId: string, expectedRev: number, now?: number }} input
|
|
855
|
+
* @returns {object} Bot(含 `appliesOnNextSession`)
|
|
856
|
+
* @throws {RosterError} 不存在 / 已退役 / 缺字段 / rev 不匹配
|
|
857
|
+
*/
|
|
858
|
+
function setPreset(input = {}) {
|
|
859
|
+
const { preset, byId, now } = input ?? {}
|
|
860
|
+
const current = loadForCas(input, 'setPreset')
|
|
861
|
+
const by = requireString(byId, 'byId', 'setPreset')
|
|
862
|
+
assertNotRetired(current, 'setPreset', '换预制体')
|
|
863
|
+
|
|
864
|
+
const raw = preset === undefined || preset === null ? '' : String(preset).trim()
|
|
865
|
+
const nextPreset = raw === '' ? undefined : raw
|
|
866
|
+
if ((current.preset ?? undefined) === nextPreset) {
|
|
867
|
+
throw new RosterError(
|
|
868
|
+
`roster.setPreset:Bot ${current.id} 的 preset 已是 ${JSON.stringify(nextPreset ?? null)}——无需变更`,
|
|
869
|
+
'ROSTER_NOTHING_TO_SET_PRESET',
|
|
870
|
+
{ id: current.id, preset: nextPreset ?? null },
|
|
871
|
+
)
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const at = normalizeNow(now, 'setPreset')
|
|
875
|
+
const before = clone(current)
|
|
876
|
+
const next = clone(current)
|
|
877
|
+
next.rev = current.rev + 1
|
|
878
|
+
if (nextPreset === undefined) delete next.preset
|
|
879
|
+
else next.preset = nextPreset
|
|
880
|
+
next.presetUpdatedAt = at
|
|
881
|
+
next.presetUpdatedBy = by
|
|
882
|
+
const committed = commit(current.id, 'setPreset', by, at, before, next)
|
|
883
|
+
// 诚实告知:换 preset 不会立刻影响已存在的会话
|
|
884
|
+
return { ...committed, appliesOnNextSession: true }
|
|
885
|
+
}
|
|
886
|
+
|
|
834
887
|
/**
|
|
835
888
|
* 退役 → **终态,但释放名额**(名字可复用、cwd 可回收;§3.1 "关键" 段)。
|
|
836
889
|
*
|
|
@@ -1408,6 +1461,7 @@ export function createRoster(ctx, config = {}) {
|
|
|
1408
1461
|
hasSession,
|
|
1409
1462
|
rename,
|
|
1410
1463
|
retire,
|
|
1464
|
+
setPreset,
|
|
1411
1465
|
setCwd,
|
|
1412
1466
|
setPrepareCommand,
|
|
1413
1467
|
getProfile,
|
package/src/settings.js
CHANGED
|
@@ -16,8 +16,8 @@ import { resolve, sep, isAbsolute, join } from 'node:path'
|
|
|
16
16
|
|
|
17
17
|
/** 设置项定义:默认值 + 校验。 */
|
|
18
18
|
export const SETTING_SPECS = Object.freeze({
|
|
19
|
-
/**
|
|
20
|
-
defaultLeaseMs: { def: 5 * 60_000, min: 10_000, max: 24 * 60 * 60_000, label: '
|
|
19
|
+
/** 任务默认占用时长(ms):owner claim 后多久未心跳即视为失联。 */
|
|
20
|
+
defaultLeaseMs: { def: 5 * 60_000, min: 10_000, max: 24 * 60 * 60_000, label: '任务占用默认时长(ms)' },
|
|
21
21
|
/** 唤醒节流窗口(ms):同一目标在窗内最多唤醒一次。 */
|
|
22
22
|
throttleMs: { def: 5_000, min: 0, max: 60 * 60_000, label: '唤醒节流窗口(ms)' },
|
|
23
23
|
/** 每目标唤醒队列上限(背压阈值)。 */
|