Skill: flobo3/genealogy-agent (version 1.0.1)

--- name: genealogy-agent description: Extract, structure, research, and visualize family history from raw text. Builds knowledge graphs, generates Mermaid trees, Obsidian vaults, and GEDCOM exports. --- # genealogy-agent A powerful agent skill for extracting, structuring, researching, and visualizing family history from raw text (memoirs, archives, chat logs). ## Features - **LLM Extraction**: Parses unstructured text to find people, dates, locations, and relationships. - **Knowledge Graph**: Stores family data in a local JSONL ontology graph. - **Obsidian Vault Generation**: Automatically generates an Obsidian-compatible vault (Markdown files with YAML frontmatter) for your family tree. - **Auto-Research**: Autonomously searches global and regional databases (FamilySearch, Ancestry, OBD Memorial, Geneteka, etc.) to find new facts about ancestors. - **GEDCOM Export**: Exports your family tree to the standard `.ged` format for use in MyHeritage, FamilySearch, or any desktop genealogy software. - **Mermaid Visualization**: Automatically generates beautiful family tree diagrams. ## Setup 1. Install dependencies: `uv pip install pydantic litellm duckduckgo-search` 2. Set your preferred LLM provider (e.g., `OPENAI_API_KEY` or `GEMINI_API_KEY`). ## Usage You can ask the agent to: - "Extract family members from this text: [text]" - "Build a family tree from the extracted data" - "Research my great-grandfather Ivan Ivanov born in 1910" - "Export my family tree to GEDCOM" - "Generate an Obsidian vault from my family data" ## Tools - `extract_family_data`: Extracts structured family data (people, dates, relationships) from raw text using an LLM. - `build_family_graph`: Saves extracted data into the local ontology graph. - `generate_mermaid_tree`: Generates a Mermaid diagram from the graph. - `generate_obsidian_vault`: Generates an Obsidian-formatted vault from extracted family data. - `research_ancestor`: Autonomously researches an ancestor using web search and LLM extraction. - `export_gedcom`: Exports extracted family data to a standard GEDCOM (.ged) file.

path: README.md
content: # genealogy-agent

A powerful agent skill for extracting, structuring, researching, and visualizing family history from raw text (memoirs, archives, chat logs).

## Features
- **LLM Extraction**: Parses unstructured text to find people, dates, locations, and relationships.
- **Knowledge Graph**: Stores family data in a local JSONL ontology graph.
- **Obsidian Vault Generation**: Automatically generates an Obsidian-compatible vault (Markdown files with YAML frontmatter) for your family tree.
- **Auto-Research**: Autonomously searches global and regional databases (FamilySearch, Ancestry, OBD Memorial, Geneteka, etc.) to find new facts about ancestors.
- **GEDCOM Export**: Exports your family tree to the standard `.ged` format for use in MyHeritage, FamilySearch, or any desktop genealogy software.
- **Mermaid Visualization**: Automatically generates beautiful family tree diagrams.

## Setup
1. Install dependencies: `uv pip install pydantic litellm duckduckgo-search`
2. Set your preferred LLM provider (e.g., `OPENAI_API_KEY` or `GEMINI_API_KEY`).

## Usage
You can ask the agent to:
- "Extract family members from this text: [text]"
- "Generate an Obsidian vault from my family data"
- "Research my great-grandfather Ivan Ivanov born in 1910"
- "Export my family tree to GEDCOM"

## Tools
- `extract_family_data`: Extracts structured family data from raw text.
- `build_family_graph`: Saves extracted data into the local ontology graph.
- `generate_obsidian_vault`: Generates an Obsidian-formatted vault from extracted family data.
- `research_ancestor`: Autonomously researches an ancestor using web search and LLM extraction.
- `export_gedcom`: Exports extracted family data to a standard GEDCOM (.ged) file.
- `generate_mermaid_tree`: Generates a Mermaid diagram from the graph.

path: scripts/build_graph.py
content: import json
import os
from typing import Dict, Any, List

def build_graph(data: Dict[str, Any], output_file: str = "family_graph.jsonl"):
    """Saves extracted family data into a local JSONL ontology graph."""
    
    # Create directory if it doesn't exist
    os.makedirs(os.path.dirname(output_file), exist_ok=True)
    
    with open(output_file, "w", encoding="utf-8") as f:
        # Write people as nodes
        for person in data.get("people", []):
            node = {
                "type": "node",
                "id": person["id"],
                "label": "Person",
                "props": {
                    "name": person["name"],
                    "birth_date": person.get("birth_date"),
                    "death_date": person.get("death_date"),
                    "birth_place": person.get("birth_place"),
                    "notes": person.get("notes"),
                }
            }
            f.write(json.dumps(node, ensure_ascii=False) + "\n")
            
        # Write relationships as edges
        for rel in data.get("relationships", []):
            edge = {
                "type": "edge",
                "source": rel["source_id"],
                "target": rel["target_id"],
                "label": rel["type"],
                "props": {}
            }
            f.write(json.dumps(edge, ensure_ascii=False) + "\n")
            
    print(f"Graph saved to {output_file}")

if __name__ == "__main__":
    import sys
    if len(sys.argv) > 1:
        input_file = sys.argv[1]
        output_file = sys.argv[2] if len(sys.argv) > 2 else "family_graph.jsonl"
        
        with open(input_file, "r", encoding="utf-8") as f:
            data = json.load(f)
            
        build_graph(data, output_file)
    else:
        print("Usage: python build_graph.py input_data.json [output_graph.jsonl]")

path: scripts/export_gedcom.py
content: import json
import os
from typing import Dict, Any, List
from datetime import datetime

def export_gedcom(data: Dict[str, Any], output_file: str = "family_tree.ged"):
    """Exports extracted family data to a standard GEDCOM (.ged) file."""
    
    lines = [
        "0 HEAD",
        "1 SOUR genealogy-agent",
        "2 NAME Genealogy Agent by flobo3",
        "2 VERS 1.0",
        "1 DEST ANY",
        f"1 DATE {datetime.now().strftime('%d %b %Y').upper()}",
        "1 CHAR UTF-8",
    ]
    
    # Map our IDs to GEDCOM IDs (e.g., @I1@)
    id_map = {}
    gedcom_id_counter = 1
    
    # Write Individuals (INDI)
    for person in data.get("people", []):
        ged_id = f"@I{gedcom_id_counter}@"
        id_map[person["id"]] = ged_id
        gedcom_id_counter += 1
        
        lines.append(f"0 {ged_id} INDI")
        
        # Name
        name_parts = person["name"].split(" ", 1)
        first_name = name_parts[0]
        last_name = f"/{name_parts[1]}/" if len(name_parts) > 1 else ""
        lines.append(f"1 NAME {first_name} {last_name}")
        
        # Birth
        if person.get("birth_date") or person.get("birth_place"):
            lines.append("1 BIRT")
            if person.get("birth_date"):
                lines.append(f"2 DATE {person['birth_date']}")
            if person.get("birth_place"):
                lines.append(f"2 PLAC {person['birth_place']}")
                
        # Death
        if person.get("death_date"):
            lines.append("1 DEAT")
            lines.append(f"2 DATE {person['death_date']}")
            
        # Notes
        if person.get("notes"):
            lines.append(f"1 NOTE {person['notes']}")
            
    # Write Families (FAM)
    # Group relationships by parents to create families
    families = {}
    fam_id_counter = 1
    
    for rel in data.get("relationships", []):
        if rel["type"] == "parent":
            parent = rel["source_id"]
            child = rel["target_id"]
            
            # Find or create a family for this parent
            fam_id = None
            for fid, fam in families.items():
                if parent in fam["parents"]:
                    fam_id = fid
                    break
                    
            if not fam_id:
                fam_id = f"@F{fam_id_counter}@"
                families[fam_id] = {"parents": [parent], "children": []}
                fam_id_counter += 1
                
            if child not in families[fam_id]["children"]:
                families[fam_id]["children"].append(child)
                
        elif rel["type"] == "spouse":
            p1 = rel["source_id"]
            p2 = rel["target_id"]
            
            # Find or create a family for these spouses
            fam_id = None
            for fid, fam in families.items():
                if p1 in fam["parents"] or p2 in fam["parents"]:
                    fam_id = fid
                    if p1 not in fam["parents"]: fam["parents"].append(p1)
                    if p2 not in fam["parents"]: fam["parents"].append(p2)
                    break
                    
            if not fam_id:
                fam_id = f"@F{fam_id_counter}@"
                families[fam_id] = {"parents": [p1, p2], "children": []}
                fam_id_counter += 1
                
    # Write FAM records
    for fam_id, fam in families.items():
        lines.append(f"0 {fam_id} FAM")
        for parent in fam["parents"]:
            if parent in id_map:
                # In GEDCOM, HUSB is male parent, WIFE is female. We'll just use HUSB/WIFE arbitrarily for now.
                role = "HUSB" if fam["parents"].index(parent) == 0 else "WIFE"
                lines.append(f"1 {role} {id_map[parent]}")
        for child in fam["children"]:
            if child in id_map:
                lines.append(f"1 CHIL {id_map[child]}")
                
    # Link Individuals to Families
    for fam_id, fam in families.items():
        for parent in fam["parents"]:
            if parent in id_map:
                # Find the INDI record and append FAMS
                idx = lines.index(f"0 {id_map[parent]} INDI")
                lines.insert(idx + 1, f"1 FAMS {fam_id}")
        for child in fam["children"]:
            if child in id_map:
                # Find the INDI record and append FAMC
                idx = lines.index(f"0 {id_map[child]} INDI")
                lines.insert(idx + 1, f"1 FAMC {fam_id}")
                
    lines.append("0 TRLR")
    
    with open(output_file, "w", encoding="utf-8") as f:
        f.write("\n".join(lines) + "\n")
        
    print(f"GEDCOM file saved to {output_file}")

if __name__ == "__main__":
    import sys
    if len(sys.argv) > 1:
        input_file = sys.argv[1]
        output_file = sys.argv[2] if len(sys.argv) > 2 else "family_tree.ged"
        
        with open(input_file, "r", encoding="utf-8") as f:
            data = json.load(f)
            
        export_gedcom(data, output_file)
    else:
        print("Usage: python export_gedcom.py input_data.json [output_tree.ged]")

path: scripts/extract.py
content: import json
import os
from typing import List, Optional, Dict, Any
from pydantic import BaseModel, Field
from litellm import completion

class Person(BaseModel):
    id: str = Field(description="Unique identifier (e.g., 'ivan_ivanov')")
    name: str = Field(description="Full name")
    birth_date: Optional[str] = Field(description="Birth date or year")
    death_date: Optional[str] = Field(description="Death date or year")
    birth_place: Optional[str] = Field(description="Birth place")
    notes: Optional[str] = Field(description="Additional biographical notes")

class Relationship(BaseModel):
    source_id: str = Field(description="ID of the person (e.g., 'ivan_ivanov')")
    target_id: str = Field(description="ID of the related person (e.g., 'maria_ivanova')")
    type: str = Field(description="Relationship type (e.g., 'parent', 'child', 'spouse', 'sibling')")

class FamilyData(BaseModel):
    people: List[Person]
    relationships: List[Relationship]

def extract_family_data(text: str, model: str = "gemini/gemini-2.5-flash") -> Dict[str, Any]:
    """Extracts structured family data from raw text using an LLM."""
    prompt = f"""
    Extract all people, their biographical details (birth/death dates, places), and their relationships from the following text.
    Return the data as a structured JSON object with 'people' and 'relationships' lists.
    Use consistent IDs for people (e.g., lowercase names with underscores).
    
    Text:
    {text}
    """
    
    response = completion(
        model=model,
        messages=[{"role": "user", "content": prompt}],
        response_format=FamilyData,
    )
    
    # litellm returns a Pydantic object if response_format is used
    data = response.choices[0].message.content
    if isinstance(data, str):
        return json.loads(data)
    return data.model_dump()

if __name__ == "__main__":
    import sys
    if len(sys.argv) > 1:
        text = sys.argv[1]
        print(json.dumps(extract_family_data(text), indent=2, ensure_ascii=False))
    else:
        print("Usage: python extract.py 'raw text about family history'")

path: scripts/generate_mermaid.py
content: import json
import os
from typing import Dict, Any, List

def generate_mermaid(data: Dict[str, Any], output_file: str = "family_tree.md"):
    """Generates a Mermaid diagram from extracted family data."""
    
    mermaid_lines = [
        "```mermaid",
        "graph TD",
        "%% Family Tree Diagram",
    ]
    
    # Add nodes
    for person in data.get("people", []):
        node_id = person["id"]
        name = person["name"]
        birth = person.get("birth_date", "")
        death = person.get("death_date", "")
        
        label = f"{name}"
        if birth or death:
            label += f"<br>({birth} - {death})"
            
        mermaid_lines.append(f'    {node_id}["{label}"]')
        
    # Add edges
    for rel in data.get("relationships", []):
        source = rel["source_id"]
        target = rel["target_id"]
        rel_type = rel["type"]
        
        if rel_type == "parent":
            mermaid_lines.append(f'    {source} -->|parent| {target}')
        elif rel_type == "child":
            mermaid_lines.append(f'    {target} -->|parent| {source}')
        elif rel_type == "spouse":
            mermaid_lines.append(f'    {source} ---|spouse| {target}')
        elif rel_type == "sibling":
            mermaid_lines.append(f'    {source} -.-|sibling| {target}')
            
    mermaid_lines.append("```")
    
    with open(output_file, "w", encoding="utf-8") as f:
        f.write("\n".join(mermaid_lines))
        
    print(f"Mermaid diagram saved to {output_file}")

if __name__ == "__main__":
    import sys
    if len(sys.argv) > 1:
        input_file = sys.argv[1]
        output_file = sys.argv[2] if len(sys.argv) > 2 else "family_tree.md"
        
        with open(input_file, "r", encoding="utf-8") as f:
            data = json.load(f)
            
        generate_mermaid(data, output_file)
    else:
        print("Usage: python generate_mermaid.py input_data.json [output_tree.md]")

path: scripts/generate_obsidian.py
content: import json
import os
import sys
from datetime import datetime

def generate_obsidian_vault(input_file: str, output_dir: str):
    """Generates an Obsidian-formatted vault from extracted family data."""
    if not os.path.exists(output_dir):
        os.makedirs(output_dir)

    with open(input_file, 'r', encoding='utf-8') as f:
        data = json.load(f)

    people = {p['id']: p for p in data.get('people', [])}
    relationships = data.get('relationships', [])

    # Build relationship maps
    parents_map = {p_id: [] for p_id in people}
    children_map = {p_id: [] for p_id in people}
    spouses_map = {p_id: [] for p_id in people}

    for rel in relationships:
        src = rel['source_id']
        tgt = rel['target_id']
        rtype = rel['type'].lower()

        if rtype == 'parent':
            if tgt in children_map and src in parents_map:
                children_map[src].append(tgt)
                parents_map[tgt].append(src)
        elif rtype == 'child':
            if tgt in parents_map and src in children_map:
                parents_map[src].append(tgt)
                children_map[tgt].append(src)
        elif rtype == 'spouse':
            if tgt in spouses_map and src in spouses_map:
                spouses_map[src].append(tgt)
                spouses_map[tgt].append(src)

    today = datetime.now().strftime("%Y-%m-%d")

    for p_id, person in people.items():
        name = person.get('name', 'Unknown')
        b_date = person.get('birth_date', '')
        b_place = person.get('birth_place', '')
        d_date = person.get('death_date', '')
        d_place = person.get('death_place', '')
        notes = person.get('notes', '')

        # Resolve relationships
        parents = [f"[[{people[pid]['name']}]]" for pid in parents_map.get(p_id, []) if pid in people]
        father = parents[0] if len(parents) > 0 else ""
        mother = parents[1] if len(parents) > 1 else ""
        
        spouses = [f"[[{people[sid]['name']}]]" for sid in spouses_map.get(p_id, []) if sid in people]
        spouse_str = spouses[0] if spouses else ""

        children = [f"\"{people[cid]['name']}\"" for cid in children_map.get(p_id, []) if cid in people]
        children_yaml = "\n  - ".join([""] + children) if children else " []"

        md_content = f"""---
full_name: "{name}"
birth_date: "{b_date}"
birth_place: "{b_place}"
death_date: "{d_date}"
death_place: "{d_place}"
father: "{father}"
mother: "{mother}"
spouse: "{spouse_str}"
children:{children_yaml}
confidence: "Moderate Signal"
sources: []
open_questions: []
last_updated: "{today}"
---

# {name}

## Life Events

| Event | Date | Place | Source |
|-------|------|-------|--------|
| Birth | {b_date} | {b_place} | |
| Marriage | | | |
| Death | {d_date} | {d_place} | |

## Sources

1. 

## Open Questions

- [ ] 

## Notes

{notes}
"""
        safe_name = "".join([c for c in name if c.isalpha() or c.isdigit() or c in (' ', '-', '_')]).rstrip()
        file_path = os.path.join(output_dir, f"{safe_name}.md")
        
        with open(file_path, 'w', encoding='utf-8') as f:
            f.write(md_content)

    print(f"Successfully generated {len(people)} Obsidian markdown files in '{output_dir}'.")

if __name__ == "__main__":
    if len(sys.argv) != 3:
        print("Usage: python generate_obsidian.py <input_json_file> <output_directory>")
        sys.exit(1)
    
    generate_obsidian_vault(sys.argv[1], sys.argv[2])


path: scripts/research.py
content: import json
import os
import sys
from typing import Dict, Any, List
from litellm import completion
from duckduckgo_search import DDGS

# Fix Windows console encoding
if sys.stdout.encoding != 'utf-8':
    sys.stdout.reconfigure(encoding='utf-8')

def search_ancestor(person_data: Dict[str, Any], model: str = "gemini/gemini-2.5-flash") -> Dict[str, Any]:
    """
    Autonomously researches an ancestor using web search and LLM extraction.
    Based on the autoresearch-genealogy methodology.
    """
    name = person_data.get("name", "")
    birth = person_data.get("birth_date", "")
    place = person_data.get("birth_place", "")
    
    if not name:
        return {"error": "Name is required for research."}
        
    # Extract just the main location (e.g., "Простоквашино" from "Простоквашино, Московская область")
    short_place = place.split(',')[0].strip() if place else ""
    
    print(f"Researching: {name} (b. {birth}, {short_place})")
    
    # 1. Formulate broader search queries
    queries = [
        f'{name} {birth} {short_place}',
        f'{name} {birth} genealogy',
    ]
    
    # Region-specific databases based on character sets or common names
    if any(char in name for char in "абвгдеёжзийклмнопрстуфхцчшщъыьэюяАБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ"):
        # Russian / Soviet databases (broader queries without site: operator which DDG sometimes struggles with)
        queries.extend([
            f'{name} {birth} Память Народа',
            f'{name} {birth} ОБД Мемориал',
            f'{name} {birth} {short_place} Мемориал',
            f'{name} {birth} Бессмертный полк'
        ])
    else:
        # General English / US / UK databases
        queries.extend([
            f'{name} {birth} site:findagrave.com',
            f'{name} {birth} site:familysearch.org',
            f'{name} {birth} site:ancestry.com'
        ])
        
    all_results = []
    with DDGS() as ddgs:
        # Limit to top 4 queries to avoid rate limits
        search_queries = queries[-4:] if len(queries) > 4 else queries
        for query in search_queries:
            print(f"Searching: {query}")
            try:
                results = list(ddgs.text(query, max_results=3))
                all_results.extend(results)
            except Exception as e:
                print(f"Search failed for {query}: {e}")
                
    if not all_results:
        return {"status": "No new information found.", "person": person_data}
        
    # 2. Compile search results into a context string
    context = "\n\n".join([f"Title: {r.get('title')}\nSnippet: {r.get('body')}\nURL: {r.get('href')}" for r in all_results])
    
    # 3. Use LLM to extract new facts and verify against existing data
    prompt = f"""
    You are an expert genealogist following strict evidence-based methodology.
    
    Current known data for this person:
    {json.dumps(person_data, indent=2, ensure_ascii=False)}
    
    Web search results found:
    {context}
    
    Task:
    1. Analyze the search results to find NEW information about this person (death date, spouse, children, military service, etc.).
    2. Verify that the new information likely belongs to the SAME person (check dates and locations).
    3. Return an updated JSON object for this person, merging the old data with the new facts.
    4. Add a 'research_notes' field explaining what was found and citing the URLs.
    5. If no new reliable information is found, return the original data unchanged.
    
    Return ONLY valid JSON matching the original structure, plus 'research_notes'.
    """
    
    try:
        response = completion(
            model=model,
            messages=[{"role": "user", "content": prompt}],
            response_format={"type": "json_object"}
        )
        
        updated_data = json.loads(response.choices[0].message.content)
        return {"status": "Research complete", "person": updated_data}
    except Exception as e:
        return {"error": f"LLM extraction failed: {e}", "person": person_data}

if __name__ == "__main__":
    if len(sys.argv) > 1:
        input_file = sys.argv[1]
        person_id = sys.argv[2] if len(sys.argv) > 2 else None
        
        with open(input_file, "r", encoding="utf-8") as f:
            data = json.load(f)
            
        if person_id:
            # Research specific person
            person = next((p for p in data.get("people", []) if p["id"] == person_id), None)
            if person:
                result = search_ancestor(person)
                print(json.dumps(result, indent=2, ensure_ascii=False))
            else:
                print(f"Person {person_id} not found.")
        else:
            # Research first person as demo
            if data.get("people"):
                result = search_ancestor(data["people"][0])
                print(json.dumps(result, indent=2, ensure_ascii=False))
            else:
                print("No people found in data.")
    else:
        print("Usage: python research.py input_data.json [person_id]")

path: skill.json
content: {
  "name": "genealogy-agent",
  "description": "Extracts family history from raw text, builds a local knowledge graph, generates Mermaid trees, and exports to Obsidian format.",
  "version": "1.1.0",
  "author": "flobo3",
  "tools": [
    {
      "name": "extract_family_data",
      "description": "Extracts structured family data (people, dates, relationships) from raw text using an LLM.",
      "command": "uv run scripts/extract.py \"{{text}}\"",
      "parameters": {
        "text": {
          "type": "string",
          "description": "Raw text containing family history"
        }
      }
    },
    {
      "name": "build_family_graph",
      "description": "Saves extracted family data into a local JSONL ontology graph.",
      "command": "uv run scripts/build_graph.py \"{{input_file}}\" \"{{output_file}}\"",
      "parameters": {
        "input_file": {
          "type": "string",
          "description": "Path to the JSON file with extracted data"
        },
        "output_file": {
          "type": "string",
          "description": "Path to save the JSONL graph (default: family_graph.jsonl)"
        }
      }
    },
    {
      "name": "generate_mermaid_tree",
      "description": "Generates a Mermaid diagram from extracted family data.",
      "command": "uv run scripts/generate_mermaid.py \"{{input_file}}\" \"{{output_file}}\"",
      "parameters": {
        "input_file": {
          "type": "string",
          "description": "Path to the JSON file with extracted data"
        },
        "output_file": {
          "type": "string",
          "description": "Path to save the Mermaid diagram (default: family_tree.md)"
        }
      }
    },
    {
      "name": "generate_obsidian_vault",
      "description": "Generates an Obsidian-formatted vault (Markdown files with YAML frontmatter) from extracted family data.",
      "command": "uv run scripts/generate_obsidian.py \"{{input_file}}\" \"{{output_dir}}\"",
      "parameters": {
        "input_file": {
          "type": "string",
          "description": "Path to the JSON file with extracted data"
        },
        "output_dir": {
          "type": "string",
          "description": "Directory to save the Obsidian markdown files"
        }
      }
    },
    {
      "name": "research_ancestor",
      "description": "Autonomously researches an ancestor using web search (DuckDuckGo) and LLM extraction to find new facts (death dates, military service, obituaries).",
      "command": "uv run scripts/research.py \"{{input_file}}\" \"{{person_id}}\"",
      "parameters": {
        "input_file": {
          "type": "string",
          "description": "Path to the JSON file with extracted data"
        },
        "person_id": {
          "type": "string",
          "description": "ID of the person to research (e.g., 'ivan_ivanov')"
        }
      }
    },
    {
      "name": "export_gedcom",
      "description": "Exports extracted family data to a standard GEDCOM (.ged) file for use in MyHeritage, FamilySearch, etc.",
      "command": "uv run scripts/export_gedcom.py \"{{input_file}}\" \"{{output_file}}\"",
      "parameters": {
        "input_file": {
          "type": "string",
          "description": "Path to the JSON file with extracted data"
        },
        "output_file": {
          "type": "string",
          "description": "Path to save the GEDCOM file (default: family_tree.ged)"
        }
      }
    }
  ]
}

Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)