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.
Files changed (156) 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 +137 -0
  19. forgecli/cli/commands_wrappers.py +63 -0
  20. forgecli/cli/main.py +122 -0
  21. forgecli/cli/ui.py +56 -0
  22. forgecli/config/__init__.py +28 -0
  23. forgecli/config/loader.py +85 -0
  24. forgecli/config/settings.py +204 -0
  25. forgecli/config/writer.py +90 -0
  26. forgecli/core/__init__.py +35 -0
  27. forgecli/core/container.py +68 -0
  28. forgecli/core/context.py +54 -0
  29. forgecli/core/credentials.py +114 -0
  30. forgecli/core/errors.py +27 -0
  31. forgecli/core/events.py +67 -0
  32. forgecli/core/logging.py +47 -0
  33. forgecli/core/models.py +159 -0
  34. forgecli/core/plugins.py +56 -0
  35. forgecli/core/service.py +22 -0
  36. forgecli/docs/__init__.py +5 -0
  37. forgecli/docs/generator.py +119 -0
  38. forgecli/engine/__init__.py +105 -0
  39. forgecli/engine/context.py +171 -0
  40. forgecli/engine/defaults.py +89 -0
  41. forgecli/engine/events.py +185 -0
  42. forgecli/engine/execution.py +526 -0
  43. forgecli/engine/plugins.py +146 -0
  44. forgecli/engine/runner.py +155 -0
  45. forgecli/engine/stages/__init__.py +33 -0
  46. forgecli/engine/stages/caveman_optimizer.py +47 -0
  47. forgecli/engine/stages/context_optimizer.py +44 -0
  48. forgecli/engine/stages/execution_engine_stage.py +108 -0
  49. forgecli/engine/stages/git_engine.py +30 -0
  50. forgecli/engine/stages/intent_analyzer.py +38 -0
  51. forgecli/engine/stages/model_router.py +66 -0
  52. forgecli/engine/stages/planning_engine.py +45 -0
  53. forgecli/engine/stages/repository_analyzer.py +54 -0
  54. forgecli/engine/stages/validation_engine.py +89 -0
  55. forgecli/git/__init__.py +9 -0
  56. forgecli/git/repo.py +55 -0
  57. forgecli/git/service.py +45 -0
  58. forgecli/graph/__init__.py +56 -0
  59. forgecli/graph/backend_graphify.py +453 -0
  60. forgecli/graph/edge.py +19 -0
  61. forgecli/graph/graph.py +85 -0
  62. forgecli/graph/graphify.py +412 -0
  63. forgecli/graph/indexer.py +82 -0
  64. forgecli/graph/node.py +47 -0
  65. forgecli/graph/repository.py +164 -0
  66. forgecli/memory/__init__.py +12 -0
  67. forgecli/memory/cache.py +61 -0
  68. forgecli/memory/history.py +138 -0
  69. forgecli/memory/store.py +87 -0
  70. forgecli/optimizer/__init__.py +14 -0
  71. forgecli/optimizer/caveman/__init__.py +173 -0
  72. forgecli/optimizer/caveman/cli.py +64 -0
  73. forgecli/optimizer/caveman/decorator.py +67 -0
  74. forgecli/optimizer/caveman/factory.py +51 -0
  75. forgecli/optimizer/caveman/ruleset.py +156 -0
  76. forgecli/optimizer/caveman/state.py +52 -0
  77. forgecli/optimizer/chunker.py +85 -0
  78. forgecli/optimizer/optimizer.py +81 -0
  79. forgecli/optimizer/ponytail/__init__.py +183 -0
  80. forgecli/optimizer/ponytail/cli.py +168 -0
  81. forgecli/optimizer/ponytail/decorator.py +70 -0
  82. forgecli/optimizer/ponytail/factory.py +51 -0
  83. forgecli/optimizer/ponytail/ruleset.py +168 -0
  84. forgecli/optimizer/ponytail/state.py +53 -0
  85. forgecli/optimizer/ranker.py +37 -0
  86. forgecli/optimizer/summarizer.py +44 -0
  87. forgecli/orchestrator/__init__.py +707 -0
  88. forgecli/planner/__init__.py +56 -0
  89. forgecli/planner/agent.py +61 -0
  90. forgecli/planner/plan.py +65 -0
  91. forgecli/planner/planner.py +17 -0
  92. forgecli/planner/render.py +271 -0
  93. forgecli/planner/serialize.py +106 -0
  94. forgecli/planner/software.py +834 -0
  95. forgecli/platform/__init__.py +91 -0
  96. forgecli/platform/core.py +201 -0
  97. forgecli/platform/deps.py +354 -0
  98. forgecli/platform/paths.py +236 -0
  99. forgecli/platform/shell.py +176 -0
  100. forgecli/platform/update.py +252 -0
  101. forgecli/plugins/__init__.py +251 -0
  102. forgecli/prompts/__init__.py +11 -0
  103. forgecli/prompts/loader.py +28 -0
  104. forgecli/prompts/registry.py +32 -0
  105. forgecli/prompts/renderer.py +23 -0
  106. forgecli/providers/__init__.py +39 -0
  107. forgecli/providers/anthropic.py +206 -0
  108. forgecli/providers/base.py +206 -0
  109. forgecli/providers/builtin.py +26 -0
  110. forgecli/providers/conversation.py +78 -0
  111. forgecli/providers/google.py +295 -0
  112. forgecli/providers/http_base.py +211 -0
  113. forgecli/providers/mock.py +113 -0
  114. forgecli/providers/openai.py +202 -0
  115. forgecli/providers/openai_compatible.py +728 -0
  116. forgecli/providers/router.py +345 -0
  117. forgecli/providers/router_state.py +89 -0
  118. forgecli/review/__init__.py +45 -0
  119. forgecli/review/analyzer.py +124 -0
  120. forgecli/review/analyzers/__init__.py +1 -0
  121. forgecli/review/analyzers/architecture.py +258 -0
  122. forgecli/review/analyzers/complexity.py +167 -0
  123. forgecli/review/analyzers/dead_code.py +262 -0
  124. forgecli/review/analyzers/duplicates.py +163 -0
  125. forgecli/review/analyzers/performance.py +165 -0
  126. forgecli/review/analyzers/security.py +251 -0
  127. forgecli/review/finding.py +68 -0
  128. forgecli/review/report.py +311 -0
  129. forgecli/review/repository.py +131 -0
  130. forgecli/review/suggestions.py +98 -0
  131. forgecli/runtime/__init__.py +6 -0
  132. forgecli/runtime/cache_store.py +77 -0
  133. forgecli/runtime/prepare.py +199 -0
  134. forgecli/runtime/wrappers.py +152 -0
  135. forgecli/sdk/__init__.py +132 -0
  136. forgecli/sdk/events.py +206 -0
  137. forgecli/sdk/interfaces.py +282 -0
  138. forgecli/sdk/loader.py +243 -0
  139. forgecli/sdk/manager.py +693 -0
  140. forgecli/sdk/manifest.py +397 -0
  141. forgecli/sdk/sandbox.py +197 -0
  142. forgecli/sdk/version.py +316 -0
  143. forgecli/templates/__init__.py +9 -0
  144. forgecli/templates/engine.py +37 -0
  145. forgecli/templates/registry.py +32 -0
  146. forgecli/utils/__init__.py +19 -0
  147. forgecli/utils/fs.py +91 -0
  148. forgecli/utils/ids.py +11 -0
  149. forgecli/utils/io.py +27 -0
  150. forgecli/utils/paths.py +70 -0
  151. forgecli/utils/timing.py +25 -0
  152. forgeoptimizer-1.0.0.dist-info/METADATA +169 -0
  153. forgeoptimizer-1.0.0.dist-info/RECORD +156 -0
  154. forgeoptimizer-1.0.0.dist-info/WHEEL +4 -0
  155. forgeoptimizer-1.0.0.dist-info/entry_points.txt +2 -0
  156. 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