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,156 @@
1
+ """Self-contained Python implementation of the Caveman ruleset.
2
+
3
+ The ruleset mirrors the behavior described on https://github.com/JuliusBrussee/caveman.
4
+ It does not shell out to anything; it rewrites the system message of a
5
+ :class:`ChatRequest` to bias the model toward token-efficient output.
6
+
7
+ The five intensity levels match the official Caveman project:
8
+
9
+ * ``off`` - pass-through (handled by the composite, not this class).
10
+ * ``lite`` - concise communication; drop filler words and pleasantries.
11
+ * ``full`` - full caveman: fragments, [thing][action][reason] pattern.
12
+ * ``ultra`` - maximum compression; grunt-level communication.
13
+ * ``wenyan`` - Classical Chinese literary style for max semantic density.
14
+
15
+ The output is deterministic for a given (intensity, prompt) pair, so
16
+ tests can pin it.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from forgecli.optimizer.caveman import (
22
+ CavemanIntensity,
23
+ CavemanPromptOptimizer,
24
+ OptimizedRequest,
25
+ _clone_request,
26
+ _ensure_user_message,
27
+ )
28
+ from forgecli.providers.base import ChatMessage, ChatRequest, Role
29
+
30
+ _CAVEMAN_GUIDANCE: dict[CavemanIntensity, str] = {
31
+ CavemanIntensity.OFF: "",
32
+ CavemanIntensity.LITE: (
33
+ "CAVEMAN (lite): communicate concisely. "
34
+ "Drop filler words (just, really, basically, actually). "
35
+ "Drop pleasantries (sure, certainly, happy to, I think). "
36
+ "Use short sentences. Keep technical terms exact."
37
+ ),
38
+ CavemanIntensity.FULL: (
39
+ "CAVEMAN (full): talk like caveman. Brain still big, mouth small.\n\n"
40
+ "Rules:\n"
41
+ "- Drop filler words, pleasantries, transition phrases\n"
42
+ "- Use sentence fragments where meaning is clear\n"
43
+ "- Keep technical terms exact \u2014 never simplify them\n"
44
+ "- Follow [thing] [action] [reason] pattern\n"
45
+ "- No summaries, no disclaimers, no meta-commentary\n"
46
+ "- Code blocks and error messages stay unmodified"
47
+ ),
48
+ CavemanIntensity.ULTRA: (
49
+ "CAVEMAN (ultra): maximum compression.\n\n"
50
+ "- One sentence max per idea\n"
51
+ "- No articles (a, an, the) unless essential\n"
52
+ "- No polite prefixes \u2014 get straight to the point\n"
53
+ "- [action] [thing] directly\n"
54
+ "- Grunt-level communication preferred\n"
55
+ "- Code unchanged. Technical terms unchanged."
56
+ ),
57
+ CavemanIntensity.WENYAN: (
58
+ "CAVEMAN (wenyan): respond in Classical Chinese literary style "
59
+ "(\u6587\u8a00 / wenyanwen).\n\n"
60
+ "- Maximum semantic density per character\n"
61
+ "- Use classical parallel structure where appropriate\n"
62
+ "- Eliminate all modern conversational padding\n"
63
+ "- \u4e00\u8a00\u4ee5\u84cb\u4e4b: express essence in minimum characters\n"
64
+ "- Code blocks, technical terms, and proper nouns stay in original form"
65
+ ),
66
+ }
67
+
68
+
69
+ class CavemanRulesetOptimizer(CavemanPromptOptimizer):
70
+ """Rewrite system messages to apply the Caveman ruleset."""
71
+
72
+ name = "caveman.ruleset"
73
+
74
+ def __init__(self, *, intensity: CavemanIntensity = CavemanIntensity.LITE) -> None:
75
+ self._intensity = CavemanIntensity.parse(intensity)
76
+
77
+ @property
78
+ def intensity(self) -> CavemanIntensity:
79
+ return self._intensity
80
+
81
+ def set_intensity(self, intensity: CavemanIntensity | str) -> None:
82
+ self._intensity = CavemanIntensity.parse(intensity)
83
+
84
+ async def optimize_chat(self, request: ChatRequest) -> OptimizedRequest:
85
+ if self._intensity is CavemanIntensity.OFF:
86
+ return OptimizedRequest(
87
+ request=request,
88
+ notes=("caveman off",),
89
+ intensity=CavemanIntensity.OFF,
90
+ source="caveman-ruleset",
91
+ )
92
+
93
+ guidance = _CAVEMAN_GUIDANCE[self._intensity]
94
+ messages = list(request.messages)
95
+
96
+ if not _ensure_user_message(messages):
97
+ return OptimizedRequest(
98
+ request=request,
99
+ notes=("no user message; caveman passthrough",),
100
+ intensity=self._intensity,
101
+ source="caveman-ruleset",
102
+ )
103
+
104
+ rewritten = _rewrite_messages(messages, guidance)
105
+ notes = _build_notes(self._intensity)
106
+ return OptimizedRequest(
107
+ request=_clone_request(request, rewritten),
108
+ notes=notes,
109
+ intensity=self._intensity,
110
+ source="caveman-ruleset",
111
+ )
112
+
113
+
114
+ def _rewrite_messages(
115
+ messages: list[ChatMessage],
116
+ guidance: str,
117
+ ) -> list[ChatMessage]:
118
+ """Insert Caveman guidance at the head of the conversation.
119
+
120
+ Rules:
121
+ * If the first message is a system message, prepend the guidance
122
+ to it (separated by two newlines).
123
+ * If there is no system message, insert one.
124
+ * If the first system message is *empty*, replace it.
125
+ * Otherwise the user/assistant turns are left untouched.
126
+ """
127
+ if not messages:
128
+ return [ChatMessage(role=Role.SYSTEM, content=guidance)]
129
+
130
+ first = messages[0]
131
+ if first.role is Role.SYSTEM:
132
+ existing = first.content.strip()
133
+ if not existing:
134
+ return [ChatMessage(role=Role.SYSTEM, content=guidance), *messages[1:]]
135
+ new_content = f"{guidance}\n\n{first.content}" if guidance else first.content
136
+ return [ChatMessage(role=Role.SYSTEM, content=new_content), *messages[1:]]
137
+
138
+ return [ChatMessage(role=Role.SYSTEM, content=guidance), *messages]
139
+
140
+
141
+ def _build_notes(intensity: CavemanIntensity) -> tuple[str, ...]:
142
+ notes: list[str] = [f"caveman intensity={intensity.value}"]
143
+ if intensity is CavemanIntensity.FULL:
144
+ notes.append("caveman full mode \u2014 fragments + [thing][action][reason]")
145
+ elif intensity is CavemanIntensity.ULTRA:
146
+ notes.append("caveman ultra \u2014 maximum compression")
147
+ elif intensity is CavemanIntensity.WENYAN:
148
+ notes.append("caveman wenyan \u2014 classical Chinese literary style")
149
+ elif intensity is CavemanIntensity.LITE:
150
+ notes.append("caveman lite \u2014 concise, no filler")
151
+ return tuple(notes)
152
+
153
+
154
+ __all__ = [
155
+ "CavemanRulesetOptimizer",
156
+ ]
@@ -0,0 +1,52 @@
1
+ """Live runtime state for the Caveman optimizer.
2
+
3
+ The intensity can be flipped at runtime. Because the setting is held
4
+ in :class:`AppContext.extras`, every subcommand that resolves the
5
+ optimizer sees the latest value. Tests can override it directly.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+ from forgecli.optimizer.caveman import CavemanIntensity
13
+
14
+
15
+ @dataclass
16
+ class CavemanState:
17
+ """Mutable intensity for the running CLI process."""
18
+
19
+ intensity: CavemanIntensity = CavemanIntensity.LITE
20
+ backend: str = "ruleset" # "ruleset" | "cli" | "auto"
21
+ binary: str = "caveman"
22
+
23
+ @classmethod
24
+ def from_extras(cls, extras: dict[str, object]) -> CavemanState:
25
+ state = cls()
26
+ intensity = extras.get("caveman.intensity") or extras.get(
27
+ "caveman_intensity"
28
+ )
29
+ backend = extras.get("caveman.backend") or extras.get("caveman_backend")
30
+ binary = extras.get("caveman.binary") or extras.get("caveman_binary")
31
+ if isinstance(intensity, str):
32
+ try:
33
+ state.intensity = CavemanIntensity.parse(intensity)
34
+ except ValueError:
35
+ state.intensity = CavemanIntensity.LITE
36
+ elif isinstance(intensity, CavemanIntensity):
37
+ state.intensity = intensity
38
+ if isinstance(backend, str):
39
+ state.backend = backend
40
+ if isinstance(binary, str):
41
+ state.binary = binary
42
+ return state
43
+
44
+ def to_extras(self) -> dict[str, str]:
45
+ return {
46
+ "caveman.intensity": self.intensity.value,
47
+ "caveman.backend": self.backend,
48
+ "caveman.binary": self.binary,
49
+ }
50
+
51
+
52
+ __all__ = ["CavemanState"]
@@ -0,0 +1,85 @@
1
+ """Chunkers split long content into model-sized pieces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from dataclasses import dataclass
7
+ from typing import Protocol
8
+
9
+
10
+ class SupportsLen(Protocol):
11
+ """Minimal protocol for anything with a ``text`` attribute."""
12
+
13
+ text: str
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Chunk:
18
+ """A piece of content along with its provenance."""
19
+
20
+ text: str
21
+ index: int
22
+ start: int
23
+ end: int
24
+ source_id: str | None = None
25
+
26
+
27
+ class Chunker:
28
+ """Split text into overlapping windows of ``size`` characters.
29
+
30
+ This is intentionally the dumbest possible chunker; smarter chunkers
31
+ (e.g. AST-aware, sentence-aware) can be added by implementing the
32
+ same interface and registering them with the optimizer.
33
+ """
34
+
35
+ def __init__(self, *, size: int = 4000, overlap: int = 200) -> None:
36
+ if size <= 0:
37
+ raise ValueError("chunk size must be positive")
38
+ if overlap < 0 or overlap >= size:
39
+ raise ValueError("overlap must be in [0, size)")
40
+ self._size = size
41
+ self._overlap = overlap
42
+
43
+ @property
44
+ def size(self) -> int:
45
+ return self._size
46
+
47
+ @property
48
+ def overlap(self) -> int:
49
+ return self._overlap
50
+
51
+ def split(self, text: str, *, source_id: str | None = None) -> list[Chunk]:
52
+ """Split ``text`` into chunks of up to ``self.size`` characters."""
53
+ if not text:
54
+ return []
55
+ chunks: list[Chunk] = []
56
+ step = self._size - self._overlap
57
+ idx = 0
58
+ pos = 0
59
+ n = len(text)
60
+ while pos < n:
61
+ end = min(pos + self._size, n)
62
+ chunks.append(
63
+ Chunk(
64
+ text=text[pos:end],
65
+ index=idx,
66
+ start=pos,
67
+ end=end,
68
+ source_id=source_id,
69
+ )
70
+ )
71
+ idx += 1
72
+ if end == n:
73
+ break
74
+ pos += step
75
+ return chunks
76
+
77
+ def split_many(
78
+ self,
79
+ items: Iterable[tuple[str, str | None]],
80
+ ) -> list[Chunk]:
81
+ """Split several texts; ``items`` yields ``(text, source_id)`` pairs."""
82
+ result: list[Chunk] = []
83
+ for text, source_id in items:
84
+ result.extend(self.split(text, source_id=source_id))
85
+ return result
@@ -0,0 +1,81 @@
1
+ """High-level context orchestrator: chunk → rank → (optionally) summarize."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ from forgecli.core.service import Service
8
+ from forgecli.optimizer.chunker import Chunk, Chunker
9
+ from forgecli.optimizer.ranker import Ranker
10
+ from forgecli.optimizer.summarizer import Summarizer
11
+
12
+
13
+ @dataclass
14
+ class OptimizedContext:
15
+ """The output of :meth:`ContextOptimizer.optimize`."""
16
+
17
+ chunks: list[Chunk] = field(default_factory=list)
18
+ summary: str = ""
19
+ total_chars: int = 0
20
+
21
+
22
+ class ContextOptimizer(Service):
23
+ """Orchestrates chunking, ranking, and summarization."""
24
+
25
+ name = "optimizer"
26
+
27
+ def __init__(
28
+ self,
29
+ *,
30
+ chunker: Chunker | None = None,
31
+ ranker: Ranker | None = None,
32
+ summarizer: Summarizer | None = None,
33
+ max_context_tokens: int = 200_000,
34
+ ) -> None:
35
+ super().__init__()
36
+ self._chunker = chunker or Chunker()
37
+ self._ranker = ranker or Ranker()
38
+ self._summarizer = summarizer
39
+ self._max_tokens = max_context_tokens
40
+
41
+ @property
42
+ def chunker(self) -> Chunker:
43
+ return self._chunker
44
+
45
+ @property
46
+ def ranker(self) -> Ranker:
47
+ return self._ranker
48
+
49
+ @property
50
+ def summarizer(self) -> Summarizer | None:
51
+ return self._summarizer
52
+
53
+ def optimize(
54
+ self,
55
+ text: str,
56
+ *,
57
+ query: str = "",
58
+ source_id: str | None = None,
59
+ ) -> OptimizedContext:
60
+ """Chunk ``text`` and rank it against ``query`` (no AI call)."""
61
+ chunks = self._chunker.split(text, source_id=source_id)
62
+ if query:
63
+ ranked = self._ranker.rank(query, chunks)
64
+ chunks = [chunk for chunk, _score in ranked]
65
+ return OptimizedContext(
66
+ chunks=chunks,
67
+ total_chars=sum(len(c.text) for c in chunks),
68
+ )
69
+
70
+ async def optimize_and_summarize(
71
+ self,
72
+ text: str,
73
+ *,
74
+ query: str = "",
75
+ source_id: str | None = None,
76
+ ) -> OptimizedContext:
77
+ """Like :meth:`optimize` but produces a summary when a summarizer is set."""
78
+ ctx = self.optimize(text, query=query, source_id=source_id)
79
+ if self._summarizer is not None and ctx.chunks:
80
+ ctx.summary = await self._summarizer.summarize(ctx.chunks)
81
+ return ctx
@@ -0,0 +1,183 @@
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
+
23
+ from forgecli.providers.base import ChatMessage, ChatRequest
24
+
25
+
26
+ class Intensity(str, Enum):
27
+ """How aggressively to optimize prompts.
28
+
29
+ The semantics match the Ponytail commands exactly:
30
+
31
+ * ``off`` - no rewriting; pass prompts through unchanged.
32
+ * ``lite`` - default; appends a one-line hint naming the lazier
33
+ alternative and lets the model decide.
34
+ * ``full`` - applies the full "ladder" ruleset and instructs the
35
+ model to ship the shortest diff.
36
+ * ``ultra`` - aggressive YAGNI; instructs the model to challenge
37
+ the rest of the requirement in the same breath.
38
+ """
39
+
40
+ OFF = "off"
41
+ LITE = "lite"
42
+ FULL = "full"
43
+ ULTRA = "ultra"
44
+
45
+ @classmethod
46
+ def parse(cls, value: str | Intensity | None) -> Intensity:
47
+ """Parse a string into an :class:`Intensity`, falling back to LITE."""
48
+ if value is None or value == "":
49
+ return cls.LITE
50
+ if isinstance(value, cls):
51
+ return value
52
+ try:
53
+ return cls(value.lower())
54
+ except ValueError as exc:
55
+ raise ValueError(
56
+ f"Unknown intensity {value!r}; expected one of "
57
+ f"{', '.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: Intensity = 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(
83
+ self, request: ChatRequest
84
+ ) -> OptimizedRequest:
85
+ """Return an optimized copy of ``request``."""
86
+
87
+ async def is_available(self) -> bool:
88
+ """Return whether this optimizer is available to use."""
89
+ return True
90
+
91
+
92
+ class CompositeOptimizer(PromptOptimizer):
93
+ """Pick the right optimizer for the configured :class:`Intensity`."""
94
+
95
+ name = "composite"
96
+
97
+ def __init__(
98
+ self,
99
+ *,
100
+ intensity: Intensity = Intensity.LITE,
101
+ ruleset: PromptOptimizer | None = None,
102
+ external: PromptOptimizer | None = None,
103
+ ) -> None:
104
+ self._intensity = intensity
105
+ self._ruleset = ruleset
106
+ self._external = external
107
+ self._sync_ruleset_intensity()
108
+
109
+ def _sync_ruleset_intensity(self) -> None:
110
+ """Propagate the composite's intensity to the ruleset, if compatible."""
111
+ ruleset = self._ruleset
112
+ if isinstance(ruleset, PonytailRulesetOptimizer):
113
+ ruleset.set_intensity(self._intensity)
114
+
115
+ @property
116
+ def intensity(self) -> Intensity:
117
+ return self._intensity
118
+
119
+ def set_intensity(self, intensity: Intensity | str) -> None:
120
+ self._intensity = Intensity.parse(intensity)
121
+ # Keep the ruleset in sync so its output reflects the new level
122
+ # when we fall back to it.
123
+ if isinstance(self._ruleset, PonytailRulesetOptimizer):
124
+ self._ruleset.set_intensity(self._intensity)
125
+
126
+ async def optimize_chat(self, request: ChatRequest) -> OptimizedRequest:
127
+ if self._intensity is Intensity.OFF:
128
+ return OptimizedRequest(
129
+ request=request,
130
+ notes=("optimizer off",),
131
+ intensity=Intensity.OFF,
132
+ source="passthrough",
133
+ )
134
+
135
+ if self._external is not None and await self._external.is_available():
136
+ result = await self._external.optimize_chat(request)
137
+ return OptimizedRequest(
138
+ request=result.request,
139
+ notes=result.notes,
140
+ intensity=self._intensity,
141
+ source="external",
142
+ )
143
+
144
+ if self._ruleset is None:
145
+ return OptimizedRequest(
146
+ request=request,
147
+ notes=("no ruleset registered",),
148
+ intensity=self._intensity,
149
+ source="passthrough",
150
+ )
151
+
152
+ result = await self._ruleset.optimize_chat(request)
153
+ return OptimizedRequest(
154
+ request=result.request,
155
+ notes=result.notes,
156
+ intensity=self._intensity,
157
+ source="ruleset",
158
+ )
159
+
160
+
161
+ def _ensure_user_message(messages: Sequence[ChatMessage]) -> bool:
162
+ return any(m.role.value == "user" for m in messages)
163
+
164
+
165
+ def _clone_request(request: ChatRequest, messages: Sequence[ChatMessage]) -> ChatRequest:
166
+ """Return a copy of ``request`` with ``messages`` replaced."""
167
+ return request.model_copy(update={"messages": list(messages)})
168
+
169
+
170
+ __all__ = [
171
+ "CompositeOptimizer",
172
+ "Intensity",
173
+ "OptimizedProvider",
174
+ "OptimizedRequest",
175
+ "PonytailCLIOptimizer",
176
+ "PonytailRulesetOptimizer",
177
+ "PromptOptimizer",
178
+ ]
179
+
180
+ # Re-export the two concrete implementations and the decorator.
181
+ from forgecli.optimizer.ponytail.cli import PonytailCLIOptimizer # noqa: E402
182
+ from forgecli.optimizer.ponytail.decorator import OptimizedProvider # noqa: E402
183
+ from forgecli.optimizer.ponytail.ruleset import PonytailRulesetOptimizer # noqa: E402