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/teams.js CHANGED
@@ -64,7 +64,10 @@ export const DEFAULT_TEAM_ADMIN_POLICY = 'open'
64
64
  const HUMAN_IDS = Object.freeze(['ui', 'user', 'person'])
65
65
 
66
66
  /** 合法动作(含 setPolicy:策略变更本身也是要留痕的事件)。 */
67
- const ACTIONS = Object.freeze(['create', 'addMember', 'removeMember', 'setRole', 'close', 'note', 'setPolicy', 'attachRoom'])
67
+ const ACTIONS = Object.freeze(['create', 'addMember', 'removeMember', 'setRole', 'close', 'note', 'setPolicy', 'attachRoom', 'route'])
68
+
69
+ /** 跨团队路由的最大 hop 数(>3 拒绝)。 */
70
+ const ROUTE_MAX_HOP = 3
68
71
 
69
72
  /** 结构化错误。 */
70
73
  export class TeamError extends Error {
@@ -192,6 +195,12 @@ export function createTeams(ctx, config = {}) {
192
195
  if (rows.length === 0) { diagnostics.hydratedFrom = 'empty'; return }
193
196
  let skipped = 0
194
197
  for (const ev of rows) {
198
+ // route 事件是**审计记录**,不是团队状态变更 —— 没有 id/rev,仍要进 events
199
+ // 供 listRoutes 查询;只有团队状态变更事件才需要 id + rev 去重。
200
+ if (ev?.action === 'route') {
201
+ events.push(clone(ev))
202
+ continue
203
+ }
195
204
  if (!ev || typeof ev.id !== 'string' || !ev.after) { skipped += 1; continue }
196
205
  const current = entries.get(ev.id)
197
206
  if (current && Number.isFinite(ev.rev) && ev.rev <= current.rev) { skipped += 1; continue }
@@ -282,7 +291,7 @@ export function createTeams(ctx, config = {}) {
282
291
  */
283
292
  function create(input = {}) {
284
293
  assertAlive('create')
285
- const { name, type, purpose, memberIds, leadId, adminPolicy, by, now } = input ?? {}
294
+ const { name, type, purpose, memberIds, leadId, adminPolicy, by, now, kind, nativeTeamId } = input ?? {}
286
295
  const nameNorm = requireString(name, 'name', 'create')
287
296
  if (nameNorm.length > 120) throw new TeamError(`teams.create:name ≤120 字符(收到 ${nameNorm.length})`, 'TEAM_BAD_FIELD', { field: 'name' })
288
297
  const teamType = type ?? 'big'
@@ -301,18 +310,40 @@ export function createTeams(ctx, config = {}) {
301
310
  throw new TeamError(`teams.create:leadId ${lead} 不能同时出现在 memberIds 中(会形成自引用)`, 'TEAM_BAD_FIELD', { field: 'memberIds' })
302
311
  }
303
312
 
313
+ // ── kind:人类/智能体(服务层强制推导,防止伪造)─────────────────────────
314
+ // 由执行者身份(author)硬推导:HUMAN_IDS ⇒ 'human',其他 ⇒ 'agent'。
315
+ // 显式传 kind 但与推导矛盾 ⇒ 抛 TEAM_KIND_MISMATCH(不许伪造 Boss 建的团队)。
316
+ const derivedKind = HUMAN_IDS.includes(author) ? 'human' : 'agent'
317
+ if (kind !== undefined && kind !== null && kind !== derivedKind) {
318
+ throw new TeamError(
319
+ `teams.create:kind=${JSON.stringify(kind)} 与执行者身份矛盾(执行者 ${author} ⇒ '${derivedKind}')——不许伪造`,
320
+ 'TEAM_KIND_MISMATCH',
321
+ { author, derivedKind, requested: kind },
322
+ )
323
+ }
324
+ const teamKind = derivedKind
325
+
304
326
  idCounter += 1
305
327
  const id = `team-${idCounter}`
306
328
  /** @type {Record<string, unknown>} */
307
329
  const rec = {
308
- id, rev: 1, name: nameNorm, type: teamType, purpose: purpose ?? '',
330
+ id, rev: 1, name: nameNorm, type: teamType, kind: teamKind, purpose: purpose ?? '',
309
331
  leadId: lead, members,
332
+ // ── t-39 第2批 C:绑定 DSH 原生 team ──
333
+ // cs-bots 团队(teams.jsonl)与 DSH 原生 team(agentTeams)是**两套人模型**,
334
+ // 没有任何自动交集(原生 TeamId = root session id,跟我们的 team-N 毫无关系)
335
+ // ⇒ 必须显式绑定。这个字段是「Boss 在这个 cs-bots 房间发言时,该同步给哪个
336
+ // 原生团队」的唯一依据。
337
+ //
338
+ // 为什么从 cs-bots 侧配:原生侧**没有**「列出所有原生 team」的接口,
339
+ // 只有 `tryMembership(agent)`(从某个成员反查它所属团队)⇒ 反向枚举不可行。
340
+ nativeTeamId: typeof nativeTeamId === 'string' && nativeTeamId.length > 0 ? nativeTeamId : null,
310
341
  adminPolicy: policy,
311
342
  status: 'open',
312
343
  createdAt: at, createdBy: author, updatedAt: at, updatedBy: author,
313
344
  }
314
345
  commit('create', at, author, null, rec)
315
- info(`[cs-bots/teams] 团队 ${id}(${nameNorm},${teamType})创建 by ${author}`)
346
+ info(`[cs-bots/teams] 团队 ${id}(${nameNorm},${teamType},${teamKind})创建 by ${author}`)
316
347
  return clone(rec)
317
348
  }
318
349
 
@@ -502,6 +533,209 @@ export function createTeams(ctx, config = {}) {
502
533
  return clone(next)
503
534
  }
504
535
 
536
+ // ── A. 跨团队路由(t-39 第2批 · bot_team_route 的服务层本体)────────────────
537
+
538
+ /**
539
+ * 解析并校验一条跨团队路由目标 —— **只做决定,不做投递**。
540
+ *
541
+ * 为什么拆开:投递要 dispatcher(wakes.jsonl / dedup / 背压),而 dispatcher 挂在
542
+ * `src/tools.js` 的组装层(`s.dispatcher`)。若把投递塞进 TeamsService,本服务就得
543
+ * 反向依赖 dispatcher —— 两个域服务互指会让装配顺序变成硬约束。所以这里只回答
544
+ * 「路由到谁」,投递由工具层完成,语义边界清晰。
545
+ *
546
+ * 失败一律**返回可读的 refused**,不抛裸错 —— 契约要求 `{ok:false, decision:'refused',
547
+ * reason}` 而不是异常(异常在工具层会被包成 `tool-crashed`,理由就丢了)。
548
+ *
549
+ * @param {{ fromTeam?: string, toTeam?: string, toBot?: string,
550
+ * taskId: string, text?: string, hop?: number, by: string }} input
551
+ * @returns {{ ok: true, decision: 'accepted', to: { kind: 'team'|'bot', id: string,
552
+ * leadId: string, name: string }, hop: number, dedupKey: string,
553
+ * fromTeam: string }
554
+ * | { ok: false, decision: 'refused', reason: string, to?: object, hop?: number }}
555
+ */
556
+ function resolveRoute(input = {}) {
557
+ assertAlive('resolveRoute')
558
+ const { fromTeam, toTeam, toBot, taskId, hop, by } = input ?? {}
559
+ const author = requireString(by, 'by', 'resolveRoute')
560
+
561
+ // ── 参数校验:全部**可读拒绝** ──
562
+ if (typeof taskId !== 'string' || taskId.trim() === '') {
563
+ return { ok: false, decision: 'refused', reason: 'ROUTE_TASK_ID_REQUIRED' }
564
+ }
565
+ if (!toTeam && !toBot) {
566
+ return { ok: false, decision: 'refused', reason: 'ROUTE_TARGET_REQUIRED' }
567
+ }
568
+ if (toTeam && toBot) {
569
+ return { ok: false, decision: 'refused', reason: 'ROUTE_TARGET_AMBIGUOUS' }
570
+ }
571
+
572
+ // ── hop 计数:>3 拒绝(环路防护第一道闸)──
573
+ const hopCount = Number.isFinite(Number(hop)) ? Math.max(0, Math.trunc(Number(hop))) : 0
574
+ if (hopCount > ROUTE_MAX_HOP) {
575
+ return { ok: false, decision: 'refused', reason: 'ROUTE_HOP_EXCEEDED', hop: hopCount }
576
+ }
577
+
578
+ // ── 解析目标团队 / 目标 lead ──
579
+ let targetTeamId = null
580
+ let targetLeadId = null
581
+ let targetName = null
582
+
583
+ if (toTeam) {
584
+ const team = entries.get(toTeam)
585
+ if (!team) return { ok: false, decision: 'refused', reason: 'ROUTE_TARGET_NOT_FOUND', hop: hopCount }
586
+ targetTeamId = team.id
587
+ targetLeadId = team.leadId ?? null
588
+ targetName = team.name
589
+ } else {
590
+ // toBot:必须**属于**某个 cs-bots 团队(leadId 或成员),否则无法确定唤醒谁
591
+ for (const t of entries.values()) {
592
+ if (t.leadId === toBot || (Array.isArray(t.members) && t.members.includes(toBot))) {
593
+ targetTeamId = t.id
594
+ targetLeadId = t.leadId ?? null
595
+ targetName = t.name
596
+ break
597
+ }
598
+ }
599
+ if (!targetTeamId) {
600
+ return { ok: false, decision: 'refused', reason: 'ROUTE_TARGET_NOT_FOUND', hop: hopCount }
601
+ }
602
+ }
603
+
604
+ // 目标团队必须有人可唤 —— 契约:**只唤 leadId**,不 wake 全部成员
605
+ if (!targetLeadId) {
606
+ return {
607
+ ok: false,
608
+ decision: 'refused',
609
+ reason: 'ROUTE_NO_LEAD',
610
+ to: { kind: toTeam ? 'team' : 'bot', id: targetTeamId, name: targetName },
611
+ hop: hopCount,
612
+ }
613
+ }
614
+
615
+ const fromKey = fromTeam ?? author
616
+
617
+ // 去重键:交给 dispatcher 的 dedupKey 机制(不自己造一套)。
618
+ // **不含 hop** —— 这样同一 (from→to, taskId) 的重复路由会命中 dispatcher 的
619
+ // IdempotencySet 而被判 skipped,正是「A→B→A→B 第 3 跳被拒」的实现路径。
620
+ const dedupKey = `route:${fromKey}:${targetTeamId}:${taskId}`
621
+
622
+ return {
623
+ ok: true,
624
+ decision: 'accepted',
625
+ to: { kind: toTeam ? 'team' : 'bot', id: targetTeamId, leadId: targetLeadId, name: targetName },
626
+ hop: hopCount,
627
+ dedupKey,
628
+ fromTeam: fromKey,
629
+ }
630
+ }
631
+
632
+ /**
633
+ * **一条龙路由**:决定 → 投递 → 留痕。工具层(`bot_team_route`)与 HTTP 层
634
+ * (`POST /teams/route`)**共用这一个方法**。
635
+ *
636
+ * 为什么必须共用:两条路各写一遍,行为就会漂 —— 工具层去重了、HTTP 层没去重,
637
+ * 或者一边先投后记、一边先记后投。契约要求「跨团队路由」只有一套语义,
638
+ * 所以序列编排只能有一处。
639
+ *
640
+ * 依赖注入:`dispatcher` 由调用方传入(TeamsService 不反向依赖 dispatcher,
641
+ * 否则两个域服务互指,装配顺序变成硬约束)。
642
+ *
643
+ * @param {{ fromTeam?: string, toTeam?: string, toBot?: string, taskId?: string,
644
+ * text?: string, hop?: number, by: string, now?: number,
645
+ * dispatcher: object }} input
646
+ * @returns {object} `{ok, decision, to, hop, wake, reason?, taskId}` —— DecisionKind 语义
647
+ */
648
+ function deliverRoute(input = {}) {
649
+ assertAlive('deliverRoute')
650
+ const { dispatcher, taskId: rawTaskId, by, now, text, hop } = input ?? {}
651
+ const author = requireString(by, 'by', 'deliverRoute')
652
+
653
+ // taskId 缺省生成:页面上的「发协作请求」不一定挂任务(Boss 只是想跨团队喊一句)。
654
+ // 每轮生成独立 id ⇒ 不会被 dispatcher 误判重(同一轮重发才判重,由调用方复用同一 id)。
655
+ const taskId = typeof rawTaskId === 'string' && rawTaskId.trim() !== ''
656
+ ? rawTaskId.trim()
657
+ : `route-${normalizeNow(now, 'deliverRoute')}`
658
+
659
+ const decision = resolveRoute({ ...input, taskId, by: author })
660
+ if (!decision.ok) return { ...decision, taskId }
661
+
662
+ if (!dispatcher || typeof dispatcher.notify !== 'function') {
663
+ return { ok: false, decision: 'refused', reason: 'ROUTE_NO_DISPATCHER', to: decision.to, hop: decision.hop, taskId }
664
+ }
665
+
666
+ // 投递:只唤对方 leadId;dedupKey 交给 dispatcher(不自己造去重);
667
+ // teamId 让 byTeam() 能按团队聚合唤醒成本。
668
+ const wakeText = text ?? `[cs-bots 跨团队路由] ${decision.fromTeam} → ${decision.to.id}`
669
+ + `(lead=${decision.to.leadId}, hop=${decision.hop}, task=${taskId})`
670
+ const wake = dispatcher.notify({
671
+ target: decision.to.leadId,
672
+ reason: 'room-message',
673
+ dedupKey: decision.dedupKey,
674
+ text: wakeText,
675
+ teamId: decision.to.id,
676
+ })
677
+
678
+ // 留痕在投递之后(投递已有 dedupKey 记在 wakes.jsonl;这里记的是「路由决策」本身)。
679
+ // 落盘失败**必须显式报错**,不能吞 —— 「投了没记」会让环路统计漏跳。
680
+ try {
681
+ recordRoute({ to: decision.to, fromTeam: decision.fromTeam, taskId, text, hop: decision.hop, by: author, now, wake })
682
+ } catch (recordError) {
683
+ return {
684
+ ok: false, decision: 'refused', reason: 'ROUTE_RECORD_FAILED',
685
+ message: recordError?.message ?? String(recordError),
686
+ to: decision.to, hop: decision.hop, wake, taskId,
687
+ }
688
+ }
689
+
690
+ return { ok: true, decision: 'delivered', to: decision.to, hop: decision.hop, wake, taskId }
691
+ }
692
+
693
+ /**
694
+ * 把已经做出的路由决定**落盘留痕**(intent-first:先记后投)。
695
+ *
696
+ * 顺序不可颠倒:`记了没投` 是可见可重试的;`投了没记` 则绕过限额/审计,
697
+ * 让环路防护统计不到这一跳 —— 那正是要防的。
698
+ * @param {{ to: object, fromTeam: string, taskId: string, text?: string, hop: number,
699
+ * by: string, now?: number, wake?: object }} input
700
+ */
701
+ function recordRoute(input = {}) {
702
+ assertAlive('recordRoute')
703
+ const { to, fromTeam, taskId, text, hop, by, now, wake } = input ?? {}
704
+ const author = requireString(by, 'by', 'recordRoute')
705
+ const at = normalizeNow(now, 'recordRoute')
706
+ const rec = {
707
+ toKind: to?.kind ?? 'team',
708
+ toId: to?.id ?? null,
709
+ toLeadId: to?.leadId ?? null,
710
+ toName: to?.name ?? null,
711
+ fromTeam: fromTeam ?? null,
712
+ taskId,
713
+ text: text ?? '',
714
+ hop: Number(hop ?? 0),
715
+ wake: wake ?? null,
716
+ at,
717
+ }
718
+ if (!appendJsonl(jsonlPath, { action: 'route', at, by: author, before: null, after: rec })) {
719
+ throw new TeamError(`teams.recordRoute:事件追加失败(${jsonlPath})—— 拒绝「投了没记」`, 'TEAM_APPEND_FAILED', { action: 'route' })
720
+ }
721
+ events.push({ action: 'route', at, by: author, before: null, after: clone(rec) })
722
+ info(`[cs-bots/teams] route ${rec.fromTeam} → ${rec.toId}(lead=${rec.toLeadId}, hop=${rec.hop}, task=${taskId})by ${author}`)
723
+ return clone(rec)
724
+ }
725
+
726
+ /**
727
+ * 查 route 审计记录(按 toId / taskId / fromTeam 过滤)。
728
+ * @param {{ toId?: string, fromTeam?: string, taskId?: string }} [query]
729
+ */
730
+ function listRoutes(query = {}) {
731
+ assertAlive('listRoutes')
732
+ let rows = events.filter((e) => e.action === 'route').map((e) => ({ ...e.after, by: e.by, at: e.at }))
733
+ if (query?.toId !== undefined && query?.toId !== null) rows = rows.filter((r) => r.toId === query.toId)
734
+ if (query?.fromTeam !== undefined && query?.fromTeam !== null) rows = rows.filter((r) => r.fromTeam === query.fromTeam)
735
+ if (query?.taskId !== undefined && query?.taskId !== null) rows = rows.filter((r) => r.taskId === query.taskId)
736
+ return clone(rows)
737
+ }
738
+
505
739
  // ── API:读 ────────────────────────────────────────────────────────────────
506
740
 
507
741
  function list(query = {}) {
@@ -513,6 +747,113 @@ export function createTeams(ctx, config = {}) {
513
747
  return clone(rows.map((r) => (r.adminPolicy ? r : { ...r, adminPolicy: DEFAULT_TEAM_ADMIN_POLICY })))
514
748
  }
515
749
 
750
+ /**
751
+ * 按房间 id 反查绑定它的团队(t-39 第2批 C)。
752
+ *
753
+ * 用途:Boss 在 cs-bots 团队的议事厅发言 ⇒ 知道该同步给哪个原生 team。
754
+ * 一个房间只可能被一个团队绑定(`attachRoom` 是 CAS 单值覆盖)。
755
+ *
756
+ * @param {string} roomId
757
+ * @returns {object|null} 团队记录(含 `nativeTeamId`),没绑定 ⇒ null
758
+ */
759
+ function findByRoom(roomId) {
760
+ assertAlive('findByRoom')
761
+ if (typeof roomId !== 'string' || roomId.length === 0) return null
762
+ for (const t of entries.values()) {
763
+ if (t.roomId === roomId) return get(t.id)
764
+ }
765
+ return null
766
+ }
767
+
768
+ /**
769
+ * **发言同步给 DSH 原生 team 的 Lead**(t-39 第2批 C · Boss 体验 item 5)。
770
+ *
771
+ * ### 为什么要这个
772
+ * Boss 在 cs-bots 团队的议事厅发言,原生 team 的 Lead 感知不到 —— 因为两套
773
+ * 团队模型是**分离的**(cs-bots `teams.jsonl` vs DSH `agentTeam`),消息不会自动
774
+ * 跨过去。这个方法负责把这条链补上。
775
+ *
776
+ * ### 链路(lead 批准的实现方向,2026-11-11)
777
+ * Boss 在 cs-bots 房间发言 → `findByRoom(roomId)` 反查绑定团队
778
+ * → 取该团队的 `nativeTeamId` → 投递给原生 team 的 Lead。
779
+ *
780
+ * **为什么投递 target = nativeTeamId 就是投递给 Lead**:
781
+ * DSH 原生 `TeamId` 就是 root session id(`TeamId(id: SessionId|string)`,
782
+ * 且 `tryMembership(agent).root.id === membership.id`)。所以原生 team 的 Lead
783
+ * 会话 id 就等于这个 TeamId。投递 root = 投递 Lead,走的是 root 路径,**合法**。
784
+ *
785
+ * ### 为什么用 dispatcher 而不是 agentTeams.sendMessage
786
+ * 官方 `sendMessage(caller, ...)` 需要 Lead 身份的 `Agent` 作为凭证,且校验
787
+ * Lead→child 边 —— 队友不能给 Lead 发消息。cs-bots 是 root session,用
788
+ * `dispatcher.followup(root)` 投递是 root 路径,不冒充任何人。
789
+ *
790
+ * ### 诚实原则(硬要求)
791
+ * 同步失败**绝不**返回 `ok:false`,也**绝不**假装成功:
792
+ * `{ ok: true, synced: false, reason }` —— 房间消息本身已经成功落盘,
793
+ * 同步是附加动作,失败必须可见但不影响消息。
794
+ *
795
+ * reason 取值(给前端 A 的确定形状):
796
+ * - `NATIVE_TEAM_NOT_FOUND` 房间没绑任何 cs-bots 团队
797
+ * - `NATIVE_TEAM_NOT_BOUND` 团队存在但 `nativeTeamId` 为 null(要先配置绑定)
798
+ * - `SYNC_DISPATCHER_UNAVAILABLE` 装配层没注入 dispatcher
799
+ * - `SYNC_DEDUP_HIT` 这条消息之前已同步过(幂等,不算新同步也不算失败)
800
+ * - `SYNC_DELIVERY_FAILED` 投递失败(dispatcher 返回 failed)
801
+ * - `SYNCED` 同步成功
802
+ *
803
+ * @param {{ roomId: string, messageId?: string, text?: string, by: string,
804
+ * dispatcher: object }} input
805
+ * @returns {{ ok: true, synced: boolean, reason: string,
806
+ * teamId?: string, nativeTeamId?: string, leadId?: string, wake?: object }}
807
+ */
808
+ function syncToNativeLead(input = {}) {
809
+ assertAlive('syncToNativeLead')
810
+ const { roomId, messageId, text, by, dispatcher } = input ?? {}
811
+ requireString(by, 'by', 'syncToNativeLead')
812
+
813
+ // ① 反查房间属于哪个 cs-bots 团队(一个房间只可能被一个团队绑定)
814
+ const team = findByRoom(roomId)
815
+ if (!team) {
816
+ return { ok: true, synced: false, reason: 'NATIVE_TEAM_NOT_FOUND', detail: '房间未绑定任何 cs-bots 团队' }
817
+ }
818
+
819
+ // ② 团队有没有绑定 DSH 原生 team —— 这是同步的唯一依据
820
+ if (!team.nativeTeamId) {
821
+ return {
822
+ ok: true, synced: false, reason: 'NATIVE_TEAM_NOT_BOUND',
823
+ teamId: team.id, detail: '团队未绑定 DSH 原生 team,请先在团队详情里配置 nativeTeamId',
824
+ }
825
+ }
826
+
827
+ // ③ dispatcher 在不在(缺失 ⇒ 无法投递,但不是消息本身的错)
828
+ if (!dispatcher || typeof dispatcher.notify !== 'function') {
829
+ return {
830
+ ok: true, synced: false, reason: 'SYNC_DISPATCHER_UNAVAILABLE',
831
+ teamId: team.id, nativeTeamId: team.nativeTeamId,
832
+ }
833
+ }
834
+
835
+ // ④ 投递给原生 team 的 Lead(root session id == nativeTeamId)
836
+ // dedupKey 用消息 id —— 同一消息重复 post 不会重复同步(幂等)。
837
+ const dedupKey = `lead-sync:${messageId ?? roomId}:${team.id}`
838
+ const wake = dispatcher.notify({
839
+ target: team.nativeTeamId,
840
+ reason: 'room-message',
841
+ dedupKey,
842
+ text: `[cs-bots 团队发言同步] 「${team.name}」房间新消息:${String(text ?? '').slice(0, 200)}`,
843
+ teamId: team.id,
844
+ })
845
+
846
+ // ⑤ 诚实汇报:只有真的发出去才算 synced:true
847
+ const leadId = team.nativeTeamId
848
+ if (wake?.ok === true && wake?.result === 'sent') {
849
+ return { ok: true, synced: true, reason: 'SYNCED', teamId: team.id, nativeTeamId: leadId, leadId, wake }
850
+ }
851
+ if (wake?.ok === true && wake?.result === 'skipped') {
852
+ return { ok: true, synced: false, reason: 'SYNC_DEDUP_HIT', teamId: team.id, nativeTeamId: leadId, leadId, wake }
853
+ }
854
+ return { ok: true, synced: false, reason: 'SYNC_DELIVERY_FAILED', teamId: team.id, nativeTeamId: leadId, leadId, wake }
855
+ }
856
+
516
857
  function get(id) {
517
858
  assertAlive('get')
518
859
  const t = entries.get(id)
@@ -548,8 +889,16 @@ export function createTeams(ctx, config = {}) {
548
889
  // 契约 §3:把「议事厅」绑到团队上。api.js 的 ensureTeamRoom 会调它;
549
890
  // 不导出 ⇒ 房间建好了但团队记不住 roomId,重启后自动配厅就永远重配一遍。
550
891
  attachRoom,
892
+ // 契约 t-39:跨团队路由(只决定 + 留痕,投递由工具层用 dispatcher 完成)
893
+ resolveRoute,
894
+ recordRoute,
895
+ listRoutes,
896
+ // 一条龙:工具层与 HTTP 层共用的唯一序列编排
897
+ deliverRoute,
551
898
  list,
552
899
  get,
900
+ findByRoom,
901
+ syncToNativeLead,
553
902
  stats,
554
903
  replay,
555
904
  diagnostics: () => clone(diagnostics),
package/src/tools.js CHANGED
@@ -1132,7 +1132,7 @@ const DEFS = [
1132
1132
  },
1133
1133
  {
1134
1134
  name: 'bot_teams_add',
1135
- description: '向团队添加成员(需带 expectedRev 做 CAS)。',
1135
+ description: '向团队添加成员(需带 expectedRev 做 CAS)。若该团队已绑定房间(roomId),自动把成员拉进房间。',
1136
1136
  parameters: {
1137
1137
  type: 'object',
1138
1138
  properties: {
@@ -1146,7 +1146,18 @@ const DEFS = [
1146
1146
  run: (s, a, exec) => {
1147
1147
  if (!s.teams) return failure('teams-unavailable')
1148
1148
  try {
1149
- return success({ team: s.teams.addMember({ ...a, by: callerOf(exec) }) })
1149
+ const team = s.teams.addMember({ ...a, by: callerOf(exec) })
1150
+ // ── ③ bot_teams_add 补 attachRoom:若团队有 roomId,自动拉人进房间(失败不阻断主流程)──
1151
+ // 参照 api.js:1555 附近的逻辑:只回报 roomError,不让它把「加入团队成功」变成失败。
1152
+ let roomError
1153
+ if (team.roomId && s.room) {
1154
+ try {
1155
+ s.room.join({ roomId: team.roomId, sessionId: a.memberId, byId: callerOf(exec) })
1156
+ } catch (e) {
1157
+ roomError = `room.join 失败(${e?.code ?? 'room-join-failed'}):${e?.message ?? String(e)}`
1158
+ }
1159
+ }
1160
+ return success({ team, roomError })
1150
1161
  } catch (error) {
1151
1162
  return failure(error?.code ?? 'teams-add-failed', { message: error?.message ?? String(error) })
1152
1163
  }
@@ -1223,6 +1234,41 @@ const DEFS = [
1223
1234
  }
1224
1235
  },
1225
1236
  },
1237
+ // ── A. bot_team_route(t-39 第2批 · 跨团队定向路由)─────────────────────────
1238
+ {
1239
+ name: 'bot_team_route',
1240
+ description:
1241
+ '跨团队定向路由:把任务/消息路由给另一个团队或某个 Bot 所在团队的 lead。' +
1242
+ '只唤醒对方 leadId,不唤醒全部成员。先落 route 事件再投递(intent-first)。' +
1243
+ '失败一律返回可读的 {ok:false, decision:"refused", reason},绝不静默。',
1244
+ parameters: {
1245
+ type: 'object',
1246
+ properties: {
1247
+ fromTeam: { type: 'string', description: '发送方团队 id(可选;缺省按执行者会话 id 推导)' },
1248
+ toTeam: { type: 'string', description: '目标团队 id' },
1249
+ toBot: { type: 'string', description: '目标 Bot 的会话 id(toTeam 与 toBot 二选一)' },
1250
+ taskId: {
1251
+ type: 'string',
1252
+ description: '关联任务 id(可选;缺省自动生成 route-<时间戳>——页面「发协作请求」不一定挂任务)。'
1253
+ + '同一轮重发请复用同一 taskId,否则会被当成新路由(去重键含 taskId)',
1254
+ },
1255
+ text: { type: 'string', description: '要投递的正文内容' },
1256
+ hop: { type: 'number', description: '当前跳数(>3 拒绝;缺省 0)' },
1257
+ },
1258
+ additionalProperties: false,
1259
+ },
1260
+ run: (s, a, exec) => {
1261
+ if (!s.teams) return failure('teams-unavailable')
1262
+ // 唯一的序列编排在 `teams.deliverRoute()`(决定 → 投递 → 留痕)。
1263
+ // 工具层与 `POST /teams/route` 共用它 —— 两路各写一遍行为就会漂
1264
+ // (一边去重了、一边没去重;一边先投后记、一边先记后投)。
1265
+ return s.teams.deliverRoute({
1266
+ ...a,
1267
+ by: callerOf(exec),
1268
+ dispatcher: s.dispatcher,
1269
+ })
1270
+ },
1271
+ },
1226
1272
  ]
1227
1273
 
1228
1274