
Obsidian Literature Workflow
- 1 installs
- 3.2k repo stars
- Updated August 4, 2026
- brycewang-stanford/awesome-agent-skills-for-empirical-research
obsidian-literature-workflow is a skill that runs filesystem-first literature review inside an Obsidian project vault, ingesting Zotero papers and synthesizing them into knowledge notes.
About
This skill handles the literature sub-workflow inside a filesystem-first Obsidian research vault. A researcher uses it to ingest papers from Zotero into canonical Papers/ notes, normalize note structure, synthesize durable literature knowledge into Knowledge/ notes, and refresh a literature canvas. It matters for organizing paper notes and connecting them to project knowledge without an Obsidian MCP.
- Filesystem-first literature review inside an Obsidian vault
- Ingests Zotero papers into canonical Papers/ notes
- Synthesizes literature into Knowledge/ notes and a literature canvas
Obsidian Literature Workflow by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,361 of 1,879 Documentation skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
obsidian-literature-workflow capabilities & compatibility
- Capabilities
- literature review · research synthesis
- Works with
- obsidian
- Use cases
- research · documentation
- Pricing
- Free
What obsidian-literature-workflow says it does
Handle the **literature sub-workflow** inside the filesystem-first Obsidian project knowledge base.
Durable paper notes live under `Papers/` inside `Research/{project-slug}/`
npx skills add https://github.com/brycewang-stanford/awesome-agent-skills-for-empirical-research --skill obsidian-literature-workflowAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 3.2k |
| Last updated | August 4, 2026 |
| Repository | brycewang-stanford/awesome-agent-skills-for-empirical-research ↗ |
What it does
Run filesystem-first literature review, ingest Zotero papers, and synthesize paper notes into project knowledge in Obsidian.
Who is it for?
Managing paper notes and literature synthesis inside an Obsidian research vault.
Skip if: Workflows that require an Obsidian MCP, or creating Concepts, Datasets, or .base views by default.
When should I use this skill?
When the user keeps paper notes in an Obsidian project and wants filesystem-first literature review or Zotero ingestion.
What you get
Canonical Papers/ notes, durable Knowledge/ synthesis, and a refreshed literature canvas.
- Canonical Papers/*.md notes
- Knowledge/ literature synthesis notes
- Maps/literature.canvas
By the numbers
- 10-step default workflow
- 6-field standardized review schema
Files
Obsidian Literature Workflow
Handle the literature sub-workflow inside the filesystem-first Obsidian project knowledge base.
Role in the workflow
This is a supporting skill under obsidian-project-memory.
Use it when the user says things like:
- “My papers are in Obsidian”
- “Read papers from Zotero and create notes in the vault”
- “Review the notes under
Papers/” - “Generate literature knowledge notes from this project vault”
- “Connect paper notes to project knowledge”
- “Show me the literature structure as a map/graph”
- “Check whether all papers already have detailed notes”
Assumptions
- The project is already bound through
.claude/project-memory/registry.yaml, or can be imported withobsidian-project-bootstrap - Durable paper notes live under
Papers/insideResearch/{project-slug}/ - Obsidian note writing is done through the filesystem; no Obsidian MCP is required
- Zotero may still be used as the source of metadata/full text; use
$zotero-obsidian-bridgewhen the source corpus is in Zotero
Default workflow
1. Read bound project memory and locate the vault root. 2. If the source papers are in Zotero, explicitly use $zotero-obsidian-bridge to pull them into canonical Papers/*.md notes. 3. Scan Papers/ and adjacent synthesis notes using filesystem tools. 4. Normalize note metadata and structure using $obsidian-markdown.
- Prefer the standardized review schema:
ClaimMethodEvidenceLimitationDirect relevance to repoRelation to other papers
5. Query narrowly first:
- read the relevant paper notes,
- then the linked
Knowledge/notes, - and only then open
Writing/if the user asked for a review or comparison deliverable.
6. Prefer updating existing paper notes and literature synthesis notes over creating sibling notes. 7. Default literature synthesis should land in Knowledge/, not Experiments/ or Results/:
- update
Knowledge/Literature-Overview.md - update
Knowledge/Method-Families.md - update
Knowledge/Research-Gaps.md
when the synthesis is stable enough to deserve canonical notes. 8. If the source is a named collection and the user cares about completeness, maintain a collection inventory note with explicit coverage mapping. 9. Refresh Maps/literature.canvas after major paper-note changes or batch note creation.
- Prefer argument-map structure over dense all-to-all links.
- Prefer semantic filtering and edge thinning.
- Create
Maps/literature-main.canvasonly when a lightweight presentation graph is needed.
10. Update the daily note and project memory with what changed.
Default outputs
Papers/remains first-class: one durable paper note per paper whenever possibleKnowledge/holds durable literature synthesis notesMaps/literature.canvasis the default visual graph surface- literature work should usually produce at least one of:
- new or updated paper notes,
- new or updated knowledge notes,
- literature-canvas refresh,
- optional writing synthesis when requested
Default stance
Do not assume by default:
Concepts/Datasets/.baseviews
The literature workflow may create Maps/literature.canvas by default. Other artifacts still require explicit justification.
References
Load only what is needed:
references/PAPER-NOTE-SCHEMA.md- detailed paper-note frontmatter and sectionsreferences/CANVAS-WORKFLOW.md- how and when to refreshMaps/literature.canvas
Literature Canvas Workflow
Default output
Maps/literature.canvas
Purpose
Provide a default Obsidian literature graph that:
- keeps
Papers/as the canonical paper-note surface, - keeps
Knowledge/as the canonical synthesis surface, - visualizes paper-to-paper and paper-to-knowledge relationships,
- stays lightweight enough to refresh after each major Zotero ingestion.
Default behavior
- Use paper-note frontmatter and wikilinks as the primary graph source.
- Use
Papers/*.mdand relevantKnowledge/*.mdas file nodes. - Create
.canvasby default for literature ingestion and review workflows. - Treat Mermaid or markdown graph notes as optional legacy companions, not the default graph artifact.
- Prefer argument-map structure with
paper,claim,method, andgapnodes over raw all-to-all paper linking. - Thin edges aggressively; keep only the main reasoning chain and a small number of explicit semantic paper-to-paper relations.
- Hide or down-rank side branches when they clutter the display graph.
Refresh triggers
Refresh the literature canvas when:
- new Zotero-sourced paper notes are added,
- paper notes gain new
linked_knowledgeedges or meaningful wikilinks, - knowledge synthesis notes are updated after a literature pass,
- a batch Zotero review or note-ingestion pass finishes,
- a full-collection normalization pass changes many paper-note relationships.
Recommended command
python3 "${CLAUDE_PLUGIN_ROOT}/skills/obsidian-literature-workflow/scripts/build_literature_canvas.py" --cwd "$PWD"Display rule
- If a second lightweight showcase graph is useful, maintain
Maps/literature-main.canvasas a filtered presentation copy rather than bloating the default working canvas.
Legacy Literature Map Workflow
This reference is kept for backwards compatibility only.
Legacy output
Knowledge/Literature-Map.md
Current default
- Use
CANVAS-WORKFLOW.mdfor the current default literature graph artifact. - The default graph is now
Maps/literature.canvas, not a Mermaid note. - Keep this markdown workflow only when a note-based map is specifically requested or needed for compatibility.
Refresh triggers
Refresh the markdown map only when:
- new Zotero-sourced paper notes are added,
- paper-note links or metadata materially change,
- a batch literature review finishes.
Recommended command
python3 "${CLAUDE_PLUGIN_ROOT}/skills/obsidian-literature-workflow/scripts/build_literature_graph.py" --cwd "$PWD"Paper Note Schema
Use this schema when normalizing or creating durable paper notes in Papers/.
Required frontmatter
---
type: paper
title: "Paper Title"
project: project-slug
authors:
- Author A
year: 2026
venue: "Venue"
doi: "10.xxxx/xxxxx"
url: "https://..."
citekey: "author2026paper"
status: read
updated: 2026-03-16T00:00:00Z
---Recommended frontmatter for knowledge mapping
keywords:
- decoding
concepts:
- shared geometry
methods:
- contrastive learning
related_papers:
- "Papers/Neighbor-Paper"
linked_knowledge:
- "Knowledge/Literature-Overview"
- "Knowledge/Method-Families"Recommended sections
## Claim## Research question## Method## Evidence## Strengths## Limitation## Direct relevance to repo## Relation to other papers## Knowledge links## Optional downstream hooks
Working rules from real collection passes
- Prefer one canonical note per paper and keep the schema aligned across the whole covered set.
- When the user asks to check all papers, perform a coverage pass rather than stopping at a representative subset.
- Use
zotero_keyas the durable join key between Zotero items and Obsidian notes. - Keep
Direct relevance to repoconcrete enough to drive experiments, writing, or review prioritization.
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import hashlib
import json
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
WIKILINK_RE = re.compile(r"\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|[^\]]+)?\]\]")
LIST_KEYS = {
"authors",
"keywords",
"concepts",
"methods",
"related_papers",
"linked_knowledge",
"paper_relationships",
"linked_papers",
"argument_claims",
"argument_methods",
"argument_gaps",
"linked_claims",
"linked_methods",
"linked_gaps",
"claim_method_links",
"method_gap_links",
}
PAPER_LIMIT = 48
KNOWLEDGE_LIMIT = 18
KNOWLEDGE_COLUMNS = 3
NODE_WIDTH = 300
NODE_HEIGHT = 180
ARG_NODE_WIDTH = 280
ARG_NODE_HEIGHT = 120
X_GAP = 80
Y_GAP = 70
GROUP_X_GAP = 180
GROUP_Y_GAP = 180
ARG_GROUP_X_GAP = 200
PAPER_RELATION_LIMIT = 2
PAPER_CLAIM_LIMIT = 1
PAPER_METHOD_LIMIT = 1
PAPER_RELATION_LIMIT = 1
SUBFIELD_ORDER = (
"speech-transfer-constraints",
"alignment-and-domain-adaptation",
"geometry-and-representation",
"subject-aware-adaptation",
"other",
)
SUBFIELD_LABELS = {
"speech-transfer-constraints": "Speech-specific transfer constraints",
"alignment-and-domain-adaptation": "Alignment and domain adaptation",
"geometry-and-representation": "Geometry and representation",
"subject-aware-adaptation": "Subject-aware adaptation",
"other": "Other papers",
}
SUBFIELD_COLORS = {
"speech-transfer-constraints": "6",
"alignment-and-domain-adaptation": "2",
"geometry-and-representation": "5",
"subject-aware-adaptation": "3",
"other": "1",
}
SUBFIELD_COLUMNS = 2
@dataclass(frozen=True)
class NoteRecord:
title: str
note_relpath: str
vault_relpath: str
file_name: str
related_papers: tuple[str, ...]
linked_knowledge: tuple[str, ...]
paper_relationships: tuple[tuple[str, str], ...]
linked_papers: tuple[str, ...]
claim_refs: tuple[str, ...]
method_refs: tuple[str, ...]
gap_refs: tuple[str, ...]
claim_method_links: tuple[tuple[str, str, str], ...]
method_gap_links: tuple[tuple[str, str, str], ...]
wikilinks: tuple[str, ...]
subfield: str
canvas_visibility: str
def load_project_kb_module() -> Any:
script_dir = Path(__file__).resolve().parents[2] / "obsidian-project-memory" / "scripts"
sys.path.insert(0, str(script_dir))
import project_kb # type: ignore
return project_kb
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Build a default literature canvas for the bound Obsidian project."
)
parser.add_argument("--cwd", default=".")
parser.add_argument("--project-id", default="")
parser.add_argument("--output", default="Maps/literature.canvas")
return parser.parse_args()
def parse_frontmatter(text: str) -> dict[str, Any]:
if not text.startswith("---\n"):
return {}
end = text.find("\n---\n", 4)
if end == -1:
return {}
lines = text[4:end].splitlines()
data: dict[str, Any] = {}
current_key: str | None = None
for raw_line in lines:
if not raw_line.strip():
continue
if raw_line.startswith(" - ") or raw_line.startswith("- "):
if current_key and current_key in LIST_KEYS:
data.setdefault(current_key, []).append(
raw_line.split("- ", 1)[1].strip().strip('"')
)
continue
if ":" not in raw_line:
current_key = None
continue
key, value = raw_line.split(":", 1)
key = key.strip()
value = value.strip()
current_key = key
if not value:
if key in LIST_KEYS:
data[key] = []
else:
data[key] = ""
continue
data[key] = value.strip('"')
return data
def body_without_frontmatter(text: str) -> str:
if not text.startswith("---\n"):
return text
end = text.find("\n---\n", 4)
if end == -1:
return text
return text[end + 5 :]
def normalize_note_target(value: str) -> str:
cleaned = value.strip()
if cleaned.endswith(".md"):
cleaned = cleaned[:-3]
if cleaned.startswith("./"):
cleaned = cleaned[2:]
return cleaned
def parse_relationship_entries(values: list[str]) -> tuple[tuple[str, str], ...]:
parsed: list[tuple[str, str]] = []
for raw in values:
cleaned = raw.strip()
if not cleaned:
continue
if "::" in cleaned:
target, label = cleaned.split("::", 1)
else:
target, label = cleaned, "related"
parsed.append((normalize_note_target(target), label.strip() or "related"))
return tuple(parsed)
def parse_triple_entries(values: list[str]) -> tuple[tuple[str, str, str], ...]:
parsed: list[tuple[str, str, str]] = []
for raw in values:
cleaned = raw.strip()
if not cleaned:
continue
parts = [part.strip() for part in cleaned.split("::")]
if len(parts) == 2:
parsed.append((parts[0], parts[1], "relates"))
elif len(parts) >= 3:
parsed.append((parts[0], parts[1], parts[2] or "relates"))
return tuple(parsed)
def parse_plain_labels(values: list[str]) -> tuple[str, ...]:
labels = []
for raw in values:
cleaned = raw.strip()
if cleaned and cleaned not in labels:
labels.append(cleaned)
return tuple(labels)
def extract_wikilinks(text: str) -> tuple[str, ...]:
seen: list[str] = []
for match in WIKILINK_RE.findall(text):
target = normalize_note_target(match)
if target not in seen:
seen.append(target)
return tuple(seen)
def note_id(prefix: str, value: str) -> str:
digest = hashlib.sha1(f"{prefix}:{value}".encode("utf-8")).hexdigest()
return digest[:16]
def file_node(note: NoteRecord, x: int, y: int, color: str) -> dict[str, Any]:
return {
"id": note_id("node", note.note_relpath),
"type": "file",
"x": x,
"y": y,
"width": NODE_WIDTH,
"height": NODE_HEIGHT,
"file": note.vault_relpath,
"color": color,
}
def text_node(node_key: str, text: str, x: int, y: int, width: int, height: int) -> dict[str, Any]:
return {
"id": note_id("text", node_key),
"type": "text",
"x": x,
"y": y,
"width": width,
"height": height,
"text": text,
"color": "5",
}
def group_node(node_key: str, label: str, x: int, y: int, width: int, height: int, color: str) -> dict[str, Any]:
return {
"id": note_id("group", node_key),
"type": "group",
"x": x,
"y": y,
"width": width,
"height": height,
"label": label,
"color": color,
}
def edge_id(source: str, target: str, label: str) -> str:
digest = hashlib.sha1(f"{source}:{target}:{label}".encode("utf-8")).hexdigest()
return digest[:16]
def make_edge(from_node: str, to_node: str, label: str) -> dict[str, Any]:
return {
"id": edge_id(from_node, to_node, label),
"fromNode": from_node,
"fromSide": "bottom",
"toNode": to_node,
"toSide": "top",
"toEnd": "arrow",
"label": label,
}
def collect_notes(folder: Path, folder_name: str, vault_root: Path, limit: int) -> list[NoteRecord]:
notes: list[NoteRecord] = []
if not folder.exists():
return notes
for path in sorted(folder.glob("*.md"))[:limit]:
text = path.read_text(encoding="utf-8")
frontmatter = parse_frontmatter(text)
body = body_without_frontmatter(text)
title = str(frontmatter.get("title") or path.stem.replace("-", " "))
notes.append(
NoteRecord(
title=title,
note_relpath=f"{folder_name}/{path.name}",
vault_relpath=str(path.relative_to(vault_root)).replace("\\", "/"),
file_name=path.name,
related_papers=tuple(
normalize_note_target(v) for v in frontmatter.get("related_papers", [])
),
linked_knowledge=tuple(
normalize_note_target(v) for v in frontmatter.get("linked_knowledge", [])
),
paper_relationships=parse_relationship_entries(
list(frontmatter.get("paper_relationships", []))
),
linked_papers=tuple(
normalize_note_target(v) for v in frontmatter.get("linked_papers", [])
),
claim_refs=parse_plain_labels(
list(frontmatter.get("argument_claims", []))
+ list(frontmatter.get("linked_claims", []))
),
method_refs=parse_plain_labels(
list(frontmatter.get("argument_methods", []))
+ list(frontmatter.get("linked_methods", []))
),
gap_refs=parse_plain_labels(
list(frontmatter.get("argument_gaps", []))
+ list(frontmatter.get("linked_gaps", []))
),
claim_method_links=parse_triple_entries(
list(frontmatter.get("claim_method_links", []))
),
method_gap_links=parse_triple_entries(
list(frontmatter.get("method_gap_links", []))
),
wikilinks=extract_wikilinks(body),
subfield=str(frontmatter.get("subfield") or "other"),
canvas_visibility=str(frontmatter.get("canvas_visibility") or "show"),
)
)
return notes
def looks_like_literature_knowledge(note: NoteRecord) -> bool:
key = note.note_relpath.lower()
if any(token in key for token in ("literature", "paper", "method", "survey", "gap", "related")):
return True
return any(target.startswith("Papers/") for target in note.wikilinks)
def select_knowledge_notes(
all_knowledge: list[NoteRecord], papers: list[NoteRecord]
) -> list[NoteRecord]:
requested = set()
for note in papers:
requested.update(note.linked_knowledge)
requested.update(target for target in note.wikilinks if target.startswith("Knowledge/"))
selected: list[NoteRecord] = []
seen: set[str] = set()
for note in all_knowledge:
note_key = note.note_relpath[:-3]
if note_key in requested:
selected.append(note)
seen.add(note_key)
if selected:
return selected[:KNOWLEDGE_LIMIT]
for note in all_knowledge:
note_key = note.note_relpath[:-3]
if note_key in seen:
continue
if looks_like_literature_knowledge(note):
selected.append(note)
seen.add(note_key)
if len(selected) >= KNOWLEDGE_LIMIT:
break
return selected[:KNOWLEDGE_LIMIT]
def layout_grid(index: int, columns: int, x0: int, y0: int) -> tuple[int, int]:
col = index % columns
row = index // columns
x = x0 + col * (NODE_WIDTH + X_GAP)
y = y0 + row * (NODE_HEIGHT + Y_GAP)
return x, y
def compute_group_dimensions(item_count: int, columns: int) -> tuple[int, int]:
rows = max(1, (max(item_count, 1) - 1) // columns + 1)
width = columns * NODE_WIDTH + (columns - 1) * X_GAP + 120
height = rows * NODE_HEIGHT + (rows - 1) * Y_GAP + 140
return width, height
def compute_argument_group_dimensions(item_count: int, columns: int) -> tuple[int, int]:
rows = max(1, (max(item_count, 1) - 1) // columns + 1)
width = columns * ARG_NODE_WIDTH + (columns - 1) * X_GAP + 120
height = rows * ARG_NODE_HEIGHT + (rows - 1) * Y_GAP + 140
return width, height
def argument_node(kind: str, label: str, x: int, y: int) -> dict[str, Any]:
return {
"id": note_id(kind, label),
"type": "text",
"x": x,
"y": y,
"width": ARG_NODE_WIDTH,
"height": ARG_NODE_HEIGHT,
"text": label,
"color": {"claim": "4", "method": "2", "gap": "6"}.get(kind, "5"),
}
def render_canvas(project_title: str, papers: list[NoteRecord], knowledge: list[NoteRecord]) -> dict[str, Any]:
nodes: list[dict[str, Any]] = []
edges: list[dict[str, Any]] = []
node_lookup: dict[str, str] = {}
claim_lookup: dict[str, str] = {}
method_lookup: dict[str, str] = {}
gap_lookup: dict[str, str] = {}
knowledge_rows = max(1, (max(len(knowledge), 1) - 1) // KNOWLEDGE_COLUMNS + 1)
knowledge_group_width = KNOWLEDGE_COLUMNS * NODE_WIDTH + (KNOWLEDGE_COLUMNS - 1) * X_GAP + 120
knowledge_group_height = knowledge_rows * NODE_HEIGHT + (knowledge_rows - 1) * Y_GAP + 140
knowledge_y = 220
argument_y = knowledge_y + knowledge_group_height + 180
nodes.append(
text_node(
"literature-summary",
(
f"# Literature Canvas\\n\\n"
f"Project: {project_title}\\n\\n"
f"- This is an argument map with `paper + claim + method + gap`.\\n"
f"- The graph is intentionally thinned: each paper only keeps its most important claim / method links.\\n"
f"- Papers are grouped by subfield and spaced out to reduce crossing lines."
),
0,
0,
1080,
150,
)
)
nodes.append(group_node("knowledge-group", "Knowledge", -40, knowledge_y - 40, knowledge_group_width, knowledge_group_height, "4"))
for idx, note in enumerate(knowledge):
x, y = layout_grid(idx, KNOWLEDGE_COLUMNS, 20, knowledge_y)
node = file_node(note, x, y, "4")
nodes.append(node)
node_lookup[note.note_relpath[:-3]] = node["id"]
claim_labels: list[str] = []
method_labels: list[str] = []
gap_labels: list[str] = []
for note in (*papers, *knowledge):
for label in note.claim_refs:
if label not in claim_labels:
claim_labels.append(label)
for label in note.method_refs:
if label not in method_labels:
method_labels.append(label)
for label in note.gap_refs:
if label not in gap_labels:
gap_labels.append(label)
claim_group_width, claim_group_height = compute_argument_group_dimensions(len(claim_labels), 2)
method_group_width, method_group_height = compute_argument_group_dimensions(len(method_labels), 2)
gap_group_width, gap_group_height = compute_argument_group_dimensions(len(gap_labels), 2)
argument_row_height = max(claim_group_height, method_group_height, gap_group_height)
argument_groups = [
("claim", "Claims", claim_labels, -40, argument_y, claim_group_width, claim_group_height, "4"),
("method", "Methods", method_labels, -40 + claim_group_width + ARG_GROUP_X_GAP, argument_y, method_group_width, method_group_height, "2"),
("gap", "Gaps", gap_labels, -40 + claim_group_width + ARG_GROUP_X_GAP + method_group_width + ARG_GROUP_X_GAP, argument_y, gap_group_width, gap_group_height, "6"),
]
for kind, label, labels, group_x, group_y, group_width, group_height, color in argument_groups:
nodes.append(group_node(f"{kind}-group", label, group_x, group_y, group_width, group_height, color))
for idx, arg_label in enumerate(labels):
x, y = layout_grid(idx, 2, group_x + 60, group_y + 40)
node = argument_node(kind, arg_label, x, y)
nodes.append(node)
if kind == "claim":
claim_lookup[arg_label] = node["id"]
elif kind == "method":
method_lookup[arg_label] = node["id"]
else:
gap_lookup[arg_label] = node["id"]
paper_y = argument_y + argument_row_height + 180
grouped_papers: dict[str, list[NoteRecord]] = {}
for note in papers:
grouped_papers.setdefault(note.subfield or "other", []).append(note)
active_groups = [key for key in SUBFIELD_ORDER if grouped_papers.get(key)]
if not active_groups:
active_groups = ["other"]
grouped_papers["other"] = list(papers)
group_layout_meta: list[tuple[str, int, int, int, int]] = []
for idx, subfield in enumerate(active_groups):
notes_in_group = grouped_papers[subfield]
group_width, group_height = compute_group_dimensions(len(notes_in_group), 2)
col = idx % SUBFIELD_COLUMNS
row = idx // SUBFIELD_COLUMNS
group_x = -40 + col * (group_width + GROUP_X_GAP)
group_y = paper_y + row * (group_height + GROUP_Y_GAP)
group_layout_meta.append((subfield, group_x, group_y, group_width, group_height))
nodes.append(
group_node(
f"paper-group-{subfield}",
SUBFIELD_LABELS.get(subfield, subfield.replace("-", " ").title()),
group_x,
group_y,
group_width,
group_height,
SUBFIELD_COLORS.get(subfield, "2"),
)
)
for note_idx, note in enumerate(notes_in_group):
x, y = layout_grid(note_idx, 2, group_x + 60, group_y + 40)
node = file_node(note, x, y, SUBFIELD_COLORS.get(subfield, "2"))
nodes.append(node)
node_lookup[note.note_relpath[:-3]] = node["id"]
seen_edges: set[tuple[str, str, str]] = set()
for note in papers:
source = node_lookup.get(note.note_relpath[:-3])
if not source:
continue
explicit_relationships = list(note.paper_relationships)
if not explicit_relationships:
explicit_relationships = [(target, "related") for target in note.related_papers]
for target, label in explicit_relationships[:PAPER_RELATION_LIMIT]:
target_node = node_lookup.get(target)
if not target_node:
continue
marker = (source, target_node, label)
if marker not in seen_edges:
edges.append(make_edge(source, target_node, label))
seen_edges.add(marker)
for claim in note.claim_refs[:PAPER_CLAIM_LIMIT]:
target_node = claim_lookup.get(claim)
if not target_node:
continue
marker = (source, target_node, "supports")
if marker not in seen_edges:
edges.append(make_edge(source, target_node, "supports"))
seen_edges.add(marker)
for method in note.method_refs[:PAPER_METHOD_LIMIT]:
target_node = method_lookup.get(method)
if not target_node:
continue
marker = (source, target_node, "uses")
if marker not in seen_edges:
edges.append(make_edge(source, target_node, "uses"))
seen_edges.add(marker)
for note in knowledge:
source = node_lookup.get(note.note_relpath[:-3])
if not source:
continue
for target in note.linked_papers:
target_node = node_lookup.get(target)
if not target_node:
continue
marker = (source, target_node, "summarizes")
if marker not in seen_edges:
edges.append(make_edge(source, target_node, "summarizes"))
seen_edges.add(marker)
for claim in note.claim_refs:
target_node = claim_lookup.get(claim)
if not target_node:
continue
marker = (source, target_node, "summarizes")
if marker not in seen_edges:
edges.append(make_edge(source, target_node, "summarizes"))
seen_edges.add(marker)
for method in note.method_refs:
target_node = method_lookup.get(method)
if not target_node:
continue
marker = (source, target_node, "summarizes")
if marker not in seen_edges:
edges.append(make_edge(source, target_node, "summarizes"))
seen_edges.add(marker)
for gap in note.gap_refs:
target_node = gap_lookup.get(gap)
if not target_node:
continue
marker = (source, target_node, "summarizes")
if marker not in seen_edges:
edges.append(make_edge(source, target_node, "summarizes"))
seen_edges.add(marker)
for claim, method, label in note.claim_method_links:
source_node = claim_lookup.get(claim)
target_node = method_lookup.get(method)
if not source_node or not target_node:
continue
marker = (source_node, target_node, label)
if marker not in seen_edges:
edges.append(make_edge(source_node, target_node, label))
seen_edges.add(marker)
for method, gap, label in note.method_gap_links:
source_node = method_lookup.get(method)
target_node = gap_lookup.get(gap)
if not source_node or not target_node:
continue
marker = (source_node, target_node, label)
if marker not in seen_edges:
edges.append(make_edge(source_node, target_node, label))
seen_edges.add(marker)
for target in note.wikilinks:
if not target.startswith("Knowledge/"):
continue
target_node = node_lookup.get(target)
if not target_node:
continue
marker = (source, target_node, "relates")
if marker not in seen_edges:
edges.append(make_edge(source, target_node, "relates"))
seen_edges.add(marker)
return {"nodes": nodes, "edges": edges}
def main() -> None:
args = parse_args()
project_kb = load_project_kb_module()
repo_root = project_kb.find_repo_root(Path(args.cwd).resolve())
binding = project_kb.resolve_binding(repo_root, args.project_id or None)
papers = [
note
for note in collect_notes(binding.project_root / "Papers", "Papers", binding.vault_path, PAPER_LIMIT)
if note.canvas_visibility != "hidden"
]
all_knowledge = collect_notes(
binding.project_root / "Knowledge", "Knowledge", binding.vault_path, KNOWLEDGE_LIMIT * 3
)
knowledge = select_knowledge_notes(all_knowledge, papers)
canvas = render_canvas(project_kb.titleize_slug(binding.project_id), papers, knowledge)
output_path = binding.project_root / args.output
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(canvas, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(
json.dumps(
{
"project_id": binding.project_id,
"output": str(output_path),
"paper_count": len(papers),
"knowledge_count": len(knowledge),
},
ensure_ascii=False,
)
)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
PAPER_LIMIT = 48
EDGE_LIMIT = 160
WIKILINK_RE = re.compile(r"\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|[^\]]+)?\]\]")
LIST_KEYS = {
'authors', 'keywords', 'concepts', 'methods',
'related_papers', 'linked_experiments', 'linked_results'
}
@dataclass(frozen=True)
class PaperNote:
title: str
note_relpath: str
file_name: str
concepts: tuple[str, ...]
methods: tuple[str, ...]
related_papers: tuple[str, ...]
linked_experiments: tuple[str, ...]
linked_results: tuple[str, ...]
wikilinks: tuple[str, ...]
def load_project_kb_module() -> Any:
script_dir = Path(__file__).resolve().parents[2] / 'obsidian-project-memory' / 'scripts'
sys.path.insert(0, str(script_dir))
import project_kb # type: ignore
return project_kb
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description='Build a literature graph note for the bound Obsidian project.')
parser.add_argument('--cwd', default='.')
parser.add_argument('--project-id', default='')
parser.add_argument('--output', default='Knowledge/Literature-Map.md')
return parser.parse_args()
def parse_frontmatter(text: str) -> dict[str, Any]:
if not text.startswith('---\n'):
return {}
end = text.find('\n---\n', 4)
if end == -1:
return {}
lines = text[4:end].splitlines()
data: dict[str, Any] = {}
current_key: str | None = None
for raw_line in lines:
if not raw_line.strip():
continue
if raw_line.startswith(' - ') or raw_line.startswith('- '):
if current_key and current_key in LIST_KEYS:
data.setdefault(current_key, []).append(raw_line.split('- ', 1)[1].strip().strip('"'))
continue
if ':' not in raw_line:
current_key = None
continue
key, value = raw_line.split(':', 1)
key = key.strip()
value = value.strip()
current_key = key
if not value:
if key in LIST_KEYS:
data[key] = []
else:
data[key] = ''
continue
data[key] = value.strip('"')
return data
def body_without_frontmatter(text: str) -> str:
if not text.startswith('---\n'):
return text
end = text.find('\n---\n', 4)
if end == -1:
return text
return text[end + 5:]
def normalize_note_target(value: str) -> str:
cleaned = value.strip()
if cleaned.endswith('.md'):
cleaned = cleaned[:-3]
if cleaned.startswith('./'):
cleaned = cleaned[2:]
return cleaned
def mermaid_id(prefix: str, value: str) -> str:
slug = re.sub(r'[^a-zA-Z0-9]+', '_', value).strip('_').lower()
if not slug:
slug = 'node'
return f'{prefix}_{slug[:48]}'
def extract_wikilinks(text: str) -> tuple[str, ...]:
seen: list[str] = []
for match in WIKILINK_RE.findall(text):
target = normalize_note_target(match)
if target not in seen:
seen.append(target)
return tuple(seen)
def collect_paper_notes(project_root: Path) -> list[PaperNote]:
papers_dir = project_root / 'Papers'
notes: list[PaperNote] = []
if not papers_dir.exists():
return notes
for path in sorted(papers_dir.glob('*.md'))[:PAPER_LIMIT]:
text = path.read_text(encoding='utf-8')
frontmatter = parse_frontmatter(text)
body = body_without_frontmatter(text)
title = str(frontmatter.get('title') or path.stem.replace('-', ' '))
notes.append(
PaperNote(
title=title,
note_relpath=f'Papers/{path.name}',
file_name=path.name,
concepts=tuple(frontmatter.get('concepts', [])),
methods=tuple(frontmatter.get('methods', [])),
related_papers=tuple(normalize_note_target(v) for v in frontmatter.get('related_papers', [])),
linked_experiments=tuple(normalize_note_target(v) for v in frontmatter.get('linked_experiments', [])),
linked_results=tuple(normalize_note_target(v) for v in frontmatter.get('linked_results', [])),
wikilinks=extract_wikilinks(body),
)
)
return notes
def render_mermaid(notes: list[PaperNote]) -> str:
lines: list[str] = ['graph LR']
paper_index = {note.note_relpath[:-3]: note for note in notes}
seen_edges: set[tuple[str, str, str]] = set()
for note in notes:
paper_node = mermaid_id('paper', note.file_name)
lines.append(f' {paper_node}["{note.title}"]')
for concept in note.concepts:
concept_node = mermaid_id('concept', concept)
lines.append(f' {concept_node}(("{concept}"))')
edge = (paper_node, concept_node, 'concept')
if edge not in seen_edges and len(seen_edges) < EDGE_LIMIT:
lines.append(f' {paper_node} -->|concept| {concept_node}')
seen_edges.add(edge)
for method in note.methods:
method_node = mermaid_id('method', method)
lines.append(f' {method_node}{{"{method}"}}')
edge = (paper_node, method_node, 'method')
if edge not in seen_edges and len(seen_edges) < EDGE_LIMIT:
lines.append(f' {paper_node} -->|method| {method_node}')
seen_edges.add(edge)
related_targets = set(note.related_papers)
related_targets.update(target for target in note.wikilinks if target.startswith('Papers/'))
for target in sorted(related_targets):
target_note = paper_index.get(target)
if not target_note:
continue
target_node = mermaid_id('paper', target_note.file_name)
edge = (paper_node, target_node, 'related')
if edge not in seen_edges and len(seen_edges) < EDGE_LIMIT:
lines.append(f' {paper_node} -->|related| {target_node}')
seen_edges.add(edge)
for experiment in note.linked_experiments:
exp_node = mermaid_id('experiment', experiment)
label = experiment.split('/')[-1]
lines.append(f' {exp_node}["{label}"]')
edge = (paper_node, exp_node, 'experiment')
if edge not in seen_edges and len(seen_edges) < EDGE_LIMIT:
lines.append(f' {paper_node} -->|experiment| {exp_node}')
seen_edges.add(edge)
for result in note.linked_results:
result_node = mermaid_id('result', result)
label = result.split('/')[-1]
lines.append(f' {result_node}["{label}"]')
edge = (paper_node, result_node, 'result')
if edge not in seen_edges and len(seen_edges) < EDGE_LIMIT:
lines.append(f' {paper_node} -->|result| {result_node}')
seen_edges.add(edge)
deduped: list[str] = []
seen_lines: set[str] = set()
for line in lines:
if line not in seen_lines:
deduped.append(line)
seen_lines.add(line)
return '\n'.join(deduped)
def render_map_note(project_id: str, notes: list[PaperNote], mermaid_graph: str, updated: str) -> str:
paper_bullets = '\n'.join(f'- [[{note.note_relpath[:-3]}]]' for note in notes) or '- No paper notes yet.'
concept_counts: dict[str, int] = {}
method_counts: dict[str, int] = {}
for note in notes:
for concept in note.concepts:
concept_counts[concept] = concept_counts.get(concept, 0) + 1
for method in note.methods:
method_counts[method] = method_counts.get(method, 0) + 1
concept_lines = '\n'.join(
f'- {name} ({count})' for name, count in sorted(concept_counts.items(), key=lambda kv: (-kv[1], kv[0]))[:12]
) or '- No concept clusters recorded yet.'
method_lines = '\n'.join(
f'- {name} ({count})' for name, count in sorted(method_counts.items(), key=lambda kv: (-kv[1], kv[0]))[:12]
) or '- No method clusters recorded yet.'
return f'''---
type: knowledge
title: Literature Map
project: {project_id}
updated: {updated}
---
# Literature Map
## Purpose
- Provide a lightweight literature knowledge map for the current project.
- Help navigate paper notes, concept clusters, and downstream experiment/result hooks.
## Paper notes included
{paper_bullets}
## Concept clusters
{concept_lines}
## Method clusters
{method_lines}
## Graph
```mermaid
{mermaid_graph}
```
## How to use
- Open the linked paper notes above for detailed reading notes.
- Use Obsidian backlinks/local graph on top of these wikilinks.
- Refresh this note after a batch Zotero ingestion or major paper-note update.
'''
def main() -> None:
args = parse_args()
project_kb = load_project_kb_module()
repo_root = project_kb.find_repo_root(Path(args.cwd).resolve())
binding = project_kb.resolve_binding(repo_root, args.project_id or None)
notes = collect_paper_notes(binding.project_root)
output_rel = args.output if args.output.endswith('.md') else f'{args.output}.md'
output_path = binding.project_root / output_rel
mermaid_graph = render_mermaid(notes)
content = render_map_note(binding.project_id, notes, mermaid_graph, project_kb.now_iso())
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(content.rstrip() + '\n', encoding='utf-8')
print(output_path)
if __name__ == '__main__':
main()
Related skills
FAQ
Does obsidian-literature-workflow need an Obsidian MCP?
No, Obsidian note writing is done through the filesystem; no Obsidian MCP is required.
Where does literature synthesis land?
In Knowledge/ notes such as Literature-Overview, Method-Families, and Research-Gaps, not Experiments or Results.