mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-16 18:25:17 +02:00
tasks execute immediately
This commit is contained in:
parent
91030a5fca
commit
d291ceec80
8 changed files with 245 additions and 24 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 { 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();
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue