limitless_detect_meetings
Detect and analyze meetings from lifelog recordings to extract participants, topics, and action items using natural time queries.
Instructions
Automatically detect and extract meetings/conversations from lifelogs with intelligent analysis of participants, topics, action items, and key information.
Input Schema
TableJSON Schema
| Name | Required | Description | Default |
|---|---|---|---|
| time_expression | No | Natural time expression like 'today', 'yesterday', 'this week' (defaults to 'today'). | |
| timezone | No | IANA timezone for date/time parameters. | |
| min_duration_minutes | No | Minimum duration in minutes to consider as a meeting. |
Implementation Reference
- src/server.ts:705-756 (registration)MCP tool registration for 'limitless_detect_meetings', including tool name, description, input schema, and handler function.server.tool("limitless_detect_meetings", "Automatically detect and extract meetings/conversations from lifelogs with intelligent analysis of participants, topics, action items, and key information.", MeetingDetectionArgsSchema, async (args, _extra) => { try { const timeExpression = args.time_expression || 'today'; const parser = new NaturalTimeParser({ timezone: args.timezone }); const timeRange = parser.parseTimeExpression(timeExpression); // Fetch all logs with pagination const allLogs: Lifelog[] = []; let cursor: string | undefined = undefined; while (true) { const result = await getLifelogsWithPagination(limitlessApiKey, { start: timeRange.start, end: timeRange.end, timezone: timeRange.timezone, includeMarkdown: true, includeHeadings: true, limit: MAX_API_LIMIT, direction: 'asc', cursor: cursor }); allLogs.push(...result.lifelogs); if (!result.pagination.nextCursor || result.lifelogs.length < MAX_API_LIMIT) { break; } cursor = result.pagination.nextCursor; } const meetings = MeetingDetector.detectMeetings(allLogs); // Filter by minimum duration if specified const filteredMeetings = meetings.filter(meeting => meeting.duration >= (args.min_duration_minutes || 5) * 60 * 1000 ); return createSafeResponse( filteredMeetings, filteredMeetings.length === 0 ? `No meetings detected for "${timeExpression}"` : `Found ${filteredMeetings.length} meeting(s) for "${timeExpression}"` ); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { content: [{ type: "text", text: `Error detecting meetings: ${errorMessage}` }], isError: true }; } } );
- src/server.ts:708-755 (handler)The primary handler function that orchestrates lifelog fetching via pagination, invokes MeetingDetector for analysis, applies duration filtering, and formats the safe response.async (args, _extra) => { try { const timeExpression = args.time_expression || 'today'; const parser = new NaturalTimeParser({ timezone: args.timezone }); const timeRange = parser.parseTimeExpression(timeExpression); // Fetch all logs with pagination const allLogs: Lifelog[] = []; let cursor: string | undefined = undefined; while (true) { const result = await getLifelogsWithPagination(limitlessApiKey, { start: timeRange.start, end: timeRange.end, timezone: timeRange.timezone, includeMarkdown: true, includeHeadings: true, limit: MAX_API_LIMIT, direction: 'asc', cursor: cursor }); allLogs.push(...result.lifelogs); if (!result.pagination.nextCursor || result.lifelogs.length < MAX_API_LIMIT) { break; } cursor = result.pagination.nextCursor; } const meetings = MeetingDetector.detectMeetings(allLogs); // Filter by minimum duration if specified const filteredMeetings = meetings.filter(meeting => meeting.duration >= (args.min_duration_minutes || 5) * 60 * 1000 ); return createSafeResponse( filteredMeetings, filteredMeetings.length === 0 ? `No meetings detected for "${timeExpression}"` : `Found ${filteredMeetings.length} meeting(s) for "${timeExpression}"` ); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { content: [{ type: "text", text: `Error detecting meetings: ${errorMessage}` }], isError: true }; } }
- src/server.ts:231-235 (schema)Input validation schema using Zod for tool parameters: time_expression (optional), timezone (optional), min_duration_minutes (optional, default 5).const MeetingDetectionArgsSchema = { time_expression: z.string().optional().describe("Natural time expression like 'today', 'yesterday', 'this week' (defaults to 'today')."), timezone: z.string().optional().describe("IANA timezone for date/time parameters."), min_duration_minutes: z.number().optional().default(5).describe("Minimum duration in minutes to consider as a meeting."), };
- src/advanced-features.ts:718-1016 (helper)Core MeetingDetector class with static detectMeetings() method that groups lifelogs by time proximity, analyzes for meeting criteria (multiple speakers, duration), extracts participants/topics/action items/decisions/summary, used by the tool handler.export class MeetingDetector { /** * Detect meetings from lifelogs using sophisticated analysis */ static detectMeetings(lifelogs: Lifelog[]): Meeting[] { if (!lifelogs.length) return []; // Group lifelogs by continuous time periods const timeGroups = this.groupByTimeProximity(lifelogs); // Analyze each group for meeting characteristics const meetings: Meeting[] = []; for (const group of timeGroups) { const meeting = this.analyzePotentialMeeting(group); if (meeting) { meetings.push(meeting); } } return meetings.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()); } private static groupByTimeProximity(lifelogs: Lifelog[], maxGapMinutes: number = 15): Lifelog[][] { if (!lifelogs.length) return []; // Sort by start time const sorted = [...lifelogs].sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime() ); const groups: Lifelog[][] = []; let currentGroup: Lifelog[] = [sorted[0]]; for (let i = 1; i < sorted.length; i++) { const current = sorted[i]; const previous = currentGroup[currentGroup.length - 1]; const gap = new Date(current.startTime).getTime() - new Date(previous.endTime).getTime(); const gapMinutes = gap / (1000 * 60); if (gapMinutes <= maxGapMinutes) { currentGroup.push(current); } else { groups.push(currentGroup); currentGroup = [current]; } } groups.push(currentGroup); return groups; } private static analyzePotentialMeeting(lifelogs: Lifelog[]): Meeting | null { if (!lifelogs.length) return null; // Extract all speakers and content nodes const allNodes: LifelogContentNode[] = []; const speakers = new Set<string>(); let hasUserSpeaker = false; for (const lifelog of lifelogs) { if (lifelog.contents) { allNodes.push(...lifelog.contents); for (const node of lifelog.contents) { if (node.speakerName) { speakers.add(node.speakerName); } if (node.speakerIdentifier === "user") { hasUserSpeaker = true; } } } } // Meeting criteria: multiple speakers OR user interaction OR significant duration const duration = new Date(lifelogs[lifelogs.length - 1].endTime).getTime() - new Date(lifelogs[0].startTime).getTime(); const durationMinutes = duration / (1000 * 60); const isMeeting = speakers.size > 1 || hasUserSpeaker || durationMinutes > 5; if (!isMeeting) return null; // Build meeting object const participants = this.extractParticipants(allNodes); const actionItems = ActionItemExtractor.extractFromNodes(allNodes, lifelogs[0].id); const topics = this.extractTopics(allNodes); const keyDecisions = this.extractDecisions(allNodes); return { id: `meeting_${lifelogs[0].startTime}_${lifelogs.length}`, title: this.generateMeetingTitle(lifelogs, participants), startTime: lifelogs[0].startTime, endTime: lifelogs[lifelogs.length - 1].endTime, duration, participants, mainTopics: topics, actionItems, keyDecisions, summary: this.generateMeetingSummary(lifelogs, participants, topics), lifelogIds: lifelogs.map(l => l.id) }; } private static extractParticipants(nodes: LifelogContentNode[]): MeetingParticipant[] { const participantMap = new Map<string, { name: string; identifier?: "user" | null; duration: number; messageCount: number; }>(); for (const node of nodes) { if (node.speakerName) { const existing = participantMap.get(node.speakerName) || { name: node.speakerName, identifier: node.speakerIdentifier, duration: 0, messageCount: 0 }; existing.messageCount++; if (node.startOffsetMs !== undefined && node.endOffsetMs !== undefined) { existing.duration += node.endOffsetMs - node.startOffsetMs; } participantMap.set(node.speakerName, existing); } } return Array.from(participantMap.values()).map(p => ({ name: p.name, identifier: p.identifier, speakingDuration: p.duration, messageCount: p.messageCount })); } private static extractTopics(nodes: LifelogContentNode[]): string[] { const topics: string[] = []; for (const node of nodes) { if (node.type === "heading1" || node.type === "heading2") { if (node.content) { topics.push(node.content); } } } return topics.slice(0, 5); // Top 5 topics } private static extractDecisions(nodes: LifelogContentNode[]): string[] { const decisions: string[] = []; const decisionKeywords = /\b(decided|agreed|concluded|determined|resolved|final decision|we will|going with)\b/i; for (const node of nodes) { if (node.content && decisionKeywords.test(node.content)) { decisions.push(node.content); } } return decisions; } private static generateMeetingTitle(lifelogs: Lifelog[], participants: MeetingParticipant[]): string { // Use first heading if available for (const lifelog of lifelogs) { if (lifelog.title && lifelog.title.trim()) { return lifelog.title; } } // Generate from participants if (participants.length > 1) { const others = participants.filter(p => p.identifier !== "user").map(p => p.name); if (others.length > 0) { return `Meeting with ${others.slice(0, 2).join(", ")}${others.length > 2 ? ` and ${others.length - 2} others` : ""}`; } } // Fallback to time-based title const startTime = new Date(lifelogs[0].startTime); return `Meeting at ${startTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`; } private static generateMeetingSummary(lifelogs: Lifelog[], participants: MeetingParticipant[], topics: string[]): string { const participantNames = participants.map(p => p.name).join(", "); const duration = Math.round((new Date(lifelogs[lifelogs.length - 1].endTime).getTime() - new Date(lifelogs[0].startTime).getTime()) / (1000 * 60)); // Extract key technical details, numbers, and specific information const allNodes: LifelogContentNode[] = []; for (const lifelog of lifelogs) { if (lifelog.contents) { allNodes.push(...lifelog.contents); } } const technicalDetails = this.extractTechnicalDetails(lifelogs); const numbersAndFigures = this.extractNumbersAndFigures(lifelogs); const keyDecisions = this.extractDecisions(allNodes); let summary = `${duration}-minute meeting with ${participantNames}.`; if (topics.length > 0) { summary += ` Topics covered: ${topics.join(", ")}.`; } if (technicalDetails.length > 0) { summary += ` Technical elements discussed: ${technicalDetails.slice(0, 5).join(", ")}.`; } if (numbersAndFigures.length > 0) { summary += ` Key figures mentioned: ${numbersAndFigures.slice(0, 8).join(", ")}.`; } if (keyDecisions.length > 0) { summary += ` Decisions made: ${keyDecisions.slice(0, 3).join("; ")}.`; } return summary; } private static extractTechnicalDetails(lifelogs: Lifelog[]): string[] { const technicalTerms = new Set<string>(); const technicalPatterns = [ // Scientific and medical terms /\b[A-Z][a-z]+(?:ine|ase|oid|gen|ide|ate|ium|sis|tion|logy|graphy|metry|scopy|therapy|diagnosis)\b/g, // Technical abbreviations and acronyms /\b[A-Z]{2,6}\b/g, // Software/technology terms /\b(?:API|SDK|REST|GraphQL|JSON|XML|HTTP|HTTPS|SQL|NoSQL|CI\/CD|DevOps|ML|AI|GPU|CPU|RAM|SSD|IoT|VR|AR|blockchain|cryptocurrency|algorithm|database|server|cloud|kubernetes|docker|microservice)\b/gi, // Scientific units and measurements /\b\d+(?:\.\d+)?\s*(?:mg|kg|ml|cm|mm|km|hz|ghz|mb|gb|tb|fps|rpm|°[CF]|pH|ppm|mol|atm|bar|pascal|joule|watt|volt|amp|ohm)\b/gi, // Chemical formulas /\b[A-Z][a-z]?\d*(?:[A-Z][a-z]?\d*)*\b/g, // Version numbers and model numbers /\bv?\d+\.\d+(?:\.\d+)*\b|\b[A-Z]+\d+[A-Z]*\d*\b/gi ]; for (const lifelog of lifelogs) { if (lifelog.contents) { for (const node of lifelog.contents) { if (node.content) { for (const pattern of technicalPatterns) { const matches = node.content.match(pattern) || []; matches.forEach(match => { if (match.length > 2) { technicalTerms.add(match); } }); } } } } } return Array.from(technicalTerms).slice(0, 10); } private static extractNumbersAndFigures(lifelogs: Lifelog[]): string[] { const figures = new Set<string>(); const numberPatterns = [ // Percentages and ratios /\b\d+(?:\.\d+)?%\b|\b\d+:\d+\b|\b\d+\/\d+\b/g, // Currency amounts /\$\d+(?:,\d{3})*(?:\.\d{2})?\b|\b\d+(?:,\d{3})*(?:\.\d{2})?\s*(?:dollars?|USD|EUR|GBP|million|billion|thousand|k)\b/gi, // Large numbers with commas /\b\d{1,3}(?:,\d{3})+(?:\.\d+)?\b/g, // Time durations with specific units /\b\d+(?:\.\d+)?\s*(?:hours?|minutes?|seconds?|days?|weeks?|months?|years?|milliseconds?|microseconds?)\b/gi, // Dates with specific formats /\b(?:January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{1,2},?\s+\d{4}\b/gi, // Scientific notation /\b\d+(?:\.\d+)?[eE][+-]?\d+\b/g, // Specific measurements /\b\d+(?:\.\d+)?\s*(?:x|×)\s*\d+(?:\.\d+)?\b/g ]; for (const lifelog of lifelogs) { if (lifelog.contents) { for (const node of lifelog.contents) { if (node.content) { for (const pattern of numberPatterns) { const matches = node.content.match(pattern) || []; matches.forEach(match => figures.add(match)); } } } } } return Array.from(figures).slice(0, 15); } }
- src/advanced-features.ts:25-37 (helper)Type definition for Meeting output structure returned by the detection logic.export interface Meeting { id: string; title: string; startTime: string; endTime: string; duration: number; // in milliseconds participants: MeetingParticipant[]; mainTopics: string[]; actionItems: ActionItem[]; keyDecisions: string[]; summary: string; lifelogIds: string[]; }