Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@ Thumbs.db
# Model cache (large files)
.cache/
*.onnx
worker/node_modules/
15 changes: 14 additions & 1 deletion paper_intelligence/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
Provides AI agents with efficient, searchable access to PDF documents.
PDFs are automatically processed on first search (1-3 minutes), then
all subsequent searches are instant.

Papers are synced to/from Cloudflare R2 via rclone for shared access.
"""

from typing import Literal
Expand Down Expand Up @@ -50,9 +52,13 @@ def search(
Returns:
Search results with content, location, and relevance scores
"""
from .sync import pull
from .tools.search import search as _search

return _search(
# Pull latest from R2 before searching
pull()

result = _search(
query=query,
sources=sources,
mode=mode,
Expand All @@ -63,6 +69,13 @@ def search(
use_llm=use_llm,
)

# If any papers were auto-processed, push to R2
if result.get("processing_notes"):
from .sync import push
push()

return result


@mcp.tool()
def get_paper_info(paper_dir: str) -> dict:
Expand Down
129 changes: 129 additions & 0 deletions paper_intelligence/sync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"""R2 sync via rclone for shared paper storage.

Auto-configures rclone on first use from 1Password credentials.
Auto-installs rclone via brew if not found.
"""

import logging
import os
import shutil
import subprocess
from pathlib import Path

logger = logging.getLogger(__name__)

RCLONE_REMOTE = "r2:paper-intelligence"
LOCAL_DIR = str(Path("~/Documents/papers").expanduser())
RCLONE_CONF = Path.home() / ".config" / "rclone" / "rclone.conf"

# 1Password paths for R2 credentials
OP_ITEM = "R2 Paper Intelligence"
OP_VAULT = "CLI Secrets"


def _ensure_rclone_installed() -> bool:
"""Install rclone via brew if not found."""
if shutil.which("rclone"):
return True
logger.info("Installing rclone...")
try:
subprocess.run(
["brew", "install", "rclone"],
check=True, capture_output=True, timeout=120,
)
return True
except Exception:
logger.warning("Could not install rclone — install manually: brew install rclone")
return False


def _ensure_rclone_configured() -> bool:
"""Configure rclone for R2 from 1Password if not already set up."""
if RCLONE_CONF.exists() and "r2" in RCLONE_CONF.read_text():
return True

logger.info("Configuring rclone for R2 from 1Password...")
try:
access_key = subprocess.check_output(
["op", "read", f"op://{OP_VAULT}/{OP_ITEM}/Access Key ID"],
text=True, stderr=subprocess.DEVNULL,
).strip()
secret_key = subprocess.check_output(
["op", "read", f"op://{OP_VAULT}/{OP_ITEM}/Secret Access Key"],
text=True, stderr=subprocess.DEVNULL,
).strip()
endpoint = subprocess.check_output(
["op", "read", f"op://{OP_VAULT}/{OP_ITEM}/Endpoint"],
text=True, stderr=subprocess.DEVNULL,
).strip()
except Exception:
logger.warning("Could not read R2 credentials from 1Password — skipping sync")
return False

RCLONE_CONF.parent.mkdir(parents=True, exist_ok=True)

# Append r2 config (preserve existing config if any)
existing = RCLONE_CONF.read_text() if RCLONE_CONF.exists() else ""
if "[r2]" not in existing:
with open(RCLONE_CONF, "a") as f:
f.write(f"""
[r2]
type = s3
provider = Cloudflare
access_key_id = {access_key}
secret_access_key = {secret_key}
endpoint = {endpoint}
acl = private
no_check_bucket = true
""")
return True


def _ensure_setup() -> bool:
"""Ensure rclone is installed and configured. Returns True if ready."""
return _ensure_rclone_installed() and _ensure_rclone_configured()


def _run_rclone(args: list[str]) -> bool:
"""Run an rclone command. Returns True on success."""
cmd = ["rclone"] + args
try:
subprocess.run(
cmd,
check=True,
capture_output=True,
text=True,
timeout=300,
)
return True
except FileNotFoundError:
logger.warning("rclone not installed — skipping sync")
return False
except subprocess.CalledProcessError as e:
logger.warning(f"rclone failed: {e.stderr.strip()}")
return False
except subprocess.TimeoutExpired:
logger.warning("rclone timed out")
return False


def pull():
"""Pull latest papers from R2 (additive only — won't delete local files)."""
if not _ensure_setup():
return False
return _run_rclone([
"copy", RCLONE_REMOTE, LOCAL_DIR,
"--transfers", "8",
"--checkers", "16",
])


def push():
"""Push local papers to R2 (additive only — won't delete remote files)."""
if not _ensure_setup():
return False
return _run_rclone([
"copy", LOCAL_DIR, RCLONE_REMOTE,
"--transfers", "8",
"--checkers", "16",
])
45 changes: 44 additions & 1 deletion paper_intelligence/tools/convert.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""PDF to Markdown conversion tool using Marker."""

import hashlib
import os
from pathlib import Path
from typing import Optional
Expand All @@ -16,6 +17,47 @@
pass


def pdf_hash(pdf_path: Path) -> str:
"""Compute SHA-256 hash of a PDF file."""
h = hashlib.sha256()
with open(pdf_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
h.update(chunk)
return h.hexdigest()


def find_duplicate(pdf_path: str | Path, papers_dir: str | Path | None = None) -> Optional[str]:
"""Check if a PDF already exists in the papers library by content hash.

Args:
pdf_path: Path to the PDF to check
papers_dir: Directory containing paper subdirectories (default: parent of pdf_path)

Returns:
Name of the existing paper if duplicate found, None otherwise
"""
from ..metadata import read_metadata

pdf_path = Path(pdf_path).expanduser().resolve()
if not pdf_path.exists():
return None

new_hash = pdf_hash(pdf_path)

if papers_dir is None:
papers_dir = pdf_path.parent
papers_dir = Path(papers_dir).expanduser().resolve()

for paper_dir in papers_dir.iterdir():
if not paper_dir.is_dir():
continue
meta = read_metadata(paper_dir)
if meta and meta.get("pdf_hash") == new_hash:
return paper_dir.name

return None


def get_output_dir(pdf_path: Path) -> Path:
"""Generate output directory for a PDF file.

Expand Down Expand Up @@ -114,11 +156,12 @@ def convert_pdf(
from ..metadata import write_metadata

image_count = len(images) if images else 0
content_hash = pdf_hash(pdf_path)
write_metadata(
paper_dir=out_dir,
source_pdf=pdf_path.name,
steps_completed=["convert"],
extra={"image_count": image_count},
extra={"image_count": image_count, "pdf_hash": content_hash},
)

return {
Expand Down
7 changes: 6 additions & 1 deletion paper_intelligence/tools/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,17 @@ def _process_paper_if_needed(
Returns:
Tuple of (paper_dir, error_message). If error, paper_dir is None.
"""
from .convert import convert_pdf, get_output_dir
from .convert import convert_pdf, find_duplicate, get_output_dir
from .embed import embed_document
from .index import index_markdown

# Handle PDF files
if path.is_file() and path.suffix.lower() == ".pdf":
# Check for duplicate by content hash
dup = find_duplicate(path, papers_dir=path.parent)
if dup:
return path.parent / dup, None

paper_dir = get_output_dir(path)

# Check if already processed and compatible
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "paper-intelligence"
version = "0.3.0"
version = "0.4.1"
description = "MCP server enabling AI agents to efficiently search and understand PDF documents"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading