Initial commit: Discord-Claude Gateway with event-driven agent runtime
This commit is contained in:
235
src/agent-runtime.ts
Normal file
235
src/agent-runtime.ts
Normal 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
160
src/bootstrap-manager.ts
Normal 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
72
src/channel-queue.ts
Normal 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
69
src/config.ts
Normal 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
112
src/cron-scheduler.ts
Normal 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
165
src/discord-bot.ts
Normal 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
41
src/error-formatter.ts
Normal 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
116
src/event-queue.ts
Normal 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
195
src/gateway-core.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
86
src/heartbeat-scheduler.ts
Normal file
86
src/heartbeat-scheduler.ts
Normal 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
92
src/hook-manager.ts
Normal 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
10
src/index.ts
Normal 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);
|
||||
});
|
||||
66
src/markdown-config-loader.ts
Normal file
66
src/markdown-config-loader.ts
Normal 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
125
src/response-formatter.ts
Normal 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
19
src/session-manager.ts
Normal 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
17
src/shutdown-handler.ts
Normal 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"));
|
||||
}
|
||||
33
src/system-prompt-assembler.ts
Normal file
33
src/system-prompt-assembler.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user