forgeoptimizer 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +137 -0
- forgecli/cli/commands_wrappers.py +63 -0
- forgecli/cli/main.py +122 -0
- forgecli/cli/ui.py +56 -0
- forgecli/config/__init__.py +28 -0
- forgecli/config/loader.py +85 -0
- forgecli/config/settings.py +204 -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 +159 -0
- forgecli/core/plugins.py +56 -0
- forgecli/core/service.py +22 -0
- forgecli/docs/__init__.py +5 -0
- forgecli/docs/generator.py +119 -0
- forgecli/engine/__init__.py +105 -0
- forgecli/engine/context.py +171 -0
- forgecli/engine/defaults.py +89 -0
- forgecli/engine/events.py +185 -0
- forgecli/engine/execution.py +526 -0
- forgecli/engine/plugins.py +146 -0
- forgecli/engine/runner.py +155 -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 +108 -0
- forgecli/engine/stages/git_engine.py +30 -0
- forgecli/engine/stages/intent_analyzer.py +38 -0
- forgecli/engine/stages/model_router.py +66 -0
- forgecli/engine/stages/planning_engine.py +45 -0
- forgecli/engine/stages/repository_analyzer.py +54 -0
- forgecli/engine/stages/validation_engine.py +89 -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 +453 -0
- forgecli/graph/edge.py +19 -0
- forgecli/graph/graph.py +85 -0
- forgecli/graph/graphify.py +412 -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 +138 -0
- forgecli/memory/store.py +87 -0
- forgecli/optimizer/__init__.py +14 -0
- forgecli/optimizer/caveman/__init__.py +173 -0
- forgecli/optimizer/caveman/cli.py +64 -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 +52 -0
- forgecli/optimizer/chunker.py +85 -0
- forgecli/optimizer/optimizer.py +81 -0
- forgecli/optimizer/ponytail/__init__.py +183 -0
- forgecli/optimizer/ponytail/cli.py +168 -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 +53 -0
- forgecli/optimizer/ranker.py +37 -0
- forgecli/optimizer/summarizer.py +44 -0
- forgecli/orchestrator/__init__.py +707 -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 +271 -0
- forgecli/planner/serialize.py +106 -0
- forgecli/planner/software.py +834 -0
- forgecli/platform/__init__.py +91 -0
- forgecli/platform/core.py +201 -0
- forgecli/platform/deps.py +354 -0
- forgecli/platform/paths.py +236 -0
- forgecli/platform/shell.py +176 -0
- forgecli/platform/update.py +252 -0
- forgecli/plugins/__init__.py +251 -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 +206 -0
- forgecli/providers/base.py +206 -0
- forgecli/providers/builtin.py +26 -0
- forgecli/providers/conversation.py +78 -0
- forgecli/providers/google.py +295 -0
- forgecli/providers/http_base.py +211 -0
- forgecli/providers/mock.py +113 -0
- forgecli/providers/openai.py +202 -0
- forgecli/providers/openai_compatible.py +728 -0
- forgecli/providers/router.py +345 -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 +258 -0
- forgecli/review/analyzers/complexity.py +167 -0
- forgecli/review/analyzers/dead_code.py +262 -0
- forgecli/review/analyzers/duplicates.py +163 -0
- forgecli/review/analyzers/performance.py +165 -0
- forgecli/review/analyzers/security.py +251 -0
- forgecli/review/finding.py +68 -0
- forgecli/review/report.py +311 -0
- forgecli/review/repository.py +131 -0
- forgecli/review/suggestions.py +98 -0
- forgecli/runtime/__init__.py +6 -0
- forgecli/runtime/cache_store.py +77 -0
- forgecli/runtime/prepare.py +199 -0
- forgecli/runtime/wrappers.py +152 -0
- forgecli/sdk/__init__.py +132 -0
- forgecli/sdk/events.py +206 -0
- forgecli/sdk/interfaces.py +282 -0
- forgecli/sdk/loader.py +243 -0
- forgecli/sdk/manager.py +693 -0
- forgecli/sdk/manifest.py +397 -0
- forgecli/sdk/sandbox.py +197 -0
- forgecli/sdk/version.py +316 -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 +91 -0
- forgecli/utils/ids.py +11 -0
- forgecli/utils/io.py +27 -0
- forgecli/utils/paths.py +70 -0
- forgecli/utils/timing.py +25 -0
- forgeoptimizer-1.0.0.dist-info/METADATA +169 -0
- forgeoptimizer-1.0.0.dist-info/RECORD +156 -0
- forgeoptimizer-1.0.0.dist-info/WHEEL +4 -0
- forgeoptimizer-1.0.0.dist-info/entry_points.txt +2 -0
- forgeoptimizer-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Optional adapter for an external ``ponytail`` CLI.
|
|
2
|
+
|
|
3
|
+
Some Ponytail installations (or future refactors) ship a binary on
|
|
4
|
+
``PATH`` that can rewrite a prompt itself. This adapter detects the
|
|
5
|
+
binary and forwards the conversation to it. When the binary is not
|
|
6
|
+
installed, :meth:`is_available` returns ``False`` and the
|
|
7
|
+
:class:`CompositeOptimizer` falls back to the in-process ruleset.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import contextlib
|
|
14
|
+
import os
|
|
15
|
+
import shutil
|
|
16
|
+
import tempfile
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from forgecli.core.errors import ForgeCLIError
|
|
20
|
+
from forgecli.optimizer.ponytail import (
|
|
21
|
+
Intensity,
|
|
22
|
+
OptimizedRequest,
|
|
23
|
+
PromptOptimizer,
|
|
24
|
+
)
|
|
25
|
+
from forgecli.providers.base import ChatMessage, ChatRequest, Role
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PonytailCLIError(ForgeCLIError):
|
|
29
|
+
"""Raised when the external ``ponytail`` binary fails."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PonytailCLIOptimizer(PromptOptimizer):
|
|
33
|
+
"""Forward prompts to an external ``ponytail`` binary via stdin/stdout.
|
|
34
|
+
|
|
35
|
+
The CLI is expected to accept a JSON payload on stdin and emit a
|
|
36
|
+
JSON payload on stdout. When such a binary is not present the
|
|
37
|
+
adapter is harmless — :meth:`is_available` returns ``False`` and
|
|
38
|
+
the composite falls back to the in-process ruleset.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
name = "ponytail.cli"
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
*,
|
|
46
|
+
executable: str | None = None,
|
|
47
|
+
timeout: float = 30.0,
|
|
48
|
+
) -> None:
|
|
49
|
+
self._executable = executable or os.environ.get(
|
|
50
|
+
"FORGECLI_PONYTAIL_BIN", "ponytail"
|
|
51
|
+
)
|
|
52
|
+
self._timeout = timeout
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def executable(self) -> str:
|
|
56
|
+
return self._executable
|
|
57
|
+
|
|
58
|
+
async def is_available(self) -> bool:
|
|
59
|
+
return shutil.which(self._executable) is not None
|
|
60
|
+
|
|
61
|
+
async def optimize_chat(self, request: ChatRequest) -> OptimizedRequest:
|
|
62
|
+
if not await self.is_available():
|
|
63
|
+
raise PonytailCLIError(
|
|
64
|
+
f"Ponytail executable {self._executable!r} not found on PATH"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
binary = shutil.which(self._executable) or self._executable
|
|
68
|
+
payload = _request_to_json(request)
|
|
69
|
+
with tempfile.NamedTemporaryFile(
|
|
70
|
+
"w", suffix=".json", delete=False, encoding="utf-8"
|
|
71
|
+
) as src:
|
|
72
|
+
src.write(payload)
|
|
73
|
+
src_path = Path(src.name)
|
|
74
|
+
try:
|
|
75
|
+
proc = await asyncio.create_subprocess_exec(
|
|
76
|
+
binary,
|
|
77
|
+
"optimize",
|
|
78
|
+
"--stdin",
|
|
79
|
+
str(src_path),
|
|
80
|
+
stdout=asyncio.subprocess.PIPE,
|
|
81
|
+
stderr=asyncio.subprocess.PIPE,
|
|
82
|
+
)
|
|
83
|
+
try:
|
|
84
|
+
stdout, stderr = await asyncio.wait_for(
|
|
85
|
+
proc.communicate(), timeout=self._timeout
|
|
86
|
+
)
|
|
87
|
+
except TimeoutError as exc:
|
|
88
|
+
proc.kill()
|
|
89
|
+
raise PonytailCLIError(
|
|
90
|
+
f"ponytail optimize timed out after {self._timeout}s"
|
|
91
|
+
) from exc
|
|
92
|
+
finally:
|
|
93
|
+
with contextlib.suppress(OSError):
|
|
94
|
+
src_path.unlink()
|
|
95
|
+
|
|
96
|
+
if proc.returncode != 0:
|
|
97
|
+
raise PonytailCLIError(
|
|
98
|
+
f"ponytail exited with {proc.returncode}: "
|
|
99
|
+
f"{stderr.decode(errors='replace').strip()}"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return _json_to_optimized(stdout.decode(errors="replace"))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _request_to_json(request: ChatRequest) -> str:
|
|
106
|
+
"""Serialize a :class:`ChatRequest` for the CLI."""
|
|
107
|
+
import json
|
|
108
|
+
|
|
109
|
+
return json.dumps(
|
|
110
|
+
{
|
|
111
|
+
"model": request.model,
|
|
112
|
+
"temperature": request.temperature,
|
|
113
|
+
"max_tokens": request.max_tokens,
|
|
114
|
+
"messages": [
|
|
115
|
+
{
|
|
116
|
+
"role": m.role.value,
|
|
117
|
+
"content": m.content,
|
|
118
|
+
"name": m.name,
|
|
119
|
+
"tool_call_id": m.tool_call_id,
|
|
120
|
+
}
|
|
121
|
+
for m in request.messages
|
|
122
|
+
],
|
|
123
|
+
},
|
|
124
|
+
ensure_ascii=False,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _json_to_optimized(raw: str) -> OptimizedRequest:
|
|
129
|
+
"""Parse the JSON the CLI emitted and rebuild an :class:`OptimizedRequest`."""
|
|
130
|
+
import json
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
payload = json.loads(raw)
|
|
134
|
+
except json.JSONDecodeError as exc:
|
|
135
|
+
raise PonytailCLIError(f"ponytail returned invalid JSON: {exc}") from exc
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
intensity = Intensity.parse(payload.get("intensity", "lite"))
|
|
139
|
+
messages = [
|
|
140
|
+
ChatMessage(
|
|
141
|
+
role=Role(m["role"]),
|
|
142
|
+
content=m["content"],
|
|
143
|
+
name=m.get("name"),
|
|
144
|
+
tool_call_id=m.get("tool_call_id"),
|
|
145
|
+
)
|
|
146
|
+
for m in payload.get("messages", [])
|
|
147
|
+
]
|
|
148
|
+
except (KeyError, ValueError) as exc:
|
|
149
|
+
raise PonytailCLIError(
|
|
150
|
+
f"ponytail returned an unexpected payload: {exc}"
|
|
151
|
+
) from exc
|
|
152
|
+
|
|
153
|
+
new_request = ChatRequest(
|
|
154
|
+
model=payload.get("model"),
|
|
155
|
+
messages=messages,
|
|
156
|
+
temperature=payload.get("temperature"),
|
|
157
|
+
max_tokens=payload.get("max_tokens"),
|
|
158
|
+
)
|
|
159
|
+
notes = tuple(payload.get("notes", ()))
|
|
160
|
+
return OptimizedRequest(
|
|
161
|
+
request=new_request,
|
|
162
|
+
notes=notes,
|
|
163
|
+
intensity=intensity,
|
|
164
|
+
source="external",
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
__all__ = ["PonytailCLIError", "PonytailCLIOptimizer"]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""A :class:`Provider` decorator that runs a :class:`PromptOptimizer`
|
|
2
|
+
on every chat request before delegating to the wrapped provider.
|
|
3
|
+
|
|
4
|
+
The decorator is transparent for every other operation (``stream``,
|
|
5
|
+
``embed``, ``list_models``) — those either pass through unchanged or
|
|
6
|
+
inherit the base behaviour.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import AsyncIterator
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from forgecli.optimizer.ponytail import OptimizedRequest, PromptOptimizer
|
|
15
|
+
from forgecli.providers.base import (
|
|
16
|
+
ChatRequest,
|
|
17
|
+
ChatResponse,
|
|
18
|
+
EmbeddingRequest,
|
|
19
|
+
EmbeddingResponse,
|
|
20
|
+
ModelInfo,
|
|
21
|
+
Provider,
|
|
22
|
+
StreamChunk,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class OptimizedProvider(Provider[Any]):
|
|
27
|
+
"""Wrap a :class:`Provider` so every chat call is pre-optimized."""
|
|
28
|
+
|
|
29
|
+
name = "optimized"
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
base: Provider[Any],
|
|
34
|
+
optimizer: PromptOptimizer,
|
|
35
|
+
) -> None:
|
|
36
|
+
# The wrapped provider already has its own concrete config; we
|
|
37
|
+
# expose it via ``.config`` for compatibility.
|
|
38
|
+
super().__init__(base.config)
|
|
39
|
+
self._base = base
|
|
40
|
+
self._optimizer = optimizer
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def base(self) -> Provider[Any]:
|
|
44
|
+
return self._base
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def optimizer(self) -> PromptOptimizer:
|
|
48
|
+
return self._optimizer
|
|
49
|
+
|
|
50
|
+
async def chat(self, request: ChatRequest) -> ChatResponse:
|
|
51
|
+
optimized: OptimizedRequest = await self._optimizer.optimize_chat(request)
|
|
52
|
+
return await self._base.chat(optimized.request)
|
|
53
|
+
|
|
54
|
+
async def stream(self, request: ChatRequest) -> AsyncIterator[StreamChunk]:
|
|
55
|
+
optimized: OptimizedRequest = await self._optimizer.optimize_chat(request)
|
|
56
|
+
async for chunk in self._base.stream(optimized.request):
|
|
57
|
+
yield chunk
|
|
58
|
+
|
|
59
|
+
async def embed(self, request: EmbeddingRequest) -> EmbeddingResponse:
|
|
60
|
+
# Embedding calls don't carry chat messages; pass through.
|
|
61
|
+
return await self._base.embed(request)
|
|
62
|
+
|
|
63
|
+
async def list_models(self) -> list[ModelInfo]:
|
|
64
|
+
return await self._base.list_models()
|
|
65
|
+
|
|
66
|
+
def validate(self) -> None:
|
|
67
|
+
return self._base.validate()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
__all__ = ["OptimizedProvider"]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Factory for building the live :class:`PromptOptimizer`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from forgecli.config.settings import ForgeSettings
|
|
6
|
+
from forgecli.optimizer.ponytail import (
|
|
7
|
+
CompositeOptimizer,
|
|
8
|
+
Intensity,
|
|
9
|
+
PonytailCLIOptimizer,
|
|
10
|
+
PonytailRulesetOptimizer,
|
|
11
|
+
PromptOptimizer,
|
|
12
|
+
)
|
|
13
|
+
from forgecli.optimizer.ponytail.state import OptimizerState
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_optimizer(
|
|
17
|
+
state: OptimizerState,
|
|
18
|
+
settings: ForgeSettings | None = None,
|
|
19
|
+
) -> PromptOptimizer:
|
|
20
|
+
"""Build the composite optimizer from the runtime state + config.
|
|
21
|
+
|
|
22
|
+
The composite always falls back to the in-process ruleset; the
|
|
23
|
+
external CLI adapter is added on top whenever the configured
|
|
24
|
+
backend asks for it and the binary is available.
|
|
25
|
+
"""
|
|
26
|
+
ruleset = PonytailRulesetOptimizer(intensity=state.intensity)
|
|
27
|
+
|
|
28
|
+
external: PromptOptimizer | None = None
|
|
29
|
+
if state.backend in {"cli", "auto"}:
|
|
30
|
+
binary = state.binary
|
|
31
|
+
if settings is not None and settings.prompt_optimizer.binary:
|
|
32
|
+
binary = settings.prompt_optimizer.binary
|
|
33
|
+
external = PonytailCLIOptimizer(executable=binary)
|
|
34
|
+
elif state.backend == "ruleset":
|
|
35
|
+
external = None
|
|
36
|
+
|
|
37
|
+
if settings is not None and not settings.prompt_optimizer.enabled:
|
|
38
|
+
return CompositeOptimizer(
|
|
39
|
+
intensity=Intensity.OFF,
|
|
40
|
+
ruleset=ruleset,
|
|
41
|
+
external=external,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
return CompositeOptimizer(
|
|
45
|
+
intensity=state.intensity,
|
|
46
|
+
ruleset=ruleset,
|
|
47
|
+
external=external,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
__all__ = ["build_optimizer"]
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Self-contained Python implementation of the Ponytail ruleset.
|
|
2
|
+
|
|
3
|
+
The ruleset mirrors the behavior described on https://ponytail.dev/ and
|
|
4
|
+
the project's GitHub README (DietrichGebert/ponytail). It does not
|
|
5
|
+
shell out to anything; it rewrites the system message of a
|
|
6
|
+
:class:`ChatRequest` to bias the model toward the lazier correct
|
|
7
|
+
solution.
|
|
8
|
+
|
|
9
|
+
The four intensity levels match the official ``/ponytail`` command:
|
|
10
|
+
|
|
11
|
+
* ``off`` - pass-through (handled by the composite, not this class).
|
|
12
|
+
* ``lite`` - default; appends a single sentence reminding the model to
|
|
13
|
+
name the lazier alternative and lets the user pick.
|
|
14
|
+
* ``full`` - prepends the full "ladder" instruction and tells the
|
|
15
|
+
model to ship the shortest diff.
|
|
16
|
+
* ``ultra`` - aggressive YAGNI; tells the model to ship the one-liner
|
|
17
|
+
and challenge the rest of the requirement in the same
|
|
18
|
+
breath.
|
|
19
|
+
|
|
20
|
+
The output is deterministic for a given (intensity, prompt) pair, so
|
|
21
|
+
tests can pin it.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from forgecli.optimizer.ponytail import (
|
|
27
|
+
CompositeOptimizer,
|
|
28
|
+
Intensity,
|
|
29
|
+
OptimizedRequest,
|
|
30
|
+
PromptOptimizer,
|
|
31
|
+
_clone_request,
|
|
32
|
+
_ensure_user_message,
|
|
33
|
+
)
|
|
34
|
+
from forgecli.providers.base import ChatMessage, ChatRequest, Role
|
|
35
|
+
|
|
36
|
+
LADDER_INSTRUCTION = (
|
|
37
|
+
"Stop at the first rung that holds. Apply the Ponytail ladder before "
|
|
38
|
+
"writing any code:\n"
|
|
39
|
+
" 1. Does this need to exist? Speculative need = skip it (YAGNI).\n"
|
|
40
|
+
" 2. Already in this codebase? Reuse the helper, util, or pattern "
|
|
41
|
+
"that already lives here.\n"
|
|
42
|
+
" 3. Does the standard library do it? Use it.\n"
|
|
43
|
+
" 4. Native platform feature covers it? (e.g. <input type='date'> "
|
|
44
|
+
"over a picker lib.)\n"
|
|
45
|
+
" 5. Already-installed dependency solves it? Use it. Don't add a "
|
|
46
|
+
"new one.\n"
|
|
47
|
+
" 6. Can it be one line? One line.\n"
|
|
48
|
+
" 7. Only then: the minimum code that works."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_INTENSITY_GUIDANCE: dict[Intensity, str] = {
|
|
53
|
+
Intensity.OFF: "",
|
|
54
|
+
Intensity.LITE: (
|
|
55
|
+
"Ponytail (lite): when you reach for code, also name the lazier "
|
|
56
|
+
"correct alternative in a single sentence. The user picks."
|
|
57
|
+
),
|
|
58
|
+
Intensity.FULL: (
|
|
59
|
+
"Ponytail (full): apply the Ponytail ladder before writing any code. "
|
|
60
|
+
"Ship the shortest diff and the shortest explanation. Never "
|
|
61
|
+
"simplify away validation, error handling, security, or accessibility.\n\n"
|
|
62
|
+
f"{LADDER_INSTRUCTION}"
|
|
63
|
+
),
|
|
64
|
+
Intensity.ULTRA: (
|
|
65
|
+
"Ponytail (ultra): YAGNI extremist. Ship the one-liner. In the "
|
|
66
|
+
"same breath, name what is being *cut* from the original "
|
|
67
|
+
"requirement and why the cut is safe."
|
|
68
|
+
),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class PonytailRulesetOptimizer(PromptOptimizer):
|
|
73
|
+
"""Rewrite system messages to apply the Ponytail ruleset."""
|
|
74
|
+
|
|
75
|
+
name = "ponytail.ruleset"
|
|
76
|
+
|
|
77
|
+
def __init__(self, *, intensity: Intensity = Intensity.LITE) -> None:
|
|
78
|
+
self._intensity = Intensity.parse(intensity)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def intensity(self) -> Intensity:
|
|
82
|
+
return self._intensity
|
|
83
|
+
|
|
84
|
+
def set_intensity(self, intensity: Intensity | str) -> None:
|
|
85
|
+
self._intensity = Intensity.parse(intensity)
|
|
86
|
+
|
|
87
|
+
async def optimize_chat(self, request: ChatRequest) -> OptimizedRequest:
|
|
88
|
+
if self._intensity is Intensity.OFF:
|
|
89
|
+
return OptimizedRequest(
|
|
90
|
+
request=request,
|
|
91
|
+
notes=("ponytail off",),
|
|
92
|
+
intensity=Intensity.OFF,
|
|
93
|
+
source="ruleset",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
guidance = _INTENSITY_GUIDANCE[self._intensity]
|
|
97
|
+
messages = list(request.messages)
|
|
98
|
+
|
|
99
|
+
if not _ensure_user_message(messages):
|
|
100
|
+
# The model must see a user turn; without one the request is
|
|
101
|
+
# malformed. Pass through unchanged and let the caller decide.
|
|
102
|
+
return OptimizedRequest(
|
|
103
|
+
request=request,
|
|
104
|
+
notes=("no user message; passthrough",),
|
|
105
|
+
intensity=self._intensity,
|
|
106
|
+
source="ruleset",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
rewritten = _rewrite_messages(messages, guidance)
|
|
110
|
+
notes = _build_notes(self._intensity, len(messages), len(rewritten))
|
|
111
|
+
return OptimizedRequest(
|
|
112
|
+
request=_clone_request(request, rewritten),
|
|
113
|
+
notes=notes,
|
|
114
|
+
intensity=self._intensity,
|
|
115
|
+
source="ruleset",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _rewrite_messages(
|
|
120
|
+
messages: list[ChatMessage],
|
|
121
|
+
guidance: str,
|
|
122
|
+
) -> list[ChatMessage]:
|
|
123
|
+
"""Insert Ponytail guidance at the head of the conversation.
|
|
124
|
+
|
|
125
|
+
Rules:
|
|
126
|
+
* If the first message is a system message, prepend the guidance
|
|
127
|
+
to it (separated by two newlines).
|
|
128
|
+
* If there is no system message, insert one.
|
|
129
|
+
* If the first system message is *empty*, replace it.
|
|
130
|
+
* Otherwise the user/assistant turns are left untouched.
|
|
131
|
+
"""
|
|
132
|
+
if not messages:
|
|
133
|
+
return [ChatMessage(role=Role.SYSTEM, content=guidance)]
|
|
134
|
+
|
|
135
|
+
first = messages[0]
|
|
136
|
+
if first.role is Role.SYSTEM:
|
|
137
|
+
existing = first.content.strip()
|
|
138
|
+
if not existing:
|
|
139
|
+
return [ChatMessage(role=Role.SYSTEM, content=guidance), *messages[1:]]
|
|
140
|
+
new_content = f"{guidance}\n\n{first.content}" if guidance else first.content
|
|
141
|
+
return [ChatMessage(role=Role.SYSTEM, content=new_content), *messages[1:]]
|
|
142
|
+
|
|
143
|
+
return [ChatMessage(role=Role.SYSTEM, content=guidance), *messages]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _build_notes(
|
|
147
|
+
intensity: Intensity, original_count: int, rewritten_count: int
|
|
148
|
+
) -> tuple[str, ...]:
|
|
149
|
+
notes: list[str] = [f"ponytail intensity={intensity.value}"]
|
|
150
|
+
if intensity is Intensity.FULL:
|
|
151
|
+
notes.append("ladder enforced")
|
|
152
|
+
elif intensity is Intensity.ULTRA:
|
|
153
|
+
notes.append("yagni extremist mode")
|
|
154
|
+
elif intensity is Intensity.LITE:
|
|
155
|
+
notes.append("named the lazier alternative")
|
|
156
|
+
if rewritten_count != original_count:
|
|
157
|
+
notes.append(f"inserted system message ({rewritten_count} > {original_count})")
|
|
158
|
+
return tuple(notes)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
__all__ = [
|
|
162
|
+
"LADDER_INSTRUCTION",
|
|
163
|
+
"PonytailRulesetOptimizer",
|
|
164
|
+
]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# Re-export the composite here so the public namespace stays compact.
|
|
168
|
+
_ = CompositeOptimizer
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Live runtime state for the prompt optimizer.
|
|
2
|
+
|
|
3
|
+
The optimizer intensity can be flipped from the CLI via
|
|
4
|
+
``forge optimizer lite|full|ultra|off``. Because the setting is held
|
|
5
|
+
in :class:`AppContext.extras`, every subcommand that resolves the
|
|
6
|
+
optimizer sees the latest value. Tests can override it directly.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from forgecli.optimizer.ponytail import Intensity
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class OptimizerState:
|
|
18
|
+
"""Mutable intensity for the running CLI process."""
|
|
19
|
+
|
|
20
|
+
intensity: Intensity = Intensity.LITE
|
|
21
|
+
backend: str = "ruleset" # "ruleset" | "cli" | "auto"
|
|
22
|
+
binary: str = "ponytail"
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def from_extras(cls, extras: dict[str, object]) -> OptimizerState:
|
|
26
|
+
state = cls()
|
|
27
|
+
intensity = extras.get("optimizer.intensity") or extras.get(
|
|
28
|
+
"optimizer_intensity"
|
|
29
|
+
)
|
|
30
|
+
backend = extras.get("optimizer.backend") or extras.get("optimizer_backend")
|
|
31
|
+
binary = extras.get("optimizer.binary") or extras.get("optimizer_binary")
|
|
32
|
+
if isinstance(intensity, str):
|
|
33
|
+
try:
|
|
34
|
+
state.intensity = Intensity.parse(intensity)
|
|
35
|
+
except ValueError:
|
|
36
|
+
state.intensity = Intensity.LITE
|
|
37
|
+
elif isinstance(intensity, Intensity):
|
|
38
|
+
state.intensity = intensity
|
|
39
|
+
if isinstance(backend, str):
|
|
40
|
+
state.backend = backend
|
|
41
|
+
if isinstance(binary, str):
|
|
42
|
+
state.binary = binary
|
|
43
|
+
return state
|
|
44
|
+
|
|
45
|
+
def to_extras(self) -> dict[str, str]:
|
|
46
|
+
return {
|
|
47
|
+
"optimizer.intensity": self.intensity.value,
|
|
48
|
+
"optimizer.backend": self.backend,
|
|
49
|
+
"optimizer.binary": self.binary,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
__all__ = ["OptimizerState"]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Ranks chunks by relevance to a query."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
|
|
7
|
+
from forgecli.optimizer.chunker import Chunk
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Ranker:
|
|
11
|
+
"""Default ranker based on simple lexical overlap.
|
|
12
|
+
|
|
13
|
+
The interface is deliberately small: ``score(query, chunk)`` returns a
|
|
14
|
+
float; higher is more relevant. Real implementations may use
|
|
15
|
+
embeddings, BM25, or graph-based signals.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def score(self, query: str, chunk: Chunk) -> float:
|
|
19
|
+
"""Score a single ``chunk`` against ``query``."""
|
|
20
|
+
if not query or not chunk.text:
|
|
21
|
+
return 0.0
|
|
22
|
+
q_tokens = self._tokenize(query)
|
|
23
|
+
c_tokens = set(self._tokenize(chunk.text))
|
|
24
|
+
if not q_tokens:
|
|
25
|
+
return 0.0
|
|
26
|
+
hits = sum(1 for token in q_tokens if token in c_tokens)
|
|
27
|
+
return hits / max(len(q_tokens), 1)
|
|
28
|
+
|
|
29
|
+
def rank(self, query: str, chunks: Iterable[Chunk]) -> list[tuple[Chunk, float]]:
|
|
30
|
+
"""Return ``chunks`` paired with scores, sorted descending by score."""
|
|
31
|
+
scored = [(chunk, self.score(query, chunk)) for chunk in chunks]
|
|
32
|
+
scored.sort(key=lambda pair: pair[1], reverse=True)
|
|
33
|
+
return scored
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def _tokenize(text: str) -> list[str]:
|
|
37
|
+
return [tok.lower() for tok in text.split() if tok]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Summarization interface (placeholder)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from forgecli.core.service import Service
|
|
6
|
+
from forgecli.optimizer.chunker import Chunk
|
|
7
|
+
from forgecli.providers.base import ChatMessage, ChatRequest, Provider, Role
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Summarizer(Service):
|
|
11
|
+
"""Condenses a sequence of chunks using a chat provider.
|
|
12
|
+
|
|
13
|
+
The summarizer is intentionally a thin wrapper around :class:`Provider`
|
|
14
|
+
so we can swap between AI-backed and heuristic implementations without
|
|
15
|
+
touching call sites.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
name = "optimizer.summarizer"
|
|
19
|
+
|
|
20
|
+
def __init__(self, provider: Provider, *, model: str | None = None) -> None:
|
|
21
|
+
super().__init__()
|
|
22
|
+
self._provider = provider
|
|
23
|
+
self._model = model
|
|
24
|
+
|
|
25
|
+
async def summarize(self, chunks: list[Chunk], *, max_words: int = 200) -> str:
|
|
26
|
+
"""Return a textual summary of ``chunks`` (placeholder)."""
|
|
27
|
+
joined = "\n\n".join(c.text for c in chunks)
|
|
28
|
+
if not joined:
|
|
29
|
+
return ""
|
|
30
|
+
request = ChatRequest(
|
|
31
|
+
model=self._model,
|
|
32
|
+
messages=[
|
|
33
|
+
ChatMessage(
|
|
34
|
+
role=Role.SYSTEM,
|
|
35
|
+
content=(
|
|
36
|
+
"You are a concise code-aware summarizer. "
|
|
37
|
+
f"Respond in at most {max_words} words."
|
|
38
|
+
),
|
|
39
|
+
),
|
|
40
|
+
ChatMessage(role=Role.USER, content=joined),
|
|
41
|
+
],
|
|
42
|
+
)
|
|
43
|
+
response = await self._provider.chat(request)
|
|
44
|
+
return response.message.content
|