From 71c32d24d8782f9fb215af73e8e323bf653d71a7 Mon Sep 17 00:00:00 2001 From: vycdev2 <125471328+vycdev2@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:15:47 +0000 Subject: [PATCH] fix: isolate channel history by ID --- AGENTS.md | 12 ++- CHANGELOG.md | 1 + src/askClaude.ts | 3 +- src/config.ts | 4 + src/discord/commands/storage.ts | 9 +- src/discord/handler.ts | 32 +++++- src/mcp/server.ts | 48 +++++++-- src/storage/history.ts | 31 +++--- src/storage/historyPaths.ts | 67 ++++++++++++ src/storage/summaries.ts | 57 +++++++---- tests/historyIsolation.test.mjs | 176 ++++++++++++++++++++++++++++++++ tests/mcpHistory.test.mjs | 15 +++ 12 files changed, 406 insertions(+), 49 deletions(-) create mode 100644 src/storage/historyPaths.ts create mode 100644 tests/historyIsolation.test.mjs diff --git a/AGENTS.md b/AGENTS.md index 4450987..03f6d8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,12 +119,18 @@ HTTP POST /mcp → Parse JSON-RPC → Route to tool handler → Execute → JSON ## Storage layout -All data is stored as flat text files under `MESSAGES_DIR`: +All data is stored as text files under `MESSAGES_DIR`. ID-keyed channel data +uses dedicated `v2/` namespaces so legacy flat filenames cannot be mistaken +for current channel history: ``` messages/ -├── history/ → Daily logs: {channel}_{YYYY-MM-DD}.txt -├── summaries/ → Daily summaries: {channel}_{YYYY-MM-DD}.txt +├── history/ +│ ├── v2/ → Daily logs: v2_{channelId}__{channel}_{YYYY-MM-DD}.txt +│ └── *.txt → Legacy name-keyed logs (explicit browsing only) +├── summaries/ +│ ├── v2/ → Daily summaries: v2_{channelId}__{channel}_{YYYY-MM-DD}.txt +│ └── *.txt → Legacy name-keyed summaries ├── profiles/ → User profiles: {userId}.txt, server memory: server_{guildId}.txt ├── pending/ → In-flight messages (temp files) └── images/ → Downloaded attachments diff --git a/CHANGELOG.md b/CHANGELOG.md index 20fc90a..b0b590e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed +- Isolate saved history and summaries by Discord channel ID in dedicated storage namespaces so same-named channels do not share automatic context. - Reject fractional MCP limits instead of passing them to file slicing or the Discord API. - Return a client error for malformed MCP request URLs instead of stopping the server. - Force-stop timed-out Claude CLI processes before releasing their concurrency slots. diff --git a/src/askClaude.ts b/src/askClaude.ts index 6eadc0e..6e6c7ca 100644 --- a/src/askClaude.ts +++ b/src/askClaude.ts @@ -22,12 +22,13 @@ export async function askClaude( author: string, authorId: string, channelName: string, + channelId: string, serverName: string, guildId: string, imagePaths: string[] = [], liveMessages: string = "", ): Promise { - const recentHistory = loadRecentHistory(channelName, question); + const recentHistory = loadRecentHistory(channelId, question, channelName); const userProfile = getUserProfile(authorId); const serverMemory = getServerMemory(guildId); diff --git a/src/config.ts b/src/config.ts index e072c97..b2691e5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -63,9 +63,11 @@ export const AUTH_ADMIN_USER_IDS = new Set( .filter(Boolean), ); export const HISTORY_DIR = path.join(MESSAGES_DIR, "history"); +export const HISTORY_V2_DIR = path.join(HISTORY_DIR, "v2"); export const PENDING_DIR = path.join(MESSAGES_DIR, "pending"); export const PROFILES_DIR = path.join(MESSAGES_DIR, "profiles"); export const SUMMARIES_DIR = path.join(MESSAGES_DIR, "summaries"); +export const SUMMARIES_V2_DIR = path.join(SUMMARIES_DIR, "v2"); export const IMAGES_DIR = path.join(MESSAGES_DIR, "images"); export const PROFILE_MAX_CHARS = 2000; @@ -102,7 +104,9 @@ export const PROMPTS_PATH = // Ensure directories exist fs.mkdirSync(HISTORY_DIR, { recursive: true }); +fs.mkdirSync(HISTORY_V2_DIR, { recursive: true }); fs.mkdirSync(PENDING_DIR, { recursive: true }); fs.mkdirSync(PROFILES_DIR, { recursive: true }); fs.mkdirSync(SUMMARIES_DIR, { recursive: true }); +fs.mkdirSync(SUMMARIES_V2_DIR, { recursive: true }); fs.mkdirSync(IMAGES_DIR, { recursive: true }); diff --git a/src/discord/commands/storage.ts b/src/discord/commands/storage.ts index ba21f1f..ae4cdd3 100644 --- a/src/discord/commands/storage.ts +++ b/src/discord/commands/storage.ts @@ -12,9 +12,14 @@ import { export async function handleStorage(msg: Message): Promise { console.error(`[Bot] Storage requested by ${msg.author.tag}`); - const countFiles = (dir: string) => { + const countFiles = (dir: string): number => { try { - return fs.readdirSync(dir).filter((f) => f.endsWith(".txt")).length; + return fs.readdirSync(dir).reduce((total, file) => { + const filePath = path.join(dir, file); + const stat = fs.statSync(filePath); + if (stat.isDirectory()) return total + countFiles(filePath); + return total + (file.endsWith(".txt") ? 1 : 0); + }, 0); } catch { return 0; } diff --git a/src/discord/handler.ts b/src/discord/handler.ts index 8652c5b..2299491 100644 --- a/src/discord/handler.ts +++ b/src/discord/handler.ts @@ -110,7 +110,13 @@ async function buildLiveMessagesContext( function logIncomingMessage(msg: Message): void { const content = messageContentForMemory(msg); if (!content) return; - appendToLog(authorLabel(msg.author), content, msg.channel instanceof TextChannel ? msg.channel.name : "unknown", msg.createdAt); + appendToLog( + authorLabel(msg.author), + content, + msg.channel.id, + msg.channel instanceof TextChannel ? msg.channel.name : "unknown", + msg.createdAt, + ); } async function enforceRequiredRole(msg: Message): Promise { @@ -376,6 +382,7 @@ export function registerHandler() { userLabel, user.id, msg.channel.name, + msg.channel.id, msg.guild.name, msg.guild.id, imagePaths, @@ -400,8 +407,18 @@ export function registerHandler() { } } - appendToLog(userLabel, `[🤖 reaction on: ${msg.content?.slice(0, 100)}]`, msg.channel.name); - appendToLog(botName + " (bot)", parsedResponse.historyContent, msg.channel.name); + appendToLog( + userLabel, + `[🤖 reaction on: ${msg.content?.slice(0, 100)}]`, + msg.channel.id, + msg.channel.name, + ); + appendToLog( + botName + " (bot)", + parsedResponse.historyContent, + msg.channel.id, + msg.channel.name, + ); console.error(`[Bot] Reaction-triggered response sent successfully`); } catch (error: any) { @@ -577,6 +594,7 @@ async function processMessage(msg: Message): Promise { authorLabel(msg.author), msg.author.id, msg.channel.name, + msg.channel.id, msg.guild?.name || "DM", msg.guild?.id || "unknown", imagePaths, @@ -600,6 +618,7 @@ async function processMessage(msg: Message): Promise { appendToLog( botName + " (bot)", parsedResponse.historyContent, + msg.channel.id, msg.channel.name, ); } @@ -629,7 +648,12 @@ async function processMessage(msg: Message): Promise { console.error(`[Bot] Response sent successfully`); - appendToLog(botName + " (bot)", parsedResponse.historyContent, msg.channel.name); + appendToLog( + botName + " (bot)", + parsedResponse.historyContent, + msg.channel.id, + msg.channel.name, + ); // Background jobs const conversationContext = liveMessages || `${authorLabel(msg.author)}: ${rawQuestion}\n${botName} (bot): ${response}`; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index d81c76e..2b4d341 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -10,12 +10,14 @@ import path from "path"; import { DISCORD_MESSAGE_MAX_CHARS, HISTORY_DIR, + HISTORY_V2_DIR, PENDING_DIR, } from "../config.js"; import { client } from "../discord/client.js"; import { findChannel } from "../discord/helpers.js"; import { downloadAttachment } from "../storage/images.js"; import { compareHistoryFilenames } from "./historyFiles.js"; +import { parseChannelHistoryFileName } from "../storage/historyPaths.js"; const ReactToMessageSchema = z.object({ server: z @@ -159,7 +161,7 @@ export function createMcpServer(): Server { channel: { type: "string", description: - "Optional channel name to narrow history files", + "Optional channel name or ID to narrow history files", }, date: { type: "string", @@ -283,18 +285,46 @@ export function createMcpServer(): Server { let files = fs .readdirSync(dir) .filter((f) => f.endsWith(".txt")) - .sort( - type === "history" - ? compareHistoryFilenames - : undefined, + .map((file) => ({ + displayName: file, + filePath: path.join(dir, file), + channelId: undefined as string | undefined, + channelName: undefined as string | undefined, + })); + + if (type === "history") { + const channelFiles = fs + .readdirSync(HISTORY_V2_DIR) + .filter((file) => file.endsWith(".txt")) + .map((file) => { + const parsed = parseChannelHistoryFileName(file); + return { + displayName: `v2/${file}`, + filePath: path.join(HISTORY_V2_DIR, file), + channelId: parsed?.channelId, + channelName: parsed?.channelName, + }; + }); + files.push(...channelFiles); + files.sort((left, right) => + compareHistoryFilenames( + left.displayName, + right.displayName, + ), ); + } if (safeChannel) { - files = files.filter((f) => f.startsWith(`${safeChannel}_`)); + files = files.filter((file) => + file.channelId !== undefined + ? file.channelId === safeChannel || + file.channelName === safeChannel + : file.displayName.startsWith(`${safeChannel}_`), + ); } if (date) { files = files.filter((f) => - f.endsWith(`_${date}.txt`), + f.displayName.endsWith(`_${date}.txt`), ); } @@ -305,7 +335,7 @@ export function createMcpServer(): Server { let matchingFiles = candidateFiles .map((file) => { let lines = fs - .readFileSync(path.join(dir, file), "utf-8") + .readFileSync(file.filePath, "utf-8") .split("\n") .map((line) => line.trim()) .filter(Boolean); @@ -316,7 +346,7 @@ export function createMcpServer(): Server { ); } - return { file, lines }; + return { file: file.displayName, lines }; }) .filter(({ lines }) => !searchLower || lines.length > 0); diff --git a/src/storage/history.ts b/src/storage/history.ts index 11a950b..5514c24 100644 --- a/src/storage/history.ts +++ b/src/storage/history.ts @@ -1,7 +1,6 @@ import fs from "fs"; -import path from "path"; import { - HISTORY_DIR, + HISTORY_V2_DIR, HISTORY_RECENT_LINES, HISTORY_RECAP_MAX_CHARS, HISTORY_RECAP_MAX_LINES, @@ -9,6 +8,7 @@ import { HISTORY_SEARCH_MAX_BLOCKS, } from "../config.js"; import { getSummaryPath, loadRecentSummaries } from "./summaries.js"; +import { getChannelHistoryPath } from "./historyPaths.js"; const HISTORY_STOP_WORDS = new Set([ "about", @@ -62,19 +62,22 @@ const HISTORY_STOP_WORDS = new Set([ "you", ]); -export function getDailyLogPath(channelName: string, date: Date = new Date()): string { - const dateStr = date.toISOString().split("T")[0]; - const safeName = channelName.replace(/[^a-zA-Z0-9-_]/g, "_"); - return path.join(HISTORY_DIR, `${safeName}_${dateStr}.txt`); +export function getDailyLogPath( + channelId: string, + date: Date = new Date(), + channelName: string = "channel", +): string { + return getChannelHistoryPath(HISTORY_V2_DIR, channelId, channelName, date); } export function appendToLog( author: string, content: string, + channelId: string, channelName: string, timestamp: Date = new Date(), ) { - const filePath = getDailyLogPath(channelName, timestamp); + const filePath = getDailyLogPath(channelId, timestamp, channelName); const time = timestamp.toTimeString().split(" ")[0]; const normalized = content.replace(/\s+/g, " ").trim() || "[no text]"; const line = `[${time}] ${author}: ${normalized}\n`; @@ -161,19 +164,23 @@ function buildRelevantSnippets(lines: string[], terms: string[]): string[] { return snippets; } -export function loadRecentHistory(channelName: string, question: string = ""): string { +export function loadRecentHistory( + channelId: string, + question: string = "", + channelName: string = "channel", +): string { const parts: string[] = []; const deepHistory = isDeepHistoryRequest(question); const searchTerms = extractSearchTerms(question); - const olderSummaries = loadRecentSummaries(channelName, 7); + const olderSummaries = loadRecentSummaries(channelId, 7, channelName); if (olderSummaries) { parts.push(`--- Past week summaries ---\n${olderSummaries}`); } const yesterday = new Date(Date.now() - 86400000); - const yesterdaySummary = getSummaryPath(channelName, yesterday); - const yesterdayLog = getDailyLogPath(channelName, yesterday); + const yesterdaySummary = getSummaryPath(channelId, yesterday, channelName); + const yesterdayLog = getDailyLogPath(channelId, yesterday, channelName); if (!fs.existsSync(yesterdaySummary) && fs.existsSync(yesterdayLog)) { const lines = readLogLines(yesterdayLog); if (lines.length > 0) { @@ -188,7 +195,7 @@ export function loadRecentHistory(channelName: string, question: string = ""): s } } - const todayPath = getDailyLogPath(channelName); + const todayPath = getDailyLogPath(channelId, new Date(), channelName); if (fs.existsSync(todayPath)) { const lines = readLogLines(todayPath); const relevantSnippets = buildRelevantSnippets(lines, searchTerms); diff --git a/src/storage/historyPaths.ts b/src/storage/historyPaths.ts new file mode 100644 index 0000000..b34d7c3 --- /dev/null +++ b/src/storage/historyPaths.ts @@ -0,0 +1,67 @@ +import fs from "fs"; +import path from "path"; + +const CHANNEL_HISTORY_PREFIX = "v2_"; +const DATE_PATTERN = "\\d{4}-\\d{2}-\\d{2}"; + +export interface ChannelHistoryFile { + channelId: string; + channelName: string; + date: string; +} + +function sanitizeSegment(value: string, fallback: string): string { + const sanitized = value.replace(/[^a-zA-Z0-9-_]/g, "_"); + return sanitized || fallback; +} + +export function getChannelHistoryFileName( + channelId: string, + channelName: string, + date: Date, +): string { + const dateStr = date.toISOString().split("T")[0]; + const safeChannelId = sanitizeSegment(channelId, "unknown"); + const safeChannelName = sanitizeSegment(channelName, "channel"); + return `${CHANNEL_HISTORY_PREFIX}${safeChannelId}__${safeChannelName}_${dateStr}.txt`; +} + +export function getChannelHistoryPath( + directory: string, + channelId: string, + channelName: string, + date: Date, +): string { + const expectedPath = path.join( + directory, + getChannelHistoryFileName(channelId, channelName, date), + ); + if (fs.existsSync(expectedPath)) return expectedPath; + + const safeChannelId = sanitizeSegment(channelId, "unknown"); + const dateStr = date.toISOString().split("T")[0]; + try { + const existingFile = fs.readdirSync(directory).find((fileName) => { + const parsed = parseChannelHistoryFileName(fileName); + return parsed?.channelId === safeChannelId && parsed.date === dateStr; + }); + return existingFile ? path.join(directory, existingFile) : expectedPath; + } catch { + return expectedPath; + } +} + +export function parseChannelHistoryFileName( + fileName: string, +): ChannelHistoryFile | null { + const match = fileName.match( + new RegExp(`^${CHANNEL_HISTORY_PREFIX}([^_]+)__(.+)_(${DATE_PATTERN})\\.txt$`), + ); + if (!match) return null; + + return { + channelId: match[1], + channelName: match[2], + date: match[3], + }; +} diff --git a/src/storage/summaries.ts b/src/storage/summaries.ts index 38e2864..93d9a2f 100644 --- a/src/storage/summaries.ts +++ b/src/storage/summaries.ts @@ -1,22 +1,36 @@ import fs from "fs"; -import path from "path"; -import { HISTORY_DIR, SUMMARIES_DIR, BOT_EFFORT, BOT_MODEL } from "../config.js"; +import { + HISTORY_V2_DIR, + SUMMARIES_V2_DIR, + BOT_EFFORT, + BOT_MODEL, +} from "../config.js"; import { runClaude } from "../claude.js"; import { renderPrompt } from "../prompts.js"; +import { + getChannelHistoryPath, + parseChannelHistoryFileName, +} from "./historyPaths.js"; const summariesInProgress = new Set(); -export function getSummaryPath(channelName: string, date: Date): string { - const dateStr = date.toISOString().split("T")[0]; - const safeName = channelName.replace(/[^a-zA-Z0-9-_]/g, "_"); - return path.join(SUMMARIES_DIR, `${safeName}_${dateStr}.txt`); +export function getSummaryPath( + channelId: string, + date: Date, + channelName: string = "channel", +): string { + return getChannelHistoryPath(SUMMARIES_V2_DIR, channelId, channelName, date); } -export function loadRecentSummaries(channelName: string, days: number = 7): string { +export function loadRecentSummaries( + channelId: string, + days: number = 7, + channelName: string = "channel", +): string { const summaries: string[] = []; for (let i = 1; i <= days; i++) { const date = new Date(Date.now() - i * 86400000); - const summaryPath = getSummaryPath(channelName, date); + const summaryPath = getSummaryPath(channelId, date, channelName); if (fs.existsSync(summaryPath)) { const dateStr = date.toISOString().split("T")[0]; summaries.push( @@ -27,18 +41,17 @@ export function loadRecentSummaries(channelName: string, days: number = 7): stri return summaries.reverse().join("\n\n"); } -function getLogPath(channelName: string, date: Date): string { - const dateStr = date.toISOString().split("T")[0]; - const safeName = channelName.replace(/[^a-zA-Z0-9-_]/g, "_"); - return path.join(HISTORY_DIR, `${safeName}_${dateStr}.txt`); +function getLogPath(channelId: string, channelName: string, date: Date): string { + return getChannelHistoryPath(HISTORY_V2_DIR, channelId, channelName, date); } export async function generateDailySummary( + channelId: string, channelName: string, date: Date, ): Promise { - const logPath = getLogPath(channelName, date); - const summaryPath = getSummaryPath(channelName, date); + const logPath = getLogPath(channelId, channelName, date); + const summaryPath = getSummaryPath(channelId, date, channelName); if ( !fs.existsSync(logPath) || @@ -90,13 +103,21 @@ export async function ensureYesterdaySummaries(): Promise { const yesterday = new Date(Date.now() - 86400000); try { const files = fs - .readdirSync(HISTORY_DIR) + .readdirSync(HISTORY_V2_DIR) .filter((f) => f.endsWith(".txt")); const dateStr = yesterday.toISOString().split("T")[0]; - const yesterdayFiles = files.filter((f) => f.includes(dateStr)); + const yesterdayFiles = files.filter((file) => { + const parsed = parseChannelHistoryFileName(file); + return parsed?.date === dateStr; + }); for (const file of yesterdayFiles) { - const channelName = file.replace(`_${dateStr}.txt`, ""); - await generateDailySummary(channelName, yesterday); + const parsed = parseChannelHistoryFileName(file); + if (!parsed) continue; + await generateDailySummary( + parsed.channelId, + parsed.channelName, + yesterday, + ); } } catch (err: any) { console.error( diff --git a/tests/historyIsolation.test.mjs b/tests/historyIsolation.test.mjs new file mode 100644 index 0000000..d64693e --- /dev/null +++ b/tests/historyIsolation.test.mjs @@ -0,0 +1,176 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const messagesDir = fs.mkdtempSync( + path.join(os.tmpdir(), "claudify-channel-history-"), +); +process.env.MESSAGES_DIR = messagesDir; + +const { + appendToLog, + getDailyLogPath, + loadRecentHistory, +} = await import("../build/storage/history.js"); +const { + ensureYesterdaySummaries, + getSummaryPath, + loadRecentSummaries, +} = await import("../build/storage/summaries.js"); +const { + getChannelHistoryFileName, + parseChannelHistoryFileName, +} = await import("../build/storage/historyPaths.js"); + +test.after(() => fs.rmSync(messagesDir, { recursive: true, force: true })); + +test("channel history filenames preserve IDs with complex display names", () => { + const fileName = getChannelHistoryFileName( + "111111111111111111", + "release__2026-08-01", + new Date("2026-08-02T00:00:00.000Z"), + ); + assert.deepEqual(parseChannelHistoryFileName(fileName), { + channelId: "111111111111111111", + channelName: "release__2026-08-01", + date: "2026-08-02", + }); +}); + +test("same-named channels use isolated history and summaries", async () => { + const channelName = "general"; + const firstChannelId = "111111111111111111"; + const secondChannelId = "222222222222222222"; + const yesterday = new Date(Date.now() - 86_400_000); + + appendToLog( + "first-user", + "first channel secret", + firstChannelId, + channelName, + yesterday, + ); + appendToLog( + "second-user", + "second channel secret", + secondChannelId, + channelName, + yesterday, + ); + + const firstLog = getDailyLogPath(firstChannelId, yesterday, channelName); + const secondLog = getDailyLogPath(secondChannelId, yesterday, channelName); + assert.notEqual(firstLog, secondLog); + assert.match(fs.readFileSync(firstLog, "utf8"), /first channel secret/); + assert.doesNotMatch(fs.readFileSync(firstLog, "utf8"), /second channel secret/); + assert.match(fs.readFileSync(secondLog, "utf8"), /second channel secret/); + + assert.match( + loadRecentHistory(firstChannelId, "ordinary question", channelName), + /first channel secret/, + ); + assert.doesNotMatch( + loadRecentHistory(firstChannelId, "ordinary question", channelName), + /second channel secret/, + ); + + await ensureYesterdaySummaries(); + const firstSummary = getSummaryPath(firstChannelId, yesterday, channelName); + const secondSummary = getSummaryPath(secondChannelId, yesterday, channelName); + assert.notEqual(firstSummary, secondSummary); + assert.match(fs.readFileSync(firstSummary, "utf8"), /first channel secret/); + assert.match(fs.readFileSync(secondSummary, "utf8"), /second channel secret/); + + assert.equal( + getDailyLogPath(firstChannelId, yesterday, "renamed-general"), + firstLog, + ); + assert.match( + loadRecentHistory( + firstChannelId, + "ordinary question", + "renamed-general", + ), + /first channel secret/, + ); + assert.equal( + getSummaryPath(firstChannelId, yesterday, "renamed-general"), + firstSummary, + ); +}); + +test("automatic history loading does not fall back to legacy name-only files", () => { + const date = new Date().toISOString().split("T")[0]; + const legacyPath = path.join(messagesDir, "history", `general_${date}.txt`); + fs.writeFileSync( + legacyPath, + "[10:00:00] legacy-user: unattributed legacy secret\n", + "utf8", + ); + + assert.doesNotMatch( + loadRecentHistory( + "333333333333333333", + "ordinary question", + "general", + ), + /unattributed legacy secret/, + ); +}); + +test("legacy flat filenames cannot collide with namespaced channel data", () => { + const channelId = "333333333333333333"; + const channelName = "general"; + const today = new Date(); + const todayString = today.toISOString().split("T")[0]; + const collidingLegacyLog = path.join( + messagesDir, + "history", + `v2_${channelId}__${channelName}_${todayString}.txt`, + ); + fs.writeFileSync( + collidingLegacyLog, + "[10:00:00] legacy-user: colliding legacy history secret\n", + "utf8", + ); + + appendToLog( + "current-user", + "current channel message", + channelId, + channelName, + today, + ); + const currentLog = getDailyLogPath(channelId, today, channelName); + + assert.notEqual(currentLog, collidingLegacyLog); + assert.equal(path.basename(path.dirname(currentLog)), "v2"); + assert.match(fs.readFileSync(currentLog, "utf8"), /current channel message/); + assert.doesNotMatch( + loadRecentHistory(channelId, "ordinary question", channelName), + /colliding legacy history secret/, + ); + + const yesterday = new Date(Date.now() - 86_400_000); + const yesterdayString = yesterday.toISOString().split("T")[0]; + const collidingLegacySummary = path.join( + messagesDir, + "summaries", + `v2_${channelId}__${channelName}_${yesterdayString}.txt`, + ); + fs.writeFileSync( + collidingLegacySummary, + "colliding legacy summary secret", + "utf8", + ); + + const currentSummary = getSummaryPath(channelId, yesterday, channelName); + assert.notEqual(currentSummary, collidingLegacySummary); + assert.equal(path.basename(path.dirname(currentSummary)), "v2"); + assert.doesNotMatch( + loadRecentSummaries(channelId, 1, channelName), + /colliding legacy summary secret/, + ); +}); diff --git a/tests/mcpHistory.test.mjs b/tests/mcpHistory.test.mjs index c890627..8c01509 100644 --- a/tests/mcpHistory.test.mjs +++ b/tests/mcpHistory.test.mjs @@ -29,6 +29,16 @@ test("history date filters match only the log date suffix", async (t) => { "[11:00:00] user: wrong-day entry\n", "utf8", ); + const historyV2Dir = path.join(historyDir, "v2"); + fs.mkdirSync(historyV2Dir, { recursive: true }); + fs.writeFileSync( + path.join( + historyV2Dir, + "v2_111111111111111111__general_2026-08-01.txt", + ), + "[12:00:00] user: namespaced entry\n", + "utf8", + ); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -51,6 +61,11 @@ test("history date filters match only the log date suffix", async (t) => { assert.equal(typeof text, "string"); assert.match(text, /general_2026-08-01\.txt/); assert.match(text, /expected entry/); + assert.match( + text, + /v2\/v2_111111111111111111__general_2026-08-01\.txt/, + ); + assert.match(text, /namespaced entry/); assert.doesNotMatch(text, /release_2026-08-01_2026-08-02\.txt/); assert.doesNotMatch(text, /wrong-day entry/); });