Merge pull request #490 from rowboatlabs/dev

Dev
This commit is contained in:
arkml 2026-04-13 22:21:12 +05:30 committed by GitHub
commit 2133d7226f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 1018 additions and 386 deletions

1
.gitignore vendored
View file

@ -3,3 +3,4 @@
.vscode/
data/
.venv/
.claude/

View file

@ -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
}
]
}

View file

@ -110,6 +110,18 @@ function markdownToHtml(markdown: string, title: string): string {
</style></head><body>${html}</body></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<typeof workspaceShared.WorkspaceCh
/**
* Start workspace watcher
* Watches ~/.rowboat recursively and emits change events to renderer
* Watches the configured workspace root recursively and emits change events to renderer
*
* This should be called once when the app starts (from main.ts).
* The watcher runs as a main-process service and catches ALL filesystem changes
@ -607,24 +619,12 @@ export function setupIpcHandlers() {
},
// Shell integration handlers
'shell:openPath': 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 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)');

View file

@ -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) {

View file

@ -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}`);

View file

@ -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",

View file

@ -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<void>) | 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<Map<string, MarkdownEditorHandle>>(new Map())
const [paletteContext, setPaletteContext] = useState<CommandPaletteContext | null>(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<FileTab[]>([])
const [activeFileTabId, setActiveFileTabId] = useState<string | null>(null)
const activeFileTabIdRef = useRef(activeFileTabId)
activeFileTabIdRef.current = activeFileTabId
const [editorSessionByTabId, setEditorSessionByTabId] = useState<Record<string, number>>({})
const fileHistoryHandlersRef = useRef<Map<string, MarkdownHistoryHandlers>>(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) => `<file path="${file.path}">\n${file.content}\n</file>`)
.join('\n')
formattedMessage = `<attached-files>\n${filesXml}\n</attached-files>\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}
>
<MarkdownEditor
ref={(el) => {
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() {
/>
</SidebarProvider>
</div>
<SearchDialog
<CommandPalette
open={isSearchOpen}
onOpenChange={setIsSearchOpen}
onSelectFile={navigateToFile}
onSelectRun={(id) => { void navigateToView({ type: 'chat', runId: id }) }}
initialContext={paletteContext}
onChatSubmit={submitFromPalette}
/>
</SidebarSectionProvider>
<Toaster />

View file

@ -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 <FilePathCard filePath={text} />
}
}
if (
typeof childProps.className === 'string' &&
childProps.className.includes('language-mermaid')
) {
const text = typeof childProps.children === 'string'
? childProps.children.trim()
: ''
if (text) {
return <MermaidRenderer source={text} />
}
}
}
// Passthrough for all other code blocks - return children directly

View file

@ -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<FileMention[]>([]);
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 }];
});
}, []);

View file

@ -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,29 +54,20 @@ 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 ''
const blocks: string[] = []
// Helper to convert a node to markdown text
const nodeToText = (node: {
type?: string
content?: Array<{
type JsonNode = {
type?: string
content?: JsonNode[]
text?: string
marks?: Array<{ type: string; attrs?: Record<string, unknown> }>
attrs?: Record<string, unknown>
}>
attrs?: Record<string, unknown>
}): string => {
}
// 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 || ''
// Apply marks (bold, italic, etc.)
if (child.marks) {
for (const mark of child.marks) {
if (mark.type === 'bold') text = `**${text}**`
@ -93,30 +85,12 @@ function getMarkdownWithBlankLines(editor: Editor): string {
}
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('')
} else {
blocks.push(text)
}
} else if (node.type === 'heading') {
const level = (node.attrs?.level as number) || 1
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<Record<string, unknown>>; attrs?: Record<string, unknown> },
indent: number
): string[] => {
// 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 Array<{ content?: Array<Record<string, unknown>>; attrs?: Record<string, unknown> }>
const items = (listNode.content || []) as JsonNode[]
items.forEach((item, index) => {
const indentStr = ' '.repeat(indent)
let prefix: string
@ -128,7 +102,7 @@ function getMarkdownWithBlankLines(editor: Editor): string {
} else {
prefix = '- '
}
const itemContent = (item.content || []) as Array<{ type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record<string, unknown> }> }>; attrs?: Record<string, unknown> }>
const itemContent = (item.content || []) as JsonNode[]
let firstPara = true
itemContent.forEach(child => {
if (child.type === 'bulletList' || child.type === 'orderedList' || child.type === 'taskList') {
@ -145,66 +119,156 @@ function getMarkdownWithBlankLines(editor: Editor): string {
})
})
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)
if (!text || text === BLANK_LINE_MARKER || text.trim() === BLANK_LINE_MARKER) return ''
return text
}
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') {
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<MarkdownEditorHandle, MarkdownEditorProps>(function MarkdownEditor({
content,
onChange,
onPrimaryHeadingCommit,
@ -451,7 +520,7 @@ export function MarkdownEditor({
onFrontmatterChange,
onExport,
notePath,
}: MarkdownEditorProps) {
}, ref) {
const isInternalUpdate = useRef(false)
const wrapperRef = useRef<HTMLDivElement>(null)
const [activeWikiLink, setActiveWikiLink] = useState<WikiLinkMatch | null>(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({
</div>
</div>
)
}
})

View file

@ -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<HTMLDivElement>(null)
const [svg, setSvg] = useState<string | null>(null)
const [error, setError] = useState<string | null>(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 (
<div className={className}>
<div style={{ color: 'var(--destructive, #ef4444)', fontSize: 12, marginBottom: 4 }}>
Invalid mermaid syntax
</div>
<pre style={{ fontSize: 12, opacity: 0.7, whiteSpace: 'pre-wrap', margin: 0 }}>
<code>{source}</code>
</pre>
</div>
)
}
if (!svg) {
return (
<div className={className} style={{ fontSize: 13, opacity: 0.5 }}>
Rendering diagram...
</div>
)
}
return (
<div
ref={containerRef}
className={className}
dangerouslySetInnerHTML={{ __html: svg }}
style={{ lineHeight: 0 }}
/>
)
}

View file

@ -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<Mode>('chat')
const [chatInput, setChatInput] = useState('')
const [contextChip, setContextChip] = useState<CommandPaletteContext | null>(null)
const chatInputRef = useRef<HTMLInputElement>(null)
const searchInputRef = useRef<HTMLInputElement>(null)
const [query, setQuery] = useState('')
const [results, setResults] = useState<SearchResult[]>([])
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,12 +185,76 @@ export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }:
<CommandDialog
open={open}
onOpenChange={onOpenChange}
title="Search"
description="Search across knowledge and chats"
title={mode === 'chat' ? 'Chat with copilot' : 'Search'}
description={mode === 'chat' ? 'Start a chat — Tab to switch to search' : 'Search across knowledge and chats — Tab to switch to chat'}
showCloseButton={false}
className="top-[20%] translate-y-0"
>
{/* Mode strip */}
<div className="flex items-center gap-1.5 px-3 py-2 border-b">
<ModeButton
active={mode === 'chat'}
onClick={() => setMode('chat')}
icon={<MessageSquareIcon className="size-3" />}
label="Chat"
/>
<ModeButton
active={mode === 'search'}
onClick={() => setMode('search')}
icon={<FileTextIcon className="size-3" />}
label="Search"
/>
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">Tab to switch</span>
</div>
{mode === 'chat' ? (
<div className="flex flex-col">
<input
ref={chatInputRef}
type="text"
value={chatInput}
onChange={(e) => 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 && (
<div className="flex items-center gap-2 px-3 pb-3">
<span className="inline-flex items-center gap-1.5 rounded-md border bg-muted/40 px-2 py-1 text-xs">
<FileTextIcon className="size-3 shrink-0 text-muted-foreground" />
<span className="font-medium">{deriveDisplayName(contextChip.path)}</span>
<span className="text-muted-foreground">· Line {contextChip.lineNumber}</span>
<button
type="button"
onClick={() => setContextChip(null)}
aria-label="Remove context"
className="ml-0.5 rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
>
<XIcon className="size-3" />
</button>
</span>
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">Enter to send</span>
</div>
)}
{!contextChip && (
<div className="flex items-center px-3 pb-3">
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">Enter to send</span>
</div>
)}
</div>
) : (
<>
<CommandInput
ref={searchInputRef}
placeholder="Search..."
value={query}
onValueChange={setQuery}
@ -179,10 +315,48 @@ export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }:
</CommandGroup>
)}
</CommandList>
</>
)}
</CommandDialog>
)
}
// 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 (
<button
type="button"
onClick={onClick}
className={cn(
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
active
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground"
)}
>
{icon}
{label}
</button>
)
}
function FilterToggle({
active,
onClick,

View file

@ -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<string, unknown> }; deleteNode: () => void }) {
const source = (node.attrs.data as string) || ''
return (
<NodeViewWrapper className="mermaid-block-wrapper" data-type="mermaid-block">
<div className="mermaid-block-card">
<button
className="mermaid-block-delete"
onClick={deleteNode}
aria-label="Delete mermaid block"
>
<X size={14} />
</button>
{source ? (
<MermaidRenderer source={source} />
) : (
<div className="mermaid-block-empty">
<GitBranch size={16} />
<span>Empty mermaid block</span>
</div>
)}
</div>
</NodeViewWrapper>
)
}
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<string, unknown> }) {
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
},
},
}
},
})

View file

@ -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%;

View file

@ -566,7 +566,8 @@ export function convertFromMessages(messages: z.infer<typeof Message>[]): 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)

View file

@ -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\\<name>\\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

View file

@ -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
`;

View file

@ -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

View file

@ -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

View file

@ -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/"\`

View file

@ -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/"\`

View file

@ -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 <url>\` to disambiguate:

View file

@ -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<typeof BuiltinToolsSchema> = {
},
'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 `<path>`, `<type>`, `<content>` 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 {
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 = [
`<path>${relPath}</path>`,
`<type>file</type>`,
`<content>`,
prefixed.join('\n'),
'',
footer,
`</content>`,
].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<typeof BuiltinToolsSchema> = {
} catch {
return {
success: false,
error: 'Exa Search API key not configured. Create ~/.rowboat/config/exa-search.json with { "apiKey": "<your-key>" }',
error: `Exa Search API key not configured. Create ${exaConfigPath} with { "apiKey": "<your-key>" }`,
};
}
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}`,
};
}

View file

@ -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);

View file

@ -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.)

View file

@ -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[] = [
{

View file

@ -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<string, unknown> = {
calendar_id: 'primary',
time_min: timeMin,

View file

@ -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++;

View file

@ -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) ────────────────

View file

@ -14,7 +14,7 @@ const defaultConfig: z.infer<typeof ModelConfig> = {
provider: {
flavor: "openai",
},
model: "gpt-4.1",
model: "gpt-5.4",
};
export class FSModelConfigRepo implements IModelConfigRepo {

View file

@ -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<Record<string, unknown> | 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": "<your-key>" }');
throw new Error(`ElevenLabs not configured. Create ${path.join(WorkDir, 'config', 'elevenlabs.json')} with { "apiKey": "<your-key>" }`);
}
const voiceId = config.elevenlabs.voiceId || 'UgBBYS2sOqTuMpoF3BR0';
url = `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`;

View file

@ -10,7 +10,7 @@ export type WorkspaceChangeCallback = (event: z.infer<typeof WorkspaceChangeEven
/**
* Create a workspace watcher
* Watches ~/.rowboat recursively and emits change events via callback
* Watches the configured workspace root recursively and emits change events via callback
*
* Returns a watcher instance that can be closed.
* The watcher emits events immediately without debouncing.
@ -74,4 +74,3 @@ export async function createWorkspaceWatcher(
return watcher;
}

View file

@ -41,6 +41,7 @@ export const UserAttachmentPart = z.object({
filename: z.string(), // display name ("photo.png")
mimeType: z.string(), // MIME type ("image/png", "text/plain")
size: z.number().optional(), // bytes
lineNumber: z.number().int().min(1).optional(), // 1-indexed line in source file (for editor-context references)
});
// Any single part of a user message (text or attachment)

148
apps/x/pnpm-lock.yaml generated
View file

@ -220,6 +220,9 @@ importers:
lucide-react:
specifier: ^0.562.0
version: 0.562.0(react@19.2.3)
mermaid:
specifier: ^11.14.0
version: 11.14.0
motion:
specifier: ^12.23.26
version: 12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@ -743,20 +746,20 @@ packages:
'@braintree/sanitize-url@7.1.1':
resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==}
'@chevrotain/cst-dts-gen@11.0.3':
resolution: {integrity: sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==}
'@chevrotain/cst-dts-gen@12.0.0':
resolution: {integrity: sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==}
'@chevrotain/gast@11.0.3':
resolution: {integrity: sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==}
'@chevrotain/gast@12.0.0':
resolution: {integrity: sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==}
'@chevrotain/regexp-to-ast@11.0.3':
resolution: {integrity: sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==}
'@chevrotain/regexp-to-ast@12.0.0':
resolution: {integrity: sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==}
'@chevrotain/types@11.0.3':
resolution: {integrity: sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==}
'@chevrotain/types@12.0.0':
resolution: {integrity: sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==}
'@chevrotain/utils@11.0.3':
resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==}
'@chevrotain/utils@12.0.0':
resolution: {integrity: sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==}
'@composio/client@0.1.0-alpha.56':
resolution: {integrity: sha512-hNgChB5uhdvT4QXNzzfUuvtG6vrfanQQFY2hPyKwbeR4x6mEmIGFiZ4y2qynErdUWldAZiB/7pY/MBMg6Q9E0g==}
@ -1422,8 +1425,8 @@ packages:
resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==}
engines: {node: '>= 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: {}