Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/discord/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -764,6 +764,7 @@ async function processMessage(msg: Message): Promise<void> {
: `channel:${msg.channel.id}`,
guildId: msg.guild?.id,
guildName: msg.guild?.name,
channelId: msg.channel.id,
channelName: msg.channel.name,
users: participantUsers,
conversationContext,
Expand Down
32 changes: 20 additions & 12 deletions src/storage/memoryBatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -21,7 +22,7 @@ interface PendingMemoryBatch {
firstQueuedAt: number;
guildId?: string;
guildName?: string;
channelContexts: Map<string, string>;
channelContexts: Map<string, { name: string; context: string }>;
users: Map<string, { tag: string; id: string }>;
timer?: NodeJS.Timeout;
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
);
Expand All @@ -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(", ")}`;
Expand Down
44 changes: 44 additions & 0 deletions tests/memoryBatcher.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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" },
Expand Down Expand Up @@ -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 = [];
Expand All @@ -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`,
Expand Down