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.
- forgecli/__init__.py +4 -0
- forgecli/build/__init__.py +135 -0
- forgecli/build/apply.py +218 -0
- forgecli/build/caveman_optimize.py +37 -0
- forgecli/build/diff_extract.py +218 -0
- forgecli/build/llm.py +167 -0
- forgecli/build/optimize.py +37 -0
- forgecli/build/pipeline.py +76 -0
- forgecli/build/retrieval.py +157 -0
- forgecli/build/summarize.py +76 -0
- forgecli/build/test_run.py +75 -0
- forgecli/builder/__init__.py +11 -0
- forgecli/builder/builder.py +53 -0
- forgecli/builder/editor.py +36 -0
- forgecli/builder/formatter.py +31 -0
- forgecli/cli/__init__.py +5 -0
- forgecli/cli/bootstrap.py +281 -0
- forgecli/cli/commands_graph.py +149 -0
- forgecli/cli/commands_wrappers.py +80 -0
- forgecli/cli/daemon.py +744 -0
- forgecli/cli/main.py +234 -0
- forgecli/cli/ui.py +56 -0
- forgecli/config/__init__.py +28 -0
- forgecli/config/loader.py +85 -0
- forgecli/config/settings.py +206 -0
- forgecli/config/writer.py +90 -0
- forgecli/core/__init__.py +35 -0
- forgecli/core/container.py +68 -0
- forgecli/core/context.py +54 -0
- forgecli/core/credentials.py +114 -0
- forgecli/core/errors.py +27 -0
- forgecli/core/events.py +67 -0
- forgecli/core/logging.py +47 -0
- forgecli/core/models.py +214 -0
- forgecli/core/plugins.py +54 -0
- forgecli/core/service.py +22 -0
- forgecli/docs/__init__.py +5 -0
- forgecli/docs/generator.py +115 -0
- forgecli/engine/__init__.py +105 -0
- forgecli/engine/context.py +163 -0
- forgecli/engine/defaults.py +89 -0
- forgecli/engine/events.py +185 -0
- forgecli/engine/execution.py +519 -0
- forgecli/engine/plugins.py +145 -0
- forgecli/engine/runner.py +158 -0
- forgecli/engine/stages/__init__.py +33 -0
- forgecli/engine/stages/caveman_optimizer.py +47 -0
- forgecli/engine/stages/context_optimizer.py +44 -0
- forgecli/engine/stages/execution_engine_stage.py +102 -0
- forgecli/engine/stages/git_engine.py +30 -0
- forgecli/engine/stages/intent_analyzer.py +38 -0
- forgecli/engine/stages/model_router.py +65 -0
- forgecli/engine/stages/planning_engine.py +45 -0
- forgecli/engine/stages/repository_analyzer.py +54 -0
- forgecli/engine/stages/validation_engine.py +87 -0
- forgecli/git/__init__.py +9 -0
- forgecli/git/repo.py +55 -0
- forgecli/git/service.py +45 -0
- forgecli/graph/__init__.py +56 -0
- forgecli/graph/backend_graphify.py +448 -0
- forgecli/graph/edge.py +19 -0
- forgecli/graph/graph.py +85 -0
- forgecli/graph/graphify.py +405 -0
- forgecli/graph/indexer.py +82 -0
- forgecli/graph/node.py +47 -0
- forgecli/graph/repository.py +164 -0
- forgecli/memory/__init__.py +12 -0
- forgecli/memory/cache.py +61 -0
- forgecli/memory/history.py +136 -0
- forgecli/memory/store.py +85 -0
- forgecli/optimizer/__init__.py +13 -0
- forgecli/optimizer/caveman/__init__.py +169 -0
- forgecli/optimizer/caveman/cli.py +62 -0
- forgecli/optimizer/caveman/decorator.py +67 -0
- forgecli/optimizer/caveman/factory.py +51 -0
- forgecli/optimizer/caveman/ruleset.py +156 -0
- forgecli/optimizer/caveman/state.py +50 -0
- forgecli/optimizer/chunker.py +85 -0
- forgecli/optimizer/optimizer.py +81 -0
- forgecli/optimizer/ponytail/__init__.py +181 -0
- forgecli/optimizer/ponytail/cli.py +159 -0
- forgecli/optimizer/ponytail/decorator.py +70 -0
- forgecli/optimizer/ponytail/factory.py +51 -0
- forgecli/optimizer/ponytail/ruleset.py +168 -0
- forgecli/optimizer/ponytail/state.py +51 -0
- forgecli/optimizer/ranker.py +37 -0
- forgecli/optimizer/summarizer.py +44 -0
- forgecli/orchestrator/__init__.py +706 -0
- forgecli/planner/__init__.py +56 -0
- forgecli/planner/agent.py +61 -0
- forgecli/planner/plan.py +65 -0
- forgecli/planner/planner.py +17 -0
- forgecli/planner/render.py +267 -0
- forgecli/planner/serialize.py +104 -0
- forgecli/planner/software.py +832 -0
- forgecli/platform/__init__.py +91 -0
- forgecli/platform/core.py +201 -0
- forgecli/platform/deps.py +361 -0
- forgecli/platform/paths.py +234 -0
- forgecli/platform/shell.py +176 -0
- forgecli/platform/update.py +253 -0
- forgecli/plugins/__init__.py +249 -0
- forgecli/prompts/__init__.py +11 -0
- forgecli/prompts/loader.py +28 -0
- forgecli/prompts/registry.py +32 -0
- forgecli/prompts/renderer.py +23 -0
- forgecli/providers/__init__.py +39 -0
- forgecli/providers/anthropic.py +207 -0
- forgecli/providers/base.py +204 -0
- forgecli/providers/builtin.py +26 -0
- forgecli/providers/conversation.py +74 -0
- forgecli/providers/google.py +290 -0
- forgecli/providers/http_base.py +207 -0
- forgecli/providers/mock.py +111 -0
- forgecli/providers/openai.py +206 -0
- forgecli/providers/openai_compatible.py +787 -0
- forgecli/providers/router.py +340 -0
- forgecli/providers/router_state.py +89 -0
- forgecli/review/__init__.py +45 -0
- forgecli/review/analyzer.py +124 -0
- forgecli/review/analyzers/__init__.py +1 -0
- forgecli/review/analyzers/architecture.py +255 -0
- forgecli/review/analyzers/complexity.py +162 -0
- forgecli/review/analyzers/dead_code.py +255 -0
- forgecli/review/analyzers/duplicates.py +161 -0
- forgecli/review/analyzers/performance.py +161 -0
- forgecli/review/analyzers/security.py +244 -0
- forgecli/review/finding.py +68 -0
- forgecli/review/report.py +321 -0
- forgecli/review/repository.py +130 -0
- forgecli/review/suggestions.py +98 -0
- forgecli/runtime/__init__.py +6 -0
- forgecli/runtime/cache_store.py +75 -0
- forgecli/runtime/mcp_config.py +118 -0
- forgecli/runtime/prepare.py +203 -0
- forgecli/runtime/wrappers.py +150 -0
- forgecli/sdk/__init__.py +132 -0
- forgecli/sdk/events.py +206 -0
- forgecli/sdk/interfaces.py +282 -0
- forgecli/sdk/loader.py +239 -0
- forgecli/sdk/manager.py +678 -0
- forgecli/sdk/manifest.py +395 -0
- forgecli/sdk/sandbox.py +247 -0
- forgecli/sdk/version.py +313 -0
- forgecli/templates/__init__.py +9 -0
- forgecli/templates/engine.py +37 -0
- forgecli/templates/registry.py +32 -0
- forgecli/utils/__init__.py +19 -0
- forgecli/utils/fs.py +121 -0
- forgecli/utils/ids.py +11 -0
- forgecli/utils/io.py +27 -0
- forgecli/utils/paths.py +78 -0
- forgecli/utils/stats.py +179 -0
- forgecli/utils/timing.py +25 -0
- forgeoptimizer-0.1.0.dist-info/METADATA +177 -0
- forgeoptimizer-0.1.0.dist-info/RECORD +159 -0
- forgeoptimizer-0.1.0.dist-info/WHEEL +4 -0
- forgeoptimizer-0.1.0.dist-info/entry_points.txt +2 -0
- 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")
|