forgeoptimizer 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. forgecli/__init__.py +4 -0
  2. forgecli/build/__init__.py +135 -0
  3. forgecli/build/apply.py +218 -0
  4. forgecli/build/caveman_optimize.py +37 -0
  5. forgecli/build/diff_extract.py +218 -0
  6. forgecli/build/llm.py +167 -0
  7. forgecli/build/optimize.py +37 -0
  8. forgecli/build/pipeline.py +76 -0
  9. forgecli/build/retrieval.py +157 -0
  10. forgecli/build/summarize.py +76 -0
  11. forgecli/build/test_run.py +75 -0
  12. forgecli/builder/__init__.py +11 -0
  13. forgecli/builder/builder.py +53 -0
  14. forgecli/builder/editor.py +36 -0
  15. forgecli/builder/formatter.py +31 -0
  16. forgecli/cli/__init__.py +5 -0
  17. forgecli/cli/bootstrap.py +281 -0
  18. forgecli/cli/commands_graph.py +149 -0
  19. forgecli/cli/commands_wrappers.py +80 -0
  20. forgecli/cli/daemon.py +744 -0
  21. forgecli/cli/main.py +234 -0
  22. forgecli/cli/ui.py +56 -0
  23. forgecli/config/__init__.py +28 -0
  24. forgecli/config/loader.py +85 -0
  25. forgecli/config/settings.py +206 -0
  26. forgecli/config/writer.py +90 -0
  27. forgecli/core/__init__.py +35 -0
  28. forgecli/core/container.py +68 -0
  29. forgecli/core/context.py +54 -0
  30. forgecli/core/credentials.py +114 -0
  31. forgecli/core/errors.py +27 -0
  32. forgecli/core/events.py +67 -0
  33. forgecli/core/logging.py +47 -0
  34. forgecli/core/models.py +214 -0
  35. forgecli/core/plugins.py +54 -0
  36. forgecli/core/service.py +22 -0
  37. forgecli/docs/__init__.py +5 -0
  38. forgecli/docs/generator.py +115 -0
  39. forgecli/engine/__init__.py +105 -0
  40. forgecli/engine/context.py +163 -0
  41. forgecli/engine/defaults.py +89 -0
  42. forgecli/engine/events.py +185 -0
  43. forgecli/engine/execution.py +519 -0
  44. forgecli/engine/plugins.py +145 -0
  45. forgecli/engine/runner.py +158 -0
  46. forgecli/engine/stages/__init__.py +33 -0
  47. forgecli/engine/stages/caveman_optimizer.py +47 -0
  48. forgecli/engine/stages/context_optimizer.py +44 -0
  49. forgecli/engine/stages/execution_engine_stage.py +102 -0
  50. forgecli/engine/stages/git_engine.py +30 -0
  51. forgecli/engine/stages/intent_analyzer.py +38 -0
  52. forgecli/engine/stages/model_router.py +65 -0
  53. forgecli/engine/stages/planning_engine.py +45 -0
  54. forgecli/engine/stages/repository_analyzer.py +54 -0
  55. forgecli/engine/stages/validation_engine.py +87 -0
  56. forgecli/git/__init__.py +9 -0
  57. forgecli/git/repo.py +55 -0
  58. forgecli/git/service.py +45 -0
  59. forgecli/graph/__init__.py +56 -0
  60. forgecli/graph/backend_graphify.py +448 -0
  61. forgecli/graph/edge.py +19 -0
  62. forgecli/graph/graph.py +85 -0
  63. forgecli/graph/graphify.py +405 -0
  64. forgecli/graph/indexer.py +82 -0
  65. forgecli/graph/node.py +47 -0
  66. forgecli/graph/repository.py +164 -0
  67. forgecli/memory/__init__.py +12 -0
  68. forgecli/memory/cache.py +61 -0
  69. forgecli/memory/history.py +136 -0
  70. forgecli/memory/store.py +85 -0
  71. forgecli/optimizer/__init__.py +13 -0
  72. forgecli/optimizer/caveman/__init__.py +169 -0
  73. forgecli/optimizer/caveman/cli.py +62 -0
  74. forgecli/optimizer/caveman/decorator.py +67 -0
  75. forgecli/optimizer/caveman/factory.py +51 -0
  76. forgecli/optimizer/caveman/ruleset.py +156 -0
  77. forgecli/optimizer/caveman/state.py +50 -0
  78. forgecli/optimizer/chunker.py +85 -0
  79. forgecli/optimizer/optimizer.py +81 -0
  80. forgecli/optimizer/ponytail/__init__.py +181 -0
  81. forgecli/optimizer/ponytail/cli.py +159 -0
  82. forgecli/optimizer/ponytail/decorator.py +70 -0
  83. forgecli/optimizer/ponytail/factory.py +51 -0
  84. forgecli/optimizer/ponytail/ruleset.py +168 -0
  85. forgecli/optimizer/ponytail/state.py +51 -0
  86. forgecli/optimizer/ranker.py +37 -0
  87. forgecli/optimizer/summarizer.py +44 -0
  88. forgecli/orchestrator/__init__.py +706 -0
  89. forgecli/planner/__init__.py +56 -0
  90. forgecli/planner/agent.py +61 -0
  91. forgecli/planner/plan.py +65 -0
  92. forgecli/planner/planner.py +17 -0
  93. forgecli/planner/render.py +267 -0
  94. forgecli/planner/serialize.py +104 -0
  95. forgecli/planner/software.py +832 -0
  96. forgecli/platform/__init__.py +91 -0
  97. forgecli/platform/core.py +201 -0
  98. forgecli/platform/deps.py +361 -0
  99. forgecli/platform/paths.py +234 -0
  100. forgecli/platform/shell.py +176 -0
  101. forgecli/platform/update.py +253 -0
  102. forgecli/plugins/__init__.py +249 -0
  103. forgecli/prompts/__init__.py +11 -0
  104. forgecli/prompts/loader.py +28 -0
  105. forgecli/prompts/registry.py +32 -0
  106. forgecli/prompts/renderer.py +23 -0
  107. forgecli/providers/__init__.py +39 -0
  108. forgecli/providers/anthropic.py +207 -0
  109. forgecli/providers/base.py +204 -0
  110. forgecli/providers/builtin.py +26 -0
  111. forgecli/providers/conversation.py +74 -0
  112. forgecli/providers/google.py +290 -0
  113. forgecli/providers/http_base.py +207 -0
  114. forgecli/providers/mock.py +111 -0
  115. forgecli/providers/openai.py +206 -0
  116. forgecli/providers/openai_compatible.py +787 -0
  117. forgecli/providers/router.py +340 -0
  118. forgecli/providers/router_state.py +89 -0
  119. forgecli/review/__init__.py +45 -0
  120. forgecli/review/analyzer.py +124 -0
  121. forgecli/review/analyzers/__init__.py +1 -0
  122. forgecli/review/analyzers/architecture.py +255 -0
  123. forgecli/review/analyzers/complexity.py +162 -0
  124. forgecli/review/analyzers/dead_code.py +255 -0
  125. forgecli/review/analyzers/duplicates.py +161 -0
  126. forgecli/review/analyzers/performance.py +161 -0
  127. forgecli/review/analyzers/security.py +244 -0
  128. forgecli/review/finding.py +68 -0
  129. forgecli/review/report.py +321 -0
  130. forgecli/review/repository.py +130 -0
  131. forgecli/review/suggestions.py +98 -0
  132. forgecli/runtime/__init__.py +6 -0
  133. forgecli/runtime/cache_store.py +75 -0
  134. forgecli/runtime/mcp_config.py +118 -0
  135. forgecli/runtime/prepare.py +203 -0
  136. forgecli/runtime/wrappers.py +150 -0
  137. forgecli/sdk/__init__.py +132 -0
  138. forgecli/sdk/events.py +206 -0
  139. forgecli/sdk/interfaces.py +282 -0
  140. forgecli/sdk/loader.py +239 -0
  141. forgecli/sdk/manager.py +678 -0
  142. forgecli/sdk/manifest.py +395 -0
  143. forgecli/sdk/sandbox.py +247 -0
  144. forgecli/sdk/version.py +313 -0
  145. forgecli/templates/__init__.py +9 -0
  146. forgecli/templates/engine.py +37 -0
  147. forgecli/templates/registry.py +32 -0
  148. forgecli/utils/__init__.py +19 -0
  149. forgecli/utils/fs.py +121 -0
  150. forgecli/utils/ids.py +11 -0
  151. forgecli/utils/io.py +27 -0
  152. forgecli/utils/paths.py +78 -0
  153. forgecli/utils/stats.py +179 -0
  154. forgecli/utils/timing.py +25 -0
  155. forgeoptimizer-0.1.0.dist-info/METADATA +177 -0
  156. forgeoptimizer-0.1.0.dist-info/RECORD +159 -0
  157. forgeoptimizer-0.1.0.dist-info/WHEEL +4 -0
  158. forgeoptimizer-0.1.0.dist-info/entry_points.txt +2 -0
  159. forgeoptimizer-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,181 @@
1
+ """Ponytail prompt-optimizer integration.
2
+
3
+ ForgeCLI integrates the [Ponytail](https://ponytail.dev/) ruleset
4
+ behind a small :class:`PromptOptimizer` interface and applies it
5
+ transparently to every chat call. Two implementations are shipped:
6
+
7
+ * :class:`PonytailRulesetOptimizer` — a self-contained Python
8
+ implementation of the Ponytail "ladder" (lite / full / ultra).
9
+ Always available, no external dependencies.
10
+ * :class:`PonytailCLIOptimizer` — an optional adapter that shells out
11
+ to an external ``ponytail`` binary if one is on ``PATH``.
12
+
13
+ The :class:`CompositeOptimizer` picks between them at runtime.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from abc import ABC, abstractmethod
19
+ from collections.abc import Sequence
20
+ from dataclasses import dataclass
21
+ from enum import Enum
22
+ from typing import Any
23
+
24
+ from forgecli.providers.base import ChatMessage, ChatRequest
25
+
26
+
27
+ class Intensity(str, Enum):
28
+ """How aggressively to optimize prompts.
29
+
30
+ The semantics match the Ponytail commands exactly:
31
+
32
+ * ``off`` - no rewriting; pass prompts through unchanged.
33
+ * ``lite`` - default; appends a one-line hint naming the lazier
34
+ alternative and lets the model decide.
35
+ * ``full`` - applies the full "ladder" ruleset and instructs the
36
+ model to ship the shortest diff.
37
+ * ``ultra`` - aggressive YAGNI; instructs the model to challenge
38
+ the rest of the requirement in the same breath.
39
+ """
40
+
41
+ OFF = "off"
42
+ LITE = "lite"
43
+ FULL = "full"
44
+ ULTRA = "ultra"
45
+
46
+ @classmethod
47
+ def parse(cls, value: str | Intensity | None) -> Intensity:
48
+ """Parse a string into an :class:`Intensity`, falling back to LITE."""
49
+ if value is None or value == "":
50
+ return cls.LITE
51
+ if isinstance(value, cls):
52
+ return value
53
+ try:
54
+ return cls(value.lower())
55
+ except ValueError as exc:
56
+ raise ValueError(
57
+ f"Unknown intensity {value!r}; expected one of {', '.join(i.value for i in cls)}"
58
+ ) from exc
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class OptimizedRequest:
63
+ """The output of :meth:`PromptOptimizer.optimize_chat`."""
64
+
65
+ request: ChatRequest
66
+ notes: tuple[str, ...] = ()
67
+ intensity: Any = Intensity.LITE
68
+ source: str = "ruleset" # "ruleset" | "external" | "passthrough"
69
+
70
+
71
+ class PromptOptimizer(ABC):
72
+ """Strategy interface for prompt optimization.
73
+
74
+ Implementations must be deterministic and side-effect free
75
+ (other than logging), so they can be invoked transparently before
76
+ every model call.
77
+ """
78
+
79
+ name: str = "abstract"
80
+
81
+ @abstractmethod
82
+ async def optimize_chat(self, request: ChatRequest) -> OptimizedRequest:
83
+ """Return an optimized copy of ``request``."""
84
+
85
+ async def is_available(self) -> bool:
86
+ """Return whether this optimizer is available to use."""
87
+ return True
88
+
89
+
90
+ class CompositeOptimizer(PromptOptimizer):
91
+ """Pick the right optimizer for the configured :class:`Intensity`."""
92
+
93
+ name = "composite"
94
+
95
+ def __init__(
96
+ self,
97
+ *,
98
+ intensity: Intensity = Intensity.LITE,
99
+ ruleset: PromptOptimizer | None = None,
100
+ external: PromptOptimizer | None = None,
101
+ ) -> None:
102
+ self._intensity = intensity
103
+ self._ruleset = ruleset
104
+ self._external = external
105
+ self._sync_ruleset_intensity()
106
+
107
+ def _sync_ruleset_intensity(self) -> None:
108
+ """Propagate the composite's intensity to the ruleset, if compatible."""
109
+ ruleset = self._ruleset
110
+ if isinstance(ruleset, PonytailRulesetOptimizer):
111
+ ruleset.set_intensity(self._intensity)
112
+
113
+ @property
114
+ def intensity(self) -> Intensity:
115
+ return self._intensity
116
+
117
+ def set_intensity(self, intensity: Intensity | str) -> None:
118
+ self._intensity = Intensity.parse(intensity)
119
+ # Keep the ruleset in sync so its output reflects the new level
120
+ # when we fall back to it.
121
+ if isinstance(self._ruleset, PonytailRulesetOptimizer):
122
+ self._ruleset.set_intensity(self._intensity)
123
+
124
+ async def optimize_chat(self, request: ChatRequest) -> OptimizedRequest:
125
+ if self._intensity is Intensity.OFF:
126
+ return OptimizedRequest(
127
+ request=request,
128
+ notes=("optimizer off",),
129
+ intensity=Intensity.OFF,
130
+ source="passthrough",
131
+ )
132
+
133
+ if self._external is not None and await self._external.is_available():
134
+ result = await self._external.optimize_chat(request)
135
+ return OptimizedRequest(
136
+ request=result.request,
137
+ notes=result.notes,
138
+ intensity=self._intensity,
139
+ source="external",
140
+ )
141
+
142
+ if self._ruleset is None:
143
+ return OptimizedRequest(
144
+ request=request,
145
+ notes=("no ruleset registered",),
146
+ intensity=self._intensity,
147
+ source="passthrough",
148
+ )
149
+
150
+ result = await self._ruleset.optimize_chat(request)
151
+ return OptimizedRequest(
152
+ request=result.request,
153
+ notes=result.notes,
154
+ intensity=self._intensity,
155
+ source="ruleset",
156
+ )
157
+
158
+
159
+ def _ensure_user_message(messages: Sequence[ChatMessage]) -> bool:
160
+ return any(m.role.value == "user" for m in messages)
161
+
162
+
163
+ def _clone_request(request: ChatRequest, messages: Sequence[ChatMessage]) -> ChatRequest:
164
+ """Return a copy of ``request`` with ``messages`` replaced."""
165
+ return request.model_copy(update={"messages": list(messages)})
166
+
167
+
168
+ __all__ = [
169
+ "CompositeOptimizer",
170
+ "Intensity",
171
+ "OptimizedProvider",
172
+ "OptimizedRequest",
173
+ "PonytailCLIOptimizer",
174
+ "PonytailRulesetOptimizer",
175
+ "PromptOptimizer",
176
+ ]
177
+
178
+ # Re-export the two concrete implementations and the decorator.
179
+ from forgecli.optimizer.ponytail.cli import PonytailCLIOptimizer # noqa: E402
180
+ from forgecli.optimizer.ponytail.decorator import OptimizedProvider # noqa: E402
181
+ from forgecli.optimizer.ponytail.ruleset import PonytailRulesetOptimizer # noqa: E402
@@ -0,0 +1,159 @@
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("FORGECLI_PONYTAIL_BIN", "ponytail")
50
+ self._timeout = timeout
51
+
52
+ @property
53
+ def executable(self) -> str:
54
+ return self._executable
55
+
56
+ async def is_available(self) -> bool:
57
+ return shutil.which(self._executable) is not None
58
+
59
+ async def optimize_chat(self, request: ChatRequest) -> OptimizedRequest:
60
+ if not await self.is_available():
61
+ raise PonytailCLIError(f"Ponytail executable {self._executable!r} not found on PATH")
62
+
63
+ binary = shutil.which(self._executable) or self._executable
64
+ payload = _request_to_json(request)
65
+ with tempfile.NamedTemporaryFile(
66
+ "w", suffix=".json", delete=False, encoding="utf-8"
67
+ ) as src:
68
+ src.write(payload)
69
+ src_path = Path(src.name)
70
+ try:
71
+ proc = await asyncio.create_subprocess_exec(
72
+ binary,
73
+ "optimize",
74
+ "--stdin",
75
+ str(src_path),
76
+ stdout=asyncio.subprocess.PIPE,
77
+ stderr=asyncio.subprocess.PIPE,
78
+ )
79
+ try:
80
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=self._timeout)
81
+ except TimeoutError as exc:
82
+ proc.kill()
83
+ raise PonytailCLIError(
84
+ f"ponytail optimize timed out after {self._timeout}s"
85
+ ) from exc
86
+ finally:
87
+ with contextlib.suppress(OSError):
88
+ src_path.unlink()
89
+
90
+ if proc.returncode != 0:
91
+ raise PonytailCLIError(
92
+ f"ponytail exited with {proc.returncode}: {stderr.decode(errors='replace').strip()}"
93
+ )
94
+
95
+ return _json_to_optimized(stdout.decode(errors="replace"))
96
+
97
+
98
+ def _request_to_json(request: ChatRequest) -> str:
99
+ """Serialize a :class:`ChatRequest` for the CLI."""
100
+ import json
101
+
102
+ return json.dumps(
103
+ {
104
+ "model": request.model,
105
+ "temperature": request.temperature,
106
+ "max_tokens": request.max_tokens,
107
+ "messages": [
108
+ {
109
+ "role": m.role.value,
110
+ "content": m.content,
111
+ "name": m.name,
112
+ "tool_call_id": m.tool_call_id,
113
+ }
114
+ for m in request.messages
115
+ ],
116
+ },
117
+ ensure_ascii=False,
118
+ )
119
+
120
+
121
+ def _json_to_optimized(raw: str) -> OptimizedRequest:
122
+ """Parse the JSON the CLI emitted and rebuild an :class:`OptimizedRequest`."""
123
+ import json
124
+
125
+ try:
126
+ payload = json.loads(raw)
127
+ except json.JSONDecodeError as exc:
128
+ raise PonytailCLIError(f"ponytail returned invalid JSON: {exc}") from exc
129
+
130
+ try:
131
+ intensity = Intensity.parse(payload.get("intensity", "lite"))
132
+ messages = [
133
+ ChatMessage(
134
+ role=Role(m["role"]),
135
+ content=m["content"],
136
+ name=m.get("name"),
137
+ tool_call_id=m.get("tool_call_id"),
138
+ )
139
+ for m in payload.get("messages", [])
140
+ ]
141
+ except (KeyError, ValueError) as exc:
142
+ raise PonytailCLIError(f"ponytail returned an unexpected payload: {exc}") from exc
143
+
144
+ new_request = ChatRequest(
145
+ model=payload.get("model"),
146
+ messages=messages,
147
+ temperature=payload.get("temperature"),
148
+ max_tokens=payload.get("max_tokens"),
149
+ )
150
+ notes = tuple(payload.get("notes", ()))
151
+ return OptimizedRequest(
152
+ request=new_request,
153
+ notes=notes,
154
+ intensity=intensity,
155
+ source="external",
156
+ )
157
+
158
+
159
+ __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,51 @@
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("optimizer_intensity")
28
+ backend = extras.get("optimizer.backend") or extras.get("optimizer_backend")
29
+ binary = extras.get("optimizer.binary") or extras.get("optimizer_binary")
30
+ if isinstance(intensity, str):
31
+ try:
32
+ state.intensity = Intensity.parse(intensity)
33
+ except ValueError:
34
+ state.intensity = Intensity.LITE
35
+ elif isinstance(intensity, Intensity):
36
+ state.intensity = intensity
37
+ if isinstance(backend, str):
38
+ state.backend = backend
39
+ if isinstance(binary, str):
40
+ state.binary = binary
41
+ return state
42
+
43
+ def to_extras(self) -> dict[str, str]:
44
+ return {
45
+ "optimizer.intensity": self.intensity.value,
46
+ "optimizer.backend": self.backend,
47
+ "optimizer.binary": self.binary,
48
+ }
49
+
50
+
51
+ __all__ = ["OptimizerState"]