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.
- forgecli/__init__.py +4 -0
- forgecli/build/__init__.py +135 -0
- forgecli/build/apply.py +218 -0
- forgecli/build/caveman_optimize.py +37 -0
- forgecli/build/diff_extract.py +218 -0
- forgecli/build/llm.py +167 -0
- forgecli/build/optimize.py +37 -0
- forgecli/build/pipeline.py +76 -0
- forgecli/build/retrieval.py +157 -0
- forgecli/build/summarize.py +76 -0
- forgecli/build/test_run.py +75 -0
- forgecli/builder/__init__.py +11 -0
- forgecli/builder/builder.py +53 -0
- forgecli/builder/editor.py +36 -0
- forgecli/builder/formatter.py +31 -0
- forgecli/cli/__init__.py +5 -0
- forgecli/cli/bootstrap.py +281 -0
- forgecli/cli/commands_graph.py +149 -0
- forgecli/cli/commands_wrappers.py +80 -0
- forgecli/cli/daemon.py +744 -0
- forgecli/cli/main.py +234 -0
- forgecli/cli/ui.py +56 -0
- forgecli/config/__init__.py +28 -0
- forgecli/config/loader.py +85 -0
- forgecli/config/settings.py +206 -0
- forgecli/config/writer.py +90 -0
- forgecli/core/__init__.py +35 -0
- forgecli/core/container.py +68 -0
- forgecli/core/context.py +54 -0
- forgecli/core/credentials.py +114 -0
- forgecli/core/errors.py +27 -0
- forgecli/core/events.py +67 -0
- forgecli/core/logging.py +47 -0
- forgecli/core/models.py +214 -0
- forgecli/core/plugins.py +54 -0
- forgecli/core/service.py +22 -0
- forgecli/docs/__init__.py +5 -0
- forgecli/docs/generator.py +115 -0
- forgecli/engine/__init__.py +105 -0
- forgecli/engine/context.py +163 -0
- forgecli/engine/defaults.py +89 -0
- forgecli/engine/events.py +185 -0
- forgecli/engine/execution.py +519 -0
- forgecli/engine/plugins.py +145 -0
- forgecli/engine/runner.py +158 -0
- forgecli/engine/stages/__init__.py +33 -0
- forgecli/engine/stages/caveman_optimizer.py +47 -0
- forgecli/engine/stages/context_optimizer.py +44 -0
- forgecli/engine/stages/execution_engine_stage.py +102 -0
- forgecli/engine/stages/git_engine.py +30 -0
- forgecli/engine/stages/intent_analyzer.py +38 -0
- forgecli/engine/stages/model_router.py +65 -0
- forgecli/engine/stages/planning_engine.py +45 -0
- forgecli/engine/stages/repository_analyzer.py +54 -0
- forgecli/engine/stages/validation_engine.py +87 -0
- forgecli/git/__init__.py +9 -0
- forgecli/git/repo.py +55 -0
- forgecli/git/service.py +45 -0
- forgecli/graph/__init__.py +56 -0
- forgecli/graph/backend_graphify.py +448 -0
- forgecli/graph/edge.py +19 -0
- forgecli/graph/graph.py +85 -0
- forgecli/graph/graphify.py +405 -0
- forgecli/graph/indexer.py +82 -0
- forgecli/graph/node.py +47 -0
- forgecli/graph/repository.py +164 -0
- forgecli/memory/__init__.py +12 -0
- forgecli/memory/cache.py +61 -0
- forgecli/memory/history.py +136 -0
- forgecli/memory/store.py +85 -0
- forgecli/optimizer/__init__.py +13 -0
- forgecli/optimizer/caveman/__init__.py +169 -0
- forgecli/optimizer/caveman/cli.py +62 -0
- forgecli/optimizer/caveman/decorator.py +67 -0
- forgecli/optimizer/caveman/factory.py +51 -0
- forgecli/optimizer/caveman/ruleset.py +156 -0
- forgecli/optimizer/caveman/state.py +50 -0
- forgecli/optimizer/chunker.py +85 -0
- forgecli/optimizer/optimizer.py +81 -0
- forgecli/optimizer/ponytail/__init__.py +181 -0
- forgecli/optimizer/ponytail/cli.py +159 -0
- forgecli/optimizer/ponytail/decorator.py +70 -0
- forgecli/optimizer/ponytail/factory.py +51 -0
- forgecli/optimizer/ponytail/ruleset.py +168 -0
- forgecli/optimizer/ponytail/state.py +51 -0
- forgecli/optimizer/ranker.py +37 -0
- forgecli/optimizer/summarizer.py +44 -0
- forgecli/orchestrator/__init__.py +706 -0
- forgecli/planner/__init__.py +56 -0
- forgecli/planner/agent.py +61 -0
- forgecli/planner/plan.py +65 -0
- forgecli/planner/planner.py +17 -0
- forgecli/planner/render.py +267 -0
- forgecli/planner/serialize.py +104 -0
- forgecli/planner/software.py +832 -0
- forgecli/platform/__init__.py +91 -0
- forgecli/platform/core.py +201 -0
- forgecli/platform/deps.py +361 -0
- forgecli/platform/paths.py +234 -0
- forgecli/platform/shell.py +176 -0
- forgecli/platform/update.py +253 -0
- forgecli/plugins/__init__.py +249 -0
- forgecli/prompts/__init__.py +11 -0
- forgecli/prompts/loader.py +28 -0
- forgecli/prompts/registry.py +32 -0
- forgecli/prompts/renderer.py +23 -0
- forgecli/providers/__init__.py +39 -0
- forgecli/providers/anthropic.py +207 -0
- forgecli/providers/base.py +204 -0
- forgecli/providers/builtin.py +26 -0
- forgecli/providers/conversation.py +74 -0
- forgecli/providers/google.py +290 -0
- forgecli/providers/http_base.py +207 -0
- forgecli/providers/mock.py +111 -0
- forgecli/providers/openai.py +206 -0
- forgecli/providers/openai_compatible.py +787 -0
- forgecli/providers/router.py +340 -0
- forgecli/providers/router_state.py +89 -0
- forgecli/review/__init__.py +45 -0
- forgecli/review/analyzer.py +124 -0
- forgecli/review/analyzers/__init__.py +1 -0
- forgecli/review/analyzers/architecture.py +255 -0
- forgecli/review/analyzers/complexity.py +162 -0
- forgecli/review/analyzers/dead_code.py +255 -0
- forgecli/review/analyzers/duplicates.py +161 -0
- forgecli/review/analyzers/performance.py +161 -0
- forgecli/review/analyzers/security.py +244 -0
- forgecli/review/finding.py +68 -0
- forgecli/review/report.py +321 -0
- forgecli/review/repository.py +130 -0
- forgecli/review/suggestions.py +98 -0
- forgecli/runtime/__init__.py +6 -0
- forgecli/runtime/cache_store.py +75 -0
- forgecli/runtime/mcp_config.py +118 -0
- forgecli/runtime/prepare.py +203 -0
- forgecli/runtime/wrappers.py +150 -0
- forgecli/sdk/__init__.py +132 -0
- forgecli/sdk/events.py +206 -0
- forgecli/sdk/interfaces.py +282 -0
- forgecli/sdk/loader.py +239 -0
- forgecli/sdk/manager.py +678 -0
- forgecli/sdk/manifest.py +395 -0
- forgecli/sdk/sandbox.py +247 -0
- forgecli/sdk/version.py +313 -0
- forgecli/templates/__init__.py +9 -0
- forgecli/templates/engine.py +37 -0
- forgecli/templates/registry.py +32 -0
- forgecli/utils/__init__.py +19 -0
- forgecli/utils/fs.py +121 -0
- forgecli/utils/ids.py +11 -0
- forgecli/utils/io.py +27 -0
- forgecli/utils/paths.py +78 -0
- forgecli/utils/stats.py +179 -0
- forgecli/utils/timing.py +25 -0
- forgeoptimizer-0.1.0.dist-info/METADATA +177 -0
- forgeoptimizer-0.1.0.dist-info/RECORD +159 -0
- forgeoptimizer-0.1.0.dist-info/WHEEL +4 -0
- forgeoptimizer-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|