mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-28 09:56:23 +02:00
Livenote2 (#440)
* tasks execute immediately * response formatting * remove at rowbot block for single tasks * show last ran time stamp
This commit is contained in:
parent
91030a5fca
commit
affc9956f4
9 changed files with 372 additions and 39 deletions
|
|
@ -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();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3869,6 +3869,7 @@ function App() {
|
|||
>
|
||||
<MarkdownEditor
|
||||
content={tabContent}
|
||||
notePath={tab.path}
|
||||
onChange={(markdown) => { if (!isViewingHistory) handleEditorChange(tab.path, markdown) }}
|
||||
onPrimaryHeadingCommit={() => {
|
||||
untitledRenameReadyPathsRef.current.add(tab.path)
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(null)
|
||||
|
|
@ -928,24 +930,17 @@ export function MarkdownEditor({
|
|||
}
|
||||
|
||||
if (activeRowboatMention) {
|
||||
// Classify schedule intent for new blocks
|
||||
const blockData: Record<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = {
|
||||
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 = `<!--task-target:${targetId}-->`
|
||||
const closeTag = `<!--/task-target:${targetId}-->`
|
||||
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> }; 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 (
|
||||
<NodeViewWrapper className="task-block-wrapper" data-type="task-block">
|
||||
<div className="task-block-card">
|
||||
|
|
@ -29,10 +46,17 @@ function TaskBlockView({ node, deleteNode }: { node: { attrs: Record<string, unk
|
|||
</button>
|
||||
<div className="task-block-content">
|
||||
<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">
|
||||
<CalendarClock size={12} />
|
||||
{scheduleLabel}
|
||||
{lastRunLabel && <span className="task-block-last-run"> · last ran {lastRunLabel}</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 <instruction>\` 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)
|
||||
<!--rowboat-schedule:{"type":"...","label":"..."}-->
|
||||
|
||||
Schedule types:
|
||||
1. "cron" — recurring: \`<!--rowboat-schedule:{"type":"cron","expression":"<5-field cron>","startDate":"<ISO>","endDate":"<ISO>","label":"<label>"}-->\`
|
||||
"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 time window: \`<!--rowboat-schedule:{"type":"window","cron":"<cron>","startTime":"HH:MM","endTime":"HH:MM","startDate":"<ISO>","endDate":"<ISO>","label":"<label>"}-->\`
|
||||
|
||||
3. "once" — future one-time: \`<!--rowboat-schedule:{"type":"once","runAt":"<ISO 8601>","label":"<label>"}-->\`
|
||||
|
||||
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)
|
||||
<!--rowboat-instruction:the refined instruction text-->
|
||||
|
||||
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" → \`<!--rowboat-instruction:Check for new emails and summarize any important ones.-->\`
|
||||
- User says "news about claude daily" → \`<!--rowboat-instruction:Search for the latest news about Anthropic's Claude AI and list the top stories with sources.-->\`
|
||||
|
||||
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:
|
||||
|
||||
\`\`\`
|
||||
<!--task-target:TARGETID-->
|
||||
...existing content...
|
||||
<!--/task-target:TARGETID-->
|
||||
\`\`\`
|
||||
|
||||
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
|
||||
`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = `<!--task-target:${targetId}-->`;
|
||||
const closeTag = `<!--/task-target:${targetId}-->`;
|
||||
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<void> {
|
|||
|
||||
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<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.
|
||||
*/
|
||||
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 = /<!--rowboat-schedule:([\s\S]*?)-->/;
|
||||
const scheduleMatch = rawResponse.match(scheduleMarkerRegex);
|
||||
|
||||
// Parse out the instruction marker if present
|
||||
const instructionMarkerRegex = /<!--rowboat-instruction:([\s\S]*?)-->/;
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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<typeof InlineTaskBlockSchema>;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue