This commit is contained in:
Corey Johnson 2025-06-16 09:27:35 -07:00
commit 1b8b68a572
42 changed files with 1903 additions and 0 deletions

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

36
.gitignore vendored Normal file
View File

@ -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

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"cSpell.words": ["luxon"]
}

3
README.md Normal file
View File

@ -0,0 +1,3 @@
# The Rabbit Hole
We'll figure out what this is later.

140
bun.lock Normal file
View File

@ -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=="],
}
}

16
package.json Normal file
View File

@ -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"
}
}

3
packages/http/README.md Normal file
View File

@ -0,0 +1,3 @@
# Nano Remix (BETTER NAME NEEDED)
- You'll want to add `.nano-remix` to your `.gitignore` file.

View File

@ -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 (
<div class="mb-8">
<h2 class="text-xl font-medium text-blue-800 mb-4">Create a Reminder</h2>
<Form name="create-reminder" class="bg-white rounded-lg shadow p-6 max-w-lg" method="POST">
<input type="hidden" name="_action" value="create" />
{props.error && (
<div class="mb-4 p-3 bg-red-50 border border-red-200 text-red-800 rounded">{props.error}</div>
)}
{props.success && (
<div class="mb-4 p-3 bg-green-50 border border-green-200 text-green-800 rounded">
Reminder created successfully!
</div>
)}
<div class="mb-4">
<label class="block text-gray-700 mb-1" for="title">
Title
</label>
<input
id="title"
name="title"
type="text"
class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:border-blue-500"
required
/>
</div>
<div class="mb-4">
<label class="block text-gray-700 mb-1" for="dueDate">
Due Date
</label>
<input
id="dueDate"
name="dueDate"
type="datetime-local"
class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:border-blue-500"
required
/>
</div>
<div class="mb-6">
<label class="block text-gray-700 mb-1" for="assignee">
Assignee
</label>
<select
id="assignee"
name="assignee"
class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:border-blue-500"
>
<option value="">-- Unassigned --</option>
{users.map((user) => (
<option key={user} value={user}>
{user}
</option>
))}
</select>
</div>
<button
type="submit"
class="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
disabled={props.loading}
>
{props.loading ? "Creating..." : "Create Reminder"}
</button>
</Form>
</div>
)
}

26
packages/http/logo.svg Normal file
View File

@ -0,0 +1,26 @@
<?xml version="1.0" ?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<defs>
<style>.cls-1{fill:#8c6239;}.cls-1,.cls-3{stroke:#42210b;}.cls-1,.cls-2,.cls-3,.cls-4{stroke-miterlimit:10;stroke-width:2px;}.cls-2{fill:#22b573;}.cls-2,.cls-4{stroke:#004924;}.cls-3{fill:#c69c6d;}.cls-4{fill:none;stroke-linecap:round;}.cls-5{fill:#33d186;}</style>
</defs>
<title/>
<g data-name="Layer 5" id="Layer_5">
<g data-name="Layer 17" id="Layer_17">
<polygon class="cls-1" points="44.3 54.13 19.7 54.13 16.14 44.57 47.86 44.57 44.3 54.13"/>
<path class="cls-2" d="M41.33,39.38H22.67L20.46,28C18.83,19.64,23.34,10.88,31,10.88h2c7.66,0,12.17,8.77,10.54,17.16Z"/>
<rect class="cls-3" height="5.38" rx="1.9" ry="1.9" width="38.25" x="12.88" y="39.25"/>
<line class="cls-4" x1="25.69" x2="21.56" y1="13.63" y2="9.88"/>
<line class="cls-4" x1="37.87" x2="42.38" y1="15.55" y2="11.45"/>
<line class="cls-4" x1="22.69" x2="18.56" y1="23.63" y2="19.88"/>
<line class="cls-4" x1="23.69" x2="19.56" y1="35.63" y2="31.88"/>
<line class="cls-4" x1="42.37" x2="46.5" y1="27.35" y2="23.03"/>
<line class="cls-4" x1="40.75" x2="46.12" y1="34.5" y2="31.88"/>
<circle class="cls-5" cx="36.75" cy="22.13" r="1.75"/>
<circle class="cls-5" cx="29.75" cy="22.13" r="1.75"/>
<circle class="cls-5" cx="30.75" cy="16.13" r="1.75"/>
<circle class="cls-5" cx="34.75" cy="31.13" r="1.75"/>
<circle class="cls-5" cx="26.75" cy="30.13" r="1.75"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -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"
}
}

View File

@ -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<typeof loader>) => {
const { data, loading, error } = useAction()
return (
<div class="bg-blue-50 min-h-screen pb-8">
<h1 class="p-4 text-2xl bg-blue-100 text-blue-800">Reminders</h1>
<main class="p-4">
<div>
<h2 class="text-xl font-medium text-blue-800 mb-4">Current Reminders</h2>
<Reminders reminders={props.reminders} />
</div>
<div class="mt-8">
<CreateReminder loading={loading} success={data?.success} error={error ?? data?.error} />
</div>
</main>
</div>
)
}
const Reminders = ({ reminders }: { reminders: Reminder[] }) => {
return (
<>
{reminders.length === 0 ? (
<p class="text-gray-600">No reminders found.</p>
) : (
<ul class="bg-white rounded-lg shadow overflow-hidden divide-y divide-gray-200">
{reminders.map((reminder) => (
<li key={reminder.id} class="p-4 hover:bg-gray-50">
<div class="flex justify-between items-start">
<div>
<div class="font-medium">{reminder.title}</div>
<div class="text-sm text-gray-500 flex justify-between mt-1">
<span>Due: {new Date(reminder.dueDate).toLocaleString()}</span>
{reminder.assignee && (
<span class="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded ml-2">
{reminder.assignee}
</span>
)}
</div>
</div>
<Form method="POST" class="inline-block">
<input type="hidden" name="_action" value="delete" />
<input type="hidden" name="id" value={reminder.id} />
<button
type="submit"
class="text-red-600 hover:text-red-800 text-sm font-medium"
title="Delete Reminder"
>
Delete
</button>
</Form>
</div>
</li>
))}
</ul>
)}
</>
)
}

14
packages/http/server.tsx Normal file
View File

@ -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,
},
})

View File

@ -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/*"]
}
}
}

34
packages/nano-remix/.gitignore vendored Normal file
View File

@ -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

View File

@ -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.

View File

@ -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"
}
}

View File

@ -0,0 +1,95 @@
import { type FC, useEffect, useState, type JSX } from "hono/jsx"
import type { Action } from "@/main"
type ActionData<T extends (...args: any) => Promise<any>> = Exclude<Awaited<ReturnType<T>>, 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 = <D,>(Component: FC<D>) => {
const WrappedComponent = () => {
const [loaderData, setLoaderData] = useState(() => {
const loaderDataString = document.getElementById("__LOADER_DATA__")?.textContent
return JSON.parse(loaderDataString || "{}")
})
useEffect(() => {
window._setLoaderData = setLoaderData
}, [])
return <Component {...loaderData} />
}
return WrappedComponent
}
// Hook to access action result on the client
export const useAction = <TAction extends Action>() => {
const [data, setData] = useState<ActionData<TAction> | undefined>()
const [status, setStatus] = useState("idle")
const [error, setError] = useState<string>()
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 (
<form {...props} onSubmit={handleSubmit}>
{props.children}
</form>
)
}

View File

@ -0,0 +1,15 @@
import { nanoRemix } from "@/nanoRemix"
import { Form, useAction, wrapComponentWithLoader } from "@/clientHelpers"
export { Form, useAction, wrapComponentWithLoader }
export { nanoRemix }
export type Loader<Data extends object> = (req: Request) => Promise<Data> | Data
export type LoaderProps<T> = T extends Loader<infer R> ? Awaited<R> : never
export type Action<Data = unknown> = (req: Request) => Promise<Response | Data>
export type Head = {
title?: string
links?: { rel: string; href: string; attributes?: string }[]
scripts?: { src: string; type?: boolean }[]
}

View File

@ -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(<WrappedComponent />, 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
}

View File

@ -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<any>
}
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(/</g, "\\u003c")
return new Response(
`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="${routeName + ".css"}" />
<title>${component.head?.title ?? "Untitled"}</title>
${
component.head?.links
?.map((link: any) => `<link rel="${link.rel}" href="${link.href}" ${link.attributes || ""}>`)
.join("\n") || ""
}
${
component.head?.scripts
?.map(
(script: any) =>
`<script src="${script.src}" ${script.type ? `type="${script.type}"` : ""}></script>`
)
.join("\n") || ""
}
<script id="__LOADER_DATA__" type="application/json">${escapedLoaderData}</script>
</head>
<body>
<div id="root">${component.default(loaderData)}</div>
<script type="module" src="${routeName + ".js"}"></script>
</body>
</html>`,
{
headers: {
"Content-Type": "text/html; charset=utf-8",
},
}
)
}

View File

@ -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/*"]
}
}
}

34
packages/shared/.gitignore vendored Normal file
View File

@ -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

15
packages/shared/README.md Normal file
View File

@ -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.

View File

@ -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"
}
]
}

1
packages/shared/main.ts Normal file
View File

@ -0,0 +1 @@
console.log("Hello via Bun!");

View File

@ -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"
}
}

90
packages/shared/src/kv.ts Normal file
View File

@ -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<string, string> // threadId: previousResponseId
reminders: Reminder[]
}
const version = 10
const set = async <T extends keyof Keys>(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 <T extends keyof Keys>(
key: T,
defaultValue: Keys[T],
updateFn: (prev: Keys[T]) => Promise<Keys[T]> | 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 <T extends keyof Keys>(key: T, defaultValue?: Keys[T]): Promise<Keys[T]> => {
try {
const store = readStore()
return store[`${key}.${version}`] ?? defaultValue
} catch (error) {
console.error(`Error retrieving key "${key}":`, error)
throw error
}
}
const remove = <T extends keyof Keys>(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<string, any> => {
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<string, any>): 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 }

View File

@ -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`)
}

View File

@ -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
}

View File

@ -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 = <T>(array: T[]): T => {
const randomIndex = Math.floor(Math.random() * array.length)
return array[randomIndex]!
}
export const zone = "America/Los_Angeles"

View File

@ -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/*"]
}
}
}

15
packages/spike/README.md Normal file
View File

@ -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.

View File

@ -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"
}
}

162
packages/spike/src/ai.ts Normal file
View File

@ -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<any>[]
toolResponseCallback?: (tool: CustomTool<any>) => 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<any>[]): 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")}`
}

View File

@ -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)
}

View File

@ -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(
`<html>
<body>
<h1>Authenticate spike</h1>
<a href="https://discord.com/oauth2/authorize?client_id=${process.env.DISCORD_CLIENT_ID}&scope=bot&permissions=0">Authorize</a>
</body>
</html>`,
{
headers: {
"Content-Type": "text/html",
},
}
)
},
},
},
})
console.log(`Server is running on ${server.url}`)
}

View File

@ -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<Message>
let history: Record<string, Message[]> = {}
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)
}
}

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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. Dont end your turn until youre 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 youre unsure about any part of the users request, use the tools available to find the right information. Dont 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 (12 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 rigorouslyeven 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 "<emoji>" 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!
<response> 🎄
----
user: Hi! I'm happy to see you!
<response> 🤗
----
user: Ugh! My boss told me to come in this weekend so I can't go to the concert
<response> 🤬
----
user: I need help buying a car.
<response> 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.
<response> 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!
<response> 🎉`
}

110
packages/spike/src/tools.ts Normal file
View File

@ -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<T extends z.ZodTypeAny> = {
name: string
description: string
parameters: T
function: (args: z.infer<T>) => unknown
}
// Helper function that enforces types. Makes sure the function args match the parameters field.
const createTool = <T extends z.ZodTypeAny>(tool: CustomTool<T>): CustomTool<T> => 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 = <T extends z.ZodTypeAny>(tools: CustomTool<T>[]) => {
const json: OpenAI.Responses.Tool[] = tools.map((customTool) => {
let parameters: Record<string, unknown> = {}
// 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
}

View File

@ -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/*"]
}
}
}