diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 2b43983f..db84ff5e 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -41,6 +41,7 @@ import { search } from '@x/core/dist/search/search.js'; import { versionHistory, voice } from '@x/core'; import { classifySchedule } from '@x/core/dist/knowledge/inline_tasks.js'; import { getBillingInfo } from '@x/core/dist/billing/billing.js'; +import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js'; /** * Convert markdown to a styled HTML document for PDF/DOCX export. @@ -701,6 +702,10 @@ export function setupIpcHandlers() { return { success: false, error: 'Unknown format' }; }, + 'meeting:summarize': async (_event, args) => { + const notes = await summarizeMeeting(args.transcript); + return { notes }; + }, 'inline-task:classifySchedule': async (_event, args) => { const schedule = await classifySchedule(args.instruction); return { schedule }; diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index fdea8bac..950cac31 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -3343,12 +3343,46 @@ function App() { navigateToFile(notePath) }, [loadDirectory, navigateToFile, fileTabs]) + const meetingNotePathRef = useRef(null) + const handleToggleMeeting = useCallback(async () => { if (meetingTranscription.state === 'recording') { await meetingTranscription.stop() + + // Read the final transcript and generate meeting notes via LLM + const notePath = meetingNotePathRef.current + if (notePath) { + try { + const result = await window.ipc.invoke('workspace:readFile', { path: notePath, encoding: 'utf8' }) + const fileContent = result.data + if (fileContent && fileContent.trim()) { + // Call LLM to summarize the transcript + const { notes } = await window.ipc.invoke('meeting:summarize', { transcript: fileContent }) + if (notes) { + // Prepend meeting notes below the title but above the transcript + const { raw: fm, body: transcriptBody } = splitFrontmatter(fileContent) + // Strip the "# Meeting note" title from transcript body — we'll put it first + const bodyWithoutTitle = transcriptBody.replace(/^#\s+Meeting note\s*\n*/, '') + const newBody = '# Meeting note\n\n' + notes + '\n\n---\n\n## Raw transcript\n\n' + bodyWithoutTitle + const newContent = fm ? `${fm}\n${newBody}` : newBody + await window.ipc.invoke('workspace:writeFile', { + path: notePath, + data: newContent, + opts: { encoding: 'utf8' }, + }) + // Refresh the file view + await handleVoiceNoteCreated(notePath) + } + } + } catch (err) { + console.error('[meeting] Failed to generate meeting notes:', err) + } + meetingNotePathRef.current = null + } } else if (meetingTranscription.state === 'idle') { const notePath = await meetingTranscription.start() if (notePath) { + meetingNotePathRef.current = notePath await handleVoiceNoteCreated(notePath) } } diff --git a/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts b/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts index 304264b8..26f15b17 100644 --- a/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts +++ b/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts @@ -48,11 +48,11 @@ function formatTranscript(entries: TranscriptEntry[], date: string): string { '---', 'type: meeting', 'source: rowboat', - 'title: Meeting Transcription', + 'title: Meeting note', `date: "${date}"`, '---', '', - '# Meeting Transcription', + '# Meeting note', '', ]; for (let i = 0; i < entries.length; i++) { diff --git a/apps/x/packages/core/src/knowledge/summarize_meeting.ts b/apps/x/packages/core/src/knowledge/summarize_meeting.ts new file mode 100644 index 00000000..395bffa7 --- /dev/null +++ b/apps/x/packages/core/src/knowledge/summarize_meeting.ts @@ -0,0 +1,31 @@ +import { generateText } from 'ai'; +import container from '../di/container.js'; +import type { IModelConfigRepo } from '../models/repo.js'; +import { createProvider } from '../models/models.js'; + +const SYSTEM_PROMPT = `You are a meeting notes assistant. Given a raw meeting transcript, create concise, well-organized meeting notes. + +Format rules: +- Use ### for section headers that group related discussion topics +- Section headers should be in sentence case (e.g. "### Onboarding flow status"), NOT Title Case +- Use bullet points with sub-bullets for details +- Include a "### Action items" section at the end if any were discussed +- Focus on decisions, key discussions, and takeaways — not verbatim quotes +- Attribute statements to speakers when relevant (use their names/labels from the transcript) +- Keep it concise — the notes should be much shorter than the transcript +- Output markdown only, no preamble or explanation`; + +export async function summarizeMeeting(transcript: string): Promise { + const repo = container.resolve('modelConfigRepo'); + const config = await repo.getConfig(); + const provider = createProvider(config.provider); + const model = provider.languageModel(config.model); + + const result = await generateText({ + model, + system: SYSTEM_PROMPT, + prompt: transcript, + }); + + return result.text.trim(); +} diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index b6abbf29..711c7e93 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -498,6 +498,14 @@ const ipcSchemas = { token: z.string(), }).nullable(), }, + 'meeting:summarize': { + req: z.object({ + transcript: z.string(), + }), + res: z.object({ + notes: z.string(), + }), + }, // Inline task schedule classification 'export:note': { req: z.object({