mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-26 17:06: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
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue