forgeoptimizer 0.1.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.
Files changed (159) hide show
  1. forgecli/__init__.py +4 -0
  2. forgecli/build/__init__.py +135 -0
  3. forgecli/build/apply.py +218 -0
  4. forgecli/build/caveman_optimize.py +37 -0
  5. forgecli/build/diff_extract.py +218 -0
  6. forgecli/build/llm.py +167 -0
  7. forgecli/build/optimize.py +37 -0
  8. forgecli/build/pipeline.py +76 -0
  9. forgecli/build/retrieval.py +157 -0
  10. forgecli/build/summarize.py +76 -0
  11. forgecli/build/test_run.py +75 -0
  12. forgecli/builder/__init__.py +11 -0
  13. forgecli/builder/builder.py +53 -0
  14. forgecli/builder/editor.py +36 -0
  15. forgecli/builder/formatter.py +31 -0
  16. forgecli/cli/__init__.py +5 -0
  17. forgecli/cli/bootstrap.py +281 -0
  18. forgecli/cli/commands_graph.py +149 -0
  19. forgecli/cli/commands_wrappers.py +80 -0
  20. forgecli/cli/daemon.py +744 -0
  21. forgecli/cli/main.py +234 -0
  22. forgecli/cli/ui.py +56 -0
  23. forgecli/config/__init__.py +28 -0
  24. forgecli/config/loader.py +85 -0
  25. forgecli/config/settings.py +206 -0
  26. forgecli/config/writer.py +90 -0
  27. forgecli/core/__init__.py +35 -0
  28. forgecli/core/container.py +68 -0
  29. forgecli/core/context.py +54 -0
  30. forgecli/core/credentials.py +114 -0
  31. forgecli/core/errors.py +27 -0
  32. forgecli/core/events.py +67 -0
  33. forgecli/core/logging.py +47 -0
  34. forgecli/core/models.py +214 -0
  35. forgecli/core/plugins.py +54 -0
  36. forgecli/core/service.py +22 -0
  37. forgecli/docs/__init__.py +5 -0
  38. forgecli/docs/generator.py +115 -0
  39. forgecli/engine/__init__.py +105 -0
  40. forgecli/engine/context.py +163 -0
  41. forgecli/engine/defaults.py +89 -0
  42. forgecli/engine/events.py +185 -0
  43. forgecli/engine/execution.py +519 -0
  44. forgecli/engine/plugins.py +145 -0
  45. forgecli/engine/runner.py +158 -0
  46. forgecli/engine/stages/__init__.py +33 -0
  47. forgecli/engine/stages/caveman_optimizer.py +47 -0
  48. forgecli/engine/stages/context_optimizer.py +44 -0
  49. forgecli/engine/stages/execution_engine_stage.py +102 -0
  50. forgecli/engine/stages/git_engine.py +30 -0
  51. forgecli/engine/stages/intent_analyzer.py +38 -0
  52. forgecli/engine/stages/model_router.py +65 -0
  53. forgecli/engine/stages/planning_engine.py +45 -0
  54. forgecli/engine/stages/repository_analyzer.py +54 -0
  55. forgecli/engine/stages/validation_engine.py +87 -0
  56. forgecli/git/__init__.py +9 -0
  57. forgecli/git/repo.py +55 -0
  58. forgecli/git/service.py +45 -0
  59. forgecli/graph/__init__.py +56 -0
  60. forgecli/graph/backend_graphify.py +448 -0
  61. forgecli/graph/edge.py +19 -0
  62. forgecli/graph/graph.py +85 -0
  63. forgecli/graph/graphify.py +405 -0
  64. forgecli/graph/indexer.py +82 -0
  65. forgecli/graph/node.py +47 -0
  66. forgecli/graph/repository.py +164 -0
  67. forgecli/memory/__init__.py +12 -0
  68. forgecli/memory/cache.py +61 -0
  69. forgecli/memory/history.py +136 -0
  70. forgecli/memory/store.py +85 -0
  71. forgecli/optimizer/__init__.py +13 -0
  72. forgecli/optimizer/caveman/__init__.py +169 -0
  73. forgecli/optimizer/caveman/cli.py +62 -0
  74. forgecli/optimizer/caveman/decorator.py +67 -0
  75. forgecli/optimizer/caveman/factory.py +51 -0
  76. forgecli/optimizer/caveman/ruleset.py +156 -0
  77. forgecli/optimizer/caveman/state.py +50 -0
  78. forgecli/optimizer/chunker.py +85 -0
  79. forgecli/optimizer/optimizer.py +81 -0
  80. forgecli/optimizer/ponytail/__init__.py +181 -0
  81. forgecli/optimizer/ponytail/cli.py +159 -0
  82. forgecli/optimizer/ponytail/decorator.py +70 -0
  83. forgecli/optimizer/ponytail/factory.py +51 -0
  84. forgecli/optimizer/ponytail/ruleset.py +168 -0
  85. forgecli/optimizer/ponytail/state.py +51 -0
  86. forgecli/optimizer/ranker.py +37 -0
  87. forgecli/optimizer/summarizer.py +44 -0
  88. forgecli/orchestrator/__init__.py +706 -0
  89. forgecli/planner/__init__.py +56 -0
  90. forgecli/planner/agent.py +61 -0
  91. forgecli/planner/plan.py +65 -0
  92. forgecli/planner/planner.py +17 -0
  93. forgecli/planner/render.py +267 -0
  94. forgecli/planner/serialize.py +104 -0
  95. forgecli/planner/software.py +832 -0
  96. forgecli/platform/__init__.py +91 -0
  97. forgecli/platform/core.py +201 -0
  98. forgecli/platform/deps.py +361 -0
  99. forgecli/platform/paths.py +234 -0
  100. forgecli/platform/shell.py +176 -0
  101. forgecli/platform/update.py +253 -0
  102. forgecli/plugins/__init__.py +249 -0
  103. forgecli/prompts/__init__.py +11 -0
  104. forgecli/prompts/loader.py +28 -0
  105. forgecli/prompts/registry.py +32 -0
  106. forgecli/prompts/renderer.py +23 -0
  107. forgecli/providers/__init__.py +39 -0
  108. forgecli/providers/anthropic.py +207 -0
  109. forgecli/providers/base.py +204 -0
  110. forgecli/providers/builtin.py +26 -0
  111. forgecli/providers/conversation.py +74 -0
  112. forgecli/providers/google.py +290 -0
  113. forgecli/providers/http_base.py +207 -0
  114. forgecli/providers/mock.py +111 -0
  115. forgecli/providers/openai.py +206 -0
  116. forgecli/providers/openai_compatible.py +787 -0
  117. forgecli/providers/router.py +340 -0
  118. forgecli/providers/router_state.py +89 -0
  119. forgecli/review/__init__.py +45 -0
  120. forgecli/review/analyzer.py +124 -0
  121. forgecli/review/analyzers/__init__.py +1 -0
  122. forgecli/review/analyzers/architecture.py +255 -0
  123. forgecli/review/analyzers/complexity.py +162 -0
  124. forgecli/review/analyzers/dead_code.py +255 -0
  125. forgecli/review/analyzers/duplicates.py +161 -0
  126. forgecli/review/analyzers/performance.py +161 -0
  127. forgecli/review/analyzers/security.py +244 -0
  128. forgecli/review/finding.py +68 -0
  129. forgecli/review/report.py +321 -0
  130. forgecli/review/repository.py +130 -0
  131. forgecli/review/suggestions.py +98 -0
  132. forgecli/runtime/__init__.py +6 -0
  133. forgecli/runtime/cache_store.py +75 -0
  134. forgecli/runtime/mcp_config.py +118 -0
  135. forgecli/runtime/prepare.py +203 -0
  136. forgecli/runtime/wrappers.py +150 -0
  137. forgecli/sdk/__init__.py +132 -0
  138. forgecli/sdk/events.py +206 -0
  139. forgecli/sdk/interfaces.py +282 -0
  140. forgecli/sdk/loader.py +239 -0
  141. forgecli/sdk/manager.py +678 -0
  142. forgecli/sdk/manifest.py +395 -0
  143. forgecli/sdk/sandbox.py +247 -0
  144. forgecli/sdk/version.py +313 -0
  145. forgecli/templates/__init__.py +9 -0
  146. forgecli/templates/engine.py +37 -0
  147. forgecli/templates/registry.py +32 -0
  148. forgecli/utils/__init__.py +19 -0
  149. forgecli/utils/fs.py +121 -0
  150. forgecli/utils/ids.py +11 -0
  151. forgecli/utils/io.py +27 -0
  152. forgecli/utils/paths.py +78 -0
  153. forgecli/utils/stats.py +179 -0
  154. forgecli/utils/timing.py +25 -0
  155. forgeoptimizer-0.1.0.dist-info/METADATA +177 -0
  156. forgeoptimizer-0.1.0.dist-info/RECORD +159 -0
  157. forgeoptimizer-0.1.0.dist-info/WHEEL +4 -0
  158. forgeoptimizer-0.1.0.dist-info/entry_points.txt +2 -0
  159. forgeoptimizer-0.1.0.dist-info/licenses/LICENSE +21 -0
forgecli/cli/daemon.py ADDED
@@ -0,0 +1,744 @@
1
+ """Forge Context Daemon and MCP Server.
2
+
3
+ Provides a long-running local background service to monitor repositories,
4
+ rebuild context incrementally, and expose HTTP API endpoints and an MCP server.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import hashlib
11
+ import json
12
+ import os
13
+ import re
14
+ import sys
15
+ import threading
16
+ import time
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ import httpx
21
+ import uvicorn
22
+ from fastapi import FastAPI
23
+
24
+ from forgecli.cli.commands_graph import setup_graphify_credentials
25
+ from forgecli.graph.backend_graphify import GraphifyRepositoryGraph
26
+ from forgecli.optimizer.chunker import Chunker
27
+ from forgecli.runtime.prepare import prepare_runtime_sync, resolve_repo_root
28
+
29
+ app = FastAPI(title="Forge Context Runtime API")
30
+ watchers: dict[str, RepoWatcher] = {}
31
+ watchers_lock = threading.Lock()
32
+
33
+
34
+ def get_recursive_fingerprint(root: Path) -> str:
35
+ """Compute a signature of the repository based on file paths, modification times, and sizes."""
36
+ sig = hashlib.sha256()
37
+ sig.update(str(root.resolve()).encode("utf-8"))
38
+
39
+ skip_dirs = {
40
+ ".git",
41
+ ".venv",
42
+ "__pycache__",
43
+ "node_modules",
44
+ "dist",
45
+ "build",
46
+ ".forge",
47
+ "graphify-out",
48
+ ".mypy_cache",
49
+ ".pytest_cache",
50
+ ".ruff_cache",
51
+ }
52
+
53
+ try:
54
+ for path in sorted(root.rglob("*"), key=lambda p: str(p)):
55
+ try:
56
+ parts = path.relative_to(root).parts
57
+ if any(part.startswith(".") or part in skip_dirs for part in parts[:-1]):
58
+ continue
59
+ if path.is_file() and not path.name.startswith("."):
60
+ stat = path.stat()
61
+ sig.update(f"{path.name}:{stat.st_mtime}:{stat.st_size}".encode())
62
+ except (ValueError, OSError):
63
+ continue
64
+ except OSError:
65
+ pass
66
+
67
+ return sig.hexdigest()[:24]
68
+
69
+
70
+ def extract_symbols_fallback(root: Path) -> list[dict[str, Any]]:
71
+ """Fallback class and function parser for Python and JS/TS when graphify is not available."""
72
+ symbols = []
73
+ skip_dirs = {
74
+ ".git",
75
+ ".venv",
76
+ "__pycache__",
77
+ "node_modules",
78
+ "dist",
79
+ "build",
80
+ ".forge",
81
+ "graphify-out",
82
+ ".mypy_cache",
83
+ ".pytest_cache",
84
+ ".ruff_cache",
85
+ }
86
+
87
+ py_class_re = re.compile(r"^class\s+([A-Za-z0-9_]+)")
88
+ py_def_re = re.compile(r"^\s*def\s+([A-Za-z0-9_]+)")
89
+ js_class_re = re.compile(r"^class\s+([A-Za-z0-9_]+)")
90
+ js_func_re = re.compile(r"^\s*(?:async\s+)?function\s+([A-Za-z0-9_]+)")
91
+ js_arrow_re = re.compile(r"^\s*const\s+([A-Za-z0-9_]+)\s*=\s*(?:\([^)]*\)|[A-Za-z0-9_]+)\s*=>")
92
+
93
+ try:
94
+ for p in root.rglob("*"):
95
+ try:
96
+ parts = p.relative_to(root).parts
97
+ if any(part.startswith(".") or part in skip_dirs for part in parts[:-1]):
98
+ continue
99
+ if p.is_file() and not p.name.startswith("."):
100
+ rel_path = str(p.relative_to(root))
101
+ ext = p.suffix
102
+ if ext == ".py":
103
+ with open(p, encoding="utf-8", errors="replace") as f:
104
+ for idx, line in enumerate(f, 1):
105
+ m = py_class_re.match(line)
106
+ if m:
107
+ symbols.append(
108
+ {
109
+ "name": m.group(1),
110
+ "type": "class",
111
+ "file": rel_path,
112
+ "line": idx,
113
+ }
114
+ )
115
+ continue
116
+ m = py_def_re.match(line)
117
+ if m:
118
+ symbols.append(
119
+ {
120
+ "name": m.group(1),
121
+ "type": "function",
122
+ "file": rel_path,
123
+ "line": idx,
124
+ }
125
+ )
126
+ elif ext in {".js", ".ts", ".jsx", ".tsx"}:
127
+ with open(p, encoding="utf-8", errors="replace") as f:
128
+ for idx, line in enumerate(f, 1):
129
+ m = js_class_re.match(line)
130
+ if m:
131
+ symbols.append(
132
+ {
133
+ "name": m.group(1),
134
+ "type": "class",
135
+ "file": rel_path,
136
+ "line": idx,
137
+ }
138
+ )
139
+ continue
140
+ m = js_func_re.match(line)
141
+ if m:
142
+ symbols.append(
143
+ {
144
+ "name": m.group(1),
145
+ "type": "function",
146
+ "file": rel_path,
147
+ "line": idx,
148
+ }
149
+ )
150
+ continue
151
+ m = js_arrow_re.match(line)
152
+ if m:
153
+ symbols.append(
154
+ {
155
+ "name": m.group(1),
156
+ "type": "function",
157
+ "file": rel_path,
158
+ "line": idx,
159
+ }
160
+ )
161
+ except Exception:
162
+ continue
163
+ except OSError:
164
+ pass
165
+ return symbols
166
+
167
+
168
+ def extract_dependencies_fallback(root: Path) -> list[dict[str, Any]]:
169
+ """Fallback import statements parser for Python and JS/TS when graphify is not available."""
170
+ dependencies = []
171
+ skip_dirs = {
172
+ ".git",
173
+ ".venv",
174
+ "__pycache__",
175
+ "node_modules",
176
+ "dist",
177
+ "build",
178
+ ".forge",
179
+ "graphify-out",
180
+ ".mypy_cache",
181
+ ".pytest_cache",
182
+ ".ruff_cache",
183
+ }
184
+
185
+ py_import_re = re.compile(
186
+ r"^\s*(?:import\s+([A-Za-z0-9_.,\s]+)|from\s+([A-Za-z0-9_.]+)\s+import)"
187
+ )
188
+ js_import_re = re.compile(r"^\s*import\s+.*?\s+from\s+['\"]([^'\"]+)['\"]")
189
+ js_require_re = re.compile(
190
+ r"^\s*(?:const|let|var)\s+.*?\s*=\s*require\(\s*['\"]([^'\"]+)['\"]\s*\)"
191
+ )
192
+
193
+ try:
194
+ for p in root.rglob("*"):
195
+ try:
196
+ parts = p.relative_to(root).parts
197
+ if any(part.startswith(".") or part in skip_dirs for part in parts[:-1]):
198
+ continue
199
+ if p.is_file() and not p.name.startswith("."):
200
+ rel_path = str(p.relative_to(root))
201
+ ext = p.suffix
202
+ if ext == ".py":
203
+ with open(p, encoding="utf-8", errors="replace") as f:
204
+ for line in f:
205
+ m = py_import_re.match(line)
206
+ if m:
207
+ dep = m.group(1) or m.group(2)
208
+ dependencies.append(
209
+ {
210
+ "source": rel_path,
211
+ "target": dep.strip(),
212
+ "type": "import",
213
+ }
214
+ )
215
+ elif ext in {".js", ".ts", ".jsx", ".tsx"}:
216
+ with open(p, encoding="utf-8", errors="replace") as f:
217
+ for line in f:
218
+ m = js_import_re.match(line)
219
+ if m:
220
+ dependencies.append(
221
+ {"source": rel_path, "target": m.group(1), "type": "import"}
222
+ )
223
+ continue
224
+ m = js_require_re.match(line)
225
+ if m:
226
+ dependencies.append(
227
+ {
228
+ "source": rel_path,
229
+ "target": m.group(1),
230
+ "type": "require",
231
+ }
232
+ )
233
+ except Exception:
234
+ continue
235
+ except OSError:
236
+ pass
237
+ return dependencies
238
+
239
+
240
+ class RepoWatcher(threading.Thread):
241
+ """Background thread that monitors a repository and updates cached outputs on change."""
242
+
243
+ def __init__(self, root: Path, update_interval: float = 2.0):
244
+ super().__init__(daemon=True)
245
+ self.root = root
246
+ self.update_interval = update_interval
247
+ self.last_fingerprint = ""
248
+ self.running = True
249
+
250
+ self.context_summary = ""
251
+ self.context_file = ""
252
+ self.graph_snapshot: Any = None
253
+ self.symbols: list[dict[str, Any]] = []
254
+ self.dependencies: list[dict[str, Any]] = []
255
+ self.files: list[dict[str, Any]] = []
256
+ self.chunks: list[dict[str, Any]] = []
257
+ self.summary_md = ""
258
+
259
+ def run(self) -> None:
260
+ while self.running:
261
+ try:
262
+ fingerprint = get_recursive_fingerprint(self.root)
263
+ if fingerprint != self.last_fingerprint:
264
+ self.last_fingerprint = fingerprint
265
+ self.refresh_context()
266
+ except Exception:
267
+ pass
268
+ time.sleep(self.update_interval)
269
+
270
+ def refresh_context(self) -> None:
271
+ # 1. Scan/Optimize context
272
+ prepared = prepare_runtime_sync(self.root, force=True, quiet=True)
273
+ self.context_summary = prepared.context_summary
274
+ self.context_file = str(prepared.context_file)
275
+
276
+ # 2. Get list of files
277
+ skip_dirs = {
278
+ ".git",
279
+ ".venv",
280
+ "__pycache__",
281
+ "node_modules",
282
+ "dist",
283
+ "build",
284
+ ".forge",
285
+ "graphify-out",
286
+ ".mypy_cache",
287
+ ".pytest_cache",
288
+ ".ruff_cache",
289
+ }
290
+ self.files = []
291
+ try:
292
+ for p in sorted(self.root.rglob("*"), key=lambda x: str(x)):
293
+ try:
294
+ parts = p.relative_to(self.root).parts
295
+ if any(part.startswith(".") or part in skip_dirs for part in parts[:-1]):
296
+ continue
297
+ if p.is_file() and not p.name.startswith("."):
298
+ stat = p.stat()
299
+ self.files.append(
300
+ {
301
+ "name": p.name,
302
+ "path": str(p.relative_to(self.root)),
303
+ "size_bytes": stat.st_size,
304
+ "mtime": stat.st_mtime,
305
+ }
306
+ )
307
+ except (ValueError, OSError):
308
+ continue
309
+ except OSError:
310
+ pass
311
+
312
+ # 3. Chunks
313
+ chunker = Chunker(size=4000, overlap=200)
314
+ self.chunks = []
315
+ for file_info in self.files:
316
+ p = self.root / file_info["path"]
317
+ try:
318
+ text = p.read_text(encoding="utf-8", errors="replace")
319
+ file_chunks = chunker.split(text, source_id=file_info["path"])
320
+ for c in file_chunks:
321
+ self.chunks.append(
322
+ {
323
+ "text": c.text,
324
+ "index": c.index,
325
+ "start": c.start,
326
+ "end": c.end,
327
+ "source_id": c.source_id,
328
+ }
329
+ )
330
+ except OSError:
331
+ pass
332
+
333
+ # 4. Graph snapshot
334
+ backend = GraphifyRepositoryGraph(root=self.root)
335
+ self.graph_snapshot = None
336
+
337
+ loop = asyncio.new_event_loop()
338
+ try:
339
+ is_graphify = loop.run_until_complete(backend.is_available())
340
+ if is_graphify:
341
+ active_provider = setup_graphify_credentials(self.root)
342
+ if active_provider:
343
+ loop.run_until_complete(backend.update_graph())
344
+ self.graph_snapshot = loop.run_until_complete(backend.load())
345
+ except Exception:
346
+ pass
347
+ finally:
348
+ loop.close()
349
+
350
+ if self.graph_snapshot:
351
+ self.symbols = []
352
+ for node in self.graph_snapshot.nodes:
353
+ self.symbols.append(
354
+ {
355
+ "name": node.label,
356
+ "type": node.file_type or "symbol",
357
+ "file": node.source_file or "",
358
+ "line": node.source_location or 1,
359
+ }
360
+ )
361
+
362
+ self.dependencies = []
363
+ for edge in self.graph_snapshot.edges:
364
+ self.dependencies.append(
365
+ {"source": edge.source, "target": edge.target, "type": edge.relation}
366
+ )
367
+ else:
368
+ self.symbols = extract_symbols_fallback(self.root)
369
+ self.dependencies = extract_dependencies_fallback(self.root)
370
+
371
+ # 5. Build summary markdown
372
+ lines = [
373
+ f"# Forge Repository Summary: {self.root.name}",
374
+ f"- **Root Directory:** `{self.root}`",
375
+ f"- **Files Count:** {len(self.files)}",
376
+ f"- **Total Size:** {sum(f['size_bytes'] for f in self.files)} bytes",
377
+ "",
378
+ "## Repository Layout",
379
+ ]
380
+ for f in self.files[:36]:
381
+ lines.append(f"- `{f['path']}` ({f['size_bytes']} bytes)")
382
+ self.summary_md = "\n".join(lines)
383
+
384
+
385
+ def get_watcher_for_path(path: str | Path | None) -> RepoWatcher:
386
+ """Return a cached or newly launched RepoWatcher thread for the specified path."""
387
+ cwd = Path(path or Path.cwd()).resolve()
388
+ root = resolve_repo_root(cwd)
389
+ root_str = str(root)
390
+
391
+ with watchers_lock:
392
+ if root_str not in watchers:
393
+ watcher = RepoWatcher(root)
394
+ watcher.start()
395
+ watchers[root_str] = watcher
396
+ return watchers[root_str]
397
+
398
+
399
+ @app.get("/health")
400
+ def health(path: str | None = None) -> dict[str, Any]:
401
+ w = get_watcher_for_path(path)
402
+ return {"status": "ok", "pid": os.getpid(), "root": str(w.root)}
403
+
404
+
405
+ @app.get("/summary")
406
+ def get_summary(path: str | None = None) -> dict[str, Any]:
407
+ w = get_watcher_for_path(path)
408
+ return {
409
+ "markdown": w.summary_md,
410
+ "json": {
411
+ "repository": w.root.name,
412
+ "root": str(w.root),
413
+ "files_count": len(w.files),
414
+ "total_size_bytes": sum(f["size_bytes"] for f in w.files),
415
+ "files": w.files,
416
+ },
417
+ }
418
+
419
+
420
+ @app.get("/context")
421
+ def get_context(path: str | None = None) -> dict[str, Any]:
422
+ w = get_watcher_for_path(path)
423
+ return {
424
+ "markdown": w.context_summary,
425
+ "json": {"summary": w.context_summary, "context_file": w.context_file},
426
+ }
427
+
428
+
429
+ @app.get("/graph")
430
+ def get_graph(path: str | None = None) -> dict[str, Any]:
431
+ w = get_watcher_for_path(path)
432
+ nodes = []
433
+ edges = []
434
+ if w.graph_snapshot:
435
+ nodes = [
436
+ {"id": n.id, "label": n.label, "file_type": n.file_type} for n in w.graph_snapshot.nodes
437
+ ]
438
+ edges = [
439
+ {"source": e.source, "target": e.target, "relation": e.relation}
440
+ for e in w.graph_snapshot.edges
441
+ ]
442
+
443
+ md_lines = ["# Knowledge Graph", ""]
444
+ for e in edges[:100]:
445
+ md_lines.append(f"- `{e['source']}` --[{e['relation']}]--> `{e['target']}`")
446
+
447
+ return {"markdown": "\n".join(md_lines), "json": {"nodes": nodes, "edges": edges}}
448
+
449
+
450
+ @app.get("/symbols")
451
+ def get_symbols(path: str | None = None) -> dict[str, Any]:
452
+ w = get_watcher_for_path(path)
453
+ md_lines = ["# Codebase Symbols", ""]
454
+ for s in w.symbols[:200]:
455
+ md_lines.append(f"- **{s['name']}** ({s['type']}) in `{s['file']}`:L{s['line']}")
456
+
457
+ return {"markdown": "\n".join(md_lines), "json": w.symbols}
458
+
459
+
460
+ @app.get("/dependencies")
461
+ def get_dependencies(path: str | None = None) -> dict[str, Any]:
462
+ w = get_watcher_for_path(path)
463
+ md_lines = ["# Module Dependencies", ""]
464
+ for d in w.dependencies[:200]:
465
+ md_lines.append(f"- `{d['source']}` --[{d['type']}]--> `{d['target']}`")
466
+
467
+ return {"markdown": "\n".join(md_lines), "json": w.dependencies}
468
+
469
+
470
+ @app.get("/files")
471
+ def get_files(path: str | None = None) -> dict[str, Any]:
472
+ w = get_watcher_for_path(path)
473
+ md_lines = ["# Repository Files", ""]
474
+ for f in w.files:
475
+ md_lines.append(f"- `{f['path']}` ({f['size_bytes']} bytes)")
476
+
477
+ return {"markdown": "\n".join(md_lines), "json": w.files}
478
+
479
+
480
+ @app.get("/chunks")
481
+ def get_chunks(path: str | None = None) -> dict[str, Any]:
482
+ w = get_watcher_for_path(path)
483
+ md_lines = ["# Code Chunks", ""]
484
+ for idx, c in enumerate(w.chunks[:100]):
485
+ md_lines.append(f"### Chunk {idx} (from `{c['source_id']}`)")
486
+ md_lines.append("```")
487
+ md_lines.append(c["text"][:300] + "...")
488
+ md_lines.append("```")
489
+ md_lines.append("")
490
+
491
+ return {"markdown": "\n".join(md_lines), "json": w.chunks}
492
+
493
+
494
+ def is_daemon_running() -> bool:
495
+ """Ping the daemon health endpoint to see if it is alive."""
496
+ try:
497
+ with httpx.Client(timeout=0.5) as client:
498
+ r = client.get("http://127.0.0.1:16868/health")
499
+ return r.status_code == 200
500
+ except Exception:
501
+ return False
502
+
503
+
504
+ def start_daemon_background() -> None:
505
+ """Launch the daemon in the background as a subprocess."""
506
+ import subprocess
507
+
508
+ # Launch forgecli.cli.daemon in a background python process
509
+ subprocess.Popen(
510
+ [sys.executable, "-m", "forgecli.cli.daemon"],
511
+ stdout=subprocess.DEVNULL,
512
+ stderr=subprocess.DEVNULL,
513
+ close_fds=True,
514
+ )
515
+
516
+
517
+ def run_mcp_stdio() -> None:
518
+ """Run the stdio MCP protocol handler, passing requests to the local daemon."""
519
+ daemon_url = "http://127.0.0.1:16868"
520
+
521
+ if not is_daemon_running():
522
+ start_daemon_background()
523
+ for _ in range(20):
524
+ if is_daemon_running():
525
+ break
526
+ time.sleep(0.2)
527
+
528
+ sys.stderr.write("Forge MCP Server active over stdio.\n")
529
+ sys.stderr.flush()
530
+
531
+ with httpx.Client(timeout=15.0) as client:
532
+ for line in sys.stdin:
533
+ if not line.strip():
534
+ continue
535
+ try:
536
+ req = json.loads(line)
537
+ req_id = req.get("id")
538
+ method = req.get("method")
539
+ params = req.get("params", {})
540
+
541
+ if method == "initialize":
542
+ res = {
543
+ "jsonrpc": "2.0",
544
+ "id": req_id,
545
+ "result": {
546
+ "protocolVersion": "2024-11-05",
547
+ "capabilities": {"tools": {}},
548
+ "serverInfo": {"name": "forge", "version": "0.1.0"},
549
+ },
550
+ }
551
+ sys.stdout.write(json.dumps(res) + "\n")
552
+ sys.stdout.flush()
553
+ elif method == "notifications/initialized":
554
+ continue
555
+ elif method == "tools/list":
556
+ res = {
557
+ "jsonrpc": "2.0",
558
+ "id": req_id,
559
+ "result": {
560
+ "tools": [
561
+ {
562
+ "name": "get_summary",
563
+ "description": "Get a summary of the repository layout, file count, and size.",
564
+ "inputSchema": {
565
+ "type": "object",
566
+ "properties": {
567
+ "path": {
568
+ "type": "string",
569
+ "description": "Optional repository path.",
570
+ }
571
+ },
572
+ },
573
+ },
574
+ {
575
+ "name": "get_optimized_context",
576
+ "description": "Get the fully optimized and compressed codebase context.",
577
+ "inputSchema": {
578
+ "type": "object",
579
+ "properties": {
580
+ "path": {
581
+ "type": "string",
582
+ "description": "Optional repository path.",
583
+ }
584
+ },
585
+ },
586
+ },
587
+ {
588
+ "name": "get_dependency_graph",
589
+ "description": "Get file/module dependency relationships.",
590
+ "inputSchema": {
591
+ "type": "object",
592
+ "properties": {
593
+ "path": {
594
+ "type": "string",
595
+ "description": "Optional repository path.",
596
+ }
597
+ },
598
+ },
599
+ },
600
+ {
601
+ "name": "file_lookup",
602
+ "description": "Retrieve file contents or metadata by relative path.",
603
+ "inputSchema": {
604
+ "type": "object",
605
+ "properties": {
606
+ "file_path": {
607
+ "type": "string",
608
+ "description": "Relative file path.",
609
+ },
610
+ "path": {
611
+ "type": "string",
612
+ "description": "Optional repository path.",
613
+ },
614
+ },
615
+ "required": ["file_path"],
616
+ },
617
+ },
618
+ {
619
+ "name": "symbol_lookup",
620
+ "description": "Lookup symbol definitions (classes/functions) and locations.",
621
+ "inputSchema": {
622
+ "type": "object",
623
+ "properties": {
624
+ "query": {
625
+ "type": "string",
626
+ "description": "Symbol name search query.",
627
+ },
628
+ "path": {
629
+ "type": "string",
630
+ "description": "Optional repository path.",
631
+ },
632
+ },
633
+ },
634
+ },
635
+ {
636
+ "name": "semantic_search",
637
+ "description": "Search codebase context using keyword/phrase matching.",
638
+ "inputSchema": {
639
+ "type": "object",
640
+ "properties": {
641
+ "query": {
642
+ "type": "string",
643
+ "description": "Search term.",
644
+ },
645
+ "path": {
646
+ "type": "string",
647
+ "description": "Optional repository path.",
648
+ },
649
+ },
650
+ "required": ["query"],
651
+ },
652
+ },
653
+ ]
654
+ },
655
+ }
656
+ sys.stdout.write(json.dumps(res) + "\n")
657
+ sys.stdout.flush()
658
+ elif method == "tools/call":
659
+ tool_name = params.get("name")
660
+ args = params.get("arguments", {})
661
+ path_param = args.get("path", str(Path.cwd()))
662
+
663
+ content = ""
664
+ if tool_name == "get_summary":
665
+ r = client.get(f"{daemon_url}/summary?path={path_param}")
666
+ content = r.json()["markdown"]
667
+ elif tool_name == "get_optimized_context":
668
+ r = client.get(f"{daemon_url}/context?path={path_param}")
669
+ content = r.json()["markdown"]
670
+ elif tool_name == "get_dependency_graph":
671
+ r = client.get(f"{daemon_url}/dependencies?path={path_param}")
672
+ content = r.json()["markdown"]
673
+ elif tool_name == "file_lookup":
674
+ file_path = args.get("file_path")
675
+ r = client.get(f"{daemon_url}/files?path={path_param}")
676
+ files_list = r.json()["json"]
677
+ matched = next((f for f in files_list if f["path"] == file_path), None)
678
+ if matched:
679
+ p = Path(path_param) / file_path
680
+ try:
681
+ content = p.read_text(encoding="utf-8", errors="replace")
682
+ except Exception as e:
683
+ content = f"Error reading file: {e}"
684
+ else:
685
+ content = f"File {file_path} not found in repository."
686
+ elif tool_name == "symbol_lookup":
687
+ query = args.get("query", "")
688
+ r = client.get(f"{daemon_url}/symbols?path={path_param}")
689
+ symbols = r.json()["json"]
690
+ matches = [s for s in symbols if query.lower() in s["name"].lower()]
691
+ if matches:
692
+ content = "\n".join(
693
+ f"- **{m['name']}** ({m['type']}) in `{m['file']}`:L{m['line']}"
694
+ for m in matches
695
+ )
696
+ else:
697
+ content = f"No symbols matching '{query}' found."
698
+ elif tool_name == "semantic_search":
699
+ query = args.get("query", "")
700
+ r = client.get(f"{daemon_url}/chunks?path={path_param}")
701
+ chunks = r.json()["json"]
702
+ matches = [c for c in chunks if query.lower() in c["text"].lower()]
703
+ if matches:
704
+ lines = []
705
+ for idx, m in enumerate(matches[:5]):
706
+ lines.append(f"### Match {idx + 1} in `{m['source_id']}`")
707
+ lines.append("```")
708
+ lines.append(
709
+ m["text"][:500] + ("..." if len(m["text"]) > 500 else "")
710
+ )
711
+ lines.append("```")
712
+ lines.append("")
713
+ content = "\n".join(lines)
714
+ else:
715
+ content = f"No matches found for search query '{query}'."
716
+ else:
717
+ content = f"Unknown tool: {tool_name}"
718
+
719
+ res = {
720
+ "jsonrpc": "2.0",
721
+ "id": req_id,
722
+ "result": {"content": [{"type": "text", "text": content}]},
723
+ }
724
+ sys.stdout.write(json.dumps(res) + "\n")
725
+ sys.stdout.flush()
726
+ else:
727
+ if req_id is not None:
728
+ res = {
729
+ "jsonrpc": "2.0",
730
+ "id": req_id,
731
+ "error": {"code": -32601, "message": f"Method not found: {method}"},
732
+ }
733
+ sys.stdout.write(json.dumps(res) + "\n")
734
+ sys.stdout.flush()
735
+ except Exception as e:
736
+ sys.stderr.write(f"Error handling line: {e}\n")
737
+ sys.stderr.flush()
738
+
739
+
740
+ if __name__ == "__main__":
741
+ # Start the daemon app on port 16868
742
+ # Start watcher for the current directory by default
743
+ get_watcher_for_path(Path.cwd())
744
+ uvicorn.run(app, host="127.0.0.1", port=16868, log_level="warning")