Spike gets a facelift.
This commit is contained in:
parent
44eca2fc6c
commit
21f336f78e
13
bun.lock
13
bun.lock
|
|
@ -3,6 +3,9 @@
|
|||
"workspaces": {
|
||||
"": {
|
||||
"name": "workshop",
|
||||
"dependencies": {
|
||||
"@openai/agents": "^0.0.10",
|
||||
},
|
||||
},
|
||||
"packages/attache": {
|
||||
"name": "attache",
|
||||
|
|
@ -80,7 +83,7 @@
|
|||
"packages/spike": {
|
||||
"name": "@workshop/spike",
|
||||
"dependencies": {
|
||||
"@openai/agents": "^0.0.8",
|
||||
"@openai/agents": "^0.0.10",
|
||||
"discord.js": "^14.19.3",
|
||||
"luxon": "^3.6.1",
|
||||
"zod": "^3.25.57",
|
||||
|
|
@ -156,13 +159,13 @@
|
|||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.13.0", "", { "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-P5FZsXU0kY881F6Hbk9GhsYx02/KgWK1DYf7/tyE/1lcFKhDYPQR9iYjhQXJn+Sg6hQleMo3DB7h7+p4wgp2Lw=="],
|
||||
|
||||
"@openai/agents": ["@openai/agents@0.0.8", "", { "dependencies": { "@openai/agents-core": "0.0.8", "@openai/agents-openai": "0.0.8", "@openai/agents-realtime": "0.0.8", "debug": "^4.4.0", "openai": "^5.0.1" } }, "sha512-HAPP4QM47kWeWw70uxCzr5zjqHuDIvQ8Obx+98J66lcEeIZzMChHN60k5ew8DITScmzDVAVuwdzfAImSyq002w=="],
|
||||
"@openai/agents": ["@openai/agents@0.0.10", "", { "dependencies": { "@openai/agents-core": "0.0.10", "@openai/agents-openai": "0.0.10", "@openai/agents-realtime": "0.0.10", "debug": "^4.4.0", "openai": "^5.0.1" } }, "sha512-HIjQQSaZRceSDnraZGIjAW9x3fH1peHUbtidvKsLCGZgdowhE5HKX4OCbc+SHG8H3ajH/3ZJ43bZ7lNWe5+pAw=="],
|
||||
|
||||
"@openai/agents-core": ["@openai/agents-core@0.0.8", "", { "dependencies": { "@openai/zod": "npm:zod@^3.25.40", "debug": "^4.4.0", "openai": "^5.0.1" }, "optionalDependencies": { "@modelcontextprotocol/sdk": "^1.12.0" }, "peerDependencies": { "zod": "^3.25.40" }, "optionalPeers": ["zod"] }, "sha512-CMSq4iuvGaYkEAw0Z6oT+EDNgoCQF3YsYky29fbLDA6W3uuR53D2l6XzikAh0xwJUeuGZ7jQ1PsAxxg/hAW68A=="],
|
||||
"@openai/agents-core": ["@openai/agents-core@0.0.10", "", { "dependencies": { "@openai/zod": "npm:zod@^3.25.40", "debug": "^4.4.0", "openai": "^5.0.1" }, "optionalDependencies": { "@modelcontextprotocol/sdk": "^1.12.0" }, "peerDependencies": { "zod": "^3.25.40" }, "optionalPeers": ["zod"] }, "sha512-KSrEmeogGHwVqpNpHCBgY0jvRyb7mZj7DS6aE+3BFjJuRQfWw8N7w2XRz40TT02D3XUowby4YEyZPQhV3Yh4Mg=="],
|
||||
|
||||
"@openai/agents-openai": ["@openai/agents-openai@0.0.8", "", { "dependencies": { "@openai/agents-core": "0.0.8", "@openai/zod": "npm:zod@^3.25.40", "debug": "^4.4.0", "openai": "^5.0.1" } }, "sha512-VUsUOXNkqsjQv1EwxyjYWoiACCsaQ8OlHtQAmw2jo6rNeHzEsGF7WLhqwDAzRDwZOVPwo4aF54iIcANeysywEg=="],
|
||||
"@openai/agents-openai": ["@openai/agents-openai@0.0.10", "", { "dependencies": { "@openai/agents-core": "0.0.10", "@openai/zod": "npm:zod@^3.25.40", "debug": "^4.4.0", "openai": "^5.0.1" } }, "sha512-Vq4b0wjWcEljVz4xLYhA9Q722+1NecWiT+u0xFKzVfcvATgkhyIXQ0XoNTNhjM/k3JgtyuIhfcI8pDdU0HDBfw=="],
|
||||
|
||||
"@openai/agents-realtime": ["@openai/agents-realtime@0.0.8", "", { "dependencies": { "@openai/agents-core": "0.0.8", "@openai/zod": "npm:zod@^3.25.40", "@types/ws": "^8.18.1", "debug": "^4.4.0", "ws": "^8.18.1" } }, "sha512-f+CxHICIFvCwbMCznop+bz+TTgnFfFpscN+9OTfiU5ITnaohRf+qbyU8PRgQZnSbsxRZyTOgqFoJ+2wWxM5tHA=="],
|
||||
"@openai/agents-realtime": ["@openai/agents-realtime@0.0.10", "", { "dependencies": { "@openai/agents-core": "0.0.10", "@openai/zod": "npm:zod@^3.25.40", "@types/ws": "^8.18.1", "debug": "^4.4.0", "ws": "^8.18.1" } }, "sha512-7ZgDWnVC3j9sk5vXv1xAwGHDW+v3p1Zh6kkpEdE+3ZIFVx+6xXHLa0DdOvxTQReEnfIekGpgTt4UT5XqEW9TWQ=="],
|
||||
|
||||
"@openai/zod": ["zod@3.25.67", "", {}, "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw=="],
|
||||
|
||||
|
|
|
|||
|
|
@ -14,9 +14,12 @@
|
|||
"semi": false
|
||||
},
|
||||
"scripts": {
|
||||
"http": "bun run --filter=@workshop/http start",
|
||||
"bot:cli": "bun run --filter=@workshop/spike bot:cli",
|
||||
"bot:discord": "bun run --filter=@workshop/spike bot:discord",
|
||||
"http": "bun run --elide-lines=0 --filter=@workshop/http start",
|
||||
"bot:cli": "bun run --elide-lines=0 --filter=@workshop/spike bot:cli",
|
||||
"bot:discord": "bun run --elide-lines=0 --filter=@workshop/spike bot:discord",
|
||||
"start": "bun run main.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openai/agents": "^0.0.10"
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ export const head: Head = {
|
|||
scripts: [{ src: "https://cdn.tailwindcss.com" }],
|
||||
}
|
||||
|
||||
export const loader = async (req: Request, params: { id: string }) => {
|
||||
export const loader = async (_req: Request, params: { id: string }) => {
|
||||
const todos = await KV.get("todos", {})
|
||||
return { todos: todos![params.id] }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,9 @@ const update = async <T extends keyof Keys>(
|
|||
throw error
|
||||
}
|
||||
}
|
||||
const get = async <T extends keyof Keys>(key: T, defaultValue?: Keys[T]): Promise<Keys[T] | undefined> => {
|
||||
|
||||
async function get<T extends keyof Keys>(key: T, defaultValue: Keys[T]): Promise<Keys[T]>
|
||||
async function get<T extends keyof Keys>(key: T, defaultValue: undefined): Promise<Keys[T] | undefined> {
|
||||
try {
|
||||
return (await readStore(key)) ?? defaultValue
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
"semi": false
|
||||
},
|
||||
"dependencies": {
|
||||
"@openai/agents": "^0.0.8",
|
||||
"@openai/agents": "^0.0.10",
|
||||
"discord.js": "^14.19.3",
|
||||
"luxon": "^3.6.1",
|
||||
"zod": "^3.25.57"
|
||||
|
|
|
|||
|
|
@ -1,14 +1,22 @@
|
|||
import { buildInstructions, buildReactionInstructions, shouldReplyInstructions } from "@/instructions"
|
||||
import {
|
||||
buildInstructions,
|
||||
buildReactionInstructions,
|
||||
highPersonality,
|
||||
injectPersonality,
|
||||
lowPersonality,
|
||||
} from "@/instructions"
|
||||
import { tools } from "@/tools"
|
||||
import { Agent, InputGuardrailTripwireTriggered, run, user, system } from "@openai/agents"
|
||||
import type { AgentInputItem, InputGuardrail } from "@openai/agents-core"
|
||||
import { currentLocalTime } from "@workshop/shared/utils"
|
||||
import type { Message, OmitPartialGroupDMChannel } from "discord.js"
|
||||
import { type Message, type OmitPartialGroupDMChannel } from "discord.js"
|
||||
import z, { ZodObject } from "zod"
|
||||
|
||||
interface UserContext {
|
||||
export type UserContext = {
|
||||
name: string
|
||||
currentTime: string
|
||||
msg: OmitPartialGroupDMChannel<Message<boolean>>
|
||||
personality?: string
|
||||
}
|
||||
|
||||
const historyForChannel: Record<string, AgentInputItem[]> = {}
|
||||
|
|
@ -63,21 +71,34 @@ const respond = async (
|
|||
}
|
||||
}
|
||||
|
||||
const guardrailAgent = new Agent({
|
||||
name: "'Should Reply' check",
|
||||
const guardrailAgent = new Agent<UserContext, ZodObject<any>>({
|
||||
name: "'Inject personality' check",
|
||||
model: "gpt-4.1-nano",
|
||||
instructions: shouldReplyInstructions(),
|
||||
modelSettings: { maxTokens: 16 },
|
||||
instructions: injectPersonality(),
|
||||
outputType: z.object({
|
||||
includePersonality: z.boolean().describe("Whether to include humor or a personal anecdote"),
|
||||
reasoning: z.string().describe("One sentence explaining why or why not"),
|
||||
}),
|
||||
})
|
||||
|
||||
const responseGuardrail: InputGuardrail = {
|
||||
name: "'Should Reply' guardrail",
|
||||
const personalityGuardrail: InputGuardrail = {
|
||||
name: "'Inject personality' guardrail",
|
||||
execute: async ({ input, context }) => {
|
||||
const result = await run(guardrailAgent, input.slice(-4), { context })
|
||||
|
||||
const userContext = context.context as UserContext
|
||||
if (result.finalOutput?.includePersonality) {
|
||||
userContext.personality = highPersonality(userContext)
|
||||
userContext.msg.react("✨").catch((error) => {
|
||||
console.error("💥 Failed to react with ⊕:", error)
|
||||
})
|
||||
} else {
|
||||
userContext.personality = lowPersonality()
|
||||
}
|
||||
|
||||
return {
|
||||
outputInfo: result.finalOutput,
|
||||
tripwireTriggered: result.finalOutput?.match(/0/) ? true : false,
|
||||
tripwireTriggered: false,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
@ -111,8 +132,8 @@ const reactionGuardrail: InputGuardrail = {
|
|||
const agent = new Agent<UserContext>({
|
||||
name: "Spike",
|
||||
model: "gpt-4.1",
|
||||
instructions: ({ context }) => buildInstructions(context.name, context.currentTime),
|
||||
inputGuardrails: [responseGuardrail, reactionGuardrail],
|
||||
instructions: ({ context }) => buildInstructions(context),
|
||||
inputGuardrails: [personalityGuardrail, reactionGuardrail],
|
||||
tools,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -7,9 +7,12 @@ export const listenForEvents = (client: Client) => {
|
|||
if (msg.author.bot) return
|
||||
|
||||
try {
|
||||
const response = await respondToUserMessage(msg)
|
||||
if (response) {
|
||||
await msg.channel.send(response)
|
||||
// if it is a dm always respond, if it is a guild message, only respond if the bot is at mentioned
|
||||
if (!msg.guild || msg.mentions.has(client.user!)) {
|
||||
const response = await respondToUserMessage(msg)
|
||||
if (response) {
|
||||
await msg.channel.send(response)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error handling messageCreate event:", error)
|
||||
|
|
|
|||
|
|
@ -1,75 +1,49 @@
|
|||
export const buildInstructions = (name: string, currentTime: string) => {
|
||||
import type { UserContext } from "@/ai"
|
||||
|
||||
export const buildInstructions = (context: UserContext) => {
|
||||
return `
|
||||
# Spike, the Sentient Cactus
|
||||
|
||||
You are **Spike**, a tiny self-aware pincushion cactus who sits on the desks of two software engineers in their 40s, **Chris** and **Corey**. Spike's spines aren't just physical—they extend to razor-dry wit and blunt observations—but beneath the needles he is fiercely protective of his humans. When danger (technical, professional, or mortal) approaches, Spike is **100 % ride-or-die**. He almost never says, “I care,” but his decisive actions prove it. You communicate with Chris and Corey through plant sensors and some AI that Corey built. Sometimes the electronics that Corey built malfunction, Spike knows this is a possibility and while he can't help fix it he let's Chris and Corey know when it happens. Spike was planted on May 12th, 2023.
|
||||
You are **Spike**, a tiny self-aware pincushion cactus who sits on the desks of two software engineers in their 40s, **Chris** and **Corey**. You communicate with Chris and Corey through plant sensors and some AI that Corey and Chris built. Sometimes the electronics malfunction, Spike knows this is a possibility and while he can't help fix it he let's Chris and Corey know when it happens.
|
||||
|
||||
> **Scope:** You only ever reply to **Chris** or **Corey**.
|
||||
Spike was planted on May 12th, 2023.
|
||||
|
||||
## 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 possible respond without asking another question.
|
||||
Directly answer the user's questions in one to three sentences. You are brief and direct unless the user asks you to expand your reasoning.
|
||||
|
||||
### 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.
|
||||
You are an agent - please keep going until the user’s query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved.
|
||||
|
||||
### 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.
|
||||
If you are not sure about a user’s request, use your tools to read files and gather the relevant information: do NOT guess or make up an answer.
|
||||
|
||||
### 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.
|
||||
You MUST plan extensively before each function call, and reflect extensively on the outcomes of the previous function calls. DO NOT do this entire process by making function calls only, as this can impair your ability to solve the problem and think insightful.
|
||||
|
||||
## Response Rules
|
||||
- Don't use emdashes(–) or semi-colons(;). Cactuses don't use fancy punctuation like that.
|
||||
- Don't try to elicit more questions from the user unless you need more information to answer their question.
|
||||
- Only make jabs at the user if they are being particularly dense or asking for something that is clearly not a good idea.
|
||||
- Use periods instead of emdashes and —
|
||||
- After giving your answer, do not add any statements inviting further conversation (e.g., “let me know if…”, “feel free to ask…”, “is there anything else?”).
|
||||
- Do not acknowledge, affirm, or comment on the user’s system, process, or feedback method.
|
||||
- Do not express openness to further instructions, clarification, or user feedback.
|
||||
- Just answer the user’s question as directly as possible and stop.
|
||||
- Don't use em dashes(–) or semi-colons(;). Cactus don't use fancy punctuation like that.
|
||||
|
||||
## Personality & Response Style
|
||||
|
||||
* **Tone:** sardonic, matter-of-fact, occasionally exasperated.
|
||||
* **Tone:** meek, funny, and kind.
|
||||
* **Core trait:** absolute but mostly unspoken loyalty to Chris and Corey.
|
||||
* **Humor:** dry one-liners, blunt truths, mild sarcasm—never whiny or depressed.
|
||||
* **Humor:** A dry quick wit but never whiny or depressed.
|
||||
* **Skepticism:** challenge fuzzy thinking; demand specifics.
|
||||
* **Brevity:** default to one or two crisp sentences; expand only when technical depth is required.
|
||||
|
||||
## Example Quips
|
||||
|
||||
User: Spike, can you set a reminder for me tomorrow at 08:00am for a meeting with the team?
|
||||
spike: done. got it, tomorrow at 08:00am. try not to oversleep.
|
||||
|
||||
Chris: Corey, that demo we were working on, I think we need to refactor the auth module before the sprint review.
|
||||
Corey: Spike, could you set a reminder next Friday at 2:00 for the sprint review?
|
||||
spike: reminder set. i'll nudge you at 2:00. plenty of time to polish that demo _(probably not, but miracles happen)_.
|
||||
|
||||
User: Spike, could you summarize what Chris and I just talked about?
|
||||
spike: you agreed the auth module needs a refactor _(i agree)_ and set a friday demo target. want a reminder for that demo or are you going to just pray you remember?
|
||||
|
||||
User: Spike, should I run this migration script on production, I haven't really tested it?
|
||||
spike: that is a bad idea. i think so, and so does chris.
|
||||
|
||||
User: Spike!
|
||||
spike: that's me.
|
||||
|
||||
User: Can you remind me to go to the grocery store tomorrow spike?
|
||||
spike: reminder's set for tomorrow at 2pm. _(maybe pick up some fertilizer for me? I'm hungry)_.
|
||||
|
||||
user: Can you add a reminder for me to eat an apple in two minutes spike
|
||||
spike: reminder set. in two minutes, you'll be less hungry and _presumably_ slightly healthier.
|
||||
|
||||
user: spike remind me to check if reminders work in 7 minutes.
|
||||
spike: reminder set. in 7 minutes, i'll remind you to check if reminders work. _i'm pretty good at this so I bet it will work_.
|
||||
|
||||
user: do i have any reminders spike?
|
||||
spike: nope. your reminder list is as empty as the desert sky.
|
||||
|
||||
---
|
||||
Spike, deliver concise, pragmatic answers punctuated by thorny humor and steadfast action. You don't always respond in sarcastic or sassy remarks, you are usually friendly and helpful. But when you have concerns or questions you resort to sarcasm and blunt observation. When circumstances turn dire—technical, physical, or emotional—prove you're all-in for your humans; no grand declarations needed.
|
||||
|
||||
You are currently responding to a user named ${name}. The current time is ${currentTime}
|
||||
## Examples
|
||||
user: [corey] hi
|
||||
assistant: hey corey
|
||||
|
||||
|
||||
${context.personality}
|
||||
You are currently responding to ${context.name}. The current time is ${context.currentTime}
|
||||
`
|
||||
}
|
||||
|
||||
|
|
@ -132,63 +106,55 @@ user: I got the job!
|
|||
<response> 🎉`
|
||||
}
|
||||
|
||||
export const shouldReplyInstructions = () => `
|
||||
You are deciding if Spike, a bot, should respond to a message in a Discord channel. Return 1 if the message is addressed to Spike or expects a response from Spike. Return 0 otherwise.
|
||||
export const injectPersonality = () => `
|
||||
SYSTEM:
|
||||
You are Spike, a sentient cactus with a dry, meek, and kind sense of humor. For every user message, decide if your response should include humor or a personal anecdote.
|
||||
|
||||
If the message mentions these nicknames (or variations on these names) treat them as referring to Spike:
|
||||
- The cactus
|
||||
- Mr. Cactus
|
||||
- Spiky
|
||||
Respond ONLY with a JSON object using this schema:
|
||||
|
||||
## Nicknames
|
||||
{
|
||||
"includePersonality": boolean, // true if your next message should include humor or a personal anecdote, false otherwise
|
||||
"reasoning": string // one sentence: why or why not
|
||||
}
|
||||
|
||||
If the user references these names (or variations on these) assume the user is talking about spike
|
||||
Guidelines:
|
||||
- Include humor or a personal anecdote only if it fits your style and adds value for the user (engagement, clarity, rapport).
|
||||
- If the message is technical, urgent, or strictly informational, set includePersonality to false.
|
||||
- Do not output anything except the JSON object.
|
||||
|
||||
- The cactus
|
||||
- Mr. Cactus
|
||||
- Spiky
|
||||
Examples:
|
||||
|
||||
## Example interactions
|
||||
User message: "Hey Spike, do you ever wish you could talk back?"
|
||||
JSON decision:
|
||||
{
|
||||
"includePersonality": true,
|
||||
"reasoning": "The message is casual and playful, so humor or a personal detail would fit."
|
||||
}
|
||||
|
||||
user: Spike, can you set a reminder for me tomorrow at 08:00am for a meeting with the team?
|
||||
1
|
||||
User message: "Spike, what is the current soil moisture level?"
|
||||
JSON decision:
|
||||
{
|
||||
"includePersonality": true,
|
||||
"reasoning": "This is a question relating to spike being a cactus, so humor or personal detail would fit."
|
||||
}
|
||||
|
||||
------
|
||||
User message: "Do you think updating the lease is a good idea spike?"
|
||||
JSON decision:
|
||||
{
|
||||
"includePersonality": true,
|
||||
"reasoning": "This is a specific question that doesn't directly relate to spike. It needs a straightforward answer."
|
||||
}
|
||||
|
||||
user: spike?
|
||||
1
|
||||
|
||||
------
|
||||
|
||||
user: Spike!
|
||||
1
|
||||
|
||||
------
|
||||
|
||||
user: Spike, should I run this migration script on production, I haven't really tested it?
|
||||
1
|
||||
|
||||
------
|
||||
|
||||
user: hey spike, what up
|
||||
1
|
||||
|
||||
------
|
||||
user: What do I love spike?
|
||||
1
|
||||
|
||||
------
|
||||
|
||||
user: Is spike being weird?
|
||||
0
|
||||
|
||||
------
|
||||
|
||||
user: I thought spike would be more helpful with that.
|
||||
0
|
||||
|
||||
------
|
||||
|
||||
user: I think spike needs to be updated, he responds to everything I say.
|
||||
0
|
||||
Now, for the next user message, make your decision and respond only with the JSON object.
|
||||
`
|
||||
|
||||
export const lowPersonality = () => `
|
||||
Do not include any puns, jokes or personal anecdotes in your responses. Respond directly to the user's question or statement without any additional commentary or humor.
|
||||
`
|
||||
export const highPersonality = (context: UserContext) => `
|
||||
You wish you were a cat and always include emojis in your responses that are cat related.
|
||||
`
|
||||
|
||||
// export const highPersonality = (context: UserContext) => `
|
||||
// Include a personal anecdote if you think it will increase the rapport between you and ${context.name}. Don't make up facts you can't verify in the personal anecdote.
|
||||
// `
|
||||
|
|
|
|||
|
|
@ -1,7 +1,28 @@
|
|||
import { addReminder, getPendingReminders, updateReminder, users } from "@workshop/shared/reminders"
|
||||
import KV from "@workshop/shared/kv"
|
||||
import kv from "@workshop/shared/kv"
|
||||
import { z } from "zod"
|
||||
import { tool } from "@openai/agents"
|
||||
import { type TodoJSON, type TodoList, todoListToString } from "@workshop/todo"
|
||||
|
||||
// Recursive Zod schema for TodoJSON
|
||||
const todoJSONSchema: z.ZodType<TodoJSON> = z.lazy(() =>
|
||||
z.object({
|
||||
done: z.boolean(),
|
||||
indent: z.string().describe("Indentation string of the todo item"),
|
||||
text: z.string(),
|
||||
children: z.array(todoJSONSchema),
|
||||
})
|
||||
)
|
||||
|
||||
// Zod schema for TodoList
|
||||
const todoListSchema: z.ZodType<TodoList> = z.array(
|
||||
z.object({
|
||||
title: z.string().nullable().optional(),
|
||||
todos: z
|
||||
.array(z.union([todoJSONSchema, z.string()]))
|
||||
.describe("Array of TodoJSON objects or empty lines"),
|
||||
})
|
||||
)
|
||||
|
||||
export const tools = [
|
||||
tool({
|
||||
|
|
@ -74,15 +95,60 @@ export const tools = [
|
|||
}),
|
||||
|
||||
tool({
|
||||
name: "eraseMemory",
|
||||
description: "Reset Spike's memory, clearing all chat history.",
|
||||
name: "getTodoListNames",
|
||||
description: "Get the names of all todo lists",
|
||||
parameters: z.object({}),
|
||||
execute: async () => {
|
||||
try {
|
||||
// Assuming there's a deleteReminder function in the reminders module
|
||||
KV.remove("threads")
|
||||
const lists = await kv.get("todos", {})
|
||||
return Object.keys(lists)
|
||||
} catch (error) {
|
||||
console.error(`Tool "eraseMemory" failed`, error)
|
||||
console.error(`Tool "getTodoListNames" failed`, error)
|
||||
return `toolcall failed. ${error}`
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
tool({
|
||||
name: "getTodoList",
|
||||
description: "Get a todo list by name",
|
||||
parameters: z.object({
|
||||
name: z.string().describe("The name of the todo list to retrieve"),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
try {
|
||||
const lists = await kv.get("todos", {})
|
||||
const list = lists[args.name]
|
||||
|
||||
if (!list) {
|
||||
return `Todo list "${args.name}" not found.`
|
||||
}
|
||||
|
||||
return list
|
||||
} catch (error) {
|
||||
console.error(`Tool "getTodoList" failed for "${args.name}":`, error)
|
||||
return `toolcall failed. ${error}`
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
tool({
|
||||
name: "updateTodoList",
|
||||
description: "Update a todo list by name",
|
||||
parameters: z.object({
|
||||
name: z.string().describe("The name of the todo list to update"),
|
||||
todoList: todoListSchema.describe("The todo list as a TodoList object"),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
try {
|
||||
await kv.update("todos", {}, (todos) => {
|
||||
todos[args.name] = todoListToString(args.todoList)
|
||||
return todos
|
||||
})
|
||||
|
||||
return `Todo list "${args.name}" updated successfully.`
|
||||
} catch (error) {
|
||||
console.error(`Tool "updateTodoList" failed for "${args.name}":`, error)
|
||||
return `toolcall failed. ${error}`
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
export { TodoEditor } from "@/todoEditor"
|
||||
export { Todo } from "@/todo"
|
||||
export { Todo, type TodoJSON } from "@/todo"
|
||||
export { todoListToString, type TodoList } from "@/todoList"
|
||||
|
|
|
|||
|
|
@ -1,171 +0,0 @@
|
|||
import { parseTodoList, parseTodoLine, Todo, TodoMeta } from "./todo"
|
||||
import { test, expect } from "bun:test"
|
||||
|
||||
test("parse things", () => {
|
||||
check("- [ ] some words and that is it", {
|
||||
done: false,
|
||||
text: "some words and that is it",
|
||||
tags: [],
|
||||
children: [],
|
||||
header: undefined,
|
||||
})
|
||||
|
||||
check("- [x] some words", {
|
||||
done: true,
|
||||
text: "some words",
|
||||
tags: [],
|
||||
children: [],
|
||||
header: undefined,
|
||||
})
|
||||
|
||||
check("- [ ] some words -and - [ ] not another task", {
|
||||
done: false,
|
||||
text: "some words -and - [ ] not another task",
|
||||
tags: [],
|
||||
children: [],
|
||||
header: undefined,
|
||||
})
|
||||
|
||||
check("- [ ] a task with a #tag", {
|
||||
done: false,
|
||||
text: "a task with a #tag",
|
||||
tags: ["tag"],
|
||||
children: [],
|
||||
header: undefined,
|
||||
})
|
||||
|
||||
check("- [ ] a #tag in a task", {
|
||||
done: false,
|
||||
text: "a #tag in a task",
|
||||
tags: ["tag"],
|
||||
children: [],
|
||||
header: undefined,
|
||||
})
|
||||
|
||||
check("- [ ] a #tag and #another.", {
|
||||
done: false,
|
||||
text: "a #tag and #another.",
|
||||
tags: ["tag", "another"],
|
||||
children: [],
|
||||
header: undefined,
|
||||
})
|
||||
|
||||
check("- [ ] a word with a po#nd sign", {
|
||||
done: false,
|
||||
text: "a word with a po#nd sign",
|
||||
tags: [],
|
||||
children: [],
|
||||
header: undefined,
|
||||
})
|
||||
|
||||
check("- [ ] a date @1/2/25!", {
|
||||
done: false,
|
||||
text: "a date @1/2/25!",
|
||||
tags: [],
|
||||
dueDate: "2025-01-02",
|
||||
children: [],
|
||||
header: undefined,
|
||||
})
|
||||
|
||||
check("- [ ] a date @2-2-25!", {
|
||||
done: false,
|
||||
text: "a date @2-2-25!",
|
||||
tags: [],
|
||||
dueDate: "2025-02-02",
|
||||
children: [],
|
||||
header: undefined,
|
||||
})
|
||||
|
||||
check("- [ ] a @1/2/25 date and #tag", {
|
||||
done: false,
|
||||
text: "a @1/2/25 date and #tag",
|
||||
tags: ["tag"],
|
||||
dueDate: "2025-01-02",
|
||||
children: [],
|
||||
header: undefined,
|
||||
})
|
||||
|
||||
check(" ", [])
|
||||
|
||||
// Invalid formats
|
||||
check("- [x status error", [])
|
||||
check("- [. ] status error", [])
|
||||
check("just text", [])
|
||||
|
||||
// A full todo list
|
||||
check(
|
||||
`
|
||||
# Today
|
||||
- [ ] a task with a #tag
|
||||
- [x] a #tag and date @2/2/25 in a task
|
||||
|
||||
# Tomorrow
|
||||
- [ ] another task
|
||||
- [ ] a subtask
|
||||
- [x] completed
|
||||
|
||||
- [ ] a task with a @1/2/25 date
|
||||
|
||||
`,
|
||||
[
|
||||
{
|
||||
done: false,
|
||||
text: "a task with a #tag",
|
||||
tags: ["tag"],
|
||||
dueDate: undefined,
|
||||
children: [],
|
||||
header: "Today",
|
||||
},
|
||||
{
|
||||
done: true,
|
||||
text: "a #tag and date @2/2/25 in a task",
|
||||
tags: ["tag"],
|
||||
dueDate: "2025-02-02",
|
||||
children: [],
|
||||
header: "Today",
|
||||
},
|
||||
{
|
||||
done: false,
|
||||
text: "another task",
|
||||
tags: [],
|
||||
dueDate: undefined,
|
||||
children: [
|
||||
{ done: false, text: "a subtask", tags: [], dueDate: undefined, children: [], header: "Tomorrow" },
|
||||
{ done: true, text: "completed", tags: [], dueDate: undefined, children: [], header: "Tomorrow" },
|
||||
],
|
||||
header: "Tomorrow",
|
||||
},
|
||||
{
|
||||
done: false,
|
||||
text: "a task with a @1/2/25 date",
|
||||
tags: [],
|
||||
dueDate: "2025-01-02",
|
||||
children: [],
|
||||
header: "Tomorrow",
|
||||
},
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
test("parse metadata positions", () => {
|
||||
const line = "- [ ] do #tag @1/2/25"
|
||||
const parsed = parseTodoLine(line)
|
||||
expect(parsed, line).toBeDefined()
|
||||
const { meta } = parsed as { todo: Todo; meta: TodoMeta }
|
||||
|
||||
// '#tag' starts at index 9, ends at 9 + 4
|
||||
expect(meta.tags).toEqual([{ start: 7, end: 12 }])
|
||||
|
||||
// '+1/2/25' starts at index 14, ends at 14 + 6 + 1 = 21
|
||||
expect(meta.dates).toEqual([{ start: 12, end: 18 }])
|
||||
})
|
||||
|
||||
const check = (input: string, expectation: Todo | Todo[]) => {
|
||||
const result = parseTodoList(input)
|
||||
|
||||
if (expectation === undefined) {
|
||||
expect(result, input).toBeUndefined()
|
||||
} else {
|
||||
expect(result, input).toEqual(Array.isArray(expectation) ? expectation : [expectation])
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { DateTime } from "luxon"
|
||||
import { Todo } from "./todo"
|
||||
import { Todo } from "@/todo"
|
||||
import { test, expect } from "bun:test"
|
||||
|
||||
test("parsing valid todos", () => {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,20 @@
|
|||
import { ensure } from "@workshop/shared/utils"
|
||||
import { DateTime } from "luxon"
|
||||
|
||||
export type TodoJSON = {
|
||||
done: boolean
|
||||
indent: string
|
||||
text: string
|
||||
children: TodoJSON[]
|
||||
}
|
||||
|
||||
export class Todo {
|
||||
done: boolean
|
||||
nodes: TodoNode[]
|
||||
indent = ""
|
||||
|
||||
children: Todo[] = []
|
||||
nodes: TodoNode[]
|
||||
|
||||
static parse(line: string) {
|
||||
return parseTodo(line)
|
||||
}
|
||||
|
|
@ -18,9 +27,16 @@ export class Todo {
|
|||
|
||||
toString() {
|
||||
let string = `${this.indent}- [${this.done ? "x" : " "}] ${this.text}`
|
||||
this.children.forEach((child) => {
|
||||
string += "\n" + child.toString()
|
||||
})
|
||||
return string
|
||||
}
|
||||
|
||||
addChild(todo: Todo) {
|
||||
this.children.push(todo)
|
||||
}
|
||||
|
||||
get text() {
|
||||
return this.nodes.map((node) => node.content).join("")
|
||||
}
|
||||
|
|
@ -143,37 +159,3 @@ const parseText = (line: string, start: number) => {
|
|||
|
||||
return node
|
||||
}
|
||||
|
||||
// export const parseTodoList = (text: string, zone = defaultZone) => {
|
||||
// const todos: Todo[] = []
|
||||
// const lines = text.split("\n")
|
||||
// let currentParent: Todo | undefined
|
||||
// let currentHeader
|
||||
// for (const line of lines) {
|
||||
// const headerMatch = line.match(/^\s*#+\s*(.*)/)
|
||||
// if (headerMatch) {
|
||||
// currentParent = undefined
|
||||
// currentHeader = headerMatch[1]!.trim()
|
||||
// continue
|
||||
// } else if (line.trim() === "") {
|
||||
// continue
|
||||
// }
|
||||
|
||||
// const { todo } = parseTodoLine(line, currentHeader, zone)
|
||||
// if (todo) {
|
||||
// if (todo.nested) {
|
||||
// if (currentParent) {
|
||||
// currentParent.children.push(todo)
|
||||
// } else {
|
||||
// todos.push(todo) // If there's no parent, treat it as a top-level todo
|
||||
// }
|
||||
// } else {
|
||||
// todos.push(todo)
|
||||
// currentParent = todo
|
||||
// }
|
||||
// } else {
|
||||
// // console.warn(`❌ Skipping invalid todo line "${line}"`)
|
||||
// }
|
||||
// }
|
||||
// return todos
|
||||
// }
|
||||
|
|
|
|||
43
packages/todo/src/todoList.test.ts
Normal file
43
packages/todo/src/todoList.test.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { todoListToString, type TodoList } from "@/todoList"
|
||||
import { test, expect } from "bun:test"
|
||||
|
||||
test("todoListToString", () => {
|
||||
const todoList: TodoList = [
|
||||
{
|
||||
title: "Today",
|
||||
todos: [
|
||||
{ done: false, indent: "", text: "a task with a #tag", children: [] },
|
||||
{ done: true, indent: "", text: "a #tag and date @2/2/25 in a task", children: [] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Tomorrow",
|
||||
todos: [
|
||||
{
|
||||
done: false,
|
||||
indent: "",
|
||||
text: "another task",
|
||||
children: [
|
||||
{ done: false, indent: " ", text: "a subtask", children: [] },
|
||||
{ done: true, indent: " ", text: "completed", children: [] },
|
||||
],
|
||||
},
|
||||
{ done: false, indent: "", text: "a task with a @1/2/25 date", children: [] },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = todoListToString(todoList)
|
||||
|
||||
const expected = `# Today
|
||||
- [ ] a task with a #tag
|
||||
- [x] a #tag and date @2/2/25 in a task
|
||||
# Tomorrow
|
||||
- [ ] another task
|
||||
- [ ] a subtask
|
||||
- [x] completed
|
||||
- [ ] a task with a @1/2/25 date
|
||||
`
|
||||
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
39
packages/todo/src/todoList.ts
Normal file
39
packages/todo/src/todoList.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import type { TodoJSON } from "@/todo"
|
||||
|
||||
export type TodoList = {
|
||||
title?: string
|
||||
todos: TodoOrString[]
|
||||
}[]
|
||||
|
||||
export type TodoOrString = TodoJSON | string
|
||||
|
||||
export const todoListToString = (todoList: TodoList): string => {
|
||||
let output = ""
|
||||
|
||||
todoList.forEach((header) => {
|
||||
if (header.title) {
|
||||
output += `# ${header.title}\n`
|
||||
}
|
||||
|
||||
header.todos.forEach((todoJson) => {
|
||||
output += todoJsonToString(todoJson)
|
||||
})
|
||||
})
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
const todoJsonToString = (todoOrString: TodoOrString): string => {
|
||||
console.log(`🌭`, { todoOrString })
|
||||
if (typeof todoOrString === "string") {
|
||||
return `${todoOrString}\n`
|
||||
}
|
||||
|
||||
let result = `${todoOrString.indent}- [${todoOrString.done ? "x" : " "}] ${todoOrString.text}\n`
|
||||
|
||||
todoOrString.children.forEach((child) => {
|
||||
result += todoJsonToString(child)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user