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
12 changes: 9 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion src/askClaude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
const recentHistory = loadRecentHistory(channelName, question);
const recentHistory = loadRecentHistory(channelId, question, channelName);
const userProfile = getUserProfile(authorId);
const serverMemory = getServerMemory(guildId);

Expand Down
4 changes: 4 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 });
9 changes: 7 additions & 2 deletions src/discord/commands/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,14 @@ import {

export async function handleStorage(msg: Message): Promise<void> {
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;
}
Expand Down
32 changes: 28 additions & 4 deletions src/discord/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
Expand Down Expand Up @@ -376,6 +382,7 @@ export function registerHandler() {
userLabel,
user.id,
msg.channel.name,
msg.channel.id,
msg.guild.name,
msg.guild.id,
imagePaths,
Expand All @@ -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) {
Expand Down Expand Up @@ -577,6 +594,7 @@ async function processMessage(msg: Message): Promise<void> {
authorLabel(msg.author),
msg.author.id,
msg.channel.name,
msg.channel.id,
msg.guild?.name || "DM",
msg.guild?.id || "unknown",
imagePaths,
Expand All @@ -600,6 +618,7 @@ async function processMessage(msg: Message): Promise<void> {
appendToLog(
botName + " (bot)",
parsedResponse.historyContent,
msg.channel.id,
msg.channel.name,
);
}
Expand Down Expand Up @@ -629,7 +648,12 @@ async function processMessage(msg: Message): Promise<void> {

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}`;
Expand Down
48 changes: 39 additions & 9 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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`),
);
}

Expand All @@ -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);
Expand All @@ -316,7 +346,7 @@ export function createMcpServer(): Server {
);
}

return { file, lines };
return { file: file.displayName, lines };
})
.filter(({ lines }) => !searchLower || lines.length > 0);

Expand Down
31 changes: 19 additions & 12 deletions src/storage/history.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
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,
HISTORY_SEARCH_CONTEXT_LINES,
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",
Expand Down Expand Up @@ -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`;
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand Down
Loading