import { readFile, writeFile, readdir, mkdir, unlink } from "node:fs/promises"; import { join } from "node:path"; import { parseFrontmatter, type AgentSessionEvent, } from "@earendil-works/pi-coding-agent"; export const DISCORD_LIMIT = 1900; export function chunkForDiscord(text: string, limit = DISCORD_LIMIT): string[] { if (!text) return []; if (text.length <= limit) return [text]; const out: string[] = []; let i = 0; while (i < text.length) { let end = Math.min(i + limit, text.length); if (end < text.length) { const nl = text.lastIndexOf("\n", end); if (nl > i + limit / 2) end = nl; } out.push(text.slice(i, end)); i = end; } return out; } function assistantMessageSeparator(buffer: string): string { if (!buffer.trim()) return ""; if (buffer.endsWith("\n\n")) return ""; if (buffer.endsWith("\n")) return "\n"; return "\n\n"; } export function createAssistantTextCollector() { let buffer = ""; let assistantMessages = 0; return { handleEvent(event: AgentSessionEvent) { if (event.type === "message_start" && event.message.role === "assistant") { if (assistantMessages > 0) { buffer += assistantMessageSeparator(buffer); } assistantMessages += 1; return; } if ( event.type === "message_update" && event.assistantMessageEvent.type === "text_delta" ) { buffer += event.assistantMessageEvent.delta; } }, getOutput() { return buffer; }, }; } export interface ScheduleFrontmatter { name: string; cron: string; destination: string; /** Discord user ID for DM fallback (set when destination is a DM channel). */ user?: string; created: string; last_run: string | null; } export interface ScheduleFile extends ScheduleFrontmatter { prompt: string; filePath: string; } export function isValidScheduleName(name: string): boolean { return /^[a-z0-9][a-z0-9-]{0,62}$/.test(name); } export async function readSchedule(filePath: string): Promise { const raw = await readFile(filePath, "utf8"); const { frontmatter, body } = parseFrontmatter>(raw); return { name: String(frontmatter.name ?? ""), cron: String(frontmatter.cron ?? ""), destination: String(frontmatter.destination ?? ""), user: frontmatter.user == null ? undefined : String(frontmatter.user), created: String(frontmatter.created ?? ""), last_run: frontmatter.last_run == null ? null : String(frontmatter.last_run), prompt: body.trim(), filePath, }; } export async function listScheduleFiles(schedulesDir: string): Promise { await mkdir(schedulesDir, { recursive: true }); const files = await readdir(schedulesDir); const out: ScheduleFile[] = []; for (const f of files) { if (!f.endsWith(".md")) continue; try { out.push(await readSchedule(join(schedulesDir, f))); } catch (err) { console.error(`scheduler: skipping malformed ${f}:`, err); } } return out; } function formatScheduleFile(meta: ScheduleFrontmatter, prompt: string): string { // Quote snowflake IDs so the YAML parser keeps them as strings. // Discord IDs are 64-bit and can exceed Number.MAX_SAFE_INTEGER. const userLine = meta.user ? `user: "${meta.user}"\n` : ""; const lr = meta.last_run ? `"${meta.last_run}"` : "null"; return `--- name: ${meta.name} cron: "${meta.cron}" destination: "${meta.destination}" ${userLine}created: ${meta.created} last_run: ${lr} --- ${prompt} `; } export async function writeScheduleFile( schedulesDir: string, meta: ScheduleFrontmatter, prompt: string ): Promise { await mkdir(schedulesDir, { recursive: true }); const filePath = join(schedulesDir, `${meta.name}.md`); await writeFile(filePath, formatScheduleFile(meta, prompt), "utf8"); return filePath; } export async function deleteScheduleFile( schedulesDir: string, name: string ): Promise { try { await unlink(join(schedulesDir, `${name}.md`)); return true; } catch { return false; } } export async function updateLastRun( filePath: string, when: Date = new Date() ): Promise { let file: ScheduleFile; try { file = await readSchedule(filePath); } catch (err: any) { if (err?.code === "ENOENT") { // Schedule file was deleted (e.g. via cancel_schedule). Nothing to update. return; } throw err; } await writeFile( filePath, formatScheduleFile( { name: file.name, cron: file.cron, destination: file.destination, user: file.user, created: file.created, last_run: when.toISOString(), }, file.prompt ), "utf8" ); }