mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-25 00:16:29 +02:00
inline task agent v1
This commit is contained in:
parent
5aba6025dc
commit
bd4cc1145d
13 changed files with 1221 additions and 6 deletions
|
|
@ -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 };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
109
apps/x/apps/renderer/src/components/rowboat-mention-popover.tsx
Normal file
109
apps/x/apps/renderer/src/components/rowboat-mention-popover.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
98
apps/x/apps/renderer/src/extensions/task-block.tsx
Normal file
98
apps/x/apps/renderer/src/extensions/task-block.tsx
Normal 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
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
27
apps/x/packages/core/src/knowledge/inline_task_agent.ts
Normal file
27
apps/x/packages/core/src/knowledge/inline_task_agent.ts
Normal 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.
|
||||
`;
|
||||
}
|
||||
626
apps/x/packages/core/src/knowledge/inline_tasks.ts
Normal file
626
apps/x/packages/core/src/knowledge/inline_tasks.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
33
apps/x/packages/shared/src/inline-task.ts
Normal file
33
apps/x/packages/shared/src/inline-task.ts
Normal 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>;
|
||||
|
|
@ -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;
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue