personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add Granola to Convey import app and Settings sync

- Import app: add Granola source card (path_input type with guide),
auto-detect ~/.local/share/muesli/transcripts/ via /api/check-path
endpoint, pre-fill path and show transcript count
- Settings > Sync: add Granola toggle card alongside Plaud with
hourly sync schedule (sol import --sync granola --save)
- Auto-run muesli sync: when hourly sync fires, check for muesli on
PATH and run `muesli sync` before importing (hands-off sync chain)
- Guide: setup instructions for muesli install + extraction

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+216 -2
+25
apps/import/guides/granola.md
··· 1 + # Importing Granola Transcripts 2 + 3 + Granola doesn't offer a native export — [muesli](https://github.com/harperreed/muesli) extracts your meeting transcripts to local markdown files. 4 + 5 + ## Install muesli 6 + 7 + ```bash 8 + cargo install --git https://github.com/harperreed/muesli.git --all-features 9 + ``` 10 + 11 + Requires [Rust/Cargo](https://rustup.rs/). If you already have Rust installed, this takes about a minute. 12 + 13 + ## Extract your transcripts 14 + 15 + ```bash 16 + muesli sync 17 + ``` 18 + 19 + This pulls your Granola meeting transcripts to `~/.local/share/muesli/transcripts/`. First run downloads everything; subsequent runs are incremental (~1 second). 20 + 21 + ## Point solstone at the output 22 + 23 + In the next step, confirm the path to your muesli transcripts folder (auto-detected if found at the default location). 24 + 25 + After import, you can enable hourly sync in **Settings > Sync** so new Granola meetings appear in your journal automatically.
+46
apps/import/routes.py
··· 101 101 "accept": ".txt", 102 102 }, 103 103 { 104 + "name": "granola", 105 + "display_name": "Granola", 106 + "emoji": "🎙️", 107 + "icon": "mic", 108 + "description": "Import meeting transcripts from Granola via muesli", 109 + "input_type": "path_input", 110 + "upload_prompt": "Path to muesli transcripts folder", 111 + "has_guide": True, 112 + "accept": "", 113 + }, 114 + { 104 115 "name": "recording", 105 116 "display_name": "Meeting Recording", 106 117 "emoji": "🎙️", ··· 423 434 def import_sources() -> Any: 424 435 """Return available import source metadata.""" 425 436 return jsonify(SOURCE_METADATA) 437 + 438 + 439 + @import_bp.route("/api/check-path/<source>") 440 + def import_check_path(source: str) -> Any: 441 + """Check default path for a path_input source and return info.""" 442 + if source == "granola": 443 + default_path = Path.home() / ".local" / "share" / "muesli" / "transcripts" 444 + if default_path.is_dir(): 445 + count = sum(1 for f in default_path.glob("*.md")) 446 + return jsonify( 447 + { 448 + "found": True, 449 + "path": str(default_path), 450 + "count": count, 451 + "message": f"Found {count} Granola transcript{'s' if count != 1 else ''}.", 452 + } 453 + ) 454 + # Check if muesli is installed but no transcripts yet 455 + muesli_dir = default_path.parent 456 + if muesli_dir.is_dir(): 457 + return jsonify( 458 + { 459 + "found": False, 460 + "path": str(default_path), 461 + "message": "Muesli is installed but no transcripts found. Run `muesli sync` first.", 462 + } 463 + ) 464 + return jsonify( 465 + { 466 + "found": False, 467 + "path": "", 468 + "message": "No muesli installation found. Follow the guide above to install.", 469 + } 470 + ) 471 + return jsonify({"found": False, "path": "", "message": ""}), 404 426 472 427 473 428 474 @import_bp.route("/api/guide/<source>")
+28 -1
apps/import/workspace.html
··· 968 968 <div class="import-step"> 969 969 <div class="import-step-number">Step 2</div> 970 970 ${source.input_type === 'path_input' ? ` 971 - <h4>Point to your vault</h4> 971 + <h4>${escapeHtml(source.upload_prompt || 'Paste the full path to your folder')}</h4> 972 972 <input id="guidedPathInput" class="import-path-input" type="text" 973 973 placeholder="${escapeHtml(source.upload_prompt || 'Paste the full path to your folder')}" /> 974 + <div id="guidedPathStatus" style="margin-top: 0.5em; font-size: 0.9em; color: #666;"></div> 974 975 ` : ` 975 976 <h4>Upload your export</h4> 976 977 <div class="import-upload-area" id="guidedDropArea"> ··· 1004 1005 loadFacets(window.selectedFacet || '', document.getElementById('guidedFacetSelect')); 1005 1006 if (source.input_type !== 'path_input') { 1006 1007 setupGuidedUploadArea(); 1008 + } else { 1009 + // Auto-detect default path for path_input sources 1010 + checkDefaultPath(sourceName); 1007 1011 } 1008 1012 document.getElementById('guidedStartBtn').addEventListener('click', () => startGuidedImport(source)); 1013 + } 1014 + 1015 + async function checkDefaultPath(sourceName) { 1016 + const pathInput = document.getElementById('guidedPathInput'); 1017 + const statusDiv = document.getElementById('guidedPathStatus'); 1018 + if (!pathInput || !statusDiv) return; 1019 + 1020 + try { 1021 + const response = await fetch(`/app/import/api/check-path/${sourceName}`); 1022 + if (!response.ok) return; 1023 + const data = await response.json(); 1024 + 1025 + if (data.found && data.path) { 1026 + pathInput.value = data.path; 1027 + statusDiv.textContent = data.message; 1028 + statusDiv.style.color = '#059669'; 1029 + } else if (data.message) { 1030 + statusDiv.textContent = data.message; 1031 + statusDiv.style.color = '#d97706'; 1032 + } 1033 + } catch (err) { 1034 + // Silent fail — auto-detection is best-effort 1035 + } 1009 1036 } 1010 1037 1011 1038 function renderQuickImportFlow() {
+39 -1
apps/settings/routes.py
··· 1536 1536 schedules = json.load(f) 1537 1537 1538 1538 plaud_entry = schedules.get("sync:plaud", {}) 1539 + granola_entry = schedules.get("sync:granola", {}) 1539 1540 1540 1541 # Check token availability from env/system_env 1541 1542 config = get_journal_config() ··· 1552 1553 plaud_entry.get("enabled", True) if plaud_entry else False 1553 1554 ), 1554 1555 "configured": bool(plaud_entry), 1555 - } 1556 + }, 1557 + "granola": { 1558 + "enabled": ( 1559 + granola_entry.get("enabled", True) 1560 + if granola_entry 1561 + else False 1562 + ), 1563 + "configured": bool(granola_entry), 1564 + }, 1556 1565 } 1557 1566 ) 1558 1567 ··· 1603 1612 } 1604 1613 schedules["sync:plaud"]["enabled"] = enabled 1605 1614 changed_fields["plaud.enabled"] = enabled 1615 + 1616 + # Handle granola sync toggle 1617 + if "granola" in request_data: 1618 + granola_data = request_data["granola"] 1619 + if not isinstance(granola_data, dict): 1620 + return jsonify({"error": "granola must be an object"}), 400 1621 + 1622 + if "enabled" in granola_data: 1623 + enabled = granola_data["enabled"] 1624 + if not isinstance(enabled, bool): 1625 + return jsonify({"error": "granola.enabled must be a boolean"}), 400 1626 + 1627 + old_entry = schedules.get("sync:granola", {}) 1628 + old_enabled = old_entry.get("enabled", True) if old_entry else False 1629 + 1630 + if enabled != old_enabled: 1631 + if "sync:granola" not in schedules: 1632 + schedules["sync:granola"] = { 1633 + "cmd": [ 1634 + "sol", 1635 + "import", 1636 + "--sync", 1637 + "granola", 1638 + "--save", 1639 + ], 1640 + "every": "hourly", 1641 + } 1642 + schedules["sync:granola"]["enabled"] = enabled 1643 + changed_fields["granola.enabled"] = enabled 1606 1644 1607 1645 if changed_fields: 1608 1646 with open(schedules_path, "w", encoding="utf-8") as f:
+40
apps/settings/workspace.html
··· 2073 2073 Configure your Plaud token in <a href="#apikeys" style="color: #667eea;">API Keys</a> to enable sync. 2074 2074 </p> 2075 2075 </div> 2076 + 2077 + <div id="granolaSyncCard"> 2078 + <h3 style="margin: 1.5em 0 0.75em 0; font-size: 1em; color: #374151;">Granola (via muesli)</h3> 2079 + <p style="color: #666; font-size: 0.9em; margin: 0 0 1em 0;"> 2080 + Automatically sync new meeting transcripts from Granola. 2081 + </p> 2082 + 2083 + <div class="settings-field" id="granolaSyncField"> 2084 + <label>Hourly Sync</label> 2085 + <div style="display: flex; align-items: center; gap: 1em;"> 2086 + <label class="toggle-switch toggle-positive"> 2087 + <input type="checkbox" id="field-granola-sync-enabled"> 2088 + <span class="slider"></span> 2089 + </label> 2090 + <span style="color: #666; font-size: 0.9em;">Check for new transcripts every hour</span> 2091 + </div> 2092 + </div> 2093 + </div> 2076 2094 </section> 2077 2095 2078 2096 <!-- Facet Appearance Section --> ··· 3491 3509 field.style.opacity = '0.5'; 3492 3510 note.style.display = 'block'; 3493 3511 } 3512 + 3513 + // Granola sync card 3514 + const granola = data.granola || {}; 3515 + const granolaToggle = document.getElementById('field-granola-sync-enabled'); 3516 + granolaToggle.checked = granola.enabled || false; 3494 3517 } 3495 3518 3496 3519 document.getElementById('field-plaud-sync-enabled').addEventListener('change', async function() { ··· 3500 3523 method: 'PUT', 3501 3524 headers: { 'Content-Type': 'application/json' }, 3502 3525 body: JSON.stringify({ plaud: { enabled } }) 3526 + }); 3527 + const result = await response.json(); 3528 + if (result.error) throw new Error(result.error); 3529 + showFieldStatus(this, 'saved'); 3530 + } catch (err) { 3531 + console.error('Error saving sync setting:', err); 3532 + showFieldStatus(this, 'error', err.message); 3533 + } 3534 + }); 3535 + 3536 + document.getElementById('field-granola-sync-enabled').addEventListener('change', async function() { 3537 + const enabled = this.checked; 3538 + try { 3539 + const response = await fetch('api/sync', { 3540 + method: 'PUT', 3541 + headers: { 'Content-Type': 'application/json' }, 3542 + body: JSON.stringify({ granola: { enabled } }) 3503 3543 }); 3504 3544 const result = await response.json(); 3505 3545 if (result.error) throw new Error(result.error);
+38
think/importers/cli.py
··· 107 107 return timestamp 108 108 109 109 110 + def _run_muesli_sync() -> bool: 111 + """Run `muesli sync` if muesli is on PATH. Returns True if it ran successfully.""" 112 + import shutil 113 + import subprocess 114 + 115 + muesli_path = shutil.which("muesli") 116 + if not muesli_path: 117 + logger.info("muesli not found on PATH — skipping muesli sync") 118 + return False 119 + 120 + print("Running muesli sync...") 121 + try: 122 + result = subprocess.run( 123 + [muesli_path, "sync"], 124 + capture_output=True, 125 + text=True, 126 + timeout=60, 127 + ) 128 + if result.returncode == 0: 129 + print(" muesli sync complete.") 130 + else: 131 + logger.warning("muesli sync exited with code %d: %s", result.returncode, result.stderr.strip()) 132 + print(f" muesli sync failed (exit {result.returncode}), continuing with existing files.") 133 + return result.returncode == 0 134 + except subprocess.TimeoutExpired: 135 + logger.warning("muesli sync timed out after 60s") 136 + print(" muesli sync timed out, continuing with existing files.") 137 + return False 138 + except Exception as exc: 139 + logger.warning("muesli sync error: %s", exc) 140 + print(f" muesli sync error: {exc}, continuing with existing files.") 141 + return False 142 + 143 + 110 144 def _run_sync(backend_name: str, *, dry_run: bool = True, **extra: Any) -> None: 111 145 """Run sync for a named backend and print results.""" 112 146 import inspect ··· 129 163 raise SystemExit( 130 164 f"Unknown sync backend: {backend_name}\nAvailable backends: {available}" 131 165 ) 166 + 167 + # Auto-run muesli sync before granola import (hands-off sync chain) 168 + if backend_name == "granola" and not dry_run: 169 + _run_muesli_sync() 132 170 133 171 mode = "save" if not dry_run else "catalog" 134 172 print(f"Syncing {backend_name} ({mode} mode)...")