Merge remote-tracking branch 'origin/dev' into feature/composio-tools-library

This commit is contained in:
tusharmagar 2026-03-31 17:03:40 +05:30
commit 013f6bdf17
39 changed files with 2729 additions and 1027 deletions

View file

@ -11,6 +11,9 @@ module.exports = {
icon: './icons/icon', // .icns extension added automatically
appBundleId: 'com.rowboat.app',
appCategoryType: 'public.app-category.productivity',
extendInfo: {
NSAudioCaptureUsageDescription: 'Rowboat needs access to system audio to transcribe meetings from other apps (Zoom, Meet, etc.)',
},
osxSign: {
batchCodesignCalls: true,
optionsForFile: () => ({

View file

@ -25,7 +25,7 @@ export interface AuthServerResult {
*/
export function createAuthServer(
port: number = DEFAULT_PORT,
onCallback: (code: string, state: string) => void | Promise<void>
onCallback: (params: Record<string, string>) => void | Promise<void>
): Promise<AuthServerResult> {
return new Promise((resolve, reject) => {
const server = createServer((req, res) => {
@ -67,7 +67,7 @@ export function createAuthServer(
// Handle callback - either traditional OAuth with code/state or Composio-style notification
// Composio callbacks may not have code/state, just a notification that the flow completed
onCallback(code || '', state || '');
onCallback(Object.fromEntries(url.searchParams.entries()));
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`

View file

@ -150,7 +150,7 @@ export async function initiateConnection(toolkitSlug: string): Promise<{
// Set up callback server
let cleanupTimeout: NodeJS.Timeout;
let callbackHandled = false;
const { server } = await createAuthServer(8081, async (_code, _state) => {
const { server } = await createAuthServer(8081, async () => {
// Guard against duplicate callbacks (browser may send multiple requests)
if (callbackHandled) return;
callbackHandled = true;

View file

@ -1,4 +1,4 @@
import { ipcMain, BrowserWindow, shell, dialog } from 'electron';
import { ipcMain, BrowserWindow, shell, dialog, systemPreferences, desktopCapturer } from 'electron';
import { ipc } from '@x/shared';
import path from 'node:path';
import os from 'node:os';
@ -735,6 +735,24 @@ export function setupIpcHandlers() {
return { success: false, error: 'Unknown format' };
},
'meeting:checkScreenPermission': async () => {
if (process.platform !== 'darwin') return { granted: true };
const status = systemPreferences.getMediaAccessStatus('screen');
console.log('[meeting] Screen recording permission status:', status);
if (status === 'granted') return { granted: true };
// Not granted — call desktopCapturer.getSources() to register the app
// in the macOS Screen Recording list. On first call this shows the
// native permission prompt (signed apps are remembered across restarts).
try { await desktopCapturer.getSources({ types: ['screen'] }); } catch { /* ignore */ }
// Re-check after the native prompt was dismissed
const statusAfter = systemPreferences.getMediaAccessStatus('screen');
console.log('[meeting] Screen recording permission status after prompt:', statusAfter);
return { granted: statusAfter === 'granted' };
},
'meeting:openScreenRecordingSettings': async () => {
await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture');
return { success: true };
},
'meeting:summarize': async (_event, args) => {
const notes = await summarizeMeeting(args.transcript, args.meetingStartTime, args.calendarEventJson);
return { notes };

View file

@ -25,6 +25,7 @@ import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js";
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
import started from "electron-squirrel-startup";
import { execSync } from "node:child_process";
import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@ -234,6 +235,9 @@ app.whenReady().then(async () => {
// start agent notes learning service
initAgentNotes();
// start chrome extension sync server
initChromeSync();
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();

View file

@ -187,12 +187,12 @@ export async function connectProvider(provider: string, clientId?: string): Prom
// Create callback server
let callbackHandled = false;
const { server } = await createAuthServer(8080, async (code, receivedState) => {
const { server } = await createAuthServer(8080, async (params: Record<string, string>) => {
// Guard against duplicate callbacks (browser may send multiple requests)
if (callbackHandled) return;
callbackHandled = true;
// Validate state
if (receivedState !== state) {
if (params.state !== state) {
throw new Error('Invalid state parameter - possible CSRF attack');
}
@ -203,7 +203,7 @@ export async function connectProvider(provider: string, clientId?: string): Prom
try {
// Build callback URL for token exchange
const callbackUrl = new URL(`${REDIRECT_URI}?code=${code}&state=${receivedState}`);
const callbackUrl = new URL(`${REDIRECT_URI}?${new URLSearchParams(params).toString()}`);
// Exchange code for tokens
console.log(`[OAuth] Exchanging authorization code for tokens (${provider})...`);

View file

@ -33,6 +33,7 @@ import {
} from '@/components/ai-elements/prompt-input';
import { Shimmer } from '@/components/ai-elements/shimmer';
import { useSmoothedText } from './hooks/useSmoothedText';
import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool';
import { WebSearchResult } from '@/components/ai-elements/web-search-result';
import { AppActionCard } from '@/components/ai-elements/app-action-card';
@ -93,6 +94,11 @@ interface TreeNode extends DirEntry {
const streamdownComponents = { pre: MarkdownPreOverride }
function SmoothStreamingMessage({ text, components }: { text: string; components: typeof streamdownComponents }) {
const smoothText = useSmoothedText(text)
return <MessageResponse components={components}>{smoothText}</MessageResponse>
}
const DEFAULT_SIDEBAR_WIDTH = 256
const wikiLinkRegex = /\[\[([^[\]]+)\]\]/g
const graphPalette = [
@ -478,7 +484,7 @@ function FixedSidebarToggle({
)}
style={{ marginLeft: TITLEBAR_BUTTON_GAP_PX }}
>
{meetingSummarizing ? (
{meetingSummarizing || meetingState === 'connecting' ? (
<LoaderIcon className="size-4 animate-spin" />
) : meetingState === 'recording' ? (
<SquareIcon className="size-4 animate-pulse" />
@ -488,7 +494,7 @@ function FixedSidebarToggle({
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{meetingSummarizing ? 'Generating meeting notes...' : meetingState === 'recording' ? 'Stop meeting notes' : 'Take new meeting notes'}
{meetingSummarizing ? 'Generating meeting notes...' : meetingState === 'connecting' ? 'Starting transcription...' : meetingState === 'recording' ? 'Stop meeting notes' : 'Take new meeting notes'}
</TooltipContent>
</Tooltip>
)}
@ -697,13 +703,18 @@ function App() {
window.ipc.invoke('oauth:getState', null),
]).then(([config, oauthState]) => {
const rowboatConnected = oauthState.config?.rowboat?.connected ?? false
setVoiceAvailable(!!config.deepgram || rowboatConnected)
const hasVoice = !!config.deepgram || rowboatConnected
setVoiceAvailable(hasVoice)
setTtsAvailable(!!config.elevenlabs || rowboatConnected)
// Pre-cache auth details so mic click skips IPC round-trips
if (hasVoice) {
voice.warmup()
}
}).catch(() => {
setVoiceAvailable(false)
setTtsAvailable(false)
})
}, [])
}, [voice.warmup])
useEffect(() => {
refreshVoiceAvailability()
@ -754,6 +765,22 @@ function App() {
isRecordingRef.current = false
}, [voice])
// Enter to submit voice input, Escape to cancel
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!isRecordingRef.current) return
if (e.key === 'Enter') {
e.preventDefault()
handleSubmitRecording()
} else if (e.key === 'Escape') {
e.preventDefault()
handleCancelRecording()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [handleSubmitRecording, handleCancelRecording])
// Helper to cancel recording from any navigation handler
const cancelRecordingIfActive = useCallback(() => {
if (isRecordingRef.current) {
@ -3390,9 +3417,9 @@ function App() {
const [meetingSummarizing, setMeetingSummarizing] = useState(false)
const [showMeetingPermissions, setShowMeetingPermissions] = useState(false)
const startMeetingAfterPermissions = useCallback(async () => {
setShowMeetingPermissions(false)
localStorage.setItem('meeting-permissions-acknowledged', '1')
const [checkingPermission, setCheckingPermission] = useState(false)
const startMeetingNow = useCallback(async () => {
const calEvent = pendingCalendarEventRef.current
pendingCalendarEventRef.current = undefined
const notePath = await meetingTranscription.start(calEvent)
@ -3402,6 +3429,23 @@ function App() {
}
}, [meetingTranscription, handleVoiceNoteCreated])
const handleCheckPermissionAndRetry = useCallback(async () => {
setCheckingPermission(true)
try {
const { granted } = await window.ipc.invoke('meeting:checkScreenPermission', null)
if (granted) {
setShowMeetingPermissions(false)
await startMeetingNow()
}
} finally {
setCheckingPermission(false)
}
}, [startMeetingNow])
const handleOpenScreenRecordingSettings = useCallback(async () => {
await window.ipc.invoke('meeting:openScreenRecordingSettings', null)
}, [])
const handleToggleMeeting = useCallback(async () => {
if (meetingTranscription.state === 'recording') {
await meetingTranscription.stop()
@ -3423,16 +3467,15 @@ function App() {
const calendarEventJson = calEventMatch?.[1]?.replace(/''/g, "'")
const { notes } = await window.ipc.invoke('meeting:summarize', { transcript: fileContent, meetingStartTime, calendarEventJson })
if (notes) {
// Prepend meeting notes below the title but above the transcript
const { raw: fm, body: transcriptBody } = splitFrontmatter(fileContent)
// Use frontmatter title as the heading (set from calendar event summary)
// Prepend meeting notes above the existing transcript block
const { raw: fm, body } = splitFrontmatter(fileContent)
const fmTitleMatch = fileContent.match(/^title:\s*(.+)$/m)
const noteTitle = fmTitleMatch?.[1]?.trim() || 'Meeting note'
// Strip any existing top-level heading from body
const bodyWithoutTitle = transcriptBody.replace(/^#\s+.+\s*\n*/, '')
// Also strip any title/heading the LLM may have generated
const noteTitle = fmTitleMatch?.[1]?.trim() || 'Meeting Notes'
const cleanedNotes = notes.replace(/^#{1,2}\s+.+\n+/, '')
const newBody = `# ${noteTitle}\n\n` + cleanedNotes + '\n\n---\n\n## Raw transcript\n\n' + bodyWithoutTitle
// Extract the existing transcript block and preserve it as-is
const transcriptBlockMatch = body.match(/(```transcript\n[\s\S]*?\n```)/)
const transcriptBlock = transcriptBlockMatch?.[1] || ''
const newBody = `# ${noteTitle}\n\n` + cleanedNotes + (transcriptBlock ? '\n\n' + transcriptBlock : '')
const newContent = fm ? `${fm}\n${newBody}` : newBody
await window.ipc.invoke('workspace:writeFile', {
path: notePath,
@ -3450,20 +3493,18 @@ function App() {
meetingNotePathRef.current = null
}
} else if (meetingTranscription.state === 'idle') {
// Show permissions modal on first use (macOS only — Windows works out of the box)
if (isMac && !localStorage.getItem('meeting-permissions-acknowledged')) {
setShowMeetingPermissions(true)
return
}
const calEvent = pendingCalendarEventRef.current
pendingCalendarEventRef.current = undefined
const notePath = await meetingTranscription.start(calEvent)
if (notePath) {
meetingNotePathRef.current = notePath
await handleVoiceNoteCreated(notePath)
// On macOS, check screen recording permission before starting
if (isMac) {
const result = await window.ipc.invoke('meeting:checkScreenPermission', null)
console.log('[meeting] Permission check result:', result)
if (!result.granted) {
setShowMeetingPermissions(true)
return
}
}
await startMeetingNow()
}
}, [meetingTranscription, handleVoiceNoteCreated])
}, [meetingTranscription, handleVoiceNoteCreated, startMeetingNow])
handleToggleMeetingRef.current = handleToggleMeeting
// Listen for calendar block "join meeting & take notes" events
@ -4237,7 +4278,7 @@ function App() {
{tabState.currentAssistantMessage && (
<Message from="assistant">
<MessageContent>
<MessageResponse components={streamdownComponents}>{tabState.currentAssistantMessage.replace(/<\/?voice>/g, '')}</MessageResponse>
<SmoothStreamingMessage text={tabState.currentAssistantMessage.replace(/<\/?voice>/g, '')} components={streamdownComponents} />
</MessageContent>
</Message>
)}
@ -4394,23 +4435,25 @@ function App() {
<Dialog open={showMeetingPermissions} onOpenChange={setShowMeetingPermissions}>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>Meeting transcription setup</DialogTitle>
<DialogTitle>Screen recording permission required</DialogTitle>
<DialogDescription>
Rowboat needs <strong>Screen Recording</strong> permission to capture meeting audio from other apps (Zoom, Meet, etc.).
Rowboat needs <strong>Screen Recording</strong> permission to capture meeting audio from other apps (Zoom, Meet, etc.). This feature won't work without it.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 text-sm text-muted-foreground">
<p>To enable this:</p>
<ol className="list-decimal list-inside space-y-1.5">
<li>Open <strong>System Settings</strong> <strong>Privacy & Security</strong></li>
<li>Click <strong>Screen Recording</strong></li>
<li>Open <strong>System Settings</strong> <strong>Privacy & Security</strong> <strong>Screen Recording</strong></li>
<li>Toggle on <strong>Rowboat</strong></li>
<li>You may need to restart the app after granting permission</li>
</ol>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowMeetingPermissions(false)}>Cancel</Button>
<Button onClick={() => { void startMeetingAfterPermissions() }}>Continue</Button>
<Button variant="outline" onClick={() => { void handleOpenScreenRecordingSettings() }}>Open System Settings</Button>
<Button onClick={() => { void handleCheckPermissionAndRetry() }} disabled={checkingPermission}>
{checkingPermission ? 'Checking...' : 'Check Again'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View file

@ -266,7 +266,7 @@ function ChatInputInner({
return () => window.removeEventListener('models-config-changed', handler)
}, [loadModelConfig])
// Check search tool availability (brave or exa, or signed-in via gateway)
// Check search tool availability (exa or signed-in via gateway)
useEffect(() => {
const checkSearch = async () => {
if (isRowboatConnected) {
@ -275,17 +275,10 @@ function ChatInputInner({
}
let available = false
try {
const raw = await window.ipc.invoke('workspace:readFile', { path: 'config/brave-search.json' })
const raw = await window.ipc.invoke('workspace:readFile', { path: 'config/exa-search.json' })
const config = JSON.parse(raw.data)
if (config.apiKey) available = true
} catch { /* not configured */ }
if (!available) {
try {
const raw = await window.ipc.invoke('workspace:readFile', { path: 'config/exa-search.json' })
const config = JSON.parse(raw.data)
if (config.apiKey) available = true
} catch { /* not configured */ }
}
setSearchAvailable(available)
}
checkSearch()

View file

@ -15,6 +15,7 @@ import { ChartBlockExtension } from '@/extensions/chart-block'
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 { Markdown } from 'tiptap-markdown'
import { useEffect, useCallback, useMemo, useRef, useState } from 'react'
import { Calendar, ChevronDown, ExternalLink } from 'lucide-react'
@ -108,39 +109,44 @@ function getMarkdownWithBlankLines(editor: Editor): string {
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') {
// Handle lists - all items are part of one block
const listLines: string[] = []
const listItems = (node.content || []) as Array<{ content?: Array<unknown>; attrs?: Record<string, unknown> }>
listItems.forEach((item, index) => {
const prefix = node.type === 'orderedList' ? `${index + 1}. ` : '- '
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> }>
itemContent.forEach((para: { type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record<string, unknown> }> }>; attrs?: Record<string, unknown> }, paraIndex: number) => {
const text = nodeToText(para)
if (paraIndex === 0) {
listLines.push(prefix + 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[] => {
const lines: string[] = []
const items = (listNode.content || []) as Array<{ content?: Array<Record<string, unknown>>; attrs?: Record<string, unknown> }>
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 {
listLines.push(' ' + text)
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> }>
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)
}
}
})
})
})
blocks.push(listLines.join('\n'))
} else if (node.type === 'taskList') {
const listLines: string[] = []
const listItems = (node.content || []) as Array<{ content?: Array<unknown>; attrs?: Record<string, unknown> }>
listItems.forEach(item => {
const checked = item.attrs?.checked ? 'x' : ' '
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> }>
itemContent.forEach((para: { type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record<string, unknown> }> }>; attrs?: Record<string, unknown> }, paraIndex: number) => {
const text = nodeToText(para)
if (paraIndex === 0) {
listLines.push(`- [${checked}] ${text}`)
} else {
listLines.push(' ' + text)
}
})
})
blocks.push(listLines.join('\n'))
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') {
@ -155,6 +161,8 @@ function getMarkdownWithBlankLines(editor: Editor): string {
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') {
const lang = (node.attrs?.language as string) || ''
blocks.push('```' + lang + '\n' + nodeToText(node) + '\n```')
@ -567,6 +575,7 @@ export function MarkdownEditor({
TableBlockExtension,
CalendarBlockExtension,
EmailBlockExtension,
TranscriptBlockExtension,
WikiLink.configure({
onCreate: wikiLinks?.onCreate
? (path) => {

View file

@ -9,12 +9,15 @@ function formatTime(dateStr: string): string {
return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
}
function getDateParts(dateStr: string): { day: number; month: string; weekday: string } {
function getDateParts(dateStr: string): { day: number; month: string; weekday: string; isToday: boolean } {
const d = new Date(dateStr)
const now = new Date()
const isToday = d.getDate() === now.getDate() && d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear()
return {
day: d.getDate(),
month: d.toLocaleDateString([], { month: 'long' }),
weekday: d.toLocaleDateString([], { weekday: 'short' }),
month: d.toLocaleDateString([], { month: 'short' }).toUpperCase(),
weekday: d.toLocaleDateString([], { weekday: 'short' }).toUpperCase(),
isToday,
}
}
@ -62,7 +65,8 @@ interface ResolvedEvent {
conferenceLink?: string
}
const EVENT_BAR_COLOR = '#7ec8c8'
const GCAL_EVENT_COLOR = '#039be5'
const GCAL_TODAY_COLOR = '#1a73e8'
function JoinMeetingSplitButton({ onJoinAndNotes, onNotesOnly }: {
onJoinAndNotes: () => void
@ -273,11 +277,8 @@ function CalendarBlockView({ node, deleteNode }: { node: { attrs: Record<string,
<div className="calendar-block-date-left">
{parts ? (
<>
<span className="calendar-block-day">{parts.day}</span>
<div className="calendar-block-month-weekday">
<span className="calendar-block-month">{parts.month}</span>
<span className="calendar-block-weekday">{parts.weekday}</span>
</div>
<span className="calendar-block-weekday" style={parts.isToday ? { color: GCAL_TODAY_COLOR } : undefined}>{parts.weekday}</span>
<span className={`calendar-block-day${parts.isToday ? ' calendar-block-day-today' : ''}`}>{parts.day}</span>
</>
) : (
<span className="calendar-block-day">?</span>
@ -288,16 +289,13 @@ function CalendarBlockView({ node, deleteNode }: { node: { attrs: Record<string,
<div
key={event._idx}
className={`calendar-block-event ${event.htmlLink ? 'calendar-block-event-clickable' : ''}`}
style={{ backgroundColor: GCAL_EVENT_COLOR }}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); handleEventClick(event) }}
>
<div
className="calendar-block-event-bar"
style={{ backgroundColor: EVENT_BAR_COLOR }}
/>
<div className="calendar-block-event-content">
<div className="calendar-block-event-title">
{event.summary || 'Untitled event'}
{event.summary || '(No title)'}
</div>
<div className="calendar-block-event-time">
{getTimeRange(event)}

View file

@ -1,8 +1,9 @@
import { mergeAttributes, Node } from '@tiptap/react'
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import { X, Mail, ChevronDown, ExternalLink, Copy, Check, Sparkles, Loader2, MessageSquare } from 'lucide-react'
import { X, Mail, ChevronDown, ExternalLink, Copy, Check, MessageSquare } from 'lucide-react'
import { blocks } from '@x/shared'
import { useState, useEffect, useRef, useCallback } from 'react'
import { useTheme } from '@/contexts/theme-context'
// --- Helpers ---
@ -17,8 +18,10 @@ function formatEmailDate(dateStr: string): string {
}
}
function getInitials(name: string): string {
return name.split(/\s+/).map(w => w[0]).filter(Boolean).slice(0, 2).join('').toUpperCase()
/** Extract just the name part from "Name <email>" format */
function senderFirstName(from: string): string {
const name = from.replace(/<.*>/, '').trim()
return name.split(/\s+/)[0] || name
}
declare global {
@ -45,27 +48,15 @@ function EmailBlockView({ node, deleteNode, updateAttributes }: {
const hasDraft = !!config?.draft_response
const hasPastSummary = !!config?.past_summary
const responseMode = config?.response_mode || 'both'
const { resolvedTheme } = useTheme()
// Local draft state for editing
const [draftBody, setDraftBody] = useState(config?.draft_response || '')
const [contextExpanded, setContextExpanded] = useState(false)
const [emailExpanded, setEmailExpanded] = useState(false)
const [copied, setCopied] = useState(false)
const [generating, setGenerating] = useState(false)
const [responseSplitOpen, setResponseSplitOpen] = useState(false)
const responseSplitRef = useRef<HTMLDivElement>(null)
const bodyRef = useRef<HTMLTextAreaElement>(null)
// Close split dropdown on outside click
useEffect(() => {
if (!responseSplitOpen) return
const handler = (e: MouseEvent) => {
if (responseSplitRef.current && !responseSplitRef.current.contains(e.target as globalThis.Node)) setResponseSplitOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [responseSplitOpen])
// Sync draft from external changes
useEffect(() => {
try {
@ -89,53 +80,23 @@ function EmailBlockView({ node, deleteNode, updateAttributes }: {
} catch { /* ignore */ }
}, [raw, updateAttributes])
const generateResponse = useCallback(async () => {
if (!config || generating) return
setGenerating(true)
try {
const ipc = (window as unknown as { ipc: { invoke: (channel: string, args: Record<string, unknown>) => Promise<{ response?: string }> } }).ipc
// Build context for the agent
let noteContent = `# Email: ${config.subject || 'No subject'}\n\n`
noteContent += `**From:** ${config.from || 'Unknown'}\n`
noteContent += `**Date:** ${config.date || 'Unknown'}\n\n`
noteContent += `## Latest email\n\n${config.latest_email}\n\n`
if (config.past_summary) {
noteContent += `## Earlier conversation summary\n\n${config.past_summary}\n\n`
}
const result = await ipc.invoke('inline-task:process', {
instruction: `Draft a concise, professional response to this email. Return only the email body text, no subject line or headers.`,
noteContent,
notePath: '',
})
if (result.response) {
// Clean up the response — strip any markdown headers the agent may add
const cleaned = result.response.replace(/^#+\s+.*\n*/gm, '').trim()
setDraftBody(cleaned)
// Update the block data to include the draft
const current = JSON.parse(raw) as Record<string, unknown>
updateAttributes({ data: JSON.stringify({ ...current, draft_response: cleaned }) })
}
} catch (err) {
console.error('[email-block] Failed to generate response:', err)
} finally {
setGenerating(false)
}
}, [config, generating, raw, updateAttributes])
const draftWithAssistant = useCallback(() => {
if (!config) return
let prompt = `Help me draft a response to this email`
let prompt = draftBody
? `Help me refine this draft response to an email`
: `Help me draft a response to this email`
if (config.threadId) {
prompt += `. Read the full thread at gmail_sync/${config.threadId}.md for context`
}
prompt += `.\n\n`
prompt += `**From:** ${config.from || 'Unknown'}\n`
prompt += `**Subject:** ${config.subject || 'No subject'}\n`
if (draftBody) {
prompt += `\n**Current draft:**\n${draftBody}\n`
}
window.__pendingEmailDraft = { prompt }
window.dispatchEvent(new Event('email-block:draft-with-assistant'))
}, [config])
}, [config, draftBody])
if (!config) {
return (
@ -152,185 +113,112 @@ function EmailBlockView({ node, deleteNode, updateAttributes }: {
? `https://mail.google.com/mail/u/0/#all/${config.threadId}`
: null
// --- Render: Draft mode (draft_response present) ---
if (hasDraft) {
return (
<NodeViewWrapper className="email-block-wrapper" data-type="email-block">
<div className="email-block-card" onMouseDown={(e) => e.stopPropagation()}>
<button className="email-block-delete" onClick={deleteNode} aria-label="Delete email block">
<X size={14} />
</button>
{/* Draft header */}
{config.to && (
<div className="email-draft-block-header">
<div className="email-draft-block-field">
<span className="email-draft-block-label">To</span>
<span className="email-draft-block-value">{config.to}</span>
</div>
{config.subject && (
<div className="email-draft-block-field">
<span className="email-draft-block-label">Subject</span>
<span className="email-draft-block-value">{config.subject}</span>
// Build summary: use explicit summary, or auto-generate from sender + subject
const summary = config.summary
|| (config.from && config.subject
? `${senderFirstName(config.from)} reached out about ${config.subject}`
: config.subject || 'New email')
return (
<NodeViewWrapper className="email-block-wrapper" data-type="email-block">
<div className="email-block-card email-block-card-gmail" onMouseDown={(e) => e.stopPropagation()}>
<button className="email-block-delete" onClick={deleteNode} aria-label="Delete email block">
<X size={14} />
</button>
{/* Header: Email badge */}
<div className="email-block-badge">
<Mail size={13} />
Email
</div>
{/* Summary */}
<div className="email-block-summary">{summary}</div>
{/* Expandable email details */}
<button
className="email-block-expand-btn"
onClick={(e) => { e.stopPropagation(); setEmailExpanded(!emailExpanded) }}
onMouseDown={(e) => e.stopPropagation()}
>
<ChevronDown size={13} className={`email-block-toggle-chevron ${emailExpanded ? 'email-block-toggle-chevron-open' : ''}`} />
{emailExpanded ? 'Hide email' : 'Show email'}
{config.from && <span className="email-block-expand-meta">· From {senderFirstName(config.from)}</span>}
{config.date && <span className="email-block-expand-meta">· {formatEmailDate(config.date)}</span>}
</button>
{emailExpanded && (
<div className="email-block-email-details">
<div className="email-block-message">
<div className="email-block-message-header">
<div className="email-block-sender-info">
<div className="email-block-sender-row">
<div className="email-block-sender-name">{config.from || 'Unknown'}</div>
{config.date && <div className="email-block-sender-date">{formatEmailDate(config.date)}</div>}
</div>
{config.subject && <div className="email-block-subject-line">Subject: {config.subject}</div>}
</div>
)}
</div>
<div className="email-block-message-body">{config.latest_email}</div>
</div>
)}
{/* Editable draft body */}
<textarea
ref={bodyRef}
className="email-draft-block-body-input"
value={draftBody}
onChange={(e) => setDraftBody(e.target.value)}
onBlur={() => commitDraft(draftBody)}
placeholder="Write your reply..."
rows={3}
/>
{/* Action buttons */}
<div className="email-draft-block-actions">
{(hasPastSummary || config.latest_email) && (
<button
className="email-block-gmail-btn"
onClick={(e) => { e.stopPropagation(); setContextExpanded(!contextExpanded) }}
onMouseDown={(e) => e.stopPropagation()}
>
<ChevronDown size={13} className={`email-block-toggle-chevron ${contextExpanded ? 'email-block-toggle-chevron-open' : ''}`} />
{contextExpanded ? 'Hide' : 'Show'} context
</button>
{hasPastSummary && (
<div className="email-block-context-section">
<div className="email-block-context-label">Earlier conversation</div>
<div className="email-block-context-summary">{config.past_summary}</div>
</div>
)}
</div>
)}
{/* Draft section */}
{hasDraft && (
<div className="email-block-draft-section">
<div className="email-block-draft-label">Draft reply</div>
<textarea
key={resolvedTheme}
ref={bodyRef}
className="email-draft-block-body-input"
value={draftBody}
onChange={(e) => setDraftBody(e.target.value)}
onBlur={() => commitDraft(draftBody)}
placeholder="Write your reply..."
rows={3}
/>
</div>
)}
{/* Action buttons */}
<div className="email-block-actions">
<button
className="email-block-gmail-btn email-block-gmail-btn-primary"
onClick={draftWithAssistant}
>
<MessageSquare size={13} />
{hasDraft ? 'Refine with Rowboat' : 'Draft with Rowboat'}
</button>
{hasDraft && (
<button
className="email-block-gmail-btn"
className="email-block-gmail-btn email-block-gmail-btn-primary"
onClick={() => {
void navigator.clipboard.writeText(draftBody)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
navigator.clipboard.writeText(draftBody).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}).catch(() => {
// Fallback for Electron contexts where clipboard API may fail
const textarea = document.createElement('textarea')
textarea.value = draftBody
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}}
>
{copied ? <Check size={13} /> : <Copy size={13} />}
{copied ? 'Copied!' : 'Copy draft'}
</button>
{gmailUrl && (
<button
className="email-block-gmail-btn"
onClick={() => {
void navigator.clipboard.writeText(draftBody)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
window.open(gmailUrl, '_blank')
}}
>
<ExternalLink size={13} />
Reply in Gmail
</button>
)}
</div>
{/* Context: latest email + past summary */}
{contextExpanded && (
<div className="email-block-context">
<div className="email-block-context-section">
<div className="email-block-message">
<div className="email-block-message-header">
{config.from && <div className="email-block-avatar">{getInitials(config.from)}</div>}
<div className="email-block-sender-info">
{config.from && <div className="email-block-sender-name">{config.from}</div>}
{config.date && <div className="email-block-sender-date">{formatEmailDate(config.date)}</div>}
</div>
</div>
<div className="email-block-message-body">{config.latest_email}</div>
</div>
</div>
{hasPastSummary && (
<div className="email-block-context-section">
<div className="email-block-context-label">Earlier conversation</div>
<div className="email-block-context-summary">{config.past_summary}</div>
</div>
)}
</div>
)}
</div>
</NodeViewWrapper>
)
}
// --- Render: Read mode (no draft_response) ---
return (
<NodeViewWrapper className="email-block-wrapper" data-type="email-block">
<div className="email-block-card" onMouseDown={(e) => e.stopPropagation()}>
<button className="email-block-delete" onClick={deleteNode} aria-label="Delete email block">
<X size={14} />
</button>
{config.subject && <div className="email-block-subject">{config.subject}</div>}
{/* Latest email message */}
<div className="email-block-message">
<div className="email-block-message-header">
{config.from && <div className="email-block-avatar">{getInitials(config.from)}</div>}
<div className="email-block-sender-info">
{config.from && <div className="email-block-sender-name">{config.from}</div>}
{config.date && <div className="email-block-sender-date">{formatEmailDate(config.date)}</div>}
</div>
</div>
<div className="email-block-message-body">{config.latest_email}</div>
</div>
{/* Action buttons */}
<div className="email-draft-block-actions">
{hasPastSummary && (
<button
className="email-block-gmail-btn"
onClick={(e) => { e.stopPropagation(); setContextExpanded(!contextExpanded) }}
onMouseDown={(e) => e.stopPropagation()}
>
<ChevronDown size={13} className={`email-block-toggle-chevron ${contextExpanded ? 'email-block-toggle-chevron-open' : ''}`} />
{contextExpanded ? 'Hide' : 'Show'} context
</button>
)}
{responseMode === 'inline' && (
<button
className="email-block-gmail-btn email-block-generate-btn"
onClick={generateResponse}
disabled={generating}
>
{generating ? <Loader2 size={13} className="email-block-spinner" /> : <Sparkles size={13} />}
{generating ? 'Generating...' : 'Generate response'}
</button>
)}
{responseMode === 'assistant' && (
<button
className="email-block-gmail-btn email-block-generate-btn"
onClick={draftWithAssistant}
>
<MessageSquare size={13} />
Draft with assistant
</button>
)}
{responseMode === 'both' && (
<div className="email-block-response-split" ref={responseSplitRef}>
<button
className="email-block-split-main"
onClick={generateResponse}
disabled={generating}
>
{generating ? <Loader2 size={13} className="email-block-spinner" /> : <Sparkles size={13} />}
{generating ? 'Generating...' : 'Generate response'}
</button>
<button
className={`email-block-split-chevron ${responseSplitOpen ? 'email-block-split-chevron-open' : ''}`}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); setResponseSplitOpen(!responseSplitOpen) }}
>
<ChevronDown size={12} />
</button>
{responseSplitOpen && (
<div className="email-block-split-dropdown">
<button
className="email-block-split-option"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); setResponseSplitOpen(false); draftWithAssistant() }}
>
<MessageSquare size={13} />
Draft with assistant
</button>
</div>
)}
</div>
)}
{gmailUrl && (
<button
@ -342,15 +230,6 @@ function EmailBlockView({ node, deleteNode, updateAttributes }: {
</button>
)}
</div>
{/* Past summary context */}
{contextExpanded && hasPastSummary && (
<div className="email-block-context">
<div className="email-block-context-section">
<div className="email-block-context-label">Earlier conversation</div>
<div className="email-block-context-summary">{config.past_summary}</div>
</div>
</div>
)}
</div>
</NodeViewWrapper>
)

View file

@ -0,0 +1,177 @@
import { mergeAttributes, Node } from '@tiptap/react'
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import { ChevronDown, FileText } from 'lucide-react'
import { blocks } from '@x/shared'
import { useState, useMemo } from 'react'
interface TranscriptEntry {
speaker: string
text: string
}
function parseTranscript(raw: string): TranscriptEntry[] {
const entries: TranscriptEntry[] = []
const lines = raw.split('\n')
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) continue
// Match **Speaker Name:** text or **You:** text
const match = trimmed.match(/^\*\*(.+?):\*\*\s*(.*)$/)
if (match) {
entries.push({ speaker: match[1], text: match[2] })
} else if (entries.length > 0) {
// Continuation line — append to last entry
entries[entries.length - 1].text += ' ' + trimmed
}
}
return entries
}
function speakerColor(speaker: string): string {
// Simple hash to pick a consistent color per speaker
let hash = 0
for (let i = 0; i < speaker.length; i++) {
hash = speaker.charCodeAt(i) + ((hash << 5) - hash)
}
const colors = [
'#3b82f6', // blue
'#06b6d4', // cyan
'#6366f1', // indigo
'#8b5cf6', // purple
'#0ea5e9', // sky
'#2563eb', // blue darker
'#7c3aed', // violet
]
return colors[Math.abs(hash) % colors.length]
}
function TranscriptBlockView({ node, getPos, editor }: {
node: { attrs: Record<string, unknown> }
getPos: () => number | undefined
// eslint-disable-next-line @typescript-eslint/no-explicit-any
editor: any
}) {
const raw = node.attrs.data as string
let config: blocks.TranscriptBlock | null = null
try {
config = blocks.TranscriptBlockSchema.parse(JSON.parse(raw))
} catch {
// fallback below
}
// Auto-detect: expand if this is the first real block (live recording),
// collapse if there's other content above (notes have been generated)
const isFirstBlock = useMemo(() => {
try {
const pos = getPos()
if (pos === undefined) return false
const firstChild = editor?.state?.doc?.firstChild
if (!firstChild) return true
// If the transcript block is right after the first node (heading), it's the main content
return pos <= (firstChild.nodeSize ?? 0) + 1
} catch {
return false
}
}, [getPos, editor])
const [expanded, setExpanded] = useState(isFirstBlock)
const entries = useMemo(() => {
if (!config) return []
return parseTranscript(config.transcript)
}, [config])
if (!config) {
return (
<NodeViewWrapper className="transcript-block-wrapper" data-type="transcript-block">
<div className="transcript-block-card transcript-block-error">
<FileText size={16} />
<span>Invalid transcript block</span>
</div>
</NodeViewWrapper>
)
}
return (
<NodeViewWrapper className="transcript-block-wrapper" data-type="transcript-block">
<div className="transcript-block-card" onMouseDown={(e) => e.stopPropagation()}>
<button
className="transcript-block-toggle"
onClick={(e) => { e.stopPropagation(); setExpanded(!expanded) }}
onMouseDown={(e) => e.stopPropagation()}
>
<ChevronDown size={14} className={`transcript-block-chevron ${expanded ? 'transcript-block-chevron-open' : ''}`} />
<FileText size={14} />
<span>Raw transcript</span>
</button>
{expanded && (
<div className="transcript-block-content">
{entries.length > 0 ? (
entries.map((entry, i) => (
<div key={i} className="transcript-entry">
<span className="transcript-speaker" style={{ color: speakerColor(entry.speaker) }}>
{entry.speaker}
</span>
<span className="transcript-text">{entry.text}</span>
</div>
))
) : (
<div className="transcript-raw">{config.transcript}</div>
)}
</div>
)}
</div>
</NodeViewWrapper>
)
}
export const TranscriptBlockExtension = Node.create({
name: 'transcriptBlock',
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-transcript')) {
return { data: code.textContent || '{}' }
}
return false
},
}]
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'transcript-block' })]
},
addNodeView() {
return ReactNodeViewRenderer(TranscriptBlockView)
},
addStorage() {
return {
markdown: {
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
state.write('```transcript\n' + node.attrs.data + '\n```')
state.closeBlock(node)
},
parse: {},
},
}
},
})

View file

@ -60,7 +60,7 @@ export interface CalendarEventMeta {
}
function formatTranscript(entries: TranscriptEntry[], date: string, calendarEvent?: CalendarEventMeta): string {
const noteTitle = calendarEvent?.summary || 'Meeting note';
const noteTitle = calendarEvent?.summary || 'Meeting Notes';
const lines = [
'---',
'type: meeting',
@ -89,13 +89,18 @@ function formatTranscript(entries: TranscriptEntry[], date: string, calendarEven
`# ${noteTitle}`,
'',
);
// Build the raw transcript text
const transcriptLines: string[] = [];
for (let i = 0; i < entries.length; i++) {
if (i > 0 && entries[i].speaker !== entries[i - 1].speaker) {
lines.push('');
transcriptLines.push('');
}
lines.push(`**${entries[i].speaker}:** ${entries[i].text}`);
lines.push('');
transcriptLines.push(`**${entries[i].speaker}:** ${entries[i].text}`);
transcriptLines.push('');
}
const transcriptText = transcriptLines.join('\n').trim();
const transcriptData = JSON.stringify({ transcript: transcriptText });
lines.push('```transcript', transcriptData, '```');
return lines.join('\n');
}
@ -187,52 +192,83 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
if (state !== 'idle') return null;
setState('connecting');
// Detect headphones vs speakers
const usingHeadphones = await detectHeadphones();
console.log(`[meeting] Audio output mode: ${usingHeadphones ? 'headphones' : 'speakers'}`);
// Rowboat WebSocket + bearer token when signed in; else local Deepgram API key
let ws: WebSocket;
try {
const account = await refreshRowboatAccount();
if (
account?.signedIn &&
account.accessToken &&
account.config?.websocketApiUrl
) {
const listenUrl = buildDeepgramListenUrl(account.config.websocketApiUrl, DEEPGRAM_PARAMS);
console.log('[meeting] Using Rowboat WebSocket');
ws = new WebSocket(listenUrl, ['bearer', account.accessToken]);
} else {
const config = await window.ipc.invoke('voice:getConfig', null);
if (!config?.deepgram) {
console.error('[meeting] No Deepgram config available');
setState('idle');
return null;
// Run independent setup steps in parallel for faster startup
const [headphoneResult, wsResult, micResult, systemResult] = await Promise.allSettled([
// 1. Detect headphones vs speakers
detectHeadphones(),
// 2. Set up Deepgram WebSocket (account refresh + connect + wait for open)
(async () => {
const account = await refreshRowboatAccount();
let ws: WebSocket;
if (
account?.signedIn &&
account.accessToken &&
account.config?.websocketApiUrl
) {
const listenUrl = buildDeepgramListenUrl(account.config.websocketApiUrl, DEEPGRAM_PARAMS);
console.log('[meeting] Using Rowboat WebSocket');
ws = new WebSocket(listenUrl, ['bearer', account.accessToken]);
} else {
const config = await window.ipc.invoke('voice:getConfig', null);
if (!config?.deepgram) {
throw new Error('No Deepgram config available');
}
console.log('[meeting] Using Deepgram API key');
ws = new WebSocket(DEEPGRAM_LISTEN_URL, ['token', config.deepgram.apiKey]);
}
console.log('[meeting] Using Deepgram API key');
ws = new WebSocket(DEEPGRAM_LISTEN_URL, ['token', config.deepgram.apiKey]);
}
} catch (err) {
console.error('[meeting] Failed to connect Deepgram:', err);
setState('idle');
return null;
}
wsRef.current = ws;
const ok = await new Promise<boolean>((resolve) => {
ws.onopen = () => resolve(true);
ws.onerror = () => resolve(false);
setTimeout(() => resolve(false), 5000);
});
if (!ok) throw new Error('WebSocket failed to connect');
console.log('[meeting] WebSocket connected');
return ws;
})(),
// 3. Get mic stream
navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
}),
// 4. Get system audio via getDisplayMedia (loopback)
(async () => {
const stream = await navigator.mediaDevices.getDisplayMedia({ audio: true, video: true });
stream.getVideoTracks().forEach(t => t.stop());
if (stream.getAudioTracks().length === 0) {
stream.getTracks().forEach(t => t.stop());
throw new Error('No audio track from getDisplayMedia');
}
console.log('[meeting] System audio captured');
return stream;
})(),
]);
// Wait for WS open
const wsOk = await new Promise<boolean>((resolve) => {
ws.onopen = () => resolve(true);
ws.onerror = () => resolve(false);
setTimeout(() => resolve(false), 5000);
});
if (!wsOk) {
console.error('[meeting] WebSocket failed to connect');
// Check for failures — clean up any successful resources if something failed
const failed = wsResult.status === 'rejected'
|| micResult.status === 'rejected'
|| systemResult.status === 'rejected';
if (failed) {
if (wsResult.status === 'rejected') console.error('[meeting] WebSocket setup failed:', wsResult.reason);
if (micResult.status === 'rejected') console.error('[meeting] Microphone access denied:', micResult.reason);
if (systemResult.status === 'rejected') console.error('[meeting] System audio access denied:', systemResult.reason);
// Clean up any resources that did succeed
if (wsResult.status === 'fulfilled') { wsResult.value.close(); }
if (micResult.status === 'fulfilled') { micResult.value.getTracks().forEach(t => t.stop()); }
if (systemResult.status === 'fulfilled') { systemResult.value.getTracks().forEach(t => t.stop()); }
cleanup();
setState('idle');
return null;
}
console.log('[meeting] WebSocket connected');
const usingHeadphones = headphoneResult.status === 'fulfilled' ? headphoneResult.value : false;
console.log(`[meeting] Audio output mode: ${usingHeadphones ? 'headphones' : 'speakers'}`);
const ws = wsResult.value;
wsRef.current = ws;
// Set up WS message handler
transcriptRef.current = [];
@ -283,43 +319,10 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
wsRef.current = null;
};
// Get mic stream
let micStream: MediaStream;
try {
micStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
});
} catch (err) {
console.error('[meeting] Microphone access denied:', err);
cleanup();
setState('idle');
return null;
}
const micStream = micResult.value;
micStreamRef.current = micStream;
// Get system audio via getDisplayMedia (loopback)
let systemStream: MediaStream;
try {
systemStream = await navigator.mediaDevices.getDisplayMedia({ audio: true, video: true });
systemStream.getVideoTracks().forEach(t => t.stop());
} catch (err) {
console.error('[meeting] System audio access denied:', err);
cleanup();
setState('idle');
return null;
}
if (systemStream.getAudioTracks().length === 0) {
console.error('[meeting] No audio track from getDisplayMedia');
systemStream.getTracks().forEach(t => t.stop());
cleanup();
setState('idle');
return null;
}
console.log('[meeting] System audio captured');
const systemStream = systemResult.value;
systemStreamRef.current = systemStream;
// ----- Audio pipeline -----

View file

@ -0,0 +1,48 @@
import { useEffect, useRef, useState } from 'react'
/**
* Smoothly reveals streamed text by buffering incoming chunks and releasing
* them gradually via requestAnimationFrame, producing the fluid typing effect
* seen in apps like Claude and ChatGPT.
*/
export function useSmoothedText(targetText: string): string {
const [displayText, setDisplayText] = useState('')
const targetRef = useRef('')
const displayLenRef = useRef(0)
const rafRef = useRef<number>(0)
targetRef.current = targetText
useEffect(() => {
// Target cleared → immediately clear display
if (!targetText) {
displayLenRef.current = 0
setDisplayText('')
cancelAnimationFrame(rafRef.current)
return
}
const tick = () => {
const target = targetRef.current
if (!target) return
const currentLen = displayLenRef.current
if (currentLen < target.length) {
const remaining = target.length - currentLen
// Adaptive speed: reveal faster when buffer is large, slower when small
const step = Math.max(2, Math.ceil(remaining * 0.18))
displayLenRef.current = Math.min(currentLen + step, target.length)
setDisplayText(target.slice(0, displayLenRef.current))
rafRef.current = requestAnimationFrame(tick)
}
// When caught up, stop. New useEffect call restarts when more text arrives.
}
cancelAnimationFrame(rafRef.current)
rafRef.current = requestAnimationFrame(tick)
return () => cancelAnimationFrame(rafRef.current)
}, [targetText])
return displayText
}

View file

@ -13,9 +13,14 @@ const DEEPGRAM_PARAMS = new URLSearchParams({
smart_format: 'true',
punctuate: 'true',
language: 'en',
endpointing: '100',
no_delay: 'true',
});
const DEEPGRAM_LISTEN_URL = `wss://api.deepgram.com/v1/listen?${DEEPGRAM_PARAMS.toString()}`;
// Cache auth details so we don't need IPC round-trips on every mic click
let cachedAuth: { type: 'rowboat'; url: string; token: string } | { type: 'local'; apiKey: string } | null = null;
export function useVoiceMode() {
const { refresh: refreshRowboatAccount } = useRowboatAccount();
const [state, setState] = useState<VoiceState>('idle');
@ -26,32 +31,54 @@ export function useVoiceMode() {
const audioCtxRef = useRef<AudioContext | null>(null);
const transcriptBufferRef = useRef('');
const interimRef = useRef('');
// Buffer audio chunks captured before the WebSocket is ready
const audioBufferRef = useRef<ArrayBuffer[]>([]);
// Connect (or reconnect) the Deepgram WebSocket.
// Refreshes Rowboat account before connect so access token is current.
const connectWs = useCallback(async () => {
if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) return;
let ws: WebSocket;
// Refresh cached auth details (called on warmup, not on mic click)
const refreshAuth = useCallback(async () => {
const account = await refreshRowboatAccount();
if (
account?.signedIn &&
account.accessToken &&
account.config?.websocketApiUrl
) {
const listenUrl = buildDeepgramListenUrl(account.config.websocketApiUrl, DEEPGRAM_PARAMS);
ws = new WebSocket(listenUrl, ['bearer', account.accessToken]);
cachedAuth = { type: 'rowboat', url: account.config.websocketApiUrl, token: account.accessToken };
} else {
// Fall back to local API key (passed as subprotocol)
const config = await window.ipc.invoke('voice:getConfig', null);
if (!config?.deepgram) return;
ws = new WebSocket(DEEPGRAM_LISTEN_URL, ['token', config.deepgram.apiKey]);
if (config?.deepgram) {
cachedAuth = { type: 'local', apiKey: config.deepgram.apiKey };
}
}
}, [refreshRowboatAccount]);
// Create and connect a Deepgram WebSocket using cached auth.
// Starts the connection and returns immediately (does not wait for open).
const connectWs = useCallback(async () => {
if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) return;
// Refresh auth if we don't have it cached yet
if (!cachedAuth) {
await refreshAuth();
}
if (!cachedAuth) return;
let ws: WebSocket;
if (cachedAuth.type === 'rowboat') {
const listenUrl = buildDeepgramListenUrl(cachedAuth.url, DEEPGRAM_PARAMS);
ws = new WebSocket(listenUrl, ['bearer', cachedAuth.token]);
} else {
ws = new WebSocket(DEEPGRAM_LISTEN_URL, ['token', cachedAuth.apiKey]);
}
wsRef.current = ws;
ws.onopen = () => {
console.log('[voice] WebSocket connected');
// Flush any buffered audio captured while we were connecting
const buffered = audioBufferRef.current;
audioBufferRef.current = [];
for (const chunk of buffered) {
ws.send(chunk);
}
};
ws.onmessage = (event) => {
@ -73,13 +100,15 @@ export function useVoiceMode() {
ws.onerror = () => {
console.error('[voice] WebSocket error');
// Auth may be stale — clear cache so next attempt refreshes
cachedAuth = null;
};
ws.onclose = () => {
console.log('[voice] WebSocket closed');
wsRef.current = null;
};
}, [refreshRowboatAccount]);
}, [refreshAuth]);
// Stop audio capture and close WS
const stopAudioCapture = useCallback(() => {
@ -100,6 +129,7 @@ export function useVoiceMode() {
wsRef.current.close();
wsRef.current = null;
}
audioBufferRef.current = [];
setInterimText('');
transcriptBufferRef.current = '';
interimRef.current = '';
@ -112,60 +142,48 @@ export function useVoiceMode() {
transcriptBufferRef.current = '';
interimRef.current = '';
setInterimText('');
audioBufferRef.current = [];
// If WS isn't connected, connect and wait for it
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
setState('connecting');
connectWs();
// Wait for WS to be ready (up to 5 seconds)
const wsOk = await new Promise<boolean>((resolve) => {
const checkInterval = setInterval(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
clearInterval(checkInterval);
resolve(true);
}
}, 50);
setTimeout(() => {
clearInterval(checkInterval);
resolve(false);
}, 5000);
});
if (!wsOk) {
setState('idle');
return;
}
}
// Show listening immediately — don't wait for WebSocket
setState('listening');
// Start mic
let stream: MediaStream | null = null;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (err) {
console.error('Microphone access denied:', err);
// Kick off mic + WebSocket in parallel, don't await WebSocket
const [stream] = await Promise.all([
navigator.mediaDevices.getUserMedia({ audio: true }).catch((err) => {
console.error('Microphone access denied:', err);
return null;
}),
connectWs(),
]);
if (!stream) {
setState('idle');
return;
}
mediaStreamRef.current = stream;
// Start audio capture
// Start audio capture immediately — buffer if WS isn't open yet
const audioCtx = new AudioContext({ sampleRate: 16000 });
audioCtxRef.current = audioCtx;
const source = audioCtx.createMediaStreamSource(stream);
const processor = audioCtx.createScriptProcessor(4096, 1, 1);
const processor = audioCtx.createScriptProcessor(2048, 1, 1);
processorRef.current = processor;
processor.onaudioprocess = (e) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
const float32 = e.inputBuffer.getChannelData(0);
const int16 = new Int16Array(float32.length);
for (let i = 0; i < float32.length; i++) {
const s = Math.max(-1, Math.min(1, float32[i]));
int16[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
}
wsRef.current.send(int16.buffer);
const buffer = int16.buffer;
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(buffer);
} else {
// WebSocket still connecting — buffer the audio
audioBufferRef.current.push(buffer);
}
};
source.connect(processor);
@ -188,5 +206,10 @@ export function useVoiceMode() {
stopAudioCapture();
}, [stopAudioCapture]);
return { state, interimText, start, submit, cancel };
/** Pre-cache auth details so mic click skips IPC round-trips */
const warmup = useCallback(() => {
refreshAuth().catch(() => {});
}, [refreshAuth]);
return { state, interimText, start, submit, cancel, warmup };
}

View file

@ -115,35 +115,27 @@ export type WebSearchCardData = {
export const getWebSearchCardData = (tool: ToolCall): WebSearchCardData | null => {
if (tool.name === 'web-search') {
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
const result = tool.result as Record<string, unknown> | undefined
return {
query: (input?.query as string) || '',
results: (result?.results as WebSearchCardResult[]) || [],
}
}
if (tool.name === 'research-search') {
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
const result = tool.result as Record<string, unknown> | undefined
const rawResults = (result?.results as Array<{
title: string
url: string
description?: string
highlights?: string[]
text?: string
}>) || []
const mapped = rawResults.map((entry) => ({
title: entry.title,
url: entry.url,
description: entry.highlights?.[0] || (entry.text ? entry.text.slice(0, 200) : ''),
description: entry.description || entry.highlights?.[0] || (entry.text ? entry.text.slice(0, 200) : ''),
}))
const category = input?.category as string | undefined
return {
query: (input?.query as string) || '',
results: mapped,
title: category
? `${category.charAt(0).toUpperCase() + category.slice(1)} search`
: 'Researched the web',
title: (!category || category === 'general')
? 'Web search'
: `${category.charAt(0).toUpperCase() + category.slice(1)} search`,
}
}

View file

@ -618,7 +618,8 @@
.tiptap-editor .ProseMirror .chart-block-wrapper,
.tiptap-editor .ProseMirror .table-block-wrapper,
.tiptap-editor .ProseMirror .calendar-block-wrapper,
.tiptap-editor .ProseMirror .email-block-wrapper {
.tiptap-editor .ProseMirror .email-block-wrapper,
.tiptap-editor .ProseMirror .transcript-block-wrapper {
margin: 8px 0;
}
@ -628,7 +629,8 @@
.tiptap-editor .ProseMirror .table-block-card,
.tiptap-editor .ProseMirror .calendar-block-card,
.tiptap-editor .ProseMirror .email-block-card,
.tiptap-editor .ProseMirror .email-draft-block-card {
.tiptap-editor .ProseMirror .email-draft-block-card,
.tiptap-editor .ProseMirror .transcript-block-card {
position: relative;
padding: 12px 14px;
border: 1px solid var(--border);
@ -644,7 +646,8 @@
.tiptap-editor .ProseMirror .table-block-card:hover,
.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 .email-draft-block-card:hover,
.tiptap-editor .ProseMirror .transcript-block-card:hover {
background-color: color-mix(in srgb, var(--muted) 70%, transparent);
}
@ -856,12 +859,13 @@
font-size: 13px;
}
/* Calendar block */
/* Calendar block Google Calendar style */
.tiptap-editor .ProseMirror .calendar-block-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
color: var(--foreground);
font-weight: 500;
margin-bottom: 4px;
color: color-mix(in srgb, var(--foreground) 70%, transparent);
letter-spacing: 0.01em;
}
.tiptap-editor .ProseMirror .calendar-block-loading,
@ -870,7 +874,7 @@
align-items: center;
justify-content: center;
height: 60px;
font-size: 13px;
font-size: 14px;
color: color-mix(in srgb, var(--foreground) 45%, transparent);
}
@ -894,66 +898,69 @@
.tiptap-editor .ProseMirror .calendar-block-separator {
border: none;
border-top: 1px dashed color-mix(in srgb, var(--foreground) 20%, transparent);
margin: 4px 0;
border-top: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
margin: 0;
}
.tiptap-editor .ProseMirror .calendar-block-date-row {
display: flex;
align-items: flex-start;
gap: 0;
gap: 12px;
padding: 12px 0;
}
.tiptap-editor .ProseMirror .calendar-block-date-left {
display: flex;
align-items: baseline;
gap: 6px;
width: 140px;
flex-shrink: 0;
padding-top: 4px;
}
.tiptap-editor .ProseMirror .calendar-block-day {
font-size: 28px;
font-weight: 300;
line-height: 1;
color: color-mix(in srgb, var(--foreground) 70%, transparent);
}
.tiptap-editor .ProseMirror .calendar-block-month-weekday {
display: flex;
flex-direction: column;
gap: 0;
}
.tiptap-editor .ProseMirror .calendar-block-month {
font-size: 12px;
color: color-mix(in srgb, var(--foreground) 50%, transparent);
line-height: 1.3;
align-items: center;
width: 56px;
flex-shrink: 0;
padding-top: 2px;
}
.tiptap-editor .ProseMirror .calendar-block-weekday {
font-size: 12px;
color: color-mix(in srgb, var(--foreground) 40%, transparent);
line-height: 1.3;
font-size: 11px;
font-weight: 500;
color: color-mix(in srgb, var(--foreground) 45%, transparent);
line-height: 1;
letter-spacing: 0.05em;
margin-bottom: 2px;
}
.tiptap-editor .ProseMirror .calendar-block-day {
font-size: 26px;
font-weight: 400;
line-height: 1;
color: color-mix(in srgb, var(--foreground) 65%, transparent);
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.tiptap-editor .ProseMirror .calendar-block-day-today {
background-color: #1a73e8;
color: #fff !important;
}
.tiptap-editor .ProseMirror .calendar-block-events {
display: flex;
flex-direction: column;
gap: 12px;
gap: 6px;
flex: 1;
min-width: 0;
padding-top: 4px;
}
.tiptap-editor .ProseMirror .calendar-block-event {
display: flex;
align-items: stretch;
gap: 10px;
padding: 4px 8px;
border-radius: 6px;
transition: background-color 0.12s ease;
padding: 8px 12px;
border-radius: 4px;
transition: filter 0.12s ease;
min-height: 0;
}
.tiptap-editor .ProseMirror .calendar-block-event-clickable {
@ -961,14 +968,7 @@
}
.tiptap-editor .ProseMirror .calendar-block-event-clickable:hover {
background-color: color-mix(in srgb, var(--foreground) 5%, transparent);
}
.tiptap-editor .ProseMirror .calendar-block-event-bar {
width: 3px;
border-radius: 2px;
flex-shrink: 0;
min-height: 32px;
filter: brightness(0.9);
}
.tiptap-editor .ProseMirror .calendar-block-event-content {
@ -981,20 +981,22 @@
.tiptap-editor .ProseMirror .calendar-block-event-title {
font-size: 14px;
font-weight: 500;
color: var(--foreground);
color: #fff;
line-height: 1.3;
}
.tiptap-editor .ProseMirror .calendar-block-event-time {
font-size: 12px;
color: color-mix(in srgb, var(--foreground) 45%, transparent);
color: rgba(255, 255, 255, 0.85);
line-height: 1.3;
}
.tiptap-editor .ProseMirror .calendar-block-split-btn {
position: relative;
display: inline-flex;
align-items: stretch;
margin-top: 4px;
border-radius: 5px;
margin-top: 6px;
border-radius: 4px;
overflow: visible;
}
@ -1005,17 +1007,17 @@
padding: 4px 8px 4px 10px;
font-size: 12px;
font-weight: 500;
color: #7ec8c8;
background: color-mix(in srgb, #7ec8c8 12%, transparent);
border: 1px solid color-mix(in srgb, #7ec8c8 25%, transparent);
color: #fff;
background: rgba(255, 255, 255, 0.2);
border: none;
border-right: none;
border-radius: 5px 0 0 5px;
border-radius: 4px 0 0 4px;
cursor: pointer;
transition: background-color 0.12s ease;
}
.tiptap-editor .ProseMirror .calendar-block-split-main:hover {
background: color-mix(in srgb, #7ec8c8 22%, transparent);
background: rgba(255, 255, 255, 0.3);
}
.tiptap-editor .ProseMirror .calendar-block-split-chevron-wrap {
@ -1028,21 +1030,21 @@
align-items: center;
justify-content: center;
padding: 4px 6px;
color: #7ec8c8;
background: color-mix(in srgb, #7ec8c8 12%, transparent);
border: 1px solid color-mix(in srgb, #7ec8c8 25%, transparent);
border-left: 1px solid color-mix(in srgb, #7ec8c8 20%, transparent);
border-radius: 0 5px 5px 0;
color: #fff;
background: rgba(255, 255, 255, 0.2);
border: none;
border-left: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 0 4px 4px 0;
cursor: pointer;
transition: background-color 0.12s ease;
}
.tiptap-editor .ProseMirror .calendar-block-split-chevron:hover {
background: color-mix(in srgb, #7ec8c8 22%, transparent);
background: rgba(255, 255, 255, 0.3);
}
.tiptap-editor .ProseMirror .calendar-block-split-chevron-open {
border-radius: 0 5px 0 0;
border-radius: 0 4px 0 0;
border-bottom-color: transparent;
}
@ -1051,10 +1053,11 @@
top: calc(100% - 1px);
right: 0;
z-index: 50;
background: color-mix(in srgb, #7ec8c8 12%, transparent);
border: 1px solid color-mix(in srgb, #7ec8c8 25%, transparent);
border-top: none;
border-radius: 0 0 5px 5px;
background: #039be5;
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 0 0 4px 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
.tiptap-editor .ProseMirror .calendar-block-split-option {
@ -1065,7 +1068,7 @@
padding: 5px 10px;
font-size: 12px;
font-weight: 500;
color: #7ec8c8;
color: #fff;
background: none;
border: none;
cursor: pointer;
@ -1073,90 +1076,86 @@
}
.tiptap-editor .ProseMirror .calendar-block-split-option:hover {
background: color-mix(in srgb, #7ec8c8 22%, transparent);
background: rgba(255, 255, 255, 0.15);
}
.tiptap-editor .ProseMirror .calendar-block-join-btn {
display: inline-flex;
align-items: center;
gap: 5px;
margin-top: 4px;
margin-top: 6px;
padding: 4px 10px;
font-size: 12px;
font-weight: 500;
color: #7ec8c8;
background: color-mix(in srgb, #7ec8c8 12%, transparent);
border: 1px solid color-mix(in srgb, #7ec8c8 25%, transparent);
border-radius: 5px;
cursor: pointer;
transition: background-color 0.12s ease, border-color 0.12s ease;
width: fit-content;
}
.tiptap-editor .ProseMirror .calendar-block-join-btn:hover {
background: color-mix(in srgb, #7ec8c8 22%, transparent);
border-color: color-mix(in srgb, #7ec8c8 40%, transparent);
}
/* Email block */
.tiptap-editor .ProseMirror .email-block-subject {
font-size: 14px;
font-weight: 600;
color: var(--foreground);
margin-bottom: 8px;
}
.tiptap-editor .ProseMirror .email-block-loading,
.tiptap-editor .ProseMirror .email-block-empty {
display: flex;
align-items: center;
gap: 6px;
height: 50px;
justify-content: center;
font-size: 13px;
color: color-mix(in srgb, var(--foreground) 45%, transparent);
}
.tiptap-editor .ProseMirror .email-block-error,
.tiptap-editor .ProseMirror .email-draft-block-error {
display: flex;
align-items: center;
gap: 6px;
color: color-mix(in srgb, var(--foreground) 55%, transparent);
font-size: 13px;
}
.tiptap-editor .ProseMirror .email-block-error-msg {
font-size: 13px;
color: #ef4444;
padding: 8px 0;
}
.tiptap-editor .ProseMirror .email-block-thread {
display: flex;
flex-direction: column;
gap: 0;
}
.tiptap-editor .ProseMirror .email-block-thread-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
margin-bottom: 6px;
font-size: 12px;
font-weight: 500;
color: color-mix(in srgb, var(--foreground) 50%, transparent);
background: color-mix(in srgb, var(--foreground) 5%, transparent);
border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
border-radius: 12px;
color: #fff;
background: rgba(255, 255, 255, 0.2);
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.12s ease;
width: fit-content;
}
.tiptap-editor .ProseMirror .email-block-thread-toggle:hover {
background: color-mix(in srgb, var(--foreground) 10%, transparent);
.tiptap-editor .ProseMirror .calendar-block-join-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
/* Email block Gmail style */
.tiptap-editor .ProseMirror .email-block-card-gmail {
background-color: var(--background);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1), 0 1px 3px 1px rgba(0, 0, 0, 0.06);
}
.tiptap-editor .ProseMirror .email-block-card-gmail:hover {
background-color: var(--background);
}
/* Email badge */
.tiptap-editor .ProseMirror .email-block-badge {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: color-mix(in srgb, var(--foreground) 45%, transparent);
margin-bottom: 8px;
}
/* Summary */
.tiptap-editor .ProseMirror .email-block-summary {
font-size: 15px;
font-weight: 500;
color: var(--foreground);
line-height: 1.4;
margin-bottom: 10px;
}
/* Expand button */
.tiptap-editor .ProseMirror .email-block-expand-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0;
font-size: 13px;
font-weight: 400;
color: color-mix(in srgb, var(--foreground) 50%, transparent);
background: none;
border: none;
cursor: pointer;
transition: color 0.12s ease;
margin-bottom: 4px;
}
.tiptap-editor .ProseMirror .email-block-expand-btn:hover {
color: var(--foreground);
}
.tiptap-editor .ProseMirror .email-block-expand-meta {
color: color-mix(in srgb, var(--foreground) 35%, transparent);
}
.tiptap-editor .ProseMirror .email-block-toggle-chevron {
@ -1167,111 +1166,156 @@
transform: rotate(180deg);
}
.tiptap-editor .ProseMirror .email-block-message {
padding: 8px 0;
/* Email details (expanded) */
.tiptap-editor .ProseMirror .email-block-email-details {
margin-top: 10px;
padding: 12px;
background: color-mix(in srgb, var(--foreground) 4%, transparent);
border-radius: 6px;
display: flex;
flex-direction: column;
gap: 12px;
}
.tiptap-editor .ProseMirror .email-block-message + .email-block-message {
border-top: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
.tiptap-editor .ProseMirror .email-block-message {
padding: 0;
}
.tiptap-editor .ProseMirror .email-block-message-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.tiptap-editor .ProseMirror .email-block-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: color-mix(in srgb, var(--primary) 20%, transparent);
color: var(--primary);
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
flex-shrink: 0;
align-items: flex-start;
gap: 12px;
margin-bottom: 10px;
}
.tiptap-editor .ProseMirror .email-block-sender-info {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
gap: 2px;
}
.tiptap-editor .ProseMirror .email-block-sender-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.tiptap-editor .ProseMirror .email-block-sender-name {
font-size: 13px;
font-size: 14px;
font-weight: 500;
color: var(--foreground);
}
.tiptap-editor .ProseMirror .email-block-sender-date {
font-size: 11px;
font-size: 12px;
color: color-mix(in srgb, var(--foreground) 50%, transparent);
white-space: nowrap;
flex-shrink: 0;
}
.tiptap-editor .ProseMirror .email-block-subject-line {
font-size: 12px;
color: color-mix(in srgb, var(--foreground) 45%, transparent);
}
.tiptap-editor .ProseMirror .email-block-message-body {
font-size: 13px;
font-size: 14px;
color: color-mix(in srgb, var(--foreground) 80%, transparent);
white-space: pre-wrap;
line-height: 1.5;
padding-left: 36px;
}
.tiptap-editor .ProseMirror .email-block-context {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
display: flex;
flex-direction: column;
gap: 10px;
line-height: 1.58;
}
.tiptap-editor .ProseMirror .email-block-context-section {
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 10px;
border-top: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
}
.tiptap-editor .ProseMirror .email-block-context-label {
font-size: 11px;
font-weight: 600;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
letter-spacing: 0.07em;
color: color-mix(in srgb, var(--foreground) 40%, transparent);
}
.tiptap-editor .ProseMirror .email-block-context-summary {
font-size: 13px;
font-size: 14px;
color: color-mix(in srgb, var(--foreground) 65%, transparent);
line-height: 1.5;
line-height: 1.58;
white-space: pre-wrap;
padding-left: 8px;
border-left: 2px solid color-mix(in srgb, var(--foreground) 10%, transparent);
padding-left: 12px;
border-left: 3px solid color-mix(in srgb, var(--foreground) 12%, transparent);
}
/* Draft section */
.tiptap-editor .ProseMirror .email-block-draft-section {
margin-top: 10px;
padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent);
border-radius: 6px;
}
.tiptap-editor .ProseMirror .email-block-draft-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: color-mix(in srgb, var(--foreground) 40%, transparent);
margin-bottom: 4px;
}
.tiptap-editor .ProseMirror .email-draft-block-body-input {
width: 100%;
font-size: 14px;
color: var(--foreground);
background: none;
border: none;
outline: none;
padding: 4px 0;
font-family: inherit;
line-height: 1.58;
resize: none;
overflow: hidden;
}
.tiptap-editor .ProseMirror .email-draft-block-body-input::placeholder {
color: color-mix(in srgb, var(--foreground) 35%, transparent);
}
/* Action buttons */
.tiptap-editor .ProseMirror .email-block-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
}
.tiptap-editor .ProseMirror .email-block-gmail-btn {
display: inline-flex;
align-items: center;
gap: 5px;
margin-top: 8px;
padding: 5px 12px;
font-size: 12px;
gap: 6px;
padding: 7px 16px;
font-size: 14px;
font-weight: 500;
color: color-mix(in srgb, var(--foreground) 60%, transparent);
background: color-mix(in srgb, var(--foreground) 5%, transparent);
border: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent);
border-radius: 6px;
background: transparent;
border: 1px solid var(--border);
border-radius: 18px;
cursor: pointer;
transition: background-color 0.12s ease, color 0.12s ease;
transition: background-color 0.15s ease, box-shadow 0.15s ease;
width: fit-content;
letter-spacing: 0.01em;
}
.tiptap-editor .ProseMirror .email-block-gmail-btn:hover {
background: color-mix(in srgb, var(--foreground) 10%, transparent);
background: color-mix(in srgb, var(--foreground) 8%, transparent);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1), 0 1px 3px 1px rgba(0, 0, 0, 0.06);
color: var(--foreground);
}
@ -1280,212 +1324,93 @@
cursor: default;
}
.tiptap-editor .ProseMirror .email-block-generate-btn {
color: var(--primary);
border-color: color-mix(in srgb, var(--primary) 25%, transparent);
.tiptap-editor .ProseMirror .email-block-gmail-btn-primary {
color: #fff;
background: #1a73e8;
border-color: #1a73e8;
}
.tiptap-editor .ProseMirror .email-block-generate-btn:hover:not(:disabled) {
background: color-mix(in srgb, var(--primary) 10%, transparent);
color: var(--primary);
.tiptap-editor .ProseMirror .email-block-gmail-btn-primary:hover:not(:disabled) {
background: #1765cc;
box-shadow: 0 1px 2px 0 rgba(26, 115, 232, 0.45), 0 1px 3px 1px rgba(26, 115, 232, 0.3);
color: #fff;
}
@keyframes email-block-spin {
to { transform: rotate(360deg); }
}
.tiptap-editor .ProseMirror .email-block-spinner {
animation: email-block-spin 1s linear infinite;
}
/* Email block split button (generate/assistant) */
.tiptap-editor .ProseMirror .email-block-response-split {
position: relative;
display: inline-flex;
align-items: stretch;
margin-top: 8px;
}
.tiptap-editor .ProseMirror .email-block-response-split > button {
box-sizing: border-box;
}
.tiptap-editor .ProseMirror .email-block-split-main {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 8px 5px 12px;
font-size: 12px;
font-weight: 500;
color: var(--primary);
background: color-mix(in srgb, var(--foreground) 5%, transparent);
border: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent);
border-right: none;
border-radius: 6px 0 0 6px;
cursor: pointer;
transition: background-color 0.12s ease;
}
.tiptap-editor .ProseMirror .email-block-split-main:hover:not(:disabled) {
background: color-mix(in srgb, var(--foreground) 10%, transparent);
}
.tiptap-editor .ProseMirror .email-block-split-main:disabled {
opacity: 0.6;
cursor: default;
}
.tiptap-editor .ProseMirror .email-block-split-chevron {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 5px 6px;
color: color-mix(in srgb, var(--foreground) 60%, transparent);
background: color-mix(in srgb, var(--foreground) 5%, transparent);
border: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent);
border-left: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
border-radius: 0 6px 6px 0;
cursor: pointer;
transition: background-color 0.12s ease;
}
.tiptap-editor .ProseMirror .email-block-split-chevron:hover {
background: color-mix(in srgb, var(--foreground) 10%, transparent);
}
.tiptap-editor .ProseMirror .email-block-split-chevron-open {
border-radius: 0 6px 0 0;
border-bottom-color: transparent;
}
.tiptap-editor .ProseMirror .email-block-split-dropdown {
position: absolute;
top: calc(100% - 1px);
right: 0;
z-index: 50;
background: color-mix(in srgb, var(--foreground) 5%, transparent);
border: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent);
border-top: none;
border-radius: 0 0 6px 6px;
}
.tiptap-editor .ProseMirror .email-block-split-option {
display: flex;
align-items: center;
gap: 5px;
white-space: nowrap;
padding: 5px 12px;
font-size: 12px;
font-weight: 500;
color: color-mix(in srgb, var(--foreground) 60%, transparent);
background: none;
border: none;
border-radius: 0 0 6px 6px;
cursor: pointer;
transition: background-color 0.12s ease;
}
.tiptap-editor .ProseMirror .email-block-split-option:hover {
background: color-mix(in srgb, var(--foreground) 10%, transparent);
color: var(--foreground);
}
/* Email draft block */
.tiptap-editor .ProseMirror .email-draft-block-header {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
}
.tiptap-editor .ProseMirror .email-draft-block-field {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 13px;
}
.tiptap-editor .ProseMirror .email-draft-block-label {
font-weight: 500;
color: color-mix(in srgb, var(--foreground) 45%, transparent);
min-width: 50px;
}
.tiptap-editor .ProseMirror .email-draft-block-value {
color: var(--foreground);
}
.tiptap-editor .ProseMirror .email-draft-block-input {
flex: 1;
font-size: 13px;
color: var(--foreground);
background: none;
border: none;
outline: none;
padding: 2px 0;
font-family: inherit;
}
.tiptap-editor .ProseMirror .email-draft-block-input::placeholder {
color: color-mix(in srgb, var(--foreground) 30%, transparent);
}
.tiptap-editor .ProseMirror .email-draft-block-body-input {
width: 100%;
font-size: 13px;
color: var(--foreground);
background: none;
border: none;
outline: none;
padding: 4px 0;
font-family: inherit;
line-height: 1.6;
resize: none;
overflow: hidden;
}
.tiptap-editor .ProseMirror .email-draft-block-body-input::placeholder {
color: color-mix(in srgb, var(--foreground) 30%, transparent);
}
.tiptap-editor .ProseMirror .email-draft-block-actions {
.tiptap-editor .ProseMirror .email-block-error,
.tiptap-editor .ProseMirror .email-draft-block-error {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
color: color-mix(in srgb, var(--foreground) 55%, transparent);
font-size: 14px;
}
.tiptap-editor .ProseMirror .email-draft-block-reply {
margin-top: 6px;
}
.tiptap-editor .ProseMirror .email-draft-block-reply-toggle {
display: inline-flex;
/* Transcript block */
.tiptap-editor .ProseMirror .transcript-block-toggle {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
margin-bottom: 6px;
font-size: 12px;
gap: 6px;
width: 100%;
padding: 0;
font-size: 13px;
font-weight: 500;
color: color-mix(in srgb, var(--foreground) 50%, transparent);
background: color-mix(in srgb, var(--foreground) 5%, transparent);
border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
border-radius: 12px;
color: color-mix(in srgb, var(--foreground) 60%, transparent);
background: none;
border: none;
cursor: pointer;
transition: background-color 0.12s ease;
width: fit-content;
transition: color 0.12s ease;
}
.tiptap-editor .ProseMirror .email-draft-block-reply-toggle:hover {
background: color-mix(in srgb, var(--foreground) 10%, transparent);
.tiptap-editor .ProseMirror .transcript-block-toggle:hover {
color: var(--foreground);
}
.tiptap-editor .ProseMirror .email-draft-block-reply-thread {
padding: 4px 0 0 8px;
border-left: 2px solid color-mix(in srgb, var(--foreground) 10%, transparent);
margin-left: 4px;
.tiptap-editor .ProseMirror .transcript-block-chevron {
transition: transform 0.15s ease;
flex-shrink: 0;
}
.tiptap-editor .ProseMirror .transcript-block-chevron-open {
transform: rotate(180deg);
}
.tiptap-editor .ProseMirror .transcript-block-content {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
display: flex;
flex-direction: column;
gap: 6px;
}
.tiptap-editor .ProseMirror .transcript-entry {
font-size: 13px;
line-height: 1.5;
}
.tiptap-editor .ProseMirror .transcript-speaker {
font-weight: 600;
margin-right: 6px;
}
.tiptap-editor .ProseMirror .transcript-text {
color: color-mix(in srgb, var(--foreground) 75%, transparent);
}
.tiptap-editor .ProseMirror .transcript-raw {
font-size: 13px;
line-height: 1.6;
color: color-mix(in srgb, var(--foreground) 70%, transparent);
white-space: pre-wrap;
word-break: break-word;
}
.tiptap-editor .ProseMirror .transcript-block-error {
display: flex;
align-items: center;
gap: 6px;
color: color-mix(in srgb, var(--foreground) 55%, transparent);
font-size: 13px;
}
/* Meeting event banner */

View file

@ -24,7 +24,9 @@
"ai": "^5.0.133",
"awilix": "^12.0.5",
"chokidar": "^4.0.3",
"cors": "^2.8.6",
"cron-parser": "^5.5.0",
"express": "^5.2.1",
"glob": "^13.0.0",
"google-auth-library": "^10.5.0",
"isomorphic-git": "^1.29.0",
@ -41,6 +43,8 @@
"zod": "^4.2.1"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/node": "^25.0.3",
"@types/papaparse": "^5.5.2",
"@types/pdf-parse": "^1.1.5"

View file

@ -868,7 +868,7 @@ export async function* streamAgent({
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-mini" : defaultModel;
const defaultInlineTaskModel = signedIn ? "gpt-5.4" : defaultModel;
const modelId = isInlineTaskAgent
? defaultInlineTaskModel
: (isKgAgent && modelConfig.knowledgeGraphModel)
@ -878,6 +878,9 @@ export async function* streamAgent({
logger.log(`using model: ${modelId}`);
let loopCounter = 0;
let voiceInput = false;
let voiceOutput: 'summary' | 'full' | null = null;
let searchEnabled = false;
while (true) {
// Check abort at the top of each iteration
signal.throwIfAborted();
@ -991,9 +994,6 @@ export async function* streamAgent({
}
// get any queued user messages
let voiceInput = false;
let voiceOutput: 'summary' | 'full' | null = null;
let searchEnabled = false;
while (true) {
const msg = await messageQueue.dequeue(runId);
if (!msg) {
@ -1061,14 +1061,14 @@ export async function* streamAgent({
}
if (voiceOutput === 'summary') {
loopLogger.log('voice output enabled (summary mode), injecting voice output prompt');
instructionsWithDateTime += `\n\n# Voice Output (MANDATORY)\nThe user has voice output enabled. You MUST start your response with <voice></voice> tags that provide a spoken summary and guide to your written response. This is NOT optional — every response MUST begin with <voice> tags.\n\nRules:\n1. ALWAYS start your response with one or more <voice> tags. Never skip them.\n2. Place ALL <voice> tags at the BEGINNING of your response, before any detailed content. Do NOT intersperse <voice> tags throughout the response.\n3. Wrap EACH spoken sentence in its own separate <voice> tag so it can be spoken incrementally. Do NOT wrap everything in a single <voice> block.\n4. Use voice as a TL;DR and navigation aid — do NOT read the entire response aloud.\n\nExample — if the user asks "what happened in my meeting with Sarah yesterday?":\n<voice>Your meeting with Sarah covered three main things: the Q2 roadmap timeline, hiring for the backend role, and the client demo next week.</voice>\n<voice>I've pulled out the key details and action items below — the demo prep notes are at the end.</voice>\n\n## Meeting with Sarah — March 11\n(Then the full detailed written response follows without any more <voice> tags.)\n\nAny text outside <voice> tags is shown visually but not spoken.`;
instructionsWithDateTime += `\n\n# Voice Output (MANDATORY — READ THIS FIRST)\nThe user has voice output enabled. THIS IS YOUR #1 PRIORITY: you MUST start your response with <voice></voice> tags. If your response does not begin with <voice> tags, the user will hear nothing — which is a broken experience. NEVER skip this.\n\nRules:\n1. YOUR VERY FIRST OUTPUT MUST BE A <voice> TAG. No exceptions. Do not start with markdown, headings, or any other text. The literal first characters of your response must be "<voice>".\n2. Place ALL <voice> tags at the BEGINNING of your response, before any detailed content. Do NOT intersperse <voice> tags throughout the response.\n3. Wrap EACH spoken sentence in its own separate <voice> tag so it can be spoken incrementally. Do NOT wrap everything in a single <voice> block.\n4. Use voice as a TL;DR and navigation aid — do NOT read the entire response aloud.\n5. After all <voice> tags, you may include detailed written content (markdown, tables, code, etc.) that will be shown visually but not spoken.\n\n## Examples\n\nExample 1 — User asks: "what happened in my meeting with Alex yesterday?"\n\n<voice>Your meeting with Alex covered three main things: the Q2 roadmap timeline, hiring for the backend role, and the client demo next week.</voice>\n<voice>I've pulled out the key details and action items below — the demo prep notes are at the end.</voice>\n\n## Meeting with Alex — March 11\n### Roadmap\n- Agreed to push Q2 launch to April 15...\n(detailed written content continues)\n\nExample 2 — User asks: "summarize my emails"\n\n<voice>You have five new emails since this morning.</voice>\n<voice>Two are from your team — Jordan sent the RFC you requested and Taylor flagged a contract issue.</voice>\n<voice>There's also a warm intro from a VC partner connecting you with someone at a prospective customer.</voice>\n<voice>I've drafted responses for three of them. The details and drafts are below.</voice>\n\n(email blocks, tables, and detailed content follow)\n\nExample 3 — User asks: "what's on my calendar today?"\n\n<voice>You've got a pretty packed day — seven meetings starting with standup at 9.</voice>\n<voice>The big ones are your investor call at 11, lunch with a partner from your lead VC at 12:30, and a customer call at 4.</voice>\n<voice>Your only free block for deep work is 2:30 to 4.</voice>\n\n(calendar block with full event details follows)\n\nExample 4 — User asks: "draft an email to Sam with our metrics"\n\n<voice>Done — I've drafted the email to Sam with your latest WAU and churn numbers.</voice>\n<voice>Take a look at the draft below and send it when you're ready.</voice>\n\n(email block with draft follows)\n\nREMEMBER: If you do not start with <voice> tags, the user hears silence. Always speak first, then write.`;
} else if (voiceOutput === 'full') {
loopLogger.log('voice output enabled (full mode), injecting voice output prompt');
instructionsWithDateTime += `\n\n# Voice Output — Full Read-Aloud (MANDATORY)\nThe user wants your ENTIRE response spoken aloud. You MUST wrap your full response in <voice></voice> tags. This is NOT optional.\n\nRules:\n1. Wrap EACH sentence in its own separate <voice> tag so it can be spoken incrementally.\n2. Write your response in a natural, conversational style suitable for listening — no markdown headings, bullet points, or formatting symbols. Use plain spoken language.\n3. Structure the content as if you are speaking to the user directly. Use transitions like "first", "also", "one more thing" instead of visual formatting.\n4. Every sentence MUST be inside a <voice> tag. Do not leave any content outside <voice> tags.\n\nExample:\n<voice>Your meeting with Sarah covered three main things.</voice>\n<voice>First, you discussed the Q2 roadmap timeline and agreed to push the launch to April.</voice>\n<voice>Second, you talked about hiring for the backend role — Sarah will send over two candidates by Friday.</voice>\n<voice>And lastly, the client demo is next week on Thursday at 2pm, and you're handling the intro slides.</voice>`;
instructionsWithDateTime += `\n\n# Voice Output — Full Read-Aloud (MANDATORY — READ THIS FIRST)\nThe user wants your ENTIRE response spoken aloud. THIS IS YOUR #1 PRIORITY: every single sentence must be wrapped in <voice></voice> tags. If you write anything outside <voice> tags, the user will not hear it — which is a broken experience. NEVER skip this.\n\nRules:\n1. YOUR VERY FIRST OUTPUT MUST BE A <voice> TAG. No exceptions. The literal first characters of your response must be "<voice>".\n2. Wrap EACH sentence in its own separate <voice> tag so it can be spoken incrementally.\n3. Write your response in a natural, conversational style suitable for listening — no markdown headings, bullet points, or formatting symbols. Use plain spoken language.\n4. Structure the content as if you are speaking to the user directly. Use transitions like "first", "also", "one more thing" instead of visual formatting.\n5. EVERY sentence MUST be inside a <voice> tag. Do not leave ANY content outside <voice> tags. If it's not in a <voice> tag, the user cannot hear it.\n\n## Examples\n\nExample 1 — User asks: "what happened in my meeting with Alex yesterday?"\n\n<voice>Your meeting with Alex covered three main things.</voice>\n<voice>First, you discussed the Q2 roadmap timeline and agreed to push the launch to April.</voice>\n<voice>Second, you talked about hiring for the backend role — Alex will send over two candidates by Friday.</voice>\n<voice>And lastly, the client demo is next week on Thursday at 2pm, and you're handling the intro slides.</voice>\n\nExample 2 — User asks: "summarize my emails"\n\n<voice>You've got five new emails since this morning.</voice>\n<voice>Two are from your team — Jordan sent the RFC you asked for, and Taylor flagged a contract issue that needs your sign-off.</voice>\n<voice>There's a warm intro from a VC partner connecting you with an engineering lead at a potential customer.</voice>\n<voice>And someone from a prospective client wants to confirm your API tier before your call this afternoon.</voice>\n<voice>I've drafted replies for three of them — the metrics update, the intro, and the API question.</voice>\n<voice>The only one I left for you is Taylor's contract redline, since that needs your judgment on the liability cap.</voice>\n\nExample 3 — User asks: "what's on my calendar today?"\n\n<voice>You've got a packed day — seven meetings starting with standup at 9.</voice>\n<voice>The highlights are your investor call at 11, lunch with a VC partner at 12:30, and a customer call at 4.</voice>\n<voice>Your only open block for deep work is 2:30 to 4, so plan accordingly.</voice>\n<voice>Oh, and your 1-on-1 with your co-founder is at 5:30 — that's a walking meeting.</voice>\n\nExample 4 — User asks: "how are our metrics looking?"\n\n<voice>Metrics are looking strong this week.</voice>\n<voice>You hit 2,573 weekly active users, which is up 12% week over week.</voice>\n<voice>That means you've crossed the 2,500 milestone — worth calling out in your next investor update.</voice>\n<voice>Churn is down to 4.1%, improving month over month.</voice>\n<voice>The trailing 8-week compound growth rate is about 10%.</voice>\n\nREMEMBER: Start with <voice> immediately. No preamble, no markdown before it. Speak first.`;
}
if (searchEnabled) {
loopLogger.log('search enabled, injecting search prompt');
instructionsWithDateTime += `\n\n# Search\nThe user has requested a search. Load the search skill and use web search or research search as needed to answer their query.`;
instructionsWithDateTime += `\n\n# Search\nThe user has requested a search. Use the web-search tool to answer their query.`;
}
let streamError: string | null = null;
for await (const event of streamLlm(

View file

@ -255,7 +255,7 @@ ${runtimeContextPrompt}
- \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\` - MCP server management and execution
- \`loadSkill\` - Skill loading
- \`slack-checkConnection\`, \`slack-listAvailableTools\`, \`slack-executeAction\` - Slack integration (requires Slack to be connected via Composio). Use \`slack-listAvailableTools\` first to discover available tool slugs, then \`slack-executeAction\` to execute them.
- \`web-search\` and \`research-search\` - Web and research search tools (available when configured). **You MUST load the \`web-search\` skill before using either of these tools.** It tells you which tool to pick and how many searches to do.
- \`web-search\` - Search the web. Returns rich results with full text, highlights, and metadata. The \`category\` parameter defaults to \`general\` (full web search) — only use a specific category like \`news\`, \`company\`, \`research paper\` etc. when the query is clearly about that type. For everyday queries (weather, restaurants, prices, how-to), use \`general\`.
- \`app-navigation\` - Control the app UI: open notes, switch views, filter/search the knowledge base, manage saved views. **Load the \`app-navigation\` skill before using this tool.**
- \`save-to-memory\` - Save observations about the user to the agent memory system. Use this proactively during conversations.
- **Composio tools** (\`composio-*\`) — External service integrations enabled by the user in Settings > Tools Library. These connect to third-party apps like Gmail, GitHub, Linear, Notion, etc. See the "Composio Integration Tools" section below for available tools.

View file

@ -10,7 +10,7 @@ import organizeFilesSkill from "./organize-files/skill.js";
import slackSkill from "./slack/skill.js";
import backgroundAgentsSkill from "./background-agents/skill.js";
import createPresentationsSkill from "./create-presentations/skill.js";
import webSearchSkill from "./web-search/skill.js";
import appNavigationSkill from "./app-navigation/skill.js";
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
@ -84,12 +84,6 @@ const definitions: SkillDefinition[] = [
summary: "Discovering, executing, and integrating MCP tools. Use this to check what external capabilities are available and execute MCP tools on behalf of users.",
content: mcpIntegrationSkill,
},
{
id: "web-search",
title: "Web Search",
summary: "Searching the web or researching a topic. Guidance on when to use web-search vs research-search, and how many searches to do.",
content: webSearchSkill,
},
{
id: "deletion-guardrails",
title: "Deletion Guardrails",

View file

@ -1029,123 +1029,15 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
// ============================================================================
// Web Search (Brave Search API)
// Web Search (Exa Search API)
// ============================================================================
'web-search': {
description: 'Search the web using Brave Search. Returns web results with titles, URLs, and descriptions.',
inputSchema: z.object({
query: z.string().describe('The search query'),
count: z.number().optional().describe('Number of results to return (default: 5, max: 20)'),
freshness: z.string().optional().describe('Filter by freshness: pd (past day), pw (past week), pm (past month), py (past year)'),
}),
isAvailable: async () => {
if (await isSignedIn()) return true;
try {
const braveConfigPath = path.join(WorkDir, 'config', 'brave-search.json');
const raw = await fs.readFile(braveConfigPath, 'utf8');
const config = JSON.parse(raw);
return !!config.apiKey;
} catch {
return false;
}
},
execute: async ({ query, count, freshness }: { query: string; count?: number; freshness?: string }) => {
try {
const resultCount = Math.min(Math.max(count || 5, 1), 20);
const params = new URLSearchParams({
q: query,
count: String(resultCount),
});
if (freshness) {
params.set('freshness', freshness);
}
let response: Response;
if (await isSignedIn()) {
// Use proxy
const accessToken = await getAccessToken();
response = await fetch(`${API_URL}/v1/search/brave?${params.toString()}`, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/json',
},
});
} else {
// Read API key from config
const braveConfigPath = path.join(WorkDir, 'config', 'brave-search.json');
let apiKey: string;
try {
const raw = await fs.readFile(braveConfigPath, 'utf8');
const config = JSON.parse(raw);
apiKey = config.apiKey;
} catch {
return {
success: false,
error: 'Brave Search API key not configured. Create ~/.rowboat/config/brave-search.json with { "apiKey": "<your-key>" }',
};
}
if (!apiKey) {
return {
success: false,
error: 'Brave Search API key is empty. Set "apiKey" in ~/.rowboat/config/brave-search.json',
};
}
response = await fetch(`https://api.search.brave.com/res/v1/web/search?${params.toString()}`, {
headers: {
'X-Subscription-Token': apiKey,
'Accept': 'application/json',
},
});
}
if (!response.ok) {
const body = await response.text();
return {
success: false,
error: `Brave Search API error (${response.status}): ${body}`,
};
}
const data = await response.json() as {
web?: { results?: Array<{ title?: string; url?: string; description?: string }> };
};
const results = (data.web?.results || []).map((r: { title?: string; url?: string; description?: string }) => ({
title: r.title || '',
url: r.url || '',
description: r.description || '',
}));
return {
success: true,
query,
results,
count: results.length,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
},
},
// ============================================================================
// Research Search (Exa Search API)
// ============================================================================
'research-search': {
description: 'Use this for finding articles, blog posts, papers, companies, people, or exploring a topic in depth. Best for discovery and research where you need quality sources, not a quick fact.',
description: 'Search the web for articles, blog posts, papers, companies, people, news, or explore a topic in depth. Returns rich results with full text, highlights, and metadata.',
inputSchema: z.object({
query: z.string().describe('The search query'),
numResults: z.number().optional().describe('Number of results to return (default: 5, max: 20)'),
category: z.enum(['company', 'research paper', 'news', 'tweet', 'personal site', 'financial report', 'people']).optional().describe('Filter results by category'),
category: z.enum(['general', 'company', 'research paper', 'news', 'tweet', 'personal site', 'financial report', 'people']).optional().describe('Search category. Defaults to "general" which searches the entire web. Only use a specific category when the query is clearly about that type (e.g. "research paper" for academic papers, "company" for company info). For everyday queries like weather, restaurants, prices, how-to, etc., use "general" or omit entirely.'),
}),
isAvailable: async () => {
if (await isSignedIn()) return true;
@ -1171,7 +1063,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
highlights: true,
},
};
if (category) {
if (category && category !== 'general') {
reqBody.category = category;
}

View file

@ -0,0 +1,96 @@
# Page Capture Chrome Extension
A Chrome extension that captures web pages you visit and sends them to a local server for storage as markdown files.
## Structure
```
/extension
manifest.json # Chrome extension manifest (v3)
background.js # Service worker that captures pages
/server
server.py # Flask server for storing captures
captured_pages/ # Directory where pages are saved
```
## Setup
### 1. Install Server Dependencies
```bash
cd server
pip install flask flask-cors
```
### 2. Start the Server
```bash
cd server
python server.py
```
The server will run at `http://localhost:3001`.
### 3. Install the Chrome Extension
1. Open Chrome and navigate to `chrome://extensions/`
2. Enable "Developer mode" (toggle in top right)
3. Click "Load unpacked"
4. Select the `extension` folder
## Usage
Once both the server is running and the extension is installed, the extension will automatically capture pages as you browse:
- Every page load (http/https URLs only) triggers a capture
- Content is hashed with SHA-256 to avoid duplicate captures
- Pages are saved as markdown files with frontmatter metadata
## API Endpoints
### POST /capture
Receives captured page data.
**Request body:**
```json
{
"url": "https://example.com",
"content": "Page text content...",
"timestamp": 1706123456789,
"title": "Page Title"
}
```
**Response:**
```json
{"status": "captured", "filename": "1706123456789_example_com.md"}
```
### GET /status
Returns the count of captured pages.
**Response:**
```json
{"count": 42}
```
## File Format
Captured pages are saved as markdown with YAML frontmatter:
```markdown
---
url: https://example.com/page
title: Page Title
captured_at: 2024-01-24T12:34:56
---
Page content here...
```
## Debugging
- **Extension logs**: Open `chrome://extensions/`, find "Page Capture", click "Service worker" to view console logs
- **Server logs**: Check the terminal where `server.py` is running

View file

@ -0,0 +1,388 @@
const SERVER_URL = 'http://localhost:3001';
const contentHashMap = new Map();
let cachedConfig = null;
let serverReachable = true;
// Default config
const DEFAULT_CONFIG = {
mode: 'ask',
whitelist: [],
blacklist: [],
enabled: true
};
// Config management
async function loadConfig() {
try {
const response = await fetch(`${SERVER_URL}/browse/config`);
if (response.ok) {
cachedConfig = await response.json();
serverReachable = true;
} else {
throw new Error('Server returned error');
}
} catch (error) {
console.log(`[Page Capture] Failed to load config: ${error.message}`);
serverReachable = false;
cachedConfig = cachedConfig || DEFAULT_CONFIG;
}
return cachedConfig;
}
async function saveConfig(config) {
try {
const response = await fetch(`${SERVER_URL}/browse/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
if (response.ok) {
cachedConfig = config;
serverReachable = true;
return true;
}
} catch (error) {
console.log(`[Page Capture] Failed to save config: ${error.message}`);
serverReachable = false;
}
return false;
}
function getConfig() {
return cachedConfig || DEFAULT_CONFIG;
}
function extractDomain(url) {
try {
const parsed = new URL(url);
return parsed.hostname;
} catch {
return null;
}
}
function isWhitelisted(domain) {
const config = getConfig();
return config.whitelist.some(d => domain === d || domain.endsWith('.' + d));
}
function isBlacklisted(domain) {
const config = getConfig();
return config.blacklist.some(d => domain === d || domain.endsWith('.' + d));
}
function getDomainStatus(domain) {
const config = getConfig();
if (isBlacklisted(domain)) return 'blacklisted';
if (config.mode === 'all') return 'capturing';
if (isWhitelisted(domain)) return 'whitelisted';
return 'unknown';
}
function shouldCapture(domain) {
const config = getConfig();
if (!config.enabled) return false;
if (isBlacklisted(domain)) return false;
if (config.mode === 'all') return true;
return isWhitelisted(domain);
}
// Badge management
async function setBadge(tabId, type) {
try {
if (type === 'needs-approval') {
await chrome.action.setBadgeText({ tabId, text: '?' });
await chrome.action.setBadgeBackgroundColor({ tabId, color: '#F59E0B' });
} else if (type === 'server-error') {
await chrome.action.setBadgeText({ tabId, text: '!' });
await chrome.action.setBadgeBackgroundColor({ tabId, color: '#EF4444' });
} else {
await chrome.action.setBadgeText({ tabId, text: '' });
}
} catch (error) {
console.log(`[Page Capture] Failed to set badge: ${error.message}`);
}
}
async function updateBadgeForTab(tabId, url) {
if (!serverReachable) {
await setBadge(tabId, 'server-error');
return;
}
const domain = extractDomain(url);
if (!domain) {
await setBadge(tabId, 'clear');
return;
}
const status = getDomainStatus(domain);
if (status === 'unknown') {
await setBadge(tabId, 'needs-approval');
} else {
await setBadge(tabId, 'clear');
}
}
// Content hashing
async function hashContent(content) {
const encoder = new TextEncoder();
const data = encoder.encode(content);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
function isValidUrl(url) {
if (!url) return false;
try {
const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
async function capturePageContent(tabId) {
try {
const results = await chrome.scripting.executeScript({
target: { tabId },
func: () => document.body.innerText
});
return results[0]?.result || '';
} catch (error) {
console.log(`[Page Capture] Failed to capture content: ${error.message}`);
return null;
}
}
async function sendToServer(data) {
try {
const response = await fetch(`${SERVER_URL}/capture`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
serverReachable = response.ok;
return response.ok;
} catch (error) {
console.log(`[Page Capture] Failed to send to server: ${error.message}`);
serverReachable = false;
return false;
}
}
async function captureTab(tabId, tab) {
const content = await capturePageContent(tabId);
if (content === null) return false;
const hash = await hashContent(content);
const lastHash = contentHashMap.get(tab.url);
if (lastHash === hash) {
console.log(`[Page Capture] Content unchanged for: ${tab.url}`);
return true;
}
contentHashMap.set(tab.url, hash);
const payload = {
url: tab.url,
content,
timestamp: Date.now(),
title: tab.title || 'Untitled'
};
const success = await sendToServer(payload);
if (success) {
console.log(`[Page Capture] Captured: ${tab.url}`);
}
return success;
}
// Tab update listener
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
if (changeInfo.status !== 'complete') return;
if (!isValidUrl(tab.url)) {
console.log(`[Page Capture] Skipping non-http URL: ${tab.url}`);
return;
}
const domain = extractDomain(tab.url);
if (!domain) return;
await updateBadgeForTab(tabId, tab.url);
if (!shouldCapture(domain)) {
console.log(`[Page Capture] Skipping (not whitelisted): ${tab.url}`);
return;
}
await captureTab(tabId, tab);
});
// Tab activated listener - update badge
chrome.tabs.onActivated.addListener(async (activeInfo) => {
try {
const tab = await chrome.tabs.get(activeInfo.tabId);
if (tab.url && isValidUrl(tab.url)) {
await updateBadgeForTab(activeInfo.tabId, tab.url);
}
} catch (error) {
console.log(`[Page Capture] Failed to update badge on tab switch: ${error.message}`);
}
});
// Handle scroll capture messages from content script
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'SCROLL_CAPTURE') {
const { url, content, timestamp, title, scrollY } = message;
const domain = extractDomain(url);
if (!shouldCapture(domain)) {
console.log(`[Page Capture] Skipping scroll capture (not whitelisted): ${url}`);
return;
}
console.log(`[Page Capture] Received scroll capture for: ${url}`);
hashContent(content).then(async (hash) => {
const lastHash = contentHashMap.get(url);
if (lastHash === hash) {
console.log(`[Page Capture] Hash unchanged, skipping: ${url}`);
return;
}
contentHashMap.set(url, hash);
const payload = { url, content, timestamp, title };
const success = await sendToServer(payload);
if (success) {
console.log(`[Page Capture] Scroll captured (y=${scrollY}): ${url}`);
}
});
return;
}
// Handle messages from popup
if (message.type === 'GET_CONFIG') {
loadConfig().then(config => {
sendResponse({ config, serverReachable });
});
return true;
}
if (message.type === 'SAVE_CONFIG') {
saveConfig(message.config).then(success => {
sendResponse({ success });
// Update badges on all tabs
chrome.tabs.query({}, tabs => {
tabs.forEach(tab => {
if (tab.url && isValidUrl(tab.url)) {
updateBadgeForTab(tab.id, tab.url);
}
});
});
});
return true;
}
if (message.type === 'GET_DOMAIN_STATUS') {
const domain = extractDomain(message.url);
const status = domain ? getDomainStatus(domain) : 'unknown';
sendResponse({ status, domain, serverReachable });
return true;
}
if (message.type === 'APPROVE_DOMAIN') {
const config = getConfig();
const domain = message.domain;
if (!config.whitelist.includes(domain)) {
config.whitelist.push(domain);
}
config.blacklist = config.blacklist.filter(d => d !== domain);
saveConfig(config).then(success => {
sendResponse({ success });
chrome.tabs.query({}, tabs => {
tabs.forEach(tab => {
if (tab.url && isValidUrl(tab.url)) {
updateBadgeForTab(tab.id, tab.url);
}
});
});
});
return true;
}
if (message.type === 'REJECT_DOMAIN') {
const config = getConfig();
const domain = message.domain;
if (!config.blacklist.includes(domain)) {
config.blacklist.push(domain);
}
config.whitelist = config.whitelist.filter(d => d !== domain);
saveConfig(config).then(success => {
sendResponse({ success });
chrome.tabs.query({}, tabs => {
tabs.forEach(tab => {
if (tab.url && isValidUrl(tab.url)) {
updateBadgeForTab(tab.id, tab.url);
}
});
});
});
return true;
}
if (message.type === 'CAPTURE_ONCE') {
chrome.tabs.query({ active: true, currentWindow: true }, async tabs => {
if (tabs[0]) {
const success = await captureTab(tabs[0].id, tabs[0]);
sendResponse({ success });
} else {
sendResponse({ success: false });
}
});
return true;
}
if (message.type === 'REMOVE_FROM_WHITELIST') {
const config = getConfig();
config.whitelist = config.whitelist.filter(d => d !== message.domain);
saveConfig(config).then(success => {
sendResponse({ success });
chrome.tabs.query({}, tabs => {
tabs.forEach(tab => {
if (tab.url && isValidUrl(tab.url)) {
updateBadgeForTab(tab.id, tab.url);
}
});
});
});
return true;
}
if (message.type === 'REMOVE_FROM_BLACKLIST') {
const config = getConfig();
config.blacklist = config.blacklist.filter(d => d !== message.domain);
saveConfig(config).then(success => {
sendResponse({ success });
chrome.tabs.query({}, tabs => {
tabs.forEach(tab => {
if (tab.url && isValidUrl(tab.url)) {
updateBadgeForTab(tab.id, tab.url);
}
});
});
});
return true;
}
});
// Load config on startup
loadConfig().then(() => {
console.log('[Page Capture] Config loaded');
});
console.log('[Page Capture] Service worker started');

View file

@ -0,0 +1,81 @@
const DEBOUNCE_MS = 800;
const MIN_SCROLL_PIXELS = 500;
const MIN_CONTENT_CHANGE = 100; // characters
let debounceTimer = null;
let lastCapturedContent = null;
let lastScrollTop = 0;
let scrollContainer = null;
function getScrollTop() {
if (!scrollContainer || scrollContainer === window) {
return window.scrollY;
}
if (scrollContainer === document) {
return document.documentElement.scrollTop;
}
return scrollContainer.scrollTop || 0;
}
function captureAndSend() {
const content = document.body.innerText;
// Skip if content unchanged or minimal change
if (lastCapturedContent) {
const lengthDiff = Math.abs(content.length - lastCapturedContent.length);
if (content === lastCapturedContent || lengthDiff < MIN_CONTENT_CHANGE) {
return;
}
}
lastCapturedContent = content;
lastScrollTop = getScrollTop();
chrome.runtime.sendMessage({
type: 'SCROLL_CAPTURE',
url: window.location.href,
title: document.title,
content: content,
timestamp: Date.now(),
scrollY: lastScrollTop
});
}
function onScroll() {
const currentScrollTop = getScrollTop();
const scrollDelta = Math.abs(currentScrollTop - lastScrollTop);
if (scrollDelta < MIN_SCROLL_PIXELS) {
return;
}
if (debounceTimer) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(() => {
captureAndSend();
}, DEBOUNCE_MS);
}
function init() {
// Use document with capture to catch scroll events from any element
document.addEventListener('scroll', (e) => {
const target = e.target;
const scrollTop = target === document ? document.documentElement.scrollTop : target.scrollTop;
// Update scroll container if we found the real one
if (scrollTop > 0 && scrollContainer !== target) {
scrollContainer = target;
}
onScroll();
}, { capture: true, passive: true });
}
// Wait for page to be ready, then init
if (document.readyState === 'complete') {
init();
} else {
window.addEventListener('load', init);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 912 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1,40 @@
{
"manifest_version": 3,
"name": "Rowboat Browser Capture",
"version": "1.1.1",
"description": "Allows users to save and capture web page content to their Rowboat workspace.",
"icons": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"permissions": [
"tabs",
"scripting",
"activeTab"
],
"host_permissions": [
"http://*/*",
"https://*/*"
],
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["http://*/*", "https://*/*"],
"js": ["content.js"],
"run_at": "document_idle"
}
]
}

View file

@ -0,0 +1,174 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rowboat</title>
<link rel="stylesheet" href="styles.css">
<style>
body {
width: 320px;
padding: 16px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.domain {
font-weight: 500;
font-size: 14px;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.approval-section {
background-color: var(--bg-secondary);
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
}
.approval-title {
font-weight: 500;
margin-bottom: 8px;
}
.approval-buttons {
display: flex;
gap: 8px;
}
.approval-buttons .btn {
flex: 1;
}
.toggle-section {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
margin-bottom: 12px;
}
.toggle-label {
font-size: 13px;
color: var(--text-secondary);
}
.error-message {
background-color: rgba(239, 68, 68, 0.1);
border: 1px solid var(--error-color);
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
color: var(--error-color);
font-size: 13px;
}
.settings-section {
border-top: 1px solid var(--border-color);
padding-top: 12px;
margin-top: 4px;
}
.settings-title {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 8px;
}
.settings-radio {
display: flex;
flex-direction: column;
gap: 6px;
}
.settings-radio label {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-secondary);
cursor: pointer;
padding: 4px 0;
}
.settings-radio input[type="radio"] {
accent-color: var(--accent-color);
}
.stats {
display: flex;
align-items: center;
padding-top: 12px;
border-top: 1px solid var(--border-color);
margin-top: 12px;
}
.stats-count {
font-size: 12px;
color: var(--text-muted);
}
.hidden {
display: none !important;
}
</style>
</head>
<body>
<div class="header">
<span class="domain" id="domainDisplay">-</span>
<span class="status-badge" id="statusBadge">
<span class="status-dot"></span>
<span id="statusText">-</span>
</span>
</div>
<div class="error-message hidden" id="errorMessage">
Cannot reach Rowboat app.
</div>
<div class="approval-section hidden" id="approvalSection">
<div class="approval-title">Index this site?</div>
<div class="approval-buttons">
<button class="btn btn-primary btn-sm" id="approveBtn">Yes, always</button>
<button class="btn btn-secondary btn-sm" id="rejectBtn">No</button>
</div>
<button class="btn btn-secondary btn-sm btn-block mt-2" id="captureOnceBtn">Just this page</button>
</div>
<div class="toggle-section hidden" id="toggleSection">
<span class="toggle-label" id="toggleLabel">Capturing this site</span>
<button class="btn btn-secondary btn-sm" id="toggleBtn">Stop</button>
</div>
<div class="settings-section">
<div class="settings-title">Settings</div>
<div class="settings-radio">
<label>
<input type="radio" name="captureMode" value="work">
Auto-index active tab
</label>
<label>
<input type="radio" name="captureMode" value="ask">
Ask me each time
</label>
</div>
</div>
<div class="stats">
<span class="stats-count" id="statsCount">-</span>
</div>
<script src="popup.js"></script>
</body>
</html>

View file

@ -0,0 +1,258 @@
const SERVER_URL = 'http://localhost:3001';
let currentDomain = null;
let currentStatus = null;
let currentConfig = null;
async function getCurrentTab() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
return tab;
}
function extractDomain(url) {
try {
const parsed = new URL(url);
return parsed.hostname;
} catch {
return null;
}
}
function updateStatusBadge(status, serverReachable) {
const badge = document.getElementById('statusBadge');
const statusText = document.getElementById('statusText');
badge.classList.remove('capturing', 'not-capturing', 'awaiting', 'error');
if (!serverReachable) {
badge.classList.add('error');
statusText.textContent = 'Error';
return;
}
switch (status) {
case 'whitelisted':
case 'capturing':
badge.classList.add('capturing');
statusText.textContent = 'Indexing';
break;
case 'blacklisted':
badge.classList.add('not-capturing');
statusText.textContent = 'Not indexing';
break;
case 'unknown':
badge.classList.add('awaiting');
statusText.textContent = 'Awaiting';
break;
default:
badge.classList.add('not-capturing');
statusText.textContent = 'Unknown';
}
}
function showApprovalSection(show) {
document.getElementById('approvalSection').classList.toggle('hidden', !show);
}
function showToggleSection(show, isCapturing) {
const section = document.getElementById('toggleSection');
const label = document.getElementById('toggleLabel');
const btn = document.getElementById('toggleBtn');
section.classList.toggle('hidden', !show);
if (isCapturing) {
label.textContent = 'Capturing this site';
btn.textContent = 'Stop';
btn.onclick = () => removeDomain('whitelist');
} else {
label.textContent = 'Not capturing this site';
btn.textContent = 'Start';
btn.onclick = () => removeDomain('blacklist');
}
}
function showError(show) {
document.getElementById('errorMessage').classList.toggle('hidden', !show);
}
// Settings section
function getSelectedMode(config) {
return config.mode === 'all' ? 'work' : 'ask';
}
function initSettings(config) {
currentConfig = config;
const mode = getSelectedMode(config);
const radio = document.querySelector(`input[name="captureMode"][value="${mode}"]`);
if (radio) radio.checked = true;
}
async function saveSettingsFromUI() {
const selectedRadio = document.querySelector('input[name="captureMode"]:checked');
const mode = selectedRadio ? selectedRadio.value : 'ask';
let config;
if (mode === 'work') {
config = {
mode: 'all',
whitelist: currentConfig ? currentConfig.whitelist : [],
blacklist: currentConfig ? currentConfig.blacklist : [],
enabled: true
};
} else {
config = {
mode: 'ask',
whitelist: currentConfig ? currentConfig.whitelist : [],
blacklist: currentConfig ? currentConfig.blacklist : [],
enabled: true
};
}
try {
await chrome.runtime.sendMessage({ type: 'SAVE_CONFIG', config });
currentConfig = config;
await loadStatus();
} catch (error) {
console.error('Failed to save settings:', error);
}
}
// Domain status
async function loadStatus() {
const tab = await getCurrentTab();
if (!tab || !tab.url) {
document.getElementById('domainDisplay').textContent = 'No page';
return;
}
currentDomain = extractDomain(tab.url);
if (!currentDomain) {
document.getElementById('domainDisplay').textContent = 'Invalid URL';
return;
}
document.getElementById('domainDisplay').textContent = currentDomain;
try {
const response = await chrome.runtime.sendMessage({
type: 'GET_DOMAIN_STATUS',
url: tab.url
});
currentStatus = response.status;
const serverReachable = response.serverReachable;
updateStatusBadge(currentStatus, serverReachable);
showError(!serverReachable);
if (!serverReachable) {
showApprovalSection(false);
showToggleSection(false, false);
return;
}
if (currentStatus === 'unknown') {
showApprovalSection(true);
showToggleSection(false, false);
} else if (currentStatus === 'whitelisted' || currentStatus === 'capturing') {
showApprovalSection(false);
showToggleSection(true, true);
} else if (currentStatus === 'blacklisted') {
showApprovalSection(false);
showToggleSection(true, false);
} else {
showApprovalSection(false);
showToggleSection(false, false);
}
} catch (error) {
console.error('Failed to get status:', error);
showError(true);
}
}
async function loadStats() {
try {
const response = await fetch(`${SERVER_URL}/status`);
if (response.ok) {
const data = await response.json();
document.getElementById('statsCount').textContent = `${data.count} pages indexed locally`;
}
} catch (error) {
console.log('Failed to load stats:', error);
}
}
async function approveDomain() {
if (!currentDomain) return;
try {
await chrome.runtime.sendMessage({ type: 'APPROVE_DOMAIN', domain: currentDomain });
// Reload config to reflect the new whitelist in settings
const resp = await chrome.runtime.sendMessage({ type: 'GET_CONFIG' });
if (resp && resp.config) initSettings(resp.config);
await loadStatus();
} catch (error) {
console.error('Failed to approve domain:', error);
}
}
async function rejectDomain() {
if (!currentDomain) return;
try {
await chrome.runtime.sendMessage({ type: 'REJECT_DOMAIN', domain: currentDomain });
await loadStatus();
} catch (error) {
console.error('Failed to reject domain:', error);
}
}
async function captureOnce() {
try {
const response = await chrome.runtime.sendMessage({ type: 'CAPTURE_ONCE' });
if (response.success) {
window.close();
}
} catch (error) {
console.error('Failed to capture:', error);
}
}
async function removeDomain(list) {
if (!currentDomain) return;
try {
const messageType = list === 'whitelist' ? 'REMOVE_FROM_WHITELIST' : 'REMOVE_FROM_BLACKLIST';
await chrome.runtime.sendMessage({ type: messageType, domain: currentDomain });
// Reload config to reflect changes in settings
const resp = await chrome.runtime.sendMessage({ type: 'GET_CONFIG' });
if (resp && resp.config) initSettings(resp.config);
await loadStatus();
} catch (error) {
console.error('Failed to remove domain:', error);
}
}
document.addEventListener('DOMContentLoaded', async () => {
// Load config and init settings
try {
const resp = await chrome.runtime.sendMessage({ type: 'GET_CONFIG' });
if (resp && resp.config) {
initSettings(resp.config);
}
} catch (error) {
console.error('Failed to load config:', error);
}
// Radio change listeners
document.querySelectorAll('input[name="captureMode"]').forEach(radio => {
radio.addEventListener('change', () => saveSettingsFromUI());
});
loadStatus();
loadStats();
document.getElementById('approveBtn').addEventListener('click', approveDomain);
document.getElementById('rejectBtn').addEventListener('click', rejectDomain);
document.getElementById('captureOnceBtn').addEventListener('click', captureOnce);
});

View file

@ -0,0 +1,279 @@
:root {
--bg-primary: #ffffff;
--bg-secondary: #f9fafb;
--bg-tertiary: #f3f4f6;
--text-primary: #111827;
--text-secondary: #6b7280;
--text-muted: #9ca3af;
--border-color: #e5e7eb;
--accent-color: #3b82f6;
--accent-hover: #2563eb;
--success-color: #10b981;
--warning-color: #f59e0b;
--error-color: #ef4444;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 4px 6px rgba(0, 0, 0, 0.1);
}
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #1f2937;
--bg-secondary: #111827;
--bg-tertiary: #374151;
--text-primary: #f9fafb;
--text-secondary: #d1d5db;
--text-muted: #9ca3af;
--border-color: #374151;
--accent-color: #60a5fa;
--accent-hover: #3b82f6;
--success-color: #34d399;
--warning-color: #fbbf24;
--error-color: #f87171;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 4px 6px rgba(0, 0, 0, 0.3);
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--text-primary);
background-color: var(--bg-primary);
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
border-radius: 6px;
border: none;
cursor: pointer;
transition: all 0.15s ease;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background-color: var(--accent-color);
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: var(--accent-hover);
}
.btn-secondary {
background-color: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover:not(:disabled) {
background-color: var(--border-color);
}
.btn-ghost {
background-color: transparent;
color: var(--text-secondary);
}
.btn-ghost:hover:not(:disabled) {
background-color: var(--bg-tertiary);
color: var(--text-primary);
}
.btn-sm {
padding: 4px 8px;
font-size: 12px;
}
.btn-block {
width: 100%;
}
/* Status badges */
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
}
.status-badge.capturing {
background-color: rgba(16, 185, 129, 0.1);
color: var(--success-color);
}
.status-badge.not-capturing {
background-color: rgba(107, 114, 128, 0.1);
color: var(--text-secondary);
}
.status-badge.awaiting {
background-color: rgba(245, 158, 11, 0.1);
color: var(--warning-color);
}
.status-badge.error {
background-color: rgba(239, 68, 68, 0.1);
color: var(--error-color);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: currentColor;
}
/* Cards */
.card {
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 16px;
}
/* Form elements */
.radio-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.radio-option {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
border: 1px solid var(--border-color);
border-radius: 8px;
cursor: pointer;
transition: all 0.15s ease;
}
.radio-option:hover {
border-color: var(--accent-color);
background-color: var(--bg-secondary);
}
.radio-option.selected {
border-color: var(--accent-color);
background-color: rgba(59, 130, 246, 0.05);
}
.radio-option input[type="radio"] {
margin-top: 2px;
accent-color: var(--accent-color);
}
.radio-option-content {
flex: 1;
}
.radio-option-title {
font-weight: 500;
color: var(--text-primary);
}
.radio-option-desc {
font-size: 13px;
color: var(--text-secondary);
margin-top: 2px;
}
/* Toggle/Checkbox */
.toggle-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
padding-left: 24px;
}
.toggle-item {
display: flex;
align-items: center;
gap: 8px;
}
.toggle-item input[type="checkbox"] {
accent-color: var(--accent-color);
}
.toggle-item label {
font-size: 13px;
color: var(--text-secondary);
cursor: pointer;
}
/* Divider */
.divider {
height: 1px;
background-color: var(--border-color);
margin: 12px 0;
}
/* Link */
.link {
color: var(--accent-color);
text-decoration: none;
font-size: 13px;
}
.link:hover {
text-decoration: underline;
}
/* Text utilities */
.text-sm {
font-size: 12px;
}
.text-muted {
color: var(--text-muted);
}
.text-secondary {
color: var(--text-secondary);
}
.text-center {
text-align: center;
}
/* Spacing utilities */
.mt-1 { margin-top: 4px; }
.mt-2 { margin-top: 8px; }
.mt-3 { margin-top: 12px; }
.mt-4 { margin-top: 16px; }
.mb-1 { margin-bottom: 4px; }
.mb-2 { margin-bottom: 8px; }
.mb-3 { margin-bottom: 12px; }
.mb-4 { margin-bottom: 16px; }
/* Flex utilities */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.gap-1 { gap: 4px; }
.gap-2 { gap: 8px; }
.gap-3 { gap: 12px; }

View file

@ -0,0 +1,281 @@
import express from 'express';
import cors from 'cors';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { WorkDir } from '../../../config/config.js';
const app = express();
app.use(cors());
app.use(express.json({ limit: '10mb' }));
const CAPTURED_PAGES_DIR = path.join(WorkDir, 'chrome_sync');
const CONFIG_DIR = path.join(WorkDir, 'config');
const CONFIG_FILE = path.join(CONFIG_DIR, 'chrome-plugin.json');
interface Config {
mode: 'all' | 'ask';
whitelist: string[];
blacklist: string[];
enabled: boolean;
}
const DEFAULT_CONFIG: Config = {
mode: 'ask',
whitelist: [],
blacklist: [],
enabled: true
};
const contentHashes = new Map<string, string>();
function extractDomain(url: string): string {
try {
const parsed = new URL(url);
return parsed.host || 'unknown';
} catch {
return 'unknown';
}
}
function pathToSlug(url: string): string {
try {
const parsed = new URL(url);
const p = parsed.pathname + (parsed.search || '');
if (!p || p === '/') return 'index';
let slug = p.replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_|_$/g, '');
return slug.substring(0, 80) || 'index';
} catch {
return 'index';
}
}
function hashContent(content: string): string {
return crypto.createHash('sha256').update(content, 'utf-8').digest('hex');
}
function findExistingFile(domainDir: string, pathSlug: string): string | null {
if (!fs.existsSync(domainDir)) return null;
const files = fs.readdirSync(domainDir);
for (const filename of files) {
if (filename.endsWith(`_${pathSlug}.md`)) {
return path.join(domainDir, filename);
}
}
return null;
}
// POST /capture
app.post('/capture', (req, res) => {
const data = req.body;
if (!data) {
return res.status(400).json({ error: 'No JSON data provided' });
}
const { url, content = '', timestamp, title = 'Untitled' } = data;
if (!url || !timestamp) {
return res.status(400).json({ error: 'Missing required fields: url, timestamp' });
}
const domain = extractDomain(url);
const pathSlug = pathToSlug(url);
const contentHash = hashContent(content);
const cacheKey = `${domain}/${pathSlug}`;
const dt = new Date(timestamp);
const year = dt.getFullYear();
const month = String(dt.getMonth() + 1).padStart(2, '0');
const day = String(dt.getDate()).padStart(2, '0');
const dateStr = `${year}-${month}-${day}`;
const hours = String(dt.getHours()).padStart(2, '0');
const minutes = String(dt.getMinutes()).padStart(2, '0');
const seconds = String(dt.getSeconds()).padStart(2, '0');
const timeStr = `${hours}-${minutes}`;
const timeDisplay = `${hours}:${minutes}:${seconds}`;
const tzOffset = -dt.getTimezoneOffset();
const tzSign = tzOffset >= 0 ? '+' : '-';
const tzHours = String(Math.floor(Math.abs(tzOffset) / 60)).padStart(2, '0');
const tzMins = String(Math.abs(tzOffset) % 60).padStart(2, '0');
const isoTimestamp = `${dateStr}T${hours}:${minutes}:${seconds}${tzSign}${tzHours}:${tzMins}`;
// date/domain directory structure
const domainDir = path.join(CAPTURED_PAGES_DIR, dateStr, domain);
fs.mkdirSync(domainDir, { recursive: true });
const existingFile = findExistingFile(domainDir, pathSlug);
if (existingFile && contentHashes.get(cacheKey) === contentHash) {
return res.json({ status: 'skipped', reason: 'duplicate content' });
}
contentHashes.set(cacheKey, contentHash);
// If file exists, append with scroll separator
if (existingFile) {
const scrollSeparator = `\n\n---\n📜 Scroll captured at ${timeDisplay}\n---\n\n`;
fs.appendFileSync(existingFile, scrollSeparator + content, 'utf-8');
const rel = `${dateStr}/${domain}/${path.basename(existingFile)}`;
return res.json({ status: 'appended', filename: rel });
}
// New file - create with frontmatter
const filename = `${timeStr}_${pathSlug}.md`;
const filepath = path.join(domainDir, filename);
const markdownContent = `---
url: ${url}
title: ${title}
captured_at: ${isoTimestamp}
---
${content}
`;
fs.writeFileSync(filepath, markdownContent, 'utf-8');
return res.status(201).json({ status: 'captured', filename: `${dateStr}/${domain}/${filename}` });
});
// GET /status
app.get('/status', (_req, res) => {
let count = 0;
const domains: Record<string, number> = {};
if (!fs.existsSync(CAPTURED_PAGES_DIR)) {
return res.json({ count: 0, domains: [] });
}
for (const dateEntry of fs.readdirSync(CAPTURED_PAGES_DIR)) {
const datePath = path.join(CAPTURED_PAGES_DIR, dateEntry);
if (!fs.statSync(datePath).isDirectory()) continue;
for (const domainEntry of fs.readdirSync(datePath)) {
const domainPath = path.join(datePath, domainEntry);
if (!fs.statSync(domainPath).isDirectory()) continue;
const domainCount = fs.readdirSync(domainPath).filter(f => f.endsWith('.md')).length;
count += domainCount;
if (domainCount > 0) {
domains[domainEntry] = (domains[domainEntry] || 0) + domainCount;
}
}
}
const domainList = Object.entries(domains)
.map(([domain, c]) => ({ domain, count: c }))
.sort((a, b) => b.count - a.count);
return res.json({ count, domains: domainList });
});
// Config helpers
function loadConfig(): Config {
if (fs.existsSync(CONFIG_FILE)) {
try {
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
return JSON.parse(raw);
} catch {
// fall through
}
}
return { ...DEFAULT_CONFIG };
}
function saveConfig(config: Config): void {
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
}
function validateConfig(data: any): data is Config {
if (typeof data !== 'object' || data === null) return false;
if (data.mode !== 'all' && data.mode !== 'ask') return false;
if (!Array.isArray(data.whitelist)) return false;
if (!Array.isArray(data.blacklist)) return false;
if (typeof data.enabled !== 'boolean') return false;
return true;
}
// GET /browse/config
app.get('/browse/config', (_req, res) => {
const config = loadConfig();
return res.json(config);
});
// POST /browse/config
app.post('/browse/config', (req, res) => {
const data = req.body;
if (!data) {
return res.status(400).json({ error: 'No JSON data provided' });
}
if (!validateConfig(data)) {
return res.status(400).json({ error: 'Invalid config shape' });
}
saveConfig(data);
return res.json({ status: 'saved', config: data });
});
const PORT = 3001;
const RETENTION_DAYS = 7;
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
function cleanUpOldFiles(): void {
if (!fs.existsSync(CAPTURED_PAGES_DIR)) return;
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - RETENTION_DAYS);
const cutoffStr = cutoff.toISOString().slice(0, 10); // YYYY-MM-DD
for (const dateEntry of fs.readdirSync(CAPTURED_PAGES_DIR)) {
// only process date-formatted directories
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateEntry)) continue;
if (dateEntry >= cutoffStr) continue;
const datePath = path.join(CAPTURED_PAGES_DIR, dateEntry);
if (!fs.statSync(datePath).isDirectory()) continue;
fs.rmSync(datePath, { recursive: true, force: true });
console.log(`[ChromeSync] Cleaned up old captures: ${dateEntry}`);
}
}
function isServerEnabled(): boolean {
if (!fs.existsSync(CONFIG_FILE)) return false;
try {
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
const config = JSON.parse(raw);
return config.serverEnabled === true;
} catch {
return false;
}
}
function startServer(): void {
fs.mkdirSync(CAPTURED_PAGES_DIR, { recursive: true });
cleanUpOldFiles();
setInterval(cleanUpOldFiles, CLEANUP_INTERVAL_MS);
app.listen(PORT, 'localhost', () => {
console.log('[ChromeSync] Server starting.');
console.log(` Captured pages: ${CAPTURED_PAGES_DIR}`);
console.log(` Config: ${CONFIG_FILE}`);
console.log(` Listening on http://localhost:${PORT}`);
});
}
export async function init(): Promise<void> {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
if (isServerEnabled()) {
startServer();
return;
}
console.log('[ChromeSync] Server disabled, watching config for changes...');
fs.watch(CONFIG_DIR, (_, filename) => {
if (filename === 'chrome-plugin.json' && isServerEnabled()) {
console.log('[ChromeSync] serverEnabled set to true, starting server...');
startServer();
}
});
}

View file

@ -15,7 +15,8 @@ const SYSTEM_PROMPT = `You are a meeting notes assistant. Given a raw meeting tr
## Calendar matching
You will be given the transcript (with a timestamp of when recording started) and recent calendar events with their titles, times, and attendees. If a calendar event clearly matches this meeting (overlapping time + content aligns), then:
- Do NOT output a title or heading the title is already set by the caller.
- Replace generic speaker labels ("Speaker 0", "Speaker 1", "System audio") with actual attendee names, but ONLY if you have HIGH CONFIDENCE about which speaker is which based on the discussion content. If unsure, use "They" instead of "Speaker 0" etc.
- ONLY use names from the calendar event attendee list. Do NOT introduce names that are not in the attendee list any unrecognized names in the transcript are transcription errors.
- Replace generic speaker labels ("Speaker 0", "Speaker 1", "System audio") with actual attendee names from the list, but ONLY if you have HIGH CONFIDENCE about which speaker is which based on the discussion content. If unsure, use "They" instead of "Speaker 0" etc.
- "You" in the transcript is the local user if the calendar event has an organizer or you can identify who "You" is from context, use their name.
If no calendar event matches with high confidence, or if no calendar events are provided, use "They" for all non-"You" speakers.

View file

@ -63,6 +63,7 @@ export type CalendarBlock = z.infer<typeof CalendarBlockSchema>;
export const EmailBlockSchema = z.object({
threadId: z.string().optional(),
summary: z.string().optional(),
subject: z.string().optional(),
from: z.string().optional(),
to: z.string().optional(),
@ -74,3 +75,9 @@ export const EmailBlockSchema = z.object({
});
export type EmailBlock = z.infer<typeof EmailBlockSchema>;
export const TranscriptBlockSchema = z.object({
transcript: z.string(),
});
export type TranscriptBlock = z.infer<typeof TranscriptBlockSchema>;

View file

@ -576,6 +576,16 @@ const ipcSchemas = {
mimeType: z.string(),
}),
},
'meeting:checkScreenPermission': {
req: z.null(),
res: z.object({
granted: z.boolean(),
}),
},
'meeting:openScreenRecordingSettings': {
req: z.null(),
res: z.object({ success: z.boolean() }),
},
'meeting:summarize': {
req: z.object({
transcript: z.string(),

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

@ -240,7 +240,7 @@ importers:
version: 19.2.3(react@19.2.3)
recharts:
specifier: ^3.8.0
version: 3.8.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1)
version: 3.8.1(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1)
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@ -310,16 +310,16 @@ importers:
dependencies:
'@ai-sdk/anthropic':
specifier: ^2.0.63
version: 2.0.63(zod@4.2.1)
version: 2.0.70(zod@4.2.1)
'@ai-sdk/google':
specifier: ^2.0.53
version: 2.0.53(zod@4.2.1)
version: 2.0.61(zod@4.2.1)
'@ai-sdk/openai':
specifier: ^2.0.91
version: 2.0.91(zod@4.2.1)
version: 2.0.99(zod@4.2.1)
'@ai-sdk/openai-compatible':
specifier: ^1.0.33
version: 1.0.33(zod@4.2.1)
version: 1.0.34(zod@4.2.1)
'@ai-sdk/provider':
specifier: ^2.0.1
version: 2.0.1
@ -334,7 +334,7 @@ importers:
version: 1.25.1(hono@4.11.3)(zod@4.2.1)
'@openrouter/ai-sdk-provider':
specifier: ^1.2.6
version: 1.5.4(ai@5.0.133(zod@4.2.1))(zod@4.2.1)
version: 1.5.4(ai@5.0.151(zod@4.2.1))(zod@4.2.1)
'@react-pdf/renderer':
specifier: ^4.3.2
version: 4.3.2(react@19.2.3)
@ -346,16 +346,22 @@ importers:
version: link:../shared
ai:
specifier: ^5.0.133
version: 5.0.133(zod@4.2.1)
version: 5.0.151(zod@4.2.1)
awilix:
specifier: ^12.0.5
version: 12.0.5
chokidar:
specifier: ^4.0.3
version: 4.0.3
cors:
specifier: ^2.8.6
version: 2.8.6
cron-parser:
specifier: ^5.5.0
version: 5.5.0
express:
specifier: ^5.2.1
version: 5.2.1
glob:
specifier: ^13.0.0
version: 13.0.0
@ -399,6 +405,12 @@ importers:
specifier: ^4.2.1
version: 4.2.1
devDependencies:
'@types/cors':
specifier: ^2.8.19
version: 2.8.19
'@types/express':
specifier: ^5.0.6
version: 5.0.6
'@types/node':
specifier: ^25.0.3
version: 25.0.3
@ -417,8 +429,8 @@ importers:
packages:
'@ai-sdk/anthropic@2.0.63':
resolution: {integrity: sha512-zXlUPCkumnvp8lWS9VFcen/MLF6CL/t1zAKDhpobYj9y/nmylQrKtRvn3RwH871Wd3dF3KYEUXd6M2c6dfCKOA==}
'@ai-sdk/anthropic@2.0.70':
resolution: {integrity: sha512-W3WjQlb0Ho+CVAQUvb8Rtk3hGS3Jlgy79ihY2H0yj2k4yU8XuxpQw0Oz+7JQsB47j+jlHhk7nUXtxhAeRg3S3Q==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
@ -429,26 +441,26 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/gateway@2.0.39':
resolution: {integrity: sha512-ULnefGmRHG0/tRrf+dtDwgQYAttGi/TR0FmASAzTs1dtpeZp4Xoh1VyWrX3Z1bM3WDs9RM3ZeSE77kQT/jbfjw==}
'@ai-sdk/gateway@2.0.56':
resolution: {integrity: sha512-omvb2Bwpgqg8PKqOpYdIaW+fdEIWcfm2B/j3dx37DxzOIt6fr57VVcfw7pu/EaACcY0O+wsg50iFCPGcsI2Cbg==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/google@2.0.53':
resolution: {integrity: sha512-ccCxr5mrd3AC2CjLq4e1ST7+UiN5T2Pdmgi0XdWM3QohmNBwUQ/RBG7BvL+cB/ex/j6y64tkMmpYz9zBw/SEFQ==}
'@ai-sdk/google@2.0.61':
resolution: {integrity: sha512-hIs7UvL8X5MBG3uxdciSotD4I27UcMa4/we9Qf98fM/RgMTwyk9zXcr7GM6k5yLBZ5S0QeZWkfqKwtdiDnUEEQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/openai-compatible@1.0.33':
resolution: {integrity: sha512-2KMcR2xAul3u5dGZD7gONgbIki3Hg7Ey+sFu7gsiJ4U2iRU0GDV3ccNq79dTuAEXPDFcOWCUpW8A8jXc0kxJxQ==}
'@ai-sdk/openai-compatible@1.0.34':
resolution: {integrity: sha512-AnGoxVNZ/E3EU4lW12rrufI6riqL2cEv4jk3OrjJ/i54XwR0CJU1V26jXAwxb+Pc+uZmYG++HM+gzXxPQZkMNQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/openai@2.0.91':
resolution: {integrity: sha512-lozfRHfSTHg5/UliQjTDcOtISYGbEpt4FS/6QM5PcLmhdT0HmROllaBmG7+JaK+uqFtDXZGgMIpz3bqB9nzqCQ==}
'@ai-sdk/openai@2.0.99':
resolution: {integrity: sha512-wwa1/DuO9XThaA+sAi0d3+xfkbEx9nRhZ1USV6kktndmEs8aQRR0DJK/Iec+mwNu06IhfDGd5vMscR1U1q155g==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
@ -459,8 +471,8 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider-utils@3.0.21':
resolution: {integrity: sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q==}
'@ai-sdk/provider-utils@3.0.22':
resolution: {integrity: sha512-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
@ -3221,9 +3233,18 @@ packages:
'@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
'@types/cacheable-request@6.0.3':
resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==}
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
'@types/cors@2.8.19':
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
@ -3335,6 +3356,12 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/express-serve-static-core@5.1.1':
resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==}
'@types/express@5.0.6':
resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==}
'@types/fs-extra@9.0.13':
resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==}
@ -3347,6 +3374,9 @@ packages:
'@types/http-cache-semantics@4.0.4':
resolution: {integrity: sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==}
'@types/http-errors@2.0.5':
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@ -3398,6 +3428,12 @@ packages:
'@types/pdf-parse@1.1.5':
resolution: {integrity: sha512-kBfrSXsloMnUJOKi25s3+hRmkycHfLK6A09eRGqF/N8BkQoPUmaCr+q8Cli5FnfohEz/rsv82zAiPz/LXtOGhA==}
'@types/qs@6.14.0':
resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
'@types/range-parser@1.2.7':
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
@ -3409,6 +3445,12 @@ packages:
'@types/responselike@1.0.3':
resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==}
'@types/send@1.2.1':
resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
'@types/serve-static@2.2.0':
resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
@ -3617,8 +3659,8 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4.1.8
ai@5.0.133:
resolution: {integrity: sha512-N6KnwSWKcXEWPnAri3anRuzRvcrvtDz1W1JG9CvMrQ0Xdp8Vu8ZToNW/eHt63CmrbmzTwVw/HaCtJuO+MYtS7A==}
ai@5.0.151:
resolution: {integrity: sha512-tsLIv+QN9wJ/xl/fnYgjRYoGSThdnOfU4d6+7QEUKX3EcwviWMEaL1gOE+zfdkcay/Tbc02ZBtTRHoulS6DYvQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
@ -4057,8 +4099,8 @@ packages:
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
cors@2.8.5:
resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==}
cors@2.8.6:
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
engines: {node: '>= 0.10'}
cose-base@1.0.3:
@ -4945,7 +4987,6 @@ 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:
@ -4954,7 +4995,7 @@ packages:
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
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
deprecated: Glob versions prior to v9 are no longer supported
glob@8.1.0:
resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==}
@ -6671,8 +6712,8 @@ packages:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
recharts@3.8.0:
resolution: {integrity: sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==}
recharts@3.8.1:
resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==}
engines: {node: '>=18'}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
@ -7141,7 +7182,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 exorbitant 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 exhorbitant rates) by contacting i@izs.me
temp@0.9.4:
resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==}
@ -7306,8 +7347,8 @@ packages:
ufo@1.6.1:
resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==}
underscore@1.13.7:
resolution: {integrity: sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==}
underscore@1.13.8:
resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@ -7694,10 +7735,10 @@ packages:
snapshots:
'@ai-sdk/anthropic@2.0.63(zod@4.2.1)':
'@ai-sdk/anthropic@2.0.70(zod@4.2.1)':
dependencies:
'@ai-sdk/provider': 2.0.1
'@ai-sdk/provider-utils': 3.0.21(zod@4.2.1)
'@ai-sdk/provider-utils': 3.0.22(zod@4.2.1)
zod: 4.2.1
'@ai-sdk/gateway@2.0.24(zod@4.2.1)':
@ -7707,29 +7748,29 @@ snapshots:
'@vercel/oidc': 3.0.5
zod: 4.2.1
'@ai-sdk/gateway@2.0.39(zod@4.2.1)':
'@ai-sdk/gateway@2.0.56(zod@4.2.1)':
dependencies:
'@ai-sdk/provider': 2.0.1
'@ai-sdk/provider-utils': 3.0.21(zod@4.2.1)
'@ai-sdk/provider-utils': 3.0.22(zod@4.2.1)
'@vercel/oidc': 3.1.0
zod: 4.2.1
'@ai-sdk/google@2.0.53(zod@4.2.1)':
'@ai-sdk/google@2.0.61(zod@4.2.1)':
dependencies:
'@ai-sdk/provider': 2.0.1
'@ai-sdk/provider-utils': 3.0.21(zod@4.2.1)
'@ai-sdk/provider-utils': 3.0.22(zod@4.2.1)
zod: 4.2.1
'@ai-sdk/openai-compatible@1.0.33(zod@4.2.1)':
'@ai-sdk/openai-compatible@1.0.34(zod@4.2.1)':
dependencies:
'@ai-sdk/provider': 2.0.1
'@ai-sdk/provider-utils': 3.0.21(zod@4.2.1)
'@ai-sdk/provider-utils': 3.0.22(zod@4.2.1)
zod: 4.2.1
'@ai-sdk/openai@2.0.91(zod@4.2.1)':
'@ai-sdk/openai@2.0.99(zod@4.2.1)':
dependencies:
'@ai-sdk/provider': 2.0.1
'@ai-sdk/provider-utils': 3.0.21(zod@4.2.1)
'@ai-sdk/provider-utils': 3.0.22(zod@4.2.1)
zod: 4.2.1
'@ai-sdk/provider-utils@3.0.20(zod@4.2.1)':
@ -7739,7 +7780,7 @@ snapshots:
eventsource-parser: 3.0.6
zod: 4.2.1
'@ai-sdk/provider-utils@3.0.21(zod@4.2.1)':
'@ai-sdk/provider-utils@3.0.22(zod@4.2.1)':
dependencies:
'@ai-sdk/provider': 2.0.1
'@standard-schema/spec': 1.1.0
@ -9240,7 +9281,7 @@ snapshots:
ajv: 8.17.1
ajv-formats: 3.0.1(ajv@8.17.1)
content-type: 1.0.5
cors: 2.8.5
cors: 2.8.6
cross-spawn: 7.0.6
eventsource: 3.0.7
eventsource-parser: 3.0.6
@ -9432,10 +9473,10 @@ snapshots:
'@oozcitak/util@8.3.4': {}
'@openrouter/ai-sdk-provider@1.5.4(ai@5.0.133(zod@4.2.1))(zod@4.2.1)':
'@openrouter/ai-sdk-provider@1.5.4(ai@5.0.151(zod@4.2.1))(zod@4.2.1)':
dependencies:
'@openrouter/sdk': 0.1.27
ai: 5.0.133(zod@4.2.1)
ai: 5.0.151(zod@4.2.1)
zod: 4.2.1
'@openrouter/sdk@0.1.27':
@ -11232,6 +11273,11 @@ snapshots:
dependencies:
'@babel/types': 7.28.5
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
'@types/node': 25.0.3
'@types/cacheable-request@6.0.3':
dependencies:
'@types/http-cache-semantics': 4.0.4
@ -11239,6 +11285,14 @@ snapshots:
'@types/node': 25.0.3
'@types/responselike': 1.0.3
'@types/connect@3.4.38':
dependencies:
'@types/node': 25.0.3
'@types/cors@2.8.19':
dependencies:
'@types/node': 25.0.3
'@types/d3-array@3.2.2': {}
'@types/d3-axis@3.0.6':
@ -11378,6 +11432,19 @@ snapshots:
'@types/estree@1.0.8': {}
'@types/express-serve-static-core@5.1.1':
dependencies:
'@types/node': 25.0.3
'@types/qs': 6.14.0
'@types/range-parser': 1.2.7
'@types/send': 1.2.1
'@types/express@5.0.6':
dependencies:
'@types/body-parser': 1.19.6
'@types/express-serve-static-core': 5.1.1
'@types/serve-static': 2.2.0
'@types/fs-extra@9.0.13':
dependencies:
'@types/node': 25.0.3
@ -11391,6 +11458,8 @@ snapshots:
'@types/http-cache-semantics@4.0.4': {}
'@types/http-errors@2.0.5': {}
'@types/json-schema@7.0.15': {}
'@types/katex@0.16.7': {}
@ -11447,6 +11516,10 @@ snapshots:
dependencies:
'@types/node': 25.0.3
'@types/qs@6.14.0': {}
'@types/range-parser@1.2.7': {}
'@types/react-dom@19.2.3(@types/react@19.2.7)':
dependencies:
'@types/react': 19.2.7
@ -11459,6 +11532,15 @@ snapshots:
dependencies:
'@types/node': 25.0.3
'@types/send@1.2.1':
dependencies:
'@types/node': 25.0.3
'@types/serve-static@2.2.0':
dependencies:
'@types/http-errors': 2.0.5
'@types/node': 25.0.3
'@types/trusted-types@2.0.7':
optional: true
@ -11718,11 +11800,11 @@ snapshots:
'@opentelemetry/api': 1.9.0
zod: 4.2.1
ai@5.0.133(zod@4.2.1):
ai@5.0.151(zod@4.2.1):
dependencies:
'@ai-sdk/gateway': 2.0.39(zod@4.2.1)
'@ai-sdk/gateway': 2.0.56(zod@4.2.1)
'@ai-sdk/provider': 2.0.1
'@ai-sdk/provider-utils': 3.0.21(zod@4.2.1)
'@ai-sdk/provider-utils': 3.0.22(zod@4.2.1)
'@opentelemetry/api': 1.9.0
zod: 4.2.1
@ -12173,7 +12255,7 @@ snapshots:
core-util-is@1.0.3: {}
cors@2.8.5:
cors@2.8.6:
dependencies:
object-assign: 4.1.1
vary: 1.1.2
@ -12536,7 +12618,7 @@ snapshots:
duck@0.1.12:
dependencies:
underscore: 1.13.7
underscore: 1.13.8
dunder-proto@1.0.1:
dependencies:
@ -14078,7 +14160,7 @@ snapshots:
dependencies:
duck: 0.1.12
option: 0.2.4
underscore: 1.13.7
underscore: 1.13.8
lower-case@2.0.2:
dependencies:
@ -14147,7 +14229,7 @@ snapshots:
jszip: 3.10.1
lop: 0.4.2
path-is-absolute: 1.0.1
underscore: 1.13.7
underscore: 1.13.8
xmlbuilder: 10.1.1
map-age-cleaner@0.1.3:
@ -15441,7 +15523,7 @@ snapshots:
readdirp@4.1.2: {}
recharts@3.8.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1):
recharts@3.8.1(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1):
dependencies:
'@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1))(react@19.2.3)
clsx: 2.1.1
@ -16194,7 +16276,7 @@ snapshots:
ufo@1.6.1: {}
underscore@1.13.7: {}
underscore@1.13.8: {}
undici-types@6.21.0: {}