返回 Skill 列表
extension
分类: 开发与工程无需 API Key

kramme:granola-meeting-notes

从Granola(macOS/Windows)中提取并查询会议记录。当用户询问关于他们的会议、会议记录、参会者,或者想要查找过去会议的信息时使用。触发短语如:“什么会议”、“会议记录”、“谁参加了我的会议”、“与[某人]的会议”、“会议模式”、“我最常和谁开会”。

person作者: jakexiaohubgithub

Granola Meeting Notes

Extract and query meeting data from Granola's local cache on macOS and Windows.

Prerequisites

  • Granola must be installed (macOS or Windows)
  • User must have at least one recorded meeting

Note: Windows support is untested. The cache path is assumed to be %LOCALAPPDATA%\Granola\cache-v3.json.

Cache Location

| Platform | Path | |----------|------| | macOS | ~/Library/Application Support/Granola/cache-v3.json | | Windows | %LOCALAPPDATA%\Granola\cache-v3.json |

Reading the Cache

import json
import os
import platform
from datetime import datetime, timedelta, timezone
from collections import Counter

def get_cache_path():
    """Get Granola cache path for current platform."""
    if platform.system() == 'Windows':
        local_appdata = os.environ.get('LOCALAPPDATA', '')
        return os.path.join(local_appdata, 'Granola', 'cache-v3.json')
    else:  # macOS (and Linux if ever supported)
        return os.path.expanduser('~/Library/Application Support/Granola/cache-v3.json')

cache_path = get_cache_path()

if not os.path.exists(cache_path):
    raise FileNotFoundError(f"Granola cache not found at {cache_path}. Is Granola installed with recorded meetings?")

with open(cache_path, 'r') as f:
    raw = json.load(f)

# Handle nested 'cache' key containing stringified JSON
if 'cache' in raw and isinstance(raw['cache'], str):
    cache = json.loads(raw['cache'])
else:
    cache = raw

state = cache.get('state', cache)

Cache Structure

| Section | Description | |---------|-------------| | documents | Meeting metadata and calendar event details | | transcripts | Full transcripts with speaker identification | | documentPanels | AI-generated summaries and user notes |

Extracting Meeting Data

documents = state.get('documents', {})
transcripts = state.get('transcripts', {})
panels = state.get('documentPanels', {})

meetings = []
for doc_id, doc in documents.items():
    event = doc.get('calendarEvent', {})
    panel_data = panels.get(doc_id, {})
    transcript_data = transcripts.get(doc_id, {})

    meeting = {
        'id': doc_id,
        'title': doc.get('title', 'Untitled'),
        'created_at': doc.get('createdAt'),
        'attendees': event.get('attendees', []),
        'location': event.get('location', ''),
        'organizer': event.get('organizer', {}).get('email', ''),
        'user_notes': panel_data.get('userNotes', ''),
        'ai_summary': panel_data.get('aiSummary', ''),
        'transcript': transcript_data,
    }

    # Calculate transcript statistics
    if transcript_data:
        segments = transcript_data.get('segments', [])
        meeting['transcript_stats'] = calculate_transcript_stats(segments)

    meetings.append(meeting)

# Sort by date (newest first)
meetings.sort(key=lambda m: m.get('created_at', ''), reverse=True)

Transcript Statistics

def calculate_transcript_stats(segments):
    """Calculate word count, speaker count, and duration from transcript segments."""
    if not segments:
        return {'word_count': 0, 'speakers': [], 'duration_seconds': 0}

    words = 0
    speakers = set()

    for seg in segments:
        text = seg.get('text', '')
        words += len(text.split())
        speaker = seg.get('speaker')
        if speaker:
            speakers.add(speaker)

    # Calculate duration from first/last segment timestamps
    duration = 0
    if len(segments) >= 2:
        try:
            start = segments[0].get('start', 0)
            end = segments[-1].get('end', 0)
            duration = end - start
        except (TypeError, KeyError):
            pass

    return {
        'word_count': words,
        'speakers': list(speakers),
        'speaker_count': len(speakers),
        'duration_seconds': duration,
        'duration_formatted': f"{duration // 60}m {duration % 60}s" if duration else 'N/A'
    }

Fuzzy Search with Weighted Scoring

Use fuzzy matching for flexible search. Title matches are weighted higher than participant matches.

from difflib import SequenceMatcher

def fuzzy_match(query, text, threshold=0.6):
    """Return similarity score if above threshold, else 0."""
    if not query or not text:
        return 0
    ratio = SequenceMatcher(None, query.lower(), text.lower()).ratio()
    return ratio if ratio >= threshold else 0

def search_meetings(meetings, query, search_fields=None):
    """
    Search meetings with weighted scoring.

    search_fields: list of 'title', 'attendees', 'transcript', 'notes'
    Default: ['title', 'attendees']

    Scoring:
    - Title match: 2 points * similarity
    - Attendee match: 1 point * similarity
    - Notes/summary match: 1 point * similarity
    - Transcript match: 0.5 points * similarity
    """
    if search_fields is None:
        search_fields = ['title', 'attendees']

    results = []
    query_lower = query.lower()

    for m in meetings:
        score = 0
        match_type = None

        # Title search (weight: 2x)
        if 'title' in search_fields:
            title = m.get('title', '')
            title_score = fuzzy_match(query, title)
            if title_score > 0:
                score += title_score * 2
                match_type = 'title'

        # Attendee search (weight: 1x)
        if 'attendees' in search_fields:
            for att in m.get('attendees', []):
                email = att.get('email', '')
                name = email.split('@')[0]
                att_score = max(fuzzy_match(query, email), fuzzy_match(query, name))
                if att_score > 0:
                    score += att_score
                    match_type = match_type or 'attendee'

        # Notes/summary search (weight: 1x)
        if 'notes' in search_fields:
            notes = str(m.get('user_notes', '')) + str(m.get('ai_summary', ''))
            if query_lower in notes.lower():
                score += 1
                match_type = match_type or 'notes'

        # Transcript search (weight: 0.5x)
        if 'transcript' in search_fields:
            transcript = m.get('transcript', {})
            segments = transcript.get('segments', [])
            transcript_text = ' '.join(s.get('text', '') for s in segments)
            if query_lower in transcript_text.lower():
                score += 0.5
                match_type = match_type or 'transcript'

        if score > 0:
            results.append({**m, '_score': score, '_match_type': match_type})

    # Sort by score descending, then by date
    results.sort(key=lambda x: (-x['_score'], x.get('created_at', '')))
    return results

Pattern Analysis

Participant Frequency

def analyze_participant_frequency(meetings, days=30):
    """Find most frequent meeting participants in the last N days."""
    cutoff = datetime.now(timezone.utc) - timedelta(days=days)
    counter = Counter()

    for m in meetings:
        created = m.get('created_at')
        if not created:
            continue
        try:
            dt = datetime.fromisoformat(created.replace('Z', '+00:00'))
            if dt < cutoff:
                continue
        except ValueError:
            continue

        for att in m.get('attendees', []):
            email = att.get('email', '')
            if email:
                counter[email] += 1

    return counter.most_common(10)

Meeting Frequency by Week

def analyze_meeting_frequency(meetings, weeks=8):
    """Analyze meeting count per week."""
    cutoff = datetime.now(timezone.utc) - timedelta(weeks=weeks)
    weekly = Counter()

    for m in meetings:
        created = m.get('created_at')
        if not created:
            continue
        try:
            dt = datetime.fromisoformat(created.replace('Z', '+00:00'))
            if dt < cutoff:
                continue
            week_key = dt.strftime('%Y-W%W')
            weekly[week_key] += 1
        except ValueError:
            continue

    return sorted(weekly.items())

Topic Extraction

def extract_topics(meetings, days=30):
    """Extract common words from meeting titles as topic indicators."""
    cutoff = datetime.now(timezone.utc) - timedelta(days=days)
    words = Counter()
    stopwords = {'the', 'a', 'an', 'and', 'or', 'with', 'for', 'to', 'of', 'in', 'on', 'at', 'by'}

    for m in meetings:
        created = m.get('created_at')
        if not created:
            continue
        try:
            dt = datetime.fromisoformat(created.replace('Z', '+00:00'))
            if dt < cutoff:
                continue
        except ValueError:
            continue

        title = m.get('title', '').lower()
        for word in title.split():
            word = word.strip('.,!?:;()[]{}')
            if len(word) > 2 and word not in stopwords:
                words[word] += 1

    return words.most_common(15)

Export to Markdown

import re

def slugify(text):
    """Convert text to URL-friendly slug."""
    text = text.lower().strip()
    text = re.sub(r'[^\w\s-]', '', text)
    text = re.sub(r'[-\s]+', '-', text)
    return text[:50]

def export_meeting_to_markdown(meeting, output_dir='~/granola-exports'):
    """Export a meeting to a markdown file with auto-generated filename."""
    output_dir = os.path.expanduser(output_dir)
    os.makedirs(output_dir, exist_ok=True)

    # Generate filename: YYYY-MM-DD-title-slug.md
    date_str = meeting.get('created_at', '')[:10]
    title_slug = slugify(meeting.get('title', 'untitled'))
    filename = f"{date_str}-{title_slug}.md"
    filepath = os.path.join(output_dir, filename)

    # Format attendees
    attendees = [a.get('email', '').split('@')[0] for a in meeting.get('attendees', [])]

    # Get transcript stats
    stats = meeting.get('transcript_stats', {})

    content = f"""# {meeting.get('title', 'Untitled')}

**Date:** {format_date(meeting.get('created_at'))}
**Attendees:** {', '.join(attendees) or 'N/A'}
**Location:** {meeting.get('location') or 'N/A'}
**Organizer:** {meeting.get('organizer') or 'N/A'}

## Statistics
- **Duration:** {stats.get('duration_formatted', 'N/A')}
- **Word Count:** {stats.get('word_count', 'N/A')}
- **Speakers:** {stats.get('speaker_count', 'N/A')}

## Notes
{meeting.get('user_notes') or '_No notes_'}

## AI Summary
{meeting.get('ai_summary') or '_No summary_'}
"""
    with open(filepath, 'w') as f:
        f.write(content)

    return filepath

Timezone-Aware Date Formatting

def format_date(iso_string, format_str='%B %d, %Y at %I:%M %p'):
    """Format ISO date string to local timezone."""
    if not iso_string:
        return 'Unknown'
    try:
        dt = datetime.fromisoformat(iso_string.replace('Z', '+00:00'))
        local_dt = dt.astimezone()  # Convert to local timezone
        return local_dt.strftime(format_str)
    except ValueError:
        return iso_string[:10]

Common Queries

List Recent Meetings

days = 7
cutoff = datetime.now(timezone.utc) - timedelta(days=days)

recent = [m for m in meetings
          if m.get('created_at') and
          datetime.fromisoformat(m['created_at'].replace('Z', '+00:00')) > cutoff]

for m in recent:
    stats = m.get('transcript_stats', {})
    print(f"- {m['title']} ({format_date(m['created_at'], '%b %d')})")
    print(f"  Duration: {stats.get('duration_formatted', 'N/A')}, "
          f"Speakers: {stats.get('speaker_count', 'N/A')}")

Find Meetings by Attendee

results = search_meetings(meetings, 'john', search_fields=['attendees'])

Search All Fields

results = search_meetings(meetings, 'budget review',
                          search_fields=['title', 'attendees', 'notes', 'transcript'])

Who Do I Meet With Most?

top_participants = analyze_participant_frequency(meetings, days=30)
for email, count in top_participants:
    print(f"- {email}: {count} meetings")

Meeting Patterns

# Weekly frequency
weekly = analyze_meeting_frequency(meetings, weeks=8)
for week, count in weekly:
    print(f"- {week}: {count} meetings")

# Common topics
topics = extract_topics(meetings, days=30)
for topic, count in topics:
    print(f"- {topic}: {count} occurrences")

Output Formatting

Detailed Meeting View

## {title}
**Date:** {formatted_date}
**Attendees:** {comma_separated_names}
**Location:** {location}
**Duration:** {duration} | **Words:** {word_count} | **Speakers:** {speaker_count}

### Notes
{user_notes}

### AI Summary
{ai_summary}

Compact List View

| Date | Meeting | Attendees | Duration |
|------|---------|-----------|----------|
| Jan 20 | Weekly Standup | alice, bob | 45m |
| Jan 19 | Product Review | dave, eve | 1h 15m |

Pattern Analysis View

## Meeting Patterns (Last 30 Days)

### Top Collaborators
1. alice@company.com - 12 meetings
2. bob@company.com - 8 meetings
3. charlie@company.com - 6 meetings

### Weekly Trend
- Week 3: 8 meetings
- Week 2: 6 meetings
- Week 1: 10 meetings

### Common Topics
- standup (15), review (8), planning (6), sync (5)

Error Handling

if not os.path.exists(cache_path):
    print("Granola cache not found. Is Granola installed with recorded meetings?")
elif not meetings:
    print("No meetings found in Granola cache.")

Privacy Note

All data stays local. The cache file contains meeting transcripts and notes that may be sensitive. Never transmit this data externally.