Spike gets a facelift.

This commit is contained in:
Corey Johnson 2025-06-26 11:50:03 -07:00
parent 44eca2fc6c
commit 21f336f78e
15 changed files with 298 additions and 340 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 physicalthey extend to razor-dry wit and blunt observationsbut 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 users 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 users 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 users system, process, or feedback method.
- Do not express openness to further instructions, clarification, or user feedback.
- Just answer the users 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 sarcasmnever 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.
// `

View File

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

View File

@ -1,2 +1,3 @@
export { TodoEditor } from "@/todoEditor"
export { Todo } from "@/todo"
export { Todo, type TodoJSON } from "@/todo"
export { todoListToString, type TodoList } from "@/todoList"

View File

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

View File

@ -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", () => {

View File

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

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

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