fix(desktop): byte-bound the Bot Mode group-chat turn window

A member's turn prompt rendered only the last 24 room messages since its
last turn, so a busy room routinely lost the head of an exchange (#114341
made the cut visible; this makes it rare). The window is now bounded by
size instead of a small count: up to 200 entries within a 32 KB character
budget, oldest dropped first, the omission marker naming the exact count.
One oversized body is cut to 8 KB with the existing '… [truncated]' mark
rather than evicting the messages around it. The local room log retains
twice the window so a member that skipped a whole window still gets an
exact count instead of a clamped watermark.

Docs: one sentence in the Bot Mode guide. Tests: the existing window
tests now cover the byte bound (exact omitted count, newest kept), a
150-entry delta that fits, and the single-paste truncation.
This commit is contained in:
teknium1
2026-09-18 20:45:06 -07:00
committed by Teknium
parent 8d08293cf4
commit a767747a68
4 changed files with 108 additions and 32 deletions

View File

@@ -95,7 +95,8 @@ const groupChatSyncRetryTimers = new Map<string, ReturnType<typeof setTimeout>>(
const groupChatSyncRetryCounts = new Map<string, number>()
export let groupChatSyncDisposed = false
/** Cut one sync-projection line to the per-message budget and mark the cut.
/** Cut one room body to a per-message budget and mark the cut (the sync
* projection and the per-turn delta window share the convention).
* Receivers used to see a silent mid-sentence slice with no signal that the
* body continued. Keep the mark inside the same char budget so CJK/envelope
* accounting does not grow. */
@@ -1273,7 +1274,22 @@ export const GROUP_CHAT_MAX_ROUNDS = 3
// #94478 review: continuation rounds are bounded independently of the message cap so a pathological mention chain can't consume the room's whole budget on handoffs.
export const GROUP_CHAT_MAX_MESSAGES = 10
export const GROUP_CHAT_MAX_CONTINUATIONS = 2
export const GROUP_CHAT_HISTORY_LIMIT = 24
// Per-turn room window (#114341 follow-up): a member sees every message since
// its last turn, up to BOTH ceilings — oldest dropped first, the cut named
// exactly. Room lines are short by construction (the rules ask for 1-3
// sentences; user lines average ~100-300 chars), so ~200 entries and ~32 KB
// (~8k tokens) bite at about the same place for ordinary traffic; the char
// budget is what keeps a prompt bounded when the lines are long. One body
// is cut to LINE_CHARS (mark: '… [truncated]') rather than evicting whole
// messages, so a single giant paste costs a quarter of the window, not all
// of it, while a multi-paragraph member result still lands intact.
export const GROUP_CHAT_HISTORY_LIMIT = 200
export const GROUP_CHAT_HISTORY_CHARS = 32_000
export const GROUP_CHAT_HISTORY_LINE_CHARS = 8_000
// Room log retained locally: twice the turn window so a member that skipped
// a whole window still receives an exact omitted count, not a clamped
// watermark and a silently shortened room.
export const GROUP_CHAT_LOG_RETAIN = GROUP_CHAT_HISTORY_LIMIT * 2
export const GROUP_CHAT_MAX_MEMBERS = 6
/** Transcript form of a room speaker's identity. Friendly identity wins:
@@ -1367,7 +1383,7 @@ export function groupSpeakerLabel(name?: null | string, group?: null | string) {
export function trimGroupChatLog(
log: GroupMessage[],
watermarks: Record<string, number>,
limit = GROUP_CHAT_HISTORY_LIMIT * 4
limit = GROUP_CHAT_LOG_RETAIN
) {
if (log.length <= limit) {
return {

View File

@@ -1,5 +1,11 @@
import { botMentionTag } from './data'
import { GROUP_CHAT_HISTORY_LIMIT, groupSpeakerLabel } from './group-chat'
import {
compactGroupChatSyncText,
GROUP_CHAT_HISTORY_CHARS,
GROUP_CHAT_HISTORY_LIMIT,
GROUP_CHAT_HISTORY_LINE_CHARS,
groupSpeakerLabel
} from './group-chat'
import { groupMemberKey } from './group-membership'
import type { GroupMember, GroupMessage, GroupMessageAuthor } from './types'
@@ -51,15 +57,36 @@ export function formatGroupChatLine(entry: GroupMessage, viewer: GroupChatLineVi
return `${groupSpeakerLabel(entry.from.name, group)}${suffix}${source}: ${relabelMemberControlFrames(entry.text)}${attached}`
}
/** #114341: a member's turn renders only the last GROUP_CHAT_HISTORY_LIMIT
* delta lines while the watermark commit advances past the whole tail, so
* the head of an over-long delta is never delivered on any later turn
* either. Mark the cut — without it a member has no way to know its view
* of the room is partial (typically missing the very user instruction
* that started the exchange). */
/** #114341: a member's turn renders the newest delta lines that fit the
* window — GROUP_CHAT_HISTORY_LIMIT entries AND GROUP_CHAT_HISTORY_CHARS
* characters, each body first cut to GROUP_CHAT_HISTORY_LINE_CHARS — while
* the watermark commit advances past the whole tail, so the head of an
* over-long delta is never delivered on any later turn either. Mark the
* cut with the exact count — without it a member has no way to know its
* view of the room is partial (typically missing the very user instruction
* that started the exchange). The newest entry is always kept. */
export function formatGroupDeltaLines(delta: GroupMessage[], viewer: GroupChatLineViewer, group?: null | string) {
const omitted = delta.length - GROUP_CHAT_HISTORY_LIMIT
const lines = delta.slice(-GROUP_CHAT_HISTORY_LIMIT).map(entry => formatGroupChatLine(entry, viewer, group))
const lines: string[] = []
let chars = 0
for (let i = delta.length - 1; i >= 0 && lines.length < GROUP_CHAT_HISTORY_LIMIT; i--) {
const entry = delta[i]
const line = formatGroupChatLine(
{ ...entry, text: compactGroupChatSyncText(entry.text, GROUP_CHAT_HISTORY_LINE_CHARS).text },
viewer,
group
)
if (lines.length && chars + line.length > GROUP_CHAT_HISTORY_CHARS) {
break
}
lines.push(line)
chars += line.length + 1
}
lines.reverse()
const omitted = delta.length - lines.length
if (omitted > 0) {
lines.unshift(`… ${omitted} earlier room message${omitted === 1 ? '' : 's'} omitted since your last turn`)

View File

@@ -492,7 +492,9 @@ describe('per-member delta', () => {
const thread = room.rounds.sendToGroupChat('Trim', members, 'delivered')!
await drain(() => room.gateway.calls.length < 1)
for (let i = 0; i < 100; i++) {
const retained = room.chat.GROUP_CHAT_LOG_RETAIN
for (let i = 0; i < retained; i++) {
room.chat.appendGroupChatEntry('Trim', { kind: 'user', name: 'You' }, `unseen-${i}`, thread)
}
@@ -500,54 +502,83 @@ describe('per-member delta', () => {
await settle(room, 'Trim')
expect(room.chat.$groupChats.get().Trim.watermarks[`${thread}::research`]).toBe(0)
await room.rounds.runGroupChatRounds('Trim', members, thread)
expect(room.gateway.calls.at(-1)?.prompt).toContain('unseen-99')
expect(room.gateway.calls.at(-1)?.prompt).toContain(`unseen-${retained - 1}`)
})
// #114341: the turn renders only the last GROUP_CHAT_HISTORY_LIMIT entries
// of the delta while the watermark advances past the whole tail, so the
// #114341 follow-up: the turn renders the newest delta lines that fit the
// window (GROUP_CHAT_HISTORY_LIMIT entries / GROUP_CHAT_HISTORY_CHARS
// characters) while the watermark advances past the whole tail, so the
// head is never delivered later either. The cut must be visible to the
// member (naming how many entries it did not see); a delta that fits
// carries no marker.
it('names the omitted head of an over-long delta in the turn prompt', async () => {
// member and name EXACTLY how many entries it did not see; a delta that
// fits carries no marker.
it('names the exact omitted head of an over-budget delta and keeps the newest', async () => {
const room = await loadRoom({ turn: () => '(pass)' })
const members = [MEMBERS[0]]
const thread = room.rounds.sendToGroupChat('Head', members, 'seen-0')!
await settle(room, 'Head')
const limit = room.chat.GROUP_CHAT_HISTORY_LIMIT
const total = 40
const body = 'x'.repeat(1000)
for (let i = 1; i <= limit + 5; i++) {
room.chat.appendGroupChatEntry('Head', { kind: 'user', name: 'You' }, `unseen-${i}`, thread)
for (let i = 1; i <= total; i++) {
room.chat.appendGroupChatEntry('Head', { kind: 'user', name: 'You' }, `unseen-${i} ${body}`, thread)
}
const seen = room.chat.$groupChats.get().Head.watermarks[`${thread}::research`] || 0
const omitted = log(room, 'Head').slice(seen).length - limit
expect(omitted).toBeGreaterThan(0)
expect(total * body.length).toBeGreaterThan(room.chat.GROUP_CHAT_HISTORY_CHARS)
await room.rounds.runGroupChatRounds('Head', members, thread)
const prompt = room.gateway.calls.at(-1)?.prompt || ''
const rendered = prompt.match(/unseen-\d+ /g) || []
const omitted = Number(prompt.match(/… (\d+) earlier room messages omitted since your last turn/)?.[1])
expect(prompt).toMatch(new RegExp(`${omitted} earlier room messages omitted`))
expect(prompt).toContain(`unseen-${limit + 5}`)
expect(prompt).not.toContain(`unseen-${omitted}\n`)
expect(omitted).toBeGreaterThan(0)
expect(omitted + rendered.length).toBe(total)
expect(prompt).toContain(`unseen-${total} `)
expect(prompt).not.toContain('unseen-1 ')
expect(prompt).not.toContain('[truncated]')
})
it('adds no omission marker when the delta fits the window', async () => {
it('does not cut a 150-entry delta that fits the window', async () => {
const room = await loadRoom({ turn: () => '(pass)' })
const members = [MEMBERS[0]]
const thread = room.rounds.sendToGroupChat('Fits', members, 'seen-0')!
await settle(room, 'Fits')
for (let i = 1; i < room.chat.GROUP_CHAT_HISTORY_LIMIT; i++) {
room.chat.appendGroupChatEntry('Fits', { kind: 'user', name: 'You' }, `unseen-${i}`, thread)
for (let i = 1; i <= 150; i++) {
room.chat.appendGroupChatEntry('Fits', { kind: 'user', name: 'You' }, `unseen-${i} short room line`, thread)
}
await room.rounds.runGroupChatRounds('Fits', members, thread)
const prompt = room.gateway.calls.at(-1)?.prompt || ''
expect(prompt).toContain('unseen-1')
expect(prompt).toContain('unseen-1 short')
expect(prompt).toContain('unseen-150 short')
expect(prompt).not.toMatch(/omitted/)
})
// One giant paste is cut to the per-line budget instead of evicting the
// ordinary messages around it.
it('truncates one oversized body rather than dropping its neighbours', async () => {
const room = await loadRoom({ turn: () => '(pass)' })
const members = [MEMBERS[0]]
const thread = room.rounds.sendToGroupChat('Paste', members, 'seen-0')!
await settle(room, 'Paste')
room.chat.appendGroupChatEntry('Paste', { kind: 'user', name: 'You' }, 'before the paste', thread)
room.chat.appendGroupChatEntry('Paste', { kind: 'user', name: 'You' }, `PASTE-${'y'.repeat(60_000)}-END`, thread)
room.chat.appendGroupChatEntry('Paste', { kind: 'user', name: 'You' }, 'after the paste', thread)
await room.rounds.runGroupChatRounds('Paste', members, thread)
const prompt = room.gateway.calls.at(-1)?.prompt || ''
expect(prompt).toContain('before the paste')
expect(prompt).toContain('after the paste')
expect(prompt).toContain('PASTE-yyy')
expect(prompt).toContain('… [truncated]')
expect(prompt).not.toContain('-END')
expect(prompt).not.toMatch(/omitted/)
expect(prompt.length).toBeLessThan(room.chat.GROUP_CHAT_HISTORY_LINE_CHARS + 2000)
})
it('feeds a second send only the NEW messages', async () => {
const room = await loadRoom()
const member: GroupMember[] = [{ name: 'research', title: '' }]

View File

@@ -119,6 +119,8 @@ Right-click a local Bot → **Manage groups** to add or remove it from any numbe
To edit an existing room as a unit, use **Manage members** — from the room header's people icon or the **Manage members…** button in **Group settings**. The checklist pre-selects the current members and lists every local and Connections Bot; add or remove several at once and **Save members** applies the new roster (still 2–6 Bots) while the room, its history and each member's room session stay in place. A removed Bot takes no further turns; an added Bot reads the recent room history on its first turn. **Cancel** changes nothing.
Each member's turn carries every room message since its own last turn — up to about 200 messages or 32 KB of transcript, whichever fills first — with a single oversized paste cut to a marked excerpt rather than crowding out the messages around it; when the window still overflows, the prompt names exactly how many earlier messages were left out.
**Rooms follow your gateways, not one Desktop.** Each room's recent transcript, members, picture, and name are mirrored into the shared profile metadata of **every** gateway your Desktop is connected to, with per-gateway versioning so two Desktops writing at once merge instead of overwriting each other. Open Hermes Desktop on another machine against the same gateway (local network, Tailscale, anywhere) and the room appears with its history; gateway-only clients see it too. Rooms carry a durable internal identity, so renaming one changes just its display name everywhere, disbanding one removes it permanently on every client — even ones that were offline at the time — and recreating a same-name group starts a genuinely fresh room. If a gateway dies or is removed, nothing is lost: every connected Desktop keeps the full room locally and re-seeds any gateway it reconnects to. (The full orchestration log stays in each Desktop's local storage; the shared mirror is a bounded recent-history projection.)
A group's identity is editable, at creation and after: