---
name: obsidian-import
description: Import an Obsidian vault (extracted directory of markdown notes, frontmatter, wikilinks, tags, embedded attachments) into a {{productName}} Neo4j graph. Triggers when the user asks to import an Obsidian vault, ingest Obsidian notes, or mentions an Obsidian export by path. Skill does not parse markdown — parsing is server-side inside `mcp__plugin_memory_memory__obsidian-vault-import`. The skill is the operator-conversation surface.
---

# Obsidian Import

**Invoked from `specialists:archive-ingest-operator`** when the operator drops an extracted Obsidian vault directory (or references one by path) into chat. The admin agent dispatches the archive-ingest-operator; the archive-ingest-operator loads this skill.

**Default parent.** Every `:KnowledgeDocument` (vault page), `:DefinedTerm` (tag), `:DigitalDocument` (attachment), and `:Concept` stub this skill writes is anchored to the vault-owner. That owner is either an existing `:AdminUser` (the operator's own vault, the common case) or an external `:Person` (a partner's vault ingested for reference). The skill refuses to start until the owner is confirmed — every `:HAS_NOTE` edge points from the owner to a page, and that containment is what keeps imported pages out of orphan territory.

## Archive-owner confirmation (mandatory first step)

A vault belongs to exactly one person — the user whose Obsidian app produced it. Every page, every wikilink that resolves to the owner's contacts, every embedded photo is theirs. The skill takes the owner as an explicit input and refuses to proceed until it is confirmed.

The confirmation flow:

1. List every `:AdminUser` in the graph and display each as `{userId, name, createdAt, accountId(s) via ADMIN_OF}`.
2. Ask the operator to name the vault owner. Accept one of:
   - An existing AdminUser userId from the list (typical — the operator's own vault).
   - A new external Person identity (not yet an AdminUser) — create a fresh `:Person` with `givenName`, `familyName`, `email`/`telephone` (at least one) via `memory-write`, then use its `elementId` as the owner anchor.
3. Echo the chosen owner back verbatim (`userId=<id> name=<name>` or `personId=<id> name=<name>`) and require explicit yes/no confirmation before the tool runs.
4. Persist the confirmed owner as `$ownerNodeId` for the two tool invocations (dry-run + commit).

Refusing to assume is load-bearing: a hard-coded "it's whoever's running this" would misattribute a partner's notes to the operator on first re-use.

## Two-phase execution model

Imports run in two MCP-tool calls with operator interaction between them. The phases are non-negotiable — there is no single-shot mode, because wikilink ambiguity resolution belongs to the operator and the parser refuses to guess.

### Phase A — dry-run

1. Invoke `mcp__plugin_memory_memory__obsidian-vault-import` with `mode='dry-run'`, the confirmed `ownerNodeId`, the absolute `extractedDir`, and the operator's selected `filters` (see [§Selective-ingest](#selective-ingest)).
2. The tool walks the vault, parses everything server-side, classifies each wikilink (intra-vault page, fuzzy-matched existing entity, or stub), and returns:
   - `importId` — quote this back in the commit call.
   - `pagesDiscovered`, `pagesEligible`, `pagesUnchanged` — the operator sees the scope.
   - `wikilinks.{page, entity, stub, ambiguous}` — distribution by resolution kind.
   - `tagCount`, `attachmentCount`.
   - `ambiguities[]` — each carries an `id`, the source page, the wikilink target text, and competing candidate sets (`pageCandidates`, `entityCandidates`). These are the only items the operator must adjudicate.
   - `selectiveIngestSuggested` — true when `pagesEligible > selectiveIngestThreshold` and no filter was supplied. If true, go back to [§Selective-ingest](#selective-ingest) before continuing.
3. Surface the summary to the operator. Show each ambiguity in plain English: "Page X has a wikilink `[[Y]]` that matches both vault note `Y.md` AND existing :Person {name:'Y Smith'} — which did you mean?" Accept three answers per ambiguity: page (and which one), entity (and which one), or stub (create a fresh :Concept node).

### Phase B — commit

1. Invoke `mcp__plugin_memory_memory__obsidian-vault-import` with `mode='commit'`, the same `ownerNodeId`, `extractedDir`, `filters`, the `importId` from Phase A, and a `resolutions` map keyed by each ambiguity `id` carrying the operator's decision.
2. The tool re-walks the vault (parsing is deterministic + cheap), applies the resolutions, runs the content-hash idempotency filter against existing :KnowledgeDocument nodes (unchanged pages are skipped), copies embedded attachments to `{accountDir}/archive/obsidian/<importId>/`, then invokes `memory-archive-write` internally with `archiveType='obsidian-vault'`.
3. The tool returns the same plan shape plus `written.{createdPages, mergedPages, createdTags, createdTagEdges, createdAttachments, createdAttachmentEdges, createdWikilinkEdges}`, `attachmentsCopied`, and any per-batch `errors`.

After commit, surface counts in the chat: "Imported `N` new pages, updated `M` existing, created `K` wikilinks (`P` to vault pages, `E` to entities, `S` stub concepts), `T` tags, `A` attachments. Skipped `U` unchanged."

## Selective-ingest

The skill drives filter selection conversationally — not from a hardcoded list. Obsidian vaults vary too widely for a fixed filter axis. The natural axes are:

- **Folder path prefix** — vaults typically organise into `Daily Notes/`, `People/`, `Projects/`, etc. Filter by one or more top-level folders.
- **Frontmatter tags** — pages with `tags: [project, client]` in YAML can be picked by tag set.
- **Modified-since** — incremental imports: "only pages updated since 2026-03-01".
- **All of them** — combinations are additive (a page must satisfy every supplied filter).

**Threshold (`selectiveIngestSuggested`).** When dry-run reports `pagesEligible > 100` and no filter was supplied, ask the operator before committing: "your vault has 1,247 pages — which slice should I import first?". Offer the axes above and accept the operator's combination. Re-run dry-run with those filters to confirm the trimmed scope, then commit.

**Re-importing is idempotent.** Coming back later with a wider slice ("add Projects/ this time", "include the older notes too") hits the same `(accountId, obsidianPath)` MERGE — existing pages are matched and updated where content has changed; only the delta is new.

## Doctrine

- **Schema additions are last resort.** Vault pages reuse `:KnowledgeDocument` with `source='obsidian'`. Tags reuse `:DefinedTerm` with `category='obsidian-tag'`. Attachments reuse `:DigitalDocument`. Unresolved wikilink stubs reuse `:Concept` with `source='obsidian-stub'`. The only new schema is the `knowledge_doc_obsidian_path` index, which is part of `platform/neo4j/schema.cypher` and applied by `seed-neo4j.sh` at install time.
- **Natural-edge discipline.** A wikilink `[[X]]` is the operator's statement "this note connects to X". That becomes either `:WIKILINKS_TO` (page-to-page within the vault) or `:MENTIONS` (page-to-entity). A tag `#foo` becomes `:TAGGED_WITH`. An embedded image becomes `:EMBEDDED_IN`. No synthetic edges; every edge corresponds to something the markdown actually expresses.
- **No raw Cypher in this skill.** All writes route through `mcp__plugin_memory_memory__obsidian-vault-import`, which internally calls the `memory-archive-write` handler (the agent never invokes archive-write directly — `obsidian-vault-import` is the only tool the agent calls). Bash improvisation against `cypher-shell` is forbidden; if a vault structure the parser doesn't yet handle is needed, file a task to extend the parser library, never paper over it with a script.
- **Provenance stamp.** Every node the handler creates carries `source='obsidian'`, `createdByAgent='obsidian-import'`, `createdBySource='obsidian-import'`, `createdBySession=<sessionId>`, `createdAt=datetime()`, plus `scope='admin'` for memory-visibility.
- **Idempotency.** Same vault, same contents, same owner re-imported → `written.createdPages=0`, `mergedPages=0`, all `pagesUnchanged`. Changed pages update in place. No deletions on re-import (operator removes a note from the vault → the corresponding :KnowledgeDocument stays in Maxy until the operator explicitly trashes it).

## File roster — references

Each reference covers one parser surface inside this skill. The skill always loads these inline when the operator's question maps to one of them.

| Reference | Covers |
|-----------|--------|
| [vault-structure.md](references/vault-structure.md) | Directory layout, `.md` discovery, hidden-folder skipping, frontmatter format. |
| [wikilinks.md](references/wikilinks.md) | `[[Page]]`, `[[Page#anchor]]`, `[[Page\|Alias]]`, resolution priority, ambiguity handling. |
| [daily-notes.md](references/daily-notes.md) | Detection heuristic (filename pattern + folder name), `noteDate` property. |
| [attachments.md](references/attachments.md) | Embedded images and PDFs, archive directory layout, supported MIME types. |

## Observability

The tool emits these lines to `server.log`:

- `[obsidian-import] event=dry-run-complete importId=<id> pages=<n> unchanged=<n> wikilinks-page=<n> wikilinks-entity=<n> wikilinks-stub=<n> wikilinks-ambiguous=<n> tags=<n> attachments=<n> ms=<n>` — Phase A finished.
- `[obsidian-import] event=commit-complete importId=<id> pages=<n> written=<n> skipped=<n> wikilinks=<n> tags=<n> attachments=<n> errors=<n> ms=<n>` — Phase B finished.
- `[obsidian-import] event=attachment-missing page=<path> path=<rawpath>` — per-page when an attachment file could not be located.
- `[obsidian-parser] event=parse-failed file=<path> reason=<text>` — per-page when parsing threw; that page is skipped, the batch continues.

Diagnostic path: `grep "obsidian-import" server.log | tail -30` shows recent imports. Join with `[memory-archive-write] archiveType=obsidian-vault` lines to see per-batch write decisions.
