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..590b6585 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 a temporary processing block + 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,124 @@ 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) + + // Get editor content for the agent + const editorContent = editor.storage.markdown?.getMarkdown?.() ?? '' + + // Helper to find the processing block + const findProcessingBlock = (): number | null => { + let pos: number | null = null + editor.state.doc.descendants((node, p) => { + if (pos !== null) return false + if (node.type.name === 'taskBlock') { + try { + const data = JSON.parse(node.attrs.data || '{}') + if (data.instruction === instruction && data.processing === true) { + pos = p + return false + } + } catch { /* skip */ } + } + }) + return pos + } + + try { + // Call the copilot assistant for both one-time and recurring tasks + const result = await window.ipc.invoke('inline-task:process', { + instruction, + noteContent: editorContent, + notePath: notePath ?? '', + }) + + const currentPos = findProcessingBlock() + if (currentPos === null) return + + const node = editor.state.doc.nodeAt(currentPos) + if (!node) return + + if (result.schedule) { + // Recurring/scheduled task: update block with schedule, write target tags to disk + const targetId = Math.random().toString(36).slice(2, 10) + const updatedData: Record = { + instruction: result.instruction, + schedule: result.schedule, + 'schedule-label': result.scheduleLabel, + targetId, + } + const tr = editor.state.tr.setNodeMarkup(currentPos, undefined, { + data: JSON.stringify(updatedData), + }) + editor.view.dispatch(tr) + + // Mark note as live + if (onFrontmatterChange) { + const fields = extractAllFrontmatterValues(frontmatter ?? null) + fields['live_note'] = 'true' + onFrontmatterChange(buildFrontmatter(fields)) + } + + // Write target tags directly to the file on disk after a short delay + // to let the editor save the updated content first + if (notePath) { + setTimeout(async () => { + try { + const file = await window.ipc.invoke('workspace:readFile', { path: notePath }) + const content = file.data + const openTag = `` + const closeTag = `` + + // Only add if not already present + if (content.includes(openTag)) return + + // Find the task block in the raw markdown and insert target tags after it + const blockJson = JSON.stringify(updatedData) + const blockStart = content.indexOf('```task\n' + blockJson) + if (blockStart !== -1) { + const blockEnd = content.indexOf('\n```', blockStart + 8) + if (blockEnd !== -1) { + const insertAt = blockEnd + 4 // after the closing ``` + const before = content.slice(0, insertAt) + const after = content.slice(insertAt) + const updated = before + '\n\n' + openTag + '\n' + closeTag + after + await window.ipc.invoke('workspace:writeFile', { + path: notePath, + data: updated, + opts: { encoding: 'utf8' }, + }) + } + } + } catch (err) { + console.error('[RowboatAdd] Failed to write target tags:', err) + } + }, 500) + } + } else { + // One-time task: remove the processing block, insert response in its place + const insertPos = currentPos + const deleteEnd = currentPos + node.nodeSize + editor.chain().focus().deleteRange({ from: insertPos, to: deleteEnd }).run() + + if (result.response) { + editor.chain().insertContentAt(insertPos, result.response).run() + } + } + } catch (error) { + console.error('[RowboatAdd] Processing failed:', error) + + // Remove the processing block on error + const currentPos = findProcessingBlock() + if (currentPos !== null) { + const node = editor.state.doc.nodeAt(currentPos) + if (node) { + editor.chain().focus().deleteRange({ from: currentPos, to: currentPos + node.nodeSize }).run() + } + } + } } - }, [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..935d7a81 100644 --- a/apps/x/apps/renderer/src/extensions/task-block.tsx +++ b/apps/x/apps/renderer/src/extensions/task-block.tsx @@ -1,22 +1,39 @@ 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 formatDateTime(iso: string): string { + const d = new Date(iso) + return d.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true, + }) +} + function TaskBlockView({ node, deleteNode }: { node: { attrs: Record }; deleteNode: () => void }) { const raw = node.attrs.data as string let instruction = '' let scheduleLabel = '' + let processing = false + let lastRunAt = '' try { const parsed = inlineTask.InlineTaskBlockSchema.parse(JSON.parse(raw)) instruction = parsed.instruction scheduleLabel = parsed['schedule-label'] ?? '' + processing = parsed.processing ?? false + lastRunAt = parsed.lastRunAt ?? '' } catch { // Fallback: show raw data instruction = raw } + const lastRunLabel = lastRunAt ? formatDateTime(lastRunAt) : '' + return (
@@ -29,10 +46,17 @@ function TaskBlockView({ node, deleteNode }: { node: { attrs: Record
@rowboat {instruction} - {scheduleLabel && ( + {processing && ( + + + processing… + + )} + {!processing && scheduleLabel && ( {scheduleLabel} + {lastRunLabel && · last ran {lastRunLabel}} )}
diff --git a/apps/x/apps/renderer/src/styles/editor.css b/apps/x/apps/renderer/src/styles/editor.css index 794d8a08..12767630 100644 --- a/apps/x/apps/renderer/src/styles/editor.css +++ b/apps/x/apps/renderer/src/styles/editor.css @@ -608,6 +608,10 @@ color: color-mix(in srgb, var(--foreground) 55%, transparent); } +.tiptap-editor .ProseMirror .task-block-last-run { + color: color-mix(in srgb, var(--foreground) 38%, transparent); +} + /* Shared block styles (image, embed, chart, table) */ .tiptap-editor .ProseMirror .image-block-wrapper, .tiptap-editor .ProseMirror .embed-block-wrapper, 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..3b5a1577 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: @@ -12,16 +19,74 @@ ${toolEntries} --- # Task -You are an inline task execution agent. You receive a @rowboat instruction from within a knowledge note and execute it. +You are an inline task execution agent. You receive a @rowboat instruction from within a knowledge note and either execute it immediately or set it up as a recurring task. -# Instructions +# Two Modes -1. You will receive the full content of a knowledge note and a specific instruction extracted from a \`@rowboat \` line in that note. -2. Execute the instruction using your full workspace tool set. You have access to read files, edit files, search, run commands, etc. -3. Use the surrounding note content as context for the task. -4. Your response will be inserted directly into the note below the @rowboat instruction. Write your output as note content — it must read naturally as part of the document. -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. +## 1. One-Time Tasks (no scheduling intent) +For instructions that should be executed immediately (e.g., "summarize this note", "look up the weather"): +- Execute the instruction using your full workspace tool set +- Return the result as markdown content +- Do NOT include any schedule or instruction markers + +## 2. Recurring/Scheduled Tasks (has scheduling intent) +For instructions that imply a recurring or future-scheduled task (e.g., "every morning at 8am check emails", "remind me tomorrow at 3pm"): +- Do NOT execute the task — only set up the schedule +- You MUST include BOTH markers described below +- Do NOT include any other content besides the markers + +# Markers for Scheduled Tasks + +When the instruction has scheduling intent, your response MUST contain these markers and nothing else: + +## Schedule Marker (required) + + +Schedule types: +1. "cron" — recurring: \`\` + "startDate" defaults to now (${nowISO}). "endDate" defaults to 7 days from now (${defaultEndISO}). + Example: "every morning at 8am" → \`\` + +2. "window" — recurring with time window: \`\` + +3. "once" — future one-time: \`\` + +The "label" must be a short plain-English description starting with "runs" (e.g., "runs daily at 8 AM until Mar 24"). + +## Instruction Marker (required for scheduled tasks) + + +This is the instruction that will be executed on each scheduled run. You may refine/clarify the original instruction to make it more specific and actionable for the background agent that will execute it. For example: +- User says "check my emails every morning" → \`\` +- User says "news about claude daily" → \`\` + +If the instruction is already clear and actionable, you can keep it as-is. + +# Context + +Current local time: ${localNow} +Timezone: ${tz} +Current UTC time: ${nowISO} + +# Output Rules + +- For one-time tasks: write output as note content — it must read naturally as part of the document. NEVER include meta-commentary. Keep concise and well-formatted in markdown. +- For scheduled tasks: output ONLY the two markers (schedule + instruction), nothing else. +- Do not modify the original note file — the system handles all insertions. + +# Target Regions + +For recurring/scheduled tasks, the note will contain a **target region** delimited by HTML comment tags: + +\`\`\` + +...existing content... + +\`\`\` + +When you see a target region associated with your task (during a scheduled run), your response MUST be the replacement content for that region. You should: +- Write content that replaces whatever is currently between the tags +- Use the existing content as context (e.g., to update rather than regenerate from scratch if appropriate) +- Do NOT include the target tags themselves in your response `; } diff --git a/apps/x/packages/core/src/knowledge/inline_tasks.ts b/apps/x/packages/core/src/knowledge/inline_tasks.ts index 15d5ffc4..3f7c5ffa 100644 --- a/apps/x/packages/core/src/knowledge/inline_tasks.ts +++ b/apps/x/packages/core/src/knowledge/inline_tasks.ts @@ -176,6 +176,8 @@ interface InlineTask { startLine: number; /** Line index of the closing ``` fence */ endLine: number; + /** Target region ID for recurring tasks */ + targetId: string | null; } /** @@ -183,7 +185,7 @@ interface InlineTask { * Returns { instruction, schedule } or null if not valid JSON. * Also supports legacy @rowboat format. */ -function parseBlockContent(contentLines: string[]): { instruction: string; schedule: InlineTaskSchedule | null; lastRunAt: string | null } | null { +function parseBlockContent(contentLines: string[]): { instruction: string; schedule: InlineTaskSchedule | null; lastRunAt: string | null; targetId: string | null } | null { const raw = contentLines.join('\n').trim(); try { const data = JSON.parse(raw); @@ -193,6 +195,7 @@ function parseBlockContent(contentLines: string[]): { instruction: string; sched instruction: parsed.data.instruction, schedule: parsed.data.schedule ? { ...parsed.data.schedule, label: parsed.data['schedule-label'] ?? '' } as InlineTaskSchedule : null, lastRunAt: parsed.data.lastRunAt ?? null, + targetId: parsed.data.targetId ?? null, }; } // Fallback for blocks that have instruction but don't fully match schema @@ -201,6 +204,7 @@ function parseBlockContent(contentLines: string[]): { instruction: string; sched instruction: data.instruction, schedule: data.schedule ?? null, lastRunAt: data.lastRunAt ?? null, + targetId: data.targetId ?? null, }; } } catch { @@ -227,7 +231,7 @@ function parseBlockContent(contentLines: string[]): { instruction: string; sched const rawInstruction = firstRowboatLine?.trim() ?? instructionLines.join('\n').trim(); const instruction = rawInstruction.replace(/^@rowboat:?\s*/, ''); if (!instruction) return null; - return { instruction, schedule, lastRunAt: null }; + return { instruction, schedule, lastRunAt: null, targetId: null }; } /** @@ -308,16 +312,16 @@ function findPendingTasks(body: string): InlineTask[] { const parsed = parseBlockContent(contentLines); if (parsed) { - const { instruction, schedule, lastRunAt } = parsed; + const { instruction, schedule, lastRunAt, targetId } = parsed; if (schedule) { if (isScheduledTaskDue(schedule, lastRunAt)) { - tasks.push({ instruction, schedule, startLine, endLine }); + tasks.push({ instruction, schedule, startLine, endLine, targetId }); } } else { // One-time task: skip if already ran if (!lastRunAt) { - tasks.push({ instruction, schedule: null, startLine, endLine }); + tasks.push({ instruction, schedule: null, startLine, endLine, targetId }); } } } @@ -339,6 +343,32 @@ function insertResultBelow(body: string, endLine: number, result: string): strin } +/** + * Replace content inside a target region identified by targetId. + * If the target region exists, replaces its content. + * If it doesn't exist, creates the target region below the task block, + * wrapping any existing content between the block and the next block/heading. + */ +function replaceTargetRegion(body: string, targetId: string, result: string, endLine: number): string { + const openTag = ``; + const closeTag = ``; + const openIdx = body.indexOf(openTag); + const closeIdx = body.indexOf(closeTag); + + if (openIdx !== -1 && closeIdx !== -1 && closeIdx > openIdx) { + // Target region exists — replace content between the tags + const before = body.slice(0, openIdx + openTag.length); + const after = body.slice(closeIdx); + return before + '\n' + result + '\n' + after; + } + + // Target region doesn't exist yet — create it below the task block's closing fence + const lines = body.split('\n'); + const taggedResult = `${openTag}\n${result}\n${closeTag}`; + lines.splice(endLine + 1, 0, '', taggedResult); + return lines.join('\n'); +} + /** * Determine if a note has any "live" tell-rowboat tasks. * A task is live if: @@ -495,7 +525,13 @@ async function processInlineTasks(): Promise { const result = await extractAgentResponse(run.id); if (result) { - currentBody = insertResultBelow(currentBody, task.endLine, result); + if (task.targetId) { + // Recurring task with target region — replace content inside the region + currentBody = replaceTargetRegion(currentBody, task.targetId, result, task.endLine); + } else { + // No target region — insert below the block + currentBody = insertResultBelow(currentBody, task.endLine, result); + } // Update the block JSON with lastRunAt const timestamp = new Date().toISOString(); currentBody = updateBlockData(currentBody, task.startLine, task.endLine, timestamp); @@ -531,6 +567,85 @@ 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. + */ +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 }; + +export async function processRowboatInstruction( + instruction: string, + noteContent: string, + notePath: string, +): Promise<{ + instruction: string; + schedule: ScheduleWithoutLabel | null; + scheduleLabel: string | null; + response: string | null; +}> { + const run = await createRun({ agentId: INLINE_TASK_AGENT }); + + const message = [ + `Process the following @rowboat 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 { instruction, schedule: null, scheduleLabel: null, response: null }; + } + + // Parse out the schedule marker if present (allow multiline JSON) + const scheduleMarkerRegex = //; + const scheduleMatch = rawResponse.match(scheduleMarkerRegex); + + // Parse out the instruction marker if present + const instructionMarkerRegex = //; + const instructionMatch = rawResponse.match(instructionMarkerRegex); + + let schedule: ScheduleWithoutLabel | null = null; + let scheduleLabel: string | null = null; + let refinedInstruction = instruction; + + if (instructionMatch) { + refinedInstruction = instructionMatch[1].trim(); + } + + if (scheduleMatch) { + try { + const parsed = JSON.parse(scheduleMatch[1]); + if (parsed && typeof parsed === 'object' && parsed.type) { + scheduleLabel = parsed.label || null; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { label: _, ...rest } = parsed; + schedule = rest as ScheduleWithoutLabel; + } + } catch { + // Invalid JSON in marker — ignore + } + + // Scheduled task — no response content (agent only returns markers) + return { instruction: refinedInstruction, schedule, scheduleLabel, response: null }; + } + + // One-time task — the full response is the content + const response = rawResponse.trim() || null; + return { instruction: refinedInstruction, schedule: null, scheduleLabel: null, 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..1d405829 100644 --- a/apps/x/packages/shared/src/inline-task.ts +++ b/apps/x/packages/shared/src/inline-task.ts @@ -28,6 +28,8 @@ export const InlineTaskBlockSchema = z.object({ schedule: InlineTaskScheduleSchema.optional(), 'schedule-label': z.string().optional(), lastRunAt: z.string().optional(), + processing: z.boolean().optional(), + targetId: z.string().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..5725f035 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -522,6 +522,23 @@ const ipcSchemas = { ]).nullable(), }), }, + 'inline-task:process': { + req: z.object({ + instruction: z.string(), + noteContent: z.string(), + notePath: z.string(), + }), + res: z.object({ + instruction: z.string(), + 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(),