Skip to content

Phase 6: Obsidian Adapter #90

Description

@tinkermonkey

Implement the ObsidianAdapter that ingests an Obsidian vault, extracting per-note YAML frontmatter metadata and vault-level wikilink graph data using obsidiantools.

Requirements

  • Ingest an Obsidian vault directory, treating each .md note as a source ref (FR-5.1)
  • Extract per-note metadata: YAML frontmatter, tags, aliases using a frontmatter parsing library (FR-5.2)
  • Extract vault-level graph metadata (backlinks, forward wikilinks, connectivity) using obsidiantools (FR-5.3)
  • Store tags, aliases, frontmatter properties, wikilink graph edges, creation/modification dates in domain_metadata on each Chunk (FR-5.4)
  • Use _watching.py FileSystemWatcher when configured for push-based ingestion (FR-5.5)
  • No format conversion — vault notes are already markdown (FR-5.6)
  • Set domain = Domain.NOTES (FR-5.7)
  • adapter_id must be f"obsidian:{resolved_vault_path}"
  • Add obsidiantools to optional dependencies under obsidian group in pyproject.toml

Design Guidance

File to create: src/context_library/adapters/obsidian.py

Note: The exploration found no existing stub for this file — create it from scratch.

Class signature:

class ObsidianAdapter(BaseAdapter):
    def __init__(
        self,
        vault_path: Path,
        poll_strategy: PollStrategy = PollStrategy.PULL,
    ) -> None:
        self._vault_path = vault_path.resolve()
        self._poll_strategy = poll_strategy
        self._vault = None  # lazy-loaded obsidiantools.Vault

    @property
    def adapter_id(self) -> str:
        return f"obsidian:{self._vault_path}"

    @property
    def domain(self) -> Domain:
        return Domain.NOTES

    @property
    def normalizer_version(self) -> str:
        return "1.0.0"

    def fetch(self, source_ref: str) -> Iterator[NormalizedContent]: ...

obsidiantools integration:

import obsidiantools.api as otools

# Initialize and connect vault (builds the wikilink graph)
vault = otools.Vault(self._vault_path).connect()

# Per-note graph data:
backlinks = vault.get_backlinks(note_name)   # notes linking TO this note
wikilinks = vault.get_wikilinks(note_name)   # notes this note links TO

YAML frontmatter parsing: Use python-frontmatter or PyYAML directly:

import frontmatter  # python-frontmatter package

post = frontmatter.load(str(note_path))
fm_data = post.metadata  # dict of YAML frontmatter
body = post.content      # markdown without frontmatter
tags = fm_data.get('tags', [])
aliases = fm_data.get('aliases', [])

Metadata to store in domain_metadata (passed through extra_metadata on StructuralHints):

{
    "tags": ["tag1", "tag2"],
    "aliases": ["alias1"],
    "frontmatter": {"key": "value", ...},  # all frontmatter properties
    "wikilinks": ["Note Title 1", "Note Title 2"],  # forward links
    "backlinks": ["Note Title 3"],  # notes that link to this note
    "created_at": "2024-01-01T00:00:00",
    "modified_at": "2024-01-02T00:00:00",
}

NotesDomain reuse: The ObsidianAdapter sets domain = Domain.NOTES, so the existing NotesDomain chunker handles chunking. The adapter passes vault metadata through StructuralHints.extra_metadata so the pipeline can store it in Chunk.domain_metadata.

pyproject.toml change:

[project.optional-dependencies]
obsidian = ["obsidiantools", "python-frontmatter", "watchdog"]

Push mode: When poll_strategy == PollStrategy.PUSH, instantiate FileSystemWatcher scoped to vault path with extensions={'.md'}.

Acceptance Criteria

  • ObsidianAdapter(vault_path=path).adapter_id returns f"obsidian:{path.resolve()}"
  • fetch("") on a vault directory yields one NormalizedContent per .md note
  • NormalizedContent.markdown contains the note body without YAML frontmatter block
  • NormalizedContent.structural_hints includes tags, aliases, and frontmatter from YAML frontmatter
  • NormalizedContent.structural_hints includes wikilinks (forward links) and backlinks for the note
  • Notes with no frontmatter yield NormalizedContent without error
  • domain property returns Domain.NOTES
  • When poll_strategy=PollStrategy.PUSH, a FileSystemWatcher instance scoped to the vault path is created
  • Only modified notes (not the entire vault) are re-ingested when a single file changes in push mode
  • ObsidianAdapter is importable from context_library.adapters.obsidian
  • Unit tests use a fixture vault directory with at least 2 notes (one with wikilinks to the other)

Dependencies

Phase 1: Model Additions (PollStrategy + MessageMetadata), Phase 3: Shared Filesystem Watcher (_watching.py)

Parent Issue

Part of #83

Discussion

This work is detailed in discussion 84

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions