docgraphical 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
docgraph/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ # DocGraphical Package Entry
2
+ from .parser import parse_headings, extract_toc, extract_section, search_doc
3
+
4
+ search_file = search_doc
5
+
6
+ __version__ = "1.0.0"
7
+ __all__ = [
8
+ "parse_headings",
9
+ "extract_toc",
10
+ "extract_section",
11
+ "search_doc",
12
+ "search_file",
13
+ ]
docgraph/cli.py ADDED
@@ -0,0 +1,61 @@
1
+ """DocGraph Command Line Interface (CLI)."""
2
+
3
+ import argparse
4
+ import sys
5
+ from docgraph import __version__
6
+ from docgraph.parser import extract_toc, extract_section, search_doc
7
+
8
+
9
+ def main():
10
+ parser = argparse.ArgumentParser(
11
+ prog="docgraph",
12
+ description="DocGraph: Precision Markdown AST, TOC & Section Slicer for AI Agents & Developers."
13
+ )
14
+ parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {__version__}")
15
+
16
+ subparsers = parser.add_subparsers(dest="command", help="Available subcommands")
17
+
18
+ # Command: toc
19
+ toc_parser = subparsers.add_parser("toc", help="Extract Table of Contents (TOC) with line numbers")
20
+ toc_parser.add_argument("file", help="Path to the Markdown file")
21
+ toc_parser.add_argument("--json", action="store_true", help="Output TOC as JSON format")
22
+ toc_parser.add_argument("--md", action="store_true", help="Output TOC as Markdown list")
23
+
24
+ # Command: section
25
+ sec_parser = subparsers.add_parser("section", help="Extract a specific section by heading")
26
+ sec_parser.add_argument("file", help="Path to the Markdown file")
27
+ sec_parser.add_argument("heading", help="Target heading title (e.g. 'Installation' or '## API')")
28
+ sec_parser.add_argument("--no-sub", action="store_true", help="Exclude subsections under this heading")
29
+
30
+ # Command: search
31
+ search_parser = subparsers.add_parser("search", help="Search keywords across Markdown file or folder")
32
+ search_parser.add_argument("path", help="Path to Markdown file or root directory")
33
+ search_parser.add_argument("query", help="Search keyword or term")
34
+ search_parser.add_argument("--limit", type=int, default=30, help="Maximum number of search results")
35
+
36
+ # Command: mcp
37
+ subparsers.add_parser("mcp", help="Start DocGraph Model Context Protocol (MCP) server")
38
+
39
+ args = parser.parse_args()
40
+
41
+ if not args.command:
42
+ parser.print_help()
43
+ sys.exit(0)
44
+
45
+ if args.command == "toc":
46
+ fmt = "json" if args.json else ("markdown" if args.md else "text")
47
+ print(extract_toc(args.file, format_type=fmt))
48
+
49
+ elif args.command == "section":
50
+ print(extract_section(args.file, args.heading, include_subsections=not args.no_sub))
51
+
52
+ elif args.command == "search":
53
+ print(search_doc(args.path, args.query, max_results=args.limit))
54
+
55
+ elif args.command == "mcp":
56
+ from docgraph.mcp_server import run_mcp
57
+ run_mcp()
58
+
59
+
60
+ if __name__ == "__main__":
61
+ main()
docgraph/config.py ADDED
@@ -0,0 +1,66 @@
1
+ import os
2
+ import sys
3
+ import json
4
+ from typing import Dict, List, Optional
5
+ from .constants import CONFIG_FILE
6
+
7
+
8
+ def get_default_repo_root() -> str:
9
+ """Return default repository root."""
10
+ return os.getcwd()
11
+
12
+
13
+ def get_docgraph_dir(repo_path: Optional[str] = None) -> str:
14
+ """Return .docgraph directory path."""
15
+ base = repo_path or get_default_repo_root()
16
+ return os.path.join(base, ".docgraph")
17
+
18
+
19
+ def get_default_db_path(repo_path: Optional[str] = None) -> str:
20
+ """Return default SQLite database path."""
21
+ return os.path.join(get_docgraph_dir(repo_path), "docgraph.db")
22
+
23
+
24
+ def load_config() -> Dict[str, List[str]]:
25
+ """Load configuration from user home directory."""
26
+ if os.path.exists(CONFIG_FILE):
27
+ try:
28
+ with open(CONFIG_FILE, "r", encoding="utf-8") as f:
29
+ data = json.load(f)
30
+ roots = data.get("custom_roots") or []
31
+ excluded = data.get("excluded_paths") or []
32
+ return {"custom_roots": roots, "excluded_paths": excluded}
33
+ except Exception:
34
+ pass
35
+ return {"custom_roots": [], "excluded_paths": []}
36
+
37
+
38
+ def save_config(cfg: Dict[str, List[str]]) -> None:
39
+ """Save configuration to user home directory."""
40
+ try:
41
+ with open(CONFIG_FILE, "w", encoding="utf-8") as f:
42
+ json.dump(cfg, f, indent=2, ensure_ascii=False)
43
+ except Exception as e:
44
+ print(f"Failed to save docgraph config: {e}", file=sys.stderr)
45
+
46
+
47
+ def get_search_roots(extra_paths: Optional[List[str]] = None) -> List[str]:
48
+ """Resolve and deduplicate all documentation search roots."""
49
+ roots: List[str] = []
50
+ if extra_paths:
51
+ for p in extra_paths:
52
+ abs_p = os.path.abspath(p)
53
+ if abs_p not in roots and os.path.exists(abs_p):
54
+ roots.append(abs_p)
55
+
56
+ cfg = load_config()
57
+ for p in cfg.get("custom_roots", []):
58
+ abs_p = os.path.abspath(p)
59
+ if abs_p not in roots and os.path.exists(abs_p):
60
+ roots.append(abs_p)
61
+
62
+ # If empty, default to current working directory
63
+ if not roots:
64
+ roots.append(os.path.abspath(os.getcwd()))
65
+
66
+ return roots
docgraph/constants.py ADDED
@@ -0,0 +1,13 @@
1
+ """Constants and default configuration for DocGraph."""
2
+ import os
3
+
4
+ IGNORE_DIRS = {
5
+ ".git", ".codegraph", "node_modules", "dist", "build", ".venv", "venv", "env",
6
+ "__pycache__", ".pytest_cache", ".mypy_cache", ".idea", ".vscode", "target", "vendor"
7
+ }
8
+
9
+ DOC_EXTS = (".md", ".markdown", ".mdown", ".txt")
10
+
11
+ CONFIG_FILE = os.path.expanduser("~/.docgraph_config.json")
12
+ DEFAULT_PORT = 5002
13
+ DEFAULT_HOST = "127.0.0.1"
docgraph/db.py ADDED
@@ -0,0 +1,300 @@
1
+ """DocGraph SQLite Indexing & Graph Storage Engine.
2
+ Stores Markdown files, heading AST nodes, cross-document links, and FTS fulltext search
3
+ inside <project_root>/.docgraph/docgraph.db.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import math
10
+ import os
11
+ import re
12
+ import sqlite3
13
+ from typing import Any, Dict, List, Optional, Tuple
14
+ from .constants import IGNORE_DIRS, DOC_EXTS
15
+
16
+ SCHEMA_SQL = """
17
+ CREATE TABLE IF NOT EXISTS files (
18
+ path TEXT PRIMARY KEY,
19
+ content_hash TEXT,
20
+ size INTEGER,
21
+ modified_at REAL,
22
+ node_count INTEGER
23
+ );
24
+
25
+ CREATE TABLE IF NOT EXISTS nodes (
26
+ id TEXT PRIMARY KEY,
27
+ file_path TEXT,
28
+ kind TEXT, -- 'file', 'heading_1', 'heading_2', 'heading_3', 'code_block'
29
+ name TEXT,
30
+ level INTEGER,
31
+ start_line INTEGER,
32
+ end_line INTEGER,
33
+ token_estimate INTEGER,
34
+ content TEXT,
35
+ FOREIGN KEY(file_path) REFERENCES files(path) ON DELETE CASCADE
36
+ );
37
+
38
+ CREATE TABLE IF NOT EXISTS edges (
39
+ id TEXT PRIMARY KEY,
40
+ source TEXT,
41
+ target TEXT,
42
+ kind TEXT, -- 'contains', 'parent_child', 'doc_link'
43
+ line INTEGER,
44
+ FOREIGN KEY(source) REFERENCES nodes(id) ON DELETE CASCADE
45
+ );
46
+
47
+ CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(
48
+ id UNINDEXED,
49
+ name,
50
+ content,
51
+ tokenize = 'porter unicode61'
52
+ );
53
+ """
54
+
55
+ def get_db_path(repo_path: str) -> str:
56
+ dot_dir = os.path.join(os.path.abspath(repo_path), ".docgraph")
57
+ os.makedirs(dot_dir, exist_ok=True)
58
+ return os.path.join(dot_dir, "docgraph.db")
59
+
60
+ def init_db(db_path: str) -> sqlite3.Connection:
61
+ conn = sqlite3.connect(db_path)
62
+ conn.execute("PRAGMA journal_mode=WAL;")
63
+ conn.execute("PRAGMA foreign_keys=ON;")
64
+ conn.executescript(SCHEMA_SQL)
65
+ conn.commit()
66
+ return conn
67
+
68
+ def compute_hash(content: str) -> str:
69
+ return hashlib.sha256(content.encode("utf-8", errors="replace")).hexdigest()
70
+
71
+ def index_repository(repo_path: str) -> Tuple[int, int, int]:
72
+ repo_path = os.path.abspath(repo_path)
73
+ db_path = get_db_path(repo_path)
74
+ conn = init_db(db_path)
75
+ cur = conn.cursor()
76
+
77
+ md_files: List[str] = []
78
+ for root, dirs, files in os.walk(repo_path):
79
+ dirs[:] = [d for d in dirs if d not in IGNORE_DIRS and not d.startswith(".")]
80
+ for f in files:
81
+ if f.lower().endswith(DOC_EXTS):
82
+ md_files.append(os.path.join(root, f))
83
+
84
+ cur.execute("DELETE FROM edges;")
85
+ cur.execute("DELETE FROM nodes;")
86
+ cur.execute("DELETE FROM files;")
87
+ try:
88
+ cur.execute("DELETE FROM nodes_fts;")
89
+ except Exception:
90
+ pass
91
+
92
+ total_nodes = 0
93
+ total_edges = 0
94
+ pending_edges: List[Tuple[str, str, str, str, int]] = []
95
+
96
+ all_rel_files = set(os.path.relpath(f, repo_path).replace("\\", "/") for f in md_files)
97
+ all_rel_lower = {f.lower(): f for f in all_rel_files}
98
+
99
+ # Count unique basenames for cross-doc mention resolution
100
+ basename_counts: Dict[str, int] = {}
101
+ for f in all_rel_files:
102
+ b = os.path.basename(f)
103
+ basename_counts[b] = basename_counts.get(b, 0) + 1
104
+ unique_basenames = {b: f for b, f in [(os.path.basename(f), f) for f in all_rel_files] if basename_counts[b] == 1}
105
+
106
+ link_pattern = re.compile(r"\[([^\]]+)\]\(([^)#\s]+)(?:#[^)]*)?\)")
107
+
108
+ for file_path in md_files:
109
+ rel_path = os.path.relpath(file_path, repo_path).replace("\\", "/")
110
+ try:
111
+ stat = os.stat(file_path)
112
+ with open(file_path, "r", encoding="utf-8", errors="replace") as f:
113
+ content = f.read()
114
+ lines = content.splitlines(keepends=True)
115
+ except Exception:
116
+ continue
117
+
118
+ c_hash = compute_hash(content)
119
+ cur.execute(
120
+ "INSERT INTO files VALUES (?, ?, ?, ?, ?)",
121
+ (rel_path, c_hash, stat.st_size, stat.st_mtime, 0)
122
+ )
123
+
124
+ file_node_id = f"file::{rel_path}"
125
+ file_tokens = math.ceil(len(content) / 3.8)
126
+
127
+ cur.execute(
128
+ "INSERT INTO nodes VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
129
+ (file_node_id, rel_path, "file", os.path.basename(rel_path), 0, 1, len(lines), file_tokens, content)
130
+ )
131
+ total_nodes += 1
132
+
133
+ heading_stack: List[Tuple[int, str]] = [(0, file_node_id)]
134
+ headings_in_file = 0
135
+ in_code_block = False
136
+
137
+ for idx, line in enumerate(lines, 1):
138
+ stripped = line.strip()
139
+ if stripped.startswith("```") or stripped.startswith("~~~"):
140
+ in_code_block = not in_code_block
141
+ continue
142
+
143
+ if in_code_block:
144
+ continue
145
+
146
+ m = re.match(r"^(#{1,6})\s+(.+)$", stripped)
147
+ if m:
148
+ level = len(m.group(1))
149
+ title = m.group(2).strip()
150
+ title = re.sub(r"\s+#+$", "", title)
151
+ node_id = f"heading::{rel_path}::L{idx}::{title[:30]}"
152
+ headings_in_file += 1
153
+
154
+ # Extract section preview snippet
155
+ sec_lines = [line]
156
+ for nxt in range(idx, min(len(lines), idx + 20)):
157
+ sec_lines.append(lines[nxt])
158
+ sec_content = "".join(sec_lines)
159
+
160
+ cur.execute(
161
+ "INSERT INTO nodes VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
162
+ (node_id, rel_path, f"heading_{level}", title, level, idx, idx, math.ceil(len(sec_content)/3.8), sec_content)
163
+ )
164
+ total_nodes += 1
165
+
166
+ while heading_stack and heading_stack[-1][0] >= level:
167
+ heading_stack.pop()
168
+
169
+ parent_id = heading_stack[-1][1] if heading_stack else file_node_id
170
+ edge_id = f"edge::{parent_id}->{node_id}"
171
+ pending_edges.append((edge_id, parent_id, node_id, "parent_child", idx))
172
+ total_edges += 1
173
+
174
+ heading_stack.append((level, node_id))
175
+
176
+ cur.execute("UPDATE files SET node_count = ? WHERE path = ?", (headings_in_file + 1, rel_path))
177
+
178
+ # 1. Comprehensive cross-doc Markdown links resolution
179
+ current_dir = os.path.dirname(rel_path)
180
+ linked_targets = set()
181
+
182
+ for lm in link_pattern.finditer(content):
183
+ raw_target = lm.group(2).strip().replace("\\", "/")
184
+ if re.match(r"^(?:https?|mailto|ftp):", raw_target):
185
+ continue
186
+
187
+ candidates = [
188
+ os.path.normpath(os.path.join(current_dir, raw_target)).replace("\\", "/"),
189
+ os.path.normpath(os.path.join(current_dir, raw_target + ".md")).replace("\\", "/"),
190
+ os.path.normpath(os.path.join(current_dir, raw_target, "README.md")).replace("\\", "/"),
191
+ os.path.normpath(raw_target).replace("\\", "/"),
192
+ os.path.normpath(raw_target + ".md").replace("\\", "/")
193
+ ]
194
+
195
+ target_match = None
196
+ for cand in candidates:
197
+ cand_l = cand.lower()
198
+ if cand_l in all_rel_lower and all_rel_lower[cand_l] != rel_path:
199
+ target_match = all_rel_lower[cand_l]
200
+ break
201
+
202
+ if target_match and target_match not in linked_targets:
203
+ linked_targets.add(target_match)
204
+ link_edge_id = f"link::{file_node_id}->file::{target_match}"
205
+ pending_edges.append((link_edge_id, file_node_id, f"file::{target_match}", "doc_link", 0))
206
+ total_edges += 1
207
+
208
+ # 2. Textual path and unique doc mentions (forming rich knowledge topology)
209
+ for other_f in all_rel_files:
210
+ if other_f != rel_path and other_f not in linked_targets and other_f in content:
211
+ linked_targets.add(other_f)
212
+ link_edge_id = f"path::{file_node_id}->file::{other_f}"
213
+ pending_edges.append((link_edge_id, file_node_id, f"file::{other_f}", "doc_link", 0))
214
+ total_edges += 1
215
+
216
+ for b, target_f in unique_basenames.items():
217
+ if target_f != rel_path and target_f not in linked_targets and len(b) > 6:
218
+ if b not in ["README.md", "requirements.txt", "SKILL.md"] and b in content:
219
+ linked_targets.add(target_f)
220
+ link_edge_id = f"mention::{file_node_id}->file::{target_f}"
221
+ pending_edges.append((link_edge_id, file_node_id, f"file::{target_f}", "doc_link", 0))
222
+ total_edges += 1
223
+
224
+ for e in pending_edges:
225
+ cur.execute("INSERT OR IGNORE INTO edges VALUES (?, ?, ?, ?, ?)", e)
226
+
227
+ try:
228
+ cur.execute("INSERT INTO nodes_fts(id, name, content) SELECT id, name, content FROM nodes;")
229
+ except Exception:
230
+ pass
231
+
232
+ conn.commit()
233
+ conn.close()
234
+ return len(md_files), total_nodes, total_edges
235
+
236
+ def fetch_graph_data(repo_path: str) -> Dict[str, Any]:
237
+ repo_path = os.path.abspath(repo_path)
238
+ db_path = get_db_path(repo_path)
239
+ if not os.path.exists(db_path):
240
+ index_repository(repo_path)
241
+
242
+ conn = sqlite3.connect(db_path)
243
+ cur = conn.cursor()
244
+
245
+ cur.execute("SELECT id, name, kind, level, start_line, end_line, token_estimate, file_path, content FROM nodes;")
246
+ rows = cur.fetchall()
247
+
248
+ nodes = []
249
+ KIND_COLORS = {
250
+ "file": "#f0883e", # Document (Warm Cyber Orange)
251
+ "heading_1": "#58a6ff", # H1 Primary (Electric Blue)
252
+ "heading_2": "#3fb950", # H2 Major (Emerald Green)
253
+ "heading_3": "#bc8cff", # H3 Subsection (Vivid Purple)
254
+ "heading_4": "#ff7bba", # H4 Detail (Vibrant Rose Pink - 100% distinct from Document Orange!)
255
+ "heading_5": "#00d2d3", # H5 Fine (Cyan / Turquoise)
256
+ "heading_6": "#ffd700", # H6 Micro (Bright Gold)
257
+ }
258
+
259
+ # Hierarchy-based gradual sizing: File(12) -> H1(8.5) -> H2(6.0) -> H3(4.2) -> H4(3.0) -> H5/6(2.2)
260
+ KIND_VALS = {
261
+ "file": 12.0,
262
+ "heading_1": 8.5,
263
+ "heading_2": 6.0,
264
+ "heading_3": 4.2,
265
+ "heading_4": 3.0,
266
+ "heading_5": 2.2,
267
+ "heading_6": 1.8
268
+ }
269
+
270
+ for r in rows:
271
+ nid, name, kind, level, start_l, end_l, tokens, fpath, content = r
272
+ val = KIND_VALS.get(kind, 3.0)
273
+ nodes.append({
274
+ "id": nid,
275
+ "name": name,
276
+ "kind": kind,
277
+ "level": level,
278
+ "line": start_l,
279
+ "file": fpath,
280
+ "abs_path": os.path.normpath(os.path.join(repo_path, fpath)) if not os.path.isabs(fpath) else fpath,
281
+ "tokens": tokens,
282
+ "val": val,
283
+ "color": KIND_COLORS.get(kind, "#8b949e"),
284
+ "content": content
285
+ })
286
+
287
+ cur.execute("SELECT id, source, target, kind FROM edges;")
288
+ edges = []
289
+ for r in cur.fetchall():
290
+ eid, src, tgt, kind = r
291
+ edges.append({
292
+ "id": eid,
293
+ "source": src,
294
+ "target": tgt,
295
+ "kind": kind,
296
+ "color": "rgba(88, 166, 255, 0.45)" if kind == "parent_child" else "rgba(0, 255, 170, 0.75)"
297
+ })
298
+
299
+ conn.close()
300
+ return {"nodes": nodes, "links": edges}
docgraph/mcp_server.py ADDED
@@ -0,0 +1,69 @@
1
+ """DocGraph Native Model Context Protocol (MCP) Server.
2
+ Full integration with SQLite Graph Topology (.docgraph/docgraph.db), TOC Slicing, and AST Indexing.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import sys
9
+ from docgraph.parser import extract_toc, extract_section, search_doc
10
+ from docgraph.db import index_repository, fetch_graph_data, get_db_path
11
+
12
+
13
+ def run_mcp():
14
+ """Start MCP Server using official MCP SDK."""
15
+ try:
16
+ from mcp.server import MCPServer
17
+ except ImportError:
18
+ print(
19
+ "Error: 'mcp' SDK is required to run the MCP server.\n"
20
+ "Install it via: pip install mcp",
21
+ file=sys.stderr
22
+ )
23
+ sys.exit(1)
24
+
25
+ app = MCPServer("docgraph")
26
+
27
+ @app.tool()
28
+ def docgraph_toc(filePath: str, format: str = "text") -> str:
29
+ """Extract Table of Contents (TOC) with line anchors from a Markdown file to save 97% context tokens."""
30
+ return extract_toc(filePath, format_type=format)
31
+
32
+ @app.tool()
33
+ def docgraph_section(filePath: str, heading: str, includeSubsections: bool = True) -> str:
34
+ """Surgically extract the content of a specific heading section without reading the whole file."""
35
+ return extract_section(filePath, heading, include_subsections=includeSubsections)
36
+
37
+ @app.tool()
38
+ def docgraph_search(filePath: str, query: str, limit: int = 30) -> str:
39
+ """Search across Markdown documents or folders and locate exact line numbers and matches."""
40
+ return search_doc(filePath, query, max_results=limit)
41
+
42
+ @app.tool()
43
+ def docgraph_graph(repoPath: str) -> str:
44
+ """Retrieve 3D knowledge topology graph (nodes, edges, cross-document links) from .docgraph/docgraph.db."""
45
+ try:
46
+ data = fetch_graph_data(repoPath)
47
+ return json.dumps({
48
+ "nodes_count": len(data["nodes"]),
49
+ "links_count": len(data["links"]),
50
+ "nodes": data["nodes"][:50], # summary sample
51
+ "sample_links": data["links"][:50]
52
+ }, indent=2, ensure_ascii=False)
53
+ except Exception as e:
54
+ return f"Error fetching graph: {e}"
55
+
56
+ @app.tool()
57
+ def docgraph_index(repoPath: str) -> str:
58
+ """Scan and build/refresh .docgraph/docgraph.db SQLite AST graph index for a project."""
59
+ try:
60
+ f, n, e = index_repository(repoPath)
61
+ return f"Successfully indexed {repoPath}: {f} files, {n} nodes, {e} edges stored in .docgraph/docgraph.db"
62
+ except Exception as e:
63
+ return f"Error indexing repository: {e}"
64
+
65
+ app.run(transport="stdio")
66
+
67
+
68
+ if __name__ == "__main__":
69
+ run_mcp()