cs-bots 0.8.0 → 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/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
- if (targets === undefined || targets === null) {
1885
- targetList = room.members.filter((m) => m !== message.from)
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:房间 ${room.id} 除发送者(${message.from})外没有其他成员,且未显式指定 targets`
1889
- + ' —— 拒绝空转发(静默无操作)',
1973
+ `room.relay:targets 是空数组 —— 拒绝空转发(静默无操作),请显式给出目标或省略该字段走缺省`,
1890
1974
  'ROOM_NO_TARGETS',
1891
- { roomId: room.id, messageId: mid, from: message.from },
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 = stringList(targets, 'targets', 'relay')
1995
+ targetList = room.members.filter(wakeable)
1996
+ targetSource = 'broadcast'
1896
1997
  if (targetList.length === 0) {
1897
1998
  throw new RoomError(
1898
- `room.relay:targets 是空数组 —— 拒绝空转发(静默无操作),请显式给出目标或省略该字段走缺省`,
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,