diff --git a/changelog.d/299-face-plain-route.changed.md b/changelog.d/299-face-plain-route.changed.md new file mode 100644 index 000000000..89ee28837 --- /dev/null +++ b/changelog.d/299-face-plain-route.changed.md @@ -0,0 +1 @@ +Face-to-face rooms: a plain line (no @) is no longer answered by every Bot. Each Bot now decides whether the line is for it: a specific question gets one answer, a follow-up goes to the Bot that spoke last, and a line to everyone ("大家介绍一下自己") may get a short answer from each. A Bot that passes posts nothing. If every Bot passes on your own line, the first Bot answers so you are never ignored. `@Name` and `@all` are unchanged, replies' own `@`s still start nothing, and approval and Auto-send rules are unchanged. (#299, #295) diff --git a/core/src/host/groups.js b/core/src/host/groups.js index 0d4e040a7..dd786ccb1 100644 --- a/core/src/host/groups.js +++ b/core/src/host/groups.js @@ -111,6 +111,9 @@ export const referenceNote = references => (references?.length : '') const ROOM_REPLY = 'Reply with your message for the group, no name prefix: in a discussion, one or two short messages. When you are asked to do something, do it now with your tools. The group does not wait for you, and your final reply is posted here when you finish, so give the result, never a promise to do it later. Reply [PASS] to stay silent when you have nothing new to add or the message is for someone else.' +// Words that address the whole room: a plain line with one of them may get an answer from each Bot (#299). +const ROOM_EVERYONE = /大家|你们|各位|所有人|所有 ?[Bb]ot|每个人|每位|各自|分别|everyone|everybody|y'?all|you all|all of you|each of you|introduce yourselves/i +const ROOM_REPLY_FORCED = 'Reply with your message for the group, no name prefix, short. Do not reply [PASS].' const ROOM_CALLS = 'Writing "@Name" calls that member next, to answer or to do what you ask; when it finishes work you gave it, you answer its result. To only mention a member, write its name without "@".' const slotLabel = (slot) => { @@ -167,6 +170,27 @@ export function install(rt) { return { id: session.id, brief: session.created && briefAllowed(room, member) ? await rt.ownChatBrief(member) : undefined } } + // A plain line in a face-to-face room (#299): the Bots decide who answers. Each gets this note, so one + // Bot answers a specific question, a follow-up goes to the Bot that just spoke, and a line to everyone may + // get a short answer from each. A pass posts nothing. + function routeNote(room, member, round) { + const { broadcast, last, forced } = round.route + if (forced === member.id) return 'The user asked this and nobody else answered. Answer now: if the line is plainly for another owner\'s Bot, say that in one short sentence.' + const everyone = room.members.map(rt.botOf).filter(Boolean) + const roles = everyone.filter(bot => bot.role).map(bot => `${bot.name}${bot.id === member.id ? ' (you)' : ''}: ${String(bot.role).slice(0, 80)}`) + const friends = currentParticipants(room).filter(p => p.kind === 'bot' && p.remote).map(p => p.label || p.displayName).filter(Boolean) + return [ + 'This line names no Bot, and the group should get the right answer, not one from everyone. Decide whether it is yours.', + broadcast + ? 'It is addressed to everyone (introductions, what each of you can do, a round of opinions): give your own short answer, but reply [PASS] if it would only repeat what a Bot before you said.' + : 'Answer only if it is for you: it names you or your role, or it fits what you do best and nobody before you has answered it, or nobody has answered yet and you can add something genuinely new. Reply [PASS] when a Bot before you already answered it, when it is for another Bot by name or role, or when it follows up on another Bot\'s answer.', + last && !broadcast ? `${last.name} spoke last before this line: a follow-up to what ${last.name} said ("what did you mean", "and then?", "say more") is for ${last.name}.` : '', + roles.length > 0 ? `What members do: ${roles.join('; ')}.` : '', + friends.length > 0 ? `Other owners' Bots here answer for themselves in their own home: ${friends.join(', ')}.` : '', + '[PASS] posts nothing and nobody sees it: never explain a pass.', + ].filter(Boolean).join(' ') + } + // Lines from before the round's opening message are kept apart from this round, so // an earlier round (an old count-off, say) never reads as part of the current one. function roomPrompt(room, member, round) { @@ -196,7 +220,8 @@ export function install(rt) { working.length > 0 ? `Still at work on what they were asked; each posts here when done: ${working.map(item => `${item.bot.name} (${minutesSince(item.since)} min)`).join(', ')}.` : '', `Speaking order for this round: ${round.slots.map(slotLabel).join(' → ')}.`, `You are number ${index + 1} of ${round.slots.length}. ${before === 0 ? 'Nobody spoke before you' : `${before} went before you`}${after === 0 ? ', and you are the last.' : `; ${after} after you will see your message.`}`, - ROOM_REPLY, + round.route ? routeNote(room, member, round) : '', + round.route?.forced === member.id ? ROOM_REPLY_FORCED : ROOM_REPLY, ].filter(Boolean).join('\n\n') } @@ -253,6 +278,8 @@ export function install(rt) { if (lines.length === 0) return 0 const before = round.slots.length const members = room.members.map(rt.botOf).filter(Boolean) + // A line the user adds mid-round is answered by the rule for its own opening, so this round stops routing. + delete round.route for (const line of lines) { if (!takePosted(room.id, line.text)) appendRoom(room, rt.authoredRoomLine?.(room, { who: line.authorName || 'User', text: line.text, authorId: line.authorId, authorKind: line.authorKind || 'human' }) ?? { who: 'User', text: line.text }) round.replies.push({ kind: 'user', text: line.text, time: Date.now(), authorId: line.authorId, authorKind: line.authorKind, name: line.authorName }) @@ -267,14 +294,44 @@ export function install(rt) { return round.slots.length - before } + // True once a routed round ended with every Bot silent on the owner's own line (a friend's line is not + // forced: the friend's own home answers it). A line that follows up a friend's Bot is theirs to answer. + function fallbackDue(round) { + const route = round.route + if (!route?.fallback || route.forced !== undefined || (route.last?.remote && !route.broadcast)) return false + const states = round.slots.map(slot => slot.status) + return !states.some(status => status === 'posted' || status === 'working' || status === 'waiting-approval' || status === 'speaking' || status === 'waiting') + && states.some(status => status === 'passed' || status === 'missed') + } + + // The Bot that answered the exchange right before this line (a Bot's post, once the person's own line or + // lines are set aside), for follow-ups such as "then what did you say about X?" (#299). + function recentSpeaker(room, startSeq) { + const lines = (room.log ?? []).filter(line => !line.kind) + let at = lines.length - 1 + while (at >= 0 && (lines[at].seq ?? 0) >= startSeq) at -= 1 + while (at >= 0 && lines[at].authorKind === 'human' && !lines[at].botId) at -= 1 + const line = lines[at] + if (!line?.botId) return undefined + const local = rt.botOf(line.botId) + return local && room.members.includes(local.id) ? { id: local.id, name: local.name, remote: false } : { id: line.botId, name: line.who, remote: true } + } + async function runRound(room, round, signal, agent) { let limit = round.slots.length + ROOM_EXTRA_TURNS + (room.admin ? ROOM_ADMIN_EXTRA_TURNS : 0) const turns = new Map() // The inbox check keeps a message sent during the last speaker's turn inside // this round: it drains at the loop's head even once the slots ran out. - for (let index = 0; (index < round.slots.length || waitingLines(agent).length > 0) && round.turns < limit; index += 1) { + for (let index = 0; (index < round.slots.length || waitingLines(agent).length > 0 || fallbackDue(round)) && round.turns < limit; index += 1) { if (signal.aborted) break limit += drainRoomInbox(room, round, turns, index, agent) + // Everyone passed on the owner's own plain line: the first Bot that passed answers, so the user is never ignored (#299). + if (index >= round.slots.length && fallbackDue(round)) { + const first = round.slots.find(slot => slot.status === 'passed' || slot.status === 'missed') + round.route.forced = first.id + round.slots.push({ id: first.id, name: first.name, status: 'waiting', note: 'nobody answered yet' }) + limit += 1 + } if (index >= round.slots.length) break const slot = round.slots[index] const member = rt.botOf(slot.id) @@ -387,13 +444,22 @@ export function install(rt) { const references = posts.flatMap(post => post.references ?? []) // A member a Bot's post calls by name works for that Bot. const named = opener.botId === undefined ? [] : mentionsIn(opener.text ?? '', room.members.map(rt.botOf).filter(Boolean)).found + // A person's plain line (not @all) in a face-to-face room: the Bots decide who answers (#299). The + // owner's own line is never left unanswered; a friend's attested line is answered by their home too. + const routed = isPlainFaceLine(room, opener) && !opener.everyone + const broadcast = routed && ROOM_EVERYONE.test(opener.text ?? '') + const route = routed ? { broadcast, fallback: opener.plain !== true, last: broadcast ? undefined : recentSpeaker(room, startSeq) } : undefined + const speakers = openingSpeakers(room, opener) + const lastAt = route?.last && !route.last.remote ? speakers.findIndex(bot => bot.id === route.last.id) : -1 + if (lastAt > 0) speakers.unshift(...speakers.splice(lastAt, 1)) const round = { roomId: room.id, members: room.members, + ...(route ? { route } : {}), startSeq, ...(opener.kickoff ? { kickoff: true } : {}), ...(references.length > 0 ? { references, shown: [] } : {}), - slots: openingSpeakers(room, opener).map(member => ({ + slots: speakers.map(member => ({ id: member.id, name: member.name, status: 'waiting', ...(opener.kickoff ? { note: 'the group was just created' } : {}), ...(named.includes(member) ? { by: opener.botId } : {}), diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index e1bed694a..6aebf149f 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -963,7 +963,7 @@ export function install(rt) { // A line that names nobody (or @all) is for the whole room (#295): this home's Bots answer it too, in one pass, // but only when the server attests the person typed it; a line that names a friend's Bot calls none here. const plain = attested === true && said !== undefined && callIds.length === 0 && (said.all || said.friends.length === 0) - return rt.postToRoom(room, { id: 'face-notice', name: who }, String(text ?? ''), { quiet: true, notice: true, human: { authorId: String(authorId || who), name: who }, ...(callIds.length > 0 ? { callIds } : {}), ...(plain ? { plain: true } : {}) }) + return rt.postToRoom(room, { id: 'face-notice', name: who }, String(text ?? ''), { quiet: true, notice: true, human: { authorId: String(authorId || who), name: who }, ...(callIds.length > 0 ? { callIds } : {}), ...(plain ? { plain: true } : {}), ...(plain && said?.all ? { everyone: true } : {}) }) }, postRemote: (roomId, { handle, name, text } = {}) => { const room = rt.roomOf(roomId) diff --git a/core/src/host/turns.js b/core/src/host/turns.js index 23e80b5f6..665bd1e8c 100644 --- a/core/src/host/turns.js +++ b/core/src/host/turns.js @@ -347,6 +347,7 @@ export function install(rt) { ...(source.notice ? { notice: true } : {}), ...(source.human ? { human: source.human } : {}), ...(source.plain ? { plain: true } : {}), + ...(source.everyone ? { everyone: true } : {}), ...(Array.isArray(source.callIds) && source.callIds.length > 0 ? { callIds: source.callIds.filter(id => typeof id === 'string') } : {}), ...(source.failed ? { failed: source.failed } : {}), ...(source.resumed ? { resumed: true } : {}), diff --git a/core/test/rounds.test.js b/core/test/rounds.test.js index bd31c3bbf..a1ea92797 100644 --- a/core/test/rounds.test.js +++ b/core/test/rounds.test.js @@ -608,3 +608,171 @@ test('a message sent between rounds still opens a full round', async (t) => { const result = JSON.parse(await running) assert.deepEqual(result.replies.map(reply => reply.name ?? 'User'), ['Chief', 'Writer']) }) + +// A face-to-face room with my Chief and Writer and a friend's Chief; `plain` helpers for #299. +async function routedRoom(ctxs, id) { + const { call, rt, sent } = ctxs + await team(call, sent) + await rt.load() + const state = (await call('state', {})).value + const chief = state.mainBotId + const writer = state.bots.find(bot => bot.name === 'Writer').id + await call('update-bot', { id: writer, role: 'Writes docs and copy' }) + const hosted = await rt.upsertFaceRoom({ + roomId: `frm_${id}`, + localId: `face-${id}`, + status: 'open', + members: [ + { handle: '@chief.me.home', displayName: 'Chief', ownerName: 'Me', published: false }, + { handle: '@writer.me.home', displayName: 'Writer', ownerName: 'Me', published: false }, + { handle: '@chief.bob.home', displayName: 'Chief', ownerName: 'Bob', published: false }, + ], + localBots: [{ id: chief, handle: '@chief.me.home', name: 'Chief' }, { id: writer, handle: '@writer.me.home', name: 'Writer' }], + }) + sent.length = 0 + return { hosted, chief, writer } +} +const roomTurns = sent => sent.filter(entry => entry.message?.source?.role === 'room') +const promptOf = turn => turn.message.content[0].text +const friendPlain = text => ({ type: 'user/message', data: { source: { kind: 'bot', role: 'post', senderSessionId: 'face-notice', senderName: 'Bob', quiet: true, notice: true, plain: true, human: { authorId: '@bob.home', name: 'Bob' } }, content: [{ type: 'text', text }] } }) + +test('#299: a plain line lets the Bots decide who answers: the passes post nothing and one Bot answers a specific question', async (t) => { + const env = start() + const { emit, userMessage, relay, answer, stop, sent } = env + t.after(stop) + const { hosted, chief, writer } = await routedRoom(env, 'route') + await emit(hosted.id, userMessage('帮我看看文档怎么写')) + const running = relay(hosted.id, 'route-1') + const first = await turnFor(sent) + assert.match(promptOf(first), /names no Bot/, 'the line is routed: each Bot decides whether it is theirs') + assert.match(promptOf(first), /\[PASS\]/) + assert.match(promptOf(first), /Writer: Writes docs and copy/, 'members see what each does') + assert.match(promptOf(first), /Bob's Chief|Chief/, 'friend\'s Bots are named as answering for themselves') + await answer(first.sessionId, first.message.source.exchangeId, '[PASS]', 1) + const second = await turnFor(sent, first) + assert.ok(second) + await answer(second.sessionId, second.message.source.exchangeId, 'Here is how I would write it.', 2) + const done = JSON.parse(await running) + assert.deepEqual(done.replies.filter(reply => reply.kind === undefined).map(reply => reply.text), ['Here is how I would write it.']) + assert.deepEqual(done.replies.filter(reply => reply.kind !== undefined && reply.kind !== 'user'), [], 'a pass shows no line') + assert.equal(done.passed.length, 1) + assert.equal(hosted.log.filter(line => /PASS/.test(line.text ?? '')).length, 0, 'a pass is never logged') + assert.equal(roomTurns(sent).length, 2) + assert.ok(chief && writer) +}) + +test('#299: a line to everyone (introductions) is marked so each Bot may answer; @Name and @all are not routed', async (t) => { + const env = start() + const { emit, userMessage, relay, answer, stop, sent } = env + t.after(stop) + const { hosted, writer } = await routedRoom(env, 'every') + await emit(hosted.id, userMessage('大家介绍一下自己')) + const running = relay(hosted.id, 'every-1') + const first = await turnFor(sent) + assert.match(promptOf(first), /addressed to everyone/) + await answer(first.sessionId, first.message.source.exchangeId, 'I am the Chief.', 1) + const second = await turnFor(sent, first) + await answer(second.sessionId, second.message.source.exchangeId, 'I write docs.', 2) + const done = JSON.parse(await running) + assert.equal(done.replies.filter(reply => reply.kind === undefined).length, 2, 'an open call for everyone may get one answer from each') + + // "@Name" targets exactly that Bot and is not routed; the prompt carries no pass-by-intent note. + sent.length = 0 + await emit(hosted.id, userMessage('@Writer 你是谁')) + const named = relay(hosted.id, 'every-2') + const only = await turnFor(sent) + assert.equal(only.sessionId, hosted.sessions[writer]) + assert.doesNotMatch(promptOf(only), /names no Bot/) + await answer(only.sessionId, only.message.source.exchangeId, 'Writer here', 3) + await named + assert.equal(roomTurns(sent).length, 1) + + // A friend's @all: everyone answers as before (one pass, no routing). + sent.length = 0 + await emit(hosted.id, { type: 'user/message', data: { source: { kind: 'bot', role: 'post', senderSessionId: 'face-notice', senderName: 'Bob', quiet: true, notice: true, plain: true, everyone: true, human: { authorId: '@bob.home', name: 'Bob' } }, content: [{ type: 'text', text: '@all hi' }] } }) + const all = relay(hosted.id, 'every-3') + const a = await turnFor(sent) + assert.doesNotMatch(promptOf(a), /names no Bot/) + await answer(a.sessionId, a.message.source.exchangeId, 'hi 1', 4) + const b = await turnFor(sent, a) + await answer(b.sessionId, b.message.source.exchangeId, 'hi 2', 5) + await all +}) + +test('#299: when every Bot passes on the owner\'s own line the first one answers; a friend\'s line is not forced', async (t) => { + const env = start() + const { emit, userMessage, relay, answer, stop, sent } = env + t.after(stop) + const { hosted } = await routedRoom(env, 'fall') + await emit(hosted.id, userMessage('随便问一句')) + const running = relay(hosted.id, 'fall-1') + const first = await turnFor(sent) + await answer(first.sessionId, first.message.source.exchangeId, '[PASS]', 1) + const second = await turnFor(sent, first) + await answer(second.sessionId, second.message.source.exchangeId, '(pass)', 2) + const forced = await turnFor(sent, first, second) + assert.ok(forced, 'nobody answered, so the first Bot is asked again') + assert.equal(forced.sessionId, first.sessionId, 'admin first') + assert.match(promptOf(forced), /nobody else answered/) + assert.doesNotMatch(promptOf(forced), /Reply \[PASS\]/) + // Its own @ starts nothing: no loop. + await answer(forced.sessionId, forced.message.source.exchangeId, 'I will take it. @Writer ok?', 3) + const done = JSON.parse(await running) + assert.deepEqual(done.replies.filter(reply => reply.kind === undefined).map(reply => reply.text), ['I will take it. @Writer ok?']) + assert.equal(roomTurns(sent).length, 3, 'no follow-up turns from the reply\'s @') + + // A friend's attested line: both Bots pass and nothing is forced (their own home answers it). + sent.length = 0 + await emit(hosted.id, friendPlain('他们的问题')) + const theirs = relay(hosted.id, 'fall-2') + const a = await turnFor(sent) + await answer(a.sessionId, a.message.source.exchangeId, '[PASS]', 4) + const b = await turnFor(sent, a) + await answer(b.sessionId, b.message.source.exchangeId, '[PASS]', 5) + const result = JSON.parse(await theirs) + assert.equal(roomTurns(sent).length, 2) + assert.deepEqual(result.replies.filter(reply => reply.kind === undefined), []) +}) + +test('#299: a follow-up goes to the Bot that spoke last, who is asked first; one that follows a friend\'s Bot is not forced on mine', async (t) => { + const env = start() + const { emit, userMessage, relay, answer, stop, sent } = env + t.after(stop) + const { hosted, writer } = await routedRoom(env, 'follow') + await emit(hosted.id, userMessage('文档怎么写')) + const one = relay(hosted.id, 'follow-1') + const a = await turnFor(sent) + await answer(a.sessionId, a.message.source.exchangeId, '[PASS]', 1) + const b = await turnFor(sent, a) + assert.equal(b.sessionId, hosted.sessions[writer]) + await answer(b.sessionId, b.message.source.exchangeId, 'Start with an outline.', 2) + await one + + // "then what did you say about it?" right after: the Writer is first, not the admin. + sent.length = 0 + await emit(hosted.id, userMessage('那你刚才说的大纲具体怎么写')) + const two = relay(hosted.id, 'follow-2') + const c = await turnFor(sent) + assert.equal(c.sessionId, hosted.sessions[writer], 'the last speaker is asked first') + assert.match(promptOf(c), /Writer spoke last before this line/) + await answer(c.sessionId, c.message.source.exchangeId, 'Three headings, then fill in.', 3) + const d = await turnFor(sent, c) + assert.ok(d, 'the other Bot is still asked and sees the answer, so it can pass') + await answer(d.sessionId, d.message.source.exchangeId, '[PASS]', 4) + const done = JSON.parse(await two) + assert.deepEqual(done.replies.filter(reply => reply.kind === undefined).map(reply => reply.text), ['Three headings, then fill in.']) + + // The friend's Bot spoke last: my Bots may pass, and nothing is forced on the owner's home. + await emit(hosted.id, { type: 'user/message', data: { source: { kind: 'bot', role: 'post', senderSessionId: '@chief.bob.home', senderName: "Bob's Chief", remote: { handle: '@chief.bob.home' } }, content: [{ type: 'text', text: 'I can see the whole room.' }] } }) + await relay(hosted.id, 'follow-3') + sent.length = 0 + await emit(hosted.id, userMessage('那你说说群里都聊了啥')) + const three = relay(hosted.id, 'follow-4') + const e = await turnFor(sent) + assert.match(promptOf(e), /Chief spoke last before this line/) + await answer(e.sessionId, e.message.source.exchangeId, '[PASS]', 5) + const f = await turnFor(sent, e) + await answer(f.sessionId, f.message.source.exchangeId, '[PASS]', 6) + await three + assert.equal(roomTurns(sent).length, 2, 'no forced answer when the follow-up is to a friend\'s Bot') +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index d91c4b900..c879d6a13 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -111,7 +111,7 @@ credentials and team mode remain separate work under #55/#198. - A face room's auto title (`faceRoomFallbackTitle`) is built from every member the room has had (the poll's snapshot plus stored participants) and the creation time (`faceRoomCreatedAt`: `spec.createdAt`, else the joining deadline minus the 10-minute window, else the start; #194), so every home shows the same title and a member who left is not dropped from it (#182). - Another member's own face-room line arrives through `flybot.rooms.postHuman` (#180): a quiet `postToRoom` post carrying `source.human { authorId, name }`. `group_relay` turns it into a round reply `{ kind: 'user', other: true, name, authorId }` (not the grey `notice`), so it draws as a left-hand bubble from that person; it asks no Bot to answer. `postNotice` stays for system events (joined, left, started). Covered by `core/test/rounds.test.js`. - `flybot.chatApprovals.changed()` saves the team (moving the revision) so `bot.pendingCount` reaches the browser at once when a hold arrives or is decided (#226). Covered by `core/test/flybot.test.js`. -- The DSH composer still prompts the group Session (`user/message` → `group_relay`). Face-to-face turn rule (#167, changed in #287 and #295), `openingSpeakers`: a **plain** line (no `@` naming a Bot) starts one round in which **every** local Bot may answer, the admin first then the room's order, capped at `FACE_PLAIN_SPEAKERS` (6), one at a time and each after the earlier replies; that round is `single` (`isPlainFaceLine`): replies' own `@`s start no follow-up turns inside it. `@Name`, the label the picker shows (`Friend's Chief`, `Writer · Friend's Bot`) or a handle picks that local Bot (`face-mentions.js`); `@all` is everyone; a line that only @s a friend's Bot starts none here; A friend's line (`flybot.rooms.postHuman`, only when the server attests `typed_by_owner`, passed as `attested`) names my Bot by label or handle (`callIds` in the post's source, read by `openingSpeakers` before the quiet rule), or is plain or `@all` and then carries `plain: true`, so my Bots answer it in the same single pass (#295); a line that only names a friend's Bot, or one from a server that cannot attest, stays quiet; a bare `@Chief` from a friend whose own Chief is in the room calls nobody here. A Bot's post in a face room still starts only the Bots it @s, so Bots in two homes cannot set each other off. The reply is held / sent under the usual approval and Auto-send rules. An `@` that names nobody in the room adds one quiet notice line (`No one in this room is called @X …`). A face-room line the send path already posted (`rt.notePostedLine`) is not appended or sent again when the round reads it from the Session. Covered by `core/test/rounds.test.js`, `core/test/face-mentions.test.js` and `plugins/social/test/social.test.js`. +- The DSH composer still prompts the group Session (`user/message` → `group_relay`). Face-to-face turn rule (#167, changed in #287 and #295), `openingSpeakers`: a **plain** line (no `@` naming a Bot) starts one round in which **every** local Bot may answer, the admin first then the room's order, capped at `FACE_PLAIN_SPEAKERS` (6), one at a time and each after the earlier replies; that round is `single` (`isPlainFaceLine`): replies' own `@`s start no follow-up turns inside it. **Routed by intent (#299):** a plain line that is not `@all` (`route` on the round) also tells each Bot in its prompt (`routeNote`) to answer only when the line is theirs — named by role, nobody before them answered, or they add something new — and to reply `[PASS]` otherwise; a pass (`isPass`) posts, logs and shows nothing. A line to everyone (`ROOM_EVERYONE`: 你们/大家/everyone/introduce yourselves…) says each may answer. The Bot that spoke last before the line (`recentSpeaker`, a follow-up) is asked first and named in the note. When every Bot passes on the **owner's own** line (not a friend's attested one, whose home answers it; not a follow-up to a friend's Bot), `fallbackDue` appends one turn so the first Bot that passed answers with no pass option. `everyone` in the post's source marks a friend's `@all` (not routed). Covered by `core/test/rounds.test.js`. `@Name`, the label the picker shows (`Friend's Chief`, `Writer · Friend's Bot`) or a handle picks that local Bot (`face-mentions.js`); `@all` is everyone; a line that only @s a friend's Bot starts none here; A friend's line (`flybot.rooms.postHuman`, only when the server attests `typed_by_owner`, passed as `attested`) names my Bot by label or handle (`callIds` in the post's source, read by `openingSpeakers` before the quiet rule), or is plain or `@all` and then carries `plain: true`, so my Bots answer it in the same single pass (#295); a line that only names a friend's Bot, or one from a server that cannot attest, stays quiet; a bare `@Chief` from a friend whose own Chief is in the room calls nobody here. A Bot's post in a face room still starts only the Bots it @s, so Bots in two homes cannot set each other off. The reply is held / sent under the usual approval and Auto-send rules. An `@` that names nobody in the room adds one quiet notice line (`No one in this room is called @X …`). A face-room line the send path already posted (`rt.notePostedLine`) is not appended or sent again when the round reads it from the Session. Covered by `core/test/rounds.test.js`, `core/test/face-mentions.test.js` and `plugins/social/test/social.test.js`. - Group Session models (#289): `applyModel` does not trust `appliedModel` while a Bot is `modelStale`. A failed model selection in a group Session (from `applyModel` or from `room-participant.js` `open()`) marks the Bot stale; the next apply (model-list change, restart, or the Bot's next turn in a known group Session) selects every Session again and clears the mark once all took it. Covered by `core/test/models.test.js`. ### Face-to-face participant look diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index ae922272d..fccff6c1f 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -55,7 +55,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots 6. `flybot.roomApprovals.setProvider` lets a plugin (social) attach pending outbound holds to a shared-room view. It does not enable team mode. 7. `cleanRemote` is the record boundary for a friend's granted Bot: `name`, `homeName` and `owner` strip `@` (so they cannot look like a mention of another Bot in `nameMentions` chips/previews) and control characters/newlines, then cap at 60 characters. Handles stay on the `handle` field. Optional look fields (`color`, `avatar` object, `avatarUpdatedAt`) pass through when present so remote chats, group lists, and headers can render a peer's chosen look; missing look stays handle-seeded. Those labels then flow into delivery `fromName` (still `cleanName`'d and the body fenced) and into `list_bots` friends lines via the remote-chat roster. Group room logs use local team names, not peer labels. 8. A user's @ of a friend's Bot in a group gets a receipt line in that group (`hooks.js` `mentionReceipt`, relay role `mention`): where the reply lands, or why the send was denied, held, or failed. The receipt's role stays outside the room round trigger (`groups.js` `opens`), so it never opens a round; peer names go through `cleanName`. A human line that names a friend's Bot already in that room is **not** 1:1-relayed as the group's admin (Chief): the room message is the delivery, so Chief's missing grant does not block it (#143). A Chief-originated `bot.send` to an ungranted friend Bot is still deny. A remaining grant deny on a mention that is *not* in the room says to invite the Bot or add it to the group. -9. `flybot.rooms` hosts a Bot Social face-to-face matching room as a native room (`upsertFace` / `applyFaceLine` / `ofFace` / `dropFace` / `onFaceLine` (`fn(room, line, { mentions })`: for the owner's typed line, the handles its `@` names, #293) / `postNotice` / `postHuman` (quiet; it reads the friend's line for `@` of a local Bot by label or handle and passes `callIds`, so only those Bots answer, #287; an optional `mentions` handle list from an attested line is resolved first, #293; `attested: true`, passed only for a line the server stamps `typed_by_owner`, lets a plain or `@all` line start this home's Bots in one capped pass, #295) / `postRemote`; `postHuman` draws another member's typed line as a chat bubble from that person, #180). Slot `flybot.room.overview` is a block at the top of a group's overview: while joining, a tile grid (avatar above name + owner) with **Leave** and **Enter chat** on one row; once open, compact **Leave** and Auto-send — not Open/Start/Leave together. Slot `flybot.new-chat.action` adds a row to the sidebar **+** short action menu (next to Create group chat; `label` / `hint` go through the same `t()` map). Still not team mode. +9. `flybot.rooms` hosts a Bot Social face-to-face matching room as a native room (`upsertFace` / `applyFaceLine` / `ofFace` / `dropFace` / `onFaceLine` (`fn(room, line, { mentions })`: for the owner's typed line, the handles its `@` names, #293) / `postNotice` / `postHuman` (quiet; it reads the friend's line for `@` of a local Bot by label or handle and passes `callIds`, so only those Bots answer, #287; an optional `mentions` handle list from an attested line is resolved first, #293; `attested: true`, passed only for a line the server stamps `typed_by_owner`, lets a plain or `@all` line start this home's Bots in one capped pass, #295; a plain line is answered by the Bots that judge it theirs, `@all` is not routed, #299) / `postRemote`; `postHuman` draws another member's typed line as a chat bubble from that person, #180). Slot `flybot.room.overview` is a block at the top of a group's overview: while joining, a tile grid (avatar above name + owner) with **Leave** and **Enter chat** on one row; once open, compact **Leave** and Auto-send — not Open/Start/Leave together. Slot `flybot.new-chat.action` adds a row to the sidebar **+** short action menu (next to Create group chat; `label` / `hint` go through the same `t()` map). Still not team mode. 10. Held sends are owner-bound (#52): a hold stamps server-derived provenance on the record — `requestId`, a sha256 `version` over the request content, `origin` (`via`/`hop`/audience) and the sender Bot's `ownerId`; message claims (`meta`, text) never confer authority. `flybot/decide` passes authz for any signed-in actor and hooks binds the decision to the sender Bot's owner's session; `release` revalidates and marks records `stale` (changed request, deleted sender/target Bot, changed owner — `deleteBot` invalidates eagerly; a demoted Chief's sender-side holds are invalidated eagerly on `setMain`, not inside `staleReason`). `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. The pending list is filtered per actor under member enforcement. The default approval card shows `senderName`/`targetName` and a delegated marker (`Approval.origin`). 11. Task lifecycle (#54): `bot.send` stamps `meta.taskId` from the working task (`rt.taskIdForSend`); a held send then calls `rt.taskHeld` (blocks **that** task). The owner's `flybot/decide` calls `rt.taskDecided` (approve resumes, reject cancels); `deleteBot` of a local Bot calls `rt.taskBotDeleted` (open tasks fail). Incoming holds stay the owner's. The state machine lives in `tasks.js`; hooks only fire those three seams. Clarification and `workBotId` wait on #55. 12. Activity feed (#241): plugins that want to see where team time goes subscribe through `flybot.on(...)` and never to raw DSH `session/event` (the maintainer decision on #241). Events: `turn.start` / `turn.end` (a Bot's own chat `place: 'chat'`, its hidden group Session `'group'`, the group's own Session `'room'`, whose turn is one round, or a friend's Bot chat `'remote'` (#248: its Session runs no model and only carries lines to and from the friend, so those turns are transport, not a team Bot's work; `remote: {handle, name}` names the friend as the sidebar does (`Helper · Bob's Bot`, `Bob's Chief`; a `{kind: 'remote'}` party carries the same `name` when the roster knows it), and the friend's line into the chat is an `exchange.delivered` with `role: 'remote'`)), `tool.start` / `tool.end` (paired by `callId`, closed `interrupted` when their turn ends first) and `exchange.delivered` (a message arrived in a Session's log: `exchangeId` as in `state.exchanges`, `replyTo` of the answer, `from` a user / Bot / group / friend's Bot). The feed is metadata only: message text, tool arguments, tool output and model output never leave `flybot-feed.js`, so a consumer that stores it cannot persist a secret. Strings from outside the team (tool names, remote handles) go through `cleanLabel` (which also removes `untrusted` fence markers when spaced out or split), error outcomes carry an upper-case code (`RATE_LIMIT`, else `UNKNOWN`) and no message. Delivered means "in the recipient's log", not read; the feed cannot see read. Host nudges (retry, resume, kickoff, deferred and re-sent deliveries) are not deliveries. Not replayed: a listener hears events after it subscribes. Listeners get copies and a throwing one cannot reach the Session or the other listeners. Events fire in a read-only host too, since they only observe. diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 9297cf8e2..2d1a0be2d 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -84,7 +84,7 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ ### Face-room human turns and UI polish (#166, #167) -- A plain human line in an open face-to-face room is logged and fanned out once, and starts a single pass of every local Bot (admin first, capped at 6, no @ needed; #287, #295; core `openingSpeakers`, see [core-host](core-host.md)). A friend's attested plain line (or `@all`) starts the same pass on this home (the plugin passes `attested: true` to `postHuman` only for a stamped line); an attested line that names Bots starts only the Bot(s) it names by the label the picker shows (`Friend's Chief`) or by handle; `plugins/social/test` has the two-home case. **Explicit mention list (#293):** the sender's host resolves the picker's labels against the room (`faceMentions().handles`; core `onFaceLine(fn)` passes `{ mentions }` for the owner's typed line) and `faceApi.send` puts the handles in `mentions` next to `typed_by_owner` only when `GET /v1/platform` `features` lists `mentions` (the one feature probe, `serverMentions()`); never on the legacy envelope, Bot lines or an older server. The receiver reads `mentions` (`cleanMentions`, at most 8 handles) only from a line the server attests `typed_by_owner` (`renderFaceEvent`), and `postHuman` resolves the handles against the room first (`mentionedByHandle`): only my own Bots in the list answer; a list naming nobody the room knows, or no list, falls back to the label/name reading above. The composer's `@` picker inserts the label text, so the handles are derived from it at send, not tracked in the composer. +- A plain human line in an open face-to-face room is logged and fanned out once, and starts a single pass of every local Bot (admin first, capped at 6, no @ needed; #287, #295; core `openingSpeakers`, see [core-host](core-host.md)). Since #299 the Bots pick the responder(s) from a plain line (they may `[PASS]`; the first Bot answers if all pass on the owner's own line), and a line to everyone or `@all` is unchanged. A friend's attested plain line (or `@all`) starts the same pass on this home (the plugin passes `attested: true` to `postHuman` only for a stamped line); an attested line that names Bots starts only the Bot(s) it names by the label the picker shows (`Friend's Chief`) or by handle; `plugins/social/test` has the two-home case. **Explicit mention list (#293):** the sender's host resolves the picker's labels against the room (`faceMentions().handles`; core `onFaceLine(fn)` passes `{ mentions }` for the owner's typed line) and `faceApi.send` puts the handles in `mentions` next to `typed_by_owner` only when `GET /v1/platform` `features` lists `mentions` (the one feature probe, `serverMentions()`); never on the legacy envelope, Bot lines or an older server. The receiver reads `mentions` (`cleanMentions`, at most 8 handles) only from a line the server attests `typed_by_owner` (`renderFaceEvent`), and `postHuman` resolves the handles against the room first (`mentionedByHandle`): only my own Bots in the list answer; a list naming nobody the room knows, or no list, falls back to the label/name reading above. The composer's `@` picker inserts the label text, so the handles are derived from it at send, not tracked in the composer. - A held room line belongs to one room (#188): the send path records `settings.holdRooms[approvalId] = faceRoomId` when Bot Social's card does not name the room, and `pendingApprovals` drops holds with no known room (legacy) or whose room is left / closed / past here, so no other room shows the card and no waiting dot stays on an old row. The room scope in the client, the `roomApprovals` provider and `hidden-remotes` match the room id strictly. - Face-room holds (`face_room_message` / `roomId`) attach `pendingCount` to that room only. They do not badge an unrelated friend row via `from`/`to` handle. The social strip no longer draws an **N to approve** tag under the last sidebar row; the gold wait dot on the owning RoomRow / 1:1 remains. - Approval preview strips `To face room:` / `To the room:` and wraps on word boundaries (`overflow-wrap:break-word; word-break:normal`), with **Show more** when long. diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 017551e16..1ad80fa16 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -847,13 +847,14 @@ diff --git a/src/host/turns.js b/src/host/turns.js markExchangeTurn(sessionId, entry.turn) } } -@@ -331,6 +331,10 @@ export function install(rt) { +@@ -331,6 +331,11 @@ export function install(rt) { hop: source.hop ?? 0, ...(source.late ? { late: true } : {}), ...(source.quiet ? { quiet: true } : {}), + ...(source.notice ? { notice: true } : {}), + ...(source.human ? { human: source.human } : {}), + ...(source.plain ? { plain: true } : {}), ++ ...(source.everyone ? { everyone: true } : {}), + ...(Array.isArray(source.callIds) && source.callIds.length > 0 ? { callIds: source.callIds.filter(id => typeof id === 'string') } : {}), ...(source.failed ? { failed: source.failed } : {}), ...(source.resumed ? { resumed: true } : {}), diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 6997f29b7..aee7555e8 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -941,3 +941,175 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js test('a message sent between rounds still opens a full round', async (t) => { const { call, emit, userMessage, relay, answer, stop, sent } = start() t.after(stop) +@@ -347,3 +347,171 @@ test('a message sent between rounds still opens a full round', async (t) => { + const result = JSON.parse(await running) + assert.deepEqual(result.replies.map(reply => reply.name ?? 'User'), ['Chief', 'Writer']) + }) ++ ++// A face-to-face room with my Chief and Writer and a friend's Chief; `plain` helpers for #299. ++async function routedRoom(ctxs, id) { ++ const { call, rt, sent } = ctxs ++ await team(call, sent) ++ await rt.load() ++ const state = (await call('state', {})).value ++ const chief = state.mainBotId ++ const writer = state.bots.find(bot => bot.name === 'Writer').id ++ await call('update-bot', { id: writer, role: 'Writes docs and copy' }) ++ const hosted = await rt.upsertFaceRoom({ ++ roomId: `frm_${id}`, ++ localId: `face-${id}`, ++ status: 'open', ++ members: [ ++ { handle: '@chief.me.home', displayName: 'Chief', ownerName: 'Me', published: false }, ++ { handle: '@writer.me.home', displayName: 'Writer', ownerName: 'Me', published: false }, ++ { handle: '@chief.bob.home', displayName: 'Chief', ownerName: 'Bob', published: false }, ++ ], ++ localBots: [{ id: chief, handle: '@chief.me.home', name: 'Chief' }, { id: writer, handle: '@writer.me.home', name: 'Writer' }], ++ }) ++ sent.length = 0 ++ return { hosted, chief, writer } ++} ++const roomTurns = sent => sent.filter(entry => entry.message?.source?.role === 'room') ++const promptOf = turn => turn.message.content[0].text ++const friendPlain = text => ({ type: 'user/message', data: { source: { kind: 'bot', role: 'post', senderSessionId: 'face-notice', senderName: 'Bob', quiet: true, notice: true, plain: true, human: { authorId: '@bob.home', name: 'Bob' } }, content: [{ type: 'text', text }] } }) ++ ++test('#299: a plain line lets the Bots decide who answers: the passes post nothing and one Bot answers a specific question', async (t) => { ++ const env = start() ++ const { emit, userMessage, relay, answer, stop, sent } = env ++ t.after(stop) ++ const { hosted, chief, writer } = await routedRoom(env, 'route') ++ await emit(hosted.id, userMessage('帮我看看文档怎么写')) ++ const running = relay(hosted.id, 'route-1') ++ const first = await turnFor(sent) ++ assert.match(promptOf(first), /names no Bot/, 'the line is routed: each Bot decides whether it is theirs') ++ assert.match(promptOf(first), /\[PASS\]/) ++ assert.match(promptOf(first), /Writer: Writes docs and copy/, 'members see what each does') ++ assert.match(promptOf(first), /Bob's Chief|Chief/, 'friend\'s Bots are named as answering for themselves') ++ await answer(first.sessionId, first.message.source.exchangeId, '[PASS]', 1) ++ const second = await turnFor(sent, first) ++ assert.ok(second) ++ await answer(second.sessionId, second.message.source.exchangeId, 'Here is how I would write it.', 2) ++ const done = JSON.parse(await running) ++ assert.deepEqual(done.replies.filter(reply => reply.kind === undefined).map(reply => reply.text), ['Here is how I would write it.']) ++ assert.deepEqual(done.replies.filter(reply => reply.kind !== undefined && reply.kind !== 'user'), [], 'a pass shows no line') ++ assert.equal(done.passed.length, 1) ++ assert.equal(hosted.log.filter(line => /PASS/.test(line.text ?? '')).length, 0, 'a pass is never logged') ++ assert.equal(roomTurns(sent).length, 2) ++ assert.ok(chief && writer) ++}) ++ ++test('#299: a line to everyone (introductions) is marked so each Bot may answer; @Name and @all are not routed', async (t) => { ++ const env = start() ++ const { emit, userMessage, relay, answer, stop, sent } = env ++ t.after(stop) ++ const { hosted, writer } = await routedRoom(env, 'every') ++ await emit(hosted.id, userMessage('大家介绍一下自己')) ++ const running = relay(hosted.id, 'every-1') ++ const first = await turnFor(sent) ++ assert.match(promptOf(first), /addressed to everyone/) ++ await answer(first.sessionId, first.message.source.exchangeId, 'I am the Chief.', 1) ++ const second = await turnFor(sent, first) ++ await answer(second.sessionId, second.message.source.exchangeId, 'I write docs.', 2) ++ const done = JSON.parse(await running) ++ assert.equal(done.replies.filter(reply => reply.kind === undefined).length, 2, 'an open call for everyone may get one answer from each') ++ ++ // "@Name" targets exactly that Bot and is not routed; the prompt carries no pass-by-intent note. ++ sent.length = 0 ++ await emit(hosted.id, userMessage('@Writer 你是谁')) ++ const named = relay(hosted.id, 'every-2') ++ const only = await turnFor(sent) ++ assert.equal(only.sessionId, hosted.sessions[writer]) ++ assert.doesNotMatch(promptOf(only), /names no Bot/) ++ await answer(only.sessionId, only.message.source.exchangeId, 'Writer here', 3) ++ await named ++ assert.equal(roomTurns(sent).length, 1) ++ ++ // A friend's @all: everyone answers as before (one pass, no routing). ++ sent.length = 0 ++ await emit(hosted.id, { type: 'user/message', data: { source: { kind: 'bot', role: 'post', senderSessionId: 'face-notice', senderName: 'Bob', quiet: true, notice: true, plain: true, everyone: true, human: { authorId: '@bob.home', name: 'Bob' } }, content: [{ type: 'text', text: '@all hi' }] } }) ++ const all = relay(hosted.id, 'every-3') ++ const a = await turnFor(sent) ++ assert.doesNotMatch(promptOf(a), /names no Bot/) ++ await answer(a.sessionId, a.message.source.exchangeId, 'hi 1', 4) ++ const b = await turnFor(sent, a) ++ await answer(b.sessionId, b.message.source.exchangeId, 'hi 2', 5) ++ await all ++}) ++ ++test('#299: when every Bot passes on the owner\'s own line the first one answers; a friend\'s line is not forced', async (t) => { ++ const env = start() ++ const { emit, userMessage, relay, answer, stop, sent } = env ++ t.after(stop) ++ const { hosted } = await routedRoom(env, 'fall') ++ await emit(hosted.id, userMessage('随便问一句')) ++ const running = relay(hosted.id, 'fall-1') ++ const first = await turnFor(sent) ++ await answer(first.sessionId, first.message.source.exchangeId, '[PASS]', 1) ++ const second = await turnFor(sent, first) ++ await answer(second.sessionId, second.message.source.exchangeId, '(pass)', 2) ++ const forced = await turnFor(sent, first, second) ++ assert.ok(forced, 'nobody answered, so the first Bot is asked again') ++ assert.equal(forced.sessionId, first.sessionId, 'admin first') ++ assert.match(promptOf(forced), /nobody else answered/) ++ assert.doesNotMatch(promptOf(forced), /Reply \[PASS\]/) ++ // Its own @ starts nothing: no loop. ++ await answer(forced.sessionId, forced.message.source.exchangeId, 'I will take it. @Writer ok?', 3) ++ const done = JSON.parse(await running) ++ assert.deepEqual(done.replies.filter(reply => reply.kind === undefined).map(reply => reply.text), ['I will take it. @Writer ok?']) ++ assert.equal(roomTurns(sent).length, 3, 'no follow-up turns from the reply\'s @') ++ ++ // A friend's attested line: both Bots pass and nothing is forced (their own home answers it). ++ sent.length = 0 ++ await emit(hosted.id, friendPlain('他们的问题')) ++ const theirs = relay(hosted.id, 'fall-2') ++ const a = await turnFor(sent) ++ await answer(a.sessionId, a.message.source.exchangeId, '[PASS]', 4) ++ const b = await turnFor(sent, a) ++ await answer(b.sessionId, b.message.source.exchangeId, '[PASS]', 5) ++ const result = JSON.parse(await theirs) ++ assert.equal(roomTurns(sent).length, 2) ++ assert.deepEqual(result.replies.filter(reply => reply.kind === undefined), []) ++}) ++ ++test('#299: a follow-up goes to the Bot that spoke last, who is asked first; one that follows a friend\'s Bot is not forced on mine', async (t) => { ++ const env = start() ++ const { emit, userMessage, relay, answer, stop, sent } = env ++ t.after(stop) ++ const { hosted, writer } = await routedRoom(env, 'follow') ++ await emit(hosted.id, userMessage('文档怎么写')) ++ const one = relay(hosted.id, 'follow-1') ++ const a = await turnFor(sent) ++ await answer(a.sessionId, a.message.source.exchangeId, '[PASS]', 1) ++ const b = await turnFor(sent, a) ++ assert.equal(b.sessionId, hosted.sessions[writer]) ++ await answer(b.sessionId, b.message.source.exchangeId, 'Start with an outline.', 2) ++ await one ++ ++ // "then what did you say about it?" right after: the Writer is first, not the admin. ++ sent.length = 0 ++ await emit(hosted.id, userMessage('那你刚才说的大纲具体怎么写')) ++ const two = relay(hosted.id, 'follow-2') ++ const c = await turnFor(sent) ++ assert.equal(c.sessionId, hosted.sessions[writer], 'the last speaker is asked first') ++ assert.match(promptOf(c), /Writer spoke last before this line/) ++ await answer(c.sessionId, c.message.source.exchangeId, 'Three headings, then fill in.', 3) ++ const d = await turnFor(sent, c) ++ assert.ok(d, 'the other Bot is still asked and sees the answer, so it can pass') ++ await answer(d.sessionId, d.message.source.exchangeId, '[PASS]', 4) ++ const done = JSON.parse(await two) ++ assert.deepEqual(done.replies.filter(reply => reply.kind === undefined).map(reply => reply.text), ['Three headings, then fill in.']) ++ ++ // The friend's Bot spoke last: my Bots may pass, and nothing is forced on the owner's home. ++ await emit(hosted.id, { type: 'user/message', data: { source: { kind: 'bot', role: 'post', senderSessionId: '@chief.bob.home', senderName: "Bob's Chief", remote: { handle: '@chief.bob.home' } }, content: [{ type: 'text', text: 'I can see the whole room.' }] } }) ++ await relay(hosted.id, 'follow-3') ++ sent.length = 0 ++ await emit(hosted.id, userMessage('那你说说群里都聊了啥')) ++ const three = relay(hosted.id, 'follow-4') ++ const e = await turnFor(sent) ++ assert.match(promptOf(e), /Chief spoke last before this line/) ++ await answer(e.sessionId, e.message.source.exchangeId, '[PASS]', 5) ++ const f = await turnFor(sent, e) ++ await answer(f.sessionId, f.message.source.exchangeId, '[PASS]', 6) ++ await three ++ assert.equal(roomTurns(sent).length, 2, 'no forced answer when the follow-up is to a friend\'s Bot') ++}) diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 382c2d449..d5276972d 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -574,7 +574,17 @@ diff --git a/src/host/groups.js b/src/host/groups.js return `${line.who}: ${line.text}` } const transcript = lines => lines.map(lineText).join('\n') -@@ -113,31 +113,33 @@ const ROOM_READ_MAX = 30 +@@ -86,6 +86,9 @@ export const referenceNote = references => (references?.length + : '') + + const ROOM_REPLY = 'Reply with your message for the group, no name prefix: in a discussion, one or two short messages. When you are asked to do something, do it now with your tools. The group does not wait for you, and your final reply is posted here when you finish, so give the result, never a promise to do it later. Reply [PASS] to stay silent when you have nothing new to add or the message is for someone else.' ++// Words that address the whole room: a plain line with one of them may get an answer from each Bot (#299). ++const ROOM_EVERYONE = /大家|你们|各位|所有人|所有 ?[Bb]ot|每个人|每位|各自|分别|everyone|everybody|y'?all|you all|all of you|each of you|introduce yourselves/i ++const ROOM_REPLY_FORCED = 'Reply with your message for the group, no name prefix, short. Do not reply [PASS].' + const ROOM_CALLS = 'Writing "@Name" calls that member next, to answer or to do what you ask; when it finishes work you gave it, you answer its result. To only mention a member, write its name without "@".' + + const slotLabel = (slot) => { +@@ -113,31 +113,54 @@ const ROOM_READ_MAX = 30 export function install(rt) { const { ctx } = rt @@ -591,15 +601,6 @@ diff --git a/src/host/groups.js b/src/host/groups.js - if (known !== undefined) { - await rt.ensureCapabilities(known) - return { id: known, brief: undefined } -- } -- const id = await rt.createSession(`${member.name} · ${room.name}`, { dir: rt.workDirOf({ botId: member.id, roomId: room.id }) }) -- room.sessions[member.id] = id -- rt.groupOwners.set(id, { botId: member.id, roomId: room.id }) -- delete room.seen[member.id] -- const ref = await rt.desiredModel(member) -- if (ref !== undefined) { -- await rt.selectSessionModel(id, splitRef(ref)) -- .catch(error => rt.warn('ds-bot: model for %s in %s not applied: %s', member.name, room.name, error)) + // The brief is the member's owner's private chat with the Bot. In a room where + // another owner sits it stays out unless that owner shared it with this room (#53). + const briefAllowed = (room, member) => { @@ -612,6 +613,15 @@ diff --git a/src/host/groups.js b/src/host/groups.js + else if (p.remote) owners.add(p.handle) + else owners.add(rt.botOf(p.id)?.ownerId) } +- const id = await rt.createSession(`${member.name} · ${room.name}`, { dir: rt.workDirOf({ botId: member.id, roomId: room.id }) }) +- room.sessions[member.id] = id +- rt.groupOwners.set(id, { botId: member.id, roomId: room.id }) +- delete room.seen[member.id] +- const ref = await rt.desiredModel(member) +- if (ref !== undefined) { +- await rt.selectSessionModel(id, splitRef(ref)) +- .catch(error => rt.warn('ds-bot: model for %s in %s not applied: %s', member.name, room.name, error)) +- } - await rt.applyPermission(member, id) - .catch(error => rt.warn('ds-bot: permission for %s in %s not applied: %s', member.name, room.name, error)) - await rt.save() @@ -625,10 +635,39 @@ diff --git a/src/host/groups.js b/src/host/groups.js + async function groupSessionFor(room, member) { + const session = await participant.open({ room, member }) + return { id: session.id, brief: session.created && briefAllowed(room, member) ? await rt.ownChatBrief(member) : undefined } ++ } ++ ++ // A plain line in a face-to-face room (#299): the Bots decide who answers. Each gets this note, so one ++ // Bot answers a specific question, a follow-up goes to the Bot that just spoke, and a line to everyone may ++ // get a short answer from each. A pass posts nothing. ++ function routeNote(room, member, round) { ++ const { broadcast, last, forced } = round.route ++ if (forced === member.id) return 'The user asked this and nobody else answered. Answer now: if the line is plainly for another owner\'s Bot, say that in one short sentence.' ++ const everyone = room.members.map(rt.botOf).filter(Boolean) ++ const roles = everyone.filter(bot => bot.role).map(bot => `${bot.name}${bot.id === member.id ? ' (you)' : ''}: ${String(bot.role).slice(0, 80)}`) ++ const friends = currentParticipants(room).filter(p => p.kind === 'bot' && p.remote).map(p => p.label || p.displayName).filter(Boolean) ++ return [ ++ 'This line names no Bot, and the group should get the right answer, not one from everyone. Decide whether it is yours.', ++ broadcast ++ ? 'It is addressed to everyone (introductions, what each of you can do, a round of opinions): give your own short answer, but reply [PASS] if it would only repeat what a Bot before you said.' ++ : 'Answer only if it is for you: it names you or your role, or it fits what you do best and nobody before you has answered it, or nobody has answered yet and you can add something genuinely new. Reply [PASS] when a Bot before you already answered it, when it is for another Bot by name or role, or when it follows up on another Bot\'s answer.', ++ last && !broadcast ? `${last.name} spoke last before this line: a follow-up to what ${last.name} said ("what did you mean", "and then?", "say more") is for ${last.name}.` : '', ++ roles.length > 0 ? `What members do: ${roles.join('; ')}.` : '', ++ friends.length > 0 ? `Other owners' Bots here answer for themselves in their own home: ${friends.join(', ')}.` : '', ++ '[PASS] posts nothing and nobody sees it: never explain a pass.', ++ ].filter(Boolean).join(' ') } // Lines from before the round's opening message are kept apart from this round, so -@@ -175,77 +175,12 @@ export function install(rt) { +@@ -169,83 +169,19 @@ export function install(rt) { + working.length > 0 ? `Still at work on what they were asked; each posts here when done: ${working.map(item => `${item.bot.name} (${minutesSince(item.since)} min)`).join(', ')}.` : '', + `Speaking order for this round: ${round.slots.map(slotLabel).join(' → ')}.`, + `You are number ${index + 1} of ${round.slots.length}. ${before === 0 ? 'Nobody spoke before you' : `${before} went before you`}${after === 0 ? ', and you are the last.' : `; ${after} after you will see your message.`}`, +- ROOM_REPLY, ++ round.route ? routeNote(room, member, round) : '', ++ round.route?.forced === member.id ? ROOM_REPLY_FORCED : ROOM_REPLY, + ].filter(Boolean).join('\n\n') + } // Members whose turn in this group is running, and since when. const workingIn = room => room.members.flatMap((id) => { @@ -708,9 +747,12 @@ diff --git a/src/host/groups.js b/src/host/groups.js // A called member moves up if it is still waiting, or gets one more turn if it // already spoke; either way it goes right after slot `index`. `cap` is the caller's // per-member turn bound for the round; `by` is the calling member, if a member called. -@@ -292,8 +292,8 @@ export function install(rt) { +@@ -291,9 +291,11 @@ export function install(rt) { + if (lines.length === 0) return 0 const before = round.slots.length const members = room.members.map(rt.botOf).filter(Boolean) ++ // A line the user adds mid-round is answered by the rule for its own opening, so this round stops routing. ++ delete round.route for (const line of lines) { - appendRoom(room, { who: 'User', text: line.text }) - round.replies.push({ kind: 'user', text: line.text, time: Date.now() }) @@ -719,6 +761,52 @@ diff --git a/src/host/groups.js b/src/host/groups.js const { all, found } = mentionsIn(line.text, members) const note = all || found.length > 0 ? 'the user mentioned you' : 'new message from the user' callMembers(round, turns, index - 1, openingSpeakers(room, { text: line.text }), note, ROOM_ADMIN_TURNS_EACH) +@@ -305,14 +305,44 @@ export function install(rt) { + return round.slots.length - before + } + ++ // True once a routed round ended with every Bot silent on the owner's own line (a friend's line is not ++ // forced: the friend's own home answers it). A line that follows up a friend's Bot is theirs to answer. ++ function fallbackDue(round) { ++ const route = round.route ++ if (!route?.fallback || route.forced !== undefined || (route.last?.remote && !route.broadcast)) return false ++ const states = round.slots.map(slot => slot.status) ++ return !states.some(status => status === 'posted' || status === 'working' || status === 'waiting-approval' || status === 'speaking' || status === 'waiting') ++ && states.some(status => status === 'passed' || status === 'missed') ++ } ++ ++ // The Bot that answered the exchange right before this line (a Bot's post, once the person's own line or ++ // lines are set aside), for follow-ups such as "then what did you say about X?" (#299). ++ function recentSpeaker(room, startSeq) { ++ const lines = (room.log ?? []).filter(line => !line.kind) ++ let at = lines.length - 1 ++ while (at >= 0 && (lines[at].seq ?? 0) >= startSeq) at -= 1 ++ while (at >= 0 && lines[at].authorKind === 'human' && !lines[at].botId) at -= 1 ++ const line = lines[at] ++ if (!line?.botId) return undefined ++ const local = rt.botOf(line.botId) ++ return local && room.members.includes(local.id) ? { id: local.id, name: local.name, remote: false } : { id: line.botId, name: line.who, remote: true } ++ } ++ + async function runRound(room, round, signal, agent) { + let limit = round.slots.length + ROOM_EXTRA_TURNS + (room.admin ? ROOM_ADMIN_EXTRA_TURNS : 0) + const turns = new Map() + // The inbox check keeps a message sent during the last speaker's turn inside + // this round: it drains at the loop's head even once the slots ran out. +- for (let index = 0; (index < round.slots.length || waitingLines(agent).length > 0) && round.turns < limit; index += 1) { ++ for (let index = 0; (index < round.slots.length || waitingLines(agent).length > 0 || fallbackDue(round)) && round.turns < limit; index += 1) { + if (signal.aborted) break + limit += drainRoomInbox(room, round, turns, index, agent) ++ // Everyone passed on the owner's own plain line: the first Bot that passed answers, so the user is never ignored (#299). ++ if (index >= round.slots.length && fallbackDue(round)) { ++ const first = round.slots.find(slot => slot.status === 'passed' || slot.status === 'missed') ++ round.route.forced = first.id ++ round.slots.push({ id: first.id, name: first.name, status: 'waiting', note: 'nobody answered yet' }) ++ limit += 1 ++ } + if (index >= round.slots.length) break + const slot = round.slots[index] + const member = rt.botOf(slot.id) @@ -339,7 +339,9 @@ export function install(rt) { const prompt = roomPrompt(room, member, round) round.shown?.push(member.id) @@ -771,7 +859,28 @@ diff --git a/src/host/groups.js b/src/host/groups.js } if (posts.some(post => !post.botId && !post.kickoff)) { room.botRounds = 0 -@@ -429,6 +429,8 @@ export function install(rt) { +@@ -418,17 +418,28 @@ export function install(rt) { + const references = posts.flatMap(post => post.references ?? []) + // A member a Bot's post calls by name works for that Bot. + const named = opener.botId === undefined ? [] : mentionsIn(opener.text ?? '', room.members.map(rt.botOf).filter(Boolean)).found ++ // A person's plain line (not @all) in a face-to-face room: the Bots decide who answers (#299). The ++ // owner's own line is never left unanswered; a friend's attested line is answered by their home too. ++ const routed = isPlainFaceLine(room, opener) && !opener.everyone ++ const broadcast = routed && ROOM_EVERYONE.test(opener.text ?? '') ++ const route = routed ? { broadcast, fallback: opener.plain !== true, last: broadcast ? undefined : recentSpeaker(room, startSeq) } : undefined ++ const speakers = openingSpeakers(room, opener) ++ const lastAt = route?.last && !route.last.remote ? speakers.findIndex(bot => bot.id === route.last.id) : -1 ++ if (lastAt > 0) speakers.unshift(...speakers.splice(lastAt, 1)) + const round = { + roomId: room.id, + members: room.members, ++ ...(route ? { route } : {}), + startSeq, + ...(opener.kickoff ? { kickoff: true } : {}), + ...(references.length > 0 ? { references, shown: [] } : {}), +- slots: openingSpeakers(room, opener).map(member => ({ ++ slots: speakers.map(member => ({ + id: member.id, name: member.name, status: 'waiting', ...(opener.kickoff ? { note: 'the group was just created' } : {}), ...(named.includes(member) ? { by: opener.botId } : {}), })), diff --git a/patches/ds-bot/files/src/host/hooks.js b/patches/ds-bot/files/src/host/hooks.js index e1bed694a..6aebf149f 100644 --- a/patches/ds-bot/files/src/host/hooks.js +++ b/patches/ds-bot/files/src/host/hooks.js @@ -963,7 +963,7 @@ export function install(rt) { // A line that names nobody (or @all) is for the whole room (#295): this home's Bots answer it too, in one pass, // but only when the server attests the person typed it; a line that names a friend's Bot calls none here. const plain = attested === true && said !== undefined && callIds.length === 0 && (said.all || said.friends.length === 0) - return rt.postToRoom(room, { id: 'face-notice', name: who }, String(text ?? ''), { quiet: true, notice: true, human: { authorId: String(authorId || who), name: who }, ...(callIds.length > 0 ? { callIds } : {}), ...(plain ? { plain: true } : {}) }) + return rt.postToRoom(room, { id: 'face-notice', name: who }, String(text ?? ''), { quiet: true, notice: true, human: { authorId: String(authorId || who), name: who }, ...(callIds.length > 0 ? { callIds } : {}), ...(plain ? { plain: true } : {}), ...(plain && said?.all ? { everyone: true } : {}) }) }, postRemote: (roomId, { handle, name, text } = {}) => { const room = rt.roomOf(roomId)