Initial commit: Discord-Claude Gateway with event-driven agent runtime

This commit is contained in:
2026-02-22 00:31:25 -05:00
commit 77d7c74909
58 changed files with 11772 additions and 0 deletions

235
src/agent-runtime.ts Normal file
View File

@@ -0,0 +1,235 @@
import { query } from "@anthropic-ai/claude-agent-sdk";
import type { Event, MessagePayload, HeartbeatPayload, CronPayload, HookPayload } from "./event-queue.js";
import type { MarkdownConfigLoader } from "./markdown-config-loader.js";
import type { SystemPromptAssembler } from "./system-prompt-assembler.js";
import type { SessionManager } from "./session-manager.js";
import { formatErrorForUser } from "./error-formatter.js";
import type { HookManager } from "./hook-manager.js";
import type { GatewayConfig } from "./config.js";
export interface EventResult {
responseText?: string;
targetChannelId?: string;
sessionId?: string;
error?: string;
}
export class AgentRuntime {
private config: GatewayConfig;
private sessionManager: SessionManager;
private markdownConfigLoader: MarkdownConfigLoader;
private systemPromptAssembler: SystemPromptAssembler;
private hookManager: HookManager;
constructor(
config: GatewayConfig,
sessionManager: SessionManager,
markdownConfigLoader: MarkdownConfigLoader,
systemPromptAssembler: SystemPromptAssembler,
hookManager: HookManager,
) {
this.config = config;
this.sessionManager = sessionManager;
this.markdownConfigLoader = markdownConfigLoader;
this.systemPromptAssembler = systemPromptAssembler;
this.hookManager = hookManager;
}
async processEvent(event: Event): Promise<EventResult> {
// Fire agent_begin inline hook
await this.hookManager.fireInline("agent_begin", this);
try {
const result = await this.processEventCore(event);
// Fire agent_stop inline hook
await this.hookManager.fireInline("agent_stop", this);
return result;
} catch (error) {
// Fire agent_stop even on error
await this.hookManager.fireInline("agent_stop", this);
throw error;
}
}
private async processEventCore(event: Event): Promise<EventResult> {
// Read all markdown configs fresh
const configs = await this.markdownConfigLoader.loadAll(this.config.configDir);
const systemPrompt = this.systemPromptAssembler.assemble(configs);
switch (event.type) {
case "message":
return this.processMessage(event, systemPrompt);
case "heartbeat":
return this.processHeartbeat(event, systemPrompt);
case "cron":
return this.processCron(event, systemPrompt);
case "hook":
return this.processHook(event, systemPrompt);
default:
return {};
}
}
private async processMessage(event: Event, systemPrompt: string): Promise<EventResult> {
const payload = event.payload as MessagePayload;
const channelId = payload.prompt.channelId;
const promptText = payload.prompt.text;
const existingSessionId = this.sessionManager.getSessionId(channelId);
try {
const responseText = await this.executeQuery(
promptText,
systemPrompt,
channelId,
existingSessionId,
);
return {
responseText,
targetChannelId: channelId,
sessionId: this.sessionManager.getSessionId(channelId),
};
} catch (error) {
// If session is corrupted, remove the binding
if (this.isSessionCorrupted(error)) {
this.sessionManager.removeSession(channelId);
}
const errorMessage = formatErrorForUser(error);
return {
error: errorMessage,
targetChannelId: channelId,
};
}
}
private async processHeartbeat(event: Event, systemPrompt: string): Promise<EventResult> {
const payload = event.payload as HeartbeatPayload;
const targetChannelId = this.config.outputChannelId;
try {
const responseText = await this.executeQuery(
payload.instruction,
systemPrompt,
);
return {
responseText,
targetChannelId,
};
} catch (error) {
const errorMessage = formatErrorForUser(error);
return { error: errorMessage, targetChannelId };
}
}
private async processCron(event: Event, systemPrompt: string): Promise<EventResult> {
const payload = event.payload as CronPayload;
const targetChannelId = this.config.outputChannelId;
try {
const responseText = await this.executeQuery(
payload.instruction,
systemPrompt,
);
return {
responseText,
targetChannelId,
};
} catch (error) {
const errorMessage = formatErrorForUser(error);
return { error: errorMessage, targetChannelId };
}
}
private async processHook(event: Event, systemPrompt: string): Promise<EventResult> {
const payload = event.payload as HookPayload;
// If no instruction, return empty result (no query needed)
if (!payload.instruction) {
return {};
}
const targetChannelId = this.config.outputChannelId;
try {
const responseText = await this.executeQuery(
payload.instruction,
systemPrompt,
);
return {
responseText,
targetChannelId,
};
} catch (error) {
const errorMessage = formatErrorForUser(error);
return { error: errorMessage, targetChannelId };
}
}
private async executeQuery(
promptText: string,
systemPrompt: string,
channelId?: string,
existingSessionId?: string,
): Promise<string> {
const options: Record<string, unknown> = {
allowedTools: this.config.allowedTools,
permissionMode: this.config.permissionMode,
systemPrompt,
};
if (existingSessionId) {
options.resume = existingSessionId;
}
const queryPromise = this.consumeStream(
query({ prompt: promptText, options }),
channelId,
);
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error("Query timed out"));
}, this.config.queryTimeoutMs);
});
return Promise.race([queryPromise, timeoutPromise]);
}
private async consumeStream(
stream: AsyncIterable<any>,
channelId?: string,
): Promise<string> {
let resultText = "";
for await (const message of stream) {
// Store session_id from init messages
if (message.type === "system" && message.subtype === "init" && channelId) {
this.sessionManager.setSessionId(channelId, message.session_id);
}
// Collect result text
if ("result" in message && typeof message.result === "string") {
resultText += message.result;
}
}
return resultText;
}
private isSessionCorrupted(error: unknown): boolean {
if (error instanceof Error) {
const msg = error.message.toLowerCase();
return (
msg.includes("session") &&
(msg.includes("invalid") || msg.includes("corrupt") || msg.includes("not found") || msg.includes("expired"))
);
}
return false;
}
}

160
src/bootstrap-manager.ts Normal file
View File

@@ -0,0 +1,160 @@
import { readFile, writeFile, access } from "node:fs/promises";
import { join } from "node:path";
export interface BootConfig {
requiredFiles: string[];
optionalFiles: string[];
defaults: Record<string, string>;
}
export interface BootstrapResult {
loadedFiles: string[];
createdFiles: string[];
}
const DEFAULT_OPTIONAL_DEFAULTS: Record<string, string> = {
"agents.md": "# Operating Rules\n",
"user.md": "# User Context\n",
"memory.md": "# Memory\n",
"tools.md": "# Tool Configuration\n",
"heartbeat.md": "# Heartbeat\n",
};
const BUILTIN_BOOT_CONFIG: BootConfig = {
requiredFiles: ["soul.md", "identity.md"],
optionalFiles: ["agents.md", "user.md", "memory.md", "tools.md", "heartbeat.md"],
defaults: { ...DEFAULT_OPTIONAL_DEFAULTS },
};
export class BootstrapManager {
async run(configDir: string): Promise<BootstrapResult> {
const bootContent = await this.readBootFile(configDir);
const config = this.parseBootConfig(bootContent);
const loadedFiles: string[] = [];
const createdFiles: string[] = [];
// Verify required files exist
for (const filename of config.requiredFiles) {
const filePath = join(configDir, filename);
if (await this.fileExists(filePath)) {
loadedFiles.push(filename);
} else {
// Create required files with default content if available
const defaultContent = config.defaults[filename] ?? "";
await writeFile(filePath, defaultContent, "utf-8");
createdFiles.push(filename);
}
}
// Create missing optional files with default content
for (const filename of config.optionalFiles) {
const filePath = join(configDir, filename);
if (await this.fileExists(filePath)) {
loadedFiles.push(filename);
} else {
const defaultContent = config.defaults[filename] ?? "";
await writeFile(filePath, defaultContent, "utf-8");
createdFiles.push(filename);
}
}
// Log results
if (loadedFiles.length > 0) {
console.log(`Bootstrap: loaded files — ${loadedFiles.join(", ")}`);
}
if (createdFiles.length > 0) {
console.log(`Bootstrap: created files with defaults — ${createdFiles.join(", ")}`);
}
return { loadedFiles, createdFiles };
}
parseBootConfig(content: string | null): BootConfig {
if (content === null) {
return { ...BUILTIN_BOOT_CONFIG };
}
try {
// Simple YAML-like parsing for boot.md
const lines = content.split("\n");
let requiredFiles: string[] | null = null;
let optionalFiles: string[] | null = null;
let currentSection: "required" | "optional" | null = null;
for (const line of lines) {
const trimmed = line.trim();
if (/^#+\s*required/i.test(trimmed) || /^required\s*files?\s*:/i.test(trimmed)) {
currentSection = "required";
requiredFiles = requiredFiles ?? [];
// Check for inline list after colon
const afterColon = trimmed.split(":")[1]?.trim();
if (afterColon) {
requiredFiles.push(...afterColon.split(",").map(f => f.trim()).filter(Boolean));
currentSection = null;
}
continue;
}
if (/^#+\s*optional/i.test(trimmed) || /^optional\s*files?\s*:/i.test(trimmed)) {
currentSection = "optional";
optionalFiles = optionalFiles ?? [];
const afterColon = trimmed.split(":")[1]?.trim();
if (afterColon) {
optionalFiles.push(...afterColon.split(",").map(f => f.trim()).filter(Boolean));
currentSection = null;
}
continue;
}
// Parse list items under current section
if (currentSection && /^[-*]\s+/.test(trimmed)) {
const filename = trimmed.replace(/^[-*]\s+/, "").trim();
if (filename) {
if (currentSection === "required") {
requiredFiles = requiredFiles ?? [];
requiredFiles.push(filename);
} else {
optionalFiles = optionalFiles ?? [];
optionalFiles.push(filename);
}
}
} else if (currentSection && trimmed === "") {
// Empty line ends current section
currentSection = null;
}
}
// Fall back to built-in defaults if parsing didn't find anything useful
if (!requiredFiles && !optionalFiles) {
return { ...BUILTIN_BOOT_CONFIG };
}
return {
requiredFiles: requiredFiles ?? BUILTIN_BOOT_CONFIG.requiredFiles,
optionalFiles: optionalFiles ?? BUILTIN_BOOT_CONFIG.optionalFiles,
defaults: { ...DEFAULT_OPTIONAL_DEFAULTS },
};
} catch {
return { ...BUILTIN_BOOT_CONFIG };
}
}
private async readBootFile(configDir: string): Promise<string | null> {
try {
return await readFile(join(configDir, "boot.md"), "utf-8");
} catch {
return null;
}
}
private async fileExists(filePath: string): Promise<boolean> {
try {
await access(filePath);
return true;
} catch {
return false;
}
}
}

72
src/channel-queue.ts Normal file
View File

@@ -0,0 +1,72 @@
export class ChannelQueue {
private queues = new Map<string, Array<{ task: () => Promise<void>; resolve: () => void; reject: (err: unknown) => void }>>();
private active = new Map<string, boolean>();
async enqueue(channelId: string, task: () => Promise<void>): Promise<void> {
return new Promise<void>((resolve, reject) => {
if (!this.queues.has(channelId)) {
this.queues.set(channelId, []);
this.active.set(channelId, false);
}
this.queues.get(channelId)!.push({ task, resolve, reject });
this.processChannel(channelId);
});
}
async drainAll(): Promise<void> {
const channelIds = [...this.queues.keys()];
const pending: Promise<void>[] = [];
for (const channelId of channelIds) {
if (this.active.get(channelId) || this.queues.get(channelId)!.length > 0) {
pending.push(this.waitForChannel(channelId));
}
}
await Promise.all(pending);
}
private async processChannel(channelId: string): Promise<void> {
if (this.active.get(channelId)) {
return;
}
const queue = this.queues.get(channelId);
if (!queue || queue.length === 0) {
return;
}
this.active.set(channelId, true);
const { task, resolve, reject } = queue.shift()!;
try {
await task();
resolve();
} catch (err) {
reject(err);
} finally {
this.active.set(channelId, false);
this.processChannel(channelId);
}
}
private waitForChannel(channelId: string): Promise<void> {
return new Promise<void>((resolve) => {
const check = () => {
const queue = this.queues.get(channelId);
if (!this.active.get(channelId) && (!queue || queue.length === 0)) {
resolve();
} else {
// Enqueue a no-op task that resolves when it runs — meaning all prior tasks are done
queue!.push({
task: () => Promise.resolve(),
resolve: () => { resolve(); },
reject: () => { resolve(); },
});
}
};
check();
});
}
}

69
src/config.ts Normal file
View File

@@ -0,0 +1,69 @@
export interface GatewayConfig {
discordBotToken: string;
anthropicApiKey: string;
allowedTools: string[];
permissionMode: string;
queryTimeoutMs: number;
maxConcurrentQueries: number;
configDir: string;
maxQueueDepth: number;
outputChannelId?: string;
}
const DEFAULT_ALLOWED_TOOLS = ["Read", "Write", "Edit", "Glob", "Grep", "WebSearch", "WebFetch"];
const DEFAULT_PERMISSION_MODE = "bypassPermissions";
const DEFAULT_QUERY_TIMEOUT_MS = 120_000;
const DEFAULT_MAX_CONCURRENT_QUERIES = 5;
const DEFAULT_CONFIG_DIR = "./config";
const DEFAULT_MAX_QUEUE_DEPTH = 100;
export function loadConfig(): GatewayConfig {
const missing: string[] = [];
const discordBotToken = process.env.DISCORD_BOT_TOKEN;
const anthropicApiKey = process.env.ANTHROPIC_API_KEY;
if (!discordBotToken) missing.push("DISCORD_BOT_TOKEN");
if (!anthropicApiKey) missing.push("ANTHROPIC_API_KEY");
if (missing.length > 0) {
throw new Error(
`Missing required environment variables: ${missing.join(", ")}`
);
}
const allowedToolsRaw = process.env.ALLOWED_TOOLS;
const allowedTools = allowedToolsRaw
? allowedToolsRaw.split(",").map((t) => t.trim())
: DEFAULT_ALLOWED_TOOLS;
const permissionMode = process.env.PERMISSION_MODE ?? DEFAULT_PERMISSION_MODE;
const queryTimeoutMs = process.env.QUERY_TIMEOUT_MS
? parseInt(process.env.QUERY_TIMEOUT_MS, 10)
: DEFAULT_QUERY_TIMEOUT_MS;
const maxConcurrentQueries = process.env.MAX_CONCURRENT_QUERIES
? parseInt(process.env.MAX_CONCURRENT_QUERIES, 10)
: DEFAULT_MAX_CONCURRENT_QUERIES;
const configDir = process.env.CONFIG_DIR ?? DEFAULT_CONFIG_DIR;
const maxQueueDepth = process.env.MAX_QUEUE_DEPTH
? parseInt(process.env.MAX_QUEUE_DEPTH, 10)
: DEFAULT_MAX_QUEUE_DEPTH;
const outputChannelId = process.env.OUTPUT_CHANNEL_ID || undefined;
return {
discordBotToken: discordBotToken!,
anthropicApiKey: anthropicApiKey!,
allowedTools,
permissionMode,
queryTimeoutMs,
maxConcurrentQueries,
configDir,
maxQueueDepth,
outputChannelId,
};
}

112
src/cron-scheduler.ts Normal file
View File

@@ -0,0 +1,112 @@
import cron from "node-cron";
import type { Event } from "./event-queue.js";
export interface CronJob {
name: string;
expression: string;
instruction: string;
}
type EnqueueFn = (event: Omit<Event, "id" | "timestamp">) => Event | null;
export class CronScheduler {
private tasks: Map<string, cron.ScheduledTask> = new Map();
parseConfig(content: string): CronJob[] {
const jobs: CronJob[] = [];
const lines = content.split("\n");
// Find the "## Cron Jobs" section
let inCronSection = false;
let currentName: string | null = null;
let currentExpression: string | null = null;
let currentInstruction: string | null = null;
for (const line of lines) {
// Detect start of "## Cron Jobs" section
const h2Match = line.match(/^##\s+(.+)$/);
if (h2Match) {
if (inCronSection) {
// We hit another ## heading — check if it's a ### job or end of section
// A ## heading ends the Cron Jobs section
if (currentName !== null && currentExpression !== null && currentInstruction !== null) {
jobs.push({ name: currentName, expression: currentExpression, instruction: currentInstruction });
}
currentName = null;
currentExpression = null;
currentInstruction = null;
inCronSection = false;
}
if (h2Match[1].trim() === "Cron Jobs") {
inCronSection = true;
}
continue;
}
if (!inCronSection) continue;
// Detect ### job name headers within the Cron Jobs section
const h3Match = line.match(/^###\s+(.+)$/);
if (h3Match) {
// Push previous job if complete
if (currentName !== null && currentExpression !== null && currentInstruction !== null) {
jobs.push({ name: currentName, expression: currentExpression, instruction: currentInstruction });
}
currentName = h3Match[1].trim();
currentExpression = null;
currentInstruction = null;
continue;
}
const cronMatch = line.match(/^Cron:\s*(.+)$/);
if (cronMatch && currentName !== null) {
currentExpression = cronMatch[1].trim();
continue;
}
const instructionMatch = line.match(/^Instruction:\s*(.+)$/);
if (instructionMatch && currentName !== null) {
currentInstruction = instructionMatch[1].trim();
continue;
}
}
// Push the last job if complete
if (inCronSection && currentName !== null && currentExpression !== null && currentInstruction !== null) {
jobs.push({ name: currentName, expression: currentExpression, instruction: currentInstruction });
}
return jobs;
}
start(jobs: CronJob[], enqueue: EnqueueFn): void {
for (const job of jobs) {
if (!cron.validate(job.expression)) {
console.warn(
`Cron job "${job.name}" has invalid cron expression "${job.expression}". Skipping.`
);
continue;
}
const task = cron.schedule(job.expression, () => {
enqueue({
type: "cron",
payload: {
instruction: job.instruction,
jobName: job.name,
},
source: "cron-scheduler",
});
});
this.tasks.set(job.name, task);
}
}
stop(): void {
for (const task of this.tasks.values()) {
task.stop();
}
this.tasks.clear();
}
}

165
src/discord-bot.ts Normal file
View File

@@ -0,0 +1,165 @@
import {
Client,
GatewayIntentBits,
REST,
Routes,
SlashCommandBuilder,
type ChatInputCommandInteraction,
type Message,
type TextChannel,
} from "discord.js";
export interface Prompt {
text: string;
channelId: string;
userId: string;
guildId: string | null;
}
export function shouldIgnoreMessage(message: { author: { bot: boolean } }): boolean {
return message.author.bot;
}
export function extractPromptFromMention(content: string, botId: string): string {
return content.replace(new RegExp(`<@!?${botId}>`, "g"), "").trim();
}
export class DiscordBot {
private client: Client;
private promptHandler: ((prompt: Prompt) => void) | null = null;
private resetHandler: ((channelId: string) => void) | null = null;
constructor() {
this.client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});
}
async start(token: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
this.client.once("ready", () => {
const user = this.client.user;
console.log(`Bot logged in as ${user?.tag ?? "unknown"}`);
console.log(`Connected to ${this.client.guilds.cache.size} guild(s)`);
this.setupMessageHandler();
this.setupInteractionHandler();
resolve();
});
this.client.once("error", reject);
this.client.login(token).catch(reject);
});
}
async registerCommands(): Promise<void> {
const clientId = this.client.user?.id;
if (!clientId) {
throw new Error("Bot must be started before registering commands");
}
const claudeCommand = new SlashCommandBuilder()
.setName("claude")
.setDescription("Send a prompt to Claude")
.addStringOption((option) =>
option
.setName("prompt")
.setDescription("The prompt to send to Claude")
.setRequired(true)
);
const claudeResetCommand = new SlashCommandBuilder()
.setName("claude-reset")
.setDescription("Reset the conversation session in this channel");
const commands = [claudeCommand.toJSON(), claudeResetCommand.toJSON()];
const rest = new REST({ version: "10" }).setToken(this.client.token!);
await rest.put(Routes.applicationCommands(clientId), { body: commands });
console.log("Registered /claude and /claude-reset slash commands");
}
async sendMessage(channelId: string, content: string): Promise<void> {
try {
const channel = await this.client.channels.fetch(channelId);
if (channel && "send" in channel) {
await (channel as TextChannel).send(content);
}
} catch (error) {
console.error(
`Failed to send message to channel ${channelId} (content length: ${content.length}):`,
error
);
}
}
async sendTyping(channelId: string): Promise<void> {
try {
const channel = await this.client.channels.fetch(channelId);
if (channel && "sendTyping" in channel) {
await (channel as TextChannel).sendTyping();
}
} catch {
// Typing indicator failures are non-critical
}
}
async destroy(): Promise<void> {
await this.client.destroy();
}
onPrompt(handler: (prompt: Prompt) => void): void {
this.promptHandler = handler;
}
onReset(handler: (channelId: string) => void): void {
this.resetHandler = handler;
}
private setupMessageHandler(): void {
this.client.on("messageCreate", (message: Message) => {
if (shouldIgnoreMessage(message)) return;
const botUser = this.client.user;
if (!botUser) return;
if (!message.mentions.has(botUser)) return;
const text = extractPromptFromMention(message.content, botUser.id);
if (!text) return;
this.promptHandler?.({
text,
channelId: message.channelId,
userId: message.author.id,
guildId: message.guildId,
});
});
}
private setupInteractionHandler(): void {
this.client.on("interactionCreate", async (interaction) => {
if (!interaction.isChatInputCommand()) return;
const command = interaction as ChatInputCommandInteraction;
if (command.commandName === "claude") {
const promptText = command.options.getString("prompt", true);
await command.deferReply();
this.promptHandler?.({
text: promptText,
channelId: command.channelId,
userId: command.user.id,
guildId: command.guildId,
});
} else if (command.commandName === "claude-reset") {
this.resetHandler?.(command.channelId);
await command.reply({ content: "Session reset. Your next message will start a new conversation.", ephemeral: true });
}
});
}
}

41
src/error-formatter.ts Normal file
View File

@@ -0,0 +1,41 @@
/**
* Patterns that indicate sensitive data which must be stripped from user-facing errors.
*/
const API_KEY_PATTERN = /\b(sk-[a-zA-Z0-9_-]{10,}|key-[a-zA-Z0-9_-]{10,}|api[_-]?key[=:]\s*\S+)\b/gi;
const FILE_PATH_PATTERN =
/(?:\/(?:home|usr|var|tmp|etc|opt|srv|mnt|root|Users|Applications|Library|System|proc|dev|run|snap|nix)\/\S+|[A-Z]:\\[\w\\.\- ]+)/g;
const STACK_TRACE_PATTERN = /^\s*at\s+.+$/gm;
function sanitize(text: string): string {
return text
.replace(STACK_TRACE_PATTERN, "")
.replace(API_KEY_PATTERN, "[REDACTED]")
.replace(FILE_PATH_PATTERN, "[REDACTED_PATH]")
.replace(/\n{2,}/g, "\n")
.trim();
}
/**
* Formats an unknown error into a user-friendly message.
*
* - Includes the error type/name (e.g. "TypeError", "AuthenticationError").
* - Excludes stack traces, API keys, and file paths.
*/
export function formatErrorForUser(error: unknown): string {
if (error instanceof Error) {
const name = error.name || "Error";
const message = sanitize(error.message);
return message
? `${name}: ${message}`
: `An error occurred (${name})`;
}
if (typeof error === "string") {
return `Error: ${sanitize(error)}`;
}
return "An unknown error occurred";
}

116
src/event-queue.ts Normal file
View File

@@ -0,0 +1,116 @@
export type EventType = "message" | "heartbeat" | "cron" | "hook" | "webhook";
export type HookType = "startup" | "agent_begin" | "agent_stop" | "shutdown";
export interface MessagePayload {
prompt: { text: string; channelId: string; userId: string; guildId: string | null };
}
export interface HeartbeatPayload {
instruction: string;
checkName: string;
}
export interface CronPayload {
instruction: string;
jobName: string;
}
export interface HookPayload {
hookType: HookType;
instruction?: string;
}
export type EventPayload = MessagePayload | HeartbeatPayload | CronPayload | HookPayload;
export interface Event {
id: number;
type: EventType;
payload: EventPayload;
timestamp: Date;
source: string;
}
export class EventQueue {
private queue: Event[] = [];
private nextId = 1;
private maxDepth: number;
private handler: ((event: Event) => Promise<void>) | null = null;
private processing = false;
private drainResolvers: Array<() => void> = [];
constructor(maxDepth: number) {
this.maxDepth = maxDepth;
}
enqueue(event: Omit<Event, "id" | "timestamp">): Event | null {
if (this.queue.length >= this.maxDepth) {
return null;
}
const fullEvent: Event = {
...event,
id: this.nextId++,
timestamp: new Date(),
};
this.queue.push(fullEvent);
this.processNext();
return fullEvent;
}
dequeue(): Event | undefined {
return this.queue.shift();
}
size(): number {
return this.queue.length;
}
onEvent(handler: (event: Event) => Promise<void>): void {
this.handler = handler;
this.processNext();
}
drain(): Promise<void> {
if (this.queue.length === 0 && !this.processing) {
return Promise.resolve();
}
return new Promise<void>((resolve) => {
this.drainResolvers.push(resolve);
});
}
private processNext(): void {
if (this.processing || !this.handler || this.queue.length === 0) {
return;
}
this.processing = true;
const event = this.queue.shift()!;
this.handler(event)
.then(() => {
this.processing = false;
if (this.queue.length === 0) {
const resolvers = this.drainResolvers.splice(0);
for (const resolve of resolvers) {
resolve();
}
} else {
this.processNext();
}
})
.catch(() => {
this.processing = false;
if (this.queue.length === 0) {
const resolvers = this.drainResolvers.splice(0);
for (const resolve of resolvers) {
resolve();
}
} else {
this.processNext();
}
});
}
}

195
src/gateway-core.ts Normal file
View File

@@ -0,0 +1,195 @@
import { loadConfig, type GatewayConfig } from "./config.js";
import { DiscordBot, type Prompt } from "./discord-bot.js";
import { EventQueue, type Event, type MessagePayload } from "./event-queue.js";
import { AgentRuntime, type EventResult } from "./agent-runtime.js";
import { SessionManager } from "./session-manager.js";
import { MarkdownConfigLoader } from "./markdown-config-loader.js";
import { SystemPromptAssembler } from "./system-prompt-assembler.js";
import { HeartbeatScheduler } from "./heartbeat-scheduler.js";
import { CronScheduler } from "./cron-scheduler.js";
import { HookManager } from "./hook-manager.js";
import { BootstrapManager } from "./bootstrap-manager.js";
import { splitMessage } from "./response-formatter.js";
import { formatErrorForUser } from "./error-formatter.js";
export class GatewayCore {
private config!: GatewayConfig;
private discordBot!: DiscordBot;
private eventQueue!: EventQueue;
private agentRuntime!: AgentRuntime;
private sessionManager!: SessionManager;
private heartbeatScheduler!: HeartbeatScheduler;
private cronScheduler!: CronScheduler;
private hookManager!: HookManager;
private markdownConfigLoader!: MarkdownConfigLoader;
private activeQueryCount = 0;
private isShuttingDown = false;
async start(): Promise<void> {
// 1. Load config
this.config = loadConfig();
console.log("Configuration loaded");
// 2. Run bootstrap
const bootstrapManager = new BootstrapManager();
await bootstrapManager.run(this.config.configDir);
// 3. Start Discord bot
this.discordBot = new DiscordBot();
await this.discordBot.start(this.config.discordBotToken);
await this.discordBot.registerCommands();
// 4. Initialize EventQueue
this.eventQueue = new EventQueue(this.config.maxQueueDepth);
// 5. Initialize AgentRuntime with all dependencies
this.sessionManager = new SessionManager();
this.markdownConfigLoader = new MarkdownConfigLoader();
const systemPromptAssembler = new SystemPromptAssembler();
this.hookManager = new HookManager();
this.agentRuntime = new AgentRuntime(
this.config,
this.sessionManager,
this.markdownConfigLoader,
systemPromptAssembler,
this.hookManager,
);
// 6. Parse heartbeat.md → start HeartbeatScheduler
this.heartbeatScheduler = new HeartbeatScheduler();
const heartbeatContent = await this.markdownConfigLoader.loadFile(
this.config.configDir,
"heartbeat.md",
);
if (heartbeatContent) {
const checks = this.heartbeatScheduler.parseConfig(heartbeatContent);
this.heartbeatScheduler.start(checks, (event) =>
this.eventQueue.enqueue(event),
);
console.log(`HeartbeatScheduler started with ${checks.length} check(s)`);
} else {
console.log("No heartbeat.md found, operating without heartbeat events");
}
// 7. Parse agents.md → start CronScheduler, load HookConfig
this.cronScheduler = new CronScheduler();
const agentsContent = await this.markdownConfigLoader.loadFile(
this.config.configDir,
"agents.md",
);
if (agentsContent) {
const cronJobs = this.cronScheduler.parseConfig(agentsContent);
this.cronScheduler.start(cronJobs, (event) =>
this.eventQueue.enqueue(event),
);
console.log(`CronScheduler started with ${cronJobs.length} job(s)`);
this.hookManager.parseConfig(agentsContent);
console.log("HookConfig loaded from agents.md");
}
// 8. Register EventQueue processing handler
this.eventQueue.onEvent(async (event: Event) => {
try {
const result = await this.agentRuntime.processEvent(event);
if (result.responseText && result.targetChannelId) {
const chunks = splitMessage(result.responseText);
for (const chunk of chunks) {
await this.discordBot.sendMessage(result.targetChannelId, chunk);
}
}
if (result.error && result.targetChannelId) {
await this.discordBot.sendMessage(result.targetChannelId, result.error);
}
} catch (error) {
console.error("Error processing event:", error);
// Attempt to notify the channel if it's a message event
if (event.type === "message") {
const payload = event.payload as MessagePayload;
const errorMsg = formatErrorForUser(error);
await this.discordBot
.sendMessage(payload.prompt.channelId, errorMsg)
.catch(() => {});
}
} finally {
// Decrement active query count for message events
if (event.type === "message") {
this.activeQueryCount--;
}
}
});
// 9. Wire DiscordBot.onPrompt() to create message events and enqueue them
this.discordBot.onPrompt((prompt: Prompt) => {
if (this.isShuttingDown) {
this.discordBot
.sendMessage(prompt.channelId, "Gateway is shutting down. Please try again later.")
.catch(() => {});
return;
}
if (this.activeQueryCount >= this.config.maxConcurrentQueries) {
this.discordBot
.sendMessage(prompt.channelId, "System is busy. Please try again later.")
.catch(() => {});
return;
}
this.activeQueryCount++;
// Send typing indicator
this.discordBot.sendTyping(prompt.channelId).catch(() => {});
const enqueued = this.eventQueue.enqueue({
type: "message",
payload: { prompt },
source: "discord",
});
if (!enqueued) {
this.activeQueryCount--;
this.discordBot
.sendMessage(prompt.channelId, "System is busy. Please try again later.")
.catch(() => {});
}
});
// 10. Wire DiscordBot.onReset() to remove channel binding
this.discordBot.onReset((channelId: string) => {
this.sessionManager.removeSession(channelId);
});
// 11. Fire startup hook
this.hookManager.fire("startup", (event) => this.eventQueue.enqueue(event));
console.log("Gateway started successfully");
}
async shutdown(): Promise<void> {
console.log("Initiating graceful shutdown...");
// 1. Set isShuttingDown flag, stop accepting new events from Discord
this.isShuttingDown = true;
// 2. Stop HeartbeatScheduler and CronScheduler
this.heartbeatScheduler?.stop();
this.cronScheduler?.stop();
// 3. Fire shutdown hook (enqueue and wait for processing)
this.hookManager?.fire("shutdown", (event) => this.eventQueue.enqueue(event));
// 4. Drain EventQueue
await this.eventQueue?.drain();
// 5. Disconnect DiscordBot
await this.discordBot?.destroy();
console.log("Gateway shut down cleanly");
// 6. Exit with code 0
process.exit(0);
}
}

View File

@@ -0,0 +1,86 @@
import type { Event } from "./event-queue.js";
export interface HeartbeatCheck {
name: string;
instruction: string;
intervalSeconds: number;
}
type EnqueueFn = (event: Omit<Event, "id" | "timestamp">) => Event | null;
const MIN_INTERVAL_SECONDS = 60;
export class HeartbeatScheduler {
private timers: Map<string, ReturnType<typeof setInterval>> = new Map();
parseConfig(content: string): HeartbeatCheck[] {
const checks: HeartbeatCheck[] = [];
const lines = content.split("\n");
let currentName: string | null = null;
let currentInstruction: string | null = null;
let currentInterval: number | null = null;
for (const line of lines) {
const headerMatch = line.match(/^##\s+(.+)$/);
if (headerMatch) {
if (currentName !== null && currentInstruction !== null && currentInterval !== null) {
checks.push({ name: currentName, instruction: currentInstruction, intervalSeconds: currentInterval });
}
currentName = headerMatch[1].trim();
currentInstruction = null;
currentInterval = null;
continue;
}
const intervalMatch = line.match(/^Interval:\s*(\d+)\s*$/);
if (intervalMatch && currentName !== null) {
currentInterval = parseInt(intervalMatch[1], 10);
continue;
}
const instructionMatch = line.match(/^Instruction:\s*(.+)$/);
if (instructionMatch && currentName !== null) {
currentInstruction = instructionMatch[1].trim();
continue;
}
}
// Push the last check if valid
if (currentName !== null && currentInstruction !== null && currentInterval !== null) {
checks.push({ name: currentName, instruction: currentInstruction, intervalSeconds: currentInterval });
}
return checks;
}
start(checks: HeartbeatCheck[], enqueue: EnqueueFn): void {
for (const check of checks) {
if (check.intervalSeconds < MIN_INTERVAL_SECONDS) {
console.warn(
`Heartbeat check "${check.name}" has interval ${check.intervalSeconds}s which is below the minimum of ${MIN_INTERVAL_SECONDS}s. Skipping.`
);
continue;
}
const timer = setInterval(() => {
enqueue({
type: "heartbeat",
payload: {
instruction: check.instruction,
checkName: check.name,
},
source: "heartbeat-scheduler",
});
}, check.intervalSeconds * 1000);
this.timers.set(check.name, timer);
}
}
stop(): void {
for (const timer of this.timers.values()) {
clearInterval(timer);
}
this.timers.clear();
}
}

92
src/hook-manager.ts Normal file
View File

@@ -0,0 +1,92 @@
import type { Event, HookType } from "./event-queue.js";
export interface HookConfig {
startup?: string;
agent_begin?: string;
agent_stop?: string;
shutdown?: string;
}
export interface AgentRuntimeLike {
processEvent(event: Event): Promise<any>;
}
type EnqueueFn = (event: Omit<Event, "id" | "timestamp">) => Event | null;
export class HookManager {
private config: HookConfig = {};
parseConfig(content: string): HookConfig {
const config: HookConfig = {};
const lines = content.split("\n");
let inHooksSection = false;
let currentHookType: HookType | null = null;
for (const line of lines) {
const h2Match = line.match(/^##\s+(.+)$/);
if (h2Match) {
if (inHooksSection) {
// Another ## heading ends the Hooks section
break;
}
if (h2Match[1].trim() === "Hooks") {
inHooksSection = true;
}
continue;
}
if (!inHooksSection) continue;
const h3Match = line.match(/^###\s+(.+)$/);
if (h3Match) {
const name = h3Match[1].trim();
if (name === "startup" || name === "agent_begin" || name === "agent_stop" || name === "shutdown") {
currentHookType = name;
} else {
currentHookType = null;
}
continue;
}
const instructionMatch = line.match(/^Instruction:\s*(.+)$/);
if (instructionMatch && currentHookType !== null) {
config[currentHookType] = instructionMatch[1].trim();
continue;
}
}
this.config = config;
return config;
}
fire(hookType: HookType, enqueue: EnqueueFn): void {
const instruction = this.config[hookType];
enqueue({
type: "hook",
payload: {
hookType,
...(instruction !== undefined ? { instruction } : {}),
},
source: "hook-manager",
});
}
async fireInline(hookType: HookType, runtime: AgentRuntimeLike): Promise<void> {
const instruction = this.config[hookType];
const event: Event = {
id: 0,
type: "hook",
payload: {
hookType,
...(instruction !== undefined ? { instruction } : {}),
},
timestamp: new Date(),
source: "hook-manager-inline",
};
await runtime.processEvent(event);
}
}

10
src/index.ts Normal file
View File

@@ -0,0 +1,10 @@
import { GatewayCore } from "./gateway-core.js";
import { registerShutdownHandler } from "./shutdown-handler.js";
const gateway = new GatewayCore();
registerShutdownHandler(gateway);
gateway.start().catch((error) => {
console.error("Failed to start gateway:", error);
process.exit(1);
});

View File

@@ -0,0 +1,66 @@
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
export interface MarkdownConfigs {
soul: string | null;
identity: string | null;
agents: string | null;
user: string | null;
memory: string | null;
tools: string | null;
}
const CONFIG_FILES = ["soul.md", "identity.md", "agents.md", "user.md", "memory.md", "tools.md"] as const;
type ConfigKey = "soul" | "identity" | "agents" | "user" | "memory" | "tools";
const FILE_TO_KEY: Record<string, ConfigKey> = {
"soul.md": "soul",
"identity.md": "identity",
"agents.md": "agents",
"user.md": "user",
"memory.md": "memory",
"tools.md": "tools",
};
export class MarkdownConfigLoader {
async loadAll(configDir: string): Promise<MarkdownConfigs> {
const configs: MarkdownConfigs = {
soul: null,
identity: null,
agents: null,
user: null,
memory: null,
tools: null,
};
for (const filename of CONFIG_FILES) {
const key = FILE_TO_KEY[filename];
const filePath = join(configDir, filename);
try {
const content = await readFile(filePath, "utf-8");
configs[key] = content;
} catch {
if (filename === "memory.md") {
const defaultContent = "# Memory\n";
await writeFile(filePath, defaultContent, "utf-8");
configs[key] = defaultContent;
} else {
console.warn(`Warning: ${filename} not found in ${configDir}`);
configs[key] = null;
}
}
}
return configs;
}
async loadFile(configDir: string, filename: string): Promise<string | null> {
try {
return await readFile(join(configDir, filename), "utf-8");
} catch {
return null;
}
}
}

125
src/response-formatter.ts Normal file
View File

@@ -0,0 +1,125 @@
/**
* ResponseFormatter — splits long response text into Discord-safe chunks.
*
* Splits text into chunks of at most `maxLength` characters (default 2000).
* Prefers splitting at line boundaries. Tracks open code blocks: if a split
* occurs inside a code block, the chunk is closed with ``` and the next chunk
* reopens with ``` (preserving the language tag).
*/
const DEFAULT_MAX_LENGTH = 2000;
const CODE_FENCE = "```";
const CLOSING_SUFFIX = "\n" + CODE_FENCE;
/**
* Split `text` into an array of chunks, each at most `maxLength` characters.
*
* - Prefers splitting at newline boundaries.
* - If a code block spans a split boundary, the current chunk is closed with
* ``` and the next chunk reopens with ``` (including the original language tag).
* - Empty text returns an empty array.
*/
export function splitMessage(
text: string,
maxLength: number = DEFAULT_MAX_LENGTH,
): string[] {
if (text.length === 0) {
return [];
}
if (text.length <= maxLength) {
return [text];
}
const chunks: string[] = [];
let remaining = text;
let openFenceTag: string | null = null;
while (remaining.length > 0) {
// If we're continuing inside a code block, prepend the fence opener.
const prefix = openFenceTag !== null ? openFenceTag + "\n" : "";
// If the remaining text (with prefix) fits, emit the final chunk.
if (prefix.length + remaining.length <= maxLength) {
chunks.push(prefix + remaining);
break;
}
// Always reserve space for a potential closing fence suffix when splitting.
// This guarantees the chunk stays within maxLength even if we need to close
// a code block that was opened (or continued) in this chunk.
const budget = maxLength - prefix.length - CLOSING_SUFFIX.length;
if (budget <= 0) {
// Degenerate case: maxLength too small for overhead. Force progress.
const take = Math.max(1, maxLength - prefix.length);
chunks.push(prefix + remaining.slice(0, take));
remaining = remaining.slice(take);
continue;
}
// Find a good split point within the budget.
const splitIndex = findSplitPoint(remaining, budget);
const chunk = remaining.slice(0, splitIndex);
remaining = remaining.slice(splitIndex);
// Determine code-block state at the end of this chunk.
const fenceState = computeFenceState(chunk, openFenceTag);
if (fenceState.insideCodeBlock) {
chunks.push(prefix + chunk + CLOSING_SUFFIX);
openFenceTag = fenceState.fenceTag;
} else {
chunks.push(prefix + chunk);
openFenceTag = null;
}
}
return chunks;
}
/**
* Find the best index to split `text` at, within `budget` characters.
* Prefers the last newline boundary. Falls back to `budget` if no newline found.
*/
function findSplitPoint(text: string, budget: number): number {
const region = text.slice(0, budget);
const lastNewline = region.lastIndexOf("\n");
if (lastNewline > 0) {
return lastNewline + 1;
}
return budget;
}
/**
* Scan `chunk` for code fence toggles and determine whether we end inside
* an open code block.
*/
function computeFenceState(
chunk: string,
initialFenceTag: string | null,
): { insideCodeBlock: boolean; fenceTag: string | null } {
let inside = initialFenceTag !== null;
let fenceTag = initialFenceTag;
const fenceRegex = /^(`{3,})(.*)?$/gm;
let match: RegExpExecArray | null;
while ((match = fenceRegex.exec(chunk)) !== null) {
const backticks = match[1];
const langTag = (match[2] ?? "").trim();
if (!inside) {
inside = true;
fenceTag = langTag ? backticks + langTag : backticks;
} else {
inside = false;
fenceTag = null;
}
}
return { insideCodeBlock: inside, fenceTag: fenceTag };
}

19
src/session-manager.ts Normal file
View File

@@ -0,0 +1,19 @@
export class SessionManager {
private bindings = new Map<string, string>();
getSessionId(channelId: string): string | undefined {
return this.bindings.get(channelId);
}
setSessionId(channelId: string, sessionId: string): void {
this.bindings.set(channelId, sessionId);
}
removeSession(channelId: string): void {
this.bindings.delete(channelId);
}
clear(): void {
this.bindings.clear();
}
}

17
src/shutdown-handler.ts Normal file
View File

@@ -0,0 +1,17 @@
import type { GatewayCore } from "./gateway-core.js";
export function registerShutdownHandler(gateway: GatewayCore): void {
let shuttingDown = false;
const handler = (signal: string) => {
if (shuttingDown) {
return;
}
shuttingDown = true;
console.log(`Received ${signal}, shutting down...`);
gateway.shutdown();
};
process.on("SIGTERM", () => handler("SIGTERM"));
process.on("SIGINT", () => handler("SIGINT"));
}

View File

@@ -0,0 +1,33 @@
import type { MarkdownConfigs } from "./markdown-config-loader.js";
const PREAMBLE =
"You may update your long-term memory by writing to memory.md using the Write tool. Use this to persist important facts, lessons learned, and context across sessions.";
interface SectionDef {
key: keyof MarkdownConfigs;
header: string;
}
const SECTIONS: SectionDef[] = [
{ key: "identity", header: "## Identity" },
{ key: "soul", header: "## Personality" },
{ key: "agents", header: "## Operating Rules" },
{ key: "user", header: "## User Context" },
{ key: "memory", header: "## Long-Term Memory" },
{ key: "tools", header: "## Tool Configuration" },
];
export class SystemPromptAssembler {
assemble(configs: MarkdownConfigs): string {
const parts: string[] = [PREAMBLE, ""];
for (const { key, header } of SECTIONS) {
const content = configs[key];
if (content != null && content !== "") {
parts.push(`${header}\n\n${content}\n`);
}
}
return parts.join("\n");
}
}