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,62 @@
1
+ """Optional adapter that shells out to an external ``caveman`` binary.
2
+
3
+ The external binary is **not** shipped with ForgeCLI. The adapter
4
+ is here so the :class:`CavemanCompositeOptimizer` can transparently
5
+ pick it when the user has installed a caveman CLI tool separately.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import logging
12
+ import shutil
13
+
14
+ from forgecli.optimizer.caveman import (
15
+ CavemanIntensity,
16
+ CavemanPromptOptimizer,
17
+ OptimizedRequest,
18
+ )
19
+ from forgecli.providers.base import ChatRequest
20
+
21
+ _log = logging.getLogger("forgecli.optimizer.caveman.cli")
22
+
23
+
24
+ class CavemanCLIOptimizer(CavemanPromptOptimizer):
25
+ """Shell out to an external ``caveman`` binary for prompt optimization."""
26
+
27
+ name = "caveman.cli"
28
+
29
+ def __init__(
30
+ self,
31
+ executable: str = "caveman",
32
+ timeout_seconds: float = 30.0,
33
+ ) -> None:
34
+ self._executable = executable
35
+ self._timeout = timeout_seconds
36
+
37
+ async def is_available(self) -> bool:
38
+ """Return True when the ``caveman`` binary is on ``PATH``."""
39
+ loop = asyncio.get_running_loop()
40
+ return await loop.run_in_executor(None, lambda: shutil.which(self._executable) is not None)
41
+
42
+ async def optimize_chat(self, request: ChatRequest) -> OptimizedRequest:
43
+ """Run the external caveman binary and return the optimized request.
44
+
45
+ Currently a stub that returns the request unchanged \u2014 the
46
+ caveman CLI protocol hasn't been standardised yet. Implement
47
+ this method once a stable CLI interface exists.
48
+ """
49
+ _log.debug(
50
+ "caveman CLI adapter called; binary=%s available=%s",
51
+ self._executable,
52
+ await self.is_available(),
53
+ )
54
+ return OptimizedRequest(
55
+ request=request,
56
+ notes=("caveman CLI stub (passthrough)",),
57
+ intensity=CavemanIntensity.LITE,
58
+ source="caveman-cli",
59
+ )
60
+
61
+
62
+ __all__ = ["CavemanCLIOptimizer"]
@@ -0,0 +1,67 @@
1
+ """A :class:`Provider` decorator that runs a :class:`CavemanPromptOptimizer`
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``) \u2014 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.caveman import CavemanPromptOptimizer, OptimizedRequest
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 CavemanProvider(Provider[Any]):
27
+ """Wrap a :class:`Provider` so every chat call is caveman-optimized."""
28
+
29
+ name = "caveman-provider"
30
+
31
+ def __init__(
32
+ self,
33
+ base: Provider[Any],
34
+ optimizer: CavemanPromptOptimizer,
35
+ ) -> None:
36
+ super().__init__(base.config)
37
+ self._base = base
38
+ self._optimizer = optimizer
39
+
40
+ @property
41
+ def base(self) -> Provider[Any]:
42
+ return self._base
43
+
44
+ @property
45
+ def optimizer(self) -> CavemanPromptOptimizer:
46
+ return self._optimizer
47
+
48
+ async def chat(self, request: ChatRequest) -> ChatResponse:
49
+ optimized: OptimizedRequest = await self._optimizer.optimize_chat(request)
50
+ return await self._base.chat(optimized.request)
51
+
52
+ async def stream(self, request: ChatRequest) -> AsyncIterator[StreamChunk]:
53
+ optimized: OptimizedRequest = await self._optimizer.optimize_chat(request)
54
+ async for chunk in self._base.stream(optimized.request):
55
+ yield chunk
56
+
57
+ async def embed(self, request: EmbeddingRequest) -> EmbeddingResponse:
58
+ return await self._base.embed(request)
59
+
60
+ async def list_models(self) -> list[ModelInfo]:
61
+ return await self._base.list_models()
62
+
63
+ def validate(self) -> None:
64
+ return self._base.validate()
65
+
66
+
67
+ __all__ = ["CavemanProvider"]
@@ -0,0 +1,51 @@
1
+ """Factory for building the live :class:`CavemanPromptOptimizer`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from forgecli.config.settings import ForgeSettings
6
+ from forgecli.optimizer.caveman import (
7
+ CavemanCLIOptimizer,
8
+ CavemanCompositeOptimizer,
9
+ CavemanIntensity,
10
+ CavemanPromptOptimizer,
11
+ CavemanRulesetOptimizer,
12
+ )
13
+ from forgecli.optimizer.caveman.state import CavemanState
14
+
15
+
16
+ def build_caveman_optimizer(
17
+ state: CavemanState,
18
+ settings: ForgeSettings | None = None,
19
+ ) -> CavemanPromptOptimizer:
20
+ """Build the composite Caveman optimizer from 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 = CavemanRulesetOptimizer(intensity=state.intensity)
27
+
28
+ external: CavemanPromptOptimizer | None = None
29
+ if state.backend in {"cli", "auto"}:
30
+ binary = state.binary
31
+ if settings is not None and settings.caveman.binary:
32
+ binary = settings.caveman.binary
33
+ external = CavemanCLIOptimizer(executable=binary)
34
+ elif state.backend == "ruleset":
35
+ external = None
36
+
37
+ if settings is not None and not settings.caveman.enabled:
38
+ return CavemanCompositeOptimizer(
39
+ intensity=CavemanIntensity.OFF,
40
+ ruleset=ruleset,
41
+ external=external,
42
+ )
43
+
44
+ return CavemanCompositeOptimizer(
45
+ intensity=state.intensity,
46
+ ruleset=ruleset,
47
+ external=external,
48
+ )
49
+
50
+
51
+ __all__ = ["build_caveman_optimizer"]
@@ -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,50 @@
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("caveman_intensity")
27
+ backend = extras.get("caveman.backend") or extras.get("caveman_backend")
28
+ binary = extras.get("caveman.binary") or extras.get("caveman_binary")
29
+ if isinstance(intensity, str):
30
+ try:
31
+ state.intensity = CavemanIntensity.parse(intensity)
32
+ except ValueError:
33
+ state.intensity = CavemanIntensity.LITE
34
+ elif isinstance(intensity, CavemanIntensity):
35
+ state.intensity = intensity
36
+ if isinstance(backend, str):
37
+ state.backend = backend
38
+ if isinstance(binary, str):
39
+ state.binary = binary
40
+ return state
41
+
42
+ def to_extras(self) -> dict[str, str]:
43
+ return {
44
+ "caveman.intensity": self.intensity.value,
45
+ "caveman.backend": self.backend,
46
+ "caveman.binary": self.binary,
47
+ }
48
+
49
+
50
+ __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