diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index f04b217e..590b6585 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -973,6 +973,7 @@ export function MarkdownEditor({ } try { + // Call the copilot assistant for both one-time and recurring tasks const result = await window.ipc.invoke('inline-task:process', { instruction, noteContent: editorContent, @@ -986,11 +987,13 @@ export function MarkdownEditor({ if (!node) return if (result.schedule) { - // Scheduled task: keep the block, update with schedule info + // 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, + instruction: result.instruction, schedule: result.schedule, 'schedule-label': result.scheduleLabel, + targetId, } const tr = editor.state.tr.setNodeMarkup(currentPos, undefined, { data: JSON.stringify(updatedData), @@ -1004,24 +1007,40 @@ export function MarkdownEditor({ onFrontmatterChange(buildFrontmatter(fields)) } - // Insert response text below the block if any - if (result.response) { - let afterPos: number | null = null - editor.state.doc.descendants((n, p) => { - if (afterPos !== null) return false - if (n.type.name === 'taskBlock') { - try { - const data = JSON.parse(n.attrs.data || '{}') - if (data.instruction === instruction && !data.processing) { - afterPos = p + n.nodeSize - return false + // 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 { /* skip */ } + } + } catch (err) { + console.error('[RowboatAdd] Failed to write target tags:', err) } - }) - if (afterPos !== null) { - editor.chain().insertContentAt(afterPos, result.response).run() - } + }, 500) } } else { // One-time task: remove the processing block, insert response in its place 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 1a80ee1e..3b5a1577 100644 --- a/apps/x/packages/core/src/knowledge/inline_task_agent.ts +++ b/apps/x/packages/core/src/knowledge/inline_task_agent.ts @@ -19,42 +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 -# Schedule Classification +## 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 -If the instruction implies a recurring or future-scheduled task, you MUST include a schedule marker in your response using this exact format: +# Markers for Scheduled Tasks +When the instruction has scheduling intent, your response MUST contain these markers and nothing else: + +## Schedule Marker (required) -Place this marker at the very beginning of your response, on its own line, before any other content. - Schedule types: -1. "cron" — recurring schedule: +1. "cron" — recurring: \`\` "startDate" defaults to now (${nowISO}). "endDate" defaults to 7 days from now (${defaultEndISO}). - Example: "every morning at 8am" → + Example: "every morning at 8am" → \`\` -2. "window" — recurring with a time window: +2. "window" — recurring with time window: \`\` -3. "once" — run once at a specific future time: +3. "once" — future one-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"). +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} -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. +# 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 1b5e3876..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); @@ -536,22 +572,25 @@ async function processInlineTasks(): Promise { * 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<{ - 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; + instruction: string; + schedule: ScheduleWithoutLabel | 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}":`, + `Process the following @rowboat instruction from the note "${notePath}":`, '', `**Instruction:** ${instruction}`, '', @@ -566,41 +605,45 @@ export async function processRowboatInstruction( const rawResponse = await extractAgentResponse(run.id); if (!rawResponse) { - return { schedule: null, scheduleLabel: null, response: null }; + return { instruction, schedule: null, scheduleLabel: null, response: null }; } - // Parse out the schedule marker if present - const scheduleMarkerRegex = //; - const match = rawResponse.match(scheduleMarkerRegex); + // Parse out the schedule marker if present (allow multiline JSON) + const scheduleMarkerRegex = //; + const scheduleMatch = 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 }; + // 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 response: string | null = null; + let refinedInstruction = instruction; - if (match) { + if (instructionMatch) { + refinedInstruction = instructionMatch[1].trim(); + } + + if (scheduleMatch) { try { - const parsed = JSON.parse(match[1]); + 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 } - // Remove the marker from the response text - const cleaned = rawResponse.replace(scheduleMarkerRegex, '').trim(); - response = cleaned || null; - } else { - response = rawResponse.trim() || null; + + // Scheduled task — no response content (agent only returns markers) + return { instruction: refinedInstruction, schedule, scheduleLabel, response: null }; } - return { schedule, scheduleLabel, response }; + // One-time task — the full response is the content + const response = rawResponse.trim() || null; + return { instruction: refinedInstruction, schedule: null, scheduleLabel: null, response }; } /** diff --git a/apps/x/packages/shared/src/inline-task.ts b/apps/x/packages/shared/src/inline-task.ts index f87a276c..1d405829 100644 --- a/apps/x/packages/shared/src/inline-task.ts +++ b/apps/x/packages/shared/src/inline-task.ts @@ -29,6 +29,7 @@ export const InlineTaskBlockSchema = z.object({ '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 ad6211b0..5725f035 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -529,6 +529,7 @@ const ipcSchemas = { 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() }),