From f8e7c17be5e651f49bb63666544d05456aeec485 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:20:14 +0530 Subject: [PATCH 1/9] added smooth streaming --- apps/x/apps/renderer/src/App.tsx | 8 +++- .../renderer/src/hooks/useSmoothedText.ts | 48 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 apps/x/apps/renderer/src/hooks/useSmoothedText.ts diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 591ef21e..976c09c0 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -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 {smoothText} +} + const DEFAULT_SIDEBAR_WIDTH = 256 const wikiLinkRegex = /\[\[([^[\]]+)\]\]/g const graphPalette = [ @@ -4237,7 +4243,7 @@ function App() { {tabState.currentAssistantMessage && ( - {tabState.currentAssistantMessage.replace(/<\/?voice>/g, '')} + /g, '')} components={streamdownComponents} /> )} diff --git a/apps/x/apps/renderer/src/hooks/useSmoothedText.ts b/apps/x/apps/renderer/src/hooks/useSmoothedText.ts new file mode 100644 index 00000000..2328e72c --- /dev/null +++ b/apps/x/apps/renderer/src/hooks/useSmoothedText.ts @@ -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(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 +} From eb34873c328146815444e0b1f4c21c9f9406f894 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Thu, 26 Mar 2026 22:07:52 +0530 Subject: [PATCH 2/9] fix voice output --- apps/x/packages/core/src/agents/runtime.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index 98bae635..b7ebdfdf 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -859,7 +859,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) @@ -869,6 +869,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(); @@ -982,9 +985,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) { @@ -1052,10 +1052,10 @@ 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 tags that provide a spoken summary and guide to your written response. This is NOT optional — every response MUST begin with tags.\n\nRules:\n1. ALWAYS start your response with one or more tags. Never skip them.\n2. Place ALL tags at the BEGINNING of your response, before any detailed content. Do NOT intersperse tags throughout the response.\n3. Wrap EACH spoken sentence in its own separate tag so it can be spoken incrementally. Do NOT wrap everything in a single 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?":\nYour meeting with Sarah covered three main things: the Q2 roadmap timeline, hiring for the backend role, and the client demo next week.\nI've pulled out the key details and action items below — the demo prep notes are at the end.\n\n## Meeting with Sarah — March 11\n(Then the full detailed written response follows without any more tags.)\n\nAny text outside 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 tags. If your response does not begin with tags, the user will hear nothing — which is a broken experience. NEVER skip this.\n\nRules:\n1. YOUR VERY FIRST OUTPUT MUST BE A TAG. No exceptions. Do not start with markdown, headings, or any other text. The literal first characters of your response must be "".\n2. Place ALL tags at the BEGINNING of your response, before any detailed content. Do NOT intersperse tags throughout the response.\n3. Wrap EACH spoken sentence in its own separate tag so it can be spoken incrementally. Do NOT wrap everything in a single block.\n4. Use voice as a TL;DR and navigation aid — do NOT read the entire response aloud.\n5. After all 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\nYour meeting with Alex covered three main things: the Q2 roadmap timeline, hiring for the backend role, and the client demo next week.\nI've pulled out the key details and action items below — the demo prep notes are at the end.\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\nYou have five new emails since this morning.\nTwo are from your team — Jordan sent the RFC you requested and Taylor flagged a contract issue.\nThere's also a warm intro from a VC partner connecting you with someone at a prospective customer.\nI've drafted responses for three of them. The details and drafts are below.\n\n(email blocks, tables, and detailed content follow)\n\nExample 3 — User asks: "what's on my calendar today?"\n\nYou've got a pretty packed day — seven meetings starting with standup at 9.\nThe 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.\nYour only free block for deep work is 2:30 to 4.\n\n(calendar block with full event details follows)\n\nExample 4 — User asks: "draft an email to Sam with our metrics"\n\nDone — I've drafted the email to Sam with your latest WAU and churn numbers.\nTake a look at the draft below and send it when you're ready.\n\n(email block with draft follows)\n\nREMEMBER: If you do not start with 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 tags. This is NOT optional.\n\nRules:\n1. Wrap EACH sentence in its own separate 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 tag. Do not leave any content outside tags.\n\nExample:\nYour meeting with Sarah covered three main things.\nFirst, you discussed the Q2 roadmap timeline and agreed to push the launch to April.\nSecond, you talked about hiring for the backend role — Sarah will send over two candidates by Friday.\nAnd lastly, the client demo is next week on Thursday at 2pm, and you're handling the intro slides.`; + 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 tags. If you write anything outside 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 TAG. No exceptions. The literal first characters of your response must be "".\n2. Wrap EACH sentence in its own separate 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 tag. Do not leave ANY content outside tags. If it's not in a tag, the user cannot hear it.\n\n## Examples\n\nExample 1 — User asks: "what happened in my meeting with Alex yesterday?"\n\nYour meeting with Alex covered three main things.\nFirst, you discussed the Q2 roadmap timeline and agreed to push the launch to April.\nSecond, you talked about hiring for the backend role — Alex will send over two candidates by Friday.\nAnd lastly, the client demo is next week on Thursday at 2pm, and you're handling the intro slides.\n\nExample 2 — User asks: "summarize my emails"\n\nYou've got five new emails since this morning.\nTwo are from your team — Jordan sent the RFC you asked for, and Taylor flagged a contract issue that needs your sign-off.\nThere's a warm intro from a VC partner connecting you with an engineering lead at a potential customer.\nAnd someone from a prospective client wants to confirm your API tier before your call this afternoon.\nI've drafted replies for three of them — the metrics update, the intro, and the API question.\nThe only one I left for you is Taylor's contract redline, since that needs your judgment on the liability cap.\n\nExample 3 — User asks: "what's on my calendar today?"\n\nYou've got a packed day — seven meetings starting with standup at 9.\nThe highlights are your investor call at 11, lunch with a VC partner at 12:30, and a customer call at 4.\nYour only open block for deep work is 2:30 to 4, so plan accordingly.\nOh, and your 1-on-1 with your co-founder is at 5:30 — that's a walking meeting.\n\nExample 4 — User asks: "how are our metrics looking?"\n\nMetrics are looking strong this week.\nYou hit 2,573 weekly active users, which is up 12% week over week.\nThat means you've crossed the 2,500 milestone — worth calling out in your next investor update.\nChurn is down to 4.1%, improving month over month.\nThe trailing 8-week compound growth rate is about 10%.\n\nREMEMBER: Start with immediately. No preamble, no markdown before it. Speak first.`; } if (searchEnabled) { loopLogger.log('search enabled, injecting search prompt'); From 678e645bbc416df02199a9da5373886351d43f76 Mon Sep 17 00:00:00 2001 From: arkml <6592213+arkml@users.noreply.github.com> Date: Fri, 27 Mar 2026 23:28:38 +0530 Subject: [PATCH 3/9] Make voice input faster * make voice input faster * fix dependency bug * minor speed improvements * enter to submit --- apps/x/apps/renderer/src/App.tsx | 25 +++- .../x/apps/renderer/src/hooks/useVoiceMode.ts | 117 +++++++++++------- 2 files changed, 93 insertions(+), 49 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 976c09c0..f83ea5cb 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -703,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() @@ -760,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) { diff --git a/apps/x/apps/renderer/src/hooks/useVoiceMode.ts b/apps/x/apps/renderer/src/hooks/useVoiceMode.ts index 854ac9ea..ce4f7ccb 100644 --- a/apps/x/apps/renderer/src/hooks/useVoiceMode.ts +++ b/apps/x/apps/renderer/src/hooks/useVoiceMode.ts @@ -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('idle'); @@ -26,32 +31,54 @@ export function useVoiceMode() { const audioCtxRef = useRef(null); const transcriptBufferRef = useRef(''); const interimRef = useRef(''); + // Buffer audio chunks captured before the WebSocket is ready + const audioBufferRef = useRef([]); - // 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((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 }; } From 07d34471f557972f7d69c8cd3ce451a0b9f1848c Mon Sep 17 00:00:00 2001 From: arkml <6592213+arkml@users.noreply.github.com> Date: Fri, 27 Mar 2026 23:58:23 +0530 Subject: [PATCH 4/9] Remove brave (#452) * remove brave search * remove constraint on single search --- .../components/chat-input-with-mentions.tsx | 11 +- .../renderer/src/lib/chat-conversation.ts | 18 +-- apps/x/packages/core/src/agents/runtime.ts | 2 +- .../src/application/assistant/instructions.ts | 2 +- .../src/application/assistant/skills/index.ts | 8 +- .../core/src/application/lib/builtin-tools.ts | 116 +----------------- 6 files changed, 14 insertions(+), 143 deletions(-) diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 0c041351..03ab3f94 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -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() diff --git a/apps/x/apps/renderer/src/lib/chat-conversation.ts b/apps/x/apps/renderer/src/lib/chat-conversation.ts index 256de6d8..d92c124d 100644 --- a/apps/x/apps/renderer/src/lib/chat-conversation.ts +++ b/apps/x/apps/renderer/src/lib/chat-conversation.ts @@ -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 | undefined - const result = tool.result as Record | undefined - return { - query: (input?.query as string) || '', - results: (result?.results as WebSearchCardResult[]) || [], - } - } - - if (tool.name === 'research-search') { const input = normalizeToolInput(tool.input) as Record | undefined const result = tool.result as Record | 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`, } } diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index b7ebdfdf..b597a8d6 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -1059,7 +1059,7 @@ export async function* streamAgent({ } 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( diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index d39a0d63..a29b225f 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -209,7 +209,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. diff --git a/apps/x/packages/core/src/application/assistant/skills/index.ts b/apps/x/packages/core/src/application/assistant/skills/index.ts index f0b9186f..44774d6e 100644 --- a/apps/x/packages/core/src/application/assistant/skills/index.ts +++ b/apps/x/packages/core/src/application/assistant/skills/index.ts @@ -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", diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index 5275aaa9..069cf7ef 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -1026,123 +1026,15 @@ export const BuiltinTools: z.infer = { }, // ============================================================================ - // 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": "" }', - }; - } - - 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; @@ -1168,7 +1060,7 @@ export const BuiltinTools: z.infer = { highlights: true, }, }; - if (category) { + if (category && category !== 'general') { reqBody.category = category; } From 30e1785fe2d74332b58cebfac83762bed68f9693 Mon Sep 17 00:00:00 2001 From: arkml <6592213+arkml@users.noreply.github.com> Date: Sat, 28 Mar 2026 00:41:46 +0530 Subject: [PATCH 5/9] Chrome extension (#453) * added chrome extension * prepare chrome extension for web store submission * retention 7 days * gate chrome service with a flag --- apps/x/apps/main/src/main.ts | 4 + apps/x/packages/core/package.json | 4 + .../src/knowledge/chrome-extension/README.md | 96 +++ .../chrome-extension/extension/background.js | 388 +++++++++++ .../chrome-extension/extension/content.js | 81 +++ .../chrome-extension/extension/icon.png | Bin 0 -> 22928 bytes .../extension/icons/icon128.png | Bin 0 -> 3684 bytes .../extension/icons/icon16.png | Bin 0 -> 912 bytes .../extension/icons/icon32.png | Bin 0 -> 1196 bytes .../extension/icons/icon48.png | Bin 0 -> 1638 bytes .../chrome-extension/extension/manifest.json | 40 ++ .../chrome-extension/extension/popup.html | 174 +++++ .../chrome-extension/extension/popup.js | 258 +++++++ .../chrome-extension/extension/styles.css | 279 ++++++++ .../chrome-extension/server/server.ts | 281 ++++++++ apps/x/pnpm-lock.yaml | 644 ++++-------------- 16 files changed, 1738 insertions(+), 511 deletions(-) create mode 100644 apps/x/packages/core/src/knowledge/chrome-extension/README.md create mode 100644 apps/x/packages/core/src/knowledge/chrome-extension/extension/background.js create mode 100644 apps/x/packages/core/src/knowledge/chrome-extension/extension/content.js create mode 100644 apps/x/packages/core/src/knowledge/chrome-extension/extension/icon.png create mode 100644 apps/x/packages/core/src/knowledge/chrome-extension/extension/icons/icon128.png create mode 100644 apps/x/packages/core/src/knowledge/chrome-extension/extension/icons/icon16.png create mode 100644 apps/x/packages/core/src/knowledge/chrome-extension/extension/icons/icon32.png create mode 100644 apps/x/packages/core/src/knowledge/chrome-extension/extension/icons/icon48.png create mode 100644 apps/x/packages/core/src/knowledge/chrome-extension/extension/manifest.json create mode 100644 apps/x/packages/core/src/knowledge/chrome-extension/extension/popup.html create mode 100644 apps/x/packages/core/src/knowledge/chrome-extension/extension/popup.js create mode 100644 apps/x/packages/core/src/knowledge/chrome-extension/extension/styles.css create mode 100644 apps/x/packages/core/src/knowledge/chrome-extension/server/server.ts diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 3b8b70c8..d828f38d 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -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(); diff --git a/apps/x/packages/core/package.json b/apps/x/packages/core/package.json index 53495637..72d6f079 100644 --- a/apps/x/packages/core/package.json +++ b/apps/x/packages/core/package.json @@ -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" diff --git a/apps/x/packages/core/src/knowledge/chrome-extension/README.md b/apps/x/packages/core/src/knowledge/chrome-extension/README.md new file mode 100644 index 00000000..b7de8636 --- /dev/null +++ b/apps/x/packages/core/src/knowledge/chrome-extension/README.md @@ -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 diff --git a/apps/x/packages/core/src/knowledge/chrome-extension/extension/background.js b/apps/x/packages/core/src/knowledge/chrome-extension/extension/background.js new file mode 100644 index 00000000..235a292b --- /dev/null +++ b/apps/x/packages/core/src/knowledge/chrome-extension/extension/background.js @@ -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'); diff --git a/apps/x/packages/core/src/knowledge/chrome-extension/extension/content.js b/apps/x/packages/core/src/knowledge/chrome-extension/extension/content.js new file mode 100644 index 00000000..bae24963 --- /dev/null +++ b/apps/x/packages/core/src/knowledge/chrome-extension/extension/content.js @@ -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); +} diff --git a/apps/x/packages/core/src/knowledge/chrome-extension/extension/icon.png b/apps/x/packages/core/src/knowledge/chrome-extension/extension/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..e2fd6386b98cb11d2ba86fa6114c7e0f09bb65fb GIT binary patch literal 22928 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 literal 0 HcmV?d00001 diff --git a/apps/x/packages/core/src/knowledge/chrome-extension/extension/icons/icon128.png b/apps/x/packages/core/src/knowledge/chrome-extension/extension/icons/icon128.png new file mode 100644 index 0000000000000000000000000000000000000000..c8cc16e3fec5febfaeb10bded77c6dfc8248aa3b GIT binary patch literal 3684 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 N($vtw7pPeU{|};&eTV=6 literal 0 HcmV?d00001 diff --git a/apps/x/packages/core/src/knowledge/chrome-extension/extension/icons/icon16.png b/apps/x/packages/core/src/knowledge/chrome-extension/extension/icons/icon16.png new file mode 100644 index 0000000000000000000000000000000000000000..482631a1a327df0e235c160e5b8d4d953374387a GIT binary patch literal 912 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|DpDgn(|mmy zw18|51{Ovs23C*~Ahu(ahO^xmH5iz|;!F$-?U@WLP&Ix)8pK=%BH%Rh0!FB6px6Q? zxN3m~%m_9}pD>G9ERf=?Nk^)#sNw%$0gl~X?bAC~(f|;Iy zo`I4bmx6+VO;JjkRgjAtR6CGotCUevQedU8UtV6WS8lAAUzDzIXlZGwZ(yWvWTXpJ zp<7&;SCUwvn^&w1F$89gOKNd)QD#9&W`3Rm$lS!F{L&IzB_)tWZ~%2@ZUNk)yke-Y zfd133NG#Ad)HBeBn+()w1N5Vnb5UwyNq$jCetwP}$Z45**~Q6;1*sqh+UTRJfLdvz z4{`^RyC9whivoRZ#|6|03qd=sO(&BSf#L1w>Eak-A-FVnZ&$N}z&&eSA>Aw2lzbg> zLTb9S&k8TuKDD~t##zAnqh!d3!XxM096x?|RyOUnz*#pY$saT3&VN5U^@eJTK&R58 z9}64A8~2`Y)U)2>74v-|$Iqn^;w>8!B_3GtWz0JE`ovwe=xMBhB98N(2V6JhIn1!` zbrIv2B*y#S6@7zW?BaX3HdE?C>EE3P^0qghetJmluJF&DI;X3RPE@ewwI6O&@pQ5| zuRP_zW$t*9qe{mbG_|^z5+x43EJ^a-lq6?qVMr+ zk7=ok`=o-36<(w&b1`JM9`?7p=g-v|<(4x8Jt&cYK(?qa|JK zw2`6jZlo#ZEMc-x%z(Q;9}!LEp=8OvTS{5GHAkFV^}=_{D;AF?^WNa%d+_?0g?k67_WTb6Mw<&;$SvqAimE literal 0 HcmV?d00001 diff --git a/apps/x/packages/core/src/knowledge/chrome-extension/extension/icons/icon32.png b/apps/x/packages/core/src/knowledge/chrome-extension/extension/icons/icon32.png new file mode 100644 index 0000000000000000000000000000000000000000..2b95aa629f311be7df1f1acd954560532de27cc3 GIT binary patch literal 1196 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 Jl)J}>{{cg&ldu2) literal 0 HcmV?d00001 diff --git a/apps/x/packages/core/src/knowledge/chrome-extension/extension/manifest.json b/apps/x/packages/core/src/knowledge/chrome-extension/extension/manifest.json new file mode 100644 index 00000000..f536adec --- /dev/null +++ b/apps/x/packages/core/src/knowledge/chrome-extension/extension/manifest.json @@ -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" + } + ] +} diff --git a/apps/x/packages/core/src/knowledge/chrome-extension/extension/popup.html b/apps/x/packages/core/src/knowledge/chrome-extension/extension/popup.html new file mode 100644 index 00000000..b8d55d71 --- /dev/null +++ b/apps/x/packages/core/src/knowledge/chrome-extension/extension/popup.html @@ -0,0 +1,174 @@ + + + + + + Rowboat + + + + +
+ - + + + - + +
+ + + + + + + +
+
Settings
+
+ + +
+
+ +
+ - +
+ + + + diff --git a/apps/x/packages/core/src/knowledge/chrome-extension/extension/popup.js b/apps/x/packages/core/src/knowledge/chrome-extension/extension/popup.js new file mode 100644 index 00000000..6a3fc0b1 --- /dev/null +++ b/apps/x/packages/core/src/knowledge/chrome-extension/extension/popup.js @@ -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); +}); diff --git a/apps/x/packages/core/src/knowledge/chrome-extension/extension/styles.css b/apps/x/packages/core/src/knowledge/chrome-extension/extension/styles.css new file mode 100644 index 00000000..399c473b --- /dev/null +++ b/apps/x/packages/core/src/knowledge/chrome-extension/extension/styles.css @@ -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; } diff --git a/apps/x/packages/core/src/knowledge/chrome-extension/server/server.ts b/apps/x/packages/core/src/knowledge/chrome-extension/server/server.ts new file mode 100644 index 00000000..0cb127b9 --- /dev/null +++ b/apps/x/packages/core/src/knowledge/chrome-extension/server/server.ts @@ -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(); + +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 = {}; + + 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 { + 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(); + } + }); +} diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index c4e701a5..e59ce990 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -53,9 +53,6 @@ importers: electron-squirrel-startup: specifier: ^1.0.1 version: 1.0.1 - html-to-docx: - specifier: ^1.8.0 - version: 1.8.0(encoding@0.1.13) mammoth: specifier: ^1.11.0 version: 1.11.0 @@ -238,9 +235,6 @@ importers: react-dom: specifier: ^19.2.0 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) sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -310,16 +304,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 +328,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 +340,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 +399,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 +423,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 +435,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 +465,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 @@ -1572,46 +1578,6 @@ packages: '@octokit/types@6.41.0': resolution: {integrity: sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg==} - '@oozcitak/dom@1.15.5': - resolution: {integrity: sha512-L6v3Mwb0TaYBYgeYlIeBaHnc+2ZEaDSbFiRm5KmqZQSoBlbPlf+l6aIH/sD5GUf2MYwULw00LT7+dOnEuAEC0A==} - engines: {node: '>=8.0'} - - '@oozcitak/dom@1.15.6': - resolution: {integrity: sha512-k4uEIa6DI3FCrFJMGq/05U/59WnS9DjME0kaPqBRCJAqBTkmopbYV1Xs4qFKbDJ/9wOg8W97p+1E0heng/LH7g==} - engines: {node: '>=8.0'} - - '@oozcitak/infra@1.0.3': - resolution: {integrity: sha512-9O2wxXGnRzy76O1XUxESxDGsXT5kzETJPvYbreO4mv6bqe1+YSuux2cZTagjJ/T4UfEwFJz5ixanOqB0QgYAag==} - engines: {node: '>=6.0'} - - '@oozcitak/infra@1.0.5': - resolution: {integrity: sha512-o+zZH7M6l5e3FaAWy3ojaPIVN5eusaYPrKm6MZQt0DKNdgXa2wDYExjpP0t/zx+GoQgQKzLu7cfD8rHCLt8JrQ==} - engines: {node: '>=6.0'} - - '@oozcitak/url@1.0.0': - resolution: {integrity: sha512-LGrMeSxeLzsdaitxq3ZmBRVOrlRRQIgNNci6L0VRnOKlJFuRIkNm4B+BObXPCJA6JT5bEJtrrwjn30jueHJYZQ==} - engines: {node: '>=8.0'} - - '@oozcitak/util@1.0.1': - resolution: {integrity: sha512-dFwFqcKrQnJ2SapOmRD1nQWEZUtbtIy9Y6TyJquzsalWNJsKIPxmTI0KG6Ypyl8j7v89L2wixH9fQDNrF78hKg==} - engines: {node: '>=6.0'} - - '@oozcitak/util@1.0.2': - resolution: {integrity: sha512-4n8B1cWlJleSOSba5gxsMcN4tO8KkkcvXhNWW+ADqvq9Xj+Lrl9uCa90GRpjekqQJyt84aUX015DG81LFpZYXA==} - engines: {node: '>=6.0'} - - '@oozcitak/util@8.0.0': - resolution: {integrity: sha512-+9Hq6yuoq/3TRV/n/xcpydGBq2qN2/DEDMqNTG7rm95K6ZE2/YY/sPyx62+1n8QsE9O26e5M1URlXsk+AnN9Jw==} - engines: {node: '>=6.0'} - - '@oozcitak/util@8.3.3': - resolution: {integrity: sha512-Ufpab7G5PfnEhQyy5kDg9C8ltWJjsVT1P/IYqacjstaqydG4Q21HAT2HUZQYBrC/a1ZLKCz87pfydlDvv8y97w==} - engines: {node: '>=6.0'} - - '@oozcitak/util@8.3.4': - resolution: {integrity: sha512-6gH/bLQJSJEg7OEpkH4wGQdA8KXHRbzL1YkGyUO12YNAgV3jxKy4K9kvfXj4+9T0OLug5k58cnPCKSSIKzp7pg==} - engines: {node: '>=8.0'} - '@openrouter/ai-sdk-provider@1.5.4': resolution: {integrity: sha512-xrSQPUIH8n9zuyYZR0XK7Ba0h2KsjJcMkxnwaYfmv13pKs3sDkjPzVPPhlhzqBGddHb5cFEwJ9VFuFeDcxCDSw==} engines: {node: '>=18'} @@ -2550,17 +2516,6 @@ packages: '@react-pdf/types@2.9.2': resolution: {integrity: sha512-dufvpKId9OajLLbgn9q7VLUmyo1Jf+iyGk2ZHmCL8nIDtL8N1Ejh9TH7+pXXrR0tdie1nmnEb5Bz9U7g4hI4/g==} - '@reduxjs/toolkit@2.11.2': - resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} - peerDependencies: - react: ^16.9.0 || ^17.0.0 || ^18 || ^19 - react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 - peerDependenciesMeta: - react: - optional: true - react-redux: - optional: true - '@remirror/core-constants@3.0.0': resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} @@ -2921,9 +2876,6 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@standard-schema/utils@0.3.0': - resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} - '@swc/helpers@0.5.18': resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==} @@ -3221,9 +3173,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 +3296,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 +3314,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 +3368,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 +3385,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 +3599,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 @@ -3793,9 +3775,6 @@ packages: brotli@1.3.3: resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==} - browser-split@0.0.1: - resolution: {integrity: sha512-JhvgRb2ihQhsljNda3BI8/UcRHVzrVwo3Q+P8vDtSiyobXuFpuZ9mq+MbRGMnC22CjW3RrfXdg6j6ITX8M+7Ow==} - browserify-zlib@0.2.0: resolution: {integrity: sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==} @@ -3857,9 +3836,6 @@ packages: camel-case@4.1.2: resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} - camelize@1.0.1: - resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==} - caniuse-lite@1.0.30001761: resolution: {integrity: sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==} @@ -4057,8 +4033,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: @@ -4287,9 +4263,6 @@ packages: supports-color: optional: true - decimal.js-light@2.5.1: - resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} - decode-named-character-reference@1.2.0: resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} @@ -4358,24 +4331,12 @@ packages: dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} - dom-serializer@0.2.2: - resolution: {integrity: sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==} - dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} - dom-walk@0.1.2: - resolution: {integrity: sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==} - - domelementtype@1.3.1: - resolution: {integrity: sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==} - domelementtype@2.3.0: resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} - domhandler@2.4.2: - resolution: {integrity: sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==} - domhandler@5.0.3: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} @@ -4383,9 +4344,6 @@ packages: dompurify@3.3.1: resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==} - domutils@1.7.0: - resolution: {integrity: sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==} - domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -4470,16 +4428,6 @@ packages: resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} engines: {node: '>=10.13.0'} - ent@2.2.2: - resolution: {integrity: sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw==} - engines: {node: '>= 0.4'} - - entities@1.1.2: - resolution: {integrity: sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==} - - entities@2.2.0: - resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} - entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -4498,9 +4446,6 @@ packages: error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} - error@4.4.0: - resolution: {integrity: sha512-SNDKualLUtT4StGFP7xNfuFybL2f6iJujFtrWuvJqGbVQGaN+adE23veqzPz1hjUjTunLi2EnJ+0SJxtbJreKw==} - es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -4520,9 +4465,6 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} - es-toolkit@1.45.1: - resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==} - es6-error@4.1.1: resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} @@ -4623,9 +4565,6 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} - ev-store@7.0.0: - resolution: {integrity: sha512-otazchNRnGzp2YarBJ+GXKVGvhxVATB1zmaStxJBYet0Dyq7A9VhH8IUEB/gRcL6Ch52lfpgPTRJ2m49epyMsQ==} - event-target-shim@5.0.1: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} @@ -4945,7 +4884,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 +4892,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==} @@ -4969,9 +4907,6 @@ packages: resolution: {integrity: sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==} engines: {node: '>=10'} - global@4.4.0: - resolution: {integrity: sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==} - globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -5115,24 +5050,12 @@ packages: hsl-to-rgb-for-reals@1.1.1: resolution: {integrity: sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==} - html-entities@2.6.0: - resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} - - html-to-docx@1.8.0: - resolution: {integrity: sha512-IiMBWIqXM4+cEsW//RKoonWV7DlXAJBmmKI73XJSVWTIXjGUaxSr2ck1jqzVRZknpvO8xsFnVicldKVAWrBYBA==} - - html-to-vdom@0.7.0: - resolution: {integrity: sha512-k+d2qNkbx0JO00KezQsNcn6k2I/xSBP4yXYFLvXbcasTTDh+RDLUJS3puxqyNnpdyXWRHFGoKU7cRmby8/APcQ==} - html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} - htmlparser2@3.10.1: - resolution: {integrity: sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==} - http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} @@ -5190,23 +5113,9 @@ packages: engines: {node: '>=6.9.0'} hasBin: true - image-size@1.2.1: - resolution: {integrity: sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==} - engines: {node: '>=16.x'} - hasBin: true - - image-to-base64@2.2.0: - resolution: {integrity: sha512-Z+aMwm/91UOQqHhrz7Upre2ytKhWejZlWV/JxUTD1sT7GWWKFDJUEV5scVQKnkzSgPHFuQBUEWcanO+ma0PSVw==} - immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} - immer@10.2.0: - resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} - - immer@11.1.4: - resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==} - import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -5223,9 +5132,6 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} - individual@3.0.0: - resolution: {integrity: sha512-rUY5vtT748NMRbEMrTNiFfy29BgGZwGXUi2NFUVMWQrogSLzlJvQV9eeMWi+g1aVaQ53tpyLAQtd5x/JH0Nh1g==} - infer-owner@1.0.4: resolution: {integrity: sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==} @@ -5326,9 +5232,6 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - is-object@1.0.2: - resolution: {integrity: sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==} - is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -5339,10 +5242,6 @@ packages: is-property@1.0.2: resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} - is-regex@1.2.1: - resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} - engines: {node: '>= 0.4'} - is-stream@1.1.0: resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==} engines: {node: '>=0.10.0'} @@ -5937,9 +5836,6 @@ packages: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} - min-document@2.19.2: - resolution: {integrity: sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==} - minimatch@10.1.1: resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} engines: {node: 20 || >=22} @@ -6069,9 +5965,6 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - next-tick@0.2.2: - resolution: {integrity: sha512-f7h4svPtl+QidoBv4taKXUjJ70G2asaZ8G28nS0OkqaalX8dwwrtWtyxEDPK62AC00ur/+/E0pUwBwY5EPn15Q==} - nice-try@1.0.5: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} @@ -6532,9 +6425,6 @@ packages: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} - punycode@1.4.1: - resolution: {integrity: sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==} - punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -6594,18 +6484,6 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - react-redux@9.2.0: - resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} - peerDependencies: - '@types/react': ^18.2.25 || ^19 - react: ^18.0 || ^19 - redux: ^5.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - redux: - optional: true - react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} @@ -6671,26 +6549,10 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} - recharts@3.8.0: - resolution: {integrity: sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==} - engines: {node: '>=18'} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - rechoir@0.8.0: resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} engines: {node: '>= 10.13.0'} - redux-thunk@3.1.0: - resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} - peerDependencies: - redux: ^5.0.0 - - redux@5.0.1: - resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} - regex-recursion@6.0.2: resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} @@ -6763,9 +6625,6 @@ packages: resolution: {integrity: sha512-oTeemxwoMuxxTYxXUwjkrOPfngTQehlv0/HoYFNkB4uzsP1Un1A9nI8JQKGOFkxpqkC7qkMs0lUsGrvUlbLNUA==} engines: {node: '>=14', npm: '>=7'} - reselect@5.1.1: - resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} - resolve-alpn@1.2.1: resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} @@ -6854,10 +6713,6 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - safe-regex-test@1.1.0: - resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} - engines: {node: '>= 0.4'} - safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -7053,9 +6908,6 @@ packages: peerDependencies: react: ^18.0.0 || ^19.0.0 - string-template@0.2.1: - resolution: {integrity: sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw==} - string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -7141,7 +6993,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==} @@ -7174,9 +7026,6 @@ packages: tiny-inflate@1.0.3: resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} - tiny-invariant@1.3.3: - resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} - tinyexec@1.0.2: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} @@ -7306,8 +7155,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==} @@ -7448,12 +7297,6 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - victory-vendor@37.3.6: - resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} - - virtual-dom@2.1.1: - resolution: {integrity: sha512-wb6Qc9Lbqug0kRqo/iuApfBpJJAq14Sk1faAnSmtqXiwahg7PVTvWMs9L02Z8nNIMqbwsxzBAA90bbtRLbw0zg==} - vite-compatible-readable-stream@3.6.1: resolution: {integrity: sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==} engines: {node: '>= 6'} @@ -7603,21 +7446,11 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - x-is-array@0.1.0: - resolution: {integrity: sha512-goHPif61oNrr0jJgsXRfc8oqtYzvfiMJpTqwE7Z4y9uH+T3UozkGqQ4d2nX9mB9khvA8U2o/UbPOFjgC7hLWIA==} - - x-is-string@0.1.0: - resolution: {integrity: sha512-GojqklwG8gpzOVEVki5KudKNoq7MbbjYZCbyWzEz7tyPA7eleiE0+ePwOWQQRb5fm86rD3S8Tc0tSFf3AOv50w==} - xlsx@0.18.5: resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} engines: {node: '>=0.8'} hasBin: true - xmlbuilder2@2.1.2: - resolution: {integrity: sha512-PI710tmtVlQ5VmwzbRTuhmVhKnj9pM8Si+iOZCV2g2SNo3gCrpzR2Ka9wNzZtqfD+mnP+xkrqoNy0sjKZqP4Dg==} - engines: {node: '>=8.0'} - xmlbuilder@10.1.1: resolution: {integrity: sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==} engines: {node: '>=4.0'} @@ -7694,10 +7527,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 +7540,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 +7572,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 +9073,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 @@ -9397,45 +9230,10 @@ snapshots: dependencies: '@octokit/openapi-types': 12.11.0 - '@oozcitak/dom@1.15.5': - dependencies: - '@oozcitak/infra': 1.0.5 - '@oozcitak/url': 1.0.0 - '@oozcitak/util': 8.0.0 - - '@oozcitak/dom@1.15.6': - dependencies: - '@oozcitak/infra': 1.0.5 - '@oozcitak/url': 1.0.0 - '@oozcitak/util': 8.3.4 - - '@oozcitak/infra@1.0.3': - dependencies: - '@oozcitak/util': 1.0.1 - - '@oozcitak/infra@1.0.5': - dependencies: - '@oozcitak/util': 8.0.0 - - '@oozcitak/url@1.0.0': - dependencies: - '@oozcitak/infra': 1.0.3 - '@oozcitak/util': 1.0.2 - - '@oozcitak/util@1.0.1': {} - - '@oozcitak/util@1.0.2': {} - - '@oozcitak/util@8.0.0': {} - - '@oozcitak/util@8.3.3': {} - - '@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': @@ -10461,18 +10259,6 @@ snapshots: '@react-pdf/primitives': 4.1.1 '@react-pdf/stylesheet': 6.1.2 - '@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)': - dependencies: - '@standard-schema/spec': 1.1.0 - '@standard-schema/utils': 0.3.0 - immer: 11.1.4 - redux: 5.0.1 - redux-thunk: 3.1.0(redux@5.0.1) - reselect: 5.1.1 - optionalDependencies: - react: 19.2.3 - react-redux: 9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1) - '@remirror/core-constants@3.0.0': {} '@rolldown/pluginutils@1.0.0-beta.53': {} @@ -10918,8 +10704,6 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@standard-schema/utils@0.3.0': {} - '@swc/helpers@0.5.18': dependencies: tslib: 2.8.1 @@ -11232,6 +11016,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 +11028,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 +11175,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 +11201,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 +11259,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 +11275,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 +11543,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 @@ -11904,8 +11729,6 @@ snapshots: dependencies: base64-js: 1.5.1 - browser-split@0.0.1: {} - browserify-zlib@0.2.0: dependencies: pako: 1.0.11 @@ -12000,8 +11823,6 @@ snapshots: pascal-case: 3.1.2 tslib: 2.8.1 - camelize@1.0.1: {} - caniuse-lite@1.0.30001761: {} ccount@2.0.1: {} @@ -12173,7 +11994,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 @@ -12422,8 +12243,6 @@ snapshots: dependencies: ms: 2.1.3 - decimal.js-light@2.5.1: {} - decode-named-character-reference@1.2.0: dependencies: character-entities: 2.0.2 @@ -12487,27 +12306,14 @@ snapshots: minimatch: 3.1.2 p-limit: 3.1.0 - dom-serializer@0.2.2: - dependencies: - domelementtype: 2.3.0 - entities: 2.2.0 - dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 domhandler: 5.0.3 entities: 4.5.0 - dom-walk@0.1.2: {} - - domelementtype@1.3.1: {} - domelementtype@2.3.0: {} - domhandler@2.4.2: - dependencies: - domelementtype: 1.3.1 - domhandler@5.0.3: dependencies: domelementtype: 2.3.0 @@ -12516,11 +12322,6 @@ snapshots: optionalDependencies: '@types/trusted-types': 2.0.7 - domutils@1.7.0: - dependencies: - dom-serializer: 0.2.2 - domelementtype: 1.3.1 - domutils@3.2.2: dependencies: dom-serializer: 2.0.0 @@ -12536,7 +12337,7 @@ snapshots: duck@0.1.12: dependencies: - underscore: 1.13.7 + underscore: 1.13.8 dunder-proto@1.0.1: dependencies: @@ -12661,17 +12462,6 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 - ent@2.2.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - punycode: 1.4.1 - safe-regex-test: 1.1.0 - - entities@1.1.2: {} - - entities@2.2.0: {} - entities@4.5.0: {} entities@6.0.1: {} @@ -12684,12 +12474,6 @@ snapshots: dependencies: is-arrayish: 0.2.1 - error@4.4.0: - dependencies: - camelize: 1.0.1 - string-template: 0.2.1 - xtend: 4.0.2 - es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -12707,8 +12491,6 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 - es-toolkit@1.45.1: {} - es6-error@4.1.1: optional: true @@ -12873,10 +12655,6 @@ snapshots: etag@1.8.1: {} - ev-store@7.0.0: - dependencies: - individual: 3.0.0 - event-target-shim@5.0.1: {} eventemitter3@5.0.1: {} @@ -13320,11 +13098,6 @@ snapshots: dependencies: ini: 2.0.0 - global@4.4.0: - dependencies: - min-document: 2.19.2 - process: 0.11.10 - globals@14.0.0: {} globals@16.5.0: {} @@ -13571,44 +13344,10 @@ snapshots: hsl-to-rgb-for-reals@1.1.1: {} - html-entities@2.6.0: {} - - html-to-docx@1.8.0(encoding@0.1.13): - dependencies: - '@oozcitak/dom': 1.15.6 - '@oozcitak/util': 8.3.4 - color-name: 1.1.4 - html-entities: 2.6.0 - html-to-vdom: 0.7.0 - image-size: 1.2.1 - image-to-base64: 2.2.0(encoding@0.1.13) - jszip: 3.10.1 - lodash: 4.17.21 - mime-types: 2.1.35 - nanoid: 3.3.11 - virtual-dom: 2.1.1 - xmlbuilder2: 2.1.2 - transitivePeerDependencies: - - encoding - - html-to-vdom@0.7.0: - dependencies: - ent: 2.2.2 - htmlparser2: 3.10.1 - html-url-attributes@3.0.1: {} html-void-elements@3.0.0: {} - htmlparser2@3.10.1: - dependencies: - domelementtype: 1.3.1 - domhandler: 2.4.2 - domutils: 1.7.0 - entities: 1.1.2 - inherits: 2.0.4 - readable-stream: 3.6.2 - http-cache-semantics@4.2.0: {} http-errors@2.0.1: @@ -13673,22 +13412,8 @@ snapshots: image-size@0.7.5: optional: true - image-size@1.2.1: - dependencies: - queue: 6.0.2 - - image-to-base64@2.2.0(encoding@0.1.13): - dependencies: - node-fetch: 2.7.0(encoding@0.1.13) - transitivePeerDependencies: - - encoding - immediate@3.0.6: {} - immer@10.2.0: {} - - immer@11.1.4: {} - import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -13701,8 +13426,6 @@ snapshots: indent-string@4.0.0: {} - individual@3.0.0: {} - infer-owner@1.0.4: {} inflight@1.0.6: @@ -13777,8 +13500,6 @@ snapshots: is-number@7.0.0: {} - is-object@1.0.2: {} - is-plain-obj@4.1.0: {} is-promise@4.0.0: {} @@ -13786,13 +13507,6 @@ snapshots: is-property@1.0.2: optional: true - is-regex@1.2.1: - dependencies: - call-bound: 1.0.4 - gopd: 1.2.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - is-stream@1.1.0: {} is-stream@2.0.1: {} @@ -14078,7 +13792,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 +13861,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: @@ -14638,10 +14352,6 @@ snapshots: mimic-response@3.1.0: {} - min-document@2.19.2: - dependencies: - dom-walk: 0.1.2 - minimatch@10.1.1: dependencies: '@isaacs/brace-expansion': 5.0.0 @@ -14757,8 +14467,6 @@ snapshots: neo-async@2.6.2: {} - next-tick@0.2.2: {} - nice-try@1.0.5: {} no-case@3.0.4: @@ -15247,8 +14955,6 @@ snapshots: punycode.js@2.3.1: {} - punycode@1.4.1: {} - punycode@2.3.1: {} pusher-js@8.4.0: @@ -15358,15 +15064,6 @@ snapshots: react-is@16.13.1: {} - react-redux@9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1): - dependencies: - '@types/use-sync-external-store': 0.0.6 - react: 19.2.3 - use-sync-external-store: 1.6.0(react@19.2.3) - optionalDependencies: - '@types/react': 19.2.7 - redux: 5.0.1 - react-refresh@0.18.0: {} react-remove-scroll-bar@2.3.8(@types/react@19.2.7)(react@19.2.3): @@ -15441,36 +15138,10 @@ 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): - 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 - decimal.js-light: 2.5.1 - es-toolkit: 1.45.1 - eventemitter3: 5.0.1 - immer: 10.2.0 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - react-is: 16.13.1 - react-redux: 9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1) - reselect: 5.1.1 - tiny-invariant: 1.3.3 - use-sync-external-store: 1.6.0(react@19.2.3) - victory-vendor: 37.3.6 - transitivePeerDependencies: - - '@types/react' - - redux - rechoir@0.8.0: dependencies: resolve: 1.22.11 - redux-thunk@3.1.0(redux@5.0.1): - dependencies: - redux: 5.0.1 - - redux@5.0.1: {} - regex-recursion@6.0.2: dependencies: regex-utilities: 2.3.0 @@ -15577,8 +15248,6 @@ snapshots: dependencies: pe-library: 1.0.1 - reselect@5.1.1: {} - resolve-alpn@1.2.1: {} resolve-from@4.0.0: {} @@ -15697,12 +15366,6 @@ snapshots: safe-buffer@5.2.1: {} - safe-regex-test@1.1.0: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-regex: 1.2.1 - safer-buffer@2.1.2: {} scheduler@0.25.0-rc-603e6108-20241029: {} @@ -15953,8 +15616,6 @@ snapshots: - micromark-util-types - supports-color - string-template@0.2.1: {} - string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -16072,8 +15733,6 @@ snapshots: tiny-inflate@1.0.3: {} - tiny-invariant@1.3.3: {} - tinyexec@1.0.2: {} tinyglobby@0.2.15: @@ -16194,7 +15853,7 @@ snapshots: ufo@1.6.1: {} - underscore@1.13.7: {} + underscore@1.13.8: {} undici-types@6.21.0: {} @@ -16345,34 +16004,6 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - victory-vendor@37.3.6: - dependencies: - '@types/d3-array': 3.2.2 - '@types/d3-ease': 3.0.2 - '@types/d3-interpolate': 3.0.4 - '@types/d3-scale': 4.0.9 - '@types/d3-shape': 3.1.7 - '@types/d3-time': 3.0.4 - '@types/d3-timer': 3.0.2 - d3-array: 3.2.4 - d3-ease: 3.0.1 - d3-interpolate: 3.0.1 - d3-scale: 4.0.2 - d3-shape: 3.2.0 - d3-time: 3.1.0 - d3-timer: 3.0.1 - - virtual-dom@2.1.1: - dependencies: - browser-split: 0.0.1 - error: 4.4.0 - ev-store: 7.0.0 - global: 4.4.0 - is-object: 1.0.2 - next-tick: 0.2.2 - x-is-array: 0.1.0 - x-is-string: 0.1.0 - vite-compatible-readable-stream@3.6.1: dependencies: inherits: 2.0.4 @@ -16524,10 +16155,6 @@ snapshots: wrappy@1.0.2: {} - x-is-array@0.1.0: {} - - x-is-string@0.1.0: {} - xlsx@0.18.5: dependencies: adler-32: 1.3.1 @@ -16538,17 +16165,12 @@ snapshots: wmf: 1.0.2 word: 0.3.0 - xmlbuilder2@2.1.2: - dependencies: - '@oozcitak/dom': 1.15.5 - '@oozcitak/infra': 1.0.5 - '@oozcitak/util': 8.3.3 - xmlbuilder@10.1.1: {} xmlbuilder@15.1.1: {} - xtend@4.0.2: {} + xtend@4.0.2: + optional: true y18n@5.0.8: {} From 86cc2aaf7340afaad5ca8cf3a5d695c8291a6b01 Mon Sep 17 00:00:00 2001 From: arkml <6592213+arkml@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:31:49 +0530 Subject: [PATCH 6/9] Meeting notes2 (#454) Improve meeting transcription: screen recording permissions, collapsible transcript block --- apps/x/apps/main/forge.config.cjs | 3 + apps/x/apps/main/src/ipc.ts | 20 +- apps/x/apps/renderer/src/App.tsx | 76 +-- .../src/components/markdown-editor.tsx | 4 + .../src/extensions/transcript-block.tsx | 177 +++++++ .../src/hooks/useMeetingTranscription.ts | 161 +++--- apps/x/apps/renderer/src/styles/editor.css | 77 ++- .../core/src/knowledge/summarize_meeting.ts | 3 +- apps/x/packages/shared/src/blocks.ts | 6 + apps/x/packages/shared/src/ipc.ts | 10 + apps/x/pnpm-lock.yaml | 464 +++++++++++++++++- 11 files changed, 885 insertions(+), 116 deletions(-) create mode 100644 apps/x/apps/renderer/src/extensions/transcript-block.tsx diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index c79a8c43..178cb7e1 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -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: () => ({ diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index b92e3fe9..0fa0de79 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -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'; @@ -719,6 +719,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 }; diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index f83ea5cb..b2bc9d7f 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -484,7 +484,7 @@ function FixedSidebarToggle({ )} style={{ marginLeft: TITLEBAR_BUTTON_GAP_PX }} > - {meetingSummarizing ? ( + {meetingSummarizing || meetingState === 'connecting' ? ( ) : meetingState === 'recording' ? ( @@ -494,7 +494,7 @@ function FixedSidebarToggle({ - {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'} )} @@ -3417,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) @@ -3429,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() @@ -3450,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, @@ -3477,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 @@ -4421,23 +4435,25 @@ function App() { - Meeting transcription setup + Screen recording permission required - Rowboat needs Screen Recording permission to capture meeting audio from other apps (Zoom, Meet, etc.). + Rowboat needs Screen Recording permission to capture meeting audio from other apps (Zoom, Meet, etc.). This feature won't work without it.

To enable this:

    -
  1. Open System SettingsPrivacy & Security
  2. -
  3. Click Screen Recording
  4. +
  5. Open System SettingsPrivacy & SecurityScreen Recording
  6. Toggle on Rowboat
  7. You may need to restart the app after granting permission
- + +
diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index 2592dec3..f3ccba2d 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -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' @@ -155,6 +156,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 +570,7 @@ export function MarkdownEditor({ TableBlockExtension, CalendarBlockExtension, EmailBlockExtension, + TranscriptBlockExtension, WikiLink.configure({ onCreate: wikiLinks?.onCreate ? (path) => { diff --git a/apps/x/apps/renderer/src/extensions/transcript-block.tsx b/apps/x/apps/renderer/src/extensions/transcript-block.tsx new file mode 100644 index 00000000..9b76f568 --- /dev/null +++ b/apps/x/apps/renderer/src/extensions/transcript-block.tsx @@ -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 } + 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 ( + +
+ + Invalid transcript block +
+
+ ) + } + + return ( + +
e.stopPropagation()}> + + {expanded && ( +
+ {entries.length > 0 ? ( + entries.map((entry, i) => ( +
+ + {entry.speaker} + + {entry.text} +
+ )) + ) : ( +
{config.transcript}
+ )} +
+ )} +
+
+ ) +} + +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 }) { + 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: {}, + }, + } + }, +}) diff --git a/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts b/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts index 35a0a703..50d89a57 100644 --- a/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts +++ b/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts @@ -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((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((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 ----- diff --git a/apps/x/apps/renderer/src/styles/editor.css b/apps/x/apps/renderer/src/styles/editor.css index 8701099b..efa481c1 100644 --- a/apps/x/apps/renderer/src/styles/editor.css +++ b/apps/x/apps/renderer/src/styles/editor.css @@ -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); } @@ -1488,6 +1491,74 @@ margin-left: 4px; } +/* Transcript block */ +.tiptap-editor .ProseMirror .transcript-block-toggle { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 0; + font-size: 13px; + font-weight: 500; + color: color-mix(in srgb, var(--foreground) 60%, transparent); + background: none; + border: none; + cursor: pointer; + transition: color 0.12s ease; +} + +.tiptap-editor .ProseMirror .transcript-block-toggle:hover { + color: var(--foreground); +} + +.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 */ .meeting-event-banner { position: relative; diff --git a/apps/x/packages/core/src/knowledge/summarize_meeting.ts b/apps/x/packages/core/src/knowledge/summarize_meeting.ts index 534b6655..30e3c5d4 100644 --- a/apps/x/packages/core/src/knowledge/summarize_meeting.ts +++ b/apps/x/packages/core/src/knowledge/summarize_meeting.ts @@ -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. diff --git a/apps/x/packages/shared/src/blocks.ts b/apps/x/packages/shared/src/blocks.ts index 55d1cd3e..68209051 100644 --- a/apps/x/packages/shared/src/blocks.ts +++ b/apps/x/packages/shared/src/blocks.ts @@ -74,3 +74,9 @@ export const EmailBlockSchema = z.object({ }); export type EmailBlock = z.infer; + +export const TranscriptBlockSchema = z.object({ + transcript: z.string(), +}); + +export type TranscriptBlock = z.infer; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 5f4988f4..28718db3 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -501,6 +501,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(), diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index e59ce990..01a9240f 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: electron-squirrel-startup: specifier: ^1.0.1 version: 1.0.1 + html-to-docx: + specifier: ^1.8.0 + version: 1.8.0(encoding@0.1.13) mammoth: specifier: ^1.11.0 version: 1.11.0 @@ -235,6 +238,9 @@ importers: react-dom: specifier: ^19.2.0 version: 19.2.3(react@19.2.3) + recharts: + specifier: ^3.8.0 + 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) @@ -1578,6 +1584,46 @@ packages: '@octokit/types@6.41.0': resolution: {integrity: sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg==} + '@oozcitak/dom@1.15.5': + resolution: {integrity: sha512-L6v3Mwb0TaYBYgeYlIeBaHnc+2ZEaDSbFiRm5KmqZQSoBlbPlf+l6aIH/sD5GUf2MYwULw00LT7+dOnEuAEC0A==} + engines: {node: '>=8.0'} + + '@oozcitak/dom@1.15.6': + resolution: {integrity: sha512-k4uEIa6DI3FCrFJMGq/05U/59WnS9DjME0kaPqBRCJAqBTkmopbYV1Xs4qFKbDJ/9wOg8W97p+1E0heng/LH7g==} + engines: {node: '>=8.0'} + + '@oozcitak/infra@1.0.3': + resolution: {integrity: sha512-9O2wxXGnRzy76O1XUxESxDGsXT5kzETJPvYbreO4mv6bqe1+YSuux2cZTagjJ/T4UfEwFJz5ixanOqB0QgYAag==} + engines: {node: '>=6.0'} + + '@oozcitak/infra@1.0.5': + resolution: {integrity: sha512-o+zZH7M6l5e3FaAWy3ojaPIVN5eusaYPrKm6MZQt0DKNdgXa2wDYExjpP0t/zx+GoQgQKzLu7cfD8rHCLt8JrQ==} + engines: {node: '>=6.0'} + + '@oozcitak/url@1.0.0': + resolution: {integrity: sha512-LGrMeSxeLzsdaitxq3ZmBRVOrlRRQIgNNci6L0VRnOKlJFuRIkNm4B+BObXPCJA6JT5bEJtrrwjn30jueHJYZQ==} + engines: {node: '>=8.0'} + + '@oozcitak/util@1.0.1': + resolution: {integrity: sha512-dFwFqcKrQnJ2SapOmRD1nQWEZUtbtIy9Y6TyJquzsalWNJsKIPxmTI0KG6Ypyl8j7v89L2wixH9fQDNrF78hKg==} + engines: {node: '>=6.0'} + + '@oozcitak/util@1.0.2': + resolution: {integrity: sha512-4n8B1cWlJleSOSba5gxsMcN4tO8KkkcvXhNWW+ADqvq9Xj+Lrl9uCa90GRpjekqQJyt84aUX015DG81LFpZYXA==} + engines: {node: '>=6.0'} + + '@oozcitak/util@8.0.0': + resolution: {integrity: sha512-+9Hq6yuoq/3TRV/n/xcpydGBq2qN2/DEDMqNTG7rm95K6ZE2/YY/sPyx62+1n8QsE9O26e5M1URlXsk+AnN9Jw==} + engines: {node: '>=6.0'} + + '@oozcitak/util@8.3.3': + resolution: {integrity: sha512-Ufpab7G5PfnEhQyy5kDg9C8ltWJjsVT1P/IYqacjstaqydG4Q21HAT2HUZQYBrC/a1ZLKCz87pfydlDvv8y97w==} + engines: {node: '>=6.0'} + + '@oozcitak/util@8.3.4': + resolution: {integrity: sha512-6gH/bLQJSJEg7OEpkH4wGQdA8KXHRbzL1YkGyUO12YNAgV3jxKy4K9kvfXj4+9T0OLug5k58cnPCKSSIKzp7pg==} + engines: {node: '>=8.0'} + '@openrouter/ai-sdk-provider@1.5.4': resolution: {integrity: sha512-xrSQPUIH8n9zuyYZR0XK7Ba0h2KsjJcMkxnwaYfmv13pKs3sDkjPzVPPhlhzqBGddHb5cFEwJ9VFuFeDcxCDSw==} engines: {node: '>=18'} @@ -2516,6 +2562,17 @@ packages: '@react-pdf/types@2.9.2': resolution: {integrity: sha512-dufvpKId9OajLLbgn9q7VLUmyo1Jf+iyGk2ZHmCL8nIDtL8N1Ejh9TH7+pXXrR0tdie1nmnEb5Bz9U7g4hI4/g==} + '@reduxjs/toolkit@2.11.2': + resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + '@remirror/core-constants@3.0.0': resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} @@ -2876,6 +2933,9 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@swc/helpers@0.5.18': resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==} @@ -3775,6 +3835,9 @@ packages: brotli@1.3.3: resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==} + browser-split@0.0.1: + resolution: {integrity: sha512-JhvgRb2ihQhsljNda3BI8/UcRHVzrVwo3Q+P8vDtSiyobXuFpuZ9mq+MbRGMnC22CjW3RrfXdg6j6ITX8M+7Ow==} + browserify-zlib@0.2.0: resolution: {integrity: sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==} @@ -3836,6 +3899,9 @@ packages: camel-case@4.1.2: resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} + camelize@1.0.1: + resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==} + caniuse-lite@1.0.30001761: resolution: {integrity: sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==} @@ -4263,6 +4329,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decode-named-character-reference@1.2.0: resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} @@ -4331,12 +4400,24 @@ packages: dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} + dom-serializer@0.2.2: + resolution: {integrity: sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + dom-walk@0.1.2: + resolution: {integrity: sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==} + + domelementtype@1.3.1: + resolution: {integrity: sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==} + domelementtype@2.3.0: resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + domhandler@2.4.2: + resolution: {integrity: sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==} + domhandler@5.0.3: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} @@ -4344,6 +4425,9 @@ packages: dompurify@3.3.1: resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==} + domutils@1.7.0: + resolution: {integrity: sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==} + domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -4428,6 +4512,16 @@ packages: resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} engines: {node: '>=10.13.0'} + ent@2.2.2: + resolution: {integrity: sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw==} + engines: {node: '>= 0.4'} + + entities@1.1.2: + resolution: {integrity: sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==} + + entities@2.2.0: + resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -4446,6 +4540,9 @@ packages: error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + error@4.4.0: + resolution: {integrity: sha512-SNDKualLUtT4StGFP7xNfuFybL2f6iJujFtrWuvJqGbVQGaN+adE23veqzPz1hjUjTunLi2EnJ+0SJxtbJreKw==} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -4465,6 +4562,9 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + es-toolkit@1.45.1: + resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==} + es6-error@4.1.1: resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} @@ -4565,6 +4665,9 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + ev-store@7.0.0: + resolution: {integrity: sha512-otazchNRnGzp2YarBJ+GXKVGvhxVATB1zmaStxJBYet0Dyq7A9VhH8IUEB/gRcL6Ch52lfpgPTRJ2m49epyMsQ==} + event-target-shim@5.0.1: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} @@ -4907,6 +5010,9 @@ packages: resolution: {integrity: sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==} engines: {node: '>=10'} + global@4.4.0: + resolution: {integrity: sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==} + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -5050,12 +5156,24 @@ packages: hsl-to-rgb-for-reals@1.1.1: resolution: {integrity: sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==} + html-entities@2.6.0: + resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} + + html-to-docx@1.8.0: + resolution: {integrity: sha512-IiMBWIqXM4+cEsW//RKoonWV7DlXAJBmmKI73XJSVWTIXjGUaxSr2ck1jqzVRZknpvO8xsFnVicldKVAWrBYBA==} + + html-to-vdom@0.7.0: + resolution: {integrity: sha512-k+d2qNkbx0JO00KezQsNcn6k2I/xSBP4yXYFLvXbcasTTDh+RDLUJS3puxqyNnpdyXWRHFGoKU7cRmby8/APcQ==} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + htmlparser2@3.10.1: + resolution: {integrity: sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==} + http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} @@ -5113,9 +5231,23 @@ packages: engines: {node: '>=6.9.0'} hasBin: true + image-size@1.2.1: + resolution: {integrity: sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==} + engines: {node: '>=16.x'} + hasBin: true + + image-to-base64@2.2.0: + resolution: {integrity: sha512-Z+aMwm/91UOQqHhrz7Upre2ytKhWejZlWV/JxUTD1sT7GWWKFDJUEV5scVQKnkzSgPHFuQBUEWcanO+ma0PSVw==} + immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + immer@11.1.4: + resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -5132,6 +5264,9 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + individual@3.0.0: + resolution: {integrity: sha512-rUY5vtT748NMRbEMrTNiFfy29BgGZwGXUi2NFUVMWQrogSLzlJvQV9eeMWi+g1aVaQ53tpyLAQtd5x/JH0Nh1g==} + infer-owner@1.0.4: resolution: {integrity: sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==} @@ -5232,6 +5367,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-object@1.0.2: + resolution: {integrity: sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==} + is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -5242,6 +5380,10 @@ packages: is-property@1.0.2: resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + is-stream@1.1.0: resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==} engines: {node: '>=0.10.0'} @@ -5836,6 +5978,9 @@ packages: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} + min-document@2.19.2: + resolution: {integrity: sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==} + minimatch@10.1.1: resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} engines: {node: 20 || >=22} @@ -5965,6 +6110,9 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + next-tick@0.2.2: + resolution: {integrity: sha512-f7h4svPtl+QidoBv4taKXUjJ70G2asaZ8G28nS0OkqaalX8dwwrtWtyxEDPK62AC00ur/+/E0pUwBwY5EPn15Q==} + nice-try@1.0.5: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} @@ -6425,6 +6573,9 @@ packages: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} + punycode@1.4.1: + resolution: {integrity: sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -6484,6 +6635,18 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-redux@9.2.0: + resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} @@ -6549,10 +6712,26 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + 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 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + rechoir@0.8.0: resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} engines: {node: '>= 10.13.0'} + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + regex-recursion@6.0.2: resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} @@ -6625,6 +6804,9 @@ packages: resolution: {integrity: sha512-oTeemxwoMuxxTYxXUwjkrOPfngTQehlv0/HoYFNkB4uzsP1Un1A9nI8JQKGOFkxpqkC7qkMs0lUsGrvUlbLNUA==} engines: {node: '>=14', npm: '>=7'} + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resolve-alpn@1.2.1: resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} @@ -6713,6 +6895,10 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -6908,6 +7094,9 @@ packages: peerDependencies: react: ^18.0.0 || ^19.0.0 + string-template@0.2.1: + resolution: {integrity: sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -7026,6 +7215,9 @@ packages: tiny-inflate@1.0.3: resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinyexec@1.0.2: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} @@ -7297,6 +7489,12 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + + virtual-dom@2.1.1: + resolution: {integrity: sha512-wb6Qc9Lbqug0kRqo/iuApfBpJJAq14Sk1faAnSmtqXiwahg7PVTvWMs9L02Z8nNIMqbwsxzBAA90bbtRLbw0zg==} + vite-compatible-readable-stream@3.6.1: resolution: {integrity: sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==} engines: {node: '>= 6'} @@ -7446,11 +7644,21 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + x-is-array@0.1.0: + resolution: {integrity: sha512-goHPif61oNrr0jJgsXRfc8oqtYzvfiMJpTqwE7Z4y9uH+T3UozkGqQ4d2nX9mB9khvA8U2o/UbPOFjgC7hLWIA==} + + x-is-string@0.1.0: + resolution: {integrity: sha512-GojqklwG8gpzOVEVki5KudKNoq7MbbjYZCbyWzEz7tyPA7eleiE0+ePwOWQQRb5fm86rD3S8Tc0tSFf3AOv50w==} + xlsx@0.18.5: resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} engines: {node: '>=0.8'} hasBin: true + xmlbuilder2@2.1.2: + resolution: {integrity: sha512-PI710tmtVlQ5VmwzbRTuhmVhKnj9pM8Si+iOZCV2g2SNo3gCrpzR2Ka9wNzZtqfD+mnP+xkrqoNy0sjKZqP4Dg==} + engines: {node: '>=8.0'} + xmlbuilder@10.1.1: resolution: {integrity: sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==} engines: {node: '>=4.0'} @@ -9230,6 +9438,41 @@ snapshots: dependencies: '@octokit/openapi-types': 12.11.0 + '@oozcitak/dom@1.15.5': + dependencies: + '@oozcitak/infra': 1.0.5 + '@oozcitak/url': 1.0.0 + '@oozcitak/util': 8.0.0 + + '@oozcitak/dom@1.15.6': + dependencies: + '@oozcitak/infra': 1.0.5 + '@oozcitak/url': 1.0.0 + '@oozcitak/util': 8.3.4 + + '@oozcitak/infra@1.0.3': + dependencies: + '@oozcitak/util': 1.0.1 + + '@oozcitak/infra@1.0.5': + dependencies: + '@oozcitak/util': 8.0.0 + + '@oozcitak/url@1.0.0': + dependencies: + '@oozcitak/infra': 1.0.3 + '@oozcitak/util': 1.0.2 + + '@oozcitak/util@1.0.1': {} + + '@oozcitak/util@1.0.2': {} + + '@oozcitak/util@8.0.0': {} + + '@oozcitak/util@8.3.3': {} + + '@oozcitak/util@8.3.4': {} + '@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 @@ -10259,6 +10502,18 @@ snapshots: '@react-pdf/primitives': 4.1.1 '@react-pdf/stylesheet': 6.1.2 + '@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)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.4 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.1 + optionalDependencies: + react: 19.2.3 + react-redux: 9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1) + '@remirror/core-constants@3.0.0': {} '@rolldown/pluginutils@1.0.0-beta.53': {} @@ -10704,6 +10959,8 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@standard-schema/utils@0.3.0': {} + '@swc/helpers@0.5.18': dependencies: tslib: 2.8.1 @@ -11729,6 +11986,8 @@ snapshots: dependencies: base64-js: 1.5.1 + browser-split@0.0.1: {} + browserify-zlib@0.2.0: dependencies: pako: 1.0.11 @@ -11823,6 +12082,8 @@ snapshots: pascal-case: 3.1.2 tslib: 2.8.1 + camelize@1.0.1: {} + caniuse-lite@1.0.30001761: {} ccount@2.0.1: {} @@ -12243,6 +12504,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + decode-named-character-reference@1.2.0: dependencies: character-entities: 2.0.2 @@ -12306,14 +12569,27 @@ snapshots: minimatch: 3.1.2 p-limit: 3.1.0 + dom-serializer@0.2.2: + dependencies: + domelementtype: 2.3.0 + entities: 2.2.0 + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 domhandler: 5.0.3 entities: 4.5.0 + dom-walk@0.1.2: {} + + domelementtype@1.3.1: {} + domelementtype@2.3.0: {} + domhandler@2.4.2: + dependencies: + domelementtype: 1.3.1 + domhandler@5.0.3: dependencies: domelementtype: 2.3.0 @@ -12322,6 +12598,11 @@ snapshots: optionalDependencies: '@types/trusted-types': 2.0.7 + domutils@1.7.0: + dependencies: + dom-serializer: 0.2.2 + domelementtype: 1.3.1 + domutils@3.2.2: dependencies: dom-serializer: 2.0.0 @@ -12462,6 +12743,17 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + ent@2.2.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + punycode: 1.4.1 + safe-regex-test: 1.1.0 + + entities@1.1.2: {} + + entities@2.2.0: {} + entities@4.5.0: {} entities@6.0.1: {} @@ -12474,6 +12766,12 @@ snapshots: dependencies: is-arrayish: 0.2.1 + error@4.4.0: + dependencies: + camelize: 1.0.1 + string-template: 0.2.1 + xtend: 4.0.2 + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -12491,6 +12789,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + es-toolkit@1.45.1: {} + es6-error@4.1.1: optional: true @@ -12655,6 +12955,10 @@ snapshots: etag@1.8.1: {} + ev-store@7.0.0: + dependencies: + individual: 3.0.0 + event-target-shim@5.0.1: {} eventemitter3@5.0.1: {} @@ -13098,6 +13402,11 @@ snapshots: dependencies: ini: 2.0.0 + global@4.4.0: + dependencies: + min-document: 2.19.2 + process: 0.11.10 + globals@14.0.0: {} globals@16.5.0: {} @@ -13344,10 +13653,44 @@ snapshots: hsl-to-rgb-for-reals@1.1.1: {} + html-entities@2.6.0: {} + + html-to-docx@1.8.0(encoding@0.1.13): + dependencies: + '@oozcitak/dom': 1.15.6 + '@oozcitak/util': 8.3.4 + color-name: 1.1.4 + html-entities: 2.6.0 + html-to-vdom: 0.7.0 + image-size: 1.2.1 + image-to-base64: 2.2.0(encoding@0.1.13) + jszip: 3.10.1 + lodash: 4.17.21 + mime-types: 2.1.35 + nanoid: 3.3.11 + virtual-dom: 2.1.1 + xmlbuilder2: 2.1.2 + transitivePeerDependencies: + - encoding + + html-to-vdom@0.7.0: + dependencies: + ent: 2.2.2 + htmlparser2: 3.10.1 + html-url-attributes@3.0.1: {} html-void-elements@3.0.0: {} + htmlparser2@3.10.1: + dependencies: + domelementtype: 1.3.1 + domhandler: 2.4.2 + domutils: 1.7.0 + entities: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + http-cache-semantics@4.2.0: {} http-errors@2.0.1: @@ -13412,8 +13755,22 @@ snapshots: image-size@0.7.5: optional: true + image-size@1.2.1: + dependencies: + queue: 6.0.2 + + image-to-base64@2.2.0(encoding@0.1.13): + dependencies: + node-fetch: 2.7.0(encoding@0.1.13) + transitivePeerDependencies: + - encoding + immediate@3.0.6: {} + immer@10.2.0: {} + + immer@11.1.4: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -13426,6 +13783,8 @@ snapshots: indent-string@4.0.0: {} + individual@3.0.0: {} + infer-owner@1.0.4: {} inflight@1.0.6: @@ -13500,6 +13859,8 @@ snapshots: is-number@7.0.0: {} + is-object@1.0.2: {} + is-plain-obj@4.1.0: {} is-promise@4.0.0: {} @@ -13507,6 +13868,13 @@ snapshots: is-property@1.0.2: optional: true + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + is-stream@1.1.0: {} is-stream@2.0.1: {} @@ -14352,6 +14720,10 @@ snapshots: mimic-response@3.1.0: {} + min-document@2.19.2: + dependencies: + dom-walk: 0.1.2 + minimatch@10.1.1: dependencies: '@isaacs/brace-expansion': 5.0.0 @@ -14467,6 +14839,8 @@ snapshots: neo-async@2.6.2: {} + next-tick@0.2.2: {} + nice-try@1.0.5: {} no-case@3.0.4: @@ -14955,6 +15329,8 @@ snapshots: punycode.js@2.3.1: {} + punycode@1.4.1: {} + punycode@2.3.1: {} pusher-js@8.4.0: @@ -15064,6 +15440,15 @@ snapshots: react-is@16.13.1: {} + react-redux@9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.3 + use-sync-external-store: 1.6.0(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.7 + redux: 5.0.1 + react-refresh@0.18.0: {} react-remove-scroll-bar@2.3.8(@types/react@19.2.7)(react@19.2.3): @@ -15138,10 +15523,36 @@ snapshots: readdirp@4.1.2: {} + 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 + decimal.js-light: 2.5.1 + es-toolkit: 1.45.1 + eventemitter3: 5.0.1 + immer: 10.2.0 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-is: 16.13.1 + react-redux: 9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.3) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + rechoir@0.8.0: dependencies: resolve: 1.22.11 + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + regex-recursion@6.0.2: dependencies: regex-utilities: 2.3.0 @@ -15248,6 +15659,8 @@ snapshots: dependencies: pe-library: 1.0.1 + reselect@5.1.1: {} + resolve-alpn@1.2.1: {} resolve-from@4.0.0: {} @@ -15366,6 +15779,12 @@ snapshots: safe-buffer@5.2.1: {} + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + safer-buffer@2.1.2: {} scheduler@0.25.0-rc-603e6108-20241029: {} @@ -15616,6 +16035,8 @@ snapshots: - micromark-util-types - supports-color + string-template@0.2.1: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -15733,6 +16154,8 @@ snapshots: tiny-inflate@1.0.3: {} + tiny-invariant@1.3.3: {} + tinyexec@1.0.2: {} tinyglobby@0.2.15: @@ -16004,6 +16427,34 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.7 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + + virtual-dom@2.1.1: + dependencies: + browser-split: 0.0.1 + error: 4.4.0 + ev-store: 7.0.0 + global: 4.4.0 + is-object: 1.0.2 + next-tick: 0.2.2 + x-is-array: 0.1.0 + x-is-string: 0.1.0 + vite-compatible-readable-stream@3.6.1: dependencies: inherits: 2.0.4 @@ -16155,6 +16606,10 @@ snapshots: wrappy@1.0.2: {} + x-is-array@0.1.0: {} + + x-is-string@0.1.0: {} + xlsx@0.18.5: dependencies: adler-32: 1.3.1 @@ -16165,12 +16620,17 @@ snapshots: wmf: 1.0.2 word: 0.3.0 + xmlbuilder2@2.1.2: + dependencies: + '@oozcitak/dom': 1.15.5 + '@oozcitak/infra': 1.0.5 + '@oozcitak/util': 8.3.3 + xmlbuilder@10.1.1: {} xmlbuilder@15.1.1: {} - xtend@4.0.2: - optional: true + xtend@4.0.2: {} y18n@5.0.8: {} From 61e92783b2f7b2d8b2d551a2dfc9471d56d52632 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:43:48 +0530 Subject: [PATCH 7/9] fix nested lists save --- .../src/components/markdown-editor.tsx | 65 ++++++++++--------- 1 file changed, 35 insertions(+), 30 deletions(-) diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index f3ccba2d..d7920b8b 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -109,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; attrs?: Record }> - 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 }> }>; attrs?: Record }> - itemContent.forEach((para: { type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record }> }>; attrs?: Record }, 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>; attrs?: Record }, + indent: number + ): string[] => { + const lines: string[] = [] + const items = (listNode.content || []) as Array<{ content?: Array>; attrs?: Record }> + items.forEach((item, index) => { + const indentStr = ' '.repeat(indent) + let prefix: string + if (listNode.type === 'taskList') { + const checked = item.attrs?.checked ? 'x' : ' ' + prefix = `- [${checked}] ` + } else if (listNode.type === 'orderedList') { + prefix = `${index + 1}. ` } else { - listLines.push(' ' + text) + prefix = '- ' } + const itemContent = (item.content || []) as Array<{ type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record }> }>; attrs?: Record }> + let firstPara = true + itemContent.forEach(child => { + if (child.type === 'bulletList' || child.type === 'orderedList' || child.type === 'taskList') { + lines.push(...serializeList(child, indent + 1)) + } else { + const text = nodeToText(child) + if (firstPara) { + lines.push(indentStr + prefix + text) + firstPara = false + } else { + lines.push(indentStr + ' ' + text) + } + } + }) }) - }) - blocks.push(listLines.join('\n')) - } else if (node.type === 'taskList') { - const listLines: string[] = [] - const listItems = (node.content || []) as Array<{ content?: Array; attrs?: Record }> - 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 }> }>; attrs?: Record }> - itemContent.forEach((para: { type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record }> }>; attrs?: Record }, 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') { From 1c5e5afda8a6a8198562dc0bba0cbbf4617e98e8 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:50:23 +0530 Subject: [PATCH 8/9] fix oauth callback params propagation --- apps/x/apps/main/src/auth-server.ts | 4 ++-- apps/x/apps/main/src/composio-handler.ts | 2 +- apps/x/apps/main/src/oauth-handler.ts | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/x/apps/main/src/auth-server.ts b/apps/x/apps/main/src/auth-server.ts index b0b890c0..78e519d0 100644 --- a/apps/x/apps/main/src/auth-server.ts +++ b/apps/x/apps/main/src/auth-server.ts @@ -25,7 +25,7 @@ export interface AuthServerResult { */ export function createAuthServer( port: number = DEFAULT_PORT, - onCallback: (code: string, state: string) => void | Promise + onCallback: (params: Record) => void | Promise ): Promise { 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(` diff --git a/apps/x/apps/main/src/composio-handler.ts b/apps/x/apps/main/src/composio-handler.ts index e5b25d1a..452a76e3 100644 --- a/apps/x/apps/main/src/composio-handler.ts +++ b/apps/x/apps/main/src/composio-handler.ts @@ -143,7 +143,7 @@ export async function initiateConnection(toolkitSlug: string): Promise<{ // Set up callback server let cleanupTimeout: NodeJS.Timeout; - const { server } = await createAuthServer(8081, async (_code, _state) => { + const { server } = await createAuthServer(8081, async () => { // OAuth callback received - sync the account status try { const accountStatus = await composioClient.getConnectedAccount(connectedAccountId); diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index 2efc77c2..bf9c77ff 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -186,9 +186,9 @@ export async function connectProvider(provider: string, clientId?: string): Prom }); // Create callback server - const { server } = await createAuthServer(8080, async (code, receivedState) => { + const { server } = await createAuthServer(8080, async (params: Record) => { // Validate state - if (receivedState !== state) { + if (params.state !== state) { throw new Error('Invalid state parameter - possible CSRF attack'); } @@ -199,7 +199,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})...`); From 903fecc5f538ddf0cd2771201711e1d4807e124c Mon Sep 17 00:00:00 2001 From: arkml <6592213+arkml@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:07:41 +0530 Subject: [PATCH 9/9] Daily5 (#457) * better cal and email block design * modified email block and draft with copilot --- .../src/extensions/calendar-block.tsx | 26 +- .../renderer/src/extensions/email-block.tsx | 345 ++++------ apps/x/apps/renderer/src/styles/editor.css | 628 +++++++----------- apps/x/packages/shared/src/blocks.ts | 1 + 4 files changed, 366 insertions(+), 634 deletions(-) diff --git a/apps/x/apps/renderer/src/extensions/calendar-block.tsx b/apps/x/apps/renderer/src/extensions/calendar-block.tsx index f72dc5d4..9f0eec02 100644 --- a/apps/x/apps/renderer/src/extensions/calendar-block.tsx +++ b/apps/x/apps/renderer/src/extensions/calendar-block.tsx @@ -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 {parts ? ( <> - {parts.day} -
- {parts.month} - {parts.weekday} -
+ {parts.weekday} + {parts.day} ) : ( ? @@ -288,16 +289,13 @@ function CalendarBlockView({ node, deleteNode }: { node: { attrs: Record e.stopPropagation()} onClick={(e) => { e.stopPropagation(); handleEventClick(event) }} > -
- {event.summary || 'Untitled event'} + {event.summary || '(No title)'}
{getTimeRange(event)} diff --git a/apps/x/apps/renderer/src/extensions/email-block.tsx b/apps/x/apps/renderer/src/extensions/email-block.tsx index 9be8c72c..7356c94c 100644 --- a/apps/x/apps/renderer/src/extensions/email-block.tsx +++ b/apps/x/apps/renderer/src/extensions/email-block.tsx @@ -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 " 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(null) const bodyRef = useRef(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) => 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 - 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 ( - -
e.stopPropagation()}> - - {/* Draft header */} - {config.to && ( -
-
- To - {config.to} -
- {config.subject && ( -
- Subject - {config.subject} + // 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 ( + +
e.stopPropagation()}> + + + {/* Header: Email badge */} +
+ + Email +
+ + {/* Summary */} +
{summary}
+ + {/* Expandable email details */} + + + {emailExpanded && ( +
+
+
+
+
+
{config.from || 'Unknown'}
+ {config.date &&
{formatEmailDate(config.date)}
} +
+ {config.subject &&
Subject: {config.subject}
}
- )} +
+
{config.latest_email}
- )} - {/* Editable draft body */} -