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/build/llm.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Stage 3 — LLM call.
|
|
2
|
+
|
|
3
|
+
Graphify retrieval as context) and dispatches it through the active
|
|
4
|
+
provider. The selected provider is the one chosen by the router (see
|
|
5
|
+
:mod:`forgecli.providers.router`).
|
|
6
|
+
|
|
7
|
+
The stage supports a small retry loop: when ``context.extras`` carries
|
|
8
|
+
a positive ``retries`` count, transient ``ProviderError`` exceptions
|
|
9
|
+
are retried with a short backoff. Non-transient errors (e.g. the
|
|
10
|
+
provider returns a 4xx) are surfaced immediately.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import logging
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from forgecli.build import BuildContext
|
|
20
|
+
from forgecli.core.errors import ProviderError
|
|
21
|
+
from forgecli.providers.base import (
|
|
22
|
+
ChatMessage,
|
|
23
|
+
ChatRequest,
|
|
24
|
+
Provider,
|
|
25
|
+
Role,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
_SYSTEM_PROMPT = (
|
|
29
|
+
" 1. Speculative need = skip it ().\n"
|
|
30
|
+
" 2. Reuse the helper/pattern that already lives in the codebase.\n"
|
|
31
|
+
" 3. Use the standard library.\n"
|
|
32
|
+
" 4. Native platform feature > third-party library.\n"
|
|
33
|
+
" 5. Use an already-installed dependency before adding a new one.\n"
|
|
34
|
+
" 6. One line beats many.\n"
|
|
35
|
+
" 7. Only then: the minimum code that works.\n\n"
|
|
36
|
+
"Return ONLY a unified diff in the response. No prose, no explanation, "
|
|
37
|
+
"no code fences. The diff must apply with `git apply`.\n"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
_TRANSIENT_HTTP_CODES = {408, 425, 429, 500, 502, 503, 504}
|
|
41
|
+
_BACKOFF_SECONDS = 1.5
|
|
42
|
+
_log = logging.getLogger("forgecli.build.llm")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def llm_call(context: BuildContext) -> BuildContext:
|
|
46
|
+
"""Send the assembled prompt to the active provider and store the response."""
|
|
47
|
+
provider: Provider | None = context.extras.get("provider")
|
|
48
|
+
if provider is None:
|
|
49
|
+
raise RuntimeError("no provider wired into the build context")
|
|
50
|
+
|
|
51
|
+
intent = context.extras.get("intent")
|
|
52
|
+
|
|
53
|
+
retries = int(context.extras.get("retries", 0) or 0)
|
|
54
|
+
user_content = _format_user_prompt(context)
|
|
55
|
+
base_request: ChatRequest = context.optimized_request or ChatRequest(
|
|
56
|
+
messages=[ChatMessage(role=Role.USER, content=context.prompt)]
|
|
57
|
+
)
|
|
58
|
+
request = base_request.model_copy(
|
|
59
|
+
update={
|
|
60
|
+
"model": context.decision.model if context.decision else None,
|
|
61
|
+
"messages": _assemble_messages(base_request.messages, user_content, intent),
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
last_exc: Exception | None = None
|
|
66
|
+
for attempt in range(retries + 1):
|
|
67
|
+
try:
|
|
68
|
+
response = await provider.chat(request)
|
|
69
|
+
context.response = response
|
|
70
|
+
return context
|
|
71
|
+
except ProviderError as exc:
|
|
72
|
+
last_exc = exc
|
|
73
|
+
if not _is_transient(exc) or attempt >= retries:
|
|
74
|
+
raise
|
|
75
|
+
wait = _BACKOFF_SECONDS * (attempt + 1)
|
|
76
|
+
_log.warning(
|
|
77
|
+
"transient provider error (attempt %d/%d): %s; retrying in %.1fs",
|
|
78
|
+
attempt + 1,
|
|
79
|
+
retries + 1,
|
|
80
|
+
exc,
|
|
81
|
+
wait,
|
|
82
|
+
)
|
|
83
|
+
await asyncio.sleep(wait)
|
|
84
|
+
# Unreachable, but keep mypy happy.
|
|
85
|
+
raise last_exc if last_exc else RuntimeError("llm_call: no attempts made")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _is_transient(exc: ProviderError) -> bool:
|
|
89
|
+
"""Return True if ``exc`` looks like a transient HTTP failure."""
|
|
90
|
+
message = str(exc)
|
|
91
|
+
for code in _TRANSIENT_HTTP_CODES:
|
|
92
|
+
if f"({code})" in message:
|
|
93
|
+
return True
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _format_user_prompt(context: BuildContext) -> str:
|
|
98
|
+
parts: list[str] = [context.prompt]
|
|
99
|
+
if context.retrieval:
|
|
100
|
+
parts.append("\n" + context.retrieval)
|
|
101
|
+
intent = context.extras.get("intent")
|
|
102
|
+
from forgecli.plugins import Intent
|
|
103
|
+
|
|
104
|
+
if intent in (None, Intent.BUILD, Intent.UNKNOWN, "build"):
|
|
105
|
+
parts.append(
|
|
106
|
+
"\nGenerate code that exactly matches the request above: use the "
|
|
107
|
+
"language, filename(s), framework, approximate size, and behavior "
|
|
108
|
+
"the user asked for. "
|
|
109
|
+
"Respond with a unified diff only that applies with `git apply`. "
|
|
110
|
+
"Do not include prose, explanations, or markdown fences."
|
|
111
|
+
)
|
|
112
|
+
return "\n".join(parts)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _assemble_messages(
|
|
116
|
+
base: list[ChatMessage], user_content: str, intent: Any
|
|
117
|
+
) -> list[ChatMessage]:
|
|
118
|
+
"""Insert the system prompt at the head, replace the user message.
|
|
119
|
+
|
|
120
|
+
instructions on top by *replacing* the first user message with our
|
|
121
|
+
assembled content.
|
|
122
|
+
"""
|
|
123
|
+
out: list[ChatMessage] = []
|
|
124
|
+
replaced_user = False
|
|
125
|
+
has_system = any(m.role is Role.SYSTEM for m in base)
|
|
126
|
+
|
|
127
|
+
if not has_system:
|
|
128
|
+
if intent is None or intent == "build":
|
|
129
|
+
out.append(ChatMessage(role=Role.SYSTEM, content=_SYSTEM_PROMPT))
|
|
130
|
+
else:
|
|
131
|
+
out.append(ChatMessage(role=Role.SYSTEM, content=(
|
|
132
|
+
"You are a senior software engineer. "
|
|
133
|
+
"Answer the user's query or perform the request clearly and concisely in natural language / Markdown. "
|
|
134
|
+
"Use the codebase context provided if relevant. "
|
|
135
|
+
)))
|
|
136
|
+
|
|
137
|
+
for message in base:
|
|
138
|
+
if not replaced_user and message.role is Role.USER:
|
|
139
|
+
out.append(ChatMessage(role=Role.USER, content=user_content))
|
|
140
|
+
replaced_user = True
|
|
141
|
+
continue
|
|
142
|
+
if message.role is Role.SYSTEM:
|
|
143
|
+
if intent is None or intent == "build":
|
|
144
|
+
extra = "\n\n" + _SYSTEM_PROMPT
|
|
145
|
+
else:
|
|
146
|
+
extra = (
|
|
147
|
+
"\n\nAnswer the user's query or perform the request clearly and concisely in natural language / Markdown. "
|
|
148
|
+
"Use the codebase context provided if relevant. "
|
|
149
|
+
)
|
|
150
|
+
out.append(
|
|
151
|
+
ChatMessage(
|
|
152
|
+
role=Role.SYSTEM,
|
|
153
|
+
content=message.content + extra,
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
continue
|
|
157
|
+
out.append(message)
|
|
158
|
+
if not replaced_user:
|
|
159
|
+
out.append(ChatMessage(role=Role.USER, content=user_content))
|
|
160
|
+
return out
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
__all__ = ["llm_call"]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# Silence unused-import warnings for symbols only used in some branches.
|
|
167
|
+
_ = asyncio
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Stage 2 — Ponytail optimization.
|
|
2
|
+
|
|
3
|
+
Wraps the user prompt with the configured :class:`PromptOptimizer`
|
|
4
|
+
(default: :class:`PonytailRulesetOptimizer`). The optimized request is
|
|
5
|
+
re-used by the LLM stage; the ruleset's notes are propagated to the
|
|
6
|
+
final summary.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from forgecli.build import BuildContext
|
|
12
|
+
from forgecli.optimizer.ponytail import PromptOptimizer
|
|
13
|
+
from forgecli.providers.base import ChatMessage, ChatRequest, Role
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def ponytail_optimization(context: BuildContext) -> BuildContext:
|
|
17
|
+
"""Run the configured :class:`PromptOptimizer` and stash the result."""
|
|
18
|
+
optimizer: PromptOptimizer | None = context.extras.get("optimizer")
|
|
19
|
+
if optimizer is None:
|
|
20
|
+
# No optimizer wired: produce a minimal request so downstream stages
|
|
21
|
+
# always have something to consume.
|
|
22
|
+
context.optimized_request = ChatRequest(
|
|
23
|
+
messages=[ChatMessage(role=Role.USER, content=context.prompt)],
|
|
24
|
+
)
|
|
25
|
+
context.optimized_notes = ()
|
|
26
|
+
return context
|
|
27
|
+
|
|
28
|
+
request = ChatRequest(
|
|
29
|
+
messages=[ChatMessage(role=Role.USER, content=context.prompt)],
|
|
30
|
+
)
|
|
31
|
+
optimized = await optimizer.optimize_chat(request)
|
|
32
|
+
context.optimized_request = optimized.request
|
|
33
|
+
context.optimized_notes = optimized.notes
|
|
34
|
+
return context
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
__all__ = ["ponytail_optimization"]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Default pipeline composition."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from forgecli.build import (
|
|
9
|
+
BuildContext,
|
|
10
|
+
BuildPipeline,
|
|
11
|
+
PipelineStage,
|
|
12
|
+
)
|
|
13
|
+
from forgecli.build.apply import apply_diff
|
|
14
|
+
from forgecli.build.diff_extract import diff_extraction
|
|
15
|
+
from forgecli.build.llm import llm_call
|
|
16
|
+
from forgecli.build.optimize import ponytail_optimization
|
|
17
|
+
from forgecli.build.retrieval import graphify_retrieval
|
|
18
|
+
from forgecli.build.summarize import summarize
|
|
19
|
+
from forgecli.build.test_run import run_tests
|
|
20
|
+
from forgecli.optimizer.ponytail import PromptOptimizer
|
|
21
|
+
from forgecli.providers.base import Provider
|
|
22
|
+
from forgecli.providers.router import ModelRouter
|
|
23
|
+
from forgecli.providers.router_state import RouterState, load_state
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def default_pipeline(
|
|
27
|
+
*,
|
|
28
|
+
provider: Provider,
|
|
29
|
+
optimizer: PromptOptimizer | None,
|
|
30
|
+
graph: Any | None = None,
|
|
31
|
+
test_command: str | None = None,
|
|
32
|
+
test_timeout: float = 120.0,
|
|
33
|
+
skip_tests: bool = False,
|
|
34
|
+
) -> BuildPipeline:
|
|
35
|
+
"""Construct the canonical pipeline."""
|
|
36
|
+
stages: list[tuple[str, PipelineStage]] = [
|
|
37
|
+
("graphify-retrieval", _stage_with(graphify_retrieval, graph=graph)),
|
|
38
|
+
("ponytail-optimize", _stage_with(ponytail_optimization, optimizer=optimizer)),
|
|
39
|
+
("llm", _stage_with(llm_call, provider=provider)),
|
|
40
|
+
("diff-extract", diff_extraction),
|
|
41
|
+
("apply-diff", apply_diff),
|
|
42
|
+
]
|
|
43
|
+
if not skip_tests:
|
|
44
|
+
stages.append(("run-tests", _stage_with(run_tests, test_command=test_command, test_timeout=test_timeout)))
|
|
45
|
+
stages.append(("summarize", summarize))
|
|
46
|
+
return BuildPipeline(stages)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _stage_with(
|
|
50
|
+
stage: PipelineStage, **extras: Any
|
|
51
|
+
) -> PipelineStage:
|
|
52
|
+
"""Bind extras into a stage by closing over them."""
|
|
53
|
+
async def _wrapped(context: BuildContext) -> BuildContext:
|
|
54
|
+
context.extras.update(extras)
|
|
55
|
+
return await stage(context)
|
|
56
|
+
return _wrapped
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_context_from(
|
|
60
|
+
prompt: str,
|
|
61
|
+
*,
|
|
62
|
+
root: Path,
|
|
63
|
+
router: ModelRouter | None = None,
|
|
64
|
+
state: RouterState | None = None,
|
|
65
|
+
) -> BuildContext:
|
|
66
|
+
"""Build a :class:`BuildContext` with the router's decision pre-resolved."""
|
|
67
|
+
state = state or RouterState()
|
|
68
|
+
decision = (router or ModelRouter()).select(state.choice)
|
|
69
|
+
return BuildContext(prompt=prompt, root=root, decision=decision)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
__all__ = ["build_context_from", "default_pipeline"]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# Re-export helper for callers that want to load the persisted state too.
|
|
76
|
+
_ = load_state
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Stage 1 — Graphify retrieval.
|
|
2
|
+
|
|
3
|
+
Asks the configured :class:`RepositoryGraph` to find nodes relevant to
|
|
4
|
+
the user prompt. The output is a compact text block listing the
|
|
5
|
+
matching node labels, files, and edges, which is fed into the LLM as
|
|
6
|
+
context in :mod:`forgecli.build.llm`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from forgecli.build import BuildContext
|
|
15
|
+
from forgecli.graph.repository import GraphNode, GraphSnapshot, RepositoryGraph
|
|
16
|
+
|
|
17
|
+
_TOKEN_RE = re.compile(r"[A-Za-z0-9_./-]+")
|
|
18
|
+
_FILE_LIKE_RE = re.compile(r"^[\w./-]+\.[A-Za-z0-9]+$")
|
|
19
|
+
_REPO_HINTS = (
|
|
20
|
+
"project",
|
|
21
|
+
"repository",
|
|
22
|
+
"repo",
|
|
23
|
+
"code",
|
|
24
|
+
"file",
|
|
25
|
+
"files",
|
|
26
|
+
"architecture",
|
|
27
|
+
"implementation",
|
|
28
|
+
"bug",
|
|
29
|
+
"bugs",
|
|
30
|
+
"docs",
|
|
31
|
+
"documentation",
|
|
32
|
+
"structure",
|
|
33
|
+
"folder",
|
|
34
|
+
"directory",
|
|
35
|
+
"dir",
|
|
36
|
+
"function",
|
|
37
|
+
"method",
|
|
38
|
+
"class",
|
|
39
|
+
"module",
|
|
40
|
+
"current",
|
|
41
|
+
"this",
|
|
42
|
+
"here",
|
|
43
|
+
)
|
|
44
|
+
_STANDALONE_HINTS = (
|
|
45
|
+
"10 lines",
|
|
46
|
+
"hello world",
|
|
47
|
+
"landing page",
|
|
48
|
+
"button",
|
|
49
|
+
"snippet",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
_RETRIEVAL_CACHE: dict[tuple[int, str, int], str] = {}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def needs_repository_context(prompt: str) -> bool:
|
|
56
|
+
"""Return True when the prompt likely needs repository context."""
|
|
57
|
+
text = (prompt or "").strip().lower()
|
|
58
|
+
if not text:
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
words = set(_TOKEN_RE.findall(text))
|
|
62
|
+
if len(words) <= 4 and any(word in {"hi", "hello", "hey", "howdy", "greetings"} for word in words):
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
if any(hint in text for hint in _STANDALONE_HINTS):
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
if any(ext in text for ext in (".py", ".ts", ".tsx", ".js", ".jsx", ".json", ".md", ".html", ".css", ".yml", ".yaml", ".toml", "package.json")):
|
|
69
|
+
return True
|
|
70
|
+
|
|
71
|
+
if any(hint in words for hint in _REPO_HINTS):
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
return "what is this" in text or "explain this" in text or "this project" in text
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def graphify_retrieval(
|
|
78
|
+
context: BuildContext, *, top_k: int = 8
|
|
79
|
+
) -> BuildContext:
|
|
80
|
+
"""Return a context with ``context.retrieval`` populated."""
|
|
81
|
+
graph: RepositoryGraph | None = context.extras.get("graph")
|
|
82
|
+
if graph is None or not needs_repository_context(context.prompt):
|
|
83
|
+
context.retrieval = ""
|
|
84
|
+
return context
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
snapshot = await graph.load()
|
|
88
|
+
except Exception as exc:
|
|
89
|
+
context.retrieval = f"[graph: failed to load ({exc!r})]"
|
|
90
|
+
return context
|
|
91
|
+
|
|
92
|
+
cache_key = (id(snapshot), context.prompt.strip().lower(), top_k)
|
|
93
|
+
cached = _RETRIEVAL_CACHE.get(cache_key)
|
|
94
|
+
if cached is not None:
|
|
95
|
+
context.retrieval = cached
|
|
96
|
+
return context
|
|
97
|
+
|
|
98
|
+
matches = _rank_nodes(snapshot, context.prompt, limit=top_k)
|
|
99
|
+
if not matches:
|
|
100
|
+
context.retrieval = "[graph: no matches]"
|
|
101
|
+
return context
|
|
102
|
+
|
|
103
|
+
lines = ["[graph retrieval]"]
|
|
104
|
+
for node, _score in matches:
|
|
105
|
+
location = (
|
|
106
|
+
f" ({node.source_file}:{node.source_location})"
|
|
107
|
+
if node.source_file
|
|
108
|
+
else ""
|
|
109
|
+
)
|
|
110
|
+
lines.append(f"- {node.label}{location}")
|
|
111
|
+
context.retrieval = "\n".join(lines)
|
|
112
|
+
_RETRIEVAL_CACHE[cache_key] = context.retrieval
|
|
113
|
+
return context
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _rank_nodes(
|
|
117
|
+
snapshot: GraphSnapshot, query: str, *, limit: int
|
|
118
|
+
) -> list[tuple[GraphNode, int]]:
|
|
119
|
+
"""Return the top ``limit`` nodes most relevant to ``query``.
|
|
120
|
+
|
|
121
|
+
Scoring is intentionally simple and deterministic: each token in
|
|
122
|
+
``query`` contributes one point for every node whose label or
|
|
123
|
+
source file contains the token as a substring. Tokens that look
|
|
124
|
+
like filenames (``foo.py``, ``auth/bar.ts``) get a bonus when
|
|
125
|
+
they match a node's ``source_file`` exactly.
|
|
126
|
+
"""
|
|
127
|
+
tokens = [tok.lower() for tok in _TOKEN_RE.findall(query or "")]
|
|
128
|
+
if not tokens:
|
|
129
|
+
return []
|
|
130
|
+
scored: list[tuple[GraphNode, int]] = []
|
|
131
|
+
for node in snapshot.nodes:
|
|
132
|
+
label = (node.label or "").lower()
|
|
133
|
+
source = (node.source_file or "").lower()
|
|
134
|
+
if not label and not source:
|
|
135
|
+
continue
|
|
136
|
+
score = 0
|
|
137
|
+
for token in tokens:
|
|
138
|
+
if token and token in label:
|
|
139
|
+
score += 1
|
|
140
|
+
if token and token in source:
|
|
141
|
+
score += 1
|
|
142
|
+
if (
|
|
143
|
+
_FILE_LIKE_RE.match(token)
|
|
144
|
+
and node.source_file
|
|
145
|
+
and token == node.source_file.lower()
|
|
146
|
+
):
|
|
147
|
+
score += 3
|
|
148
|
+
if score > 0:
|
|
149
|
+
scored.append((node, score))
|
|
150
|
+
scored.sort(key=lambda pair: (pair[1], pair[0].label), reverse=True)
|
|
151
|
+
return scored[:limit]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
__all__ = ["graphify_retrieval", "needs_repository_context"]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
_ = Any # keep typing.Any referenced for the test-only type hints
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Stage 7 — summarize.
|
|
2
|
+
|
|
3
|
+
Composes a short human-readable summary of the run. The summary is
|
|
4
|
+
shown in the terminal and stored in ``context.summary``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from forgecli.build import BuildContext, BuildResult, StageStatus
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_summary(context: BuildContext) -> str:
|
|
15
|
+
"""Return a multi-line summary of the run."""
|
|
16
|
+
lines: list[str] = []
|
|
17
|
+
lines.append(f"Goal: {context.prompt}")
|
|
18
|
+
if context.decision is not None:
|
|
19
|
+
d = context.decision
|
|
20
|
+
lines.append(
|
|
21
|
+
f"Route: {d.provider_name}/{d.model} "
|
|
22
|
+
f"(mode={d.mode.value}, "
|
|
23
|
+
f"in=${d.cost_in:.5f}/1k, out=${d.cost_out:.5f}/1k)"
|
|
24
|
+
)
|
|
25
|
+
if context.applied_files:
|
|
26
|
+
rel = [str(p.relative_to(context.root)) for p in context.applied_files]
|
|
27
|
+
lines.append(f"Files touched ({len(rel)}):")
|
|
28
|
+
for path in rel:
|
|
29
|
+
lines.append(f" - {path}")
|
|
30
|
+
else:
|
|
31
|
+
lines.append("Files touched: (none)")
|
|
32
|
+
if context.test_returncode is None:
|
|
33
|
+
lines.append("Tests: skipped (no test runner available)")
|
|
34
|
+
elif context.test_returncode == 0:
|
|
35
|
+
lines.append("Tests: passed")
|
|
36
|
+
else:
|
|
37
|
+
lines.append(f"Tests: FAILED (exit {context.test_returncode})")
|
|
38
|
+
if context.test_stderr.strip():
|
|
39
|
+
lines.append("--- stderr ---")
|
|
40
|
+
lines.append(context.test_stderr.strip())
|
|
41
|
+
total = sum(s.duration_seconds or 0.0 for s in context.stages)
|
|
42
|
+
lines.append(f"Total time: {total:.2f}s across {len(context.stages)} stages")
|
|
43
|
+
return "\n".join(lines)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def summarize(context: BuildContext) -> BuildContext:
|
|
47
|
+
"""Populate ``context.summary`` with a human-readable overview."""
|
|
48
|
+
context.summary = build_summary(context)
|
|
49
|
+
return context
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def result_to_dict(result: BuildResult) -> dict[str, object]:
|
|
53
|
+
"""Return a JSON-serializable view of the result (for --json output)."""
|
|
54
|
+
return {
|
|
55
|
+
"success": result.success,
|
|
56
|
+
"failure_stage": result.failure_stage,
|
|
57
|
+
"summary": result.context.summary,
|
|
58
|
+
"applied_files": [str(p) for p in result.context.applied_files],
|
|
59
|
+
"test_returncode": result.context.test_returncode,
|
|
60
|
+
"stages": [
|
|
61
|
+
{
|
|
62
|
+
"name": s.name,
|
|
63
|
+
"status": s.status.value,
|
|
64
|
+
"duration_seconds": s.duration_seconds,
|
|
65
|
+
"notes": list(s.notes),
|
|
66
|
+
"error": s.error,
|
|
67
|
+
}
|
|
68
|
+
for s in result.context.stages
|
|
69
|
+
],
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
__all__ = ["build_summary", "result_to_dict", "summarize"]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# Silence unused-import warnings.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Stage 6 — run tests.
|
|
2
|
+
|
|
3
|
+
Runs an optional test command (default: ``pytest -q``) under the
|
|
4
|
+
project root and captures stdout/stderr/return code. The build does
|
|
5
|
+
not fail when tests fail — failures are recorded in the context and
|
|
6
|
+
surfaced in the summary.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from forgecli.build import BuildContext
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
DEFAULT_TEST_COMMAND = "pytest -q"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def run_tests(context: BuildContext) -> BuildContext:
|
|
24
|
+
"""Execute the test command and record the outcome."""
|
|
25
|
+
command = context.extras.get("test_command") or DEFAULT_TEST_COMMAND
|
|
26
|
+
if not shutil.which(command.split()[0]):
|
|
27
|
+
context.test_stdout = ""
|
|
28
|
+
context.test_stderr = f"`{command.split()[0]}` not found on PATH; skipping tests."
|
|
29
|
+
context.test_returncode = None
|
|
30
|
+
return context
|
|
31
|
+
|
|
32
|
+
proc = await asyncio.create_subprocess_shell(
|
|
33
|
+
command,
|
|
34
|
+
cwd=str(context.root),
|
|
35
|
+
stdout=asyncio.subprocess.PIPE,
|
|
36
|
+
stderr=asyncio.subprocess.PIPE,
|
|
37
|
+
env=os.environ.copy(),
|
|
38
|
+
)
|
|
39
|
+
try:
|
|
40
|
+
stdout, stderr = await asyncio.wait_for(
|
|
41
|
+
proc.communicate(), timeout=_timeout_for(context)
|
|
42
|
+
)
|
|
43
|
+
except asyncio.TimeoutError:
|
|
44
|
+
proc.kill()
|
|
45
|
+
context.test_stdout = ""
|
|
46
|
+
context.test_stderr = "tests timed out"
|
|
47
|
+
context.test_returncode = 124
|
|
48
|
+
return context
|
|
49
|
+
context.test_stdout = stdout.decode(errors="replace")
|
|
50
|
+
context.test_stderr = stderr.decode(errors="replace")
|
|
51
|
+
context.test_returncode = proc.returncode
|
|
52
|
+
return context
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _timeout_for(context: BuildContext) -> float:
|
|
56
|
+
timeout = context.extras.get("test_timeout")
|
|
57
|
+
if isinstance(timeout, (int, float)):
|
|
58
|
+
return float(timeout)
|
|
59
|
+
return 120.0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def run_subprocess_sync(command: str, root: Path, *, timeout: float) -> subprocess.CompletedProcess:
|
|
63
|
+
"""Synchronous helper used by tests."""
|
|
64
|
+
return subprocess.run(
|
|
65
|
+
command,
|
|
66
|
+
cwd=str(root),
|
|
67
|
+
shell=True,
|
|
68
|
+
capture_output=True,
|
|
69
|
+
text=True,
|
|
70
|
+
timeout=timeout,
|
|
71
|
+
check=False,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
__all__ = ["DEFAULT_TEST_COMMAND", "run_subprocess_sync", "run_tests"]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Code building pipeline: format, generate, apply edits."""
|
|
2
|
+
|
|
3
|
+
from forgecli.builder.builder import Builder
|
|
4
|
+
from forgecli.builder.editor import Editor
|
|
5
|
+
from forgecli.builder.formatter import Formatter
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Builder",
|
|
9
|
+
"Editor",
|
|
10
|
+
"Formatter",
|
|
11
|
+
]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Top-level builder pipeline orchestrator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from forgecli.builder.editor import Editor, FileEdit
|
|
10
|
+
from forgecli.builder.formatter import Formatter
|
|
11
|
+
from forgecli.core.service import Service
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class BuildResult:
|
|
16
|
+
"""The outcome of a build run."""
|
|
17
|
+
|
|
18
|
+
success: bool
|
|
19
|
+
touched: list[Path] = field(default_factory=list)
|
|
20
|
+
notes: list[str] = field(default_factory=list)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Builder(Service):
|
|
24
|
+
"""Coordinates editing, formatting, and verification of a change set."""
|
|
25
|
+
|
|
26
|
+
name = "builder"
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
*,
|
|
31
|
+
editor: Editor | None = None,
|
|
32
|
+
formatter: Formatter | None = None,
|
|
33
|
+
max_iterations: int = 3,
|
|
34
|
+
) -> None:
|
|
35
|
+
super().__init__()
|
|
36
|
+
self._editor = editor or Editor()
|
|
37
|
+
self._formatter = formatter or Formatter()
|
|
38
|
+
self._max_iterations = max_iterations
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def editor(self) -> Editor:
|
|
42
|
+
return self._editor
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def formatter(self) -> Formatter:
|
|
46
|
+
return self._formatter
|
|
47
|
+
|
|
48
|
+
def build(self, edits: Iterable[FileEdit]) -> BuildResult:
|
|
49
|
+
"""Apply ``edits`` and run post-processing; placeholder for now."""
|
|
50
|
+
touched = self._editor.apply(edits)
|
|
51
|
+
if touched:
|
|
52
|
+
self._formatter.format(touched)
|
|
53
|
+
return BuildResult(success=True, touched=touched)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""File editing primitives."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from forgecli.core.service import Service
|
|
10
|
+
from forgecli.utils.fs import atomic_write, read_text
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class FileEdit:
|
|
15
|
+
"""A single replacement operation targeting ``path``."""
|
|
16
|
+
|
|
17
|
+
path: Path
|
|
18
|
+
new_content: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Editor(Service):
|
|
22
|
+
"""Apply a batch of file edits atomically (best effort)."""
|
|
23
|
+
|
|
24
|
+
name = "builder.editor"
|
|
25
|
+
|
|
26
|
+
def apply(self, edits: Iterable[FileEdit]) -> list[Path]:
|
|
27
|
+
"""Write each edit to disk; return the list of touched paths."""
|
|
28
|
+
touched: list[Path] = []
|
|
29
|
+
for edit in edits:
|
|
30
|
+
atomic_write(edit.path, edit.new_content)
|
|
31
|
+
touched.append(Path(edit.path))
|
|
32
|
+
return touched
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def read(path: Path) -> str:
|
|
36
|
+
return read_text(path)
|