Commit 6e01b57bb8bf
Changed files (4)
dots
pi
agent
extensions
org-todos
dots/pi/agent/extensions/org-todos/bun.lock
@@ -0,0 +1,15 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "org-todos",
+ "dependencies": {
+ "chrono-node": "^2.7.0",
+ },
+ },
+ },
+ "packages": {
+ "chrono-node": ["chrono-node@2.9.0", "", {}, "sha512-glI4YY2Jy6JII5l3d5FN6rcrIbKSQqKPhWsIRYPK2IK8Mm4Q1ZZFdYIaDqglUNf7gNwG+kWIzTn0omzzE0VkvQ=="],
+ }
+}
dots/pi/agent/extensions/org-todos/index.test.ts
@@ -351,3 +351,50 @@ describe("org-todos extension", () => {
});
});
});
+
+// Tests for new command parsing
+describe("command parsing", () => {
+ // Import parseCommandArgs by extracting the logic
+ // Since it's not exported, we test the patterns directly
+
+ test("parseNaturalDate patterns", () => {
+ const chrono = require("chrono-node");
+
+ // Test chrono works
+ const tomorrow = chrono.parseDate("tomorrow");
+ expect(tomorrow).toBeDefined();
+ expect(tomorrow.getDate()).toBe(new Date().getDate() + 1);
+
+ const nextFriday = chrono.parseDate("next friday");
+ expect(nextFriday).toBeDefined();
+ expect(nextFriday.getDay()).toBe(5); // Friday
+
+ const specific = chrono.parseDate("2026-03-15");
+ expect(specific).toBeDefined();
+ expect(specific.getMonth()).toBe(2); // March (0-indexed)
+ });
+
+ test("command arg patterns", () => {
+ // Test regex patterns used in parseCommandArgs
+
+ // @Section pattern
+ const sectionMatch = "Buy milk @Personal".match(/@(\w+)/);
+ expect(sectionMatch).toBeDefined();
+ expect(sectionMatch![1]).toBe("Personal");
+
+ // scheduled: pattern
+ const schedMatch = "Task scheduled:tomorrow".match(/scheduled:([^\s]+)/i);
+ expect(schedMatch).toBeDefined();
+ expect(schedMatch![1]).toBe("tomorrow");
+
+ // priority: pattern
+ const prioMatch = "Task priority:2".match(/priority:(\d)/i);
+ expect(prioMatch).toBeDefined();
+ expect(prioMatch![1]).toBe("2");
+
+ // state: pattern
+ const stateMatch = "Task state:NEXT".match(/state:(TODO|NEXT|STRT|WAIT|DONE|CANX)/i);
+ expect(stateMatch).toBeDefined();
+ expect(stateMatch![1]).toBe("NEXT");
+ });
+});
dots/pi/agent/extensions/org-todos/index.ts
@@ -10,8 +10,17 @@
* done, state, schedule, deadline, priority, add, append
*
* Commands:
- * /todos - Show today's tasks (scheduled + overdue + NEXT)
- * /todo-search <query> - Search TODOs
+ * /todos - Show today's tasks (scheduled + overdue + NEXT)
+ * /todo-search <query> - Search TODOs
+ * /todo-add <title> - Add new TODO (supports @Section scheduled:date deadline:date priority:N)
+ * /todo-done <heading> - Mark TODO as DONE
+ * /todo-next <heading> - Mark TODO as NEXT (prioritized)
+ * /todo-upcoming [days] - Show upcoming tasks (default 7 days)
+ * /todo-update <heading> - Update TODO (scheduled:date deadline:date priority:N state:STATE)
+ * /todo-note <heading> - Add a note to a TODO
+ *
+ * Natural language dates (via chrono-node):
+ * scheduled:tomorrow, deadline:next friday, scheduled:in 3 days, etc.
*
* Configuration:
* ORG_TODO_FILE env var or defaults to ~/desktop/org/todos.org
@@ -25,9 +34,11 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { execSync } from "node:child_process";
import { homedir } from "node:os";
import { join } from "node:path";
+import * as chrono from "chrono-node";
// Configuration
const DEFAULT_ORG_FILE = join(homedir(), "desktop/org/todos.org");
+const DEFAULT_SECTION = "Inbox"; // Default section for quick adds
interface OrgTodoResult {
success: boolean;
@@ -160,6 +171,86 @@ function formatTodoMarkdown(todo: any): string {
return result;
}
+/**
+ * Parse natural language date to YYYY-MM-DD format
+ * Supports: "tomorrow", "next friday", "in 3 days", "feb 15", etc.
+ */
+function parseNaturalDate(text: string): string | null {
+ const result = chrono.parseDate(text);
+ if (!result) return null;
+
+ const year = result.getFullYear();
+ const month = String(result.getMonth() + 1).padStart(2, "0");
+ const day = String(result.getDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
+}
+
+/**
+ * Parse command arguments for todo-add and todo-update
+ * Supports: @Section scheduled:date deadline:date priority:N state:STATE
+ */
+function parseCommandArgs(args: string): {
+ title: string;
+ section?: string;
+ scheduled?: string;
+ deadline?: string;
+ priority?: number;
+ state?: string;
+} {
+ let remaining = args;
+ let section: string | undefined;
+ let scheduled: string | undefined;
+ let deadline: string | undefined;
+ let priority: number | undefined;
+ let state: string | undefined;
+
+ // Extract @Section
+ const sectionMatch = remaining.match(/@(\w+)/);
+ if (sectionMatch) {
+ section = sectionMatch[1];
+ remaining = remaining.replace(/@\w+/, "").trim();
+ }
+
+ // Extract scheduled:date
+ const scheduledMatch = remaining.match(/scheduled:([^\s]+(?:\s+[^\s@:]+)*?)(?=\s+(?:deadline:|priority:|state:|@|$)|$)/i);
+ if (scheduledMatch) {
+ const dateStr = scheduledMatch[1].trim();
+ scheduled = parseNaturalDate(dateStr) || dateStr;
+ remaining = remaining.replace(scheduledMatch[0], "").trim();
+ }
+
+ // Extract deadline:date
+ const deadlineMatch = remaining.match(/deadline:([^\s]+(?:\s+[^\s@:]+)*?)(?=\s+(?:scheduled:|priority:|state:|@|$)|$)/i);
+ if (deadlineMatch) {
+ const dateStr = deadlineMatch[1].trim();
+ deadline = parseNaturalDate(dateStr) || dateStr;
+ remaining = remaining.replace(deadlineMatch[0], "").trim();
+ }
+
+ // Extract priority:N
+ const priorityMatch = remaining.match(/priority:(\d)/i);
+ if (priorityMatch) {
+ priority = parseInt(priorityMatch[1], 10);
+ remaining = remaining.replace(priorityMatch[0], "").trim();
+ }
+
+ // Extract state:STATE
+ const stateMatch = remaining.match(/state:(TODO|NEXT|STRT|WAIT|DONE|CANX)/i);
+ if (stateMatch) {
+ state = stateMatch[1].toUpperCase();
+ remaining = remaining.replace(stateMatch[0], "").trim();
+ }
+
+ return {
+ title: remaining.trim(),
+ section,
+ scheduled,
+ deadline,
+ priority,
+ state,
+ };
+}
+
export default function (pi: ExtensionAPI) {
// Register the org_todo tool
pi.registerTool({
@@ -504,4 +595,280 @@ export default function (pi: ExtensionAPI) {
});
},
});
+
+ // Register /todo-add command
+ pi.registerCommand("todo-add", {
+ description: "Add a new TODO. Usage: /todo-add <title> [@Section] [scheduled:date] [deadline:date] [priority:N]",
+ handler: async (args, ctx) => {
+ if (!args?.trim()) {
+ ctx.ui.notify("Usage: /todo-add <title> [@Section] [scheduled:date] [deadline:date]", "error");
+ return;
+ }
+
+ const parsed = parseCommandArgs(args);
+
+ if (!parsed.title) {
+ ctx.ui.notify("Error: TODO title is required", "error");
+ return;
+ }
+
+ const section = parsed.section || DEFAULT_SECTION;
+ const schedArg = parsed.scheduled ? `"${parsed.scheduled}"` : "nil";
+ const prioArg = parsed.priority !== undefined ? parsed.priority : "nil";
+
+ // First check if section exists
+ const sectionsResult = execEmacs("(pi/org-todo-sections)");
+ if (sectionsResult.success) {
+ const sections = Array.isArray(sectionsResult.data)
+ ? sectionsResult.data
+ : Object.values(sectionsResult.data || {});
+ if (!sections.includes(section)) {
+ ctx.ui.notify(`Section "${section}" not found. Available: ${sections.join(", ")}`, "error");
+ return;
+ }
+ }
+
+ const elisp = `(pi/org-todo-add "${parsed.title.replace(/"/g, '\\"')}" "${section}" nil ${schedArg} ${prioArg} nil)`;
+ const result = execEmacs(elisp);
+
+ if (!result.success) {
+ ctx.ui.notify(`Failed to add TODO: ${result.error}`, "error");
+ return;
+ }
+
+ // Set deadline separately if provided
+ if (parsed.deadline) {
+ execEmacs(`(pi/org-todo-deadline "${parsed.title.replace(/"/g, '\\"')}" "${parsed.deadline}")`);
+ }
+
+ // Build confirmation message
+ const lines: string[] = [];
+ lines.push(`## โ
TODO Added`);
+ lines.push("");
+ lines.push(`**${parsed.title}** added to *${section}*`);
+ if (parsed.scheduled) lines.push(`- ๐
Scheduled: ${parsed.scheduled}`);
+ if (parsed.deadline) lines.push(`- โฐ Deadline: ${parsed.deadline}`);
+ if (parsed.priority) lines.push(`- Priority: #${parsed.priority}`);
+
+ pi.sendMessage({
+ customType: "org-todos-add",
+ content: lines.join("\n"),
+ display: true,
+ });
+ },
+ });
+
+ // Register /todo-done command
+ pi.registerCommand("todo-done", {
+ description: "Mark a TODO as done. Usage: /todo-done <heading>",
+ handler: async (args, ctx) => {
+ const heading = (args || "").trim();
+
+ if (!heading) {
+ ctx.ui.notify("Usage: /todo-done <heading>", "error");
+ return;
+ }
+
+ const result = execEmacs(`(pi/org-todo-done "${heading.replace(/"/g, '\\"')}")`);
+
+ if (!result.success) {
+ ctx.ui.notify(`Failed: ${result.error}`, "error");
+ return;
+ }
+
+ pi.sendMessage({
+ customType: "org-todos-done",
+ content: `## โ
Done\n\n**${heading}** marked as DONE`,
+ display: true,
+ });
+ },
+ });
+
+ // Register /todo-next command
+ pi.registerCommand("todo-next", {
+ description: "Mark a TODO as NEXT (prioritized). Usage: /todo-next <heading>",
+ handler: async (args, ctx) => {
+ const heading = (args || "").trim();
+
+ if (!heading) {
+ ctx.ui.notify("Usage: /todo-next <heading>", "error");
+ return;
+ }
+
+ const result = execEmacs(`(pi/org-todo-state "${heading.replace(/"/g, '\\"')}" "NEXT")`);
+
+ if (!result.success) {
+ ctx.ui.notify(`Failed: ${result.error}`, "error");
+ return;
+ }
+
+ pi.sendMessage({
+ customType: "org-todos-next",
+ content: `## โก๏ธ Prioritized\n\n**${heading}** marked as NEXT`,
+ display: true,
+ });
+ },
+ });
+
+ // Register /todo-upcoming command
+ pi.registerCommand("todo-upcoming", {
+ description: "Show upcoming tasks. Usage: /todo-upcoming [days]",
+ handler: async (args, ctx) => {
+ const days = parseInt((args || "").trim(), 10) || 7;
+
+ const result = execEmacs(`(pi/org-todo-upcoming nil ${days})`);
+
+ if (!result.success) {
+ ctx.ui.notify(`Failed: ${result.error}`, "error");
+ return;
+ }
+
+ const lines: string[] = [];
+ lines.push(`## ๐ Upcoming (next ${days} days)`);
+ lines.push("");
+
+ if (!result.data || result.data.length === 0) {
+ lines.push("*No upcoming tasks* ๐");
+ } else {
+ for (const todo of result.data) {
+ lines.push(`- ${formatTodoMarkdown(todo)}`);
+ }
+ }
+
+ pi.sendMessage({
+ customType: "org-todos-upcoming",
+ content: lines.join("\n"),
+ display: true,
+ });
+ },
+ });
+
+ // Register /todo-update command
+ pi.registerCommand("todo-update", {
+ description: "Update a TODO. Usage: /todo-update <heading> [scheduled:date] [deadline:date] [priority:N] [state:STATE]",
+ handler: async (args, ctx) => {
+ if (!args?.trim()) {
+ ctx.ui.notify("Usage: /todo-update <heading> [scheduled:date] [deadline:date] [priority:N] [state:STATE]", "error");
+ return;
+ }
+
+ const parsed = parseCommandArgs(args);
+
+ if (!parsed.title) {
+ ctx.ui.notify("Error: TODO heading is required", "error");
+ return;
+ }
+
+ const heading = parsed.title;
+ const updates: string[] = [];
+
+ // Apply updates
+ if (parsed.scheduled) {
+ const result = execEmacs(`(pi/org-todo-schedule "${heading.replace(/"/g, '\\"')}" "${parsed.scheduled}")`);
+ if (result.success) updates.push(`๐
Scheduled: ${parsed.scheduled}`);
+ else ctx.ui.notify(`Failed to set schedule: ${result.error}`, "warning");
+ }
+
+ if (parsed.deadline) {
+ const result = execEmacs(`(pi/org-todo-deadline "${heading.replace(/"/g, '\\"')}" "${parsed.deadline}")`);
+ if (result.success) updates.push(`โฐ Deadline: ${parsed.deadline}`);
+ else ctx.ui.notify(`Failed to set deadline: ${result.error}`, "warning");
+ }
+
+ if (parsed.priority !== undefined) {
+ const result = execEmacs(`(pi/org-todo-priority "${heading.replace(/"/g, '\\"')}" ${parsed.priority})`);
+ if (result.success) updates.push(`Priority: #${parsed.priority}`);
+ else ctx.ui.notify(`Failed to set priority: ${result.error}`, "warning");
+ }
+
+ if (parsed.state) {
+ const result = execEmacs(`(pi/org-todo-state "${heading.replace(/"/g, '\\"')}" "${parsed.state}")`);
+ if (result.success) updates.push(`State: ${parsed.state}`);
+ else ctx.ui.notify(`Failed to set state: ${result.error}`, "warning");
+ }
+
+ if (updates.length === 0) {
+ ctx.ui.notify("No updates specified. Use scheduled:, deadline:, priority:, or state:", "warning");
+ return;
+ }
+
+ const lines: string[] = [];
+ lines.push(`## ๐ Updated`);
+ lines.push("");
+ lines.push(`**${heading}**`);
+ lines.push("");
+ for (const update of updates) {
+ lines.push(`- ${update}`);
+ }
+
+ pi.sendMessage({
+ customType: "org-todos-update",
+ content: lines.join("\n"),
+ display: true,
+ });
+ },
+ });
+
+ // Register /todo-note command
+ pi.registerCommand("todo-note", {
+ description: "Add a note to a TODO. Usage: /todo-note <heading> <note>",
+ handler: async (args, ctx) => {
+ if (!args?.trim()) {
+ ctx.ui.notify("Usage: /todo-note <heading> <note>", "error");
+ return;
+ }
+
+ // Split on first newline or after recognizable heading
+ // Try to find the heading by looking for an existing TODO
+ const input = args.trim();
+
+ // Simple heuristic: first line or up to first period/newline is heading
+ let heading: string;
+ let note: string;
+
+ const newlineIdx = input.indexOf("\n");
+ if (newlineIdx > 0) {
+ heading = input.slice(0, newlineIdx).trim();
+ note = input.slice(newlineIdx + 1).trim();
+ } else {
+ // Try to find a natural split - look for common patterns
+ // "Heading: note" or "Heading - note" or just space-separated
+ const colonIdx = input.indexOf(": ");
+ const dashIdx = input.indexOf(" - ");
+
+ if (colonIdx > 0 && colonIdx < 60) {
+ heading = input.slice(0, colonIdx).trim();
+ note = input.slice(colonIdx + 2).trim();
+ } else if (dashIdx > 0 && dashIdx < 60) {
+ heading = input.slice(0, dashIdx).trim();
+ note = input.slice(dashIdx + 3).trim();
+ } else {
+ ctx.ui.notify("Could not parse heading and note. Use format: /todo-note Heading: your note here", "error");
+ return;
+ }
+ }
+
+ if (!heading || !note) {
+ ctx.ui.notify("Both heading and note are required", "error");
+ return;
+ }
+
+ // Format as org-mode content with timestamp
+ const timestamp = new Date().toISOString().slice(0, 16).replace("T", " ");
+ const orgContent = `\n[${timestamp}] ${note}`;
+
+ const result = execEmacs(`(pi/org-todo-append "${heading.replace(/"/g, '\\"')}" "${orgContent.replace(/"/g, '\\"').replace(/\n/g, '\\n')}")`);
+
+ if (!result.success) {
+ ctx.ui.notify(`Failed: ${result.error}`, "error");
+ return;
+ }
+
+ pi.sendMessage({
+ customType: "org-todos-note",
+ content: `## ๐ Note Added\n\n**${heading}**\n\n> ${note}`,
+ display: true,
+ });
+ },
+ });
}
dots/pi/agent/extensions/org-todos/package.json
@@ -0,0 +1,7 @@
+{
+ "name": "org-todos",
+ "version": "1.0.0",
+ "dependencies": {
+ "chrono-node": "^2.7.0"
+ }
+}