diff --git a/CHANGELOG.md b/CHANGELOG.md index fba9d14..7220962 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ ### Fixed +- Keep batched memory context isolated for same-named Discord channels. - Bound persisted profile and server-memory files when loading them into Claude context without splitting surrogate pairs. - Trim surrounding whitespace from configured Discord role IDs. - Preserve astral Unicode characters when `!profile` and `!guild` responses are split into Discord messages. diff --git a/src/discord/handler.ts b/src/discord/handler.ts index 4b2ec94..479a6d3 100644 --- a/src/discord/handler.ts +++ b/src/discord/handler.ts @@ -764,6 +764,7 @@ async function processMessage(msg: Message): Promise { : `channel:${msg.channel.id}`, guildId: msg.guild?.id, guildName: msg.guild?.name, + channelId: msg.channel.id, channelName: msg.channel.name, users: participantUsers, conversationContext, diff --git a/src/storage/memoryBatcher.ts b/src/storage/memoryBatcher.ts index 74ed34b..ecfacd9 100644 --- a/src/storage/memoryBatcher.ts +++ b/src/storage/memoryBatcher.ts @@ -12,6 +12,7 @@ export interface MemoryUpdateBatchRequest { scopeId: string; guildId?: string; guildName?: string; + channelId: string; channelName: string; users: Array<{ tag: string; id: string }>; conversationContext: string; @@ -21,7 +22,7 @@ interface PendingMemoryBatch { firstQueuedAt: number; guildId?: string; guildName?: string; - channelContexts: Map; + channelContexts: Map; users: Map; timer?: NodeJS.Timeout; } @@ -76,15 +77,19 @@ export class MemoryUpdateBatcher { batch.guildName = request.guildName; for (const user of request.users) batch.users.set(user.id, user); - // A newer live slice from the same channel subsumes the older one. Move - // it to the end so global trimming retains the freshest channel data. - batch.channelContexts.delete(request.channelName); + // A newer live slice from the same channel subsumes the older one. Use + // the channel ID because display names are not unique within a guild. + // Move it to the end so global trimming retains the freshest data. + batch.channelContexts.delete(request.channelId); batch.channelContexts.set( - request.channelName, - trimStartWithoutSplittingSurrogatePair( - request.conversationContext.trim(), - this.maxContextChars, - ), + request.channelId, + { + name: request.channelName, + context: trimStartWithoutSplittingSurrogatePair( + request.conversationContext.trim(), + this.maxContextChars, + ), + }, ); if (batch.timer) clearTimeout(batch.timer); @@ -114,8 +119,8 @@ export class MemoryUpdateBatcher { if (batch.timer) clearTimeout(batch.timer); const combinedContext = trimStartWithoutSplittingSurrogatePair( - Array.from(batch.channelContexts, ([channelName, context]) => - `=== #${channelName} ===\n${context}`, + Array.from(batch.channelContexts.values(), ({ name, context }) => + `=== #${name} ===\n${context}`, ).join("\n\n"), this.maxContextChars, ); @@ -126,7 +131,10 @@ export class MemoryUpdateBatcher { updates.push(this.profileUpdater(users, combinedContext)); } if (batch.guildId && batch.guildName && combinedContext) { - const channelNames = Array.from(batch.channelContexts.keys()); + const channelNames = Array.from( + batch.channelContexts.values(), + ({ name }) => name, + ); const channelLabel = channelNames.length === 1 ? channelNames[0] : `multiple channels: ${channelNames.join(", ")}`; diff --git a/tests/memoryBatcher.test.mjs b/tests/memoryBatcher.test.mjs index 1997fd3..693da05 100644 --- a/tests/memoryBatcher.test.mjs +++ b/tests/memoryBatcher.test.mjs @@ -34,6 +34,7 @@ test("memory updates debounce and replace overlapping channel snapshots", async scopeId: "guild:guild-1", guildId: "guild-1", guildName: "Guild", + channelId: "channel-1", channelName: "general", users: [{ id: "user-1", tag: "First name" }], conversationContext: "old overlapping snapshot", @@ -42,6 +43,7 @@ test("memory updates debounce and replace overlapping channel snapshots", async scopeId: "guild:guild-1", guildId: "guild-1", guildName: "Guild", + channelId: "channel-1", channelName: "general", users: [ { id: "user-1", tag: "Current name" }, @@ -69,6 +71,47 @@ test("memory updates debounce and replace overlapping channel snapshots", async }]); }); +test("memory batches keep same-named channels isolated by channel ID", async () => { + const profileCalls = []; + const serverCalls = []; + const batcher = new MemoryUpdateBatcher( + 60_000, + 600_000, + 20_000, + async (users, context) => { + profileCalls.push({ users, context }); + }, + async (...args) => { + serverCalls.push(args); + }, + ); + + batcher.enqueue({ + scopeId: "guild:guild-1", + guildId: "guild-1", + guildName: "Guild", + channelId: "channel-1", + channelName: "general", + users: [{ id: "user-1", tag: "First user" }], + conversationContext: "context from the first general channel", + }); + batcher.enqueue({ + scopeId: "guild:guild-1", + guildId: "guild-1", + guildName: "Guild", + channelId: "channel-2", + channelName: "general", + users: [{ id: "user-2", tag: "Second user" }], + conversationContext: "context from the second general channel", + }); + await batcher.flush("guild:guild-1"); + + assert.equal(profileCalls.length, 1); + assert.match(profileCalls[0].context, /context from the first general channel/); + assert.match(profileCalls[0].context, /context from the second general channel/); + assert.equal(serverCalls.length, 1); +}); + test("memory batches bound context and skip server memory for DMs", async () => { const profileCalls = []; const serverCalls = []; @@ -86,6 +129,7 @@ test("memory batches bound context and skip server memory for DMs", async () => batcher.enqueue({ scopeId: "channel:dm-1", + channelId: "dm-1", channelName: "dm", users: [{ id: "user-1", tag: "User" }], conversationContext: `discard this prefix 😀 keep this suffix`,