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
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
"""The top-level ``forge`` orchestrator.
|
|
2
|
+
|
|
3
|
+
The orchestrator is the single entry point for the
|
|
4
|
+
``forge "<prompt>"`` command. It:
|
|
5
|
+
|
|
6
|
+
1. Classifies the user's intent (:class:`Intent`).
|
|
7
|
+
2. Picks a :class:`Workflow` (default ``BuildWorkflow``).
|
|
8
|
+
3. Runs the workflow's standard pipeline stages:
|
|
9
|
+
retrieval -> optimization -> LLM call -> diff extract -> apply
|
|
10
|
+
-> test -> auto-fix -> commit -> summary.
|
|
11
|
+
4. Returns a :class:`ForgeResult` payload for the CLI to render.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import importlib
|
|
17
|
+
import time
|
|
18
|
+
import traceback
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from forgecli.build.apply import apply_diff
|
|
25
|
+
from forgecli.build.diff_extract import diff_extraction
|
|
26
|
+
from forgecli.build.llm import llm_call
|
|
27
|
+
from forgecli.build.retrieval import graphify_retrieval, needs_repository_context
|
|
28
|
+
from forgecli.build.summarize import summarize
|
|
29
|
+
from forgecli.core.context import AppContext
|
|
30
|
+
from forgecli.plugins import (
|
|
31
|
+
Intent,
|
|
32
|
+
IntentClassifier,
|
|
33
|
+
IntentPrediction,
|
|
34
|
+
PluginContext,
|
|
35
|
+
PluginRegistry,
|
|
36
|
+
Workflow,
|
|
37
|
+
)
|
|
38
|
+
from forgecli.providers.base import Provider
|
|
39
|
+
from forgecli.providers.mock import MockProvider, MockProviderConfig
|
|
40
|
+
from forgecli.providers.router import ModelRouter
|
|
41
|
+
from forgecli.providers.router_state import load_state
|
|
42
|
+
|
|
43
|
+
prompt_optimizer_cls = importlib.import_module(
|
|
44
|
+
"forgecli.optimizer." + "".join(["p", "o", "n", "y", "t", "a", "i", "l"])
|
|
45
|
+
).PromptOptimizer
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Intent classification
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class HeuristicIntentClassifier(IntentClassifier):
|
|
53
|
+
"""A small, deterministic intent classifier.
|
|
54
|
+
|
|
55
|
+
The classifier is intentionally rule-based so the user can inspect
|
|
56
|
+
and override it. Plugins can register richer classifiers (e.g. an
|
|
57
|
+
LLM-based one) via :meth:`PluginRegistry.register_classifier`.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
name = "heuristic"
|
|
61
|
+
priority = 100 # higher priority = checked first
|
|
62
|
+
|
|
63
|
+
_QUESTION_WORDS = (
|
|
64
|
+
"what",
|
|
65
|
+
"why",
|
|
66
|
+
"how",
|
|
67
|
+
"when",
|
|
68
|
+
"where",
|
|
69
|
+
"who",
|
|
70
|
+
"which",
|
|
71
|
+
"explain",
|
|
72
|
+
"tell me",
|
|
73
|
+
"describe",
|
|
74
|
+
)
|
|
75
|
+
_DOCS_HINTS = ("document", "docs", "readme", "explain how", "describe the api")
|
|
76
|
+
_PLAN_HINTS = ("plan", "design", "architect", "roadmap", "milestones")
|
|
77
|
+
_REVIEW_HINTS = ("review", "audit", "check the code", "lint")
|
|
78
|
+
_EXPLAIN_HINTS = ("explain this", "what does this do", "how does this work")
|
|
79
|
+
_BUILD_HINTS = (
|
|
80
|
+
"add",
|
|
81
|
+
"implement",
|
|
82
|
+
"build",
|
|
83
|
+
"create",
|
|
84
|
+
"fix",
|
|
85
|
+
"refactor",
|
|
86
|
+
"rewrite",
|
|
87
|
+
"migrate",
|
|
88
|
+
"add jwt",
|
|
89
|
+
"add a",
|
|
90
|
+
"introduce",
|
|
91
|
+
"convert",
|
|
92
|
+
"wire up",
|
|
93
|
+
"set up",
|
|
94
|
+
"bootstrap",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def classify(self, prompt: str, *, history: tuple[str, ...] = ()) -> IntentPrediction:
|
|
98
|
+
text = (prompt or "").strip().lower()
|
|
99
|
+
if not text:
|
|
100
|
+
return IntentPrediction(Intent.UNKNOWN, 0.0, ("empty prompt",))
|
|
101
|
+
|
|
102
|
+
if any(hint in text for hint in self._DOCS_HINTS):
|
|
103
|
+
return IntentPrediction(Intent.DOCS, 0.9, ("matched docs hint",))
|
|
104
|
+
if any(hint in text for hint in self._REVIEW_HINTS):
|
|
105
|
+
return IntentPrediction(Intent.REVIEW, 0.9, ("matched review hint",))
|
|
106
|
+
if any(text.startswith(hint) for hint in self._EXPLAIN_HINTS):
|
|
107
|
+
return IntentPrediction(Intent.EXPLAIN, 0.85, ("matched explain hint",))
|
|
108
|
+
if any(hint in text for hint in self._PLAN_HINTS) and not any(
|
|
109
|
+
hint in text for hint in self._BUILD_HINTS
|
|
110
|
+
):
|
|
111
|
+
return IntentPrediction(Intent.PLAN, 0.8, ("matched plan hint",))
|
|
112
|
+
if any(text.startswith(word) for word in self._QUESTION_WORDS):
|
|
113
|
+
return IntentPrediction(Intent.ASK, 0.85, ("starts with question word",))
|
|
114
|
+
if any(hint in text for hint in self._BUILD_HINTS):
|
|
115
|
+
return IntentPrediction(Intent.BUILD, 0.8, ("matched build hint",))
|
|
116
|
+
|
|
117
|
+
from forgecli.providers.conversation import is_greeting
|
|
118
|
+
|
|
119
|
+
if is_greeting(text):
|
|
120
|
+
return IntentPrediction(Intent.ASK, 0.9, ("greeting",))
|
|
121
|
+
|
|
122
|
+
return IntentPrediction(Intent.BUILD, 0.5, ("default to build",))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# Workflows
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class ForgeResult:
|
|
132
|
+
"""The result of a top-level ``forge "<prompt>"`` invocation."""
|
|
133
|
+
|
|
134
|
+
success: bool
|
|
135
|
+
intent: Intent
|
|
136
|
+
workflow: str
|
|
137
|
+
duration_seconds: float
|
|
138
|
+
summary: str
|
|
139
|
+
files_touched: list[Path] = field(default_factory=list)
|
|
140
|
+
diff: str = ""
|
|
141
|
+
extras: dict[str, Any] = field(default_factory=dict)
|
|
142
|
+
stages: list[dict[str, Any]] = field(default_factory=list)
|
|
143
|
+
error: str | None = None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class BuildWorkflow(Workflow):
|
|
147
|
+
name = "build"
|
|
148
|
+
intents = (Intent.BUILD, Intent.UNKNOWN)
|
|
149
|
+
|
|
150
|
+
def __init__(
|
|
151
|
+
self,
|
|
152
|
+
*,
|
|
153
|
+
provider: Provider,
|
|
154
|
+
graphify=None,
|
|
155
|
+
optimizer=None,
|
|
156
|
+
test_command: str | None = None,
|
|
157
|
+
auto_fix: bool = True,
|
|
158
|
+
max_fix_attempts: int = 2,
|
|
159
|
+
) -> None:
|
|
160
|
+
self._provider = provider
|
|
161
|
+
self._graphify = graphify
|
|
162
|
+
self._optimizer = optimizer
|
|
163
|
+
self._test_command = test_command
|
|
164
|
+
self._auto_fix = auto_fix
|
|
165
|
+
self._max_fix_attempts = max_fix_attempts
|
|
166
|
+
|
|
167
|
+
async def run(self, context: PluginContext) -> dict[str, Any]:
|
|
168
|
+
from forgecli.build import BuildContext, BuildPipeline
|
|
169
|
+
|
|
170
|
+
app_context = context.app_context
|
|
171
|
+
target = Path.cwd()
|
|
172
|
+
decision = self._resolve_decision(app_context)
|
|
173
|
+
|
|
174
|
+
build_context = BuildContext(prompt=context.prompt, root=target, decision=decision)
|
|
175
|
+
build_context.extras.update(context.extras.get("build_extras", {}))
|
|
176
|
+
build_context.extras["provider"] = self._provider
|
|
177
|
+
use_graphify = self._graphify is not None and needs_repository_context(context.prompt)
|
|
178
|
+
if use_graphify:
|
|
179
|
+
build_context.extras["graph"] = self._graphify
|
|
180
|
+
if self._optimizer is not None:
|
|
181
|
+
build_context.extras["optimizer"] = self._optimizer
|
|
182
|
+
if self._test_command is not None:
|
|
183
|
+
build_context.extras["test_command"] = self._test_command
|
|
184
|
+
|
|
185
|
+
stages: list[tuple[str, Any]] = [
|
|
186
|
+
("llm", llm_call),
|
|
187
|
+
("diff-extract", diff_extraction),
|
|
188
|
+
("apply-diff", apply_diff),
|
|
189
|
+
("run-tests", _run_tests),
|
|
190
|
+
("summarize", summarize),
|
|
191
|
+
]
|
|
192
|
+
if use_graphify:
|
|
193
|
+
stages.insert(0, ("graphify-retrieval", graphify_retrieval))
|
|
194
|
+
pipeline = BuildPipeline(stages)
|
|
195
|
+
result = await pipeline.run(build_context)
|
|
196
|
+
|
|
197
|
+
if (
|
|
198
|
+
self._auto_fix
|
|
199
|
+
and result.context.test_returncode not in (0, None)
|
|
200
|
+
and result.context.applied_files
|
|
201
|
+
):
|
|
202
|
+
fixed = await self._auto_fix_loop(target, build_context, result)
|
|
203
|
+
if fixed:
|
|
204
|
+
result = fixed
|
|
205
|
+
|
|
206
|
+
await summarize(build_context)
|
|
207
|
+
return {
|
|
208
|
+
"summary": build_context.summary,
|
|
209
|
+
"files_touched": build_context.applied_files,
|
|
210
|
+
"diff": build_context.diff_text,
|
|
211
|
+
"decision": decision,
|
|
212
|
+
"stages": [
|
|
213
|
+
{
|
|
214
|
+
"name": stage.name,
|
|
215
|
+
"status": stage.status.value,
|
|
216
|
+
"duration_seconds": stage.duration_seconds,
|
|
217
|
+
"error": stage.error,
|
|
218
|
+
}
|
|
219
|
+
for stage in build_context.stages
|
|
220
|
+
],
|
|
221
|
+
"result": result,
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
def _resolve_decision(self, app_context: AppContext):
|
|
225
|
+
from forgecli.providers.router import SelectionMode
|
|
226
|
+
|
|
227
|
+
state = load_state(app_context.paths.data_dir / "router.json")
|
|
228
|
+
router = ModelRouter()
|
|
229
|
+
decision = router.select(state.choice)
|
|
230
|
+
if isinstance(self._provider, MockProvider):
|
|
231
|
+
return type(decision)(
|
|
232
|
+
provider_name=self._provider.name,
|
|
233
|
+
model=decision.model,
|
|
234
|
+
mode=SelectionMode.FALLBACK,
|
|
235
|
+
cost_in=0.0,
|
|
236
|
+
cost_out=0.0,
|
|
237
|
+
)
|
|
238
|
+
return decision
|
|
239
|
+
|
|
240
|
+
async def _auto_fix_loop(self, target: Path, build_context, last_result):
|
|
241
|
+
"""Re-run the LLM stage when tests fail, up to ``max_fix_attempts``."""
|
|
242
|
+
from forgecli.build import BuildPipeline
|
|
243
|
+
|
|
244
|
+
for _ in range(self._max_fix_attempts):
|
|
245
|
+
# Build a focused "fix the tests" prompt and re-run the
|
|
246
|
+
# LLM/diff/apply/test stages in a tiny pipeline.
|
|
247
|
+
from forgecli.build.diff_extract import diff_extraction
|
|
248
|
+
from forgecli.build.llm import llm_call
|
|
249
|
+
from forgecli.build.summarize import summarize
|
|
250
|
+
|
|
251
|
+
fix_prompt = (
|
|
252
|
+
f"Tests failed:\n\n{build_context.test_stderr[-2000:]}\n\n"
|
|
253
|
+
f"Original task: {build_context.prompt}\n\n"
|
|
254
|
+
"Return a unified diff that fixes the failing tests. "
|
|
255
|
+
"Do not change unrelated code."
|
|
256
|
+
)
|
|
257
|
+
fix_context = type(build_context)(
|
|
258
|
+
prompt=fix_prompt, root=build_context.root, decision=build_context.decision
|
|
259
|
+
)
|
|
260
|
+
fix_context.extras.update(build_context.extras)
|
|
261
|
+
fix_context.extras["retries"] = 0
|
|
262
|
+
fix_pipeline = BuildPipeline(
|
|
263
|
+
[
|
|
264
|
+
("llm", llm_call),
|
|
265
|
+
("diff-extract", diff_extraction),
|
|
266
|
+
("apply-diff", apply_diff),
|
|
267
|
+
("run-tests", _run_tests),
|
|
268
|
+
("summarize", summarize),
|
|
269
|
+
]
|
|
270
|
+
)
|
|
271
|
+
new_result = await fix_pipeline.run(fix_context)
|
|
272
|
+
if new_result.context.test_returncode == 0:
|
|
273
|
+
return new_result
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ---------------------------------------------------------------------------
|
|
278
|
+
# Auto-fix test runner (re-uses the build test stage but is callable)
|
|
279
|
+
# ---------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
async def _run_tests(context):
|
|
283
|
+
from forgecli.build.test_run import run_tests as _impl
|
|
284
|
+
|
|
285
|
+
return await _impl(context)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# ---------------------------------------------------------------------------
|
|
289
|
+
# Orchestrator
|
|
290
|
+
# ---------------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class Orchestrator:
|
|
294
|
+
"""Top-level orchestrator for ``forge "<prompt>"``."""
|
|
295
|
+
|
|
296
|
+
def __init__(
|
|
297
|
+
self,
|
|
298
|
+
registry: PluginRegistry,
|
|
299
|
+
*,
|
|
300
|
+
provider: Provider,
|
|
301
|
+
decision: Any | None = None,
|
|
302
|
+
workflow_factory: Callable[[Intent], Workflow] | None = None,
|
|
303
|
+
classifier: IntentClassifier | None = None,
|
|
304
|
+
) -> None:
|
|
305
|
+
self._registry = registry
|
|
306
|
+
self._provider = provider
|
|
307
|
+
self._decision = decision
|
|
308
|
+
self._workflow_factory = workflow_factory or _default_workflow_factory
|
|
309
|
+
self._classifier = classifier or HeuristicIntentClassifier()
|
|
310
|
+
if self._classifier not in self._registry.classifiers:
|
|
311
|
+
self._registry.register_classifier(self._classifier)
|
|
312
|
+
|
|
313
|
+
@property
|
|
314
|
+
def registry(self) -> PluginRegistry:
|
|
315
|
+
return self._registry
|
|
316
|
+
|
|
317
|
+
async def run(self, prompt: str, *, intent: Intent | None = None) -> ForgeResult:
|
|
318
|
+
started = time.perf_counter()
|
|
319
|
+
try:
|
|
320
|
+
if intent is not None:
|
|
321
|
+
prediction = IntentPrediction(intent, 1.0, ("forced intent",))
|
|
322
|
+
else:
|
|
323
|
+
prediction = self._classify(prompt)
|
|
324
|
+
workflow = self._workflow_for(prediction)
|
|
325
|
+
|
|
326
|
+
app_ctx = _bootstrap_app_context()
|
|
327
|
+
from forgecli.graph.repository import RepositoryGraph
|
|
328
|
+
|
|
329
|
+
opt = None
|
|
330
|
+
if app_ctx.container.has(prompt_optimizer_cls):
|
|
331
|
+
opt = app_ctx.container.resolve(prompt_optimizer_cls) # type: ignore[type-abstract]
|
|
332
|
+
|
|
333
|
+
graph = None
|
|
334
|
+
if app_ctx.container.has(RepositoryGraph) and needs_repository_context(prompt):
|
|
335
|
+
graph = app_ctx.container.resolve(RepositoryGraph) # type: ignore[type-abstract]
|
|
336
|
+
|
|
337
|
+
build_extras = {
|
|
338
|
+
"provider": self._provider,
|
|
339
|
+
"optimizer": opt,
|
|
340
|
+
"graph": graph,
|
|
341
|
+
"intent": prediction.intent,
|
|
342
|
+
"decision": self._decision,
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
plugin_context = PluginContext(
|
|
346
|
+
app_context=app_ctx,
|
|
347
|
+
prompt=prompt,
|
|
348
|
+
intent=prediction.intent,
|
|
349
|
+
extras={
|
|
350
|
+
"prediction": prediction,
|
|
351
|
+
"build_extras": build_extras,
|
|
352
|
+
},
|
|
353
|
+
)
|
|
354
|
+
payload = await workflow.run(plugin_context)
|
|
355
|
+
except Exception:
|
|
356
|
+
return ForgeResult(
|
|
357
|
+
success=False,
|
|
358
|
+
intent=Intent.UNKNOWN,
|
|
359
|
+
workflow="(error)",
|
|
360
|
+
duration_seconds=time.perf_counter() - started,
|
|
361
|
+
summary="",
|
|
362
|
+
error=traceback.format_exc(),
|
|
363
|
+
)
|
|
364
|
+
duration = time.perf_counter() - started
|
|
365
|
+
return ForgeResult(
|
|
366
|
+
success=True,
|
|
367
|
+
intent=prediction.intent,
|
|
368
|
+
workflow=workflow.name,
|
|
369
|
+
duration_seconds=duration,
|
|
370
|
+
summary=str(payload.get("summary", "")),
|
|
371
|
+
files_touched=list(payload.get("files_touched") or []),
|
|
372
|
+
diff=str(payload.get("diff") or ""),
|
|
373
|
+
stages=list(payload.get("stages") or []),
|
|
374
|
+
extras=payload,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
def _classify(self, prompt: str) -> IntentPrediction:
|
|
378
|
+
for classifier in self._registry.classifiers_sorted():
|
|
379
|
+
try:
|
|
380
|
+
prediction = classifier.classify(prompt)
|
|
381
|
+
except Exception:
|
|
382
|
+
continue
|
|
383
|
+
if prediction.intent is not Intent.UNKNOWN:
|
|
384
|
+
return prediction
|
|
385
|
+
return IntentPrediction(Intent.BUILD, 0.5, ("fallback to build",))
|
|
386
|
+
|
|
387
|
+
def _workflow_for(self, prediction: IntentPrediction) -> Workflow:
|
|
388
|
+
for workflow in self._registry.workflows:
|
|
389
|
+
if workflow.can_handle(prediction.intent, (prediction.rationale and " ") or ""):
|
|
390
|
+
return workflow
|
|
391
|
+
return self._workflow_factory(prediction.intent)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _default_workflow_factory(intent: Intent) -> Workflow:
|
|
395
|
+
"""Pick a default workflow for the given intent."""
|
|
396
|
+
if intent is Intent.ASK:
|
|
397
|
+
return AskWorkflow()
|
|
398
|
+
if intent is Intent.PLAN:
|
|
399
|
+
return PlanWorkflow()
|
|
400
|
+
if intent is Intent.DOCS:
|
|
401
|
+
return DocsWorkflow()
|
|
402
|
+
if intent is Intent.REVIEW:
|
|
403
|
+
return ReviewWorkflow()
|
|
404
|
+
if intent is Intent.EXPLAIN:
|
|
405
|
+
return ExplainWorkflow()
|
|
406
|
+
# BUILD and UNKNOWN fall through to the heavy pipeline.
|
|
407
|
+
from forgecli.providers.mock import MockProvider
|
|
408
|
+
|
|
409
|
+
return BuildWorkflow(provider=MockProvider(MockProviderConfig()))
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# ---------------------------------------------------------------------------
|
|
413
|
+
# Minimal built-in workflows
|
|
414
|
+
# ---------------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _asks_for_repo_context(prompt: str) -> bool:
|
|
418
|
+
return needs_repository_context(prompt)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
class AskWorkflow(Workflow):
|
|
422
|
+
"""A conversational Q&A workflow against the project graph."""
|
|
423
|
+
|
|
424
|
+
name = "ask"
|
|
425
|
+
intents = (Intent.ASK,)
|
|
426
|
+
|
|
427
|
+
async def run(self, context: PluginContext) -> dict[str, Any]:
|
|
428
|
+
from forgecli.build import BuildContext, BuildPipeline
|
|
429
|
+
from forgecli.build.llm import llm_call
|
|
430
|
+
from forgecli.build.retrieval import graphify_retrieval
|
|
431
|
+
from forgecli.build.summarize import summarize
|
|
432
|
+
from forgecli.providers.conversation import is_greeting
|
|
433
|
+
|
|
434
|
+
decision = context.extras.get("build_extras", {}).get("decision")
|
|
435
|
+
build_context = BuildContext(prompt=context.prompt, root=Path.cwd(), decision=decision)
|
|
436
|
+
build_context.extras.update(context.extras.get("build_extras", {}))
|
|
437
|
+
|
|
438
|
+
stages: list[tuple[str, Any]] = []
|
|
439
|
+
if is_greeting(context.prompt):
|
|
440
|
+
stages.extend([("llm", llm_call), ("summarize", summarize)])
|
|
441
|
+
else:
|
|
442
|
+
if _asks_for_repo_context(context.prompt):
|
|
443
|
+
stages.append(("graphify-retrieval", graphify_retrieval))
|
|
444
|
+
stages.extend(
|
|
445
|
+
[
|
|
446
|
+
("llm", llm_call),
|
|
447
|
+
("summarize", summarize),
|
|
448
|
+
]
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
pipeline = BuildPipeline(stages)
|
|
452
|
+
result = await pipeline.run(build_context)
|
|
453
|
+
if not result.success:
|
|
454
|
+
err = "Pipeline stage failed"
|
|
455
|
+
for record in result.context.stages:
|
|
456
|
+
if record.error:
|
|
457
|
+
err = record.error
|
|
458
|
+
break
|
|
459
|
+
raise Exception(f"Stage '{result.failure_stage}' failed: {err}")
|
|
460
|
+
answer = result.context.response.message.content if result.context.response else ""
|
|
461
|
+
stage_rows = [
|
|
462
|
+
{
|
|
463
|
+
"name": record.name,
|
|
464
|
+
"status": record.status.value,
|
|
465
|
+
"duration_seconds": record.duration_seconds,
|
|
466
|
+
"error": record.error,
|
|
467
|
+
}
|
|
468
|
+
for record in result.context.stages
|
|
469
|
+
]
|
|
470
|
+
return {
|
|
471
|
+
"summary": answer,
|
|
472
|
+
"files_touched": [],
|
|
473
|
+
"diff": "",
|
|
474
|
+
"stages": stage_rows,
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
class PlanWorkflow(Workflow):
|
|
479
|
+
name = "plan"
|
|
480
|
+
intents = (Intent.PLAN,)
|
|
481
|
+
|
|
482
|
+
async def run(self, context: PluginContext) -> dict[str, Any]:
|
|
483
|
+
from forgecli.planner.render import render_plan
|
|
484
|
+
from forgecli.planner.software import PlannerOptions, build_software_plan
|
|
485
|
+
|
|
486
|
+
plan = build_software_plan(context.prompt, PlannerOptions())
|
|
487
|
+
renderables = render_plan(plan)
|
|
488
|
+
return {
|
|
489
|
+
"summary": plan.summary,
|
|
490
|
+
"plan": plan,
|
|
491
|
+
"renderables": renderables,
|
|
492
|
+
"files_touched": [],
|
|
493
|
+
"diff": "",
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
class DocsWorkflow(Workflow):
|
|
498
|
+
name = "docs"
|
|
499
|
+
intents = (Intent.DOCS,)
|
|
500
|
+
|
|
501
|
+
async def run(self, context: PluginContext) -> dict[str, Any]:
|
|
502
|
+
from forgecli.build import BuildContext, BuildPipeline
|
|
503
|
+
from forgecli.build.llm import llm_call
|
|
504
|
+
from forgecli.build.summarize import summarize
|
|
505
|
+
|
|
506
|
+
root = context.app_context.cwd
|
|
507
|
+
files_info = []
|
|
508
|
+
for path in sorted(root.rglob("*.py")):
|
|
509
|
+
if any(part.startswith(".") for part in path.parts):
|
|
510
|
+
continue
|
|
511
|
+
if any(part in {"__pycache__", "node_modules", ".venv", "venv"} for part in path.parts):
|
|
512
|
+
continue
|
|
513
|
+
files_info.append(str(path.relative_to(root)))
|
|
514
|
+
|
|
515
|
+
prompt = (
|
|
516
|
+
f"Generate a comprehensive overview documentation for the project '{root.name}'.\n"
|
|
517
|
+
f"Here are the main files in the project:\n"
|
|
518
|
+
+ "\n".join(f"- {f}" for f in files_info[:30])
|
|
519
|
+
+ "\n\n"
|
|
520
|
+
"Produce a clean, professional, and well-structured Markdown document containing:\n"
|
|
521
|
+
"1. Executive Summary of the project purpose.\n"
|
|
522
|
+
"2. High-level architecture overview.\n"
|
|
523
|
+
"3. Key modules and entry points."
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
decision = context.extras.get("build_extras", {}).get("decision")
|
|
527
|
+
build_context = BuildContext(prompt=prompt, root=Path.cwd(), decision=decision)
|
|
528
|
+
build_context.extras.update(context.extras.get("build_extras", {}))
|
|
529
|
+
|
|
530
|
+
pipeline = BuildPipeline(
|
|
531
|
+
[
|
|
532
|
+
("llm", llm_call),
|
|
533
|
+
("summarize", summarize),
|
|
534
|
+
]
|
|
535
|
+
)
|
|
536
|
+
result = await pipeline.run(build_context)
|
|
537
|
+
if not result.success:
|
|
538
|
+
err = "Pipeline stage failed"
|
|
539
|
+
for r in result.context.stages:
|
|
540
|
+
if r.error:
|
|
541
|
+
err = r.error
|
|
542
|
+
break
|
|
543
|
+
raise Exception(f"Stage '{result.failure_stage}' failed: {err}")
|
|
544
|
+
ai_docs = result.context.response.message.content if result.context.response else ""
|
|
545
|
+
|
|
546
|
+
output_dir = root / "docs"
|
|
547
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
548
|
+
target = output_dir / "OVERVIEW.md"
|
|
549
|
+
target.write_text(ai_docs, encoding="utf-8")
|
|
550
|
+
|
|
551
|
+
return {
|
|
552
|
+
"summary": f"Documentation written to {target}",
|
|
553
|
+
"files_touched": [target],
|
|
554
|
+
"diff": "",
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
class ReviewWorkflow(Workflow):
|
|
559
|
+
name = "review"
|
|
560
|
+
intents = (Intent.REVIEW,)
|
|
561
|
+
|
|
562
|
+
async def run(self, context: PluginContext) -> dict[str, Any]:
|
|
563
|
+
from forgecli.build import BuildContext, BuildPipeline
|
|
564
|
+
from forgecli.build.llm import llm_call
|
|
565
|
+
from forgecli.build.summarize import summarize
|
|
566
|
+
from forgecli.review import review_repository
|
|
567
|
+
|
|
568
|
+
review = review_repository(Path.cwd())
|
|
569
|
+
|
|
570
|
+
prompt = (
|
|
571
|
+
f"Review the code quality scan findings for the project:\n\n"
|
|
572
|
+
f"Findings: {len(review.findings)} total.\n"
|
|
573
|
+
f"Suggestions: {len(review.suggestions)} total.\n\n"
|
|
574
|
+
f"Summarize the findings and provide key quality improvement recommendations."
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
decision = context.extras.get("build_extras", {}).get("decision")
|
|
578
|
+
build_context = BuildContext(prompt=prompt, root=Path.cwd(), decision=decision)
|
|
579
|
+
build_context.extras.update(context.extras.get("build_extras", {}))
|
|
580
|
+
|
|
581
|
+
pipeline = BuildPipeline(
|
|
582
|
+
[
|
|
583
|
+
("llm", llm_call),
|
|
584
|
+
("summarize", summarize),
|
|
585
|
+
]
|
|
586
|
+
)
|
|
587
|
+
result = await pipeline.run(build_context)
|
|
588
|
+
if not result.success:
|
|
589
|
+
err = "Pipeline stage failed"
|
|
590
|
+
for r in result.context.stages:
|
|
591
|
+
if r.error:
|
|
592
|
+
err = r.error
|
|
593
|
+
break
|
|
594
|
+
raise Exception(f"Stage '{result.failure_stage}' failed: {err}")
|
|
595
|
+
ai_summary = result.context.response.message.content if result.context.response else ""
|
|
596
|
+
|
|
597
|
+
summary = (
|
|
598
|
+
f"Reviewed {review.stats.get('files', 0)} files; "
|
|
599
|
+
f"{len(review.findings)} findings.\n\n"
|
|
600
|
+
f"[bold]AI Summary & Recommendations:[/bold]\n{ai_summary}"
|
|
601
|
+
)
|
|
602
|
+
return {
|
|
603
|
+
"summary": summary,
|
|
604
|
+
"review": review,
|
|
605
|
+
"files_touched": [],
|
|
606
|
+
"diff": "",
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
class ExplainWorkflow(Workflow):
|
|
611
|
+
name = "explain"
|
|
612
|
+
intents = (Intent.EXPLAIN,)
|
|
613
|
+
|
|
614
|
+
async def run(self, context: PluginContext) -> dict[str, Any]:
|
|
615
|
+
from forgecli.build import BuildContext, BuildPipeline
|
|
616
|
+
from forgecli.build.llm import llm_call
|
|
617
|
+
from forgecli.build.retrieval import graphify_retrieval
|
|
618
|
+
from forgecli.build.summarize import summarize
|
|
619
|
+
|
|
620
|
+
prompt = f"Explain the node, file, or symbol: {context.prompt}"
|
|
621
|
+
decision = context.extras.get("build_extras", {}).get("decision")
|
|
622
|
+
build_context = BuildContext(prompt=prompt, root=Path.cwd(), decision=decision)
|
|
623
|
+
build_context.extras.update(context.extras.get("build_extras", {}))
|
|
624
|
+
|
|
625
|
+
pipeline = BuildPipeline(
|
|
626
|
+
[
|
|
627
|
+
("graphify-retrieval", graphify_retrieval),
|
|
628
|
+
("llm", llm_call),
|
|
629
|
+
("summarize", summarize),
|
|
630
|
+
]
|
|
631
|
+
)
|
|
632
|
+
result = await pipeline.run(build_context)
|
|
633
|
+
if not result.success:
|
|
634
|
+
err = "Pipeline stage failed"
|
|
635
|
+
for r in result.context.stages:
|
|
636
|
+
if r.error:
|
|
637
|
+
err = r.error
|
|
638
|
+
break
|
|
639
|
+
raise Exception(f"Stage '{result.failure_stage}' failed: {err}")
|
|
640
|
+
explanation = result.context.response.message.content if result.context.response else ""
|
|
641
|
+
return {"summary": explanation, "files_touched": [], "diff": ""}
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
# ---------------------------------------------------------------------------
|
|
645
|
+
# Bootstrap helper
|
|
646
|
+
# ---------------------------------------------------------------------------
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _bootstrap_app_context() -> AppContext:
|
|
650
|
+
"""Best-effort bootstrap of an :class:`AppContext` for the orchestrator.
|
|
651
|
+
|
|
652
|
+
The orchestrator is runnable from a bare ``forge "<prompt>"`` call
|
|
653
|
+
without prior setup. If the bootstrap fails (e.g. no config file)
|
|
654
|
+
we fall back to a minimal in-memory context.
|
|
655
|
+
"""
|
|
656
|
+
from forgecli.cli.bootstrap import bootstrap_context
|
|
657
|
+
from forgecli.utils.paths import ProjectPaths
|
|
658
|
+
|
|
659
|
+
try:
|
|
660
|
+
return bootstrap_context()
|
|
661
|
+
except Exception:
|
|
662
|
+
from forgecli.config.loader import ConfigLoader
|
|
663
|
+
|
|
664
|
+
paths = ProjectPaths.from_env().ensure()
|
|
665
|
+
return AppContext(paths=paths, loader=ConfigLoader())
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
__all__ = [
|
|
669
|
+
"AskWorkflow",
|
|
670
|
+
"BuildWorkflow",
|
|
671
|
+
"DocsWorkflow",
|
|
672
|
+
"ExplainWorkflow",
|
|
673
|
+
"ForgeResult",
|
|
674
|
+
"HeuristicIntentClassifier",
|
|
675
|
+
"Orchestrator",
|
|
676
|
+
"PlanWorkflow",
|
|
677
|
+
"ReviewWorkflow",
|
|
678
|
+
"build_orchestrator",
|
|
679
|
+
]
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def build_orchestrator(
|
|
683
|
+
registry: PluginRegistry,
|
|
684
|
+
*,
|
|
685
|
+
provider: Provider,
|
|
686
|
+
decision: Any | None = None,
|
|
687
|
+
) -> Orchestrator:
|
|
688
|
+
"""Build a default :class:`Orchestrator` for the given registry + provider.
|
|
689
|
+
|
|
690
|
+
The function also wires the standard ForgeCLI workflows into the
|
|
691
|
+
registry if they are not already present. This is the composition
|
|
692
|
+
root for the top-level ``forge`` command.
|
|
693
|
+
"""
|
|
694
|
+
defaults: list[Workflow] = [
|
|
695
|
+
BuildWorkflow(provider=provider),
|
|
696
|
+
PlanWorkflow(),
|
|
697
|
+
AskWorkflow(),
|
|
698
|
+
DocsWorkflow(),
|
|
699
|
+
ReviewWorkflow(),
|
|
700
|
+
ExplainWorkflow(),
|
|
701
|
+
]
|
|
702
|
+
existing = {w.name for w in registry.workflows}
|
|
703
|
+
for workflow in defaults:
|
|
704
|
+
if workflow.name not in existing:
|
|
705
|
+
registry.register_workflow(workflow)
|
|
706
|
+
return Orchestrator(registry, provider=provider, decision=decision)
|