Files
wehub-resource-sync 91e75e620b
CI: cua-driver distro-compat matrix / Resolve release version (push) Waiting to run
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Blocked by required conditions
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Blocked by required conditions
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Blocked by required conditions
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Blocked by required conditions
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Blocked by required conditions
CI: cua-driver distro-compat matrix / Distro compat summary (push) Blocked by required conditions
CI: Nix Linux Rust source / Nix / compositor build (push) Waiting to run
CI: Nix Linux Rust source / Nix / driver package (push) Waiting to run
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Waiting to run
CI: Rust Linux unit / Rust Linux unit and compile (push) Waiting to run
CI: Rust Windows unit / Rust Windows unit and compile (push) Waiting to run
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Waiting to run
CD: Docs MCP Server / build (linux/amd64) (push) Waiting to run
CD: Docs MCP Server / build (linux/arm64) (push) Waiting to run
CD: Docs MCP Server / merge (push) Blocked by required conditions
chore: import upstream snapshot with attribution
2026-07-13 13:03:19 +08:00

2132 lines
71 KiB
Python

"""
Modal app for CUA documentation crawling and MCP server
This app provides:
1. Scheduled daily crawling of cua.ai/docs stored in a Modal volume
2. MCP server that serves documentation search over the crawled data
Usage:
modal deploy docs/scripts/modal_app.py
"""
import asyncio
import html
from html.parser import HTMLParser
import json
import re
import sqlite3
from pathlib import Path
from typing import Optional
import modal
from markdown_it import MarkdownIt
# Define the Modal app
app = modal.App("cua-docs-mcp")
# Create persistent volumes for storing data
docs_volume = modal.Volume.from_name("cua-docs-data", create_if_missing=True)
code_volume = modal.Volume.from_name("cua-code-index", create_if_missing=True)
# GitHub token secret for cloning
github_secret = modal.Secret.from_name("github-secret", required_keys=["GITHUB_TOKEN"])
# AWS IAM role ARN for OIDC-based S3 write access (no static keys needed)
# Role created in cloud repo: terraform/aws/docs-mcp-storage/main.tf
S3_WRITE_ROLE_ARN = "arn:aws:iam::296062593712:role/modal-docs-mcp-write-role"
S3_BUCKET_NAME = "trycua-docs-mcp-data"
S3_BUCKET_REGION = "us-west-2"
# Define the container image with all dependencies
image = (
modal.Image.debian_slim(python_version="3.12")
.apt_install("git")
.pip_install(
"playwright>=1.40.0",
"lancedb>=0.4.0",
"sentence-transformers>=2.2.0",
"pyarrow>=14.0.1",
"fastapi>=0.100.0",
"fastmcp>=2.14.0",
"pydantic>=2.0.0",
"pandas>=2.0.0",
"markdown-it-py>=3.0.0",
"markitdown>=0.0.1",
"boto3>=1.34.0",
)
.run_commands("playwright install --with-deps chromium")
)
# Volume mount paths
VOLUME_PATH = "/data"
CRAWLED_DATA_PATH = f"{VOLUME_PATH}/crawled_data"
DB_PATH = f"{VOLUME_PATH}/docs_db"
# Code index volume mount path
CODE_VOLUME_PATH = "/code_data"
CODE_REPO_PATH = f"{CODE_VOLUME_PATH}/repo"
CODE_DB_PATH = f"{CODE_VOLUME_PATH}/code_db"
# =============================================================================
# Helper Functions
# =============================================================================
class HTMLToMarkdown(HTMLParser):
"""Small dependency-free HTML-to-Markdown converter for crawled docs pages.
Extraction is scoped to the page's main content container (``<article>``,
falling back to ``<main>``) and site chrome (``nav``/``aside``/``footer``) is
dropped, so the crawled corpus is the documentation body rather than the
navigation tree that repeats identically on every page.
"""
block_tags = {
"blockquote",
"br",
"div",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"header",
"li",
"main",
"ol",
"p",
"pre",
"section",
"table",
"tr",
"ul",
}
# Content of these tags is dropped entirely: non-text assets and the site
# chrome (sidebar/nav tree, "on this page" aside, footer) that is identical
# on every page and would otherwise dominate the embedded corpus.
skip_tags = {"script", "style", "svg", "nav", "aside", "footer"}
def __init__(self, scope_tag: str | None = None) -> None:
super().__init__(convert_charrefs=True)
self.parts: list[str] = []
self.skip_depth = 0
self.in_pre = False
# When set, only emit text while inside this container; None = emit all.
self.scope_tag = scope_tag
self.scope_depth = 0
@property
def _capturing(self) -> bool:
return self.scope_tag is None or self.scope_depth > 0
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
if tag in self.skip_tags:
self.skip_depth += 1
return
if tag == self.scope_tag:
self.scope_depth += 1
if self.skip_depth or not self._capturing:
return
if tag in self.block_tags:
self.parts.append("\n")
if tag == "li":
self.parts.append("- ")
elif tag == "pre":
self.in_pre = True
self.parts.append("\n```\n")
elif tag == "code" and not self.in_pre:
self.parts.append("`")
def handle_endtag(self, tag: str) -> None:
if tag in self.skip_tags and self.skip_depth:
self.skip_depth -= 1
return
if self.skip_depth:
return
if self._capturing:
if tag == "pre":
self.in_pre = False
self.parts.append("\n```\n")
elif tag == "code" and not self.in_pre:
self.parts.append("`")
if tag in self.block_tags:
self.parts.append("\n")
if tag == self.scope_tag and self.scope_depth:
self.scope_depth -= 1
def handle_data(self, data: str) -> None:
if self.skip_depth or not self._capturing:
return
text = data if self.in_pre else re.sub(r"\s+", " ", data)
if text.strip():
self.parts.append(text)
def markdown(self) -> str:
text = html.unescape("".join(self.parts))
text = re.sub(r"[ \t]+\n", "\n", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def html_to_markdown(page_html: str) -> str:
# Prefer the main content container so the navigation/sidebar chrome that
# repeats on every page does not pollute the crawled corpus; fall back to
# the whole document when neither container is present.
scope_tag = None
for tag in ("article", "main"):
if re.search(rf"<{tag}[\s>]", page_html, re.IGNORECASE):
scope_tag = tag
break
parser = HTMLToMarkdown(scope_tag)
parser.feed(page_html)
return parser.markdown()
def extract_metadata(page_html: str, title: str) -> dict[str, str]:
description = ""
match = re.search(
r'<meta[^>]+name=["\']description["\'][^>]+content=["\']([^"\']*)["\']',
page_html,
re.IGNORECASE,
)
if match:
description = html.unescape(match.group(1))
return {"title": title, "description": description}
def clean_markdown(markdown: str) -> str:
"""Extract plain text content from markdown using markdown-it-py parser"""
md_parser = MarkdownIt()
tokens = md_parser.parse(markdown)
text_parts = []
def extract_text(token_list):
for token in token_list:
if token.type == "inline" and token.children:
for child in token.children:
if child.type == "text":
text_parts.append(child.content)
elif child.type == "code_inline":
text_parts.append(child.content)
elif child.type == "softbreak":
text_parts.append(" ")
elif child.type == "hardbreak":
text_parts.append("\n")
elif token.type == "fence" or token.type == "code_block":
text_parts.append(token.content)
text_parts.append("\n")
if token.children:
extract_text(token.children)
if token.type in [
"heading_close",
"paragraph_close",
"list_item_close",
"blockquote_close",
]:
text_parts.append("\n")
extract_text(tokens)
text = "".join(text_parts)
text = re.sub(r"\n{3,}", "\n\n", text)
text = re.sub(r" {2,}", " ", text)
return text.strip()
# =============================================================================
# Crawling Functions
# =============================================================================
@app.function(
image=image,
volumes={VOLUME_PATH: docs_volume},
timeout=3600, # 1 hour timeout
cpu=2.0,
memory=4096,
)
async def crawl_docs():
"""Crawl CUA documentation and save to volume"""
import re
import shutil
from urllib.parse import urljoin, urlparse
from playwright.async_api import async_playwright
print("Starting documentation crawl...")
BASE_URL = "https://cua.ai"
DOCS_URL = f"{BASE_URL}/docs"
OUTPUT_DIR = Path(CRAWLED_DATA_PATH)
# Clear existing crawled data to ensure fresh results
if OUTPUT_DIR.exists():
shutil.rmtree(OUTPUT_DIR)
print("Cleared existing crawled data")
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
visited_urls = set()
to_visit = set()
failed_urls = set()
all_data = []
def normalize_url(url: str) -> str:
"""Normalize URL to avoid duplicates"""
parsed = urlparse(url)
path = parsed.path.rstrip("/")
if not path:
path = ""
return f"{parsed.scheme}://{parsed.netloc}{path}"
def is_valid_url(url: str) -> bool:
"""Check if URL should be crawled (only /docs pages)"""
parsed = urlparse(url)
if parsed.netloc and parsed.netloc not in ["cua.ai", "www.cua.ai"]:
return False
if not parsed.path.startswith("/docs"):
return False
# Skip non-page resources
excluded_extensions = [
".pdf",
".zip",
".png",
".jpg",
".jpeg",
".gif",
".svg",
".ico",
".css",
".js",
]
if any(parsed.path.lower().endswith(ext) for ext in excluded_extensions):
return False
return True
def extract_links(html: str, base_url: str) -> set[str]:
"""Extract all valid links from HTML"""
links = set()
# Find all href attributes
href_pattern = r'href=["\']([^"\']+)["\']'
matches = re.findall(href_pattern, html)
for match in matches:
full_url = urljoin(base_url, match)
normalized = normalize_url(full_url)
if is_valid_url(normalized):
links.add(normalized)
return links
def extract_path_info(url: str) -> dict:
"""Extract meaningful path information from URL"""
parsed = urlparse(url)
path = parsed.path.replace("/docs/", "").strip("/")
parts = path.split("/") if path else []
return {
"path": path,
"category": parts[0] if parts else "root",
"subcategory": parts[1] if len(parts) > 1 else None,
"page": parts[-1] if parts else "index",
"depth": len(parts),
}
def save_page(url: str, data: dict):
"""Save page data to a JSON file"""
parsed = urlparse(url)
path = parsed.path.strip("/") or "index"
filename = path.replace("/", "_") + ".json"
filepath = OUTPUT_DIR / filename
with open(filepath, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
# Seed URLs
seed_urls = [
DOCS_URL,
f"{DOCS_URL}/cua",
f"{DOCS_URL}/cua/guide",
f"{DOCS_URL}/cua/guide/get-started",
f"{DOCS_URL}/cua/reference",
f"{DOCS_URL}/cua/reference/computer-sdk",
f"{DOCS_URL}/cuabench",
f"{DOCS_URL}/cuabench/guide",
f"{DOCS_URL}/cuabench/reference",
]
for url in seed_urls:
normalized = normalize_url(url)
if is_valid_url(normalized):
to_visit.add(normalized)
async with async_playwright() as playwright:
browser = await playwright.chromium.launch(headless=True)
try:
while to_visit:
# Get batch of URLs to crawl
batch = []
MAX_CONCURRENT = 5
while to_visit and len(batch) < MAX_CONCURRENT:
url = to_visit.pop()
if url not in visited_urls:
batch.append(url)
visited_urls.add(url)
if not batch:
break
# Crawl each URL in batch
for url in batch:
page = None
try:
print(f"Crawling: {url}")
page = await browser.new_page()
response = await page.goto(
url,
wait_until="networkidle",
timeout=30_000,
)
if response is None or not response.ok:
status = response.status if response else "no response"
print(f"Failed to crawl {url}: HTTP {status}")
failed_urls.add(url)
continue
page_html = await page.content()
metadata = extract_metadata(page_html, await page.title())
# Extract new links from the page
new_links = extract_links(page_html, url)
for link in new_links:
if link not in visited_urls and link not in to_visit:
to_visit.add(link)
path_info = extract_path_info(url)
page_data = {
"url": url,
"title": metadata["title"],
"description": metadata["description"],
"markdown": html_to_markdown(page_html),
"path_info": path_info,
"links_found": list(new_links),
}
# Save individual page
save_page(url, page_data)
all_data.append(page_data)
await asyncio.sleep(0.5)
except Exception as e:
print(f"Error crawling {url}: {e}")
failed_urls.add(url)
finally:
if page is not None:
await page.close()
print(f"Progress: {len(visited_urls)} crawled, {len(to_visit)} remaining")
finally:
await browser.close()
# Save summary
summary = {
"total_pages": len(all_data),
"failed_urls": list(failed_urls),
"all_urls": list(visited_urls),
}
with open(OUTPUT_DIR / "_summary.json", "w", encoding="utf-8") as f:
json.dump(summary, f, indent=2)
# Save all data in one file too
with open(OUTPUT_DIR / "_all_pages.json", "w", encoding="utf-8") as f:
json.dump(all_data, f, indent=2, ensure_ascii=False)
# Commit changes to volume
docs_volume.commit()
print(f"Crawl complete! Crawled {len(all_data)} pages")
return summary
@app.function(
image=image,
volumes={VOLUME_PATH: docs_volume, CODE_VOLUME_PATH: code_volume},
timeout=1800, # 30 minutes
cpu=1.0,
memory=2048,
)
def sync_to_s3(bucket: str = S3_BUCKET_NAME):
"""Sync generated databases from Modal volumes to S3.
Uses Modal OIDC federation to assume an AWS IAM role for write access.
No static AWS credentials needed — the IAM role trust policy is scoped
to this Modal workspace and app (terraform/aws/docs-mcp-storage/main.tf).
Args:
bucket: S3 bucket name to upload to
"""
import os
import boto3
print(f"Syncing databases to s3://{bucket}/ ...")
# Use Modal's OIDC token (auto-injected env var) to assume AWS IAM role
oidc_token = os.environ["MODAL_IDENTITY_TOKEN"]
sts = boto3.client("sts", region_name=S3_BUCKET_REGION)
creds = sts.assume_role_with_web_identity(
RoleArn=S3_WRITE_ROLE_ARN,
RoleSessionName="modal-docs-mcp-s3-sync",
WebIdentityToken=oidc_token,
DurationSeconds=3600,
)["Credentials"]
s3 = boto3.client(
"s3",
region_name=S3_BUCKET_REGION,
aws_access_key_id=creds["AccessKeyId"],
aws_secret_access_key=creds["SecretAccessKey"],
aws_session_token=creds["SessionToken"],
)
uploaded = 0
# --- docs databases ---
docs_db_dir = Path(DB_PATH)
if docs_db_dir.exists():
# SQLite
sqlite_path = docs_db_dir / "docs.sqlite"
if sqlite_path.exists():
key = "docs_db/docs.sqlite"
print(f" Uploading {sqlite_path} -> {key}")
s3.upload_file(str(sqlite_path), bucket, key)
uploaded += 1
# LanceDB directory
lance_dir = docs_db_dir / "docs.lance"
if lance_dir.exists():
for fpath in lance_dir.rglob("*"):
if fpath.is_file():
key = f"docs_db/docs.lance/{fpath.relative_to(lance_dir)}"
s3.upload_file(str(fpath), bucket, key)
uploaded += 1
print(" Uploaded docs LanceDB directory")
# --- code databases ---
code_db_dir = Path(CODE_DB_PATH)
if code_db_dir.exists():
# Aggregated SQLite
code_sqlite = code_db_dir / "code_index.sqlite"
if code_sqlite.exists():
key = "code_db/code_index.sqlite"
print(f" Uploading {code_sqlite} -> {key}")
s3.upload_file(str(code_sqlite), bucket, key)
uploaded += 1
# Aggregated LanceDB directory
code_lance = code_db_dir / "code_index.lancedb"
if code_lance.exists():
for fpath in code_lance.rglob("*"):
if fpath.is_file():
key = f"code_db/code_index.lancedb/{fpath.relative_to(code_lance)}"
s3.upload_file(str(fpath), bucket, key)
uploaded += 1
print(" Uploaded code LanceDB directory")
print(f"S3 sync complete: {uploaded} files uploaded to s3://{bucket}/")
return {"bucket": bucket, "files_uploaded": uploaded}
@app.function(
image=image,
volumes={VOLUME_PATH: docs_volume},
schedule=modal.Cron("0 6 * * *"), # Daily at 6 AM UTC
timeout=3600,
)
async def scheduled_crawl():
"""Scheduled daily crawl of documentation"""
print("Running scheduled crawl...")
summary = await crawl_docs.remote.aio()
# Regenerate databases after crawl
print("Generating databases...")
await generate_vector_db.remote.aio()
await generate_sqlite_db.remote.aio()
# Sync docs databases to S3
print("Syncing docs databases to S3...")
sync_result = sync_to_s3.remote()
print(f"S3 sync result: {sync_result}")
print(f"Scheduled crawl complete: {summary}")
return summary
# =============================================================================
# Database Generation Functions
# =============================================================================
#
# The MCP server provides access to two types of query databases:
#
# 1. DOCUMENTATION DATABASES (from cua.ai/docs crawl):
# - SQLite FTS5 Database (docs.sqlite):
# * `pages` table: stores URL, title, category, and plain-text content
# * `pages_fts` virtual table: FTS5 full-text search index
# * Triggers keep FTS index synchronized with pages table
# * Built by: generate_sqlite_db()
#
# - LanceDB Vector Database (docs.lance/):
# * DocsChunk schema: text, vector (384-dim), url, title, category, chunk_index
# * Uses sentence-transformers/all-MiniLM-L6-v2 for embeddings
# * Chunks documents by paragraph for semantic search
# * Built by: generate_vector_db()
#
# 2. CODE INDEX DATABASES (from git tags):
# Per-component databases (built in parallel):
# - SQLite FTS5 Database (code_index_<component>.sqlite per component):
# * `code_files` table: component, version, file_path, content, language
# * `code_files_fts` virtual table: FTS5 full-text search index
# * Indexes source files (.py, .ts, .js, .tsx) from all git tags
# * Built by: index_component() called from generate_code_index_parallel()
#
# - LanceDB Vector Database (code_index_<component>.lancedb/ per component):
# * CodeFile schema: text, vector (384-dim), component, version, file_path, language
# * Only embeds files under 100KB to avoid memory issues
# * Built by: index_component() called from generate_code_index_parallel()
#
# Aggregated databases (for MCP server queries):
# - code_index.sqlite: Unified SQLite with all components' data + FTS5 index
# - code_index.lancedb: Unified LanceDB with all components' vectors
# - Built by: aggregate_code_databases() after parallel indexing completes
#
# Database Build Process:
# 1. scheduled_crawl() runs daily at 6 AM UTC:
# - Calls crawl_docs() to crawl cua.ai/docs
# - Calls generate_vector_db() to build LanceDB from crawled markdown
# - Calls generate_sqlite_db() to build SQLite FTS from crawled content
#
# 2. scheduled_code_index() runs daily at 5 AM UTC (before docs):
# - Calls generate_code_index_parallel() which:
# a. Clones/updates the git repository (bare clone)
# b. Groups all git tags by component (agent, computer, etc.)
# c. Dispatches parallel index_component() workers per component
# d. Each worker builds its own SQLite + LanceDB
# - Calls aggregate_code_databases() to merge per-component DBs into unified DBs
#
# Note: Modal volumes don't support atomic rename operations, so LanceDB is
# built in a temp directory first, then copied to the volume.
# =============================================================================
@app.function(
image=image,
volumes={VOLUME_PATH: docs_volume},
timeout=1800, # 30 minutes
cpu=2.0,
memory=8192,
)
async def generate_vector_db():
"""Generate LanceDB vector database from crawled data"""
import shutil
import tempfile
import lancedb
from lancedb.embeddings import get_registry
from lancedb.pydantic import LanceModel, Vector
print("Generating LanceDB vector database...")
CRAWLED_DIR = Path(CRAWLED_DATA_PATH)
DB_DIR = Path(DB_PATH)
DB_DIR.mkdir(parents=True, exist_ok=True)
# Use /tmp for LanceDB operations (Modal volumes don't support atomic rename)
TMP_LANCE_DIR = Path(tempfile.mkdtemp(prefix="lancedb_"))
print(f"Using temp directory: {TMP_LANCE_DIR}")
# Initialize embedding model
model = get_registry().get("sentence-transformers").create(name="all-MiniLM-L6-v2")
# Define schema with embedding configuration
class DocsChunk(LanceModel):
text: str = model.SourceField()
vector: Vector(model.ndims()) = model.VectorField()
url: str
title: str
category: str
chunk_index: int
# Load all crawled pages
json_files = list(CRAWLED_DIR.glob("*.json"))
json_files = [f for f in json_files if not f.name.startswith("_")]
if not json_files:
print("No crawled data found!")
return
all_chunks = []
for json_file in json_files:
with open(json_file, "r", encoding="utf-8") as f:
page_data = json.load(f)
url = page_data.get("url", "")
title = page_data.get("title", "")
markdown = page_data.get("markdown", "")
category = page_data.get("path_info", {}).get("category", "unknown")
if not markdown:
continue
# Convert markdown to plain text
text = clean_markdown(markdown)
# Simple chunking by paragraphs
paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
for i, para in enumerate(paragraphs):
if len(para) < 50: # Skip very short paragraphs
continue
chunk = {
"text": para,
"url": url,
"title": title,
"category": category,
"chunk_index": i,
}
all_chunks.append(chunk)
if not all_chunks:
print("No chunks generated!")
return
# Create LanceDB in temp directory (supports atomic operations)
db = lancedb.connect(TMP_LANCE_DIR)
# Create table with schema - embeddings are generated automatically
table = db.create_table(
"docs",
schema=DocsChunk,
mode="overwrite",
)
# Add data in batches for better performance
batch_size = 100
for i in range(0, len(all_chunks), batch_size):
batch = all_chunks[i : i + batch_size]
table.add(batch)
print(
f"Added batch {i // batch_size + 1}/{(len(all_chunks) + batch_size - 1) // batch_size}"
)
# Close the connection before copying
del table
del db
# Copy completed database to Modal volume
lance_db_dest = DB_DIR / "docs.lance"
if lance_db_dest.exists():
shutil.rmtree(lance_db_dest)
print("Cleared existing vector database on volume")
# Copy from temp to volume
shutil.copytree(TMP_LANCE_DIR / "docs.lance", lance_db_dest)
print(f"Copied LanceDB to volume: {lance_db_dest}")
# Clean up temp directory
shutil.rmtree(TMP_LANCE_DIR)
# Commit changes to volume
docs_volume.commit()
print(f"Vector database created with {len(all_chunks)} chunks")
return {"chunks": len(all_chunks)}
@app.function(
image=image,
volumes={VOLUME_PATH: docs_volume},
timeout=1800,
cpu=2.0,
memory=4096,
)
async def generate_sqlite_db():
"""Generate SQLite FTS5 database from crawled data"""
print("Generating SQLite FTS5 database...")
CRAWLED_DIR = Path(CRAWLED_DATA_PATH)
DB_DIR = Path(DB_PATH)
DB_DIR.mkdir(parents=True, exist_ok=True)
SQLITE_PATH = DB_DIR / "docs.sqlite"
# Delete existing database to ensure fresh data
if SQLITE_PATH.exists():
SQLITE_PATH.unlink()
print("Cleared existing SQLite database")
# Create database
conn = sqlite3.connect(SQLITE_PATH)
cursor = conn.cursor()
# Create tables
cursor.execute("""
CREATE TABLE pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT UNIQUE NOT NULL,
title TEXT,
category TEXT,
content TEXT
)
""")
cursor.execute("""
CREATE VIRTUAL TABLE pages_fts USING fts5(
content,
url UNINDEXED,
title UNINDEXED,
category UNINDEXED,
content='pages',
content_rowid='id'
)
""")
# Create FTS triggers BEFORE inserting data
# This is critical: since pages_fts uses external content (content='pages'),
# the FTS index is only populated via these triggers. If triggers are created
# after data insertion, the FTS table will be empty.
cursor.execute("""
CREATE TRIGGER pages_ai AFTER INSERT ON pages BEGIN
INSERT INTO pages_fts(rowid, content, url, title, category)
VALUES (new.id, new.content, new.url, new.title, new.category);
END;
""")
cursor.execute("""
CREATE TRIGGER pages_ad AFTER DELETE ON pages BEGIN
DELETE FROM pages_fts WHERE rowid = old.id;
END;
""")
cursor.execute("""
CREATE TRIGGER pages_au AFTER UPDATE ON pages BEGIN
DELETE FROM pages_fts WHERE rowid = old.id;
INSERT INTO pages_fts(rowid, content, url, title, category)
VALUES (new.id, new.content, new.url, new.title, new.category);
END;
""")
conn.commit()
# Load and insert data (triggers will populate FTS automatically)
json_files = list(CRAWLED_DIR.glob("*.json"))
json_files = [f for f in json_files if not f.name.startswith("_")]
inserted = 0
for json_file in json_files:
with open(json_file, "r", encoding="utf-8") as f:
page_data = json.load(f)
url = page_data.get("url", "")
title = page_data.get("title", "")
markdown = page_data.get("markdown", "")
category = page_data.get("path_info", {}).get("category", "unknown")
if not markdown:
continue
# Convert markdown to plain text
text = clean_markdown(markdown)
cursor.execute(
"INSERT OR REPLACE INTO pages (url, title, category, content) VALUES (?, ?, ?, ?)",
(url, title, category, text),
)
inserted += 1
conn.commit()
conn.close()
# Commit changes to volume
docs_volume.commit()
print(f"SQLite database created with {inserted} pages")
return {"pages": inserted}
# =============================================================================
# Code Index Generation Functions
# =============================================================================
# Source file extensions to index
SOURCE_EXTENSIONS = {".py", ".ts", ".js", ".tsx"}
MAX_FILE_SIZE_SQLITE = 1_000_000 # 1MB for SQLite
MAX_FILE_SIZE_EMBEDDINGS = 100_000 # 100KB for embeddings
def parse_tag(tag: str) -> tuple[str, str]:
"""Parse a git tag into component and version."""
if tag.startswith("v") and len(tag) > 1 and tag[1].isdigit():
return ("cua", tag[1:])
match = re.match(r"^(.+)-v(\d+\.\d+\.\d+.*)$", tag)
if match:
return (match.group(1), match.group(2))
raise ValueError(f"Cannot parse tag: {tag}")
def detect_language(file_path: str) -> str:
"""Detect programming language from file extension."""
ext = Path(file_path).suffix.lower()
return {".py": "python", ".ts": "typescript", ".tsx": "typescript", ".js": "javascript"}.get(
ext, "unknown"
)
def group_tags_by_component(tags: list[str]) -> dict[str, list[str]]:
"""Group git tags by their component."""
grouped: dict[str, list[str]] = {}
for tag in tags:
try:
component, _ = parse_tag(tag)
if component not in grouped:
grouped[component] = []
grouped[component].append(tag)
except ValueError:
continue
return grouped
@app.function(
image=image,
volumes={CODE_VOLUME_PATH: code_volume},
secrets=[github_secret],
timeout=3600, # 1 hour per component
cpu=2.0,
memory=8192,
)
def index_component(component: str, tags: list[str], repo_path: str) -> dict:
"""Index a single component's tags into its own SQLite and LanceDB.
Each component gets its own databases to enable parallel processing.
Args:
component: The component name (e.g., "agent", "computer")
tags: List of git tags for this component
repo_path: Path to the bare git repository
Returns:
Dict with indexing statistics
"""
import shutil
import subprocess
import tempfile
import lancedb
from lancedb.embeddings import get_registry
from lancedb.pydantic import LanceModel, Vector
print(f"[{component}] Starting indexing of {len(tags)} tags...")
DB_DIR = Path(CODE_DB_PATH)
DB_DIR.mkdir(parents=True, exist_ok=True)
# Component-specific database paths
SQLITE_PATH = DB_DIR / f"code_index_{component}.sqlite"
TMP_LANCE_DIR = Path(tempfile.mkdtemp(prefix=f"code_lancedb_{component}_"))
# Initialize SQLite for this component
if SQLITE_PATH.exists():
SQLITE_PATH.unlink()
conn = sqlite3.connect(SQLITE_PATH)
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE code_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
component TEXT NOT NULL,
version TEXT NOT NULL,
file_path TEXT NOT NULL,
content TEXT NOT NULL,
language TEXT NOT NULL,
UNIQUE(component, version, file_path)
)
""")
cursor.execute("CREATE INDEX idx_component ON code_files(component)")
cursor.execute("CREATE INDEX idx_version ON code_files(component, version)")
cursor.execute("""
CREATE VIRTUAL TABLE code_files_fts USING fts5(
content,
component UNINDEXED,
version UNINDEXED,
file_path UNINDEXED,
content='code_files',
content_rowid='id'
)
""")
cursor.execute("""
CREATE TRIGGER code_files_ai AFTER INSERT ON code_files BEGIN
INSERT INTO code_files_fts(rowid, content, component, version, file_path)
VALUES (new.id, new.content, new.component, new.version, new.file_path);
END;
""")
conn.commit()
# Initialize LanceDB
model = get_registry().get("sentence-transformers").create(name="all-MiniLM-L6-v2")
class CodeFile(LanceModel):
text: str = model.SourceField()
vector: Vector(model.ndims()) = model.VectorField()
component: str
version: str
file_path: str
language: str
lance_db = lancedb.connect(TMP_LANCE_DIR)
lance_table = lance_db.create_table("code", schema=CodeFile, mode="overwrite")
# Process tags for this component
total_files = 0
total_embedded = 0
failed_tags = []
COMMIT_BATCH_SIZE = 10
for i, tag in enumerate(tags):
print(f"[{component}] [{i + 1}/{len(tags)}] Processing {tag}")
try:
_, version = parse_tag(tag)
except ValueError as e:
print(f"[{component}] Skipping: {e}")
failed_tags.append(tag)
continue
# Get files at this tag
try:
result = subprocess.run(
["git", "ls-tree", "-r", "--name-only", tag],
cwd=repo_path,
check=True,
capture_output=True,
text=True,
)
files = [
f.strip()
for f in result.stdout.strip().split("\n")
if f.strip() and Path(f.strip()).suffix.lower() in SOURCE_EXTENSIONS
]
except subprocess.CalledProcessError:
failed_tags.append(tag)
continue
lance_batch = []
for file_path in files:
try:
result = subprocess.run(
["git", "show", f"{tag}:{file_path}"],
cwd=repo_path,
check=True,
capture_output=True,
)
content = result.stdout.decode("utf-8", errors="replace")
if "\x00" in content[:1024]:
continue # Skip binary
except (subprocess.CalledProcessError, UnicodeDecodeError):
continue
language = detect_language(file_path)
content_size = len(content)
# Add to SQLite
if content_size <= MAX_FILE_SIZE_SQLITE:
cursor.execute(
"INSERT OR REPLACE INTO code_files (component, version, file_path, content, language) VALUES (?, ?, ?, ?, ?)",
(component, version, file_path, content, language),
)
total_files += 1
# Queue for LanceDB
if content_size <= MAX_FILE_SIZE_EMBEDDINGS:
lance_batch.append(
{
"text": content,
"component": component,
"version": version,
"file_path": file_path,
"language": language,
}
)
# Batch commits
if (i + 1) % COMMIT_BATCH_SIZE == 0:
conn.commit()
print(f"[{component}] Committed batch at tag {i + 1}/{len(tags)}")
# Add to LanceDB
if lance_batch:
lance_table.add(lance_batch)
total_embedded += len(lance_batch)
# Final commit
conn.commit()
conn.close()
# Copy LanceDB to volume
lance_dest = DB_DIR / f"code_index_{component}.lancedb"
if lance_dest.exists():
shutil.rmtree(lance_dest)
shutil.copytree(TMP_LANCE_DIR, lance_dest)
shutil.rmtree(TMP_LANCE_DIR)
# Clean up LanceDB resources
del lance_table
del lance_db
print(f"[{component}] Complete: {total_files} files, {total_embedded} embedded")
return {
"component": component,
"files": total_files,
"embedded": total_embedded,
"failed_tags": len(failed_tags),
"tags_processed": len(tags),
}
@app.function(
image=image,
volumes={CODE_VOLUME_PATH: code_volume},
secrets=[github_secret],
timeout=3600, # 1 hour
cpu=1.0,
memory=4096,
)
def generate_code_index_parallel(max_concurrent: int = 4) -> dict:
"""Generate code search index with parallel component processing.
This function:
1. Clones/updates the git repository
2. Groups tags by component
3. Dispatches parallel workers to index each component
4. Each component gets its own SQLite and LanceDB
Args:
max_concurrent: Maximum number of concurrent component indexing jobs
Returns:
Aggregated statistics from all component workers
"""
import os
import subprocess
print(f"Starting parallel code indexing (max {max_concurrent} concurrent)...")
# Build authenticated URL
github_token = os.environ.get("GITHUB_TOKEN", "")
if github_token:
REPO_URL = f"https://{github_token}@github.com/trycua/cua.git"
print("Using authenticated GitHub URL")
else:
REPO_URL = "https://github.com/trycua/cua.git"
print("Warning: No GITHUB_TOKEN found")
REPO_PATH = Path(CODE_REPO_PATH)
# Clone or update repo
if REPO_PATH.exists():
print("Fetching latest tags...")
subprocess.run(["git", "fetch", "--all", "--tags"], cwd=REPO_PATH, check=True)
else:
print("Cloning repository...")
REPO_PATH.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(["git", "clone", "--bare", REPO_URL, str(REPO_PATH)], check=True)
# Get all tags
result = subprocess.run(
["git", "tag"], cwd=REPO_PATH, check=True, capture_output=True, text=True
)
all_tags = [t.strip() for t in result.stdout.strip().split("\n") if t.strip()]
print(f"Found {len(all_tags)} tags")
# Group tags by component
component_tags = group_tags_by_component(all_tags)
print(f"Components found: {list(component_tags.keys())}")
for comp, tags in component_tags.items():
print(f" {comp}: {len(tags)} tags")
# Dispatch parallel workers using Modal's map
repo_path_str = str(REPO_PATH)
args = [(comp, tags, repo_path_str) for comp, tags in component_tags.items()]
print(f"Dispatching {len(args)} parallel indexing jobs...")
# Process results with error handling for individual component failures
results = []
failed_components = []
try:
# Use return_exceptions=True to get results even when some workers fail
for i, result in enumerate(
index_component.starmap(args, order_outputs=False, return_exceptions=True)
):
comp_name = args[i][0] if i < len(args) else f"component_{i}"
if isinstance(result, Exception):
# Handle individual component failures
error_msg = str(result)
print(f"[{comp_name}] Component indexing failed: {error_msg}")
failed_components.append(
{
"component": comp_name,
"error": error_msg,
"files": 0,
"embedded": 0,
"failed_tags": len(args[i][1]) if i < len(args) else 0,
}
)
else:
results.append(result)
print(f"[{comp_name}] Component indexing succeeded")
except Exception as e:
# Handle catastrophic failures (e.g., all workers failed)
print(f"Error during parallel indexing: {e}")
# Still try to commit any partial results
pass
# Commit all changes to volume (including partial results)
code_volume.commit()
# Aggregate results from successful components
total_files = sum(r["files"] for r in results)
total_embedded = sum(r["embedded"] for r in results)
total_failed = sum(r["failed_tags"] for r in results)
# Add failed component stats
total_failed += sum(f["failed_tags"] for f in failed_components)
summary = {
"total_files": total_files,
"total_embedded": total_embedded,
"total_failed_tags": total_failed,
"components": results,
"failed_components": failed_components,
"success_count": len(results),
"failure_count": len(failed_components),
}
print("\nParallel indexing complete:")
print(f" Total files: {total_files}")
print(f" Total embedded: {total_embedded}")
print(f" Components indexed: {len(results)}")
if failed_components:
print(f" Components failed: {len(failed_components)}")
for fc in failed_components:
print(f" - {fc['component']}: {fc['error'][:100]}")
return summary
@app.function(
image=image,
volumes={CODE_VOLUME_PATH: code_volume},
timeout=1800, # 30 minutes
cpu=2.0,
memory=8192,
)
def aggregate_code_databases() -> dict:
"""Aggregate per-component databases into unified SQLite and LanceDB.
This function runs after parallel indexing to create single aggregated
databases that the MCP server can query directly, avoiding runtime
aggregation overhead.
Creates:
- code_index.sqlite: Unified SQLite with FTS5 from all components
- code_index.lancedb: Unified LanceDB with vectors from all components
Returns:
Dict with aggregation statistics
"""
import shutil
import tempfile
import lancedb
from lancedb.embeddings import get_registry
from lancedb.pydantic import LanceModel, Vector
print("Aggregating component databases...")
DB_DIR = Path(CODE_DB_PATH)
if not DB_DIR.exists():
print("No database directory found")
return {"error": "No database directory"}
# Find all component SQLite databases
component_dbs = list(DB_DIR.glob("code_index_*.sqlite"))
if not component_dbs:
print("No component databases found to aggregate")
return {"error": "No component databases found"}
print(f"Found {len(component_dbs)} component databases to aggregate")
# === Aggregate SQLite databases ===
AGGREGATED_SQLITE = DB_DIR / "code_index.sqlite"
if AGGREGATED_SQLITE.exists():
AGGREGATED_SQLITE.unlink()
print("Removed existing aggregated SQLite database")
conn = sqlite3.connect(AGGREGATED_SQLITE)
cursor = conn.cursor()
# Create main table
cursor.execute("""
CREATE TABLE code_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
component TEXT NOT NULL,
version TEXT NOT NULL,
file_path TEXT NOT NULL,
content TEXT NOT NULL,
language TEXT NOT NULL,
UNIQUE(component, version, file_path)
)
""")
cursor.execute("CREATE INDEX idx_component ON code_files(component)")
cursor.execute("CREATE INDEX idx_version ON code_files(component, version)")
# Create FTS5 virtual table
cursor.execute("""
CREATE VIRTUAL TABLE code_files_fts USING fts5(
content,
component UNINDEXED,
version UNINDEXED,
file_path UNINDEXED,
content='code_files',
content_rowid='id'
)
""")
# Create FTS triggers BEFORE inserting data
cursor.execute("""
CREATE TRIGGER code_files_ai AFTER INSERT ON code_files BEGIN
INSERT INTO code_files_fts(rowid, content, component, version, file_path)
VALUES (new.id, new.content, new.component, new.version, new.file_path);
END;
""")
conn.commit()
# Copy data from each component database
total_rows = 0
for db_path in component_dbs:
component_name = db_path.stem.replace("code_index_", "")
print(f" Aggregating {component_name}...")
# Attach component database
cursor.execute(f"ATTACH DATABASE 'file:{db_path}?mode=ro' AS comp")
# Copy data (triggers will populate FTS automatically)
cursor.execute("""
INSERT INTO code_files (component, version, file_path, content, language)
SELECT component, version, file_path, content, language FROM comp.code_files
""")
rows_copied = cursor.rowcount
total_rows += rows_copied
print(f" Copied {rows_copied} rows from {component_name}")
# Commit before detaching to release locks on the attached database
conn.commit()
cursor.execute("DETACH DATABASE comp")
conn.close()
print(f"SQLite aggregation complete: {total_rows} total rows")
# === Aggregate LanceDB databases ===
TMP_LANCE_DIR = Path(tempfile.mkdtemp(prefix="code_lancedb_agg_"))
# Initialize embedding model and schema
model = get_registry().get("sentence-transformers").create(name="all-MiniLM-L6-v2")
class CodeFile(LanceModel):
text: str = model.SourceField()
vector: Vector(model.ndims()) = model.VectorField()
component: str
version: str
file_path: str
language: str
lance_db = lancedb.connect(TMP_LANCE_DIR)
lance_table = lance_db.create_table("code", schema=CodeFile, mode="overwrite")
# Find and aggregate all component LanceDBs
component_lance_dirs = list(DB_DIR.glob("code_index_*.lancedb"))
total_vectors = 0
for lance_dir in component_lance_dirs:
component_name = lance_dir.stem.replace("code_index_", "").replace(".lancedb", "")
print(f" Aggregating vectors from {component_name}...")
try:
comp_db = lancedb.connect(lance_dir)
comp_table = comp_db.open_table("code")
# Read all data from component table (excluding vector column for re-embedding)
# Actually, we want to preserve the vectors, so read everything
data = comp_table.to_pandas()
if len(data) > 0:
# Convert to list of dicts, preserving vectors
records = data.to_dict("records")
lance_table.add(records)
total_vectors += len(records)
print(f" Added {len(records)} vectors from {component_name}")
del comp_table
del comp_db
except Exception as e:
print(f" Error aggregating {component_name}: {e}")
continue
# Close and copy to volume
del lance_table
del lance_db
AGGREGATED_LANCE = DB_DIR / "code_index.lancedb"
if AGGREGATED_LANCE.exists():
shutil.rmtree(AGGREGATED_LANCE)
shutil.copytree(TMP_LANCE_DIR, AGGREGATED_LANCE)
shutil.rmtree(TMP_LANCE_DIR)
# Commit changes to volume
code_volume.commit()
print(f"LanceDB aggregation complete: {total_vectors} total vectors")
print("Aggregation complete!")
return {
"sqlite_rows": total_rows,
"lance_vectors": total_vectors,
"components_aggregated": len(component_dbs),
}
@app.function(
image=image,
volumes={CODE_VOLUME_PATH: code_volume},
secrets=[github_secret],
timeout=3600, # 1 hour
cpu=2.0,
memory=8192,
)
async def generate_code_index():
"""Generate code search index from all git tags"""
import os
import shutil
import subprocess
import tempfile
import lancedb
from lancedb.embeddings import get_registry
from lancedb.pydantic import LanceModel, Vector
print("Generating code search index...")
# Build authenticated URL using GitHub token
github_token = os.environ.get("GITHUB_TOKEN", "")
if github_token:
REPO_URL = f"https://{github_token}@github.com/trycua/cua.git"
print("Using authenticated GitHub URL")
else:
REPO_URL = "https://github.com/trycua/cua.git"
print("Warning: No GITHUB_TOKEN found, using unauthenticated URL")
REPO_PATH = Path(CODE_REPO_PATH)
DB_DIR = Path(CODE_DB_PATH)
SQLITE_PATH = DB_DIR / "code_index.sqlite"
DB_DIR.mkdir(parents=True, exist_ok=True)
# Clone or update repo (bare clone for efficiency)
if REPO_PATH.exists():
print("Fetching latest tags...")
subprocess.run(["git", "fetch", "--all", "--tags"], cwd=REPO_PATH, check=True)
else:
print("Cloning repository...")
REPO_PATH.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(["git", "clone", "--bare", REPO_URL, str(REPO_PATH)], check=True)
# Get all tags
result = subprocess.run(
["git", "tag"], cwd=REPO_PATH, check=True, capture_output=True, text=True
)
all_tags = [t.strip() for t in result.stdout.strip().split("\n") if t.strip()]
print(f"Found {len(all_tags)} tags")
# Initialize SQLite
if SQLITE_PATH.exists():
SQLITE_PATH.unlink()
conn = sqlite3.connect(SQLITE_PATH)
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE code_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
component TEXT NOT NULL,
version TEXT NOT NULL,
file_path TEXT NOT NULL,
content TEXT NOT NULL,
language TEXT NOT NULL,
UNIQUE(component, version, file_path)
)
""")
cursor.execute("CREATE INDEX idx_component ON code_files(component)")
cursor.execute("CREATE INDEX idx_version ON code_files(component, version)")
cursor.execute("""
CREATE VIRTUAL TABLE code_files_fts USING fts5(
content,
component UNINDEXED,
version UNINDEXED,
file_path UNINDEXED,
content='code_files',
content_rowid='id'
)
""")
# FTS triggers
cursor.execute("""
CREATE TRIGGER code_files_ai AFTER INSERT ON code_files BEGIN
INSERT INTO code_files_fts(rowid, content, component, version, file_path)
VALUES (new.id, new.content, new.component, new.version, new.file_path);
END;
""")
conn.commit()
# Initialize LanceDB in temp directory
TMP_LANCE_DIR = Path(tempfile.mkdtemp(prefix="code_lancedb_"))
model = get_registry().get("sentence-transformers").create(name="all-MiniLM-L6-v2")
class CodeFile(LanceModel):
text: str = model.SourceField()
vector: Vector(model.ndims()) = model.VectorField()
component: str
version: str
file_path: str
language: str
lance_db = lancedb.connect(TMP_LANCE_DIR)
lance_table = lance_db.create_table("code", schema=CodeFile, mode="overwrite")
# Process each tag
total_files = 0
total_embedded = 0
failed_tags = []
COMMIT_BATCH_SIZE = 10 # Commit every 10 tags for better performance
for i, tag in enumerate(all_tags):
print(f"[{i + 1}/{len(all_tags)}] Processing {tag}")
try:
component, version = parse_tag(tag)
except ValueError as e:
print(f" Skipping: {e}")
failed_tags.append(tag)
continue
# Get files at this tag
try:
result = subprocess.run(
["git", "ls-tree", "-r", "--name-only", tag],
cwd=REPO_PATH,
check=True,
capture_output=True,
text=True,
)
files = [
f.strip()
for f in result.stdout.strip().split("\n")
if f.strip() and Path(f.strip()).suffix.lower() in SOURCE_EXTENSIONS
]
except subprocess.CalledProcessError:
failed_tags.append(tag)
continue
lance_batch = []
for file_path in files:
try:
result = subprocess.run(
["git", "show", f"{tag}:{file_path}"],
cwd=REPO_PATH,
check=True,
capture_output=True,
)
content = result.stdout.decode("utf-8", errors="replace")
if "\x00" in content[:1024]:
continue # Skip binary
except (subprocess.CalledProcessError, UnicodeDecodeError):
continue
language = detect_language(file_path)
content_size = len(content)
# Add to SQLite
if content_size <= MAX_FILE_SIZE_SQLITE:
cursor.execute(
"INSERT OR REPLACE INTO code_files (component, version, file_path, content, language) VALUES (?, ?, ?, ?, ?)",
(component, version, file_path, content, language),
)
total_files += 1
# Queue for LanceDB
if content_size <= MAX_FILE_SIZE_EMBEDDINGS:
lance_batch.append(
{
"text": content,
"component": component,
"version": version,
"file_path": file_path,
"language": language,
}
)
# Batch commits: commit every COMMIT_BATCH_SIZE tags
if (i + 1) % COMMIT_BATCH_SIZE == 0:
conn.commit()
print(f" Committed batch at tag {i + 1}/{len(all_tags)}")
# Add to LanceDB
if lance_batch:
lance_table.add(lance_batch)
total_embedded += len(lance_batch)
# Final commit for any remaining tags
conn.commit()
conn.close()
# Copy LanceDB to volume
try:
lance_dest = DB_DIR / "code_index.lancedb"
if lance_dest.exists():
shutil.rmtree(lance_dest)
shutil.copytree(TMP_LANCE_DIR, lance_dest)
shutil.rmtree(TMP_LANCE_DIR)
finally:
# Ensure LanceDB resources are released even if an exception occurs
del lance_table
del lance_db
code_volume.commit()
print(f"Code index complete: {total_files} files in SQLite, {total_embedded} embedded")
return {"files": total_files, "embedded": total_embedded, "failed_tags": len(failed_tags)}
@app.function(
image=image,
volumes={CODE_VOLUME_PATH: code_volume},
secrets=[github_secret],
schedule=modal.Cron("0 5 * * *"), # Daily at 5 AM UTC (before docs crawl)
timeout=7200, # 2 hours (includes aggregation time)
)
async def scheduled_code_index():
"""Scheduled daily code index generation (uses parallel processing)"""
import modal.exception
print("Running scheduled code indexing (parallel)...")
try:
result = await generate_code_index_parallel.remote.aio()
print(f"Code indexing complete: {result}")
# Log summary of any failed components
if result.get("failed_components"):
print(
f"Warning: {len(result['failed_components'])} component(s) failed during indexing"
)
for fc in result["failed_components"]:
print(f" - {fc['component']}: {fc['error'][:200]}")
# Aggregate component databases into unified DBs for the MCP server
print("Aggregating component databases...")
agg_result = aggregate_code_databases.remote()
print(f"Aggregation complete: {agg_result}")
result["aggregation"] = agg_result
# Sync code databases to S3
print("Syncing code databases to S3...")
sync_result = sync_to_s3.remote()
print(f"S3 sync result: {sync_result}")
result["s3_sync"] = sync_result
return result
except modal.exception.FunctionTimeoutError as e:
print(f"Code indexing timed out: {e}")
# Return a partial result indicating the timeout
return {
"total_files": 0,
"total_embedded": 0,
"total_failed_tags": 0,
"components": [],
"failed_components": [],
"error": f"Function timed out: {str(e)}",
"success_count": 0,
"failure_count": 0,
}
except Exception as e:
print(f"Code indexing failed with error: {e}")
# Return error information instead of crashing
return {
"total_files": 0,
"total_embedded": 0,
"total_failed_tags": 0,
"components": [],
"failed_components": [],
"error": str(e),
"success_count": 0,
"failure_count": 0,
}
# =============================================================================
# MCP Server
# =============================================================================
@app.function(
image=image,
volumes={VOLUME_PATH: docs_volume, CODE_VOLUME_PATH: code_volume},
cpu=1.0,
memory=2048,
keep_warm=1, # Keep one container warm to avoid cold start latency
)
@modal.concurrent(max_inputs=10)
@modal.asgi_app(custom_domains=["docs-mcp.cua.ai"])
def web():
"""ASGI web endpoint for the MCP server"""
import lancedb
from fastmcp import FastMCP
from lancedb.embeddings import get_registry
from starlette.middleware.cors import CORSMiddleware
# Initialize the MCP server
mcp = FastMCP(
name="CUA Docs & Code",
instructions="""CUA Documentation and Code Server - provides direct read-only query access to Computer Use Agent (CUA) documentation and versioned source code.
=== AVAILABLE TOOLS ===
Documentation:
- query_docs_db: Execute SQL queries against the documentation SQLite database
- query_docs_vectors: Execute vector similarity searches against the documentation LanceDB
Code:
- query_code_db: Execute SQL queries against the code search SQLite database
- query_code_vectors: Execute vector similarity searches against the code LanceDB
All tools are READ-ONLY. Only SELECT queries are allowed for SQL databases.
=== DOCUMENTATION DATABASE ===
The documentation database contains crawled pages from cua.ai/docs covering:
- CUA SDK: Python library for building computer-use agents
- CUA Bench: Benchmarking framework for evaluating computer-use agents
- Agent Loop: Core execution loop for autonomous agent operation
- Sandboxes: Docker and cloud VM environments for safe agent execution
- Computer interfaces: Screen, mouse, keyboard, and bash interaction APIs
=== CODE DATABASE ===
The code database contains versioned source code indexed across all git tags.
Components include: agent, computer, mcp-server, som, etc.
=== WORKFLOW EXAMPLES ===
1. Find documentation about a topic:
- Use query_docs_vectors with a natural language query for semantic search
- Use query_docs_db with FTS5 MATCH for keyword search
2. Explore code across versions:
- List components: SELECT component, COUNT(DISTINCT version) FROM code_files GROUP BY component
- Search code: Use query_code_db with FTS5 on code_files_fts
- Get file content: SELECT content FROM code_files WHERE component='agent' AND version='0.7.3' AND file_path='...'
3. Semantic code search:
- Use query_code_vectors with natural language queries like "screenshot capture implementation"
IMPORTANT: Always cite sources - URLs for docs, component@version:path for code.""",
)
# Initialize embedding model - load eagerly to avoid cold start on first search
print("Initializing embedding model...")
model = get_registry().get("sentence-transformers").create(name="all-MiniLM-L6-v2")
# Eagerly initialize database connections at startup to reduce first-request latency
print("Initializing database connections...")
# Docs LanceDB
_docs_lance_db = None
_docs_lance_table = None
db_path = Path(DB_PATH)
if db_path.exists():
try:
_docs_lance_db = lancedb.connect(db_path)
_docs_lance_table = _docs_lance_db.open_table("docs")
print(f" Docs LanceDB loaded from {db_path}")
except Exception as e:
print(f" Warning: Could not load docs LanceDB: {e}")
# Docs SQLite
_docs_sqlite_conn = None
sqlite_path = Path(DB_PATH) / "docs.sqlite"
if sqlite_path.exists():
try:
_docs_sqlite_conn = sqlite3.connect(f"file:{sqlite_path}?mode=ro", uri=True)
_docs_sqlite_conn.row_factory = sqlite3.Row
print(f" Docs SQLite loaded from {sqlite_path}")
except Exception as e:
print(f" Warning: Could not load docs SQLite: {e}")
# Code LanceDB
_code_lance_db = None
_code_lance_table = None
code_lance_path = Path(CODE_DB_PATH) / "code_index.lancedb"
if code_lance_path.exists():
try:
_code_lance_db = lancedb.connect(code_lance_path)
_code_lance_table = _code_lance_db.open_table("code")
print(f" Code LanceDB loaded from {code_lance_path}")
except Exception as e:
print(f" Warning: Could not load code LanceDB: {e}")
# Code SQLite
_code_sqlite_conn = None
code_sqlite_path = Path(CODE_DB_PATH) / "code_index.sqlite"
if code_sqlite_path.exists():
try:
_code_sqlite_conn = sqlite3.connect(f"file:{code_sqlite_path}?mode=ro", uri=True)
_code_sqlite_conn.row_factory = sqlite3.Row
print(f" Code SQLite loaded from {code_sqlite_path}")
except Exception as e:
print(f" Warning: Could not load code SQLite: {e}")
print("Database initialization complete.")
def get_lance_table():
"""Get LanceDB connection for docs (eagerly loaded)"""
if _docs_lance_table is None:
raise RuntimeError("Database not found. Run crawl and generation functions first.")
return _docs_lance_table
def get_sqlite_conn():
"""Get read-only SQLite connection for docs (eagerly loaded)"""
if _docs_sqlite_conn is None:
raise RuntimeError("SQLite database not found.")
return _docs_sqlite_conn
def get_code_lance_table():
"""Get LanceDB connection for the aggregated code database (eagerly loaded)."""
if _code_lance_table is None:
raise RuntimeError(
"Code LanceDB not found. Run generate_code_index_parallel and aggregate_code_databases first."
)
return _code_lance_table
def get_code_sqlite_conn():
"""Get read-only SQLite connection for the aggregated code database (eagerly loaded)."""
if _code_sqlite_conn is None:
raise RuntimeError(
"Code SQLite database not found. Run generate_code_index_parallel and aggregate_code_databases first."
)
return _code_sqlite_conn
# =================== DOCUMENTATION QUERY TOOLS (READ-ONLY) ===================
@mcp.tool()
def query_docs_db(sql: str) -> list[dict]:
"""
Execute a SQL query against the documentation database.
The database is READ-ONLY.
Database Schema:
Table: pages
- id INTEGER PRIMARY KEY AUTOINCREMENT
- url TEXT NOT NULL UNIQUE -- Full URL of the documentation page
- title TEXT NOT NULL -- Page title
- category TEXT NOT NULL -- Category (e.g., 'cua', 'cuabench', 'llms.txt')
- content TEXT NOT NULL -- Plain text content (markdown stripped)
Virtual Table: pages_fts (FTS5 full-text search)
- content TEXT -- Full-text indexed content
- url TEXT UNINDEXED
- title TEXT UNINDEXED
- category TEXT UNINDEXED
Example queries:
1. List all pages: SELECT url, title, category FROM pages ORDER BY category, title
2. Full-text search with snippets:
SELECT p.url, p.title, snippet(pages_fts, 0, '>>>', '<<<', '...', 64) as snippet
FROM pages_fts JOIN pages p ON pages_fts.rowid = p.id
WHERE pages_fts MATCH 'agent loop' ORDER BY rank LIMIT 10
3. Get page content: SELECT url, title, content FROM pages WHERE url LIKE '%quickstart%'
Args:
sql: SQL query to execute
Returns:
List of dictionaries, one per row, with column names as keys
"""
conn = get_sqlite_conn()
cursor = conn.cursor()
cursor.execute(sql)
return [dict(row) for row in cursor.fetchall()]
@mcp.tool()
def query_docs_vectors(
query: str,
limit: int = 10,
where: Optional[str] = None,
select: Optional[list[str]] = None,
) -> list[dict]:
"""
Execute a vector similarity search against the documentation LanceDB (read-only).
Schema:
- text TEXT -- The document chunk text
- vector VECTOR -- Embedding vector (all-MiniLM-L6-v2, 384 dimensions)
- url TEXT -- Source URL
- title TEXT -- Document title
- category TEXT -- Category (e.g., 'cua', 'cuabench')
- chunk_index INT -- Index of chunk within document
Args:
query: Natural language query to embed and search for
limit: Maximum number of results (default: 10, max: 100)
where: Optional SQL-like filter (e.g., "category = 'cua'")
select: Optional list of columns to return (default: all except vector)
Returns:
List of matching documents with similarity scores (_distance field)
"""
limit = min(max(1, limit), 100)
table = get_lance_table()
search = table.search(query).limit(limit)
if where:
search = search.where(where)
if select:
search = search.select(select)
results = search.to_list()
formatted = []
for r in results:
result = {}
for key, value in r.items():
if key == "vector":
continue
result[key] = value
formatted.append(result)
return formatted
# =================== CODE QUERY TOOLS (READ-ONLY) ===================
@mcp.tool()
def query_code_db(sql: str) -> list[dict]:
"""
Execute a SQL query against the code search database.
The database is READ-ONLY.
Database Schema:
Table: code_files
- id INTEGER PRIMARY KEY AUTOINCREMENT
- component TEXT NOT NULL -- Component name (e.g., "agent", "computer")
- version TEXT NOT NULL -- Version string (e.g., "0.7.3")
- file_path TEXT NOT NULL -- Path to file
- content TEXT NOT NULL -- Full source code content
- language TEXT NOT NULL -- Programming language
- UNIQUE(component, version, file_path)
Virtual Table: code_files_fts (FTS5 full-text search)
- content TEXT -- Full-text indexed content
- component TEXT UNINDEXED
- version TEXT UNINDEXED
- file_path TEXT UNINDEXED
Example queries:
1. List components: SELECT component, COUNT(DISTINCT version) as version_count
FROM code_files GROUP BY component ORDER BY component
2. List versions: SELECT DISTINCT version FROM code_files
WHERE component = 'agent' ORDER BY version DESC
3. Full-text search:
SELECT f.component, f.version, f.file_path,
snippet(code_files_fts, 0, '>>>', '<<<', '...', 64) as snippet
FROM code_files_fts JOIN code_files f ON code_files_fts.rowid = f.id
WHERE code_files_fts MATCH 'ComputerAgent' ORDER BY rank LIMIT 10
4. Get file content: SELECT content, language FROM code_files
WHERE component = 'agent' AND version = '0.7.3' AND file_path = 'agent/core.py'
Args:
sql: SQL query to execute
Returns:
List of dictionaries, one per row, with column names as keys
"""
conn = get_code_sqlite_conn()
cursor = conn.cursor()
cursor.execute(sql)
return [dict(row) for row in cursor.fetchall()]
@mcp.tool()
def query_code_vectors(
query: str,
limit: int = 10,
where: Optional[str] = None,
select: Optional[list[str]] = None,
component: Optional[str] = None,
) -> list[dict]:
"""
Execute a vector similarity search against the code LanceDB (read-only).
Schema:
- text TEXT -- The source code content
- vector VECTOR -- Embedding vector (all-MiniLM-L6-v2, 384 dimensions)
- component TEXT -- Component name (e.g., "agent", "computer")
- version TEXT -- Version string (e.g., "0.7.3")
- file_path TEXT -- Path to file within the component
- language TEXT -- Programming language
Args:
query: Natural language query to embed and search for
limit: Maximum number of results (default: 10, max: 100)
where: Optional SQL-like filter (e.g., "version = '0.7.3'")
select: Optional list of columns to return (default: all except vector)
component: Optional component to filter by (if not specified, searches all)
Returns:
List of matching code files with similarity scores (_distance field)
"""
limit = min(max(1, limit), 100)
table = get_code_lance_table()
search = table.search(query).limit(limit)
# Build where clause, adding component filter if specified
where_clauses = []
if component:
where_clauses.append(f"component = '{component}'")
if where:
where_clauses.append(where)
if where_clauses:
search = search.where(" AND ".join(where_clauses))
if select:
search = search.select(select)
results = search.to_list()
formatted = []
for r in results:
result = {}
for key, value in r.items():
if key == "vector":
continue
result[key] = value
formatted.append(result)
return formatted
# Create SSE app directly - endpoints at /sse (GET) and /messages (POST)
from starlette.middleware import Middleware
mcp_app = mcp.http_app(
transport="sse",
middleware=[
Middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
],
)
return mcp_app
# =============================================================================
# Local testing functions
# =============================================================================
@app.local_entrypoint()
def main(
skip_docs: bool = False,
skip_code: bool = False,
parallel: bool = True,
code_only: bool = False,
):
"""Run initial crawl and database generation
Args:
skip_docs: Skip documentation crawl and indexing
skip_code: Skip code indexing
parallel: Use parallel code indexing (default: True)
code_only: Only run code indexing (shortcut for --skip-docs)
"""
if code_only:
skip_docs = True
if not skip_docs:
print("Running initial crawl...")
summary = crawl_docs.remote()
print(f"Crawl summary: {summary}")
print("Generating vector database...")
vector_result = generate_vector_db.remote()
print(f"Vector DB: {vector_result}")
print("Generating SQLite database...")
sqlite_result = generate_sqlite_db.remote()
print(f"SQLite DB: {sqlite_result}")
if not skip_code:
if parallel:
print("Generating code index (parallel)...")
code_result = generate_code_index_parallel.remote()
else:
print("Generating code index (sequential)...")
code_result = generate_code_index.remote()
print(f"Code index: {code_result}")
# Aggregate component databases for the MCP server
print("Aggregating code databases...")
agg_result = aggregate_code_databases.remote()
print(f"Aggregation: {agg_result}")
print("Done! Deploy with: modal deploy docs/scripts/modal_app.py")