mirror of
https://github.com/lucasrosati/claude-code-memory-setup.git
synced 2026-08-03 07:40:00 +02:00
feat: add chat import scripts (closes #3)
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
# Scripts
|
||||
|
||||
Helper scripts for the Claude Code + Obsidian + Graphify setup.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `claude_to_obsidian.py` | Process exported Claude chats and import them into your Obsidian vault with frontmatter, auto-tags, and wikilinks |
|
||||
| `sync_claude_obsidian.sh` | Automation wrapper: exports Claude Code chats and runs the processor |
|
||||
|
||||
## Setup
|
||||
|
||||
1. **Copy both files to `~/scripts/`:**
|
||||
|
||||
```bash
|
||||
mkdir -p ~/scripts
|
||||
cp claude_to_obsidian.py ~/scripts/
|
||||
cp sync_claude_obsidian.sh ~/scripts/
|
||||
chmod +x ~/scripts/sync_claude_obsidian.sh
|
||||
```
|
||||
|
||||
2. **Install the Claude Code extractor:**
|
||||
|
||||
```bash
|
||||
pip install claude-conversation-extractor
|
||||
```
|
||||
|
||||
3. **Edit `sync_claude_obsidian.sh`:**
|
||||
|
||||
Open the file and change `VAULT_DIR` to point to your Obsidian vault:
|
||||
|
||||
```bash
|
||||
VAULT_DIR="$HOME/YourVaultName"
|
||||
```
|
||||
|
||||
4. **Customize tags (optional):**
|
||||
|
||||
Open `claude_to_obsidian.py` and edit the `KEYWORD_TAG_MAP` dictionary at the top. Add keywords specific to your stack and projects:
|
||||
|
||||
```python
|
||||
KEYWORD_TAG_MAP = {
|
||||
"my-project": "my-project",
|
||||
"client-name": "client-work",
|
||||
# ... your keywords
|
||||
}
|
||||
```
|
||||
|
||||
5. **Test manually:**
|
||||
|
||||
```bash
|
||||
~/scripts/sync_claude_obsidian.sh
|
||||
```
|
||||
|
||||
Check the log:
|
||||
```bash
|
||||
tail -f ~/scripts/claude_obsidian_sync.log
|
||||
```
|
||||
|
||||
6. **Schedule via cron (daily at 10pm):**
|
||||
|
||||
```bash
|
||||
(crontab -l 2>/dev/null; echo "0 22 * * * $HOME/scripts/sync_claude_obsidian.sh") | crontab -
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
1. `sync_claude_obsidian.sh` runs `claude-extract` to export all Claude Code conversations as `.md` files into `~/claude-exports/code/`
|
||||
2. Web chats can be manually exported via the **"Export Claude Chat to Markdown"** browser extension into `~/claude-exports/web/`
|
||||
3. `claude_to_obsidian.py` processes each `.md`:
|
||||
- Detects origin (Code vs Web)
|
||||
- Generates auto-tags via keyword matching
|
||||
- Adds standardized YAML frontmatter
|
||||
- Inserts `[[wikilinks]]` for notes that already exist in your vault
|
||||
4. Files are moved (with `--move` flag) into `<vault>/chats/code/` or `<vault>/chats/web/`
|
||||
|
||||
## CLI options
|
||||
|
||||
```bash
|
||||
python3 claude_to_obsidian.py \
|
||||
--export-dir ~/claude-exports \
|
||||
--vault-dir ~/ObsidianVault \
|
||||
--move
|
||||
```
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--export-dir` | Directory containing exported `.md` files (required) |
|
||||
| `--vault-dir` | Path to your Obsidian vault (required) |
|
||||
| `--dry-run` | Preview what would happen without modifying anything |
|
||||
| `--move` | Delete originals after copying (default: copy only) |
|
||||
| `--origin` | Force origin: `code`, `web`, or `auto` (default: `auto`) |
|
||||
| `--no-wikilinks` | Disable wikilink insertion |
|
||||
|
||||
## Filter in Obsidian Graph View
|
||||
|
||||
After import, use these filters:
|
||||
|
||||
- `tag:chat-import` → only imported chats
|
||||
- `path:chats` → all chats by folder
|
||||
- `-path:chats` → hide all chats
|
||||
- `tag:python tag:chat-import` → Python-related chats only
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`claude-extract` not found:**
|
||||
Install with `pip install claude-conversation-extractor`. If still not found, check that pip's bin directory is in your `$PATH`.
|
||||
|
||||
**Cron job doesn't run on macOS:**
|
||||
Grant Full Disk Access to `cron` in System Preferences → Privacy & Security → Full Disk Access.
|
||||
|
||||
**No tags being generated:**
|
||||
Check that `KEYWORD_TAG_MAP` in `claude_to_obsidian.py` contains keywords actually present in your chats. Run with `--dry-run` to debug.
|
||||
|
||||
**Wikilinks not inserted:**
|
||||
The script only inserts wikilinks for notes that already exist in your vault. Make sure your `--vault-dir` points to a vault with notes (not an empty folder).
|
||||
@@ -0,0 +1,387 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Pipeline: Claude chats → Obsidian vault
|
||||
|
||||
Adds frontmatter, automatic tags, and wikilinks to existing notes.
|
||||
|
||||
Usage:
|
||||
python3 claude_to_obsidian.py \\
|
||||
--export-dir ~/claude-exports \\
|
||||
--vault-dir ~/ObsidianVault \\
|
||||
--move
|
||||
|
||||
Options:
|
||||
--dry-run Show what would happen without modifying anything
|
||||
--move Delete originals after copying (default: copy only)
|
||||
--origin Force origin: code, web, or auto (default: auto)
|
||||
--no-wikilinks Disable wikilink insertion
|
||||
|
||||
Customize KEYWORD_TAG_MAP below to match your stack and projects.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# ============================================================================
|
||||
# CONFIGURATION — adapt KEYWORD_TAG_MAP to your stack and projects
|
||||
# ============================================================================
|
||||
# Maps keywords found in chat content → tags applied to the note.
|
||||
# Add/remove entries based on what you work with.
|
||||
|
||||
KEYWORD_TAG_MAP = {
|
||||
# Languages / Frameworks
|
||||
"python": "python",
|
||||
"javascript": "javascript",
|
||||
"typescript": "typescript",
|
||||
"react": "react",
|
||||
"vue": "vue",
|
||||
"angular": "angular",
|
||||
"fastapi": "fastapi",
|
||||
"django": "django",
|
||||
"flask": "flask",
|
||||
"nodejs": "nodejs",
|
||||
"rust": "rust",
|
||||
"go ": "golang",
|
||||
"golang": "golang",
|
||||
"sql": "sql",
|
||||
|
||||
# AI / ML
|
||||
"machine learning": "machine-learning",
|
||||
"deep learning": "deep-learning",
|
||||
"neural network": "neural-network",
|
||||
"transformer": "transformers",
|
||||
"llm": "llm",
|
||||
"gpt": "llm",
|
||||
"claude": "llm",
|
||||
"langchain": "langchain",
|
||||
"embedding": "embeddings",
|
||||
"fine-tun": "fine-tuning",
|
||||
"rag": "rag",
|
||||
"prompt": "prompt-engineering",
|
||||
"computer vision": "computer-vision",
|
||||
"nlp": "nlp",
|
||||
"pytorch": "pytorch",
|
||||
"tensorflow": "tensorflow",
|
||||
"hugging face": "huggingface",
|
||||
"huggingface": "huggingface",
|
||||
|
||||
# Automation
|
||||
"automation": "automation",
|
||||
"selenium": "automation",
|
||||
"scrapy": "web-scraping",
|
||||
"scraping": "web-scraping",
|
||||
"cron": "automation",
|
||||
"pipeline": "pipeline",
|
||||
|
||||
# Infra / DevOps
|
||||
"docker": "docker",
|
||||
"kubernetes": "kubernetes",
|
||||
"aws": "aws",
|
||||
"gcp": "gcp",
|
||||
"azure": "azure",
|
||||
"linux": "linux",
|
||||
"git": "git",
|
||||
"api": "api",
|
||||
"rest": "api",
|
||||
"graphql": "graphql",
|
||||
|
||||
# Backend services
|
||||
"supabase": "supabase",
|
||||
"firebase": "firebase",
|
||||
"postgres": "database",
|
||||
"mongodb": "database",
|
||||
"redis": "redis",
|
||||
"database": "database",
|
||||
|
||||
# Knowledge management
|
||||
"obsidian": "obsidian",
|
||||
"vault": "obsidian",
|
||||
"zettelkasten": "pkm",
|
||||
"graphify": "graphify",
|
||||
|
||||
# Generic
|
||||
"debug": "debugging",
|
||||
"error": "debugging",
|
||||
"refactor": "refactoring",
|
||||
"test": "testing",
|
||||
"deploy": "deploy",
|
||||
|
||||
# Add your own project-specific keywords here, e.g.:
|
||||
# "my-app": "my-app",
|
||||
# "client-name": "client-work",
|
||||
}
|
||||
|
||||
# Short keywords that should match only as whole words (avoid false positives)
|
||||
SHORT_KEYWORDS = {"sql", "llm", "gpt", "rag", "nlp", "git", "api", "rest", "aws", "gcp"}
|
||||
|
||||
# ============================================================================
|
||||
# CORE LOGIC — usually no need to edit below this line
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def detect_origin(filepath: Path, content: str) -> str:
|
||||
"""Detect whether the chat is from Claude Code or Claude Web."""
|
||||
path_str = str(filepath).lower()
|
||||
if any(k in path_str for k in ("code", "claude-code", ".claude/projects")):
|
||||
return "code"
|
||||
|
||||
code_indicators = ["```bash", "$ claude", "terminal", "command line"]
|
||||
hits = sum(1 for ind in code_indicators if ind.lower() in content.lower())
|
||||
if hits >= 2:
|
||||
return "code"
|
||||
|
||||
return "web"
|
||||
|
||||
|
||||
def extract_tags(content: str) -> list[str]:
|
||||
"""Extract tags via keyword matching in the content."""
|
||||
content_lower = content.lower()
|
||||
found: set[str] = set()
|
||||
|
||||
for keyword, tag in KEYWORD_TAG_MAP.items():
|
||||
if keyword in SHORT_KEYWORDS:
|
||||
if re.search(rf"\b{re.escape(keyword)}\b", content_lower):
|
||||
found.add(tag)
|
||||
else:
|
||||
if keyword in content_lower:
|
||||
found.add(tag)
|
||||
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def strip_existing_frontmatter(content: str) -> tuple[dict[str, str], str]:
|
||||
"""Remove existing frontmatter and return parsed fields + body."""
|
||||
existing: dict[str, str] = {}
|
||||
body = content
|
||||
|
||||
if content.startswith("---\n"):
|
||||
end = content.find("\n---\n", 4)
|
||||
if end != -1:
|
||||
fm_block = content[4:end]
|
||||
body = content[end + 5:]
|
||||
for line in fm_block.split("\n"):
|
||||
if ":" in line and not line.startswith(" ") and not line.startswith("-"):
|
||||
key, _, val = line.partition(":")
|
||||
existing[key.strip()] = val.strip()
|
||||
|
||||
return existing, body
|
||||
|
||||
|
||||
def build_frontmatter(
|
||||
title: str,
|
||||
tags: list[str],
|
||||
origin: str,
|
||||
created: str,
|
||||
) -> str:
|
||||
"""Build the YAML frontmatter block."""
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
all_tags = ["chat-import"] + [t for t in tags if t != "chat-import"]
|
||||
tags_yaml = "\n".join(f" - {t}" for t in all_tags)
|
||||
|
||||
return f"""---
|
||||
title: "{title}"
|
||||
tags:
|
||||
{tags_yaml}
|
||||
source: claude
|
||||
origin: {origin}
|
||||
created: {created}
|
||||
processed: {now}
|
||||
status: imported
|
||||
type: chat
|
||||
---
|
||||
|
||||
"""
|
||||
|
||||
|
||||
def collect_vault_notes(vault_dir: Path) -> list[str]:
|
||||
"""Collect names of all .md notes in the vault (without extension)."""
|
||||
notes: list[str] = []
|
||||
for md in vault_dir.rglob("*.md"):
|
||||
rel = md.relative_to(vault_dir)
|
||||
if any(p.startswith(".") for p in rel.parts):
|
||||
continue
|
||||
name = md.stem
|
||||
if len(name) >= 4:
|
||||
notes.append(name)
|
||||
|
||||
# Sort by length descending so longer names match first
|
||||
notes.sort(key=lambda n: -len(n))
|
||||
return notes
|
||||
|
||||
|
||||
def insert_wikilinks(body: str, vault_notes: list[str]) -> str:
|
||||
"""Insert [[wikilinks]] for vault notes on first occurrence."""
|
||||
# Split body into segments: inside vs outside code blocks
|
||||
parts = re.split(r"(```[\s\S]*?```|`[^`\n]+`)", body)
|
||||
linked: set[str] = set()
|
||||
|
||||
for i, part in enumerate(parts):
|
||||
# Skip code blocks
|
||||
if part.startswith("`"):
|
||||
continue
|
||||
|
||||
for note in vault_notes:
|
||||
if note in linked:
|
||||
continue
|
||||
|
||||
# Case-insensitive search with word boundary
|
||||
pattern = rf"(?<!\[\[)\b({re.escape(note)})\b(?!\]\])"
|
||||
match = re.search(pattern, part, re.IGNORECASE)
|
||||
if match:
|
||||
parts[i] = (
|
||||
part[: match.start()]
|
||||
+ f"[[{note}]]"
|
||||
+ part[match.end():]
|
||||
)
|
||||
part = parts[i]
|
||||
linked.add(note)
|
||||
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def process_file(
|
||||
filepath: Path,
|
||||
vault_dir: Path,
|
||||
vault_notes: list[str],
|
||||
origin_override: str | None,
|
||||
no_wikilinks: bool,
|
||||
dry_run: bool,
|
||||
move: bool,
|
||||
) -> dict:
|
||||
"""Process a single exported .md file."""
|
||||
content = filepath.read_text(encoding="utf-8", errors="replace")
|
||||
_, body = strip_existing_frontmatter(content)
|
||||
|
||||
origin = origin_override or detect_origin(filepath, content)
|
||||
tags = extract_tags(content)
|
||||
title = filepath.stem
|
||||
|
||||
# Created date = original file's modification date
|
||||
mtime = datetime.fromtimestamp(filepath.stat().st_mtime)
|
||||
created = mtime.strftime("%Y-%m-%d")
|
||||
|
||||
# Wikilinks
|
||||
if not no_wikilinks:
|
||||
body = insert_wikilinks(body, vault_notes)
|
||||
|
||||
# Build output
|
||||
frontmatter = build_frontmatter(title, tags, origin, created)
|
||||
output = frontmatter + body
|
||||
|
||||
dest_dir = vault_dir / "chats" / origin
|
||||
dest = dest_dir / filepath.name
|
||||
|
||||
result = {
|
||||
"source": str(filepath),
|
||||
"dest": str(dest),
|
||||
"origin": origin,
|
||||
"tags": tags,
|
||||
"title": title,
|
||||
}
|
||||
|
||||
if dry_run:
|
||||
return result
|
||||
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(output, encoding="utf-8")
|
||||
|
||||
if move:
|
||||
filepath.unlink()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Import Claude chats into an Obsidian vault"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--export-dir", required=True, type=Path,
|
||||
help="Directory containing exported .md files"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--vault-dir", required=True, type=Path,
|
||||
help="Path to the Obsidian vault"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run", action="store_true",
|
||||
help="Show what would happen without modifying anything"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--move", action="store_true",
|
||||
help="Delete originals after copying (default: copy only)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--origin", choices=["code", "web", "auto"], default="auto",
|
||||
help="Force origin or auto-detect (default: auto)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-wikilinks", action="store_true",
|
||||
help="Disable wikilink insertion"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.export_dir.exists():
|
||||
print(f"ERROR: Export directory not found: {args.export_dir}")
|
||||
return
|
||||
|
||||
if not args.vault_dir.exists():
|
||||
print(f"ERROR: Vault not found: {args.vault_dir}")
|
||||
return
|
||||
|
||||
# Collect vault notes
|
||||
vault_notes = collect_vault_notes(args.vault_dir)
|
||||
print(f"Vault notes found: {len(vault_notes)}")
|
||||
|
||||
# Find .md files in export directory
|
||||
md_files = sorted(args.export_dir.rglob("*.md"))
|
||||
if not md_files:
|
||||
print("No .md files found in export directory.")
|
||||
return
|
||||
|
||||
print(f"Files to process: {len(md_files)}")
|
||||
if args.dry_run:
|
||||
print("=== DRY RUN — nothing will be modified ===\n")
|
||||
|
||||
results = []
|
||||
origin_override = args.origin if args.origin != "auto" else None
|
||||
|
||||
for f in md_files:
|
||||
result = process_file(
|
||||
f, args.vault_dir, vault_notes,
|
||||
origin_override, args.no_wikilinks,
|
||||
args.dry_run, args.move,
|
||||
)
|
||||
results.append(result)
|
||||
|
||||
tags_str = ", ".join(result["tags"]) if result["tags"] else "(no tags)"
|
||||
prefix = "[DRY] " if args.dry_run else ""
|
||||
print(f'{prefix}✓ {result["title"]}')
|
||||
print(f' Origin: {result["origin"]} | Tags: {tags_str}')
|
||||
print(f' → {result["dest"]}')
|
||||
|
||||
# Summary
|
||||
code_count = sum(1 for r in results if r["origin"] == "code")
|
||||
web_count = sum(1 for r in results if r["origin"] == "web")
|
||||
all_tags: set[str] = set()
|
||||
for r in results:
|
||||
all_tags.update(r["tags"])
|
||||
|
||||
print(f"\n{'═' * 50}")
|
||||
print(f"SUMMARY")
|
||||
print(f"{'═' * 50}")
|
||||
print(f"Total processed: {len(results)}")
|
||||
print(f" Code: {code_count}")
|
||||
print(f" Web: {web_count}")
|
||||
print(f"Unique tags found: {len(all_tags)}")
|
||||
if all_tags:
|
||||
print(f" {', '.join(sorted(all_tags))}")
|
||||
if args.dry_run:
|
||||
print("\n⚠ Dry run — no files were modified.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/bin/bash
|
||||
# Sync Claude → Obsidian
|
||||
# Run manually or via cron.
|
||||
#
|
||||
# Setup:
|
||||
# 1. Edit VAULT_DIR below to point to your Obsidian vault
|
||||
# 2. Make executable: chmod +x sync_claude_obsidian.sh
|
||||
# 3. Test once manually: ./sync_claude_obsidian.sh
|
||||
# 4. (Optional) Add to cron to run daily at 10pm:
|
||||
# (crontab -l 2>/dev/null; echo "0 22 * * * $HOME/scripts/sync_claude_obsidian.sh") | crontab -
|
||||
|
||||
# ============================================================================
|
||||
# CONFIGURATION — edit these paths
|
||||
# ============================================================================
|
||||
|
||||
VAULT_DIR="$HOME/ObsidianVault" # path to your Obsidian vault
|
||||
EXPORT_DIR="$HOME/claude-exports" # staging area for exported chats
|
||||
SCRIPT_DIR="$HOME/scripts" # where this script and the .py live
|
||||
LOG="$SCRIPT_DIR/claude_obsidian_sync.log"
|
||||
|
||||
# ============================================================================
|
||||
|
||||
mkdir -p "$EXPORT_DIR/code" "$EXPORT_DIR/web"
|
||||
|
||||
echo "[$(date)] Starting sync..." >> "$LOG"
|
||||
|
||||
# 1. Export Claude Code chats (requires claude-conversation-extractor)
|
||||
if command -v claude-extract &> /dev/null; then
|
||||
claude-extract --all --output "$EXPORT_DIR/code" 2>> "$LOG"
|
||||
echo "[$(date)] Claude Code chats exported" >> "$LOG"
|
||||
else
|
||||
echo "[$(date)] claude-extract not found — install with:" >> "$LOG"
|
||||
echo "[$(date)] pip install claude-conversation-extractor" >> "$LOG"
|
||||
echo "[$(date)] Skipping Code export" >> "$LOG"
|
||||
fi
|
||||
|
||||
# 2. Process and send to vault
|
||||
# Web chats should be manually exported via browser extension
|
||||
# and dropped into $EXPORT_DIR/web/ — they'll be processed too.
|
||||
python3 "$SCRIPT_DIR/claude_to_obsidian.py" \
|
||||
--export-dir "$EXPORT_DIR" \
|
||||
--vault-dir "$VAULT_DIR" \
|
||||
--move 2>> "$LOG"
|
||||
|
||||
echo "[$(date)] Sync completed" >> "$LOG"
|
||||
echo "" >> "$LOG"
|
||||
Reference in New Issue
Block a user