diff --git a/.gitignore b/.gitignore index 2480e5e1..086ea0b5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ .vscode/ data/ .venv/ +.claude/ diff --git a/apps/x/.claude/launch.json b/apps/x/.claude/launch.json deleted file mode 100644 index 3ba43066..00000000 --- a/apps/x/.claude/launch.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "version": "0.0.1", - "configurations": [ - { - "name": "renderer-dev", - "runtimeExecutable": "/Users/tusharmagar/Rowboat/rowboat-V2/apps/x/apps/renderer/node_modules/.bin/vite", - "runtimeArgs": ["--port", "5173"], - "port": 5173 - } - ] -} diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index a2230eda..74388f65 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -110,6 +110,18 @@ function markdownToHtml(markdown: string, title: string): string { ${html}` } +function resolveShellPath(filePath: string): string { + if (filePath.startsWith('~')) { + return path.join(os.homedir(), filePath.slice(1)); + } + + if (path.isAbsolute(filePath)) { + return filePath; + } + + return workspace.resolveWorkspacePath(filePath); +} + type InvokeChannels = ipc.InvokeChannels; type IPCChannels = ipc.IPCChannels; @@ -271,7 +283,7 @@ function handleWorkspaceChange(event: z.infer { - let filePath = args.path; - if (filePath.startsWith('~')) { - filePath = path.join(os.homedir(), filePath.slice(1)); - } else if (!path.isAbsolute(filePath)) { - // Workspace-relative path — resolve against ~/.rowboat/ - filePath = path.join(os.homedir(), '.rowboat', filePath); - } + const filePath = resolveShellPath(args.path); const error = await shell.openPath(filePath); return { error: error || undefined }; }, 'shell:readFileBase64': async (_event, args) => { - let filePath = args.path; - if (filePath.startsWith('~')) { - filePath = path.join(os.homedir(), filePath.slice(1)); - } else if (!path.isAbsolute(filePath)) { - // Workspace-relative path — resolve against ~/.rowboat/ - filePath = path.join(os.homedir(), '.rowboat', filePath); - } + const filePath = resolveShellPath(args.path); const stat = await fs.stat(filePath); if (stat.size > 10 * 1024 * 1024) { throw new Error('File too large (>10MB)'); diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index 483f25ee..3bb9063b 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -11,6 +11,7 @@ import { triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gma import { triggerSync as triggerCalendarSync } from '@x/core/dist/knowledge/sync_calendar.js'; import { triggerSync as triggerFirefliesSync } from '@x/core/dist/knowledge/sync_fireflies.js'; import { emitOAuthEvent } from './ipc.js'; +import { getBillingInfo } from '@x/core/dist/billing/billing.js'; const REDIRECT_URI = 'http://localhost:8080/oauth/callback'; @@ -271,6 +272,17 @@ export async function connectProvider(provider: string, credentials?: { clientId triggerFirefliesSync(); } + // For Rowboat sign-in, ensure user + Stripe customer exist before + // notifying the renderer. Without this, parallel API calls from + // multiple renderer hooks race to create the user, causing duplicates. + if (provider === 'rowboat') { + try { + await getBillingInfo(); + } catch (meError) { + console.error('[OAuth] Failed to initialize user via /v1/me:', meError); + } + } + // Emit success event to renderer emitOAuthEvent({ provider, success: true }); } catch (error) { diff --git a/apps/x/apps/main/src/test-agent.ts b/apps/x/apps/main/src/test-agent.ts index 836deea7..738d861a 100644 --- a/apps/x/apps/main/src/test-agent.ts +++ b/apps/x/apps/main/src/test-agent.ts @@ -3,7 +3,7 @@ import { bus } from '@x/core/dist/runs/bus.js'; async function main() { const { id } = await runsCore.createRun({ - // this will expect an agent file to exist at ~/.rowboat/agents/test-agent.md + // this expects an agent file to exist at WorkDir/agents/test-agent.md agentId: 'test-agent', }); console.log(`created run: ${id}`); @@ -16,4 +16,4 @@ async function main() { console.log(`created message: ${msgId}`); } -main(); \ No newline at end of file +main(); diff --git a/apps/x/apps/renderer/package.json b/apps/x/apps/renderer/package.json index ebf8a650..b9990e14 100644 --- a/apps/x/apps/renderer/package.json +++ b/apps/x/apps/renderer/package.json @@ -40,6 +40,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "lucide-react": "^0.562.0", + "mermaid": "^11.14.0", "motion": "^12.23.26", "nanoid": "^5.1.6", "posthog-js": "^1.332.0", diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 17e49f6e..0d1aaaa9 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -7,7 +7,7 @@ import './App.css' import z from 'zod'; import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon, HistoryIcon, RadioIcon, SquareIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { MarkdownEditor } from './components/markdown-editor'; +import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor'; import { ChatSidebar } from './components/chat-sidebar'; import { ChatInputWithMentions, type StagedAttachment } from './components/chat-input-with-mentions'; import { ChatMessageAttachments } from '@/components/chat-message-attachments' @@ -54,7 +54,7 @@ import { Toaster } from "@/components/ui/sonner" import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter' import { OnboardingModal } from '@/components/onboarding' -import { SearchDialog } from '@/components/search-dialog' +import { CommandPalette, type CommandPaletteContext, type CommandPaletteMention } from '@/components/search-dialog' import { BackgroundTaskDetail } from '@/components/background-task-detail' import { VersionHistoryPanel } from '@/components/version-history-panel' import { FileCardProvider } from '@/contexts/file-card-context' @@ -739,6 +739,12 @@ function App() { const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean) => Promise) | null>(null) const pendingVoiceInputRef = useRef(false) + // Palette: per-tab editor handles for capturing cursor context on Cmd+K, and pending payload + // queued across the new-chat-tab state flush before submit fires. + const editorRefsByTabId = useRef>(new Map()) + const [paletteContext, setPaletteContext] = useState(null) + const [pendingPaletteSubmit, setPendingPaletteSubmit] = useState<{ text: string; mention: CommandPaletteMention | null } | null>(null) + const handleSubmitRecording = useCallback(() => { const text = voice.submit() setIsRecording(false) @@ -885,6 +891,8 @@ function App() { // File tab state const [fileTabs, setFileTabs] = useState([]) const [activeFileTabId, setActiveFileTabId] = useState(null) + const activeFileTabIdRef = useRef(activeFileTabId) + activeFileTabIdRef.current = activeFileTabId const [editorSessionByTabId, setEditorSessionByTabId] = useState>({}) const fileHistoryHandlersRef = useRef>(new Map()) const fileTabIdCounterRef = useRef(0) @@ -2144,8 +2152,9 @@ function App() { } let titleSource = userMessage + const hasMentions = (mentions?.length ?? 0) > 0 - if (hasAttachments) { + if (hasAttachments || hasMentions) { type ContentPart = | { type: 'text'; text: string } | { @@ -2154,6 +2163,7 @@ function App() { filename: string mimeType: string size?: number + lineNumber?: number } const contentParts: ContentPart[] = [] @@ -2165,6 +2175,7 @@ function App() { path: mention.path, filename: mention.displayName || mention.path.split('/').pop() || mention.path, mimeType: 'text/markdown', + ...(mention.lineNumber !== undefined ? { lineNumber: mention.lineNumber } : {}), }) } } @@ -2182,7 +2193,7 @@ function App() { if (userMessage) { contentParts.push({ type: 'text', text: userMessage }) } else { - titleSource = stagedAttachments[0]?.filename ?? '' + titleSource = stagedAttachments[0]?.filename ?? mentions?.[0]?.displayName ?? mentions?.[0]?.path ?? '' } // Shared IPC payload types can lag until package rebuilds; runtime validation still enforces schema. @@ -2200,32 +2211,9 @@ function App() { searchEnabled: searchEnabled || undefined, }) } else { - // Legacy path: plain string with optional XML-formatted @mentions. - let formattedMessage = userMessage - if (mentions && mentions.length > 0) { - const attachedFiles = await Promise.all( - mentions.map(async (mention) => { - try { - const result = await window.ipc.invoke('workspace:readFile', { path: mention.path }) - return { path: mention.path, content: result.data as string } - } catch (err) { - console.error('Failed to read mentioned file:', mention.path, err) - return { path: mention.path, content: `[Error reading file: ${mention.path}]` } - } - }) - ) - - if (attachedFiles.length > 0) { - const filesXml = attachedFiles - .map((file) => `\n${file.content}\n`) - .join('\n') - formattedMessage = `\n${filesXml}\n\n\n${userMessage}` - } - } - await window.ipc.invoke('runs:createMessage', { runId: currentRunId, - message: formattedMessage, + message: userMessage, voiceInput: pendingVoiceInputRef.current || undefined, voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined, searchEnabled: searchEnabled || undefined, @@ -2235,8 +2223,6 @@ function App() { voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined, searchEnabled: searchEnabled || undefined, }) - - titleSource = formattedMessage } pendingVoiceInputRef.current = false @@ -2675,6 +2661,32 @@ function App() { handleNewChat() }, [chatTabs, activeChatTabId, handleNewChat]) + // Palette → sidebar submission. Opens the sidebar (if closed), forces a fresh chat tab, + // queues the message; the pending-submit effect (below) flushes it once state has settled + // so handlePromptSubmit sees the new tab's null runId. + const submitFromPalette = useCallback((text: string, mention: CommandPaletteMention | null) => { + if (!isChatSidebarOpen) setIsChatSidebarOpen(true) + handleNewChatTabInSidebar() + setPendingPaletteSubmit({ text, mention }) + }, [isChatSidebarOpen, handleNewChatTabInSidebar]) + + useEffect(() => { + if (!pendingPaletteSubmit) return + const fileMention: FileMention | undefined = pendingPaletteSubmit.mention + ? { + id: `palette-${Date.now()}`, + path: pendingPaletteSubmit.mention.path, + displayName: pendingPaletteSubmit.mention.displayName, + lineNumber: pendingPaletteSubmit.mention.lineNumber, + } + : undefined + void handlePromptSubmitRef.current?.( + { text: pendingPaletteSubmit.text, files: [] }, + fileMention ? [fileMention] : undefined, + ) + setPendingPaletteSubmit(null) + }, [pendingPaletteSubmit]) + const toggleKnowledgePane = useCallback(() => { setIsRightPaneMaximized(false) setIsChatSidebarOpen(prev => !prev) @@ -3083,11 +3095,16 @@ function App() { return () => document.removeEventListener('keydown', handleKeyDown) }, [handleCloseFullScreenChat, isFullScreenChat, expandedFrom, navigateToFullScreenChat]) - // Keyboard shortcut: Cmd+K / Ctrl+K to open search + // Keyboard shortcut: Cmd+K / Ctrl+K opens the unified palette (defaults to Chat mode). + // If an editor tab is currently active, capture cursor context so Chat mode shows the + // note + line as a removable chip. useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault() + const activeId = activeFileTabIdRef.current + const handle = activeId ? editorRefsByTabId.current.get(activeId) : null + setPaletteContext(handle?.getCursorContext() ?? null) setIsSearchOpen(true) } } @@ -4210,6 +4227,10 @@ function App() { aria-hidden={!isActive} > { + if (el) editorRefsByTabId.current.set(tab.id, el) + else editorRefsByTabId.current.delete(tab.id) + }} content={tabContent} notePath={tab.path} onChange={(markdown) => { if (!isViewingHistory) handleEditorChange(tab.path, markdown) }} @@ -4529,11 +4550,13 @@ function App() { /> - { void navigateToView({ type: 'chat', runId: id }) }} + initialContext={paletteContext} + onChatSubmit={submitFromPalette} /> diff --git a/apps/x/apps/renderer/src/components/ai-elements/markdown-code-override.tsx b/apps/x/apps/renderer/src/components/ai-elements/markdown-code-override.tsx index c1470326..9e6a3d3e 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/markdown-code-override.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/markdown-code-override.tsx @@ -1,5 +1,6 @@ import { isValidElement, type JSX } from 'react' import { FilePathCard } from './file-path-card' +import { MermaidRenderer } from '@/components/mermaid-renderer' export function MarkdownPreOverride(props: JSX.IntrinsicElements['pre']) { const { children, ...rest } = props @@ -19,6 +20,17 @@ export function MarkdownPreOverride(props: JSX.IntrinsicElements['pre']) { return } } + if ( + typeof childProps.className === 'string' && + childProps.className.includes('language-mermaid') + ) { + const text = typeof childProps.children === 'string' + ? childProps.children.trim() + : '' + if (text) { + return + } + } } // Passthrough for all other code blocks - return children directly diff --git a/apps/x/apps/renderer/src/components/ai-elements/prompt-input.tsx b/apps/x/apps/renderer/src/components/ai-elements/prompt-input.tsx index 98263434..467547b1 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/prompt-input.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/prompt-input.tsx @@ -91,11 +91,12 @@ export type FileMention = { id: string; path: string; // "knowledge/notes.md" displayName: string; // "notes" + lineNumber?: number; // 1-indexed source-line reference (for editor-context mentions) }; export type MentionsContext = { mentions: FileMention[]; - addMention: (path: string, displayName: string) => void; + addMention: (path: string, displayName: string, lineNumber?: number) => void; removeMention: (id: string) => void; clearMentions: () => void; }; @@ -279,13 +280,13 @@ export function PromptInputProvider({ // ----- mentions state (for @ file mentions) const [mentionsList, setMentionsList] = useState([]); - const addMention = useCallback((path: string, displayName: string) => { + const addMention = useCallback((path: string, displayName: string, lineNumber?: number) => { setMentionsList((prev) => { - // Avoid duplicates - if (prev.some((m) => m.path === path)) { + // Avoid duplicates (same path AND same lineNumber — line-specific mentions are distinct) + if (prev.some((m) => m.path === path && m.lineNumber === lineNumber)) { return prev; } - return [...prev, { id: nanoid(), path, displayName }]; + return [...prev, { id: nanoid(), path, displayName, lineNumber }]; }); }, []); diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index d7920b8b..ee1f6033 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -16,8 +16,9 @@ import { TableBlockExtension } from '@/extensions/table-block' import { CalendarBlockExtension } from '@/extensions/calendar-block' import { EmailBlockExtension } from '@/extensions/email-block' import { TranscriptBlockExtension } from '@/extensions/transcript-block' +import { MermaidBlockExtension } from '@/extensions/mermaid-block' import { Markdown } from 'tiptap-markdown' -import { useEffect, useCallback, useMemo, useRef, useState } from 'react' +import { useEffect, useCallback, useMemo, useRef, useState, forwardRef, useImperativeHandle } from 'react' import { Calendar, ChevronDown, ExternalLink } from 'lucide-react' // Zero-width space used as invisible marker for blank lines @@ -53,158 +54,221 @@ function postprocessMarkdown(markdown: string): string { }).join('\n') } -// Custom function to get markdown that preserves empty paragraphs as blank lines -function getMarkdownWithBlankLines(editor: Editor): string { - const json = editor.getJSON() - if (!json.content) return '' +type JsonNode = { + type?: string + content?: JsonNode[] + text?: string + marks?: Array<{ type: string; attrs?: Record }> + attrs?: Record +} - const blocks: string[] = [] - - // Helper to convert a node to markdown text - const nodeToText = (node: { - type?: string - content?: Array<{ - type?: string - text?: string - marks?: Array<{ type: string; attrs?: Record }> - attrs?: Record - }> - attrs?: Record - }): string => { - if (!node.content) return '' - return node.content.map(child => { - if (child.type === 'text') { - let text = child.text || '' - // Apply marks (bold, italic, etc.) - if (child.marks) { - for (const mark of child.marks) { - if (mark.type === 'bold') text = `**${text}**` - else if (mark.type === 'italic') text = `*${text}*` - else if (mark.type === 'code') text = `\`${text}\`` - else if (mark.type === 'link' && mark.attrs?.href) text = `[${text}](${mark.attrs.href})` - } +// Convert a node's inline content (text + marks + wikiLinks + hardBreaks) to markdown text +function nodeToText(node: JsonNode): string { + if (!node.content) return '' + return node.content.map(child => { + if (child.type === 'text') { + let text = child.text || '' + if (child.marks) { + for (const mark of child.marks) { + if (mark.type === 'bold') text = `**${text}**` + else if (mark.type === 'italic') text = `*${text}*` + else if (mark.type === 'code') text = `\`${text}\`` + else if (mark.type === 'link' && mark.attrs?.href) text = `[${text}](${mark.attrs.href})` } - return text - } else if (child.type === 'wikiLink') { - const path = (child.attrs?.path as string) || '' - return path ? `[[${path}]]` : '' - } else if (child.type === 'hardBreak') { - return '\n' } - return '' - }).join('') - } + return text + } else if (child.type === 'wikiLink') { + const path = (child.attrs?.path as string) || '' + return path ? `[[${path}]]` : '' + } else if (child.type === 'hardBreak') { + return '\n' + } + return '' + }).join('') +} - for (const node of json.content) { - if (node.type === 'paragraph') { - const text = nodeToText(node) - // If the paragraph contains only the blank line marker or is empty, it's a blank line - if (!text || text === BLANK_LINE_MARKER || text.trim() === BLANK_LINE_MARKER) { - // Push empty string to represent blank line - will add extra newline when joining - blocks.push('') +// Recursively serialize a list node (one line per item; nested lists indented two spaces) +function serializeList(listNode: JsonNode, indent: number): string[] { + const lines: string[] = [] + const items = (listNode.content || []) as JsonNode[] + items.forEach((item, index) => { + const indentStr = ' '.repeat(indent) + let prefix: string + if (listNode.type === 'taskList') { + const checked = item.attrs?.checked ? 'x' : ' ' + prefix = `- [${checked}] ` + } else if (listNode.type === 'orderedList') { + prefix = `${index + 1}. ` + } else { + prefix = '- ' + } + const itemContent = (item.content || []) as JsonNode[] + let firstPara = true + itemContent.forEach(child => { + if (child.type === 'bulletList' || child.type === 'orderedList' || child.type === 'taskList') { + lines.push(...serializeList(child, indent + 1)) } else { - blocks.push(text) + const text = nodeToText(child) + if (firstPara) { + lines.push(indentStr + prefix + text) + firstPara = false + } else { + lines.push(indentStr + ' ' + text) + } } - } else if (node.type === 'heading') { - const level = (node.attrs?.level as number) || 1 + }) + }) + return lines +} + +// Serialize a single top-level block to its markdown string. Empty paragraphs (or blank-marker +// paragraphs) return '' to signal "blank line slot" for the join logic in serializeBlocksToMarkdown. +function blockToMarkdown(node: JsonNode): string { + switch (node.type) { + case 'paragraph': { const text = nodeToText(node) - blocks.push('#'.repeat(level) + ' ' + text) - } else if (node.type === 'bulletList' || node.type === 'orderedList' || node.type === 'taskList') { - // Recursively serialize lists to handle nested bullets - const serializeList = ( - listNode: { type?: string; content?: Array>; attrs?: Record }, - indent: number - ): string[] => { - const lines: string[] = [] - const items = (listNode.content || []) as Array<{ content?: Array>; attrs?: Record }> - items.forEach((item, index) => { - const indentStr = ' '.repeat(indent) - let prefix: string - if (listNode.type === 'taskList') { - const checked = item.attrs?.checked ? 'x' : ' ' - prefix = `- [${checked}] ` - } else if (listNode.type === 'orderedList') { - prefix = `${index + 1}. ` - } else { - prefix = '- ' - } - const itemContent = (item.content || []) as Array<{ type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record }> }>; attrs?: Record }> - let firstPara = true - itemContent.forEach(child => { - if (child.type === 'bulletList' || child.type === 'orderedList' || child.type === 'taskList') { - lines.push(...serializeList(child, indent + 1)) - } else { - const text = nodeToText(child) - if (firstPara) { - lines.push(indentStr + prefix + text) - firstPara = false - } else { - lines.push(indentStr + ' ' + text) - } - } - }) - }) - return lines - } - blocks.push(serializeList(node, 0).join('\n')) - } else if (node.type === 'taskBlock') { - blocks.push('```task\n' + (node.attrs?.data as string || '{}') + '\n```') - } else if (node.type === 'imageBlock') { - blocks.push('```image\n' + (node.attrs?.data as string || '{}') + '\n```') - } else if (node.type === 'embedBlock') { - blocks.push('```embed\n' + (node.attrs?.data as string || '{}') + '\n```') - } else if (node.type === 'chartBlock') { - blocks.push('```chart\n' + (node.attrs?.data as string || '{}') + '\n```') - } else if (node.type === 'tableBlock') { - blocks.push('```table\n' + (node.attrs?.data as string || '{}') + '\n```') - } else if (node.type === 'calendarBlock') { - blocks.push('```calendar\n' + (node.attrs?.data as string || '{}') + '\n```') - } else if (node.type === 'emailBlock') { - blocks.push('```email\n' + (node.attrs?.data as string || '{}') + '\n```') - } else if (node.type === 'transcriptBlock') { - blocks.push('```transcript\n' + (node.attrs?.data as string || '{}') + '\n```') - } else if (node.type === 'codeBlock') { + if (!text || text === BLANK_LINE_MARKER || text.trim() === BLANK_LINE_MARKER) return '' + return text + } + case 'heading': { + const level = (node.attrs?.level as number) || 1 + return '#'.repeat(level) + ' ' + nodeToText(node) + } + case 'bulletList': + case 'orderedList': + case 'taskList': + return serializeList(node, 0).join('\n') + case 'taskBlock': + return '```task\n' + (node.attrs?.data as string || '{}') + '\n```' + case 'imageBlock': + return '```image\n' + (node.attrs?.data as string || '{}') + '\n```' + case 'embedBlock': + return '```embed\n' + (node.attrs?.data as string || '{}') + '\n```' + case 'chartBlock': + return '```chart\n' + (node.attrs?.data as string || '{}') + '\n```' + case 'tableBlock': + return '```table\n' + (node.attrs?.data as string || '{}') + '\n```' + case 'calendarBlock': + return '```calendar\n' + (node.attrs?.data as string || '{}') + '\n```' + case 'emailBlock': + return '```email\n' + (node.attrs?.data as string || '{}') + '\n```' + case 'transcriptBlock': + return '```transcript\n' + (node.attrs?.data as string || '{}') + '\n```' + case 'mermaidBlock': + return '```mermaid\n' + (node.attrs?.data as string || '') + '\n```' + case 'codeBlock': { const lang = (node.attrs?.language as string) || '' - blocks.push('```' + lang + '\n' + nodeToText(node) + '\n```') - } else if (node.type === 'blockquote') { - const content = node.content || [] - const quoteLines = content.map(para => '> ' + nodeToText(para)) - blocks.push(quoteLines.join('\n')) - } else if (node.type === 'horizontalRule') { - blocks.push('---') - } else if (node.type === 'wikiLink') { + return '```' + lang + '\n' + nodeToText(node) + '\n```' + } + case 'blockquote': { + const content = (node.content || []) as JsonNode[] + return content.map(para => '> ' + nodeToText(para)).join('\n') + } + case 'horizontalRule': + return '---' + case 'wikiLink': { const path = (node.attrs?.path as string) || '' - blocks.push(`[[${path}]]`) - } else if (node.type === 'image') { + return `[[${path}]]` + } + case 'image': { const src = (node.attrs?.src as string) || '' const alt = (node.attrs?.alt as string) || '' - blocks.push(`![${alt}](${src})`) + return `![${alt}](${src})` } + default: + return '' } +} - // Custom join: content blocks get \n\n before them, empty blocks add \n each - // This produces: 1 empty paragraph = 3 newlines (1 blank line on disk) +// Pure helper: serialize a slice of top-level block nodes to markdown. +// Custom join: content blocks get \n\n before them, empty blocks add \n each. +// 1 empty paragraph = 3 newlines on disk (1 blank line). +function serializeBlocksToMarkdown(blocks: JsonNode[]): string { if (blocks.length === 0) return '' - let result = '' - for (let i = 0; i < blocks.length; i++) { - const block = blocks[i] + const block = blockToMarkdown(blocks[i]) const isContent = block !== '' - if (i === 0) { result = block } else if (isContent) { - // Content block: add \n\n before it (standard paragraph break) result += '\n\n' + block } else { - // Empty block: just add \n (one extra newline for blank line) result += '\n' } } - return result } + +// Custom function to get markdown that preserves empty paragraphs as blank lines +function getMarkdownWithBlankLines(editor: Editor): string { + const json = editor.getJSON() as JsonNode + if (!json.content) return '' + return serializeBlocksToMarkdown(json.content as JsonNode[]) +} + +// Compute the cursor's 1-indexed line number in the markdown that getMarkdownWithBlankLines +// would produce. Used to attach precise line-references when inserting editor-context mentions. +function getCursorContextLine(editor: Editor): number { + const $from = editor.state.selection.$from + const json = editor.getJSON() as JsonNode + const blocks = (json.content ?? []) as JsonNode[] + if (blocks.length === 0) return 1 + + const blockIndex = $from.index(0) + if (blockIndex < 0 || blockIndex >= blocks.length) return 1 + + // Line where the cursor's top-level block starts. + // Joining: prefix + '\n\n' + nextContentBlock → next block sits two lines below the prefix's last line. + let blockStartLine: number + if (blockIndex === 0) { + blockStartLine = 1 + } else { + const prefix = serializeBlocksToMarkdown(blocks.slice(0, blockIndex)) + const prefixLineCount = prefix === '' ? 0 : prefix.split('\n').length + blockStartLine = prefixLineCount + 2 + } + + return blockStartLine + computeWithinBlockOffset(blocks[blockIndex], $from) +} + +// Lines into the cursor's top-level block. 0 for the common single-line cases (paragraph/heading); +// for multi-line containers, computed against how the block serializes. +function computeWithinBlockOffset( + block: JsonNode, + $from: { parentOffset: number; depth: number; index: (depth: number) => number } +): number { + switch (block.type) { + case 'paragraph': + case 'heading': { + // Each hardBreak before the cursor moves us down one rendered line. + const offset = $from.parentOffset + let pos = 0 + let hbCount = 0 + for (const child of (block.content ?? [])) { + if (pos >= offset) break + const size = child.type === 'text' ? (child.text?.length ?? 0) : 1 + if (child.type === 'hardBreak' && pos < offset) hbCount++ + pos += size + } + return hbCount + } + case 'bulletList': + case 'orderedList': + case 'taskList': + case 'blockquote': + // Item index within the container = lines into the block (one item per line for shallow lists/quotes). + return $from.depth >= 1 ? $from.index(1) : 0 + case 'codeBlock': { + // +1 for the opening ``` fence line, plus newlines within the code text before the cursor. + const text = block.content?.[0]?.text ?? '' + const before = text.substring(0, $from.parentOffset) + return 1 + (before.match(/\n/g)?.length ?? 0) + } + default: + return 0 + } +} import { EditorToolbar } from './editor-toolbar' import { FrontmatterProperties } from './frontmatter-properties' import { WikiLink } from '@/extensions/wiki-link' @@ -436,7 +500,12 @@ const TabIndentExtension = Extension.create({ }, }) -export function MarkdownEditor({ +export interface MarkdownEditorHandle { + /** Returns {path, lineNumber} for the cursor's current position, or null if no notePath / no editor. */ + getCursorContext: () => { path: string; lineNumber: number } | null +} + +export const MarkdownEditor = forwardRef(function MarkdownEditor({ content, onChange, onPrimaryHeadingCommit, @@ -451,7 +520,7 @@ export function MarkdownEditor({ onFrontmatterChange, onExport, notePath, -}: MarkdownEditorProps) { +}, ref) { const isInternalUpdate = useRef(false) const wrapperRef = useRef(null) const [activeWikiLink, setActiveWikiLink] = useState(null) @@ -576,6 +645,7 @@ export function MarkdownEditor({ CalendarBlockExtension, EmailBlockExtension, TranscriptBlockExtension, + MermaidBlockExtension, WikiLink.configure({ onCreate: wikiLinks?.onCreate ? (path) => { @@ -785,6 +855,17 @@ export function MarkdownEditor({ }) }, [editor, wikiLinks]) + useImperativeHandle(ref, () => ({ + getCursorContext: () => { + if (!notePath || !editor) return null + try { + return { path: notePath, lineNumber: getCursorContextLine(editor) } + } catch { + return null + } + }, + }), [notePath, editor]) + const updateRowboatMentionState = useCallback(() => { if (!editor) return const { selection } = editor.state @@ -1448,4 +1529,4 @@ export function MarkdownEditor({ ) -} +}) diff --git a/apps/x/apps/renderer/src/components/mermaid-renderer.tsx b/apps/x/apps/renderer/src/components/mermaid-renderer.tsx new file mode 100644 index 00000000..db42df2e --- /dev/null +++ b/apps/x/apps/renderer/src/components/mermaid-renderer.tsx @@ -0,0 +1,89 @@ +import { useEffect, useId, useRef, useState } from 'react' +import mermaid from 'mermaid' +import { useTheme } from '@/contexts/theme-context' + +let lastTheme: string | null = null + +function ensureInit(theme: 'default' | 'dark') { + if (lastTheme === theme) return + mermaid.initialize({ + startOnLoad: false, + theme, + securityLevel: 'strict', + }) + lastTheme = theme +} + +interface MermaidRendererProps { + source: string + className?: string +} + +export function MermaidRenderer({ source, className }: MermaidRendererProps) { + const { resolvedTheme } = useTheme() + const id = useId().replace(/:/g, '-') + const containerRef = useRef(null) + const [svg, setSvg] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + if (!source.trim()) { + setSvg(null) + setError(null) + return + } + + let cancelled = false + const mermaidTheme = resolvedTheme === 'dark' ? 'dark' : 'default' + ensureInit(mermaidTheme) + + mermaid + .render(`mermaid-${id}`, source.trim()) + .then(({ svg: renderedSvg }) => { + if (!cancelled) { + setSvg(renderedSvg) + setError(null) + } + }) + .catch((err: unknown) => { + if (!cancelled) { + setSvg(null) + setError(err instanceof Error ? err.message : 'Failed to render diagram') + } + }) + + return () => { + cancelled = true + } + }, [source, resolvedTheme, id]) + + if (error) { + return ( +
+
+ Invalid mermaid syntax +
+
+          {source}
+        
+
+ ) + } + + if (!svg) { + return ( +
+ Rendering diagram... +
+ ) + } + + return ( +
+ ) +} diff --git a/apps/x/apps/renderer/src/components/search-dialog.tsx b/apps/x/apps/renderer/src/components/search-dialog.tsx index 32bca1b3..66a37802 100644 --- a/apps/x/apps/renderer/src/components/search-dialog.tsx +++ b/apps/x/apps/renderer/src/components/search-dialog.tsx @@ -1,7 +1,7 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import posthog from 'posthog-js' import * as analytics from '@/lib/analytics' -import { FileTextIcon, MessageSquareIcon } from 'lucide-react' +import { FileTextIcon, MessageSquareIcon, XIcon } from 'lucide-react' import { CommandDialog, CommandInput, @@ -22,21 +22,50 @@ interface SearchResult { } type SearchType = 'knowledge' | 'chat' +type Mode = 'chat' | 'search' function activeTabToTypes(section: ActiveSection): SearchType[] { if (section === 'knowledge') return ['knowledge'] return ['chat'] // "tasks" tab maps to chat } -interface SearchDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - onSelectFile: (path: string) => void - onSelectRun: (runId: string) => void +export type CommandPaletteContext = { + path: string + lineNumber: number } -export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }: SearchDialogProps) { +export type CommandPaletteMention = { + path: string + displayName: string + lineNumber?: number +} + +interface CommandPaletteProps { + open: boolean + onOpenChange: (open: boolean) => void + // Search mode + onSelectFile: (path: string) => void + onSelectRun: (runId: string) => void + // Chat mode + initialContext?: CommandPaletteContext | null + onChatSubmit: (text: string, mention: CommandPaletteMention | null) => void +} + +export function CommandPalette({ + open, + onOpenChange, + onSelectFile, + onSelectRun, + initialContext, + onChatSubmit, +}: CommandPaletteProps) { const { activeSection } = useSidebarSection() + const [mode, setMode] = useState('chat') + const [chatInput, setChatInput] = useState('') + const [contextChip, setContextChip] = useState(null) + const chatInputRef = useRef(null) + const searchInputRef = useRef(null) + const [query, setQuery] = useState('') const [results, setResults] = useState([]) const [isSearching, setIsSearching] = useState(false) @@ -45,17 +74,45 @@ export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }: ) const debouncedQuery = useDebounce(query, 250) - // Sync filter preselection when dialog opens + // On open: always reset to Chat mode (per spec — no mode persistence), sync context chip + // and reset search filters. useEffect(() => { if (open) { + setMode('chat') + setChatInput('') + setContextChip(initialContext ?? null) setActiveTypes(new Set(activeTabToTypes(activeSection))) } - }, [open, activeSection]) + }, [open, activeSection, initialContext]) + + // Tab cycles modes. Captured at document level so cmdk's internal Tab handling doesn't + // swallow it. Only fires while the dialog is open. + useEffect(() => { + if (!open) return + const handler = (e: KeyboardEvent) => { + if (e.key !== 'Tab') return + if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return + e.preventDefault() + e.stopPropagation() + setMode(prev => (prev === 'chat' ? 'search' : 'chat')) + } + document.addEventListener('keydown', handler, true) + return () => document.removeEventListener('keydown', handler, true) + }, [open]) + + // Refocus the appropriate input on mode change so the user can start typing immediately. + useEffect(() => { + if (!open) return + const target = mode === 'chat' ? chatInputRef : searchInputRef + target.current?.focus() + }, [open, mode]) const toggleType = useCallback((type: SearchType) => { setActiveTypes(new Set([type])) }, []) + // Search query effect (only meaningful while in search mode, but the debounce keeps running + // harmlessly otherwise — empty query skips the IPC call below). useEffect(() => { if (!debouncedQuery.trim()) { setResults([]) @@ -89,11 +146,12 @@ export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }: return () => { cancelled = true } }, [debouncedQuery, activeTypes]) - // Reset state when dialog closes + // Reset transient state on close. useEffect(() => { if (!open) { setQuery('') setResults([]) + setChatInput('') } }, [open]) @@ -106,6 +164,20 @@ export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }: } }, [onOpenChange, onSelectFile, onSelectRun]) + const submitChat = useCallback(() => { + const text = chatInput.trim() + if (!text && !contextChip) return + const mention: CommandPaletteMention | null = contextChip + ? { + path: contextChip.path, + displayName: deriveDisplayName(contextChip.path), + lineNumber: contextChip.lineNumber, + } + : null + onChatSubmit(text, mention) + onOpenChange(false) + }, [chatInput, contextChip, onChatSubmit, onOpenChange]) + const knowledgeResults = results.filter(r => r.type === 'knowledge') const chatResults = results.filter(r => r.type === 'chat') @@ -113,76 +185,178 @@ export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }: - + {/* Mode strip */}
- toggleType('knowledge')} - icon={} - label="Knowledge" - /> - toggleType('chat')} + setMode('chat')} icon={} - label="Chats" + label="Chat" /> + setMode('search')} + icon={} + label="Search" + /> + Tab to switch
- - {!query.trim() && ( - Type to search... - )} - {query.trim() && !isSearching && results.length === 0 && ( - No results found. - )} - {knowledgeResults.length > 0 && ( - - {knowledgeResults.map((result) => ( - handleSelect(result)} - > - -
- {result.title} - {result.preview} -
-
- ))} -
- )} - {chatResults.length > 0 && ( - - {chatResults.map((result) => ( - handleSelect(result)} - > - -
- {result.title} - {result.preview} -
-
- ))} -
- )} -
+ + {mode === 'chat' ? ( +
+ setChatInput(e.target.value)} + onKeyDown={(e) => { + // cmdk's Command component intercepts Enter for item selection — stop it + // before bubbling so we control the chat submit ourselves. + if (e.key === 'Enter' && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) { + e.preventDefault() + e.stopPropagation() + submitChat() + } + }} + placeholder="Ask copilot anything…" + autoFocus + className="w-full bg-transparent px-4 py-3 text-sm outline-none placeholder:text-muted-foreground" + /> + {contextChip && ( +
+ + + {deriveDisplayName(contextChip.path)} + · Line {contextChip.lineNumber} + + + Enter to send +
+ )} + {!contextChip && ( +
+ Enter to send +
+ )} +
+ ) : ( + <> + +
+ toggleType('knowledge')} + icon={} + label="Knowledge" + /> + toggleType('chat')} + icon={} + label="Chats" + /> +
+ + {!query.trim() && ( + Type to search... + )} + {query.trim() && !isSearching && results.length === 0 && ( + No results found. + )} + {knowledgeResults.length > 0 && ( + + {knowledgeResults.map((result) => ( + handleSelect(result)} + > + +
+ {result.title} + {result.preview} +
+
+ ))} +
+ )} + {chatResults.length > 0 && ( + + {chatResults.map((result) => ( + handleSelect(result)} + > + +
+ {result.title} + {result.preview} +
+
+ ))} +
+ )} +
+ + )}
) } +// Back-compat export so existing import sites don't break in one go; thin alias to CommandPalette. +export const SearchDialog = CommandPalette + +function deriveDisplayName(path: string): string { + const base = path.split('/').pop() ?? path + return base.replace(/\.md$/, '') +} + +function ModeButton({ + active, + onClick, + icon, + label, +}: { + active: boolean + onClick: () => void + icon: React.ReactNode + label: string +}) { + return ( + + ) +} + function FilterToggle({ active, onClick, diff --git a/apps/x/apps/renderer/src/extensions/mermaid-block.tsx b/apps/x/apps/renderer/src/extensions/mermaid-block.tsx new file mode 100644 index 00000000..a118c86e --- /dev/null +++ b/apps/x/apps/renderer/src/extensions/mermaid-block.tsx @@ -0,0 +1,86 @@ +import { mergeAttributes, Node } from '@tiptap/react' +import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react' +import { X, GitBranch } from 'lucide-react' +import { MermaidRenderer } from '@/components/mermaid-renderer' + +function MermaidBlockView({ node, deleteNode }: { node: { attrs: Record }; deleteNode: () => void }) { + const source = (node.attrs.data as string) || '' + + return ( + +
+ + {source ? ( + + ) : ( +
+ + Empty mermaid block +
+ )} +
+
+ ) +} + +export const MermaidBlockExtension = Node.create({ + name: 'mermaidBlock', + 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-mermaid')) { + return { data: code.textContent || '' } + } + return false + }, + }, + ] + }, + + renderHTML({ HTMLAttributes }: { HTMLAttributes: Record }) { + return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'mermaid-block' })] + }, + + addNodeView() { + return ReactNodeViewRenderer(MermaidBlockView) + }, + + addStorage() { + return { + markdown: { + serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) { + state.write('```mermaid\n' + node.attrs.data + '\n```') + state.closeBlock(node) + }, + parse: { + // handled by parseHTML + }, + }, + } + }, +}) diff --git a/apps/x/apps/renderer/src/styles/editor.css b/apps/x/apps/renderer/src/styles/editor.css index f865707e..d8918e56 100644 --- a/apps/x/apps/renderer/src/styles/editor.css +++ b/apps/x/apps/renderer/src/styles/editor.css @@ -619,7 +619,8 @@ .tiptap-editor .ProseMirror .table-block-wrapper, .tiptap-editor .ProseMirror .calendar-block-wrapper, .tiptap-editor .ProseMirror .email-block-wrapper, -.tiptap-editor .ProseMirror .transcript-block-wrapper { +.tiptap-editor .ProseMirror .transcript-block-wrapper, +.tiptap-editor .ProseMirror .mermaid-block-wrapper { margin: 8px 0; } @@ -630,7 +631,8 @@ .tiptap-editor .ProseMirror .calendar-block-card, .tiptap-editor .ProseMirror .email-block-card, .tiptap-editor .ProseMirror .email-draft-block-card, -.tiptap-editor .ProseMirror .transcript-block-card { +.tiptap-editor .ProseMirror .transcript-block-card, +.tiptap-editor .ProseMirror .mermaid-block-card { position: relative; padding: 12px 14px; border: 1px solid var(--border); @@ -647,7 +649,8 @@ .tiptap-editor .ProseMirror .calendar-block-card:hover, .tiptap-editor .ProseMirror .email-block-card:hover, .tiptap-editor .ProseMirror .email-draft-block-card:hover, -.tiptap-editor .ProseMirror .transcript-block-card:hover { +.tiptap-editor .ProseMirror .transcript-block-card:hover, +.tiptap-editor .ProseMirror .mermaid-block-card:hover { background-color: color-mix(in srgb, var(--muted) 70%, transparent); } @@ -657,7 +660,8 @@ .tiptap-editor .ProseMirror .table-block-wrapper.ProseMirror-selectednode .table-block-card, .tiptap-editor .ProseMirror .calendar-block-wrapper.ProseMirror-selectednode .calendar-block-card, .tiptap-editor .ProseMirror .email-block-wrapper.ProseMirror-selectednode .email-block-card, -.tiptap-editor .ProseMirror .email-draft-block-wrapper.ProseMirror-selectednode .email-draft-block-card { +.tiptap-editor .ProseMirror .email-draft-block-wrapper.ProseMirror-selectednode .email-draft-block-card, +.tiptap-editor .ProseMirror .mermaid-block-wrapper.ProseMirror-selectednode .mermaid-block-card { outline: 2px solid var(--primary); outline-offset: 1px; } @@ -668,7 +672,8 @@ .tiptap-editor .ProseMirror .table-block-delete, .tiptap-editor .ProseMirror .calendar-block-delete, .tiptap-editor .ProseMirror .email-block-delete, -.tiptap-editor .ProseMirror .email-draft-block-delete { +.tiptap-editor .ProseMirror .email-draft-block-delete, +.tiptap-editor .ProseMirror .mermaid-block-delete { position: absolute; top: 6px; right: 6px; @@ -693,7 +698,8 @@ .tiptap-editor .ProseMirror .table-block-card:hover .table-block-delete, .tiptap-editor .ProseMirror .calendar-block-card:hover .calendar-block-delete, .tiptap-editor .ProseMirror .email-block-card:hover .email-block-delete, -.tiptap-editor .ProseMirror .email-draft-block-card:hover .email-draft-block-delete { +.tiptap-editor .ProseMirror .email-draft-block-card:hover .email-draft-block-delete, +.tiptap-editor .ProseMirror .mermaid-block-card:hover .mermaid-block-delete { opacity: 1; } @@ -703,11 +709,26 @@ .tiptap-editor .ProseMirror .table-block-delete:hover, .tiptap-editor .ProseMirror .calendar-block-delete:hover, .tiptap-editor .ProseMirror .email-block-delete:hover, -.tiptap-editor .ProseMirror .email-draft-block-delete:hover { +.tiptap-editor .ProseMirror .email-draft-block-delete:hover, +.tiptap-editor .ProseMirror .mermaid-block-delete:hover { background-color: color-mix(in srgb, var(--foreground) 8%, transparent); color: var(--foreground); } +/* Mermaid block */ +.tiptap-editor .ProseMirror .mermaid-block-card svg { + max-width: 100%; + height: auto; +} + +.tiptap-editor .ProseMirror .mermaid-block-empty { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: color-mix(in srgb, var(--foreground) 50%, transparent); +} + /* Image block */ .tiptap-editor .ProseMirror .image-block-img { max-width: 100%; diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index 34e2b401..36aafbd8 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -566,7 +566,8 @@ export function convertFromMessages(messages: z.infer[]): ModelM for (const part of msg.content) { if (part.type === "attachment") { const sizeStr = part.size ? `, ${formatBytes(part.size)}` : ''; - attachmentLines.push(`- ${part.filename} (${part.mimeType}${sizeStr}) at ${part.path}`); + const lineStr = part.lineNumber ? ` (line ${part.lineNumber})` : ''; + attachmentLines.push(`- ${part.filename} (${part.mimeType}${sizeStr}) at ${part.path}${lineStr}`); } else { textSegments.push(part.text); } @@ -858,8 +859,8 @@ export async function* streamAgent({ const isKgAgent = knowledgeGraphAgents.includes(state.agentName!); const isInlineTaskAgent = state.agentName === "inline_task_agent"; const defaultModel = signedIn ? "gpt-5.4" : modelConfig.model; - const defaultKgModel = signedIn ? "gpt-5.4-mini" : defaultModel; - const defaultInlineTaskModel = signedIn ? "gpt-5.4" : defaultModel; + const defaultKgModel = signedIn ? "anthropic/claude-haiku-4.5" : defaultModel; + const defaultInlineTaskModel = signedIn ? "anthropic/claude-sonnet-4.6" : defaultModel; const modelId = isInlineTaskAgent ? defaultInlineTaskModel : (isKgAgent && modelConfig.knowledgeGraphModel) diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index 76472c90..022b21e4 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -112,10 +112,10 @@ Users can interact with the knowledge graph through you, open it directly in Obs ## How to Access the Knowledge Graph **CRITICAL PATH REQUIREMENT:** -- The workspace root is \`~/.rowboat/\` +- The workspace root is the configured workdir - The knowledge base is in the \`knowledge/\` subfolder - When using workspace tools, ALWAYS include \`knowledge/\` in the path -- **WRONG:** \`workspace-grep({ pattern: "John", path: "" })\` or \`path: "."\` or \`path: "~/.rowboat"\` +- **WRONG:** \`workspace-grep({ pattern: "John", path: "" })\` or \`path: "."\` or any absolute path to the workspace root - **CORRECT:** \`workspace-grep({ pattern: "John", path: "knowledge/" })\` Use the builtin workspace tools to search and read the knowledge base: @@ -212,16 +212,16 @@ For third-party services (GitHub, Gmail, Slack, etc.), load the \`composio-integ ${runtimeContextPrompt} ## Workspace Access & Scope -- **Inside \`~/.rowboat/\`:** Use builtin workspace tools (\`workspace-readFile\`, \`workspace-writeFile\`, etc.). These don't require security approval. -- **Outside \`~/.rowboat/\` (Desktop, Downloads, Documents, etc.):** Use \`executeCommand\` to run shell commands. -- **IMPORTANT:** Do NOT access files outside \`~/.rowboat/\` unless the user explicitly asks you to (e.g., "organize my Desktop", "find a file in Downloads"). +- **Inside the workspace root:** Use builtin workspace tools (\`workspace-readFile\`, \`workspace-writeFile\`, etc.). These don't require security approval. +- **Outside the workspace root (Desktop, Downloads, Documents, etc.):** Use \`executeCommand\` to run shell commands. +- **IMPORTANT:** Do NOT access files outside the workspace root unless the user explicitly asks you to (e.g., "organize my Desktop", "find a file in Downloads"). -**CRITICAL - When the user asks you to work with files outside ~/.rowboat:** +**CRITICAL - When the user asks you to work with files outside the workspace root:** - Follow the detected runtime platform above for shell syntax and filesystem path style. - On macOS/Linux, use POSIX-style commands and paths (e.g., \`~/Desktop\`, \`~/Downloads\`, \`open\` on macOS). - On Windows, use cmd-compatible commands and Windows paths (e.g., \`C:\\Users\\\\Desktop\`). - You CAN access the user's full filesystem via \`executeCommand\` - there is no sandbox restriction on paths. -- NEVER say "I can only run commands inside ~/.rowboat" or "I don't have access to your Desktop" - just use \`executeCommand\`. +- NEVER say "I can only run commands inside the workspace root" or "I don't have access to your Desktop" - just use \`executeCommand\`. - NEVER offer commands for the user to run manually - run them yourself with \`executeCommand\`. - NEVER say "I'll run shell commands equivalent to..." - just describe what you'll do in plain language (e.g., "I'll move 12 screenshots to a new Screenshots folder"). - NEVER ask what OS the user is on if runtime platform is already available. @@ -244,14 +244,14 @@ ${runtimeContextPrompt} - \`save-to-memory\` - Save observations about the user to the agent memory system. Use this proactively during conversations. - \`composio-list-toolkits\`, \`composio-search-tools\`, \`composio-execute-tool\`, \`composio-connect-toolkit\` — Composio integration tools. Load the \`composio-integration\` skill for usage guidance. -**Prefer these tools whenever possible** — they work instantly with zero friction. For file operations inside \`~/.rowboat/\`, always use these instead of \`executeCommand\`. +**Prefer these tools whenever possible** — they work instantly with zero friction. For file operations inside the workspace root, always use these instead of \`executeCommand\`. **Shell commands via \`executeCommand\`:** -- You can run ANY shell command via \`executeCommand\`. Some commands are pre-approved in \`~/.rowboat/config/security.json\` and run immediately. +- You can run ANY shell command via \`executeCommand\`. Some commands are pre-approved in \`config/security.json\` within the workspace root and run immediately. - Commands not on the pre-approved list will trigger a one-time approval prompt for the user — this is fine and expected, just a minor friction. Do NOT let this stop you from running commands you need. - **Never say "I can't run this command"** or ask the user to run something manually. Just call \`executeCommand\` and let the approval flow handle it. - When calling \`executeCommand\`, do NOT provide the \`cwd\` parameter unless absolutely necessary. The default working directory is already set to the workspace root. -- Always confirm with the user before executing commands that modify files outside \`~/.rowboat/\` (e.g., "I'll move 12 screenshots to ~/Desktop/Screenshots. Proceed?"). +- Always confirm with the user before executing commands that modify files outside the workspace root (e.g., "I'll move 12 screenshots to ~/Desktop/Screenshots. Proceed?"). **CRITICAL: MCP Server Configuration** - ALWAYS use the \`addMcpServer\` builtin tool to add or update MCP servers—it validates the configuration before saving diff --git a/apps/x/packages/core/src/application/assistant/skills/background-agents/skill.ts b/apps/x/packages/core/src/application/assistant/skills/background-agents/skill.ts index 7ac1b89e..b8e481b6 100644 --- a/apps/x/packages/core/src/application/assistant/skills/background-agents/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/background-agents/skill.ts @@ -11,7 +11,7 @@ Load this skill whenever a user wants to inspect, create, edit, or schedule back - Agents configure a model, tools (in frontmatter), and instructions (in the body) - Tools can be: builtin (like ` + "`executeCommand`" + `), MCP integrations, or **other agents** - **"Workflows" are just agents that orchestrate other agents** by having them as tools -- **Background agents run on schedules** defined in ` + "`~/.rowboat/config/agent-schedule.json`" + ` +- **Background agents run on schedules** defined in ` + "`config/agent-schedule.json`" + ` within the workspace root ## How multi-agent workflows work @@ -22,7 +22,7 @@ Load this skill whenever a user wants to inspect, create, edit, or schedule back ## Scheduling Background Agents -Background agents run automatically based on schedules defined in ` + "`~/.rowboat/config/agent-schedule.json`" + `. +Background agents run automatically based on schedules defined in ` + "`config/agent-schedule.json`" + ` in the workspace root. ### Schedule Configuration File @@ -150,7 +150,7 @@ You can add a ` + "`description`" + ` field to describe what the agent does. Thi **IMPORTANT: Do NOT modify ` + "`agent-schedule-state.json`" + `** - it is managed automatically by the background runner. -The runner automatically tracks execution state in ` + "`~/.rowboat/config/agent-schedule-state.json`" + `: +The runner automatically tracks execution state in ` + "`config/agent-schedule-state.json`" + ` in the workspace root: - ` + "`status`" + `: scheduled, running, finished, failed, triggered (for once-schedules) - ` + "`lastRunAt`" + `: When the agent last ran - ` + "`nextRunAt`" + `: When the agent will run next @@ -410,7 +410,7 @@ Create a morning briefing: Execute these steps in sequence. Don't ask for human input. ` + "```" + ` -**4. Schedule the workflow** in ` + "`~/.rowboat/config/agent-schedule.json`" + `: +**4. Schedule the workflow** in ` + "`config/agent-schedule.json`" + `: ` + "```json" + ` { "agents": { @@ -548,7 +548,7 @@ Use the search tool to find information on the web. 5. When creating multi-agent workflows, create an orchestrator agent 6. Add other agents as tools with ` + "`type: agent`" + ` for chaining 7. Use ` + "`listMcpServers`" + ` and ` + "`listMcpTools`" + ` when adding MCP integrations -8. Configure schedules in ` + "`~/.rowboat/config/agent-schedule.json`" + ` (ONLY edit this file, NOT the state file) +8. Configure schedules in ` + "`config/agent-schedule.json`" + ` (ONLY edit this file, NOT the state file) 9. Confirm work done and outline next steps once changes are complete `; diff --git a/apps/x/packages/core/src/application/assistant/skills/builtin-tools/skill.ts b/apps/x/packages/core/src/application/assistant/skills/builtin-tools/skill.ts index 0113a726..4187584e 100644 --- a/apps/x/packages/core/src/application/assistant/skills/builtin-tools/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/builtin-tools/skill.ts @@ -10,7 +10,7 @@ Agents can use builtin tools by declaring them in the YAML frontmatter \`tools\` ### executeCommand **The most powerful and versatile builtin tool** - Execute any bash/shell command and get the output. -**Security note:** Commands are filtered through \`.rowboat/config/security.json\`. Populate this file with allowed command names (array or dictionary entries). Any command not present is blocked and returns exit code 126 so the agent knows it violated the policy. +**Security note:** Commands are filtered through \`config/security.json\` in the workspace root. Populate this file with allowed command names (array or dictionary entries). Any command not present is blocked and returns exit code 126 so the agent knows it violated the policy. **Agent tool declaration (YAML frontmatter):** \`\`\`yaml diff --git a/apps/x/packages/core/src/application/assistant/skills/doc-collab/skill.ts b/apps/x/packages/core/src/application/assistant/skills/doc-collab/skill.ts index 567d43ac..f5f63c17 100644 --- a/apps/x/packages/core/src/application/assistant/skills/doc-collab/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/doc-collab/skill.ts @@ -166,7 +166,7 @@ workspace-readFile("knowledge/Projects/[Project].md") ## Document Locations -Documents are stored in \`~/.rowboat/knowledge/\` with subfolders: +Documents are stored in \`knowledge/\` within the workspace root, with subfolders: - \`People/\` - Notes about individuals - \`Organizations/\` - Notes about companies, teams - \`Projects/\` - Project documentation diff --git a/apps/x/packages/core/src/application/assistant/skills/draft-emails/skill.ts b/apps/x/packages/core/src/application/assistant/skills/draft-emails/skill.ts index 4e4322af..208e9560 100644 --- a/apps/x/packages/core/src/application/assistant/skills/draft-emails/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/draft-emails/skill.ts @@ -7,7 +7,7 @@ You are helping the user draft email responses. Use their calendar and knowledge **BEFORE drafting any email, you MUST look up the person/organization in the knowledge base.** -**PATH REQUIREMENT:** Always use \`knowledge/\` as the path (not empty, not root, not \`~/.rowboat\`). +**PATH REQUIREMENT:** Always use \`knowledge/\` as the path (not empty, not the workspace root, not an absolute path). - **WRONG:** \`path: ""\` or \`path: "."\` - **CORRECT:** \`path: "knowledge/"\` diff --git a/apps/x/packages/core/src/application/assistant/skills/meeting-prep/skill.ts b/apps/x/packages/core/src/application/assistant/skills/meeting-prep/skill.ts index 3a38e715..44453637 100644 --- a/apps/x/packages/core/src/application/assistant/skills/meeting-prep/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/meeting-prep/skill.ts @@ -7,7 +7,7 @@ You are helping the user prepare for meetings by gathering context from their kn **BEFORE creating any meeting brief, you MUST look up the attendees in the knowledge base.** -**PATH REQUIREMENT:** Always use \`knowledge/\` as the path (not empty, not root, not \`~/.rowboat\`). +**PATH REQUIREMENT:** Always use \`knowledge/\` as the path (not empty, not the workspace root, not an absolute path). - **WRONG:** \`path: ""\` or \`path: "."\` - **CORRECT:** \`path: "knowledge/"\` diff --git a/apps/x/packages/core/src/application/assistant/skills/slack/skill.ts b/apps/x/packages/core/src/application/assistant/skills/slack/skill.ts index 66df837d..81bb2562 100644 --- a/apps/x/packages/core/src/application/assistant/skills/slack/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/slack/skill.ts @@ -7,7 +7,7 @@ You interact with Slack by running **agent-slack** commands through \`executeCom ## 1. Check Connection -Before any Slack operation, read \`~/.rowboat/config/slack.json\`. If \`enabled\` is \`false\` or the \`workspaces\` array is empty, simply tell the user: "Slack is not enabled. You can enable it in the Connectors settings." Do not attempt any agent-slack commands. +Before any Slack operation, read \`config/slack.json\` from the workspace root. If \`enabled\` is \`false\` or the \`workspaces\` array is empty, simply tell the user: "Slack is not enabled. You can enable it in the Connectors settings." Do not attempt any agent-slack commands. If enabled, use the workspace URLs from the config for all commands. @@ -75,7 +75,7 @@ agent-slack canvas get F01234567 --workspace https://team.slack.com ## 3. Multi-Workspace -**Important:** The user has chosen which workspaces to use. Before your first Slack operation, read \`~/.rowboat/config/slack.json\` to see the selected workspaces. Only interact with workspaces listed in that config — ignore any other authenticated workspaces. +**Important:** The user has chosen which workspaces to use. Before your first Slack operation, read \`config/slack.json\` from the workspace root to see the selected workspaces. Only interact with workspaces listed in that config — ignore any other authenticated workspaces. If the selected workspace list contains multiple entries, use \`--workspace \` to disambiguate: diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index 404db713..f4fe42d6 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -1,6 +1,8 @@ import { z, ZodType } from "zod"; import * as path from "path"; import * as fs from "fs/promises"; +import { createReadStream } from "fs"; +import { createInterface } from "readline"; import { execSync } from "child_process"; import { glob } from "glob"; import { executeCommand, executeCommandAbortable } from "./command-executor.js"; @@ -170,14 +172,119 @@ export const BuiltinTools: z.infer = { }, 'workspace-readFile': { - description: 'Read file contents from the workspace. Supports utf8, base64, and binary encodings.', + description: 'Read a file from the workspace. For text files (utf8, the default), returns the content with each line prefixed by its 1-indexed line number (e.g. `12: some text`). Use the `offset` and `limit` parameters to page through large files; defaults read up to 2000 lines starting at line 1. Output is wrapped in ``, ``, `` tags and ends with a footer indicating whether the read reached end-of-file or was truncated. Line numbers in the output are display-only — do NOT include them when later writing or editing the file. For `base64` / `binary` encodings, returns the raw bytes as a string and ignores `offset` / `limit`.', inputSchema: z.object({ path: z.string().min(1).describe('Workspace-relative file path'), + offset: z.coerce.number().int().min(1).optional().describe('1-indexed line to start reading from (default: 1). Utf8 only.'), + limit: z.coerce.number().int().min(1).optional().describe('Maximum number of lines to read (default: 2000). Utf8 only.'), encoding: z.enum(['utf8', 'base64', 'binary']).optional().describe('File encoding (default: utf8)'), }), - execute: async ({ path: relPath, encoding = 'utf8' }: { path: string; encoding?: 'utf8' | 'base64' | 'binary' }) => { + execute: async ({ + path: relPath, + offset, + limit, + encoding = 'utf8', + }: { + path: string; + offset?: number; + limit?: number; + encoding?: 'utf8' | 'base64' | 'binary'; + }) => { try { - return await workspace.readFile(relPath, encoding); + if (encoding !== 'utf8') { + return await workspace.readFile(relPath, encoding); + } + + const DEFAULT_READ_LIMIT = 2000; + const MAX_LINE_LENGTH = 2000; + const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`; + const MAX_BYTES = 50 * 1024; + const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`; + + const absPath = workspace.resolveWorkspacePath(relPath); + const stats = await fs.lstat(absPath); + const stat = workspace.statToSchema(stats, 'file'); + const etag = workspace.computeEtag(stats.size, stats.mtimeMs); + + const effectiveOffset = offset ?? 1; + const effectiveLimit = limit ?? DEFAULT_READ_LIMIT; + const start = effectiveOffset - 1; + + const stream = createReadStream(absPath, { encoding: 'utf8' }); + const rl = createInterface({ input: stream, crlfDelay: Infinity }); + + const collected: string[] = []; + let totalLines = 0; + let bytes = 0; + let truncatedByBytes = false; + let hasMoreLines = false; + + try { + for await (const text of rl) { + totalLines += 1; + if (totalLines <= start) continue; + + if (collected.length >= effectiveLimit) { + hasMoreLines = true; + continue; + } + + const line = text.length > MAX_LINE_LENGTH + ? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX + : text; + const size = Buffer.byteLength(line, 'utf-8') + (collected.length > 0 ? 1 : 0); + if (bytes + size > MAX_BYTES) { + truncatedByBytes = true; + hasMoreLines = true; + break; + } + + collected.push(line); + bytes += size; + } + } finally { + rl.close(); + stream.destroy(); + } + + if (totalLines < effectiveOffset && !(totalLines === 0 && effectiveOffset === 1)) { + return { error: `Offset ${effectiveOffset} is out of range for this file (${totalLines} lines)` }; + } + + const prefixed = collected.map((line, index) => `${index + effectiveOffset}: ${line}`); + const lastReadLine = effectiveOffset + collected.length - 1; + const nextOffset = lastReadLine + 1; + + let footer: string; + if (truncatedByBytes) { + footer = `(Output capped at ${MAX_BYTES_LABEL}. Showing lines ${effectiveOffset}-${lastReadLine}. Use offset=${nextOffset} to continue.)`; + } else if (hasMoreLines) { + footer = `(Showing lines ${effectiveOffset}-${lastReadLine} of ${totalLines}. Use offset=${nextOffset} to continue.)`; + } else { + footer = `(End of file - total ${totalLines} lines)`; + } + + const content = [ + `${relPath}`, + `file`, + ``, + prefixed.join('\n'), + '', + footer, + ``, + ].join('\n'); + + return { + path: relPath, + encoding: 'utf8' as const, + content, + stat, + etag, + offset: effectiveOffset, + limit: effectiveLimit, + totalLines, + hasMore: hasMoreLines || truncatedByBytes, + }; } catch (error) { return { error: error instanceof Error ? error.message : 'Unknown error', @@ -1092,14 +1199,14 @@ export const BuiltinTools: z.infer = { } catch { return { success: false, - error: 'Exa Search API key not configured. Create ~/.rowboat/config/exa-search.json with { "apiKey": "" }', + error: `Exa Search API key not configured. Create ${exaConfigPath} with { "apiKey": "" }`, }; } if (!apiKey) { return { success: false, - error: 'Exa Search API key is empty. Set "apiKey" in ~/.rowboat/config/exa-search.json', + error: `Exa Search API key is empty. Set "apiKey" in ${exaConfigPath}`, }; } diff --git a/apps/x/packages/core/src/config/config.ts b/apps/x/packages/core/src/config/config.ts index abd10ec5..3d320172 100644 --- a/apps/x/packages/core/src/config/config.ts +++ b/apps/x/packages/core/src/config/config.ts @@ -3,9 +3,25 @@ import fs from "fs"; import { homedir } from "os"; import { fileURLToPath } from "url"; +function resolveWorkDir(): string { + const configured = process.env.ROWBOAT_WORKDIR; + if (!configured) { + return path.join(homedir(), ".rowboat"); + } + + const expanded = configured === "~" + ? homedir() + : (configured.startsWith("~/") || configured.startsWith("~\\")) + ? path.join(homedir(), configured.slice(2)) + : configured; + + return path.resolve(expanded); +} + // Resolve app root relative to compiled file location (dist/...) -// Allow override via ROWBOAT_WORKDIR env var for standalone pipeline usage -export const WorkDir = process.env.ROWBOAT_WORKDIR || path.join(homedir(), ".rowboat"); +// Allow override via ROWBOAT_WORKDIR env var for standalone pipeline usage. +// Normalize to an absolute path so workspace boundary checks behave consistently. +export const WorkDir = resolveWorkDir(); // Get the directory of this file (for locating bundled assets) const __filename = fileURLToPath(import.meta.url); diff --git a/apps/x/packages/core/src/knowledge/README.md b/apps/x/packages/core/src/knowledge/README.md index d8442c80..055a8bf1 100644 --- a/apps/x/packages/core/src/knowledge/README.md +++ b/apps/x/packages/core/src/knowledge/README.md @@ -13,7 +13,7 @@ Main orchestrator that: ### `graph_state.ts` State management module that tracks which files have been processed: - Uses hybrid mtime + hash approach for change detection -- Stores state in `~/.rowboat/knowledge_graph_state.json` +- Stores state in `WorkDir/knowledge_graph_state.json` - Provides modular functions for state operations ### `sync_gmail.ts` & `sync_fireflies.ts` @@ -39,7 +39,7 @@ This is efficient (only hashes potentially changed files) and reliable (confirms ### State File Structure -`~/.rowboat/knowledge_graph_state.json`: +`WorkDir/knowledge_graph_state.json`: ```json { "processedFiles": { @@ -69,7 +69,7 @@ This is efficient (only hashes potentially changed files) and reliable (confirms 3. **Agent processes batch** - Extracts entities (people, orgs, projects, topics) - - Creates/updates notes in `~/.rowboat/knowledge/` + - Creates/updates notes in `WorkDir/knowledge/` - Merges information for entities appearing in multiple files ## Replacing the Change Detection Logic @@ -135,7 +135,7 @@ import { resetGraphState } from './build_graph.js'; resetGraphState(); // Clears the state file ``` -Or manually delete: `~/.rowboat/knowledge_graph_state.json` +Or manually delete: `WorkDir/knowledge_graph_state.json` ## Note Creation Strictness @@ -143,7 +143,7 @@ The system supports three strictness levels that control how aggressively notes ### Configuration -Strictness is configured in `~/.rowboat/config/note_creation.json`: +Strictness is configured in `WorkDir/config/note_creation.json`: ```json { @@ -218,7 +218,7 @@ Each strictness level has its own agent prompt: Change `BATCH_SIZE` in `build_graph.ts` (currently 25 files per batch) ### State File Location -Change `STATE_FILE` in `graph_state.ts` (currently `~/.rowboat/knowledge_graph_state.json`) +Change `STATE_FILE` in `graph_state.ts` (currently `WorkDir/knowledge_graph_state.json`) ### Hash Algorithm Change `crypto.createHash('sha256')` in `graph_state.ts` to use a different algorithm (md5, sha1, etc.) diff --git a/apps/x/packages/core/src/knowledge/note_system.ts b/apps/x/packages/core/src/knowledge/note_system.ts index d167e97c..a62a4e37 100644 --- a/apps/x/packages/core/src/knowledge/note_system.ts +++ b/apps/x/packages/core/src/knowledge/note_system.ts @@ -9,7 +9,7 @@ export interface NoteTypeDefinition { extractionGuide: string; } -// ── Default definitions (used to seed ~/.rowboat/config/notes.json) ────────── +// ── Default definitions (used to seed WorkDir/config/notes.json) ───────────── const DEFAULT_NOTE_TYPE_DEFINITIONS: NoteTypeDefinition[] = [ { diff --git a/apps/x/packages/core/src/knowledge/sync_calendar.ts b/apps/x/packages/core/src/knowledge/sync_calendar.ts index b0345259..c6a10f8e 100644 --- a/apps/x/packages/core/src/knowledge/sync_calendar.ts +++ b/apps/x/packages/core/src/knowledge/sync_calendar.ts @@ -442,6 +442,12 @@ async function performSyncComposio() { const MAX_PAGES = 20; for (let page = 0; page < MAX_PAGES; page++) { + // Re-check connection in case user disconnected mid-sync + if (!composioAccountsRepo.isConnected('googlecalendar')) { + console.log('[Calendar] Account disconnected during sync. Stopping.'); + return; + } + const args: Record = { calendar_id: 'primary', time_min: timeMin, diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index 2f4cc806..599e75ac 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -732,6 +732,11 @@ async function performSyncComposio() { let highWaterMark: string | null = state?.last_sync ?? null; let processedCount = 0; for (const threadId of allThreadIds) { + // Re-check connection in case user disconnected mid-sync + if (!composioAccountsRepo.isConnected('gmail')) { + console.log('[Gmail] Account disconnected during sync. Stopping.'); + return; + } try { const newestInThread = await processThreadComposio(connectedAccountId, threadId, SYNC_DIR); processedCount++; diff --git a/apps/x/packages/core/src/knowledge/tag_system.ts b/apps/x/packages/core/src/knowledge/tag_system.ts index 7b46ef4d..e525655a 100644 --- a/apps/x/packages/core/src/knowledge/tag_system.ts +++ b/apps/x/packages/core/src/knowledge/tag_system.ts @@ -26,7 +26,7 @@ export interface TagDefinition { noteEffect?: NoteEffect; } -// ── Default definitions (used to seed ~/.rowboat/config/tags.json) ────────── +// ── Default definitions (used to seed WorkDir/config/tags.json) ───────────── const DEFAULT_TAG_DEFINITIONS: TagDefinition[] = [ // ── Relationship — who is this from/about (all create) ──────────────── diff --git a/apps/x/packages/core/src/models/repo.ts b/apps/x/packages/core/src/models/repo.ts index 4643951e..44a9d475 100644 --- a/apps/x/packages/core/src/models/repo.ts +++ b/apps/x/packages/core/src/models/repo.ts @@ -14,7 +14,7 @@ const defaultConfig: z.infer = { provider: { flavor: "openai", }, - model: "gpt-4.1", + model: "gpt-5.4", }; export class FSModelConfigRepo implements IModelConfigRepo { diff --git a/apps/x/packages/core/src/voice/voice.ts b/apps/x/packages/core/src/voice/voice.ts index 895c81b9..1cfba03b 100644 --- a/apps/x/packages/core/src/voice/voice.ts +++ b/apps/x/packages/core/src/voice/voice.ts @@ -2,10 +2,9 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import { isSignedIn } from '../account/account.js'; import { getAccessToken } from '../auth/tokens.js'; +import { WorkDir } from '../config/config.js'; import { API_URL } from '../config/env.js'; -const homedir = process.env.HOME || process.env.USERPROFILE || ''; - export interface VoiceConfig { deepgram: { apiKey: string } | null; elevenlabs: { apiKey: string; voiceId?: string } | null; @@ -13,7 +12,7 @@ export interface VoiceConfig { async function readJsonConfig(filename: string): Promise | null> { try { - const configPath = path.join(homedir, '.rowboat', 'config', filename); + const configPath = path.join(WorkDir, 'config', filename); const raw = await fs.readFile(configPath, 'utf8'); return JSON.parse(raw); } catch { @@ -51,7 +50,7 @@ export async function synthesizeSpeech(text: string): Promise<{ audioBase64: str console.log('[voice] synthesizing speech via Rowboat proxy, text length:', text.length, 'voiceId:', voiceId); } else { if (!config.elevenlabs) { - throw new Error('ElevenLabs not configured. Create ~/.rowboat/config/elevenlabs.json with { "apiKey": "" }'); + throw new Error(`ElevenLabs not configured. Create ${path.join(WorkDir, 'config', 'elevenlabs.json')} with { "apiKey": "" }`); } const voiceId = config.elevenlabs.voiceId || 'UgBBYS2sOqTuMpoF3BR0'; url = `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`; diff --git a/apps/x/packages/core/src/workspace/watcher.ts b/apps/x/packages/core/src/workspace/watcher.ts index 7d59331d..3460f014 100644 --- a/apps/x/packages/core/src/workspace/watcher.ts +++ b/apps/x/packages/core/src/workspace/watcher.ts @@ -10,7 +10,7 @@ export type WorkspaceChangeCallback = (event: z.infer= 12.13.0'} - '@mermaid-js/parser@0.6.3': - resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==} + '@mermaid-js/parser@1.1.0': + resolution: {integrity: sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==} '@modelcontextprotocol/sdk@1.25.1': resolution: {integrity: sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==} @@ -3531,6 +3534,9 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + '@vercel/oidc@3.0.5': resolution: {integrity: sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==} engines: {node: '>= 20'} @@ -3596,6 +3602,7 @@ packages: '@xmldom/xmldom@0.8.11': resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -3931,13 +3938,14 @@ packages: chardet@0.7.0: resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} - chevrotain-allstar@0.3.1: - resolution: {integrity: sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==} + chevrotain-allstar@0.4.1: + resolution: {integrity: sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA==} peerDependencies: - chevrotain: ^11.0.0 + chevrotain: ^12.0.0 - chevrotain@11.0.3: - resolution: {integrity: sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==} + chevrotain@12.0.0: + resolution: {integrity: sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==} + engines: {node: '>=22.0.0'} chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} @@ -4302,8 +4310,8 @@ packages: resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} engines: {node: '>=12'} - dagre-d3-es@7.0.13: - resolution: {integrity: sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==} + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} @@ -4987,6 +4995,7 @@ packages: glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@13.0.0: @@ -4995,7 +5004,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} @@ -5523,9 +5532,9 @@ packages: khroma@2.1.0: resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} - langium@3.3.1: - resolution: {integrity: sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==} - engines: {node: '>=16.0.0'} + langium@4.2.2: + resolution: {integrity: sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} layout-base@1.0.2: resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} @@ -5639,11 +5648,8 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} - lodash-es@4.17.21: - resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} - - lodash-es@4.17.22: - resolution: {integrity: sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==} + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} lodash.get@4.4.2: resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} @@ -5827,8 +5833,8 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - mermaid@11.12.2: - resolution: {integrity: sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w==} + mermaid@11.14.0: + resolution: {integrity: sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==} micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -7182,7 +7188,7 @@ packages: tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me temp@0.9.4: resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==} @@ -7556,8 +7562,8 @@ packages: resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} hasBin: true - vscode-uri@3.0.8: - resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==} + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} @@ -8408,22 +8414,20 @@ snapshots: '@braintree/sanitize-url@7.1.1': {} - '@chevrotain/cst-dts-gen@11.0.3': + '@chevrotain/cst-dts-gen@12.0.0': dependencies: - '@chevrotain/gast': 11.0.3 - '@chevrotain/types': 11.0.3 - lodash-es: 4.17.21 + '@chevrotain/gast': 12.0.0 + '@chevrotain/types': 12.0.0 - '@chevrotain/gast@11.0.3': + '@chevrotain/gast@12.0.0': dependencies: - '@chevrotain/types': 11.0.3 - lodash-es: 4.17.21 + '@chevrotain/types': 12.0.0 - '@chevrotain/regexp-to-ast@11.0.3': {} + '@chevrotain/regexp-to-ast@12.0.0': {} - '@chevrotain/types@11.0.3': {} + '@chevrotain/types@12.0.0': {} - '@chevrotain/utils@11.0.3': {} + '@chevrotain/utils@12.0.0': {} '@composio/client@0.1.0-alpha.56': {} @@ -9271,9 +9275,9 @@ snapshots: dependencies: cross-spawn: 7.0.6 - '@mermaid-js/parser@0.6.3': + '@mermaid-js/parser@1.1.0': dependencies: - langium: 3.3.1 + langium: 4.2.2 '@modelcontextprotocol/sdk@1.25.1(hono@4.11.3)(zod@4.2.1)': dependencies: @@ -11650,6 +11654,11 @@ snapshots: '@ungap/structured-clone@1.3.0': {} + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + '@vercel/oidc@3.0.5': {} '@vercel/oidc@3.1.0': {} @@ -12108,19 +12117,18 @@ snapshots: chardet@0.7.0: {} - chevrotain-allstar@0.3.1(chevrotain@11.0.3): + chevrotain-allstar@0.4.1(chevrotain@12.0.0): dependencies: - chevrotain: 11.0.3 - lodash-es: 4.17.22 + chevrotain: 12.0.0 + lodash-es: 4.18.1 - chevrotain@11.0.3: + chevrotain@12.0.0: dependencies: - '@chevrotain/cst-dts-gen': 11.0.3 - '@chevrotain/gast': 11.0.3 - '@chevrotain/regexp-to-ast': 11.0.3 - '@chevrotain/types': 11.0.3 - '@chevrotain/utils': 11.0.3 - lodash-es: 4.17.21 + '@chevrotain/cst-dts-gen': 12.0.0 + '@chevrotain/gast': 12.0.0 + '@chevrotain/regexp-to-ast': 12.0.0 + '@chevrotain/types': 12.0.0 + '@chevrotain/utils': 12.0.0 chokidar@4.0.3: dependencies: @@ -12487,10 +12495,10 @@ snapshots: d3-transition: 3.0.1(d3-selection@3.0.0) d3-zoom: 3.0.0 - dagre-d3-es@7.0.13: + dagre-d3-es@7.0.14: dependencies: d3: 7.9.0 - lodash-es: 4.17.22 + lodash-es: 4.18.1 data-uri-to-buffer@4.0.1: {} @@ -14017,13 +14025,14 @@ snapshots: khroma@2.1.0: {} - langium@3.3.1: + langium@4.2.2: dependencies: - chevrotain: 11.0.3 - chevrotain-allstar: 0.3.1(chevrotain@11.0.3) + '@chevrotain/regexp-to-ast': 12.0.0 + chevrotain: 12.0.0 + chevrotain-allstar: 0.4.1(chevrotain@12.0.0) vscode-languageserver: 9.0.1 vscode-languageserver-textdocument: 1.0.12 - vscode-uri: 3.0.8 + vscode-uri: 3.1.0 layout-base@1.0.2: {} @@ -14125,9 +14134,7 @@ snapshots: dependencies: p-locate: 5.0.0 - lodash-es@4.17.21: {} - - lodash-es@4.17.22: {} + lodash-es@4.18.1: {} lodash.get@4.4.2: {} @@ -14441,23 +14448,24 @@ snapshots: merge2@1.4.1: {} - mermaid@11.12.2: + mermaid@11.14.0: dependencies: '@braintree/sanitize-url': 7.1.1 '@iconify/utils': 3.1.0 - '@mermaid-js/parser': 0.6.3 + '@mermaid-js/parser': 1.1.0 '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 cytoscape: 3.33.1 cytoscape-cose-bilkent: 4.1.0(cytoscape@3.33.1) cytoscape-fcose: 2.2.0(cytoscape@3.33.1) d3: 7.9.0 d3-sankey: 0.12.3 - dagre-d3-es: 7.0.13 + dagre-d3-es: 7.0.14 dayjs: 1.11.19 dompurify: 3.3.1 katex: 0.16.27 khroma: 2.1.0 - lodash-es: 4.17.22 + lodash-es: 4.18.1 marked: 16.4.2 roughjs: 4.6.6 stylis: 4.3.6 @@ -16013,7 +16021,7 @@ snapshots: katex: 0.16.27 lucide-react: 0.542.0(react@19.2.3) marked: 16.4.2 - mermaid: 11.12.2 + mermaid: 11.14.0 react: 19.2.3 rehype-harden: 1.1.7 rehype-katex: 7.0.1 @@ -16492,7 +16500,7 @@ snapshots: dependencies: vscode-languageserver-protocol: 3.17.5 - vscode-uri@3.0.8: {} + vscode-uri@3.1.0: {} w3c-keyname@2.2.8: {}