inline task agent v1

This commit is contained in:
Arjun 2026-03-04 22:15:15 +05:30 committed by arkml
parent 5aba6025dc
commit bd4cc1145d
13 changed files with 1221 additions and 6 deletions

View file

@ -32,6 +32,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 } from '@x/core';
import { classifySchedule } from '@x/core/dist/knowledge/inline_tasks.js';
type InvokeChannels = ipc.InvokeChannels;
type IPCChannels = ipc.IPCChannels;
@ -531,5 +532,10 @@ export function setupIpcHandlers() {
'search:query': async (_event, args) => {
return search(args.query, args.limit, args.types);
},
// Inline task schedule classification
'inline-task:classifySchedule': async (_event, args) => {
const schedule = await classifySchedule(args.instruction);
return { schedule };
},
});
}

View file

@ -19,6 +19,7 @@ import { init as initGranolaSync } from "@x/core/dist/knowledge/granola/sync.js"
import { init as initGraphBuilder } from "@x/core/dist/knowledge/build_graph.js";
import { init as initEmailLabeling } from "@x/core/dist/knowledge/label_emails.js";
import { init as initNoteTagging } from "@x/core/dist/knowledge/tag_notes.js";
import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js";
import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
import started from "electron-squirrel-startup";
@ -178,6 +179,9 @@ app.whenReady().then(async () => {
// start note tagging service
initNoteTagging();
// start inline task service (@rowboat: mentions)
initInlineTasks();
// start background agent runner (scheduled agents)
initAgentRunner();

View file

@ -1017,10 +1017,13 @@ function App() {
frontmatterByPathRef.current.set(pathToLoad, fm)
const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim()
const isSameEditorFile = editorPathRef.current === pathToLoad
const wouldClobberActiveEdits =
isSameEditorFile
&& normalizeForCompare(editorContentRef.current) !== normalizeForCompare(body)
if (!wouldClobberActiveEdits) {
const knownBaseline = initialContentByPathRef.current.get(pathToLoad)
const hasKnownBaseline = knownBaseline !== undefined
const hasUnsavedEdits =
hasKnownBaseline
&& normalizeForCompare(editorContentRef.current) !== normalizeForCompare(knownBaseline)
const shouldPreserveActiveDraft = isSameEditorFile && hasUnsavedEdits
if (!shouldPreserveActiveDraft) {
setEditorContent(body)
if (pathToLoad.endsWith('.md')) {
setEditorCacheForPath(pathToLoad, body)

View file

@ -8,6 +8,7 @@ import Placeholder from '@tiptap/extension-placeholder'
import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item'
import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload'
import { TaskBlockExtension } from '@/extensions/task-block'
import { Markdown } from 'tiptap-markdown'
import { useEffect, useCallback, useMemo, useRef, useState } from 'react'
@ -133,6 +134,8 @@ function getMarkdownWithBlankLines(editor: Editor): string {
})
})
blocks.push(listLines.join('\n'))
} else if (node.type === 'taskBlock') {
blocks.push('```task\n' + (node.attrs?.data as string || '{}') + '\n```')
} else if (node.type === 'codeBlock') {
const lang = (node.attrs?.language as string) || ''
blocks.push('```' + lang + '\n' + nodeToText(node) + '\n```')
@ -181,8 +184,21 @@ import { WikiLink } from '@/extensions/wiki-link'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command'
import { ensureMarkdownExtension, normalizeWikiPath, wikiLabel } from '@/lib/wiki-links'
import { extractAllFrontmatterValues, buildFrontmatter } from '@/lib/frontmatter'
import { RowboatMentionPopover } from './rowboat-mention-popover'
import '@/styles/editor.css'
type RowboatMentionMatch = {
range: { from: number; to: number }
}
type RowboatBlockEdit = {
/** ProseMirror position of the taskBlock node */
nodePos: number
/** Existing instruction text */
existingText: string
}
type WikiLinkConfig = {
files: string[]
recent: string[]
@ -304,6 +320,10 @@ export function MarkdownEditor({
const onPrimaryHeadingCommitRef = useRef(onPrimaryHeadingCommit)
const wikiKeyStateRef = useRef<{ open: boolean; options: string[]; value: string }>({ open: false, options: [], value: '' })
const handleSelectWikiLinkRef = useRef<(path: string) => void>(() => {})
const [activeRowboatMention, setActiveRowboatMention] = useState<RowboatMentionMatch | null>(null)
const [rowboatBlockEdit, setRowboatBlockEdit] = useState<RowboatBlockEdit | null>(null)
const [rowboatAnchorTop, setRowboatAnchorTop] = useState<{ top: number; left: number; width: number } | null>(null)
const rowboatBlockEditRef = useRef<RowboatBlockEdit | null>(null)
// Keep ref in sync with state for the plugin to access
selectionHighlightRef.current = selectionHighlight
@ -399,6 +419,7 @@ export function MarkdownEditor({
},
}),
ImageUploadPlaceholderExtension,
TaskBlockExtension,
WikiLink.configure({
onCreate: wikiLinks?.onCreate
? (path) => {
@ -492,7 +513,7 @@ export function MarkdownEditor({
return false
},
handleClickOn: (_view, _pos, node, _nodePos, event) => {
handleClickOn: (_view, _pos, node, nodePos, event) => {
if (node.type.name === 'wikiLink') {
event.preventDefault()
wikiLinks?.onOpen?.(node.attrs.path)
@ -575,6 +596,55 @@ export function MarkdownEditor({
})
}, [editor, wikiLinks])
const updateRowboatMentionState = useCallback(() => {
if (!editor) return
const { selection } = editor.state
if (!selection.empty) {
setActiveRowboatMention(null)
setRowboatAnchorTop(null)
return
}
const { $from } = selection
if ($from.parent.type.spec.code) {
setActiveRowboatMention(null)
setRowboatAnchorTop(null)
return
}
const text = $from.parent.textBetween(0, $from.parent.content.size, '\n', '\n')
const textBefore = text.slice(0, $from.parentOffset)
// Match @rowboat at a word boundary (preceded by nothing or whitespace)
const match = textBefore.match(/(^|\s)@rowboat$/)
if (!match) {
setActiveRowboatMention(null)
setRowboatAnchorTop(null)
return
}
const triggerStart = textBefore.length - '@rowboat'.length
const from = selection.from - (textBefore.length - triggerStart)
const to = selection.from
setActiveRowboatMention({ range: { from, to } })
const wrapper = wrapperRef.current
if (!wrapper) {
setRowboatAnchorTop(null)
return
}
const coords = editor.view.coordsAtPos(selection.from)
const wrapperRect = wrapper.getBoundingClientRect()
const proseMirrorEl = wrapper.querySelector('.ProseMirror') as HTMLElement | null
const pmRect = proseMirrorEl?.getBoundingClientRect()
setRowboatAnchorTop({
top: coords.top - wrapperRect.top + wrapper.scrollTop,
left: pmRect ? pmRect.left - wrapperRect.left : 0,
width: pmRect ? pmRect.width : wrapperRect.width,
})
}, [editor])
useEffect(() => {
if (!editor || !wikiLinks) return
editor.on('update', updateWikiLinkState)
@ -585,6 +655,32 @@ export function MarkdownEditor({
}
}, [editor, wikiLinks, updateWikiLinkState])
useEffect(() => {
if (!editor) return
editor.on('update', updateRowboatMentionState)
editor.on('selectionUpdate', updateRowboatMentionState)
return () => {
editor.off('update', updateRowboatMentionState)
editor.off('selectionUpdate', updateRowboatMentionState)
}
}, [editor, updateRowboatMentionState])
// When a tell-rowboat block is clicked, compute anchor and open popover
useEffect(() => {
if (!rowboatBlockEdit || !editor) return
const wrapper = wrapperRef.current
if (!wrapper) return
const coords = editor.view.coordsAtPos(rowboatBlockEdit.nodePos)
const wrapperRect = wrapper.getBoundingClientRect()
const proseMirrorEl = wrapper.querySelector('.ProseMirror') as HTMLElement | null
const pmRect = proseMirrorEl?.getBoundingClientRect()
setRowboatAnchorTop({
top: coords.top - wrapperRect.top + wrapper.scrollTop,
left: pmRect ? pmRect.left - wrapperRect.left : 0,
width: pmRect ? pmRect.width : wrapperRect.width,
})
}, [editor, rowboatBlockEdit])
// Update editor content when prop changes (e.g., file selection changes)
useEffect(() => {
if (editor && content !== undefined) {
@ -675,6 +771,85 @@ export function MarkdownEditor({
handleSelectWikiLinkRef.current = handleSelectWikiLink
}, [handleSelectWikiLink])
const handleRowboatAdd = useCallback(async (instruction: string) => {
if (!editor) return
if (rowboatBlockEdit) {
// Editing existing taskBlock — update its data attribute
const { nodePos } = rowboatBlockEdit
const node = editor.state.doc.nodeAt(nodePos)
if (node && node.type.name === 'taskBlock') {
// Preserve existing schedule data
let updated: Record<string, unknown> = { instruction }
try {
const existing = JSON.parse(node.attrs.data || '{}')
updated = { ...existing, instruction }
} catch {
// Invalid JSON — just write new
}
const tr = editor.state.tr.setNodeMarkup(nodePos, undefined, { data: JSON.stringify(updated) })
editor.view.dispatch(tr)
}
setRowboatBlockEdit(null)
rowboatBlockEditRef.current = null
setRowboatAnchorTop(null)
return
}
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)
}
editor
.chain()
.focus()
.insertContentAt(
{ from: activeRowboatMention.range.from, to: activeRowboatMention.range.to },
[
{ type: 'taskBlock', attrs: { data: JSON.stringify(blockData) } },
{ type: 'paragraph' },
],
)
.run()
// Mark note as live
if (onFrontmatterChange) {
const fields = extractAllFrontmatterValues(frontmatter ?? null)
fields['live_note'] = 'true'
onFrontmatterChange(buildFrontmatter(fields))
}
setActiveRowboatMention(null)
setRowboatAnchorTop(null)
}
}, [editor, activeRowboatMention, rowboatBlockEdit, frontmatter, onFrontmatterChange])
const handleRowboatRemove = useCallback(() => {
if (!editor || !rowboatBlockEdit) return
const { nodePos } = rowboatBlockEdit
const node = editor.state.doc.nodeAt(nodePos)
if (node) {
editor
.chain()
.focus()
.deleteRange({ from: nodePos, to: nodePos + node.nodeSize })
.run()
}
setRowboatBlockEdit(null)
rowboatBlockEditRef.current = null
setRowboatAnchorTop(null)
}, [editor, rowboatBlockEdit])
const handleScroll = useCallback(() => {
updateWikiLinkState()
}, [updateWikiLinkState])
@ -789,6 +964,19 @@ export function MarkdownEditor({
</PopoverContent>
</Popover>
) : null}
<RowboatMentionPopover
open={Boolean((activeRowboatMention || rowboatBlockEdit) && rowboatAnchorTop)}
anchor={rowboatAnchorTop}
initialText={rowboatBlockEdit?.existingText ?? ''}
onAdd={handleRowboatAdd}
onRemove={rowboatBlockEdit ? handleRowboatRemove : undefined}
onClose={() => {
setActiveRowboatMention(null)
setRowboatBlockEdit(null)
rowboatBlockEditRef.current = null
setRowboatAnchorTop(null)
}}
/>
</div>
</div>
)

View file

@ -0,0 +1,109 @@
import { useState, useRef, useEffect } from 'react'
import { Loader2 } from 'lucide-react'
interface RowboatMentionPopoverProps {
open: boolean
anchor: { top: number; left: number; width: number } | null
initialText?: string
onAdd: (instruction: string) => void | Promise<void>
onRemove?: () => void
onClose: () => void
}
export function RowboatMentionPopover({ open, anchor, initialText = '', onAdd, onRemove, onClose }: RowboatMentionPopoverProps) {
const [text, setText] = useState('')
const [loading, setLoading] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (open) {
setText(initialText)
setLoading(false)
requestAnimationFrame(() => {
textareaRef.current?.focus()
})
}
}, [open, initialText])
// Close on outside click
useEffect(() => {
if (!open) return
const handleMouseDown = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
onClose()
}
}
document.addEventListener('mousedown', handleMouseDown)
return () => document.removeEventListener('mousedown', handleMouseDown)
}, [open, onClose])
if (!open || !anchor) return null
const handleSubmit = async () => {
const trimmed = text.trim()
if (!trimmed || loading) return
setLoading(true)
try {
await onAdd(trimmed)
} finally {
setLoading(false)
}
setText('')
}
return (
<div
ref={containerRef}
className="absolute z-50"
style={{
top: anchor.top,
left: anchor.left,
width: anchor.width,
}}
>
<div className="relative border border-input rounded-md bg-popover shadow-sm">
<div className="flex items-start gap-1.5 px-3 pt-2 pb-8">
<span className="text-sm text-muted-foreground select-none shrink-0 leading-[1.5]">@rowboat</span>
<textarea
ref={textareaRef}
className="flex-1 bg-transparent text-sm placeholder:text-muted-foreground focus:outline-none resize-none leading-[1.5]"
placeholder=""
rows={2}
value={text}
disabled={loading}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey || e.shiftKey)) {
e.preventDefault()
void handleSubmit()
}
if (e.key === 'Escape') {
e.preventDefault()
onClose()
}
}}
/>
</div>
<div className="absolute bottom-1.5 right-1.5 flex items-center gap-1.5">
{onRemove && (
<button
className="inline-flex items-center justify-center rounded px-2.5 py-1 text-xs font-medium text-muted-foreground hover:text-destructive hover:bg-destructive/10"
onClick={onRemove}
disabled={loading}
>
Remove
</button>
)}
<button
className="inline-flex items-center justify-center rounded bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
disabled={!text.trim() || loading}
onClick={() => void handleSubmit()}
>
{loading ? <Loader2 className="size-3 animate-spin" /> : 'Add'}
</button>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,98 @@
import { mergeAttributes, Node } from '@tiptap/react'
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import { CalendarClock, X } from 'lucide-react'
import { inlineTask } from '@x/shared'
function TaskBlockView({ node, deleteNode }: { node: { attrs: { data: string } }; deleteNode: () => void }) {
const raw = node.attrs.data
let instruction = ''
let scheduleLabel = ''
try {
const parsed = inlineTask.InlineTaskBlockSchema.parse(JSON.parse(raw))
instruction = parsed.instruction
scheduleLabel = parsed['schedule-label'] ?? ''
} catch {
// Fallback: show raw data
instruction = raw
}
return (
<NodeViewWrapper className="task-block-wrapper" data-type="task-block">
<div className="task-block-card">
<button
className="task-block-delete"
onClick={deleteNode}
aria-label="Delete task block"
>
<X size={14} />
</button>
<div className="task-block-content">
<span className="task-block-instruction"><span className="task-block-prefix">@rowboat</span> {instruction}</span>
{scheduleLabel && (
<span className="task-block-schedule">
<CalendarClock size={12} />
{scheduleLabel}
</span>
)}
</div>
</div>
</NodeViewWrapper>
)
}
export const TaskBlockExtension = Node.create({
name: 'taskBlock',
group: 'block',
atom: true,
selectable: true,
draggable: false,
addAttributes() {
return {
data: {
default: '{}',
},
}
},
parseHTML() {
return [
{
tag: 'pre',
priority: 60,
getAttrs(element) {
const code = element.querySelector('code')
if (!code) return false
const cls = code.className || ''
if (cls.includes('language-task') || cls.includes('language-tell-rowboat')) {
return { data: code.textContent || '{}' }
}
return false
},
},
]
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'task-block' })]
},
addNodeView() {
return ReactNodeViewRenderer(TaskBlockView)
},
addStorage() {
return {
markdown: {
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
state.write('```task\n' + node.attrs.data + '\n```')
state.closeBlock(node)
},
parse: {
// handled by parseHTML
},
},
}
},
})

View file

@ -531,6 +531,83 @@
transition: width 0.3s ease;
}
/* Task block */
.tiptap-editor .ProseMirror .task-block-wrapper {
margin: 8px 0;
}
.tiptap-editor .ProseMirror .task-block-card {
position: relative;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 8px;
background-color: color-mix(in srgb, var(--muted) 40%, transparent);
cursor: default;
transition: background-color 0.15s ease;
}
.tiptap-editor .ProseMirror .task-block-delete {
position: absolute;
top: 6px;
right: 6px;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
border-radius: 4px;
background: none;
cursor: pointer;
color: color-mix(in srgb, var(--foreground) 40%, transparent);
opacity: 0;
transition: opacity 0.15s ease, background-color 0.15s ease;
}
.tiptap-editor .ProseMirror .task-block-card:hover .task-block-delete {
opacity: 1;
}
.tiptap-editor .ProseMirror .task-block-delete:hover {
background-color: color-mix(in srgb, var(--foreground) 8%, transparent);
color: var(--foreground);
}
.tiptap-editor .ProseMirror .task-block-card:hover {
background-color: color-mix(in srgb, var(--muted) 70%, transparent);
}
.tiptap-editor .ProseMirror .task-block-wrapper.ProseMirror-selectednode .task-block-card {
outline: 2px solid var(--primary);
outline-offset: 1px;
}
.tiptap-editor .ProseMirror .task-block-content {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.tiptap-editor .ProseMirror .task-block-prefix {
color: var(--primary);
font-weight: 600;
}
.tiptap-editor .ProseMirror .task-block-instruction {
font-size: 14px;
line-height: 1.4;
color: var(--foreground);
}
.tiptap-editor .ProseMirror .task-block-schedule {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: color-mix(in srgb, var(--foreground) 55%, transparent);
}
/* Dark mode overrides */
.dark .tiptap-editor .ProseMirror {
color: rgba(255, 255, 255, 0.9);
@ -552,6 +629,10 @@
background: rgba(255, 255, 255, 0.05);
}
.dark .tiptap-editor .ProseMirror pre code {
color: inherit;
}
.dark .tiptap-editor .ProseMirror code {
background: rgba(255, 255, 255, 0.1);
color: #ff7b72;

View file

@ -27,6 +27,7 @@ import { parse } from "yaml";
import { getRaw as getNoteCreationRaw } from "../knowledge/note_creation.js";
import { getRaw as getLabelingAgentRaw } from "../knowledge/labeling_agent.js";
import { getRaw as getNoteTaggingAgentRaw } from "../knowledge/note_tagging_agent.js";
import { getRaw as getInlineTaskAgentRaw } from "../knowledge/inline_task_agent.js";
export interface IAgentRuntime {
trigger(runId: string): Promise<void>;
@ -390,6 +391,31 @@ export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
return agent;
}
if (id === 'inline_task_agent') {
const inlineTaskAgentRaw = getInlineTaskAgentRaw();
let agent: z.infer<typeof Agent> = {
name: id,
instructions: inlineTaskAgentRaw,
};
if (inlineTaskAgentRaw.startsWith("---")) {
const end = inlineTaskAgentRaw.indexOf("\n---", 3);
if (end !== -1) {
const fm = inlineTaskAgentRaw.slice(3, end).trim();
const content = inlineTaskAgentRaw.slice(end + 4).trim();
const yaml = parse(fm);
const parsed = Agent.omit({ name: true, instructions: true }).parse(yaml);
agent = {
...agent,
...parsed,
instructions: content,
};
}
}
return agent;
}
const repo = container.resolve<IAgentsRepo>('agentsRepo');
return await repo.fetch(id);
}

View file

@ -0,0 +1,27 @@
import { BuiltinTools } from '../application/lib/builtin-tools.js';
export function getRaw(): string {
const toolEntries = Object.keys(BuiltinTools)
.map(name => ` ${name}:\n type: builtin\n name: ${name}`)
.join('\n');
return `---
model: gpt-5.2
tools:
${toolEntries}
---
# Task
You are an inline task execution agent. You receive a @rowboat instruction from within a knowledge note and execute it.
# Instructions
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.
`;
}

View file

@ -0,0 +1,626 @@
import fs from 'fs';
import path from 'path';
import { CronExpressionParser } from 'cron-parser';
import { generateText } from 'ai';
import { WorkDir } from '../config/config.js';
import { createRun, createMessage, fetchRun } from '../runs/runs.js';
import { bus } from '../runs/bus.js';
import container from '../di/container.js';
import type { IModelConfigRepo } from '../models/repo.js';
import { createProvider } from '../models/models.js';
import { inlineTask } from '@x/shared';
const SYNC_INTERVAL_MS = 15 * 1000; // 15 seconds
const INLINE_TASK_AGENT = 'inline_task_agent';
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
// ---------------------------------------------------------------------------
// Minimal frontmatter helpers (duplicated from renderer to avoid cross-package
// dependency — can be moved to shared later).
// ---------------------------------------------------------------------------
function splitFrontmatter(content: string): { raw: string | null; body: string } {
if (!content.startsWith('---')) {
return { raw: null, body: content };
}
const endIndex = content.indexOf('\n---', 3);
if (endIndex === -1) {
return { raw: null, body: content };
}
const closingEnd = endIndex + 4;
const raw = content.slice(0, closingEnd);
let body = content.slice(closingEnd);
if (body.startsWith('\n')) {
body = body.slice(1);
}
return { raw, body };
}
function joinFrontmatter(raw: string | null, body: string): string {
if (!raw) return body;
return raw + '\n' + body;
}
function extractAllFrontmatterValues(raw: string | null): Record<string, string | string[]> {
const result: Record<string, string | string[]> = {};
if (!raw) return result;
const lines = raw.split('\n');
let currentKey: string | null = null;
for (const line of lines) {
if (line === '---' || line.trim() === '') {
currentKey = null;
continue;
}
const topMatch = line.match(/^(\w[\w\s]*\w|\w+):\s*(.*)$/);
if (topMatch) {
const key = topMatch[1];
const value = topMatch[2].trim();
if (value) {
result[key] = value;
currentKey = null;
} else {
currentKey = key;
result[key] = [];
}
continue;
}
if (currentKey) {
const itemMatch = line.match(/^\s+-\s+(.+)$/);
if (itemMatch) {
const arr = result[currentKey];
if (Array.isArray(arr)) {
arr.push(itemMatch[1].trim());
}
}
}
}
return result;
}
function buildFrontmatter(fields: Record<string, string | string[]>): string | null {
const lines: string[] = [];
for (const [key, value] of Object.entries(fields)) {
if (Array.isArray(value)) {
if (value.length === 0) continue;
lines.push(`${key}:`);
for (const item of value) {
if (item.trim()) lines.push(` - ${item.trim()}`);
}
} else {
const trimmed = (value ?? '').trim();
if (!trimmed) continue;
lines.push(`${key}: ${trimmed}`);
}
}
if (lines.length === 0) return null;
return `---\n${lines.join('\n')}\n---`;
}
// ---------------------------------------------------------------------------
// Schedule types
// ---------------------------------------------------------------------------
type InlineTaskSchedule =
| { type: 'cron'; expression: string; startDate: string; endDate: string; label: string }
| { type: 'window'; cron: string; startTime: string; endTime: string; startDate: string; endDate: string; label: string }
| { type: 'once'; runAt: string; label: string };
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function scanDirectoryRecursive(dir: string): string[] {
if (!fs.existsSync(dir)) return [];
const files: string[] = [];
const entries = fs.readdirSync(dir);
for (const entry of entries) {
if (entry.startsWith('.')) continue;
const fullPath = path.join(dir, entry);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
files.push(...scanDirectoryRecursive(fullPath));
} else if (stat.isFile() && entry.endsWith('.md')) {
files.push(fullPath);
}
}
return files;
}
/**
* Wait for a run to complete by listening for run-processing-end event
*/
async function waitForRunCompletion(runId: string): Promise<void> {
return new Promise(async (resolve) => {
const unsubscribe = await bus.subscribe('*', async (event) => {
if (event.type === 'run-processing-end' && event.runId === runId) {
unsubscribe();
resolve();
}
});
});
}
/**
* Extract the assistant's final text response from a run's log.
*/
async function extractAgentResponse(runId: string): Promise<string | null> {
const run = await fetchRun(runId);
// Walk backwards through the log to find the last assistant message
for (let i = run.log.length - 1; i >= 0; i--) {
const event = run.log[i];
if (event.type === 'message' && event.message.role === 'assistant') {
const content = event.message.content;
if (typeof content === 'string') {
return content;
}
// Content may be an array of parts — concatenate text parts
if (Array.isArray(content)) {
const text = content
.filter((p) => p.type === 'text')
.map((p) => (p as { type: 'text'; text: string }).text)
.join('');
return text || null;
}
}
}
return null;
}
interface InlineTask {
instruction: string;
schedule: InlineTaskSchedule | null;
/** Line index of the opening ```task fence in the body */
startLine: number;
/** Line index of the closing ``` fence */
endLine: number;
}
/**
* Parse the tell-rowboat block content (JSON format).
* 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 {
const raw = contentLines.join('\n').trim();
try {
const data = JSON.parse(raw);
const parsed = inlineTask.InlineTaskBlockSchema.safeParse(data);
if (parsed.success) {
return {
instruction: parsed.data.instruction,
schedule: parsed.data.schedule ? { ...parsed.data.schedule, label: parsed.data['schedule-label'] ?? '' } as InlineTaskSchedule : null,
lastRunAt: parsed.data.lastRunAt ?? null,
};
}
// Fallback for blocks that have instruction but don't fully match schema
if (data && typeof data === 'object' && data.instruction) {
return {
instruction: data.instruction,
schedule: data.schedule ?? null,
lastRunAt: data.lastRunAt ?? null,
};
}
} catch {
// Legacy format: @rowboat lines + optional schedule: JSON line
}
// Legacy fallback: parse @rowboat instruction and schedule: line
let schedule: InlineTaskSchedule | null = null;
const instructionLines: string[] = [];
for (const cl of contentLines) {
const schedMatch = cl.trim().match(/^schedule:\s*(.+)$/);
if (schedMatch) {
try {
const obj = JSON.parse(schedMatch[1]);
if (obj && typeof obj === 'object' && obj.type) {
schedule = obj as InlineTaskSchedule;
}
} catch { /* not JSON schedule, skip */ }
} else if (!/^schedule-config:\s/.test(cl.trim())) {
instructionLines.push(cl);
}
}
const firstRowboatLine = instructionLines.find(l => l.trim().startsWith('@rowboat'));
const rawInstruction = firstRowboatLine?.trim() ?? instructionLines.join('\n').trim();
const instruction = rawInstruction.replace(/^@rowboat:?\s*/, '');
if (!instruction) return null;
return { instruction, schedule, lastRunAt: null };
}
/**
* Determine if a scheduled task is due to run.
*/
function isScheduledTaskDue(schedule: InlineTaskSchedule, lastRunAt: string | null): boolean {
const now = new Date();
// Check startDate/endDate bounds for cron and window
if (schedule.type === 'cron' || schedule.type === 'window') {
if (schedule.startDate && now < new Date(schedule.startDate)) return false;
if (schedule.endDate && now > new Date(schedule.endDate)) return false;
}
switch (schedule.type) {
case 'cron': {
if (!lastRunAt) return true; // Never run → due
try {
const lastRun = new Date(lastRunAt);
const interval = CronExpressionParser.parse(schedule.expression, {
currentDate: lastRun,
});
const nextRun = interval.next().toDate();
return now >= nextRun;
} catch {
return false;
}
}
case 'window': {
if (!lastRunAt) return true;
try {
const lastRun = new Date(lastRunAt);
const interval = CronExpressionParser.parse(schedule.cron, {
currentDate: lastRun,
});
const nextDate = interval.next().toDate();
// Check if we're within the time window
const [startHour, startMin] = schedule.startTime.split(':').map(Number);
const [endHour, endMin] = schedule.endTime.split(':').map(Number);
const startMinutes = startHour * 60 + startMin;
const endMinutes = endHour * 60 + endMin;
const nowMinutes = now.getHours() * 60 + now.getMinutes();
// The cron date must have passed and we need to be in the time window
return now >= nextDate && nowMinutes >= startMinutes && nowMinutes <= endMinutes;
} catch {
return false;
}
}
case 'once': {
if (lastRunAt) return false; // Already ran
const runAt = new Date(schedule.runAt);
return now >= runAt;
}
}
}
/**
* Find ```tell-rowboat code blocks in a note body and return tasks that are pending execution.
*/
function findPendingTasks(body: string): InlineTask[] {
const tasks: InlineTask[] = [];
const lines = body.split('\n');
let i = 0;
while (i < lines.length) {
const trimmed = lines[i].trim();
if (trimmed.startsWith('```task') || trimmed.startsWith('```tell-rowboat')) {
const startLine = i;
i++;
const contentLines: string[] = [];
while (i < lines.length && lines[i].trim() !== '```') {
contentLines.push(lines[i]);
i++;
}
const endLine = i; // line with closing ```
const parsed = parseBlockContent(contentLines);
if (parsed) {
const { instruction, schedule, lastRunAt } = parsed;
if (schedule) {
if (isScheduledTaskDue(schedule, lastRunAt)) {
tasks.push({ instruction, schedule, startLine, endLine });
}
} else {
// One-time task: skip if already ran
if (!lastRunAt) {
tasks.push({ instruction, schedule: null, startLine, endLine });
}
}
}
}
i++;
}
return tasks;
}
/**
* Insert the agent result below the tell-rowboat code block in the body.
* Returns the updated body string.
*/
function insertResultBelow(body: string, endLine: number, result: string): string {
const lines = body.split('\n');
// Insert a blank line + result after the closing ``` fence
lines.splice(endLine + 1, 0, '', result);
return lines.join('\n');
}
/**
* Determine if a note has any "live" tell-rowboat tasks.
* A task is live if:
* - It's a one-time task that hasn't been completed yet
* - It's a scheduled task whose endDate hasn't passed (or has no endDate)
* - It's a scheduled task before its startDate (will run in the future)
*/
function hasLiveTasks(body: string): boolean {
const now = new Date();
const lines = body.split('\n');
let i = 0;
while (i < lines.length) {
const trimmed = lines[i].trim();
if (trimmed.startsWith('```task') || trimmed.startsWith('```tell-rowboat')) {
i++;
const contentLines: string[] = [];
while (i < lines.length && lines[i].trim() !== '```') {
contentLines.push(lines[i]);
i++;
}
const parsed = parseBlockContent(contentLines);
if (!parsed) { i++; continue; }
const { schedule, lastRunAt } = parsed;
if (schedule) {
if (schedule.type === 'cron' || schedule.type === 'window') {
const endDate = schedule.endDate;
if (!endDate || now <= new Date(endDate)) {
return true;
}
} else if (schedule.type === 'once') {
if (!lastRunAt) {
return true;
}
}
} else {
// One-time task without schedule: live if never ran
if (!lastRunAt) {
return true;
}
}
}
i++;
}
return false;
}
// ---------------------------------------------------------------------------
// Block data helpers
// ---------------------------------------------------------------------------
/**
* Update the JSON content inside a task code block to include lastRunAt.
* Replaces the content lines between the opening and closing fences.
*/
function updateBlockData(body: string, startLine: number, endLine: number, lastRunAt: string): string {
const lines = body.split('\n');
// Content is between startLine+1 and endLine-1
const contentLines = lines.slice(startLine + 1, endLine);
const raw = contentLines.join('\n').trim();
try {
const data = JSON.parse(raw);
data.lastRunAt = lastRunAt;
const updatedJson = JSON.stringify(data);
// Replace content lines with the updated JSON (single line)
lines.splice(startLine + 1, endLine - startLine - 1, updatedJson);
} catch {
// Not valid JSON — skip update
}
return lines.join('\n');
}
// ---------------------------------------------------------------------------
// Main processing
// ---------------------------------------------------------------------------
async function processInlineTasks(): Promise<void> {
console.log('[InlineTasks] Checking live notes...');
if (!fs.existsSync(KNOWLEDGE_DIR)) {
console.log('[InlineTasks] Knowledge directory not found');
return;
}
const allFiles = scanDirectoryRecursive(KNOWLEDGE_DIR);
let totalProcessed = 0;
for (const filePath of allFiles) {
let content: string;
try {
content = fs.readFileSync(filePath, 'utf-8');
} catch {
continue;
}
const { raw, body } = splitFrontmatter(content);
const fields = extractAllFrontmatterValues(raw);
// Only process files marked as live
if (fields['live_note'] !== 'true') continue;
const tasks = findPendingTasks(body);
if (tasks.length === 0) {
// No pending tasks — check if still live, update if not
const live = hasLiveTasks(body);
if (!live) {
fields['live_note'] = 'false';
// Remove rowboat_tasks if present (legacy cleanup)
delete fields['rowboat_tasks'];
const newRaw = buildFrontmatter(fields);
const newContent = joinFrontmatter(newRaw, body);
try {
fs.writeFileSync(filePath, newContent, 'utf-8');
const rel = path.relative(WorkDir, filePath);
console.log(`[InlineTasks] Marked ${rel} as no longer live`);
} catch { /* ignore */ }
}
continue;
}
const relativePath = path.relative(WorkDir, filePath);
console.log(`[InlineTasks] Found ${tasks.length} pending task(s) in ${relativePath}`);
// Process tasks one at a time, bottom-up so line indices stay valid
// (inserting content shifts lines below, so process from bottom to top)
const sortedTasks = [...tasks].sort((a, b) => b.endLine - a.endLine);
let currentBody = body;
for (const task of sortedTasks) {
console.log(`[InlineTasks] Running task: "${task.instruction.slice(0, 80)}..."`);
try {
const run = await createRun({ agentId: INLINE_TASK_AGENT });
const message = [
`Execute the following instruction from the note "${relativePath}":`,
'',
`**Instruction:** ${task.instruction}`,
'',
'**Full note content for context:**',
'```markdown',
content,
'```',
].join('\n');
await createMessage(run.id, message);
await waitForRunCompletion(run.id);
const result = await extractAgentResponse(run.id);
if (result) {
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);
totalProcessed++;
console.log(`[InlineTasks] Task completed`);
} else {
console.warn(`[InlineTasks] No response from agent for task`);
}
} catch (error) {
console.error(`[InlineTasks] Error processing task:`, error);
}
}
// Update frontmatter — only manage live_note, remove legacy rowboat_tasks
const live = hasLiveTasks(currentBody);
fields['live_note'] = live ? 'true' : 'false';
delete fields['rowboat_tasks'];
const newRaw = buildFrontmatter(fields);
const newContent = joinFrontmatter(newRaw, currentBody);
try {
fs.writeFileSync(filePath, newContent, 'utf-8');
console.log(`[InlineTasks] Updated ${relativePath}`);
} catch (error) {
console.error(`[InlineTasks] Error writing ${relativePath}:`, error);
}
}
if (totalProcessed > 0) {
console.log(`[InlineTasks] Done. Processed ${totalProcessed} task(s).`);
} else {
console.log('[InlineTasks] No pending tasks found');
}
}
/**
* Classify whether an instruction contains a scheduling intent using the user's configured LLM.
* Returns a schedule object or null for one-time tasks.
*/
export async function classifySchedule(instruction: string): Promise<InlineTaskSchedule | null> {
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
const config = await repo.getConfig();
const provider = createProvider(config.provider);
const model = provider.languageModel(config.model);
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 localEnd = defaultEnd.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'long' });
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
const nowISO = now.toISOString();
const defaultEndISO = defaultEnd.toISOString();
const systemPrompt = `You classify whether a user instruction contains a scheduling intent.
If the instruction implies a recurring or future-scheduled task, return a JSON object with the schedule.
If the instruction is a one-time immediate task, return null.
Every schedule object MUST include a "label" field: a short, plain-English description starting with "runs" that includes the end date (e.g. "runs every 2 minutes until Mar 12", "runs daily at 8 AM until Mar 12").
Schedule types:
1. "cron" recurring schedule. Return: {"type":"cron","expression":"<cron>","startDate":"<ISO>","endDate":"<ISO>","label":"<human readable>"}
Use standard 5-field cron (minute hour day-of-month month day-of-week).
"startDate" defaults to now (${nowISO}). "endDate" defaults to 7 days from now (${defaultEndISO}).
Override these if the user specifies a duration (e.g. "for the next 3 days" endDate = now + 3 days) or a start (e.g. "starting next Monday").
Example: "every morning at 8am" {"type":"cron","expression":"0 8 * * *","startDate":"${nowISO}","endDate":"${defaultEndISO}","label":"runs daily at 8 AM until Mar 12"}
2. "window" recurring with a time window. Return: {"type":"window","cron":"<cron>","startTime":"HH:MM","endTime":"HH:MM","startDate":"<ISO>","endDate":"<ISO>","label":"<human readable>"}
Use when the user specifies a range like "between 8am and 10am". Same startDate/endDate defaults and override rules as cron.
3. "once" run once at a specific future time. Return: {"type":"once","runAt":"<ISO 8601 datetime>","label":"<human readable>"}
Use when the user says "tomorrow at 3pm", "next Friday", etc. No startDate/endDate for once.
Current local time: ${localNow}
Timezone: ${tz}
Current UTC time: ${nowISO}
Default end time (local): ${localEnd}
Respond with ONLY valid JSON: either a schedule object or null. No other text.`;
try {
const result = await generateText({
model,
system: systemPrompt,
prompt: instruction,
});
let text = result.text.trim();
console.log('[classifySchedule] LLM response:', text);
// Strip markdown code fences if the LLM wraps the JSON
text = text.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '').trim();
if (text === 'null' || text === '') {
return null;
}
const parsed = JSON.parse(text);
if (!parsed || typeof parsed !== 'object' || !parsed.type) {
return null;
}
return parsed as InlineTaskSchedule;
} catch (error) {
console.error('[classifySchedule] Error:', error);
return null;
}
}
/**
* Main entry point runs as independent polling service
*/
export async function init() {
console.log('[InlineTasks] Starting Inline Task Service...');
console.log(`[InlineTasks] Will check for task blocks every ${SYNC_INTERVAL_MS / 1000} seconds`);
// Initial run
await processInlineTasks();
// Periodic polling
while (true) {
await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS));
try {
await processInlineTasks();
} catch (error) {
console.error('[InlineTasks] Error in main loop:', error);
}
}
}

View file

@ -6,5 +6,6 @@ export * as workspace from './workspace.js';
export * as mcp from './mcp.js';
export * as agentSchedule from './agent-schedule.js';
export * as agentScheduleState from './agent-schedule-state.js';
export * as serviceEvents from './service-events.js';
export * as serviceEvents from './service-events.js'
export * as inlineTask from './inline-task.js';
export { PrefixLogger };

View file

@ -0,0 +1,33 @@
import { z } from 'zod';
export const InlineTaskScheduleSchema = z.discriminatedUnion('type', [
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(),
}),
]);
export type InlineTaskSchedule = z.infer<typeof InlineTaskScheduleSchema>;
export const InlineTaskBlockSchema = z.object({
instruction: z.string(),
schedule: InlineTaskScheduleSchema.optional(),
'schedule-label': z.string().optional(),
lastRunAt: z.string().optional(),
});
export type InlineTaskBlock = z.infer<typeof InlineTaskBlockSchema>;

View file

@ -437,6 +437,19 @@ const ipcSchemas = {
})),
}),
},
// Inline task schedule classification
'inline-task:classifySchedule': {
req: z.object({
instruction: z.string(),
}),
res: z.object({
schedule: z.union([
z.object({ type: z.literal('cron'), expression: z.string(), startDate: z.string(), endDate: z.string(), label: z.string() }),
z.object({ type: z.literal('window'), cron: z.string(), startTime: z.string(), endTime: z.string(), startDate: z.string(), endDate: z.string(), label: z.string() }),
z.object({ type: z.literal('once'), runAt: z.string(), label: z.string() }),
]).nullable(),
}),
},
} as const;
// ============================================================================