tasks execute immediately

This commit is contained in:
Arjun 2026-03-19 00:04:19 +05:30
parent 91030a5fca
commit d291ceec80
8 changed files with 245 additions and 24 deletions

View file

@ -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 { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js';
import { search } from '@x/core/dist/search/search.js'; import { search } from '@x/core/dist/search/search.js';
import { versionHistory, voice } from '@x/core'; 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'; import { getBillingInfo } from '@x/core/dist/billing/billing.js';
/** /**
@ -705,6 +705,9 @@ export function setupIpcHandlers() {
const schedule = await classifySchedule(args.instruction); const schedule = await classifySchedule(args.instruction);
return { schedule }; return { schedule };
}, },
'inline-task:process': async (_event, args) => {
return await processRowboatInstruction(args.instruction, args.noteContent, args.notePath);
},
'voice:getConfig': async () => { 'voice:getConfig': async () => {
return voice.getVoiceConfig(); return voice.getVoiceConfig();
}, },

View file

@ -3869,6 +3869,7 @@ function App() {
> >
<MarkdownEditor <MarkdownEditor
content={tabContent} content={tabContent}
notePath={tab.path}
onChange={(markdown) => { if (!isViewingHistory) handleEditorChange(tab.path, markdown) }} onChange={(markdown) => { if (!isViewingHistory) handleEditorChange(tab.path, markdown) }}
onPrimaryHeadingCommit={() => { onPrimaryHeadingCommit={() => {
untitledRenameReadyPathsRef.current.add(tab.path) untitledRenameReadyPathsRef.current.add(tab.path)

View file

@ -232,6 +232,7 @@ interface MarkdownEditorProps {
frontmatter?: string | null frontmatter?: string | null
onFrontmatterChange?: (raw: string | null) => void onFrontmatterChange?: (raw: string | null) => void
onExport?: (format: 'md' | 'pdf' | 'docx') => void onExport?: (format: 'md' | 'pdf' | 'docx') => void
notePath?: string
} }
type WikiLinkMatch = { type WikiLinkMatch = {
@ -323,6 +324,7 @@ export function MarkdownEditor({
frontmatter, frontmatter,
onFrontmatterChange, onFrontmatterChange,
onExport, onExport,
notePath,
}: MarkdownEditorProps) { }: MarkdownEditorProps) {
const isInternalUpdate = useRef(false) const isInternalUpdate = useRef(false)
const wrapperRef = useRef<HTMLDivElement>(null) const wrapperRef = useRef<HTMLDivElement>(null)
@ -928,24 +930,17 @@ export function MarkdownEditor({
} }
if (activeRowboatMention) { if (activeRowboatMention) {
// Classify schedule intent for new blocks // Insert block with processing state immediately
const blockData: Record<string, unknown> = { instruction } const blockData: Record<string, unknown> = { instruction, processing: true }
try {
const result = await window.ipc.invoke('inline-task:classifySchedule', { instruction }) const insertFrom = activeRowboatMention.range.from
if (result.schedule) { const insertTo = activeRowboatMention.range.to
const { label, ...rest } = result.schedule
blockData.schedule = rest
blockData['schedule-label'] = label
}
} catch (error) {
console.error('[RowboatAdd] Schedule classification failed:', error)
}
editor editor
.chain() .chain()
.focus() .focus()
.insertContentAt( .insertContentAt(
{ from: activeRowboatMention.range.from, to: activeRowboatMention.range.to }, { from: insertFrom, to: insertTo },
[ [
{ type: 'taskBlock', attrs: { data: JSON.stringify(blockData) } }, { type: 'taskBlock', attrs: { data: JSON.stringify(blockData) } },
{ type: 'paragraph' }, { type: 'paragraph' },
@ -953,17 +948,109 @@ export function MarkdownEditor({
) )
.run() .run()
// Mark note as live
if (onFrontmatterChange) {
const fields = extractAllFrontmatterValues(frontmatter ?? null)
fields['live_note'] = 'true'
onFrontmatterChange(buildFrontmatter(fields))
}
setActiveRowboatMention(null) setActiveRowboatMention(null)
setRowboatAnchorTop(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<string, unknown> = { 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(() => { const handleRowboatRemove = useCallback(() => {
if (!editor || !rowboatBlockEdit) return if (!editor || !rowboatBlockEdit) return

View file

@ -1,17 +1,19 @@
import { mergeAttributes, Node } from '@tiptap/react' import { mergeAttributes, Node } from '@tiptap/react'
import { ReactNodeViewRenderer, NodeViewWrapper } 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' import { inlineTask } from '@x/shared'
function TaskBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) { function TaskBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
const raw = node.attrs.data as string const raw = node.attrs.data as string
let instruction = '' let instruction = ''
let scheduleLabel = '' let scheduleLabel = ''
let processing = false
try { try {
const parsed = inlineTask.InlineTaskBlockSchema.parse(JSON.parse(raw)) const parsed = inlineTask.InlineTaskBlockSchema.parse(JSON.parse(raw))
instruction = parsed.instruction instruction = parsed.instruction
scheduleLabel = parsed['schedule-label'] ?? '' scheduleLabel = parsed['schedule-label'] ?? ''
processing = parsed.processing ?? false
} catch { } catch {
// Fallback: show raw data // Fallback: show raw data
instruction = raw instruction = raw
@ -29,7 +31,13 @@ function TaskBlockView({ node, deleteNode }: { node: { attrs: Record<string, unk
</button> </button>
<div className="task-block-content"> <div className="task-block-content">
<span className="task-block-instruction"><span className="task-block-prefix">@rowboat</span> {instruction}</span> <span className="task-block-instruction"><span className="task-block-prefix">@rowboat</span> {instruction}</span>
{scheduleLabel && ( {processing && (
<span className="task-block-schedule">
<Loader2 size={12} className="animate-spin" />
processing
</span>
)}
{!processing && scheduleLabel && (
<span className="task-block-schedule"> <span className="task-block-schedule">
<CalendarClock size={12} /> <CalendarClock size={12} />
{scheduleLabel} {scheduleLabel}

View file

@ -5,6 +5,13 @@ export function getRaw(): string {
.map(name => ` ${name}:\n type: builtin\n name: ${name}`) .map(name => ` ${name}:\n type: builtin\n name: ${name}`)
.join('\n'); .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 `--- return `---
model: gpt-5.2 model: gpt-5.2
tools: 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. 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. 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. 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:
<!--rowboat-schedule:{"type":"...","label":"..."}-->
Place this marker at the very beginning of your response, on its own line, before any other content.
Schedule types:
1. "cron" recurring schedule: <!--rowboat-schedule:{"type":"cron","expression":"<5-field cron>","startDate":"<ISO>","endDate":"<ISO>","label":"<human readable>"}-->
"startDate" defaults to now (${nowISO}). "endDate" defaults to 7 days from now (${defaultEndISO}).
Example: "every morning at 8am" <!--rowboat-schedule:{"type":"cron","expression":"0 8 * * *","startDate":"${nowISO}","endDate":"${defaultEndISO}","label":"runs daily at 8 AM until ${defaultEnd.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}"}-->
2. "window" recurring with a time window: <!--rowboat-schedule:{"type":"window","cron":"<cron>","startTime":"HH:MM","endTime":"HH:MM","startDate":"<ISO>","endDate":"<ISO>","label":"<human readable>"}-->
3. "once" run once at a specific future time: <!--rowboat-schedule:{"type":"once","runAt":"<ISO 8601>","label":"<human readable>"}-->
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.
`; `;
} }

View file

@ -531,6 +531,78 @@ async function processInlineTasks(): Promise<void> {
} }
} }
/**
* 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 = /<!--rowboat-schedule:(.*?)-->/;
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. * Classify whether an instruction contains a scheduling intent using the user's configured LLM.
* Returns a schedule object or null for one-time tasks. * Returns a schedule object or null for one-time tasks.

View file

@ -28,6 +28,7 @@ export const InlineTaskBlockSchema = z.object({
schedule: InlineTaskScheduleSchema.optional(), schedule: InlineTaskScheduleSchema.optional(),
'schedule-label': z.string().optional(), 'schedule-label': z.string().optional(),
lastRunAt: z.string().optional(), lastRunAt: z.string().optional(),
processing: z.boolean().optional(),
}); });
export type InlineTaskBlock = z.infer<typeof InlineTaskBlockSchema>; export type InlineTaskBlock = z.infer<typeof InlineTaskBlockSchema>;

View file

@ -522,6 +522,22 @@ const ipcSchemas = {
]).nullable(), ]).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 channels
'billing:getInfo': { 'billing:getInfo': {
req: z.null(), req: z.null(),