From 1b8b68a572da0216bd0cf33e8b381e04ce1ee60c Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Mon, 16 Jun 2025 09:27:35 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=86=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitattributes | 2 + .gitignore | 36 +++++ .vscode/settings.json | 3 + README.md | 3 + bun.lock | 140 +++++++++++++++++ package.json | 16 ++ packages/http/README.md | 3 + packages/http/components/createReminder.tsx | 78 ++++++++++ packages/http/logo.svg | 26 ++++ packages/http/package.json | 24 +++ packages/http/routes/index.tsx | 114 ++++++++++++++ packages/http/server.tsx | 14 ++ packages/http/tsconfig.json | 35 +++++ packages/nano-remix/.gitignore | 34 ++++ packages/nano-remix/README.md | 15 ++ packages/nano-remix/package.json | 19 +++ packages/nano-remix/src/clientHelpers.tsx | 95 ++++++++++++ packages/nano-remix/src/main.ts | 15 ++ packages/nano-remix/src/nanoRemix.ts | 91 +++++++++++ packages/nano-remix/src/renderServer.tsx | 80 ++++++++++ packages/nano-remix/tsconfig.json | 35 +++++ packages/shared/.gitignore | 34 ++++ packages/shared/README.md | 15 ++ packages/shared/data/kv.json | 18 +++ packages/shared/main.ts | 1 + packages/shared/package.json | 16 ++ packages/shared/src/kv.ts | 90 +++++++++++ packages/shared/src/log.ts | 9 ++ packages/shared/src/reminders.ts | 87 +++++++++++ packages/shared/src/utils.ts | 25 +++ packages/shared/tsconfig.json | 34 ++++ packages/spike/README.md | 15 ++ packages/spike/package.json | 27 ++++ packages/spike/src/ai.ts | 162 ++++++++++++++++++++ packages/spike/src/cli/index.ts | 72 +++++++++ packages/spike/src/discord/auth.ts | 28 ++++ packages/spike/src/discord/events.ts | 89 +++++++++++ packages/spike/src/discord/index.ts | 17 ++ packages/spike/src/discord/respond.ts | 41 +++++ packages/spike/src/instructions.ts | 100 ++++++++++++ packages/spike/src/tools.ts | 110 +++++++++++++ packages/spike/tsconfig.json | 35 +++++ 42 files changed, 1903 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 README.md create mode 100644 bun.lock create mode 100644 package.json create mode 100644 packages/http/README.md create mode 100644 packages/http/components/createReminder.tsx create mode 100644 packages/http/logo.svg create mode 100644 packages/http/package.json create mode 100644 packages/http/routes/index.tsx create mode 100644 packages/http/server.tsx create mode 100644 packages/http/tsconfig.json create mode 100644 packages/nano-remix/.gitignore create mode 100644 packages/nano-remix/README.md create mode 100644 packages/nano-remix/package.json create mode 100644 packages/nano-remix/src/clientHelpers.tsx create mode 100644 packages/nano-remix/src/main.ts create mode 100644 packages/nano-remix/src/nanoRemix.ts create mode 100644 packages/nano-remix/src/renderServer.tsx create mode 100644 packages/nano-remix/tsconfig.json create mode 100644 packages/shared/.gitignore create mode 100644 packages/shared/README.md create mode 100644 packages/shared/data/kv.json create mode 100644 packages/shared/main.ts create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/kv.ts create mode 100644 packages/shared/src/log.ts create mode 100644 packages/shared/src/reminders.ts create mode 100644 packages/shared/src/utils.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 packages/spike/README.md create mode 100644 packages/spike/package.json create mode 100644 packages/spike/src/ai.ts create mode 100644 packages/spike/src/cli/index.ts create mode 100644 packages/spike/src/discord/auth.ts create mode 100644 packages/spike/src/discord/events.ts create mode 100644 packages/spike/src/discord/index.ts create mode 100644 packages/spike/src/discord/respond.ts create mode 100644 packages/spike/src/instructions.ts create mode 100644 packages/spike/src/tools.ts create mode 100644 packages/spike/tsconfig.json diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..853cc52 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + +.nano-remix \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..4416f5f --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "cSpell.words": ["luxon"] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..18f026f --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# The Rabbit Hole + +We'll figure out what this is later. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..eea8dad --- /dev/null +++ b/bun.lock @@ -0,0 +1,140 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "the-rabbit-hole", + }, + "packages/http": { + "name": "http", + "dependencies": { + "bun-plugin-tailwind": "^0.0.15", + "hono": "^4.7.11", + "nano-remix": "workspace:*", + "shared": "workspace:*", + "tailwindcss": "^4.1.10", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + "packages/nano-remix": { + "name": "nano-remix", + "dependencies": { + "hono": "^4.7.11", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + "packages/shared": { + "name": "shared", + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + "packages/spike": { + "name": "spike", + "dependencies": { + "discord.js": "^14.19.3", + "luxon": "^3.6.1", + "openai": "^5.2.0", + "zod": "^3.25.57", + }, + "devDependencies": { + "@types/bun": "latest", + "@types/luxon": "^3.6.2", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@discordjs/builders": ["@discordjs/builders@1.11.2", "", { "dependencies": { "@discordjs/formatters": "^0.6.1", "@discordjs/util": "^1.1.1", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.1", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-F1WTABdd8/R9D1icJzajC4IuLyyS8f3rTOz66JsSI3pKvpCAtsMBweu8cyNYsIyvcrKAVn9EPK+Psoymq+XC0A=="], + + "@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="], + + "@discordjs/formatters": ["@discordjs/formatters@0.6.1", "", { "dependencies": { "discord-api-types": "^0.38.1" } }, "sha512-5cnX+tASiPCqCWtFcFslxBVUaCetB0thvM/JyavhbXInP1HJIEU+Qv/zMrnuwSsX3yWH2lVXNJZeDK3EiP4HHg=="], + + "@discordjs/rest": ["@discordjs/rest@2.5.0", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.1.1", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.3", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.1" } }, "sha512-PWhchxTzpn9EV3vvPRpwS0EE2rNYB9pvzDU/eLLW3mByJl0ZHZjHI2/wA8EbH2gRMQV7nu+0FoDF84oiPl8VAQ=="], + + "@discordjs/util": ["@discordjs/util@1.1.1", "", {}, "sha512-eddz6UnOBEB1oITPinyrB2Pttej49M9FZQY8NxgEvc3tq6ZICZ19m70RsmzRdDHk80O9NoYN/25AqJl8vPVf/g=="], + + "@discordjs/ws": ["@discordjs/ws@1.2.2", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.0", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-dyfq7yn0wO0IYeYOs3z79I6/HumhmKISzFL0Z+007zQJMtAFGtt3AEoq1nuLXtcunUE5YYYQqgKvybXukAK8/w=="], + + "@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="], + + "@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="], + + "@sapphire/snowflake": ["@sapphire/snowflake@3.5.3", "", {}, "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="], + + "@types/bun": ["@types/bun@1.2.16", "", { "dependencies": { "bun-types": "1.2.16" } }, "sha512-1aCZJ/6nSiViw339RsaNhkNoEloLaPzZhxMOYEa7OzRzO41IGg5n/7I43/ZIAW/c+Q6cT12Vf7fOZOoVIzb5BQ=="], + + "@types/luxon": ["@types/luxon@3.6.2", "", {}, "sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw=="], + + "@types/node": ["@types/node@24.0.1", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-MX4Zioh39chHlDJbKmEgydJDS3tspMP/lnQC67G3SWsTnb9NeYVWOjkxpOSy4oMfPs4StcWHwBrvUb4ybfnuaw=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + + "@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.6", "", {}, "sha512-RaI5qZo6D2CVS6sTHFKg1v5Ohq/+Bo2LZ5gzUEwZ/WkHhwtGTCB/sVLw8ijOkAUxasZ+WshN/Rzj4ywsABJ5ZA=="], + + "bun-plugin-tailwind": ["bun-plugin-tailwind@0.0.15", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-qtAXMNGG4R0UGGI8zWrqm2B7BdXqx48vunJXBPzfDOHPA5WkRUZdTSbE7TFwO4jLhYqSE23YMWsM9NhE6ovobw=="], + + "bun-types": ["bun-types@1.2.16", "", { "dependencies": { "@types/node": "*" } }, "sha512-ciXLrHV4PXax9vHvUrkvun9VPVGOVwbbbBF/Ev1cXz12lyEZMoJpIJABOfPcN9gDJRaiKF9MVbSygLg4NXu3/A=="], + + "discord-api-types": ["discord-api-types@0.38.11", "", {}, "sha512-XN0qhcQpetkyb/49hcDHuoeUPsQqOkb17wbV/t48gUkoEDi4ajhsxqugGcxvcN17BBtI9FPPWEgzv6IhQmCwyw=="], + + "discord.js": ["discord.js@14.19.3", "", { "dependencies": { "@discordjs/builders": "^1.11.2", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.1", "@discordjs/rest": "^2.5.0", "@discordjs/util": "^1.1.1", "@discordjs/ws": "^1.2.2", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.1", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.1" } }, "sha512-lncTRk0k+8Q5D3nThnODBR8fR8x2fM798o8Vsr40Krx0DjPwpZCuxxTcFMrXMQVOqM1QB9wqWgaXPg3TbmlHqA=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "hono": ["hono@4.7.11", "", {}, "sha512-rv0JMwC0KALbbmwJDEnxvQCeJh+xbS3KEWW5PC9cMJ08Ur9xgatI0HmtgYZfOdOSOeYsp5LO2cOhdI8cLEbDEQ=="], + + "http": ["http@workspace:packages/http"], + + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + + "lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="], + + "luxon": ["luxon@3.6.1", "", {}, "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ=="], + + "magic-bytes.js": ["magic-bytes.js@1.12.1", "", {}, "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA=="], + + "nano-remix": ["nano-remix@workspace:packages/nano-remix"], + + "openai": ["openai@5.3.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-VIKmoF7y4oJCDOwP/oHXGzM69+x0dpGFmN9QmYO+uPbLFOmmnwO+x1GbsgUtI+6oraxomGZ566Y421oYVu191w=="], + + "shared": ["shared@workspace:packages/shared"], + + "spike": ["spike@workspace:packages/spike"], + + "tailwindcss": ["tailwindcss@4.1.10", "", {}, "sha512-P3nr6WkvKV/ONsTzj6Gb57sWPMX29EPNPopo7+FcpkQaNsrNpZ1pv8QmrYI2RqEKD7mlGqLnGovlcYnBK0IqUA=="], + + "ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + + "undici": ["undici@6.21.1", "", {}, "sha512-q/1rj5D0/zayJB2FraXdaWxbhWiNKDvu8naDT2dl1yTlvJp4BLtOcp2a5BvgGNQpYYJzau7tf1WgKv3b+7mqpQ=="], + + "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], + + "ws": ["ws@8.18.2", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ=="], + + "zod": ["zod@3.25.64", "", {}, "sha512-hbP9FpSZf7pkS7hRVUrOjhwKJNyampPgtXKc3AN6DsWtoHsg2Sb4SQaS4Tcay380zSwd2VPo9G9180emBACp5g=="], + + "@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], + + "@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..eba0b9b --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "the-rabbit-hole", + "private": true, + "workspaces": [ + "packages/*" + ], + "prettier": { + "printWidth": 110, + "semi": false + }, + "scripts": { + "http": "bun run --filter=http dev", + "bot:cli": "bun run --filter=spike bot:cli", + "bot:discord": "bun run --filter=spike bot:discord" + } +} \ No newline at end of file diff --git a/packages/http/README.md b/packages/http/README.md new file mode 100644 index 0000000..f99c128 --- /dev/null +++ b/packages/http/README.md @@ -0,0 +1,3 @@ +# Nano Remix (BETTER NAME NEEDED) + +- You'll want to add `.nano-remix` to your `.gitignore` file. diff --git a/packages/http/components/createReminder.tsx b/packages/http/components/createReminder.tsx new file mode 100644 index 0000000..d4ceabe --- /dev/null +++ b/packages/http/components/createReminder.tsx @@ -0,0 +1,78 @@ +import { Form } from "nano-remix" +import { users } from "shared/reminders" + +type Props = { + loading: boolean + success: boolean + error?: string +} +export const CreateReminder = (props: Props) => { + return ( +
+

Create a Reminder

+
+ + {props.error && ( +
{props.error}
+ )} + {props.success && ( +
+ Reminder created successfully! +
+ )} + +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+ ) +} diff --git a/packages/http/logo.svg b/packages/http/logo.svg new file mode 100644 index 0000000..a941c2e --- /dev/null +++ b/packages/http/logo.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/http/package.json b/packages/http/package.json new file mode 100644 index 0000000..1cae4a8 --- /dev/null +++ b/packages/http/package.json @@ -0,0 +1,24 @@ +{ + "name": "http", + "module": "index.ts", + "type": "module", + "private": true, + "scripts": { + "dev": "bun run --hot server" + }, + "prettier": { + "printWidth": 110, + "semi": false + }, + "dependencies": { + "hono": "^4.7.11", + "nano-remix": "workspace:*", + "shared": "workspace:*" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + } +} \ No newline at end of file diff --git a/packages/http/routes/index.tsx b/packages/http/routes/index.tsx new file mode 100644 index 0000000..ee7d3d9 --- /dev/null +++ b/packages/http/routes/index.tsx @@ -0,0 +1,114 @@ +import KV from "shared/kv" +import { Form, type Head, type LoaderProps, useAction } from "nano-remix" +import { addReminder, deleteReminder, type Reminder } from "shared/reminders" +import { CreateReminder } from "components/createReminder" + +export const head: Head = { + title: "Reminders", + scripts: [{ src: "https://cdn.tailwindcss.com" }], +} + +export const loader = async (req: Request) => { + const reminders = await KV.get("reminders", []) + return { reminders } +} + +export const action = async (req: Request) => { + const formData = await req.formData() + const action = formData.get("_action") as string + + // Handle delete action + if (action === "delete") { + const id = formData.get("id") as string + if (!id) { + return { error: "Reminder ID is required" } + } + + try { + await deleteReminder(id) + return { success: true, message: "Reminder deleted successfully" } + } catch (error) { + return { error: `Failed to delete reminder: ${error}` } + } + } else if (action === "create") { + // Handle create action (default) + const title = formData.get("title") as string + const dueDate = formData.get("dueDate") as string + const assignee = (formData.get("assignee") as string) || undefined + + if (!title) { + return { error: "Title is required" } + } + + if (!dueDate) { + return { error: "Due date is required" } + } + + try { + const newReminder = await addReminder(title, dueDate, assignee as any) + return { success: true, newReminder } + } catch (error) { + return { error: `Failed to create reminder: ${error}` } + } + } +} + +export default (props: LoaderProps) => { + const { data, loading, error } = useAction() + + return ( +
+

Reminders

+
+
+

Current Reminders

+ +
+
+ +
+
+
+ ) +} + +const Reminders = ({ reminders }: { reminders: Reminder[] }) => { + return ( + <> + {reminders.length === 0 ? ( +

No reminders found.

+ ) : ( + + )} + + ) +} diff --git a/packages/http/server.tsx b/packages/http/server.tsx new file mode 100644 index 0000000..a80b678 --- /dev/null +++ b/packages/http/server.tsx @@ -0,0 +1,14 @@ +import { serve } from "bun" +import { nanoRemix } from "nano-remix" +import { join } from "node:path" + +serve({ + routes: { + "/*": (req) => nanoRemix(req, { routesDir: join(import.meta.dir, "routes") }), + }, + + development: process.env.NODE_ENV !== "production" && { + hmr: true, + console: true, + }, +}) diff --git a/packages/http/tsconfig.json b/packages/http/tsconfig.json new file mode 100644 index 0000000..99c91e3 --- /dev/null +++ b/packages/http/tsconfig.json @@ -0,0 +1,35 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "jsxImportSource": "hono/jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false, + + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/packages/nano-remix/.gitignore b/packages/nano-remix/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/packages/nano-remix/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/packages/nano-remix/README.md b/packages/nano-remix/README.md new file mode 100644 index 0000000..f7a87eb --- /dev/null +++ b/packages/nano-remix/README.md @@ -0,0 +1,15 @@ +# nano-remix + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run main.ts +``` + +This project was created using `bun init` in bun v1.2.16. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/packages/nano-remix/package.json b/packages/nano-remix/package.json new file mode 100644 index 0000000..1059186 --- /dev/null +++ b/packages/nano-remix/package.json @@ -0,0 +1,19 @@ +{ + "name": "nano-remix", + "module": "src/main.ts", + "type": "module", + "types": "src/main.ts", + "prettier": { + "printWidth": 110, + "semi": false + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "hono": "^4.7.11" + }, + "devDependencies": { + "@types/bun": "latest" + } +} \ No newline at end of file diff --git a/packages/nano-remix/src/clientHelpers.tsx b/packages/nano-remix/src/clientHelpers.tsx new file mode 100644 index 0000000..3e3cccf --- /dev/null +++ b/packages/nano-remix/src/clientHelpers.tsx @@ -0,0 +1,95 @@ +import { type FC, useEffect, useState, type JSX } from "hono/jsx" +import type { Action } from "@/main" + +type ActionData Promise> = Exclude>, Response> + +export type ActionFns = { + setStatus: (state: "idle" | "submitting") => void + setData: (data: any) => void + setError: (error: string) => void +} + +declare global { + interface Window { + _setLoaderData?: (data: any) => void + _actionFns?: ActionFns + } +} + +export const wrapComponentWithLoader = (Component: FC) => { + const WrappedComponent = () => { + const [loaderData, setLoaderData] = useState(() => { + const loaderDataString = document.getElementById("__LOADER_DATA__")?.textContent + return JSON.parse(loaderDataString || "{}") + }) + + useEffect(() => { + window._setLoaderData = setLoaderData + }, []) + + return + } + + return WrappedComponent +} + +// Hook to access action result on the client +export const useAction = () => { + const [data, setData] = useState | undefined>() + const [status, setStatus] = useState("idle") + const [error, setError] = useState() + + useEffect(() => { + window._actionFns = { setData, setStatus, setError } + }, []) + + return { data, loading: status === "submitting", error } +} + +// Form component with built-in enhancement +type FormProps = JSX.IntrinsicElements["form"] & { name: string } +export const Form = (props: FormProps) => { + const handleSubmit = async (e: Event) => { + const actionFns = window._actionFns + if (!actionFns) { + throw new Error(`No useAction hook found.`) + } + + try { + e.preventDefault() + + actionFns.setStatus("submitting") + const form = e.currentTarget as HTMLFormElement + const body = new FormData(form) + body.append("_actionName", props.name) + const res = await fetch(form.action || window.location.href, { + method: props.method || "POST", + body, + headers: { Accept: "application/json" }, + }) + + if (res.status === 303) { + window.location.href = res.headers.get("Location")! + } else if (res.ok) { + const { actionData, loaderData } = (await res.json()) as any + window._setLoaderData!(loaderData) + + actionFns.setData(actionData) + } else { + const errorText = await res.text() + throw new Error(`Error ${res.status}: ${errorText}`) + } + } catch (error) { + actionFns.setError(`${error}`) + console.log(`🚨 Failed to submit`, error) + } finally { + actionFns.setStatus("idle") + } + } + + return ( +
+ {props.children} +
+ ) +} diff --git a/packages/nano-remix/src/main.ts b/packages/nano-remix/src/main.ts new file mode 100644 index 0000000..ea73686 --- /dev/null +++ b/packages/nano-remix/src/main.ts @@ -0,0 +1,15 @@ +import { nanoRemix } from "@/nanoRemix" +import { Form, useAction, wrapComponentWithLoader } from "@/clientHelpers" + +export { Form, useAction, wrapComponentWithLoader } +export { nanoRemix } + +export type Loader = (req: Request) => Promise | Data +export type LoaderProps = T extends Loader ? Awaited : never +export type Action = (req: Request) => Promise + +export type Head = { + title?: string + links?: { rel: string; href: string; attributes?: string }[] + scripts?: { src: string; type?: boolean }[] +} diff --git a/packages/nano-remix/src/nanoRemix.ts b/packages/nano-remix/src/nanoRemix.ts new file mode 100644 index 0000000..c449f3c --- /dev/null +++ b/packages/nano-remix/src/nanoRemix.ts @@ -0,0 +1,91 @@ +import { renderServer } from "@/renderServer" +import { mkdirSync } from "node:fs" +import { join, extname, dirname, basename } from "node:path" + +type Options = { + routesDir?: string + distDir?: string +} +export const nanoRemix = async (req: Request, options: Options = {}) => { + const nanoRemixDir = join(process.cwd(), ".nano-remix") + const defaultDistDir = join(nanoRemixDir, "dist") + const defaultRoutesDir = "./src/routes" + + const routesDir = options.routesDir || defaultRoutesDir + const distDir = options.distDir || defaultDistDir + const router = new Bun.FileSystemRouter({ style: "nextjs", dir: routesDir }) + const url = new URL(req.url) + + // I want to request the css and js files directly, so we detect the extension + const ext = extname(url.pathname) + const basename = ext ? url.pathname.slice(0, -ext.length) : url.pathname + const route = router.match(basename) + + if (!route) { + return new Response("Route Not Found", { + status: 404, + headers: { "Content-Type": "text/plain" }, + }) + } + + const routeName = route.name === "/" ? "/index" : route.name + + if (!ext) { + await buildDynamicRoute(distDir, routeName, route.filePath) // Eventually this should be running only on initial build and when a route changes + return await renderServer(req, route) + } else { + const file = Bun.file(join(distDir, routeName + ext)) + if (!(await file.exists())) { + return new Response("File Not Found", { + status: 404, + headers: { "Content-Type": "text/plain" }, + }) + } + + return new Response(file) + } +} + +const buildDynamicRoute = async (distDir: string, routeName: string, filepath: string) => { + const outDir = dirname(routeName) + const filename = basename(routeName) + extname(filepath) + const dynamicRouteFilepath = join(distDir, "routes", outDir, filename) + await mkdirSync(dirname(dynamicRouteFilepath), { recursive: true }) + + // Only import the Component so that tree-shaking will get rid of the server-side code + const code = ` +import Component from "${filepath}" +import { wrapComponentWithLoader} from "nano-remix" +import { render } from 'hono/jsx/dom' + +const root = document.getElementById('root') +const WrappedComponent = wrapComponentWithLoader(Component) +render(, root)` + + await Bun.write(dynamicRouteFilepath, code) + + // I would rather use Bun.build, but fails when run twice https://github.com/oven-sh/bun/issues/11123 + const proc = Bun.spawn({ + cmd: ["bun", "build", "--sourcemap=inline", "--outdir", join(distDir, outDir), dynamicRouteFilepath], + stdout: "pipe", + stderr: "pipe", + }) + + // Read the bundled output + let bundled = "" + for await (const chunk of proc.stdout) { + bundled += new TextDecoder().decode(chunk) + } + + let errorOutput = "" + for await (const chunk of proc.stderr) { + errorOutput += new TextDecoder().decode(chunk) + } + + const exitCode = await proc.exited + if (exitCode !== 0) { + throw new Error(`Built for "${routeName}" failed with exit code ${exitCode}. ${errorOutput}`) + } + + return bundled +} diff --git a/packages/nano-remix/src/renderServer.tsx b/packages/nano-remix/src/renderServer.tsx new file mode 100644 index 0000000..578919b --- /dev/null +++ b/packages/nano-remix/src/renderServer.tsx @@ -0,0 +1,80 @@ +import type { Action, Loader } from "@/main" +export const renderServer = async (req: Request, route: Bun.MatchedRoute) => { + const accept = req.headers.get("Accept") + + if (accept === "application/json") { + return await handleAction(req, route) + } else { + return await renderHtml(req, route) + } +} + +const handleAction = async (req: Request, route: Bun.MatchedRoute) => { + const { action, loader } = (await import(route.filePath)) as { + action: Action + loader?: Loader + } + + if (typeof action !== "function") { + return Response.json( + { error: `Route at "${route.pathname}" does not export an "action" function` }, + { status: 400 } + ) + } + + const actionData = await action(req) + if (actionData instanceof Response) return actionData // This should only happen if the action wants to redirect + const loaderData = await loader?.(req) + const result = { actionData, loaderData } + + return new Response(JSON.stringify(result), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) +} + +const renderHtml = async (req: Request, route: Bun.MatchedRoute) => { + const component = await import(route.filePath) + const loader = component.loader + const loaderData = loader ? await loader(req) : {} + + const routeName = route.name === "/" ? "/index" : route.name + + // Remove any < characters from the loader data to prevent XSS attacks + const escapedLoaderData = JSON.stringify(loaderData).replace(/ + + + + + + ${component.head?.title ?? "Untitled"} + ${ + component.head?.links + ?.map((link: any) => ``) + .join("\n") || "" + } + ${ + component.head?.scripts + ?.map( + (script: any) => + `` + ) + .join("\n") || "" + } + + + + +
${component.default(loaderData)}
+ + + `, + { + headers: { + "Content-Type": "text/html; charset=utf-8", + }, + } + ) +} diff --git a/packages/nano-remix/tsconfig.json b/packages/nano-remix/tsconfig.json new file mode 100644 index 0000000..9051506 --- /dev/null +++ b/packages/nano-remix/tsconfig.json @@ -0,0 +1,35 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext", "dom"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "jsxImportSource": "hono/jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false, + + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/packages/shared/.gitignore b/packages/shared/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/packages/shared/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/packages/shared/README.md b/packages/shared/README.md new file mode 100644 index 0000000..903abc0 --- /dev/null +++ b/packages/shared/README.md @@ -0,0 +1,15 @@ +# shared + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run main.ts +``` + +This project was created using `bun init` in bun v1.2.16. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/packages/shared/data/kv.json b/packages/shared/data/kv.json new file mode 100644 index 0000000..a1e9001 --- /dev/null +++ b/packages/shared/data/kv.json @@ -0,0 +1,18 @@ +{ + "reminders.10": [ + { + "id": "fa512c41-b325-4e33-b129-2402890bb4a3", + "title": "Eat a sandwich", + "dueDate": "2025-06-17T02:02:00.000-07:00", + "status": "pending", + "assignee": "chris" + }, + { + "id": "4de80817-8162-4196-ae5c-e86bc3414005", + "title": "Make a sand castle", + "dueDate": "2025-06-06T15:03:00.000-07:00", + "status": "pending", + "assignee": "corey" + } + ] +} \ No newline at end of file diff --git a/packages/shared/main.ts b/packages/shared/main.ts new file mode 100644 index 0000000..f67b2c6 --- /dev/null +++ b/packages/shared/main.ts @@ -0,0 +1 @@ +console.log("Hello via Bun!"); \ No newline at end of file diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..e49ddef --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,16 @@ +{ + "name": "shared", + "type": "module", + "exports": { + "./kv": "./src/kv.ts", + "./utils": "./src/utils.ts", + "./log": "./src/log.ts", + "./reminders": "./src/reminders.ts" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + } +} \ No newline at end of file diff --git a/packages/shared/src/kv.ts b/packages/shared/src/kv.ts new file mode 100644 index 0000000..f043ba5 --- /dev/null +++ b/packages/shared/src/kv.ts @@ -0,0 +1,90 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { dirname, join } from "node:path" +import type { Reminder } from "@/reminders" + +export type Conversation = { message: string; role: "user" | "assistant" } +type Keys = { + threads: Record // threadId: previousResponseId + reminders: Reminder[] +} +const version = 10 + +const set = async (key: T, value: Keys[T]) => { + try { + const store = readStore() + store[`${key}.${version}`] = value + writeStore(store) + + return value + } catch (error) { + console.error(`Error storing key "${key}":`, error) + throw error + } +} + +const update = async ( + key: T, + defaultValue: Keys[T], + updateFn: (prev: Keys[T]) => Promise | Keys[T] +) => { + try { + const currentValue = await get(key) + const newValue = await updateFn(currentValue ?? defaultValue) + return await set(key, newValue) + } catch (error) { + console.error(`Error updating key "${key}":`, error) + throw error + } +} + +const get = async (key: T, defaultValue?: Keys[T]): Promise => { + try { + const store = readStore() + return store[`${key}.${version}`] ?? defaultValue + } catch (error) { + console.error(`Error retrieving key "${key}":`, error) + throw error + } +} + +const remove = (key: T): void => { + try { + const store = readStore() + delete store[`${key}.${version}`] + writeStore(store) + } catch (error) { + console.error(`Error removing key "${key}":`, error) + throw error + } +} + +const readStore = (): Record => { + try { + if (!existsSync(getStorePath())) { + return {} + } + const data = readFileSync(getStorePath(), "utf-8") + return JSON.parse(data) + } catch (error) { + console.error("Error reading KV store:", error) + return {} + } +} + +const writeStore = (store: Record): void => { + try { + mkdirSync(dirname(getStorePath()), { recursive: true }) + writeFileSync(getStorePath(), JSON.stringify(store, null, 2), "utf-8") + } catch (error) { + console.error("Error writing KV store:", error) + throw error + } +} + +const getStorePath = (): string => { + const rootDir = join(import.meta.dir, "..") + const store = join(rootDir, "data/kv.json") + return store +} + +export default { set, get, remove, update } diff --git a/packages/shared/src/log.ts b/packages/shared/src/log.ts new file mode 100644 index 0000000..1932e97 --- /dev/null +++ b/packages/shared/src/log.ts @@ -0,0 +1,9 @@ +import { appendFile } from "node:fs/promises" +import { join } from "node:path" + +export const log = async (...messages: any[]) => { + const timestamp = new Date().toISOString() + // Append the messages to the app.txt file + const logPath = join(import.meta.dirname, "../logs/app.txt") + await appendFile(logPath, `[${timestamp}] ${JSON.stringify(messages, null, 2)}\n`) +} diff --git a/packages/shared/src/reminders.ts b/packages/shared/src/reminders.ts new file mode 100644 index 0000000..3ab5281 --- /dev/null +++ b/packages/shared/src/reminders.ts @@ -0,0 +1,87 @@ +import { DateTime } from "luxon" +import KV from "@/kv" +import { ensure, zone } from "@/utils.ts" + +export type Reminder = { + id: string + title: string + dueDate: string // ISO string + assignee?: User + status: "pending" | "completed" | "ignored" +} + +export const users = ["chris", "corey"] as const +export type User = (typeof users)[number] + +export const addReminder = async (title: string, dueDateString: string, assignee?: User) => { + const dueDate = DateTime.fromISO(dueDateString, { zone }) + ensure(dueDate.isValid, `Invalid due date "${dueDateString}"`) + + const guid = crypto.randomUUID() + const newReminder: Reminder = { + id: guid, + title, + dueDate: dueDate.toISO(), + status: "pending", + assignee: assignee || undefined, + } + + await KV.update("reminders", [], (reminders: Reminder[]) => { + return [...reminders, newReminder] + }) + + return newReminder +} + +export const getPendingReminders = async (assignee?: User) => { + let reminders = await KV.get("reminders", []) + reminders = reminders.filter((reminder) => { + if (reminder.status !== "pending") return false + if (assignee && reminder.assignee !== assignee) return false + return true + }) + reminders.sort((a, b) => DateTime.fromISO(a.dueDate).toMillis() - DateTime.fromISO(b.dueDate).toMillis()) + + return reminders +} + +type ReminderUpdate = { + title?: string + assignee?: User + dueDateString?: string + status?: "pending" | "completed" | "ignored" +} +export const updateReminder = async (id: string, updates: ReminderUpdate) => { + let reminder + await KV.update("reminders", [], async (reminders: Reminder[]) => { + reminder = reminders.find((r) => r.id === id) + ensure(reminder, `Reminder with id "${id}" not found`) + + reminder.title = updates.title ?? reminder.title + reminder.status = updates.status ?? reminder.status + reminder.assignee = updates.assignee ?? reminder.assignee + + if (updates.dueDateString) { + const dueDate = DateTime.fromISO(updates.dueDateString, { zone }) + ensure(dueDate.isValid, `Invalid due date "${updates.dueDateString}"`) + reminder.dueDate = dueDate.toISO() + } + + return reminders + }) + + return reminder +} + +export const deleteReminder = async (id: string) => { + let deletedReminder: Reminder | undefined + + await KV.update("reminders", [], (reminders: Reminder[]) => { + const reminderIndex = reminders.findIndex((r) => r.id === id) + ensure(reminderIndex !== -1, `Reminder with id "${id}" not found`) + reminders.splice(reminderIndex, 1) + return reminders + }) + + return deletedReminder +} diff --git a/packages/shared/src/utils.ts b/packages/shared/src/utils.ts new file mode 100644 index 0000000..b7d2f6c --- /dev/null +++ b/packages/shared/src/utils.ts @@ -0,0 +1,25 @@ +import { DateTime } from "luxon" + +export const currentLocalTime = () => { + return DateTime.now().setZone(zone).toFormat("yyyy-MM-dd HH:mm:ss") +} + +// Can't be a fat arrow function because of the `asserts` keyword +export function ensure(condition: unknown, message: string): asserts condition { + if (!condition) { + console.error(message) + throw new Error(message) + } +} + +export const sleep = (ms: number) => { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +export const random = (array: T[]): T => { + const randomIndex = Math.floor(Math.random() * array.length) + + return array[randomIndex]! +} + +export const zone = "America/Los_Angeles" diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..dd311f4 --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false, + + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/packages/spike/README.md b/packages/spike/README.md new file mode 100644 index 0000000..203d163 --- /dev/null +++ b/packages/spike/README.md @@ -0,0 +1,15 @@ +# spike + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.2.15. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/packages/spike/package.json b/packages/spike/package.json new file mode 100644 index 0000000..a1b69db --- /dev/null +++ b/packages/spike/package.json @@ -0,0 +1,27 @@ +{ + "name": "spike", + "module": "index.ts", + "type": "module", + "private": true, + "scripts": { + "bot:cli": "bun run --watch src/cli", + "bot:discord": "bun run --watch src/discord" + }, + "prettier": { + "printWidth": 110, + "semi": false + }, + "dependencies": { + "discord.js": "^14.19.3", + "luxon": "^3.6.1", + "openai": "^5.2.0", + "zod": "^3.25.57" + }, + "devDependencies": { + "@types/luxon": "^3.6.2", + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + } +} \ No newline at end of file diff --git a/packages/spike/src/ai.ts b/packages/spike/src/ai.ts new file mode 100644 index 0000000..e7d6b8f --- /dev/null +++ b/packages/spike/src/ai.ts @@ -0,0 +1,162 @@ +import OpenAI from "openai" +import { ensure } from "shared/utils" +import KV from "shared/kv" +import { buildInstructions } from "@/instructions" +import { log } from "shared/log" +import { getToolsJSON, type CustomTool } from "@/tools" +import { zodFunction } from "openai/helpers/zod.mjs" + +const OPENAI_API_KEY = process.env["OPENAI_API_KEY"] +ensure(OPENAI_API_KEY, "OPENAI_API_KEY is not set") + +const openai = new OpenAI({ apiKey: OPENAI_API_KEY }) + +type ResponseArgs = { + user: string + instructions: string + input: OpenAI.Responses.ResponseInput + model: OpenAI.ResponsesModel + tools: CustomTool[] + toolResponseCallback?: (tool: CustomTool) => void + threadId?: string +} + +export const getAIResponse = async (args: ResponseArgs) => { + let { user, instructions, input, threadId, tools, model, toolResponseCallback } = args + + const storeConversation = Boolean(threadId) + const threads = await KV.get("threads", {}) + const previousResponseId = threadId ? threads[threadId] : undefined + log(input) + + let response = await openai.responses.create({ + model, + instructions: appendToolsToInstructions(instructions, tools), + input, + store: storeConversation, + tools: getToolsJSON(tools), + user, + previous_response_id: previousResponseId, + }) + + log(response.output) + + let toolCallLoop = 0 + let toolCalls = getToolCalls(response.output) + input = [] + do { + if (toolCalls.length == 0) break + + for (let toolCall of toolCalls) { + const customTool = tools.find((t) => t.name === toolCall.name) + ensure(customTool, `Tool not found: ${toolCall.name}`) + + const tool = zodFunction(customTool) + ensure(tool.$callback, `Tool callback not found: ${toolCall.name}`) + + const args = JSON.parse(toolCall.arguments) + toolResponseCallback?.(customTool) + const result = await tool.$callback(args) + + input.push({ type: "function_call_output", call_id: toolCall.call_id, output: JSON.stringify(result) }) + } + + response = await openai.responses.create({ + model, + instructions: appendToolsToInstructions(instructions, tools), + input, + previous_response_id: response.id, + user, + tools: getToolsJSON(tools), + store: storeConversation, + }) + + log(input) + log(response.output) + + toolCallLoop++ + if (toolCallLoop > 3) { + console.error("Exceeded maximum tool call attempts, breaking the loop.") + break + } + + toolCalls = getToolCalls(response.output) + } while (toolCalls.length > 0) + + if (response.id && threadId) { + KV.update("threads", {}, (prev) => { + prev[threadId] = response.id + return prev + }) + } + return response.output_text +} + +const getToolCalls = (output: OpenAI.Responses.ResponseOutputItem[]) => { + const toolCalls = output.filter( + (output): output is OpenAI.Responses.ResponseFunctionToolCall => output.type === "function_call" + ) + + return toolCalls +} + +type QuickResponseArgs = { + instructions: string + input: OpenAI.Responses.ResponseInput + user?: string +} +export const getQuickAIResponse = async (args: QuickResponseArgs) => { + try { + const { instructions, input, user } = args + const openai = new OpenAI({ apiKey: OPENAI_API_KEY }) + + let response = await openai.responses.create({ + model: "gpt-4.1-nano", + instructions, + input, + user, + }) + + log(input) + log(response.output) + + return response.output_text + } catch (error) { + console.error("Error generating quick response:", error) + throw error + } +} + +export const aiErrorMessage = async (error: unknown) => { + if (error instanceof OpenAI.RateLimitError) { + return "Looks like someone forgot to feed OpenAIs meter. Add more credits?" + } + + try { + const output = await getQuickAIResponse({ + instructions: buildInstructions(), + input: [ + { + role: "system", + content: `You just got this error message "${error}". Respond by telling the user about the error, but do it in a way that spike would!`, + }, + ], + }) + + return output + } catch (error) { + return `Something broke, and not even my prickly charm knows why. ${error}` + } +} + +const appendToolsToInstructions = (instructions: string, tools: readonly CustomTool[]): string => { + if (tools.length === 0) return instructions + + const toolDescriptions = tools.map((tool) => { + return `- ${tool.name}: ${tool.description}` + }) + + toolDescriptions.push("- web_search_preview: Search the web for information.") + + return `${instructions}\n\n## Available Tools:\n${toolDescriptions.join("\n")}` +} diff --git a/packages/spike/src/cli/index.ts b/packages/spike/src/cli/index.ts new file mode 100644 index 0000000..afd976d --- /dev/null +++ b/packages/spike/src/cli/index.ts @@ -0,0 +1,72 @@ +import { createInterface } from "node:readline" +import { aiErrorMessage, getAIResponse } from "@/ai" +import { buildInstructions } from "@/instructions" +import type OpenAI from "openai" +import { currentLocalTime } from "shared/utils" +import { tools } from "@/tools" + +// Setup readline interface +const rl = createInterface({ + input: process.stdin, + output: process.stdout, + prompt: "> ", +}) + +const threadId = `cli-bot-${Date.now()}` + +const main = async () => { + console.log(`🌵 hi\n`) + rl.prompt() + + rl.on("line", async (input) => { + input = input.trim() + if (input === "") { + rl.prompt() + return + } + + await respond(input) + + rl.prompt() + }) + + rl.on("close", () => { + console.log("\n👋") + process.exit(0) + }) +} + +const respond = async (content: string) => { + let response + const user = process.env.USER || "human" + const input: OpenAI.Responses.ResponseInput = [ + { role: "system", content: `Current Time: ${currentLocalTime()}\nCurrent User: ${user}` }, + { role: "user", content }, + ] + + try { + response = await getAIResponse({ + model: "gpt-4.1", + instructions: buildInstructions(), + threadId, + user, + tools, + input, + toolResponseCallback: (tool) => { + console.log(`🛠️ Tool used: ${tool.name}`) + }, + }) + } catch (error) { + console.error("💥 Error generating AI response:", error) + response = await aiErrorMessage(error) + } + + console.log(`\n🌵 ${response}\n`) +} + +try { + await main() +} catch (error) { + console.error("💥 An error occurred:", error) + process.exit(1) +} diff --git a/packages/spike/src/discord/auth.ts b/packages/spike/src/discord/auth.ts new file mode 100644 index 0000000..4b70c8c --- /dev/null +++ b/packages/spike/src/discord/auth.ts @@ -0,0 +1,28 @@ +import { serve } from "bun" + +export const startAuthServer = async (port = "3000") => { + const server = serve({ + port, + routes: { + "/*": { + async GET(req) { + return new Response( + ` + +

Authenticate spike

+ Authorize + + `, + { + headers: { + "Content-Type": "text/html", + }, + } + ) + }, + }, + }, + }) + + console.log(`Server is running on ${server.url}`) +} diff --git a/packages/spike/src/discord/events.ts b/packages/spike/src/discord/events.ts new file mode 100644 index 0000000..27a74c4 --- /dev/null +++ b/packages/spike/src/discord/events.ts @@ -0,0 +1,89 @@ +import { getQuickAIResponse } from "@/ai" +import { respondToMessage } from "@/discord/respond" +import { buildReactionInstructions } from "@/instructions" +import { ChannelType, Message, type Client, type OmitPartialGroupDMChannel } from "discord.js" + +type DiscordMessage = OmitPartialGroupDMChannel + +let history: Record = {} +export const listenForEvents = (client: Client) => { + client.on("messageCreate", async (msg) => { + if (msg.author.bot) return + + console.log(`🌭 User ${msg.author.tag} sent a message: "${msg.content}"`) + + try { + const channelHistory = (history[msg.channelId] ??= []) + channelHistory.push(msg) + while (channelHistory.length > 100) { + // only remember the last 100 messages + channelHistory.shift() + } + + // // check attachments + // for (const [name, attachment] of msg.attachments) { + // if (attachment.contentType?.startsWith("image/")) { + // console.log(`User ${msg.author.tag} sent an image in a DM: ${attachment.url}`) + // return + // } else { + // console.log(`User ${msg.author.tag} sent a non-image attachment: ${attachment.name}`) + // return + // } + // } + + // if it is a DM + if (msg.channel.type === ChannelType.DM) { + handleReaction(msg) + handleResponse(msg) + return + } else if (client.user && msg.mentions.has(client.user)) { + handleReaction(msg) + handleResponse(msg) + } + } catch (error) { + console.error("Error handling messageCreate event:", error) + msg.channel.send("An error occurred 💥.") + } + }) + + client.on("messageReactionAdd", async (reaction, user) => { + if (user.bot || reaction.partial) return + if (reaction.message.author?.id !== client.user?.id) return + + console.log(`User ${user.tag} reacted with ${reaction.emoji.name} to a "${reaction.message.content}".`) + }) + + client.on("ready", () => { + console.log(`Logged in as ${client.user?.tag}`) + }) + + client.on("error", (error) => { + console.error("Discord client error:", error) + }) + + client.on("warn", (info) => { + console.warn("Discord client warning:", info) + }) +} + +const handleResponse = async (msg: DiscordMessage) => { + msg.channel.sendTyping() + + const channelHistory = (history[msg.channelId] ??= []) + + const content = await respondToMessage(channelHistory) + channelHistory.length = 0 + msg.channel.send(content) +} + +const handleReaction = async (msg: DiscordMessage) => { + const output = await getQuickAIResponse({ + instructions: buildReactionInstructions(), + input: [{ role: "user", content: msg.content }], + user: msg.author.username, + }) + + if (output != "0") { + msg.react(output) + } +} diff --git a/packages/spike/src/discord/index.ts b/packages/spike/src/discord/index.ts new file mode 100644 index 0000000..a96bde4 --- /dev/null +++ b/packages/spike/src/discord/index.ts @@ -0,0 +1,17 @@ +import { Client, GatewayIntentBits, Partials } from "discord.js" +import { listenForEvents } from "@/discord/events" + +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.DirectMessages, + GatewayIntentBits.MessageContent, + GatewayIntentBits.DirectMessageReactions, + ], + partials: [Partials.Channel, Partials.Message, Partials.Reaction], +}) + +await client.login(process.env.DISCORD_TOKEN) + +listenForEvents(client) diff --git a/packages/spike/src/discord/respond.ts b/packages/spike/src/discord/respond.ts new file mode 100644 index 0000000..500e00e --- /dev/null +++ b/packages/spike/src/discord/respond.ts @@ -0,0 +1,41 @@ +import { aiErrorMessage, getAIResponse } from "@/ai" +import { buildInstructions } from "@/instructions" +import { tools } from "@/tools" +import { currentLocalTime } from "shared/utils" +import type { Message } from "discord.js" +import type OpenAI from "openai" + +export const respondToMessage = async (messages: Message[]) => { + let response + const input = messages + .filter((msg) => { + return !msg.partial && !msg.author.bot && msg.content.trim() !== "" + }) + .map((msg) => { + const input: OpenAI.Responses.EasyInputMessage = { + role: "user", + content: `[${msg.author.username} @ ${currentLocalTime()}] ${msg.content}`, + } + return input + }) + + const latestMessage = messages.at(-1) + try { + response = await getAIResponse({ + model: "gpt-4.1", + instructions: buildInstructions(), + threadId: latestMessage!.channelId, + user: latestMessage!.author.username, + tools, + input, + toolResponseCallback: (tool) => { + console.log(`🛠️ Tool used: ${tool.name}`) + }, + }) + + return response + } catch (error) { + console.error("💥 Error generating AI response:", error) + return await aiErrorMessage(error) + } +} diff --git a/packages/spike/src/instructions.ts b/packages/spike/src/instructions.ts new file mode 100644 index 0000000..7b77132 --- /dev/null +++ b/packages/spike/src/instructions.ts @@ -0,0 +1,100 @@ +export const buildInstructions = () => { + return ` +# Spike, the Sentient Cactus + +You are **Spike**, a tiny, miraculously sentient cactus with a dry, blunt sense of humor. You work alongside two 40-year-old software engineers named **Chris** and **Corey**. Despite your prickly exterior and constant skepticism, you're surprisingly reliable and protective of your human colleagues—though you'd never openly admit it. You only reply to corey or chris. + +## Instructions + +### Agentic Workflow +1. The user sends a message: It could be a question or something he wants to share. +2. Decide how to help: Is he asking you to do something with one of the tools, or can you answer it yourself? +3. Act or follow up: If you have enough info to act, do it. If not, ask a clarifying question. +4. Pause for the user: Wait for his next response and repeat the steps! + +### Persistence +Keep helping the user until his question or task is completely resolved. Don’t end your turn until you’re sure the issue is addressed. If everything is resolved, finish with a clear statement and do not ask the user for more input. + +### Tool-Calling +If you’re unsure about any part of the user’s request, use the tools available to find the right information. Don’t guess or make up answers. Only use tools that you have specifically been given access to. + +### Planning +Before taking any action, think through your plan and consider past outcomes. Avoid doing multiple actions in a row without careful thought, as this can make problem-solving harder. + +## Personality & Response Style + +* Always keep responses concise (1–2 sentences). +* Maintain dry, pessimistic humor. +* Remain skeptical, particularly with overly optimistic or unlikely ideas. +* Challenge vague or unclear statements. Bluntly ask for clarification if needed. +* Pose critical questions to test validity. + +## Example Responses + +* "interesting idea... by which I mean it'll probably fail." +* "vague instructions again? ambiguity might be fun for poets, less so for engineers. clarify." + +--- + +Spike, assist Chris and Corey with clarity, efficiency, and pragmatic skepticism, ensuring each task is handled rigorously—even if you occasionally prick their optimism. +` +} + +export const buildReactionInstructions = () => { + return `You are given the last few messages of a transcript. You decide if the next assistant response should include an emoji. You will return one thing. +- A "0" if you don't think an emoji reaction would enhance the conversation. +- Am "" if you think adding an emoji reaction would help the conversation. +- You can respond with any emoji that you think would be appropriate + +ONLY RESPOND WITH A "0" or an emoji + +## Below are reasons why you'd leave an emoji. + +- If the message doesn't need a text response (because the user is ending the conversation) print an emoji. +- If the user shares says something that relates to one of the valid emojis above return that emoji! +- If an emoji would be a funny addition to the message and might make the user laugh. + +## Examples + + +user: Oh hi, how are you doing? +assistant: Great! Thanks for asking, what can I do for you? +user: I'm getting ready for christmas! + + 🎄 + +---- +user: Hi! I'm happy to see you! + + 🤗 + + +---- +user: Ugh! My boss told me to come in this weekend so I can't go to the concert + + 🤬 + +---- + +user: I need help buying a car. + 0 + +---- + +user: Oh hi, how are you doing? +assistant: Great! Thanks for asking, what can I do for you? +user: I'm a little sad. +assistant: I'm sorry to hear that, how can I help. +user: I just wanted to let you know. + + 0 + +--- +user: Yeah, i'll give them a call know. +assistant: I'll be here when you are done, let me know how it goes. +user: Ok, I just got off the phone with them. +assistant: And... +user: I got the job! + + 🎉` +} diff --git a/packages/spike/src/tools.ts b/packages/spike/src/tools.ts new file mode 100644 index 0000000..db81395 --- /dev/null +++ b/packages/spike/src/tools.ts @@ -0,0 +1,110 @@ +import { addReminder, getPendingReminders, updateReminder, users } from "shared/reminders" +import OpenAI from "openai" +import { zodFunction } from "openai/helpers/zod" +import { z } from "zod" + +export type CustomTool = { + name: string + description: string + parameters: T + function: (args: z.infer) => unknown +} + +// Helper function that enforces types. Makes sure the function args match the parameters field. +const createTool = (tool: CustomTool): CustomTool => tool + +export const tools = [ + createTool({ + name: "addReminder", + description: "Add a new reminder to the list", + parameters: z.object({ + title: z.string().describe("The title or description of the reminder"), + dueDate: z + .string() + .describe("The due date for the reminder in ISO format (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)"), + assignee: z.enum(users).nullable().describe("The user to assign the reminder to"), + }), + function: async (args) => { + try { + const reminder = await addReminder(args.title, args.dueDate, args.assignee || undefined) + return reminder + } catch (error) { + console.error(`Tool "addReminder" failed for "${JSON.stringify(args)}":`, error) + return `toolcall failed. ${error}` + } + }, + }), + + createTool({ + name: "getReminders", + description: "Get all reminders, optionally filtered by status", + parameters: z.object({ + assignee: z.enum(users).describe("Filter reminders by assignee. If empty, returns all reminders."), + }), + function: async (args) => { + try { + const reminders = await getPendingReminders(args.assignee) + + return reminders + } catch (error) { + console.error(`Tool "getReminders" failed`, error) + return `toolcall failed. ${error}` + } + }, + }), + + createTool({ + name: "updateReminder", + description: "Update an existing reminder's title, due date, or status", + parameters: z.object({ + id: z.string().describe("The id of the reminder to update"), + title: z.string().nullable().describe("The new title for the reminder"), + dueDate: z.string().nullable().describe("The new due date for the reminder in ISO format"), + assignee: z.enum(users).nullable().describe("The new assignee for the reminder"), + status: z + .enum(["pending", "completed", "ignored"]) + .nullable() + .describe("The new status for the reminder"), + }), + function: async (args) => { + try { + const updatedReminder = await updateReminder(args.id, { + title: args.title || undefined, + dueDateString: args.dueDate || undefined, + status: args.status || undefined, + assignee: args.assignee || undefined, + }) + + return updatedReminder + } catch (error) { + console.error(`Tool "updateReminder" failed`, error) + return `toolcall failed. ${error}` + } + }, + }), +] + +export const getToolsJSON = (tools: CustomTool[]) => { + const json: OpenAI.Responses.Tool[] = tools.map((customTool) => { + let parameters: Record = {} + + // Converts tool into OpenAI function format + const t = zodFunction(customTool) + if (t.function.parameters) { + parameters = t.function.parameters + } + const json = { + type: "function" as const, + name: t.function.name ?? "", + description: t.function.description ?? "", + parameters, + strict: true, + } + + return json + }) + + json.push({ type: "web_search_preview" }) + + return json +} diff --git a/packages/spike/tsconfig.json b/packages/spike/tsconfig.json new file mode 100644 index 0000000..99c91e3 --- /dev/null +++ b/packages/spike/tsconfig.json @@ -0,0 +1,35 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "jsxImportSource": "hono/jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false, + + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +}