personal memory agent
0
fork

Configure Feed

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

feat: Obsidian vault sync backend + Settings UI

Add ObsidianSyncBackend implementing SyncableBackend protocol for
continuous incremental sync of Obsidian vault notes. Edit-as-activity
model: edits create new journal segments at edit time, building a
history of engagement with each note.

Backend (obsidian.py):
- Two-level change detection: mtime fast path, SHA-256 content hash
- New notes: full content import via existing parsing, segment at mtime
- Edited notes: new segment at current mtime, edit_count tracked
- Deleted notes: status "removed", journal segments preserved
- Vault auto-detection: ~/Documents/Obsidian/, ~/Obsidian/, or --path
- Sync state at {journal}/imports/obsidian.json
- Registered as "obsidian" in SYNCABLE_REGISTRY

UI (Settings > Sync):
- Obsidian Vault card alongside Plaud and Granola
- Toggle for hourly sync schedule
- GET/PUT /api/sync endpoints handle obsidian

CLI: sol import --sync obsidian [--save] [--force] [--path /custom]

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

+345
+39
apps/settings/routes.py
··· 1537 1537 1538 1538 plaud_entry = schedules.get("sync:plaud", {}) 1539 1539 granola_entry = schedules.get("sync:granola", {}) 1540 + obsidian_entry = schedules.get("sync:obsidian", {}) 1540 1541 1541 1542 # Check token availability from env/system_env 1542 1543 config = get_journal_config() ··· 1559 1560 granola_entry.get("enabled", True) if granola_entry else False 1560 1561 ), 1561 1562 "configured": bool(granola_entry), 1563 + }, 1564 + "obsidian": { 1565 + "available": True, 1566 + "enabled": ( 1567 + obsidian_entry.get("enabled", True) 1568 + if obsidian_entry 1569 + else False 1570 + ), 1571 + "configured": bool(obsidian_entry), 1562 1572 }, 1563 1573 } 1564 1574 ) ··· 1639 1649 } 1640 1650 schedules["sync:granola"]["enabled"] = enabled 1641 1651 changed_fields["granola.enabled"] = enabled 1652 + 1653 + # Handle obsidian sync toggle 1654 + if "obsidian" in request_data: 1655 + obsidian_data = request_data["obsidian"] 1656 + if not isinstance(obsidian_data, dict): 1657 + return jsonify({"error": "obsidian must be an object"}), 400 1658 + 1659 + if "enabled" in obsidian_data: 1660 + enabled = obsidian_data["enabled"] 1661 + if not isinstance(enabled, bool): 1662 + return jsonify({"error": "obsidian.enabled must be a boolean"}), 400 1663 + 1664 + old_entry = schedules.get("sync:obsidian", {}) 1665 + old_enabled = old_entry.get("enabled", True) if old_entry else False 1666 + 1667 + if enabled != old_enabled: 1668 + if "sync:obsidian" not in schedules: 1669 + schedules["sync:obsidian"] = { 1670 + "cmd": [ 1671 + "sol", 1672 + "import", 1673 + "--sync", 1674 + "obsidian", 1675 + "--save", 1676 + ], 1677 + "every": "hourly", 1678 + } 1679 + schedules["sync:obsidian"]["enabled"] = enabled 1680 + changed_fields["obsidian.enabled"] = enabled 1642 1681 1643 1682 if changed_fields: 1644 1683 with open(schedules_path, "w", encoding="utf-8") as f:
+40
apps/settings/workspace.html
··· 2091 2091 </div> 2092 2092 </div> 2093 2093 </div> 2094 + 2095 + <div id="obsidianSyncCard"> 2096 + <h3 style="margin: 1.5em 0 0.75em 0; font-size: 1em; color: #374151;">Obsidian Vault</h3> 2097 + <p style="color: #666; font-size: 0.9em; margin: 0 0 1em 0;"> 2098 + Automatically sync new and updated notes from your vault. 2099 + </p> 2100 + 2101 + <div class="settings-field" id="obsidianSyncField"> 2102 + <label>Hourly Sync</label> 2103 + <div style="display: flex; align-items: center; gap: 1em;"> 2104 + <label class="toggle-switch toggle-positive"> 2105 + <input type="checkbox" id="field-obsidian-sync-enabled"> 2106 + <span class="slider"></span> 2107 + </label> 2108 + <span style="color: #666; font-size: 0.9em;">Check for changes every hour</span> 2109 + </div> 2110 + </div> 2111 + </div> 2094 2112 </section> 2095 2113 2096 2114 <!-- Facet Appearance Section --> ··· 3514 3532 const granola = data.granola || {}; 3515 3533 const granolaToggle = document.getElementById('field-granola-sync-enabled'); 3516 3534 granolaToggle.checked = granola.enabled || false; 3535 + 3536 + // Obsidian sync card 3537 + const obsidian = data.obsidian || {}; 3538 + const obsidianToggle = document.getElementById('field-obsidian-sync-enabled'); 3539 + obsidianToggle.checked = obsidian.enabled || false; 3517 3540 } 3518 3541 3519 3542 document.getElementById('field-plaud-sync-enabled').addEventListener('change', async function() { ··· 3540 3563 method: 'PUT', 3541 3564 headers: { 'Content-Type': 'application/json' }, 3542 3565 body: JSON.stringify({ granola: { enabled } }) 3566 + }); 3567 + const result = await response.json(); 3568 + if (result.error) throw new Error(result.error); 3569 + showFieldStatus(this, 'saved'); 3570 + } catch (err) { 3571 + console.error('Error saving sync setting:', err); 3572 + showFieldStatus(this, 'error', err.message); 3573 + } 3574 + }); 3575 + 3576 + document.getElementById('field-obsidian-sync-enabled').addEventListener('change', async function() { 3577 + const enabled = this.checked; 3578 + try { 3579 + const response = await fetch('api/sync', { 3580 + method: 'PUT', 3581 + headers: { 'Content-Type': 'application/json' }, 3582 + body: JSON.stringify({ obsidian: { enabled } }) 3543 3583 }); 3544 3584 const result = await response.json(); 3545 3585 if (result.error) throw new Error(result.error);
+265
think/importers/obsidian.py
··· 4 4 """Obsidian and Logseq vault importer.""" 5 5 6 6 import datetime as dt 7 + import hashlib 7 8 import logging 8 9 import os 9 10 import re ··· 18 19 write_content_manifest, 19 20 write_markdown_segments, 20 21 ) 22 + from think.importers.sync import load_sync_state, save_sync_state 21 23 22 24 logger = logging.getLogger(__name__) 23 25 ··· 420 422 return md_files 421 423 422 424 425 + # Common Obsidian vault locations for auto-detection 426 + DEFAULT_VAULT_PATHS = [ 427 + Path.home() / "Documents" / "Obsidian", 428 + Path.home() / "Obsidian", 429 + ] 430 + 431 + 432 + def _content_hash(content: str) -> str: 433 + """SHA-256 hash of file content for change detection.""" 434 + return hashlib.sha256(content.encode("utf-8")).hexdigest() 435 + 436 + 437 + def _find_vault_path( 438 + source_path: Path | None, state: dict[str, Any] | None 439 + ) -> Path: 440 + """Resolve vault path from explicit arg, sync state, or auto-detection.""" 441 + if source_path: 442 + if not source_path.is_dir(): 443 + raise ValueError(f"Vault path does not exist: {source_path}") 444 + return source_path 445 + 446 + # Check existing sync state 447 + if state and state.get("source_path"): 448 + saved = Path(state["source_path"]) 449 + if saved.is_dir(): 450 + return saved 451 + 452 + # Auto-detect common locations 453 + for p in DEFAULT_VAULT_PATHS: 454 + if p.is_dir(): 455 + return p 456 + 457 + raise ValueError( 458 + "No Obsidian vault found.\n" 459 + "Specify a vault path: sol import --sync obsidian --path /path/to/vault" 460 + ) 461 + 462 + 463 + class ObsidianSyncBackend: 464 + """Syncable backend for Obsidian vault notes with edit-as-activity model.""" 465 + 466 + name: str = "obsidian" 467 + 468 + def sync( 469 + self, 470 + journal_root: Path, 471 + *, 472 + dry_run: bool = True, 473 + source_path: Path | None = None, 474 + force: bool = False, 475 + ) -> dict[str, Any]: 476 + """Sync Obsidian vault notes incrementally. 477 + 478 + Scans the vault for markdown files, compares against sync state, 479 + and imports new notes and captures edits as new journal segments. 480 + 481 + Args: 482 + journal_root: Path to the journal root directory. 483 + dry_run: If True, catalog only (no import). If False, import. 484 + source_path: Override vault directory path. 485 + force: If True, clear sync state and re-import everything. 486 + 487 + Returns: 488 + Summary dict with total, imported, available, skipped, downloaded, errors. 489 + """ 490 + state = load_sync_state(journal_root, "obsidian") 491 + 492 + vault_path = _find_vault_path(source_path, state) 493 + 494 + if state is None: 495 + state = { 496 + "backend": "obsidian", 497 + "source_path": str(vault_path), 498 + "files": {}, 499 + } 500 + 501 + if force: 502 + state["files"] = {} 503 + 504 + known_files: dict[str, dict[str, Any]] = state.get("files", {}) 505 + 506 + # Walk vault using existing importer logic 507 + md_files = importer._walk_md_files(vault_path) 508 + 509 + current_rel_paths: set[str] = set() 510 + to_import: list[tuple[Path, str, str]] = [] # (path, rel_path, change_type) 511 + 512 + for md_path in md_files: 513 + rel_path = str(md_path.relative_to(vault_path)) 514 + current_rel_paths.add(rel_path) 515 + 516 + content = _read_file_safe(md_path) 517 + if content is None or not content.strip(): 518 + continue 519 + 520 + try: 521 + mtime = md_path.stat().st_mtime 522 + except OSError: 523 + continue 524 + 525 + hash_val = _content_hash(content) 526 + 527 + if rel_path in known_files and not force: 528 + existing = known_files[rel_path] 529 + if existing.get("status") == "imported": 530 + # Fast path: mtime unchanged — skip 531 + if mtime == existing.get("mtime"): 532 + continue 533 + # Correctness: hash unchanged — mtime-only change, skip 534 + if hash_val == existing.get("content_hash"): 535 + existing["mtime"] = mtime 536 + continue 537 + # Content actually changed — edit 538 + change_type = "edited" 539 + elif existing.get("status") == "removed": 540 + change_type = "new" 541 + else: 542 + change_type = "new" 543 + else: 544 + change_type = "new" 545 + 546 + known_files.setdefault(rel_path, {}) 547 + known_files[rel_path].update( 548 + { 549 + "filename": md_path.name, 550 + "title": md_path.stem, 551 + "mtime": mtime, 552 + "content_hash": hash_val, 553 + "status": "available", 554 + "_change_type": change_type, 555 + } 556 + ) 557 + to_import.append((md_path, rel_path, change_type)) 558 + 559 + # Detect deleted files 560 + for rel_path, info in known_files.items(): 561 + if rel_path not in current_rel_paths and info.get("status") not in ( 562 + "removed", 563 + "skipped", 564 + ): 565 + info["status"] = "removed" 566 + 567 + total = len(known_files) 568 + imported = sum( 569 + 1 for f in known_files.values() if f.get("status") == "imported" 570 + ) 571 + available = len(to_import) 572 + skipped_total = sum( 573 + 1 574 + for f in known_files.values() 575 + if f.get("status") in ("skipped", "removed") 576 + ) 577 + 578 + result: dict[str, Any] = { 579 + "total": total, 580 + "imported": imported, 581 + "available": available, 582 + "skipped": skipped_total, 583 + "downloaded": 0, 584 + "errors": [], 585 + } 586 + 587 + if not dry_run and to_import: 588 + downloaded = 0 589 + errors: list[str] = [] 590 + 591 + for md_path, rel_path, change_type in to_import: 592 + try: 593 + content = _read_file_safe(md_path) 594 + if content is None: 595 + errors.append(f"Failed to read: {rel_path}") 596 + continue 597 + 598 + mtime = md_path.stat().st_mtime 599 + tags = _parse_frontmatter_tags(content) 600 + wikilinks = WIKILINK_RE.findall(content) 601 + 602 + note = { 603 + "mtime": mtime, 604 + "title": md_path.stem, 605 + "content": content, 606 + "source_path": rel_path, 607 + "is_daily": _parse_daily_note_date(md_path.name) is not None, 608 + "tags": tags, 609 + "wikilinks": wikilinks, 610 + } 611 + 612 + windows = window_items([note], "mtime", tz=None) 613 + created_files, segments = write_markdown_segments( 614 + "obsidian", 615 + windows, 616 + lambda items: "\n\n".join( 617 + _render_note_markdown(n) for n in items 618 + ), 619 + filename="note_transcript.md", 620 + ) 621 + 622 + # Seed entities from wikilinks 623 + if wikilinks and segments: 624 + entity_dicts = [ 625 + {"name": link, "type": "Topic"} 626 + for link in sorted(set(wikilinks)) 627 + ] 628 + try: 629 + seed_entities( 630 + "import.obsidian", segments[0][0], entity_dicts 631 + ) 632 + except Exception as exc: 633 + logger.warning( 634 + "Entity seeding failed for %s: %s", rel_path, exc 635 + ) 636 + 637 + # Update sync state for this file 638 + file_state = known_files[rel_path] 639 + edit_count = file_state.get("edit_count", 0) 640 + if change_type == "edited": 641 + edit_count += 1 642 + 643 + file_state.update( 644 + { 645 + "status": "imported", 646 + "mtime": mtime, 647 + "content_hash": _content_hash(content), 648 + "edit_count": edit_count, 649 + "imported_at": dt.datetime.now().isoformat(), 650 + "segments": len(segments), 651 + } 652 + ) 653 + file_state.pop("_change_type", None) 654 + 655 + downloaded += 1 656 + action = "Re-imported (edit)" if change_type == "edited" else "Imported" 657 + logger.info("%s %s: %d segments", action, rel_path, len(segments)) 658 + 659 + except Exception as exc: 660 + msg = f"{rel_path}: {exc}" 661 + logger.warning("Import failed: %s", msg) 662 + errors.append(msg) 663 + 664 + result["downloaded"] = downloaded 665 + result["errors"] = errors 666 + result["imported"] = sum( 667 + 1 for f in known_files.values() if f.get("status") == "imported" 668 + ) 669 + result["available"] = sum( 670 + 1 for f in known_files.values() if f.get("status") == "available" 671 + ) 672 + 673 + # Clean transient fields before persisting 674 + for info in known_files.values(): 675 + info.pop("_change_type", None) 676 + 677 + state["files"] = known_files 678 + state["source_path"] = str(vault_path) 679 + state["last_sync"] = dt.datetime.now().isoformat() 680 + save_sync_state(journal_root, "obsidian", state) 681 + 682 + return result 683 + 684 + 423 685 importer = ObsidianImporter() 686 + 687 + # Module-level backend instance for registry discovery 688 + backend = ObsidianSyncBackend()
+1
think/importers/sync.py
··· 24 24 SYNCABLE_REGISTRY: dict[str, str] = { 25 25 "plaud": "think.importers.plaud", 26 26 "granola": "think.importers.granola", 27 + "obsidian": "think.importers.obsidian", 27 28 } 28 29 29 30