diff --git a/main.ts b/main.ts index b778a2e..a135c46 100644 --- a/main.ts +++ b/main.ts @@ -17,14 +17,31 @@ const run = async (cmd: string[]) => { console.log(`👋 Process ${commandText}(PID ${proc.pid}) exited with code ${status}`) } - return status + return proc } +// Make sure we cleanup processes on exit +const prepareCleanup = () => { + const cleanup = async (signal: NodeJS.Signals | "exit") => { + process.on(signal, () => { + console.log(`🛑 Received ${signal}, cleaning up...`) + procs.forEach((proc) => proc.kill("SIGTERM")) + }) + } + + cleanup("SIGINT") + cleanup("SIGTERM") + cleanup("SIGHUP") + cleanup("exit") +} + +let procs: Bun.Subprocess[] = [] try { + prepareCleanup() const isDev = process.env.NODE_ENV !== "production" const noElide = isDev ? "--elide-lines=0" : "" - await Promise.all([ + procs = await Promise.all([ run(["bun", "run", noElide, "--filter=@workshop/http", "start"]), run(["bun", "run", noElide, "--filter=@workshop/spike", "bot:discord"]), ]) diff --git a/package.json b/package.json index aa86bbb..b2d7b94 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ ], "catalog": { "hono": "^4.8.0", - "zod": "3.25.67" + "zod": "3.25.67", + "luxon": "^3.7.1" } }, "prettier": { diff --git a/packages/http/package.json b/packages/http/package.json index 9385a30..212a0cb 100644 --- a/packages/http/package.json +++ b/packages/http/package.json @@ -4,8 +4,13 @@ "type": "module", "private": true, "scripts": { - "dev": "bun run --hot src/server", - "start": "bun run src/server" + "dev": "bun run --hot src/main.ts", + "start": "bun run src/main.ts", + "cron:constantly": "echo '💰 constantly'", + "cron:hourly@0:25": "echo '⌛ hourly@:030'", + "cron:daily@21:20": "echo '🏬 daily at midnight'", + "cron:monthly@13:00": "echo '🏬 daily at noon'", + "cron:daily@21:22 #fail": "echo '🍖 constantly with failure' && ls /nonexistant" }, "prettier": { "printWidth": 110, @@ -16,7 +21,9 @@ "@workshop/shared": "workspace:*", "@workshop/todo": "workspace:*", "hono": "catalog:", - "tailwind": "^4.0.0" + "tailwind": "^4.0.0", + "luxon": "catalog:", + "zod": "catalog:" }, "devDependencies": { "@types/bun": "latest" diff --git a/packages/http/src/auth.ts b/packages/http/src/auth.ts new file mode 100644 index 0000000..3b4ac4c --- /dev/null +++ b/packages/http/src/auth.ts @@ -0,0 +1,44 @@ +export const requireAuth = (handler: (req: Request) => Response | Promise) => { + return async (req: Request) => { + if (process.env.NODE_ENV !== "production") { + return handler(req) + } + + const cookies = new Bun.CookieMap(req.headers.get("cookie") ?? "") + if (cookies.get("auth") === "authenticated") { + return handler(req) + } + + if (validAuth(req)) { + const response = await handler(req) + const newHeaders = new Headers(response.headers) + + const url = new URL(req.url) + const cookieDomain = `.${url.hostname.split(".").slice(-2).join(".")}` + + const authCookie = new Bun.Cookie({ + name: "auth", + value: "authenticated", + domain: cookieDomain, + path: "/", + maxAge: 86400, + httpOnly: true, + }) + + newHeaders.set("Set-Cookie", authCookie.toString()) + + return new Response(response.body, { status: response.status, headers: newHeaders }) + } + + return new Response("Unauthorized", { + status: 401, + headers: { "WWW-Authenticate": 'Basic realm="Workshop Access"' }, + }) + } +} + +const validAuth = (req: Request): boolean => { + const logins = ["spike:888", "defunkt:888", "probablycorey:888"] + const auth = req.headers.get("authorization") + return logins.some((login) => `Basic ${btoa(login)}` === auth) +} diff --git a/packages/http/src/cron.test.ts b/packages/http/src/cron.test.ts new file mode 100644 index 0000000..36e5d37 --- /dev/null +++ b/packages/http/src/cron.test.ts @@ -0,0 +1,59 @@ +import { type PackageInfo } from "@workshop/shared/packages" +import { DateTime } from "luxon" +import { test, expect } from "bun:test" +import { CronJob, type CronSchedule } from "./cron" + +const createCron = (cronString: string, lastRunAt?: DateTime) => { + const cronJob = new CronJob(cronString, "", {json: {name: "test", version: "1.0.0"}, path: "."}, lastRunAt) + return cronJob +} + +test("valid CronJobs", () => { + expect(createCron("hourly").schedule).toEqual({ unit: "hourly", hour: 0, minute: 0 }) + expect(createCron("hourly@").schedule).toEqual({ unit: "hourly", hour: 0, minute: 0 }) + expect(createCron("hourly@0:30").schedule).toEqual({ unit: "hourly", hour: 0, minute: 30 }) + expect(createCron("hourly@10:59").schedule).toEqual({ unit: "hourly", hour: 10, minute: 59 }) +}) + +test("invalid CronJobs", () => { + expect(() => createCron("")).toThrow() + expect(() => createCron("hourl")).toThrow() + expect(() => createCron("hourly@*")).toThrow() + expect(() => createCron("hourly@45:59")).toThrow() + expect(() => createCron("hourly@0:99")).toThrow() +}) + +test("isDue", () => { + const lastRunAt = DateTime.fromRFC2822("1 Jan 2025 00:00:00 GMT") + + let cronJob = createCron("constantly", lastRunAt) + expect(cronJob.isDue(lastRunAt.plus({ seconds: 5 }))).toBe(false) + expect(cronJob.isDue(lastRunAt.plus({ seconds: 60 }))).toBe(true) + + cronJob = createCron("hourly@0:15", lastRunAt) + expect(cronJob.isDue(lastRunAt.plus({ minutes: 14 }))).toBe(false) + + cronJob = createCron("hourly@0:30", lastRunAt.set({ minute: 45 }).minus({ hour: 1 })) + expect(cronJob.isDue(lastRunAt.set({ minute: 15 }))).toBe(false) + expect(cronJob.isDue(lastRunAt.set({ minute: 50 }))).toBe(true) + + const dailyLastRunAt = lastRunAt.set({ hour: 1, minute: 30 }).minus({ days: 1 }) + cronJob = createCron("daily@1:20", dailyLastRunAt) + expect(cronJob.isDue(dailyLastRunAt.plus({ hours: 20 }))).toBe(false) + expect(cronJob.isDue(dailyLastRunAt.plus({ hours: 25 }))).toBe(true) + + const weeklyLastRunAt = lastRunAt.set({ hour: 5, minute: 59 }).minus({ week: 1 }) + cronJob = createCron("weekly@5:59", weeklyLastRunAt) + expect(cronJob.isDue(weeklyLastRunAt.plus({ days: 1 }))).toBe(false) + expect(cronJob.isDue(weeklyLastRunAt.plus({ days: 7 }))).toBe(true) + + const monthLastRunAt = lastRunAt.set({ hour: 1, minute: 0 }).minus({ days: 20 }) + cronJob = createCron("monthly@1:00", monthLastRunAt) + expect(cronJob.isDue(monthLastRunAt.plus({ days: 5 }))).toBe(false) + expect(cronJob.isDue(monthLastRunAt.plus({ days: 20 }))).toBe(true) + + const shortMonthLastRunAt = DateTime.fromRFC2822("1 Feb 2025 00:00:00 GMT").set({ hour: 1, minute: 0 }) + cronJob = createCron("monthly@1:00", shortMonthLastRunAt) + expect(cronJob.isDue(shortMonthLastRunAt.plus({ days: 5 }))).toBe(false) + expect(cronJob.isDue(shortMonthLastRunAt.plus({ days: 29 }))).toBe(true) +}) diff --git a/packages/http/src/cron.ts b/packages/http/src/cron.ts new file mode 100644 index 0000000..85a38ae --- /dev/null +++ b/packages/http/src/cron.ts @@ -0,0 +1,211 @@ +import { getErrorMessage } from "@workshop/shared/errors" +import kv from "@workshop/shared/kv" +import { getPackageInfo, type PackageInfo } from "@workshop/shared/packages" +import { DateTime } from "luxon" +import { z } from "zod" + +export const startCron = async () => { + const crons = await getCrons() + await runCrons(crons) +} + +const runCrons = async (crons: CronJob[]) => { + try { + const promises = crons.map((cron) => { + return cron.runIfDue() + }) + + await Promise.allSettled(promises) + } catch (error) { + console.error("💥 Error running cron jobs:", error) + } finally { + setTimeout(() => { + runCrons(crons) + }, 500) + } +} + +const getCrons = async () => { + const packageInfo = await getPackageInfo() + const cronScripts: CronJob[] = [] + + for (const info of packageInfo) { + if (!info.json.scripts) continue + for (const [scriptName, scriptCommand] of Object.entries(info.json.scripts)) { + if (!scriptName.startsWith("cron:")) continue + + try { + const cronJob = new CronJob(scriptName, scriptCommand, info) + + // Create an entry for everything so we see it in the UI + await kv.update("cronJobs", {}, (jobs) => { + if (jobs[cronJob.id]) return jobs + jobs[cronJob.id] = [] + return jobs + }) + + await cronJob.recoverLastRun() + + cronScripts.push(cronJob) + } catch (error) { + console.error(`Invalid cron job "${scriptName}" in package "${info.json.name}":`, error) + continue + } + } + } + return cronScripts +} + +const DEBUG = false +export class CronJob { + schedule: CronSchedule + id: string + private lastRunAt: DateTime = DateTime.now().minus({ seconds: 60 }) + private scriptName: string + private script: string + private info: PackageInfo + + constructor(scriptName: string, script: string, info: PackageInfo, lastRunAt?: DateTime) { + this.scriptName = scriptName + this.script = script + this.info = info + this.schedule = parseScriptName(this.scriptName) + this.id = `${this.info.json.name} ${this.scriptName} ${this.script}` + this.lastRunAt = lastRunAt || DateTime.now().minus({ seconds: 60 }) + } + + static isValid(cron: string): boolean { + try { + parseScriptName(cron) + return true + } catch (error) { + return false + } + } + + debug(...args: unknown[]) { + if (!DEBUG) return + console.debug("🪲", ...args) + } + + async recoverLastRun() { + const cronJobs = await kv.get("cronJobs", {}) + const lastJob = cronJobs[this.id]?.[0] + const defaultValue = DateTime.now().minus({ seconds: 60 }) + if (!lastJob) return defaultValue + + const lastRunAt = DateTime.fromISO(lastJob.runAt) + this.debug(`Recovering last run for "${this.scriptName}"`, lastRunAt.toISO()) + this.lastRunAt = lastRunAt.isValid ? lastRunAt : defaultValue + } + + toString() { + const format = "LLL d, yyyy HH:mm:ss ZZZZ" + return `CronJob({"${this.scriptName}": "${this.script}"} lastRun:"${this.lastRunAt.toFormat(format)}")` + } + + isDue(now: DateTime): boolean { + if (this.schedule.unit === "constantly") { + const diff = now.diff(this.lastRunAt, "minutes").minutes + return diff >= 1 + } + + let previousScheduledAt = now.setZone("UTC").set({ + hour: this.schedule.hour, + minute: this.schedule.minute, + second: 0, + millisecond: 0, + }) + + if (this.schedule.unit === "weekly") { + previousScheduledAt = previousScheduledAt.startOf("week") + } else if (this.schedule.unit === "monthly") { + previousScheduledAt = previousScheduledAt.startOf("month") + } + + // If the scheduled time is in the future, go back one period + if (previousScheduledAt > now) { + const unitMap = { + hourly: { hour: 1 }, + daily: { day: 1 }, + weekly: { week: 1 }, + monthly: { month: 1 }, + } + previousScheduledAt = previousScheduledAt.minus(unitMap[this.schedule.unit]) + } + + const shouldRun = this.lastRunAt < previousScheduledAt // If the last run is before the previous scheduled time, it's due + this.debug( + `${shouldRun ? "✅" : "❌"} ${ + this.scriptName + }: Last run at "${this.lastRunAt.toISO()}" previous scheduled at "${previousScheduledAt.toISO()}"` + ) + + return shouldRun + } + + async runIfDue() { + const now = DateTime.now() + if (!this.isDue(now)) return + this.run(now) + } + + async run(now: DateTime) { + this.lastRunAt = now + + let runError: unknown + let stdout: string | undefined + let stderr: string | undefined + + try { + // --silent to suppress output of the command to stderr + const proc = Bun.spawn(["bun", "run", "--silent", this.scriptName], { + stderr: "pipe", + stdout: "pipe", + cwd: this.info.path, + }) + + await proc.exited + + stdout = typeof proc.stdout === "number" ? proc.stdout : await Bun.readableStreamToText(proc.stdout) + stderr = typeof proc.stderr === "number" ? proc.stderr : await Bun.readableStreamToText(proc.stderr) + + const status = proc.exitCode + if (status !== 0) { + throw new Error(`Failed with exit code ${status}`) + } + } catch (error) { + console.error(`💥 Error running cron job "${this.scriptName}" in "${this.info.json.name}":`, error) + runError = error + } finally { + await kv.update("cronJobs", {}, (jobs) => { + let history = jobs[this.id] || [] + history = history.slice(0, 9) // Keep only the last 10 runs + history.unshift({ + runAt: now.toISO()!, + error: runError ? getErrorMessage(runError) : undefined, + stdout, + stderr, + }) + jobs[this.id] = history + return jobs + }) + } + } +} + +const parseScriptName = (scriptName: string): CronSchedule => { + const cron = scriptName.replace("cron:", "").replace(/ #.*$/, "") + const parts = cron.split("@") + const [unit, time] = parts + const [hour, minute] = time?.split(":") || [] + + return cronScheduleSchema.parse({ unit, hour, minute }) +} + +export type CronSchedule = z.infer +const cronScheduleSchema = z.object({ + unit: z.enum(["constantly", "hourly", "daily", "weekly", "monthly"]), + hour: z.coerce.number().int().min(0).max(23).default(0), + minute: z.coerce.number().int().min(0).max(59).default(0), +}) diff --git a/packages/http/src/main.ts b/packages/http/src/main.ts new file mode 100644 index 0000000..00d1882 --- /dev/null +++ b/packages/http/src/main.ts @@ -0,0 +1,10 @@ +import { startCron } from "./cron" +import { startServer } from "./server" + +try { + await startServer() + await startCron() +} catch (error) { + console.error("💥 Error starting http:", error) + process.exit(1) +} diff --git a/packages/http/src/orchestrator.ts b/packages/http/src/orchestrator.ts index ac0ca30..a4f62b3 100644 --- a/packages/http/src/orchestrator.ts +++ b/packages/http/src/orchestrator.ts @@ -40,6 +40,7 @@ export const subdomainInfo = async () => { subdomainInfo.push({ path: info.path, + name: basename(info.path), hasDevScript: Boolean(info.json.scripts?.["subdomain:dev"]), }) } diff --git a/packages/http/src/routes/index.tsx b/packages/http/src/routes/index.tsx index ddbbc8c..8fd3e66 100644 --- a/packages/http/src/routes/index.tsx +++ b/packages/http/src/routes/index.tsx @@ -1,24 +1,66 @@ -import { subdomainPackageInfo } from "../orchestrator" +import { subdomainInfo } from "../orchestrator" import type { LoaderProps } from "@workshop/nano-remix" +import kv, { type CronJobHistory } from "@workshop/shared/kv" +import { zone } from "@workshop/shared/utils" +import { useMemo } from "hono/jsx" +import { DateTime } from "luxon" export const loader = async (_req: Request) => { - const packagePaths = await subdomainPackageInfo() - return { packagePaths } + const info = await subdomainInfo() + const cronJobs = await kv.get("cronJobs", {}) + return { subdomains: info.map((i) => i.name), cronJobs } } -export default function Index({ packagePaths }: LoaderProps) { +export default function Index({ subdomains, cronJobs }: LoaderProps) { const url = new URL(import.meta.url) + const sortedEntries = useMemo(() => { + return Object.entries(cronJobs).sort((a, b) => { + const aHistory = a[1] ?? [] + const bHistory = b[1] ?? [] + const latestA = aHistory[0] + const latestB = bHistory[0] + if (!latestA && !latestB) return 0 + if (!latestA) return 1 + if (!latestB) return -1 + return DateTime.fromISO(latestB.runAt).diff(DateTime.fromISO(latestA.runAt)).as("milliseconds") + }) + }, [cronJobs]) + return (
-

Subdomain Servers

+

Subdomain Servers

+

CronJobs

+
    + {sortedEntries.map(([id, history]) => ( +
  • +

    {id}

    +
  • ))}
) } + +const JobHistory = ({ history }: { history: CronJobHistory[] }) => { + return ( +
    + {history.slice(0, 2).map((run, index) => ( +
  • + {DateTime.fromISO(run.runAt).setZone(zone).toRFC2822()} + {run.error &&
    Error: {run.error}
    } + {run.stdout &&
    Output: {run.stdout}
    } + {run.stderr &&
    Error Output: {run.stderr}
    } +
  • + ))} +
+ ) +} diff --git a/packages/http/src/server.tsx b/packages/http/src/server.ts similarity index 59% rename from packages/http/src/server.tsx rename to packages/http/src/server.ts index 554c01f..2aa53a6 100644 --- a/packages/http/src/server.tsx +++ b/packages/http/src/server.ts @@ -1,5 +1,6 @@ import { startSubdomainServers } from "./orchestrator" import { nanoRemix } from "@workshop/nano-remix" +import { requireAuth } from "./auth" import { join } from "node:path" /** @@ -9,27 +10,16 @@ import { join } from "node:path" * 1. Starts multiple subdomain servers on different ports (3001+) * 2. Main server (port 3000) acts as a proxy that routes requests based on subdomain * 3. All requests are protected with Basic HTTP auth + persistent cookies - * 4. Once authenticated, cookies work across all subdomains (*.thedomain.com) + * 4. Once authenticated, cookies work across all subdomains (*.theworkshop.cc) * 5. In development, uses subdomain:dev scripts if available for hot reloading * * This server is designed to be run in production with NODE_ENV=production. On development, * it allows unauthenticated access because YOLO. */ -const serve = async () => { - try { - const portMap = await startSubdomainServers() - const server = startServer(portMap) - logServerInfo(server, portMap) - } catch (error) { - console.error("❌ Error starting http package:", error) - process.exit(1) - } -} +export const startServer = async () => { + const portMap = await startSubdomainServers() -serve() - -const startServer = (portMap: Record) => { const server = Bun.serve({ port: 3000, routes: { @@ -69,12 +59,13 @@ const startServer = (portMap: Record) => { }, }) - return server + logServerInfo(server, portMap) } const logServerInfo = (server: Bun.Server, portMap: Record) => { console.log(`${server.url}`) const subdomainEntries = Object.entries(portMap) + subdomainEntries.forEach(([subdomain, port], index) => { const subdomainUrl = new URL(server.url) subdomainUrl.hostname = `${subdomain}.${subdomainUrl.hostname}` @@ -85,48 +76,3 @@ const logServerInfo = (server: Bun.Server, portMap: Record) => { }) console.log("") } - -const requireAuth = (handler: (req: Request) => Response | Promise) => { - return async (req: Request) => { - if (process.env.NODE_ENV !== "production") { - return handler(req) - } - - const cookies = new Bun.CookieMap(req.headers.get("cookie") ?? "") - if (cookies.get("auth") === "authenticated") { - return handler(req) - } - - if (validAuth(req)) { - const response = await handler(req) - const newHeaders = new Headers(response.headers) - - const url = new URL(req.url) - const cookieDomain = `.${url.hostname.split(".").slice(-2).join(".")}` - - const authCookie = new Bun.Cookie({ - name: "auth", - value: "authenticated", - domain: cookieDomain, - path: "/", - maxAge: 86400, - httpOnly: true, - }) - - newHeaders.set("Set-Cookie", authCookie.toString()) - - return new Response(response.body, { status: response.status, headers: newHeaders }) - } - - return new Response("Unauthorized", { - status: 401, - headers: { "WWW-Authenticate": 'Basic realm="Workshop Access"' }, - }) - } -} - -const validAuth = (req: Request): boolean => { - const logins = ["spike:888", "defunkt:888", "probablycorey:888"] - const auth = req.headers.get("authorization") - return logins.some((login) => `Basic ${btoa(login)}` === auth) -} diff --git a/packages/shared/src/kv.ts b/packages/shared/src/kv.ts index 1715e97..618ef56 100644 --- a/packages/shared/src/kv.ts +++ b/packages/shared/src/kv.ts @@ -1,5 +1,6 @@ import { mkdir } from "node:fs/promises" import { dirname, join } from "node:path" +import { DateTime } from "luxon" const set = async (key: T, value: Keys[T]) => { try { @@ -87,7 +88,14 @@ export default { set, get, remove, update } export type Keys = { threads: Record // threadId: thread metadata todos: Record // todoId: todoText - evaluations: EvalData[] // evaluation data for dashboard import + cronJobs: Record // jobId: array of job runs +} + +export type CronJobHistory = { + error?: string + stderr?: string + stdout?: string + runAt: string } export type TrackedThread = { @@ -98,18 +106,7 @@ export type TrackedThread = { const keyVersions: Record = { threads: 1, todos: 1, - evaluations: 1, + cronJobs: 1, } as const export type Conversation = { message: string; role: "user" | "assistant" } - -export type EvalData = { - channel: string - rating: "good" | "bad" - context: { - content: string - role: "user" | "assistant" - author?: string - }[] // Last 5 messages leading up to the rated response - ratedBy: string -} diff --git a/packages/shared/src/packages.ts b/packages/shared/src/packages.ts index 6138ee5..fffe07a 100644 --- a/packages/shared/src/packages.ts +++ b/packages/shared/src/packages.ts @@ -1,9 +1,9 @@ import { readdir } from "node:fs/promises" import { join } from "node:path" -type PackageInfo = { +export type PackageInfo = { path: string - json?: { + json: { name: string version: string scripts?: Record @@ -33,7 +33,6 @@ export const getPackageInfo = async (): Promise => { packageInfo.push({ path, json }) } catch (error) { console.error(`Error processing package ${packageName}:`, error) - packageInfo.push({ path }) } } diff --git a/packages/spike/package.json b/packages/spike/package.json index 168661c..4e3a149 100644 --- a/packages/spike/package.json +++ b/packages/spike/package.json @@ -16,11 +16,10 @@ "@openai/agents": "^0.0.10", "@workshop/shared": "workspace:*", "discord.js": "^14.19.3", - "luxon": "^3.6.1", + "luxon": "catalog:", "zod": "catalog:" }, "devDependencies": { - "@types/luxon": "^3.6.2", "@types/bun": "latest" }, "peerDependencies": { diff --git a/packages/spike/src/discord/events.ts b/packages/spike/src/discord/events.ts index 99746fb..4cab071 100644 --- a/packages/spike/src/discord/events.ts +++ b/packages/spike/src/discord/events.ts @@ -1,5 +1,4 @@ import { respondToUserMessage } from "../ai" -import { storeEvaluation } from "../eval" import { ActivityType, type Client } from "discord.js" import { runCommand } from "./commands" @@ -32,17 +31,6 @@ export const listenForEvents = (client: Client) => { } }) - client.on("messageReactionAdd", async (reaction, user) => { - if (user.bot) return - - const emoji = reaction.emoji.name - - // Only process evaluation emojis - if (emoji === "✅" || emoji === "❌") { - await storeEvaluation(reaction.message, emoji, user.username || user.id) - } - }) - client.on("ready", () => { // set the bots description const branch = process.env.RENDER_GIT_BRANCH || "unknown" diff --git a/packages/spike/src/discord/keepThreadsAlive.ts b/packages/spike/src/discord/keepThreadsAlive.ts index 5c9070f..dfa4eb9 100644 --- a/packages/spike/src/discord/keepThreadsAlive.ts +++ b/packages/spike/src/discord/keepThreadsAlive.ts @@ -2,11 +2,7 @@ import { Client } from "discord.js" import { timeBomb } from "@workshop/shared/utils" import { DateTime } from "luxon" -timeBomb("7-15-2025", "Get rid of all these console messages Corey!") - -// const timeout = 1000 * 60 * 30 // 30 minutes -// const timeout = 1000 * 60 // 1 minute -const timeout = 3000 // 1 minute +const timeout = 1000 * 60 * 10 // 10 minutes export const keepThreadsAlive = async (client: Client) => { try { if (!client.isReady() || client.guilds.cache.size === 0) return @@ -14,19 +10,14 @@ export const keepThreadsAlive = async (client: Client) => { for (const guild of client.guilds.cache.values()) { const fetchedThreads = await guild.channels.fetchActiveThreads() - for (const [threadId, thread] of fetchedThreads.threads) { + for (const [_threadId, thread] of fetchedThreads.threads) { + if (!thread.name.includes("💾")) continue const archiveAt = DateTime.fromMillis(thread.archiveTimestamp || 0) - const result = await thread.setArchived( - false, - "Revived by Spike - keeping important discussion alive" - ) - // send message to the thread if it was archived if (thread.archived) { const message = `🔍 Revived: ${thread.name} -- ${archiveAt.toFormat("F")}` await thread.send(message) } - console.log(`🔍 Revive: ${thread.name}: ${archiveAt.toFormat("F")} -- ${result.archived}`) } } } catch (error) { diff --git a/packages/spike/src/eval.ts b/packages/spike/src/eval.ts deleted file mode 100644 index 6c46ce7..0000000 --- a/packages/spike/src/eval.ts +++ /dev/null @@ -1,82 +0,0 @@ -import kv from "@workshop/shared/kv" -import type { EvalData } from "@workshop/shared/kv" -import type { Message, PartialMessage } from "discord.js" - -export const storeEvaluation = async (message: Message | PartialMessage, emoji: string, ratedBy: string) => { - try { - const rating = emoji === "✅" ? "good" : emoji === "❌" ? "bad" : null - if (!rating) { - throw new Error(`Invalid evaluation emoji: ${emoji}`) - } - - // If message is partial, fetch the full message - let fullMessage: Message - if (message.partial) { - fullMessage = await message.fetch() - } else { - fullMessage = message - } - - // Fetch conversation context using Discord API - const context = await fetchConversationContext(fullMessage) - - let channelName = `${fullMessage.guild?.name} - ???` - if ("name" in fullMessage.channel) { - channelName = `${fullMessage.guild?.name} - ${fullMessage.channel.name}` - } - const evalData: EvalData = { - channel: channelName, - rating, - context, - ratedBy, - } - - // Add to evaluations - await kv.update("evaluations", [], (prev) => { - return [...prev, evalData] - }) - - console.log(`📊 Evaluation stored: ${rating} rating for message ${fullMessage.id} by ${ratedBy}`) - - // React with thumbs up to acknowledge - await fullMessage.react("👍") - - return evalData - } catch (error) { - console.error("Error storing evaluation:", error) - throw error - } -} - -// Fetch conversation context around a message using Discord API -const fetchConversationContext = async (message: Message, contextSize = 5): Promise => { - try { - // Fetch messages before the target message (the conversation leading up to this response) - const messagesBefore = await message.channel.messages.fetch({ - before: message.id, - limit: contextSize, - }) - - // Include the target message and sort by timestamp - const allMessages = [...messagesBefore.values(), message].sort( - (a, b) => a.createdTimestamp - b.createdTimestamp - ) - - return allMessages.map((msg) => ({ - content: msg.content, - role: msg.author.bot ? "assistant" : "user", - author: msg.author.username, - })) - } catch (error) { - console.error("Error fetching conversation context:", error) - - // Fallback to just the target message - return [ - { - content: message.content, - role: message.author.bot ? "assistant" : "user", - author: message.author.username, - }, - ] - } -} diff --git a/packages/todo/package.json b/packages/todo/package.json index 8b2c7d0..183b0bf 100644 --- a/packages/todo/package.json +++ b/packages/todo/package.json @@ -21,7 +21,7 @@ "@lezer/highlight": "^1.2.1", "@lezer/lr": "^1.4.2", "hono": "catalog:", - "luxon": "^3.6.1", + "luxon": "catalog:", "zzfx": "^1.3.0" }, "prettier": { diff --git a/packages/whiteboard/package.json b/packages/whiteboard/package.json index 71a2dc0..b2b135c 100644 --- a/packages/whiteboard/package.json +++ b/packages/whiteboard/package.json @@ -16,7 +16,7 @@ "@workshop/nano-remix": "workspace:*", "@workshop/shared": "workspace:*", "hono": "catalog:", - "luxon": "^3.7.1", + "luxon": "catalog:", "pixabay-api": "^1.0.4", "pngjs": "^7.0.0", "tailwind": "^4.0.0",