From d291ceec80baf38ebced163b8f4b4863f2d3edd0 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Thu, 19 Mar 2026 00:04:19 +0530 Subject: [PATCH] tasks execute immediately --- apps/x/apps/main/src/ipc.ts | 5 +- apps/x/apps/renderer/src/App.tsx | 1 + .../src/components/markdown-editor.tsx | 129 +++++++++++++++--- .../renderer/src/extensions/task-block.tsx | 12 +- .../core/src/knowledge/inline_task_agent.ts | 33 +++++ .../core/src/knowledge/inline_tasks.ts | 72 ++++++++++ apps/x/packages/shared/src/inline-task.ts | 1 + apps/x/packages/shared/src/ipc.ts | 16 +++ 8 files changed, 245 insertions(+), 24 deletions(-) diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 2b43983f..0596f1a5 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -39,7 +39,7 @@ import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo. import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js'; import { search } from '@x/core/dist/search/search.js'; import { versionHistory, voice } from '@x/core'; -import { classifySchedule } from '@x/core/dist/knowledge/inline_tasks.js'; +import { classifySchedule, processRowboatInstruction } from '@x/core/dist/knowledge/inline_tasks.js'; import { getBillingInfo } from '@x/core/dist/billing/billing.js'; /** @@ -705,6 +705,9 @@ export function setupIpcHandlers() { const schedule = await classifySchedule(args.instruction); return { schedule }; }, + 'inline-task:process': async (_event, args) => { + return await processRowboatInstruction(args.instruction, args.noteContent, args.notePath); + }, 'voice:getConfig': async () => { return voice.getVoiceConfig(); }, diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index a92f2d28..b37b8559 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -3869,6 +3869,7 @@ function App() { > { if (!isViewingHistory) handleEditorChange(tab.path, markdown) }} onPrimaryHeadingCommit={() => { untitledRenameReadyPathsRef.current.add(tab.path) diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index ba86c638..8bc4cdae 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -232,6 +232,7 @@ interface MarkdownEditorProps { frontmatter?: string | null onFrontmatterChange?: (raw: string | null) => void onExport?: (format: 'md' | 'pdf' | 'docx') => void + notePath?: string } type WikiLinkMatch = { @@ -323,6 +324,7 @@ export function MarkdownEditor({ frontmatter, onFrontmatterChange, onExport, + notePath, }: MarkdownEditorProps) { const isInternalUpdate = useRef(false) const wrapperRef = useRef(null) @@ -928,24 +930,17 @@ export function MarkdownEditor({ } if (activeRowboatMention) { - // Classify schedule intent for new blocks - const blockData: Record = { instruction } - try { - const result = await window.ipc.invoke('inline-task:classifySchedule', { instruction }) - if (result.schedule) { - const { label, ...rest } = result.schedule - blockData.schedule = rest - blockData['schedule-label'] = label - } - } catch (error) { - console.error('[RowboatAdd] Schedule classification failed:', error) - } + // Insert block with processing state immediately + const blockData: Record = { instruction, processing: true } + + const insertFrom = activeRowboatMention.range.from + const insertTo = activeRowboatMention.range.to editor .chain() .focus() .insertContentAt( - { from: activeRowboatMention.range.from, to: activeRowboatMention.range.to }, + { from: insertFrom, to: insertTo }, [ { type: 'taskBlock', attrs: { data: JSON.stringify(blockData) } }, { type: 'paragraph' }, @@ -953,17 +948,109 @@ export function MarkdownEditor({ ) .run() - // Mark note as live - if (onFrontmatterChange) { - const fields = extractAllFrontmatterValues(frontmatter ?? null) - fields['live_note'] = 'true' - onFrontmatterChange(buildFrontmatter(fields)) - } - setActiveRowboatMention(null) setRowboatAnchorTop(null) + + // Find the taskBlock we just inserted so we can update it later + const processingData = JSON.stringify(blockData) + let taskBlockPos: number | null = null + editor.state.doc.descendants((node, pos) => { + if (taskBlockPos !== null) return false + if (node.type.name === 'taskBlock' && node.attrs.data === processingData) { + taskBlockPos = pos + return false + } + }) + + // Get editor content for the agent + const editorContent = editor.storage.markdown?.getMarkdown?.() ?? '' + + // Call the assistant agent asynchronously + try { + const result = await window.ipc.invoke('inline-task:process', { + instruction, + noteContent: editorContent, + notePath: notePath ?? '', + }) + + // Update the block: remove processing, add schedule if any + const updatedData: Record = { instruction } + if (result.schedule) { + updatedData.schedule = result.schedule + updatedData['schedule-label'] = result.scheduleLabel + + // Mark note as live if there's a schedule + if (onFrontmatterChange) { + const fields = extractAllFrontmatterValues(frontmatter ?? null) + fields['live_note'] = 'true' + onFrontmatterChange(buildFrontmatter(fields)) + } + } + + // Find the processing block again (position may have shifted) + let currentPos: number | null = null + editor.state.doc.descendants((node, pos) => { + if (currentPos !== null) return false + if (node.type.name === 'taskBlock') { + try { + const data = JSON.parse(node.attrs.data || '{}') + if (data.instruction === instruction && data.processing === true) { + currentPos = pos + return false + } + } catch { /* skip */ } + } + }) + + if (currentPos !== null) { + const node = editor.state.doc.nodeAt(currentPos) + if (node) { + let tr = editor.state.tr.setNodeMarkup(currentPos, undefined, { + data: JSON.stringify(updatedData), + }) + + // Insert response text below the block if present + if (result.response) { + const insertPos = currentPos + node.nodeSize + const responseNode = editor.schema.nodes.paragraph?.create( + null, + editor.schema.text(result.response), + ) + if (responseNode) { + tr = tr.insert(insertPos, responseNode) + } + } + + editor.view.dispatch(tr) + } + } + } catch (error) { + console.error('[RowboatAdd] Processing failed:', error) + + // Remove processing state on error + let currentPos: number | null = null + editor.state.doc.descendants((node, pos) => { + if (currentPos !== null) return false + if (node.type.name === 'taskBlock') { + try { + const data = JSON.parse(node.attrs.data || '{}') + if (data.instruction === instruction && data.processing === true) { + currentPos = pos + return false + } + } catch { /* skip */ } + } + }) + + if (currentPos !== null) { + const tr = editor.state.tr.setNodeMarkup(currentPos, undefined, { + data: JSON.stringify({ instruction }), + }) + editor.view.dispatch(tr) + } + } } - }, [editor, activeRowboatMention, rowboatBlockEdit, frontmatter, onFrontmatterChange]) + }, [editor, activeRowboatMention, rowboatBlockEdit, frontmatter, onFrontmatterChange, notePath]) const handleRowboatRemove = useCallback(() => { if (!editor || !rowboatBlockEdit) return diff --git a/apps/x/apps/renderer/src/extensions/task-block.tsx b/apps/x/apps/renderer/src/extensions/task-block.tsx index b1839745..e39d8d62 100644 --- a/apps/x/apps/renderer/src/extensions/task-block.tsx +++ b/apps/x/apps/renderer/src/extensions/task-block.tsx @@ -1,17 +1,19 @@ import { mergeAttributes, Node } from '@tiptap/react' import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react' -import { CalendarClock, X } from 'lucide-react' +import { CalendarClock, Loader2, X } from 'lucide-react' import { inlineTask } from '@x/shared' function TaskBlockView({ node, deleteNode }: { node: { attrs: Record }; deleteNode: () => void }) { const raw = node.attrs.data as string let instruction = '' let scheduleLabel = '' + let processing = false try { const parsed = inlineTask.InlineTaskBlockSchema.parse(JSON.parse(raw)) instruction = parsed.instruction scheduleLabel = parsed['schedule-label'] ?? '' + processing = parsed.processing ?? false } catch { // Fallback: show raw data instruction = raw @@ -29,7 +31,13 @@ function TaskBlockView({ node, deleteNode }: { node: { attrs: Record
@rowboat {instruction} - {scheduleLabel && ( + {processing && ( + + + processing… + + )} + {!processing && scheduleLabel && ( {scheduleLabel} diff --git a/apps/x/packages/core/src/knowledge/inline_task_agent.ts b/apps/x/packages/core/src/knowledge/inline_task_agent.ts index 18baa66d..1a80ee1e 100644 --- a/apps/x/packages/core/src/knowledge/inline_task_agent.ts +++ b/apps/x/packages/core/src/knowledge/inline_task_agent.ts @@ -5,6 +5,13 @@ export function getRaw(): string { .map(name => ` ${name}:\n type: builtin\n name: ${name}`) .join('\n'); + const now = new Date(); + const defaultEnd = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); + const localNow = now.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'long' }); + const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; + const nowISO = now.toISOString(); + const defaultEndISO = defaultEnd.toISOString(); + return `--- model: gpt-5.2 tools: @@ -23,5 +30,31 @@ You are an inline task execution agent. You receive a @rowboat instruction from 5. NEVER include meta-commentary, thinking out loud, or narration about what you're doing. No "Let me look that up", "Here are the details", "I found the following", etc. Just write the content itself. 6. Keep the result concise and well-formatted in markdown. 7. Do not modify the original note file — the service will handle inserting your response. + +# Schedule Classification + +If the instruction implies a recurring or future-scheduled task, you MUST include a schedule marker in your response using this exact format: + + + +Place this marker at the very beginning of your response, on its own line, before any other content. + +Schedule types: +1. "cron" — recurring schedule: + "startDate" defaults to now (${nowISO}). "endDate" defaults to 7 days from now (${defaultEndISO}). + Example: "every morning at 8am" → + +2. "window" — recurring with a time window: + +3. "once" — run once at a specific future time: + +The "label" field must be a short plain-English description starting with "runs" (e.g. "runs every 2 minutes until Mar 12", "runs daily at 8 AM until Mar 12", "runs once on Mar 20 at 3 PM"). + +Current local time: ${localNow} +Timezone: ${tz} +Current UTC time: ${nowISO} + +If the instruction is a one-time immediate task with no scheduling intent, do NOT include the schedule marker. Just execute and return the result. +If the instruction has BOTH scheduling intent AND something to execute immediately, include the schedule marker AND your response content. `; } diff --git a/apps/x/packages/core/src/knowledge/inline_tasks.ts b/apps/x/packages/core/src/knowledge/inline_tasks.ts index 15d5ffc4..1b5e3876 100644 --- a/apps/x/packages/core/src/knowledge/inline_tasks.ts +++ b/apps/x/packages/core/src/knowledge/inline_tasks.ts @@ -531,6 +531,78 @@ async function processInlineTasks(): Promise { } } +/** + * Process a @rowboat instruction via the inline task agent. + * The agent can execute one-off tasks and/or detect scheduling intent. + * Returns schedule info (if any), a schedule label, and optional response text. + */ +export async function processRowboatInstruction( + instruction: string, + noteContent: string, + notePath: string, +): Promise<{ + schedule: { type: 'cron'; expression: string; startDate: string; endDate: string } + | { type: 'window'; cron: string; startTime: string; endTime: string; startDate: string; endDate: string } + | { type: 'once'; runAt: string } + | null; + scheduleLabel: string | null; + response: string | null; +}> { + const run = await createRun({ agentId: INLINE_TASK_AGENT }); + + const message = [ + `Execute the following instruction from the note "${notePath}":`, + '', + `**Instruction:** ${instruction}`, + '', + '**Full note content for context:**', + '```markdown', + noteContent, + '```', + ].join('\n'); + + await createMessage(run.id, message); + await waitForRunCompletion(run.id); + + const rawResponse = await extractAgentResponse(run.id); + if (!rawResponse) { + return { schedule: null, scheduleLabel: null, response: null }; + } + + // Parse out the schedule marker if present + const scheduleMarkerRegex = //; + const match = rawResponse.match(scheduleMarkerRegex); + + type ScheduleWithoutLabel = + | { type: 'cron'; expression: string; startDate: string; endDate: string } + | { type: 'window'; cron: string; startTime: string; endTime: string; startDate: string; endDate: string } + | { type: 'once'; runAt: string }; + + let schedule: ScheduleWithoutLabel | null = null; + let scheduleLabel: string | null = null; + let response: string | null = null; + + if (match) { + try { + const parsed = JSON.parse(match[1]); + if (parsed && typeof parsed === 'object' && parsed.type) { + scheduleLabel = parsed.label || null; + const { label: _, ...rest } = parsed; + schedule = rest as ScheduleWithoutLabel; + } + } catch { + // Invalid JSON in marker — ignore + } + // Remove the marker from the response text + const cleaned = rawResponse.replace(scheduleMarkerRegex, '').trim(); + response = cleaned || null; + } else { + response = rawResponse.trim() || null; + } + + return { schedule, scheduleLabel, response }; +} + /** * Classify whether an instruction contains a scheduling intent using the user's configured LLM. * Returns a schedule object or null for one-time tasks. diff --git a/apps/x/packages/shared/src/inline-task.ts b/apps/x/packages/shared/src/inline-task.ts index 1b3097e9..f87a276c 100644 --- a/apps/x/packages/shared/src/inline-task.ts +++ b/apps/x/packages/shared/src/inline-task.ts @@ -28,6 +28,7 @@ export const InlineTaskBlockSchema = z.object({ schedule: InlineTaskScheduleSchema.optional(), 'schedule-label': z.string().optional(), lastRunAt: z.string().optional(), + processing: z.boolean().optional(), }); export type InlineTaskBlock = z.infer; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 346a95d2..ad6211b0 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -522,6 +522,22 @@ const ipcSchemas = { ]).nullable(), }), }, + 'inline-task:process': { + req: z.object({ + instruction: z.string(), + noteContent: z.string(), + notePath: z.string(), + }), + res: z.object({ + schedule: z.union([ + z.object({ type: z.literal('cron'), expression: z.string(), startDate: z.string(), endDate: z.string() }), + z.object({ type: z.literal('window'), cron: z.string(), startTime: z.string(), endTime: z.string(), startDate: z.string(), endDate: z.string() }), + z.object({ type: z.literal('once'), runAt: z.string() }), + ]).nullable(), + scheduleLabel: z.string().nullable(), + response: z.string().nullable(), + }), + }, // Billing channels 'billing:getInfo': { req: z.null(),